Files
ulicraft-server-v1/plan/19-routes.md
T
oierandClaude Opus 5 bac2372c95 docs: fold the monitor service into the stack-level docs
plan/23-monitor.md already covered the design; this makes the monitor
discoverable from the places someone actually starts reading.

- README: stack table, vhost list, DNS names, layout, and a setup section
  covering the bcrypt credential, the no-published-port model, and the
  --hard-on-first-deploy requirement.
- plan/00-overview.md: drop (PLANNED), add to the always-on service list, and
  note that it starts fine with Minecraft down (missing log and refused RCON
  are reported states, not fatal).
- plan/19-routes.md: vhost row, the four GET routes with their contracts, and
  the internal mcnet edges (mc-status for rosters so it needs no Drasl creds,
  RCON for TPS, Kuma probing healthz around caddy).
- caddy/Caddyfile: the snippet index had drifted — it listed five of nine
  vhosts. Added the four missing ones.

Two gotchas promoted to CLAUDE.md because they cost real time and will recur:

- Compose interpolates $-sequences in .env VALUES, so a bare bcrypt hash is
  silently corrupted before reaching the container, and the same bug bites
  again when writing one to a remote .env over ssh. Also records that caddy
  and filestash want opposite hash formats.
- The monitor container runs as root deliberately: /data is drwxr-x--- owned
  by uid 1000, so a USER directive would make the tailer silently read nothing
  while still returning 200.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 23:30:42 +02:00

8.0 KiB

19 — HTTP route reference (nmsr, landing, auth)

