// 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 ( "bytes" "context" "encoding/json" "io" "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 } // --- registered-players roster (Drasl admin) --- // // The roster path is DELIBERATELY isolated from the status path: its own // http.Client (with a timeout), its own TTL cache, and its own cached admin // token. Drasl being slow or down must never block or break /v2/status — on any // failure we serve stale-or-empty JSON rather than hanging. Mirrors the status // cache style above (single entry, mutex-guarded). // rosterPlayer is the sanitized shape we expose publicly: uuid + name only, // everything else from Drasl stripped (no emails, capes, admin flags, etc.). type rosterPlayer struct { UUID string `json:"uuid"` Name string `json:"name"` } // draslPlayer is the subset of Drasl's GET /players entries we read. type draslPlayer struct { UUID string `json:"uuid"` Name string `json:"name"` } // roster holds the cached public payload plus the cached admin token. Both are // guarded by the same mutex; token reuse avoids a login per request. type roster struct { mu sync.Mutex payload []byte expires time.Time token string apiURL string username string password string ttl time.Duration client *http.Client } // payloadOrEmpty returns the cached payload, or an empty JSON array if nothing // has ever been fetched. Used so a Drasl failure on the very first request // still yields valid JSON ("[]") instead of an error. func (rs *roster) payloadOrEmpty() []byte { if rs.payload != nil { return rs.payload } return []byte("[]") } // fetch logs in (if no token) and lists players, re-logging in once on a 401. // On any error it returns the previous payload (stale-or-empty), never an error // to the caller — the handler always serves something. func (rs *roster) fetch() []byte { players, err := rs.listPlayers(false) if err != nil { log.Printf("roster: list players failed: %v", err) return rs.payloadOrEmpty() } out := make([]rosterPlayer, 0, len(players)) for _, p := range players { out = append(out, rosterPlayer{UUID: p.UUID, Name: p.Name}) } b, err := json.Marshal(out) if err != nil { return rs.payloadOrEmpty() } return b } // listPlayers performs GET {apiURL}/players with the cached token, logging in // first if needed. On 401 it clears the token and retries once (retried=true // prevents a loop). Caller holds rs.mu. func (rs *roster) listPlayers(retried bool) ([]draslPlayer, error) { if rs.token == "" { if err := rs.login(); err != nil { return nil, err } } req, err := http.NewRequest(http.MethodGet, rs.apiURL+"/players", nil) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+rs.token) req.Header.Set("Accept", "application/json") resp, err := rs.client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode == http.StatusUnauthorized && !retried { // Token expired/invalid — drop it and re-login once. rs.token = "" io.Copy(io.Discard, resp.Body) return rs.listPlayers(true) } if resp.StatusCode != http.StatusOK { io.Copy(io.Discard, resp.Body) return nil, &httpError{status: resp.StatusCode, op: "list players"} } var players []draslPlayer if err := json.NewDecoder(resp.Body).Decode(&players); err != nil { return nil, err } return players, nil } // login posts admin creds to {apiURL}/login and caches the returned apiToken. // Caller holds rs.mu. func (rs *roster) login() error { body, err := json.Marshal(map[string]string{ "username": rs.username, "password": rs.password, }) if err != nil { return err } req, err := http.NewRequest(http.MethodPost, rs.apiURL+"/login", bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") resp, err := rs.client.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { io.Copy(io.Discard, resp.Body) return &httpError{status: resp.StatusCode, op: "login"} } var out struct { APIToken string `json:"apiToken"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return err } if out.APIToken == "" { return &httpError{status: resp.StatusCode, op: "login (no apiToken)"} } rs.token = out.APIToken return nil } type httpError struct { status int op string } func (e *httpError) Error() string { return "drasl " + e.op + ": status " + strconv.Itoa(e.status) } 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) } rosterTTL, err := time.ParseDuration(envOr("MC_STATUS_ROSTER_TTL", "60s")) if err != nil { log.Fatalf("invalid MC_STATUS_ROSTER_TTL: %v", err) } host, port := splitHostPort(target) c := &cache{} // Registered-players roster, fed by the Drasl admin API. Isolated from the // status pinger above (own client + timeout + cache). If the admin creds are // unset, /players still works — it just always serves "[]". rs := &roster{ apiURL: strings.TrimRight(envOr("DRASL_API_URL", "http://drasl:25585/drasl/api/v2"), "/"), username: os.Getenv("DRASL_ADMIN_USERNAME"), password: os.Getenv("DRASL_ADMIN_PASSWORD"), ttl: rosterTTL, client: &http.Client{Timeout: 5 * time.Second}, } 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) }) // Registered-players roster — public path /api/mcstatus/players (caddy // strips the /api/mcstatus prefix → internal /players). Serves a sanitized // [{uuid,name}] of all Drasl players. Same CORS:* as the status route. // Isolated from the status path: own TTL cache, own token, own client; on // any Drasl failure it serves stale-or-empty rather than hanging. mux.HandleFunc("/players", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") rs.mu.Lock() defer rs.mu.Unlock() if rs.payload != nil && time.Now().Before(rs.expires) { w.Write(rs.payload) return } payload := rs.fetch() rs.payload = payload rs.expires = time.Now().Add(rs.ttl) 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, roster-ttl=%s", listen, host, port, ttl, rosterTTL) 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) }