diff --git a/scripts/opnsense-set-iface-v4.php b/scripts/opnsense-set-iface-v4.php new file mode 100644 index 0000000..dfb1da4 --- /dev/null +++ b/scripts/opnsense-set-iface-v4.php @@ -0,0 +1,210 @@ +
[] + * + * e.g. opnsense-set-iface-v4.php --commit lan 10.12.4.1 22 + * opnsense-set-iface-v4.php --commit wan 172.30.2.2 24 172.30.2.1 + * + * Sets a STATIC IPv4 address on an OPNsense base interface -- and, when a gateway is + * given, the upstream default gateway for it -- using OPNSENSE'S OWN CONFIG OBJECT, + * the same object the GUI's Interfaces > [WAN] page mutates when you press Save. + * + * This is the IPv4 sibling of opnsense-set-iface-v6.php. Same design, same guards; + * only the leaf fields differ (ipaddr/subnet rather than ipaddrv6/subnetv6), plus the + * gateway half, which has no v6 counterpart here. + * + * WHY THIS EXISTS (D-113 AMENDMENT, 2026-07-14, RE-MEASURED ON 26.7 2026-07-20): + * D-113(a2)'s text claims "DHCP, firewall, interfaces: the REST API". The + * `interfaces` half is FALSE. Base-interface addressing is served by the LEGACY page + * /interfaces.php?if=; only sub-trees (Devices, Neighbors, VIPs, ...) were + * migrated to MVC. MEASURED on OPNsense 26.7 on the vr1-dc0 edge: the global + * `interfaces/settings/get` endpoint exists but carries only offload flags, while + * `interfaces/overview/list` and `interfaces//get` both return 404 "Endpoint not + * found". So the amendment now holds on TWO majors (26.1 and 26.7), measured -- not + * inferred, and not a 26.1-only quirk. + * + * WHY THIS IS NOT THE FORBIDDEN config.xml PUSH. What D-113 banned is a + * HAND-AUTHORED / RENDERED config.xml pushed wholesale over the live one -- the + * clobber path that would wipe API-managed Kea DHCP and the firewall rules. This does + * the opposite: it LOADS the edge's live config through the vendor's own Config + * singleton, mutates a few leaf fields, and lets the vendor's own code serialize it + * back. It is the code path interfaces.php runs on Save. + * + * STRUCTURAL GUARDS. The interface COUNT must not change, and the gateway count may + * only grow by the one item we deliberately add. Both are asserted before saving and + * re-asserted on a fresh read-back: a silent structural drop on a LIVE ROUTER must + * fail loud, not be discovered later by a ping that stopped. + * + * DRY BY DEFAULT. Without --commit it prints the current -> desired delta and writes + * NOTHING. (D-117's lesson: a write-by-default 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-v4.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-v4.php [--commit]
[]\n"); + fwrite(STDERR, " e.g. opnsense-set-iface-v4.php --commit wan 172.30.2.2 24 172.30.2.1\n"); + exit(1); +} +$ifname = $argvv[0]; +$address = $argvv[1]; +$prefixlen = $argvv[2]; +$gwaddr = isset($argvv[3]) ? $argvv[3] : ''; + +/* ---- validate BEFORE touching the config. Never write an unvalidated value. ---- */ +if (filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) === false) { + fwrite(STDERR, "FAIL: '{$address}' is not a valid IPv4 address\n"); + exit(1); +} +if (!ctype_digit((string)$prefixlen) || (int)$prefixlen < 1 || (int)$prefixlen > 32) { + fwrite(STDERR, "FAIL: '{$prefixlen}' is not a valid IPv4 prefix length (1-32)\n"); + exit(1); +} +$prefixlen = (string)(int)$prefixlen; +if ($gwaddr !== '' && filter_var($gwaddr, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) === false) { + fwrite(STDERR, "FAIL: '{$gwaddr}' is not a valid IPv4 gateway address\n"); + exit(1); +} + +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); +} + +$iface_count_before = count($cfg->interfaces->children()); +$gw_count_before = isset($cfg->gateways) ? count($cfg->gateways->children()) : 0; + +$node = $cfg->interfaces->$ifname; +$cur_ip = isset($node->ipaddr) ? (string)$node->ipaddr : ''; +$cur_len = isset($node->subnet) ? (string)$node->subnet : ''; +$cur_gw = isset($node->gateway) ? (string)$node->gateway : ''; +$gwname = strtoupper($ifname) . '_GW'; + +printf("interface : %s (%s)\n", $ifname, isset($node->descr) ? (string)$node->descr : ''); +printf(" ipaddr : %s -> %s\n", $cur_ip === '' ? '(none)' : $cur_ip, $address); +printf(" subnet : %s -> %s\n", $cur_len === '' ? '(none)' : $cur_len, $prefixlen); +if ($gwaddr !== '') { + printf(" gateway : %s -> %s (%s = %s)\n", $cur_gw === '' ? '(none)' : $cur_gw, $gwname, $gwname, $gwaddr); +} else { + printf(" gateway : %s [UNTOUCHED -- no gateway argument]\n", $cur_gw === '' ? '(none)' : $cur_gw); +} +printf(" ipaddrv6 (v6) : %s [UNTOUCHED]\n", isset($node->ipaddrv6) ? (string)$node->ipaddrv6 : '(none)'); +printf(" interfaces in config: %d\n", $iface_count_before); +printf(" gateway items : %d\n", $gw_count_before); + +$addr_same = ($cur_ip === $address && $cur_len === $prefixlen); +$gw_same = ($gwaddr === '' || $cur_gw === $gwname); +if ($addr_same && $gw_same) { + 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->ipaddr = $address; +$node->subnet = $prefixlen; + +/* ---- the gateway half. Only when asked, and only ever ONE added item. ---- */ +$gw_added = 0; +if ($gwaddr !== '') { + if (!isset($cfg->gateways)) { + $cfg->addChild('gateways'); + } + $found = null; + foreach ($cfg->gateways->gateway_item as $item) { + if ((string)$item->name === $gwname) { + $found = $item; + break; + } + } + if ($found === null) { + $found = $cfg->gateways->addChild('gateway_item'); + $found->addChild('name', $gwname); + $found->addChild('interface', $ifname); + $found->addChild('gateway', $gwaddr); + $found->addChild('ipprotocol', 'inet'); + $found->addChild('defaultgw', '1'); + $found->addChild('descr', 'set by opnsense-set-iface-v4.php'); + $gw_added = 1; + } else { + $found->gateway = $gwaddr; + $found->interface = $ifname; + $found->defaultgw = '1'; + } + $node->gateway = $gwname; +} + +$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); +} +$gw_count_after = isset($cfg->gateways) ? count($cfg->gateways->children()) : 0; +if ($gw_count_after !== $gw_count_before + $gw_added) { + fwrite(STDERR, "FAIL: gateway count changed {$gw_count_before} -> {$gw_count_after} (expected +{$gw_added}). 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->ipaddr; +$got_len = (string)$verify->interfaces->$ifname->subnet; +$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); +} +if ($gwaddr !== '') { + $got_gw = (string)$verify->interfaces->$ifname->gateway; + if ($got_gw !== $gwname) { + fwrite(STDERR, "FAIL: read-back gateway mismatch -- got '{$got_gw}', wanted '{$gwname}'\n"); + exit(2); + } +} + +printf("\nOK: saved and read back -- %s = %s/%s (%d interfaces intact)\n", $ifname, $got_ip, $got_len, $got_cnt); +if ($gwaddr !== '') { + printf("OK: gateway %s = %s is the default route for %s\n", $gwname, $gwaddr, $ifname); +} +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-v4.sh b/scripts/opnsense-set-interface-v4.sh new file mode 100755 index 0000000..c51d603 --- /dev/null +++ b/scripts/opnsense-set-interface-v4.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# scripts/opnsense-set-interface-v4.sh [--commit]
[] +# +# e.g. OPNSENSE_SSH_KEY=~/vr1-dc0-creds/vr1-dc0-edge_ed25519 \ +# bash scripts/opnsense-set-interface-v4.sh --commit 192.168.1.1 lan 10.12.4.1 22 +# bash scripts/opnsense-set-interface-v4.sh --commit 192.168.1.1 wan 172.30.2.2 24 172.30.2.1 +# +# Sets a STATIC IPv4 address (and optionally the upstream default gateway) on an +# OPNsense base interface and applies it, then proves the address is actually on the +# wire. IPv4 sibling of opnsense-set-interface-v6.sh -- same design and same guards. +# +# WHY IT IS NOT THE REST API (D-113 AMENDMENT, 2026-07-14; RE-MEASURED ON 26.7, +# 2026-07-20). Base-interface addressing is served by the LEGACY page +# /interfaces.php?if=; only sub-trees were migrated to MVC. Measured on the live +# vr1-dc0 edge running 26.7: `interfaces/settings/get` returns only offload flags, +# while `interfaces/overview/list` and `interfaces//get` both 404. The amendment +# therefore holds on TWO majors, measured -- it is not a 26.1-only quirk. +# +# WHY IT IS NOT THE FORBIDDEN config.xml PUSH. It never authors a config. It ships +# scripts/opnsense-set-iface-v4.php, which loads the edge's LIVE config through +# OPNsense's own Config singleton, mutates leaf fields, asserts the interface count is +# unchanged (and that the gateway count grew by at most the one item it adds), and +# lets the vendor's own code serialize it -- the identical code path interfaces.php +# runs on Save. Nothing is replaced wholesale, so API-managed Kea DHCP and firewall +# rules cannot be clobbered. +# +# REUSABLE BY EVERY DC EDGE. dc1 and the Roosevelt metal edges take this verbatim; +# only the host/address/gateway change. +# +# ORDERING WARNING (learned on the vr1-dc0 build): re-addressing the LAN moves the +# very interface you are reaching the edge THROUGH. Do the WAN first, verify, then the +# LAN last -- and reconnect at the new LAN address afterwards. +# +# 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-v4.php" + +COMMIT=0 +if [ "${1:-}" = "--commit" ]; then COMMIT=1; shift; fi + +EDGE="${1:-}" +IFNAME="${2:-}" +ADDR="${3:-}" +PLEN="${4:-}" +GW="${5:-}" + +usage() { + echo "usage: opnsense-set-interface-v4.sh [--commit]
[]" >&2 + echo " e.g. opnsense-set-interface-v4.sh --commit 192.168.1.1 wan 172.30.2.2 24 172.30.2.1" >&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. +is_v4() { + printf '%s' "$1" | grep -qE '^([0-9]{1,3}\.){3}[0-9]{1,3}$' || return 1 + o1="${1%%.*}"; rest="${1#*.}"; o2="${rest%%.*}"; rest="${rest#*.}"; o3="${rest%%.*}"; o4="${rest#*.}" + for o in "$o1" "$o2" "$o3" "$o4"; do [ "$o" -le 255 ] || return 1; done + return 0 +} +is_v4 "$ADDR" || { echo "FAIL: '$ADDR' is not an IPv4 address" >&2; exit 1; } +case "$PLEN" in ''|*[!0-9]*) echo "FAIL: '$PLEN' is not a prefix length" >&2; exit 1 ;; esac +[ "$PLEN" -ge 1 ] && [ "$PLEN" -le 32 ] || { echo "FAIL: prefix length '$PLEN' out of range 1-32" >&2; exit 1; } +if [ -n "$GW" ]; then + is_v4 "$GW" || { echo "FAIL: '$GW' is not an IPv4 gateway address" >&2; exit 1; } +fi + +[ -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-v4.$$.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; } + +# The kernel device behind a base interface is NOT guessable -- ask the edge's own +# config which device this interface is assigned to (measured, never inferred). +DEV="$("${SSH[@]}" "root@${EDGE}" sh -s <.*|&|p" /conf/config.xml >/dev/null 2>&1 +php -r '\$c=simplexml_load_file("/conf/config.xml"); print (string)\$c->interfaces->${IFNAME}->if;' 2>/dev/null +EOF +)" +[ -n "$DEV" ] || { echo "FAIL: could not determine the device for interface '$IFNAME'" >&2; exit 2; } +echo "interface '$IFNAME' is device '$DEV' (measured from the edge's own config)" + +echo "=== BEFORE (the edge's own view) ===" +"${SSH[@]}" "root@${EDGE}" sh -s <&2; exit 2; } +ifconfig $DEV inet 2>/dev/null | grep 'inet ' || echo " (no inet on the interface)" +netstat -rn -f inet 2>/dev/null | grep '^default' || echo " (no default route)" +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' '$GW' +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) ===" +# Re-addressing the interface you are connected THROUGH drops this SSH session by +# design; the reconfigure still completes on the edge. Do not treat that as a failure. +"${SSH[@]}" "root@${EDGE}" sh -s </dev/null || true +ifconfig $DEV inet 2>/dev/null | grep 'inet ' || true +EOF +)" +printf '%s\n' "$OUT" + +# The address must actually be ON THE INTERFACE. A {"result":"saved"}-style +# self-report is not evidence; the kernel is. +if printf '%s' "$OUT" | grep -q "$ADDR"; then + echo + echo "OK: $ADDR/$PLEN is live on $IFNAME ($DEV)." + exit 0 +fi + +echo +echo "FAIL: $ADDR is NOT on $IFNAME ($DEV) after reconfigure." >&2 +echo " If this re-addressed the interface you arrived on, re-verify from a host" >&2 +echo " on the NEW subnet: ssh root@$ADDR ifconfig $DEV inet" >&2 +exit 2 diff --git a/tests/opnsense-set-interface-v4/run-tests.sh b/tests/opnsense-set-interface-v4/run-tests.sh new file mode 100755 index 0000000..64133d6 --- /dev/null +++ b/tests/opnsense-set-interface-v4/run-tests.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +# tests/opnsense-set-interface-v4/run-tests.sh +# +# Harness for scripts/opnsense-set-interface-v4.sh + scripts/opnsense-set-iface-v4.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: it 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. +# +# Same pinned properties as the v6 harness, plus the two this tool adds: +# 7. Only ipaddr/subnet/gateway may be touched on the interface node. +# 8. The GATEWAY half may add at most ONE gateway_item, count-guarded like the +# interface count -- a live router's routing table is not a place for surprises. +# ASCII + LF. +set -uo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SDIR="$(cd "$HERE/../../scripts" && pwd)" +SH="$SDIR/opnsense-set-interface-v4.sh" +PHP="$SDIR/opnsense-set-iface-v4.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-v4.sh missing" +[ -f "$PHP" ] && ok || bad "opnsense-set-iface-v4.php missing" +bash -n "$SH" 2>/dev/null && ok || bad "opnsense-set-interface-v4.sh does not parse" +if command -v php >/dev/null 2>&1; then + php -l "$PHP" >/dev/null 2>&1 && ok || bad "opnsense-set-iface-v4.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" 192.168.1.1 >/dev/null 2>&1; [ $? -ne 0 ] && ok || bad "host only must fail" +"$SH" 192.168.1.1 lan >/dev/null 2>&1; [ $? -ne 0 ] && ok || bad "no address must fail" +"$SH" 192.168.1.1 lan 10.12.4.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" 192.168.1.1 lan 2602:f3e2:f01:100::1 22 2>&1)" +printf '%s' "$out" | grep -q "not an IPv4 address" && ok || bad "a v6 address was not rejected locally" +out="$("$SH" 192.168.1.1 lan 10.12.4.999 22 2>&1)" +printf '%s' "$out" | grep -q "not an IPv4 address" && ok || bad "an out-of-range octet was not rejected locally" +out="$("$SH" 192.168.1.1 lan 10.12.4.1 33 2>&1)" +printf '%s' "$out" | grep -q "out of range" && ok || bad "prefixlen 33 was not rejected locally" +out="$("$SH" 192.168.1.1 lan 10.12.4.1 abc 2>&1)" +printf '%s' "$out" | grep -q "not a prefix length" && ok || bad "a non-numeric prefixlen was not rejected" +out="$("$SH" 192.168.1.1 wan 172.30.2.2 24 notanip 2>&1)" +printf '%s' "$out" | grep -q "not an IPv4 gateway" && ok || bad "a bad gateway was not rejected locally" + +# 3. no key -> refuse. (Validation runs first, so use an otherwise-valid call.) +out="$(env -u OPNSENSE_SSH_KEY "$SH" 192.168.1.1 lan 10.12.4.1 22 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" +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 -- the D-113 red line. +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 +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 GUARDS -- interface count, before and after a fresh read-back. +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" +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 ipaddr/subnet/gateway may be touched on the interface node. +touched="$(grep -oE '\$node->[a-zA-Z0-9_]+ =' "$PHP" | sort -u | sed 's/\$node->//; s/ =//')" +[ "$(printf '%s\n' "$touched" | grep -c .)" -eq 3 ] && ok \ + || bad "the php assigns to other than 3 interface fields: $(printf '%s' "$touched" | tr '\n' ' ')" +printf '%s\n' "$touched" | grep -qx 'ipaddr' && ok || bad "the php no longer sets ipaddr" +printf '%s\n' "$touched" | grep -qx 'subnet' && ok || bad "the php no longer sets subnet" +printf '%s\n' "$touched" | grep -qx 'gateway' && ok || bad "the php no longer sets the interface gateway" +grep -q 'UNTOUCHED' "$PHP" && ok || bad "the php no longer shows which fields it leaves alone" + +# 8. THE GATEWAY HALF: count-guarded, at most one added item, and only when asked. +grep -q 'gw_count_before' "$PHP" && ok || bad "the gateway-count guard is gone" +grep -q 'expected +' "$PHP" && ok || bad "the php does not assert HOW MANY gateway items it added" +grep -q 'gw_added' "$PHP" && ok || bad "the php no longer tracks whether it added a gateway item" +grep -q "if (\$gwaddr !== '')" "$PHP" && ok || bad "the gateway half is not conditional -- it must be opt-in" +grep -q 'read-back gateway mismatch' "$PHP" && ok || bad "the php does not verify the gateway it wrote" + +# 9. 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" +grep -qi 'tcsh' "$SH" && ok || bad "the tcsh trap is no longer documented" + +# 10. saving config != applying it. +grep -q 'configctl interface reconfigure' "$SH" && ok || bad "the wrapper never applies the change" +grep -q 'NOT yet applied to the running kernel' "$PHP" && ok || bad "the php no longer warns that a save is not an apply" + +# 11. 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" + +# 12. THE DEVICE IS MEASURED, NOT GUESSED. The v6 sibling hardcodes lan->vtnet0 / +# wan->vtnet1 via sed; that mapping is per-edge and must be read from the edge. +grep -q "s/lan/vtnet0/" "$SH" && bad "the device mapping is HARDCODED (lan->vtnet0) -- it must be measured per edge" || ok +grep -q 'measured from the edge' "$SH" && ok || bad "the wrapper does not state that it measured the device" + +# 13. the D-113 amendment must stay cited, WITH its 26.7 re-measurement -- this tool +# only exists because the API cannot do this. +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 -q '26.7' "$SH" && ok || bad "the 26.7 re-measurement is no longer cited in the wrapper" +grep -q '26.7' "$PHP" && ok || bad "the 26.7 re-measurement is no longer cited in the php" +grep -qi 'interfaces.php' "$SH" && ok || bad "the measured evidence (legacy /interfaces.php page) is no longer cited" + +# 14. THE ORDERING WARNING. Re-addressing the LAN moves the interface you arrived on; +# losing that warning is how a future operator strands themselves. +grep -qi 'WAN first' "$SH" && ok || bad "the WAN-before-LAN ordering warning is gone" + +echo +total=$((pass+fail)) +if [ "$fail" -eq 0 ]; then echo "opnsense-set-interface-v4: $pass/$total PASS"; exit 0; fi +echo "opnsense-set-interface-v4: $fail/$total FAIL"; exit 1