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
This commit is contained in:
@@ -172,6 +172,18 @@ no classic `bandsteering_mode`; replacements are `bandsteer`/`bands`/`bsstm`. (8
|
||||
```bash
|
||||
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):
|
||||
```bash
|
||||
device-control.sh <site> adopt|restart|provision|locate|unlocate|upgrade <mac> [--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 <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.
|
||||
|
||||
@@ -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 <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.
|
||||
- [ ] **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/<x>/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
|
||||
|
||||
107
.claude/skills/unifi-wifi/scripts/channel-plan.sh
Normal file
107
.claude/skills/unifi-wifi/scripts/channel-plan.sh
Normal file
@@ -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/<site>-nbr.json SURVEY_JSON=.claude/tmp/<site>-survey.json \
|
||||
# bash .../channel-plan.sh <site> 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 <site> <ng|na> [--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 cost<bestcost: bestcost=cost; best=ch
|
||||
plan[a]=best
|
||||
changes=[(a,cur[a],plan[a]) for a in sorted(plan) if cur.get(a)!=plan[a]]
|
||||
print(f"\n==== CHANNEL PLAN band={band} ({len(aps)} APs, {len(changes)} would change) ====")
|
||||
print(f"allowed channels: {CH} (non-DFS only)")
|
||||
for a,c0,c1 in changes[:60]:
|
||||
print(f" {a:<22} {str(c0):>4} -> {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
|
||||
57
.claude/skills/unifi-wifi/scripts/device-control.sh
Normal file
57
.claude/skills/unifi-wifi/scripts/device-control.sh
Normal file
@@ -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 <mac> adopt a pending device
|
||||
# restart <mac> reboot a device
|
||||
# provision<mac> force re-provision (push config) — fixes "stuck"/out-of-sync devices
|
||||
# locate <mac> flash the device LED (find it physically)
|
||||
# unlocate <mac> stop flashing
|
||||
# upgrade <mac> 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 <site> <adopt|restart|provision|locate|unlocate|upgrade> <mac> [--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 <site> <action> <mac> [--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
|
||||
@@ -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=<path> 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 2400<f<2500: return '2.4'
|
||||
@@ -109,6 +110,15 @@ for ap in sorted(data):
|
||||
nondfs=sorted([r for r in rows if r[1] not in DFS], key=lambda r:r[0])[:3]
|
||||
clean=", ".join(f"ch{c}({bz}%)" for bz,c,_,_ in nondfs)
|
||||
print(f" {b}GHz in-use {iu} | cleanest non-DFS: {clean}")
|
||||
OUT=sys.argv[2] if len(sys.argv)>2 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."
|
||||
|
||||
@@ -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 <site> adopt|restart|provision|locate|unlocate|upgrade <mac> [--apply] (cmd/devmgr,
|
||||
gated, controller-side) — remediates what gw-audit flags. Validated locate+unlocate on AP 622 (rc:ok).
|
||||
|
||||
NEW channel-plan.sh <site> 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=<path> 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.
|
||||
|
||||
Reference in New Issue
Block a user