online-mode=false made the server skip the online-auth handshake entirely, so
authlib-injector never validated sessions against Drasl: players got offline-hash
UUIDs and no skin textures (everyone Steve). authlib-injector's whole purpose is
to redirect the *online* handshake from Mojang to Drasl, so online-mode must be
true; only the 1.21+ secure profile stays off (ENFORCE_SECURE_PROFILE=false +
Drasl SignPublicKeys=false). Correct the conflation in CLAUDE.md and
plan/05-minecraft.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mounting the whole kubejs dir read-only made KubeJS's BaseProperties.save()
throw NPE on boot (it writes config/cache/generated/logs under kubejs/),
so KubeJS never initialised. Bind only the source subdirs (server_scripts,
startup_scripts, data, assets) read-only and let the kubejs root live
writable in mc_data.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The minecraft container only received forgemods jars via ./server-mods.
The distribution's files/kubejs (server_scripts, data, startup_scripts)
and CustomSkinLoader never reached the server, so server-side kubejs
recipes/scripts were silently absent.
Bind-mount both from DISTRIBUTION_WEB_ROOT (read-only, to avoid the
server polluting the client distribution with kubejs-generated files).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- landing header + footer brand: swap the Creeper pixel-art for the
ulicraft-logo-mini.svg goat mark.
- ServerCard fallback icon: same logo (real SLP favicon still overrides on
a successful ping).
- minecraft: set ICON=/extras/server-icon.png + OVERRIDE_ICON so the
in-game server-list entry and the SLP favicon use the goat logo. PNG
ships in ./runtime (mounted /extras).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every avatar rendered the default skin. NMSR fetched the player profile from
Drasl fine, but the profile embeds an absolute HTTPS skin URL (Drasl BaseURL is
https), and NMSR could not connect to auth.${BASE_DOMAIN}:443: the mcnet alias
resolves auth. -> caddy, which only listens on :80 internally. Logs showed
`fetch_texture_from_mojang … client error (Connect)` for the skin URL, so NMSR
fell back to the default skin.
Mirror what the minecraft service already does: pin auth.${BASE_DOMAIN} to the
host via extra_hosts so NMSR reaches the host nginx on :443 (real LE cert,
publicly trusted — no private CA needed), and switch the nmsr mojank endpoints
from http:// to https:// so the profile/texture scheme matches the embedded
skin URLs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Execute plan/18 (WS1-6) + plan/17.
- WS1: homepage join = 3 steps off packwiz (register -> UlicraftLauncher
-> join); per-OS downloads from a synced launcher-manifest.json (schema +
example committed); Fjord moved to its own /fjord page. Drop packwizUrl.
- WS2/3: mc-status gains an isolated /players endpoint (admin login ->
Drasl GET /players, own client+TTL+token cache, never blocks the status
path); online + registered rosters render shared name+avatar tiles;
AVATAR_MODE env (default headiso).
- WS4: /account skin CRUD via browser-direct Drasl (login -> upload ->
reset), in-memory token, players[0]; register relinks "Manage it here".
- WS5: footer link to status.${BASE_DOMAIN} in every footer.
- WS6: tooling/build-modlist.py generates mods.json + extracted logos from
the distribution forgemods; ModList.astro renders a flat list; stat tile
fed from the count.
Manifest/mods loaders read at build with a process.cwd() fallback (the
import.meta.url-only path does not resolve in Astro's bundled build).
New env: AVATAR_MODE, DRASL_ADMIN_USERNAME, DRASL_ADMIN_PASSWORD (mc-status
only). Generated mods.json / launcher-manifest.json / logos are gitignored;
.example.json files document the shapes. Build: 12 pages; mc-status builds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The distribution-fed modset is built against NeoForge 21.1.233
(distribution.json); several mods hard-require it (ferritecore ≥21.1.218,
farmersdelight ≥21.1.219, configured ≥21.1.211). The old 21.1.209 pin made
the server crash in pre-load with "Missing or unsupported mandatory
dependencies: neoforge".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop packwiz entirely. The server now loads a filtered mod subset from a
local ./server-mods volume instead of fetching a packwiz pack at boot.
New flow:
- tooling/classify-mods.py (tomllib only, no network) reads each jar's
META-INF/neoforge.mods.toml and seeds mods-sides.json with a side
(client|server|both; default both). Existing entries are preserved.
- mods-sides.json: committed, keyed by jar filename, hand-editable override
surface. Initial seed: 65 both, 11 client, 0 server (76 jars).
- tooling/sync-server-mods.sh mirrors the both/server jars from
$DISTRIBUTION_WEB_ROOT into ./server-mods/ (authoritative; prunes strays;
halts on a missing jar). Replaces render-pack.sh.
- compose: minecraft mounts ./server-mods:/mods:ro with REMOVE_OLD_MODS=TRUE
(itzg auto-syncs /mods -> /data/mods). Removed PACKWIZ_URL, the pack.
extra_host (auth. kept for authlib), and depends_on caddy (drasl kept).
Both classify-mods.py and sync-server-mods.sh resolve their source dir as:
CLI arg / MODS_DIST_DIR env / $DISTRIBUTION_WEB_ROOT (in that order).
Removed: tooling/render-pack.sh + add-custom-mod.sh (packwiz tooling),
pack/ (packwiz source), custom/ (76 jars now sourced from the distribution),
the pack. caddy vhost + its mounts/alias, and the packwiz .gitignore block.
Client distribution is UNCHANGED: HeliosLauncher still installs the full
modset from distribution.json. Docs (CLAUDE.md, plan/04/05/14, runtime)
updated; plan/04-packwiz.md stubbed as superseded by plan/04-mods.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a featured live ServerCard to the landing (replaces the static
ServerListPanel in hero + status sections): server favicon, color MOTD,
online/offline pill, players online/max with fill bar, and a player-head
row rendered by our own NMSR from Drasl skins. Progressive enhancement —
SSG skeleton degrades gracefully when JS or the API is unavailable.
Back it with a self-hosted pinger instead of the public api.mcstatus.io:
mcstatus.io's API service is closed-source (only the mcutil library is
open), so docker/mc-status wraps mcutil and re-emits its v2 JSON shape,
keeping the frontend unchanged. The service ignores the path address and
only pings MC_STATUS_TARGET (no SSRF relay), with a 30s TTL cache.
Exposed same-origin via caddy at the apex /api/mcstatus/* path (no new DNS
subdomain or LE cert change, no CORS). Uptime Kuma stays the uptime
history + alerting backend; see plan/15-mc-status.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Containers don't inherit the host's /etc/hosts; the LAN resolver returns a
dead address (192.168.0.3:443) for the public names, crash-looping the
packwiz install on boot. extra_hosts host-gateway routes HTTPS through the
host's nginx (valid LE cert) -> caddy -> service.
Also add plan/10-uptime-kuma.md (Minecraft monitor: internal + public probes)
and document the HTTPS resolution variant in plan/00-overview.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The ./custom:/srv/pack/custom mount nests inside the read-only ./pack:/srv/pack
mount. Docker can't mkdir the child mountpoint under a :ro parent, so on a fresh
checkout (no pack/custom/ dir) container init fails with "read-only file system".
Track an empty pack/custom/ so the mountpoint always exists.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Collapse the override-file matrix into a single docker-compose.yml holding the
whole stack: drasl, minecraft, mc-backup, caddy, nmsr, uptime-kuma. Bring-up is
now just `docker compose up -d --build`.
Removed features (dead-ends for this deployment):
- airgap / full asset mirror (mirror-airgap.sh, mirror-mods.sh, 20-mirror.caddy,
caddy local-CA export, /ca.crt)
- dnsmasq + avahi/mDNS + dns-records.sh + mdns-host-setup.sh + ENABLE_MDNS;
DNS is now handled outside this repo (HOST_LAN_IP dropped)
- Blessing Skin auth variant (compose + 00-core-blessingskin.caddy + BS_* env)
- host-nginx-static variant (docker-compose.nginx.yml, nginx/ulicraft.conf.tmpl)
- build-stack.sh and its run modes (online/airgap/core)
Production ingress is the only path now: host nginx terminates TLS (LE certs)
in front of caddy on a localhost-only port; caddy routes every vhost by Host
header. Launcher downloads move mirror/launcher -> launcher/.
Docs: README, deploy skill, and plan/ rewritten to match; obsolete plan docs
(dnsmasq, blessing-skin, assets-mirror, full-airgap-mirror, dns-and-run-modes)
deleted.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Caddy is no longer in the base stack. Two ingress paths now:
- docker-compose.caddy.yml — LAN/air-gap; build-stack.sh adds it for all
up modes + the prep pre-bake. Holds the mcnet auth./pack. aliases.
- docker-compose.nginx.yml — production behind the host's nginx: publishes
drasl on 127.0.0.1:25585, points minecraft at the host (extra_hosts) over
HTTPS for pack + authlib.
Base loses the caddy service and minecraft's caddy depends_on (re-added by
caddy.yml). Blessing Skin override now requires caddy.yml (it only swaps
caddy's core conf).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move pack/custom/ -> ./custom/ and bind-mount it into caddy at
/srv/pack/custom (served URL pack.${BASE_DOMAIN}/custom/ unchanged).
Keeping jars outside the packwiz pack root stops `packwiz refresh`
from indexing them as direct files on top of the mods/*.pw.toml
external refs (drops 9 bogus custom/*.jar entries from index.toml).
add-custom-mod.sh now writes jars to ./custom/. Trim the blessingskin
caddy override to just the conf swap (volumes merge by target).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rename the pack-serving vhost/alias from packwiz.${BASE_DOMAIN} to
pack.${BASE_DOMAIN} (caddy conf, caddy network alias, PACKWIZ_URL).
Inline the static-site caddy mounts (10-static.caddy, www, launcher,
CA cert) into the base compose; set MOTD to "Uli LAN party".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
DNS is now your own party server. tooling/dns-records.sh prints the records
(online = service names; airgap = + Mojang/launcher/NeoForge spoofs) in several
formats. build-stack --up online|airgap selects the mirror layer and prints the
matching records. dnsmasq moved to an optional override (docker-compose.dnsmasq.yml).
Default domain switched to .lan (.local is hijacked by mDNS on unicast DNS).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The spoof aliases (maven.neoforged.net, libraries.minecraft.net, etc.) hijacked
those hostnames for every mcnet container, so the ONLINE NeoForge install during
pre-bake hit the empty mirror (cert path failure). Moved them to the mirror
override; core/static modes resolve the real upstreams.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Base compose is core only (dnsmasq, drasl, minecraft, mc-backup, caddy
auth+packwiz). docker-compose.static.yml and docker-compose.mirror.yml layer
on the landing site and the air-gap mirror independently. build-stack.sh --up
[core|static|mirror|full] selects the override files.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mounting ./mirror/launcher at /srv/www/launcher failed: /srv/www is a
read-only mount, so the nested mountpoint can't be created. Mount it at
/srv/launcher and route /launcher/* via handle_path instead.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>