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>
91 lines
5.8 KiB
Bash
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
|