Files
claudetools/.claude/skills/unifi-wifi/scripts/pfsense-gwc.php
Howard Enos 96a5dd6e7a sync: auto-sync from HOWARD-HOME at 2026-06-21 11:22:19
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-06-21 11:22:19
2026-06-21 11:23:04 -07:00

175 lines
7.6 KiB
PHP

<?php
/*
* pfsense-gwc.php — pfSense gateway-control helper for the unifi-wifi skill (SSH backend).
* Shipped to the box at /tmp by pfsense-ssh.sh and run with `php`, which bootstraps the full
* pfSense PHP env via config.inc (gives us $config + write_config()/filter_configure()).
*
* It is argv-driven — NO operator data is interpolated into PHP source (pfsense-ssh.sh passes
* everything as positional args), so there is no shell/PHP injection surface.
*
* php pfsense-gwc.php <action> <apply:0|1> [target] [val1] [val2]
*
* actions (reads ignore apply; writes are no-ops unless apply=1):
* pf-list list NAT port-forwards
* fw-list list filter (firewall) rules
* pf-disable <tracker|descr> 1 disable a port-forward (+ its associated filter rule)
* pf-enable <tracker|descr> 1 re-enable a port-forward
* pf-delete <tracker|descr> 1 delete a port-forward (+ associated filter rule)
* pf-set-ports <tracker|descr> 1 <dst> [<localport>] change destination port (+ local-port)
* pf-set-src <tracker|descr> 1 <cidr|any> restrict the source of a port-forward
* fw-disable <tracker|descr> 1 disable a filter rule
* fw-enable <tracker|descr> 1 re-enable a filter rule
*
* Filter rules are keyed on `tracker` (the `id` field is empty on pf25.07); match also accepts an
* exact case-insensitive `descr`. Enabled/disabled is the PRESENCE of a `disabled` key (= "" means off).
* Writes: backup config.xml to /tmp first, mutate, write_config(<note>), then filter_configure().
*/
// config.inc bootstraps the full pfSense env (incl. write_config() + filter_configure()).
// Do NOT re-require util/functions/filter — that triggers a "cannot redeclare" fatal.
ini_set("display_errors", "1");
error_reporting(E_ALL & ~E_DEPRECATED & ~E_NOTICE & ~E_WARNING);
require_once("config.inc");
global $config;
$ACTION = $argv[1] ?? "";
$APPLY = (($argv[2] ?? "0") === "1");
$TARGET = $argv[3] ?? "";
$VAL1 = $argv[4] ?? "";
$VAL2 = $argv[5] ?? "";
function gwc_out($s){ fwrite(STDOUT, $s . "\n"); }
function gwc_fail($s){ fwrite(STDOUT, "[FAIL] $s\n"); exit(1); }
/* on/off label for a config entry (disabled key present => off) */
function gwc_on_off($r){ return array_key_exists("disabled", $r) ? "off" : "on "; }
/* compact rendering of a source/destination object */
function gwc_addr($o){
if (!is_array($o)) return (string)$o;
if (array_key_exists("any", $o)) return "any";
$net = $o["network"] ?? ($o["address"] ?? "");
$port = isset($o["port"]) ? (":" . $o["port"]) : "";
return ($net !== "" ? $net : "?") . $port;
}
/* find a rule in $list by exact tracker, then exact case-insensitive descr; returns index or -1 */
function gwc_find_idx($list, $key){
foreach ($list as $i => $r) {
if ((string)($r["tracker"] ?? "") === (string)$key) return $i;
}
foreach ($list as $i => $r) {
if (strcasecmp((string)($r["descr"] ?? ""), (string)$key) === 0) return $i;
}
return -1;
}
/* timestamped config backup to /tmp; returns the path (best-effort) */
function gwc_backup_config(){
$src = "/cf/conf/config.xml";
$dst = "/tmp/gwc-config-backup-" . date("Ymd-His") . ".xml";
if (@copy($src, $dst)) { gwc_out("[backup] $dst"); return $dst; }
gwc_out("[backup] WARN could not copy $src");
return "";
}
/* commit + reload */
function gwc_commit($note){
write_config($note);
gwc_out("[ok] config written: $note");
gwc_out("[reload] applying filter ...");
filter_configure();
gwc_out("[ok] filter reloaded");
}
$nat = &$config["nat"]["rule"];
$flt = &$config["filter"]["rule"];
if (!is_array($nat)) $nat = array();
if (!is_array($flt)) $flt = array();
switch ($ACTION) {
case "pf-list":
if (count($nat) === 0) { gwc_out(" (no NAT port-forwards configured)"); break; }
foreach ($nat as $i => $p) {
gwc_out(sprintf(" [%s] #%d tracker=%s '%s' %s if=%s dst=%s -> %s:%s src=%s",
gwc_on_off($p), $i, $p["tracker"] ?? "", $p["descr"] ?? "",
$p["protocol"] ?? "-", $p["interface"] ?? "?",
gwc_addr($p["destination"] ?? array()), $p["target"] ?? "?",
$p["local-port"] ?? "?", gwc_addr($p["source"] ?? array("any"=>""))));
}
break;
case "fw-list":
if (count($flt) === 0) { gwc_out(" (no filter rules)"); break; }
foreach ($flt as $i => $r) {
gwc_out(sprintf(" [%s] #%d tracker=%s %s if=%s proto=%s '%s' src=%s dst=%s",
gwc_on_off($r), $i, $r["tracker"] ?? "", $r["type"] ?? "?",
$r["interface"] ?? "?", $r["protocol"] ?? "-", $r["descr"] ?? "",
gwc_addr($r["source"] ?? array()), gwc_addr($r["destination"] ?? array())));
}
break;
case "pf-disable": case "pf-enable": case "pf-delete":
case "pf-set-ports": case "pf-set-src":
if ($TARGET === "") gwc_fail("$ACTION needs <tracker|descr>");
$i = gwc_find_idx($nat, $TARGET);
if ($i < 0) gwc_fail("port-forward '$TARGET' not found (try pf-list)");
$p = $nat[$i];
gwc_out(sprintf(" target: #%d tracker=%s '%s' [%s] dst=%s -> %s:%s src=%s",
$i, $p["tracker"] ?? "", $p["descr"] ?? "", gwc_on_off($p),
gwc_addr($p["destination"] ?? array()), $p["target"] ?? "?",
$p["local-port"] ?? "?", gwc_addr($p["source"] ?? array("any"=>""))));
$assoc = $p["associated-rule-id"] ?? "";
if (!$APPLY) { gwc_out(" [dry-run] add --apply to write."); break; }
gwc_backup_config();
if ($ACTION === "pf-delete") {
array_splice($nat, $i, 1);
if ($assoc !== "") {
foreach ($flt as $j => $r) {
if (($r["associated-rule-id"] ?? "") === $assoc) { array_splice($flt, $j, 1); break; }
}
}
gwc_commit("gwc: pf-delete '" . ($p["descr"] ?? $TARGET) . "'");
break;
}
if ($ACTION === "pf-disable") {
$nat[$i]["disabled"] = "";
foreach ($flt as $j => $r) if (($r["associated-rule-id"] ?? "") === $assoc && $assoc !== "") $flt[$j]["disabled"] = "";
} elseif ($ACTION === "pf-enable") {
unset($nat[$i]["disabled"]);
foreach ($flt as $j => $r) if (($r["associated-rule-id"] ?? "") === $assoc && $assoc !== "") unset($flt[$j]["disabled"]);
} elseif ($ACTION === "pf-set-ports") {
if ($VAL1 === "") gwc_fail("pf-set-ports needs <dstport> [<localport>]");
if (!is_array($nat[$i]["destination"])) $nat[$i]["destination"] = array();
$nat[$i]["destination"]["port"] = $VAL1;
$nat[$i]["local-port"] = ($VAL2 !== "" ? $VAL2 : $VAL1);
} elseif ($ACTION === "pf-set-src") {
if ($VAL1 === "" || strcasecmp($VAL1, "any") === 0) {
$nat[$i]["source"] = array("any" => "");
} else {
$nat[$i]["source"] = array("network" => $VAL1);
}
}
gwc_commit("gwc: $ACTION '" . ($p["descr"] ?? $TARGET) . "'");
break;
case "fw-disable": case "fw-enable":
if ($TARGET === "") gwc_fail("$ACTION needs <tracker|descr>");
$i = gwc_find_idx($flt, $TARGET);
if ($i < 0) gwc_fail("filter rule '$TARGET' not found (try fw-list)");
$r = $flt[$i];
gwc_out(sprintf(" target: #%d tracker=%s %s if=%s '%s' [%s]",
$i, $r["tracker"] ?? "", $r["type"] ?? "?", $r["interface"] ?? "?",
$r["descr"] ?? "", gwc_on_off($r)));
if (!$APPLY) { gwc_out(" [dry-run] add --apply to write."); break; }
gwc_backup_config();
if ($ACTION === "fw-disable") $flt[$i]["disabled"] = ""; else unset($flt[$i]["disabled"]);
gwc_commit("gwc: $ACTION '" . ($r["descr"] !== "" ? $r["descr"] : ($r["tracker"] ?? $TARGET)) . "'");
break;
default:
gwc_fail("unknown action '$ACTION'");
}