Compare commits

..

9 Commits

Author SHA1 Message Date
1709bcd340 docs(claude): rewrite project context for unified stack
Drop stale Keycloak/OIDC, NetBird, AdGuard/.home.local, airgap, and
Prism-specific content. Reflect the current reality: single docker-compose.yml
(drasl, minecraft, mc-backup, caddy, nmsr, uptime-kuma), Drasl password-login
auth (no OIDC), public nginx+LE -> caddy ingress, DNS handled outside the repo,
FjordLauncher for guests. Point to plan/ as the source of truth; keep the
still-valid gotchas (secure-profile, authlib-injector, NeoForge pinning, mod
source) and carry-over comms prefs.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 01:49:39 +02:00
6df885eb13 docs(readme): document NMSR avatar renderer + ingress vhosts
Add an Avatar renderer (NMSR) section: Drasl-backed config, internal mcnet
resolution, localhost-only host port, endpoints, lavapipe note. List nmsr +
distribution as optional layers, refresh the Public TLS vhost list (www/avatar,
HTTPS-only), and update the Layout tree.

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 01:40:50 +02:00
a6155819b6 feat(nginx): enforce HTTPS-only with HSTS
Add Strict-Transport-Security (max-age 1y, includeSubDomains) to every TLS
server block in both vhost templates. Combined with the existing port-80
301 redirect, browsers refuse plain HTTP to these names after first visit.

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 01:09:46 +02:00
f3c38da56c fix(nginx): use legacy listen-flag http2 for older nginx
The `http2 on;` directive needs nginx 1.25.1+; older builds reject it. Move
http2 onto the listen line (`listen 443 ssl http2;`) which works on old and
new nginx alike. Applied to both vhost templates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 01:03:39 +02:00
21e6080e78 docs(readme): document host-nginx-in-front-of-caddy public TLS path
Add a section covering cert issuance, the localhost CADDY_HTTP_PORT bind, the
caddy/static/distribution compose bring-up, and rendering/installing
nginx/ulicraft-caddy.conf.tmpl. Note DISTRIBUTION_WEB_ROOT.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 00:40:02 +02:00
55 changed files with 877 additions and 2356 deletions

View File

