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:
@@ -6,6 +6,13 @@ http://{$BASE_DOMAIN} {
|
|||||||
root * /srv/launcher
|
root * /srv/launcher
|
||||||
file_server browse
|
file_server browse
|
||||||
}
|
}
|
||||||
|
# Self-hosted Minecraft status JSON (mc-status service). Same-origin, so the
|
||||||
|
# landing's ServerCard fetch needs no CORS. The <addr> 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 {
|
handle {
|
||||||
root * /srv/www
|
root * /srv/www
|
||||||
file_server
|
file_server
|
||||||
|
|||||||
@@ -164,6 +164,22 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- mcnet
|
- 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.
|
# Status monitoring + public status page. caddy proxies status. -> :3001.
|
||||||
# Joins mcnet so monitors probe the stack by internal service name.
|
# Joins mcnet so monitors probe the stack by internal service name.
|
||||||
uptime-kuma:
|
uptime-kuma:
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
232
landing/src/components/ServerCard.astro
Normal file
232
landing/src/components/ServerCard.astro
Normal file
@@ -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
|
||||||
|
---
|
||||||
|
<div
|
||||||
|
class="servercard"
|
||||||
|
data-api={status.statusApi}
|
||||||
|
data-avatar={avatarUrl}
|
||||||
|
data-mode={HEAD_MODE}
|
||||||
|
data-slots={status.slots}
|
||||||
|
data-online={onlineLabel}
|
||||||
|
data-offline={offlineLabel}
|
||||||
|
data-empty={emptyLabel}
|
||||||
|
>
|
||||||
|
<div class="sc-top">
|
||||||
|
<div class="sc-icon">
|
||||||
|
<!-- Replaced by the real server favicon on success; Creeper otherwise. -->
|
||||||
|
<img class="sc-favicon" alt="" hidden />
|
||||||
|
<span class="sc-creeper"><Creeper /></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sc-meta">
|
||||||
|
<div class="sc-row1">
|
||||||
|
<span class="sc-title">{site.name}</span>
|
||||||
|
<span class="sc-ver">Java {site.mcVersion}</span>
|
||||||
|
</div>
|
||||||
|
<p class="sc-motd"><span class="a">⛏ {modded}</span> · <span class="sc-motd-text">{motd}</span></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sc-state">
|
||||||
|
<span class="sc-pill" data-up="">
|
||||||
|
<span class="sc-dot"></span>
|
||||||
|
<span class="sc-pill-txt">·</span>
|
||||||
|
</span>
|
||||||
|
<div class="sc-count">
|
||||||
|
<span class="sc-on">{upTo}</span> <b class="sc-max">{status.slots}</b>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Player fill bar (online/max). Hidden until a live count arrives. -->
|
||||||
|
<div class="sc-bar" hidden><span class="sc-bar-fill"></span></div>
|
||||||
|
|
||||||
|
<!-- Head row + empty state. Populated client-side. -->
|
||||||
|
<div class="sc-players" hidden>
|
||||||
|
<div class="sc-heads" aria-label="Players online"></div>
|
||||||
|
<p class="sc-empty" hidden></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.servercard {
|
||||||
|
background: var(--slot);
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
box-shadow:
|
||||||
|
inset 2px 2px 0 var(--bevel-hi),
|
||||||
|
inset -2px -2px 0 var(--bevel-lo),
|
||||||
|
0 8px 24px oklch(0 0 0 / 0.4);
|
||||||
|
}
|
||||||
|
.sc-top { display: flex; gap: 18px; align-items: center; }
|
||||||
|
|
||||||
|
.sc-icon {
|
||||||
|
width: 72px; height: 72px; flex-shrink: 0;
|
||||||
|
display: grid; place-items: center; position: relative;
|
||||||
|
background: var(--bg-2);
|
||||||
|
box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi);
|
||||||
|
}
|
||||||
|
.sc-favicon { width: 64px; height: 64px; image-rendering: pixelated; }
|
||||||
|
.sc-creeper :global(.creeper) { width: 48px; height: 48px; }
|
||||||
|
|
||||||
|
.sc-meta { flex: 1; min-width: 0; }
|
||||||
|
.sc-row1 { display: flex; align-items: baseline; gap: 12px; }
|
||||||
|
.sc-title { font-family: var(--font-head); font-weight: 600; font-size: 22px; white-space: nowrap; }
|
||||||
|
.sc-ver { color: var(--dim); font-family: var(--font-mono); font-size: 17px; white-space: nowrap; }
|
||||||
|
.sc-motd { margin: 6px 0 0; color: var(--muted); font-size: 15px; }
|
||||||
|
.sc-motd .a { color: var(--accent); }
|
||||||
|
/* mcstatus.io motd.html ships inline color styles; keep its spans inline. */
|
||||||
|
.sc-motd-text :global(span) { display: inline; }
|
||||||
|
|
||||||
|
.sc-state { text-align: right; flex-shrink: 0; display: flex; flex-direction: column; align-items: flex-end; gap: 6px; }
|
||||||
|
.sc-pill {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
font-size: 11px; font-family: var(--font-pixel, var(--font-mono));
|
||||||
|
letter-spacing: 0.04em; text-transform: uppercase;
|
||||||
|
padding: 3px 8px; color: var(--dim);
|
||||||
|
box-shadow: inset 1px 1px 0 var(--bevel-hi), inset -1px -1px 0 var(--bevel-lo);
|
||||||
|
}
|
||||||
|
.sc-dot { width: 9px; height: 9px; background: var(--dim); image-rendering: pixelated; }
|
||||||
|
.sc-pill[data-up="1"] { color: var(--accent-hi); }
|
||||||
|
.sc-pill[data-up="1"] .sc-dot { background: var(--accent); box-shadow: 0 0 6px var(--glow); }
|
||||||
|
.sc-pill[data-up="0"] { color: oklch(0.65 0.18 25); }
|
||||||
|
.sc-pill[data-up="0"] .sc-dot { background: oklch(0.62 0.2 25); }
|
||||||
|
.sc-count { font-variant-numeric: tabular-nums; font-size: 15px; color: var(--muted); }
|
||||||
|
.sc-count b { color: var(--text); }
|
||||||
|
|
||||||
|
.sc-bar { height: 6px; background: var(--bg-2); box-shadow: inset 1px 1px 0 var(--bevel-lo); }
|
||||||
|
.sc-bar-fill { display: block; height: 100%; width: 0; background: var(--accent); transition: width 0.6s ease; }
|
||||||
|
|
||||||
|
.sc-heads { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.sc-heads img {
|
||||||
|
width: 34px; height: 34px; image-rendering: pixelated;
|
||||||
|
background: var(--bg-2);
|
||||||
|
box-shadow: inset 1px 1px 0 var(--bevel-lo), inset -1px -1px 0 var(--bevel-hi);
|
||||||
|
}
|
||||||
|
.sc-empty { margin: 0; color: var(--dim); font-size: 13px; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const ZERO_UUID = "00000000-0000-0000-0000-000000000000";
|
||||||
|
|
||||||
|
async function hydrate(el: HTMLElement) {
|
||||||
|
const { api, avatar, mode, slots, online, offline, empty } = el.dataset;
|
||||||
|
if (!api || !avatar || !mode) return;
|
||||||
|
|
||||||
|
const q = <T extends HTMLElement>(s: string) => el.querySelector<T>(s);
|
||||||
|
const pill = q(".sc-pill");
|
||||||
|
const pillTxt = q(".sc-pill-txt");
|
||||||
|
const favicon = q<HTMLImageElement>(".sc-favicon");
|
||||||
|
const creeper = q(".sc-creeper");
|
||||||
|
const onEl = q(".sc-on");
|
||||||
|
const maxEl = q(".sc-max");
|
||||||
|
const motdEl = q(".sc-motd-text");
|
||||||
|
const bar = q(".sc-bar");
|
||||||
|
const fill = q<HTMLElement>(".sc-bar-fill");
|
||||||
|
const players = q(".sc-players");
|
||||||
|
const heads = q(".sc-heads");
|
||||||
|
const emptyEl = q(".sc-empty");
|
||||||
|
|
||||||
|
let data: any;
|
||||||
|
try {
|
||||||
|
const r = await fetch(api, { headers: { Accept: "application/json" } });
|
||||||
|
if (!r.ok) return; // keep SSG skeleton
|
||||||
|
data = await r.json();
|
||||||
|
} catch {
|
||||||
|
return; // offline/network → keep SSG skeleton
|
||||||
|
}
|
||||||
|
|
||||||
|
const up = data.online === true;
|
||||||
|
pill?.setAttribute("data-up", up ? "1" : "0");
|
||||||
|
if (pillTxt) pillTxt.textContent = up ? (online ?? "Online") : (offline ?? "Offline");
|
||||||
|
|
||||||
|
if (!up) return; // offline pill set; leave the rest as the static fallback
|
||||||
|
|
||||||
|
// Live count + bar.
|
||||||
|
const on = data.players?.online ?? 0;
|
||||||
|
const max = data.players?.max ?? (Number(slots) || 0);
|
||||||
|
if (onEl) onEl.textContent = String(on);
|
||||||
|
if (maxEl) maxEl.textContent = String(max);
|
||||||
|
if (bar && fill) {
|
||||||
|
bar.hidden = false;
|
||||||
|
fill.style.width = max > 0 ? `${Math.min(100, (on / max) * 100)}%` : "0%";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Real server favicon.
|
||||||
|
if (favicon && typeof data.icon === "string" && data.icon.startsWith("data:image")) {
|
||||||
|
favicon.src = data.icon;
|
||||||
|
favicon.hidden = false;
|
||||||
|
creeper?.setAttribute("hidden", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Color MOTD (mcstatus ships inline-styled spans for our own server).
|
||||||
|
if (motdEl && data.motd?.html) motdEl.innerHTML = data.motd.html;
|
||||||
|
|
||||||
|
// Player heads from OUR NMSR (Drasl skins). Filter promo/placeholder rows.
|
||||||
|
const list: Array<{ uuid?: string; name_clean?: string }> = data.players?.list ?? [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const real = list.filter((p) => {
|
||||||
|
const u = (p.uuid ?? "").toLowerCase();
|
||||||
|
if (!u || u === ZERO_UUID || seen.has(u)) return false;
|
||||||
|
seen.add(u);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (players && heads) {
|
||||||
|
players.hidden = false;
|
||||||
|
if (real.length === 0) {
|
||||||
|
if (emptyEl) {
|
||||||
|
emptyEl.textContent = empty ?? "Nobody online.";
|
||||||
|
emptyEl.hidden = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
heads.innerHTML = "";
|
||||||
|
for (const p of real.slice(0, 16)) {
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.loading = "lazy";
|
||||||
|
img.alt = p.name_clean ?? "";
|
||||||
|
img.title = p.name_clean ?? "";
|
||||||
|
img.src = `${avatar}/${mode}/${p.uuid}?size=64`;
|
||||||
|
heads.appendChild(img);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll<HTMLElement>(".servercard").forEach(hydrate);
|
||||||
|
</script>
|
||||||
@@ -51,12 +51,14 @@ export const site = {
|
|||||||
avatarUrl: `https://avatar.${BASE_DOMAIN}`,
|
avatarUrl: `https://avatar.${BASE_DOMAIN}`,
|
||||||
|
|
||||||
// Live server card (ServerCard.astro). `slots` is the SSG fallback cap shown
|
// Live server card (ServerCard.astro). `slots` is the SSG fallback cap shown
|
||||||
// before/without JS; live count comes from mcstatus.io pinging the public
|
// before/without JS; live data comes from our SELF-HOSTED mc-status service
|
||||||
// address. mcstatus.io sets Access-Control-Allow-Origin:* → direct browser
|
// (docker/mc-status, mcutil wrapper) reverse-proxied same-origin at the apex
|
||||||
// fetch, no proxy/key. Uptime Kuma stays the history + alerting backend.
|
// /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: {
|
status: {
|
||||||
slots: 10,
|
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
|
// Honest stat tiles. Keys (numbers/versions) are language-neutral; labels
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { ui, LANGS, LANG_LABEL, LANG_PATH, type Lang } from "../i18n/ui";
|
|||||||
import Creeper from "../components/Creeper.astro";
|
import Creeper from "../components/Creeper.astro";
|
||||||
import PixelIcon from "../components/PixelIcon.astro";
|
import PixelIcon from "../components/PixelIcon.astro";
|
||||||
import CopyChip from "../components/CopyChip.astro";
|
import CopyChip from "../components/CopyChip.astro";
|
||||||
import ServerListPanel from "../components/ServerListPanel.astro";
|
import ServerCard from "../components/ServerCard.astro";
|
||||||
import "../styles/main.css";
|
import "../styles/main.css";
|
||||||
|
|
||||||
// One static page per locale: en at "/", es at "/es/", eu at "/eu/".
|
// One static page per locale: en at "/", es at "/es/", eu at "/eu/".
|
||||||
@@ -108,7 +108,7 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-status">
|
<div class="hero-status">
|
||||||
<ServerListPanel
|
<ServerCard
|
||||||
modded={t.status.modded}
|
modded={t.status.modded}
|
||||||
motd={t.status.motd}
|
motd={t.status.motd}
|
||||||
upTo={t.status.upTo}
|
upTo={t.status.upTo}
|
||||||
@@ -126,7 +126,7 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
|
|||||||
<p class="lead">{t.status.lead}</p>
|
<p class="lead">{t.status.lead}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="reveal">
|
<div class="reveal">
|
||||||
<ServerListPanel
|
<ServerCard
|
||||||
modded={t.status.modded}
|
modded={t.status.modded}
|
||||||
motd={t.status.motd}
|
motd={t.status.motd}
|
||||||
upTo={t.status.upTo}
|
upTo={t.status.upTo}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# 10 — Uptime Kuma
|
# 10 — Uptime Kuma
|
||||||
|
|
||||||
|
> 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`
|
Status monitoring + public status page. Service `uptime-kuma`
|
||||||
(`louislam/uptime-kuma:1`), web UI on `:3001`, reached only through caddy at
|
(`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
|
`https://status.${BASE_DOMAIN}`. Joined to `mcnet`, so it can probe every other
|
||||||
|
|||||||
95
plan/15-mc-status.md
Normal file
95
plan/15-mc-status.md
Normal file
@@ -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 `<addr>` segment in `/v2/status/java/<addr>` 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/<uuid>?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.
|
||||||
Reference in New Issue
Block a user