Compare commits
2 Commits
ff645de2c8
...
1709bcd340
| Author | SHA1 | Date | |
|---|---|---|---|
| 1709bcd340 | |||
| 67d1f82e6a |
@@ -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,32 @@ 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'
|
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_*`).
|
||||||
|
|
||||||
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.
|
|
||||||
|
|||||||
45
.env.example
45
.env.example
@@ -1,35 +1,22 @@
|
|||||||
# 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
|
# caddy host-published port. The host's own nginx terminates TLS and
|
||||||
# standalone LAN/air-gap path. When the machine's own nginx fronts caddy
|
# reverse-proxies to caddy by Host header (nginx/ulicraft-caddy.conf.tmpl), so
|
||||||
# (nginx terminates TLS → reverse-proxies to caddy), set a high port and bind
|
# bind caddy to a high localhost-only port — only nginx should reach it.
|
||||||
# it to localhost so only nginx reaches it:
|
CADDY_HTTP_PORT=8880
|
||||||
# CADDY_HTTP_PORT=8880
|
CADDY_HTTP_BIND=127.0.0.1
|
||||||
# CADDY_HTTP_BIND=127.0.0.1
|
|
||||||
# Then render nginx/ulicraft-caddy.conf.tmpl with CADDY_HTTP_PORT.
|
|
||||||
CADDY_HTTP_PORT=80
|
|
||||||
CADDY_HTTP_BIND=0.0.0.0
|
|
||||||
|
|
||||||
# distribution.${BASE_DOMAIN} static vhost (docker-compose.distribution.yml).
|
# distribution.${BASE_DOMAIN} static vhost. Absolute host path to the built
|
||||||
# Absolute host path to the built static site — it lives in ANOTHER repo.
|
# static site — it lives in ANOTHER repo. Bind-mounted read-only into caddy.
|
||||||
# Bind-mounted read-only into caddy and served at distribution.${BASE_DOMAIN}.
|
|
||||||
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
|
||||||
|
|
||||||
@@ -45,13 +32,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
|
|
||||||
|
|||||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -6,15 +6,14 @@ 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
|
||||||
nmsr/config.toml
|
nmsr/config.toml
|
||||||
dnsmasq/dnsmasq.conf
|
|
||||||
# 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
|
||||||
@@ -23,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/
|
||||||
|
|||||||
343
CLAUDE.md
343
CLAUDE.md
@@ -1,280 +1,143 @@
|
|||||||
# Minecraft LAN Party Server — Project Context
|
# Ulicraft Server — Project Context
|
||||||
|
|
||||||
> Self-hosted modded Minecraft server for a LAN party (~8 players), with
|
> 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
|
|
||||||
|
|||||||
203
README.md
203
README.md
@@ -1,99 +1,70 @@
|
|||||||
# 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 (each its own `docker-compose.<name>.yml`):
|
Vhosts (all proxied through the host nginx → caddy):
|
||||||
- `avahi` (mDNS `.local`), `dnsmasq` (turnkey DNS) — only if you don't run your
|
|
||||||
own DNS.
|
|
||||||
- `nmsr` (NickAcPT/nmsr-rs, built from source) — skin/avatar renderer at
|
|
||||||
`avatar.${BASE_DOMAIN}`, sourced from Drasl. See **Avatar renderer** below.
|
|
||||||
- `distribution` — extra static vhost at `distribution.${BASE_DOMAIN}` whose web
|
|
||||||
root (`DISTRIBUTION_WEB_ROOT`) lives in another repo.
|
|
||||||
- `status` (louislam/uptime-kuma) — uptime monitoring + public status page at
|
|
||||||
`status.${BASE_DOMAIN}`. See **Status monitoring** below.
|
|
||||||
|
|
||||||
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/)
|
||||||
|
# authlib-injector.jar → runtime/ (server JVM agent; see drasl docs)
|
||||||
|
|
||||||
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)
|
## Public TLS (host nginx in front of caddy)
|
||||||
|
|
||||||
The LAN path above runs caddy on `:80`. To expose the stack publicly with real
|
caddy is published on a localhost-only port (`CADDY_HTTP_BIND=127.0.0.1`,
|
||||||
certs, the machine's own nginx terminates TLS and reverse-proxies every vhost to
|
`CADDY_HTTP_PORT=8880`). The host's own nginx terminates TLS with Let's Encrypt
|
||||||
caddy by Host header. Caddy stays the single router (apex landing, `www.` →
|
certs and reverse-proxies every vhost to caddy by Host header. HTTPS-only: HTTP
|
||||||
apex, `auth.` → drasl, `pack.` packwiz, `distribution.` static, `avatar.` →
|
301s to HTTPS and every TLS vhost sends HSTS.
|
||||||
nmsr). HTTPS-only: HTTP 301s to HTTPS and every TLS vhost sends HSTS.
|
|
||||||
|
|
||||||
1. **Issue certs** (OVH DNS-01, no inbound ports needed):
|
1. **Issue certs** (OVH DNS-01, no inbound ports needed):
|
||||||
```sh
|
```sh
|
||||||
@@ -102,31 +73,15 @@ nmsr). HTTPS-only: HTTP 301s to HTTPS and every TLS vhost sends HSTS.
|
|||||||
tooling/issue-letsencrypt.sh # → certs/<name>/{cert,key}.pem
|
tooling/issue-letsencrypt.sh # → certs/<name>/{cert,key}.pem
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Bind caddy to a localhost-only port** so only nginx reaches it (`.env`):
|
2. **Render + install the nginx vhost** with `tooling/render-nginx.sh`. It pulls
|
||||||
```sh
|
|
||||||
CADDY_HTTP_PORT=8880
|
|
||||||
CADDY_HTTP_BIND=127.0.0.1
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Bring up the caddy ingress** (+ static, + distribution/nmsr if used):
|
|
||||||
```sh
|
|
||||||
docker compose -f docker-compose.yml -f docker-compose.caddy.yml \
|
|
||||||
-f docker-compose.static.yml -f docker-compose.distribution.yml \
|
|
||||||
-f docker-compose.nmsr.yml -f docker-compose.status.yml up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **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
|
`BASE_DOMAIN` + `CADDY_HTTP_PORT` from `.env`, defaults `APP_DIR` to the repo
|
||||||
checkout (where `certs/` live), and renders `nginx/ulicraft-caddy.conf.tmpl`:
|
checkout (where `certs/` live), and renders `nginx/ulicraft-caddy.conf.tmpl`:
|
||||||
```sh
|
```sh
|
||||||
tooling/render-nginx.sh # preview to stdout (dry run)
|
tooling/render-nginx.sh # preview to stdout (dry run)
|
||||||
tooling/render-nginx.sh --install # write, symlink, nginx -t, reload
|
tooling/render-nginx.sh --install # write, symlink, nginx -t, reload
|
||||||
```
|
```
|
||||||
Overrides via env: `APP_DIR=/path` (if the repo isn't at the cert location),
|
Override `APP_DIR=/path` if the repo isn't at the cert location. Re-run
|
||||||
`SITE_NAME=foo.conf`, `TEMPLATE=nginx/ulicraft.conf.tmpl` (the static variant —
|
whenever you add a subdomain so its server block is rendered.
|
||||||
nginx serves the files itself + proxies only drasl; pick one, not both).
|
|
||||||
|
|
||||||
Re-run step 4 whenever you add a subdomain so its server block is rendered.
|
|
||||||
|
|
||||||
## Avatar renderer (NMSR)
|
## Avatar renderer (NMSR)
|
||||||
|
|
||||||
@@ -140,40 +95,25 @@ pins a commit; bump `ARG NMSR_REF` to update.
|
|||||||
`http://auth.${BASE_DOMAIN}`; Drasl serves the Mojang-compatible routes at its
|
`http://auth.${BASE_DOMAIN}`; Drasl serves the Mojang-compatible routes at its
|
||||||
bare BaseURL and embeds absolute skin URLs that NMSR fetches directly.
|
bare BaseURL and embeds absolute skin URLs that NMSR fetches directly.
|
||||||
`allow_offline_mode_uuids = true` (Drasl issues offline v3 UUIDs).
|
`allow_offline_mode_uuids = true` (Drasl issues offline v3 UUIDs).
|
||||||
- **Network**: skin resolution stays internal over `mcnet` (the caddy alias
|
- **Network**: skin resolution stays internal over `mcnet` (caddy alias
|
||||||
`auth.${BASE_DOMAIN}` → drasl) — no public round-trip. caddy reverse-proxies
|
`auth.${BASE_DOMAIN}` → drasl). caddy reverse-proxies `avatar.` → `nmsr:8080`.
|
||||||
`avatar.` → `nmsr:8080`. The host port is published on `127.0.0.1:9898` only
|
- **Endpoints**: e.g. `https://avatar.${BASE_DOMAIN}/skin/<player>`,
|
||||||
(local debugging; not reachable from outside).
|
|
||||||
- **Endpoints** (NMSR): e.g. `https://avatar.${BASE_DOMAIN}/skin/<player>`,
|
|
||||||
`/face/<player>`, `/fullbody/<player>` — by username or UUID.
|
`/face/<player>`, `/fullbody/<player>` — by username or UUID.
|
||||||
|
|
||||||
Bring up: add `-f docker-compose.nmsr.yml` (with `--build`) as shown above. The
|
First build is heavy (Rust compile). Headless rendering uses lavapipe (software
|
||||||
first build is heavy (Rust compile). Headless rendering uses lavapipe (software
|
|
||||||
Vulkan); if renders fail, check `docker logs nmsr` for Vulkan device init.
|
Vulkan); if renders fail, check `docker logs nmsr` for Vulkan device init.
|
||||||
|
|
||||||
`distribution.${BASE_DOMAIN}` serves a static site whose web root lives in
|
|
||||||
another repo: set `DISTRIBUTION_WEB_ROOT` (absolute host path) in `.env`; it's
|
|
||||||
bind-mounted read-only via `docker-compose.distribution.yml`.
|
|
||||||
|
|
||||||
## Status monitoring (Uptime Kuma)
|
## Status monitoring (Uptime Kuma)
|
||||||
|
|
||||||
`status.${BASE_DOMAIN}` runs [Uptime Kuma](https://github.com/louislam/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
|
— uptime probes for every vhost **and** a native Minecraft-protocol ping (player
|
||||||
count + up/down), plus a public status page. The container joins `mcnet`, so it
|
count + up/down), plus a public status page. It joins `mcnet`, so monitors can
|
||||||
can probe the stack by internal service name without leaving the host. caddy
|
probe the stack by internal service name. caddy reverse-proxies `status.` →
|
||||||
reverse-proxies `status.` → `uptime-kuma:3001`; the host port is published on
|
`uptime-kuma:3001`.
|
||||||
`127.0.0.1:3001` only (first-run admin setup / local debugging).
|
|
||||||
|
|
||||||
Bring up (layer on the caddy ingress):
|
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
|
||||||
```sh
|
persists in the `kuma_data` volume):
|
||||||
docker compose -f docker-compose.yml -f docker-compose.caddy.yml \
|
|
||||||
-f docker-compose.status.yml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Then open `https://status.${BASE_DOMAIN}` (or `http://127.0.0.1:3001` first run),
|
|
||||||
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 |
|
| Monitor | Type | Target |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -185,42 +125,35 @@ these once via the UI (data persists in the `kuma_data` volume):
|
|||||||
| Avatar (NMSR) | HTTP(s) | `https://avatar.${BASE_DOMAIN}` |
|
| Avatar (NMSR) | HTTP(s) | `https://avatar.${BASE_DOMAIN}` |
|
||||||
|
|
||||||
Internal-name targets (`minecraft`, `drasl:25585`, `nmsr:8080`) isolate "service
|
Internal-name targets (`minecraft`, `drasl:25585`, `nmsr:8080`) isolate "service
|
||||||
down" from "ingress/TLS/DNS down"; public-URL targets test the whole chain. Mix
|
down" from "ingress/TLS/DNS down"; public-URL targets test the whole chain.
|
||||||
as you like. `status` is in the default `LE_SUBDOMAINS` so its TLS cert issues
|
`status` is in the default `LE_SUBDOMAINS`; the nginx vhost adds websocket
|
||||||
with the rest; the nginx vhost adds websocket upgrade headers for Kuma's live UI.
|
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
|
||||||
.caddy.yml .nginx.yml # ingress paths (caddy-front vs nginx static)
|
# (core/static/distribution/avatar/status)
|
||||||
.nmsr.yml .distribution.yml # avatar renderer / extra static vhost
|
|
||||||
.avahi.yml .dnsmasq.yml # optional DNS layers
|
|
||||||
caddy/Caddyfile + conf.d/*.caddy # ingress vhost snippets (core/static/mirror/
|
|
||||||
# distribution/avatar)
|
|
||||||
drasl/config/config.toml.tmpl # auth config (rendered)
|
drasl/config/config.toml.tmpl # auth config (rendered)
|
||||||
nmsr/config.toml.tmpl # avatar renderer config, Drasl-backed (rendered)
|
nmsr/config.toml.tmpl # avatar renderer config, Drasl-backed (rendered)
|
||||||
dnsmasq/dnsmasq.conf.tmpl # optional DNS config (rendered)
|
docker/nmsr/ # built-from-source NMSR image
|
||||||
docker/avahi/ docker/nmsr/ # built-from-source images (mDNS, NMSR)
|
|
||||||
pack/ # packwiz modpack source
|
pack/ # packwiz modpack source
|
||||||
landing/ # Astro site → www/
|
landing/ # Astro site → www/
|
||||||
tooling/ # build-stack, render-config, render-nginx,
|
launcher/ # FjordLauncher assets (gitignored)
|
||||||
# dns-records, issue-letsencrypt,
|
tooling/ # render-config, render-pack, render-nginx,
|
||||||
# mirror-mods, mirror-airgap, fetch-launcher
|
# issue-letsencrypt, fetch-launcher, add-custom-mod
|
||||||
nginx/*.conf.tmpl # host-nginx TLS vhost templates
|
nginx/ulicraft-caddy.conf.tmpl # host-nginx TLS vhost template (front of caddy)
|
||||||
mirror/ # vendored jars + launcher + asset mirror (gitignored)
|
plan/ # design docs
|
||||||
plan/ # full design docs (00–12)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
See `plan/00-overview.md` for the full design.
|
See `plan/00-overview.md` for the full design.
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
# Base Caddy config. Vhosts live in conf.d/*.caddy snippets that are mounted
|
# 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,13 +1,5 @@
|
|||||||
# 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/* {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
# Avatar toggle — NMSR skin/avatar renderer (docker-compose.nmsr.yml).
|
# Avatar — NMSR skin/avatar renderer (nmsr service), Drasl-backed.
|
||||||
# Mounted only when the nmsr override is present.
|
|
||||||
http://avatar.{$BASE_DOMAIN} {
|
http://avatar.{$BASE_DOMAIN} {
|
||||||
reverse_proxy nmsr:8080
|
reverse_proxy nmsr:8080
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
# Status toggle — Uptime Kuma monitoring + public status page
|
# Status — Uptime Kuma monitoring + public status page (uptime-kuma service).
|
||||||
# (docker-compose.status.yml). Mounted only when the status override is present.
|
|
||||||
http://status.{$BASE_DOMAIN} {
|
http://status.{$BASE_DOMAIN} {
|
||||||
reverse_proxy uptime-kuma:3001
|
reverse_proxy uptime-kuma:3001
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,32 +0,0 @@
|
|||||||
# ──────────────────────────────────────────────────────────────────
|
|
||||||
# NMSR-aas — avatar/skin renderer at avatar.${BASE_DOMAIN}.
|
|
||||||
# ──────────────────────────────────────────────────────────────────
|
|
||||||
# docker compose -f docker-compose.yml -f docker-compose.caddy.yml \
|
|
||||||
# -f docker-compose.nmsr.yml up -d --build
|
|
||||||
#
|
|
||||||
# Built from source (no upstream image) — see docker/nmsr/Dockerfile.
|
|
||||||
# Renders YOUR players' skins by pulling profiles from Drasl over the internal
|
|
||||||
# mcnet network (config: nmsr/config.toml, rendered from .tmpl).
|
|
||||||
#
|
|
||||||
# Caddy reverse-proxies avatar.${BASE_DOMAIN} -> nmsr:8080 internally. The host
|
|
||||||
# port is published on 127.0.0.1:9898 ONLY (local debugging / not reachable from
|
|
||||||
# outside) — public access goes through nginx -> caddy.
|
|
||||||
services:
|
|
||||||
# Mount the avatar vhost snippet into caddy (the caddy override owns the base
|
|
||||||
# config; this adds one more conf.d file like the distribution override does).
|
|
||||||
caddy:
|
|
||||||
volumes:
|
|
||||||
- ./caddy/conf.d/40-avatar.caddy:/etc/caddy/conf.d/40-avatar.caddy:ro
|
|
||||||
|
|
||||||
nmsr:
|
|
||||||
build:
|
|
||||||
context: ./docker/nmsr
|
|
||||||
image: ulicraft/nmsr:local
|
|
||||||
container_name: nmsr
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "127.0.0.1:9898:8080"
|
|
||||||
volumes:
|
|
||||||
- ./nmsr/config.toml:/nmsr/config.toml:ro
|
|
||||||
networks:
|
|
||||||
- mcnet
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# Static toggle — apex landing page + launcher downloads.
|
|
||||||
# Layer on top of the base: `docker compose -f docker-compose.yml -f docker-compose.static.yml up -d`
|
|
||||||
# (or `tooling/build-stack.sh --up static`). Adds the static vhost snippet plus
|
|
||||||
# the www + launcher content mounts. Build www first: cd landing && pnpm run build.
|
|
||||||
services:
|
|
||||||
caddy:
|
|
||||||
volumes:
|
|
||||||
- ./caddy/conf.d/10-static.caddy:/etc/caddy/conf.d/10-static.caddy:ro
|
|
||||||
- ./www:/srv/www:ro
|
|
||||||
- ./mirror/launcher:/srv/launcher:ro
|
|
||||||
- ./caddy/caddy-root-ca.crt:/srv/ca-pub/ca.crt:ro
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
# ──────────────────────────────────────────────────────────────────
|
|
||||||
# Uptime Kuma — status/monitoring for every vhost + the Minecraft server,
|
|
||||||
# reachable at status.${BASE_DOMAIN}.
|
|
||||||
# ──────────────────────────────────────────────────────────────────
|
|
||||||
# docker compose -f docker-compose.yml -f docker-compose.caddy.yml \
|
|
||||||
# -f docker-compose.status.yml up -d
|
|
||||||
#
|
|
||||||
# Joins mcnet so monitors can probe the stack BY ITS INTERNAL SERVICE NAME
|
|
||||||
# (drasl:25585, nmsr:8080, minecraft:25565) without leaving the host — and can
|
|
||||||
# also hit the public https vhosts end-to-end through the host's DNS.
|
|
||||||
#
|
|
||||||
# Caddy reverse-proxies status.${BASE_DOMAIN} -> uptime-kuma:3001 internally
|
|
||||||
# (caddy/conf.d/50-status.caddy). The host port is published on 127.0.0.1:3001
|
|
||||||
# ONLY (local debugging / first-run admin setup) — public access goes through
|
|
||||||
# nginx -> caddy. Add status.${BASE_DOMAIN} to LE_SUBDOMAINS for its TLS cert.
|
|
||||||
#
|
|
||||||
# Monitors are stored in the kuma_data volume (Kuma has no config-as-code);
|
|
||||||
# add them once via the web UI — see README for the recommended monitor list.
|
|
||||||
services:
|
|
||||||
caddy:
|
|
||||||
volumes:
|
|
||||||
- ./caddy/conf.d/50-status.caddy:/etc/caddy/conf.d/50-status.caddy:ro
|
|
||||||
|
|
||||||
uptime-kuma:
|
|
||||||
image: louislam/uptime-kuma:1
|
|
||||||
container_name: uptime-kuma
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "127.0.0.1:3001:3001"
|
|
||||||
volumes:
|
|
||||||
- kuma_data:/app/data
|
|
||||||
networks:
|
|
||||||
- mcnet
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
kuma_data:
|
|
||||||
@@ -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,11 @@ 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
|
||||||
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 +107,69 @@ 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/.
|
||||||
|
- ./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
|
||||||
|
|
||||||
|
# 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
|
|
||||||
@@ -1,19 +1,17 @@
|
|||||||
# ──────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────
|
||||||
# Ulicraft host nginx — TLS terminator in FRONT of caddy.
|
# Ulicraft host nginx — TLS terminator in FRONT of caddy.
|
||||||
# ──────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────
|
||||||
# Alternative to ulicraft.conf.tmpl. Instead of nginx serving the static
|
# caddy (in docker-compose.yml) is the single internal ingress for ALL vhosts;
|
||||||
# files + proxying only drasl, here caddy (the docker container) is the single
|
# this host nginx terminates TLS and reverse-proxies everything to caddy by Host
|
||||||
# ingress for ALL vhosts and nginx just terminates TLS and reverse-proxies
|
# header. Prefer tooling/render-nginx.sh over hand-rendering.
|
||||||
# everything to caddy by Host header.
|
|
||||||
#
|
#
|
||||||
# Caddy must be published on a localhost port — set in .env:
|
# Caddy must be published on a localhost port — set in .env:
|
||||||
# CADDY_HTTP_PORT=8880
|
# CADDY_HTTP_PORT=8880
|
||||||
# CADDY_HTTP_BIND=127.0.0.1
|
# CADDY_HTTP_BIND=127.0.0.1
|
||||||
# and the stack brought up with the caddy override (NOT the nginx override):
|
# and the stack brought up:
|
||||||
# docker compose -f docker-compose.yml -f docker-compose.caddy.yml \
|
# docker compose up -d
|
||||||
# -f docker-compose.static.yml up -d
|
|
||||||
#
|
#
|
||||||
# Render with BOTH vars then install:
|
# Render with the vars then install:
|
||||||
# CADDY_HTTP_PORT=8880 BASE_DOMAIN=ulicraft.net APP_DIR=/home/ubuntu/mc/ulicraft-server-v1 \
|
# CADDY_HTTP_PORT=8880 BASE_DOMAIN=ulicraft.net APP_DIR=/home/ubuntu/mc/ulicraft-server-v1 \
|
||||||
# envsubst '$CADDY_HTTP_PORT $BASE_DOMAIN $APP_DIR' \
|
# envsubst '$CADDY_HTTP_PORT $BASE_DOMAIN $APP_DIR' \
|
||||||
# < nginx/ulicraft-caddy.conf.tmpl | sudo tee /etc/nginx/sites-available/ulicraft.conf
|
# < nginx/ulicraft-caddy.conf.tmpl | sudo tee /etc/nginx/sites-available/ulicraft.conf
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
# ──────────────────────────────────────────────────────────────────
|
|
||||||
# Ulicraft host nginx — TLS ingress with Let's Encrypt certs.
|
|
||||||
# ──────────────────────────────────────────────────────────────────
|
|
||||||
# Template. Render with BOTH vars then install:
|
|
||||||
#
|
|
||||||
# APP_DIR=/home/ubuntu/mc/ulicraft-server-v1 BASE_DOMAIN=ulicraft.net \
|
|
||||||
# envsubst '$APP_DIR $BASE_DOMAIN' \
|
|
||||||
# < nginx/ulicraft.conf.tmpl | sudo tee /etc/nginx/sites-available/ulicraft.conf
|
|
||||||
# sudo ln -sf /etc/nginx/sites-available/ulicraft.conf /etc/nginx/sites-enabled/
|
|
||||||
# sudo nginx -t && sudo systemctl reload nginx
|
|
||||||
#
|
|
||||||
# Prereqs:
|
|
||||||
# - certs issued: tooling/issue-letsencrypt.sh -> $APP_DIR/certs/<name>/{cert,key}.pem
|
|
||||||
# - the docker stack up with the nginx override (publishes drasl on 127.0.0.1:25585):
|
|
||||||
# docker compose -f docker-compose.yml -f docker-compose.nginx.yml up -d
|
|
||||||
# - nginx's user (www-data) can READ $APP_DIR/{www,pack,custom,certs}
|
|
||||||
# ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
# HTTP -> HTTPS for every name.
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
listen [::]:80;
|
|
||||||
server_name ${BASE_DOMAIN} auth.${BASE_DOMAIN} pack.${BASE_DOMAIN};
|
|
||||||
return 301 https://$host$request_uri;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Apex — static landing page (./www) + launcher downloads (./mirror/launcher).
|
|
||||||
# /ca.crt from the caddy path is intentionally omitted: LE certs are publicly
|
|
||||||
# trusted, so guests need no CA import.
|
|
||||||
server {
|
|
||||||
listen 443 ssl http2;
|
|
||||||
listen [::]:443 ssl http2;
|
|
||||||
server_name ${BASE_DOMAIN};
|
|
||||||
|
|
||||||
ssl_certificate ${APP_DIR}/certs/${BASE_DOMAIN}/cert.pem;
|
|
||||||
ssl_certificate_key ${APP_DIR}/certs/${BASE_DOMAIN}/key.pem;
|
|
||||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
|
||||||
|
|
||||||
# Launcher downloads (populated by tooling/fetch-launcher.sh -> ./mirror/launcher).
|
|
||||||
location /launcher/ {
|
|
||||||
alias ${APP_DIR}/mirror/launcher/;
|
|
||||||
autoindex on;
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
|
||||||
root ${APP_DIR}/www;
|
|
||||||
index index.html;
|
|
||||||
try_files $uri $uri/ =404;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Pack — packwiz metadata (./pack) + custom jars (./custom at /custom/).
|
|
||||||
server {
|
|
||||||
listen 443 ssl http2;
|
|
||||||
listen [::]:443 ssl http2;
|
|
||||||
server_name pack.${BASE_DOMAIN};
|
|
||||||
|
|
||||||
ssl_certificate ${APP_DIR}/certs/pack.${BASE_DOMAIN}/cert.pem;
|
|
||||||
ssl_certificate_key ${APP_DIR}/certs/pack.${BASE_DOMAIN}/key.pem;
|
|
||||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
|
||||||
|
|
||||||
# Locally-hosted custom mod jars live outside ./pack; expose them at /custom/.
|
|
||||||
location /custom/ {
|
|
||||||
alias ${APP_DIR}/custom/;
|
|
||||||
autoindex on;
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
|
||||||
root ${APP_DIR}/pack;
|
|
||||||
autoindex on;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Auth — reverse proxy to drasl (published on localhost by the nginx override).
|
|
||||||
server {
|
|
||||||
listen 443 ssl http2;
|
|
||||||
listen [::]:443 ssl http2;
|
|
||||||
server_name auth.${BASE_DOMAIN};
|
|
||||||
|
|
||||||
ssl_certificate ${APP_DIR}/certs/auth.${BASE_DOMAIN}/cert.pem;
|
|
||||||
ssl_certificate_key ${APP_DIR}/certs/auth.${BASE_DOMAIN}/key.pem;
|
|
||||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
|
||||||
|
|
||||||
client_max_body_size 4m; # skin uploads
|
|
||||||
|
|
||||||
location / {
|
|
||||||
proxy_pass http://127.0.0.1:25585;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
# Ulicraft modpack (packwiz)
|
# Ulicraft modpack (packwiz)
|
||||||
|
|
||||||
This directory is the **single source of truth** for the modpack (MC 1.21.1, NeoForge 21.1.209). Curate mods from here with `packwiz modrinth add <slug>` (prefer Modrinth over CurseForge); each mod produces a `mods/<slug>.pw.toml` metadata file referencing the upstream CDN. **Client-only mods must be tagged `side = "client"`** in their `.pw.toml` so the itzg server does not pull and crash on them. After adding/removing mods run `packwiz refresh` to recompute `index.toml`; for offline LAN play run `tooling/mirror-mods.sh` to vendor the jars and rewrite the download URLs to the local Caddy mirror. Curation is an ongoing manual task — no mods are added yet.
|
This directory is the **single source of truth** for the modpack (MC 1.21.1, NeoForge 21.1.209). Curate mods from here with `packwiz modrinth add <slug>` (prefer Modrinth over CurseForge); each mod produces a `mods/<slug>.pw.toml` metadata file referencing the upstream CDN. **Client-only mods must be tagged `side = "client"`** in their `.pw.toml` so the itzg server does not pull and crash on them. After adding/removing mods run `packwiz refresh` to recompute `index.toml`. Clients and the itzg server fetch jars from the upstream CDN referenced in each `.pw.toml`. Curation is an ongoing manual task — no mods are added yet.
|
||||||
|
|||||||
@@ -1,124 +1,127 @@
|
|||||||
# Ulicraft Server — Plan Overview
|
# Ulicraft Server — Plan Overview
|
||||||
|
|
||||||
Self-hosted modded Minecraft LAN-party stack, designed to run **fully offline**
|
Self-hosted modded Minecraft stack — a single Docker Compose file holds the
|
||||||
(air-gapped) during the party while remaining a persistent homelab service.
|
entire thing, fronted by the host's own nginx (Let's Encrypt TLS) and a caddy
|
||||||
|
internal router. Built for a LAN party but runs as a persistent homelab service.
|
||||||
|
Assumes the internet is present.
|
||||||
|
|
||||||
## Single source of configuration
|
## Single source of configuration
|
||||||
|
|
||||||
Everything is driven by two env vars in `.env`:
|
Everything is driven by one env var in `.env`:
|
||||||
|
|
||||||
```
|
```
|
||||||
BASE_DOMAIN=ulicraft.local # all services are subdomains of this
|
BASE_DOMAIN=ulicraft.net # all services are subdomains of this
|
||||||
HOST_LAN_IP=192.168.x.x # the Docker host's LAN IP (dnsmasq target)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
> `.local` collides with mDNS/Bonjour (RFC 6762). macOS + Linux Avahi may resolve
|
`render-config.sh` substitutes **only** `${BASE_DOMAIN}` into the templates.
|
||||||
> `*.local` via multicast and bypass dnsmasq. Accepted risk for now; alternatives
|
DNS is **not** this repo's concern: point `${BASE_DOMAIN}` and all subdomains
|
||||||
> if it bites: `.lan`, `home.arpa`. Flagged, user chose `.local`.
|
(`auth.` `pack.` `avatar.` `status.` `distribution.` `www.`) at the host running
|
||||||
|
nginx; that is configured outside this repo.
|
||||||
|
|
||||||
## Subdomain / ingress map
|
## Subdomain / ingress map
|
||||||
|
|
||||||
```
|
```
|
||||||
ulicraft.local → caddy → landing page (launcher downloads + guide)
|
ulicraft.net → nginx → caddy → landing page (/srv/www) + /launcher/
|
||||||
auth.ulicraft.local → caddy → drasl:25585 (auth web UI + Yggdrasil API)
|
auth.ulicraft.net → nginx → caddy → drasl:25585 (auth web UI + Yggdrasil API)
|
||||||
packwiz.ulicraft.local → caddy → /srv/pack (pack metadata + mod-jar mirror)
|
pack.ulicraft.net → nginx → caddy → /srv/pack (packwiz pack metadata)
|
||||||
assets.ulicraft.local → caddy → /srv/assets (MC asset-object mirror; HTTPS forced — see 10)
|
avatar.ulicraft.net → nginx → caddy → nmsr:8080 (skin/avatar renderer)
|
||||||
mc.ulicraft.local:25565 → minecraft (NOT http; raw MC protocol)
|
status.ulicraft.net → nginx → caddy → uptime-kuma:3001 (status page)
|
||||||
|
distribution.ulicraft.net → nginx → caddy → /srv/distribution (static, another repo)
|
||||||
|
mc.ulicraft.net:25565 → minecraft (raw MC protocol, not HTTP)
|
||||||
```
|
```
|
||||||
|
|
||||||
Caddy is the only HTTP ingress (plain `:80` for now, TLS/step-ca later).
|
The host's own nginx is the only public ingress: it terminates TLS with Let's
|
||||||
drasl's host port is removed — internal only, reached through Caddy.
|
Encrypt certs and reverse-proxies every vhost to caddy by Host header. caddy is
|
||||||
|
published on a **localhost-only** port (`CADDY_HTTP_BIND=127.0.0.1`,
|
||||||
|
`CADDY_HTTP_PORT=8880`) and acts as the internal router. drasl, nmsr and
|
||||||
|
uptime-kuma have no host ports — they are reached only through caddy.
|
||||||
|
|
||||||
### Internal resolution trick
|
### Internal resolution trick
|
||||||
|
|
||||||
Containers use the Docker resolver, NOT dnsmasq. Caddy gets network aliases so
|
Containers use the Docker resolver. caddy gets network aliases so the stack
|
||||||
the stack resolves its own subdomains internally:
|
resolves its own subdomains internally:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
caddy:
|
caddy:
|
||||||
networks:
|
networks:
|
||||||
mcnet:
|
mcnet:
|
||||||
aliases: [ "auth.${BASE_DOMAIN}", "packwiz.${BASE_DOMAIN}" ]
|
aliases: [ "auth.${BASE_DOMAIN}", "pack.${BASE_DOMAIN}" ]
|
||||||
```
|
```
|
||||||
|
|
||||||
Result: `http://auth.ulicraft.local/authlib-injector` and
|
Result: `http://auth.ulicraft.net/authlib-injector` and
|
||||||
`http://packwiz.ulicraft.local/pack.toml` resolve identically inside and outside
|
`http://pack.ulicraft.net/pack.toml` resolve identically inside the stack
|
||||||
the stack. dnsmasq exists **only for guest laptops**.
|
(minecraft → caddy) as the public names do outside it (client → nginx → caddy).
|
||||||
|
|
||||||
## Online vs offline split
|
## Build / run flow
|
||||||
|
|
||||||
```
|
```
|
||||||
PRE-PARTY (internet, run once):
|
PREP (run once, online):
|
||||||
1. curate packwiz pack → see 04-packwiz.md
|
1. render configs → tooling/render-config.sh (08-tooling.md)
|
||||||
2. mirror mod jars + rewrite URLs → tooling/mirror-mods.sh (08-tooling.md)
|
2. build landing page → cd landing && pnpm run build → www/ (09-landing.md)
|
||||||
3. pre-bake server volume → TYPE=NEOFORGE install (05-minecraft.md)
|
3. download launcher releases → tooling/fetch-launcher.sh (06-launcher.md)
|
||||||
4. download launcher releases → tooling/fetch-launcher.sh (06-launcher.md)
|
4. curate packwiz pack → see 04-packwiz.md
|
||||||
5. capture+mirror client air-gap → tooling/mirror-airgap.sh (11-full-airgap-mirror.md)
|
5. (TLS) issue Let's Encrypt certs → tooling/issue-letsencrypt.sh (15-letsencrypt.md)
|
||||||
6. build landing page → cd landing && pnpm run build → www/ (09-landing.md)
|
6. (ingress) render+install nginx → tooling/render-nginx.sh --install (15-letsencrypt.md)
|
||||||
7. render configs from templates → tooling/render-config.sh (08-tooling.md)
|
|
||||||
|
|
||||||
PARTY (air-gapped):
|
UP:
|
||||||
dnsmasq : *.ulicraft.local → HOST_LAN_IP
|
docker compose up -d --build
|
||||||
caddy : local CDN + landing page
|
|
||||||
drasl : password login (NO Keycloak/OIDC for now)
|
|
||||||
minecraft: TYPE=CUSTOM, no Mojang re-resolution
|
|
||||||
guests : install Fjord launcher, import pack, add authlib account → auth subdomain
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Bring-up is a single command. There are no run modes and no override compose
|
||||||
|
files — one `docker-compose.yml` holds drasl, minecraft, mc-backup, caddy, nmsr
|
||||||
|
and uptime-kuma.
|
||||||
|
|
||||||
## Why each hard call was made
|
## Why each hard call was made
|
||||||
|
|
||||||
- **No OIDC/Keycloak (for now)** — drasl password login. Keycloak left out of this
|
- **No OIDC/Keycloak (for now)** — drasl password login. Keycloak left out of
|
||||||
stack's scope. Re-add later if desired.
|
this stack's scope. Re-add later if desired.
|
||||||
- **itzg can't air-gap-boot in NEOFORGE mode** (re-resolves Mojang metadata every
|
- **packwiz only serves metadata** — `.pw.toml` points at Modrinth/CF CDNs;
|
||||||
start, issue #2340). Pre-bake then switch to `TYPE=CUSTOM`.
|
server and clients fetch jars from there. The internet is assumed present.
|
||||||
- **packwiz only serves metadata** — `.pw.toml` points at Modrinth CDN. Offline
|
- **Fjord launcher** has authlib-injector built in → no manual JVM agent on
|
||||||
needs jars mirrored onto Caddy + URL rewrite.
|
clients.
|
||||||
- **Fjord launcher** has authlib-injector built in → no manual JVM agent on clients.
|
- **host nginx in front of caddy** — real Let's Encrypt certs (JVM trusts them
|
||||||
|
with no CA import) and a single public TLS terminator; caddy stays an internal
|
||||||
|
Host-routed multiplexer for the stack's own services.
|
||||||
|
|
||||||
## Target folder layout
|
## Target folder layout
|
||||||
|
|
||||||
```
|
```
|
||||||
.
|
.
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml # the entire stack (no overrides)
|
||||||
├── .env / .env.example # BASE_DOMAIN, HOST_LAN_IP, RCON_PASSWORD
|
├── .env / .env.example # BASE_DOMAIN, RCON_PASSWORD, CADDY_*, LE block
|
||||||
├── caddy/Caddyfile # {$BASE_DOMAIN} vhosts + landing page
|
├── caddy/conf.d/ # per-vhost snippets (00-core, 10-static, …)
|
||||||
├── dnsmasq/dnsmasq.conf.tmpl # wildcard template
|
├── nginx/ulicraft-caddy.conf.tmpl # host nginx → caddy template (TLS terminator)
|
||||||
|
├── certs/<name>/{cert,key}.pem # Let's Encrypt output (gitignored)
|
||||||
├── drasl/config/
|
├── drasl/config/
|
||||||
│ ├── config.toml.tmpl # envsubst source
|
│ ├── config.toml.tmpl # render-config.sh source
|
||||||
│ └── config.toml # generated (gitignored)
|
│ └── config.toml # generated (gitignored)
|
||||||
|
├── nmsr/ # avatar renderer config + Dockerfile context
|
||||||
|
├── docker/nmsr/ # nmsr image build context
|
||||||
├── pack/ # packwiz source of truth
|
├── pack/ # packwiz source of truth
|
||||||
├── mirror/ # vendored mod jars + launcher releases (gitignored)
|
├── launcher/ # vendored Fjord launcher releases (gitignored)
|
||||||
├── runtime/ # authlib-injector.jar (legacy; may be unused w/ Fjord)
|
├── runtime/ # authlib-injector.jar
|
||||||
├── tooling/
|
├── tooling/ # render/fetch/issue scripts (see 08-tooling.md)
|
||||||
│ ├── render-config.sh # envsubst templates → real configs, halt if unset
|
|
||||||
│ ├── mirror-mods.sh # vendor mod jars + rewrite .pw.toml URLs
|
|
||||||
│ ├── mirror-assets.sh # mirror MC asset objects for offline clients (10)
|
|
||||||
│ └── fetch-launcher.sh # download all Fjord release assets
|
|
||||||
├── landing/ # Astro+TS landing source (build → www/)
|
├── landing/ # Astro+TS landing source (build → www/)
|
||||||
│ ├── astro.config.mjs # outDir ../www
|
├── www/ # landing build output — caddy apex (gitignored)
|
||||||
│ ├── public/logo.png # stone 3D logo
|
|
||||||
│ └── src/{data/site.ts,pages/index.astro}
|
|
||||||
├── www/ # caddy landing page — GENERATED by landing build (gitignored)
|
|
||||||
├── backups/
|
├── backups/
|
||||||
└── plan/ # these files
|
└── plan/ # these files
|
||||||
```
|
```
|
||||||
|
|
||||||
## Service plan files
|
## Service plan files
|
||||||
|
|
||||||
- `01-dnsmasq.md` — LAN wildcard DNS
|
- `02-caddy.md` — internal router (vhost conf.d snippets) behind host nginx
|
||||||
- `02-caddy.md` — ingress + local CDN + landing page
|
|
||||||
- `03-drasl.md` — auth (password login)
|
- `03-drasl.md` — auth (password login)
|
||||||
- `04-packwiz.md` — modpack source + mod mirror
|
- `04-packwiz.md` — modpack source
|
||||||
- `05-minecraft.md` — itzg NeoForge server + offline launch
|
- `05-minecraft.md` — itzg NeoForge server
|
||||||
- `06-launcher.md` — Fjord launcher fetch + distribution
|
- `06-launcher.md` — Fjord launcher fetch + distribution
|
||||||
- `07-mc-backup.md` — world backups
|
- `07-mc-backup.md` — world backups
|
||||||
- `08-tooling.md` — render/mirror/fetch scripts
|
- `08-tooling.md` — render/fetch/issue scripts
|
||||||
- `09-landing.md` — Astro+TS guest onboarding page → www/
|
- `09-landing.md` — Astro+TS guest onboarding page → www/
|
||||||
- `10-assets-mirror.md` — lighter alternative: objects-only Fjord Assets Server override
|
|
||||||
- `11-full-airgap-mirror.md` — CHOSEN: DNS+TLS transparent mirror for full client air-gap
|
|
||||||
- `12-build-order.md` — commit-per-task build sequence
|
- `12-build-order.md` — commit-per-task build sequence
|
||||||
- `13-dns-and-run-modes.md` — domain (.lan), online/airgap modes, party DNS, mDNS option
|
- `14-deploy.md` — redeploy to the production host
|
||||||
|
- `15-letsencrypt.md` — Let's Encrypt (OVH DNS-01) + host nginx ingress
|
||||||
|
|
||||||
## Boot/dependency order
|
## Boot/dependency order
|
||||||
|
|
||||||
`dnsmasq` → `caddy` (aliases) → `drasl` → `minecraft` (depends on drasl + packwiz served by caddy) → `mc-backup`.
|
`caddy` (aliases) → `drasl` → `minecraft` (depends on drasl + pack served by
|
||||||
|
caddy) → `mc-backup`. `nmsr` and `uptime-kuma` are always-on and join `mcnet`.
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
# dnsmasq — LAN wildcard DNS
|
|
||||||
|
|
||||||
> **SUPERSEDED for the default path — see `13-dns-and-run-modes.md`.** DNS is now
|
|
||||||
> your own party server (records via `tooling/dns-records.sh`); dnsmasq moved to
|
|
||||||
> an optional turnkey layer (`docker-compose.dnsmasq.yml`). Default domain is
|
|
||||||
> `.lan` (not `.local`). The wildcard/spoof template below still applies when you
|
|
||||||
> opt into dnsmasq.
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Provides name resolution for guest laptops so `*.ulicraft.local` resolves to the
|
|
||||||
Docker host. Replaces the homelab's AdGuard (dropped for the LAN party). Image:
|
|
||||||
`jpillora/dnsmasq` (dnsmasq wrapped in webproc, optional web UI on :8080).
|
|
||||||
|
|
||||||
Config is a **file**, not env-driven — `/etc/dnsmasq.conf`, standard dnsmasq
|
|
||||||
format. Rendered from a template by `tooling/render-config.sh`.
|
|
||||||
|
|
||||||
Only guest laptops point their DNS here. Stack-internal resolution is handled by
|
|
||||||
Docker network aliases on Caddy (see `02-caddy.md`), so dnsmasq is not on the
|
|
||||||
critical path for the server's own services.
|
|
||||||
|
|
||||||
## Config template
|
|
||||||
|
|
||||||
`dnsmasq/dnsmasq.conf.tmpl`:
|
|
||||||
```
|
|
||||||
# wildcard: every *.ulicraft.local → host LAN IP
|
|
||||||
address=/${BASE_DOMAIN}/${HOST_LAN_IP}
|
|
||||||
# upstream optional; party is offline so may be omitted
|
|
||||||
no-resolv
|
|
||||||
```
|
|
||||||
|
|
||||||
### Air-gap spoof records (see 11)
|
|
||||||
|
|
||||||
For full client air-gap, this file also spoofs the real upstream hosts → our
|
|
||||||
Caddy mirror (`piston-meta`/`piston-data`/`libraries.minecraft.net`/
|
|
||||||
`maven.neoforged.net`/`resources.download.minecraft.net`/`meta.prismlauncher.org`).
|
|
||||||
Full list + rationale in `11-full-airgap-mirror.md`. Keep in sync with the
|
|
||||||
capture step. Auth hosts (session/textures/api.mojang) are NOT spoofed — drasl
|
|
||||||
handles those via authlib-injector.
|
|
||||||
|
|
||||||
## Gotcha — port 53 conflict
|
|
||||||
|
|
||||||
Linux Docker hosts usually run `systemd-resolved` bound to `:53`. Publishing
|
|
||||||
`53:53` will fail with `address already in use`. Resolve by one of:
|
|
||||||
- Bind dnsmasq only to the LAN IP: publish `${HOST_LAN_IP}:53:53/udp` (+tcp)
|
|
||||||
- Or disable resolved's stub listener (`DNSStubListener=no`) and restart resolved
|
|
||||||
|
|
||||||
## Gotcha — `.local` / mDNS
|
|
||||||
|
|
||||||
See overview. Avahi may shortcut `*.local` to multicast before hitting dnsmasq.
|
|
||||||
Test on a macOS client early; if broken, switch `BASE_DOMAIN` to `.lan`.
|
|
||||||
|
|
||||||
## Tasks
|
|
||||||
|
|
||||||
- [ ] Add `dnsmasq` service to `docker-compose.yml` (`jpillora/dnsmasq`)
|
|
||||||
- [ ] Create `dnsmasq/dnsmasq.conf.tmpl` with wildcard `address=` line
|
|
||||||
- [ ] Wire `render-config.sh` to emit `dnsmasq/dnsmasq.conf` (gitignored)
|
|
||||||
- [ ] Decide port-53 strategy (bind to `HOST_LAN_IP` vs disable resolved stub)
|
|
||||||
- [ ] Publish `53/udp` + `53/tcp`; mount rendered conf read-only
|
|
||||||
- [ ] Optionally expose webproc UI (8080) behind Caddy as `dns.ulicraft.local`
|
|
||||||
- [ ] Document guest DNS setting (set DNS server = `HOST_LAN_IP`)
|
|
||||||
- [ ] Verify `*.local` resolves on Windows + macOS + Linux guests (mDNS check)
|
|
||||||
- [ ] Add air-gap spoof `address=` records (see 11) once capture confirms the host list
|
|
||||||
@@ -1,15 +1,18 @@
|
|||||||
# Caddy — ingress, local CDN, landing page
|
# Caddy — internal router behind host nginx
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
Single HTTP ingress for the stack. Three roles:
|
caddy is the stack's **internal HTTP router**, sitting behind the host's own
|
||||||
1. **Reverse proxy** — `auth.ulicraft.local` → drasl.
|
nginx (which terminates TLS — see `15-letsencrypt.md`). caddy is published only
|
||||||
2. **Local CDN / static** — `packwiz.ulicraft.local` serves pack metadata AND the
|
on a localhost-only port (`CADDY_HTTP_BIND=127.0.0.1`, `CADDY_HTTP_PORT=8880`);
|
||||||
mirrored mod jars (offline installs).
|
nginx reverse-proxies every public vhost to it by Host header. Roles:
|
||||||
3. **Landing page** — apex `ulicraft.local` serves launcher downloads + guest guide.
|
|
||||||
|
|
||||||
Plain `:80` for now (LAN). TLS via step-ca deferred. Image: `caddy:alpine`.
|
1. **Reverse proxy** — `auth.` → drasl, `avatar.` → nmsr, `status.` → uptime-kuma.
|
||||||
Caddy natively reads `{$BASE_DOMAIN}` env placeholders — no template render needed.
|
2. **Static** — `pack.` serves packwiz metadata; apex serves the landing page +
|
||||||
|
`/launcher/` downloads; `distribution.` serves a static site from another repo.
|
||||||
|
|
||||||
|
Image: `caddy:alpine`. caddy natively reads `{$BASE_DOMAIN}` env placeholders —
|
||||||
|
no template render needed.
|
||||||
|
|
||||||
## Network aliases (internal resolution)
|
## Network aliases (internal resolution)
|
||||||
|
|
||||||
@@ -19,51 +22,41 @@ caddy:
|
|||||||
mcnet:
|
mcnet:
|
||||||
aliases:
|
aliases:
|
||||||
- "auth.${BASE_DOMAIN}"
|
- "auth.${BASE_DOMAIN}"
|
||||||
- "packwiz.${BASE_DOMAIN}"
|
- "pack.${BASE_DOMAIN}"
|
||||||
```
|
```
|
||||||
Lets `minecraft` reach `http://auth.ulicraft.local/authlib-injector` and
|
Lets `minecraft` reach `http://auth.ulicraft.net/authlib-injector` and
|
||||||
`http://packwiz.ulicraft.local/pack.toml` without dnsmasq.
|
`http://pack.ulicraft.net/pack.toml` from inside the stack.
|
||||||
|
|
||||||
## Caddyfile sketch
|
## conf.d snippets
|
||||||
|
|
||||||
```
|
The Caddyfile imports per-vhost snippets from `caddy/conf.d/`:
|
||||||
{
|
|
||||||
auto_https off
|
|
||||||
}
|
|
||||||
|
|
||||||
http://{$BASE_DOMAIN} {
|
- `00-core.caddy` — `auth.` → drasl, `pack.` → `/srv/pack` (browse).
|
||||||
root * /srv/www
|
- `10-static.caddy` — apex landing (`/srv/www`) + `/launcher/*` (`/srv/launcher`).
|
||||||
file_server
|
- `30-distribution.caddy` — `distribution.` → `/srv/distribution` (static, from
|
||||||
}
|
`DISTRIBUTION_WEB_ROOT`, another repo).
|
||||||
|
- `40-avatar.caddy` — `avatar.` → `nmsr:8080` (skin/avatar renderer).
|
||||||
|
- `50-status.caddy` — `status.` → `uptime-kuma:3001` (status page).
|
||||||
|
|
||||||
http://auth.{$BASE_DOMAIN} {
|
All vhosts are plain `http://…`; TLS is nginx's job. Mounts: `./www:/srv/www:ro`,
|
||||||
reverse_proxy drasl:25585
|
`./pack:/srv/pack:ro`, `./launcher:/srv/launcher:ro`,
|
||||||
}
|
`${DISTRIBUTION_WEB_ROOT}:/srv/distribution:ro`.
|
||||||
|
|
||||||
http://packwiz.{$BASE_DOMAIN} {
|
|
||||||
root * /srv/pack
|
|
||||||
file_server browse
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Mounts: `./www:/srv/www:ro`, `./pack:/srv/pack:ro`, `./mirror/launcher:/srv/www/launcher:ro`.
|
|
||||||
|
|
||||||
## Notes / risks
|
## Notes / risks
|
||||||
|
|
||||||
- drasl behind a proxy: docs don't detail `X-Forwarded-*` trust. On plain HTTP LAN
|
- drasl behind a proxy: with matching `BaseURL` and nginx terminating TLS it is
|
||||||
with matching `BaseURL` it should be fine; revisit when TLS is added.
|
fine; nginx passes the original Host through to caddy → drasl.
|
||||||
- `file_server browse` gives a directory listing — handy for debugging the pack.
|
- `file_server browse` gives a directory listing — handy for debugging the pack.
|
||||||
- Mod-jar mirror lives under `/srv/pack/mods/` so packwiz URLs and jars share host.
|
- `/launcher/` is a sibling mount (`/srv/launcher`), not nested under the
|
||||||
|
read-only `/srv/www`; `handle_path` strips the prefix.
|
||||||
|
|
||||||
## Tasks
|
## Tasks
|
||||||
|
|
||||||
- [ ] Add `caddy` service to `docker-compose.yml`, ports `80:80` (+`443` later)
|
- [ ] `caddy` service in `docker-compose.yml`, published `127.0.0.1:${CADDY_HTTP_PORT}:80`
|
||||||
- [ ] Pass `BASE_DOMAIN` into caddy env
|
- [ ] Pass `BASE_DOMAIN` + `DISTRIBUTION_WEB_ROOT` into caddy env
|
||||||
- [ ] Create `caddy/Caddyfile` with apex + auth + packwiz vhosts
|
- [ ] Caddyfile imports `conf.d/*.caddy`
|
||||||
- [ ] Add network aliases for `auth.` and `packwiz.` subdomains
|
- [ ] Network aliases for `auth.` and `pack.` subdomains
|
||||||
- [ ] Mount `./www`, `./pack`, `./mirror/launcher`
|
- [ ] Mount `./www`, `./pack`, `./launcher`, `${DISTRIBUTION_WEB_ROOT}`
|
||||||
- [ ] Confirm reverse_proxy to drasl works (auth web UI loads, Yggdrasil API responds)
|
- [ ] Confirm reverse_proxy to drasl works (auth web UI loads, Yggdrasil API responds)
|
||||||
- [ ] Verify internal alias resolution from `minecraft` container (curl pack.toml)
|
- [ ] Verify internal alias resolution from `minecraft` container (curl pack.toml)
|
||||||
- [ ] Air-gap mirror: add per-host `tls internal` vhosts + aliases + mounts (see 11)
|
- [ ] Verify nmsr + uptime-kuma proxied (`avatar.` renders, `status.` loads)
|
||||||
- [ ] Export Caddy root CA for guest trust (LAN-party TLS = `tls internal`; step-ca deferred)
|
|
||||||
- [ ] Defer (reminder): graduate `tls internal` → step-ca for persistent homelab
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
Self-hosted, Yggdrasil-compatible auth + skin server. Drop-in Mojang replacement
|
Self-hosted, Yggdrasil-compatible auth + skin server. Drop-in Mojang replacement
|
||||||
via authlib-injector. Image: `unmojang/drasl:latest`. Reached only through Caddy
|
via authlib-injector. Image: `unmojang/drasl:latest`. Reached only through Caddy
|
||||||
at `auth.ulicraft.local`; no host port.
|
at `auth.ulicraft.net`; no host port.
|
||||||
|
|
||||||
**OIDC/Keycloak is OUT for now** — drasl runs in **password-login** mode
|
**OIDC/Keycloak is OUT for now** — drasl runs in **password-login** mode
|
||||||
(`AllowPasswordLogin = true`). Admin + guests register with a username/password on
|
(`AllowPasswordLogin = true`). Admin + guests register with a username/password on
|
||||||
@@ -35,7 +35,7 @@ AllowPasswordLogin = true
|
|||||||
- **1.21+ secure profile**: drasl `SignPublicKeys = false` MUST pair with server
|
- **1.21+ secure profile**: drasl `SignPublicKeys = false` MUST pair with server
|
||||||
`ENFORCE_SECURE_PROFILE=FALSE`. Linked — both or neither.
|
`ENFORCE_SECURE_PROFILE=FALSE`. Linked — both or neither.
|
||||||
- **authlib endpoint** = `BaseURL` + `/authlib-injector` →
|
- **authlib endpoint** = `BaseURL` + `/authlib-injector` →
|
||||||
`http://auth.ulicraft.local/authlib-injector`. Must match on server and client.
|
`http://auth.ulicraft.net/authlib-injector`. Must match on server and client.
|
||||||
Network alias makes this identical inside/outside the stack.
|
Network alias makes this identical inside/outside the stack.
|
||||||
- **`Domain` affects skins** — if wrong, authlib clients may not see skins.
|
- **`Domain` affects skins** — if wrong, authlib clients may not see skins.
|
||||||
|
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
# Blessing Skin — auth + skin server (Drasl alternative)
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Drop-in alternative to Drasl (`plan/03-drasl.md`). Blessing Skin Server (BSS) is
|
|
||||||
a PHP skin station; the **yggdrasil-api plugin** gives it a Yggdrasil API that
|
|
||||||
authlib-injector talks to — same end result as Drasl, different internals.
|
|
||||||
|
|
||||||
Run it as an override **instead of** Drasl:
|
|
||||||
|
|
||||||
```
|
|
||||||
docker compose -f docker-compose.yml -f docker-compose.blessingskin.yml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
The override (`docker-compose.blessingskin.yml`) repoints Caddy's `auth.*` vhost
|
|
||||||
at `blessing-skin:80`, adds `mariadb` + `blessing-skin`, and switches the
|
|
||||||
Minecraft authlib endpoint to `/api/yggdrasil`. Drasl stays defined but idle and
|
|
||||||
unreachable (compose merges `depends_on`, so an override can't remove it);
|
|
||||||
`docker compose … stop drasl` after up if you want it down.
|
|
||||||
|
|
||||||
## Drasl vs Blessing Skin — what changes
|
|
||||||
|
|
||||||
| | Drasl | Blessing Skin |
|
|
||||||
|---|---|---|
|
|
||||||
| Backend | single Go binary | PHP app + **MariaDB** (no SQLite) |
|
|
||||||
| Config | rendered `config.toml` | **web install wizard** + DB |
|
|
||||||
| Yggdrasil API | built in | **yggdrasil-api plugin** (install + enable) |
|
|
||||||
| authlib endpoint | `…/authlib-injector` | `…/api/yggdrasil` |
|
|
||||||
| OIDC/Keycloak | supported (future) | not native (OAuth plugins exist) |
|
|
||||||
| Server online-mode | repo used FALSE | **TRUE** (real session validation) |
|
|
||||||
|
|
||||||
## Setup (first run)
|
|
||||||
|
|
||||||
1. `cp .env.example .env`, fill the `BS_*` vars. Generate `BS_APP_KEY`:
|
|
||||||
```
|
|
||||||
docker run --rm azusamikan/blessing-skin-server-docker:latest \
|
|
||||||
php artisan key:generate --show
|
|
||||||
```
|
|
||||||
2. Bring the stack up with the override (command above).
|
|
||||||
3. Open `http://auth.${BASE_DOMAIN}` → BSS install wizard. DB host = `mariadb`,
|
|
||||||
port `3306`, name/user/pass = the `BS_*` values. Create the admin account.
|
|
||||||
4. Admin panel → **Plugins → Plugin Market** → install **yggdrasil-api** →
|
|
||||||
enable it. (Needs internet; for air-gap, pre-place the plugin jar/zip into
|
|
||||||
the `bs_plugins` volume.) After enabling, the API root is live at
|
|
||||||
`http://auth.${BASE_DOMAIN}/api/yggdrasil`.
|
|
||||||
5. Each player: register on the BSS site, add a character whose name == their
|
|
||||||
in-game player name, upload a skin.
|
|
||||||
6. Restart minecraft if it came up before the plugin was enabled.
|
|
||||||
|
|
||||||
## Client (Prism / authlib-injector)
|
|
||||||
|
|
||||||
- authlib-injector URL: `http://auth.${BASE_DOMAIN}/api/yggdrasil`
|
|
||||||
- Login = BSS **email + password** (BSS uses email as the account id).
|
|
||||||
- Must match the server's `-javaagent` arg exactly (same host + path).
|
|
||||||
|
|
||||||
## Gotchas
|
|
||||||
|
|
||||||
- **`/api/yggdrasil`, not `/authlib-injector`.** Wrong path → silent "Invalid
|
|
||||||
session". Server `JVM_OPTS` and every client must agree.
|
|
||||||
- **ONLINE_MODE=TRUE.** authlib-injector only authenticates when online-mode is
|
|
||||||
on; the server then validates the join against BSS. (The Drasl variant in this
|
|
||||||
repo set FALSE — do not copy that here.)
|
|
||||||
- **Secure profile (1.21+).** Override ships `ENFORCE_SECURE_PROFILE=FALSE` for
|
|
||||||
safety. yggdrasil-api *does* sign profile keys, so TRUE may work — flip and
|
|
||||||
test if signed chat matters; revert on "Invalid signature".
|
|
||||||
- **APP_KEY must persist.** Set `BS_APP_KEY` in `.env`. Losing it invalidates
|
|
||||||
all sessions and breaks encrypted data.
|
|
||||||
- **DB is the source of truth.** `bs_db` volume holds accounts; `bs_storage`
|
|
||||||
holds skins; `bs_plugins` holds the yggdrasil-api plugin. Back these up.
|
|
||||||
- **Air-gap:** the plugin market needs internet. Install + enable yggdrasil-api
|
|
||||||
while online (it persists in `bs_plugins`), or stage the plugin manually.
|
|
||||||
|
|
||||||
## Image note
|
|
||||||
|
|
||||||
`azusamikan/blessing-skin-server-docker:latest` is a community image and the
|
|
||||||
env-var names (`DB_*`, `APP_KEY`) may differ slightly by tag — if auto-config
|
|
||||||
doesn't take, the web wizard is the reliable path. Official source:
|
|
||||||
https://github.com/bs-community/blessing-skin-server ,
|
|
||||||
plugin: https://github.com/bs-community/yggdrasil-api , authlib-injector setup:
|
|
||||||
https://github.com/bs-community/blessing-skin-manual/blob/master/man/yggdrasil-api/authlib-injector.md
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# packwiz — modpack source of truth + offline mod mirror
|
# packwiz — modpack source of truth
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
@@ -7,30 +7,21 @@ directory is the **single source of truth** for mods, consumed by both:
|
|||||||
- the **server** (itzg `PACKWIZ_URL`), and
|
- the **server** (itzg `PACKWIZ_URL`), and
|
||||||
- **clients** (Fjord launcher imports the pack / packwiz-installer).
|
- **clients** (Fjord launcher imports the pack / packwiz-installer).
|
||||||
|
|
||||||
Caddy serves `./pack` at `packwiz.ulicraft.local`. packwiz only emits **metadata**
|
Caddy serves `./pack` at `pack.ulicraft.net`. packwiz emits **metadata**
|
||||||
(`pack.toml`, `index.toml`, `mods/*.pw.toml`); the `.pw.toml` files reference
|
(`pack.toml`, `index.toml`, `mods/*.pw.toml`); the `.pw.toml` files reference
|
||||||
Modrinth/CF **CDN URLs**, so true offline requires mirroring jars locally.
|
Modrinth/CF **CDN URLs**, so server and clients fetch the jars from those CDNs.
|
||||||
|
The stack assumes the internet is present.
|
||||||
|
|
||||||
## Behaviors confirmed
|
## Behaviors confirmed
|
||||||
|
|
||||||
- **Prune**: packwiz-installer tracks a manifest and **removes** client mods no
|
- **Prune**: packwiz-installer tracks a manifest and **removes** client mods no
|
||||||
longer in the index on re-run. (Server side via itzg likewise re-syncs.)
|
longer in the index on re-run. (Server side via itzg likewise re-syncs.)
|
||||||
- **Download source**: jars come from CDN URLs in `.pw.toml`, NOT the pack host —
|
- **Download source**: jars come from the CDN URLs in `.pw.toml`, not the pack
|
||||||
unless URLs are rewritten to the local mirror.
|
host (which serves only metadata).
|
||||||
- **itzg side filtering**: itzg downloads **server-side mods only**. Client-only
|
- **itzg side filtering**: itzg downloads **server-side mods only**. Client-only
|
||||||
mods MUST be tagged `side = "client"` (not `both`) or the server may pull and
|
mods MUST be tagged `side = "client"` (not `both`) or the server may pull and
|
||||||
crash on them.
|
crash on them.
|
||||||
|
|
||||||
## Offline mod mirror (the key work)
|
|
||||||
|
|
||||||
`tooling/mirror-mods.sh` (see `08-tooling.md`):
|
|
||||||
1. Parse each `mods/*.pw.toml`, download the jar into `./mirror/mods/`.
|
|
||||||
2. Copy jars into `./pack/mods-files/` (served at `packwiz.ulicraft.local/...`).
|
|
||||||
3. Rewrite each `[download] url` to point at `http://packwiz.ulicraft.local/...`.
|
|
||||||
4. `packwiz refresh` to recompute hashes/index.
|
|
||||||
|
|
||||||
Result: server + clients install mods entirely from the LAN. No CDN at party time.
|
|
||||||
|
|
||||||
## NeoForge pinning
|
## NeoForge pinning
|
||||||
|
|
||||||
`pack.toml` pins MC `1.21.1` + NeoForge `21.1.x`. Verify exact NeoForge build
|
`pack.toml` pins MC `1.21.1` + NeoForge `21.1.x`. Verify exact NeoForge build
|
||||||
@@ -42,7 +33,5 @@ against projects.neoforged.net before locking.
|
|||||||
- [ ] Curate mod shortlist (perf/tech/magic/storage/QoL/worldgen/map/voice)
|
- [ ] Curate mod shortlist (perf/tech/magic/storage/QoL/worldgen/map/voice)
|
||||||
- [ ] Tag client-only mods `side = "client"`
|
- [ ] Tag client-only mods `side = "client"`
|
||||||
- [ ] `packwiz refresh`; verify `index.toml` committed (clients fetch it)
|
- [ ] `packwiz refresh`; verify `index.toml` committed (clients fetch it)
|
||||||
- [ ] Write `tooling/mirror-mods.sh` (vendor jars + rewrite URLs + refresh)
|
- [ ] Point server `PACKWIZ_URL=http://pack.ulicraft.net/pack.toml`
|
||||||
- [ ] Point server `PACKWIZ_URL=http://packwiz.ulicraft.local/pack.toml`
|
- [ ] Test install end-to-end on a fresh client
|
||||||
- [ ] `.gitignore` the vendored `./mirror/` jars; keep `./pack/*.toml` tracked
|
|
||||||
- [ ] Test offline install end-to-end (cut internet, install on a fresh client)
|
|
||||||
|
|||||||
@@ -1,39 +1,30 @@
|
|||||||
# Minecraft server — itzg NeoForge + offline launch
|
# Minecraft server — itzg NeoForge
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
`itzg/minecraft-server:java21`, NeoForge 1.21.1, offline-mode + authlib-injector
|
`itzg/minecraft-server:java21`, NeoForge 1.21.1, offline-mode + authlib-injector
|
||||||
pointing at drasl. Mods via `PACKWIZ_URL`. The challenge: **itzg re-resolves Mojang
|
pointing at drasl. Mods via `PACKWIZ_URL`. itzg installs MC + NeoForge +
|
||||||
version metadata on every boot** (issue #2340), so it cannot air-gap-boot in
|
libraries + server mods on first boot and re-syncs on restart; the stack assumes
|
||||||
`TYPE=NEOFORGE` mode. Solved with a pre-bake + custom-launch switch.
|
the internet is present, so `TYPE=NEOFORGE` boots normally every time.
|
||||||
|
|
||||||
## Offline strategy (two phases)
|
## Server config
|
||||||
|
|
||||||
**Phase 1 — pre-bake (online, once):**
|
|
||||||
```
|
```
|
||||||
TYPE=NEOFORGE, VERSION=1.21.1, NEOFORGE_VERSION=21.1.x
|
TYPE=NEOFORGE, VERSION=1.21.1, NEOFORGE_VERSION=21.1.x
|
||||||
PACKWIZ_URL=http://packwiz.ulicraft.local/pack.toml
|
PACKWIZ_URL=http://pack.${BASE_DOMAIN}/pack.toml
|
||||||
```
|
```
|
||||||
Boot once with internet → installs MC + NeoForge + libraries + server mods into
|
|
||||||
the `mc_data` volume, generates NeoForge's launch args/`run.sh`.
|
|
||||||
|
|
||||||
**Phase 2 — offline launch (party):**
|
First boot installs MC + NeoForge + libraries + server mods into the `mc_data`
|
||||||
Switch to `TYPE=CUSTOM` + `CUSTOM_SERVER=<container path>` pointing at the
|
volume. Subsequent boots re-sync the pack against `PACKWIZ_URL`.
|
||||||
already-installed NeoForge launcher in `/data`. A **container path (not URL)**
|
|
||||||
means no download, no Mojang resolution → boots air-gapped, survives restarts.
|
|
||||||
|
|
||||||
> OPEN: NeoForge 1.21.1 launches via `@libraries/.../unix_args.txt` / `run.sh`,
|
## Auth / config
|
||||||
> not a single fat jar. Exact `CUSTOM_SERVER` target + any `EXTRA_ARGS` must be
|
|
||||||
> verified against a real pre-baked volume. Tracked as a task.
|
|
||||||
|
|
||||||
## Auth / config (carry-over)
|
|
||||||
|
|
||||||
```
|
```
|
||||||
ONLINE_MODE=FALSE
|
ONLINE_MODE=FALSE
|
||||||
ENFORCE_SECURE_PROFILE=FALSE # pairs with drasl SignPublicKeys=false
|
ENFORCE_SECURE_PROFILE=FALSE # pairs with drasl SignPublicKeys=false
|
||||||
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
|
||||||
```
|
```
|
||||||
authlib URL uses the `auth` subdomain; resolves internally via Caddi alias.
|
authlib URL uses the `auth` subdomain; resolves internally via the caddy alias.
|
||||||
(Server still needs the agent even though Fjord clients have it built in.)
|
(Server still needs the agent even though Fjord clients have it built in.)
|
||||||
|
|
||||||
Memory: `INIT_MEMORY 4G`, `MAX_MEMORY 10G`, `USE_AIKAR_FLAGS TRUE`.
|
Memory: `INIT_MEMORY 4G`, `MAX_MEMORY 10G`, `USE_AIKAR_FLAGS TRUE`.
|
||||||
@@ -41,12 +32,9 @@ RCON enabled for mc-backup.
|
|||||||
|
|
||||||
## Tasks
|
## Tasks
|
||||||
|
|
||||||
- [ ] Compose: switch authlib URL to `http://auth.${BASE_DOMAIN}/authlib-injector`
|
- [ ] Compose: authlib URL `http://auth.${BASE_DOMAIN}/authlib-injector`
|
||||||
- [ ] Compose: replace `MODRINTH_PROJECTS` with `PACKWIZ_URL` (packwiz subdomain)
|
- [ ] Compose: `PACKWIZ_URL=http://pack.${BASE_DOMAIN}/pack.toml`
|
||||||
- [ ] Mount renamed `./runtime` (was `./extras`) for authlib-injector.jar
|
- [ ] Mount `./runtime` for authlib-injector.jar at `/extras`
|
||||||
- [ ] `depends_on`: drasl + caddy (pack served before server installs)
|
- [ ] `depends_on`: drasl + caddy (pack served before server installs)
|
||||||
- [ ] Pre-bake the volume online; snapshot it
|
|
||||||
- [ ] Identify exact NeoForge launch target for `TYPE=CUSTOM` / `CUSTOM_SERVER`
|
|
||||||
- [ ] Add a documented "offline mode" compose override (`docker-compose.offline.yml`?)
|
|
||||||
- [ ] Verify air-gapped boot: cut internet, restart container, server comes up
|
|
||||||
- [ ] Confirm `ENFORCE_SECURE_PROFILE=FALSE` ↔ drasl `SignPublicKeys=false`
|
- [ ] Confirm `ENFORCE_SECURE_PROFILE=FALSE` ↔ drasl `SignPublicKeys=false`
|
||||||
|
- [ ] Verify a client joins with their drasl skin
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ drasl account directly in the launcher (no manual JVM `-javaagent`), import the
|
|||||||
modpack, and play.
|
modpack, and play.
|
||||||
|
|
||||||
We host the launcher installers for **all platforms** on the landing page
|
We host the launcher installers for **all platforms** on the landing page
|
||||||
(`ulicraft.local`), served by Caddy from `./mirror/launcher/`. A script downloads
|
(`ulicraft.net`), served by Caddy from `./launcher/` (mounted at `/srv/launcher`,
|
||||||
the latest release assets ahead of time so the party is fully offline.
|
reachable at `/launcher/`). A script downloads the release assets ahead of time.
|
||||||
|
|
||||||
## Release assets (latest = 11.0.2.0)
|
## Release assets (latest = 11.0.2.0)
|
||||||
|
|
||||||
@@ -26,12 +26,12 @@ Setup `.exe`, macOS `.dmg`, Linux `.AppImage`.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Download all FjordLauncherUnlocked release assets for offline hosting.
|
# Download all FjordLauncherUnlocked release assets for local hosting.
|
||||||
# Halts on any error or ambiguity (no silent partial downloads).
|
# Halts on any error or ambiguity (no silent partial downloads).
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
REPO="hero-persson/FjordLauncherUnlocked"
|
REPO="hero-persson/FjordLauncherUnlocked"
|
||||||
DEST_ROOT="$(dirname "$0")/../mirror/launcher"
|
DEST_ROOT="$(dirname "$0")/../launcher"
|
||||||
API="https://api.github.com/repos/${REPO}/releases/latest"
|
API="https://api.github.com/repos/${REPO}/releases/latest"
|
||||||
|
|
||||||
command -v curl >/dev/null || { echo "curl required"; exit 1; }
|
command -v curl >/dev/null || { echo "curl required"; exit 1; }
|
||||||
@@ -59,13 +59,13 @@ ln -sfn "$tag" "${DEST_ROOT}/latest"
|
|||||||
echo "Done. ${#urls[@]} assets in ${dest}"
|
echo "Done. ${#urls[@]} assets in ${dest}"
|
||||||
```
|
```
|
||||||
|
|
||||||
Caddy mount: `./mirror/launcher:/srv/www/launcher:ro`.
|
Caddy mount: `./launcher:/srv/launcher:ro` (served at `/launcher/…`).
|
||||||
|
|
||||||
### Stable symlinks (so landing links survive version bumps)
|
### Stable symlinks (so landing links survive version bumps)
|
||||||
|
|
||||||
The landing page (`09-landing.md`) links to **fixed** filenames, not versioned
|
The landing page (`09-landing.md`) links to **fixed** filenames, not versioned
|
||||||
ones. After downloading, `fetch-launcher.sh` must create these symlinks under
|
ones. After downloading, `fetch-launcher.sh` must create these symlinks under
|
||||||
`mirror/launcher/latest/` pointing at the real assets:
|
`launcher/latest/` pointing at the real assets:
|
||||||
|
|
||||||
- `fjord-windows-setup.exe` → Windows Setup `.exe` (x64)
|
- `fjord-windows-setup.exe` → Windows Setup `.exe` (x64)
|
||||||
- `fjord-macos.dmg` → macOS `.dmg`
|
- `fjord-macos.dmg` → macOS `.dmg`
|
||||||
@@ -78,22 +78,19 @@ sync with `landing/src/data/site.ts`.**
|
|||||||
|
|
||||||
## Per-guest flow
|
## Per-guest flow
|
||||||
|
|
||||||
1. Open `http://ulicraft.local`, download launcher for their OS, install.
|
1. Open `https://ulicraft.net`, download launcher for their OS, install.
|
||||||
2. Add account: authlib-injector type, URL
|
2. Add account: authlib-injector type, URL
|
||||||
`http://auth.ulicraft.local/authlib-injector`, drasl username/password.
|
`https://auth.ulicraft.net/authlib-injector`, drasl username/password.
|
||||||
3. (Offline) Settings → APIs → **Assets Server** =
|
3. Import the modpack (packwiz URL `https://pack.ulicraft.net/pack.toml`,
|
||||||
`https://assets.ulicraft.local/` so the heavy asset download comes from the LAN
|
|
||||||
mirror (see 10-assets-mirror.md). Requires trusting the local CA.
|
|
||||||
4. Import the modpack (packwiz URL `http://packwiz.ulicraft.local/pack.toml`,
|
|
||||||
or a shared instance zip).
|
or a shared instance zip).
|
||||||
5. Connect to `mc.ulicraft.local:25565`.
|
4. Connect to `mc.ulicraft.net:25565`.
|
||||||
|
|
||||||
## Tasks
|
## Tasks
|
||||||
|
|
||||||
- [ ] Create `tooling/fetch-launcher.sh` (above); `chmod +x`
|
- [ ] Create `tooling/fetch-launcher.sh` (above); `chmod +x`
|
||||||
- [ ] Extend script: create stable symlinks (win/mac/linux) in `mirror/launcher/latest/`
|
- [ ] Extend script: create stable symlinks (win/mac/linux) in `launcher/latest/`
|
||||||
- [ ] Run it pre-party while online; verify all assets land in `./mirror/launcher/<tag>/`
|
- [ ] Run it pre-party; verify all assets land in `./launcher/<tag>/`
|
||||||
- [ ] `.gitignore` `./mirror/` (done)
|
- [ ] `.gitignore` `./launcher/` (done)
|
||||||
- [ ] Landing page DONE — `landing/` Astro project, builds to `www/` (see 09-landing.md)
|
- [ ] Landing page DONE — `landing/` Astro project, builds to `www/` (see 09-landing.md)
|
||||||
- [ ] Confirm Fjord authlib-injector account flow against drasl
|
- [ ] Confirm Fjord authlib-injector account flow against drasl
|
||||||
- [ ] Decide whether to ship a pre-built instance zip vs packwiz URL import
|
- [ ] Decide whether to ship a pre-built instance zip vs packwiz URL import
|
||||||
|
|||||||
@@ -1,77 +1,89 @@
|
|||||||
# Tooling — config rendering, mod mirror, launcher fetch
|
# Tooling — config rendering, pack render, ingress, fetch scripts
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
Three scripts in `./tooling/` turn the two env vars + templates into a working,
|
Scripts in `./tooling/` turn `BASE_DOMAIN` + templates into a working stack and
|
||||||
offline-capable stack. All are **cautious**: they halt on unset vars or missing
|
handle ingress (nginx vhost, TLS certs) and launcher/font fetches. All are
|
||||||
inputs rather than emit half-broken output.
|
**cautious**: they halt on unset vars or missing inputs rather than emit
|
||||||
|
half-broken output.
|
||||||
|
|
||||||
|
Surviving scripts:
|
||||||
|
|
||||||
|
- `render-config.sh` — render `*.tmpl` configs (BASE_DOMAIN only)
|
||||||
|
- `render-pack.sh` — render packwiz pack templates + index
|
||||||
|
- `render-nginx.sh` — render/install the host nginx vhost (TLS terminator → caddy)
|
||||||
|
- `issue-letsencrypt.sh` — issue LE certs via OVH DNS-01 (see 15-letsencrypt.md)
|
||||||
|
- `fetch-launcher.sh` — download Fjord launcher release assets (see 06-launcher.md)
|
||||||
|
- `add-custom-mod.sh` — add a custom (non-CDN) mod to the pack
|
||||||
|
- `fetch-fonts.sh` — vendor the landing page fonts (see 09-landing.md)
|
||||||
|
|
||||||
## render-config.sh
|
## render-config.sh
|
||||||
|
|
||||||
Renders every `*.tmpl` into its real config by substituting `BASE_DOMAIN` /
|
Renders every `*.tmpl` into its real config by substituting **only**
|
||||||
`HOST_LAN_IP`. Caddy is excluded (it reads `{$BASE_DOMAIN}` natively).
|
`${BASE_DOMAIN}` (so literal `$`-syntax belonging to the target file — caddy,
|
||||||
|
toml — is left untouched). Caddy is excluded entirely (it reads `{$BASE_DOMAIN}`
|
||||||
|
natively).
|
||||||
|
|
||||||
Targets:
|
Targets:
|
||||||
- `drasl/config/config.toml.tmpl` → `drasl/config/config.toml`
|
- `drasl/config/config.toml.tmpl` → `drasl/config/config.toml`
|
||||||
- `dnsmasq/dnsmasq.conf.tmpl` → `dnsmasq/dnsmasq.conf`
|
- `nmsr/config.toml.tmpl` → `nmsr/config.toml`
|
||||||
|
|
||||||
|
It then delegates pack templates (`mods/*.pw.toml.tmpl`, CustomSkinLoader, …) +
|
||||||
|
the packwiz index to `render-pack.sh` — the single source of pack render.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
[ -f .env ] && set -a && . ./.env && set +a
|
cd "$(dirname "$0")/.."
|
||||||
: "${BASE_DOMAIN:?BASE_DOMAIN unset}"
|
[ -f .env ] && { set -a; . ./.env; set +a; }
|
||||||
: "${HOST_LAN_IP:?HOST_LAN_IP unset}"
|
: "${BASE_DOMAIN:?BASE_DOMAIN unset (set in .env)}"
|
||||||
|
|
||||||
render() { # $1=tmpl $2=out
|
render() { # $1=tmpl $2=out
|
||||||
[ -f "$1" ] || { echo "missing template: $1"; exit 1; }
|
[ -f "$1" ] || { echo "missing template: $1" >&2; exit 1; }
|
||||||
envsubst '${BASE_DOMAIN} ${HOST_LAN_IP}' < "$1" > "$2"
|
envsubst '${BASE_DOMAIN}' < "$1" > "$2"
|
||||||
echo "rendered $2"
|
echo "rendered $2"
|
||||||
}
|
}
|
||||||
|
|
||||||
render drasl/config/config.toml.tmpl drasl/config/config.toml
|
render drasl/config/config.toml.tmpl drasl/config/config.toml
|
||||||
render dnsmasq/dnsmasq.conf.tmpl dnsmasq/dnsmasq.conf
|
render nmsr/config.toml.tmpl nmsr/config.toml
|
||||||
|
tooling/render-pack.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
> Note: `envsubst` with an explicit var list avoids clobbering `$`-syntax that
|
## render-nginx.sh
|
||||||
> belongs to the target file (e.g. dnsmasq/caddy literals).
|
|
||||||
|
|
||||||
## mirror-mods.sh
|
Renders `nginx/ulicraft-caddy.conf.tmpl` (the host nginx that terminates TLS in
|
||||||
|
front of caddy), substituting `$BASE_DOMAIN $APP_DIR $CADDY_HTTP_PORT`. Prints to
|
||||||
|
stdout by default; `--install` writes to sites-available, symlinks sites-enabled,
|
||||||
|
`nginx -t`, and reloads nginx. See `15-letsencrypt.md`.
|
||||||
|
|
||||||
Vendors mod jars for offline install and rewrites packwiz URLs to the LAN host.
|
## issue-letsencrypt.sh
|
||||||
See `04-packwiz.md`. Outline:
|
|
||||||
1. For each `pack/mods/*.pw.toml`, read `[download] url` + `filename`.
|
|
||||||
2. Download jar → `pack/mods-files/` (served by Caddy under packwiz subdomain).
|
|
||||||
3. Rewrite `url` to `http://packwiz.${BASE_DOMAIN}/mods-files/<filename>`.
|
|
||||||
4. `packwiz refresh` to recompute hashes + `index.toml`.
|
|
||||||
Must halt if a download fails or a hash mismatches.
|
|
||||||
|
|
||||||
## mirror-assets.sh
|
Issues a separate cert for the apex and each `LE_SUBDOMAINS` entry via acme.sh +
|
||||||
|
OVH DNS-01 → `certs/<name>/{cert,key}.pem`. See `15-letsencrypt.md`.
|
||||||
Mirrors Minecraft asset objects for offline clients (the ~400 MB+ chunk) and lays
|
|
||||||
them out for Caddy + the Fjord "Assets Server" override. Full design in
|
|
||||||
`10-assets-mirror.md`. Verifies SHA1, idempotent, halts on mismatch.
|
|
||||||
|
|
||||||
## fetch-launcher.sh
|
## fetch-launcher.sh
|
||||||
|
|
||||||
Downloads all FjordLauncherUnlocked release assets → `mirror/launcher/<tag>/`.
|
Downloads FjordLauncherUnlocked release assets → `launcher/<tag>/` and maintains
|
||||||
Full script in `06-launcher.md`.
|
a `latest` symlink + stable per-OS filename symlinks. Full script in
|
||||||
|
`06-launcher.md`.
|
||||||
|
|
||||||
## Generated / gitignored
|
## Generated / gitignored
|
||||||
|
|
||||||
```
|
```
|
||||||
drasl/config/config.toml # rendered
|
drasl/config/config.toml # rendered
|
||||||
dnsmasq/dnsmasq.conf # rendered
|
nmsr/config.toml # rendered
|
||||||
pack/mods-files/ # vendored jars
|
launcher/ # launcher assets
|
||||||
mirror/ # launcher assets + mod mirror
|
www/ # landing build output
|
||||||
|
certs/ # Let's Encrypt certs
|
||||||
.env # secrets + host-specific
|
.env # secrets + host-specific
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tasks
|
## Tasks
|
||||||
|
|
||||||
- [ ] Write `tooling/render-config.sh` (above), `chmod +x`
|
- [ ] Write `tooling/render-config.sh` (above), `chmod +x`
|
||||||
- [ ] Write `tooling/mirror-mods.sh` (toml parse + download + rewrite + refresh)
|
- [ ] Write `tooling/render-pack.sh` (pack templates + index)
|
||||||
- [ ] Write `tooling/mirror-assets.sh` (see 10)
|
- [ ] Write `tooling/render-nginx.sh` (see 15)
|
||||||
|
- [ ] Write `tooling/issue-letsencrypt.sh` (see 15)
|
||||||
- [ ] Write `tooling/fetch-launcher.sh` (see 06)
|
- [ ] Write `tooling/fetch-launcher.sh` (see 06)
|
||||||
- [ ] Add `gettext` (envsubst) + `jq` to host prereqs doc
|
- [ ] Add `gettext` (envsubst) + `jq` to host prereqs doc
|
||||||
- [ ] Update `.gitignore` for rendered configs + `mirror/` + `pack/mods-files/`
|
- [ ] Update `.gitignore` for rendered configs + `launcher/` + `www/` + `certs/`
|
||||||
- [ ] `build-stack.sh` orchestrator (preflight + `--prep` online + `--up` offline) — DONE, sequences the sub-scripts; see plan/12
|
|
||||||
|
|||||||
@@ -2,17 +2,18 @@
|
|||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
Apex `ulicraft.local` serves a static page guiding guests through joining:
|
Apex `ulicraft.net` serves a static page guiding guests through joining:
|
||||||
download launcher → add account → import modpack → connect. Built with **Astro +
|
download launcher → add account → import modpack → connect. Built with **Astro +
|
||||||
TypeScript** in `./landing/`; `pnpm run build` emits straight into `./www/`,
|
TypeScript** in `./landing/`; `pnpm run build` emits straight into `./www/`,
|
||||||
which Caddy/nginx serve at the apex. No runtime framework — output is plain
|
which Caddy serves at the apex (behind host nginx). No runtime framework —
|
||||||
HTML/CSS plus one tiny vanilla script (copy-to-clipboard + scroll reveal).
|
output is plain HTML/CSS plus one tiny vanilla script (copy-to-clipboard +
|
||||||
|
scroll reveal).
|
||||||
|
|
||||||
The visual is the **"Carved from stone"** pixel design ("Claude Design",
|
The visual is the **"Carved from stone"** pixel design ("Claude Design",
|
||||||
`landing/design/`): dark overworld palette, MC-GUI bevel buttons, pixel type,
|
`landing/design/`): dark overworld palette, MC-GUI bevel buttons, pixel type,
|
||||||
creeper mark, server-list panel. That design was a React/Babel-via-CDN prototype
|
creeper mark, server-list panel. That design was a React/Babel-via-CDN prototype
|
||||||
for a *fictional public SMP*; it was **ported to static Astro** with the real
|
for a *fictional public SMP*; it was **ported to static Astro** with the real
|
||||||
modded-LAN content and offline-safe assets.
|
modded-LAN content and vendored assets.
|
||||||
|
|
||||||
## Stack & wiring
|
## Stack & wiring
|
||||||
|
|
||||||
@@ -20,8 +21,8 @@ modded-LAN content and offline-safe assets.
|
|||||||
- `astro.config.mjs` → `outDir: "../www"`. Build overwrites `www/`; `www/` is
|
- `astro.config.mjs` → `outDir: "../www"`. Build overwrites `www/`; `www/` is
|
||||||
gitignored (generated artifact).
|
gitignored (generated artifact).
|
||||||
- `BASE_DOMAIN` env read at build time in `src/data/site.ts`
|
- `BASE_DOMAIN` env read at build time in `src/data/site.ts`
|
||||||
(`process.env.BASE_DOMAIN ?? "ulicraft.local"`). Bake before deploy:
|
(`process.env.BASE_DOMAIN ?? "ulicraft.net"`). Bake before deploy:
|
||||||
`BASE_DOMAIN=ulicraft.local pnpm run build`.
|
`BASE_DOMAIN=ulicraft.net pnpm run build`.
|
||||||
- Logo: wide wordmark from the design at `landing/public/logo.png` (hero + favicon).
|
- Logo: wide wordmark from the design at `landing/public/logo.png` (hero + favicon).
|
||||||
`image-rendering: pixelated` keeps it crisp.
|
`image-rendering: pixelated` keeps it crisp.
|
||||||
|
|
||||||
@@ -31,7 +32,7 @@ landing/
|
|||||||
├── design/ # "Claude Design" prototype (reference, not built)
|
├── design/ # "Claude Design" prototype (reference, not built)
|
||||||
├── public/
|
├── public/
|
||||||
│ ├── logo.png # hero wordmark (from design/assets/ulicraft-logo.png)
|
│ ├── logo.png # hero wordmark (from design/assets/ulicraft-logo.png)
|
||||||
│ └── fonts/ # vendored woff2 + fonts.css (offline-safe)
|
│ └── fonts/ # vendored woff2 + fonts.css (self-contained)
|
||||||
└── src/
|
└── src/
|
||||||
├── data/site.ts # single source: content + theme knobs
|
├── data/site.ts # single source: content + theme knobs
|
||||||
├── styles/main.css # ported design system (mood/hero/bevels)
|
├── styles/main.css # ported design system (mood/hero/bevels)
|
||||||
@@ -39,10 +40,11 @@ landing/
|
|||||||
└── pages/index.astro # composition + copy/reveal script + dust
|
└── pages/index.astro # composition + copy/reveal script + dust
|
||||||
```
|
```
|
||||||
|
|
||||||
## Fonts — vendored for offline LAN
|
## Fonts — vendored locally
|
||||||
|
|
||||||
The design pulled fonts from Google Fonts CDN; that breaks on offline LAN day.
|
The design pulled fonts from Google Fonts CDN; we vendor them instead to keep the
|
||||||
`tooling/fetch-fonts.sh` downloads the woff2 + a URL-rewritten `fonts.css` into
|
page self-contained. `tooling/fetch-fonts.sh` downloads the woff2 + a
|
||||||
|
URL-rewritten `fonts.css` into
|
||||||
`landing/public/fonts/`. `index.astro` links `/fonts/fonts.css`. Re-run the
|
`landing/public/fonts/`. `index.astro` links `/fonts/fonts.css`. Re-run the
|
||||||
script only if the font set changes (keep families in sync with `main.css`
|
script only if the font set changes (keep families in sync with `main.css`
|
||||||
`--font-*`): Pixelify Sans, Space Grotesk, Press Start 2P, Silkscreen, VT323.
|
`--font-*`): Pixelify Sans, Space Grotesk, Press Start 2P, Silkscreen, VT323.
|
||||||
@@ -59,7 +61,7 @@ script only if the font set changes (keep families in sync with `main.css`
|
|||||||
|
|
||||||
## Languages (i18n)
|
## Languages (i18n)
|
||||||
|
|
||||||
Three locales, static, build-time, offline-safe — **English** (default, `/`),
|
Three locales, static, build-time — **English** (default, `/`),
|
||||||
**Spanish** (`/es/`), **Euskera** (`/eu/`).
|
**Spanish** (`/es/`), **Euskera** (`/eu/`).
|
||||||
|
|
||||||
- One template `src/pages/[...lang].astro` with `getStaticPaths` emits all three
|
- One template `src/pages/[...lang].astro` with `getStaticPaths` emits all three
|
||||||
@@ -85,8 +87,8 @@ Three locales, static, build-time, offline-safe — **English** (default, `/`),
|
|||||||
- Features ×4: kitchen-sink pack · self-hosted Drasl identity+skins · one-click
|
- Features ×4: kitchen-sink pack · self-hosted Drasl identity+skins · one-click
|
||||||
packwiz · built-to-last (homelab + 6h backups).
|
packwiz · built-to-last (homelab + 6h backups).
|
||||||
- How to Join ×4: **1** Fjord launcher (3 OS) → **2** authlib account
|
- How to Join ×4: **1** Fjord launcher (3 OS) → **2** authlib account
|
||||||
(`auth.${BASE_DOMAIN}/authlib-injector`) + register link + CA-cert note →
|
(`auth.${BASE_DOMAIN}/authlib-injector`) + register link →
|
||||||
**3** packwiz import (`packwiz.${BASE_DOMAIN}/pack.toml`) → **4** connect.
|
**3** packwiz import (`pack.${BASE_DOMAIN}/pack.toml`) → **4** connect.
|
||||||
- Footer: Mojang trademark disclaimer.
|
- Footer: Mojang trademark disclaimer.
|
||||||
|
|
||||||
## Launcher download links (Option A — stable symlinks)
|
## Launcher download links (Option A — stable symlinks)
|
||||||
@@ -97,9 +99,9 @@ Page links to fixed names so they survive launcher version bumps:
|
|||||||
- `fjord-linux-x86_64.AppImage`
|
- `fjord-linux-x86_64.AppImage`
|
||||||
|
|
||||||
`tooling/fetch-launcher.sh` must create these as symlinks under
|
`tooling/fetch-launcher.sh` must create these as symlinks under
|
||||||
`mirror/launcher/latest/` pointing at the real versioned assets. **Keep the names
|
`launcher/latest/` pointing at the real versioned assets. **Keep the names in
|
||||||
in sync** between `site.ts` and the script. Mounted so they resolve at
|
sync** between `site.ts` and the script. Mounted so they resolve at
|
||||||
`/launcher/latest/…` (see nginx/Caddy ingress).
|
`/launcher/latest/…` (see Caddy `10-static.caddy`).
|
||||||
|
|
||||||
## Tasks
|
## Tasks
|
||||||
|
|
||||||
@@ -107,9 +109,9 @@ in sync** between `site.ts` and the script. Mounted so they resolve at
|
|||||||
- [x] Port design → static Astro (components + main.css + index.astro)
|
- [x] Port design → static Astro (components + main.css + index.astro)
|
||||||
- [x] Real content in `site.ts`; theme knobs replace TweaksPanel
|
- [x] Real content in `site.ts`; theme knobs replace TweaksPanel
|
||||||
- [x] i18n: en/es/eu via `[...lang].astro` + `i18n/ui.ts`; nav language switcher
|
- [x] i18n: en/es/eu via `[...lang].astro` + `i18n/ui.ts`; nav language switcher
|
||||||
- [x] `BASE_DOMAIN=ulicraft.local pnpm run build` → verified `www/` output + screenshot
|
- [x] `BASE_DOMAIN=ulicraft.net pnpm run build` → verified `www/` output + screenshot
|
||||||
- [ ] Extend `fetch-launcher.sh`: symlink stable names → versioned files
|
- [ ] Extend `fetch-launcher.sh`: symlink stable names → versioned files
|
||||||
- [ ] Confirm ingress serves apex from `./www` and `/launcher/latest/*` from mirror
|
- [ ] Confirm ingress serves apex from `./www` and `/launcher/latest/*` from `./launcher`
|
||||||
- [ ] Optional: try `mood: nether` / `hero: split` for party day
|
- [ ] Optional: try `mood: nether` / `hero: split` for party day
|
||||||
- [ ] Optional: wire real player count (option C) via mc-monitor JSON if wanted
|
- [ ] Optional: wire real player count (option C) via mc-monitor JSON if wanted
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
# Assets mirror — offline Minecraft asset objects for clients
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Guest launchers (FjordLauncherUnlocked, a Prism soft-fork) download ~400–600 MB of
|
|
||||||
**asset objects** (textures, sounds, lang) from `resources.download.minecraft.net`
|
|
||||||
on first instance launch. For an air-gapped party that's the single biggest
|
|
||||||
client-side download. Fjord/Prism expose an **"Assets Server"** override, so we
|
|
||||||
mirror the objects onto our host and point guests at it.
|
|
||||||
|
|
||||||
`tooling/mirror-assets.sh` downloads + verifies every asset object for the pinned
|
|
||||||
Minecraft version and lays them out so Caddy can serve them in the exact path
|
|
||||||
shape the launcher expects.
|
|
||||||
|
|
||||||
## Fjord/Prism override fields (confirmed)
|
|
||||||
|
|
||||||
Settings → APIs → **Services** tab:
|
|
||||||
|
|
||||||
| Field | Setting key | Default | What it overrides |
|
|
||||||
|---|---|---|---|
|
|
||||||
| **Assets Server** | `ResourceURL` | `https://resources.download.minecraft.net/` | asset **objects only** |
|
|
||||||
| **Metadata Server** | (meta URL) | `https://meta.prismlauncher.org/v1/` | Prism-format version meta |
|
|
||||||
|
|
||||||
- Added in Prism 10.0.0 (PR #3875); inherited by Fjord.
|
|
||||||
- **There is NO libraries-URL field.** Library + client.jar URLs live inside the
|
|
||||||
version/meta JSON; only a meta mirror (or DNS spoof) can redirect them.
|
|
||||||
|
|
||||||
### ⚠️ HTTPS is forced
|
|
||||||
|
|
||||||
PR #3875 validates the Assets Server URL as **HTTPS and auto-rewrites `http://`
|
|
||||||
→ `https://`**. So the assets mirror **must be served over TLS** — plain `:80`
|
|
||||||
Caddy will not work for this endpoint. This pulls local TLS forward for one
|
|
||||||
subdomain ahead of the rest of the stack.
|
|
||||||
|
|
||||||
Options to satisfy it:
|
|
||||||
1. Caddy TLS on `assets.${BASE_DOMAIN}` with a cert from a local CA
|
|
||||||
(step-ca); guests import the CA root once. Cleanest, matches homelab.
|
|
||||||
2. Caddy `internal` CA / self-signed — guests must trust it (more friction, per
|
|
||||||
machine).
|
|
||||||
3. Sidestep entirely with **pre-seed** (below) and skip the Assets Server override.
|
|
||||||
|
|
||||||
## What this mirror does and does NOT cover
|
|
||||||
|
|
||||||
| Client download | Host | Covered here? |
|
|
||||||
|---|---|---|
|
|
||||||
| asset objects (bulk) | resources.download.minecraft.net | ✅ this mirror + Assets Server field |
|
|
||||||
| asset index json | meta/piston | ⚠️ fetched here for completeness; launcher still pulls it from meta unless meta is also mirrored |
|
|
||||||
| libraries | libraries.minecraft.net | ❌ no override field |
|
|
||||||
| client.jar | piston-data.mojang.com | ❌ |
|
|
||||||
| version/meta json | meta.prismlauncher.org | ❌ (separate Metadata Server field) |
|
|
||||||
|
|
||||||
**Full client air-gap therefore needs more than this.** For the uncovered, small
|
|
||||||
(~150 MB) remainder, the pragmatic fallback is **pre-seed**: each guest launches
|
|
||||||
the instance once while online (on arrival, before air-gapping); Prism caches
|
|
||||||
libraries/jar/meta locally. The assets mirror still saves the heavy 400 MB+ per
|
|
||||||
guest. A full meta+libraries mirror (mcm-style / PrismLauncher-meta gen) is the
|
|
||||||
heavyweight alternative, deferred.
|
|
||||||
|
|
||||||
## tooling/mirror-assets.sh — design
|
|
||||||
|
|
||||||
Input: `MC_VERSION` (default `1.21.1`, or read from the pre-baked server's
|
|
||||||
`version.json`). Cautious: verifies SHA1 on every file, halts on mismatch,
|
|
||||||
idempotent (skips already-valid files).
|
|
||||||
|
|
||||||
```
|
|
||||||
1. GET https://piston-meta.mojang.com/mc/game/version_manifest_v2.json
|
|
||||||
→ find entry for $MC_VERSION → version-json URL
|
|
||||||
2. GET version-json → read .assetIndex { id, url, sha1 }
|
|
||||||
3. GET asset index → mirror/assets/indexes/<id>.json (verify sha1)
|
|
||||||
4. For each object in index .objects{}:
|
|
||||||
hash=<sha1>; rel="${hash:0:2}/${hash}"
|
|
||||||
dest="mirror/assets/objects/${rel}"
|
|
||||||
[ -f "$dest" ] && sha1 ok → skip
|
|
||||||
else GET https://resources.download.minecraft.net/${rel} → $dest ; verify sha1
|
|
||||||
5. Report counts; non-zero exit on any failure.
|
|
||||||
```
|
|
||||||
|
|
||||||
Layout served by Caddy:
|
|
||||||
```
|
|
||||||
mirror/assets/
|
|
||||||
├── indexes/<id>.json # e.g. 17.json / 1.21.json (for pre-seed/meta use)
|
|
||||||
└── objects/<2hex>/<sha1> # launcher requests <ResourceURL>/<2hex>/<sha1>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Caddy wiring
|
|
||||||
|
|
||||||
Serve the **objects** dir at the web root of an HTTPS vhost so the launcher's
|
|
||||||
`<ResourceURL>/<2hex>/<hash>` resolves directly:
|
|
||||||
|
|
||||||
```
|
|
||||||
https://assets.{$BASE_DOMAIN} {
|
|
||||||
tls <local-ca-cert> <key> # or `tls internal`
|
|
||||||
root * /srv/assets/objects
|
|
||||||
file_server
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Mount `./mirror/assets/objects:/srv/assets/objects:ro`. Add network alias
|
|
||||||
`assets.${BASE_DOMAIN}` to Caddy (same trick as auth/packwiz).
|
|
||||||
Fjord "Assets Server" = `https://assets.${BASE_DOMAIN}/`.
|
|
||||||
|
|
||||||
## Tasks
|
|
||||||
|
|
||||||
- [ ] Write `tooling/mirror-assets.sh` (above); `chmod +x`; deps `curl` + `jq` + `sha1sum`
|
|
||||||
- [ ] Decide MC_VERSION source: hardcode `1.21.1` vs read pre-baked `version.json`
|
|
||||||
- [ ] Run pre-party while online; verify `mirror/assets/objects/` populated + sha1 clean
|
|
||||||
- [ ] Stand up TLS for `assets.${BASE_DOMAIN}` (step-ca or `tls internal`) — REQUIRED (HTTPS forced)
|
|
||||||
- [ ] Add `assets.` Caddy vhost + network alias + mount
|
|
||||||
- [ ] Document guest step: Settings → APIs → Assets Server = `https://assets.${BASE_DOMAIN}/`
|
|
||||||
- [ ] Document guest CA-trust step (import local CA root) if not using a publicly-trusted cert
|
|
||||||
- [ ] Confirm in Fjord's actual UI that the field exists + HTTPS-forcing behavior matches Prism
|
|
||||||
- [ ] Fallback path: pre-seed instructions (launch once online) for libraries/jar/meta
|
|
||||||
- [ ] Defer decision: full meta+libraries mirror (mcm / PrismLauncher-meta) vs pre-seed
|
|
||||||
- [ ] `.gitignore` already covers `mirror/`
|
|
||||||
```
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
# Full client air-gap — DNS + TLS transparent mirror
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Goal: a guest laptop with **zero internet** can install + launch the modded
|
|
||||||
instance entirely from the LAN. The chosen architecture is a **transparent
|
|
||||||
mirror**: dnsmasq points the real upstream hostnames at our host, Caddy serves
|
|
||||||
byte-exact copies of the files at the same paths, and `tls internal` mints certs
|
|
||||||
those hostnames validate against (guests trust the Caddy root CA once).
|
|
||||||
|
|
||||||
The launcher is **unmodified** — it requests the real Mojang/Prism/NeoForge URLs;
|
|
||||||
DNS just resolves them to us. This is cleaner than rewriting Prism-format
|
|
||||||
metadata, and it makes the `10-assets-mirror.md` "Assets Server" field
|
|
||||||
**redundant** (the launcher hits the real resources host, which now resolves to
|
|
||||||
the LAN). `10` is kept as the lighter objects-only alternative.
|
|
||||||
|
|
||||||
## Decision record
|
|
||||||
|
|
||||||
- **TLS for LAN party = Caddy `tls internal`** (not step-ca). Zero extra infra;
|
|
||||||
Caddy is the local CA. step-ca stays a future-homelab reminder (see `02-caddy`
|
|
||||||
open item). Guests import the Caddy root CA once.
|
|
||||||
- **Full air-gap chosen** over pre-seed.
|
|
||||||
|
|
||||||
## Hosts to mirror (NeoForge 1.21.1 instance)
|
|
||||||
|
|
||||||
| Host | Serves | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| `meta.prismlauncher.org` | Prism-format component meta (MC, LWJGL, NeoForge) | path `/v1/...` |
|
|
||||||
| `piston-meta.mojang.com` | version manifest + version json | |
|
|
||||||
| `piston-data.mojang.com` | client.jar, asset index, some objects | |
|
|
||||||
| `libraries.minecraft.net` | vanilla MC libraries (maven layout) | |
|
|
||||||
| `maven.neoforged.net` | NeoForge libraries | more mavens may appear → capture catches them |
|
|
||||||
| `resources.download.minecraft.net` | asset objects (~400 MB+) | the bulk |
|
|
||||||
|
|
||||||
**Not mirrored — auth runtime** (`session.minecraft.net`, `textures.minecraft.net`,
|
|
||||||
`api.mojang.com`, `sessionserver.mojang.com`): these are redirected to **drasl**
|
|
||||||
by the authlib-injector account already; leave them to drasl.
|
|
||||||
|
|
||||||
## How it works
|
|
||||||
|
|
||||||
```
|
|
||||||
guest laptop (LAN DNS = our dnsmasq, trusts Caddy root CA)
|
|
||||||
│ resolves meta.prismlauncher.org, libraries.minecraft.net, … → HOST_LAN_IP
|
|
||||||
▼
|
|
||||||
dnsmasq ── spoof A-records ──► Caddy (tls internal, cert per host)
|
|
||||||
└─ static file_server, byte-exact path mirror
|
|
||||||
```
|
|
||||||
|
|
||||||
### dnsmasq (extends 01)
|
|
||||||
|
|
||||||
Add explicit spoof records alongside the `*.${BASE_DOMAIN}` wildcard:
|
|
||||||
|
|
||||||
```
|
|
||||||
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}
|
|
||||||
```
|
|
||||||
> Only effective while the guest uses our dnsmasq as resolver. Fully reversible —
|
|
||||||
> off-LAN the real hosts resolve normally. Keep this list in sync with whatever
|
|
||||||
> the capture step (below) actually observed.
|
|
||||||
|
|
||||||
### Caddy (extends 02)
|
|
||||||
|
|
||||||
One vhost per spoofed host, each rooted at its mirror tree, all `tls internal`:
|
|
||||||
|
|
||||||
```
|
|
||||||
piston-data.mojang.com, libraries.minecraft.net, resources.download.minecraft.net,
|
|
||||||
meta.prismlauncher.org, piston-meta.mojang.com, maven.neoforged.net {
|
|
||||||
tls internal
|
|
||||||
root * /srv/mirror/{host}
|
|
||||||
file_server
|
|
||||||
}
|
|
||||||
```
|
|
||||||
(Practically: a small snippet per host, or Caddy on-demand TLS + a path-routed
|
|
||||||
`map`. Keep explicit per-host for clarity.) Add each as a **network alias** on
|
|
||||||
Caddy too, so the `minecraft`/tooling containers resolve them internally.
|
|
||||||
|
|
||||||
Mount `./mirror/upstream/<host>:/srv/mirror/<host>:ro`.
|
|
||||||
|
|
||||||
## Capture (the actual work) — `tooling/mirror-airgap.sh`
|
|
||||||
|
|
||||||
Byte-exact mirroring needs the exact URL set. Capture it once, online:
|
|
||||||
|
|
||||||
1. **Record**: launch the instance once with the launcher pointed at a logging
|
|
||||||
forward-proxy (mitmproxy `--mode reverse`/transparent, or even Caddy as a
|
|
||||||
logging reverse proxy). Collect every requested absolute URL.
|
|
||||||
2. **Fetch**: for each URL, download to `mirror/upstream/<host>/<path>`,
|
|
||||||
preserving path exactly. Verify SHA1 where the source json provides it
|
|
||||||
(libraries, assets). Halt on mismatch.
|
|
||||||
3. **Assets shortcut**: the objects (resources.download…) can be filled directly
|
|
||||||
from the asset index (same logic as `10-assets-mirror.md`) without proxy
|
|
||||||
capture — reuse that loop into `mirror/upstream/resources.download.minecraft.net/<2hex>/<hash>`.
|
|
||||||
4. **Verify offline**: re-run a launch on a second machine with internet cut and
|
|
||||||
DNS set to our dnsmasq + CA trusted. Fix any 404 → missing URL → add to fetch.
|
|
||||||
|
|
||||||
> Cautious by design: the proxy-capture enumerates whatever hosts/paths are *truly*
|
|
||||||
> hit (incl. unforeseen maven mirrors), so the mirror is complete rather than
|
|
||||||
> guessed. Re-capture if MC/NeoForge versions change.
|
|
||||||
|
|
||||||
## Guest one-time setup (offline)
|
|
||||||
|
|
||||||
1. Set laptop DNS → our dnsmasq (LAN IP). (Often via DHCP/router, or manual.)
|
|
||||||
2. Import the **Caddy root CA** into the OS/browser trust store.
|
|
||||||
3. Install Fjord, add authlib-injector account (`auth.${BASE_DOMAIN}`), import pack,
|
|
||||||
launch. All downloads resolve to the LAN mirror.
|
|
||||||
|
|
||||||
No launcher API-field overrides needed in this architecture.
|
|
||||||
|
|
||||||
## Open / reminders
|
|
||||||
|
|
||||||
- [ ] **Reminder (kept):** graduate `tls internal` → step-ca for the persistent
|
|
||||||
homelab; revisit cert distribution then.
|
|
||||||
- [ ] Confirm exact `meta.prismlauncher.org` path layout Fjord requests (fork may
|
|
||||||
pin a different meta host/version).
|
|
||||||
- [ ] Verify Caddy `tls internal` certs validate in Fjord's Qt network stack with
|
|
||||||
the root CA imported (no extra pinning).
|
|
||||||
- [ ] mitmproxy vs Caddy-log for the capture step — pick one.
|
|
||||||
- [ ] CA-trust UX per OS (Windows/macOS/Linux) — document for guests.
|
|
||||||
|
|
||||||
## Tasks
|
|
||||||
|
|
||||||
- [ ] Write `tooling/mirror-airgap.sh` (capture + fetch + sha1 verify + offline re-test)
|
|
||||||
- [ ] Add spoof `address=` lines to `dnsmasq/dnsmasq.conf.tmpl` (01)
|
|
||||||
- [ ] Add per-host `tls internal` vhosts + network aliases + mounts to Caddy (02)
|
|
||||||
- [ ] Export Caddy root CA; build a guest CA-import mini-guide (link from landing page)
|
|
||||||
- [ ] Run capture online for MC 1.21.1 + NeoForge 21.1.x; populate `mirror/upstream/`
|
|
||||||
- [ ] Offline acceptance test on a clean machine (internet cut, LAN DNS, CA trusted)
|
|
||||||
- [ ] `.gitignore` already covers `mirror/`
|
|
||||||
```
|
|
||||||
@@ -1,59 +1,62 @@
|
|||||||
# Build order — one commit per task
|
# Build order — one commit per task
|
||||||
|
|
||||||
Dependency-ordered. Each numbered item = one git commit. `main` currently has
|
Dependency-ordered. Each numbered item = one git commit. Operational steps
|
||||||
**zero commits**; item 0 is the baseline. Operational steps (pre-bake, capture,
|
(prep, acceptance test) need internet/hardware and are NOT commits — marked
|
||||||
acceptance test) need internet/hardware and are NOT commits — marked `[ops]`.
|
`[ops]`.
|
||||||
|
|
||||||
> Commit message convention: Conventional Commits, scope = service. Footer:
|
> Commit message convention: Conventional Commits, scope = service. Footer:
|
||||||
> `Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>`.
|
> `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`.
|
||||||
|
|
||||||
## Phase 0 — baseline
|
## Phase 0 — baseline
|
||||||
0. `chore: baseline scaffolding` — existing untracked tree (CLAUDE.md, plan/,
|
0. `chore: baseline scaffolding` — CLAUDE.md, plan/, docker-compose.yml,
|
||||||
current docker-compose.yml, extras/, .gitignore, .env.example).
|
runtime/, .gitignore, .env.example.
|
||||||
|
|
||||||
## Phase 1 — config foundation
|
## Phase 1 — config foundation
|
||||||
1. `feat(env): add BASE_DOMAIN + HOST_LAN_IP to .env.example`
|
1. `feat(env): add BASE_DOMAIN + RCON_PASSWORD + CADDY_* to .env.example`
|
||||||
2. `feat(tooling): add render-config.sh (envsubst, halt on unset var)`
|
2. `feat(tooling): add render-config.sh (envsubst BASE_DOMAIN, halt on unset)`
|
||||||
|
|
||||||
## Phase 2 — DNS + ingress (no app yet, but the backbone)
|
## Phase 2 — ingress (caddy internal router)
|
||||||
3. `feat(dnsmasq): add dnsmasq.conf.tmpl (wildcard *.BASE_DOMAIN)`
|
3. `feat(caddy): add conf.d snippets (00-core auth+pack, 10-static apex+launcher)`
|
||||||
4. `feat(caddy): add Caddyfile (apex + auth + packwiz vhosts)`
|
4. `feat(compose): add caddy service (127.0.0.1:CADDY_HTTP_PORT) + network aliases`
|
||||||
5. `feat(compose): add dnsmasq + caddy services + network aliases`
|
|
||||||
|
|
||||||
## Phase 3 — auth
|
## Phase 3 — auth
|
||||||
6. `feat(drasl): add config.toml.tmpl (password login, SignPublicKeys=false)`
|
5. `feat(drasl): add config.toml.tmpl (password login, SignPublicKeys=false)`
|
||||||
7. `refactor(compose): drasl internal-only behind caddy; authlib URL → auth.`
|
6. `refactor(compose): drasl internal-only behind caddy; authlib URL → auth.`
|
||||||
|
|
||||||
## Phase 4 — modpack
|
## Phase 4 — modpack
|
||||||
8. `feat(pack): packwiz init + initial mod curation`
|
7. `feat(pack): packwiz init + initial mod curation`
|
||||||
9. `feat(tooling): add mirror-mods.sh (vendor jars + rewrite .pw.toml URLs)`
|
8. `feat(tooling): add render-pack.sh + add-custom-mod.sh`
|
||||||
10. `refactor(compose): minecraft via PACKWIZ_URL; extras→runtime`
|
9. `refactor(compose): minecraft via PACKWIZ_URL`
|
||||||
|
|
||||||
## Phase 5 — landing
|
## Phase 5 — landing
|
||||||
11. `feat(landing): Astro+TS onboarding site → www/` (files already drafted)
|
10. `feat(landing): Astro+TS onboarding site → www/`
|
||||||
12. `chore(landing): wire logo + .gitignore for www/ + build output`
|
11. `chore(landing): wire logo + .gitignore for www/`
|
||||||
|
|
||||||
## Phase 6 — launcher distribution
|
## Phase 6 — launcher distribution
|
||||||
13. `feat(tooling): add fetch-launcher.sh (all platforms + stable symlinks)`
|
12. `feat(tooling): add fetch-launcher.sh (all platforms + stable symlinks)`
|
||||||
|
|
||||||
## Phase 7 — full client air-gap (chosen path, plan/11)
|
## Phase 7 — extra services
|
||||||
14. `feat(tooling): add mirror-airgap.sh (capture + fetch + sha1 verify)`
|
13. `feat(avatar): add nmsr service (Drasl-backed) + 40-avatar.caddy`
|
||||||
15. `feat(dnsmasq): add air-gap spoof records for upstream hosts`
|
14. `feat(status): add uptime-kuma service + 50-status.caddy`
|
||||||
16. `feat(caddy): add tls internal vhosts for upstream mirror hosts`
|
15. `feat(distribution): add 30-distribution.caddy (static, another repo)`
|
||||||
|
|
||||||
## Phase 8 — backups + orchestration
|
## Phase 8 — backups
|
||||||
17. `feat(compose): add mc-backup service (RCON, 6h)`
|
16. `feat(compose): add mc-backup service (RCON, 6h)`
|
||||||
18. `feat(tooling): add build-stack.sh orchestrator`
|
|
||||||
|
|
||||||
## Phase 9 — operational (no commits)
|
## Phase 9 — production ingress / TLS
|
||||||
- [ops] Run `build-stack.sh --prep` online: render configs, build landing,
|
17. `feat(nginx): add ulicraft-caddy.conf.tmpl + render-nginx.sh`
|
||||||
mirror mods, mirror air-gap, fetch launcher, **pre-bake** server volume.
|
18. `feat(tls): add issue-letsencrypt.sh (OVH DNS-01) + LE env block`
|
||||||
- [ops] Offline acceptance test: clean machine, internet cut, LAN DNS = dnsmasq,
|
|
||||||
Caddy CA trusted → install Fjord, import pack, join.
|
## Phase 10 — operational (no commits)
|
||||||
- [ops] If green: tag `v1-lan-ready`.
|
- [ops] Prep online (once): `tooling/render-config.sh`, build landing
|
||||||
|
(`cd landing && pnpm run build`), `tooling/fetch-launcher.sh`, fetch
|
||||||
|
authlib-injector into `runtime/`.
|
||||||
|
- [ops] TLS: `tooling/issue-letsencrypt.sh`, then
|
||||||
|
`tooling/render-nginx.sh --install`.
|
||||||
|
- [ops] Bring up: `docker compose up -d --build`.
|
||||||
|
- [ops] Acceptance: install Fjord, add drasl account, import pack, join.
|
||||||
|
- [ops] If green: tag `v1-ready`.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
- Items 3–5 can be smoke-tested (`docker compose up dnsmasq caddy`) before later
|
- Bring-up is a single command — no run modes, no override compose files.
|
||||||
phases exist — Caddy serves a 404/landing, dnsmasq resolves.
|
|
||||||
- Items 14–16 depend on a working online launch to capture the URL set first.
|
|
||||||
- Each commit should leave the tree in a non-broken state (compose still parses).
|
- Each commit should leave the tree in a non-broken state (compose still parses).
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
# DNS strategy + run modes
|
|
||||||
|
|
||||||
How names resolve and which layers come up. Supersedes the "dnsmasq is core"
|
|
||||||
assumption in `01-dnsmasq.md`.
|
|
||||||
|
|
||||||
## Domain: `.lan`, not `.local`
|
|
||||||
|
|
||||||
`BASE_DOMAIN=ulicraft.lan` by default. `.local` is intercepted by mDNS on
|
|
||||||
macOS/Linux and does **not** resolve through a normal unicast DNS server
|
|
||||||
(that was the `DNS_PROBE_FINISHED_NXDOMAIN`). Use `.local` only with the mDNS
|
|
||||||
path below.
|
|
||||||
|
|
||||||
## Run modes — `tooling/build-stack.sh --up <mode>`
|
|
||||||
|
|
||||||
| Mode | Compose layers | Use |
|
|
||||||
|---|---|---|
|
|
||||||
| `online` | base + static | internet present; landing + launcher; no mirror |
|
|
||||||
| `airgap` | base + static + mirror | no internet; full asset mirror on `:443` (default) |
|
|
||||||
| `core` | base | debugging |
|
|
||||||
|
|
||||||
Base = drasl, minecraft, mc-backup, caddy(auth+packwiz). `static` = apex landing +
|
|
||||||
launcher + `/ca.crt`. `mirror` = upstream spoof vhosts (`tls internal`) + `:443` +
|
|
||||||
caddy aliases. `--up` prints the DNS records for the chosen mode.
|
|
||||||
|
|
||||||
## DNS: your own party server (primary)
|
|
||||||
|
|
||||||
You run the party DNS. Generate records with `tooling/dns-records.sh <mode>`:
|
|
||||||
- `online` → service names (`${BASE_DOMAIN}`, `auth.`, `packwiz.`, `mc.`) → `HOST_LAN_IP`
|
|
||||||
- `airgap` → the above **plus** the spoofed upstreams (`meta.prismlauncher.org`,
|
|
||||||
`piston-meta`/`piston-data.mojang.com`, `libraries.minecraft.net`,
|
|
||||||
`maven.neoforged.net`, `resources.download.minecraft.net`) → `HOST_LAN_IP`
|
|
||||||
|
|
||||||
Output formats: plain, `/etc/hosts`, BIND zone, dnsmasq `address=`, plus the
|
|
||||||
Minecraft SRV line. Minecraft needs no port (`mc.${BASE_DOMAIN}` defaults to 25565).
|
|
||||||
|
|
||||||
## Optional resolver layers
|
|
||||||
|
|
||||||
- **dnsmasq** (`docker-compose.dnsmasq.yml`) — turnkey DNS if you don't run your
|
|
||||||
own. Binds `${HOST_LAN_IP}:53`. Serves the rendered `dnsmasq/dnsmasq.conf`
|
|
||||||
(wildcard + airgap spoofs).
|
|
||||||
- **mDNS / Avahi** (`docker-compose.avahi.yml`, `ENABLE_MDNS=true`) — zero-config
|
|
||||||
`.local` for guests (macOS/Linux native; Windows needs Bonjour). See below.
|
|
||||||
|
|
||||||
## mDNS path (ENABLE_MDNS=true + BASE_DOMAIN=*.local)
|
|
||||||
|
|
||||||
The `avahi` container publishes the service names to the **host's** avahi-daemon
|
|
||||||
over its D-Bus socket (it does NOT run its own daemon — that collides with the
|
|
||||||
host on `:5353`).
|
|
||||||
|
|
||||||
Requirements / gotchas (build-stack warns on these):
|
|
||||||
- host runs `avahi-daemon`; `/run/dbus/system_bus_socket` mounted in.
|
|
||||||
- `security_opt: apparmor=unconfined` on the container (docker-default AppArmor
|
|
||||||
blocks the D-Bus publish).
|
|
||||||
- multi-interface hosts (e.g. many docker bridges) → avahi self-collides
|
|
||||||
("Local name collision"); set `allow-interfaces=<lan-nic>` in
|
|
||||||
`/etc/avahi/avahi-daemon.conf`.
|
|
||||||
- mDNS resolves **only** `.local`, so it cannot serve the air-gap upstream
|
|
||||||
spoofs (real public hostnames) — those always need real DNS.
|
|
||||||
|
|
||||||
## Status (tested 2026-05-24)
|
|
||||||
|
|
||||||
- `.lan` + own-DNS path: verified end-to-end (apex/auth/packwiz/ca.crt/mirror
|
|
||||||
all 200, minecraft healthy).
|
|
||||||
- mDNS publish mechanism: verified reaching host avahi; full green-light blocked
|
|
||||||
on this dev box only by its 38 docker bridges (allow-interfaces fixes it).
|
|
||||||
@@ -7,9 +7,10 @@ Production host for the Ulicraft stack.
|
|||||||
| 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`) |
|
| Compose | single `docker-compose.yml` (no overrides) |
|
||||||
| Auth | Drasl (base stack — no Blessing Skin override) |
|
| Auth | Drasl (password login) |
|
||||||
| Restart | **hard** (`compose down` → `build-stack.sh --up`) — brief full downtime |
|
| Ingress | host nginx (Let's Encrypt TLS) → caddy → services (see 15-letsencrypt.md) |
|
||||||
|
| Restart | **hard** (`compose down` → `compose up --build`) — brief full downtime |
|
||||||
|
|
||||||
## Routine redeploy
|
## Routine redeploy
|
||||||
|
|
||||||
@@ -17,9 +18,9 @@ Production host for the Ulicraft stack.
|
|||||||
ssh cochi
|
ssh cochi
|
||||||
cd /home/ubuntu/mc/ulicraft-server-v1
|
cd /home/ubuntu/mc/ulicraft-server-v1
|
||||||
git pull --ff-only
|
git pull --ff-only
|
||||||
# hard restart: tear the stack down, then bring it back up (renders configs first)
|
tooling/render-config.sh # re-render drasl/nmsr/pack configs from .env
|
||||||
docker compose -f docker-compose.yml -f docker-compose.static.yml down --remove-orphans
|
docker compose down --remove-orphans
|
||||||
tooling/build-stack.sh --up online
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
Or as a one-shot from your workstation:
|
Or as a one-shot from your workstation:
|
||||||
@@ -28,19 +29,20 @@ Or as a one-shot from your workstation:
|
|||||||
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'
|
docker compose down --remove-orphans
|
||||||
|
docker compose up -d --build'
|
||||||
```
|
```
|
||||||
|
|
||||||
`build-stack.sh --up online` runs preflight (checks `.env`), `render-config.sh`
|
`render-config.sh` re-renders configs (and `render-pack.sh` for the packwiz
|
||||||
(which renders configs + `render-pack.sh` for the packwiz pack), then
|
pack) from `.env` before the stack comes back up.
|
||||||
`docker compose ... up -d` with the online file set.
|
|
||||||
|
|
||||||
## What survives a hard restart
|
## What survives a hard restart
|
||||||
|
|
||||||
Named volumes persist — world and auth data are safe:
|
Named volumes persist — world and auth data are safe:
|
||||||
- `mc_data` — world + installed NeoForge/mods
|
- `mc_data` — world + installed NeoForge/mods
|
||||||
- `drasl_state` — Drasl accounts/skins
|
- `drasl_state` — Drasl accounts/skins
|
||||||
|
- `kuma_data` — Uptime Kuma config/history
|
||||||
- `backups/` — mc-backup snapshots
|
- `backups/` — mc-backup snapshots
|
||||||
|
|
||||||
`down` removes **containers**, not volumes. Players are disconnected during the
|
`down` removes **containers**, not volumes. Players are disconnected during the
|
||||||
@@ -49,22 +51,20 @@ restart (a few minutes); they reconnect once `minecraft` is healthy again.
|
|||||||
## Prerequisites (one-time, on `cochi`)
|
## Prerequisites (one-time, on `cochi`)
|
||||||
|
|
||||||
- SSH access as the `cochi` alias.
|
- SSH access as the `cochi` alias.
|
||||||
- `.env` present and complete: `BASE_DOMAIN`, `HOST_LAN_IP`, `RCON_PASSWORD`
|
- `.env` present and complete: `BASE_DOMAIN`, `RCON_PASSWORD`, `CADDY_HTTP_PORT`,
|
||||||
(`render-config.sh` halts on any unset var, even in online mode).
|
`CADDY_HTTP_BIND`, `DISTRIBUTION_WEB_ROOT`, and the Let's Encrypt block
|
||||||
|
(`render-config.sh` halts on an unset `BASE_DOMAIN`).
|
||||||
- Docker + compose plugin, `packwiz`, `envsubst`, `git`.
|
- Docker + compose plugin, `packwiz`, `envsubst`, `git`.
|
||||||
- **First deploy only** — heavy online prep (fetch launcher, pre-bake the server
|
- Host nginx + Let's Encrypt certs installed once — see `15-letsencrypt.md`.
|
||||||
volume, etc.):
|
- **First deploy only** — online prep (build landing, fetch launcher, fetch
|
||||||
```bash
|
authlib into `runtime/`). Not needed for routine redeploys.
|
||||||
tooling/build-stack.sh --prep
|
|
||||||
```
|
|
||||||
Not needed for routine redeploys; `--up` alone re-syncs the pack and configs.
|
|
||||||
|
|
||||||
## Verify
|
## Verify
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f docker-compose.yml -f docker-compose.static.yml ps
|
docker compose ps # caddy, drasl, minecraft, mc-backup, nmsr, uptime-kuma up
|
||||||
docker compose -f docker-compose.yml -f docker-compose.static.yml logs -f minecraft # watch for "Done"
|
docker compose logs -f minecraft # watch for "Done"
|
||||||
curl -fsS "http://auth.$(grep '^BASE_DOMAIN=' .env | cut -d= -f2)/" >/dev/null && echo "auth ok"
|
curl -fsS "https://auth.$(grep '^BASE_DOMAIN=' .env | cut -d= -f2)/" >/dev/null && echo "auth ok"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Rollback
|
## Rollback
|
||||||
@@ -72,15 +72,14 @@ curl -fsS "http://auth.$(grep '^BASE_DOMAIN=' .env | cut -d= -f2)/" >/dev/null &
|
|||||||
```bash
|
```bash
|
||||||
git log --oneline -5 # find the last-good commit
|
git log --oneline -5 # find the last-good commit
|
||||||
git checkout <sha> # or: git reset --hard <sha>
|
git checkout <sha> # or: git reset --hard <sha>
|
||||||
docker compose -f docker-compose.yml -f docker-compose.static.yml down --remove-orphans
|
docker compose down --remove-orphans
|
||||||
tooling/build-stack.sh --up online
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- **Online mode needs internet** on `cochi` at restart (no air-gap mirror): the
|
- Production needs internet on `cochi` at restart: the server installs/re-syncs
|
||||||
server installs NeoForge + mods over the network. For an internet-less deploy,
|
NeoForge + mods over the network.
|
||||||
switch to `airgap` (needs `--prep` first) — see `plan/13-dns-and-run-modes.md`.
|
- All ingress goes through host nginx → caddy. After a redeploy, nginx config is
|
||||||
- Switching to the **Blessing Skin** auth variant changes the file set to
|
unchanged; only re-run `tooling/render-nginx.sh --install` if `BASE_DOMAIN` /
|
||||||
`-f docker-compose.yml -f docker-compose.blessingskin.yml` (see
|
`CADDY_HTTP_PORT` changed (see `15-letsencrypt.md`).
|
||||||
`plan/03b-blessing-skin.md`); update the `down`/`up` commands accordingly.
|
|
||||||
|
|||||||
@@ -1,25 +1,17 @@
|
|||||||
# TLS — Let's Encrypt (OVH DNS-01) + host nginx ingress
|
# TLS — Let's Encrypt (OVH DNS-01) + host nginx ingress
|
||||||
|
|
||||||
Production serves real, publicly-trusted certs via the **host's nginx**. Caddy is
|
Production serves real, publicly-trusted certs via the **host's own nginx**.
|
||||||
no longer the production ingress — it moved to its own compose file for the
|
nginx terminates TLS and reverse-proxies every vhost to the caddy container
|
||||||
LAN/air-gap path.
|
(published localhost-only on `${CADDY_HTTP_PORT}`) by Host header. caddy is the
|
||||||
|
internal router; nginx is the public TLS terminator. This is the only ingress
|
||||||
## Two ingress paths
|
path — there are no compose overrides.
|
||||||
|
|
||||||
| Path | Ingress | Compose | TLS |
|
|
||||||
|------|---------|---------|-----|
|
|
||||||
| Production | host **nginx** | `docker-compose.yml` + `docker-compose.nginx.yml` | Let's Encrypt (this doc) |
|
|
||||||
| LAN / air-gap | **caddy** container | `docker-compose.yml` + `docker-compose.caddy.yml` (+ static/mirror) | Caddy internal CA |
|
|
||||||
|
|
||||||
`build-stack.sh` drives the caddy path (adds `docker-compose.caddy.yml`
|
|
||||||
automatically). The nginx path is a manual deploy (below). This supersedes the
|
|
||||||
ingress part of `plan/14-deploy.md` for production.
|
|
||||||
|
|
||||||
## Certs: one per name, via OVH DNS-01
|
## Certs: one per name, via OVH DNS-01
|
||||||
|
|
||||||
`tooling/issue-letsencrypt.sh` issues a **separate** cert for the apex and each
|
`tooling/issue-letsencrypt.sh` issues a **separate** cert for the apex and each
|
||||||
subdomain (`${BASE_DOMAIN}`, `auth.…`, `pack.…`) using acme.sh + the OVH DNS-01
|
subdomain (`${BASE_DOMAIN}`, `auth.…`, `pack.…`, plus the rest of
|
||||||
challenge — no inbound ports needed, so it works even on a LAN-only host.
|
`LE_SUBDOMAINS`) using acme.sh + the OVH DNS-01 challenge — no inbound ports
|
||||||
|
needed, so it works regardless of public reachability.
|
||||||
|
|
||||||
### One-time setup
|
### One-time setup
|
||||||
|
|
||||||
@@ -36,7 +28,7 @@ challenge — no inbound ports needed, so it works even on a LAN-only host.
|
|||||||
### Issue
|
### Issue
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tooling/issue-letsencrypt.sh # real certs
|
tooling/issue-letsencrypt.sh # real certs
|
||||||
LE_STAGING=1 tooling/issue-letsencrypt.sh # dry run against the LE staging CA
|
LE_STAGING=1 tooling/issue-letsencrypt.sh # dry run against the LE staging CA
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -46,31 +38,30 @@ the reload with `RELOAD_CMD=...` if nginx isn't systemd-managed.
|
|||||||
|
|
||||||
## nginx
|
## nginx
|
||||||
|
|
||||||
1. Stack up with the nginx override (publishes drasl on `127.0.0.1:25585`):
|
1. Bring the stack up (caddy publishes on `127.0.0.1:${CADDY_HTTP_PORT}`):
|
||||||
```bash
|
```bash
|
||||||
docker compose -f docker-compose.yml -f docker-compose.nginx.yml up -d
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
2. Render + install the vhost (`nginx/ulicraft.conf.tmpl`):
|
2. Render + install the vhost (`nginx/ulicraft-caddy.conf.tmpl`):
|
||||||
```bash
|
```bash
|
||||||
APP_DIR=/home/ubuntu/mc/ulicraft-server-v1 BASE_DOMAIN=ulicraft.net \
|
tooling/render-nginx.sh --install
|
||||||
envsubst '$APP_DIR $BASE_DOMAIN' \
|
|
||||||
< nginx/ulicraft.conf.tmpl | sudo tee /etc/nginx/sites-available/ulicraft.conf
|
|
||||||
sudo ln -sf /etc/nginx/sites-available/ulicraft.conf /etc/nginx/sites-enabled/
|
|
||||||
sudo nginx -t && sudo systemctl reload nginx
|
|
||||||
```
|
```
|
||||||
nginx then: apex + `pack.*` as static files (`www`, `pack`, `custom`),
|
This substitutes `$BASE_DOMAIN $APP_DIR $CADDY_HTTP_PORT`, writes to
|
||||||
`auth.*` → drasl. Ensure `www-data` can read `$APP_DIR/{www,pack,custom,certs}`.
|
`sites-available`, symlinks `sites-enabled`, runs `nginx -t`, and reloads
|
||||||
|
nginx. nginx then terminates TLS per vhost (certs under `$APP_DIR/certs`) and
|
||||||
|
reverse-proxies each Host to caddy on `127.0.0.1:${CADDY_HTTP_PORT}`. Ensure
|
||||||
|
`www-data` can read `$APP_DIR/certs`.
|
||||||
|
|
||||||
## DNS
|
## DNS
|
||||||
|
|
||||||
Point the public/party DNS for `${BASE_DOMAIN}`, `auth.${BASE_DOMAIN}`,
|
Point the public DNS for `${BASE_DOMAIN}` and every subdomain
|
||||||
`pack.${BASE_DOMAIN}` at the host. The Minecraft container reaches them via
|
(`auth.` `pack.` `avatar.` `status.` `distribution.` `www.`) at the host running
|
||||||
`extra_hosts: host-gateway` (set in `docker-compose.nginx.yml`) and uses HTTPS —
|
nginx. DNS is configured **outside this repo**. The Minecraft container reaches
|
||||||
the JVM trusts Let's Encrypt with no CA import (the big win over the internal CA).
|
the stack's own names via caddy's `mcnet` aliases, not public DNS.
|
||||||
|
|
||||||
## Why DNS-01 (not HTTP-01)
|
## Why DNS-01 (not HTTP-01)
|
||||||
|
|
||||||
`HOST_LAN_IP` shows a LAN host; HTTP-01/TLS-ALPN-01 need public inbound on 80/443.
|
|
||||||
DNS-01 only needs the OVH API, so it issues regardless of public reachability —
|
DNS-01 only needs the OVH API, so it issues regardless of public reachability —
|
||||||
and `ulicraft.net` is a real OVH-managed domain, which LE requires (it never
|
and `ulicraft.net` is a real OVH-managed domain, which LE requires (it never
|
||||||
issues for `.lan`/`.local`).
|
issues for `.lan`/`.local`). HTTP-01/TLS-ALPN-01 would need public inbound on
|
||||||
|
80/443. The JVM trusts the resulting Let's Encrypt certs with no CA import.
|
||||||
|
|||||||
@@ -1,185 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# build-stack.sh — orchestrate the Ulicraft stack: offline prep then bring-up.
|
|
||||||
#
|
|
||||||
# Phases:
|
|
||||||
# preflight always — verify .env + required tools
|
|
||||||
# --prep ONLINE — render configs, build landing, mirror mods + air-gap,
|
|
||||||
# fetch launcher, pre-bake the server volume
|
|
||||||
# --up OFFLINE — docker compose up -d the full stack
|
|
||||||
# (no flag) preflight + print what each phase would do, then exit
|
|
||||||
#
|
|
||||||
# Cautious by design: halts on the first missing var, missing tool, or failed
|
|
||||||
# step. Each sub-script is itself responsible for its own integrity checks.
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
cd "$(dirname "$0")/.." # repo root
|
|
||||||
COMPOSE=(docker compose)
|
|
||||||
|
|
||||||
# ---- helpers ---------------------------------------------------------------
|
|
||||||
log() { printf '\n\033[1;32m==>\033[0m %s\n' "$*"; }
|
|
||||||
warn() { printf '\033[1;33m!!\033[0m %s\n' "$*" >&2; }
|
|
||||||
die() { printf '\033[1;31mxx\033[0m %s\n' "$*" >&2; exit 1; }
|
|
||||||
|
|
||||||
need_tool() { command -v "$1" >/dev/null 2>&1 || die "missing required tool: $1"; }
|
|
||||||
have_script() { [ -x "tooling/$1" ] || die "missing/not-executable: tooling/$1"; }
|
|
||||||
|
|
||||||
# ---- preflight -------------------------------------------------------------
|
|
||||||
preflight() {
|
|
||||||
log "preflight"
|
|
||||||
[ -f .env ] || die ".env not found (copy .env.example and fill it)"
|
|
||||||
# shellcheck disable=SC1091
|
|
||||||
set -a; . ./.env; set +a
|
|
||||||
: "${BASE_DOMAIN:?BASE_DOMAIN unset in .env}"
|
|
||||||
: "${HOST_LAN_IP:?HOST_LAN_IP unset in .env}"
|
|
||||||
: "${RCON_PASSWORD:?RCON_PASSWORD unset in .env}"
|
|
||||||
|
|
||||||
need_tool docker
|
|
||||||
${COMPOSE[@]} version >/dev/null 2>&1 || die "docker compose plugin not available"
|
|
||||||
need_tool envsubst
|
|
||||||
need_tool jq
|
|
||||||
need_tool curl
|
|
||||||
need_tool sha1sum
|
|
||||||
command -v pnpm >/dev/null 2>&1 || warn "pnpm not found — landing build will be skipped if absent"
|
|
||||||
|
|
||||||
echo " BASE_DOMAIN=${BASE_DOMAIN} HOST_LAN_IP=${HOST_LAN_IP}"
|
|
||||||
echo " preflight ok"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---- online prep -----------------------------------------------------------
|
|
||||||
prep() {
|
|
||||||
log "PHASE prep (requires internet)"
|
|
||||||
|
|
||||||
log "render configs"
|
|
||||||
have_script render-config.sh
|
|
||||||
tooling/render-config.sh
|
|
||||||
|
|
||||||
log "build landing page → www/"
|
|
||||||
if command -v pnpm >/dev/null 2>&1; then
|
|
||||||
( cd landing && pnpm install --frozen-lockfile && BASE_DOMAIN="${BASE_DOMAIN}" pnpm run build )
|
|
||||||
else
|
|
||||||
warn "pnpm absent — skipping landing build"
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "mirror mod jars (offline pack)"
|
|
||||||
have_script mirror-mods.sh
|
|
||||||
tooling/mirror-mods.sh
|
|
||||||
|
|
||||||
log "mirror full air-gap upstream (meta/libraries/assets/client.jar)"
|
|
||||||
have_script mirror-airgap.sh
|
|
||||||
tooling/mirror-airgap.sh
|
|
||||||
|
|
||||||
log "fetch launcher releases"
|
|
||||||
have_script fetch-launcher.sh
|
|
||||||
tooling/fetch-launcher.sh
|
|
||||||
|
|
||||||
log "fetch authlib-injector.jar (server JVM agent → drasl)"
|
|
||||||
if [ -f runtime/authlib-injector.jar ]; then
|
|
||||||
echo " runtime/authlib-injector.jar present — skipping"
|
|
||||||
else
|
|
||||||
ai_url="$(curl -fsSL https://api.github.com/repos/yushijinhun/authlib-injector/releases/latest \
|
|
||||||
| jq -r '.assets[] | select(.name|test("authlib-injector-[0-9].*\\.jar$")) | .browser_download_url' | head -1)"
|
|
||||||
[ -n "$ai_url" ] && [ "$ai_url" != "null" ] || die "could not resolve authlib-injector release asset"
|
|
||||||
curl -fSL --retry 3 -o runtime/authlib-injector.jar "$ai_url"
|
|
||||||
echo " downloaded $(basename "$ai_url")"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "${ENABLE_MDNS:-false}" = true ]; then
|
|
||||||
log "build avahi mDNS image (cache it for offline use)"
|
|
||||||
"${COMPOSE[@]}" -f docker-compose.yml -f docker-compose.avahi.yml build avahi
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "pre-bake server volume (TYPE=NEOFORGE install, one-shot, online)"
|
|
||||||
# Bring minecraft up once so itzg installs NeoForge + mods into the volume,
|
|
||||||
# then stop it. The party then runs offline against the baked volume.
|
|
||||||
# caddy.yml so the pack (pack.${BASE_DOMAIN}) resolves + is served during bake.
|
|
||||||
"${COMPOSE[@]}" -f docker-compose.yml -f docker-compose.caddy.yml up -d minecraft
|
|
||||||
warn "watch logs until 'Done' then re-run with --up; or automate a readiness probe"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---- bring-up --------------------------------------------------------------
|
|
||||||
# Modes (DNS is YOUR party server — see tooling/dns-records.sh for the records):
|
|
||||||
# online base + landing/launcher; internet present, no mirror
|
|
||||||
# airgap online + full upstream mirror (tls internal :443) for no-internet
|
|
||||||
# core base only (no landing, no mirror) — debugging
|
|
||||||
up() {
|
|
||||||
local mode="${1:-airgap}"
|
|
||||||
# build-stack is the caddy (LAN/air-gap) deployer; caddy lives in its own file
|
|
||||||
# now (the base stack can also run behind the host's nginx — see
|
|
||||||
# docker-compose.nginx.yml). Always include caddy here.
|
|
||||||
local -a files=(-f docker-compose.yml -f docker-compose.caddy.yml)
|
|
||||||
case "$mode" in
|
|
||||||
core) ;;
|
|
||||||
online) files+=(-f docker-compose.static.yml) ;;
|
|
||||||
airgap) files+=(-f docker-compose.static.yml -f docker-compose.mirror.yml) ;;
|
|
||||||
*) die "unknown up mode: '$mode' (use online|airgap|core)" ;;
|
|
||||||
esac
|
|
||||||
# Optional mDNS (.local) responder — see ENABLE_MDNS in .env.
|
|
||||||
if [ "${ENABLE_MDNS:-false}" = true ]; then
|
|
||||||
files+=(-f docker-compose.avahi.yml)
|
|
||||||
case "$BASE_DOMAIN" in
|
|
||||||
*.local) ;;
|
|
||||||
*) warn "ENABLE_MDNS=true but BASE_DOMAIN=$BASE_DOMAIN is not *.local — mDNS won't resolve it" ;;
|
|
||||||
esac
|
|
||||||
pgrep -x avahi-daemon >/dev/null 2>&1 \
|
|
||||||
|| warn "host avahi-daemon not detected — the avahi container publishes through it; start it (e.g. 'systemctl start avahi-daemon')"
|
|
||||||
[ -S /run/dbus/system_bus_socket ] \
|
|
||||||
|| warn "host D-Bus socket /run/dbus/system_bus_socket missing — avahi publish will fail"
|
|
||||||
local ifc; ifc=$(ip -o link show 2>/dev/null | wc -l)
|
|
||||||
[ "${ifc:-0}" -gt 6 ] \
|
|
||||||
&& warn "host has ${ifc} network interfaces — set 'allow-interfaces=<lan-nic>' in /etc/avahi/avahi-daemon.conf, else avahi self-collides (Local name collision)"
|
|
||||||
fi
|
|
||||||
log "PHASE up (mode=$mode${ENABLE_MDNS:+, mdns=$ENABLE_MDNS})"
|
|
||||||
have_script render-config.sh
|
|
||||||
tooling/render-config.sh # cheap; ensures configs match current .env
|
|
||||||
|
|
||||||
# Static mode mounts ./caddy/caddy-root-ca.crt as a file at /srv/ca-pub/ca.crt.
|
|
||||||
# Pre-create it (empty) so Docker doesn't make a directory at that path; the
|
|
||||||
# real cert is exported below once Caddy's local CA exists.
|
|
||||||
case "$mode" in
|
|
||||||
online|airgap) [ -e caddy/caddy-root-ca.crt ] || : > caddy/caddy-root-ca.crt ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
"${COMPOSE[@]}" "${files[@]}" up -d
|
|
||||||
|
|
||||||
# Mirror/full provision Caddy's local CA — export it so guests can trust the
|
|
||||||
# HTTPS asset mirror (served at http://${BASE_DOMAIN}/ca.crt in static/full).
|
|
||||||
case "$mode" in
|
|
||||||
airgap)
|
|
||||||
local i
|
|
||||||
for i in 1 2 3 4 5; do
|
|
||||||
docker exec caddy cat /data/caddy/pki/authorities/local/root.crt \
|
|
||||||
> caddy/caddy-root-ca.crt 2>/dev/null && break
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
[ -s caddy/caddy-root-ca.crt ] \
|
|
||||||
&& echo " exported Caddy CA → caddy/caddy-root-ca.crt (served at /ca.crt)" \
|
|
||||||
|| warn "Caddy CA not ready; export later: docker exec caddy cat /data/caddy/pki/authorities/local/root.crt > caddy/caddy-root-ca.crt"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
log "stack up (mode=$mode). apex: http://${BASE_DOMAIN} auth: http://auth.${BASE_DOMAIN}"
|
|
||||||
echo
|
|
||||||
log "Add these to your party DNS (mode=$mode):"
|
|
||||||
[ -x tooling/dns-records.sh ] && tooling/dns-records.sh "$mode" | sed -n '/## Plain/,/^$/p'
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---- dispatch --------------------------------------------------------------
|
|
||||||
preflight
|
|
||||||
case "${1:-}" in
|
|
||||||
--prep) prep ;;
|
|
||||||
--up) up "${2:-airgap}" ;;
|
|
||||||
"") cat <<EOF
|
|
||||||
|
|
||||||
Nothing run. Choose a phase:
|
|
||||||
$0 --prep # ONLINE, once: render, build landing, mirror, fetch, pre-bake
|
|
||||||
$0 --up [mode] # bring the stack up; mode = online|airgap|core (default airgap)
|
|
||||||
# online = landing + launcher; internet present, no mirror
|
|
||||||
# airgap = online + full upstream mirror (:443), no internet
|
|
||||||
# core = base only (debugging)
|
|
||||||
|
|
||||||
DNS: configure your party DNS with the records from tooling/dns-records.sh [mode].
|
|
||||||
mDNS (.local) alternative: set ENABLE_MDNS=true + BASE_DOMAIN=*.local in .env.
|
|
||||||
EOF
|
|
||||||
;;
|
|
||||||
*) die "unknown arg: $1 (use --prep or --up [online|airgap|core])" ;;
|
|
||||||
esac
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# dns-records.sh — print the DNS records to configure on YOUR party DNS server.
|
|
||||||
#
|
|
||||||
# Usage: tooling/dns-records.sh [online|airgap] (default: airgap)
|
|
||||||
# online service names only — your DNS forwards everything else to the internet
|
|
||||||
# airgap service names + the spoofed upstream hosts (Mojang/launcher/NeoForge),
|
|
||||||
# so guest laptops fetch game files from the LAN mirror with no internet
|
|
||||||
#
|
|
||||||
# All names point to HOST_LAN_IP. Reads BASE_DOMAIN / HOST_LAN_IP from .env.
|
|
||||||
# Prints several formats; use whichever your DNS UI wants.
|
|
||||||
set -euo pipefail
|
|
||||||
cd "$(dirname "$0")/.."
|
|
||||||
|
|
||||||
[ -f .env ] && { set -a; . ./.env; set +a; }
|
|
||||||
: "${BASE_DOMAIN:?BASE_DOMAIN unset (set in .env)}"
|
|
||||||
: "${HOST_LAN_IP:?HOST_LAN_IP unset (set in .env)}"
|
|
||||||
|
|
||||||
mode="${1:-airgap}"
|
|
||||||
|
|
||||||
# Stack service names (always).
|
|
||||||
services=(
|
|
||||||
"${BASE_DOMAIN}"
|
|
||||||
"auth.${BASE_DOMAIN}"
|
|
||||||
"packwiz.${BASE_DOMAIN}"
|
|
||||||
"mc.${BASE_DOMAIN}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Upstream hosts spoofed onto the LAN mirror (AIRGAP only). Keep in sync with
|
|
||||||
# caddy/conf.d/20-mirror.caddy and docker-compose.mirror.yml.
|
|
||||||
spoof=(
|
|
||||||
"meta.prismlauncher.org"
|
|
||||||
"piston-meta.mojang.com"
|
|
||||||
"piston-data.mojang.com"
|
|
||||||
"libraries.minecraft.net"
|
|
||||||
"maven.neoforged.net"
|
|
||||||
"resources.download.minecraft.net"
|
|
||||||
)
|
|
||||||
|
|
||||||
names=("${services[@]}")
|
|
||||||
case "$mode" in
|
|
||||||
online) ;;
|
|
||||||
airgap) names+=("${spoof[@]}") ;;
|
|
||||||
*) echo "usage: $0 [online|airgap] [hosts]" >&2; exit 1 ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# Marker so /etc/hosts edits can be applied/removed cleanly.
|
|
||||||
MARK="ulicraft ${mode}"
|
|
||||||
|
|
||||||
# `hosts` format: print ONLY the /etc/hosts block (marker-wrapped) so it can be
|
|
||||||
# piped straight into /etc/hosts to simulate the party DNS. See README.
|
|
||||||
if [ "${2:-}" = hosts ]; then
|
|
||||||
echo "# >>> ${MARK} >>>"
|
|
||||||
for n in "${names[@]}"; do printf '%s %s\n' "$HOST_LAN_IP" "$n"; done
|
|
||||||
echo "# <<< ${MARK} <<<"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "# DNS records for mode=${mode} -> ${HOST_LAN_IP}"
|
|
||||||
echo "# Configure these on your party DNS server (all A records to the host)."
|
|
||||||
echo
|
|
||||||
|
|
||||||
echo "## Plain (name -> ip)"
|
|
||||||
for n in "${names[@]}"; do printf '%-36s %s\n' "$n" "$HOST_LAN_IP"; done
|
|
||||||
echo
|
|
||||||
|
|
||||||
echo "## hosts file (/etc/hosts style)"
|
|
||||||
for n in "${names[@]}"; do printf '%s %s\n' "$HOST_LAN_IP" "$n"; done
|
|
||||||
echo
|
|
||||||
|
|
||||||
echo "## BIND zone (A records)"
|
|
||||||
for n in "${names[@]}"; do printf '%-36s IN A %s\n' "${n}." "$HOST_LAN_IP"; done
|
|
||||||
echo
|
|
||||||
|
|
||||||
echo "## dnsmasq (address=)"
|
|
||||||
echo "address=/${BASE_DOMAIN}/${HOST_LAN_IP} # wildcard covers all *.${BASE_DOMAIN}"
|
|
||||||
if [ "$mode" = airgap ]; then
|
|
||||||
for n in "${spoof[@]}"; do printf 'address=/%s/%s\n' "$n" "$HOST_LAN_IP"; done
|
|
||||||
fi
|
|
||||||
echo
|
|
||||||
|
|
||||||
echo "## Minecraft (no port needed)"
|
|
||||||
echo "# Clients connect to mc.${BASE_DOMAIN} (defaults to :25565)."
|
|
||||||
echo "# Optional SRV if your DNS supports it:"
|
|
||||||
echo "_minecraft._tcp.mc.${BASE_DOMAIN}. IN SRV 0 0 25565 mc.${BASE_DOMAIN}."
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
# offline LAN hosting, then create stable-name symlinks for the landing page.
|
# offline LAN hosting, then create stable-name symlinks for the landing page.
|
||||||
#
|
#
|
||||||
# Guests use FjordLauncherUnlocked (Prism/PolyMC fork with authlib-injector
|
# Guests use FjordLauncherUnlocked (Prism/PolyMC fork with authlib-injector
|
||||||
# built in). We mirror every release asset under mirror/launcher/<tag>/ and
|
# built in). We mirror every release asset under launcher/<tag>/ and
|
||||||
# maintain a `latest` symlink → <tag>. Caddy serves this at /launcher/latest/…
|
# maintain a `latest` symlink → <tag>. Caddy serves this at /launcher/latest/…
|
||||||
#
|
#
|
||||||
# The landing page (landing/src/data/site.ts) links to FIXED filenames so the
|
# The landing page (landing/src/data/site.ts) links to FIXED filenames so the
|
||||||
@@ -16,11 +16,11 @@
|
|||||||
# Cautious by design: halts on any error, and if a stable-name category matches
|
# Cautious by design: halts on any error, and if a stable-name category matches
|
||||||
# 0 or >1 candidate it prints the candidates and exits non-zero rather than
|
# 0 or >1 candidate it prints the candidates and exits non-zero rather than
|
||||||
# guessing. Idempotent — re-runs overwrite symlinks and re-download safely.
|
# guessing. Idempotent — re-runs overwrite symlinks and re-download safely.
|
||||||
# mirror/ is gitignored — only this script is tracked.
|
# launcher/ is gitignored — only this script is tracked.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
REPO="hero-persson/FjordLauncherUnlocked"
|
REPO="hero-persson/FjordLauncherUnlocked"
|
||||||
DEST_ROOT="$(dirname "$0")/../mirror/launcher"
|
DEST_ROOT="$(dirname "$0")/../launcher"
|
||||||
API="https://api.github.com/repos/${REPO}/releases/latest"
|
API="https://api.github.com/repos/${REPO}/releases/latest"
|
||||||
|
|
||||||
command -v curl >/dev/null || { echo "curl required" >&2; exit 1; }
|
command -v curl >/dev/null || { echo "curl required" >&2; exit 1; }
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# mdns-host-setup.sh — pin the HOST avahi-daemon to the LAN interface so it
|
|
||||||
# stops self-colliding across docker bridges ("Local name collision"), which
|
|
||||||
# is what blocks the mDNS (.local) path on a host with many interfaces.
|
|
||||||
#
|
|
||||||
# Detects the LAN NIC from HOST_LAN_IP (.env), sets allow-interfaces=<nic> in
|
|
||||||
# /etc/avahi/avahi-daemon.conf (idempotent, backs up first), restarts avahi.
|
|
||||||
# Re-execs itself with sudo if not root. Run: tooling/mdns-host-setup.sh
|
|
||||||
set -euo pipefail
|
|
||||||
cd "$(dirname "$0")/.."
|
|
||||||
|
|
||||||
[ -f .env ] && { set -a; . ./.env; set +a; }
|
|
||||||
: "${HOST_LAN_IP:?HOST_LAN_IP unset (set in .env)}"
|
|
||||||
|
|
||||||
nic="$(ip -o -4 addr show | awk -v ip="$HOST_LAN_IP" 'index($0, " "ip"/"){print $2; exit}')"
|
|
||||||
[ -n "$nic" ] || { echo "ERROR: no interface found with IP $HOST_LAN_IP" >&2; exit 1; }
|
|
||||||
echo "LAN interface for ${HOST_LAN_IP}: ${nic}"
|
|
||||||
|
|
||||||
conf=/etc/avahi/avahi-daemon.conf
|
|
||||||
[ -f "$conf" ] || { echo "ERROR: $conf not found — is avahi-daemon installed?" >&2; exit 1; }
|
|
||||||
|
|
||||||
# Need root to edit the conf + restart the service.
|
|
||||||
if [ "$(id -u)" -ne 0 ]; then
|
|
||||||
echo "escalating with sudo…"
|
|
||||||
exec sudo "$0" "$@"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Already pinned to this NIC? nothing to do.
|
|
||||||
if grep -qE "^allow-interfaces=${nic}([,[:space:]]|$)" "$conf"; then
|
|
||||||
echo "already set: allow-interfaces=${nic}"
|
|
||||||
else
|
|
||||||
cp -a "$conf" "${conf}.bak.$(date +%s)"
|
|
||||||
if grep -qE '^allow-interfaces=' "$conf"; then
|
|
||||||
sed -i -E "s/^allow-interfaces=.*/allow-interfaces=${nic}/" "$conf"
|
|
||||||
elif grep -qE '^#\s*allow-interfaces=' "$conf"; then
|
|
||||||
sed -i -E "s/^#\s*allow-interfaces=.*/allow-interfaces=${nic}/" "$conf"
|
|
||||||
else
|
|
||||||
# insert right after the [server] section header
|
|
||||||
sed -i -E "/^\[server\]/a allow-interfaces=${nic}" "$conf"
|
|
||||||
fi
|
|
||||||
echo "set allow-interfaces=${nic} (backup: ${conf}.bak.*)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "restarting avahi-daemon…"
|
|
||||||
systemctl restart avahi-daemon
|
|
||||||
sleep 1
|
|
||||||
systemctl is-active --quiet avahi-daemon && echo "avahi-daemon active" || { echo "ERROR: avahi-daemon not active" >&2; exit 1; }
|
|
||||||
grep -E '^allow-interfaces=' "$conf"
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# mirror-airgap.sh — populate the byte-exact upstream mirror for full client air-gap.
|
|
||||||
#
|
|
||||||
# Architecture (see plan/11-full-airgap-mirror.md): dnsmasq spoofs the real
|
|
||||||
# upstream hostnames -> our host; Caddy serves byte-exact path mirrors over HTTPS
|
|
||||||
# with `tls internal`. The launcher is UNMODIFIED — it requests the real Mojang
|
|
||||||
# URLs and DNS resolves them to us. Mirror files live at:
|
|
||||||
# mirror/upstream/<host>/<path> (== the real URL path, byte-for-byte)
|
|
||||||
# `mirror/` is gitignored.
|
|
||||||
#
|
|
||||||
# ── What this script does DETERMINISTICALLY (no proxy needed) ────────────────
|
|
||||||
# Everything reachable purely from the Mojang piston metadata graph for a given
|
|
||||||
# MC version:
|
|
||||||
# 1. version_manifest_v2.json (piston-meta.mojang.com)
|
|
||||||
# 2. the per-version <id>.json (piston-meta.mojang.com)
|
|
||||||
# 3. client.jar (piston-data.mojang.com) — sha1 verified
|
|
||||||
# 4. every library artifact (libraries.minecraft.net / url-derived host) — sha1 verified
|
|
||||||
# 5. the asset index (piston-meta / piston-data, real path) — sha1 verified
|
|
||||||
# 6. every asset object (resources.download.minecraft.net/<2hex>/<hash>) — sha1 verified, idempotent
|
|
||||||
# All downloads go to a .partial file, are sha1-checked, then atomically moved.
|
|
||||||
# Any fetch failure or sha1 mismatch HALTS the script (set -euo pipefail).
|
|
||||||
#
|
|
||||||
# ── What this script CANNOT do here — the MANUAL PROXY-CAPTURE step ──────────
|
|
||||||
# Two upstream surfaces are NOT enumerable from the piston graph and require the
|
|
||||||
# online proxy-capture procedure documented in plan/11-full-airgap-mirror.md
|
|
||||||
# ("Capture (the actual work)"):
|
|
||||||
#
|
|
||||||
# * meta.prismlauncher.org — Prism-format component metadata (the launcher's
|
|
||||||
# own index of MC / LWJGL / NeoForge components).
|
|
||||||
# Layout is launcher-specific (/v1/...), versioned,
|
|
||||||
# and only knowable by observing what the launcher
|
|
||||||
# actually requests.
|
|
||||||
# * maven.neoforged.net — NeoForge installer + the NeoForge-processed
|
|
||||||
# libraries (and possibly extra maven mirrors the
|
|
||||||
# NeoForge install pulls). The exact artifact set
|
|
||||||
# depends on the launcher/installer run.
|
|
||||||
#
|
|
||||||
# For those: launch the instance once online behind a logging forward/reverse
|
|
||||||
# proxy (mitmproxy or a logging Caddy), collect every absolute URL hit, then
|
|
||||||
# fetch each into mirror/upstream/<host>/<path> preserving the path exactly and
|
|
||||||
# verifying sha1 where the source provides it. This script prints a MANUAL STEP
|
|
||||||
# notice at the end as a reminder.
|
|
||||||
#
|
|
||||||
# Requires: curl, jq, sha1sum.
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
cd "$(dirname "$0")/.." # repo root
|
|
||||||
|
|
||||||
# --- env (optional) --------------------------------------------------------
|
|
||||||
[ -f .env ] && { set -a; . ./.env; set +a; }
|
|
||||||
|
|
||||||
MC_VERSION="${MC_VERSION:-1.21.1}"
|
|
||||||
NEOFORGE_VERSION="${NEOFORGE_VERSION:-21.1.209}" # informational; NeoForge libs come from capture
|
|
||||||
|
|
||||||
# --- required tools --------------------------------------------------------
|
|
||||||
for tool in curl jq sha1sum; do
|
|
||||||
command -v "$tool" >/dev/null 2>&1 || { echo "error: '$tool' is required but not found in PATH" >&2; exit 1; }
|
|
||||||
done
|
|
||||||
|
|
||||||
MIRROR_ROOT="mirror/upstream"
|
|
||||||
|
|
||||||
MANIFEST_HOST="piston-meta.mojang.com"
|
|
||||||
MANIFEST_PATH="mc/game/version_manifest_v2.json"
|
|
||||||
MANIFEST_URL="https://${MANIFEST_HOST}/${MANIFEST_PATH}"
|
|
||||||
RESOURCES_HOST="resources.download.minecraft.net"
|
|
||||||
|
|
||||||
echo "mirror-airgap: MC_VERSION=${MC_VERSION} NEOFORGE_VERSION=${NEOFORGE_VERSION} (NeoForge handled by capture)"
|
|
||||||
echo "mirror root: ${MIRROR_ROOT}/"
|
|
||||||
|
|
||||||
# --- helpers ---------------------------------------------------------------
|
|
||||||
sha1_of() { sha1sum "$1" | awk '{print $1}'; }
|
|
||||||
|
|
||||||
# host + path from an absolute https?:// URL (path has no leading slash).
|
|
||||||
url_host() { printf '%s' "$1" | sed -E 's#^[a-zA-Z]+://([^/]+)/.*$#\1#'; }
|
|
||||||
url_path() { printf '%s' "$1" | sed -E 's#^[a-zA-Z]+://[^/]+/##'; }
|
|
||||||
|
|
||||||
# Download $url into mirror/upstream/<host>/<path>, verifying sha1 ($want_sha1,
|
|
||||||
# may be empty to skip verification). Idempotent: a present file whose sha1
|
|
||||||
# already matches is skipped. Halts on any failure or mismatch.
|
|
||||||
# Echoes the destination path on stdout (last line) for callers that need it.
|
|
||||||
fetch_to_mirror() { # $1=url $2=want_sha1(optional)
|
|
||||||
local url="$1" want="${2:-}"
|
|
||||||
local host path dest tmp got
|
|
||||||
host="$(url_host "$url")"
|
|
||||||
path="$(url_path "$url")"
|
|
||||||
[ -n "$host" ] || { echo "error: could not parse host from url: $url" >&2; exit 1; }
|
|
||||||
[ -n "$path" ] || { echo "error: could not parse path from url: $url" >&2; exit 1; }
|
|
||||||
dest="${MIRROR_ROOT}/${host}/${path}"
|
|
||||||
|
|
||||||
if [ -f "$dest" ]; then
|
|
||||||
if [ -n "$want" ]; then
|
|
||||||
got="$(sha1_of "$dest")"
|
|
||||||
if [ "$got" = "$want" ]; then
|
|
||||||
printf '%s\n' "$dest"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
echo " re-fetch ${host}/${path} (local sha1 mismatch)" >&2
|
|
||||||
else
|
|
||||||
# no sha1 to check (e.g. manifest/version json) — re-fetch to stay fresh
|
|
||||||
:
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
mkdir -p "$(dirname "$dest")"
|
|
||||||
tmp="${dest}.partial"
|
|
||||||
if ! curl -fSL --retry 3 -o "$tmp" "$url"; then
|
|
||||||
rm -f "$tmp"
|
|
||||||
echo "error: download failed: $url" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -n "$want" ]; then
|
|
||||||
got="$(sha1_of "$tmp")"
|
|
||||||
if [ "$got" != "$want" ]; then
|
|
||||||
rm -f "$tmp"
|
|
||||||
echo "error: sha1 mismatch for $url" >&2
|
|
||||||
echo " expected: $want" >&2
|
|
||||||
echo " got: $got" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
mv -f "$tmp" "$dest"
|
|
||||||
printf '%s\n' "$dest"
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- 1. version manifest ---------------------------------------------------
|
|
||||||
echo "[1/6] version manifest"
|
|
||||||
manifest_dest="$(fetch_to_mirror "$MANIFEST_URL" "" | tail -n1)"
|
|
||||||
|
|
||||||
version_url="$(jq -r --arg v "$MC_VERSION" '.versions[] | select(.id == $v) | .url' "$manifest_dest")"
|
|
||||||
[ -n "$version_url" ] && [ "$version_url" != "null" ] || {
|
|
||||||
echo "error: MC version '$MC_VERSION' not found in version manifest" >&2; exit 1; }
|
|
||||||
echo " version json: $version_url"
|
|
||||||
|
|
||||||
# --- 2. per-version json ---------------------------------------------------
|
|
||||||
echo "[2/6] version json"
|
|
||||||
version_dest="$(fetch_to_mirror "$version_url" "" | tail -n1)"
|
|
||||||
|
|
||||||
# --- 3. client.jar ---------------------------------------------------------
|
|
||||||
echo "[3/6] client.jar"
|
|
||||||
client_url="$(jq -r '.downloads.client.url' "$version_dest")"
|
|
||||||
client_sha1="$(jq -r '.downloads.client.sha1' "$version_dest")"
|
|
||||||
[ -n "$client_url" ] && [ "$client_url" != "null" ] || { echo "error: no .downloads.client.url in version json" >&2; exit 1; }
|
|
||||||
fetch_to_mirror "$client_url" "$client_sha1" >/dev/null
|
|
||||||
echo " ok client.jar (sha1 verified)"
|
|
||||||
|
|
||||||
# --- 4. libraries ----------------------------------------------------------
|
|
||||||
echo "[4/6] libraries"
|
|
||||||
lib_count="$(jq '[.libraries[] | select(.downloads.artifact != null)] | length' "$version_dest")"
|
|
||||||
echo " $lib_count library artifact(s)"
|
|
||||||
# Emit "url<TAB>sha1" per artifact; host+path derived from the url itself so
|
|
||||||
# whatever maven host appears (libraries.minecraft.net or others) is mirrored.
|
|
||||||
jq -r '.libraries[] | select(.downloads.artifact != null)
|
|
||||||
| "\(.downloads.artifact.url)\t\(.downloads.artifact.sha1)"' "$version_dest" \
|
|
||||||
| while IFS=$'\t' read -r lurl lsha1; do
|
|
||||||
[ -n "$lurl" ] || continue
|
|
||||||
fetch_to_mirror "$lurl" "$lsha1" >/dev/null
|
|
||||||
done
|
|
||||||
echo " ok libraries (sha1 verified)"
|
|
||||||
|
|
||||||
# --- 5. asset index --------------------------------------------------------
|
|
||||||
echo "[5/6] asset index"
|
|
||||||
ai_url="$(jq -r '.assetIndex.url' "$version_dest")"
|
|
||||||
ai_sha1="$(jq -r '.assetIndex.sha1' "$version_dest")"
|
|
||||||
ai_id="$(jq -r '.assetIndex.id' "$version_dest")"
|
|
||||||
[ -n "$ai_url" ] && [ "$ai_url" != "null" ] || { echo "error: no .assetIndex.url in version json" >&2; exit 1; }
|
|
||||||
echo " asset index id=$ai_id"
|
|
||||||
ai_dest="$(fetch_to_mirror "$ai_url" "$ai_sha1" | tail -n1)"
|
|
||||||
|
|
||||||
# --- 6. asset objects ------------------------------------------------------
|
|
||||||
echo "[6/6] asset objects (the bulk — this is the large/slow part)"
|
|
||||||
obj_total="$(jq '.objects | length' "$ai_dest")"
|
|
||||||
echo " $obj_total object(s) -> ${RESOURCES_HOST}/<2hex>/<hash>"
|
|
||||||
n=0
|
|
||||||
# Each object: hash -> served at resources.download.minecraft.net/<first2>/<hash>;
|
|
||||||
# sha1 == the hash itself. Idempotent: fetch_to_mirror skips valid existing files.
|
|
||||||
jq -r '.objects[].hash' "$ai_dest" \
|
|
||||||
| while read -r hash; do
|
|
||||||
[ -n "$hash" ] || continue
|
|
||||||
n=$((n + 1))
|
|
||||||
prefix="${hash:0:2}"
|
|
||||||
obj_url="https://${RESOURCES_HOST}/${prefix}/${hash}"
|
|
||||||
fetch_to_mirror "$obj_url" "$hash" >/dev/null
|
|
||||||
done
|
|
||||||
echo " ok asset objects (sha1 verified, idempotent)"
|
|
||||||
|
|
||||||
# --- manual step notice ----------------------------------------------------
|
|
||||||
cat >&2 <<'NOTICE'
|
|
||||||
|
|
||||||
────────────────────────────────────────────────────────────────────────────
|
|
||||||
MANUAL STEP REQUIRED — proxy-capture (see plan/11-full-airgap-mirror.md)
|
|
||||||
────────────────────────────────────────────────────────────────────────────
|
|
||||||
This script mirrored the deterministic Mojang piston graph only:
|
|
||||||
piston-meta.mojang.com, piston-data.mojang.com, libraries.minecraft.net,
|
|
||||||
resources.download.minecraft.net.
|
|
||||||
|
|
||||||
The following are NOT enumerable from the piston graph and were NOT fetched:
|
|
||||||
* meta.prismlauncher.org — Prism-format component metadata (/v1/...)
|
|
||||||
* maven.neoforged.net — NeoForge installer + processed libraries
|
|
||||||
(plus any extra maven mirrors the install pulls)
|
|
||||||
|
|
||||||
To complete the air-gap mirror, run the capture procedure from
|
|
||||||
plan/11-full-airgap-mirror.md ("Capture (the actual work)"):
|
|
||||||
1. Launch the instance ONCE online behind a logging forward/reverse proxy
|
|
||||||
(mitmproxy or a logging Caddy). Collect every absolute URL requested.
|
|
||||||
2. Fetch each into mirror/upstream/<host>/<path>, preserving the path
|
|
||||||
exactly; verify sha1 where the source json provides it.
|
|
||||||
3. Offline re-test on a clean machine (internet cut, LAN DNS = dnsmasq,
|
|
||||||
Caddy root CA trusted). Any 404 -> a missing URL -> add it to the fetch.
|
|
||||||
────────────────────────────────────────────────────────────────────────────
|
|
||||||
NOTICE
|
|
||||||
|
|
||||||
echo "deterministic mirror complete for MC ${MC_VERSION}."
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# mirror-mods.sh — vendor mod jars locally + rewrite packwiz download URLs.
|
|
||||||
#
|
|
||||||
# For offline (air-gapped) LAN play, packwiz .pw.toml files point at the
|
|
||||||
# Modrinth/CF CDN. This script downloads each jar into pack/mods-files/,
|
|
||||||
# rewrites the [download] url to the local Caddy mirror, then runs
|
|
||||||
# `packwiz refresh` so index.toml/hashes match the rewritten state.
|
|
||||||
#
|
|
||||||
# Cautious by design: halts on download failure or hash mismatch, idempotent
|
|
||||||
# (skips already-vendored valid jars), and no-ops gracefully with zero mods.
|
|
||||||
# pack/mods-files/ is gitignored — only the .pw.toml metadata is tracked.
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
cd "$(dirname "$0")/.." # repo root
|
|
||||||
|
|
||||||
# --- env -------------------------------------------------------------------
|
|
||||||
[ -f .env ] && { set -a; . ./.env; set +a; }
|
|
||||||
: "${BASE_DOMAIN:?BASE_DOMAIN unset (set in .env)}"
|
|
||||||
|
|
||||||
# --- packwiz binary --------------------------------------------------------
|
|
||||||
PACKWIZ="/home/oier/go/bin/packwiz"
|
|
||||||
[ -x "$PACKWIZ" ] || PACKWIZ="$(command -v packwiz || true)"
|
|
||||||
[ -n "$PACKWIZ" ] || { echo "error: packwiz not found (looked at /home/oier/go/bin/packwiz and PATH)" >&2; exit 1; }
|
|
||||||
|
|
||||||
PACK_DIR="pack"
|
|
||||||
MODS_META_DIR="$PACK_DIR/mods"
|
|
||||||
MODS_FILES_DIR="$PACK_DIR/mods-files"
|
|
||||||
MIRROR_BASE="http://packwiz.${BASE_DOMAIN}/mods-files"
|
|
||||||
|
|
||||||
[ -f "$PACK_DIR/pack.toml" ] || { echo "error: $PACK_DIR/pack.toml not found — run packwiz init first" >&2; exit 1; }
|
|
||||||
|
|
||||||
# --- collect .pw.toml files ------------------------------------------------
|
|
||||||
shopt -s nullglob
|
|
||||||
metas=("$MODS_META_DIR"/*.pw.toml)
|
|
||||||
shopt -u nullglob
|
|
||||||
|
|
||||||
if [ "${#metas[@]}" -eq 0 ]; then
|
|
||||||
echo "no mods in $MODS_META_DIR/ — nothing to mirror (no-op)."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
mkdir -p "$MODS_FILES_DIR"
|
|
||||||
|
|
||||||
# --- helpers ---------------------------------------------------------------
|
|
||||||
# Read a top-level scalar TOML key (filename) from a .pw.toml.
|
|
||||||
toml_top_value() { # $1=file $2=key
|
|
||||||
sed -n "s/^[[:space:]]*$2[[:space:]]*=[[:space:]]*\"\(.*\)\"[[:space:]]*\$/\1/p" "$1" | head -n1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Read a key from the [download] table only.
|
|
||||||
toml_download_value() { # $1=file $2=key
|
|
||||||
awk -v key="$2" '
|
|
||||||
/^\[/ { in_dl = ($0 ~ /^\[download\]/) ; next }
|
|
||||||
in_dl {
|
|
||||||
line=$0
|
|
||||||
sub(/^[ \t]*/, "", line)
|
|
||||||
if (line ~ "^" key "[ \t]*=") {
|
|
||||||
sub("^" key "[ \t]*=[ \t]*", "", line)
|
|
||||||
gsub(/^"|"[ \t]*$/, "", line)
|
|
||||||
print line
|
|
||||||
exit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
' "$1"
|
|
||||||
}
|
|
||||||
|
|
||||||
sha256_of() { sha256sum "$1" | awk '{print $1}'; }
|
|
||||||
|
|
||||||
changed=0
|
|
||||||
echo "mirroring ${#metas[@]} mod(s) → $MIRROR_BASE"
|
|
||||||
|
|
||||||
for meta in "${metas[@]}"; do
|
|
||||||
filename="$(toml_top_value "$meta" filename)"
|
|
||||||
url="$(toml_download_value "$meta" url)"
|
|
||||||
hash_format="$(toml_download_value "$meta" hash-format)"
|
|
||||||
want_hash="$(toml_download_value "$meta" hash)"
|
|
||||||
|
|
||||||
[ -n "$filename" ] || { echo "error: no filename in $meta" >&2; exit 1; }
|
|
||||||
[ -n "$url" ] || { echo "error: no [download] url in $meta" >&2; exit 1; }
|
|
||||||
|
|
||||||
dest="$MODS_FILES_DIR/$filename"
|
|
||||||
already_local="$MIRROR_BASE/$filename"
|
|
||||||
|
|
||||||
# If the .pw.toml already points at the mirror and the jar exists & verifies,
|
|
||||||
# skip (idempotent re-run).
|
|
||||||
if [ "$url" = "$already_local" ] && [ -f "$dest" ]; then
|
|
||||||
if [ "${hash_format:-sha256}" = "sha256" ] && [ -n "$want_hash" ]; then
|
|
||||||
if [ "$(sha256_of "$dest")" = "$want_hash" ]; then
|
|
||||||
echo " skip $filename (already vendored, hash ok)"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
echo " re-fetch $filename (local hash mismatch)"
|
|
||||||
else
|
|
||||||
echo " skip $filename (already vendored)"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Download (from current url — CDN on first run). Use a temp file so a failed
|
|
||||||
# download never leaves a partial jar behind.
|
|
||||||
echo " fetch $filename"
|
|
||||||
tmp="$dest.partial"
|
|
||||||
if ! curl -fSL --retry 3 -o "$tmp" "$url"; then
|
|
||||||
rm -f "$tmp"
|
|
||||||
echo "error: download failed for $filename ($url)" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Verify against the recorded hash before trusting the file.
|
|
||||||
if [ "${hash_format:-sha256}" = "sha256" ] && [ -n "$want_hash" ]; then
|
|
||||||
got_hash="$(sha256_of "$tmp")"
|
|
||||||
if [ "$got_hash" != "$want_hash" ]; then
|
|
||||||
rm -f "$tmp"
|
|
||||||
echo "error: hash mismatch for $filename" >&2
|
|
||||||
echo " expected: $want_hash" >&2
|
|
||||||
echo " got: $got_hash" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
mv -f "$tmp" "$dest"
|
|
||||||
|
|
||||||
# Rewrite the [download] url to the local mirror if not already.
|
|
||||||
if [ "$url" != "$already_local" ]; then
|
|
||||||
esc_old="$(printf '%s' "$url" | sed -e 's/[\/&]/\\&/g')"
|
|
||||||
esc_new="$(printf '%s' "$already_local" | sed -e 's/[\/&]/\\&/g')"
|
|
||||||
sed -i "s/^\([[:space:]]*url[[:space:]]*=[[:space:]]*\)\"$esc_old\"/\1\"$esc_new\"/" "$meta"
|
|
||||||
echo " url → $already_local"
|
|
||||||
changed=1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# --- refresh so index.toml + hashes reflect the rewritten metadata ---------
|
|
||||||
echo "running packwiz refresh ..."
|
|
||||||
( cd "$PACK_DIR" && "$PACKWIZ" refresh )
|
|
||||||
|
|
||||||
echo "done. mirrored ${#metas[@]} mod(s)$( [ "$changed" -eq 1 ] && echo ', URLs rewritten to local mirror' )."
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# render-config.sh — turn *.tmpl files into real configs via envsubst.
|
# render-config.sh — turn *.tmpl files into real configs via envsubst.
|
||||||
# Substitutes ONLY ${BASE_DOMAIN} and ${HOST_LAN_IP} so literal $-syntax that
|
# Substitutes ONLY ${BASE_DOMAIN} so literal $-syntax that belongs to the target
|
||||||
# belongs to the target file (dnsmasq, caddy, toml) is left untouched.
|
# file (caddy, toml) is left untouched.
|
||||||
# Halts on any unset var or missing template (cautious: no half-rendered output).
|
# Halts on any unset var or missing template (cautious: no half-rendered output).
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -9,17 +9,15 @@ cd "$(dirname "$0")/.." # repo root
|
|||||||
|
|
||||||
[ -f .env ] && { set -a; . ./.env; set +a; }
|
[ -f .env ] && { set -a; . ./.env; set +a; }
|
||||||
: "${BASE_DOMAIN:?BASE_DOMAIN unset (set in .env)}"
|
: "${BASE_DOMAIN:?BASE_DOMAIN unset (set in .env)}"
|
||||||
: "${HOST_LAN_IP:?HOST_LAN_IP unset (set in .env)}"
|
|
||||||
|
|
||||||
render() { # $1=template $2=output
|
render() { # $1=template $2=output
|
||||||
[ -f "$1" ] || { echo "missing template: $1" >&2; exit 1; }
|
[ -f "$1" ] || { echo "missing template: $1" >&2; exit 1; }
|
||||||
envsubst '${BASE_DOMAIN} ${HOST_LAN_IP}' < "$1" > "$2"
|
envsubst '${BASE_DOMAIN}' < "$1" > "$2"
|
||||||
echo "rendered $2"
|
echo "rendered $2"
|
||||||
}
|
}
|
||||||
|
|
||||||
render drasl/config/config.toml.tmpl drasl/config/config.toml
|
render drasl/config/config.toml.tmpl drasl/config/config.toml
|
||||||
render nmsr/config.toml.tmpl nmsr/config.toml
|
render nmsr/config.toml.tmpl nmsr/config.toml
|
||||||
render dnsmasq/dnsmasq.conf.tmpl dnsmasq/dnsmasq.conf
|
|
||||||
|
|
||||||
# pack templates (mods/*.pw.toml.tmpl, CustomSkinLoader, …) + packwiz index.
|
# pack templates (mods/*.pw.toml.tmpl, CustomSkinLoader, …) + packwiz index.
|
||||||
# Delegated to render-pack.sh (BASE_DOMAIN only — single source of pack render).
|
# Delegated to render-pack.sh (BASE_DOMAIN only — single source of pack render).
|
||||||
|
|||||||
@@ -1,22 +1,21 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# render-nginx.sh — render an nginx vhost template with the .env values.
|
# render-nginx.sh — render the nginx vhost template with the .env values.
|
||||||
#
|
#
|
||||||
# Substitutes $BASE_DOMAIN, $APP_DIR, $CADDY_HTTP_PORT into a template and
|
# Substitutes $BASE_DOMAIN, $APP_DIR, $CADDY_HTTP_PORT into
|
||||||
# prints the result to stdout, or installs it with --install (writes to
|
# nginx/ulicraft-caddy.conf.tmpl (the host nginx that terminates TLS in front of
|
||||||
# sites-available, symlinks sites-enabled, tests, reloads nginx).
|
# caddy) and prints the result to stdout, or installs it with --install (writes
|
||||||
|
# to sites-available, symlinks sites-enabled, tests, reloads nginx).
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# tooling/render-nginx.sh # render caddy-front template -> stdout
|
# tooling/render-nginx.sh # render -> stdout
|
||||||
# tooling/render-nginx.sh --install # render + install + reload nginx
|
# tooling/render-nginx.sh --install # render + install + reload nginx
|
||||||
# TEMPLATE=nginx/ulicraft.conf.tmpl tooling/render-nginx.sh # the static variant
|
|
||||||
#
|
#
|
||||||
# .env vars used:
|
# .env vars used:
|
||||||
# BASE_DOMAIN required
|
# BASE_DOMAIN required
|
||||||
# CADDY_HTTP_PORT caddy-front template only (localhost port nginx proxies to)
|
# CADDY_HTTP_PORT localhost port nginx reverse-proxies to (caddy)
|
||||||
# optional:
|
# optional:
|
||||||
# APP_DIR host path to this repo (cert files live under APP_DIR/certs)
|
# APP_DIR host path to this repo (cert files live under APP_DIR/certs)
|
||||||
# — defaults to the repo root (this checkout)
|
# — defaults to the repo root (this checkout)
|
||||||
# TEMPLATE template to render (default nginx/ulicraft-caddy.conf.tmpl)
|
|
||||||
# SITE_NAME installed filename (default ulicraft.conf)
|
# SITE_NAME installed filename (default ulicraft.conf)
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
# ${BASE_DOMAIN}, then rebuild the packwiz index.
|
# ${BASE_DOMAIN}, then rebuild the packwiz index.
|
||||||
#
|
#
|
||||||
# Pack templates are domain-dependent build output (download URLs, skin API
|
# Pack templates are domain-dependent build output (download URLs, skin API
|
||||||
# roots, …). This renderer needs ONLY ${BASE_DOMAIN} — no dnsmasq HOST_LAN_IP —
|
# roots, …). This renderer needs ONLY ${BASE_DOMAIN}, so it can run while
|
||||||
# so it can run while curating mods before the full deploy config exists.
|
# curating mods before the full deploy config exists.
|
||||||
# Both tooling/render-config.sh (deploy) and tooling/add-custom-mod.sh call it.
|
# Both tooling/render-config.sh (deploy) and tooling/add-custom-mod.sh call it.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user