diff --git a/.claude/skills/unifi-wifi/scripts/apply-radio.sh b/.claude/skills/unifi-wifi/scripts/apply-radio.sh index 0b9b32f..9b3670e 100644 --- a/.claude/skills/unifi-wifi/scripts/apply-radio.sh +++ b/.claude/skills/unifi-wifi/scripts/apply-radio.sh @@ -51,20 +51,80 @@ print("REST payload per AP (what --apply WOULD PUT to /proxy/network/api/s//dev/null || true)" - if [ -z "$RW_U" ]; then - echo - echo "[BLOCKED] --apply requested but no read-WRITE admin vaulted. This is intentional (live facility)." - echo " 1) Create a read-WRITE admin in the UniFi UI (OS Settings -> Admins -> full/site admin)." - echo " 2) Vault: bash .claude/skills/vault/scripts/vault-helper.sh new infrastructure/uos-server-network-api-rw \\" - echo " --kind generic --name 'UOS Network API (read-write admin)' --tag unifi --set username= --set password=" - echo " 3) Re-run with --apply. (The write path will: capture old values, PUT per AP via the REST API," - echo " and you validate with watch-ap.sh before/after. Roll out per --zone, not site-wide.)" - exit 2 - fi - echo "[ERROR] write path is staged but not yet wired in this version — confirm read/watch loop with Howard first, then I'll enable it." - exit 3 +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 -echo -echo "[dry-run] no changes made. Re-run with --apply once a read-write admin is vaulted (and after the live-watch loop is validated)." + +# ----- 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 < 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= --set password= + 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=dict(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 (before/after). Roll out per --zone.") +PY