Execute plan/18 (WS1-6) + plan/17.
- WS1: homepage join = 3 steps off packwiz (register -> UlicraftLauncher
-> join); per-OS downloads from a synced launcher-manifest.json (schema +
example committed); Fjord moved to its own /fjord page. Drop packwizUrl.
- WS2/3: mc-status gains an isolated /players endpoint (admin login ->
Drasl GET /players, own client+TTL+token cache, never blocks the status
path); online + registered rosters render shared name+avatar tiles;
AVATAR_MODE env (default headiso).
- WS4: /account skin CRUD via browser-direct Drasl (login -> upload ->
reset), in-memory token, players[0]; register relinks "Manage it here".
- WS5: footer link to status.${BASE_DOMAIN} in every footer.
- WS6: tooling/build-modlist.py generates mods.json + extracted logos from
the distribution forgemods; ModList.astro renders a flat list; stat tile
fed from the count.
Manifest/mods loaders read at build with a process.cwd() fallback (the
import.meta.url-only path does not resolve in Astro's bundled build).
New env: AVATAR_MODE, DRASL_ADMIN_USERNAME, DRASL_ADMIN_PASSWORD (mc-status
only). Generated mods.json / launcher-manifest.json / logos are gitignored;
.example.json files document the shapes. Build: 12 pages; mc-status builds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
398 lines
11 KiB
Go
398 lines
11 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 (
|
|
"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/<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)
|
|
})
|
|
|
|
// 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)
|
|
}
|