feat(landing): live server card backed by self-hosted mc-status
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>
This commit is contained in:
15
docker/mc-status/Dockerfile
Normal file
15
docker/mc-status/Dockerfile
Normal file
@@ -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"]
|
||||
5
docker/mc-status/go.mod
Normal file
5
docker/mc-status/go.mod
Normal file
@@ -0,0 +1,5 @@
|
||||
module ulicraft/mc-status
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require github.com/mcstatus-io/mcutil/v4 v4.0.1
|
||||
2
docker/mc-status/go.sum
Normal file
2
docker/mc-status/go.sum
Normal file
@@ -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=
|
||||
196
docker/mc-status/main.go
Normal file
196
docker/mc-status/main.go
Normal file
@@ -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/<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)
|
||||
}
|
||||
Reference in New Issue
Block a user