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>
This commit is contained in:
2026-06-17 09:05:50 -07:00
parent dccd381820
commit 80723d159d
2 changed files with 69 additions and 18 deletions

View File

@@ -97,6 +97,28 @@ until the MAC list was supplied). The analyzer fetches the uap macs from `/stat/
2.4GHz util 65-90% / interf 53-78% / ~1 client each, all power-down, 0 disables. Apples-to-apples (same
self-hosted controller + site). Connector lacks the roam graph, so disables need `NEIGHBOR_JSON`.
### Disables on a remote/non-UOS site (neighbor-collect --console)
`neighbor-collect.sh` builds the AP-to-AP SNR matrix (the only source of managed-AP redundancy; it's
NOT in any API - SSH-to-`/proc/ui_neighbor` only). It now gets its name/BSSID/IP map from the connector:
```bash
bash .../neighbor-collect.sh --console "<name>" [--site <short>] clients/<slug>/unifi-ap-ssh [snr_min]
NBR_JSON=adj.json bash .../neighbor-collect.sh --console "UOS Server" --site va6iba3v clients/cascades-tucson/unifi-ap-ssh
NEIGHBOR_JSON=adj.json bash .../optimize-radios.sh --console "UOS Server" --site va6iba3v 14 ng # now gets disables
```
The name map comes from the cloud (no UOS login); the AP SSH harvest still needs L3 reach to the AP mgmt
VLAN (the connector proxies the controller, not SSH to APs). So: connector for airtime everywhere +
neighbor-collect for disables wherever you have AP-VLAN reach. Nothing is lost vs the UOS path.
## Gateway VPN / Teleport (via the connector)
The connector reaches the gateway config surface - `GET/PUT /rest/networkconf`, `/rest/setting`,
`/rest/wlanconf`, `/stat/sysinfo` (all 200). VPN servers/tunnels live in `/rest/networkconf`
(`purpose=remote-user-vpn`, `vpn_type=wireguard-server|openvpn-server`; site-to-site as `*-vpn`).
So VPN configs are READABLE now and WRITABLE in principle (PUT/POST) - but writes are high-stakes
(lockout risk) and must be DRY-RUN + confirm gated like gw-control.sh; not wired in yet.
**Teleport: no usable API found** - `/v1/teleport`, `/ea/teleport` -> 404; per-console `/teleport` -> 403;
no teleport-tagged networks. It's the ui.com-brokered zero-config overlay, managed via the account/app;
needs deeper research before claiming any programmatic path.
## Gotchas
- Direct WAN SSH/HTTPS to a standalone UDM is usually firewalled (Brooklyn 67.1.139.219:
22/443/8443 filtered) - the connector is the remote path, not direct.

View File

@@ -24,32 +24,61 @@ 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}"
SITEARG="${1:?usage: neighbor-collect.sh <site-name|id> [ap-ssh-vault-path] [snr_min]}"
VP="${2:-clients/cascades-tucson/unifi-ap-ssh}"; SNR_MIN="${3:-20}"
# 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
# --- controller creds + login (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; }
# resolve site short name
SHORT="$(curl -sk -b "$CJ" "$base/proxy/network/api/self/sites" | python -c "
# --- 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] site=$SHORT SNR_MIN=$SNR_MIN"
# --- build maps (mac->name for ess_ap_list, bssid->name for ssid/*, name->ip to SSH) ---
curl -sk -b "$CJ" "$base/proxy/network/api/s/$SHORT/stat/device" -o "$TMP/dev.json"
[ -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']