#!/usr/bin/env bash
# scripts/site-headend-install.sh [--check|--dry-run] [--role <region+rack|rack>]
# region+rack (default): --compose-cidr <CIDR> [--maas-url-ip <IP>]
# rack: --region-url <http://REGION-IP:5240/MAAS> --enroll-secret-file <path>
#
# Installs a VR1 SITE HEADEND on the host it runs on. Two roles (D-123 rules ONE MAAS
# region on Office1 + a rack controller PER DC):
#
# --role region+rack (DEFAULT -- unchanged legacy behavior): MAAS (region+rack) + LXD, with
# that LXD registered back into MAAS as an LXD VM host so MAAS can COMPOSE the site's
# non-stack service machines into it. This is D-114, VR0's proven `lxd` + `tailscale`
# pattern applied per site. This is the Office1 (voffice1) build.
#
# --role rack: a DC rack controller (vvr1-dc0) that ENROLLS to Office1's EXISTING region
# (D-123). A rack runs NO PostgreSQL, does its own `maas init rack`, and composes
# NOTHING -- the DC's OpenStack nodes are OpenTofu-created and DISCOVERED via the vcloud
# virsh vm-host registered to the REGION (not composed here). It therefore SKIPS all of
# the region DB / createadmin / LXD / vm-host / compose-DHCP work below. Enabling
# DHCP-on-metal-admin is a REGION-side op (Office1's MAAS, naming this rack as
# primary_rack) done as a RUNBOOK step -- NOT by this script (see rack note below).
#
# RUN IT ON THE SITE HOST, as root:
# region+rack (voffice1):
# ssh <site-host> 'sudo bash -s' -- --compose-cidr 10.10.1.0/24 < scripts/site-headend-install.sh
# rack (vvr1-dc0): the region operator reads the enrollment secret from the REGION host's
# /var/snap/maas/common/maas/secret and drops it into a 0600 file on this host first:
# ssh <dc-rack> 'sudo bash -s' -- --role rack \
# --region-url http://10.10.0.20:5240/MAAS --enroll-secret-file /root/region-enroll.secret \
# < scripts/site-headend-install.sh
#
# --check report what is installed/configured; mutate nothing; exit 0 ok / 1 incomplete
# --dry-run print what it would do; mutate nothing
# (no flag) install + configure (idempotent; safe to re-run)
#
# EXIT: 0 ok | 1 check-failed | 2 bad args (incl. bad/missing --role args) | 3 unsupported OS
# | 4 install failed (incl. rack: enrollment-secret file absent/empty at mutate time). ASCII + LF.
#
# =====================================================================================
# THE FOUR TRAPS THIS SCRIPT EXISTS TO ENCODE (all hit for real on 2026-07-13; do not
# "simplify" any of them away):
#
# 1. `lxd init --auto` FAILS OUTRIGHT on a host that also runs MAAS.
# It creates lxdbr0 with LXD's own DHCP+DNS ON, which starts a dnsmasq; dnsmasq binds
# the WILDCARD 0.0.0.0:53, and MAAS's bind9 (`named`) already holds :53. Verbatim:
# Failed starting network: The DNS and DHCP service exited prematurely: exit status 2
# ("dnsmasq: failed to create listening socket for 10.10.1.1: Address already in use")
# Note the message names the BRIDGE address, which sends you hunting the wrong thing --
# the real conflict is the wildcard bind. So: NEVER `lxd init --auto` here. Create the
# bridge explicitly with DHCP/DNS off AND `raw.dnsmasq=port=0`, which stops dnsmasq
# binding :53 at all. (`ipv4.dhcp=false` + `dns.mode=none` alone are NOT enough -- LXD
# still spawns dnsmasq for a managed bridge, and it still tries to bind :53.)
#
# 2. `lxc` READS ITS CONFIG FROM STDIN. If you pipe this script to `bash -s` over ssh, the
# FIRST `lxc` call with no stdin redirect EATS THE REST OF THE SCRIPT as YAML and dies
# with `yaml: unmarshal errors`, silently truncating everything after it. Every `lxc`
# call below therefore ends in `</dev/null`. Same class as the OPNsense tcsh trap.
#
# 3. LXD SNAP TRACK CHANGES ARE ONE-WAY -- you cannot downgrade a track. LXD MUST be
# installed DIRECTLY onto 5.21/stable and never allowed to land on `latest` first,
# because MAAS 3.6/3.7 is INCOMPATIBLE with LXD >= 6.7 (LXD 6.7 consolidated API
# endpoints; MAAS's pinned pylxd 2.3.5 cannot speak to them; fixed in pylxd >= 2.3.9,
# not yet in a MAAS release). Canonical's guidance: LXD <= 6.6, or the 5.21 LTS track.
# https://discourse.maas.io/t/maas-incompatibility-with-lxd-6-7/15749
# Bonus: `core.trust_password` still EXISTS in 5.21 and is GONE in 6.x -- the pin also
# preserves the scriptable registration path.
#
# 4. MAAS DHCP IS ENABLED ON THE COMPOSE NETWORK ONLY -- NEVER on the site LAN. The site
# LAN already has an authoritative DHCP server (Kea, on the OPNsense edge). Two DHCP
# servers on one L2 is an intermittent failure that is genuinely unpleasant to
# diagnose. --compose-cidr is REQUIRED and has no default precisely so this can never
# be picked by accident, and the script REFUSES to touch DHCP on any other subnet.
#
# TRAPS 1-3 (LXD/dnsmasq) DO NOT APPLY IN --role rack: a rack installs no LXD, creates no
# bridge, and runs no dnsmasq, so the :53/:67 conflict and the 5.21-track pin are moot.
# Trap 4's SPIRIT is kept absolutely: rack mode configures NO DHCP at all (metal-admin DHCP
# is a REGION-side op, not this script's), so it cannot fight the edge's Kea by construction.
# =====================================================================================
set -uo pipefail
CHECK=0; DRYRUN=0; COMPOSE_CIDR=""; MAAS_IP=""
ROLE="region+rack"; REGION_URL=""; ENROLL_SECRET_FILE=""
# D-123 MODEL B: a DC rack (vvr1-dc0) is ALSO the nested libvirt/KVM host for the INNER
# OpenTofu root that builds the node fleet (opentofu/vr1-dc0-substrate/). --host-nodes adds
# that setup to the rack install (make it a libvirt host + nested KVM + inner pool + base
# image) so the qemu+ssh inner provider (R-5) has something to connect to.
HOST_NODES=0; HOST_USER="jessea123"; INNER_POOL_PATH="/var/lib/libvirt/vr1-dc0-inner"; OPNSENSE_BASE=""
# SEC-010: the transit-leg interface name on vvr1-dc0 (the outer NIC; netplan set-name in the
# outer cloud-init is "mgmt"). The DC-LOCAL FORWARD-drop is keyed on it.
TRANSIT_IF="mgmt"
while [ $# -gt 0 ]; do
case "$1" in
--check) CHECK=1 ;;
--dry-run) DRYRUN=1 ;;
--role) shift; ROLE="${1:-}" ;;
--compose-cidr) shift; COMPOSE_CIDR="${1:-}" ;;
--maas-url-ip) shift; MAAS_IP="${1:-}" ;;
--region-url) shift; REGION_URL="${1:-}" ;;
--enroll-secret-file) shift; ENROLL_SECRET_FILE="${1:-}" ;;
--host-nodes) HOST_NODES=1 ;;
--host-user) shift; HOST_USER="${1:-}" ;;
--inner-pool-path) shift; INNER_POOL_PATH="${1:-}" ;;
--opnsense-base) shift; OPNSENSE_BASE="${1:-}" ;;
--transit-if) shift; TRANSIT_IF="${1:-}" ;;
-h|--help)
echo "usage: site-headend-install.sh [--check|--dry-run] [--role <region+rack|rack>]"
echo " region+rack (default): --compose-cidr <CIDR> [--maas-url-ip <IP>]"
echo " rack: --region-url <http://REGION-IP:5240/MAAS> --enroll-secret-file <path>"
echo " rack + Model B node host (vvr1-dc0): add --host-nodes [--host-user U]"
echo " [--inner-pool-path P] [--opnsense-base QCOW2]"
exit 0 ;;
*) echo "FAIL: unknown arg '$1'" >&2; exit 2 ;;
esac
shift
done
# --host-nodes only makes sense on a DC rack (the Model B node host); reject on region+rack.
[ "$HOST_NODES" = "1" ] && [ "$ROLE" != "rack" ] && { echo "FAIL: --host-nodes requires --role rack (it is the D-123 Model B DC node host)" >&2; exit 2; }
[ -n "$OPNSENSE_BASE" ] || OPNSENSE_BASE="$INNER_POOL_PATH/opnsense-26.1-nano.qcow2"
case "$ROLE" in
region+rack|rack) ;;
*) echo "FAIL: --role must be 'region+rack' (default) or 'rack' (got '$ROLE')" >&2; exit 2 ;;
esac
# Per-role REQUIRED args are validated UNCONDITIONALLY here (mirrors the legacy --compose-cidr
# check, which already gated --check too), so a bad invocation is rejected before any mode runs.
if [ "$ROLE" = "rack" ]; then
# rack enrolls to Office1's EXISTING region -- no compose network (LXD-compose is a region
# concern); it needs the region URL + the region's enrollment secret (read from a file, never
# echoed -- same discipline as $SECRETS below; the region operator obtains it from the region
# host's /var/snap/maas/common/maas/secret).
[ -n "$REGION_URL" ] || { echo "FAIL: --role rack REQUIRES --region-url <http://REGION-IP:5240/MAAS>" >&2; exit 2; }
echo "$REGION_URL" | grep -qiE '^https?://.+/MAAS/?$' || { echo "FAIL: --region-url must look like http://REGION-IP:5240/MAAS (got '$REGION_URL')" >&2; exit 2; }
[ -n "$ENROLL_SECRET_FILE" ] || { echo "FAIL: --role rack REQUIRES --enroll-secret-file <path> (the region's enrollment secret; NEVER passed on the command line)" >&2; exit 2; }
# --compose-cidr is NOT used in rack mode; ignore it if present.
else
[ -n "$COMPOSE_CIDR" ] || { echo "FAIL: --compose-cidr is REQUIRED (e.g. 10.10.1.0/24). It has no default on purpose: MAAS must serve DHCP on the compose network ONLY, never on the site LAN, which the edge's Kea already owns." >&2; exit 2; }
echo "$COMPOSE_CIDR" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.0/24$' || { echo "FAIL: --compose-cidr must be a /24 like 10.10.1.0/24 (got '$COMPOSE_CIDR')" >&2; exit 2; }
BASE="${COMPOSE_CIDR%.0/24}" # 10.10.1
BRIDGE_IP="${BASE}.1"
RANGE_LO="${BASE}.100"
RANGE_HI="${BASE}.200"
fi
SECRETS=/root/maas-secrets
MAAS_CHANNEL="3.7/stable"
PG_CHANNEL="16/stable"
LXD_CHANNEL="5.21/stable" # TRAP 3 -- do not change without reading it
run() { # run <desc> -- <cmd...>
local d="$1"; shift; [ "${1:-}" = "--" ] && shift
if [ "$DRYRUN" = "1" ]; then printf ' [dry-run] %s\n $ %s\n' "$d" "$*"; return 0; fi
echo " -> $d"; "$@"
}
have() { command -v "$1" >/dev/null 2>&1; }
# ---------------- check mode (read-only) ----------------
report() {
local bad=0
snap list maas >/dev/null 2>&1 && echo "OK: maas snap $(snap list maas 2>/dev/null | awk 'NR>1{print $2" ("$4")"}')" || { echo "ABSENT: maas snap"; bad=1; }
snap list postgresql >/dev/null 2>&1 && echo "OK: postgresql $(snap list postgresql 2>/dev/null | awk 'NR>1{print $2}')" || { echo "ABSENT: postgresql snap"; bad=1; }
if snap list lxd >/dev/null 2>&1; then
local tr; tr="$(snap list lxd 2>/dev/null | awk 'NR>1{print $4}')"
if [ "$tr" = "$LXD_CHANNEL" ]; then echo "OK: lxd on $tr"
else echo "WRONG TRACK: lxd is on '$tr', must be '$LXD_CHANNEL' (MAAS breaks on LXD >= 6.7; track changes are ONE-WAY -- a reinstall is needed)"; bad=1; fi
else echo "ABSENT: lxd snap"; bad=1; fi
if have lxc && lxc network show lxdbr0 </dev/null >/dev/null 2>&1; then
echo "OK: lxdbr0 exists (ipv4.dhcp=$(lxc network get lxdbr0 ipv4.dhcp </dev/null 2>/dev/null), dns.mode=$(lxc network get lxdbr0 dns.mode </dev/null 2>/dev/null))"
local n53 n67
n53=$(ss -lunp 2>/dev/null | grep dnsmasq | grep -c ':53 ')
n67=$(ss -lunp 2>/dev/null | grep dnsmasq | grep -c ':67 ')
if [ "${n53:-0}" = "0" ] && [ "${n67:-0}" = "0" ]; then
echo "OK: dnsmasq binds NO :53 and NO :67 -- MAAS and the edge's Kea remain sole DHCP/DNS authorities"
else
echo "FAIL: dnsmasq holds :53=$n53 :67=$n67 -- it is FIGHTING MAAS/Kea (see trap 1)"; bad=1
fi
else echo "ABSENT: lxdbr0"; bad=1; fi
if have maas && maas status 2>/dev/null | grep -q regiond; then echo "OK: MAAS initialised"; else echo "ABSENT: MAAS not initialised"; bad=1; fi
return "$bad"
}
# ---------------- check mode, RACK role (read-only) ----------------
# A rack runs NO PostgreSQL and NO LXD, so we assert only: the maas snap is present and
# `maas status` shows `rackd` running. If the rack has been through `maas init rack`, the
# region URL it enrolled to is recorded (offline-readable) in the rackd config -- report it
# as the connected-to-region signal, best-effort (never fatal; the file is root-only).
report_rack() {
local bad=0
snap list maas >/dev/null 2>&1 && echo "OK: maas snap $(snap list maas 2>/dev/null | awk 'NR>1{print $2" ("$4")"}')" || { echo "ABSENT: maas snap"; bad=1; }
if have maas && maas status 2>/dev/null | grep -q rackd; then echo "OK: MAAS rackd running"; else echo "ABSENT: MAAS rackd not running (not enrolled to a region)"; bad=1; fi
local ru; ru="$(grep -sh -m1 -E '^maas_url:' /var/snap/maas/current/rackd.conf 2>/dev/null | awk '{print $2}')"
[ -n "$ru" ] && echo "OK: enrolled to region $ru"
return "$bad"
}
# Dispatch check/precheck to the role-appropriate reporter.
# D-123 Model B node-host: report readiness of vvr1-dc0 as a nested libvirt host (--check).
node_host_check() {
local ok=0
echo "== node-host (Model B) readiness =="
have virsh && echo " [ok] libvirt/qemu installed" || { echo " [--] libvirt/qemu NOT installed"; ok=1; }
local kmod=""; [ -d /sys/module/kvm_amd ] && kmod=kvm_amd; [ -d /sys/module/kvm_intel ] && kmod=kvm_intel
if [ -n "$kmod" ] && grep -qiE '^(Y|1)$' "/sys/module/$kmod/parameters/nested" 2>/dev/null; then
echo " [ok] nested KVM ON ($kmod)"
else echo " [--] nested KVM OFF (kmod=${kmod:-none}) -- inner node VMs get no /dev/kvm"; ok=1; fi
id -nG "$HOST_USER" 2>/dev/null | grep -qw libvirt && echo " [ok] $HOST_USER in libvirt group" || { echo " [--] $HOST_USER NOT in libvirt group"; ok=1; }
[ -d "$INNER_POOL_PATH" ] && echo " [ok] inner pool dir $INNER_POOL_PATH" || { echo " [--] inner pool dir $INNER_POOL_PATH missing"; ok=1; }
[ -s "$OPNSENSE_BASE" ] && echo " [ok] opnsense base $OPNSENSE_BASE" || { echo " [--] opnsense base missing -- run: opnsense-prep-image.sh 26.1 $OPNSENSE_BASE"; ok=1; }
# SEC-010 mechanical pre-apply gate: the transit FORWARD-drop must be present (DC-LOCAL, D-052/D-100)
# AND must key on an interface that ACTUALLY EXISTS -- nftables oifname/iifname with an absent name
# loads clean but matches NOTHING (fail-open), which would silently leave DC-LOCAL unenforced.
if ! nft list table inet sec010 >/dev/null 2>&1; then
echo " [--] SEC-010 FORWARD-drop MISSING -- DC-LOCAL NOT enforced (metal-admin could route across the fiber)"; ok=1
elif ! ip link show "$TRANSIT_IF" >/dev/null 2>&1; then
echo " [--] SEC-010 rule keys on '$TRANSIT_IF' but NO such interface -- rule matches NOTHING (FAIL-OPEN); confirm the transit interface name (--transit-if)"; ok=1
else
echo " [ok] SEC-010 transit FORWARD-drop present + transit interface '$TRANSIT_IF' exists"
fi
return $ok
}
# D-123 Model B node-host: make this rack (vvr1-dc0) a nested libvirt/KVM host so the inner
# OpenTofu root (opentofu/vr1-dc0-substrate/, qemu+ssh from Office1, R-5) can build the fleet.
node_host_setup() {
echo "== node-host (D-123 Model B): make this rack a nested libvirt/KVM host for the inner tofu =="
run "install qemu-kvm + libvirt + qemu-utils" -- apt-get install -y qemu-kvm libvirt-daemon-system qemu-utils || exit 4
run "enable libvirtd" -- systemctl enable --now libvirtd
# nested KVM (persist + verify): inner guests need /dev/kvm, which needs the host module's
# nested=1 AND the outer tofu's expose_nested_virt=true on vvr1-dc0.
local kmod=""; [ -d /sys/module/kvm_amd ] && kmod=kvm_amd; [ -d /sys/module/kvm_intel ] && kmod=kvm_intel
[ -n "$kmod" ] || { echo "FAIL: no kvm_amd/kvm_intel -- is expose_nested_virt=true on vvr1-dc0? (outer tofu)" >&2; exit 4; }
run "persist nested=1 for $kmod" -- bash -c "echo 'options $kmod nested=1' > /etc/modprobe.d/kvm-nested.conf"
if ! grep -qiE '^(Y|1)$' "/sys/module/$kmod/parameters/nested" 2>/dev/null; then
run "reload $kmod for nested=1" -- bash -c "modprobe -r $kmod 2>/dev/null; modprobe $kmod" \
|| echo " WARN: could not reload $kmod (VMs running?) -- REBOOT vvr1-dc0 to apply nested=1"
fi
grep -qiE '^(Y|1)$' "/sys/module/$kmod/parameters/nested" 2>/dev/null \
&& echo " -> nested KVM ON ($kmod)" || echo " WARN: nested KVM still OFF -- reboot vvr1-dc0 (inner nova will not boot until ON)"
run "add $HOST_USER to libvirt group" -- usermod -aG libvirt "$HOST_USER"
# inner pool dir + AppArmor grant (platform-traps: a non-default libvirt pool path otherwise
# yields qemu 'Permission denied' define-then-fail on the inner domains -- a silent dead guest).
run "create inner pool dir $INNER_POOL_PATH" -- mkdir -p "$INNER_POOL_PATH"
run "AppArmor: allow inner pool path" -- bash -c "install -d /etc/apparmor.d/local/abstractions 2>/dev/null; grep -qF '$INNER_POOL_PATH' /etc/apparmor.d/local/abstractions/libvirt-qemu 2>/dev/null || echo ' \"$INNER_POOL_PATH/**\" rwk,' >> /etc/apparmor.d/local/abstractions/libvirt-qemu; systemctl reload apparmor 2>/dev/null || true"
# SEC-010 (D-052/D-100 DC-LOCAL under Model B): ip_forward is forced ON by the inner vr1-dc0-wan
# NAT, so a global ip_forward=0 is unusable. Enforce DC-local with a FORWARD-drop across the TRANSIT
# leg -- nothing routes ACROSS the fiber THROUGH vvr1-dc0 (the rack proxies MAAS at the app layer =
# originated/terminated, NOT forwarded). vvr1-dc0's own region traffic (OUTPUT/INPUT) is unaffected.
echo "== SEC-010 (DC-LOCAL): FORWARD-drop across the transit leg '$TRANSIT_IF' =="
run "install nftables" -- apt-get install -y nftables
if [ "$DRYRUN" = "1" ]; then
echo " [dry-run] write /etc/nftables-sec010.nft (FORWARD-drop in+out '$TRANSIT_IF') + enable sec010-fw.service (boot-persistent)"
else
cat > /etc/nftables-sec010.nft <<NFT
#!/usr/sbin/nft -f
table inet sec010 {
chain forward {
type filter hook forward priority -10; policy accept;
oifname "$TRANSIT_IF" drop
iifname "$TRANSIT_IF" drop
}
}
NFT
nft -f /etc/nftables-sec010.nft && echo " -> SEC-010 FORWARD-drop loaded (transit leg '$TRANSIT_IF')"
cat > /etc/systemd/system/sec010-fw.service <<UNIT
[Unit]
Description=SEC-010 DC-LOCAL forward-drop across the transit leg (D-052/D-100)
After=nftables.service network-pre.target
[Service]
Type=oneshot
ExecStart=/usr/sbin/nft -f /etc/nftables-sec010.nft
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload; systemctl enable sec010-fw.service >/dev/null 2>&1 && echo " -> sec010-fw.service enabled (boot-persistent)"
fi
# OPNsense base for the inner edge (opnsense-prep-image.sh needs network; run on this host).
# Verify, do NOT fetch inline (the prep script is not guaranteed present here).
if [ -s "$OPNSENSE_BASE" ]; then echo " -> opnsense base present: $OPNSENSE_BASE"
else echo " NOTE: opnsense base MISSING at $OPNSENSE_BASE -- run on this host BEFORE the inner apply:"; echo " bash scripts/opnsense-prep-image.sh 26.1 $OPNSENSE_BASE"; fi
echo " -> node-host ready. Inner apply next (from Office1): cd opentofu/vr1-dc0-substrate && tofu apply."
}
do_report() {
if [ "$ROLE" = "rack" ]; then
report_rack; local r=$?
if [ "$HOST_NODES" = "1" ]; then node_host_check || r=1; fi
return $r
else report; fi
}
report_st=0
if [ "$CHECK" = "1" ]; then do_report; exit $?; fi
do_report >/dev/null 2>&1; report_st=$?
[ "$report_st" = "0" ] && [ "$DRYRUN" = "0" ] && { echo "-- site headend already installed and configured --"; exit 0; }
have apt-get || { echo "FAIL: Debian/Ubuntu (apt) only." >&2; exit 3; }
# root is required to MUTATE, not to plan: --dry-run stays runnable by anyone (and by the harness).
[ "$DRYRUN" = "1" ] || [ "$(id -u)" = "0" ] || { echo "FAIL: must run as root (use: ssh <host> 'sudo bash -s' -- ... < $0)" >&2; exit 4; }
# =====================================================================================
# RACK ROLE (D-123): enroll a DC rack controller to Office1's EXISTING region. ONLY these
# steps run -- and NONE of the region+rack work below (no PostgreSQL, no `maas init
# region+rack`, no createadmin/apikey/login, no LXD, no LXD vm-host registration, no
# compose-network DHCP). Rationale: a rack runs no DB, composes nothing (the DC's OpenStack
# nodes are OpenTofu-created + discovered via the vcloud virsh vm-host registered to the
# REGION, not composed here), and DHCP-on-metal-admin is a REGION-side op (enabled against
# Office1's MAAS naming this rack as primary_rack -- a runbook step, NOT this script). So
# traps 1-3 (LXD/dnsmasq) do not apply, and trap 4's spirit holds: rack mode touches NO DHCP.
# =====================================================================================
if [ "$ROLE" = "rack" ]; then
echo "== 1. time: MAAS manages time via chrony; systemd-timesyncd conflicts =="
run "disable systemd-timesyncd" -- systemctl disable --now systemd-timesyncd >/dev/null 2>&1
echo "== 2. MAAS $MAAS_CHANNEL (rack: NO PostgreSQL -- the rack runs no DB) =="
snap list maas >/dev/null 2>&1 || run "install maas" -- snap install maas --channel="$MAAS_CHANNEL" || exit 4
echo "== 3. maas init rack -- enroll to the region at $REGION_URL =="
# `maas init rack --maas-url <URL> --secret <SECRET>` is the MAAS snap rack-registration
# invocation -- CONFIRMED, not invented: this form is documented across the MAAS snap docs and
# discourse and is stable across 3.x (so it holds for our 3.7/stable pin), and the region's
# shared secret lives at /var/snap/maas/common/maas/secret on the region host (matches the
# runbook). The secret is READ from the file at
# execution time and NEVER echoed -- so this step is hand-rolled rather than routed through
# run() (which would print the expanded command, leaking the secret in --dry-run).
if [ "$DRYRUN" = "0" ]; then
[ -s "$ENROLL_SECRET_FILE" ] || { echo "FAIL: --enroll-secret-file '$ENROLL_SECRET_FILE' is absent or empty (need the region's enrollment secret)" >&2; exit 4; }
if maas status 2>/dev/null | grep -q rackd; then
echo " -> already enrolled (rackd running)"
else
maas init rack --maas-url "$REGION_URL" --secret "$(cat "$ENROLL_SECRET_FILE")" >/dev/null 2>&1 \
|| { echo "FAIL: maas init rack" >&2; exit 4; }
echo " -> rack registered to region $REGION_URL"
fi
else
echo " [dry-run] maas init rack --maas-url $REGION_URL --secret <read from $ENROLL_SECRET_FILE at run time; never printed>"
fi
# SKIPPED IN RACK MODE (region concerns): PostgreSQL, maas init region+rack,
# createadmin/apikey/login, LXD (5.21 pin), LXD vm-host registration, compose-network DHCP.
# metal-admin DHCP (this rack as primary_rack) is enabled REGION-side as a runbook step.
# D-123 MODEL B: if this DC rack is ALSO the node host, set up nested libvirt (respects --dry-run).
[ "$HOST_NODES" = "1" ] && node_host_setup
if [ "$DRYRUN" = "1" ]; then echo " (dry-run: no changes made)"; exit 0; fi
echo
echo "RACK ENROLLED to $REGION_URL. Next (REGION-side runbook, NOT this script): enable"
echo "DHCP on metal-admin against Office1's MAAS, naming this host as primary_rack."
exit 0
fi
# ---- region+rack role (default; unchanged legacy behavior) ----
[ -n "$MAAS_IP" ] || MAAS_IP="$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{print $7; exit}')"
[ -n "$MAAS_IP" ] || { echo "FAIL: could not measure this host's outbound IP; pass --maas-url-ip" >&2; exit 4; }
install -d -m 0700 "$SECRETS"
newpass() { # newpass <file> -- generate once, 0600, NEVER echoed
[ -s "$1" ] && return 0
openssl rand -base64 30 | tr -dc 'A-Za-z0-9' | head -c 28 > "$1"; chmod 0600 "$1"
}
echo "== 1. time: MAAS manages time via chrony; systemd-timesyncd conflicts =="
run "disable systemd-timesyncd" -- systemctl disable --now systemd-timesyncd >/dev/null 2>&1
echo "== 2. MAAS $MAAS_CHANNEL + PostgreSQL $PG_CHANNEL =="
snap list maas >/dev/null 2>&1 || run "install maas" -- snap install maas --channel="$MAAS_CHANNEL" || exit 4
snap list postgresql >/dev/null 2>&1 || run "install postgresql" -- snap install postgresql --channel="$PG_CHANNEL" || exit 4
echo "== 3. MAAS database (password generated here, 0600, never printed) =="
if [ "$DRYRUN" = "0" ]; then
newpass "$SECRETS/db.pass"; DBPASS="$(cat "$SECRETS/db.pass")"
postgresql.psql -U postgres -h /tmp -tAc "SELECT 1 FROM pg_roles WHERE rolname='maas'" 2>/dev/null | grep -q 1 \
|| postgresql.psql -U postgres -h /tmp -c "CREATE ROLE maas LOGIN PASSWORD '$DBPASS'" >/dev/null 2>&1
postgresql.psql -U postgres -h /tmp -tAc "SELECT 1 FROM pg_database WHERE datname='maas'" 2>/dev/null | grep -q 1 \
|| postgresql.createdb -U postgres -h /tmp -O maas maas >/dev/null 2>&1
echo " -> role + database 'maas' present"
else
echo " [dry-run] create role+db 'maas' with a generated password"
fi
echo "== 4. maas init region+rack =="
if [ "$DRYRUN" = "0" ]; then
if maas status 2>/dev/null | grep -q regiond; then
echo " -> already initialised"
else
maas init region+rack --maas-url "http://${MAAS_IP}:5240/MAAS" \
--database-uri "postgres://maas:$(cat "$SECRETS/db.pass")@localhost/maas" --force >/dev/null 2>&1 \
|| { echo "FAIL: maas init" >&2; exit 4; }
echo " -> initialised"
fi
newpass "$SECRETS/admin.pass"
maas apikey --username admin >/dev/null 2>&1 || \
maas createadmin --username admin --password "$(cat "$SECRETS/admin.pass")" --email "admin@$(hostname)" >/dev/null 2>&1
maas apikey --username admin > "$SECRETS/admin.apikey" 2>/dev/null; chmod 0600 "$SECRETS/admin.apikey"
for i in $(seq 1 60); do curl -sf -o /dev/null --max-time 3 "http://${MAAS_IP}:5240/MAAS/" && break; sleep 5; done
maas login admin "http://${MAAS_IP}:5240/MAAS" "$(cat "$SECRETS/admin.apikey")" >/dev/null 2>&1
echo " -> admin ready, CLI logged in"
else
echo " [dry-run] maas init region+rack + createadmin + login"
fi
echo "== 5. LXD, DIRECTLY onto $LXD_CHANNEL (trap 3: track changes are ONE-WAY) =="
if snap list lxd >/dev/null 2>&1; then
tr="$(snap list lxd 2>/dev/null | awk 'NR>1{print $4}')"
[ "$tr" = "$LXD_CHANNEL" ] || { echo "FAIL: lxd already on track '$tr'; you CANNOT downgrade a snap track. Remove it (snap remove lxd) and re-run." >&2; exit 4; }
else
run "install lxd" -- snap install lxd --channel="$LXD_CHANNEL" || exit 4
run "hold lxd auto-refresh" -- snap refresh --hold lxd >/dev/null 2>&1
fi
[ "$DRYRUN" = "0" ] && lxd waitready --timeout=90
echo "== 6. LXD storage + compose bridge (built explicitly; see trap 1 -- the auto-init path FAILS here) =="
if [ "$DRYRUN" = "0" ]; then
# dir driver: conservative inside an already-nested guest (no zfs loop file).
lxc storage show default </dev/null >/dev/null 2>&1 || lxc storage create default dir </dev/null >/dev/null 2>&1
if ! lxc network show lxdbr0 </dev/null >/dev/null 2>&1; then
lxc network create lxdbr0 \
ipv4.address="${BRIDGE_IP}/24" ipv4.nat=true ipv4.dhcp=false \
ipv6.address=none dns.mode=none raw.dnsmasq="port=0" </dev/null >/dev/null 2>&1 \
|| { echo "FAIL: could not create lxdbr0 (see trap 1)" >&2; exit 4; }
fi
lxc profile device remove default eth0 </dev/null >/dev/null 2>&1
lxc profile device add default root disk path=/ pool=default </dev/null >/dev/null 2>&1
lxc profile device add default eth0 nic network=lxdbr0 name=eth0 </dev/null >/dev/null 2>&1
lxc config set core.https_address '[::]:8443' </dev/null
newpass "$SECRETS/lxd-trust.pass"
lxc config set core.trust_password "$(cat "$SECRETS/lxd-trust.pass")" </dev/null
echo " -> lxdbr0 ${BRIDGE_IP}/24, NAT on, LXD DHCP/DNS OFF; API on :8443"
n53=$(ss -lunp 2>/dev/null | grep dnsmasq | grep -c ':53 ')
n67=$(ss -lunp 2>/dev/null | grep dnsmasq | grep -c ':67 ')
[ "${n53:-0}" = "0" ] && [ "${n67:-0}" = "0" ] \
|| { echo "FAIL: dnsmasq is holding :53=$n53 :67=$n67 -- it will fight MAAS/Kea (trap 1)" >&2; exit 4; }
echo " -> VERIFIED: dnsmasq binds no :53 and no :67"
else
echo " [dry-run] create dir pool + lxdbr0 ${BRIDGE_IP}/24 (dhcp off, dns off, raw.dnsmasq=port=0), expose API"
fi
echo "== 7. register LXD into MAAS as a VM host =="
if [ "$DRYRUN" = "0" ]; then
if maas admin vm-hosts read 2>/dev/null | grep -q '"type": "lxd"'; then
echo " -> LXD vm-host already registered"
else
maas admin vm-hosts create type=lxd name="$(hostname)-lxd" \
power_address="https://${MAAS_IP}:8443" project=default \
password="$(cat "$SECRETS/lxd-trust.pass")" >/dev/null 2>&1 \
&& echo " -> registered as $(hostname)-lxd" || { echo "FAIL: vm-hosts create" >&2; exit 4; }
fi
else
echo " [dry-run] maas admin vm-hosts create type=lxd power_address=https://${MAAS_IP}:8443"
fi
echo "== 8. MAAS DHCP on the COMPOSE network ONLY (trap 4) =="
if [ "$DRYRUN" = "0" ]; then
SUB_JSON="$(maas admin subnets read 2>/dev/null)"
SUB_ID="$(printf '%s' "$SUB_JSON" | python3 -c "
import sys,json
t=sys.argv[1]
for s in json.load(sys.stdin):
if s.get('cidr')==t: print(s['id']); break
" "$COMPOSE_CIDR")"
[ -n "$SUB_ID" ] || { echo "FAIL: MAAS has not discovered $COMPOSE_CIDR (is lxdbr0 up?)" >&2; exit 4; }
FAB_ID="$(printf '%s' "$SUB_JSON" | python3 -c "
import sys,json
t=sys.argv[1]
for s in json.load(sys.stdin):
if s.get('cidr')==t: print(s['vlan']['fabric_id']); break
" "$COMPOSE_CIDR")"
RACK="$(maas admin rack-controllers read 2>/dev/null | python3 -c "import sys,json;print(json.load(sys.stdin)[0]['system_id'])")"
# GATEWAY: MAAS auto-discovers the compose subnet from lxdbr0's interface, and that discovery
# carries NO gateway. Without this, a DEPLOYED machine gets a netplan with NO DEFAULT ROUTE --
# it comes up looking fine (DNS even resolves, because MAAS's bind9 is link-local) but has NO
# EGRESS, so the first `apt install` on it hangs. lxdbr0 IS the gateway (it does the NAT).
# Measured 2026-07-13: office1-netbox deployed with gateway_ip=None and had to be redeployed.
maas admin subnet update "$SUB_ID" gateway_ip="$BRIDGE_IP" >/dev/null 2>&1 \
&& echo " -> gateway_ip=$BRIDGE_IP set on $COMPOSE_CIDR"
maas admin ipranges create type=dynamic subnet="$SUB_ID" start_ip="$RANGE_LO" end_ip="$RANGE_HI" \
comment="MAAS DHCP/PXE for LXD-composed VMs (D-114)" >/dev/null 2>&1
maas admin vlan update "$FAB_ID" 0 dhcp_on=True primary_rack="$RACK" >/dev/null 2>&1 \
&& echo " -> DHCP ON for $COMPOSE_CIDR ($RANGE_LO-$RANGE_HI), rack=$RACK"
# HARD GUARD: assert MAAS did not turn DHCP on for any OTHER subnet.
BADSUB="$(maas admin subnets read 2>/dev/null | python3 -c "
import sys,json
t=sys.argv[1]
bad=[s['cidr'] for s in json.load(sys.stdin) if s['vlan'].get('dhcp_on') and s.get('cidr')!=t]
print(','.join(bad))
" "$COMPOSE_CIDR")"
if [ -n "$BADSUB" ]; then
echo "FAIL: MAAS DHCP is ON for a subnet that is NOT the compose network: $BADSUB" >&2
echo " The site LAN's DHCP belongs to the edge's Kea. Turn this OFF before proceeding." >&2
exit 4
fi
echo " -> VERIFIED: MAAS DHCP is on for $COMPOSE_CIDR and NO other subnet"
else
echo " [dry-run] iprange $RANGE_LO-$RANGE_HI + dhcp_on for $COMPOSE_CIDR only"
fi
[ "$DRYRUN" = "1" ] && { echo " (dry-run: no changes made)"; exit 0; }
echo
echo "INSTALLED. MAAS UI: http://${MAAS_IP}:5240/MAAS (admin password: $SECRETS/admin.pass, 0600)"
echo "Compose a service VM with:"
echo " maas admin vm-host compose <id> cores=2 memory=4096 storage=root:40 hostname=<name>"
exit 0