diff --git a/.claude/skills/unifi-wifi/SKILL.md b/.claude/skills/unifi-wifi/SKILL.md index e3884cd..ba693d8 100644 --- a/.claude/skills/unifi-wifi/SKILL.md +++ b/.claude/skills/unifi-wifi/SKILL.md @@ -76,12 +76,18 @@ vaulted dedicated key `infrastructure/uos-server-ssh-key` (works from any fleet `infrastructure/uos-server-network-api`. See data-access.md "Plane 2". ## Applying changes — IMPORTANT boundary -This skill is **audit + advisory** today. **Writing config to the controller is not wired** and is -high-stakes (a bad channel/power/disable push degrades a live facility). When change-application is -added it MUST: go through the controller API with an authed account; change **one zone at a time**; -capture live `cu_total`/satisfaction **before and after** each change; and never run nightly/auto -channel optimization in ultra-dense sites (pin a manual plan). Until then, hand the recommended -plan to a tech to apply in the UniFi UI, or get explicit go before any write path is built. +Config changes CAN be automated across many APs (no per-AP UI clicking) via the controller REST API +(`PUT .../rest/device/` radio_table) — **`scripts/apply-radio.sh`** does this: +```bash +bash .claude/skills/unifi-wifi/scripts/apply-radio.sh cascades ng power low [--zone "Floor 3"] # DRY-RUN preview +``` +Dry-run prints per-AP before->after + rollback values + the exact REST payload. **Writes are GATED +OFF** until (1) a read-WRITE controller admin is vaulted (`infrastructure/uos-server-network-api-rw`; +the root SSH key is the data plane, NOT an API write session) and (2) `--apply` is passed — and even +then must: capture old values, go **one `--zone` at a time**, validate live `cu_total`/busy% with +`watch-ap.sh` **before and after**, and never run nightly/auto channel optimization in ultra-dense +sites. `disable` is intentionally NOT in apply-radio.sh yet — it needs the RF-neighbor table to prove +redundancy first (see interference-model.md). Get explicit go before enabling any write. ## Roadmap - **Phase 1 (done):** config + interference audit, flags, methodology. Read-only. diff --git a/.claude/skills/unifi-wifi/scripts/apply-radio.sh b/.claude/skills/unifi-wifi/scripts/apply-radio.sh new file mode 100644 index 0000000..0b9b32f --- /dev/null +++ b/.claude/skills/unifi-wifi/scripts/apply-radio.sh @@ -0,0 +1,70 @@ +#!/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//rest/device/) 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 power > [--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 power [--zone Z] [--apply]}"; BAND="${2:?band ng|na|6e}"; ACT="${3:?action: power}"; VAL="${4:?value: low|medium|high|auto|}" +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|"; 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 <&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//rest/device/):"); +print(" { \"radio_table\": [ { \"radio\":\""+BAND+"\", \"tx_power_mode\":\""+MODE+"\""+(DBM?(", \"tx_power\":"+DBM):"")+" , ...other fields unchanged... } ] }"); +JS + +if [ "$APPLY" = "1" ]; then + RW_U="$(bash "$VAULT" get-field infrastructure/uos-server-network-api-rw credentials.username 2>/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 +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)."