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:
@@ -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.
|
||||
|
||||
@@ -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']
|
||||
|
||||
Reference in New Issue
Block a user