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
This commit is contained in:
@@ -76,7 +76,22 @@ def _json_default(o):
|
||||
|
||||
|
||||
# --- table renderers ----------------------------------------------------------
|
||||
def _print_kv(d: dict) -> None:
|
||||
def _print_kv(d) -> None:
|
||||
# Tolerant: some API methods return a list (e.g. installation links,
|
||||
# endpoint tags) rather than a dict. Render either cleanly.
|
||||
if isinstance(d, list):
|
||||
for i, item in enumerate(d):
|
||||
if isinstance(item, dict):
|
||||
if i:
|
||||
print(" ---")
|
||||
for k, v in item.items():
|
||||
print(f" {k}: {v}")
|
||||
else:
|
||||
print(f" {item}")
|
||||
return
|
||||
if not isinstance(d, dict):
|
||||
print(f" {d}")
|
||||
return
|
||||
for k, v in d.items():
|
||||
print(f" {k}: {v}")
|
||||
|
||||
|
||||
@@ -523,7 +523,8 @@ class GravityZoneClient:
|
||||
)
|
||||
|
||||
def create_custom_group(self, name: str, parent_id: Optional[str] = None) -> Any:
|
||||
params: dict = {"name": name}
|
||||
# API param is `groupName` (verified live 2026-06-21), NOT `name`.
|
||||
params: dict = {"groupName": name}
|
||||
if parent_id:
|
||||
params["parentId"] = parent_id
|
||||
result = self._jsonrpc_request("network", "createCustomGroup", params)
|
||||
|
||||
@@ -43,20 +43,24 @@ path is Cascades — override with the script's vault-path arg per client.
|
||||
to ride out transient VPN flaps without wasting a sweep.
|
||||
- **[WIP] Client DHCP/DNS policy, deeper VPN (server) config, adoption *remediation* depth** — port-forward
|
||||
+ WAN firewall is now covered (gw-control); remaining gateway config (VPN server stand-up, DHCP/DNS) is future.
|
||||
- **[WORKING] pfSense gateway access via SSH** — `scripts/pfsense-ssh.sh <slug> audit|dhcp|run "<cmd>"`.
|
||||
- **[WORKING] pfSense gateway compatibility layer via SSH** — `scripts/pfsense-ssh.sh <slug> <verb>`.
|
||||
DECISION (Mike 2026-06-16): **no RESTAPI package needed** — VPN + SSH shell reads the same data and makes
|
||||
changes. Cred = `clients/<slug>/pfsense-firewall`. Validated live on Cascades (pfSense Plus 25.07; admin
|
||||
SSH = real shell). `audit`/`dhcp` are read-only; `run` executes arbitrary commands (incl. changes —
|
||||
operator-gated, no dry-run). Structured/gated CONTROL verbs (block-ips via easyrule, pf/fw toggles) are
|
||||
the remaining build — ROADMAP §E. (REST `pfsense-backend.sh` kept as a dormant optional alternative.)
|
||||
`gw-audit.sh`/`gw-control.sh` now **auto-dispatch** to it when a site has no UniFi gateway (num_gw=0) AND a
|
||||
pfSense API cred is vaulted at `clients/<slug>/pfsense-api` (or pass `--pfsense <slug>` when the UOS site
|
||||
name differs from the client slug) — the SAME verbs (`gw-audit`, `pf-list/disable/enable/set-ports`,
|
||||
`fw-list/disable/enable`, `block-ips`) work against either gateway vendor, so callers/docs don't fork.
|
||||
Decision (Howard 2026-06-16, per Mike's §E): REST API package backend + dispatch *inside* the existing
|
||||
verbs. One-time setup: `pfsense-backend.sh clients/<slug>/pfsense-api setup`. **Live validation pending** a
|
||||
reachable pfSense with the API pkg installed + key vaulted (REST endpoint paths follow the v2 schema and
|
||||
must be verified against the installed API version on first live run). Design/verb-map: `references/ROADMAP.md` §E.
|
||||
changes. Cred = `clients/<slug>/pfsense-firewall` (host + admin user/pass), system OpenSSH via askpass.
|
||||
Validated live on Cascades (pfSense Plus 25.07).
|
||||
- **Reads (no gate):** `audit` (WAN/DHCP/states/DNS/NIC health), `dhcp` (pool pressure), `pf-list`
|
||||
(NAT port-forwards), `fw-list` (filter rules), `showblock [--if wan]` (active easyrule blocks),
|
||||
`run "<cmd>"` (arbitrary; incl. changes — operator-gated, no dry-run).
|
||||
- **Writes (DRY-RUN default; add `--apply` to commit — `write_config` + `filter_configure`):**
|
||||
`fw-disable|fw-enable <tracker|descr>`, `block-ips|unblock <ip[,ip,...]> [--if wan]` (easyrule) — both
|
||||
**live-validated**; `pf-disable|pf-enable|pf-delete <tracker|descr>`, `pf-set-ports <dst> [<local>]`,
|
||||
`pf-set-src <cidr|any>` (port-forwards + associated filter rule) — built, **live-verify pending** (no
|
||||
box with forwards available yet). Filter rules match by `tracker` (the `id` field is empty on 25.07) or
|
||||
exact `descr`. Each write backs up `config.xml` first; writes drive `scripts/pfsense-gwc.php`.
|
||||
- **Dispatch:** `gw-audit.sh`/`gw-control.sh` auto-route the SAME verbs to this SSH backend when a
|
||||
`clients/<slug>/pfsense-firewall` cred is vaulted (dispatch runs before UOS site resolution, so a
|
||||
pfSense-only slug works; pass `--pfsense <slug>` if the UOS site name differs). The REST
|
||||
`pfsense-backend.sh` (`clients/<slug>/pfsense-api`) remains a **dormant fallback** only. Design/verb-map +
|
||||
pfSense PHP gotchas: `references/ROADMAP.md` §E.
|
||||
- **Per-client requirement:** `watch-ap`/`neighbor-collect`/`survey-collect`/`dfs-check` default the
|
||||
AP device-auth SSH cred to `clients/cascades-tucson/unifi-ap-ssh`; for another client, vault its
|
||||
own `clients/<x>/unifi-ap-ssh` and pass it as the script's vault-path arg.
|
||||
@@ -247,8 +251,9 @@ gw-control.sh <site> pf-set-src <name|id> <cidr|any> # restrict a forward to a
|
||||
gw-control.sh <site> fw-list # list firewall rules
|
||||
gw-control.sh <site> fw-disable|fw-enable <name|id> # toggle a WAN rule (e.g. a "GRE" accept)
|
||||
gw-control.sh <site> block-ips <ip[,ip,...]> [--group N] # WAN address-group + WAN_IN drop rule
|
||||
# pfSense sites (no UniFi gw): the same verbs auto-route to scripts/pfsense-backend.sh when a
|
||||
# clients/<slug>/pfsense-api cred is vaulted (or pass --pfsense <slug>). Run `pfsense-backend.sh <vp> setup` first.
|
||||
# pfSense sites (no UniFi gw): the same verbs auto-route to scripts/pfsense-ssh.sh (SSH backend) when a
|
||||
# clients/<slug>/pfsense-firewall cred is vaulted (or pass --pfsense <slug>). pfSense fw verbs match a rule by
|
||||
# `tracker` or exact `descr` (not the UniFi name/id), e.g.: gw-control.sh <slug> fw-disable "Block Guest to LAN"
|
||||
```
|
||||
Closing an internet-facing PPTP usually = `pf-set-ports VPN 80,443` (drop tcp 1723) **+** `fw-disable GRE`
|
||||
(PPTP needs both the 1723 forward and the GRE WAN_IN rule). Reads via Mongo (no cred); writes via the RW
|
||||
|
||||
@@ -125,17 +125,26 @@ with VPN + SSH we can read the same data and make changes." Confirmed: Cascades
|
||||
straight to a shell** (no menu gotcha). So the upgrade/package blocker is **MOOT** and the layer is
|
||||
**OFF HOLD**.
|
||||
|
||||
**STATUS: WORKING (read) via `scripts/pfsense-ssh.sh` — control verbs WIP.**
|
||||
- `pfsense-ssh.sh <slug> audit` — version/WAN-media/gateway-events/DHCP-exhaustion/states/DNS/load/NIC-errors.
|
||||
- `pfsense-ssh.sh <slug> dhcp` — pool utilization + "no free leases" check.
|
||||
- `pfsense-ssh.sh <slug> run "<cmd>"` — arbitrary command (reads OR changes; operator-gated, no dry-run).
|
||||
- Cred = `clients/<slug>/pfsense-firewall` (host + admin user/pass), system OpenSSH via askpass. Validated
|
||||
live on Cascades 2026-06-16 (the pfSense-health audit in the unifi-full-audit report came from this).
|
||||
**STATUS (2026-06-21): SSH backend control verbs DONE — reads + filter toggles + blocking validated
|
||||
live on Cascades; NAT (pf-*) built but live-verify pending.**
|
||||
Reads (no gate): `audit`, `dhcp`, `pf-list`, `fw-list`, `showblock [--if wan]`, `run "<cmd>"`.
|
||||
Writes (DRY-RUN default; `--apply` to commit — `write_config` + `filter_configure`):
|
||||
- `fw-disable|fw-enable <tracker|descr>` — toggle a filter rule (match by `tracker` or exact `descr`). **[x] live-validated.**
|
||||
- `block-ips|unblock <ip[,ip,...]> [--if wan]` — `easyrule block/unblock`. **[x] live-validated** (block→showblock→unblock cycle on a TEST-NET IP).
|
||||
- `pf-disable|pf-enable|pf-delete <tracker|descr>`, `pf-set-ports <dst> [<local>]`, `pf-set-src <cidr|any>` — port-forwards
|
||||
(+ the associated filter rule). **[~] built against the documented NAT schema; Cascades has 0 forwards so NOT yet
|
||||
live-verified — confirm field names on the first box that has port-forwards before trusting `--apply`.**
|
||||
- How it works: `pfsense-ssh.sh` ships `scripts/pfsense-gwc.php` to the box (base64 over the wire) and runs it under
|
||||
`php`, which bootstraps `$config` via `config.inc` (do NOT re-require util/functions/filter — "cannot redeclare"
|
||||
fatal). Each write backs up `/cf/conf/config.xml` to `/tmp` first; `write_config()` also keeps pfSense's own
|
||||
config history. Cred = `clients/<slug>/pfsense-firewall` (host + admin user/pass), system OpenSSH via askpass.
|
||||
- [x] **Dispatch rewired:** `gw-control.sh` / `gw-audit.sh` now prefer the SSH backend (keyed on
|
||||
`clients/<slug>/pfsense-firewall`) and route the same verbs to it; dispatch runs BEFORE UOS site resolution so a
|
||||
pfSense-only slug works. REST path is the dormant fallback.
|
||||
|
||||
**Remaining build (SSH backend):** named, reviewed, gated CONTROL verbs mapping the gw-control contract to
|
||||
SSH primitives — `block-ips` → `easyrule block wan <ip>`; `pf-list`/`fw-list` → read config.xml / `pfSsh.php`;
|
||||
toggles → config edit + `filter_configure`/`rc.reload_all`; backup config.xml first. Then optionally wire
|
||||
gw-audit/gw-control dispatch to the SSH backend when `clients/<slug>/pfsense-firewall` exists + num_gw=0.
|
||||
**Remaining (SSH backend):** [ ] live-verify the `pf-*` NAT verbs on a box that has port-forwards (e.g. once a
|
||||
client's pfSense with a real forward is reachable); [ ] optional `pf-add`/create verbs if we ever need to *add*
|
||||
forwards (today we only need to close/scope existing exposure).
|
||||
|
||||
**Superseded/optional:** the REST `pfsense-backend.sh` + `clients/<slug>/pfsense-api` path stays in-tree
|
||||
as a dormant alternative (works if a site ever installs the pkg) but is no longer the plan.
|
||||
@@ -156,3 +165,10 @@ via `easyrule` (lowest-risk write, directly useful for the same brute-force cont
|
||||
precompute + inject a compact flat **string**.
|
||||
- AP-side SSH: `sshpass` or `SSH_ASKPASS` fallback; `</dev/null` in `while read` loops; **foreground**.
|
||||
- Windows text-mode file writes add CRLF → strip `\r` after bash `read`.
|
||||
- pfSense PHP (`pfsense-gwc.php`): bootstrap with `require_once("config.inc")` ONLY — it pulls in
|
||||
`write_config()`/`filter_configure()`; re-requiring util/functions/filter → "cannot redeclare" fatal.
|
||||
`display_errors` is **Off**, so php fatals are SILENT — always run php with `2>&1` AND `ini_set("display_errors","1")`
|
||||
or you get rc=255 with no message. pfSense already defines `backup_config()` (and many generic names) → **prefix
|
||||
all helper functions** (`gwc_*`). `pfSsh.php` does NOT eval piped ad-hoc code (only its built-in `playback`
|
||||
scripts) — use `php <file>` instead. Filter rules are keyed on `tracker` (the `id` field is ""); enabled/disabled
|
||||
= PRESENCE of a `disabled` key. Ship the helper via `base64 | openssl base64 -A -d` (both present on FreeBSD).
|
||||
|
||||
@@ -101,19 +101,32 @@ PY
|
||||
# pfSense gateway/WAN/DHCP audit via the backend so one `gw-audit <site>` covers either gateway vendor.
|
||||
NGW="$(cat "$TMP/ngw" 2>/dev/null || echo 1)"
|
||||
if [ "$NGW" = "0" ]; then
|
||||
cands=()
|
||||
[ -n "$PFARG" ] && { case "$PFARG" in */*) cands+=("$PFARG");; *) cands+=("clients/$PFARG/pfsense-api");; esac; }
|
||||
cands+=("clients/$SITEARG/pfsense-api")
|
||||
pf_vp=""
|
||||
for cand in "${cands[@]}"; do
|
||||
if [ -n "$(bash "$VAULT" get-field "$cand" credentials.apikey 2>/dev/null || bash "$VAULT" get-field "$cand" apikey 2>/dev/null)" ]; then pf_vp="$cand"; break; fi
|
||||
# PREFERRED: SSH backend (Mike's 2026-06-16 decision), keyed on clients/<slug>/pfsense-firewall.
|
||||
ssh_slug=""
|
||||
for s in "$PFARG" "$SITEARG"; do
|
||||
[ -n "$s" ] || continue
|
||||
case "$s" in */*) continue;; esac
|
||||
if [ -n "$(bash "$VAULT" get-field "clients/$s/pfsense-firewall" credentials.password 2>/dev/null)" ]; then ssh_slug="$s"; break; fi
|
||||
done
|
||||
if [ -n "$pf_vp" ]; then
|
||||
echo; echo "[INFO] pfSense gateway cred found (vault:$pf_vp) -> pfSense gateway audit:"
|
||||
bash "$REPO/.claude/skills/unifi-wifi/scripts/pfsense-backend.sh" "$pf_vp" audit || true
|
||||
if [ -n "$ssh_slug" ]; then
|
||||
echo; echo "[INFO] pfSense gateway (SSH cred vault:clients/$ssh_slug/pfsense-firewall) -> pfSense gateway audit:"
|
||||
bash "$REPO/.claude/skills/unifi-wifi/scripts/pfsense-ssh.sh" "$ssh_slug" audit || true
|
||||
else
|
||||
echo; echo "[INFO] gateway is third-party (pfSense?). For pfSense WAN/DHCP/firewall audit, vault an API"
|
||||
echo " cred then re-run (pass --pfsense <slug> if the UOS site name differs from the client slug):"
|
||||
echo " bash .claude/skills/unifi-wifi/scripts/pfsense-backend.sh clients/<slug>/pfsense-api setup"
|
||||
# REST fallback (dormant): only if a pfSense-api cred is vaulted.
|
||||
cands=()
|
||||
[ -n "$PFARG" ] && { case "$PFARG" in */*) cands+=("$PFARG");; *) cands+=("clients/$PFARG/pfsense-api");; esac; }
|
||||
cands+=("clients/$SITEARG/pfsense-api")
|
||||
pf_vp=""
|
||||
for cand in "${cands[@]}"; do
|
||||
if [ -n "$(bash "$VAULT" get-field "$cand" credentials.apikey 2>/dev/null || bash "$VAULT" get-field "$cand" apikey 2>/dev/null)" ]; then pf_vp="$cand"; break; fi
|
||||
done
|
||||
if [ -n "$pf_vp" ]; then
|
||||
echo; echo "[INFO] pfSense gateway REST cred found (vault:$pf_vp) -> pfSense gateway audit (dormant REST path):"
|
||||
bash "$REPO/.claude/skills/unifi-wifi/scripts/pfsense-backend.sh" "$pf_vp" audit || true
|
||||
else
|
||||
echo; echo "[INFO] gateway is third-party (pfSense?). For pfSense WAN/DHCP/firewall audit, vault an SSH"
|
||||
echo " cred at clients/<slug>/pfsense-firewall (host + credentials.username/password), then re-run"
|
||||
echo " (pass --pfsense <slug> if the UOS site name differs from the client slug)."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -40,21 +40,30 @@ while [ $# -gt 0 ]; do
|
||||
esac
|
||||
done
|
||||
|
||||
# resolve SITE (24-hex id, or fuzzy name via --sites)
|
||||
if [[ "$SITEARG" =~ ^[0-9a-f]{24}$ ]]; then SITE="$SITEARG"; else
|
||||
SITE="$(bash "$UOS" --sites 2>/dev/null | grep -vi 'pq.html' | grep -i "$SITEARG" | awk '{print $1}' | head -1)"; fi
|
||||
[ -n "$SITE" ] || { echo "[ERROR] site not found: $SITEARG"; exit 1; }
|
||||
MONGO_CLEAN='grep -viE pq.html|post-quantum|store now|server may need'
|
||||
|
||||
# ---------- pfSense dispatch (ROADMAP §E): gateway is pfSense (vaulted API cred) -> same verbs, pfSense backend ----------
|
||||
# pfSense isn't on the UOS controller, so the UniFi Mongo/REST path below would find nothing. If a
|
||||
# pfSense REST API cred is vaulted for this site, route the SAME verb (pf-*/fw-*/block-ips) to it.
|
||||
# ---------- pfSense dispatch (ROADMAP §E): gateway is pfSense -> same verbs, pfSense backend ----------
|
||||
# Runs BEFORE UOS site resolution: a pfSense-only site is keyed by client slug (not a UOS site name),
|
||||
# so the SAME verb (pf-*/fw-*/block-ips) routes to a pfSense backend here. PREFERRED = SSH backend
|
||||
# (Mike's 2026-06-16 decision: no REST package needed), keyed on a vaulted clients/<slug>/pfsense-firewall
|
||||
# cred. The REST backend (pfsense-backend.sh, clients/<slug>/pfsense-api) is kept only as a dormant fallback.
|
||||
ssh_slug=""
|
||||
for s in "$PFARG" "$SITEARG"; do
|
||||
[ -n "$s" ] || continue
|
||||
case "$s" in */*) continue;; esac # SSH backend takes a slug, not a vault path
|
||||
if [ -n "$(bash "$VAULT" get-field "clients/$s/pfsense-firewall" credentials.password 2>/dev/null)" ]; then ssh_slug="$s"; break; fi
|
||||
done
|
||||
if [ -n "$ssh_slug" ]; then
|
||||
echo "[INFO] pfSense gateway (SSH cred vault:clients/$ssh_slug/pfsense-firewall) -> dispatching '$ACT' to pfsense-ssh.sh"
|
||||
args=("$ssh_slug" "$ACT"); [ ${#POS[@]} -gt 0 ] && args+=("${POS[@]}")
|
||||
[ "$APPLY" = "1" ] && args+=(--apply)
|
||||
exec bash "$REPO/.claude/skills/unifi-wifi/scripts/pfsense-ssh.sh" "${args[@]}"
|
||||
fi
|
||||
# REST fallback (dormant): only if a pfSense-api cred is vaulted and no SSH cred was found.
|
||||
pf_cands=()
|
||||
[ -n "$PFARG" ] && { case "$PFARG" in */*) pf_cands+=("$PFARG");; *) pf_cands+=("clients/$PFARG/pfsense-api");; esac; }
|
||||
pf_cands+=("clients/$SITEARG/pfsense-api")
|
||||
for cand in "${pf_cands[@]}"; do
|
||||
if [ -n "$(bash "$VAULT" get-field "$cand" credentials.apikey 2>/dev/null || bash "$VAULT" get-field "$cand" apikey 2>/dev/null)" ]; then
|
||||
echo "[INFO] pfSense gateway (cred vault:$cand) -> dispatching '$ACT' to pfsense-backend.sh"
|
||||
echo "[INFO] pfSense gateway (REST cred vault:$cand) -> dispatching '$ACT' to pfsense-backend.sh (dormant REST path)"
|
||||
args=("$cand" "$ACT"); [ ${#POS[@]} -gt 0 ] && args+=("${POS[@]}")
|
||||
[ "$ACT" = "block-ips" ] && args+=(--alias "${GROUP// /_}")
|
||||
[ "$APPLY" = "1" ] && args+=(--apply)
|
||||
@@ -62,6 +71,12 @@ for cand in "${pf_cands[@]}"; do
|
||||
fi
|
||||
done
|
||||
|
||||
# resolve SITE (24-hex id, or fuzzy name via --sites) — UniFi gateway path below
|
||||
if [[ "$SITEARG" =~ ^[0-9a-f]{24}$ ]]; then SITE="$SITEARG"; else
|
||||
SITE="$(bash "$UOS" --sites 2>/dev/null | grep -vi 'pq.html' | grep -i "$SITEARG" | awk '{print $1}' | head -1)"; fi
|
||||
[ -n "$SITE" ] || { echo "[ERROR] site not found: $SITEARG"; exit 1; }
|
||||
MONGO_CLEAN='grep -viE pq.html|post-quantum|store now|server may need'
|
||||
|
||||
# ---------- READ-ONLY listers (Mongo, no cred) ----------
|
||||
pf_list() {
|
||||
cat <<JS | bash "$UOS" 2>&1 | grep -viE 'pq.html|post-quantum|store now|server may need'
|
||||
|
||||
174
.claude/skills/unifi-wifi/scripts/pfsense-gwc.php
Normal file
174
.claude/skills/unifi-wifi/scripts/pfsense-gwc.php
Normal file
@@ -0,0 +1,174 @@
|
||||
<?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'");
|
||||
}
|
||||
@@ -9,17 +9,37 @@
|
||||
# heredoc so awk/quoting is clean.
|
||||
#
|
||||
# Usage:
|
||||
# pfsense-ssh.sh <slug> audit # read-only health: version/WAN/DHCP-exhaustion/DNS/states/load
|
||||
# pfsense-ssh.sh <slug> dhcp # DHCP pool utilization + "no free leases" check
|
||||
# pfsense-ssh.sh <slug> run "<command>" # arbitrary command (CAN mutate — operator-gated; e.g. run "pfctl -si")
|
||||
# pfsense-ssh.sh <slug> shell # (prints the interactive ssh command to paste)
|
||||
# READ (no gate):
|
||||
# pfsense-ssh.sh <slug> audit # read-only health: version/WAN/DHCP-exhaustion/DNS/states/load
|
||||
# pfsense-ssh.sh <slug> dhcp # DHCP pool utilization + "no free leases" check
|
||||
# pfsense-ssh.sh <slug> pf-list # list NAT port-forwards (WAN exposure)
|
||||
# pfsense-ssh.sh <slug> fw-list # list filter (firewall) rules
|
||||
# pfsense-ssh.sh <slug> showblock [--if wan] # active easyrule blocks
|
||||
# pfsense-ssh.sh <slug> run "<command>" # arbitrary command (CAN mutate — operator-gated)
|
||||
# pfsense-ssh.sh <slug> shell # (prints the interactive ssh command to paste)
|
||||
# WRITE (DRY-RUN by default; add --apply to commit — write_config + filter reload):
|
||||
# pfsense-ssh.sh <slug> pf-disable|pf-enable|pf-delete <tracker|descr> [--apply]
|
||||
# pfsense-ssh.sh <slug> pf-set-ports <tracker|descr> <dstport> [<localport>] [--apply]
|
||||
# pfsense-ssh.sh <slug> pf-set-src <tracker|descr> <cidr|any> [--apply]
|
||||
# pfsense-ssh.sh <slug> fw-disable|fw-enable <tracker|descr> [--apply]
|
||||
# pfsense-ssh.sh <slug> block-ips|unblock <ip[,ip,...]> [--if wan] [--apply] # easyrule
|
||||
# The pf-*/fw-* verbs drive scripts/pfsense-gwc.php (bootstraps $config); block/unblock use `easyrule`.
|
||||
# NOTE: `run` executes whatever you pass, including changes — there is no dry-run for it. For repeatable
|
||||
# changes prefer adding a named, reviewed verb here over ad-hoc `run`.
|
||||
# changes prefer the named, reviewed verbs above over ad-hoc `run`.
|
||||
set -uo pipefail
|
||||
REPO="$(git rev-parse --show-toplevel 2>/dev/null || echo .)"
|
||||
VAULT="$REPO/.claude/scripts/vault.sh"
|
||||
SLUG="${1:?usage: pfsense-ssh.sh <slug> <audit|dhcp|run|shell> [args]}"
|
||||
ACT="${2:?action: audit|dhcp|run|shell}"; shift 2 || true
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
GWC_PHP="$HERE/pfsense-gwc.php"
|
||||
SLUG="${1:?usage: pfsense-ssh.sh <slug> <action> [args] [--apply]}"
|
||||
ACT="${2:?action: audit|dhcp|pf-list|fw-list|pf-*|fw-*|block-ips|unblock|showblock|run|shell}"; shift 2 || true
|
||||
RAWARGS=("$@") # preserved verbatim for `run`
|
||||
APPLY=0; BLOCK_IF="wan"; POS=()
|
||||
while [ $# -gt 0 ]; do case "$1" in
|
||||
--apply) APPLY=1; shift;;
|
||||
--if) BLOCK_IF="${2:?--if needs an interface}"; shift 2;;
|
||||
*) POS+=("$1"); shift;;
|
||||
esac; done
|
||||
VP="clients/$SLUG/pfsense-firewall"
|
||||
HOST="$(bash "$VAULT" get-field "$VP" host 2>/dev/null || bash "$VAULT" get-field "$VP" credentials.host 2>/dev/null || true)"
|
||||
U="$(bash "$VAULT" get-field "$VP" credentials.username 2>/dev/null || true)"
|
||||
@@ -36,11 +56,45 @@ pfssh(){ SSH_ASKPASS="$ASKP" SSH_ASKPASS_REQUIRE=force DISPLAY=:0 ssh \
|
||||
-o PreferredAuthentications=password -o PubkeyAuthentication=no -o NumberOfPasswordPrompts=1 \
|
||||
"$U@$HOST" 'sh -s' 2>/dev/null; }
|
||||
|
||||
# shell-quote a value for safe embedding in the remote sh script
|
||||
sq(){ printf "'%s'" "$(printf '%s' "${1:-}" | sed "s/'/'\\\\''/g")"; }
|
||||
# ship pfsense-gwc.php to /tmp on the box (base64 over the wire) and run it with argv: action apply T V1 V2
|
||||
run_gwc(){
|
||||
[ -f "$GWC_PHP" ] || { echo "[ERROR] helper not found: $GWC_PHP"; exit 1; }
|
||||
local b64; b64="$(base64 "$GWC_PHP" | tr -d '\n')"
|
||||
{ printf 'A=%s; AP=%s; T=%s; V1=%s; V2=%s\n' "$(sq "$1")" "$(sq "$2")" "$(sq "$3")" "$(sq "$4")" "$(sq "$5")"
|
||||
printf 'printf %%s %s | openssl base64 -A -d > /tmp/pfsense-gwc.php\n' "$(sq "$b64")"
|
||||
printf 'php /tmp/pfsense-gwc.php "$A" "$AP" "$T" "$V1" "$V2" 2>&1\n' # 2>&1: surface php fatals (display_errors is Off on pfSense)
|
||||
printf 'rm -f /tmp/pfsense-gwc.php\n'
|
||||
} | pfssh
|
||||
}
|
||||
|
||||
echo "[INFO] pfSense $ACT @ $U@$HOST (vault:$VP)"
|
||||
case "$ACT" in
|
||||
run)
|
||||
CMD="$*"; [ -n "$CMD" ] || { echo "[ERROR] run needs a command"; exit 1; }
|
||||
CMD="${RAWARGS[*]}"; [ -n "$CMD" ] || { echo "[ERROR] run needs a command"; exit 1; }
|
||||
printf '%s\n' "$CMD" | pfssh ;;
|
||||
|
||||
pf-list) run_gwc pf-list 0 "" "" "" ;;
|
||||
fw-list) run_gwc fw-list 0 "" "" "" ;;
|
||||
|
||||
pf-disable|pf-enable|pf-delete|pf-set-ports|pf-set-src|fw-disable|fw-enable)
|
||||
[ -n "${POS[0]:-}" ] || { echo "[ERROR] $ACT needs <tracker|descr>"; exit 1; }
|
||||
echo "[INFO] mode=$([ "$APPLY" = 1 ] && echo APPLY || echo DRY-RUN)"
|
||||
run_gwc "$ACT" "$APPLY" "${POS[0]:-}" "${POS[1]:-}" "${POS[2]:-}" ;;
|
||||
|
||||
block-ips|unblock)
|
||||
IPS="${POS[0]:-}"; [ -n "$IPS" ] || { echo "[ERROR] $ACT needs <ip[,ip,...]>"; exit 1; }
|
||||
VERB=$([ "$ACT" = block-ips ] && echo block || echo unblock)
|
||||
echo "[INFO] easyrule $VERB if=$BLOCK_IF ips=$IPS mode=$([ "$APPLY" = 1 ] && echo APPLY || echo DRY-RUN)"
|
||||
if [ "$APPLY" != 1 ]; then
|
||||
echo " [dry-run] would run (per ip): easyrule $VERB $BLOCK_IF <ip>. Add --apply to write."; exit 0; fi
|
||||
CMDS=""; IFS=',' read -ra ARR <<< "$IPS"
|
||||
for ip in "${ARR[@]}"; do ip="$(printf '%s' "$ip" | tr -d ' ')"; [ -n "$ip" ] && CMDS+="echo \"-- $VERB $ip\"; easyrule $VERB $BLOCK_IF $ip; "; done
|
||||
printf '%s\n' "$CMDS" | pfssh ;;
|
||||
|
||||
showblock)
|
||||
printf 'easyrule showblock %s\n' "$BLOCK_IF" | pfssh ;;
|
||||
dhcp)
|
||||
pfssh <<'RSCRIPT'
|
||||
echo "## DHCP backend"; { pgrep -lf dhcpd >/dev/null && echo "ISC dhcpd active"; }; { pgrep -lf kea >/dev/null && echo "Kea active"; }
|
||||
@@ -64,5 +118,5 @@ echo "## mbuf"; netstat -m 2>/dev/null | head -1
|
||||
echo "## NIC errors (Ierrs/Oerrs/Coll)"; netstat -i 2>/dev/null | awk 'NR==1 || ($1 ~ /^(igc|em|ix|vmx)[0-9]$/)'
|
||||
RSCRIPT
|
||||
;;
|
||||
*) echo "action: audit|dhcp|run|shell"; exit 1;;
|
||||
*) echo "action: audit|dhcp|pf-list|fw-list|pf-disable|pf-enable|pf-delete|pf-set-ports|pf-set-src|fw-disable|fw-enable|block-ips|unblock|showblock|run|shell"; exit 1;;
|
||||
esac
|
||||
|
||||
@@ -17,6 +17,10 @@ Categories (the `[type]` tag): _(none)_ = skill/command execution failure ·
|
||||
|
||||
<!-- Append entries below this line -->
|
||||
|
||||
2026-06-21 | Howard-Home | bitdefender | GravityZone API error [network.createCustomGroup]: The required parameter is missing : groupName [ctx: cmd=raw]
|
||||
|
||||
2026-06-21 | Howard-Home | bitdefender | GravityZone API error [network.createCustomGroup]: One or more parameters are not expected: name [ctx: cmd=make-group]
|
||||
|
||||
2026-06-21 | Howard-Home | bitdefender | GravityZone API error [policies.getPolicyDetails]: Invalid value for 'policyId' parameter. [ctx: cmd=policy]
|
||||
|
||||
2026-06-21 | Howard-Home | bitdefender | GravityZone API error [network.getManagedEndpointDetails]: Invalid value for 'endpointId' parameter. Expected format: 24-char hex ID [ctx: cmd=endpoint]
|
||||
|
||||
Reference in New Issue
Block a user