Files
claudetools/.claude/skills/unifi-wifi/scripts/dfs-check.sh
Howard Enos 412c0eff31 unifi-wifi: skill health pass — fix optimize-radios stray REPL echo + ASCII-clean all output
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>
2026-06-16 15:08:35 -07:00

91 lines
5.8 KiB
Bash

#!/usr/bin/env bash
# dfs-check.sh — empirical DFS radar-event history across a site's APs (the real DFS reality check).
#
# UniFi does NOT surface radar/DFS events in the controller DB/API. Each AP's kernel ring buffer
# (`dmesg`) records actual radar detections + channel-availability-check (CAC) events. This sweeps
# every AP, greps `dmesg` with PRECISE patterns (the loose `cac`/`dfs` grep false-matches "cached
# ifindex" — excluded here), and reports which APs on DFS channels (52-144) have actually been hit.
# Answers "is DFS safe to use at this site?" with data (matters near military/airport radar).
# Non-disruptive read. Works for any UOS site.
#
# Needs: controller cred (infrastructure/uos-server-network-api-rw, for AP list+5GHz channels) +
# per-site AP device-auth SSH cred + site VPN reach. RUN IN FOREGROUND.
#
# Usage: bash .claude/skills/unifi-wifi/scripts/dfs-check.sh <site-name|id> [ap-ssh-vault-path]
set -uo pipefail
REPO="$(git rev-parse --show-toplevel 2>/dev/null || echo .)"
VAULT="$REPO/.claude/scripts/vault.sh"
HOST="${UOS_HOST:-172.16.3.29}"; PORT="${UOS_HTTPS_PORT:-11443}"
SITEARG="${1:?usage: dfs-check.sh <site-name|id> [ap-ssh-vault-path]}"
VP="${2:-clients/cascades-tucson/unifi-ap-ssh}"
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
CU="$(bash "$VAULT" get-field infrastructure/uos-server-network-api-rw credentials.username 2>/dev/null)"
CP="$(bash "$VAULT" get-field infrastructure/uos-server-network-api-rw credentials.password 2>/dev/null)"
[ -n "$CU" ] && [ -n "$CP" ] || { echo "[ERROR] no controller cred (infrastructure/uos-server-network-api-rw)"; exit 1; }
base="https://$HOST:$PORT"; CJ="$TMP/cj"
code=$(curl -sk -c "$CJ" -o /dev/null -w '%{http_code}' -X POST "$base/api/auth/login" -H 'Content-Type: application/json' \
--data-binary "$(python -c 'import json,sys;print(json.dumps({"username":sys.argv[1],"password":sys.argv[2]}))' "$CU" "$CP")")
[ "$code" = "200" ] || { echo "[ERROR] controller login HTTP $code"; exit 1; }
SHORT="$(curl -sk -b "$CJ" "$base/proxy/network/api/self/sites" | python -c "
import sys,json; d=json.load(sys.stdin).get('data',[]); q='''$SITEARG'''.lower()
for s in d:
if s.get('_id')=='''$SITEARG''' or s.get('name')=='''$SITEARG''' or q in (s.get('desc','').lower()): print(s.get('name')); break
")"; [ -n "$SHORT" ] || SHORT="$SITEARG"
echo "[INFO] site=$SHORT"
# AP list with current 5GHz channel (flag DFS APs).
# NOTE: temp paths via ARGV (MSYS translates POSIX->Windows for python.exe); $TMP inside a
# `python -c` string is not translated and fails on Windows.
curl -sk -b "$CJ" "$base/proxy/network/api/s/$SHORT/stat/device" -o "$TMP/dev.json"
python - "$TMP/dev.json" "$TMP/aps.tsv" <<'PY'
import sys,json
out=[]
for a in json.load(open(sys.argv[1])).get('data',[]):
if a.get('type')!='uap' or a.get('state')!=1 or not a.get('ip'): continue
na=[r for r in a.get('radio_table_stats',[]) if r.get('radio')=='na']
ch=na[0].get('channel') if na else '?'
try: dfs='DFS' if 52<=int(ch)<=144 else 'clear'
except: dfs='?'
out.append(f"{a.get('name') or a.get('mac')}\t{a.get('ip')}\t{ch}\t{dfs}")
open(sys.argv[2],'w',newline='\n').write('\n'.join(out))
print(f"[INFO] {len(out)} online APs; {sum(1 for x in out if chr(9)+'DFS' in x)} currently on DFS 5GHz channels")
PY
AU="$(bash "$VAULT" get-field "$VP" credentials.username 2>/dev/null)"
AP_PW="$(bash "$VAULT" get-field "$VP" credentials.password 2>/dev/null)"; export AP_PW
[ -n "$AU" ] && [ -n "$AP_PW" ] || { echo "[BLOCKED] no AP device-auth cred at vault:$VP"; echo " Controller-side scripts (audit-site/live-stats/model-rank/optimize-radios/apply-radio) still work for this site."; echo " For AP-side collectors, vault this client's cred then re-run:"; echo " bash .claude/skills/vault/scripts/vault-helper.sh new $VP --kind generic --name '<Client> UniFi AP device-auth SSH' --tag unifi --set username=<u> --set password=<pw>"; exit 2; }
SSH_OPTS=(-o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null \
-o PreferredAuthentications=password -o PubkeyAuthentication=no -o NumberOfPasswordPrompts=1)
if command -v sshpass >/dev/null 2>&1; then
ap_ssh() { SSHPASS="$AP_PW" sshpass -e ssh "${SSH_OPTS[@]}" "$@" </dev/null; }
else
ASKP="$TMP/askpass.sh"; printf '#!/bin/sh\nprintf "%%s\\n" "$AP_PW"\n' > "$ASKP"; chmod +x "$ASKP"
ap_ssh() { SSH_ASKPASS="$ASKP" SSH_ASKPASS_REQUIRE=force DISPLAY="${DISPLAY:-:0}" ssh "${SSH_OPTS[@]}" "$@" </dev/null; }
fi
# PRECISE radar/DFS patterns; exclude the "cached ifindex" false positive
PAT='radar|DFS|NOL|channel availability|CAC '
echo "==== DFS RADAR EVENTS (dmesg per AP; '* on DFS now') ===="
n=0; tot=$(wc -l < "$TMP/aps.tsv"); hits=0
while IFS=$'\t' read -r name ip ch dfs; do
ip="${ip%$'\r'}"; dfs="${dfs%$'\r'}"; [ -z "$ip" ] && continue; n=$((n+1))
printf '\r[INFO] checking %d/%d ' "$n" "$tot" >&2
ev=""; t=0; reach=0 # retry per AP (transient VPN flaps); ap_ssh rc distinguishes unreachable from no-events
while [ $t -lt 3 ]; do if ev="$(ap_ssh "$AU@$ip" "dmesg 2>/dev/null | grep -iE '$PAT' | grep -iv 'cached' | tail -4" 2>/dev/null)"; then reach=1; break; fi; t=$((t+1)); sleep 2; done
[ "$reach" = 1 ] || { echo "[UNREACHABLE] $name ($ip) - skipped"; continue; }
cnt=$(printf '%s' "$ev" | grep -c . )
mark=$([ "$dfs" = "DFS" ] && echo ' *' || echo '')
if [ "$cnt" -gt 0 ]; then
hits=$((hits+1))
echo "[RADAR] $name (5GHz ch$ch$mark): $cnt event(s)"
printf '%s\n' "$ev" | sed 's/^/ /'
fi
done < "$TMP/aps.tsv"; echo "" >&2
echo ""
if [ "$hits" -eq 0 ]; then
echo "[OK] No real radar/DFS events found in any AP's dmesg -> DFS appears low-risk at this site."
echo " (dmesg is bounded; re-run periodically. Absence over long uptime = strong signal DFS is usable.)"
else
echo "[WARNING] $hits AP(s) logged radar/DFS events -> DFS is being hit; prefer non-DFS (UNII-1 36-48 + UNII-3 149-165)."
fi