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