diff --git a/caddy/conf.d/10-static.caddy b/caddy/conf.d/10-static.caddy index b4e7e73..0eb18c2 100644 --- a/caddy/conf.d/10-static.caddy +++ b/caddy/conf.d/10-static.caddy @@ -6,6 +6,13 @@ http://{$BASE_DOMAIN} { root * /srv/launcher file_server browse } + # Self-hosted Minecraft status JSON (mc-status service). Same-origin, so the + # landing's ServerCard fetch needs no CORS. The in the path is + # ignored by mc-status — it only ever pings its configured MC_STATUS_TARGET. + handle /api/mcstatus/* { + uri strip_prefix /api/mcstatus + reverse_proxy mc-status:8080 + } handle { root * /srv/www file_server diff --git a/docker-compose.yml b/docker-compose.yml index 8e5ac9f..7dc9592 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -164,6 +164,22 @@ services: networks: - mcnet + # Self-hosted SLP→JSON pinger (built from docker/mc-status, mcutil wrapper). + # Emits mcstatus.io v2 JSON so the landing's ServerCard reads it unchanged. + # No published port — caddy proxies apex /api/mcstatus/* -> mc-status:8080. + # Pings the minecraft service internally over mcnet; result cached 30s. + mc-status: + build: + context: ./docker/mc-status + image: ulicraft/mc-status:local + container_name: mc-status + restart: unless-stopped + environment: + MC_STATUS_TARGET: "minecraft:25565" + MC_STATUS_CACHE_TTL: "30s" + networks: + - mcnet + # Status monitoring + public status page. caddy proxies status. -> :3001. # Joins mcnet so monitors probe the stack by internal service name. uptime-kuma: diff --git a/docker/mc-status/Dockerfile b/docker/mc-status/Dockerfile new file mode 100644 index 0000000..4ca4b0b --- /dev/null +++ b/docker/mc-status/Dockerfile @@ -0,0 +1,15 @@ +# mc-status — self-hosted SLP→JSON pinger (mcutil wrapper). Multi-stage: +# build a static binary, ship it on scratch. +FROM golang:1.25-alpine AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/mc-status . + +FROM scratch +# CA roots so SRV/DNS over the resolver and any TLS lookups work. +COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ +COPY --from=build /out/mc-status /mc-status +EXPOSE 8080 +ENTRYPOINT ["/mc-status"] diff --git a/docker/mc-status/go.mod b/docker/mc-status/go.mod new file mode 100644 index 0000000..3ae4be4 --- /dev/null +++ b/docker/mc-status/go.mod @@ -0,0 +1,5 @@ +module ulicraft/mc-status + +go 1.25.0 + +require github.com/mcstatus-io/mcutil/v4 v4.0.1 diff --git a/docker/mc-status/go.sum b/docker/mc-status/go.sum new file mode 100644 index 0000000..a0a3220 --- /dev/null +++ b/docker/mc-status/go.sum @@ -0,0 +1,2 @@ +github.com/mcstatus-io/mcutil/v4 v4.0.1 h1:/AQkHrz7irCU7USGnrH3kneQw80aDQOVdOWc8xu/NUY= +github.com/mcstatus-io/mcutil/v4 v4.0.1/go.mod h1:yC91WInI1U2GAMFWgpPgsAULPVS2o+4JCZbiiWhHwxM= diff --git a/docker/mc-status/main.go b/docker/mc-status/main.go new file mode 100644 index 0000000..7316639 --- /dev/null +++ b/docker/mc-status/main.go @@ -0,0 +1,196 @@ +// 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) +} diff --git a/landing/src/components/ServerCard.astro b/landing/src/components/ServerCard.astro new file mode 100644 index 0000000..3e51745 --- /dev/null +++ b/landing/src/components/ServerCard.astro @@ -0,0 +1,232 @@ +--- +// Featured LIVE server card. Progressive enhancement: +// SSG → renders a static skeleton (server icon, MOTD, slot cap) identical in +// spirit to the old ServerListPanel, so it looks right with JS off or +// if the status API is unreachable. +// Client → fetches mcstatus.io (CORS:*, no key, 60s cache) for the public +// address and fills in: live online/offline pill, players online/max + +// fill bar, the real server favicon, color MOTD, and a row of player +// HEADS rendered by OUR NMSR from Drasl skins (not Mojang/Crafatar). +// +// Live labels are passed as data-* so copy stays translatable from the page +// (defaults are English). Heads use /headiso. Promo/placeholder players with +// the all-zero UUID are filtered out. +import Creeper from "./Creeper.astro"; +import { site } from "../data/site"; + +interface Props { + // SSG fallback copy (same props the old ServerListPanel took). + modded: string; + motd: string; + upTo: string; + // Live labels (optional, English defaults). Pass t.* from the page to i18n. + onlineLabel?: string; + offlineLabel?: string; + emptyLabel?: string; +} +const { + modded, + motd, + upTo, + onlineLabel = "Online", + offlineLabel = "Offline", + emptyLabel = "Nobody online — be the first.", +} = Astro.props; + +const { status, avatarUrl } = site; +const HEAD_MODE = "headiso"; // NMSR render mode for the head row +--- +
+
+
+ + + +
+ +
+
+ {site.name} + Java {site.mcVersion} +
+

