Air-gap mirror uses tls internal; guests must trust Caddy's local CA. Serve it at http://<domain>/ca.crt from a world-readable mount (the live pki dir is root-only 0600). build-stack --up mirror|full exports the CA after bring-up; landing page links it. Pre-creates the mount target to avoid a dir bind. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
162 lines
6.4 KiB
Bash
Executable File
162 lines
6.4 KiB
Bash
Executable File
#!/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
|
|
|
|
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.
|
|
"${COMPOSE[@]}" up -d minecraft
|
|
warn "watch logs until 'Done' then re-run with --up; or automate a readiness probe"
|
|
}
|
|
|
|
# ---- offline bring-up ------------------------------------------------------
|
|
# Modes select which optional layers come up (override files):
|
|
# core base only (dnsmasq, drasl, minecraft, mc-backup, caddy auth+packwiz)
|
|
# static + apex landing page + launcher downloads
|
|
# mirror + full air-gap upstream mirror (tls internal on :443)
|
|
# full static + mirror (default)
|
|
up() {
|
|
local mode="${1:-full}"
|
|
local -a files=(-f docker-compose.yml)
|
|
case "$mode" in
|
|
core) ;;
|
|
static) files+=(-f docker-compose.static.yml) ;;
|
|
mirror) files+=(-f docker-compose.mirror.yml) ;;
|
|
full) files+=(-f docker-compose.static.yml -f docker-compose.mirror.yml) ;;
|
|
*) die "unknown up mode: '$mode' (use core|static|mirror|full)" ;;
|
|
esac
|
|
log "PHASE up (mode=$mode)"
|
|
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
|
|
static|full) [ -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
|
|
mirror|full)
|
|
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}"
|
|
}
|
|
|
|
# ---- dispatch --------------------------------------------------------------
|
|
preflight
|
|
case "${1:-}" in
|
|
--prep) prep ;;
|
|
--up) up "${2:-full}" ;;
|
|
"") cat <<EOF
|
|
|
|
Nothing run. Choose a phase:
|
|
$0 --prep # ONLINE: render, build landing, mirror everything, pre-bake server
|
|
$0 --up [mode] # bring the stack up; mode = core|static|mirror|full (default full)
|
|
# core = no static site, no mirror
|
|
# static = + apex landing + launcher downloads
|
|
# mirror = + full air-gap upstream mirror (:443)
|
|
# full = static + mirror
|
|
|
|
Sub-scripts expected in tooling/: render-config.sh, mirror-mods.sh,
|
|
mirror-airgap.sh, fetch-launcher.sh (built in their own commits — see plan/12).
|
|
EOF
|
|
;;
|
|
*) die "unknown arg: $1 (use --prep or --up [core|static|mirror|full])" ;;
|
|
esac
|