From 48592bd16b12fb5b6753303fa071505def678cc9 Mon Sep 17 00:00:00 2001 From: Howard Enos Date: Tue, 16 Jun 2026 07:27:06 -0700 Subject: [PATCH] sync: auto-sync from HOWARD-HOME at 2026-06-16 07:26:57 Author: Howard Enos Machine: HOWARD-HOME Timestamp: 2026-06-16 07:26:57 --- .claude/skills/unifi-wifi/SKILL.md | 12 ++ .../skills/unifi-wifi/references/ROADMAP.md | 10 +- .../skills/unifi-wifi/scripts/channel-plan.sh | 107 ++++++++++++++++++ .../unifi-wifi/scripts/device-control.sh | 57 ++++++++++ .../unifi-wifi/scripts/survey-collect.sh | 14 ++- ...026-06-15-howard-cascades-wifi-rf-audit.md | 19 ++++ 6 files changed, 214 insertions(+), 5 deletions(-) create mode 100644 .claude/skills/unifi-wifi/scripts/channel-plan.sh create mode 100644 .claude/skills/unifi-wifi/scripts/device-control.sh diff --git a/.claude/skills/unifi-wifi/SKILL.md b/.claude/skills/unifi-wifi/SKILL.md index 14e6aa6..da75340 100644 --- a/.claude/skills/unifi-wifi/SKILL.md +++ b/.claude/skills/unifi-wifi/SKILL.md @@ -172,6 +172,18 @@ no classic `bandsteering_mode`; replacements are `bandsteer`/`bands`/`bsstm`. (8 ```bash client-control.sh block|unblock|kick [--apply] # ban a MAC / un-ban / force-reconnect ``` +**Device / adoption remediation — `scripts/device-control.sh`** (pairs with gw-audit flags; gated): +```bash +device-control.sh adopt|restart|provision|locate|unlocate|upgrade [--apply] +``` +**Channel plan — `scripts/channel-plan.sh`** (computes + applies a co-channel-minimizing plan): +```bash +NEIGHBOR_JSON=...nbr.json SURVEY_JSON=...survey.json \ + channel-plan.sh 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=`. 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 d2bc949..dedcd97 100644 --- a/.claude/skills/unifi-wifi/references/ROADMAP.md +++ b/.claude/skills/unifi-wifi/references/ROADMAP.md @@ -39,7 +39,10 @@ side, multi-client enablement, and non-WiFi scope. Build/validate new apply acti 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. +- [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) - [ ] Per-client AP device-auth cred: vault `clients//unifi-ap-ssh`, pass as the script arg (only @@ -56,8 +59,9 @@ side, multi-client enablement, and non-WiFi scope. Build/validate new apply acti - [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. -- [ ] **Deeper firewall/VPN policy + adoption remediation** — read/health covered; config + remediation - actions (adopt a pending device, restart, etc.) are future. Access layer already reaches these. +- [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. +- [ ] **Deeper firewall/VPN policy** — config beyond health read; future. Access layer reaches it. ## D. Robustness / ops - [ ] **VPN-flap resilience** in the AP-side loops (resume/retry so a mid-run tunnel drop doesn't waste diff --git a/.claude/skills/unifi-wifi/scripts/channel-plan.sh b/.claude/skills/unifi-wifi/scripts/channel-plan.sh new file mode 100644 index 0000000..fbaac6c --- /dev/null +++ b/.claude/skills/unifi-wifi/scripts/channel-plan.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# channel-plan.sh — compute (and optionally apply) a per-AP channel plan that minimizes co-channel +# overlap, using the measured AP-to-AP neighbor matrix (neighbor-collect) + per-channel busy% (survey). +# ng (2.4): greedy graph-coloring into 1/6/11 so strong neighbors differ. +# na (5GHz): per AP pick the NON-DFS channel with lowest cost = measured busy% + neighbor-collision +# penalty (avoid DFS near military/airport radar; spreads APs apart). +# DRY-RUN default; --apply writes per-AP via the controller REST radio_table (gated, rollback saved). +# Inputs: NEIGHBOR_JSON (from neighbor-collect, required) + SURVEY_JSON (from survey-collect, for na). +# +# Usage: +# NEIGHBOR_JSON=.claude/tmp/-nbr.json SURVEY_JSON=.claude/tmp/-survey.json \ +# bash .../channel-plan.sh ng|na [--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" +HOST="${UOS_HOST:-172.16.3.29}"; PORT="${UOS_HTTPS_PORT:-11443}" +SITEARG="${1:?usage: channel-plan.sh [--apply]}"; BAND="${2:?band ng|na}"; APPLY=0 +shift 2; while [ $# -gt 0 ]; do case "$1" in --apply) APPLY=1; shift;; *) shift;; esac; done +case "$BAND" in ng|na) ;; *) echo "band must be ng|na (channel planning is for 2.4/5GHz)"; exit 1;; esac +NEIGHBOR_JSON="${NEIGHBOR_JSON:-}"; SURVEY_JSON="${SURVEY_JSON:-}"; SNR_MIN="${NBR_SNR_MIN:-20}" +[ -n "$NEIGHBOR_JSON" ] && [ -f "$NEIGHBOR_JSON" ] || { echo "[ERROR] NEIGHBOR_JSON required (run neighbor-collect.sh with NBR_JSON=...)"; exit 1; } +U="$(bash "$VAULT" get-field infrastructure/uos-server-network-api-rw credentials.username 2>/dev/null)" +P="$(bash "$VAULT" get-field infrastructure/uos-server-network-api-rw credentials.password 2>/dev/null)" +[ -n "$U" ] && [ -n "$P" ] || { echo "[ERROR] no controller cred"; 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] channel-plan site=$SITE band=$BAND mode=$([ $APPLY = 1 ] && echo APPLY || echo DRY-RUN) (neighbor=$NEIGHBOR_JSON survey=${SURVEY_JSON:-none})" +export CP_SITE="$SITE" CP_BAND="$BAND" CP_APPLY="$APPLY" CP_NBR="$NEIGHBOR_JSON" CP_SURVEY="$SURVEY_JSON" CP_SNR="$SNR_MIN" RW_U="$U" RW_P="$P" REPO +python - <<'PY' +import os,sys,json,ssl,urllib.request,http.cookiejar +band=os.environ['CP_BAND']; apply=os.environ['CP_APPLY']=='1'; SNR=int(os.environ['CP_SNR']) +nbr=json.load(open(os.environ['CP_NBR'])) +survey=json.load(open(os.environ['CP_SURVEY'])) if os.environ.get('CP_SURVEY') and os.path.exists(os.environ['CP_SURVEY']) else {} +sb={'ng':'2.4','na':'5'}[band] +CH = [1,6,11] if band=='ng' else [36,40,44,48,149,153,157,161] # non-DFS only (radar-safe) +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(m,p,b=None,csrf=None,wh=False): + d=json.dumps(b).encode() if b is not None else None + r=urllib.request.Request(base+p,data=d,method=m);r.add_header('Content-Type','application/json') + if csrf:r.add_header('X-CSRF-Token',csrf) + x=op.open(r,timeout=20);return (x.read().decode('utf-8','replace'),x.headers) if wh else x.read().decode('utf-8','replace') +_,hd=call('POST','/api/auth/login',{'username':os.environ['RW_U'],'password':os.environ['RW_P']},wh=True) +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['CP_SITE']),None) +devs=json.loads(call('GET',f'/proxy/network/api/s/{short}/stat/device')).get('data',[]) +# current channel per AP (by name) + name->(mac,_id,radio_table) +cur={}; meta={} +for d in devs: + if d.get('type')!='uap' or d.get('state')!=1: continue + nm=d.get('name') or d.get('mac'); meta[nm]=d + for r in d.get('radio_table_stats',[]): + if r.get('radio')==band: + try: cur[nm]=int(r.get('channel')) + except: cur[nm]=None +# adjacency: strong bidirectional neighbors (union over bands), by name +def msnr(a,b): + o=nbr.get(a,{}); + return max([o.get(x,{}).get(b,-1) for x in ('2.4','5','6')]+[-1]) +aps=[a for a in cur if a in nbr] +adj={a:set() for a in aps} +for a in aps: + for b in aps: + if a!=b and msnr(a,b)>=SNR and msnr(b,a)>=SNR: adj[a].add(b) +order=sorted(aps,key=lambda a:-len(adj[a])) # most-constrained first +plan={} +def busy(ap,ch): + try: return survey.get(ap,{}).get(sb,{}).get(str(ch),50) + except: return 50 +for a in order: + best=None;bestcost=1e9 + for ch in CH: + coll=sum(1 for n in adj[a] if plan.get(n)==ch) # strong neighbors already on this channel + cost = coll*1000 + (busy(a,ch) if band=='na' else 0) # ng: pure separation; na: separation + measured busy + if cost4} -> {c1} (strong-neighbor adj={len(adj[a])})") +if len(changes)>60: print(f" ...(+{len(changes)-60} more)") +# collision check after plan +post=sum(1 for a in aps for n in adj[a] if plan.get(n)==plan.get(a))//2 +pre =sum(1 for a in aps for n in adj[a] if cur.get(n)==cur.get(a) and cur.get(a))//2 +print(f"\nstrong-neighbor co-channel pairs: before={pre} after={post}") +if not apply: + print("\n[dry-run] no changes. Add --apply to write per-AP channels (gated, rollback saved).") + sys.exit(0) +# APPLY: per changed AP, set radio_table channel +roll=[];done=0;fail=0 +for a,c0,c1 in changes: + d=meta[a]; rt=d.get('radio_table') or [] + for r in rt: + if r.get('radio')==band: r['channel']=c1 + try: + call('PUT',f"/proxy/network/api/s/{short}/rest/device/{d['_id']}",{'radio_table':rt},csrf=csrf) + roll.append({'name':a,'band':band,'old':c0}); done+=1; print(f" [ok] {a} {band} -> ch{c1}") + except Exception as e: fail+=1; print(f" [FAIL] {a}: {e}") +rp=os.path.join(os.environ.get('REPO','.'),'.claude','tmp',f"chanplan-rollback-{short}-{band}.json") +os.makedirs(os.path.dirname(rp),exist_ok=True); open(rp,'w').write(json.dumps(roll,indent=1)) +print(f"\n[APPLY] {done} changed, {fail} failed. Rollback: {rp}") +print("[validate] re-run survey-collect / watch-ap after settle; roll out per-zone in practice.") +PY diff --git a/.claude/skills/unifi-wifi/scripts/device-control.sh b/.claude/skills/unifi-wifi/scripts/device-control.sh new file mode 100644 index 0000000..1dc5caa --- /dev/null +++ b/.claude/skills/unifi-wifi/scripts/device-control.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# device-control.sh — adoption remediation + device ops via the controller (cmd/devmgr). +# Pairs with gw-audit.sh, which flags pending/disconnected/upgradable devices; this remediates them. +# adopt adopt a pending device +# restart reboot a device +# provision force re-provision (push config) — fixes "stuck"/out-of-sync devices +# locate flash the device LED (find it physically) +# unlocate stop flashing +# upgrade upgrade firmware to the controller-recommended version +# DRY-RUN default; --apply gated behind infrastructure/uos-server-network-api-rw. Controller-side +# (no AP cred / VPN) -> any UOS site. Find MACs with: gw-audit.sh / sites.sh / stat/device. +# +# Usage: bash .claude/skills/unifi-wifi/scripts/device-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: device-control.sh [--apply]}" +ACT="${2:?action: adopt|restart|provision|locate|unlocate|upgrade}"; 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 + adopt) CMD="adopt";; restart) CMD="restart";; provision) CMD="force-provision";; + locate) CMD="set-locate";; unlocate) CMD="unset-locate";; upgrade) CMD="upgrade";; + *) echo "action: adopt|restart|provision|locate|unlocate|upgrade"; 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/devmgr {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 DC_SITE="$SITE" DC_CMD="$CMD" DC_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=30);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['DC_SITE']),None) +if not short:print("[ERROR] site resolve failed");sys.exit(1) +try: + r=call('POST',f"/proxy/network/api/s/{short}/cmd/devmgr",{'cmd':os.environ['DC_CMD'],'mac':os.environ['DC_MAC']},csrf=csrf) + meta=json.loads(r).get('meta',{}) + print(f" [{'ok' if meta.get('rc')=='ok' else 'FAIL'}] {os.environ['DC_CMD']} {os.environ['DC_MAC']} -> {meta}") +except Exception as e:print(" [FAIL]",e) +PY diff --git a/.claude/skills/unifi-wifi/scripts/survey-collect.sh b/.claude/skills/unifi-wifi/scripts/survey-collect.sh index 5dbcd72..fea96c3 100644 --- a/.claude/skills/unifi-wifi/scripts/survey-collect.sh +++ b/.claude/skills/unifi-wifi/scripts/survey-collect.sh @@ -69,8 +69,9 @@ while IFS=$'\t' read -r name ip; do done < "$TMP/aps.tsv"; echo "" >&2 # --- parse + report cleanest channels per AP per band --- -python - "$RAW" <<'PY' -import sys,re +# Set SURVEY_JSON= to also emit machine-readable {ap:{band:{channel:busy%}}} (for channel-plan.sh). +python - "$RAW" "${SURVEY_JSON:-NONE}" <<'PY' +import sys,re,json def band(f): f=int(f) if 24002 else 'NONE' +if OUT!='NONE': + j={} + for ap in data: + for b in data[ap]: + for busy,c,inuse,noise in data[ap][b]: + j.setdefault(ap,{}).setdefault(b,{})[str(c)]=busy # busy% per channel + json.dump(j,open(OUT,'w')) + print(f"\n[INFO] wrote survey JSON -> {OUT} ({len(j)} APs) — feed to channel-plan.sh via SURVEY_JSON") PY echo "" echo "[next] use the cleanest-channel data for a manual 1/6/11 (2.4) + non-DFS (5GHz) plan; apply via apply-radio.sh per zone." 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 a7286a9..5788118 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 @@ -483,3 +483,22 @@ SKILL now end-to-end for any client: WiFi (monitor + tune + full apply incl devi switch/PoE audit, gateway/WAN/site-health audit, sites.sh discovery. ROADMAP remaining: deeper firewall/VPN policy + adoption REMEDIATION (adopt/restart), channel-plan apply, per-client AP creds/VPN. Coord: this msg. + +--- + +## Update: 2026-06-16 07:26 PT — adoption remediation (device-control.sh) + channel-plan apply (channel-plan.sh) + +NEW device-control.sh adopt|restart|provision|locate|unlocate|upgrade [--apply] (cmd/devmgr, +gated, controller-side) — remediates what gw-audit flags. Validated locate+unlocate on AP 622 (rc:ok). + +NEW channel-plan.sh ng|na [--apply] (NEIGHBOR_JSON + SURVEY_JSON): + ng: graph-color 1/6/11 via the neighbor matrix (strong neighbors differ). + na: per-AP lowest-cost NON-DFS channel = measured busy% (survey) + neighbor-collision penalty. + Reports co-channel pairs before/after; gated apply per-AP rest/device + rollback. + DRY-RUN validated on Cascades: ng 92->35 co-channel pairs (51 APs change); na 20->0 + ALL moved off + DFS onto non-DFS (radar-safe) (71 APs). Not applied (production paused); PUT mechanism proven. + Added SURVEY_JSON= emit to survey-collect.sh ({ap:{band:{channel:busy%}}}). + +SKILL now: WiFi (monitor+tune+full apply+device-lock+client/device control+channel-plan) + switch/PoE +audit + gateway/WAN/site-health + multi-client. ROADMAP nearly clear (deeper firewall/VPN policy + +per-client AP creds/VPN remain). Coord: this msg.