Quick map of the public + internal HTTP routes. caddy (caddy/conf.d/*.caddy) is the internal router; the host nginx terminates TLS and forwards each vhost to it by Host header. All ${BASE_DOMAIN} = ulicraft.net in prod.

Vhost → service (caddy)

Vhost caddy file Upstream
${BASE_DOMAIN} (apex) 10-static.caddy static /srv/www + /srv/launcher + mc-status
auth.${BASE_DOMAIN} 00-core.caddy drasl:25585
avatar.${BASE_DOMAIN} 40-avatar.caddy nmsr:8080
status.${BASE_DOMAIN} 50-status.caddy uptime-kuma:3001
upload.${BASE_DOMAIN} 60-upload.caddy filestash:8334
files.${BASE_DOMAIN} 62-files.caddy static /srv/files (read-only, no-auth browse of the share)
distribution.${BASE_DOMAIN} 30-distribution.caddy static /srv/distribution (other repo)
generators.${BASE_DOMAIN} 70-generators.caddy generators:80 (offline misode fork + data mirror)
monitor.${BASE_DOMAIN} 80-monitor.caddy monitor:8080 (admin log stream + dashboard, basic_auth)

NMSR — avatar.${BASE_DOMAIN} (skin/avatar renderer)

reverse_proxy nmsr:8080. Stateless renderer; pulls profiles+skins from Drasl over mcnet (no public round-trip).

  • GET /{mode}/{uuid}?size=N — render an avatar PNG.
    • mode = any NMSR render mode (headiso is the configured default via AVATAR_MODE; others incl. head, face, fullbody, fullbodyiso).
    • uuid = Drasl player UUID; size = px.
    • Landing usage: ${avatarUrl}/${avatarMode}/<uuid>?size=64|96.
  • No per-request refresh. A ?skin=<url> override is ignored by this build (tested). Skin changes propagate via the resolve cache only.
  • Caching (nmsr/config.toml): resolve_cache_duration = 60s (uuid→profile, bounds stale-avatar window), texture_cache_duration = 48h (safe — Drasl skin URLs are content-hashed, new skin = new URL). Instant global flush = restart nmsr. See plan/03-drasl.md for the Drasl-backed mojank wiring.

Landing — ${BASE_DOMAIN} (apex)

10-static.caddy. Three handlers:

  • handle_path /launcher/* → /srv/launcher (file browse) — launcher downloads.
  • handle /api/mcstatus/* → uri strip_prefix /api/mcstatus → mc-status:8080:
    • /api/mcstatus/v2/status/java/<addr> → internal /v2/status/java/ — live server status JSON (mcstatus.io-compatible, CORS: *, <addr> ignored — it only pings MC_STATUS_TARGET). Landing statusApi.
    • /api/mcstatus/players → internal /players — registered-players roster, sanitized [{uuid,name}], ~60s cache (mc-status admin-logs-in to Drasl GET /players). Landing rosterApi.
    • /healthz — mc-status liveness (internal).
  • handle {} → /srv/www static (Astro output). Pages: /, /es, /eu (index), /{lang}/register, /{lang}/account, /{lang}/fjord.

Browser → Drasl directly (CORS-scoped to apex via Drasl CORSAllowOrigins), base https://auth.${BASE_DOMAIN}/drasl/api/v2 (site.draslApiUrl):

  • register.astro: POST /users (register), POST /login, PATCH /players/{uuid} (set initial skin).
  • account.astro: POST /login, PATCH /players/{uuid} with {skinBase64,skinModel} (upload) or {deleteSkin:true} (reset).

Files — files.${BASE_DOMAIN} (Filestash player share)

reverse_proxy filestash:8334. Screenshots / schematics / maps. One shared login for every player (FILES_AUTH=htpasswd), writable. Full design: plan/20-files.md.

  • / — SPA. The login form renders only at GET /api/session/auth/?action=redirect&label=files. Without action=redirect the request falls through to the credential check, fails with empty creds and 303s back to ?action=redirect — so a bare /api/session/auth/?label=files returning a 303 is normal, not a bug. POST with user/password authenticates.
  • GET /api/config → result.origin must be https://files.${BASE_DOMAIN}. It's built as (force_ssl ? https:// : http://) + general.host, so a scheme in general.host yields http://https://… and silently breaks every browser while the server keeps answering 200. Check this after any config change.
  • /api/* — JSON API. Requires the header X-Requested-With: XmlHttpRequest (the SPA sends it); without it Filestash's intrusion detection 403s the request. Relevant when curling it by hand.
  • /admin — admin console, own bcrypt password (auth.admin). Deliberately public; nginx throttles it (limit_req, 6r/m). It cannot save — the config is a read-only mount.
  • ?label=files matters. The attribute mapping is keyed by the connection label; auth without it yields backend error - Not Allowed.

Filestash blocks any request whose Host doesn't match its general.host (https://files.${BASE_DOMAIN}) — nginx forwards the original Host and caddy passes it through, so don't rewrite it anywhere in the chain.


Auth / Drasl — auth.${BASE_DOMAIN}

reverse_proxy drasl:25585. One service exposes three surfaces:

  • Web UI: / (login / register / profile pages), /web/texture/skin/<sha256>.png, /web/texture/cape/<sha256>.png.
  • Yggdrasil / authlib-injector: /authlib-injector (API root — server JVM agent + clients point here), /session/minecraft/..., mojang-compat /users/profiles/minecraft/{name} and /session/minecraft/profile/{uuid} (also what NMSR calls internally).
  • Drasl REST API v2 (/drasl/api/v2):
    • POST /login {username,password} → {apiToken}
    • POST /users {username,password[,inviteCode]} — register (invite-gated when RequireInvite=true; admins from DefaultAdmins are promoted at startup, see plan/03-drasl.md)
    • GET /players — admin only, full roster
    • PATCH /players/{uuid} — skin CRUD (own player, or admin)
    • POST /invites — admin, mint invite codes

Monitor — monitor.${BASE_DOMAIN} (admin only)

reverse_proxy monitor:8080, behind caddy basic_auth (bcrypt, MONITOR_USER / MONITOR_PASSWORD_HASH). The only vhost in the stack that is gated at caddy, because the service itself has no auth and publishes no port — caddy is the sole way in. Per-host on/off via MONITOR_ENABLED.

Read-only: every route is a GET, and nothing mutates state.

  • GET / — the whole UI (single HTML file embedded in the binary).
  • GET /api/log[?since=<seq>] → {lines[], seq, dropped, reset, total, tail{}}. Omit since for the full buffer without a spurious "dropped" notice. since is a sequence number, not a byte offset — the log rotates on every deploy. reset: true means discard what you have (fell behind, or the service restarted and your cursor is in the future).
  • GET /api/summary → all dashboard tiles in one response. Each part is independently cached and degrades on its own; failures appear as sibling *_err fields next to the last good value.
  • GET /healthz → ok. Liveness only, deliberately not tied to the log file.

Full design + gotchas: plan/23-monitor.md.


Internal (service → service, over mcnet)

  • minecraft → -javaagent authlib-injector=http://auth.${BASE_DOMAIN}/authlib-injector (caddy mcnet alias → drasl).
  • nmsr → http://auth.${BASE_DOMAIN} (mojang-compat session/textures/api).
  • mc-status → http://drasl:25585/drasl/api/v2 (DRASL_API_URL) for the roster; pings MC_STATUS_TARGET for live status.
  • mc-backup → RCON minecraft:25575.
  • monitor → http://mc-status:8080 (/v2/status/java/* + /players) for the status and roster tiles, so it needs no Drasl credentials of its own; RCON minecraft:25575 for TPS, restricted to a compile-time allowlist (neoforge tps, list) that no HTTP input can reach. Reads mc_data:/data:ro (log + statfs) and ./backups:ro (backup freshness).
  • uptime-kuma → can probe http://monitor:8080/healthz directly, bypassing caddy, so the monitor vhost needs no unauthenticated path carve-out.