Compare commits

...

2 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
54 changed files with 625 additions and 2484 deletions

View File

@@ -1,6 +1,6 @@
--- ---
name: deploy 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` # Deploy Ulicraft to `cochi`
@@ -13,8 +13,9 @@ Redeploy the running stack on the production host. Full reference:
- Host: `cochi` (SSH alias) - Host: `cochi` (SSH alias)
- Path: `/home/ubuntu/mc/ulicraft-server-v1` - Path: `/home/ubuntu/mc/ulicraft-server-v1`
- Branch: `main` - Branch: `main`
- Run mode: `online` → compose files `docker-compose.yml` + `docker-compose.static.yml` - Stack: single unified `docker-compose.yml` (drasl, minecraft, mc-backup,
- Auth: Drasl (base stack) caddy, nmsr, uptime-kuma)
- Auth: Drasl
- Restart: **hard** (down → up) — players are disconnected for a few minutes - Restart: **hard** (down → up) — players are disconnected for a few minutes
## Procedure ## Procedure
@@ -35,32 +36,32 @@ invoking this skill is the authorization to proceed, but:
ssh cochi 'set -e ssh cochi 'set -e
cd /home/ubuntu/mc/ulicraft-server-v1 cd /home/ubuntu/mc/ulicraft-server-v1
git pull --ff-only git pull --ff-only
docker compose -f docker-compose.yml -f docker-compose.static.yml down --remove-orphans tooling/render-config.sh
tooling/build-stack.sh --up online' 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 - `--ff-only` refuses to merge — if the pull is not a fast-forward, STOP and
report (local changes on the host need manual resolution). report (local changes on the host need manual resolution).
- `build-stack.sh --up online` renders configs (needs `.env` complete: - `render-config.sh` re-renders drasl/nmsr/packwiz configs from `.env`
`BASE_DOMAIN`, `HOST_LAN_IP`, `RCON_PASSWORD`) and runs `compose up -d`. (needs `.env` complete: `BASE_DOMAIN`, `RCON_PASSWORD`,
`DISTRIBUTION_WEB_ROOT`, `CADDY_HTTP_*`).
3. **Verify.** 3. **Verify.**
```bash ```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 Confirm `caddy`, `drasl`, `minecraft`, `mc-backup`, `nmsr`, `uptime-kuma` are
log briefly and confirm it reaches `Done` (server ready): Up. Tail the minecraft log briefly and confirm it reaches `Done` (server
ready):
```bash ```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 ## Guardrails
- Never `git reset --hard` or discard changes on `cochi` without telling the user - Never `git reset --hard` or discard changes on `cochi` without telling the user
first — there may be host-local edits. first — there may be host-local edits.
- Volumes (`mc_data`, `drasl_state`) persist across `down`; the world is safe. Do - Volumes (`mc_data`, `drasl_state`, `kuma_data`) persist across `down`; the
NOT pass `-v`/`--volumes` to `down`. 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 - If a step fails, STOP and surface the exact error + the failing command. Do not
improvise recovery on production. 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 # Copy to .env and fill in real values
# Single base domain for the whole stack; every service is a subdomain of this. # 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 # DNS is handled OUTSIDE this repo — point ${BASE_DOMAIN} and every subdomain
# won't resolve through a normal unicast DNS server. # (auth. pack. avatar. status. distribution. www.) at the host running nginx.
BASE_DOMAIN=ulicraft.lan BASE_DOMAIN=ulicraft.net
# 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
RCON_PASSWORD=change-me-to-something-random RCON_PASSWORD=change-me-to-something-random
# Caddy host-published port (docker-compose.caddy.yml). Default 80 for the # caddy host-published port. The host's own nginx terminates TLS and
# standalone LAN/air-gap path. When the machine's own nginx fronts caddy # reverse-proxies to caddy by Host header (nginx/ulicraft-caddy.conf.tmpl), so
# (nginx terminates TLS → reverse-proxies to caddy), set a high port and bind # bind caddy to a high localhost-only port — only nginx should reach it.
# it to localhost so only nginx reaches it: CADDY_HTTP_PORT=8880
# CADDY_HTTP_PORT=8880 CADDY_HTTP_BIND=127.0.0.1
# 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
# distribution.${BASE_DOMAIN} static vhost (docker-compose.distribution.yml). # distribution.${BASE_DOMAIN} static vhost. Absolute host path to the built
# Absolute host path to the built static site — it lives in ANOTHER repo. # static site — it lives in ANOTHER repo. Bind-mounted read-only into caddy.
# Bind-mounted read-only into caddy and served at distribution.${BASE_DOMAIN}.
DISTRIBUTION_WEB_ROOT=/home/oier/projects/mc-mods/ulicraft-group/ulicraft-distribution/dist DISTRIBUTION_WEB_ROOT=/home/oier/projects/mc-mods/ulicraft-group/ulicraft-distribution/dist
# Only needed if using CurseForge-exclusive mods: # Only needed if using CurseForge-exclusive mods:
# CF_API_KEY=your-curseforge-api-key # CF_API_KEY=your-curseforge-api-key
@@ -45,13 +32,3 @@ OVH_AK=
OVH_AS= OVH_AS=
OVH_CK= OVH_CK=
# LE_STAGING=1 # use the untrusted staging CA for dry runs # 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

8
.gitignore vendored
View File

@@ -6,15 +6,14 @@ runtime/authlib-injector.jar
# rendered configs (from tooling/render-config.sh) # rendered configs (from tooling/render-config.sh)
drasl/config/config.toml drasl/config/config.toml
nmsr/config.toml nmsr/config.toml
dnsmasq/dnsmasq.conf
# packwiz pack files rendered from pack/**/*.tmpl by tooling/render-pack.sh # packwiz pack files rendered from pack/**/*.tmpl by tooling/render-pack.sh
# (domain-dependent build output); source of truth is the .tmpl + custom/ jars. # (domain-dependent build output); source of truth is the .tmpl + custom/ jars.
pack/mods/*.pw.toml pack/mods/*.pw.toml
pack/index.toml pack/index.toml
pack/CustomSkinLoader/CustomSkinLoader.json pack/CustomSkinLoader/CustomSkinLoader.json
# vendored / mirrored binaries # vendored binaries
mirror/ launcher/
pack/mods-files/ pack/mods-files/
# landing page: generated build output + deps # landing page: generated build output + deps
@@ -23,8 +22,5 @@ landing/node_modules/
landing/.astro/ landing/.astro/
landing/dist/ 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) # Let's Encrypt certs + private keys (issued by tooling/issue-letsencrypt.sh)
certs/ 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 > Self-hosted modded Minecraft (NeoForge 1.21.1) with self-hosted auth/skins
> persistent auth/skin infrastructure designed to outlive the LAN weekend > (Drasl), a packwiz modpack, avatar rendering, a guest landing page, and uptime
> and integrate with the existing homelab. > 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 ## Goals & Constraints
- **Players**: 410 friends, LAN-based - **Players**: 410 friends
- **Modpack vibe**: Kitchen-sink (tech + magic + exploration + QoL) - **Modpack vibe**: Kitchen-sink (tech + magic + exploration + QoL)
- **Pack approach**: Manually curated (~50100 mods), NOT a forked existing pack - **Pack approach**: Manually curated (~50100 mods), NOT a forked existing pack
- **Minecraft version**: 1.21.1 - **Minecraft version**: 1.21.1, **NeoForge** (not classic Forge)
- **Mod loader**: NeoForge (NOT classic Forge — see Decisions below) - **Auth/skins**: Drasl (Yggdrasil-compatible), **password login** — no OIDC
- **Network**: LAN party context, but stack is persistent (lives past the weekend) - **DNS**: handled OUTSIDE this repo; point `${BASE_DOMAIN}` + subdomains at the host
- **Auth**: Self-hosted Yggdrasil-compatible (Drasl) + Keycloak OIDC - **Persistence horizon**: long-term service, not disposable
- **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`.
## Architecture ## Architecture
``` One unified `docker-compose.yml`. The host's own nginx terminates TLS (Let's
LAN segment 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
├── AdGuard Home → resolves drasl.home.local, keycloak.home.local via caddy's `mcnet` aliases (`auth.`, `pack.`).
│ 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
Clients (LAN party guests): ```
Prism Launcher → authlib-injector account pointing at Drasl Internet ── host nginx (TLS, LE certs, HSTS) ──► caddy (127.0.0.1:8880)
→ joins minecraft:25565 │ 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. ## Key Decisions
2. Clicks "Register with Keycloak" → redirected to Keycloak login.
3. Logs in with homelab Keycloak credentials. - **NeoForge over Forge** — the 1.21.x kitchen-sink ecosystem is overwhelmingly
4. Returned to Drasl, picks a player name, uploads a skin. NeoForge; the Forge team migrated. `itzg/minecraft-server` via `TYPE=NEOFORGE`.
5. Drasl shows them a "Minecraft Token" on their profile page. - **Drasl over Ely.by / vanilla offline** — self-hosted Go service, Yggdrasil
6. In Prism Launcher: Settings → Accounts → Add authlib-injector account drop-in, skins + capes, actively maintained. Run with **password login**
with URL `http://drasl.home.local:25585/authlib-injector` and the (no OIDC/Keycloak in this stack).
Minecraft Token as password. - **Manual curation** (user choice, flagged expensive) — if it gets painful,
7. Joins Minecraft server. Server validates session against Drasl (via fork-and-trim Leaking Kitchen Sink is still on the table (closest vibe match).
authlib-injector JVM agent), Drasl confirms, player enters with their skin. - **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 ## Critical Gotchas
### Minecraft 1.21+ secure profile incompatibility ### Minecraft 1.21+ secure profile
- Server: must set `enforce-secure-profile=false` (compose: `ENFORCE_SECURE_PROFILE=FALSE`) - Server: `ENFORCE_SECURE_PROFILE=FALSE` (`enforce-secure-profile=false`)
- Drasl: must set `SignPublicKeys = false` - Drasl: `SignPublicKeys = false`
- These are linked. The Drasl docs explicitly warn: *"Mixed authentication does - Linked. Drasl docs: *"Mixed authentication does not work with
not work with `SignPublicKeys = true` on Minecraft 1.21+."* `SignPublicKeys = true` on Minecraft 1.21+."*
### authlib-injector JVM agent ### authlib-injector JVM agent
- Syntax: `-javaagent:/path/to/authlib-injector.jar=<yggdrasil-api-url>` - Syntax: `-javaagent:/extras/authlib-injector.jar=<yggdrasil-api-url>`
- The URL after `=` is the **API root**, which for Drasl is - URL after `=` is the **API root** = `<BaseURL>/authlib-injector`. For this
`<BaseURL>/authlib-injector`. stack: `http://auth.${BASE_DOMAIN}/authlib-injector` (internal, over mcnet).
- **Must match between server and client.** If server uses - **Must match between server and client** (clients use the public
`http://drasl.home.local:25585/authlib-injector` and client uses anything `https://auth.${BASE_DOMAIN}/authlib-injector`), else session validation fails
else (e.g. an IP), session validation fails silently with "Invalid session". silently with "Invalid session".
- Download from: https://github.com/yushijinhun/authlib-injector/releases - Releases: https://github.com/yushijinhun/authlib-injector — jar lives in
- Mount into the itzg container at `/extras/authlib-injector.jar`. `runtime/` (mounted 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.
### NeoForge version pinning ### NeoForge version pinning
Mods are picky about exact NeoForge minor versions. The compose currently Mods are picky about exact minor versions. Compose pins `NEOFORGE_VERSION:
pins `NEOFORGE_VERSION: "21.1.209"` as a placeholder. **Verify against "21.1.209"`. **Verify against https://projects.neoforged.net/neoforged/neoforge
https://projects.neoforged.net/neoforged/neoforge before deploying.** Don't before deploying.** Never `"latest"` for a stable server.
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.
### Mod source preference ### Mod source preference
Prefer **Modrinth over CurseForge** for itzg auto-download. CurseForge Prefer **Modrinth over CurseForge** (`packwiz modrinth add <slug>`). CF needs
requires an API key (`CF_API_KEY`) and has been historically flakier with `CF_API_KEY` and is flakier. Tag client-only mods `side = "client"` so the server
the itzg image. Use CF only for mods that are exclusive to it. doesn't pull and crash on them.
## 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
```
### Memory sizing ### Memory sizing
With ~50100 mods and 8 concurrent players on 1.21.1: ~50100 mods, 8 players, 1.21.1: `INIT_MEMORY: 4G`, `MAX_MEMORY: 10G`,
- `INIT_MEMORY: 4G`, `MAX_MEMORY: 10G` is a safe starting point `USE_AIKAR_FLAGS: TRUE`.
- Use `USE_AIKAR_FLAGS: TRUE` for the well-known GC tuning
- Monitor with `mc-monitor` (itzg makes one) if you want metrics
### Backups ## Config (.env)
`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.
## 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** Launcher: **FjordLauncherUnlocked** (Prism fork, first-class authlib-injector),
- Client type: `OpenID Connect` mirrored at apex `/launcher/`. Per guest:
- Client ID: `drasl` 1. Download from `https://${BASE_DOMAIN}``/launcher/`.
- Name: `Drasl Minecraft Auth` 2. Add an authlib-injector account, URL
`https://auth.${BASE_DOMAIN}/authlib-injector` (register/login at
2. **Capability config** `https://auth.${BASE_DOMAIN}`).
- Client authentication: **ON** (confidential client) 3. Import the pack `https://pack.${BASE_DOMAIN}/pack.toml`.
- Authentication flow: **Standard flow** only 4. Connect to `${BASE_DOMAIN}` (`:25565`).
- 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
## Open / Pending Work ## Open / Pending Work
These were on the roadmap when we ended the brainstorm: - [ ] **Mod shortlist** for the kitchen-sink pack: Performance (Embeddium,
FerriteCore, ModernFix), Tech (Mekanism, Create, IE, AE2), Magic (Ars Nouveau,
- [ ] **Mod shortlist for the kitchen-sink pack.** Categories to fill: Botania, Iron's Spells), Storage (Sophisticated, Functional), Exploration
- Performance (Embeddium/Sodium-equivalent for NeoForge, FerriteCore, ModernFix) (YUNG's, Repurposed Structures), QoL (JEI, Jade, IPN, AppleSkin), World gen
- Tech/automation (Mekanism, Create, Immersive Engineering, AE2) (Tectonic, Terralith — verify NeoForge 1.21.1), Compat (Polymorph), Map (Xaero),
- Magic (Ars Nouveau, Botania, Iron's Spells 'n Spellbooks) Voice (Simple Voice Chat — 24454/udp already in compose).
- Storage (Sophisticated Storage/Backpacks, Functional Storage) - [ ] Server-side perf mods (C2ME, etc.).
- Exploration (YUNG's structures, Repurposed Structures, biome packs) - [ ] **Verify NeoForge version** against current stable before first deploy.
- QoL (JEI, Jade, JEI Resources, Inventory Profiles Next, AppleSkin) - [ ] **Bootstrap Drasl admin** account.
- 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
## Reference URLs ## Reference URLs
- Drasl repo: https://github.com/unmojang/drasl - Drasl: https://github.com/unmojang/drasl — config docs:
- Drasl configuration docs: https://github.com/unmojang/drasl/blob/master/doc/configuration.md https://github.com/unmojang/drasl/blob/master/doc/configuration.md
- Drasl recipes (example configs): https://github.com/unmojang/drasl/blob/master/doc/recipes.md
- authlib-injector: https://github.com/yushijinhun/authlib-injector - authlib-injector: https://github.com/yushijinhun/authlib-injector
- itzg/minecraft-server: https://github.com/itzg/docker-minecraft-server - 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 - itzg/mc-backup: https://github.com/itzg/docker-mc-backup
- NeoForge versions: https://projects.neoforged.net/neoforged/neoforge - NeoForge versions: https://projects.neoforged.net/neoforged/neoforge
- Prism Launcher: https://prismlauncher.org/ - NMSR: https://github.com/NickAcPT/nmsr-rs
- Reference modpacks for inspiration (NOT forking): - Uptime Kuma: https://github.com/louislam/uptime-kuma
- ATM10 (Modrinth/CurseForge — ~500 mods, NeoForge 1.21.1) - FjordLauncherUnlocked: https://github.com/hero-persson/FjordLauncherUnlocked
- Leaking Kitchen Sink (CurseForge — trimmed ~150 mods, NeoForge 1.21.1) - Reference packs (inspiration, NOT forking): ATM10, Leaking Kitchen Sink.
## Communication Preferences (carry-over from past sessions) ## Communication Preferences (carry-over)
- Concise and direct - Concise and direct; diagnose + resolve without demanding repro steps.
- Diagnose and resolve without demanding detailed reproduction steps - Prefer iterative single-change updates over large rewrites.
- Prefer iterative single-change updates over large rewrites - Cautious scripts that halt on ambiguity rather than proceeding silently.
- Cautious scripts that halt on ambiguity rather than proceeding silently - Avoid over-engineering when a simple answer suffices.
- Avoid over-engineered responses when a simple answer suffices - Spanish or English both fine.
- Spanish or English fine; user is comfortable in both

203
README.md
View File

@@ -1,99 +1,70 @@
# Ulicraft Server # Ulicraft Server
Self-hosted modded Minecraft (NeoForge 1.21.1) for a LAN party. Runs **online** Self-hosted modded Minecraft (NeoForge 1.21.1) with self-hosted auth/skins
(internet present) or **fully air-gapped** (offline). Self-hosted auth/skins (Drasl), a packwiz modpack, avatar rendering, a guest landing page, and uptime
(Drasl), a packwiz modpack, a guest landing page, and a transparent mirror of monitoring. One unified `docker-compose.yml` runs the whole stack behind the
Mojang/launcher/NeoForge assets so guest laptops need no internet. host's nginx + Let's Encrypt.
## Stack ## Stack
One compose file, one `docker compose up -d`:
| Service | Image | Role | | Service | Image | Role |
|---|---|---| |---|---|---|
| `minecraft` | itzg/minecraft-server | NeoForge 1.21.1 server, `:25565` | | `minecraft` | itzg/minecraft-server | NeoForge 1.21.1 server, `:25565` |
| `drasl` | unmojang/drasl | Yggdrasil auth + skins (password login) | | `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 | | `mc-backup` | itzg/mc-backup | world backups every 6h via RCON |
Optional layers (each its own `docker-compose.<name>.yml`): Vhosts (all proxied through the host nginx → caddy):
- `avahi` (mDNS `.local`), `dnsmasq` (turnkey DNS) — only if you don't run your
own DNS.
- `nmsr` (NickAcPT/nmsr-rs, built from source) — skin/avatar renderer at
`avatar.${BASE_DOMAIN}`, sourced from Drasl. See **Avatar renderer** below.
- `distribution` — extra static vhost at `distribution.${BASE_DOMAIN}` whose web
root (`DISTRIBUTION_WEB_ROOT`) lives in another repo.
- `status` (louislam/uptime-kuma) — uptime monitoring + public status page at
`status.${BASE_DOMAIN}`. See **Status monitoring** below.
Config lives in `.env`: `BASE_DOMAIN` (default `ulicraft.lan`), `HOST_LAN_IP`, - apex `${BASE_DOMAIN}` — landing page + `/launcher/` downloads
`RCON_PASSWORD`, `ENABLE_MDNS`. Services are subdomains: `auth.`, `packwiz.`, - `auth.` — Drasl
`mc.`, plus the apex landing page. - `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 Handled **outside this repo**. Point `${BASE_DOMAIN}` and every subdomain
tooling/dns-records.sh online # service names only (internet present) (`auth. pack. avatar. status. distribution. www.`) at the host running nginx.
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`.
## Prerequisites ## Prerequisites
Docker + Docker Compose; for prep also `pnpm`, `packwiz`, `jq`, `curl`, Docker + Docker Compose; for prep also `pnpm`, `packwiz`, `jq`, `curl`,
`envsubst`, `sha1sum`. `envsubst`.
## Launch ## Launch
```sh ```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, # One-time / on change — render configs + build content:
# mirror mods+assets, fetch launcher + tooling/render-config.sh # drasl + nmsr + packwiz configs from *.tmpl
# authlib, pre-bake the server volume ( 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 docker compose up -d --build # first run installs NeoForge + mods, builds nmsr
tooling/build-stack.sh --up airgap # offline; full asset mirror on :443 (default) docker compose down # stop
tooling/build-stack.sh --up core # base only (debugging)
``` ```
`--up` prints the DNS records to add for that mode. Down: First boot: itzg installs NeoForge + the packwiz mods into the `mc_data` volume;
`docker compose -f docker-compose.yml -f docker-compose.static.yml -f docker-compose.mirror.yml down` nmsr does a one-time Rust compile. Watch `docker compose logs -f minecraft` until
`Done`.
## Public TLS (host nginx in front of caddy) ## Public TLS (host nginx in front of caddy)
The LAN path above runs caddy on `:80`. To expose the stack publicly with real caddy is published on a localhost-only port (`CADDY_HTTP_BIND=127.0.0.1`,
certs, the machine's own nginx terminates TLS and reverse-proxies every vhost to `CADDY_HTTP_PORT=8880`). The host's own nginx terminates TLS with Let's Encrypt
caddy by Host header. Caddy stays the single router (apex landing, `www.` certs and reverse-proxies every vhost to caddy by Host header. HTTPS-only: HTTP
apex, `auth.` → drasl, `pack.` packwiz, `distribution.` static, `avatar.` 301s to HTTPS and every TLS vhost sends HSTS.
nmsr). HTTPS-only: HTTP 301s to HTTPS and every TLS vhost sends HSTS.
1. **Issue certs** (OVH DNS-01, no inbound ports needed): 1. **Issue certs** (OVH DNS-01, no inbound ports needed):
```sh ```sh
@@ -102,31 +73,15 @@ nmsr). HTTPS-only: HTTP 301s to HTTPS and every TLS vhost sends HSTS.
tooling/issue-letsencrypt.sh # → certs/<name>/{cert,key}.pem tooling/issue-letsencrypt.sh # → certs/<name>/{cert,key}.pem
``` ```
2. **Bind caddy to a localhost-only port** so only nginx reaches it (`.env`): 2. **Render + install the nginx vhost** with `tooling/render-nginx.sh`. It pulls
```sh
CADDY_HTTP_PORT=8880
CADDY_HTTP_BIND=127.0.0.1
```
3. **Bring up the caddy ingress** (+ static, + distribution/nmsr if used):
```sh
docker compose -f docker-compose.yml -f docker-compose.caddy.yml \
-f docker-compose.static.yml -f docker-compose.distribution.yml \
-f docker-compose.nmsr.yml -f docker-compose.status.yml up -d --build
```
4. **Render + install the nginx vhost** with `tooling/render-nginx.sh`. It pulls
`BASE_DOMAIN` + `CADDY_HTTP_PORT` from `.env`, defaults `APP_DIR` to the repo `BASE_DOMAIN` + `CADDY_HTTP_PORT` from `.env`, defaults `APP_DIR` to the repo
checkout (where `certs/` live), and renders `nginx/ulicraft-caddy.conf.tmpl`: checkout (where `certs/` live), and renders `nginx/ulicraft-caddy.conf.tmpl`:
```sh ```sh
tooling/render-nginx.sh # preview to stdout (dry run) tooling/render-nginx.sh # preview to stdout (dry run)
tooling/render-nginx.sh --install # write, symlink, nginx -t, reload tooling/render-nginx.sh --install # write, symlink, nginx -t, reload
``` ```
Overrides via env: `APP_DIR=/path` (if the repo isn't at the cert location), Override `APP_DIR=/path` if the repo isn't at the cert location. Re-run
`SITE_NAME=foo.conf`, `TEMPLATE=nginx/ulicraft.conf.tmpl` (the static variant — whenever you add a subdomain so its server block is rendered.
nginx serves the files itself + proxies only drasl; pick one, not both).
Re-run step 4 whenever you add a subdomain so its server block is rendered.
## Avatar renderer (NMSR) ## Avatar renderer (NMSR)
@@ -140,40 +95,25 @@ pins a commit; bump `ARG NMSR_REF` to update.
`http://auth.${BASE_DOMAIN}`; Drasl serves the Mojang-compatible routes at its `http://auth.${BASE_DOMAIN}`; Drasl serves the Mojang-compatible routes at its
bare BaseURL and embeds absolute skin URLs that NMSR fetches directly. bare BaseURL and embeds absolute skin URLs that NMSR fetches directly.
`allow_offline_mode_uuids = true` (Drasl issues offline v3 UUIDs). `allow_offline_mode_uuids = true` (Drasl issues offline v3 UUIDs).
- **Network**: skin resolution stays internal over `mcnet` (the caddy alias - **Network**: skin resolution stays internal over `mcnet` (caddy alias
`auth.${BASE_DOMAIN}` → drasl) — no public round-trip. caddy reverse-proxies `auth.${BASE_DOMAIN}` → drasl). caddy reverse-proxies `avatar.` → `nmsr:8080`.
`avatar.` → `nmsr:8080`. The host port is published on `127.0.0.1:9898` only - **Endpoints**: e.g. `https://avatar.${BASE_DOMAIN}/skin/<player>`,
(local debugging; not reachable from outside).
- **Endpoints** (NMSR): e.g. `https://avatar.${BASE_DOMAIN}/skin/<player>`,
`/face/<player>`, `/fullbody/<player>` — by username or UUID. `/face/<player>`, `/fullbody/<player>` — by username or UUID.
Bring up: add `-f docker-compose.nmsr.yml` (with `--build`) as shown above. The First build is heavy (Rust compile). Headless rendering uses lavapipe (software
first build is heavy (Rust compile). Headless rendering uses lavapipe (software
Vulkan); if renders fail, check `docker logs nmsr` for Vulkan device init. Vulkan); if renders fail, check `docker logs nmsr` for Vulkan device init.
`distribution.${BASE_DOMAIN}` serves a static site whose web root lives in
another repo: set `DISTRIBUTION_WEB_ROOT` (absolute host path) in `.env`; it's
bind-mounted read-only via `docker-compose.distribution.yml`.
## Status monitoring (Uptime Kuma) ## Status monitoring (Uptime Kuma)
`status.${BASE_DOMAIN}` runs [Uptime Kuma](https://github.com/louislam/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 — uptime probes for every vhost **and** a native Minecraft-protocol ping (player
count + up/down), plus a public status page. The container joins `mcnet`, so it count + up/down), plus a public status page. It joins `mcnet`, so monitors can
can probe the stack by internal service name without leaving the host. caddy probe the stack by internal service name. caddy reverse-proxies `status.` →
reverse-proxies `status.` → `uptime-kuma:3001`; the host port is published on `uptime-kuma:3001`.
`127.0.0.1:3001` only (first-run admin setup / local debugging).
Bring up (layer on the caddy ingress): 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
```sh persists in the `kuma_data` volume):
docker compose -f docker-compose.yml -f docker-compose.caddy.yml \
-f docker-compose.status.yml up -d
```
Then open `https://status.${BASE_DOMAIN}` (or `http://127.0.0.1:3001` first run),
create the admin account, and add monitors. Kuma has no config-as-code — add
these once via the UI (data persists in the `kuma_data` volume):
| Monitor | Type | Target | | Monitor | Type | Target |
|---|---|---| |---|---|---|
@@ -185,42 +125,35 @@ these once via the UI (data persists in the `kuma_data` volume):
| Avatar (NMSR) | HTTP(s) | `https://avatar.${BASE_DOMAIN}` | | Avatar (NMSR) | HTTP(s) | `https://avatar.${BASE_DOMAIN}` |
Internal-name targets (`minecraft`, `drasl:25585`, `nmsr:8080`) isolate "service Internal-name targets (`minecraft`, `drasl:25585`, `nmsr:8080`) isolate "service
down" from "ingress/TLS/DNS down"; public-URL targets test the whole chain. Mix down" from "ingress/TLS/DNS down"; public-URL targets test the whole chain.
as you like. `status` is in the default `LE_SUBDOMAINS` so its TLS cert issues `status` is in the default `LE_SUBDOMAINS`; the nginx vhost adds websocket
with the rest; the nginx vhost adds websocket upgrade headers for Kuma's live UI. upgrade headers for Kuma's live UI.
## Join (guests) ## Join (guests)
1. Open `http://ulicraft.lan`, download FjordLauncherUnlocked for your OS. 1. Open `https://${BASE_DOMAIN}`, download FjordLauncherUnlocked for your OS.
2. Add an **authlib-injector** account, URL `http://auth.ulicraft.lan/authlib-injector` 2. Add an **authlib-injector** account, URL
(register/login at `http://auth.ulicraft.lan`). `https://auth.${BASE_DOMAIN}/authlib-injector` (register/login at
3. **Air-gap only:** trust the local CA — download `http://ulicraft.lan/ca.crt` `https://auth.${BASE_DOMAIN}`).
and add it to your OS trust store (so the HTTPS asset mirror is trusted). 3. Import the pack: `https://pack.${BASE_DOMAIN}/pack.toml`.
4. Import the pack: `http://packwiz.ulicraft.lan/pack.toml`. 4. Connect to **`${BASE_DOMAIN}`** (Minecraft, `:25565`).
5. Connect to **`mc.ulicraft.lan`** (no port needed).
## Layout ## Layout
``` ```
docker-compose.yml # core: drasl, minecraft, mc-backup, caddy docker-compose.yml # the whole stack (one file)
.static.yml .mirror.yml # online/airgap layers caddy/Caddyfile + conf.d/*.caddy # ingress vhost snippets
.caddy.yml .nginx.yml # ingress paths (caddy-front vs nginx static) # (core/static/distribution/avatar/status)
.nmsr.yml .distribution.yml # avatar renderer / extra static vhost
.avahi.yml .dnsmasq.yml # optional DNS layers
caddy/Caddyfile + conf.d/*.caddy # ingress vhost snippets (core/static/mirror/
# distribution/avatar)
drasl/config/config.toml.tmpl # auth config (rendered) drasl/config/config.toml.tmpl # auth config (rendered)
nmsr/config.toml.tmpl # avatar renderer config, Drasl-backed (rendered) nmsr/config.toml.tmpl # avatar renderer config, Drasl-backed (rendered)
dnsmasq/dnsmasq.conf.tmpl # optional DNS config (rendered) docker/nmsr/ # built-from-source NMSR image
docker/avahi/ docker/nmsr/ # built-from-source images (mDNS, NMSR)
pack/ # packwiz modpack source pack/ # packwiz modpack source
landing/ # Astro site → www/ landing/ # Astro site → www/
tooling/ # build-stack, render-config, render-nginx, launcher/ # FjordLauncher assets (gitignored)
# dns-records, issue-letsencrypt, tooling/ # render-config, render-pack, render-nginx,
# mirror-mods, mirror-airgap, fetch-launcher # issue-letsencrypt, fetch-launcher, add-custom-mod
nginx/*.conf.tmpl # host-nginx TLS vhost templates nginx/ulicraft-caddy.conf.tmpl # host-nginx TLS vhost template (front of caddy)
mirror/ # vendored jars + launcher + asset mirror (gitignored) plan/ # design docs
plan/ # full design docs (0012)
``` ```
See `plan/00-overview.md` for the full design. 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 # Base Caddy config. Vhosts live in conf.d/*.caddy snippets:
# in selectively so the stack can run with or without the static site / mirror: # conf.d/00-core.caddy auth + packwiz
# conf.d/00-core.caddy auth + packwiz (always — core ingress) # conf.d/10-static.caddy apex landing + launcher downloads
# conf.d/10-static.caddy apex landing + launcher downloads (static toggle) # conf.d/30-distribution.caddy static site from another repo
# conf.d/20-mirror.caddy air-gap upstream mirror (tls internal) (mirror toggle) # conf.d/40-avatar.caddy nmsr skin/avatar renderer
# auto_https disable_redirects: http:// sites stay plaintext on :80; the mirror # conf.d/50-status.caddy uptime-kuma status page
# snippet's hosts still get internal TLS on :443 when that snippet is present. # 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 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. # Apex landing page (/srv/www) + launcher downloads (/srv/launcher).
# Mounted only when the stack runs with static files (build-stack.sh up static|full).
http://{$BASE_DOMAIN} { 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 # Launcher downloads are a sibling mount (/srv/launcher), not nested under
# the read-only /srv/www. handle_path strips the /launcher prefix. # the read-only /srv/www. handle_path strips the /launcher prefix.
handle_path /launcher/* { 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. # Distribution — static site served from a web root in ANOTHER repo. The host
# The host path is set by DISTRIBUTION_WEB_ROOT in .env and bind-mounted to # 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 # /srv/distribution by the caddy service in docker-compose.yml.
# set (see the distribution override).
http://distribution.{$BASE_DOMAIN} { http://distribution.{$BASE_DOMAIN} {
root * /srv/distribution root * /srv/distribution
file_server browse file_server browse

View File

@@ -1,5 +1,4 @@
# Avatar toggle — NMSR skin/avatar renderer (docker-compose.nmsr.yml). # Avatar — NMSR skin/avatar renderer (nmsr service), Drasl-backed.
# Mounted only when the nmsr override is present.
http://avatar.{$BASE_DOMAIN} { http://avatar.{$BASE_DOMAIN} {
reverse_proxy nmsr:8080 reverse_proxy nmsr:8080
} }

View File

@@ -1,5 +1,4 @@
# Status toggle — Uptime Kuma monitoring + public status page # Status — Uptime Kuma monitoring + public status page (uptime-kuma service).
# (docker-compose.status.yml). Mounted only when the status override is present.
http://status.{$BASE_DOMAIN} { http://status.{$BASE_DOMAIN} {
reverse_proxy uptime-kuma:3001 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,32 +0,0 @@
# ──────────────────────────────────────────────────────────────────
# NMSR-aas — avatar/skin renderer at avatar.${BASE_DOMAIN}.
# ──────────────────────────────────────────────────────────────────
# docker compose -f docker-compose.yml -f docker-compose.caddy.yml \
# -f docker-compose.nmsr.yml up -d --build
#
# Built from source (no upstream image) — see docker/nmsr/Dockerfile.
# Renders YOUR players' skins by pulling profiles from Drasl over the internal
# mcnet network (config: nmsr/config.toml, rendered from .tmpl).
#
# Caddy reverse-proxies avatar.${BASE_DOMAIN} -> nmsr:8080 internally. The host
# port is published on 127.0.0.1:9898 ONLY (local debugging / not reachable from
# outside) — public access goes through nginx -> caddy.
services:
# Mount the avatar vhost snippet into caddy (the caddy override owns the base
# config; this adds one more conf.d file like the distribution override does).
caddy:
volumes:
- ./caddy/conf.d/40-avatar.caddy:/etc/caddy/conf.d/40-avatar.caddy:ro
nmsr:
build:
context: ./docker/nmsr
image: ulicraft/nmsr:local
container_name: nmsr
restart: unless-stopped
ports:
- "127.0.0.1:9898:8080"
volumes:
- ./nmsr/config.toml:/nmsr/config.toml:ro
networks:
- mcnet

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,36 +0,0 @@
# ──────────────────────────────────────────────────────────────────
# Uptime Kuma — status/monitoring for every vhost + the Minecraft server,
# reachable at status.${BASE_DOMAIN}.
# ──────────────────────────────────────────────────────────────────
# docker compose -f docker-compose.yml -f docker-compose.caddy.yml \
# -f docker-compose.status.yml up -d
#
# Joins mcnet so monitors can probe the stack BY ITS INTERNAL SERVICE NAME
# (drasl:25585, nmsr:8080, minecraft:25565) without leaving the host — and can
# also hit the public https vhosts end-to-end through the host's DNS.
#
# Caddy reverse-proxies status.${BASE_DOMAIN} -> uptime-kuma:3001 internally
# (caddy/conf.d/50-status.caddy). The host port is published on 127.0.0.1:3001
# ONLY (local debugging / first-run admin setup) — public access goes through
# nginx -> caddy. Add status.${BASE_DOMAIN} to LE_SUBDOMAINS for its TLS cert.
#
# Monitors are stored in the kuma_data volume (Kuma has no config-as-code);
# add them once via the web UI — see README for the recommended monitor list.
services:
caddy:
volumes:
- ./caddy/conf.d/50-status.caddy:/etc/caddy/conf.d/50-status.caddy:ro
uptime-kuma:
image: louislam/uptime-kuma:1
container_name: uptime-kuma
restart: unless-stopped
ports:
- "127.0.0.1:3001:3001"
volumes:
- kuma_data:/app/data
networks:
- mcnet
volumes:
kuma_data:

View File

@@ -1,31 +1,31 @@
# ────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────
# Ulicraft LAN-party stack — Minecraft 1.21.1 NeoForge + Drasl auth # Ulicraft LAN-party stack — Minecraft 1.21.1 NeoForge + Drasl auth
# ────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────
# Single source of config: BASE_DOMAIN + HOST_LAN_IP in .env. # Single, unified compose. One file holds the whole stack:
# DNS is provided by YOUR party DNS server — see tooling/dns-records.sh for the # drasl auth + skins (Yggdrasil), internal only
# records to add (online vs airgap). A turnkey dnsmasq is available as an # minecraft the server (itzg/NEOFORGE), installs mods via packwiz
# optional layer: docker-compose.dnsmasq.yml. # mc-backup world backups every 6h via RCON
# - ingress is layered on, NOT in this base file: caddy (docker-compose.caddy.yml, # caddy internal ingress — routes auth./pack./apex/launcher/avatar.
# LAN/air-gap, holds mcnet aliases) OR the host's nginx (docker-compose.nginx.yml # /distribution. by Host header (conf.d/*.caddy)
# + Let's Encrypt, production) # nmsr skin/avatar renderer at avatar.${BASE_DOMAIN}
# - drasl handles auth (password login; NO Keycloak/OIDC in this stack) # uptime-kuma status monitoring at status.${BASE_DOMAIN}
# - packwiz pack is served by the ingress at pack.${BASE_DOMAIN} #
# - minecraft installs mods via PACKWIZ_URL, authlib-injector -> auth. # 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: 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: drasl:
image: unmojang/drasl:latest image: unmojang/drasl:latest
container_name: drasl container_name: drasl
restart: unless-stopped restart: unless-stopped
# Internal-only: no published host port. Reached via Caddy at # Internal-only: no published host port. Reached via caddy at
# auth.${BASE_DOMAIN} (Caddy holds the network alias on mcnet). # auth.${BASE_DOMAIN} (caddy holds the network alias on mcnet).
volumes: volumes:
- ./drasl/config:/etc/drasl:ro - ./drasl/config:/etc/drasl:ro
- drasl_state:/var/lib/drasl - drasl_state:/var/lib/drasl
@@ -58,7 +58,7 @@ services:
# ── Auth: offline mode + authlib-injector pointing at Drasl ─ # ── Auth: offline mode + authlib-injector pointing at Drasl ─
ONLINE_MODE: "FALSE" 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" JVM_OPTS: "-javaagent:/extras/authlib-injector.jar=http://auth.${BASE_DOMAIN}/authlib-injector"
# ── Server config ─────────────────────────────────────────── # ── Server config ───────────────────────────────────────────
@@ -71,9 +71,7 @@ services:
ENFORCE_SECURE_PROFILE: "FALSE" # required for authlib-injector on 1.21+ ENFORCE_SECURE_PROFILE: "FALSE" # required for authlib-injector on 1.21+
ALLOW_FLIGHT: "TRUE" # many tech mods need this ALLOW_FLIGHT: "TRUE" # many tech mods need this
# ── Mod source: packwiz pack served by the ingress ────────── # ── Mod source: packwiz pack served by caddy (alias pack.) ──
# http + caddy alias by default; the nginx override switches this to
# https + host-gateway (extra_hosts) for the production path.
PACKWIZ_URL: "http://pack.${BASE_DOMAIN}/pack.toml" PACKWIZ_URL: "http://pack.${BASE_DOMAIN}/pack.toml"
# ── RCON for remote admin ─────────────────────────────────── # ── RCON for remote admin ───────────────────────────────────
@@ -86,11 +84,11 @@ services:
- mc_data:/data - mc_data:/data
- ./runtime:/extras:ro # authlib-injector.jar lives here - ./runtime:/extras:ro # authlib-injector.jar lives here
depends_on: depends_on:
- drasl # ingress (caddy/nginx) is layered separately - drasl
- caddy # pack./auth. must resolve at boot
networks: networks:
- mcnet - mcnet
# Optional: backups
mc-backup: mc-backup:
image: itzg/mc-backup image: itzg/mc-backup
container_name: mc-backup container_name: mc-backup
@@ -109,9 +107,69 @@ services:
networks: networks:
- mcnet - 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: volumes:
drasl_state: drasl_state:
mc_data: mc_data:
kuma_data:
networks: networks:
mcnet: 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

View File

@@ -1,19 +1,17 @@
# ────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────
# Ulicraft host nginx — TLS terminator in FRONT of caddy. # Ulicraft host nginx — TLS terminator in FRONT of caddy.
# ────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────
# Alternative to ulicraft.conf.tmpl. Instead of nginx serving the static # caddy (in docker-compose.yml) is the single internal ingress for ALL vhosts;
# files + proxying only drasl, here caddy (the docker container) is the single # this host nginx terminates TLS and reverse-proxies everything to caddy by Host
# ingress for ALL vhosts and nginx just terminates TLS and reverse-proxies # header. Prefer tooling/render-nginx.sh over hand-rendering.
# everything to caddy by Host header.
# #
# Caddy must be published on a localhost port — set in .env: # Caddy must be published on a localhost port — set in .env:
# CADDY_HTTP_PORT=8880 # CADDY_HTTP_PORT=8880
# CADDY_HTTP_BIND=127.0.0.1 # CADDY_HTTP_BIND=127.0.0.1
# and the stack brought up with the caddy override (NOT the nginx override): # and the stack brought up:
# docker compose -f docker-compose.yml -f docker-compose.caddy.yml \ # docker compose up -d
# -f docker-compose.static.yml 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 \ # CADDY_HTTP_PORT=8880 BASE_DOMAIN=ulicraft.net APP_DIR=/home/ubuntu/mc/ulicraft-server-v1 \
# envsubst '$CADDY_HTTP_PORT $BASE_DOMAIN $APP_DIR' \ # envsubst '$CADDY_HTTP_PORT $BASE_DOMAIN $APP_DIR' \
# < nginx/ulicraft-caddy.conf.tmpl | sudo tee /etc/nginx/sites-available/ulicraft.conf # < nginx/ulicraft-caddy.conf.tmpl | sudo tee /etc/nginx/sites-available/ulicraft.conf

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 http2;
listen [::]:443 ssl http2;
server_name ${BASE_DOMAIN};
ssl_certificate ${APP_DIR}/certs/${BASE_DOMAIN}/cert.pem;
ssl_certificate_key ${APP_DIR}/certs/${BASE_DOMAIN}/key.pem;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Launcher downloads (populated by tooling/fetch-launcher.sh -> ./mirror/launcher).
location /launcher/ {
alias ${APP_DIR}/mirror/launcher/;
autoindex on;
}
location / {
root ${APP_DIR}/www;
index index.html;
try_files $uri $uri/ =404;
}
}
# Pack — packwiz metadata (./pack) + custom jars (./custom at /custom/).
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name pack.${BASE_DOMAIN};
ssl_certificate ${APP_DIR}/certs/pack.${BASE_DOMAIN}/cert.pem;
ssl_certificate_key ${APP_DIR}/certs/pack.${BASE_DOMAIN}/key.pem;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Locally-hosted custom mod jars live outside ./pack; expose them at /custom/.
location /custom/ {
alias ${APP_DIR}/custom/;
autoindex on;
}
location / {
root ${APP_DIR}/pack;
autoindex on;
}
}
# Auth — reverse proxy to drasl (published on localhost by the nginx override).
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name auth.${BASE_DOMAIN};
ssl_certificate ${APP_DIR}/certs/auth.${BASE_DOMAIN}/cert.pem;
ssl_certificate_key ${APP_DIR}/certs/auth.${BASE_DOMAIN}/key.pem;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
client_max_body_size 4m; # skin uploads
location / {
proxy_pass http://127.0.0.1:25585;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

View File

@@ -1,3 +1,3 @@
# Ulicraft modpack (packwiz) # 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 # Ulicraft Server — Plan Overview
Self-hosted modded Minecraft LAN-party stack, designed to run **fully offline** Self-hosted modded Minecraft stack — a single Docker Compose file holds the
(air-gapped) during the party while remaining a persistent homelab service. 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 ## 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 BASE_DOMAIN=ulicraft.net # all services are subdomains of this
HOST_LAN_IP=192.168.x.x # the Docker host's LAN IP (dnsmasq target)
``` ```
> `.local` collides with mDNS/Bonjour (RFC 6762). macOS + Linux Avahi may resolve `render-config.sh` substitutes **only** `${BASE_DOMAIN}` into the templates.
> `*.local` via multicast and bypass dnsmasq. Accepted risk for now; alternatives DNS is **not** this repo's concern: point `${BASE_DOMAIN}` and all subdomains
> if it bites: `.lan`, `home.arpa`. Flagged, user chose `.local`. (`auth.` `pack.` `avatar.` `status.` `distribution.` `www.`) at the host running
nginx; that is configured outside this repo.
## Subdomain / ingress map ## Subdomain / ingress map
``` ```
ulicraft.local → caddy → landing page (launcher downloads + guide) ulicraft.net nginx → caddy → landing page (/srv/www) + /launcher/
auth.ulicraft.local → caddy → drasl:25585 (auth web UI + Yggdrasil API) auth.ulicraft.net → nginx → caddy → drasl:25585 (auth web UI + Yggdrasil API)
packwiz.ulicraft.local → caddy → /srv/pack (pack metadata + mod-jar mirror) pack.ulicraft.net → nginx → caddy → /srv/pack (packwiz pack metadata)
assets.ulicraft.localcaddy → /srv/assets (MC asset-object mirror; HTTPS forced — see 10) avatar.ulicraft.net nginx → caddy → nmsr:8080 (skin/avatar renderer)
mc.ulicraft.local:25565 → minecraft (NOT http; raw MC protocol) 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). The host's own nginx is the only public ingress: it terminates TLS with Let's
drasl's host port is removed — internal only, reached through Caddy. 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 ### Internal resolution trick
Containers use the Docker resolver, NOT dnsmasq. Caddy gets network aliases so Containers use the Docker resolver. caddy gets network aliases so the stack
the stack resolves its own subdomains internally: resolves its own subdomains internally:
```yaml ```yaml
caddy: caddy:
networks: networks:
mcnet: mcnet:
aliases: [ "auth.${BASE_DOMAIN}", "packwiz.${BASE_DOMAIN}" ] aliases: [ "auth.${BASE_DOMAIN}", "pack.${BASE_DOMAIN}" ]
``` ```
Result: `http://auth.ulicraft.local/authlib-injector` and Result: `http://auth.ulicraft.net/authlib-injector` and
`http://packwiz.ulicraft.local/pack.toml` resolve identically inside and outside `http://pack.ulicraft.net/pack.toml` resolve identically inside the stack
the stack. dnsmasq exists **only for guest laptops**. (minecraft → caddy) as the public names do outside it (client → nginx → caddy).
## Online vs offline split ## Build / run flow
``` ```
PRE-PARTY (internet, run once): PREP (run once, online):
1. curate packwiz pack → see 04-packwiz.md 1. render configs → tooling/render-config.sh (08-tooling.md)
2. mirror mod jars + rewrite URLs → tooling/mirror-mods.sh (08-tooling.md) 2. build landing page → cd landing && pnpm run build → www/ (09-landing.md)
3. pre-bake server volume → TYPE=NEOFORGE install (05-minecraft.md) 3. download launcher releases → tooling/fetch-launcher.sh (06-launcher.md)
4. download launcher releases → tooling/fetch-launcher.sh (06-launcher.md) 4. curate packwiz pack → see 04-packwiz.md
5. capture+mirror client air-gap → tooling/mirror-airgap.sh (11-full-airgap-mirror.md) 5. (TLS) issue Let's Encrypt certs → tooling/issue-letsencrypt.sh (15-letsencrypt.md)
6. build landing page → cd landing && pnpm run build → www/ (09-landing.md) 6. (ingress) render+install nginx → tooling/render-nginx.sh --install (15-letsencrypt.md)
7. render configs from templates → tooling/render-config.sh (08-tooling.md)
PARTY (air-gapped): UP:
dnsmasq : *.ulicraft.local → HOST_LAN_IP docker compose up -d --build
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
``` ```
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 ## Why each hard call was made
- **No OIDC/Keycloak (for now)** — drasl password login. Keycloak left out of this - **No OIDC/Keycloak (for now)** — drasl password login. Keycloak left out of
stack's scope. Re-add later if desired. this stack's scope. Re-add later if desired.
- **itzg can't air-gap-boot in NEOFORGE mode** (re-resolves Mojang metadata every - **packwiz only serves metadata** — `.pw.toml` points at Modrinth/CF CDNs;
start, issue #2340). Pre-bake then switch to `TYPE=CUSTOM`. server and clients fetch jars from there. The internet is assumed present.
- **packwiz only serves metadata** — `.pw.toml` points at Modrinth CDN. Offline - **Fjord launcher** has authlib-injector built in → no manual JVM agent on
needs jars mirrored onto Caddy + URL rewrite. clients.
- **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 ## Target folder layout
``` ```
. .
├── docker-compose.yml ├── docker-compose.yml # the entire stack (no overrides)
├── .env / .env.example # BASE_DOMAIN, HOST_LAN_IP, RCON_PASSWORD ├── .env / .env.example # BASE_DOMAIN, RCON_PASSWORD, CADDY_*, LE block
├── caddy/Caddyfile # {$BASE_DOMAIN} vhosts + landing page ├── caddy/conf.d/ # per-vhost snippets (00-core, 10-static, …)
├── dnsmasq/dnsmasq.conf.tmpl # wildcard template ├── nginx/ulicraft-caddy.conf.tmpl # host nginx → caddy template (TLS terminator)
├── certs/<name>/{cert,key}.pem # Let's Encrypt output (gitignored)
├── drasl/config/ ├── drasl/config/
│ ├── config.toml.tmpl # envsubst source │ ├── config.toml.tmpl # render-config.sh source
│ └── config.toml # generated (gitignored) │ └── config.toml # generated (gitignored)
├── nmsr/ # avatar renderer config + Dockerfile context
├── docker/nmsr/ # nmsr image build context
├── pack/ # packwiz source of truth ├── pack/ # packwiz source of truth
├── mirror/ # vendored mod jars + launcher releases (gitignored) ├── launcher/ # vendored Fjord launcher releases (gitignored)
├── runtime/ # authlib-injector.jar (legacy; may be unused w/ Fjord) ├── runtime/ # authlib-injector.jar
├── tooling/ ├── tooling/ # render/fetch/issue scripts (see 08-tooling.md)
│ ├── render-config.sh # envsubst templates → real configs, halt if unset
│ ├── mirror-mods.sh # vendor mod jars + rewrite .pw.toml URLs
│ ├── mirror-assets.sh # mirror MC asset objects for offline clients (10)
│ └── fetch-launcher.sh # download all Fjord release assets
├── landing/ # Astro+TS landing source (build → www/) ├── landing/ # Astro+TS landing source (build → www/)
│ ├── astro.config.mjs # outDir ../www ├── www/ # landing build output — caddy apex (gitignored)
│ ├── public/logo.png # stone 3D logo
│ └── src/{data/site.ts,pages/index.astro}
├── www/ # caddy landing page — GENERATED by landing build (gitignored)
├── backups/ ├── backups/
└── plan/ # these files └── plan/ # these files
``` ```
## Service plan files ## Service plan files
- `01-dnsmasq.md` — LAN wildcard DNS - `02-caddy.md` — internal router (vhost conf.d snippets) behind host nginx
- `02-caddy.md` — ingress + local CDN + landing page
- `03-drasl.md` — auth (password login) - `03-drasl.md` — auth (password login)
- `04-packwiz.md` — modpack source + mod mirror - `04-packwiz.md` — modpack source
- `05-minecraft.md` — itzg NeoForge server + offline launch - `05-minecraft.md` — itzg NeoForge server
- `06-launcher.md` — Fjord launcher fetch + distribution - `06-launcher.md` — Fjord launcher fetch + distribution
- `07-mc-backup.md` — world backups - `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/ - `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 - `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 ## 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 ## Summary
Single HTTP ingress for the stack. Three roles: caddy is the stack's **internal HTTP router**, sitting behind the host's own
1. **Reverse proxy**`auth.ulicraft.local` → drasl. nginx (which terminates TLS — see `15-letsencrypt.md`). caddy is published only
2. **Local CDN / static**`packwiz.ulicraft.local` serves pack metadata AND the on a localhost-only port (`CADDY_HTTP_BIND=127.0.0.1`, `CADDY_HTTP_PORT=8880`);
mirrored mod jars (offline installs). nginx reverse-proxies every public vhost to it by Host header. Roles:
3. **Landing page** — apex `ulicraft.local` serves launcher downloads + guest guide.
Plain `:80` for now (LAN). TLS via step-ca deferred. Image: `caddy:alpine`. 1. **Reverse proxy**`auth.` → drasl, `avatar.` → nmsr, `status.` → uptime-kuma.
Caddy natively reads `{$BASE_DOMAIN}` env placeholders — no template render needed. 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) ## Network aliases (internal resolution)
@@ -19,51 +22,41 @@ caddy:
mcnet: mcnet:
aliases: aliases:
- "auth.${BASE_DOMAIN}" - "auth.${BASE_DOMAIN}"
- "packwiz.${BASE_DOMAIN}" - "pack.${BASE_DOMAIN}"
``` ```
Lets `minecraft` reach `http://auth.ulicraft.local/authlib-injector` and Lets `minecraft` reach `http://auth.ulicraft.net/authlib-injector` and
`http://packwiz.ulicraft.local/pack.toml` without dnsmasq. `http://pack.ulicraft.net/pack.toml` from inside the stack.
## Caddyfile sketch ## conf.d snippets
``` The Caddyfile imports per-vhost snippets from `caddy/conf.d/`:
{
auto_https off
}
http://{$BASE_DOMAIN} { - `00-core.caddy``auth.` → drasl, `pack.``/srv/pack` (browse).
root * /srv/www - `10-static.caddy` — apex landing (`/srv/www`) + `/launcher/*` (`/srv/launcher`).
file_server - `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} { All vhosts are plain `http://…`; TLS is nginx's job. Mounts: `./www:/srv/www:ro`,
reverse_proxy drasl:25585 `./pack:/srv/pack:ro`, `./launcher:/srv/launcher:ro`,
} `${DISTRIBUTION_WEB_ROOT}:/srv/distribution:ro`.
http://packwiz.{$BASE_DOMAIN} {
root * /srv/pack
file_server browse
}
```
Mounts: `./www:/srv/www:ro`, `./pack:/srv/pack:ro`, `./mirror/launcher:/srv/www/launcher:ro`.
## Notes / risks ## Notes / risks
- drasl behind a proxy: docs don't detail `X-Forwarded-*` trust. On plain HTTP LAN - drasl behind a proxy: with matching `BaseURL` and nginx terminating TLS it is
with matching `BaseURL` it should be fine; revisit when TLS is added. fine; nginx passes the original Host through to caddy → drasl.
- `file_server browse` gives a directory listing — handy for debugging the pack. - `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 ## Tasks
- [ ] Add `caddy` service to `docker-compose.yml`, ports `80:80` (+`443` later) - [ ] `caddy` service in `docker-compose.yml`, published `127.0.0.1:${CADDY_HTTP_PORT}:80`
- [ ] Pass `BASE_DOMAIN` into caddy env - [ ] Pass `BASE_DOMAIN` + `DISTRIBUTION_WEB_ROOT` into caddy env
- [ ] Create `caddy/Caddyfile` with apex + auth + packwiz vhosts - [ ] Caddyfile imports `conf.d/*.caddy`
- [ ] Add network aliases for `auth.` and `packwiz.` subdomains - [ ] Network aliases for `auth.` and `pack.` subdomains
- [ ] Mount `./www`, `./pack`, `./mirror/launcher` - [ ] Mount `./www`, `./pack`, `./launcher`, `${DISTRIBUTION_WEB_ROOT}`
- [ ] Confirm reverse_proxy to drasl works (auth web UI loads, Yggdrasil API responds) - [ ] Confirm reverse_proxy to drasl works (auth web UI loads, Yggdrasil API responds)
- [ ] Verify internal alias resolution from `minecraft` container (curl pack.toml) - [ ] Verify internal alias resolution from `minecraft` container (curl pack.toml)
- [ ] Air-gap mirror: add per-host `tls internal` vhosts + aliases + mounts (see 11) - [ ] Verify nmsr + uptime-kuma proxied (`avatar.` renders, `status.` loads)
- [ ] 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

View File

@@ -4,7 +4,7 @@
Self-hosted, Yggdrasil-compatible auth + skin server. Drop-in Mojang replacement Self-hosted, Yggdrasil-compatible auth + skin server. Drop-in Mojang replacement
via authlib-injector. Image: `unmojang/drasl:latest`. Reached only through Caddy 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 **OIDC/Keycloak is OUT for now** — drasl runs in **password-login** mode
(`AllowPasswordLogin = true`). Admin + guests register with a username/password on (`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 - **1.21+ secure profile**: drasl `SignPublicKeys = false` MUST pair with server
`ENFORCE_SECURE_PROFILE=FALSE`. Linked — both or neither. `ENFORCE_SECURE_PROFILE=FALSE`. Linked — both or neither.
- **authlib endpoint** = `BaseURL` + `/authlib-injector` - **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. Network alias makes this identical inside/outside the stack.
- **`Domain` affects skins** — if wrong, authlib clients may not see skins. - **`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 ## Summary
@@ -7,30 +7,21 @@ directory is the **single source of truth** for mods, consumed by both:
- the **server** (itzg `PACKWIZ_URL`), and - the **server** (itzg `PACKWIZ_URL`), and
- **clients** (Fjord launcher imports the pack / packwiz-installer). - **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 (`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 ## Behaviors confirmed
- **Prune**: packwiz-installer tracks a manifest and **removes** client mods no - **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.) 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 — - **Download source**: jars come from the CDN URLs in `.pw.toml`, not the pack
unless URLs are rewritten to the local mirror. host (which serves only metadata).
- **itzg side filtering**: itzg downloads **server-side mods only**. Client-only - **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 mods MUST be tagged `side = "client"` (not `both`) or the server may pull and
crash on them. 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 ## NeoForge pinning
`pack.toml` pins MC `1.21.1` + NeoForge `21.1.x`. Verify exact NeoForge build `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) - [ ] Curate mod shortlist (perf/tech/magic/storage/QoL/worldgen/map/voice)
- [ ] Tag client-only mods `side = "client"` - [ ] Tag client-only mods `side = "client"`
- [ ] `packwiz refresh`; verify `index.toml` committed (clients fetch it) - [ ] `packwiz refresh`; verify `index.toml` committed (clients fetch it)
- [ ] Write `tooling/mirror-mods.sh` (vendor jars + rewrite URLs + refresh) - [ ] Point server `PACKWIZ_URL=http://pack.ulicraft.net/pack.toml`
- [ ] Point server `PACKWIZ_URL=http://packwiz.ulicraft.local/pack.toml` - [ ] Test install end-to-end on a fresh client
- [ ] `.gitignore` the vendored `./mirror/` jars; keep `./pack/*.toml` tracked
- [ ] Test offline install end-to-end (cut internet, install on a fresh client)

View File

@@ -1,39 +1,30 @@
# Minecraft server — itzg NeoForge + offline launch # Minecraft server — itzg NeoForge
## Summary ## Summary
`itzg/minecraft-server:java21`, NeoForge 1.21.1, offline-mode + authlib-injector `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 pointing at drasl. Mods via `PACKWIZ_URL`. itzg installs MC + NeoForge +
version metadata on every boot** (issue #2340), so it cannot air-gap-boot in libraries + server mods on first boot and re-syncs on restart; the stack assumes
`TYPE=NEOFORGE` mode. Solved with a pre-bake + custom-launch switch. 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 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):** First boot installs MC + NeoForge + libraries + server mods into the `mc_data`
Switch to `TYPE=CUSTOM` + `CUSTOM_SERVER=<container path>` pointing at the volume. Subsequent boots re-sync the pack against `PACKWIZ_URL`.
already-installed NeoForge launcher in `/data`. A **container path (not URL)**
means no download, no Mojang resolution → boots air-gapped, survives restarts.
> OPEN: NeoForge 1.21.1 launches via `@libraries/.../unix_args.txt` / `run.sh`, ## Auth / config
> not a single fat jar. Exact `CUSTOM_SERVER` target + any `EXTRA_ARGS` must be
> verified against a real pre-baked volume. Tracked as a task.
## Auth / config (carry-over)
``` ```
ONLINE_MODE=FALSE ONLINE_MODE=FALSE
ENFORCE_SECURE_PROFILE=FALSE # pairs with drasl SignPublicKeys=false ENFORCE_SECURE_PROFILE=FALSE # pairs with drasl SignPublicKeys=false
JVM_OPTS=-javaagent:/extras/authlib-injector.jar=http://auth.${BASE_DOMAIN}/authlib-injector 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.) (Server still needs the agent even though Fjord clients have it built in.)
Memory: `INIT_MEMORY 4G`, `MAX_MEMORY 10G`, `USE_AIKAR_FLAGS TRUE`. Memory: `INIT_MEMORY 4G`, `MAX_MEMORY 10G`, `USE_AIKAR_FLAGS TRUE`.
@@ -41,12 +32,9 @@ RCON enabled for mc-backup.
## Tasks ## Tasks
- [ ] Compose: switch authlib URL to `http://auth.${BASE_DOMAIN}/authlib-injector` - [ ] Compose: authlib URL `http://auth.${BASE_DOMAIN}/authlib-injector`
- [ ] Compose: replace `MODRINTH_PROJECTS` with `PACKWIZ_URL` (packwiz subdomain) - [ ] Compose: `PACKWIZ_URL=http://pack.${BASE_DOMAIN}/pack.toml`
- [ ] Mount renamed `./runtime` (was `./extras`) for authlib-injector.jar - [ ] Mount `./runtime` for authlib-injector.jar at `/extras`
- [ ] `depends_on`: drasl + caddy (pack served before server installs) - [ ] `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` - [ ] 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. modpack, and play.
We host the launcher installers for **all platforms** on the landing page We host the launcher installers for **all platforms** on the landing page
(`ulicraft.local`), served by Caddy from `./mirror/launcher/`. A script downloads (`ulicraft.net`), served by Caddy from `./launcher/` (mounted at `/srv/launcher`,
the latest release assets ahead of time so the party is fully offline. reachable at `/launcher/`). A script downloads the release assets ahead of time.
## Release assets (latest = 11.0.2.0) ## Release assets (latest = 11.0.2.0)
@@ -26,12 +26,12 @@ Setup `.exe`, macOS `.dmg`, Linux `.AppImage`.
```bash ```bash
#!/usr/bin/env 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). # Halts on any error or ambiguity (no silent partial downloads).
set -euo pipefail set -euo pipefail
REPO="hero-persson/FjordLauncherUnlocked" REPO="hero-persson/FjordLauncherUnlocked"
DEST_ROOT="$(dirname "$0")/../mirror/launcher" DEST_ROOT="$(dirname "$0")/../launcher"
API="https://api.github.com/repos/${REPO}/releases/latest" API="https://api.github.com/repos/${REPO}/releases/latest"
command -v curl >/dev/null || { echo "curl required"; exit 1; } 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}" 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) ### Stable symlinks (so landing links survive version bumps)
The landing page (`09-landing.md`) links to **fixed** filenames, not versioned The landing page (`09-landing.md`) links to **fixed** filenames, not versioned
ones. After downloading, `fetch-launcher.sh` must create these symlinks under 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-windows-setup.exe` → Windows Setup `.exe` (x64)
- `fjord-macos.dmg` → macOS `.dmg` - `fjord-macos.dmg` → macOS `.dmg`
@@ -78,22 +78,19 @@ sync with `landing/src/data/site.ts`.**
## Per-guest flow ## 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 2. Add account: authlib-injector type, URL
`http://auth.ulicraft.local/authlib-injector`, drasl username/password. `https://auth.ulicraft.net/authlib-injector`, drasl username/password.
3. (Offline) Settings → APIs → **Assets Server** = 3. Import the modpack (packwiz URL `https://pack.ulicraft.net/pack.toml`,
`https://assets.ulicraft.local/` so the heavy asset download comes from the LAN
mirror (see 10-assets-mirror.md). Requires trusting the local CA.
4. Import the modpack (packwiz URL `http://packwiz.ulicraft.local/pack.toml`,
or a shared instance zip). or a shared instance zip).
5. Connect to `mc.ulicraft.local:25565`. 4. Connect to `mc.ulicraft.net:25565`.
## Tasks ## Tasks
- [ ] Create `tooling/fetch-launcher.sh` (above); `chmod +x` - [ ] Create `tooling/fetch-launcher.sh` (above); `chmod +x`
- [ ] Extend script: create stable symlinks (win/mac/linux) in `mirror/launcher/latest/` - [ ] Extend script: create stable symlinks (win/mac/linux) in `launcher/latest/`
- [ ] Run it pre-party while online; verify all assets land in `./mirror/launcher/<tag>/` - [ ] Run it pre-party; verify all assets land in `./launcher/<tag>/`
- [ ] `.gitignore` `./mirror/` (done) - [ ] `.gitignore` `./launcher/` (done)
- [ ] Landing page DONE — `landing/` Astro project, builds to `www/` (see 09-landing.md) - [ ] Landing page DONE — `landing/` Astro project, builds to `www/` (see 09-landing.md)
- [ ] Confirm Fjord authlib-injector account flow against drasl - [ ] Confirm Fjord authlib-injector account flow against drasl
- [ ] Decide whether to ship a pre-built instance zip vs packwiz URL import - [ ] 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 ## Summary
Three scripts in `./tooling/` turn the two env vars + templates into a working, Scripts in `./tooling/` turn `BASE_DOMAIN` + templates into a working stack and
offline-capable stack. All are **cautious**: they halt on unset vars or missing handle ingress (nginx vhost, TLS certs) and launcher/font fetches. All are
inputs rather than emit half-broken output. **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 ## render-config.sh
Renders every `*.tmpl` into its real config by substituting `BASE_DOMAIN` / Renders every `*.tmpl` into its real config by substituting **only**
`HOST_LAN_IP`. Caddy is excluded (it reads `{$BASE_DOMAIN}` natively). `${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: Targets:
- `drasl/config/config.toml.tmpl` `drasl/config/config.toml` - `drasl/config/config.toml.tmpl``drasl/config/config.toml`
- `dnsmasq/dnsmasq.conf.tmpl``dnsmasq/dnsmasq.conf` - `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 ```bash
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
[ -f .env ] && set -a && . ./.env && set +a cd "$(dirname "$0")/.."
: "${BASE_DOMAIN:?BASE_DOMAIN unset}" [ -f .env ] && { set -a; . ./.env; set +a; }
: "${HOST_LAN_IP:?HOST_LAN_IP unset}" : "${BASE_DOMAIN:?BASE_DOMAIN unset (set in .env)}"
render() { # $1=tmpl $2=out render() { # $1=tmpl $2=out
[ -f "$1" ] || { echo "missing template: $1"; exit 1; } [ -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" echo "rendered $2"
} }
render drasl/config/config.toml.tmpl drasl/config/config.toml 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 ## render-nginx.sh
> belongs to the target file (e.g. dnsmasq/caddy literals).
## 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. ## issue-letsencrypt.sh
See `04-packwiz.md`. Outline:
1. For each `pack/mods/*.pw.toml`, read `[download] url` + `filename`.
2. Download jar → `pack/mods-files/` (served by Caddy under packwiz subdomain).
3. Rewrite `url` to `http://packwiz.${BASE_DOMAIN}/mods-files/<filename>`.
4. `packwiz refresh` to recompute hashes + `index.toml`.
Must halt if a download fails or a hash mismatches.
## mirror-assets.sh 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`.
Mirrors Minecraft asset objects for offline clients (the ~400 MB+ chunk) and lays
them out for Caddy + the Fjord "Assets Server" override. Full design in
`10-assets-mirror.md`. Verifies SHA1, idempotent, halts on mismatch.
## fetch-launcher.sh ## fetch-launcher.sh
Downloads all FjordLauncherUnlocked release assets → `mirror/launcher/<tag>/`. Downloads FjordLauncherUnlocked release assets → `launcher/<tag>/` and maintains
Full script in `06-launcher.md`. a `latest` symlink + stable per-OS filename symlinks. Full script in
`06-launcher.md`.
## Generated / gitignored ## Generated / gitignored
``` ```
drasl/config/config.toml # rendered drasl/config/config.toml # rendered
dnsmasq/dnsmasq.conf # rendered nmsr/config.toml # rendered
pack/mods-files/ # vendored jars launcher/ # launcher assets
mirror/ # launcher assets + mod mirror www/ # landing build output
certs/ # Let's Encrypt certs
.env # secrets + host-specific .env # secrets + host-specific
``` ```
## Tasks ## Tasks
- [ ] Write `tooling/render-config.sh` (above), `chmod +x` - [ ] Write `tooling/render-config.sh` (above), `chmod +x`
- [ ] Write `tooling/mirror-mods.sh` (toml parse + download + rewrite + refresh) - [ ] Write `tooling/render-pack.sh` (pack templates + index)
- [ ] Write `tooling/mirror-assets.sh` (see 10) - [ ] Write `tooling/render-nginx.sh` (see 15)
- [ ] Write `tooling/issue-letsencrypt.sh` (see 15)
- [ ] Write `tooling/fetch-launcher.sh` (see 06) - [ ] Write `tooling/fetch-launcher.sh` (see 06)
- [ ] Add `gettext` (envsubst) + `jq` to host prereqs doc - [ ] Add `gettext` (envsubst) + `jq` to host prereqs doc
- [ ] Update `.gitignore` for rendered configs + `mirror/` + `pack/mods-files/` - [ ] Update `.gitignore` for rendered configs + `launcher/` + `www/` + `certs/`
- [ ] `build-stack.sh` orchestrator (preflight + `--prep` online + `--up` offline) — DONE, sequences the sub-scripts; see plan/12

View File

@@ -2,17 +2,18 @@
## Summary ## 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 + download launcher → add account → import modpack → connect. Built with **Astro +
TypeScript** in `./landing/`; `pnpm run build` emits straight into `./www/`, TypeScript** in `./landing/`; `pnpm run build` emits straight into `./www/`,
which Caddy/nginx serve at the apex. No runtime framework — output is plain which Caddy serves at the apex (behind host nginx). No runtime framework —
HTML/CSS plus one tiny vanilla script (copy-to-clipboard + scroll reveal). 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", The visual is the **"Carved from stone"** pixel design ("Claude Design",
`landing/design/`): dark overworld palette, MC-GUI bevel buttons, pixel type, `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 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 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 ## Stack & wiring
@@ -20,8 +21,8 @@ modded-LAN content and offline-safe assets.
- `astro.config.mjs``outDir: "../www"`. Build overwrites `www/`; `www/` is - `astro.config.mjs``outDir: "../www"`. Build overwrites `www/`; `www/` is
gitignored (generated artifact). gitignored (generated artifact).
- `BASE_DOMAIN` env read at build time in `src/data/site.ts` - `BASE_DOMAIN` env read at build time in `src/data/site.ts`
(`process.env.BASE_DOMAIN ?? "ulicraft.local"`). Bake before deploy: (`process.env.BASE_DOMAIN ?? "ulicraft.net"`). Bake before deploy:
`BASE_DOMAIN=ulicraft.local pnpm run build`. `BASE_DOMAIN=ulicraft.net pnpm run build`.
- Logo: wide wordmark from the design at `landing/public/logo.png` (hero + favicon). - Logo: wide wordmark from the design at `landing/public/logo.png` (hero + favicon).
`image-rendering: pixelated` keeps it crisp. `image-rendering: pixelated` keeps it crisp.
@@ -31,7 +32,7 @@ landing/
├── design/ # "Claude Design" prototype (reference, not built) ├── design/ # "Claude Design" prototype (reference, not built)
├── public/ ├── public/
│ ├── logo.png # hero wordmark (from design/assets/ulicraft-logo.png) │ ├── 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/ └── src/
├── data/site.ts # single source: content + theme knobs ├── data/site.ts # single source: content + theme knobs
├── styles/main.css # ported design system (mood/hero/bevels) ├── styles/main.css # ported design system (mood/hero/bevels)
@@ -39,10 +40,11 @@ landing/
└── pages/index.astro # composition + copy/reveal script + dust └── 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. The design pulled fonts from Google Fonts CDN; we vendor them instead to keep the
`tooling/fetch-fonts.sh` downloads the woff2 + a URL-rewritten `fonts.css` into 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 `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` script only if the font set changes (keep families in sync with `main.css`
`--font-*`): Pixelify Sans, Space Grotesk, Press Start 2P, Silkscreen, VT323. `--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) ## Languages (i18n)
Three locales, static, build-time, offline-safe**English** (default, `/`), Three locales, static, build-time — **English** (default, `/`),
**Spanish** (`/es/`), **Euskera** (`/eu/`). **Spanish** (`/es/`), **Euskera** (`/eu/`).
- One template `src/pages/[...lang].astro` with `getStaticPaths` emits all three - 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 - Features ×4: kitchen-sink pack · self-hosted Drasl identity+skins · one-click
packwiz · built-to-last (homelab + 6h backups). packwiz · built-to-last (homelab + 6h backups).
- How to Join ×4: **1** Fjord launcher (3 OS) → **2** authlib account - How to Join ×4: **1** Fjord launcher (3 OS) → **2** authlib account
(`auth.${BASE_DOMAIN}/authlib-injector`) + register link + CA-cert note (`auth.${BASE_DOMAIN}/authlib-injector`) + register link →
**3** packwiz import (`packwiz.${BASE_DOMAIN}/pack.toml`) → **4** connect. **3** packwiz import (`pack.${BASE_DOMAIN}/pack.toml`) → **4** connect.
- Footer: Mojang trademark disclaimer. - Footer: Mojang trademark disclaimer.
## Launcher download links (Option A — stable symlinks) ## 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` - `fjord-linux-x86_64.AppImage`
`tooling/fetch-launcher.sh` must create these as symlinks under `tooling/fetch-launcher.sh` must create these as symlinks under
`mirror/launcher/latest/` pointing at the real versioned assets. **Keep the names `launcher/latest/` pointing at the real versioned assets. **Keep the names in
in sync** between `site.ts` and the script. Mounted so they resolve at sync** between `site.ts` and the script. Mounted so they resolve at
`/launcher/latest/…` (see nginx/Caddy ingress). `/launcher/latest/…` (see Caddy `10-static.caddy`).
## Tasks ## 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] Port design → static Astro (components + main.css + index.astro)
- [x] Real content in `site.ts`; theme knobs replace TweaksPanel - [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] 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 - [ ] 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: try `mood: nether` / `hero: split` for party day
- [ ] Optional: wire real player count (option C) via mc-monitor JSON if wanted - [ ] 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 # Build order — one commit per task
Dependency-ordered. Each numbered item = one git commit. `main` currently has Dependency-ordered. Each numbered item = one git commit. Operational steps
**zero commits**; item 0 is the baseline. Operational steps (pre-bake, capture, (prep, acceptance test) need internet/hardware and are NOT commits — marked
acceptance test) need internet/hardware and are NOT commits — marked `[ops]`. `[ops]`.
> Commit message convention: Conventional Commits, scope = service. Footer: > 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 ## Phase 0 — baseline
0. `chore: baseline scaffolding`existing untracked tree (CLAUDE.md, plan/, 0. `chore: baseline scaffolding`CLAUDE.md, plan/, docker-compose.yml,
current docker-compose.yml, extras/, .gitignore, .env.example). runtime/, .gitignore, .env.example.
## Phase 1 — config foundation ## Phase 1 — config foundation
1. `feat(env): add BASE_DOMAIN + HOST_LAN_IP to .env.example` 1. `feat(env): add BASE_DOMAIN + RCON_PASSWORD + CADDY_* to .env.example`
2. `feat(tooling): add render-config.sh (envsubst, halt on unset var)` 2. `feat(tooling): add render-config.sh (envsubst BASE_DOMAIN, halt on unset)`
## Phase 2 — DNS + ingress (no app yet, but the backbone) ## Phase 2 — ingress (caddy internal router)
3. `feat(dnsmasq): add dnsmasq.conf.tmpl (wildcard *.BASE_DOMAIN)` 3. `feat(caddy): add conf.d snippets (00-core auth+pack, 10-static apex+launcher)`
4. `feat(caddy): add Caddyfile (apex + auth + packwiz vhosts)` 4. `feat(compose): add caddy service (127.0.0.1:CADDY_HTTP_PORT) + network aliases`
5. `feat(compose): add dnsmasq + caddy services + network aliases`
## Phase 3 — auth ## Phase 3 — auth
6. `feat(drasl): add config.toml.tmpl (password login, SignPublicKeys=false)` 5. `feat(drasl): add config.toml.tmpl (password login, SignPublicKeys=false)`
7. `refactor(compose): drasl internal-only behind caddy; authlib URL → auth.` 6. `refactor(compose): drasl internal-only behind caddy; authlib URL → auth.`
## Phase 4 — modpack ## Phase 4 — modpack
8. `feat(pack): packwiz init + initial mod curation` 7. `feat(pack): packwiz init + initial mod curation`
9. `feat(tooling): add mirror-mods.sh (vendor jars + rewrite .pw.toml URLs)` 8. `feat(tooling): add render-pack.sh + add-custom-mod.sh`
10. `refactor(compose): minecraft via PACKWIZ_URL; extras→runtime` 9. `refactor(compose): minecraft via PACKWIZ_URL`
## Phase 5 — landing ## Phase 5 — landing
11. `feat(landing): Astro+TS onboarding site → www/` (files already drafted) 10. `feat(landing): Astro+TS onboarding site → www/`
12. `chore(landing): wire logo + .gitignore for www/ + build output` 11. `chore(landing): wire logo + .gitignore for www/`
## Phase 6 — launcher distribution ## 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) ## Phase 7 — extra services
14. `feat(tooling): add mirror-airgap.sh (capture + fetch + sha1 verify)` 13. `feat(avatar): add nmsr service (Drasl-backed) + 40-avatar.caddy`
15. `feat(dnsmasq): add air-gap spoof records for upstream hosts` 14. `feat(status): add uptime-kuma service + 50-status.caddy`
16. `feat(caddy): add tls internal vhosts for upstream mirror hosts` 15. `feat(distribution): add 30-distribution.caddy (static, another repo)`
## Phase 8 — backups + orchestration ## Phase 8 — backups
17. `feat(compose): add mc-backup service (RCON, 6h)` 16. `feat(compose): add mc-backup service (RCON, 6h)`
18. `feat(tooling): add build-stack.sh orchestrator`
## Phase 9 — operational (no commits) ## Phase 9 — production ingress / TLS
- [ops] Run `build-stack.sh --prep` online: render configs, build landing, 17. `feat(nginx): add ulicraft-caddy.conf.tmpl + render-nginx.sh`
mirror mods, mirror air-gap, fetch launcher, **pre-bake** server volume. 18. `feat(tls): add issue-letsencrypt.sh (OVH DNS-01) + LE env block`
- [ops] Offline acceptance test: clean machine, internet cut, LAN DNS = dnsmasq,
Caddy CA trusted → install Fjord, import pack, join. ## Phase 10 — operational (no commits)
- [ops] If green: tag `v1-lan-ready`. - [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 ## Notes
- Items 35 can be smoke-tested (`docker compose up dnsmasq caddy`) before later - Bring-up is a single command — no run modes, no override compose files.
phases exist — Caddy serves a 404/landing, dnsmasq resolves.
- Items 1416 depend on a working online launch to capture the URL set first.
- Each commit should leave the tree in a non-broken state (compose still parses). - 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) | | Host | `cochi` (SSH alias) |
| Path | `/home/ubuntu/mc/ulicraft-server-v1` | | Path | `/home/ubuntu/mc/ulicraft-server-v1` |
| Branch | `main` | | Branch | `main` |
| Run mode | `online` (compose files: `docker-compose.yml` + `docker-compose.static.yml`) | | Compose | single `docker-compose.yml` (no overrides) |
| Auth | Drasl (base stack — no Blessing Skin override) | | Auth | Drasl (password login) |
| Restart | **hard** (`compose down``build-stack.sh --up`) — brief full downtime | | 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 ## Routine redeploy
@@ -17,9 +18,9 @@ Production host for the Ulicraft stack.
ssh cochi ssh cochi
cd /home/ubuntu/mc/ulicraft-server-v1 cd /home/ubuntu/mc/ulicraft-server-v1
git pull --ff-only git pull --ff-only
# hard restart: tear the stack down, then bring it back up (renders configs first) tooling/render-config.sh # re-render drasl/nmsr/pack configs from .env
docker compose -f docker-compose.yml -f docker-compose.static.yml down --remove-orphans docker compose down --remove-orphans
tooling/build-stack.sh --up online docker compose up -d --build
``` ```
Or as a one-shot from your workstation: Or as a one-shot from your workstation:
@@ -28,19 +29,20 @@ Or as a one-shot from your workstation:
ssh cochi 'set -e ssh cochi 'set -e
cd /home/ubuntu/mc/ulicraft-server-v1 cd /home/ubuntu/mc/ulicraft-server-v1
git pull --ff-only git pull --ff-only
docker compose -f docker-compose.yml -f docker-compose.static.yml down --remove-orphans tooling/render-config.sh
tooling/build-stack.sh --up online' docker compose down --remove-orphans
docker compose up -d --build'
``` ```
`build-stack.sh --up online` runs preflight (checks `.env`), `render-config.sh` `render-config.sh` re-renders configs (and `render-pack.sh` for the packwiz
(which renders configs + `render-pack.sh` for the packwiz pack), then pack) from `.env` before the stack comes back up.
`docker compose ... up -d` with the online file set.
## What survives a hard restart ## What survives a hard restart
Named volumes persist — world and auth data are safe: Named volumes persist — world and auth data are safe:
- `mc_data` — world + installed NeoForge/mods - `mc_data` — world + installed NeoForge/mods
- `drasl_state` — Drasl accounts/skins - `drasl_state` — Drasl accounts/skins
- `kuma_data` — Uptime Kuma config/history
- `backups/` — mc-backup snapshots - `backups/` — mc-backup snapshots
`down` removes **containers**, not volumes. Players are disconnected during the `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`) ## Prerequisites (one-time, on `cochi`)
- SSH access as the `cochi` alias. - SSH access as the `cochi` alias.
- `.env` present and complete: `BASE_DOMAIN`, `HOST_LAN_IP`, `RCON_PASSWORD` - `.env` present and complete: `BASE_DOMAIN`, `RCON_PASSWORD`, `CADDY_HTTP_PORT`,
(`render-config.sh` halts on any unset var, even in online mode). `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`. - Docker + compose plugin, `packwiz`, `envsubst`, `git`.
- **First deploy only** — heavy online prep (fetch launcher, pre-bake the server - Host nginx + Let's Encrypt certs installed once — see `15-letsencrypt.md`.
volume, etc.): - **First deploy only** — online prep (build landing, fetch launcher, fetch
```bash authlib into `runtime/`). Not needed for routine redeploys.
tooling/build-stack.sh --prep
```
Not needed for routine redeploys; `--up` alone re-syncs the pack and configs.
## Verify ## Verify
```bash ```bash
docker compose -f docker-compose.yml -f docker-compose.static.yml ps docker compose ps # caddy, drasl, minecraft, mc-backup, nmsr, uptime-kuma up
docker compose -f docker-compose.yml -f docker-compose.static.yml logs -f minecraft # watch for "Done" docker compose logs -f minecraft # watch for "Done"
curl -fsS "http://auth.$(grep '^BASE_DOMAIN=' .env | cut -d= -f2)/" >/dev/null && echo "auth ok" curl -fsS "https://auth.$(grep '^BASE_DOMAIN=' .env | cut -d= -f2)/" >/dev/null && echo "auth ok"
``` ```
## Rollback ## Rollback
@@ -72,15 +72,14 @@ curl -fsS "http://auth.$(grep '^BASE_DOMAIN=' .env | cut -d= -f2)/" >/dev/null &
```bash ```bash
git log --oneline -5 # find the last-good commit git log --oneline -5 # find the last-good commit
git checkout <sha> # or: git reset --hard <sha> git checkout <sha> # or: git reset --hard <sha>
docker compose -f docker-compose.yml -f docker-compose.static.yml down --remove-orphans docker compose down --remove-orphans
tooling/build-stack.sh --up online docker compose up -d --build
``` ```
## Notes ## Notes
- **Online mode needs internet** on `cochi` at restart (no air-gap mirror): the - Production needs internet on `cochi` at restart: the server installs/re-syncs
server installs NeoForge + mods over the network. For an internet-less deploy, NeoForge + mods over the network.
switch to `airgap` (needs `--prep` first) — see `plan/13-dns-and-run-modes.md`. - All ingress goes through host nginx → caddy. After a redeploy, nginx config is
- Switching to the **Blessing Skin** auth variant changes the file set to unchanged; only re-run `tooling/render-nginx.sh --install` if `BASE_DOMAIN` /
`-f docker-compose.yml -f docker-compose.blessingskin.yml` (see `CADDY_HTTP_PORT` changed (see `15-letsencrypt.md`).
`plan/03b-blessing-skin.md`); update the `down`/`up` commands accordingly.

View File

@@ -1,25 +1,17 @@
# TLS — Let's Encrypt (OVH DNS-01) + host nginx ingress # TLS — Let's Encrypt (OVH DNS-01) + host nginx ingress
Production serves real, publicly-trusted certs via the **host's nginx**. Caddy is Production serves real, publicly-trusted certs via the **host's own nginx**.
no longer the production ingress — it moved to its own compose file for the nginx terminates TLS and reverse-proxies every vhost to the caddy container
LAN/air-gap path. (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
## Two ingress paths path — there are no compose overrides.
| 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.
## Certs: one per name, via OVH DNS-01 ## Certs: one per name, via OVH DNS-01
`tooling/issue-letsencrypt.sh` issues a **separate** cert for the apex and each `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 subdomain (`${BASE_DOMAIN}`, `auth.…`, `pack.…`, plus the rest of
challenge — no inbound ports needed, so it works even on a LAN-only host. `LE_SUBDOMAINS`) using acme.sh + the OVH DNS-01 challenge — no inbound ports
needed, so it works regardless of public reachability.
### One-time setup ### One-time setup
@@ -36,7 +28,7 @@ challenge — no inbound ports needed, so it works even on a LAN-only host.
### Issue ### Issue
```bash ```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 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 ## 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 ```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 ```bash
APP_DIR=/home/ubuntu/mc/ulicraft-server-v1 BASE_DOMAIN=ulicraft.net \ tooling/render-nginx.sh --install
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
``` ```
nginx then: apex + `pack.*` as static files (`www`, `pack`, `custom`), This substitutes `$BASE_DOMAIN $APP_DIR $CADDY_HTTP_PORT`, writes to
`auth.*` → drasl. Ensure `www-data` can read `$APP_DIR/{www,pack,custom,certs}`. `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 ## DNS
Point the public/party DNS for `${BASE_DOMAIN}`, `auth.${BASE_DOMAIN}`, Point the public DNS for `${BASE_DOMAIN}` and every subdomain
`pack.${BASE_DOMAIN}` at the host. The Minecraft container reaches them via (`auth.` `pack.` `avatar.` `status.` `distribution.` `www.`) at the host running
`extra_hosts: host-gateway` (set in `docker-compose.nginx.yml`) and uses HTTPS — nginx. DNS is configured **outside this repo**. The Minecraft container reaches
the JVM trusts Let's Encrypt with no CA import (the big win over the internal CA). the stack's own names via caddy's `mcnet` aliases, not public DNS.
## Why DNS-01 (not HTTP-01) ## 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 — 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 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. # offline LAN hosting, then create stable-name symlinks for the landing page.
# #
# Guests use FjordLauncherUnlocked (Prism/PolyMC fork with authlib-injector # 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/… # 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 # 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 # 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 # 0 or >1 candidate it prints the candidates and exits non-zero rather than
# guessing. Idempotent — re-runs overwrite symlinks and re-download safely. # 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 set -euo pipefail
REPO="hero-persson/FjordLauncherUnlocked" REPO="hero-persson/FjordLauncherUnlocked"
DEST_ROOT="$(dirname "$0")/../mirror/launcher" DEST_ROOT="$(dirname "$0")/../launcher"
API="https://api.github.com/repos/${REPO}/releases/latest" API="https://api.github.com/repos/${REPO}/releases/latest"
command -v curl >/dev/null || { echo "curl required" >&2; exit 1; } command -v curl >/dev/null || { echo "curl required" >&2; exit 1; }

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

View File

@@ -1,22 +1,21 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# render-nginx.sh — render an nginx vhost template with the .env values. # render-nginx.sh — render the nginx vhost template with the .env values.
# #
# Substitutes $BASE_DOMAIN, $APP_DIR, $CADDY_HTTP_PORT into a template and # Substitutes $BASE_DOMAIN, $APP_DIR, $CADDY_HTTP_PORT into
# prints the result to stdout, or installs it with --install (writes to # nginx/ulicraft-caddy.conf.tmpl (the host nginx that terminates TLS in front of
# sites-available, symlinks sites-enabled, tests, reloads nginx). # caddy) and prints the result to stdout, or installs it with --install (writes
# to sites-available, symlinks sites-enabled, tests, reloads nginx).
# #
# Usage: # Usage:
# tooling/render-nginx.sh # render caddy-front template -> stdout # tooling/render-nginx.sh # render -> stdout
# tooling/render-nginx.sh --install # render + install + reload nginx # tooling/render-nginx.sh --install # render + install + reload nginx
# TEMPLATE=nginx/ulicraft.conf.tmpl tooling/render-nginx.sh # the static variant
# #
# .env vars used: # .env vars used:
# BASE_DOMAIN required # BASE_DOMAIN required
# CADDY_HTTP_PORT caddy-front template only (localhost port nginx proxies to) # CADDY_HTTP_PORT localhost port nginx reverse-proxies to (caddy)
# optional: # optional:
# APP_DIR host path to this repo (cert files live under APP_DIR/certs) # APP_DIR host path to this repo (cert files live under APP_DIR/certs)
# — defaults to the repo root (this checkout) # — defaults to the repo root (this checkout)
# TEMPLATE template to render (default nginx/ulicraft-caddy.conf.tmpl)
# SITE_NAME installed filename (default ulicraft.conf) # SITE_NAME installed filename (default ulicraft.conf)
set -euo pipefail set -euo pipefail

View File

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