#!/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