@@ -1,6 +1,6 @@
---
name: deploy
description: Redeploy the Ulicraft Minecraft stack to the production host `cochi` — git pull on main then a hard restart (compose down + build-stack.sh --up online). Use when the user says "deploy", "redeploy", "deploy to cochi", "push to prod", "ship it", or "restart the server" for this project. Causes brief Minecraft downtime.
description: Redeploy the Ulicraft Minecraft stack to the production host `cochi` — git pull on main then a hard restart (compose down + up). Use when the user says "deploy", "redeploy", "deploy to cochi", "push to prod", "ship it", or "restart the server" for this project. Causes brief Minecraft downtime.
---
# Deploy Ulicraft to `cochi`
@@ -13,8 +13,9 @@ Redeploy the running stack on the production host. Full reference:
- Host: `cochi` (SSH alias)
- Path: `/home/ubuntu/mc/ulicraft-server-v1`
- Branch: `main`
- Run mode: `online` → compose files `docker-compose.yml` + `docker-compose.static.yml`
- Auth: Drasl (base stack)
- Stack: single unified `docker-compose.yml` (drasl, minecraft, mc-backup,
caddy, nmsr, uptime-kuma)
- Auth: Drasl
- Restart: **hard** (down → up) — players are disconnected for a few minutes
## Procedure
@@ -35,32 +36,32 @@ invoking this skill is the authorization to proceed, but:
ssh cochi 'set -e
cd /home/ubuntu/mc/ulicraft-server-v1
git pull --ff-only
docker compose -f docker-compose.yml -f docker-compose.static.yml down --remove-orphans
tooling/build-stack.sh --up online'
tooling/render-config.sh
docker compose down --remove-orphans
docker compose up -d --build'
```
- `--ff-only` refuses to merge — if the pull is not a fast-forward, STOP and
report (local changes on the host need manual resolution).
- `build-stack.sh --up online` renders configs (needs `.env` complete:
`BASE_DOMAIN`, `HOST_LAN_IP`, `RCON_PASSWORD`) and runs `compose up -d`.
- `render-config.sh` re-renders drasl/nmsr/packwiz configs from `.env`
(needs `.env` complete: `BASE_DOMAIN`, `RCON_PASSWORD`,
`DISTRIBUTION_WEB_ROOT`, `CADDY_HTTP_*`).
3. **Verify.**
```bash
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && docker compose -f docker-compose.yml -f docker-compose.static.yml ps'
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && docker compose ps'
```
Confirm `caddy`, `drasl`, `minecraft`, `mc-backup` are Up. Tail the minecraft
log briefly and confirm it reaches `Done` (server ready):
Confirm `caddy`, `drasl`, `minecraft`, `mc-backup`, `nmsr`, `uptime-kuma` are
Up. Tail the minecraft log briefly and confirm it reaches `Done` (server
ready):
```bash
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && timeout 120 docker compose -f docker-compose.yml -f docker-compose.static.yml logs -f minecraft' 2>/dev/null | grep -m1 "Done"
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && timeout 120 docker compose logs -f minecraft' 2>/dev/null | grep -m1 "Done"
```
## Guardrails
- Never `git reset --hard` or discard changes on `cochi` without telling the user
first — there may be host-local edits.
- Volumes (`mc_data`, `drasl_state`) persist across `down`; the world is safe. Do
NOT pass `-v`/`--volumes` to `down`.
- Volumes (`mc_data`, `drasl_state`, `kuma_data`) persist across `down`; the
world and monitors are safe. Do NOT pass `-v`/`--volumes` to `down`.
- If a step fails, STOP and surface the exact error + the failing command. Do not
improvise recovery on production.
- Blessing Skin variant or `airgap` mode changes the compose file set — if the
user asks for those, follow `plan/14-deploy.md` instead of the fixed commands
above.

View File

@@ -1,35 +1,22 @@
# Copy to .env and fill in real values
# Single base domain for the whole stack; every service is a subdomain of this.
# Use .lan (NOT .local) — .local is intercepted by mDNS on macOS/Linux and
# won't resolve through a normal unicast DNS server.
BASE_DOMAIN=ulicraft.lan
# The Docker host's LAN IP — every service name resolves here.
HOST_LAN_IP=192.168.1.10
# mDNS toggle. true + BASE_DOMAIN=*.local → an Avahi container publishes the
# service names over mDNS (zero-config for macOS/Linux guests; Windows needs
# Bonjour). false → use your own party DNS (records: tooling/dns-records.sh).
# NOTE: mDNS resolves ONLY *.local; it cannot serve the air-gap upstream-host
# spoofs — those always need a real DNS server.
ENABLE_MDNS=false
# DNS is handled OUTSIDE this repo — point ${BASE_DOMAIN} and every subdomain
# (auth. pack. avatar. status. distribution. www.) at the host running nginx.
BASE_DOMAIN=ulicraft.net
RCON_PASSWORD=change-me-to-something-random
# Caddy host-published port (docker-compose.caddy.yml). Default 80 for the
# standalone LAN/air-gap path. When the machine's own nginx fronts caddy
# (nginx terminates TLS → reverse-proxies to caddy), set a high port and bind
# it to localhost so only nginx reaches it:
# CADDY_HTTP_PORT=8880
# CADDY_HTTP_BIND=127.0.0.1
# Then render nginx/ulicraft-caddy.conf.tmpl with CADDY_HTTP_PORT.
CADDY_HTTP_PORT=80
CADDY_HTTP_BIND=0.0.0.0
# caddy host-published port. The host's own nginx terminates TLS and
# reverse-proxies to caddy by Host header (nginx/ulicraft-caddy.conf.tmpl), so
# bind caddy to a high localhost-only port — only nginx should reach it.
CADDY_HTTP_PORT=8880
CADDY_HTTP_BIND=127.0.0.1
# distribution.${BASE_DOMAIN} static vhost (docker-compose.distribution.yml).
# Absolute host path to the built static site — it lives in ANOTHER repo.
# Bind-mounted read-only into caddy and served at distribution.${BASE_DOMAIN}.
# distribution.${BASE_DOMAIN} static vhost. Absolute host path to the built
# static site — it lives in ANOTHER repo. Bind-mounted read-only into caddy.
DISTRIBUTION_WEB_ROOT=/home/oier/projects/mc-mods/ulicraft-group/ulicraft-distribution/dist
# Only needed if using CurseForge-exclusive mods:
# CF_API_KEY=your-curseforge-api-key
@@ -37,7 +24,7 @@ DISTRIBUTION_WEB_ROOT=/home/oier/projects/mc-mods/ulicraft-group/ulicraft-distri
# Only needed to issue real TLS certs. DNS-01 needs no inbound ports.
LE_EMAIL=you@example.com
# space-separated subdomains; apex ${BASE_DOMAIN} is always included
LE_SUBDOMAINS=auth pack distribution
LE_SUBDOMAINS=auth pack distribution www avatar status
# OVH API creds (DNS zone write). Create at https://eu.api.ovh.com/createToken/
# OVH_CK can be blank on first run — acme.sh prints an auth URL, then paste it.
OVH_END_POINT=ovh-eu
@@ -45,13 +32,3 @@ OVH_AK=
OVH_AS=
OVH_CK=
# LE_STAGING=1 # use the untrusted staging CA for dry runs
# ── Blessing Skin auth variant (docker-compose.blessingskin.yml) ──────
# Only needed when running the Blessing Skin override instead of Drasl.
BS_DB_NAME=blessingskin
BS_DB_USER=blessingskin
BS_DB_PASSWORD=change-me-bs-db
BS_DB_ROOT_PASSWORD=change-me-bs-root
# Generate once and paste here so it survives container recreates:
# docker run --rm azusamikan/blessing-skin-server-docker:latest php artisan key:generate --show
BS_APP_KEY=base64:replace-with-generated-key

9
.gitignore vendored
View File

@@ -5,15 +5,15 @@ runtime/authlib-injector.jar
# rendered configs (from tooling/render-config.sh)
drasl/config/config.toml
dnsmasq/dnsmasq.conf
nmsr/config.toml
# packwiz pack files rendered from pack/**/*.tmpl by tooling/render-pack.sh
# (domain-dependent build output); source of truth is the .tmpl + custom/ jars.
pack/mods/*.pw.toml
pack/index.toml
pack/CustomSkinLoader/CustomSkinLoader.json
# vendored / mirrored binaries
mirror/
# vendored binaries
launcher/
pack/mods-files/
# landing page: generated build output + deps
@@ -22,8 +22,5 @@ landing/node_modules/
landing/.astro/
landing/dist/
# exported Caddy CA (per-deploy artifact)
caddy/caddy-root-ca.crt
# Let's Encrypt certs + private keys (issued by tooling/issue-letsencrypt.sh)
certs/

343
CLAUDE.md
View File

@@ -1,280 +1,143 @@
# Minecraft LAN Party Server — Project Context
# Ulicraft Server — Project Context
> Self-hosted modded Minecraft server for a LAN party (~8 players), with
> persistent auth/skin infrastructure designed to outlive the LAN weekend
> and integrate with the existing homelab.
> Self-hosted modded Minecraft (NeoForge 1.21.1) with self-hosted auth/skins
> (Drasl), a packwiz modpack, avatar rendering, a guest landing page, and uptime
> monitoring. Public, internet-present deployment behind the host's nginx + TLS.
**`plan/` is the source of truth for the design.** Start at `plan/00-overview.md`.
This file is a fast orientation + the durable decisions/gotchas; when it disagrees
with `plan/` or the code, those win.
## Goals & Constraints
- **Players**: 410 friends, LAN-based
- **Players**: 410 friends
- **Modpack vibe**: Kitchen-sink (tech + magic + exploration + QoL)
- **Pack approach**: Manually curated (~50100 mods), NOT a forked existing pack
- **Minecraft version**: 1.21.1
- **Mod loader**: NeoForge (NOT classic Forge — see Decisions below)
- **Network**: LAN party context, but stack is persistent (lives past the weekend)
- **Auth**: Self-hosted Yggdrasil-compatible (Drasl) + Keycloak OIDC
- **Skins**: Handled by Drasl
- **Persistence horizon**: Long-term homelab service, not disposable
## Existing Homelab Context (relevant to this project)
- **Keycloak 26.0.7** running in Docker Compose (PostgreSQL 17, production mode,
bind-mounted secrets, xforwarded proxy headers). This is the IdP for everything.
- **NetBird** as the proxy/networking layer for remote access (NOT used for the
LAN party itself — see Decisions).
- **AdGuard Home** as recursive DNS resolver — used here for `*.home.local` LAN records.
- **Multi-VPS topology**: this Minecraft project likely runs on a local box or
beefier homelab node, not a VPS (RAM/CPU requirements).
- Operator timezone: Europe/Madrid.
## Key Decisions (with reasoning)
### NeoForge over classic Forge
Despite the user originally saying "Forge", the 1.21.x kitchen-sink ecosystem
(ATM10, Leaking Kitchen Sink, etc.) is overwhelmingly NeoForge in 2025/2026.
The original Forge team essentially migrated to NeoForge. `itzg/minecraft-server`
supports both via `TYPE=NEOFORGE`. Going with NeoForge 1.21.1.
### Drasl over Ely.by or vanilla offline mode
- **Ely.by**: not self-hosted, Russian-hosted, no control over identity layer.
- **Vanilla offline + no auth server**: works for one weekend but skins break,
no persistent identity, doesn't match homelab philosophy.
- **Drasl**: self-hosted Go service, Yggdrasil-compatible, drop-in Mojang
replacement, supports skins + capes, **has first-class OIDC support with
PKCE** (perfect for Keycloak), actively maintained (current version 3.4.2+).
GPLv3 licensed.
### LAN (not NetBird) for the party itself
NetBird is the homelab's remote access layer, but adding a mesh VPN on top
of a LAN party introduces complexity for guests with no upside. Drasl and
Minecraft live on the LAN; clients reach them via `drasl.home.local` resolved
through AdGuard.
### Manual curation (user choice — flagged as expensive)
User insisted on manual curation despite my pushback that forking ATM10 or
Leaking Kitchen Sink would save weeks of crash-log triage. **Note for future
sessions**: if curation gets painful, the fork-and-trim option is still on
the table — Leaking Kitchen Sink is the closest match to the requested vibe.
### itzg/minecraft-server as base image
The de facto standard. Handles NeoForge installation automatically via
`TYPE=NEOFORGE` + `VERSION` + `NEOFORGE_VERSION`. Supports Modrinth and
CurseForge mod auto-download via `MODRINTH_PROJECTS` and `CURSEFORGE_FILES`.
- **Minecraft version**: 1.21.1, **NeoForge** (not classic Forge)
- **Auth/skins**: Drasl (Yggdrasil-compatible), **password login** — no OIDC
- **DNS**: handled OUTSIDE this repo; point `${BASE_DOMAIN}` + subdomains at the host
- **Persistence horizon**: long-term service, not disposable
## Architecture
```
LAN segment
├── AdGuard Home → resolves drasl.home.local, keycloak.home.local
│ to the Docker host's LAN IP
├── Keycloak (existing, separate compose)
│ realm: homelab
│ client: drasl (confidential, PKCE S256)
└── Minecraft host (this project's compose)
├── drasl :25585 (Yggdrasil API + web UI)
│ └── OIDC → Keycloak
├── minecraft :25565 (server)
│ :24454/udp (Simple Voice Chat, optional)
│ └── -javaagent:authlib-injector.jar=http://drasl.home.local:25585/authlib-injector
│ └── ONLINE_MODE=false (required for authlib-injector)
└── mc-backup (itzg/mc-backup, 6h interval)
└── RCON → minecraft
One unified `docker-compose.yml`. The host's own nginx terminates TLS (Let's
Encrypt) and reverse-proxies every vhost to caddy (published localhost-only) by
Host header. caddy is the internal router; containers reach the stack's own names
via caddy's `mcnet` aliases (`auth.`, `pack.`).
Clients (LAN party guests):
Prism Launcher → authlib-injector account pointing at Drasl
→ joins minecraft:25565
```
Internet ── host nginx (TLS, LE certs, HSTS) ──► caddy (127.0.0.1:8880)
│ routes by Host:
${BASE_DOMAIN} ─► /srv/www landing + /launcher/ downloads
auth.${BASE_DOMAIN} ─► drasl (Yggdrasil API + web UI)
pack.${BASE_DOMAIN} ─► packwiz files + /custom/ jars
avatar.${BASE_DOMAIN} ─► nmsr (skin/avatar renderer, Drasl-backed)
status.${BASE_DOMAIN} ─► uptime-kuma (status page + monitors)
distribution.${BASE} ─► static site from ANOTHER repo (DISTRIBUTION_WEB_ROOT)
minecraft :25565 (+ 24454/udp Simple Voice Chat)
└ ONLINE_MODE=false, -javaagent authlib-injector → http://auth.${BASE_DOMAIN}/authlib-injector
└ mods via PACKWIZ_URL → http://pack.${BASE_DOMAIN}/pack.toml
mc-backup ── RCON → minecraft (6h interval, prune 14d, world-only)
```
## Auth Flow (end-to-end)
Bring-up: render configs → build landing → fetch launcher → `docker compose up -d --build`.
See `plan/12-build-order.md` and `plan/14-deploy.md`. Deploy to prod host `cochi`
via the `deploy` skill.
1. Guest opens `http://drasl.home.local:25585` in browser.
2. Clicks "Register with Keycloak" → redirected to Keycloak login.
3. Logs in with homelab Keycloak credentials.
4. Returned to Drasl, picks a player name, uploads a skin.
5. Drasl shows them a "Minecraft Token" on their profile page.
6. In Prism Launcher: Settings → Accounts → Add authlib-injector account
with URL `http://drasl.home.local:25585/authlib-injector` and the
Minecraft Token as password.
7. Joins Minecraft server. Server validates session against Drasl (via
authlib-injector JVM agent), Drasl confirms, player enters with their skin.
## Key Decisions
- **NeoForge over Forge** — the 1.21.x kitchen-sink ecosystem is overwhelmingly
NeoForge; the Forge team migrated. `itzg/minecraft-server` via `TYPE=NEOFORGE`.
- **Drasl over Ely.by / vanilla offline** — self-hosted Go service, Yggdrasil
drop-in, skins + capes, actively maintained. Run with **password login**
(no OIDC/Keycloak in this stack).
- **Manual curation** (user choice, flagged expensive) — if it gets painful,
fork-and-trim Leaking Kitchen Sink is still on the table (closest vibe match).
- **itzg/minecraft-server** base image — de-facto standard, auto-installs NeoForge.
- **packwiz** for the modpack — `PACKWIZ_URL` drives server mod install; clients
import the same `pack.toml`. Jars come from the upstream CDN per `.pw.toml`.
## Critical Gotchas
### Minecraft 1.21+ secure profile incompatibility
- Server: must set `enforce-secure-profile=false` (compose: `ENFORCE_SECURE_PROFILE=FALSE`)
- Drasl: must set `SignPublicKeys = false`
- These are linked. The Drasl docs explicitly warn: *"Mixed authentication does
not work with `SignPublicKeys = true` on Minecraft 1.21+."*
### Minecraft 1.21+ secure profile
- Server: `ENFORCE_SECURE_PROFILE=FALSE` (`enforce-secure-profile=false`)
- Drasl: `SignPublicKeys = false`
- Linked. Drasl docs: *"Mixed authentication does not work with
`SignPublicKeys = true` on Minecraft 1.21+."*
### authlib-injector JVM agent
- Syntax: `-javaagent:/path/to/authlib-injector.jar=<yggdrasil-api-url>`
- The URL after `=` is the **API root**, which for Drasl is
`<BaseURL>/authlib-injector`.
- **Must match between server and client.** If server uses
`http://drasl.home.local:25585/authlib-injector` and client uses anything
else (e.g. an IP), session validation fails silently with "Invalid session".
- Download from: https://github.com/yushijinhun/authlib-injector/releases
- Mount into the itzg container at `/extras/authlib-injector.jar`.
### Keycloak reachability on LAN day
If Keycloak is only reachable via NetBird or only from the public internet,
**Drasl OIDC registration breaks on LAN-only day**. Options:
1. Expose Keycloak on the LAN (simplest)
2. Pre-register all guests before the party
3. Temporarily disable OIDC and allow password registration during the LAN
(`AllowPasswordLogin = true`, comment out the OIDC block)
### HTTP vs HTTPS for OIDC
Modern browsers warn on plain HTTP for OIDC flows but they work. For a
production-grade setup, front Drasl with Caddy or Traefik using a local CA
cert (smallstep/step-ca pairs well with the existing homelab). For LAN-only
HTTP is fine.
- Syntax: `-javaagent:/extras/authlib-injector.jar=<yggdrasil-api-url>`
- URL after `=` is the **API root** = `<BaseURL>/authlib-injector`. For this
stack: `http://auth.${BASE_DOMAIN}/authlib-injector` (internal, over mcnet).
- **Must match between server and client** (clients use the public
`https://auth.${BASE_DOMAIN}/authlib-injector`), else session validation fails
silently with "Invalid session".
- Releases: https://github.com/yushijinhun/authlib-injector — jar lives in
`runtime/` (mounted at `/extras/authlib-injector.jar`).
### NeoForge version pinning
Mods are picky about exact NeoForge minor versions. The compose currently
pins `NEOFORGE_VERSION: "21.1.209"` as a placeholder. **Verify against
https://projects.neoforged.net/neoforged/neoforge before deploying.** Don't
use `"latest"` for a stable server.
### Drasl username vs player name
- **Drasl username** = OIDC user's email (used for web UI login)
- **Player name** = `preferred_username` from OIDC, or user-chosen if
`AllowChoosingPlayerName = true`
- Keycloak users must have email set. Verify the realm's email-as-username
setting and the `preferred_username` mapper are configured.
Mods are picky about exact minor versions. Compose pins `NEOFORGE_VERSION:
"21.1.209"`. **Verify against https://projects.neoforged.net/neoforged/neoforge
before deploying.** Never `"latest"` for a stable server.
### Mod source preference
Prefer **Modrinth over CurseForge** for itzg auto-download. CurseForge
requires an API key (`CF_API_KEY`) and has been historically flakier with
the itzg image. Use CF only for mods that are exclusive to it.
## Compose Stack
The working compose file is at `./docker-compose.yml`. Key environment vars
documented inline. Adjacent files:
```
.
├── docker-compose.yml
├── .env # RCON_PASSWORD, CF_API_KEY (if needed)
├── drasl/
│ └── config/
│ ├── config.toml # see ./drasl-config.toml example
│ └── keycloak-client-secret # single line, no trailing newline
├── extras/
│ ├── authlib-injector.jar # downloaded from yushijinhun's releases
│ ├── modrinth-mods.txt # one mod slug per line
│ └── cf-mods.txt # only if using CurseForge mods
├── backups/ # mc-backup writes here
└── CLAUDE.md # this file
```
Prefer **Modrinth over CurseForge** (`packwiz modrinth add <slug>`). CF needs
`CF_API_KEY` and is flakier. Tag client-only mods `side = "client"` so the server
doesn't pull and crash on them.
### Memory sizing
With ~50100 mods and 8 concurrent players on 1.21.1:
- `INIT_MEMORY: 4G`, `MAX_MEMORY: 10G` is a safe starting point
- Use `USE_AIKAR_FLAGS: TRUE` for the well-known GC tuning
- Monitor with `mc-monitor` (itzg makes one) if you want metrics
~50100 mods, 8 players, 1.21.1: `INIT_MEMORY: 4G`, `MAX_MEMORY: 10G`,
`USE_AIKAR_FLAGS: TRUE`.
### Backups
`itzg/mc-backup` runs alongside, talks to the server via RCON, takes
snapshots every 6h, prunes after 14 days. Backups are world-only (no mod
jars), which is correct — mods are reproducible from the mod list files.
## Config (.env)
## Keycloak Client Configuration
`BASE_DOMAIN`, `RCON_PASSWORD`, `CADDY_HTTP_PORT`/`CADDY_HTTP_BIND`,
`DISTRIBUTION_WEB_ROOT`, the Let's Encrypt block (`LE_EMAIL`, `LE_SUBDOMAINS`,
`OVH_*`), optional `CF_API_KEY`. Rendered configs (drasl, nmsr, packwiz) come
from `*.tmpl` via `tooling/render-config.sh` (substitutes `${BASE_DOMAIN}` only).
In the `homelab` realm (or whatever the realm is called):
## Client Distribution (guests)
1. **Clients → Create client**
- Client type: `OpenID Connect`
- Client ID: `drasl`
- Name: `Drasl Minecraft Auth`
2. **Capability config**
- Client authentication: **ON** (confidential client)
- Authentication flow: **Standard flow** only
- Disable: Direct access grants, Implicit, Service accounts
3. **Login settings**
- Root URL: `http://drasl.home.local:25585`
- Valid redirect URIs: `http://drasl.home.local:25585/web/oidc-callback/Keycloak`
(the `Keycloak` at the end MUST match the `Name = "Keycloak"` in Drasl's
`[[RegistrationOIDC]]` block — case sensitive)
- Web origins: `http://drasl.home.local:25585`
4. **Credentials tab** → copy Client Secret to
`./drasl/config/keycloak-client-secret` (no trailing newline).
5. **Advanced tab** → PKCE Code Challenge Method: `S256`
## Client Distribution (LAN guests)
### Recommended launcher
**Prism Launcher** — open source, cross-platform (Win/Mac/Linux), first-class
authlib-injector support, can import/export instance ZIPs.
### Per-guest one-time setup
1. Install Prism Launcher.
2. Add authlib-injector account:
- URL: `http://drasl.home.local:25585/authlib-injector`
- Username/password: their Drasl credentials (or Minecraft Token if
registered via Keycloak OIDC)
3. Import the modpack instance ZIP.
### What to ship guests
- A Prism instance ZIP (right-click instance → Export Instance)
- A one-page README with the auth URL and import steps
- Optionally `authlib-injector.jar` for guests who refuse Prism
Launcher: **FjordLauncherUnlocked** (Prism fork, first-class authlib-injector),
mirrored at apex `/launcher/`. Per guest:
1. Download from `https://${BASE_DOMAIN}``/launcher/`.
2. Add an authlib-injector account, URL
`https://auth.${BASE_DOMAIN}/authlib-injector` (register/login at
`https://auth.${BASE_DOMAIN}`).
3. Import the pack `https://pack.${BASE_DOMAIN}/pack.toml`.
4. Connect to `${BASE_DOMAIN}` (`:25565`).
## Open / Pending Work
These were on the roadmap when we ended the brainstorm:
- [ ] **Mod shortlist for the kitchen-sink pack.** Categories to fill:
- Performance (Embeddium/Sodium-equivalent for NeoForge, FerriteCore, ModernFix)
- Tech/automation (Mekanism, Create, Immersive Engineering, AE2)
- Magic (Ars Nouveau, Botania, Iron's Spells 'n Spellbooks)
- Storage (Sophisticated Storage/Backpacks, Functional Storage)
- Exploration (YUNG's structures, Repurposed Structures, biome packs)
- QoL (JEI, Jade, JEI Resources, Inventory Profiles Next, AppleSkin)
- World gen (Tectonic, Terralith — check NeoForge 1.21.1 compat)
- Compat glue (Polymorph for recipe conflicts)
- Map (XaeroMinimap + Xaero World Map)
- Voice (Simple Voice Chat — port 24454/udp already in compose)
- [ ] **Decide on dimension/server-side performance mods** (C2ME, Lithium-equivalent)
- [ ] **Reverse proxy + local TLS** for Drasl (Caddy + step-ca recommended,
given the existing homelab)
- [ ] **DNS record** in AdGuard for `drasl.home.local` → Docker host LAN IP
- [ ] **Keycloak client** creation per the section above
- [ ] **Verify NeoForge version** against current stable before first deploy
- [ ] **Bootstrap admin in Drasl**: leave `AllowPasswordLogin = true` initially,
create the admin account, then optionally disable password login to force OIDC
- [ ] **Mod shortlist** for the kitchen-sink pack: Performance (Embeddium,
FerriteCore, ModernFix), Tech (Mekanism, Create, IE, AE2), Magic (Ars Nouveau,
Botania, Iron's Spells), Storage (Sophisticated, Functional), Exploration
(YUNG's, Repurposed Structures), QoL (JEI, Jade, IPN, AppleSkin), World gen
(Tectonic, Terralith — verify NeoForge 1.21.1), Compat (Polymorph), Map (Xaero),
Voice (Simple Voice Chat — 24454/udp already in compose).
- [ ] Server-side perf mods (C2ME, etc.).
- [ ] **Verify NeoForge version** against current stable before first deploy.
- [ ] **Bootstrap Drasl admin** account.
## Reference URLs
- Drasl repo: https://github.com/unmojang/drasl
- Drasl configuration docs: https://github.com/unmojang/drasl/blob/master/doc/configuration.md
- Drasl recipes (example configs): https://github.com/unmojang/drasl/blob/master/doc/recipes.md
- Drasl: https://github.com/unmojang/drasl — config docs:
https://github.com/unmojang/drasl/blob/master/doc/configuration.md
- authlib-injector: https://github.com/yushijinhun/authlib-injector
- itzg/minecraft-server: https://github.com/itzg/docker-minecraft-server
- itzg NeoForge docs: https://github.com/itzg/docker-minecraft-server/blob/master/docs/types-and-platforms/server-types/forge.md
(NeoForge: …/docs/types-and-platforms/server-types/forge.md)
- itzg/mc-backup: https://github.com/itzg/docker-mc-backup
- NeoForge versions: https://projects.neoforged.net/neoforged/neoforge
- Prism Launcher: https://prismlauncher.org/
- Reference modpacks for inspiration (NOT forking):
- ATM10 (Modrinth/CurseForge — ~500 mods, NeoForge 1.21.1)
- Leaking Kitchen Sink (CurseForge — trimmed ~150 mods, NeoForge 1.21.1)
- NMSR: https://github.com/NickAcPT/nmsr-rs
- Uptime Kuma: https://github.com/louislam/uptime-kuma
- FjordLauncherUnlocked: https://github.com/hero-persson/FjordLauncherUnlocked
- Reference packs (inspiration, NOT forking): ATM10, Leaking Kitchen Sink.
## Communication Preferences (carry-over from past sessions)
## Communication Preferences (carry-over)
- Concise and direct
- Diagnose and resolve without demanding detailed reproduction steps
- Prefer iterative single-change updates over large rewrites
- Cautious scripts that halt on ambiguity rather than proceeding silently
- Avoid over-engineered responses when a simple answer suffices
- Spanish or English fine; user is comfortable in both
- Concise and direct; diagnose + resolve without demanding repro steps.
- Prefer iterative single-change updates over large rewrites.
- Cautious scripts that halt on ambiguity rather than proceeding silently.
- Avoid over-engineering when a simple answer suffices.
- Spanish or English both fine.

190
README.md
View File

@@ -1,111 +1,159 @@
# Ulicraft Server
Self-hosted modded Minecraft (NeoForge 1.21.1) for a LAN party. Runs **online**
(internet present) or **fully air-gapped** (offline). Self-hosted auth/skins
(Drasl), a packwiz modpack, a guest landing page, and a transparent mirror of
Mojang/launcher/NeoForge assets so guest laptops need no internet.
Self-hosted modded Minecraft (NeoForge 1.21.1) with self-hosted auth/skins
(Drasl), a packwiz modpack, avatar rendering, a guest landing page, and uptime
monitoring. One unified `docker-compose.yml` runs the whole stack behind the
host's nginx + Let's Encrypt.
## Stack
One compose file, one `docker compose up -d`:
| Service | Image | Role |
|---|---|---|
| `minecraft` | itzg/minecraft-server | NeoForge 1.21.1 server, `:25565` |
| `drasl` | unmojang/drasl | Yggdrasil auth + skins (password login) |
| `caddy` | caddy:alpine | ingress: landing, auth/packwiz proxy, asset mirror (`tls internal`) |
| `caddy` | caddy:alpine | internal ingress: routes every vhost by Host header |
| `nmsr` | built from source | skin/avatar renderer at `avatar.${BASE_DOMAIN}` |
| `uptime-kuma` | louislam/uptime-kuma | status page at `status.${BASE_DOMAIN}` |
| `mc-backup` | itzg/mc-backup | world backups every 6h via RCON |
Optional layers: `avahi` (mDNS `.local`), `dnsmasq` (turnkey DNS) — only if you
don't run your own DNS.
Vhosts (all proxied through the host nginx → caddy):
Config lives in `.env`: `BASE_DOMAIN` (default `ulicraft.lan`), `HOST_LAN_IP`,
`RCON_PASSWORD`, `ENABLE_MDNS`. Services are subdomains: `auth.`, `packwiz.`,
`mc.`, plus the apex landing page.
- apex `${BASE_DOMAIN}` — landing page + `/launcher/` downloads
- `auth.` — Drasl
- `pack.` — packwiz metadata + `/custom/` jars
- `avatar.` — NMSR
- `status.` — Uptime Kuma
- `distribution.` — static site from another repo (`DISTRIBUTION_WEB_ROOT`)
## DNS (pick one)
Config lives in `.env`: `BASE_DOMAIN`, `RCON_PASSWORD`, `CADDY_HTTP_PORT` /
`CADDY_HTTP_BIND`, `DISTRIBUTION_WEB_ROOT`. See `.env.example`.
Names must resolve to `HOST_LAN_IP`. Use **your own party DNS server** (recommended):
## DNS
```sh
tooling/dns-records.sh online # service names only (internet present)
tooling/dns-records.sh airgap # + Mojang/launcher/NeoForge spoofs (offline)
```
Paste the printed records into your DNS. **Use `.lan`, not `.local`**`.local`
is intercepted by mDNS and won't resolve via unicast DNS.
Alternatives: `ENABLE_MDNS=true` + `BASE_DOMAIN=*.local` (zero-config mDNS,
macOS/Linux native, Windows needs Bonjour) — but mDNS can't serve the air-gap
upstream spoofs. Or layer in `docker-compose.dnsmasq.yml` for a turnkey DNS.
### Simulate the DNS via /etc/hosts (single machine, testing)
No DNS server? Append the records straight to `/etc/hosts` (marker-wrapped):
```sh
tooling/dns-records.sh online hosts | sudo tee -a /etc/hosts # services only
tooling/dns-records.sh airgap hosts | sudo tee -a /etc/hosts # + Mojang spoofs
```
Remove them later:
```sh
sudo sed -i '/# >>> ulicraft/,/# <<< ulicraft/d' /etc/hosts
```
Notes:
- `/etc/hosts` has **no wildcard** — only the listed names resolve.
- `airgap` adds the Mojang/launcher/NeoForge hostnames pointing at the LAN; this
**breaks normal internet access to those domains** while present. Use it only
while offline; for an online box use `online`.
- `mc.<domain>` works (client defaults to :25565); SRV isn't used via `/etc/hosts`.
Handled **outside this repo**. Point `${BASE_DOMAIN}` and every subdomain
(`auth. pack. avatar. status. distribution. www.`) at the host running nginx.
## Prerequisites
Docker + Docker Compose; for prep also `pnpm`, `packwiz`, `jq`, `curl`,
`envsubst`, `sha1sum`.
`envsubst`.
## Launch
```sh
cp .env.example .env # set BASE_DOMAIN, HOST_LAN_IP, RCON_PASSWORD
cp .env.example .env # set BASE_DOMAIN, RCON_PASSWORD, DISTRIBUTION_WEB_ROOT
tooling/build-stack.sh --prep # ONLINE, once: render, build landing,
# mirror mods+assets, fetch launcher +
# authlib, pre-bake the server volume
# One-time / on change — render configs + build content:
tooling/render-config.sh # drasl + nmsr + packwiz configs from *.tmpl
( cd landing && pnpm install && BASE_DOMAIN="$BASE_DOMAIN" pnpm run build ) # → www/
tooling/fetch-launcher.sh # FjordLauncher assets → launcher/ (served at /launcher/)
# authlib-injector.jar → runtime/ (server JVM agent; see drasl docs)
tooling/build-stack.sh --up online # internet present, no mirror
tooling/build-stack.sh --up airgap # offline; full asset mirror on :443 (default)
tooling/build-stack.sh --up core # base only (debugging)
docker compose up -d --build # first run installs NeoForge + mods, builds nmsr
docker compose down # stop
```
`--up` prints the DNS records to add for that mode. Down:
`docker compose -f docker-compose.yml -f docker-compose.static.yml -f docker-compose.mirror.yml down`
First boot: itzg installs NeoForge + the packwiz mods into the `mc_data` volume;
nmsr does a one-time Rust compile. Watch `docker compose logs -f minecraft` until
`Done`.
## Public TLS (host nginx in front of caddy)
caddy is published on a localhost-only port (`CADDY_HTTP_BIND=127.0.0.1`,
`CADDY_HTTP_PORT=8880`). The host's own nginx terminates TLS with Let's Encrypt
certs and reverse-proxies every vhost to caddy by Host header. HTTPS-only: HTTP
301s to HTTPS and every TLS vhost sends HSTS.
1. **Issue certs** (OVH DNS-01, no inbound ports needed):
```sh
# .env: BASE_DOMAIN, LE_EMAIL, OVH_* creds,
# LE_SUBDOMAINS="auth pack distribution www avatar status"
tooling/issue-letsencrypt.sh # → certs/<name>/{cert,key}.pem
```
2. **Render + install the nginx vhost** with `tooling/render-nginx.sh`. It pulls
`BASE_DOMAIN` + `CADDY_HTTP_PORT` from `.env`, defaults `APP_DIR` to the repo
checkout (where `certs/` live), and renders `nginx/ulicraft-caddy.conf.tmpl`:
```sh
tooling/render-nginx.sh # preview to stdout (dry run)
tooling/render-nginx.sh --install # write, symlink, nginx -t, reload
```
Override `APP_DIR=/path` if the repo isn't at the cert location. Re-run
whenever you add a subdomain so its server block is rendered.
## Avatar renderer (NMSR)
`avatar.${BASE_DOMAIN}` renders players' skins/avatars via
[NMSR-aas](https://github.com/NickAcPT/nmsr-rs), sourced from **Drasl** (not
Mojang). No upstream image exists, so it's built from source — `docker/nmsr/`
pins a commit; bump `ARG NMSR_REF` to update.
- **Config**: `nmsr/config.toml.tmpl` → rendered to `nmsr/config.toml` by
`tooling/render-config.sh`. `[mojank]` points every Mojang endpoint at
`http://auth.${BASE_DOMAIN}`; Drasl serves the Mojang-compatible routes at its
bare BaseURL and embeds absolute skin URLs that NMSR fetches directly.
`allow_offline_mode_uuids = true` (Drasl issues offline v3 UUIDs).
- **Network**: skin resolution stays internal over `mcnet` (caddy alias
`auth.${BASE_DOMAIN}` → drasl). caddy reverse-proxies `avatar.` → `nmsr:8080`.
- **Endpoints**: e.g. `https://avatar.${BASE_DOMAIN}/skin/<player>`,
`/face/<player>`, `/fullbody/<player>` — by username or UUID.
First build is heavy (Rust compile). Headless rendering uses lavapipe (software
Vulkan); if renders fail, check `docker logs nmsr` for Vulkan device init.
## Status monitoring (Uptime Kuma)
`status.${BASE_DOMAIN}` runs [Uptime Kuma](https://github.com/louislam/uptime-kuma)
— uptime probes for every vhost **and** a native Minecraft-protocol ping (player
count + up/down), plus a public status page. It joins `mcnet`, so monitors can
probe the stack by internal service name. caddy reverse-proxies `status.` →
`uptime-kuma:3001`.
First run, open `https://status.${BASE_DOMAIN}`, create the admin account, and
add monitors. Kuma has no config-as-code — add these once via the UI (data
persists in the `kuma_data` volume):
| Monitor | Type | Target |
|---|---|---|
| Minecraft | Minecraft Server | `minecraft` : `25565` |
| Drasl auth | HTTP(s) | `https://auth.${BASE_DOMAIN}` |
| Landing (apex) | HTTP(s) | `https://${BASE_DOMAIN}` |
| Packwiz | HTTP(s) | `https://pack.${BASE_DOMAIN}` |
| Distribution | HTTP(s) | `https://distribution.${BASE_DOMAIN}` |
| Avatar (NMSR) | HTTP(s) | `https://avatar.${BASE_DOMAIN}` |
Internal-name targets (`minecraft`, `drasl:25585`, `nmsr:8080`) isolate "service
down" from "ingress/TLS/DNS down"; public-URL targets test the whole chain.
`status` is in the default `LE_SUBDOMAINS`; the nginx vhost adds websocket
upgrade headers for Kuma's live UI.
## Join (guests)
1. Open `http://ulicraft.lan`, download FjordLauncherUnlocked for your OS.
2. Add an **authlib-injector** account, URL `http://auth.ulicraft.lan/authlib-injector`
(register/login at `http://auth.ulicraft.lan`).
3. **Air-gap only:** trust the local CA — download `http://ulicraft.lan/ca.crt`
and add it to your OS trust store (so the HTTPS asset mirror is trusted).
4. Import the pack: `http://packwiz.ulicraft.lan/pack.toml`.
5. Connect to **`mc.ulicraft.lan`** (no port needed).
1. Open `https://${BASE_DOMAIN}`, download FjordLauncherUnlocked for your OS.
2. Add an **authlib-injector** account, URL
`https://auth.${BASE_DOMAIN}/authlib-injector` (register/login at
`https://auth.${BASE_DOMAIN}`).
3. Import the pack: `https://pack.${BASE_DOMAIN}/pack.toml`.
4. Connect to **`${BASE_DOMAIN}`** (Minecraft, `:25565`).
## Layout
```
docker-compose.yml # core: drasl, minecraft, mc-backup, caddy
.static.yml .mirror.yml # online/airgap layers
.avahi.yml .dnsmasq.yml # optional DNS layers
caddy/Caddyfile + conf.d/*.caddy # ingress vhost snippets (core/static/mirror)
docker-compose.yml # the whole stack (one file)
caddy/Caddyfile + conf.d/*.caddy # ingress vhost snippets
# (core/static/distribution/avatar/status)
drasl/config/config.toml.tmpl # auth config (rendered)
dnsmasq/dnsmasq.conf.tmpl # optional DNS config (rendered)
docker/avahi/ # mDNS responder image
nmsr/config.toml.tmpl # avatar renderer config, Drasl-backed (rendered)
docker/nmsr/ # built-from-source NMSR image
pack/ # packwiz modpack source
landing/ # Astro site → www/
tooling/ # build-stack, render-config, dns-records,
# mirror-mods, mirror-airgap, fetch-launcher
mirror/ # vendored jars + launcher + asset mirror (gitignored)
plan/ # full design docs (0012)
launcher/ # FjordLauncher assets (gitignored)
tooling/ # render-config, render-pack, render-nginx,
# issue-letsencrypt, fetch-launcher, add-custom-mod
nginx/ulicraft-caddy.conf.tmpl # host-nginx TLS vhost template (front of caddy)
plan/ # design docs
```
See `plan/00-overview.md` for the full design.

View File

@@ -1,10 +1,12 @@
# Base Caddy config. Vhosts live in conf.d/*.caddy snippets that are mounted
# in selectively so the stack can run with or without the static site / mirror:
# conf.d/00-core.caddy auth + packwiz (always — core ingress)
# conf.d/10-static.caddy apex landing + launcher downloads (static toggle)
# conf.d/20-mirror.caddy air-gap upstream mirror (tls internal) (mirror toggle)
# auto_https disable_redirects: http:// sites stay plaintext on :80; the mirror
# snippet's hosts still get internal TLS on :443 when that snippet is present.
# Base Caddy config. Vhosts live in conf.d/*.caddy snippets:
# conf.d/00-core.caddy auth + packwiz
# conf.d/10-static.caddy apex landing + launcher downloads
# conf.d/30-distribution.caddy static site from another repo
# conf.d/40-avatar.caddy nmsr skin/avatar renderer
# conf.d/50-status.caddy uptime-kuma status page
# All snippets are plain http:// on :80 — the host's nginx terminates TLS in
# front (nginx/ulicraft-caddy.conf.tmpl). disable_redirects keeps caddy from
# upgrading to its own https.
{
auto_https disable_redirects
}

View File

@@ -1,14 +0,0 @@
# Core ingress — Blessing Skin variant. Replaces 00-core.caddy when the
# docker-compose.blessingskin.yml override is in play (mounted at the same
# target path). auth.* now points at Blessing Skin instead of Drasl.
#
# Blessing Skin serves BOTH its web UI (/) and the Yggdrasil API (/api/yggdrasil)
# on the same vhost, so a single reverse_proxy covers everything.
http://auth.{$BASE_DOMAIN} {
reverse_proxy blessing-skin:80
}
http://pack.{$BASE_DOMAIN} {
root * /srv/pack
file_server browse
}

View File

@@ -1,13 +1,5 @@
# Static toggle — apex landing page + launcher downloads.
# Mounted only when the stack runs with static files (build-stack.sh up static|full).
# Apex landing page (/srv/www) + launcher downloads (/srv/launcher).
http://{$BASE_DOMAIN} {
# Caddy's local CA root, so guests can trust the air-gap HTTPS mirror.
# Mounted from ./caddy/caddy-root-ca.crt (exported by build-stack prep;
# the live pki dir is root-only 0600 and can't be served directly).
handle /ca.crt {
root * /srv/ca-pub
file_server
}
# Launcher downloads are a sibling mount (/srv/launcher), not nested under
# the read-only /srv/www. handle_path strips the /launcher prefix.
handle_path /launcher/* {

View File

@@ -1,9 +0,0 @@
# Mirror toggle — full client air-gap mirror (see plan/11-full-airgap-mirror.md).
# Byte-exact copies of the real upstream hosts over HTTPS with Caddy's local CA.
# dnsmasq spoofs these hostnames -> our host; {host} roots each at its subtree.
# Mounted only when the stack runs with the mirror (build-stack.sh up mirror|full).
meta.prismlauncher.org, piston-meta.mojang.com, piston-data.mojang.com, libraries.minecraft.net, maven.neoforged.net, resources.download.minecraft.net {
tls internal
root * /srv/mirror/{host}
file_server
}

View File

@@ -1,7 +1,6 @@
# Distribution toggle — static site served from a web root in ANOTHER repo.
# The host path is set by DISTRIBUTION_WEB_ROOT in .env and bind-mounted to
# /srv/distribution by docker-compose.caddy.yml. Mounted only when that var is
# set (see the distribution override).
# Distribution — static site served from a web root in ANOTHER repo. The host
# path is set by DISTRIBUTION_WEB_ROOT in .env and bind-mounted to
# /srv/distribution by the caddy service in docker-compose.yml.
http://distribution.{$BASE_DOMAIN} {
root * /srv/distribution
file_server browse

View File

@@ -0,0 +1,4 @@
# Avatar — NMSR skin/avatar renderer (nmsr service), Drasl-backed.
http://avatar.{$BASE_DOMAIN} {
reverse_proxy nmsr:8080
}

View File

@@ -0,0 +1,4 @@
# Status — Uptime Kuma monitoring + public status page (uptime-kuma service).
http://status.{$BASE_DOMAIN} {
reverse_proxy uptime-kuma:3001
}

View File

@@ -1,21 +0,0 @@
# Resolve every *.${BASE_DOMAIN} (and the apex) to the Docker host LAN IP.
# Rendered by tooling/render-config.sh -> dnsmasq/dnsmasq.conf (gitignored).
address=/${BASE_DOMAIN}/${HOST_LAN_IP}
no-resolv
# Minecraft SRV record: guests connect to "mc.${BASE_DOMAIN}" with NO port —
# the SRV lookup points the client at port 25565. Target mc.${BASE_DOMAIN}
# resolves to HOST_LAN_IP via the wildcard above.
# Format: srv-host=_service._proto.name,target,port
srv-host=_minecraft._tcp.mc.${BASE_DOMAIN},mc.${BASE_DOMAIN},25565
# Full client air-gap (see plan/11-full-airgap-mirror.md): spoof the real
# upstream hosts -> our Caddy mirror. Auth hosts (session/textures/api.mojang)
# are NOT spoofed — drasl handles those via authlib-injector. Keep this list in
# sync with the proxy-capture step.
address=/meta.prismlauncher.org/${HOST_LAN_IP}
address=/piston-meta.mojang.com/${HOST_LAN_IP}
address=/piston-data.mojang.com/${HOST_LAN_IP}
address=/libraries.minecraft.net/${HOST_LAN_IP}
address=/maven.neoforged.net/${HOST_LAN_IP}
address=/resources.download.minecraft.net/${HOST_LAN_IP}

View File

@@ -1,28 +0,0 @@
# mDNS toggle — publish *.local service names via the HOST's avahi-daemon for
# zero-config guest resolution. Enabled when ENABLE_MDNS=true in .env and only
# meaningful when BASE_DOMAIN=*.local.
#
# Host prerequisites:
# - avahi-daemon running on the host
# - if the host has many interfaces (e.g. lots of docker bridges), restrict
# avahi to the LAN NIC in /etc/avahi/avahi-daemon.conf:
# allow-interfaces=<lan-iface>
# otherwise avahi sees its own announcements across bridges → "Local name
# collision" and publishing fails.
services:
avahi:
build: ./docker/avahi
image: ulicraft-avahi:local
container_name: avahi
restart: unless-stopped
network_mode: host
# AppArmor's docker-default profile blocks the D-Bus publish to host avahi.
security_opt:
- apparmor=unconfined
environment:
BASE_DOMAIN: ${BASE_DOMAIN}
HOST_LAN_IP: ${HOST_LAN_IP}
DBUS_SYSTEM_BUS_ADDRESS: "unix:path=/run/dbus/system_bus_socket"
volumes:
# Talk to the host's avahi-daemon (host must run avahi-daemon).
- /run/dbus/system_bus_socket:/run/dbus/system_bus_socket

View File

@@ -1,105 +0,0 @@
# ──────────────────────────────────────────────────────────────────
# Blessing Skin auth variant — use INSTEAD of Drasl.
# ──────────────────────────────────────────────────────────────────
# Layer this over the base file AND the caddy ingress (caddy lives in its own
# file now; this override only swaps caddy's core conf, so caddy.yml is required):
#
# docker compose -f docker-compose.yml -f docker-compose.caddy.yml \
# -f docker-compose.blessingskin.yml up -d
#
# What it does vs the base stack:
# - drasl → left running but UNUSED + unreachable (Caddy no longer
# routes to it; it has no host port). Compose merges
# depends_on, so it can't be removed from an override —
# idling it is the clean option. `docker compose ... stop
# drasl` after up if you want it down.
# - caddy → auth.* now reverse-proxies blessing-skin:80
# - mariadb → new; Blessing Skin's datastore (no SQLite support)
# - blessing-skin → new; PHP skin server + yggdrasil-api plugin
# - minecraft → authlib-injector now points at /api/yggdrasil,
# ONLINE_MODE=TRUE (real session validation against BSS)
#
# First-run is a WEB WIZARD — see plan/03b-blessing-skin.md. The yggdrasil-api
# plugin must be installed + enabled from the BSS admin panel before the
# /api/yggdrasil endpoint (and thus Minecraft login) works.
#
# DB creds + APP_KEY come from .env (see .env.example: BS_* vars).
# ──────────────────────────────────────────────────────────────────
services:
caddy:
# Swap the core ingress conf for the Blessing Skin one. Compose MERGES
# volumes by container path, so mounting the BSS conf at the SAME target
# (/etc/caddy/conf.d/00-core.caddy) shadows the base 00-core.caddy; all
# other base mounts (pack, custom, static, ca) are kept automatically.
volumes:
- ./caddy/conf.d/00-core-blessingskin.caddy:/etc/caddy/conf.d/00-core.caddy:ro
depends_on:
- blessing-skin
mariadb:
image: mariadb:11
container_name: mariadb
restart: unless-stopped
environment:
MARIADB_DATABASE: ${BS_DB_NAME}
MARIADB_USER: ${BS_DB_USER}
MARIADB_PASSWORD: ${BS_DB_PASSWORD}
MARIADB_ROOT_PASSWORD: ${BS_DB_ROOT_PASSWORD}
TZ: "Europe/Madrid"
volumes:
- bs_db:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 10
networks:
- mcnet
blessing-skin:
image: azusamikan/blessing-skin-server-docker:latest
container_name: blessing-skin
restart: unless-stopped
# Internal-only — reached via Caddy at auth.${BASE_DOMAIN}.
environment:
APP_URL: "http://auth.${BASE_DOMAIN}"
# Persist APP_KEY across recreates (else sessions/encryption break).
# Generate once: docker run --rm azusamikan/blessing-skin-server-docker:latest php artisan key:generate --show
APP_KEY: ${BS_APP_KEY}
DB_DRIVER: "mysql"
DB_HOST: "mariadb"
DB_PORT: "3306"
DB_DATABASE: ${BS_DB_NAME}
DB_USERNAME: ${BS_DB_USER}
DB_PASSWORD: ${BS_DB_PASSWORD}
TZ: "Europe/Madrid"
volumes:
- bs_storage:/app/storage # uploaded skins/capes + textures
- bs_plugins:/app/plugins # yggdrasil-api plugin lives here
depends_on:
mariadb:
condition: service_healthy
networks:
- mcnet
minecraft:
environment:
# authlib-injector API root for Blessing Skin's yggdrasil-api plugin
# is <site>/api/yggdrasil (NOT /authlib-injector like Drasl).
JVM_OPTS: "-javaagent:/extras/authlib-injector.jar=http://auth.${BASE_DOMAIN}/api/yggdrasil"
# authlib-injector needs real auth: online-mode TRUE so the server
# validates sessions against Blessing Skin. (Drasl variant used FALSE.)
ONLINE_MODE: "TRUE"
# Keep FALSE for safety on 1.21+. The yggdrasil-api plugin DOES sign
# profile keys, so you MAY flip this to TRUE if signed chat is wanted.
ENFORCE_SECURE_PROFILE: "FALSE"
# Merged with the base depends_on (drasl, caddy); just adds blessing-skin so
# Minecraft starts after the skin server is up.
depends_on:
- blessing-skin
volumes:
bs_db:
bs_storage:
bs_plugins:

View File

@@ -1,50 +0,0 @@
# ──────────────────────────────────────────────────────────────────
# Caddy ingress — LAN / air-gap path. NOT started with the base stack.
# ──────────────────────────────────────────────────────────────────
# Caddy was moved out of docker-compose.yml so the base stack can run behind
# the host's nginx (production) without it. build-stack.sh adds this file for
# its online/airgap/core modes; static/mirror overrides mount extra vhosts in.
#
# docker compose -f docker-compose.yml -f docker-compose.caddy.yml up -d
#
# Holds the mcnet aliases (auth./pack.${BASE_DOMAIN}) so containers resolve the
# stack's own names internally — this is why minecraft can reach the pack here
# without extra_hosts (the nginx path uses extra_hosts instead).
services:
caddy:
image: caddy:alpine
container_name: caddy
restart: unless-stopped
# Host-published port. Default 80 for the standalone LAN/air-gap path; set
# CADDY_HTTP_PORT to a high localhost-only port when the machine's nginx
# fronts caddy (nginx terminates TLS, reverse-proxies here by Host header —
# see nginx/ulicraft-caddy.conf.tmpl).
ports:
- "${CADDY_HTTP_BIND:-0.0.0.0}:${CADDY_HTTP_PORT:-80}:80"
environment:
BASE_DOMAIN: ${BASE_DOMAIN}
volumes:
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- ./caddy/conf.d/00-core.caddy:/etc/caddy/conf.d/00-core.caddy:ro
- ./caddy/conf.d/10-static.caddy:/etc/caddy/conf.d/10-static.caddy:ro
- ./www:/srv/www:ro
- ./mirror/launcher:/srv/launcher:ro
- ./caddy/caddy-root-ca.crt:/srv/ca-pub/ca.crt:ro
- ./pack:/srv/pack:ro
# Custom (locally-hosted) mod jars live OUTSIDE ./pack so packwiz refresh
# doesn't index them as direct files. Served at pack.${BASE_DOMAIN}/custom/.
- ./custom:/srv/pack/custom:ro
networks:
mcnet:
# The upstream spoof-host aliases live in docker-compose.mirror.yml —
# adding them here would hijack maven.neoforged.net etc. for ALL mcnet
# containers, breaking the ONLINE NeoForge install during pre-bake.
aliases:
- "auth.${BASE_DOMAIN}"
- "pack.${BASE_DOMAIN}"
# Restore the ordering the base file used to have (base drops it so it can run
# caddy-less behind nginx).
minecraft:
depends_on:
- caddy

View File

@@ -1,15 +0,0 @@
# ──────────────────────────────────────────────────────────────────
# Distribution vhost — distribution.${BASE_DOMAIN}, static files from a web
# root that lives in ANOTHER repo. Layered on top of the caddy ingress.
# ──────────────────────────────────────────────────────────────────
# docker compose -f docker-compose.yml -f docker-compose.caddy.yml \
# -f docker-compose.distribution.yml up -d
#
# DISTRIBUTION_WEB_ROOT (.env) = absolute host path to the built static site in
# the other repo. Bind-mounted read-only; caddy serves it via the
# conf.d/30-distribution.caddy snippet.
services:
caddy:
volumes:
- ./caddy/conf.d/30-distribution.caddy:/etc/caddy/conf.d/30-distribution.caddy:ro
- ${DISTRIBUTION_WEB_ROOT:?DISTRIBUTION_WEB_ROOT unset in .env}:/srv/distribution:ro

View File

@@ -1,20 +0,0 @@
# Optional turnkey DNS — only if you DON'T run your own party DNS server.
# Layer on: `docker compose -f docker-compose.yml -f docker-compose.dnsmasq.yml ... up -d`.
# Serves dnsmasq/dnsmasq.conf (rendered from the template, includes the airgap
# spoof records). If you have your own DNS, skip this and use the records from
# `tooling/dns-records.sh` instead.
services:
dnsmasq:
image: jpillora/dnsmasq
container_name: dnsmasq
restart: unless-stopped
ports:
# Bind DNS to the host LAN IP so it doesn't clash with systemd-resolved on :53.
- "${HOST_LAN_IP}:53:53/udp"
- "${HOST_LAN_IP}:53:53/tcp"
volumes:
- ./dnsmasq/dnsmasq.conf:/etc/dnsmasq.conf:ro
networks:
- mcnet
# webproc UI (:8080) can be exposed behind Caddy as dns.${BASE_DOMAIN};
# guard with HTTP_USER / HTTP_PASS.

View File

@@ -1,25 +0,0 @@
# Mirror toggle — full client air-gap upstream mirror (see plan/11).
# Layer on top of the base: `docker compose -f docker-compose.yml -f docker-compose.mirror.yml up -d`
# (or `tooling/build-stack.sh --up mirror`). Adds the mirror vhost snippet, the
# upstream content mount, and publishes :443 for Caddy's `tls internal` certs.
# Populate the mirror first: tooling/mirror-airgap.sh (+ the proxy-capture step).
services:
caddy:
ports:
- "443:443"
volumes:
- ./caddy/conf.d/20-mirror.caddy:/etc/caddy/conf.d/20-mirror.caddy:ro
- ./mirror/upstream:/srv/mirror:ro
networks:
mcnet:
# Spoof the real upstream hosts -> Caddy (air-gap runtime only). Includes
# the base aliases because compose replaces (not merges) the alias list.
aliases:
- "auth.${BASE_DOMAIN}"
- "packwiz.${BASE_DOMAIN}"
- "meta.prismlauncher.org"
- "piston-meta.mojang.com"
- "piston-data.mojang.com"
- "libraries.minecraft.net"
- "maven.neoforged.net"
- "resources.download.minecraft.net"

View File

@@ -1,30 +0,0 @@
# ──────────────────────────────────────────────────────────────────
# Production ingress = the HOST's nginx + Let's Encrypt. No caddy.
# ──────────────────────────────────────────────────────────────────
# docker compose -f docker-compose.yml -f docker-compose.nginx.yml up -d
#
# nginx (on the host, already installed) terminates TLS with the LE certs from
# tooling/issue-letsencrypt.sh and:
# - serves apex + pack.${BASE_DOMAIN} as static files (./www, ./pack, ./custom)
# - reverse-proxies auth.${BASE_DOMAIN} -> 127.0.0.1:25585 (drasl)
# See nginx/ulicraft.conf.tmpl.
#
# This override:
# - publishes drasl on localhost so the host nginx can reach it
# - points the minecraft container at the host (extra_hosts) over HTTPS, so it
# fetches the pack and validates sessions through the same public names the
# LE certs cover (JVM trusts Let's Encrypt out of the box)
services:
drasl:
ports:
- "127.0.0.1:25585:25585"
minecraft:
# Make the public names resolve to the host running nginx.
extra_hosts:
- "auth.${BASE_DOMAIN}:host-gateway"
- "pack.${BASE_DOMAIN}:host-gateway"
environment:
# Use HTTPS now that nginx terminates TLS with a publicly-trusted cert.
JVM_OPTS: "-javaagent:/extras/authlib-injector.jar=https://auth.${BASE_DOMAIN}/authlib-injector"
PACKWIZ_URL: "https://pack.${BASE_DOMAIN}/pack.toml"

View File

@@ -1,11 +0,0 @@
# Static toggle — apex landing page + launcher downloads.
# Layer on top of the base: `docker compose -f docker-compose.yml -f docker-compose.static.yml up -d`
# (or `tooling/build-stack.sh --up static`). Adds the static vhost snippet plus
# the www + launcher content mounts. Build www first: cd landing && pnpm run build.
services:
caddy:
volumes:
- ./caddy/conf.d/10-static.caddy:/etc/caddy/conf.d/10-static.caddy:ro
- ./www:/srv/www:ro
- ./mirror/launcher:/srv/launcher:ro
- ./caddy/caddy-root-ca.crt:/srv/ca-pub/ca.crt:ro

View File

@@ -1,31 +1,31 @@
# ──────────────────────────────────────────────────────────────────
# Ulicraft LAN-party stack — Minecraft 1.21.1 NeoForge + Drasl auth
# ──────────────────────────────────────────────────────────────────
# Single source of config: BASE_DOMAIN + HOST_LAN_IP in .env.
# DNS is provided by YOUR party DNS server — see tooling/dns-records.sh for the
# records to add (online vs airgap). A turnkey dnsmasq is available as an
# optional layer: docker-compose.dnsmasq.yml.
# - ingress is layered on, NOT in this base file: caddy (docker-compose.caddy.yml,
# LAN/air-gap, holds mcnet aliases) OR the host's nginx (docker-compose.nginx.yml
# + Let's Encrypt, production)
# - drasl handles auth (password login; NO Keycloak/OIDC in this stack)
# - packwiz pack is served by the ingress at pack.${BASE_DOMAIN}
# - minecraft installs mods via PACKWIZ_URL, authlib-injector -> auth.
# Single, unified compose. One file holds the whole stack:
# drasl auth + skins (Yggdrasil), internal only
# minecraft the server (itzg/NEOFORGE), installs mods via packwiz
# mc-backup world backups every 6h via RCON
# caddy internal ingress — routes auth./pack./apex/launcher/avatar.
# /distribution. by Host header (conf.d/*.caddy)
# nmsr skin/avatar renderer at avatar.${BASE_DOMAIN}
# uptime-kuma status monitoring at status.${BASE_DOMAIN}
#
# Bring up: docker compose up -d (render configs first: tooling/render-config.sh)
#
# DNS is handled OUTSIDE this repo — point every service name at the host.
# Production TLS terminates at the HOST's nginx in front of caddy
# (nginx/ulicraft-caddy.conf.tmpl + Let's Encrypt); caddy is published on a
# localhost-only port (CADDY_HTTP_BIND/CADDY_HTTP_PORT in .env). Containers reach
# the stack's own names internally via caddy's mcnet aliases (auth./pack.).
# ──────────────────────────────────────────────────────────────────
services:
# NOTE: the HTTP(S) ingress is NOT in this base file.
# - LAN / air-gap: caddy (docker-compose.caddy.yml) — added by build-stack.sh
# - production: the HOST's nginx + Let's Encrypt (docker-compose.nginx.yml
# publishes drasl to localhost; nginx/ulicraft.conf.tmpl)
# Base alone (drasl + minecraft + mc-backup) is not a complete deployment —
# always layer one ingress file on top.
drasl:
image: unmojang/drasl:latest
container_name: drasl
restart: unless-stopped
# Internal-only: no published host port. Reached via Caddy at
# auth.${BASE_DOMAIN} (Caddy holds the network alias on mcnet).
# Internal-only: no published host port. Reached via caddy at
# auth.${BASE_DOMAIN} (caddy holds the network alias on mcnet).
volumes:
- ./drasl/config:/etc/drasl:ro
- drasl_state:/var/lib/drasl
@@ -58,7 +58,7 @@ services:
# ── Auth: offline mode + authlib-injector pointing at Drasl ─
ONLINE_MODE: "FALSE"
# mount authlib-injector.jar at /extras/authlib-injector.jar
# Resolves over mcnet to caddy (alias auth.${BASE_DOMAIN}) -> drasl.
JVM_OPTS: "-javaagent:/extras/authlib-injector.jar=http://auth.${BASE_DOMAIN}/authlib-injector"
# ── Server config ───────────────────────────────────────────
@@ -71,9 +71,7 @@ services:
ENFORCE_SECURE_PROFILE: "FALSE" # required for authlib-injector on 1.21+
ALLOW_FLIGHT: "TRUE" # many tech mods need this
# ── Mod source: packwiz pack served by the ingress ──────────
# http + caddy alias by default; the nginx override switches this to
# https + host-gateway (extra_hosts) for the production path.
# ── Mod source: packwiz pack served by caddy (alias pack.) ──
PACKWIZ_URL: "http://pack.${BASE_DOMAIN}/pack.toml"
# ── RCON for remote admin ───────────────────────────────────
@@ -86,11 +84,11 @@ services:
- mc_data:/data
- ./runtime:/extras:ro # authlib-injector.jar lives here
depends_on:
- drasl # ingress (caddy/nginx) is layered separately
- drasl
- caddy # pack./auth. must resolve at boot
networks:
- mcnet
# Optional: backups
mc-backup:
image: itzg/mc-backup
container_name: mc-backup
@@ -109,9 +107,69 @@ services:
networks:
- mcnet
# Internal ingress. Routes every vhost by Host header (conf.d/*.caddy):
# auth. -> drasl, pack. -> packwiz files, apex -> landing + /launcher,
# avatar. -> nmsr, status. -> uptime-kuma, distribution. -> static site.
# Published on a localhost-only port; the host's nginx terminates TLS in
# front of it (nginx/ulicraft-caddy.conf.tmpl).
caddy:
image: caddy:alpine
container_name: caddy
restart: unless-stopped
ports:
- "${CADDY_HTTP_BIND:-127.0.0.1}:${CADDY_HTTP_PORT:-8880}:80"
environment:
BASE_DOMAIN: ${BASE_DOMAIN}
volumes:
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- ./caddy/conf.d/00-core.caddy:/etc/caddy/conf.d/00-core.caddy:ro
- ./caddy/conf.d/10-static.caddy:/etc/caddy/conf.d/10-static.caddy:ro
- ./caddy/conf.d/30-distribution.caddy:/etc/caddy/conf.d/30-distribution.caddy:ro
- ./caddy/conf.d/40-avatar.caddy:/etc/caddy/conf.d/40-avatar.caddy:ro
- ./caddy/conf.d/50-status.caddy:/etc/caddy/conf.d/50-status.caddy:ro
- ./www:/srv/www:ro
- ./launcher:/srv/launcher:ro
- ./pack:/srv/pack:ro
# Custom (locally-hosted) mod jars live OUTSIDE ./pack so packwiz refresh
# doesn't index them as direct files. Served at pack.${BASE_DOMAIN}/custom/.
- ./custom:/srv/pack/custom:ro
# Static site from ANOTHER repo (absolute host path in .env).
- ${DISTRIBUTION_WEB_ROOT:?DISTRIBUTION_WEB_ROOT unset in .env}:/srv/distribution:ro
networks:
mcnet:
# Containers resolve the stack's own public names to caddy internally.
aliases:
- "auth.${BASE_DOMAIN}"
- "pack.${BASE_DOMAIN}"
# Avatar/skin renderer — built from source (docker/nmsr/Dockerfile), pulls
# player profiles from Drasl over mcnet. caddy proxies avatar. -> nmsr:8080.
nmsr:
build:
context: ./docker/nmsr
image: ulicraft/nmsr:local
container_name: nmsr
restart: unless-stopped
volumes:
- ./nmsr/config.toml:/nmsr/config.toml:ro
networks:
- mcnet
# Status monitoring + public status page. caddy proxies status. -> :3001.
# Joins mcnet so monitors probe the stack by internal service name.
uptime-kuma:
image: louislam/uptime-kuma:1
container_name: uptime-kuma
restart: unless-stopped
volumes:
- kuma_data:/app/data
networks:
- mcnet
volumes:
drasl_state:
mc_data:
kuma_data:
networks:
mcnet:

View File

@@ -1,9 +0,0 @@
# Publishes the stack's service names on .local via the HOST's avahi-daemon
# (over its D-Bus socket — no second daemon, avoids the :5353 collision).
# Requires the host to run avahi-daemon and the socket to be mounted (see
# docker-compose.avahi.yml). Used when ENABLE_MDNS=true and BASE_DOMAIN=*.local.
FROM alpine:3.20
RUN apk add --no-cache avahi-tools
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]

View File

@@ -1,32 +0,0 @@
#!/bin/sh
# Publish A records for the stack's service names on .local via the HOST's
# avahi-daemon (reached over its mounted D-Bus socket). One avahi-publish per
# name; if any drops the container exits so Docker restarts it.
set -eu
: "${BASE_DOMAIN:?BASE_DOMAIN unset}"
: "${HOST_LAN_IP:?HOST_LAN_IP unset}"
case "$BASE_DOMAIN" in
*.local) ;;
*) echo "WARN: mDNS resolves only .local — BASE_DOMAIN=$BASE_DOMAIN will NOT be discoverable via avahi. Set BASE_DOMAIN=*.local to use mDNS." >&2 ;;
esac
bus="${DBUS_SYSTEM_BUS_ADDRESS:-unix:path=/run/dbus/system_bus_socket}"
sock=${bus#unix:path=}
if [ ! -S "$sock" ]; then
echo "ERROR: host D-Bus socket '$sock' not found. Mount the host's" >&2
echo " /run/dbus/system_bus_socket and ensure avahi-daemon runs on the host." >&2
exit 1
fi
export DBUS_SYSTEM_BUS_ADDRESS="$bus"
pids=""
for n in "$BASE_DOMAIN" "auth.$BASE_DOMAIN" "packwiz.$BASE_DOMAIN" "mc.$BASE_DOMAIN"; do
echo "mDNS publish (host avahi): $n -> $HOST_LAN_IP"
avahi-publish -a "$n" "$HOST_LAN_IP" &
pids="$pids $!"
done
# shellcheck disable=SC2086
wait -n $pids 2>/dev/null || wait

33
docker/nmsr/Dockerfile Normal file
View File

@@ -0,0 +1,33 @@
# NMSR-as-a-Service (https://github.com/NickAcPT/nmsr-rs) — built from source.
# Upstream ships no published image, so we vendor a pinned build. Adapted from
# the upstream Dockerfile but: pinned to a commit (reproducible), and the config
# is bind-mounted at runtime (not COPYed) so it can change without a rebuild.
#
# Pin — bump deliberately:
ARG NMSR_REF=948ba4bc027b
FROM rust:slim-bookworm AS builder
ARG NMSR_REF
WORKDIR /tmp
RUN apt-get update -y \
&& apt-get --no-install-recommends install -y git libssl-dev pkg-config ca-certificates
RUN git clone https://github.com/NickAcPT/nmsr-rs/
WORKDIR /tmp/nmsr-rs
RUN git checkout "${NMSR_REF}"
# target-cpu=native: this image is built on the host that runs it (self-host).
RUN RUSTFLAGS="-Ctarget-cpu=native" \
cargo build --release --bin nmsr-aas --features ears --package nmsr-aas
FROM rust:slim-bookworm
# mesa-vulkan-drivers provides lavapipe (software Vulkan) for headless rendering.
RUN apt-get update -y \
&& apt-get --no-install-recommends install -y mesa-vulkan-drivers ca-certificates
WORKDIR /nmsr
COPY --from=builder /tmp/nmsr-rs/target/release/nmsr-aas /nmsr/nmsr-aas
ENV NMSR_USE_SMAA=1 \
NMSR_SAMPLE_COUNT=1 \
WGPU_BACKEND=vulkan \
RUST_BACKTRACE=1
EXPOSE 8080
# config.toml is bind-mounted by docker-compose.nmsr.yml.
CMD ["/nmsr/nmsr-aas", "-c", "/nmsr/config.toml"]

View File

@@ -1,19 +1,17 @@
# ──────────────────────────────────────────────────────────────────
# Ulicraft host nginx — TLS terminator in FRONT of caddy.
# ──────────────────────────────────────────────────────────────────
# Alternative to ulicraft.conf.tmpl. Instead of nginx serving the static
# files + proxying only drasl, here caddy (the docker container) is the single
# ingress for ALL vhosts and nginx just terminates TLS and reverse-proxies
# everything to caddy by Host header.
# caddy (in docker-compose.yml) is the single internal ingress for ALL vhosts;
# this host nginx terminates TLS and reverse-proxies everything to caddy by Host
# header. Prefer tooling/render-nginx.sh over hand-rendering.
#
# Caddy must be published on a localhost port — set in .env:
# CADDY_HTTP_PORT=8880
# CADDY_HTTP_BIND=127.0.0.1
# and the stack brought up with the caddy override (NOT the nginx override):
# docker compose -f docker-compose.yml -f docker-compose.caddy.yml \
# -f docker-compose.static.yml up -d
# and the stack brought up:
# docker compose up -d
#
# Render with BOTH vars then install:
# Render with the vars then install:
# CADDY_HTTP_PORT=8880 BASE_DOMAIN=ulicraft.net APP_DIR=/home/ubuntu/mc/ulicraft-server-v1 \
# envsubst '$CADDY_HTTP_PORT $BASE_DOMAIN $APP_DIR' \
# < nginx/ulicraft-caddy.conf.tmpl | sudo tee /etc/nginx/sites-available/ulicraft.conf
@@ -32,7 +30,7 @@ upstream ulicraft_caddy {
server {
listen 80;
listen [::]:80;
server_name ${BASE_DOMAIN} auth.${BASE_DOMAIN} pack.${BASE_DOMAIN} distribution.${BASE_DOMAIN};
server_name ${BASE_DOMAIN} www.${BASE_DOMAIN} auth.${BASE_DOMAIN} pack.${BASE_DOMAIN} distribution.${BASE_DOMAIN} avatar.${BASE_DOMAIN} status.${BASE_DOMAIN};
return 301 https://$host$request_uri;
}
@@ -40,13 +38,13 @@ server {
# caddy routes by the forwarded Host header (apex -> www, auth -> drasl,
# pack -> packwiz files).
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name ${BASE_DOMAIN};
ssl_certificate ${APP_DIR}/certs/${BASE_DOMAIN}/cert.pem;
ssl_certificate_key ${APP_DIR}/certs/${BASE_DOMAIN}/key.pem;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
client_max_body_size 4m; # skin uploads (auth) reuse the same proxy block
@@ -59,14 +57,27 @@ server {
}
}
# www -> apex canonical redirect (needs its own cert; covered by LE_SUBDOMAINS).
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name www.${BASE_DOMAIN};
ssl_certificate ${APP_DIR}/certs/www.${BASE_DOMAIN}/cert.pem;
ssl_certificate_key ${APP_DIR}/certs/www.${BASE_DOMAIN}/key.pem;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
return 301 https://${BASE_DOMAIN}$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name auth.${BASE_DOMAIN};
ssl_certificate ${APP_DIR}/certs/auth.${BASE_DOMAIN}/cert.pem;
ssl_certificate_key ${APP_DIR}/certs/auth.${BASE_DOMAIN}/key.pem;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
client_max_body_size 4m; # skin uploads
@@ -80,13 +91,13 @@ server {
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name pack.${BASE_DOMAIN};
ssl_certificate ${APP_DIR}/certs/pack.${BASE_DOMAIN}/cert.pem;
ssl_certificate_key ${APP_DIR}/certs/pack.${BASE_DOMAIN}/key.pem;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
location / {
proxy_pass http://ulicraft_caddy;
@@ -98,13 +109,13 @@ server {
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name distribution.${BASE_DOMAIN};
ssl_certificate ${APP_DIR}/certs/distribution.${BASE_DOMAIN}/cert.pem;
ssl_certificate_key ${APP_DIR}/certs/distribution.${BASE_DOMAIN}/key.pem;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
location / {
proxy_pass http://ulicraft_caddy;
@@ -114,3 +125,44 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name avatar.${BASE_DOMAIN};
ssl_certificate ${APP_DIR}/certs/avatar.${BASE_DOMAIN}/cert.pem;
ssl_certificate_key ${APP_DIR}/certs/avatar.${BASE_DOMAIN}/key.pem;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
location / {
proxy_pass http://ulicraft_caddy;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name status.${BASE_DOMAIN};
ssl_certificate ${APP_DIR}/certs/status.${BASE_DOMAIN}/cert.pem;
ssl_certificate_key ${APP_DIR}/certs/status.${BASE_DOMAIN}/key.pem;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
location / {
proxy_pass http://ulicraft_caddy;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Uptime Kuma uses websockets (socket.io) for live status updates.
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}

View File

@@ -1,93 +0,0 @@
# ──────────────────────────────────────────────────────────────────
# Ulicraft host nginx — TLS ingress with Let's Encrypt certs.
# ──────────────────────────────────────────────────────────────────
# Template. Render with BOTH vars then install:
#
# APP_DIR=/home/ubuntu/mc/ulicraft-server-v1 BASE_DOMAIN=ulicraft.net \
# envsubst '$APP_DIR $BASE_DOMAIN' \
# < nginx/ulicraft.conf.tmpl | sudo tee /etc/nginx/sites-available/ulicraft.conf
# sudo ln -sf /etc/nginx/sites-available/ulicraft.conf /etc/nginx/sites-enabled/
# sudo nginx -t && sudo systemctl reload nginx
#
# Prereqs:
# - certs issued: tooling/issue-letsencrypt.sh -> $APP_DIR/certs/<name>/{cert,key}.pem
# - the docker stack up with the nginx override (publishes drasl on 127.0.0.1:25585):
# docker compose -f docker-compose.yml -f docker-compose.nginx.yml up -d
# - nginx's user (www-data) can READ $APP_DIR/{www,pack,custom,certs}
# ──────────────────────────────────────────────────────────────────
# HTTP -> HTTPS for every name.
server {
listen 80;
listen [::]:80;
server_name ${BASE_DOMAIN} auth.${BASE_DOMAIN} pack.${BASE_DOMAIN};
return 301 https://$host$request_uri;
}
# Apex — static landing page (./www) + launcher downloads (./mirror/launcher).
# /ca.crt from the caddy path is intentionally omitted: LE certs are publicly
# trusted, so guests need no CA import.
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name ${BASE_DOMAIN};
ssl_certificate ${APP_DIR}/certs/${BASE_DOMAIN}/cert.pem;
ssl_certificate_key ${APP_DIR}/certs/${BASE_DOMAIN}/key.pem;
# Launcher downloads (populated by tooling/fetch-launcher.sh -> ./mirror/launcher).
location /launcher/ {
alias ${APP_DIR}/mirror/launcher/;
autoindex on;
}
location / {
root ${APP_DIR}/www;
index index.html;
try_files $uri $uri/ =404;
}
}
# Pack — packwiz metadata (./pack) + custom jars (./custom at /custom/).
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name pack.${BASE_DOMAIN};
ssl_certificate ${APP_DIR}/certs/pack.${BASE_DOMAIN}/cert.pem;
ssl_certificate_key ${APP_DIR}/certs/pack.${BASE_DOMAIN}/key.pem;
# Locally-hosted custom mod jars live outside ./pack; expose them at /custom/.
location /custom/ {
alias ${APP_DIR}/custom/;
autoindex on;
}
location / {
root ${APP_DIR}/pack;
autoindex on;
}
}
# Auth — reverse proxy to drasl (published on localhost by the nginx override).
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name auth.${BASE_DOMAIN};
ssl_certificate ${APP_DIR}/certs/auth.${BASE_DOMAIN}/cert.pem;
ssl_certificate_key ${APP_DIR}/certs/auth.${BASE_DOMAIN}/key.pem;
client_max_body_size 4m; # skin uploads
location / {
proxy_pass http://127.0.0.1:25585;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

37
nmsr/config.toml.tmpl Normal file
View File

@@ -0,0 +1,37 @@
# NMSR-aas config — renders to nmsr/config.toml (gitignored) via
# tooling/render-config.sh. Only ${BASE_DOMAIN} is substituted.
#
# Skins come from Drasl, NOT Mojang. Drasl exposes Mojang-compatible routes at
# the bare BaseURL:
# session_server -> {url}/session/minecraft/profile/{uuid}
# mojang_api_server -> {url}/users/profiles/minecraft/{name}
# and embeds absolute skin URLs (http://auth.${BASE_DOMAIN}/web/texture/skin/…)
# in the profile, which NMSR fetches directly (textures_server is only a
# fallback). NMSR resolves auth.${BASE_DOMAIN} internally over the docker
# network (caddy holds the mcnet alias → drasl), so no public round-trip.
[server]
address = "0.0.0.0"
port = 8080
[caching]
cleanup_interval = "1h"
resolve_cache_duration = "15m"
texture_cache_duration = "48h"
[caching.cache_biases]
[mojank]
# Point every Mojang endpoint at Drasl (internal, plain http via the caddy alias).
session_server = "http://auth.${BASE_DOMAIN}"
textures_server = "http://auth.${BASE_DOMAIN}"
mojang_api_server = "http://auth.${BASE_DOMAIN}"
session_server_rate_limit = 10
# This is an offline-mode (authlib-injector) server — Drasl issues v3 offline
# UUIDs, so they must be accepted.
allow_offline_mode_uuids = true
use_dashless_uuids = false
[rendering]
sample_count = 1
use_smaa = true

View File

@@ -1,3 +1,3 @@
# Ulicraft modpack (packwiz)
This directory is the **single source of truth** for the modpack (MC 1.21.1, NeoForge 21.1.209). Curate mods from here with `packwiz modrinth add <slug>` (prefer Modrinth over CurseForge); each mod produces a `mods/<slug>.pw.toml` metadata file referencing the upstream CDN. **Client-only mods must be tagged `side = "client"`** in their `.pw.toml` so the itzg server does not pull and crash on them. After adding/removing mods run `packwiz refresh` to recompute `index.toml`; for offline LAN play run `tooling/mirror-mods.sh` to vendor the jars and rewrite the download URLs to the local Caddy mirror. Curation is an ongoing manual task — no mods are added yet.
This directory is the **single source of truth** for the modpack (MC 1.21.1, NeoForge 21.1.209). Curate mods from here with `packwiz modrinth add <slug>` (prefer Modrinth over CurseForge); each mod produces a `mods/<slug>.pw.toml` metadata file referencing the upstream CDN. **Client-only mods must be tagged `side = "client"`** in their `.pw.toml` so the itzg server does not pull and crash on them. After adding/removing mods run `packwiz refresh` to recompute `index.toml`. Clients and the itzg server fetch jars from the upstream CDN referenced in each `.pw.toml`. Curation is an ongoing manual task — no mods are added yet.

View File

@@ -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.localcaddy → /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`.

View File

@@ -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

View File

@@ -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)

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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/`

View File

@@ -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
```

View File

@@ -1,114 +0,0 @@
# 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

@@ -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/`
```

View File

@@ -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 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.
- 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).

View File

@@ -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).

View File

@@ -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`).

View File

@@ -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.

View File

@@ -1,185 +0,0 @@
#!/usr/bin/env bash
# build-stack.sh — orchestrate the Ulicraft stack: offline prep then bring-up.
#
# Phases:
# preflight always — verify .env + required tools
# --prep ONLINE — render configs, build landing, mirror mods + air-gap,
# fetch launcher, pre-bake the server volume
# --up OFFLINE — docker compose up -d the full stack
# (no flag) preflight + print what each phase would do, then exit
#
# Cautious by design: halts on the first missing var, missing tool, or failed
# step. Each sub-script is itself responsible for its own integrity checks.
set -euo pipefail
cd "$(dirname "$0")/.." # repo root
COMPOSE=(docker compose)
# ---- helpers ---------------------------------------------------------------
log() { printf '\n\033[1;32m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m!!\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1;31mxx\033[0m %s\n' "$*" >&2; exit 1; }
need_tool() { command -v "$1" >/dev/null 2>&1 || die "missing required tool: $1"; }
have_script() { [ -x "tooling/$1" ] || die "missing/not-executable: tooling/$1"; }
# ---- preflight -------------------------------------------------------------
preflight() {
log "preflight"
[ -f .env ] || die ".env not found (copy .env.example and fill it)"
# shellcheck disable=SC1091
set -a; . ./.env; set +a
: "${BASE_DOMAIN:?BASE_DOMAIN unset in .env}"
: "${HOST_LAN_IP:?HOST_LAN_IP unset in .env}"
: "${RCON_PASSWORD:?RCON_PASSWORD unset in .env}"
need_tool docker
${COMPOSE[@]} version >/dev/null 2>&1 || die "docker compose plugin not available"
need_tool envsubst
need_tool jq
need_tool curl
need_tool sha1sum
command -v pnpm >/dev/null 2>&1 || warn "pnpm not found — landing build will be skipped if absent"
echo " BASE_DOMAIN=${BASE_DOMAIN} HOST_LAN_IP=${HOST_LAN_IP}"
echo " preflight ok"
}
# ---- online prep -----------------------------------------------------------
prep() {
log "PHASE prep (requires internet)"
log "render configs"
have_script render-config.sh
tooling/render-config.sh
log "build landing page → www/"
if command -v pnpm >/dev/null 2>&1; then
( cd landing && pnpm install --frozen-lockfile && BASE_DOMAIN="${BASE_DOMAIN}" pnpm run build )
else
warn "pnpm absent — skipping landing build"
fi
log "mirror mod jars (offline pack)"
have_script mirror-mods.sh
tooling/mirror-mods.sh
log "mirror full air-gap upstream (meta/libraries/assets/client.jar)"
have_script mirror-airgap.sh
tooling/mirror-airgap.sh
log "fetch launcher releases"
have_script fetch-launcher.sh
tooling/fetch-launcher.sh
log "fetch authlib-injector.jar (server JVM agent → drasl)"
if [ -f runtime/authlib-injector.jar ]; then
echo " runtime/authlib-injector.jar present — skipping"
else
ai_url="$(curl -fsSL https://api.github.com/repos/yushijinhun/authlib-injector/releases/latest \
| jq -r '.assets[] | select(.name|test("authlib-injector-[0-9].*\\.jar$")) | .browser_download_url' | head -1)"
[ -n "$ai_url" ] && [ "$ai_url" != "null" ] || die "could not resolve authlib-injector release asset"
curl -fSL --retry 3 -o runtime/authlib-injector.jar "$ai_url"
echo " downloaded $(basename "$ai_url")"
fi
if [ "${ENABLE_MDNS:-false}" = true ]; then
log "build avahi mDNS image (cache it for offline use)"
"${COMPOSE[@]}" -f docker-compose.yml -f docker-compose.avahi.yml build avahi
fi
log "pre-bake server volume (TYPE=NEOFORGE install, one-shot, online)"
# Bring minecraft up once so itzg installs NeoForge + mods into the volume,
# then stop it. The party then runs offline against the baked volume.
# caddy.yml so the pack (pack.${BASE_DOMAIN}) resolves + is served during bake.
"${COMPOSE[@]}" -f docker-compose.yml -f docker-compose.caddy.yml up -d minecraft
warn "watch logs until 'Done' then re-run with --up; or automate a readiness probe"
}
# ---- bring-up --------------------------------------------------------------
# Modes (DNS is YOUR party server — see tooling/dns-records.sh for the records):
# online base + landing/launcher; internet present, no mirror
# airgap online + full upstream mirror (tls internal :443) for no-internet
# core base only (no landing, no mirror) — debugging
up() {
local mode="${1:-airgap}"
# build-stack is the caddy (LAN/air-gap) deployer; caddy lives in its own file
# now (the base stack can also run behind the host's nginx — see
# docker-compose.nginx.yml). Always include caddy here.
local -a files=(-f docker-compose.yml -f docker-compose.caddy.yml)
case "$mode" in
core) ;;
online) files+=(-f docker-compose.static.yml) ;;
airgap) files+=(-f docker-compose.static.yml -f docker-compose.mirror.yml) ;;
*) die "unknown up mode: '$mode' (use online|airgap|core)" ;;
esac
# Optional mDNS (.local) responder — see ENABLE_MDNS in .env.
if [ "${ENABLE_MDNS:-false}" = true ]; then
files+=(-f docker-compose.avahi.yml)
case "$BASE_DOMAIN" in
*.local) ;;
*) warn "ENABLE_MDNS=true but BASE_DOMAIN=$BASE_DOMAIN is not *.local — mDNS won't resolve it" ;;
esac
pgrep -x avahi-daemon >/dev/null 2>&1 \
|| warn "host avahi-daemon not detected — the avahi container publishes through it; start it (e.g. 'systemctl start avahi-daemon')"
[ -S /run/dbus/system_bus_socket ] \
|| warn "host D-Bus socket /run/dbus/system_bus_socket missing — avahi publish will fail"
local ifc; ifc=$(ip -o link show 2>/dev/null | wc -l)
[ "${ifc:-0}" -gt 6 ] \
&& warn "host has ${ifc} network interfaces — set 'allow-interfaces=<lan-nic>' in /etc/avahi/avahi-daemon.conf, else avahi self-collides (Local name collision)"
fi
log "PHASE up (mode=$mode${ENABLE_MDNS:+, mdns=$ENABLE_MDNS})"
have_script render-config.sh
tooling/render-config.sh # cheap; ensures configs match current .env
# Static mode mounts ./caddy/caddy-root-ca.crt as a file at /srv/ca-pub/ca.crt.
# Pre-create it (empty) so Docker doesn't make a directory at that path; the
# real cert is exported below once Caddy's local CA exists.
case "$mode" in
online|airgap) [ -e caddy/caddy-root-ca.crt ] || : > caddy/caddy-root-ca.crt ;;
esac
"${COMPOSE[@]}" "${files[@]}" up -d
# Mirror/full provision Caddy's local CA — export it so guests can trust the
# HTTPS asset mirror (served at http://${BASE_DOMAIN}/ca.crt in static/full).
case "$mode" in
airgap)
local i
for i in 1 2 3 4 5; do
docker exec caddy cat /data/caddy/pki/authorities/local/root.crt \
> caddy/caddy-root-ca.crt 2>/dev/null && break
sleep 1
done
[ -s caddy/caddy-root-ca.crt ] \
&& echo " exported Caddy CA → caddy/caddy-root-ca.crt (served at /ca.crt)" \
|| warn "Caddy CA not ready; export later: docker exec caddy cat /data/caddy/pki/authorities/local/root.crt > caddy/caddy-root-ca.crt"
;;
esac
log "stack up (mode=$mode). apex: http://${BASE_DOMAIN} auth: http://auth.${BASE_DOMAIN}"
echo
log "Add these to your party DNS (mode=$mode):"
[ -x tooling/dns-records.sh ] && tooling/dns-records.sh "$mode" | sed -n '/## Plain/,/^$/p'
}
# ---- dispatch --------------------------------------------------------------
preflight
case "${1:-}" in
--prep) prep ;;
--up) up "${2:-airgap}" ;;
"") cat <<EOF
Nothing run. Choose a phase:
$0 --prep # ONLINE, once: render, build landing, mirror, fetch, pre-bake
$0 --up [mode] # bring the stack up; mode = online|airgap|core (default airgap)
# online = landing + launcher; internet present, no mirror
# airgap = online + full upstream mirror (:443), no internet
# core = base only (debugging)
DNS: configure your party DNS with the records from tooling/dns-records.sh [mode].
mDNS (.local) alternative: set ENABLE_MDNS=true + BASE_DOMAIN=*.local in .env.
EOF
;;
*) die "unknown arg: $1 (use --prep or --up [online|airgap|core])" ;;
esac

View File

@@ -1,84 +0,0 @@
#!/usr/bin/env bash
# dns-records.sh — print the DNS records to configure on YOUR party DNS server.
#
# Usage: tooling/dns-records.sh [online|airgap] (default: airgap)
# online service names only — your DNS forwards everything else to the internet
# airgap service names + the spoofed upstream hosts (Mojang/launcher/NeoForge),
# so guest laptops fetch game files from the LAN mirror with no internet
#
# All names point to HOST_LAN_IP. Reads BASE_DOMAIN / HOST_LAN_IP from .env.
# Prints several formats; use whichever your DNS UI wants.
set -euo pipefail
cd "$(dirname "$0")/.."
[ -f .env ] && { set -a; . ./.env; set +a; }
: "${BASE_DOMAIN:?BASE_DOMAIN unset (set in .env)}"
: "${HOST_LAN_IP:?HOST_LAN_IP unset (set in .env)}"
mode="${1:-airgap}"
# Stack service names (always).
services=(
"${BASE_DOMAIN}"
"auth.${BASE_DOMAIN}"
"packwiz.${BASE_DOMAIN}"
"mc.${BASE_DOMAIN}"
)
# Upstream hosts spoofed onto the LAN mirror (AIRGAP only). Keep in sync with
# caddy/conf.d/20-mirror.caddy and docker-compose.mirror.yml.
spoof=(
"meta.prismlauncher.org"
"piston-meta.mojang.com"
"piston-data.mojang.com"
"libraries.minecraft.net"
"maven.neoforged.net"
"resources.download.minecraft.net"
)
names=("${services[@]}")
case "$mode" in
online) ;;
airgap) names+=("${spoof[@]}") ;;
*) echo "usage: $0 [online|airgap] [hosts]" >&2; exit 1 ;;
esac
# Marker so /etc/hosts edits can be applied/removed cleanly.
MARK="ulicraft ${mode}"
# `hosts` format: print ONLY the /etc/hosts block (marker-wrapped) so it can be
# piped straight into /etc/hosts to simulate the party DNS. See README.
if [ "${2:-}" = hosts ]; then
echo "# >>> ${MARK} >>>"
for n in "${names[@]}"; do printf '%s %s\n' "$HOST_LAN_IP" "$n"; done
echo "# <<< ${MARK} <<<"
exit 0
fi
echo "# DNS records for mode=${mode} -> ${HOST_LAN_IP}"
echo "# Configure these on your party DNS server (all A records to the host)."
echo
echo "## Plain (name -> ip)"
for n in "${names[@]}"; do printf '%-36s %s\n' "$n" "$HOST_LAN_IP"; done
echo
echo "## hosts file (/etc/hosts style)"
for n in "${names[@]}"; do printf '%s %s\n' "$HOST_LAN_IP" "$n"; done
echo
echo "## BIND zone (A records)"
for n in "${names[@]}"; do printf '%-36s IN A %s\n' "${n}." "$HOST_LAN_IP"; done
echo
echo "## dnsmasq (address=)"
echo "address=/${BASE_DOMAIN}/${HOST_LAN_IP} # wildcard covers all *.${BASE_DOMAIN}"
if [ "$mode" = airgap ]; then
for n in "${spoof[@]}"; do printf 'address=/%s/%s\n' "$n" "$HOST_LAN_IP"; done
fi
echo
echo "## Minecraft (no port needed)"
echo "# Clients connect to mc.${BASE_DOMAIN} (defaults to :25565)."
echo "# Optional SRV if your DNS supports it:"
echo "_minecraft._tcp.mc.${BASE_DOMAIN}. IN SRV 0 0 25565 mc.${BASE_DOMAIN}."

View File

@@ -3,7 +3,7 @@
# offline LAN hosting, then create stable-name symlinks for the landing page.
#
# Guests use FjordLauncherUnlocked (Prism/PolyMC fork with authlib-injector
# built in). We mirror every release asset under mirror/launcher/<tag>/ and
# built in). We mirror every release asset under launcher/<tag>/ and
# maintain a `latest` symlink → <tag>. Caddy serves this at /launcher/latest/…
#
# The landing page (landing/src/data/site.ts) links to FIXED filenames so the
@@ -16,11 +16,11 @@
# Cautious by design: halts on any error, and if a stable-name category matches
# 0 or >1 candidate it prints the candidates and exits non-zero rather than
# guessing. Idempotent — re-runs overwrite symlinks and re-download safely.
# mirror/ is gitignored — only this script is tracked.
# launcher/ is gitignored — only this script is tracked.
set -euo pipefail
REPO="hero-persson/FjordLauncherUnlocked"
DEST_ROOT="$(dirname "$0")/../mirror/launcher"
DEST_ROOT="$(dirname "$0")/../launcher"
API="https://api.github.com/repos/${REPO}/releases/latest"
command -v curl >/dev/null || { echo "curl required" >&2; exit 1; }

View File

@@ -17,7 +17,7 @@
# OVH_CK OVH consumer key (obtained on first run — see below)
# OVH_END_POINT OVH API endpoint (e.g. ovh-eu)
# optional:
# LE_SUBDOMAINS space-separated (default: "auth pack distribution")
# LE_SUBDOMAINS space-separated (default: "auth pack distribution www avatar status")
# LE_STAGING=1 use the LE staging CA (untrusted, for dry runs)
#
# First-time OVH consumer key: set OVH_AK/OVH_AS/OVH_END_POINT, leave OVH_CK
@@ -53,7 +53,7 @@ ACME="${ACME_SH:-$HOME/.acme.sh/acme.sh}"
then re-run (or set ACME_SH=/path/to/acme.sh)."
CERT_DIR="${CERT_DIR:-certs}"
read -r -a SUBDOMAINS <<< "${LE_SUBDOMAINS:-auth pack distribution}"
read -r -a SUBDOMAINS <<< "${LE_SUBDOMAINS:-auth pack distribution www avatar status}"
if [ "${LE_STAGING:-0}" = "1" ]; then
SERVER="letsencrypt_test"; warn "STAGING mode — certs will NOT be trusted by browsers"

View File

@@ -1,48 +0,0 @@
#!/usr/bin/env bash
# mdns-host-setup.sh — pin the HOST avahi-daemon to the LAN interface so it
# stops self-colliding across docker bridges ("Local name collision"), which
# is what blocks the mDNS (.local) path on a host with many interfaces.
#
# Detects the LAN NIC from HOST_LAN_IP (.env), sets allow-interfaces=<nic> in
# /etc/avahi/avahi-daemon.conf (idempotent, backs up first), restarts avahi.
# Re-execs itself with sudo if not root. Run: tooling/mdns-host-setup.sh
set -euo pipefail
cd "$(dirname "$0")/.."
[ -f .env ] && { set -a; . ./.env; set +a; }
: "${HOST_LAN_IP:?HOST_LAN_IP unset (set in .env)}"
nic="$(ip -o -4 addr show | awk -v ip="$HOST_LAN_IP" 'index($0, " "ip"/"){print $2; exit}')"
[ -n "$nic" ] || { echo "ERROR: no interface found with IP $HOST_LAN_IP" >&2; exit 1; }
echo "LAN interface for ${HOST_LAN_IP}: ${nic}"
conf=/etc/avahi/avahi-daemon.conf
[ -f "$conf" ] || { echo "ERROR: $conf not found — is avahi-daemon installed?" >&2; exit 1; }
# Need root to edit the conf + restart the service.
if [ "$(id -u)" -ne 0 ]; then
echo "escalating with sudo…"
exec sudo "$0" "$@"
fi
# Already pinned to this NIC? nothing to do.
if grep -qE "^allow-interfaces=${nic}([,[:space:]]|$)" "$conf"; then
echo "already set: allow-interfaces=${nic}"
else
cp -a "$conf" "${conf}.bak.$(date +%s)"
if grep -qE '^allow-interfaces=' "$conf"; then
sed -i -E "s/^allow-interfaces=.*/allow-interfaces=${nic}/" "$conf"
elif grep -qE '^#\s*allow-interfaces=' "$conf"; then
sed -i -E "s/^#\s*allow-interfaces=.*/allow-interfaces=${nic}/" "$conf"
else
# insert right after the [server] section header
sed -i -E "/^\[server\]/a allow-interfaces=${nic}" "$conf"
fi
echo "set allow-interfaces=${nic} (backup: ${conf}.bak.*)"
fi
echo "restarting avahi-daemon…"
systemctl restart avahi-daemon
sleep 1
systemctl is-active --quiet avahi-daemon && echo "avahi-daemon active" || { echo "ERROR: avahi-daemon not active" >&2; exit 1; }
grep -E '^allow-interfaces=' "$conf"

View File

@@ -1,213 +0,0 @@
#!/usr/bin/env bash
# mirror-airgap.sh — populate the byte-exact upstream mirror for full client air-gap.
#
# Architecture (see plan/11-full-airgap-mirror.md): dnsmasq spoofs the real
# upstream hostnames -> our host; Caddy serves byte-exact path mirrors over HTTPS
# with `tls internal`. The launcher is UNMODIFIED — it requests the real Mojang
# URLs and DNS resolves them to us. Mirror files live at:
# mirror/upstream/<host>/<path> (== the real URL path, byte-for-byte)
# `mirror/` is gitignored.
#
# ── What this script does DETERMINISTICALLY (no proxy needed) ────────────────
# Everything reachable purely from the Mojang piston metadata graph for a given
# MC version:
# 1. version_manifest_v2.json (piston-meta.mojang.com)
# 2. the per-version <id>.json (piston-meta.mojang.com)
# 3. client.jar (piston-data.mojang.com) — sha1 verified
# 4. every library artifact (libraries.minecraft.net / url-derived host) — sha1 verified
# 5. the asset index (piston-meta / piston-data, real path) — sha1 verified
# 6. every asset object (resources.download.minecraft.net/<2hex>/<hash>) — sha1 verified, idempotent
# All downloads go to a .partial file, are sha1-checked, then atomically moved.
# Any fetch failure or sha1 mismatch HALTS the script (set -euo pipefail).
#
# ── What this script CANNOT do here — the MANUAL PROXY-CAPTURE step ──────────
# Two upstream surfaces are NOT enumerable from the piston graph and require the
# online proxy-capture procedure documented in plan/11-full-airgap-mirror.md
# ("Capture (the actual work)"):
#
# * meta.prismlauncher.org — Prism-format component metadata (the launcher's
# own index of MC / LWJGL / NeoForge components).
# Layout is launcher-specific (/v1/...), versioned,
# and only knowable by observing what the launcher
# actually requests.
# * maven.neoforged.net — NeoForge installer + the NeoForge-processed
# libraries (and possibly extra maven mirrors the
# NeoForge install pulls). The exact artifact set
# depends on the launcher/installer run.
#
# For those: launch the instance once online behind a logging forward/reverse
# proxy (mitmproxy or a logging Caddy), collect every absolute URL hit, then
# fetch each into mirror/upstream/<host>/<path> preserving the path exactly and
# verifying sha1 where the source provides it. This script prints a MANUAL STEP
# notice at the end as a reminder.
#
# Requires: curl, jq, sha1sum.
set -euo pipefail
cd "$(dirname "$0")/.." # repo root
# --- env (optional) --------------------------------------------------------
[ -f .env ] && { set -a; . ./.env; set +a; }
MC_VERSION="${MC_VERSION:-1.21.1}"
NEOFORGE_VERSION="${NEOFORGE_VERSION:-21.1.209}" # informational; NeoForge libs come from capture
# --- required tools --------------------------------------------------------
for tool in curl jq sha1sum; do
command -v "$tool" >/dev/null 2>&1 || { echo "error: '$tool' is required but not found in PATH" >&2; exit 1; }
done
MIRROR_ROOT="mirror/upstream"
MANIFEST_HOST="piston-meta.mojang.com"
MANIFEST_PATH="mc/game/version_manifest_v2.json"
MANIFEST_URL="https://${MANIFEST_HOST}/${MANIFEST_PATH}"
RESOURCES_HOST="resources.download.minecraft.net"
echo "mirror-airgap: MC_VERSION=${MC_VERSION} NEOFORGE_VERSION=${NEOFORGE_VERSION} (NeoForge handled by capture)"
echo "mirror root: ${MIRROR_ROOT}/"
# --- helpers ---------------------------------------------------------------
sha1_of() { sha1sum "$1" | awk '{print $1}'; }
# host + path from an absolute https?:// URL (path has no leading slash).
url_host() { printf '%s' "$1" | sed -E 's#^[a-zA-Z]+://([^/]+)/.*$#\1#'; }
url_path() { printf '%s' "$1" | sed -E 's#^[a-zA-Z]+://[^/]+/##'; }
# Download $url into mirror/upstream/<host>/<path>, verifying sha1 ($want_sha1,
# may be empty to skip verification). Idempotent: a present file whose sha1
# already matches is skipped. Halts on any failure or mismatch.
# Echoes the destination path on stdout (last line) for callers that need it.
fetch_to_mirror() { # $1=url $2=want_sha1(optional)
local url="$1" want="${2:-}"
local host path dest tmp got
host="$(url_host "$url")"
path="$(url_path "$url")"
[ -n "$host" ] || { echo "error: could not parse host from url: $url" >&2; exit 1; }
[ -n "$path" ] || { echo "error: could not parse path from url: $url" >&2; exit 1; }
dest="${MIRROR_ROOT}/${host}/${path}"
if [ -f "$dest" ]; then
if [ -n "$want" ]; then
got="$(sha1_of "$dest")"
if [ "$got" = "$want" ]; then
printf '%s\n' "$dest"
return 0
fi
echo " re-fetch ${host}/${path} (local sha1 mismatch)" >&2
else
# no sha1 to check (e.g. manifest/version json) — re-fetch to stay fresh
:
fi
fi
mkdir -p "$(dirname "$dest")"
tmp="${dest}.partial"
if ! curl -fSL --retry 3 -o "$tmp" "$url"; then
rm -f "$tmp"
echo "error: download failed: $url" >&2
exit 1
fi
if [ -n "$want" ]; then
got="$(sha1_of "$tmp")"
if [ "$got" != "$want" ]; then
rm -f "$tmp"
echo "error: sha1 mismatch for $url" >&2
echo " expected: $want" >&2
echo " got: $got" >&2
exit 1
fi
fi
mv -f "$tmp" "$dest"
printf '%s\n' "$dest"
}
# --- 1. version manifest ---------------------------------------------------
echo "[1/6] version manifest"
manifest_dest="$(fetch_to_mirror "$MANIFEST_URL" "" | tail -n1)"
version_url="$(jq -r --arg v "$MC_VERSION" '.versions[] | select(.id == $v) | .url' "$manifest_dest")"
[ -n "$version_url" ] && [ "$version_url" != "null" ] || {
echo "error: MC version '$MC_VERSION' not found in version manifest" >&2; exit 1; }
echo " version json: $version_url"
# --- 2. per-version json ---------------------------------------------------
echo "[2/6] version json"
version_dest="$(fetch_to_mirror "$version_url" "" | tail -n1)"
# --- 3. client.jar ---------------------------------------------------------
echo "[3/6] client.jar"
client_url="$(jq -r '.downloads.client.url' "$version_dest")"
client_sha1="$(jq -r '.downloads.client.sha1' "$version_dest")"
[ -n "$client_url" ] && [ "$client_url" != "null" ] || { echo "error: no .downloads.client.url in version json" >&2; exit 1; }
fetch_to_mirror "$client_url" "$client_sha1" >/dev/null
echo " ok client.jar (sha1 verified)"
# --- 4. libraries ----------------------------------------------------------
echo "[4/6] libraries"
lib_count="$(jq '[.libraries[] | select(.downloads.artifact != null)] | length' "$version_dest")"
echo " $lib_count library artifact(s)"
# Emit "url<TAB>sha1" per artifact; host+path derived from the url itself so
# whatever maven host appears (libraries.minecraft.net or others) is mirrored.
jq -r '.libraries[] | select(.downloads.artifact != null)
| "\(.downloads.artifact.url)\t\(.downloads.artifact.sha1)"' "$version_dest" \
| while IFS=$'\t' read -r lurl lsha1; do
[ -n "$lurl" ] || continue
fetch_to_mirror "$lurl" "$lsha1" >/dev/null
done
echo " ok libraries (sha1 verified)"
# --- 5. asset index --------------------------------------------------------
echo "[5/6] asset index"
ai_url="$(jq -r '.assetIndex.url' "$version_dest")"
ai_sha1="$(jq -r '.assetIndex.sha1' "$version_dest")"
ai_id="$(jq -r '.assetIndex.id' "$version_dest")"
[ -n "$ai_url" ] && [ "$ai_url" != "null" ] || { echo "error: no .assetIndex.url in version json" >&2; exit 1; }
echo " asset index id=$ai_id"
ai_dest="$(fetch_to_mirror "$ai_url" "$ai_sha1" | tail -n1)"
# --- 6. asset objects ------------------------------------------------------
echo "[6/6] asset objects (the bulk — this is the large/slow part)"
obj_total="$(jq '.objects | length' "$ai_dest")"
echo " $obj_total object(s) -> ${RESOURCES_HOST}/<2hex>/<hash>"
n=0
# Each object: hash -> served at resources.download.minecraft.net/<first2>/<hash>;
# sha1 == the hash itself. Idempotent: fetch_to_mirror skips valid existing files.
jq -r '.objects[].hash' "$ai_dest" \
| while read -r hash; do
[ -n "$hash" ] || continue
n=$((n + 1))
prefix="${hash:0:2}"
obj_url="https://${RESOURCES_HOST}/${prefix}/${hash}"
fetch_to_mirror "$obj_url" "$hash" >/dev/null
done
echo " ok asset objects (sha1 verified, idempotent)"
# --- manual step notice ----------------------------------------------------
cat >&2 <<'NOTICE'
────────────────────────────────────────────────────────────────────────────
MANUAL STEP REQUIRED — proxy-capture (see plan/11-full-airgap-mirror.md)
────────────────────────────────────────────────────────────────────────────
This script mirrored the deterministic Mojang piston graph only:
piston-meta.mojang.com, piston-data.mojang.com, libraries.minecraft.net,
resources.download.minecraft.net.
The following are NOT enumerable from the piston graph and were NOT fetched:
* meta.prismlauncher.org — Prism-format component metadata (/v1/...)
* maven.neoforged.net — NeoForge installer + processed libraries
(plus any extra maven mirrors the install pulls)
To complete the air-gap mirror, run the capture procedure from
plan/11-full-airgap-mirror.md ("Capture (the actual work)"):
1. Launch the instance ONCE online behind a logging forward/reverse proxy
(mitmproxy or a logging Caddy). Collect every absolute URL requested.
2. Fetch each into mirror/upstream/<host>/<path>, preserving the path
exactly; verify sha1 where the source json provides it.
3. Offline re-test on a clean machine (internet cut, LAN DNS = dnsmasq,
Caddy root CA trusted). Any 404 -> a missing URL -> add it to the fetch.
────────────────────────────────────────────────────────────────────────────
NOTICE
echo "deterministic mirror complete for MC ${MC_VERSION}."

View File

@@ -1,136 +0,0 @@
#!/usr/bin/env bash
# mirror-mods.sh — vendor mod jars locally + rewrite packwiz download URLs.
#
# For offline (air-gapped) LAN play, packwiz .pw.toml files point at the
# Modrinth/CF CDN. This script downloads each jar into pack/mods-files/,
# rewrites the [download] url to the local Caddy mirror, then runs
# `packwiz refresh` so index.toml/hashes match the rewritten state.
#
# Cautious by design: halts on download failure or hash mismatch, idempotent
# (skips already-vendored valid jars), and no-ops gracefully with zero mods.
# pack/mods-files/ is gitignored — only the .pw.toml metadata is tracked.
set -euo pipefail
cd "$(dirname "$0")/.." # repo root
# --- env -------------------------------------------------------------------
[ -f .env ] && { set -a; . ./.env; set +a; }
: "${BASE_DOMAIN:?BASE_DOMAIN unset (set in .env)}"
# --- packwiz binary --------------------------------------------------------
PACKWIZ="/home/oier/go/bin/packwiz"
[ -x "$PACKWIZ" ] || PACKWIZ="$(command -v packwiz || true)"
[ -n "$PACKWIZ" ] || { echo "error: packwiz not found (looked at /home/oier/go/bin/packwiz and PATH)" >&2; exit 1; }
PACK_DIR="pack"
MODS_META_DIR="$PACK_DIR/mods"
MODS_FILES_DIR="$PACK_DIR/mods-files"
MIRROR_BASE="http://packwiz.${BASE_DOMAIN}/mods-files"
[ -f "$PACK_DIR/pack.toml" ] || { echo "error: $PACK_DIR/pack.toml not found — run packwiz init first" >&2; exit 1; }
# --- collect .pw.toml files ------------------------------------------------
shopt -s nullglob
metas=("$MODS_META_DIR"/*.pw.toml)
shopt -u nullglob
if [ "${#metas[@]}" -eq 0 ]; then
echo "no mods in $MODS_META_DIR/ — nothing to mirror (no-op)."
exit 0
fi
mkdir -p "$MODS_FILES_DIR"
# --- helpers ---------------------------------------------------------------
# Read a top-level scalar TOML key (filename) from a .pw.toml.
toml_top_value() { # $1=file $2=key
sed -n "s/^[[:space:]]*$2[[:space:]]*=[[:space:]]*\"\(.*\)\"[[:space:]]*\$/\1/p" "$1" | head -n1
}
# Read a key from the [download] table only.
toml_download_value() { # $1=file $2=key
awk -v key="$2" '
/^\[/ { in_dl = ($0 ~ /^\[download\]/) ; next }
in_dl {
line=$0
sub(/^[ \t]*/, "", line)
if (line ~ "^" key "[ \t]*=") {
sub("^" key "[ \t]*=[ \t]*", "", line)
gsub(/^"|"[ \t]*$/, "", line)
print line
exit
}
}
' "$1"
}
sha256_of() { sha256sum "$1" | awk '{print $1}'; }
changed=0
echo "mirroring ${#metas[@]} mod(s) → $MIRROR_BASE"
for meta in "${metas[@]}"; do
filename="$(toml_top_value "$meta" filename)"
url="$(toml_download_value "$meta" url)"
hash_format="$(toml_download_value "$meta" hash-format)"
want_hash="$(toml_download_value "$meta" hash)"
[ -n "$filename" ] || { echo "error: no filename in $meta" >&2; exit 1; }
[ -n "$url" ] || { echo "error: no [download] url in $meta" >&2; exit 1; }
dest="$MODS_FILES_DIR/$filename"
already_local="$MIRROR_BASE/$filename"
# If the .pw.toml already points at the mirror and the jar exists & verifies,
# skip (idempotent re-run).
if [ "$url" = "$already_local" ] && [ -f "$dest" ]; then
if [ "${hash_format:-sha256}" = "sha256" ] && [ -n "$want_hash" ]; then
if [ "$(sha256_of "$dest")" = "$want_hash" ]; then
echo " skip $filename (already vendored, hash ok)"
continue
fi
echo " re-fetch $filename (local hash mismatch)"
else
echo " skip $filename (already vendored)"
continue
fi
fi
# Download (from current url — CDN on first run). Use a temp file so a failed
# download never leaves a partial jar behind.
echo " fetch $filename"
tmp="$dest.partial"
if ! curl -fSL --retry 3 -o "$tmp" "$url"; then
rm -f "$tmp"
echo "error: download failed for $filename ($url)" >&2
exit 1
fi
# Verify against the recorded hash before trusting the file.
if [ "${hash_format:-sha256}" = "sha256" ] && [ -n "$want_hash" ]; then
got_hash="$(sha256_of "$tmp")"
if [ "$got_hash" != "$want_hash" ]; then
rm -f "$tmp"
echo "error: hash mismatch for $filename" >&2
echo " expected: $want_hash" >&2
echo " got: $got_hash" >&2
exit 1
fi
fi
mv -f "$tmp" "$dest"
# Rewrite the [download] url to the local mirror if not already.
if [ "$url" != "$already_local" ]; then
esc_old="$(printf '%s' "$url" | sed -e 's/[\/&]/\\&/g')"
esc_new="$(printf '%s' "$already_local" | sed -e 's/[\/&]/\\&/g')"
sed -i "s/^\([[:space:]]*url[[:space:]]*=[[:space:]]*\)\"$esc_old\"/\1\"$esc_new\"/" "$meta"
echo " url → $already_local"
changed=1
fi
done
# --- refresh so index.toml + hashes reflect the rewritten metadata ---------
echo "running packwiz refresh ..."
( cd "$PACK_DIR" && "$PACKWIZ" refresh )
echo "done. mirrored ${#metas[@]} mod(s)$( [ "$changed" -eq 1 ] && echo ', URLs rewritten to local mirror' )."

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# render-config.sh — turn *.tmpl files into real configs via envsubst.
# Substitutes ONLY ${BASE_DOMAIN} and ${HOST_LAN_IP} so literal $-syntax that
# belongs to the target file (dnsmasq, caddy, toml) is left untouched.
# Substitutes ONLY ${BASE_DOMAIN} so literal $-syntax that belongs to the target
# file (caddy, toml) is left untouched.
# Halts on any unset var or missing template (cautious: no half-rendered output).
set -euo pipefail
@@ -9,16 +9,15 @@ cd "$(dirname "$0")/.." # repo root
[ -f .env ] && { set -a; . ./.env; set +a; }
: "${BASE_DOMAIN:?BASE_DOMAIN unset (set in .env)}"
: "${HOST_LAN_IP:?HOST_LAN_IP unset (set in .env)}"
render() { # $1=template $2=output
[ -f "$1" ] || { echo "missing template: $1" >&2; exit 1; }
envsubst '${BASE_DOMAIN} ${HOST_LAN_IP}' < "$1" > "$2"
envsubst '${BASE_DOMAIN}' < "$1" > "$2"
echo "rendered $2"
}
render drasl/config/config.toml.tmpl drasl/config/config.toml
render dnsmasq/dnsmasq.conf.tmpl dnsmasq/dnsmasq.conf
render nmsr/config.toml.tmpl nmsr/config.toml
# pack templates (mods/*.pw.toml.tmpl, CustomSkinLoader, …) + packwiz index.
# Delegated to render-pack.sh (BASE_DOMAIN only — single source of pack render).

