#!/usr/bin/env bash
# scripts/opnsense-set-interface-v4.sh [--commit] <edge-host> <interface> <address> <prefixlen> [<gateway>]
#
#   e.g. OPNSENSE_SSH_KEY=~/vr1-dc0-creds/vr1-dc0-edge_ed25519 \
#        bash scripts/opnsense-set-interface-v4.sh --commit 192.168.1.1 lan 10.12.4.1 22
#        bash scripts/opnsense-set-interface-v4.sh --commit 192.168.1.1 wan 172.30.2.2 24 172.30.2.1
#
# Sets a STATIC IPv4 address (and optionally the upstream default gateway) on an
# OPNsense base interface and applies it, then proves the address is actually on the
# wire. IPv4 sibling of opnsense-set-interface-v6.sh -- same design and same guards.
#
# WHY IT IS NOT THE REST API (D-113 AMENDMENT, 2026-07-14; RE-MEASURED ON 26.7,
# 2026-07-20). Base-interface addressing is served by the LEGACY page
# /interfaces.php?if=<if>; only sub-trees were migrated to MVC. Measured on the live
# vr1-dc0 edge running 26.7: `interfaces/settings/get` returns only offload flags,
# while `interfaces/overview/list` and `interfaces/<if>/get` both 404. The amendment
# therefore holds on TWO majors, measured -- it is not a 26.1-only quirk.
#
# WHY IT IS NOT THE FORBIDDEN config.xml PUSH. It never authors a config. It ships
# scripts/opnsense-set-iface-v4.php, which loads the edge's LIVE config through
# OPNsense's own Config singleton, mutates leaf fields, asserts the interface count is
# unchanged (and that the gateway count grew by at most the one item it adds), and
# lets the vendor's own code serialize it -- the identical code path interfaces.php
# runs on Save. Nothing is replaced wholesale, so API-managed Kea DHCP and firewall
# rules cannot be clobbered.
#
# REUSABLE BY EVERY DC EDGE. dc1 and the Roosevelt metal edges take this verbatim;
# only the host/address/gateway change.
#
# ORDERING WARNING (learned on the vr1-dc0 build): re-addressing the LAN moves the
# very interface you are reaching the edge THROUGH. Do the WAN first, verify, then the
# LAN last -- and reconnect at the new LAN address afterwards.
#
# 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-v4.php"

COMMIT=0
if [ "${1:-}" = "--commit" ]; then COMMIT=1; shift; fi

EDGE="${1:-}"
IFNAME="${2:-}"
ADDR="${3:-}"
PLEN="${4:-}"
GW="${5:-}"

usage() {
  echo "usage: opnsense-set-interface-v4.sh [--commit] <edge-host> <interface> <address> <prefixlen> [<gateway>]" >&2
  echo "  e.g. opnsense-set-interface-v4.sh --commit 192.168.1.1 wan 172.30.2.2 24 172.30.2.1" >&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.
is_v4() {
  printf '%s' "$1" | grep -qE '^([0-9]{1,3}\.){3}[0-9]{1,3}$' || return 1
  o1="${1%%.*}"; rest="${1#*.}"; o2="${rest%%.*}"; rest="${rest#*.}"; o3="${rest%%.*}"; o4="${rest#*.}"
  for o in "$o1" "$o2" "$o3" "$o4"; do [ "$o" -le 255 ] || return 1; done
  return 0
}
is_v4 "$ADDR" || { echo "FAIL: '$ADDR' is not an IPv4 address" >&2; exit 1; }
case "$PLEN" in ''|*[!0-9]*) echo "FAIL: '$PLEN' is not a prefix length" >&2; exit 1 ;; esac
[ "$PLEN" -ge 1 ] && [ "$PLEN" -le 32 ] || { echo "FAIL: prefix length '$PLEN' out of range 1-32" >&2; exit 1; }
if [ -n "$GW" ]; then
  is_v4 "$GW" || { echo "FAIL: '$GW' is not an IPv4 gateway address" >&2; exit 1; }
fi

[ -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-v4.$$.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; }

# The kernel device behind a base interface is NOT guessable -- ask the edge's own
# config which device this interface is assigned to (measured, never inferred).
DEV="$("${SSH[@]}" "root@${EDGE}" sh -s <<EOF
sed -n "s|.*<${IFNAME}>.*|&|p" /conf/config.xml >/dev/null 2>&1
php -r '\$c=simplexml_load_file("/conf/config.xml"); print (string)\$c->interfaces->${IFNAME}->if;' 2>/dev/null
EOF
)"
[ -n "$DEV" ] || { echo "FAIL: could not determine the device for interface '$IFNAME'" >&2; exit 2; }
echo "interface '$IFNAME' is device '$DEV' (measured from the edge's own config)"

