# Ulicraft Server

Self-hosted modded Minecraft (NeoForge 1.21.1) with self-hosted auth/skins
(Drasl), a packwiz modpack, avatar rendering, a guest landing page, and uptime
monitoring. One unified `docker-compose.yml` runs the whole stack behind the
host's nginx + Let's Encrypt.

## Stack

One compose file, one `docker compose up -d`:

| Service | Image | Role |
|---|---|---|
| `minecraft` | itzg/minecraft-server | NeoForge 1.21.1 server, `:25565` |
| `drasl` | unmojang/drasl | Yggdrasil auth + skins (password login) |
| `caddy` | caddy:alpine | 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 |

Vhosts (all proxied through the host nginx → caddy):

- apex `${BASE_DOMAIN}` — landing page + `/launcher/` downloads
- `auth.` — Drasl
- `pack.` — packwiz metadata + `/custom/` jars
- `avatar.` — NMSR
- `status.` — Uptime Kuma
- `distribution.` — static site from another repo (`DISTRIBUTION_WEB_ROOT`)

Config lives in `.env`: `BASE_DOMAIN`, `RCON_PASSWORD`, `CADDY_HTTP_PORT` /
`CADDY_HTTP_BIND`, `DISTRIBUTION_WEB_ROOT`. See `.env.example`.

## DNS

Handled **outside this repo**. Point `${BASE_DOMAIN}` and every subdomain
(`auth. pack. avatar. status. distribution. www.`) at the host running nginx.

## Prerequisites

Docker + Docker Compose; for prep also `pnpm`, `packwiz`, `jq`, `curl`,
`envsubst`.

## Launch

```sh
cp .env.example .env            # set BASE_DOMAIN, RCON_PASSWORD, DISTRIBUTION_WEB_ROOT

# One-time / on change — render configs + build content:
tooling/render-config.sh        # drasl + nmsr + packwiz configs from *.tmpl
( cd landing && pnpm install && BASE_DOMAIN="$BASE_DOMAIN" pnpm run build )   # → www/
tooling/fetch-launcher.sh       # FjordLauncher assets → launcher/ (served at /launcher/)
tooling/fetch-authlib.sh        # authlib-injector.jar → runtime/ (server JVM agent)

docker compose up -d --build    # first run installs NeoForge + mods, builds nmsr
docker compose down             # stop
```

First boot: itzg installs NeoForge + the packwiz mods into the `mc_data` volume;
nmsr does a one-time Rust compile. Watch `docker compose logs -f minecraft` until
`Done`.

## Public TLS (host nginx in front of caddy)

caddy is published on a localhost-only port (`CADDY_HTTP_BIND=127.0.0.1`,
`CADDY_HTTP_PORT=8880`). The host's own nginx terminates TLS with Let's Encrypt
certs and reverse-proxies every vhost to caddy by Host header. HTTPS-only: HTTP
301s to HTTPS and every TLS vhost sends HSTS.

1. **Issue certs** (OVH DNS-01, no inbound ports needed):
   ```sh
   # .env: BASE_DOMAIN, LE_EMAIL, OVH_* creds,
   #   LE_SUBDOMAINS="auth pack distribution www avatar status"
   tooling/issue-letsencrypt.sh        # → certs/<name>/{cert,key}.pem
   ```

2. **Render + install the nginx vhost** with `tooling/render-nginx.sh`. It pulls
   `BASE_DOMAIN` + `CADDY_HTTP_PORT` from `.env`, defaults `APP_DIR` to the repo
   checkout (where `certs/` live), and renders `nginx/ulicraft-caddy.conf.tmpl`:
   ```sh
   tooling/render-nginx.sh                       # preview to stdout (dry run)
   tooling/render-nginx.sh --install             # write, symlink, nginx -t, reload
   ```
   Override `APP_DIR=/path` if the repo isn't at the cert location. Re-run
   whenever you add a subdomain so its server block is rendered.

## Avatar renderer (NMSR)

