diff --git a/.claude/memory/MEMORY.md b/.claude/memory/MEMORY.md index b7183fc1..c6ea9ec1 100644 --- a/.claude/memory/MEMORY.md +++ b/.claude/memory/MEMORY.md @@ -30,6 +30,7 @@ - [Unraid VM no-IP causes](unraid-windows-vm-virtio-no-ip.md) — PRIMARY (general "new VMs stopped getting IPs lately"): Docker sets bridge-nf-call-iptables=1, so br0 VM DHCP OFFERs hit DOCKER-FORWARD (no br0 ACCEPT) and get dropped; new VMs can't complete DORA (existing renew via ESTABLISHED). Fix `=0` runtime (needs persistent post-Docker hook; not yet persisted on Jupiter). SECONDARY (Windows VM): virtio-net has no in-box driver -> use e1000 or virtio-win. Diagnose: tcpdump DHCP on pfSense; /sys vnetN rx_packets. - [Starr Pass mail routing](reference_starrpass_mail_routing.md) — starrpass.com is DIRECT to MS (EOP/Defender, tenant 222450dd…); only devconllc.com is on Mailprotector (MP acct 16170). Check @starrpass.com quarantine/rejects via remediation-tool, not Mailprotector. - [AAD Connect msDS-KeyCredentialLink writeback](reference_aadconnect_keycredlink_writeback.md) — "completed-export-errors" + 8344 INSUFF_ACCESS_RIGHTS on a protected admin account = WHfB key writeback blocked by AdminSDHolder. Diagnose with csexport /f:x; fix with dsacls WP;msDS-KeyCredentialLink on AdminSDHolder + SDProp. +- [UniFi Site Manager cloud API](reference_unifi_site_manager_api.md) — `api.ui.com` + `X-API-KEY` (vault `services/unifi-site-manager`) = remote access to the WHOLE ACG UniFi fleet (~36 consoles) outside UOS. Tier1 `/v1/hosts|sites|devices|isp-metrics` = inventory+health+WAN. Tier2 CONNECTOR `/v1/connector/consoles/{id}/proxy/network/api/s/default/stat/{device,sta}` = **full UOS parity** (per-radio cu_total airtime + per-client RSSI) for ANY console, remote. Backend `unifi-wifi/scripts/gw-sitemanager.sh` (`fleet|devices|sites|isp|net`). Standalone UDM WAN SSH usually firewalled; per-console SSH pw at `clients//udm-ssh`. - [reference_sqlx_migrations_immutable](reference_sqlx_migrations_immutable.md) -- NEVER edit an already-applied sqlx migration file — even a comment. sqlx::migrate! checksums each file at compile time and validates against _sqlx_migrations at startup; a changed checksum crash-loops the server with "migration N was previously applied but has been modified". Code review MUST flag any edit to an applied migration. ## Users diff --git a/.claude/memory/reference_unifi_site_manager_api.md b/.claude/memory/reference_unifi_site_manager_api.md new file mode 100644 index 00000000..eb5c6c14 --- /dev/null +++ b/.claude/memory/reference_unifi_site_manager_api.md @@ -0,0 +1,33 @@ +--- +name: reference_unifi_site_manager_api +description: UniFi Site Manager cloud API (api.ui.com) + its CONNECTOR proxy give remote access to the WHOLE ACG UniFi fleet (~36 consoles) outside UOS - AND full UOS-parity RF/client data via the connector. Key vaulted at services/unifi-site-manager; backend = unifi-wifi skill gw-sitemanager.sh. +metadata: + type: reference +--- + +ACG has a **UniFi Site Manager / Cloud API** key (account owner mike@azcomputerguru.com) +that reaches every ACG UniFi console remotely - no UOS server, no on-site/LAN access. This is +the "access a UDM outside the UOS environment" path, and via the connector it reaches +**UOS-parity depth**. Backend: `.claude/skills/unifi-wifi/scripts/gw-sitemanager.sh`. +Full catalog: `.claude/skills/unifi-wifi/references/site-manager-api.md`. + +- **Base:** `https://api.ui.com` - **Auth:** header `X-API-KEY: ` + `Accept: application/json`. +- **Key:** vault `services/unifi-site-manager` (`credentials.api_key`). +- **Tier 1 (Site Manager, fleet overview):** `GET /v1/hosts` (~36 consoles: id, WAN ipAddress, + controllers+integrationApis), `/v1/sites` (health counts, IPS, ISP/ASN), `/v1/devices` + (inventory: name/model/ip/state/fw), `/v1/isp-metrics/{5m,1h}` (WAN latency/throughput/downtime + time-series). Inventory + health + WAN, NOT per-radio/per-client. +- **Tier 2 (CONNECTOR -> console LOCAL Network API = UOS PARITY):** + `https://api.ui.com/v1/connector/consoles/{hostId}/proxy/network/` with the SAME account key. + - `/proxy/network/api/s/{site}/stat/device` -> `radio_table_stats` (cu_total airtime, channel, bw, + tx_power, num_sta, satisfaction) - the SAME depth as UOS Mongo `ace_stat`. + - `/proxy/network/api/s/{site}/stat/sta` -> per-client rssi/signal/noise/satisfaction/rates. + - `/proxy/network/integration/v1/...` -> official Integration API (sites/devices/clients + POST + actions: device restart, client block/unblock). + - site short name is usually `default`. Confirmed live on Brooklyn/Skybar 2026-06-17. + - == parity for ANY console remotely (broader than UOS, which only sees UOS-adopted sites). +- **Standalone consoles:** direct WAN SSH/HTTPS to a UDM is usually FIREWALLED (e.g. Brooklyn/Skybar + 67.1.139.219 - 22/443/8443 filtered). Use the connector; per-console device SSH pw under + `clients//udm-ssh` (e.g. clients/brooklyn-skybar/udm-ssh). + +Relevant to extending `unifi-wifi` to non-UOS sites. See [[reference_resource_map]]. diff --git a/.claude/skills/unifi-wifi/SKILL.md b/.claude/skills/unifi-wifi/SKILL.md index 502276a9..1f2a58e5 100644 --- a/.claude/skills/unifi-wifi/SKILL.md +++ b/.claude/skills/unifi-wifi/SKILL.md @@ -170,6 +170,13 @@ vaulted dedicated key `infrastructure/uos-server-ssh-key` (works from any fleet changes (before/after) and find the worst APs by live airtime. Wiring it needs a dedicated read-only UniFi admin or integration API key on `.29`, vaulted as `infrastructure/uos-server-network-api`. See data-access.md "Plane 2". +- **Plane 3 - UniFi cloud (Site Manager API + connector; WIRED 2026-06-17):** reaches ANY of the + ~36 ACG consoles remotely with NO UOS server and NO LAN/VPN, via `api.ui.com` + the account key + (vault `services/unifi-site-manager`). Tier 1 = fleet inventory/health/WAN-ISP; the CONNECTOR + (`/v1/connector/consoles//proxy/network/api/s//stat/{device,sta}`) delivers the SAME + `cu_total`/RSSI/satisfaction depth as Plane 1+2 (= UOS PARITY) for non-UOS / standalone UDMs. + Backend: `scripts/gw-sitemanager.sh` (`fleet|devices|sites|isp|net`); catalog: + `references/site-manager-api.md`. Use this for consoles NOT adopted into the UOS server. ## Applying changes — IMPORTANT boundary Config changes are automated across many APs (no per-AP UI clicking) via the controller REST API diff --git a/.claude/skills/unifi-wifi/references/site-manager-api.md b/.claude/skills/unifi-wifi/references/site-manager-api.md new file mode 100644 index 00000000..77d31d14 --- /dev/null +++ b/.claude/skills/unifi-wifi/references/site-manager-api.md @@ -0,0 +1,83 @@ +# UniFi Site Manager cloud API + connector (remote, outside-UOS access) + +Empirically mapped 2026-06-17 against ACG's live account key (vault +`services/unifi-site-manager`), corroborated by Grok + Gemini web search. +This is the "access UniFi outside the UOS environment" path, and it reaches +**UOS-parity depth** for the whole fleet remotely. + +Backend script: `scripts/gw-sitemanager.sh`. Base host: `https://api.ui.com`. +Auth: header `X-API-KEY: ` (account-level Site Manager key, owner mike@). Read-heavy; +some POST actions exist (device restart, client block) - not wired in yet. + +## Three tiers + +### Tier 1 - Site Manager API (`/v1/...`, cloud, fleet overview) +| Endpoint | Returns | +|---|---| +| `GET /v1/hosts` | All consoles (id, hardwareId, **ipAddress** WAN, type, owner, reportedState incl. `controllers[]` with each app's `integrationApis` location). 36 for ACG. | +| `GET /v1/hosts/{id}` | One console detail. | +| `GET /v1/sites` | Sites across hosts (82): `meta`, `statistics.counts` (device/client counts), `statistics.gateway` (IPS mode/signature), `ispInfo` (ASN/provider), `percentages.txRetry`. | +| `GET /v1/devices` | Per-host device inventory: name, model, **ip**, status, firmware, uptime, adoption. No RF. | +| `GET /v1/isp-metrics/{5m\|1h}` | Per host/site WAN time-series `periods[]` (~264): `metricTime` + `data.wan` {avgLatency, maxLatency, download_kbps, upload_kbps, packetLoss, uptime, downtime}. Newer gateways (UCG/UDM-SE) populate it; older UDM Pro may report zeros. | +| `GET /v1/sd-wan-configs` | SD-WAN configs (0 for ACG). | + +`/ea/*` mirrors most of these. Response wrapper: `{data, httpStatusCode, traceId}` (Site +Manager) ; sub-resource paths like `/v1/hosts/{id}/devices` 404 - use the flat collections +and filter by `hostId`. + +### Tier 2 - Cloud CONNECTOR proxy -> console LOCAL Network API (UOS PARITY) +The key unlock. The account key proxies into each console's local UniFi Network API +**remotely**, no LAN/VPN: + +``` +https://api.ui.com/v1/connector/consoles/{consoleId}/proxy/network/ +``` +`{consoleId}` = the host id from `/v1/hosts`. Two sub-surfaces both work: + +- **Official Integration API** - `/proxy/network/integration/v1/...` + - `/info`, `/sites` (returns site UUIDs), `/sites/{uuid}/devices`, `/sites/{uuid}/devices/{id}`, + `/sites/{uuid}/devices/{id}/statistics/latest`, `/sites/{uuid}/clients`, `/clients/{mac}`. + - Device `interfaces.radios[]`: frequencyGHz, channel, channelWidthMHz, wlanStandard; live stats add txRetriesPct. + - Clean/supported but **shallower** than the internal API (client list lacks RSSI in v1 on Net 10.4). + - Has POST actions: device `/restart`, client `/block` `/unblock`. + +- **Internal stat API** - `/proxy/network/api/s/{site}/stat/...` (site = short name, usually `default`) + - `GET .../stat/device` -> per-device `radio_table_stats[]`: **cu_total** (channel utilization = + airtime), cu_self_rx, cu_self_tx, channel, last_channel, bw, **tx_power**, num_sta, satisfaction, + tx_retries, tx_retries_pct, ast_txto/ast_cst/ast_be_xmit. == the UOS Mongo `ace_stat` depth. + - `GET .../stat/sta` -> per-client: **rssi, signal, noise**, satisfaction, channel, radio, essid, + tx_rate, rx_rate, tx_retries, anomalies. + - `GET .../v2/api/site/{site}/...` also reachable. + - This is what `gw-sitemanager.sh net radios|clients` uses. + +### Tier 3 - the OLD path (UOS Mongo `ace`/`ace_stat`) +Still used by the rest of the unifi-wifi skill (`audit-site.sh`, `model-rank.sh`, etc.) for +UOS-ADOPTED sites. Tier 2 now gives the same data for NON-UOS consoles (and the whole fleet). + +## Parity verdict +- Tier 1 alone: inventory + health + WAN/ISP telemetry. NOT RF/per-client. +- **Tier 2 (connector -> internal stat API): full UOS parity** - per-radio airtime/channel/ + tx-power and per-client RSSI/satisfaction - for ANY of the 36 consoles, remotely, with the + one account key. Broader coverage than UOS (UOS only sees UOS-adopted sites). + +## Examples +```bash +S=.claude/skills/unifi-wifi/scripts/gw-sitemanager.sh +bash $S fleet # all 36 consoles: WAN IP, model, #devices, online +bash $S devices "Brooklyn/Skybar" # device inventory for a console +bash $S sites brooklyn # site health (counts, IPS, ISP) +bash $S isp "CGU-Curves" 5m 12 # WAN latency/throughput history +bash $S net brooklyn radios # DEEP per-AP airtime/channel/txpower (parity) +bash $S net brooklyn clients # DEEP per-client RSSI/signal/satisfaction +bash $S net brooklyn raw /integration/v1/sites # proxy any /proxy/network/... 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. +- Internal stat API uses the site SHORT name (`default`), not the integration UUID. Override + with `net radios --site ` for multi-site consoles. +- Older gateways report empty ISP metrics; radio/client data still works via the connector. +- Key is account-level (owner). Non-owner/org keys may 403 on some endpoints. +- Sources: developer.ui.com (Site Manager + Network), help.ui.com "Getting Started with the + Official UniFi API", Art-of-WiFi UniFi-API-client (`connect_via_site_manager`). diff --git a/.claude/skills/unifi-wifi/scripts/gw-sitemanager.sh b/.claude/skills/unifi-wifi/scripts/gw-sitemanager.sh new file mode 100644 index 00000000..f89718af --- /dev/null +++ b/.claude/skills/unifi-wifi/scripts/gw-sitemanager.sh @@ -0,0 +1,273 @@ +#!/usr/bin/env bash +# gw-sitemanager.sh -- UniFi Site Manager CLOUD API backend for the unifi-wifi skill. +# +# Reaches EVERY ACG UniFi console remotely via api.ui.com (Ubiquiti's cloud), with +# NO UOS server and NO local-network access. This is the "outside UOS" / alternate +# route -- use it for consoles that are NOT adopted into the UOS server (standalone +# UDMs like Brooklyn/Skybar), for fleet-wide overview, and for WAN/ISP telemetry. +# +# Auth: X-API-KEY header. Key is read from the vault (services/unifi-site-manager), +# or override with UNIFI_SM_KEY=... . Read-only. +# +# DATA DEPTH (empirically mapped 2026-06-17 -- see references/site-manager-api.md): +# TIER 1 (Site Manager api.ui.com, the fleet/* + isp commands): inventory (hosts + +# per-host devices), site health counts, gateway IPS posture, ISP/ASN, and WAN/ISP +# time-series (latency/throughput/downtime 5m|1h). NOT per-radio/per-client. +# TIER 2 (cloud CONNECTOR -> console LOCAL Network API, the `net` command): the SAME +# ace_stat depth as the UOS Mongo path -- per-radio cu_total airtime, channel, width, +# tx_power, num_sta, satisfaction; per-client rssi/signal/noise/satisfaction/rates. +# Reached remotely via /v1/connector/consoles//proxy/network/... with this same +# account key. == UOS PARITY for ANY console, no LAN/VPN/UOS server. +# +# Usage: +# gw-sitemanager.sh fleet [filter] # list all consoles (name, WAN IP, state, #devices) +# gw-sitemanager.sh host # one console: identity + summary +# gw-sitemanager.sh devices # device inventory for a console +# gw-sitemanager.sh sites [filter] # sites with health counts + ISP + IPS +# gw-sitemanager.sh isp [5m|1h] [N] # recent WAN/ISP metrics (default 12 periods) +# gw-sitemanager.sh net radios # DEEP: per-AP airtime/channel/txpower (UOS parity) +# gw-sitemanager.sh net clients # DEEP: per-client RSSI/signal/satisfaction +# gw-sitemanager.sh net devices # DEEP: internal device list (#sta, uptime) +# gw-sitemanager.sh net raw [--site ] # proxy any /proxy/network/... path +# gw-sitemanager.sh find # resolve a console name -> host id +# gw-sitemanager.sh raw # GET an arbitrary api.ui.com path (escape hatch) +# +# Exit: 0 ok, 1 error/no-data, 2 usage. +set -uo pipefail +SELF="gw-sitemanager" +BASE="https://api.ui.com" + +PY="$(command -v py 2>/dev/null || command -v python 2>/dev/null || command -v python3 2>/dev/null || true)" +[ -z "$PY" ] && { echo "[$SELF] python required" >&2; exit 1; } + +REPO_ROOT="${CLAUDETOOLS_ROOT:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" + +# --- API key from vault (or env override) --- +KEY="${UNIFI_SM_KEY:-}" +if [ -z "$KEY" ]; then + KEY="$(bash "$REPO_ROOT/.claude/scripts/vault.sh" get-field services/unifi-site-manager credentials.api_key 2>/dev/null | tr -d '\r\n')" +fi +[ -z "$KEY" ] && { echo "[$SELF] no API key (vault services/unifi-site-manager credentials.api_key, or set UNIFI_SM_KEY)" >&2; exit 1; } + +api() { # $1 = path -> raw JSON on stdout; non-200 -> empty + stderr note + local path="$1" resp code body + resp=$(curl -s -w "\n__C__%{http_code}" -H "X-API-KEY: $KEY" -H "Accept: application/json" "$BASE$path" 2>/dev/null) + code=$(printf '%s' "$resp" | sed -n 's/.*__C__//p') + body=$(printf '%s' "$resp" | sed 's/__C__[0-9]*$//') + if [ "$code" != "200" ]; then echo "[$SELF] GET $path -> HTTP $code" >&2; return 1; fi + printf '%s' "$body" +} + +MODE="${1:-}"; shift 2>/dev/null || true +[ -z "$MODE" ] && { echo "usage: $SELF {fleet|host|devices|sites|isp|find|raw} ..." >&2; exit 2; } + +case "$MODE" in + fleet|hosts) + FILTER="${1:-}" + HJSON="$(api /v1/hosts)" || exit 1 + DJSON="$(api /v1/devices)" || exit 1 + printf '%s\n\x1e%s' "$HJSON" "$DJSON" | "$PY" -c " +import json,sys +raw=sys.stdin.read().split('\x1e',1) +hosts=json.loads(raw[0]).get('data',[]) +devs ={g['hostId']:g.get('devices',[]) for g in json.loads(raw[1]).get('data',[])} +flt='''$FILTER'''.strip().lower() +rows=[] +for h in hosts: + name=((h.get('reportedState') or {}).get('name')) or h.get('id','') + if flt and flt not in name.lower(): continue + dl=devs.get(h['id'],[]) + online=sum(1 for d in dl if (d.get('status')=='online')) + cons=next((d for d in dl if d.get('isConsole')), None) + model=(cons or {}).get('model') or '?' + rows.append((name, h.get('ipAddress') or '-', model, len(dl), online, 'owner' if h.get('owner') else 'shared')) +rows.sort(key=lambda r:r[0].lower()) +print('%-30s %-16s %-12s %5s %7s %s' % ('CONSOLE','WAN IP','MODEL','DEVS','ONLINE','OWN')) +for r in rows: print('%-30s %-16s %-12s %5d %7d %s' % (r[0][:30], r[1], str(r[2])[:12], r[3], r[4], r[5])) +print('\n%d console(s)%s' % (len(rows), (' matching \"'+flt+'\"') if flt else '')) +" + ;; + + find|host) + [ -z "${1:-}" ] && { echo "usage: $SELF $MODE " >&2; exit 2; } + Q="$1" + api /v1/hosts | "$PY" -c " +import json,sys +q='''$Q'''.strip().lower() +hosts=json.loads(sys.stdin.read()).get('data',[]) +def nm(h): return ((h.get('reportedState') or {}).get('name')) or '' +m=[h for h in hosts if h.get('id','').lower()==q or q in nm(h).lower()] +if not m: print('[no match for \"%s\"]'%q); sys.exit(1) +for h in m[:8]: + rs=h.get('reportedState') or {} + print('name :', nm(h)) + print('id :', h.get('id')) + print('wan ip :', h.get('ipAddress')) + print('type/owner:', h.get('type'), '/', 'owner' if h.get('owner') else 'shared') + print('hardwareId:', h.get('hardwareId')) + print('registered:', h.get('registrationTime'),' last-change:', h.get('lastConnectionStateChange')) + ctrls=rs.get('controllers') or (rs.get('userData') or {}).get('controllers') or [] + if isinstance(ctrls,list) and ctrls: + print('controllers:') + for c in ctrls: + if not isinstance(c,dict): continue + integ=' [Integration API: '+ (c.get('integrationApis')[0].get('apiDocsLocation','') if c.get('integrationApis') else '') +']' if c.get('integrationApis') else '' + print(' %-11s %-9s v%-10s%s' % (c.get('name','?'), c.get('state',c.get('status','?')), (c.get('version') or '-'), integ)) + if len(m)>1: print('---') +" || exit 1 + ;; + + devices) + [ -z "${1:-}" ] && { echo "usage: $SELF devices " >&2; exit 2; } + Q="$1" + HID="$(api /v1/hosts | "$PY" -c " +import json,sys +q='''$Q'''.strip().lower() +hosts=json.loads(sys.stdin.read()).get('data',[]) +def nm(h): return ((h.get('reportedState') or {}).get('name')) or '' +m=[h for h in hosts if h.get('id','').lower()==q or q in nm(h).lower()] +print(m[0]['id'] if m else '') +")" + [ -z "$HID" ] && { echo "[$SELF] no console matching '$Q'" >&2; exit 1; } + api /v1/devices | HID="$HID" "$PY" -c " +import json,sys,os +hid=os.environ['HID'] +g=next((x for x in json.loads(sys.stdin.read()).get('data',[]) if x.get('hostId')==hid), None) +if not g: print('[no device data]'); sys.exit(1) +ds=sorted(g.get('devices',[]), key=lambda d:(d.get('productLine',''), d.get('name') or '')) +print('%-26s %-20s %-15s %-8s %-9s %s' % ('NAME','MODEL','IP','STATE','FW','LINE')) +for d in ds: + print('%-26s %-20s %-15s %-8s %-9s %s' % ((d.get('name') or '?')[:26],(d.get('model') or '?')[:20], + d.get('ip') or '-', d.get('status') or '?', str(d.get('version') or '-')[:9], d.get('productLine') or '')) +print('\n%s: %d devices, %d online' % (g.get('hostName'), len(ds), sum(1 for d in ds if d.get('status')=='online'))) +" || exit 1 + ;; + + sites) + FILTER="${1:-}" + HJSON="$(api /v1/hosts)" || exit 1 + SJSON="$(api /v1/sites)" || exit 1 + printf '%s\n\x1e%s' "$HJSON" "$SJSON" | FLT="${FILTER}" "$PY" -c " +import json,sys,os +raw=sys.stdin.read().split('\x1e',1) +hosts={h['id']:((h.get('reportedState') or {}).get('name') or h.get('id','')) for h in json.loads(raw[0]).get('data',[])} +sites=json.loads(raw[1]).get('data',[]) +flt=os.environ.get('FLT','').strip().lower() +print('%-22s %-16s %4s %4s %6s %6s %-5s %s' % ('CONSOLE','SITE','DEV','OFF','WIFIcl','WIREcl','IPS','ISP')) +n=0 +for s in sorted(sites, key=lambda s: hosts.get(s.get('hostId',''),'').lower()): + cons=hosts.get(s.get('hostId',''),'?') + meta=s.get('meta') or {}; sname=meta.get('desc') or meta.get('name') or '' + if flt and flt not in cons.lower() and flt not in sname.lower(): continue + st=s.get('statistics') or {}; c=st.get('counts') or {}; gw=st.get('gateway') or {}; isp=st.get('ispInfo') or {} + n+=1 + print('%-22s %-16s %4s %4s %6s %6s %-5s %s' % (cons[:22], sname[:16], + c.get('totalDevice','-'), c.get('offlineDevice','-'), c.get('wifiClient','-'), c.get('wiredClient','-'), + (gw.get('ipsMode') or '-')[:5], (isp.get('name') or '-'))) +print('\n%d site(s)%s' % (n,(' matching \"'+flt+'\"') if flt else '')) +" || exit 1 + ;; + + isp) + [ -z "${1:-}" ] && { echo "usage: $SELF isp [5m|1h] [N]" >&2; exit 2; } + Q="$1"; GRAN="${2:-5m}"; N="${3:-12}" + case "$GRAN" in 5m|1h) ;; *) echo "[$SELF] granularity must be 5m or 1h" >&2; exit 2;; esac + HID="$(api /v1/hosts | "$PY" -c " +import json,sys +q='''$Q'''.strip().lower() +hosts=json.loads(sys.stdin.read()).get('data',[]) +def nm(h): return ((h.get('reportedState') or {}).get('name')) or '' +m=[h for h in hosts if h.get('id','').lower()==q or q in nm(h).lower()] +print(m[0]['id'] if m else '') +")" + [ -z "$HID" ] && { echo "[$SELF] no console matching '$Q'" >&2; exit 1; } + api "/v1/isp-metrics/$GRAN" | HID="$HID" N="$N" GRAN="$GRAN" "$PY" -c " +import json,sys,os +hid=os.environ['HID']; N=int(os.environ['N']) +items=[x for x in json.loads(sys.stdin.read()).get('data',[]) if x.get('hostId')==hid] +if not items: print('[no ISP metrics for this console]'); sys.exit(1) +print('WAN/ISP metrics (%s, last %d periods)' % (os.environ['GRAN'], N)) +for it in items: + per=it.get('periods',[])[-N:] + print(' site %s -- %d periods total' % (it.get('siteId','?'), len(it.get('periods',[])))) + print(' %-22s %7s %9s %9s %5s %5s' % ('TIME(UTC)','lat(ms)','dl(Mbps)','ul(Mbps)','loss','down')) + for p in per: + w=((p.get('data') or {}).get('wan')) or {} + t=p.get('metricTime') or '?' + dl=w.get('download_kbps'); ul=w.get('upload_kbps') + print(' %-22s %7s %9s %9s %5s %5s' % (str(t)[:22], w.get('avgLatency','-'), + round(dl/1000,1) if isinstance(dl,(int,float)) else '-', + round(ul/1000,1) if isinstance(ul,(int,float)) else '-', + w.get('packetLoss', w.get('packet_loss','-')), w.get('downtime','-'))) +" || exit 1 + ;; + + net) + # DEEP tier (UOS PARITY): proxy the console's LOCAL Network API through the cloud + # connector -- api.ui.com/v1/connector/consoles//proxy/network/... -- using the + # account key. The internal /api/s//stat/* endpoints return the SAME ace_stat + # depth the UOS Mongo path gives (per-radio cu_total airtime, per-client rssi/sat), + # for ANY console remotely, no LAN/VPN/UOS. (Confirmed empirically 2026-06-17.) + [ -z "${1:-}" ] && { echo "usage: $SELF net {radios|clients|devices|raw } [--site ]" >&2; exit 2; } + Q="$1"; SUB="${2:-radios}"; SITE="default"; RAWPATH="" + shift 2 2>/dev/null || shift $# 2>/dev/null || true + while [ $# -gt 0 ]; do case "$1" in --site) SITE="${2:-default}"; shift 2 2>/dev/null || shift;; *) RAWPATH="$1"; shift;; esac; done + HID="$(api /v1/hosts | "$PY" -c " +import json,sys +q='''$Q'''.strip().lower() +hosts=json.loads(sys.stdin.read()).get('data',[]) +def nm(h): return ((h.get('reportedState') or {}).get('name')) or '' +m=[h for h in hosts if h.get('id','').lower()==q or q in nm(h).lower()] +print(m[0]['id'] if m else '') +")" + [ -z "$HID" ] && { echo "[$SELF] no console matching '$Q'" >&2; exit 1; } + CB="/v1/connector/consoles/$HID/proxy/network" + case "$SUB" in + radios) + api "$CB/api/s/$SITE/stat/device" | "$PY" -c " +import json,sys +B={'ng':'2.4','na':'5','6e':'6','6g':'6'} +d=json.loads(sys.stdin.read()).get('data',[]) +aps=[x for x in d if x.get('type')=='uap'] +print('%-22s %-4s %-5s %-3s %6s %4s %6s %5s %6s' % ('AP','BAND','CHAN','BW','UTIL%','STA','TXPWR','SAT','RETRY%')) +for ap in sorted(aps,key=lambda a:a.get('name') or ''): + for r in (ap.get('radio_table_stats') or []): + print('%-22s %-4s %-5s %-3s %6s %4s %6s %5s %6s' % ((ap.get('name') or '?')[:22], B.get(r.get('radio'),str(r.get('radio'))), + r.get('channel'), r.get('bw'), r.get('cu_total'), r.get('num_sta'), r.get('tx_power'), r.get('satisfaction'), r.get('tx_retries_pct'))) +print('\n%d AP(s) on site \"$SITE\"' % len(aps)) +" || exit 1 ;; + clients|sta) + api "$CB/api/s/$SITE/stat/sta" | "$PY" -c " +import json,sys +cl=json.loads(sys.stdin.read()).get('data',[]) +wifi=[c for c in cl if not c.get('is_wired')] +print('%-20s %5s %7s %6s %4s %4s %-15s %s' % ('CLIENT','RSSI','SIGNAL','NOISE','CH','SAT','RATE kbps t/r','SSID')) +for c in sorted(wifi,key=lambda c:-(c.get('rssi') or 0)): + nm=c.get('hostname') or c.get('name') or c.get('mac') + print('%-20s %5s %7s %6s %4s %4s %-15s %s' % (str(nm)[:20], c.get('rssi'), c.get('signal'), c.get('noise'), + c.get('channel'), c.get('satisfaction'), str(c.get('tx_rate','-'))+'/'+str(c.get('rx_rate','-')), c.get('essid','-'))) +print('\n%d wireless of %d total client(s)' % (len(wifi), len(cl))) +" || exit 1 ;; + devices) + api "$CB/api/s/$SITE/stat/device" | "$PY" -c " +import json,sys +d=json.loads(sys.stdin.read()).get('data',[]) +print('%-22s %-6s %-15s %5s %9s' % ('NAME','TYPE','IP','#STA','UPTIME(h)')) +for x in sorted(d,key=lambda a:(a.get('type',''),a.get('name') or '')): + print('%-22s %-6s %-15s %5s %9s' % ((x.get('name') or '?')[:22], x.get('type'), x.get('ip','-'), x.get('num_sta','-'), round((x.get('uptime') or 0)/3600))) +" || exit 1 ;; + raw) + [ -z "$RAWPATH" ] && { echo "usage: $SELF net raw " >&2; exit 2; } + api "$CB$RAWPATH" | "$PY" -c "import json,sys; print(json.dumps(json.load(sys.stdin),indent=1)[:5000])" || exit 1 ;; + *) echo "[$SELF] net subcommand must be radios|clients|devices|raw" >&2; exit 2 ;; + esac + ;; + + raw) + [ -z "${1:-}" ] && { echo "usage: $SELF raw " >&2; exit 2; } + api "$1" | "$PY" -c "import json,sys; print(json.dumps(json.load(sys.stdin),indent=1)[:4000])" || exit 1 + ;; + + *) + echo "[$SELF] unknown mode '$MODE' (fleet|host|devices|sites|isp|find|raw)" >&2; exit 2 ;; +esac