refactor!: unify into one compose, drop airgap/mirror/dnsmasq/avahi/blessingskin
Collapse the override-file matrix into a single docker-compose.yml holding the whole stack: drasl, minecraft, mc-backup, caddy, nmsr, uptime-kuma. Bring-up is now just `docker compose up -d --build`. Removed features (dead-ends for this deployment): - airgap / full asset mirror (mirror-airgap.sh, mirror-mods.sh, 20-mirror.caddy, caddy local-CA export, /ca.crt) - dnsmasq + avahi/mDNS + dns-records.sh + mdns-host-setup.sh + ENABLE_MDNS; DNS is now handled outside this repo (HOST_LAN_IP dropped) - Blessing Skin auth variant (compose + 00-core-blessingskin.caddy + BS_* env) - host-nginx-static variant (docker-compose.nginx.yml, nginx/ulicraft.conf.tmpl) - build-stack.sh and its run modes (online/airgap/core) Production ingress is the only path now: host nginx terminates TLS (LE certs) in front of caddy on a localhost-only port; caddy routes every vhost by Host header. Launcher downloads move mirror/launcher -> launcher/. Docs: README, deploy skill, and plan/ rewritten to match; obsolete plan docs (dnsmasq, blessing-skin, assets-mirror, full-airgap-mirror, dns-and-run-modes) deleted. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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/<name>/{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`.
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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=<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.
|
||||
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
|
||||
|
||||
@@ -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/<tag>/`
|
||||
- [ ] `.gitignore` `./mirror/` (done)
|
||||
- [ ] Extend script: create stable symlinks (win/mac/linux) in `launcher/latest/`
|
||||
- [ ] Run it pre-party; verify all assets land in `./launcher/<tag>/`
|
||||
- [ ] `.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
|
||||
|
||||
@@ -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/<filename>`.
|
||||
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/<name>/{cert,key}.pem`. See `15-letsencrypt.md`.
|
||||
|
||||
## fetch-launcher.sh
|
||||
|
||||
Downloads all FjordLauncherUnlocked release assets → `mirror/launcher/<tag>/`.
|
||||
Full script in `06-launcher.md`.
|
||||
Downloads FjordLauncherUnlocked release assets → `launcher/<tag>/` 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/`
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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/<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/`
|
||||
```
|
||||
@@ -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/<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/`
|
||||
```
|
||||
@@ -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) <noreply@anthropic.com>`.
|
||||
> `Co-Authored-By: Claude Opus 4.8 (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).
|
||||
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).
|
||||
|
||||
@@ -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>`
|
||||
|
||||
| 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 <mode>`:
|
||||
- `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=<lan-nic>` 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).
|
||||
@@ -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 <sha> # or: git reset --hard <sha>
|
||||
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`).
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user