initial commit

This commit is contained in:
2026-05-24 04:03:42 +02:00
commit c09da15bd0
28 changed files with 5801 additions and 0 deletions

122
plan/00-overview.md Normal file
View File

@@ -0,0 +1,122 @@
# 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.
## Single source of configuration
Everything is driven by two env vars 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)
```
> `.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`.
## 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)
```
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.
### Internal resolution trick
Containers use the Docker resolver, NOT dnsmasq. Caddy gets network aliases so
the stack resolves its own subdomains internally:
```yaml
caddy:
networks:
mcnet:
aliases: [ "auth.${BASE_DOMAIN}", "packwiz.${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**.
## Online vs offline split
```
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)
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
```
## 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.
## 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
├── drasl/config/
│ ├── config.toml.tmpl # envsubst source
│ └── config.toml # generated (gitignored)
├── 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
├── 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)
├── backups/
└── plan/ # these files
```
## Service plan files
- `01-dnsmasq.md` — LAN wildcard DNS
- `02-caddy.md` — ingress + local CDN + landing page
- `03-drasl.md` — auth (password login)
- `04-packwiz.md` — modpack source + mod mirror
- `05-minecraft.md` — itzg NeoForge server + offline launch
- `06-launcher.md` — Fjord launcher fetch + distribution
- `07-mc-backup.md` — world backups
- `08-tooling.md` — render/mirror/fetch 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
## Boot/dependency order
`dnsmasq``caddy` (aliases) → `drasl``minecraft` (depends on drasl + packwiz served by caddy) → `mc-backup`.

57
plan/01-dnsmasq.md Normal file
View File

@@ -0,0 +1,57 @@
# dnsmasq — LAN wildcard DNS
## 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

69
plan/02-caddy.md Normal file
View File

@@ -0,0 +1,69 @@
# Caddy — ingress, local CDN, landing page
## 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.
Plain `:80` for now (LAN). TLS via step-ca deferred. Image: `caddy:alpine`.
Caddy natively reads `{$BASE_DOMAIN}` env placeholders — no template render needed.
## Network aliases (internal resolution)
```yaml
caddy:
networks:
mcnet:
aliases:
- "auth.${BASE_DOMAIN}"
- "packwiz.${BASE_DOMAIN}"
```
Lets `minecraft` reach `http://auth.ulicraft.local/authlib-injector` and
`http://packwiz.ulicraft.local/pack.toml` without dnsmasq.
## Caddyfile sketch
```
{
auto_https off
}
http://{$BASE_DOMAIN} {
root * /srv/www
file_server
}
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`.
## 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.
- `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.
## 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`
- [ ] 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

52
plan/03-drasl.md Normal file
View File

@@ -0,0 +1,52 @@
# Drasl — self-hosted auth (Yggdrasil + skins)
## Summary
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.
**OIDC/Keycloak is OUT for now** — drasl runs in **password-login** mode
(`AllowPasswordLogin = true`). Admin + guests register with a username/password on
the drasl web UI. Re-introducing Keycloak OIDC later is a future task.
Config is **TOML only** (no env var support). So `config.toml` is rendered from
`config.toml.tmpl` by `tooling/render-config.sh` to inject `BASE_DOMAIN`.
## config.toml.tmpl (key fields)
```toml
BaseURL = "http://auth.${BASE_DOMAIN}"
Domain = "auth.${BASE_DOMAIN}"
ListenAddress = "0.0.0.0:25585" # internal; Caddy proxies
DefaultAdminUsernames = ["admin"]
AllowPasswordLogin = true
[ApplicationOwner]
# ...
# OIDC block intentionally omitted for now (no Keycloak).
# [[RegistrationOIDC]] ← future
```
## Critical gotchas (carry-over)
- **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.
Network alias makes this identical inside/outside the stack.
- **`Domain` affects skins** — if wrong, authlib clients may not see skins.
## Tasks
- [ ] Create `drasl/config/config.toml.tmpl` (password-login, no OIDC block)
- [ ] Set `BaseURL`/`Domain` to `auth.${BASE_DOMAIN}`, `ListenAddress 0.0.0.0:25585`
- [ ] Set `SignPublicKeys = false`
- [ ] Wire `render-config.sh``drasl/config/config.toml` (gitignored)
- [ ] Remove drasl host port from compose (internal only)
- [ ] Update compose: drasl on `mcnet`, no `25585:25585` publish
- [ ] Bootstrap admin account on first run; create guest accounts
- [ ] Verify authlib endpoint responds through Caddy
- [ ] Future: re-add `[[RegistrationOIDC]]` Keycloak block + secret file

