#!/usr/bin/env bash
# scripts/site-headend-install.sh [--check|--dry-run] --compose-cidr <CIDR> [--maas-url-ip <IP>]
#
# Installs a VR1 SITE HEADEND on the host it runs on: 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, and it is VR0's proven `lxd` + `tailscale`
# pattern applied per site.
#
# RUN IT ON THE SITE HOST (voffice1 today; vdc1/vdc2 later), as root:
# ssh <site-host> 'sudo bash -s' -- --compose-cidr 10.10.1.0/24 < 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 | 3 unsupported OS | 4 install failed. 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.
# =====================================================================================
set -uo pipefail
CHECK=0; DRYRUN=0; COMPOSE_CIDR=""; MAAS_IP=""
while [ $# -gt 0 ]; do
case "$1" in
--check) CHECK=1 ;;
--dry-run) DRYRUN=1 ;;
--compose-cidr) shift; COMPOSE_CIDR="${1:-}" ;;
--maas-url-ip) shift; MAAS_IP="${1:-}" ;;
-h|--help)
echo "usage: site-headend-install.sh [--check|--dry-run] --compose-cidr <CIDR> [--maas-url-ip <IP>]"
exit 0 ;;
*) echo "FAIL: unknown arg '$1'" >&2; exit 2 ;;
esac
shift
done
[ -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"
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"
}
report_st=0
if [ "$CHECK" = "1" ]; then report; exit $?; fi
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; }
[ -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