⛏ {modded} · {motd}

+
+ +
+ + + · + +
+ {upTo} {status.slots} +
+
+
+ + + + + + +
+ + + + diff --git a/landing/src/data/site.ts b/landing/src/data/site.ts index faa2ac9..cb8af1f 100644 --- a/landing/src/data/site.ts +++ b/landing/src/data/site.ts @@ -51,12 +51,14 @@ export const site = { avatarUrl: `https://avatar.${BASE_DOMAIN}`, // Live server card (ServerCard.astro). `slots` is the SSG fallback cap shown - // before/without JS; live count comes from mcstatus.io pinging the public - // address. mcstatus.io sets Access-Control-Allow-Origin:* → direct browser - // fetch, no proxy/key. Uptime Kuma stays the history + alerting backend. + // before/without JS; live data comes from our SELF-HOSTED mc-status service + // (docker/mc-status, mcutil wrapper) reverse-proxied same-origin at the apex + // /api/mcstatus/* path — no CORS, no third-party calls. It emits mcstatus.io + // v2 JSON, so the URL stays drop-in compatible. Uptime Kuma stays the history + // + alerting backend (plan/10). status: { slots: 10, - statusApi: `https://api.mcstatus.io/v2/status/java/${BASE_DOMAIN}`, + statusApi: `/api/mcstatus/v2/status/java/${BASE_DOMAIN}`, }, // Honest stat tiles. Keys (numbers/versions) are language-neutral; labels diff --git a/landing/src/pages/[...lang].astro b/landing/src/pages/[...lang].astro index 9c8c197..beeb4c9 100644 --- a/landing/src/pages/[...lang].astro +++ b/landing/src/pages/[...lang].astro @@ -4,7 +4,7 @@ import { ui, LANGS, LANG_LABEL, LANG_PATH, type Lang } from "../i18n/ui"; import Creeper from "../components/Creeper.astro"; import PixelIcon from "../components/PixelIcon.astro"; import CopyChip from "../components/CopyChip.astro"; -import ServerListPanel from "../components/ServerListPanel.astro"; +import ServerCard from "../components/ServerCard.astro"; import "../styles/main.css"; // One static page per locale: en at "/", es at "/es/", eu at "/eu/". @@ -108,7 +108,7 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
- {

{t.status.lead}

- Distinct from **`plan/15-mc-status.md`**: Kuma is the uptime *history* + +> alerting backend; mc-status is the live data feed for the landing's server +> card. Both ping `minecraft` over `mcnet` but serve different purposes. + Status monitoring + public status page. Service `uptime-kuma` (`louislam/uptime-kuma:1`), web UI on `:3001`, reached only through caddy at `https://status.${BASE_DOMAIN}`. Joined to `mcnet`, so it can probe every other diff --git a/plan/15-mc-status.md b/plan/15-mc-status.md new file mode 100644 index 0000000..9f0def8 --- /dev/null +++ b/plan/15-mc-status.md @@ -0,0 +1,95 @@ +# 15 — mc-status (self-hosted live server card backend) + +Self-hosted Minecraft Server List Ping → JSON service that feeds the landing's +live **ServerCard**. Replaces the third-party `api.mcstatus.io` dependency. + +## Why not just use api.mcstatus.io + +The public API works (CORS `*`, no key), but mcstatus.io's **API service is not +open source** — only its underlying Go library, `mcutil`, is. To self-host we +run the *same library* behind our own thin HTTP shell that re-emits mcstatus.io's +**v2 JSON shape**, so the frontend (`ServerCard.astro`) stays byte-identical — +only the URL changes. itzg/mc-status is deprecated; mc-monitor only exports +Prometheus metrics (no icon/MOTD/player sample), so neither fits the card. + +## Service + +`docker/mc-status/` — Go, built from source (multi-stage → scratch), image +`ulicraft/mc-status:local`. Wraps `github.com/mcstatus-io/mcutil/v4` +`status.Modern()` and maps `StatusModern` → v2 JSON: + +| mcutil field | v2 field ServerCard reads | +|---|---| +| `Players.Online/Max` | `players.online` / `players.max` | +| `Players.Sample[].ID` | `players.list[].uuid` | +| `Players.Sample[].Name` (`formatting.Result`) | `players.list[].name_raw/clean/html` | +| `MOTD` (`formatting.Result`) | `motd.raw/clean/html` | +| `Favicon` (`data:image/png;base64,…`) | `icon` | + +On any ping failure it returns `{"online": false}` so the card shows an offline +state without erroring. + +### Hardening + +- **No open SSRF relay.** The `` segment in `/v2/status/java/` is + **ignored** — the service only ever pings `MC_STATUS_TARGET`. The segment + exists only to keep the URL drop-in compatible with the public API. +- **TTL cache** (`MC_STATUS_CACHE_TTL`, default `30s`) so a busy landing page + can't ping the Minecraft server once per visitor. + +### Env + +| Var | Default | Notes | +|---|---|---| +| `MC_STATUS_TARGET` | `minecraft:25565` | pinged internally over `mcnet` | +| `MC_STATUS_CACHE_TTL` | `30s` | in-memory cache window | +| `MC_STATUS_LISTEN` | `:8080` | bind address | + +## Ingress + +Path-on-apex (no new DNS subdomain, no Let's Encrypt cert change — DNS/TLS are +managed outside this repo). `caddy/conf.d/10-static.caddy`: + +```caddy +handle /api/mcstatus/* { + uri strip_prefix /api/mcstatus + reverse_proxy mc-status:8080 +} +``` + +Same-origin → the ServerCard fetch needs no CORS. `site.ts`: +`statusApi: \`/api/mcstatus/v2/status/java/${BASE_DOMAIN}\``. + +## Data flow + +``` +guest browser ─ GET /api/mcstatus/v2/status/java/${BASE_DOMAIN} + └► caddy (apex) ─ strip_prefix /api/mcstatus ─► mc-status:8080 + └► mcutil SLP ping ─► minecraft:25565 (internal, mcnet) + ◄── v2 JSON {online, players.list[uuid,name_*], motd.html, icon} +ServerCard → player heads via NMSR: avatar.${BASE_DOMAIN}/headiso/?size=64 +``` + +## Relationship to Uptime Kuma (plan/10) + +Distinct roles. **Kuma** = uptime *history* + alerting backend (Minecraft Server +monitor, status page). **mc-status** = live, public-facing data for the landing +card (richer: icon/MOTD/player heads). Both ping `minecraft` over `mcnet`. + +## Validation notes + +Built + `go vet`/`gofmt` clean. The live ping path can't be exercised from the +dev sandbox (egress proxy allows TCP connect but filters the raw SLP handshake → +`context deadline exceeded`); it resolves on the prod host where `minecraft` is +reachable over `mcnet`. After deploy, confirm: + +``` +curl -s https://${BASE_DOMAIN}/api/mcstatus/v2/status/java/${BASE_DOMAIN} | jq . +``` +expecting `online:true`, a populated `players.list`, and `motd.html`. + +## Tasks + +- [ ] `docker compose up -d --build mc-status` on prod, verify the curl above. +- [ ] Confirm the SLP player sample carries real (Drasl) UUIDs so NMSR heads + render; if empty, the card falls back to the count-only / empty state.