# 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.<name>.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.<domain>` 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/<name>/{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/<player>`,
  `/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
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.
