Files
claudetools/.claude/skills/unifi-wifi/scripts/apply-radio.sh
Mike Swanson 6772c96fef sync: auto-sync from GURU-5070 at 2026-06-15 20:03:03
Author: Mike Swanson
Machine: GURU-5070
Timestamp: 2026-06-15 20:03:03
2026-06-15 20:03:17 -07:00

131 lines
8.1 KiB
Bash

#!/usr/bin/env bash
# apply-radio.sh — compute (and, when enabled, apply) a radio config change across a set of APs.
# DRY-RUN BY DEFAULT: prints the exact per-AP before->after, the REST payload that would be sent,
# and the captured old values for rollback. Writes are GATED and off until a read-WRITE controller
# admin is vaulted AND --apply is passed (production safety; this is a live facility).
#
# 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
# vaulted root SSH key is the data plane (reads/AP-watch), NOT an API write credential. Editing Mongo
# + forcing a re-provision is possible with root but unsupported/fragile -- don't, on a live site.
#
# Usage:
# bash .../apply-radio.sh <site> <band ng|na|6e> power <low|medium|high|auto|<dBm>> [--zone "Floor 3"]
# (--apply to actually write — refused until infrastructure/uos-server-network-api-rw is vaulted)
# Examples:
# apply-radio.sh cascades ng power low # preview: drop all 2.4 radios to Low
# apply-radio.sh cascades ng power low --zone "Floor 3"# preview: just Floor 3
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> power <val> [--zone Z] [--apply]}"; BAND="${2:?band ng|na|6e}"; ACT="${3:?action: power}"; VAL="${4:?value: low|medium|high|auto|<dBm>}"
ZONE=""; APPLY=0; shift 4 || true
while [ $# -gt 0 ]; do case "$1" in --zone) ZONE="$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
[ "$ACT" = "power" ] || { echo "[ERROR] only 'power' is implemented (disable needs the RF-neighbor table; see interference-model.md)"; exit 1; }
# value -> tx_power_mode (+ tx_power if a dBm number)
if [[ "$VAL" =~ ^[0-9]+$ ]]; then MODE="custom"; DBM="$VAL"; else MODE="$VAL"; DBM=""; fi
case "$MODE" in low|medium|high|auto|custom) ;; *) echo "value must be low|medium|high|auto|<dBm>"; 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 -> tx_power_mode=$MODE${DBM:+ tx_power=${DBM}dBm}${ZONE:+ zone='$ZONE'} mode=$([ $APPLY = 1 ] && echo APPLY || echo DRY-RUN)"
# compute the change set from current config (Mongo)
cat <<JS | bash "$UOS" 2>&1 | grep -viE 'pq.html|post-quantum|store now|server may need' | tee /tmp/apply_preview.$$
var SITE='$SITE',BAND='$BAND',MODE='$MODE',DBM='$DBM',ZONE='$ZONE';
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;
(a.radio_table||[]).forEach(function(r){ if(r.radio!==BAND) return;
var curMode=r.tx_power_mode, curDbm=(r.tx_power!=null?r.tx_power:'');
if(curMode===MODE && (MODE!=='custom' || String(curDbm)===DBM)){ skip++; return; } // already at target
n++;
print("CHANGE "+(a.name||a._id)+" ["+BAND+"] tx_power_mode: "+curMode+(curMode=='custom'?('('+curDbm+')'):'')+" -> "+MODE+(DBM?('('+DBM+')'):'')
+" (rollback: mode="+curMode+(curDbm!==''?(' dbm='+curDbm):'')+")");
});
});
print("\nSUMMARY: "+n+" radios would change, "+skip+" already at target.");
print("REST payload per AP (what --apply WOULD PUT to /proxy/network/api/s/<site>/rest/device/<id>):");
print(" { \"radio_table\": [ { \"radio\":\""+BAND+"\", \"tx_power_mode\":\""+MODE+"\""+(DBM?(", \"tx_power\":"+DBM):"")+" , ...other fields unchanged... } ] }");
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 (login -> per-AP GET/modify/PUT via the controller REST API, 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
UniFi OS authenticates against unifi-core, so this admin must be created in the UI (it cannot be
minted from Mongo). Howard has full controller access — this is his to create (~1 min):
1) UniFi OS -> Settings -> Admins -> Add Admin (Full Management / site Admin; set username + password).
2) 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>
3) Re-run this command with --apply.
Once vaulted, this works for the whole fleet (incl. HOWARD-HOME).
EOF
exit 2
fi
export AR_SITE="$SITE" AR_BAND="$BAND" AR_MODE="$MODE" AR_DBM="$DBM" AR_ZONE="$ZONE"
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
# login + CSRF
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')
# resolve short site name
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'];mode=os.environ['AR_MODE'];dbm=os.environ['AR_DBM'];zone=os.environ['AR_ZONE']
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
rt=d.get('radio_table') or [];changed=False;old=[]
for r in rt:
if r.get('radio')!=band:continue
old.append({'tx_power_mode':r.get('tx_power_mode'),'tx_power':r.get('tx_power')})
if r.get('tx_power_mode')==mode and (mode!='custom' or str(r.get('tx_power'))==dbm):continue
r['tx_power_mode']=mode
if dbm:r['tx_power']=int(dbm)
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} {mode}{('('+dbm+')') if dbm else ''}")
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}.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