#!/usr/bin/env bash # discover-unifi.sh — pull UniFi infrastructure via the Site Manager API # https://api.ui.com (docs: https://developer.ui.com/site-manager/) # # 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. # # 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_API_KEY= scripts/discover-unifi.sh hosts # UNIFI_API_KEY= scripts/discover-unifi.sh sites # UNIFI_API_KEY= scripts/discover-unifi.sh devices # UNIFI_API_KEY= scripts/discover-unifi.sh all # dumps all three as TSVs # # Alternatively, put the key in /etc/unifi-api.env (root:600) with # UNIFI_API_KEY=... # and source it before calling. # # Output: TSV on stdout. IP is column 1 for hosts/devices so these can # be piped into discover-gaps.sh directly: # # hosts: IP MAC NAME MODEL FW_VERSION SOURCE # (IP is LAN — picked from reportedState.ipAddrs first # private-range entry; falls back to WAN if none found) # devices: IP MAC NAME MODEL HOST_NAME SOURCE # (APs, switches — the actual network equipment) # sites: SITE_ID SITE_NAME HOST_ID SOURCE # (metadata only — no IP column, not useful for gap analysis) # # SOURCE column is "unifi:" so concatenated outputs stay # distinguishable. # # Requires: curl + jq (apt install jq). set -euo pipefail if [ -z "${1:-}" ]; then cat <<'EOF' >&2 usage: UNIFI_API_KEY=... scripts/discover-unifi.sh env: UNIFI_API_KEY required (Bearer key from Ubiquiti console) UNIFI_API_BASE optional, default: https://api.ui.com EOF exit 2 fi : "${UNIFI_API_KEY:?UNIFI_API_KEY env var required — see ui.com console → API keys}" 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 # ---------------------------------------------------------------------- # 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 # ---------------------------------------------------------------------- # Shared jq prelude: normalize MAC to xx:xx:xx:xx:xx:xx lowercase so # output aligns with the FortiGate format. JQ_PRELUDE=' def norm_mac: . as $m | if $m == null or $m == "-" then "-" else ($m | ascii_downcase | gsub("[^0-9a-f]"; "")) as $h | if ($h | length) == 12 then ($h[0:2] + ":" + $h[2:4] + ":" + $h[4:6] + ":" + $h[6:8] + ":" + $h[8:10] + ":" + $h[10:12]) else $m end end; ' # LAN filter regex. Default matches the fleet's 10.x.x.x space; override # with UNIFI_LAN_FILTER if you run a different private range. LAN_FILTER="${UNIFI_LAN_FILTER:-^10\\.}" emit_hosts() { # LAN IP is the first RFC1918 entry in reportedState.ipAddrs that is # NOT also present as a WAN ipv4 (reportedState.wans[]) — UDMs with # RFC1918-addressed WAN2 interfaces would otherwise get mis-picked as # their LAN IP. Falls back through the usual chain if nothing matches. ui_get /ea/hosts | jq -r "$JQ_PRELUDE"' . as $h | (($h.reportedState.wans // []) | map(.ipv4 // empty)) as $wans | [ ( ([(.reportedState.ipAddrs // [])[] | select( (test("^10\\.") or test("^192\\.168\\.") or test("^172\\.(1[6-9]|2[0-9]|3[0-1])\\.") ) and (. as $ip | ($wans | index($ip)) == null) )] | .[0]) // .reportedState.ip // .ipAddress // "-" ), ((.reportedState.mac // "-") | norm_mac), (.reportedState.hostname // .reportedState.name // "-"), (.reportedState.hardware.shortname // .reportedState.hardware.name // .type // "-"), (.reportedState.version // "-"), "unifi:hosts" ] | @tsv ' } emit_sites() { # Sites have no IP — no point running these through discover-gaps.sh. # Kept here for inventory purposes only. ui_get /ea/sites | jq -r ' [ (.siteId // .id // "-"), (.meta.name // .name // "-"), (.hostId // "-"), "unifi:sites" ] | @tsv ' } emit_devices() { # /ea/devices returns per-host wrappers; flatten into one row per # AP/switch. Drop entries that clutter gap analysis without adding value: # - IPs outside the fleet LAN range (UDM's public WAN IP listed as # a "device", etc.) # - UDM self-records (isConsole=true or their WAN IP matches a # wans[].ipv4 — the UDM itself shows up in its own devices list) # - UCI records (UniFi Cable Internet = ISP uplink tracking, model="UCI") ui_get /ea/devices | jq -r \ --arg lan "$LAN_FILTER" \ "$JQ_PRELUDE"' . as $h | (($h.wans // []) | map(.ipv4 // empty)) as $wans | (.devices // [])[] | select( (.ip // "") | test($lan) ) | select( (.isConsole // false) != true and (.model // "") != "UCI" and (.ip as $ip | ($wans | index($ip)) == null) ) | [ (.ip // "-"), ((.mac // .id // "-") | norm_mac), (.name // "-"), (.model // .shortname // "-"), ($h.hostName // $h.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 ;; # Raw JSON dump for a given endpoint. Useful for figuring out what # fields actually exist so the TSV emitters can be updated. raw) shift path="${1:-/ea/hosts}" curl -sS -H "X-API-KEY: ${UNIFI_API_KEY}" -H "Accept: application/json" \ "${BASE}${path}" | jq . ;; *) echo "error: unknown mode '$MODE' — expected hosts|sites|devices|all|raw " >&2 exit 2 ;; esac