Phase 1 of plan/23-monitor.md: a small Go service that tails the Minecraft server's latest.log into an in-memory ring buffer and serves it to an embedded single-page UI (autoscroll, substring/regex filter, level chips). Read-only by construction — /data is mounted :ro, there is no mutating route, and the container holds no credentials. It publishes NO port: caddy's basic_auth vhost is the only way in, which is the point, since the service has no auth of its own and the log carries player IPs, chat and admin commands. Two non-obvious choices, both to avoid prod-only failures: - Polling, not SSE. The chain is host nginx -> caddy -> app, and nginx buffers proxied responses by default, so an EventSource would stall on cochi (whose vhost is an untracked per-host file) and not reproduce locally. Delay was accepted at design time, so polling is strictly better. - The cursor is a sequence number, not a byte offset, and the tailer reopens on inode change or size shrink. itzg rotates latest.log on every server restart, i.e. every deploy — a naive tailer goes silent afterwards while still answering HTTP 200. Verified against rotate, truncate, delete/recreate, partial-line and ring-overflow cases. MONITOR_ENABLED (on|off) gates both layers per host, mirroring FILES_BROWSE. MONITOR_PASSWORD_HASH must be bcrypt AND single-quoted in .env: Compose interpolates $-sequences in .env values, so a bare hash is silently mangled ($2a$14$abc... -> $$2a$$14...) and every login fails with no hint why. Confirmed empirically, and it is the opposite of filestash, which needs $6$. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
376 lines
19 KiB
YAML
376 lines
19 KiB
YAML
# ──────────────────────────────────────────────────────────────────
|
|
# Ulicraft LAN-party stack — Minecraft 1.21.1 NeoForge + Drasl auth
|
|
# ──────────────────────────────────────────────────────────────────
|
|
# Single, unified compose. One file holds the whole stack:
|
|
# drasl auth + skins (Yggdrasil), internal only
|
|
# minecraft the server (itzg/NEOFORGE), mods from ./server-mods volume
|
|
# mc-backup world backups every 6h via RCON
|
|
# 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}
|
|
#
|
|
# 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:
|
|
drasl:
|
|
image: unmojang/drasl:latest
|
|
container_name: drasl
|
|
restart: unless-stopped
|
|
# Internal-only: no published host port. Reached via caddy at
|
|
# auth.${BASE_DOMAIN} (caddy holds the network alias on mcnet).
|
|
volumes:
|
|
- ./drasl/config:/etc/drasl:ro
|
|
- drasl_state:/var/lib/drasl
|
|
networks:
|
|
- mcnet
|
|
# No env vars — config is the rendered ./drasl/config/config.toml
|
|
# (from config.toml.tmpl via tooling/render-config.sh).
|
|
|
|
minecraft:
|
|
image: itzg/minecraft-server:java21
|
|
container_name: minecraft
|
|
restart: unless-stopped
|
|
stdin_open: true
|
|
tty: true
|
|
ports:
|
|
- "25565:25565"
|
|
- "24454:24454/udp" # Simple Voice Chat, if used
|
|
environment:
|
|
EULA: "TRUE"
|
|
TYPE: "NEOFORGE"
|
|
VERSION: "1.21.1"
|
|
# Verify NEOFORGE_VERSION against projects.neoforged.net before deploy.
|
|
# 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"
|
|
MAX_MEMORY: "10G" # 8 players + 100 mods comfortably
|
|
USE_AIKAR_FLAGS: "TRUE"
|
|
JVM_XX_OPTS: "-XX:+UseG1GC"
|
|
|
|
# ── 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=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
|
|
|
|
ENABLE_COMMAND_BLOCK: "TRUE"
|
|
# ── 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"
|
|
RCON_PASSWORD: ${RCON_PASSWORD}
|
|
RCON_PORT: 25575
|
|
|
|
TZ: "Europe/Madrid"
|
|
volumes:
|
|
- mc_data:/data
|
|
- ./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:
|
|
- 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:
|
|
- mcnet
|
|
|
|
mc-backup:
|
|
image: itzg/mc-backup
|
|
container_name: mc-backup
|
|
restart: unless-stopped
|
|
environment:
|
|
BACKUP_INTERVAL: "6h"
|
|
RCON_HOST: "minecraft"
|
|
RCON_PASSWORD: ${RCON_PASSWORD}
|
|
PRUNE_BACKUPS_DAYS: "14"
|
|
TZ: "Europe/Madrid"
|
|
volumes:
|
|
- mc_data:/data:ro
|
|
- ./backups:/backups
|
|
depends_on:
|
|
- minecraft
|
|
networks:
|
|
- 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"
|
|
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.
|
|
- mc_data:/data: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:
|
|
drasl_state:
|
|
mc_data:
|
|
kuma_data:
|
|
filestash_state:
|
|
|
|
networks:
|
|
mcnet:
|
|
driver: bridge
|