echo "=== BEFORE (the edge's own view) ==="
"${SSH[@]}" "root@${EDGE}" sh -s <<EOF || { echo "FAIL: could not read the edge" >&2; exit 2; }
ifconfig $DEV inet 2>/dev/null | grep 'inet ' || echo "  (no inet on the interface)"
netstat -rn -f inet 2>/dev/null | grep '^default' || echo "  (no default route)"
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 <<EOF || { echo "FAIL: the setter failed on $EDGE" >&2; exit 2; }
cd /usr/local/opnsense/mvc && php '$REMOTE_PHP' $PHPARGS '$IFNAME' '$ADDR' '$PLEN' '$GW'
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) ==="
# Re-addressing the interface you are connected THROUGH drops this SSH session by
# design; the reconfigure still completes on the edge. Do not treat that as a failure.
#
# `filter reload` ADDED 2026-07-27 (operator-ruled; appendix-A "edge passes no LAN
# traffic after v4 addressing"). MEASURED on the fresh dc1 edge 2026-07-23: `interface
# reconfigure` applies the address to the KERNEL but does NOT regenerate the pf ruleset,
# so OPNsense never creates its automatic outbound-NAT rules for the (re-)addressed LAN.
# Forwarded LAN packets then leave the WAN un-NAT'd and replies never return -- the edge
# passes NO LAN traffic while looking perfectly configured. Without this line the
# addressing operation is INCOMPLETE, not merely unpolished, which is why the reload
# belongs here rather than in a runbook step: DoD prose did not fire at dc1, and this
# repo has a measured record of prose-only controls not firing (D-137's own wiring sat as
# prose at phase-3:498 and missed BOTH DC standups).
#
# The defect is SILENT and self-masking: any later plugin install or config apply reloads
# the filter as a side effect, so dc0's 2026-07-20 qga/plugin work hid it there until dc1
# -- which had no later work -- exposed it at the mirror build.
#
# IT IS DELIBERATELY *NOT* ON THE NEXT LINE OF THIS HEREDOC. Re-addressing the interface
# you arrived on drops the session DURING `interface reconfigure`, so a following command
# in the same stream would never execute on the edge -- and that drop case is precisely
# the dc1 scenario that produced the defect (the LAN was moved 192.168.1.1 -> 10.12.64.1
# over the LAN itself). Putting the reload here would have looked like a fix while fixing
# nothing exactly when it matters. It runs below instead, over a FRESH connection to
# whichever address the edge now answers on.
"${SSH[@]}" "root@${EDGE}" sh -s <<EOF || echo "  (connection dropped -- expected when re-addressing the interface you arrived on)"
configctl interface reconfigure $IFNAME
EOF

echo
echo "=== AFTER: GROUND TRUTH (the kernel, not the config file) ==="
# Reach the edge at its NEW address if we just moved the interface we arrived on.
PROBE_HOST="$EDGE"
[ "$ADDR" != "$EDGE" ] || PROBE_HOST="$ADDR"

echo
echo "=== REGENERATE THE PF RULESET (appendix-A: edge passes no LAN traffic) ==="
# Fresh connection, post-reconfigure, so this runs even when the reconfigure dropped us.
"${SSH[@]}" "root@${PROBE_HOST}" sh -s <<'EOF' || echo "  WARN: could not reload the filter -- run 'configctl filter reload' on the edge and re-test"
configctl filter reload
EOF
# Report the automatic outbound NAT rules. A healthy edge shows `nat on <wan> inet from
# (<lan>:network) ...`; the broken state shows ONLY the `no nat/rdr proto carp` boilerplate.
# This is REPORTED, not gated: at the very first addressing of an edge there may legitimately
# be no gateway yet and so no NAT to generate, and a hard failure here would block a correct
# run. The script's hard gate stays the address-on-the-kernel check below -- a self-report is
# never evidence, but neither is an assertion the operation cannot yet satisfy.
NATOUT="$("${SSH[@]}" "root@${PROBE_HOST}" sh -s <<'EOF' 2>/dev/null || true
pfctl -s nat 2>/dev/null | grep -v 'proto carp' | head -5
EOF
)"
if [ -n "$NATOUT" ]; then
  printf '%s\n' "$NATOUT" | sed 's/^/  /'
else
  echo "  WARN: no automatic outbound NAT rules beyond the carp boilerplate."
  echo "        If this edge is meant to NAT a LAN, that is the appendix-A defect --"
  echo "        verify with: pfctl -s nat   and re-test from a LAN client (0% loss, curl 301)."
fi
OUT="$("${SSH[@]}" "root@${PROBE_HOST}" sh -s <<EOF 2>/dev/null || true
ifconfig $DEV inet 2>/dev/null | grep 'inet ' || true
EOF
)"
printf '%s\n' "$OUT"

# The address must actually be ON THE INTERFACE. A {"result":"saved"}-style
# self-report is not evidence; the kernel is.
if printf '%s' "$OUT" | grep -q "$ADDR"; then
  echo
  echo "OK: $ADDR/$PLEN is live on $IFNAME ($DEV)."
  exit 0
fi

echo
echo "FAIL: $ADDR is NOT on $IFNAME ($DEV) after reconfigure." >&2
echo "      If this re-addressed the interface you arrived on, re-verify from a host" >&2
echo "      on the NEW subnet: ssh root@$ADDR ifconfig $DEV inet" >&2
exit 2
