Compare commits
19 Commits
landing
...
d21a7f0e92
| Author | SHA1 | Date | |
|---|---|---|---|
| d21a7f0e92 | |||
| df8ffca53e | |||
| 067e990306 | |||
| 673202e1e4 | |||
| 1ec3a9c1ee | |||
| 663b87a4e4 | |||
| a2821f2bbc | |||
| cfd60131fb | |||
| 95c63e1ee0 | |||
| 46103495fb | |||
| 1709bcd340 | |||
| 67d1f82e6a | |||
| ff645de2c8 | |||
| 6df885eb13 | |||
| 6fac1647aa | |||
| a6155819b6 | |||
| 3185fea4c1 | |||
| f3c38da56c | |||
| 21e6080e78 |
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: deploy
|
||||
description: Redeploy the Ulicraft Minecraft stack to the production host `cochi` — git pull on main then a hard restart (compose down + build-stack.sh --up online). Use when the user says "deploy", "redeploy", "deploy to cochi", "push to prod", "ship it", or "restart the server" for this project. Causes brief Minecraft downtime.
|
||||
description: Redeploy the Ulicraft Minecraft stack to the production host `cochi` — git pull on main then a hard restart (compose down + up). Use when the user says "deploy", "redeploy", "deploy to cochi", "push to prod", "ship it", or "restart the server" for this project. Causes brief Minecraft downtime.
|
||||
---
|
||||
|
||||
# Deploy Ulicraft to `cochi`
|
||||
@@ -13,8 +13,9 @@ Redeploy the running stack on the production host. Full reference:
|
||||
- Host: `cochi` (SSH alias)
|
||||
- Path: `/home/ubuntu/mc/ulicraft-server-v1`
|
||||
- Branch: `main`
|
||||
- Run mode: `online` → compose files `docker-compose.yml` + `docker-compose.static.yml`
|
||||
- Auth: Drasl (base stack)
|
||||
- Stack: single unified `docker-compose.yml` (drasl, minecraft, mc-backup,
|
||||
caddy, nmsr, uptime-kuma)
|
||||
- Auth: Drasl
|
||||
- Restart: **hard** (down → up) — players are disconnected for a few minutes
|
||||
|
||||
## Procedure
|
||||
@@ -35,32 +36,50 @@ invoking this skill is the authorization to proceed, but:
|
||||
ssh cochi 'set -e
|
||||
cd /home/ubuntu/mc/ulicraft-server-v1
|
||||
git pull --ff-only
|
||||
docker compose -f docker-compose.yml -f docker-compose.static.yml down --remove-orphans
|
||||
tooling/build-stack.sh --up online'
|
||||
tooling/render-config.sh
|
||||
tooling/fetch-authlib.sh
|
||||
docker compose down --remove-orphans
|
||||
docker compose up -d --build'
|
||||
```
|
||||
- `--ff-only` refuses to merge — if the pull is not a fast-forward, STOP and
|
||||
report (local changes on the host need manual resolution).
|
||||
- `build-stack.sh --up online` renders configs (needs `.env` complete:
|
||||
`BASE_DOMAIN`, `HOST_LAN_IP`, `RCON_PASSWORD`) and runs `compose up -d`.
|
||||
- `render-config.sh` re-renders drasl/nmsr/packwiz configs from `.env`
|
||||
(needs `.env` complete: `BASE_DOMAIN`, `RCON_PASSWORD`,
|
||||
`DISTRIBUTION_WEB_ROOT`, `CADDY_HTTP_*`, `REGISTRATION_MODE`). It halts on
|
||||
an invalid `REGISTRATION_MODE` (must be `invite` or `open`).
|
||||
- `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".
|
||||
|
||||
2b. **Landing rebuild (only if needed).** The static site `www/` is gitignored —
|
||||
`git pull` does NOT update it. If the pulled commits (from the step-1
|
||||
pre-check) touch `landing/`, or `REGISTRATION_MODE` changed, rebuild it;
|
||||
otherwise skip:
|
||||
```bash
|
||||
ssh cochi 'set -e
|
||||
cd /home/ubuntu/mc/ulicraft-server-v1
|
||||
cd landing && set -a && . ../.env && set +a && pnpm run build'
|
||||
```
|
||||
Sourcing `.env` bakes `BASE_DOMAIN` + `REGISTRATION_MODE` (invite-field on/off)
|
||||
into the output. A flag-only change just needs this rebuild + a `drasl`
|
||||
restart (already covered by step 2's `up`), not the world downtime.
|
||||
|
||||
3. **Verify.**
|
||||
```bash
|
||||
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && docker compose -f docker-compose.yml -f docker-compose.static.yml ps'
|
||||
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && docker compose ps'
|
||||
```
|
||||
Confirm `caddy`, `drasl`, `minecraft`, `mc-backup` are Up. Tail the minecraft
|
||||
log briefly and confirm it reaches `Done` (server ready):
|
||||
Confirm `caddy`, `drasl`, `minecraft`, `mc-backup`, `nmsr`, `uptime-kuma` are
|
||||
Up. Tail the minecraft log briefly and confirm it reaches `Done` (server
|
||||
ready):
|
||||
```bash
|
||||
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && timeout 120 docker compose -f docker-compose.yml -f docker-compose.static.yml logs -f minecraft' 2>/dev/null | grep -m1 "Done"
|
||||
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && timeout 120 docker compose logs -f minecraft' 2>/dev/null | grep -m1 "Done"
|
||||
```
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Never `git reset --hard` or discard changes on `cochi` without telling the user
|
||||
first — there may be host-local edits.
|
||||
- Volumes (`mc_data`, `drasl_state`) persist across `down`; the world is safe. Do
|
||||
NOT pass `-v`/`--volumes` to `down`.
|
||||
- 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
|
||||
improvise recovery on production.
|
||||
- Blessing Skin variant or `airgap` mode changes the compose file set — if the
|
||||
user asks for those, follow `plan/14-deploy.md` instead of the fixed commands
|
||||
above.
|
||||
|
||||
55
.env.example
55
.env.example
@@ -1,35 +1,30 @@
|
||||
# Copy to .env and fill in real values
|
||||
|
||||
# Single base domain for the whole stack; every service is a subdomain of this.
|
||||
# Use .lan (NOT .local) — .local is intercepted by mDNS on macOS/Linux and
|
||||
# won't resolve through a normal unicast DNS server.
|
||||
BASE_DOMAIN=ulicraft.lan
|
||||
# The Docker host's LAN IP — every service name resolves here.
|
||||
HOST_LAN_IP=192.168.1.10
|
||||
|
||||
# mDNS toggle. true + BASE_DOMAIN=*.local → an Avahi container publishes the
|
||||
# service names over mDNS (zero-config for macOS/Linux guests; Windows needs
|
||||
# Bonjour). false → use your own party DNS (records: tooling/dns-records.sh).
|
||||
# NOTE: mDNS resolves ONLY *.local; it cannot serve the air-gap upstream-host
|
||||
# spoofs — those always need a real DNS server.
|
||||
ENABLE_MDNS=false
|
||||
# DNS is handled OUTSIDE this repo — point ${BASE_DOMAIN} and every subdomain
|
||||
# (auth. pack. avatar. status. distribution. www.) at the host running nginx.
|
||||
BASE_DOMAIN=ulicraft.net
|
||||
|
||||
RCON_PASSWORD=change-me-to-something-random
|
||||
|
||||
# Caddy host-published port (docker-compose.caddy.yml). Default 80 for the
|
||||
# standalone LAN/air-gap path. When the machine's own nginx fronts caddy
|
||||
# (nginx terminates TLS → reverse-proxies to caddy), set a high port and bind
|
||||
# it to localhost so only nginx reaches it:
|
||||
# CADDY_HTTP_PORT=8880
|
||||
# CADDY_HTTP_BIND=127.0.0.1
|
||||
# Then render nginx/ulicraft-caddy.conf.tmpl with CADDY_HTTP_PORT.
|
||||
CADDY_HTTP_PORT=80
|
||||
CADDY_HTTP_BIND=0.0.0.0
|
||||
# Registration mode for self-hosted accounts (Drasl) + landing register form.
|
||||
# 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 BOTH render-config.sh (-> Drasl RegistrationNewPlayer.RequireInvite)
|
||||
# and the landing build (-> show/hide the invite-code field). Default: invite.
|
||||
REGISTRATION_MODE=invite
|
||||
|
||||
# distribution.${BASE_DOMAIN} static vhost (docker-compose.distribution.yml).
|
||||
# Absolute host path to the built static site — it lives in ANOTHER repo.
|
||||
# Bind-mounted read-only into caddy and served at distribution.${BASE_DOMAIN}.
|
||||
# caddy host-published port. The host's own nginx terminates TLS and
|
||||
# reverse-proxies to caddy by Host header (nginx/ulicraft-caddy.conf.tmpl), so
|
||||
# bind caddy to a high localhost-only port — only nginx should reach it.
|
||||
CADDY_HTTP_PORT=8880
|
||||
CADDY_HTTP_BIND=127.0.0.1
|
||||
|
||||
# distribution.${BASE_DOMAIN} static vhost. Absolute host path to the built
|
||||
# static site — it lives in ANOTHER repo. Bind-mounted read-only into caddy.
|
||||
DISTRIBUTION_WEB_ROOT=/home/oier/projects/mc-mods/ulicraft-group/ulicraft-distribution/dist
|
||||
|
||||
# Only needed if using CurseForge-exclusive mods:
|
||||
# CF_API_KEY=your-curseforge-api-key
|
||||
|
||||
@@ -37,7 +32,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
|
||||
LE_SUBDOMAINS=auth pack distribution www avatar status
|
||||
# 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
|
||||
@@ -45,13 +40,3 @@ OVH_AK=
|
||||
OVH_AS=
|
||||
OVH_CK=
|
||||
# LE_STAGING=1 # use the untrusted staging CA for dry runs
|
||||
|
||||
# ── Blessing Skin auth variant (docker-compose.blessingskin.yml) ──────
|
||||
# Only needed when running the Blessing Skin override instead of Drasl.
|
||||
BS_DB_NAME=blessingskin
|
||||
BS_DB_USER=blessingskin
|
||||
BS_DB_PASSWORD=change-me-bs-db
|
||||
BS_DB_ROOT_PASSWORD=change-me-bs-root
|
||||
# Generate once and paste here so it survives container recreates:
|
||||
# docker run --rm azusamikan/blessing-skin-server-docker:latest php artisan key:generate --show
|
||||
BS_APP_KEY=base64:replace-with-generated-key
|
||||
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -5,15 +5,15 @@ runtime/authlib-injector.jar
|
||||
|
||||
# rendered configs (from tooling/render-config.sh)
|
||||
drasl/config/config.toml
|
||||
dnsmasq/dnsmasq.conf
|
||||
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
|
||||
|
||||
# vendored / mirrored binaries
|
||||
mirror/
|
||||
# vendored binaries
|
||||
launcher/
|
||||
pack/mods-files/
|
||||
|
||||
# landing page: generated build output + deps
|
||||
@@ -22,8 +22,5 @@ landing/node_modules/
|
||||
landing/.astro/
|
||||
landing/dist/
|
||||
|
||||
# exported Caddy CA (per-deploy artifact)
|
||||
caddy/caddy-root-ca.crt
|
||||
|
||||
# Let's Encrypt certs + private keys (issued by tooling/issue-letsencrypt.sh)
|
||||
certs/
|
||||
|
||||
343
CLAUDE.md
343
CLAUDE.md
@@ -1,280 +1,143 @@
|
||||
# Minecraft LAN Party Server — Project Context
|
||||
# Ulicraft Server — Project Context
|
||||
|
||||
> Self-hosted modded Minecraft server for a LAN party (~8 players), with
|
||||
> persistent auth/skin infrastructure designed to outlive the LAN weekend
|
||||
> and integrate with the existing homelab.
|
||||
> 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. Public, internet-present deployment behind the host's nginx + TLS.
|
||||
|
||||
**`plan/` is the source of truth for the design.** Start at `plan/00-overview.md`.
|
||||
This file is a fast orientation + the durable decisions/gotchas; when it disagrees
|
||||
with `plan/` or the code, those win.
|
||||
|
||||
## Goals & Constraints
|
||||
|
||||
- **Players**: 4–10 friends, LAN-based
|
||||
- **Players**: 4–10 friends
|
||||
- **Modpack vibe**: Kitchen-sink (tech + magic + exploration + QoL)
|
||||
- **Pack approach**: Manually curated (~50–100 mods), NOT a forked existing pack
|
||||
- **Minecraft version**: 1.21.1
|
||||
- **Mod loader**: NeoForge (NOT classic Forge — see Decisions below)
|
||||
- **Network**: LAN party context, but stack is persistent (lives past the weekend)
|
||||
- **Auth**: Self-hosted Yggdrasil-compatible (Drasl) + Keycloak OIDC
|
||||
- **Skins**: Handled by Drasl
|
||||
- **Persistence horizon**: Long-term homelab service, not disposable
|
||||
|
||||
## Existing Homelab Context (relevant to this project)
|
||||
|
||||
- **Keycloak 26.0.7** running in Docker Compose (PostgreSQL 17, production mode,
|
||||
bind-mounted secrets, xforwarded proxy headers). This is the IdP for everything.
|
||||
- **NetBird** as the proxy/networking layer for remote access (NOT used for the
|
||||
LAN party itself — see Decisions).
|
||||
- **AdGuard Home** as recursive DNS resolver — used here for `*.home.local` LAN records.
|
||||
- **Multi-VPS topology**: this Minecraft project likely runs on a local box or
|
||||
beefier homelab node, not a VPS (RAM/CPU requirements).
|
||||
- Operator timezone: Europe/Madrid.
|
||||
|
||||
## Key Decisions (with reasoning)
|
||||
|
||||
### NeoForge over classic Forge
|
||||
Despite the user originally saying "Forge", the 1.21.x kitchen-sink ecosystem
|
||||
(ATM10, Leaking Kitchen Sink, etc.) is overwhelmingly NeoForge in 2025/2026.
|
||||
The original Forge team essentially migrated to NeoForge. `itzg/minecraft-server`
|
||||
supports both via `TYPE=NEOFORGE`. Going with NeoForge 1.21.1.
|
||||
|
||||
### Drasl over Ely.by or vanilla offline mode
|
||||
- **Ely.by**: not self-hosted, Russian-hosted, no control over identity layer.
|
||||
- **Vanilla offline + no auth server**: works for one weekend but skins break,
|
||||
no persistent identity, doesn't match homelab philosophy.
|
||||
- **Drasl**: self-hosted Go service, Yggdrasil-compatible, drop-in Mojang
|
||||
replacement, supports skins + capes, **has first-class OIDC support with
|
||||
PKCE** (perfect for Keycloak), actively maintained (current version 3.4.2+).
|
||||
GPLv3 licensed.
|
||||
|
||||
### LAN (not NetBird) for the party itself
|
||||
NetBird is the homelab's remote access layer, but adding a mesh VPN on top
|
||||
of a LAN party introduces complexity for guests with no upside. Drasl and
|
||||
Minecraft live on the LAN; clients reach them via `drasl.home.local` resolved
|
||||
through AdGuard.
|
||||
|
||||
### Manual curation (user choice — flagged as expensive)
|
||||
User insisted on manual curation despite my pushback that forking ATM10 or
|
||||
Leaking Kitchen Sink would save weeks of crash-log triage. **Note for future
|
||||
sessions**: if curation gets painful, the fork-and-trim option is still on
|
||||
the table — Leaking Kitchen Sink is the closest match to the requested vibe.
|
||||
|
||||
### itzg/minecraft-server as base image
|
||||
The de facto standard. Handles NeoForge installation automatically via
|
||||
`TYPE=NEOFORGE` + `VERSION` + `NEOFORGE_VERSION`. Supports Modrinth and
|
||||
CurseForge mod auto-download via `MODRINTH_PROJECTS` and `CURSEFORGE_FILES`.
|
||||
- **Minecraft version**: 1.21.1, **NeoForge** (not classic Forge)
|
||||
- **Auth/skins**: Drasl (Yggdrasil-compatible), **password login** — no OIDC
|
||||
- **DNS**: handled OUTSIDE this repo; point `${BASE_DOMAIN}` + subdomains at the host
|
||||
- **Persistence horizon**: long-term service, not disposable
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
LAN segment
|
||||
│
|
||||
├── AdGuard Home → resolves drasl.home.local, keycloak.home.local
|
||||
│ to the Docker host's LAN IP
|
||||
│
|
||||
├── Keycloak (existing, separate compose)
|
||||
│ realm: homelab
|
||||
│ client: drasl (confidential, PKCE S256)
|
||||
│
|
||||
└── Minecraft host (this project's compose)
|
||||
│
|
||||
├── drasl :25585 (Yggdrasil API + web UI)
|
||||
│ └── OIDC → Keycloak
|
||||
│
|
||||
├── minecraft :25565 (server)
|
||||
│ :24454/udp (Simple Voice Chat, optional)
|
||||
│ └── -javaagent:authlib-injector.jar=http://drasl.home.local:25585/authlib-injector
|
||||
│ └── ONLINE_MODE=false (required for authlib-injector)
|
||||
│
|
||||
└── mc-backup (itzg/mc-backup, 6h interval)
|
||||
└── RCON → minecraft
|
||||
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.`).
|
||||
|
||||
Clients (LAN party guests):
|
||||
Prism Launcher → authlib-injector account pointing at Drasl
|
||||
→ joins minecraft:25565
|
||||
```
|
||||
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)
|
||||
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
|
||||
mc-backup ── RCON → minecraft (6h interval, prune 14d, world-only)
|
||||
```
|
||||
|
||||
## Auth Flow (end-to-end)
|
||||
Bring-up: render configs → build landing → fetch launcher → `docker compose up -d --build`.
|
||||
See `plan/12-build-order.md` and `plan/14-deploy.md`. Deploy to prod host `cochi`
|
||||
via the `deploy` skill.
|
||||
|
||||
1. Guest opens `http://drasl.home.local:25585` in browser.
|
||||
2. Clicks "Register with Keycloak" → redirected to Keycloak login.
|
||||
3. Logs in with homelab Keycloak credentials.
|
||||
4. Returned to Drasl, picks a player name, uploads a skin.
|
||||
5. Drasl shows them a "Minecraft Token" on their profile page.
|
||||
6. In Prism Launcher: Settings → Accounts → Add authlib-injector account
|
||||
with URL `http://drasl.home.local:25585/authlib-injector` and the
|
||||
Minecraft Token as password.
|
||||
7. Joins Minecraft server. Server validates session against Drasl (via
|
||||
authlib-injector JVM agent), Drasl confirms, player enters with their skin.
|
||||
## Key Decisions
|
||||
|
||||
- **NeoForge over Forge** — the 1.21.x kitchen-sink ecosystem is overwhelmingly
|
||||
NeoForge; the Forge team migrated. `itzg/minecraft-server` via `TYPE=NEOFORGE`.
|
||||
- **Drasl over Ely.by / vanilla offline** — self-hosted Go service, Yggdrasil
|
||||
drop-in, skins + capes, actively maintained. Run with **password login**
|
||||
(no OIDC/Keycloak in this stack).
|
||||
- **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`.
|
||||
|
||||
## Critical Gotchas
|
||||
|
||||
### Minecraft 1.21+ secure profile incompatibility
|
||||
- Server: must set `enforce-secure-profile=false` (compose: `ENFORCE_SECURE_PROFILE=FALSE`)
|
||||
- Drasl: must set `SignPublicKeys = false`
|
||||
- These are linked. The Drasl docs explicitly warn: *"Mixed authentication does
|
||||
not work with `SignPublicKeys = true` on Minecraft 1.21+."*
|
||||
### Minecraft 1.21+ secure profile
|
||||
- Server: `ENFORCE_SECURE_PROFILE=FALSE` (`enforce-secure-profile=false`)
|
||||
- Drasl: `SignPublicKeys = false`
|
||||
- Linked. Drasl docs: *"Mixed authentication does not work with
|
||||
`SignPublicKeys = true` on Minecraft 1.21+."*
|
||||
|
||||
### authlib-injector JVM agent
|
||||
- Syntax: `-javaagent:/path/to/authlib-injector.jar=<yggdrasil-api-url>`
|
||||
- The URL after `=` is the **API root**, which for Drasl is
|
||||
`<BaseURL>/authlib-injector`.
|
||||
- **Must match between server and client.** If server uses
|
||||
`http://drasl.home.local:25585/authlib-injector` and client uses anything
|
||||
else (e.g. an IP), session validation fails silently with "Invalid session".
|
||||
- Download from: https://github.com/yushijinhun/authlib-injector/releases
|
||||
- Mount into the itzg container at `/extras/authlib-injector.jar`.
|
||||
|
||||
### Keycloak reachability on LAN day
|
||||
If Keycloak is only reachable via NetBird or only from the public internet,
|
||||
**Drasl OIDC registration breaks on LAN-only day**. Options:
|
||||
1. Expose Keycloak on the LAN (simplest)
|
||||
2. Pre-register all guests before the party
|
||||
3. Temporarily disable OIDC and allow password registration during the LAN
|
||||
(`AllowPasswordLogin = true`, comment out the OIDC block)
|
||||
|
||||
### HTTP vs HTTPS for OIDC
|
||||
Modern browsers warn on plain HTTP for OIDC flows but they work. For a
|
||||
production-grade setup, front Drasl with Caddy or Traefik using a local CA
|
||||
cert (smallstep/step-ca pairs well with the existing homelab). For LAN-only
|
||||
HTTP is fine.
|
||||
- Syntax: `-javaagent:/extras/authlib-injector.jar=<yggdrasil-api-url>`
|
||||
- URL after `=` is the **API root** = `<BaseURL>/authlib-injector`. For this
|
||||
stack: `http://auth.${BASE_DOMAIN}/authlib-injector` (internal, over mcnet).
|
||||
- **Must match between server and client** (clients use the public
|
||||
`https://auth.${BASE_DOMAIN}/authlib-injector`), else session validation fails
|
||||
silently with "Invalid session".
|
||||
- Releases: https://github.com/yushijinhun/authlib-injector — jar lives in
|
||||
`runtime/` (mounted at `/extras/authlib-injector.jar`).
|
||||
|
||||
### NeoForge version pinning
|
||||
Mods are picky about exact NeoForge minor versions. The compose currently
|
||||
pins `NEOFORGE_VERSION: "21.1.209"` as a placeholder. **Verify against
|
||||
https://projects.neoforged.net/neoforged/neoforge before deploying.** Don't
|
||||
use `"latest"` for a stable server.
|
||||
|
||||
### Drasl username vs player name
|
||||
- **Drasl username** = OIDC user's email (used for web UI login)
|
||||
- **Player name** = `preferred_username` from OIDC, or user-chosen if
|
||||
`AllowChoosingPlayerName = true`
|
||||
- Keycloak users must have email set. Verify the realm's email-as-username
|
||||
setting and the `preferred_username` mapper are configured.
|
||||
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.
|
||||
|
||||
### Mod source preference
|
||||
Prefer **Modrinth over CurseForge** for itzg auto-download. CurseForge
|
||||
requires an API key (`CF_API_KEY`) and has been historically flakier with
|
||||
the itzg image. Use CF only for mods that are exclusive to it.
|
||||
|
||||
## Compose Stack
|
||||
|
||||
The working compose file is at `./docker-compose.yml`. Key environment vars
|
||||
documented inline. Adjacent files:
|
||||
|
||||
```
|
||||
.
|
||||
├── docker-compose.yml
|
||||
├── .env # RCON_PASSWORD, CF_API_KEY (if needed)
|
||||
├── drasl/
|
||||
│ └── config/
|
||||
│ ├── config.toml # see ./drasl-config.toml example
|
||||
│ └── keycloak-client-secret # single line, no trailing newline
|
||||
├── extras/
|
||||
│ ├── authlib-injector.jar # downloaded from yushijinhun's releases
|
||||
│ ├── modrinth-mods.txt # one mod slug per line
|
||||
│ └── cf-mods.txt # only if using CurseForge mods
|
||||
├── backups/ # mc-backup writes here
|
||||
└── CLAUDE.md # this file
|
||||
```
|
||||
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.
|
||||
|
||||
### Memory sizing
|
||||
With ~50–100 mods and 8 concurrent players on 1.21.1:
|
||||
- `INIT_MEMORY: 4G`, `MAX_MEMORY: 10G` is a safe starting point
|
||||
- Use `USE_AIKAR_FLAGS: TRUE` for the well-known GC tuning
|
||||
- Monitor with `mc-monitor` (itzg makes one) if you want metrics
|
||||
~50–100 mods, 8 players, 1.21.1: `INIT_MEMORY: 4G`, `MAX_MEMORY: 10G`,
|
||||
`USE_AIKAR_FLAGS: TRUE`.
|
||||
|
||||
### Backups
|
||||
`itzg/mc-backup` runs alongside, talks to the server via RCON, takes
|
||||
snapshots every 6h, prunes after 14 days. Backups are world-only (no mod
|
||||
jars), which is correct — mods are reproducible from the mod list files.
|
||||
## Config (.env)
|
||||
|
||||
## Keycloak Client Configuration
|
||||
`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).
|
||||
|
||||
In the `homelab` realm (or whatever the realm is called):
|
||||
## Client Distribution (guests)
|
||||
|
||||
1. **Clients → Create client**
|
||||
- Client type: `OpenID Connect`
|
||||
- Client ID: `drasl`
|
||||
- Name: `Drasl Minecraft Auth`
|
||||
|
||||
2. **Capability config**
|
||||
- Client authentication: **ON** (confidential client)
|
||||
- Authentication flow: **Standard flow** only
|
||||
- Disable: Direct access grants, Implicit, Service accounts
|
||||
|
||||
3. **Login settings**
|
||||
- Root URL: `http://drasl.home.local:25585`
|
||||
- Valid redirect URIs: `http://drasl.home.local:25585/web/oidc-callback/Keycloak`
|
||||
(the `Keycloak` at the end MUST match the `Name = "Keycloak"` in Drasl's
|
||||
`[[RegistrationOIDC]]` block — case sensitive)
|
||||
- Web origins: `http://drasl.home.local:25585`
|
||||
|
||||
4. **Credentials tab** → copy Client Secret to
|
||||
`./drasl/config/keycloak-client-secret` (no trailing newline).
|
||||
|
||||
5. **Advanced tab** → PKCE Code Challenge Method: `S256`
|
||||
|
||||
## Client Distribution (LAN guests)
|
||||
|
||||
### Recommended launcher
|
||||
**Prism Launcher** — open source, cross-platform (Win/Mac/Linux), first-class
|
||||
authlib-injector support, can import/export instance ZIPs.
|
||||
|
||||
### Per-guest one-time setup
|
||||
1. Install Prism Launcher.
|
||||
2. Add authlib-injector account:
|
||||
- URL: `http://drasl.home.local:25585/authlib-injector`
|
||||
- Username/password: their Drasl credentials (or Minecraft Token if
|
||||
registered via Keycloak OIDC)
|
||||
3. Import the modpack instance ZIP.
|
||||
|
||||
### What to ship guests
|
||||
- A Prism instance ZIP (right-click instance → Export Instance)
|
||||
- A one-page README with the auth URL and import steps
|
||||
- Optionally `authlib-injector.jar` for guests who refuse Prism
|
||||
Launcher: **FjordLauncherUnlocked** (Prism fork, first-class authlib-injector),
|
||||
mirrored at apex `/launcher/`. Per guest:
|
||||
1. Download from `https://${BASE_DOMAIN}` → `/launcher/`.
|
||||
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`.
|
||||
4. Connect to `${BASE_DOMAIN}` (`:25565`).
|
||||
|
||||
## Open / Pending Work
|
||||
|
||||
These were on the roadmap when we ended the brainstorm:
|
||||
|
||||
- [ ] **Mod shortlist for the kitchen-sink pack.** Categories to fill:
|
||||
- Performance (Embeddium/Sodium-equivalent for NeoForge, FerriteCore, ModernFix)
|
||||
- Tech/automation (Mekanism, Create, Immersive Engineering, AE2)
|
||||
- Magic (Ars Nouveau, Botania, Iron's Spells 'n Spellbooks)
|
||||
- Storage (Sophisticated Storage/Backpacks, Functional Storage)
|
||||
- Exploration (YUNG's structures, Repurposed Structures, biome packs)
|
||||
- QoL (JEI, Jade, JEI Resources, Inventory Profiles Next, AppleSkin)
|
||||
- World gen (Tectonic, Terralith — check NeoForge 1.21.1 compat)
|
||||
- Compat glue (Polymorph for recipe conflicts)
|
||||
- Map (XaeroMinimap + Xaero World Map)
|
||||
- Voice (Simple Voice Chat — port 24454/udp already in compose)
|
||||
- [ ] **Decide on dimension/server-side performance mods** (C2ME, Lithium-equivalent)
|
||||
- [ ] **Reverse proxy + local TLS** for Drasl (Caddy + step-ca recommended,
|
||||
given the existing homelab)
|
||||
- [ ] **DNS record** in AdGuard for `drasl.home.local` → Docker host LAN IP
|
||||
- [ ] **Keycloak client** creation per the section above
|
||||
- [ ] **Verify NeoForge version** against current stable before first deploy
|
||||
- [ ] **Bootstrap admin in Drasl**: leave `AllowPasswordLogin = true` initially,
|
||||
create the admin account, then optionally disable password login to force OIDC
|
||||
- [ ] **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).
|
||||
- [ ] Server-side perf mods (C2ME, etc.).
|
||||
- [ ] **Verify NeoForge version** against current stable before first deploy.
|
||||
- [ ] **Bootstrap Drasl admin** account.
|
||||
|
||||
## Reference URLs
|
||||
|
||||
- Drasl repo: https://github.com/unmojang/drasl
|
||||
- Drasl configuration docs: https://github.com/unmojang/drasl/blob/master/doc/configuration.md
|
||||
- Drasl recipes (example configs): https://github.com/unmojang/drasl/blob/master/doc/recipes.md
|
||||
- Drasl: https://github.com/unmojang/drasl — config docs:
|
||||
https://github.com/unmojang/drasl/blob/master/doc/configuration.md
|
||||
- authlib-injector: https://github.com/yushijinhun/authlib-injector
|
||||
- itzg/minecraft-server: https://github.com/itzg/docker-minecraft-server
|
||||
- itzg NeoForge docs: https://github.com/itzg/docker-minecraft-server/blob/master/docs/types-and-platforms/server-types/forge.md
|
||||
(NeoForge: …/docs/types-and-platforms/server-types/forge.md)
|
||||
- itzg/mc-backup: https://github.com/itzg/docker-mc-backup
|
||||
- NeoForge versions: https://projects.neoforged.net/neoforged/neoforge
|
||||
- Prism Launcher: https://prismlauncher.org/
|
||||
- Reference modpacks for inspiration (NOT forking):
|
||||
- ATM10 (Modrinth/CurseForge — ~500 mods, NeoForge 1.21.1)
|
||||
- Leaking Kitchen Sink (CurseForge — trimmed ~150 mods, NeoForge 1.21.1)
|
||||
- NMSR: https://github.com/NickAcPT/nmsr-rs
|
||||
- Uptime Kuma: https://github.com/louislam/uptime-kuma
|
||||
- FjordLauncherUnlocked: https://github.com/hero-persson/FjordLauncherUnlocked
|
||||
- Reference packs (inspiration, NOT forking): ATM10, Leaking Kitchen Sink.
|
||||
|
||||
## Communication Preferences (carry-over from past sessions)
|
||||
## Communication Preferences (carry-over)
|
||||
|
||||
- Concise and direct
|
||||
- Diagnose and resolve without demanding detailed reproduction steps
|
||||
- Prefer iterative single-change updates over large rewrites
|
||||
- Cautious scripts that halt on ambiguity rather than proceeding silently
|
||||
- Avoid over-engineered responses when a simple answer suffices
|
||||
- Spanish or English fine; user is comfortable in both
|
||||
- Concise and direct; diagnose + resolve without demanding repro steps.
|
||||
- Prefer iterative single-change updates over large rewrites.
|
||||
- Cautious scripts that halt on ambiguity rather than proceeding silently.
|
||||
- Avoid over-engineering when a simple answer suffices.
|
||||
- Spanish or English both fine.
|
||||
|
||||
190
README.md
190
README.md
@@ -1,111 +1,159 @@
|
||||
# Ulicraft Server
|
||||
|
||||
Self-hosted modded Minecraft (NeoForge 1.21.1) for a LAN party. Runs **online**
|
||||
(internet present) or **fully air-gapped** (offline). Self-hosted auth/skins
|
||||
(Drasl), a packwiz modpack, a guest landing page, and a transparent mirror of
|
||||
Mojang/launcher/NeoForge assets so guest laptops need no internet.
|
||||
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.
|
||||
|
||||
## Stack
|
||||
|
||||
One compose file, one `docker compose up -d`:
|
||||
|
||||
| Service | Image | Role |
|
||||
|---|---|---|
|
||||
| `minecraft` | itzg/minecraft-server | NeoForge 1.21.1 server, `:25565` |
|
||||
| `drasl` | unmojang/drasl | Yggdrasil auth + skins (password login) |
|
||||
| `caddy` | caddy:alpine | ingress: landing, auth/packwiz proxy, asset mirror (`tls internal`) |
|
||||
| `caddy` | caddy:alpine | internal ingress: routes every vhost by Host header |
|
||||
| `nmsr` | built from source | skin/avatar renderer at `avatar.${BASE_DOMAIN}` |
|
||||
| `uptime-kuma` | louislam/uptime-kuma | status page at `status.${BASE_DOMAIN}` |
|
||||
| `mc-backup` | itzg/mc-backup | world backups every 6h via RCON |
|
||||
|
||||
Optional layers: `avahi` (mDNS `.local`), `dnsmasq` (turnkey DNS) — only if you
|
||||
don't run your own DNS.
|
||||
Vhosts (all proxied through the host nginx → caddy):
|
||||
|
||||
Config lives in `.env`: `BASE_DOMAIN` (default `ulicraft.lan`), `HOST_LAN_IP`,
|
||||
`RCON_PASSWORD`, `ENABLE_MDNS`. Services are subdomains: `auth.`, `packwiz.`,
|
||||
`mc.`, plus the apex landing page.
|
||||
- apex `${BASE_DOMAIN}` — landing page + `/launcher/` downloads
|
||||
- `auth.` — Drasl
|
||||
- `pack.` — packwiz metadata + `/custom/` jars
|
||||
- `avatar.` — NMSR
|
||||
- `status.` — Uptime Kuma
|
||||
- `distribution.` — static site from another repo (`DISTRIBUTION_WEB_ROOT`)
|
||||
|
||||
## DNS (pick one)
|
||||
Config lives in `.env`: `BASE_DOMAIN`, `RCON_PASSWORD`, `CADDY_HTTP_PORT` /
|
||||
`CADDY_HTTP_BIND`, `DISTRIBUTION_WEB_ROOT`. See `.env.example`.
|
||||
|
||||
Names must resolve to `HOST_LAN_IP`. Use **your own party DNS server** (recommended):
|
||||
## DNS
|
||||
|
||||
```sh
|
||||
tooling/dns-records.sh online # service names only (internet present)
|
||||
tooling/dns-records.sh airgap # + Mojang/launcher/NeoForge spoofs (offline)
|
||||
```
|
||||
Paste the printed records into your DNS. **Use `.lan`, not `.local`** — `.local`
|
||||
is intercepted by mDNS and won't resolve via unicast DNS.
|
||||
|
||||
Alternatives: `ENABLE_MDNS=true` + `BASE_DOMAIN=*.local` (zero-config mDNS,
|
||||
macOS/Linux native, Windows needs Bonjour) — but mDNS can't serve the air-gap
|
||||
upstream spoofs. Or layer in `docker-compose.dnsmasq.yml` for a turnkey DNS.
|
||||
|
||||
### Simulate the DNS via /etc/hosts (single machine, testing)
|
||||
|
||||
No DNS server? Append the records straight to `/etc/hosts` (marker-wrapped):
|
||||
|
||||
```sh
|
||||
tooling/dns-records.sh online hosts | sudo tee -a /etc/hosts # services only
|
||||
tooling/dns-records.sh airgap hosts | sudo tee -a /etc/hosts # + Mojang spoofs
|
||||
```
|
||||
|
||||
Remove them later:
|
||||
|
||||
```sh
|
||||
sudo sed -i '/# >>> ulicraft/,/# <<< ulicraft/d' /etc/hosts
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `/etc/hosts` has **no wildcard** — only the listed names resolve.
|
||||
- `airgap` adds the Mojang/launcher/NeoForge hostnames pointing at the LAN; this
|
||||
**breaks normal internet access to those domains** while present. Use it only
|
||||
while offline; for an online box use `online`.
|
||||
- `mc.<domain>` works (client defaults to :25565); SRV isn't used via `/etc/hosts`.
|
||||
Handled **outside this repo**. Point `${BASE_DOMAIN}` and every subdomain
|
||||
(`auth. pack. avatar. status. distribution. www.`) at the host running nginx.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Docker + Docker Compose; for prep also `pnpm`, `packwiz`, `jq`, `curl`,
|
||||
`envsubst`, `sha1sum`.
|
||||
`envsubst`.
|
||||
|
||||
## Launch
|
||||
|
||||
```sh
|
||||
cp .env.example .env # set BASE_DOMAIN, HOST_LAN_IP, RCON_PASSWORD
|
||||
cp .env.example .env # set BASE_DOMAIN, RCON_PASSWORD, DISTRIBUTION_WEB_ROOT
|
||||
|
||||
tooling/build-stack.sh --prep # ONLINE, once: render, build landing,
|
||||
# mirror mods+assets, fetch launcher +
|
||||
# authlib, pre-bake the server volume
|
||||
# One-time / on change — render configs + build content:
|
||||
tooling/render-config.sh # drasl + nmsr + packwiz configs from *.tmpl
|
||||
( cd landing && pnpm install && BASE_DOMAIN="$BASE_DOMAIN" pnpm run build ) # → www/
|
||||
tooling/fetch-launcher.sh # FjordLauncher assets → launcher/ (served at /launcher/)
|
||||
tooling/fetch-authlib.sh # authlib-injector.jar → runtime/ (server JVM agent)
|
||||
|
||||
tooling/build-stack.sh --up online # internet present, no mirror
|
||||
tooling/build-stack.sh --up airgap # offline; full asset mirror on :443 (default)
|
||||
tooling/build-stack.sh --up core # base only (debugging)
|
||||
docker compose up -d --build # first run installs NeoForge + mods, builds nmsr
|
||||
docker compose down # stop
|
||||
```
|
||||
|
||||
`--up` prints the DNS records to add for that mode. Down:
|
||||
`docker compose -f docker-compose.yml -f docker-compose.static.yml -f docker-compose.mirror.yml down`
|
||||
First boot: itzg installs NeoForge + the packwiz mods into the `mc_data` volume;
|
||||
nmsr does a one-time Rust compile. Watch `docker compose logs -f minecraft` until
|
||||
`Done`.
|
||||
|
||||
## Public TLS (host nginx in front of caddy)
|
||||
|
||||
caddy is published on a localhost-only port (`CADDY_HTTP_BIND=127.0.0.1`,
|
||||
`CADDY_HTTP_PORT=8880`). The host's own nginx terminates TLS with Let's Encrypt
|
||||
certs and reverse-proxies every vhost to caddy by Host header. HTTPS-only: HTTP
|
||||
301s to HTTPS and every TLS vhost sends HSTS.
|
||||
|
||||
1. **Issue certs** (OVH DNS-01, no inbound ports needed):
|
||||
```sh
|
||||
# .env: BASE_DOMAIN, LE_EMAIL, OVH_* creds,
|
||||
# LE_SUBDOMAINS="auth pack distribution www avatar status"
|
||||
tooling/issue-letsencrypt.sh # → certs/<name>/{cert,key}.pem
|
||||
```
|
||||
|
||||
2. **Render + install the nginx vhost** with `tooling/render-nginx.sh`. It pulls
|
||||
`BASE_DOMAIN` + `CADDY_HTTP_PORT` from `.env`, defaults `APP_DIR` to the repo
|
||||
checkout (where `certs/` live), and renders `nginx/ulicraft-caddy.conf.tmpl`:
|
||||
```sh
|
||||
tooling/render-nginx.sh # preview to stdout (dry run)
|
||||
tooling/render-nginx.sh --install # write, symlink, nginx -t, reload
|
||||
```
|
||||
Override `APP_DIR=/path` if the repo isn't at the cert location. Re-run
|
||||
whenever you add a subdomain so its server block is rendered.
|
||||
|
||||
## Avatar renderer (NMSR)
|
||||
|
||||
`avatar.${BASE_DOMAIN}` renders players' skins/avatars via
|
||||
[NMSR-aas](https://github.com/NickAcPT/nmsr-rs), sourced from **Drasl** (not
|
||||
Mojang). No upstream image exists, so it's built from source — `docker/nmsr/`
|
||||
pins a commit; bump `ARG NMSR_REF` to update.
|
||||
|
||||
- **Config**: `nmsr/config.toml.tmpl` → rendered to `nmsr/config.toml` by
|
||||
`tooling/render-config.sh`. `[mojank]` points every Mojang endpoint at
|
||||
`http://auth.${BASE_DOMAIN}`; Drasl serves the Mojang-compatible routes at its
|
||||
bare BaseURL and embeds absolute skin URLs that NMSR fetches directly.
|
||||
`allow_offline_mode_uuids = true` (Drasl issues offline v3 UUIDs).
|
||||
- **Network**: skin resolution stays internal over `mcnet` (caddy alias
|
||||
`auth.${BASE_DOMAIN}` → drasl). caddy reverse-proxies `avatar.` → `nmsr:8080`.
|
||||
- **Endpoints**: e.g. `https://avatar.${BASE_DOMAIN}/skin/<player>`,
|
||||
`/face/<player>`, `/fullbody/<player>` — by username or UUID.
|
||||
|
||||
First build is heavy (Rust compile). Headless rendering uses lavapipe (software
|
||||
Vulkan); if renders fail, check `docker logs nmsr` for Vulkan device init.
|
||||
|
||||
## Status monitoring (Uptime Kuma)
|
||||
|
||||
`status.${BASE_DOMAIN}` runs [Uptime Kuma](https://github.com/louislam/uptime-kuma)
|
||||
— uptime probes for every vhost **and** a native Minecraft-protocol ping (player
|
||||
count + up/down), plus a public status page. It joins `mcnet`, so monitors can
|
||||
probe the stack by internal service name. caddy reverse-proxies `status.` →
|
||||
`uptime-kuma:3001`.
|
||||
|
||||
First run, open `https://status.${BASE_DOMAIN}`, create the admin account, and
|
||||
add monitors. Kuma has no config-as-code — add these once via the UI (data
|
||||
persists in the `kuma_data` volume):
|
||||
|
||||
| Monitor | Type | Target |
|
||||
|---|---|---|
|
||||
| Minecraft | Minecraft Server | `minecraft` : `25565` |
|
||||
| Drasl auth | HTTP(s) | `https://auth.${BASE_DOMAIN}` |
|
||||
| Landing (apex) | HTTP(s) | `https://${BASE_DOMAIN}` |
|
||||
| Packwiz | HTTP(s) | `https://pack.${BASE_DOMAIN}` |
|
||||
| Distribution | HTTP(s) | `https://distribution.${BASE_DOMAIN}` |
|
||||
| Avatar (NMSR) | HTTP(s) | `https://avatar.${BASE_DOMAIN}` |
|
||||
|
||||
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.
|
||||
|
||||
## Join (guests)
|
||||
|
||||
1. Open `http://ulicraft.lan`, download FjordLauncherUnlocked for your OS.
|
||||
2. Add an **authlib-injector** account, URL `http://auth.ulicraft.lan/authlib-injector`
|
||||
(register/login at `http://auth.ulicraft.lan`).
|
||||
3. **Air-gap only:** trust the local CA — download `http://ulicraft.lan/ca.crt`
|
||||
and add it to your OS trust store (so the HTTPS asset mirror is trusted).
|
||||
4. Import the pack: `http://packwiz.ulicraft.lan/pack.toml`.
|
||||
5. Connect to **`mc.ulicraft.lan`** (no port needed).
|
||||
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`.
|
||||
4. Connect to **`${BASE_DOMAIN}`** (Minecraft, `:25565`).
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
docker-compose.yml # core: drasl, minecraft, mc-backup, caddy
|
||||
.static.yml .mirror.yml # online/airgap layers
|
||||
.avahi.yml .dnsmasq.yml # optional DNS layers
|
||||
caddy/Caddyfile + conf.d/*.caddy # ingress vhost snippets (core/static/mirror)
|
||||
docker-compose.yml # the whole stack (one file)
|
||||
caddy/Caddyfile + conf.d/*.caddy # ingress vhost snippets
|
||||
# (core/static/distribution/avatar/status)
|
||||
drasl/config/config.toml.tmpl # auth config (rendered)
|
||||
dnsmasq/dnsmasq.conf.tmpl # optional DNS config (rendered)
|
||||
docker/avahi/ # mDNS responder image
|
||||
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/
|
||||
tooling/ # build-stack, render-config, dns-records,
|
||||
# mirror-mods, mirror-airgap, fetch-launcher
|
||||
mirror/ # vendored jars + launcher + asset mirror (gitignored)
|
||||
plan/ # full design docs (00–12)
|
||||
launcher/ # FjordLauncher assets (gitignored)
|
||||
tooling/ # render-config, render-pack, render-nginx,
|
||||
# issue-letsencrypt, fetch-launcher, add-custom-mod
|
||||
nginx/ulicraft-caddy.conf.tmpl # host-nginx TLS vhost template (front of caddy)
|
||||
plan/ # design docs
|
||||
```
|
||||
|
||||
See `plan/00-overview.md` for the full design.
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
# Base Caddy config. Vhosts live in conf.d/*.caddy snippets that are mounted
|
||||
# in selectively so the stack can run with or without the static site / mirror:
|
||||
# conf.d/00-core.caddy auth + packwiz (always — core ingress)
|
||||
# conf.d/10-static.caddy apex landing + launcher downloads (static toggle)
|
||||
# conf.d/20-mirror.caddy air-gap upstream mirror (tls internal) (mirror toggle)
|
||||
# auto_https disable_redirects: http:// sites stay plaintext on :80; the mirror
|
||||
# snippet's hosts still get internal TLS on :443 when that snippet is present.
|
||||
# Base Caddy config. Vhosts live in conf.d/*.caddy snippets:
|
||||
# conf.d/00-core.caddy auth + packwiz
|
||||
# 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
|
||||
# conf.d/50-status.caddy uptime-kuma status page
|
||||
# All snippets are plain http:// on :80 — the host's nginx terminates TLS in
|
||||
# front (nginx/ulicraft-caddy.conf.tmpl). disable_redirects keeps caddy from
|
||||
# upgrading to its own https.
|
||||
{
|
||||
auto_https disable_redirects
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
# Core ingress — Blessing Skin variant. Replaces 00-core.caddy when the
|
||||
# docker-compose.blessingskin.yml override is in play (mounted at the same
|
||||
# target path). auth.* now points at Blessing Skin instead of Drasl.
|
||||
#
|
||||
# Blessing Skin serves BOTH its web UI (/) and the Yggdrasil API (/api/yggdrasil)
|
||||
# on the same vhost, so a single reverse_proxy covers everything.
|
||||
http://auth.{$BASE_DOMAIN} {
|
||||
reverse_proxy blessing-skin:80
|
||||
}
|
||||
|
||||
http://pack.{$BASE_DOMAIN} {
|
||||
root * /srv/pack
|
||||
file_server browse
|
||||
}
|
||||
@@ -1,19 +1,18 @@
|
||||
# Static toggle — apex landing page + launcher downloads.
|
||||
# Mounted only when the stack runs with static files (build-stack.sh up static|full).
|
||||
# Apex — landing page (/srv/www) + launcher downloads (/srv/launcher).
|
||||
http://{$BASE_DOMAIN} {
|
||||
# Caddy's local CA root, so guests can trust the air-gap HTTPS mirror.
|
||||
# Mounted from ./caddy/caddy-root-ca.crt (exported by build-stack prep;
|
||||
# the live pki dir is root-only 0600 and can't be served directly).
|
||||
handle /ca.crt {
|
||||
root * /srv/ca-pub
|
||||
file_server
|
||||
}
|
||||
# Launcher downloads are a sibling mount (/srv/launcher), not nested under
|
||||
# the read-only /srv/www. handle_path strips the /launcher prefix.
|
||||
handle_path /launcher/* {
|
||||
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
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
# Mirror toggle — full client air-gap mirror (see plan/11-full-airgap-mirror.md).
|
||||
# Byte-exact copies of the real upstream hosts over HTTPS with Caddy's local CA.
|
||||
# dnsmasq spoofs these hostnames -> our host; {host} roots each at its subtree.
|
||||
# Mounted only when the stack runs with the mirror (build-stack.sh up mirror|full).
|
||||
meta.prismlauncher.org, piston-meta.mojang.com, piston-data.mojang.com, libraries.minecraft.net, maven.neoforged.net, resources.download.minecraft.net {
|
||||
tls internal
|
||||
root * /srv/mirror/{host}
|
||||
file_server
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
# Distribution toggle — static site served from a web root in ANOTHER repo.
|
||||
# The host path is set by DISTRIBUTION_WEB_ROOT in .env and bind-mounted to
|
||||
# /srv/distribution by docker-compose.caddy.yml. Mounted only when that var is
|
||||
# set (see the distribution override).
|
||||
# Distribution — static site served from a web root in ANOTHER repo. The host
|
||||
# path is set by DISTRIBUTION_WEB_ROOT in .env and bind-mounted to
|
||||
# /srv/distribution by the caddy service in docker-compose.yml.
|
||||
http://distribution.{$BASE_DOMAIN} {
|
||||
root * /srv/distribution
|
||||
file_server browse
|
||||
|
||||
4
caddy/conf.d/40-avatar.caddy
Normal file
4
caddy/conf.d/40-avatar.caddy
Normal file
@@ -0,0 +1,4 @@
|
||||
# Avatar — NMSR skin/avatar renderer (nmsr service), Drasl-backed.
|
||||
http://avatar.{$BASE_DOMAIN} {
|
||||
reverse_proxy nmsr:8080
|
||||
}
|
||||
4
caddy/conf.d/50-status.caddy
Normal file
4
caddy/conf.d/50-status.caddy
Normal file
@@ -0,0 +1,4 @@
|
||||
# Status — Uptime Kuma monitoring + public status page (uptime-kuma service).
|
||||
http://status.{$BASE_DOMAIN} {
|
||||
reverse_proxy uptime-kuma:3001
|
||||
}
|
||||
BIN
custom/BetterF3-11.0.3-NeoForge-1.21.1.jar
Normal file
BIN
custom/BetterF3-11.0.3-NeoForge-1.21.1.jar
Normal file
Binary file not shown.
BIN
custom/BridgingMod-2.6.2+1.21.1.neoforge-release.jar
Normal file
BIN
custom/BridgingMod-2.6.2+1.21.1.neoforge-release.jar
Normal file
Binary file not shown.
BIN
custom/Controlling-neoforge-1.21.1-19.0.5.jar
Normal file
BIN
custom/Controlling-neoforge-1.21.1-19.0.5.jar
Normal file
Binary file not shown.
BIN
custom/ExplorersCompass-1.21.1-3.4.0-neoforge.jar
Normal file
BIN
custom/ExplorersCompass-1.21.1-3.4.0-neoforge.jar
Normal file
Binary file not shown.
BIN
custom/FarmersDelight-1.21.1-1.3.2.jar
Normal file
BIN
custom/FarmersDelight-1.21.1-1.3.2.jar
Normal file
Binary file not shown.
BIN
custom/FramedBlocks-10.6.0.jar
Normal file
BIN
custom/FramedBlocks-10.6.0.jar
Normal file
Binary file not shown.
BIN
custom/ImmediatelyFast-NeoForge-1.6.10+1.21.1.jar
Normal file
BIN
custom/ImmediatelyFast-NeoForge-1.6.10+1.21.1.jar
Normal file
Binary file not shown.
BIN
custom/Necronomicon-NeoForge-1.6.0+1.21.jar
Normal file
BIN
custom/Necronomicon-NeoForge-1.6.0+1.21.jar
Normal file
Binary file not shown.
BIN
custom/Patchouli-1.21.1-93-NEOFORGE.jar
Normal file
BIN
custom/Patchouli-1.21.1-93-NEOFORGE.jar
Normal file
Binary file not shown.
BIN
custom/Searchables-neoforge-1.21.1-1.0.2.jar
Normal file
BIN
custom/Searchables-neoforge-1.21.1-1.0.2.jar
Normal file
Binary file not shown.
BIN
custom/TravelersTitles-1.21.1-NeoForge-5.1.3.jar
Normal file
BIN
custom/TravelersTitles-1.21.1-NeoForge-5.1.3.jar
Normal file
Binary file not shown.
BIN
custom/YungsApi-1.21.1-NeoForge-5.1.6.jar
Normal file
BIN
custom/YungsApi-1.21.1-NeoForge-5.1.6.jar
Normal file
Binary file not shown.
BIN
custom/appleskin-neoforge-mc1.21-3.0.9.jar
Normal file
BIN
custom/appleskin-neoforge-mc1.21-3.0.9.jar
Normal file
Binary file not shown.
BIN
custom/architectury-13.0.8-neoforge.jar
Normal file
BIN
custom/architectury-13.0.8-neoforge.jar
Normal file
Binary file not shown.
BIN
custom/baguettelib-1.21.1-NeoForge-2.0.3.jar
Normal file
BIN
custom/baguettelib-1.21.1-NeoForge-2.0.3.jar
Normal file
Binary file not shown.
BIN
custom/cardboardboxes-1.21.1-0.1.2.jar
Normal file
BIN
custom/cardboardboxes-1.21.1-0.1.2.jar
Normal file
Binary file not shown.
BIN
custom/cloth-config-15.0.140-neoforge.jar
Normal file
BIN
custom/cloth-config-15.0.140-neoforge.jar
Normal file
Binary file not shown.
BIN
custom/comforts-neoforge-9.0.5+1.21.1.jar
Normal file
BIN
custom/comforts-neoforge-9.0.5+1.21.1.jar
Normal file
Binary file not shown.
BIN
custom/configured-neoforge-1.21.1-2.6.3.jar
Normal file
BIN
custom/configured-neoforge-1.21.1-2.6.3.jar
Normal file
Binary file not shown.
BIN
custom/corpse-neoforge-1.21.1-1.1.13.jar
Normal file
BIN
custom/corpse-neoforge-1.21.1-1.1.13.jar
Normal file
Binary file not shown.
BIN
custom/corpsecurioscompat-1.21.1-NeoForge-4.0.1.jar
Normal file
BIN
custom/corpsecurioscompat-1.21.1-NeoForge-4.0.1.jar
Normal file
Binary file not shown.
BIN
custom/curios-neoforge-9.5.1+1.21.1.jar
Normal file
BIN
custom/curios-neoforge-9.5.1+1.21.1.jar
Normal file
Binary file not shown.
BIN
custom/drippyloadingscreen_neoforge_3.1.2_MC_1.21.1.jar
Normal file
BIN
custom/drippyloadingscreen_neoforge_3.1.2_MC_1.21.1.jar
Normal file
Binary file not shown.
BIN
custom/entity_model_features-3.2.4-1.21-neoforge.jar
Normal file
BIN
custom/entity_model_features-3.2.4-1.21-neoforge.jar
Normal file
Binary file not shown.
BIN
custom/entity_texture_features_1.21-neoforge-7.1.jar
Normal file
BIN
custom/entity_texture_features_1.21-neoforge-7.1.jar
Normal file
Binary file not shown.
BIN
custom/entityculling-neoforge-1.10.2-mc1.21.1.jar
Normal file
BIN
custom/entityculling-neoforge-1.10.2-mc1.21.1.jar
Normal file
Binary file not shown.
BIN
custom/fancymenu_neoforge_3.9.1_MC_1.21.1.jar
Normal file
BIN
custom/fancymenu_neoforge_3.9.1_MC_1.21.1.jar
Normal file
Binary file not shown.
BIN
custom/fastleafdecay-35.jar
Normal file
BIN
custom/fastleafdecay-35.jar
Normal file
Binary file not shown.
BIN
custom/ferritecore-7.0.3-neoforge.jar
Normal file
BIN
custom/ferritecore-7.0.3-neoforge.jar
Normal file
Binary file not shown.
BIN
custom/ftb-chunks-neoforge-2101.1.17.jar
Normal file
BIN
custom/ftb-chunks-neoforge-2101.1.17.jar
Normal file
Binary file not shown.
BIN
custom/ftb-essentials-neoforge-2101.1.9.jar
Normal file
BIN
custom/ftb-essentials-neoforge-2101.1.9.jar
Normal file
Binary file not shown.
BIN
custom/ftb-library-neoforge-2101.1.31.jar
Normal file
BIN
custom/ftb-library-neoforge-2101.1.31.jar
Normal file
Binary file not shown.
BIN
custom/ftb-teams-neoforge-2101.1.10.jar
Normal file
BIN
custom/ftb-teams-neoforge-2101.1.10.jar
Normal file
Binary file not shown.
BIN
custom/ftb-ultimine-neoforge-2101.1.14.jar
Normal file
BIN
custom/ftb-ultimine-neoforge-2101.1.14.jar
Normal file
Binary file not shown.
BIN
custom/functionalstorage-1.21.1-1.5.7.jar
Normal file
BIN
custom/functionalstorage-1.21.1-1.5.7.jar
Normal file
Binary file not shown.
BIN
custom/handcrafted-neoforge-1.21.1-4.0.3.jar
Normal file
BIN
custom/handcrafted-neoforge-1.21.1-4.0.3.jar
Normal file
Binary file not shown.
BIN
custom/iris-neoforge-1.8.12+mc1.21.1.jar
Normal file
BIN
custom/iris-neoforge-1.8.12+mc1.21.1.jar
Normal file
Binary file not shown.
BIN
custom/jamlib-neoforge-1.3.6+1.21.1.jar
Normal file
BIN
custom/jamlib-neoforge-1.3.6+1.21.1.jar
Normal file
Binary file not shown.
BIN
custom/konkrete_neoforge_1.9.9_MC_1.21.jar
Normal file
BIN
custom/konkrete_neoforge_1.9.9_MC_1.21.jar
Normal file
Binary file not shown.
BIN
custom/kubejs-neoforge-2101.7.2-build.368.jar
Normal file
BIN
custom/kubejs-neoforge-2101.7.2-build.368.jar
Normal file
Binary file not shown.
BIN
custom/kubejsdelight-1.1.6.jar
Normal file
BIN
custom/kubejsdelight-1.1.6.jar
Normal file
Binary file not shown.
BIN
custom/lithium-neoforge-0.15.3+mc1.21.1.jar
Normal file
BIN
custom/lithium-neoforge-0.15.3+mc1.21.1.jar
Normal file
Binary file not shown.
BIN
custom/lootr-neoforge-1.21.1-1.11.37.120.jar
Normal file
BIN
custom/lootr-neoforge-1.21.1-1.11.37.120.jar
Normal file
Binary file not shown.
BIN
custom/melody_neoforge_1.0.10_MC_1.21.jar
Normal file
BIN
custom/melody_neoforge_1.0.10_MC_1.21.jar
Normal file
Binary file not shown.
BIN
custom/morefunctionalstorage-1.2.3.jar
Normal file
BIN
custom/morefunctionalstorage-1.2.3.jar
Normal file
Binary file not shown.
BIN
custom/polymorph-neoforge-1.1.0+1.21.1.jar
Normal file
BIN
custom/polymorph-neoforge-1.1.0+1.21.1.jar
Normal file
Binary file not shown.
BIN
custom/ponderjs-neoforge-1.21.1-2.4.0.jar
Normal file
BIN
custom/ponderjs-neoforge-1.21.1-2.4.0.jar
Normal file
Binary file not shown.
BIN
custom/resourcefullib-neoforge-1.21-3.0.12.jar
Normal file
BIN
custom/resourcefullib-neoforge-1.21-3.0.12.jar
Normal file
Binary file not shown.
BIN
custom/rhino-2101.2.7-build.85.jar
Normal file
BIN
custom/rhino-2101.2.7-build.85.jar
Normal file
Binary file not shown.
BIN
custom/rightclickharvest-neoforge-4.6.1+1.21.1.jar
Normal file
BIN
custom/rightclickharvest-neoforge-4.6.1+1.21.1.jar
Normal file
Binary file not shown.
BIN
custom/simplemagnets-1.1.12c-neoforge-mc1.21.jar
Normal file
BIN
custom/simplemagnets-1.1.12c-neoforge-mc1.21.jar
Normal file
Binary file not shown.
BIN
custom/sodium-extra-neoforge-0.8.7+mc1.21.1.jar
Normal file
BIN
custom/sodium-extra-neoforge-0.8.7+mc1.21.1.jar
Normal file
Binary file not shown.
BIN
custom/sodium-neoforge-0.8.12-alpha.4+mc1.21.1.jar
Normal file
BIN
custom/sodium-neoforge-0.8.12-alpha.4+mc1.21.1.jar
Normal file
Binary file not shown.
BIN
custom/sophisticatedbackpacks-1.21.1-3.25.55.1852.jar
Normal file
BIN
custom/sophisticatedbackpacks-1.21.1-3.25.55.1852.jar
Normal file
Binary file not shown.
BIN
custom/sophisticatedcore-1.21.1-1.4.49.1976.jar
Normal file
BIN
custom/sophisticatedcore-1.21.1-1.4.49.1976.jar
Normal file
Binary file not shown.
BIN
custom/spark-1.10.124-neoforge.jar
Normal file
BIN
custom/spark-1.10.124-neoforge.jar
Normal file
Binary file not shown.
BIN
custom/supermartijn642configlib-1.1.8-neoforge-mc1.21.jar
Normal file
BIN
custom/supermartijn642configlib-1.1.8-neoforge-mc1.21.jar
Normal file
Binary file not shown.
BIN
custom/supermartijn642corelib-1.1.21-neoforge-mc1.21.jar
Normal file
BIN
custom/supermartijn642corelib-1.1.21-neoforge-mc1.21.jar
Normal file
Binary file not shown.
BIN
custom/tagtooltips-neoforge-1.21.1-1.2.0.jar
Normal file
BIN
custom/tagtooltips-neoforge-1.21.1-1.2.0.jar
Normal file
Binary file not shown.
BIN
custom/titanium-1.21-4.0.43.jar
Normal file
BIN
custom/titanium-1.21-4.0.43.jar
Normal file
Binary file not shown.
BIN
custom/torchmaster-neoforge-1.21.1-21.1.9.jar
Normal file
BIN
custom/torchmaster-neoforge-1.21.1-21.1.9.jar
Normal file
Binary file not shown.
BIN
custom/trashcans-1.0.18c-neoforge-mc1.21.jar
Normal file
BIN
custom/trashcans-1.0.18c-neoforge-mc1.21.jar
Normal file
Binary file not shown.
BIN
custom/utilitarian-1.21.1-0.19.2.jar
Normal file
BIN
custom/utilitarian-1.21.1-0.19.2.jar
Normal file
Binary file not shown.
BIN
custom/voicechat-neoforge-1.21.1-2.6.18.jar
Normal file
BIN
custom/voicechat-neoforge-1.21.1-2.6.18.jar
Normal file
Binary file not shown.
BIN
custom/welcomescreen-neoforge-1.0.0-1.21.1.jar
Normal file
BIN
custom/welcomescreen-neoforge-1.0.0-1.21.1.jar
Normal file
Binary file not shown.
BIN
custom/yeetusexperimentus-neoforge-87.0.0.jar
Normal file
BIN
custom/yeetusexperimentus-neoforge-87.0.0.jar
Normal file
Binary file not shown.
BIN
custom/yet_another_config_lib_v3-3.8.2+1.21.1-neoforge.jar
Normal file
BIN
custom/yet_another_config_lib_v3-3.8.2+1.21.1-neoforge.jar
Normal file
Binary file not shown.
@@ -1,21 +0,0 @@
|
||||
# Resolve every *.${BASE_DOMAIN} (and the apex) to the Docker host LAN IP.
|
||||
# Rendered by tooling/render-config.sh -> dnsmasq/dnsmasq.conf (gitignored).
|
||||
address=/${BASE_DOMAIN}/${HOST_LAN_IP}
|
||||
no-resolv
|
||||
|
||||
# Minecraft SRV record: guests connect to "mc.${BASE_DOMAIN}" with NO port —
|
||||
# the SRV lookup points the client at port 25565. Target mc.${BASE_DOMAIN}
|
||||
# resolves to HOST_LAN_IP via the wildcard above.
|
||||
# Format: srv-host=_service._proto.name,target,port
|
||||
srv-host=_minecraft._tcp.mc.${BASE_DOMAIN},mc.${BASE_DOMAIN},25565
|
||||
|
||||
# Full client air-gap (see plan/11-full-airgap-mirror.md): spoof the real
|
||||
# upstream hosts -> our Caddy mirror. Auth hosts (session/textures/api.mojang)
|
||||
# are NOT spoofed — drasl handles those via authlib-injector. Keep this list in
|
||||
# sync with the proxy-capture step.
|
||||
address=/meta.prismlauncher.org/${HOST_LAN_IP}
|
||||
address=/piston-meta.mojang.com/${HOST_LAN_IP}
|
||||
address=/piston-data.mojang.com/${HOST_LAN_IP}
|
||||
address=/libraries.minecraft.net/${HOST_LAN_IP}
|
||||
address=/maven.neoforged.net/${HOST_LAN_IP}
|
||||
address=/resources.download.minecraft.net/${HOST_LAN_IP}
|
||||
@@ -1,28 +0,0 @@
|
||||
# mDNS toggle — publish *.local service names via the HOST's avahi-daemon for
|
||||
# zero-config guest resolution. Enabled when ENABLE_MDNS=true in .env and only
|
||||
# meaningful when BASE_DOMAIN=*.local.
|
||||
#
|
||||
# Host prerequisites:
|
||||
# - avahi-daemon running on the host
|
||||
# - if the host has many interfaces (e.g. lots of docker bridges), restrict
|
||||
# avahi to the LAN NIC in /etc/avahi/avahi-daemon.conf:
|
||||
# allow-interfaces=<lan-iface>
|
||||
# otherwise avahi sees its own announcements across bridges → "Local name
|
||||
# collision" and publishing fails.
|
||||
services:
|
||||
avahi:
|
||||
build: ./docker/avahi
|
||||
image: ulicraft-avahi:local
|
||||
container_name: avahi
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
# AppArmor's docker-default profile blocks the D-Bus publish to host avahi.
|
||||
security_opt:
|
||||
- apparmor=unconfined
|
||||
environment:
|
||||
BASE_DOMAIN: ${BASE_DOMAIN}
|
||||
HOST_LAN_IP: ${HOST_LAN_IP}
|
||||
DBUS_SYSTEM_BUS_ADDRESS: "unix:path=/run/dbus/system_bus_socket"
|
||||
volumes:
|
||||
# Talk to the host's avahi-daemon (host must run avahi-daemon).
|
||||
- /run/dbus/system_bus_socket:/run/dbus/system_bus_socket
|
||||
@@ -1,105 +0,0 @@
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
# Blessing Skin auth variant — use INSTEAD of Drasl.
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
# Layer this over the base file AND the caddy ingress (caddy lives in its own
|
||||
# file now; this override only swaps caddy's core conf, so caddy.yml is required):
|
||||
#
|
||||
# docker compose -f docker-compose.yml -f docker-compose.caddy.yml \
|
||||
# -f docker-compose.blessingskin.yml up -d
|
||||
#
|
||||
# What it does vs the base stack:
|
||||
# - drasl → left running but UNUSED + unreachable (Caddy no longer
|
||||
# routes to it; it has no host port). Compose merges
|
||||
# depends_on, so it can't be removed from an override —
|
||||
# idling it is the clean option. `docker compose ... stop
|
||||
# drasl` after up if you want it down.
|
||||
# - caddy → auth.* now reverse-proxies blessing-skin:80
|
||||
# - mariadb → new; Blessing Skin's datastore (no SQLite support)
|
||||
# - blessing-skin → new; PHP skin server + yggdrasil-api plugin
|
||||
# - minecraft → authlib-injector now points at /api/yggdrasil,
|
||||
# ONLINE_MODE=TRUE (real session validation against BSS)
|
||||
#
|
||||
# First-run is a WEB WIZARD — see plan/03b-blessing-skin.md. The yggdrasil-api
|
||||
# plugin must be installed + enabled from the BSS admin panel before the
|
||||
# /api/yggdrasil endpoint (and thus Minecraft login) works.
|
||||
#
|
||||
# DB creds + APP_KEY come from .env (see .env.example: BS_* vars).
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
|
||||
services:
|
||||
caddy:
|
||||
# Swap the core ingress conf for the Blessing Skin one. Compose MERGES
|
||||
# volumes by container path, so mounting the BSS conf at the SAME target
|
||||
# (/etc/caddy/conf.d/00-core.caddy) shadows the base 00-core.caddy; all
|
||||
# other base mounts (pack, custom, static, ca) are kept automatically.
|
||||
volumes:
|
||||
- ./caddy/conf.d/00-core-blessingskin.caddy:/etc/caddy/conf.d/00-core.caddy:ro
|
||||
depends_on:
|
||||
- blessing-skin
|
||||
|
||||
mariadb:
|
||||
image: mariadb:11
|
||||
container_name: mariadb
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MARIADB_DATABASE: ${BS_DB_NAME}
|
||||
MARIADB_USER: ${BS_DB_USER}
|
||||
MARIADB_PASSWORD: ${BS_DB_PASSWORD}
|
||||
MARIADB_ROOT_PASSWORD: ${BS_DB_ROOT_PASSWORD}
|
||||
TZ: "Europe/Madrid"
|
||||
volumes:
|
||||
- bs_db:/var/lib/mysql
|
||||
healthcheck:
|
||||
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
networks:
|
||||
- mcnet
|
||||
|
||||
blessing-skin:
|
||||
image: azusamikan/blessing-skin-server-docker:latest
|
||||
container_name: blessing-skin
|
||||
restart: unless-stopped
|
||||
# Internal-only — reached via Caddy at auth.${BASE_DOMAIN}.
|
||||
environment:
|
||||
APP_URL: "http://auth.${BASE_DOMAIN}"
|
||||
# Persist APP_KEY across recreates (else sessions/encryption break).
|
||||
# Generate once: docker run --rm azusamikan/blessing-skin-server-docker:latest php artisan key:generate --show
|
||||
APP_KEY: ${BS_APP_KEY}
|
||||
DB_DRIVER: "mysql"
|
||||
DB_HOST: "mariadb"
|
||||
DB_PORT: "3306"
|
||||
DB_DATABASE: ${BS_DB_NAME}
|
||||
DB_USERNAME: ${BS_DB_USER}
|
||||
DB_PASSWORD: ${BS_DB_PASSWORD}
|
||||
TZ: "Europe/Madrid"
|
||||
volumes:
|
||||
- bs_storage:/app/storage # uploaded skins/capes + textures
|
||||
- bs_plugins:/app/plugins # yggdrasil-api plugin lives here
|
||||
depends_on:
|
||||
mariadb:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- mcnet
|
||||
|
||||
minecraft:
|
||||
environment:
|
||||
# authlib-injector API root for Blessing Skin's yggdrasil-api plugin
|
||||
# is <site>/api/yggdrasil (NOT /authlib-injector like Drasl).
|
||||
JVM_OPTS: "-javaagent:/extras/authlib-injector.jar=http://auth.${BASE_DOMAIN}/api/yggdrasil"
|
||||
# authlib-injector needs real auth: online-mode TRUE so the server
|
||||
# validates sessions against Blessing Skin. (Drasl variant used FALSE.)
|
||||
ONLINE_MODE: "TRUE"
|
||||
# Keep FALSE for safety on 1.21+. The yggdrasil-api plugin DOES sign
|
||||
# profile keys, so you MAY flip this to TRUE if signed chat is wanted.
|
||||
ENFORCE_SECURE_PROFILE: "FALSE"
|
||||
# Merged with the base depends_on (drasl, caddy); just adds blessing-skin so
|
||||
# Minecraft starts after the skin server is up.
|
||||
depends_on:
|
||||
- blessing-skin
|
||||
|
||||
volumes:
|
||||
bs_db:
|
||||
bs_storage:
|
||||
bs_plugins:
|
||||
@@ -1,50 +0,0 @@
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
# Caddy ingress — LAN / air-gap path. NOT started with the base stack.
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
# Caddy was moved out of docker-compose.yml so the base stack can run behind
|
||||
# the host's nginx (production) without it. build-stack.sh adds this file for
|
||||
# its online/airgap/core modes; static/mirror overrides mount extra vhosts in.
|
||||
#
|
||||
# docker compose -f docker-compose.yml -f docker-compose.caddy.yml up -d
|
||||
#
|
||||
# Holds the mcnet aliases (auth./pack.${BASE_DOMAIN}) so containers resolve the
|
||||
# stack's own names internally — this is why minecraft can reach the pack here
|
||||
# without extra_hosts (the nginx path uses extra_hosts instead).
|
||||
services:
|
||||
caddy:
|
||||
image: caddy:alpine
|
||||
container_name: caddy
|
||||
restart: unless-stopped
|
||||
# Host-published port. Default 80 for the standalone LAN/air-gap path; set
|
||||
# CADDY_HTTP_PORT to a high localhost-only port when the machine's nginx
|
||||
# fronts caddy (nginx terminates TLS, reverse-proxies here by Host header —
|
||||
# see nginx/ulicraft-caddy.conf.tmpl).
|
||||
ports:
|
||||
- "${CADDY_HTTP_BIND:-0.0.0.0}:${CADDY_HTTP_PORT:-80}:80"
|
||||
environment:
|
||||
BASE_DOMAIN: ${BASE_DOMAIN}
|
||||
volumes:
|
||||
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- ./caddy/conf.d/00-core.caddy:/etc/caddy/conf.d/00-core.caddy:ro
|
||||
- ./caddy/conf.d/10-static.caddy:/etc/caddy/conf.d/10-static.caddy:ro
|
||||
- ./www:/srv/www:ro
|
||||
- ./mirror/launcher:/srv/launcher:ro
|
||||
- ./caddy/caddy-root-ca.crt:/srv/ca-pub/ca.crt:ro
|
||||
- ./pack:/srv/pack:ro
|
||||
# Custom (locally-hosted) mod jars live OUTSIDE ./pack so packwiz refresh
|
||||
# doesn't index them as direct files. Served at pack.${BASE_DOMAIN}/custom/.
|
||||
- ./custom:/srv/pack/custom:ro
|
||||
networks:
|
||||
mcnet:
|
||||
# The upstream spoof-host aliases live in docker-compose.mirror.yml —
|
||||
# adding them here would hijack maven.neoforged.net etc. for ALL mcnet
|
||||
# containers, breaking the ONLINE NeoForge install during pre-bake.
|
||||
aliases:
|
||||
- "auth.${BASE_DOMAIN}"
|
||||
- "pack.${BASE_DOMAIN}"
|
||||
|
||||
# Restore the ordering the base file used to have (base drops it so it can run
|
||||
# caddy-less behind nginx).
|
||||
minecraft:
|
||||
depends_on:
|
||||
- caddy
|
||||
@@ -1,15 +0,0 @@
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
# Distribution vhost — distribution.${BASE_DOMAIN}, static files from a web
|
||||
# root that lives in ANOTHER repo. Layered on top of the caddy ingress.
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
# docker compose -f docker-compose.yml -f docker-compose.caddy.yml \
|
||||
# -f docker-compose.distribution.yml up -d
|
||||
#
|
||||
# DISTRIBUTION_WEB_ROOT (.env) = absolute host path to the built static site in
|
||||
# the other repo. Bind-mounted read-only; caddy serves it via the
|
||||
# conf.d/30-distribution.caddy snippet.
|
||||
services:
|
||||
caddy:
|
||||
volumes:
|
||||
- ./caddy/conf.d/30-distribution.caddy:/etc/caddy/conf.d/30-distribution.caddy:ro
|
||||
- ${DISTRIBUTION_WEB_ROOT:?DISTRIBUTION_WEB_ROOT unset in .env}:/srv/distribution:ro
|
||||
@@ -1,20 +0,0 @@
|
||||
# Optional turnkey DNS — only if you DON'T run your own party DNS server.
|
||||
# Layer on: `docker compose -f docker-compose.yml -f docker-compose.dnsmasq.yml ... up -d`.
|
||||
# Serves dnsmasq/dnsmasq.conf (rendered from the template, includes the airgap
|
||||
# spoof records). If you have your own DNS, skip this and use the records from
|
||||
# `tooling/dns-records.sh` instead.
|
||||
services:
|
||||
dnsmasq:
|
||||
image: jpillora/dnsmasq
|
||||
container_name: dnsmasq
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
# Bind DNS to the host LAN IP so it doesn't clash with systemd-resolved on :53.
|
||||
- "${HOST_LAN_IP}:53:53/udp"
|
||||
- "${HOST_LAN_IP}:53:53/tcp"
|
||||
volumes:
|
||||
- ./dnsmasq/dnsmasq.conf:/etc/dnsmasq.conf:ro
|
||||
networks:
|
||||
- mcnet
|
||||
# webproc UI (:8080) can be exposed behind Caddy as dns.${BASE_DOMAIN};
|
||||
# guard with HTTP_USER / HTTP_PASS.
|
||||
@@ -1,25 +0,0 @@
|
||||
# Mirror toggle — full client air-gap upstream mirror (see plan/11).
|
||||
# Layer on top of the base: `docker compose -f docker-compose.yml -f docker-compose.mirror.yml up -d`
|
||||
# (or `tooling/build-stack.sh --up mirror`). Adds the mirror vhost snippet, the
|
||||
# upstream content mount, and publishes :443 for Caddy's `tls internal` certs.
|
||||
# Populate the mirror first: tooling/mirror-airgap.sh (+ the proxy-capture step).
|
||||
services:
|
||||
caddy:
|
||||
ports:
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./caddy/conf.d/20-mirror.caddy:/etc/caddy/conf.d/20-mirror.caddy:ro
|
||||
- ./mirror/upstream:/srv/mirror:ro
|
||||
networks:
|
||||
mcnet:
|
||||
# Spoof the real upstream hosts -> Caddy (air-gap runtime only). Includes
|
||||
# the base aliases because compose replaces (not merges) the alias list.
|
||||
aliases:
|
||||
- "auth.${BASE_DOMAIN}"
|
||||
- "packwiz.${BASE_DOMAIN}"
|
||||
- "meta.prismlauncher.org"
|
||||
- "piston-meta.mojang.com"
|
||||
- "piston-data.mojang.com"
|
||||
- "libraries.minecraft.net"
|
||||
- "maven.neoforged.net"
|
||||
- "resources.download.minecraft.net"
|
||||
@@ -1,30 +0,0 @@
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
# Production ingress = the HOST's nginx + Let's Encrypt. No caddy.
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
# docker compose -f docker-compose.yml -f docker-compose.nginx.yml up -d
|
||||
#
|
||||
# nginx (on the host, already installed) terminates TLS with the LE certs from
|
||||
# tooling/issue-letsencrypt.sh and:
|
||||
# - serves apex + pack.${BASE_DOMAIN} as static files (./www, ./pack, ./custom)
|
||||
# - reverse-proxies auth.${BASE_DOMAIN} -> 127.0.0.1:25585 (drasl)
|
||||
# See nginx/ulicraft.conf.tmpl.
|
||||
#
|
||||
# This override:
|
||||
# - publishes drasl on localhost so the host nginx can reach it
|
||||
# - points the minecraft container at the host (extra_hosts) over HTTPS, so it
|
||||
# fetches the pack and validates sessions through the same public names the
|
||||
# LE certs cover (JVM trusts Let's Encrypt out of the box)
|
||||
services:
|
||||
drasl:
|
||||
ports:
|
||||
- "127.0.0.1:25585:25585"
|
||||
|
||||
minecraft:
|
||||
# Make the public names resolve to the host running nginx.
|
||||
extra_hosts:
|
||||
- "auth.${BASE_DOMAIN}:host-gateway"
|
||||
- "pack.${BASE_DOMAIN}:host-gateway"
|
||||
environment:
|
||||
# Use HTTPS now that nginx terminates TLS with a publicly-trusted cert.
|
||||
JVM_OPTS: "-javaagent:/extras/authlib-injector.jar=https://auth.${BASE_DOMAIN}/authlib-injector"
|
||||
PACKWIZ_URL: "https://pack.${BASE_DOMAIN}/pack.toml"
|
||||
@@ -1,11 +0,0 @@
|
||||
# Static toggle — apex landing page + launcher downloads.
|
||||
# Layer on top of the base: `docker compose -f docker-compose.yml -f docker-compose.static.yml up -d`
|
||||
# (or `tooling/build-stack.sh --up static`). Adds the static vhost snippet plus
|
||||
# the www + launcher content mounts. Build www first: cd landing && pnpm run build.
|
||||
services:
|
||||
caddy:
|
||||
volumes:
|
||||
- ./caddy/conf.d/10-static.caddy:/etc/caddy/conf.d/10-static.caddy:ro
|
||||
- ./www:/srv/www:ro
|
||||
- ./mirror/launcher:/srv/launcher:ro
|
||||
- ./caddy/caddy-root-ca.crt:/srv/ca-pub/ca.crt:ro
|
||||
@@ -1,31 +1,31 @@
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
# Ulicraft LAN-party stack — Minecraft 1.21.1 NeoForge + Drasl auth
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
# Single source of config: BASE_DOMAIN + HOST_LAN_IP in .env.
|
||||
# DNS is provided by YOUR party DNS server — see tooling/dns-records.sh for the
|
||||
# records to add (online vs airgap). A turnkey dnsmasq is available as an
|
||||
# optional layer: docker-compose.dnsmasq.yml.
|
||||
# - ingress is layered on, NOT in this base file: caddy (docker-compose.caddy.yml,
|
||||
# LAN/air-gap, holds mcnet aliases) OR the host's nginx (docker-compose.nginx.yml
|
||||
# + Let's Encrypt, production)
|
||||
# - drasl handles auth (password login; NO Keycloak/OIDC in this stack)
|
||||
# - packwiz pack is served by the ingress at pack.${BASE_DOMAIN}
|
||||
# - minecraft installs mods via PACKWIZ_URL, authlib-injector -> auth.
|
||||
# Single, unified compose. One file holds the whole stack:
|
||||
# drasl auth + skins (Yggdrasil), internal only
|
||||
# minecraft the server (itzg/NEOFORGE), installs mods via packwiz
|
||||
# mc-backup world backups every 6h via RCON
|
||||
# caddy internal ingress — routes auth./pack./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 aliases (auth./pack.).
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
|
||||
services:
|
||||
# NOTE: the HTTP(S) ingress is NOT in this base file.
|
||||
# - LAN / air-gap: caddy (docker-compose.caddy.yml) — added by build-stack.sh
|
||||
# - production: the HOST's nginx + Let's Encrypt (docker-compose.nginx.yml
|
||||
# publishes drasl to localhost; nginx/ulicraft.conf.tmpl)
|
||||
# Base alone (drasl + minecraft + mc-backup) is not a complete deployment —
|
||||
# always layer one ingress file on top.
|
||||
drasl:
|
||||
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).
|
||||
# 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
|
||||
@@ -58,7 +58,7 @@ services:
|
||||
|
||||
# ── Auth: offline mode + authlib-injector pointing at Drasl ─
|
||||
ONLINE_MODE: "FALSE"
|
||||
# mount authlib-injector.jar at /extras/authlib-injector.jar
|
||||
# Resolves over mcnet to caddy (alias auth.${BASE_DOMAIN}) -> drasl.
|
||||
JVM_OPTS: "-javaagent:/extras/authlib-injector.jar=http://auth.${BASE_DOMAIN}/authlib-injector"
|
||||
|
||||
# ── Server config ───────────────────────────────────────────
|
||||
@@ -71,9 +71,7 @@ services:
|
||||
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 the ingress ──────────
|
||||
# http + caddy alias by default; the nginx override switches this to
|
||||
# https + host-gateway (extra_hosts) for the production path.
|
||||
# ── Mod source: packwiz pack served by caddy (alias pack.) ──
|
||||
PACKWIZ_URL: "http://pack.${BASE_DOMAIN}/pack.toml"
|
||||
|
||||
# ── RCON for remote admin ───────────────────────────────────
|
||||
@@ -86,11 +84,17 @@ services:
|
||||
- mc_data:/data
|
||||
- ./runtime:/extras:ro # authlib-injector.jar lives here
|
||||
depends_on:
|
||||
- drasl # ingress (caddy/nginx) is layered separately
|
||||
- drasl
|
||||
- caddy # pack./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.
|
||||
extra_hosts:
|
||||
- "auth.${BASE_DOMAIN}:host-gateway"
|
||||
- "pack.${BASE_DOMAIN}:host-gateway"
|
||||
networks:
|
||||
- mcnet
|
||||
|
||||
# Optional: backups
|
||||
mc-backup:
|
||||
image: itzg/mc-backup
|
||||
container_name: mc-backup
|
||||
@@ -109,9 +113,88 @@ services:
|
||||
networks:
|
||||
- mcnet
|
||||
|
||||
# Internal ingress. Routes every vhost by Host header (conf.d/*.caddy):
|
||||
# auth. -> drasl, pack. -> packwiz files, 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}
|
||||
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
|
||||
- ./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:
|
||||
mcnet:
|
||||
# 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.
|
||||
nmsr:
|
||||
build:
|
||||
context: ./docker/nmsr
|
||||
image: ulicraft/nmsr:local
|
||||
container_name: nmsr
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./nmsr/config.toml:/nmsr/config.toml:ro
|
||||
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"
|
||||
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:
|
||||
|
||||
networks:
|
||||
mcnet:
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
# Publishes the stack's service names on .local via the HOST's avahi-daemon
|
||||
# (over its D-Bus socket — no second daemon, avoids the :5353 collision).
|
||||
# Requires the host to run avahi-daemon and the socket to be mounted (see
|
||||
# docker-compose.avahi.yml). Used when ENABLE_MDNS=true and BASE_DOMAIN=*.local.
|
||||
FROM alpine:3.20
|
||||
RUN apk add --no-cache avahi-tools
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
@@ -1,32 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Publish A records for the stack's service names on .local via the HOST's
|
||||
# avahi-daemon (reached over its mounted D-Bus socket). One avahi-publish per
|
||||
# name; if any drops the container exits so Docker restarts it.
|
||||
set -eu
|
||||
|
||||
: "${BASE_DOMAIN:?BASE_DOMAIN unset}"
|
||||
: "${HOST_LAN_IP:?HOST_LAN_IP unset}"
|
||||
|
||||
case "$BASE_DOMAIN" in
|
||||
*.local) ;;
|
||||
*) echo "WARN: mDNS resolves only .local — BASE_DOMAIN=$BASE_DOMAIN will NOT be discoverable via avahi. Set BASE_DOMAIN=*.local to use mDNS." >&2 ;;
|
||||
esac
|
||||
|
||||
bus="${DBUS_SYSTEM_BUS_ADDRESS:-unix:path=/run/dbus/system_bus_socket}"
|
||||
sock=${bus#unix:path=}
|
||||
if [ ! -S "$sock" ]; then
|
||||
echo "ERROR: host D-Bus socket '$sock' not found. Mount the host's" >&2
|
||||
echo " /run/dbus/system_bus_socket and ensure avahi-daemon runs on the host." >&2
|
||||
exit 1
|
||||
fi
|
||||
export DBUS_SYSTEM_BUS_ADDRESS="$bus"
|
||||
|
||||
pids=""
|
||||
for n in "$BASE_DOMAIN" "auth.$BASE_DOMAIN" "packwiz.$BASE_DOMAIN" "mc.$BASE_DOMAIN"; do
|
||||
echo "mDNS publish (host avahi): $n -> $HOST_LAN_IP"
|
||||
avahi-publish -a "$n" "$HOST_LAN_IP" &
|
||||
pids="$pids $!"
|
||||
done
|
||||
|
||||
# shellcheck disable=SC2086
|
||||
wait -n $pids 2>/dev/null || wait
|
||||
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=
|
||||
196
docker/mc-status/main.go
Normal file
196
docker/mc-status/main.go
Normal file
@@ -0,0 +1,196 @@
|
||||
// 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 (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
host, port := splitHostPort(target)
|
||||
c := &cache{}
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
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", listen, host, port, ttl)
|
||||
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)
|
||||
}
|
||||
33
docker/nmsr/Dockerfile
Normal file
33
docker/nmsr/Dockerfile
Normal file
@@ -0,0 +1,33 @@
|
||||
# NMSR-as-a-Service (https://github.com/NickAcPT/nmsr-rs) — built from source.
|
||||
# Upstream ships no published image, so we vendor a pinned build. Adapted from
|
||||
# the upstream Dockerfile but: pinned to a commit (reproducible), and the config
|
||||
# is bind-mounted at runtime (not COPYed) so it can change without a rebuild.
|
||||
#
|
||||
# Pin — bump deliberately:
|
||||
ARG NMSR_REF=948ba4bc027b
|
||||
|
||||
FROM rust:slim-bookworm AS builder
|
||||
ARG NMSR_REF
|
||||
WORKDIR /tmp
|
||||
RUN apt-get update -y \
|
||||
&& apt-get --no-install-recommends install -y git libssl-dev pkg-config ca-certificates
|
||||
RUN git clone https://github.com/NickAcPT/nmsr-rs/
|
||||
WORKDIR /tmp/nmsr-rs
|
||||
RUN git checkout "${NMSR_REF}"
|
||||
# target-cpu=native: this image is built on the host that runs it (self-host).
|
||||
RUN RUSTFLAGS="-Ctarget-cpu=native" \
|
||||
cargo build --release --bin nmsr-aas --features ears --package nmsr-aas
|
||||
|
||||
FROM rust:slim-bookworm
|
||||
# mesa-vulkan-drivers provides lavapipe (software Vulkan) for headless rendering.
|
||||
RUN apt-get update -y \
|
||||
&& apt-get --no-install-recommends install -y mesa-vulkan-drivers ca-certificates
|
||||
WORKDIR /nmsr
|
||||
COPY --from=builder /tmp/nmsr-rs/target/release/nmsr-aas /nmsr/nmsr-aas
|
||||
ENV NMSR_USE_SMAA=1 \
|
||||
NMSR_SAMPLE_COUNT=1 \
|
||||
WGPU_BACKEND=vulkan \
|
||||
RUST_BACKTRACE=1
|
||||
EXPOSE 8080
|
||||
# config.toml is bind-mounted by docker-compose.nmsr.yml.
|
||||
CMD ["/nmsr/nmsr-aas", "-c", "/nmsr/config.toml"]
|
||||
@@ -1,9 +1,13 @@
|
||||
# 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.
|
||||
|
||||
BaseURL = "http://auth.${BASE_DOMAIN}"
|
||||
# Public scheme is HTTPS — the host nginx terminates TLS in front of caddy.
|
||||
# Drasl builds absolute URLs (textures, authlib-injector API location) from this;
|
||||
# an http:// value triggers mixed-content + broken login on the https origin.
|
||||
BaseURL = "https://auth.${BASE_DOMAIN}"
|
||||
Domain = "auth.${BASE_DOMAIN}"
|
||||
ListenAddress = "0.0.0.0:25585" # internal; Caddy reverse-proxies to it
|
||||
DefaultAdminUsernames = ["admin"]
|
||||
@@ -18,5 +22,27 @@ SignPublicKeys = false
|
||||
# Free-text owner label shown in the web UI (a string, not a table).
|
||||
ApplicationOwner = "Ulicraft"
|
||||
|
||||
# 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}
|
||||
|
||||
# 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).
|
||||
CORSAllowOrigins = ["https://${BASE_DOMAIN}"]
|
||||
|
||||
# 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
|
||||
|
||||
232
landing/src/components/ServerCard.astro
Normal file
232
landing/src/components/ServerCard.astro
Normal file
@@ -0,0 +1,232 @@
|
||||
---
|
||||
// Featured LIVE server card. Progressive enhancement:
|
||||
// SSG → renders a static skeleton (server icon, MOTD, slot cap) identical in
|
||||
// spirit to the old ServerListPanel, so it looks right with JS off or
|
||||
// if the status API is unreachable.
|
||||
// Client → fetches mcstatus.io (CORS:*, no key, 60s cache) for the public
|
||||
// address and fills in: live online/offline pill, players online/max +
|
||||
// fill bar, the real server favicon, color MOTD, and a row of player
|
||||
// HEADS rendered by OUR NMSR from Drasl skins (not Mojang/Crafatar).
|
||||
//
|
||||
// Live labels are passed as data-* so copy stays translatable from the page
|
||||
// (defaults are English). Heads use /headiso. Promo/placeholder players with
|
||||
// the all-zero UUID are filtered out.
|
||||
import Creeper from "./Creeper.astro";
|
||||
import { site } from "../data/site";
|
||||
|
||||
interface Props {
|
||||
// SSG fallback copy (same props the old ServerListPanel took).
|
||||
modded: string;
|
||||
motd: string;
|
||||
upTo: string;
|
||||
// Live labels (optional, English defaults). Pass t.* from the page to i18n.
|
||||
onlineLabel?: string;
|
||||
offlineLabel?: string;
|
||||
emptyLabel?: string;
|
||||
}
|
||||
const {
|
||||
modded,
|
||||
motd,
|
||||
upTo,
|
||||
onlineLabel = "Online",
|
||||
offlineLabel = "Offline",
|
||||
emptyLabel = "Nobody online — be the first.",
|
||||
} = Astro.props;
|
||||
|
||||
const { status, avatarUrl } = site;
|
||||
const HEAD_MODE = "headiso"; // NMSR render mode for the head row
|
||||
---
|
||||
<div
|
||||
class="servercard"
|
||||
data-api={status.statusApi}
|
||||
data-avatar={avatarUrl}
|
||||
data-mode={HEAD_MODE}
|
||||
data-slots={status.slots}
|
||||
data-online={onlineLabel}
|
||||
data-offline={offlineLabel}
|
||||
data-empty={emptyLabel}
|
||||
>
|
||||
<div class="sc-top">
|
||||
<div class="sc-icon">
|
||||
<!-- Replaced by the real server favicon on success; Creeper otherwise. -->
|
||||
<img class="sc-favicon" alt="" hidden />
|
||||
<span class="sc-creeper"><Creeper /></span>
|
||||
</div>
|
||||
|
||||
<div class="sc-meta">
|
||||
<div class="sc-row1">
|
||||
<span class="sc-title">{site.name}</span>
|
||||
<span class="sc-ver">Java {site.mcVersion}</span>
|
||||
</div>
|
||||
<p class="sc-motd"><span class="a">⛏ {modded}</span> · <span class="sc-motd-text">{motd}</span></p>
|
||||
</div>
|
||||
|
||||
<div class="sc-state">
|
||||
<span class="sc-pill" data-up="">
|
||||
<span class="sc-dot"></span>
|
||||
<span class="sc-pill-txt">·</span>
|
||||
</span>
|
||||
<div class="sc-count">
|
||||
<span class="sc-on">{upTo}</span> <b class="sc-max">{status.slots}</b>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Player fill bar (online/max). Hidden until a live count arrives. -->
|
||||
<div class="sc-bar" hidden><span class="sc-bar-fill"></span></div>
|
||||
|
||||
<!-- Head row + empty state. Populated client-side. -->
|
||||
<div class="sc-players" hidden>
|
||||
<div class="sc-heads" aria-label="Players online"></div>
|
||||
<p class="sc-empty" hidden></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.servercard {
|
||||
background: var(--slot);
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
box-shadow:
|
||||
inset 2px 2px 0 var(--bevel-hi),
|
||||
inset -2px -2px 0 var(--bevel-lo),
|
||||
0 8px 24px oklch(0 0 0 / 0.4);
|
||||
}
|
||||
.sc-top { display: flex; gap: 18px; align-items: center; }
|
||||
|
||||
.sc-icon {
|
||||
width: 72px; height: 72px; flex-shrink: 0;
|
||||
display: grid; place-items: center; position: relative;
|
||||
background: var(--bg-2);
|
||||
box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi);
|
||||
}
|
||||
.sc-favicon { width: 64px; height: 64px; image-rendering: pixelated; }
|
||||
.sc-creeper :global(.creeper) { width: 48px; height: 48px; }
|
||||
|
||||
.sc-meta { flex: 1; min-width: 0; }
|
||||
.sc-row1 { display: flex; align-items: baseline; gap: 12px; }
|
||||
.sc-title { font-family: var(--font-head); font-weight: 600; font-size: 22px; white-space: nowrap; }
|
||||
.sc-ver { color: var(--dim); font-family: var(--font-mono); font-size: 17px; white-space: nowrap; }
|
||||
.sc-motd { margin: 6px 0 0; color: var(--muted); font-size: 15px; }
|
||||
.sc-motd .a { color: var(--accent); }
|
||||
/* mcstatus.io motd.html ships inline color styles; keep its spans inline. */
|
||||
.sc-motd-text :global(span) { display: inline; }
|
||||
|
||||
.sc-state { text-align: right; flex-shrink: 0; display: flex; flex-direction: column; align-items: flex-end; gap: 6px; }
|
||||
.sc-pill {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
font-size: 11px; font-family: var(--font-pixel, var(--font-mono));
|
||||
letter-spacing: 0.04em; text-transform: uppercase;
|
||||
padding: 3px 8px; color: var(--dim);
|
||||
box-shadow: inset 1px 1px 0 var(--bevel-hi), inset -1px -1px 0 var(--bevel-lo);
|
||||
}
|
||||
.sc-dot { width: 9px; height: 9px; background: var(--dim); image-rendering: pixelated; }
|
||||
.sc-pill[data-up="1"] { color: var(--accent-hi); }
|
||||
.sc-pill[data-up="1"] .sc-dot { background: var(--accent); box-shadow: 0 0 6px var(--glow); }
|
||||
.sc-pill[data-up="0"] { color: oklch(0.65 0.18 25); }
|
||||
.sc-pill[data-up="0"] .sc-dot { background: oklch(0.62 0.2 25); }
|
||||
.sc-count { font-variant-numeric: tabular-nums; font-size: 15px; color: var(--muted); }
|
||||
.sc-count b { color: var(--text); }
|
||||
|
||||
.sc-bar { height: 6px; background: var(--bg-2); box-shadow: inset 1px 1px 0 var(--bevel-lo); }
|
||||
.sc-bar-fill { display: block; height: 100%; width: 0; background: var(--accent); transition: width 0.6s ease; }
|
||||
|
||||
.sc-heads { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.sc-heads img {
|
||||
width: 34px; height: 34px; image-rendering: pixelated;
|
||||
background: var(--bg-2);
|
||||
box-shadow: inset 1px 1px 0 var(--bevel-lo), inset -1px -1px 0 var(--bevel-hi);
|
||||
}
|
||||
.sc-empty { margin: 0; color: var(--dim); font-size: 13px; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const ZERO_UUID = "00000000-0000-0000-0000-000000000000";
|
||||
|
||||
async function hydrate(el: HTMLElement) {
|
||||
const { api, avatar, mode, slots, online, offline, empty } = el.dataset;
|
||||
if (!api || !avatar || !mode) return;
|
||||
|
||||
const q = <T extends HTMLElement>(s: string) => el.querySelector<T>(s);
|
||||
const pill = q(".sc-pill");
|
||||
const pillTxt = q(".sc-pill-txt");
|
||||
const favicon = q<HTMLImageElement>(".sc-favicon");
|
||||
const creeper = q(".sc-creeper");
|
||||
const onEl = q(".sc-on");
|
||||
const maxEl = q(".sc-max");
|
||||
const motdEl = q(".sc-motd-text");
|
||||
const bar = q(".sc-bar");
|
||||
const fill = q<HTMLElement>(".sc-bar-fill");
|
||||
const players = q(".sc-players");
|
||||
const heads = q(".sc-heads");
|
||||
const emptyEl = q(".sc-empty");
|
||||
|
||||
let data: any;
|
||||
try {
|
||||
const r = await fetch(api, { headers: { Accept: "application/json" } });
|
||||
if (!r.ok) return; // keep SSG skeleton
|
||||
data = await r.json();
|
||||
} catch {
|
||||
return; // offline/network → keep SSG skeleton
|
||||
}
|
||||
|
||||
const up = data.online === true;
|
||||
pill?.setAttribute("data-up", up ? "1" : "0");
|
||||
if (pillTxt) pillTxt.textContent = up ? (online ?? "Online") : (offline ?? "Offline");
|
||||
|
||||
if (!up) return; // offline pill set; leave the rest as the static fallback
|
||||
|
||||
// Live count + bar.
|
||||
const on = data.players?.online ?? 0;
|
||||
const max = data.players?.max ?? (Number(slots) || 0);
|
||||
if (onEl) onEl.textContent = String(on);
|
||||
if (maxEl) maxEl.textContent = String(max);
|
||||
if (bar && fill) {
|
||||
bar.hidden = false;
|
||||
fill.style.width = max > 0 ? `${Math.min(100, (on / max) * 100)}%` : "0%";
|
||||
}
|
||||
|
||||
// Real server favicon.
|
||||
if (favicon && typeof data.icon === "string" && data.icon.startsWith("data:image")) {
|
||||
favicon.src = data.icon;
|
||||
favicon.hidden = false;
|
||||
creeper?.setAttribute("hidden", "");
|
||||
}
|
||||
|
||||
// Color MOTD (mcstatus ships inline-styled spans for our own server).
|
||||
if (motdEl && data.motd?.html) motdEl.innerHTML = data.motd.html;
|
||||
|
||||
// Player heads from OUR NMSR (Drasl skins). Filter promo/placeholder rows.
|
||||
const list: Array<{ uuid?: string; name_clean?: string }> = data.players?.list ?? [];
|
||||
const seen = new Set<string>();
|
||||
const real = list.filter((p) => {
|
||||
const u = (p.uuid ?? "").toLowerCase();
|
||||
if (!u || u === ZERO_UUID || seen.has(u)) return false;
|
||||
seen.add(u);
|
||||
return true;
|
||||
});
|
||||
if (players && heads) {
|
||||
players.hidden = false;
|
||||
if (real.length === 0) {
|
||||
if (emptyEl) {
|
||||
emptyEl.textContent = empty ?? "Nobody online.";
|
||||
emptyEl.hidden = false;
|
||||
}
|
||||
} else {
|
||||
heads.innerHTML = "";
|
||||
for (const p of real.slice(0, 16)) {
|
||||
const img = document.createElement("img");
|
||||
img.loading = "lazy";
|
||||
img.alt = p.name_clean ?? "";
|
||||
img.title = p.name_clean ?? "";
|
||||
img.src = `${avatar}/${mode}/${p.uuid}?size=64`;
|
||||
heads.appendChild(img);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll<HTMLElement>(".servercard").forEach(hydrate);
|
||||
</script>
|
||||
@@ -4,6 +4,10 @@
|
||||
// BASE_DOMAIN is read at build time; falls back to ulicraft.local.
|
||||
const BASE_DOMAIN = process.env.BASE_DOMAIN ?? "ulicraft.local";
|
||||
|
||||
// REGISTRATION_MODE (invite|open) is read at build time, same .env as Drasl's
|
||||
// render-config.sh. "invite" => the register form must collect an invite code.
|
||||
const REGISTRATION_MODE = process.env.REGISTRATION_MODE ?? "invite";
|
||||
|
||||
// Minecraft / pack facts, surfaced in a few places.
|
||||
const MC_VERSION = "1.21.1";
|
||||
|
||||
@@ -12,6 +16,11 @@ export const site = {
|
||||
baseDomain: BASE_DOMAIN,
|
||||
mcVersion: MC_VERSION,
|
||||
|
||||
// Register form behaviour, driven by REGISTRATION_MODE in .env.
|
||||
// true => show the invite-code field (Drasl RequireInvite = true)
|
||||
// false => open self-registration
|
||||
registrationRequiresInvite: REGISTRATION_MODE === "invite",
|
||||
|
||||
// Design knobs (replace the design's in-browser TweaksPanel). Baked at build.
|
||||
// mood: "grass" | "nether" | "end"
|
||||
// hero: "centered" | "split" | "spotlight"
|
||||
@@ -24,24 +33,32 @@ export const site = {
|
||||
dust: true,
|
||||
},
|
||||
|
||||
// Connect target shown to guests. No port needed — a dnsmasq SRV record
|
||||
// (_minecraft._tcp.mc.<domain>) points clients at 25565.
|
||||
serverAddress: `mc.${BASE_DOMAIN}`,
|
||||
// Connect target shown to guests. No port needed (defaults to 25565).
|
||||
serverAddress: `${BASE_DOMAIN}`,
|
||||
|
||||
// Drasl auth web UI + authlib-injector API root.
|
||||
authUrl: `http://auth.${BASE_DOMAIN}`,
|
||||
authlibUrl: `http://auth.${BASE_DOMAIN}/authlib-injector`,
|
||||
// Drasl auth web UI + authlib-injector API root (public HTTPS).
|
||||
authUrl: `https://auth.${BASE_DOMAIN}`,
|
||||
authlibUrl: `https://auth.${BASE_DOMAIN}/authlib-injector`,
|
||||
// Drasl REST API v2 root — the /register form calls this from the browser.
|
||||
// Requires CORSAllowOrigins to include the apex in drasl config.toml.
|
||||
draslApiUrl: `https://auth.${BASE_DOMAIN}/drasl/api/v2`,
|
||||
|
||||
// packwiz modpack entrypoint.
|
||||
packwizUrl: `http://packwiz.${BASE_DOMAIN}/pack.toml`,
|
||||
// packwiz modpack entrypoint (public HTTPS, pack. subdomain).
|
||||
packwizUrl: `https://pack.${BASE_DOMAIN}/pack.toml`,
|
||||
|
||||
// Caddy local CA root — guests import this to trust the offline asset mirror.
|
||||
caCertUrl: `http://${BASE_DOMAIN}/ca.crt`,
|
||||
// NMSR avatar renderer root. Player heads come from OUR Drasl skins, not
|
||||
// Mojang/Crafatar — head URL is `${avatarUrl}/headiso/<uuid>?size=64`.
|
||||
avatarUrl: `https://avatar.${BASE_DOMAIN}`,
|
||||
|
||||
// Static server-list panel (Status section). No fake live count — see plan
|
||||
// 09-landing.md option B.
|
||||
// Live server card (ServerCard.astro). `slots` is the SSG fallback cap shown
|
||||
// before/without JS; live data comes from our SELF-HOSTED mc-status service
|
||||
// (docker/mc-status, mcutil wrapper) reverse-proxied same-origin at the apex
|
||||
// /api/mcstatus/* path — no CORS, no third-party calls. It emits mcstatus.io
|
||||
// v2 JSON, so the URL stays drop-in compatible. Uptime Kuma stays the history
|
||||
// + alerting backend (plan/10).
|
||||
status: {
|
||||
slots: 10,
|
||||
statusApi: `/api/mcstatus/v2/status/java/${BASE_DOMAIN}`,
|
||||
},
|
||||
|
||||
// Honest stat tiles. Keys (numbers/versions) are language-neutral; labels
|
||||
|
||||
@@ -13,6 +13,12 @@ export const LANGS: Lang[] = ["en", "es", "eu"];
|
||||
export const LANG_LABEL: Record<Lang, string> = { en: "EN", es: "ES", eu: "EU" };
|
||||
// URL path per locale (en is default, served at root).
|
||||
export const LANG_PATH: Record<Lang, string> = { en: "/", es: "/es/", eu: "/eu/" };
|
||||
// Register page path per locale (mirrors LANG_PATH: en at /register).
|
||||
export const LANG_REGISTER_PATH: Record<Lang, string> = {
|
||||
en: "/register",
|
||||
es: "/es/register",
|
||||
eu: "/eu/register",
|
||||
};
|
||||
|
||||
export interface UI {
|
||||
htmlLang: string;
|
||||
@@ -54,9 +60,6 @@ export interface UI {
|
||||
pb: string; // after it
|
||||
copy: string;
|
||||
registerPre: string;
|
||||
caPre: string;
|
||||
caLink: string;
|
||||
caPost: string;
|
||||
};
|
||||
s3: { kicker: string; h: string; p: string };
|
||||
s4: {
|
||||
@@ -68,6 +71,39 @@ export interface UI {
|
||||
};
|
||||
};
|
||||
footer: { join: string; disc: string };
|
||||
register: {
|
||||
navHome: string; // "← Home" link in nav
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
lead: string;
|
||||
usernameLabel: string;
|
||||
usernamePlaceholder: string;
|
||||
passwordLabel: string;
|
||||
passwordPlaceholder: string;
|
||||
inviteLabel: string;
|
||||
invitePlaceholder: string;
|
||||
inviteHint: string; // where to get a code
|
||||
submit: string;
|
||||
submitting: string;
|
||||
haveAccount: string; // "Already have an account?"
|
||||
loginLink: string; // links to the Drasl web UI
|
||||
successTitle: string;
|
||||
successBody: string; // {name} = player name
|
||||
uuidLabel: string;
|
||||
skinTitle: string;
|
||||
skinLead: string;
|
||||
skinChoose: string;
|
||||
modelLabel: string;
|
||||
modelClassic: string;
|
||||
modelSlim: string;
|
||||
skinUpload: string;
|
||||
skinUploading: string;
|
||||
skinOk: string;
|
||||
finish: string; // proceed to How to Join
|
||||
errFields: string;
|
||||
errSkinType: string;
|
||||
errNetwork: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const ui: Record<Lang, UI> = {
|
||||
@@ -137,9 +173,6 @@ export const ui: Record<Lang, UI> = {
|
||||
pb: "account using this URL, then log in with your Ulicraft credentials.",
|
||||
copy: "Copy",
|
||||
registerPre: "Need credentials? Register at",
|
||||
caPre: "Offline party? Download and trust the",
|
||||
caLink: "local CA certificate",
|
||||
caPost: "so the game-file mirror works over HTTPS.",
|
||||
},
|
||||
s3: {
|
||||
kicker: "Modpack",
|
||||
@@ -158,6 +191,39 @@ export const ui: Record<Lang, UI> = {
|
||||
join: "How to Join",
|
||||
disc: "Not affiliated with Mojang or Microsoft. Minecraft is a trademark of Mojang AB.",
|
||||
},
|
||||
register: {
|
||||
navHome: "← Home",
|
||||
eyebrow: "Account",
|
||||
title: "Create your Ulicraft account.",
|
||||
lead: "One username and password for the server, your skin, and your cape. Takes a minute.",
|
||||
usernameLabel: "Username",
|
||||
usernamePlaceholder: "your in-game name",
|
||||
passwordLabel: "Password",
|
||||
passwordPlaceholder: "choose a password",
|
||||
inviteLabel: "Invite code",
|
||||
invitePlaceholder: "paste your invite code",
|
||||
inviteHint: "Ask an admin for an invite code.",
|
||||
submit: "Create account",
|
||||
submitting: "Creating…",
|
||||
haveAccount: "Already have an account?",
|
||||
loginLink: "Manage it here",
|
||||
successTitle: "Account created.",
|
||||
successBody: "Welcome, {name}. You can set a skin now, or head straight to How to Join.",
|
||||
uuidLabel: "Player UUID",
|
||||
skinTitle: "Set your skin (optional)",
|
||||
skinLead: "Upload a Minecraft skin PNG. You can always change it later in the account page.",
|
||||
skinChoose: "Choose PNG…",
|
||||
modelLabel: "Model",
|
||||
modelClassic: "Classic",
|
||||
modelSlim: "Slim",
|
||||
skinUpload: "Upload skin",
|
||||
skinUploading: "Uploading…",
|
||||
skinOk: "Skin set!",
|
||||
finish: "Continue to How to Join",
|
||||
errFields: "Fill in every field.",
|
||||
errSkinType: "Pick a PNG image.",
|
||||
errNetwork: "Network error — try again.",
|
||||
},
|
||||
},
|
||||
|
||||
es: {
|
||||
@@ -226,9 +292,6 @@ export const ui: Record<Lang, UI> = {
|
||||
pb: "con esta URL y luego inicia sesión con tus credenciales de Ulicraft.",
|
||||
copy: "Copiar",
|
||||
registerPre: "¿Sin credenciales? Regístrate en",
|
||||
caPre: "¿Fiesta sin internet? Descarga y confía en el",
|
||||
caLink: "certificado CA local",
|
||||
caPost: "para que el mirror de archivos funcione por HTTPS.",
|
||||
},
|
||||
s3: {
|
||||
kicker: "Modpack",
|
||||
@@ -247,6 +310,39 @@ export const ui: Record<Lang, UI> = {
|
||||
join: "Cómo entrar",
|
||||
disc: "No afiliado a Mojang ni Microsoft. Minecraft es una marca de Mojang AB.",
|
||||
},
|
||||
register: {
|
||||
navHome: "← Inicio",
|
||||
eyebrow: "Cuenta",
|
||||
title: "Crea tu cuenta de Ulicraft.",
|
||||
lead: "Un usuario y contraseña para el servidor, tu skin y tu capa. Cosa de un minuto.",
|
||||
usernameLabel: "Usuario",
|
||||
usernamePlaceholder: "tu nombre en el juego",
|
||||
passwordLabel: "Contraseña",
|
||||
passwordPlaceholder: "elige una contraseña",
|
||||
inviteLabel: "Código de invitación",
|
||||
invitePlaceholder: "pega tu código de invitación",
|
||||
inviteHint: "Pide un código de invitación a un admin.",
|
||||
submit: "Crear cuenta",
|
||||
submitting: "Creando…",
|
||||
haveAccount: "¿Ya tienes cuenta?",
|
||||
loginLink: "Gestiónala aquí",
|
||||
successTitle: "Cuenta creada.",
|
||||
successBody: "Bienvenido, {name}. Puedes poner una skin ahora o ir directo a Cómo entrar.",
|
||||
uuidLabel: "UUID del jugador",
|
||||
skinTitle: "Pon tu skin (opcional)",
|
||||
skinLead: "Sube un PNG de skin de Minecraft. Siempre puedes cambiarla luego en tu cuenta.",
|
||||
skinChoose: "Elegir PNG…",
|
||||
modelLabel: "Modelo",
|
||||
modelClassic: "Clásico",
|
||||
modelSlim: "Fino",
|
||||
skinUpload: "Subir skin",
|
||||
skinUploading: "Subiendo…",
|
||||
skinOk: "¡Skin puesta!",
|
||||
finish: "Continuar a Cómo entrar",
|
||||
errFields: "Rellena todos los campos.",
|
||||
errSkinType: "Elige una imagen PNG.",
|
||||
errNetwork: "Error de red: inténtalo de nuevo.",
|
||||
},
|
||||
},
|
||||
|
||||
eu: {
|
||||
@@ -315,9 +411,6 @@ export const ui: Record<Lang, UI> = {
|
||||
pb: "kontu bat URL honekin, eta gero hasi saioa zure Ulicraft kredentzialekin.",
|
||||
copy: "Kopiatu",
|
||||
registerPre: "Kredentzialik ez? Erregistratu hemen:",
|
||||
caPre: "Internetik gabeko festa? Deskargatu eta fidatu",
|
||||
caLink: "tokiko CA ziurtagiriaz",
|
||||
caPost: "fitxategi-mirrorrak HTTPS bidez ibil dadin.",
|
||||
},
|
||||
s3: {
|
||||
kicker: "Modpacka",
|
||||
@@ -336,5 +429,38 @@ export const ui: Record<Lang, UI> = {
|
||||
join: "Nola sartu",
|
||||
disc: "Ez dago Mojang edo Microsoft-ekin lotuta. Minecraft Mojang AB-ren marka da.",
|
||||
},
|
||||
register: {
|
||||
navHome: "← Hasiera",
|
||||
eyebrow: "Kontua",
|
||||
title: "Sortu zure Ulicraft kontua.",
|
||||
lead: "Erabiltzaile eta pasahitz bat zerbitzarirako, zure azalerako eta kaparako. Minutu batean.",
|
||||
usernameLabel: "Erabiltzailea",
|
||||
usernamePlaceholder: "zure jokoko izena",
|
||||
passwordLabel: "Pasahitza",
|
||||
passwordPlaceholder: "aukeratu pasahitz bat",
|
||||
inviteLabel: "Gonbidapen-kodea",
|
||||
invitePlaceholder: "itsatsi zure gonbidapen-kodea",
|
||||
inviteHint: "Eskatu gonbidapen-kode bat admin bati.",
|
||||
submit: "Sortu kontua",
|
||||
submitting: "Sortzen…",
|
||||
haveAccount: "Baduzu kontua?",
|
||||
loginLink: "Kudeatu hemen",
|
||||
successTitle: "Kontua sortuta.",
|
||||
successBody: "Ongi etorri, {name}. Azala orain jar dezakezu, edo zuzenean Nola sartu atalera joan.",
|
||||
uuidLabel: "Jokalariaren UUIDa",
|
||||
skinTitle: "Jarri zure azala (aukerakoa)",
|
||||
skinLead: "Igo Minecraft azal PNG bat. Beti alda dezakezu gero zure kontuan.",
|
||||
skinChoose: "Aukeratu PNGa…",
|
||||
modelLabel: "Eredua",
|
||||
modelClassic: "Klasikoa",
|
||||
modelSlim: "Mehea",
|
||||
skinUpload: "Igo azala",
|
||||
skinUploading: "Igotzen…",
|
||||
skinOk: "Azala jarrita!",
|
||||
finish: "Jarraitu Nola sartura",
|
||||
errFields: "Bete eremu guztiak.",
|
||||
errSkinType: "Aukeratu PNG irudi bat.",
|
||||
errNetwork: "Sare-errorea: saiatu berriro.",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user