Compare commits
18 Commits
2c3e4cf385
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 08fe8c9c53 | |||
| 10b86ae23e | |||
| cd79b0e540 | |||
| 25df9f9398 | |||
| 1958a96acf | |||
| e45ce210ba | |||
| d12071df04 | |||
| a67b9cf59e | |||
| d303d23d18 | |||
| 614e47598b | |||
| 69871c8a93 | |||
| 015bf7fbe3 | |||
| d0bc81f0f7 | |||
| 35be2e6b76 | |||
| 33b9ebc7f6 | |||
| b9929da54d | |||
| 36c28362bc | |||
| 07a4ae469d |
@@ -14,7 +14,7 @@ Redeploy the running stack on the production host. Full reference:
|
|||||||
- Path: `/home/ubuntu/mc/ulicraft-server-v1`
|
- Path: `/home/ubuntu/mc/ulicraft-server-v1`
|
||||||
- Branch: `main`
|
- Branch: `main`
|
||||||
- Stack: single unified `docker-compose.yml` (drasl, minecraft, mc-backup,
|
- Stack: single unified `docker-compose.yml` (drasl, minecraft, mc-backup,
|
||||||
caddy, nmsr, uptime-kuma)
|
caddy, nmsr, mc-status, uptime-kuma, filestash)
|
||||||
- Auth: Drasl
|
- Auth: Drasl
|
||||||
- Restart: **hard** (down → up) — players are disconnected for a few minutes
|
- Restart: **hard** (down → up) — players are disconnected for a few minutes
|
||||||
|
|
||||||
@@ -31,6 +31,18 @@ invoking this skill is the authorization to proceed, but:
|
|||||||
already up to date and ask whether to restart anyway (they may want a plain
|
already up to date and ask whether to restart anyway (they may want a plain
|
||||||
restart). If `git fetch` fails (host unreachable, auth), STOP and report.
|
restart). If `git fetch` fails (host unreachable, auth), STOP and report.
|
||||||
|
|
||||||
|
**If the incoming commits ADD A SERVICE, check `.env` on the host first.**
|
||||||
|
A new service with `${FOO:?...}` guards (filestash uses them) fails the
|
||||||
|
**whole compose file**, not just that service — so a missing var means
|
||||||
|
`up` refuses to start *anything*, and a `--hard` deploy has already run
|
||||||
|
`down`. The stack stays down. Check before tearing it down:
|
||||||
|
```bash
|
||||||
|
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && grep -c "^FILES_" .env'
|
||||||
|
```
|
||||||
|
Same for a new **subdomain**: it needs a cert (`LE_SUBDOMAINS` +
|
||||||
|
`issue-letsencrypt.sh`) and an nginx vhost — see step 2b, which the deploy
|
||||||
|
does NOT do for you.
|
||||||
|
|
||||||
2. **Pull, ensure authlib, then `update-all.sh --hard`** (single SSH session,
|
2. **Pull, ensure authlib, then `update-all.sh --hard`** (single SSH session,
|
||||||
halts on any error):
|
halts on any error):
|
||||||
```bash
|
```bash
|
||||||
@@ -58,16 +70,31 @@ invoking this skill is the authorization to proceed, but:
|
|||||||
landing rebuild is unconditional (the gitignored `www/` is never updated by
|
landing rebuild is unconditional (the gitignored `www/` is never updated by
|
||||||
`git pull`), so no separate landing step is needed.
|
`git pull`), so no separate landing step is needed.
|
||||||
|
|
||||||
|
2b. **Only if the deploy changed `nginx/ulicraft-caddy.conf.tmpl`** (a new
|
||||||
|
subdomain, a new body-size/rate-limit rule, a changed `CADDY_HTTP_PORT`).
|
||||||
|
`update-all.sh` does **not** touch the host nginx, so a new vhost simply
|
||||||
|
won't exist and the subdomain 404s / hits the wrong server block:
|
||||||
|
```bash
|
||||||
|
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && tooling/render-nginx.sh --install'
|
||||||
|
```
|
||||||
|
It runs `nginx -t` before reloading, so a bad render fails safe. The cert
|
||||||
|
must already exist (`certs/<name>/cert.pem`) or nginx won't load the block —
|
||||||
|
issue it first with `tooling/issue-letsencrypt.sh` (reads `LE_SUBDOMAINS`).
|
||||||
|
|
||||||
3. **Verify.**
|
3. **Verify.**
|
||||||
```bash
|
```bash
|
||||||
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && docker compose ps'
|
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && docker compose ps'
|
||||||
```
|
```
|
||||||
Confirm `caddy`, `drasl`, `minecraft`, `mc-backup`, `nmsr`, `uptime-kuma` are
|
Confirm `caddy`, `drasl`, `minecraft`, `mc-backup`, `nmsr`, `mc-status`,
|
||||||
Up. Tail the minecraft log briefly and confirm it reaches `Done` (server
|
`uptime-kuma`, `filestash` are Up. Tail the minecraft log briefly and confirm
|
||||||
ready):
|
it reaches the ready marker (match `Done (Ns)! For help` — a bare `Done`
|
||||||
|
also matches unrelated mod-loading lines):
|
||||||
```bash
|
```bash
|
||||||
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && timeout 120 docker compose logs -f minecraft' 2>/dev/null | grep -m1 "Done"
|
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && timeout 180 docker compose logs -f minecraft' 2>/dev/null | grep -m1 'Done ([0-9.]*s)! For help'
|
||||||
```
|
```
|
||||||
|
If a public vhost changed, verify it end-to-end from your workstation rather
|
||||||
|
than trusting `compose ps` (a container can be Up while nginx never routes to
|
||||||
|
it): `curl -s -o /dev/null -w '%{http_code}' https://<vhost>/`.
|
||||||
|
|
||||||
## Guardrails
|
## Guardrails
|
||||||
|
|
||||||
@@ -81,6 +108,18 @@ invoking this skill is the authorization to proceed, but:
|
|||||||
`docker compose up -d --force-recreate caddy`, then verify with
|
`docker compose up -d --force-recreate caddy`, then verify with
|
||||||
`docker compose exec caddy cat /etc/caddy/conf.d/<file>`. The `--hard` path
|
`docker compose exec caddy cat /etc/caddy/conf.d/<file>`. The `--hard` path
|
||||||
(full `down`/`up`) already recreates, so it's unaffected.
|
(full `down`/`up`) already recreates, so it's unaffected.
|
||||||
|
- **Same trap for `filestash/config.json`** — also a single-file bind mount, and
|
||||||
|
it is *re-rendered on every run* by `render-config.sh`, so a rolling
|
||||||
|
`update-all.sh` leaves filestash reading the stale inode after any `FILES_*`
|
||||||
|
change (rotating the shared password, flipping `FILES_AUTH`). Apply with
|
||||||
|
`docker compose up -d --force-recreate filestash`. `--hard` is unaffected.
|
||||||
|
- **Never let `filestash/config.json` be missing when compose runs.** It is
|
||||||
|
gitignored and rendered; if it doesn't exist, Docker creates a *directory* at
|
||||||
|
that path and filestash breaks in a confusing way. `update-all.sh` renders
|
||||||
|
before `up`, so the normal path is safe — just don't hand-run `docker compose
|
||||||
|
up` on the host without rendering first. If it happened: `rm -rf
|
||||||
|
filestash/config.json && tooling/render-config.sh && docker compose up -d
|
||||||
|
--force-recreate filestash`.
|
||||||
- Volumes (`mc_data`, `drasl_state`, `kuma_data`) persist across `down`; the
|
- Volumes (`mc_data`, `drasl_state`, `kuma_data`) persist across `down`; the
|
||||||
world and monitors are safe. Do 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
|
||||||
|
|||||||
48
.env.example
48
.env.example
@@ -49,6 +49,52 @@ CADDY_HTTP_BIND=127.0.0.1
|
|||||||
# static site — it lives in ANOTHER repo. Bind-mounted read-only into caddy.
|
# static site — it lives in ANOTHER repo. Bind-mounted read-only into caddy.
|
||||||
DISTRIBUTION_WEB_ROOT=/home/oier/projects/mc-mods/ulicraft-group/ulicraft-distribution/dist
|
DISTRIBUTION_WEB_ROOT=/home/oier/projects/mc-mods/ulicraft-group/ulicraft-distribution/dist
|
||||||
|
|
||||||
|
# ── files.${BASE_DOMAIN} — Filestash player file share ────────────────
|
||||||
|
# Screenshots / schematics / maps. ONE shared login for all players, writable.
|
||||||
|
# Full design + gotchas: plan/20-files.md.
|
||||||
|
|
||||||
|
# Host path holding the shared files. Keep it OUTSIDE the repo — guest-writable
|
||||||
|
# data must not live in a git tree we `git pull` into.
|
||||||
|
FILES_DATA_DIR=/srv/ulicraft-files
|
||||||
|
|
||||||
|
# Literal mount flag for FILES_DATA_DIR: rw | ro. `ro` makes the share
|
||||||
|
# read-only at the KERNEL, regardless of what the Filestash UI thinks. It is a
|
||||||
|
# mount flag rather than a boolean because compose has no conditionals — the
|
||||||
|
# value is substituted straight into the volume string.
|
||||||
|
FILES_MOUNT_MODE=rw
|
||||||
|
|
||||||
|
# Auth middleware (rendered into config.json by render-config.sh):
|
||||||
|
# htpasswd one shared user/pass — the only internet-safe value
|
||||||
|
# passthrough NO LOGIN. Anyone reaching the vhost is in.
|
||||||
|
# none alias of passthrough
|
||||||
|
# passthrough/none on a WRITABLE, internet-reachable share is an open upload
|
||||||
|
# relay (malware/phishing hosted on your domain, under your cert, with your
|
||||||
|
# bandwidth — and a domain blocklisting would take auth. and the apex down
|
||||||
|
# with it). Only set it once this host is unreachable from the internet.
|
||||||
|
FILES_AUTH=htpasswd
|
||||||
|
|
||||||
|
# The shared player credential (FILES_AUTH=htpasswd).
|
||||||
|
# FILES_PASSWORD_HASH: openssl passwd -6 '<password>'
|
||||||
|
# MUST be $6$ (sha512). The htpasswd plugin's bcrypt branch only accepts $2a$,
|
||||||
|
# so a $2y$ hash from `htpasswd -B` fails EVERY login, silently.
|
||||||
|
# render-config.sh rejects the wrong format rather than shipping it.
|
||||||
|
FILES_USER=uli
|
||||||
|
FILES_PASSWORD_HASH=
|
||||||
|
|
||||||
|
# Filestash /admin console password — bcrypt, and /admin IS publicly exposed, so
|
||||||
|
# use a 20+ char generated password. It is the only credential guarding config.
|
||||||
|
# docker run --rm caddy:alpine caddy hash-password --plaintext '<password>'
|
||||||
|
FILES_ADMIN_PASSWORD_HASH=
|
||||||
|
|
||||||
|
# Session key (openssl rand -hex 16). Must be non-empty: an empty secret_key
|
||||||
|
# makes filestash rewrite config.json at boot, which fails on the :ro mount.
|
||||||
|
FILES_SECRET_KEY=
|
||||||
|
|
||||||
|
# Unlocks the local storage backend, which filestash admin-gates: it refuses to
|
||||||
|
# init unless the connection params carry this secret. config.json supplies it
|
||||||
|
# server-side; players never see it. openssl rand -hex 24.
|
||||||
|
FILES_LOCAL_SECRET=
|
||||||
|
|
||||||
# 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
|
||||||
|
|
||||||
@@ -56,7 +102,7 @@ DISTRIBUTION_WEB_ROOT=/home/oier/projects/mc-mods/ulicraft-group/ulicraft-distri
|
|||||||
# Only needed to issue real TLS certs. DNS-01 needs no inbound ports.
|
# Only needed to issue real TLS certs. DNS-01 needs no inbound ports.
|
||||||
LE_EMAIL=you@example.com
|
LE_EMAIL=you@example.com
|
||||||
# space-separated subdomains; apex ${BASE_DOMAIN} is always included
|
# space-separated subdomains; apex ${BASE_DOMAIN} is always included
|
||||||
LE_SUBDOMAINS=auth distribution www avatar status
|
LE_SUBDOMAINS=auth distribution www avatar status files
|
||||||
# OVH API creds (DNS zone write). Create at https://eu.api.ovh.com/createToken/
|
# OVH API creds (DNS zone write). Create at https://eu.api.ovh.com/createToken/
|
||||||
# OVH_CK can be blank on first run — acme.sh prints an auth URL, then paste it.
|
# OVH_CK can be blank on first run — acme.sh prints an auth URL, then paste it.
|
||||||
OVH_END_POINT=ovh-eu
|
OVH_END_POINT=ovh-eu
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -6,6 +6,9 @@ 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
|
||||||
|
# holds the admin bcrypt hash, the shared player hash, the session key and the
|
||||||
|
# local-backend secret — only the .tmpl is tracked
|
||||||
|
filestash/config.json
|
||||||
|
|
||||||
# server mod subset synced from the distribution repo by
|
# server mod subset synced from the distribution repo by
|
||||||
# tooling/sync-server-mods.sh (source of truth is mods-sides.json + $DIST jars)
|
# tooling/sync-server-mods.sh (source of truth is mods-sides.json + $DIST jars)
|
||||||
|
|||||||
38
CLAUDE.md
38
CLAUDE.md
@@ -32,10 +32,11 @@ Internet ── host nginx (TLS, LE certs, HSTS) ──► caddy (127.0.0.1:8880
|
|||||||
auth.${BASE_DOMAIN} ─► drasl (Yggdrasil API + web UI)
|
auth.${BASE_DOMAIN} ─► drasl (Yggdrasil API + web UI)
|
||||||
avatar.${BASE_DOMAIN} ─► nmsr (skin/avatar renderer, Drasl-backed)
|
avatar.${BASE_DOMAIN} ─► nmsr (skin/avatar renderer, Drasl-backed)
|
||||||
status.${BASE_DOMAIN} ─► uptime-kuma (status page + monitors)
|
status.${BASE_DOMAIN} ─► uptime-kuma (status page + monitors)
|
||||||
|
files.${BASE_DOMAIN} ─► filestash (player file share, ONE shared login, writable)
|
||||||
distribution.${BASE} ─► static site from ANOTHER repo (DISTRIBUTION_WEB_ROOT)
|
distribution.${BASE} ─► static site from ANOTHER repo (DISTRIBUTION_WEB_ROOT)
|
||||||
|
|
||||||
minecraft :25565 (+ 24454/udp Simple Voice Chat)
|
minecraft :25565 (+ 24454/udp Simple Voice Chat)
|
||||||
└ ONLINE_MODE=false, -javaagent authlib-injector → http://auth.${BASE_DOMAIN}/authlib-injector
|
└ ONLINE_MODE=true (validated against Drasl, NOT offline), -javaagent authlib-injector → http://auth.${BASE_DOMAIN}/authlib-injector
|
||||||
└ mods from ./server-mods volume (/mods, REMOVE_OLD_MODS) — see plan/04-mods.md
|
└ mods from ./server-mods volume (/mods, REMOVE_OLD_MODS) — see plan/04-mods.md
|
||||||
mc-backup ── RCON → minecraft (6h interval, prune 14d, world-only)
|
mc-backup ── RCON → minecraft (6h interval, prune 14d, world-only)
|
||||||
```
|
```
|
||||||
@@ -67,6 +68,14 @@ via the `deploy` skill.
|
|||||||
- Drasl: `SignPublicKeys = false`
|
- Drasl: `SignPublicKeys = false`
|
||||||
- Linked. Drasl docs: *"Mixed authentication does not work with
|
- Linked. Drasl docs: *"Mixed authentication does not work with
|
||||||
`SignPublicKeys = true` on Minecraft 1.21+."*
|
`SignPublicKeys = true` on Minecraft 1.21+."*
|
||||||
|
- **This is the ONLY auth flag that goes off. `ONLINE_MODE` stays TRUE** — see
|
||||||
|
next gotcha. Do not conflate "secure profile off" with "offline mode".
|
||||||
|
|
||||||
|
### ONLINE_MODE must be TRUE (authlib-injector ≠ offline)
|
||||||
|
authlib-injector redirects the server's normal **online**-auth handshake from
|
||||||
|
Mojang to Drasl. `ONLINE_MODE=FALSE` skips that handshake → offline-hash UUIDs,
|
||||||
|
no Drasl session validation, and **no skins** (everyone joins but everyone is
|
||||||
|
Steve; `usercache.json` shows offline-hash UUIDs). Keep `ONLINE_MODE=TRUE`.
|
||||||
|
|
||||||
### authlib-injector JVM agent
|
### authlib-injector JVM agent
|
||||||
- Syntax: `-javaagent:/extras/authlib-injector.jar=<yggdrasil-api-url>`
|
- Syntax: `-javaagent:/extras/authlib-injector.jar=<yggdrasil-api-url>`
|
||||||
@@ -97,6 +106,29 @@ automatically; cosmetic-client mods that declare `BOTH` stay `both` until you
|
|||||||
hand-edit `mods-sides.json` to `client`. No Modrinth/packwiz, no network at
|
hand-edit `mods-sides.json` to `client`. No Modrinth/packwiz, no network at
|
||||||
classify/sync time. See `plan/04-mods.md`.
|
classify/sync time. See `plan/04-mods.md`.
|
||||||
|
|
||||||
|
### Filestash (files.) — four traps
|
||||||
|
Full list in `plan/20-files.md`; the ones that cost real time:
|
||||||
|
- **`general.host` is a BARE HOSTNAME** (`files.${BASE_DOMAIN}`, no scheme) +
|
||||||
|
`force_ssl: true`. The SPA builds its origin as `scheme + host`, so a URL there
|
||||||
|
yields `http://https://files...` and every client-side redirect dies. The
|
||||||
|
server strips the scheme before its own Host check, so **curl and the uptime
|
||||||
|
monitor stay green while every browser is broken**. Cost a debugging round on
|
||||||
|
2026-07-14.
|
||||||
|
- The `local` backend is **admin-gated**: without `LOCAL_BACKEND_SECRET` in the
|
||||||
|
connection params, every login fails with `backend error - Not Allowed` (looks
|
||||||
|
like bad credentials, isn't).
|
||||||
|
- `FILES_PASSWORD_HASH` must be **`$6$`** (`openssl passwd -6`). A `$2y$` bcrypt
|
||||||
|
from `htpasswd -B` fails every login silently — the plugin only matches `$2a$`.
|
||||||
|
- `config.json` is rendered + mounted `:ro` (the admin console can't save, on
|
||||||
|
purpose). Single-file bind mount ⇒ **`--force-recreate` after re-rendering**,
|
||||||
|
never `restart`.
|
||||||
|
|
||||||
|
**Verifying an SPA:** a 200 on `/` proves the server is up, not that the app
|
||||||
|
works. After any filestash config change, check the browser-facing values
|
||||||
|
(`curl /api/config` → `origin`) and re-read `docker compose logs filestash` —
|
||||||
|
the broken redirect announced itself there (`msg=Redirecting to http://https://`)
|
||||||
|
while every other check passed.
|
||||||
|
|
||||||
### Memory sizing
|
### Memory sizing
|
||||||
~50–100 mods, 8 players, 1.21.1: `INIT_MEMORY: 4G`, `MAX_MEMORY: 10G`,
|
~50–100 mods, 8 players, 1.21.1: `INIT_MEMORY: 4G`, `MAX_MEMORY: 10G`,
|
||||||
`USE_AIKAR_FLAGS: TRUE`.
|
`USE_AIKAR_FLAGS: TRUE`.
|
||||||
@@ -155,6 +187,10 @@ Full design settled across WS1–WS6; all tasks unchecked, ready to execute.
|
|||||||
distribution forgemods (`tooling/build-modlist.py`), flat alphabetical, pretty
|
distribution forgemods (`tooling/build-modlist.py`), flat alphabetical, pretty
|
||||||
names, no categories/links; feeds the "50+" stat tile.
|
names, no categories/links; feeds the "50+" stat tile.
|
||||||
- [ ] Server-side perf mods (C2ME, etc.).
|
- [ ] Server-side perf mods (C2ME, etc.).
|
||||||
|
- [x] **Filestash file share** (`files.`, 2026-07-14) — live in prod. One shared
|
||||||
|
htpasswd login, writable, rendered `:ro` config. See `plan/20-files.md`.
|
||||||
|
Follow-ups: add the Uptime Kuma HTTP monitor for `files.`; the share is
|
||||||
|
**outside mc-backup** and unquota'd (accepted — eyeballed).
|
||||||
- [ ] **Verify NeoForge version** against current stable before first deploy.
|
- [ ] **Verify NeoForge version** against current stable before first deploy.
|
||||||
- [x] **Bootstrap Drasl admin** account (`admin`, 2026-06-10). Key is
|
- [x] **Bootstrap Drasl admin** account (`admin`, 2026-06-10). Key is
|
||||||
`DefaultAdmins` (not `DefaultAdminUsernames`); first admin needs an invite-flip.
|
`DefaultAdmins` (not `DefaultAdminUsernames`); first admin needs an invite-flip.
|
||||||
|
|||||||
55
README.md
55
README.md
@@ -15,24 +15,27 @@ One compose file, one `docker compose up -d`:
|
|||||||
| `drasl` | unmojang/drasl | Yggdrasil auth + skins (password login) |
|
| `drasl` | unmojang/drasl | Yggdrasil auth + skins (password login) |
|
||||||
| `caddy` | caddy:alpine | internal ingress: routes every vhost by Host header |
|
| `caddy` | caddy:alpine | internal ingress: routes every vhost by Host header |
|
||||||
| `nmsr` | built from source | skin/avatar renderer at `avatar.${BASE_DOMAIN}` |
|
| `nmsr` | built from source | skin/avatar renderer at `avatar.${BASE_DOMAIN}` |
|
||||||
|
| `mc-status` | built from source | live server-status JSON for the landing card |
|
||||||
| `uptime-kuma` | louislam/uptime-kuma | status page at `status.${BASE_DOMAIN}` |
|
| `uptime-kuma` | louislam/uptime-kuma | status page at `status.${BASE_DOMAIN}` |
|
||||||
|
| `filestash` | machines/filestash (digest-pinned) | player file share at `files.${BASE_DOMAIN}` |
|
||||||
| `mc-backup` | itzg/mc-backup | world backups every 6h via RCON |
|
| `mc-backup` | itzg/mc-backup | world backups every 6h via RCON |
|
||||||
|
|
||||||
Vhosts (all proxied through the host nginx → caddy):
|
Vhosts (all proxied through the host nginx → caddy):
|
||||||
|
|
||||||
- apex `${BASE_DOMAIN}` — landing page + `/launcher/` downloads
|
- apex `${BASE_DOMAIN}` — landing page + `/launcher/` downloads + `/api/mcstatus/*`
|
||||||
- `auth.` — Drasl
|
- `auth.` — Drasl
|
||||||
- `avatar.` — NMSR
|
- `avatar.` — NMSR
|
||||||
- `status.` — Uptime Kuma
|
- `status.` — Uptime Kuma
|
||||||
|
- `files.` — Filestash player file share (one shared login, writable)
|
||||||
- `distribution.` — static site from another repo (`DISTRIBUTION_WEB_ROOT`)
|
- `distribution.` — static site from another repo (`DISTRIBUTION_WEB_ROOT`)
|
||||||
|
|
||||||
Config lives in `.env`: `BASE_DOMAIN`, `RCON_PASSWORD`, `CADDY_HTTP_PORT` /
|
Config lives in `.env`: `BASE_DOMAIN`, `RCON_PASSWORD`, `CADDY_HTTP_PORT` /
|
||||||
`CADDY_HTTP_BIND`, `DISTRIBUTION_WEB_ROOT`. See `.env.example`.
|
`CADDY_HTTP_BIND`, `DISTRIBUTION_WEB_ROOT`, the `FILES_*` block. See `.env.example`.
|
||||||
|
|
||||||
## DNS
|
## DNS
|
||||||
|
|
||||||
Handled **outside this repo**. Point `${BASE_DOMAIN}` and every subdomain
|
Handled **outside this repo**. Point `${BASE_DOMAIN}` and every subdomain
|
||||||
(`auth. avatar. status. distribution. www.`) at the host running nginx.
|
(`auth. avatar. status. files. distribution. www.`) at the host running nginx.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
@@ -121,6 +124,7 @@ persists in the `kuma_data` volume):
|
|||||||
| Landing (apex) | HTTP(s) | `https://${BASE_DOMAIN}` |
|
| Landing (apex) | HTTP(s) | `https://${BASE_DOMAIN}` |
|
||||||
| Distribution | HTTP(s) | `https://distribution.${BASE_DOMAIN}` |
|
| Distribution | HTTP(s) | `https://distribution.${BASE_DOMAIN}` |
|
||||||
| Avatar (NMSR) | HTTP(s) | `https://avatar.${BASE_DOMAIN}` |
|
| Avatar (NMSR) | HTTP(s) | `https://avatar.${BASE_DOMAIN}` |
|
||||||
|
| Files (Filestash) | HTTP(s) | `https://files.${BASE_DOMAIN}` (expect 200 — the login page is a 200) |
|
||||||
|
|
||||||
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.
|
down" from "ingress/TLS/DNS down"; public-URL targets test the whole chain.
|
||||||
@@ -153,6 +157,51 @@ curl -H "Host: ulicraft.net" \
|
|||||||
|
|
||||||
Wired in `landing/src/data/site.ts` (`statusApi`) and `caddy/conf.d/10-static.caddy`.
|
Wired in `landing/src/data/site.ts` (`statusApi`) and `caddy/conf.d/10-static.caddy`.
|
||||||
|
|
||||||
|
## Player file share (Filestash)
|
||||||
|
|
||||||
|
`files.${BASE_DOMAIN}` is a web file share for player media — screenshots,
|
||||||
|
schematics, maps, guides. **One shared username/password for everybody**
|
||||||
|
(`FILES_USER` / `FILES_PASSWORD_HASH`), read **and** write. It is *not* a backup
|
||||||
|
browser and *not* a mod mirror: world snapshots contain every player's inventory
|
||||||
|
and coordinates, and the modset already ships via `distribution.`.
|
||||||
|
|
||||||
|
Full design, deploy steps and gotchas: **`plan/20-files.md`**. The short version:
|
||||||
|
|
||||||
|
- Config is `filestash/config.json`, **rendered from a template and mounted
|
||||||
|
`:ro`** — git is the source of truth. The admin console at `/admin` loads but
|
||||||
|
**cannot save**; that's deliberate. Change config by editing the `.tmpl`,
|
||||||
|
re-rendering, and **recreating** the container (`up -d --force-recreate
|
||||||
|
filestash` — a single-file bind mount pins the inode, so `restart` reads the
|
||||||
|
stale file).
|
||||||
|
- Files live in `${FILES_DATA_DIR}` on the host, outside the repo. **Not backed
|
||||||
|
up** by mc-backup — treat the share as disposable.
|
||||||
|
- `FILES_MOUNT_MODE=ro|rw` is the literal mount flag: `ro` makes the share
|
||||||
|
read-only at the *kernel*, whatever the UI thinks.
|
||||||
|
- `FILES_AUTH=htpasswd|passthrough` — `passthrough` means **no login at all**.
|
||||||
|
Only valid once the host is off the public internet: an unauthenticated
|
||||||
|
*writable* share on a public domain is an open upload relay.
|
||||||
|
|
||||||
|
Four traps that cost real time (all enforced or documented in the tooling):
|
||||||
|
|
||||||
|
- **`general.host` is a bare hostname**, no scheme (`files.${BASE_DOMAIN}`), with
|
||||||
|
`force_ssl: true`. The SPA builds its redirect origin as `scheme + host`, so a
|
||||||
|
URL there produces `http://https://files...` and every client-side redirect
|
||||||
|
breaks. The server strips the scheme before its *own* Host check, so `curl`
|
||||||
|
and the uptime monitor stay **green while every browser is broken**.
|
||||||
|
- **`FILES_PASSWORD_HASH` must be `$6$`** (`openssl passwd -6`). The htpasswd
|
||||||
|
plugin's bcrypt branch only matches `$2a$`, so a `$2y$` hash from
|
||||||
|
`htpasswd -B` fails *every* login, silently. `render-config.sh` rejects it.
|
||||||
|
- **The `local` storage backend is admin-gated** — without `FILES_LOCAL_SECRET`
|
||||||
|
reaching it through the connection params, every login dies with
|
||||||
|
`backend error - Not Allowed`, which looks like bad credentials and isn't.
|
||||||
|
- **Don't set `APPLICATION_URL`/`ADMIN_PASSWORD` env** — either makes Filestash
|
||||||
|
rewrite `config.json` at boot, which fails against the `:ro` mount.
|
||||||
|
|
||||||
|
> Verifying this service: a 200 on `/` only proves the server is up. Check the
|
||||||
|
> browser-facing origin — `curl -s -H 'X-Requested-With: XmlHttpRequest`
|
||||||
|
> `https://files.${BASE_DOMAIN}/api/config | jq -r .result.origin` must print
|
||||||
|
> `https://files.${BASE_DOMAIN}` — and re-read `docker compose logs filestash`.
|
||||||
|
|
||||||
## Join (guests)
|
## Join (guests)
|
||||||
|
|
||||||
1. Open `https://${BASE_DOMAIN}`, download FjordLauncherUnlocked for your OS.
|
1. Open `https://${BASE_DOMAIN}`, download FjordLauncherUnlocked for your OS.
|
||||||
|
|||||||
10
caddy/conf.d/60-files.caddy
Normal file
10
caddy/conf.d/60-files.caddy
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Files — Filestash player file share (filestash service). Shared login,
|
||||||
|
# writable. See plan/20-files.md.
|
||||||
|
#
|
||||||
|
# Filestash blocks any request whose Host header doesn't match its configured
|
||||||
|
# `general.host` (https://files.${BASE_DOMAIN}), with "only traffic from X is
|
||||||
|
# allowed". The host nginx forwards the original Host, and caddy passes it
|
||||||
|
# through untouched — do NOT rewrite it here or every request 403s.
|
||||||
|
http://files.{$BASE_DOMAIN} {
|
||||||
|
reverse_proxy filestash:8334
|
||||||
|
}
|
||||||
@@ -58,8 +58,13 @@ services:
|
|||||||
USE_AIKAR_FLAGS: "TRUE"
|
USE_AIKAR_FLAGS: "TRUE"
|
||||||
JVM_XX_OPTS: "-XX:+UseG1GC"
|
JVM_XX_OPTS: "-XX:+UseG1GC"
|
||||||
|
|
||||||
# ── Auth: offline mode + authlib-injector pointing at Drasl ─
|
# ── Auth: ONLINE mode validated against Drasl via authlib-injector ─
|
||||||
ONLINE_MODE: "FALSE"
|
# MUST be true: authlib-injector redirects the normal online-auth
|
||||||
|
# handshake from Mojang to Drasl. online-mode=false skips the handshake
|
||||||
|
# entirely -> offline UUIDs, no session validation, NO SKINS for anyone.
|
||||||
|
# Secure-profile is the part that must stay off on 1.21+ (see
|
||||||
|
# ENFORCE_SECURE_PROFILE + Drasl SignPublicKeys=false), NOT online-mode.
|
||||||
|
ONLINE_MODE: "TRUE"
|
||||||
# Resolves over mcnet to caddy (alias auth.${BASE_DOMAIN}) -> drasl.
|
# Resolves over mcnet to caddy (alias auth.${BASE_DOMAIN}) -> drasl.
|
||||||
JVM_OPTS: "-javaagent:/extras/authlib-injector.jar=https://auth.${BASE_DOMAIN}/authlib-injector"
|
JVM_OPTS: "-javaagent:/extras/authlib-injector.jar=https://auth.${BASE_DOMAIN}/authlib-injector"
|
||||||
|
|
||||||
@@ -95,6 +100,17 @@ services:
|
|||||||
- mc_data:/data
|
- mc_data:/data
|
||||||
- ./runtime:/extras:ro # authlib-injector.jar lives here
|
- ./runtime:/extras:ro # authlib-injector.jar lives here
|
||||||
- ./server-mods:/mods:ro # itzg auto-syncs /mods → /data/mods
|
- ./server-mods:/mods:ro # itzg auto-syncs /mods → /data/mods
|
||||||
|
# Distribution client-override files the server also needs, from the live
|
||||||
|
# distribution (DISTRIBUTION_WEB_ROOT), same source as caddy. Only the kubejs
|
||||||
|
# *source* subdirs are bind-mounted ro — kubejs itself writes config/, cache/,
|
||||||
|
# generated/, logs/ at boot, so the kubejs ROOT must stay writable (lives in
|
||||||
|
# mc_data). Mounting the whole kubejs dir ro makes BaseProperties.save() NPE
|
||||||
|
# and kubejs never initialises. CustomSkinLoader is ro (read-only json).
|
||||||
|
- ${DISTRIBUTION_WEB_ROOT:?DISTRIBUTION_WEB_ROOT unset in .env}/servers/ulicraft-1.21.1/files/kubejs/server_scripts:/data/kubejs/server_scripts:ro
|
||||||
|
- ${DISTRIBUTION_WEB_ROOT}/servers/ulicraft-1.21.1/files/kubejs/startup_scripts:/data/kubejs/startup_scripts:ro
|
||||||
|
- ${DISTRIBUTION_WEB_ROOT}/servers/ulicraft-1.21.1/files/kubejs/data:/data/kubejs/data:ro
|
||||||
|
- ${DISTRIBUTION_WEB_ROOT}/servers/ulicraft-1.21.1/files/kubejs/assets:/data/kubejs/assets:ro
|
||||||
|
- ${DISTRIBUTION_WEB_ROOT}/servers/ulicraft-1.21.1/files/CustomSkinLoader:/data/CustomSkinLoader:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
- drasl # authlib (auth.) must resolve at boot
|
- drasl # authlib (auth.) must resolve at boot
|
||||||
# Containers don't inherit the host's /etc/hosts; the LAN resolver returns a
|
# Containers don't inherit the host's /etc/hosts; the LAN resolver returns a
|
||||||
@@ -143,6 +159,7 @@ services:
|
|||||||
- ./caddy/conf.d/30-distribution.caddy:/etc/caddy/conf.d/30-distribution.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/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
|
- ./caddy/conf.d/50-status.caddy:/etc/caddy/conf.d/50-status.caddy:ro
|
||||||
|
- ./caddy/conf.d/60-files.caddy:/etc/caddy/conf.d/60-files.caddy:ro
|
||||||
- ./www:/srv/www:ro
|
- ./www:/srv/www:ro
|
||||||
- ./launcher:/srv/launcher:ro
|
- ./launcher:/srv/launcher:ro
|
||||||
# Static site from ANOTHER repo (absolute host path in .env).
|
# Static site from ANOTHER repo (absolute host path in .env).
|
||||||
@@ -197,6 +214,38 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- mcnet
|
- mcnet
|
||||||
|
|
||||||
|
# Player file share (screenshots, schematics, maps) at files.${BASE_DOMAIN}.
|
||||||
|
# ONE shared login for everybody (FILES_AUTH=htpasswd), writable. See
|
||||||
|
# plan/20-files.md — it documents every non-obvious bit of this service.
|
||||||
|
#
|
||||||
|
# Config is rendered read-only from filestash/config.json.tmpl: filestash has
|
||||||
|
# no env-var config, and its admin console writes config.json back at runtime,
|
||||||
|
# so pinning it :ro is what keeps git authoritative. The console still loads —
|
||||||
|
# it just cannot save. That is intentional, not a bug.
|
||||||
|
filestash:
|
||||||
|
image: machines/filestash@sha256:8cb09d42470328a02aec6e3861f912addf8a9d54ad63d9c965bcb48df5ad36b1
|
||||||
|
container_name: filestash
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
# The `local` storage backend is admin-gated in filestash: it refuses to
|
||||||
|
# init unless the connection params carry this secret (or the admin
|
||||||
|
# password). config.json's attribute_mapping supplies it server-side, so
|
||||||
|
# players never see it. Without this, every login dies on "Not Allowed".
|
||||||
|
LOCAL_BACKEND_SECRET: ${FILES_LOCAL_SECRET:?FILES_LOCAL_SECRET unset in .env}
|
||||||
|
# Do NOT set APPLICATION_URL / ADMIN_PASSWORD here: either one makes
|
||||||
|
# filestash rewrite config.json at boot, which fails on the :ro mount.
|
||||||
|
# Both live in the rendered config instead (general.host / auth.admin).
|
||||||
|
volumes:
|
||||||
|
# /app/data/state holds config + sqlite + search index + logs, so the
|
||||||
|
# DIRECTORY must stay writable. Only config.json is pinned read-only.
|
||||||
|
- filestash_state:/app/data/state
|
||||||
|
- ./filestash/config.json:/app/data/state/config/config.json:ro
|
||||||
|
# The share itself. FILES_MOUNT_MODE is the literal mount flag (ro|rw) —
|
||||||
|
# `ro` enforces a read-only share at the KERNEL, whatever the UI thinks.
|
||||||
|
- ${FILES_DATA_DIR:?FILES_DATA_DIR unset in .env}:/data/files:${FILES_MOUNT_MODE:-ro}
|
||||||
|
networks:
|
||||||
|
- mcnet
|
||||||
|
|
||||||
# Status monitoring + public status page. caddy proxies status. -> :3001.
|
# Status monitoring + public status page. caddy proxies status. -> :3001.
|
||||||
# Joins mcnet so monitors probe the stack by internal service name.
|
# Joins mcnet so monitors probe the stack by internal service name.
|
||||||
uptime-kuma:
|
uptime-kuma:
|
||||||
@@ -212,6 +261,7 @@ volumes:
|
|||||||
drasl_state:
|
drasl_state:
|
||||||
mc_data:
|
mc_data:
|
||||||
kuma_data:
|
kuma_data:
|
||||||
|
filestash_state:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
mcnet:
|
mcnet:
|
||||||
|
|||||||
31
filestash/config.json.tmpl
Normal file
31
filestash/config.json.tmpl
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"general": {
|
||||||
|
"name": "Ulicraft Files",
|
||||||
|
"host": "files.${BASE_DOMAIN}",
|
||||||
|
"force_ssl": true,
|
||||||
|
"secret_key": "${FILES_SECRET_KEY}",
|
||||||
|
"editor": "base",
|
||||||
|
"display_hidden": false,
|
||||||
|
"upload_button": true,
|
||||||
|
"fork_button": false
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"admin": "${FILES_ADMIN_PASSWORD_HASH}"
|
||||||
|
},
|
||||||
|
"middleware": {
|
||||||
|
"identity_provider": {
|
||||||
|
"type": "${FILES_IDP_TYPE}",
|
||||||
|
"params": "${FILES_IDP_PARAMS}"
|
||||||
|
},
|
||||||
|
"attribute_mapping": {
|
||||||
|
"related_backend": "files",
|
||||||
|
"params": "{\"files\":{\"type\":\"local\",\"path\":\"/data/files/\",\"password\":\"${FILES_LOCAL_SECRET}\"}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"connections": [
|
||||||
|
{
|
||||||
|
"label": "files",
|
||||||
|
"type": "local"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
// loaded via ./data/mods.ts (resilient: missing file → empty list, build never
|
// loaded via ./data/mods.ts (resilient: missing file → empty list, build never
|
||||||
// fails). Flat alphabetical grid: extracted logo (or a pixel placeholder) +
|
// fails). Flat alphabetical grid: extracted logo (or a pixel placeholder) +
|
||||||
// pretty name + version. description is exposed as a tooltip. No categories, no
|
// pretty name + version. description is exposed as a tooltip. No categories, no
|
||||||
// external links. Tiles share the MC-GUI bevel treatment used by .feat/.tile.
|
// external links. Tiles share the MC-GUI bevel treatment used by .feat.
|
||||||
import { MODS } from "../data/mods";
|
import { MODS } from "../data/mods";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const {
|
|||||||
upTo,
|
upTo,
|
||||||
onlineLabel = "Online",
|
onlineLabel = "Online",
|
||||||
offlineLabel = "Offline",
|
offlineLabel = "Offline",
|
||||||
emptyLabel = "Nobody online — be the first.",
|
emptyLabel = "Nobody online",
|
||||||
} = Astro.props;
|
} = Astro.props;
|
||||||
|
|
||||||
const { status, avatarUrl, avatarMode } = site;
|
const { status, avatarUrl, avatarMode } = site;
|
||||||
@@ -98,7 +98,6 @@ const { status, avatarUrl, avatarMode } = site;
|
|||||||
width: 72px; height: 72px; flex-shrink: 0;
|
width: 72px; height: 72px; flex-shrink: 0;
|
||||||
display: grid; place-items: center; position: relative;
|
display: grid; place-items: center; position: relative;
|
||||||
background: var(--bg-2);
|
background: var(--bg-2);
|
||||||
box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi);
|
|
||||||
}
|
}
|
||||||
.sc-favicon { width: 64px; height: 64px; image-rendering: pixelated; }
|
.sc-favicon { width: 64px; height: 64px; image-rendering: pixelated; }
|
||||||
.sc-creeper img { width: 56px; height: 56px; }
|
.sc-creeper img { width: 56px; height: 56px; }
|
||||||
@@ -207,7 +206,7 @@ const { status, avatarUrl, avatarMode } = site;
|
|||||||
players.hidden = false;
|
players.hidden = false;
|
||||||
if (real.length === 0) {
|
if (real.length === 0) {
|
||||||
if (emptyEl) {
|
if (emptyEl) {
|
||||||
emptyEl.textContent = empty ?? "Nobody online.";
|
emptyEl.textContent = empty ?? "Nobody online";
|
||||||
emptyEl.hidden = false;
|
emptyEl.hidden = false;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -5,15 +5,9 @@
|
|||||||
// @ts-ignore — node builtins (no @types/node in this Astro SSG; same reason
|
// @ts-ignore — node builtins (no @types/node in this Astro SSG; same reason
|
||||||
// `process` below is untyped). Used only at build time to read the manifest.
|
// `process` below is untyped). Used only at build time to read the manifest.
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { MODS } from "./mods";
|
|
||||||
|
|
||||||
const BASE_DOMAIN = process.env.BASE_DOMAIN ?? "ulicraft.local";
|
const BASE_DOMAIN = process.env.BASE_DOMAIN ?? "ulicraft.local";
|
||||||
|
|
||||||
// Installed-pack mod count, fed by tooling/build-modlist.py via mods.json (see
|
|
||||||
// ./mods.ts). Feeds the homepage "Mods" stat tile. When the generated catalogue
|
|
||||||
// is absent/empty (fresh checkout), fall back to the honest "50+" estimate.
|
|
||||||
const MOD_COUNT_KEY = MODS.count > 0 ? `${MODS.count}` : "50+";
|
|
||||||
|
|
||||||
// NMSR render mode for avatar tiles. AVATAR_MODE is the ServerCard online row +
|
// NMSR render mode for avatar tiles. AVATAR_MODE is the ServerCard online row +
|
||||||
// account preview (heads); ROSTER_AVATAR_MODE is the Members grid (full body by
|
// account preview (heads); ROSTER_AVATAR_MODE is the Members grid (full body by
|
||||||
// default). Read at build time, same as BASE_DOMAIN. e.g. "headiso" | "head" |
|
// default). Read at build time, same as BASE_DOMAIN. e.g. "headiso" | "head" |
|
||||||
@@ -183,15 +177,6 @@ export const site = {
|
|||||||
rosterApi: `/api/mcstatus/players`,
|
rosterApi: `/api/mcstatus/players`,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Honest stat tiles. Keys (numbers/versions) are language-neutral; labels
|
|
||||||
// come from ui.ts (same order).
|
|
||||||
stats: [
|
|
||||||
{ key: MOD_COUNT_KEY },
|
|
||||||
{ key: MC_VERSION },
|
|
||||||
{ key: "10" },
|
|
||||||
{ key: "6h" },
|
|
||||||
],
|
|
||||||
|
|
||||||
// Feature cards. glyph ∈ shield|diamond|orb|heart; gold = gold pixel fill.
|
// Feature cards. glyph ∈ shield|diamond|orb|heart; gold = gold pixel fill.
|
||||||
// Text (h/p) comes from ui.ts (same order).
|
// Text (h/p) comes from ui.ts (same order).
|
||||||
features: [
|
features: [
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ export interface UI {
|
|||||||
copyIp: string;
|
copyIp: string;
|
||||||
howToJoin: string;
|
howToJoin: string;
|
||||||
metaModpack: string;
|
metaModpack: string;
|
||||||
metaLan: string;
|
|
||||||
};
|
};
|
||||||
status: {
|
status: {
|
||||||
eyebrow: string;
|
eyebrow: string;
|
||||||
@@ -52,8 +51,8 @@ export interface UI {
|
|||||||
modded: string;
|
modded: string;
|
||||||
motd: string;
|
motd: string;
|
||||||
upTo: string;
|
upTo: string;
|
||||||
|
empty: string; // shown on the ServerCard when nobody is online
|
||||||
};
|
};
|
||||||
statLabels: [string, string, string, string];
|
|
||||||
members: {
|
members: {
|
||||||
eyebrow: string;
|
eyebrow: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -194,7 +193,6 @@ export const ui: Record<Lang, UI> = {
|
|||||||
copyIp: "Copy IP",
|
copyIp: "Copy IP",
|
||||||
howToJoin: "How to Join",
|
howToJoin: "How to Join",
|
||||||
metaModpack: "NeoForge modpack",
|
metaModpack: "NeoForge modpack",
|
||||||
metaLan: "LAN-only",
|
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
eyebrow: "Server",
|
eyebrow: "Server",
|
||||||
@@ -203,8 +201,8 @@ export const ui: Record<Lang, UI> = {
|
|||||||
modded: "Modded",
|
modded: "Modded",
|
||||||
motd: "NeoForge modpack · Simple Voice Chat · LAN-only",
|
motd: "NeoForge modpack · Simple Voice Chat · LAN-only",
|
||||||
upTo: "up to",
|
upTo: "up to",
|
||||||
|
empty: "Nobody online",
|
||||||
},
|
},
|
||||||
statLabels: ["Mods", "NeoForge", "Player slots", "World backups"],
|
|
||||||
members: {
|
members: {
|
||||||
eyebrow: "El valle",
|
eyebrow: "El valle",
|
||||||
title: "Who's on the server.",
|
title: "Who's on the server.",
|
||||||
@@ -376,7 +374,6 @@ export const ui: Record<Lang, UI> = {
|
|||||||
copyIp: "Copiar IP",
|
copyIp: "Copiar IP",
|
||||||
howToJoin: "Cómo entrar",
|
howToJoin: "Cómo entrar",
|
||||||
metaModpack: "Pack NeoForge",
|
metaModpack: "Pack NeoForge",
|
||||||
metaLan: "Solo LAN",
|
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
eyebrow: "Servidor",
|
eyebrow: "Servidor",
|
||||||
@@ -385,8 +382,8 @@ export const ui: Record<Lang, UI> = {
|
|||||||
modded: "Con mods",
|
modded: "Con mods",
|
||||||
motd: "Pack NeoForge · Simple Voice Chat · Solo LAN",
|
motd: "Pack NeoForge · Simple Voice Chat · Solo LAN",
|
||||||
upTo: "hasta",
|
upTo: "hasta",
|
||||||
|
empty: "No hay nadie conectado",
|
||||||
},
|
},
|
||||||
statLabels: ["Mods", "NeoForge", "Plazas", "Copias del mundo"],
|
|
||||||
members: {
|
members: {
|
||||||
eyebrow: "El valle",
|
eyebrow: "El valle",
|
||||||
title: "Quién está en el servidor.",
|
title: "Quién está en el servidor.",
|
||||||
@@ -558,7 +555,6 @@ export const ui: Record<Lang, UI> = {
|
|||||||
copyIp: "Kopiatu IPa",
|
copyIp: "Kopiatu IPa",
|
||||||
howToJoin: "Nola sartu",
|
howToJoin: "Nola sartu",
|
||||||
metaModpack: "NeoForge packa",
|
metaModpack: "NeoForge packa",
|
||||||
metaLan: "LAN soilik",
|
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
eyebrow: "Zerbitzaria",
|
eyebrow: "Zerbitzaria",
|
||||||
@@ -567,8 +563,8 @@ export const ui: Record<Lang, UI> = {
|
|||||||
modded: "Mod-duna",
|
modded: "Mod-duna",
|
||||||
motd: "NeoForge packa · Simple Voice Chat · LAN soilik",
|
motd: "NeoForge packa · Simple Voice Chat · LAN soilik",
|
||||||
upTo: "gehienez",
|
upTo: "gehienez",
|
||||||
|
empty: "Inor ez dago konektatuta",
|
||||||
},
|
},
|
||||||
statLabels: ["Modak", "NeoForge", "Plazak", "Munduaren babeskopiak"],
|
|
||||||
members: {
|
members: {
|
||||||
eyebrow: "El valle",
|
eyebrow: "El valle",
|
||||||
title: "Nor dago zerbitzarian.",
|
title: "Nor dago zerbitzarian.",
|
||||||
|
|||||||
@@ -111,8 +111,6 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
|
|||||||
<span>Java {site.mcVersion}</span>
|
<span>Java {site.mcVersion}</span>
|
||||||
<span class="dot"></span>
|
<span class="dot"></span>
|
||||||
<span>{t.hero.metaModpack}</span>
|
<span>{t.hero.metaModpack}</span>
|
||||||
<span class="dot"></span>
|
|
||||||
<span>{t.hero.metaLan}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-status">
|
<div class="hero-status">
|
||||||
@@ -120,6 +118,7 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
|
|||||||
modded={t.status.modded}
|
modded={t.status.modded}
|
||||||
motd={t.status.motd}
|
motd={t.status.motd}
|
||||||
upTo={t.status.upTo}
|
upTo={t.status.upTo}
|
||||||
|
emptyLabel={t.status.empty}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -178,16 +177,9 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
|
|||||||
modded={t.status.modded}
|
modded={t.status.modded}
|
||||||
motd={t.status.motd}
|
motd={t.status.motd}
|
||||||
upTo={t.status.upTo}
|
upTo={t.status.upTo}
|
||||||
|
emptyLabel={t.status.empty}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-grid">
|
|
||||||
{site.stats.map((s, i) => (
|
|
||||||
<div class="tile reveal" style={`transition-delay:${i * 60}ms`}>
|
|
||||||
<div class="k">{s.key}</div>
|
|
||||||
<div class="l">{t.statLabels[i]}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -472,22 +472,6 @@ section { position: relative; z-index: 1; }
|
|||||||
.bars i:nth-child(4){height:85%}
|
.bars i:nth-child(4){height:85%}
|
||||||
.bars i:nth-child(5){height:100%}
|
.bars i:nth-child(5){height:100%}
|
||||||
|
|
||||||
/* stat tiles */
|
|
||||||
.stat-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, 1fr);
|
|
||||||
gap: 16px;
|
|
||||||
margin-top: 28px;
|
|
||||||
}
|
|
||||||
.tile {
|
|
||||||
background: var(--surface);
|
|
||||||
padding: 22px;
|
|
||||||
box-shadow: inset 2px 2px 0 var(--bevel-hi), inset -2px -2px 0 var(--bevel-lo);
|
|
||||||
}
|
|
||||||
.tile .k { font-family: var(--font-head); font-weight: 600; font-size: 38px; color: var(--text); line-height: 1; font-variant-numeric: tabular-nums; }
|
|
||||||
.tile .k .u { color: var(--accent); font-size: 22px; }
|
|
||||||
.tile .l { margin-top: 8px; color: var(--dim); font-size: 13px; letter-spacing: 0.04em; text-transform: uppercase; font-family: var(--font-pixel); font-size: 9px; line-height: 1.8; }
|
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
PLAYER ROSTER (shared avatar tiles)
|
PLAYER ROSTER (shared avatar tiles)
|
||||||
Used by ServerCard.astro (online row) and PlayerRoster.astro
|
Used by ServerCard.astro (online row) and PlayerRoster.astro
|
||||||
@@ -650,7 +634,6 @@ section { position: relative; z-index: 1; }
|
|||||||
:root[data-head="8bit"] .step .num { font-size: 30px; }
|
:root[data-head="8bit"] .step .num { font-size: 30px; }
|
||||||
:root[data-head="8bit"] .feat h3 { font-size: 15px; line-height: 1.4; }
|
:root[data-head="8bit"] .feat h3 { font-size: 15px; line-height: 1.4; }
|
||||||
:root[data-head="8bit"] .serverlist .title { font-size: 16px; }
|
:root[data-head="8bit"] .serverlist .title { font-size: 16px; }
|
||||||
:root[data-head="8bit"] .tile .k { font-size: 26px; }
|
|
||||||
:root[data-head="8bit"] .ip-chip button { font-size: 12px; }
|
:root[data-head="8bit"] .ip-chip button { font-size: 12px; }
|
||||||
:root[data-head="8bit"] .live-pill { font-size: 12px; }
|
:root[data-head="8bit"] .live-pill { font-size: 12px; }
|
||||||
|
|
||||||
@@ -733,7 +716,6 @@ section { position: relative; z-index: 1; }
|
|||||||
@media (max-width: 880px) {
|
@media (max-width: 880px) {
|
||||||
:root[data-hero="split"] .hero-grid { grid-template-columns: 1fr; }
|
:root[data-hero="split"] .hero-grid { grid-template-columns: 1fr; }
|
||||||
:root[data-hero="split"] .hero-status { display: block; }
|
:root[data-hero="split"] .hero-status { display: block; }
|
||||||
.stat-grid { grid-template-columns: repeat(2, 1fr); }
|
|
||||||
.feat-grid { grid-template-columns: 1fr; }
|
.feat-grid { grid-template-columns: 1fr; }
|
||||||
.nav-links { display: none; }
|
.nav-links { display: none; }
|
||||||
.serverlist { flex-wrap: wrap; }
|
.serverlist { flex-wrap: wrap; }
|
||||||
@@ -743,5 +725,4 @@ section { position: relative; z-index: 1; }
|
|||||||
.step { grid-template-columns: 1fr; gap: 18px; }
|
.step { grid-template-columns: 1fr; gap: 18px; }
|
||||||
.step .num { width: 64px; height: 64px; font-size: 32px; }
|
.step .num { width: 64px; height: 64px; font-size: 32px; }
|
||||||
.ip-chip .ip-val { font-size: 22px; }
|
.ip-chip .ip-val { font-size: 22px; }
|
||||||
.stat-grid { grid-template-columns: 1fr 1fr; }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,31 +11,50 @@
|
|||||||
"EasyMagic-v21.1.4-1.21.1-NeoForge.jar": "both",
|
"EasyMagic-v21.1.4-1.21.1-NeoForge.jar": "both",
|
||||||
"EnderStorage-1.21.1-2.13.0.191.jar": "both",
|
"EnderStorage-1.21.1-2.13.0.191.jar": "both",
|
||||||
"ExplorersCompass-1.21.1-3.4.0-neoforge.jar": "both",
|
"ExplorersCompass-1.21.1-3.4.0-neoforge.jar": "both",
|
||||||
|
"ExtremeSoundMuffler-3.56_NeoForge-1.21.jar": "client",
|
||||||
"FarmersDelight-1.21.1-1.3.2.jar": "both",
|
"FarmersDelight-1.21.1-1.3.2.jar": "both",
|
||||||
"FramedBlocks-10.6.0.jar": "both",
|
|
||||||
"GnKinetics-1.21.1-1.0m.jar": "both",
|
|
||||||
"HangGlider-v21.1.0-1.21.1-NeoForge.jar": "both",
|
"HangGlider-v21.1.0-1.21.1-NeoForge.jar": "both",
|
||||||
"ImmediatelyFast-NeoForge-1.6.10+1.21.1.jar": "client",
|
"ImmediatelyFast-NeoForge-1.6.10+1.21.1.jar": "client",
|
||||||
"Jade-1.21.1-NeoForge-15.10.5.jar": "both",
|
"Jade-1.21.1-NeoForge-15.10.5.jar": "both",
|
||||||
"JadeAddons-1.21.1-NeoForge-6.1.0.jar": "both",
|
"JadeAddons-1.21.1-NeoForge-6.1.0.jar": "both",
|
||||||
"JustEnoughProfessions-neoforge-1.21.1-4.0.5.jar": "both",
|
"JustEnoughProfessions-neoforge-1.21.1-4.0.5.jar": "both",
|
||||||
"JustEnoughResources-NeoForge-1.21.1-1.6.0.17.jar": "client",
|
"JustEnoughResources-NeoForge-1.21.1-1.6.0.17.jar": "client",
|
||||||
|
"MoogsEndStructures-1.21-2.0.3.jar": "both",
|
||||||
|
"MoogsMissingVillages-1.21-2.1.1.jar": "both",
|
||||||
|
"MoogsNetherStructures-1.21-3.0.0-alpha.2.jar": "both",
|
||||||
|
"MoogsVoyagerStructures-1.21-5.0.11.jar": "both",
|
||||||
"MouseTweaks-neoforge-mc1.21-2.26.1.jar": "client",
|
"MouseTweaks-neoforge-mc1.21-2.26.1.jar": "client",
|
||||||
"Neat-1.21-47-NEOFORGE.jar": "both",
|
"Neat-1.21-47-NEOFORGE.jar": "both",
|
||||||
"Necronomicon-NeoForge-1.6.0+1.21.jar": "both",
|
"Necronomicon-NeoForge-1.6.0+1.21.jar": "both",
|
||||||
"Patchouli-1.21.1-93-NEOFORGE.jar": "both",
|
"Patchouli-1.21.1-93-NEOFORGE.jar": "both",
|
||||||
"Ping-Wheel-1.12.2-neoforge-1.21.1.jar": "both",
|
"Ping-Wheel-1.12.2-neoforge-1.21.1.jar": "both",
|
||||||
|
"Placebo-1.21.1-9.9.1.jar": "both",
|
||||||
"PuzzlesLib-v21.1.51-1.21.1-NeoForge.jar": "both",
|
"PuzzlesLib-v21.1.51-1.21.1-NeoForge.jar": "both",
|
||||||
"Searchables-neoforge-1.21.1-1.0.2.jar": "client",
|
"Searchables-neoforge-1.21.1-1.0.2.jar": "client",
|
||||||
|
"TerraBlender-neoforge-1.21.1-4.1.0.8.jar": "both",
|
||||||
|
"ToastControl-1.21.1-9.0.1.jar": "client",
|
||||||
"TravelersTitles-1.21.1-NeoForge-5.1.3.jar": "client",
|
"TravelersTitles-1.21.1-NeoForge-5.1.3.jar": "client",
|
||||||
"UsefulSlime-neoforge-1.21-1.12.1.jar": "both",
|
"UsefulSlime-neoforge-1.21-1.12.1.jar": "both",
|
||||||
"YungsApi-1.21.1-NeoForge-5.1.6.jar": "both",
|
"YungsApi-1.21.1-NeoForge-5.1.6.jar": "both",
|
||||||
|
"YungsBetterCaves-1.21.1-NeoForge-3.1.4.jar": "both",
|
||||||
|
"YungsBetterDesertTemples-1.21.1-NeoForge-4.1.5.jar": "both",
|
||||||
|
"YungsBetterDungeons-1.21.1-NeoForge-5.1.4.jar": "both",
|
||||||
|
"YungsBetterEndIsland-1.21.1-NeoForge-3.1.2.jar": "both",
|
||||||
|
"YungsBetterJungleTemples-1.21.1-NeoForge-3.1.2.jar": "both",
|
||||||
|
"YungsBetterMineshafts-1.21.1-NeoForge-5.1.1.jar": "both",
|
||||||
|
"YungsBetterNetherFortresses-1.21.1-NeoForge-3.1.5.jar": "both",
|
||||||
|
"YungsBetterOceanMonuments-1.21.1-NeoForge-4.1.2.jar": "both",
|
||||||
|
"YungsBetterStrongholds-1.21.1-NeoForge-5.1.3.jar": "both",
|
||||||
|
"YungsBetterWitchHuts-1.21.1-NeoForge-4.1.1.jar": "both",
|
||||||
|
"YungsBridges-1.21.1-NeoForge-5.1.1.jar": "both",
|
||||||
|
"YungsCaveBiomes-1.21.1-NeoForge-3.1.1.jar": "both",
|
||||||
|
"amendments-neoforge-1.21-2.1.5.jar": "both",
|
||||||
"appleskin-neoforge-mc1.21-3.0.9.jar": "client",
|
"appleskin-neoforge-mc1.21-3.0.9.jar": "client",
|
||||||
"architectury-13.0.8-neoforge.jar": "both",
|
"architectury-13.0.8-neoforge.jar": "both",
|
||||||
"baguettelib-1.21.1-NeoForge-2.0.3.jar": "both",
|
"baguettelib-1.21.1-NeoForge-2.0.3.jar": "both",
|
||||||
"balm-neoforge-1.21.1-21.0.58.jar": "both",
|
"balm-neoforge-1.21.1-21.0.58.jar": "both",
|
||||||
"bellsandwhistles-0.4.7-1.21.1.jar": "both",
|
|
||||||
"buildinggadgets2-1.3.9.jar": "both",
|
"buildinggadgets2-1.3.9.jar": "both",
|
||||||
|
"caelus-neoforge-7.0.1+1.21.1.jar": "both",
|
||||||
"cardboardboxes-1.21.1-0.1.2.jar": "both",
|
"cardboardboxes-1.21.1-0.1.2.jar": "both",
|
||||||
"charginggadgets-1.14.1.jar": "both",
|
"charginggadgets-1.14.1.jar": "both",
|
||||||
"cloth-config-15.0.140-neoforge.jar": "both",
|
"cloth-config-15.0.140-neoforge.jar": "both",
|
||||||
@@ -55,11 +74,10 @@
|
|||||||
"create_mechanical_chicken-1.21.1-1.3.3-6.0.6.jar": "both",
|
"create_mechanical_chicken-1.21.1-1.3.3-6.0.6.jar": "both",
|
||||||
"create_mechanical_extruder-1.21.1-2.2.0-6.0.8.jar": "both",
|
"create_mechanical_extruder-1.21.1-2.2.0-6.0.8.jar": "both",
|
||||||
"create_mechanical_spawner-1.21.1-1.3.0-6.0.8.jar": "both",
|
"create_mechanical_spawner-1.21.1-1.3.0-6.0.8.jar": "both",
|
||||||
"create_vibrant_vaults-0.3.2.jar": "both",
|
|
||||||
"createaddition-1.6.0.jar": "both",
|
"createaddition-1.6.0.jar": "both",
|
||||||
"createdeco-2.1.3.jar": "both",
|
"createdeco-2.1.3.jar": "both",
|
||||||
|
"createornithopterglider-1.2.0-1.21.1.jar": "both",
|
||||||
"createsifter-1.21.1-2.2.1.jar": "both",
|
"createsifter-1.21.1-2.2.1.jar": "both",
|
||||||
"createtransmission-1.2.1+neoforge-create6-1.21.1.jar": "both",
|
|
||||||
"createultimine-1.21.1-neoforge-1.3.2.jar": "both",
|
"createultimine-1.21.1-neoforge-1.3.2.jar": "both",
|
||||||
"curios-neoforge-9.5.1+1.21.1.jar": "both",
|
"curios-neoforge-9.5.1+1.21.1.jar": "both",
|
||||||
"cwb-neoforge-3.0.0+mc1.21.jar": "client",
|
"cwb-neoforge-3.0.0+mc1.21.jar": "client",
|
||||||
@@ -68,23 +86,29 @@
|
|||||||
"easy-piglins-neoforge-1.21.1-1.1.0.jar": "both",
|
"easy-piglins-neoforge-1.21.1-1.1.0.jar": "both",
|
||||||
"easy-villagers-neoforge-1.21.1-1.1.42.jar": "both",
|
"easy-villagers-neoforge-1.21.1-1.1.42.jar": "both",
|
||||||
"easydisenchanting-neoforge-1.0.0-1.21.1.jar": "both",
|
"easydisenchanting-neoforge-1.0.0-1.21.1.jar": "both",
|
||||||
|
"emotecraft-for-MC1.21.1-2.4.12-neoforge.jar": "both",
|
||||||
"enderrf-1.21.1-1.0.1.jar": "both",
|
"enderrf-1.21.1-1.0.1.jar": "both",
|
||||||
"entity_model_features-3.2.4-1.21-neoforge.jar": "client",
|
"entity_model_features-3.2.4-1.21-neoforge.jar": "client",
|
||||||
"entity_texture_features_1.21-neoforge-7.1.jar": "client",
|
"entity_texture_features_1.21-neoforge-7.1.jar": "client",
|
||||||
"entityculling-neoforge-1.10.2-mc1.21.1.jar": "client",
|
"entityculling-neoforge-1.10.2-mc1.21.1.jar": "client",
|
||||||
|
"etched-5.1.0.jar": "both",
|
||||||
"fancymenu_neoforge_3.9.1_MC_1.21.1.jar": "client",
|
"fancymenu_neoforge_3.9.1_MC_1.21.1.jar": "client",
|
||||||
"fastleafdecay-35.jar": "both",
|
"fastleafdecay-35.jar": "both",
|
||||||
"ferritecore-7.0.3-neoforge.jar": "both",
|
"ferritecore-7.0.3-neoforge.jar": "both",
|
||||||
|
"fishingoverhaul-1.21.1-1.2.1.jar": "both",
|
||||||
"ftb-chunks-neoforge-2101.1.17.jar": "both",
|
"ftb-chunks-neoforge-2101.1.17.jar": "both",
|
||||||
"ftb-essentials-neoforge-2101.1.9.jar": "both",
|
"ftb-essentials-neoforge-2101.1.9.jar": "both",
|
||||||
"ftb-library-neoforge-2101.1.31.jar": "both",
|
"ftb-library-neoforge-2101.1.31.jar": "both",
|
||||||
"ftb-teams-neoforge-2101.1.10.jar": "both",
|
"ftb-teams-neoforge-2101.1.10.jar": "both",
|
||||||
"ftb-ultimine-neoforge-2101.1.14.jar": "both",
|
"ftb-ultimine-neoforge-2101.1.14.jar": "both",
|
||||||
"functionalstorage-1.21.1-1.5.7.jar": "both",
|
"functionalstorage-1.21.1-1.5.7.jar": "both",
|
||||||
|
"fusion-1.3.2b-neoforge-mc1.21.1.jar": "client",
|
||||||
"fzzy_config-0.7.6+1.21+neoforge.jar": "both",
|
"fzzy_config-0.7.6+1.21+neoforge.jar": "both",
|
||||||
|
"geckolib-neoforge-1.21.1-4.8.4.jar": "both",
|
||||||
"handcrafted-neoforge-1.21.1-4.0.3.jar": "both",
|
"handcrafted-neoforge-1.21.1-4.0.3.jar": "both",
|
||||||
"interiors-1.21.1-neoforge-0.6.1.jar": "both",
|
"immersive_paintings-neoforge-1.21.1-0.7.8.jar": "both",
|
||||||
"inventoryessentials-neoforge-1.21.1-21.1.15.jar": "both",
|
"inventoryessentials-neoforge-1.21.1-21.1.15.jar": "both",
|
||||||
|
"iris-neoforge-1.8.12+mc1.21.1.jar": "client",
|
||||||
"jamlib-neoforge-1.3.6+1.21.1.jar": "both",
|
"jamlib-neoforge-1.3.6+1.21.1.jar": "both",
|
||||||
"jei-1.21.1-neoforge-19.27.0.340.jar": "both",
|
"jei-1.21.1-neoforge-19.27.0.340.jar": "both",
|
||||||
"konkrete_neoforge_1.9.9_MC_1.21.jar": "client",
|
"konkrete_neoforge_1.9.9_MC_1.21.jar": "client",
|
||||||
@@ -94,24 +118,30 @@
|
|||||||
"kubejsdelight-1.1.6.jar": "both",
|
"kubejsdelight-1.1.6.jar": "both",
|
||||||
"laserio-1.9.11.jar": "both",
|
"laserio-1.9.11.jar": "both",
|
||||||
"lithium-neoforge-0.15.3+mc1.21.1.jar": "both",
|
"lithium-neoforge-0.15.3+mc1.21.1.jar": "both",
|
||||||
|
"lootjs-neoforge-1.21.1-3.7.0.jar": "both",
|
||||||
"lootr-neoforge-1.21.1-1.11.37.120.jar": "both",
|
"lootr-neoforge-1.21.1-1.11.37.120.jar": "both",
|
||||||
"mechanical_cow-1.21.1-1.2.0-6.0.8.jar": "both",
|
"mechanical_cow-1.21.1-1.2.0-6.0.8.jar": "both",
|
||||||
"mechanicals-1.21.1-1.1.0.jar": "both",
|
"mechanicals-1.21.1-1.1.0.jar": "both",
|
||||||
"melody_neoforge_1.0.10_MC_1.21.jar": "client",
|
"melody_neoforge_1.0.10_MC_1.21.jar": "client",
|
||||||
|
"melter-1.21.1-1.8.2.jar": "both",
|
||||||
"mob_grinding_utils-1.1.10+mc1.21.1.jar": "both",
|
"mob_grinding_utils-1.1.10+mc1.21.1.jar": "both",
|
||||||
"modernfix-neoforge-5.27.12+mc1.21.1.jar": "both",
|
"modernfix-neoforge-5.27.12+mc1.21.1.jar": "both",
|
||||||
|
"moogs_structures-neoforge-1.21.1-3.0.0.jar": "both",
|
||||||
|
"moonlight-neoforge-1.21.1-3.0.22.jar": "both",
|
||||||
"morefunctionalstorage-1.2.3.jar": "both",
|
"morefunctionalstorage-1.2.3.jar": "both",
|
||||||
"moreoverlays-1.24.2-mc1.21.1-neoforge.jar": "client",
|
"moreoverlays-1.24.2-mc1.21.1-neoforge.jar": "client",
|
||||||
"picaxe-1.21.1-neoforge-1.0.9.jar": "both",
|
|
||||||
"polymorph-neoforge-1.1.0+1.21.1.jar": "both",
|
"polymorph-neoforge-1.1.0+1.21.1.jar": "both",
|
||||||
"ponderjs-neoforge-1.21.1-2.4.0.jar": "client",
|
"ponderjs-neoforge-1.21.1-2.4.0.jar": "client",
|
||||||
|
"rechiseled-1.2.5-neoforge-mc1.21.jar": "both",
|
||||||
"resourcefullib-neoforge-1.21-3.0.12.jar": "both",
|
"resourcefullib-neoforge-1.21-3.0.12.jar": "both",
|
||||||
"rhino-2101.2.7-build.85.jar": "both",
|
"rhino-2101.2.7-build.85.jar": "both",
|
||||||
"rightclickharvest-neoforge-4.6.1+1.21.1.jar": "both",
|
"rightclickharvest-neoforge-4.6.1+1.21.1.jar": "both",
|
||||||
|
"seamless-loading-screen-2.2.1+1.21-neoforge.jar": "client",
|
||||||
"simplemagnets-1.1.12c-neoforge-mc1.21.jar": "both",
|
"simplemagnets-1.1.12c-neoforge-mc1.21.jar": "both",
|
||||||
"sliceanddice-forge-4.2.4.jar": "both",
|
"sliceanddice-forge-4.2.4.jar": "both",
|
||||||
"sodium-extra-neoforge-0.8.7+mc1.21.1.jar": "client",
|
"sodium-extra-neoforge-0.8.7+mc1.21.1.jar": "client",
|
||||||
"sodium-neoforge-0.8.12-alpha.4+mc1.21.1.jar": "client",
|
"sodium-neoforge-0.8.12-alpha.4+mc1.21.1.jar": "client",
|
||||||
|
"solcarrot-1.21.1-1.16.6.jar": "both",
|
||||||
"sophisticatedbackpacks-1.21.1-3.25.55.1852.jar": "both",
|
"sophisticatedbackpacks-1.21.1-3.25.55.1852.jar": "both",
|
||||||
"sophisticatedbackpackscreateintegration-1.21.1-0.1.6.99.jar": "both",
|
"sophisticatedbackpackscreateintegration-1.21.1-0.1.6.99.jar": "both",
|
||||||
"sophisticatedcore-1.21.1-1.4.49.1976.jar": "both",
|
"sophisticatedcore-1.21.1-1.4.49.1976.jar": "both",
|
||||||
@@ -124,9 +154,9 @@
|
|||||||
"torchmaster-neoforge-1.21.1-21.1.9.jar": "both",
|
"torchmaster-neoforge-1.21.1-21.1.9.jar": "both",
|
||||||
"trashcans-1.0.18c-neoforge-mc1.21.jar": "both",
|
"trashcans-1.0.18c-neoforge-mc1.21.jar": "both",
|
||||||
"txnilib-neoforge-1.0.24-1.21.1.jar": "both",
|
"txnilib-neoforge-1.0.24-1.21.1.jar": "both",
|
||||||
|
"ulicraftmod-1.21.1-1.0.0-6.0.10.jar": "both",
|
||||||
"utilitarian-1.21.1-0.19.2.jar": "both",
|
"utilitarian-1.21.1-0.19.2.jar": "both",
|
||||||
"voicechat-neoforge-1.21.1-2.6.18.jar": "both",
|
"voicechat-neoforge-1.21.1-2.6.18.jar": "both",
|
||||||
"welcomescreen-neoforge-1.0.0-1.21.1.jar": "client",
|
|
||||||
"wm_binaries-3.0.0-rc.1.jar": "client",
|
"wm_binaries-3.0.0-rc.1.jar": "client",
|
||||||
"yeetusexperimentus-neoforge-87.0.0.jar": "client",
|
"yeetusexperimentus-neoforge-87.0.0.jar": "client",
|
||||||
"yet_another_config_lib_v3-3.8.2+1.21.1-neoforge.jar": "both"
|
"yet_another_config_lib_v3-3.8.2+1.21.1-neoforge.jar": "both"
|
||||||
|
|||||||
@@ -26,11 +26,15 @@ upstream ulicraft_caddy {
|
|||||||
server 127.0.0.1:${CADDY_HTTP_PORT};
|
server 127.0.0.1:${CADDY_HTTP_PORT};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Brute-force throttle for the publicly-exposed Filestash /admin console (it is
|
||||||
|
# the only credential on files.), applied in its location block below.
|
||||||
|
limit_req_zone $binary_remote_addr zone=files_admin:10m rate=6r/m;
|
||||||
|
|
||||||
# HTTP -> HTTPS for every name.
|
# HTTP -> HTTPS for every name.
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
listen [::]:80;
|
listen [::]:80;
|
||||||
server_name ${BASE_DOMAIN} www.${BASE_DOMAIN} auth.${BASE_DOMAIN} distribution.${BASE_DOMAIN} avatar.${BASE_DOMAIN} status.${BASE_DOMAIN};
|
server_name ${BASE_DOMAIN} www.${BASE_DOMAIN} auth.${BASE_DOMAIN} distribution.${BASE_DOMAIN} avatar.${BASE_DOMAIN} status.${BASE_DOMAIN} files.${BASE_DOMAIN};
|
||||||
return 301 https://$host$request_uri;
|
return 301 https://$host$request_uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +129,44 @@ server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Filestash player file share. Uploads need a real body limit (nginx defaults to
|
||||||
|
# 1m, which rejects almost anything worth sharing) and streaming rather than
|
||||||
|
# spooling — a 2g upload buffered to disk would write it twice on a host whose
|
||||||
|
# disk is already the shared failure domain for Minecraft + Drasl + backups.
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
listen [::]:443 ssl http2;
|
||||||
|
server_name files.${BASE_DOMAIN};
|
||||||
|
|
||||||
|
ssl_certificate ${APP_DIR}/certs/files.${BASE_DOMAIN}/cert.pem;
|
||||||
|
ssl_certificate_key ${APP_DIR}/certs/files.${BASE_DOMAIN}/key.pem;
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
|
||||||
|
client_max_body_size 2g; # uploads; other vhosts stay at 4m
|
||||||
|
proxy_request_buffering off; # stream uploads, don't spool them to disk
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
proxy_send_timeout 300s;
|
||||||
|
|
||||||
|
# /admin is reachable on purpose, so throttle it: it is the only password
|
||||||
|
# on this vhost. 6 req/min, small burst.
|
||||||
|
location /admin {
|
||||||
|
limit_req zone=files_admin burst=3 nodelay;
|
||||||
|
proxy_pass http://ulicraft_caddy;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://ulicraft_caddy;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 443 ssl http2;
|
listen 443 ssl http2;
|
||||||
listen [::]:443 ssl http2;
|
listen [::]:443 ssl http2;
|
||||||
|
|||||||
@@ -13,9 +13,10 @@ Everything is driven by one env var in `.env`:
|
|||||||
BASE_DOMAIN=ulicraft.net # all services are subdomains of this
|
BASE_DOMAIN=ulicraft.net # all services are subdomains of this
|
||||||
```
|
```
|
||||||
|
|
||||||
`render-config.sh` substitutes **only** `${BASE_DOMAIN}` into the templates.
|
`render-config.sh` substitutes `${BASE_DOMAIN}` into the templates (plus the
|
||||||
|
derived `REGISTRATION_*` and `FILES_*` values — see `20-files.md`).
|
||||||
DNS is **not** this repo's concern: point `${BASE_DOMAIN}` and all subdomains
|
DNS is **not** this repo's concern: point `${BASE_DOMAIN}` and all subdomains
|
||||||
(`auth.` `pack.` `avatar.` `status.` `distribution.` `www.`) at the host running
|
(`auth.` `avatar.` `status.` `files.` `distribution.` `www.`) at the host running
|
||||||
nginx; that is configured outside this repo.
|
nginx; that is configured outside this repo.
|
||||||
|
|
||||||
## Subdomain / ingress map
|
## Subdomain / ingress map
|
||||||
@@ -23,9 +24,9 @@ nginx; that is configured outside this repo.
|
|||||||
```
|
```
|
||||||
ulicraft.net → nginx → caddy → landing page (/srv/www) + /launcher/
|
ulicraft.net → nginx → caddy → landing page (/srv/www) + /launcher/
|
||||||
auth.ulicraft.net → nginx → caddy → drasl:25585 (auth web UI + Yggdrasil API)
|
auth.ulicraft.net → nginx → caddy → drasl:25585 (auth web UI + Yggdrasil API)
|
||||||
pack.ulicraft.net → nginx → caddy → /srv/pack (packwiz pack metadata)
|
|
||||||
avatar.ulicraft.net → nginx → caddy → nmsr:8080 (skin/avatar renderer)
|
avatar.ulicraft.net → nginx → caddy → nmsr:8080 (skin/avatar renderer)
|
||||||
status.ulicraft.net → nginx → caddy → uptime-kuma:3001 (status page)
|
status.ulicraft.net → nginx → caddy → uptime-kuma:3001 (status page)
|
||||||
|
files.ulicraft.net → nginx → caddy → filestash:8334 (player file share, shared login)
|
||||||
distribution.ulicraft.net → nginx → caddy → /srv/distribution (static, another repo)
|
distribution.ulicraft.net → nginx → caddy → /srv/distribution (static, another repo)
|
||||||
mc.ulicraft.net:25565 → minecraft (raw MC protocol, not HTTP)
|
mc.ulicraft.net:25565 → minecraft (raw MC protocol, not HTTP)
|
||||||
```
|
```
|
||||||
@@ -45,36 +46,35 @@ resolves its own subdomains internally:
|
|||||||
caddy:
|
caddy:
|
||||||
networks:
|
networks:
|
||||||
mcnet:
|
mcnet:
|
||||||
aliases: [ "auth.${BASE_DOMAIN}", "pack.${BASE_DOMAIN}" ]
|
aliases: [ "auth.${BASE_DOMAIN}" ]
|
||||||
```
|
```
|
||||||
|
|
||||||
Result: `http://auth.ulicraft.net/authlib-injector` and
|
Result: `http://auth.ulicraft.net/authlib-injector` resolves identically inside
|
||||||
`http://pack.ulicraft.net/pack.toml` resolve identically inside the stack
|
the stack (minecraft → caddy) as the public name does outside it (client → nginx
|
||||||
(minecraft → caddy) as the public names do outside it (client → nginx → caddy).
|
→ caddy).
|
||||||
|
|
||||||
#### HTTPS variant (`extra_hosts: host-gateway`)
|
#### HTTPS variant (`extra_hosts: host-gateway`)
|
||||||
|
|
||||||
The caddy alias only serves **http** on `mcnet` (port 80). Once the stack moved
|
The caddy alias only serves **http** on `mcnet` (port 80). Once the stack moved
|
||||||
to https URLs (drasl `BaseURL`, `PACKWIZ_URL`, the authlib-injector agent must
|
to https URLs (drasl `BaseURL`; the authlib-injector agent must match the
|
||||||
match the client's https endpoint), the internal http alias is no longer enough —
|
client's https endpoint), the internal http alias is no longer enough — the
|
||||||
the container needs to reach a **TLS** terminator holding a valid cert.
|
container needs to reach a **TLS** terminator holding a valid cert.
|
||||||
|
|
||||||
Caddy does not terminate TLS internally; the host's nginx does (on `0.0.0.0:443`).
|
Caddy does not terminate TLS internally; the host's nginx does (on `0.0.0.0:443`).
|
||||||
So pin the public names to the host in the service, not to caddy:
|
So pin the public name to the host in the service, not to caddy:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
minecraft:
|
minecraft:
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "auth.${BASE_DOMAIN}:host-gateway"
|
- "auth.${BASE_DOMAIN}:host-gateway"
|
||||||
- "pack.${BASE_DOMAIN}:host-gateway"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`/etc/hosts` (extra_hosts) wins over the Docker resolver, so the container reaches
|
`/etc/hosts` (extra_hosts) wins over the Docker resolver, so the container reaches
|
||||||
the host's nginx (valid LE cert) → caddy → service. This is required because
|
the host's nginx (valid LE cert) → caddy → service. This is required because
|
||||||
**containers do not inherit the host's `/etc/hosts`** — on cochi the host resolves
|
**containers do not inherit the host's `/etc/hosts`** — on cochi the host resolves
|
||||||
`auth./pack.` to the public IP, but inside a container the LAN resolver returns a
|
`auth.` to the public IP, but inside a container the LAN resolver returns a
|
||||||
dead address (`192.168.0.3:443`, connection refused), which crash-loops the
|
dead address (`192.168.0.3:443`, connection refused), which crash-loops session
|
||||||
packwiz install on boot. `host-gateway` sidesteps DNS entirely.
|
validation on boot. `host-gateway` sidesteps DNS entirely.
|
||||||
|
|
||||||
## Build / run flow
|
## Build / run flow
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ PREP (run once, online):
|
|||||||
1. render configs → tooling/render-config.sh (08-tooling.md)
|
1. render configs → tooling/render-config.sh (08-tooling.md)
|
||||||
2. build landing page → cd landing && pnpm run build → www/ (09-landing.md)
|
2. build landing page → cd landing && pnpm run build → www/ (09-landing.md)
|
||||||
3. download launcher releases → tooling/fetch-launcher.sh (06-launcher.md)
|
3. download launcher releases → tooling/fetch-launcher.sh (06-launcher.md)
|
||||||
4. curate packwiz pack → see 04-packwiz.md
|
4. sync server mods → tooling/sync-server-mods.sh (04-mods.md)
|
||||||
5. (TLS) issue Let's Encrypt certs → tooling/issue-letsencrypt.sh (15-letsencrypt.md)
|
5. (TLS) issue Let's Encrypt certs → tooling/issue-letsencrypt.sh (15-letsencrypt.md)
|
||||||
6. (ingress) render+install nginx → tooling/render-nginx.sh --install (15-letsencrypt.md)
|
6. (ingress) render+install nginx → tooling/render-nginx.sh --install (15-letsencrypt.md)
|
||||||
|
|
||||||
@@ -92,15 +92,16 @@ UP:
|
|||||||
```
|
```
|
||||||
|
|
||||||
Bring-up is a single command. There are no run modes and no override compose
|
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
|
files — one `docker-compose.yml` holds drasl, minecraft, mc-backup, caddy, nmsr,
|
||||||
and uptime-kuma.
|
mc-status, uptime-kuma and filestash.
|
||||||
|
|
||||||
## Why each hard call was made
|
## Why each hard call was made
|
||||||
|
|
||||||
- **No OIDC/Keycloak (for now)** — drasl password login. Keycloak left out of
|
- **No OIDC/Keycloak (for now)** — drasl password login. Keycloak left out of
|
||||||
this stack's scope. Re-add later if desired.
|
this stack's scope. Re-add later if desired.
|
||||||
- **packwiz only serves metadata** — `.pw.toml` points at Modrinth/CF CDNs;
|
- **Distribution-fed mods** — the modset's source of truth is the HeliosLauncher
|
||||||
server and clients fetch jars from there. The internet is assumed present.
|
distribution repo; clients install the full set, the server loads a filtered
|
||||||
|
`both`/`server` subset from `./server-mods`. See `04-mods.md`.
|
||||||
- **Fjord launcher** has authlib-injector built in → no manual JVM agent on
|
- **Fjord launcher** has authlib-injector built in → no manual JVM agent on
|
||||||
clients.
|
clients.
|
||||||
- **host nginx in front of caddy** — real Let's Encrypt certs (JVM trusts them
|
- **host nginx in front of caddy** — real Let's Encrypt certs (JVM trusts them
|
||||||
@@ -120,8 +121,11 @@ and uptime-kuma.
|
|||||||
│ ├── config.toml.tmpl # render-config.sh source
|
│ ├── config.toml.tmpl # render-config.sh source
|
||||||
│ └── config.toml # generated (gitignored)
|
│ └── config.toml # generated (gitignored)
|
||||||
├── nmsr/ # avatar renderer config + Dockerfile context
|
├── nmsr/ # avatar renderer config + Dockerfile context
|
||||||
|
├── filestash/
|
||||||
|
│ ├── config.json.tmpl # render-config.sh source (files. share)
|
||||||
|
│ └── config.json # generated, mounted :ro (gitignored — holds hashes)
|
||||||
├── docker/nmsr/ # nmsr image build context
|
├── docker/nmsr/ # nmsr image build context
|
||||||
├── pack/ # packwiz source of truth
|
├── server-mods/ # filtered server modset (synced, gitignored)
|
||||||
├── launcher/ # vendored Fjord launcher releases (gitignored)
|
├── launcher/ # vendored Fjord launcher releases (gitignored)
|
||||||
├── runtime/ # authlib-injector.jar
|
├── runtime/ # authlib-injector.jar
|
||||||
├── tooling/ # render/fetch/issue scripts (see 08-tooling.md)
|
├── tooling/ # render/fetch/issue scripts (see 08-tooling.md)
|
||||||
@@ -135,7 +139,7 @@ and uptime-kuma.
|
|||||||
|
|
||||||
- `02-caddy.md` — internal router (vhost conf.d snippets) behind host nginx
|
- `02-caddy.md` — internal router (vhost conf.d snippets) behind host nginx
|
||||||
- `03-drasl.md` — auth (password login)
|
- `03-drasl.md` — auth (password login)
|
||||||
- `04-packwiz.md` — modpack source
|
- `04-mods.md` — distribution-fed mod volume + server filtering (supersedes `04-packwiz.md`)
|
||||||
- `05-minecraft.md` — itzg NeoForge server
|
- `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
|
||||||
@@ -146,9 +150,11 @@ and uptime-kuma.
|
|||||||
- `15-letsencrypt.md` — Let's Encrypt (OVH DNS-01) + host nginx ingress
|
- `15-letsencrypt.md` — Let's Encrypt (OVH DNS-01) + host nginx ingress
|
||||||
- `17-mod-shortlist.md` — kitchen-sink gap doc + landing mod-list dataset (PLANNED)
|
- `17-mod-shortlist.md` — kitchen-sink gap doc + landing mod-list dataset (PLANNED)
|
||||||
- `18-landing-rework.md` — join flow / rosters / account / mod list / footer (PLANNED)
|
- `18-landing-rework.md` — join flow / rosters / account / mod list / footer (PLANNED)
|
||||||
- `19-routes.md` — HTTP route reference (nmsr / landing / auth + internal)
|
- `19-routes.md` — HTTP route reference (nmsr / landing / auth / files + internal)
|
||||||
|
- `20-files.md` — Filestash player file share (`files.`, one shared login)
|
||||||
|
|
||||||
## Boot/dependency order
|
## Boot/dependency order
|
||||||
|
|
||||||
`caddy` (aliases) → `drasl` → `minecraft` (depends on drasl + pack served by
|
`caddy` (aliases) → `drasl` → `minecraft` (depends on drasl) → `mc-backup`.
|
||||||
caddy) → `mc-backup`. `nmsr` and `uptime-kuma` are always-on and join `mcnet`.
|
`nmsr`, `mc-status`, `uptime-kuma` and `filestash` are always-on and join `mcnet`
|
||||||
|
(no ordering constraints — none of them are on the auth/boot path).
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ nginx (which terminates TLS — see `15-letsencrypt.md`). caddy is published onl
|
|||||||
on a localhost-only port (`CADDY_HTTP_BIND=127.0.0.1`, `CADDY_HTTP_PORT=8880`);
|
on a localhost-only port (`CADDY_HTTP_BIND=127.0.0.1`, `CADDY_HTTP_PORT=8880`);
|
||||||
nginx reverse-proxies every public vhost to it by Host header. Roles:
|
nginx reverse-proxies every public vhost to it by Host header. Roles:
|
||||||
|
|
||||||
1. **Reverse proxy** — `auth.` → drasl, `avatar.` → nmsr, `status.` → uptime-kuma.
|
1. **Reverse proxy** — `auth.` → drasl, `avatar.` → nmsr, `status.` → uptime-kuma,
|
||||||
2. **Static** — `pack.` serves packwiz metadata; apex serves the landing page +
|
`files.` → filestash.
|
||||||
`/launcher/` downloads; `distribution.` serves a static site from another repo.
|
2. **Static** — 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 —
|
Image: `caddy:alpine`. caddy natively reads `{$BASE_DOMAIN}` env placeholders —
|
||||||
no template render needed.
|
no template render needed.
|
||||||
@@ -22,31 +23,35 @@ caddy:
|
|||||||
mcnet:
|
mcnet:
|
||||||
aliases:
|
aliases:
|
||||||
- "auth.${BASE_DOMAIN}"
|
- "auth.${BASE_DOMAIN}"
|
||||||
- "pack.${BASE_DOMAIN}"
|
|
||||||
```
|
```
|
||||||
Lets `minecraft` reach `http://auth.ulicraft.net/authlib-injector` and
|
Lets `minecraft` reach `http://auth.ulicraft.net/authlib-injector` from inside
|
||||||
`http://pack.ulicraft.net/pack.toml` from inside the stack.
|
the stack.
|
||||||
|
|
||||||
## conf.d snippets
|
## conf.d snippets
|
||||||
|
|
||||||
The Caddyfile imports per-vhost snippets from `caddy/conf.d/`:
|
The Caddyfile imports per-vhost snippets from `caddy/conf.d/`:
|
||||||
|
|
||||||
- `00-core.caddy` — `auth.` → drasl, `pack.` → `/srv/pack` (browse).
|
- `00-core.caddy` — `auth.` → drasl.
|
||||||
- `10-static.caddy` — apex landing (`/srv/www`) + `/launcher/*` (`/srv/launcher`).
|
- `10-static.caddy` — apex landing (`/srv/www`) + `/launcher/*` (`/srv/launcher`).
|
||||||
- `30-distribution.caddy` — `distribution.` → `/srv/distribution` (static, from
|
- `30-distribution.caddy` — `distribution.` → `/srv/distribution` (static, from
|
||||||
`DISTRIBUTION_WEB_ROOT`, another repo).
|
`DISTRIBUTION_WEB_ROOT`, another repo).
|
||||||
- `40-avatar.caddy` — `avatar.` → `nmsr:8080` (skin/avatar renderer).
|
- `40-avatar.caddy` — `avatar.` → `nmsr:8080` (skin/avatar renderer).
|
||||||
- `50-status.caddy` — `status.` → `uptime-kuma:3001` (status page).
|
- `50-status.caddy` — `status.` → `uptime-kuma:3001` (status page).
|
||||||
|
- `60-files.caddy` — `files.` → `filestash:8334` (player file share, `20-files.md`).
|
||||||
|
|
||||||
All vhosts are plain `http://…`; TLS is nginx's job. Mounts: `./www:/srv/www:ro`,
|
All vhosts are plain `http://…`; TLS is nginx's job. Mounts: `./www:/srv/www:ro`,
|
||||||
`./pack:/srv/pack:ro`, `./launcher:/srv/launcher:ro`,
|
`./launcher:/srv/launcher:ro`, `${DISTRIBUTION_WEB_ROOT}:/srv/distribution:ro`.
|
||||||
`${DISTRIBUTION_WEB_ROOT}:/srv/distribution:ro`.
|
|
||||||
|
> **Don't rewrite the `Host` header for `files.`** — filestash rejects any
|
||||||
|
> request whose Host doesn't match its configured `general.host`, with *"only
|
||||||
|
> traffic from X is allowed"* (a 403 that reads like an auth failure). nginx
|
||||||
|
> forwards the original Host and caddy passes it through untouched. Keep it that
|
||||||
|
> way.
|
||||||
|
|
||||||
## Notes / risks
|
## Notes / risks
|
||||||
|
|
||||||
- drasl behind a proxy: with matching `BaseURL` and nginx terminating TLS it is
|
- drasl behind a proxy: with matching `BaseURL` and nginx terminating TLS it is
|
||||||
fine; nginx passes the original Host through to caddy → drasl.
|
fine; nginx passes the original Host through to caddy → drasl.
|
||||||
- `file_server browse` gives a directory listing — handy for debugging the pack.
|
|
||||||
- `/launcher/` is a sibling mount (`/srv/launcher`), not nested under the
|
- `/launcher/` is a sibling mount (`/srv/launcher`), not nested under the
|
||||||
read-only `/srv/www`; `handle_path` strips the prefix.
|
read-only `/srv/www`; `handle_path` strips the prefix.
|
||||||
|
|
||||||
@@ -55,8 +60,8 @@ All vhosts are plain `http://…`; TLS is nginx's job. Mounts: `./www:/srv/www:r
|
|||||||
- [ ] `caddy` service in `docker-compose.yml`, published `127.0.0.1:${CADDY_HTTP_PORT}:80`
|
- [ ] `caddy` service in `docker-compose.yml`, published `127.0.0.1:${CADDY_HTTP_PORT}:80`
|
||||||
- [ ] Pass `BASE_DOMAIN` + `DISTRIBUTION_WEB_ROOT` into caddy env
|
- [ ] Pass `BASE_DOMAIN` + `DISTRIBUTION_WEB_ROOT` into caddy env
|
||||||
- [ ] Caddyfile imports `conf.d/*.caddy`
|
- [ ] Caddyfile imports `conf.d/*.caddy`
|
||||||
- [ ] Network aliases for `auth.` and `pack.` subdomains
|
- [ ] Network alias for `auth.` subdomain
|
||||||
- [ ] Mount `./www`, `./pack`, `./launcher`, `${DISTRIBUTION_WEB_ROOT}`
|
- [ ] Mount `./www`, `./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 authlib-injector)
|
||||||
- [ ] Verify nmsr + uptime-kuma proxied (`avatar.` renders, `status.` loads)
|
- [ ] Verify nmsr + uptime-kuma proxied (`avatar.` renders, `status.` loads)
|
||||||
|
|||||||
@@ -25,16 +25,95 @@ mirrors `/mods` into `/data/mods` and prunes removed ones (`REMOVE_OLD_MODS`).
|
|||||||
## Auth / config
|
## Auth / config
|
||||||
|
|
||||||
```
|
```
|
||||||
ONLINE_MODE=FALSE
|
ONLINE_MODE=TRUE
|
||||||
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 the caddy 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.)
|
||||||
|
|
||||||
|
**`ONLINE_MODE` MUST be true.** authlib-injector works by redirecting the
|
||||||
|
server's normal online-auth handshake from Mojang to Drasl. With
|
||||||
|
`ONLINE_MODE=FALSE` the server skips that handshake entirely → players get
|
||||||
|
offline-hash UUIDs, no session validation, and **no skins** (the server never
|
||||||
|
fetches textures from Drasl). The thing that must stay *off* for 1.21+ is the
|
||||||
|
**secure profile** (`ENFORCE_SECURE_PROFILE=FALSE` + Drasl `SignPublicKeys=false`),
|
||||||
|
not online-mode. Symptom of getting this wrong: everyone can join but everyone is
|
||||||
|
Steve, and `usercache.json` shows offline-hash UUIDs.
|
||||||
|
|
||||||
Memory: `INIT_MEMORY 4G`, `MAX_MEMORY 10G`, `USE_AIKAR_FLAGS TRUE`.
|
Memory: `INIT_MEMORY 4G`, `MAX_MEMORY 10G`, `USE_AIKAR_FLAGS TRUE`.
|
||||||
RCON enabled for mc-backup.
|
RCON enabled for mc-backup.
|
||||||
|
|
||||||
|
## Server operators (OP)
|
||||||
|
|
||||||
|
**Do NOT use the itzg `OPS` env var.** It resolves usernames to UUIDs via a
|
||||||
|
Mojang lookup, but this server is `ONLINE_MODE=TRUE` validated against Drasl —
|
||||||
|
Drasl assigns its own UUIDs (not Mojang, not the offline-hash). A Mojang-resolved UUID
|
||||||
|
would not match the player's real session UUID, so the op entry would be inert
|
||||||
|
(and could clobber a correct `ops.json` on reboot).
|
||||||
|
|
||||||
|
**Op via RCON instead.** The player must have connected at least once so their
|
||||||
|
real Drasl UUID is cached in `/data/usercache.json`; then `op <name>` writes that
|
||||||
|
cached UUID to `ops.json`. On the host:
|
||||||
|
|
||||||
|
```
|
||||||
|
ssh cochi
|
||||||
|
docker exec minecraft rcon-cli list # confirm cache / who's on
|
||||||
|
docker exec minecraft sh -c 'cat /data/usercache.json' # verify name→UUID cached
|
||||||
|
docker exec minecraft rcon-cli op <name>
|
||||||
|
docker exec minecraft sh -c 'cat /data/ops.json' # verify level 4 + right UUID
|
||||||
|
```
|
||||||
|
|
||||||
|
`ops.json` lives in the `mc_data` volume → persists across restarts; no reboot
|
||||||
|
needed (takes effect live). Current op: `oier`
|
||||||
|
(`7fbc9adb-bcf4-3913-9a99-46d46acd1009`), level 4, set 2026-06-21.
|
||||||
|
|
||||||
|
## Simple Voice Chat — `voice_host` (NOT repo-tracked)
|
||||||
|
|
||||||
|
Voice chat is UDP `24454/udp`, published in compose. The mod auto-generates
|
||||||
|
`/data/config/voicechat/voicechat-server.properties` in the `mc_data` volume.
|
||||||
|
|
||||||
|
**Gotcha — `voice_host` must be set or remote clients get a red/crossed icon.**
|
||||||
|
Behind any NAT/Docker the server announces an address for clients' UDP
|
||||||
|
connection; if `voice_host=` is empty it auto-detects the internal Docker IP →
|
||||||
|
unreachable → the secret (TCP) is sent but the UDP handshake never completes
|
||||||
|
("Sent secret" with no following "successfully connected to voice chat").
|
||||||
|
|
||||||
|
Set on prod:
|
||||||
|
|
||||||
|
```
|
||||||
|
docker exec minecraft sed -i 's/^voice_host=.*/voice_host=ulicraft.net/' \
|
||||||
|
/data/config/voicechat/voicechat-server.properties
|
||||||
|
docker restart minecraft # mod reads props only at startup
|
||||||
|
```
|
||||||
|
|
||||||
|
**⚠️ This value lives in the `mc_data` volume, NOT auto-managed from this repo.**
|
||||||
|
It survives restarts and `compose down/up`, but a **world wipe / fresh `mc_data`**
|
||||||
|
regenerates the file with `voice_host=` empty → voice silently breaks again.
|
||||||
|
|
||||||
|
A known-good prod snapshot is committed at
|
||||||
|
`server-configs/voicechat-server.properties` (reference copy, `voice_host=ulicraft.net`).
|
||||||
|
After a world reset, either re-run the `sed` + `docker restart` above, or restore
|
||||||
|
the snapshot:
|
||||||
|
|
||||||
|
```
|
||||||
|
docker cp server-configs/voicechat-server.properties \
|
||||||
|
minecraft:/data/config/voicechat/voicechat-server.properties
|
||||||
|
docker restart minecraft
|
||||||
|
```
|
||||||
|
|
||||||
|
(Snapshot is not auto-mounted — to make it durable, add a bind mount of
|
||||||
|
`server-configs/voicechat-server.properties` into `/data/config/voicechat/` in
|
||||||
|
compose.)
|
||||||
|
|
||||||
|
Verify after a client joins:
|
||||||
|
`docker logs minecraft 2>&1 | grep -i "connected to voice"` → expect
|
||||||
|
`Player <name> ... successfully connected to voice chat`.
|
||||||
|
|
||||||
|
Note: a red voice icon can ALSO be client-side — a VPN/zero-trust client
|
||||||
|
(Cloudflare One/WARP, FortiClient) on the player's machine silently drops the
|
||||||
|
voice UDP. Rule out VPNs before touching the server.
|
||||||
|
|
||||||
## Tasks
|
## Tasks
|
||||||
|
|
||||||
- [ ] Compose: authlib URL `http://auth.${BASE_DOMAIN}/authlib-injector`
|
- [ ] Compose: authlib URL `http://auth.${BASE_DOMAIN}/authlib-injector`
|
||||||
@@ -44,3 +123,4 @@ RCON enabled for mc-backup.
|
|||||||
- [ ] `depends_on`: drasl (authlib must resolve at boot)
|
- [ ] `depends_on`: drasl (authlib must resolve at boot)
|
||||||
- [ ] Confirm `ENFORCE_SECURE_PROFILE=FALSE` ↔ drasl `SignPublicKeys=false`
|
- [ ] Confirm `ENFORCE_SECURE_PROFILE=FALSE` ↔ drasl `SignPublicKeys=false`
|
||||||
- [ ] Verify a client joins with their drasl skin
|
- [ ] Verify a client joins with their drasl skin
|
||||||
|
- [ ] After any world wipe: re-set `voice_host` (see Simple Voice Chat section)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
`itzg/mc-backup` alongside the server, talking via RCON. Snapshots the world every
|
`itzg/mc-backup` alongside the server, talking via RCON. Snapshots the world every
|
||||||
6h, prunes after 14 days. World-only (no mod jars — mods are reproducible from the
|
6h, prunes after 14 days. World-only (no mod jars — mods are reproducible from the
|
||||||
packwiz pack). Unchanged from the original design; lowest-priority service.
|
distribution). Unchanged from the original design; lowest-priority service.
|
||||||
|
|
||||||
## Config (carry-over)
|
## Config (carry-over)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Tooling — config rendering, pack render, ingress, fetch scripts
|
# Tooling — config rendering, mod sync, ingress, fetch scripts
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
@@ -10,11 +10,13 @@ half-broken output.
|
|||||||
Surviving scripts:
|
Surviving scripts:
|
||||||
|
|
||||||
- `render-config.sh` — render `*.tmpl` configs (BASE_DOMAIN only)
|
- `render-config.sh` — render `*.tmpl` configs (BASE_DOMAIN only)
|
||||||
- `render-pack.sh` — render packwiz pack templates + index
|
- `classify-mods.py` — read each jar's `neoforge.mods.toml`, seed `mods-sides.json` (see 04-mods.md)
|
||||||
|
- `sync-server-mods.sh` — copy `both`/`server` jars from the distribution → `./server-mods` (see 04-mods.md)
|
||||||
|
- `build-modlist.py` — generate the landing `mods.json` + extract logos (see 17-mod-shortlist.md)
|
||||||
- `render-nginx.sh` — render/install the host nginx vhost (TLS terminator → caddy)
|
- `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)
|
- `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)
|
- `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-authlib.sh` — vendor the authlib-injector jar → `runtime/`
|
||||||
- `fetch-fonts.sh` — vendor the landing page fonts (see 09-landing.md)
|
- `fetch-fonts.sh` — vendor the landing page fonts (see 09-landing.md)
|
||||||
|
|
||||||
## render-config.sh
|
## render-config.sh
|
||||||
@@ -28,8 +30,8 @@ Targets:
|
|||||||
- `drasl/config/config.toml.tmpl` → `drasl/config/config.toml`
|
- `drasl/config/config.toml.tmpl` → `drasl/config/config.toml`
|
||||||
- `nmsr/config.toml.tmpl` → `nmsr/config.toml`
|
- `nmsr/config.toml.tmpl` → `nmsr/config.toml`
|
||||||
|
|
||||||
It then delegates pack templates (`mods/*.pw.toml.tmpl`, CustomSkinLoader, …) +
|
Mods are **not** rendered — they are synced from the distribution by
|
||||||
the packwiz index to `render-pack.sh` — the single source of pack render.
|
`sync-server-mods.sh` (see 04-mods.md), no templating involved.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
@@ -46,7 +48,6 @@ render() { # $1=tmpl $2=out
|
|||||||
|
|
||||||
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
|
||||||
tooling/render-pack.sh
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## render-nginx.sh
|
## render-nginx.sh
|
||||||
@@ -81,7 +82,7 @@ certs/ # Let's Encrypt certs
|
|||||||
## Tasks
|
## Tasks
|
||||||
|
|
||||||
- [ ] Write `tooling/render-config.sh` (above), `chmod +x`
|
- [ ] Write `tooling/render-config.sh` (above), `chmod +x`
|
||||||
- [ ] Write `tooling/render-pack.sh` (pack templates + index)
|
- [ ] Write `tooling/sync-server-mods.sh` (see 04-mods.md)
|
||||||
- [ ] Write `tooling/render-nginx.sh` (see 15)
|
- [ ] Write `tooling/render-nginx.sh` (see 15)
|
||||||
- [ ] Write `tooling/issue-letsencrypt.sh` (see 15)
|
- [ ] Write `tooling/issue-letsencrypt.sh` (see 15)
|
||||||
- [ ] Write `tooling/fetch-launcher.sh` (see 06)
|
- [ ] Write `tooling/fetch-launcher.sh` (see 06)
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ monitor stays green. Run both to tell *server down* apart from *path broken*.
|
|||||||
> Internal `minecraft` resolves because uptime-kuma is on `mcnet` (the container
|
> Internal `minecraft` resolves because uptime-kuma is on `mcnet` (the container
|
||||||
> name is the hostname). It is **not** routed through caddy — caddy only fronts
|
> name is the hostname). It is **not** routed through caddy — caddy only fronts
|
||||||
> HTTP vhosts, and MC is raw TCP. Do not point the monitor at `caddy` or any
|
> HTTP vhosts, and MC is raw TCP. Do not point the monitor at `caddy` or any
|
||||||
> `status.`/`pack.` alias.
|
> `status.` alias.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
@@ -50,6 +50,14 @@ monitor stays green. Run both to tell *server down* apart from *path broken*.
|
|||||||
- Put both MC monitors on the public **status page**
|
- Put both MC monitors on the public **status page**
|
||||||
(Settings → Status Pages) so guests can self-check before pinging you.
|
(Settings → Status Pages) so guests can self-check before pinging you.
|
||||||
- Optional companion HTTP monitors for the web stack:
|
- Optional companion HTTP monitors for the web stack:
|
||||||
`https://auth.${BASE_DOMAIN}`, `https://pack.${BASE_DOMAIN}/pack.toml`,
|
`https://auth.${BASE_DOMAIN}`, `https://distribution.${BASE_DOMAIN}`,
|
||||||
`https://${BASE_DOMAIN}` — a red `pack.toml` here explains client mod-mismatch
|
`https://${BASE_DOMAIN}`, `https://files.${BASE_DOMAIN}` — a red
|
||||||
join failures.
|
`distribution.` here explains client mod-mismatch join failures.
|
||||||
|
|
||||||
|
> **An HTTP monitor cannot see an SPA break.** It asserts "the server returned
|
||||||
|
> 200", which is a much weaker claim than "the app works". `files.` (Filestash)
|
||||||
|
> proved this on 2026-07-14: a bad `general.host` made every client-side redirect
|
||||||
|
> resolve to `http://https://files…`, so the site was unusable in every browser
|
||||||
|
> while `/` kept returning 200 and the monitor stayed **green**. If you want a
|
||||||
|
> monitor with teeth on a JS app, use Kuma's **Keyword** type against a string
|
||||||
|
> the *rendered* page must contain, and don't read green as "users can log in".
|
||||||
|
|||||||
@@ -50,6 +50,15 @@ Dependency-ordered. Each numbered item = one git commit. Operational steps
|
|||||||
17. `feat(nginx): add ulicraft-caddy.conf.tmpl + render-nginx.sh`
|
17. `feat(nginx): add ulicraft-caddy.conf.tmpl + render-nginx.sh`
|
||||||
18. `feat(tls): add issue-letsencrypt.sh (OVH DNS-01) + LE env block`
|
18. `feat(tls): add issue-letsencrypt.sh (OVH DNS-01) + LE env block`
|
||||||
|
|
||||||
|
## Phase 11 — file share (DONE, 2026-07-14)
|
||||||
|
19. `feat(files): add Filestash player file share at files.${BASE_DOMAIN}` —
|
||||||
|
compose service (digest-pinned) + `60-files.caddy` + nginx vhost (2g uploads,
|
||||||
|
`/admin` rate limit) + `filestash/config.json.tmpl` rendered `:ro` +
|
||||||
|
`FILES_*` env block. One shared htpasswd login, writable. See `20-files.md`.
|
||||||
|
- [ops] `files` added to `LE_SUBDOMAINS` → `issue-letsencrypt.sh`, then
|
||||||
|
`render-nginx.sh --install` (a new subdomain needs BOTH; `update-all.sh`
|
||||||
|
does not touch host nginx).
|
||||||
|
|
||||||
## Phase 10 — operational (no commits)
|
## Phase 10 — operational (no commits)
|
||||||
- [ops] Prep online (once): `tooling/render-config.sh`, build landing
|
- [ops] Prep online (once): `tooling/render-config.sh`, build landing
|
||||||
(`cd landing && set -a && . ../.env && set +a && pnpm run build` — sources
|
(`cd landing && set -a && . ../.env && set +a && pnpm run build` — sources
|
||||||
|
|||||||
@@ -56,8 +56,19 @@ Everyday landing/config tweak (no world downtime):
|
|||||||
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && git pull --ff-only && ./update-all.sh'
|
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && git pull --ff-only && ./update-all.sh'
|
||||||
```
|
```
|
||||||
|
|
||||||
Steps `update-all.sh` runs, in order: `render-config.sh` (drasl/nmsr from `.env`;
|
> **Same inode trap for `filestash/config.json`.** It is a single-file bind mount
|
||||||
halts on a bad `REGISTRATION_MODE`) → `sync-server-mods.sh` (mirror the
|
> *and* it is re-rendered on every `update-all.sh` run, so a **rolling** update
|
||||||
|
> after any `FILES_*` change (rotating the shared password, flipping
|
||||||
|
> `FILES_AUTH`) leaves filestash reading the stale inode. Apply with
|
||||||
|
> `docker compose up -d --force-recreate filestash`. `--hard` is unaffected.
|
||||||
|
> Also: never run `docker compose up` on the host without rendering first — a
|
||||||
|
> missing `filestash/config.json` makes Docker create a **directory** at that
|
||||||
|
> path. See `plan/20-files.md`.
|
||||||
|
|
||||||
|
Steps `update-all.sh` runs, in order: `render-config.sh` (drasl/nmsr/filestash
|
||||||
|
from `.env`; halts on a bad `REGISTRATION_MODE`, a bad `FILES_AUTH`, or a
|
||||||
|
`FILES_PASSWORD_HASH` in a format the htpasswd plugin can't verify) →
|
||||||
|
`sync-server-mods.sh` (mirror the
|
||||||
`both`/`server` subset from `mods-sides.json` out of `$DISTRIBUTION_WEB_ROOT`
|
`both`/`server` subset from `mods-sides.json` out of `$DISTRIBUTION_WEB_ROOT`
|
||||||
into `./server-mods`) → `build-modlist.py` (regen landing `mods.json` + logos;
|
into `./server-mods`) → `build-modlist.py` (regen landing `mods.json` + logos;
|
||||||
**run under `python>=3.11`** — cochi's default `python3` is 3.10, but
|
**run under `python>=3.11`** — cochi's default `python3` is 3.10, but
|
||||||
@@ -75,30 +86,25 @@ pull` never updates them. `update-all.sh` regenerates both every run
|
|||||||
`render-config.sh` changed its config (Drasl's `RequireInvite`/CORS/RateLimit) —
|
`render-config.sh` changed its config (Drasl's `RequireInvite`/CORS/RateLimit) —
|
||||||
no world downtime.
|
no world downtime.
|
||||||
|
|
||||||
## Known host-local divergence on `cochi` (uncommitted)
|
## Host-local divergence on `cochi` — RESOLVED (verified 2026-07-14)
|
||||||
|
|
||||||
`cochi` carries deliberate local edits that are **not in the repo**, so
|
**There are no longer any tracked modifications on the host.** `git diff HEAD`
|
||||||
`git pull --ff-only` **aborts** ("local changes would be overwritten") whenever
|
is empty; `git pull --ff-only` fast-forwards cleanly (confirmed on the filestash
|
||||||
an incoming commit also touches `docker-compose.yml`. As of 2026-06-09 the host
|
deploy). The old divergence — `docker-compose.yml` forced `http`→`https`,
|
||||||
has, uncommitted:
|
`landing/package.json`, `landing/pnpm-workspace.yaml` — has been converged into
|
||||||
|
the repo. The stash–pull–pop dance previously documented here is **no longer
|
||||||
|
needed**.
|
||||||
|
|
||||||
- `docker-compose.yml` — internal URLs forced `http`→`https`
|
What remains on the host is **untracked only**, so it never blocks a pull:
|
||||||
(`JVM_OPTS` authlib-injector), for the in-container HTTPS path. (The old
|
|
||||||
`PACKWIZ_URL` divergence is moot — packwiz was removed; see `04-mods.md`.)
|
|
||||||
- `dnsmasq/` (new), `caddy/caddy-root-ca.crt`, `landing/pnpm-workspace.yaml` —
|
|
||||||
internal DNS + private CA so containers resolve/trust `https://auth.` etc.
|
|
||||||
- `landing/package.json` modified. (The former `pack/` divergence is resolved —
|
|
||||||
`pack/` and `custom/` were deleted with the packwiz removal.)
|
|
||||||
|
|
||||||
**Do not steamroll it.** Before a deploy that edits `docker-compose.yml`, either:
|
- `dnsmasq/`, `caddy/caddy-root-ca.crt` — internal DNS + private CA so containers
|
||||||
1. **Converge** — commit the host edits to `main` + push, then a clean ff-pull
|
resolve/trust `https://auth.` etc. Host-local infra, deliberately not in git.
|
||||||
deploys (preferred — ends the divergence). The https/dnsmasq/CA setup belongs
|
- `pack/` — **stale leftover** from the packwiz era (packwiz was removed; see
|
||||||
in the repo.
|
`04-mods.md`). Nothing reads it. Safe to delete.
|
||||||
2. **Stash–pull–pop** — `git stash` → `git pull --ff-only` → `git stash pop`.
|
|
||||||
Non-destructive; safe only when the incoming and host edits are in different
|
|
||||||
regions of the file. Stop on a pop conflict.
|
|
||||||
|
|
||||||
Never `git reset --hard` / discard on the host without confirming first.
|
Still true, and worth keeping: never `git reset --hard` or discard on the host
|
||||||
|
without confirming first, and re-check `git status` there before any deploy that
|
||||||
|
touches tracked files — a *new* host-local edit would reintroduce the abort.
|
||||||
|
|
||||||
## What survives a hard restart
|
## What survives a hard restart
|
||||||
|
|
||||||
@@ -116,8 +122,15 @@ restart (a few minutes); they reconnect once `minecraft` is healthy again.
|
|||||||
- SSH access as the `cochi` alias.
|
- SSH access as the `cochi` alias.
|
||||||
- `.env` present and complete: `BASE_DOMAIN`, `RCON_PASSWORD`, `CADDY_HTTP_PORT`,
|
- `.env` present and complete: `BASE_DOMAIN`, `RCON_PASSWORD`, `CADDY_HTTP_PORT`,
|
||||||
`CADDY_HTTP_BIND`, `DISTRIBUTION_WEB_ROOT`, `REGISTRATION_MODE` (`invite`|`open`,
|
`CADDY_HTTP_BIND`, `DISTRIBUTION_WEB_ROOT`, `REGISTRATION_MODE` (`invite`|`open`,
|
||||||
defaults `invite`), and the Let's Encrypt block (`render-config.sh` halts on an
|
defaults `invite`), the `FILES_*` block (`plan/20-files.md`), and the Let's
|
||||||
unset `BASE_DOMAIN` or an invalid `REGISTRATION_MODE`).
|
Encrypt block. `render-config.sh` halts on an unset `BASE_DOMAIN`, an invalid
|
||||||
|
`REGISTRATION_MODE`/`FILES_AUTH`, or a `FILES_PASSWORD_HASH` the htpasswd
|
||||||
|
plugin can't verify.
|
||||||
|
> **A missing `FILES_*` var takes the WHOLE stack down, not just filestash.**
|
||||||
|
> The compose service uses `${FILES_DATA_DIR:?}` / `${FILES_LOCAL_SECRET:?}`
|
||||||
|
> guards, and compose fails the entire file on an unset required var — so
|
||||||
|
> `up` starts *nothing*, and a `--hard` deploy has already run `down`. Check
|
||||||
|
> `.env` on the host **before** deploying a commit that adds a guarded service.
|
||||||
- Docker + compose plugin, `envsubst`, `jq` (used by `sync-server-mods.sh`;
|
- Docker + compose plugin, `envsubst`, `jq` (used by `sync-server-mods.sh`;
|
||||||
falls back to `python3` if absent), `git`.
|
falls back to `python3` if absent), `git`.
|
||||||
- Host nginx + Let's Encrypt certs installed once — see `15-letsencrypt.md`.
|
- Host nginx + Let's Encrypt certs installed once — see `15-letsencrypt.md`.
|
||||||
@@ -127,11 +140,15 @@ restart (a few minutes); they reconnect once `minecraft` is healthy again.
|
|||||||
## Verify
|
## Verify
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose ps # caddy, drasl, minecraft, mc-backup, nmsr, mc-status, uptime-kuma up
|
docker compose ps # caddy drasl minecraft mc-backup nmsr mc-status uptime-kuma filestash
|
||||||
# self-hosted live status feed (plan/15-mc-status.md):
|
# self-hosted live status feed (plan/15-mc-status.md):
|
||||||
curl -fsS "https://$(grep '^BASE_DOMAIN=' .env | cut -d= -f2)/api/mcstatus/v2/status/java/$(grep '^BASE_DOMAIN=' .env | cut -d= -f2)" | jq .online
|
curl -fsS "https://$(grep '^BASE_DOMAIN=' .env | cut -d= -f2)/api/mcstatus/v2/status/java/$(grep '^BASE_DOMAIN=' .env | cut -d= -f2)" | jq .online
|
||||||
docker compose logs -f minecraft # watch for "Done"
|
# server ready — match the full marker, not a bare "Done" (that also hits mod-load lines)
|
||||||
|
docker compose logs -f minecraft | grep -m1 'Done ([0-9.]*s)! For help'
|
||||||
curl -fsS "https://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"
|
||||||
|
# files. — 200 = live; anonymous API must be REFUSED (auth is on)
|
||||||
|
curl -s -o /dev/null -w 'files: %{http_code}\n' "https://files.$(grep '^BASE_DOMAIN=' .env | cut -d= -f2)/"
|
||||||
|
curl -s -H 'X-Requested-With: XmlHttpRequest' "https://files.$(grep '^BASE_DOMAIN=' .env | cut -d= -f2)/api/session" # want "Not authorised"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Rollback
|
## Rollback
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ path — there are no compose overrides.
|
|||||||
## 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.…`, plus the rest of
|
subdomain (`${BASE_DOMAIN}`, `auth.…`, plus the rest of
|
||||||
`LE_SUBDOMAINS`) using acme.sh + the OVH DNS-01 challenge — no inbound ports
|
`LE_SUBDOMAINS`) using acme.sh + the OVH DNS-01 challenge — no inbound ports
|
||||||
needed, so it works regardless of public reachability.
|
needed, so it works regardless of public reachability.
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ the reload with `RELOAD_CMD=...` if nginx isn't systemd-managed.
|
|||||||
## DNS
|
## DNS
|
||||||
|
|
||||||
Point the public DNS for `${BASE_DOMAIN}` and every subdomain
|
Point the public DNS for `${BASE_DOMAIN}` and every subdomain
|
||||||
(`auth.` `pack.` `avatar.` `status.` `distribution.` `www.`) at the host running
|
(`auth.` `avatar.` `status.` `distribution.` `www.`) at the host running
|
||||||
nginx. DNS is configured **outside this repo**. The Minecraft container reaches
|
nginx. DNS is configured **outside this repo**. The Minecraft container reaches
|
||||||
the stack's own names via caddy's `mcnet` aliases, not public DNS.
|
the stack's own names via caddy's `mcnet` aliases, not public DNS.
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
| `auth.${BASE_DOMAIN}` | `00-core.caddy` | `drasl:25585` |
|
| `auth.${BASE_DOMAIN}` | `00-core.caddy` | `drasl:25585` |
|
||||||
| `avatar.${BASE_DOMAIN}` | `40-avatar.caddy` | `nmsr:8080` |
|
| `avatar.${BASE_DOMAIN}` | `40-avatar.caddy` | `nmsr:8080` |
|
||||||
| `status.${BASE_DOMAIN}` | `50-status.caddy` | `uptime-kuma:3001` |
|
| `status.${BASE_DOMAIN}` | `50-status.caddy` | `uptime-kuma:3001` |
|
||||||
|
| `files.${BASE_DOMAIN}` | `60-files.caddy` | `filestash:8334` |
|
||||||
| `distribution.${BASE_DOMAIN}` | `30-distribution.caddy` | static `/srv/distribution` (other repo) |
|
| `distribution.${BASE_DOMAIN}` | `30-distribution.caddy` | static `/srv/distribution` (other repo) |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -58,6 +59,37 @@ base `https://auth.${BASE_DOMAIN}/drasl/api/v2` (`site.draslApiUrl`):
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Files — `files.${BASE_DOMAIN}` (Filestash player share)
|
||||||
|
|
||||||
|
`reverse_proxy filestash:8334`. Screenshots / schematics / maps. **One shared
|
||||||
|
login** for every player (`FILES_AUTH=htpasswd`), writable. Full design:
|
||||||
|
`plan/20-files.md`.
|
||||||
|
|
||||||
|
- `/` — SPA. The login form renders **only** at
|
||||||
|
`GET /api/session/auth/?action=redirect&label=files`. Without `action=redirect`
|
||||||
|
the request falls through to the credential check, fails with empty creds and
|
||||||
|
303s back to `?action=redirect` — so a bare `/api/session/auth/?label=files`
|
||||||
|
returning a 303 is **normal, not a bug**. `POST` with `user`/`password`
|
||||||
|
authenticates.
|
||||||
|
- `GET /api/config` → `result.origin` must be `https://files.${BASE_DOMAIN}`.
|
||||||
|
It's built as `(force_ssl ? https:// : http://) + general.host`, so a scheme in
|
||||||
|
`general.host` yields `http://https://…` and silently breaks every browser
|
||||||
|
while the server keeps answering 200. Check this after any config change.
|
||||||
|
- `/api/*` — JSON API. Requires the header `X-Requested-With: XmlHttpRequest`
|
||||||
|
(the SPA sends it); without it Filestash's intrusion detection 403s the
|
||||||
|
request. Relevant when curling it by hand.
|
||||||
|
- `/admin` — admin console, own bcrypt password (`auth.admin`). Deliberately
|
||||||
|
public; nginx throttles it (`limit_req`, 6r/m). It **cannot save** — the
|
||||||
|
config is a read-only mount.
|
||||||
|
- **`?label=files` matters.** The attribute mapping is keyed by the connection
|
||||||
|
label; auth without it yields `backend error - Not Allowed`.
|
||||||
|
|
||||||
|
Filestash blocks any request whose `Host` doesn't match its `general.host`
|
||||||
|
(`https://files.${BASE_DOMAIN}`) — nginx forwards the original Host and caddy
|
||||||
|
passes it through, so don't rewrite it anywhere in the chain.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Auth / Drasl — `auth.${BASE_DOMAIN}`
|
## Auth / Drasl — `auth.${BASE_DOMAIN}`
|
||||||
|
|
||||||
`reverse_proxy drasl:25585`. One service exposes three surfaces:
|
`reverse_proxy drasl:25585`. One service exposes three surfaces:
|
||||||
|
|||||||
203
plan/20-files.md
Normal file
203
plan/20-files.md
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
# 20 — Filestash (`files.${BASE_DOMAIN}`)
|
||||||
|
|
||||||
|
> Web file share for player media (screenshots, schematics, maps, guides).
|
||||||
|
> One [Filestash](https://github.com/mickael-kerjean/filestash) container behind
|
||||||
|
> caddy, **one shared username/password for everybody**, **writable**.
|
||||||
|
> Config is rendered read-only from a template, like drasl/nmsr.
|
||||||
|
>
|
||||||
|
> **Not** a backup browser, **not** a mod mirror. See "What goes in it".
|
||||||
|
>
|
||||||
|
> Everything below was verified against the real image (digest pinned in
|
||||||
|
> compose) and against the upstream source, not inferred from the docs — the
|
||||||
|
> docs omit most of it.
|
||||||
|
|
||||||
|
## Shape
|
||||||
|
|
||||||
|
```
|
||||||
|
Internet ── host nginx (TLS, LE cert for files.) ──► caddy ──► filestash:8334
|
||||||
|
│
|
||||||
|
${FILES_DATA_DIR} ──┘ (bind mount)
|
||||||
|
```
|
||||||
|
|
||||||
|
| Thing | Value |
|
||||||
|
|---|---|
|
||||||
|
| Vhost | `files.${BASE_DOMAIN}` |
|
||||||
|
| Image | `machines/filestash` **pinned by digest** (upstream ships only `latest`) |
|
||||||
|
| Container port | `8334`, runs as **uid/gid 1000** (`filestash`) |
|
||||||
|
| Auth | htpasswd, **one shared account**, `FILES_USER` / `FILES_PASSWORD_HASH` |
|
||||||
|
| Access | read **+ write** for anyone with the password |
|
||||||
|
| Data | `${FILES_DATA_DIR}` on the host, **outside the repo** |
|
||||||
|
| State | named volume `filestash_state` → `/app/data/state` |
|
||||||
|
| Config | `filestash/config.json` rendered from `.tmpl`, mounted `:ro` |
|
||||||
|
| Backups | **none** — outside mc-backup's scope. Treat as disposable. |
|
||||||
|
| Quota | none — eyeballed |
|
||||||
|
|
||||||
|
## Decisions (settled — don't relitigate)
|
||||||
|
|
||||||
|
**Auth is htpasswd with one shared credential, not SSO.** Filestash's OIDC and
|
||||||
|
SAML middlewares are **enterprise-only**; the community build compiles in
|
||||||
|
`htpasswd`, `ldap`, `passthrough`, `wordpress`, `local` only (verified in
|
||||||
|
`server/plugin/index.go`). Keycloak was considered and dropped — it would need an
|
||||||
|
oauth2-proxy sidecar in front + `passthrough`. That path stays open but is not
|
||||||
|
built.
|
||||||
|
|
||||||
|
**One instance, everything behind the password.** No anonymous read tier.
|
||||||
|
Filestash's auth middleware is **global to the instance**, so "anonymous read +
|
||||||
|
authenticated write" is impossible in one container (that's the enterprise
|
||||||
|
RBAC). A two-instance split was designed and rejected as not worth it.
|
||||||
|
|
||||||
|
**`FILES_AUTH=none` is gated on the host being internal-only.** The off-switch
|
||||||
|
exists for when this server moves behind a LAN/VPN. **Do not set it while
|
||||||
|
`files.` is internet-reachable** — auth-off on a *writable* public share is an
|
||||||
|
open upload relay: someone parks malware or a phishing kit on your domain, under
|
||||||
|
your LE cert, and the abuse mail, the bandwidth, and a possible domain
|
||||||
|
blocklisting (which would take `auth.` and the apex down with it, breaking logins
|
||||||
|
and the launcher for every player) all land on you. `render-config.sh` prints a
|
||||||
|
loud warning when it renders this mode.
|
||||||
|
|
||||||
|
**Shared credential ⇒ no audit trail, no per-person revocation.** You will never
|
||||||
|
know who deleted a folder; rotating means re-telling everyone. Fine for a
|
||||||
|
friends' media share — but nothing here is backed up, so treat the share as
|
||||||
|
disposable and keep anything you'd miss elsewhere.
|
||||||
|
|
||||||
|
## What goes in it
|
||||||
|
|
||||||
|
| Content | Verdict |
|
||||||
|
|---|---|
|
||||||
|
| Screenshots, schematics, maps, guides | ✅ the point |
|
||||||
|
| World downloads / `./backups/*.tgz` | ❌ contains `playerdata/`, `stats/`, the full map — every player's inventory and coords, handed to anyone with the shared password |
|
||||||
|
| Mods / jars | ❌ redistribution; `distribution.${BASE_DOMAIN}` already serves the modset to launchers |
|
||||||
|
|
||||||
|
## `.env`
|
||||||
|
|
||||||
|
See `.env.example` for the annotated block. Summary:
|
||||||
|
|
||||||
|
| Var | What |
|
||||||
|
|---|---|
|
||||||
|
| `FILES_DATA_DIR` | host path of the share, outside the repo |
|
||||||
|
| `FILES_MOUNT_MODE` | literal mount flag, `rw` \| `ro` |
|
||||||
|
| `FILES_AUTH` | `htpasswd` \| `passthrough` \| `none` |
|
||||||
|
| `FILES_USER` | shared login name |
|
||||||
|
| `FILES_PASSWORD_HASH` | **`$6$`**, from `openssl passwd -6 '<pw>'` |
|
||||||
|
| `FILES_ADMIN_PASSWORD_HASH` | bcrypt, from `docker run --rm caddy:alpine caddy hash-password --plaintext '<pw>'` |
|
||||||
|
| `FILES_SECRET_KEY` | `openssl rand -hex 16`, must be non-empty |
|
||||||
|
| `FILES_LOCAL_SECRET` | `openssl rand -hex 24`, unlocks the local backend |
|
||||||
|
| `LE_SUBDOMAINS` | must include `files` |
|
||||||
|
|
||||||
|
`filestash/config.json` is **rendered and gitignored** — it holds every hash and
|
||||||
|
both secrets. Only the `.tmpl` is tracked.
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
```sh
|
||||||
|
tooling/render-config.sh # -> filestash/config.json (validates the JSON)
|
||||||
|
tooling/issue-letsencrypt.sh # picks up `files` from LE_SUBDOMAINS
|
||||||
|
tooling/render-nginx.sh --install # adds the files. vhost + reloads nginx
|
||||||
|
mkdir -p "$FILES_DATA_DIR" # uid 1000 must be able to write it
|
||||||
|
docker compose up -d filestash caddy
|
||||||
|
```
|
||||||
|
|
||||||
|
Then add an Uptime Kuma HTTP monitor for `https://files.${BASE_DOMAIN}` (expect
|
||||||
|
`200` — the login page is a 200) and put it on the status page with the others.
|
||||||
|
|
||||||
|
## Gotchas (all of these were hit for real)
|
||||||
|
|
||||||
|
### The `local` backend is admin-gated — this is the one that wastes an evening
|
||||||
|
`plg_backend_local.Init()` **refuses to start** unless the connection params
|
||||||
|
carry `$LOCAL_BACKEND_SECRET` or the bcrypt admin password. Miss it and every
|
||||||
|
login dies at `backend error - Not Allowed`, which reads like a credentials
|
||||||
|
problem and isn't. `config.json`'s `attribute_mapping` injects the secret
|
||||||
|
server-side (Filestash calls this the "facade pattern"), so players never see it.
|
||||||
|
`FILES_LOCAL_SECRET` must be **identical** in the compose env and the rendered
|
||||||
|
config — `render-config.sh` renders both from the same var.
|
||||||
|
|
||||||
|
### `$2y$` bcrypt silently fails every login
|
||||||
|
The htpasswd plugin's bcrypt branch matches on the literal prefix **`$2a$`**.
|
||||||
|
`htpasswd -B` emits `$2y$`, which falls through to `return false` — so every
|
||||||
|
login fails with correct credentials and no useful log line. Use
|
||||||
|
**`openssl passwd -6`** (`$6$`, sha512) for `FILES_PASSWORD_HASH`.
|
||||||
|
`render-config.sh` hard-fails on a `$2y$`/`$2b$` hash rather than shipping a
|
||||||
|
config nobody can log into. (The *admin* password is checked by Go's bcrypt
|
||||||
|
directly, which does accept `$2y$` — but `caddy hash-password` gives `$2a$`
|
||||||
|
anyway, so just use that for both.)
|
||||||
|
|
||||||
|
### `general.host` is a BARE HOSTNAME — a scheme breaks the browser only
|
||||||
|
`"host": "files.ulicraft.net"`, **not** `"https://files.ulicraft.net"`, plus
|
||||||
|
`"force_ssl": true`. The SPA computes its redirect origin as
|
||||||
|
`(force_ssl ? "https://" : "http://") + host`, so a scheme in `host` yields
|
||||||
|
**`http://https://files.ulicraft.net`** and every client-side redirect dies —
|
||||||
|
the page loads, then silently fails to route anywhere.
|
||||||
|
|
||||||
|
The vicious part: the server-side Host check (`SecureOrigin`) **strips** the
|
||||||
|
scheme before comparing, so the server is perfectly happy. `curl /` returns 200,
|
||||||
|
the API answers, and the Uptime Kuma monitor stays green — **only real browsers
|
||||||
|
break**. Symptom in the log is a `POST /report?...msg=Redirecting to
|
||||||
|
http://https://...` line. Hit this on 2026-07-14; fixed in `config.json.tmpl`.
|
||||||
|
|
||||||
|
`force_ssl` only sets an HSTS header — it does **not** redirect, so it can't loop
|
||||||
|
behind the nginx→caddy (plain http) hop. It must be `true` or the SPA builds
|
||||||
|
`http://` origins on an https page.
|
||||||
|
|
||||||
|
### `general.host` must also match the incoming `Host` header
|
||||||
|
Filestash blocks every non-`/admin/` request whose `Host` differs from
|
||||||
|
`general.host`, with *"only traffic from X is allowed"* — a 403 that looks like
|
||||||
|
an auth failure. nginx forwards the original Host and caddy passes it through —
|
||||||
|
don't rewrite it anywhere.
|
||||||
|
|
||||||
|
### The login form only renders at `?action=redirect`
|
||||||
|
`GET /api/session/auth/` renders the htpasswd form **only** when
|
||||||
|
`action=redirect` is present; any other request falls through to the credential
|
||||||
|
check, fails with empty creds, and 303s back to `?action=redirect`. So the real
|
||||||
|
form URL is `/api/session/auth/?action=redirect&label=files`. A bare
|
||||||
|
`/api/session/auth/?label=files` returning a 303 is **normal**, not a bug.
|
||||||
|
|
||||||
|
### `config.json` is a single-file bind mount → stale inode
|
||||||
|
Same shape as the caddy `conf.d` gotcha. The mount pins the **inode**, so
|
||||||
|
re-rendering (or a `git pull`) writes a *new* file while the container keeps
|
||||||
|
reading the *old* one.
|
||||||
|
|
||||||
|
**After re-rendering: `docker compose up -d --force-recreate filestash`.**
|
||||||
|
`restart` is not enough. Rotating the shared password needs a recreate too.
|
||||||
|
|
||||||
|
### Don't set `APPLICATION_URL` or `ADMIN_PASSWORD` env
|
||||||
|
Either one makes Filestash **rewrite `config.json` at boot**, which fails against
|
||||||
|
the `:ro` mount. Both live in the rendered config instead (`general.host` /
|
||||||
|
`auth.admin`). `general.secret_key` must likewise be non-empty, or Filestash
|
||||||
|
generates one and tries to save that. With all three set in the file, Filestash
|
||||||
|
never attempts a boot write and the `:ro` mount is clean — you get one harmless
|
||||||
|
`cannot chmod config file` WARN in the log, and nothing else.
|
||||||
|
|
||||||
|
### The admin console cannot save
|
||||||
|
`/admin` loads and its login works, but `config.json` is `:ro`, so every save
|
||||||
|
silently fails. That's the deliberate trade for keeping git authoritative
|
||||||
|
(drasl/nmsr never write their configs; Filestash does, which is exactly why it
|
||||||
|
needs pinning). To change config: edit the `.tmpl`, re-render, recreate. To
|
||||||
|
re-derive the schema after an upstream upgrade: run the image locally against a
|
||||||
|
*writable* state volume, click it into shape, `docker cp` the result out.
|
||||||
|
|
||||||
|
### The API needs `X-Requested-With: XmlHttpRequest`
|
||||||
|
The SPA sends it; anything else (curl, a naive monitor) trips intrusion
|
||||||
|
detection and gets a 403 that looks like an auth bug. Only matters when probing
|
||||||
|
by hand — the Kuma monitor hits `/`, which is fine.
|
||||||
|
|
||||||
|
### Disk is the shared failure domain
|
||||||
|
`FILES_DATA_DIR` grows without a quota, driven by whoever has the password. A
|
||||||
|
full disk on `cochi` does not just break `files.` — it takes **Minecraft, Drasl,
|
||||||
|
and mc-backup** down with it. Eyeballing is the accepted plan. Revisit (quota,
|
||||||
|
separate partition, or a Kuma disk monitor) if the share sees real use.
|
||||||
|
|
||||||
|
### Pinned digest, not `latest`
|
||||||
|
`machines/filestash` publishes essentially only `latest`. Unpinned, any
|
||||||
|
`docker compose pull` silently ships a new upstream build of an internet-facing,
|
||||||
|
writable file manager. Pin the digest; upgrade deliberately — same discipline as
|
||||||
|
`NEOFORGE_VERSION`.
|
||||||
|
|
||||||
|
## Deferred
|
||||||
|
|
||||||
|
- **Keycloak SSO** — needs oauth2-proxy + `FILES_AUTH=passthrough`. Not built.
|
||||||
|
- **Anonymous access** (`FILES_AUTH=none`) — only once the host is internal-only.
|
||||||
|
- **Landing page link** — deliberately none on day one; `plan/18-landing-rework.md`
|
||||||
|
is mid-rework, so revisit with WS5 (footer) rather than merging into a moving
|
||||||
|
design.
|
||||||
|
- **Per-user accounts / roles** — htpasswd RBAC is enterprise-only.
|
||||||
|
- **Backups / quota** — none.
|
||||||
56
server-configs/voicechat-server.properties
Normal file
56
server-configs/voicechat-server.properties
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# Simple Voice Chat server config v1.21.1-2.6.18
|
||||||
|
|
||||||
|
# The port number to use for the voice chat communication.
|
||||||
|
# Audio packets are always transmitted via the UDP protocol on the port number
|
||||||
|
# specified here, independently of other networking used for the game server.
|
||||||
|
# Set this to '-1' to use the same port number that is used by the Minecraft server.
|
||||||
|
# However, it is strongly recommended NOT to use the same port number because UDP on
|
||||||
|
# it is also used by default for the server query. Doing so may crash the server!
|
||||||
|
port=24454
|
||||||
|
# The server IP address to bind the voice chat to
|
||||||
|
# Leave blank to use the 'server-ip' property from the 'server.properties' config file
|
||||||
|
# To bind to the wildcard IP address, use '*'
|
||||||
|
bind_address=
|
||||||
|
# The distance to which the voice can be heard
|
||||||
|
max_voice_distance=48.0
|
||||||
|
# The distance to which the voice can be heard when whispering
|
||||||
|
whisper_distance=24.0
|
||||||
|
# The Opus codec
|
||||||
|
# Valid values are 'VOIP', 'AUDIO', and 'RESTRICTED_LOWDELAY'
|
||||||
|
codec=VOIP
|
||||||
|
# The maximum size that audio packets are allowed to have (in bytes)
|
||||||
|
# Setting this to lower values might cause issues
|
||||||
|
mtu_size=1275
|
||||||
|
# The maximum number of packets a player can send per second
|
||||||
|
# Set this to -1 to disable the rate limit - This must be greater than 0 in all other cases
|
||||||
|
# This only applies to voice chat packets that are sent through Minecrafts networking
|
||||||
|
# This affects actions like opening/joining/leaving voice chat groups or general state changes like disabling/enabling the voice chat
|
||||||
|
tcp_rate_limit=16
|
||||||
|
# The frequency at which keep-alive packets are sent (in milliseconds)
|
||||||
|
# Setting this to a higher value may result in timeouts
|
||||||
|
keep_alive=1000
|
||||||
|
# If group chats are allowed
|
||||||
|
enable_groups=true
|
||||||
|
# The hostname that clients should use to connect to the voice chat
|
||||||
|
# This may also include a port, e.g. 'example.com:24454' or just a port, e.g. '24454'
|
||||||
|
# Do NOT change this value if you don't know what you're doing
|
||||||
|
voice_host=ulicraft.net
|
||||||
|
# If players are allowed to record the voice chat audio
|
||||||
|
allow_recording=true
|
||||||
|
# If spectators are allowed to talk to other players
|
||||||
|
spectator_interaction=false
|
||||||
|
# If spectators can talk to players they are spectating
|
||||||
|
spectator_player_possession=false
|
||||||
|
# If players without the voice chat mod should be kicked from the server
|
||||||
|
force_voice_chat=false
|
||||||
|
# The amount of time the server should wait to check if a player has the mod installed (in milliseconds)
|
||||||
|
# Only relevant when 'force_voice_chat' is set to 'true'
|
||||||
|
login_timeout=10000
|
||||||
|
# The range in which the voice chat should broadcast audio
|
||||||
|
# A value less than 0 means 'max_voice_distance'
|
||||||
|
broadcast_range=-1.0
|
||||||
|
# If the voice chat server should reply to external pings
|
||||||
|
allow_pings=true
|
||||||
|
# If the mod should load native libraries on dedicated servers
|
||||||
|
# This is mostly relevant for voice chat addons
|
||||||
|
use_natives=true
|
||||||
@@ -29,6 +29,67 @@ render() { # $1=template $2=output
|
|||||||
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
|
||||||
|
|
||||||
|
# ── filestash (files.${BASE_DOMAIN}) ────────────────────────────────────
|
||||||
|
# Filestash has NO env-var config: config.json is the source of truth and the
|
||||||
|
# admin console writes it back at runtime. We render it and mount it read-only,
|
||||||
|
# so git stays authoritative (the console renders but cannot save — by design).
|
||||||
|
#
|
||||||
|
# general.host in the template is a BARE HOSTNAME (files.$BASE_DOMAIN, no
|
||||||
|
# scheme) and general.force_ssl is true. The SPA builds its redirect origin as
|
||||||
|
# (force_ssl ? "https://" : "http://") + host, so a scheme in `host` produces
|
||||||
|
# "http://https://files..." and every client-side redirect breaks. The
|
||||||
|
# server-side Host check strips the scheme first, so curl/monitors see a healthy
|
||||||
|
# 200 and ONLY browsers fail. Don't "fix" it by putting the URL back.
|
||||||
|
#
|
||||||
|
# FILES_AUTH picks the identity_provider block. The params fields are JSON
|
||||||
|
# *strings* holding escaped JSON (that's Filestash's schema, not a typo) —
|
||||||
|
# built here rather than in the template because the shape differs per mode.
|
||||||
|
: "${FILES_AUTH:=htpasswd}"
|
||||||
|
: "${FILES_SECRET_KEY:?FILES_SECRET_KEY unset (openssl rand -hex 16)}"
|
||||||
|
: "${FILES_ADMIN_PASSWORD_HASH:?FILES_ADMIN_PASSWORD_HASH unset (docker run --rm caddy:alpine caddy hash-password --plaintext '<pw>')}"
|
||||||
|
: "${FILES_LOCAL_SECRET:?FILES_LOCAL_SECRET unset (openssl rand -hex 24) — unlocks the admin-gated local backend}"
|
||||||
|
|
||||||
|
case "$FILES_AUTH" in
|
||||||
|
htpasswd)
|
||||||
|
: "${FILES_USER:?FILES_USER unset (shared login for players)}"
|
||||||
|
: "${FILES_PASSWORD_HASH:?FILES_PASSWORD_HASH unset (openssl passwd -6 '<pw>')}"
|
||||||
|
# The htpasswd plugin's bcrypt branch only accepts $2a$ — a $2y$ hash (what
|
||||||
|
# `htpasswd -B` emits) silently fails every login. $6$ (openssl passwd -6)
|
||||||
|
# is the safe, explicitly-supported format. Halt rather than ship a config
|
||||||
|
# nobody can log into.
|
||||||
|
case "$FILES_PASSWORD_HASH" in
|
||||||
|
'$6$'*|'$5$'*|'$2a$'*|'$apr1$'*|'$1$'*) ;;
|
||||||
|
'$2y$'*|'$2b$'*)
|
||||||
|
echo "FILES_PASSWORD_HASH is bcrypt '${FILES_PASSWORD_HASH:0:4}' — the htpasswd plugin only accepts \$2a\$." >&2
|
||||||
|
echo "Use: openssl passwd -6 '<password>'" >&2
|
||||||
|
exit 1 ;;
|
||||||
|
*) echo "FILES_PASSWORD_HASH is not a recognised hash (want \$6\$… from: openssl passwd -6 '<password>')" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
FILES_IDP_TYPE="htpasswd"
|
||||||
|
FILES_IDP_PARAMS="{\\\"type\\\":\\\"htpasswd\\\",\\\"users\\\":\\\"${FILES_USER}:${FILES_PASSWORD_HASH}\\\"}"
|
||||||
|
;;
|
||||||
|
passthrough|none)
|
||||||
|
# NO LOGIN — anyone who reaches the vhost gets in. Only safe once this host
|
||||||
|
# is NOT reachable from the internet (see plan/20-files.md).
|
||||||
|
echo "!! FILES_AUTH=$FILES_AUTH — filestash will have NO authentication." >&2
|
||||||
|
echo "!! Only valid if files.${BASE_DOMAIN} is unreachable from the internet." >&2
|
||||||
|
FILES_IDP_TYPE="passthrough"
|
||||||
|
FILES_IDP_PARAMS="{\\\"type\\\":\\\"passthrough\\\",\\\"strategy\\\":\\\"direct\\\"}"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "FILES_AUTH must be 'htpasswd', 'passthrough' or 'none' (got '$FILES_AUTH')" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
export FILES_IDP_TYPE FILES_IDP_PARAMS
|
||||||
|
|
||||||
|
envsubst '${BASE_DOMAIN} ${FILES_IDP_TYPE} ${FILES_IDP_PARAMS} ${FILES_ADMIN_PASSWORD_HASH} ${FILES_SECRET_KEY} ${FILES_LOCAL_SECRET}' \
|
||||||
|
< filestash/config.json.tmpl > filestash/config.json
|
||||||
|
|
||||||
|
# The hashes contain '$' and the params fields are nested JSON — one bad escape
|
||||||
|
# yields a config filestash silently ignores. Fail loudly instead.
|
||||||
|
python3 -c 'import json,sys; json.load(open("filestash/config.json"))' \
|
||||||
|
|| { echo "filestash/config.json is not valid JSON — refusing to ship it" >&2; exit 1; }
|
||||||
|
echo "rendered filestash/config.json (auth=$FILES_AUTH)"
|
||||||
|
|
||||||
# Server mods are no longer rendered here — they come from the distribution repo
|
# Server mods are no longer rendered here — they come from the distribution repo
|
||||||
# via tooling/sync-server-mods.sh (run separately in the deploy flow). See
|
# via tooling/sync-server-mods.sh (run separately in the deploy flow). See
|
||||||
# plan/04-mods.md.
|
# plan/04-mods.md.
|
||||||
|
|||||||
Reference in New Issue
Block a user