Add generators.${BASE_DOMAIN}: a pinned fork of misode.github.io served
with its own mcmeta + vanilla-mcdoc mirror, so it works with zero internet
at the LAN party. Fork lives in ulicraft-generators (branch ulicraft),
pinned at upstream 2bfe5522.
The app is 100% client-side: every data fetch comes from the player's
browser, not from cochi. So offline capability is entirely about which
origins the bundle refers to, plus a mirror to serve them from. The vhost
sets Content-Security-Policy with connect-src 'self' as ENFORCEMENT: an
external origin that sneaks back in (or returns via an upstream merge)
then breaks loudly in prod, on an ordinary day, instead of silently at the
venue with no internet to debug it.
sync-generators-data.sh mirrors ~109M: the 1.21.1 summary/data/atlas tags,
assets-tiny (assets minus hundreds of MB of .ogg — hence no sounds
generator), vanilla-mcdoc symbols.json, and summary/versions/data.min.json,
which comes from a BRANCH head rather than a version tag and runs during
Spyglass init. It verifies a sentinel file per source instead of trusting a
clean clone exit, since a renamed upstream path would otherwise sync
"successfully" and only fail in the browser at the party.
The container serves the bundle and the mirror on one origin, so there is
no CORS, no second cert and no second DNS name.
DEPLOY ORDER MATTERS. GENERATORS_REPO must be set in cochi's .env BEFORE
pulling this: compose uses ${GENERATORS_REPO:?...}, and an unset required
variable makes docker compose fail to parse the file — taking down every
service including Minecraft, not just this one. Full runbook and the rest
of the prerequisites: plan/21-generators.md.
Verified offline in real Chromium with every non-origin request aborted:
zero off-origin attempts, zero same-origin failures, zero console errors,
generators rendering schemas and live JSON.
6.1 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.netin 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 |
files.${BASE_DOMAIN} |
60-files.caddy |
filestash:8334 |
distribution.${BASE_DOMAIN} |
30-distribution.caddy |
static /srv/distribution (other repo) |
generators.${BASE_DOMAIN} |
70-generators.caddy |
generators:80 (offline misode fork + data mirror) |
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 (headisois the configured default viaAVATAR_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. Seeplan/03-drasl.mdfor 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 pingsMC_STATUS_TARGET). LandingstatusApi./api/mcstatus/players→ internal/players— registered-players roster, sanitized[{uuid,name}], ~60s cache (mc-status admin-logs-in to DraslGET /players). LandingrosterApi./healthz— mc-status liveness (internal).
handle {}→/srv/wwwstatic (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 atGET /api/session/auth/?action=redirect&label=files. Withoutaction=redirectthe request falls through to the credential check, fails with empty creds and 303s back to?action=redirect— so a bare/api/session/auth/?label=filesreturning a 303 is normal, not a bug.POSTwithuser/passwordauthenticates.GET /api/config→result.originmust behttps://files.${BASE_DOMAIN}. It's built as(force_ssl ? https:// : http://) + general.host, so a scheme ingeneral.hostyieldshttp://https://…and silently breaks every browser while the server keeps answering 200. Check this after any config change./api/*— JSON API. Requires the headerX-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=filesmatters. The attribute mapping is keyed by the connection label; auth without it yieldsbackend 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 whenRequireInvite=true; admins fromDefaultAdminsare promoted at startup, seeplan/03-drasl.md)GET /players— admin only, full rosterPATCH /players/{uuid}— skin CRUD (own player, or admin)POST /invites— admin, mint invite codes
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; pingsMC_STATUS_TARGETfor live status.mc-backup→ RCONminecraft:25575.