#!/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. "${COMPOSE[@]}" 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}" local -a files=(-f docker-compose.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=' 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 <