60
tooling/render-nginx.sh Executable file
View File

@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# render-nginx.sh — render the nginx vhost template with the .env values.
#
# Substitutes $BASE_DOMAIN, $APP_DIR, $CADDY_HTTP_PORT into
# nginx/ulicraft-caddy.conf.tmpl (the host nginx that terminates TLS in front of
# caddy) and prints the result to stdout, or installs it with --install (writes
# to sites-available, symlinks sites-enabled, tests, reloads nginx).
#
# Usage:
# tooling/render-nginx.sh # render -> stdout
# tooling/render-nginx.sh --install # render + install + reload nginx
#
# .env vars used:
# BASE_DOMAIN required
# CADDY_HTTP_PORT localhost port nginx reverse-proxies to (caddy)
# optional:
# APP_DIR host path to this repo (cert files live under APP_DIR/certs)
# — defaults to the repo root (this checkout)
# SITE_NAME installed filename (default ulicraft.conf)
set -euo pipefail
cd "$(dirname "$0")/.." # repo root
log() { printf '\n\033[1;32m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m!!\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1;31mxx\033[0m %s\n' "$*" >&2; exit 1; }
[ -f .env ] || die ".env not found (copy .env.example and fill it)"
# shellcheck disable=SC1091
set -a; . ./.env; set +a
: "${BASE_DOMAIN:?BASE_DOMAIN unset in .env}"
TEMPLATE="${TEMPLATE:-nginx/ulicraft-caddy.conf.tmpl}"
[ -f "$TEMPLATE" ] || die "template not found: $TEMPLATE"
APP_DIR="${APP_DIR:-$PWD}"
SITE_NAME="${SITE_NAME:-ulicraft.conf}"
# CADDY_HTTP_PORT only matters for the caddy-front template; default 80 so the
# static template (which never references it) still renders cleanly.
export BASE_DOMAIN APP_DIR
export CADDY_HTTP_PORT="${CADDY_HTTP_PORT:-80}"
rendered="$(envsubst '$BASE_DOMAIN $APP_DIR $CADDY_HTTP_PORT' < "$TEMPLATE")"
if [ "${1:-}" != "--install" ]; then
printf '%s\n' "$rendered"
warn "dry run — pipe to a file or pass --install to deploy"
exit 0
fi
command -v nginx >/dev/null 2>&1 || die "nginx not found on PATH"
avail="/etc/nginx/sites-available/$SITE_NAME"
enabled="/etc/nginx/sites-enabled/$SITE_NAME"
log "installing $TEMPLATE -> $avail (APP_DIR=$APP_DIR, CADDY_HTTP_PORT=$CADDY_HTTP_PORT)"
printf '%s\n' "$rendered" | sudo tee "$avail" >/dev/null
sudo ln -sf "$avail" "$enabled"
sudo nginx -t
sudo systemctl reload nginx
log "done — nginx reloaded"

View File

@@ -3,8 +3,8 @@
# ${BASE_DOMAIN}, then rebuild the packwiz index.
#
# Pack templates are domain-dependent build output (download URLs, skin API
# roots, …). This renderer needs ONLY ${BASE_DOMAIN} — no dnsmasq HOST_LAN_IP —
# so it can run while curating mods before the full deploy config exists.
# roots, …). This renderer needs ONLY ${BASE_DOMAIN}, so it can run while
# curating mods before the full deploy config exists.
# Both tooling/render-config.sh (deploy) and tooling/add-custom-mod.sh call it.
set -euo pipefail