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