Newer
Older
openstack-caracal-dc-dc / scripts / opnsense-render-config.sh
#!/usr/bin/env bash
# scripts/opnsense-render-config.sh <output-config.xml>
#
# Renders opentofu/templates/opnsense-config.xml.tmpl into a real config.xml
# by substituting {{TOKEN}} markers (this repo's existing clientdocs
# convention, reused here) from required environment variables -- no
# invented defaults except NTP_UPSTREAM_SERVERS/NTP_PREFER_SERVER, which
# default to the SAME public pool OPNsense's own shipped config.xml.sample
# uses (a real, confirmed default, not an invention -- see
# opentofu/templates/README.md). Everything else fails loud if unset.
#
# Feed the result to scripts/opnsense-build-config-iso.sh next.
# Exit: 0 rendered | 1 required token/template missing.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TEMPLATE="$HERE/../opentofu/templates/opnsense-config.xml.tmpl"
OUT="${1:?usage: opnsense-render-config.sh <output-config.xml>}"

: "${NTP_UPSTREAM_SERVERS:=0.opnsense.pool.ntp.org 1.opnsense.pool.ntp.org 2.opnsense.pool.ntp.org 3.opnsense.pool.ntp.org}"
: "${NTP_PREFER_SERVER:=0.opnsense.pool.ntp.org}"

req() { # req VARNAME -- fail loud if unset, no inferred fallback
  local name="$1"
  [ -n "${!name:-}" ] || { echo "FAIL: \$$name not set -- see opentofu/templates/README.md"; exit 1; }
}

REQUIRED_VARS=(
  OPNSENSE_HOSTNAME DOMAIN ROOT_PASSWORD_HASH
  WAN_IF WAN_IPADDR WAN_SUBNET_BITS WAN_GATEWAY
  LAN_IF LAN_IPADDR LAN_SUBNET_BITS
  ROOT_AUTHORIZED_KEYS
  DHCP_POOL_START DHCP_POOL_END
)
# ROOT_AUTHORIZED_KEYS: the PUBLIC key text (e.g. the contents of an id_ed25519.pub).
# REQUIRED, no default -- D-112(c) makes SSH the management path for the edge, so an edge
# rendered without a key is a lockout waiting to happen. PUBLIC key material only; the
# private key is never read by this repo.
#
# MIRROR_SYNC_PROTOCOL/MIRROR_UPSTREAM_NET/MIRROR_SYNC_PORT removed (DOCFIX-185):
# the edge is a normal simulated-ISP router, not an egress-airgap firewall -- the
# WAN mirror/NTP egress-control rules that used them are gone from the template.
for v in "${REQUIRED_VARS[@]}"; do
  req "$v"
done

# Base64 for <authorizedkeys>, which OPNsense base64_decode()s into ~/.ssh/authorized_keys
# (verified upstream, src/etc/inc/auth.inc). Computed AFTER req() so a missing key fails with
# the friendly message rather than a `set -u` unbound-variable crash.
ROOT_AUTHORIZED_KEYS_B64="$(printf '%s\n' "$ROOT_AUTHORIZED_KEYS" | base64 -w0)"

# --- LAN DHCP (Kea) -------------------------------------------------------------------
# Derive the LAN network CIDR and a DETERMINISTIC subnet4 uuid, and VALIDATE that the pool
# actually lies inside the LAN subnet (a pool outside its subnet is silently accepted by the
# XML but serves nobody). Deterministic uuid5 => re-rendering is idempotent and does not churn
# the config. Fails loud on any inconsistency -- no inferred values.
_dhcp="$(python3 - "$LAN_IPADDR" "$LAN_SUBNET_BITS" "$DHCP_POOL_START" "$DHCP_POOL_END" <<'PY'
import sys, ipaddress, uuid
ip, bits, lo, hi = sys.argv[1], int(sys.argv[2]), sys.argv[3], sys.argv[4]
net = ipaddress.ip_network(f"{ip}/{bits}", strict=False)
a, b = ipaddress.ip_address(lo), ipaddress.ip_address(hi)
for label, addr in (("DHCP_POOL_START", a), ("DHCP_POOL_END", b)):
    if addr not in net:
        sys.exit(f"FAIL: ${label} {addr} is not inside the LAN subnet {net}")
if a > b:
    sys.exit(f"FAIL: $DHCP_POOL_START {a} is above $DHCP_POOL_END {b}")
if ipaddress.ip_address(ip) in list(ipaddress.summarize_address_range(a, b))[0]:
    sys.exit(f"FAIL: the LAN address {ip} falls inside the DHCP pool {a}-{b} (it must not)")
print(str(net))
print(f"{a}-{b}")
print(uuid.uuid5(uuid.NAMESPACE_DNS, f"opnsense-kea-dhcp4-{net}"))
PY
)" || { echo "$_dhcp"; exit 1; }
LAN_NETWORK_CIDR="$(sed -n 1p <<<"$_dhcp")"
DHCP_POOL="$(sed -n 2p <<<"$_dhcp")"
DHCP_SUBNET_UUID="$(sed -n 3p <<<"$_dhcp")"

[ -r "$TEMPLATE" ] || { echo "FAIL: template not found: $TEMPLATE"; exit 1; }

route_block() { # route_block <network-var-name> <gateway-var-name> <descr-var-name>
  local net="${!1:-}" gw="${!2:-}" descr="${!3:-}"
  if [ -z "$net" ]; then
    return 0 # no route needed at this site -- emit nothing, not a placeholder
  fi
  [ -n "$gw" ] || { echo "FAIL: \$$1 is set but \$$2 is not"; exit 1; }
  printf '    <route>\n      <network>%s</network>\n      <gateway>%s</gateway>\n      <descr>%s</descr>\n      <enabled>1</enabled>\n    </route>\n' \
    "$net" "$gw" "${descr:-static route}"
}

ROUTE1_BLOCK="$(route_block ROUTE1_NETWORK ROUTE1_GATEWAY ROUTE1_DESCR)"
ROUTE2_BLOCK="$(route_block ROUTE2_NETWORK ROUTE2_GATEWAY ROUTE2_DESCR)"

content="$(cat "$TEMPLATE")"
for v in "${REQUIRED_VARS[@]}" NTP_UPSTREAM_SERVERS NTP_PREFER_SERVER \
         ROOT_AUTHORIZED_KEYS_B64 LAN_NETWORK_CIDR DHCP_POOL DHCP_SUBNET_UUID; do
  content="${content//"{{$v}}"/${!v}}"
done
content="${content//"{{ROUTE1_BLOCK}}"/$ROUTE1_BLOCK}"
content="${content//"{{ROUTE2_BLOCK}}"/$ROUTE2_BLOCK}"

if grep -qE '\{\{[A-Z0-9_]+\}\}' <<<"$content"; then
  echo "FAIL: unresolved {{TOKEN}} remains in rendered output -- template/script token lists are out of sync:"
  grep -oE '\{\{[A-Z0-9_]+\}\}' <<<"$content" | sort -u
  exit 1
fi

printf '%s' "$content" > "$OUT"
echo "PASS: rendered $OUT"