<?php
/*
* opnsense-set-iface-v6.php [--commit] <interface> <address> <prefixlen>
*
* 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] <interface> <address> <prefixlen>\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 <interfaces> 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");