Newer
Older
openstack-caracal-dc-dc / scripts / opnsense-set-interface-v6.sh
#!/usr/bin/env bash
# scripts/opnsense-set-interface-v6.sh [--commit] <edge-host> <interface> <address> <prefixlen>
#
#   e.g. OPNSENSE_SSH_KEY=~/.ssh/office1-edge \
#        bash scripts/opnsense-set-interface-v6.sh --commit 10.10.0.1 lan 2602:f3e2:f01:100::1 64
#
# Sets a STATIC IPv6 address on an OPNsense base interface and applies it, then
# proves the address is actually on the wire.
#
# WHY IT IS NOT THE REST API (D-113 AMENDMENT, 2026-07-14). Measured on the live
# Office1 edge (OPNsense 26.1): Interfaces > [LAN] is served by the LEGACY page
# /interfaces.php?if=lan. Only the Devices and Neighbors sub-trees were migrated to
# MVC. There is NO REST endpoint for a base interface's ipaddrv6. D-113(a2)'s claim
# that "interfaces" are API-covered was inferred, never measured, and is false.
#
# WHY IT IS NOT THE FORBIDDEN config.xml PUSH. It never authors a config. It ships
# scripts/opnsense-set-iface-v6.php, which loads the edge's LIVE config through
# OPNsense's own Config singleton, mutates two leaf fields, asserts the interface
# count is unchanged, and lets the vendor's own code serialize it -- the identical
# code path interfaces.php runs on Save. Nothing is replaced wholesale, so the
# API-managed Kea DHCP and the firewall rules cannot be clobbered. Same principle as
# opnsense-bootstrap-apikey.sh: call the vendor's interface, do not re-implement it.
#
# REUSABLE BY THE DC EDGES. Stage 3 builds DC0's and DC1's edges from the same
# module; this script takes them verbatim, only the host/address change.
#
# DRY BY DEFAULT -- without --commit, nothing is written and nothing is applied.
#
# root's shell on the edge is TCSH, not sh. A $(...) inside a quoted remote command
# dies with "Illegal variable name", and it dies QUIETLY. Every remote command here
# is therefore fed to `sh -s` over stdin, never interpolated into a tcsh -c string.
#
# Exit: 0 ok (or dry run) | 1 usage/precondition | 2 remote set/apply/verify failed.
set -euo pipefail

HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SETTER="$HERE/opnsense-set-iface-v6.php"

COMMIT=0
if [ "${1:-}" = "--commit" ]; then COMMIT=1; shift; fi

EDGE="${1:-}"
IFNAME="${2:-}"
ADDR="${3:-}"
PLEN="${4:-}"

usage() {
  echo "usage: opnsense-set-interface-v6.sh [--commit] <edge-host> <interface> <address> <prefixlen>" >&2
  echo "  e.g. opnsense-set-interface-v6.sh --commit 10.10.0.1 lan 2602:f3e2:f01:100::1 64" >&2
  echo "  env: OPNSENSE_SSH_KEY (required)" >&2
  echo "  default: DRY RUN -- prints the delta, writes nothing." >&2
  exit 1
}

[ -n "$EDGE" ] && [ -n "$IFNAME" ] && [ -n "$ADDR" ] && [ -n "$PLEN" ] || usage
[ -f "$SETTER" ] || { echo "FAIL: setter not found: $SETTER" >&2; exit 1; }

# Validate locally too. The PHP validates as well -- but a bad value should never
# even reach the live router.
case "$ADDR" in *:*) : ;; *) echo "FAIL: '$ADDR' is not an IPv6 address" >&2; exit 1 ;; esac
case "$PLEN" in ''|*[!0-9]*) echo "FAIL: '$PLEN' is not a prefix length" >&2; exit 1 ;; esac
[ "$PLEN" -ge 1 ] && [ "$PLEN" -le 128 ] || { echo "FAIL: prefix length '$PLEN' out of range 1-128" >&2; exit 1; }

[ -n "${OPNSENSE_SSH_KEY:-}" ] || { echo "FAIL: \$OPNSENSE_SSH_KEY not set" >&2; exit 1; }

SSH=(ssh -i "$OPNSENSE_SSH_KEY" -o BatchMode=yes -o ConnectTimeout=15)
SCP=(scp -i "$OPNSENSE_SSH_KEY" -o BatchMode=yes -o ConnectTimeout=15 -q)

REMOTE_PHP="/tmp/opnsense-set-iface-v6.$$.php"
cleanup() { "${SSH[@]}" "root@${EDGE}" "rm -f '$REMOTE_PHP'" >/dev/null 2>&1 || true; }
trap cleanup EXIT

"${SCP[@]}" "$SETTER" "root@${EDGE}:${REMOTE_PHP}" \
  || { echo "FAIL: could not ship the setter to $EDGE" >&2; exit 2; }

echo "=== BEFORE (the edge's own view) ==="
"${SSH[@]}" "root@${EDGE}" sh -s <<EOF || { echo "FAIL: could not read the edge" >&2; exit 2; }
ifconfig $(printf '%s' "$IFNAME" | sed 's/lan/vtnet0/; s/wan/vtnet1/') inet6 2>/dev/null | grep inet6 || echo "  (no inet6 on the interface)"
EOF

# load_phalcon.php is resolved RELATIVE to /usr/local/opnsense/mvc -- must cd there.
PHPARGS=""
[ "$COMMIT" = "1" ] && PHPARGS="--commit"

echo
echo "=== SET (via OPNsense's own config model) ==="
"${SSH[@]}" "root@${EDGE}" sh -s <<EOF || { echo "FAIL: the setter failed on $EDGE" >&2; exit 2; }
cd /usr/local/opnsense/mvc && php '$REMOTE_PHP' $PHPARGS '$IFNAME' '$ADDR' '$PLEN'
EOF

if [ "$COMMIT" != "1" ]; then
  echo
  echo "DRY RUN -- nothing written, nothing applied. Re-run with --commit."
  exit 0
fi

echo
echo "=== APPLY (configctl -- saving config does NOT reconfigure the kernel) ==="
"${SSH[@]}" "root@${EDGE}" sh -s <<EOF || { echo "FAIL: configctl reconfigure failed" >&2; exit 2; }
configctl interface reconfigure $IFNAME
EOF

echo
echo "=== AFTER: GROUND TRUTH (the kernel, not the config file) ==="
DEV="$(printf '%s' "$IFNAME" | sed 's/lan/vtnet0/; s/wan/vtnet1/')"
OUT="$("${SSH[@]}" "root@${EDGE}" sh -s <<EOF
ifconfig $DEV inet6 2>/dev/null | grep inet6 || true
EOF
)"
printf '%s\n' "$OUT"

# The address must actually be ON THE INTERFACE. `{"result":"saved"}`-style
# self-reports are not evidence; the kernel is.
if printf '%s' "$OUT" | grep -qi "$(printf '%s' "$ADDR" | tr 'A-Z' 'a-z')"; then
  echo
  echo "OK: $ADDR/$PLEN is live on $IFNAME ($DEV)."
  echo "    Next: Router Advertisements -- that half IS API-native:"
  echo "      bash scripts/opnsense-api.sh POST /api/radvd/settings/addEntry ..."
  exit 0
fi

echo
echo "FAIL: $ADDR is NOT on $DEV after reconfigure. Config saved but not in effect." >&2
exit 2