34 Commits

Author SHA1 Message Date
10b86ae23e fix(files): general.host must be a bare hostname, not a URL
The SPA computes its redirect origin as (force_ssl ? "https://" : "http://")
+ general.host. With host set to "https://files.${BASE_DOMAIN}" that produced
"http://https://files.ulicraft.net", so every client-side redirect broke and
the page never routed anywhere.

Server-side this was invisible: SecureOrigin strips the scheme before the
Host check, so `curl /` returned 200, the API answered, and the uptime
monitor stayed green — only real browsers failed. The tell is a
`POST /report?...msg=Redirecting to http://https://...` line in the log.

Set host to the bare hostname and force_ssl=true (which the origin needs to
build https://, and which only emits an HSTS header — it does not redirect,
so it can't loop behind the plain-http nginx→caddy hop).

Verified: origin is now "https://files.ulicraft.net"; login + ls still work.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 18:27:42 +02:00
25df9f9398 feat(files): add Filestash player file share at files.${BASE_DOMAIN}
One shared login (htpasswd) for all players, writable. Config is rendered
from a template and mounted read-only, so git stays authoritative — the
admin console loads but cannot save, by design.

Filestash's OIDC/SAML middlewares are enterprise-only, so Keycloak SSO is
out; FILES_AUTH keeps htpasswd/passthrough switchable for later. Auth-off
(passthrough) is only valid once the host is off the public internet —
render-config.sh warns loudly when rendering it.

Three traps, all found by testing against the pinned image:
- the `local` backend is admin-gated: without LOCAL_BACKEND_SECRET in the
  connection params every login fails with "backend error - Not Allowed"
- the htpasswd plugin only accepts $2a$ bcrypt, so a $2y$ hash from
  `htpasswd -B` fails every login silently — use `openssl passwd -6`.
  render-config.sh rejects the wrong format rather than shipping it
- APPLICATION_URL/ADMIN_PASSWORD env make filestash rewrite config.json at
  boot, which fails on the :ro mount — both live in the rendered config

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 18:08:46 +02:00
c3b2d89ddc feat(landing): switch heading font to Block Blueprint
Replace Pixelify Sans with Block Blueprint as the heading font (theme.headFont).
Block Blueprint isn't on Google Fonts, so it's vendored straight from 1001fonts
(License: 1001Fonts Free For Personal Use — fine for this private friends'
server) as a TTF, with a hand-written @font-face appended to fonts.css. The
vendor step is added to fetch-fonts.sh (runs after the Google rewrite so it
survives re-runs). Pixelify stays available as a HEAD_FONTS option + the
hard-coded fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 22:17:52 +02:00
6c7e259fcb feat(landing): join-flow rework, player rosters, account page, mod list
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>
2026-06-10 18:17:14 +02:00
27237bc7c1 feat(mods): replace packwiz with distribution-fed custom mod volume
Drop packwiz entirely. The server now loads a filtered mod subset from a
local ./server-mods volume instead of fetching a packwiz pack at boot.

New flow:
- tooling/classify-mods.py (tomllib only, no network) reads each jar's
  META-INF/neoforge.mods.toml and seeds mods-sides.json with a side
  (client|server|both; default both). Existing entries are preserved.
- mods-sides.json: committed, keyed by jar filename, hand-editable override
  surface. Initial seed: 65 both, 11 client, 0 server (76 jars).
- tooling/sync-server-mods.sh mirrors the both/server jars from
  $DISTRIBUTION_WEB_ROOT into ./server-mods/ (authoritative; prunes strays;
  halts on a missing jar). Replaces render-pack.sh.
- compose: minecraft mounts ./server-mods:/mods:ro with REMOVE_OLD_MODS=TRUE
  (itzg auto-syncs /mods -> /data/mods). Removed PACKWIZ_URL, the pack.
  extra_host (auth. kept for authlib), and depends_on caddy (drasl kept).

Both classify-mods.py and sync-server-mods.sh resolve their source dir as:
CLI arg / MODS_DIST_DIR env / $DISTRIBUTION_WEB_ROOT (in that order).

Removed: tooling/render-pack.sh + add-custom-mod.sh (packwiz tooling),
pack/ (packwiz source), custom/ (76 jars now sourced from the distribution),
the pack. caddy vhost + its mounts/alias, and the packwiz .gitignore block.

Client distribution is UNCHANGED: HeliosLauncher still installs the full
modset from distribution.json. Docs (CLAUDE.md, plan/04/05/14, runtime)
updated; plan/04-packwiz.md stubbed as superseded by plan/04-mods.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 23:34:38 +02:00
df8ffca53e feat(landing): self-serve registration page via Drasl API (B1)
Add a static /register page (en/es/eu) that calls the Drasl REST API v2
directly from the browser: register -> login -> optional skin upload, all
in the pixel design. Guests never touch Drasl's own web UI.

Drasl config gains the pieces this needs:
- CORSAllowOrigins scoped to the apex (the API has no CORS until set;
  never "*").
- [RateLimit] for the now public-facing anonymous POST /users.
- [RegistrationNewPlayer] with RequireInvite driven by a new
  REGISTRATION_MODE (invite|open) .env flag.

REGISTRATION_MODE has two consumers from one key: render-config.sh derives
the TOML boolean for Drasl (drasl is TOML-only, no env), and the landing
build reads it to show/hide the invite-code field. render-config.sh halts
on any value other than invite/open.

Security verified against drasl source: anonymous POST /users cannot set
privileged fields (isAdmin/isLocked/chosenUuid/maxPlayerCount are gated on
callerIsAdmin in CreateUser), so browser-direct registration is safe.

Docs: plan/16-landing-registration.md captures the design + the B1 vs fork
decision; build-order, deploy, and the deploy skill wire REGISTRATION_MODE
and the landing-rebuild requirement (www/ is gitignored, not updated by a
git pull).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 21:55:51 +02:00
a2821f2bbc feat(tooling): fetch-authlib.sh to download authlib-injector.jar
The server JVM agent (runtime/authlib-injector.jar, mounted at /extras) is
gitignored and was previously fetched by the deleted build-stack.sh prep. A
fresh checkout had no jar, so minecraft crashlooped with "Error opening zip
file or JAR manifest missing". Add a standalone fetch tool: resolves the latest
release asset, downloads, and verifies it's a real zip/jar (PK magic) before
trusting it. Idempotent (skips if valid, --force to re-download). Wire it into
the deploy skill and README launch steps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 03:06:59 +02:00
67d1f82e6a refactor!: unify into one compose, drop airgap/mirror/dnsmasq/avahi/blessingskin
Collapse the override-file matrix into a single docker-compose.yml holding the
whole stack: drasl, minecraft, mc-backup, caddy, nmsr, uptime-kuma. Bring-up is
now just `docker compose up -d --build`.

Removed features (dead-ends for this deployment):
- airgap / full asset mirror (mirror-airgap.sh, mirror-mods.sh, 20-mirror.caddy,
  caddy local-CA export, /ca.crt)
- dnsmasq + avahi/mDNS + dns-records.sh + mdns-host-setup.sh + ENABLE_MDNS;
  DNS is now handled outside this repo (HOST_LAN_IP dropped)
- Blessing Skin auth variant (compose + 00-core-blessingskin.caddy + BS_* env)
- host-nginx-static variant (docker-compose.nginx.yml, nginx/ulicraft.conf.tmpl)
- build-stack.sh and its run modes (online/airgap/core)

Production ingress is the only path now: host nginx terminates TLS (LE certs)
in front of caddy on a localhost-only port; caddy routes every vhost by Host
header. Launcher downloads move mirror/launcher -> launcher/.

Docs: README, deploy skill, and plan/ rewritten to match; obsolete plan docs
(dnsmasq, blessing-skin, assets-mirror, full-airgap-mirror, dns-and-run-modes)
deleted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 02:14:09 +02:00
ff645de2c8 feat(status): Uptime Kuma monitoring at status.${BASE_DOMAIN}
Add docker-compose.status.yml (louislam/uptime-kuma) layered on the caddy
ingress, joining mcnet so it probes the stack by internal service name and
the public vhosts end-to-end. Host port bound to 127.0.0.1:3001 only; public
access via nginx -> caddy.

- caddy/conf.d/50-status.caddy: status. -> uptime-kuma:3001
- nginx caddy-front template: status. TLS vhost + websocket upgrade headers
  for Kuma's live UI; status. added to the HTTP->HTTPS redirect list
- status added to LE_SUBDOMAINS defaults (.env.example, issue-letsencrypt.sh)
- README: Status monitoring section + recommended monitor list (incl. native
  Minecraft-protocol ping)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 01:49:39 +02:00
6fac1647aa feat(avatar): NMSR skin renderer at avatar.${BASE_DOMAIN}, Drasl-backed
Add NMSR-as-a-Service (NickAcPT/nmsr-rs) rendering players' skins/avatars,
sourced from Drasl instead of Mojang.

- docker/nmsr/Dockerfile     build from source, pinned to commit 948ba4bc027b
                             (ears feature, lavapipe software Vulkan)
- nmsr/config.toml.tmpl       [mojank] -> http://auth.${BASE_DOMAIN}; Drasl
                              serves the Mojang-compatible routes at its bare
                              BaseURL and embeds absolute skin URLs that NMSR
                              fetches directly. allow_offline_mode_uuids=true.
- docker-compose.nmsr.yml     nmsr service (build), host bind 127.0.0.1:9898
                              only, config mount; mounts the caddy avatar snippet
- caddy/conf.d/40-avatar.caddy avatar.${BASE_DOMAIN} -> reverse_proxy nmsr:8080
- nginx-front + LE_SUBDOMAINS  public TLS vhost (HSTS) + cert for avatar
- render-config.sh / .gitignore render + ignore nmsr/config.toml

Skin resolution stays internal over mcnet (caddy alias -> drasl), no public
round-trip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 01:40:50 +02:00
3185fea4c1 feat(nginx): render-nginx.sh + www->apex redirect vhost
Add tooling/render-nginx.sh: pulls BASE_DOMAIN/CADDY_HTTP_PORT from .env,
defaults APP_DIR to the repo checkout, renders a vhost template (default the
caddy-front one) to stdout, or --install writes + symlinks + nginx -t + reloads.
Document it in the README, replacing the manual envsubst one-liner.

Also add the www.${BASE_DOMAIN} canonical 301->apex block and put www in the
LE_SUBDOMAINS default so its cert is issued.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 01:09:46 +02:00
ab3d2c201b feat(ingress): public distribution.${BASE_DOMAIN} static vhost
Serve a static site at distribution.${BASE_DOMAIN} whose web root lives in
another repo (DISTRIBUTION_WEB_ROOT, bind-mounted read-only into caddy):
  - caddy/conf.d/30-distribution.caddy  file_server vhost
  - docker-compose.distribution.yml     mount snippet + external web root
  - issue-letsencrypt.sh / .env.example add distribution to LE_SUBDOMAINS
  - ulicraft-caddy.conf.tmpl            TLS-front block proxying to caddy

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 00:30:27 +02:00
3d52951355 feat(landing): pixel design ported to static Astro + en/es/eu i18n
Adopt the "Carved from stone" pixel design (landing/design/) as the apex
onboarding page, ported from its React/Babel-via-CDN prototype to static
Astro so it works on an offline LAN. Real modded-LAN content replaces the
prototype's fictional public-SMP copy.

- Vendor web fonts locally (tooling/fetch-fonts.sh -> public/fonts/) so the
  page renders without the Google Fonts CDN at party time.
- Port the design system (main.css: mood/hero/bevels) and split markup into
  Astro components (Creeper, PixelIcon, CopyChip, ServerListPanel).
- site.ts holds language-neutral config + theme knobs (mood/hero/headFont/
  dust) that replace the design's in-browser TweaksPanel; LITERALS holds
  never-translated product/in-game terms.
- i18n: English (/), Spanish (/es/), Euskera (/eu/) generated from one
  [...lang].astro via getStaticPaths; copy lives in src/i18n/ui.ts. Nav has
  an EN/ES/EU switcher; html lang + hreflang set per page.
- Status panel shows a static server-list row + honest stat tiles (no fake
  live count). Copy + scroll-reveal are the only client JS.

Build emits to ../www (gitignored). Euskera strings pending a native review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 18:18:36 +02:00
843347ad00 feat(tls): Let's Encrypt via OVH DNS-01 + host nginx vhost
tooling/issue-letsencrypt.sh: manual script, issues one cert per name
(apex + LE_SUBDOMAINS) with acme.sh + OVH DNS-01 (no inbound ports),
installs to certs/<name>/ and reloads nginx. nginx/ulicraft.conf.tmpl:
TLS vhosts for apex/pack (static) + auth (proxy to drasl). OVH creds and
LE_EMAIL in .env.example; certs/ gitignored. plan/15-letsencrypt.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 23:35:07 +02:00
4c874e20ed refactor(ingress): extract caddy to its own compose file; add nginx path
Caddy is no longer in the base stack. Two ingress paths now:
- docker-compose.caddy.yml — LAN/air-gap; build-stack.sh adds it for all
  up modes + the prep pre-bake. Holds the mcnet auth./pack. aliases.
- docker-compose.nginx.yml — production behind the host's nginx: publishes
  drasl on 127.0.0.1:25585, points minecraft at the host (extra_hosts) over
  HTTPS for pack + authlib.

Base loses the caddy service and minecraft's caddy depends_on (re-added by
caddy.yml). Blessing Skin override now requires caddy.yml (it only swaps
caddy's core conf).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 23:35:07 +02:00
6e91749f59 refactor(pack): template CustomSkinLoader + unify pack rendering
Add tooling/render-pack.sh: renders all pack/**/*.tmpl with ${BASE_DOMAIN}
(no dnsmasq HOST_LAN_IP coupling) and rebuilds the packwiz index. Both
render-config.sh and add-custom-mod.sh now delegate to it.

CustomSkinLoader.json's skin API roots are no longer frozen — it becomes
CustomSkinLoader.json.tmpl, rendered like the mod metadata. .packwizignore
broadened to *.tmpl; rendered .json gitignored as build output.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 22:42:32 +02:00
f7858d4ec4 refactor(pack): template custom mod metadata so BASE_DOMAIN isn't frozen
The download URL is the only domain-dependent field in the external-file
metadata. Make mods/*.pw.toml build output rendered from committed
mods/*.pw.toml.tmpl (url uses ${BASE_DOMAIN}); render-config.sh and
add-custom-mod.sh render them + packwiz refresh at deploy/add time.

- pack/.packwizignore: keep *.pw.toml.tmpl out of the index
- gitignore rendered pack/mods/*.pw.toml + pack/index.toml (build output)
- seed a minimal index.toml before refresh (packwiz won't bootstrap one)
- add-custom-mod renders inline (BASE_DOMAIN only) — no coupling to
  dnsmasq's HOST_LAN_IP

Domain changes now just need a re-render, not a metadata rewrite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 22:07:35 +02:00
39be74d028 fix(pack): regenerate custom mod URLs for current BASE_DOMAIN
The mods/*.pw.toml download URLs were frozen at ulicraft.lan; the stack
now uses ulicraft.net. Regenerate all nine with the current domain.

add-custom-mod.sh: skip the copy when source == hosted dest (`-ef`),
so `add-custom-mod.sh custom --force` works as an in-place regen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:52:00 +02:00
c40cbe7594 refactor(pack): host custom jars from ./custom outside the pack root
Move pack/custom/ -> ./custom/ and bind-mount it into caddy at
/srv/pack/custom (served URL pack.${BASE_DOMAIN}/custom/ unchanged).
Keeping jars outside the packwiz pack root stops `packwiz refresh`
from indexing them as direct files on top of the mods/*.pw.toml
external refs (drops 9 bogus custom/*.jar entries from index.toml).

add-custom-mod.sh now writes jars to ./custom/. Trim the blessingskin
caddy override to just the conf swap (volumes merge by target).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:22:14 +02:00
2405eee57d feat(tooling): add-custom-mod.sh folder mode
Accept a directory: process every *.jar inside and run packwiz refresh
once at the end. --name rejected with a folder (names derived per-jar);
halts on an empty folder. Single-jar path unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:51:56 +02:00
e1ccdda961 feat(tooling): add-custom-mod.sh — host local jars as packwiz external files
Copies a local jar under pack/custom/ (served by caddy), sha256s it,
writes pack/mods/<slug>.pw.toml per packwiz "Other external files",
then packwiz refresh. Cautious: halts on bad side, whitespace in
filename, or existing entry without --force.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:48:55 +02:00
c279f35e14 feat(dns): 'hosts' output + /etc/hosts simulation docs
dns-records.sh <mode> hosts prints a marker-wrapped /etc/hosts block to pipe
into /etc/hosts (single-machine testing without a DNS server). README documents
apply/remove + the airgap caveat (spoof hosts break real internet while present).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 14:23:14 +02:00
d8aefe95c4 feat(tooling): mdns-host-setup.sh — pin host avahi to the LAN NIC
Auto-detects the LAN interface from HOST_LAN_IP, sets allow-interfaces in
/etc/avahi/avahi-daemon.conf (idempotent, backs up), restarts avahi. Self-sudos.
Fixes the multi-bridge 'Local name collision' that blocks the mDNS path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 14:11:41 +02:00
64f11c8120 feat(build-stack): mDNS preflight warnings (host avahi, dbus, interface count)
When ENABLE_MDNS=true, warn if BASE_DOMAIN isn't .local, host avahi-daemon
isn't running, the D-Bus socket is missing, or the host has many interfaces
(avahi self-collision risk → suggests allow-interfaces).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 06:05:44 +02:00
f14578dc65 feat(dns): party-DNS records generator + online/airgap modes
DNS is now your own party server. tooling/dns-records.sh prints the records
(online = service names; airgap = + Mojang/launcher/NeoForge spoofs) in several
formats. build-stack --up online|airgap selects the mirror layer and prints the
matching records. dnsmasq moved to an optional override (docker-compose.dnsmasq.yml).
Default domain switched to .lan (.local is hijacked by mDNS on unicast DNS).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 05:56:54 +02:00
390f8c78af feat(caddy): serve Caddy root CA at /ca.crt for guest trust
Air-gap mirror uses tls internal; guests must trust Caddy's local CA. Serve it
at http://<domain>/ca.crt from a world-readable mount (the live pki dir is
root-only 0600). build-stack --up mirror|full exports the CA after bring-up;
landing page links it. Pre-creates the mount target to avoid a dir bind.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 05:32:31 +02:00
b277fc6268 feat(build-stack): fetch authlib-injector.jar during prep
Server JVM agent needs runtime/authlib-injector.jar (gitignored); pull the
latest yushijinhun release if absent so pre-bake doesn't crash-loop on a
missing agent jar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 05:20:02 +02:00
d6e7504d6e feat(compose): optional static/mirror via override files; build-stack up modes
Base compose is core only (dnsmasq, drasl, minecraft, mc-backup, caddy
auth+packwiz). docker-compose.static.yml and docker-compose.mirror.yml layer
on the landing site and the air-gap mirror independently. build-stack.sh --up
[core|static|mirror|full] selects the override files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 05:07:20 +02:00
eede0102c6 fix(tooling): fetch-launcher Windows stable symlink picks MSVC Setup x64
MSVC and MinGW Setup .exe both matched 'setup.*.exe'; tightened the
include pattern to 'msvc-setup.*.exe' so exactly one candidate resolves.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 05:02:02 +02:00
33eb5c60e6 feat(tooling): add mirror-airgap.sh (deterministic mirror + capture notice)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 04:47:02 +02:00
a6a43a17ac feat(tooling): add fetch-launcher.sh (all platforms + stable symlinks)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 04:44:27 +02:00
5f7a197c56 feat(tooling): add mirror-mods.sh (vendor jars + rewrite .pw.toml URLs)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 04:40:56 +02:00
1300a02070 feat(tooling): add render-config.sh (envsubst, halt on unset var)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 04:07:54 +02:00
c09da15bd0 initial commit 2026-05-24 04:03:42 +02:00