refactor!: unify into one compose, drop airgap/mirror/dnsmasq/avahi/blessingskin

Collapse the override-file matrix into a single docker-compose.yml holding the
whole stack: drasl, minecraft, mc-backup, caddy, nmsr, uptime-kuma. Bring-up is
now just `docker compose up -d --build`.

Removed features (dead-ends for this deployment):
- airgap / full asset mirror (mirror-airgap.sh, mirror-mods.sh, 20-mirror.caddy,
  caddy local-CA export, /ca.crt)
- dnsmasq + avahi/mDNS + dns-records.sh + mdns-host-setup.sh + ENABLE_MDNS;
  DNS is now handled outside this repo (HOST_LAN_IP dropped)
- Blessing Skin auth variant (compose + 00-core-blessingskin.caddy + BS_* env)
- host-nginx-static variant (docker-compose.nginx.yml, nginx/ulicraft.conf.tmpl)
- build-stack.sh and its run modes (online/airgap/core)

Production ingress is the only path now: host nginx terminates TLS (LE certs)
in front of caddy on a localhost-only port; caddy routes every vhost by Host
header. Launcher downloads move mirror/launcher -> launcher/.

Docs: README, deploy skill, and plan/ rewritten to match; obsolete plan docs
(dnsmasq, blessing-skin, assets-mirror, full-airgap-mirror, dns-and-run-modes)
deleted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 02:14:09 +02:00
parent ff645de2c8
commit 67d1f82e6a
53 changed files with 522 additions and 2244 deletions

View File

@@ -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

View File

@@ -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}."

View File

@@ -3,7 +3,7 @@
# offline LAN hosting, then create stable-name symlinks for the landing page.
#
# 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/…
#
# 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
# 0 or >1 candidate it prints the candidates and exits non-zero rather than
# 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
REPO="hero-persson/FjordLauncherUnlocked"
DEST_ROOT="$(dirname "$0")/../mirror/launcher"
DEST_ROOT="$(dirname "$0")/../launcher"
API="https://api.github.com/repos/${REPO}/releases/latest"
command -v curl >/dev/null || { echo "curl required" >&2; exit 1; }

View File

@@ -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"

View File

@@ -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}."

View File

@@ -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' )."

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# render-config.sh — turn *.tmpl files into real configs via envsubst.
# Substitutes ONLY ${BASE_DOMAIN} and ${HOST_LAN_IP} so literal $-syntax that
# belongs to the target file (dnsmasq, caddy, toml) is left untouched.
# Substitutes ONLY ${BASE_DOMAIN} so literal $-syntax that belongs to the target
# file (caddy, toml) is left untouched.
# Halts on any unset var or missing template (cautious: no half-rendered output).
set -euo pipefail
@@ -9,17 +9,15 @@ cd "$(dirname "$0")/.." # repo root
[ -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)}"
render() { # $1=template $2=output
[ -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"
}
render drasl/config/config.toml.tmpl drasl/config/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.
# Delegated to render-pack.sh (BASE_DOMAIN only — single source of pack render).

View File

@@ -1,22 +1,21 @@
#!/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
# prints the result to stdout, or installs it with --install (writes to
# sites-available, symlinks sites-enabled, tests, reloads nginx).
# Substitutes $BASE_DOMAIN, $APP_DIR, $CADDY_HTTP_PORT into
# nginx/ulicraft-caddy.conf.tmpl (the host nginx that terminates TLS in front of
# caddy) and prints the result to stdout, or installs it with --install (writes
# to sites-available, symlinks sites-enabled, tests, reloads nginx).
#
# 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
# TEMPLATE=nginx/ulicraft.conf.tmpl tooling/render-nginx.sh # the static variant
#
# .env vars used:
# 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:
# APP_DIR host path to this repo (cert files live under APP_DIR/certs)
# — defaults to the repo root (this checkout)
# TEMPLATE template to render (default nginx/ulicraft-caddy.conf.tmpl)
# SITE_NAME installed filename (default ulicraft.conf)
set -euo pipefail

View File

@@ -3,8 +3,8 @@
# ${BASE_DOMAIN}, then rebuild the packwiz index.
#
# Pack templates are domain-dependent build output (download URLs, skin API
# roots, …). This renderer needs ONLY ${BASE_DOMAIN} — no dnsmasq HOST_LAN_IP —
# so it can run while curating mods before the full deploy config exists.
# roots, …). This renderer needs ONLY ${BASE_DOMAIN}, so it can run while
# curating mods before the full deploy config exists.
# Both tooling/render-config.sh (deploy) and tooling/add-custom-mod.sh call it.
set -euo pipefail