`avatar.${BASE_DOMAIN}` renders players' skins/avatars via
[NMSR-aas](https://github.com/NickAcPT/nmsr-rs), sourced from **Drasl** (not
Mojang). No upstream image exists, so it's built from source — `docker/nmsr/`
pins a commit; bump `ARG NMSR_REF` to update.

- **Config**: `nmsr/config.toml.tmpl` → rendered to `nmsr/config.toml` by
  `tooling/render-config.sh`. `[mojank]` points every Mojang endpoint at
  `http://auth.${BASE_DOMAIN}`; Drasl serves the Mojang-compatible routes at its
  bare BaseURL and embeds absolute skin URLs that NMSR fetches directly.
  `allow_offline_mode_uuids = true` (Drasl issues offline v3 UUIDs).
- **Network**: skin resolution stays internal over `mcnet` (caddy alias
  `auth.${BASE_DOMAIN}` → drasl). caddy reverse-proxies `avatar.` → `nmsr:8080`.
- **Endpoints**: e.g. `https://avatar.${BASE_DOMAIN}/skin/<player>`,
  `/face/<player>`, `/fullbody/<player>` — by username or UUID.

First build is heavy (Rust compile). Headless rendering uses lavapipe (software
Vulkan); if renders fail, check `docker logs nmsr` for Vulkan device init.

## Status monitoring (Uptime Kuma)

`status.${BASE_DOMAIN}` runs [Uptime Kuma](https://github.com/louislam/uptime-kuma)
— uptime probes for every vhost **and** a native Minecraft-protocol ping (player
count + up/down), plus a public status page. It joins `mcnet`, so monitors can
probe the stack by internal service name. caddy reverse-proxies `status.` →
`uptime-kuma:3001`.

First run, open `https://status.${BASE_DOMAIN}`, create the admin account, and
add monitors. Kuma has no config-as-code — add these once via the UI (data
persists in the `kuma_data` volume):

| Monitor | Type | Target |
|---|---|---|
| Minecraft | Minecraft Server | `minecraft` : `25565` |
| Drasl auth | HTTP(s) | `https://auth.${BASE_DOMAIN}` |
| Landing (apex) | HTTP(s) | `https://${BASE_DOMAIN}` |
| Packwiz | HTTP(s) | `https://pack.${BASE_DOMAIN}` |
| Distribution | HTTP(s) | `https://distribution.${BASE_DOMAIN}` |
| Avatar (NMSR) | HTTP(s) | `https://avatar.${BASE_DOMAIN}` |

Internal-name targets (`minecraft`, `drasl:25585`, `nmsr:8080`) isolate "service
down" from "ingress/TLS/DNS down"; public-URL targets test the whole chain.
`status` is in the default `LE_SUBDOMAINS`; the nginx vhost adds websocket
upgrade headers for Kuma's live UI.

## Join (guests)

1. Open `https://${BASE_DOMAIN}`, download FjordLauncherUnlocked for your OS.
2. Add an **authlib-injector** account, URL
   `https://auth.${BASE_DOMAIN}/authlib-injector` (register/login at
   `https://auth.${BASE_DOMAIN}`).
3. Import the pack: `https://pack.${BASE_DOMAIN}/pack.toml`.
4. Connect to **`${BASE_DOMAIN}`** (Minecraft, `:25565`).

## Layout

```
docker-compose.yml                    # the whole stack (one file)
caddy/Caddyfile + conf.d/*.caddy      # ingress vhost snippets
                                      #   (core/static/distribution/avatar/status)
drasl/config/config.toml.tmpl         # auth config (rendered)
nmsr/config.toml.tmpl                 # avatar renderer config, Drasl-backed (rendered)
docker/nmsr/                          # built-from-source NMSR image
pack/                                 # packwiz modpack source
landing/                              # Astro site → www/
launcher/                             # FjordLauncher assets (gitignored)
tooling/                             # render-config, render-pack, render-nginx,
                                      #   issue-letsencrypt, fetch-launcher, add-custom-mod
nginx/ulicraft-caddy.conf.tmpl        # host-nginx TLS vhost template (front of caddy)
plan/                                 # design docs
```

See `plan/00-overview.md` for the full design.
