Compare commits
51 Commits
067e990306
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -14,7 +14,7 @@ Redeploy the running stack on the production host. Full reference:
|
||||
- Path: `/home/ubuntu/mc/ulicraft-server-v1`
|
||||
- Branch: `main`
|
||||
- Stack: single unified `docker-compose.yml` (drasl, minecraft, mc-backup,
|
||||
caddy, nmsr, uptime-kuma)
|
||||
caddy, nmsr, mc-status, uptime-kuma, filestash)
|
||||
- Auth: Drasl
|
||||
- Restart: **hard** (down → up) — players are disconnected for a few minutes
|
||||
|
||||
@@ -31,40 +31,95 @@ invoking this skill is the authorization to proceed, but:
|
||||
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.
|
||||
|
||||
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
|
||||
ssh cochi 'set -e
|
||||
cd /home/ubuntu/mc/ulicraft-server-v1
|
||||
git pull --ff-only
|
||||
tooling/render-config.sh
|
||||
tooling/fetch-authlib.sh
|
||||
docker compose down --remove-orphans
|
||||
docker compose up -d --build'
|
||||
./update-all.sh --hard'
|
||||
```
|
||||
- `--ff-only` refuses to merge — if the pull is not a fast-forward, STOP and
|
||||
report (local changes on the host need manual resolution).
|
||||
- `render-config.sh` re-renders drasl/nmsr/packwiz configs from `.env`
|
||||
(needs `.env` complete: `BASE_DOMAIN`, `RCON_PASSWORD`,
|
||||
`DISTRIBUTION_WEB_ROOT`, `CADDY_HTTP_*`).
|
||||
- `fetch-authlib.sh` ensures `runtime/authlib-injector.jar` exists (gitignored;
|
||||
skips if already valid). Missing jar = minecraft crashloops with "Error
|
||||
opening zip file or JAR manifest missing".
|
||||
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.**
|
||||
```bash
|
||||
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && docker compose ps'
|
||||
```
|
||||
Confirm `caddy`, `drasl`, `minecraft`, `mc-backup`, `nmsr`, `uptime-kuma` are
|
||||
Up. Tail the minecraft log briefly and confirm it reaches `Done` (server
|
||||
ready):
|
||||
Confirm `caddy`, `drasl`, `minecraft`, `mc-backup`, `nmsr`, `mc-status`,
|
||||
`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
|
||||
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && timeout 120 docker compose logs -f minecraft' 2>/dev/null | grep -m1 "Done"
|
||||
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && timeout 180 docker compose logs -f minecraft' 2>/dev/null | grep -m1 'Done ([0-9.]*s)! For help'
|
||||
```
|
||||
If a public vhost changed, verify it end-to-end from your workstation rather
|
||||
than trusting `compose ps` (a container can be Up while nginx never routes to
|
||||
it): `curl -s -o /dev/null -w '%{http_code}' https://<vhost>/`.
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Never `git reset --hard` or discard changes on `cochi` without telling the user
|
||||
first — there may be host-local edits.
|
||||
- **Caddy conf changes need a recreate, not reload/restart.** If the deploy
|
||||
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
|
||||
|
||||
82
.env.example
82
.env.example
@@ -2,11 +2,43 @@
|
||||
|
||||
# Single base domain for the whole stack; every service is a subdomain of this.
|
||||
# DNS is handled OUTSIDE this repo — point ${BASE_DOMAIN} and every subdomain
|
||||
# (auth. pack. avatar. status. distribution. www.) at the host running nginx.
|
||||
# (auth. avatar. status. distribution. www.) at the host running nginx.
|
||||
BASE_DOMAIN=ulicraft.net
|
||||
|
||||
RCON_PASSWORD=change-me-to-something-random
|
||||
|
||||
# Registration mode (BACKEND gate) for self-hosted accounts (Drasl).
|
||||
# invite = closed; guests need an admin-minted invite code (recommended on a
|
||||
# public, internet-present host). Admin mints via POST /drasl/api/v2/invites.
|
||||
# open = anyone can self-register with username+password (set [RateLimit]!).
|
||||
# Consumed by render-config.sh (-> Drasl RegistrationNewPlayer.RequireInvite).
|
||||
# Default: invite. NOTE: this no longer controls the landing invite FIELD — that's
|
||||
# REGISTRATION_SHOW_INVITE below.
|
||||
REGISTRATION_MODE=invite
|
||||
|
||||
# Show the invite-code FIELD on the landing register form. Read by the landing
|
||||
# BUILD (data/site.ts) only; default false (hidden). Independent of the backend
|
||||
# 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.
|
||||
@@ -17,6 +49,52 @@ CADDY_HTTP_BIND=127.0.0.1
|
||||
# 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
|
||||
|
||||
# ── files.${BASE_DOMAIN} — Filestash player file share ────────────────
|
||||
# Screenshots / schematics / maps. ONE shared login for all players, writable.
|
||||
# Full design + gotchas: plan/20-files.md.
|
||||
|
||||
# Host path holding the shared files. Keep it OUTSIDE the repo — guest-writable
|
||||
# data must not live in a git tree we `git pull` into.
|
||||
FILES_DATA_DIR=/srv/ulicraft-files
|
||||
|
||||
# Literal mount flag for FILES_DATA_DIR: rw | ro. `ro` makes the share
|
||||
# read-only at the KERNEL, regardless of what the Filestash UI thinks. It is a
|
||||
# mount flag rather than a boolean because compose has no conditionals — the
|
||||
# value is substituted straight into the volume string.
|
||||
FILES_MOUNT_MODE=rw
|
||||
|
||||
# Auth middleware (rendered into config.json by render-config.sh):
|
||||
# htpasswd one shared user/pass — the only internet-safe value
|
||||
# passthrough NO LOGIN. Anyone reaching the vhost is in.
|
||||
# none alias of passthrough
|
||||
# passthrough/none on a WRITABLE, internet-reachable share is an open upload
|
||||
# relay (malware/phishing hosted on your domain, under your cert, with your
|
||||
# bandwidth — and a domain blocklisting would take auth. and the apex down
|
||||
# with it). Only set it once this host is unreachable from the internet.
|
||||
FILES_AUTH=htpasswd
|
||||
|
||||
# The shared player credential (FILES_AUTH=htpasswd).
|
||||
# FILES_PASSWORD_HASH: openssl passwd -6 '<password>'
|
||||
# MUST be $6$ (sha512). The htpasswd plugin's bcrypt branch only accepts $2a$,
|
||||
# so a $2y$ hash from `htpasswd -B` fails EVERY login, silently.
|
||||
# render-config.sh rejects the wrong format rather than shipping it.
|
||||
FILES_USER=uli
|
||||
FILES_PASSWORD_HASH=
|
||||
|
||||
# Filestash /admin console password — bcrypt, and /admin IS publicly exposed, so
|
||||
# use a 20+ char generated password. It is the only credential guarding config.
|
||||
# docker run --rm caddy:alpine caddy hash-password --plaintext '<password>'
|
||||
FILES_ADMIN_PASSWORD_HASH=
|
||||
|
||||
# Session key (openssl rand -hex 16). Must be non-empty: an empty secret_key
|
||||
# makes filestash rewrite config.json at boot, which fails on the :ro mount.
|
||||
FILES_SECRET_KEY=
|
||||
|
||||
# Unlocks the local storage backend, which filestash admin-gates: it refuses to
|
||||
# init unless the connection params carry this secret. config.json supplies it
|
||||
# server-side; players never see it. openssl rand -hex 24.
|
||||
FILES_LOCAL_SECRET=
|
||||
|
||||
# Only needed if using CurseForge-exclusive mods:
|
||||
# CF_API_KEY=your-curseforge-api-key
|
||||
|
||||
@@ -24,7 +102,7 @@ DISTRIBUTION_WEB_ROOT=/home/oier/projects/mc-mods/ulicraft-group/ulicraft-distri
|
||||
# Only needed to issue real TLS certs. DNS-01 needs no inbound ports.
|
||||
LE_EMAIL=you@example.com
|
||||
# space-separated subdomains; apex ${BASE_DOMAIN} is always included
|
||||
LE_SUBDOMAINS=auth pack distribution www avatar status
|
||||
LE_SUBDOMAINS=auth distribution www avatar status files
|
||||
# OVH API creds (DNS zone write). Create at https://eu.api.ovh.com/createToken/
|
||||
# OVH_CK can be blank on first run — acme.sh prints an auth URL, then paste it.
|
||||
OVH_END_POINT=ovh-eu
|
||||
|
||||
26
.gitignore
vendored
26
.gitignore
vendored
@@ -6,15 +6,16 @@ runtime/authlib-injector.jar
|
||||
# rendered configs (from tooling/render-config.sh)
|
||||
drasl/config/config.toml
|
||||
nmsr/config.toml
|
||||
# packwiz pack files rendered from pack/**/*.tmpl by tooling/render-pack.sh
|
||||
# (domain-dependent build output); source of truth is the .tmpl + custom/ jars.
|
||||
pack/mods/*.pw.toml
|
||||
pack/index.toml
|
||||
pack/CustomSkinLoader/CustomSkinLoader.json
|
||||
# holds the admin bcrypt hash, the shared player hash, the session key and the
|
||||
# local-backend secret — only the .tmpl is tracked
|
||||
filestash/config.json
|
||||
|
||||
# server mod subset synced from the distribution repo by
|
||||
# tooling/sync-server-mods.sh (source of truth is mods-sides.json + $DIST jars)
|
||||
server-mods/
|
||||
|
||||
# vendored binaries
|
||||
launcher/
|
||||
pack/mods-files/
|
||||
|
||||
# landing page: generated build output + deps
|
||||
www/
|
||||
@@ -22,5 +23,18 @@ landing/node_modules/
|
||||
landing/.astro/
|
||||
landing/dist/
|
||||
|
||||
# launcher download list: the custom-launcher build output launcher.json,
|
||||
# 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)
|
||||
certs/
|
||||
|
||||
126
CLAUDE.md
126
CLAUDE.md
@@ -1,7 +1,7 @@
|
||||
# Ulicraft Server — Project Context
|
||||
|
||||
> Self-hosted modded Minecraft (NeoForge 1.21.1) with self-hosted auth/skins
|
||||
> (Drasl), a packwiz modpack, avatar rendering, a guest landing page, and uptime
|
||||
> (Drasl), a distribution-fed modpack, avatar rendering, a guest landing page, and uptime
|
||||
> 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`.
|
||||
@@ -23,25 +23,25 @@ with `plan/` or the code, those win.
|
||||
One unified `docker-compose.yml`. The host's own nginx terminates TLS (Let's
|
||||
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
|
||||
via caddy's `mcnet` aliases (`auth.`, `pack.`).
|
||||
via caddy's `mcnet` aliases (`auth.`).
|
||||
|
||||
```
|
||||
Internet ── host nginx (TLS, LE certs, HSTS) ──► caddy (127.0.0.1:8880)
|
||||
│ routes by Host:
|
||||
${BASE_DOMAIN} ─► /srv/www landing + /launcher/ downloads
|
||||
auth.${BASE_DOMAIN} ─► drasl (Yggdrasil API + web UI)
|
||||
pack.${BASE_DOMAIN} ─► packwiz files + /custom/ jars
|
||||
avatar.${BASE_DOMAIN} ─► nmsr (skin/avatar renderer, Drasl-backed)
|
||||
status.${BASE_DOMAIN} ─► uptime-kuma (status page + monitors)
|
||||
files.${BASE_DOMAIN} ─► filestash (player file share, ONE shared login, writable)
|
||||
distribution.${BASE} ─► static site from ANOTHER repo (DISTRIBUTION_WEB_ROOT)
|
||||
|
||||
minecraft :25565 (+ 24454/udp Simple Voice Chat)
|
||||
└ ONLINE_MODE=false, -javaagent authlib-injector → http://auth.${BASE_DOMAIN}/authlib-injector
|
||||
└ mods via PACKWIZ_URL → http://pack.${BASE_DOMAIN}/pack.toml
|
||||
└ 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)
|
||||
```
|
||||
|
||||
Bring-up: render configs → build landing → fetch launcher → `docker compose up -d --build`.
|
||||
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.
|
||||
|
||||
@@ -55,8 +55,11 @@ via the `deploy` skill.
|
||||
- **Manual curation** (user choice, flagged expensive) — if it gets painful,
|
||||
fork-and-trim Leaking Kitchen Sink is still on the table (closest vibe match).
|
||||
- **itzg/minecraft-server** base image — de-facto standard, auto-installs NeoForge.
|
||||
- **packwiz** for the modpack — `PACKWIZ_URL` drives server mod install; clients
|
||||
import the same `pack.toml`. Jars come from the upstream CDN per `.pw.toml`.
|
||||
- **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
|
||||
|
||||
@@ -65,6 +68,14 @@ via the `deploy` skill.
|
||||
- Drasl: `SignPublicKeys = false`
|
||||
- Linked. Drasl docs: *"Mixed authentication does not work with
|
||||
`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
|
||||
- Syntax: `-javaagent:/extras/authlib-injector.jar=<yggdrasil-api-url>`
|
||||
@@ -78,13 +89,45 @@ via the `deploy` skill.
|
||||
|
||||
### NeoForge version pinning
|
||||
Mods are picky about exact minor versions. Compose pins `NEOFORGE_VERSION:
|
||||
"21.1.209"`. **Verify against https://projects.neoforged.net/neoforged/neoforge
|
||||
before deploying.** Never `"latest"` for a stable server.
|
||||
"21.1.233"` — **must match the distribution's NeoForge** (`distribution.json`),
|
||||
since mods are built against it (e.g. ferritecore≥218, farmersdelight≥219).
|
||||
**Verify against https://projects.neoforged.net/neoforged/neoforge before
|
||||
deploying.** Never `"latest"` for a stable server.
|
||||
|
||||
### Mod source preference
|
||||
Prefer **Modrinth over CurseForge** (`packwiz modrinth add <slug>`). CF needs
|
||||
`CF_API_KEY` and is flakier. Tag client-only mods `side = "client"` so the server
|
||||
doesn't pull and crash on them.
|
||||
### Mod source + server filtering
|
||||
The full modset lives in the distribution repo (`$DISTRIBUTION_WEB_ROOT/servers/
|
||||
ulicraft-1.21.1/forgemods/required/*.jar`), managed by Nebula. Clients get
|
||||
everything via `distribution.json`. The server runs a **filtered subset**:
|
||||
`tooling/classify-mods.py` reads each jar's `neoforge.mods.toml` with tomllib and
|
||||
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`.
|
||||
|
||||
### Filestash (files.) — four traps
|
||||
Full list in `plan/20-files.md`; the ones that cost real time:
|
||||
- **`general.host` is a BARE HOSTNAME** (`files.${BASE_DOMAIN}`, no scheme) +
|
||||
`force_ssl: true`. The SPA builds its origin as `scheme + host`, so a URL there
|
||||
yields `http://https://files...` and every client-side redirect dies. The
|
||||
server strips the scheme before its own Host check, so **curl and the uptime
|
||||
monitor stay green while every browser is broken**. Cost a debugging round on
|
||||
2026-07-14.
|
||||
- The `local` backend is **admin-gated**: without `LOCAL_BACKEND_SECRET` in the
|
||||
connection params, every login fails with `backend error - Not Allowed` (looks
|
||||
like bad credentials, isn't).
|
||||
- `FILES_PASSWORD_HASH` must be **`$6$`** (`openssl passwd -6`). A `$2y$` bcrypt
|
||||
from `htpasswd -B` fails every login silently — the plugin only matches `$2a$`.
|
||||
- `config.json` is rendered + mounted `:ro` (the admin console can't save, on
|
||||
purpose). Single-file bind mount ⇒ **`--force-recreate` after re-rendering**,
|
||||
never `restart`.
|
||||
|
||||
**Verifying an SPA:** a 200 on `/` proves the server is up, not that the app
|
||||
works. After any filestash config change, check the browser-facing values
|
||||
(`curl /api/config` → `origin`) and re-read `docker compose logs filestash` —
|
||||
the broken redirect announced itself there (`msg=Redirecting to http://https://`)
|
||||
while every other check passed.
|
||||
|
||||
### Memory sizing
|
||||
~50–100 mods, 8 players, 1.21.1: `INIT_MEMORY: 4G`, `MAX_MEMORY: 10G`,
|
||||
@@ -93,9 +136,11 @@ doesn't pull and crash on them.
|
||||
## Config (.env)
|
||||
|
||||
`BASE_DOMAIN`, `RCON_PASSWORD`, `CADDY_HTTP_PORT`/`CADDY_HTTP_BIND`,
|
||||
`DISTRIBUTION_WEB_ROOT`, the Let's Encrypt block (`LE_EMAIL`, `LE_SUBDOMAINS`,
|
||||
`OVH_*`), optional `CF_API_KEY`. Rendered configs (drasl, nmsr, packwiz) come
|
||||
from `*.tmpl` via `tooling/render-config.sh` (substitutes `${BASE_DOMAIN}` only).
|
||||
`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`.
|
||||
|
||||
## Client Distribution (guests)
|
||||
|
||||
@@ -105,20 +150,51 @@ mirrored at apex `/launcher/`. Per guest:
|
||||
2. Add an authlib-injector account, URL
|
||||
`https://auth.${BASE_DOMAIN}/authlib-injector` (register/login at
|
||||
`https://auth.${BASE_DOMAIN}`).
|
||||
3. Import the pack `https://pack.${BASE_DOMAIN}/pack.toml`.
|
||||
3. The launcher installs the modpack from the distribution (`distribution.json`).
|
||||
(packwiz and the `pack.` service/subdomain have been fully removed.)
|
||||
4. Connect to `${BASE_DOMAIN}` (`:25565`).
|
||||
|
||||
## Open / Pending Work
|
||||
|
||||
- [ ] **Mod shortlist** for the kitchen-sink pack: Performance (Embeddium,
|
||||
FerriteCore, ModernFix), Tech (Mekanism, Create, IE, AE2), Magic (Ars Nouveau,
|
||||
Botania, Iron's Spells), Storage (Sophisticated, Functional), Exploration
|
||||
(YUNG's, Repurposed Structures), QoL (JEI, Jade, IPN, AppleSkin), World gen
|
||||
(Tectonic, Terralith — verify NeoForge 1.21.1), Compat (Polymorph), Map (Xaero),
|
||||
Voice (Simple Voice Chat — 24454/udp already in compose).
|
||||
### Roadmap — mod migration follow-ups (post packwiz→04-mods)
|
||||
- [ ] **Curate `mods-sides.json` cosmetic-client mods.** tomllib auto-flagged 11
|
||||
hard client-only mods; the other 65 default `both`, including client-cosmetic
|
||||
ones (BetterF3, entityculling, MouseTweaks, ponderjs, fancymenu, drippy, melody,
|
||||
welcomescreen, Searchables, JustEnoughResources, TravelersTitles, yeetus…) 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).
|
||||
|
||||
- [ ] **WS6 — Mod shortlist + list component (`plan/17-mod-shortlist.md`).**
|
||||
Light gap doc: current 76-jar pack is vanilla+ QoL/perf; **empty** on tech &
|
||||
magic, thin worldgen, no map; orphan deps `ponderjs`/`Necronomicon`. Landing
|
||||
mod-list component = auto-generated `mods.json` + extracted logos from the
|
||||
distribution forgemods (`tooling/build-modlist.py`), flat alphabetical, pretty
|
||||
names, no categories/links; feeds the "50+" stat tile.
|
||||
- [ ] Server-side perf mods (C2ME, etc.).
|
||||
- [x] **Filestash file share** (`files.`, 2026-07-14) — live in prod. One shared
|
||||
htpasswd login, writable, rendered `:ro` config. See `plan/20-files.md`.
|
||||
Follow-ups: add the Uptime Kuma HTTP monitor for `files.`; the share is
|
||||
**outside mc-backup** and unquota'd (accepted — eyeballed).
|
||||
- [ ] **Verify NeoForge version** against current stable before first deploy.
|
||||
- [ ] **Bootstrap Drasl admin** account.
|
||||
- [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
|
||||
|
||||
|
||||
107
README.md
107
README.md
@@ -1,9 +1,9 @@
|
||||
# Ulicraft Server
|
||||
|
||||
Self-hosted modded Minecraft (NeoForge 1.21.1) with self-hosted auth/skins
|
||||
(Drasl), a packwiz modpack, avatar rendering, a guest landing page, and uptime
|
||||
monitoring. One unified `docker-compose.yml` runs the whole stack behind the
|
||||
host's nginx + Let's Encrypt.
|
||||
(Drasl), a distribution-fed modpack, avatar rendering, a guest landing page, and
|
||||
uptime monitoring. One unified `docker-compose.yml` runs the whole stack behind
|
||||
the host's nginx + Let's Encrypt.
|
||||
|
||||
## Stack
|
||||
|
||||
@@ -15,30 +15,31 @@ One compose file, one `docker compose up -d`:
|
||||
| `drasl` | unmojang/drasl | Yggdrasil auth + skins (password login) |
|
||||
| `caddy` | caddy:alpine | internal ingress: routes every vhost by Host header |
|
||||
| `nmsr` | built from source | skin/avatar renderer at `avatar.${BASE_DOMAIN}` |
|
||||
| `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 at `files.${BASE_DOMAIN}` |
|
||||
| `mc-backup` | itzg/mc-backup | world backups every 6h via RCON |
|
||||
|
||||
Vhosts (all proxied through the host nginx → caddy):
|
||||
|
||||
- apex `${BASE_DOMAIN}` — landing page + `/launcher/` downloads
|
||||
- apex `${BASE_DOMAIN}` — landing page + `/launcher/` downloads + `/api/mcstatus/*`
|
||||
- `auth.` — Drasl
|
||||
- `pack.` — packwiz metadata + `/custom/` jars
|
||||
- `avatar.` — NMSR
|
||||
- `status.` — Uptime Kuma
|
||||
- `files.` — Filestash player file share (one shared login, writable)
|
||||
- `distribution.` — static site from another repo (`DISTRIBUTION_WEB_ROOT`)
|
||||
|
||||
Config lives in `.env`: `BASE_DOMAIN`, `RCON_PASSWORD`, `CADDY_HTTP_PORT` /
|
||||
`CADDY_HTTP_BIND`, `DISTRIBUTION_WEB_ROOT`. See `.env.example`.
|
||||
`CADDY_HTTP_BIND`, `DISTRIBUTION_WEB_ROOT`, the `FILES_*` block. See `.env.example`.
|
||||
|
||||
## DNS
|
||||
|
||||
Handled **outside this repo**. Point `${BASE_DOMAIN}` and every subdomain
|
||||
(`auth. pack. avatar. status. distribution. www.`) at the host running nginx.
|
||||
(`auth. avatar. status. files. distribution. www.`) at the host running nginx.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Docker + Docker Compose; for prep also `pnpm`, `packwiz`, `jq`, `curl`,
|
||||
`envsubst`.
|
||||
Docker + Docker Compose; for prep also `pnpm`, `jq`, `curl`, `envsubst`.
|
||||
|
||||
## Launch
|
||||
|
||||
@@ -46,7 +47,8 @@ Docker + Docker Compose; for prep also `pnpm`, `packwiz`, `jq`, `curl`,
|
||||
cp .env.example .env # set BASE_DOMAIN, RCON_PASSWORD, DISTRIBUTION_WEB_ROOT
|
||||
|
||||
# One-time / on change — render configs + build content:
|
||||
tooling/render-config.sh # drasl + nmsr + packwiz configs from *.tmpl
|
||||
tooling/render-config.sh # drasl + nmsr configs from *.tmpl
|
||||
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)
|
||||
@@ -55,7 +57,7 @@ docker compose up -d --build # first run installs NeoForge + mods, builds nms
|
||||
docker compose down # stop
|
||||
```
|
||||
|
||||
First boot: itzg installs NeoForge + the packwiz mods into the `mc_data` volume;
|
||||
First boot: itzg installs NeoForge + the synced server mods into the `mc_data` volume;
|
||||
nmsr does a one-time Rust compile. Watch `docker compose logs -f minecraft` until
|
||||
`Done`.
|
||||
|
||||
@@ -69,7 +71,7 @@ certs and reverse-proxies every vhost to caddy by Host header. HTTPS-only: HTTP
|
||||
1. **Issue certs** (OVH DNS-01, no inbound ports needed):
|
||||
```sh
|
||||
# .env: BASE_DOMAIN, LE_EMAIL, OVH_* creds,
|
||||
# LE_SUBDOMAINS="auth pack distribution www avatar status"
|
||||
# LE_SUBDOMAINS="auth distribution www avatar status"
|
||||
tooling/issue-letsencrypt.sh # → certs/<name>/{cert,key}.pem
|
||||
```
|
||||
|
||||
@@ -120,22 +122,93 @@ persists in the `kuma_data` volume):
|
||||
| Minecraft | Minecraft Server | `minecraft` : `25565` |
|
||||
| Drasl auth | HTTP(s) | `https://auth.${BASE_DOMAIN}` |
|
||||
| Landing (apex) | HTTP(s) | `https://${BASE_DOMAIN}` |
|
||||
| Packwiz | HTTP(s) | `https://pack.${BASE_DOMAIN}` |
|
||||
| Distribution | HTTP(s) | `https://distribution.${BASE_DOMAIN}` |
|
||||
| Avatar (NMSR) | HTTP(s) | `https://avatar.${BASE_DOMAIN}` |
|
||||
| Files (Filestash) | HTTP(s) | `https://files.${BASE_DOMAIN}` (expect 200 — the login page is a 200) |
|
||||
|
||||
Internal-name targets (`minecraft`, `drasl:25585`, `nmsr:8080`) isolate "service
|
||||
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`.
|
||||
|
||||
## Player file share (Filestash)
|
||||
|
||||
`files.${BASE_DOMAIN}` is a web file share for player media — screenshots,
|
||||
schematics, maps, guides. **One shared username/password for everybody**
|
||||
(`FILES_USER` / `FILES_PASSWORD_HASH`), read **and** write. It is *not* a backup
|
||||
browser and *not* a mod mirror: world snapshots contain every player's inventory
|
||||
and coordinates, and the modset already ships via `distribution.`.
|
||||
|
||||
Full design, deploy steps and gotchas: **`plan/20-files.md`**. The short version:
|
||||
|
||||
- Config is `filestash/config.json`, **rendered from a template and mounted
|
||||
`:ro`** — git is the source of truth. The admin console at `/admin` loads but
|
||||
**cannot save**; that's deliberate. Change config by editing the `.tmpl`,
|
||||
re-rendering, and **recreating** the container (`up -d --force-recreate
|
||||
filestash` — a single-file bind mount pins the inode, so `restart` reads the
|
||||
stale file).
|
||||
- Files live in `${FILES_DATA_DIR}` on the host, outside the repo. **Not backed
|
||||
up** by mc-backup — treat the share as disposable.
|
||||
- `FILES_MOUNT_MODE=ro|rw` is the literal mount flag: `ro` makes the share
|
||||
read-only at the *kernel*, whatever the UI thinks.
|
||||
- `FILES_AUTH=htpasswd|passthrough` — `passthrough` means **no login at all**.
|
||||
Only valid once the host is off the public internet: an unauthenticated
|
||||
*writable* share on a public domain is an open upload relay.
|
||||
|
||||
Four traps that cost real time (all enforced or documented in the tooling):
|
||||
|
||||
- **`general.host` is a bare hostname**, no scheme (`files.${BASE_DOMAIN}`), with
|
||||
`force_ssl: true`. The SPA builds its redirect origin as `scheme + host`, so a
|
||||
URL there produces `http://https://files...` and every client-side redirect
|
||||
breaks. The server strips the scheme before its *own* Host check, so `curl`
|
||||
and the uptime monitor stay **green while every browser is broken**.
|
||||
- **`FILES_PASSWORD_HASH` must be `$6$`** (`openssl passwd -6`). The htpasswd
|
||||
plugin's bcrypt branch only matches `$2a$`, so a `$2y$` hash from
|
||||
`htpasswd -B` fails *every* login, silently. `render-config.sh` rejects it.
|
||||
- **The `local` storage backend is admin-gated** — without `FILES_LOCAL_SECRET`
|
||||
reaching it through the connection params, every login dies with
|
||||
`backend error - Not Allowed`, which looks like bad credentials and isn't.
|
||||
- **Don't set `APPLICATION_URL`/`ADMIN_PASSWORD` env** — either makes Filestash
|
||||
rewrite `config.json` at boot, which fails against the `:ro` mount.
|
||||
|
||||
> Verifying this service: a 200 on `/` only proves the server is up. Check the
|
||||
> browser-facing origin — `curl -s -H 'X-Requested-With: XmlHttpRequest`
|
||||
> `https://files.${BASE_DOMAIN}/api/config | jq -r .result.origin` must print
|
||||
> `https://files.${BASE_DOMAIN}` — and re-read `docker compose logs filestash`.
|
||||
|
||||
## Join (guests)
|
||||
|
||||
1. Open `https://${BASE_DOMAIN}`, download FjordLauncherUnlocked for your OS.
|
||||
2. Add an **authlib-injector** account, URL
|
||||
`https://auth.${BASE_DOMAIN}/authlib-injector` (register/login at
|
||||
`https://auth.${BASE_DOMAIN}`).
|
||||
3. Import the pack: `https://pack.${BASE_DOMAIN}/pack.toml`.
|
||||
3. The launcher installs the modpack from the distribution automatically.
|
||||
4. Connect to **`${BASE_DOMAIN}`** (Minecraft, `:25565`).
|
||||
|
||||
## Layout
|
||||
@@ -147,11 +220,11 @@ caddy/Caddyfile + conf.d/*.caddy # ingress vhost snippets
|
||||
drasl/config/config.toml.tmpl # auth config (rendered)
|
||||
nmsr/config.toml.tmpl # avatar renderer config, Drasl-backed (rendered)
|
||||
docker/nmsr/ # built-from-source NMSR image
|
||||
pack/ # packwiz modpack source
|
||||
landing/ # Astro site → www/
|
||||
launcher/ # FjordLauncher assets (gitignored)
|
||||
tooling/ # render-config, render-pack, render-nginx,
|
||||
# issue-letsencrypt, fetch-launcher, add-custom-mod
|
||||
server-mods/ # filtered server mod jars (synced, gitignored)
|
||||
tooling/ # render-config, sync-server-mods, render-nginx,
|
||||
# issue-letsencrypt, fetch-launcher
|
||||
nginx/ulicraft-caddy.conf.tmpl # host-nginx TLS vhost template (front of caddy)
|
||||
plan/ # design docs
|
||||
```
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Base Caddy config. Vhosts live in conf.d/*.caddy snippets:
|
||||
# conf.d/00-core.caddy auth + packwiz
|
||||
# conf.d/00-core.caddy auth
|
||||
# conf.d/10-static.caddy apex landing + launcher downloads
|
||||
# conf.d/30-distribution.caddy static site from another repo
|
||||
# conf.d/40-avatar.caddy nmsr skin/avatar renderer
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
# Core ingress — always mounted. Drasl auth + packwiz pack metadata.
|
||||
# Core ingress — always mounted. Drasl auth.
|
||||
http://auth.{$BASE_DOMAIN} {
|
||||
reverse_proxy drasl:25585
|
||||
}
|
||||
|
||||
http://pack.{$BASE_DOMAIN} {
|
||||
root * /srv/pack
|
||||
file_server browse
|
||||
}
|
||||
|
||||
@@ -6,6 +6,13 @@ http://{$BASE_DOMAIN} {
|
||||
root * /srv/launcher
|
||||
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 {
|
||||
root * /srv/www
|
||||
file_server
|
||||
|
||||
@@ -3,5 +3,8 @@
|
||||
# /srv/distribution by the caddy service in docker-compose.yml.
|
||||
http://distribution.{$BASE_DOMAIN} {
|
||||
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
|
||||
}
|
||||
|
||||
10
caddy/conf.d/60-files.caddy
Normal file
10
caddy/conf.d/60-files.caddy
Normal file
@@ -0,0 +1,10 @@
|
||||
# Files — Filestash player file share (filestash service). Shared login,
|
||||
# writable. See plan/20-files.md.
|
||||
#
|
||||
# Filestash blocks any request whose Host header doesn't match its configured
|
||||
# `general.host` (https://files.${BASE_DOMAIN}), with "only traffic from X is
|
||||
# allowed". The host nginx forwards the original Host, and caddy passes it
|
||||
# through untouched — do NOT rewrite it here or every request 403s.
|
||||
http://files.{$BASE_DOMAIN} {
|
||||
reverse_proxy filestash:8334
|
||||
}
|
||||
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.
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.
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.
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.
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.
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.
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.
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -3,9 +3,9 @@
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
# Single, unified compose. One file holds the whole stack:
|
||||
# drasl auth + skins (Yggdrasil), internal only
|
||||
# minecraft the server (itzg/NEOFORGE), installs mods via packwiz
|
||||
# minecraft the server (itzg/NEOFORGE), mods from ./server-mods volume
|
||||
# mc-backup world backups every 6h via RCON
|
||||
# caddy internal ingress — routes auth./pack./apex/launcher/avatar.
|
||||
# caddy internal ingress — routes auth./apex/launcher/avatar.
|
||||
# /distribution. by Host header (conf.d/*.caddy)
|
||||
# nmsr skin/avatar renderer at avatar.${BASE_DOMAIN}
|
||||
# uptime-kuma status monitoring at status.${BASE_DOMAIN}
|
||||
@@ -16,7 +16,7 @@
|
||||
# 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 aliases (auth./pack.).
|
||||
# the stack's own names internally via caddy's mcnet alias (auth.).
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
|
||||
services:
|
||||
@@ -48,7 +48,9 @@ services:
|
||||
TYPE: "NEOFORGE"
|
||||
VERSION: "1.21.1"
|
||||
# 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 ────────────────────────────────────
|
||||
INIT_MEMORY: "4G"
|
||||
@@ -56,23 +58,37 @@ services:
|
||||
USE_AIKAR_FLAGS: "TRUE"
|
||||
JVM_XX_OPTS: "-XX:+UseG1GC"
|
||||
|
||||
# ── Auth: offline mode + authlib-injector pointing at Drasl ─
|
||||
ONLINE_MODE: "FALSE"
|
||||
# ── Auth: ONLINE mode validated against Drasl via authlib-injector ─
|
||||
# MUST be true: authlib-injector redirects the normal online-auth
|
||||
# handshake from Mojang to Drasl. online-mode=false skips the handshake
|
||||
# entirely -> offline UUIDs, no session validation, NO SKINS for anyone.
|
||||
# Secure-profile is the part that must stay off on 1.21+ (see
|
||||
# ENFORCE_SECURE_PROFILE + Drasl SignPublicKeys=false), NOT online-mode.
|
||||
ONLINE_MODE: "TRUE"
|
||||
# Resolves over mcnet to caddy (alias auth.${BASE_DOMAIN}) -> drasl.
|
||||
JVM_OPTS: "-javaagent:/extras/authlib-injector.jar=http://auth.${BASE_DOMAIN}/authlib-injector"
|
||||
JVM_OPTS: "-javaagent:/extras/authlib-injector.jar=https://auth.${BASE_DOMAIN}/authlib-injector"
|
||||
|
||||
# ── Server config ───────────────────────────────────────────
|
||||
DIFFICULTY: "normal"
|
||||
MODE: "survival"
|
||||
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"
|
||||
VIEW_DISTANCE: "10"
|
||||
SIMULATION_DISTANCE: "8"
|
||||
ENFORCE_SECURE_PROFILE: "FALSE" # required for authlib-injector on 1.21+
|
||||
ALLOW_FLIGHT: "TRUE" # many tech mods need this
|
||||
|
||||
# ── Mod source: packwiz pack served by caddy (alias pack.) ──
|
||||
PACKWIZ_URL: "http://pack.${BASE_DOMAIN}/pack.toml"
|
||||
# ── Mod source: filtered subset of the distribution modset ──
|
||||
# Jars are synced into ./server-mods by tooling/sync-server-mods.sh
|
||||
# (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 ───────────────────────────────────
|
||||
ENABLE_RCON: "TRUE"
|
||||
@@ -82,16 +98,26 @@ services:
|
||||
TZ: "Europe/Madrid"
|
||||
volumes:
|
||||
- mc_data:/data
|
||||
- ./runtime:/extras:ro # authlib-injector.jar lives here
|
||||
- ./runtime:/extras:ro # authlib-injector.jar lives here
|
||||
- ./server-mods:/mods:ro # itzg auto-syncs /mods → /data/mods
|
||||
# Distribution client-override files the server also needs, from the live
|
||||
# distribution (DISTRIBUTION_WEB_ROOT), same source as caddy. Only the kubejs
|
||||
# *source* subdirs are bind-mounted ro — kubejs itself writes config/, cache/,
|
||||
# generated/, logs/ at boot, so the kubejs ROOT must stay writable (lives in
|
||||
# mc_data). Mounting the whole kubejs dir ro makes BaseProperties.save() NPE
|
||||
# and kubejs never initialises. CustomSkinLoader is ro (read-only json).
|
||||
- ${DISTRIBUTION_WEB_ROOT:?DISTRIBUTION_WEB_ROOT unset in .env}/servers/ulicraft-1.21.1/files/kubejs/server_scripts:/data/kubejs/server_scripts:ro
|
||||
- ${DISTRIBUTION_WEB_ROOT}/servers/ulicraft-1.21.1/files/kubejs/startup_scripts:/data/kubejs/startup_scripts:ro
|
||||
- ${DISTRIBUTION_WEB_ROOT}/servers/ulicraft-1.21.1/files/kubejs/data:/data/kubejs/data:ro
|
||||
- ${DISTRIBUTION_WEB_ROOT}/servers/ulicraft-1.21.1/files/kubejs/assets:/data/kubejs/assets:ro
|
||||
- ${DISTRIBUTION_WEB_ROOT}/servers/ulicraft-1.21.1/files/CustomSkinLoader:/data/CustomSkinLoader:ro
|
||||
depends_on:
|
||||
- drasl
|
||||
- caddy # pack./auth. must resolve at boot
|
||||
- drasl # authlib (auth.) must resolve at boot
|
||||
# Containers don't inherit the host's /etc/hosts; the LAN resolver returns a
|
||||
# dead address for the public names. Pin auth./pack. to the host so HTTPS to
|
||||
# them lands on the host's nginx (valid LE cert) -> caddy -> service.
|
||||
# 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"
|
||||
- "pack.${BASE_DOMAIN}:host-gateway"
|
||||
networks:
|
||||
- mcnet
|
||||
|
||||
@@ -114,7 +140,7 @@ services:
|
||||
- mcnet
|
||||
|
||||
# Internal ingress. Routes every vhost by Host header (conf.d/*.caddy):
|
||||
# auth. -> drasl, pack. -> packwiz files, apex -> landing + /launcher,
|
||||
# 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).
|
||||
@@ -133,15 +159,9 @@ services:
|
||||
- ./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-files.caddy:/etc/caddy/conf.d/60-files.caddy:ro
|
||||
- ./www:/srv/www:ro
|
||||
- ./launcher:/srv/launcher: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/.
|
||||
# Nested under the :ro /srv/pack mount — the mountpoint pack/custom/ must
|
||||
# pre-exist on the host (tracked via pack/custom/.gitkeep) or Docker can't
|
||||
# mkdir it in the read-only parent.
|
||||
- ./custom:/srv/pack/custom:ro
|
||||
# Static site from ANOTHER repo (absolute host path in .env).
|
||||
- ${DISTRIBUTION_WEB_ROOT:?DISTRIBUTION_WEB_ROOT unset in .env}:/srv/distribution:ro
|
||||
networks:
|
||||
@@ -149,7 +169,6 @@ services:
|
||||
# Containers resolve the stack's own public names to caddy internally.
|
||||
aliases:
|
||||
- "auth.${BASE_DOMAIN}"
|
||||
- "pack.${BASE_DOMAIN}"
|
||||
|
||||
# Avatar/skin renderer — built from source (docker/nmsr/Dockerfile), pulls
|
||||
# player profiles from Drasl over mcnet. caddy proxies avatar. -> nmsr:8080.
|
||||
@@ -161,6 +180,69 @@ services:
|
||||
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 files.${BASE_DOMAIN}.
|
||||
# ONE shared login for everybody (FILES_AUTH=htpasswd), writable. See
|
||||
# plan/20-files.md — it documents every non-obvious bit of this service.
|
||||
#
|
||||
# Config is rendered read-only from filestash/config.json.tmpl: filestash has
|
||||
# no env-var config, and its admin console writes config.json back at runtime,
|
||||
# so pinning it :ro is what keeps git authoritative. The console still loads —
|
||||
# it just cannot save. That is intentional, not a bug.
|
||||
filestash:
|
||||
image: machines/filestash@sha256:8cb09d42470328a02aec6e3861f912addf8a9d54ad63d9c965bcb48df5ad36b1
|
||||
container_name: filestash
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# The `local` storage backend is admin-gated in filestash: it refuses to
|
||||
# init unless the connection params carry this secret (or the admin
|
||||
# password). config.json's attribute_mapping supplies it server-side, so
|
||||
# players never see it. Without this, every login dies on "Not Allowed".
|
||||
LOCAL_BACKEND_SECRET: ${FILES_LOCAL_SECRET:?FILES_LOCAL_SECRET unset in .env}
|
||||
# Do NOT set APPLICATION_URL / ADMIN_PASSWORD here: either one makes
|
||||
# filestash rewrite config.json at boot, which fails on the :ro mount.
|
||||
# Both live in the rendered config instead (general.host / auth.admin).
|
||||
volumes:
|
||||
# /app/data/state holds config + sqlite + search index + logs, so the
|
||||
# DIRECTORY must stay writable. Only config.json is pinned read-only.
|
||||
- filestash_state:/app/data/state
|
||||
- ./filestash/config.json:/app/data/state/config/config.json:ro
|
||||
# The share itself. FILES_MOUNT_MODE is the literal mount flag (ro|rw) —
|
||||
# `ro` enforces a read-only share at the KERNEL, whatever the UI thinks.
|
||||
- ${FILES_DATA_DIR:?FILES_DATA_DIR unset in .env}:/data/files:${FILES_MOUNT_MODE:-ro}
|
||||
networks:
|
||||
- mcnet
|
||||
|
||||
@@ -179,6 +261,7 @@ volumes:
|
||||
drasl_state:
|
||||
mc_data:
|
||||
kuma_data:
|
||||
filestash_state:
|
||||
|
||||
networks:
|
||||
mcnet:
|
||||
|
||||
15
docker/mc-status/Dockerfile
Normal file
15
docker/mc-status/Dockerfile
Normal file
@@ -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"]
|
||||
5
docker/mc-status/go.mod
Normal file
5
docker/mc-status/go.mod
Normal file
@@ -0,0 +1,5 @@
|
||||
module ulicraft/mc-status
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require github.com/mcstatus-io/mcutil/v4 v4.0.1
|
||||
2
docker/mc-status/go.sum
Normal file
2
docker/mc-status/go.sum
Normal file
@@ -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=
|
||||
397
docker/mc-status/main.go
Normal file
397
docker/mc-status/main.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
# Drasl config — password-login mode (NO Keycloak/OIDC for now).
|
||||
# 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.
|
||||
|
||||
# Public scheme is HTTPS — the host nginx terminates TLS in front of caddy.
|
||||
@@ -9,7 +10,7 @@
|
||||
BaseURL = "https://auth.${BASE_DOMAIN}"
|
||||
Domain = "auth.${BASE_DOMAIN}"
|
||||
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.
|
||||
AllowPasswordLogin = true
|
||||
@@ -21,5 +22,29 @@ SignPublicKeys = false
|
||||
# Free-text owner label shown in the web UI (a string, not a table).
|
||||
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).
|
||||
# [[RegistrationOIDC]] <- future task
|
||||
|
||||
31
filestash/config.json.tmpl
Normal file
31
filestash/config.json.tmpl
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"general": {
|
||||
"name": "Ulicraft Files",
|
||||
"host": "files.${BASE_DOMAIN}",
|
||||
"force_ssl": true,
|
||||
"secret_key": "${FILES_SECRET_KEY}",
|
||||
"editor": "base",
|
||||
"display_hidden": false,
|
||||
"upload_button": true,
|
||||
"fork_button": false
|
||||
},
|
||||
"auth": {
|
||||
"admin": "${FILES_ADMIN_PASSWORD_HASH}"
|
||||
},
|
||||
"middleware": {
|
||||
"identity_provider": {
|
||||
"type": "${FILES_IDP_TYPE}",
|
||||
"params": "${FILES_IDP_PARAMS}"
|
||||
},
|
||||
"attribute_mapping": {
|
||||
"related_backend": "files",
|
||||
"params": "{\"files\":{\"type\":\"local\",\"path\":\"/data/files/\",\"password\":\"${FILES_LOCAL_SECRET}\"}}"
|
||||
}
|
||||
},
|
||||
"connections": [
|
||||
{
|
||||
"label": "files",
|
||||
"type": "local"
|
||||
}
|
||||
]
|
||||
}
|
||||
5
landing/pnpm-workspace.yaml
Normal file
5
landing/pnpm-workspace.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
packages:
|
||||
- '.'
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
- sharp
|
||||
BIN
landing/public/Goat_JE1_BE1.webp
Normal file
BIN
landing/public/Goat_JE1_BE1.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
BIN
landing/public/favicon.png
Normal file
BIN
landing/public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.0 KiB |
BIN
landing/public/fonts/BlockBlueprint.ttf
Normal file
BIN
landing/public/fonts/BlockBlueprint.ttf
Normal file
Binary file not shown.
@@ -268,3 +268,13 @@
|
||||
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;
|
||||
}
|
||||
/* 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');
|
||||
}
|
||||
|
||||
52
landing/public/ulicraft-logo-mini.svg
Normal file
52
landing/public/ulicraft-logo-mini.svg
Normal file
@@ -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);
|
||||
// "url" = smaller, wraps (auth / packwiz URLs). Copy handled by the global
|
||||
// [data-copy] script in [...lang].astro; data-label / data-copied drive the
|
||||
// (localized) button text and transient feedback.
|
||||
// "url" = smaller, wraps (auth / authlib URLs). Copy handled by the global
|
||||
// [data-copy] script in [...lang].astro (and the equivalent on /fjord);
|
||||
// data-label / data-copied drive the (localized) button text + feedback.
|
||||
interface Props {
|
||||
value: string;
|
||||
variant?: "ip" | "url";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user