b33439499b
First real run surfaced 31 gap rows, ~20 of which were noise. These
changes reduce the output to actionable signal.
1. discover-unifi: /ea/devices now filters out
- IPs outside the fleet LAN range (UDM's WAN IP appearing as a
"device", ISP uplink records with public IPs)
- UDM self-records (isConsole=true, or IP matches wans[].ipv4)
- UCI records (UniFi Cable Internet = ISP modem tracking)
LAN filter regex defaults to ^10\. (matches 10.0.0.0/8); override
via UNIFI_LAN_FILTER env var if you run other private ranges.
2. discover-gaps: new --ignore-unifi flag drops rows where the final
SOURCE column starts with "unifi:". Useful for "show me servery
things to manage, not the fleet's network hardware."
3. discover-gaps: known-IP set now pulls IPs from
servers/*/proxmox-details.txt AND servers/*/system-details.txt in
addition to README.md and ssh-target. Consequence: VMs tracked by
proxmox_inspect.sh are automatically counted as known without
needing a separate servers/<vmname>/ dir. Also strips meaningless
addresses (127.*, 0.0.0.0, 169.254.*) so they can't false-positive
a "known" match.
4. MAC normalization: both discover-fortigate and discover-unifi now
emit xx:xx:xx:xx:xx:xx lowercase. Previously FortiGate used colon
format, UniFi used no-separator uppercase — same MAC looked
different per source. Fortigate does tolower() in awk; UniFi uses
a shared jq `norm_mac` function.
235 lines
7.3 KiB
Bash
Executable File
235 lines
7.3 KiB
Bash
Executable File
#!/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=<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
|
|
#
|
|
# 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:<endpoint>" 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 <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
|
|
|
|
: "${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 <path>" >&2
|
|
exit 2
|
|
;;
|
|
esac
|