Compare commits
97
Commits
landing
...
fe91bf7778
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe91bf7778 | ||
|
|
0176a674e3 | ||
|
|
671e6af05a | ||
|
|
fbed9a5af7 | ||
|
|
40fb50e86f | ||
|
|
d105fc9acf | ||
|
|
3eb20054a0 | ||
|
|
bac2372c95 | ||
|
|
bc99427ef9 | ||
|
|
00984e1a70 | ||
|
|
53604a6127 | ||
|
|
fca6747bcc | ||
|
|
8507ffd020 | ||
|
|
92c6f5fc8f | ||
|
|
dfb8719074 | ||
|
|
a3c97af374 | ||
|
|
5e5367e6d5 | ||
|
|
95f97a3b75 | ||
|
|
e831d8b9f3 | ||
|
|
6b317d179a | ||
|
|
16523e4f62 | ||
|
|
243787c530 | ||
|
|
9322e39a8c | ||
|
|
05a98efe57 | ||
|
|
b9bcc13b73 | ||
|
|
00e9815307 | ||
|
|
b6b02398da | ||
|
|
7164371d34 | ||
|
|
0bf42d5797 | ||
|
|
08fe8c9c53 | ||
|
|
10b86ae23e | ||
|
|
cd79b0e540 | ||
|
|
25df9f9398 | ||
|
|
1958a96acf | ||
|
|
e45ce210ba | ||
|
|
d12071df04 | ||
|
|
a67b9cf59e | ||
|
|
d303d23d18 | ||
|
|
614e47598b | ||
|
|
69871c8a93 | ||
|
|
015bf7fbe3 | ||
|
|
d0bc81f0f7 | ||
|
|
35be2e6b76 | ||
|
|
33b9ebc7f6 | ||
|
|
b9929da54d | ||
|
|
36c28362bc | ||
|
|
07a4ae469d | ||
|
|
2c3e4cf385 | ||
|
|
4855447b29 | ||
|
|
14ef545bbb | ||
|
|
7222904c91 | ||
|
|
8a71709048 | ||
|
|
db63aa7d10 | ||
|
|
39b9262ee9 | ||
|
|
037b1777d6 | ||
|
|
1019da66a3 | ||
|
|
0b098ae06e | ||
|
|
db270a9695 | ||
|
|
4750c0d6c7 | ||
|
|
220b43d007 | ||
|
|
37d21cacf9 | ||
|
|
3af14d0144 | ||
|
|
369b4dc0a0 | ||
|
|
c3b2d89ddc | ||
|
|
362228f985 | ||
|
|
c8a6c77a8e | ||
|
|
c233b1bfbc | ||
|
|
de1ac2ee17 | ||
|
|
5d5602fbd7 | ||
|
|
97347693b6 | ||
|
|
a138bbfd72 | ||
|
|
6c7e259fcb | ||
|
|
096ef82420 | ||
|
|
c249172baa | ||
|
|
fa8cede6ae | ||
|
|
84196d59d0 | ||
|
|
27237bc7c1 | ||
|
|
90c3be7bb5 | ||
|
|
d21a7f0e92 | ||
|
|
df8ffca53e | ||
|
|
067e990306 | ||
|
|
673202e1e4 | ||
|
|
1ec3a9c1ee | ||
|
|
663b87a4e4 | ||
|
|
a2821f2bbc | ||
|
|
cfd60131fb | ||
|
|
95c63e1ee0 | ||
|
|
46103495fb | ||
|
|
1709bcd340 | ||
|
|
67d1f82e6a | ||
|
|
ff645de2c8 | ||
|
|
6df885eb13 | ||
|
|
6fac1647aa | ||
|
|
a6155819b6 | ||
|
|
3185fea4c1 | ||
|
|
f3c38da56c | ||
|
|
21e6080e78 |
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: deploy
|
name: deploy
|
||||||
description: Redeploy the Ulicraft Minecraft stack to the production host `cochi` — git pull on main then a hard restart (compose down + build-stack.sh --up online). Use when the user says "deploy", "redeploy", "deploy to cochi", "push to prod", "ship it", or "restart the server" for this project. Causes brief Minecraft downtime.
|
description: Redeploy the Ulicraft Minecraft stack to the production host `cochi` — git pull on main then a hard restart (compose down + up). Use when the user says "deploy", "redeploy", "deploy to cochi", "push to prod", "ship it", or "restart the server" for this project. Causes brief Minecraft downtime.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Deploy Ulicraft to `cochi`
|
# Deploy Ulicraft to `cochi`
|
||||||
@@ -13,8 +13,9 @@ Redeploy the running stack on the production host. Full reference:
|
|||||||
- Host: `cochi` (SSH alias)
|
- Host: `cochi` (SSH alias)
|
||||||
- Path: `/home/ubuntu/mc/ulicraft-server-v1`
|
- Path: `/home/ubuntu/mc/ulicraft-server-v1`
|
||||||
- Branch: `main`
|
- Branch: `main`
|
||||||
- Run mode: `online` → compose files `docker-compose.yml` + `docker-compose.static.yml`
|
- Stack: single unified `docker-compose.yml` (drasl, minecraft, mc-backup,
|
||||||
- Auth: Drasl (base stack)
|
caddy, nmsr, mc-status, uptime-kuma, filestash)
|
||||||
|
- Auth: Drasl
|
||||||
- Restart: **hard** (down → up) — players are disconnected for a few minutes
|
- Restart: **hard** (down → up) — players are disconnected for a few minutes
|
||||||
|
|
||||||
## Procedure
|
## Procedure
|
||||||
@@ -30,37 +31,96 @@ 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.
|
||||||
|
|
||||||
2. **Pull + hard restart** (single SSH session, halts on any error):
|
**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,
|
||||||
|
halts on any error):
|
||||||
```bash
|
```bash
|
||||||
ssh cochi 'set -e
|
ssh cochi 'set -e
|
||||||
cd /home/ubuntu/mc/ulicraft-server-v1
|
cd /home/ubuntu/mc/ulicraft-server-v1
|
||||||
git pull --ff-only
|
git pull --ff-only
|
||||||
docker compose -f docker-compose.yml -f docker-compose.static.yml down --remove-orphans
|
tooling/fetch-authlib.sh
|
||||||
tooling/build-stack.sh --up online'
|
./update-all.sh --hard'
|
||||||
```
|
```
|
||||||
- `--ff-only` refuses to merge — if the pull is not a fast-forward, STOP and
|
- `--ff-only` refuses to merge — if the pull is not a fast-forward, STOP and
|
||||||
report (local changes on the host need manual resolution).
|
report (local changes on the host need manual resolution).
|
||||||
- `build-stack.sh --up online` renders configs (needs `.env` complete:
|
- `fetch-authlib.sh` ensures `runtime/authlib-injector.jar` exists (gitignored;
|
||||||
`BASE_DOMAIN`, `HOST_LAN_IP`, `RCON_PASSWORD`) and runs `compose up -d`.
|
skips if already valid; NOT part of update-all since it rarely changes).
|
||||||
|
Missing jar = minecraft crashloops with "Error opening zip file or JAR
|
||||||
|
manifest missing". Run it before `update-all` so the jar is present when
|
||||||
|
compose brings minecraft up.
|
||||||
|
- **`update-all.sh` is the single source of truth** for the post-pull build +
|
||||||
|
restart steps (render drasl/nmsr configs from `.env`, sync server mods, regen
|
||||||
|
the landing mod list, rebuild `www/`, then apply via docker compose). It is
|
||||||
|
also runnable on its own for a lighter rolling update (no `--hard` =
|
||||||
|
change-detected restart, no world downtime). `--hard` here does
|
||||||
|
`down --remove-orphans && up -d --build` for the deliberate full restart.
|
||||||
|
`render-config.sh` halts on an invalid `REGISTRATION_MODE` (must be `invite`
|
||||||
|
or `open`); a missing distribution jar halts `sync-server-mods.sh`. The
|
||||||
|
landing rebuild is unconditional (the gitignored `www/` is never updated by
|
||||||
|
`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 -f docker-compose.yml -f docker-compose.static.yml ps'
|
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && docker compose ps'
|
||||||
```
|
```
|
||||||
Confirm `caddy`, `drasl`, `minecraft`, `mc-backup` are Up. Tail the minecraft
|
Confirm `caddy`, `drasl`, `minecraft`, `mc-backup`, `nmsr`, `mc-status`,
|
||||||
log briefly and confirm it reaches `Done` (server ready):
|
`uptime-kuma`, `filestash` are Up. Tail the minecraft log briefly and confirm
|
||||||
|
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 -f docker-compose.yml -f docker-compose.static.yml logs -f minecraft' 2>/dev/null | grep -m1 "Done"
|
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && timeout 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
|
||||||
|
|
||||||
- Never `git reset --hard` or discard changes on `cochi` without telling the user
|
- Never `git reset --hard` or discard changes on `cochi` without telling the user
|
||||||
first — there may be host-local edits.
|
first — there may be host-local edits.
|
||||||
- Volumes (`mc_data`, `drasl_state`) persist across `down`; the world is safe. Do
|
- **Caddy conf changes need a recreate, not reload/restart.** If the deploy
|
||||||
NOT pass `-v`/`--volumes` to `down`.
|
touched any `caddy/conf.d/*.caddy` or the Caddyfile and you did a *rolling*
|
||||||
|
`update-all.sh` (not `--hard`), the change WON'T apply: those are single-file
|
||||||
|
bind mounts pinned to the host inode, and `git pull` gives the file a new inode.
|
||||||
|
`restart`/`caddy reload` reuse the stale inode. Run
|
||||||
|
`docker compose up -d --force-recreate caddy`, then verify with
|
||||||
|
`docker compose exec caddy cat /etc/caddy/conf.d/<file>`. The `--hard` path
|
||||||
|
(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
|
||||||
|
world and monitors are safe. Do NOT pass `-v`/`--volumes` to `down`.
|
||||||
- If a step fails, STOP and surface the exact error + the failing command. Do not
|
- If a step fails, STOP and surface the exact error + the failing command. Do not
|
||||||
improvise recovery on production.
|
improvise recovery on production.
|
||||||
- Blessing Skin variant or `airgap` mode changes the compose file set — if the
|
|
||||||
user asks for those, follow `plan/14-deploy.md` instead of the fixed commands
|
|
||||||
above.
|
|
||||||
+157
-35
@@ -1,43 +1,175 @@
|
|||||||
# Copy to .env and fill in real values
|
# Copy to .env and fill in real values
|
||||||
|
|
||||||
# Single base domain for the whole stack; every service is a subdomain of this.
|
# Single base domain for the whole stack; every service is a subdomain of this.
|
||||||
# Use .lan (NOT .local) — .local is intercepted by mDNS on macOS/Linux and
|
# DNS is handled OUTSIDE this repo — point ${BASE_DOMAIN} and every subdomain
|
||||||
# won't resolve through a normal unicast DNS server.
|
# (auth. avatar. status. distribution. www.) at the host running nginx.
|
||||||
BASE_DOMAIN=ulicraft.lan
|
BASE_DOMAIN=ulicraft.net
|
||||||
# The Docker host's LAN IP — every service name resolves here.
|
|
||||||
HOST_LAN_IP=192.168.1.10
|
|
||||||
|
|
||||||
# mDNS toggle. true + BASE_DOMAIN=*.local → an Avahi container publishes the
|
|
||||||
# service names over mDNS (zero-config for macOS/Linux guests; Windows needs
|
|
||||||
# Bonjour). false → use your own party DNS (records: tooling/dns-records.sh).
|
|
||||||
# NOTE: mDNS resolves ONLY *.local; it cannot serve the air-gap upstream-host
|
|
||||||
# spoofs — those always need a real DNS server.
|
|
||||||
ENABLE_MDNS=false
|
|
||||||
|
|
||||||
RCON_PASSWORD=change-me-to-something-random
|
RCON_PASSWORD=change-me-to-something-random
|
||||||
|
|
||||||
# Caddy host-published port (docker-compose.caddy.yml). Default 80 for the
|
# Registration mode (BACKEND gate) for self-hosted accounts (Drasl).
|
||||||
# standalone LAN/air-gap path. When the machine's own nginx fronts caddy
|
# invite = closed; guests need an admin-minted invite code (recommended on a
|
||||||
# (nginx terminates TLS → reverse-proxies to caddy), set a high port and bind
|
# public, internet-present host). Admin mints via POST /drasl/api/v2/invites.
|
||||||
# it to localhost so only nginx reaches it:
|
# open = anyone can self-register with username+password (set [RateLimit]!).
|
||||||
# CADDY_HTTP_PORT=8880
|
# Consumed by render-config.sh (-> Drasl RegistrationNewPlayer.RequireInvite).
|
||||||
# CADDY_HTTP_BIND=127.0.0.1
|
# Default: invite. NOTE: this no longer controls the landing invite FIELD — that's
|
||||||
# Then render nginx/ulicraft-caddy.conf.tmpl with CADDY_HTTP_PORT.
|
# REGISTRATION_SHOW_INVITE below.
|
||||||
CADDY_HTTP_PORT=80
|
REGISTRATION_MODE=invite
|
||||||
CADDY_HTTP_BIND=0.0.0.0
|
|
||||||
|
|
||||||
# distribution.${BASE_DOMAIN} static vhost (docker-compose.distribution.yml).
|
# Show the invite-code FIELD on the landing register form. Read by the landing
|
||||||
# Absolute host path to the built static site — it lives in ANOTHER repo.
|
# BUILD (data/site.ts) only; default false (hidden). Independent of the backend
|
||||||
# Bind-mounted read-only into caddy and served at distribution.${BASE_DOMAIN}.
|
# gate above. CAVEAT: if REGISTRATION_MODE=invite (Drasl requires a code) but this
|
||||||
|
# is false, public self-registration can't supply one — enable both together, or
|
||||||
|
# mint accounts admin-side. true | false.
|
||||||
|
REGISTRATION_SHOW_INVITE=false
|
||||||
|
|
||||||
|
# NMSR avatar render mode for the landing's avatar tiles. Read by the landing
|
||||||
|
# BUILD (data/site.ts) only. Avatar URL =
|
||||||
|
# https://avatar.${BASE_DOMAIN}/<mode>/<uuid>?size=N. e.g. headiso | head | fullbody.
|
||||||
|
# AVATAR_MODE → ServerCard online row + account preview (heads)
|
||||||
|
# ROSTER_AVATAR_MODE → Members grid (full body; default fullbodyiso)
|
||||||
|
AVATAR_MODE=headiso
|
||||||
|
ROSTER_AVATAR_MODE=fullbodyiso
|
||||||
|
|
||||||
|
# Drasl admin creds for the registered-players roster (/api/mcstatus/players).
|
||||||
|
# Consumed ONLY by the mc-status container (server-side login → sanitized
|
||||||
|
# [{uuid,name}] list); these NEVER reach the browser. Use a DEDICATED, rotatable
|
||||||
|
# admin account (Drasl has no read-only role) to limit blast radius. Leave blank
|
||||||
|
# to disable the roster (the Members grid then just shows its empty state).
|
||||||
|
DRASL_ADMIN_USERNAME=
|
||||||
|
DRASL_ADMIN_PASSWORD=
|
||||||
|
|
||||||
|
# caddy host-published port. The host's own nginx terminates TLS and
|
||||||
|
# reverse-proxies to caddy by Host header (nginx/ulicraft-caddy.conf.tmpl), so
|
||||||
|
# bind caddy to a high localhost-only port — only nginx should reach it.
|
||||||
|
CADDY_HTTP_PORT=8880
|
||||||
|
CADDY_HTTP_BIND=127.0.0.1
|
||||||
|
|
||||||
|
# distribution.${BASE_DOMAIN} static vhost. Absolute host path to the built
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# ── upload.${BASE_DOMAIN} — Filestash player file share (write) ───────
|
||||||
|
# (files.${BASE_DOMAIN} = read-only, no-auth caddy browse of the same dir)
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# files.${BASE_DOMAIN} — the read-only, NO-AUTH browse of the same share.
|
||||||
|
# Per-host toggle, `on` | `off` (default on). It gates two layers, both reading
|
||||||
|
# this var: the caddy vhost (docker-compose.yml substitutes the value into the
|
||||||
|
# conf.d mount path — 62-files.on.caddy vs the 62-files.off.caddy stub) and the
|
||||||
|
# public nginx vhost (tooling/render-nginx.sh). `off` also drops the name from
|
||||||
|
# the port-80 redirect, so no cert is needed — drop `files` from LE_SUBDOMAINS
|
||||||
|
# too on that host. upload. (filestash, authenticated) is NOT affected.
|
||||||
|
# After changing it: tooling/render-nginx.sh --install
|
||||||
|
# docker compose up -d --force-recreate caddy
|
||||||
|
FILES_BROWSE=on
|
||||||
|
|
||||||
|
# Runtime uid:gid of the filestash process (docker `user:`). Leave at the image
|
||||||
|
# default 1000:1000 UNLESS you want files it writes into the share owned by a
|
||||||
|
# host account. To own the share as `oier` on oxps: set both to 1002 and run
|
||||||
|
# tooling/filestash-set-run-user.sh (it chowns the state volume + share to match
|
||||||
|
# — a bare override without the chown makes filestash fail to write its sqlite).
|
||||||
|
# The compose keeps group_add 1000 so any uid can still exec the image binary.
|
||||||
|
FILES_UID=1000
|
||||||
|
FILES_GID=1000
|
||||||
|
|
||||||
|
# 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=
|
||||||
|
|
||||||
|
# ── generators.${BASE_DOMAIN} — offline Minecraft data pack generators ────
|
||||||
|
# A pinned fork of misode.github.io, built from source and served with its own
|
||||||
|
# data mirror so it works with ZERO internet at the LAN party. The app is 100%
|
||||||
|
# client-side, so the mirror is what makes it offline-capable — not the fact
|
||||||
|
# that the host has no WAN. Full design: plan/21-generators.md.
|
||||||
|
|
||||||
|
# Absolute host path to the ulicraft-generators checkout (the fork). This is the
|
||||||
|
# docker build context; the fork carries its own Dockerfile + Caddyfile.
|
||||||
|
GENERATORS_REPO=/home/oier/projects/mc-mods/ulicraft-group/ulicraft-generators
|
||||||
|
|
||||||
|
# Where tooling/sync-generators-data.sh puts the ~107M mcmeta + vanilla-mcdoc
|
||||||
|
# mirror. Gitignored, fetched deliberately with WAN, mounted read-only.
|
||||||
|
# Must be re-synced (with a matching MC_REF) if the pinned MC version moves.
|
||||||
|
GENERATORS_DATA_DIR=./generators-data
|
||||||
|
|
||||||
# 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
|
||||||
|
|
||||||
|
# ── Monitor (monitor.${BASE_DOMAIN}) — admin log stream ───────────────
|
||||||
|
# Read-only view of the Minecraft server's latest.log. ADMIN ONLY: the log
|
||||||
|
# carries player IPs, chat and admin commands verbatim, and nothing sanitizes
|
||||||
|
# it. Full design + gotchas: plan/23-monitor.md.
|
||||||
|
#
|
||||||
|
# Per-host toggle, `on` | `off` (default on), same two-layer gating as
|
||||||
|
# FILES_BROWSE: the caddy conf mount (docker-compose.yml substitutes the value
|
||||||
|
# into the path — 80-monitor.on.caddy vs the .off.caddy stub) and the public
|
||||||
|
# nginx vhost (tooling/render-nginx.sh). `off` also drops the name from the
|
||||||
|
# port-80 redirect, so no cert is needed — drop `monitor` from LE_SUBDOMAINS
|
||||||
|
# too on that host.
|
||||||
|
# After changing it: tooling/render-nginx.sh --install
|
||||||
|
# docker compose up -d --force-recreate caddy
|
||||||
|
MONITOR_ENABLED=on
|
||||||
|
|
||||||
|
# The ONLY credential guarding the log stream — caddy basic_auth. The monitor
|
||||||
|
# container has no auth of its own and publishes no port, so this is the whole
|
||||||
|
# access control.
|
||||||
|
MONITOR_USER=admin
|
||||||
|
#
|
||||||
|
# MUST be bcrypt, generated with:
|
||||||
|
# docker run --rm caddy:alpine caddy hash-password --plaintext 'your-password'
|
||||||
|
# (This is the OPPOSITE of FILES_PASSWORD_HASH, which must be $6$ and silently
|
||||||
|
# rejects bcrypt. Do not reuse a hash between the two.)
|
||||||
|
#
|
||||||
|
# MUST be SINGLE-QUOTED here. Docker Compose interpolates $-sequences in .env
|
||||||
|
# values, so an unquoted bcrypt hash is silently mangled — $2a$14$abcdefg…
|
||||||
|
# becomes $$2a$$14… with the $abcdefg… segment eaten as an unset variable, and
|
||||||
|
# every login fails with no hint as to why. Verified behaviour, not a guess.
|
||||||
|
MONITOR_PASSWORD_HASH='$2a$14$replace.me.with.a.real.bcrypt.hash'
|
||||||
|
|
||||||
# ── Let's Encrypt via OVH DNS-01 (tooling/issue-letsencrypt.sh) ───────
|
# ── Let's Encrypt via OVH DNS-01 (tooling/issue-letsencrypt.sh) ───────
|
||||||
# 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 pack distribution
|
LE_SUBDOMAINS=auth distribution www avatar status upload files generators monitor
|
||||||
# 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
|
||||||
@@ -45,13 +177,3 @@ OVH_AK=
|
|||||||
OVH_AS=
|
OVH_AS=
|
||||||
OVH_CK=
|
OVH_CK=
|
||||||
# LE_STAGING=1 # use the untrusted staging CA for dry runs
|
# LE_STAGING=1 # use the untrusted staging CA for dry runs
|
||||||
|
|
||||||
# ── Blessing Skin auth variant (docker-compose.blessingskin.yml) ──────
|
|
||||||
# Only needed when running the Blessing Skin override instead of Drasl.
|
|
||||||
BS_DB_NAME=blessingskin
|
|
||||||
BS_DB_USER=blessingskin
|
|
||||||
BS_DB_PASSWORD=change-me-bs-db
|
|
||||||
BS_DB_ROOT_PASSWORD=change-me-bs-root
|
|
||||||
# Generate once and paste here so it survives container recreates:
|
|
||||||
# docker run --rm azusamikan/blessing-skin-server-docker:latest php artisan key:generate --show
|
|
||||||
BS_APP_KEY=base64:replace-with-generated-key
|
|
||||||
+35
-11
@@ -5,16 +5,27 @@ 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
|
||||||
dnsmasq/dnsmasq.conf
|
nmsr/config.toml
|
||||||
# packwiz pack files rendered from pack/**/*.tmpl by tooling/render-pack.sh
|
# holds the admin bcrypt hash, the shared player hash, the session key and the
|
||||||
# (domain-dependent build output); source of truth is the .tmpl + custom/ jars.
|
# local-backend secret — only the .tmpl is tracked
|
||||||
pack/mods/*.pw.toml
|
filestash/config.json
|
||||||
pack/index.toml
|
|
||||||
pack/CustomSkinLoader/CustomSkinLoader.json
|
|
||||||
|
|
||||||
# vendored / mirrored binaries
|
# server mod subset synced from the distribution repo by
|
||||||
mirror/
|
# tooling/sync-server-mods.sh (source of truth is mods-sides.json + $DIST jars)
|
||||||
pack/mods-files/
|
server-mods/
|
||||||
|
|
||||||
|
# mod configs, seeded out of the mc_data volume by tooling/export-server-config.sh
|
||||||
|
# and then bind-mounted rw at /data/config. Host-specific runtime state (cochi and
|
||||||
|
# oxps diverge), regenerated by the mods themselves — not a source of truth.
|
||||||
|
server-config/
|
||||||
|
|
||||||
|
# vendored binaries
|
||||||
|
launcher/
|
||||||
|
|
||||||
|
# offline data mirror for generators.${BASE_DOMAIN} (~107M of mcmeta +
|
||||||
|
# vanilla-mcdoc), synced by tooling/sync-generators-data.sh. Needs WAN, so it is
|
||||||
|
# fetched deliberately before the party — never at the venue.
|
||||||
|
generators-data/
|
||||||
|
|
||||||
# landing page: generated build output + deps
|
# landing page: generated build output + deps
|
||||||
www/
|
www/
|
||||||
@@ -22,8 +33,21 @@ landing/node_modules/
|
|||||||
landing/.astro/
|
landing/.astro/
|
||||||
landing/dist/
|
landing/dist/
|
||||||
|
|
||||||
# exported Caddy CA (per-deploy artifact)
|
# launcher download list: the custom-launcher build output launcher.json,
|
||||||
caddy/caddy-root-ca.crt
|
# synced from UlicraftLauncher/dist/launcher.json (launcher.example.json is
|
||||||
|
# committed as the documented shape)
|
||||||
|
landing/src/data/launcher.json
|
||||||
|
|
||||||
|
# mod catalogue + extracted logos: generated by tooling/build-modlist.py from
|
||||||
|
# the distribution forgemods (the .example.json is committed as the empty shape)
|
||||||
|
landing/src/data/mods.json
|
||||||
|
landing/public/mod-logos/
|
||||||
|
|
||||||
|
# compiled mc-status binary (built into the image; stray local `go build` output)
|
||||||
|
docker/mc-status/mc-status
|
||||||
|
|
||||||
# Let's Encrypt certs + private keys (issued by tooling/issue-letsencrypt.sh)
|
# Let's Encrypt certs + private keys (issued by tooling/issue-letsencrypt.sh)
|
||||||
certs/
|
certs/
|
||||||
|
|
||||||
|
# migration bundles (migrate-export.sh) — hold .env secrets + LE private keys
|
||||||
|
ulicraft-bundle-*.tar.gz
|
||||||
@@ -1,280 +1,282 @@
|
|||||||
# Minecraft LAN Party Server — Project Context
|
# Ulicraft Server — Project Context
|
||||||
|
|
||||||
> Self-hosted modded Minecraft server for a LAN party (~8 players), with
|
> Self-hosted modded Minecraft (NeoForge 1.21.1) with self-hosted auth/skins
|
||||||
> persistent auth/skin infrastructure designed to outlive the LAN weekend
|
> (Drasl), a distribution-fed modpack, avatar rendering, a guest landing page, and uptime
|
||||||
> and integrate with the existing homelab.
|
> monitoring. Public, internet-present deployment behind the host's nginx + TLS.
|
||||||
|
|
||||||
|
**`plan/` is the source of truth for the design.** Start at `plan/00-overview.md`.
|
||||||
|
This file is a fast orientation + the durable decisions/gotchas; when it disagrees
|
||||||
|
with `plan/` or the code, those win.
|
||||||
|
|
||||||
## Goals & Constraints
|
## Goals & Constraints
|
||||||
|
|
||||||
- **Players**: 4–10 friends, LAN-based
|
- **Players**: 4–10 friends
|
||||||
- **Modpack vibe**: Kitchen-sink (tech + magic + exploration + QoL)
|
- **Modpack vibe**: Kitchen-sink (tech + magic + exploration + QoL)
|
||||||
- **Pack approach**: Manually curated (~50–100 mods), NOT a forked existing pack
|
- **Pack approach**: Manually curated, NOT a forked existing pack. Original target
|
||||||
- **Minecraft version**: 1.21.1
|
~50–100 mods; **actual is 164 jars** (2026-07-20, kitchen-sink expansion) —
|
||||||
- **Mod loader**: NeoForge (NOT classic Forge — see Decisions below)
|
ceiling knowingly exceeded, watch RAM
|
||||||
- **Network**: LAN party context, but stack is persistent (lives past the weekend)
|
- **Minecraft version**: 1.21.1, **NeoForge** (not classic Forge)
|
||||||
- **Auth**: Self-hosted Yggdrasil-compatible (Drasl) + Keycloak OIDC
|
- **Auth/skins**: Drasl (Yggdrasil-compatible), **password login** — no OIDC
|
||||||
- **Skins**: Handled by Drasl
|
- **DNS**: handled OUTSIDE this repo; point `${BASE_DOMAIN}` + subdomains at the host
|
||||||
- **Persistence horizon**: Long-term homelab service, not disposable
|
- **Persistence horizon**: long-term service, not disposable
|
||||||
|
|
||||||
## Existing Homelab Context (relevant to this project)
|
|
||||||
|
|
||||||
- **Keycloak 26.0.7** running in Docker Compose (PostgreSQL 17, production mode,
|
|
||||||
bind-mounted secrets, xforwarded proxy headers). This is the IdP for everything.
|
|
||||||
- **NetBird** as the proxy/networking layer for remote access (NOT used for the
|
|
||||||
LAN party itself — see Decisions).
|
|
||||||
- **AdGuard Home** as recursive DNS resolver — used here for `*.home.local` LAN records.
|
|
||||||
- **Multi-VPS topology**: this Minecraft project likely runs on a local box or
|
|
||||||
beefier homelab node, not a VPS (RAM/CPU requirements).
|
|
||||||
- Operator timezone: Europe/Madrid.
|
|
||||||
|
|
||||||
## Key Decisions (with reasoning)
|
|
||||||
|
|
||||||
### NeoForge over classic Forge
|
|
||||||
Despite the user originally saying "Forge", the 1.21.x kitchen-sink ecosystem
|
|
||||||
(ATM10, Leaking Kitchen Sink, etc.) is overwhelmingly NeoForge in 2025/2026.
|
|
||||||
The original Forge team essentially migrated to NeoForge. `itzg/minecraft-server`
|
|
||||||
supports both via `TYPE=NEOFORGE`. Going with NeoForge 1.21.1.
|
|
||||||
|
|
||||||
### Drasl over Ely.by or vanilla offline mode
|
|
||||||
- **Ely.by**: not self-hosted, Russian-hosted, no control over identity layer.
|
|
||||||
- **Vanilla offline + no auth server**: works for one weekend but skins break,
|
|
||||||
no persistent identity, doesn't match homelab philosophy.
|
|
||||||
- **Drasl**: self-hosted Go service, Yggdrasil-compatible, drop-in Mojang
|
|
||||||
replacement, supports skins + capes, **has first-class OIDC support with
|
|
||||||
PKCE** (perfect for Keycloak), actively maintained (current version 3.4.2+).
|
|
||||||
GPLv3 licensed.
|
|
||||||
|
|
||||||
### LAN (not NetBird) for the party itself
|
|
||||||
NetBird is the homelab's remote access layer, but adding a mesh VPN on top
|
|
||||||
of a LAN party introduces complexity for guests with no upside. Drasl and
|
|
||||||
Minecraft live on the LAN; clients reach them via `drasl.home.local` resolved
|
|
||||||
through AdGuard.
|
|
||||||
|
|
||||||
### Manual curation (user choice — flagged as expensive)
|
|
||||||
User insisted on manual curation despite my pushback that forking ATM10 or
|
|
||||||
Leaking Kitchen Sink would save weeks of crash-log triage. **Note for future
|
|
||||||
sessions**: if curation gets painful, the fork-and-trim option is still on
|
|
||||||
the table — Leaking Kitchen Sink is the closest match to the requested vibe.
|
|
||||||
|
|
||||||
### itzg/minecraft-server as base image
|
|
||||||
The de facto standard. Handles NeoForge installation automatically via
|
|
||||||
`TYPE=NEOFORGE` + `VERSION` + `NEOFORGE_VERSION`. Supports Modrinth and
|
|
||||||
CurseForge mod auto-download via `MODRINTH_PROJECTS` and `CURSEFORGE_FILES`.
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
One unified `docker-compose.yml`. The host's own nginx terminates TLS (Let's
|
||||||
LAN segment
|
Encrypt) and reverse-proxies every vhost to caddy (published localhost-only) by
|
||||||
│
|
Host header. caddy is the internal router; containers reach the stack's own names
|
||||||
├── AdGuard Home → resolves drasl.home.local, keycloak.home.local
|
via caddy's `mcnet` aliases (`auth.`).
|
||||||
│ to the Docker host's LAN IP
|
|
||||||
│
|
|
||||||
├── Keycloak (existing, separate compose)
|
|
||||||
│ realm: homelab
|
|
||||||
│ client: drasl (confidential, PKCE S256)
|
|
||||||
│
|
|
||||||
└── Minecraft host (this project's compose)
|
|
||||||
│
|
|
||||||
├── drasl :25585 (Yggdrasil API + web UI)
|
|
||||||
│ └── OIDC → Keycloak
|
|
||||||
│
|
|
||||||
├── minecraft :25565 (server)
|
|
||||||
│ :24454/udp (Simple Voice Chat, optional)
|
|
||||||
│ └── -javaagent:authlib-injector.jar=http://drasl.home.local:25585/authlib-injector
|
|
||||||
│ └── ONLINE_MODE=false (required for authlib-injector)
|
|
||||||
│
|
|
||||||
└── mc-backup (itzg/mc-backup, 6h interval)
|
|
||||||
└── RCON → minecraft
|
|
||||||
|
|
||||||
Clients (LAN party guests):
|
```
|
||||||
Prism Launcher → authlib-injector account pointing at Drasl
|
Internet ── host nginx (TLS, LE certs, HSTS) ──► caddy (127.0.0.1:8880)
|
||||||
→ joins minecraft:25565
|
│ routes by Host:
|
||||||
|
${BASE_DOMAIN} ─► /srv/www landing + /launcher/ downloads
|
||||||
|
auth.${BASE_DOMAIN} ─► drasl (Yggdrasil API + web UI)
|
||||||
|
avatar.${BASE_DOMAIN} ─► nmsr (skin/avatar renderer, Drasl-backed)
|
||||||
|
status.${BASE_DOMAIN} ─► uptime-kuma (status page + monitors)
|
||||||
|
upload.${BASE_DOMAIN} ─► filestash (player file share, ONE shared login, writable)
|
||||||
|
files.${BASE_DOMAIN} ─► caddy (read-only, no-auth browse of the same share dir;
|
||||||
|
per-host on/off via FILES_BROWSE — off on cochi)
|
||||||
|
monitor.${BASE_DOMAIN}─► monitor (ADMIN ONLY: latest.log stream + dashboard tiles,
|
||||||
|
caddy basic_auth; per-host on/off MONITOR_ENABLED)
|
||||||
|
distribution.${BASE} ─► static site from ANOTHER repo (DISTRIBUTION_WEB_ROOT)
|
||||||
|
|
||||||
|
minecraft :25565 (+ 24454/udp Simple Voice Chat)
|
||||||
|
└ 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
|
||||||
|
mc-backup ── RCON → minecraft (6h interval, prune 14d, world-only)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Auth Flow (end-to-end)
|
Bring-up: render configs → sync server mods → build landing → fetch launcher → `docker compose up -d --build`.
|
||||||
|
See `plan/12-build-order.md` and `plan/14-deploy.md`. Deploy to prod host `cochi`
|
||||||
|
via the `deploy` skill.
|
||||||
|
|
||||||
1. Guest opens `http://drasl.home.local:25585` in browser.
|
## Key Decisions
|
||||||
2. Clicks "Register with Keycloak" → redirected to Keycloak login.
|
|
||||||
3. Logs in with homelab Keycloak credentials.
|
- **NeoForge over Forge** — the 1.21.x kitchen-sink ecosystem is overwhelmingly
|
||||||
4. Returned to Drasl, picks a player name, uploads a skin.
|
NeoForge; the Forge team migrated. `itzg/minecraft-server` via `TYPE=NEOFORGE`.
|
||||||
5. Drasl shows them a "Minecraft Token" on their profile page.
|
- **Drasl over Ely.by / vanilla offline** — self-hosted Go service, Yggdrasil
|
||||||
6. In Prism Launcher: Settings → Accounts → Add authlib-injector account
|
drop-in, skins + capes, actively maintained. Run with **password login**
|
||||||
with URL `http://drasl.home.local:25585/authlib-injector` and the
|
(no OIDC/Keycloak in this stack).
|
||||||
Minecraft Token as password.
|
- **Manual curation** (user choice, flagged expensive) — if it gets painful,
|
||||||
7. Joins Minecraft server. Server validates session against Drasl (via
|
fork-and-trim Leaking Kitchen Sink is still on the table (closest vibe match).
|
||||||
authlib-injector JVM agent), Drasl confirms, player enters with their skin.
|
- **itzg/minecraft-server** base image — de-facto standard, auto-installs NeoForge.
|
||||||
|
- **Distribution-fed mod volume (replaced packwiz)** — the modset's source of
|
||||||
|
truth is the HeliosLauncher distribution repo (`$DISTRIBUTION_WEB_ROOT`).
|
||||||
|
Clients install the full set via `distribution.json`. The SERVER loads a
|
||||||
|
filtered subset (`both`/`server`) from a mounted `./server-mods` volume
|
||||||
|
(`REMOVE_OLD_MODS=TRUE`). See `plan/04-mods.md`.
|
||||||
|
|
||||||
## Critical Gotchas
|
## Critical Gotchas
|
||||||
|
|
||||||
### Minecraft 1.21+ secure profile incompatibility
|
### Minecraft 1.21+ secure profile
|
||||||
- Server: must set `enforce-secure-profile=false` (compose: `ENFORCE_SECURE_PROFILE=FALSE`)
|
- Server: `ENFORCE_SECURE_PROFILE=FALSE` (`enforce-secure-profile=false`)
|
||||||
- Drasl: must set `SignPublicKeys = false`
|
- Drasl: `SignPublicKeys = false`
|
||||||
- These are linked. The Drasl docs explicitly warn: *"Mixed authentication does
|
- Linked. Drasl docs: *"Mixed authentication does not work with
|
||||||
not work with `SignPublicKeys = true` on Minecraft 1.21+."*
|
`SignPublicKeys = true` on Minecraft 1.21+."*
|
||||||
|
- **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:/path/to/authlib-injector.jar=<yggdrasil-api-url>`
|
- Syntax: `-javaagent:/extras/authlib-injector.jar=<yggdrasil-api-url>`
|
||||||
- The URL after `=` is the **API root**, which for Drasl is
|
- URL after `=` is the **API root** = `<BaseURL>/authlib-injector`. For this
|
||||||
`<BaseURL>/authlib-injector`.
|
stack: `http://auth.${BASE_DOMAIN}/authlib-injector` (internal, over mcnet).
|
||||||
- **Must match between server and client.** If server uses
|
- **Must match between server and client** (clients use the public
|
||||||
`http://drasl.home.local:25585/authlib-injector` and client uses anything
|
`https://auth.${BASE_DOMAIN}/authlib-injector`), else session validation fails
|
||||||
else (e.g. an IP), session validation fails silently with "Invalid session".
|
silently with "Invalid session".
|
||||||
- Download from: https://github.com/yushijinhun/authlib-injector/releases
|
- Releases: https://github.com/yushijinhun/authlib-injector — jar lives in
|
||||||
- Mount into the itzg container at `/extras/authlib-injector.jar`.
|
`runtime/` (mounted at `/extras/authlib-injector.jar`).
|
||||||
|
|
||||||
### Keycloak reachability on LAN day
|
|
||||||
If Keycloak is only reachable via NetBird or only from the public internet,
|
|
||||||
**Drasl OIDC registration breaks on LAN-only day**. Options:
|
|
||||||
1. Expose Keycloak on the LAN (simplest)
|
|
||||||
2. Pre-register all guests before the party
|
|
||||||
3. Temporarily disable OIDC and allow password registration during the LAN
|
|
||||||
(`AllowPasswordLogin = true`, comment out the OIDC block)
|
|
||||||
|
|
||||||
### HTTP vs HTTPS for OIDC
|
|
||||||
Modern browsers warn on plain HTTP for OIDC flows but they work. For a
|
|
||||||
production-grade setup, front Drasl with Caddy or Traefik using a local CA
|
|
||||||
cert (smallstep/step-ca pairs well with the existing homelab). For LAN-only
|
|
||||||
HTTP is fine.
|
|
||||||
|
|
||||||
### NeoForge version pinning
|
### NeoForge version pinning
|
||||||
Mods are picky about exact NeoForge minor versions. The compose currently
|
Mods are picky about exact minor versions. Compose pins `NEOFORGE_VERSION:
|
||||||
pins `NEOFORGE_VERSION: "21.1.209"` as a placeholder. **Verify against
|
"21.1.233"` — **must match the distribution's NeoForge** (`distribution.json`),
|
||||||
https://projects.neoforged.net/neoforged/neoforge before deploying.** Don't
|
since mods are built against it (e.g. ferritecore≥218, farmersdelight≥219).
|
||||||
use `"latest"` for a stable server.
|
**Verify against https://projects.neoforged.net/neoforged/neoforge before
|
||||||
|
deploying.** Never `"latest"` for a stable server.
|
||||||
|
|
||||||
### Drasl username vs player name
|
### Mod source + server filtering
|
||||||
- **Drasl username** = OIDC user's email (used for web UI login)
|
The full modset lives in the distribution repo (`$DISTRIBUTION_WEB_ROOT/servers/
|
||||||
- **Player name** = `preferred_username` from OIDC, or user-chosen if
|
ulicraft-1.21.1/forgemods/required/*.jar`), managed by Nebula. Clients get
|
||||||
`AllowChoosingPlayerName = true`
|
everything via `distribution.json`. The server runs a **filtered subset**:
|
||||||
- Keycloak users must have email set. Verify the realm's email-as-username
|
`tooling/classify-mods.py` reads each jar's `neoforge.mods.toml` with tomllib and
|
||||||
setting and the `preferred_username` mapper are configured.
|
seeds `mods-sides.json` (`client|server|both`, default `both`), then
|
||||||
|
`tooling/sync-server-mods.sh` copies the `both`/`server` jars into `./server-mods`
|
||||||
|
(mounted at `/mods`). Hard client-only mods (sodium/iris) classify as `client`
|
||||||
|
automatically; cosmetic-client mods that declare `BOTH` stay `both` until you
|
||||||
|
hand-edit `mods-sides.json` to `client`. No Modrinth/packwiz, no network at
|
||||||
|
classify/sync time. See `plan/04-mods.md`.
|
||||||
|
|
||||||
### Mod source preference
|
### Writable bind mounts — seed `./server-config` first
|
||||||
Prefer **Modrinth over CurseForge** for itzg auto-download. CurseForge
|
`/mods`, `/data/config` and the four kubejs source subdirs are mounted **rw**
|
||||||
requires an API key (`CF_API_KEY`) and has been historically flakier with
|
(2026-07-26). Two consequences:
|
||||||
the itzg image. Use CF only for mods that are exclusive to it.
|
- `./server-config:/data/config` is a NEW bind — config used to live inside
|
||||||
|
`mc_data`. A bind mount **shadows** what's under it, so booting with an empty
|
||||||
|
`./server-config` makes every mod regenerate defaults. Run
|
||||||
|
`tooling/export-server-config.sh` once per host, server stopped, before that
|
||||||
|
deploy. The old files survive in `mc_data` under the mount, but the server
|
||||||
|
stops seeing them.
|
||||||
|
- itzg runs as **uid 1000**; a rw mount only works if the host dir permits it.
|
||||||
|
`cochi` is uid 1000, **`oxps` `oier` is uid 1002** → pass `MC_UID`/`MC_GID`.
|
||||||
|
- kubejs writes now land in `$DISTRIBUTION_WEB_ROOT` — i.e. as uncommitted
|
||||||
|
changes in that host's distribution checkout.
|
||||||
|
The kubejs ROOT still must not be mounted (NPE at init) — unchanged by the mode.
|
||||||
|
Full detail: `plan/05-minecraft.md` → "Writable bind mounts".
|
||||||
|
|
||||||
## Compose Stack
|
### Filestash (upload.) — four traps
|
||||||
|
Filestash serves at `upload.${BASE_DOMAIN}` (can't live under a subpath — SPA
|
||||||
|
hardcodes `/api`, `/assets`, `/login` at root). `files.${BASE_DOMAIN}` is a
|
||||||
|
separate caddy `file_server browse` of the same `FILES_DATA_DIR` — read-only,
|
||||||
|
**no auth**, every uploaded file publicly listable.
|
||||||
|
Full list in `plan/20-files.md`; the ones that cost real time:
|
||||||
|
- **`general.host` is a BARE HOSTNAME** (`upload.${BASE_DOMAIN}`, no scheme) +
|
||||||
|
`force_ssl: true`. The SPA builds its origin as `scheme + host`, so a URL there
|
||||||
|
yields `http://https://upload...` 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`.
|
||||||
|
|
||||||
The working compose file is at `./docker-compose.yml`. Key environment vars
|
**Verifying an SPA:** a 200 on `/` proves the server is up, not that the app
|
||||||
documented inline. Adjacent files:
|
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.
|
||||||
|
|
||||||
|
### Compose eats `$` in `.env` values — quote every hash
|
||||||
|
`docker compose` interpolates `$`-sequences inside `.env` **values**, so a bare
|
||||||
|
bcrypt hash is silently corrupted before it ever reaches a container:
|
||||||
|
|
||||||
```
|
```
|
||||||
.
|
MONITOR_PASSWORD_HASH=$2a$14$abcdefgHIJKLmnop.qrstuv # WRONG
|
||||||
├── docker-compose.yml
|
→ container sees $2a$14.qrstuv # $abcdefg… eaten as an unset var
|
||||||
├── .env # RCON_PASSWORD, CF_API_KEY (if needed)
|
MONITOR_PASSWORD_HASH='$2a$14$abcdefgHIJKLmnop.qrstuv' # single quotes fix it
|
||||||
├── drasl/
|
|
||||||
│ └── config/
|
|
||||||
│ ├── config.toml # see ./drasl-config.toml example
|
|
||||||
│ └── keycloak-client-secret # single line, no trailing newline
|
|
||||||
├── extras/
|
|
||||||
│ ├── authlib-injector.jar # downloaded from yushijinhun's releases
|
|
||||||
│ ├── modrinth-mods.txt # one mod slug per line
|
|
||||||
│ └── cf-mods.txt # only if using CurseForge mods
|
|
||||||
├── backups/ # mc-backup writes here
|
|
||||||
└── CLAUDE.md # this file
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Verified, not folklore. Every login fails with no hint as to why — the hash
|
||||||
|
looks fine in `.env` and `docker compose config` re-escapes it to `$$…`, which
|
||||||
|
looks wrong but is correct. **The same bug bites over SSH**: writing a hash to a
|
||||||
|
remote `.env` through `ssh host "...$HASH..."` lets the remote shell expand it.
|
||||||
|
Transport it base64-encoded and verify with `md5sum` on both ends.
|
||||||
|
|
||||||
|
Note the two services want *opposite* formats: caddy `basic_auth` needs
|
||||||
|
**bcrypt** (`caddy hash-password`), filestash needs **`$6$`** (`openssl passwd
|
||||||
|
-6`) and silently rejects bcrypt. Never copy a hash between them.
|
||||||
|
|
||||||
|
### Monitor runs as root on purpose
|
||||||
|
`/data` is `drwxr-x---` owned by uid 1000 (itzg). A non-root container user
|
||||||
|
cannot traverse it, so the log tailer would read nothing — **silently**, while
|
||||||
|
still answering HTTP 200. Adding a `USER` directive to `docker/monitor/` as
|
||||||
|
"hardening" breaks it. What actually bounds the risk there: every mount is `:ro`,
|
||||||
|
no port is published, and the one credential it holds (RCON) can only send
|
||||||
|
commands from a compile-time allowlist.
|
||||||
|
|
||||||
### Memory sizing
|
### Memory sizing
|
||||||
With ~50–100 mods and 8 concurrent players on 1.21.1:
|
~50–100 mods, 8 players, 1.21.1: `INIT_MEMORY: 4G`, `MAX_MEMORY: 10G`,
|
||||||
- `INIT_MEMORY: 4G`, `MAX_MEMORY: 10G` is a safe starting point
|
`USE_AIKAR_FLAGS: TRUE`. **The pack is now 164 jars / 133 server-side** (Create
|
||||||
- Use `USE_AIKAR_FLAGS: TRUE` for the well-known GC tuning
|
ecosystem + Ars Nouveau + full worldgen) — sized for the old 50–100 assumption, so
|
||||||
- Monitor with `mc-monitor` (itzg makes one) if you want metrics
|
`MAX_MEMORY` may need a bump. Re-check heap usage under load before adding more.
|
||||||
|
|
||||||
### Backups
|
## Config (.env)
|
||||||
`itzg/mc-backup` runs alongside, talks to the server via RCON, takes
|
|
||||||
snapshots every 6h, prunes after 14 days. Backups are world-only (no mod
|
|
||||||
jars), which is correct — mods are reproducible from the mod list files.
|
|
||||||
|
|
||||||
## Keycloak Client Configuration
|
`BASE_DOMAIN`, `RCON_PASSWORD`, `CADDY_HTTP_PORT`/`CADDY_HTTP_BIND`,
|
||||||
|
`DISTRIBUTION_WEB_ROOT` (source of the modset + caddy `distribution.` mount), the
|
||||||
|
Let's Encrypt block (`LE_EMAIL`, `LE_SUBDOMAINS`, `OVH_*`). Rendered configs
|
||||||
|
(drasl, nmsr) come from `*.tmpl` via `tooling/render-config.sh` (substitutes
|
||||||
|
`${BASE_DOMAIN}` only). Server mods are synced (not rendered) by
|
||||||
|
`tooling/sync-server-mods.sh`.
|
||||||
|
|
||||||
In the `homelab` realm (or whatever the realm is called):
|
## Client Distribution (guests)
|
||||||
|
|
||||||
1. **Clients → Create client**
|
Launcher: **FjordLauncherUnlocked** (Prism fork, first-class authlib-injector),
|
||||||
- Client type: `OpenID Connect`
|
mirrored at apex `/launcher/`. Per guest:
|
||||||
- Client ID: `drasl`
|
1. Download from `https://${BASE_DOMAIN}` → `/launcher/`.
|
||||||
- Name: `Drasl Minecraft Auth`
|
2. Add an authlib-injector account, URL
|
||||||
|
`https://auth.${BASE_DOMAIN}/authlib-injector` (register/login at
|
||||||
2. **Capability config**
|
`https://auth.${BASE_DOMAIN}`).
|
||||||
- Client authentication: **ON** (confidential client)
|
3. The launcher installs the modpack from the distribution (`distribution.json`).
|
||||||
- Authentication flow: **Standard flow** only
|
(packwiz and the `pack.` service/subdomain have been fully removed.)
|
||||||
- Disable: Direct access grants, Implicit, Service accounts
|
4. Connect to `${BASE_DOMAIN}` (`:25565`).
|
||||||
|
|
||||||
3. **Login settings**
|
|
||||||
- Root URL: `http://drasl.home.local:25585`
|
|
||||||
- Valid redirect URIs: `http://drasl.home.local:25585/web/oidc-callback/Keycloak`
|
|
||||||
(the `Keycloak` at the end MUST match the `Name = "Keycloak"` in Drasl's
|
|
||||||
`[[RegistrationOIDC]]` block — case sensitive)
|
|
||||||
- Web origins: `http://drasl.home.local:25585`
|
|
||||||
|
|
||||||
4. **Credentials tab** → copy Client Secret to
|
|
||||||
`./drasl/config/keycloak-client-secret` (no trailing newline).
|
|
||||||
|
|
||||||
5. **Advanced tab** → PKCE Code Challenge Method: `S256`
|
|
||||||
|
|
||||||
## Client Distribution (LAN guests)
|
|
||||||
|
|
||||||
### Recommended launcher
|
|
||||||
**Prism Launcher** — open source, cross-platform (Win/Mac/Linux), first-class
|
|
||||||
authlib-injector support, can import/export instance ZIPs.
|
|
||||||
|
|
||||||
### Per-guest one-time setup
|
|
||||||
1. Install Prism Launcher.
|
|
||||||
2. Add authlib-injector account:
|
|
||||||
- URL: `http://drasl.home.local:25585/authlib-injector`
|
|
||||||
- Username/password: their Drasl credentials (or Minecraft Token if
|
|
||||||
registered via Keycloak OIDC)
|
|
||||||
3. Import the modpack instance ZIP.
|
|
||||||
|
|
||||||
### What to ship guests
|
|
||||||
- A Prism instance ZIP (right-click instance → Export Instance)
|
|
||||||
- A one-page README with the auth URL and import steps
|
|
||||||
- Optionally `authlib-injector.jar` for guests who refuse Prism
|
|
||||||
|
|
||||||
## Open / Pending Work
|
## Open / Pending Work
|
||||||
|
|
||||||
These were on the roadmap when we ended the brainstorm:
|
### Roadmap — mod migration follow-ups (post packwiz→04-mods)
|
||||||
|
- [ ] **Curate `mods-sides.json` cosmetic-client mods.** Pack is now **164 jars**
|
||||||
|
(2026-07-20): **31 `client` / 133 `both`**. The `both` set still contains
|
||||||
|
client-cosmetic mods that load harmlessly on the server but waste RAM — hand-edit
|
||||||
|
them to `client` so `sync-server-mods.sh` drops them from `./server-mods`.
|
||||||
|
(Also review `appleskin`, flagged `client` — flip to `both` if server-side food
|
||||||
|
data wanted.)
|
||||||
|
### Landing rework + mod list (PLANNED — see `plan/18-landing-rework.md`)
|
||||||
|
Full design settled across WS1–WS6; all tasks unchecked, ready to execute.
|
||||||
|
- [ ] **WS1 — Join flow off packwiz.** Homepage = UlicraftLauncher, 3 steps
|
||||||
|
(register → download launcher → join); drop `packwizUrl` + packwiz step.
|
||||||
|
Per-OS downloads from a synced `launcher.json` (shape
|
||||||
|
`productName/version/files[]`, the build output of the `custom-launcher`
|
||||||
|
repo; `launcher.example.json` committed as the documented fallback). Fjord
|
||||||
|
moves to its own `/fjord` page.
|
||||||
|
- [ ] **WS2/3 — Player rosters.** Online (mc-status, live) + all-registered (new
|
||||||
|
`mc-status /api/mcstatus/players`, admin-login → Drasl `GET /players`, ~60s
|
||||||
|
cache). Shared avatar tile, `AVATAR_MODE` env (default `headiso`). Admin creds
|
||||||
|
→ mc-status only.
|
||||||
|
- [ ] **WS4 — Account page** (`/account`): skin CRUD via browser-direct Drasl
|
||||||
|
(reuses `register.astro` skin JS). Skins only, in-memory token, `players[0]`.
|
||||||
|
- [ ] **WS5 — Footer status link** to `status.${BASE_DOMAIN}` (nav unchanged).
|
||||||
|
|
||||||
- [ ] **Mod shortlist for the kitchen-sink pack.** Categories to fill:
|
- [ ] **WS6 — Mod shortlist + list component (`plan/17-mod-shortlist.md`).**
|
||||||
- Performance (Embeddium/Sodium-equivalent for NeoForge, FerriteCore, ModernFix)
|
Gap doc part **DONE**: pack filled out to **164 jars** (2026-07-20) — Create
|
||||||
- Tech/automation (Mekanism, Create, Immersive Engineering, AE2)
|
ecosystem (tech), Ars Nouveau + ars_creo (magic), full YUNG's + Moog's +
|
||||||
- Magic (Ars Nouveau, Botania, Iron's Spells 'n Spellbooks)
|
TerraBlender (worldgen); `ponderjs` orphan resolved by Create. Still open: **no
|
||||||
- Storage (Sophisticated Storage/Backpacks, Functional Storage)
|
map mod** (Xaero's), `Necronomicon` orphan (no Iron's Spells), and 164 > the
|
||||||
- Exploration (YUNG's structures, Repurposed Structures, biome packs)
|
stated 50–100 ceiling. Still to build: landing mod-list component =
|
||||||
- QoL (JEI, Jade, JEI Resources, Inventory Profiles Next, AppleSkin)
|
auto-generated `mods.json` + extracted logos from the distribution forgemods
|
||||||
- World gen (Tectonic, Terralith — check NeoForge 1.21.1 compat)
|
(`tooling/build-modlist.py`), flat alphabetical, pretty names, no
|
||||||
- Compat glue (Polymorph for recipe conflicts)
|
categories/links; feeds the "50+" stat tile.
|
||||||
- Map (XaeroMinimap + Xaero World Map)
|
- [ ] Server-side perf mods (C2ME, etc. — `modernfix` already in the pack).
|
||||||
- Voice (Simple Voice Chat — port 24454/udp already in compose)
|
- [x] **Filestash file share** (`files.`, 2026-07-14) — live in prod. One shared
|
||||||
- [ ] **Decide on dimension/server-side performance mods** (C2ME, Lithium-equivalent)
|
htpasswd login, writable, rendered `:ro` config. See `plan/20-files.md`.
|
||||||
- [ ] **Reverse proxy + local TLS** for Drasl (Caddy + step-ca recommended,
|
Follow-ups: add the Uptime Kuma HTTP monitor for `files.`; the share is
|
||||||
given the existing homelab)
|
**outside mc-backup** and unquota'd (accepted — eyeballed).
|
||||||
- [ ] **DNS record** in AdGuard for `drasl.home.local` → Docker host LAN IP
|
- [x] **Monitor** (`monitor.`, 2026-08-01) — admin-only `latest.log` stream +
|
||||||
- [ ] **Keycloak client** creation per the section above
|
dashboard tiles (status, TPS via allowlisted RCON, players, disk, backups,
|
||||||
- [ ] **Verify NeoForge version** against current stable before first deploy
|
joins/leaves) + opt-in browser notifications on join/leave (bell toggle,
|
||||||
- [ ] **Bootstrap admin in Drasl**: leave `AllowPasswordLogin = true` initially,
|
client-side only). Live on cochi, all four phases done. See `plan/23-monitor.md`.
|
||||||
create the admin account, then optionally disable password login to force OIDC
|
Follow-up: add the Uptime Kuma monitor for `http://monitor:8080/healthz`.
|
||||||
|
- [ ] **Verify NeoForge version** against current stable before first deploy.
|
||||||
|
- [x] **Bootstrap Drasl admin** account (`admin`, 2026-06-10). Key is
|
||||||
|
`DefaultAdmins` (not `DefaultAdminUsernames`); first admin needs an invite-flip.
|
||||||
|
See `plan/03-drasl.md` gotchas.
|
||||||
|
|
||||||
## Reference URLs
|
## Reference URLs
|
||||||
|
|
||||||
- Drasl repo: https://github.com/unmojang/drasl
|
- Drasl: https://github.com/unmojang/drasl — config docs:
|
||||||
- Drasl configuration docs: https://github.com/unmojang/drasl/blob/master/doc/configuration.md
|
https://github.com/unmojang/drasl/blob/master/doc/configuration.md
|
||||||
- Drasl recipes (example configs): https://github.com/unmojang/drasl/blob/master/doc/recipes.md
|
|
||||||
- authlib-injector: https://github.com/yushijinhun/authlib-injector
|
- authlib-injector: https://github.com/yushijinhun/authlib-injector
|
||||||
- itzg/minecraft-server: https://github.com/itzg/docker-minecraft-server
|
- itzg/minecraft-server: https://github.com/itzg/docker-minecraft-server
|
||||||
- itzg NeoForge docs: https://github.com/itzg/docker-minecraft-server/blob/master/docs/types-and-platforms/server-types/forge.md
|
(NeoForge: …/docs/types-and-platforms/server-types/forge.md)
|
||||||
- itzg/mc-backup: https://github.com/itzg/docker-mc-backup
|
- itzg/mc-backup: https://github.com/itzg/docker-mc-backup
|
||||||
- NeoForge versions: https://projects.neoforged.net/neoforged/neoforge
|
- NeoForge versions: https://projects.neoforged.net/neoforged/neoforge
|
||||||
- Prism Launcher: https://prismlauncher.org/
|
- NMSR: https://github.com/NickAcPT/nmsr-rs
|
||||||
- Reference modpacks for inspiration (NOT forking):
|
- Uptime Kuma: https://github.com/louislam/uptime-kuma
|
||||||
- ATM10 (Modrinth/CurseForge — ~500 mods, NeoForge 1.21.1)
|
- FjordLauncherUnlocked: https://github.com/hero-persson/FjordLauncherUnlocked
|
||||||
- Leaking Kitchen Sink (CurseForge — trimmed ~150 mods, NeoForge 1.21.1)
|
- Reference packs (inspiration, NOT forking): ATM10, Leaking Kitchen Sink.
|
||||||
|
|
||||||
## Communication Preferences (carry-over from past sessions)
|
## Communication Preferences (carry-over)
|
||||||
|
|
||||||
- Concise and direct
|
- Concise and direct; diagnose + resolve without demanding repro steps.
|
||||||
- Diagnose and resolve without demanding detailed reproduction steps
|
- Prefer iterative single-change updates over large rewrites.
|
||||||
- Prefer iterative single-change updates over large rewrites
|
- Cautious scripts that halt on ambiguity rather than proceeding silently.
|
||||||
- Cautious scripts that halt on ambiguity rather than proceeding silently
|
- Avoid over-engineering when a simple answer suffices.
|
||||||
- Avoid over-engineered responses when a simple answer suffices
|
- Spanish or English both fine.
|
||||||
- Spanish or English fine; user is comfortable in both
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
|
||||||
|
Easiest — exec into container:
|
||||||
|
ssh oxps
|
||||||
|
cd ~/mc/ulicraft-server
|
||||||
|
docker compose exec minecraft bash # world at /data
|
||||||
|
|
||||||
|
Host path (needs sudo, root-owned):
|
||||||
|
ssh oxps
|
||||||
|
docker volume inspect ulicraft-server_mc_data -f '{{.Mountpoint}}'
|
||||||
|
# -> /var/lib/docker/volumes/ulicraft-server_mc_data/_data
|
||||||
|
sudo ls /var/lib/docker/volumes/ulicraft-server_mc_data/_data
|
||||||
|
Project name = dir name, so verify with docker volume ls | grep mc_data if dir differs.
|
||||||
|
|
||||||
|
Copy files out/in without root:
|
||||||
|
docker compose cp minecraft:/data/logs/latest.log ./latest.log
|
||||||
|
docker compose cp ./foo.json minecraft:/data/config/
|
||||||
|
|
||||||
|
Caveats:
|
||||||
|
- Writing world files while server runs → corruption. Stop first: docker compose stop minecraft.
|
||||||
|
- oxps LAN-only, so ssh oxps from LAN only.
|
||||||
|
- mc-backup mounts same volume :ro — backups in backups/ are safer read source for world snapshots.
|
||||||
|
|
||||||
@@ -1,111 +1,285 @@
|
|||||||
# Ulicraft Server
|
# Ulicraft Server
|
||||||
|
|
||||||
Self-hosted modded Minecraft (NeoForge 1.21.1) for a LAN party. Runs **online**
|
Self-hosted modded Minecraft (NeoForge 1.21.1) with self-hosted auth/skins
|
||||||
(internet present) or **fully air-gapped** (offline). Self-hosted auth/skins
|
(Drasl), a distribution-fed modpack, avatar rendering, a guest landing page, and
|
||||||
(Drasl), a packwiz modpack, a guest landing page, and a transparent mirror of
|
uptime monitoring. One unified `docker-compose.yml` runs the whole stack behind
|
||||||
Mojang/launcher/NeoForge assets so guest laptops need no internet.
|
the host's nginx + Let's Encrypt.
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
|
One compose file, one `docker compose up -d`:
|
||||||
|
|
||||||
| Service | Image | Role |
|
| Service | Image | Role |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `minecraft` | itzg/minecraft-server | NeoForge 1.21.1 server, `:25565` |
|
| `minecraft` | itzg/minecraft-server | NeoForge 1.21.1 server, `:25565` |
|
||||||
| `drasl` | unmojang/drasl | Yggdrasil auth + skins (password login) |
|
| `drasl` | unmojang/drasl | Yggdrasil auth + skins (password login) |
|
||||||
| `caddy` | caddy:alpine | ingress: landing, auth/packwiz proxy, asset mirror (`tls internal`) |
|
| `caddy` | caddy:alpine | internal ingress: routes every vhost by Host header |
|
||||||
|
| `nmsr` | built from source | skin/avatar renderer at `avatar.${BASE_DOMAIN}` |
|
||||||
|
| `mc-status` | built from source | live server-status JSON for the landing card |
|
||||||
|
| `uptime-kuma` | louislam/uptime-kuma | status page at `status.${BASE_DOMAIN}` |
|
||||||
|
| `filestash` | machines/filestash (digest-pinned) | player file share (upload) at `upload.${BASE_DOMAIN}` |
|
||||||
| `mc-backup` | itzg/mc-backup | world backups every 6h via RCON |
|
| `mc-backup` | itzg/mc-backup | world backups every 6h via RCON |
|
||||||
|
| `monitor` | built from source | admin-only log stream + dashboard at `monitor.${BASE_DOMAIN}` |
|
||||||
|
|
||||||
Optional layers: `avahi` (mDNS `.local`), `dnsmasq` (turnkey DNS) — only if you
|
Vhosts (all proxied through the host nginx → caddy):
|
||||||
don't run your own DNS.
|
|
||||||
|
|
||||||
Config lives in `.env`: `BASE_DOMAIN` (default `ulicraft.lan`), `HOST_LAN_IP`,
|
- apex `${BASE_DOMAIN}` — landing page + `/launcher/` downloads + `/api/mcstatus/*`
|
||||||
`RCON_PASSWORD`, `ENABLE_MDNS`. Services are subdomains: `auth.`, `packwiz.`,
|
- `auth.` — Drasl
|
||||||
`mc.`, plus the apex landing page.
|
- `avatar.` — NMSR
|
||||||
|
- `status.` — Uptime Kuma
|
||||||
|
- `files.` — Filestash player file share (one shared login, writable)
|
||||||
|
- `monitor.` — **admin only**, log stream + dashboard (caddy basic_auth)
|
||||||
|
- `distribution.` — static site from another repo (`DISTRIBUTION_WEB_ROOT`)
|
||||||
|
|
||||||
## DNS (pick one)
|
Config lives in `.env`: `BASE_DOMAIN`, `RCON_PASSWORD`, `CADDY_HTTP_PORT` /
|
||||||
|
`CADDY_HTTP_BIND`, `DISTRIBUTION_WEB_ROOT`, the `FILES_*` block. See `.env.example`.
|
||||||
|
|
||||||
Names must resolve to `HOST_LAN_IP`. Use **your own party DNS server** (recommended):
|
## DNS
|
||||||
|
|
||||||
```sh
|
Handled **outside this repo**. Point `${BASE_DOMAIN}` and every subdomain
|
||||||
tooling/dns-records.sh online # service names only (internet present)
|
(`auth. avatar. status. files. monitor. distribution. www.`) at the host running
|
||||||
tooling/dns-records.sh airgap # + Mojang/launcher/NeoForge spoofs (offline)
|
nginx.
|
||||||
```
|
|
||||||
Paste the printed records into your DNS. **Use `.lan`, not `.local`** — `.local`
|
|
||||||
is intercepted by mDNS and won't resolve via unicast DNS.
|
|
||||||
|
|
||||||
Alternatives: `ENABLE_MDNS=true` + `BASE_DOMAIN=*.local` (zero-config mDNS,
|
|
||||||
macOS/Linux native, Windows needs Bonjour) — but mDNS can't serve the air-gap
|
|
||||||
upstream spoofs. Or layer in `docker-compose.dnsmasq.yml` for a turnkey DNS.
|
|
||||||
|
|
||||||
### Simulate the DNS via /etc/hosts (single machine, testing)
|
|
||||||
|
|
||||||
No DNS server? Append the records straight to `/etc/hosts` (marker-wrapped):
|
|
||||||
|
|
||||||
```sh
|
|
||||||
tooling/dns-records.sh online hosts | sudo tee -a /etc/hosts # services only
|
|
||||||
tooling/dns-records.sh airgap hosts | sudo tee -a /etc/hosts # + Mojang spoofs
|
|
||||||
```
|
|
||||||
|
|
||||||
Remove them later:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
sudo sed -i '/# >>> ulicraft/,/# <<< ulicraft/d' /etc/hosts
|
|
||||||
```
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- `/etc/hosts` has **no wildcard** — only the listed names resolve.
|
|
||||||
- `airgap` adds the Mojang/launcher/NeoForge hostnames pointing at the LAN; this
|
|
||||||
**breaks normal internet access to those domains** while present. Use it only
|
|
||||||
while offline; for an online box use `online`.
|
|
||||||
- `mc.<domain>` works (client defaults to :25565); SRV isn't used via `/etc/hosts`.
|
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
Docker + Docker Compose; for prep also `pnpm`, `packwiz`, `jq`, `curl`,
|
Docker + Docker Compose; for prep also `pnpm`, `jq`, `curl`, `envsubst`.
|
||||||
`envsubst`, `sha1sum`.
|
|
||||||
|
|
||||||
## Launch
|
## Launch
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cp .env.example .env # set BASE_DOMAIN, HOST_LAN_IP, RCON_PASSWORD
|
cp .env.example .env # set BASE_DOMAIN, RCON_PASSWORD, DISTRIBUTION_WEB_ROOT
|
||||||
|
|
||||||
tooling/build-stack.sh --prep # ONLINE, once: render, build landing,
|
# One-time / on change — render configs + build content:
|
||||||
# mirror mods+assets, fetch launcher +
|
tooling/render-config.sh # drasl + nmsr configs from *.tmpl
|
||||||
# authlib, pre-bake the server volume
|
tooling/sync-server-mods.sh # filtered server mods → ./server-mods
|
||||||
|
( cd landing && pnpm install && BASE_DOMAIN="$BASE_DOMAIN" pnpm run build ) # → www/
|
||||||
|
tooling/fetch-launcher.sh # FjordLauncher assets → launcher/ (served at /launcher/)
|
||||||
|
tooling/fetch-authlib.sh # authlib-injector.jar → runtime/ (server JVM agent)
|
||||||
|
|
||||||
tooling/build-stack.sh --up online # internet present, no mirror
|
docker compose up -d --build # first run installs NeoForge + mods, builds nmsr
|
||||||
tooling/build-stack.sh --up airgap # offline; full asset mirror on :443 (default)
|
docker compose down # stop
|
||||||
tooling/build-stack.sh --up core # base only (debugging)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`--up` prints the DNS records to add for that mode. Down:
|
First boot: itzg installs NeoForge + the synced server mods into the `mc_data` volume;
|
||||||
`docker compose -f docker-compose.yml -f docker-compose.static.yml -f docker-compose.mirror.yml down`
|
nmsr does a one-time Rust compile. Watch `docker compose logs -f minecraft` until
|
||||||
|
`Done`.
|
||||||
|
|
||||||
|
## Public TLS (host nginx in front of caddy)
|
||||||
|
|
||||||
|
caddy is published on a localhost-only port (`CADDY_HTTP_BIND=127.0.0.1`,
|
||||||
|
`CADDY_HTTP_PORT=8880`). The host's own nginx terminates TLS with Let's Encrypt
|
||||||
|
certs and reverse-proxies every vhost to caddy by Host header. HTTPS-only: HTTP
|
||||||
|
301s to HTTPS and every TLS vhost sends HSTS.
|
||||||
|
|
||||||
|
1. **Issue certs** (OVH DNS-01, no inbound ports needed):
|
||||||
|
```sh
|
||||||
|
# .env: BASE_DOMAIN, LE_EMAIL, OVH_* creds,
|
||||||
|
# LE_SUBDOMAINS="auth distribution www avatar status"
|
||||||
|
tooling/issue-letsencrypt.sh # → certs/<name>/{cert,key}.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Render + install the nginx vhost** with `tooling/render-nginx.sh`. It pulls
|
||||||
|
`BASE_DOMAIN` + `CADDY_HTTP_PORT` from `.env`, defaults `APP_DIR` to the repo
|
||||||
|
checkout (where `certs/` live), and renders `nginx/ulicraft-caddy.conf.tmpl`:
|
||||||
|
```sh
|
||||||
|
tooling/render-nginx.sh # preview to stdout (dry run)
|
||||||
|
tooling/render-nginx.sh --install # write, symlink, nginx -t, reload
|
||||||
|
```
|
||||||
|
Override `APP_DIR=/path` if the repo isn't at the cert location. Re-run
|
||||||
|
whenever you add a subdomain so its server block is rendered.
|
||||||
|
|
||||||
|
## Avatar renderer (NMSR)
|
||||||
|
|
||||||
|
`avatar.${BASE_DOMAIN}` renders players' skins/avatars via
|
||||||
|
[NMSR-aas](https://github.com/NickAcPT/nmsr-rs), sourced from **Drasl** (not
|
||||||
|
Mojang). No upstream image exists, so it's built from source — `docker/nmsr/`
|
||||||
|
pins a commit; bump `ARG NMSR_REF` to update.
|
||||||
|
|
||||||
|
- **Config**: `nmsr/config.toml.tmpl` → rendered to `nmsr/config.toml` by
|
||||||
|
`tooling/render-config.sh`. `[mojank]` points every Mojang endpoint at
|
||||||
|
`http://auth.${BASE_DOMAIN}`; Drasl serves the Mojang-compatible routes at its
|
||||||
|
bare BaseURL and embeds absolute skin URLs that NMSR fetches directly.
|
||||||
|
`allow_offline_mode_uuids = true` (Drasl issues offline v3 UUIDs).
|
||||||
|
- **Network**: skin resolution stays internal over `mcnet` (caddy alias
|
||||||
|
`auth.${BASE_DOMAIN}` → drasl). caddy reverse-proxies `avatar.` → `nmsr:8080`.
|
||||||
|
- **Endpoints**: e.g. `https://avatar.${BASE_DOMAIN}/skin/<player>`,
|
||||||
|
`/face/<player>`, `/fullbody/<player>` — by username or UUID.
|
||||||
|
|
||||||
|
First build is heavy (Rust compile). Headless rendering uses lavapipe (software
|
||||||
|
Vulkan); if renders fail, check `docker logs nmsr` for Vulkan device init.
|
||||||
|
|
||||||
|
## Status monitoring (Uptime Kuma)
|
||||||
|
|
||||||
|
`status.${BASE_DOMAIN}` runs [Uptime Kuma](https://github.com/louislam/uptime-kuma)
|
||||||
|
— uptime probes for every vhost **and** a native Minecraft-protocol ping (player
|
||||||
|
count + up/down), plus a public status page. It joins `mcnet`, so monitors can
|
||||||
|
probe the stack by internal service name. caddy reverse-proxies `status.` →
|
||||||
|
`uptime-kuma:3001`.
|
||||||
|
|
||||||
|
First run, open `https://status.${BASE_DOMAIN}`, create the admin account, and
|
||||||
|
add monitors. Kuma has no config-as-code — add these once via the UI (data
|
||||||
|
persists in the `kuma_data` volume):
|
||||||
|
|
||||||
|
| Monitor | Type | Target |
|
||||||
|
|---|---|---|
|
||||||
|
| Minecraft | Minecraft Server | `minecraft` : `25565` |
|
||||||
|
| Drasl auth | HTTP(s) | `https://auth.${BASE_DOMAIN}` |
|
||||||
|
| Landing (apex) | HTTP(s) | `https://${BASE_DOMAIN}` |
|
||||||
|
| Distribution | HTTP(s) | `https://distribution.${BASE_DOMAIN}` |
|
||||||
|
| Avatar (NMSR) | HTTP(s) | `https://avatar.${BASE_DOMAIN}` |
|
||||||
|
| Upload (Filestash) | HTTP(s) | `https://upload.${BASE_DOMAIN}` (expect 200 — the login page is a 200) |
|
||||||
|
| Files (static browse) | HTTP(s) | `https://files.${BASE_DOMAIN}` (caddy dir listing of the share, no auth) |
|
||||||
|
|
||||||
|
Internal-name targets (`minecraft`, `drasl:25585`, `nmsr:8080`) isolate "service
|
||||||
|
down" from "ingress/TLS/DNS down"; public-URL targets test the whole chain.
|
||||||
|
`status` is in the default `LE_SUBDOMAINS`; the nginx vhost adds websocket
|
||||||
|
upgrade headers for Kuma's live UI.
|
||||||
|
|
||||||
|
## Server status JSON (mc-status)
|
||||||
|
|
||||||
|
`mc-status` is a self-hosted SLP→JSON pinger (built from `docker/mc-status`, an
|
||||||
|
mcutil wrapper) that the landing's `ServerCard` reads for live player count + MOTD.
|
||||||
|
It emits **mcstatus.io v2** JSON, so the card consumes it unchanged — but
|
||||||
|
same-origin, with no CORS and no third-party calls. caddy proxies the apex
|
||||||
|
`/api/mcstatus/*` path to `mc-status:8080` (strips the prefix); the container has
|
||||||
|
no published port. The pinger only ever probes its configured `MC_STATUS_TARGET`,
|
||||||
|
so the path after the prefix is ignored.
|
||||||
|
|
||||||
|
Endpoint (apex, same-origin):
|
||||||
|
|
||||||
|
```
|
||||||
|
https://${BASE_DOMAIN}/api/mcstatus/v2/status/java/${BASE_DOMAIN}
|
||||||
|
```
|
||||||
|
|
||||||
|
Hitting caddy directly (localhost-only, routes by Host header) — e.g. `ulicraft.net`
|
||||||
|
on `127.0.0.1:8880`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -H "Host: ulicraft.net" \
|
||||||
|
http://127.0.0.1:8880/api/mcstatus/v2/status/java/ulicraft.net
|
||||||
|
```
|
||||||
|
|
||||||
|
Wired in `landing/src/data/site.ts` (`statusApi`) and `caddy/conf.d/10-static.caddy`.
|
||||||
|
|
||||||
|
## Monitor (admin log stream + dashboard)
|
||||||
|
|
||||||
|
`monitor.${BASE_DOMAIN}` — a small Go service (`docker/monitor/`) that tails the
|
||||||
|
Minecraft server's `latest.log` and shows it live with autoscroll, a filter and
|
||||||
|
ERROR/WARN/INFO chips, above a row of tiles: server status, TPS, players online
|
||||||
|
(with NMSR heads), registered accounts, disk free, backup freshness and recent
|
||||||
|
joins/leaves.
|
||||||
|
|
||||||
|
The bell in the header turns on **browser notifications for player join/leave**.
|
||||||
|
Opt-in, off by default, remembered per browser; it fires only while the page is
|
||||||
|
open (a background tab counts, a closed one doesn't).
|
||||||
|
|
||||||
|
**Admin only, and read-only.** The log carries player IPs, chat and admin
|
||||||
|
commands verbatim and nothing sanitizes it, so the whole vhost sits behind caddy
|
||||||
|
`basic_auth`. There are no controls in the UI — every route is a `GET`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# generate the credential (BCRYPT — the opposite of FILES_PASSWORD_HASH, which needs $6$)
|
||||||
|
docker run --rm caddy:alpine caddy hash-password --plaintext 'your-password'
|
||||||
|
```
|
||||||
|
|
||||||
|
Then in `.env` — **single-quote the hash**, Compose mangles bare `$`-sequences:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
MONITOR_ENABLED=on # per-host on|off, like FILES_BROWSE
|
||||||
|
MONITOR_USER=admin
|
||||||
|
MONITOR_PASSWORD_HASH='$2a$14$...'
|
||||||
|
```
|
||||||
|
|
||||||
|
The container publishes **no port**: caddy is the only way in, so there is no
|
||||||
|
host listener to firewall. It holds one credential (`RCON_PASSWORD`, for the TPS
|
||||||
|
tile) and can only send commands from a compile-time allowlist that no HTTP
|
||||||
|
input can reach.
|
||||||
|
|
||||||
|
Adding it needs a cert (`monitor` in `LE_SUBDOMAINS` →
|
||||||
|
`tooling/issue-letsencrypt.sh`) and an nginx vhost (`tooling/render-nginx.sh
|
||||||
|
--install`). Because it adds a caddy conf file, the **first** deploy needs
|
||||||
|
`./update-all.sh --hard` (world downtime); later changes are rolling.
|
||||||
|
|
||||||
|
Full design, gotchas and verification: **`plan/23-monitor.md`**.
|
||||||
|
|
||||||
|
## Player file share (Filestash)
|
||||||
|
|
||||||
|
`upload.${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. The same share is
|
||||||
|
also exposed **read-only, no-auth** as a plain caddy directory listing at
|
||||||
|
`files.${BASE_DOMAIN}` — every uploaded file is publicly downloadable there.
|
||||||
|
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 (`upload.${BASE_DOMAIN}`), with
|
||||||
|
`force_ssl: true`. The SPA builds its redirect origin as `scheme + host`, so a
|
||||||
|
URL there produces `http://https://upload...` 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://upload.${BASE_DOMAIN}/api/config | jq -r .result.origin` must print
|
||||||
|
> `https://upload.${BASE_DOMAIN}` — and re-read `docker compose logs filestash`.
|
||||||
|
|
||||||
## Join (guests)
|
## Join (guests)
|
||||||
|
|
||||||
1. Open `http://ulicraft.lan`, download FjordLauncherUnlocked for your OS.
|
1. Open `https://${BASE_DOMAIN}`, download FjordLauncherUnlocked for your OS.
|
||||||
2. Add an **authlib-injector** account, URL `http://auth.ulicraft.lan/authlib-injector`
|
2. Add an **authlib-injector** account, URL
|
||||||
(register/login at `http://auth.ulicraft.lan`).
|
`https://auth.${BASE_DOMAIN}/authlib-injector` (register/login at
|
||||||
3. **Air-gap only:** trust the local CA — download `http://ulicraft.lan/ca.crt`
|
`https://auth.${BASE_DOMAIN}`).
|
||||||
and add it to your OS trust store (so the HTTPS asset mirror is trusted).
|
3. The launcher installs the modpack from the distribution automatically.
|
||||||
4. Import the pack: `http://packwiz.ulicraft.lan/pack.toml`.
|
4. Connect to **`${BASE_DOMAIN}`** (Minecraft, `:25565`).
|
||||||
5. Connect to **`mc.ulicraft.lan`** (no port needed).
|
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
docker-compose.yml # core: drasl, minecraft, mc-backup, caddy
|
docker-compose.yml # the whole stack (one file)
|
||||||
.static.yml .mirror.yml # online/airgap layers
|
caddy/Caddyfile + conf.d/*.caddy # ingress vhost snippets
|
||||||
.avahi.yml .dnsmasq.yml # optional DNS layers
|
# (core/static/distribution/avatar/status)
|
||||||
caddy/Caddyfile + conf.d/*.caddy # ingress vhost snippets (core/static/mirror)
|
|
||||||
drasl/config/config.toml.tmpl # auth config (rendered)
|
drasl/config/config.toml.tmpl # auth config (rendered)
|
||||||
dnsmasq/dnsmasq.conf.tmpl # optional DNS config (rendered)
|
nmsr/config.toml.tmpl # avatar renderer config, Drasl-backed (rendered)
|
||||||
docker/avahi/ # mDNS responder image
|
docker/nmsr/ # built-from-source NMSR image
|
||||||
pack/ # packwiz modpack source
|
docker/mc-status/ # built-from-source SLP→JSON pinger
|
||||||
|
docker/monitor/ # built-from-source admin log stream + dashboard
|
||||||
landing/ # Astro site → www/
|
landing/ # Astro site → www/
|
||||||
tooling/ # build-stack, render-config, dns-records,
|
launcher/ # FjordLauncher assets (gitignored)
|
||||||
# mirror-mods, mirror-airgap, fetch-launcher
|
server-mods/ # filtered server mod jars (synced, gitignored)
|
||||||
mirror/ # vendored jars + launcher + asset mirror (gitignored)
|
tooling/ # render-config, sync-server-mods, render-nginx,
|
||||||
plan/ # full design docs (00–12)
|
# issue-letsencrypt, fetch-launcher
|
||||||
|
nginx/ulicraft-caddy.conf.tmpl # host-nginx TLS vhost template (front of caddy)
|
||||||
|
plan/ # design docs
|
||||||
```
|
```
|
||||||
|
|
||||||
See `plan/00-overview.md` for the full design.
|
See `plan/00-overview.md` for the full design.
|
||||||
|
|
||||||
|
## Ban IPs
|
||||||
|
`docker compose exec minecraft rcon-cli ban-ip 1.1.1.1`
|
||||||
+13
-7
@@ -1,10 +1,16 @@
|
|||||||
# Base Caddy config. Vhosts live in conf.d/*.caddy snippets that are mounted
|
# Base Caddy config. Vhosts live in conf.d/*.caddy snippets:
|
||||||
# in selectively so the stack can run with or without the static site / mirror:
|
# conf.d/00-core.caddy auth
|
||||||
# conf.d/00-core.caddy auth + packwiz (always — core ingress)
|
# conf.d/10-static.caddy apex landing + launcher downloads
|
||||||
# conf.d/10-static.caddy apex landing + launcher downloads (static toggle)
|
# conf.d/30-distribution.caddy static site from another repo
|
||||||
# conf.d/20-mirror.caddy air-gap upstream mirror (tls internal) (mirror toggle)
|
# conf.d/40-avatar.caddy nmsr skin/avatar renderer
|
||||||
# auto_https disable_redirects: http:// sites stay plaintext on :80; the mirror
|
# conf.d/50-status.caddy uptime-kuma status page
|
||||||
# snippet's hosts still get internal TLS on :443 when that snippet is present.
|
# conf.d/60-upload.caddy filestash player file share
|
||||||
|
# conf.d/62-files.caddy read-only browse of the share (FILES_BROWSE)
|
||||||
|
# conf.d/70-generators.caddy offline misode data-pack generators
|
||||||
|
# conf.d/80-monitor.caddy admin log stream (basic_auth, MONITOR_ENABLED)
|
||||||
|
# All snippets are plain http:// on :80 — the host's nginx terminates TLS in
|
||||||
|
# front (nginx/ulicraft-caddy.conf.tmpl). disable_redirects keeps caddy from
|
||||||
|
# upgrading to its own https.
|
||||||
{
|
{
|
||||||
auto_https disable_redirects
|
auto_https disable_redirects
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
# Core ingress — Blessing Skin variant. Replaces 00-core.caddy when the
|
|
||||||
# docker-compose.blessingskin.yml override is in play (mounted at the same
|
|
||||||
# target path). auth.* now points at Blessing Skin instead of Drasl.
|
|
||||||
#
|
|
||||||
# Blessing Skin serves BOTH its web UI (/) and the Yggdrasil API (/api/yggdrasil)
|
|
||||||
# on the same vhost, so a single reverse_proxy covers everything.
|
|
||||||
http://auth.{$BASE_DOMAIN} {
|
|
||||||
reverse_proxy blessing-skin:80
|
|
||||||
}
|
|
||||||
|
|
||||||
http://pack.{$BASE_DOMAIN} {
|
|
||||||
root * /srv/pack
|
|
||||||
file_server browse
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,4 @@
|
|||||||
# Core ingress — always mounted. Drasl auth + packwiz pack metadata.
|
# Core ingress — always mounted. Drasl auth.
|
||||||
http://auth.{$BASE_DOMAIN} {
|
http://auth.{$BASE_DOMAIN} {
|
||||||
reverse_proxy drasl:25585
|
reverse_proxy drasl:25585
|
||||||
}
|
}
|
||||||
|
|
||||||
http://pack.{$BASE_DOMAIN} {
|
|
||||||
root * /srv/pack
|
|
||||||
file_server browse
|
|
||||||
}
|
|
||||||
@@ -1,19 +1,18 @@
|
|||||||
# Static toggle — apex landing page + launcher downloads.
|
# Apex — landing page (/srv/www) + launcher downloads (/srv/launcher).
|
||||||
# Mounted only when the stack runs with static files (build-stack.sh up static|full).
|
|
||||||
http://{$BASE_DOMAIN} {
|
http://{$BASE_DOMAIN} {
|
||||||
# Caddy's local CA root, so guests can trust the air-gap HTTPS mirror.
|
|
||||||
# Mounted from ./caddy/caddy-root-ca.crt (exported by build-stack prep;
|
|
||||||
# the live pki dir is root-only 0600 and can't be served directly).
|
|
||||||
handle /ca.crt {
|
|
||||||
root * /srv/ca-pub
|
|
||||||
file_server
|
|
||||||
}
|
|
||||||
# Launcher downloads are a sibling mount (/srv/launcher), not nested under
|
# Launcher downloads are a sibling mount (/srv/launcher), not nested under
|
||||||
# the read-only /srv/www. handle_path strips the /launcher prefix.
|
# the read-only /srv/www. handle_path strips the /launcher prefix.
|
||||||
handle_path /launcher/* {
|
handle_path /launcher/* {
|
||||||
root * /srv/launcher
|
root * /srv/launcher
|
||||||
file_server browse
|
file_server browse
|
||||||
}
|
}
|
||||||
|
# Self-hosted Minecraft status JSON (mc-status service). Same-origin, so the
|
||||||
|
# landing's ServerCard fetch needs no CORS. The <addr> in the path is
|
||||||
|
# ignored by mc-status — it only ever pings its configured MC_STATUS_TARGET.
|
||||||
|
handle /api/mcstatus/* {
|
||||||
|
uri strip_prefix /api/mcstatus
|
||||||
|
reverse_proxy mc-status:8080
|
||||||
|
}
|
||||||
handle {
|
handle {
|
||||||
root * /srv/www
|
root * /srv/www
|
||||||
file_server
|
file_server
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
# Mirror toggle — full client air-gap mirror (see plan/11-full-airgap-mirror.md).
|
|
||||||
# Byte-exact copies of the real upstream hosts over HTTPS with Caddy's local CA.
|
|
||||||
# dnsmasq spoofs these hostnames -> our host; {host} roots each at its subtree.
|
|
||||||
# Mounted only when the stack runs with the mirror (build-stack.sh up mirror|full).
|
|
||||||
meta.prismlauncher.org, piston-meta.mojang.com, piston-data.mojang.com, libraries.minecraft.net, maven.neoforged.net, resources.download.minecraft.net {
|
|
||||||
tls internal
|
|
||||||
root * /srv/mirror/{host}
|
|
||||||
file_server
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
# Distribution toggle — static site served from a web root in ANOTHER repo.
|
# Distribution — static site served from a web root in ANOTHER repo. The host
|
||||||
# The host path is set by DISTRIBUTION_WEB_ROOT in .env and bind-mounted to
|
# path is set by DISTRIBUTION_WEB_ROOT in .env and bind-mounted to
|
||||||
# /srv/distribution by docker-compose.caddy.yml. Mounted only when that var is
|
# /srv/distribution by the caddy service in docker-compose.yml.
|
||||||
# set (see the distribution override).
|
|
||||||
http://distribution.{$BASE_DOMAIN} {
|
http://distribution.{$BASE_DOMAIN} {
|
||||||
root * /srv/distribution
|
root * /srv/distribution
|
||||||
|
# The landing page (apex origin) fetches launcher.json cross-origin to render
|
||||||
|
# the download buttons. Allow it — launcher assets are fully public, no creds.
|
||||||
|
header /launcher/* Access-Control-Allow-Origin "*"
|
||||||
file_server browse
|
file_server browse
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Avatar — NMSR skin/avatar renderer (nmsr service), Drasl-backed.
|
||||||
|
http://avatar.{$BASE_DOMAIN} {
|
||||||
|
reverse_proxy nmsr:8080
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Status — Uptime Kuma monitoring + public status page (uptime-kuma service).
|
||||||
|
http://status.{$BASE_DOMAIN} {
|
||||||
|
reverse_proxy uptime-kuma:3001
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Upload — 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://upload.${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.
|
||||||
|
#
|
||||||
|
# Filestash CANNOT be served under a subpath (SPA hardcodes /api, /assets, /login
|
||||||
|
# at root), so it gets its own subdomain. The read-only browse of the same share
|
||||||
|
# lives at files.${BASE_DOMAIN} (62-files.caddy).
|
||||||
|
http://upload.{$BASE_DOMAIN} {
|
||||||
|
reverse_proxy filestash:8334
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Files browse DISABLED (FILES_BROWSE=off in .env).
|
||||||
|
#
|
||||||
|
# This stub is mounted at /etc/caddy/conf.d/62-files.caddy instead of
|
||||||
|
# 62-files.on.caddy, so caddy defines NO vhost for files.${BASE_DOMAIN} and the
|
||||||
|
# share is not served read-only anywhere. Uploads via upload.${BASE_DOMAIN}
|
||||||
|
# (filestash) are unaffected — that is a separate service.
|
||||||
|
#
|
||||||
|
# Caddy needs a parseable file here, not an absent one: the Caddyfile imports
|
||||||
|
# conf.d/*.caddy and the mount target is fixed. Comments only, on purpose.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Files — read-only static browse of the shared upload dir (FILES_DATA_DIR,
|
||||||
|
# mounted at /srv/files). NO AUTH: every uploaded file is publicly listable and
|
||||||
|
# downloadable here. Writes happen through filestash at upload.${BASE_DOMAIN}
|
||||||
|
# (60-upload.caddy); this vhost is browse-only.
|
||||||
|
http://files.{$BASE_DOMAIN} {
|
||||||
|
root * /srv/files
|
||||||
|
file_server browse
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Generators — a pinned, self-contained fork of misode.github.io (Minecraft
|
||||||
|
# data pack generators) at generators.${BASE_DOMAIN}. Full design: plan/21-generators.md.
|
||||||
|
#
|
||||||
|
# The container serves BOTH the app bundle and the ~107M offline data mirror
|
||||||
|
# (mcmeta + vanilla-mcdoc) that tooling/sync-generators-data.sh produces, so
|
||||||
|
# the whole thing is single-origin by construction.
|
||||||
|
#
|
||||||
|
# WHY THAT MATTERS: this app is 100% client-side. Every data fetch comes from
|
||||||
|
# the PLAYER'S BROWSER, not from this host. Blocking the container's egress
|
||||||
|
# proves nothing, and "cochi has no WAN at the party" protects nothing — a
|
||||||
|
# single leftover upstream URL simply fails in the browser, at the venue, with
|
||||||
|
# no internet available to debug it.
|
||||||
|
http://generators.{$BASE_DOMAIN} {
|
||||||
|
# The CSP is the ENFORCEMENT mechanism for offline operation, not decoration.
|
||||||
|
#
|
||||||
|
# `connect-src 'self'` is the load-bearing directive: it hard-blocks any
|
||||||
|
# fetch/XHR to an origin we do not serve. If an upstream URL survives the
|
||||||
|
# fork patch — or a future upstream merge reintroduces one — it breaks
|
||||||
|
# LOUDLY in prod, on an ordinary day with internet available to fix it,
|
||||||
|
# instead of silently at the party. This is the same lesson as the filestash
|
||||||
|
# `http://https://` incident: a 200 on `/` proves the server is up, never
|
||||||
|
# that the app works.
|
||||||
|
#
|
||||||
|
# script-src/style-src stay permissive ON PURPOSE for now. Spyglass and
|
||||||
|
# deepslate may need wasm/eval and this has NOT been characterised yet;
|
||||||
|
# tightening them is a post-party task. Loosening `connect-src` is not —
|
||||||
|
# that one is the whole point.
|
||||||
|
header {
|
||||||
|
Content-Security-Policy "default-src 'self'; connect-src 'self'; img-src 'self' data: blob:; script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; font-src 'self'; worker-src 'self' blob:; base-uri 'self'; form-action 'self'"
|
||||||
|
# The mirror is pinned to one MC version and changes only when
|
||||||
|
# sync-generators-data.sh is re-run, so let browsers keep it. This is
|
||||||
|
# also what makes a second visit cheap on a LAN with no upstream.
|
||||||
|
?Cache-Control "public, max-age=604800"
|
||||||
|
}
|
||||||
|
reverse_proxy generators:80
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Monitor DISABLED (MONITOR_ENABLED=off in .env).
|
||||||
|
#
|
||||||
|
# This stub is mounted at /etc/caddy/conf.d/80-monitor.caddy instead of
|
||||||
|
# 80-monitor.on.caddy, so caddy defines NO vhost for monitor.${BASE_DOMAIN} and
|
||||||
|
# the log stream is unreachable on this host. The monitor container may still be
|
||||||
|
# running — it just has no ingress, and it publishes no port, so nothing can
|
||||||
|
# reach it.
|
||||||
|
#
|
||||||
|
# Caddy needs a parseable file here, not an absent one: the Caddyfile imports
|
||||||
|
# conf.d/*.caddy and the mount target is fixed. Comments only, on purpose.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Monitor — admin-only latest.log stream (monitor service). Read-only: the UI
|
||||||
|
# only ever issues GETs, and the service exposes no mutating route at all.
|
||||||
|
# Full design: plan/23-monitor.md.
|
||||||
|
#
|
||||||
|
# basic_auth here IS the access control. The monitor container has no auth of
|
||||||
|
# its own and publishes no port, so caddy is the only path to it — there is no
|
||||||
|
# second door to also lock.
|
||||||
|
#
|
||||||
|
# The hash must be BCRYPT (`caddy hash-password`). Note this is the OPPOSITE of
|
||||||
|
# filestash (60-upload.caddy / plan/20-files.md), which only accepts $6$ and
|
||||||
|
# silently rejects bcrypt. Do not copy a hash between the two services.
|
||||||
|
#
|
||||||
|
# No path is exempted. Uptime Kuma probes /healthz as http://monitor:8080/healthz
|
||||||
|
# over mcnet, bypassing caddy entirely, so the whole vhost stays gated.
|
||||||
|
http://monitor.{$BASE_DOMAIN} {
|
||||||
|
basic_auth {
|
||||||
|
{$MONITOR_USER} {$MONITOR_PASSWORD_HASH}
|
||||||
|
}
|
||||||
|
reverse_proxy monitor:8080
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,21 +0,0 @@
|
|||||||
# Resolve every *.${BASE_DOMAIN} (and the apex) to the Docker host LAN IP.
|
|
||||||
# Rendered by tooling/render-config.sh -> dnsmasq/dnsmasq.conf (gitignored).
|
|
||||||
address=/${BASE_DOMAIN}/${HOST_LAN_IP}
|
|
||||||
no-resolv
|
|
||||||
|
|
||||||
# Minecraft SRV record: guests connect to "mc.${BASE_DOMAIN}" with NO port —
|
|
||||||
# the SRV lookup points the client at port 25565. Target mc.${BASE_DOMAIN}
|
|
||||||
# resolves to HOST_LAN_IP via the wildcard above.
|
|
||||||
# Format: srv-host=_service._proto.name,target,port
|
|
||||||
srv-host=_minecraft._tcp.mc.${BASE_DOMAIN},mc.${BASE_DOMAIN},25565
|
|
||||||
|
|
||||||
# Full client air-gap (see plan/11-full-airgap-mirror.md): spoof the real
|
|
||||||
# upstream hosts -> our Caddy mirror. Auth hosts (session/textures/api.mojang)
|
|
||||||
# are NOT spoofed — drasl handles those via authlib-injector. Keep this list in
|
|
||||||
# sync with the proxy-capture step.
|
|
||||||
address=/meta.prismlauncher.org/${HOST_LAN_IP}
|
|
||||||
address=/piston-meta.mojang.com/${HOST_LAN_IP}
|
|
||||||
address=/piston-data.mojang.com/${HOST_LAN_IP}
|
|
||||||
address=/libraries.minecraft.net/${HOST_LAN_IP}
|
|
||||||
address=/maven.neoforged.net/${HOST_LAN_IP}
|
|
||||||
address=/resources.download.minecraft.net/${HOST_LAN_IP}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
# mDNS toggle — publish *.local service names via the HOST's avahi-daemon for
|
|
||||||
# zero-config guest resolution. Enabled when ENABLE_MDNS=true in .env and only
|
|
||||||
# meaningful when BASE_DOMAIN=*.local.
|
|
||||||
#
|
|
||||||
# Host prerequisites:
|
|
||||||
# - avahi-daemon running on the host
|
|
||||||
# - if the host has many interfaces (e.g. lots of docker bridges), restrict
|
|
||||||
# avahi to the LAN NIC in /etc/avahi/avahi-daemon.conf:
|
|
||||||
# allow-interfaces=<lan-iface>
|
|
||||||
# otherwise avahi sees its own announcements across bridges → "Local name
|
|
||||||
# collision" and publishing fails.
|
|
||||||
services:
|
|
||||||
avahi:
|
|
||||||
build: ./docker/avahi
|
|
||||||
image: ulicraft-avahi:local
|
|
||||||
container_name: avahi
|
|
||||||
restart: unless-stopped
|
|
||||||
network_mode: host
|
|
||||||
# AppArmor's docker-default profile blocks the D-Bus publish to host avahi.
|
|
||||||
security_opt:
|
|
||||||
- apparmor=unconfined
|
|
||||||
environment:
|
|
||||||
BASE_DOMAIN: ${BASE_DOMAIN}
|
|
||||||
HOST_LAN_IP: ${HOST_LAN_IP}
|
|
||||||
DBUS_SYSTEM_BUS_ADDRESS: "unix:path=/run/dbus/system_bus_socket"
|
|
||||||
volumes:
|
|
||||||
# Talk to the host's avahi-daemon (host must run avahi-daemon).
|
|
||||||
- /run/dbus/system_bus_socket:/run/dbus/system_bus_socket
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
# ──────────────────────────────────────────────────────────────────
|
|
||||||
# Blessing Skin auth variant — use INSTEAD of Drasl.
|
|
||||||
# ──────────────────────────────────────────────────────────────────
|
|
||||||
# Layer this over the base file AND the caddy ingress (caddy lives in its own
|
|
||||||
# file now; this override only swaps caddy's core conf, so caddy.yml is required):
|
|
||||||
#
|
|
||||||
# docker compose -f docker-compose.yml -f docker-compose.caddy.yml \
|
|
||||||
# -f docker-compose.blessingskin.yml up -d
|
|
||||||
#
|
|
||||||
# What it does vs the base stack:
|
|
||||||
# - drasl → left running but UNUSED + unreachable (Caddy no longer
|
|
||||||
# routes to it; it has no host port). Compose merges
|
|
||||||
# depends_on, so it can't be removed from an override —
|
|
||||||
# idling it is the clean option. `docker compose ... stop
|
|
||||||
# drasl` after up if you want it down.
|
|
||||||
# - caddy → auth.* now reverse-proxies blessing-skin:80
|
|
||||||
# - mariadb → new; Blessing Skin's datastore (no SQLite support)
|
|
||||||
# - blessing-skin → new; PHP skin server + yggdrasil-api plugin
|
|
||||||
# - minecraft → authlib-injector now points at /api/yggdrasil,
|
|
||||||
# ONLINE_MODE=TRUE (real session validation against BSS)
|
|
||||||
#
|
|
||||||
# First-run is a WEB WIZARD — see plan/03b-blessing-skin.md. The yggdrasil-api
|
|
||||||
# plugin must be installed + enabled from the BSS admin panel before the
|
|
||||||
# /api/yggdrasil endpoint (and thus Minecraft login) works.
|
|
||||||
#
|
|
||||||
# DB creds + APP_KEY come from .env (see .env.example: BS_* vars).
|
|
||||||
# ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
services:
|
|
||||||
caddy:
|
|
||||||
# Swap the core ingress conf for the Blessing Skin one. Compose MERGES
|
|
||||||
# volumes by container path, so mounting the BSS conf at the SAME target
|
|
||||||
# (/etc/caddy/conf.d/00-core.caddy) shadows the base 00-core.caddy; all
|
|
||||||
# other base mounts (pack, custom, static, ca) are kept automatically.
|
|
||||||
volumes:
|
|
||||||
- ./caddy/conf.d/00-core-blessingskin.caddy:/etc/caddy/conf.d/00-core.caddy:ro
|
|
||||||
depends_on:
|
|
||||||
- blessing-skin
|
|
||||||
|
|
||||||
mariadb:
|
|
||||||
image: mariadb:11
|
|
||||||
container_name: mariadb
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
MARIADB_DATABASE: ${BS_DB_NAME}
|
|
||||||
MARIADB_USER: ${BS_DB_USER}
|
|
||||||
MARIADB_PASSWORD: ${BS_DB_PASSWORD}
|
|
||||||
MARIADB_ROOT_PASSWORD: ${BS_DB_ROOT_PASSWORD}
|
|
||||||
TZ: "Europe/Madrid"
|
|
||||||
volumes:
|
|
||||||
- bs_db:/var/lib/mysql
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 10
|
|
||||||
networks:
|
|
||||||
- mcnet
|
|
||||||
|
|
||||||
blessing-skin:
|
|
||||||
image: azusamikan/blessing-skin-server-docker:latest
|
|
||||||
container_name: blessing-skin
|
|
||||||
restart: unless-stopped
|
|
||||||
# Internal-only — reached via Caddy at auth.${BASE_DOMAIN}.
|
|
||||||
environment:
|
|
||||||
APP_URL: "http://auth.${BASE_DOMAIN}"
|
|
||||||
# Persist APP_KEY across recreates (else sessions/encryption break).
|
|
||||||
# Generate once: docker run --rm azusamikan/blessing-skin-server-docker:latest php artisan key:generate --show
|
|
||||||
APP_KEY: ${BS_APP_KEY}
|
|
||||||
DB_DRIVER: "mysql"
|
|
||||||
DB_HOST: "mariadb"
|
|
||||||
DB_PORT: "3306"
|
|
||||||
DB_DATABASE: ${BS_DB_NAME}
|
|
||||||
DB_USERNAME: ${BS_DB_USER}
|
|
||||||
DB_PASSWORD: ${BS_DB_PASSWORD}
|
|
||||||
TZ: "Europe/Madrid"
|
|
||||||
volumes:
|
|
||||||
- bs_storage:/app/storage # uploaded skins/capes + textures
|
|
||||||
- bs_plugins:/app/plugins # yggdrasil-api plugin lives here
|
|
||||||
depends_on:
|
|
||||||
mariadb:
|
|
||||||
condition: service_healthy
|
|
||||||
networks:
|
|
||||||
- mcnet
|
|
||||||
|
|
||||||
minecraft:
|
|
||||||
environment:
|
|
||||||
# authlib-injector API root for Blessing Skin's yggdrasil-api plugin
|
|
||||||
# is <site>/api/yggdrasil (NOT /authlib-injector like Drasl).
|
|
||||||
JVM_OPTS: "-javaagent:/extras/authlib-injector.jar=http://auth.${BASE_DOMAIN}/api/yggdrasil"
|
|
||||||
# authlib-injector needs real auth: online-mode TRUE so the server
|
|
||||||
# validates sessions against Blessing Skin. (Drasl variant used FALSE.)
|
|
||||||
ONLINE_MODE: "TRUE"
|
|
||||||
# Keep FALSE for safety on 1.21+. The yggdrasil-api plugin DOES sign
|
|
||||||
# profile keys, so you MAY flip this to TRUE if signed chat is wanted.
|
|
||||||
ENFORCE_SECURE_PROFILE: "FALSE"
|
|
||||||
# Merged with the base depends_on (drasl, caddy); just adds blessing-skin so
|
|
||||||
# Minecraft starts after the skin server is up.
|
|
||||||
depends_on:
|
|
||||||
- blessing-skin
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
bs_db:
|
|
||||||
bs_storage:
|
|
||||||
bs_plugins:
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
# ──────────────────────────────────────────────────────────────────
|
|
||||||
# Caddy ingress — LAN / air-gap path. NOT started with the base stack.
|
|
||||||
# ──────────────────────────────────────────────────────────────────
|
|
||||||
# Caddy was moved out of docker-compose.yml so the base stack can run behind
|
|
||||||
# the host's nginx (production) without it. build-stack.sh adds this file for
|
|
||||||
# its online/airgap/core modes; static/mirror overrides mount extra vhosts in.
|
|
||||||
#
|
|
||||||
# docker compose -f docker-compose.yml -f docker-compose.caddy.yml up -d
|
|
||||||
#
|
|
||||||
# Holds the mcnet aliases (auth./pack.${BASE_DOMAIN}) so containers resolve the
|
|
||||||
# stack's own names internally — this is why minecraft can reach the pack here
|
|
||||||
# without extra_hosts (the nginx path uses extra_hosts instead).
|
|
||||||
services:
|
|
||||||
caddy:
|
|
||||||
image: caddy:alpine
|
|
||||||
container_name: caddy
|
|
||||||
restart: unless-stopped
|
|
||||||
# Host-published port. Default 80 for the standalone LAN/air-gap path; set
|
|
||||||
# CADDY_HTTP_PORT to a high localhost-only port when the machine's nginx
|
|
||||||
# fronts caddy (nginx terminates TLS, reverse-proxies here by Host header —
|
|
||||||
# see nginx/ulicraft-caddy.conf.tmpl).
|
|
||||||
ports:
|
|
||||||
- "${CADDY_HTTP_BIND:-0.0.0.0}:${CADDY_HTTP_PORT:-80}:80"
|
|
||||||
environment:
|
|
||||||
BASE_DOMAIN: ${BASE_DOMAIN}
|
|
||||||
volumes:
|
|
||||||
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
|
|
||||||
- ./caddy/conf.d/00-core.caddy:/etc/caddy/conf.d/00-core.caddy:ro
|
|
||||||
- ./caddy/conf.d/10-static.caddy:/etc/caddy/conf.d/10-static.caddy:ro
|
|
||||||
- ./www:/srv/www:ro
|
|
||||||
- ./mirror/launcher:/srv/launcher:ro
|
|
||||||
- ./caddy/caddy-root-ca.crt:/srv/ca-pub/ca.crt:ro
|
|
||||||
- ./pack:/srv/pack:ro
|
|
||||||
# Custom (locally-hosted) mod jars live OUTSIDE ./pack so packwiz refresh
|
|
||||||
# doesn't index them as direct files. Served at pack.${BASE_DOMAIN}/custom/.
|
|
||||||
- ./custom:/srv/pack/custom:ro
|
|
||||||
networks:
|
|
||||||
mcnet:
|
|
||||||
# The upstream spoof-host aliases live in docker-compose.mirror.yml —
|
|
||||||
# adding them here would hijack maven.neoforged.net etc. for ALL mcnet
|
|
||||||
# containers, breaking the ONLINE NeoForge install during pre-bake.
|
|
||||||
aliases:
|
|
||||||
- "auth.${BASE_DOMAIN}"
|
|
||||||
- "pack.${BASE_DOMAIN}"
|
|
||||||
|
|
||||||
# Restore the ordering the base file used to have (base drops it so it can run
|
|
||||||
# caddy-less behind nginx).
|
|
||||||
minecraft:
|
|
||||||
depends_on:
|
|
||||||
- caddy
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# ──────────────────────────────────────────────────────────────────
|
|
||||||
# Distribution vhost — distribution.${BASE_DOMAIN}, static files from a web
|
|
||||||
# root that lives in ANOTHER repo. Layered on top of the caddy ingress.
|
|
||||||
# ──────────────────────────────────────────────────────────────────
|
|
||||||
# docker compose -f docker-compose.yml -f docker-compose.caddy.yml \
|
|
||||||
# -f docker-compose.distribution.yml up -d
|
|
||||||
#
|
|
||||||
# DISTRIBUTION_WEB_ROOT (.env) = absolute host path to the built static site in
|
|
||||||
# the other repo. Bind-mounted read-only; caddy serves it via the
|
|
||||||
# conf.d/30-distribution.caddy snippet.
|
|
||||||
services:
|
|
||||||
caddy:
|
|
||||||
volumes:
|
|
||||||
- ./caddy/conf.d/30-distribution.caddy:/etc/caddy/conf.d/30-distribution.caddy:ro
|
|
||||||
- ${DISTRIBUTION_WEB_ROOT:?DISTRIBUTION_WEB_ROOT unset in .env}:/srv/distribution:ro
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
# Optional turnkey DNS — only if you DON'T run your own party DNS server.
|
|
||||||
# Layer on: `docker compose -f docker-compose.yml -f docker-compose.dnsmasq.yml ... up -d`.
|
|
||||||
# Serves dnsmasq/dnsmasq.conf (rendered from the template, includes the airgap
|
|
||||||
# spoof records). If you have your own DNS, skip this and use the records from
|
|
||||||
# `tooling/dns-records.sh` instead.
|
|
||||||
services:
|
|
||||||
dnsmasq:
|
|
||||||
image: jpillora/dnsmasq
|
|
||||||
container_name: dnsmasq
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
# Bind DNS to the host LAN IP so it doesn't clash with systemd-resolved on :53.
|
|
||||||
- "${HOST_LAN_IP}:53:53/udp"
|
|
||||||
- "${HOST_LAN_IP}:53:53/tcp"
|
|
||||||
volumes:
|
|
||||||
- ./dnsmasq/dnsmasq.conf:/etc/dnsmasq.conf:ro
|
|
||||||
networks:
|
|
||||||
- mcnet
|
|
||||||
# webproc UI (:8080) can be exposed behind Caddy as dns.${BASE_DOMAIN};
|
|
||||||
# guard with HTTP_USER / HTTP_PASS.
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
# Mirror toggle — full client air-gap upstream mirror (see plan/11).
|
|
||||||
# Layer on top of the base: `docker compose -f docker-compose.yml -f docker-compose.mirror.yml up -d`
|
|
||||||
# (or `tooling/build-stack.sh --up mirror`). Adds the mirror vhost snippet, the
|
|
||||||
# upstream content mount, and publishes :443 for Caddy's `tls internal` certs.
|
|
||||||
# Populate the mirror first: tooling/mirror-airgap.sh (+ the proxy-capture step).
|
|
||||||
services:
|
|
||||||
caddy:
|
|
||||||
ports:
|
|
||||||
- "443:443"
|
|
||||||
volumes:
|
|
||||||
- ./caddy/conf.d/20-mirror.caddy:/etc/caddy/conf.d/20-mirror.caddy:ro
|
|
||||||
- ./mirror/upstream:/srv/mirror:ro
|
|
||||||
networks:
|
|
||||||
mcnet:
|
|
||||||
# Spoof the real upstream hosts -> Caddy (air-gap runtime only). Includes
|
|
||||||
# the base aliases because compose replaces (not merges) the alias list.
|
|
||||||
aliases:
|
|
||||||
- "auth.${BASE_DOMAIN}"
|
|
||||||
- "packwiz.${BASE_DOMAIN}"
|
|
||||||
- "meta.prismlauncher.org"
|
|
||||||
- "piston-meta.mojang.com"
|
|
||||||
- "piston-data.mojang.com"
|
|
||||||
- "libraries.minecraft.net"
|
|
||||||
- "maven.neoforged.net"
|
|
||||||
- "resources.download.minecraft.net"
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
# ──────────────────────────────────────────────────────────────────
|
|
||||||
# Production ingress = the HOST's nginx + Let's Encrypt. No caddy.
|
|
||||||
# ──────────────────────────────────────────────────────────────────
|
|
||||||
# docker compose -f docker-compose.yml -f docker-compose.nginx.yml up -d
|
|
||||||
#
|
|
||||||
# nginx (on the host, already installed) terminates TLS with the LE certs from
|
|
||||||
# tooling/issue-letsencrypt.sh and:
|
|
||||||
# - serves apex + pack.${BASE_DOMAIN} as static files (./www, ./pack, ./custom)
|
|
||||||
# - reverse-proxies auth.${BASE_DOMAIN} -> 127.0.0.1:25585 (drasl)
|
|
||||||
# See nginx/ulicraft.conf.tmpl.
|
|
||||||
#
|
|
||||||
# This override:
|
|
||||||
# - publishes drasl on localhost so the host nginx can reach it
|
|
||||||
# - points the minecraft container at the host (extra_hosts) over HTTPS, so it
|
|
||||||
# fetches the pack and validates sessions through the same public names the
|
|
||||||
# LE certs cover (JVM trusts Let's Encrypt out of the box)
|
|
||||||
services:
|
|
||||||
drasl:
|
|
||||||
ports:
|
|
||||||
- "127.0.0.1:25585:25585"
|
|
||||||
|
|
||||||
minecraft:
|
|
||||||
# Make the public names resolve to the host running nginx.
|
|
||||||
extra_hosts:
|
|
||||||
- "auth.${BASE_DOMAIN}:host-gateway"
|
|
||||||
- "pack.${BASE_DOMAIN}:host-gateway"
|
|
||||||
environment:
|
|
||||||
# Use HTTPS now that nginx terminates TLS with a publicly-trusted cert.
|
|
||||||
JVM_OPTS: "-javaagent:/extras/authlib-injector.jar=https://auth.${BASE_DOMAIN}/authlib-injector"
|
|
||||||
PACKWIZ_URL: "https://pack.${BASE_DOMAIN}/pack.toml"
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# Static toggle — apex landing page + launcher downloads.
|
|
||||||
# Layer on top of the base: `docker compose -f docker-compose.yml -f docker-compose.static.yml up -d`
|
|
||||||
# (or `tooling/build-stack.sh --up static`). Adds the static vhost snippet plus
|
|
||||||
# the www + launcher content mounts. Build www first: cd landing && pnpm run build.
|
|
||||||
services:
|
|
||||||
caddy:
|
|
||||||
volumes:
|
|
||||||
- ./caddy/conf.d/10-static.caddy:/etc/caddy/conf.d/10-static.caddy:ro
|
|
||||||
- ./www:/srv/www:ro
|
|
||||||
- ./mirror/launcher:/srv/launcher:ro
|
|
||||||
- ./caddy/caddy-root-ca.crt:/srv/ca-pub/ca.crt:ro
|
|
||||||
+307
-30
@@ -1,31 +1,31 @@
|
|||||||
# ──────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────
|
||||||
# Ulicraft LAN-party stack — Minecraft 1.21.1 NeoForge + Drasl auth
|
# Ulicraft LAN-party stack — Minecraft 1.21.1 NeoForge + Drasl auth
|
||||||
# ──────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────
|
||||||
# Single source of config: BASE_DOMAIN + HOST_LAN_IP in .env.
|
# Single, unified compose. One file holds the whole stack:
|
||||||
# DNS is provided by YOUR party DNS server — see tooling/dns-records.sh for the
|
# drasl auth + skins (Yggdrasil), internal only
|
||||||
# records to add (online vs airgap). A turnkey dnsmasq is available as an
|
# minecraft the server (itzg/NEOFORGE), mods from ./server-mods volume
|
||||||
# optional layer: docker-compose.dnsmasq.yml.
|
# mc-backup world backups every 6h via RCON
|
||||||
# - ingress is layered on, NOT in this base file: caddy (docker-compose.caddy.yml,
|
# caddy internal ingress — routes auth./apex/launcher/avatar.
|
||||||
# LAN/air-gap, holds mcnet aliases) OR the host's nginx (docker-compose.nginx.yml
|
# /distribution. by Host header (conf.d/*.caddy)
|
||||||
# + Let's Encrypt, production)
|
# nmsr skin/avatar renderer at avatar.${BASE_DOMAIN}
|
||||||
# - drasl handles auth (password login; NO Keycloak/OIDC in this stack)
|
# uptime-kuma status monitoring at status.${BASE_DOMAIN}
|
||||||
# - packwiz pack is served by the ingress at pack.${BASE_DOMAIN}
|
#
|
||||||
# - minecraft installs mods via PACKWIZ_URL, authlib-injector -> auth.
|
# Bring up: docker compose up -d (render configs first: tooling/render-config.sh)
|
||||||
|
#
|
||||||
|
# DNS is handled OUTSIDE this repo — point every service name at the host.
|
||||||
|
# Production TLS terminates at the HOST's nginx in front of caddy
|
||||||
|
# (nginx/ulicraft-caddy.conf.tmpl + Let's Encrypt); caddy is published on a
|
||||||
|
# localhost-only port (CADDY_HTTP_BIND/CADDY_HTTP_PORT in .env). Containers reach
|
||||||
|
# the stack's own names internally via caddy's mcnet alias (auth.).
|
||||||
# ──────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# NOTE: the HTTP(S) ingress is NOT in this base file.
|
|
||||||
# - LAN / air-gap: caddy (docker-compose.caddy.yml) — added by build-stack.sh
|
|
||||||
# - production: the HOST's nginx + Let's Encrypt (docker-compose.nginx.yml
|
|
||||||
# publishes drasl to localhost; nginx/ulicraft.conf.tmpl)
|
|
||||||
# Base alone (drasl + minecraft + mc-backup) is not a complete deployment —
|
|
||||||
# always layer one ingress file on top.
|
|
||||||
drasl:
|
drasl:
|
||||||
image: unmojang/drasl:latest
|
image: unmojang/drasl:latest
|
||||||
container_name: drasl
|
container_name: drasl
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# Internal-only: no published host port. Reached via Caddy at
|
# Internal-only: no published host port. Reached via caddy at
|
||||||
# auth.${BASE_DOMAIN} (Caddy holds the network alias on mcnet).
|
# auth.${BASE_DOMAIN} (caddy holds the network alias on mcnet).
|
||||||
volumes:
|
volumes:
|
||||||
- ./drasl/config:/etc/drasl:ro
|
- ./drasl/config:/etc/drasl:ro
|
||||||
- drasl_state:/var/lib/drasl
|
- drasl_state:/var/lib/drasl
|
||||||
@@ -48,7 +48,9 @@ services:
|
|||||||
TYPE: "NEOFORGE"
|
TYPE: "NEOFORGE"
|
||||||
VERSION: "1.21.1"
|
VERSION: "1.21.1"
|
||||||
# Verify NEOFORGE_VERSION against projects.neoforged.net before deploy.
|
# Verify NEOFORGE_VERSION against projects.neoforged.net before deploy.
|
||||||
NEOFORGE_VERSION: "21.1.209" # pin a known-good build; update deliberately
|
# Must match the distribution's NeoForge (distribution.json → 21.1.233);
|
||||||
|
# several mods (ferritecore≥218, farmersdelight≥219, configured≥211) need it.
|
||||||
|
NEOFORGE_VERSION: "21.1.233" # pin a known-good build; update deliberately
|
||||||
|
|
||||||
# ── Memory / performance ────────────────────────────────────
|
# ── Memory / performance ────────────────────────────────────
|
||||||
INIT_MEMORY: "4G"
|
INIT_MEMORY: "4G"
|
||||||
@@ -56,25 +58,38 @@ 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
|
||||||
# mount authlib-injector.jar at /extras/authlib-injector.jar
|
# handshake from Mojang to Drasl. online-mode=false skips the handshake
|
||||||
JVM_OPTS: "-javaagent:/extras/authlib-injector.jar=http://auth.${BASE_DOMAIN}/authlib-injector"
|
# 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.
|
||||||
|
JVM_OPTS: "-javaagent:/extras/authlib-injector.jar=https://auth.${BASE_DOMAIN}/authlib-injector"
|
||||||
|
|
||||||
# ── Server config ───────────────────────────────────────────
|
# ── Server config ───────────────────────────────────────────
|
||||||
DIFFICULTY: "normal"
|
DIFFICULTY: "normal"
|
||||||
MODE: "survival"
|
MODE: "survival"
|
||||||
MOTD: "Uli LAN party"
|
MOTD: "Uli LAN party"
|
||||||
|
# Server-list icon (auto-scaled to 64x64). PNG lives in ./runtime (mounted
|
||||||
|
# /extras); OVERRIDE_ICON re-applies it on every boot. Also surfaces in the
|
||||||
|
# landing ServerCard via the SLP favicon.
|
||||||
|
ICON: "/extras/server-icon.png"
|
||||||
|
OVERRIDE_ICON: "TRUE"
|
||||||
MAX_PLAYERS: "10"
|
MAX_PLAYERS: "10"
|
||||||
VIEW_DISTANCE: "10"
|
VIEW_DISTANCE: "10"
|
||||||
SIMULATION_DISTANCE: "8"
|
SIMULATION_DISTANCE: "8"
|
||||||
ENFORCE_SECURE_PROFILE: "FALSE" # required for authlib-injector on 1.21+
|
ENFORCE_SECURE_PROFILE: "FALSE" # required for authlib-injector on 1.21+
|
||||||
ALLOW_FLIGHT: "TRUE" # many tech mods need this
|
ALLOW_FLIGHT: "TRUE" # many tech mods need this
|
||||||
|
|
||||||
# ── Mod source: packwiz pack served by the ingress ──────────
|
ENABLE_COMMAND_BLOCK: "TRUE"
|
||||||
# http + caddy alias by default; the nginx override switches this to
|
# ── Mod source: filtered subset of the distribution modset ──
|
||||||
# https + host-gateway (extra_hosts) for the production path.
|
# Jars are synced into ./server-mods by tooling/sync-server-mods.sh
|
||||||
PACKWIZ_URL: "http://pack.${BASE_DOMAIN}/pack.toml"
|
# (both/server entries from mods-sides.json) and mounted at /mods below;
|
||||||
|
# itzg auto-syncs /mods → /data/mods. REMOVE_OLD_MODS makes that mirror
|
||||||
|
# authoritative so removed mods get pruned. See plan/04-mods.md.
|
||||||
|
REMOVE_OLD_MODS: "TRUE"
|
||||||
|
|
||||||
# ── RCON for remote admin ───────────────────────────────────
|
# ── RCON for remote admin ───────────────────────────────────
|
||||||
ENABLE_RCON: "TRUE"
|
ENABLE_RCON: "TRUE"
|
||||||
@@ -84,13 +99,40 @@ services:
|
|||||||
TZ: "Europe/Madrid"
|
TZ: "Europe/Madrid"
|
||||||
volumes:
|
volumes:
|
||||||
- mc_data:/data
|
- mc_data:/data
|
||||||
- ./runtime:/extras:ro # authlib-injector.jar lives here
|
- ./runtime:/extras:ro # authlib-injector.jar lives here
|
||||||
|
# Writable (no :ro) so edits round-trip: the container can write back into
|
||||||
|
# the host dir. All of these must be writable by the server's uid (itzg runs
|
||||||
|
# as 1000) — see plan/05-minecraft.md "Writable bind mounts".
|
||||||
|
- ./server-mods:/mods # itzg auto-syncs /mods → /data/mods
|
||||||
|
# Mod configs. NOT in mc_data anymore — bind-mounted so they are editable
|
||||||
|
# from the host without root. Seed the dir from the volume BEFORE the first
|
||||||
|
# boot with this mount (tooling/export-server-config.sh), otherwise an empty
|
||||||
|
# host dir shadows the existing /data/config and every mod regenerates
|
||||||
|
# defaults.
|
||||||
|
- ./server-config:/data/config
|
||||||
|
# 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 — 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 makes BaseProperties.save() NPE
|
||||||
|
# and kubejs never initialises.
|
||||||
|
# These write back into the distribution checkout — an in-game /kubejs
|
||||||
|
# reload + edit lands in DISTRIBUTION_WEB_ROOT, i.e. in git. Intentional.
|
||||||
|
- ${DISTRIBUTION_WEB_ROOT:?DISTRIBUTION_WEB_ROOT unset in .env}/servers/ulicraft-1.21.1/files/kubejs/server_scripts:/data/kubejs/server_scripts
|
||||||
|
- ${DISTRIBUTION_WEB_ROOT}/servers/ulicraft-1.21.1/files/kubejs/startup_scripts:/data/kubejs/startup_scripts
|
||||||
|
- ${DISTRIBUTION_WEB_ROOT}/servers/ulicraft-1.21.1/files/kubejs/data:/data/kubejs/data
|
||||||
|
- ${DISTRIBUTION_WEB_ROOT}/servers/ulicraft-1.21.1/files/kubejs/assets:/data/kubejs/assets
|
||||||
|
- ${DISTRIBUTION_WEB_ROOT}/servers/ulicraft-1.21.1/files/CustomSkinLoader:/data/CustomSkinLoader:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
- drasl # ingress (caddy/nginx) is layered separately
|
- drasl # authlib (auth.) must resolve at boot
|
||||||
|
# Containers don't inherit the host's /etc/hosts; the LAN resolver returns a
|
||||||
|
# dead address for the public names. Pin auth. to the host so HTTPS to it
|
||||||
|
# lands on the host's nginx (valid LE cert) -> caddy -> drasl.
|
||||||
|
extra_hosts:
|
||||||
|
- "auth.${BASE_DOMAIN}:host-gateway"
|
||||||
networks:
|
networks:
|
||||||
- mcnet
|
- mcnet
|
||||||
|
|
||||||
# Optional: backups
|
|
||||||
mc-backup:
|
mc-backup:
|
||||||
image: itzg/mc-backup
|
image: itzg/mc-backup
|
||||||
container_name: mc-backup
|
container_name: mc-backup
|
||||||
@@ -109,9 +151,244 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- mcnet
|
- mcnet
|
||||||
|
|
||||||
|
# Internal ingress. Routes every vhost by Host header (conf.d/*.caddy):
|
||||||
|
# auth. -> drasl, apex -> landing + /launcher,
|
||||||
|
# avatar. -> nmsr, status. -> uptime-kuma, distribution. -> static site.
|
||||||
|
# Published on a localhost-only port; the host's nginx terminates TLS in
|
||||||
|
# front of it (nginx/ulicraft-caddy.conf.tmpl).
|
||||||
|
caddy:
|
||||||
|
image: caddy:alpine
|
||||||
|
container_name: caddy
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${CADDY_HTTP_BIND:-127.0.0.1}:${CADDY_HTTP_PORT:-8880}:80"
|
||||||
|
environment:
|
||||||
|
BASE_DOMAIN: ${BASE_DOMAIN}
|
||||||
|
# basic_auth credentials for monitor.${BASE_DOMAIN} (80-monitor.on.caddy).
|
||||||
|
# MONITOR_PASSWORD_HASH must be BCRYPT (`caddy hash-password`) and must be
|
||||||
|
# SINGLE-QUOTED in .env — compose interpolates $-sequences in .env values,
|
||||||
|
# so a bare bcrypt hash gets silently mangled ($2a$14$abc… -> $$2a$$14…)
|
||||||
|
# and every login fails. See plan/23-monitor.md.
|
||||||
|
MONITOR_USER: ${MONITOR_USER:-admin}
|
||||||
|
MONITOR_PASSWORD_HASH: ${MONITOR_PASSWORD_HASH:-}
|
||||||
|
volumes:
|
||||||
|
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
- ./caddy/conf.d/00-core.caddy:/etc/caddy/conf.d/00-core.caddy:ro
|
||||||
|
- ./caddy/conf.d/10-static.caddy:/etc/caddy/conf.d/10-static.caddy:ro
|
||||||
|
- ./caddy/conf.d/30-distribution.caddy:/etc/caddy/conf.d/30-distribution.caddy:ro
|
||||||
|
- ./caddy/conf.d/40-avatar.caddy:/etc/caddy/conf.d/40-avatar.caddy:ro
|
||||||
|
- ./caddy/conf.d/50-status.caddy:/etc/caddy/conf.d/50-status.caddy:ro
|
||||||
|
- ./caddy/conf.d/60-upload.caddy:/etc/caddy/conf.d/60-upload.caddy:ro
|
||||||
|
# files.${BASE_DOMAIN} read-only browse, gated by FILES_BROWSE (on|off).
|
||||||
|
# The value is substituted straight into the source path — `on` mounts the
|
||||||
|
# real vhost, `off` a comment-only stub — because compose has no
|
||||||
|
# conditionals (same trick as FILES_MOUNT_MODE). Changing it needs
|
||||||
|
# `docker compose up -d --force-recreate caddy`: a single-file bind mount
|
||||||
|
# pins the inode. The public gate is the nginx vhost (render-nginx.sh
|
||||||
|
# reads the same var); this one keeps caddy itself from serving the share.
|
||||||
|
- ./caddy/conf.d/62-files.${FILES_BROWSE:-on}.caddy:/etc/caddy/conf.d/62-files.caddy:ro
|
||||||
|
- ./caddy/conf.d/70-generators.caddy:/etc/caddy/conf.d/70-generators.caddy:ro
|
||||||
|
# monitor.${BASE_DOMAIN} admin log stream, gated by MONITOR_ENABLED
|
||||||
|
# (on|off) — same substitute-into-the-source-path trick as FILES_BROWSE
|
||||||
|
# above, and the same recreate requirement when the value changes.
|
||||||
|
- ./caddy/conf.d/80-monitor.${MONITOR_ENABLED:-on}.caddy:/etc/caddy/conf.d/80-monitor.caddy:ro
|
||||||
|
- ./www:/srv/www:ro
|
||||||
|
- ./launcher:/srv/launcher:ro
|
||||||
|
# Read-only browse of the shared upload dir at files.${BASE_DOMAIN}
|
||||||
|
# (62-files.caddy). Same host path filestash writes to via /data/files.
|
||||||
|
- ${FILES_DATA_DIR:?FILES_DATA_DIR unset in .env}:/srv/files:ro
|
||||||
|
# Static site from ANOTHER repo (absolute host path in .env).
|
||||||
|
- ${DISTRIBUTION_WEB_ROOT:?DISTRIBUTION_WEB_ROOT unset in .env}:/srv/distribution:ro
|
||||||
|
networks:
|
||||||
|
mcnet:
|
||||||
|
# Containers resolve the stack's own public names to caddy internally.
|
||||||
|
aliases:
|
||||||
|
- "auth.${BASE_DOMAIN}"
|
||||||
|
|
||||||
|
# Minecraft data pack generators at generators.${BASE_DOMAIN} — a pinned fork
|
||||||
|
# of misode.github.io, built from source. caddy proxies generators. -> :80.
|
||||||
|
# Full design + the offline rationale: plan/21-generators.md.
|
||||||
|
#
|
||||||
|
# Built from the FORK's own checkout (GENERATORS_REPO), not from ./docker/,
|
||||||
|
# because the fork is not published yet — unlike nmsr, its Dockerfile cannot
|
||||||
|
# git-clone a pinned upstream ref. The fork carries its own Dockerfile+Caddyfile.
|
||||||
|
#
|
||||||
|
# The container serves the app AND the data mirror on one origin. That is the
|
||||||
|
# entire offline strategy: this app is 100% client-side, so every fetch comes
|
||||||
|
# from the player's browser. At the party there is no internet — anything
|
||||||
|
# off-origin is simply dead, which is why caddy/conf.d/70-generators.caddy
|
||||||
|
# enforces `connect-src 'self'`.
|
||||||
|
generators:
|
||||||
|
build:
|
||||||
|
context: ${GENERATORS_REPO:?GENERATORS_REPO unset in .env (path to the ulicraft-generators checkout)}
|
||||||
|
args:
|
||||||
|
# Must match the mcmeta ref mirrored into GENERATORS_DATA_DIR and the
|
||||||
|
# MIRRORED_VERSION pin in the fork's src/app/services/Versions.ts.
|
||||||
|
LATEST_VERSION: "1.21.1"
|
||||||
|
image: ulicraft/generators:local
|
||||||
|
container_name: generators
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
# ~107M mcmeta + vanilla-mcdoc mirror from tooling/sync-generators-data.sh.
|
||||||
|
# Read-only: it is a fetched artifact, never something the app writes.
|
||||||
|
- ${GENERATORS_DATA_DIR:-./generators-data}:/srv/data:ro
|
||||||
|
networks:
|
||||||
|
- mcnet
|
||||||
|
|
||||||
|
# Avatar/skin renderer — built from source (docker/nmsr/Dockerfile), pulls
|
||||||
|
# player profiles from Drasl over mcnet. caddy proxies avatar. -> nmsr:8080.
|
||||||
|
nmsr:
|
||||||
|
build:
|
||||||
|
context: ./docker/nmsr
|
||||||
|
image: ulicraft/nmsr:local
|
||||||
|
container_name: nmsr
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./nmsr/config.toml:/nmsr/config.toml:ro
|
||||||
|
# Drasl embeds absolute HTTPS skin URLs (BaseURL is https) in the profile.
|
||||||
|
# The mcnet alias resolves auth. -> caddy (http :80 only), so NMSR's https
|
||||||
|
# skin fetch to auth.:443 finds no listener and every avatar falls back to
|
||||||
|
# the default skin. Pin auth. to the host (same as the minecraft service) so
|
||||||
|
# NMSR reaches the host nginx on :443 with the real LE cert.
|
||||||
|
extra_hosts:
|
||||||
|
- "auth.${BASE_DOMAIN}:host-gateway"
|
||||||
|
networks:
|
||||||
|
- mcnet
|
||||||
|
|
||||||
|
# Self-hosted SLP→JSON pinger (built from docker/mc-status, mcutil wrapper).
|
||||||
|
# Emits mcstatus.io v2 JSON so the landing's ServerCard reads it unchanged.
|
||||||
|
# No published port — caddy proxies apex /api/mcstatus/* -> mc-status:8080.
|
||||||
|
# Pings the minecraft service internally over mcnet; result cached 30s.
|
||||||
|
mc-status:
|
||||||
|
build:
|
||||||
|
context: ./docker/mc-status
|
||||||
|
image: ulicraft/mc-status:local
|
||||||
|
container_name: mc-status
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MC_STATUS_TARGET: "minecraft:25565"
|
||||||
|
MC_STATUS_CACHE_TTL: "30s"
|
||||||
|
# Registered-players roster (/api/mcstatus/players). mc-status logs into
|
||||||
|
# the Drasl admin API server-side and exposes a sanitized [{uuid,name}]
|
||||||
|
# list; these creds NEVER reach the browser. Use a dedicated, rotatable
|
||||||
|
# admin account. Isolated from the status pinger (own TTL/client).
|
||||||
|
DRASL_API_URL: "http://drasl:25585/drasl/api/v2"
|
||||||
|
DRASL_ADMIN_USERNAME: ${DRASL_ADMIN_USERNAME}
|
||||||
|
DRASL_ADMIN_PASSWORD: ${DRASL_ADMIN_PASSWORD}
|
||||||
|
MC_STATUS_ROSTER_TTL: "60s"
|
||||||
|
networks:
|
||||||
|
- mcnet
|
||||||
|
|
||||||
|
# Player file share (screenshots, schematics, maps) at upload.${BASE_DOMAIN}.
|
||||||
|
# ONE shared login for everybody (FILES_AUTH=htpasswd), writable. The same
|
||||||
|
# share is browsable read-only + no-auth at files.${BASE_DOMAIN} (via caddy). 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
|
||||||
|
# Runtime uid/gid of the filestash process. Default 1000:1000 = the image's
|
||||||
|
# own `filestash` user (cochi runs this — files in the share end up 1000).
|
||||||
|
# A host can override so share files are owned by a host account instead
|
||||||
|
# (oxps: FILES_UID/FILES_GID=1002 → files owned by `oier`). When overriding
|
||||||
|
# to a non-1000 uid you MUST also chown the filestash_state volume + the
|
||||||
|
# FILES_DATA_DIR share to that uid — see plan/20-files.md. group_add keeps
|
||||||
|
# the image binary (/app/filestash is 0730, group filestash) executable and
|
||||||
|
# /app/data (0770) traversable no matter which uid runs; on cochi it is the
|
||||||
|
# process's own gid, harmlessly redundant.
|
||||||
|
user: "${FILES_UID:-1000}:${FILES_GID:-1000}"
|
||||||
|
group_add:
|
||||||
|
- "1000"
|
||||||
|
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
|
||||||
|
|
||||||
|
# Admin-only log stream at monitor.${BASE_DOMAIN} (built from docker/monitor).
|
||||||
|
# Phase 1 of plan/23-monitor.md: tails the Minecraft server's latest.log into
|
||||||
|
# an in-memory ring buffer and serves it to a small embedded UI by polling.
|
||||||
|
#
|
||||||
|
# Read-only in every sense: /data is mounted :ro, the service exposes no
|
||||||
|
# mutating route, and it holds no credentials. It publishes NO port — the only
|
||||||
|
# way in is caddy's basic_auth vhost, which is exactly the point (the container
|
||||||
|
# has no auth of its own, so an accidental port publish would expose the raw
|
||||||
|
# log — chat, IPs, admin commands — to the internet). Keep it portless.
|
||||||
|
#
|
||||||
|
# Uptime Kuma can probe http://monitor:8080/healthz directly over mcnet,
|
||||||
|
# bypassing caddy, so no auth exemption is needed on the vhost.
|
||||||
|
monitor:
|
||||||
|
build:
|
||||||
|
context: ./docker/monitor
|
||||||
|
image: ulicraft/monitor:local
|
||||||
|
container_name: monitor
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MONITOR_LOG_PATH: "/data/logs/latest.log"
|
||||||
|
MONITOR_BUFFER_LINES: "5000"
|
||||||
|
# Dashboard tiles. Server status + rosters are read from mc-status over
|
||||||
|
# mcnet rather than from Drasl directly — mc-status already holds the
|
||||||
|
# admin credentials and exposes a sanitized list, so this container does
|
||||||
|
# not need a second copy of that secret.
|
||||||
|
MONITOR_MCSTATUS_URL: "http://mc-status:8080"
|
||||||
|
# Player heads. Rendered by NMSR on its own PUBLIC origin, so the browser
|
||||||
|
# fetches them cross-origin (no auth involved, avatars are public anyway).
|
||||||
|
MONITOR_AVATAR_URL: "https://avatar.${BASE_DOMAIN}"
|
||||||
|
MONITOR_AVATAR_MODE: "${AVATAR_MODE:-headiso}"
|
||||||
|
# TPS via RCON. This is the ONLY credential the monitor holds, and the
|
||||||
|
# only command it can send is the compile-time allowlist in rcon.go
|
||||||
|
# (`neoforge tps`, `list`) — no HTTP input reaches a command string, so
|
||||||
|
# the UI stays incapable of running anything. NeoForge 1.21.1 uses
|
||||||
|
# `neoforge tps`; `forge tps` does not exist here.
|
||||||
|
MONITOR_RCON_ADDR: "minecraft:25575"
|
||||||
|
MONITOR_RCON_PASSWORD: ${RCON_PASSWORD}
|
||||||
|
MONITOR_TPS_INTERVAL: "30s"
|
||||||
|
volumes:
|
||||||
|
# The whole Minecraft data dir, read-only. Only logs/latest.log is read;
|
||||||
|
# mounting /data rather than /data/logs keeps the path stable if itzg ever
|
||||||
|
# moves the log dir, and :ro means it cannot touch the world either way.
|
||||||
|
# It is also what the disk-free tile statfs's.
|
||||||
|
- mc_data:/data:ro
|
||||||
|
# Backup freshness tile. Same dir mc-backup writes to, read-only here.
|
||||||
|
- ./backups:/backups:ro
|
||||||
|
networks:
|
||||||
|
- mcnet
|
||||||
|
|
||||||
|
# Status monitoring + public status page. caddy proxies status. -> :3001.
|
||||||
|
# Joins mcnet so monitors probe the stack by internal service name.
|
||||||
|
uptime-kuma:
|
||||||
|
image: louislam/uptime-kuma:1
|
||||||
|
container_name: uptime-kuma
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- kuma_data:/app/data
|
||||||
|
networks:
|
||||||
|
- mcnet
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
drasl_state:
|
drasl_state:
|
||||||
mc_data:
|
mc_data:
|
||||||
|
kuma_data:
|
||||||
|
filestash_state:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
mcnet:
|
mcnet:
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
# Publishes the stack's service names on .local via the HOST's avahi-daemon
|
|
||||||
# (over its D-Bus socket — no second daemon, avoids the :5353 collision).
|
|
||||||
# Requires the host to run avahi-daemon and the socket to be mounted (see
|
|
||||||
# docker-compose.avahi.yml). Used when ENABLE_MDNS=true and BASE_DOMAIN=*.local.
|
|
||||||
FROM alpine:3.20
|
|
||||||
RUN apk add --no-cache avahi-tools
|
|
||||||
COPY entrypoint.sh /entrypoint.sh
|
|
||||||
RUN chmod +x /entrypoint.sh
|
|
||||||
ENTRYPOINT ["/entrypoint.sh"]
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# Publish A records for the stack's service names on .local via the HOST's
|
|
||||||
# avahi-daemon (reached over its mounted D-Bus socket). One avahi-publish per
|
|
||||||
# name; if any drops the container exits so Docker restarts it.
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
: "${BASE_DOMAIN:?BASE_DOMAIN unset}"
|
|
||||||
: "${HOST_LAN_IP:?HOST_LAN_IP unset}"
|
|
||||||
|
|
||||||
case "$BASE_DOMAIN" in
|
|
||||||
*.local) ;;
|
|
||||||
*) echo "WARN: mDNS resolves only .local — BASE_DOMAIN=$BASE_DOMAIN will NOT be discoverable via avahi. Set BASE_DOMAIN=*.local to use mDNS." >&2 ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
bus="${DBUS_SYSTEM_BUS_ADDRESS:-unix:path=/run/dbus/system_bus_socket}"
|
|
||||||
sock=${bus#unix:path=}
|
|
||||||
if [ ! -S "$sock" ]; then
|
|
||||||
echo "ERROR: host D-Bus socket '$sock' not found. Mount the host's" >&2
|
|
||||||
echo " /run/dbus/system_bus_socket and ensure avahi-daemon runs on the host." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
export DBUS_SYSTEM_BUS_ADDRESS="$bus"
|
|
||||||
|
|
||||||
pids=""
|
|
||||||
for n in "$BASE_DOMAIN" "auth.$BASE_DOMAIN" "packwiz.$BASE_DOMAIN" "mc.$BASE_DOMAIN"; do
|
|
||||||
echo "mDNS publish (host avahi): $n -> $HOST_LAN_IP"
|
|
||||||
avahi-publish -a "$n" "$HOST_LAN_IP" &
|
|
||||||
pids="$pids $!"
|
|
||||||
done
|
|
||||||
|
|
||||||
# shellcheck disable=SC2086
|
|
||||||
wait -n $pids 2>/dev/null || wait
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# mc-status — self-hosted SLP→JSON pinger (mcutil wrapper). Multi-stage:
|
||||||
|
# build a static binary, ship it on scratch.
|
||||||
|
FROM golang:1.25-alpine AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/mc-status .
|
||||||
|
|
||||||
|
FROM scratch
|
||||||
|
# CA roots so SRV/DNS over the resolver and any TLS lookups work.
|
||||||
|
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||||||
|
COPY --from=build /out/mc-status /mc-status
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["/mc-status"]
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
module ulicraft/mc-status
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require github.com/mcstatus-io/mcutil/v4 v4.0.1
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
github.com/mcstatus-io/mcutil/v4 v4.0.1 h1:/AQkHrz7irCU7USGnrH3kneQw80aDQOVdOWc8xu/NUY=
|
||||||
|
github.com/mcstatus-io/mcutil/v4 v4.0.1/go.mod h1:yC91WInI1U2GAMFWgpPgsAULPVS2o+4JCZbiiWhHwxM=
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
// mc-status — a tiny self-hosted Minecraft Server List Ping → JSON service.
|
||||||
|
//
|
||||||
|
// It wraps github.com/mcstatus-io/mcutil (the same library that powers
|
||||||
|
// api.mcstatus.io) and re-emits the result in mcstatus.io's *v2* JSON shape, so
|
||||||
|
// the landing's ServerCard.astro can talk to it with zero changes — just point
|
||||||
|
// `statusApi` at this service instead of the public API.
|
||||||
|
//
|
||||||
|
// Hardening notes:
|
||||||
|
// - The address in the request path is IGNORED. We only ever ping the single
|
||||||
|
// allow-listed MC_STATUS_TARGET. This is deliberate: a path-controlled
|
||||||
|
// pinger would be an open SSRF relay. The path segment is kept only so the
|
||||||
|
// URL stays drop-in compatible with api.mcstatus.io/v2/status/java/<addr>.
|
||||||
|
// - Results are cached in-memory for MC_STATUS_CACHE_TTL so a busy landing
|
||||||
|
// page can't hammer the Minecraft server with a ping per visitor.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mcstatus-io/mcutil/v4/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- mcstatus.io v2 response shape (only the fields ServerCard reads) ---
|
||||||
|
|
||||||
|
type v2Version struct {
|
||||||
|
NameRaw string `json:"name_raw"`
|
||||||
|
NameClean string `json:"name_clean"`
|
||||||
|
NameHTML string `json:"name_html"`
|
||||||
|
Protocol int64 `json:"protocol"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type v2Player struct {
|
||||||
|
UUID string `json:"uuid"`
|
||||||
|
NameRaw string `json:"name_raw"`
|
||||||
|
NameClean string `json:"name_clean"`
|
||||||
|
NameHTML string `json:"name_html"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type v2Players struct {
|
||||||
|
Online int64 `json:"online"`
|
||||||
|
Max int64 `json:"max"`
|
||||||
|
List []v2Player `json:"list"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type v2MOTD struct {
|
||||||
|
Raw string `json:"raw"`
|
||||||
|
Clean string `json:"clean"`
|
||||||
|
HTML string `json:"html"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type v2Response struct {
|
||||||
|
Online bool `json:"online"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port uint16 `json:"port"`
|
||||||
|
Version *v2Version `json:"version,omitempty"`
|
||||||
|
Players *v2Players `json:"players,omitempty"`
|
||||||
|
MOTD *v2MOTD `json:"motd,omitempty"`
|
||||||
|
Icon *string `json:"icon"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- tiny TTL cache (single target → single entry) ---
|
||||||
|
|
||||||
|
type cache struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
payload []byte
|
||||||
|
expires time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func deref[T any](p *T) (v T) {
|
||||||
|
if p != nil {
|
||||||
|
v = *p
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- registered-players roster (Drasl admin) ---
|
||||||
|
//
|
||||||
|
// The roster path is DELIBERATELY isolated from the status path: its own
|
||||||
|
// http.Client (with a timeout), its own TTL cache, and its own cached admin
|
||||||
|
// token. Drasl being slow or down must never block or break /v2/status — on any
|
||||||
|
// failure we serve stale-or-empty JSON rather than hanging. Mirrors the status
|
||||||
|
// cache style above (single entry, mutex-guarded).
|
||||||
|
|
||||||
|
// rosterPlayer is the sanitized shape we expose publicly: uuid + name only,
|
||||||
|
// everything else from Drasl stripped (no emails, capes, admin flags, etc.).
|
||||||
|
type rosterPlayer struct {
|
||||||
|
UUID string `json:"uuid"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// draslPlayer is the subset of Drasl's GET /players entries we read.
|
||||||
|
type draslPlayer struct {
|
||||||
|
UUID string `json:"uuid"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// roster holds the cached public payload plus the cached admin token. Both are
|
||||||
|
// guarded by the same mutex; token reuse avoids a login per request.
|
||||||
|
type roster struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
payload []byte
|
||||||
|
expires time.Time
|
||||||
|
|
||||||
|
token string
|
||||||
|
|
||||||
|
apiURL string
|
||||||
|
username string
|
||||||
|
password string
|
||||||
|
ttl time.Duration
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// payloadOrEmpty returns the cached payload, or an empty JSON array if nothing
|
||||||
|
// has ever been fetched. Used so a Drasl failure on the very first request
|
||||||
|
// still yields valid JSON ("[]") instead of an error.
|
||||||
|
func (rs *roster) payloadOrEmpty() []byte {
|
||||||
|
if rs.payload != nil {
|
||||||
|
return rs.payload
|
||||||
|
}
|
||||||
|
return []byte("[]")
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetch logs in (if no token) and lists players, re-logging in once on a 401.
|
||||||
|
// On any error it returns the previous payload (stale-or-empty), never an error
|
||||||
|
// to the caller — the handler always serves something.
|
||||||
|
func (rs *roster) fetch() []byte {
|
||||||
|
players, err := rs.listPlayers(false)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("roster: list players failed: %v", err)
|
||||||
|
return rs.payloadOrEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]rosterPlayer, 0, len(players))
|
||||||
|
for _, p := range players {
|
||||||
|
out = append(out, rosterPlayer{UUID: p.UUID, Name: p.Name})
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(out)
|
||||||
|
if err != nil {
|
||||||
|
return rs.payloadOrEmpty()
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// listPlayers performs GET {apiURL}/players with the cached token, logging in
|
||||||
|
// first if needed. On 401 it clears the token and retries once (retried=true
|
||||||
|
// prevents a loop). Caller holds rs.mu.
|
||||||
|
func (rs *roster) listPlayers(retried bool) ([]draslPlayer, error) {
|
||||||
|
if rs.token == "" {
|
||||||
|
if err := rs.login(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodGet, rs.apiURL+"/players", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+rs.token)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := rs.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusUnauthorized && !retried {
|
||||||
|
// Token expired/invalid — drop it and re-login once.
|
||||||
|
rs.token = ""
|
||||||
|
io.Copy(io.Discard, resp.Body)
|
||||||
|
return rs.listPlayers(true)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
io.Copy(io.Discard, resp.Body)
|
||||||
|
return nil, &httpError{status: resp.StatusCode, op: "list players"}
|
||||||
|
}
|
||||||
|
|
||||||
|
var players []draslPlayer
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&players); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return players, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// login posts admin creds to {apiURL}/login and caches the returned apiToken.
|
||||||
|
// Caller holds rs.mu.
|
||||||
|
func (rs *roster) login() error {
|
||||||
|
body, err := json.Marshal(map[string]string{
|
||||||
|
"username": rs.username,
|
||||||
|
"password": rs.password,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodPost, rs.apiURL+"/login", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := rs.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
io.Copy(io.Discard, resp.Body)
|
||||||
|
return &httpError{status: resp.StatusCode, op: "login"}
|
||||||
|
}
|
||||||
|
|
||||||
|
var out struct {
|
||||||
|
APIToken string `json:"apiToken"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if out.APIToken == "" {
|
||||||
|
return &httpError{status: resp.StatusCode, op: "login (no apiToken)"}
|
||||||
|
}
|
||||||
|
rs.token = out.APIToken
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type httpError struct {
|
||||||
|
status int
|
||||||
|
op string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *httpError) Error() string {
|
||||||
|
return "drasl " + e.op + ": status " + strconv.Itoa(e.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
target := envOr("MC_STATUS_TARGET", "minecraft:25565")
|
||||||
|
listen := envOr("MC_STATUS_LISTEN", ":8080")
|
||||||
|
ttl, err := time.ParseDuration(envOr("MC_STATUS_CACHE_TTL", "30s"))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("invalid MC_STATUS_CACHE_TTL: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rosterTTL, err := time.ParseDuration(envOr("MC_STATUS_ROSTER_TTL", "60s"))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("invalid MC_STATUS_ROSTER_TTL: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
host, port := splitHostPort(target)
|
||||||
|
c := &cache{}
|
||||||
|
|
||||||
|
// Registered-players roster, fed by the Drasl admin API. Isolated from the
|
||||||
|
// status pinger above (own client + timeout + cache). If the admin creds are
|
||||||
|
// unset, /players still works — it just always serves "[]".
|
||||||
|
rs := &roster{
|
||||||
|
apiURL: strings.TrimRight(envOr("DRASL_API_URL", "http://drasl:25585/drasl/api/v2"), "/"),
|
||||||
|
username: os.Getenv("DRASL_ADMIN_USERNAME"),
|
||||||
|
password: os.Getenv("DRASL_ADMIN_PASSWORD"),
|
||||||
|
ttl: rosterTTL,
|
||||||
|
client: &http.Client{Timeout: 5 * time.Second},
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
// Drop-in path: /v2/status/java/<addr> — <addr> is ignored (see file header).
|
||||||
|
mux.HandleFunc("/v2/status/java/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
fresh := c.payload != nil && time.Now().Before(c.expires)
|
||||||
|
if fresh {
|
||||||
|
payload := c.payload
|
||||||
|
c.mu.Unlock()
|
||||||
|
w.Write(payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
payload := buildPayload(host, port)
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
c.payload = payload
|
||||||
|
c.expires = time.Now().Add(ttl)
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
w.Write(payload)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Registered-players roster — public path /api/mcstatus/players (caddy
|
||||||
|
// strips the /api/mcstatus prefix → internal /players). Serves a sanitized
|
||||||
|
// [{uuid,name}] of all Drasl players. Same CORS:* as the status route.
|
||||||
|
// Isolated from the status path: own TTL cache, own token, own client; on
|
||||||
|
// any Drasl failure it serves stale-or-empty rather than hanging.
|
||||||
|
mux.HandleFunc("/players", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
|
||||||
|
rs.mu.Lock()
|
||||||
|
defer rs.mu.Unlock()
|
||||||
|
|
||||||
|
if rs.payload != nil && time.Now().Before(rs.expires) {
|
||||||
|
w.Write(rs.payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := rs.fetch()
|
||||||
|
rs.payload = payload
|
||||||
|
rs.expires = time.Now().Add(rs.ttl)
|
||||||
|
w.Write(payload)
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("ok"))
|
||||||
|
})
|
||||||
|
|
||||||
|
log.Printf("mc-status listening on %s, target=%s:%d, ttl=%s, roster-ttl=%s", listen, host, port, ttl, rosterTTL)
|
||||||
|
log.Fatal(http.ListenAndServe(listen, mux))
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildPayload pings the target and marshals the v2 response. On any ping
|
||||||
|
// failure it returns a minimal {"online":false} so the frontend can show an
|
||||||
|
// offline state without erroring.
|
||||||
|
func buildPayload(host string, port uint16) []byte {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
resp, err := status.Modern(ctx, host, port)
|
||||||
|
if err != nil {
|
||||||
|
b, _ := json.Marshal(v2Response{Online: false, Host: host, Port: port})
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
out := v2Response{
|
||||||
|
Online: true,
|
||||||
|
Host: host,
|
||||||
|
Port: port,
|
||||||
|
Icon: resp.Favicon,
|
||||||
|
Version: &v2Version{
|
||||||
|
NameRaw: resp.Version.Name.Raw,
|
||||||
|
NameClean: resp.Version.Name.Clean,
|
||||||
|
NameHTML: resp.Version.Name.HTML,
|
||||||
|
Protocol: resp.Version.Protocol,
|
||||||
|
},
|
||||||
|
MOTD: &v2MOTD{
|
||||||
|
Raw: resp.MOTD.Raw,
|
||||||
|
Clean: resp.MOTD.Clean,
|
||||||
|
HTML: resp.MOTD.HTML,
|
||||||
|
},
|
||||||
|
Players: &v2Players{
|
||||||
|
Online: deref(resp.Players.Online),
|
||||||
|
Max: deref(resp.Players.Max),
|
||||||
|
List: make([]v2Player, 0, len(resp.Players.Sample)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range resp.Players.Sample {
|
||||||
|
out.Players.List = append(out.Players.List, v2Player{
|
||||||
|
UUID: p.ID,
|
||||||
|
NameRaw: p.Name.Raw,
|
||||||
|
NameClean: p.Name.Clean,
|
||||||
|
NameHTML: p.Name.HTML,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
b, _ := json.Marshal(out)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOr(key, def string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitHostPort(target string) (string, uint16) {
|
||||||
|
host, portStr, found := strings.Cut(target, ":")
|
||||||
|
if !found {
|
||||||
|
return target, 25565
|
||||||
|
}
|
||||||
|
p, err := strconv.ParseUint(portStr, 10, 16)
|
||||||
|
if err != nil {
|
||||||
|
return host, 25565
|
||||||
|
}
|
||||||
|
return host, uint16(p)
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# monitor — admin-only log stream + dashboard. Multi-stage: build a static
|
||||||
|
# binary (UI is //go:embed-ed into it), ship it on scratch.
|
||||||
|
#
|
||||||
|
# No CA roots needed: phase 1 talks to nothing over the network — it only reads
|
||||||
|
# a file. Add them here if a later phase makes outbound TLS calls.
|
||||||
|
FROM golang:1.25-alpine AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod ./
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/monitor .
|
||||||
|
|
||||||
|
FROM scratch
|
||||||
|
COPY --from=build /out/monitor /monitor
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["/monitor"]
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
// Dashboard collectors (phase 2 + 3 of plan/23-monitor.md).
|
||||||
|
//
|
||||||
|
// Every tile is fed by its own independently-cached collector. That isolation
|
||||||
|
// is the whole design: mc-status being slow, the backups dir being unreadable,
|
||||||
|
// or RCON refusing a connection must degrade exactly one tile and never block
|
||||||
|
// the summary response or the log stream. On error a collector keeps serving
|
||||||
|
// its last good value and reports the error alongside it — stale-with-a-reason
|
||||||
|
// beats a blank page, and beats a spinner that never resolves.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- TTL cache -------------------------------------------------------------
|
||||||
|
|
||||||
|
// cached memoizes one collector for ttl. Zero value is not usable; see newCached.
|
||||||
|
type cached[T any] struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
val T
|
||||||
|
have bool
|
||||||
|
lastErr string
|
||||||
|
expires time.Time
|
||||||
|
|
||||||
|
ttl time.Duration
|
||||||
|
fn func() (T, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCached[T any](ttl time.Duration, fn func() (T, error)) *cached[T] {
|
||||||
|
return &cached[T]{ttl: ttl, fn: fn}
|
||||||
|
}
|
||||||
|
|
||||||
|
// get returns the cached value (refreshing if stale) and the last error, if any.
|
||||||
|
// It never returns an error *instead* of a value — a failed refresh yields the
|
||||||
|
// previous value plus the error text.
|
||||||
|
func (c *cached[T]) get() (T, string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
if c.have && time.Now().Before(c.expires) {
|
||||||
|
return c.val, c.lastErr
|
||||||
|
}
|
||||||
|
v, err := c.fn()
|
||||||
|
c.expires = time.Now().Add(c.ttl)
|
||||||
|
if err != nil {
|
||||||
|
c.lastErr = err.Error()
|
||||||
|
return c.val, c.lastErr // stale-or-zero, with the reason
|
||||||
|
}
|
||||||
|
c.val, c.have, c.lastErr = v, true, ""
|
||||||
|
return c.val, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- server status + rosters (via mc-status over mcnet) --------------------
|
||||||
|
|
||||||
|
// The monitor deliberately does NOT hold Drasl admin credentials. mc-status
|
||||||
|
// already logs in server-side and exposes a sanitized [{uuid,name}], so we read
|
||||||
|
// its output instead of duplicating the secret. See plan/15-mc-status.md.
|
||||||
|
|
||||||
|
type mcPlayer struct {
|
||||||
|
UUID string `json:"uuid"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type statusInfo struct {
|
||||||
|
Online bool `json:"online"`
|
||||||
|
Count int64 `json:"count"`
|
||||||
|
Max int64 `json:"max"`
|
||||||
|
MOTD string `json:"motd"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Players []mcPlayer `json:"players"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// v2 subset of the mcstatus.io shape mc-status re-emits.
|
||||||
|
type v2Wire struct {
|
||||||
|
Online bool `json:"online"`
|
||||||
|
Players struct {
|
||||||
|
Online int64 `json:"online"`
|
||||||
|
Max int64 `json:"max"`
|
||||||
|
List []struct {
|
||||||
|
UUID string `json:"uuid"`
|
||||||
|
NameClean string `json:"name_clean"`
|
||||||
|
} `json:"list"`
|
||||||
|
} `json:"players"`
|
||||||
|
MOTD struct {
|
||||||
|
Clean string `json:"clean"`
|
||||||
|
} `json:"motd"`
|
||||||
|
Version struct {
|
||||||
|
NameClean string `json:"name_clean"`
|
||||||
|
} `json:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchStatus(client *http.Client, base string) (statusInfo, error) {
|
||||||
|
var wire v2Wire
|
||||||
|
// The address segment is ignored by mc-status (it only ever pings its own
|
||||||
|
// configured target) — any placeholder works.
|
||||||
|
if err := getJSON(client, base+"/v2/status/java/self", &wire); err != nil {
|
||||||
|
return statusInfo{}, err
|
||||||
|
}
|
||||||
|
out := statusInfo{
|
||||||
|
Online: wire.Online,
|
||||||
|
Count: wire.Players.Online,
|
||||||
|
Max: wire.Players.Max,
|
||||||
|
MOTD: wire.MOTD.Clean,
|
||||||
|
Version: wire.Version.NameClean,
|
||||||
|
}
|
||||||
|
for _, p := range wire.Players.List {
|
||||||
|
out.Players = append(out.Players, mcPlayer{UUID: p.UUID, Name: p.NameClean})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchRegistered(client *http.Client, base string) ([]mcPlayer, error) {
|
||||||
|
var players []mcPlayer
|
||||||
|
if err := getJSON(client, base+"/players", &players); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return players, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getJSON(client *http.Client, url string, dst any) error {
|
||||||
|
resp, err := client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return &statusCodeError{url: url, code: resp.StatusCode}
|
||||||
|
}
|
||||||
|
return json.NewDecoder(resp.Body).Decode(dst)
|
||||||
|
}
|
||||||
|
|
||||||
|
type statusCodeError struct {
|
||||||
|
url string
|
||||||
|
code int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *statusCodeError) Error() string {
|
||||||
|
return e.url + ": HTTP " + itoa(e.code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func itoa(n int) string {
|
||||||
|
if n == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
var b [12]byte
|
||||||
|
i := len(b)
|
||||||
|
for n > 0 {
|
||||||
|
i--
|
||||||
|
b[i] = byte('0' + n%10)
|
||||||
|
n /= 10
|
||||||
|
}
|
||||||
|
return string(b[i:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- disk ------------------------------------------------------------------
|
||||||
|
|
||||||
|
type diskInfo struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
TotalBytes uint64 `json:"total_bytes"`
|
||||||
|
FreeBytes uint64 `json:"free_bytes"`
|
||||||
|
UsedPct float64 `json:"used_pct"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// diskUsage statfs's the world volume. This reports the underlying host
|
||||||
|
// filesystem, which is exactly what matters: a full disk is the failure that
|
||||||
|
// actually takes the server down, and backups (15G+ and growing) share it.
|
||||||
|
func diskUsage(path string) (diskInfo, error) {
|
||||||
|
var st syscall.Statfs_t
|
||||||
|
if err := syscall.Statfs(path, &st); err != nil {
|
||||||
|
return diskInfo{Path: path}, err
|
||||||
|
}
|
||||||
|
bs := uint64(st.Bsize)
|
||||||
|
total := st.Blocks * bs
|
||||||
|
free := st.Bavail * bs // Bavail, not Bfree — exclude root-reserved blocks
|
||||||
|
info := diskInfo{Path: path, TotalBytes: total, FreeBytes: free}
|
||||||
|
if total > 0 {
|
||||||
|
info.UsedPct = float64(total-free) / float64(total) * 100
|
||||||
|
}
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- backups ---------------------------------------------------------------
|
||||||
|
|
||||||
|
type backupInfo struct {
|
||||||
|
Newest string `json:"newest"`
|
||||||
|
SizeBytes int64 `json:"size_bytes"`
|
||||||
|
Mtime int64 `json:"mtime"`
|
||||||
|
AgeSeconds int64 `json:"age_seconds"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
TotalBytes int64 `json:"total_bytes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// backupStatus finds the newest archive written by mc-backup (world-*.tar.gz).
|
||||||
|
// Age is the number that matters — "backups exist" is not the same claim as
|
||||||
|
// "a backup ran recently", and only the second one is worth a tile.
|
||||||
|
func backupStatus(dir string) (backupInfo, error) {
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return backupInfo{}, err
|
||||||
|
}
|
||||||
|
var out backupInfo
|
||||||
|
var newest time.Time
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".tar.gz") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fi, err := e.Info()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out.Count++
|
||||||
|
out.TotalBytes += fi.Size()
|
||||||
|
if fi.ModTime().After(newest) {
|
||||||
|
newest = fi.ModTime()
|
||||||
|
out.Newest = e.Name()
|
||||||
|
out.SizeBytes = fi.Size()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out.Count > 0 {
|
||||||
|
out.Mtime = newest.Unix()
|
||||||
|
out.AgeSeconds = int64(time.Since(newest).Seconds())
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- session history (derived from the log we already tail) ----------------
|
||||||
|
|
||||||
|
type sessionEvent struct {
|
||||||
|
Time string `json:"time"`
|
||||||
|
Player string `json:"player"`
|
||||||
|
Action string `json:"action"` // "joined" | "left"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Matches the message tail of e.g.
|
||||||
|
//
|
||||||
|
// [01Aug2026 22:55:34.358] [Server thread/INFO] [net.minecraft...]: Oier joined the game
|
||||||
|
var sessionRe = regexp.MustCompile(`]: (\S+) (joined|left) the game\b`)
|
||||||
|
|
||||||
|
// leading [timestamp] of a log line, whatever its format
|
||||||
|
var stampRe = regexp.MustCompile(`^\[([^\]]+)\]`)
|
||||||
|
|
||||||
|
// events is a bounded history of joins/leaves, filled by the tailer as lines
|
||||||
|
// arrive. Free: the bytes are already being read for the log stream. It also
|
||||||
|
// means the cold-start backfill populates history without any persistence.
|
||||||
|
type events struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
buf []sessionEvent
|
||||||
|
max int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newEvents(max int) *events { return &events{max: max} }
|
||||||
|
|
||||||
|
func (e *events) observe(line string) {
|
||||||
|
m := sessionRe.FindStringSubmatch(line)
|
||||||
|
if m == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ev := sessionEvent{Player: m[1], Action: m[2]}
|
||||||
|
if ts := stampRe.FindStringSubmatch(line); ts != nil {
|
||||||
|
ev.Time = ts[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
e.buf = append(e.buf, ev)
|
||||||
|
if len(e.buf) > e.max {
|
||||||
|
e.buf = e.buf[len(e.buf)-e.max:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// recent returns up to n events, newest first.
|
||||||
|
func (e *events) recent(n int) []sessionEvent {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
out := make([]sessionEvent, 0, n)
|
||||||
|
for i := len(e.buf) - 1; i >= 0 && len(out) < n; i-- {
|
||||||
|
out = append(out, e.buf[i])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- assembled summary -----------------------------------------------------
|
||||||
|
|
||||||
|
type summary struct {
|
||||||
|
Server statusInfo `json:"server"`
|
||||||
|
ServerErr string `json:"server_err,omitempty"`
|
||||||
|
Registered int `json:"registered"`
|
||||||
|
RosterErr string `json:"roster_err,omitempty"`
|
||||||
|
Disk diskInfo `json:"disk"`
|
||||||
|
DiskErr string `json:"disk_err,omitempty"`
|
||||||
|
Backup backupInfo `json:"backup"`
|
||||||
|
BackupErr string `json:"backup_err,omitempty"`
|
||||||
|
TPS *tpsInfo `json:"tps"`
|
||||||
|
TPSErr string `json:"tps_err,omitempty"`
|
||||||
|
Sessions []sessionEvent `json:"sessions"`
|
||||||
|
AvatarURL string `json:"avatar_url"`
|
||||||
|
AvatarMode string `json:"avatar_mode"`
|
||||||
|
Now int64 `json:"now"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectors owns every tile source. Assembled once in main and shared.
|
||||||
|
type collectors struct {
|
||||||
|
status *cached[statusInfo]
|
||||||
|
registered *cached[[]mcPlayer]
|
||||||
|
disk *cached[diskInfo]
|
||||||
|
backup *cached[backupInfo]
|
||||||
|
tps *cached[*tpsInfo]
|
||||||
|
events *events
|
||||||
|
|
||||||
|
avatarURL string
|
||||||
|
avatarMode string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *collectors) summary() summary {
|
||||||
|
// Refresh concurrently. Sequentially, a summary during an outage would cost
|
||||||
|
// the SUM of every timeout (~15s with mc-status hanging and RCON blackholed)
|
||||||
|
// — and an outage is exactly when someone is staring at this page. Each
|
||||||
|
// cached has its own mutex, so this is just fan-out.
|
||||||
|
var (
|
||||||
|
wg sync.WaitGroup
|
||||||
|
status statusInfo
|
||||||
|
statusErr string
|
||||||
|
registered []mcPlayer
|
||||||
|
rosterErr string
|
||||||
|
disk diskInfo
|
||||||
|
diskErr string
|
||||||
|
backup backupInfo
|
||||||
|
backupErr string
|
||||||
|
tps *tpsInfo
|
||||||
|
tpsErr string
|
||||||
|
)
|
||||||
|
wg.Add(5)
|
||||||
|
go func() { defer wg.Done(); status, statusErr = c.status.get() }()
|
||||||
|
go func() { defer wg.Done(); registered, rosterErr = c.registered.get() }()
|
||||||
|
go func() { defer wg.Done(); disk, diskErr = c.disk.get() }()
|
||||||
|
go func() { defer wg.Done(); backup, backupErr = c.backup.get() }()
|
||||||
|
go func() { defer wg.Done(); tps, tpsErr = c.tps.get() }()
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
return summary{
|
||||||
|
Server: status,
|
||||||
|
ServerErr: statusErr,
|
||||||
|
Registered: len(registered),
|
||||||
|
RosterErr: rosterErr,
|
||||||
|
Disk: disk,
|
||||||
|
DiskErr: diskErr,
|
||||||
|
Backup: backup,
|
||||||
|
BackupErr: backupErr,
|
||||||
|
TPS: tps,
|
||||||
|
TPSErr: tpsErr,
|
||||||
|
Sessions: c.events.recent(40),
|
||||||
|
AvatarURL: c.avatarURL,
|
||||||
|
AvatarMode: c.avatarMode,
|
||||||
|
Now: time.Now().Unix(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
module ulicraft/monitor
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
@@ -0,0 +1,501 @@
|
|||||||
|
// monitor — admin-only, read-only view of the Minecraft server's latest.log.
|
||||||
|
//
|
||||||
|
// See plan/23-monitor.md. A tailer follows /data/logs/latest.log into an
|
||||||
|
// in-memory ring buffer (/api/log), a set of independently-cached collectors
|
||||||
|
// feed the dashboard tiles (/api/summary, collect.go), and both are served to
|
||||||
|
// an embedded single-page UI by polling.
|
||||||
|
//
|
||||||
|
// The service is read-only in every sense. /data and /backups are mounted :ro,
|
||||||
|
// no route mutates anything, and the single credential it holds — the RCON
|
||||||
|
// password — can only send commands from a compile-time allowlist that no HTTP
|
||||||
|
// input can reach (rcon.go).
|
||||||
|
//
|
||||||
|
// Design notes that are NOT obvious from the code:
|
||||||
|
//
|
||||||
|
// - The cursor is a SEQUENCE NUMBER, not a byte offset. itzg rotates
|
||||||
|
// latest.log -> YYYY-MM-DD-N.log.gz on every server restart (i.e. on every
|
||||||
|
// deploy), which makes byte offsets wrong routinely rather than rarely.
|
||||||
|
//
|
||||||
|
// - The tailer reopens on inode change OR size shrink. Both happen on every
|
||||||
|
// deploy. A tailer that merely follows an already-open fd goes permanently
|
||||||
|
// silent after the first restart while still answering HTTP 200 — the
|
||||||
|
// "monitor says everything is fine" failure. This is the single most likely
|
||||||
|
// way this service breaks, so it is checked every tick.
|
||||||
|
//
|
||||||
|
// - Transport is POLLING, not SSE. The request chain is host nginx -> caddy ->
|
||||||
|
// here, and nginx buffers proxied responses by default; an EventSource would
|
||||||
|
// stall on prod (where the vhost is an untracked per-host file) and not
|
||||||
|
// reproduce locally. Delay is acceptable by design, so polling is strictly
|
||||||
|
// better here.
|
||||||
|
//
|
||||||
|
// - No persistence. On boot we prime the ring from the tail of the file on
|
||||||
|
// disk, so a restart costs nothing that isn't still there.
|
||||||
|
//
|
||||||
|
// This service publishes no port (mcnet only) and is reached exclusively through
|
||||||
|
// caddy's basic_auth vhost.
|
||||||
|
//
|
||||||
|
// It runs as root inside the container ON PURPOSE: /data is drwxr-x--- owned by
|
||||||
|
// uid 1000 (itzg), so a non-root user cannot even traverse it and the tailer
|
||||||
|
// would silently read nothing. Every mount is :ro, which is what actually bounds
|
||||||
|
// the damage here.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed ui/index.html
|
||||||
|
var indexHTML []byte
|
||||||
|
|
||||||
|
// --- ring buffer -----------------------------------------------------------
|
||||||
|
|
||||||
|
// ring is a fixed-capacity circular buffer of log lines with monotonic sequence
|
||||||
|
// numbers. Sequence numbers never restart while the process lives, so a client
|
||||||
|
// cursor stays valid across log rotations.
|
||||||
|
type ring struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
buf []string
|
||||||
|
head int // index of the oldest line
|
||||||
|
n int // lines currently held
|
||||||
|
next int64 // sequence number the next appended line will get
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRing(capacity int) *ring {
|
||||||
|
if capacity < 1 {
|
||||||
|
capacity = 1
|
||||||
|
}
|
||||||
|
return &ring{buf: make([]string, capacity)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ring) append(s string) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if r.n == len(r.buf) {
|
||||||
|
// Full: overwrite the oldest and advance head. O(1) — deliberately not a
|
||||||
|
// slice shuffle, because boot spam appends thousands of lines a second.
|
||||||
|
r.buf[r.head] = s
|
||||||
|
r.head = (r.head + 1) % len(r.buf)
|
||||||
|
} else {
|
||||||
|
r.buf[(r.head+r.n)%len(r.buf)] = s
|
||||||
|
r.n++
|
||||||
|
}
|
||||||
|
r.next++
|
||||||
|
}
|
||||||
|
|
||||||
|
// snapshot returns every buffered line plus the cursor to resume from.
|
||||||
|
func (r *ring) snapshot() ([]string, int64) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
return r.collect(r.next - int64(r.n)), r.next
|
||||||
|
}
|
||||||
|
|
||||||
|
// since returns the lines after `seq`.
|
||||||
|
//
|
||||||
|
// dropped > 0 means the caller fell behind the buffer and lines were lost;
|
||||||
|
// reset means the caller must discard what it has and take these lines as the
|
||||||
|
// new truth (it fell behind, or the server restarted and its cursor is now in
|
||||||
|
// the future). Without this the UI would lie by omission after any burst.
|
||||||
|
func (r *ring) since(seq int64) (lines []string, next int64, dropped int64, reset bool) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
first := r.next - int64(r.n)
|
||||||
|
switch {
|
||||||
|
case seq > r.next:
|
||||||
|
// Cursor from a previous process lifetime — sequence numbering restarted.
|
||||||
|
seq, reset = first, true
|
||||||
|
case seq < first:
|
||||||
|
dropped, seq, reset = first-seq, first, true
|
||||||
|
}
|
||||||
|
return r.collect(seq), r.next, dropped, reset
|
||||||
|
}
|
||||||
|
|
||||||
|
// collect copies lines from `seq` onward. Caller holds r.mu.
|
||||||
|
func (r *ring) collect(seq int64) []string {
|
||||||
|
first := r.next - int64(r.n)
|
||||||
|
start := int(seq - first)
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
out := make([]string, 0, r.n-start)
|
||||||
|
for i := start; i < r.n; i++ {
|
||||||
|
out = append(out, r.buf[(r.head+i)%len(r.buf)])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ring) count() int {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
return r.n
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- tailer ----------------------------------------------------------------
|
||||||
|
|
||||||
|
// tailState is what the UI shows about the file itself. Mtime is the useful
|
||||||
|
// one: it lets the page distinguish "the server is quiet" from "the tailer is
|
||||||
|
// stuck", which HTTP status alone can never do.
|
||||||
|
type tailState struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Mtime int64 `json:"mtime"`
|
||||||
|
Rotations int64 `json:"rotations"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type tailer struct {
|
||||||
|
path string
|
||||||
|
backfill int
|
||||||
|
r *ring
|
||||||
|
ev *events // session history, derived from the same bytes (may be nil)
|
||||||
|
|
||||||
|
f *os.File
|
||||||
|
pos int64 // byte offset of the next unread COMPLETE line
|
||||||
|
dev uint64
|
||||||
|
ino uint64
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
state tailState
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tailer) snapshotState() tailState {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
return t.state
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tailer) setErr(err error) {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
t.state.OK = false
|
||||||
|
if err != nil {
|
||||||
|
t.state.Error = err.Error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tailer) run(interval time.Duration) {
|
||||||
|
for {
|
||||||
|
t.tick()
|
||||||
|
time.Sleep(interval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tailer) tick() {
|
||||||
|
fi, err := os.Stat(t.path)
|
||||||
|
if err != nil {
|
||||||
|
// Missing file is normal before the server's first boot, and during the
|
||||||
|
// window where itzg has rotated but not yet recreated latest.log.
|
||||||
|
t.closeFile()
|
||||||
|
t.setErr(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dev, ino, ok := statKey(fi)
|
||||||
|
if !ok {
|
||||||
|
t.setErr(errUnsupportedStat)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case t.f == nil:
|
||||||
|
// First open of this process: seed the ring from the tail of the file so
|
||||||
|
// the page has history immediately. Subsequent opens start at 0.
|
||||||
|
if err := t.open(fi, t.backfill > 0); err != nil {
|
||||||
|
t.setErr(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case dev != t.dev || ino != t.ino:
|
||||||
|
// Rotated: latest.log is a different file now. Read the new one whole.
|
||||||
|
log.Printf("tail: %s rotated (ino %d -> %d), reopening", t.path, t.ino, ino)
|
||||||
|
t.closeFile()
|
||||||
|
if err := t.open(fi, false); err != nil {
|
||||||
|
t.setErr(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.mu.Lock()
|
||||||
|
t.state.Rotations++
|
||||||
|
t.mu.Unlock()
|
||||||
|
case fi.Size() < t.pos:
|
||||||
|
// Truncated in place (same inode, smaller). Re-read from the start.
|
||||||
|
log.Printf("tail: %s truncated (%d -> %d), rewinding", t.path, t.pos, fi.Size())
|
||||||
|
t.pos = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
t.drain(fi.Size())
|
||||||
|
|
||||||
|
t.mu.Lock()
|
||||||
|
t.state.OK = true
|
||||||
|
t.state.Error = ""
|
||||||
|
t.state.Size = fi.Size()
|
||||||
|
t.state.Mtime = fi.ModTime().Unix()
|
||||||
|
t.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tailer) open(fi os.FileInfo, backfill bool) error {
|
||||||
|
f, err := os.Open(t.path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
t.f = f
|
||||||
|
t.dev, t.ino, _ = statKey(fi)
|
||||||
|
t.pos = 0
|
||||||
|
if backfill {
|
||||||
|
t.pos = backfillOffset(f, fi.Size(), t.backfill)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tailer) closeFile() {
|
||||||
|
if t.f != nil {
|
||||||
|
t.f.Close()
|
||||||
|
t.f = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// drain reads every COMPLETE line between pos and size into the ring.
|
||||||
|
//
|
||||||
|
// A trailing partial line (the writer is mid-write) does not advance pos, so it
|
||||||
|
// is re-read on the next tick once its newline lands. That keeps the "never emit
|
||||||
|
// half a line" guarantee without any partial-line state to get wrong.
|
||||||
|
func (t *tailer) drain(size int64) {
|
||||||
|
if t.f == nil || size <= t.pos {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := t.f.Seek(t.pos, io.SeekStart); err != nil {
|
||||||
|
t.setErr(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rd := bufio.NewReaderSize(t.f, 64<<10)
|
||||||
|
for {
|
||||||
|
line, err := rd.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
// io.EOF with a partial line: leave pos before it and retry next tick.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.pos += int64(len(line))
|
||||||
|
clean := trimEOL(line)
|
||||||
|
t.r.append(clean)
|
||||||
|
if t.ev != nil {
|
||||||
|
t.ev.observe(clean)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// backfillOffset returns the byte offset of the start of the last `want` lines,
|
||||||
|
// scanning at most maxScan bytes back from the end. Bounded on purpose: a
|
||||||
|
// 164-mod server's latest.log is mostly boot spam and can be large, and we only
|
||||||
|
// ever want the tail of it.
|
||||||
|
func backfillOffset(f *os.File, size int64, want int) int64 {
|
||||||
|
const maxScan = 8 << 20
|
||||||
|
|
||||||
|
start := size - maxScan
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
buf := make([]byte, size-start)
|
||||||
|
if _, err := f.ReadAt(buf, start); err != nil && err != io.EOF {
|
||||||
|
return size
|
||||||
|
}
|
||||||
|
|
||||||
|
count := 0
|
||||||
|
for i := len(buf) - 1; i >= 0; i-- {
|
||||||
|
if buf[i] != '\n' || i == len(buf)-1 {
|
||||||
|
continue // the final newline terminates the last line, it starts nothing
|
||||||
|
}
|
||||||
|
if count++; count >= want {
|
||||||
|
return start + int64(i) + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if start == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
// Fewer than `want` lines in the window — begin at the first COMPLETE line so
|
||||||
|
// we never emit the tail half of a line we sliced through.
|
||||||
|
if idx := bytes.IndexByte(buf, '\n'); idx >= 0 {
|
||||||
|
return start + int64(idx) + 1
|
||||||
|
}
|
||||||
|
return size
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimEOL(s string) string {
|
||||||
|
for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == '\r') {
|
||||||
|
s = s[:len(s)-1]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
type stringError string
|
||||||
|
|
||||||
|
func (e stringError) Error() string { return string(e) }
|
||||||
|
|
||||||
|
const errUnsupportedStat = stringError("stat: unsupported platform (no syscall.Stat_t)")
|
||||||
|
|
||||||
|
func statKey(fi os.FileInfo) (dev, ino uint64, ok bool) {
|
||||||
|
st, ok := fi.Sys().(*syscall.Stat_t)
|
||||||
|
if !ok {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
return uint64(st.Dev), uint64(st.Ino), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- http ------------------------------------------------------------------
|
||||||
|
|
||||||
|
type logResponse struct {
|
||||||
|
Lines []string `json:"lines"`
|
||||||
|
Seq int64 `json:"seq"`
|
||||||
|
Dropped int64 `json:"dropped"`
|
||||||
|
Reset bool `json:"reset"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Tail tailState `json:"tail"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
listen := envOr("MONITOR_LISTEN", ":8080")
|
||||||
|
path := envOr("MONITOR_LOG_PATH", "/data/logs/latest.log")
|
||||||
|
|
||||||
|
bufLines, err := strconv.Atoi(envOr("MONITOR_BUFFER_LINES", "5000"))
|
||||||
|
if err != nil || bufLines < 1 {
|
||||||
|
log.Fatalf("invalid MONITOR_BUFFER_LINES: %q", os.Getenv("MONITOR_BUFFER_LINES"))
|
||||||
|
}
|
||||||
|
interval, err := time.ParseDuration(envOr("MONITOR_TAIL_INTERVAL", "500ms"))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("invalid MONITOR_TAIL_INTERVAL: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := newRing(bufLines)
|
||||||
|
ev := newEvents(500)
|
||||||
|
t := &tailer{path: path, backfill: bufLines, r: r, ev: ev, state: tailState{Path: path}}
|
||||||
|
go t.run(interval)
|
||||||
|
|
||||||
|
col := buildCollectors(ev)
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
// GET /api/log[?since=<seq>] — omit `since` for a full buffer without a
|
||||||
|
// spurious "dropped" notice on first load.
|
||||||
|
mux.HandleFunc("/api/log", func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
var resp logResponse
|
||||||
|
if raw := req.URL.Query().Get("since"); raw != "" {
|
||||||
|
seq, err := strconv.ParseInt(raw, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "bad since", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp.Lines, resp.Seq, resp.Dropped, resp.Reset = r.since(seq)
|
||||||
|
} else {
|
||||||
|
resp.Lines, resp.Seq = r.snapshot()
|
||||||
|
}
|
||||||
|
resp.Total = r.count()
|
||||||
|
resp.Tail = t.snapshotState()
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
})
|
||||||
|
|
||||||
|
// GET /api/summary — every dashboard tile in one response. Each collector is
|
||||||
|
// independently cached and degrades on its own; a failure shows up as a
|
||||||
|
// *_err field next to a stale value rather than failing the request.
|
||||||
|
mux.HandleFunc("/api/summary", func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
json.NewEncoder(w).Encode(col.summary())
|
||||||
|
})
|
||||||
|
|
||||||
|
// Liveness only. Deliberately NOT tied to the log file: Minecraft being down
|
||||||
|
// is a legitimate state, and Uptime Kuma probes this over mcnet (bypassing
|
||||||
|
// caddy, hence unauthenticated by construction).
|
||||||
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
w.Write([]byte("ok"))
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
if req.URL.Path != "/" {
|
||||||
|
http.NotFound(w, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||||
|
w.Write(indexHTML)
|
||||||
|
})
|
||||||
|
|
||||||
|
log.Printf("monitor listening on %s, tailing %s (buffer=%d lines, interval=%s)",
|
||||||
|
listen, path, bufLines, interval)
|
||||||
|
log.Fatal(http.ListenAndServe(listen, mux))
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOr(key, def string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func envDur(key, def string) time.Duration {
|
||||||
|
d, err := time.ParseDuration(envOr(key, def))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("invalid %s: %v", key, err)
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildCollectors wires every dashboard tile source. TTLs are per-collector and
|
||||||
|
// chosen against what each thing actually costs: mc-status is a local HTTP hop
|
||||||
|
// (cheap, and it caches its own upstream ping), statfs is free, the backups dir
|
||||||
|
// is a directory scan, RCON opens a socket to the game server.
|
||||||
|
func buildCollectors(ev *events) *collectors {
|
||||||
|
base := strings.TrimRight(envOr("MONITOR_MCSTATUS_URL", "http://mc-status:8080"), "/")
|
||||||
|
dataDir := envOr("MONITOR_DATA_DIR", "/data")
|
||||||
|
backupDir := envOr("MONITOR_BACKUP_DIR", "/backups")
|
||||||
|
|
||||||
|
// Short timeout everywhere: a hung dependency must not hold the summary.
|
||||||
|
client := &http.Client{Timeout: 5 * time.Second}
|
||||||
|
|
||||||
|
rconAddr := envOr("MONITOR_RCON_ADDR", "minecraft:25575")
|
||||||
|
rconPass := os.Getenv("MONITOR_RCON_PASSWORD")
|
||||||
|
tpsInterval := envDur("MONITOR_TPS_INTERVAL", "30s")
|
||||||
|
|
||||||
|
return &collectors{
|
||||||
|
status: newCached(envDur("MONITOR_STATUS_TTL", "15s"), func() (statusInfo, error) {
|
||||||
|
return fetchStatus(client, base)
|
||||||
|
}),
|
||||||
|
registered: newCached(envDur("MONITOR_ROSTER_TTL", "60s"), func() ([]mcPlayer, error) {
|
||||||
|
return fetchRegistered(client, base)
|
||||||
|
}),
|
||||||
|
disk: newCached(envDur("MONITOR_DISK_TTL", "60s"), func() (diskInfo, error) {
|
||||||
|
return diskUsage(dataDir)
|
||||||
|
}),
|
||||||
|
backup: newCached(envDur("MONITOR_BACKUP_TTL", "60s"), func() (backupInfo, error) {
|
||||||
|
return backupStatus(backupDir)
|
||||||
|
}),
|
||||||
|
tps: newCached(tpsInterval, func() (*tpsInfo, error) {
|
||||||
|
if rconPass == "" {
|
||||||
|
return nil, errors.New("rcon: MONITOR_RCON_PASSWORD unset (TPS disabled)")
|
||||||
|
}
|
||||||
|
raw, err := rconQuery(rconAddr, rconPass, "neoforge tps", 5*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return parseTPS(raw)
|
||||||
|
}),
|
||||||
|
events: ev,
|
||||||
|
avatarURL: strings.TrimRight(envOr("MONITOR_AVATAR_URL", ""), "/"),
|
||||||
|
avatarMode: envOr("MONITOR_AVATAR_MODE", "headiso"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Locks the two things that are easy to break silently: the RCON allowlist
|
||||||
|
// (the guarantee that this service cannot run commands) and the `neoforge tps`
|
||||||
|
// output format, which differs from the older Forge wording.
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseTPSRealOutput(t *testing.T) {
|
||||||
|
// Captured verbatim from `docker exec minecraft rcon-cli neoforge tps` on prod,
|
||||||
|
// ANSI resets included.
|
||||||
|
raw := "Overworld: 20.000 TPS (0.298 ms/tick)\n\x1b[0mThe Nether: 19.100 TPS (0.014 ms/tick)\n" +
|
||||||
|
"\x1b[0mworld_dimension_nexus:creative: 20.000 TPS (0.009 ms/tick)\n" +
|
||||||
|
"\x1b[0mOverall: 18.500 TPS (0.357 ms/tick)\n\x1b[0m"
|
||||||
|
got, err := parseTPS(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("err: %v", err)
|
||||||
|
}
|
||||||
|
if got.TPS != 18.5 || got.MSPT != 0.357 {
|
||||||
|
t.Fatalf("overall wrong: %+v", got)
|
||||||
|
}
|
||||||
|
if len(got.Dimensions) != 3 {
|
||||||
|
t.Fatalf("want 3 dims, got %d: %+v", len(got.Dimensions), got.Dimensions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTPSUnknownCommand(t *testing.T) {
|
||||||
|
if _, err := parseTPS("Unknown or incomplete command, see below for error\nforge tps<--[HERE]"); err == nil {
|
||||||
|
t.Fatal("want error for unknown command")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRCONAllowlist(t *testing.T) {
|
||||||
|
for _, bad := range []string{"op Oier", "stop", "say hi", "ban x", "", "neoforge tps ", "LIST"} {
|
||||||
|
if _, err := rconQuery("127.0.0.1:1", "pw", bad, 0); err != errRCONNotAllowed {
|
||||||
|
t.Fatalf("command %q was not rejected by the allowlist (err=%v)", bad, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionParse(t *testing.T) {
|
||||||
|
e := newEvents(10)
|
||||||
|
e.observe("[01Aug2026 22:55:34.358] [Server thread/INFO] [net.minecraft.server.MinecraftServer/]: Oier joined the game")
|
||||||
|
e.observe("[01Aug2026 23:01:02.111] [Server thread/INFO] [net.minecraft.server.MinecraftServer/]: Oier left the game")
|
||||||
|
e.observe("[01Aug2026 23:02:00.000] [Server thread/INFO] [x/]: something else entirely")
|
||||||
|
got := e.recent(10)
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("want 2 events, got %d: %+v", len(got), got)
|
||||||
|
}
|
||||||
|
if got[0].Action != "left" || got[0].Player != "Oier" || got[0].Time != "01Aug2026 23:01:02.111" {
|
||||||
|
t.Fatalf("newest-first wrong: %+v", got[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
// Minimal Source RCON client — the ONLY thing in this service that talks to
|
||||||
|
// Minecraft rather than reading a file, and the only place it holds a secret.
|
||||||
|
//
|
||||||
|
// Implemented here rather than pulled in as a dependency for one reason: the
|
||||||
|
// allowlist below. It is a compile-time constant, and there is deliberately no
|
||||||
|
// code path from an HTTP request to a command string. The UI cannot run
|
||||||
|
// commands because the server cannot be asked to run one — "read-only" is a
|
||||||
|
// property of the build, not a rule someone has to remember.
|
||||||
|
//
|
||||||
|
// Protocol: https://developer.valvesoftware.com/wiki/Source_RCON_Protocol
|
||||||
|
// A fresh connection per poll (every 30s). Reconnecting costs nothing at that
|
||||||
|
// rate and removes all stale-connection handling.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
rconTypeAuth int32 = 3
|
||||||
|
rconTypeExec int32 = 2
|
||||||
|
rconTypeAuthResp int32 = 2
|
||||||
|
rconMaxBody = 4096
|
||||||
|
)
|
||||||
|
|
||||||
|
// rconAllowed is the complete set of commands this service can ever send.
|
||||||
|
// Both are pure reads. Do NOT make this configurable — see the file header.
|
||||||
|
var rconAllowed = map[string]bool{
|
||||||
|
"neoforge tps": true,
|
||||||
|
"list": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
var errRCONNotAllowed = errors.New("rcon: command not in allowlist")
|
||||||
|
|
||||||
|
// rconQuery dials, authenticates, runs one allowlisted command, and closes.
|
||||||
|
func rconQuery(addr, password, cmd string, timeout time.Duration) (string, error) {
|
||||||
|
if !rconAllowed[cmd] {
|
||||||
|
return "", errRCONNotAllowed
|
||||||
|
}
|
||||||
|
conn, err := net.DialTimeout("tcp", addr, timeout)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
conn.SetDeadline(time.Now().Add(timeout))
|
||||||
|
|
||||||
|
if err := rconSend(conn, 1, rconTypeAuth, password); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
// Some servers emit an empty RESPONSE_VALUE before the auth result; read
|
||||||
|
// until the auth response arrives (bounded, so a chatty peer can't spin us).
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
id, typ, _, err := rconRecv(conn)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if typ != rconTypeAuthResp {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if id == -1 {
|
||||||
|
return "", errors.New("rcon: authentication failed (bad password)")
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rconSend(conn, 2, rconTypeExec, cmd); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Responses may span several packets. Read until the peer goes quiet.
|
||||||
|
var sb strings.Builder
|
||||||
|
for {
|
||||||
|
_, _, body, err := rconRecv(conn)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
sb.WriteString(body)
|
||||||
|
if sb.Len() > rconMaxBody*4 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
conn.SetReadDeadline(time.Now().Add(250 * time.Millisecond))
|
||||||
|
}
|
||||||
|
if sb.Len() == 0 {
|
||||||
|
return "", errors.New("rcon: empty response")
|
||||||
|
}
|
||||||
|
return sb.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rconSend(w io.Writer, id, typ int32, body string) error {
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
// size counts everything after itself: id + type + body + two NUL bytes.
|
||||||
|
binary.Write(buf, binary.LittleEndian, int32(10+len(body)))
|
||||||
|
binary.Write(buf, binary.LittleEndian, id)
|
||||||
|
binary.Write(buf, binary.LittleEndian, typ)
|
||||||
|
buf.WriteString(body)
|
||||||
|
buf.Write([]byte{0, 0})
|
||||||
|
_, err := w.Write(buf.Bytes())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func rconRecv(r io.Reader) (id, typ int32, body string, err error) {
|
||||||
|
var size int32
|
||||||
|
if err = binary.Read(r, binary.LittleEndian, &size); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if size < 10 || size > rconMaxBody+16 {
|
||||||
|
err = errors.New("rcon: implausible packet size " + strconv.Itoa(int(size)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
buf := make([]byte, size)
|
||||||
|
if _, err = io.ReadFull(r, buf); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id = int32(binary.LittleEndian.Uint32(buf[0:4]))
|
||||||
|
typ = int32(binary.LittleEndian.Uint32(buf[4:8]))
|
||||||
|
body = string(bytes.TrimRight(buf[8:], "\x00"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- TPS ---------------------------------------------------------------
|
||||||
|
|
||||||
|
// tpsInfo is the parsed result of `neoforge tps`.
|
||||||
|
type tpsInfo struct {
|
||||||
|
TPS float64 `json:"tps"`
|
||||||
|
MSPT float64 `json:"mspt"`
|
||||||
|
Dimensions []dimensionT `json:"dimensions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type dimensionT struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
TPS float64 `json:"tps"`
|
||||||
|
MSPT float64 `json:"mspt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NeoForge 1.21.1 prints one line per dimension then an "Overall:" line:
|
||||||
|
//
|
||||||
|
// Overworld: 20.000 TPS (0.298 ms/tick)
|
||||||
|
// Overall: 20.000 TPS (0.357 ms/tick)
|
||||||
|
//
|
||||||
|
// Verified against the running server — note this is NOT the older Forge
|
||||||
|
// "Mean tick time / Mean TPS" wording, and `forge tps` does not exist here
|
||||||
|
// ("Unknown or incomplete command").
|
||||||
|
var tpsLineRe = regexp.MustCompile(`(?m)^(.+?):\s*([0-9.]+)\s*TPS\s*\(([0-9.]+)\s*ms/tick\)`)
|
||||||
|
|
||||||
|
// colorCodes strips Minecraft §-codes and any ANSI escapes a wrapper added.
|
||||||
|
var colorCodes = regexp.MustCompile("§.|\x1b\\[[0-9;]*m")
|
||||||
|
|
||||||
|
func parseTPS(raw string) (*tpsInfo, error) {
|
||||||
|
clean := colorCodes.ReplaceAllString(raw, "")
|
||||||
|
matches := tpsLineRe.FindAllStringSubmatch(clean, -1)
|
||||||
|
if len(matches) == 0 {
|
||||||
|
// Most likely "Unknown or incomplete command" — treat as unavailable
|
||||||
|
// rather than an error that kills the poll loop.
|
||||||
|
return nil, errors.New("rcon: no TPS lines in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
out := &tpsInfo{}
|
||||||
|
for _, m := range matches {
|
||||||
|
name := strings.TrimSpace(m[1])
|
||||||
|
tps, _ := strconv.ParseFloat(m[2], 64)
|
||||||
|
mspt, _ := strconv.ParseFloat(m[3], 64)
|
||||||
|
if strings.EqualFold(name, "Overall") {
|
||||||
|
out.TPS, out.MSPT = tps, mspt
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out.Dimensions = append(out.Dimensions, dimensionT{Name: name, TPS: tps, MSPT: mspt})
|
||||||
|
}
|
||||||
|
if out.TPS == 0 && len(out.Dimensions) > 0 {
|
||||||
|
// No "Overall" line — fall back to the worst dimension so the tile still
|
||||||
|
// says something useful rather than showing 0 TPS.
|
||||||
|
worst := out.Dimensions[0]
|
||||||
|
for _, d := range out.Dimensions[1:] {
|
||||||
|
if d.TPS < worst.TPS {
|
||||||
|
worst = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.TPS, out.MSPT = worst.TPS, worst.MSPT
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,643 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<title>Ulicraft — monitor</title>
|
||||||
|
<style>
|
||||||
|
/* Palette lifted from the landing's default (grass) theme so this doesn't look
|
||||||
|
like a different product. See landing/src/styles/main.css. */
|
||||||
|
:root {
|
||||||
|
--bg: oklch(0.165 0.014 150);
|
||||||
|
--bg-2: oklch(0.205 0.016 150);
|
||||||
|
--surface: oklch(0.235 0.017 150);
|
||||||
|
--line: oklch(0.33 0.016 150);
|
||||||
|
--accent: oklch(0.72 0.16 145);
|
||||||
|
--accent-ink:oklch(0.17 0.05 150);
|
||||||
|
--gold: oklch(0.82 0.135 85);
|
||||||
|
--red: oklch(0.68 0.19 25);
|
||||||
|
--text: oklch(0.95 0.008 110);
|
||||||
|
--muted: oklch(0.72 0.012 145);
|
||||||
|
--dim: oklch(0.55 0.012 145);
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { height: 100%; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font: 14px/1.4 system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
flex: none;
|
||||||
|
background: var(--bg-2);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
padding: 8px 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px 12px;
|
||||||
|
}
|
||||||
|
h1 { font-size: 14px; margin: 0; font-weight: 600; letter-spacing: .02em; }
|
||||||
|
h1 small { color: var(--dim); font-weight: 400; }
|
||||||
|
|
||||||
|
.dot { width: 9px; height: 9px; border-radius: 50%; background: var(--dim); flex: none; }
|
||||||
|
.dot.ok { background: var(--accent); box-shadow: 0 0 6px var(--accent); }
|
||||||
|
.dot.bad { background: var(--red); box-shadow: 0 0 6px var(--red); }
|
||||||
|
|
||||||
|
.stat { color: var(--muted); font-size: 12px; white-space: nowrap; }
|
||||||
|
.stat b { color: var(--text); font-weight: 600; }
|
||||||
|
.err { color: var(--red); font-size: 12px; }
|
||||||
|
.spacer { flex: 1 1 auto; }
|
||||||
|
|
||||||
|
input[type=search] {
|
||||||
|
flex: 1 1 220px;
|
||||||
|
min-width: 160px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text);
|
||||||
|
padding: 5px 8px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
input[type=search]:focus { outline: none; border-color: var(--accent); }
|
||||||
|
input[type=search].bad-re { border-color: var(--red); }
|
||||||
|
|
||||||
|
.chips { display: flex; gap: 4px; }
|
||||||
|
.chip {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--dim);
|
||||||
|
padding: 4px 8px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.chip[aria-pressed="true"] { color: var(--text); border-color: var(--accent); }
|
||||||
|
.chip[data-level=ERROR][aria-pressed="true"] { color: var(--red); border-color: var(--red); }
|
||||||
|
.chip[data-level=WARN][aria-pressed="true"] { color: var(--gold); border-color: var(--gold); }
|
||||||
|
|
||||||
|
.chip.bell { font-size: 13px; line-height: 1.15; padding: 4px 7px; margin-left: 4px; }
|
||||||
|
.chip.bell[data-state="on"] { color: var(--accent); border-color: var(--accent); }
|
||||||
|
.chip.bell[data-state="blocked"],
|
||||||
|
.chip.bell[data-state="unsupported"] { color: var(--red); cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* --- dashboard tiles --- */
|
||||||
|
#tiles {
|
||||||
|
flex: none;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--bg-2);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.tile {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.tile h2 {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--dim);
|
||||||
|
}
|
||||||
|
.tile .big { font-size: 19px; font-weight: 600; line-height: 1.15; }
|
||||||
|
.tile .sub { font-size: 11.5px; color: var(--muted); margin-top: 2px; overflow-wrap: anywhere; }
|
||||||
|
.tile .sub.warnish { color: var(--gold); }
|
||||||
|
.tile .sub.badish { color: var(--red); }
|
||||||
|
.good { color: var(--accent); }
|
||||||
|
.warn { color: var(--gold); }
|
||||||
|
.bad { color: var(--red); }
|
||||||
|
|
||||||
|
.bar { height: 5px; background: var(--slot, oklch(0.185 0.014 150)); border-radius: 3px; margin-top: 6px; overflow: hidden; }
|
||||||
|
.bar > i { display: block; height: 100%; background: var(--accent); }
|
||||||
|
.bar > i.warn { background: var(--gold); }
|
||||||
|
.bar > i.bad { background: var(--red); }
|
||||||
|
|
||||||
|
.heads { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 5px; }
|
||||||
|
.head { display: flex; align-items: center; gap: 4px; font-size: 11.5px; color: var(--muted); }
|
||||||
|
.head img { width: 20px; height: 20px; image-rendering: pixelated; border-radius: 3px; background: var(--bg); }
|
||||||
|
|
||||||
|
.sessions { margin-top: 4px; font-size: 11.5px; line-height: 1.5; max-height: 76px; overflow: auto; }
|
||||||
|
.sessions div { color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.sessions .j { color: var(--accent); }
|
||||||
|
.sessions .l { color: var(--dim); }
|
||||||
|
|
||||||
|
#log {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 8px 12px 40px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.ln { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
|
.ln.error { color: var(--red); }
|
||||||
|
.ln.warn { color: var(--gold); }
|
||||||
|
.ln.meta { color: var(--dim); font-style: italic; padding: 4px 0; }
|
||||||
|
|
||||||
|
#live {
|
||||||
|
position: fixed;
|
||||||
|
right: 18px;
|
||||||
|
bottom: 18px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-ink);
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 2px 10px rgb(0 0 0 / .4);
|
||||||
|
}
|
||||||
|
#live[hidden] { display: none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<span class="dot" id="dot"></span>
|
||||||
|
<h1>Ulicraft <small>latest.log</small></h1>
|
||||||
|
<span class="stat" id="age">—</span>
|
||||||
|
<span class="stat" id="counts">—</span>
|
||||||
|
<span class="err" id="error"></span>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<input type="search" id="filter" placeholder="filter… (/regex/ for regex, press / to focus)" spellcheck="false">
|
||||||
|
<span class="chips">
|
||||||
|
<button class="chip" data-level="ERROR" aria-pressed="true">ERROR</button>
|
||||||
|
<button class="chip" data-level="WARN" aria-pressed="true">WARN</button>
|
||||||
|
<button class="chip" data-level="INFO" aria-pressed="true">INFO</button>
|
||||||
|
</span>
|
||||||
|
<button class="chip bell" id="bell" data-state="off" aria-pressed="false" title="join/leave notifications">🔕</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div id="tiles">
|
||||||
|
<div class="tile" id="t-server"> <h2>Server</h2> <div class="big">—</div><div class="sub"></div></div>
|
||||||
|
<div class="tile" id="t-tps"> <h2>TPS</h2> <div class="big">—</div><div class="sub"></div></div>
|
||||||
|
<div class="tile" id="t-players"> <h2>Online</h2> <div class="big">—</div><div class="heads"></div></div>
|
||||||
|
<div class="tile" id="t-reg"> <h2>Registered</h2><div class="big">—</div><div class="sub"></div></div>
|
||||||
|
<div class="tile" id="t-disk"> <h2>Disk</h2> <div class="big">—</div><div class="sub"></div><div class="bar"><i></i></div></div>
|
||||||
|
<div class="tile" id="t-backup"> <h2>Last backup</h2><div class="big">—</div><div class="sub"></div></div>
|
||||||
|
<div class="tile" id="t-sessions"><h2>Recent joins</h2><div class="sessions"></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="log" role="log" aria-live="off"></div>
|
||||||
|
<button id="live" hidden>jump to live ↓</button>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const MAX = 5000; // must not exceed the server's ring capacity
|
||||||
|
const POLL_MS = 2000; // delay is acceptable by design; see plan/23-monitor.md
|
||||||
|
const STICK_PX = 40; // "close enough to the bottom" tolerance
|
||||||
|
|
||||||
|
const logEl = document.getElementById("log");
|
||||||
|
const dotEl = document.getElementById("dot");
|
||||||
|
const ageEl = document.getElementById("age");
|
||||||
|
const countsEl = document.getElementById("counts");
|
||||||
|
const errEl = document.getElementById("error");
|
||||||
|
const filterEl = document.getElementById("filter");
|
||||||
|
const liveEl = document.getElementById("live");
|
||||||
|
|
||||||
|
// records: {text, level, rendered}. `rendered` tracks whether a DOM node
|
||||||
|
// exists for this record, so trimming can remove exactly the right number of
|
||||||
|
// leading nodes without a full re-render on every poll.
|
||||||
|
let records = [];
|
||||||
|
let seq = null; // null => first fetch, ask for the whole buffer
|
||||||
|
let needle = "";
|
||||||
|
let regex = null;
|
||||||
|
const levelOn = { ERROR: true, WARN: true, INFO: true };
|
||||||
|
|
||||||
|
// Two bracket groups, then the level: the timestamp format differs between
|
||||||
|
// itzg's console output ([12:34:56]) and the file's ([01Aug2026 22:55:34.358]),
|
||||||
|
// so match the *shape* rather than the timestamp:
|
||||||
|
// [<anything>] [<thread>/LEVEL] [<logger>]: message
|
||||||
|
const LEVEL_RE = /^\[[^\]]*\]\s*\[[^\]]*\/([A-Z]+)\]/;
|
||||||
|
|
||||||
|
function levelOf(text) {
|
||||||
|
const m = LEVEL_RE.exec(text);
|
||||||
|
if (!m) return "INFO"; // stack traces, mod spam, etc.
|
||||||
|
const lv = m[1];
|
||||||
|
if (lv === "ERROR" || lv === "FATAL" || lv === "SEVERE") return "ERROR";
|
||||||
|
if (lv === "WARN" || lv === "WARNING") return "WARN";
|
||||||
|
return "INFO";
|
||||||
|
}
|
||||||
|
|
||||||
|
function passes(rec) {
|
||||||
|
if (rec.level === "META") return true; // dropped-lines markers always show
|
||||||
|
if (!levelOn[rec.level]) return false;
|
||||||
|
if (regex) return regex.test(rec.text);
|
||||||
|
if (needle) return rec.text.toLowerCase().includes(needle);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeFor(rec) {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.className = "ln " + rec.level.toLowerCase();
|
||||||
|
el.textContent = rec.text; // textContent, never innerHTML
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
const atBottom = () =>
|
||||||
|
logEl.scrollHeight - logEl.scrollTop - logEl.clientHeight < STICK_PX;
|
||||||
|
|
||||||
|
const toBottom = () => { logEl.scrollTop = logEl.scrollHeight; };
|
||||||
|
|
||||||
|
function addRecords(recs) {
|
||||||
|
const stick = atBottom();
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
for (const rec of recs) {
|
||||||
|
records.push(rec);
|
||||||
|
if (passes(rec)) { frag.appendChild(nodeFor(rec)); rec.rendered = true; }
|
||||||
|
}
|
||||||
|
logEl.appendChild(frag);
|
||||||
|
trim();
|
||||||
|
if (stick) toBottom();
|
||||||
|
updateCounts();
|
||||||
|
updateLiveButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
function trim() {
|
||||||
|
const excess = records.length - MAX;
|
||||||
|
if (excess <= 0) return;
|
||||||
|
let nodes = 0;
|
||||||
|
for (let i = 0; i < excess; i++) if (records[i].rendered) nodes++;
|
||||||
|
records.splice(0, excess);
|
||||||
|
for (let i = 0; i < nodes && logEl.firstChild; i++) logEl.removeChild(logEl.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full rebuild — only on a filter change, never on a poll.
|
||||||
|
function rerender() {
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
for (const rec of records) {
|
||||||
|
rec.rendered = passes(rec);
|
||||||
|
if (rec.rendered) frag.appendChild(nodeFor(rec));
|
||||||
|
}
|
||||||
|
logEl.replaceChildren(frag);
|
||||||
|
toBottom();
|
||||||
|
updateCounts();
|
||||||
|
updateLiveButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
records = [];
|
||||||
|
logEl.replaceChildren();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCounts() {
|
||||||
|
const shown = logEl.childElementCount;
|
||||||
|
countsEl.textContent = shown === records.length
|
||||||
|
? `${records.length} lines`
|
||||||
|
: `${shown} / ${records.length} lines`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLiveButton() { liveEl.hidden = atBottom(); }
|
||||||
|
|
||||||
|
function updateTail(tail) {
|
||||||
|
if (!tail) return;
|
||||||
|
if (!tail.ok) {
|
||||||
|
dotEl.className = "dot bad";
|
||||||
|
errEl.textContent = tail.error || "log unavailable";
|
||||||
|
ageEl.textContent = "—";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
errEl.textContent = "";
|
||||||
|
dotEl.className = "dot ok";
|
||||||
|
// Browser vs container clock skew can make this slightly off; clamped at 0.
|
||||||
|
const age = Math.max(0, Math.floor(Date.now() / 1000) - tail.mtime);
|
||||||
|
const rot = tail.rotations ? ` · ${tail.rotations} rotation${tail.rotations > 1 ? "s" : ""}` : "";
|
||||||
|
ageEl.innerHTML = `last write <b>${age}s</b> ago${rot}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function poll() {
|
||||||
|
try {
|
||||||
|
const url = seq === null ? "/api/log" : "/api/log?since=" + seq;
|
||||||
|
const res = await fetch(url, { cache: "no-store" });
|
||||||
|
if (!res.ok) throw new Error("HTTP " + res.status);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.reset) {
|
||||||
|
reset();
|
||||||
|
if (data.dropped > 0) {
|
||||||
|
addRecords([{ text: `— fell behind, ${data.dropped} line(s) skipped —`, level: "META", rendered: false }]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seq = data.seq;
|
||||||
|
|
||||||
|
if (data.lines.length) {
|
||||||
|
addRecords(data.lines.map(text => ({ text, level: levelOf(text), rendered: false })));
|
||||||
|
} else {
|
||||||
|
updateCounts();
|
||||||
|
}
|
||||||
|
updateTail(data.tail);
|
||||||
|
} catch (e) {
|
||||||
|
dotEl.className = "dot bad";
|
||||||
|
errEl.textContent = String(e.message || e);
|
||||||
|
} finally {
|
||||||
|
setTimeout(poll, POLL_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- dashboard tiles ------------------------------------------------------
|
||||||
|
|
||||||
|
// Tiles move far slower than the log does. Poll harder only while join/leave
|
||||||
|
// notifications are on, since then the poll interval IS the alert latency.
|
||||||
|
const SUMMARY_IDLE_MS = 10000;
|
||||||
|
const SUMMARY_NOTIFY_MS = 5000;
|
||||||
|
|
||||||
|
const fmtBytes = (n) => {
|
||||||
|
if (!n) return "0 B";
|
||||||
|
const u = ["B", "KB", "MB", "GB", "TB"];
|
||||||
|
const i = Math.min(u.length - 1, Math.floor(Math.log(n) / Math.log(1024)));
|
||||||
|
return (n / Math.pow(1024, i)).toFixed(i ? 1 : 0) + " " + u[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmtAge = (s) => {
|
||||||
|
if (s == null) return "—";
|
||||||
|
if (s < 60) return s + "s ago";
|
||||||
|
if (s < 3600) return Math.floor(s / 60) + "m ago";
|
||||||
|
if (s < 86400) return Math.floor(s / 3600) + "h ago";
|
||||||
|
return Math.floor(s / 86400) + "d ago";
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set a tile's big value, its class, and its subtitle in one go. `err` wins
|
||||||
|
// over the value: a stale number with no warning would be a lie.
|
||||||
|
function tile(id, big, bigClass, sub, subClass, err) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
el.querySelector(".big").textContent = big;
|
||||||
|
el.querySelector(".big").className = "big " + (bigClass || "");
|
||||||
|
const s = el.querySelector(".sub");
|
||||||
|
if (s) {
|
||||||
|
s.textContent = err ? err : (sub || "");
|
||||||
|
s.className = "sub " + (err ? "badish" : (subClass || ""));
|
||||||
|
}
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSummary(d) {
|
||||||
|
// Server
|
||||||
|
tile("t-server",
|
||||||
|
d.server.online ? "online" : "offline",
|
||||||
|
d.server.online ? "good" : "bad",
|
||||||
|
d.server.online ? (d.server.version || "") + (d.server.motd ? " · " + d.server.motd : "") : "",
|
||||||
|
"", d.server_err);
|
||||||
|
|
||||||
|
// TPS — 20 is the ceiling; below ~19.5 is noticeable, below 15 is bad.
|
||||||
|
if (d.tps) {
|
||||||
|
const t = d.tps.tps;
|
||||||
|
tile("t-tps", t.toFixed(2), t >= 19.5 ? "good" : t >= 15 ? "warn" : "bad",
|
||||||
|
d.tps.mspt.toFixed(2) + " ms/tick", t >= 19.5 ? "" : "warnish", d.tps_err);
|
||||||
|
} else {
|
||||||
|
tile("t-tps", "—", "", "", "", d.tps_err || "unavailable");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Online players + heads (rendered by NMSR on its own public origin)
|
||||||
|
const pl = d.server.players || [];
|
||||||
|
tile("t-players", `${d.server.count ?? 0} / ${d.server.max ?? 0}`, pl.length ? "good" : "");
|
||||||
|
const heads = document.querySelector("#t-players .heads");
|
||||||
|
heads.replaceChildren();
|
||||||
|
for (const p of pl) {
|
||||||
|
const wrap = document.createElement("span");
|
||||||
|
wrap.className = "head";
|
||||||
|
if (d.avatar_url && p.uuid) {
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.src = `${d.avatar_url}/${d.avatar_mode}/${p.uuid}?size=40`;
|
||||||
|
img.alt = "";
|
||||||
|
img.loading = "lazy";
|
||||||
|
wrap.appendChild(img);
|
||||||
|
}
|
||||||
|
const nm = document.createElement("span");
|
||||||
|
nm.textContent = p.name || "?";
|
||||||
|
wrap.appendChild(nm);
|
||||||
|
heads.appendChild(wrap);
|
||||||
|
}
|
||||||
|
|
||||||
|
tile("t-reg", String(d.registered ?? 0), "", "Drasl accounts", "", d.roster_err);
|
||||||
|
|
||||||
|
// Disk — the failure that actually takes the server down.
|
||||||
|
const used = d.disk.used_pct || 0;
|
||||||
|
const cls = used >= 90 ? "bad" : used >= 80 ? "warn" : "good";
|
||||||
|
tile("t-disk", fmtBytes(d.disk.free_bytes) + " free", cls,
|
||||||
|
used.toFixed(1) + "% used of " + fmtBytes(d.disk.total_bytes), "", d.disk_err);
|
||||||
|
const bar = document.querySelector("#t-disk .bar > i");
|
||||||
|
bar.style.width = Math.min(100, used) + "%";
|
||||||
|
bar.className = cls === "good" ? "" : cls;
|
||||||
|
|
||||||
|
// Backup freshness. mc-backup runs every 6h, so >8h means it stopped.
|
||||||
|
if (d.backup && d.backup.count) {
|
||||||
|
const age = d.backup.age_seconds;
|
||||||
|
tile("t-backup", fmtAge(age), age > 28800 ? "bad" : age > 21600 ? "warn" : "good",
|
||||||
|
`${d.backup.count} archives · ${fmtBytes(d.backup.total_bytes)}`, "", d.backup_err);
|
||||||
|
} else {
|
||||||
|
tile("t-backup", "none", "bad", "", "", d.backup_err);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSessions(d.sessions);
|
||||||
|
|
||||||
|
// Recent joins/leaves, parsed out of the log we already hold.
|
||||||
|
const box = document.querySelector("#t-sessions .sessions");
|
||||||
|
box.replaceChildren();
|
||||||
|
if (!d.sessions || !d.sessions.length) {
|
||||||
|
const e = document.createElement("div");
|
||||||
|
e.textContent = "no joins in buffer";
|
||||||
|
box.appendChild(e);
|
||||||
|
} else {
|
||||||
|
for (const s of d.sessions.slice(0, 12)) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
const who = document.createElement("span");
|
||||||
|
who.className = s.action === "joined" ? "j" : "l";
|
||||||
|
who.textContent = `${s.action === "joined" ? "→" : "←"} ${s.player}`;
|
||||||
|
row.appendChild(who);
|
||||||
|
row.appendChild(document.createTextNode(" " + (s.time || "")));
|
||||||
|
box.appendChild(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollSummary() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/summary", { cache: "no-store" });
|
||||||
|
if (res.ok) renderSummary(await res.json());
|
||||||
|
} catch { /* tiles keep their last values; the log poll owns the status dot */ }
|
||||||
|
finally { setTimeout(pollSummary, notifyOn ? SUMMARY_NOTIFY_MS : SUMMARY_IDLE_MS); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- join/leave notifications ---------------------------------------------
|
||||||
|
//
|
||||||
|
// Rides the same sessions[] the "Recent joins" tile renders — no second
|
||||||
|
// source, no re-parsing of the log in JS.
|
||||||
|
//
|
||||||
|
// The whole difficulty is not notifying for history, and there are two ways
|
||||||
|
// history shows up: the buffer already holds events when the page loads, and
|
||||||
|
// the monitor's cold-start backfill re-reads 5000 log lines on every deploy,
|
||||||
|
// repopulating the events ring with the SAME events. So newness is decided by
|
||||||
|
// a persisted seen-set keyed on the log's own timestamp — stable across both a
|
||||||
|
// page reload and a container restart, which a server-side counter would not
|
||||||
|
// be (it resets to zero and every event looks new again).
|
||||||
|
|
||||||
|
const NOTIFY_KEY = "ulicraft.monitor.notify";
|
||||||
|
const SEEN_KEY = "ulicraft.monitor.seen";
|
||||||
|
const SEEN_MAX = 500;
|
||||||
|
const BURST_MIN = 3; // more than this in one poll collapses to one notification
|
||||||
|
|
||||||
|
let notifyOn = localStorage.getItem(NOTIFY_KEY) === "1";
|
||||||
|
let seen = new Set(loadSeen());
|
||||||
|
let baselined = false;
|
||||||
|
|
||||||
|
function loadSeen() {
|
||||||
|
try { return JSON.parse(localStorage.getItem(SEEN_KEY)) || []; } catch { return []; }
|
||||||
|
}
|
||||||
|
function saveSeen() {
|
||||||
|
const arr = [...seen].slice(-SEEN_MAX); // Set preserves insertion order
|
||||||
|
seen = new Set(arr);
|
||||||
|
try { localStorage.setItem(SEEN_KEY, JSON.stringify(arr)); } catch { /* quota */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const evKey = (s) => `${s.time}|${s.player}|${s.action}`;
|
||||||
|
|
||||||
|
function handleSessions(sessions) {
|
||||||
|
if (!Array.isArray(sessions)) return;
|
||||||
|
|
||||||
|
// Server returns newest-first; walk backwards so `seen` stays chronological
|
||||||
|
// and the burst summary reads in the right order.
|
||||||
|
const fresh = [];
|
||||||
|
for (let i = sessions.length - 1; i >= 0; i--) {
|
||||||
|
const k = evKey(sessions[i]);
|
||||||
|
if (seen.has(k)) continue;
|
||||||
|
seen.add(k);
|
||||||
|
fresh.push(sessions[i]);
|
||||||
|
}
|
||||||
|
if (fresh.length) saveSeen();
|
||||||
|
|
||||||
|
// The first response after a page load is history by definition. Flip the
|
||||||
|
// flag unconditionally — doing it only when fresh.length > 0 would mean a
|
||||||
|
// returning user (everything already seen) burns their baseline on the
|
||||||
|
// FIRST REAL event instead, silently swallowing it.
|
||||||
|
const isBaseline = !baselined;
|
||||||
|
baselined = true;
|
||||||
|
if (isBaseline || !fresh.length) return;
|
||||||
|
|
||||||
|
if (!notifyOn || !("Notification" in window) || Notification.permission !== "granted") return;
|
||||||
|
|
||||||
|
if (fresh.length > BURST_MIN) {
|
||||||
|
// A server restart makes everyone rejoin at once; one line beats six.
|
||||||
|
const joined = fresh.filter(s => s.action === "joined").length;
|
||||||
|
const left = fresh.length - joined;
|
||||||
|
const parts = [];
|
||||||
|
if (joined) parts.push(`${joined} joined`);
|
||||||
|
if (left) parts.push(`${left} left`);
|
||||||
|
notify("Player activity", parts.join(", "), "burst:" + evKey(fresh[fresh.length - 1]));
|
||||||
|
} else {
|
||||||
|
for (const s of fresh) notify(`${s.player} ${s.action}`, s.time, evKey(s));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function notify(title, body, tag) {
|
||||||
|
try {
|
||||||
|
const n = new Notification(title, { body, tag }); // tag: identical events can't stack
|
||||||
|
n.onclick = () => { window.focus(); n.close(); };
|
||||||
|
} catch { /* some browsers throw outside a service worker; nothing to do */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const bellEl = document.getElementById("bell");
|
||||||
|
|
||||||
|
function paintBell() {
|
||||||
|
const supported = "Notification" in window;
|
||||||
|
const blocked = supported && Notification.permission === "denied";
|
||||||
|
const state = !supported ? "unsupported" : blocked ? "blocked" : notifyOn ? "on" : "off";
|
||||||
|
bellEl.dataset.state = state;
|
||||||
|
bellEl.setAttribute("aria-pressed", String(state === "on"));
|
||||||
|
bellEl.textContent = state === "on" ? "🔔" : "🔕";
|
||||||
|
bellEl.title = {
|
||||||
|
unsupported: "this browser has no Notification API",
|
||||||
|
// Say this out loud: "enabled it and nothing happened" is otherwise
|
||||||
|
// indistinguishable from the feature being broken.
|
||||||
|
blocked: "blocked in browser settings — allow notifications for this site",
|
||||||
|
on: "join/leave notifications ON",
|
||||||
|
off: "join/leave notifications off — click to enable",
|
||||||
|
}[state];
|
||||||
|
}
|
||||||
|
|
||||||
|
bellEl.addEventListener("click", async () => {
|
||||||
|
if (!("Notification" in window)) return;
|
||||||
|
if (notifyOn) {
|
||||||
|
notifyOn = false;
|
||||||
|
} else {
|
||||||
|
// requestPermission must be called from a user gesture — i.e. this click,
|
||||||
|
// never on page load.
|
||||||
|
if (Notification.permission === "default") {
|
||||||
|
try { await Notification.requestPermission(); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
if (Notification.permission !== "granted") { paintBell(); return; }
|
||||||
|
notifyOn = true;
|
||||||
|
}
|
||||||
|
localStorage.setItem(NOTIFY_KEY, notifyOn ? "1" : "0");
|
||||||
|
paintBell();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- filter ---------------------------------------------------------------
|
||||||
|
|
||||||
|
let debounce;
|
||||||
|
filterEl.addEventListener("input", () => {
|
||||||
|
clearTimeout(debounce);
|
||||||
|
debounce = setTimeout(() => {
|
||||||
|
const raw = filterEl.value.trim();
|
||||||
|
const re = raw.length > 1 && raw.startsWith("/") ? raw.lastIndexOf("/") : -1;
|
||||||
|
regex = null;
|
||||||
|
needle = "";
|
||||||
|
filterEl.classList.remove("bad-re");
|
||||||
|
if (re > 0) {
|
||||||
|
try {
|
||||||
|
regex = new RegExp(raw.slice(1, re), raw.slice(re + 1) || "i");
|
||||||
|
} catch {
|
||||||
|
filterEl.classList.add("bad-re"); // keep showing everything, don't lie
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
needle = raw.toLowerCase();
|
||||||
|
}
|
||||||
|
rerender();
|
||||||
|
}, 120);
|
||||||
|
});
|
||||||
|
|
||||||
|
// [data-level] only — the bell is also a .chip but is not a level filter.
|
||||||
|
for (const chip of document.querySelectorAll(".chip[data-level]")) {
|
||||||
|
chip.addEventListener("click", () => {
|
||||||
|
const on = chip.getAttribute("aria-pressed") !== "true";
|
||||||
|
chip.setAttribute("aria-pressed", String(on));
|
||||||
|
levelOn[chip.dataset.level] = on;
|
||||||
|
rerender();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "/" && document.activeElement !== filterEl) {
|
||||||
|
e.preventDefault();
|
||||||
|
filterEl.focus();
|
||||||
|
filterEl.select();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
logEl.addEventListener("scroll", updateLiveButton, { passive: true });
|
||||||
|
liveEl.addEventListener("click", () => { toBottom(); updateLiveButton(); });
|
||||||
|
|
||||||
|
paintBell();
|
||||||
|
poll();
|
||||||
|
pollSummary();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# NMSR-as-a-Service (https://github.com/NickAcPT/nmsr-rs) — built from source.
|
||||||
|
# Upstream ships no published image, so we vendor a pinned build. Adapted from
|
||||||
|
# the upstream Dockerfile but: pinned to a commit (reproducible), and the config
|
||||||
|
# is bind-mounted at runtime (not COPYed) so it can change without a rebuild.
|
||||||
|
#
|
||||||
|
# Pin — bump deliberately:
|
||||||
|
ARG NMSR_REF=948ba4bc027b
|
||||||
|
|
||||||
|
FROM rust:slim-bookworm AS builder
|
||||||
|
ARG NMSR_REF
|
||||||
|
WORKDIR /tmp
|
||||||
|
RUN apt-get update -y \
|
||||||
|
&& apt-get --no-install-recommends install -y git libssl-dev pkg-config ca-certificates
|
||||||
|
RUN git clone https://github.com/NickAcPT/nmsr-rs/
|
||||||
|
WORKDIR /tmp/nmsr-rs
|
||||||
|
RUN git checkout "${NMSR_REF}"
|
||||||
|
# target-cpu=native: this image is built on the host that runs it (self-host).
|
||||||
|
RUN RUSTFLAGS="-Ctarget-cpu=native" \
|
||||||
|
cargo build --release --bin nmsr-aas --features ears --package nmsr-aas
|
||||||
|
|
||||||
|
FROM rust:slim-bookworm
|
||||||
|
# mesa-vulkan-drivers provides lavapipe (software Vulkan) for headless rendering.
|
||||||
|
RUN apt-get update -y \
|
||||||
|
&& apt-get --no-install-recommends install -y mesa-vulkan-drivers ca-certificates
|
||||||
|
WORKDIR /nmsr
|
||||||
|
COPY --from=builder /tmp/nmsr-rs/target/release/nmsr-aas /nmsr/nmsr-aas
|
||||||
|
ENV NMSR_USE_SMAA=1 \
|
||||||
|
NMSR_SAMPLE_COUNT=1 \
|
||||||
|
WGPU_BACKEND=vulkan \
|
||||||
|
RUST_BACKTRACE=1
|
||||||
|
EXPOSE 8080
|
||||||
|
# config.toml is bind-mounted by docker-compose.nmsr.yml.
|
||||||
|
CMD ["/nmsr/nmsr-aas", "-c", "/nmsr/config.toml"]
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
# Drasl config — password-login mode (NO Keycloak/OIDC for now).
|
# Drasl config — password-login mode (NO Keycloak/OIDC for now).
|
||||||
# TOML only (drasl has no env support). Rendered by tooling/render-config.sh
|
# TOML only (drasl has no env support). Rendered by tooling/render-config.sh
|
||||||
# -> drasl/config/config.toml (gitignored). Only ${BASE_DOMAIN} is substituted.
|
# -> drasl/config/config.toml (gitignored). Substitutes ${BASE_DOMAIN} and
|
||||||
|
# ${REGISTRATION_REQUIRE_INVITE} (derived from REGISTRATION_MODE in .env).
|
||||||
# Reached only via Caddy at auth.${BASE_DOMAIN}; drasl has no published host port.
|
# Reached only via Caddy at auth.${BASE_DOMAIN}; drasl has no published host port.
|
||||||
|
|
||||||
BaseURL = "http://auth.${BASE_DOMAIN}"
|
# Public scheme is HTTPS — the host nginx terminates TLS in front of caddy.
|
||||||
|
# Drasl builds absolute URLs (textures, authlib-injector API location) from this;
|
||||||
|
# an http:// value triggers mixed-content + broken login on the https origin.
|
||||||
|
BaseURL = "https://auth.${BASE_DOMAIN}"
|
||||||
Domain = "auth.${BASE_DOMAIN}"
|
Domain = "auth.${BASE_DOMAIN}"
|
||||||
ListenAddress = "0.0.0.0:25585" # internal; Caddy reverse-proxies to it
|
ListenAddress = "0.0.0.0:25585" # internal; Caddy reverse-proxies to it
|
||||||
DefaultAdminUsernames = ["admin"]
|
DefaultAdmins = ["admin"]
|
||||||
|
|
||||||
# Password login: admin + guests register a username/password on the web UI.
|
# Password login: admin + guests register a username/password on the web UI.
|
||||||
AllowPasswordLogin = true
|
AllowPasswordLogin = true
|
||||||
@@ -18,5 +22,29 @@ SignPublicKeys = false
|
|||||||
# Free-text owner label shown in the web UI (a string, not a table).
|
# Free-text owner label shown in the web UI (a string, not a table).
|
||||||
ApplicationOwner = "Ulicraft"
|
ApplicationOwner = "Ulicraft"
|
||||||
|
|
||||||
|
# CORS: the landing register/account form (served from the apex) calls the
|
||||||
|
# Drasl API from the browser. Without this the API has NO CORS headers and the
|
||||||
|
# browser blocks every cross-origin call. Scope to the apex only — never "*",
|
||||||
|
# which would let any site script the auth API. Echo backfills AllowMethods
|
||||||
|
# (incl. PATCH/DELETE) and reflects requested headers (Content-Type/Authorization).
|
||||||
|
# MUST stay top-level (before any [Section]) — under a table it parses as
|
||||||
|
# <table>.CORSAllowOrigins and drasl ignores it (silent: no CORS headers).
|
||||||
|
CORSAllowOrigins = ["https://${BASE_DOMAIN}"]
|
||||||
|
|
||||||
|
# New-account registration (username+password). RequireInvite is derived from
|
||||||
|
# REGISTRATION_MODE in .env by render-config.sh: invite -> true, open -> false.
|
||||||
|
# When true, non-admin callers (web UI + landing register form) must supply a
|
||||||
|
# valid invite code; admins bypass. Mint codes via POST /drasl/api/v2/invites
|
||||||
|
# with an admin api token.
|
||||||
|
[RegistrationNewPlayer]
|
||||||
|
Allow = true
|
||||||
|
RequireInvite = ${REGISTRATION_REQUIRE_INVITE}
|
||||||
|
|
||||||
|
# Rate limit the now public-facing API (anonymous POST /users etc). Token
|
||||||
|
# bucket; admins are exempt. Conservative for a 4-10 player server.
|
||||||
|
[RateLimit]
|
||||||
|
RequestsPerSecond = 5
|
||||||
|
Burst = 10
|
||||||
|
|
||||||
# OIDC block intentionally omitted for now (no Keycloak).
|
# OIDC block intentionally omitted for now (no Keycloak).
|
||||||
# [[RegistrationOIDC]] <- future task
|
# [[RegistrationOIDC]] <- future task
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"general": {
|
||||||
|
"name": "Ulicraft Files",
|
||||||
|
"host": "upload.${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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
packages:
|
||||||
|
- '.'
|
||||||
|
onlyBuiltDependencies:
|
||||||
|
- esbuild
|
||||||
|
- sharp
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.0 KiB |
Binary file not shown.
@@ -268,3 +268,13 @@
|
|||||||
src: url(/fonts/pxiKyp0ihIEF2isfFJU.woff2) format('woff2');
|
src: url(/fonts/pxiKyp0ihIEF2isfFJU.woff2) format('woff2');
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
}
|
}
|
||||||
|
/* Block Blueprint — vendored from 1001fonts (NOT on Google Fonts; appended by
|
||||||
|
fetch-fonts.sh after the Google rewrite). License: 1001Fonts Free For Personal
|
||||||
|
Use. Single weight, TTF (no woff2 toolchain on the build box). */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Block Blueprint';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(/fonts/BlockBlueprint.ttf) format('truetype');
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
width="64"
|
||||||
|
height="64"
|
||||||
|
viewBox="0 0 9.677 9.667"
|
||||||
|
version="1.1"
|
||||||
|
id="svg3"
|
||||||
|
sodipodi:docname="mojang.svg"
|
||||||
|
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<defs
|
||||||
|
id="defs3" />
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview3"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:zoom="2.0467427"
|
||||||
|
inkscape:cx="-92.830429"
|
||||||
|
inkscape:cy="96.006206"
|
||||||
|
inkscape:window-width="1920"
|
||||||
|
inkscape:window-height="992"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="0"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="g3" />
|
||||||
|
<path
|
||||||
|
d="M1.54 2.844c.314-.76 1.31-.46 1.954-.528.785-.083 1.503.272 2.1.758l.164-.9c.327.345.587.756.964 1.052.28.254.655-.342.86-.013.42.864.408 1.86.54 2.795l-.788-.373C6.9 4.17 5.126 3.052 3.656 3.685c-1.294.592-1.156 2.65.06 3.255 1.354.703 2.953.51 4.405.292-.07.42-.34.87-.834.816l-4.95.002c-.5.055-.886-.413-.838-.89l.04-4.315z"
|
||||||
|
fill="#fff"
|
||||||
|
id="path3" />
|
||||||
|
<g
|
||||||
|
id="g3"
|
||||||
|
transform="matrix(0.33310606,0,0,0.33310606,8.7658015,-1.332)">
|
||||||
|
<circle
|
||||||
|
style="fill:#283c63;fill-opacity:1;stroke:none;stroke-width:4.73305"
|
||||||
|
id="path1-9"
|
||||||
|
r="14.345329"
|
||||||
|
cy="18.66217"
|
||||||
|
cx="-11.946259" />
|
||||||
|
<path
|
||||||
|
id="logo"
|
||||||
|
d="m -16.05726,28.400159 c -0.04546,-0.07393 -0.04146,-0.951547 0.009,-1.949545 l 0.09191,-1.81518 -0.526472,-0.542457 c -0.672825,-0.692806 -1.103893,-0.809189 -1.679816,-0.453545 -0.538461,0.333165 -0.830668,0.335663 -1.972023,0.01948 -0.862137,-0.23926 -1.268729,-0.459538 -1.120876,-0.607391 0.03746,-0.03797 0.539459,-0.211288 1.113883,-0.385613 1.338658,-0.405094 1.827167,-0.597401 1.740754,-0.684314 -0.03847,-0.03746 -0.615382,0.08842 -1.283213,0.280718 -1.289707,0.370628 -1.760235,0.354645 -1.986009,-0.06693 -0.109385,-0.204296 -0.07793,-0.311188 0.153347,-0.526972 0.159839,-0.148851 0.329669,-0.270729 0.376622,-0.270729 0.04745,0 0.407591,-0.182817 0.801196,-0.406593 l 0.714784,-0.406592 -0.627871,0.07393 c -0.554444,0.06494 -0.62787,0.04396 -0.62787,-0.177322 0,-0.497002 0.182316,-0.679319 1.807687,-1.811185 2.161834,-1.50599 2.788706,-2.085909 3.82167,-3.534457 0.472526,-0.662836 1.121376,-1.3966 1.441555,-1.630866 0.416083,-0.304693 0.627371,-0.578419 0.741756,-0.959537 0.174825,-0.584415 0.423575,-0.866132 0.643355,-0.730268 0.07793,0.04795 0.376124,0.554444 0.663335,1.124871 0.495503,0.985014 0.792705,1.382116 0.792705,1.057441 0,-0.08142 -0.279719,-0.693305 -0.620877,-1.359636 -0.56843,-1.107891 -0.770727,-1.918578 -0.537462,-2.151844 0.143857,-0.143855 0.308192,0.03196 1.558938,1.673323 1.3971002,1.833162 1.6083883,2.205789 1.7192766,3.026966 0.076421,0.567931 0.084919,0.57792 0.1873128,0.217782 0.07543,-0.264735 0.041463,-0.515983 -0.1143866,-0.842656 -0.1218821,-0.255744 -0.1923077,-0.494004 -0.1568436,-0.52947 0.076922,-0.07692 3.2342583,1.562934 4.8366523,2.511483 1.3516446,0.800198 1.8501452,1.238759 1.8501452,1.62637 0,0.467532 -1.0164816,2.36463 -1.8831125,3.514478 -1.1038935,1.465529 -3.3566354,3.276715 -4.0799095,3.280711 -0.2612379,0.0015 -2.1603357,-1.960035 -2.2577377,-2.332162 -0.04496,-0.173826 0.17882,-0.701797 0.63936,-1.505991 0.6992989,-1.221275 0.9160807,-1.784211 0.4735251,-1.227769 l -0.8541441,1.073923 c -0.469529,0.590908 -0.860637,0.910587 -1.561435,1.278718 -0.513984,0.270729 -0.969029,0.580419 -1.010487,0.689309 -0.04146,0.108396 0,0.510987 0.09141,0.894104 0.132368,0.551947 0.133367,0.803694 0.006,1.211286 -0.253246,0.809189 -1.090407,2.56343 -1.223774,2.56343 -0.06593,0 -0.0889,-0.104396 -0.05095,-0.231267 0.03747,-0.127373 0.104397,-0.40959 0.147852,-0.627872 0.07543,-0.376622 0.05095,-0.363135 -0.489509,0.264236 -0.742257,0.863135 -1.41708,1.519976 -1.561435,1.519976 -0.06293,0 -0.152347,-0.06044 -0.197802,-0.134865 z m 3.54145,-11.877094 c 0.213785,-0.214286 -0.03447,-0.411587 -0.516982,-0.411587 -0.567931,0 -0.974523,0.202296 -0.974523,0.485512 0,0.177822 0.110388,0.198801 0.683815,0.130869 0.375623,-0.04446 0.739259,-0.136863 0.80769,-0.204794 z m 2.467027,-4.450039 c -0.167332,-0.201798 0.631368,-0.91908 1.5134836,-1.359638 1.1278687,-0.563436 2.9155763,-0.601397 3.9170734,-0.08341 0.3346641,0.172825 0.9740234,0.845152 0.8866115,0.932564 -0.019979,0.01998 -0.3626379,-0.07642 -0.7617364,-0.214285 -1.3331643,-0.460038 -3.0534394,-0.26973 -4.6588303,0.514484 -0.5889094,0.288211 -0.7927059,0.335664 -0.8966018,0.210289 z m -0.83716,-0.906592 c -0.262737,-0.321178 -0.478521,-0.643355 -0.478521,-0.716281 0,-0.204296 1.587908,-1.2002978 2.160335,-1.3746227 0.5724257,-0.173826 1.252744,-0.2857133 2.3086856,-0.2857133 1.0559416,0 1.7432527,0.1608381 2.547446,0.5429553 0.8121863,0.3861131 2.468526,1.7382577 2.1448505,1.7507447 -0.085909,0.0035 -0.650848,-0.243257 -1.2557409,-0.547452 -1.0034949,-0.504993 -1.2057923,-0.5584397 -2.3261694,-0.6108863 -1.6183763,-0.07543 -2.2127818,0.1048983 -3.4750167,1.0559403 -0.5614371,0.423076 -1.0489481,0.769229 -1.0839131,0.769229 -0.03497,0 -0.27872,-0.262735 -0.541956,-0.584413 z"
|
||||||
|
style="fill:#bbbbbb;fill-opacity:1;stroke:none;stroke-width:0.499498;stroke-opacity:1" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.4 KiB |
@@ -1,8 +1,8 @@
|
|||||||
---
|
---
|
||||||
// MC-GUI slot + copy button. variant "ip" = big gold mono (server address);
|
// MC-GUI slot + copy button. variant "ip" = big gold mono (server address);
|
||||||
// "url" = smaller, wraps (auth / packwiz URLs). Copy handled by the global
|
// "url" = smaller, wraps (auth / authlib URLs). Copy handled by the global
|
||||||
// [data-copy] script in [...lang].astro; data-label / data-copied drive the
|
// [data-copy] script in [...lang].astro (and the equivalent on /fjord);
|
||||||
// (localized) button text and transient feedback.
|
// data-label / data-copied drive the (localized) button text + feedback.
|
||||||
interface Props {
|
interface Props {
|
||||||
value: string;
|
value: string;
|
||||||
variant?: "ip" | "url";
|
variant?: "ip" | "url";
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
---
|
||||||
|
// Installed-pack mod list (the "Mods" section). Build-time baked from the
|
||||||
|
// auto-generated catalogue (tooling/build-modlist.py → src/data/mods.json),
|
||||||
|
// loaded via ./data/mods.ts (resilient: missing file → empty list, build never
|
||||||
|
// fails). Flat alphabetical grid: extracted logo (or a pixel placeholder) +
|
||||||
|
// pretty name + version. description is exposed as a tooltip. No categories, no
|
||||||
|
// external links. Tiles share the MC-GUI bevel treatment used by .feat.
|
||||||
|
import { MODS } from "../data/mods";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
emptyLabel?: string;
|
||||||
|
}
|
||||||
|
const { emptyLabel = "Mod list not generated yet." } = Astro.props;
|
||||||
|
|
||||||
|
const mods = MODS.mods;
|
||||||
|
---
|
||||||
|
{mods.length === 0 ? (
|
||||||
|
<p class="mods-empty">{emptyLabel}</p>
|
||||||
|
) : (
|
||||||
|
<div class="mod-grid">
|
||||||
|
{mods.map((m) => (
|
||||||
|
<div class="mod" title={m.description || m.name}>
|
||||||
|
<div class="mod-logo">
|
||||||
|
{m.logo ? (
|
||||||
|
<img src={m.logo} alt="" width="40" height="40" loading="lazy" />
|
||||||
|
) : (
|
||||||
|
<span class="mod-noicon" aria-hidden="true">{m.name.slice(0, 1).toUpperCase()}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div class="mod-meta">
|
||||||
|
<span class="mod-name">{m.name}</span>
|
||||||
|
{m.version && <span class="mod-ver">{m.version}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.mod-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.mod {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: inset 2px 2px 0 var(--bevel-hi), inset -2px -2px 0 var(--bevel-lo);
|
||||||
|
transition: transform .14s ease, filter .14s ease;
|
||||||
|
}
|
||||||
|
.mod:hover { transform: translateY(-2px); filter: brightness(1.06); }
|
||||||
|
.mod-logo {
|
||||||
|
width: 40px; height: 40px; flex-shrink: 0;
|
||||||
|
display: grid; place-items: center;
|
||||||
|
background: var(--bg-2);
|
||||||
|
box-shadow: inset 1px 1px 0 var(--bevel-lo), inset -1px -1px 0 var(--bevel-hi);
|
||||||
|
}
|
||||||
|
.mod-logo img { width: 40px; height: 40px; image-rendering: pixelated; }
|
||||||
|
.mod-noicon {
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 20px;
|
||||||
|
color: var(--accent);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.mod-meta { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.mod-name {
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--text);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.mod-ver {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--dim);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.mods-empty { margin: 0; color: var(--dim); font-size: 14px; }
|
||||||
|
</style>
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
---
|
---
|
||||||
// 7x7 feature glyphs — styled by .picon in main.css.
|
// Feature icon. Default: a 7x7 procedural glyph (styled by .picon in main.css).
|
||||||
|
// Optional: pass `img` (an absolute path like "/icons/foo.png|svg" under
|
||||||
|
// landing/public/) to render a custom image instead of a glyph.
|
||||||
interface Props {
|
interface Props {
|
||||||
glyph: "shield" | "diamond" | "orb" | "heart";
|
glyph: "shield" | "diamond" | "orb" | "heart";
|
||||||
gold?: boolean;
|
gold?: boolean;
|
||||||
|
img?: string; // custom icon path in /public/icons/ — overrides the glyph
|
||||||
}
|
}
|
||||||
const { glyph, gold = false } = Astro.props;
|
const { glyph, gold = false, img } = Astro.props;
|
||||||
|
|
||||||
const GLYPHS: Record<Props["glyph"], string[]> = {
|
const GLYPHS: Record<Props["glyph"], string[]> = {
|
||||||
shield: ["0111110", "1111111", "1111111", "1111111", "0111110", "0011100", "0001000"],
|
shield: ["0111110", "1111111", "1111111", "1111111", "0111110", "0011100", "0001000"],
|
||||||
@@ -15,6 +18,10 @@ const GLYPHS: Record<Props["glyph"], string[]> = {
|
|||||||
const cells = GLYPHS[glyph].join("").split("");
|
const cells = GLYPHS[glyph].join("").split("");
|
||||||
const onClass = gold ? "go" : "on";
|
const onClass = gold ? "go" : "on";
|
||||||
---
|
---
|
||||||
<div class="picon" aria-hidden="true">
|
{img ? (
|
||||||
{cells.map((c) => <i class={c === "1" ? onClass : undefined} />)}
|
<img class="picon-img" src={img} alt="" aria-hidden="true" width="52" height="52" />
|
||||||
</div>
|
) : (
|
||||||
|
<div class="picon" aria-hidden="true">
|
||||||
|
{cells.map((c) => <i class={c === "1" ? onClass : undefined} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
---
|
||||||
|
// Registered-players roster (the "Members" section). Progressive enhancement,
|
||||||
|
// same shape as ServerCard:
|
||||||
|
// SSG → renders an empty skeleton (a single .roster grid + an empty-state
|
||||||
|
// line) so the page is valid with JS off.
|
||||||
|
// Client → fetches OUR mc-status roster endpoint (site.status.rosterApi,
|
||||||
|
// same-origin via caddy /api/mcstatus/* → internal /players). It
|
||||||
|
// returns a sanitized [{uuid,name}] of every Drasl player (admin login
|
||||||
|
// is server-side in mc-status — no admin token reaches the page). Each
|
||||||
|
// player renders as a SHARED avatar tile (.roster-tile, styled in
|
||||||
|
// main.css alongside ServerCard's online tiles).
|
||||||
|
//
|
||||||
|
// Empty/error → hide the grid and show the "no members yet" line (data-empty).
|
||||||
|
import { site } from "../data/site";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
// Empty-state copy (passed from the page for i18n; English default).
|
||||||
|
emptyLabel?: string;
|
||||||
|
}
|
||||||
|
const { emptyLabel = "No members yet." } = Astro.props;
|
||||||
|
|
||||||
|
const { status, avatarUrl, rosterAvatarMode } = site;
|
||||||
|
---
|
||||||
|
<div
|
||||||
|
class="roster-block"
|
||||||
|
data-api={status.rosterApi}
|
||||||
|
data-avatar={avatarUrl}
|
||||||
|
data-mode={rosterAvatarMode}
|
||||||
|
data-empty={emptyLabel}
|
||||||
|
>
|
||||||
|
<div class="roster" aria-label="Registered players"></div>
|
||||||
|
<p class="roster-empty" hidden></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function hydrate(el: HTMLElement) {
|
||||||
|
const { api, avatar, mode, empty } = el.dataset;
|
||||||
|
if (!api || !avatar || !mode) return;
|
||||||
|
|
||||||
|
const grid = el.querySelector<HTMLElement>(".roster");
|
||||||
|
const emptyEl = el.querySelector<HTMLElement>(".roster-empty");
|
||||||
|
if (!grid) return;
|
||||||
|
|
||||||
|
const showEmpty = () => {
|
||||||
|
grid.hidden = true;
|
||||||
|
if (emptyEl) {
|
||||||
|
emptyEl.textContent = empty ?? "No members yet.";
|
||||||
|
emptyEl.hidden = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let data: Array<{ uuid?: string; name?: string }>;
|
||||||
|
try {
|
||||||
|
const r = await fetch(api, { headers: { Accept: "application/json" } });
|
||||||
|
if (!r.ok) return showEmpty();
|
||||||
|
data = await r.json();
|
||||||
|
} catch {
|
||||||
|
return showEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop the service `admin` account from the public members grid.
|
||||||
|
const players = Array.isArray(data)
|
||||||
|
? data.filter((p) => p && p.uuid && p.name?.toLowerCase() !== "admin")
|
||||||
|
: [];
|
||||||
|
if (players.length === 0) return showEmpty();
|
||||||
|
|
||||||
|
grid.innerHTML = "";
|
||||||
|
for (const p of players) {
|
||||||
|
grid.appendChild(rosterTile(p.uuid!, p.name ?? "", avatar, mode));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared avatar tile (avatar + name) — identical markup to ServerCard's
|
||||||
|
// online tiles, so .roster / .roster-tile (global, main.css) style both.
|
||||||
|
function rosterTile(uuid: string, name: string, avatar: string, mode: string) {
|
||||||
|
const tile = document.createElement("div");
|
||||||
|
tile.className = "roster-tile";
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.loading = "lazy";
|
||||||
|
img.alt = name;
|
||||||
|
img.src = `${avatar}/${mode}/${uuid}?size=256`;
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.className = "roster-name";
|
||||||
|
label.textContent = name;
|
||||||
|
label.title = name;
|
||||||
|
tile.append(img, label);
|
||||||
|
return tile;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll<HTMLElement>(".roster-block").forEach(hydrate);
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
---
|
||||||
|
// Featured LIVE server card. Progressive enhancement:
|
||||||
|
// SSG → renders a static skeleton (server icon, MOTD, slot cap) identical in
|
||||||
|
// spirit to the old ServerListPanel, so it looks right with JS off or
|
||||||
|
// if the status API is unreachable.
|
||||||
|
// Client → fetches mcstatus.io (CORS:*, no key, 60s cache) for the public
|
||||||
|
// address and fills in: live online/offline pill, players online/max +
|
||||||
|
// fill bar, the real server favicon, color MOTD, and a row of player
|
||||||
|
// HEADS rendered by OUR NMSR from Drasl skins (not Mojang/Crafatar).
|
||||||
|
//
|
||||||
|
// Live labels are passed as data-* so copy stays translatable from the page
|
||||||
|
// (defaults are English). Heads use /headiso. Promo/placeholder players with
|
||||||
|
// the all-zero UUID are filtered out.
|
||||||
|
import { site } from "../data/site";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
// SSG fallback copy (same props the old ServerListPanel took).
|
||||||
|
modded: string;
|
||||||
|
motd: string;
|
||||||
|
upTo: string;
|
||||||
|
// Live labels (optional, English defaults). Pass t.* from the page to i18n.
|
||||||
|
onlineLabel?: string;
|
||||||
|
offlineLabel?: string;
|
||||||
|
emptyLabel?: string;
|
||||||
|
}
|
||||||
|
const {
|
||||||
|
modded,
|
||||||
|
motd,
|
||||||
|
upTo,
|
||||||
|
onlineLabel = "Online",
|
||||||
|
offlineLabel = "Offline",
|
||||||
|
emptyLabel = "Nobody online",
|
||||||
|
} = Astro.props;
|
||||||
|
|
||||||
|
const { status, avatarUrl, avatarMode } = site;
|
||||||
|
---
|
||||||
|
<div
|
||||||
|
class="servercard"
|
||||||
|
data-api={status.statusApi}
|
||||||
|
data-avatar={avatarUrl}
|
||||||
|
data-mode={avatarMode}
|
||||||
|
data-slots={status.slots}
|
||||||
|
data-online={onlineLabel}
|
||||||
|
data-offline={offlineLabel}
|
||||||
|
data-empty={emptyLabel}
|
||||||
|
>
|
||||||
|
<div class="sc-top">
|
||||||
|
<div class="sc-icon">
|
||||||
|
<!-- Replaced by the real server favicon on success; Ulicraft logo otherwise. -->
|
||||||
|
<img class="sc-favicon" alt="" hidden />
|
||||||
|
<span class="sc-creeper"><img src="/ulicraft-logo-mini.svg" alt="" width="56" height="56" /></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sc-meta">
|
||||||
|
<div class="sc-row1">
|
||||||
|
<span class="sc-title">{site.name}</span>
|
||||||
|
<span class="sc-ver">Java {site.mcVersion}</span>
|
||||||
|
</div>
|
||||||
|
<p class="sc-motd"><span class="a">⛏ {modded}</span> · <span class="sc-motd-text">{motd}</span></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sc-state">
|
||||||
|
<span class="sc-pill" data-up="">
|
||||||
|
<span class="sc-dot"></span>
|
||||||
|
<span class="sc-pill-txt">·</span>
|
||||||
|
</span>
|
||||||
|
<div class="sc-count">
|
||||||
|
<span class="sc-on">{upTo}</span> <b class="sc-max">{status.slots}</b>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Player fill bar (online/max). Hidden until a live count arrives. -->
|
||||||
|
<div class="sc-bar" hidden><span class="sc-bar-fill"></span></div>
|
||||||
|
|
||||||
|
<!-- Avatar tiles + empty state. Populated client-side. -->
|
||||||
|
<div class="sc-players" hidden>
|
||||||
|
<div class="roster" aria-label="Players online"></div>
|
||||||
|
<p class="sc-empty" hidden></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.servercard {
|
||||||
|
background: var(--slot);
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
box-shadow:
|
||||||
|
inset 2px 2px 0 var(--bevel-hi),
|
||||||
|
inset -2px -2px 0 var(--bevel-lo),
|
||||||
|
0 8px 24px oklch(0 0 0 / 0.4);
|
||||||
|
}
|
||||||
|
.sc-top { display: flex; gap: 18px; align-items: center; }
|
||||||
|
|
||||||
|
.sc-icon {
|
||||||
|
width: 72px; height: 72px; flex-shrink: 0;
|
||||||
|
display: grid; place-items: center; position: relative;
|
||||||
|
background: var(--bg-2);
|
||||||
|
}
|
||||||
|
.sc-favicon { width: 64px; height: 64px; image-rendering: pixelated; }
|
||||||
|
.sc-creeper img { width: 56px; height: 56px; }
|
||||||
|
|
||||||
|
.sc-meta { flex: 1; min-width: 0; }
|
||||||
|
.sc-row1 { display: flex; align-items: baseline; gap: 12px; }
|
||||||
|
.sc-title { font-family: var(--font-head); font-weight: 600; font-size: 22px; white-space: nowrap; }
|
||||||
|
.sc-ver { color: var(--dim); font-family: var(--font-mono); font-size: 17px; white-space: nowrap; }
|
||||||
|
.sc-motd { margin: 6px 0 0; color: var(--muted); font-size: 15px; }
|
||||||
|
.sc-motd .a { color: var(--accent); }
|
||||||
|
/* mcstatus.io motd.html ships inline color styles; keep its spans inline. */
|
||||||
|
.sc-motd-text :global(span) { display: inline; }
|
||||||
|
|
||||||
|
.sc-state { text-align: right; flex-shrink: 0; display: flex; flex-direction: column; align-items: flex-end; gap: 6px; }
|
||||||
|
.sc-pill {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
font-size: 11px; font-family: var(--font-pixel, var(--font-mono));
|
||||||
|
letter-spacing: 0.04em; text-transform: uppercase;
|
||||||
|
padding: 3px 8px; color: var(--dim);
|
||||||
|
box-shadow: inset 1px 1px 0 var(--bevel-hi), inset -1px -1px 0 var(--bevel-lo);
|
||||||
|
}
|
||||||
|
.sc-dot { width: 9px; height: 9px; background: var(--dim); image-rendering: pixelated; }
|
||||||
|
.sc-pill[data-up="1"] { color: var(--accent-hi); }
|
||||||
|
.sc-pill[data-up="1"] .sc-dot { background: var(--accent); box-shadow: 0 0 6px var(--glow); }
|
||||||
|
.sc-pill[data-up="0"] { color: oklch(0.65 0.18 25); }
|
||||||
|
.sc-pill[data-up="0"] .sc-dot { background: oklch(0.62 0.2 25); }
|
||||||
|
.sc-count { font-variant-numeric: tabular-nums; font-size: 15px; color: var(--muted); }
|
||||||
|
.sc-count b { color: var(--text); }
|
||||||
|
|
||||||
|
.sc-bar { height: 6px; background: var(--bg-2); box-shadow: inset 1px 1px 0 var(--bevel-lo); }
|
||||||
|
.sc-bar-fill { display: block; height: 100%; width: 0; background: var(--accent); transition: width 0.6s ease; }
|
||||||
|
|
||||||
|
/* Online avatar tiles use the shared .roster / .roster-tile styles in
|
||||||
|
main.css (also used by PlayerRoster.astro). */
|
||||||
|
.sc-empty { margin: 0; color: var(--dim); font-size: 13px; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const ZERO_UUID = "00000000-0000-0000-0000-000000000000";
|
||||||
|
|
||||||
|
async function hydrate(el: HTMLElement) {
|
||||||
|
const { api, avatar, mode, slots, online, offline, empty } = el.dataset;
|
||||||
|
if (!api || !avatar || !mode) return;
|
||||||
|
|
||||||
|
const q = <T extends HTMLElement>(s: string) => el.querySelector<T>(s);
|
||||||
|
const pill = q(".sc-pill");
|
||||||
|
const pillTxt = q(".sc-pill-txt");
|
||||||
|
const favicon = q<HTMLImageElement>(".sc-favicon");
|
||||||
|
const creeper = q(".sc-creeper");
|
||||||
|
const onEl = q(".sc-on");
|
||||||
|
const maxEl = q(".sc-max");
|
||||||
|
const motdEl = q(".sc-motd-text");
|
||||||
|
const bar = q(".sc-bar");
|
||||||
|
const fill = q<HTMLElement>(".sc-bar-fill");
|
||||||
|
const players = q(".sc-players");
|
||||||
|
const roster = q(".roster");
|
||||||
|
const emptyEl = q(".sc-empty");
|
||||||
|
|
||||||
|
let data: any;
|
||||||
|
try {
|
||||||
|
const r = await fetch(api, { headers: { Accept: "application/json" } });
|
||||||
|
if (!r.ok) return; // keep SSG skeleton
|
||||||
|
data = await r.json();
|
||||||
|
} catch {
|
||||||
|
return; // offline/network → keep SSG skeleton
|
||||||
|
}
|
||||||
|
|
||||||
|
const up = data.online === true;
|
||||||
|
pill?.setAttribute("data-up", up ? "1" : "0");
|
||||||
|
if (pillTxt) pillTxt.textContent = up ? (online ?? "Online") : (offline ?? "Offline");
|
||||||
|
|
||||||
|
if (!up) return; // offline pill set; leave the rest as the static fallback
|
||||||
|
|
||||||
|
// Live count + bar.
|
||||||
|
const on = data.players?.online ?? 0;
|
||||||
|
const max = data.players?.max ?? (Number(slots) || 0);
|
||||||
|
if (onEl) onEl.textContent = String(on);
|
||||||
|
if (maxEl) maxEl.textContent = String(max);
|
||||||
|
if (bar && fill) {
|
||||||
|
bar.hidden = false;
|
||||||
|
fill.style.width = max > 0 ? `${Math.min(100, (on / max) * 100)}%` : "0%";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Real server favicon.
|
||||||
|
if (favicon && typeof data.icon === "string" && data.icon.startsWith("data:image")) {
|
||||||
|
favicon.src = data.icon;
|
||||||
|
favicon.hidden = false;
|
||||||
|
creeper?.setAttribute("hidden", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Color MOTD (mcstatus ships inline-styled spans for our own server).
|
||||||
|
if (motdEl && data.motd?.html) motdEl.innerHTML = data.motd.html;
|
||||||
|
|
||||||
|
// Labeled avatar tiles from OUR NMSR (Drasl skins). Filter promo/placeholder
|
||||||
|
// rows (all-zero UUID) and dedupe. Tiles share .roster styles with the
|
||||||
|
// Members grid (PlayerRoster.astro) — see rosterTile() below.
|
||||||
|
const list: Array<{ uuid?: string; name_clean?: string }> = data.players?.list ?? [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const real = list.filter((p) => {
|
||||||
|
const u = (p.uuid ?? "").toLowerCase();
|
||||||
|
if (!u || u === ZERO_UUID || seen.has(u)) return false;
|
||||||
|
seen.add(u);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (players && roster) {
|
||||||
|
players.hidden = false;
|
||||||
|
if (real.length === 0) {
|
||||||
|
if (emptyEl) {
|
||||||
|
emptyEl.textContent = empty ?? "Nobody online";
|
||||||
|
emptyEl.hidden = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
roster.innerHTML = "";
|
||||||
|
for (const p of real.slice(0, 16)) {
|
||||||
|
roster.appendChild(rosterTile(p.uuid!, p.name_clean ?? "", avatar, mode));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build one shared avatar tile (avatar + name). Same markup PlayerRoster
|
||||||
|
// builds, so .roster / .roster-tile (global, in main.css) style both.
|
||||||
|
function rosterTile(uuid: string, name: string, avatar: string, mode: string) {
|
||||||
|
const tile = document.createElement("div");
|
||||||
|
tile.className = "roster-tile";
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.loading = "lazy";
|
||||||
|
img.alt = name;
|
||||||
|
img.src = `${avatar}/${mode}/${uuid}?size=64`;
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.className = "roster-name";
|
||||||
|
label.textContent = name;
|
||||||
|
label.title = name;
|
||||||
|
tile.append(img, label);
|
||||||
|
return tile;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll<HTMLElement>(".servercard").forEach(hydrate);
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"productName": "Ulicraft",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"baseUrl": "https://distribution.ulicraft.net/launcher",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"name": "Ulicraft-1.0.0.exe",
|
||||||
|
"os": "windows",
|
||||||
|
"arch": "x64",
|
||||||
|
"ext": "exe",
|
||||||
|
"size": 90893289,
|
||||||
|
"url": "https://distribution.ulicraft.net/launcher/Ulicraft-1.0.0.exe"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Ulicraft-1.0.0.AppImage",
|
||||||
|
"os": "linux",
|
||||||
|
"arch": "x64",
|
||||||
|
"ext": "AppImage",
|
||||||
|
"size": 95722608,
|
||||||
|
"url": "https://distribution.ulicraft.net/launcher/Ulicraft-1.0.0.AppImage"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Ulicraft-1.0.0.deb",
|
||||||
|
"os": "linux",
|
||||||
|
"arch": "x64",
|
||||||
|
"ext": "deb",
|
||||||
|
"size": 87751876,
|
||||||
|
"url": "https://distribution.ulicraft.net/launcher/Ulicraft-1.0.0.deb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Ulicraft-1.0.0.tar.gz",
|
||||||
|
"os": "linux",
|
||||||
|
"arch": "x64",
|
||||||
|
"ext": "tar.gz",
|
||||||
|
"size": 116347746,
|
||||||
|
"url": "https://distribution.ulicraft.net/launcher/Ulicraft-1.0.0.tar.gz"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"count": 0,
|
||||||
|
"mods": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// Build-time loader for the auto-generated mod catalogue. tooling/build-modlist.py
|
||||||
|
// writes src/data/mods.json (gitignored, generated) from the distribution's full
|
||||||
|
// client modset; it MAY NOT EXIST on a fresh checkout, so we read it at runtime
|
||||||
|
// with fs (a static `import` would hard-fail the build when absent). Fall back to
|
||||||
|
// the committed .example.json (an empty catalogue); if even that is unreadable,
|
||||||
|
// expose an empty catalogue so the page simply renders no mods and the stat tile
|
||||||
|
// keeps its default. Same pattern as loadLauncherManifest in site.ts.
|
||||||
|
// @ts-ignore — node builtins (no @types/node in this Astro SSG; same reason
|
||||||
|
// `process`/`import.meta.url` reads work at build time, as in site.ts).
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
export type Mod = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
authors: string;
|
||||||
|
description: string;
|
||||||
|
logo: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModCatalogue = {
|
||||||
|
schemaVersion: number;
|
||||||
|
count: number;
|
||||||
|
mods: Mod[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function loadMods(): ModCatalogue {
|
||||||
|
const empty: ModCatalogue = { schemaVersion: 1, count: 0, mods: [] };
|
||||||
|
// Resolve relative to this module first (works when import.meta.url points at
|
||||||
|
// the source dir), then fall back to a cwd-relative path (Astro runs the build
|
||||||
|
// from the landing root, so src/data/* resolves there even when the module has
|
||||||
|
// been bundled to a different location). Prefer the generated mods.json, then
|
||||||
|
// the committed empty mods.example.json.
|
||||||
|
// @ts-ignore — `process` is untyped here (no @types/node), same as site.ts.
|
||||||
|
const cwd: string = typeof process !== "undefined" ? process.cwd() : ".";
|
||||||
|
const candidates: Array<string | URL> = [];
|
||||||
|
for (const name of ["mods.json", "mods.example.json"]) {
|
||||||
|
try {
|
||||||
|
candidates.push(new URL(name, import.meta.url));
|
||||||
|
} catch {
|
||||||
|
// import.meta.url unavailable — skip the module-relative candidate.
|
||||||
|
}
|
||||||
|
candidates.push(`${cwd}/src/data/${name}`);
|
||||||
|
}
|
||||||
|
for (const c of candidates) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(readFileSync(c, "utf8")) as ModCatalogue;
|
||||||
|
if (parsed && Array.isArray(parsed.mods)) return parsed;
|
||||||
|
} catch {
|
||||||
|
// try the next candidate (file missing/unreadable → fall back)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MODS = loadMods();
|
||||||
+160
-25
@@ -2,8 +2,114 @@
|
|||||||
// domains, URLs, theme, launcher files, and the structural shape of the
|
// domains, URLs, theme, launcher files, and the structural shape of the
|
||||||
// stat/feature lists. Translatable copy lives in src/i18n/ui.ts.
|
// stat/feature lists. Translatable copy lives in src/i18n/ui.ts.
|
||||||
// BASE_DOMAIN is read at build time; falls back to ulicraft.local.
|
// BASE_DOMAIN is read at build time; falls back to ulicraft.local.
|
||||||
|
// @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.
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
const BASE_DOMAIN = process.env.BASE_DOMAIN ?? "ulicraft.local";
|
const BASE_DOMAIN = process.env.BASE_DOMAIN ?? "ulicraft.local";
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// default). Read at build time, same as BASE_DOMAIN. e.g. "headiso" | "head" |
|
||||||
|
// "fullbody" | "fullbodyiso".
|
||||||
|
// @ts-ignore — `process` is untyped here (no @types/node), same as BASE_DOMAIN.
|
||||||
|
const AVATAR_MODE = process.env.AVATAR_MODE ?? "headiso";
|
||||||
|
// @ts-ignore — `process` is untyped here (no @types/node).
|
||||||
|
const ROSTER_AVATAR_MODE = process.env.ROSTER_AVATAR_MODE ?? "fullbodyiso";
|
||||||
|
|
||||||
|
// UlicraftLauncher download list. Source of truth is the custom-launcher repo's
|
||||||
|
// build output `launcher.json` (shape: productName/version/baseUrl/files[]),
|
||||||
|
// published to https://distribution.${BASE}/launcher/launcher.json. The release
|
||||||
|
// flow syncs that file into this dir as `launcher.json` (gitignored, generated)
|
||||||
|
// — same pattern as sync-server-mods.sh. It MAY NOT EXIST at build time, so we
|
||||||
|
// read it at runtime with fs (a static `import` would hard-fail the build when
|
||||||
|
// absent). Fall back to the committed `launcher.example.json` (the documented
|
||||||
|
// shape); if even that is unreadable, expose an empty file list so the homepage
|
||||||
|
// simply renders no download buttons. The raw launcher.json is normalized into
|
||||||
|
// the internal shape the page renders against (builds[]).
|
||||||
|
type LauncherBuild = {
|
||||||
|
os: string; // "windows" | "macos" | "linux"
|
||||||
|
arch: string; // "x64" | "arm64" | "universal"
|
||||||
|
format: string; // file extension: "exe" | "dmg" | "AppImage" | "deb" | "tar.gz" | …
|
||||||
|
filename: string;
|
||||||
|
url: string;
|
||||||
|
size?: number;
|
||||||
|
};
|
||||||
|
type LauncherFile = { filename: string; url: string; size?: number };
|
||||||
|
type LauncherManifest = {
|
||||||
|
product: string;
|
||||||
|
version: string;
|
||||||
|
builds: LauncherBuild[];
|
||||||
|
// Not present in launcher.json; the Fjord page degrades gracefully when absent.
|
||||||
|
fjordPack?: LauncherFile;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Raw shape as produced by the custom-launcher build (UlicraftLauncher/dist/launcher.json).
|
||||||
|
type RawLauncherJson = {
|
||||||
|
productName?: string;
|
||||||
|
version?: string;
|
||||||
|
baseUrl?: string;
|
||||||
|
files?: Array<{ name: string; os: string; arch: string; ext: string; url: string; size?: number }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeLauncher(raw: RawLauncherJson): LauncherManifest {
|
||||||
|
return {
|
||||||
|
product: raw.productName ?? "UlicraftLauncher",
|
||||||
|
version: raw.version ?? "0.0.0",
|
||||||
|
builds: (raw.files ?? []).map((f) => ({
|
||||||
|
os: f.os,
|
||||||
|
arch: f.arch,
|
||||||
|
format: f.ext,
|
||||||
|
filename: f.name,
|
||||||
|
url: f.url,
|
||||||
|
size: f.size,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadLauncherManifest(): LauncherManifest {
|
||||||
|
const empty: LauncherManifest = { product: "UlicraftLauncher", version: "0.0.0", builds: [] };
|
||||||
|
// Resolve relative to this module first, then fall back to a cwd-relative path:
|
||||||
|
// Astro bundles this module so import.meta.url may not point at src/data/ at
|
||||||
|
// build time, but the build runs from the landing root, so <cwd>/src/data/*
|
||||||
|
// resolves. Prefer the synced launcher.json, then the committed example.
|
||||||
|
// @ts-ignore — `process` is untyped here (no @types/node).
|
||||||
|
const cwd: string = typeof process !== "undefined" ? process.cwd() : ".";
|
||||||
|
const candidates: Array<string | URL> = [];
|
||||||
|
for (const name of ["launcher.json", "launcher.example.json"]) {
|
||||||
|
try {
|
||||||
|
candidates.push(new URL(name, import.meta.url));
|
||||||
|
} catch {
|
||||||
|
// import.meta.url unavailable — skip the module-relative candidate.
|
||||||
|
}
|
||||||
|
candidates.push(`${cwd}/src/data/${name}`);
|
||||||
|
}
|
||||||
|
for (const c of candidates) {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(readFileSync(c, "utf8")) as RawLauncherJson;
|
||||||
|
if (raw && Array.isArray(raw.files)) return normalizeLauncher(raw);
|
||||||
|
} catch {
|
||||||
|
// try the next candidate (synced file missing → fall back to example)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LAUNCHER_MANIFEST = loadLauncherManifest();
|
||||||
|
|
||||||
|
// REGISTRATION_MODE (invite|open) is read at build time, same .env as Drasl's
|
||||||
|
// render-config.sh. "invite" => the register form must collect an invite code.
|
||||||
|
const REGISTRATION_MODE = process.env.REGISTRATION_MODE ?? "invite";
|
||||||
|
|
||||||
|
// REGISTRATION_SHOW_INVITE (true|false, default false=hidden) ONLY toggles the
|
||||||
|
// visibility of the invite-code field on the register form. The backend invite
|
||||||
|
// gate stays on Drasl's RequireInvite (driven by REGISTRATION_MODE). Decoupled on
|
||||||
|
// purpose: invites are usually admin-minted and the field is hidden by default.
|
||||||
|
// CAVEAT: if REGISTRATION_MODE=invite (backend requires a code) but this is
|
||||||
|
// false, public self-registration can't supply one — set both together.
|
||||||
|
// @ts-ignore — `process` is untyped here (no @types/node).
|
||||||
|
const REGISTRATION_SHOW_INVITE = process.env.REGISTRATION_SHOW_INVITE ?? "false";
|
||||||
|
|
||||||
// Minecraft / pack facts, surfaced in a few places.
|
// Minecraft / pack facts, surfaced in a few places.
|
||||||
const MC_VERSION = "1.21.1";
|
const MC_VERSION = "1.21.1";
|
||||||
|
|
||||||
@@ -12,47 +118,69 @@ export const site = {
|
|||||||
baseDomain: BASE_DOMAIN,
|
baseDomain: BASE_DOMAIN,
|
||||||
mcVersion: MC_VERSION,
|
mcVersion: MC_VERSION,
|
||||||
|
|
||||||
|
// Register form behaviour, driven by REGISTRATION_MODE in .env.
|
||||||
|
// true => Drasl RequireInvite = true (backend gate)
|
||||||
|
// false => open self-registration
|
||||||
|
registrationRequiresInvite: REGISTRATION_MODE === "invite",
|
||||||
|
// Whether the invite-code FIELD is shown on the register form (default false).
|
||||||
|
// Independent of the backend gate above — see REGISTRATION_SHOW_INVITE note.
|
||||||
|
registrationShowInvite: REGISTRATION_SHOW_INVITE === "true",
|
||||||
|
|
||||||
// Design knobs (replace the design's in-browser TweaksPanel). Baked at build.
|
// Design knobs (replace the design's in-browser TweaksPanel). Baked at build.
|
||||||
// mood: "grass" | "nether" | "end"
|
// mood: "grass" | "nether" | "end"
|
||||||
// hero: "centered" | "split" | "spotlight"
|
// hero: "centered" | "split" | "spotlight"
|
||||||
// headFont: "pixelify" | "8bit" | "silkscreen"
|
// headFont: "blockblueprint" | "pixelify" | "8bit" | "silkscreen"
|
||||||
// dust: floating pixel particles in the hero
|
// dust: floating pixel particles in the hero
|
||||||
theme: {
|
theme: {
|
||||||
mood: "grass",
|
mood: "grass",
|
||||||
hero: "centered",
|
hero: "centered",
|
||||||
headFont: "pixelify",
|
headFont: "blockblueprint",
|
||||||
dust: true,
|
dust: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Connect target shown to guests. No port needed — a dnsmasq SRV record
|
// Connect target shown to guests. No port needed (defaults to 25565).
|
||||||
// (_minecraft._tcp.mc.<domain>) points clients at 25565.
|
serverAddress: `${BASE_DOMAIN}`,
|
||||||
serverAddress: `mc.${BASE_DOMAIN}`,
|
|
||||||
|
|
||||||
// Drasl auth web UI + authlib-injector API root.
|
// Drasl auth web UI + authlib-injector API root (public HTTPS).
|
||||||
authUrl: `http://auth.${BASE_DOMAIN}`,
|
authUrl: `https://auth.${BASE_DOMAIN}`,
|
||||||
authlibUrl: `http://auth.${BASE_DOMAIN}/authlib-injector`,
|
authlibUrl: `https://auth.${BASE_DOMAIN}/authlib-injector`,
|
||||||
|
// Drasl REST API v2 root — the /register form calls this from the browser.
|
||||||
|
// Requires CORSAllowOrigins to include the apex in drasl config.toml.
|
||||||
|
draslApiUrl: `https://auth.${BASE_DOMAIN}/drasl/api/v2`,
|
||||||
|
|
||||||
// packwiz modpack entrypoint.
|
// NMSR avatar renderer root. Player heads come from OUR Drasl skins, not
|
||||||
packwizUrl: `http://packwiz.${BASE_DOMAIN}/pack.toml`,
|
// Mojang/Crafatar — head URL is `${avatarUrl}/headiso/<uuid>?size=64`.
|
||||||
|
avatarUrl: `https://avatar.${BASE_DOMAIN}`,
|
||||||
|
|
||||||
// Caddy local CA root — guests import this to trust the offline asset mirror.
|
// NMSR render mode for avatar tiles. Avatar URL = `${avatarUrl}/<mode>/<uuid>?size=N`.
|
||||||
caCertUrl: `http://${BASE_DOMAIN}/ca.crt`,
|
// avatarMode → ServerCard online row + account preview (heads)
|
||||||
|
// rosterAvatarMode → PlayerRoster members grid (full body)
|
||||||
|
avatarMode: AVATAR_MODE,
|
||||||
|
rosterAvatarMode: ROSTER_AVATAR_MODE,
|
||||||
|
|
||||||
// Static server-list panel (Status section). No fake live count — see plan
|
// Uptime Kuma status page (external link in footer).
|
||||||
// 09-landing.md option B.
|
statusUrl: `https://status.${BASE_DOMAIN}`,
|
||||||
|
|
||||||
|
// Public read-only file share (caddy file_server browse of FILES_DATA_DIR).
|
||||||
|
// Linked from the nav + hero. Uploads live at upload.${BASE_DOMAIN} (auth'd).
|
||||||
|
filesUrl: `https://files.${BASE_DOMAIN}`,
|
||||||
|
|
||||||
|
// Live server card (ServerCard.astro). `slots` is the SSG fallback cap shown
|
||||||
|
// before/without JS; live data comes from our SELF-HOSTED mc-status service
|
||||||
|
// (docker/mc-status, mcutil wrapper) reverse-proxied same-origin at the apex
|
||||||
|
// /api/mcstatus/* path — no CORS, no third-party calls. It emits mcstatus.io
|
||||||
|
// v2 JSON, so the URL stays drop-in compatible. Uptime Kuma stays the history
|
||||||
|
// + alerting backend (plan/10).
|
||||||
status: {
|
status: {
|
||||||
slots: 10,
|
slots: 10,
|
||||||
|
statusApi: `/api/mcstatus/v2/status/java/${BASE_DOMAIN}`,
|
||||||
|
// Registered-players roster (PlayerRoster.astro). Same mc-status service,
|
||||||
|
// same-origin via caddy /api/mcstatus/* → internal /players. Returns a
|
||||||
|
// sanitized [{uuid,name}] of every Drasl player (admin login is server-side
|
||||||
|
// in mc-status; no admin token in the page).
|
||||||
|
rosterApi: `/api/mcstatus/players`,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Honest stat tiles. Keys (numbers/versions) are language-neutral; labels
|
|
||||||
// come from ui.ts (same order).
|
|
||||||
stats: [
|
|
||||||
{ key: "50+" },
|
|
||||||
{ 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: [
|
||||||
@@ -62,10 +190,16 @@ export const site = {
|
|||||||
{ glyph: "heart", gold: true },
|
{ glyph: "heart", gold: true },
|
||||||
],
|
],
|
||||||
|
|
||||||
// Launcher downloads. Stable filenames are symlinks created by
|
// UlicraftLauncher download manifest — feeds the homepage join step 2
|
||||||
|
// per-OS buttons from `builds[]` (and the Fjord page's .mrpack via
|
||||||
|
// `fjordPack`). Synced from the custom-launcher repo; see loader above.
|
||||||
|
launcherManifest: LAUNCHER_MANIFEST,
|
||||||
|
|
||||||
|
// Fjord Launcher downloads (the alternative manual path on /fjord, OFF the
|
||||||
|
// homepage flow). Stable filenames are symlinks created by
|
||||||
// tooling/fetch-launcher.sh — keep these in sync with that script. OS names
|
// tooling/fetch-launcher.sh — keep these in sync with that script. OS names
|
||||||
// and hints are technical, kept language-neutral here.
|
// and hints are technical, kept language-neutral here.
|
||||||
launcher: {
|
fjord: {
|
||||||
name: "Fjord Launcher",
|
name: "Fjord Launcher",
|
||||||
base: "/launcher/latest",
|
base: "/launcher/latest",
|
||||||
downloads: [
|
downloads: [
|
||||||
@@ -86,6 +220,7 @@ export const LITERALS = {
|
|||||||
|
|
||||||
// Head-font CSS stacks, keyed by theme.headFont.
|
// Head-font CSS stacks, keyed by theme.headFont.
|
||||||
export const HEAD_FONTS: Record<string, string> = {
|
export const HEAD_FONTS: Record<string, string> = {
|
||||||
|
blockblueprint: "'Block Blueprint', system-ui, sans-serif",
|
||||||
pixelify: "'Pixelify Sans', system-ui, sans-serif",
|
pixelify: "'Pixelify Sans', system-ui, sans-serif",
|
||||||
"8bit": "'Press Start 2P', monospace",
|
"8bit": "'Press Start 2P', monospace",
|
||||||
silkscreen: "'Silkscreen', system-ui, sans-serif",
|
silkscreen: "'Silkscreen', system-ui, sans-serif",
|
||||||
|
|||||||
+444
-56
@@ -2,7 +2,7 @@
|
|||||||
// locales; the page (src/pages/[...lang].astro) renders against it.
|
// locales; the page (src/pages/[...lang].astro) renders against it.
|
||||||
//
|
//
|
||||||
// Conventions:
|
// Conventions:
|
||||||
// - "{name}" in step1 is replaced with the launcher name at render time.
|
// - "{name}" is replaced with the launcher name at render time.
|
||||||
// - Sentences containing links/kbd/literals are split into parts (a/b/pre/
|
// - Sentences containing links/kbd/literals are split into parts (a/b/pre/
|
||||||
// post/link) so the markup stays in the template, not in the strings.
|
// post/link) so the markup stays in the template, not in the strings.
|
||||||
// - Product / in-game literals (authlib-injector, Multiplayer, Add Server)
|
// - Product / in-game literals (authlib-injector, Multiplayer, Add Server)
|
||||||
@@ -11,21 +11,39 @@
|
|||||||
export type Lang = "en" | "es" | "eu";
|
export type Lang = "en" | "es" | "eu";
|
||||||
export const LANGS: Lang[] = ["en", "es", "eu"];
|
export const LANGS: Lang[] = ["en", "es", "eu"];
|
||||||
export const LANG_LABEL: Record<Lang, string> = { en: "EN", es: "ES", eu: "EU" };
|
export const LANG_LABEL: Record<Lang, string> = { en: "EN", es: "ES", eu: "EU" };
|
||||||
// URL path per locale (en is default, served at root).
|
// URL path per locale (es is default, served at root; en moves to /en/).
|
||||||
export const LANG_PATH: Record<Lang, string> = { en: "/", es: "/es/", eu: "/eu/" };
|
export const LANG_PATH: Record<Lang, string> = { es: "/", en: "/en/", eu: "/eu/" };
|
||||||
|
// Register page path per locale (mirrors LANG_PATH: es at /register).
|
||||||
|
export const LANG_REGISTER_PATH: Record<Lang, string> = {
|
||||||
|
es: "/register",
|
||||||
|
en: "/en/register",
|
||||||
|
eu: "/eu/register",
|
||||||
|
};
|
||||||
|
// Fjord (alternative path) page path per locale (mirrors LANG_REGISTER_PATH).
|
||||||
|
export const LANG_FJORD_PATH: Record<Lang, string> = {
|
||||||
|
es: "/fjord",
|
||||||
|
en: "/en/fjord",
|
||||||
|
eu: "/eu/fjord",
|
||||||
|
};
|
||||||
|
// Account page path per locale (mirrors LANG_REGISTER_PATH).
|
||||||
|
export const LANG_ACCOUNT_PATH: Record<Lang, string> = {
|
||||||
|
es: "/account",
|
||||||
|
en: "/en/account",
|
||||||
|
eu: "/eu/account",
|
||||||
|
};
|
||||||
|
|
||||||
export interface UI {
|
export interface UI {
|
||||||
htmlLang: string;
|
htmlLang: string;
|
||||||
copied: string; // transient copy-button feedback
|
copied: string; // transient copy-button feedback
|
||||||
nav: { status: string; features: string; join: string; play: string };
|
nav: { status: string; members: string; features: string; mods: string; join: string; files: string; play: string; register: string; login: string };
|
||||||
hero: {
|
hero: {
|
||||||
eyebrow: string; // "Modded LAN · NeoForge" — version appended at render
|
eyebrow: string; // "Modded LAN · NeoForge" — version appended at render
|
||||||
tagline: string;
|
tagline: string;
|
||||||
sub: string;
|
sub: string;
|
||||||
copyIp: string;
|
copyIp: string;
|
||||||
howToJoin: string;
|
howToJoin: string;
|
||||||
|
files: string;
|
||||||
metaModpack: string;
|
metaModpack: string;
|
||||||
metaLan: string;
|
|
||||||
};
|
};
|
||||||
status: {
|
status: {
|
||||||
eyebrow: string;
|
eyebrow: string;
|
||||||
@@ -34,15 +52,41 @@ 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
|
||||||
|
};
|
||||||
|
members: {
|
||||||
|
eyebrow: string;
|
||||||
|
title: string;
|
||||||
|
lead: string;
|
||||||
|
empty: string; // shown when the roster is empty or unreachable
|
||||||
};
|
};
|
||||||
statLabels: [string, string, string, string];
|
|
||||||
features: {
|
features: {
|
||||||
eyebrow: string;
|
eyebrow: string;
|
||||||
title: string;
|
title: string;
|
||||||
lead: string;
|
lead: string;
|
||||||
items: { h: string; p: string }[];
|
items: { h: string; p: string }[];
|
||||||
};
|
};
|
||||||
|
mods: {
|
||||||
|
eyebrow: string;
|
||||||
|
title: string;
|
||||||
|
lead: string;
|
||||||
|
empty: string; // shown when the catalogue is absent/empty (not yet generated)
|
||||||
|
};
|
||||||
join: {
|
join: {
|
||||||
|
eyebrow: string;
|
||||||
|
title: string;
|
||||||
|
lead: string;
|
||||||
|
s1: {
|
||||||
|
kicker: string;
|
||||||
|
h: string;
|
||||||
|
pa: string; // before the register link
|
||||||
|
pb: string; // after it
|
||||||
|
registerLink: string;
|
||||||
|
};
|
||||||
|
s2: { kicker: string; h: string; p: string };
|
||||||
|
};
|
||||||
|
fjord: {
|
||||||
|
navHome: string; // "← Home" link in nav
|
||||||
eyebrow: string;
|
eyebrow: string;
|
||||||
title: string;
|
title: string;
|
||||||
lead: string;
|
lead: string;
|
||||||
@@ -53,10 +97,6 @@ export interface UI {
|
|||||||
pa: string; // before the authlib-injector literal
|
pa: string; // before the authlib-injector literal
|
||||||
pb: string; // after it
|
pb: string; // after it
|
||||||
copy: string;
|
copy: string;
|
||||||
registerPre: string;
|
|
||||||
caPre: string;
|
|
||||||
caLink: string;
|
|
||||||
caPost: string;
|
|
||||||
};
|
};
|
||||||
s3: { kicker: string; h: string; p: string };
|
s3: { kicker: string; h: string; p: string };
|
||||||
s4: {
|
s4: {
|
||||||
@@ -66,26 +106,95 @@ export interface UI {
|
|||||||
pb: string; // after them
|
pb: string; // after them
|
||||||
copy: string;
|
copy: string;
|
||||||
};
|
};
|
||||||
|
noPack: string; // shown when fjordPack is missing from the manifest
|
||||||
|
};
|
||||||
|
footer: { join: string; status: string; disc: string };
|
||||||
|
register: {
|
||||||
|
navHome: string; // "← Home" link in nav
|
||||||
|
eyebrow: string;
|
||||||
|
title: string;
|
||||||
|
lead: string;
|
||||||
|
usernameLabel: string;
|
||||||
|
usernamePlaceholder: string;
|
||||||
|
passwordLabel: string;
|
||||||
|
passwordPlaceholder: string;
|
||||||
|
inviteLabel: string;
|
||||||
|
invitePlaceholder: string;
|
||||||
|
inviteHint: string; // where to get a code
|
||||||
|
submit: string;
|
||||||
|
submitting: string;
|
||||||
|
haveAccount: string; // "Already have an account?"
|
||||||
|
loginLink: string; // links to the account page
|
||||||
|
successTitle: string;
|
||||||
|
successBody: string; // {name} = player name
|
||||||
|
uuidLabel: string;
|
||||||
|
skinTitle: string;
|
||||||
|
skinLead: string;
|
||||||
|
skinChoose: string;
|
||||||
|
modelLabel: string;
|
||||||
|
modelClassic: string;
|
||||||
|
modelSlim: string;
|
||||||
|
skinUpload: string;
|
||||||
|
skinUploading: string;
|
||||||
|
skinOk: string;
|
||||||
|
finish: string; // proceed to How to Join
|
||||||
|
errFields: string;
|
||||||
|
errSkinType: string;
|
||||||
|
errNetwork: string;
|
||||||
|
};
|
||||||
|
account: {
|
||||||
|
navHome: string; // "← Home" link in nav
|
||||||
|
eyebrow: string;
|
||||||
|
title: string;
|
||||||
|
lead: string;
|
||||||
|
// Login form
|
||||||
|
usernameLabel: string;
|
||||||
|
usernamePlaceholder: string;
|
||||||
|
passwordLabel: string;
|
||||||
|
passwordPlaceholder: string;
|
||||||
|
submit: string;
|
||||||
|
submitting: string;
|
||||||
|
noAccount: string; // "No account yet?"
|
||||||
|
registerLink: string; // links to /register
|
||||||
|
// Skin panel
|
||||||
|
refresh: string; // re-fetch the NMSR avatar
|
||||||
|
playerLabel: string; // "Logged in as"
|
||||||
|
skinTitle: string;
|
||||||
|
skinLead: string; // reuses register.skinLead concept
|
||||||
|
skinChoose: string;
|
||||||
|
modelLabel: string;
|
||||||
|
modelClassic: string;
|
||||||
|
modelSlim: string;
|
||||||
|
skinUpload: string;
|
||||||
|
skinUploading: string;
|
||||||
|
skinOk: string;
|
||||||
|
skinPreviewNote: string; // "NMSR takes ~1 min to update" hint
|
||||||
|
resetSkin: string;
|
||||||
|
resetConfirm: string; // window.confirm text
|
||||||
|
resetOk: string;
|
||||||
|
// Errors
|
||||||
|
errFields: string;
|
||||||
|
errSkinType: string;
|
||||||
|
errNetwork: string;
|
||||||
};
|
};
|
||||||
footer: { join: string; disc: string };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ui: Record<Lang, UI> = {
|
export const ui: Record<Lang, UI> = {
|
||||||
en: {
|
en: {
|
||||||
htmlLang: "en",
|
htmlLang: "en",
|
||||||
copied: "Copied!",
|
copied: "Copied!",
|
||||||
nav: { status: "Status", features: "Features", join: "How to Join", play: "Play Now" },
|
nav: { status: "Status", members: "Members", features: "Features", mods: "Mods", join: "How to Join", files: "Files", play: "Play", register: "Register", login: "Account" },
|
||||||
hero: {
|
hero: {
|
||||||
eyebrow: "Modded LAN · NeoForge",
|
eyebrow: "Modded LAN · NeoForge",
|
||||||
tagline: "Kitchen-sink survival, built to outlast the weekend.",
|
tagline: "Kitchen-sink survival, built to outlast the weekend.",
|
||||||
sub:
|
sub:
|
||||||
"A self-hosted modded server for the crew — a curated tech + magic + " +
|
"A self-hosted modded server for El valle — a curated tech + magic + " +
|
||||||
"exploration pack, your own skins, one-click setup. Grab the address, " +
|
"exploration pack, your own skins, one-click setup. Grab the address, " +
|
||||||
"import the pack, dig in.",
|
"import the pack, dig in.",
|
||||||
copyIp: "Copy IP",
|
copyIp: "Copy IP",
|
||||||
howToJoin: "How to Join",
|
howToJoin: "How to Join",
|
||||||
|
files: "Files",
|
||||||
metaModpack: "NeoForge modpack",
|
metaModpack: "NeoForge modpack",
|
||||||
metaLan: "LAN-only",
|
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
eyebrow: "Server",
|
eyebrow: "Server",
|
||||||
@@ -94,11 +203,17 @@ 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",
|
||||||
|
},
|
||||||
|
members: {
|
||||||
|
eyebrow: "El valle",
|
||||||
|
title: "Who's on the server.",
|
||||||
|
lead: "Everyone registered on Ulicraft, with their own self-hosted skin.",
|
||||||
|
empty: "No members yet — be the first to register.",
|
||||||
},
|
},
|
||||||
statLabels: ["Mods", "NeoForge", "Player slots", "World backups"],
|
|
||||||
features: {
|
features: {
|
||||||
eyebrow: "What makes it Ulicraft",
|
eyebrow: "What makes it Ulicraft",
|
||||||
title: "Built for the crew.",
|
title: "Built for El valle.",
|
||||||
lead:
|
lead:
|
||||||
"Self-hosted, curated, and persistent — a modded server that respects " +
|
"Self-hosted, curated, and persistent — a modded server that respects " +
|
||||||
"your time and outlives the LAN.",
|
"your time and outlives the LAN.",
|
||||||
@@ -113,7 +228,7 @@ export const ui: Record<Lang, UI> = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
h: "One-click setup",
|
h: "One-click setup",
|
||||||
p: "packwiz installs and updates the whole modpack inside your launcher, so everyone stays in sync.",
|
p: "The Ulicraft Launcher installs and updates the whole modpack from our distribution, so everyone stays in sync.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
h: "Built to last",
|
h: "Built to last",
|
||||||
@@ -121,30 +236,50 @@ export const ui: Record<Lang, UI> = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
mods: {
|
||||||
|
eyebrow: "The pack",
|
||||||
|
title: "Every mod in the pack.",
|
||||||
|
lead: "The full client modset, installed automatically by the launcher. No hunting, no version mismatches.",
|
||||||
|
empty: "Mod list not generated yet — check back soon.",
|
||||||
|
},
|
||||||
join: {
|
join: {
|
||||||
eyebrow: "How to Join · 4 Steps",
|
eyebrow: "How to Join · 2 Steps",
|
||||||
title: "From zero to spawning in.",
|
title: "From zero to spawning in.",
|
||||||
lead: "One-time setup. After this, the launcher keeps you in sync.",
|
lead: "One-time setup. After this, the launcher keeps you in sync.",
|
||||||
s1: {
|
s1: {
|
||||||
|
kicker: "Account",
|
||||||
|
h: "Register",
|
||||||
|
pa: "Create your Ulicraft account at",
|
||||||
|
pb: "— one username and password for the server and your skin.",
|
||||||
|
registerLink: "Register",
|
||||||
|
},
|
||||||
|
s2: {
|
||||||
kicker: "Launcher",
|
kicker: "Launcher",
|
||||||
h: "Download {name}",
|
h: "Download {name}",
|
||||||
p: "Grab {name} for your system — a Prism-based launcher with authlib-injector support built in.",
|
p: "Grab {name}, log in with your Ulicraft credentials, and hit play. Everything else — the modpack, the server address, updates — is handled inside the launcher.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fjord: {
|
||||||
|
navHome: "← Home",
|
||||||
|
eyebrow: "Alternative · Fjord Launcher",
|
||||||
|
title: "Play with Fjord Launcher.",
|
||||||
|
lead: "Prefer a vanilla Prism-based launcher? Set it up manually with Fjord and import the Ulicraft pack.",
|
||||||
|
s1: {
|
||||||
|
kicker: "Launcher",
|
||||||
|
h: "Download Fjord",
|
||||||
|
p: "Grab Fjord Launcher for your system — a Prism-based launcher with authlib-injector support built in.",
|
||||||
},
|
},
|
||||||
s2: {
|
s2: {
|
||||||
kicker: "Account",
|
kicker: "Account",
|
||||||
h: "Add your account",
|
h: "Add your account",
|
||||||
pa: "In the launcher, add an",
|
pa: "In Fjord, add an",
|
||||||
pb: "account using this URL, then log in with your Ulicraft credentials.",
|
pb: "account using this URL, then log in with your Ulicraft credentials.",
|
||||||
copy: "Copy",
|
copy: "Copy",
|
||||||
registerPre: "Need credentials? Register at",
|
|
||||||
caPre: "Offline party? Download and trust the",
|
|
||||||
caLink: "local CA certificate",
|
|
||||||
caPost: "so the game-file mirror works over HTTPS.",
|
|
||||||
},
|
},
|
||||||
s3: {
|
s3: {
|
||||||
kicker: "Modpack",
|
kicker: "Modpack",
|
||||||
h: "Import the modpack",
|
h: "Import the Ulicraft pack",
|
||||||
p: "Add a new instance from this packwiz URL — it installs and updates the whole pack.",
|
p: "Download the Ulicraft modpack and import it into Fjord as a new instance — it installs the whole pack.",
|
||||||
},
|
},
|
||||||
s4: {
|
s4: {
|
||||||
kicker: "Connect",
|
kicker: "Connect",
|
||||||
@@ -153,28 +288,95 @@ export const ui: Record<Lang, UI> = {
|
|||||||
pb: ", paste the address, and join.",
|
pb: ", paste the address, and join.",
|
||||||
copy: "Copy IP",
|
copy: "Copy IP",
|
||||||
},
|
},
|
||||||
|
noPack: "The pack download isn't available yet — check back soon.",
|
||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
join: "How to Join",
|
join: "How to Join",
|
||||||
|
status: "Server status",
|
||||||
disc: "Not affiliated with Mojang or Microsoft. Minecraft is a trademark of Mojang AB.",
|
disc: "Not affiliated with Mojang or Microsoft. Minecraft is a trademark of Mojang AB.",
|
||||||
},
|
},
|
||||||
|
register: {
|
||||||
|
navHome: "← Home",
|
||||||
|
eyebrow: "Account",
|
||||||
|
title: "Create your Ulicraft account.",
|
||||||
|
lead: "One username and password for the server, your skin, and your cape. Takes a minute.",
|
||||||
|
usernameLabel: "Username",
|
||||||
|
usernamePlaceholder: "your in-game name",
|
||||||
|
passwordLabel: "Password",
|
||||||
|
passwordPlaceholder: "choose a password",
|
||||||
|
inviteLabel: "Invite code",
|
||||||
|
invitePlaceholder: "paste your invite code",
|
||||||
|
inviteHint: "Ask an admin for an invite code.",
|
||||||
|
submit: "Create account",
|
||||||
|
submitting: "Creating…",
|
||||||
|
haveAccount: "Already have an account?",
|
||||||
|
loginLink: "Manage it here",
|
||||||
|
successTitle: "Account created.",
|
||||||
|
successBody: "Welcome, {name}. You can set a skin now, or head straight to How to Join.",
|
||||||
|
uuidLabel: "Player UUID",
|
||||||
|
skinTitle: "Set your skin (optional)",
|
||||||
|
skinLead: "Upload a Minecraft skin PNG. You can always change it later in the account page.",
|
||||||
|
skinChoose: "Choose PNG…",
|
||||||
|
modelLabel: "Model",
|
||||||
|
modelClassic: "Classic",
|
||||||
|
modelSlim: "Slim",
|
||||||
|
skinUpload: "Upload skin",
|
||||||
|
skinUploading: "Uploading…",
|
||||||
|
skinOk: "Skin set!",
|
||||||
|
finish: "Continue to How to Join",
|
||||||
|
errFields: "Fill in every field.",
|
||||||
|
errSkinType: "Pick a PNG image.",
|
||||||
|
errNetwork: "Network error — try again.",
|
||||||
|
},
|
||||||
|
account: {
|
||||||
|
navHome: "← Home",
|
||||||
|
eyebrow: "Account",
|
||||||
|
title: "Manage your skin.",
|
||||||
|
lead: "Log in to upload or reset your Minecraft skin.",
|
||||||
|
usernameLabel: "Username",
|
||||||
|
usernamePlaceholder: "your in-game name",
|
||||||
|
passwordLabel: "Password",
|
||||||
|
passwordPlaceholder: "your password",
|
||||||
|
submit: "Log in",
|
||||||
|
submitting: "Logging in…",
|
||||||
|
noAccount: "No account yet?",
|
||||||
|
registerLink: "Register here",
|
||||||
|
refresh: "Refresh",
|
||||||
|
playerLabel: "Logged in as",
|
||||||
|
skinTitle: "Your skin",
|
||||||
|
skinLead: "Upload a Minecraft skin PNG. The 3D preview below updates instantly; the in-game avatar takes ~1 minute.",
|
||||||
|
skinChoose: "Choose PNG…",
|
||||||
|
modelLabel: "Model",
|
||||||
|
modelClassic: "Classic",
|
||||||
|
modelSlim: "Slim",
|
||||||
|
skinUpload: "Upload skin",
|
||||||
|
skinUploading: "Uploading…",
|
||||||
|
skinOk: "Skin updated!",
|
||||||
|
skinPreviewNote: "Avatar updates in ~1 min.",
|
||||||
|
resetSkin: "Reset to default",
|
||||||
|
resetConfirm: "Reset your skin to the default? This cannot be undone.",
|
||||||
|
resetOk: "Skin reset to default.",
|
||||||
|
errFields: "Fill in every field.",
|
||||||
|
errSkinType: "Pick a PNG image.",
|
||||||
|
errNetwork: "Network error — try again.",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
es: {
|
es: {
|
||||||
htmlLang: "es",
|
htmlLang: "es",
|
||||||
copied: "¡Copiado!",
|
copied: "¡Copiado!",
|
||||||
nav: { status: "Estado", features: "Características", join: "Cómo entrar", play: "Jugar ahora" },
|
nav: { status: "Estado", members: "Miembros", features: "Características", mods: "Mods", join: "Cómo entrar", files: "Archivos", play: "Jugar", register: "Registro", login: "Cuenta" },
|
||||||
hero: {
|
hero: {
|
||||||
eyebrow: "LAN con mods · NeoForge",
|
eyebrow: "LAN con mods · NeoForge",
|
||||||
tagline: "Supervivencia todo incluido, para durar más que el finde.",
|
tagline: "Supervivencia todo incluido, para durar más que el finde.",
|
||||||
sub:
|
sub:
|
||||||
"Un servidor con mods autoalojado para la cuadrilla: un pack curado de " +
|
"Un servidor con mods autoalojado para El valle: un pack curado de " +
|
||||||
"tecnología, magia y exploración, tus propias skins y configuración en un " +
|
"tecnología, magia y exploración, tus propias skins y configuración en un " +
|
||||||
"clic. Copia la dirección, importa el pack y a cavar.",
|
"clic. Copia la dirección, importa el pack y a cavar.",
|
||||||
copyIp: "Copiar IP",
|
copyIp: "Copiar IP",
|
||||||
howToJoin: "Cómo entrar",
|
howToJoin: "Cómo entrar",
|
||||||
|
files: "Archivos",
|
||||||
metaModpack: "Pack NeoForge",
|
metaModpack: "Pack NeoForge",
|
||||||
metaLan: "Solo LAN",
|
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
eyebrow: "Servidor",
|
eyebrow: "Servidor",
|
||||||
@@ -183,11 +385,17 @@ 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",
|
||||||
|
},
|
||||||
|
members: {
|
||||||
|
eyebrow: "El valle",
|
||||||
|
title: "Quién está en el servidor.",
|
||||||
|
lead: "Todos los registrados en Ulicraft, cada uno con su skin autoalojada.",
|
||||||
|
empty: "Aún no hay miembros — sé el primero en registrarte.",
|
||||||
},
|
},
|
||||||
statLabels: ["Mods", "NeoForge", "Plazas", "Copias del mundo"],
|
|
||||||
features: {
|
features: {
|
||||||
eyebrow: "Qué hace especial a Ulicraft",
|
eyebrow: "Qué hace especial a Ulicraft",
|
||||||
title: "Hecho para la cuadrilla.",
|
title: "Hecho para El valle.",
|
||||||
lead:
|
lead:
|
||||||
"Autoalojado, curado y persistente: un servidor con mods que respeta tu " +
|
"Autoalojado, curado y persistente: un servidor con mods que respeta tu " +
|
||||||
"tiempo y sobrevive a la LAN.",
|
"tiempo y sobrevive a la LAN.",
|
||||||
@@ -202,7 +410,7 @@ export const ui: Record<Lang, UI> = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
h: "Configuración en un clic",
|
h: "Configuración en un clic",
|
||||||
p: "packwiz instala y actualiza todo el modpack dentro de tu launcher, así todos vais sincronizados.",
|
p: "El Ulicraft Launcher instala y actualiza todo el modpack desde nuestra distribución, así todos vais sincronizados.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
h: "Hecho para durar",
|
h: "Hecho para durar",
|
||||||
@@ -210,30 +418,50 @@ export const ui: Record<Lang, UI> = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
mods: {
|
||||||
|
eyebrow: "El pack",
|
||||||
|
title: "Todos los mods del pack.",
|
||||||
|
lead: "El modset completo del cliente, instalado automáticamente por el launcher. Sin búsquedas ni versiones incompatibles.",
|
||||||
|
empty: "La lista de mods aún no está generada — vuelve pronto.",
|
||||||
|
},
|
||||||
join: {
|
join: {
|
||||||
eyebrow: "Cómo entrar · 4 pasos",
|
eyebrow: "Cómo entrar · 2 pasos",
|
||||||
title: "De cero a aparecer en el mundo.",
|
title: "De cero a aparecer en el mundo.",
|
||||||
lead: "Configuración única. Después, el launcher te mantiene sincronizado.",
|
lead: "Configuración única. Después, el launcher te mantiene sincronizado.",
|
||||||
s1: {
|
s1: {
|
||||||
|
kicker: "Cuenta",
|
||||||
|
h: "Regístrate",
|
||||||
|
pa: "Crea tu cuenta de Ulicraft en",
|
||||||
|
pb: "— un usuario y contraseña para el servidor y tu skin.",
|
||||||
|
registerLink: "Registro",
|
||||||
|
},
|
||||||
|
s2: {
|
||||||
kicker: "Launcher",
|
kicker: "Launcher",
|
||||||
h: "Descarga {name}",
|
h: "Descarga {name}",
|
||||||
p: "Coge {name} para tu sistema: un launcher basado en Prism con soporte authlib-injector integrado.",
|
p: "Coge {name}, inicia sesión con tus credenciales de Ulicraft y dale a jugar. Todo lo demás — el modpack, la dirección del servidor, las actualizaciones — se gestiona dentro del launcher.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fjord: {
|
||||||
|
navHome: "← Inicio",
|
||||||
|
eyebrow: "Alternativa · Fjord Launcher",
|
||||||
|
title: "Juega con Fjord Launcher.",
|
||||||
|
lead: "¿Prefieres un launcher vanilla basado en Prism? Configúralo a mano con Fjord e importa el pack de Ulicraft.",
|
||||||
|
s1: {
|
||||||
|
kicker: "Launcher",
|
||||||
|
h: "Descarga Fjord",
|
||||||
|
p: "Coge Fjord Launcher para tu sistema: un launcher basado en Prism con soporte authlib-injector integrado.",
|
||||||
},
|
},
|
||||||
s2: {
|
s2: {
|
||||||
kicker: "Cuenta",
|
kicker: "Cuenta",
|
||||||
h: "Añade tu cuenta",
|
h: "Añade tu cuenta",
|
||||||
pa: "En el launcher, añade una cuenta",
|
pa: "En Fjord, añade una cuenta",
|
||||||
pb: "con esta URL y luego inicia sesión con tus credenciales de Ulicraft.",
|
pb: "con esta URL y luego inicia sesión con tus credenciales de Ulicraft.",
|
||||||
copy: "Copiar",
|
copy: "Copiar",
|
||||||
registerPre: "¿Sin credenciales? Regístrate en",
|
|
||||||
caPre: "¿Fiesta sin internet? Descarga y confía en el",
|
|
||||||
caLink: "certificado CA local",
|
|
||||||
caPost: "para que el mirror de archivos funcione por HTTPS.",
|
|
||||||
},
|
},
|
||||||
s3: {
|
s3: {
|
||||||
kicker: "Modpack",
|
kicker: "Modpack",
|
||||||
h: "Importa el modpack",
|
h: "Importa el pack de Ulicraft",
|
||||||
p: "Añade una instancia nueva desde esta URL de packwiz: instala y actualiza todo el pack.",
|
p: "Descarga el modpack de Ulicraft e impórtalo en Fjord como instancia nueva: instala todo el pack.",
|
||||||
},
|
},
|
||||||
s4: {
|
s4: {
|
||||||
kicker: "Conectar",
|
kicker: "Conectar",
|
||||||
@@ -242,28 +470,95 @@ export const ui: Record<Lang, UI> = {
|
|||||||
pb: ", pega la dirección y entra.",
|
pb: ", pega la dirección y entra.",
|
||||||
copy: "Copiar IP",
|
copy: "Copiar IP",
|
||||||
},
|
},
|
||||||
|
noPack: "La descarga del pack aún no está disponible — vuelve pronto.",
|
||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
join: "Cómo entrar",
|
join: "Cómo entrar",
|
||||||
|
status: "Estado del servidor",
|
||||||
disc: "No afiliado a Mojang ni Microsoft. Minecraft es una marca de Mojang AB.",
|
disc: "No afiliado a Mojang ni Microsoft. Minecraft es una marca de Mojang AB.",
|
||||||
},
|
},
|
||||||
|
register: {
|
||||||
|
navHome: "← Inicio",
|
||||||
|
eyebrow: "Cuenta",
|
||||||
|
title: "Crea tu cuenta de Ulicraft.",
|
||||||
|
lead: "Un usuario y contraseña para el servidor, tu skin y tu capa. Cosa de un minuto.",
|
||||||
|
usernameLabel: "Usuario",
|
||||||
|
usernamePlaceholder: "tu nombre en el juego",
|
||||||
|
passwordLabel: "Contraseña",
|
||||||
|
passwordPlaceholder: "elige una contraseña",
|
||||||
|
inviteLabel: "Código de invitación",
|
||||||
|
invitePlaceholder: "pega tu código de invitación",
|
||||||
|
inviteHint: "Pide un código de invitación a un admin.",
|
||||||
|
submit: "Crear cuenta",
|
||||||
|
submitting: "Creando…",
|
||||||
|
haveAccount: "¿Ya tienes cuenta?",
|
||||||
|
loginLink: "Gestiónala aquí",
|
||||||
|
successTitle: "Cuenta creada.",
|
||||||
|
successBody: "Bienvenido, {name}. Puedes poner una skin ahora o ir directo a Cómo entrar.",
|
||||||
|
uuidLabel: "UUID del jugador",
|
||||||
|
skinTitle: "Pon tu skin (opcional)",
|
||||||
|
skinLead: "Sube un PNG de skin de Minecraft. Siempre puedes cambiarla luego en tu cuenta.",
|
||||||
|
skinChoose: "Elegir PNG…",
|
||||||
|
modelLabel: "Modelo",
|
||||||
|
modelClassic: "Clásico",
|
||||||
|
modelSlim: "Fino",
|
||||||
|
skinUpload: "Subir skin",
|
||||||
|
skinUploading: "Subiendo…",
|
||||||
|
skinOk: "¡Skin puesta!",
|
||||||
|
finish: "Continuar a Cómo entrar",
|
||||||
|
errFields: "Rellena todos los campos.",
|
||||||
|
errSkinType: "Elige una imagen PNG.",
|
||||||
|
errNetwork: "Error de red: inténtalo de nuevo.",
|
||||||
|
},
|
||||||
|
account: {
|
||||||
|
navHome: "← Inicio",
|
||||||
|
eyebrow: "Cuenta",
|
||||||
|
title: "Gestiona tu skin.",
|
||||||
|
lead: "Inicia sesión para subir o restablecer tu skin de Minecraft.",
|
||||||
|
usernameLabel: "Usuario",
|
||||||
|
usernamePlaceholder: "tu nombre en el juego",
|
||||||
|
passwordLabel: "Contraseña",
|
||||||
|
passwordPlaceholder: "tu contraseña",
|
||||||
|
submit: "Iniciar sesión",
|
||||||
|
submitting: "Entrando…",
|
||||||
|
noAccount: "¿Aún no tienes cuenta?",
|
||||||
|
registerLink: "Regístrate aquí",
|
||||||
|
refresh: "Actualizar",
|
||||||
|
playerLabel: "Sesión iniciada como",
|
||||||
|
skinTitle: "Tu skin",
|
||||||
|
skinLead: "Sube un PNG de skin de Minecraft. La vista previa 3D se actualiza al instante; el avatar en el juego tarda ~1 minuto.",
|
||||||
|
skinChoose: "Elegir PNG…",
|
||||||
|
modelLabel: "Modelo",
|
||||||
|
modelClassic: "Clásico",
|
||||||
|
modelSlim: "Fino",
|
||||||
|
skinUpload: "Subir skin",
|
||||||
|
skinUploading: "Subiendo…",
|
||||||
|
skinOk: "¡Skin actualizada!",
|
||||||
|
skinPreviewNote: "El avatar se actualiza en ~1 min.",
|
||||||
|
resetSkin: "Restablecer skin",
|
||||||
|
resetConfirm: "¿Restablecer tu skin a la predeterminada? Esta acción no se puede deshacer.",
|
||||||
|
resetOk: "Skin restablecida.",
|
||||||
|
errFields: "Rellena todos los campos.",
|
||||||
|
errSkinType: "Elige una imagen PNG.",
|
||||||
|
errNetwork: "Error de red: inténtalo de nuevo.",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
eu: {
|
eu: {
|
||||||
htmlLang: "eu",
|
htmlLang: "eu",
|
||||||
copied: "Kopiatuta!",
|
copied: "Kopiatuta!",
|
||||||
nav: { status: "Egoera", features: "Ezaugarriak", join: "Nola sartu", play: "Jokatu orain" },
|
nav: { status: "Egoera", members: "Kideak", features: "Ezaugarriak", mods: "Mods", join: "Nola sartu", files: "Fitxategiak", play: "Jolastu", register: "Erregistroa", login: "Kontua" },
|
||||||
hero: {
|
hero: {
|
||||||
eyebrow: "Mod-dun LANa · NeoForge",
|
eyebrow: "Mod-dun LANa · NeoForge",
|
||||||
tagline: "Mod ugariko biziraupena, irauteko egina.",
|
tagline: "Mod ugariko biziraupena, irauteko egina.",
|
||||||
sub:
|
sub:
|
||||||
"Koadrilarentzako mod-dun zerbitzari auto-ostatatua: teknologia, magia eta " +
|
"El valle-rentzako mod-dun zerbitzari auto-ostatatua: teknologia, magia eta " +
|
||||||
"esplorazio pack zaindua, zure azalak eta konfigurazioa klik bakarrean. " +
|
"esplorazio pack zaindua, zure azalak eta konfigurazioa klik bakarrean. " +
|
||||||
"Hartu helbidea, inportatu packa eta hasi zulatzen.",
|
"Hartu helbidea, inportatu packa eta hasi zulatzen.",
|
||||||
copyIp: "Kopiatu IPa",
|
copyIp: "Kopiatu IPa",
|
||||||
howToJoin: "Nola sartu",
|
howToJoin: "Nola sartu",
|
||||||
|
files: "Fitxategiak",
|
||||||
metaModpack: "NeoForge packa",
|
metaModpack: "NeoForge packa",
|
||||||
metaLan: "LAN soilik",
|
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
eyebrow: "Zerbitzaria",
|
eyebrow: "Zerbitzaria",
|
||||||
@@ -272,11 +567,17 @@ 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",
|
||||||
|
},
|
||||||
|
members: {
|
||||||
|
eyebrow: "El valle",
|
||||||
|
title: "Nor dago zerbitzarian.",
|
||||||
|
lead: "Ulicraft-en erregistratutako guztiak, bakoitza bere azal auto-ostatatuarekin.",
|
||||||
|
empty: "Oraindik ez dago kiderik — izan zaitez lehena erregistratzen.",
|
||||||
},
|
},
|
||||||
statLabels: ["Modak", "NeoForge", "Plazak", "Munduaren babeskopiak"],
|
|
||||||
features: {
|
features: {
|
||||||
eyebrow: "Zerk egiten du Ulicraft berezi",
|
eyebrow: "Zerk egiten du Ulicraft berezi",
|
||||||
title: "Koadrilarentzat eginda.",
|
title: "El valle-rentzat eginda.",
|
||||||
lead:
|
lead:
|
||||||
"Auto-ostatatua, zaindua eta iraunkorra: zure denbora errespetatzen duen " +
|
"Auto-ostatatua, zaindua eta iraunkorra: zure denbora errespetatzen duen " +
|
||||||
"eta LANa gainditzen duen mod-dun zerbitzaria.",
|
"eta LANa gainditzen duen mod-dun zerbitzaria.",
|
||||||
@@ -291,7 +592,7 @@ export const ui: Record<Lang, UI> = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
h: "Konfigurazioa klik batean",
|
h: "Konfigurazioa klik batean",
|
||||||
p: "packwiz-ek modpack osoa instalatu eta eguneratzen du zure launcherrean, denok sinkronizatuta egoteko.",
|
p: "Ulicraft Launcher-ek modpack osoa instalatu eta eguneratzen du gure banaketatik, denok sinkronizatuta egoteko.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
h: "Irauteko eginda",
|
h: "Irauteko eginda",
|
||||||
@@ -299,30 +600,50 @@ export const ui: Record<Lang, UI> = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
mods: {
|
||||||
|
eyebrow: "Packa",
|
||||||
|
title: "Packeko mod guztiak.",
|
||||||
|
lead: "Bezeroaren mod-multzo osoa, launcherrak automatikoki instalatua. Bilaketarik gabe, bertsio-gatazkarik gabe.",
|
||||||
|
empty: "Mod zerrenda oraindik ez da sortu — itzuli laster.",
|
||||||
|
},
|
||||||
join: {
|
join: {
|
||||||
eyebrow: "Nola sartu · 4 urrats",
|
eyebrow: "Nola sartu · 2 urrats",
|
||||||
title: "Zerotik mundura agertzera.",
|
title: "Zerotik mundura agertzera.",
|
||||||
lead: "Behin bakarrik konfiguratu. Gero, launcherrak sinkronizatuta mantenduko zaitu.",
|
lead: "Behin bakarrik konfiguratu. Gero, launcherrak sinkronizatuta mantenduko zaitu.",
|
||||||
s1: {
|
s1: {
|
||||||
|
kicker: "Kontua",
|
||||||
|
h: "Erregistratu",
|
||||||
|
pa: "Sortu zure Ulicraft kontua hemen:",
|
||||||
|
pb: "— erabiltzaile eta pasahitz bat zerbitzarirako eta zure azalerako.",
|
||||||
|
registerLink: "Erregistroa",
|
||||||
|
},
|
||||||
|
s2: {
|
||||||
kicker: "Launcherra",
|
kicker: "Launcherra",
|
||||||
h: "Deskargatu {name}",
|
h: "Deskargatu {name}",
|
||||||
p: "Hartu {name} zure sistemarako: Prism-en oinarritutako launcherra, authlib-injector euskarriarekin.",
|
p: "Hartu {name}, hasi saioa zure Ulicraft kredentzialekin eta sakatu jolastu. Gainerako guztia — modpacka, zerbitzariaren helbidea, eguneraketak — launcherraren barruan kudeatzen da.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fjord: {
|
||||||
|
navHome: "← Hasiera",
|
||||||
|
eyebrow: "Alternatiba · Fjord Launcher",
|
||||||
|
title: "Jokatu Fjord Launcher-ekin.",
|
||||||
|
lead: "Prism-en oinarritutako launcher vanilla nahiago duzu? Konfiguratu eskuz Fjord-ekin eta inportatu Ulicraft packa.",
|
||||||
|
s1: {
|
||||||
|
kicker: "Launcherra",
|
||||||
|
h: "Deskargatu Fjord",
|
||||||
|
p: "Hartu Fjord Launcher zure sistemarako: Prism-en oinarritutako launcherra, authlib-injector euskarriarekin.",
|
||||||
},
|
},
|
||||||
s2: {
|
s2: {
|
||||||
kicker: "Kontua",
|
kicker: "Kontua",
|
||||||
h: "Gehitu zure kontua",
|
h: "Gehitu zure kontua",
|
||||||
pa: "Launcherrean, gehitu",
|
pa: "Fjord-en, gehitu",
|
||||||
pb: "kontu bat URL honekin, eta gero hasi saioa zure Ulicraft kredentzialekin.",
|
pb: "kontu bat URL honekin, eta gero hasi saioa zure Ulicraft kredentzialekin.",
|
||||||
copy: "Kopiatu",
|
copy: "Kopiatu",
|
||||||
registerPre: "Kredentzialik ez? Erregistratu hemen:",
|
|
||||||
caPre: "Internetik gabeko festa? Deskargatu eta fidatu",
|
|
||||||
caLink: "tokiko CA ziurtagiriaz",
|
|
||||||
caPost: "fitxategi-mirrorrak HTTPS bidez ibil dadin.",
|
|
||||||
},
|
},
|
||||||
s3: {
|
s3: {
|
||||||
kicker: "Modpacka",
|
kicker: "Modpacka",
|
||||||
h: "Inportatu modpacka",
|
h: "Inportatu Ulicraft packa",
|
||||||
p: "Gehitu instantzia berri bat packwiz URL honetatik: pack osoa instalatu eta eguneratzen du.",
|
p: "Deskargatu Ulicraft modpacka eta inportatu Fjord-en instantzia berri gisa: pack osoa instalatzen du.",
|
||||||
},
|
},
|
||||||
s4: {
|
s4: {
|
||||||
kicker: "Konektatu",
|
kicker: "Konektatu",
|
||||||
@@ -331,10 +652,77 @@ export const ui: Record<Lang, UI> = {
|
|||||||
pb: ", itsatsi helbidea eta sartu.",
|
pb: ", itsatsi helbidea eta sartu.",
|
||||||
copy: "Kopiatu IPa",
|
copy: "Kopiatu IPa",
|
||||||
},
|
},
|
||||||
|
noPack: "Packaren deskarga ez dago oraindik eskuragarri — itzuli laster.",
|
||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
join: "Nola sartu",
|
join: "Nola sartu",
|
||||||
|
status: "Zerbitzariaren egoera",
|
||||||
disc: "Ez dago Mojang edo Microsoft-ekin lotuta. Minecraft Mojang AB-ren marka da.",
|
disc: "Ez dago Mojang edo Microsoft-ekin lotuta. Minecraft Mojang AB-ren marka da.",
|
||||||
},
|
},
|
||||||
|
register: {
|
||||||
|
navHome: "← Hasiera",
|
||||||
|
eyebrow: "Kontua",
|
||||||
|
title: "Sortu zure Ulicraft kontua.",
|
||||||
|
lead: "Erabiltzaile eta pasahitz bat zerbitzarirako, zure azalerako eta kaparako. Minutu batean.",
|
||||||
|
usernameLabel: "Erabiltzailea",
|
||||||
|
usernamePlaceholder: "zure jokoko izena",
|
||||||
|
passwordLabel: "Pasahitza",
|
||||||
|
passwordPlaceholder: "aukeratu pasahitz bat",
|
||||||
|
inviteLabel: "Gonbidapen-kodea",
|
||||||
|
invitePlaceholder: "itsatsi zure gonbidapen-kodea",
|
||||||
|
inviteHint: "Eskatu gonbidapen-kode bat admin bati.",
|
||||||
|
submit: "Sortu kontua",
|
||||||
|
submitting: "Sortzen…",
|
||||||
|
haveAccount: "Baduzu kontua?",
|
||||||
|
loginLink: "Kudeatu hemen",
|
||||||
|
successTitle: "Kontua sortuta.",
|
||||||
|
successBody: "Ongi etorri, {name}. Azala orain jar dezakezu, edo zuzenean Nola sartu atalera joan.",
|
||||||
|
uuidLabel: "Jokalariaren UUIDa",
|
||||||
|
skinTitle: "Jarri zure azala (aukerakoa)",
|
||||||
|
skinLead: "Igo Minecraft azal PNG bat. Beti alda dezakezu gero zure kontuan.",
|
||||||
|
skinChoose: "Aukeratu PNGa…",
|
||||||
|
modelLabel: "Eredua",
|
||||||
|
modelClassic: "Klasikoa",
|
||||||
|
modelSlim: "Mehea",
|
||||||
|
skinUpload: "Igo azala",
|
||||||
|
skinUploading: "Igotzen…",
|
||||||
|
skinOk: "Azala jarrita!",
|
||||||
|
finish: "Jarraitu Nola sartura",
|
||||||
|
errFields: "Bete eremu guztiak.",
|
||||||
|
errSkinType: "Aukeratu PNG irudi bat.",
|
||||||
|
errNetwork: "Sare-errorea: saiatu berriro.",
|
||||||
|
},
|
||||||
|
account: {
|
||||||
|
navHome: "← Hasiera",
|
||||||
|
eyebrow: "Kontua",
|
||||||
|
title: "Kudeatu zure azala.",
|
||||||
|
lead: "Hasi saioa zure Minecraft azala igotzeko edo berrezartzeko.",
|
||||||
|
usernameLabel: "Erabiltzailea",
|
||||||
|
usernamePlaceholder: "zure jokoko izena",
|
||||||
|
passwordLabel: "Pasahitza",
|
||||||
|
passwordPlaceholder: "zure pasahitza",
|
||||||
|
submit: "Hasi saioa",
|
||||||
|
submitting: "Sartzen…",
|
||||||
|
noAccount: "Oraindik konturik ez?",
|
||||||
|
registerLink: "Erregistratu hemen",
|
||||||
|
refresh: "Eguneratu",
|
||||||
|
playerLabel: "Saioa hasita:",
|
||||||
|
skinTitle: "Zure azala",
|
||||||
|
skinLead: "Igo Minecraft azal PNG bat. 3D aurrebista berehala eguneratzen da; jokoko avatarra ~1 minutuan.",
|
||||||
|
skinChoose: "Aukeratu PNGa…",
|
||||||
|
modelLabel: "Eredua",
|
||||||
|
modelClassic: "Klasikoa",
|
||||||
|
modelSlim: "Mehea",
|
||||||
|
skinUpload: "Igo azala",
|
||||||
|
skinUploading: "Igotzen…",
|
||||||
|
skinOk: "Azala eguneratuta!",
|
||||||
|
skinPreviewNote: "Avatarra ~1 min barru eguneratzen da.",
|
||||||
|
resetSkin: "Berrezarri azala",
|
||||||
|
resetConfirm: "Zure azala lehenetsi nahi duzu? Ezin da desegin.",
|
||||||
|
resetOk: "Azala berrezarrita.",
|
||||||
|
errFields: "Bete eremu guztiak.",
|
||||||
|
errSkinType: "Aukeratu PNG irudi bat.",
|
||||||
|
errNetwork: "Sare-errorea: saiatu berriro.",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
+139
-113
@@ -1,26 +1,34 @@
|
|||||||
---
|
---
|
||||||
import { site, LITERALS, HEAD_FONTS } from "../data/site";
|
import { site, HEAD_FONTS } from "../data/site";
|
||||||
import { ui, LANGS, LANG_LABEL, LANG_PATH, type Lang } from "../i18n/ui";
|
import { ui, LANGS, LANG_LABEL, LANG_PATH, LANG_REGISTER_PATH, LANG_ACCOUNT_PATH, type Lang } from "../i18n/ui";
|
||||||
import Creeper from "../components/Creeper.astro";
|
|
||||||
import PixelIcon from "../components/PixelIcon.astro";
|
import PixelIcon from "../components/PixelIcon.astro";
|
||||||
import CopyChip from "../components/CopyChip.astro";
|
import ServerCard from "../components/ServerCard.astro";
|
||||||
import ServerListPanel from "../components/ServerListPanel.astro";
|
import PlayerRoster from "../components/PlayerRoster.astro";
|
||||||
|
import ModList from "../components/ModList.astro";
|
||||||
import "../styles/main.css";
|
import "../styles/main.css";
|
||||||
|
|
||||||
// One static page per locale: en at "/", es at "/es/", eu at "/eu/".
|
// One static page per locale: en at "/", es at "/es/", eu at "/eu/".
|
||||||
export function getStaticPaths() {
|
export function getStaticPaths() {
|
||||||
return [
|
return [
|
||||||
{ params: { lang: undefined }, props: { locale: "en" as Lang } },
|
{ params: { lang: undefined }, props: { locale: "es" as Lang } },
|
||||||
{ params: { lang: "es" }, props: { locale: "es" as Lang } },
|
{ params: { lang: "en" }, props: { locale: "en" as Lang } },
|
||||||
{ params: { lang: "eu" }, props: { locale: "eu" as Lang } },
|
{ params: { lang: "eu" }, props: { locale: "eu" as Lang } },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
const { locale } = Astro.props;
|
const { locale } = Astro.props;
|
||||||
const t = ui[locale];
|
const t = ui[locale];
|
||||||
const { theme, launcher } = site;
|
const { theme, launcherManifest } = site;
|
||||||
const headFont = HEAD_FONTS[theme.headFont] ?? HEAD_FONTS.pixelify;
|
const headFont = HEAD_FONTS[theme.headFont] ?? HEAD_FONTS.pixelify;
|
||||||
const launcherName = launcher.name;
|
const launcherName = launcherManifest.product;
|
||||||
|
const registerPath = LANG_REGISTER_PATH[locale];
|
||||||
|
const accountPath = LANG_ACCOUNT_PATH[locale];
|
||||||
|
|
||||||
|
// Per-OS download buttons (join step 2) are fetched client-side from the live
|
||||||
|
// launcher.json on page load (no build-time baking, no polling) — see the
|
||||||
|
// script below. We only pass the URL; buttons render into the #launcher-dl
|
||||||
|
// skeleton. URL derives from the distribution subdomain.
|
||||||
|
const launcherJsonUrl = `https://distribution.${site.baseDomain}/launcher/launcher.json`;
|
||||||
|
|
||||||
// Build-time floating dust bits (index-derived, no runtime RNG).
|
// Build-time floating dust bits (index-derived, no runtime RNG).
|
||||||
const dustBits = Array.from({ length: 16 }, (_, i) => {
|
const dustBits = Array.from({ length: 16 }, (_, i) => {
|
||||||
@@ -48,7 +56,7 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>{site.name} — {t.hero.howToJoin}</title>
|
<title>{site.name} — {t.hero.howToJoin}</title>
|
||||||
<meta name="description" content={`${site.name}: ${t.hero.tagline}`} />
|
<meta name="description" content={`${site.name}: ${t.hero.tagline}`} />
|
||||||
<link rel="icon" type="image/png" href="/logo.png" />
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
<link rel="stylesheet" href="/fonts/fonts.css" />
|
<link rel="stylesheet" href="/fonts/fonts.css" />
|
||||||
{LANGS.map((l) => <link rel="alternate" hreflang={l} href={LANG_PATH[l]} />)}
|
{LANGS.map((l) => <link rel="alternate" hreflang={l} href={LANG_PATH[l]} />)}
|
||||||
</head>
|
</head>
|
||||||
@@ -57,13 +65,13 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
|
|||||||
<header class="nav">
|
<header class="nav">
|
||||||
<div class="wrap nav-in">
|
<div class="wrap nav-in">
|
||||||
<a class="brand" href="#top">
|
<a class="brand" href="#top">
|
||||||
<Creeper />
|
<img class="brand-logo" src="/ulicraft-logo-mini.svg" alt="" width="34" height="34" />
|
||||||
<span class="name">ULICRAFT</span>
|
<span class="name">ULICRAFT</span>
|
||||||
</a>
|
</a>
|
||||||
<nav class="nav-links">
|
<nav class="nav-links">
|
||||||
<a href="#status">{t.nav.status}</a>
|
|
||||||
<a href="#features">{t.nav.features}</a>
|
|
||||||
<a href="#join">{t.nav.join}</a>
|
<a href="#join">{t.nav.join}</a>
|
||||||
|
<a href={site.filesUrl} target="_blank" rel="noopener">{t.nav.files}</a>
|
||||||
|
<a href="#status">{t.nav.status}</a>
|
||||||
</nav>
|
</nav>
|
||||||
<span class="nav-spacer"></span>
|
<span class="nav-spacer"></span>
|
||||||
<div class="lang-switch" aria-label="Language">
|
<div class="lang-switch" aria-label="Language">
|
||||||
@@ -71,6 +79,8 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
|
|||||||
<a href={LANG_PATH[l]} class:list={[l === locale && "on"]} aria-current={l === locale ? "true" : undefined}>{LANG_LABEL[l]}</a>
|
<a href={LANG_PATH[l]} class:list={[l === locale && "on"]} aria-current={l === locale ? "true" : undefined}>{LANG_LABEL[l]}</a>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<a class="mc-btn sm" href={registerPath}>{t.nav.register}</a>
|
||||||
|
<a class="mc-btn sm" href={accountPath}>{t.nav.login}</a>
|
||||||
<a class="mc-btn primary sm" href="#join">{t.nav.play}</a>
|
<a class="mc-btn primary sm" href="#join">{t.nav.play}</a>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -96,75 +106,26 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
|
|||||||
<h1 class="hero-tagline">{t.hero.tagline}</h1>
|
<h1 class="hero-tagline">{t.hero.tagline}</h1>
|
||||||
<p class="hero-sub">{t.hero.sub}</p>
|
<p class="hero-sub">{t.hero.sub}</p>
|
||||||
<div class="hero-ip-row">
|
<div class="hero-ip-row">
|
||||||
<CopyChip value={site.serverAddress} variant="ip" label={t.hero.copyIp} copiedLabel={t.copied} />
|
<a class="mc-btn primary" href="#join">{t.hero.howToJoin}</a>
|
||||||
<a class="mc-btn" href="#join">{t.hero.howToJoin}</a>
|
<a class="mc-btn" href={site.filesUrl} target="_blank" rel="noopener">{t.hero.files}</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-meta">
|
<div class="hero-meta">
|
||||||
<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">
|
||||||
<ServerListPanel
|
<ServerCard
|
||||||
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>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- STATUS -->
|
|
||||||
<section id="status" class="pad">
|
|
||||||
<div class="wrap">
|
|
||||||
<div class="sec-head reveal">
|
|
||||||
<span class="eyebrow">{t.status.eyebrow}</span>
|
|
||||||
<h2 class="section-title">{t.status.title}</h2>
|
|
||||||
<p class="lead">{t.status.lead}</p>
|
|
||||||
</div>
|
|
||||||
<div class="reveal">
|
|
||||||
<ServerListPanel
|
|
||||||
modded={t.status.modded}
|
|
||||||
motd={t.status.motd}
|
|
||||||
upTo={t.status.upTo}
|
|
||||||
/>
|
|
||||||
</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>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- FEATURES -->
|
|
||||||
<section id="features" class="pad" style="background: var(--bg-2)">
|
|
||||||
<div class="wrap">
|
|
||||||
<div class="sec-head reveal">
|
|
||||||
<span class="eyebrow">{t.features.eyebrow}</span>
|
|
||||||
<h2 class="section-title">{t.features.title}</h2>
|
|
||||||
<p class="lead">{t.features.lead}</p>
|
|
||||||
</div>
|
|
||||||
<div class="feat-grid">
|
|
||||||
{site.features.map((f, i) => (
|
|
||||||
<div class="feat reveal" style={`transition-delay:${(i % 2) * 80}ms`}>
|
|
||||||
<PixelIcon glyph={f.glyph} gold={f.gold} />
|
|
||||||
<div>
|
|
||||||
<h3>{t.features.items[i].h}</h3>
|
|
||||||
<p>{t.features.items[i].p}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- HOW TO JOIN -->
|
<!-- HOW TO JOIN -->
|
||||||
<section id="join" class="pad">
|
<section id="join" class="pad">
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
@@ -180,15 +141,10 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
|
|||||||
<div class="num">1</div>
|
<div class="num">1</div>
|
||||||
<div class="body">
|
<div class="body">
|
||||||
<span class="kicker">{t.join.s1.kicker}</span>
|
<span class="kicker">{t.join.s1.kicker}</span>
|
||||||
<h3>{t.join.s1.h.replace("{name}", launcherName)}</h3>
|
<h3>{t.join.s1.h}</h3>
|
||||||
<p>{t.join.s1.p.replace(/\{name\}/g, launcherName)}</p>
|
<p>
|
||||||
<div class="actions">
|
{t.join.s1.pa} <a href={registerPath}>{t.join.s1.registerLink}</a> {t.join.s1.pb}
|
||||||
{launcher.downloads.map((d) => (
|
</p>
|
||||||
<a class="mc-btn sm" href={`${launcher.base}/${d.file}`}>
|
|
||||||
{d.os} <span class="hint">· {d.hint}</span>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -197,48 +153,88 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
|
|||||||
<div class="num">2</div>
|
<div class="num">2</div>
|
||||||
<div class="body">
|
<div class="body">
|
||||||
<span class="kicker">{t.join.s2.kicker}</span>
|
<span class="kicker">{t.join.s2.kicker}</span>
|
||||||
<h3>{t.join.s2.h}</h3>
|
<h3>{t.join.s2.h.replace("{name}", launcherName)}</h3>
|
||||||
<p>
|
<p>{t.join.s2.p.replace(/\{name\}/g, launcherName)}</p>
|
||||||
{t.join.s2.pa} <strong style="color:var(--text)">{LITERALS.authlib}</strong> {t.join.s2.pb}
|
<div class="actions" id="launcher-dl" data-src={launcherJsonUrl}>
|
||||||
</p>
|
<span class="mc-btn sm dl-skeleton" aria-hidden="true"></span>
|
||||||
<div class="actions" style="margin-bottom:14px">
|
<span class="mc-btn sm dl-skeleton" aria-hidden="true"></span>
|
||||||
<CopyChip value={site.authlibUrl} variant="url" label={t.join.s2.copy} copiedLabel={t.copied} />
|
<span class="mc-btn sm dl-skeleton" aria-hidden="true"></span>
|
||||||
</div>
|
|
||||||
<p>
|
|
||||||
{t.join.s2.registerPre} <a href={site.authUrl}>{site.authUrl}</a>.
|
|
||||||
</p>
|
|
||||||
<span class="hint">
|
|
||||||
{t.join.s2.caPre} <a href={site.caCertUrl}>{t.join.s2.caLink}</a> {t.join.s2.caPost}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- STEP 3 -->
|
|
||||||
<div class="step reveal">
|
|
||||||
<div class="num">3</div>
|
|
||||||
<div class="body">
|
|
||||||
<span class="kicker">{t.join.s3.kicker}</span>
|
|
||||||
<h3>{t.join.s3.h}</h3>
|
|
||||||
<p>{t.join.s3.p}</p>
|
|
||||||
<div class="actions">
|
|
||||||
<CopyChip value={site.packwizUrl} variant="url" label={t.join.s2.copy} copiedLabel={t.copied} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- STEP 4 -->
|
<!-- STATUS -->
|
||||||
<div class="step reveal">
|
<section id="status" class="pad">
|
||||||
<div class="num">4</div>
|
<div class="wrap">
|
||||||
<div class="body">
|
<div class="sec-head reveal">
|
||||||
<span class="kicker">{t.join.s4.kicker}</span>
|
<span class="eyebrow">{t.status.eyebrow}</span>
|
||||||
<h3>{t.join.s4.h}</h3>
|
<h2 class="section-title">{t.status.title}</h2>
|
||||||
<p>
|
<p class="lead">{t.status.lead}</p>
|
||||||
{t.join.s4.pa} <kbd>{LITERALS.multiplayer}</kbd> → <kbd>{LITERALS.addServer}</kbd>{t.join.s4.pb}
|
</div>
|
||||||
</p>
|
<div class="reveal">
|
||||||
<div class="actions">
|
<ServerCard
|
||||||
<CopyChip value={site.serverAddress} variant="ip" label={t.join.s4.copy} copiedLabel={t.copied} />
|
modded={t.status.modded}
|
||||||
|
motd={t.status.motd}
|
||||||
|
upTo={t.status.upTo}
|
||||||
|
emptyLabel={t.status.empty}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- MEMBERS (registered players roster) -->
|
||||||
|
<section id="members" class="pad" style="background: var(--bg-2)">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="sec-head reveal">
|
||||||
|
<span class="eyebrow">{t.members.eyebrow}</span>
|
||||||
|
<h2 class="section-title">{t.members.title}</h2>
|
||||||
|
<p class="lead">{t.members.lead}</p>
|
||||||
|
</div>
|
||||||
|
<div class="reveal">
|
||||||
|
<PlayerRoster emptyLabel={t.members.empty} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- MODS (installed-pack list, auto-generated) -->
|
||||||
|
<section id="mods" class="pad" style="background: var(--bg-2)">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="sec-head reveal">
|
||||||
|
<span class="eyebrow">{t.mods.eyebrow}</span>
|
||||||
|
<h2 class="section-title">{t.mods.title}</h2>
|
||||||
|
<p class="lead">{t.mods.lead}</p>
|
||||||
|
</div>
|
||||||
|
<div class="reveal">
|
||||||
|
<ModList emptyLabel={t.mods.empty} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- FEATURES -->
|
||||||
|
<section id="features" class="pad">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="sec-head reveal">
|
||||||
|
<span class="eyebrow">{t.features.eyebrow}</span>
|
||||||
|
<h2 class="section-title">{t.features.title}</h2>
|
||||||
|
<p class="lead">{t.features.lead}</p>
|
||||||
|
</div>
|
||||||
|
<div class="feat-split">
|
||||||
|
<div class="feat-grid">
|
||||||
|
{site.features.map((f, i) => (
|
||||||
|
<div class="feat reveal" style={`transition-delay:${(i % 2) * 80}ms`}>
|
||||||
|
<PixelIcon glyph={f.glyph} gold={f.gold} />
|
||||||
|
<div>
|
||||||
|
<h3>{t.features.items[i].h}</h3>
|
||||||
|
<p>{t.features.items[i].p}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
|
</div>
|
||||||
|
<div class="feat-aside reveal">
|
||||||
|
<img src="/Goat_JE1_BE1.webp" alt="" width="1200" height="1200" loading="lazy" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -249,11 +245,12 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
|
|||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
<div class="wrap footer-in">
|
<div class="wrap footer-in">
|
||||||
<a class="brand" href="#top">
|
<a class="brand" href="#top">
|
||||||
<Creeper />
|
<img class="brand-logo" src="/ulicraft-logo-mini.svg" alt="" width="34" height="34" />
|
||||||
<span class="name">ULICRAFT</span>
|
<span class="name">ULICRAFT</span>
|
||||||
</a>
|
</a>
|
||||||
<div class="footer-links">
|
<div class="footer-links">
|
||||||
<a class="mc-btn primary sm" href="#join">{t.footer.join}</a>
|
<a class="mc-btn primary sm" href="#join">{t.footer.join}</a>
|
||||||
|
<a class="mc-btn sm" href={site.statusUrl} target="_blank" rel="noopener">{t.footer.status}</a>
|
||||||
</div>
|
</div>
|
||||||
<p class="disc">
|
<p class="disc">
|
||||||
{t.footer.disc} {site.name} · {site.baseDomain}
|
{t.footer.disc} {site.name} · {site.baseDomain}
|
||||||
@@ -312,6 +309,35 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
|
|||||||
);
|
);
|
||||||
els.forEach((el) => io.observe(el));
|
els.forEach((el) => io.observe(el));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Launcher downloads — fetch the live launcher.json once on load (no
|
||||||
|
// polling) and render per-OS buttons into the #launcher-dl skeleton.
|
||||||
|
// launcher.json shape: { files: [{ name, os, arch, ext, url }] }.
|
||||||
|
const dl = document.getElementById("launcher-dl");
|
||||||
|
const src = dl?.dataset.src;
|
||||||
|
if (dl && src) {
|
||||||
|
const OS: Record<string, string> = { windows: "Windows", macos: "macOS", linux: "Linux" };
|
||||||
|
fetch(src, { cache: "no-store" })
|
||||||
|
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
|
||||||
|
.then((data) => {
|
||||||
|
const files = Array.isArray(data?.files) ? data.files : [];
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
for (const f of files) {
|
||||||
|
if (!f?.url) continue;
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.className = "mc-btn sm";
|
||||||
|
a.href = f.url;
|
||||||
|
a.append(`${OS[f.os] ?? f.os ?? "Download"} `);
|
||||||
|
const hint = document.createElement("span");
|
||||||
|
hint.className = "hint";
|
||||||
|
hint.textContent = `· ${[f.arch, f.ext].filter(Boolean).join(" · ")}`;
|
||||||
|
a.append(hint);
|
||||||
|
frag.append(a);
|
||||||
|
}
|
||||||
|
dl.replaceChildren(frag); // empty list → no buttons (skeleton cleared)
|
||||||
|
})
|
||||||
|
.catch(() => dl.replaceChildren()); // unreachable/error → clear skeleton
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
---
|
||||||
|
import { site, HEAD_FONTS } from "../../data/site";
|
||||||
|
import {
|
||||||
|
ui,
|
||||||
|
LANGS,
|
||||||
|
LANG_LABEL,
|
||||||
|
LANG_PATH,
|
||||||
|
LANG_ACCOUNT_PATH,
|
||||||
|
LANG_REGISTER_PATH,
|
||||||
|
type Lang,
|
||||||
|
} from "../../i18n/ui";
|
||||||
|
import "../../styles/main.css";
|
||||||
|
|
||||||
|
// One static account page per locale: /account, /es/account, /eu/account.
|
||||||
|
export function getStaticPaths() {
|
||||||
|
return [
|
||||||
|
{ params: { lang: undefined }, props: { locale: "es" as Lang } },
|
||||||
|
{ params: { lang: "en" }, props: { locale: "en" as Lang } },
|
||||||
|
{ params: { lang: "eu" }, props: { locale: "eu" as Lang } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { locale } = Astro.props;
|
||||||
|
const t = ui[locale];
|
||||||
|
const a = t.account;
|
||||||
|
const { theme } = site;
|
||||||
|
const headFont = HEAD_FONTS[theme.headFont] ?? HEAD_FONTS.pixelify;
|
||||||
|
const homePath = LANG_PATH[locale];
|
||||||
|
const registerPath = LANG_REGISTER_PATH[locale];
|
||||||
|
const accountPath = LANG_ACCOUNT_PATH[locale];
|
||||||
|
|
||||||
|
// Strings the client script needs at runtime (kept minimal, passed via define:vars).
|
||||||
|
const clientCfg = {
|
||||||
|
apiUrl: site.draslApiUrl,
|
||||||
|
avatarUrl: site.avatarUrl,
|
||||||
|
avatarMode: site.avatarMode,
|
||||||
|
submit: a.submit,
|
||||||
|
submitting: a.submitting,
|
||||||
|
skinUpload: a.skinUpload,
|
||||||
|
skinUploading: a.skinUploading,
|
||||||
|
skinOk: a.skinOk,
|
||||||
|
refresh: a.refresh,
|
||||||
|
skinPreviewNote: a.skinPreviewNote,
|
||||||
|
resetSkin: a.resetSkin,
|
||||||
|
resetConfirm: a.resetConfirm,
|
||||||
|
resetOk: a.resetOk,
|
||||||
|
errFields: a.errFields,
|
||||||
|
errSkinType: a.errSkinType,
|
||||||
|
errNetwork: a.errNetwork,
|
||||||
|
};
|
||||||
|
---
|
||||||
|
|
||||||
|
<!doctype html>
|
||||||
|
<html
|
||||||
|
lang={t.htmlLang}
|
||||||
|
data-mood={theme.mood}
|
||||||
|
data-head={theme.headFont}
|
||||||
|
style={`--font-head: ${headFont}`}
|
||||||
|
>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>{site.name} — {a.title}</title>
|
||||||
|
<meta name="description" content={a.lead} />
|
||||||
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
|
<link rel="stylesheet" href="/fonts/fonts.css" />
|
||||||
|
{LANGS.map((l) => <link rel="alternate" hreflang={l} href={LANG_ACCOUNT_PATH[l]} />)}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- NAV -->
|
||||||
|
<header class="nav">
|
||||||
|
<div class="wrap nav-in">
|
||||||
|
<a class="brand" href={homePath}>
|
||||||
|
<img class="brand-logo" src="/ulicraft-logo-mini.svg" alt="" width="34" height="34" />
|
||||||
|
<span class="name">ULICRAFT</span>
|
||||||
|
</a>
|
||||||
|
<nav class="nav-links">
|
||||||
|
<a href={homePath}>{a.navHome}</a>
|
||||||
|
</nav>
|
||||||
|
<span class="nav-spacer"></span>
|
||||||
|
<div class="lang-switch" aria-label="Language">
|
||||||
|
{LANGS.map((l) => (
|
||||||
|
<a
|
||||||
|
href={LANG_ACCOUNT_PATH[l]}
|
||||||
|
class:list={[l === locale && "on"]}
|
||||||
|
aria-current={l === locale ? "true" : undefined}
|
||||||
|
>{LANG_LABEL[l]}</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<a class="mc-btn sm" href={registerPath}>{t.nav.register}</a>
|
||||||
|
<a class="mc-btn sm" href={accountPath}>{t.nav.login}</a>
|
||||||
|
<a class="mc-btn primary sm" href={`${homePath}#join`}>{t.nav.play}</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="top">
|
||||||
|
<section class="pad">
|
||||||
|
<div class="reg-wrap">
|
||||||
|
<div class="reg-card">
|
||||||
|
<span class="eyebrow">{a.eyebrow}</span>
|
||||||
|
<h1>{a.title}</h1>
|
||||||
|
<p class="lead">{a.lead}</p>
|
||||||
|
|
||||||
|
<!-- LOGIN (hidden once authenticated) -->
|
||||||
|
<div id="acc-login">
|
||||||
|
<form class="reg-form" id="acc-form" novalidate>
|
||||||
|
<div class="reg-field">
|
||||||
|
<label for="acc-username">{a.usernameLabel}</label>
|
||||||
|
<input class="reg-input" id="acc-username" name="username"
|
||||||
|
type="text" autocomplete="username" placeholder={a.usernamePlaceholder} required />
|
||||||
|
</div>
|
||||||
|
<div class="reg-field">
|
||||||
|
<label for="acc-password">{a.passwordLabel}</label>
|
||||||
|
<input class="reg-input" id="acc-password" name="password"
|
||||||
|
type="password" autocomplete="current-password" placeholder={a.passwordPlaceholder} required />
|
||||||
|
</div>
|
||||||
|
<div class="reg-msg err" id="acc-error" role="alert" hidden></div>
|
||||||
|
<button class="mc-btn primary" id="acc-submit" type="submit">{a.submit}</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="reg-foot">
|
||||||
|
{a.noAccount} <a href={registerPath}>{a.registerLink}</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SKIN PANEL (revealed after login) -->
|
||||||
|
<div class="reg-success" id="acc-panel" hidden>
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow" style="color:var(--accent)">{a.playerLabel}</span>
|
||||||
|
<p class="lead" id="acc-player-name" style="margin-top:6px;font-size:20px;color:var(--text)"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Current avatar preview (NMSR) -->
|
||||||
|
<div id="acc-avatar-wrap" style="display:flex;align-items:flex-start;gap:20px;flex-wrap:wrap">
|
||||||
|
<img id="acc-avatar" src="" alt="avatar" width="96" height="96"
|
||||||
|
style="image-rendering:pixelated;width:96px;height:96px;background:var(--slot)" />
|
||||||
|
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:10px;align-items:flex-start">
|
||||||
|
<button class="mc-btn sm" id="acc-avatar-refresh" type="button">{a.refresh}</button>
|
||||||
|
<p class="reg-skin" style="border:none;padding-top:0;margin:0;color:var(--dim);font-size:13px" id="acc-preview-note"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="reg-skin" id="acc-skin">
|
||||||
|
<div>
|
||||||
|
<h3>{a.skinTitle}</h3>
|
||||||
|
<p class="lead">{a.skinLead}</p>
|
||||||
|
</div>
|
||||||
|
<div class="reg-field">
|
||||||
|
<label class="mc-btn sm" for="acc-skin-file" style="align-self:flex-start">{a.skinChoose}</label>
|
||||||
|
<input id="acc-skin-file" type="file" accept="image/png" hidden />
|
||||||
|
<span class="reg-file-name" id="acc-skin-name"></span>
|
||||||
|
</div>
|
||||||
|
<div class="reg-field">
|
||||||
|
<label>{a.modelLabel}</label>
|
||||||
|
<div class="reg-models">
|
||||||
|
<label><input type="radio" name="skin-model" value="classic" checked /> {a.modelClassic}</label>
|
||||||
|
<label><input type="radio" name="skin-model" value="slim" /> {a.modelSlim}</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="reg-msg" id="acc-skin-msg" role="status" hidden></div>
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:12px;align-items:center">
|
||||||
|
<button class="mc-btn primary" id="acc-skin-upload" type="button">{a.skinUpload}</button>
|
||||||
|
<button class="mc-btn" id="acc-skin-reset" type="button" style="color:var(--dim)">{a.resetSkin}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a class="mc-btn sm" href={homePath}>{a.navHome}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- FOOTER -->
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="wrap footer-in">
|
||||||
|
<a class="brand" href={homePath}>
|
||||||
|
<img class="brand-logo" src="/ulicraft-logo-mini.svg" alt="" width="34" height="34" />
|
||||||
|
<span class="name">ULICRAFT</span>
|
||||||
|
</a>
|
||||||
|
<div class="footer-links">
|
||||||
|
<a class="mc-btn sm" href={site.statusUrl} target="_blank" rel="noopener">{t.footer.status}</a>
|
||||||
|
</div>
|
||||||
|
<p class="disc">
|
||||||
|
{t.footer.disc} {site.name} · {site.baseDomain}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script define:vars={{ cfg: clientCfg }}>
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
const form = $("acc-form");
|
||||||
|
const loginBox = $("acc-login");
|
||||||
|
const errBox = $("acc-error");
|
||||||
|
const submitBtn = $("acc-submit");
|
||||||
|
const panel = $("acc-panel");
|
||||||
|
const headers = { "Content-Type": "application/json" };
|
||||||
|
|
||||||
|
// Token + player kept in memory only — never stored in localStorage.
|
||||||
|
let apiToken = null;
|
||||||
|
let player = null;
|
||||||
|
|
||||||
|
const showMsg = (box, msg) => {
|
||||||
|
box.textContent = msg;
|
||||||
|
box.hidden = false;
|
||||||
|
};
|
||||||
|
const apiMessage = async (res, fallback) => {
|
||||||
|
try {
|
||||||
|
const data = await res.json();
|
||||||
|
return data && data.message ? data.message : fallback;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Login ----
|
||||||
|
form.addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
errBox.hidden = true;
|
||||||
|
const username = $("acc-username").value.trim();
|
||||||
|
const password = $("acc-password").value;
|
||||||
|
if (!username || !password) {
|
||||||
|
errBox.classList.add("err");
|
||||||
|
showMsg(errBox, cfg.errFields);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.textContent = cfg.submitting;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${cfg.apiUrl}/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
errBox.classList.add("err");
|
||||||
|
showMsg(errBox, await apiMessage(res, cfg.errNetwork));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
apiToken = data.apiToken;
|
||||||
|
player = data.user.players[0];
|
||||||
|
|
||||||
|
// Show panel — hide the whole login block (form + heading foot).
|
||||||
|
loginBox.hidden = true;
|
||||||
|
$("acc-player-name").textContent = player.name;
|
||||||
|
// Set NMSR avatar
|
||||||
|
const avatarImg = $("acc-avatar");
|
||||||
|
avatarImg.src = `${cfg.avatarUrl}/${cfg.avatarMode}/${player.uuid}?size=96`;
|
||||||
|
avatarImg.alt = player.name;
|
||||||
|
// Show NMSR lag hint
|
||||||
|
$("acc-preview-note").textContent = cfg.skinPreviewNote;
|
||||||
|
panel.hidden = false;
|
||||||
|
} catch {
|
||||||
|
errBox.classList.add("err");
|
||||||
|
showMsg(errBox, cfg.errNetwork);
|
||||||
|
} finally {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.textContent = cfg.submit;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Avatar refresh (re-fetch NMSR with a cache-bust) ----
|
||||||
|
$("acc-avatar-refresh").addEventListener("click", () => {
|
||||||
|
if (!player) return;
|
||||||
|
$("acc-avatar").src = `${cfg.avatarUrl}/${cfg.avatarMode}/${player.uuid}?size=96&t=${Date.now()}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Skin upload ----
|
||||||
|
const fileInput = $("acc-skin-file");
|
||||||
|
const fileName = $("acc-skin-name");
|
||||||
|
const skinMsg = $("acc-skin-msg");
|
||||||
|
const skinBtn = $("acc-skin-upload");
|
||||||
|
const resetBtn = $("acc-skin-reset");
|
||||||
|
|
||||||
|
fileInput.addEventListener("change", () => {
|
||||||
|
skinMsg.hidden = true;
|
||||||
|
skinMsg.classList.remove("ok", "err");
|
||||||
|
fileName.textContent = fileInput.files && fileInput.files[0] ? fileInput.files[0].name : "";
|
||||||
|
});
|
||||||
|
|
||||||
|
const readBase64 = (file) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const fr = new FileReader();
|
||||||
|
fr.onload = () => resolve(String(fr.result).split(",")[1]);
|
||||||
|
fr.onerror = reject;
|
||||||
|
fr.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
skinBtn.addEventListener("click", async () => {
|
||||||
|
skinMsg.hidden = true;
|
||||||
|
skinMsg.classList.remove("ok", "err");
|
||||||
|
const file = fileInput.files && fileInput.files[0];
|
||||||
|
if (!file) {
|
||||||
|
skinMsg.classList.add("err");
|
||||||
|
showMsg(skinMsg, cfg.errSkinType);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.type !== "image/png") {
|
||||||
|
skinMsg.classList.add("err");
|
||||||
|
showMsg(skinMsg, cfg.errSkinType);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const model = document.querySelector('input[name="skin-model"]:checked').value;
|
||||||
|
skinBtn.disabled = true;
|
||||||
|
resetBtn.disabled = true;
|
||||||
|
skinBtn.textContent = cfg.skinUploading;
|
||||||
|
try {
|
||||||
|
const skinBase64 = await readBase64(file);
|
||||||
|
const res = await fetch(`${cfg.apiUrl}/players/${player.uuid}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { ...headers, Authorization: `Bearer ${apiToken}` },
|
||||||
|
body: JSON.stringify({ skinBase64, skinModel: model }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
skinMsg.classList.add("err");
|
||||||
|
showMsg(skinMsg, await apiMessage(res, cfg.errNetwork));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Show the local file immediately as instant confirmation (NMSR lags ~1m).
|
||||||
|
const dataUrl = URL.createObjectURL(file);
|
||||||
|
$("acc-avatar").src = dataUrl;
|
||||||
|
skinMsg.classList.add("ok");
|
||||||
|
showMsg(skinMsg, cfg.skinOk);
|
||||||
|
// Clear file picker
|
||||||
|
fileInput.value = "";
|
||||||
|
fileName.textContent = "";
|
||||||
|
} catch {
|
||||||
|
skinMsg.classList.add("err");
|
||||||
|
showMsg(skinMsg, cfg.errNetwork);
|
||||||
|
} finally {
|
||||||
|
skinBtn.disabled = false;
|
||||||
|
resetBtn.disabled = false;
|
||||||
|
skinBtn.textContent = cfg.skinUpload;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Reset to default ----
|
||||||
|
resetBtn.addEventListener("click", async () => {
|
||||||
|
if (!window.confirm(cfg.resetConfirm)) return;
|
||||||
|
skinMsg.hidden = true;
|
||||||
|
skinMsg.classList.remove("ok", "err");
|
||||||
|
skinBtn.disabled = true;
|
||||||
|
resetBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${cfg.apiUrl}/players/${player.uuid}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { ...headers, Authorization: `Bearer ${apiToken}` },
|
||||||
|
body: JSON.stringify({ deleteSkin: true }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
skinMsg.classList.add("err");
|
||||||
|
showMsg(skinMsg, await apiMessage(res, cfg.errNetwork));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Reset preview back to NMSR (will eventually show the default skin).
|
||||||
|
$("acc-avatar").src = `${cfg.avatarUrl}/${cfg.avatarMode}/${player.uuid}?size=96&t=${Date.now()}`;
|
||||||
|
skinMsg.classList.add("ok");
|
||||||
|
showMsg(skinMsg, cfg.resetOk);
|
||||||
|
} catch {
|
||||||
|
skinMsg.classList.add("err");
|
||||||
|
showMsg(skinMsg, cfg.errNetwork);
|
||||||
|
} finally {
|
||||||
|
skinBtn.disabled = false;
|
||||||
|
resetBtn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
---
|
||||||
|
import { site, LITERALS, HEAD_FONTS } from "../../data/site";
|
||||||
|
import {
|
||||||
|
ui,
|
||||||
|
LANGS,
|
||||||
|
LANG_LABEL,
|
||||||
|
LANG_PATH,
|
||||||
|
LANG_FJORD_PATH,
|
||||||
|
LANG_REGISTER_PATH,
|
||||||
|
LANG_ACCOUNT_PATH,
|
||||||
|
type Lang,
|
||||||
|
} from "../../i18n/ui";
|
||||||
|
import CopyChip from "../../components/CopyChip.astro";
|
||||||
|
import "../../styles/main.css";
|
||||||
|
|
||||||
|
// One static Fjord page per locale: /fjord, /es/fjord, /eu/fjord. This is the
|
||||||
|
// alternative manual path — intentionally NOT linked from the homepage nav.
|
||||||
|
export function getStaticPaths() {
|
||||||
|
return [
|
||||||
|
{ params: { lang: undefined }, props: { locale: "es" as Lang } },
|
||||||
|
{ params: { lang: "en" }, props: { locale: "en" as Lang } },
|
||||||
|
{ params: { lang: "eu" }, props: { locale: "eu" as Lang } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { locale } = Astro.props;
|
||||||
|
const t = ui[locale];
|
||||||
|
const f = t.fjord;
|
||||||
|
const { theme, fjord } = site;
|
||||||
|
const headFont = HEAD_FONTS[theme.headFont] ?? HEAD_FONTS.pixelify;
|
||||||
|
const homePath = LANG_PATH[locale];
|
||||||
|
const registerPath = LANG_REGISTER_PATH[locale];
|
||||||
|
const accountPath = LANG_ACCOUNT_PATH[locale];
|
||||||
|
const fjordPack = site.launcherManifest.fjordPack;
|
||||||
|
---
|
||||||
|
|
||||||
|
<!doctype html>
|
||||||
|
<html
|
||||||
|
lang={t.htmlLang}
|
||||||
|
data-mood={theme.mood}
|
||||||
|
data-head={theme.headFont}
|
||||||
|
style={`--font-head: ${headFont}`}
|
||||||
|
>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>{site.name} — {f.title}</title>
|
||||||
|
<meta name="description" content={f.lead} />
|
||||||
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
|
<link rel="stylesheet" href="/fonts/fonts.css" />
|
||||||
|
{LANGS.map((l) => <link rel="alternate" hreflang={l} href={LANG_FJORD_PATH[l]} />)}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- NAV -->
|
||||||
|
<header class="nav">
|
||||||
|
<div class="wrap nav-in">
|
||||||
|
<a class="brand" href={homePath}>
|
||||||
|
<img class="brand-logo" src="/ulicraft-logo-mini.svg" alt="" width="34" height="34" />
|
||||||
|
<span class="name">ULICRAFT</span>
|
||||||
|
</a>
|
||||||
|
<nav class="nav-links">
|
||||||
|
<a href={homePath}>{f.navHome}</a>
|
||||||
|
</nav>
|
||||||
|
<span class="nav-spacer"></span>
|
||||||
|
<div class="lang-switch" aria-label="Language">
|
||||||
|
{LANGS.map((l) => (
|
||||||
|
<a
|
||||||
|
href={LANG_FJORD_PATH[l]}
|
||||||
|
class:list={[l === locale && "on"]}
|
||||||
|
aria-current={l === locale ? "true" : undefined}
|
||||||
|
>{LANG_LABEL[l]}</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<a class="mc-btn sm" href={registerPath}>{t.nav.register}</a>
|
||||||
|
<a class="mc-btn sm" href={accountPath}>{t.nav.login}</a>
|
||||||
|
<a class="mc-btn primary sm" href={`${homePath}#join`}>{t.nav.play}</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="top">
|
||||||
|
<section id="join" class="pad">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="sec-head reveal">
|
||||||
|
<span class="eyebrow">{f.eyebrow}</span>
|
||||||
|
<h2 class="section-title">{f.title}</h2>
|
||||||
|
<p class="lead">{f.lead}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="steps">
|
||||||
|
<!-- STEP 1 -->
|
||||||
|
<div class="step reveal">
|
||||||
|
<div class="num">1</div>
|
||||||
|
<div class="body">
|
||||||
|
<span class="kicker">{f.s1.kicker}</span>
|
||||||
|
<h3>{f.s1.h}</h3>
|
||||||
|
<p>{f.s1.p}</p>
|
||||||
|
<div class="actions">
|
||||||
|
{fjord.downloads.map((d) => (
|
||||||
|
<a class="mc-btn sm" href={`${fjord.base}/${d.file}`}>
|
||||||
|
{d.os} <span class="hint">· {d.hint}</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- STEP 2 -->
|
||||||
|
<div class="step reveal">
|
||||||
|
<div class="num">2</div>
|
||||||
|
<div class="body">
|
||||||
|
<span class="kicker">{f.s2.kicker}</span>
|
||||||
|
<h3>{f.s2.h}</h3>
|
||||||
|
<p>
|
||||||
|
{f.s2.pa} <strong style="color:var(--text)">{LITERALS.authlib}</strong> {f.s2.pb}
|
||||||
|
</p>
|
||||||
|
<div class="actions">
|
||||||
|
<CopyChip value={site.authlibUrl} variant="url" label={f.s2.copy} copiedLabel={t.copied} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- STEP 3 -->
|
||||||
|
<div class="step reveal">
|
||||||
|
<div class="num">3</div>
|
||||||
|
<div class="body">
|
||||||
|
<span class="kicker">{f.s3.kicker}</span>
|
||||||
|
<h3>{f.s3.h}</h3>
|
||||||
|
<p>{f.s3.p}</p>
|
||||||
|
<div class="actions">
|
||||||
|
{fjordPack ? (
|
||||||
|
<a class="mc-btn sm" href={fjordPack.url}>
|
||||||
|
{fjordPack.filename} <span class="hint">· .mrpack</span>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<p class="hint">{f.noPack}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- STEP 4 -->
|
||||||
|
<div class="step reveal">
|
||||||
|
<div class="num">4</div>
|
||||||
|
<div class="body">
|
||||||
|
<span class="kicker">{f.s4.kicker}</span>
|
||||||
|
<h3>{f.s4.h}</h3>
|
||||||
|
<p>
|
||||||
|
{f.s4.pa} <kbd>{LITERALS.multiplayer}</kbd> → <kbd>{LITERALS.addServer}</kbd>{f.s4.pb}
|
||||||
|
</p>
|
||||||
|
<div class="actions">
|
||||||
|
<CopyChip value={site.serverAddress} variant="ip" label={f.s4.copy} copiedLabel={t.copied} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- FOOTER -->
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="wrap footer-in">
|
||||||
|
<a class="brand" href={homePath}>
|
||||||
|
<img class="brand-logo" src="/ulicraft-logo-mini.svg" alt="" width="34" height="34" />
|
||||||
|
<span class="name">ULICRAFT</span>
|
||||||
|
</a>
|
||||||
|
<div class="footer-links">
|
||||||
|
<a class="mc-btn primary sm" href={`${homePath}#join`}>{t.footer.join}</a>
|
||||||
|
<a class="mc-btn sm" href={site.statusUrl} target="_blank" rel="noopener">{t.footer.status}</a>
|
||||||
|
</div>
|
||||||
|
<p class="disc">
|
||||||
|
{t.footer.disc} {site.name} · {site.baseDomain}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Copy-to-clipboard for every [data-copy] button (HTTP-LAN safe fallback).
|
||||||
|
// Mirrors the global handler in [...lang].astro (CopyChip depends on it).
|
||||||
|
function copyText(text: string) {
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
const ta = document.createElement("textarea");
|
||||||
|
ta.value = text;
|
||||||
|
ta.style.position = "fixed";
|
||||||
|
ta.style.opacity = "0";
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
try {
|
||||||
|
document.execCommand("copy");
|
||||||
|
} catch {}
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
}
|
||||||
|
document.querySelectorAll<HTMLButtonElement>("button[data-copy]").forEach((btn) => {
|
||||||
|
let timer: number | undefined;
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
copyText(btn.dataset.copy ?? "");
|
||||||
|
const label = btn.dataset.label ?? "Copy";
|
||||||
|
btn.textContent = btn.dataset.copied ?? "Copied!";
|
||||||
|
btn.classList.add("copied");
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = window.setTimeout(() => {
|
||||||
|
btn.textContent = label;
|
||||||
|
btn.classList.remove("copied");
|
||||||
|
}, 1600);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scroll reveal — visible at rest; .anim added when scrolled into view.
|
||||||
|
if (!window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||||||
|
const els = [...document.querySelectorAll<HTMLElement>(".reveal")];
|
||||||
|
const io = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
for (const e of entries) {
|
||||||
|
if (e.isIntersecting) {
|
||||||
|
e.target.classList.add("anim");
|
||||||
|
io.unobserve(e.target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: "0px 0px -10% 0px" }
|
||||||
|
);
|
||||||
|
els.forEach((el) => io.observe(el));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
---
|
||||||
|
import { site, HEAD_FONTS } from "../../data/site";
|
||||||
|
import {
|
||||||
|
ui,
|
||||||
|
LANGS,
|
||||||
|
LANG_LABEL,
|
||||||
|
LANG_PATH,
|
||||||
|
LANG_REGISTER_PATH,
|
||||||
|
LANG_ACCOUNT_PATH,
|
||||||
|
type Lang,
|
||||||
|
} from "../../i18n/ui";
|
||||||
|
import "../../styles/main.css";
|
||||||
|
|
||||||
|
// One static register page per locale: /register, /es/register, /eu/register.
|
||||||
|
export function getStaticPaths() {
|
||||||
|
return [
|
||||||
|
{ params: { lang: undefined }, props: { locale: "es" as Lang } },
|
||||||
|
{ params: { lang: "en" }, props: { locale: "en" as Lang } },
|
||||||
|
{ params: { lang: "eu" }, props: { locale: "eu" as Lang } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { locale } = Astro.props;
|
||||||
|
const t = ui[locale];
|
||||||
|
const r = t.register;
|
||||||
|
const { theme } = site;
|
||||||
|
const headFont = HEAD_FONTS[theme.headFont] ?? HEAD_FONTS.pixelify;
|
||||||
|
const homePath = LANG_PATH[locale];
|
||||||
|
const registerPath = LANG_REGISTER_PATH[locale];
|
||||||
|
const accountPath = LANG_ACCOUNT_PATH[locale];
|
||||||
|
// Visibility of the invite-code field (default hidden). Independent of the
|
||||||
|
// backend gate (site.registrationRequiresInvite / Drasl RequireInvite).
|
||||||
|
const showInvite = site.registrationShowInvite;
|
||||||
|
|
||||||
|
// Strings the client script needs at runtime (kept minimal, passed via define:vars).
|
||||||
|
const clientCfg = {
|
||||||
|
apiUrl: site.draslApiUrl,
|
||||||
|
showInvite,
|
||||||
|
submit: r.submit,
|
||||||
|
submitting: r.submitting,
|
||||||
|
skinUpload: r.skinUpload,
|
||||||
|
skinUploading: r.skinUploading,
|
||||||
|
skinOk: r.skinOk,
|
||||||
|
successBody: r.successBody,
|
||||||
|
errFields: r.errFields,
|
||||||
|
errSkinType: r.errSkinType,
|
||||||
|
errNetwork: r.errNetwork,
|
||||||
|
};
|
||||||
|
---
|
||||||
|
|
||||||
|
<!doctype html>
|
||||||
|
<html
|
||||||
|
lang={t.htmlLang}
|
||||||
|
data-mood={theme.mood}
|
||||||
|
data-head={theme.headFont}
|
||||||
|
style={`--font-head: ${headFont}`}
|
||||||
|
>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>{site.name} — {r.title}</title>
|
||||||
|
<meta name="description" content={r.lead} />
|
||||||
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
|
<link rel="stylesheet" href="/fonts/fonts.css" />
|
||||||
|
{LANGS.map((l) => <link rel="alternate" hreflang={l} href={LANG_REGISTER_PATH[l]} />)}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- NAV -->
|
||||||
|
<header class="nav">
|
||||||
|
<div class="wrap nav-in">
|
||||||
|
<a class="brand" href={homePath}>
|
||||||
|
<img class="brand-logo" src="/ulicraft-logo-mini.svg" alt="" width="34" height="34" />
|
||||||
|
<span class="name">ULICRAFT</span>
|
||||||
|
</a>
|
||||||
|
<nav class="nav-links">
|
||||||
|
<a href={homePath}>{r.navHome}</a>
|
||||||
|
</nav>
|
||||||
|
<span class="nav-spacer"></span>
|
||||||
|
<div class="lang-switch" aria-label="Language">
|
||||||
|
{LANGS.map((l) => (
|
||||||
|
<a
|
||||||
|
href={LANG_REGISTER_PATH[l]}
|
||||||
|
class:list={[l === locale && "on"]}
|
||||||
|
aria-current={l === locale ? "true" : undefined}
|
||||||
|
>{LANG_LABEL[l]}</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<a class="mc-btn sm" href={registerPath}>{t.nav.register}</a>
|
||||||
|
<a class="mc-btn sm" href={accountPath}>{t.nav.login}</a>
|
||||||
|
<a class="mc-btn primary sm" href={`${homePath}#join`}>{t.nav.play}</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="top">
|
||||||
|
<section class="pad">
|
||||||
|
<div class="reg-wrap">
|
||||||
|
<div class="reg-card">
|
||||||
|
<span class="eyebrow">{r.eyebrow}</span>
|
||||||
|
<h1>{r.title}</h1>
|
||||||
|
<p class="lead">{r.lead}</p>
|
||||||
|
|
||||||
|
<!-- REGISTER FORM -->
|
||||||
|
<form class="reg-form" id="reg-form" novalidate>
|
||||||
|
<div class="reg-field">
|
||||||
|
<label for="reg-username">{r.usernameLabel}</label>
|
||||||
|
<input class="reg-input" id="reg-username" name="username"
|
||||||
|
type="text" autocomplete="username" placeholder={r.usernamePlaceholder} required />
|
||||||
|
</div>
|
||||||
|
<div class="reg-field">
|
||||||
|
<label for="reg-password">{r.passwordLabel}</label>
|
||||||
|
<input class="reg-input" id="reg-password" name="password"
|
||||||
|
type="password" autocomplete="new-password" placeholder={r.passwordPlaceholder} required />
|
||||||
|
</div>
|
||||||
|
{showInvite && (
|
||||||
|
<div class="reg-field">
|
||||||
|
<label for="reg-invite">{r.inviteLabel}</label>
|
||||||
|
<input class="reg-input" id="reg-invite" name="invite"
|
||||||
|
type="text" placeholder={r.invitePlaceholder} required />
|
||||||
|
<span class="hint">{r.inviteHint}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div class="reg-msg err" id="reg-error" role="alert" hidden></div>
|
||||||
|
<button class="mc-btn primary" id="reg-submit" type="submit">{r.submit}</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="reg-foot">
|
||||||
|
{r.haveAccount} <a href={LANG_ACCOUNT_PATH[locale]}>{r.loginLink}</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- SUCCESS + SKIN -->
|
||||||
|
<div class="reg-success" id="reg-success" hidden>
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow" style="color:var(--accent)">{r.successTitle}</span>
|
||||||
|
<p class="lead" id="reg-success-body" style="margin-top:10px"></p>
|
||||||
|
</div>
|
||||||
|
<div class="reg-field">
|
||||||
|
<label>{r.uuidLabel}</label>
|
||||||
|
<div class="reg-uuid" id="reg-uuid"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="reg-skin" id="reg-skin">
|
||||||
|
<div>
|
||||||
|
<h3>{r.skinTitle}</h3>
|
||||||
|
<p class="lead">{r.skinLead}</p>
|
||||||
|
</div>
|
||||||
|
<div class="reg-field">
|
||||||
|
<label class="mc-btn sm" for="reg-skin-file" style="align-self:flex-start">{r.skinChoose}</label>
|
||||||
|
<input id="reg-skin-file" type="file" accept="image/png" hidden />
|
||||||
|
<span class="reg-file-name" id="reg-skin-name"></span>
|
||||||
|
</div>
|
||||||
|
<div class="reg-field">
|
||||||
|
<label>{r.modelLabel}</label>
|
||||||
|
<div class="reg-models">
|
||||||
|
<label><input type="radio" name="skin-model" value="classic" checked /> {r.modelClassic}</label>
|
||||||
|
<label><input type="radio" name="skin-model" value="slim" /> {r.modelSlim}</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="reg-msg" id="reg-skin-msg" role="status" hidden></div>
|
||||||
|
<button class="mc-btn" id="reg-skin-upload" type="button">{r.skinUpload}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a class="mc-btn primary" href={`${homePath}#join`}>{r.finish}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- FOOTER -->
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="wrap footer-in">
|
||||||
|
<a class="brand" href={homePath}>
|
||||||
|
<img class="brand-logo" src="/ulicraft-logo-mini.svg" alt="" width="34" height="34" />
|
||||||
|
<span class="name">ULICRAFT</span>
|
||||||
|
</a>
|
||||||
|
<div class="footer-links">
|
||||||
|
<a class="mc-btn sm" href={site.statusUrl} target="_blank" rel="noopener">{t.footer.status}</a>
|
||||||
|
</div>
|
||||||
|
<p class="disc">
|
||||||
|
{t.footer.disc} {site.name} · {site.baseDomain}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script define:vars={{ cfg: clientCfg }}>
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
const form = $("reg-form");
|
||||||
|
const errBox = $("reg-error");
|
||||||
|
const submitBtn = $("reg-submit");
|
||||||
|
const successBox = $("reg-success");
|
||||||
|
const headers = { "Content-Type": "application/json" };
|
||||||
|
|
||||||
|
let apiToken = null;
|
||||||
|
let player = null;
|
||||||
|
|
||||||
|
const showErr = (box, msg) => {
|
||||||
|
box.textContent = msg;
|
||||||
|
box.hidden = false;
|
||||||
|
};
|
||||||
|
const apiMessage = async (res, fallback) => {
|
||||||
|
try {
|
||||||
|
const data = await res.json();
|
||||||
|
return data && data.message ? data.message : fallback;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
form.addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
errBox.hidden = true;
|
||||||
|
const username = $("reg-username").value.trim();
|
||||||
|
const password = $("reg-password").value;
|
||||||
|
const inviteEl = $("reg-invite");
|
||||||
|
const invite = inviteEl ? inviteEl.value.trim() : null;
|
||||||
|
if (!username || !password || (cfg.showInvite && !invite)) {
|
||||||
|
showErr(errBox, cfg.errFields);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.textContent = cfg.submitting;
|
||||||
|
try {
|
||||||
|
const body = { username, password, playerName: username };
|
||||||
|
if (invite) body.inviteCode = invite;
|
||||||
|
let res = await fetch(`${cfg.apiUrl}/users`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
showErr(errBox, await apiMessage(res, cfg.errNetwork));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Log in to get an API token for the (optional) skin upload.
|
||||||
|
res = await fetch(`${cfg.apiUrl}/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
showErr(errBox, await apiMessage(res, cfg.errNetwork));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
apiToken = data.apiToken;
|
||||||
|
player = data.user.players[0];
|
||||||
|
form.hidden = true;
|
||||||
|
$("reg-uuid").textContent = player.uuid;
|
||||||
|
$("reg-success-body").textContent = cfg.successBody.replace("{name}", player.name);
|
||||||
|
successBox.hidden = false;
|
||||||
|
} catch {
|
||||||
|
showErr(errBox, cfg.errNetwork);
|
||||||
|
} finally {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.textContent = cfg.submit;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Skin upload ----
|
||||||
|
const fileInput = $("reg-skin-file");
|
||||||
|
const fileName = $("reg-skin-name");
|
||||||
|
const skinMsg = $("reg-skin-msg");
|
||||||
|
const skinBtn = $("reg-skin-upload");
|
||||||
|
|
||||||
|
fileInput.addEventListener("change", () => {
|
||||||
|
skinMsg.hidden = true;
|
||||||
|
fileName.textContent = fileInput.files && fileInput.files[0] ? fileInput.files[0].name : "";
|
||||||
|
});
|
||||||
|
|
||||||
|
const readBase64 = (file) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const fr = new FileReader();
|
||||||
|
fr.onload = () => resolve(String(fr.result).split(",")[1]);
|
||||||
|
fr.onerror = reject;
|
||||||
|
fr.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
skinBtn.addEventListener("click", async () => {
|
||||||
|
skinMsg.hidden = true;
|
||||||
|
skinMsg.classList.remove("ok", "err");
|
||||||
|
const file = fileInput.files && fileInput.files[0];
|
||||||
|
if (!file) {
|
||||||
|
skinMsg.classList.add("err");
|
||||||
|
showErr(skinMsg, cfg.errSkinType);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.type !== "image/png") {
|
||||||
|
skinMsg.classList.add("err");
|
||||||
|
showErr(skinMsg, cfg.errSkinType);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const model = document.querySelector('input[name="skin-model"]:checked').value;
|
||||||
|
skinBtn.disabled = true;
|
||||||
|
skinBtn.textContent = cfg.skinUploading;
|
||||||
|
try {
|
||||||
|
const skinBase64 = await readBase64(file);
|
||||||
|
const res = await fetch(`${cfg.apiUrl}/players/${player.uuid}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { ...headers, Authorization: `Bearer ${apiToken}` },
|
||||||
|
body: JSON.stringify({ skinBase64, skinModel: model }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
skinMsg.classList.add("err");
|
||||||
|
showErr(skinMsg, await apiMessage(res, cfg.errNetwork));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
skinMsg.classList.add("ok");
|
||||||
|
showErr(skinMsg, cfg.skinOk);
|
||||||
|
} catch {
|
||||||
|
skinMsg.classList.add("err");
|
||||||
|
showErr(skinMsg, cfg.errNetwork);
|
||||||
|
} finally {
|
||||||
|
skinBtn.disabled = false;
|
||||||
|
skinBtn.textContent = cfg.skinUpload;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+139
-19
@@ -207,6 +207,26 @@ section { position: relative; z-index: 1; }
|
|||||||
}
|
}
|
||||||
.mc-btn.sm { font-size: 14px; padding: 10px 16px 12px; }
|
.mc-btn.sm { font-size: 14px; padding: 10px 16px 12px; }
|
||||||
|
|
||||||
|
/* Launcher download skeleton (placeholder while launcher.json loads). */
|
||||||
|
.dl-skeleton {
|
||||||
|
min-width: 9.5rem;
|
||||||
|
color: transparent;
|
||||||
|
pointer-events: none;
|
||||||
|
background-image: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
oklch(1 0 0 / 0.04),
|
||||||
|
oklch(1 0 0 / 0.14),
|
||||||
|
oklch(1 0 0 / 0.04)
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: dl-shimmer 1.2s linear infinite;
|
||||||
|
}
|
||||||
|
.dl-skeleton::after { content: "\00a0"; } /* keep height when text is empty */
|
||||||
|
@keyframes dl-shimmer {
|
||||||
|
from { background-position: 200% 0; }
|
||||||
|
to { background-position: -200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Copy-IP chip ---- */
|
/* ---- Copy-IP chip ---- */
|
||||||
.ip-chip {
|
.ip-chip {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -245,7 +265,7 @@ section { position: relative; z-index: 1; }
|
|||||||
.ip-chip button:hover { filter: brightness(1.1); }
|
.ip-chip button:hover { filter: brightness(1.1); }
|
||||||
.ip-chip button.copied { background: var(--gold); color: oklch(0.20 0.05 80); }
|
.ip-chip button.copied { background: var(--gold); color: oklch(0.20 0.05 80); }
|
||||||
|
|
||||||
/* url variant: long auth/packwiz URLs — smaller, wraps instead of overflowing */
|
/* url variant: long auth URLs — smaller, wraps instead of overflowing */
|
||||||
.ip-chip.url { max-width: 100%; }
|
.ip-chip.url { max-width: 100%; }
|
||||||
.ip-chip.url .ip-val {
|
.ip-chip.url .ip-val {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
@@ -275,6 +295,7 @@ section { position: relative; z-index: 1; }
|
|||||||
height: 68px;
|
height: 68px;
|
||||||
}
|
}
|
||||||
.brand { display: flex; align-items: center; gap: 12px; text-decoration: none; color: var(--text); }
|
.brand { display: flex; align-items: center; gap: 12px; text-decoration: none; color: var(--text); }
|
||||||
|
.brand-logo { width: 34px; height: 34px; flex-shrink: 0; }
|
||||||
.brand .name {
|
.brand .name {
|
||||||
font-family: var(--font-head);
|
font-family: var(--font-head);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -451,30 +472,69 @@ 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 {
|
PLAYER ROSTER (shared avatar tiles)
|
||||||
|
Used by ServerCard.astro (online row) and PlayerRoster.astro
|
||||||
|
(Members grid). A tile is an NMSR avatar + the player name.
|
||||||
|
============================================================ */
|
||||||
|
.roster {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
gap: 16px;
|
gap: 12px;
|
||||||
margin-top: 28px;
|
|
||||||
}
|
}
|
||||||
.tile {
|
.roster-tile {
|
||||||
background: var(--surface);
|
display: flex;
|
||||||
padding: 22px;
|
flex-direction: column;
|
||||||
box-shadow: inset 2px 2px 0 var(--bevel-hi), inset -2px -2px 0 var(--bevel-lo);
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 8px;
|
||||||
|
background: var(--bg-2);
|
||||||
}
|
}
|
||||||
.tile .k { font-family: var(--font-head); font-weight: 600; font-size: 38px; color: var(--text); line-height: 1; font-variant-numeric: tabular-nums; }
|
.roster-tile img {
|
||||||
.tile .k .u { color: var(--accent); font-size: 22px; }
|
width: auto;
|
||||||
.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; }
|
height: 230px;
|
||||||
|
object-fit: contain;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
.roster-name {
|
||||||
|
max-width: 100%;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 18px;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.roster-empty { margin: 0; color: var(--dim); font-size: 14px; }
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
FEATURES
|
FEATURES
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
.feat-split {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.2fr 1fr;
|
||||||
|
gap: 28px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
.feat-grid {
|
.feat-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: 1fr;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
}
|
}
|
||||||
|
.feat-aside {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.feat-aside img {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
height: auto;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.feat-split { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
.feat {
|
.feat {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
@@ -487,7 +547,7 @@ section { position: relative; z-index: 1; }
|
|||||||
.feat h3 { font-size: 21px; margin-bottom: 8px; }
|
.feat h3 { font-size: 21px; margin-bottom: 8px; }
|
||||||
.feat p { margin: 0; color: var(--muted); font-size: 16px; }
|
.feat p { margin: 0; color: var(--muted); font-size: 16px; }
|
||||||
|
|
||||||
/* pixel icon */
|
/* pixel icon — plain (no bevel) */
|
||||||
.picon {
|
.picon {
|
||||||
width: 52px; height: 52px; flex-shrink: 0;
|
width: 52px; height: 52px; flex-shrink: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -495,12 +555,17 @@ section { position: relative; z-index: 1; }
|
|||||||
grid-template-rows: repeat(7, 1fr);
|
grid-template-rows: repeat(7, 1fr);
|
||||||
background: var(--slot);
|
background: var(--slot);
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi);
|
|
||||||
image-rendering: pixelated;
|
image-rendering: pixelated;
|
||||||
}
|
}
|
||||||
.picon i { background: transparent; }
|
.picon i { background: transparent; }
|
||||||
.picon i.on { background: var(--accent); }
|
.picon i.on { background: var(--accent); }
|
||||||
.picon i.go { background: var(--gold); }
|
.picon i.go { background: var(--gold); }
|
||||||
|
/* custom image icon (PixelIcon `img` prop) */
|
||||||
|
.picon-img {
|
||||||
|
width: 52px; height: 52px; flex-shrink: 0;
|
||||||
|
object-fit: contain;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
HOW TO JOIN — steps
|
HOW TO JOIN — steps
|
||||||
@@ -569,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; }
|
||||||
|
|
||||||
@@ -588,13 +652,70 @@ section { position: relative; z-index: 1; }
|
|||||||
.reveal.anim { animation: reveal-in .6s cubic-bezier(.2,.7,.3,1) both; }
|
.reveal.anim { animation: reveal-in .6s cubic-bezier(.2,.7,.3,1) both; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
REGISTER PAGE
|
||||||
|
============================================================ */
|
||||||
|
.reg-wrap { width: min(560px, calc(100% - 48px)); margin-inline: auto; }
|
||||||
|
.reg-card {
|
||||||
|
background: var(--surface);
|
||||||
|
padding: clamp(24px, 4vw, 40px);
|
||||||
|
box-shadow: inset 2px 2px 0 var(--bevel-hi), inset -2px -2px 0 var(--bevel-lo), 0 8px 24px oklch(0 0 0 / 0.4);
|
||||||
|
}
|
||||||
|
.reg-card .eyebrow { display: block; margin-bottom: 12px; }
|
||||||
|
.reg-card h1 { font-size: clamp(28px, 4vw, 40px); }
|
||||||
|
.reg-card .lead { margin: 14px 0 0; font-size: 17px; }
|
||||||
|
|
||||||
|
.reg-form { display: flex; flex-direction: column; gap: 18px; margin-top: 28px; }
|
||||||
|
.reg-field { display: flex; flex-direction: column; gap: 7px; }
|
||||||
|
.reg-field label {
|
||||||
|
font-family: var(--font-pixel); font-size: 9px; letter-spacing: 0.1em;
|
||||||
|
color: var(--dim); text-transform: uppercase; line-height: 1.8;
|
||||||
|
}
|
||||||
|
.reg-input {
|
||||||
|
font-family: var(--font-body); font-size: 16px; color: var(--text);
|
||||||
|
background: var(--slot); border: 0; padding: 13px 14px;
|
||||||
|
box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi);
|
||||||
|
}
|
||||||
|
.reg-input::placeholder { color: var(--dim); }
|
||||||
|
.reg-input:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||||
|
.reg-field .hint { color: var(--dim); font-size: 13px; }
|
||||||
|
|
||||||
|
.reg-models { display: flex; gap: 10px; }
|
||||||
|
.reg-models label {
|
||||||
|
display: inline-flex; align-items: center; gap: 8px;
|
||||||
|
font-family: var(--font-body); font-size: 15px; letter-spacing: 0; text-transform: none;
|
||||||
|
color: var(--muted); cursor: pointer;
|
||||||
|
background: var(--slot); padding: 9px 14px;
|
||||||
|
box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi);
|
||||||
|
}
|
||||||
|
.reg-models input { accent-color: var(--accent); }
|
||||||
|
|
||||||
|
.reg-msg { font-size: 15px; padding: 12px 14px; box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi); }
|
||||||
|
.reg-msg[hidden] { display: none; }
|
||||||
|
.reg-msg.err { background: oklch(0.30 0.10 30); color: oklch(0.92 0.04 40); }
|
||||||
|
.reg-msg.ok { background: var(--slot); color: var(--accent); }
|
||||||
|
|
||||||
|
.reg-foot { margin-top: 22px; color: var(--dim); font-size: 14.5px; }
|
||||||
|
.reg-foot a { color: var(--accent); }
|
||||||
|
|
||||||
|
.reg-success[hidden], .reg-skin[hidden] { display: none; }
|
||||||
|
.reg-success { margin-top: 26px; display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
.reg-uuid {
|
||||||
|
font-family: var(--font-mono); font-size: 17px; color: var(--gold);
|
||||||
|
background: var(--slot); padding: 8px 12px; word-break: break-all;
|
||||||
|
box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi);
|
||||||
|
}
|
||||||
|
.reg-skin { margin-top: 8px; display: flex; flex-direction: column; gap: 16px; border-top: 1px solid var(--line); padding-top: 22px; }
|
||||||
|
.reg-skin h3 { font-size: 22px; }
|
||||||
|
.reg-skin .lead { font-size: 15px; }
|
||||||
|
.reg-file-name { color: var(--muted); font-size: 14px; }
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
RESPONSIVE
|
RESPONSIVE
|
||||||
============================================================ */
|
============================================================ */
|
||||||
@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; }
|
||||||
@@ -604,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; }
|
|
||||||
}
|
}
|
||||||
+192
@@ -0,0 +1,192 @@
|
|||||||
|
{
|
||||||
|
"BetterF3-11.0.3-NeoForge-1.21.1.jar": "client",
|
||||||
|
"BridgingMod-2.6.2+1.21.1.neoforge-release.jar": "client",
|
||||||
|
"CodeChickenLib-1.21.1-4.6.1.526.jar": "both",
|
||||||
|
"Controlling-neoforge-1.21.1-19.0.5.jar": "client",
|
||||||
|
"CreateDragonsPlus-1.11.2b.jar": "both",
|
||||||
|
"CreativeCore_NEOFORGE_v2.13.41_mc1.21.1.jar": "both",
|
||||||
|
"CustomSkinLoader_Universal-14.28.jar": "client",
|
||||||
|
"DefaultSettings-1.20.x-4.0.8-NeoForge.jar": "client",
|
||||||
|
"EasierSleeping-1.21.1-4.0.1.jar": "both",
|
||||||
|
"EasyAnvils-v21.1.0-1.21.1-NeoForge.jar": "both",
|
||||||
|
"EasyMagic-v21.1.4-1.21.1-NeoForge.jar": "both",
|
||||||
|
"Emojiful-Neoforge-1.21-5.2.4-all.jar": "client",
|
||||||
|
"EnderStorage-1.21.1-2.13.0.191.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",
|
||||||
|
"HangGlider-v21.1.0-1.21.1-NeoForge.jar": "both",
|
||||||
|
"ImmediatelyFast-NeoForge-1.6.10+1.21.1.jar": "client",
|
||||||
|
"Jade-1.21.1-NeoForge-15.10.5.jar": "both",
|
||||||
|
"JadeAddons-1.21.1-NeoForge-6.1.0.jar": "both",
|
||||||
|
"JustEnoughProfessions-neoforge-1.21.1-4.0.5.jar": "both",
|
||||||
|
"JustEnoughResources-NeoForge-1.21.1-1.6.0.17.jar": "client",
|
||||||
|
"Loot Beams Refork-neoforge-1.21.1-3.4.7.jar": "both",
|
||||||
|
"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",
|
||||||
|
"Neat-1.21-47-NEOFORGE.jar": "both",
|
||||||
|
"Necronomicon-NeoForge-1.6.0+1.21.jar": "both",
|
||||||
|
"Nirvana Lib-neoforge-1.21.1-2.2.0.jar": "both",
|
||||||
|
"Patchouli-1.21.1-93-NEOFORGE.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.52-1.21.1-NeoForge.jar": "both",
|
||||||
|
"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",
|
||||||
|
"UsefulSlime-neoforge-1.21-1.12.1.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",
|
||||||
|
"architectury-13.0.11-neoforge.jar": "both",
|
||||||
|
"ars_creo-1.21.1-5.4.0.jar": "both",
|
||||||
|
"ars_nouveau-1.21.1-5.12.1.jar": "both",
|
||||||
|
"ars_technica-1.21.1-2.7.6.jar": "both",
|
||||||
|
"baguettelib-1.21.1-NeoForge-2.0.3.jar": "both",
|
||||||
|
"balm-neoforge-1.21.1-21.0.63.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",
|
||||||
|
"charginggadgets-1.14.1.jar": "both",
|
||||||
|
"cloth-config-15.0.140-neoforge.jar": "both",
|
||||||
|
"collective-1.21.1-8.39.jar": "both",
|
||||||
|
"colorfulhearts-neoforge-1.21.1-10.5.9.jar": "client",
|
||||||
|
"comforts-neoforge-9.0.5+1.21.1.jar": "both",
|
||||||
|
"common-networking-neoforge-1.0.21-1.21.1.jar": "both",
|
||||||
|
"configured-neoforge-1.21.1-2.6.3.jar": "both",
|
||||||
|
"copycats-3.0.4+mc.1.21.1-neoforge.jar": "both",
|
||||||
|
"coroutil-neoforge-1.21.0-1.3.9.jar": "both",
|
||||||
|
"corpse-neoforge-1.21.1-1.1.13.jar": "both",
|
||||||
|
"corpsecurioscompat-1.21.1-NeoForge-4.0.1.jar": "both",
|
||||||
|
"create-1.21.1-6.0.10.jar": "both",
|
||||||
|
"create-central-kitchen-2.5.0.jar": "both",
|
||||||
|
"create-enchantment-industry-2.4.1.jar": "both",
|
||||||
|
"create-integrated-farming-1.2.6.jar": "both",
|
||||||
|
"create_connected-1.2.2-mc1.21.1.jar": "both",
|
||||||
|
"create_jet_boots-1.21.1-1.1.3-6.0.6.jar": "both",
|
||||||
|
"create_jetpack-forge-5.1.2.jar": "both",
|
||||||
|
"create_jetpack_curios-1.2.0-neoforge-1.21.1.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_spawner-1.21.1-1.3.0-6.0.8.jar": "both",
|
||||||
|
"createaddition-1.6.0.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",
|
||||||
|
"createultimine-1.21.1-neoforge-1.3.2.jar": "both",
|
||||||
|
"cupboard-1.21.1-3.9.jar": "both",
|
||||||
|
"curios-neoforge-9.5.1+1.21.1.jar": "both",
|
||||||
|
"cwb-neoforge-3.0.0+mc1.21.jar": "client",
|
||||||
|
"defaultoptions-neoforge-1.21.1-21.1.7.jar": "both",
|
||||||
|
"dirigo-neoforge-1.21.1-1.3.0.jar": "both",
|
||||||
|
"drippyloadingscreen_neoforge_3.1.2_MC_1.21.1.jar": "client",
|
||||||
|
"easy-piglins-neoforge-1.21.1-1.1.0.jar": "both",
|
||||||
|
"easy-villagers-neoforge-1.21.1-1.1.42.jar": "both",
|
||||||
|
"easy_npc-neoforge-1.21.1-7.4.1.jar": "both",
|
||||||
|
"easy_npc_config_ui-neoforge-1.21.1-7.4.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",
|
||||||
|
"entity_model_features-3.2.4-1.21-neoforge.jar": "client",
|
||||||
|
"entity_texture_features_1.21-neoforge-7.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",
|
||||||
|
"fastleafdecay-35.jar": "both",
|
||||||
|
"ferritecore-7.0.3-neoforge.jar": "both",
|
||||||
|
"fishingoverhaul-1.21.1-1.2.1.jar": "both",
|
||||||
|
"flightapi-neoforge-1.1.1.jar": "both",
|
||||||
|
"ftb-chunks-neoforge-2101.1.17.jar": "both",
|
||||||
|
"ftb-essentials-neoforge-2101.1.9.jar": "both",
|
||||||
|
"ftb-library-neoforge-2101.1.33.jar": "both",
|
||||||
|
"ftb-teams-neoforge-2101.1.10.jar": "both",
|
||||||
|
"ftb-ultimine-neoforge-2101.1.14.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",
|
||||||
|
"geckolib-neoforge-1.21.1-4.8.4.jar": "both",
|
||||||
|
"handcrafted-neoforge-1.21.1-4.0.3.jar": "both",
|
||||||
|
"immersive_paintings-neoforge-1.21.1-0.7.8.jar": "both",
|
||||||
|
"inventoryessentials-neoforge-1.21.1-21.1.15.jar": "both",
|
||||||
|
"jamlib-neoforge-1.3.6+1.21.1.jar": "both",
|
||||||
|
"jei-1.21.1-neoforge-19.27.0.340.jar": "both",
|
||||||
|
"konkrete_neoforge_1.9.9_MC_1.21.jar": "client",
|
||||||
|
"kotlinforforge-5.11.0-all.jar": "both",
|
||||||
|
"kubejs-create-neoforge-2101.3.1-build.18.jar": "both",
|
||||||
|
"kubejs-neoforge-2101.7.2-build.368.jar": "both",
|
||||||
|
"kubejsdelight-1.1.6.jar": "both",
|
||||||
|
"laserio-1.9.11.jar": "both",
|
||||||
|
"lithium-neoforge-0.15.3+mc1.21.1.jar": "both",
|
||||||
|
"lootintegrations-1.21.1-4.7.jar": "both",
|
||||||
|
"lootintegrations_vanilla-1.7.jar": "both",
|
||||||
|
"lootintegrations_yungs-1.6.jar": "both",
|
||||||
|
"lootjs-neoforge-1.21.1-3.7.0.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",
|
||||||
|
"mechanicals-1.21.1-1.1.0.jar": "both",
|
||||||
|
"mechtrowel-1.3.2.1.jar": "both",
|
||||||
|
"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",
|
||||||
|
"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",
|
||||||
|
"moreoverlays-1.24.2-mc1.21.1-neoforge.jar": "client",
|
||||||
|
"polymorph-neoforge-1.1.0+1.21.1.jar": "both",
|
||||||
|
"ponderjs-neoforge-1.21.1-2.4.0.jar": "client",
|
||||||
|
"rechiseled-1.2.5-neoforge-mc1.21.jar": "both",
|
||||||
|
"repeatable_trial_vaults-neoforge-1.21-1.0.2.jar": "both",
|
||||||
|
"resourcefullib-neoforge-1.21-3.0.12.jar": "both",
|
||||||
|
"rhino-2101.2.7-build.85.jar": "both",
|
||||||
|
"rightclickharvest-neoforge-4.6.1+1.21.1.jar": "both",
|
||||||
|
"sawmill-1.21-1.7.2-neoforge.jar": "both",
|
||||||
|
"seamless-loading-screen-2.2.1+1.21-neoforge.jar": "client",
|
||||||
|
"simplemagnets-1.1.12c-neoforge-mc1.21.jar": "both",
|
||||||
|
"sliceanddice-forge-4.2.4.jar": "both",
|
||||||
|
"smartfeedingtroughs-neoforge-1.21.1-1.2.0.jar": "both",
|
||||||
|
"sodium-extra-neoforge-0.8.7+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",
|
||||||
|
"sophisticatedbackpackscreateintegration-1.21.1-0.1.6.99.jar": "both",
|
||||||
|
"sophisticatedcore-1.21.1-1.4.49.1976.jar": "both",
|
||||||
|
"spark-1.10.124-neoforge.jar": "both",
|
||||||
|
"starbunclemania-1.21.1-1.5.7.jar": "both",
|
||||||
|
"starterkit-1.21.1-8.1.jar": "both",
|
||||||
|
"stattweaks-neoforge-v2.0.0.jar": "both",
|
||||||
|
"supermartijn642configlib-1.1.8-neoforge-mc1.21.jar": "both",
|
||||||
|
"supermartijn642corelib-1.1.21-neoforge-mc1.21.jar": "both",
|
||||||
|
"supplementaries-1.21-3.5.26-neoforge.jar": "both",
|
||||||
|
"tagtooltips-neoforge-1.21.1-1.2.0.jar": "client",
|
||||||
|
"titanium-1.21-4.0.43.jar": "both",
|
||||||
|
"toolsjs-0.1.1.jar": "both",
|
||||||
|
"torchmaster-neoforge-1.21.1-21.1.9.jar": "both",
|
||||||
|
"trashcans-1.0.18c-neoforge-mc1.21.jar": "both",
|
||||||
|
"trophymanager-1.21.1-2.5.0.jar": "both",
|
||||||
|
"txnilib-neoforge-1.0.24-1.21.1.jar": "both",
|
||||||
|
"ulicraftmod-1.21.1-1.1.3-6.0.10.jar": "both",
|
||||||
|
"utilitarian-1.21.1-0.19.2.jar": "both",
|
||||||
|
"voicechat-neoforge-1.21.1-2.6.18.jar": "both",
|
||||||
|
"watut-neoforge-1.21.0-1.2.7.jar": "both",
|
||||||
|
"wm_binaries-3.0.0-rc.1.jar": "client",
|
||||||
|
"world_dimension_nexus-neoforge-1.21.1-0.11.0.jar": "both",
|
||||||
|
"yeetusexperimentus-neoforge-87.0.0.jar": "client",
|
||||||
|
"yet_another_config_lib_v3-3.8.2+1.21.1-neoforge.jar": "both"
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Read-only static browse of the same share (files.) — caddy serves the dir
|
||||||
|
# listing, no auth. Read-only, so no upload tuning: standard 4m body is fine.
|
||||||
|
#
|
||||||
|
# Rendered into ulicraft-caddy.conf.tmpl at ${FILES_BROWSE_SERVER} by
|
||||||
|
# render-nginx.sh, ONLY when FILES_BROWSE=on. With `off` the placeholder
|
||||||
|
# collapses to a comment, so the name has no TLS vhost at all (and needs no
|
||||||
|
# cert — drop `files` from LE_SUBDOMAINS on that host).
|
||||||
|
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;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Monitor (monitor.) — admin-only log stream, basic_auth enforced by caddy.
|
||||||
|
#
|
||||||
|
# Rendered into ulicraft-caddy.conf.tmpl at ${MONITOR_SERVER} by render-nginx.sh,
|
||||||
|
# ONLY when MONITOR_ENABLED=on. With `off` the placeholder collapses to a
|
||||||
|
# comment, so the name has no TLS vhost at all (and needs no cert — drop
|
||||||
|
# `monitor` from LE_SUBDOMAINS on that host).
|
||||||
|
#
|
||||||
|
# proxy_buffering is left at the default ON: the log API is plain polling, not
|
||||||
|
# SSE, precisely so this vhost needs no special treatment. If a future phase
|
||||||
|
# switches to streaming, `proxy_buffering off;` MUST be added here or the stream
|
||||||
|
# stalls on prod only — see plan/23-monitor.md.
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
listen [::]:443 ssl http2;
|
||||||
|
server_name monitor.${BASE_DOMAIN};
|
||||||
|
|
||||||
|
ssl_certificate ${APP_DIR}/certs/monitor.${BASE_DOMAIN}/cert.pem;
|
||||||
|
ssl_certificate_key ${APP_DIR}/certs/monitor.${BASE_DOMAIN}/key.pem;
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
|
||||||
|
# Admin-only surface — keep it out of search engines even though it 401s.
|
||||||
|
add_header X-Robots-Tag "noindex, nofollow" always;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+138
-29
@@ -1,19 +1,17 @@
|
|||||||
# ──────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────
|
||||||
# Ulicraft host nginx — TLS terminator in FRONT of caddy.
|
# Ulicraft host nginx — TLS terminator in FRONT of caddy.
|
||||||
# ──────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────
|
||||||
# Alternative to ulicraft.conf.tmpl. Instead of nginx serving the static
|
# caddy (in docker-compose.yml) is the single internal ingress for ALL vhosts;
|
||||||
# files + proxying only drasl, here caddy (the docker container) is the single
|
# this host nginx terminates TLS and reverse-proxies everything to caddy by Host
|
||||||
# ingress for ALL vhosts and nginx just terminates TLS and reverse-proxies
|
# header. Prefer tooling/render-nginx.sh over hand-rendering.
|
||||||
# everything to caddy by Host header.
|
|
||||||
#
|
#
|
||||||
# Caddy must be published on a localhost port — set in .env:
|
# Caddy must be published on a localhost port — set in .env:
|
||||||
# CADDY_HTTP_PORT=8880
|
# CADDY_HTTP_PORT=8880
|
||||||
# CADDY_HTTP_BIND=127.0.0.1
|
# CADDY_HTTP_BIND=127.0.0.1
|
||||||
# and the stack brought up with the caddy override (NOT the nginx override):
|
# and the stack brought up:
|
||||||
# docker compose -f docker-compose.yml -f docker-compose.caddy.yml \
|
# docker compose up -d
|
||||||
# -f docker-compose.static.yml up -d
|
|
||||||
#
|
#
|
||||||
# Render with BOTH vars then install:
|
# Render with the vars then install:
|
||||||
# CADDY_HTTP_PORT=8880 BASE_DOMAIN=ulicraft.net APP_DIR=/home/ubuntu/mc/ulicraft-server-v1 \
|
# CADDY_HTTP_PORT=8880 BASE_DOMAIN=ulicraft.net APP_DIR=/home/ubuntu/mc/ulicraft-server-v1 \
|
||||||
# envsubst '$CADDY_HTTP_PORT $BASE_DOMAIN $APP_DIR' \
|
# envsubst '$CADDY_HTTP_PORT $BASE_DOMAIN $APP_DIR' \
|
||||||
# < nginx/ulicraft-caddy.conf.tmpl | sudo tee /etc/nginx/sites-available/ulicraft.conf
|
# < nginx/ulicraft-caddy.conf.tmpl | sudo tee /etc/nginx/sites-available/ulicraft.conf
|
||||||
@@ -28,25 +26,28 @@ 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 upload.), 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} auth.${BASE_DOMAIN} pack.${BASE_DOMAIN} distribution.${BASE_DOMAIN};
|
server_name ${BASE_DOMAIN} www.${BASE_DOMAIN} auth.${BASE_DOMAIN} distribution.${BASE_DOMAIN} avatar.${BASE_DOMAIN} status.${BASE_DOMAIN} upload.${BASE_DOMAIN}${FILES_BROWSE_NAME}${MONITOR_NAME} generators.${BASE_DOMAIN};
|
||||||
return 301 https://$host$request_uri;
|
return 301 https://$host$request_uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
# One TLS server block per name (each has its own LE cert). All proxy to caddy;
|
# One TLS server block per name (each has its own LE cert). All proxy to caddy;
|
||||||
# caddy routes by the forwarded Host header (apex -> www, auth -> drasl,
|
# caddy routes by the forwarded Host header (apex -> www, auth -> drasl).
|
||||||
# pack -> packwiz files).
|
|
||||||
server {
|
server {
|
||||||
listen 443 ssl;
|
listen 443 ssl http2;
|
||||||
listen [::]:443 ssl;
|
listen [::]:443 ssl http2;
|
||||||
http2 on;
|
|
||||||
server_name ${BASE_DOMAIN};
|
server_name ${BASE_DOMAIN};
|
||||||
|
|
||||||
ssl_certificate ${APP_DIR}/certs/${BASE_DOMAIN}/cert.pem;
|
ssl_certificate ${APP_DIR}/certs/${BASE_DOMAIN}/cert.pem;
|
||||||
ssl_certificate_key ${APP_DIR}/certs/${BASE_DOMAIN}/key.pem;
|
ssl_certificate_key ${APP_DIR}/certs/${BASE_DOMAIN}/key.pem;
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
|
||||||
client_max_body_size 4m; # skin uploads (auth) reuse the same proxy block
|
client_max_body_size 4m; # skin uploads (auth) reuse the same proxy block
|
||||||
|
|
||||||
@@ -59,14 +60,27 @@ server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# www -> apex canonical redirect (needs its own cert; covered by LE_SUBDOMAINS).
|
||||||
server {
|
server {
|
||||||
listen 443 ssl;
|
listen 443 ssl http2;
|
||||||
listen [::]:443 ssl;
|
listen [::]:443 ssl http2;
|
||||||
http2 on;
|
server_name www.${BASE_DOMAIN};
|
||||||
|
|
||||||
|
ssl_certificate ${APP_DIR}/certs/www.${BASE_DOMAIN}/cert.pem;
|
||||||
|
ssl_certificate_key ${APP_DIR}/certs/www.${BASE_DOMAIN}/key.pem;
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
|
||||||
|
return 301 https://${BASE_DOMAIN}$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
listen [::]:443 ssl http2;
|
||||||
server_name auth.${BASE_DOMAIN};
|
server_name auth.${BASE_DOMAIN};
|
||||||
|
|
||||||
ssl_certificate ${APP_DIR}/certs/auth.${BASE_DOMAIN}/cert.pem;
|
ssl_certificate ${APP_DIR}/certs/auth.${BASE_DOMAIN}/cert.pem;
|
||||||
ssl_certificate_key ${APP_DIR}/certs/auth.${BASE_DOMAIN}/key.pem;
|
ssl_certificate_key ${APP_DIR}/certs/auth.${BASE_DOMAIN}/key.pem;
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
|
||||||
client_max_body_size 4m; # skin uploads
|
client_max_body_size 4m; # skin uploads
|
||||||
|
|
||||||
@@ -80,13 +94,36 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 443 ssl;
|
listen 443 ssl http2;
|
||||||
listen [::]:443 ssl;
|
listen [::]:443 ssl http2;
|
||||||
http2 on;
|
server_name distribution.${BASE_DOMAIN};
|
||||||
server_name pack.${BASE_DOMAIN};
|
|
||||||
|
|
||||||
ssl_certificate ${APP_DIR}/certs/pack.${BASE_DOMAIN}/cert.pem;
|
ssl_certificate ${APP_DIR}/certs/distribution.${BASE_DOMAIN}/cert.pem;
|
||||||
ssl_certificate_key ${APP_DIR}/certs/pack.${BASE_DOMAIN}/key.pem;
|
ssl_certificate_key ${APP_DIR}/certs/distribution.${BASE_DOMAIN}/key.pem;
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Data pack generators — a pinned, self-contained fork of misode.github.io.
|
||||||
|
# Serves its own mcmeta/vanilla-mcdoc mirror from the same origin, so it works
|
||||||
|
# with ZERO internet at party time (the app is 100% client-side: every data
|
||||||
|
# fetch comes from the player's browser, not from this host). caddy sets the
|
||||||
|
# CSP that enforces that — see caddy/conf.d/70-generators.caddy.
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
listen [::]:443 ssl http2;
|
||||||
|
server_name generators.${BASE_DOMAIN};
|
||||||
|
|
||||||
|
ssl_certificate ${APP_DIR}/certs/generators.${BASE_DOMAIN}/cert.pem;
|
||||||
|
ssl_certificate_key ${APP_DIR}/certs/generators.${BASE_DOMAIN}/key.pem;
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://ulicraft_caddy;
|
proxy_pass http://ulicraft_caddy;
|
||||||
@@ -98,13 +135,13 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 443 ssl;
|
listen 443 ssl http2;
|
||||||
listen [::]:443 ssl;
|
listen [::]:443 ssl http2;
|
||||||
http2 on;
|
server_name avatar.${BASE_DOMAIN};
|
||||||
server_name distribution.${BASE_DOMAIN};
|
|
||||||
|
|
||||||
ssl_certificate ${APP_DIR}/certs/distribution.${BASE_DOMAIN}/cert.pem;
|
ssl_certificate ${APP_DIR}/certs/avatar.${BASE_DOMAIN}/cert.pem;
|
||||||
ssl_certificate_key ${APP_DIR}/certs/distribution.${BASE_DOMAIN}/key.pem;
|
ssl_certificate_key ${APP_DIR}/certs/avatar.${BASE_DOMAIN}/key.pem;
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://ulicraft_caddy;
|
proxy_pass http://ulicraft_caddy;
|
||||||
@@ -114,3 +151,75 @@ server {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Filestash player file share (upload.). 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 upload.${BASE_DOMAIN};
|
||||||
|
|
||||||
|
ssl_certificate ${APP_DIR}/certs/upload.${BASE_DOMAIN}/cert.pem;
|
||||||
|
ssl_certificate_key ${APP_DIR}/certs/upload.${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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# files. (read-only browse) goes here — the whole server block, or a single
|
||||||
|
# comment line when FILES_BROWSE=off. Source: nginx/files-browse.server.tmpl,
|
||||||
|
# spliced in by render-nginx.sh.
|
||||||
|
${FILES_BROWSE_SERVER}
|
||||||
|
|
||||||
|
# monitor. (admin log stream, basic_auth in caddy) goes here — the whole server
|
||||||
|
# block, or a single comment line when MONITOR_ENABLED=off. Source:
|
||||||
|
# nginx/monitor.server.tmpl, spliced in by render-nginx.sh.
|
||||||
|
${MONITOR_SERVER}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
listen [::]:443 ssl http2;
|
||||||
|
server_name status.${BASE_DOMAIN};
|
||||||
|
|
||||||
|
ssl_certificate ${APP_DIR}/certs/status.${BASE_DOMAIN}/cert.pem;
|
||||||
|
ssl_certificate_key ${APP_DIR}/certs/status.${BASE_DOMAIN}/key.pem;
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
# Uptime Kuma uses websockets (socket.io) for live status updates.
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
# ──────────────────────────────────────────────────────────────────
|
|
||||||
# Ulicraft host nginx — TLS ingress with Let's Encrypt certs.
|
|
||||||
# ──────────────────────────────────────────────────────────────────
|
|
||||||
# Template. Render with BOTH vars then install:
|
|
||||||
#
|
|
||||||
# APP_DIR=/home/ubuntu/mc/ulicraft-server-v1 BASE_DOMAIN=ulicraft.net \
|
|
||||||
# envsubst '$APP_DIR $BASE_DOMAIN' \
|
|
||||||
# < nginx/ulicraft.conf.tmpl | sudo tee /etc/nginx/sites-available/ulicraft.conf
|
|
||||||
# sudo ln -sf /etc/nginx/sites-available/ulicraft.conf /etc/nginx/sites-enabled/
|
|
||||||
# sudo nginx -t && sudo systemctl reload nginx
|
|
||||||
#
|
|
||||||
# Prereqs:
|
|
||||||
# - certs issued: tooling/issue-letsencrypt.sh -> $APP_DIR/certs/<name>/{cert,key}.pem
|
|
||||||
# - the docker stack up with the nginx override (publishes drasl on 127.0.0.1:25585):
|
|
||||||
# docker compose -f docker-compose.yml -f docker-compose.nginx.yml up -d
|
|
||||||
# - nginx's user (www-data) can READ $APP_DIR/{www,pack,custom,certs}
|
|
||||||
# ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
# HTTP -> HTTPS for every name.
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
listen [::]:80;
|
|
||||||
server_name ${BASE_DOMAIN} auth.${BASE_DOMAIN} pack.${BASE_DOMAIN};
|
|
||||||
return 301 https://$host$request_uri;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Apex — static landing page (./www) + launcher downloads (./mirror/launcher).
|
|
||||||
# /ca.crt from the caddy path is intentionally omitted: LE certs are publicly
|
|
||||||
# trusted, so guests need no CA import.
|
|
||||||
server {
|
|
||||||
listen 443 ssl;
|
|
||||||
listen [::]:443 ssl;
|
|
||||||
http2 on;
|
|
||||||
server_name ${BASE_DOMAIN};
|
|
||||||
|
|
||||||
ssl_certificate ${APP_DIR}/certs/${BASE_DOMAIN}/cert.pem;
|
|
||||||
ssl_certificate_key ${APP_DIR}/certs/${BASE_DOMAIN}/key.pem;
|
|
||||||
|
|
||||||
# Launcher downloads (populated by tooling/fetch-launcher.sh -> ./mirror/launcher).
|
|
||||||
location /launcher/ {
|
|
||||||
alias ${APP_DIR}/mirror/launcher/;
|
|
||||||
autoindex on;
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
|
||||||
root ${APP_DIR}/www;
|
|
||||||
index index.html;
|
|
||||||
try_files $uri $uri/ =404;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Pack — packwiz metadata (./pack) + custom jars (./custom at /custom/).
|
|
||||||
server {
|
|
||||||
listen 443 ssl;
|
|
||||||
listen [::]:443 ssl;
|
|
||||||
http2 on;
|
|
||||||
server_name pack.${BASE_DOMAIN};
|
|
||||||
|
|
||||||
ssl_certificate ${APP_DIR}/certs/pack.${BASE_DOMAIN}/cert.pem;
|
|
||||||
ssl_certificate_key ${APP_DIR}/certs/pack.${BASE_DOMAIN}/key.pem;
|
|
||||||
|
|
||||||
# Locally-hosted custom mod jars live outside ./pack; expose them at /custom/.
|
|
||||||
location /custom/ {
|
|
||||||
alias ${APP_DIR}/custom/;
|
|
||||||
autoindex on;
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
|
||||||
root ${APP_DIR}/pack;
|
|
||||||
autoindex on;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Auth — reverse proxy to drasl (published on localhost by the nginx override).
|
|
||||||
server {
|
|
||||||
listen 443 ssl;
|
|
||||||
listen [::]:443 ssl;
|
|
||||||
http2 on;
|
|
||||||
server_name auth.${BASE_DOMAIN};
|
|
||||||
|
|
||||||
ssl_certificate ${APP_DIR}/certs/auth.${BASE_DOMAIN}/cert.pem;
|
|
||||||
ssl_certificate_key ${APP_DIR}/certs/auth.${BASE_DOMAIN}/key.pem;
|
|
||||||
|
|
||||||
client_max_body_size 4m; # skin uploads
|
|
||||||
|
|
||||||
location / {
|
|
||||||
proxy_pass http://127.0.0.1:25585;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# NMSR-aas config — renders to nmsr/config.toml (gitignored) via
|
||||||
|
# tooling/render-config.sh. Only ${BASE_DOMAIN} is substituted.
|
||||||
|
#
|
||||||
|
# Skins come from Drasl, NOT Mojang. Drasl exposes Mojang-compatible routes at
|
||||||
|
# the bare BaseURL:
|
||||||
|
# session_server -> {url}/session/minecraft/profile/{uuid}
|
||||||
|
# mojang_api_server -> {url}/users/profiles/minecraft/{name}
|
||||||
|
# and embeds absolute skin URLs (https://auth.${BASE_DOMAIN}/web/texture/skin/…)
|
||||||
|
# in the profile — Drasl's BaseURL is https, so these are https and NMSR must
|
||||||
|
# fetch them over :443. The mcnet alias only resolves auth. -> caddy on :80, so
|
||||||
|
# the nmsr service pins auth.${BASE_DOMAIN} to the host (extra_hosts in
|
||||||
|
# docker-compose.yml) and talks to the host nginx on :443 (real LE cert). Hence
|
||||||
|
# the https:// endpoints below — they must match the scheme of the embedded
|
||||||
|
# skin URLs, or the skin fetch Connect-fails and avatars render the default skin.
|
||||||
|
|
||||||
|
[server]
|
||||||
|
address = "0.0.0.0"
|
||||||
|
port = 8080
|
||||||
|
|
||||||
|
[caching]
|
||||||
|
cleanup_interval = "1h"
|
||||||
|
# resolve_cache_duration caches the uuid -> profile (incl. skin URL) lookup, so
|
||||||
|
# it bounds how long a changed skin keeps showing the OLD avatar. Kept short so
|
||||||
|
# skin changes on the account page propagate within ~1 min (NMSR has no
|
||||||
|
# per-request force-refresh; a `?skin=` override is ignored by this build). Cost
|
||||||
|
# is one extra internal Drasl profile lookup per avatar per minute — trivial over
|
||||||
|
# mcnet for a 4-10 player server. For an instant global flush: restart nmsr.
|
||||||
|
resolve_cache_duration = "60s"
|
||||||
|
# texture cache stays long: Drasl skin URLs are content-hashed
|
||||||
|
# (/web/texture/skin/<sha256>.png), so a new skin = new URL = fresh fetch; this
|
||||||
|
# 48h cache never serves a stale skin.
|
||||||
|
texture_cache_duration = "48h"
|
||||||
|
|
||||||
|
[caching.cache_biases]
|
||||||
|
|
||||||
|
[mojank]
|
||||||
|
# Point every Mojang endpoint at Drasl over https — auth. is pinned to the host
|
||||||
|
# (extra_hosts), so this reaches the host nginx on :443 with the real LE cert.
|
||||||
|
# Must stay https to match the https skin URLs Drasl embeds in the profile.
|
||||||
|
session_server = "https://auth.${BASE_DOMAIN}"
|
||||||
|
textures_server = "https://auth.${BASE_DOMAIN}"
|
||||||
|
mojang_api_server = "https://auth.${BASE_DOMAIN}"
|
||||||
|
session_server_rate_limit = 10
|
||||||
|
# This is an offline-mode (authlib-injector) server — Drasl issues v3 offline
|
||||||
|
# UUIDs, so they must be accepted.
|
||||||
|
allow_offline_mode_uuids = true
|
||||||
|
use_dashless_uuids = false
|
||||||
|
|
||||||
|
[rendering]
|
||||||
|
sample_count = 1
|
||||||
|
use_smaa = true
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
# Template sources for pack metadata/config — rendered to their real files by
|
|
||||||
# tooling/render-pack.sh. packwiz must NOT index the templates themselves.
|
|
||||||
*.tmpl
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"enable": true,
|
|
||||||
"loadlist": [
|
|
||||||
{
|
|
||||||
"name": "Ulicraft Skins",
|
|
||||||
"type": "MojangAPI",
|
|
||||||
"apiRoot": "https://auth.${BASE_DOMAIN}/account/",
|
|
||||||
"sessionRoot": "https://auth.${BASE_DOMAIN}/session/"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
# Ulicraft modpack (packwiz)
|
|
||||||
|
|
||||||
This directory is the **single source of truth** for the modpack (MC 1.21.1, NeoForge 21.1.209). Curate mods from here with `packwiz modrinth add <slug>` (prefer Modrinth over CurseForge); each mod produces a `mods/<slug>.pw.toml` metadata file referencing the upstream CDN. **Client-only mods must be tagged `side = "client"`** in their `.pw.toml` so the itzg server does not pull and crash on them. After adding/removing mods run `packwiz refresh` to recompute `index.toml`; for offline LAN play run `tooling/mirror-mods.sh` to vendor the jars and rewrite the download URLs to the local Caddy mirror. Curation is an ongoing manual task — no mods are added yet.
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
name = "balm-neoforge-1.21.1-21.0.58"
|
|
||||||
filename = "balm-neoforge-1.21.1-21.0.58.jar"
|
|
||||||
side = "both"
|
|
||||||
|
|
||||||
[download]
|
|
||||||
url = "http://pack.${BASE_DOMAIN}/custom/balm-neoforge-1.21.1-21.0.58.jar"
|
|
||||||
hash-format = "sha256"
|
|
||||||
hash = "aa2463747bad4c2351869226ea7bce7839510dfd2ac387f8d1588b1ce9309c67"
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
name = "CustomSkinLoader_Universal-14.28"
|
|
||||||
filename = "CustomSkinLoader_Universal-14.28.jar"
|
|
||||||
side = "both"
|
|
||||||
|
|
||||||
[download]
|
|
||||||
url = "http://pack.${BASE_DOMAIN}/custom/CustomSkinLoader_Universal-14.28.jar"
|
|
||||||
hash-format = "sha256"
|
|
||||||
hash = "0008fb0a91dd1e84d52162cfe96addb5f6aed3b76d426ea7305d7624e31f1894"
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
name = "inventoryessentials-neoforge-1.21.1-21.1.15"
|
|
||||||
filename = "inventoryessentials-neoforge-1.21.1-21.1.15.jar"
|
|
||||||
side = "both"
|
|
||||||
|
|
||||||
[download]
|
|
||||||
url = "http://pack.${BASE_DOMAIN}/custom/inventoryessentials-neoforge-1.21.1-21.1.15.jar"
|
|
||||||
hash-format = "sha256"
|
|
||||||
hash = "b7a5c5526a45256551543419ba2b5d54e56c585d76a645a8cea8cb8ea843ae2c"
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
name = "Jade-1.21.1-NeoForge-15.10.5"
|
|
||||||
filename = "Jade-1.21.1-NeoForge-15.10.5.jar"
|
|
||||||
side = "both"
|
|
||||||
|
|
||||||
[download]
|
|
||||||
url = "http://pack.${BASE_DOMAIN}/custom/Jade-1.21.1-NeoForge-15.10.5.jar"
|
|
||||||
hash-format = "sha256"
|
|
||||||
hash = "067bb4b007e1d6f6b79f0afe99c91252aa825472b99a76d33a60d24442f9e92d"
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
name = "JadeAddons-1.21.1-NeoForge-6.1.0"
|
|
||||||
filename = "JadeAddons-1.21.1-NeoForge-6.1.0.jar"
|
|
||||||
side = "both"
|
|
||||||
|
|
||||||
[download]
|
|
||||||
url = "http://pack.${BASE_DOMAIN}/custom/JadeAddons-1.21.1-NeoForge-6.1.0.jar"
|
|
||||||
hash-format = "sha256"
|
|
||||||
hash = "5f242668ad710092964d4d9afda8a94fa2401665ea13eea67d55d07e4dff8a64"
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
name = "jei-1.21.1-neoforge-19.27.0.340"
|
|
||||||
filename = "jei-1.21.1-neoforge-19.27.0.340.jar"
|
|
||||||
side = "both"
|
|
||||||
|
|
||||||
[download]
|
|
||||||
url = "http://pack.${BASE_DOMAIN}/custom/jei-1.21.1-neoforge-19.27.0.340.jar"
|
|
||||||
hash-format = "sha256"
|
|
||||||
hash = "8aaf547432f1b4958239b036356b910692fe40f858c3073d996f56bbf7c99826"
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
name = "JustEnoughProfessions-neoforge-1.21.1-4.0.5"
|
|
||||||
filename = "JustEnoughProfessions-neoforge-1.21.1-4.0.5.jar"
|
|
||||||
side = "both"
|
|
||||||
|
|
||||||
[download]
|
|
||||||
url = "http://pack.${BASE_DOMAIN}/custom/JustEnoughProfessions-neoforge-1.21.1-4.0.5.jar"
|
|
||||||
hash-format = "sha256"
|
|
||||||
hash = "b320b47f1791f56df9e1a05138f0e312e6e54541945ff15f2f1fd398cd1d4d9d"
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
name = "JustEnoughResources-NeoForge-1.21.1-1.6.0.17"
|
|
||||||
filename = "JustEnoughResources-NeoForge-1.21.1-1.6.0.17.jar"
|
|
||||||
side = "both"
|
|
||||||
|
|
||||||
[download]
|
|
||||||
url = "http://pack.${BASE_DOMAIN}/custom/JustEnoughResources-NeoForge-1.21.1-1.6.0.17.jar"
|
|
||||||
hash-format = "sha256"
|
|
||||||
hash = "7a47d69b5530704690d5a5f0726cd74a2d6c57df17dd4a8589c4ab5212f91460"
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
name = "MouseTweaks-neoforge-mc1.21-2.26.1"
|
|
||||||
filename = "MouseTweaks-neoforge-mc1.21-2.26.1.jar"
|
|
||||||
side = "both"
|
|
||||||
|
|
||||||
[download]
|
|
||||||
url = "http://pack.${BASE_DOMAIN}/custom/MouseTweaks-neoforge-mc1.21-2.26.1.jar"
|
|
||||||
hash-format = "sha256"
|
|
||||||
hash = "68e6f4201c5de97b77929a7215c9552495696ca6a3bf3ae4eacc34e135f6cc8b"
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
name = "Ulicraft"
|
|
||||||
author = "Oier"
|
|
||||||
version = "1.0.0"
|
|
||||||
pack-format = "packwiz:1.1.0"
|
|
||||||
|
|
||||||
[index]
|
|
||||||
file = "index.toml"
|
|
||||||
hash-format = "sha256"
|
|
||||||
hash = "9bca0b1a4434050835cab46116d7c14aafec971ff5a95a1ee0c4232c8bcbc355"
|
|
||||||
|
|
||||||
[versions]
|
|
||||||
minecraft = "1.21.1"
|
|
||||||
neoforge = "21.1.209"
|
|
||||||
+109
-69
@@ -1,124 +1,164 @@
|
|||||||
# Ulicraft Server — Plan Overview
|
# Ulicraft Server — Plan Overview
|
||||||
|
|
||||||
Self-hosted modded Minecraft LAN-party stack, designed to run **fully offline**
|
Self-hosted modded Minecraft stack — a single Docker Compose file holds the
|
||||||
(air-gapped) during the party while remaining a persistent homelab service.
|
entire thing, fronted by the host's own nginx (Let's Encrypt TLS) and a caddy
|
||||||
|
internal router. Built for a LAN party but runs as a persistent homelab service.
|
||||||
|
Assumes the internet is present.
|
||||||
|
|
||||||
## Single source of configuration
|
## Single source of configuration
|
||||||
|
|
||||||
Everything is driven by two env vars in `.env`:
|
Everything is driven by one env var in `.env`:
|
||||||
|
|
||||||
```
|
```
|
||||||
BASE_DOMAIN=ulicraft.local # all services are subdomains of this
|
BASE_DOMAIN=ulicraft.net # all services are subdomains of this
|
||||||
HOST_LAN_IP=192.168.x.x # the Docker host's LAN IP (dnsmasq target)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
> `.local` collides with mDNS/Bonjour (RFC 6762). macOS + Linux Avahi may resolve
|
`render-config.sh` substitutes `${BASE_DOMAIN}` into the templates (plus the
|
||||||
> `*.local` via multicast and bypass dnsmasq. Accepted risk for now; alternatives
|
derived `REGISTRATION_*` and `FILES_*` values — see `20-files.md`).
|
||||||
> if it bites: `.lan`, `home.arpa`. Flagged, user chose `.local`.
|
DNS is **not** this repo's concern: point `${BASE_DOMAIN}` and all subdomains
|
||||||
|
(`auth.` `avatar.` `status.` `files.` `distribution.` `www.`) at the host running
|
||||||
|
nginx; that is configured outside this repo.
|
||||||
|
|
||||||
## Subdomain / ingress map
|
## Subdomain / ingress map
|
||||||
|
|
||||||
```
|
```
|
||||||
ulicraft.local → caddy → landing page (launcher downloads + guide)
|
ulicraft.net → nginx → caddy → landing page (/srv/www) + /launcher/
|
||||||
auth.ulicraft.local → caddy → drasl:25585 (auth web UI + Yggdrasil API)
|
auth.ulicraft.net → nginx → caddy → drasl:25585 (auth web UI + Yggdrasil API)
|
||||||
packwiz.ulicraft.local → caddy → /srv/pack (pack metadata + mod-jar mirror)
|
avatar.ulicraft.net → nginx → caddy → nmsr:8080 (skin/avatar renderer)
|
||||||
assets.ulicraft.local → caddy → /srv/assets (MC asset-object mirror; HTTPS forced — see 10)
|
status.ulicraft.net → nginx → caddy → uptime-kuma:3001 (status page)
|
||||||
mc.ulicraft.local:25565 → minecraft (NOT http; raw MC protocol)
|
files.ulicraft.net → nginx → caddy → filestash:8334 (player file share, shared login)
|
||||||
|
distribution.ulicraft.net → nginx → caddy → /srv/distribution (static, another repo)
|
||||||
|
mc.ulicraft.net:25565 → minecraft (raw MC protocol, not HTTP)
|
||||||
```
|
```
|
||||||
|
|
||||||
Caddy is the only HTTP ingress (plain `:80` for now, TLS/step-ca later).
|
The host's own nginx is the only public ingress: it terminates TLS with Let's
|
||||||
drasl's host port is removed — internal only, reached through Caddy.
|
Encrypt certs and reverse-proxies every vhost to caddy by Host header. caddy is
|
||||||
|
published on a **localhost-only** port (`CADDY_HTTP_BIND=127.0.0.1`,
|
||||||
|
`CADDY_HTTP_PORT=8880`) and acts as the internal router. drasl, nmsr and
|
||||||
|
uptime-kuma have no host ports — they are reached only through caddy.
|
||||||
|
|
||||||
### Internal resolution trick
|
### Internal resolution trick
|
||||||
|
|
||||||
Containers use the Docker resolver, NOT dnsmasq. Caddy gets network aliases so
|
Containers use the Docker resolver. caddy gets network aliases so the stack
|
||||||
the stack resolves its own subdomains internally:
|
resolves its own subdomains internally:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
caddy:
|
caddy:
|
||||||
networks:
|
networks:
|
||||||
mcnet:
|
mcnet:
|
||||||
aliases: [ "auth.${BASE_DOMAIN}", "packwiz.${BASE_DOMAIN}" ]
|
aliases: [ "auth.${BASE_DOMAIN}" ]
|
||||||
```
|
```
|
||||||
|
|
||||||
Result: `http://auth.ulicraft.local/authlib-injector` and
|
Result: `http://auth.ulicraft.net/authlib-injector` resolves identically inside
|
||||||
`http://packwiz.ulicraft.local/pack.toml` resolve identically inside and outside
|
the stack (minecraft → caddy) as the public name does outside it (client → nginx
|
||||||
the stack. dnsmasq exists **only for guest laptops**.
|
→ caddy).
|
||||||
|
|
||||||
## Online vs offline split
|
#### HTTPS variant (`extra_hosts: host-gateway`)
|
||||||
|
|
||||||
|
The caddy alias only serves **http** on `mcnet` (port 80). Once the stack moved
|
||||||
|
to https URLs (drasl `BaseURL`; the authlib-injector agent must match the
|
||||||
|
client's https endpoint), the internal http alias is no longer enough — the
|
||||||
|
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`).
|
||||||
|
So pin the public name to the host in the service, not to caddy:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
minecraft:
|
||||||
|
extra_hosts:
|
||||||
|
- "auth.${BASE_DOMAIN}:host-gateway"
|
||||||
|
```
|
||||||
|
|
||||||
|
`/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
|
||||||
|
**containers do not inherit the host's `/etc/hosts`** — on cochi the host resolves
|
||||||
|
`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 session
|
||||||
|
validation on boot. `host-gateway` sidesteps DNS entirely.
|
||||||
|
|
||||||
|
## Build / run flow
|
||||||
|
|
||||||
```
|
```
|
||||||
PRE-PARTY (internet, run once):
|
PREP (run once, online):
|
||||||
1. curate packwiz pack → see 04-packwiz.md
|
1. render configs → tooling/render-config.sh (08-tooling.md)
|
||||||
2. mirror mod jars + rewrite URLs → tooling/mirror-mods.sh (08-tooling.md)
|
2. build landing page → cd landing && pnpm run build → www/ (09-landing.md)
|
||||||
3. pre-bake server volume → TYPE=NEOFORGE install (05-minecraft.md)
|
3. download launcher releases → tooling/fetch-launcher.sh (06-launcher.md)
|
||||||
4. download launcher releases → tooling/fetch-launcher.sh (06-launcher.md)
|
4. sync server mods → tooling/sync-server-mods.sh (04-mods.md)
|
||||||
5. capture+mirror client air-gap → tooling/mirror-airgap.sh (11-full-airgap-mirror.md)
|
5. (TLS) issue Let's Encrypt certs → tooling/issue-letsencrypt.sh (15-letsencrypt.md)
|
||||||
6. build landing page → cd landing && pnpm run build → www/ (09-landing.md)
|
6. (ingress) render+install nginx → tooling/render-nginx.sh --install (15-letsencrypt.md)
|
||||||
7. render configs from templates → tooling/render-config.sh (08-tooling.md)
|
|
||||||
|
|
||||||
PARTY (air-gapped):
|
UP:
|
||||||
dnsmasq : *.ulicraft.local → HOST_LAN_IP
|
docker compose up -d --build
|
||||||
caddy : local CDN + landing page
|
|
||||||
drasl : password login (NO Keycloak/OIDC for now)
|
|
||||||
minecraft: TYPE=CUSTOM, no Mojang re-resolution
|
|
||||||
guests : install Fjord launcher, import pack, add authlib account → auth subdomain
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Bring-up is a single command. There are no run modes and no override compose
|
||||||
|
files — one `docker-compose.yml` holds drasl, minecraft, mc-backup, caddy, nmsr,
|
||||||
|
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 this
|
- **No OIDC/Keycloak (for now)** — drasl password login. Keycloak left out of
|
||||||
stack's scope. Re-add later if desired.
|
this stack's scope. Re-add later if desired.
|
||||||
- **itzg can't air-gap-boot in NEOFORGE mode** (re-resolves Mojang metadata every
|
- **Distribution-fed mods** — the modset's source of truth is the HeliosLauncher
|
||||||
start, issue #2340). Pre-bake then switch to `TYPE=CUSTOM`.
|
distribution repo; clients install the full set, the server loads a filtered
|
||||||
- **packwiz only serves metadata** — `.pw.toml` points at Modrinth CDN. Offline
|
`both`/`server` subset from `./server-mods`. See `04-mods.md`.
|
||||||
needs jars mirrored onto Caddy + URL rewrite.
|
- **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
|
||||||
|
with no CA import) and a single public TLS terminator; caddy stays an internal
|
||||||
|
Host-routed multiplexer for the stack's own services.
|
||||||
|
|
||||||
## Target folder layout
|
## Target folder layout
|
||||||
|
|
||||||
```
|
```
|
||||||
.
|
.
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml # the entire stack (no overrides)
|
||||||
├── .env / .env.example # BASE_DOMAIN, HOST_LAN_IP, RCON_PASSWORD
|
├── .env / .env.example # BASE_DOMAIN, RCON_PASSWORD, CADDY_*, LE block
|
||||||
├── caddy/Caddyfile # {$BASE_DOMAIN} vhosts + landing page
|
├── caddy/conf.d/ # per-vhost snippets (00-core, 10-static, …)
|
||||||
├── dnsmasq/dnsmasq.conf.tmpl # wildcard template
|
├── nginx/ulicraft-caddy.conf.tmpl # host nginx → caddy template (TLS terminator)
|
||||||
|
├── certs/<name>/{cert,key}.pem # Let's Encrypt output (gitignored)
|
||||||
├── drasl/config/
|
├── drasl/config/
|
||||||
│ ├── config.toml.tmpl # envsubst source
|
│ ├── config.toml.tmpl # render-config.sh source
|
||||||
│ └── config.toml # generated (gitignored)
|
│ └── config.toml # generated (gitignored)
|
||||||
├── pack/ # packwiz source of truth
|
├── nmsr/ # avatar renderer config + Dockerfile context
|
||||||
├── mirror/ # vendored mod jars + launcher releases (gitignored)
|
├── filestash/
|
||||||
├── runtime/ # authlib-injector.jar (legacy; may be unused w/ Fjord)
|
│ ├── config.json.tmpl # render-config.sh source (files. share)
|
||||||
├── tooling/
|
│ └── config.json # generated, mounted :ro (gitignored — holds hashes)
|
||||||
│ ├── render-config.sh # envsubst templates → real configs, halt if unset
|
├── docker/nmsr/ # nmsr image build context
|
||||||
│ ├── mirror-mods.sh # vendor mod jars + rewrite .pw.toml URLs
|
├── server-mods/ # filtered server modset (synced, gitignored)
|
||||||
│ ├── mirror-assets.sh # mirror MC asset objects for offline clients (10)
|
├── launcher/ # vendored Fjord launcher releases (gitignored)
|
||||||
│ └── fetch-launcher.sh # download all Fjord release assets
|
├── runtime/ # authlib-injector.jar
|
||||||
|
├── tooling/ # render/fetch/issue scripts (see 08-tooling.md)
|
||||||
├── landing/ # Astro+TS landing source (build → www/)
|
├── landing/ # Astro+TS landing source (build → www/)
|
||||||
│ ├── astro.config.mjs # outDir ../www
|
├── www/ # landing build output — caddy apex (gitignored)
|
||||||
│ ├── public/logo.png # stone 3D logo
|
|
||||||
│ └── src/{data/site.ts,pages/index.astro}
|
|
||||||
├── www/ # caddy landing page — GENERATED by landing build (gitignored)
|
|
||||||
├── backups/
|
├── backups/
|
||||||
└── plan/ # these files
|
└── plan/ # these files
|
||||||
```
|
```
|
||||||
|
|
||||||
## Service plan files
|
## Service plan files
|
||||||
|
|
||||||
- `01-dnsmasq.md` — LAN wildcard DNS
|
- `02-caddy.md` — internal router (vhost conf.d snippets) behind host nginx
|
||||||
- `02-caddy.md` — ingress + local CDN + landing page
|
|
||||||
- `03-drasl.md` — auth (password login)
|
- `03-drasl.md` — auth (password login)
|
||||||
- `04-packwiz.md` — modpack source + mod mirror
|
- `04-mods.md` — distribution-fed mod volume + server filtering (supersedes `04-packwiz.md`)
|
||||||
- `05-minecraft.md` — itzg NeoForge server + offline launch
|
- `05-minecraft.md` — itzg NeoForge server
|
||||||
- `06-launcher.md` — Fjord launcher fetch + distribution
|
- `06-launcher.md` — Fjord launcher fetch + distribution
|
||||||
- `07-mc-backup.md` — world backups
|
- `07-mc-backup.md` — world backups
|
||||||
- `08-tooling.md` — render/mirror/fetch scripts
|
- `08-tooling.md` — render/fetch/issue scripts
|
||||||
- `09-landing.md` — Astro+TS guest onboarding page → www/
|
- `09-landing.md` — Astro+TS guest onboarding page → www/
|
||||||
- `10-assets-mirror.md` — lighter alternative: objects-only Fjord Assets Server override
|
|
||||||
- `11-full-airgap-mirror.md` — CHOSEN: DNS+TLS transparent mirror for full client air-gap
|
|
||||||
- `12-build-order.md` — commit-per-task build sequence
|
- `12-build-order.md` — commit-per-task build sequence
|
||||||
- `13-dns-and-run-modes.md` — domain (.lan), online/airgap modes, party DNS, mDNS option
|
- `14-deploy.md` — redeploy to the production host
|
||||||
|
- `15-letsencrypt.md` — Let's Encrypt (OVH DNS-01) + host nginx ingress
|
||||||
|
- `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)
|
||||||
|
- `19-routes.md` — HTTP route reference (nmsr / landing / auth / files + internal)
|
||||||
|
- `20-files.md` — Filestash player file share (`files.`, one shared login)
|
||||||
|
- `23-monitor.md` — admin log stream + dashboard (`monitor.`, basic auth)
|
||||||
|
|
||||||
## Boot/dependency order
|
## Boot/dependency order
|
||||||
|
|
||||||
`dnsmasq` → `caddy` (aliases) → `drasl` → `minecraft` (depends on drasl + packwiz served by caddy) → `mc-backup`.
|
`caddy` (aliases) → `drasl` → `minecraft` (depends on drasl) → `mc-backup`.
|
||||||
|
`nmsr`, `mc-status`, `uptime-kuma`, `filestash` and `monitor` are always-on and
|
||||||
|
join `mcnet` (no ordering constraints — none of them are on the auth/boot path).
|
||||||
|
`monitor` reads Minecraft's log volume and queries its RCON, but starts fine
|
||||||
|
without either: a missing log file and a refused RCON dial are ordinary states
|
||||||
|
it reports rather than errors it dies on.
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
# dnsmasq — LAN wildcard DNS
|
|
||||||
|
|
||||||
> **SUPERSEDED for the default path — see `13-dns-and-run-modes.md`.** DNS is now
|
|
||||||
> your own party server (records via `tooling/dns-records.sh`); dnsmasq moved to
|
|
||||||
> an optional turnkey layer (`docker-compose.dnsmasq.yml`). Default domain is
|
|
||||||
> `.lan` (not `.local`). The wildcard/spoof template below still applies when you
|
|
||||||
> opt into dnsmasq.
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Provides name resolution for guest laptops so `*.ulicraft.local` resolves to the
|
|
||||||
Docker host. Replaces the homelab's AdGuard (dropped for the LAN party). Image:
|
|
||||||
`jpillora/dnsmasq` (dnsmasq wrapped in webproc, optional web UI on :8080).
|
|
||||||
|
|
||||||
Config is a **file**, not env-driven — `/etc/dnsmasq.conf`, standard dnsmasq
|
|
||||||
format. Rendered from a template by `tooling/render-config.sh`.
|
|
||||||
|
|
||||||
Only guest laptops point their DNS here. Stack-internal resolution is handled by
|
|
||||||
Docker network aliases on Caddy (see `02-caddy.md`), so dnsmasq is not on the
|
|
||||||
critical path for the server's own services.
|
|
||||||
|
|
||||||
## Config template
|
|
||||||
|
|
||||||
`dnsmasq/dnsmasq.conf.tmpl`:
|
|
||||||
```
|
|
||||||
# wildcard: every *.ulicraft.local → host LAN IP
|
|
||||||
address=/${BASE_DOMAIN}/${HOST_LAN_IP}
|
|
||||||
# upstream optional; party is offline so may be omitted
|
|
||||||
no-resolv
|
|
||||||
```
|
|
||||||
|
|
||||||
### Air-gap spoof records (see 11)
|
|
||||||
|
|
||||||
For full client air-gap, this file also spoofs the real upstream hosts → our
|
|
||||||
Caddy mirror (`piston-meta`/`piston-data`/`libraries.minecraft.net`/
|
|
||||||
`maven.neoforged.net`/`resources.download.minecraft.net`/`meta.prismlauncher.org`).
|
|
||||||
Full list + rationale in `11-full-airgap-mirror.md`. Keep in sync with the
|
|
||||||
capture step. Auth hosts (session/textures/api.mojang) are NOT spoofed — drasl
|
|
||||||
handles those via authlib-injector.
|
|
||||||
|
|
||||||
## Gotcha — port 53 conflict
|
|
||||||
|
|
||||||
Linux Docker hosts usually run `systemd-resolved` bound to `:53`. Publishing
|
|
||||||
`53:53` will fail with `address already in use`. Resolve by one of:
|
|
||||||
- Bind dnsmasq only to the LAN IP: publish `${HOST_LAN_IP}:53:53/udp` (+tcp)
|
|
||||||
- Or disable resolved's stub listener (`DNSStubListener=no`) and restart resolved
|
|
||||||
|
|
||||||
## Gotcha — `.local` / mDNS
|
|
||||||
|
|
||||||
See overview. Avahi may shortcut `*.local` to multicast before hitting dnsmasq.
|
|
||||||
Test on a macOS client early; if broken, switch `BASE_DOMAIN` to `.lan`.
|
|
||||||
|
|
||||||
## Tasks
|
|
||||||
|
|
||||||
- [ ] Add `dnsmasq` service to `docker-compose.yml` (`jpillora/dnsmasq`)
|
|
||||||
- [ ] Create `dnsmasq/dnsmasq.conf.tmpl` with wildcard `address=` line
|
|
||||||
- [ ] Wire `render-config.sh` to emit `dnsmasq/dnsmasq.conf` (gitignored)
|
|
||||||
- [ ] Decide port-53 strategy (bind to `HOST_LAN_IP` vs disable resolved stub)
|
|
||||||
- [ ] Publish `53/udp` + `53/tcp`; mount rendered conf read-only
|
|
||||||
- [ ] Optionally expose webproc UI (8080) behind Caddy as `dns.ulicraft.local`
|
|
||||||
- [ ] Document guest DNS setting (set DNS server = `HOST_LAN_IP`)
|
|
||||||
- [ ] Verify `*.local` resolves on Windows + macOS + Linux guests (mDNS check)
|
|
||||||
- [ ] Add air-gap spoof `address=` records (see 11) once capture confirms the host list
|
|
||||||
+45
-43
@@ -1,15 +1,20 @@
|
|||||||
# Caddy — ingress, local CDN, landing page
|
# Caddy — internal router behind host nginx
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
Single HTTP ingress for the stack. Three roles:
|
caddy is the stack's **internal HTTP router**, sitting behind the host's own
|
||||||
1. **Reverse proxy** — `auth.ulicraft.local` → drasl.
|
nginx (which terminates TLS — see `15-letsencrypt.md`). caddy is published only
|
||||||
2. **Local CDN / static** — `packwiz.ulicraft.local` serves pack metadata AND the
|
on a localhost-only port (`CADDY_HTTP_BIND=127.0.0.1`, `CADDY_HTTP_PORT=8880`);
|
||||||
mirrored mod jars (offline installs).
|
nginx reverse-proxies every public vhost to it by Host header. Roles:
|
||||||
3. **Landing page** — apex `ulicraft.local` serves launcher downloads + guest guide.
|
|
||||||
|
|
||||||
Plain `:80` for now (LAN). TLS via step-ca deferred. Image: `caddy:alpine`.
|
1. **Reverse proxy** — `auth.` → drasl, `avatar.` → nmsr, `status.` → uptime-kuma,
|
||||||
Caddy natively reads `{$BASE_DOMAIN}` env placeholders — no template render needed.
|
`upload.` → filestash.
|
||||||
|
2. **Static** — apex serves the landing page + `/launcher/` downloads;
|
||||||
|
`distribution.` serves a static site from another repo; `files.` serves a
|
||||||
|
read-only browse of the upload share.
|
||||||
|
|
||||||
|
Image: `caddy:alpine`. caddy natively reads `{$BASE_DOMAIN}` env placeholders —
|
||||||
|
no template render needed.
|
||||||
|
|
||||||
## Network aliases (internal resolution)
|
## Network aliases (internal resolution)
|
||||||
|
|
||||||
@@ -19,51 +24,48 @@ caddy:
|
|||||||
mcnet:
|
mcnet:
|
||||||
aliases:
|
aliases:
|
||||||
- "auth.${BASE_DOMAIN}"
|
- "auth.${BASE_DOMAIN}"
|
||||||
- "packwiz.${BASE_DOMAIN}"
|
|
||||||
```
|
```
|
||||||
Lets `minecraft` reach `http://auth.ulicraft.local/authlib-injector` and
|
Lets `minecraft` reach `http://auth.ulicraft.net/authlib-injector` from inside
|
||||||
`http://packwiz.ulicraft.local/pack.toml` without dnsmasq.
|
the stack.
|
||||||
|
|
||||||
## Caddyfile sketch
|
## conf.d snippets
|
||||||
|
|
||||||
```
|
The Caddyfile imports per-vhost snippets from `caddy/conf.d/`:
|
||||||
{
|
|
||||||
auto_https off
|
|
||||||
}
|
|
||||||
|
|
||||||
http://{$BASE_DOMAIN} {
|
- `00-core.caddy` — `auth.` → drasl.
|
||||||
root * /srv/www
|
- `10-static.caddy` — apex landing (`/srv/www`) + `/launcher/*` (`/srv/launcher`).
|
||||||
file_server
|
- `30-distribution.caddy` — `distribution.` → `/srv/distribution` (static, from
|
||||||
}
|
`DISTRIBUTION_WEB_ROOT`, another repo).
|
||||||
|
- `40-avatar.caddy` — `avatar.` → `nmsr:8080` (skin/avatar renderer).
|
||||||
|
- `50-status.caddy` — `status.` → `uptime-kuma:3001` (status page).
|
||||||
|
- `60-upload.caddy` — `upload.` → `filestash:8334` (player file share, `20-files.md`).
|
||||||
|
- `62-files.caddy` — `files.` → static `/srv/files` (read-only, no-auth `file_server
|
||||||
|
browse` of the same `FILES_DATA_DIR` filestash writes to).
|
||||||
|
|
||||||
http://auth.{$BASE_DOMAIN} {
|
All vhosts are plain `http://…`; TLS is nginx's job. Mounts: `./www:/srv/www:ro`,
|
||||||
reverse_proxy drasl:25585
|
`./launcher:/srv/launcher:ro`, `${DISTRIBUTION_WEB_ROOT}:/srv/distribution:ro`,
|
||||||
}
|
`${FILES_DATA_DIR}:/srv/files:ro`.
|
||||||
|
|
||||||
http://packwiz.{$BASE_DOMAIN} {
|
> **Don't rewrite the `Host` header for `upload.`** — filestash rejects any
|
||||||
root * /srv/pack
|
> request whose Host doesn't match its configured `general.host`, with *"only
|
||||||
file_server browse
|
> 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.
|
||||||
|
|
||||||
Mounts: `./www:/srv/www:ro`, `./pack:/srv/pack:ro`, `./mirror/launcher:/srv/www/launcher:ro`.
|
|
||||||
|
|
||||||
## Notes / risks
|
## Notes / risks
|
||||||
|
|
||||||
- drasl behind a proxy: docs don't detail `X-Forwarded-*` trust. On plain HTTP LAN
|
- drasl behind a proxy: with matching `BaseURL` and nginx terminating TLS it is
|
||||||
with matching `BaseURL` it should be fine; revisit when TLS is added.
|
fine; nginx passes the original Host through to caddy → drasl.
|
||||||
- `file_server browse` gives a directory listing — handy for debugging the pack.
|
- `/launcher/` is a sibling mount (`/srv/launcher`), not nested under the
|
||||||
- Mod-jar mirror lives under `/srv/pack/mods/` so packwiz URLs and jars share host.
|
read-only `/srv/www`; `handle_path` strips the prefix.
|
||||||
|
|
||||||
## Tasks
|
## Tasks
|
||||||
|
|
||||||
- [ ] Add `caddy` service to `docker-compose.yml`, ports `80:80` (+`443` later)
|
- [ ] `caddy` service in `docker-compose.yml`, published `127.0.0.1:${CADDY_HTTP_PORT}:80`
|
||||||
- [ ] Pass `BASE_DOMAIN` into caddy env
|
- [ ] Pass `BASE_DOMAIN` + `DISTRIBUTION_WEB_ROOT` into caddy env
|
||||||
- [ ] Create `caddy/Caddyfile` with apex + auth + packwiz vhosts
|
- [ ] Caddyfile imports `conf.d/*.caddy`
|
||||||
- [ ] Add network aliases for `auth.` and `packwiz.` subdomains
|
- [ ] Network alias for `auth.` subdomain
|
||||||
- [ ] Mount `./www`, `./pack`, `./mirror/launcher`
|
- [ ] 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)
|
||||||
- [ ] Air-gap mirror: add per-host `tls internal` vhosts + aliases + mounts (see 11)
|
- [ ] Verify nmsr + uptime-kuma proxied (`avatar.` renders, `status.` loads)
|
||||||
- [ ] Export Caddy root CA for guest trust (LAN-party TLS = `tls internal`; step-ca deferred)
|
|
||||||
- [ ] Defer (reminder): graduate `tls internal` → step-ca for persistent homelab
|
|
||||||
+31
-5
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
Self-hosted, Yggdrasil-compatible auth + skin server. Drop-in Mojang replacement
|
Self-hosted, Yggdrasil-compatible auth + skin server. Drop-in Mojang replacement
|
||||||
via authlib-injector. Image: `unmojang/drasl:latest`. Reached only through Caddy
|
via authlib-injector. Image: `unmojang/drasl:latest`. Reached only through Caddy
|
||||||
at `auth.ulicraft.local`; no host port.
|
at `auth.ulicraft.net`; no host port.
|
||||||
|
|
||||||
**OIDC/Keycloak is OUT for now** — drasl runs in **password-login** mode
|
**OIDC/Keycloak is OUT for now** — drasl runs in **password-login** mode
|
||||||
(`AllowPasswordLogin = true`). Admin + guests register with a username/password on
|
(`AllowPasswordLogin = true`). Admin + guests register with a username/password on
|
||||||
@@ -16,13 +16,16 @@ Config is **TOML only** (no env var support). So `config.toml` is rendered from
|
|||||||
## config.toml.tmpl (key fields)
|
## config.toml.tmpl (key fields)
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
BaseURL = "http://auth.${BASE_DOMAIN}"
|
BaseURL = "https://auth.${BASE_DOMAIN}" # public scheme is HTTPS (nginx TLS)
|
||||||
Domain = "auth.${BASE_DOMAIN}"
|
Domain = "auth.${BASE_DOMAIN}"
|
||||||
ListenAddress = "0.0.0.0:25585" # internal; Caddy proxies
|
ListenAddress = "0.0.0.0:25585" # internal; Caddy proxies
|
||||||
DefaultAdminUsernames = ["admin"]
|
DefaultAdmins = ["admin"] # NOT DefaultAdminUsernames (see gotchas)
|
||||||
|
|
||||||
AllowPasswordLogin = true
|
AllowPasswordLogin = true
|
||||||
|
|
||||||
|
# Top-level — NOT under any [Section] (see gotchas).
|
||||||
|
CORSAllowOrigins = ["https://${BASE_DOMAIN}"]
|
||||||
|
|
||||||
[ApplicationOwner]
|
[ApplicationOwner]
|
||||||
# ...
|
# ...
|
||||||
|
|
||||||
@@ -35,9 +38,31 @@ AllowPasswordLogin = true
|
|||||||
- **1.21+ secure profile**: drasl `SignPublicKeys = false` MUST pair with server
|
- **1.21+ secure profile**: drasl `SignPublicKeys = false` MUST pair with server
|
||||||
`ENFORCE_SECURE_PROFILE=FALSE`. Linked — both or neither.
|
`ENFORCE_SECURE_PROFILE=FALSE`. Linked — both or neither.
|
||||||
- **authlib endpoint** = `BaseURL` + `/authlib-injector` →
|
- **authlib endpoint** = `BaseURL` + `/authlib-injector` →
|
||||||
`http://auth.ulicraft.local/authlib-injector`. Must match on server and client.
|
`http://auth.ulicraft.net/authlib-injector`. Must match on server and client.
|
||||||
Network alias makes this identical inside/outside the stack.
|
Network alias makes this identical inside/outside the stack.
|
||||||
- **`Domain` affects skins** — if wrong, authlib clients may not see skins.
|
- **`Domain` affects skins** — if wrong, authlib clients may not see skins.
|
||||||
|
- **Config keys are validated loosely — wrong keys are silently ignored** (drasl
|
||||||
|
logs `Warning: unknown config option <key>` and moves on). Two that bit us:
|
||||||
|
- Admin list key is **`DefaultAdmins`** (top-level), NOT `DefaultAdminUsernames`
|
||||||
|
(that key never existed → no admin → admin-only routes like `GET /players`
|
||||||
|
return 403).
|
||||||
|
- **`CORSAllowOrigins` must be top-level**, before any `[Section]`. Placed after
|
||||||
|
a table header it parses as `<table>.CORSAllowOrigins`, is ignored, and the
|
||||||
|
API serves no CORS headers → landing register/account browser calls blocked.
|
||||||
|
- `RateLimit.Burst` is valid in drasl master but warns on `unmojang/drasl:latest`
|
||||||
|
(image lags) — harmless.
|
||||||
|
- **First-admin bootstrap with invite mode on is a chicken-egg.** `DefaultAdmins`
|
||||||
|
only promotes the account at startup reconciliation, and `POST /drasl/api/v2/users`
|
||||||
|
is invite-gated (admins do NOT bypass the *register* endpoint). To create the
|
||||||
|
first admin: temporarily set `RequireInvite = false` in the rendered config,
|
||||||
|
`docker compose restart drasl`, `POST /users` to register `admin`, then revert +
|
||||||
|
restart (always trap-revert). API recap: `POST /login {username,password}` →
|
||||||
|
`{apiToken}`; admin-only `GET /players` with `Authorization: Bearer <token>`.
|
||||||
|
- **NMSR avatars need https + a host-gateway pin** (see `plan/19-routes.md`).
|
||||||
|
Drasl's profile embeds **https** skin URLs (BaseURL is https). NMSR reaches
|
||||||
|
those via `extra_hosts: auth.${BASE_DOMAIN}:host-gateway` (host nginx :443, real
|
||||||
|
LE cert) with https mojank endpoints. Without it the skin fetch Connect-fails to
|
||||||
|
caddy:443 and every avatar renders the DEFAULT skin.
|
||||||
|
|
||||||
## Tasks
|
## Tasks
|
||||||
|
|
||||||
@@ -47,6 +72,7 @@ AllowPasswordLogin = true
|
|||||||
- [ ] Wire `render-config.sh` → `drasl/config/config.toml` (gitignored)
|
- [ ] Wire `render-config.sh` → `drasl/config/config.toml` (gitignored)
|
||||||
- [ ] Remove drasl host port from compose (internal only)
|
- [ ] Remove drasl host port from compose (internal only)
|
||||||
- [ ] Update compose: drasl on `mcnet`, no `25585:25585` publish
|
- [ ] Update compose: drasl on `mcnet`, no `25585:25585` publish
|
||||||
- [ ] Bootstrap admin account on first run; create guest accounts
|
- [x] Bootstrap admin account (`admin`, done 2026-06-10 via invite-flip; creds in
|
||||||
|
cochi `.env` for the mc-status roster). Create guest accounts as needed.
|
||||||
- [ ] Verify authlib endpoint responds through Caddy
|
- [ ] Verify authlib endpoint responds through Caddy
|
||||||
- [ ] Future: re-add `[[RegistrationOIDC]]` Keycloak block + secret file
|
- [ ] Future: re-add `[[RegistrationOIDC]]` Keycloak block + secret file
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
# Blessing Skin — auth + skin server (Drasl alternative)
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Drop-in alternative to Drasl (`plan/03-drasl.md`). Blessing Skin Server (BSS) is
|
|
||||||
a PHP skin station; the **yggdrasil-api plugin** gives it a Yggdrasil API that
|
|
||||||
authlib-injector talks to — same end result as Drasl, different internals.
|
|
||||||
|
|
||||||
Run it as an override **instead of** Drasl:
|
|
||||||
|
|
||||||
```
|
|
||||||
docker compose -f docker-compose.yml -f docker-compose.blessingskin.yml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
The override (`docker-compose.blessingskin.yml`) repoints Caddy's `auth.*` vhost
|
|
||||||
at `blessing-skin:80`, adds `mariadb` + `blessing-skin`, and switches the
|
|
||||||
Minecraft authlib endpoint to `/api/yggdrasil`. Drasl stays defined but idle and
|
|
||||||
unreachable (compose merges `depends_on`, so an override can't remove it);
|
|
||||||
`docker compose … stop drasl` after up if you want it down.
|
|
||||||
|
|
||||||
## Drasl vs Blessing Skin — what changes
|
|
||||||
|
|
||||||
| | Drasl | Blessing Skin |
|
|
||||||
|---|---|---|
|
|
||||||
| Backend | single Go binary | PHP app + **MariaDB** (no SQLite) |
|
|
||||||
| Config | rendered `config.toml` | **web install wizard** + DB |
|
|
||||||
| Yggdrasil API | built in | **yggdrasil-api plugin** (install + enable) |
|
|
||||||
| authlib endpoint | `…/authlib-injector` | `…/api/yggdrasil` |
|
|
||||||
| OIDC/Keycloak | supported (future) | not native (OAuth plugins exist) |
|
|
||||||
| Server online-mode | repo used FALSE | **TRUE** (real session validation) |
|
|
||||||
|
|
||||||
## Setup (first run)
|
|
||||||
|
|
||||||
1. `cp .env.example .env`, fill the `BS_*` vars. Generate `BS_APP_KEY`:
|
|
||||||
```
|
|
||||||
docker run --rm azusamikan/blessing-skin-server-docker:latest \
|
|
||||||
php artisan key:generate --show
|
|
||||||
```
|
|
||||||
2. Bring the stack up with the override (command above).
|
|
||||||
3. Open `http://auth.${BASE_DOMAIN}` → BSS install wizard. DB host = `mariadb`,
|
|
||||||
port `3306`, name/user/pass = the `BS_*` values. Create the admin account.
|
|
||||||
4. Admin panel → **Plugins → Plugin Market** → install **yggdrasil-api** →
|
|
||||||
enable it. (Needs internet; for air-gap, pre-place the plugin jar/zip into
|
|
||||||
the `bs_plugins` volume.) After enabling, the API root is live at
|
|
||||||
`http://auth.${BASE_DOMAIN}/api/yggdrasil`.
|
|
||||||
5. Each player: register on the BSS site, add a character whose name == their
|
|
||||||
in-game player name, upload a skin.
|
|
||||||
6. Restart minecraft if it came up before the plugin was enabled.
|
|
||||||
|
|
||||||
## Client (Prism / authlib-injector)
|
|
||||||
|
|
||||||
- authlib-injector URL: `http://auth.${BASE_DOMAIN}/api/yggdrasil`
|
|
||||||
- Login = BSS **email + password** (BSS uses email as the account id).
|
|
||||||
- Must match the server's `-javaagent` arg exactly (same host + path).
|
|
||||||
|
|
||||||
## Gotchas
|
|
||||||
|
|
||||||
- **`/api/yggdrasil`, not `/authlib-injector`.** Wrong path → silent "Invalid
|
|
||||||
session". Server `JVM_OPTS` and every client must agree.
|
|
||||||
- **ONLINE_MODE=TRUE.** authlib-injector only authenticates when online-mode is
|
|
||||||
on; the server then validates the join against BSS. (The Drasl variant in this
|
|
||||||
repo set FALSE — do not copy that here.)
|
|
||||||
- **Secure profile (1.21+).** Override ships `ENFORCE_SECURE_PROFILE=FALSE` for
|
|
||||||
safety. yggdrasil-api *does* sign profile keys, so TRUE may work — flip and
|
|
||||||
test if signed chat matters; revert on "Invalid signature".
|
|
||||||
- **APP_KEY must persist.** Set `BS_APP_KEY` in `.env`. Losing it invalidates
|
|
||||||
all sessions and breaks encrypted data.
|
|
||||||
- **DB is the source of truth.** `bs_db` volume holds accounts; `bs_storage`
|
|
||||||
holds skins; `bs_plugins` holds the yggdrasil-api plugin. Back these up.
|
|
||||||
- **Air-gap:** the plugin market needs internet. Install + enable yggdrasil-api
|
|
||||||
while online (it persists in `bs_plugins`), or stage the plugin manually.
|
|
||||||
|
|
||||||
## Image note
|
|
||||||
|
|
||||||
`azusamikan/blessing-skin-server-docker:latest` is a community image and the
|
|
||||||
env-var names (`DB_*`, `APP_KEY`) may differ slightly by tag — if auto-config
|
|
||||||
doesn't take, the web wizard is the reliable path. Official source:
|
|
||||||
https://github.com/bs-community/blessing-skin-server ,
|
|
||||||
plugin: https://github.com/bs-community/yggdrasil-api , authlib-injector setup:
|
|
||||||
https://github.com/bs-community/blessing-skin-manual/blob/master/man/yggdrasil-api/authlib-injector.md
|
|
||||||
Loaded 100 of 150 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user