16 KiB
unifi-wifi — roadmap
Status as of 2026-06-16. The skill is fleet-generic (every script takes <site>; works on any
of the ~49 UOS sites) and WiFi data-gathering is complete. Remaining work is the apply/action
side, multi-client enablement, and non-WiFi scope. Build/validate new apply actions against
zero-client radios (6 GHz is ~0-client fleetwide) — change → verify → revert, no disruption.
Done (WiFi monitoring + analysis)
audit-site.sh— config + foreign-interference flags (Plane 1, Mongo).live-stats.sh— live per-AP utilization/satisfaction/retry% + per-client RSSI/retry%/reason (Plane 2).model-rank.sh/optimize-radios.sh— airtime-reduction + coverage-safe plan. optimize-radios consumes the SNR matrix (NEIGHBOR_JSON) for data-backed disables (roam graph fallback).neighbor-collect.sh— AP-to-AP SNR matrix from/proc/ui_neighbor(+NBR_JSONadjacency emit).survey-collect.sh— measured per-channel busy%/noise → cleanest-channel input.dfs-check.sh— empirical DFS radar history (dmesg) → is DFS safe at this site.watch-ap.sh— live per-AP RF stream (mca-dump + iw survey).apply-radio.sh— gated config apply. Currently: power, width (ht), channel, min-RSSI (allradio_tablePUTs). DRY-RUN default;--applyneedsinfrastructure/uos-server-network-api-rw.
A. Apply side — make WiFi changes fully executable
- apply-radio: power (tx_power_mode/tx_power)
- apply-radio: width (ht 20/40/80/160)
- apply-radio: channel (manual channel assignment)
- apply-radio: min-RSSI (min_rssi_enabled + min_rssi)
- apply-radio: disable/enable a radio — DONE. Disable =
tx_power_mode:"disabled"(confirmed 2026-06-16 via UI-toggle + device-JSON diff); enable =auto. Validated full cycle on a 0-client 6 GHz radio. Use--ap <name>to target one AP (the right scope for disables). Still highest-risk (coverage holes) — gate + validate per AP with watch-ap; pair with optimize-radios' disable list. - min data rates (kill 1–11 Mbps; 2.4 floor 12/24) — DONE in
apply-wlan.sh(minrate ng|na auto|off|<Mbps>). Requiresminrate_setting_preference=manual(handled). Validated on a 0-client WLAN. band-steering: DONE — modern UniFi has nobandsteering_mode; located its replacements (2026-06-16) and wired all into apply-wlan:bandsteer(no2ghz_oui),bands(wlan_bands — 5g/5g6e forces clients up),steer(roaming_assistant_na),bsstm(bss_transition, 802.11v). Validated on a 0-client WLAN. (fast_roaming_enabled/802.11r left out — risky for IoT.) - full wlanconf knob set — apply-wlan now also does
wlan(enable/disable),dtim,mcast,bcfilter,rrm(802.11k),ftroam(802.11r, warns),isolation,hidessid. dtim apply-validated; rest via the proven gated REST path. - device-level lock / control —
apply-wlan aps <ap-list>(broadcasting_aps = restrict SSID to APs, the "lock to AP" lever) +apply-wlan macfilter off|allow|deny <macs>; newclient-control.shblock|unblock|kick a MAC (validated on a dummy MAC). UniFi has NO native per-client AP pin — these are the real mechanisms. - channel-plan apply — DONE:
channel-plan.shng (1/6/11 graph-color via neighbor matrix) + na (lowest-cost non-DFS = survey busy% + neighbor-collision penalty); reports co-channel before/after, gated apply + rollback. Dry-run validated on Cascades (ng 92->35 pairs; na 20->0 + all off DFS).survey-collectnow emits SURVEY_JSON for it.
B. Multi-client enablement (use on any client we manage)
- Per-client AP device-auth cred: vault
clients/<x>/unifi-ap-ssh, pass as the script arg (only Cascades exists today). Keys vaulted per-client as needed. - Per-client L3 reach to APs (site VPN / route) for the AP-side collectors (Cascades split-tunnel done).
- Controller-only mode documented + discoverable —
scripts/sites.shlists all sites + AP-cred readiness; controller-side scripts validated on other clients (Glabman, Sonoran Glass). AP-side scripts print the exact vault command when a client's cred is missing (and note controller-side works).
C. Non-WiFi UniFi (currently WIP / out of scope)
- Switch/PoE collector — DONE:
switch-audit.sh(port up/down, PoE budget+draw, ERRORS, rate-based DROPS, PoE faults, and UNDERSPEED = gig-capable port linked at 10/100 = the FastEthernet issue, now systematically found — ~25 such ports at Cascades). Controller-side, any site. - Gateway/WAN + site health — DONE:
gw-audit.sh(WAN status/IP/uplink, internet latency/ drops/speedtest, gw CPU/mem/uptime, adoption rollup: adopted/disconnected/pending + firmware-upgradable, with flags). Handles third-party-firewall sites. Validated on a USG site + the pfSense Cascades site. - adoption / device remediation — DONE:
device-control.shadopt|restart|provision|locate| unlocate|upgrade a device (cmd/devmgr, gated; validated locate/unlocate on AP 622). Pairs with gw-audit. - Port-forwards + WAN firewall control — DONE:
gw-control.shpf-list/disable/enable/delete/ set-ports/set-src, fw-disable/enable, block-ips (RW REST: rest/portforward|firewallrule|firewallgroup, gated/DRY-RUN, rollback). Dry-run validated 2026-06-16 on Grabb & Durando USG-3P (closes the brute-forced PPTP forward 1723→GND-SERVER + its GRE WAN_IN rule). First production apply pending. - Deeper VPN policy — gateway-hosted VPN server stand-up (USG L2TP/IPsec remote-user, RADIUS), client DHCP/DNS policy. Config beyond port-forward/WAN-firewall; future. Access layer reaches it.
D. Robustness / ops
- VPN-flap resilience — AP-side loops now retry per AP (3x, capture-to-var so a failed try never appends partial data); dfs-check distinguishes unreachable from no-events. Validated (74/74). Still foreground.
- Scheduling / fleet monitoring —
monitor-run.sh <site|all>= cron-friendly read-only health digest (gateway + switch/PoE flags + WiFi flag count) per site. Validated. (Cron theallsweep nightly.) - Vault read-only
infrastructure/uos-server-network-api(least-privilege; RW does double duty now).
E. pfSense gateway support — gateway "compatibility layer" (NEW, proposed 2026-06-16)
Why: a very common ACG topology is UniFi APs/switches (on the UOS controller) behind a pfSense
gateway instead of a UniFi gateway (USG/UXG/UCG) — Cascades, our own office, and several other
clients. The controller-side WiFi/switch tooling already works at these sites, but the gateway verbs
(gw-audit health, gw-control port-forwards / WAN firewall / block-ips, VPN) only speak the UniFi
controller REST. Goal: make the same verbs work against a pfSense gateway via a thin driver, so one
skill covers WiFi+switch+gateway regardless of who makes the gateway.
Design — a gw driver interface (backend behind the existing verbs), not a parallel skill.
Detection is already in hand: gw-audit reports num_gw=0 → "no UniFi gateway (third-party firewall,
e.g. pfSense)". That signal selects the pfsense backend instead of the unifi backend. The verbs
stay identical; only the driver differs:
| verb (UniFi backend) | pfSense equivalent |
|---|---|
gw-audit (WAN/health/uptime) |
pfctl -si, gateway status, get_interface_*; CPU/mem/uptime via shell |
pf-list / pf-set-ports / pf-disable |
NAT port-forward objects (<nat> in config.xml / REST firewall/nat/port_forward) |
fw-disable / fw-enable |
filter rules (<filter> / REST firewall/rule) |
block-ips |
easyrule block wan <ip> (cleanest map) or an address alias + WAN block rule |
| VPN server stand-up (Deeper VPN) | pfSense is the better VPN host — OpenVPN / IPsec / WireGuard native (relevant to moving clients off Windows RRAS PPTP, cf. Grabb) |
Backend options for pfSense (ranked):
- pfSense REST API package (
pfSense-pkg-API/ built-in API on Plus) — cleanest 1:1 object mapping (NAT, rules, aliases, interfaces, services). Cost: must standardize on installing/enabling it fleet-wide. - SSH + stock CLI —
easyrule block/unblock(perfect forblock-ips),pfSsh.php playbackandphp -rfor config reads/writes +filter_configure/rc.reload_allto apply. No package needed. diag_command.phpphp-exec over the web GUI — the fleet's existing ad-hoc pfSense pattern (Howard). Works but fragile (curl quoting; CSRF; see errorlog 2026-06-16). Good stopgap, poor primitive.config.xmlread/edit + reload — most powerful, highest blast radius; backup-first, atomic reload.
Already have: vaulted pfSense creds for Cascades (clients/cascades-tucson/pfsense-firewall,
…/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 — RESOLVED (Howard 2026-06-16, "try what Mike wanted unless we hit a real roadblock"):
- Primary backend: REST API package (
pfSense-pkg-RESTAPIv2,X-API-Key). SSHeasyrule/pfSsh.phpkept as the documented fallback for boxes that can't install the pkg. - Where the abstraction lives: dispatch inside
gw-audit.sh/gw-control.sh(keyed on num_gw=0 + a vaultedclients/<slug>/pfsense-apicred, or--pfsense <slug>), so callers/SKILL docs don't fork. Implemented inscripts/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 pfSensefirewall/applyis called after each change. config.xml backup-first is the SSH-fallback's job.
DECISION (Mike, 2026-06-16): backend = SSH, NOT the REST API package. "We don't need the RESTAPI — with VPN + SSH we can read the same data and make changes." Confirmed: Cascades pfSense is Plus 25.07-RELEASE (current, not old — the earlier "too old" premise was wrong) and admin SSH drops straight to a shell (no menu gotcha). So the upgrade/package blocker is MOOT and the layer is OFF HOLD.
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 bytrackeror exactdescr). [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.shshipsscripts/pfsense-gwc.phpto the box (base64 over the wire) and runs it underphp, which bootstraps$configviaconfig.inc(do NOT re-require util/functions/filter — "cannot redeclare" fatal). Each write backs up/cf/conf/config.xmlto/tmpfirst;write_config()also keeps pfSense's own config history. Cred =clients/<slug>/pfsense-firewall(host + admin user/pass), system OpenSSH via askpass. - SSH port configurable (2026-06-21):
--port N> vaultport/credentials.portfield > default 22. Unblocks non-standard-port boxes like the ACG office gateway (2248) — storeport: 2248in its vault entry. - Cred-path convention = option A (Mike 2026-06-21): the slug arg is a FULL vault path when it
contains
/(any infra-vaulted device, e.g.infrastructure/pfsense-firewall), elseclients/<slug>/pfsense-firewall. No cred duplication; explicit (no implicit fallback ladder). Resolves loud ([ERROR] no cred at <path>) on a bad path.gw-audit/gw-control--pfsenseinherits it. So the ACG office box is now reachable:gw-audit '<site>' --pfsense infrastructure/pfsense-firewall(+port: 2248in that entry). - Dispatch rewired:
gw-control.sh/gw-audit.shnow prefer the SSH backend (keyed onclients/<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 (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.
- Site→gateway map (2026-06-21):
sites.shnow prints a live gateway map — every UOS site classified UniFi-gateway (model) vs no-UniFi-gateway (pfSense/third-party candidate), plus the list of vaultedclients/*/pfsense-firewallcreds with resolved host:port (SSH-backend-ready). Generated live (always current), not a static file. Today: 12 UniFi-gw / 36 no-UniFi-gw sites; 1 pfSense cred (Cascades).- Auto-select from the map (2026-06-21):
gw-audit/gw-controlnow route a site to its pfSense cred automatically (no--pfsense) when it's bound inreferences/site-gateways.tsv(keyed on the resolved 24-hex UOS site_id; carries an optional port). Manage withscripts/gateway-map.sh(lookup/list/validate/suggest— suggest cross-refs no-UniFi-gw controller sites + vaulted creds to find unmapped bindings). Validated:gw-control Cascades fw-listandgw-audit Cascadesboth auto-dispatch to the pfSense SSH backend. Seeded with Cascades; add rows viagateway-map.sh suggest.
- Auto-select from the map (2026-06-21):
- VPN convergence: the "Deeper VPN — gateway-hosted VPN server" item (C) is easier and better on pfSense (WireGuard/OpenVPN) than on a USG — fold the Grabb-style "retire Windows RRAS PPTP → gateway VPN" play into the pfSense driver from the start.
First validation targets: our office + Cascades (creds already vaulted; gw-audit already classifies
them as pfSense). Start read-only (gw-audit parity: WAN/health via easyrule/pfctl), then block-ips
via easyrule (lowest-risk write, directly useful for the same brute-force containment as the UniFi side).
Cross-platform notes (baked into the scripts; keep for any new ones)
- Pass temp paths to python via ARGV (MSYS translates POSIX→Windows for python.exe);
$TMPinside apython -cstring is NOT translated → fails on Windows. - Do NOT inject large data as a JS object literal into the mongo shell (~21 KB crashes SpiderMonkey); precompute + inject a compact flat string.
- AP-side SSH:
sshpassorSSH_ASKPASSfallback;</dev/nullinwhile readloops; foreground. - Windows text-mode file writes add CRLF → strip
\rafter bashread. - pfSense PHP (
pfsense-gwc.php): bootstrap withrequire_once("config.inc")ONLY — it pulls inwrite_config()/filter_configure(); re-requiring util/functions/filter → "cannot redeclare" fatal.display_errorsis Off, so php fatals are SILENT — always run php with2>&1ANDini_set("display_errors","1")or you get rc=255 with no message. pfSense already definesbackup_config()(and many generic names) → prefix all helper functions (gwc_*).pfSsh.phpdoes NOT eval piped ad-hoc code (only its built-inplaybackscripts) — usephp <file>instead. Filter rules are keyed ontracker(theidfield is ""); enabled/disabled = PRESENCE of adisabledkey. Ship the helper viabase64 | openssl base64 -A -d(both present on FreeBSD).