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