# 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_JSON` adjacency 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**
  (all `radio_table` PUTs). DRY-RUN default; `--apply` needs `infrastructure/uos-server-network-api-rw`.

## A. Apply side — make WiFi changes fully executable
- [x] apply-radio: power (tx_power_mode/tx_power)
- [x] apply-radio: width (ht 20/40/80/160)
- [x] apply-radio: channel (manual channel assignment)
- [x] apply-radio: min-RSSI (min_rssi_enabled + min_rssi)
- [x] **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.
- [x] **min data rates** (kill 1–11 Mbps; 2.4 floor 12/24) — DONE in `apply-wlan.sh`
  (`minrate ng|na auto|off|<Mbps>`). Requires `minrate_setting_preference=manual` (handled). Validated
  on a 0-client WLAN. **band-steering: DONE** — modern UniFi has no `bandsteering_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.)
- [x] **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.
- [x] **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>`; new `client-control.sh`
  block|unblock|kick a MAC (validated on a dummy MAC). UniFi has NO native per-client AP pin — these are
  the real mechanisms.
- [x] **channel-plan apply** — DONE: `channel-plan.sh` ng (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-collect` now emits SURVEY_JSON for it.

## B. Multi-client enablement (use on any client we manage)
> Procedure is prepped + copy-paste ready in **`references/onboarding.md`** (§B). Each item below is blocked
> only on the external input (a cred / site reach), not on any code.
- [ ] 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. → steps: onboarding.md §B1.
- [ ] Per-client L3 reach to APs (site VPN / route) for the AP-side collectors (Cascades split-tunnel done).
- [ ] (pfSense sites) vault `clients/<x>/pfsense-firewall` + add a gateway-map row → onboarding.md §B2.
- [x] Controller-only mode documented + discoverable — `scripts/sites.sh` lists 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)
- [x] **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.
- [x] **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.
- [x] **adoption / device remediation** — DONE: `device-control.sh` adopt|restart|provision|locate|
  unlocate|upgrade a device (cmd/devmgr, gated; validated locate/unlocate on AP 622). Pairs with gw-audit.
- [x] **Port-forwards + WAN firewall control** — DONE: `gw-control.sh` pf-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 + client DHCP/DNS policy. Build-ready
  design below; no external input needed to START (Cascades pfSense is reachable for dev), so this is the next
  real *feature* build when we choose to take it. The motivating play: retire client Windows RRAS PPTP
  (cf. Grabb) onto a modern gateway VPN.
  - **Backend split (mirror the gateway compatibility layer):** pfSense is the better VPN host -> do it there
    first via the existing SSH backend; UniFi USG (L2TP/IPsec + RADIUS) is a later parallel driver.
  - **pfSense WireGuard (preferred) — verbs to add to `pfsense-gwc.php` + `pfsense-ssh.sh`:**
    `vpn-list` (tunnels + peers, read), `vpn-add-peer <name> <pubkey> <allowed-ip>` (gated), `vpn-rm-peer`,
    `vpn-show-config <peer>` (emit the client config/QR text to hand off via discord-dm). pfSense 25.07 has
    native WireGuard (`/usr/local/pkg/wireguard` config in `$config`); same write_config()+`(rc.)reload`
    pattern as the pf-*/fw-* verbs. OpenVPN is the fallback for clients that need it.
  - **DHCP/DNS policy:** read side already exists (`pfsense-ssh.sh dhcp`); add gated `dhcp-set-range` /
    `dns-set` verbs through the same gwc.php path if/when needed.
  - **Open decisions (for /shape-spec when we start):** WireGuard vs OpenVPN default per client; where peer
    keys/configs are vaulted (`clients/<slug>/vpn-*`); whether to drive UniFi USG VPN via REST or leave it
    pfSense-only initially. Access layer + creds already reach the gateways.

## D. Robustness / ops
- [x] **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.
- [x] **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 the `all` sweep nightly.)
- [ ] Vault read-only `infrastructure/uos-server-network-api` (least-privilege). **Code-ready:** `gw-audit`
  already prefers this cred and auto-uses it once vaulted (falls back to RW + prints a hint until then).
  One step — create the View-Only admin + vault it: **`references/onboarding.md` §D**.

## 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):**
1. **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.
2. **SSH + stock CLI** — `easyrule block/unblock` (perfect for `block-ips`), `pfSsh.php playback` and
   `php -r` for config reads/writes + `filter_configure`/`rc.reload_all` to apply. No package needed.
3. **`diag_command.php` php-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.
4. **`config.xml` read/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"):**
- [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/<slug>/pfsense-api` cred, or `--pfsense <slug>`), 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.

**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 + ALL live-verified on Cascades (reads, filter
toggles, blocking, AND the NAT pf-* verbs).**
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). **[x] live-validated 2026-06-21**: full cycle on Cascades against a temporary
  source-locked (TEST-NET) disabled forward — create → pf-list parse → set-ports (dst+local) → set-src →
  enable → disable → delete (NAT + associated filter rule both removed, box back to baseline). NAT rules have no
  `tracker`, so match by `descr` or index. (Verified the verb mechanics + the documented NAT schema fields
  `destination.network/port`, `target`, `local-port`, `associated-rule-id`, `disabled`; a GUI-created forward
  would be a belt-and-suspenders schema spot-check but the fields rendered/round-tripped correctly.)
- 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] **SSH port configurable** (2026-06-21): `--port N` > vault `port`/`credentials.port` field > default 22.
  Unblocks non-standard-port boxes like the ACG office gateway (2248) — store `port: 2248` in its vault entry.
- [x] **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`), else `clients/<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` `--pfsense` inherits it. So the ACG office box is now reachable:
  `gw-audit '<site>' --pfsense infrastructure/pfsense-firewall` (+ `port: 2248` in that entry).
- [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 (SSH backend):** [x] pf-* live-verified (2026-06-21, above). [ ] optional: spot-check pf-* against a
GUI-created forward when one is reachable (schema belt-and-suspenders); [ ] 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.
- [x] **Site→gateway map** (2026-06-21): `sites.sh` now prints a live gateway map — every UOS site
  classified UniFi-gateway (model) vs no-UniFi-gateway (pfSense/third-party candidate), plus the list of
  vaulted `clients/*/pfsense-firewall` creds 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).
  - [x] **Auto-select from the map** (2026-06-21): `gw-audit`/`gw-control` now route a site to its pfSense
    cred automatically (no `--pfsense`) when it's bound in `references/site-gateways.tsv` (keyed on the resolved
    24-hex UOS site_id; carries an optional port). Manage with `scripts/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-list` and `gw-audit Cascades` both auto-dispatch
    to the pfSense SSH backend. Seeded with Cascades; add rows via `gateway-map.sh suggest`.
- [ ] **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); `$TMP` inside
  a `python -c` string 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: `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).
