feat(dns): party-DNS records generator + online/airgap modes

DNS is now your own party server. tooling/dns-records.sh prints the records
(online = service names; airgap = + Mojang/launcher/NeoForge spoofs) in several
formats. build-stack --up online|airgap selects the mirror layer and prints the
matching records. dnsmasq moved to an optional override (docker-compose.dnsmasq.yml).
Default domain switched to .lan (.local is hijacked by mDNS on unicast DNS).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-24 05:56:54 +02:00
parent 390f8c78af
commit f14578dc65
5 changed files with 144 additions and 46 deletions

View File

@@ -1,10 +1,19 @@
# 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.
BASE_DOMAIN=ulicraft.local # Use .lan (NOT .local) — .local is intercepted by mDNS on macOS/Linux and
# The Docker host's LAN IP — dnsmasq points *.BASE_DOMAIN here for guest laptops. # won't resolve through a normal unicast DNS server.
BASE_DOMAIN=ulicraft.lan
# The Docker host's LAN IP — every service name resolves here.
HOST_LAN_IP=192.168.1.10 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
# 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

View File

@@ -0,0 +1,20 @@
# 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.

View File

@@ -1,8 +1,10 @@
# ────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────
# Ulicraft LAN-party stack — Minecraft 1.21.1 NeoForge + Drasl auth # Ulicraft LAN-party stack — Minecraft 1.21.1 NeoForge + Drasl auth
# ────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────
# Topology (single source of config: BASE_DOMAIN + HOST_LAN_IP in .env): # Single source of config: BASE_DOMAIN + HOST_LAN_IP in .env.
# - dnsmasq resolves *.${BASE_DOMAIN} -> HOST_LAN_IP for guest laptops # DNS is provided by YOUR party DNS server — see tooling/dns-records.sh for the
# records to add (online vs airgap). A turnkey dnsmasq is available as an
# optional layer: docker-compose.dnsmasq.yml.
# - caddy is the only HTTP ingress; holds mcnet aliases so the stack # - caddy is the only HTTP ingress; holds mcnet aliases so the stack
# resolves auth.${BASE_DOMAIN} / packwiz.${BASE_DOMAIN} internally too # resolves auth.${BASE_DOMAIN} / packwiz.${BASE_DOMAIN} internally too
# - drasl handles auth (password login; NO Keycloak/OIDC in this stack) # - drasl handles auth (password login; NO Keycloak/OIDC in this stack)
@@ -11,24 +13,6 @@
# ────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────
services: 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
# The webproc UI (:8080) can be exposed later behind Caddy as dns.${BASE_DOMAIN};
# guard it with HTTP_USER / HTTP_PASS env vars.
# environment:
# HTTP_USER: "admin"
# HTTP_PASS: "change-me"
# Core ingress. Static site + air-gap mirror are layered in via override # Core ingress. Static site + air-gap mirror are layered in via override
# files (docker-compose.static.yml / .mirror.yml) — see build-stack.sh --up. # files (docker-compose.static.yml / .mirror.yml) — see build-stack.sh --up.
caddy: caddy:

View File

