diff --git a/.claude/skills/deploy/SKILL.md b/.claude/skills/deploy/SKILL.md index 1bf3255..844c610 100644 --- a/.claude/skills/deploy/SKILL.md +++ b/.claude/skills/deploy/SKILL.md @@ -1,6 +1,6 @@ --- name: deploy -description: Redeploy the Ulicraft Minecraft stack to the production host `cochi` — git pull on main then a hard restart (compose down + build-stack.sh --up online). Use when the user says "deploy", "redeploy", "deploy to cochi", "push to prod", "ship it", or "restart the server" for this project. Causes brief Minecraft downtime. +description: Redeploy the Ulicraft Minecraft stack to the production host `cochi` — git pull on main then a hard restart (compose down + up). Use when the user says "deploy", "redeploy", "deploy to cochi", "push to prod", "ship it", or "restart the server" for this project. Causes brief Minecraft downtime. --- # Deploy Ulicraft to `cochi` @@ -13,8 +13,9 @@ Redeploy the running stack on the production host. Full reference: - Host: `cochi` (SSH alias) - Path: `/home/ubuntu/mc/ulicraft-server-v1` - Branch: `main` -- Run mode: `online` → compose files `docker-compose.yml` + `docker-compose.static.yml` -- Auth: Drasl (base stack) +- Stack: single unified `docker-compose.yml` (drasl, minecraft, mc-backup, + caddy, nmsr, uptime-kuma) +- Auth: Drasl - Restart: **hard** (down → up) — players are disconnected for a few minutes ## Procedure @@ -35,32 +36,32 @@ invoking this skill is the authorization to proceed, but: ssh cochi 'set -e cd /home/ubuntu/mc/ulicraft-server-v1 git pull --ff-only - docker compose -f docker-compose.yml -f docker-compose.static.yml down --remove-orphans - tooling/build-stack.sh --up online' + tooling/render-config.sh + docker compose down --remove-orphans + docker compose up -d --build' ``` - `--ff-only` refuses to merge — if the pull is not a fast-forward, STOP and report (local changes on the host need manual resolution). - - `build-stack.sh --up online` renders configs (needs `.env` complete: - `BASE_DOMAIN`, `HOST_LAN_IP`, `RCON_PASSWORD`) and runs `compose up -d`. + - `render-config.sh` re-renders drasl/nmsr/packwiz configs from `.env` + (needs `.env` complete: `BASE_DOMAIN`, `RCON_PASSWORD`, + `DISTRIBUTION_WEB_ROOT`, `CADDY_HTTP_*`). 3. **Verify.** ```bash - ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && docker compose -f docker-compose.yml -f docker-compose.static.yml ps' + ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && docker compose ps' ``` - Confirm `caddy`, `drasl`, `minecraft`, `mc-backup` are Up. Tail the minecraft - log briefly and confirm it reaches `Done` (server ready): + Confirm `caddy`, `drasl`, `minecraft`, `mc-backup`, `nmsr`, `uptime-kuma` are + Up. Tail the minecraft log briefly and confirm it reaches `Done` (server + ready): ```bash - ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && timeout 120 docker compose -f docker-compose.yml -f docker-compose.static.yml logs -f minecraft' 2>/dev/null | grep -m1 "Done" + ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && timeout 120 docker compose logs -f minecraft' 2>/dev/null | grep -m1 "Done" ``` ## Guardrails - Never `git reset --hard` or discard changes on `cochi` without telling the user first — there may be host-local edits. -- Volumes (`mc_data`, `drasl_state`) persist across `down`; the world is safe. Do - NOT pass `-v`/`--volumes` to `down`. +- Volumes (`mc_data`, `drasl_state`, `kuma_data`) persist across `down`; the + world and monitors are safe. Do NOT pass `-v`/`--volumes` to `down`. - If a step fails, STOP and surface the exact error + the failing command. Do not improvise recovery on production. -- Blessing Skin variant or `airgap` mode changes the compose file set — if the - user asks for those, follow `plan/14-deploy.md` instead of the fixed commands - above. diff --git a/.env.example b/.env.example index f2f4b24..bf8acca 100644 --- a/.env.example +++ b/.env.example @@ -1,35 +1,22 @@ # Copy to .env and fill in real values # Single base domain for the whole stack; every service is a subdomain of this. -# Use .lan (NOT .local) — .local is intercepted by mDNS on macOS/Linux and -# won't resolve through a normal unicast DNS server. -BASE_DOMAIN=ulicraft.lan -# The Docker host's LAN IP — every service name resolves here. -HOST_LAN_IP=192.168.1.10 - -# mDNS toggle. true + BASE_DOMAIN=*.local → an Avahi container publishes the -# service names over mDNS (zero-config for macOS/Linux guests; Windows needs -# Bonjour). false → use your own party DNS (records: tooling/dns-records.sh). -# NOTE: mDNS resolves ONLY *.local; it cannot serve the air-gap upstream-host -# spoofs — those always need a real DNS server. -ENABLE_MDNS=false +# DNS is handled OUTSIDE this repo — point ${BASE_DOMAIN} and every subdomain +# (auth. pack. avatar. status. distribution. www.) at the host running nginx. +BASE_DOMAIN=ulicraft.net RCON_PASSWORD=change-me-to-something-random -# Caddy host-published port (docker-compose.caddy.yml). Default 80 for the -# standalone LAN/air-gap path. When the machine's own nginx fronts caddy -# (nginx terminates TLS → reverse-proxies to caddy), set a high port and bind -# it to localhost so only nginx reaches it: -# CADDY_HTTP_PORT=8880 -# CADDY_HTTP_BIND=127.0.0.1 -# Then render nginx/ulicraft-caddy.conf.tmpl with CADDY_HTTP_PORT. -CADDY_HTTP_PORT=80 -CADDY_HTTP_BIND=0.0.0.0 +# caddy host-published port. The host's own nginx terminates TLS and +# reverse-proxies to caddy by Host header (nginx/ulicraft-caddy.conf.tmpl), so +# bind caddy to a high localhost-only port — only nginx should reach it. +CADDY_HTTP_PORT=8880 +CADDY_HTTP_BIND=127.0.0.1 -# distribution.${BASE_DOMAIN} static vhost (docker-compose.distribution.yml). -# Absolute host path to the built static site — it lives in ANOTHER repo. -# Bind-mounted read-only into caddy and served at distribution.${BASE_DOMAIN}. +# distribution.${BASE_DOMAIN} static vhost. Absolute host path to the built +# static site — it lives in ANOTHER repo. Bind-mounted read-only into caddy. DISTRIBUTION_WEB_ROOT=/home/oier/projects/mc-mods/ulicraft-group/ulicraft-distribution/dist + # Only needed if using CurseForge-exclusive mods: # CF_API_KEY=your-curseforge-api-key @@ -45,13 +32,3 @@ OVH_AK= OVH_AS= OVH_CK= # LE_STAGING=1 # use the untrusted staging CA for dry runs - -# ── Blessing Skin auth variant (docker-compose.blessingskin.yml) ────── -# Only needed when running the Blessing Skin override instead of Drasl. -BS_DB_NAME=blessingskin -BS_DB_USER=blessingskin -BS_DB_PASSWORD=change-me-bs-db -BS_DB_ROOT_PASSWORD=change-me-bs-root -# Generate once and paste here so it survives container recreates: -# docker run --rm azusamikan/blessing-skin-server-docker:latest php artisan key:generate --show -BS_APP_KEY=base64:replace-with-generated-key diff --git a/.gitignore b/.gitignore index 599fa66..2050418 100644 --- a/.gitignore +++ b/.gitignore @@ -6,15 +6,14 @@ runtime/authlib-injector.jar # rendered configs (from tooling/render-config.sh) drasl/config/config.toml nmsr/config.toml -dnsmasq/dnsmasq.conf # packwiz pack files rendered from pack/**/*.tmpl by tooling/render-pack.sh # (domain-dependent build output); source of truth is the .tmpl + custom/ jars. pack/mods/*.pw.toml pack/index.toml pack/CustomSkinLoader/CustomSkinLoader.json -# vendored / mirrored binaries -mirror/ +# vendored binaries +launcher/ pack/mods-files/ # landing page: generated build output + deps @@ -23,8 +22,5 @@ landing/node_modules/ landing/.astro/ landing/dist/ -# exported Caddy CA (per-deploy artifact) -caddy/caddy-root-ca.crt - # Let's Encrypt certs + private keys (issued by tooling/issue-letsencrypt.sh) certs/ diff --git a/README.md b/README.md index 8add3a2..40cadfc 100644 --- a/README.md +++ b/README.md @@ -1,99 +1,70 @@ # Ulicraft Server -Self-hosted modded Minecraft (NeoForge 1.21.1) for a LAN party. Runs **online** -(internet present) or **fully air-gapped** (offline). Self-hosted auth/skins -(Drasl), a packwiz modpack, a guest landing page, and a transparent mirror of -Mojang/launcher/NeoForge assets so guest laptops need no internet. +Self-hosted modded Minecraft (NeoForge 1.21.1) with self-hosted auth/skins +(Drasl), a packwiz modpack, avatar rendering, a guest landing page, and uptime +monitoring. One unified `docker-compose.yml` runs the whole stack behind the +host's nginx + Let's Encrypt. ## Stack +One compose file, one `docker compose up -d`: + | Service | Image | Role | |---|---|---| | `minecraft` | itzg/minecraft-server | NeoForge 1.21.1 server, `:25565` | | `drasl` | unmojang/drasl | Yggdrasil auth + skins (password login) | -| `caddy` | caddy:alpine | ingress: landing, auth/packwiz proxy, asset mirror (`tls internal`) | +| `caddy` | caddy:alpine | internal ingress: routes every vhost by Host header | +| `nmsr` | built from source | skin/avatar renderer at `avatar.${BASE_DOMAIN}` | +| `uptime-kuma` | louislam/uptime-kuma | status page at `status.${BASE_DOMAIN}` | | `mc-backup` | itzg/mc-backup | world backups every 6h via RCON | -Optional layers (each its own `docker-compose..yml`): -- `avahi` (mDNS `.local`), `dnsmasq` (turnkey DNS) — only if you don't run your - own DNS. -- `nmsr` (NickAcPT/nmsr-rs, built from source) — skin/avatar renderer at - `avatar.${BASE_DOMAIN}`, sourced from Drasl. See **Avatar renderer** below. -- `distribution` — extra static vhost at `distribution.${BASE_DOMAIN}` whose web - root (`DISTRIBUTION_WEB_ROOT`) lives in another repo. -- `status` (louislam/uptime-kuma) — uptime monitoring + public status page at - `status.${BASE_DOMAIN}`. See **Status monitoring** below. +Vhosts (all proxied through the host nginx → caddy): -Config lives in `.env`: `BASE_DOMAIN` (default `ulicraft.lan`), `HOST_LAN_IP`, -`RCON_PASSWORD`, `ENABLE_MDNS`. Services are subdomains: `auth.`, `packwiz.`, -`mc.`, plus the apex landing page. +- apex `${BASE_DOMAIN}` — landing page + `/launcher/` downloads +- `auth.` — Drasl +- `pack.` — packwiz metadata + `/custom/` jars +- `avatar.` — NMSR +- `status.` — Uptime Kuma +- `distribution.` — static site from another repo (`DISTRIBUTION_WEB_ROOT`) -## DNS (pick one) +Config lives in `.env`: `BASE_DOMAIN`, `RCON_PASSWORD`, `CADDY_HTTP_PORT` / +`CADDY_HTTP_BIND`, `DISTRIBUTION_WEB_ROOT`. See `.env.example`. -Names must resolve to `HOST_LAN_IP`. Use **your own party DNS server** (recommended): +## DNS -```sh -tooling/dns-records.sh online # service names only (internet present) -tooling/dns-records.sh airgap # + Mojang/launcher/NeoForge spoofs (offline) -``` -Paste the printed records into your DNS. **Use `.lan`, not `.local`** — `.local` -is intercepted by mDNS and won't resolve via unicast DNS. - -Alternatives: `ENABLE_MDNS=true` + `BASE_DOMAIN=*.local` (zero-config mDNS, -macOS/Linux native, Windows needs Bonjour) — but mDNS can't serve the air-gap -upstream spoofs. Or layer in `docker-compose.dnsmasq.yml` for a turnkey DNS. - -### Simulate the DNS via /etc/hosts (single machine, testing) - -No DNS server? Append the records straight to `/etc/hosts` (marker-wrapped): - -```sh -tooling/dns-records.sh online hosts | sudo tee -a /etc/hosts # services only -tooling/dns-records.sh airgap hosts | sudo tee -a /etc/hosts # + Mojang spoofs -``` - -Remove them later: - -```sh -sudo sed -i '/# >>> ulicraft/,/# <<< ulicraft/d' /etc/hosts -``` - -Notes: -- `/etc/hosts` has **no wildcard** — only the listed names resolve. -- `airgap` adds the Mojang/launcher/NeoForge hostnames pointing at the LAN; this - **breaks normal internet access to those domains** while present. Use it only - while offline; for an online box use `online`. -- `mc.` works (client defaults to :25565); SRV isn't used via `/etc/hosts`. +Handled **outside this repo**. Point `${BASE_DOMAIN}` and every subdomain +(`auth. pack. avatar. status. distribution. www.`) at the host running nginx. ## Prerequisites Docker + Docker Compose; for prep also `pnpm`, `packwiz`, `jq`, `curl`, -`envsubst`, `sha1sum`. +`envsubst`. ## Launch ```sh -cp .env.example .env # set BASE_DOMAIN, HOST_LAN_IP, RCON_PASSWORD +cp .env.example .env # set BASE_DOMAIN, RCON_PASSWORD, DISTRIBUTION_WEB_ROOT -tooling/build-stack.sh --prep # ONLINE, once: render, build landing, - # mirror mods+assets, fetch launcher + - # authlib, pre-bake the server volume +# One-time / on change — render configs + build content: +tooling/render-config.sh # drasl + nmsr + packwiz configs from *.tmpl +( cd landing && pnpm install && BASE_DOMAIN="$BASE_DOMAIN" pnpm run build ) # → www/ +tooling/fetch-launcher.sh # FjordLauncher assets → launcher/ (served at /launcher/) +# authlib-injector.jar → runtime/ (server JVM agent; see drasl docs) -tooling/build-stack.sh --up online # internet present, no mirror -tooling/build-stack.sh --up airgap # offline; full asset mirror on :443 (default) -tooling/build-stack.sh --up core # base only (debugging) +docker compose up -d --build # first run installs NeoForge + mods, builds nmsr +docker compose down # stop ``` -`--up` prints the DNS records to add for that mode. Down: -`docker compose -f docker-compose.yml -f docker-compose.static.yml -f docker-compose.mirror.yml down` +First boot: itzg installs NeoForge + the packwiz mods into the `mc_data` volume; +nmsr does a one-time Rust compile. Watch `docker compose logs -f minecraft` until +`Done`. ## Public TLS (host nginx in front of caddy) -The LAN path above runs caddy on `:80`. To expose the stack publicly with real -certs, the machine's own nginx terminates TLS and reverse-proxies every vhost to -caddy by Host header. Caddy stays the single router (apex landing, `www.` → -apex, `auth.` → drasl, `pack.` packwiz, `distribution.` static, `avatar.` → -nmsr). HTTPS-only: HTTP 301s to HTTPS and every TLS vhost sends HSTS. +caddy is published on a localhost-only port (`CADDY_HTTP_BIND=127.0.0.1`, +`CADDY_HTTP_PORT=8880`). The host's own nginx terminates TLS with Let's Encrypt +certs and reverse-proxies every vhost to caddy by Host header. HTTPS-only: HTTP +301s to HTTPS and every TLS vhost sends HSTS. 1. **Issue certs** (OVH DNS-01, no inbound ports needed): ```sh @@ -102,31 +73,15 @@ nmsr). HTTPS-only: HTTP 301s to HTTPS and every TLS vhost sends HSTS. tooling/issue-letsencrypt.sh # → certs//{cert,key}.pem ``` -2. **Bind caddy to a localhost-only port** so only nginx reaches it (`.env`): - ```sh - CADDY_HTTP_PORT=8880 - CADDY_HTTP_BIND=127.0.0.1 - ``` - -3. **Bring up the caddy ingress** (+ static, + distribution/nmsr if used): - ```sh - docker compose -f docker-compose.yml -f docker-compose.caddy.yml \ - -f docker-compose.static.yml -f docker-compose.distribution.yml \ - -f docker-compose.nmsr.yml -f docker-compose.status.yml up -d --build - ``` - -4. **Render + install the nginx vhost** with `tooling/render-nginx.sh`. It pulls +2. **Render + install the nginx vhost** with `tooling/render-nginx.sh`. It pulls `BASE_DOMAIN` + `CADDY_HTTP_PORT` from `.env`, defaults `APP_DIR` to the repo checkout (where `certs/` live), and renders `nginx/ulicraft-caddy.conf.tmpl`: ```sh tooling/render-nginx.sh # preview to stdout (dry run) tooling/render-nginx.sh --install # write, symlink, nginx -t, reload ``` - Overrides via env: `APP_DIR=/path` (if the repo isn't at the cert location), - `SITE_NAME=foo.conf`, `TEMPLATE=nginx/ulicraft.conf.tmpl` (the static variant — - nginx serves the files itself + proxies only drasl; pick one, not both). - -Re-run step 4 whenever you add a subdomain so its server block is rendered. + Override `APP_DIR=/path` if the repo isn't at the cert location. Re-run + whenever you add a subdomain so its server block is rendered. ## Avatar renderer (NMSR) @@ -140,40 +95,25 @@ pins a commit; bump `ARG NMSR_REF` to update. `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` (Drasl issues offline v3 UUIDs). -- **Network**: skin resolution stays internal over `mcnet` (the caddy alias - `auth.${BASE_DOMAIN}` → drasl) — no public round-trip. caddy reverse-proxies - `avatar.` → `nmsr:8080`. The host port is published on `127.0.0.1:9898` only - (local debugging; not reachable from outside). -- **Endpoints** (NMSR): e.g. `https://avatar.${BASE_DOMAIN}/skin/`, +- **Network**: skin resolution stays internal over `mcnet` (caddy alias + `auth.${BASE_DOMAIN}` → drasl). caddy reverse-proxies `avatar.` → `nmsr:8080`. +- **Endpoints**: e.g. `https://avatar.${BASE_DOMAIN}/skin/`, `/face/`, `/fullbody/` — by username or UUID. -Bring up: add `-f docker-compose.nmsr.yml` (with `--build`) as shown above. The -first build is heavy (Rust compile). Headless rendering uses lavapipe (software +First build is heavy (Rust compile). Headless rendering uses lavapipe (software Vulkan); if renders fail, check `docker logs nmsr` for Vulkan device init. -`distribution.${BASE_DOMAIN}` serves a static site whose web root lives in -another repo: set `DISTRIBUTION_WEB_ROOT` (absolute host path) in `.env`; it's -bind-mounted read-only via `docker-compose.distribution.yml`. - ## Status monitoring (Uptime Kuma) `status.${BASE_DOMAIN}` runs [Uptime Kuma](https://github.com/louislam/uptime-kuma) — uptime probes for every vhost **and** a native Minecraft-protocol ping (player -count + up/down), plus a public status page. The container joins `mcnet`, so it -can probe the stack by internal service name without leaving the host. caddy -reverse-proxies `status.` → `uptime-kuma:3001`; the host port is published on -`127.0.0.1:3001` only (first-run admin setup / local debugging). +count + up/down), plus a public status page. It joins `mcnet`, so monitors can +probe the stack by internal service name. caddy reverse-proxies `status.` → +`uptime-kuma:3001`. -Bring up (layer on the caddy ingress): - -```sh -docker compose -f docker-compose.yml -f docker-compose.caddy.yml \ - -f docker-compose.status.yml up -d -``` - -Then open `https://status.${BASE_DOMAIN}` (or `http://127.0.0.1:3001` first run), -create the admin account, and add monitors. Kuma has no config-as-code — add -these once via the UI (data persists in the `kuma_data` volume): +First run, open `https://status.${BASE_DOMAIN}`, create the admin account, and +add monitors. Kuma has no config-as-code — add these once via the UI (data +persists in the `kuma_data` volume): | Monitor | Type | Target | |---|---|---| @@ -185,42 +125,35 @@ these once via the UI (data persists in the `kuma_data` volume): | Avatar (NMSR) | HTTP(s) | `https://avatar.${BASE_DOMAIN}` | Internal-name targets (`minecraft`, `drasl:25585`, `nmsr:8080`) isolate "service -down" from "ingress/TLS/DNS down"; public-URL targets test the whole chain. Mix -as you like. `status` is in the default `LE_SUBDOMAINS` so its TLS cert issues -with the rest; the nginx vhost adds websocket upgrade headers for Kuma's live UI. +down" from "ingress/TLS/DNS down"; public-URL targets test the whole chain. +`status` is in the default `LE_SUBDOMAINS`; the nginx vhost adds websocket +upgrade headers for Kuma's live UI. ## Join (guests) -1. Open `http://ulicraft.lan`, download FjordLauncherUnlocked for your OS. -2. Add an **authlib-injector** account, URL `http://auth.ulicraft.lan/authlib-injector` - (register/login at `http://auth.ulicraft.lan`). -3. **Air-gap only:** trust the local CA — download `http://ulicraft.lan/ca.crt` - and add it to your OS trust store (so the HTTPS asset mirror is trusted). -4. Import the pack: `http://packwiz.ulicraft.lan/pack.toml`. -5. Connect to **`mc.ulicraft.lan`** (no port needed). +1. Open `https://${BASE_DOMAIN}`, download FjordLauncherUnlocked for your OS. +2. Add an **authlib-injector** account, URL + `https://auth.${BASE_DOMAIN}/authlib-injector` (register/login at + `https://auth.${BASE_DOMAIN}`). +3. Import the pack: `https://pack.${BASE_DOMAIN}/pack.toml`. +4. Connect to **`${BASE_DOMAIN}`** (Minecraft, `:25565`). ## Layout ``` -docker-compose.yml # core: drasl, minecraft, mc-backup, caddy - .static.yml .mirror.yml # online/airgap layers - .caddy.yml .nginx.yml # ingress paths (caddy-front vs nginx static) - .nmsr.yml .distribution.yml # avatar renderer / extra static vhost - .avahi.yml .dnsmasq.yml # optional DNS layers -caddy/Caddyfile + conf.d/*.caddy # ingress vhost snippets (core/static/mirror/ - # distribution/avatar) +docker-compose.yml # the whole stack (one file) +caddy/Caddyfile + conf.d/*.caddy # ingress vhost snippets + # (core/static/distribution/avatar/status) drasl/config/config.toml.tmpl # auth config (rendered) nmsr/config.toml.tmpl # avatar renderer config, Drasl-backed (rendered) -dnsmasq/dnsmasq.conf.tmpl # optional DNS config (rendered) -docker/avahi/ docker/nmsr/ # built-from-source images (mDNS, NMSR) +docker/nmsr/ # built-from-source NMSR image pack/ # packwiz modpack source landing/ # Astro site → www/ -tooling/ # build-stack, render-config, render-nginx, - # dns-records, issue-letsencrypt, - # mirror-mods, mirror-airgap, fetch-launcher -nginx/*.conf.tmpl # host-nginx TLS vhost templates -mirror/ # vendored jars + launcher + asset mirror (gitignored) -plan/ # full design docs (00–12) +launcher/ # FjordLauncher assets (gitignored) +tooling/ # render-config, render-pack, render-nginx, + # issue-letsencrypt, fetch-launcher, add-custom-mod +nginx/ulicraft-caddy.conf.tmpl # host-nginx TLS vhost template (front of caddy) +plan/ # design docs ``` See `plan/00-overview.md` for the full design. diff --git a/caddy/Caddyfile b/caddy/Caddyfile index 0a51b44..eba7263 100644 --- a/caddy/Caddyfile +++ b/caddy/Caddyfile @@ -1,10 +1,12 @@ -# Base Caddy config. Vhosts live in conf.d/*.caddy snippets that are mounted -# in selectively so the stack can run with or without the static site / mirror: -# conf.d/00-core.caddy auth + packwiz (always — core ingress) -# conf.d/10-static.caddy apex landing + launcher downloads (static toggle) -# conf.d/20-mirror.caddy air-gap upstream mirror (tls internal) (mirror toggle) -# auto_https disable_redirects: http:// sites stay plaintext on :80; the mirror -# snippet's hosts still get internal TLS on :443 when that snippet is present. +# Base Caddy config. Vhosts live in conf.d/*.caddy snippets: +# conf.d/00-core.caddy auth + packwiz +# conf.d/10-static.caddy apex landing + launcher downloads +# conf.d/30-distribution.caddy static site from another repo +# conf.d/40-avatar.caddy nmsr skin/avatar renderer +# conf.d/50-status.caddy uptime-kuma status page +# All snippets are plain http:// on :80 — the host's nginx terminates TLS in +# front (nginx/ulicraft-caddy.conf.tmpl). disable_redirects keeps caddy from +# upgrading to its own https. { auto_https disable_redirects } diff --git a/caddy/conf.d/00-core-blessingskin.caddy b/caddy/conf.d/00-core-blessingskin.caddy deleted file mode 100644 index 9b363bd..0000000 --- a/caddy/conf.d/00-core-blessingskin.caddy +++ /dev/null @@ -1,14 +0,0 @@ -# Core ingress — Blessing Skin variant. Replaces 00-core.caddy when the -# docker-compose.blessingskin.yml override is in play (mounted at the same -# target path). auth.* now points at Blessing Skin instead of Drasl. -# -# Blessing Skin serves BOTH its web UI (/) and the Yggdrasil API (/api/yggdrasil) -# on the same vhost, so a single reverse_proxy covers everything. -http://auth.{$BASE_DOMAIN} { - reverse_proxy blessing-skin:80 -} - -http://pack.{$BASE_DOMAIN} { - root * /srv/pack - file_server browse -} diff --git a/caddy/conf.d/10-static.caddy b/caddy/conf.d/10-static.caddy index 2f87d44..b4e7e73 100644 --- a/caddy/conf.d/10-static.caddy +++ b/caddy/conf.d/10-static.caddy @@ -1,13 +1,5 @@ -# Static toggle — apex landing page + launcher downloads. -# Mounted only when the stack runs with static files (build-stack.sh up static|full). +# Apex — landing page (/srv/www) + launcher downloads (/srv/launcher). http://{$BASE_DOMAIN} { - # Caddy's local CA root, so guests can trust the air-gap HTTPS mirror. - # Mounted from ./caddy/caddy-root-ca.crt (exported by build-stack prep; - # the live pki dir is root-only 0600 and can't be served directly). - handle /ca.crt { - root * /srv/ca-pub - file_server - } # Launcher downloads are a sibling mount (/srv/launcher), not nested under # the read-only /srv/www. handle_path strips the /launcher prefix. handle_path /launcher/* { diff --git a/caddy/conf.d/20-mirror.caddy b/caddy/conf.d/20-mirror.caddy deleted file mode 100644 index f67b9b4..0000000 --- a/caddy/conf.d/20-mirror.caddy +++ /dev/null @@ -1,9 +0,0 @@ -# Mirror toggle — full client air-gap mirror (see plan/11-full-airgap-mirror.md). -# Byte-exact copies of the real upstream hosts over HTTPS with Caddy's local CA. -# dnsmasq spoofs these hostnames -> our host; {host} roots each at its subtree. -# Mounted only when the stack runs with the mirror (build-stack.sh up mirror|full). -meta.prismlauncher.org, piston-meta.mojang.com, piston-data.mojang.com, libraries.minecraft.net, maven.neoforged.net, resources.download.minecraft.net { - tls internal - root * /srv/mirror/{host} - file_server -} diff --git a/caddy/conf.d/30-distribution.caddy b/caddy/conf.d/30-distribution.caddy index a17b359..55af256 100644 --- a/caddy/conf.d/30-distribution.caddy +++ b/caddy/conf.d/30-distribution.caddy @@ -1,7 +1,6 @@ -# Distribution toggle — static site served from a web root in ANOTHER repo. -# The host path is set by DISTRIBUTION_WEB_ROOT in .env and bind-mounted to -# /srv/distribution by docker-compose.caddy.yml. Mounted only when that var is -# set (see the distribution override). +# Distribution — static site served from a web root in ANOTHER repo. The host +# path is set by DISTRIBUTION_WEB_ROOT in .env and bind-mounted to +# /srv/distribution by the caddy service in docker-compose.yml. http://distribution.{$BASE_DOMAIN} { root * /srv/distribution file_server browse diff --git a/caddy/conf.d/40-avatar.caddy b/caddy/conf.d/40-avatar.caddy index 078ee29..86de416 100644 --- a/caddy/conf.d/40-avatar.caddy +++ b/caddy/conf.d/40-avatar.caddy @@ -1,5 +1,4 @@ -# Avatar toggle — NMSR skin/avatar renderer (docker-compose.nmsr.yml). -# Mounted only when the nmsr override is present. +# Avatar — NMSR skin/avatar renderer (nmsr service), Drasl-backed. http://avatar.{$BASE_DOMAIN} { reverse_proxy nmsr:8080 } diff --git a/caddy/conf.d/50-status.caddy b/caddy/conf.d/50-status.caddy index fd9f55d..aedc2f0 100644 --- a/caddy/conf.d/50-status.caddy +++ b/caddy/conf.d/50-status.caddy @@ -1,5 +1,4 @@ -# Status toggle — Uptime Kuma monitoring + public status page -# (docker-compose.status.yml). Mounted only when the status override is present. +# Status — Uptime Kuma monitoring + public status page (uptime-kuma service). http://status.{$BASE_DOMAIN} { reverse_proxy uptime-kuma:3001 } diff --git a/dnsmasq/dnsmasq.conf.tmpl b/dnsmasq/dnsmasq.conf.tmpl deleted file mode 100644 index 2ed0f7d..0000000 --- a/dnsmasq/dnsmasq.conf.tmpl +++ /dev/null @@ -1,21 +0,0 @@ -# Resolve every *.${BASE_DOMAIN} (and the apex) to the Docker host LAN IP. -# Rendered by tooling/render-config.sh -> dnsmasq/dnsmasq.conf (gitignored). -address=/${BASE_DOMAIN}/${HOST_LAN_IP} -no-resolv - -# Minecraft SRV record: guests connect to "mc.${BASE_DOMAIN}" with NO port — -# the SRV lookup points the client at port 25565. Target mc.${BASE_DOMAIN} -# resolves to HOST_LAN_IP via the wildcard above. -# Format: srv-host=_service._proto.name,target,port -srv-host=_minecraft._tcp.mc.${BASE_DOMAIN},mc.${BASE_DOMAIN},25565 - -# Full client air-gap (see plan/11-full-airgap-mirror.md): spoof the real -# upstream hosts -> our Caddy mirror. Auth hosts (session/textures/api.mojang) -# are NOT spoofed — drasl handles those via authlib-injector. Keep this list in -# sync with the proxy-capture step. -address=/meta.prismlauncher.org/${HOST_LAN_IP} -address=/piston-meta.mojang.com/${HOST_LAN_IP} -address=/piston-data.mojang.com/${HOST_LAN_IP} -address=/libraries.minecraft.net/${HOST_LAN_IP} -address=/maven.neoforged.net/${HOST_LAN_IP} -address=/resources.download.minecraft.net/${HOST_LAN_IP} diff --git a/docker-compose.avahi.yml b/docker-compose.avahi.yml deleted file mode 100644 index 690f01e..0000000 --- a/docker-compose.avahi.yml +++ /dev/null @@ -1,28 +0,0 @@ -# mDNS toggle — publish *.local service names via the HOST's avahi-daemon for -# zero-config guest resolution. Enabled when ENABLE_MDNS=true in .env and only -# meaningful when BASE_DOMAIN=*.local. -# -# Host prerequisites: -# - avahi-daemon running on the host -# - if the host has many interfaces (e.g. lots of docker bridges), restrict -# avahi to the LAN NIC in /etc/avahi/avahi-daemon.conf: -# allow-interfaces= -# otherwise avahi sees its own announcements across bridges → "Local name -# collision" and publishing fails. -services: - avahi: - build: ./docker/avahi - image: ulicraft-avahi:local - container_name: avahi - restart: unless-stopped - network_mode: host - # AppArmor's docker-default profile blocks the D-Bus publish to host avahi. - security_opt: - - apparmor=unconfined - environment: - BASE_DOMAIN: ${BASE_DOMAIN} - HOST_LAN_IP: ${HOST_LAN_IP} - DBUS_SYSTEM_BUS_ADDRESS: "unix:path=/run/dbus/system_bus_socket" - volumes: - # Talk to the host's avahi-daemon (host must run avahi-daemon). - - /run/dbus/system_bus_socket:/run/dbus/system_bus_socket diff --git a/docker-compose.blessingskin.yml b/docker-compose.blessingskin.yml deleted file mode 100644 index b6d1633..0000000 --- a/docker-compose.blessingskin.yml +++ /dev/null @@ -1,105 +0,0 @@ -# ────────────────────────────────────────────────────────────────── -# Blessing Skin auth variant — use INSTEAD of Drasl. -# ────────────────────────────────────────────────────────────────── -# Layer this over the base file AND the caddy ingress (caddy lives in its own -# file now; this override only swaps caddy's core conf, so caddy.yml is required): -# -# docker compose -f docker-compose.yml -f docker-compose.caddy.yml \ -# -f docker-compose.blessingskin.yml up -d -# -# What it does vs the base stack: -# - drasl → left running but UNUSED + unreachable (Caddy no longer -# routes to it; it has no host port). Compose merges -# depends_on, so it can't be removed from an override — -# idling it is the clean option. `docker compose ... stop -# drasl` after up if you want it down. -# - caddy → auth.* now reverse-proxies blessing-skin:80 -# - mariadb → new; Blessing Skin's datastore (no SQLite support) -# - blessing-skin → new; PHP skin server + yggdrasil-api plugin -# - minecraft → authlib-injector now points at /api/yggdrasil, -# ONLINE_MODE=TRUE (real session validation against BSS) -# -# First-run is a WEB WIZARD — see plan/03b-blessing-skin.md. The yggdrasil-api -# plugin must be installed + enabled from the BSS admin panel before the -# /api/yggdrasil endpoint (and thus Minecraft login) works. -# -# DB creds + APP_KEY come from .env (see .env.example: BS_* vars). -# ────────────────────────────────────────────────────────────────── - -services: - caddy: - # Swap the core ingress conf for the Blessing Skin one. Compose MERGES - # volumes by container path, so mounting the BSS conf at the SAME target - # (/etc/caddy/conf.d/00-core.caddy) shadows the base 00-core.caddy; all - # other base mounts (pack, custom, static, ca) are kept automatically. - volumes: - - ./caddy/conf.d/00-core-blessingskin.caddy:/etc/caddy/conf.d/00-core.caddy:ro - depends_on: - - blessing-skin - - mariadb: - image: mariadb:11 - container_name: mariadb - restart: unless-stopped - environment: - MARIADB_DATABASE: ${BS_DB_NAME} - MARIADB_USER: ${BS_DB_USER} - MARIADB_PASSWORD: ${BS_DB_PASSWORD} - MARIADB_ROOT_PASSWORD: ${BS_DB_ROOT_PASSWORD} - TZ: "Europe/Madrid" - volumes: - - bs_db:/var/lib/mysql - healthcheck: - test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] - interval: 10s - timeout: 5s - retries: 10 - networks: - - mcnet - - blessing-skin: - image: azusamikan/blessing-skin-server-docker:latest - container_name: blessing-skin - restart: unless-stopped - # Internal-only — reached via Caddy at auth.${BASE_DOMAIN}. - environment: - APP_URL: "http://auth.${BASE_DOMAIN}" - # Persist APP_KEY across recreates (else sessions/encryption break). - # Generate once: docker run --rm azusamikan/blessing-skin-server-docker:latest php artisan key:generate --show - APP_KEY: ${BS_APP_KEY} - DB_DRIVER: "mysql" - DB_HOST: "mariadb" - DB_PORT: "3306" - DB_DATABASE: ${BS_DB_NAME} - DB_USERNAME: ${BS_DB_USER} - DB_PASSWORD: ${BS_DB_PASSWORD} - TZ: "Europe/Madrid" - volumes: - - bs_storage:/app/storage # uploaded skins/capes + textures - - bs_plugins:/app/plugins # yggdrasil-api plugin lives here - depends_on: - mariadb: - condition: service_healthy - networks: - - mcnet - - minecraft: - environment: - # authlib-injector API root for Blessing Skin's yggdrasil-api plugin - # is /api/yggdrasil (NOT /authlib-injector like Drasl). - JVM_OPTS: "-javaagent:/extras/authlib-injector.jar=http://auth.${BASE_DOMAIN}/api/yggdrasil" - # authlib-injector needs real auth: online-mode TRUE so the server - # validates sessions against Blessing Skin. (Drasl variant used FALSE.) - ONLINE_MODE: "TRUE" - # Keep FALSE for safety on 1.21+. The yggdrasil-api plugin DOES sign - # profile keys, so you MAY flip this to TRUE if signed chat is wanted. - ENFORCE_SECURE_PROFILE: "FALSE" - # Merged with the base depends_on (drasl, caddy); just adds blessing-skin so - # Minecraft starts after the skin server is up. - depends_on: - - blessing-skin - -volumes: - bs_db: - bs_storage: - bs_plugins: diff --git a/docker-compose.caddy.yml b/docker-compose.caddy.yml deleted file mode 100644 index 0216e6c..0000000 --- a/docker-compose.caddy.yml +++ /dev/null @@ -1,50 +0,0 @@ -# ────────────────────────────────────────────────────────────────── -# Caddy ingress — LAN / air-gap path. NOT started with the base stack. -# ────────────────────────────────────────────────────────────────── -# Caddy was moved out of docker-compose.yml so the base stack can run behind -# the host's nginx (production) without it. build-stack.sh adds this file for -# its online/airgap/core modes; static/mirror overrides mount extra vhosts in. -# -# docker compose -f docker-compose.yml -f docker-compose.caddy.yml up -d -# -# Holds the mcnet aliases (auth./pack.${BASE_DOMAIN}) so containers resolve the -# stack's own names internally — this is why minecraft can reach the pack here -# without extra_hosts (the nginx path uses extra_hosts instead). -services: - caddy: - image: caddy:alpine - container_name: caddy - restart: unless-stopped - # Host-published port. Default 80 for the standalone LAN/air-gap path; set - # CADDY_HTTP_PORT to a high localhost-only port when the machine's nginx - # fronts caddy (nginx terminates TLS, reverse-proxies here by Host header — - # see nginx/ulicraft-caddy.conf.tmpl). - ports: - - "${CADDY_HTTP_BIND:-0.0.0.0}:${CADDY_HTTP_PORT:-80}:80" - environment: - BASE_DOMAIN: ${BASE_DOMAIN} - volumes: - - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro - - ./caddy/conf.d/00-core.caddy:/etc/caddy/conf.d/00-core.caddy:ro - - ./caddy/conf.d/10-static.caddy:/etc/caddy/conf.d/10-static.caddy:ro - - ./www:/srv/www:ro - - ./mirror/launcher:/srv/launcher:ro - - ./caddy/caddy-root-ca.crt:/srv/ca-pub/ca.crt:ro - - ./pack:/srv/pack:ro - # Custom (locally-hosted) mod jars live OUTSIDE ./pack so packwiz refresh - # doesn't index them as direct files. Served at pack.${BASE_DOMAIN}/custom/. - - ./custom:/srv/pack/custom:ro - networks: - mcnet: - # The upstream spoof-host aliases live in docker-compose.mirror.yml — - # adding them here would hijack maven.neoforged.net etc. for ALL mcnet - # containers, breaking the ONLINE NeoForge install during pre-bake. - aliases: - - "auth.${BASE_DOMAIN}" - - "pack.${BASE_DOMAIN}" - - # Restore the ordering the base file used to have (base drops it so it can run - # caddy-less behind nginx). - minecraft: - depends_on: - - caddy diff --git a/docker-compose.distribution.yml b/docker-compose.distribution.yml deleted file mode 100644 index b1fb5cc..0000000 --- a/docker-compose.distribution.yml +++ /dev/null @@ -1,15 +0,0 @@ -# ────────────────────────────────────────────────────────────────── -# Distribution vhost — distribution.${BASE_DOMAIN}, static files from a web -# root that lives in ANOTHER repo. Layered on top of the caddy ingress. -# ────────────────────────────────────────────────────────────────── -# docker compose -f docker-compose.yml -f docker-compose.caddy.yml \ -# -f docker-compose.distribution.yml up -d -# -# DISTRIBUTION_WEB_ROOT (.env) = absolute host path to the built static site in -# the other repo. Bind-mounted read-only; caddy serves it via the -# conf.d/30-distribution.caddy snippet. -services: - caddy: - volumes: - - ./caddy/conf.d/30-distribution.caddy:/etc/caddy/conf.d/30-distribution.caddy:ro - - ${DISTRIBUTION_WEB_ROOT:?DISTRIBUTION_WEB_ROOT unset in .env}:/srv/distribution:ro diff --git a/docker-compose.dnsmasq.yml b/docker-compose.dnsmasq.yml deleted file mode 100644 index 99005c1..0000000 --- a/docker-compose.dnsmasq.yml +++ /dev/null @@ -1,20 +0,0 @@ -# Optional turnkey DNS — only if you DON'T run your own party DNS server. -# Layer on: `docker compose -f docker-compose.yml -f docker-compose.dnsmasq.yml ... up -d`. -# Serves dnsmasq/dnsmasq.conf (rendered from the template, includes the airgap -# spoof records). If you have your own DNS, skip this and use the records from -# `tooling/dns-records.sh` instead. -services: - dnsmasq: - image: jpillora/dnsmasq - container_name: dnsmasq - restart: unless-stopped - ports: - # Bind DNS to the host LAN IP so it doesn't clash with systemd-resolved on :53. - - "${HOST_LAN_IP}:53:53/udp" - - "${HOST_LAN_IP}:53:53/tcp" - volumes: - - ./dnsmasq/dnsmasq.conf:/etc/dnsmasq.conf:ro - networks: - - mcnet - # webproc UI (:8080) can be exposed behind Caddy as dns.${BASE_DOMAIN}; - # guard with HTTP_USER / HTTP_PASS. diff --git a/docker-compose.mirror.yml b/docker-compose.mirror.yml deleted file mode 100644 index eb12abe..0000000 --- a/docker-compose.mirror.yml +++ /dev/null @@ -1,25 +0,0 @@ -# Mirror toggle — full client air-gap upstream mirror (see plan/11). -# Layer on top of the base: `docker compose -f docker-compose.yml -f docker-compose.mirror.yml up -d` -# (or `tooling/build-stack.sh --up mirror`). Adds the mirror vhost snippet, the -# upstream content mount, and publishes :443 for Caddy's `tls internal` certs. -# Populate the mirror first: tooling/mirror-airgap.sh (+ the proxy-capture step). -services: - caddy: - ports: - - "443:443" - volumes: - - ./caddy/conf.d/20-mirror.caddy:/etc/caddy/conf.d/20-mirror.caddy:ro - - ./mirror/upstream:/srv/mirror:ro - networks: - mcnet: - # Spoof the real upstream hosts -> Caddy (air-gap runtime only). Includes - # the base aliases because compose replaces (not merges) the alias list. - aliases: - - "auth.${BASE_DOMAIN}" - - "packwiz.${BASE_DOMAIN}" - - "meta.prismlauncher.org" - - "piston-meta.mojang.com" - - "piston-data.mojang.com" - - "libraries.minecraft.net" - - "maven.neoforged.net" - - "resources.download.minecraft.net" diff --git a/docker-compose.nginx.yml b/docker-compose.nginx.yml deleted file mode 100644 index 6ad66cb..0000000 --- a/docker-compose.nginx.yml +++ /dev/null @@ -1,30 +0,0 @@ -# ────────────────────────────────────────────────────────────────── -# Production ingress = the HOST's nginx + Let's Encrypt. No caddy. -# ────────────────────────────────────────────────────────────────── -# docker compose -f docker-compose.yml -f docker-compose.nginx.yml up -d -# -# nginx (on the host, already installed) terminates TLS with the LE certs from -# tooling/issue-letsencrypt.sh and: -# - serves apex + pack.${BASE_DOMAIN} as static files (./www, ./pack, ./custom) -# - reverse-proxies auth.${BASE_DOMAIN} -> 127.0.0.1:25585 (drasl) -# See nginx/ulicraft.conf.tmpl. -# -# This override: -# - publishes drasl on localhost so the host nginx can reach it -# - points the minecraft container at the host (extra_hosts) over HTTPS, so it -# fetches the pack and validates sessions through the same public names the -# LE certs cover (JVM trusts Let's Encrypt out of the box) -services: - drasl: - ports: - - "127.0.0.1:25585:25585" - - minecraft: - # Make the public names resolve to the host running nginx. - extra_hosts: - - "auth.${BASE_DOMAIN}:host-gateway" - - "pack.${BASE_DOMAIN}:host-gateway" - environment: - # Use HTTPS now that nginx terminates TLS with a publicly-trusted cert. - JVM_OPTS: "-javaagent:/extras/authlib-injector.jar=https://auth.${BASE_DOMAIN}/authlib-injector" - PACKWIZ_URL: "https://pack.${BASE_DOMAIN}/pack.toml" diff --git a/docker-compose.nmsr.yml b/docker-compose.nmsr.yml deleted file mode 100644 index 2f7bdb4..0000000 --- a/docker-compose.nmsr.yml +++ /dev/null @@ -1,32 +0,0 @@ -# ────────────────────────────────────────────────────────────────── -# NMSR-aas — avatar/skin renderer at avatar.${BASE_DOMAIN}. -# ────────────────────────────────────────────────────────────────── -# docker compose -f docker-compose.yml -f docker-compose.caddy.yml \ -# -f docker-compose.nmsr.yml up -d --build -# -# Built from source (no upstream image) — see docker/nmsr/Dockerfile. -# Renders YOUR players' skins by pulling profiles from Drasl over the internal -# mcnet network (config: nmsr/config.toml, rendered from .tmpl). -# -# Caddy reverse-proxies avatar.${BASE_DOMAIN} -> nmsr:8080 internally. The host -# port is published on 127.0.0.1:9898 ONLY (local debugging / not reachable from -# outside) — public access goes through nginx -> caddy. -services: - # Mount the avatar vhost snippet into caddy (the caddy override owns the base - # config; this adds one more conf.d file like the distribution override does). - caddy: - volumes: - - ./caddy/conf.d/40-avatar.caddy:/etc/caddy/conf.d/40-avatar.caddy:ro - - nmsr: - build: - context: ./docker/nmsr - image: ulicraft/nmsr:local - container_name: nmsr - restart: unless-stopped - ports: - - "127.0.0.1:9898:8080" - volumes: - - ./nmsr/config.toml:/nmsr/config.toml:ro - networks: - - mcnet diff --git a/docker-compose.static.yml b/docker-compose.static.yml deleted file mode 100644 index 97f503d..0000000 --- a/docker-compose.static.yml +++ /dev/null @@ -1,11 +0,0 @@ -# Static toggle — apex landing page + launcher downloads. -# Layer on top of the base: `docker compose -f docker-compose.yml -f docker-compose.static.yml up -d` -# (or `tooling/build-stack.sh --up static`). Adds the static vhost snippet plus -# the www + launcher content mounts. Build www first: cd landing && pnpm run build. -services: - caddy: - volumes: - - ./caddy/conf.d/10-static.caddy:/etc/caddy/conf.d/10-static.caddy:ro - - ./www:/srv/www:ro - - ./mirror/launcher:/srv/launcher:ro - - ./caddy/caddy-root-ca.crt:/srv/ca-pub/ca.crt:ro diff --git a/docker-compose.status.yml b/docker-compose.status.yml deleted file mode 100644 index 9d04298..0000000 --- a/docker-compose.status.yml +++ /dev/null @@ -1,36 +0,0 @@ -# ────────────────────────────────────────────────────────────────── -# Uptime Kuma — status/monitoring for every vhost + the Minecraft server, -# reachable at status.${BASE_DOMAIN}. -# ────────────────────────────────────────────────────────────────── -# docker compose -f docker-compose.yml -f docker-compose.caddy.yml \ -# -f docker-compose.status.yml up -d -# -# Joins mcnet so monitors can probe the stack BY ITS INTERNAL SERVICE NAME -# (drasl:25585, nmsr:8080, minecraft:25565) without leaving the host — and can -# also hit the public https vhosts end-to-end through the host's DNS. -# -# Caddy reverse-proxies status.${BASE_DOMAIN} -> uptime-kuma:3001 internally -# (caddy/conf.d/50-status.caddy). The host port is published on 127.0.0.1:3001 -# ONLY (local debugging / first-run admin setup) — public access goes through -# nginx -> caddy. Add status.${BASE_DOMAIN} to LE_SUBDOMAINS for its TLS cert. -# -# Monitors are stored in the kuma_data volume (Kuma has no config-as-code); -# add them once via the web UI — see README for the recommended monitor list. -services: - caddy: - volumes: - - ./caddy/conf.d/50-status.caddy:/etc/caddy/conf.d/50-status.caddy:ro - - uptime-kuma: - image: louislam/uptime-kuma:1 - container_name: uptime-kuma - restart: unless-stopped - ports: - - "127.0.0.1:3001:3001" - volumes: - - kuma_data:/app/data - networks: - - mcnet - -volumes: - kuma_data: diff --git a/docker-compose.yml b/docker-compose.yml index eaa0da4..8dd2018 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,31 +1,31 @@ # ────────────────────────────────────────────────────────────────── # Ulicraft LAN-party stack — Minecraft 1.21.1 NeoForge + Drasl auth # ────────────────────────────────────────────────────────────────── -# Single source of config: BASE_DOMAIN + HOST_LAN_IP in .env. -# DNS is provided by YOUR party DNS server — see tooling/dns-records.sh for the -# records to add (online vs airgap). A turnkey dnsmasq is available as an -# optional layer: docker-compose.dnsmasq.yml. -# - ingress is layered on, NOT in this base file: caddy (docker-compose.caddy.yml, -# LAN/air-gap, holds mcnet aliases) OR the host's nginx (docker-compose.nginx.yml -# + Let's Encrypt, production) -# - drasl handles auth (password login; NO Keycloak/OIDC in this stack) -# - packwiz pack is served by the ingress at pack.${BASE_DOMAIN} -# - minecraft installs mods via PACKWIZ_URL, authlib-injector -> auth. +# Single, unified compose. One file holds the whole stack: +# drasl auth + skins (Yggdrasil), internal only +# minecraft the server (itzg/NEOFORGE), installs mods via packwiz +# mc-backup world backups every 6h via RCON +# caddy internal ingress — routes auth./pack./apex/launcher/avatar. +# /distribution. by Host header (conf.d/*.caddy) +# nmsr skin/avatar renderer at avatar.${BASE_DOMAIN} +# uptime-kuma status monitoring at status.${BASE_DOMAIN} +# +# Bring up: docker compose up -d (render configs first: tooling/render-config.sh) +# +# DNS is handled OUTSIDE this repo — point every service name at the host. +# Production TLS terminates at the HOST's nginx in front of caddy +# (nginx/ulicraft-caddy.conf.tmpl + Let's Encrypt); caddy is published on a +# localhost-only port (CADDY_HTTP_BIND/CADDY_HTTP_PORT in .env). Containers reach +# the stack's own names internally via caddy's mcnet aliases (auth./pack.). # ────────────────────────────────────────────────────────────────── services: - # NOTE: the HTTP(S) ingress is NOT in this base file. - # - LAN / air-gap: caddy (docker-compose.caddy.yml) — added by build-stack.sh - # - production: the HOST's nginx + Let's Encrypt (docker-compose.nginx.yml - # publishes drasl to localhost; nginx/ulicraft.conf.tmpl) - # Base alone (drasl + minecraft + mc-backup) is not a complete deployment — - # always layer one ingress file on top. drasl: image: unmojang/drasl:latest container_name: drasl restart: unless-stopped - # Internal-only: no published host port. Reached via Caddy at - # auth.${BASE_DOMAIN} (Caddy holds the network alias on mcnet). + # Internal-only: no published host port. Reached via caddy at + # auth.${BASE_DOMAIN} (caddy holds the network alias on mcnet). volumes: - ./drasl/config:/etc/drasl:ro - drasl_state:/var/lib/drasl @@ -58,7 +58,7 @@ services: # ── Auth: offline mode + authlib-injector pointing at Drasl ─ ONLINE_MODE: "FALSE" - # mount authlib-injector.jar at /extras/authlib-injector.jar + # Resolves over mcnet to caddy (alias auth.${BASE_DOMAIN}) -> drasl. JVM_OPTS: "-javaagent:/extras/authlib-injector.jar=http://auth.${BASE_DOMAIN}/authlib-injector" # ── Server config ─────────────────────────────────────────── @@ -71,9 +71,7 @@ services: ENFORCE_SECURE_PROFILE: "FALSE" # required for authlib-injector on 1.21+ ALLOW_FLIGHT: "TRUE" # many tech mods need this - # ── Mod source: packwiz pack served by the ingress ────────── - # http + caddy alias by default; the nginx override switches this to - # https + host-gateway (extra_hosts) for the production path. + # ── Mod source: packwiz pack served by caddy (alias pack.) ── PACKWIZ_URL: "http://pack.${BASE_DOMAIN}/pack.toml" # ── RCON for remote admin ─────────────────────────────────── @@ -86,11 +84,11 @@ services: - mc_data:/data - ./runtime:/extras:ro # authlib-injector.jar lives here depends_on: - - drasl # ingress (caddy/nginx) is layered separately + - drasl + - caddy # pack./auth. must resolve at boot networks: - mcnet - # Optional: backups mc-backup: image: itzg/mc-backup container_name: mc-backup @@ -109,9 +107,69 @@ services: networks: - mcnet + # Internal ingress. Routes every vhost by Host header (conf.d/*.caddy): + # auth. -> drasl, pack. -> packwiz files, apex -> landing + /launcher, + # avatar. -> nmsr, status. -> uptime-kuma, distribution. -> static site. + # Published on a localhost-only port; the host's nginx terminates TLS in + # front of it (nginx/ulicraft-caddy.conf.tmpl). + caddy: + image: caddy:alpine + container_name: caddy + restart: unless-stopped + ports: + - "${CADDY_HTTP_BIND:-127.0.0.1}:${CADDY_HTTP_PORT:-8880}:80" + environment: + BASE_DOMAIN: ${BASE_DOMAIN} + volumes: + - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro + - ./caddy/conf.d/00-core.caddy:/etc/caddy/conf.d/00-core.caddy:ro + - ./caddy/conf.d/10-static.caddy:/etc/caddy/conf.d/10-static.caddy:ro + - ./caddy/conf.d/30-distribution.caddy:/etc/caddy/conf.d/30-distribution.caddy:ro + - ./caddy/conf.d/40-avatar.caddy:/etc/caddy/conf.d/40-avatar.caddy:ro + - ./caddy/conf.d/50-status.caddy:/etc/caddy/conf.d/50-status.caddy:ro + - ./www:/srv/www:ro + - ./launcher:/srv/launcher:ro + - ./pack:/srv/pack:ro + # Custom (locally-hosted) mod jars live OUTSIDE ./pack so packwiz refresh + # doesn't index them as direct files. Served at pack.${BASE_DOMAIN}/custom/. + - ./custom:/srv/pack/custom:ro + # Static site from ANOTHER repo (absolute host path in .env). + - ${DISTRIBUTION_WEB_ROOT:?DISTRIBUTION_WEB_ROOT unset in .env}:/srv/distribution:ro + networks: + mcnet: + # Containers resolve the stack's own public names to caddy internally. + aliases: + - "auth.${BASE_DOMAIN}" + - "pack.${BASE_DOMAIN}" + + # Avatar/skin renderer — built from source (docker/nmsr/Dockerfile), pulls + # player profiles from Drasl over mcnet. caddy proxies avatar. -> nmsr:8080. + nmsr: + build: + context: ./docker/nmsr + image: ulicraft/nmsr:local + container_name: nmsr + restart: unless-stopped + volumes: + - ./nmsr/config.toml:/nmsr/config.toml:ro + networks: + - mcnet + + # Status monitoring + public status page. caddy proxies status. -> :3001. + # Joins mcnet so monitors probe the stack by internal service name. + uptime-kuma: + image: louislam/uptime-kuma:1 + container_name: uptime-kuma + restart: unless-stopped + volumes: + - kuma_data:/app/data + networks: + - mcnet + volumes: drasl_state: mc_data: + kuma_data: networks: mcnet: diff --git a/docker/avahi/Dockerfile b/docker/avahi/Dockerfile deleted file mode 100644 index f494f48..0000000 --- a/docker/avahi/Dockerfile +++ /dev/null @@ -1,9 +0,0 @@ -# Publishes the stack's service names on .local via the HOST's avahi-daemon -# (over its D-Bus socket — no second daemon, avoids the :5353 collision). -# Requires the host to run avahi-daemon and the socket to be mounted (see -# docker-compose.avahi.yml). Used when ENABLE_MDNS=true and BASE_DOMAIN=*.local. -FROM alpine:3.20 -RUN apk add --no-cache avahi-tools -COPY entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh -ENTRYPOINT ["/entrypoint.sh"] diff --git a/docker/avahi/entrypoint.sh b/docker/avahi/entrypoint.sh deleted file mode 100755 index fd7f128..0000000 --- a/docker/avahi/entrypoint.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/bin/sh -# Publish A records for the stack's service names on .local via the HOST's -# avahi-daemon (reached over its mounted D-Bus socket). One avahi-publish per -# name; if any drops the container exits so Docker restarts it. -set -eu - -: "${BASE_DOMAIN:?BASE_DOMAIN unset}" -: "${HOST_LAN_IP:?HOST_LAN_IP unset}" - -case "$BASE_DOMAIN" in - *.local) ;; - *) echo "WARN: mDNS resolves only .local — BASE_DOMAIN=$BASE_DOMAIN will NOT be discoverable via avahi. Set BASE_DOMAIN=*.local to use mDNS." >&2 ;; -esac - -bus="${DBUS_SYSTEM_BUS_ADDRESS:-unix:path=/run/dbus/system_bus_socket}" -sock=${bus#unix:path=} -if [ ! -S "$sock" ]; then - echo "ERROR: host D-Bus socket '$sock' not found. Mount the host's" >&2 - echo " /run/dbus/system_bus_socket and ensure avahi-daemon runs on the host." >&2 - exit 1 -fi -export DBUS_SYSTEM_BUS_ADDRESS="$bus" - -pids="" -for n in "$BASE_DOMAIN" "auth.$BASE_DOMAIN" "packwiz.$BASE_DOMAIN" "mc.$BASE_DOMAIN"; do - echo "mDNS publish (host avahi): $n -> $HOST_LAN_IP" - avahi-publish -a "$n" "$HOST_LAN_IP" & - pids="$pids $!" -done - -# shellcheck disable=SC2086 -wait -n $pids 2>/dev/null || wait diff --git a/nginx/ulicraft-caddy.conf.tmpl b/nginx/ulicraft-caddy.conf.tmpl index ce8a35d..b297fbb 100644 --- a/nginx/ulicraft-caddy.conf.tmpl +++ b/nginx/ulicraft-caddy.conf.tmpl @@ -1,19 +1,17 @@ # ────────────────────────────────────────────────────────────────── # Ulicraft host nginx — TLS terminator in FRONT of caddy. # ────────────────────────────────────────────────────────────────── -# Alternative to ulicraft.conf.tmpl. Instead of nginx serving the static -# files + proxying only drasl, here caddy (the docker container) is the single -# ingress for ALL vhosts and nginx just terminates TLS and reverse-proxies -# everything to caddy by Host header. +# caddy (in docker-compose.yml) is the single internal ingress for ALL vhosts; +# this host nginx terminates TLS and reverse-proxies everything to caddy by Host +# header. Prefer tooling/render-nginx.sh over hand-rendering. # # Caddy must be published on a localhost port — set in .env: # CADDY_HTTP_PORT=8880 # CADDY_HTTP_BIND=127.0.0.1 -# and the stack brought up with the caddy override (NOT the nginx override): -# docker compose -f docker-compose.yml -f docker-compose.caddy.yml \ -# -f docker-compose.static.yml up -d +# and the stack brought up: +# docker compose up -d # -# Render with BOTH vars then install: +# Render with the vars then install: # CADDY_HTTP_PORT=8880 BASE_DOMAIN=ulicraft.net APP_DIR=/home/ubuntu/mc/ulicraft-server-v1 \ # envsubst '$CADDY_HTTP_PORT $BASE_DOMAIN $APP_DIR' \ # < nginx/ulicraft-caddy.conf.tmpl | sudo tee /etc/nginx/sites-available/ulicraft.conf diff --git a/nginx/ulicraft.conf.tmpl b/nginx/ulicraft.conf.tmpl deleted file mode 100644 index 70d644a..0000000 --- a/nginx/ulicraft.conf.tmpl +++ /dev/null @@ -1,93 +0,0 @@ -# ────────────────────────────────────────────────────────────────── -# Ulicraft host nginx — TLS ingress with Let's Encrypt certs. -# ────────────────────────────────────────────────────────────────── -# Template. Render with BOTH vars then install: -# -# APP_DIR=/home/ubuntu/mc/ulicraft-server-v1 BASE_DOMAIN=ulicraft.net \ -# envsubst '$APP_DIR $BASE_DOMAIN' \ -# < nginx/ulicraft.conf.tmpl | sudo tee /etc/nginx/sites-available/ulicraft.conf -# sudo ln -sf /etc/nginx/sites-available/ulicraft.conf /etc/nginx/sites-enabled/ -# sudo nginx -t && sudo systemctl reload nginx -# -# Prereqs: -# - certs issued: tooling/issue-letsencrypt.sh -> $APP_DIR/certs//{cert,key}.pem -# - the docker stack up with the nginx override (publishes drasl on 127.0.0.1:25585): -# docker compose -f docker-compose.yml -f docker-compose.nginx.yml up -d -# - nginx's user (www-data) can READ $APP_DIR/{www,pack,custom,certs} -# ────────────────────────────────────────────────────────────────── - -# HTTP -> HTTPS for every name. -server { - listen 80; - listen [::]:80; - server_name ${BASE_DOMAIN} auth.${BASE_DOMAIN} pack.${BASE_DOMAIN}; - return 301 https://$host$request_uri; -} - -# Apex — static landing page (./www) + launcher downloads (./mirror/launcher). -# /ca.crt from the caddy path is intentionally omitted: LE certs are publicly -# trusted, so guests need no CA import. -server { - listen 443 ssl http2; - listen [::]:443 ssl http2; - server_name ${BASE_DOMAIN}; - - ssl_certificate ${APP_DIR}/certs/${BASE_DOMAIN}/cert.pem; - ssl_certificate_key ${APP_DIR}/certs/${BASE_DOMAIN}/key.pem; - add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; - - # Launcher downloads (populated by tooling/fetch-launcher.sh -> ./mirror/launcher). - location /launcher/ { - alias ${APP_DIR}/mirror/launcher/; - autoindex on; - } - - location / { - root ${APP_DIR}/www; - index index.html; - try_files $uri $uri/ =404; - } -} - -# Pack — packwiz metadata (./pack) + custom jars (./custom at /custom/). -server { - listen 443 ssl http2; - listen [::]:443 ssl http2; - server_name pack.${BASE_DOMAIN}; - - ssl_certificate ${APP_DIR}/certs/pack.${BASE_DOMAIN}/cert.pem; - ssl_certificate_key ${APP_DIR}/certs/pack.${BASE_DOMAIN}/key.pem; - add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; - - # Locally-hosted custom mod jars live outside ./pack; expose them at /custom/. - location /custom/ { - alias ${APP_DIR}/custom/; - autoindex on; - } - - location / { - root ${APP_DIR}/pack; - autoindex on; - } -} - -# Auth — reverse proxy to drasl (published on localhost by the nginx override). -server { - listen 443 ssl http2; - listen [::]:443 ssl http2; - server_name auth.${BASE_DOMAIN}; - - ssl_certificate ${APP_DIR}/certs/auth.${BASE_DOMAIN}/cert.pem; - ssl_certificate_key ${APP_DIR}/certs/auth.${BASE_DOMAIN}/key.pem; - add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; - - client_max_body_size 4m; # skin uploads - - location / { - proxy_pass http://127.0.0.1:25585; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } -} diff --git a/pack/README.md b/pack/README.md index 8917b7d..306a9d9 100644 --- a/pack/README.md +++ b/pack/README.md @@ -1,3 +1,3 @@ # Ulicraft modpack (packwiz) -This directory is the **single source of truth** for the modpack (MC 1.21.1, NeoForge 21.1.209). Curate mods from here with `packwiz modrinth add ` (prefer Modrinth over CurseForge); each mod produces a `mods/.pw.toml` metadata file referencing the upstream CDN. **Client-only mods must be tagged `side = "client"`** in their `.pw.toml` so the itzg server does not pull and crash on them. After adding/removing mods run `packwiz refresh` to recompute `index.toml`; for offline LAN play run `tooling/mirror-mods.sh` to vendor the jars and rewrite the download URLs to the local Caddy mirror. Curation is an ongoing manual task — no mods are added yet. +This directory is the **single source of truth** for the modpack (MC 1.21.1, NeoForge 21.1.209). Curate mods from here with `packwiz modrinth add ` (prefer Modrinth over CurseForge); each mod produces a `mods/.pw.toml` metadata file referencing the upstream CDN. **Client-only mods must be tagged `side = "client"`** in their `.pw.toml` so the itzg server does not pull and crash on them. After adding/removing mods run `packwiz refresh` to recompute `index.toml`. Clients and the itzg server fetch jars from the upstream CDN referenced in each `.pw.toml`. Curation is an ongoing manual task — no mods are added yet. diff --git a/plan/00-overview.md b/plan/00-overview.md index 24205bf..a27a3e4 100644 --- a/plan/00-overview.md +++ b/plan/00-overview.md @@ -1,124 +1,127 @@ # Ulicraft Server — Plan Overview -Self-hosted modded Minecraft LAN-party stack, designed to run **fully offline** -(air-gapped) during the party while remaining a persistent homelab service. +Self-hosted modded Minecraft stack — a single Docker Compose file holds the +entire thing, fronted by the host's own nginx (Let's Encrypt TLS) and a caddy +internal router. Built for a LAN party but runs as a persistent homelab service. +Assumes the internet is present. ## Single source of configuration -Everything is driven by two env vars in `.env`: +Everything is driven by one env var in `.env`: ``` -BASE_DOMAIN=ulicraft.local # all services are subdomains of this -HOST_LAN_IP=192.168.x.x # the Docker host's LAN IP (dnsmasq target) +BASE_DOMAIN=ulicraft.net # all services are subdomains of this ``` -> `.local` collides with mDNS/Bonjour (RFC 6762). macOS + Linux Avahi may resolve -> `*.local` via multicast and bypass dnsmasq. Accepted risk for now; alternatives -> if it bites: `.lan`, `home.arpa`. Flagged, user chose `.local`. +`render-config.sh` substitutes **only** `${BASE_DOMAIN}` into the templates. +DNS is **not** this repo's concern: point `${BASE_DOMAIN}` and all subdomains +(`auth.` `pack.` `avatar.` `status.` `distribution.` `www.`) at the host running +nginx; that is configured outside this repo. ## Subdomain / ingress map ``` -ulicraft.local → caddy → landing page (launcher downloads + guide) -auth.ulicraft.local → caddy → drasl:25585 (auth web UI + Yggdrasil API) -packwiz.ulicraft.local → caddy → /srv/pack (pack metadata + mod-jar mirror) -assets.ulicraft.local → caddy → /srv/assets (MC asset-object mirror; HTTPS forced — see 10) -mc.ulicraft.local:25565 → minecraft (NOT http; raw MC protocol) +ulicraft.net → nginx → caddy → landing page (/srv/www) + /launcher/ +auth.ulicraft.net → nginx → caddy → drasl:25585 (auth web UI + Yggdrasil API) +pack.ulicraft.net → nginx → caddy → /srv/pack (packwiz pack metadata) +avatar.ulicraft.net → nginx → caddy → nmsr:8080 (skin/avatar renderer) +status.ulicraft.net → nginx → caddy → uptime-kuma:3001 (status page) +distribution.ulicraft.net → nginx → caddy → /srv/distribution (static, another repo) +mc.ulicraft.net:25565 → minecraft (raw MC protocol, not HTTP) ``` -Caddy is the only HTTP ingress (plain `:80` for now, TLS/step-ca later). -drasl's host port is removed — internal only, reached through Caddy. +The host's own nginx is the only public ingress: it terminates TLS with Let's +Encrypt certs and reverse-proxies every vhost to caddy by Host header. caddy is +published on a **localhost-only** port (`CADDY_HTTP_BIND=127.0.0.1`, +`CADDY_HTTP_PORT=8880`) and acts as the internal router. drasl, nmsr and +uptime-kuma have no host ports — they are reached only through caddy. ### Internal resolution trick -Containers use the Docker resolver, NOT dnsmasq. Caddy gets network aliases so -the stack resolves its own subdomains internally: +Containers use the Docker resolver. caddy gets network aliases so the stack +resolves its own subdomains internally: ```yaml caddy: networks: mcnet: - aliases: [ "auth.${BASE_DOMAIN}", "packwiz.${BASE_DOMAIN}" ] + aliases: [ "auth.${BASE_DOMAIN}", "pack.${BASE_DOMAIN}" ] ``` -Result: `http://auth.ulicraft.local/authlib-injector` and -`http://packwiz.ulicraft.local/pack.toml` resolve identically inside and outside -the stack. dnsmasq exists **only for guest laptops**. +Result: `http://auth.ulicraft.net/authlib-injector` and +`http://pack.ulicraft.net/pack.toml` resolve identically inside the stack +(minecraft → caddy) as the public names do outside it (client → nginx → caddy). -## Online vs offline split +## Build / run flow ``` -PRE-PARTY (internet, run once): - 1. curate packwiz pack → see 04-packwiz.md - 2. mirror mod jars + rewrite URLs → tooling/mirror-mods.sh (08-tooling.md) - 3. pre-bake server volume → TYPE=NEOFORGE install (05-minecraft.md) - 4. download launcher releases → tooling/fetch-launcher.sh (06-launcher.md) - 5. capture+mirror client air-gap → tooling/mirror-airgap.sh (11-full-airgap-mirror.md) - 6. build landing page → cd landing && pnpm run build → www/ (09-landing.md) - 7. render configs from templates → tooling/render-config.sh (08-tooling.md) +PREP (run once, online): + 1. render configs → tooling/render-config.sh (08-tooling.md) + 2. build landing page → cd landing && pnpm run build → www/ (09-landing.md) + 3. download launcher releases → tooling/fetch-launcher.sh (06-launcher.md) + 4. curate packwiz pack → see 04-packwiz.md + 5. (TLS) issue Let's Encrypt certs → tooling/issue-letsencrypt.sh (15-letsencrypt.md) + 6. (ingress) render+install nginx → tooling/render-nginx.sh --install (15-letsencrypt.md) -PARTY (air-gapped): - dnsmasq : *.ulicraft.local → HOST_LAN_IP - caddy : local CDN + landing page - drasl : password login (NO Keycloak/OIDC for now) - minecraft: TYPE=CUSTOM, no Mojang re-resolution - guests : install Fjord launcher, import pack, add authlib account → auth subdomain +UP: + docker compose up -d --build ``` +Bring-up is a single command. There are no run modes and no override compose +files — one `docker-compose.yml` holds drasl, minecraft, mc-backup, caddy, nmsr +and uptime-kuma. + ## Why each hard call was made -- **No OIDC/Keycloak (for now)** — drasl password login. Keycloak left out of this - stack's scope. Re-add later if desired. -- **itzg can't air-gap-boot in NEOFORGE mode** (re-resolves Mojang metadata every - start, issue #2340). Pre-bake then switch to `TYPE=CUSTOM`. -- **packwiz only serves metadata** — `.pw.toml` points at Modrinth CDN. Offline - needs jars mirrored onto Caddy + URL rewrite. -- **Fjord launcher** has authlib-injector built in → no manual JVM agent on clients. +- **No OIDC/Keycloak (for now)** — drasl password login. Keycloak left out of + this stack's scope. Re-add later if desired. +- **packwiz only serves metadata** — `.pw.toml` points at Modrinth/CF CDNs; + server and clients fetch jars from there. The internet is assumed present. +- **Fjord launcher** has authlib-injector built in → no manual JVM agent on + clients. +- **host nginx in front of caddy** — real Let's Encrypt certs (JVM trusts them + with no CA import) and a single public TLS terminator; caddy stays an internal + Host-routed multiplexer for the stack's own services. ## Target folder layout ``` . -├── docker-compose.yml -├── .env / .env.example # BASE_DOMAIN, HOST_LAN_IP, RCON_PASSWORD -├── caddy/Caddyfile # {$BASE_DOMAIN} vhosts + landing page -├── dnsmasq/dnsmasq.conf.tmpl # wildcard template +├── docker-compose.yml # the entire stack (no overrides) +├── .env / .env.example # BASE_DOMAIN, RCON_PASSWORD, CADDY_*, LE block +├── caddy/conf.d/ # per-vhost snippets (00-core, 10-static, …) +├── nginx/ulicraft-caddy.conf.tmpl # host nginx → caddy template (TLS terminator) +├── certs//{cert,key}.pem # Let's Encrypt output (gitignored) ├── drasl/config/ -│ ├── config.toml.tmpl # envsubst source +│ ├── config.toml.tmpl # render-config.sh source │ └── config.toml # generated (gitignored) +├── nmsr/ # avatar renderer config + Dockerfile context +├── docker/nmsr/ # nmsr image build context ├── pack/ # packwiz source of truth -├── mirror/ # vendored mod jars + launcher releases (gitignored) -├── runtime/ # authlib-injector.jar (legacy; may be unused w/ Fjord) -├── tooling/ -│ ├── render-config.sh # envsubst templates → real configs, halt if unset -│ ├── mirror-mods.sh # vendor mod jars + rewrite .pw.toml URLs -│ ├── mirror-assets.sh # mirror MC asset objects for offline clients (10) -│ └── fetch-launcher.sh # download all Fjord release assets +├── launcher/ # vendored Fjord launcher releases (gitignored) +├── runtime/ # authlib-injector.jar +├── tooling/ # render/fetch/issue scripts (see 08-tooling.md) ├── landing/ # Astro+TS landing source (build → www/) -│ ├── astro.config.mjs # outDir ../www -│ ├── public/logo.png # stone 3D logo -│ └── src/{data/site.ts,pages/index.astro} -├── www/ # caddy landing page — GENERATED by landing build (gitignored) +├── www/ # landing build output — caddy apex (gitignored) ├── backups/ └── plan/ # these files ``` ## Service plan files -- `01-dnsmasq.md` — LAN wildcard DNS -- `02-caddy.md` — ingress + local CDN + landing page +- `02-caddy.md` — internal router (vhost conf.d snippets) behind host nginx - `03-drasl.md` — auth (password login) -- `04-packwiz.md` — modpack source + mod mirror -- `05-minecraft.md` — itzg NeoForge server + offline launch +- `04-packwiz.md` — modpack source +- `05-minecraft.md` — itzg NeoForge server - `06-launcher.md` — Fjord launcher fetch + distribution - `07-mc-backup.md` — world backups -- `08-tooling.md` — render/mirror/fetch scripts +- `08-tooling.md` — render/fetch/issue scripts - `09-landing.md` — Astro+TS guest onboarding page → www/ -- `10-assets-mirror.md` — lighter alternative: objects-only Fjord Assets Server override -- `11-full-airgap-mirror.md` — CHOSEN: DNS+TLS transparent mirror for full client air-gap - `12-build-order.md` — commit-per-task build sequence -- `13-dns-and-run-modes.md` — domain (.lan), online/airgap modes, party DNS, mDNS option +- `14-deploy.md` — redeploy to the production host +- `15-letsencrypt.md` — Let's Encrypt (OVH DNS-01) + host nginx ingress ## Boot/dependency order -`dnsmasq` → `caddy` (aliases) → `drasl` → `minecraft` (depends on drasl + packwiz served by caddy) → `mc-backup`. +`caddy` (aliases) → `drasl` → `minecraft` (depends on drasl + pack served by +caddy) → `mc-backup`. `nmsr` and `uptime-kuma` are always-on and join `mcnet`. diff --git a/plan/01-dnsmasq.md b/plan/01-dnsmasq.md deleted file mode 100644 index 9808690..0000000 --- a/plan/01-dnsmasq.md +++ /dev/null @@ -1,63 +0,0 @@ -# dnsmasq — LAN wildcard DNS - -> **SUPERSEDED for the default path — see `13-dns-and-run-modes.md`.** DNS is now -> your own party server (records via `tooling/dns-records.sh`); dnsmasq moved to -> an optional turnkey layer (`docker-compose.dnsmasq.yml`). Default domain is -> `.lan` (not `.local`). The wildcard/spoof template below still applies when you -> opt into dnsmasq. - -## Summary - -Provides name resolution for guest laptops so `*.ulicraft.local` resolves to the -Docker host. Replaces the homelab's AdGuard (dropped for the LAN party). Image: -`jpillora/dnsmasq` (dnsmasq wrapped in webproc, optional web UI on :8080). - -Config is a **file**, not env-driven — `/etc/dnsmasq.conf`, standard dnsmasq -format. Rendered from a template by `tooling/render-config.sh`. - -Only guest laptops point their DNS here. Stack-internal resolution is handled by -Docker network aliases on Caddy (see `02-caddy.md`), so dnsmasq is not on the -critical path for the server's own services. - -## Config template - -`dnsmasq/dnsmasq.conf.tmpl`: -``` -# wildcard: every *.ulicraft.local → host LAN IP -address=/${BASE_DOMAIN}/${HOST_LAN_IP} -# upstream optional; party is offline so may be omitted -no-resolv -``` - -### Air-gap spoof records (see 11) - -For full client air-gap, this file also spoofs the real upstream hosts → our -Caddy mirror (`piston-meta`/`piston-data`/`libraries.minecraft.net`/ -`maven.neoforged.net`/`resources.download.minecraft.net`/`meta.prismlauncher.org`). -Full list + rationale in `11-full-airgap-mirror.md`. Keep in sync with the -capture step. Auth hosts (session/textures/api.mojang) are NOT spoofed — drasl -handles those via authlib-injector. - -## Gotcha — port 53 conflict - -Linux Docker hosts usually run `systemd-resolved` bound to `:53`. Publishing -`53:53` will fail with `address already in use`. Resolve by one of: -- Bind dnsmasq only to the LAN IP: publish `${HOST_LAN_IP}:53:53/udp` (+tcp) -- Or disable resolved's stub listener (`DNSStubListener=no`) and restart resolved - -## Gotcha — `.local` / mDNS - -See overview. Avahi may shortcut `*.local` to multicast before hitting dnsmasq. -Test on a macOS client early; if broken, switch `BASE_DOMAIN` to `.lan`. - -## Tasks - -- [ ] Add `dnsmasq` service to `docker-compose.yml` (`jpillora/dnsmasq`) -- [ ] Create `dnsmasq/dnsmasq.conf.tmpl` with wildcard `address=` line -- [ ] Wire `render-config.sh` to emit `dnsmasq/dnsmasq.conf` (gitignored) -- [ ] Decide port-53 strategy (bind to `HOST_LAN_IP` vs disable resolved stub) -- [ ] Publish `53/udp` + `53/tcp`; mount rendered conf read-only -- [ ] Optionally expose webproc UI (8080) behind Caddy as `dns.ulicraft.local` -- [ ] Document guest DNS setting (set DNS server = `HOST_LAN_IP`) -- [ ] Verify `*.local` resolves on Windows + macOS + Linux guests (mDNS check) -- [ ] Add air-gap spoof `address=` records (see 11) once capture confirms the host list diff --git a/plan/02-caddy.md b/plan/02-caddy.md index 1078795..da63935 100644 --- a/plan/02-caddy.md +++ b/plan/02-caddy.md @@ -1,15 +1,18 @@ -# Caddy — ingress, local CDN, landing page +# Caddy — internal router behind host nginx ## Summary -Single HTTP ingress for the stack. Three roles: -1. **Reverse proxy** — `auth.ulicraft.local` → drasl. -2. **Local CDN / static** — `packwiz.ulicraft.local` serves pack metadata AND the - mirrored mod jars (offline installs). -3. **Landing page** — apex `ulicraft.local` serves launcher downloads + guest guide. +caddy is the stack's **internal HTTP router**, sitting behind the host's own +nginx (which terminates TLS — see `15-letsencrypt.md`). caddy is published only +on a localhost-only port (`CADDY_HTTP_BIND=127.0.0.1`, `CADDY_HTTP_PORT=8880`); +nginx reverse-proxies every public vhost to it by Host header. Roles: -Plain `:80` for now (LAN). TLS via step-ca deferred. Image: `caddy:alpine`. -Caddy natively reads `{$BASE_DOMAIN}` env placeholders — no template render needed. +1. **Reverse proxy** — `auth.` → drasl, `avatar.` → nmsr, `status.` → uptime-kuma. +2. **Static** — `pack.` serves packwiz metadata; apex serves the landing page + + `/launcher/` downloads; `distribution.` serves a static site from another repo. + +Image: `caddy:alpine`. caddy natively reads `{$BASE_DOMAIN}` env placeholders — +no template render needed. ## Network aliases (internal resolution) @@ -19,51 +22,41 @@ caddy: mcnet: aliases: - "auth.${BASE_DOMAIN}" - - "packwiz.${BASE_DOMAIN}" + - "pack.${BASE_DOMAIN}" ``` -Lets `minecraft` reach `http://auth.ulicraft.local/authlib-injector` and -`http://packwiz.ulicraft.local/pack.toml` without dnsmasq. +Lets `minecraft` reach `http://auth.ulicraft.net/authlib-injector` and +`http://pack.ulicraft.net/pack.toml` from inside the stack. -## Caddyfile sketch +## conf.d snippets -``` -{ - auto_https off -} +The Caddyfile imports per-vhost snippets from `caddy/conf.d/`: -http://{$BASE_DOMAIN} { - root * /srv/www - file_server -} +- `00-core.caddy` — `auth.` → drasl, `pack.` → `/srv/pack` (browse). +- `10-static.caddy` — apex landing (`/srv/www`) + `/launcher/*` (`/srv/launcher`). +- `30-distribution.caddy` — `distribution.` → `/srv/distribution` (static, from + `DISTRIBUTION_WEB_ROOT`, another repo). +- `40-avatar.caddy` — `avatar.` → `nmsr:8080` (skin/avatar renderer). +- `50-status.caddy` — `status.` → `uptime-kuma:3001` (status page). -http://auth.{$BASE_DOMAIN} { - reverse_proxy drasl:25585 -} - -http://packwiz.{$BASE_DOMAIN} { - root * /srv/pack - file_server browse -} -``` - -Mounts: `./www:/srv/www:ro`, `./pack:/srv/pack:ro`, `./mirror/launcher:/srv/www/launcher:ro`. +All vhosts are plain `http://…`; TLS is nginx's job. Mounts: `./www:/srv/www:ro`, +`./pack:/srv/pack:ro`, `./launcher:/srv/launcher:ro`, +`${DISTRIBUTION_WEB_ROOT}:/srv/distribution:ro`. ## Notes / risks -- drasl behind a proxy: docs don't detail `X-Forwarded-*` trust. On plain HTTP LAN - with matching `BaseURL` it should be fine; revisit when TLS is added. +- drasl behind a proxy: with matching `BaseURL` and nginx terminating TLS it is + fine; nginx passes the original Host through to caddy → drasl. - `file_server browse` gives a directory listing — handy for debugging the pack. -- Mod-jar mirror lives under `/srv/pack/mods/` so packwiz URLs and jars share host. +- `/launcher/` is a sibling mount (`/srv/launcher`), not nested under the + read-only `/srv/www`; `handle_path` strips the prefix. ## Tasks -- [ ] Add `caddy` service to `docker-compose.yml`, ports `80:80` (+`443` later) -- [ ] Pass `BASE_DOMAIN` into caddy env -- [ ] Create `caddy/Caddyfile` with apex + auth + packwiz vhosts -- [ ] Add network aliases for `auth.` and `packwiz.` subdomains -- [ ] Mount `./www`, `./pack`, `./mirror/launcher` +- [ ] `caddy` service in `docker-compose.yml`, published `127.0.0.1:${CADDY_HTTP_PORT}:80` +- [ ] Pass `BASE_DOMAIN` + `DISTRIBUTION_WEB_ROOT` into caddy env +- [ ] Caddyfile imports `conf.d/*.caddy` +- [ ] Network aliases for `auth.` and `pack.` subdomains +- [ ] Mount `./www`, `./pack`, `./launcher`, `${DISTRIBUTION_WEB_ROOT}` - [ ] Confirm reverse_proxy to drasl works (auth web UI loads, Yggdrasil API responds) - [ ] Verify internal alias resolution from `minecraft` container (curl pack.toml) -- [ ] Air-gap mirror: add per-host `tls internal` vhosts + aliases + mounts (see 11) -- [ ] Export Caddy root CA for guest trust (LAN-party TLS = `tls internal`; step-ca deferred) -- [ ] Defer (reminder): graduate `tls internal` → step-ca for persistent homelab +- [ ] Verify nmsr + uptime-kuma proxied (`avatar.` renders, `status.` loads) diff --git a/plan/03-drasl.md b/plan/03-drasl.md index 8bf1257..bf07da6 100644 --- a/plan/03-drasl.md +++ b/plan/03-drasl.md @@ -4,7 +4,7 @@ Self-hosted, Yggdrasil-compatible auth + skin server. Drop-in Mojang replacement via authlib-injector. Image: `unmojang/drasl:latest`. Reached only through Caddy -at `auth.ulicraft.local`; no host port. +at `auth.ulicraft.net`; no host port. **OIDC/Keycloak is OUT for now** — drasl runs in **password-login** mode (`AllowPasswordLogin = true`). Admin + guests register with a username/password on @@ -35,7 +35,7 @@ AllowPasswordLogin = true - **1.21+ secure profile**: drasl `SignPublicKeys = false` MUST pair with server `ENFORCE_SECURE_PROFILE=FALSE`. Linked — both or neither. - **authlib endpoint** = `BaseURL` + `/authlib-injector` → - `http://auth.ulicraft.local/authlib-injector`. Must match on server and client. + `http://auth.ulicraft.net/authlib-injector`. Must match on server and client. Network alias makes this identical inside/outside the stack. - **`Domain` affects skins** — if wrong, authlib clients may not see skins. diff --git a/plan/03b-blessing-skin.md b/plan/03b-blessing-skin.md deleted file mode 100644 index 4942e91..0000000 --- a/plan/03b-blessing-skin.md +++ /dev/null @@ -1,80 +0,0 @@ -# Blessing Skin — auth + skin server (Drasl alternative) - -## Summary - -Drop-in alternative to Drasl (`plan/03-drasl.md`). Blessing Skin Server (BSS) is -a PHP skin station; the **yggdrasil-api plugin** gives it a Yggdrasil API that -authlib-injector talks to — same end result as Drasl, different internals. - -Run it as an override **instead of** Drasl: - -``` -docker compose -f docker-compose.yml -f docker-compose.blessingskin.yml up -d -``` - -The override (`docker-compose.blessingskin.yml`) repoints Caddy's `auth.*` vhost -at `blessing-skin:80`, adds `mariadb` + `blessing-skin`, and switches the -Minecraft authlib endpoint to `/api/yggdrasil`. Drasl stays defined but idle and -unreachable (compose merges `depends_on`, so an override can't remove it); -`docker compose … stop drasl` after up if you want it down. - -## Drasl vs Blessing Skin — what changes - -| | Drasl | Blessing Skin | -|---|---|---| -| Backend | single Go binary | PHP app + **MariaDB** (no SQLite) | -| Config | rendered `config.toml` | **web install wizard** + DB | -| Yggdrasil API | built in | **yggdrasil-api plugin** (install + enable) | -| authlib endpoint | `…/authlib-injector` | `…/api/yggdrasil` | -| OIDC/Keycloak | supported (future) | not native (OAuth plugins exist) | -| Server online-mode | repo used FALSE | **TRUE** (real session validation) | - -## Setup (first run) - -1. `cp .env.example .env`, fill the `BS_*` vars. Generate `BS_APP_KEY`: - ``` - docker run --rm azusamikan/blessing-skin-server-docker:latest \ - php artisan key:generate --show - ``` -2. Bring the stack up with the override (command above). -3. Open `http://auth.${BASE_DOMAIN}` → BSS install wizard. DB host = `mariadb`, - port `3306`, name/user/pass = the `BS_*` values. Create the admin account. -4. Admin panel → **Plugins → Plugin Market** → install **yggdrasil-api** → - enable it. (Needs internet; for air-gap, pre-place the plugin jar/zip into - the `bs_plugins` volume.) After enabling, the API root is live at - `http://auth.${BASE_DOMAIN}/api/yggdrasil`. -5. Each player: register on the BSS site, add a character whose name == their - in-game player name, upload a skin. -6. Restart minecraft if it came up before the plugin was enabled. - -## Client (Prism / authlib-injector) - -- authlib-injector URL: `http://auth.${BASE_DOMAIN}/api/yggdrasil` -- Login = BSS **email + password** (BSS uses email as the account id). -- Must match the server's `-javaagent` arg exactly (same host + path). - -## Gotchas - -- **`/api/yggdrasil`, not `/authlib-injector`.** Wrong path → silent "Invalid - session". Server `JVM_OPTS` and every client must agree. -- **ONLINE_MODE=TRUE.** authlib-injector only authenticates when online-mode is - on; the server then validates the join against BSS. (The Drasl variant in this - repo set FALSE — do not copy that here.) -- **Secure profile (1.21+).** Override ships `ENFORCE_SECURE_PROFILE=FALSE` for - safety. yggdrasil-api *does* sign profile keys, so TRUE may work — flip and - test if signed chat matters; revert on "Invalid signature". -- **APP_KEY must persist.** Set `BS_APP_KEY` in `.env`. Losing it invalidates - all sessions and breaks encrypted data. -- **DB is the source of truth.** `bs_db` volume holds accounts; `bs_storage` - holds skins; `bs_plugins` holds the yggdrasil-api plugin. Back these up. -- **Air-gap:** the plugin market needs internet. Install + enable yggdrasil-api - while online (it persists in `bs_plugins`), or stage the plugin manually. - -## Image note - -`azusamikan/blessing-skin-server-docker:latest` is a community image and the -env-var names (`DB_*`, `APP_KEY`) may differ slightly by tag — if auto-config -doesn't take, the web wizard is the reliable path. Official source: -https://github.com/bs-community/blessing-skin-server , -plugin: https://github.com/bs-community/yggdrasil-api , authlib-injector setup: -https://github.com/bs-community/blessing-skin-manual/blob/master/man/yggdrasil-api/authlib-injector.md diff --git a/plan/04-packwiz.md b/plan/04-packwiz.md index 1dca7b8..99d4f8a 100644 --- a/plan/04-packwiz.md +++ b/plan/04-packwiz.md @@ -1,4 +1,4 @@ -# packwiz — modpack source of truth + offline mod mirror +# packwiz — modpack source of truth ## Summary @@ -7,30 +7,21 @@ directory is the **single source of truth** for mods, consumed by both: - the **server** (itzg `PACKWIZ_URL`), and - **clients** (Fjord launcher imports the pack / packwiz-installer). -Caddy serves `./pack` at `packwiz.ulicraft.local`. packwiz only emits **metadata** +Caddy serves `./pack` at `pack.ulicraft.net`. packwiz emits **metadata** (`pack.toml`, `index.toml`, `mods/*.pw.toml`); the `.pw.toml` files reference -Modrinth/CF **CDN URLs**, so true offline requires mirroring jars locally. +Modrinth/CF **CDN URLs**, so server and clients fetch the jars from those CDNs. +The stack assumes the internet is present. ## Behaviors confirmed - **Prune**: packwiz-installer tracks a manifest and **removes** client mods no longer in the index on re-run. (Server side via itzg likewise re-syncs.) -- **Download source**: jars come from CDN URLs in `.pw.toml`, NOT the pack host — - unless URLs are rewritten to the local mirror. +- **Download source**: jars come from the CDN URLs in `.pw.toml`, not the pack + host (which serves only metadata). - **itzg side filtering**: itzg downloads **server-side mods only**. Client-only mods MUST be tagged `side = "client"` (not `both`) or the server may pull and crash on them. -## Offline mod mirror (the key work) - -`tooling/mirror-mods.sh` (see `08-tooling.md`): -1. Parse each `mods/*.pw.toml`, download the jar into `./mirror/mods/`. -2. Copy jars into `./pack/mods-files/` (served at `packwiz.ulicraft.local/...`). -3. Rewrite each `[download] url` to point at `http://packwiz.ulicraft.local/...`. -4. `packwiz refresh` to recompute hashes/index. - -Result: server + clients install mods entirely from the LAN. No CDN at party time. - ## NeoForge pinning `pack.toml` pins MC `1.21.1` + NeoForge `21.1.x`. Verify exact NeoForge build @@ -42,7 +33,5 @@ against projects.neoforged.net before locking. - [ ] Curate mod shortlist (perf/tech/magic/storage/QoL/worldgen/map/voice) - [ ] Tag client-only mods `side = "client"` - [ ] `packwiz refresh`; verify `index.toml` committed (clients fetch it) -- [ ] Write `tooling/mirror-mods.sh` (vendor jars + rewrite URLs + refresh) -- [ ] Point server `PACKWIZ_URL=http://packwiz.ulicraft.local/pack.toml` -- [ ] `.gitignore` the vendored `./mirror/` jars; keep `./pack/*.toml` tracked -- [ ] Test offline install end-to-end (cut internet, install on a fresh client) +- [ ] Point server `PACKWIZ_URL=http://pack.ulicraft.net/pack.toml` +- [ ] Test install end-to-end on a fresh client diff --git a/plan/05-minecraft.md b/plan/05-minecraft.md index 05eb824..870805b 100644 --- a/plan/05-minecraft.md +++ b/plan/05-minecraft.md @@ -1,39 +1,30 @@ -# Minecraft server — itzg NeoForge + offline launch +# Minecraft server — itzg NeoForge ## Summary `itzg/minecraft-server:java21`, NeoForge 1.21.1, offline-mode + authlib-injector -pointing at drasl. Mods via `PACKWIZ_URL`. The challenge: **itzg re-resolves Mojang -version metadata on every boot** (issue #2340), so it cannot air-gap-boot in -`TYPE=NEOFORGE` mode. Solved with a pre-bake + custom-launch switch. +pointing at drasl. Mods via `PACKWIZ_URL`. itzg installs MC + NeoForge + +libraries + server mods on first boot and re-syncs on restart; the stack assumes +the internet is present, so `TYPE=NEOFORGE` boots normally every time. -## Offline strategy (two phases) +## Server config -**Phase 1 — pre-bake (online, once):** ``` TYPE=NEOFORGE, VERSION=1.21.1, NEOFORGE_VERSION=21.1.x -PACKWIZ_URL=http://packwiz.ulicraft.local/pack.toml +PACKWIZ_URL=http://pack.${BASE_DOMAIN}/pack.toml ``` -Boot once with internet → installs MC + NeoForge + libraries + server mods into -the `mc_data` volume, generates NeoForge's launch args/`run.sh`. -**Phase 2 — offline launch (party):** -Switch to `TYPE=CUSTOM` + `CUSTOM_SERVER=` pointing at the -already-installed NeoForge launcher in `/data`. A **container path (not URL)** -means no download, no Mojang resolution → boots air-gapped, survives restarts. +First boot installs MC + NeoForge + libraries + server mods into the `mc_data` +volume. Subsequent boots re-sync the pack against `PACKWIZ_URL`. -> OPEN: NeoForge 1.21.1 launches via `@libraries/.../unix_args.txt` / `run.sh`, -> not a single fat jar. Exact `CUSTOM_SERVER` target + any `EXTRA_ARGS` must be -> verified against a real pre-baked volume. Tracked as a task. - -## Auth / config (carry-over) +## Auth / config ``` ONLINE_MODE=FALSE ENFORCE_SECURE_PROFILE=FALSE # pairs with drasl SignPublicKeys=false JVM_OPTS=-javaagent:/extras/authlib-injector.jar=http://auth.${BASE_DOMAIN}/authlib-injector ``` -authlib URL uses the `auth` subdomain; resolves internally via Caddi alias. +authlib URL uses the `auth` subdomain; resolves internally via the caddy alias. (Server still needs the agent even though Fjord clients have it built in.) Memory: `INIT_MEMORY 4G`, `MAX_MEMORY 10G`, `USE_AIKAR_FLAGS TRUE`. @@ -41,12 +32,9 @@ RCON enabled for mc-backup. ## Tasks -- [ ] Compose: switch authlib URL to `http://auth.${BASE_DOMAIN}/authlib-injector` -- [ ] Compose: replace `MODRINTH_PROJECTS` with `PACKWIZ_URL` (packwiz subdomain) -- [ ] Mount renamed `./runtime` (was `./extras`) for authlib-injector.jar +- [ ] Compose: authlib URL `http://auth.${BASE_DOMAIN}/authlib-injector` +- [ ] Compose: `PACKWIZ_URL=http://pack.${BASE_DOMAIN}/pack.toml` +- [ ] Mount `./runtime` for authlib-injector.jar at `/extras` - [ ] `depends_on`: drasl + caddy (pack served before server installs) -- [ ] Pre-bake the volume online; snapshot it -- [ ] Identify exact NeoForge launch target for `TYPE=CUSTOM` / `CUSTOM_SERVER` -- [ ] Add a documented "offline mode" compose override (`docker-compose.offline.yml`?) -- [ ] Verify air-gapped boot: cut internet, restart container, server comes up - [ ] Confirm `ENFORCE_SECURE_PROFILE=FALSE` ↔ drasl `SignPublicKeys=false` +- [ ] Verify a client joins with their drasl skin diff --git a/plan/06-launcher.md b/plan/06-launcher.md index 2fec025..c10c076 100644 --- a/plan/06-launcher.md +++ b/plan/06-launcher.md @@ -8,8 +8,8 @@ drasl account directly in the launcher (no manual JVM `-javaagent`), import the modpack, and play. We host the launcher installers for **all platforms** on the landing page -(`ulicraft.local`), served by Caddy from `./mirror/launcher/`. A script downloads -the latest release assets ahead of time so the party is fully offline. +(`ulicraft.net`), served by Caddy from `./launcher/` (mounted at `/srv/launcher`, +reachable at `/launcher/`). A script downloads the release assets ahead of time. ## Release assets (latest = 11.0.2.0) @@ -26,12 +26,12 @@ Setup `.exe`, macOS `.dmg`, Linux `.AppImage`. ```bash #!/usr/bin/env bash -# Download all FjordLauncherUnlocked release assets for offline hosting. +# Download all FjordLauncherUnlocked release assets for local hosting. # Halts on any error or ambiguity (no silent partial downloads). set -euo pipefail REPO="hero-persson/FjordLauncherUnlocked" -DEST_ROOT="$(dirname "$0")/../mirror/launcher" +DEST_ROOT="$(dirname "$0")/../launcher" API="https://api.github.com/repos/${REPO}/releases/latest" command -v curl >/dev/null || { echo "curl required"; exit 1; } @@ -59,13 +59,13 @@ ln -sfn "$tag" "${DEST_ROOT}/latest" echo "Done. ${#urls[@]} assets in ${dest}" ``` -Caddy mount: `./mirror/launcher:/srv/www/launcher:ro`. +Caddy mount: `./launcher:/srv/launcher:ro` (served at `/launcher/…`). ### Stable symlinks (so landing links survive version bumps) The landing page (`09-landing.md`) links to **fixed** filenames, not versioned ones. After downloading, `fetch-launcher.sh` must create these symlinks under -`mirror/launcher/latest/` pointing at the real assets: +`launcher/latest/` pointing at the real assets: - `fjord-windows-setup.exe` → Windows Setup `.exe` (x64) - `fjord-macos.dmg` → macOS `.dmg` @@ -78,22 +78,19 @@ sync with `landing/src/data/site.ts`.** ## Per-guest flow -1. Open `http://ulicraft.local`, download launcher for their OS, install. +1. Open `https://ulicraft.net`, download launcher for their OS, install. 2. Add account: authlib-injector type, URL - `http://auth.ulicraft.local/authlib-injector`, drasl username/password. -3. (Offline) Settings → APIs → **Assets Server** = - `https://assets.ulicraft.local/` so the heavy asset download comes from the LAN - mirror (see 10-assets-mirror.md). Requires trusting the local CA. -4. Import the modpack (packwiz URL `http://packwiz.ulicraft.local/pack.toml`, + `https://auth.ulicraft.net/authlib-injector`, drasl username/password. +3. Import the modpack (packwiz URL `https://pack.ulicraft.net/pack.toml`, or a shared instance zip). -5. Connect to `mc.ulicraft.local:25565`. +4. Connect to `mc.ulicraft.net:25565`. ## Tasks - [ ] Create `tooling/fetch-launcher.sh` (above); `chmod +x` -- [ ] Extend script: create stable symlinks (win/mac/linux) in `mirror/launcher/latest/` -- [ ] Run it pre-party while online; verify all assets land in `./mirror/launcher//` -- [ ] `.gitignore` `./mirror/` (done) +- [ ] Extend script: create stable symlinks (win/mac/linux) in `launcher/latest/` +- [ ] Run it pre-party; verify all assets land in `./launcher//` +- [ ] `.gitignore` `./launcher/` (done) - [ ] Landing page DONE — `landing/` Astro project, builds to `www/` (see 09-landing.md) - [ ] Confirm Fjord authlib-injector account flow against drasl - [ ] Decide whether to ship a pre-built instance zip vs packwiz URL import diff --git a/plan/08-tooling.md b/plan/08-tooling.md index 86e8cd4..a226e0f 100644 --- a/plan/08-tooling.md +++ b/plan/08-tooling.md @@ -1,77 +1,89 @@ -# Tooling — config rendering, mod mirror, launcher fetch +# Tooling — config rendering, pack render, ingress, fetch scripts ## Summary -Three scripts in `./tooling/` turn the two env vars + templates into a working, -offline-capable stack. All are **cautious**: they halt on unset vars or missing -inputs rather than emit half-broken output. +Scripts in `./tooling/` turn `BASE_DOMAIN` + templates into a working stack and +handle ingress (nginx vhost, TLS certs) and launcher/font fetches. All are +**cautious**: they halt on unset vars or missing inputs rather than emit +half-broken output. + +Surviving scripts: + +- `render-config.sh` — render `*.tmpl` configs (BASE_DOMAIN only) +- `render-pack.sh` — render packwiz pack templates + index +- `render-nginx.sh` — render/install the host nginx vhost (TLS terminator → caddy) +- `issue-letsencrypt.sh` — issue LE certs via OVH DNS-01 (see 15-letsencrypt.md) +- `fetch-launcher.sh` — download Fjord launcher release assets (see 06-launcher.md) +- `add-custom-mod.sh` — add a custom (non-CDN) mod to the pack +- `fetch-fonts.sh` — vendor the landing page fonts (see 09-landing.md) ## render-config.sh -Renders every `*.tmpl` into its real config by substituting `BASE_DOMAIN` / -`HOST_LAN_IP`. Caddy is excluded (it reads `{$BASE_DOMAIN}` natively). +Renders every `*.tmpl` into its real config by substituting **only** +`${BASE_DOMAIN}` (so literal `$`-syntax belonging to the target file — caddy, +toml — is left untouched). Caddy is excluded entirely (it reads `{$BASE_DOMAIN}` +natively). Targets: -- `drasl/config/config.toml.tmpl` → `drasl/config/config.toml` -- `dnsmasq/dnsmasq.conf.tmpl` → `dnsmasq/dnsmasq.conf` +- `drasl/config/config.toml.tmpl` → `drasl/config/config.toml` +- `nmsr/config.toml.tmpl` → `nmsr/config.toml` + +It then delegates pack templates (`mods/*.pw.toml.tmpl`, CustomSkinLoader, …) + +the packwiz index to `render-pack.sh` — the single source of pack render. ```bash #!/usr/bin/env bash set -euo pipefail -[ -f .env ] && set -a && . ./.env && set +a -: "${BASE_DOMAIN:?BASE_DOMAIN unset}" -: "${HOST_LAN_IP:?HOST_LAN_IP unset}" +cd "$(dirname "$0")/.." +[ -f .env ] && { set -a; . ./.env; set +a; } +: "${BASE_DOMAIN:?BASE_DOMAIN unset (set in .env)}" render() { # $1=tmpl $2=out - [ -f "$1" ] || { echo "missing template: $1"; exit 1; } - envsubst '${BASE_DOMAIN} ${HOST_LAN_IP}' < "$1" > "$2" + [ -f "$1" ] || { echo "missing template: $1" >&2; exit 1; } + envsubst '${BASE_DOMAIN}' < "$1" > "$2" echo "rendered $2" } render drasl/config/config.toml.tmpl drasl/config/config.toml -render dnsmasq/dnsmasq.conf.tmpl dnsmasq/dnsmasq.conf +render nmsr/config.toml.tmpl nmsr/config.toml +tooling/render-pack.sh ``` -> Note: `envsubst` with an explicit var list avoids clobbering `$`-syntax that -> belongs to the target file (e.g. dnsmasq/caddy literals). +## render-nginx.sh -## mirror-mods.sh +Renders `nginx/ulicraft-caddy.conf.tmpl` (the host nginx that terminates TLS in +front of caddy), substituting `$BASE_DOMAIN $APP_DIR $CADDY_HTTP_PORT`. Prints to +stdout by default; `--install` writes to sites-available, symlinks sites-enabled, +`nginx -t`, and reloads nginx. See `15-letsencrypt.md`. -Vendors mod jars for offline install and rewrites packwiz URLs to the LAN host. -See `04-packwiz.md`. Outline: -1. For each `pack/mods/*.pw.toml`, read `[download] url` + `filename`. -2. Download jar → `pack/mods-files/` (served by Caddy under packwiz subdomain). -3. Rewrite `url` to `http://packwiz.${BASE_DOMAIN}/mods-files/`. -4. `packwiz refresh` to recompute hashes + `index.toml`. -Must halt if a download fails or a hash mismatches. +## issue-letsencrypt.sh -## mirror-assets.sh - -Mirrors Minecraft asset objects for offline clients (the ~400 MB+ chunk) and lays -them out for Caddy + the Fjord "Assets Server" override. Full design in -`10-assets-mirror.md`. Verifies SHA1, idempotent, halts on mismatch. +Issues a separate cert for the apex and each `LE_SUBDOMAINS` entry via acme.sh + +OVH DNS-01 → `certs//{cert,key}.pem`. See `15-letsencrypt.md`. ## fetch-launcher.sh -Downloads all FjordLauncherUnlocked release assets → `mirror/launcher//`. -Full script in `06-launcher.md`. +Downloads FjordLauncherUnlocked release assets → `launcher//` and maintains +a `latest` symlink + stable per-OS filename symlinks. Full script in +`06-launcher.md`. ## Generated / gitignored ``` drasl/config/config.toml # rendered -dnsmasq/dnsmasq.conf # rendered -pack/mods-files/ # vendored jars -mirror/ # launcher assets + mod mirror +nmsr/config.toml # rendered +launcher/ # launcher assets +www/ # landing build output +certs/ # Let's Encrypt certs .env # secrets + host-specific ``` ## Tasks - [ ] Write `tooling/render-config.sh` (above), `chmod +x` -- [ ] Write `tooling/mirror-mods.sh` (toml parse + download + rewrite + refresh) -- [ ] Write `tooling/mirror-assets.sh` (see 10) +- [ ] Write `tooling/render-pack.sh` (pack templates + index) +- [ ] Write `tooling/render-nginx.sh` (see 15) +- [ ] Write `tooling/issue-letsencrypt.sh` (see 15) - [ ] Write `tooling/fetch-launcher.sh` (see 06) - [ ] Add `gettext` (envsubst) + `jq` to host prereqs doc -- [ ] Update `.gitignore` for rendered configs + `mirror/` + `pack/mods-files/` -- [ ] `build-stack.sh` orchestrator (preflight + `--prep` online + `--up` offline) — DONE, sequences the sub-scripts; see plan/12 +- [ ] Update `.gitignore` for rendered configs + `launcher/` + `www/` + `certs/` diff --git a/plan/09-landing.md b/plan/09-landing.md index 2349e4e..df72e98 100644 --- a/plan/09-landing.md +++ b/plan/09-landing.md @@ -2,17 +2,18 @@ ## Summary -Apex `ulicraft.local` serves a static page guiding guests through joining: +Apex `ulicraft.net` serves a static page guiding guests through joining: download launcher → add account → import modpack → connect. Built with **Astro + TypeScript** in `./landing/`; `pnpm run build` emits straight into `./www/`, -which Caddy/nginx serve at the apex. No runtime framework — output is plain -HTML/CSS plus one tiny vanilla script (copy-to-clipboard + scroll reveal). +which Caddy serves at the apex (behind host nginx). No runtime framework — +output is plain HTML/CSS plus one tiny vanilla script (copy-to-clipboard + +scroll reveal). The visual is the **"Carved from stone"** pixel design ("Claude Design", `landing/design/`): dark overworld palette, MC-GUI bevel buttons, pixel type, creeper mark, server-list panel. That design was a React/Babel-via-CDN prototype for a *fictional public SMP*; it was **ported to static Astro** with the real -modded-LAN content and offline-safe assets. +modded-LAN content and vendored assets. ## Stack & wiring @@ -20,8 +21,8 @@ modded-LAN content and offline-safe assets. - `astro.config.mjs` → `outDir: "../www"`. Build overwrites `www/`; `www/` is gitignored (generated artifact). - `BASE_DOMAIN` env read at build time in `src/data/site.ts` - (`process.env.BASE_DOMAIN ?? "ulicraft.local"`). Bake before deploy: - `BASE_DOMAIN=ulicraft.local pnpm run build`. + (`process.env.BASE_DOMAIN ?? "ulicraft.net"`). Bake before deploy: + `BASE_DOMAIN=ulicraft.net pnpm run build`. - Logo: wide wordmark from the design at `landing/public/logo.png` (hero + favicon). `image-rendering: pixelated` keeps it crisp. @@ -31,7 +32,7 @@ landing/ ├── design/ # "Claude Design" prototype (reference, not built) ├── public/ │ ├── logo.png # hero wordmark (from design/assets/ulicraft-logo.png) -│ └── fonts/ # vendored woff2 + fonts.css (offline-safe) +│ └── fonts/ # vendored woff2 + fonts.css (self-contained) └── src/ ├── data/site.ts # single source: content + theme knobs ├── styles/main.css # ported design system (mood/hero/bevels) @@ -39,10 +40,11 @@ landing/ └── pages/index.astro # composition + copy/reveal script + dust ``` -## Fonts — vendored for offline LAN +## Fonts — vendored locally -The design pulled fonts from Google Fonts CDN; that breaks on offline LAN day. -`tooling/fetch-fonts.sh` downloads the woff2 + a URL-rewritten `fonts.css` into +The design pulled fonts from Google Fonts CDN; we vendor them instead to keep the +page self-contained. `tooling/fetch-fonts.sh` downloads the woff2 + a +URL-rewritten `fonts.css` into `landing/public/fonts/`. `index.astro` links `/fonts/fonts.css`. Re-run the script only if the font set changes (keep families in sync with `main.css` `--font-*`): Pixelify Sans, Space Grotesk, Press Start 2P, Silkscreen, VT323. @@ -59,7 +61,7 @@ script only if the font set changes (keep families in sync with `main.css` ## Languages (i18n) -Three locales, static, build-time, offline-safe — **English** (default, `/`), +Three locales, static, build-time — **English** (default, `/`), **Spanish** (`/es/`), **Euskera** (`/eu/`). - One template `src/pages/[...lang].astro` with `getStaticPaths` emits all three @@ -85,8 +87,8 @@ Three locales, static, build-time, offline-safe — **English** (default, `/`), - Features ×4: kitchen-sink pack · self-hosted Drasl identity+skins · one-click packwiz · built-to-last (homelab + 6h backups). - How to Join ×4: **1** Fjord launcher (3 OS) → **2** authlib account - (`auth.${BASE_DOMAIN}/authlib-injector`) + register link + CA-cert note → - **3** packwiz import (`packwiz.${BASE_DOMAIN}/pack.toml`) → **4** connect. + (`auth.${BASE_DOMAIN}/authlib-injector`) + register link → + **3** packwiz import (`pack.${BASE_DOMAIN}/pack.toml`) → **4** connect. - Footer: Mojang trademark disclaimer. ## Launcher download links (Option A — stable symlinks) @@ -97,9 +99,9 @@ Page links to fixed names so they survive launcher version bumps: - `fjord-linux-x86_64.AppImage` `tooling/fetch-launcher.sh` must create these as symlinks under -`mirror/launcher/latest/` pointing at the real versioned assets. **Keep the names -in sync** between `site.ts` and the script. Mounted so they resolve at -`/launcher/latest/…` (see nginx/Caddy ingress). +`launcher/latest/` pointing at the real versioned assets. **Keep the names in +sync** between `site.ts` and the script. Mounted so they resolve at +`/launcher/latest/…` (see Caddy `10-static.caddy`). ## Tasks @@ -107,9 +109,9 @@ in sync** between `site.ts` and the script. Mounted so they resolve at - [x] Port design → static Astro (components + main.css + index.astro) - [x] Real content in `site.ts`; theme knobs replace TweaksPanel - [x] i18n: en/es/eu via `[...lang].astro` + `i18n/ui.ts`; nav language switcher -- [x] `BASE_DOMAIN=ulicraft.local pnpm run build` → verified `www/` output + screenshot +- [x] `BASE_DOMAIN=ulicraft.net pnpm run build` → verified `www/` output + screenshot - [ ] Extend `fetch-launcher.sh`: symlink stable names → versioned files -- [ ] Confirm ingress serves apex from `./www` and `/launcher/latest/*` from mirror +- [ ] Confirm ingress serves apex from `./www` and `/launcher/latest/*` from `./launcher` - [ ] Optional: try `mood: nether` / `hero: split` for party day - [ ] Optional: wire real player count (option C) via mc-monitor JSON if wanted ``` diff --git a/plan/10-assets-mirror.md b/plan/10-assets-mirror.md deleted file mode 100644 index f8a807f..0000000 --- a/plan/10-assets-mirror.md +++ /dev/null @@ -1,114 +0,0 @@ -# Assets mirror — offline Minecraft asset objects for clients - -## Summary - -Guest launchers (FjordLauncherUnlocked, a Prism soft-fork) download ~400–600 MB of -**asset objects** (textures, sounds, lang) from `resources.download.minecraft.net` -on first instance launch. For an air-gapped party that's the single biggest -client-side download. Fjord/Prism expose an **"Assets Server"** override, so we -mirror the objects onto our host and point guests at it. - -`tooling/mirror-assets.sh` downloads + verifies every asset object for the pinned -Minecraft version and lays them out so Caddy can serve them in the exact path -shape the launcher expects. - -## Fjord/Prism override fields (confirmed) - -Settings → APIs → **Services** tab: - -| Field | Setting key | Default | What it overrides | -|---|---|---|---| -| **Assets Server** | `ResourceURL` | `https://resources.download.minecraft.net/` | asset **objects only** | -| **Metadata Server** | (meta URL) | `https://meta.prismlauncher.org/v1/` | Prism-format version meta | - -- Added in Prism 10.0.0 (PR #3875); inherited by Fjord. -- **There is NO libraries-URL field.** Library + client.jar URLs live inside the - version/meta JSON; only a meta mirror (or DNS spoof) can redirect them. - -### ⚠️ HTTPS is forced - -PR #3875 validates the Assets Server URL as **HTTPS and auto-rewrites `http://` -→ `https://`**. So the assets mirror **must be served over TLS** — plain `:80` -Caddy will not work for this endpoint. This pulls local TLS forward for one -subdomain ahead of the rest of the stack. - -Options to satisfy it: -1. Caddy TLS on `assets.${BASE_DOMAIN}` with a cert from a local CA - (step-ca); guests import the CA root once. Cleanest, matches homelab. -2. Caddy `internal` CA / self-signed — guests must trust it (more friction, per - machine). -3. Sidestep entirely with **pre-seed** (below) and skip the Assets Server override. - -## What this mirror does and does NOT cover - -| Client download | Host | Covered here? | -|---|---|---| -| asset objects (bulk) | resources.download.minecraft.net | ✅ this mirror + Assets Server field | -| asset index json | meta/piston | ⚠️ fetched here for completeness; launcher still pulls it from meta unless meta is also mirrored | -| libraries | libraries.minecraft.net | ❌ no override field | -| client.jar | piston-data.mojang.com | ❌ | -| version/meta json | meta.prismlauncher.org | ❌ (separate Metadata Server field) | - -**Full client air-gap therefore needs more than this.** For the uncovered, small -(~150 MB) remainder, the pragmatic fallback is **pre-seed**: each guest launches -the instance once while online (on arrival, before air-gapping); Prism caches -libraries/jar/meta locally. The assets mirror still saves the heavy 400 MB+ per -guest. A full meta+libraries mirror (mcm-style / PrismLauncher-meta gen) is the -heavyweight alternative, deferred. - -## tooling/mirror-assets.sh — design - -Input: `MC_VERSION` (default `1.21.1`, or read from the pre-baked server's -`version.json`). Cautious: verifies SHA1 on every file, halts on mismatch, -idempotent (skips already-valid files). - -``` -1. GET https://piston-meta.mojang.com/mc/game/version_manifest_v2.json - → find entry for $MC_VERSION → version-json URL -2. GET version-json → read .assetIndex { id, url, sha1 } -3. GET asset index → mirror/assets/indexes/.json (verify sha1) -4. For each object in index .objects{}: - hash=; rel="${hash:0:2}/${hash}" - dest="mirror/assets/objects/${rel}" - [ -f "$dest" ] && sha1 ok → skip - else GET https://resources.download.minecraft.net/${rel} → $dest ; verify sha1 -5. Report counts; non-zero exit on any failure. -``` - -Layout served by Caddy: -``` -mirror/assets/ -├── indexes/.json # e.g. 17.json / 1.21.json (for pre-seed/meta use) -└── objects/<2hex>/ # launcher requests /<2hex>/ -``` - -### Caddy wiring - -Serve the **objects** dir at the web root of an HTTPS vhost so the launcher's -`/<2hex>/` resolves directly: - -``` -https://assets.{$BASE_DOMAIN} { - tls # or `tls internal` - root * /srv/assets/objects - file_server -} -``` -Mount `./mirror/assets/objects:/srv/assets/objects:ro`. Add network alias -`assets.${BASE_DOMAIN}` to Caddy (same trick as auth/packwiz). -Fjord "Assets Server" = `https://assets.${BASE_DOMAIN}/`. - -## Tasks - -- [ ] Write `tooling/mirror-assets.sh` (above); `chmod +x`; deps `curl` + `jq` + `sha1sum` -- [ ] Decide MC_VERSION source: hardcode `1.21.1` vs read pre-baked `version.json` -- [ ] Run pre-party while online; verify `mirror/assets/objects/` populated + sha1 clean -- [ ] Stand up TLS for `assets.${BASE_DOMAIN}` (step-ca or `tls internal`) — REQUIRED (HTTPS forced) -- [ ] Add `assets.` Caddy vhost + network alias + mount -- [ ] Document guest step: Settings → APIs → Assets Server = `https://assets.${BASE_DOMAIN}/` -- [ ] Document guest CA-trust step (import local CA root) if not using a publicly-trusted cert -- [ ] Confirm in Fjord's actual UI that the field exists + HTTPS-forcing behavior matches Prism -- [ ] Fallback path: pre-seed instructions (launch once online) for libraries/jar/meta -- [ ] Defer decision: full meta+libraries mirror (mcm / PrismLauncher-meta) vs pre-seed -- [ ] `.gitignore` already covers `mirror/` -``` diff --git a/plan/11-full-airgap-mirror.md b/plan/11-full-airgap-mirror.md deleted file mode 100644 index 24a9f28..0000000 --- a/plan/11-full-airgap-mirror.md +++ /dev/null @@ -1,132 +0,0 @@ -# Full client air-gap — DNS + TLS transparent mirror - -## Summary - -Goal: a guest laptop with **zero internet** can install + launch the modded -instance entirely from the LAN. The chosen architecture is a **transparent -mirror**: dnsmasq points the real upstream hostnames at our host, Caddy serves -byte-exact copies of the files at the same paths, and `tls internal` mints certs -those hostnames validate against (guests trust the Caddy root CA once). - -The launcher is **unmodified** — it requests the real Mojang/Prism/NeoForge URLs; -DNS just resolves them to us. This is cleaner than rewriting Prism-format -metadata, and it makes the `10-assets-mirror.md` "Assets Server" field -**redundant** (the launcher hits the real resources host, which now resolves to -the LAN). `10` is kept as the lighter objects-only alternative. - -## Decision record - -- **TLS for LAN party = Caddy `tls internal`** (not step-ca). Zero extra infra; - Caddy is the local CA. step-ca stays a future-homelab reminder (see `02-caddy` - open item). Guests import the Caddy root CA once. -- **Full air-gap chosen** over pre-seed. - -## Hosts to mirror (NeoForge 1.21.1 instance) - -| Host | Serves | Notes | -|---|---|---| -| `meta.prismlauncher.org` | Prism-format component meta (MC, LWJGL, NeoForge) | path `/v1/...` | -| `piston-meta.mojang.com` | version manifest + version json | | -| `piston-data.mojang.com` | client.jar, asset index, some objects | | -| `libraries.minecraft.net` | vanilla MC libraries (maven layout) | | -| `maven.neoforged.net` | NeoForge libraries | more mavens may appear → capture catches them | -| `resources.download.minecraft.net` | asset objects (~400 MB+) | the bulk | - -**Not mirrored — auth runtime** (`session.minecraft.net`, `textures.minecraft.net`, -`api.mojang.com`, `sessionserver.mojang.com`): these are redirected to **drasl** -by the authlib-injector account already; leave them to drasl. - -## How it works - -``` -guest laptop (LAN DNS = our dnsmasq, trusts Caddy root CA) - │ resolves meta.prismlauncher.org, libraries.minecraft.net, … → HOST_LAN_IP - ▼ -dnsmasq ── spoof A-records ──► Caddy (tls internal, cert per host) - └─ static file_server, byte-exact path mirror -``` - -### dnsmasq (extends 01) - -Add explicit spoof records alongside the `*.${BASE_DOMAIN}` wildcard: - -``` -address=/meta.prismlauncher.org/${HOST_LAN_IP} -address=/piston-meta.mojang.com/${HOST_LAN_IP} -address=/piston-data.mojang.com/${HOST_LAN_IP} -address=/libraries.minecraft.net/${HOST_LAN_IP} -address=/maven.neoforged.net/${HOST_LAN_IP} -address=/resources.download.minecraft.net/${HOST_LAN_IP} -``` -> Only effective while the guest uses our dnsmasq as resolver. Fully reversible — -> off-LAN the real hosts resolve normally. Keep this list in sync with whatever -> the capture step (below) actually observed. - -### Caddy (extends 02) - -One vhost per spoofed host, each rooted at its mirror tree, all `tls internal`: - -``` -piston-data.mojang.com, libraries.minecraft.net, resources.download.minecraft.net, -meta.prismlauncher.org, piston-meta.mojang.com, maven.neoforged.net { - tls internal - root * /srv/mirror/{host} - file_server -} -``` -(Practically: a small snippet per host, or Caddy on-demand TLS + a path-routed -`map`. Keep explicit per-host for clarity.) Add each as a **network alias** on -Caddy too, so the `minecraft`/tooling containers resolve them internally. - -Mount `./mirror/upstream/:/srv/mirror/:ro`. - -## Capture (the actual work) — `tooling/mirror-airgap.sh` - -Byte-exact mirroring needs the exact URL set. Capture it once, online: - -1. **Record**: launch the instance once with the launcher pointed at a logging - forward-proxy (mitmproxy `--mode reverse`/transparent, or even Caddy as a - logging reverse proxy). Collect every requested absolute URL. -2. **Fetch**: for each URL, download to `mirror/upstream//`, - preserving path exactly. Verify SHA1 where the source json provides it - (libraries, assets). Halt on mismatch. -3. **Assets shortcut**: the objects (resources.download…) can be filled directly - from the asset index (same logic as `10-assets-mirror.md`) without proxy - capture — reuse that loop into `mirror/upstream/resources.download.minecraft.net/<2hex>/`. -4. **Verify offline**: re-run a launch on a second machine with internet cut and - DNS set to our dnsmasq + CA trusted. Fix any 404 → missing URL → add to fetch. - -> Cautious by design: the proxy-capture enumerates whatever hosts/paths are *truly* -> hit (incl. unforeseen maven mirrors), so the mirror is complete rather than -> guessed. Re-capture if MC/NeoForge versions change. - -## Guest one-time setup (offline) - -1. Set laptop DNS → our dnsmasq (LAN IP). (Often via DHCP/router, or manual.) -2. Import the **Caddy root CA** into the OS/browser trust store. -3. Install Fjord, add authlib-injector account (`auth.${BASE_DOMAIN}`), import pack, - launch. All downloads resolve to the LAN mirror. - -No launcher API-field overrides needed in this architecture. - -## Open / reminders - -- [ ] **Reminder (kept):** graduate `tls internal` → step-ca for the persistent - homelab; revisit cert distribution then. -- [ ] Confirm exact `meta.prismlauncher.org` path layout Fjord requests (fork may - pin a different meta host/version). -- [ ] Verify Caddy `tls internal` certs validate in Fjord's Qt network stack with - the root CA imported (no extra pinning). -- [ ] mitmproxy vs Caddy-log for the capture step — pick one. -- [ ] CA-trust UX per OS (Windows/macOS/Linux) — document for guests. - -## Tasks - -- [ ] Write `tooling/mirror-airgap.sh` (capture + fetch + sha1 verify + offline re-test) -- [ ] Add spoof `address=` lines to `dnsmasq/dnsmasq.conf.tmpl` (01) -- [ ] Add per-host `tls internal` vhosts + network aliases + mounts to Caddy (02) -- [ ] Export Caddy root CA; build a guest CA-import mini-guide (link from landing page) -- [ ] Run capture online for MC 1.21.1 + NeoForge 21.1.x; populate `mirror/upstream/` -- [ ] Offline acceptance test on a clean machine (internet cut, LAN DNS, CA trusted) -- [ ] `.gitignore` already covers `mirror/` -``` diff --git a/plan/12-build-order.md b/plan/12-build-order.md index e05f54c..eda98d7 100644 --- a/plan/12-build-order.md +++ b/plan/12-build-order.md @@ -1,59 +1,62 @@ # Build order — one commit per task -Dependency-ordered. Each numbered item = one git commit. `main` currently has -**zero commits**; item 0 is the baseline. Operational steps (pre-bake, capture, -acceptance test) need internet/hardware and are NOT commits — marked `[ops]`. +Dependency-ordered. Each numbered item = one git commit. Operational steps +(prep, acceptance test) need internet/hardware and are NOT commits — marked +`[ops]`. > Commit message convention: Conventional Commits, scope = service. Footer: -> `Co-Authored-By: Claude Opus 4.7 (1M context) `. +> `Co-Authored-By: Claude Opus 4.8 (1M context) `. ## Phase 0 — baseline -0. `chore: baseline scaffolding` — existing untracked tree (CLAUDE.md, plan/, - current docker-compose.yml, extras/, .gitignore, .env.example). +0. `chore: baseline scaffolding` — CLAUDE.md, plan/, docker-compose.yml, + runtime/, .gitignore, .env.example. ## Phase 1 — config foundation -1. `feat(env): add BASE_DOMAIN + HOST_LAN_IP to .env.example` -2. `feat(tooling): add render-config.sh (envsubst, halt on unset var)` +1. `feat(env): add BASE_DOMAIN + RCON_PASSWORD + CADDY_* to .env.example` +2. `feat(tooling): add render-config.sh (envsubst BASE_DOMAIN, halt on unset)` -## Phase 2 — DNS + ingress (no app yet, but the backbone) -3. `feat(dnsmasq): add dnsmasq.conf.tmpl (wildcard *.BASE_DOMAIN)` -4. `feat(caddy): add Caddyfile (apex + auth + packwiz vhosts)` -5. `feat(compose): add dnsmasq + caddy services + network aliases` +## Phase 2 — ingress (caddy internal router) +3. `feat(caddy): add conf.d snippets (00-core auth+pack, 10-static apex+launcher)` +4. `feat(compose): add caddy service (127.0.0.1:CADDY_HTTP_PORT) + network aliases` ## Phase 3 — auth -6. `feat(drasl): add config.toml.tmpl (password login, SignPublicKeys=false)` -7. `refactor(compose): drasl internal-only behind caddy; authlib URL → auth.` +5. `feat(drasl): add config.toml.tmpl (password login, SignPublicKeys=false)` +6. `refactor(compose): drasl internal-only behind caddy; authlib URL → auth.` ## Phase 4 — modpack -8. `feat(pack): packwiz init + initial mod curation` -9. `feat(tooling): add mirror-mods.sh (vendor jars + rewrite .pw.toml URLs)` -10. `refactor(compose): minecraft via PACKWIZ_URL; extras→runtime` +7. `feat(pack): packwiz init + initial mod curation` +8. `feat(tooling): add render-pack.sh + add-custom-mod.sh` +9. `refactor(compose): minecraft via PACKWIZ_URL` ## Phase 5 — landing -11. `feat(landing): Astro+TS onboarding site → www/` (files already drafted) -12. `chore(landing): wire logo + .gitignore for www/ + build output` +10. `feat(landing): Astro+TS onboarding site → www/` +11. `chore(landing): wire logo + .gitignore for www/` ## Phase 6 — launcher distribution -13. `feat(tooling): add fetch-launcher.sh (all platforms + stable symlinks)` +12. `feat(tooling): add fetch-launcher.sh (all platforms + stable symlinks)` -## Phase 7 — full client air-gap (chosen path, plan/11) -14. `feat(tooling): add mirror-airgap.sh (capture + fetch + sha1 verify)` -15. `feat(dnsmasq): add air-gap spoof records for upstream hosts` -16. `feat(caddy): add tls internal vhosts for upstream mirror hosts` +## Phase 7 — extra services +13. `feat(avatar): add nmsr service (Drasl-backed) + 40-avatar.caddy` +14. `feat(status): add uptime-kuma service + 50-status.caddy` +15. `feat(distribution): add 30-distribution.caddy (static, another repo)` -## Phase 8 — backups + orchestration -17. `feat(compose): add mc-backup service (RCON, 6h)` -18. `feat(tooling): add build-stack.sh orchestrator` +## Phase 8 — backups +16. `feat(compose): add mc-backup service (RCON, 6h)` -## Phase 9 — operational (no commits) -- [ops] Run `build-stack.sh --prep` online: render configs, build landing, - mirror mods, mirror air-gap, fetch launcher, **pre-bake** server volume. -- [ops] Offline acceptance test: clean machine, internet cut, LAN DNS = dnsmasq, - Caddy CA trusted → install Fjord, import pack, join. -- [ops] If green: tag `v1-lan-ready`. +## Phase 9 — production ingress / TLS +17. `feat(nginx): add ulicraft-caddy.conf.tmpl + render-nginx.sh` +18. `feat(tls): add issue-letsencrypt.sh (OVH DNS-01) + LE env block` + +## Phase 10 — operational (no commits) +- [ops] Prep online (once): `tooling/render-config.sh`, build landing + (`cd landing && pnpm run build`), `tooling/fetch-launcher.sh`, fetch + authlib-injector into `runtime/`. +- [ops] TLS: `tooling/issue-letsencrypt.sh`, then + `tooling/render-nginx.sh --install`. +- [ops] Bring up: `docker compose up -d --build`. +- [ops] Acceptance: install Fjord, add drasl account, import pack, join. +- [ops] If green: tag `v1-ready`. ## Notes -- Items 3–5 can be smoke-tested (`docker compose up dnsmasq caddy`) before later - phases exist — Caddy serves a 404/landing, dnsmasq resolves. -- Items 14–16 depend on a working online launch to capture the URL set first. +- Bring-up is a single command — no run modes, no override compose files. - Each commit should leave the tree in a non-broken state (compose still parses). diff --git a/plan/13-dns-and-run-modes.md b/plan/13-dns-and-run-modes.md deleted file mode 100644 index 853504d..0000000 --- a/plan/13-dns-and-run-modes.md +++ /dev/null @@ -1,65 +0,0 @@ -# DNS strategy + run modes - -How names resolve and which layers come up. Supersedes the "dnsmasq is core" -assumption in `01-dnsmasq.md`. - -## Domain: `.lan`, not `.local` - -`BASE_DOMAIN=ulicraft.lan` by default. `.local` is intercepted by mDNS on -macOS/Linux and does **not** resolve through a normal unicast DNS server -(that was the `DNS_PROBE_FINISHED_NXDOMAIN`). Use `.local` only with the mDNS -path below. - -## Run modes — `tooling/build-stack.sh --up ` - -| Mode | Compose layers | Use | -|---|---|---| -| `online` | base + static | internet present; landing + launcher; no mirror | -| `airgap` | base + static + mirror | no internet; full asset mirror on `:443` (default) | -| `core` | base | debugging | - -Base = drasl, minecraft, mc-backup, caddy(auth+packwiz). `static` = apex landing + -launcher + `/ca.crt`. `mirror` = upstream spoof vhosts (`tls internal`) + `:443` + -caddy aliases. `--up` prints the DNS records for the chosen mode. - -## DNS: your own party server (primary) - -You run the party DNS. Generate records with `tooling/dns-records.sh `: -- `online` → service names (`${BASE_DOMAIN}`, `auth.`, `packwiz.`, `mc.`) → `HOST_LAN_IP` -- `airgap` → the above **plus** the spoofed upstreams (`meta.prismlauncher.org`, - `piston-meta`/`piston-data.mojang.com`, `libraries.minecraft.net`, - `maven.neoforged.net`, `resources.download.minecraft.net`) → `HOST_LAN_IP` - -Output formats: plain, `/etc/hosts`, BIND zone, dnsmasq `address=`, plus the -Minecraft SRV line. Minecraft needs no port (`mc.${BASE_DOMAIN}` defaults to 25565). - -## Optional resolver layers - -- **dnsmasq** (`docker-compose.dnsmasq.yml`) — turnkey DNS if you don't run your - own. Binds `${HOST_LAN_IP}:53`. Serves the rendered `dnsmasq/dnsmasq.conf` - (wildcard + airgap spoofs). -- **mDNS / Avahi** (`docker-compose.avahi.yml`, `ENABLE_MDNS=true`) — zero-config - `.local` for guests (macOS/Linux native; Windows needs Bonjour). See below. - -## mDNS path (ENABLE_MDNS=true + BASE_DOMAIN=*.local) - -The `avahi` container publishes the service names to the **host's** avahi-daemon -over its D-Bus socket (it does NOT run its own daemon — that collides with the -host on `:5353`). - -Requirements / gotchas (build-stack warns on these): -- host runs `avahi-daemon`; `/run/dbus/system_bus_socket` mounted in. -- `security_opt: apparmor=unconfined` on the container (docker-default AppArmor - blocks the D-Bus publish). -- multi-interface hosts (e.g. many docker bridges) → avahi self-collides - ("Local name collision"); set `allow-interfaces=` in - `/etc/avahi/avahi-daemon.conf`. -- mDNS resolves **only** `.local`, so it cannot serve the air-gap upstream - spoofs (real public hostnames) — those always need real DNS. - -## Status (tested 2026-05-24) - -- `.lan` + own-DNS path: verified end-to-end (apex/auth/packwiz/ca.crt/mirror - all 200, minecraft healthy). -- mDNS publish mechanism: verified reaching host avahi; full green-light blocked - on this dev box only by its 38 docker bridges (allow-interfaces fixes it). diff --git a/plan/14-deploy.md b/plan/14-deploy.md index 008ffbe..8f46b93 100644 --- a/plan/14-deploy.md +++ b/plan/14-deploy.md @@ -7,9 +7,10 @@ Production host for the Ulicraft stack. | Host | `cochi` (SSH alias) | | Path | `/home/ubuntu/mc/ulicraft-server-v1` | | Branch | `main` | -| Run mode | `online` (compose files: `docker-compose.yml` + `docker-compose.static.yml`) | -| Auth | Drasl (base stack — no Blessing Skin override) | -| Restart | **hard** (`compose down` → `build-stack.sh --up`) — brief full downtime | +| Compose | single `docker-compose.yml` (no overrides) | +| Auth | Drasl (password login) | +| Ingress | host nginx (Let's Encrypt TLS) → caddy → services (see 15-letsencrypt.md) | +| Restart | **hard** (`compose down` → `compose up --build`) — brief full downtime | ## Routine redeploy @@ -17,9 +18,9 @@ Production host for the Ulicraft stack. ssh cochi cd /home/ubuntu/mc/ulicraft-server-v1 git pull --ff-only -# hard restart: tear the stack down, then bring it back up (renders configs first) -docker compose -f docker-compose.yml -f docker-compose.static.yml down --remove-orphans -tooling/build-stack.sh --up online +tooling/render-config.sh # re-render drasl/nmsr/pack configs from .env +docker compose down --remove-orphans +docker compose up -d --build ``` Or as a one-shot from your workstation: @@ -28,19 +29,20 @@ Or as a one-shot from your workstation: ssh cochi 'set -e cd /home/ubuntu/mc/ulicraft-server-v1 git pull --ff-only - docker compose -f docker-compose.yml -f docker-compose.static.yml down --remove-orphans - tooling/build-stack.sh --up online' + tooling/render-config.sh + docker compose down --remove-orphans + docker compose up -d --build' ``` -`build-stack.sh --up online` runs preflight (checks `.env`), `render-config.sh` -(which renders configs + `render-pack.sh` for the packwiz pack), then -`docker compose ... up -d` with the online file set. +`render-config.sh` re-renders configs (and `render-pack.sh` for the packwiz +pack) from `.env` before the stack comes back up. ## What survives a hard restart Named volumes persist — world and auth data are safe: - `mc_data` — world + installed NeoForge/mods - `drasl_state` — Drasl accounts/skins +- `kuma_data` — Uptime Kuma config/history - `backups/` — mc-backup snapshots `down` removes **containers**, not volumes. Players are disconnected during the @@ -49,22 +51,20 @@ restart (a few minutes); they reconnect once `minecraft` is healthy again. ## Prerequisites (one-time, on `cochi`) - SSH access as the `cochi` alias. -- `.env` present and complete: `BASE_DOMAIN`, `HOST_LAN_IP`, `RCON_PASSWORD` - (`render-config.sh` halts on any unset var, even in online mode). +- `.env` present and complete: `BASE_DOMAIN`, `RCON_PASSWORD`, `CADDY_HTTP_PORT`, + `CADDY_HTTP_BIND`, `DISTRIBUTION_WEB_ROOT`, and the Let's Encrypt block + (`render-config.sh` halts on an unset `BASE_DOMAIN`). - Docker + compose plugin, `packwiz`, `envsubst`, `git`. -- **First deploy only** — heavy online prep (fetch launcher, pre-bake the server - volume, etc.): - ```bash - tooling/build-stack.sh --prep - ``` - Not needed for routine redeploys; `--up` alone re-syncs the pack and configs. +- Host nginx + Let's Encrypt certs installed once — see `15-letsencrypt.md`. +- **First deploy only** — online prep (build landing, fetch launcher, fetch + authlib into `runtime/`). Not needed for routine redeploys. ## Verify ```bash -docker compose -f docker-compose.yml -f docker-compose.static.yml ps -docker compose -f docker-compose.yml -f docker-compose.static.yml logs -f minecraft # watch for "Done" -curl -fsS "http://auth.$(grep '^BASE_DOMAIN=' .env | cut -d= -f2)/" >/dev/null && echo "auth ok" +docker compose ps # caddy, drasl, minecraft, mc-backup, nmsr, uptime-kuma up +docker compose logs -f minecraft # watch for "Done" +curl -fsS "https://auth.$(grep '^BASE_DOMAIN=' .env | cut -d= -f2)/" >/dev/null && echo "auth ok" ``` ## Rollback @@ -72,15 +72,14 @@ curl -fsS "http://auth.$(grep '^BASE_DOMAIN=' .env | cut -d= -f2)/" >/dev/null & ```bash git log --oneline -5 # find the last-good commit git checkout # or: git reset --hard -docker compose -f docker-compose.yml -f docker-compose.static.yml down --remove-orphans -tooling/build-stack.sh --up online +docker compose down --remove-orphans +docker compose up -d --build ``` ## Notes -- **Online mode needs internet** on `cochi` at restart (no air-gap mirror): the - server installs NeoForge + mods over the network. For an internet-less deploy, - switch to `airgap` (needs `--prep` first) — see `plan/13-dns-and-run-modes.md`. -- Switching to the **Blessing Skin** auth variant changes the file set to - `-f docker-compose.yml -f docker-compose.blessingskin.yml` (see - `plan/03b-blessing-skin.md`); update the `down`/`up` commands accordingly. +- Production needs internet on `cochi` at restart: the server installs/re-syncs + NeoForge + mods over the network. +- All ingress goes through host nginx → caddy. After a redeploy, nginx config is + unchanged; only re-run `tooling/render-nginx.sh --install` if `BASE_DOMAIN` / + `CADDY_HTTP_PORT` changed (see `15-letsencrypt.md`). diff --git a/plan/15-letsencrypt.md b/plan/15-letsencrypt.md index 745aa2b..347222c 100644 --- a/plan/15-letsencrypt.md +++ b/plan/15-letsencrypt.md @@ -1,25 +1,17 @@ # TLS — Let's Encrypt (OVH DNS-01) + host nginx ingress -Production serves real, publicly-trusted certs via the **host's nginx**. Caddy is -no longer the production ingress — it moved to its own compose file for the -LAN/air-gap path. - -## Two ingress paths - -| Path | Ingress | Compose | TLS | -|------|---------|---------|-----| -| Production | host **nginx** | `docker-compose.yml` + `docker-compose.nginx.yml` | Let's Encrypt (this doc) | -| LAN / air-gap | **caddy** container | `docker-compose.yml` + `docker-compose.caddy.yml` (+ static/mirror) | Caddy internal CA | - -`build-stack.sh` drives the caddy path (adds `docker-compose.caddy.yml` -automatically). The nginx path is a manual deploy (below). This supersedes the -ingress part of `plan/14-deploy.md` for production. +Production serves real, publicly-trusted certs via the **host's own nginx**. +nginx terminates TLS and reverse-proxies every vhost to the caddy container +(published localhost-only on `${CADDY_HTTP_PORT}`) by Host header. caddy is the +internal router; nginx is the public TLS terminator. This is the only ingress +path — there are no compose overrides. ## Certs: one per name, via OVH DNS-01 `tooling/issue-letsencrypt.sh` issues a **separate** cert for the apex and each -subdomain (`${BASE_DOMAIN}`, `auth.…`, `pack.…`) using acme.sh + the OVH DNS-01 -challenge — no inbound ports needed, so it works even on a LAN-only host. +subdomain (`${BASE_DOMAIN}`, `auth.…`, `pack.…`, plus the rest of +`LE_SUBDOMAINS`) using acme.sh + the OVH DNS-01 challenge — no inbound ports +needed, so it works regardless of public reachability. ### One-time setup @@ -36,7 +28,7 @@ challenge — no inbound ports needed, so it works even on a LAN-only host. ### Issue ```bash -tooling/issue-letsencrypt.sh # real certs +tooling/issue-letsencrypt.sh # real certs LE_STAGING=1 tooling/issue-letsencrypt.sh # dry run against the LE staging CA ``` @@ -46,31 +38,30 @@ the reload with `RELOAD_CMD=...` if nginx isn't systemd-managed. ## nginx -1. Stack up with the nginx override (publishes drasl on `127.0.0.1:25585`): +1. Bring the stack up (caddy publishes on `127.0.0.1:${CADDY_HTTP_PORT}`): ```bash - docker compose -f docker-compose.yml -f docker-compose.nginx.yml up -d + docker compose up -d --build ``` -2. Render + install the vhost (`nginx/ulicraft.conf.tmpl`): +2. Render + install the vhost (`nginx/ulicraft-caddy.conf.tmpl`): ```bash - APP_DIR=/home/ubuntu/mc/ulicraft-server-v1 BASE_DOMAIN=ulicraft.net \ - envsubst '$APP_DIR $BASE_DOMAIN' \ - < nginx/ulicraft.conf.tmpl | sudo tee /etc/nginx/sites-available/ulicraft.conf - sudo ln -sf /etc/nginx/sites-available/ulicraft.conf /etc/nginx/sites-enabled/ - sudo nginx -t && sudo systemctl reload nginx + tooling/render-nginx.sh --install ``` - nginx then: apex + `pack.*` as static files (`www`, `pack`, `custom`), - `auth.*` → drasl. Ensure `www-data` can read `$APP_DIR/{www,pack,custom,certs}`. + This substitutes `$BASE_DOMAIN $APP_DIR $CADDY_HTTP_PORT`, writes to + `sites-available`, symlinks `sites-enabled`, runs `nginx -t`, and reloads + nginx. nginx then terminates TLS per vhost (certs under `$APP_DIR/certs`) and + reverse-proxies each Host to caddy on `127.0.0.1:${CADDY_HTTP_PORT}`. Ensure + `www-data` can read `$APP_DIR/certs`. ## DNS -Point the public/party DNS for `${BASE_DOMAIN}`, `auth.${BASE_DOMAIN}`, -`pack.${BASE_DOMAIN}` at the host. The Minecraft container reaches them via -`extra_hosts: host-gateway` (set in `docker-compose.nginx.yml`) and uses HTTPS — -the JVM trusts Let's Encrypt with no CA import (the big win over the internal CA). +Point the public DNS for `${BASE_DOMAIN}` and every subdomain +(`auth.` `pack.` `avatar.` `status.` `distribution.` `www.`) at the host running +nginx. DNS is configured **outside this repo**. The Minecraft container reaches +the stack's own names via caddy's `mcnet` aliases, not public DNS. ## Why DNS-01 (not HTTP-01) -`HOST_LAN_IP` shows a LAN host; HTTP-01/TLS-ALPN-01 need public inbound on 80/443. DNS-01 only needs the OVH API, so it issues regardless of public reachability — and `ulicraft.net` is a real OVH-managed domain, which LE requires (it never -issues for `.lan`/`.local`). +issues for `.lan`/`.local`). HTTP-01/TLS-ALPN-01 would need public inbound on +80/443. The JVM trusts the resulting Let's Encrypt certs with no CA import. diff --git a/tooling/build-stack.sh b/tooling/build-stack.sh deleted file mode 100755 index 7d33bb5..0000000 --- a/tooling/build-stack.sh +++ /dev/null @@ -1,185 +0,0 @@ -#!/usr/bin/env bash -# build-stack.sh — orchestrate the Ulicraft stack: offline prep then bring-up. -# -# Phases: -# preflight always — verify .env + required tools -# --prep ONLINE — render configs, build landing, mirror mods + air-gap, -# fetch launcher, pre-bake the server volume -# --up OFFLINE — docker compose up -d the full stack -# (no flag) preflight + print what each phase would do, then exit -# -# Cautious by design: halts on the first missing var, missing tool, or failed -# step. Each sub-script is itself responsible for its own integrity checks. -set -euo pipefail - -cd "$(dirname "$0")/.." # repo root -COMPOSE=(docker compose) - -# ---- helpers --------------------------------------------------------------- -log() { printf '\n\033[1;32m==>\033[0m %s\n' "$*"; } -warn() { printf '\033[1;33m!!\033[0m %s\n' "$*" >&2; } -die() { printf '\033[1;31mxx\033[0m %s\n' "$*" >&2; exit 1; } - -need_tool() { command -v "$1" >/dev/null 2>&1 || die "missing required tool: $1"; } -have_script() { [ -x "tooling/$1" ] || die "missing/not-executable: tooling/$1"; } - -# ---- preflight ------------------------------------------------------------- -preflight() { - log "preflight" - [ -f .env ] || die ".env not found (copy .env.example and fill it)" - # shellcheck disable=SC1091 - set -a; . ./.env; set +a - : "${BASE_DOMAIN:?BASE_DOMAIN unset in .env}" - : "${HOST_LAN_IP:?HOST_LAN_IP unset in .env}" - : "${RCON_PASSWORD:?RCON_PASSWORD unset in .env}" - - need_tool docker - ${COMPOSE[@]} version >/dev/null 2>&1 || die "docker compose plugin not available" - need_tool envsubst - need_tool jq - need_tool curl - need_tool sha1sum - command -v pnpm >/dev/null 2>&1 || warn "pnpm not found — landing build will be skipped if absent" - - echo " BASE_DOMAIN=${BASE_DOMAIN} HOST_LAN_IP=${HOST_LAN_IP}" - echo " preflight ok" -} - -# ---- online prep ----------------------------------------------------------- -prep() { - log "PHASE prep (requires internet)" - - log "render configs" - have_script render-config.sh - tooling/render-config.sh - - log "build landing page → www/" - if command -v pnpm >/dev/null 2>&1; then - ( cd landing && pnpm install --frozen-lockfile && BASE_DOMAIN="${BASE_DOMAIN}" pnpm run build ) - else - warn "pnpm absent — skipping landing build" - fi - - log "mirror mod jars (offline pack)" - have_script mirror-mods.sh - tooling/mirror-mods.sh - - log "mirror full air-gap upstream (meta/libraries/assets/client.jar)" - have_script mirror-airgap.sh - tooling/mirror-airgap.sh - - log "fetch launcher releases" - have_script fetch-launcher.sh - tooling/fetch-launcher.sh - - log "fetch authlib-injector.jar (server JVM agent → drasl)" - if [ -f runtime/authlib-injector.jar ]; then - echo " runtime/authlib-injector.jar present — skipping" - else - ai_url="$(curl -fsSL https://api.github.com/repos/yushijinhun/authlib-injector/releases/latest \ - | jq -r '.assets[] | select(.name|test("authlib-injector-[0-9].*\\.jar$")) | .browser_download_url' | head -1)" - [ -n "$ai_url" ] && [ "$ai_url" != "null" ] || die "could not resolve authlib-injector release asset" - curl -fSL --retry 3 -o runtime/authlib-injector.jar "$ai_url" - echo " downloaded $(basename "$ai_url")" - fi - - if [ "${ENABLE_MDNS:-false}" = true ]; then - log "build avahi mDNS image (cache it for offline use)" - "${COMPOSE[@]}" -f docker-compose.yml -f docker-compose.avahi.yml build avahi - fi - - log "pre-bake server volume (TYPE=NEOFORGE install, one-shot, online)" - # Bring minecraft up once so itzg installs NeoForge + mods into the volume, - # then stop it. The party then runs offline against the baked volume. - # caddy.yml so the pack (pack.${BASE_DOMAIN}) resolves + is served during bake. - "${COMPOSE[@]}" -f docker-compose.yml -f docker-compose.caddy.yml up -d minecraft - warn "watch logs until 'Done' then re-run with --up; or automate a readiness probe" -} - -# ---- bring-up -------------------------------------------------------------- -# Modes (DNS is YOUR party server — see tooling/dns-records.sh for the records): -# online base + landing/launcher; internet present, no mirror -# airgap online + full upstream mirror (tls internal :443) for no-internet -# core base only (no landing, no mirror) — debugging -up() { - local mode="${1:-airgap}" - # build-stack is the caddy (LAN/air-gap) deployer; caddy lives in its own file - # now (the base stack can also run behind the host's nginx — see - # docker-compose.nginx.yml). Always include caddy here. - local -a files=(-f docker-compose.yml -f docker-compose.caddy.yml) - case "$mode" in - core) ;; - online) files+=(-f docker-compose.static.yml) ;; - airgap) files+=(-f docker-compose.static.yml -f docker-compose.mirror.yml) ;; - *) die "unknown up mode: '$mode' (use online|airgap|core)" ;; - esac - # Optional mDNS (.local) responder — see ENABLE_MDNS in .env. - if [ "${ENABLE_MDNS:-false}" = true ]; then - files+=(-f docker-compose.avahi.yml) - case "$BASE_DOMAIN" in - *.local) ;; - *) warn "ENABLE_MDNS=true but BASE_DOMAIN=$BASE_DOMAIN is not *.local — mDNS won't resolve it" ;; - esac - pgrep -x avahi-daemon >/dev/null 2>&1 \ - || warn "host avahi-daemon not detected — the avahi container publishes through it; start it (e.g. 'systemctl start avahi-daemon')" - [ -S /run/dbus/system_bus_socket ] \ - || warn "host D-Bus socket /run/dbus/system_bus_socket missing — avahi publish will fail" - local ifc; ifc=$(ip -o link show 2>/dev/null | wc -l) - [ "${ifc:-0}" -gt 6 ] \ - && warn "host has ${ifc} network interfaces — set 'allow-interfaces=' in /etc/avahi/avahi-daemon.conf, else avahi self-collides (Local name collision)" - fi - log "PHASE up (mode=$mode${ENABLE_MDNS:+, mdns=$ENABLE_MDNS})" - have_script render-config.sh - tooling/render-config.sh # cheap; ensures configs match current .env - - # Static mode mounts ./caddy/caddy-root-ca.crt as a file at /srv/ca-pub/ca.crt. - # Pre-create it (empty) so Docker doesn't make a directory at that path; the - # real cert is exported below once Caddy's local CA exists. - case "$mode" in - online|airgap) [ -e caddy/caddy-root-ca.crt ] || : > caddy/caddy-root-ca.crt ;; - esac - - "${COMPOSE[@]}" "${files[@]}" up -d - - # Mirror/full provision Caddy's local CA — export it so guests can trust the - # HTTPS asset mirror (served at http://${BASE_DOMAIN}/ca.crt in static/full). - case "$mode" in - airgap) - local i - for i in 1 2 3 4 5; do - docker exec caddy cat /data/caddy/pki/authorities/local/root.crt \ - > caddy/caddy-root-ca.crt 2>/dev/null && break - sleep 1 - done - [ -s caddy/caddy-root-ca.crt ] \ - && echo " exported Caddy CA → caddy/caddy-root-ca.crt (served at /ca.crt)" \ - || warn "Caddy CA not ready; export later: docker exec caddy cat /data/caddy/pki/authorities/local/root.crt > caddy/caddy-root-ca.crt" - ;; - esac - - log "stack up (mode=$mode). apex: http://${BASE_DOMAIN} auth: http://auth.${BASE_DOMAIN}" - echo - log "Add these to your party DNS (mode=$mode):" - [ -x tooling/dns-records.sh ] && tooling/dns-records.sh "$mode" | sed -n '/## Plain/,/^$/p' -} - -# ---- dispatch -------------------------------------------------------------- -preflight -case "${1:-}" in - --prep) prep ;; - --up) up "${2:-airgap}" ;; - "") cat <&2; exit 1 ;; -esac - -# Marker so /etc/hosts edits can be applied/removed cleanly. -MARK="ulicraft ${mode}" - -# `hosts` format: print ONLY the /etc/hosts block (marker-wrapped) so it can be -# piped straight into /etc/hosts to simulate the party DNS. See README. -if [ "${2:-}" = hosts ]; then - echo "# >>> ${MARK} >>>" - for n in "${names[@]}"; do printf '%s %s\n' "$HOST_LAN_IP" "$n"; done - echo "# <<< ${MARK} <<<" - exit 0 -fi - -echo "# DNS records for mode=${mode} -> ${HOST_LAN_IP}" -echo "# Configure these on your party DNS server (all A records to the host)." -echo - -echo "## Plain (name -> ip)" -for n in "${names[@]}"; do printf '%-36s %s\n' "$n" "$HOST_LAN_IP"; done -echo - -echo "## hosts file (/etc/hosts style)" -for n in "${names[@]}"; do printf '%s %s\n' "$HOST_LAN_IP" "$n"; done -echo - -echo "## BIND zone (A records)" -for n in "${names[@]}"; do printf '%-36s IN A %s\n' "${n}." "$HOST_LAN_IP"; done -echo - -echo "## dnsmasq (address=)" -echo "address=/${BASE_DOMAIN}/${HOST_LAN_IP} # wildcard covers all *.${BASE_DOMAIN}" -if [ "$mode" = airgap ]; then - for n in "${spoof[@]}"; do printf 'address=/%s/%s\n' "$n" "$HOST_LAN_IP"; done -fi -echo - -echo "## Minecraft (no port needed)" -echo "# Clients connect to mc.${BASE_DOMAIN} (defaults to :25565)." -echo "# Optional SRV if your DNS supports it:" -echo "_minecraft._tcp.mc.${BASE_DOMAIN}. IN SRV 0 0 25565 mc.${BASE_DOMAIN}." diff --git a/tooling/fetch-launcher.sh b/tooling/fetch-launcher.sh index d3c6227..6736c1b 100755 --- a/tooling/fetch-launcher.sh +++ b/tooling/fetch-launcher.sh @@ -3,7 +3,7 @@ # offline LAN hosting, then create stable-name symlinks for the landing page. # # Guests use FjordLauncherUnlocked (Prism/PolyMC fork with authlib-injector -# built in). We mirror every release asset under mirror/launcher// and +# built in). We mirror every release asset under launcher// and # maintain a `latest` symlink → . Caddy serves this at /launcher/latest/… # # The landing page (landing/src/data/site.ts) links to FIXED filenames so the @@ -16,11 +16,11 @@ # Cautious by design: halts on any error, and if a stable-name category matches # 0 or >1 candidate it prints the candidates and exits non-zero rather than # guessing. Idempotent — re-runs overwrite symlinks and re-download safely. -# mirror/ is gitignored — only this script is tracked. +# launcher/ is gitignored — only this script is tracked. set -euo pipefail REPO="hero-persson/FjordLauncherUnlocked" -DEST_ROOT="$(dirname "$0")/../mirror/launcher" +DEST_ROOT="$(dirname "$0")/../launcher" API="https://api.github.com/repos/${REPO}/releases/latest" command -v curl >/dev/null || { echo "curl required" >&2; exit 1; } diff --git a/tooling/mdns-host-setup.sh b/tooling/mdns-host-setup.sh deleted file mode 100755 index 40cf972..0000000 --- a/tooling/mdns-host-setup.sh +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env bash -# mdns-host-setup.sh — pin the HOST avahi-daemon to the LAN interface so it -# stops self-colliding across docker bridges ("Local name collision"), which -# is what blocks the mDNS (.local) path on a host with many interfaces. -# -# Detects the LAN NIC from HOST_LAN_IP (.env), sets allow-interfaces= in -# /etc/avahi/avahi-daemon.conf (idempotent, backs up first), restarts avahi. -# Re-execs itself with sudo if not root. Run: tooling/mdns-host-setup.sh -set -euo pipefail -cd "$(dirname "$0")/.." - -[ -f .env ] && { set -a; . ./.env; set +a; } -: "${HOST_LAN_IP:?HOST_LAN_IP unset (set in .env)}" - -nic="$(ip -o -4 addr show | awk -v ip="$HOST_LAN_IP" 'index($0, " "ip"/"){print $2; exit}')" -[ -n "$nic" ] || { echo "ERROR: no interface found with IP $HOST_LAN_IP" >&2; exit 1; } -echo "LAN interface for ${HOST_LAN_IP}: ${nic}" - -conf=/etc/avahi/avahi-daemon.conf -[ -f "$conf" ] || { echo "ERROR: $conf not found — is avahi-daemon installed?" >&2; exit 1; } - -# Need root to edit the conf + restart the service. -if [ "$(id -u)" -ne 0 ]; then - echo "escalating with sudo…" - exec sudo "$0" "$@" -fi - -# Already pinned to this NIC? nothing to do. -if grep -qE "^allow-interfaces=${nic}([,[:space:]]|$)" "$conf"; then - echo "already set: allow-interfaces=${nic}" -else - cp -a "$conf" "${conf}.bak.$(date +%s)" - if grep -qE '^allow-interfaces=' "$conf"; then - sed -i -E "s/^allow-interfaces=.*/allow-interfaces=${nic}/" "$conf" - elif grep -qE '^#\s*allow-interfaces=' "$conf"; then - sed -i -E "s/^#\s*allow-interfaces=.*/allow-interfaces=${nic}/" "$conf" - else - # insert right after the [server] section header - sed -i -E "/^\[server\]/a allow-interfaces=${nic}" "$conf" - fi - echo "set allow-interfaces=${nic} (backup: ${conf}.bak.*)" -fi - -echo "restarting avahi-daemon…" -systemctl restart avahi-daemon -sleep 1 -systemctl is-active --quiet avahi-daemon && echo "avahi-daemon active" || { echo "ERROR: avahi-daemon not active" >&2; exit 1; } -grep -E '^allow-interfaces=' "$conf" diff --git a/tooling/mirror-airgap.sh b/tooling/mirror-airgap.sh deleted file mode 100755 index 85e10f0..0000000 --- a/tooling/mirror-airgap.sh +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env bash -# mirror-airgap.sh — populate the byte-exact upstream mirror for full client air-gap. -# -# Architecture (see plan/11-full-airgap-mirror.md): dnsmasq spoofs the real -# upstream hostnames -> our host; Caddy serves byte-exact path mirrors over HTTPS -# with `tls internal`. The launcher is UNMODIFIED — it requests the real Mojang -# URLs and DNS resolves them to us. Mirror files live at: -# mirror/upstream// (== the real URL path, byte-for-byte) -# `mirror/` is gitignored. -# -# ── What this script does DETERMINISTICALLY (no proxy needed) ──────────────── -# Everything reachable purely from the Mojang piston metadata graph for a given -# MC version: -# 1. version_manifest_v2.json (piston-meta.mojang.com) -# 2. the per-version .json (piston-meta.mojang.com) -# 3. client.jar (piston-data.mojang.com) — sha1 verified -# 4. every library artifact (libraries.minecraft.net / url-derived host) — sha1 verified -# 5. the asset index (piston-meta / piston-data, real path) — sha1 verified -# 6. every asset object (resources.download.minecraft.net/<2hex>/) — sha1 verified, idempotent -# All downloads go to a .partial file, are sha1-checked, then atomically moved. -# Any fetch failure or sha1 mismatch HALTS the script (set -euo pipefail). -# -# ── What this script CANNOT do here — the MANUAL PROXY-CAPTURE step ────────── -# Two upstream surfaces are NOT enumerable from the piston graph and require the -# online proxy-capture procedure documented in plan/11-full-airgap-mirror.md -# ("Capture (the actual work)"): -# -# * meta.prismlauncher.org — Prism-format component metadata (the launcher's -# own index of MC / LWJGL / NeoForge components). -# Layout is launcher-specific (/v1/...), versioned, -# and only knowable by observing what the launcher -# actually requests. -# * maven.neoforged.net — NeoForge installer + the NeoForge-processed -# libraries (and possibly extra maven mirrors the -# NeoForge install pulls). The exact artifact set -# depends on the launcher/installer run. -# -# For those: launch the instance once online behind a logging forward/reverse -# proxy (mitmproxy or a logging Caddy), collect every absolute URL hit, then -# fetch each into mirror/upstream// preserving the path exactly and -# verifying sha1 where the source provides it. This script prints a MANUAL STEP -# notice at the end as a reminder. -# -# Requires: curl, jq, sha1sum. -set -euo pipefail - -cd "$(dirname "$0")/.." # repo root - -# --- env (optional) -------------------------------------------------------- -[ -f .env ] && { set -a; . ./.env; set +a; } - -MC_VERSION="${MC_VERSION:-1.21.1}" -NEOFORGE_VERSION="${NEOFORGE_VERSION:-21.1.209}" # informational; NeoForge libs come from capture - -# --- required tools -------------------------------------------------------- -for tool in curl jq sha1sum; do - command -v "$tool" >/dev/null 2>&1 || { echo "error: '$tool' is required but not found in PATH" >&2; exit 1; } -done - -MIRROR_ROOT="mirror/upstream" - -MANIFEST_HOST="piston-meta.mojang.com" -MANIFEST_PATH="mc/game/version_manifest_v2.json" -MANIFEST_URL="https://${MANIFEST_HOST}/${MANIFEST_PATH}" -RESOURCES_HOST="resources.download.minecraft.net" - -echo "mirror-airgap: MC_VERSION=${MC_VERSION} NEOFORGE_VERSION=${NEOFORGE_VERSION} (NeoForge handled by capture)" -echo "mirror root: ${MIRROR_ROOT}/" - -# --- helpers --------------------------------------------------------------- -sha1_of() { sha1sum "$1" | awk '{print $1}'; } - -# host + path from an absolute https?:// URL (path has no leading slash). -url_host() { printf '%s' "$1" | sed -E 's#^[a-zA-Z]+://([^/]+)/.*$#\1#'; } -url_path() { printf '%s' "$1" | sed -E 's#^[a-zA-Z]+://[^/]+/##'; } - -# Download $url into mirror/upstream//, verifying sha1 ($want_sha1, -# may be empty to skip verification). Idempotent: a present file whose sha1 -# already matches is skipped. Halts on any failure or mismatch. -# Echoes the destination path on stdout (last line) for callers that need it. -fetch_to_mirror() { # $1=url $2=want_sha1(optional) - local url="$1" want="${2:-}" - local host path dest tmp got - host="$(url_host "$url")" - path="$(url_path "$url")" - [ -n "$host" ] || { echo "error: could not parse host from url: $url" >&2; exit 1; } - [ -n "$path" ] || { echo "error: could not parse path from url: $url" >&2; exit 1; } - dest="${MIRROR_ROOT}/${host}/${path}" - - if [ -f "$dest" ]; then - if [ -n "$want" ]; then - got="$(sha1_of "$dest")" - if [ "$got" = "$want" ]; then - printf '%s\n' "$dest" - return 0 - fi - echo " re-fetch ${host}/${path} (local sha1 mismatch)" >&2 - else - # no sha1 to check (e.g. manifest/version json) — re-fetch to stay fresh - : - fi - fi - - mkdir -p "$(dirname "$dest")" - tmp="${dest}.partial" - if ! curl -fSL --retry 3 -o "$tmp" "$url"; then - rm -f "$tmp" - echo "error: download failed: $url" >&2 - exit 1 - fi - - if [ -n "$want" ]; then - got="$(sha1_of "$tmp")" - if [ "$got" != "$want" ]; then - rm -f "$tmp" - echo "error: sha1 mismatch for $url" >&2 - echo " expected: $want" >&2 - echo " got: $got" >&2 - exit 1 - fi - fi - mv -f "$tmp" "$dest" - printf '%s\n' "$dest" -} - -# --- 1. version manifest --------------------------------------------------- -echo "[1/6] version manifest" -manifest_dest="$(fetch_to_mirror "$MANIFEST_URL" "" | tail -n1)" - -version_url="$(jq -r --arg v "$MC_VERSION" '.versions[] | select(.id == $v) | .url' "$manifest_dest")" -[ -n "$version_url" ] && [ "$version_url" != "null" ] || { - echo "error: MC version '$MC_VERSION' not found in version manifest" >&2; exit 1; } -echo " version json: $version_url" - -# --- 2. per-version json --------------------------------------------------- -echo "[2/6] version json" -version_dest="$(fetch_to_mirror "$version_url" "" | tail -n1)" - -# --- 3. client.jar --------------------------------------------------------- -echo "[3/6] client.jar" -client_url="$(jq -r '.downloads.client.url' "$version_dest")" -client_sha1="$(jq -r '.downloads.client.sha1' "$version_dest")" -[ -n "$client_url" ] && [ "$client_url" != "null" ] || { echo "error: no .downloads.client.url in version json" >&2; exit 1; } -fetch_to_mirror "$client_url" "$client_sha1" >/dev/null -echo " ok client.jar (sha1 verified)" - -# --- 4. libraries ---------------------------------------------------------- -echo "[4/6] libraries" -lib_count="$(jq '[.libraries[] | select(.downloads.artifact != null)] | length' "$version_dest")" -echo " $lib_count library artifact(s)" -# Emit "urlsha1" per artifact; host+path derived from the url itself so -# whatever maven host appears (libraries.minecraft.net or others) is mirrored. -jq -r '.libraries[] | select(.downloads.artifact != null) - | "\(.downloads.artifact.url)\t\(.downloads.artifact.sha1)"' "$version_dest" \ -| while IFS=$'\t' read -r lurl lsha1; do - [ -n "$lurl" ] || continue - fetch_to_mirror "$lurl" "$lsha1" >/dev/null - done -echo " ok libraries (sha1 verified)" - -# --- 5. asset index -------------------------------------------------------- -echo "[5/6] asset index" -ai_url="$(jq -r '.assetIndex.url' "$version_dest")" -ai_sha1="$(jq -r '.assetIndex.sha1' "$version_dest")" -ai_id="$(jq -r '.assetIndex.id' "$version_dest")" -[ -n "$ai_url" ] && [ "$ai_url" != "null" ] || { echo "error: no .assetIndex.url in version json" >&2; exit 1; } -echo " asset index id=$ai_id" -ai_dest="$(fetch_to_mirror "$ai_url" "$ai_sha1" | tail -n1)" - -# --- 6. asset objects ------------------------------------------------------ -echo "[6/6] asset objects (the bulk — this is the large/slow part)" -obj_total="$(jq '.objects | length' "$ai_dest")" -echo " $obj_total object(s) -> ${RESOURCES_HOST}/<2hex>/" -n=0 -# Each object: hash -> served at resources.download.minecraft.net//; -# sha1 == the hash itself. Idempotent: fetch_to_mirror skips valid existing files. -jq -r '.objects[].hash' "$ai_dest" \ -| while read -r hash; do - [ -n "$hash" ] || continue - n=$((n + 1)) - prefix="${hash:0:2}" - obj_url="https://${RESOURCES_HOST}/${prefix}/${hash}" - fetch_to_mirror "$obj_url" "$hash" >/dev/null - done -echo " ok asset objects (sha1 verified, idempotent)" - -# --- manual step notice ---------------------------------------------------- -cat >&2 <<'NOTICE' - -──────────────────────────────────────────────────────────────────────────── - MANUAL STEP REQUIRED — proxy-capture (see plan/11-full-airgap-mirror.md) -──────────────────────────────────────────────────────────────────────────── - This script mirrored the deterministic Mojang piston graph only: - piston-meta.mojang.com, piston-data.mojang.com, libraries.minecraft.net, - resources.download.minecraft.net. - - The following are NOT enumerable from the piston graph and were NOT fetched: - * meta.prismlauncher.org — Prism-format component metadata (/v1/...) - * maven.neoforged.net — NeoForge installer + processed libraries - (plus any extra maven mirrors the install pulls) - - To complete the air-gap mirror, run the capture procedure from - plan/11-full-airgap-mirror.md ("Capture (the actual work)"): - 1. Launch the instance ONCE online behind a logging forward/reverse proxy - (mitmproxy or a logging Caddy). Collect every absolute URL requested. - 2. Fetch each into mirror/upstream//, preserving the path - exactly; verify sha1 where the source json provides it. - 3. Offline re-test on a clean machine (internet cut, LAN DNS = dnsmasq, - Caddy root CA trusted). Any 404 -> a missing URL -> add it to the fetch. -──────────────────────────────────────────────────────────────────────────── -NOTICE - -echo "deterministic mirror complete for MC ${MC_VERSION}." diff --git a/tooling/mirror-mods.sh b/tooling/mirror-mods.sh deleted file mode 100755 index b2c2f2f..0000000 --- a/tooling/mirror-mods.sh +++ /dev/null @@ -1,136 +0,0 @@ -#!/usr/bin/env bash -# mirror-mods.sh — vendor mod jars locally + rewrite packwiz download URLs. -# -# For offline (air-gapped) LAN play, packwiz .pw.toml files point at the -# Modrinth/CF CDN. This script downloads each jar into pack/mods-files/, -# rewrites the [download] url to the local Caddy mirror, then runs -# `packwiz refresh` so index.toml/hashes match the rewritten state. -# -# Cautious by design: halts on download failure or hash mismatch, idempotent -# (skips already-vendored valid jars), and no-ops gracefully with zero mods. -# pack/mods-files/ is gitignored — only the .pw.toml metadata is tracked. -set -euo pipefail - -cd "$(dirname "$0")/.." # repo root - -# --- env ------------------------------------------------------------------- -[ -f .env ] && { set -a; . ./.env; set +a; } -: "${BASE_DOMAIN:?BASE_DOMAIN unset (set in .env)}" - -# --- packwiz binary -------------------------------------------------------- -PACKWIZ="/home/oier/go/bin/packwiz" -[ -x "$PACKWIZ" ] || PACKWIZ="$(command -v packwiz || true)" -[ -n "$PACKWIZ" ] || { echo "error: packwiz not found (looked at /home/oier/go/bin/packwiz and PATH)" >&2; exit 1; } - -PACK_DIR="pack" -MODS_META_DIR="$PACK_DIR/mods" -MODS_FILES_DIR="$PACK_DIR/mods-files" -MIRROR_BASE="http://packwiz.${BASE_DOMAIN}/mods-files" - -[ -f "$PACK_DIR/pack.toml" ] || { echo "error: $PACK_DIR/pack.toml not found — run packwiz init first" >&2; exit 1; } - -# --- collect .pw.toml files ------------------------------------------------ -shopt -s nullglob -metas=("$MODS_META_DIR"/*.pw.toml) -shopt -u nullglob - -if [ "${#metas[@]}" -eq 0 ]; then - echo "no mods in $MODS_META_DIR/ — nothing to mirror (no-op)." - exit 0 -fi - -mkdir -p "$MODS_FILES_DIR" - -# --- helpers --------------------------------------------------------------- -# Read a top-level scalar TOML key (filename) from a .pw.toml. -toml_top_value() { # $1=file $2=key - sed -n "s/^[[:space:]]*$2[[:space:]]*=[[:space:]]*\"\(.*\)\"[[:space:]]*\$/\1/p" "$1" | head -n1 -} - -# Read a key from the [download] table only. -toml_download_value() { # $1=file $2=key - awk -v key="$2" ' - /^\[/ { in_dl = ($0 ~ /^\[download\]/) ; next } - in_dl { - line=$0 - sub(/^[ \t]*/, "", line) - if (line ~ "^" key "[ \t]*=") { - sub("^" key "[ \t]*=[ \t]*", "", line) - gsub(/^"|"[ \t]*$/, "", line) - print line - exit - } - } - ' "$1" -} - -sha256_of() { sha256sum "$1" | awk '{print $1}'; } - -changed=0 -echo "mirroring ${#metas[@]} mod(s) → $MIRROR_BASE" - -for meta in "${metas[@]}"; do - filename="$(toml_top_value "$meta" filename)" - url="$(toml_download_value "$meta" url)" - hash_format="$(toml_download_value "$meta" hash-format)" - want_hash="$(toml_download_value "$meta" hash)" - - [ -n "$filename" ] || { echo "error: no filename in $meta" >&2; exit 1; } - [ -n "$url" ] || { echo "error: no [download] url in $meta" >&2; exit 1; } - - dest="$MODS_FILES_DIR/$filename" - already_local="$MIRROR_BASE/$filename" - - # If the .pw.toml already points at the mirror and the jar exists & verifies, - # skip (idempotent re-run). - if [ "$url" = "$already_local" ] && [ -f "$dest" ]; then - if [ "${hash_format:-sha256}" = "sha256" ] && [ -n "$want_hash" ]; then - if [ "$(sha256_of "$dest")" = "$want_hash" ]; then - echo " skip $filename (already vendored, hash ok)" - continue - fi - echo " re-fetch $filename (local hash mismatch)" - else - echo " skip $filename (already vendored)" - continue - fi - fi - - # Download (from current url — CDN on first run). Use a temp file so a failed - # download never leaves a partial jar behind. - echo " fetch $filename" - tmp="$dest.partial" - if ! curl -fSL --retry 3 -o "$tmp" "$url"; then - rm -f "$tmp" - echo "error: download failed for $filename ($url)" >&2 - exit 1 - fi - - # Verify against the recorded hash before trusting the file. - if [ "${hash_format:-sha256}" = "sha256" ] && [ -n "$want_hash" ]; then - got_hash="$(sha256_of "$tmp")" - if [ "$got_hash" != "$want_hash" ]; then - rm -f "$tmp" - echo "error: hash mismatch for $filename" >&2 - echo " expected: $want_hash" >&2 - echo " got: $got_hash" >&2 - exit 1 - fi - fi - mv -f "$tmp" "$dest" - - # Rewrite the [download] url to the local mirror if not already. - if [ "$url" != "$already_local" ]; then - esc_old="$(printf '%s' "$url" | sed -e 's/[\/&]/\\&/g')" - esc_new="$(printf '%s' "$already_local" | sed -e 's/[\/&]/\\&/g')" - sed -i "s/^\([[:space:]]*url[[:space:]]*=[[:space:]]*\)\"$esc_old\"/\1\"$esc_new\"/" "$meta" - echo " url → $already_local" - changed=1 - fi -done - -# --- refresh so index.toml + hashes reflect the rewritten metadata --------- -echo "running packwiz refresh ..." -( cd "$PACK_DIR" && "$PACKWIZ" refresh ) - -echo "done. mirrored ${#metas[@]} mod(s)$( [ "$changed" -eq 1 ] && echo ', URLs rewritten to local mirror' )." diff --git a/tooling/render-config.sh b/tooling/render-config.sh index f249560..956bc31 100755 --- a/tooling/render-config.sh +++ b/tooling/render-config.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # render-config.sh — turn *.tmpl files into real configs via envsubst. -# Substitutes ONLY ${BASE_DOMAIN} and ${HOST_LAN_IP} so literal $-syntax that -# belongs to the target file (dnsmasq, caddy, toml) is left untouched. +# Substitutes ONLY ${BASE_DOMAIN} so literal $-syntax that belongs to the target +# file (caddy, toml) is left untouched. # Halts on any unset var or missing template (cautious: no half-rendered output). set -euo pipefail @@ -9,17 +9,15 @@ cd "$(dirname "$0")/.." # repo root [ -f .env ] && { set -a; . ./.env; set +a; } : "${BASE_DOMAIN:?BASE_DOMAIN unset (set in .env)}" -: "${HOST_LAN_IP:?HOST_LAN_IP unset (set in .env)}" render() { # $1=template $2=output [ -f "$1" ] || { echo "missing template: $1" >&2; exit 1; } - envsubst '${BASE_DOMAIN} ${HOST_LAN_IP}' < "$1" > "$2" + envsubst '${BASE_DOMAIN}' < "$1" > "$2" echo "rendered $2" } render drasl/config/config.toml.tmpl drasl/config/config.toml render nmsr/config.toml.tmpl nmsr/config.toml -render dnsmasq/dnsmasq.conf.tmpl dnsmasq/dnsmasq.conf # pack templates (mods/*.pw.toml.tmpl, CustomSkinLoader, …) + packwiz index. # Delegated to render-pack.sh (BASE_DOMAIN only — single source of pack render). diff --git a/tooling/render-nginx.sh b/tooling/render-nginx.sh index cec2913..591d55f 100755 --- a/tooling/render-nginx.sh +++ b/tooling/render-nginx.sh @@ -1,22 +1,21 @@ #!/usr/bin/env bash -# render-nginx.sh — render an nginx vhost template with the .env values. +# render-nginx.sh — render the nginx vhost template with the .env values. # -# Substitutes $BASE_DOMAIN, $APP_DIR, $CADDY_HTTP_PORT into a template and -# prints the result to stdout, or installs it with --install (writes to -# sites-available, symlinks sites-enabled, tests, reloads nginx). +# Substitutes $BASE_DOMAIN, $APP_DIR, $CADDY_HTTP_PORT into +# nginx/ulicraft-caddy.conf.tmpl (the host nginx that terminates TLS in front of +# caddy) and prints the result to stdout, or installs it with --install (writes +# to sites-available, symlinks sites-enabled, tests, reloads nginx). # # Usage: -# tooling/render-nginx.sh # render caddy-front template -> stdout +# tooling/render-nginx.sh # render -> stdout # tooling/render-nginx.sh --install # render + install + reload nginx -# TEMPLATE=nginx/ulicraft.conf.tmpl tooling/render-nginx.sh # the static variant # # .env vars used: # BASE_DOMAIN required -# CADDY_HTTP_PORT caddy-front template only (localhost port nginx proxies to) +# CADDY_HTTP_PORT localhost port nginx reverse-proxies to (caddy) # optional: # APP_DIR host path to this repo (cert files live under APP_DIR/certs) # — defaults to the repo root (this checkout) -# TEMPLATE template to render (default nginx/ulicraft-caddy.conf.tmpl) # SITE_NAME installed filename (default ulicraft.conf) set -euo pipefail diff --git a/tooling/render-pack.sh b/tooling/render-pack.sh index c657dea..e419043 100755 --- a/tooling/render-pack.sh +++ b/tooling/render-pack.sh @@ -3,8 +3,8 @@ # ${BASE_DOMAIN}, then rebuild the packwiz index. # # Pack templates are domain-dependent build output (download URLs, skin API -# roots, …). This renderer needs ONLY ${BASE_DOMAIN} — no dnsmasq HOST_LAN_IP — -# so it can run while curating mods before the full deploy config exists. +# roots, …). This renderer needs ONLY ${BASE_DOMAIN}, so it can run while +# curating mods before the full deploy config exists. # Both tooling/render-config.sh (deploy) and tooling/add-custom-mod.sh call it. set -euo pipefail