diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e105c71..f11a1c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,4 +22,4 @@ jobs: run: go test ./... - name: Build CLI - run: go build ./cmd/mcstatus + run: go build ./cmd/gamestatus diff --git a/.gitignore b/.gitignore index 78b7275..fd0e2f9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ -/mcstatus -/mcstatus.exe +/gamestatus +/gamestatus.exe diff --git a/.goreleaser.yml b/.goreleaser.yml index 07f6627..728ae4c 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -1,11 +1,11 @@ version: 2 -project_name: mcstatus +project_name: gamestatus builds: - - id: mcstatus - main: ./cmd/mcstatus - binary: mcstatus + - id: gamestatus + main: ./cmd/gamestatus + binary: gamestatus env: - CGO_ENABLED=0 goos: diff --git a/README.md b/README.md index eeea058..3a628f5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,17 @@ -# mcstatus +# gamestatus -Small Go library for querying Minecraft Java and Bedrock server status. +Small Go library for querying game server status. + +Current support: + +- Minecraft Java +- Minecraft Bedrock +- Source / A2S servers +- Rust servers +- Counter-Strike 2 servers +- Valve/Source servers + +The project is structured so other game protocols can be added later. Rust currently uses the same A2S-based transport layer as Source-style servers. ## Features @@ -8,33 +19,42 @@ Small Go library for querying Minecraft Java and Bedrock server status. - Java full query - Bedrock RakNet ping - Bedrock full query +- Source A2S info +- Source A2S players and rules via full query +- Rust query via the same Source/A2S layer +- CS2 query via the same Source/A2S layer +- Valve query via the same Source/A2S layer - Optional SRV lookup - Simple MOTD cleanup helper ## Install ```bash -go get github.com/VexoraDevelopment/mcstatus +go get github.com/VexoraDevelopment/gamestatus ``` ## CLI -The repository also ships a small CLI in `cmd/mcstatus`. +The repository also ships a small CLI in `cmd/gamestatus`. Build locally: ```bash -go build -o mcstatus ./cmd/mcstatus +go build -o gamestatus ./cmd/gamestatus ``` Examples: ```bash -mcstatus mc.hypixel.net -mcstatus java -json mc.hypixel.net -mcstatus bedrock -query play.example.com:19132 -mcstatus auto -pretty localhost -mcstatus java -motd-raw mc.hypixel.net +gamestatus mc.hypixel.net +gamestatus java -json mc.hypixel.net +gamestatus bedrock -query play.example.com:19132 +gamestatus source 127.0.0.1:27015 +gamestatus rust 127.0.0.1:28015 +gamestatus cs2 127.0.0.1:27015 +gamestatus valve 127.0.0.1:27015 +gamestatus auto -pretty localhost +gamestatus java -motd-raw mc.hypixel.net ``` If you prefer prebuilt binaries, use the files attached to GitHub Releases. @@ -48,11 +68,11 @@ import ( "fmt" "time" - "github.com/VexoraDevelopment/mcstatus" + "github.com/VexoraDevelopment/gamestatus" ) func main() { - client := mcstatus.NewAll(3 * time.Second) + client := gamestatus.NewAll(3 * time.Second) info, err := client.Java.Connect("hypixel.net", 25565) if err != nil { @@ -69,10 +89,11 @@ func main() { ## Packages -- `github.com/VexoraDevelopment/mcstatus` -- `github.com/VexoraDevelopment/mcstatus/java` -- `github.com/VexoraDevelopment/mcstatus/bedrock` -- `github.com/VexoraDevelopment/mcstatus/text` +- `github.com/VexoraDevelopment/gamestatus` +- `github.com/VexoraDevelopment/gamestatus/java` +- `github.com/VexoraDevelopment/gamestatus/bedrock` +- `github.com/VexoraDevelopment/gamestatus/source` +- `github.com/VexoraDevelopment/gamestatus/text` ## Releases @@ -82,7 +103,7 @@ Pushing a tag like `v1.0.1` triggers GitHub Actions and publishes release archiv - Linux - macOS -using the `mcstatus` CLI binary from `cmd/mcstatus`. +using the `gamestatus` CLI binary from `cmd/gamestatus`. ## Notes diff --git a/bedrock/bedrock.go b/bedrock/bedrock.go index 4015cd1..c5631d9 100644 --- a/bedrock/bedrock.go +++ b/bedrock/bedrock.go @@ -12,7 +12,7 @@ import ( "strings" "time" - "github.com/VexoraDevelopment/mcstatus/info" + "github.com/VexoraDevelopment/gamestatus/info" ) type Options struct { diff --git a/client.go b/client.go index 97f6719..1e91e05 100644 --- a/client.go +++ b/client.go @@ -1,20 +1,23 @@ -package mcstatus +package gamestatus import ( "time" - "github.com/VexoraDevelopment/mcstatus/bedrock" - "github.com/VexoraDevelopment/mcstatus/java" + "github.com/VexoraDevelopment/gamestatus/bedrock" + "github.com/VexoraDevelopment/gamestatus/java" + "github.com/VexoraDevelopment/gamestatus/source" ) type Client struct { Java *java.Client Bedrock *bedrock.Client + Source *source.Client } func NewAll(timeout time.Duration) *Client { return &Client{ Java: java.New(java.Options{Timeout: timeout, EnableSRV: true}), Bedrock: bedrock.New(bedrock.Options{Timeout: timeout, EnableSRV: true}), + Source: source.New(source.Options{Timeout: timeout}), } } diff --git a/cmd/mcstatus/main.go b/cmd/gamestatus/main.go similarity index 67% rename from cmd/mcstatus/main.go rename to cmd/gamestatus/main.go index 3c316d6..cfdb478 100644 --- a/cmd/mcstatus/main.go +++ b/cmd/gamestatus/main.go @@ -9,8 +9,8 @@ import ( "strings" "time" - "github.com/VexoraDevelopment/mcstatus" - "github.com/VexoraDevelopment/mcstatus/text" + "github.com/VexoraDevelopment/gamestatus" + "github.com/VexoraDevelopment/gamestatus/text" ) func main() { @@ -28,7 +28,7 @@ func main() { fatal(err) } - client := mcstatus.NewAll(cfg.timeout) + client := gamestatus.NewAll(cfg.timeout) info, err := connect(client, cfg.edition, host, port, cfg.useQuery) if err != nil { @@ -48,12 +48,12 @@ func main() { } type cliConfig struct { - edition string - timeout time.Duration - asJSON bool + edition string + timeout time.Duration + asJSON bool useQuery bool - pretty bool - motdRaw bool + pretty bool + motdRaw bool } func parseCLI(args []string) (cliConfig, string, error) { @@ -62,9 +62,9 @@ func parseCLI(args []string) (cliConfig, string, error) { timeout: 3 * time.Second, } - fs := flag.NewFlagSet("mcstatus", flag.ContinueOnError) + fs := flag.NewFlagSet("gamestatus", flag.ContinueOnError) fs.SetOutput(os.Stderr) - edition := fs.String("edition", "auto", "server edition: auto, java, bedrock") + edition := fs.String("edition", "auto", "server edition: auto, java, bedrock, source, rust, cs2, valve") timeout := fs.Duration("timeout", 3*time.Second, "request timeout") asJSON := fs.Bool("json", false, "print JSON output") useQuery := fs.Bool("query", false, "use full query when supported") @@ -73,7 +73,7 @@ func parseCLI(args []string) (cliConfig, string, error) { if len(args) > 0 { switch strings.ToLower(strings.TrimSpace(args[0])) { - case "auto", "java", "bedrock": + case "auto", "java", "bedrock", "source", "rust", "cs2", "valve": cfg.edition = strings.ToLower(strings.TrimSpace(args[0])) args = args[1:] } @@ -87,7 +87,7 @@ func parseCLI(args []string) (cliConfig, string, error) { if cfg.edition == "auto" && len(args) > 0 { // Preserve subcommand semantics while still allowing -edition overrides. switch strings.ToLower(strings.TrimSpace(os.Args[1])) { - case "auto", "java", "bedrock": + case "auto", "java", "bedrock", "source", "rust", "cs2", "valve": cfg.edition = strings.ToLower(strings.TrimSpace(os.Args[1])) } } @@ -104,14 +104,18 @@ func parseCLI(args []string) (cliConfig, string, error) { } func printUsage() { - fmt.Fprintf(os.Stderr, "Usage: %s [auto|java|bedrock] [flags] \n\n", os.Args[0]) + fmt.Fprintf(os.Stderr, "Usage: %s [auto|java|bedrock|source|rust|cs2|valve] [flags] \n\n", os.Args[0]) fmt.Fprintln(os.Stderr, "Examples:") fmt.Fprintf(os.Stderr, " %s mc.hypixel.net\n", os.Args[0]) fmt.Fprintf(os.Stderr, " %s java mc.hypixel.net\n", os.Args[0]) fmt.Fprintf(os.Stderr, " %s bedrock -query play.example.com:19132\n\n", os.Args[0]) + fmt.Fprintf(os.Stderr, " %s source 127.0.0.1:27015\n\n", os.Args[0]) + fmt.Fprintf(os.Stderr, " %s rust 127.0.0.1:28015\n\n", os.Args[0]) + fmt.Fprintf(os.Stderr, " %s cs2 127.0.0.1:27015\n\n", os.Args[0]) + fmt.Fprintf(os.Stderr, " %s valve 127.0.0.1:27015\n\n", os.Args[0]) fmt.Fprintln(os.Stderr, "Flags:") fmt.Fprintln(os.Stderr, " -edition string") - fmt.Fprintln(os.Stderr, " server edition: auto, java, bedrock") + fmt.Fprintln(os.Stderr, " server edition: auto, java, bedrock, source, rust, cs2, valve") fmt.Fprintln(os.Stderr, " -timeout duration") fmt.Fprintln(os.Stderr, " request timeout (default 3s)") fmt.Fprintln(os.Stderr, " -json") @@ -124,36 +128,75 @@ func printUsage() { fmt.Fprintln(os.Stderr, " print raw MOTD without cleanup") } -func connect(client *mcstatus.Client, edition, host string, port int, useQuery bool) (*mcstatus.ServerInfo, error) { +func connect(client *gamestatus.Client, edition, host string, port int, useQuery bool) (*gamestatus.ServerInfo, error) { switch edition { case "auto": if info, err := connectJava(client, host, port, useQuery); err == nil { return info, nil } - return connectBedrock(client, host, port, useQuery) + if info, err := connectBedrock(client, host, port, useQuery); err == nil { + return info, nil + } + return connectSource(client, host, port, useQuery) case "java": return connectJava(client, host, port, useQuery) case "bedrock": return connectBedrock(client, host, port, useQuery) + case "source": + return connectSource(client, host, port, useQuery) + case "rust": + return connectRust(client, host, port, useQuery) + case "cs2": + return connectCS2(client, host, port, useQuery) + case "valve": + return connectValve(client, host, port, useQuery) default: return nil, fmt.Errorf("unsupported edition %q", edition) } } -func connectJava(client *mcstatus.Client, host string, port int, useQuery bool) (*mcstatus.ServerInfo, error) { +func connectJava(client *gamestatus.Client, host string, port int, useQuery bool) (*gamestatus.ServerInfo, error) { if useQuery { return client.Java.QueryFull(host, port) } return client.Java.Connect(host, port) } -func connectBedrock(client *mcstatus.Client, host string, port int, useQuery bool) (*mcstatus.ServerInfo, error) { +func connectBedrock(client *gamestatus.Client, host string, port int, useQuery bool) (*gamestatus.ServerInfo, error) { if useQuery { return client.Bedrock.QueryFull(host, port) } return client.Bedrock.Connect(host, port) } +func connectSource(client *gamestatus.Client, host string, port int, useQuery bool) (*gamestatus.ServerInfo, error) { + if useQuery { + return client.Source.QueryFull(host, port) + } + return client.Source.Connect(host, port) +} + +func connectRust(client *gamestatus.Client, host string, port int, useQuery bool) (*gamestatus.ServerInfo, error) { + if useQuery { + return client.Source.QueryFullRust(host, port) + } + return client.Source.ConnectRust(host, port) +} + +func connectCS2(client *gamestatus.Client, host string, port int, useQuery bool) (*gamestatus.ServerInfo, error) { + if useQuery { + return client.Source.QueryFullCS2(host, port) + } + return client.Source.ConnectCS2(host, port) +} + +func connectValve(client *gamestatus.Client, host string, port int, useQuery bool) (*gamestatus.ServerInfo, error) { + if useQuery { + return client.Source.QueryFullValve(host, port) + } + return client.Source.ConnectValve(host, port) +} + func parseTarget(target string) (string, int, error) { host := strings.TrimSpace(target) if host == "" { @@ -191,7 +234,7 @@ func parseTarget(target string) (string, int, error) { return host, 0, nil } -func printText(info *mcstatus.ServerInfo, pretty bool, motdRaw bool) { +func printText(info *gamestatus.ServerInfo, pretty bool, motdRaw bool) { motd := info.MOTD if !motdRaw { motd = text.CleanMOTD(motd) @@ -234,7 +277,7 @@ func printText(info *mcstatus.ServerInfo, pretty bool, motdRaw bool) { fmt.Printf("Query: %t\n", info.Query) } -func printPretty(info *mcstatus.ServerInfo, motd string) { +func printPretty(info *gamestatus.ServerInfo, motd string) { fmt.Printf("%s %s:%d\n", strings.ToUpper(string(info.Edition)), info.Address, info.Port) if motd != "" { fmt.Printf("%s\n", motd) @@ -253,6 +296,6 @@ func printPretty(info *mcstatus.ServerInfo, motd string) { } func fatal(err error) { - fmt.Fprintln(os.Stderr, "mcstatus:", err) + fmt.Fprintln(os.Stderr, "gamestatus:", err) os.Exit(1) } diff --git a/doc.go b/doc.go index 56a6faf..002f2f6 100644 --- a/doc.go +++ b/doc.go @@ -1 +1 @@ -package mcstatus +package gamestatus diff --git a/go.mod b/go.mod index ebb5e6c..d9b4f37 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ -module github.com/VexoraDevelopment/mcstatus +module github.com/VexoraDevelopment/gamestatus go 1.25 + +require github.com/rumblefrog/go-a2s v1.0.3 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c4fe106 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/rumblefrog/go-a2s v1.0.3 h1:Y1r8oX5IOL8b3KHhN9RCY+2bdmpio52Acd1KsYg4efI= +github.com/rumblefrog/go-a2s v1.0.3/go.mod h1:6nq//LMUMa3ElowQ7eH8atnDbQG+nVMFsaMFzSo8p/M= diff --git a/info/types.go b/info/types.go index 458b7f4..b792af6 100644 --- a/info/types.go +++ b/info/types.go @@ -10,6 +10,10 @@ type Edition string const ( EditionJava Edition = "java" EditionBedrock Edition = "bedrock" + EditionSource Edition = "source" + EditionRust Edition = "rust" + EditionCS2 Edition = "cs2" + EditionValve Edition = "valve" ) type ServerInfo struct { diff --git a/java/java.go b/java/java.go index 49c03d3..200b20d 100644 --- a/java/java.go +++ b/java/java.go @@ -14,7 +14,7 @@ import ( "strings" "time" - "github.com/VexoraDevelopment/mcstatus/info" + "github.com/VexoraDevelopment/gamestatus/info" ) type Options struct { diff --git a/java/query.go b/java/query.go index faec61f..81be6dd 100644 --- a/java/query.go +++ b/java/query.go @@ -10,7 +10,7 @@ import ( "strings" "time" - "github.com/VexoraDevelopment/mcstatus/info" + "github.com/VexoraDevelopment/gamestatus/info" ) func (c *Client) QueryFull(address string, port int) (*info.ServerInfo, error) { diff --git a/source/source.go b/source/source.go new file mode 100644 index 0000000..aa84637 --- /dev/null +++ b/source/source.go @@ -0,0 +1,189 @@ +package source + +import ( + "encoding/json" + "net" + "strconv" + "strings" + "time" + + a2s "github.com/rumblefrog/go-a2s" + + "github.com/VexoraDevelopment/gamestatus/info" +) + +type Options struct { + Timeout time.Duration +} + +type Client struct { + opt Options +} + +func New(opt Options) *Client { + if opt.Timeout <= 0 { + opt.Timeout = 2 * time.Second + } + return &Client{opt: opt} +} + +func (c *Client) Connect(address string, port int) (*info.ServerInfo, error) { + return c.query(address, port, false) +} + +func (c *Client) QueryFull(address string, port int) (*info.ServerInfo, error) { + return c.query(address, port, true) +} + +func (c *Client) ConnectRust(address string, port int) (*info.ServerInfo, error) { + out, err := c.query(address, port, false) + if err != nil { + return nil, err + } + out.Edition = info.EditionRust + return out, nil +} + +func (c *Client) QueryFullRust(address string, port int) (*info.ServerInfo, error) { + out, err := c.query(address, port, true) + if err != nil { + return nil, err + } + out.Edition = info.EditionRust + return out, nil +} + +func (c *Client) ConnectCS2(address string, port int) (*info.ServerInfo, error) { + out, err := c.query(address, port, false) + if err != nil { + return nil, err + } + out.Edition = info.EditionCS2 + return out, nil +} + +func (c *Client) QueryFullCS2(address string, port int) (*info.ServerInfo, error) { + out, err := c.query(address, port, true) + if err != nil { + return nil, err + } + out.Edition = info.EditionCS2 + return out, nil +} + +func (c *Client) ConnectValve(address string, port int) (*info.ServerInfo, error) { + out, err := c.query(address, port, false) + if err != nil { + return nil, err + } + out.Edition = info.EditionValve + return out, nil +} + +func (c *Client) QueryFullValve(address string, port int) (*info.ServerInfo, error) { + out, err := c.query(address, port, true) + if err != nil { + return nil, err + } + out.Edition = info.EditionValve + return out, nil +} + +func (c *Client) query(address string, port int, full bool) (*info.ServerInfo, error) { + addr := normalizeAddress(address, port) + client, err := a2s.NewClient(addr, a2s.TimeoutOption(c.opt.Timeout)) + if err != nil { + return nil, err + } + defer client.Close() + + start := time.Now() + infoResp, err := client.QueryInfo() + if err != nil { + return nil, err + } + + out := &info.ServerInfo{ + Edition: info.EditionSource, + Address: address, + IP: resolveIP(address), + Port: effectivePort(address, port), + MOTD: infoResp.Name, + Version: infoResp.Version, + Online: int(infoResp.Players), + Slots: int(infoResp.MaxPlayers), + Software: infoResp.Game, + Game: infoResp.Folder, + Map: infoResp.Map, + Query: full, + Latency: time.Since(start), + } + + if full { + playerResp, err := client.QueryPlayer() + if err == nil && playerResp != nil { + names := make([]string, 0, len(playerResp.Players)) + for _, p := range playerResp.Players { + if p == nil { + continue + } + name := strings.TrimSpace(p.Name) + if name != "" { + names = append(names, name) + } + } + out.Players = names + } + rulesResp, err := client.QueryRules() + if err == nil && rulesResp != nil { + out.RawQuery = rulesResp.Rules + } + } + + raw, _ := json.Marshal(infoResp) + out.Raw = raw + return out, nil +} + +func normalizeAddress(host string, port int) string { + if port == 0 { + port = a2s.DefaultPort + } + if strings.Contains(host, ":") { + if _, _, err := net.SplitHostPort(host); err == nil { + return host + } + if strings.Count(host, ":") > 1 && !strings.HasPrefix(host, "[") { + return net.JoinHostPort(host, strconv.Itoa(port)) + } + } + return net.JoinHostPort(host, strconv.Itoa(port)) +} + +func effectivePort(host string, port int) int { + if port != 0 { + return port + } + if _, p, err := net.SplitHostPort(host); err == nil { + if v, convErr := strconv.Atoi(p); convErr == nil { + return v + } + } + return a2s.DefaultPort +} + +func resolveIP(host string) string { + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + host = strings.TrimPrefix(host, "[") + host = strings.TrimSuffix(host, "]") + if ip := net.ParseIP(host); ip != nil { + return ip.String() + } + ips, err := net.LookupIP(host) + if err != nil || len(ips) == 0 { + return "" + } + return ips[0].String() +} diff --git a/types.go b/types.go index 1f63afa..663d031 100644 --- a/types.go +++ b/types.go @@ -1,6 +1,6 @@ -package mcstatus +package gamestatus -import "github.com/VexoraDevelopment/mcstatus/info" +import "github.com/VexoraDevelopment/gamestatus/info" type Edition = info.Edition