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"