@@ -83,6 +83,11 @@ prep() {
echo " downloaded $(basename "$ai_url")" echo " downloaded $(basename "$ai_url")"
fi 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)" log "pre-bake server volume (TYPE=NEOFORGE install, one-shot, online)"
# Bring minecraft up once so itzg installs NeoForge + mods into the volume, # Bring minecraft up once so itzg installs NeoForge + mods into the volume,
# then stop it. The party then runs offline against the baked volume. # then stop it. The party then runs offline against the baked volume.
@@ -90,23 +95,29 @@ prep() {
warn "watch logs until 'Done' then re-run with --up; or automate a readiness probe" warn "watch logs until 'Done' then re-run with --up; or automate a readiness probe"
} }
# ---- offline bring-up ------------------------------------------------------ # ---- bring-up --------------------------------------------------------------
# Modes select which optional layers come up (override files): # Modes (DNS is YOUR party server — see tooling/dns-records.sh for the records):
# core base only (dnsmasq, drasl, minecraft, mc-backup, caddy auth+packwiz) # online base + landing/launcher; internet present, no mirror
# static + apex landing page + launcher downloads # airgap online + full upstream mirror (tls internal :443) for no-internet
# mirror + full air-gap upstream mirror (tls internal on :443) # core base only (no landing, no mirror) — debugging
# full static + mirror (default)
up() { up() {
local mode="${1:-full}" local mode="${1:-airgap}"
local -a files=(-f docker-compose.yml) local -a files=(-f docker-compose.yml)
case "$mode" in case "$mode" in
core) ;; core) ;;
static) files+=(-f docker-compose.static.yml) ;; online) files+=(-f docker-compose.static.yml) ;;
mirror) files+=(-f docker-compose.mirror.yml) ;; airgap) files+=(-f docker-compose.static.yml -f docker-compose.mirror.yml) ;;
full) files+=(-f docker-compose.static.yml -f docker-compose.mirror.yml) ;; *) die "unknown up mode: '$mode' (use online|airgap|core)" ;;
*) die "unknown up mode: '$mode' (use core|static|mirror|full)" ;;
esac esac
log "PHASE up (mode=$mode)" # 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
fi
log "PHASE up (mode=$mode${ENABLE_MDNS:+, mdns=$ENABLE_MDNS})"
have_script render-config.sh have_script render-config.sh
tooling/render-config.sh # cheap; ensures configs match current .env tooling/render-config.sh # cheap; ensures configs match current .env
@@ -114,7 +125,7 @@ up() {
# Pre-create it (empty) so Docker doesn't make a directory at that path; the # 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. # real cert is exported below once Caddy's local CA exists.
case "$mode" in case "$mode" in
static|full) [ -e caddy/caddy-root-ca.crt ] || : > caddy/caddy-root-ca.crt ;; online|airgap) [ -e caddy/caddy-root-ca.crt ] || : > caddy/caddy-root-ca.crt ;;
esac esac
"${COMPOSE[@]}" "${files[@]}" up -d "${COMPOSE[@]}" "${files[@]}" up -d
@@ -122,7 +133,7 @@ up() {
# Mirror/full provision Caddy's local CA — export it so guests can trust the # 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). # HTTPS asset mirror (served at http://${BASE_DOMAIN}/ca.crt in static/full).
case "$mode" in case "$mode" in
mirror|full) airgap)
local i local i
for i in 1 2 3 4 5; do for i in 1 2 3 4 5; do
docker exec caddy cat /data/caddy/pki/authorities/local/root.crt \ docker exec caddy cat /data/caddy/pki/authorities/local/root.crt \
@@ -136,26 +147,28 @@ up() {
esac esac
log "stack up (mode=$mode). apex: http://${BASE_DOMAIN} auth: http://auth.${BASE_DOMAIN}" 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 -------------------------------------------------------------- # ---- dispatch --------------------------------------------------------------
preflight preflight
case "${1:-}" in case "${1:-}" in
--prep) prep ;; --prep) prep ;;
--up) up "${2:-full}" ;; --up) up "${2:-airgap}" ;;
"") cat <<EOF "") cat <<EOF
Nothing run. Choose a phase: Nothing run. Choose a phase:
$0 --prep # ONLINE: render, build landing, mirror everything, pre-bake server $0 --prep # ONLINE, once: render, build landing, mirror, fetch, pre-bake
$0 --up [mode] # bring the stack up; mode = core|static|mirror|full (default full) $0 --up [mode] # bring the stack up; mode = online|airgap|core (default airgap)
# core = no static site, no mirror # online = landing + launcher; internet present, no mirror
# static = + apex landing + launcher downloads # airgap = online + full upstream mirror (:443), no internet
# mirror = + full air-gap upstream mirror (:443) # core = base only (debugging)
# full = static + mirror
Sub-scripts expected in tooling/: render-config.sh, mirror-mods.sh, DNS: configure your party DNS with the records from tooling/dns-records.sh [mode].
mirror-airgap.sh, fetch-launcher.sh (built in their own commits — see plan/12). mDNS (.local) alternative: set ENABLE_MDNS=true + BASE_DOMAIN=*.local in .env.
EOF EOF
;; ;;
*) die "unknown arg: $1 (use --prep or --up [core|static|mirror|full])" ;; *) die "unknown arg: $1 (use --prep or --up [online|airgap|core])" ;;
esac esac

72
tooling/dns-records.sh Executable file
View File

@@ -0,0 +1,72 @@
#!/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]" >&2; exit 1 ;;
esac
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}."