Add a featured live ServerCard to the landing (replaces the static ServerListPanel in hero + status sections): server favicon, color MOTD, online/offline pill, players online/max with fill bar, and a player-head row rendered by our own NMSR from Drasl skins. Progressive enhancement — SSG skeleton degrades gracefully when JS or the API is unavailable. Back it with a self-hosted pinger instead of the public api.mcstatus.io: mcstatus.io's API service is closed-source (only the mcutil library is open), so docker/mc-status wraps mcutil and re-emits its v2 JSON shape, keeping the frontend unchanged. The service ignores the path address and only pings MC_STATUS_TARGET (no SSRF relay), with a 30s TTL cache. Exposed same-origin via caddy at the apex /api/mcstatus/* path (no new DNS subdomain or LE cert change, no CORS). Uptime Kuma stays the uptime history + alerting backend; see plan/15-mc-status.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
197 lines
4.9 KiB
Go
197 lines
4.9 KiB
Go
// 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/<addr>.
|
|
// - 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/<addr> — <addr> 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)
|
|
}
|