<?php
/*
 * opnsense-set-iface-v4.php [--commit] <interface> <address> <prefixlen> [<gateway>]
 *
 *   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=<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/<if>/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] <interface> <address> <prefixlen> [<gateway>]\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 <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);
}

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