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