Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d21a7f0e92 | ||
|
|
df8ffca53e | ||
|
|
067e990306 | ||
|
|
673202e1e4 | ||
|
|
1ec3a9c1ee | ||
|
|
663b87a4e4 | ||
|
|
a2821f2bbc | ||
|
|
cfd60131fb | ||
|
|
95c63e1ee0 | ||
|
|
46103495fb | ||
|
|
1709bcd340 | ||
|
|
67d1f82e6a | ||
|
|
ff645de2c8 | ||
|
|
6df885eb13 | ||
|
|
6fac1647aa | ||
|
|
a6155819b6 | ||
|
|
3185fea4c1 | ||
|
|
f3c38da56c | ||
|
|
21e6080e78 |
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: deploy
|
name: deploy
|
||||||
description: Redeploy the Ulicraft Minecraft stack to the production host `cochi` — git pull on main then a hard restart (compose down + build-stack.sh --up online). Use when the user says "deploy", "redeploy", "deploy to cochi", "push to prod", "ship it", or "restart the server" for this project. Causes brief Minecraft downtime.
|
description: Redeploy the Ulicraft Minecraft stack to the production host `cochi` — git pull on main then a hard restart (compose down + up). Use when the user says "deploy", "redeploy", "deploy to cochi", "push to prod", "ship it", or "restart the server" for this project. Causes brief Minecraft downtime.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Deploy Ulicraft to `cochi`
|
# Deploy Ulicraft to `cochi`
|
||||||
@@ -13,8 +13,9 @@ Redeploy the running stack on the production host. Full reference:
|
|||||||
- Host: `cochi` (SSH alias)
|
- Host: `cochi` (SSH alias)
|
||||||
- Path: `/home/ubuntu/mc/ulicraft-server-v1`
|
- Path: `/home/ubuntu/mc/ulicraft-server-v1`
|
||||||
- Branch: `main`
|
- Branch: `main`
|
||||||
- Run mode: `online` → compose files `docker-compose.yml` + `docker-compose.static.yml`
|
- Stack: single unified `docker-compose.yml` (drasl, minecraft, mc-backup,
|
||||||
- Auth: Drasl (base stack)
|
caddy, nmsr, uptime-kuma)
|
||||||
|
- Auth: Drasl
|
||||||
- Restart: **hard** (down → up) — players are disconnected for a few minutes
|
- Restart: **hard** (down → up) — players are disconnected for a few minutes
|
||||||
|
|
||||||
## Procedure
|
## Procedure
|
||||||
@@ -35,32 +36,50 @@ invoking this skill is the authorization to proceed, but:
|
|||||||
ssh cochi 'set -e
|
ssh cochi 'set -e
|
||||||
cd /home/ubuntu/mc/ulicraft-server-v1
|
cd /home/ubuntu/mc/ulicraft-server-v1
|
||||||
git pull --ff-only
|
git pull --ff-only
|
||||||
docker compose -f docker-compose.yml -f docker-compose.static.yml down --remove-orphans
|
tooling/render-config.sh
|
||||||
tooling/build-stack.sh --up online'
|
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
|
- `--ff-only` refuses to merge — if the pull is not a fast-forward, STOP and
|
||||||
report (local changes on the host need manual resolution).
|
report (local changes on the host need manual resolution).
|
||||||
- `build-stack.sh --up online` renders configs (needs `.env` complete:
|
- `render-config.sh` re-renders drasl/nmsr/packwiz configs from `.env`
|
||||||
`BASE_DOMAIN`, `HOST_LAN_IP`, `RCON_PASSWORD`) and runs `compose up -d`.
|
(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.**
|
3. **Verify.**
|
||||||
```bash
|
```bash
|
||||||
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && docker compose -f docker-compose.yml -f docker-compose.static.yml ps'
|
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && docker compose ps'
|
||||||
```
|
```
|
||||||
Confirm `caddy`, `drasl`, `minecraft`, `mc-backup` are Up. Tail the minecraft
|
Confirm `caddy`, `drasl`, `minecraft`, `mc-backup`, `nmsr`, `uptime-kuma` are
|
||||||
log briefly and confirm it reaches `Done` (server ready):
|
Up. Tail the minecraft log briefly and confirm it reaches `Done` (server
|
||||||
|
ready):
|
||||||
```bash
|
```bash
|
||||||
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && timeout 120 docker compose -f docker-compose.yml -f docker-compose.static.yml logs -f minecraft' 2>/dev/null | grep -m1 "Done"
|
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && timeout 120 docker compose logs -f minecraft' 2>/dev/null | grep -m1 "Done"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Guardrails
|
## Guardrails
|
||||||
|
|
||||||
- Never `git reset --hard` or discard changes on `cochi` without telling the user
|
- Never `git reset --hard` or discard changes on `cochi` without telling the user
|
||||||
first — there may be host-local edits.
|
first — there may be host-local edits.
|
||||||
- Volumes (`mc_data`, `drasl_state`) persist across `down`; the world is safe. Do
|
- Volumes (`mc_data`, `drasl_state`, `kuma_data`) persist across `down`; the
|
||||||
NOT pass `-v`/`--volumes` to `down`.
|
world and monitors are safe. Do NOT pass `-v`/`--volumes` to `down`.
|
||||||
- If a step fails, STOP and surface the exact error + the failing command. Do not
|
- If a step fails, STOP and surface the exact error + the failing command. Do not
|
||||||
improvise recovery on production.
|
improvise recovery on production.
|
||||||
- Blessing Skin variant or `airgap` mode changes the compose file set — if the
|
|
||||||
user asks for those, follow `plan/14-deploy.md` instead of the fixed commands
|
|
||||||
above.
|
|
||||||
+20
-35
@@ -1,35 +1,30 @@
|
|||||||
# Copy to .env and fill in real values
|
# Copy to .env and fill in real values
|
||||||
|
|
||||||
# Single base domain for the whole stack; every service is a subdomain of this.
|
# Single base domain for the whole stack; every service is a subdomain of this.
|
||||||
# Use .lan (NOT .local) — .local is intercepted by mDNS on macOS/Linux and
|
# DNS is handled OUTSIDE this repo — point ${BASE_DOMAIN} and every subdomain
|
||||||
# won't resolve through a normal unicast DNS server.
|
# (auth. pack. avatar. status. distribution. www.) at the host running nginx.
|
||||||
BASE_DOMAIN=ulicraft.lan
|
BASE_DOMAIN=ulicraft.net
|
||||||
# The Docker host's LAN IP — every service name resolves here.
|
|
||||||
HOST_LAN_IP=192.168.1.10
|
|
||||||
|
|
||||||
# mDNS toggle. true + BASE_DOMAIN=*.local → an Avahi container publishes the
|
|
||||||
# service names over mDNS (zero-config for macOS/Linux guests; Windows needs
|
|
||||||
# Bonjour). false → use your own party DNS (records: tooling/dns-records.sh).
|
|
||||||
# NOTE: mDNS resolves ONLY *.local; it cannot serve the air-gap upstream-host
|
|
||||||
# spoofs — those always need a real DNS server.
|
|
||||||
ENABLE_MDNS=false
|
|
||||||
|
|
||||||
RCON_PASSWORD=change-me-to-something-random
|
RCON_PASSWORD=change-me-to-something-random
|
||||||
|
|
||||||
# Caddy host-published port (docker-compose.caddy.yml). Default 80 for the
|
# Registration mode for self-hosted accounts (Drasl) + landing register form.
|
||||||
# standalone LAN/air-gap path. When the machine's own nginx fronts caddy
|
# invite = closed; guests need an admin-minted invite code (recommended on a
|
||||||
# (nginx terminates TLS → reverse-proxies to caddy), set a high port and bind
|
# public, internet-present host). Admin mints via POST /drasl/api/v2/invites.
|
||||||
# it to localhost so only nginx reaches it:
|
# open = anyone can self-register with username+password (set [RateLimit]!).
|
||||||
# CADDY_HTTP_PORT=8880
|
# Consumed by BOTH render-config.sh (-> Drasl RegistrationNewPlayer.RequireInvite)
|
||||||
# CADDY_HTTP_BIND=127.0.0.1
|
# and the landing build (-> show/hide the invite-code field). Default: invite.
|
||||||
# Then render nginx/ulicraft-caddy.conf.tmpl with CADDY_HTTP_PORT.
|
REGISTRATION_MODE=invite
|
||||||
CADDY_HTTP_PORT=80
|
|
||||||
CADDY_HTTP_BIND=0.0.0.0
|
|
||||||
|
|
||||||
# distribution.${BASE_DOMAIN} static vhost (docker-compose.distribution.yml).
|
# caddy host-published port. The host's own nginx terminates TLS and
|
||||||
# Absolute host path to the built static site — it lives in ANOTHER repo.
|
# reverse-proxies to caddy by Host header (nginx/ulicraft-caddy.conf.tmpl), so
|
||||||
# Bind-mounted read-only into caddy and served at distribution.${BASE_DOMAIN}.
|
# bind caddy to a high localhost-only port — only nginx should reach it.
|
||||||
|
CADDY_HTTP_PORT=8880
|
||||||
|
CADDY_HTTP_BIND=127.0.0.1
|
||||||
|
|
||||||
|
# distribution.${BASE_DOMAIN} static vhost. Absolute host path to the built
|
||||||
|
# static site — it lives in ANOTHER repo. Bind-mounted read-only into caddy.
|
||||||
DISTRIBUTION_WEB_ROOT=/home/oier/projects/mc-mods/ulicraft-group/ulicraft-distribution/dist
|
DISTRIBUTION_WEB_ROOT=/home/oier/projects/mc-mods/ulicraft-group/ulicraft-distribution/dist
|
||||||
|
|
||||||
# Only needed if using CurseForge-exclusive mods:
|
# Only needed if using CurseForge-exclusive mods:
|
||||||
# CF_API_KEY=your-curseforge-api-key
|
# CF_API_KEY=your-curseforge-api-key
|
||||||
|
|
||||||
@@ -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.
|
# Only needed to issue real TLS certs. DNS-01 needs no inbound ports.
|
||||||
LE_EMAIL=you@example.com
|
LE_EMAIL=you@example.com
|
||||||
# space-separated subdomains; apex ${BASE_DOMAIN} is always included
|
# space-separated subdomains; apex ${BASE_DOMAIN} is always included
|
||||||
LE_SUBDOMAINS=auth pack distribution
|
LE_SUBDOMAINS=auth pack distribution www avatar status
|
||||||
# OVH API creds (DNS zone write). Create at https://eu.api.ovh.com/createToken/
|
# OVH API creds (DNS zone write). Create at https://eu.api.ovh.com/createToken/
|
||||||
# OVH_CK can be blank on first run — acme.sh prints an auth URL, then paste it.
|
# OVH_CK can be blank on first run — acme.sh prints an auth URL, then paste it.
|
||||||
OVH_END_POINT=ovh-eu
|
OVH_END_POINT=ovh-eu
|
||||||
@@ -45,13 +40,3 @@ OVH_AK=
|
|||||||
OVH_AS=
|
OVH_AS=
|
||||||
OVH_CK=
|
OVH_CK=
|
||||||
# LE_STAGING=1 # use the untrusted staging CA for dry runs
|
# LE_STAGING=1 # use the untrusted staging CA for dry runs
|
||||||
|
|
||||||
# ── Blessing Skin auth variant (docker-compose.blessingskin.yml) ──────
|
|
||||||
# Only needed when running the Blessing Skin override instead of Drasl.
|
|
||||||
BS_DB_NAME=blessingskin
|
|
||||||
BS_DB_USER=blessingskin
|
|
||||||
BS_DB_PASSWORD=change-me-bs-db
|
|
||||||
BS_DB_ROOT_PASSWORD=change-me-bs-root
|
|
||||||
# Generate once and paste here so it survives container recreates:
|
|
||||||
# docker run --rm azusamikan/blessing-skin-server-docker:latest php artisan key:generate --show
|
|
||||||
BS_APP_KEY=base64:replace-with-generated-key
|
|
||||||
+3
-6
@@ -5,15 +5,15 @@ runtime/authlib-injector.jar
|
|||||||
|
|
||||||
# rendered configs (from tooling/render-config.sh)
|
# rendered configs (from tooling/render-config.sh)
|
||||||
drasl/config/config.toml
|
drasl/config/config.toml
|
||||||
dnsmasq/dnsmasq.conf
|
nmsr/config.toml
|
||||||
# packwiz pack files rendered from pack/**/*.tmpl by tooling/render-pack.sh
|
# packwiz pack files rendered from pack/**/*.tmpl by tooling/render-pack.sh
|
||||||
# (domain-dependent build output); source of truth is the .tmpl + custom/ jars.
|
# (domain-dependent build output); source of truth is the .tmpl + custom/ jars.
|
||||||
pack/mods/*.pw.toml
|
pack/mods/*.pw.toml
|
||||||
pack/index.toml
|
pack/index.toml
|
||||||
pack/CustomSkinLoader/CustomSkinLoader.json
|
pack/CustomSkinLoader/CustomSkinLoader.json
|
||||||
|
|
||||||
# vendored / mirrored binaries
|
# vendored binaries
|
||||||
mirror/
|
launcher/
|
||||||
pack/mods-files/
|
pack/mods-files/
|
||||||
|
|
||||||
# landing page: generated build output + deps
|
# landing page: generated build output + deps
|
||||||
@@ -22,8 +22,5 @@ landing/node_modules/
|
|||||||
landing/.astro/
|
landing/.astro/
|
||||||
landing/dist/
|
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)
|
# Let's Encrypt certs + private keys (issued by tooling/issue-letsencrypt.sh)
|
||||||
certs/
|
certs/
|
||||||
@@ -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
|
> Self-hosted modded Minecraft (NeoForge 1.21.1) with self-hosted auth/skins
|
||||||
> persistent auth/skin infrastructure designed to outlive the LAN weekend
|
> (Drasl), a packwiz modpack, avatar rendering, a guest landing page, and uptime
|
||||||
> and integrate with the existing homelab.
|
> monitoring. Public, internet-present deployment behind the host's nginx + TLS.
|
||||||
|
|
||||||
|
**`plan/` is the source of truth for the design.** Start at `plan/00-overview.md`.
|
||||||
|
This file is a fast orientation + the durable decisions/gotchas; when it disagrees
|
||||||
|
with `plan/` or the code, those win.
|
||||||
|
|
||||||
## Goals & Constraints
|
## Goals & Constraints
|
||||||
|
|
||||||
- **Players**: 4–10 friends, LAN-based
|
- **Players**: 4–10 friends
|
||||||
- **Modpack vibe**: Kitchen-sink (tech + magic + exploration + QoL)
|
- **Modpack vibe**: Kitchen-sink (tech + magic + exploration + QoL)
|
||||||
- **Pack approach**: Manually curated (~50–100 mods), NOT a forked existing pack
|
- **Pack approach**: Manually curated (~50–100 mods), NOT a forked existing pack
|
||||||
- **Minecraft version**: 1.21.1
|
- **Minecraft version**: 1.21.1, **NeoForge** (not classic Forge)
|
||||||
- **Mod loader**: NeoForge (NOT classic Forge — see Decisions below)
|
- **Auth/skins**: Drasl (Yggdrasil-compatible), **password login** — no OIDC
|
||||||
- **Network**: LAN party context, but stack is persistent (lives past the weekend)
|
- **DNS**: handled OUTSIDE this repo; point `${BASE_DOMAIN}` + subdomains at the host
|
||||||
- **Auth**: Self-hosted Yggdrasil-compatible (Drasl) + Keycloak OIDC
|
- **Persistence horizon**: long-term service, not disposable
|
||||||
- **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`.
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
One unified `docker-compose.yml`. The host's own nginx terminates TLS (Let's
|
||||||
LAN segment
|
Encrypt) and reverse-proxies every vhost to caddy (published localhost-only) by
|
||||||
│
|
Host header. caddy is the internal router; containers reach the stack's own names
|
||||||
├── AdGuard Home → resolves drasl.home.local, keycloak.home.local
|
via caddy's `mcnet` aliases (`auth.`, `pack.`).
|
||||||
│ to the Docker host's LAN IP
|
|
||||||
│
|
|
||||||
├── Keycloak (existing, separate compose)
|
|
||||||
│ realm: homelab
|
|
||||||
│ client: drasl (confidential, PKCE S256)
|
|
||||||
│
|
|
||||||
└── Minecraft host (this project's compose)
|
|
||||||
│
|
|
||||||
├── drasl :25585 (Yggdrasil API + web UI)
|
|
||||||
│ └── OIDC → Keycloak
|
|
||||||
│
|
|
||||||
├── minecraft :25565 (server)
|
|
||||||
│ :24454/udp (Simple Voice Chat, optional)
|
|
||||||
│ └── -javaagent:authlib-injector.jar=http://drasl.home.local:25585/authlib-injector
|
|
||||||
│ └── ONLINE_MODE=false (required for authlib-injector)
|
|
||||||
│
|
|
||||||
└── mc-backup (itzg/mc-backup, 6h interval)
|
|
||||||
└── RCON → minecraft
|
|
||||||
|
|
||||||
Clients (LAN party guests):
|
```
|
||||||
Prism Launcher → authlib-injector account pointing at Drasl
|
Internet ── host nginx (TLS, LE certs, HSTS) ──► caddy (127.0.0.1:8880)
|
||||||
→ joins minecraft:25565
|
│ routes by Host:
|
||||||
|
${BASE_DOMAIN} ─► /srv/www landing + /launcher/ downloads
|
||||||
|
auth.${BASE_DOMAIN} ─► drasl (Yggdrasil API + web UI)
|
||||||
|
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.
|
## Key Decisions
|
||||||
2. Clicks "Register with Keycloak" → redirected to Keycloak login.
|
|
||||||
3. Logs in with homelab Keycloak credentials.
|
- **NeoForge over Forge** — the 1.21.x kitchen-sink ecosystem is overwhelmingly
|
||||||
4. Returned to Drasl, picks a player name, uploads a skin.
|
NeoForge; the Forge team migrated. `itzg/minecraft-server` via `TYPE=NEOFORGE`.
|
||||||
5. Drasl shows them a "Minecraft Token" on their profile page.
|
- **Drasl over Ely.by / vanilla offline** — self-hosted Go service, Yggdrasil
|
||||||
6. In Prism Launcher: Settings → Accounts → Add authlib-injector account
|
drop-in, skins + capes, actively maintained. Run with **password login**
|
||||||
with URL `http://drasl.home.local:25585/authlib-injector` and the
|
(no OIDC/Keycloak in this stack).
|
||||||
Minecraft Token as password.
|
- **Manual curation** (user choice, flagged expensive) — if it gets painful,
|
||||||
7. Joins Minecraft server. Server validates session against Drasl (via
|
fork-and-trim Leaking Kitchen Sink is still on the table (closest vibe match).
|
||||||
authlib-injector JVM agent), Drasl confirms, player enters with their skin.
|
- **itzg/minecraft-server** base image — de-facto standard, auto-installs NeoForge.
|
||||||
|
- **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
|
## Critical Gotchas
|
||||||
|
|
||||||
### Minecraft 1.21+ secure profile incompatibility
|
### Minecraft 1.21+ secure profile
|
||||||
- Server: must set `enforce-secure-profile=false` (compose: `ENFORCE_SECURE_PROFILE=FALSE`)
|
- Server: `ENFORCE_SECURE_PROFILE=FALSE` (`enforce-secure-profile=false`)
|
||||||
- Drasl: must set `SignPublicKeys = false`
|
- Drasl: `SignPublicKeys = false`
|
||||||
- These are linked. The Drasl docs explicitly warn: *"Mixed authentication does
|
- Linked. Drasl docs: *"Mixed authentication does not work with
|
||||||
not work with `SignPublicKeys = true` on Minecraft 1.21+."*
|
`SignPublicKeys = true` on Minecraft 1.21+."*
|
||||||
|
|
||||||
### authlib-injector JVM agent
|
### authlib-injector JVM agent
|
||||||
- Syntax: `-javaagent:/path/to/authlib-injector.jar=<yggdrasil-api-url>`
|
- Syntax: `-javaagent:/extras/authlib-injector.jar=<yggdrasil-api-url>`
|
||||||
- The URL after `=` is the **API root**, which for Drasl is
|
- URL after `=` is the **API root** = `<BaseURL>/authlib-injector`. For this
|
||||||
`<BaseURL>/authlib-injector`.
|
stack: `http://auth.${BASE_DOMAIN}/authlib-injector` (internal, over mcnet).
|
||||||
- **Must match between server and client.** If server uses
|
- **Must match between server and client** (clients use the public
|
||||||
`http://drasl.home.local:25585/authlib-injector` and client uses anything
|
`https://auth.${BASE_DOMAIN}/authlib-injector`), else session validation fails
|
||||||
else (e.g. an IP), session validation fails silently with "Invalid session".
|
silently with "Invalid session".
|
||||||
- Download from: https://github.com/yushijinhun/authlib-injector/releases
|
- Releases: https://github.com/yushijinhun/authlib-injector — jar lives in
|
||||||
- Mount into the itzg container at `/extras/authlib-injector.jar`.
|
`runtime/` (mounted at `/extras/authlib-injector.jar`).
|
||||||
|
|
||||||
### Keycloak reachability on LAN day
|
|
||||||
If Keycloak is only reachable via NetBird or only from the public internet,
|
|
||||||
**Drasl OIDC registration breaks on LAN-only day**. Options:
|
|
||||||
1. Expose Keycloak on the LAN (simplest)
|
|
||||||
2. Pre-register all guests before the party
|
|
||||||
3. Temporarily disable OIDC and allow password registration during the LAN
|
|
||||||
(`AllowPasswordLogin = true`, comment out the OIDC block)
|
|
||||||
|
|
||||||
### HTTP vs HTTPS for OIDC
|
|
||||||
Modern browsers warn on plain HTTP for OIDC flows but they work. For a
|
|
||||||
production-grade setup, front Drasl with Caddy or Traefik using a local CA
|
|
||||||
cert (smallstep/step-ca pairs well with the existing homelab). For LAN-only
|
|
||||||
HTTP is fine.
|
|
||||||
|
|
||||||
### NeoForge version pinning
|
### NeoForge version pinning
|
||||||
Mods are picky about exact NeoForge minor versions. The compose currently
|
Mods are picky about exact minor versions. Compose pins `NEOFORGE_VERSION:
|
||||||
pins `NEOFORGE_VERSION: "21.1.209"` as a placeholder. **Verify against
|
"21.1.209"`. **Verify against https://projects.neoforged.net/neoforged/neoforge
|
||||||
https://projects.neoforged.net/neoforged/neoforge before deploying.** Don't
|
before deploying.** Never `"latest"` for a stable server.
|
||||||
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.
|
|
||||||
|
|
||||||
### Mod source preference
|
### Mod source preference
|
||||||
Prefer **Modrinth over CurseForge** for itzg auto-download. CurseForge
|
Prefer **Modrinth over CurseForge** (`packwiz modrinth add <slug>`). CF needs
|
||||||
requires an API key (`CF_API_KEY`) and has been historically flakier with
|
`CF_API_KEY` and is flakier. Tag client-only mods `side = "client"` so the server
|
||||||
the itzg image. Use CF only for mods that are exclusive to it.
|
doesn't pull and crash on them.
|
||||||
|
|
||||||
## 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
|
|
||||||
```
|
|
||||||
|
|
||||||
### Memory sizing
|
### Memory sizing
|
||||||
With ~50–100 mods and 8 concurrent players on 1.21.1:
|
~50–100 mods, 8 players, 1.21.1: `INIT_MEMORY: 4G`, `MAX_MEMORY: 10G`,
|
||||||
- `INIT_MEMORY: 4G`, `MAX_MEMORY: 10G` is a safe starting point
|
`USE_AIKAR_FLAGS: TRUE`.
|
||||||
- Use `USE_AIKAR_FLAGS: TRUE` for the well-known GC tuning
|
|
||||||
- Monitor with `mc-monitor` (itzg makes one) if you want metrics
|
|
||||||
|
|
||||||
### Backups
|
## Config (.env)
|
||||||
`itzg/mc-backup` runs alongside, talks to the server via RCON, takes
|
|
||||||
snapshots every 6h, prunes after 14 days. Backups are world-only (no mod
|
|
||||||
jars), which is correct — mods are reproducible from the mod list files.
|
|
||||||
|
|
||||||
## Keycloak Client Configuration
|
`BASE_DOMAIN`, `RCON_PASSWORD`, `CADDY_HTTP_PORT`/`CADDY_HTTP_BIND`,
|
||||||
|
`DISTRIBUTION_WEB_ROOT`, 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**
|
Launcher: **FjordLauncherUnlocked** (Prism fork, first-class authlib-injector),
|
||||||
- Client type: `OpenID Connect`
|
mirrored at apex `/launcher/`. Per guest:
|
||||||
- Client ID: `drasl`
|
1. Download from `https://${BASE_DOMAIN}` → `/launcher/`.
|
||||||
- Name: `Drasl Minecraft Auth`
|
2. Add an authlib-injector account, URL
|
||||||
|
`https://auth.${BASE_DOMAIN}/authlib-injector` (register/login at
|
||||||
2. **Capability config**
|
`https://auth.${BASE_DOMAIN}`).
|
||||||
- Client authentication: **ON** (confidential client)
|
3. Import the pack `https://pack.${BASE_DOMAIN}/pack.toml`.
|
||||||
- Authentication flow: **Standard flow** only
|
4. Connect to `${BASE_DOMAIN}` (`:25565`).
|
||||||
- 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
|
|
||||||
|
|
||||||
## Open / Pending Work
|
## Open / Pending Work
|
||||||
|
|
||||||
These were on the roadmap when we ended the brainstorm:
|
- [ ] **Mod shortlist** for the kitchen-sink pack: Performance (Embeddium,
|
||||||
|
FerriteCore, ModernFix), Tech (Mekanism, Create, IE, AE2), Magic (Ars Nouveau,
|
||||||
- [ ] **Mod shortlist for the kitchen-sink pack.** Categories to fill:
|
Botania, Iron's Spells), Storage (Sophisticated, Functional), Exploration
|
||||||
- Performance (Embeddium/Sodium-equivalent for NeoForge, FerriteCore, ModernFix)
|
(YUNG's, Repurposed Structures), QoL (JEI, Jade, IPN, AppleSkin), World gen
|
||||||
- Tech/automation (Mekanism, Create, Immersive Engineering, AE2)
|
(Tectonic, Terralith — verify NeoForge 1.21.1), Compat (Polymorph), Map (Xaero),
|
||||||
- Magic (Ars Nouveau, Botania, Iron's Spells 'n Spellbooks)
|
Voice (Simple Voice Chat — 24454/udp already in compose).
|
||||||
- Storage (Sophisticated Storage/Backpacks, Functional Storage)
|
- [ ] Server-side perf mods (C2ME, etc.).
|
||||||
- Exploration (YUNG's structures, Repurposed Structures, biome packs)
|
- [ ] **Verify NeoForge version** against current stable before first deploy.
|
||||||
- QoL (JEI, Jade, JEI Resources, Inventory Profiles Next, AppleSkin)
|
- [ ] **Bootstrap Drasl admin** account.
|
||||||
- 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
|
|
||||||
|
|
||||||
## Reference URLs
|
## Reference URLs
|
||||||
|
|
||||||
- Drasl repo: https://github.com/unmojang/drasl
|
- Drasl: https://github.com/unmojang/drasl — config docs:
|
||||||
- Drasl configuration docs: https://github.com/unmojang/drasl/blob/master/doc/configuration.md
|
https://github.com/unmojang/drasl/blob/master/doc/configuration.md
|
||||||
- Drasl recipes (example configs): https://github.com/unmojang/drasl/blob/master/doc/recipes.md
|
|
||||||
- authlib-injector: https://github.com/yushijinhun/authlib-injector
|
- authlib-injector: https://github.com/yushijinhun/authlib-injector
|
||||||
- itzg/minecraft-server: https://github.com/itzg/docker-minecraft-server
|
- itzg/minecraft-server: https://github.com/itzg/docker-minecraft-server
|
||||||
- itzg NeoForge docs: https://github.com/itzg/docker-minecraft-server/blob/master/docs/types-and-platforms/server-types/forge.md
|
(NeoForge: …/docs/types-and-platforms/server-types/forge.md)
|
||||||
- itzg/mc-backup: https://github.com/itzg/docker-mc-backup
|
- itzg/mc-backup: https://github.com/itzg/docker-mc-backup
|
||||||
- NeoForge versions: https://projects.neoforged.net/neoforged/neoforge
|
- NeoForge versions: https://projects.neoforged.net/neoforged/neoforge
|
||||||
- Prism Launcher: https://prismlauncher.org/
|
- NMSR: https://github.com/NickAcPT/nmsr-rs
|
||||||
- Reference modpacks for inspiration (NOT forking):
|
- Uptime Kuma: https://github.com/louislam/uptime-kuma
|
||||||
- ATM10 (Modrinth/CurseForge — ~500 mods, NeoForge 1.21.1)
|
- FjordLauncherUnlocked: https://github.com/hero-persson/FjordLauncherUnlocked
|
||||||
- Leaking Kitchen Sink (CurseForge — trimmed ~150 mods, NeoForge 1.21.1)
|
- Reference packs (inspiration, NOT forking): ATM10, Leaking Kitchen Sink.
|
||||||
|
|
||||||
## Communication Preferences (carry-over from past sessions)
|
## Communication Preferences (carry-over)
|
||||||
|
|
||||||
- Concise and direct
|
- Concise and direct; diagnose + resolve without demanding repro steps.
|
||||||
- Diagnose and resolve without demanding detailed reproduction steps
|
- Prefer iterative single-change updates over large rewrites.
|
||||||
- Prefer iterative single-change updates over large rewrites
|
- Cautious scripts that halt on ambiguity rather than proceeding silently.
|
||||||
- Cautious scripts that halt on ambiguity rather than proceeding silently
|
- Avoid over-engineering when a simple answer suffices.
|
||||||
- Avoid over-engineered responses when a simple answer suffices
|
- Spanish or English both fine.
|
||||||
- Spanish or English fine; user is comfortable in both
|
|
||||||
@@ -1,111 +1,159 @@
|
|||||||
# Ulicraft Server
|
# Ulicraft Server
|
||||||
|
|
||||||
Self-hosted modded Minecraft (NeoForge 1.21.1) for a LAN party. Runs **online**
|
Self-hosted modded Minecraft (NeoForge 1.21.1) with self-hosted auth/skins
|
||||||
(internet present) or **fully air-gapped** (offline). Self-hosted auth/skins
|
(Drasl), a packwiz modpack, avatar rendering, a guest landing page, and uptime
|
||||||
(Drasl), a packwiz modpack, a guest landing page, and a transparent mirror of
|
monitoring. One unified `docker-compose.yml` runs the whole stack behind the
|
||||||
Mojang/launcher/NeoForge assets so guest laptops need no internet.
|
host's nginx + Let's Encrypt.
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
|
One compose file, one `docker compose up -d`:
|
||||||
|
|
||||||
| Service | Image | Role |
|
| Service | Image | Role |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `minecraft` | itzg/minecraft-server | NeoForge 1.21.1 server, `:25565` |
|
| `minecraft` | itzg/minecraft-server | NeoForge 1.21.1 server, `:25565` |
|
||||||
| `drasl` | unmojang/drasl | Yggdrasil auth + skins (password login) |
|
| `drasl` | unmojang/drasl | Yggdrasil auth + skins (password login) |
|
||||||
| `caddy` | caddy:alpine | ingress: landing, auth/packwiz proxy, asset mirror (`tls internal`) |
|
| `caddy` | caddy:alpine | internal ingress: routes every vhost by Host header |
|
||||||
|
| `nmsr` | built from source | skin/avatar renderer at `avatar.${BASE_DOMAIN}` |
|
||||||
|
| `uptime-kuma` | louislam/uptime-kuma | status page at `status.${BASE_DOMAIN}` |
|
||||||
| `mc-backup` | itzg/mc-backup | world backups every 6h via RCON |
|
| `mc-backup` | itzg/mc-backup | world backups every 6h via RCON |
|
||||||
|
|
||||||
Optional layers: `avahi` (mDNS `.local`), `dnsmasq` (turnkey DNS) — only if you
|
Vhosts (all proxied through the host nginx → caddy):
|
||||||
don't run your own DNS.
|
|
||||||
|
|
||||||
Config lives in `.env`: `BASE_DOMAIN` (default `ulicraft.lan`), `HOST_LAN_IP`,
|
- apex `${BASE_DOMAIN}` — landing page + `/launcher/` downloads
|
||||||
`RCON_PASSWORD`, `ENABLE_MDNS`. Services are subdomains: `auth.`, `packwiz.`,
|
- `auth.` — Drasl
|
||||||
`mc.`, plus the apex landing page.
|
- `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
|
Handled **outside this repo**. Point `${BASE_DOMAIN}` and every subdomain
|
||||||
tooling/dns-records.sh online # service names only (internet present)
|
(`auth. pack. avatar. status. distribution. www.`) at the host running nginx.
|
||||||
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`.
|
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
Docker + Docker Compose; for prep also `pnpm`, `packwiz`, `jq`, `curl`,
|
Docker + Docker Compose; for prep also `pnpm`, `packwiz`, `jq`, `curl`,
|
||||||
`envsubst`, `sha1sum`.
|
`envsubst`.
|
||||||
|
|
||||||
## Launch
|
## Launch
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cp .env.example .env # set BASE_DOMAIN, HOST_LAN_IP, RCON_PASSWORD
|
cp .env.example .env # set BASE_DOMAIN, RCON_PASSWORD, DISTRIBUTION_WEB_ROOT
|
||||||
|
|
||||||
tooling/build-stack.sh --prep # ONLINE, once: render, build landing,
|
# One-time / on change — render configs + build content:
|
||||||
# mirror mods+assets, fetch launcher +
|
tooling/render-config.sh # drasl + nmsr + packwiz configs from *.tmpl
|
||||||
# authlib, pre-bake the server volume
|
( cd landing && pnpm install && BASE_DOMAIN="$BASE_DOMAIN" pnpm run build ) # → www/
|
||||||
|
tooling/fetch-launcher.sh # FjordLauncher assets → launcher/ (served at /launcher/)
|
||||||
|
tooling/fetch-authlib.sh # authlib-injector.jar → runtime/ (server JVM agent)
|
||||||
|
|
||||||
tooling/build-stack.sh --up online # internet present, no mirror
|
docker compose up -d --build # first run installs NeoForge + mods, builds nmsr
|
||||||
tooling/build-stack.sh --up airgap # offline; full asset mirror on :443 (default)
|
docker compose down # stop
|
||||||
tooling/build-stack.sh --up core # base only (debugging)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`--up` prints the DNS records to add for that mode. Down:
|
First boot: itzg installs NeoForge + the packwiz mods into the `mc_data` volume;
|
||||||
`docker compose -f docker-compose.yml -f docker-compose.static.yml -f docker-compose.mirror.yml down`
|
nmsr does a one-time Rust compile. Watch `docker compose logs -f minecraft` until
|
||||||
|
`Done`.
|
||||||
|
|
||||||
|
## Public TLS (host nginx in front of caddy)
|
||||||
|
|
||||||
|
caddy is published on a localhost-only port (`CADDY_HTTP_BIND=127.0.0.1`,
|
||||||
|
`CADDY_HTTP_PORT=8880`). The host's own nginx terminates TLS with Let's Encrypt
|
||||||
|
certs and reverse-proxies every vhost to caddy by Host header. HTTPS-only: HTTP
|
||||||
|
301s to HTTPS and every TLS vhost sends HSTS.
|
||||||
|
|
||||||
|
1. **Issue certs** (OVH DNS-01, no inbound ports needed):
|
||||||
|
```sh
|
||||||
|
# .env: BASE_DOMAIN, LE_EMAIL, OVH_* creds,
|
||||||
|
# LE_SUBDOMAINS="auth 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)
|
## Join (guests)
|
||||||
|
|
||||||
1. Open `http://ulicraft.lan`, download FjordLauncherUnlocked for your OS.
|
1. Open `https://${BASE_DOMAIN}`, download FjordLauncherUnlocked for your OS.
|
||||||
2. Add an **authlib-injector** account, URL `http://auth.ulicraft.lan/authlib-injector`
|
2. Add an **authlib-injector** account, URL
|
||||||
(register/login at `http://auth.ulicraft.lan`).
|
`https://auth.${BASE_DOMAIN}/authlib-injector` (register/login at
|
||||||
3. **Air-gap only:** trust the local CA — download `http://ulicraft.lan/ca.crt`
|
`https://auth.${BASE_DOMAIN}`).
|
||||||
and add it to your OS trust store (so the HTTPS asset mirror is trusted).
|
3. Import the pack: `https://pack.${BASE_DOMAIN}/pack.toml`.
|
||||||
4. Import the pack: `http://packwiz.ulicraft.lan/pack.toml`.
|
4. Connect to **`${BASE_DOMAIN}`** (Minecraft, `:25565`).
|
||||||
5. Connect to **`mc.ulicraft.lan`** (no port needed).
|
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
docker-compose.yml # core: drasl, minecraft, mc-backup, caddy
|
docker-compose.yml # the whole stack (one file)
|
||||||
.static.yml .mirror.yml # online/airgap layers
|
caddy/Caddyfile + conf.d/*.caddy # ingress vhost snippets
|
||||||
.avahi.yml .dnsmasq.yml # optional DNS layers
|
# (core/static/distribution/avatar/status)
|
||||||
caddy/Caddyfile + conf.d/*.caddy # ingress vhost snippets (core/static/mirror)
|
|
||||||
drasl/config/config.toml.tmpl # auth config (rendered)
|
drasl/config/config.toml.tmpl # auth config (rendered)
|
||||||
dnsmasq/dnsmasq.conf.tmpl # optional DNS config (rendered)
|
nmsr/config.toml.tmpl # avatar renderer config, Drasl-backed (rendered)
|
||||||
docker/avahi/ # mDNS responder image
|
docker/nmsr/ # built-from-source NMSR image
|
||||||
pack/ # packwiz modpack source
|
pack/ # packwiz modpack source
|
||||||
landing/ # Astro site → www/
|
landing/ # Astro site → www/
|
||||||
tooling/ # build-stack, render-config, dns-records,
|
launcher/ # FjordLauncher assets (gitignored)
|
||||||
# mirror-mods, mirror-airgap, fetch-launcher
|
tooling/ # render-config, render-pack, render-nginx,
|
||||||
mirror/ # vendored jars + launcher + asset mirror (gitignored)
|
# issue-letsencrypt, fetch-launcher, add-custom-mod
|
||||||
plan/ # full design docs (00–12)
|
nginx/ulicraft-caddy.conf.tmpl # host-nginx TLS vhost template (front of caddy)
|
||||||
|
plan/ # design docs
|
||||||
```
|
```
|
||||||
|
|
||||||
See `plan/00-overview.md` for the full design.
|
See `plan/00-overview.md` for the full design.
|
||||||
+9
-7
@@ -1,10 +1,12 @@
|
|||||||
# Base Caddy config. Vhosts live in conf.d/*.caddy snippets that are mounted
|
# Base Caddy config. Vhosts live in conf.d/*.caddy snippets:
|
||||||
# in selectively so the stack can run with or without the static site / mirror:
|
# conf.d/00-core.caddy auth + packwiz
|
||||||
# conf.d/00-core.caddy auth + packwiz (always — core ingress)
|
# conf.d/10-static.caddy apex landing + launcher downloads
|
||||||
# conf.d/10-static.caddy apex landing + launcher downloads (static toggle)
|
# conf.d/30-distribution.caddy static site from another repo
|
||||||
# conf.d/20-mirror.caddy air-gap upstream mirror (tls internal) (mirror toggle)
|
# conf.d/40-avatar.caddy nmsr skin/avatar renderer
|
||||||
# auto_https disable_redirects: http:// sites stay plaintext on :80; the mirror
|
# conf.d/50-status.caddy uptime-kuma status page
|
||||||
# snippet's hosts still get internal TLS on :443 when that snippet is present.
|
# All snippets are plain http:// on :80 — the host's nginx terminates TLS in
|
||||||
|
# front (nginx/ulicraft-caddy.conf.tmpl). disable_redirects keeps caddy from
|
||||||
|
# upgrading to its own https.
|
||||||
{
|
{
|
||||||
auto_https disable_redirects
|
auto_https disable_redirects
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
# Core ingress — Blessing Skin variant. Replaces 00-core.caddy when the
|
|
||||||
# docker-compose.blessingskin.yml override is in play (mounted at the same
|
|
||||||
# target path). auth.* now points at Blessing Skin instead of Drasl.
|
|
||||||
#
|
|
||||||
# Blessing Skin serves BOTH its web UI (/) and the Yggdrasil API (/api/yggdrasil)
|
|
||||||
# on the same vhost, so a single reverse_proxy covers everything.
|
|
||||||
http://auth.{$BASE_DOMAIN} {
|
|
||||||
reverse_proxy blessing-skin:80
|
|
||||||
}
|
|
||||||
|
|
||||||
http://pack.{$BASE_DOMAIN} {
|
|
||||||
root * /srv/pack
|
|
||||||
file_server browse
|
|
||||||
}
|
|
||||||
@@ -1,19 +1,18 @@
|
|||||||
# Static toggle — apex landing page + launcher downloads.
|
# Apex — landing page (/srv/www) + launcher downloads (/srv/launcher).
|
||||||
# Mounted only when the stack runs with static files (build-stack.sh up static|full).
|
|
||||||
http://{$BASE_DOMAIN} {
|
http://{$BASE_DOMAIN} {
|
||||||
# Caddy's local CA root, so guests can trust the air-gap HTTPS mirror.
|
|
||||||
# Mounted from ./caddy/caddy-root-ca.crt (exported by build-stack prep;
|
|
||||||
# the live pki dir is root-only 0600 and can't be served directly).
|
|
||||||
handle /ca.crt {
|
|
||||||
root * /srv/ca-pub
|
|
||||||
file_server
|
|
||||||
}
|
|
||||||
# Launcher downloads are a sibling mount (/srv/launcher), not nested under
|
# Launcher downloads are a sibling mount (/srv/launcher), not nested under
|
||||||
# the read-only /srv/www. handle_path strips the /launcher prefix.
|
# the read-only /srv/www. handle_path strips the /launcher prefix.
|
||||||
handle_path /launcher/* {
|
handle_path /launcher/* {
|
||||||
root * /srv/launcher
|
root * /srv/launcher
|
||||||
file_server browse
|
file_server browse
|
||||||
}
|
}
|
||||||
|
# Self-hosted Minecraft status JSON (mc-status service). Same-origin, so the
|
||||||
|
# landing's ServerCard fetch needs no CORS. The <addr> in the path is
|
||||||
|
# ignored by mc-status — it only ever pings its configured MC_STATUS_TARGET.
|
||||||
|
handle /api/mcstatus/* {
|
||||||
|
uri strip_prefix /api/mcstatus
|
||||||
|
reverse_proxy mc-status:8080
|
||||||
|
}
|
||||||
handle {
|
handle {
|
||||||
root * /srv/www
|
root * /srv/www
|
||||||
file_server
|
file_server
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
# Mirror toggle — full client air-gap mirror (see plan/11-full-airgap-mirror.md).
|
|
||||||
# Byte-exact copies of the real upstream hosts over HTTPS with Caddy's local CA.
|
|
||||||
# dnsmasq spoofs these hostnames -> our host; {host} roots each at its subtree.
|
|
||||||
# Mounted only when the stack runs with the mirror (build-stack.sh up mirror|full).
|
|
||||||
meta.prismlauncher.org, piston-meta.mojang.com, piston-data.mojang.com, libraries.minecraft.net, maven.neoforged.net, resources.download.minecraft.net {
|
|
||||||
tls internal
|
|
||||||
root * /srv/mirror/{host}
|
|
||||||
file_server
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
# Distribution toggle — static site served from a web root in ANOTHER repo.
|
# Distribution — static site served from a web root in ANOTHER repo. The host
|
||||||
# The host path is set by DISTRIBUTION_WEB_ROOT in .env and bind-mounted to
|
# path is set by DISTRIBUTION_WEB_ROOT in .env and bind-mounted to
|
||||||
# /srv/distribution by docker-compose.caddy.yml. Mounted only when that var is
|
# /srv/distribution by the caddy service in docker-compose.yml.
|
||||||
# set (see the distribution override).
|
|
||||||
http://distribution.{$BASE_DOMAIN} {
|
http://distribution.{$BASE_DOMAIN} {
|
||||||
root * /srv/distribution
|
root * /srv/distribution
|
||||||
file_server browse
|
file_server browse
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Avatar — NMSR skin/avatar renderer (nmsr service), Drasl-backed.
|
||||||
|
http://avatar.{$BASE_DOMAIN} {
|
||||||
|
reverse_proxy nmsr:8080
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Status — Uptime Kuma monitoring + public status page (uptime-kuma service).
|
||||||
|
http://status.{$BASE_DOMAIN} {
|
||||||
|
reverse_proxy uptime-kuma:3001
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
|
||||||
+107
-24
@@ -1,31 +1,31 @@
|
|||||||
# ──────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────
|
||||||
# Ulicraft LAN-party stack — Minecraft 1.21.1 NeoForge + Drasl auth
|
# Ulicraft LAN-party stack — Minecraft 1.21.1 NeoForge + Drasl auth
|
||||||
# ──────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────
|
||||||
# Single source of config: BASE_DOMAIN + HOST_LAN_IP in .env.
|
# Single, unified compose. One file holds the whole stack:
|
||||||
# DNS is provided by YOUR party DNS server — see tooling/dns-records.sh for the
|
# drasl auth + skins (Yggdrasil), internal only
|
||||||
# records to add (online vs airgap). A turnkey dnsmasq is available as an
|
# minecraft the server (itzg/NEOFORGE), installs mods via packwiz
|
||||||
# optional layer: docker-compose.dnsmasq.yml.
|
# mc-backup world backups every 6h via RCON
|
||||||
# - ingress is layered on, NOT in this base file: caddy (docker-compose.caddy.yml,
|
# caddy internal ingress — routes auth./pack./apex/launcher/avatar.
|
||||||
# LAN/air-gap, holds mcnet aliases) OR the host's nginx (docker-compose.nginx.yml
|
# /distribution. by Host header (conf.d/*.caddy)
|
||||||
# + Let's Encrypt, production)
|
# nmsr skin/avatar renderer at avatar.${BASE_DOMAIN}
|
||||||
# - drasl handles auth (password login; NO Keycloak/OIDC in this stack)
|
# uptime-kuma status monitoring at status.${BASE_DOMAIN}
|
||||||
# - packwiz pack is served by the ingress at pack.${BASE_DOMAIN}
|
#
|
||||||
# - minecraft installs mods via PACKWIZ_URL, authlib-injector -> auth.
|
# Bring up: docker compose up -d (render configs first: tooling/render-config.sh)
|
||||||
|
#
|
||||||
|
# DNS is handled OUTSIDE this repo — point every service name at the host.
|
||||||
|
# Production TLS terminates at the HOST's nginx in front of caddy
|
||||||
|
# (nginx/ulicraft-caddy.conf.tmpl + Let's Encrypt); caddy is published on a
|
||||||
|
# localhost-only port (CADDY_HTTP_BIND/CADDY_HTTP_PORT in .env). Containers reach
|
||||||
|
# the stack's own names internally via caddy's mcnet aliases (auth./pack.).
|
||||||
# ──────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# NOTE: the HTTP(S) ingress is NOT in this base file.
|
|
||||||
# - LAN / air-gap: caddy (docker-compose.caddy.yml) — added by build-stack.sh
|
|
||||||
# - production: the HOST's nginx + Let's Encrypt (docker-compose.nginx.yml
|
|
||||||
# publishes drasl to localhost; nginx/ulicraft.conf.tmpl)
|
|
||||||
# Base alone (drasl + minecraft + mc-backup) is not a complete deployment —
|
|
||||||
# always layer one ingress file on top.
|
|
||||||
drasl:
|
drasl:
|
||||||
image: unmojang/drasl:latest
|
image: unmojang/drasl:latest
|
||||||
container_name: drasl
|
container_name: drasl
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# Internal-only: no published host port. Reached via Caddy at
|
# Internal-only: no published host port. Reached via caddy at
|
||||||
# auth.${BASE_DOMAIN} (Caddy holds the network alias on mcnet).
|
# auth.${BASE_DOMAIN} (caddy holds the network alias on mcnet).
|
||||||
volumes:
|
volumes:
|
||||||
- ./drasl/config:/etc/drasl:ro
|
- ./drasl/config:/etc/drasl:ro
|
||||||
- drasl_state:/var/lib/drasl
|
- drasl_state:/var/lib/drasl
|
||||||
@@ -58,7 +58,7 @@ services:
|
|||||||
|
|
||||||
# ── Auth: offline mode + authlib-injector pointing at Drasl ─
|
# ── Auth: offline mode + authlib-injector pointing at Drasl ─
|
||||||
ONLINE_MODE: "FALSE"
|
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"
|
JVM_OPTS: "-javaagent:/extras/authlib-injector.jar=http://auth.${BASE_DOMAIN}/authlib-injector"
|
||||||
|
|
||||||
# ── Server config ───────────────────────────────────────────
|
# ── Server config ───────────────────────────────────────────
|
||||||
@@ -71,9 +71,7 @@ services:
|
|||||||
ENFORCE_SECURE_PROFILE: "FALSE" # required for authlib-injector on 1.21+
|
ENFORCE_SECURE_PROFILE: "FALSE" # required for authlib-injector on 1.21+
|
||||||
ALLOW_FLIGHT: "TRUE" # many tech mods need this
|
ALLOW_FLIGHT: "TRUE" # many tech mods need this
|
||||||
|
|
||||||
# ── Mod source: packwiz pack served by the ingress ──────────
|
# ── Mod source: packwiz pack served by caddy (alias pack.) ──
|
||||||
# http + caddy alias by default; the nginx override switches this to
|
|
||||||
# https + host-gateway (extra_hosts) for the production path.
|
|
||||||
PACKWIZ_URL: "http://pack.${BASE_DOMAIN}/pack.toml"
|
PACKWIZ_URL: "http://pack.${BASE_DOMAIN}/pack.toml"
|
||||||
|
|
||||||
# ── RCON for remote admin ───────────────────────────────────
|
# ── RCON for remote admin ───────────────────────────────────
|
||||||
@@ -86,11 +84,17 @@ services:
|
|||||||
- mc_data:/data
|
- mc_data:/data
|
||||||
- ./runtime:/extras:ro # authlib-injector.jar lives here
|
- ./runtime:/extras:ro # authlib-injector.jar lives here
|
||||||
depends_on:
|
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:
|
networks:
|
||||||
- mcnet
|
- mcnet
|
||||||
|
|
||||||
# Optional: backups
|
|
||||||
mc-backup:
|
mc-backup:
|
||||||
image: itzg/mc-backup
|
image: itzg/mc-backup
|
||||||
container_name: mc-backup
|
container_name: mc-backup
|
||||||
@@ -109,9 +113,88 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- mcnet
|
- 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:
|
volumes:
|
||||||
drasl_state:
|
drasl_state:
|
||||||
mc_data:
|
mc_data:
|
||||||
|
kuma_data:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
mcnet:
|
mcnet:
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
# Publishes the stack's service names on .local via the HOST's avahi-daemon
|
|
||||||
# (over its D-Bus socket — no second daemon, avoids the :5353 collision).
|
|
||||||
# Requires the host to run avahi-daemon and the socket to be mounted (see
|
|
||||||
# docker-compose.avahi.yml). Used when ENABLE_MDNS=true and BASE_DOMAIN=*.local.
|
|
||||||
FROM alpine:3.20
|
|
||||||
RUN apk add --no-cache avahi-tools
|
|
||||||
COPY entrypoint.sh /entrypoint.sh
|
|
||||||
RUN chmod +x /entrypoint.sh
|
|
||||||
ENTRYPOINT ["/entrypoint.sh"]
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# Publish A records for the stack's service names on .local via the HOST's
|
|
||||||
# avahi-daemon (reached over its mounted D-Bus socket). One avahi-publish per
|
|
||||||
# name; if any drops the container exits so Docker restarts it.
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
: "${BASE_DOMAIN:?BASE_DOMAIN unset}"
|
|
||||||
: "${HOST_LAN_IP:?HOST_LAN_IP unset}"
|
|
||||||
|
|
||||||
case "$BASE_DOMAIN" in
|
|
||||||
*.local) ;;
|
|
||||||
*) echo "WARN: mDNS resolves only .local — BASE_DOMAIN=$BASE_DOMAIN will NOT be discoverable via avahi. Set BASE_DOMAIN=*.local to use mDNS." >&2 ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
bus="${DBUS_SYSTEM_BUS_ADDRESS:-unix:path=/run/dbus/system_bus_socket}"
|
|
||||||
sock=${bus#unix:path=}
|
|
||||||
if [ ! -S "$sock" ]; then
|
|
||||||
echo "ERROR: host D-Bus socket '$sock' not found. Mount the host's" >&2
|
|
||||||
echo " /run/dbus/system_bus_socket and ensure avahi-daemon runs on the host." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
export DBUS_SYSTEM_BUS_ADDRESS="$bus"
|
|
||||||
|
|
||||||
pids=""
|
|
||||||
for n in "$BASE_DOMAIN" "auth.$BASE_DOMAIN" "packwiz.$BASE_DOMAIN" "mc.$BASE_DOMAIN"; do
|
|
||||||
echo "mDNS publish (host avahi): $n -> $HOST_LAN_IP"
|
|
||||||
avahi-publish -a "$n" "$HOST_LAN_IP" &
|
|
||||||
pids="$pids $!"
|
|
||||||
done
|
|
||||||
|
|
||||||
# shellcheck disable=SC2086
|
|
||||||
wait -n $pids 2>/dev/null || wait
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# mc-status — self-hosted SLP→JSON pinger (mcutil wrapper). Multi-stage:
|
||||||
|
# build a static binary, ship it on scratch.
|
||||||
|
FROM golang:1.25-alpine AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/mc-status .
|
||||||
|
|
||||||
|
FROM scratch
|
||||||
|
# CA roots so SRV/DNS over the resolver and any TLS lookups work.
|
||||||
|
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||||||
|
COPY --from=build /out/mc-status /mc-status
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["/mc-status"]
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
module ulicraft/mc-status
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require github.com/mcstatus-io/mcutil/v4 v4.0.1
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
github.com/mcstatus-io/mcutil/v4 v4.0.1 h1:/AQkHrz7irCU7USGnrH3kneQw80aDQOVdOWc8xu/NUY=
|
||||||
|
github.com/mcstatus-io/mcutil/v4 v4.0.1/go.mod h1:yC91WInI1U2GAMFWgpPgsAULPVS2o+4JCZbiiWhHwxM=
|
||||||
@@ -0,0 +1,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)
|
||||||
|
}
|
||||||
@@ -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).
|
# Drasl config — password-login mode (NO Keycloak/OIDC for now).
|
||||||
# TOML only (drasl has no env support). Rendered by tooling/render-config.sh
|
# TOML only (drasl has no env support). Rendered by tooling/render-config.sh
|
||||||
# -> drasl/config/config.toml (gitignored). Only ${BASE_DOMAIN} is substituted.
|
# -> drasl/config/config.toml (gitignored). Substitutes ${BASE_DOMAIN} and
|
||||||
|
# ${REGISTRATION_REQUIRE_INVITE} (derived from REGISTRATION_MODE in .env).
|
||||||
# Reached only via Caddy at auth.${BASE_DOMAIN}; drasl has no published host port.
|
# Reached only via Caddy at auth.${BASE_DOMAIN}; drasl has no published host port.
|
||||||
|
|
||||||
BaseURL = "http://auth.${BASE_DOMAIN}"
|
# Public scheme is HTTPS — the host nginx terminates TLS in front of caddy.
|
||||||
|
# Drasl builds absolute URLs (textures, authlib-injector API location) from this;
|
||||||
|
# an http:// value triggers mixed-content + broken login on the https origin.
|
||||||
|
BaseURL = "https://auth.${BASE_DOMAIN}"
|
||||||
Domain = "auth.${BASE_DOMAIN}"
|
Domain = "auth.${BASE_DOMAIN}"
|
||||||
ListenAddress = "0.0.0.0:25585" # internal; Caddy reverse-proxies to it
|
ListenAddress = "0.0.0.0:25585" # internal; Caddy reverse-proxies to it
|
||||||
DefaultAdminUsernames = ["admin"]
|
DefaultAdminUsernames = ["admin"]
|
||||||
@@ -18,5 +22,27 @@ SignPublicKeys = false
|
|||||||
# Free-text owner label shown in the web UI (a string, not a table).
|
# Free-text owner label shown in the web UI (a string, not a table).
|
||||||
ApplicationOwner = "Ulicraft"
|
ApplicationOwner = "Ulicraft"
|
||||||
|
|
||||||
|
# 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).
|
# OIDC block intentionally omitted for now (no Keycloak).
|
||||||
# [[RegistrationOIDC]] <- future task
|
# [[RegistrationOIDC]] <- future task
|
||||||
@@ -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>
|
||||||
+29
-12
@@ -4,6 +4,10 @@
|
|||||||
// BASE_DOMAIN is read at build time; falls back to ulicraft.local.
|
// BASE_DOMAIN is read at build time; falls back to ulicraft.local.
|
||||||
const BASE_DOMAIN = process.env.BASE_DOMAIN ?? "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.
|
// Minecraft / pack facts, surfaced in a few places.
|
||||||
const MC_VERSION = "1.21.1";
|
const MC_VERSION = "1.21.1";
|
||||||
|
|
||||||
@@ -12,6 +16,11 @@ export const site = {
|
|||||||
baseDomain: BASE_DOMAIN,
|
baseDomain: BASE_DOMAIN,
|
||||||
mcVersion: MC_VERSION,
|
mcVersion: MC_VERSION,
|
||||||
|
|
||||||
|
// Register form behaviour, driven by REGISTRATION_MODE in .env.
|
||||||
|
// true => 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.
|
// Design knobs (replace the design's in-browser TweaksPanel). Baked at build.
|
||||||
// mood: "grass" | "nether" | "end"
|
// mood: "grass" | "nether" | "end"
|
||||||
// hero: "centered" | "split" | "spotlight"
|
// hero: "centered" | "split" | "spotlight"
|
||||||
@@ -24,24 +33,32 @@ export const site = {
|
|||||||
dust: true,
|
dust: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Connect target shown to guests. No port needed — a dnsmasq SRV record
|
// Connect target shown to guests. No port needed (defaults to 25565).
|
||||||
// (_minecraft._tcp.mc.<domain>) points clients at 25565.
|
serverAddress: `${BASE_DOMAIN}`,
|
||||||
serverAddress: `mc.${BASE_DOMAIN}`,
|
|
||||||
|
|
||||||
// Drasl auth web UI + authlib-injector API root.
|
// Drasl auth web UI + authlib-injector API root (public HTTPS).
|
||||||
authUrl: `http://auth.${BASE_DOMAIN}`,
|
authUrl: `https://auth.${BASE_DOMAIN}`,
|
||||||
authlibUrl: `http://auth.${BASE_DOMAIN}/authlib-injector`,
|
authlibUrl: `https://auth.${BASE_DOMAIN}/authlib-injector`,
|
||||||
|
// Drasl REST API v2 root — the /register form calls this from the browser.
|
||||||
|
// Requires CORSAllowOrigins to include the apex in drasl config.toml.
|
||||||
|
draslApiUrl: `https://auth.${BASE_DOMAIN}/drasl/api/v2`,
|
||||||
|
|
||||||
// packwiz modpack entrypoint.
|
// packwiz modpack entrypoint (public HTTPS, pack. subdomain).
|
||||||
packwizUrl: `http://packwiz.${BASE_DOMAIN}/pack.toml`,
|
packwizUrl: `https://pack.${BASE_DOMAIN}/pack.toml`,
|
||||||
|
|
||||||
// Caddy local CA root — guests import this to trust the offline asset mirror.
|
// NMSR avatar renderer root. Player heads come from OUR Drasl skins, not
|
||||||
caCertUrl: `http://${BASE_DOMAIN}/ca.crt`,
|
// 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
|
// Live server card (ServerCard.astro). `slots` is the SSG fallback cap shown
|
||||||
// 09-landing.md option B.
|
// before/without JS; live data comes from our SELF-HOSTED mc-status service
|
||||||
|
// (docker/mc-status, mcutil wrapper) reverse-proxied same-origin at the apex
|
||||||
|
// /api/mcstatus/* path — no CORS, no third-party calls. It emits mcstatus.io
|
||||||
|
// v2 JSON, so the URL stays drop-in compatible. Uptime Kuma stays the history
|
||||||
|
// + alerting backend (plan/10).
|
||||||
status: {
|
status: {
|
||||||
slots: 10,
|
slots: 10,
|
||||||
|
statusApi: `/api/mcstatus/v2/status/java/${BASE_DOMAIN}`,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Honest stat tiles. Keys (numbers/versions) are language-neutral; labels
|
// Honest stat tiles. Keys (numbers/versions) are language-neutral; labels
|
||||||
|
|||||||
+138
-12
@@ -13,6 +13,12 @@ export const LANGS: Lang[] = ["en", "es", "eu"];
|
|||||||
export const LANG_LABEL: Record<Lang, string> = { en: "EN", es: "ES", eu: "EU" };
|
export const LANG_LABEL: Record<Lang, string> = { en: "EN", es: "ES", eu: "EU" };
|
||||||
// URL path per locale (en is default, served at root).
|
// URL path per locale (en is default, served at root).
|
||||||
export const LANG_PATH: Record<Lang, string> = { en: "/", es: "/es/", eu: "/eu/" };
|
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 {
|
export interface UI {
|
||||||
htmlLang: string;
|
htmlLang: string;
|
||||||
@@ -54,9 +60,6 @@ export interface UI {
|
|||||||
pb: string; // after it
|
pb: string; // after it
|
||||||
copy: string;
|
copy: string;
|
||||||
registerPre: string;
|
registerPre: string;
|
||||||
caPre: string;
|
|
||||||
caLink: string;
|
|
||||||
caPost: string;
|
|
||||||
};
|
};
|
||||||
s3: { kicker: string; h: string; p: string };
|
s3: { kicker: string; h: string; p: string };
|
||||||
s4: {
|
s4: {
|
||||||
@@ -68,6 +71,39 @@ export interface UI {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
footer: { join: string; disc: string };
|
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> = {
|
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.",
|
pb: "account using this URL, then log in with your Ulicraft credentials.",
|
||||||
copy: "Copy",
|
copy: "Copy",
|
||||||
registerPre: "Need credentials? Register at",
|
registerPre: "Need credentials? Register at",
|
||||||
caPre: "Offline party? Download and trust the",
|
|
||||||
caLink: "local CA certificate",
|
|
||||||
caPost: "so the game-file mirror works over HTTPS.",
|
|
||||||
},
|
},
|
||||||
s3: {
|
s3: {
|
||||||
kicker: "Modpack",
|
kicker: "Modpack",
|
||||||
@@ -158,6 +191,39 @@ export const ui: Record<Lang, UI> = {
|
|||||||
join: "How to Join",
|
join: "How to Join",
|
||||||
disc: "Not affiliated with Mojang or Microsoft. Minecraft is a trademark of Mojang AB.",
|
disc: "Not affiliated with Mojang or Microsoft. Minecraft is a trademark of Mojang AB.",
|
||||||
},
|
},
|
||||||
|
register: {
|
||||||
|
navHome: "← Home",
|
||||||
|
eyebrow: "Account",
|
||||||
|
title: "Create your Ulicraft account.",
|
||||||
|
lead: "One username and password for the server, your skin, and your cape. Takes a minute.",
|
||||||
|
usernameLabel: "Username",
|
||||||
|
usernamePlaceholder: "your in-game name",
|
||||||
|
passwordLabel: "Password",
|
||||||
|
passwordPlaceholder: "choose a password",
|
||||||
|
inviteLabel: "Invite code",
|
||||||
|
invitePlaceholder: "paste your invite code",
|
||||||
|
inviteHint: "Ask an admin for an invite code.",
|
||||||
|
submit: "Create account",
|
||||||
|
submitting: "Creating…",
|
||||||
|
haveAccount: "Already have an account?",
|
||||||
|
loginLink: "Manage it here",
|
||||||
|
successTitle: "Account created.",
|
||||||
|
successBody: "Welcome, {name}. You can set a skin now, or head straight to How to Join.",
|
||||||
|
uuidLabel: "Player UUID",
|
||||||
|
skinTitle: "Set your skin (optional)",
|
||||||
|
skinLead: "Upload a Minecraft skin PNG. You can always change it later in the account page.",
|
||||||
|
skinChoose: "Choose PNG…",
|
||||||
|
modelLabel: "Model",
|
||||||
|
modelClassic: "Classic",
|
||||||
|
modelSlim: "Slim",
|
||||||
|
skinUpload: "Upload skin",
|
||||||
|
skinUploading: "Uploading…",
|
||||||
|
skinOk: "Skin set!",
|
||||||
|
finish: "Continue to How to Join",
|
||||||
|
errFields: "Fill in every field.",
|
||||||
|
errSkinType: "Pick a PNG image.",
|
||||||
|
errNetwork: "Network error — try again.",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
es: {
|
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.",
|
pb: "con esta URL y luego inicia sesión con tus credenciales de Ulicraft.",
|
||||||
copy: "Copiar",
|
copy: "Copiar",
|
||||||
registerPre: "¿Sin credenciales? Regístrate en",
|
registerPre: "¿Sin credenciales? Regístrate en",
|
||||||
caPre: "¿Fiesta sin internet? Descarga y confía en el",
|
|
||||||
caLink: "certificado CA local",
|
|
||||||
caPost: "para que el mirror de archivos funcione por HTTPS.",
|
|
||||||
},
|
},
|
||||||
s3: {
|
s3: {
|
||||||
kicker: "Modpack",
|
kicker: "Modpack",
|
||||||
@@ -247,6 +310,39 @@ export const ui: Record<Lang, UI> = {
|
|||||||
join: "Cómo entrar",
|
join: "Cómo entrar",
|
||||||
disc: "No afiliado a Mojang ni Microsoft. Minecraft es una marca de Mojang AB.",
|
disc: "No afiliado a Mojang ni Microsoft. Minecraft es una marca de Mojang AB.",
|
||||||
},
|
},
|
||||||
|
register: {
|
||||||
|
navHome: "← Inicio",
|
||||||
|
eyebrow: "Cuenta",
|
||||||
|
title: "Crea tu cuenta de Ulicraft.",
|
||||||
|
lead: "Un usuario y contraseña para el servidor, tu skin y tu capa. Cosa de un minuto.",
|
||||||
|
usernameLabel: "Usuario",
|
||||||
|
usernamePlaceholder: "tu nombre en el juego",
|
||||||
|
passwordLabel: "Contraseña",
|
||||||
|
passwordPlaceholder: "elige una contraseña",
|
||||||
|
inviteLabel: "Código de invitación",
|
||||||
|
invitePlaceholder: "pega tu código de invitación",
|
||||||
|
inviteHint: "Pide un código de invitación a un admin.",
|
||||||
|
submit: "Crear cuenta",
|
||||||
|
submitting: "Creando…",
|
||||||
|
haveAccount: "¿Ya tienes cuenta?",
|
||||||
|
loginLink: "Gestiónala aquí",
|
||||||
|
successTitle: "Cuenta creada.",
|
||||||
|
successBody: "Bienvenido, {name}. Puedes poner una skin ahora o ir directo a Cómo entrar.",
|
||||||
|
uuidLabel: "UUID del jugador",
|
||||||
|
skinTitle: "Pon tu skin (opcional)",
|
||||||
|
skinLead: "Sube un PNG de skin de Minecraft. Siempre puedes cambiarla luego en tu cuenta.",
|
||||||
|
skinChoose: "Elegir PNG…",
|
||||||
|
modelLabel: "Modelo",
|
||||||
|
modelClassic: "Clásico",
|
||||||
|
modelSlim: "Fino",
|
||||||
|
skinUpload: "Subir skin",
|
||||||
|
skinUploading: "Subiendo…",
|
||||||
|
skinOk: "¡Skin puesta!",
|
||||||
|
finish: "Continuar a Cómo entrar",
|
||||||
|
errFields: "Rellena todos los campos.",
|
||||||
|
errSkinType: "Elige una imagen PNG.",
|
||||||
|
errNetwork: "Error de red: inténtalo de nuevo.",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
eu: {
|
eu: {
|
||||||
@@ -315,9 +411,6 @@ export const ui: Record<Lang, UI> = {
|
|||||||
pb: "kontu bat URL honekin, eta gero hasi saioa zure Ulicraft kredentzialekin.",
|
pb: "kontu bat URL honekin, eta gero hasi saioa zure Ulicraft kredentzialekin.",
|
||||||
copy: "Kopiatu",
|
copy: "Kopiatu",
|
||||||
registerPre: "Kredentzialik ez? Erregistratu hemen:",
|
registerPre: "Kredentzialik ez? Erregistratu hemen:",
|
||||||
caPre: "Internetik gabeko festa? Deskargatu eta fidatu",
|
|
||||||
caLink: "tokiko CA ziurtagiriaz",
|
|
||||||
caPost: "fitxategi-mirrorrak HTTPS bidez ibil dadin.",
|
|
||||||
},
|
},
|
||||||
s3: {
|
s3: {
|
||||||
kicker: "Modpacka",
|
kicker: "Modpacka",
|
||||||
@@ -336,5 +429,38 @@ export const ui: Record<Lang, UI> = {
|
|||||||
join: "Nola sartu",
|
join: "Nola sartu",
|
||||||
disc: "Ez dago Mojang edo Microsoft-ekin lotuta. Minecraft Mojang AB-ren marka da.",
|
disc: "Ez dago Mojang edo Microsoft-ekin lotuta. Minecraft Mojang AB-ren marka da.",
|
||||||
},
|
},
|
||||||
|
register: {
|
||||||
|
navHome: "← Hasiera",
|
||||||
|
eyebrow: "Kontua",
|
||||||
|
title: "Sortu zure Ulicraft kontua.",
|
||||||
|
lead: "Erabiltzaile eta pasahitz bat zerbitzarirako, zure azalerako eta kaparako. Minutu batean.",
|
||||||
|
usernameLabel: "Erabiltzailea",
|
||||||
|
usernamePlaceholder: "zure jokoko izena",
|
||||||
|
passwordLabel: "Pasahitza",
|
||||||
|
passwordPlaceholder: "aukeratu pasahitz bat",
|
||||||
|
inviteLabel: "Gonbidapen-kodea",
|
||||||
|
invitePlaceholder: "itsatsi zure gonbidapen-kodea",
|
||||||
|
inviteHint: "Eskatu gonbidapen-kode bat admin bati.",
|
||||||
|
submit: "Sortu kontua",
|
||||||
|
submitting: "Sortzen…",
|
||||||
|
haveAccount: "Baduzu kontua?",
|
||||||
|
loginLink: "Kudeatu hemen",
|
||||||
|
successTitle: "Kontua sortuta.",
|
||||||
|
successBody: "Ongi etorri, {name}. Azala orain jar dezakezu, edo zuzenean Nola sartu atalera joan.",
|
||||||
|
uuidLabel: "Jokalariaren UUIDa",
|
||||||
|
skinTitle: "Jarri zure azala (aukerakoa)",
|
||||||
|
skinLead: "Igo Minecraft azal PNG bat. Beti alda dezakezu gero zure kontuan.",
|
||||||
|
skinChoose: "Aukeratu PNGa…",
|
||||||
|
modelLabel: "Eredua",
|
||||||
|
modelClassic: "Klasikoa",
|
||||||
|
modelSlim: "Mehea",
|
||||||
|
skinUpload: "Igo azala",
|
||||||
|
skinUploading: "Igotzen…",
|
||||||
|
skinOk: "Azala jarrita!",
|
||||||
|
finish: "Jarraitu Nola sartura",
|
||||||
|
errFields: "Bete eremu guztiak.",
|
||||||
|
errSkinType: "Aukeratu PNG irudi bat.",
|
||||||
|
errNetwork: "Sare-errorea: saiatu berriro.",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
Loaded 100 of 141 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user