10 KiB
Session — unifi-wifi pfSense SSH gateway-control verbs (ROADMAP §E)
User
- User: Howard Enos (howard)
- Machine: Howard-Home
- Role: tech
Session Summary
Picked up the unifi-wifi skill build-out, specifically the open WIP in ROADMAP §E — the pfSense
gateway "compatibility layer". The skill already had a working read-only SSH backend
(pfsense-ssh.sh: audit/dhcp/run/shell) and a superseded REST backend
(pfsense-backend.sh) that held the full control-verb contract. Per Mike's 2026-06-16 decision
(SSH, not the REST API package), the remaining work was to implement gated CONTROL verbs on the
SSH backend mirroring the gw-control contract, then rewire gw-audit.sh/gw-control.sh dispatch
to the SSH backend.
Before writing code, ran read-only discovery against the live Cascades pfSense (Plus 25.07-RELEASE,
reachable at 192.168.0.1 over the Cascades site VPN) to pin the exact config schema rather than
guess. Discovery established: pfSsh.php does NOT eval piped ad-hoc code (only its built-in
playback scripts), so the reliable primitive is php with a require_once("config.inc")
bootstrap, which loads $config + write_config() + filter_configure(). Filter rules are keyed
on tracker (the id field is empty on 25.07); enabled/disabled is the PRESENCE of a disabled
key. easyrule supports block/unblock/showblock <interface> <source> and auto-manages its alias
- WAN rule. Cascades has 20 filter rules and 0 NAT port-forwards.
Built a new versioned PHP helper scripts/pfsense-gwc.php (argv-driven, no operator data
interpolated into PHP source) for list/toggle/set/delete against $config, with a config backup +
write_config() + filter_configure() on every write. Extended pfsense-ssh.sh with 12 verbs
(pf-list, fw-list, showblock, pf-disable/enable/delete, pf-set-ports, pf-set-src,
fw-disable/enable, block-ips, unblock) — DRY-RUN by default, --apply to commit. The helper
ships to the box via base64 | openssl base64 -A -d and runs with argv. Rewired dispatch in both
gw-control.sh and gw-audit.sh to prefer the SSH backend (keyed on
clients/<slug>/pfsense-firewall), running the dispatch BEFORE UOS site resolution so a
pfSense-only client slug works; the REST backend is now a dormant fallback.
Validated live on Cascades: reads (fw-list all 20 rules with correct schema, pf-list,
showblock), tracker- and descr-based matching, the full block-ips/unblock cycle on a TEST-NET
documentation IP (192.0.2.123), and the pfsense-gwc.php write path via a fw-disable/fw-enable
round-trip on an inert rule (config backup + reload confirmed, rule returned to original state).
Updated ROADMAP §E status to done, recorded the pfSense PHP gotchas, and updated SKILL.md's verb
reference + dispatch description.
Key Decisions
php+config.incbootstrap, not config.xml text parsing. Robust against version drift and gives pfSense's ownwrite_config()/filter_configure()for safe commit + reload. Chosen after confirmingpfSsh.phponly runs its built-inplaybackscripts, not piped code.- Versioned PHP helper file (
pfsense-gwc.php) instead of a bash heredoc. Keeps the control logic readable/lintable; shipped to the box per-call via base64 over the existing SSH session. - argv-driven helper. All operator data passed as positional args, never interpolated into PHP source — no shell/PHP injection surface.
- Match filter rules by
trackeror exactdescr, notid. Theidfield is empty on pf25.07;trackeris the stable unique key. - Dispatch runs before UOS site resolution. A pfSense-only site is keyed by client slug (not a UOS site name), so resolving the UOS site first would hard-exit before dispatch could fire.
- SSH backend preferred, REST kept dormant. Honors Mike's 2026-06-16 decision; REST
(
pfsense-backend.sh+clients/<slug>/pfsense-api) stays in-tree only as a fallback. block-ipsviaeasyrule, toggles via the PHP path. easyrule is the cleanest, canonical pfSense block mechanism (auto alias + rule); rule toggles need direct$configedits.
Problems Encountered
- pfSsh.php piped code did nothing. Expected
echo '...; exec' | pfSsh.phpto eval; it only prints its banner +playbackcommand list. Switched tophpwith a temp-file script. - php_rc=255 with no error output. pfSense runs
display_errors=Off, so fatals are silent. Fixed by running php with2>&1ANDini_set("display_errors","1")in the helper. Cannot redeclare backup_config()fatal. pfSense'sconfig.lib.incalready defines it (and many generic names). Prefixed all helper functions withgwc_. Also dropped the extrarequire_onceof util/functions/filter —config.incalready pulls them; re-requiring caused the redeclare fatal.- Empty output on first
fw-listruns. Root-caused via step markers in the remote script (the three issues above), not by guessing. No such file or directoryon a verb test. Working dir had persisted from an earliercdinto the scripts dir; relative script path broke. Re-ran withcd /c/claudetools+ path.
Configuration Changes
Created:
.claude/skills/unifi-wifi/scripts/pfsense-gwc.php— argv-driven pfSense config control helper.
Modified:
.claude/skills/unifi-wifi/scripts/pfsense-ssh.sh— added 12 control verbs,--apply/--ifparsing,sq()shell-quote +run_gwc()ship-and-run helper, fixedrunto use preserved args..claude/skills/unifi-wifi/scripts/gw-control.sh— SSH-first dispatch (pfsense-firewall cred), moved above UOS site resolution; REST kept as fallback..claude/skills/unifi-wifi/scripts/gw-audit.sh— same SSH-first dispatch foraudit..claude/skills/unifi-wifi/references/ROADMAP.md— §E status → done; verb list; pfSense PHP gotchas added to cross-platform notes..claude/skills/unifi-wifi/SKILL.md— pfSense section rewritten (read/write verb tables, dispatch); gw-control usage block corrected (routes to pfsense-ssh.sh, match by tracker/descr).
Credentials & Secrets
No new credentials created or discovered. Used existing vault entries (read-only):
clients/cascades-tucson/pfsense-firewall— host 192.168.0.1, useradmin, password stored. Used bypfsense-ssh.sh(system OpenSSH via askpass).clients/cascades-tucson/pfsense-openvpn-howard— the Cascades site VPN profile (referenced for reachability; not directly used by the scripts).
Infrastructure & Servers
- Cascades pfSense — 192.168.0.1 (LAN), pfSense Plus 25.07-RELEASE, admin SSH drops straight to a shell. Reachable over the Cascades site VPN (16ms). 20 filter rules, 0 NAT port-forwards.
- PHP include_path on the box:
.:/etc/inc:/usr/local/pfSense/include:...—require_once("config.inc")resolves from anywhere. - Test residue left on Cascades: an inert
'Blocked via EasyRule'WAN block rule (tracker 1782065739) + emptyEasyRuleBlockHostsWANalias, created by theblock-ipsvalidation. Blocks nothing (empty alias); returned to enabled state. Pending operator decision to remove or keep.
Commands & Outputs
# Discovery (read-only) — schema + tooling
pfsense-ssh.sh cascades-tucson run 'cat /etc/version' # 25.07-RELEASE
# php bootstrap works; pfSsh.php only runs playback scripts (not piped code)
php /tmp/q.php -> NAT_RULES=0 FILTER_RULES=20
easyrule -> block|unblock|showblock <if> <src>; pass <if> <proto> <src> <dst> [port]
# New read verbs
pfsense-ssh.sh cascades-tucson fw-list # 20 rules, keyed on tracker
pfsense-ssh.sh cascades-tucson pf-list # (no NAT port-forwards configured)
pfsense-ssh.sh cascades-tucson showblock # No entries are blocked on interface: wan
# Live write validations
pfsense-ssh.sh cascades-tucson block-ips 192.0.2.123 --apply # Block added successfully
pfsense-ssh.sh cascades-tucson showblock # 192.0.2.123/32
pfsense-ssh.sh cascades-tucson unblock 192.0.2.123 --apply # Entry unblocked successfully
pfsense-ssh.sh cascades-tucson fw-disable 1782065739 --apply # backup + write_config + filter reload -> [off]
pfsense-ssh.sh cascades-tucson fw-enable 1782065739 --apply # -> [on] (restored)
# Dispatch validation
gw-control.sh cascades-tucson fw-list # -> dispatches to pfsense-ssh.sh
gw-control.sh cascades-tucson fw-disable 1772841904 # dry-run via dispatch
# Syntax check
bash -n pfsense-ssh.sh gw-control.sh gw-audit.sh # all [OK]
Key gotcha encoded in scripts: pfSense display_errors=Off (run php with 2>&1 + ini_set),
backup_config() collision (prefix gwc_*), config.inc-only bootstrap (no re-require).
Pending / Incomplete Tasks
pf-*(NAT port-forward) verbs are built but NOT live-verified. Cascades has 0 port-forwards, so they were coded against the documented pfSense NAT schema (destination.port,target,local-port,associated-rule-id). Need a pfSense box with an actual port-forward + a vaultedclients/<slug>/pfsense-firewallcred to confirm field names before trusting--apply. Marked live-verify-pending in ROADMAP §E.- Cascades easyrule test residue — decide whether to remove the inert
'Blocked via EasyRule'rule (tracker 1782065739) + emptyEasyRuleBlockHostsWANalias. - Optional
pf-add/create verbs — not needed today (we only close/scope existing exposure); noted in ROADMAP as future. - Other §B/§C/§D ROADMAP items remain (per-client AP creds, gateway-hosted VPN server, read-only vault cred) — untouched this session.
Reference Information
- Session-log path:
session-logs/2026-06/2026-06-21-howard-unifi-pfsense-control-verbs.md - Skill:
.claude/skills/unifi-wifi/— ROADMAP §E is the design/verb-map + pfSense PHP gotchas. - Verb contract source (UniFi side):
scripts/gw-control.shheader. - Prior sync commits this session:
5ede4fe(earlier auto-sync),96a5dd6(the pfSense build). - pfSense filter-rule schema: keyed on
tracker;type= pass/block/reject;disabledkey presence = off;source/destinationare objects ({any:""}or{network,port}).