Files
claudetools/.claude/skills/unifi-wifi/scripts/neighbor-collect.sh
Mike Swanson 80723d159d unifi-wifi: neighbor-collect connector-capable (remote disables) + document VPN/Teleport reach
neighbor-collect.sh: add `--console <name> [--site <short>]` so the AP name/BSSID/IP map can come
from the cloud connector (/v1/connector/.../stat/device) instead of a UOS direct-login -- lets the
disable-analysis collector run against ANY console we have AP-VLAN reach to (the AP SSH harvest of
/proc/ui_neighbor is unchanged and still needs L3 reach). UOS path untouched. Validated against
Cascades via connector: source=CONNECTOR, built 77-mac + 450-bssid map for the 75 online APs.

This completes the hybrid (don't-lose-functionality): connector for airtime everywhere + neighbor-
collect (any source) for the SNR matrix -> NEIGHBOR_JSON -> optimize-radios disables on remote sites.

Documented (references/site-manager-api.md): the neighbor-collect --console flow, and the gateway
VPN/Teleport reach -- connector reaches /rest/networkconf (VPN servers: wireguard-server/openvpn-
server, site-to-site) read+writable in principle (gate writes like gw-control); Teleport has no
usable API (v1/ea/teleport 404, per-console /teleport 403).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 09:05:50 -07:00

198 lines
12 KiB
Bash

#!/usr/bin/env bash
# neighbor-collect.sh — harvest the AP-to-AP RF-neighbor SNR matrix for a UniFi site.
#
# THE KEY DISCOVERY (2026-06-15): UniFi does NOT expose managed-AP-to-managed-AP visibility
# through any documented API, the controller `rogue`/`stat/rogueap` (both filter out our own
# APs), Mongo, 802.11k hostapd, or Channel AI's channelplan. BUT each AP keeps the data
# internally in /proc, populated NON-DISRUPTIVELY by UniFi's own background RRM scanning:
# /proc/ui_neighbor/ess_ap_list — full list of our APs this AP hears (serial/band/channel)
# /proc/ui_neighbor/ssid/<n> — same neighbors WITH SNR (per scan-vap, per band)
# This script SSHes each AP, reads those, maps neighbor BSSIDs/serials -> AP names, and emits
# the AP-to-AP SNR adjacency matrix + a redundancy summary (which APs are heard strongly by >=2
# neighbors = candidates whose radio can be powered-down/disabled without a coverage hole).
# /proc/ui_neighbor exists on every UniFi AP, so this is fleet-generic (any UOS site).
#
# Reads are non-disruptive. Needs: controller cred (infrastructure/uos-server-network-api-rw,
# for the BSSID/mac->name map) + per-site AP device-auth SSH cred + L3 reach to the AP mgmt VLAN
# (the site VPN). AP SSH uses sshpass if present, else an SSH_ASKPASS fallback (Windows has no
# sshpass; the fallback needs `ssh` from PATH = MSYS ssh on Windows).
#
# Usage: bash .claude/skills/unifi-wifi/scripts/neighbor-collect.sh <site-name|id> [ap-ssh-vault-path] [snr_min]
# e.g. bash .../neighbor-collect.sh cascades # default cred + SNR>=20
# bash .../neighbor-collect.sh cascades clients/acme/unifi-ap-ssh 25
set -uo pipefail
REPO="$(git rev-parse --show-toplevel 2>/dev/null || echo .)"
VAULT="$REPO/.claude/scripts/vault.sh"; UOS="$REPO/.claude/scripts/uos-mongo.sh"
HOST="${UOS_HOST:-172.16.3.29}"; PORT="${UOS_HTTPS_PORT:-11443}"
# Data source for the AP name/BSSID/IP map: UOS direct-login (default) OR the cloud CONNECTOR
# (`--console <name> [--site <short>]`) for non-UOS / remote consoles. The AP SSH harvest below is
# IDENTICAL either way and still needs L3 reach to the AP mgmt VLAN (the connector proxies the
# controller, not SSH to individual APs).
CONSOLE=""; CSITE=""
if [ "${1:-}" = "--console" ]; then
CONSOLE="${2:?--console needs a console name}"; shift 2 2>/dev/null || shift
if [ "${1:-}" = "--site" ]; then CSITE="${2:-}"; shift 2 2>/dev/null || shift; fi
VP="${1:-}"; SNR_MIN="${2:-20}"
[ -n "$VP" ] || { echo "[ERROR] --console mode needs the AP-SSH vault path:"; echo " neighbor-collect.sh --console \"<name>\" [--site <short>] clients/<slug>/unifi-ap-ssh [snr_min]"; exit 2; }
else
SITEARG="${1:?usage: neighbor-collect.sh <site> [ap-ssh-vault-path] [snr_min] | --console <name> [--site <short>] <ap-ssh-vault-path> [snr_min]}"
VP="${2:-clients/cascades-tucson/unifi-ap-ssh}"; SNR_MIN="${3:-20}"
fi
NBR_JSON="${NBR_JSON:-}" # if set, also write a machine-readable adjacency {ap:{band:{nbr:snr}}} here
# (consumed by optimize-radios.sh via NEIGHBOR_JSON for data-backed disables)
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
# --- fetch the site device list (-> $TMP/dev.json) from the chosen data source ---
if [ -n "$CONSOLE" ]; then
# CONNECTOR source: api.ui.com (no UOS login; works for any remote console)
KEY="$(bash "$VAULT" get-field services/unifi-site-manager credentials.api_key 2>/dev/null | tr -d '\r\n')"
[ -n "$KEY" ] || { echo "[ERROR] no UniFi Site Manager key (vault services/unifi-site-manager)"; exit 1; }
HID="$(curl -s -H "X-API-KEY: $KEY" "https://api.ui.com/v1/hosts" | python -c "
import json,sys
q='''$CONSOLE'''.strip().lower()
for h in json.load(sys.stdin).get('data',[]):
nm=((h.get('reportedState') or {}).get('name')) or ''
if h.get('id','').lower()==q or q in nm.lower(): print(h['id']); break
")"
[ -n "$HID" ] || { echo "[ERROR] no console matching '$CONSOLE' in the Site Manager fleet"; exit 1; }
CC="https://api.ui.com/v1/connector/consoles/$HID/proxy/network"
if [ -n "$CSITE" ]; then SHORT="$CSITE"; else
SHORT="$(curl -s -H "X-API-KEY: $KEY" "$CC/api/self/sites" | python -c "import json,sys; d=json.load(sys.stdin).get('data',[]); print(d[0].get('name') if d else 'default')")"
fi
echo "[INFO] source=CONNECTOR console='$CONSOLE' site=$SHORT SNR_MIN=$SNR_MIN"
curl -s -H "X-API-KEY: $KEY" "$CC/api/s/$SHORT/stat/device" -o "$TMP/dev.json"
else
# UOS direct-login source (RW admin reads fine)
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] source=UOS site=$SHORT SNR_MIN=$SNR_MIN"
curl -sk -b "$CJ" "$base/proxy/network/api/s/$SHORT/stat/device" -o "$TMP/dev.json"
fi
python - "$TMP/dev.json" "$TMP" <<'PY'
import json,sys
d=[a for a in json.load(open(sys.argv[1])).get('data',[]) if a.get('type')=='uap']
macmap={}; bssmap={}; aps=[]
for a in d:
nm=a.get('name') or a.get('mac'); mac=(a.get('mac') or '').lower(); ip=a.get('ip')
if mac: macmap[mac]=nm
for v in a.get('vap_table',[]):
b=(v.get('bssid') or '').lower()
if b: bssmap[b]=nm
if ip and a.get('state')==1: aps.append((nm,ip)) # online only
json.dump(macmap,open(sys.argv[2]+'/macmap.json','w'))
json.dump(bssmap,open(sys.argv[2]+'/bssmap.json','w'))
open(sys.argv[2]+'/aps.tsv','w',newline='\n').write('\n'.join(f"{n}\t{i}" for n,i in aps)) # force LF (Windows text mode would write CRLF -> \r breaks ssh target)
print(f"[INFO] {len(aps)} online APs; {len(macmap)} mac + {len(bssmap)} bssid map entries")
PY
# --- AP SSH auth: sshpass if present, else SSH_ASKPASS fallback ---
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)
# NOTE: </dev/null on each ssh is REQUIRED — in the `while read` harvest loop, ssh would otherwise
# consume the loop's stdin (the AP list) and break iteration. Run this in the FOREGROUND: a fully
# detached background process can't spawn the SSH_ASKPASS helper, so every AP fails auth.
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
# --- harvest /proc/ui_neighbor from each AP into one tagged raw file ---
RAW="$TMP/raw.txt"; n=0; ok=0
while IFS=$'\t' read -r name ip; do
ip="${ip%$'\r'}"; name="${name%$'\r'}" # strip any CR (Windows line endings) so ssh target is valid
[ -z "$ip" ] && continue; n=$((n+1))
echo "###AP $name $ip" >> "$RAW"
# retry per AP (transient VPN flaps); capture to var so a failed try never appends partial data
out=""; t=0
while [ $t -lt 3 ]; do if out="$(ap_ssh "$AU@$ip" 'echo "@@ESS"; cat /proc/ui_neighbor/ess_ap_list 2>/dev/null; for s in /proc/ui_neighbor/ssid/*; do echo "@@SSID $s"; cat "$s" 2>/dev/null; done' 2>/dev/null)" && [ -n "$out" ]; then break; fi; t=$((t+1)); sleep 2; done
if [ -n "$out" ]; then printf '%s\n' "$out" >> "$RAW"; ok=$((ok+1)); else echo "@@UNREACHABLE" >> "$RAW"; fi
printf '\r[INFO] harvested %d/%d (reachable %d) ' "$n" "$(wc -l < "$TMP/aps.tsv")" "$ok" >&2
done < "$TMP/aps.tsv"
echo "" >&2
# --- parse + map + emit adjacency matrix + redundancy summary ---
python - "$RAW" "$TMP/macmap.json" "$TMP/bssmap.json" "$SNR_MIN" "${NBR_JSON:-NONE}" <<'PY'
import json,re,sys
raw=open(sys.argv[1],encoding='utf-8',errors='replace').read().splitlines()
macmap=json.load(open(sys.argv[2])); bssmap=json.load(open(sys.argv[3])); SNR_MIN=int(sys.argv[4])
OUTJSON=sys.argv[5] if len(sys.argv)>5 else 'NONE'
BN={'0':'2.4','1':'5','2':'6'}; EB={'2.4ghz':'2.4','5ghz':'5','6ghz':'6'}
# edges[(src, band)] = {neighbor_name: best_snr}; presence[(src,band)] = set(names) from ess_ap_list
edges={}; presence={}; cur=None; mode=None; band=None
def add_edge(src,band,name,snr):
k=(src,band); edges.setdefault(k,{});
if name not in edges[k] or snr>edges[k][name]: edges[k][name]=snr
for ln in raw:
if ln.startswith('###AP'):
p=ln.split('\t'); cur=p[1] if len(p)>1 else None; mode=None; band=None; continue
if ln.startswith('@@ESS'): mode='ess'; continue
if ln.startswith('@@SSID'): mode='ssid'; band=None; continue
if ln.startswith('@@UNREACHABLE') or cur is None: continue
if mode=='ess':
p=ln.split()
if len(p)>=3 and re.match(r'^[0-9a-f:]{17}$',p[0]):
b=EB.get(p[1].lower()); nm=macmap.get(p[0].lower())
if b and nm and nm!=cur: presence.setdefault((cur,b),set()).add(nm)
elif mode=='ssid':
mb=re.match(r'band\((\d)\)',ln.strip())
if mb: band=BN.get(mb.group(1)); continue
p=ln.split()
if band and len(p)>=2 and re.match(r'^[0-9a-f:]{17}$',p[0]) and p[1].lstrip('-').isdigit():
nm=bssmap.get(p[0].lower())
if nm and nm!=cur: add_edge(cur,band,nm,int(p[1]))
aps=sorted({k[0] for k in list(edges)+list(presence)})
print(f"\n==== AP-to-AP RF NEIGHBOR MATRIX ({len(aps)} APs reporting) ====")
print("(SNR from /proc/ui_neighbor/ssid; '+N more' = additional neighbors seen in ess_ap_list w/o fresh SNR)\n")
for ap in aps:
line=[f"{ap}:"]
for b in ('2.4','5','6'):
snrs=edges.get((ap,b),{})
seen=presence.get((ap,b),set())
extra=len(seen - set(snrs))
if snrs or seen:
top=sorted(snrs.items(),key=lambda x:-x[1])[:5]
s=", ".join(f"{n}({v})" for n,v in top)
more=f" +{extra} more" if extra>0 else ""
line.append(f"\n {b}GHz: {s}{more}")
print("".join(line))
# redundancy summary: APs whose radio is "safe-ish" to power-down/disable = heard by >=2 neighbors at strong SNR
print(f"\n==== REDUNDANCY (neighbors at SNR>={SNR_MIN}; >=2 strong same-band neighbors = coverage-redundant) ====")
for b in ('2.4','5','6'):
rows=[]
for ap in aps:
strong=[n for n,v in edges.get((ap,b),{}).items() if v>=SNR_MIN]
rows.append((len(strong), ap, strong))
redund=[r for r in rows if r[0]>=2]
print(f"\n-- {b}GHz: {len(redund)}/{len(rows)} APs have >=2 strong neighbors (disable/power-down candidates) --")
for cnt,ap,strong in sorted(redund,reverse=True)[:12]:
print(f" {ap}: {cnt} strong ({', '.join(strong[:4])}{'...' if len(strong)>4 else ''})")
# machine-readable adjacency for optimize-radios.sh: {ap_name: {band: {neighbor_name: snr}}}
if OUTJSON!='NONE':
adj={}
for (src,b),nbrs in edges.items():
adj.setdefault(src,{})[b]=nbrs
json.dump(adj, open(OUTJSON,'w'))
print(f"\n[INFO] wrote adjacency JSON -> {OUTJSON} ({len(adj)} APs) - feed to optimize-radios.sh via NEIGHBOR_JSON")
PY
echo ""
echo "[next] feed the redundancy list to optimize-radios.sh; validate per-zone with watch-ap.sh before any --apply."