diff --git a/scripts/opnsense-set-iface-v6.php b/scripts/opnsense-set-iface-v6.php new file mode 100644 index 0000000..dd92e8b --- /dev/null +++ b/scripts/opnsense-set-iface-v6.php @@ -0,0 +1,146 @@ +
+ * + * e.g. opnsense-set-iface-v6.php --commit lan 2602:f3e2:f01:100::1 64 + * + * Sets a STATIC IPv6 address on an OPNsense base interface, using OPNSENSE'S OWN + * CONFIG OBJECT -- the same object the GUI's Interfaces > [LAN] page mutates when + * you press Save. + * + * WHY THIS EXISTS (D-113 AMENDMENT, 2026-07-14): D-113(a2)'s text claims + * "DHCP, firewall, interfaces: the REST API". The `interfaces` half is FALSE and + * was never measured. On OPNsense 26.1, Interfaces > [LAN] is served by the LEGACY + * page /interfaces.php?if=lan -- only the Devices and Neighbors sub-trees were ever + * migrated to MVC. There is NO REST endpoint that sets a base interface's ipaddrv6. + * Measured on the live Office1 edge, not inferred. + * + * WHY THIS IS NOT THE FORBIDDEN config.xml PUSH. The thing D-113 banned is a + * HAND-AUTHORED / RENDERED config.xml pushed wholesale over the live one -- the + * clobber path that would have wiped the API-managed Kea DHCP (667 migration- + * populated elements, 8 firewall rules). This does the OPPOSITE: + * + * - it never AUTHORS a config. It LOADS the edge's live config through the + * vendor's own Config singleton, mutates exactly TWO leaf fields, and lets + * the vendor's own code serialize it back. + * - it is byte-for-byte the code path interfaces.php runs on Save. + * - it asserts, before and after, that the interface COUNT is unchanged -- so a + * silent structural drop cannot pass unnoticed. + * + * Same principle as scripts/opnsense-mint-apikey.php, which mints an API key by + * calling Auth\User->apikeys->add() rather than re-implementing OPNsense's $6$ + * crypt format: CALL THE VENDOR'S INTERFACE, DO NOT RE-IMPLEMENT THE INTERNALS. + * + * DRY BY DEFAULT. Without --commit it prints the current -> desired delta and + * writes NOTHING. (D-117's lesson: a write-by-default IPAM/config tool is how an + * off-by-one lands in production unannounced.) + * + * This script does NOT apply the change to the running kernel -- saving config and + * reconfiguring the interface are separate steps, deliberately. The caller + * (scripts/opnsense-set-interface-v6.sh) runs `configctl interface reconfigure`. + * + * Run from /usr/local/opnsense/mvc (load_phalcon.php is resolved relative to it). + * + * Exit: 0 ok (or dry run) | 1 usage/validation error | 2 save failed. + */ +require_once('script/load_phalcon.php'); + +use OPNsense\Core\Config; + +$argvv = $argv; +array_shift($argvv); +$commit = false; +if (isset($argvv[0]) && $argvv[0] === '--commit') { + $commit = true; + array_shift($argvv); +} + +if (count($argvv) < 3) { + fwrite(STDERR, "usage: opnsense-set-iface-v6.php [--commit]
\n"); + fwrite(STDERR, " e.g. opnsense-set-iface-v6.php --commit lan 2602:f3e2:f01:100::1 64\n"); + exit(1); +} +list($ifname, $address, $prefixlen) = $argvv; + +/* ---- validate BEFORE touching the config. Never write an unvalidated value. ---- */ +if (filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) === false) { + fwrite(STDERR, "FAIL: '{$address}' is not a valid IPv6 address\n"); + exit(1); +} +if (!ctype_digit((string)$prefixlen) || (int)$prefixlen < 1 || (int)$prefixlen > 128) { + fwrite(STDERR, "FAIL: '{$prefixlen}' is not a valid IPv6 prefix length (1-128)\n"); + exit(1); +} +$prefixlen = (string)(int)$prefixlen; + +Config::getInstance()->lock(); +$cfg = Config::getInstance()->object(); + +if (!isset($cfg->interfaces)) { + fwrite(STDERR, "FAIL: config has no node -- refusing to guess\n"); + exit(1); +} +if (!isset($cfg->interfaces->$ifname)) { + $known = array(); + foreach ($cfg->interfaces->children() as $k => $v) { + $known[] = $k; + } + fwrite(STDERR, "FAIL: no such interface '{$ifname}'. Known: " . implode(', ', $known) . "\n"); + exit(1); +} + +/* Structural guard: the interface COUNT must not change. If serializing the + * vendor's own object ever drops a node, that is a silent corruption of a live + * router and it must fail loud, not be discovered later by a ping that stopped. */ +$iface_count_before = count($cfg->interfaces->children()); + +$node = $cfg->interfaces->$ifname; +$cur_ip = isset($node->ipaddrv6) ? (string)$node->ipaddrv6 : ''; +$cur_len = isset($node->subnetv6) ? (string)$node->subnetv6 : ''; + +printf("interface : %s (%s)\n", $ifname, isset($node->descr) ? (string)$node->descr : ''); +printf(" ipaddrv6 : %s -> %s\n", $cur_ip === '' ? '(none)' : $cur_ip, $address); +printf(" subnetv6 : %s -> %s\n", $cur_len === '' ? '(none)' : $cur_len, $prefixlen); +printf(" ipaddr (v4) : %s [UNTOUCHED]\n", isset($node->ipaddr) ? (string)$node->ipaddr : '(none)'); +printf(" interfaces in config: %d\n", $iface_count_before); + +if ($cur_ip === $address && $cur_len === $prefixlen) { + print("\nNO CHANGE -- already set to the desired value. Nothing to do.\n"); + exit(0); +} + +if (!$commit) { + print("\nDRY RUN -- nothing was written. Re-run with --commit to apply.\n"); + exit(0); +} + +$node->ipaddrv6 = $address; +$node->subnetv6 = $prefixlen; + +$iface_count_after = count($cfg->interfaces->children()); +if ($iface_count_after !== $iface_count_before) { + fwrite(STDERR, "FAIL: interface count changed {$iface_count_before} -> {$iface_count_after}. NOT SAVING.\n"); + exit(2); +} + +Config::getInstance()->save(); + +/* Read back from a FRESH load -- the service's own verdict, not our in-memory copy. */ +Config::getInstance()->forceReload(); +$verify = Config::getInstance()->object(); +$got_ip = (string)$verify->interfaces->$ifname->ipaddrv6; +$got_len = (string)$verify->interfaces->$ifname->subnetv6; +$got_cnt = count($verify->interfaces->children()); + +if ($got_ip !== $address || $got_len !== $prefixlen) { + fwrite(STDERR, "FAIL: read-back mismatch -- got {$got_ip}/{$got_len}, wanted {$address}/{$prefixlen}\n"); + exit(2); +} +if ($got_cnt !== $iface_count_before) { + fwrite(STDERR, "FAIL: read-back interface count {$got_cnt} != {$iface_count_before} -- CONFIG DAMAGED\n"); + exit(2); +} + +printf("\nOK: saved and read back -- %s = %s/%s (%d interfaces intact)\n", $ifname, $got_ip, $got_len, $got_cnt); +print("NOTE: config is saved but NOT yet applied to the running kernel.\n"); +print(" The caller must run: configctl interface reconfigure {$ifname}\n"); diff --git a/scripts/opnsense-set-interface-v6.sh b/scripts/opnsense-set-interface-v6.sh new file mode 100755 index 0000000..6e8383b --- /dev/null +++ b/scripts/opnsense-set-interface-v6.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# scripts/opnsense-set-interface-v6.sh [--commit]
+# +# 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]
" >&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 <&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 <&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 <&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 </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 diff --git a/tests/opnsense-set-interface-v6/run-tests.sh b/tests/opnsense-set-interface-v6/run-tests.sh new file mode 100755 index 0000000..cb44de6 --- /dev/null +++ b/tests/opnsense-set-interface-v6/run-tests.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# tests/opnsense-set-interface-v6/run-tests.sh +# +# Harness for scripts/opnsense-set-interface-v6.sh + scripts/opnsense-set-iface-v6.php. +# +# OFFLINE. Touches no edge. It exercises the arg contract and the SAFETY PROPERTIES, +# which are the entire reason this script is allowed to exist at all: it is the ONLY +# tool in this repo that writes an OPNsense base-interface config, on a LIVE router +# whose DHCP and firewall rules are API-managed and would be destroyed by a wholesale +# config.xml replace. +# +# Pinned here, each one load-bearing: +# 1. DRY BY DEFAULT. --commit is required to write. (D-117's lesson.) +# 2. It NEVER renders or pushes a config.xml. It mutates the vendor's own live +# Config object in place -- the thing D-113 banned is the wholesale push. +# 3. The interface COUNT is asserted unchanged before saving, and re-asserted on a +# fresh read-back. A silent structural drop on a live router must fail loud. +# 4. Values are validated BEFORE they reach the router (both layers). +# 5. Remote commands go through `sh -s`. root's shell on the edge is TCSH and a +# $(...) in a quoted remote command dies QUIETLY ("Illegal variable name"). +# 6. Ground truth is the KERNEL (ifconfig), never the config file or a self-report. +# ASCII + LF. +set -uo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SDIR="$(cd "$HERE/../../scripts" && pwd)" +SH="$SDIR/opnsense-set-interface-v6.sh" +PHP="$SDIR/opnsense-set-iface-v6.php" +pass=0; fail=0 +ok() { pass=$((pass+1)); } +bad() { fail=$((fail+1)); echo " FAIL: $1"; } + +# 0. both exist and the shell half parses +[ -f "$SH" ] && ok || bad "opnsense-set-interface-v6.sh missing" +[ -f "$PHP" ] && ok || bad "opnsense-set-iface-v6.php missing" +bash -n "$SH" 2>/dev/null && ok || bad "opnsense-set-interface-v6.sh does not parse" +# php is not installed on the jumphost (it lives on the edge). Lint only if present -- +# never silently skip-and-pass a real syntax check when the tool IS available. +if command -v php >/dev/null 2>&1; then + php -l "$PHP" >/dev/null 2>&1 && ok || bad "opnsense-set-iface-v6.php does not parse (php -l)" +else + ok # documented: no php on this host; the edge parses it at run time +fi + +# 1. arg contract -- every missing arg must fail, not default to something. +"$SH" >/dev/null 2>&1; [ $? -ne 0 ] && ok || bad "no args must fail" +"$SH" 10.10.0.1 >/dev/null 2>&1; [ $? -ne 0 ] && ok || bad "host only must fail" +"$SH" 10.10.0.1 lan >/dev/null 2>&1; [ $? -ne 0 ] && ok || bad "no address must fail" +"$SH" 10.10.0.1 lan 2602:f3e2:f01:100::1 >/dev/null 2>&1; [ $? -ne 0 ] && ok || bad "no prefixlen must fail" + +# 2. VALIDATION BEFORE THE ROUTER. A bad value must die locally -- it must never be +# shipped to a live edge to be rejected there. +out="$("$SH" 10.10.0.1 lan 10.10.0.1 64 2>&1)" +printf '%s' "$out" | grep -q "not an IPv6 address" && ok || bad "a v4 address was not rejected locally" +out="$("$SH" 10.10.0.1 lan 2602:f3e2:f01:100::1 129 2>&1)" +printf '%s' "$out" | grep -q "out of range" && ok || bad "prefixlen 129 was not rejected locally" +out="$("$SH" 10.10.0.1 lan 2602:f3e2:f01:100::1 abc 2>&1)" +printf '%s' "$out" | grep -q "not a prefix length" && ok || bad "a non-numeric prefixlen was not rejected" + +# 3. no key -> refuse. (Validation runs first, so use an otherwise-valid call.) +out="$(env -u OPNSENSE_SSH_KEY "$SH" 10.10.0.1 lan 2602:f3e2:f01:100::1 64 2>&1)" +printf '%s' "$out" | grep -q 'OPNSENSE_SSH_KEY not set' && ok || bad "missing OPNSENSE_SSH_KEY was not refused" + +# 4. DRY BY DEFAULT -- in BOTH halves. +grep -q '\-\-commit' "$SH" && ok || bad "the wrapper has no --commit flag" +grep -q '\-\-commit' "$PHP" && ok || bad "the php has no --commit flag" +grep -q 'DRY RUN -- nothing written, nothing applied' "$SH" && ok || bad "the wrapper does not announce its dry run" +grep -q 'DRY RUN -- nothing was written' "$PHP" && ok || bad "the php does not announce its dry run" +# the php must not save unless committed +grep -q 'if (!\$commit)' "$PHP" && ok || bad "the php has no dry-run guard before the write" + +# 5. IT MUST NEVER RENDER OR PUSH A config.xml. This is the D-113 red line: a +# wholesale replace would wipe the API-managed Kea DHCP and the firewall rules. +grep -qiE 'config\.xml\.tmpl|render-config|scp .*config\.xml|>[[:space:]]*/conf/config\.xml' "$SH" \ + && bad "the wrapper renders or pushes a config.xml -- THE FORBIDDEN CLOBBER PATH" || ok +grep -qE 'file_put_contents|fopen\(.*/conf/config\.xml' "$PHP" \ + && bad "the php writes config.xml directly instead of via the vendor's Config object" || ok +# it must go through the vendor's own singleton +grep -q 'Config::getInstance()' "$PHP" && ok || bad "the php does not use OPNsense's own Config singleton" +grep -q 'use OPNsense\\Core\\Config;' "$PHP" && ok || bad "the php no longer imports OPNsense's Config" + +# 6. THE STRUCTURAL GUARD. Serializing the vendor's object must not drop nodes, and +# if it ever does, a LIVE ROUTER is corrupt. Fail loud, do not discover it later. +grep -q 'iface_count_before' "$PHP" && ok || bad "the interface-count guard is gone" +grep -q 'NOT SAVING' "$PHP" && ok || bad "the php no longer refuses to save on a count mismatch" +grep -q 'CONFIG DAMAGED' "$PHP" && ok || bad "the post-save count re-check is gone" +# and it must READ BACK from a fresh load, not trust its in-memory copy +grep -q 'forceReload' "$PHP" && ok || bad "the php does not re-read from disk -- it would trust its own memory" +grep -q 'read-back mismatch' "$PHP" && ok || bad "the php does not verify what it actually wrote" + +# 7. only ipaddrv6/subnetv6 may be touched. Anything else on a live edge is scope creep +# with a router at the end of it. +touched="$(grep -oE '\$node->[a-zA-Z0-9_]+ =' "$PHP" | sort -u | sed 's/\$node->//; s/ =//')" +[ "$(printf '%s\n' "$touched" | grep -c .)" -eq 2 ] && ok \ + || bad "the php assigns to more than 2 interface fields: $(printf '%s' "$touched" | tr '\n' ' ')" +printf '%s\n' "$touched" | grep -qx 'ipaddrv6' && ok || bad "the php no longer sets ipaddrv6" +printf '%s\n' "$touched" | grep -qx 'subnetv6' && ok || bad "the php no longer sets subnetv6" +grep -q 'UNTOUCHED' "$PHP" && ok || bad "the php no longer shows that the v4 address is left alone" + +# 8. THE TCSH TRAP. root's shell on the edge is tcsh; a $(...) inside a quoted remote +# command dies QUIETLY. Every remote command must be fed to `sh -s`. +grep -q 'sh -s' "$SH" && ok || bad "remote commands are not fed to 'sh -s' -- the TCSH trap (root's shell is tcsh)" +grep -qi 'tcsh' "$SH" && ok || bad "the tcsh trap is no longer documented -- the next person will re-learn it the hard way" + +# 9. saving config != applying it. The reconfigure step must exist, and the php must +# say plainly that it does NOT apply. +grep -q 'configctl interface reconfigure' "$SH" && ok || bad "the wrapper never applies the change (configctl reconfigure)" +grep -q 'NOT yet applied to the running kernel' "$PHP" && ok || bad "the php no longer warns that a save is not an apply" + +# 10. GROUND TRUTH. The kernel decides, not the config file and not a self-report. +grep -q 'ifconfig' "$SH" && ok || bad "the wrapper never checks the KERNEL for the address" +grep -q 'GROUND TRUTH' "$SH" && ok || bad "the ground-truth read-back is gone" +grep -q 'is NOT on' "$SH" && ok || bad "the wrapper does not FAIL when the address is absent after reconfigure" + +# 11. the D-113 amendment must stay cited -- this script only exists because the API +# cannot do this, and that finding is the whole justification for the SSH path. +grep -q 'D-113' "$SH" && ok || bad "the wrapper no longer cites D-113 (why this is not the REST API)" +grep -q 'D-113' "$PHP" && ok || bad "the php no longer cites D-113" +grep -qi 'interfaces.php' "$SH" && ok || bad "the measured evidence (legacy /interfaces.php page) is no longer cited" + +echo +total=$((pass+fail)) +if [ "$fail" -eq 0 ]; then echo "opnsense-set-interface-v6: $pass/$total PASS"; exit 0; fi +echo "opnsense-set-interface-v6: $fail/$total FAIL"; exit 1