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>
198 lines
12 KiB
Bash
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."
|