<?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");
