Files
claudetools/.claude/skills/unifi-wifi/SKILL.md
Mike Swanson 6fdc21d955 unifi-wifi: cloud Site Manager backend (gw-sitemanager.sh) + UOS-parity connector tier
New backend reaching ANY of the ~36 ACG UniFi consoles remotely via api.ui.com with the
account key (vault services/unifi-site-manager) - no UOS server, no LAN/VPN. Mapped the API
surface empirically (key live), corroborated by grok+gemini web search:

- Tier 1 (Site Manager): fleet/devices/sites/isp commands - inventory, site health (counts,
  IPS, ISP/ASN), and WAN/ISP time-series (latency/throughput/downtime).
- Tier 2 (CLOUD CONNECTOR -> console LOCAL Network API = UOS PARITY): the `net` command proxies
  /v1/connector/consoles/<id>/proxy/network/api/s/<site>/stat/{device,sta}, returning the SAME
  ace_stat depth as the UOS Mongo path - per-radio cu_total airtime/channel/bw/tx_power/num_sta/
  satisfaction and per-client rssi/signal/noise/satisfaction/rates. Verified live on Brooklyn/
  Skybar (standalone UDM, WAN-firewalled): `net brooklyn radios` + `net brooklyn clients` work.

This achieves parity with (and broader coverage than) the UOS server for non-UOS consoles.
Added references/site-manager-api.md (full catalog + 3 tiers), a Plane 3 note in SKILL.md, and
updated the reference memory. Read-only; POST actions (device restart, client block) exist, not wired.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 08:32:00 -07:00

22 KiB
Raw Blame History

name, description
name description
unifi-wifi Analyze and tune UniFi WiFi for performance + stability, especially in dense/congested environments. Audits AP/radio config and the neighbor-interference map from the UOS controller, flags issues (2.4GHz over-provisioning, channel width, min-RSSI/sticky clients, channel plan), and recommends prioritized changes. Works for any UniFi site on the UOS (172.16.3.29); Cascades is the hard case. Triggers: unifi wifi tuning, RF/airtime/channel analysis, 2.4GHz congestion, AP channel plan, sticky clients, wireless performance.

UniFi WiFi tuning (UOS sites)

Data-driven WiFi tuning for UniFi sites on the UOS Server (172.16.3.29). Goal: solid performance + stability for connected devices in congested environments by analyzing what the controller knows and making prioritized, validated changes. Built for any site; Cascades (77 APs, ~550 clients, brutal 2.4GHz) is the reference hard case.

Multi-client (any UOS site)

scripts/sites.sh lists all ~49 sites (APs/switches/gateways) + per-client AP-cred readiness — the entry point for pointing the skill at another client. Controller-side scripts (audit-site, live-stats, model-rank, optimize-radios, apply-radio) work on ANY site with zero per-client setup (shared controller creds) — pass the site name/desc/id. AP-side collectors (neighbor/survey/dfs/ watch-ap) additionally need that client's clients/<slug>/unifi-ap-ssh vaulted + L3 reach (site VPN); if missing they print the exact vault command and exit (controller-side still works). Default AP-cred path is Cascades — override with the script's vault-path arg per client.

Status (2026-06-15)

  • [WORKING] WiFi monitoring + RF tuning — complete data-gathering for any UOS site/client: config + interference (audit-site), live per-AP + per-client (live-stats), airtime history
    • roam graph (model-rank/optimize-radios), measured per-channel occupancy (survey-collect), empirical DFS radar history (dfs-check), the AP-to-AP SNR neighbor matrix from /proc/ui_neighbor (neighbor-collect), per-AP live stream (watch-ap), and gated config apply (apply-radio). All site-parameterized → works on every UniFi site we monitor.
  • [WORKING] Switch / PoE auditscripts/switch-audit.sh <site> [--all-ports]: per-switch PoE budget, port up/total, and flags (UNDERSPEED gig-port-at-10/100, rate-based DROPS, ERRORS, PoE faults, PoE-budget pressure). Controller-side, any site. (Found ~25 100M-linked gig ports at Cascades.)
  • [WORKING] Gateway / WAN / internet + site healthscripts/gw-audit.sh <site>: WAN status/IP/ uplink, internet latency/drops/speedtest, gateway CPU/mem/uptime, and the adoption/health rollup (APs/switches adopted vs disconnected vs pending, client counts, firmware-upgradable) with flags. Handles third-party-firewall sites (num_gw=0). Controller-side, any site.
  • [WORKING] Gateway router actions — port-forwards + WAN firewallscripts/gw-control.sh <site>: list/disable/enable/delete/re-scope port-forwards (pf-*), toggle WAN firewall rules (fw-disable/ fw-enable), and drop attacker IPs at the edge (block-ips). The write companion to gw-audit; closes an internet-facing exposure (e.g. a brute-forced PPTP). Gated/DRY-RUN, rollback saved. Controller-side.
  • [WORKING] Scheduled fleet monitoringscripts/monitor-run.sh <site|all>: controller-side read-only health digest per site (gateway/WAN flags + switch/PoE flags + WiFi config flag count), cron-friendly for ongoing monitoring of every client. AP-side collectors now retry per-AP (3x) 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 SSHscripts/pfsense-ssh.sh <slug> audit|dhcp|run "<cmd>". 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.
  • 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.

