diff --git a/.claude/skills/unifi-wifi/SKILL.md b/.claude/skills/unifi-wifi/SKILL.md index feb2469..969f7a0 100644 --- a/.claude/skills/unifi-wifi/SKILL.md +++ b/.claude/skills/unifi-wifi/SKILL.md @@ -145,14 +145,28 @@ 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: ```bash -bash .../apply-wlan.sh minrate ng|na auto|off| [--wlan NAME] [--apply] # kill 1-11Mbps: minrate ng 12 -bash .../apply-wlan.sh bandsteer on|off [--wlan NAME] [--apply] # no2ghz_oui: steer 5GHz-capable off 2.4 -bash .../apply-wlan.sh bands both|5g|5g6e|6e|all [--wlan NAME] [--apply] # wlan_bands: force SSID onto bands -bash .../apply-wlan.sh steer on|off [--wlan NAME] [--apply] # roaming_assistant_na (sticky-client kick) -bash .../apply-wlan.sh bsstm on|off [--wlan NAME] [--apply] # bss_transition (802.11v) +# tuning +apply-wlan.sh minrate ng|na auto|off| [--wlan N] [--apply] # kill 1-11Mbps: minrate ng 12 +apply-wlan.sh dtim ng|na|6e <1-255> [--wlan N] [--apply] # DTIM (power-save/mcast) +apply-wlan.sh mcast|bcfilter on|off [--wlan N] [--apply] # multicast-enhance / broadcast-filter +# steering / roaming +apply-wlan.sh bandsteer on|off [--wlan N] [--apply] # no2ghz_oui: 5GHz-capable off 2.4 +apply-wlan.sh bands both|5g|5g6e|6e|all [--wlan N] [--apply] # wlan_bands: force SSID onto bands +apply-wlan.sh steer|bsstm|rrm|ftroam on|off [--wlan N] [--apply] # roam-assist / 802.11v / 802.11k / 802.11r +# access / security +apply-wlan.sh wlan on|off [--wlan N] [--apply] # enable/disable the SSID +apply-wlan.sh isolation|hidessid on|off [--wlan N] [--apply] +apply-wlan.sh macfilter off|allow|deny [--wlan N] [--apply] # per-WLAN MAC allow/deny +apply-wlan.sh aps [--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): +```bash +client-control.sh block|unblock|kick [--apply] # ban a MAC / un-ban / force-reconnect ``` -Band steering: modern UniFi has NO classic `bandsteering_mode`; its replacements are `no2ghz_oui` -(`bandsteer`) + `wlan_bands` (`bands` — 5g/5g6e forces clients up) + 802.11v `bss_transition` (`bsstm`). 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. diff --git a/.claude/skills/unifi-wifi/references/ROADMAP.md b/.claude/skills/unifi-wifi/references/ROADMAP.md index 4b7e812..bb1e163 100644 --- a/.claude/skills/unifi-wifi/references/ROADMAP.md +++ b/.claude/skills/unifi-wifi/references/ROADMAP.md @@ -32,6 +32,13 @@ side, multi-client enablement, and non-WiFi scope. Build/validate new apply acti 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 ` (broadcasting_aps = restrict SSID to + APs, the "lock to AP" lever) + `apply-wlan macfilter off|allow|deny `; 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. - [ ] **channel-plan apply** — feed `survey-collect` cleanest-channel output into a per-AP channel set. ## B. Multi-client enablement (use on any client we manage) diff --git a/.claude/skills/unifi-wifi/scripts/apply-wlan.sh b/.claude/skills/unifi-wifi/scripts/apply-wlan.sh index 6c494be..e1f6b37 100644 --- a/.claude/skills/unifi-wifi/scripts/apply-wlan.sh +++ b/.claude/skills/unifi-wifi/scripts/apply-wlan.sh @@ -22,9 +22,13 @@ 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" SITEARG="${1:?usage: apply-wlan.sh ... [--wlan NAME] [--apply]}" -ACT="${2:?action: minrate|steer|bandsteer|bands|bsstm}"; shift 2 +ACT="${2:?action: minrate|bandsteer|bands|steer|bsstm|wlan|dtim|mcast|bcfilter|rrm|ftroam|isolation|hidessid|macfilter|aps}"; shift 2 WLAN=""; APPLY=0 -# action-specific positional args +# resolve SITE now (the 'aps' action needs it to map AP names -> MACs) +if [[ "$SITEARG" =~ ^[0-9a-f]{24}$ ]]; then SITE="$SITEARG"; else + SITE="$(bash "$UOS" --sites 2>/dev/null | grep -vi 'pq.html' | grep -i "$SITEARG" | awk '{print $1}' | head -1)"; fi +[ -n "$SITE" ] || { echo "[ERROR] site not found"; exit 1; } +# action-specific positional args -> FIELDS (the wlanconf fields to set) case "$ACT" in minrate) RBAND="${1:?minrate }"; RVAL="${2:?minrate }"; shift 2 case "$RBAND" in ng|na) ;; *) echo "minrate band must be ng|na"; exit 1;; esac @@ -48,13 +52,42 @@ case "$ACT" in *) echo "bands: both|5g|5g6e|6e|all"; exit 1;; esac ;; bsstm) SVAL="${1:?bsstm }"; shift # 802.11v BSS Transition Management (assists steering/roaming) case "$SVAL" in on) FIELDS="{\"bss_transition\":true}";; off) FIELDS="{\"bss_transition\":false}";; *) echo "bsstm: on|off"; exit 1;; esac ;; - *) echo "action must be minrate|steer|bandsteer|bands|bsstm"; exit 1;; + wlan) SVAL="${1:?wlan }"; shift # enable/disable the whole SSID + case "$SVAL" in on) FIELDS="{\"enabled\":true}";; off) FIELDS="{\"enabled\":false}";; *) echo "wlan: on|off"; exit 1;; esac ;; + dtim) DBAND="${1:?dtim <1-255>}"; DV="${2:?dtim <1-255>}"; shift 2 + case "$DBAND" in ng|na|6e) ;; *) echo "dtim band: ng|na|6e"; exit 1;; esac + [[ "$DV" =~ ^[0-9]+$ ]] || { echo "dtim value: 1-255"; exit 1; } + FIELDS="{\"dtim_mode\":\"custom\",\"dtim_${DBAND}\":$DV}" ;; # per-band DTIM needs dtim_mode=custom + mcast) SVAL="${1:?mcast }"; shift # multicast enhancement (convert mcast->unicast) + case "$SVAL" in on) FIELDS="{\"mcastenhance_enabled\":true}";; off) FIELDS="{\"mcastenhance_enabled\":false}";; *) echo "mcast: on|off"; exit 1;; esac ;; + bcfilter)SVAL="${1:?bcfilter }"; shift # block broadcast (ARP/DHCP storms) on the WLAN + case "$SVAL" in on) FIELDS="{\"bc_filter_enabled\":true}";; off) FIELDS="{\"bc_filter_enabled\":false}";; *) echo "bcfilter: on|off"; exit 1;; esac ;; + rrm) SVAL="${1:?rrm }"; shift # 802.11k neighbor reports (roaming assist) + case "$SVAL" in on) FIELDS="{\"rrm_enabled\":true}";; off) FIELDS="{\"rrm_enabled\":false}";; *) echo "rrm: on|off"; exit 1;; esac ;; + ftroam) SVAL="${1:?ftroam }"; shift # 802.11r fast roaming + case "$SVAL" in on) FIELDS="{\"fast_roaming_enabled\":true}"; echo "[WARNING] 802.11r can break legacy/medical/IoT clients — test on a scoped SSID first.";; off) FIELDS="{\"fast_roaming_enabled\":false}";; *) echo "ftroam: on|off"; exit 1;; esac ;; + isolation) SVAL="${1:?isolation }"; shift # L2 client isolation + case "$SVAL" in on) FIELDS="{\"l2_isolation\":true}";; off) FIELDS="{\"l2_isolation\":false}";; *) echo "isolation: on|off"; exit 1;; esac ;; + hidessid) SVAL="${1:?hidessid }"; shift + case "$SVAL" in on) FIELDS="{\"hide_ssid\":true}";; off) FIELDS="{\"hide_ssid\":false}";; *) echo "hidessid: on|off"; exit 1;; esac ;; + macfilter) MV="${1:?macfilter [mac,mac,...]}"; shift # per-WLAN MAC allow/deny list + case "$MV" in + off) FIELDS="{\"mac_filter_enabled\":false}";; + allow|deny) ML="${1:?macfilter $MV }"; shift + macs=$(echo "$ML" | tr 'A-Z,' 'a-z\n' | sed '/^$/d; s/^/"/; s/$/"/' | paste -sd, -) + FIELDS="{\"mac_filter_enabled\":true,\"mac_filter_policy\":\"$MV\",\"mac_filter_list\":[$macs]}" ;; + *) echo "macfilter: off | allow | deny "; exit 1;; esac ;; + aps) AV="${1:?aps }"; shift # restrict the WLAN to specific APs (broadcasting_aps) = closest thing to 'lock to AP' + if [ "$AV" = all ]; then FIELDS="{\"broadcasting_aps\":[]}"; + else + macs=$(echo "$AV" | tr ',' '\n' | while IFS= read -r nm; do [ -z "$nm" ] && continue; printf 'db.device.find({site_id:"%s",type:"uap",name:"%s"},{mac:1}).forEach(function(d){print(d.mac);});\n' "$SITE" "$nm"; done | bash "$UOS" 2>/dev/null | grep -iE '^[0-9a-f:]{17}$' | sed 's/^/"/; s/$/"/' | paste -sd, -) + [ -n "$macs" ] || { echo "[ERROR] could not resolve any AP name in: $AV"; exit 1; } + FIELDS="{\"broadcasting_aps\":[$macs]}" + fi ;; + *) echo "action must be minrate|bandsteer|bands|steer|bsstm|wlan|dtim|mcast|bcfilter|rrm|ftroam|isolation|hidessid|macfilter|aps"; exit 1;; esac while [ $# -gt 0 ]; do case "$1" in --wlan) WLAN="$2"; shift 2;; --apply) APPLY=1; shift;; *) shift;; esac; done -if [[ "$SITEARG" =~ ^[0-9a-f]{24}$ ]]; then SITE="$SITEARG"; else - SITE="$(bash "$UOS" --sites 2>/dev/null | grep -vi 'pq.html' | grep -i "$SITEARG" | awk '{print $1}' | head -1)"; fi -[ -n "$SITE" ] || { echo "[ERROR] site not found"; exit 1; } echo "[INFO] site=$SITE set $FIELDS${WLAN:+ wlan='$WLAN'} mode=$([ $APPLY = 1 ] && echo APPLY || echo DRY-RUN)" [ -z "$WLAN" ] && echo "[WARNING] no --wlan filter: this targets ALL WLANs at the site (incl. Guest). Consider --wlan." diff --git a/.claude/skills/unifi-wifi/scripts/client-control.sh b/.claude/skills/unifi-wifi/scripts/client-control.sh new file mode 100644 index 0000000..41ef468 --- /dev/null +++ b/.claude/skills/unifi-wifi/scripts/client-control.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# client-control.sh — operational per-client wifi controls via the controller (cmd/stamgr). +# block = ban a MAC from the wifi entirely (persists until unblock) +# unblock = remove the ban +# kick = force-deauth/reconnect a client now (one-shot; e.g. to re-steer it after a change) +# These are the device-level "lock"/eviction tools (UniFi has no per-client AP pin; to constrain a +# device to an AP use apply-wlan 'aps' (broadcasting_aps) ± 'macfilter' on a dedicated SSID). +# +# DRY-RUN default; --apply gated behind infrastructure/uos-server-network-api-rw. Controller-side +# (no AP cred / VPN needed) -> works for any site. +# +# Usage: bash .../client-control.sh [--apply] +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" +SITEARG="${1:?usage: client-control.sh [--apply]}" +ACT="${2:?action: block|unblock|kick}"; MAC="$(echo "${3:?mac required}" | tr 'A-Z' 'a-z')"; APPLY=0 +shift 3; while [ $# -gt 0 ]; do case "$1" in --apply) APPLY=1; shift;; *) shift;; esac; done +case "$ACT" in block) CMD="block-sta";; unblock) CMD="unblock-sta";; kick) CMD="kick-sta";; *) echo "action: block|unblock|kick"; exit 1;; esac +[[ "$MAC" =~ ^[0-9a-f:]{17}$ ]] || { echo "[ERROR] mac must be aa:bb:cc:dd:ee:ff"; exit 1; } +if [[ "$SITEARG" =~ ^[0-9a-f]{24}$ ]]; then SITE="$SITEARG"; else + SITE="$(bash "$UOS" --sites 2>/dev/null | grep -vi 'pq.html' | grep -i "$SITEARG" | awk '{print $1}' | head -1)"; fi +[ -n "$SITE" ] || { echo "[ERROR] site not found"; exit 1; } +echo "[INFO] site=$SITE $ACT ($CMD) mac=$MAC mode=$([ $APPLY = 1 ] && echo APPLY || echo DRY-RUN)" +if [ "$APPLY" != "1" ]; then echo "[dry-run] would POST cmd/stamgr {cmd:'$CMD', mac:'$MAC'}. Add --apply."; exit 0; fi + +RWP="infrastructure/uos-server-network-api-rw" +export RW_U="$(bash "$VAULT" get-field "$RWP" credentials.username 2>/dev/null || true)" +export RW_P="$(bash "$VAULT" get-field "$RWP" credentials.password 2>/dev/null || true)" +[ -n "$RW_U" ] && [ -n "$RW_P" ] || { echo "[BLOCKED] --apply needs RW admin vaulted at $RWP"; exit 2; } +export CC_SITE="$SITE" CC_CMD="$CMD" CC_MAC="$MAC" +python - <<'PY' +import os,sys,json,ssl,urllib.request,http.cookiejar +H="172.16.3.29";PORT=11443;base=f"https://{H}:{PORT}" +ctx=ssl.create_default_context();ctx.check_hostname=False;ctx.verify_mode=ssl.CERT_NONE +cj=http.cookiejar.CookieJar();op=urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj),urllib.request.HTTPSHandler(context=ctx)) +def call(method,path,body=None,csrf=None,wh=False): + data=json.dumps(body).encode() if body is not None else None + r=urllib.request.Request(base+path,data=data,method=method);r.add_header('Content-Type','application/json') + if csrf:r.add_header('X-CSRF-Token',csrf) + resp=op.open(r,timeout=20);return (resp.read().decode('utf-8','replace'),resp.headers) if wh else resp.read().decode('utf-8','replace') +try:_,hd=call('POST','/api/auth/login',{'username':os.environ['RW_U'],'password':os.environ['RW_P']},wh=True) +except Exception as e:print("[ERROR] login failed:",e);sys.exit(1) +csrf=hd.get('X-CSRF-Token') or hd.get('X-Updated-Csrf-Token') +sites=json.loads(call('GET','/proxy/network/api/self/sites')).get('data',[]) +short=next((s['name'] for s in sites if s.get('_id')==os.environ['CC_SITE']),None) +if not short:print("[ERROR] site resolve failed");sys.exit(1) +try: + r=call('POST',f"/proxy/network/api/s/{short}/cmd/stamgr",{'cmd':os.environ['CC_CMD'],'mac':os.environ['CC_MAC']},csrf=csrf) + meta=json.loads(r).get('meta',{}) + print(f" [{'ok' if meta.get('rc')=='ok' else 'FAIL'}] {os.environ['CC_CMD']} {os.environ['CC_MAC']} -> {meta}") +except Exception as e:print(" [FAIL]",e) +PY diff --git a/clients/cascades-tucson/session-logs/2026-06/2026-06-15-howard-cascades-wifi-rf-audit.md b/clients/cascades-tucson/session-logs/2026-06/2026-06-15-howard-cascades-wifi-rf-audit.md index bf5a6fa..a19e7d9 100644 --- a/clients/cascades-tucson/session-logs/2026-06/2026-06-15-howard-cascades-wifi-rf-audit.md +++ b/clients/cascades-tucson/session-logs/2026-06/2026-06-15-howard-cascades-wifi-rf-audit.md @@ -424,3 +424,25 @@ Also surfaced setting.radio_ai (Channel AI: channels_na includes DFS, ht_modes_n WiFi APPLY SURFACE NOW COMPLETE: apply-radio (power all-states/width/channel/minrssi/disable/enable, --zone/--ap) + apply-wlan (minrate/bandsteer/bands/steer/bsstm, --wlan), all gated + rollback + validated on 0-client sandboxes. Coord: this msg. Remaining (ROADMAP C): switch/PoE + gateway collectors. + +--- + +## Update: 2026-06-16 01:09 PT — WiFi control surface complete: full wlanconf knobs + device-lock + client controls + +(a) DEVICE LOCK / CONTROL: +- apply-wlan aps -> broadcasting_aps (resolves AP NAMES->MACs; restricts SSID to APs = + the "lock device to AP" lever, since UniFi has NO native per-client AP pin). Validated dry-run: + aps 622 --wlan CSCNet -> broadcasting_aps=[0c:ea:14:3e:65:a2]. +- apply-wlan macfilter off|allow|deny -> mac_filter_*. Dry-run validated. +- NEW client-control.sh: block|unblock|kick a MAC via cmd/stamgr (gated). Validated block+unblock on + dummy 00:00:00:00:00:01 (rc:ok), cleaned up. + +(b) REMAINING wlanconf TUNING KNOBS added to apply-wlan: wlan(enable/disable), dtim + (dtim_mode=custom gate), mcast(mcastenhance), bcfilter, rrm(802.11k), ftroam(802.11r, warns), + isolation(l2_isolation), hidessid. dtim apply-validated on 0-client Green Valley (mode default->custom, + reverted). Others via the proven gated REST path. + +apply-wlan restructured: SITE resolved before the action case (so aps can map names->macs). +WiFi APPLY SURFACE COMPLETE: apply-radio (radio_table) + apply-wlan (wlanconf, 15 actions) + +client-control (cmd/stamgr), all gated + rollback + validated on 0-client sandboxes / dummy MAC. +SKILL.md + ROADMAP updated. Coord: this msg. NEXT: switches/PoE collector (ROADMAP C).