48
plan/04-packwiz.md Normal file
View File

@@ -0,0 +1,48 @@
# packwiz — modpack source of truth + offline mod mirror
## Summary
`packwiz` (installed at `~/go/bin/packwiz`) authors the modpack. The `./pack`
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**
(`pack.toml`, `index.toml`, `mods/*.pw.toml`); the `.pw.toml` files reference
Modrinth/CF **CDN URLs**, so true offline requires mirroring jars locally.
## 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.
- **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
against projects.neoforged.net before locking.
## Tasks
- [ ] `packwiz init``./pack` (MC 1.21.1, modloader neoforge, pinned version)
- [ ] 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)

52
plan/05-minecraft.md Normal file
View File

@@ -0,0 +1,52 @@
# Minecraft server — itzg NeoForge + offline launch
## 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.
## Offline strategy (two phases)
**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
```
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=<container path>` 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.
> 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)
```
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.
(Server still needs the agent even though Fjord clients have it built in.)
Memory: `INIT_MEMORY 4G`, `MAX_MEMORY 10G`, `USE_AIKAR_FLAGS TRUE`.
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
- [ ] `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`

100
plan/06-launcher.md Normal file
View File

@@ -0,0 +1,100 @@
# Launcher — FjordLauncherUnlocked distribution
## Summary
Guests use **FjordLauncherUnlocked** (`hero-persson/FjordLauncherUnlocked`) — a
Prism/PolyMC fork with **authlib-injector support built in**. So guests add their
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.
## Release assets (latest = 11.0.2.0)
Every platform variant is published as a GitHub release asset:
- **Windows**: MSVC + MinGW, each as `.zip`, `Portable .zip`, `Setup .exe`; x64 + arm64
- **macOS**: `.dmg`, `.zip`
- **Linux**: `.AppImage` (x86_64, aarch64) + `.zsync`, portable `.tar.gz` (Qt6)
The fetch script grabs **all** assets via the GitHub API (loops
`browser_download_url`). The landing page highlights the common three: Windows
Setup `.exe`, macOS `.dmg`, Linux `.AppImage`.
## tooling/fetch-launcher.sh
```bash
#!/usr/bin/env bash
# Download all FjordLauncherUnlocked release assets for offline hosting.
# Halts on any error or ambiguity (no silent partial downloads).
set -euo pipefail
REPO="hero-persson/FjordLauncherUnlocked"
DEST_ROOT="$(dirname "$0")/../mirror/launcher"
API="https://api.github.com/repos/${REPO}/releases/latest"
command -v curl >/dev/null || { echo "curl required"; exit 1; }
command -v jq >/dev/null || { echo "jq required"; exit 1; }
meta="$(curl -fsSL "$API")"
tag="$(jq -r '.tag_name' <<<"$meta")"
[ -n "$tag" ] && [ "$tag" != "null" ] || { echo "no tag_name resolved"; exit 1; }
dest="${DEST_ROOT}/${tag}"
mkdir -p "$dest"
echo "Fetching FjordLauncher ${tag}${dest}"
mapfile -t urls < <(jq -r '.assets[].browser_download_url' <<<"$meta")
[ "${#urls[@]}" -gt 0 ] || { echo "no assets found"; exit 1; }
for url in "${urls[@]}"; do
fname="$(basename "$url")"
echo "${fname}"
curl -fSL --retry 3 -o "${dest}/${fname}" "$url"
done
# stable symlink for the landing page to reference
ln -sfn "$tag" "${DEST_ROOT}/latest"
echo "Done. ${#urls[@]} assets in ${dest}"
```
Caddy mount: `./mirror/launcher:/srv/www/launcher:ro`.
### 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:
- `fjord-windows-setup.exe` → Windows Setup `.exe` (x64)
- `fjord-macos.dmg` → macOS `.dmg`
- `fjord-linux-x86_64.AppImage` → Linux x86_64 `.AppImage`
Selecting the right asset from the release list needs filename matching (the
upstream names carry MSVC/MinGW/arch/version). Match cautiously; halt if a
category resolves to 0 or >1 candidate rather than guessing. **Names must stay in
sync with `landing/src/data/site.ts`.**
## Per-guest flow
1. Open `http://ulicraft.local`, 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`,
or a shared instance zip).
5. Connect to `mc.ulicraft.local: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/<tag>/`
- [ ] `.gitignore` `./mirror/` (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
- [ ] Pin a launcher version (don't silently follow `latest` on party day)

30
plan/07-mc-backup.md Normal file
View File

@@ -0,0 +1,30 @@
# mc-backup — world backups
## Summary
`itzg/mc-backup` alongside the server, talking via RCON. Snapshots the world every
6h, prunes after 14 days. World-only (no mod jars — mods are reproducible from the
packwiz pack). Unchanged from the original design; lowest-priority service.
## Config (carry-over)
```yaml
mc-backup:
image: itzg/mc-backup
environment:
BACKUP_INTERVAL: "6h"
RCON_HOST: "minecraft"
RCON_PASSWORD: ${RCON_PASSWORD}
PRUNE_BACKUPS_DAYS: "14"
TZ: "Europe/Madrid"
volumes:
- mc_data:/data:ro
- ./backups:/backups
```
## Tasks
- [ ] Keep `mc-backup` service on `mcnet`, `depends_on: minecraft`
- [ ] Confirm RCON enabled on server (`ENABLE_RCON`, `RCON_PASSWORD`)
- [ ] Verify `./backups/` is gitignored (already is)
- [ ] Optional: shorter interval / manual `rcon save-all` before risky changes

77
plan/08-tooling.md Normal file
View File

@@ -0,0 +1,77 @@
# Tooling — config rendering, mod mirror, launcher fetch
## 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.
## 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).
Targets:
- `drasl/config/config.toml.tmpl``drasl/config/config.toml`
- `dnsmasq/dnsmasq.conf.tmpl``dnsmasq/dnsmasq.conf`
```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}"
render() { # $1=tmpl $2=out
[ -f "$1" ] || { echo "missing template: $1"; exit 1; }
envsubst '${BASE_DOMAIN} ${HOST_LAN_IP}' < "$1" > "$2"
echo "rendered $2"
}
render drasl/config/config.toml.tmpl drasl/config/config.toml
render dnsmasq/dnsmasq.conf.tmpl dnsmasq/dnsmasq.conf
```
> Note: `envsubst` with an explicit var list avoids clobbering `$`-syntax that
> belongs to the target file (e.g. dnsmasq/caddy literals).
## mirror-mods.sh
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/<filename>`.
4. `packwiz refresh` to recompute hashes + `index.toml`.
Must halt if a download fails or a hash mismatches.
## 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.
## fetch-launcher.sh
Downloads all FjordLauncherUnlocked release assets → `mirror/launcher/<tag>/`.
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
.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/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

53
plan/09-landing.md Normal file
View File

@@ -0,0 +1,53 @@
# Landing page — guest onboarding site
## Summary
Apex `ulicraft.local` serves a clean static page guiding guests through joining:
download launcher → add account → import modpack → connect. Built with **Astro +
TypeScript** in its own `./landing/` project; `npm run build` emits straight into
`./www/`, which Caddy already serves at the apex domain. No runtime JS framework —
output is plain HTML/CSS plus one tiny copy-to-clipboard script.
## Stack & wiring
- `landing/` — Astro project (own `package.json`, `tsconfig`).
- `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`.
- Logo: drop PNG at `landing/public/logo.png` → copied to `www/logo.png`,
referenced as `/logo.png` (favicon + hero). `image-rendering: pixelated` keeps
the stone 3D logo crisp.
## Content (src/data/site.ts is the single source)
- Hero: logo, name, server address `mc.${BASE_DOMAIN}:25565` (copy button).
- Step 1: launcher downloads — 3 OS buttons → `/launcher/latest/<stable>`.
- Step 2: add authlib-injector account, URL `http://auth.${BASE_DOMAIN}/authlib-injector`,
register link to `http://auth.${BASE_DOMAIN}`.
- Step 3: import packwiz `http://packwiz.${BASE_DOMAIN}/pack.toml`.
- Step 4: connect to server address.
## Launcher download links (Option A — stable symlinks)
Page links to fixed names so they survive launcher version bumps:
- `fjord-windows-setup.exe`
- `fjord-macos.dmg`
- `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. Caddy mount
`./mirror/launcher:/srv/www/launcher:ro` makes them resolve at `/launcher/latest/…`.
## Tasks
- [ ] User: place logo at `landing/public/logo.png`
- [ ] `cd landing && pnpm install`
- [ ] `BASE_DOMAIN=ulicraft.local pnpm run build` → verify `www/index.html` + `www/logo.png`
- [ ] Extend `fetch-launcher.sh`: after download, symlink stable names → versioned files
(resolve which asset = win setup / mac dmg / linux AppImage)
- [ ] Confirm Caddy serves apex from `./www` and `/launcher/latest/*` from mirror
- [ ] Optional: pin launcher tag instead of `latest` for party day
- [ ] Optional: add a "what's in the pack" section once mod list firms up

114
plan/10-assets-mirror.md Normal file
View File

@@ -0,0 +1,114 @@
# Assets mirror — offline Minecraft asset objects for clients
## Summary
Guest launchers (FjordLauncherUnlocked, a Prism soft-fork) download ~400600 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/<id>.json (verify sha1)
4. For each object in index .objects{}:
hash=<sha1>; 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/<id>.json # e.g. 17.json / 1.21.json (for pre-seed/meta use)
└── objects/<2hex>/<sha1> # launcher requests <ResourceURL>/<2hex>/<sha1>
```
### Caddy wiring
Serve the **objects** dir at the web root of an HTTPS vhost so the launcher's
`<ResourceURL>/<2hex>/<hash>` resolves directly:
```
https://assets.{$BASE_DOMAIN} {
tls <local-ca-cert> <key> # 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/`
```

View File

@@ -0,0 +1,132 @@
# 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/<host>:/srv/mirror/<host>: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/<host>/<path>`,
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>/<hash>`.
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/`
```

59
plan/12-build-order.md Normal file
View File

@@ -0,0 +1,59 @@
# 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]`.
> Commit message convention: Conventional Commits, scope = service. Footer:
> `Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>`.
## Phase 0 — baseline
0. `chore: baseline scaffolding` — existing untracked tree (CLAUDE.md, plan/,
current docker-compose.yml, extras/, .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)`
## 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 3 — auth
6. `feat(drasl): add config.toml.tmpl (password login, SignPublicKeys=false)`
7. `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`
## Phase 5 — landing
11. `feat(landing): Astro+TS onboarding site → www/` (files already drafted)
12. `chore(landing): wire logo + .gitignore for www/ + build output`
## Phase 6 — launcher distribution
13. `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 8 — backups + orchestration
17. `feat(compose): add mc-backup service (RCON, 6h)`
18. `feat(tooling): add build-stack.sh orchestrator`
## 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`.
## Notes
- Items 35 can be smoke-tested (`docker compose up dnsmasq caddy`) before later
phases exist — Caddy serves a 404/landing, dnsmasq resolves.
- Items 1416 depend on a working online launch to capture the URL set first.
- Each commit should leave the tree in a non-broken state (compose still parses).