Full verification of the skill against Cascades (live): - All 19 scripts syntax-clean. - Controller-side read-only validated live: sites, audit-site, switch-audit, live-stats, model-rank, optimize-radios, monitor-run, gw-audit. Dry-run apply paths validated: apply-radio, apply-wlan, client-control, device-control. AP-side mechanism validated: SSH auth + /proc/ui_neighbor read on a sample AP; full neighbor-collect (74-AP SNR sweep) -> channel-plan end-to-end produced a 1/6/11 plan. Fixes: - optimize-radios.sh: the `for(k in prof)` loop's numeric completion value was REPL-echoed by the legacy mongo shell (stray "94.56..." line in output). Terminated the loop body with `void 0` to suppress it. - ASCII-clean printed output (CLAUDE.md no-non-ASCII): replaced em-dashes / Unicode arrows / § that reached stdout and rendered as `?`/mojibake on the Windows console, across optimize-radios, neighbor-collect, survey-collect, dfs-check, audit-site, sites, monitor-run, apply-radio, apply-wlan, pfsense-backend. (Comment-only non-ASCII left as-is; never printed.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
148 lines
9.1 KiB
Bash
148 lines
9.1 KiB
Bash
#!/usr/bin/env bash
|
|
# apply-radio.sh — compute (and, when enabled, apply) a radio_table config change across a set of APs.
|
|
# DRY-RUN BY DEFAULT: prints the exact per-AP before->after, the REST payload, and captured rollback
|
|
# values. Writes are GATED off until a read-WRITE controller admin is vaulted AND --apply is passed
|
|
# (production safety; live facility). Roll out per --zone, validate with watch-ap.sh before+after.
|
|
#
|
|
# WHY API, not SSH/Mongo: UniFi config is controller-authoritative. The supported write path is the
|
|
# controller REST API (PUT /proxy/network/api/s/<site>/rest/device/<id>) with an ADMIN SESSION. The
|
|
# root SSH key is the data plane (reads/AP-watch), NOT an API write credential.
|
|
#
|
|
# Usage:
|
|
# bash .../apply-radio.sh <site> <band ng|na|6e> <action> <value> [--zone "Floor 3"] [--apply]
|
|
# Actions / values (all are radio_table fields):
|
|
# power low|medium|high|auto|<dBm> -> tx_power_mode (+ tx_power if a dBm number)
|
|
# width 20|40|80|160 -> ht (channel width)
|
|
# channel <number>|auto -> channel
|
|
# minrssi off|on|-<NN> -> min_rssi_enabled (+ min_rssi if a dBm number)
|
|
# disable -> tx_power_mode=disabled (turn the radio OFF)
|
|
# enable -> tx_power_mode=auto (turn it back ON)
|
|
# (radio disable == tx_power_mode "disabled"; confirmed 2026-06-16 by UI-toggle + device-JSON diff)
|
|
# Examples:
|
|
# apply-radio.sh cascades na width 40 # preview: 5GHz -> 40MHz everywhere
|
|
# apply-radio.sh cascades na width 40 --zone "Floor 4" --apply
|
|
# apply-radio.sh cascades ng minrssi -76 --apply # enable 2.4 min-RSSI -76
|
|
# apply-radio.sh cascades 6e power low --zone "Floor 6"
|
|
set -euo 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-radio.sh <site> <band> <action> [value] [--zone Z] [--ap NAME] [--apply]}"
|
|
BAND="${2:?band ng|na|6e}"; ACT="${3:?action: power|width|channel|minrssi|disable|enable}"
|
|
shift 3
|
|
VAL="" # disable/enable take no value; the others consume the next positional as the value
|
|
case "$ACT" in power|width|channel|minrssi) VAL="${1:-}"; shift || true;; esac
|
|
ZONE=""; APN=""; APPLY=0
|
|
while [ $# -gt 0 ]; do case "$1" in --zone) ZONE="$2"; shift 2;; --ap) APN="$2"; shift 2;; --apply) APPLY=1; shift;; *) shift;; esac; done
|
|
case "$BAND" in ng|na|6e) ;; *) echo "band must be ng|na|6e"; exit 1;; esac
|
|
|
|
# action+value -> the radio_table fields to set (compact JSON, used by both the preview JS and apply python)
|
|
case "$ACT" in
|
|
power)
|
|
if [[ "$VAL" =~ ^-?[0-9]+$ ]]; then FIELDS="{\"tx_power_mode\":\"custom\",\"tx_power\":$VAL}";
|
|
else case "$VAL" in low|medium|high|auto) FIELDS="{\"tx_power_mode\":\"$VAL\"}";; *) echo "power: low|medium|high|auto|<dBm>"; exit 1;; esac; fi ;;
|
|
width) case "$VAL" in 20|40|80|160) FIELDS="{\"ht\":$VAL}";; *) echo "width: 20|40|80|160"; exit 1;; esac ;;
|
|
channel) if [[ "$VAL" =~ ^[0-9]+$ ]]; then FIELDS="{\"channel\":$VAL}"; elif [ "$VAL" = auto ]; then FIELDS="{\"channel\":\"auto\"}"; else echo "channel: <number>|auto"; exit 1; fi ;;
|
|
minrssi) case "$VAL" in
|
|
off) FIELDS="{\"min_rssi_enabled\":false}";;
|
|
on) FIELDS="{\"min_rssi_enabled\":true}";;
|
|
-[0-9]*) FIELDS="{\"min_rssi_enabled\":true,\"min_rssi\":$VAL}";;
|
|
*) echo "minrssi: off|on|-<NN>"; exit 1;; esac ;;
|
|
disable) FIELDS="{\"tx_power_mode\":\"disabled\"}";; # turn the radio OFF (confirmed via UI diff 2026-06-16)
|
|
enable) FIELDS="{\"tx_power_mode\":\"auto\"}";; # turn it back ON (auto power)
|
|
*) echo "action must be power|width|channel|minrssi|disable|enable"; exit 1;;
|
|
esac
|
|
|
|
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 band=$BAND set $FIELDS${ZONE:+ zone='$ZONE'}${APN:+ ap='$APN'} mode=$([ $APPLY = 1 ] && echo APPLY || echo DRY-RUN)"
|
|
|
|
# ---- DRY-RUN preview: compare target fields vs current radio_table (Mongo) ----
|
|
cat <<JS | bash "$UOS" 2>&1 | grep -viE 'pq.html|post-quantum|store now|server may need'
|
|
var SITE='$SITE',BAND='$BAND',FIELDS=$FIELDS;
|
|
function zoneOf(n){var fz=String(n||'').match(/(\d)(?:st|nd|rd|th)\s*floor/i),rm=String(n||'').match(/\b(\d)\d{2}\b/);return fz?('Floor '+fz[1]):(rm?('Floor '+rm[1]):'misc');}
|
|
var n=0,skip=0;
|
|
db.device.find({site_id:SITE,type:'uap'},{name:1,radio_table:1}).forEach(function(a){
|
|
if('$ZONE' && zoneOf(a.name)!=='$ZONE') return;
|
|
if('$APN' && (a.name||'')!=='$APN') return;
|
|
(a.radio_table||[]).forEach(function(r){ if(r.radio!==BAND) return;
|
|
var change=false,roll={};
|
|
for(var f in FIELDS){ roll[f]=(r[f]!==undefined?r[f]:null); if(String(r[f])!==String(FIELDS[f])) change=true; }
|
|
if(!change){ skip++; return; }
|
|
n++; print("CHANGE "+(a.name||a._id)+" ["+BAND+"] "+JSON.stringify(roll)+" -> "+JSON.stringify(FIELDS));
|
|
});
|
|
});
|
|
print("\nSUMMARY: "+n+" radios would change, "+skip+" already at target.");
|
|
print("REST PUT /proxy/network/api/s/<site>/rest/device/<id> body: { radio_table:[ {radio:'"+BAND+"', ...current..., "+Object.keys(FIELDS).map(function(k){return k+':'+JSON.stringify(FIELDS[k]);}).join(', ')+"} ] }");
|
|
JS
|
|
|
|
if [ "$APPLY" != "1" ]; then
|
|
echo; echo "[dry-run] no changes made. Add --apply to write (needs the RW admin vaulted - see below)."
|
|
exit 0
|
|
fi
|
|
|
|
# ---- WRITE PATH (controller REST: login -> per-AP GET/modify/PUT, with rollback) ----
|
|
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)"
|
|
if [ -z "$RW_U" ] || [ -z "$RW_P" ]; then
|
|
cat <<EOF
|
|
|
|
[BLOCKED] --apply needs a read-WRITE controller admin vaulted at: $RWP
|
|
Create in UniFi OS -> Settings -> Admins (Full Management), then:
|
|
bash .claude/skills/vault/scripts/vault-helper.sh new $RWP --kind generic \\
|
|
--name 'UOS Network API (read-write admin)' --tag unifi --set username=<u> --set password=<pw>
|
|
EOF
|
|
exit 2
|
|
fi
|
|
|
|
export AR_SITE="$SITE" AR_BAND="$BAND" AR_FIELDS="$FIELDS" AR_ZONE="$ZONE" AR_AP="$APN" REPO
|
|
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,want_headers=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);hdr=resp.headers;txt=resp.read().decode('utf-8','replace')
|
|
return (txt,hdr) if want_headers else txt
|
|
try:_,hd=call('POST','/api/auth/login',{'username':os.environ['RW_U'],'password':os.environ['RW_P']},want_headers=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['AR_SITE']),None)
|
|
if not short:print("[ERROR] site resolve failed");sys.exit(1)
|
|
band=os.environ['AR_BAND'];zone=os.environ['AR_ZONE'];apn=os.environ.get('AR_AP','');fields=json.loads(os.environ['AR_FIELDS'])
|
|
def zof(n):
|
|
import re;n=n or ''
|
|
m=re.search(r'(\d)(?:st|nd|rd|th)\s*floor',n,re.I) or re.search(r'\b(\d)\d{2}\b',n)
|
|
return ('Floor '+m.group(1)) if m else 'misc'
|
|
devs=json.loads(call('GET',f'/proxy/network/api/s/{short}/stat/device')).get('data',[])
|
|
roll=[];done=0;fail=0
|
|
for d in devs:
|
|
if d.get('type')!='uap':continue
|
|
if zone and zof(d.get('name'))!=zone:continue
|
|
if apn and d.get('name')!=apn:continue
|
|
rt=d.get('radio_table') or [];changed=False;old=[]
|
|
for r in rt:
|
|
if r.get('radio')!=band:continue
|
|
old.append({f:r.get(f) for f in fields})
|
|
if all(str(r.get(f))==str(v) for f,v in fields.items()):continue
|
|
for f,v in fields.items():r[f]=v
|
|
changed=True
|
|
if not changed:continue
|
|
try:
|
|
call('PUT',f"/proxy/network/api/s/{short}/rest/device/{d['_id']}",{'radio_table':rt},csrf=csrf)
|
|
roll.append({'id':d['_id'],'name':d.get('name'),'old':old});done+=1;print(f" [ok] {d.get('name')} -> {band} {fields}")
|
|
except Exception as e:
|
|
fail+=1;print(f" [FAIL] {d.get('name')}: {e}")
|
|
rp=os.path.join(os.environ.get('REPO','.'),'.claude','tmp',f"apply-rollback-{short}-{band}-{'-'.join(fields)}.json")
|
|
try:
|
|
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 saved: {rp}")
|
|
except Exception as e:print("[APPLY] done; rollback save failed:",e)
|
|
print("[validate] watch the target APs live: watch-ap.sh <ap-ip> (before/after). Roll out per --zone.")
|
|
PY
|