// mc-status — a tiny self-hosted Minecraft Server List Ping → JSON service. // // It wraps github.com/mcstatus-io/mcutil (the same library that powers // api.mcstatus.io) and re-emits the result in mcstatus.io's *v2* JSON shape, so // the landing's ServerCard.astro can talk to it with zero changes — just point // `statusApi` at this service instead of the public API. // // Hardening notes: // - The address in the request path is IGNORED. We only ever ping the single // allow-listed MC_STATUS_TARGET. This is deliberate: a path-controlled // pinger would be an open SSRF relay. The path segment is kept only so the // URL stays drop-in compatible with api.mcstatus.io/v2/status/java/. // - Results are cached in-memory for MC_STATUS_CACHE_TTL so a busy landing // page can't hammer the Minecraft server with a ping per visitor. package main import ( "context" "encoding/json" "log" "net/http" "os" "strconv" "strings" "sync" "time" "github.com/mcstatus-io/mcutil/v4/status" ) // --- mcstatus.io v2 response shape (only the fields ServerCard reads) --- type v2Version struct { NameRaw string `json:"name_raw"` NameClean string `json:"name_clean"` NameHTML string `json:"name_html"` Protocol int64 `json:"protocol"` } type v2Player struct { UUID string `json:"uuid"` NameRaw string `json:"name_raw"` NameClean string `json:"name_clean"` NameHTML string `json:"name_html"` } type v2Players struct { Online int64 `json:"online"` Max int64 `json:"max"` List []v2Player `json:"list"` } type v2MOTD struct { Raw string `json:"raw"` Clean string `json:"clean"` HTML string `json:"html"` } type v2Response struct { Online bool `json:"online"` Host string `json:"host"` Port uint16 `json:"port"` Version *v2Version `json:"version,omitempty"` Players *v2Players `json:"players,omitempty"` MOTD *v2MOTD `json:"motd,omitempty"` Icon *string `json:"icon"` } // --- tiny TTL cache (single target → single entry) --- type cache struct { mu sync.Mutex payload []byte expires time.Time } func deref[T any](p *T) (v T) { if p != nil { v = *p } return } func main() { target := envOr("MC_STATUS_TARGET", "minecraft:25565") listen := envOr("MC_STATUS_LISTEN", ":8080") ttl, err := time.ParseDuration(envOr("MC_STATUS_CACHE_TTL", "30s")) if err != nil { log.Fatalf("invalid MC_STATUS_CACHE_TTL: %v", err) } host, port := splitHostPort(target) c := &cache{} mux := http.NewServeMux() // Drop-in path: /v2/status/java/ is ignored (see file header). mux.HandleFunc("/v2/status/java/", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") c.mu.Lock() fresh := c.payload != nil && time.Now().Before(c.expires) if fresh { payload := c.payload c.mu.Unlock() w.Write(payload) return } c.mu.Unlock() payload := buildPayload(host, port) c.mu.Lock() c.payload = payload c.expires = time.Now().Add(ttl) c.mu.Unlock() w.Write(payload) }) mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok")) }) log.Printf("mc-status listening on %s, target=%s:%d, ttl=%s", listen, host, port, ttl) log.Fatal(http.ListenAndServe(listen, mux)) } // buildPayload pings the target and marshals the v2 response. On any ping // failure it returns a minimal {"online":false} so the frontend can show an // offline state without erroring. func buildPayload(host string, port uint16) []byte { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() resp, err := status.Modern(ctx, host, port) if err != nil { b, _ := json.Marshal(v2Response{Online: false, Host: host, Port: port}) return b } out := v2Response{ Online: true, Host: host, Port: port, Icon: resp.Favicon, Version: &v2Version{ NameRaw: resp.Version.Name.Raw, NameClean: resp.Version.Name.Clean, NameHTML: resp.Version.Name.HTML, Protocol: resp.Version.Protocol, }, MOTD: &v2MOTD{ Raw: resp.MOTD.Raw, Clean: resp.MOTD.Clean, HTML: resp.MOTD.HTML, }, Players: &v2Players{ Online: deref(resp.Players.Online), Max: deref(resp.Players.Max), List: make([]v2Player, 0, len(resp.Players.Sample)), }, } for _, p := range resp.Players.Sample { out.Players.List = append(out.Players.List, v2Player{ UUID: p.ID, NameRaw: p.Name.Raw, NameClean: p.Name.Clean, NameHTML: p.Name.HTML, }) } b, _ := json.Marshal(out) return b } func envOr(key, def string) string { if v := os.Getenv(key); v != "" { return v } return def } func splitHostPort(target string) (string, uint16) { host, portStr, found := strings.Cut(target, ":") if !found { return target, 25565 } p, err := strconv.ParseUint(portStr, 10, 16) if err != nil { return host, 25565 } return host, uint16(p) }