From 921891b27af3cb0663013611a1a2d5ce8871cc00 Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Tue, 21 Apr 2026 11:51:00 -0700 Subject: [PATCH] scripts: network-discovery tooling for FortiGate + UniFi MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three scripts that surface hosts on the fleet's networks that aren't already tracked under servers/*/. Goal: spot servers that need management coverage (inventory, backup, monitoring) without wandering the LAN by hand. discover-fortigate.sh SSH to a FortiGate admin, run `execute dhcp lease-list all`, emit TSV (IP, MAC, hostname, vdom, source). SSH was picked over the REST API for now because it needs no API-token plumbing. The parser is defensive about FortiOS output format drift (multiple VDOM sections, optional hostname). API variant can replace it when the extra robustness is worth the token setup. discover-unifi.sh Cookie-auth REST call against a UniFi Controller. Tries /api/auth/login (UniFi OS / UDM / Cloud Key Gen2+) first; falls back to legacy /api/login for self-hosted controllers. Output is the same TSV shape as the FortiGate script so the two mix. Needs UNIFI_USER / UNIFI_PASS env and jq. discover-gaps.sh Consumes one or more TSVs from the sources above. Builds the set of managed IPs from servers/*/ssh-target (plus a grep of README files for documented IPs) and prints any discovered IPs not in that set. Exit code is 1 if gaps found — suitable for cron alerting. Common pipeline: scripts/discover-fortigate.sh ana-fw.phasefinal.com > /tmp/ana.tsv scripts/discover-fortigate.sh nh3-gw.phasefinal.com > /tmp/nh3.tsv UNIFI_USER=admin UNIFI_PASS=… scripts/discover-unifi.sh esh-uc.esteban.net > /tmp/esh.tsv scripts/discover-gaps.sh /tmp/ana.tsv /tmp/nh3.tsv /tmp/esh.tsv First-time use probably needs: - SSH access configured to each FortiGate (admin login, key preferred) - UniFi user with read access (the built-in API read-only role works) - `jq` installed on the runner (for UniFi script) --- README.md | 5 ++- scripts/discover-fortigate.sh | 78 ++++++++++++++++++++++++++++++++++ scripts/discover-gaps.sh | 78 ++++++++++++++++++++++++++++++++++ scripts/discover-unifi.sh | 80 +++++++++++++++++++++++++++++++++++ 4 files changed, 240 insertions(+), 1 deletion(-) create mode 100755 scripts/discover-fortigate.sh create mode 100755 scripts/discover-gaps.sh create mode 100755 scripts/discover-unifi.sh diff --git a/README.md b/README.md index 4ee728b..529b3bb 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,10 @@ Per-host snapshots of the running system live under `servers//system-detai │ ├── refresh-proxmox-info.sh # pull fresh proxmox-details.txt for one/all PVE nodes │ ├── add-host.sh # register a new server (writes servers//ssh-target) │ ├── sync-stacks.sh # pull /opt/docker/{compose,conf}/ → stacks-mirror/ -│ └── deploy-stack.sh # push stacks-mirror/// with diff + prompt +│ ├── deploy-stack.sh # push stacks-mirror/// with diff + prompt +│ ├── discover-fortigate.sh # DHCP lease list from a FortiGate via SSH +│ ├── discover-unifi.sh # client list from a UniFi Controller via REST +│ └── discover-gaps.sh # find IPs in discovery TSVs not tracked in servers/ ├── servers/ # per-host notes + latest snapshot + ssh-target fallback │ └── / │ ├── README.md diff --git a/scripts/discover-fortigate.sh b/scripts/discover-fortigate.sh new file mode 100755 index 0000000..2abefc5 --- /dev/null +++ b/scripts/discover-fortigate.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# discover-fortigate.sh — pull DHCP lease list from a FortiGate. +# +# SSHes to a FortiGate admin account, runs `execute dhcp lease-list`, +# parses the output into TSV (IP, MAC, hostname, vdom, source). +# +# Usage: +# scripts/discover-fortigate.sh +# +# Env overrides: +# FORTIGATE_SSH_USER default: admin +# +# Output: TSV on stdout, one lease per line: +# IP MAC HOSTNAME VDOM SOURCE +# Where SOURCE is "fortigate:" so multiple runs can be concatenated +# and still identified. +# +# Example: +# scripts/discover-fortigate.sh ana-fw.phasefinal.com > leases-ana.tsv +# scripts/discover-fortigate.sh nh3-gw.phasefinal.com > leases-nh3.tsv +# +# Requires: ssh config (or `~/.ssh/config` host alias) for the FortiGate, +# with key auth OR interactive password. FortiGate's SSH expects admin-level +# credentials. + +set -euo pipefail + +if [ -z "${1:-}" ]; then + echo "usage: $(basename "$0") " >&2 + exit 2 +fi + +HOST="$1" +USER="${FORTIGATE_SSH_USER:-admin}" + +# FortiGate CLI command. `execute dhcp lease-list all` dumps every vdom. +# If the device is single-vdom, `execute dhcp lease-list` (no arg) also works. +# Send via stdin so we don't rely on interactive shell handling. +raw=$(ssh -o BatchMode=no -o StrictHostKeyChecking=accept-new \ + "${USER}@${HOST}" 'execute dhcp lease-list all' 2>/dev/null \ + || ssh -o BatchMode=no -o StrictHostKeyChecking=accept-new \ + "${USER}@${HOST}" 'execute dhcp lease-list' 2>/dev/null) + +if [ -z "$raw" ]; then + echo "error: no lease data from $HOST (check SSH access / admin creds)" >&2 + exit 1 +fi + +# Parse. +# +# FortiGate lease output looks approximately like: +# +# VDOM: root +# Interface: internal +# IP MAC Hostname Lease-Expiry +# 10.250.50.100 00:1a:2b:3c:4d:5e laptop-01 Mon Apr 21 14:30:00 2026 +# 10.250.50.101 aa:bb:cc:dd:ee:ff printer Mon Apr 21 15:00:00 2026 +# +# Format varies by FortiOS version. The awk below is defensive: +# - only accepts lines where field 1 matches IPv4 +# - assumes MAC is field 2, hostname is field 3-4 (may contain whitespace), +# which is the most common layout. +awk -v src="fortigate:${HOST}" -v vdom="" ' + /^VDOM:/ { vdom=$2; next } + /^Interface:/ { next } + /^IP[[:space:]]/ { next } + /^[[:space:]]*$/ { next } + $1 ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/ { + ip = $1 + mac = $2 + # Hostname may be missing (shows "-" or empty) or contain spaces; take + # the rest of the line minus the trailing date fields. + host = "" + for (i=3; i<=NF-6; i++) host = host (host=="" ? "" : " ") $i + if (host == "" || host == "-") host = "-" + printf "%s\t%s\t%s\t%s\t%s\n", ip, mac, host, (vdom==""?"-":vdom), src + } +' <<<"$raw" diff --git a/scripts/discover-gaps.sh b/scripts/discover-gaps.sh new file mode 100755 index 0000000..47a328e --- /dev/null +++ b/scripts/discover-gaps.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# discover-gaps.sh — find devices in discovery TSVs that aren't in +# servers/*/ssh-target (the authoritative "managed hosts" list). +# +# Input: one or more TSV files, each a line of: +# IP MAC HOSTNAME (EXTRA1) (EXTRA2) … +# Produced by discover-fortigate.sh, discover-unifi.sh, or equivalent. +# +# Usage: +# scripts/discover-gaps.sh leases-ana.tsv leases-nh3.tsv +# +# Output: rows where IP does NOT match any IP in servers/*/ssh-target, +# sorted for readability. Same TSV format as input. +# +# Exit code: +# 0 if zero gaps found +# 1 if one or more unmanaged IPs detected (useful for cron alerting) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +SERVERS_DIR="$REPO_ROOT/servers" + +if [ "$#" -lt 1 ]; then + echo "usage: $(basename "$0") [more.tsv ...]" >&2 + exit 2 +fi + +# Build the set of managed IPs from servers/*/ssh-target. +# ssh-target may contain "user@IP" or a bare IP/hostname. We extract any +# IPv4 and (if the value is a hostname) try to resolve it. +managed_ips=$( + for stf in "$SERVERS_DIR"/*/ssh-target; do + [ -r "$stf" ] || continue + target=$(awk 'NF{print $1; exit}' "$stf") + val=${target##*@} + if [[ "$val" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "$val" + else + # getent hosts returns "IP name [alias…]"; take first field if resolved + ip=$(getent hosts "$val" 2>/dev/null | awk 'NR==1{print $1}') + [ -n "$ip" ] && echo "$ip" + fi + done | sort -u +) + +if [ -z "$managed_ips" ]; then + echo "warning: no managed IPs found under $SERVERS_DIR/*/ssh-target" >&2 +fi + +# Also tolerate IPs that appear in servers/*/README.md (for hosts that +# have snapshot info but no ssh-target — e.g. Proxmox hosts documented +# there). Extract any IPv4-looking token from READMEs. +readme_ips=$( + grep -rhEo '([0-9]+\.){3}[0-9]+' "$SERVERS_DIR"/*/README.md 2>/dev/null | sort -u || true +) + +all_known=$(printf '%s\n%s\n' "$managed_ips" "$readme_ips" | sort -u) + +# Read the discovery TSVs, filter out lines whose IP is in all_known. +unmanaged=$( + cat "$@" | awk -v known="$all_known" ' + BEGIN { + n=split(known, arr, "\n") + for (i=1; i<=n; i++) if (arr[i] != "") k[arr[i]] = 1 + } + NF >= 1 && $1 ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/ && !($1 in k) + ' | sort -t$'\t' -k1,1 -V -u +) + +if [ -z "$unmanaged" ]; then + echo "no unmanaged devices — every discovered IP is tracked under servers/" >&2 + exit 0 +fi + +printf '%s\n' "$unmanaged" +exit 1 diff --git a/scripts/discover-unifi.sh b/scripts/discover-unifi.sh new file mode 100755 index 0000000..78a25ef --- /dev/null +++ b/scripts/discover-unifi.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# discover-unifi.sh — pull client list from a UniFi Controller (UDM / UniFi OS). +# +# Uses the UniFi REST API: +# POST /api/login {username, password} +# GET /proxy/network/api/s//stat/sta +# +# 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//stat/sta directly +# (no /proxy/network prefix). Script tries UniFi OS first, falls back. +# +# Usage: +# UNIFI_USER=admin UNIFI_PASS=... scripts/discover-unifi.sh +# +# Optional env: +# UNIFI_SITE default: default +# UNIFI_PORT default: 443 +# +# Output: TSV on stdout, one client per line: +# IP MAC HOSTNAME AP_ALIAS SOURCE + +set -euo pipefail + +if [ -z "${1:-}" ]; then + echo "usage: UNIFI_USER=admin UNIFI_PASS=… $(basename "$0") " >&2 + exit 2 +fi + +CONTROLLER="$1" +PORT="${UNIFI_PORT:-443}" +SITE="${UNIFI_SITE:-default}" +BASE="https://${CONTROLLER}:${PORT}" + +: "${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") + +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"