From 1118594cd25192682c32243e5eee56e9a837d269 Mon Sep 17 00:00:00 2001 From: Howard Enos Date: Tue, 16 Jun 2026 13:51:10 -0700 Subject: [PATCH] =?UTF-8?q?unifi-wifi:=20pfSense=20gateway=20compat=20laye?= =?UTF-8?q?r=20(=C2=A7E)=20=E2=80=94=20REST=20backend=20+=20dispatch=20ins?= =?UTF-8?q?ide=20gw-audit/gw-control?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Howard's decision (2026-06-16, "try what Mike wanted"): Mike's §E open decisions resolved as REST API package backend + dispatch INSIDE the existing gateway verbs (his lean), not sibling scripts. - NEW scripts/pfsense-backend.sh: pfSense REST API (pfSense-pkg-RESTAPI v2, X-API-Key) driver exposing the same verbs as gw-control (audit, pf-list/disable/enable/delete/set-ports, fw-list/disable/enable, block-ips) + a `setup` helper. Writes --apply-gated with per-object rollback to .claude/tmp + firewall/apply. - gw-audit.sh: when num_gw=0 and a clients//pfsense-api cred is vaulted (or --pfsense ), appends the pfSense WAN/DHCP/firewall audit; else prints the setup hint. (captures num_gw to gate.) - gw-control.sh: same-verb auto-dispatch to pfsense-backend when a pfSense cred resolves for the site. - SKILL.md [PROPOSED]->[SCAFFOLDED]; ROADMAP §E open decisions marked resolved. STATUS: scaffolded. BLOCKED/setup/no-cred paths tested; gw-audit dispatch validated live (Cascades num_gw=0 -> hint). Live REST calls pending a reachable pfSense with the API pkg + a vaulted key; v2 endpoint paths must be verified against the installed API version on first live run. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/skills/unifi-wifi/SKILL.md | 15 +- .../skills/unifi-wifi/references/ROADMAP.md | 24 +- .claude/skills/unifi-wifi/scripts/gw-audit.sh | 31 ++- .../skills/unifi-wifi/scripts/gw-control.sh | 19 +- .../unifi-wifi/scripts/pfsense-backend.sh | 217 ++++++++++++++++++ 5 files changed, 291 insertions(+), 15 deletions(-) create mode 100644 .claude/skills/unifi-wifi/scripts/pfsense-backend.sh diff --git a/.claude/skills/unifi-wifi/SKILL.md b/.claude/skills/unifi-wifi/SKILL.md index e8d29af..3205ce1 100644 --- a/.claude/skills/unifi-wifi/SKILL.md +++ b/.claude/skills/unifi-wifi/SKILL.md @@ -43,10 +43,15 @@ 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. -- **[PROPOSED] pfSense gateway compatibility layer** — the gateway verbs (gw-audit / gw-control / VPN) speak - UniFi REST today; the common "UniFi APs+switches behind a pfSense gateway" topology (Cascades, our office, - several clients) needs the same verbs via a pfSense driver (REST-API pkg or stock `easyrule`/`pfSsh.php`). - Design + backend options + verb-mapping captured in `references/ROADMAP.md` § E. +- **[SCAFFOLDED] pfSense gateway compatibility layer** — `scripts/pfsense-backend.sh` (REST API pkg backend). + `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//pfsense-api` (or pass `--pfsense ` 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//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. - **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//unifi-ap-ssh` and pass it as the script's vault-path arg. @@ -204,6 +209,8 @@ gw-control.sh pf-set-src # restrict a forward to a gw-control.sh fw-list # list firewall rules gw-control.sh fw-disable|fw-enable # toggle a WAN rule (e.g. a "GRE" accept) gw-control.sh block-ips [--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//pfsense-api cred is vaulted (or pass --pfsense ). Run `pfsense-backend.sh setup` first. ``` 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 diff --git a/.claude/skills/unifi-wifi/references/ROADMAP.md b/.claude/skills/unifi-wifi/references/ROADMAP.md index 03c8a43..be78dce 100644 --- a/.claude/skills/unifi-wifi/references/ROADMAP.md +++ b/.claude/skills/unifi-wifi/references/ROADMAP.md @@ -109,14 +109,22 @@ stay identical; only the driver differs: `…/pfsense-openvpn-howard`) and **our office** (`infrastructure/pfsense-firewall`) — i.e. the auth side exists for at least two sites; per-client pfSense cred vaulting mirrors the AP-SSH-cred pattern. -**Open decisions (resolve before building):** -- [ ] **Primary backend:** standardize on the REST API package (clean, an install step per box) vs. - stock SSH `easyrule`/`pfSsh.php` (no install, messier, but works on every pfSense today)? -- [ ] **Where the abstraction lives:** dispatch *inside* `gw-audit.sh`/`gw-control.sh` on detected gw type, - vs. sibling `pf-gw-audit.sh`/`pf-gw-control.sh` with a shared verb contract. (Lean: dispatch inside, so - callers/SKILL docs don't fork.) -- [ ] **Reach + rollback:** many pfSense gws are behind the site (need Tailscale/site-VPN reach, like the - AP collectors); every write backs up config.xml first and reloads atomically (`filter_configure`). +**Open decisions — RESOLVED (Howard 2026-06-16, "try what Mike wanted unless we hit a real roadblock"):** +- [x] **Primary backend:** **REST API package** (`pfSense-pkg-RESTAPI` v2, `X-API-Key`). SSH + `easyrule`/`pfSsh.php` kept as the documented fallback for boxes that can't install the pkg. +- [x] **Where the abstraction lives:** **dispatch *inside*** `gw-audit.sh`/`gw-control.sh` (keyed on + num_gw=0 + a vaulted `clients//pfsense-api` cred, or `--pfsense `), so callers/SKILL docs + don't fork. Implemented in `scripts/pfsense-backend.sh` + dispatch hooks in both gw scripts. +- [ ] **Reach + rollback:** many pfSense gws are behind the site (need site-VPN reach, like the AP + collectors). DONE: writes are `--apply`-gated and save a per-object rollback to `.claude/tmp/`, and + pfSense `firewall/apply` is called after each change. config.xml backup-first is the SSH-fallback's job. + +**STATUS: SCAFFOLDED — live validation pending.** Build complete (backend + dispatch + setup helper); +the BLOCKED/setup/no-cred-hint paths are tested. The live REST calls (audit/pf-*/fw-*/block-ips) need a +reachable pfSense with the API pkg installed + a key vaulted; REST endpoint paths follow the v2 schema and +must be verified against the installed API version on first live run. Cascades + ACG office have pfSense +web creds vaulted (`clients/cascades-tucson/pfsense-firewall`, `infrastructure/pfsense-firewall`) — still +need the API key added at `clients//pfsense-api`. - [ ] **Site→gateway map:** record per-site gateway type + access (UOS site_id ↔ pfSense host/cred) so the driver auto-selects. Could live alongside `sites.sh` output. - [ ] **VPN convergence:** the "Deeper VPN — gateway-hosted VPN server" item (C) is *easier and better* on diff --git a/.claude/skills/unifi-wifi/scripts/gw-audit.sh b/.claude/skills/unifi-wifi/scripts/gw-audit.sh index 2c182c2..cd8b7a3 100644 --- a/.claude/skills/unifi-wifi/scripts/gw-audit.sh +++ b/.claude/skills/unifi-wifi/scripts/gw-audit.sh @@ -14,7 +14,9 @@ set -uo pipefail REPO="$(git rev-parse --show-toplevel 2>/dev/null || echo .)" UOS="$REPO/.claude/scripts/uos-mongo.sh"; VAULT="$REPO/.claude/scripts/vault.sh" HOST="${UOS_HOST:-172.16.3.29}"; PORT="${UOS_HTTPS_PORT:-11443}" -SITEARG="${1:?usage: gw-audit.sh }" +SITEARG="${1:?usage: gw-audit.sh [--pfsense ]}"; shift || true +PFARG="" # optional: pfSense client slug (or full vault path) when UOS site name != client slug +while [ $# -gt 0 ]; do case "$1" in --pfsense) PFARG="${2:?--pfsense needs a slug/vault-path}"; shift 2;; *) shift;; esac; done TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT U="$(bash "$VAULT" get-field infrastructure/uos-server-network-api-rw credentials.username 2>/dev/null)" P="$(bash "$VAULT" get-field infrastructure/uos-server-network-api-rw credentials.password 2>/dev/null)" @@ -31,8 +33,9 @@ for s in d: echo "[INFO] gateway/health audit: site=$SHORT" curl -sk -b "$CJ" "$base/proxy/network/api/s/$SHORT/stat/health" -o "$TMP/health.json" curl -sk -b "$CJ" "$base/proxy/network/api/s/$SHORT/stat/device" -o "$TMP/dev.json" +export NGW_FILE="$TMP/ngw" python - "$TMP/health.json" "$TMP/dev.json" <<'PY' -import sys,json +import sys,json,os H={h['subsystem']:h for h in json.load(open(sys.argv[1])).get('data',[])} devs=json.load(open(sys.argv[2])).get('data',[]) flags=[] @@ -41,6 +44,8 @@ wan=H.get('wan',{}); www=H.get('www',{}); wlan=H.get('wlan',{}); lan=H.get('lan' print("\n== WAN / Internet ==") ngw=wan.get('num_gw',0) +try: open(os.environ['NGW_FILE'],'w').write(str(ngw)) +except Exception: pass if not ngw: print(" no UniFi gateway at this site (third-party firewall, e.g. pfSense)") else: @@ -90,3 +95,25 @@ if flags: else: print(" [OK] gateway/WAN/adoption all healthy") PY + +# ---------- pfSense gateway augmentation (ROADMAP §E: dispatch when there's no UniFi gateway) ---------- +# A pfSense site shows num_gw=0 above (no UniFi gateway). If a pfSense REST API cred is vaulted, run the +# pfSense gateway/WAN/DHCP audit via the backend so one `gw-audit ` 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 + 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 + 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 if the UOS site name differs from the client slug):" + echo " bash .claude/skills/unifi-wifi/scripts/pfsense-backend.sh clients//pfsense-api setup" + fi +fi diff --git a/.claude/skills/unifi-wifi/scripts/gw-control.sh b/.claude/skills/unifi-wifi/scripts/gw-control.sh index d881b15..2b460e1 100644 --- a/.claude/skills/unifi-wifi/scripts/gw-control.sh +++ b/.claude/skills/unifi-wifi/scripts/gw-control.sh @@ -30,11 +30,12 @@ SITEARG="${1:?usage: gw-control.sh [args] [--apply]}" ACT="${2:?action: pf-list|pf-disable|pf-enable|pf-delete|pf-set-ports|pf-set-src|fw-list|fw-disable|fw-enable|block-ips}" shift 2 -APPLY=0; GROUP="gw-control blocklist"; POS=() +APPLY=0; GROUP="gw-control blocklist"; PFARG=""; POS=() while [ $# -gt 0 ]; do case "$1" in --apply) APPLY=1; shift;; --group) GROUP="${2:?--group needs a name}"; shift 2;; + --pfsense) PFARG="${2:?--pfsense needs a slug/vault-path}"; shift 2;; # route to pfSense backend (§E) *) POS+=("$1"); shift;; esac done @@ -45,6 +46,22 @@ if [[ "$SITEARG" =~ ^[0-9a-f]{24}$ ]]; then SITE="$SITEARG"; else [ -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. +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" + args=("$cand" "$ACT"); [ ${#POS[@]} -gt 0 ] && args+=("${POS[@]}") + [ "$ACT" = "block-ips" ] && args+=(--alias "${GROUP// /_}") + [ "$APPLY" = "1" ] && args+=(--apply) + exec bash "$REPO/.claude/skills/unifi-wifi/scripts/pfsense-backend.sh" "${args[@]}" + fi +done + # ---------- READ-ONLY listers (Mongo, no cred) ---------- pf_list() { cat <&1 | grep -viE 'pq.html|post-quantum|store now|server may need' diff --git a/.claude/skills/unifi-wifi/scripts/pfsense-backend.sh b/.claude/skills/unifi-wifi/scripts/pfsense-backend.sh new file mode 100644 index 0000000..cee97f3 --- /dev/null +++ b/.claude/skills/unifi-wifi/scripts/pfsense-backend.sh @@ -0,0 +1,217 @@ +#!/usr/bin/env bash +# pfsense-backend.sh — pfSense gateway driver (REST API package backend) for the unifi-wifi skill. +# This is the pfSense half of the "gateway compatibility layer" (ROADMAP §E): the SAME gateway verbs +# that gw-audit.sh/gw-control.sh expose for UniFi gateways, implemented against a pfSense firewall. +# gw-audit.sh / gw-control.sh auto-DISPATCH here when a site has no UniFi gateway (num_gw=0) AND a +# pfSense API cred is vaulted — callers/SKILL docs don't fork (Mike's §E "dispatch inside" lean). +# +# BACKEND = pfSense REST API package (pfSense-pkg-RESTAPI v2, jaredhendrickson13/pfsense-api). +# Auth: header X-API-Key: Base: /api/v2/ +# One-time per-site setup (see `setup` action below): install the pkg, mint a key, vault it. +# NOTE: endpoint paths follow the v2 schema; on the FIRST live run VERIFY them against the installed +# API version (the pkg evolves). Writes are gated behind --apply and auto-save a rollback, exactly +# like the UniFi side. Use a READ-ONLY key for audit; writes need a key with write privilege. +# +# Usage: +# bash pfsense-backend.sh audit +# bash pfsense-backend.sh pf-list +# bash pfsense-backend.sh pf-disable|pf-enable|pf-delete [--apply] +# bash pfsense-backend.sh pf-set-ports [--apply] +# bash pfsense-backend.sh fw-list +# bash pfsense-backend.sh fw-disable|fw-enable [--apply] +# bash pfsense-backend.sh block-ips [--alias NAME] [--apply] +# bash pfsense-backend.sh setup # print the one-time install+vault steps +set -uo pipefail +REPO="$(git rev-parse --show-toplevel 2>/dev/null || echo .)" +VAULT="$REPO/.claude/scripts/vault.sh" +VP="${1:?usage: pfsense-backend.sh [args] [--apply]}" +ACT="${2:?action: audit|pf-list|pf-disable|pf-enable|pf-delete|pf-set-ports|fw-list|fw-disable|fw-enable|block-ips|setup}" +shift 2 +APPLY=0; ALIAS="gw_control_blocklist"; POS=() +while [ $# -gt 0 ]; do case "$1" in + --apply) APPLY=1; shift;; + --alias) ALIAS="${2:?--alias needs a name}"; shift 2;; + *) POS+=("$1"); shift;; esac; done + +setup_msg(){ cat < System > Package Manager > Available Packages: install 'RESTAPI' + 2) System > REST API > Settings: enable; Auth Method = API Key + 3) System > REST API > Keys: create a key (READ-ONLY for audit; a write-capable key for pf-/fw-/block-ips) + 4) vault it (url = https://, apikey = the key): + bash $REPO/.claude/skills/vault/scripts/vault-helper.sh new $VP \\ + --kind generic --name ' pfSense REST API' --tag pfsense \\ + --set url=https:// --set apikey= + (No package / can't install? SSH 'easyrule' + config.xml fallback is the planned alt backend — ROADMAP §E.) +EOF +} +[ "$ACT" = "setup" ] && { setup_msg; exit 0; } + +URL="$(bash "$VAULT" get-field "$VP" credentials.url 2>/dev/null || bash "$VAULT" get-field "$VP" url 2>/dev/null || true)" +KEY="$(bash "$VAULT" get-field "$VP" credentials.apikey 2>/dev/null || bash "$VAULT" get-field "$VP" apikey 2>/dev/null || true)" +if [ -z "$URL" ] || [ -z "$KEY" ]; then + echo "[BLOCKED] no pfSense REST API cred at vault:$VP"; echo; setup_msg; exit 2 +fi +echo "[INFO] pfSense $ACT @ $URL mode=$([ $APPLY = 1 ] && echo APPLY || echo DRY-RUN)" + +export PF_URL="$URL" PF_KEY="$KEY" PF_ACT="$ACT" PF_APPLY="$APPLY" PF_ALIAS="$ALIAS" REPO +export PF_A0="${POS[0]:-}" PF_A1="${POS[1]:-}" +python - <<'PY' +import os,sys,json,ssl,urllib.request,urllib.error +URL=os.environ['PF_URL'].rstrip('/'); KEY=os.environ['PF_KEY'] +ACT=os.environ['PF_ACT']; APPLY=os.environ['PF_APPLY']=='1' +A0=os.environ['PF_A0']; A1=os.environ['PF_A1']; ALIAS=os.environ['PF_ALIAS'] +ctx=ssl.create_default_context(); ctx.check_hostname=False; ctx.verify_mode=ssl.CERT_NONE +def call(method,path,body=None): + data=json.dumps(body).encode() if body is not None else None + r=urllib.request.Request(f"{URL}/api/v2{path}",data=data,method=method) + r.add_header('X-API-Key',KEY); r.add_header('Content-Type','application/json') + resp=urllib.request.urlopen(r,timeout=20,context=ctx) + txt=resp.read().decode('utf-8','replace') + try: j=json.loads(txt) + except Exception: return txt + return j +def data(j): return j.get('data',j) if isinstance(j,dict) else j +def err(e): + body='' + try: body=e.read().decode('utf-8','replace')[:300] + except Exception: pass + print(f"[FAIL] HTTP {getattr(e,'code','?')}: {body or e}") + print("[hint] if 404/endpoint-not-found, the installed REST API version uses a different path — " + "check System > REST API > Documentation and adjust pfsense-backend.sh.") + sys.exit(1) +def save_rollback(tag,obj): + rp=os.path.join(os.environ.get('REPO','.'),'.claude','tmp',f"pfsense-rollback-{tag}.json") + try: + os.makedirs(os.path.dirname(rp),exist_ok=True); open(rp,'w').write(json.dumps(obj,indent=1)) + print(f"[rollback] saved {rp}") + except Exception as ex: print("[rollback] save failed:",ex) +def apply_fw(): + if APPLY: + try: call('POST','/firewall/apply',{}); print("[ok] firewall changes applied") + except urllib.error.HTTPError as e: err(e) + +def find(items,key,namekeys=('descr','name','desc,')): + for i,it in enumerate(items): + if str(it.get('id',i))==str(key): return it + for nk in namekeys: + if it.get(nk) and str(it[nk]).lower()==key.lower(): return it + return None + +try: + if ACT=='audit': + flags=[] + print("\n== WAN / Gateways ==") + try: + gws=data(call('GET','/status/gateways')) + for g in (gws or []): + nm=g.get('name'); st=g.get('status'); loss=g.get('loss'); rtt=g.get('delay') + print(f" {nm}: status={st} rtt={rtt} loss={loss} monitor={g.get('monitorip')}") + if st and str(st).lower() not in ('online','up','none',''): flags.append(f"WAN {nm} status={st}") + try: + if loss and float(str(loss).rstrip('% ').strip() or 0)>5: flags.append(f"WAN {nm} loss={loss}") + except Exception: pass + if not gws: print(" (no gateway data)") + except urllib.error.HTTPError as e: print(f" [skip] status/gateways: HTTP {e.code} (verify endpoint)") + print("\n== System ==") + for ep in ('/status/system','/system/version'): + try: + s=data(call('GET',ep)) + if isinstance(s,dict): print(" "+", ".join(f"{k}={s.get(k)}" for k in list(s)[:8])); break + except urllib.error.HTTPError: continue + else: print(" (system endpoint not found — verify path)") + print("\n== DHCP scopes (pool pressure) ==") + try: + ds=data(call('GET','/services/dhcp_server')) + for d in (ds or []): + if not d.get('enable',True): continue + print(f" {d.get('interface')}: pool {d.get('range_from')}-{d.get('range_to')}") + if not ds: print(" (none / disabled)") + except urllib.error.HTTPError as e: print(f" [skip] services/dhcp_server: HTTP {e.code} (verify endpoint)") + print("\n== WAN exposure (port-forwards) ==") + try: + pfs=data(call('GET','/firewall/nat/port_forwards')) + for p in (pfs or []): + dis='off' if p.get('disabled') else 'on ' + print(f" [{dis}] id={p.get('id')} '{p.get('descr','')}' {p.get('protocol')} " + f"{p.get('interface')} dst_port={p.get('destination_port')} -> {p.get('target')}:{p.get('local_port')} src={p.get('source','any')}") + if not p.get('disabled') and str(p.get('source','any')).lower() in ('any','wanaddress',''): + flags.append(f"port-forward '{p.get('descr')}' open to ANY source") + if not pfs: print(" (none)") + except urllib.error.HTTPError as e: print(f" [skip] firewall/nat/port_forwards: HTTP {e.code} (verify endpoint)") + print("\n== FLAGS ==") + print("\n".join(" [!] "+f for f in flags) if flags else " [OK] (or endpoints need version verification)") + sys.exit(0) + + if ACT=='pf-list': + for p in (data(call('GET','/firewall/nat/port_forwards')) or []): + dis='off' if p.get('disabled') else 'on ' + print(f" [{dis}] id={p.get('id')} '{p.get('descr','')}' {p.get('protocol')} {p.get('interface')} " + f"dst_port={p.get('destination_port')} -> {p.get('target')}:{p.get('local_port')} src={p.get('source','any')}") + sys.exit(0) + if ACT=='fw-list': + for r in (data(call('GET','/firewall/rules')) or []): + dis='off' if r.get('disabled') else 'on ' + print(f" [{dis}] id={r.get('id')} {r.get('interface')} {r.get('type')} '{r.get('descr','')}' " + f"proto={r.get('protocol')} dst_port={r.get('destination_port')} src={r.get('source','any')}") + sys.exit(0) + + if ACT in ('pf-disable','pf-enable','pf-delete','pf-set-ports'): + pfs=data(call('GET','/firewall/nat/port_forwards')); pf=find(pfs,A0) + if not pf: print(f"[FAIL] port-forward '{A0}' not found"); sys.exit(1) + before={k:pf.get(k) for k in ('disabled','destination_port','local_port','source')} + print(f" target: id={pf.get('id')} '{pf.get('descr')}' {before}") + if not APPLY: print(" [dry-run] add --apply to write."); sys.exit(0) + save_rollback(f"pf-{pf.get('id')}",pf) + if ACT=='pf-delete': + call('DELETE','/firewall/nat/port_forward',{'id':pf['id']}); apply_fw() + print(f"[ok] deleted port-forward '{pf.get('descr')}'"); sys.exit(0) + body={'id':pf['id']} + if ACT=='pf-disable': body['disabled']=True + elif ACT=='pf-enable': body['disabled']=False + elif ACT=='pf-set-ports': + if not A1: print("[FAIL] pf-set-ports needs "); sys.exit(1) + body['destination_port']=A1 + call('PATCH','/firewall/nat/port_forward',body); apply_fw() + print(f"[ok] {ACT} '{pf.get('descr')}' {before} -> {body}"); sys.exit(0) + + if ACT in ('fw-disable','fw-enable'): + rules=data(call('GET','/firewall/rules')); r=find(rules,A0) + if not r: print(f"[FAIL] firewall rule '{A0}' not found"); sys.exit(1) + print(f" target: id={r.get('id')} '{r.get('descr')}' disabled={r.get('disabled')}") + if not APPLY: print(" [dry-run] add --apply to write."); sys.exit(0) + save_rollback(f"fw-{r.get('id')}",r) + call('PATCH','/firewall/rule',{'id':r['id'],'disabled':(ACT=='fw-disable')}); apply_fw() + print(f"[ok] {ACT} '{r.get('descr')}'"); sys.exit(0) + + if ACT=='block-ips': + ips=[x.strip() for x in A0.split(',') if x.strip()] + if not ips: print("[FAIL] block-ips needs "); sys.exit(1) + aliases=data(call('GET','/firewall/aliases')); al=find(aliases,ALIAS) + print(f" would ensure alias '{ALIAS}' contains: {ips} + a WAN block rule referencing it") + if not APPLY: print(" [dry-run] add --apply to write."); sys.exit(0) + if al: + before=list(al.get('address',[]) or []) + save_rollback(f"alias-{al.get('id')}",{'id':al.get('id'),'address':before}) + al['address']=sorted(set(before)|set(ips)) + call('PATCH','/firewall/alias',{'id':al['id'],'address':al['address']}) + print(f"[ok] alias '{ALIAS}' {len(before)}->{len(al['address'])} entries") + else: + call('POST','/firewall/alias',{'name':ALIAS,'type':'host','address':sorted(set(ips)),'descr':'gw-control blocklist'}) + print(f"[ok] created alias '{ALIAS}' with {len(ips)} entries") + rules=data(call('GET','/firewall/rules')) + existing=next((r for r in rules if r.get('descr')==f"Block {ALIAS}" and r.get('interface') in ('wan','WAN')),None) + if existing: + print(f"[ok] WAN block rule 'Block {ALIAS}' already present -> alias updated") + else: + call('POST','/firewall/rule',{'type':'block','interface':'wan','ipprotocol':'inet','protocol':'any', + 'source':ALIAS,'destination':'any','descr':f"Block {ALIAS}",'disabled':False}) + print(f"[ok] created WAN block rule 'Block {ALIAS}' (source alias {ALIAS})") + apply_fw() + print("[note] verify rule order/precedence in the pfSense GUI; place the block above permissive WAN rules.") + sys.exit(0) + + print("[ERROR] unhandled action",ACT); sys.exit(1) +except urllib.error.HTTPError as e: err(e) +except Exception as e: print("[FAIL]",e); sys.exit(1) +PY