scripts/discover-unifi: switch to Site Manager API (developer.ui.com)

Rewrite to use Ubiquiti's public cloud API at api.ui.com instead of
logging into individual controllers via session cookies. Benefits:

  - One API key covers every UniFi OS device on the account (no
    per-controller login logic, no cookie jar lifecycle).
  - Read-only by design (auth keys are scoped).
  - Works across sites transparently.

Three endpoints wired up: hosts (controllers / Cloud Keys), sites,
and devices (APs / switches). Each emits a distinct TSV shape so the
output can be concatenated and still parsed.

`all` mode runs all three and prints section markers on stderr so
the stdout stream stays clean TSV suitable for discover-gaps.sh.

Pagination handled via nextToken. Rate limit not enforced locally;
Ubiquiti documents generous defaults for read endpoints.

Note: Site Manager API (early access) doesn't appear to expose a
connected-client list directly. For endpoint discovery (IP + MAC of
connected clients like laptops, IoT, etc.) we'd still need to hit
each local controller's REST API — follow-up if the infrastructure-
level data isn't enough.

Requires: curl (present), jq (apt install jq).
This commit is contained in:
2026-04-21 14:04:50 -07:00
parent 8f1a2789a9
commit 3bdbfc3424
+147 -59
View File
@@ -1,80 +1,168 @@
#!/usr/bin/env bash
# discover-unifi.sh — pull client list from a UniFi Controller (UDM / UniFi OS).
# discover-unifi.sh — pull UniFi infrastructure via the Site Manager API
# https://api.ui.com (docs: https://developer.ui.com/site-manager/)
#
# Uses the UniFi REST API:
# POST /api/login {username, password}
# GET /proxy/network/api/s/<site>/stat/sta
# Uses the cloud-hosted API with a Bearer-key header instead of logging
# into individual controllers directly. One script covers every UniFi
# host registered to the Ubiquiti account that issued the API key.
#
# UniFi OS cookie-auth variant (post-2021 devices / UDM / Cloud Key Gen2+).
# For legacy standalone controllers (self-hosted UniFi Network software on
# Linux), the endpoint is /api/login and /api/s/<site>/stat/sta directly
# (no /proxy/network prefix). Script tries UniFi OS first, falls back.
# Auth: X-API-KEY header with a key generated in the Ubiquiti console
# (account settings → API keys). Store it outside version control —
# pass via env var UNIFI_API_KEY or a protected env file.
#
# Usage:
# UNIFI_USER=admin UNIFI_PASS=... scripts/discover-unifi.sh <controller-host>
# UNIFI_API_KEY=<your-key> scripts/discover-unifi.sh hosts
# UNIFI_API_KEY=<your-key> scripts/discover-unifi.sh sites
# UNIFI_API_KEY=<your-key> scripts/discover-unifi.sh devices
# UNIFI_API_KEY=<your-key> scripts/discover-unifi.sh all # dumps all three as TSVs
#
# Optional env:
# UNIFI_SITE default: default
# UNIFI_PORT default: 443
# Alternatively, put the key in /etc/unifi-api.env (root:600) with
# UNIFI_API_KEY=...
# and source it before calling.
#
# Output: TSV on stdout, one client per line:
# IP MAC HOSTNAME AP_ALIAS SOURCE
# Output: TSV on stdout. Column layout depends on the endpoint:
#
# hosts: host_id host_name host_ip model_name controller_version
# sites: site_id site_name host_id description
# devices: mac ip name model site_id host_id
#
# Each row ends with a SOURCE column so outputs can be concatenated and
# still distinguished:
# unifi:<endpoint>
#
# Requires: curl + jq (apt install jq).
set -euo pipefail
if [ -z "${1:-}" ]; then
echo "usage: UNIFI_USER=admin UNIFI_PASS=… $(basename "$0") <controller-host>" >&2
cat <<'EOF' >&2
usage:
UNIFI_API_KEY=... scripts/discover-unifi.sh <hosts|sites|devices|all>
env:
UNIFI_API_KEY required (Bearer key from Ubiquiti console)
UNIFI_API_BASE optional, default: https://api.ui.com
EOF
exit 2
fi
CONTROLLER="$1"
PORT="${UNIFI_PORT:-443}"
SITE="${UNIFI_SITE:-default}"
BASE="https://${CONTROLLER}:${PORT}"
: "${UNIFI_API_KEY:?UNIFI_API_KEY env var required — see ui.com console → API keys}"
: "${UNIFI_USER:?UNIFI_USER env var required}"
: "${UNIFI_PASS:?UNIFI_PASS env var required (never put this in shell history — use a password-manager integration)}"
COOKIE_JAR=$(mktemp)
trap 'rm -f "$COOKIE_JAR"' EXIT
login() {
local path="$1"
curl -sk -c "$COOKIE_JAR" -X POST "$BASE$path" \
-H 'Content-Type: application/json' \
-d "{\"username\":\"$UNIFI_USER\",\"password\":\"$UNIFI_PASS\"}" \
-o /dev/null -w '%{http_code}'
}
# Try UniFi OS endpoint first, fall back to legacy
CODE=$(login /api/auth/login)
if [ "$CODE" = "200" ]; then
STATS_PATH="/proxy/network/api/s/${SITE}/stat/sta"
else
CODE=$(login /api/login)
if [ "$CODE" = "200" ]; then
STATS_PATH="/api/s/${SITE}/stat/sta"
else
echo "error: UniFi login failed (tried /api/auth/login and /api/login, got HTTP $CODE)" >&2
exit 1
fi
fi
json=$(curl -sk -b "$COOKIE_JAR" "$BASE$STATS_PATH")
BASE="${UNIFI_API_BASE:-https://api.ui.com}"
MODE="$1"
if ! command -v jq >/dev/null 2>&1; then
echo "error: jq not installed (apt install jq)" >&2
exit 2
fi
# Standardize to TSV. Each client record has: ip, mac, hostname, name (alias), ap_mac.
jq -r --arg src "unifi:${CONTROLLER}" '
.data[] |
[
(.ip // "-"),
(.mac // "-"),
(.hostname // .name // "-"),
(.ap_mac // "-"),
$src
] | @tsv
' <<<"$json"
# ----------------------------------------------------------------------
# Helpers
# ----------------------------------------------------------------------
# Paginated GET against the Site Manager API. Concatenates all pages and
# emits each element of the `data` array as one compact JSON object on
# stdout (one per line). Fails loudly on HTTP != 200 (prints the body).
ui_get() {
local path="$1"
local next=""
local url body code
while : ; do
url="${BASE}${path}"
if [ -n "$next" ]; then
case "$path" in
*\?*) url="${url}&nextToken=${next}" ;;
*) url="${url}?nextToken=${next}" ;;
esac
fi
tmp=$(mktemp)
code=$(curl -sS \
-H "X-API-KEY: ${UNIFI_API_KEY}" \
-H "Accept: application/json" \
-o "$tmp" -w '%{http_code}' \
"$url")
body=$(cat "$tmp"); rm -f "$tmp"
if [ "$code" != "200" ]; then
echo "error: ${path} returned HTTP ${code}" >&2
echo "body:" >&2
echo "$body" | head -20 >&2
return 1
fi
# Emit each data item on its own line
jq -c '.data[]?' <<<"$body"
# Check for pagination
next=$(jq -r '.nextToken // empty' <<<"$body")
[ -z "$next" ] && break
done
}
# ----------------------------------------------------------------------
# Endpoint-specific TSV formatters
# ----------------------------------------------------------------------
emit_hosts() {
ui_get /ea/hosts | jq -r '
[
(.id // "-"),
(.hardware.name // .userData.name // .reportedState.name // "-"),
(.ipAddress // .reportedState.ip // "-"),
(.hardware.shortname // .type // "-"),
(.reportedState.version // "-"),
"unifi:hosts"
] | @tsv
'
}
emit_sites() {
ui_get /ea/sites | jq -r '
[
(.siteId // .id // "-"),
(.meta.name // .name // "-"),
(.hostId // "-"),
(.meta.desc // "-"),
"unifi:sites"
] | @tsv
'
}
emit_devices() {
ui_get /ea/devices | jq -r '
[
(.mac // "-"),
(.ip // "-"),
(.name // .hardware.name // "-"),
(.model // .hardware.shortname // "-"),
(.siteId // "-"),
(.hostId // "-"),
"unifi:devices"
] | @tsv
'
}
# ----------------------------------------------------------------------
# Dispatch
# ----------------------------------------------------------------------
case "$MODE" in
hosts) emit_hosts ;;
sites) emit_sites ;;
devices) emit_devices ;;
all)
echo "# === UniFi hosts ===" >&2
emit_hosts
echo "# === UniFi sites ===" >&2
emit_sites
echo "# === UniFi devices ===" >&2
emit_devices
;;
*)
echo "error: unknown mode '$MODE' — expected hosts|sites|devices|all" >&2
exit 2
;;
esac