diff --git a/.claude/skills/unifi-wifi/references/site-manager-api.md b/.claude/skills/unifi-wifi/references/site-manager-api.md index 2dc6639b..4eeb0f58 100644 --- a/.claude/skills/unifi-wifi/references/site-manager-api.md +++ b/.claude/skills/unifi-wifi/references/site-manager-api.md @@ -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 "" [--site ] clients//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. diff --git a/.claude/skills/unifi-wifi/scripts/neighbor-collect.sh b/.claude/skills/unifi-wifi/scripts/neighbor-collect.sh index 090dc689..c53fb41e 100644 --- a/.claude/skills/unifi-wifi/scripts/neighbor-collect.sh +++ b/.claude/skills/unifi-wifi/scripts/neighbor-collect.sh @@ -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 [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 [--site ]`) 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 \"\" [--site ] clients//unifi-ap-ssh [snr_min]"; exit 2; } +else + SITEARG="${1:?usage: neighbor-collect.sh [ap-ssh-vault-path] [snr_min] | --console [--site ] [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']