First, load context

  • references/data-access.md — what data the UOS exposes and how to read it (the two planes: Mongo config/interference now, live Network API later).
  • references/methodology.md — the prioritized tuning playbook (synthesized from a multi-model pass + live recon). Read before recommending any change.
  • references/interference-model.md — design for the AP-interference / airtime-reduction model (which radios to disable / power down), incl. the data feasibility (needs Plane 2 + a collector — the signals aren't in Mongo).

What it does (current = Plane 1, read-only)

  1. Audit a site — config + interference, no live plane needed:
    bash .claude/skills/unifi-wifi/scripts/audit-site.sh <site-name|site_id>
    bash .claude/skills/unifi-wifi/scripts/audit-site.sh cascades
    
    Outputs 2.4/5/6 config summary, the per-channel neighbor-density (interference) map, and flagged issues (2.4 over-provisioning, 40/80/160MHz width, off-1/6/11 channels, min-RSSI off, high power).
  2. Rank airtime-reduction candidates — which radios to disable / power down, from real history (ace_stat: per-AP airtime cu_total/cu_interf/num_sta + the wifi_connectivity_event roam graph). Works for any band:
    bash .claude/skills/unifi-wifi/scripts/model-rank.sh <site> [days=7] [band=ng|na|6e|all]
    
    (Cascades 2.4: 75 radios at 7494% utilization, 6181% interference, ~1 client each → disable/power-down.) 2b. Radio usage history — is a radio actually USED (so you can tell disable-safe from power-down)? Per-AP time-avg concurrent users + peak station count over the retained window (stat_daily, ~77d):
    bash .claude/skills/unifi-wifi/scripts/radio-usage.sh <site> [band=ng|na|6e] [days=30]
    NEIGHBOR_JSON=<nbr.json> bash .../radio-usage.sh <site> ng 77   # adds a safe-to-disable shortlist
    bash .../radio-usage.sh <site> ng --ap "<AP name>"             # per-DEVICE: who's on this AP's 2.4 + steerable?
    
    The --ap mode lists the devices on one AP's band (live clients + recent assoc events) and tags each steerable (dual-band, from events; or a randomized MAC = modern phone/laptop) vs legacy/IoT (fixed vendor OUI, 2.4-only) — so you know whether the fix is band-steering or whether a 2.4-only device forces the radio to stay. (Needs controller cred for the live BSSID map; assoc-event retention is ~1 day.) Key distinction: avg~0 but peak>0 = NOT unused (takes bursts → POWER-DOWN); only peak==0 over the window = genuinely unused (disable-safe). With the SNR matrix it cross-checks low-use APs against overlapping neighbors w/ headroom. (Cascades 2.4: only 1 never-used radio — the offline AP 108; every other radio takes real bursts → confirms power-down, not disable. optimize-radios does the conflict-free pick.)
  3. Optimize (coverage-safe plan) — which radios to power-down (do first) vs disable (after), per band, without opening coverage holes or capacity cascades:
    bash .claude/skills/unifi-wifi/scripts/optimize-radios.sh <site> [days=14] [band=ng|na|6e]
    
    Coverage model = the roam graph (materials-aware: clients can't roam through Cascades' steel hallway walls, so the model never calls cross-wall APs redundant). Multi-model hardened (bidirectional roams, load-shift simulation, 40%/zone cap, stepwise). On Cascades 2.4 it recommends power-down on 74/75 radios (safe big win) and 0 disables until the live RF table proves redundancy — see interference-model.md. 3b. Coverage-thin 2.4 (which radios to turn OFF) — when 2.4 is over-deployed (every AP blasting 2.4, heavy co-channel contention), compute a coverage-redundancy (dominating-set) DISABLE plan on the 2.4 SNR layer so each disabled AP stays covered by a nearby ACTIVE-2.4 neighbor:
    NBR=.claude/tmp/<site>-nbr.json; NBR_JSON=$NBR bash .../neighbor-collect.sh <site>
    NEIGHBOR_JSON=$NBR bash .../coverage-thin.sh <site> [days=14]      # MINCOV=2 for the resilient subset
    
    Unlike optimize-radios (band-AGNOSTIC physical adjacency → can wrongly count an ng-DISABLED AP as a coverer via its 5/6 GHz), coverage-thin uses the 2.4 layer and only counts neighbors whose 2.4 stays ON; flags single-coverer disables, caps per-zone, halves co-channel count. Cascades: ~34 of 76 2.4 radios disable-able with ≥2 active coverers each (ch6 28→13, ch11/ch1 →13).
  4. Validate live (Plane 2) — current cu_total/satisfaction/per-AP RF, before+after a change, and the AP-to-AP RF-neighbor table that unlocks confident disables:
    bash .claude/skills/unifi-wifi/scripts/live-stats.sh <site> [--clients]
    
    Needs a read-only controller admin vaulted at infrastructure/uos-server-network-api (the script prints the one-time provisioning steps if it's missing).
    • Per-AP live watch (no controller lag) — SSH straight into an AP and stream channel-busy% + noise + clients/retries every ~2s, to watch a targeted change land in real time:
      bash .claude/skills/unifi-wifi/scripts/watch-ap.sh <ap-ip> [interval]
      
      Uses mca-dump + iw survey on the AP. Device-auth cred vaulted (clients/cascades-tucson/unifi-ap-ssh). Needs L3 reach to the AP (at Cascades: bring up the site VPN; APs are on 192.168.2.x/3.x) + local sshpass.
  5. Interpret the flags against methodology.md (fix order: prune 2.4 -> shrink cells/power -> min data rates -> manual 1/6/11 plan -> min-RSSI + roaming -> steer to 6GHz).
  6. Recommend a prioritized, per-zone change plan. Roll out per zone, not site-wide at once.

AP-side collectors (direct AP SSH over the site VPN — non-disruptive, foreground)

These read each AP directly (controller hides this data). All take <site> [ap-ssh-vault-path], use sshpass-or-SSH_ASKPASS auth, and must run in the foreground:

# AP-to-AP SNR neighbor matrix (/proc/ui_neighbor) -> redundancy = data-backed disable candidates.
# Set NBR_JSON to also emit a machine-readable adjacency, then feed it to optimize-radios.sh:
NBR_JSON=.claude/tmp/<site>-nbr.json bash .claude/skills/unifi-wifi/scripts/neighbor-collect.sh cascades
NEIGHBOR_JSON=.claude/tmp/<site>-nbr.json bash .claude/skills/unifi-wifi/scripts/optimize-radios.sh cascades 14 ng
#   ^ optimize-radios uses the measured SNR overlap (not the sparse roam graph) -> real DISABLE list.
bash .claude/skills/unifi-wifi/scripts/neighbor-collect.sh cascades [vault-path] [snr_min=20]
# measured per-channel busy%/noise per AP -> cleanest-channel plan (iw survey dump)
bash .claude/skills/unifi-wifi/scripts/survey-collect.sh cascades [vault-path]
# empirical DFS radar-event history per AP (dmesg) -> is DFS safe at this site?
bash .claude/skills/unifi-wifi/scripts/dfs-check.sh cascades [vault-path]

The neighbor matrix is the breakthrough: UniFi exposes managed-AP-to-managed-AP visibility nowhere in the controller API/DB (all filter our own APs), but each AP keeps it in /proc/ui_neighbor/{ess_ap_list,ssid/*}, populated non-disruptively by background RRM scanning.

Ad-hoc Mongo queries: .claude/scripts/uos-mongo.sh (recipes in data-access.md). Access is the vaulted dedicated key infrastructure/uos-server-ssh-key (works from any fleet machine).

The two data planes (know which you're using)

  • Plane 1 — Mongo ace (available now, read-only via SSH): radio config (radio_table), the rogue neighbor-interference map, channelplan, AP/client inventory. Enough for the full config audit + channel/interference plan — covers the 2.4GHz "first problem".
  • Plane 2 — live Network API (stat/device, stat/sta; NOT yet wired): live channel utilization (cu_total), per-client RSSI/SNR/retries, AP satisfaction. Needed to validate changes (before/after) and find the worst APs by live airtime. Wiring it needs a dedicated read-only UniFi admin or integration API key on .29, vaulted as infrastructure/uos-server-network-api. See data-access.md "Plane 2".
  • Plane 3 - UniFi cloud (Site Manager API + connector; WIRED 2026-06-17): reaches ANY of the ~36 ACG consoles remotely with NO UOS server and NO LAN/VPN, via api.ui.com + the account key (vault services/unifi-site-manager). Tier 1 = fleet inventory/health/WAN-ISP; the CONNECTOR (/v1/connector/consoles/<id>/proxy/network/api/s/<site>/stat/{device,sta}) delivers the SAME cu_total/RSSI/satisfaction depth as Plane 1+2 (= UOS PARITY) for non-UOS / standalone UDMs. Backend: scripts/gw-sitemanager.sh (fleet|devices|sites|isp|net); catalog: references/site-manager-api.md. Use this for consoles NOT adopted into the UOS server.

Applying changes — IMPORTANT boundary

Config changes are automated across many APs (no per-AP UI clicking) via the controller REST API (PUT .../rest/device/<id> radio_table) — scripts/apply-radio.sh. Actions (all radio_table):

bash .../apply-radio.sh <site> <ng|na|6e> power   low|medium|high|auto|<dBm>  [--zone Z] [--apply]
bash .../apply-radio.sh <site> <ng|na|6e> width   20|40|80|160                [--zone Z] [--apply]
bash .../apply-radio.sh <site> <ng|na|6e> channel <number>|auto               [--zone Z] [--apply]
bash .../apply-radio.sh <site> <ng|na|6e> minrssi off|on|-<NN>                [--zone Z] [--apply]
bash .../apply-radio.sh <site> <ng|na|6e> disable      # radio OFF (tx_power_mode=disabled)  [--ap NAME] [--apply]
bash .../apply-radio.sh <site> <ng|na|6e> enable       # radio ON  (tx_power_mode=auto)      [--ap NAME] [--apply]

--ap "<name>" targets a single AP (the right scope for disables — execute optimize-radios' disable list one AP at a time: optimize-radios ... -> for each, apply-radio <site> ng disable --ap <name> --apply). Dry-run (default) prints per-AP before->after + rollback values + the REST payload. Writes are GATED OFF until (1) infrastructure/uos-server-network-api-rw is vaulted (the root SSH key is the data plane, NOT an API write session) and (2) --apply is passed. Even then: rollback is auto-saved to .claude/tmp/apply-rollback-*.json, go one --zone at a time, validate live with watch-ap.sh before and after, and never auto channel-optimize in ultra-dense sites. WRITE PATH VALIDATED 2026-06-16 (apply->verify->revert + full disable/enable cycle on 0-client 6 GHz radios). Radio disable IS implemented = tx_power_mode:"disabled" (confirmed via UI-toggle + device-JSON diff); enable sets it back to auto. min-data-rate / band-steering live in wlanconf (not radio_table) — separate future apply path (see references/ROADMAP.md). Get explicit go before any write. Full roadmap: references/ROADMAP.md.

WLAN-level knobs — scripts/apply-wlan.sh (wlanconf, not radio_table; affects every AP on the WLAN — target with --wlan). Same gated REST path (rest/wlanconf), dry-run default, rollback saved:

# tuning
apply-wlan.sh <site> minrate ng|na auto|off|<Mbps>   [--wlan N] [--apply]  # kill 1-11Mbps: minrate ng 12
apply-wlan.sh <site> dtim ng|na|6e <1-255>           [--wlan N] [--apply]  # DTIM (power-save/mcast)
apply-wlan.sh <site> mcast|bcfilter on|off           [--wlan N] [--apply]  # multicast-enhance / broadcast-filter
# steering / roaming
apply-wlan.sh <site> bandsteer on|off                [--wlan N] [--apply]  # no2ghz_oui: 5GHz-capable off 2.4
apply-wlan.sh <site> bands both|5g|5g6e|6e|all       [--wlan N] [--apply]  # wlan_bands: force SSID onto bands
apply-wlan.sh <site> steer|bsstm|rrm|ftroam on|off   [--wlan N] [--apply]  # roam-assist / 802.11v / 802.11k / 802.11r
# access / security
apply-wlan.sh <site> wlan on|off                     [--wlan N] [--apply]  # enable/disable the SSID
apply-wlan.sh <site> isolation|hidessid on|off       [--wlan N] [--apply]
apply-wlan.sh <site> macfilter off|allow|deny <macs> [--wlan N] [--apply]  # per-WLAN MAC allow/deny
apply-wlan.sh <site> aps <ap1,ap2|all>               [--wlan N] [--apply]  # broadcasting_aps: restrict SSID to APs

"Lock a device to an AP" (UniFi has no native per-client AP pin): use aps to put an SSID on only the chosen AP(s) ± macfilter to admit only that device — that constrains it to those APs. Band steering: no classic bandsteering_mode; replacements are bandsteer/bands/bsstm. (802.11r ftroam warns — risky for IoT.)

Client controls — scripts/client-control.sh (operational; controller-side, gated):

client-control.sh <site> block|unblock|kick <mac> [--apply]   # ban a MAC / un-ban / force-reconnect

Device / adoption remediation — scripts/device-control.sh (pairs with gw-audit flags; gated):

device-control.sh <site> adopt|restart|locate|unlocate|upgrade <mac> [--apply]
device-control.sh <site> poe-cycle <ap-name|mac> [--apply]   # RECOVER a hung AP: power-cycle its PoE switch port

SAFETY: provision/force-provision is removed — it took AP 445 fully offline on a U7-Pro (2026-06-16), requiring a physical port power-cycle. To recover a hung/offline AP, use poe-cycle (remote PoE port power-cycle of the AP's uplink port); to push config cleanly use restart or let the controller converge.

Gateway router actions — scripts/gw-control.sh (port-forwards + WAN firewall; the write side of gw-audit; gated/DRY-RUN, rollback saved):

gw-control.sh <site> pf-list                          # list port-forwards (id/on/proto/ports/dst/src)
gw-control.sh <site> pf-set-ports <name|id> <dst> [fwd]   # change forwarded ports (drop one, e.g. 1723)
gw-control.sh <site> pf-disable|pf-enable|pf-delete <name|id>
gw-control.sh <site> pf-set-src <name|id> <cidr|any>  # restrict a forward to a known source
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.

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 admin REST (rest/portforward|firewallrule|firewallgroup). block-ips clones an existing WAN_IN rule's schema for firmware compatibility — verify the new rule's precedence in the UI. Dry-run validated 2026-06-16 on Grabb & Durando (USG-3P): identified the live VPN forward (80,443,1723→.200) + GRE WAN_IN accept. Channel plan — scripts/channel-plan.sh (computes + applies a co-channel-minimizing plan):

NEIGHBOR_JSON=...nbr.json SURVEY_JSON=...survey.json \
  channel-plan.sh <site> ng|na [--apply]    # ng: 1/6/11 graph-color; na: cleanest NON-DFS + separation

ng uses the neighbor matrix to graph-color 1/6/11; na picks each AP's lowest-cost non-DFS channel (measured busy% + neighbor-collision penalty). Reports co-channel pairs before/after. (Cascades dry-run: ng 92→35 pairs; na 20→0 + all off DFS.) survey-collect.sh emits its JSON via SURVEY_JSON=<path>. GOTCHA (handled): a manual min rate is only honored when minrate_setting_preference=manual — the script sets it; minrate ... auto hands rate management back to the controller. Write path validated 2026-06-16 on a 0-client WLAN (Green Valley Computer Club) — apply->verify->restore.

Roadmap

  • Phase 1 (done): config + interference audit, flags, methodology. Read-only.
  • Phase 2: wire the live Network API (Plane 2) for cu_total/satisfaction/per-client RF → before/after validation + "worst APs by airtime" ranking.
  • Phase 3: assisted change application (per-zone, API-driven, with live before/after gating).

Notes

  • The methodology is independent-model guidance + config-plane recon — validate against live stats before trusting any single recommendation, and roll out per zone.
  • Multi-site: pass any site name/id; uos-mongo.sh --sites lists them. Cascades = 685f39068e65331c46ef6dd2.