DECISION (Mike, 2026-06-16): drop the RESTAPI package — VPN + SSH shell reads the same data and makes changes. Confirmed Cascades pfSense is Plus 25.07-RELEASE (current; the "too old" premise was wrong) and admin SSH = real shell (no menu). The upgrade/package blocker is moot; compat layer is off hold. - NEW scripts/pfsense-ssh.sh: audit (version/WAN-media/gateway-events/DHCP-exhaustion/states/DNS/load/NIC), dhcp (pool utilization + no-free-leases), run "<cmd>" (arbitrary, incl changes; operator-gated). Cred from clients/<slug>/pfsense-firewall; system OpenSSH via askpass. Validated live on Cascades. - audit report: added "pfSense health check (2026-06-16)" — DHCP NOT exhausted (192.168.0.0/22 pool 270/507, 0 no-free-leases), DNS up, dual-WAN stable (no gateway flaps), states/load healthy => gateway is NOT a WiFi factor; the 2.4 GHz RF work is the sole fix. (Minor: igc3/WAN2 I225 2.5G counter quirk, not a fault.) - ROADMAP §E + SKILL.md updated to the SSH backend decision; REST pfsense-backend.sh kept dormant/optional. - Remaining: named gated CONTROL verbs over SSH (easyrule block-ips, pf/fw toggles) + optional gw-* dispatch. - Closed obsolete coord todo (upgrade-pfSense-for-RESTAPI). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
69 lines
4.7 KiB
Bash
69 lines
4.7 KiB
Bash
#!/usr/bin/env bash
|
|
# pfsense-ssh.sh — talk to a client's pfSense over SSH (site VPN reachable). This is the SSH backend for
|
|
# the gateway compatibility layer. DECISION (Mike, 2026-06-16): the RESTAPI package is NOT needed — with
|
|
# VPN + SSH shell we can read the same data and make changes directly. (Confirmed on Cascades pfSense Plus
|
|
# 25.07: admin SSH drops straight to a shell, no menu gotcha.)
|
|
#
|
|
# Cred from the vault: clients/<slug>/pfsense-firewall (top-level `host`, credentials.username/password).
|
|
# Uses SYSTEM OpenSSH via an SSH_ASKPASS helper (no sshpass dependency); runs each call as `sh -s` over a
|
|
# heredoc so awk/quoting is clean.
|
|
#
|
|
# Usage:
|
|
# pfsense-ssh.sh <slug> audit # read-only health: version/WAN/DHCP-exhaustion/DNS/states/load
|
|
# pfsense-ssh.sh <slug> dhcp # DHCP pool utilization + "no free leases" check
|
|
# pfsense-ssh.sh <slug> run "<command>" # arbitrary command (CAN mutate — operator-gated; e.g. run "pfctl -si")
|
|
# pfsense-ssh.sh <slug> shell # (prints the interactive ssh command to paste)
|
|
# NOTE: `run` executes whatever you pass, including changes — there is no dry-run for it. For repeatable
|
|
# changes prefer adding a named, reviewed verb here over ad-hoc `run`.
|
|
set -uo pipefail
|
|
REPO="$(git rev-parse --show-toplevel 2>/dev/null || echo .)"
|
|
VAULT="$REPO/.claude/scripts/vault.sh"
|
|
SLUG="${1:?usage: pfsense-ssh.sh <slug> <audit|dhcp|run|shell> [args]}"
|
|
ACT="${2:?action: audit|dhcp|run|shell}"; shift 2 || true
|
|
VP="clients/$SLUG/pfsense-firewall"
|
|
HOST="$(bash "$VAULT" get-field "$VP" host 2>/dev/null || bash "$VAULT" get-field "$VP" credentials.host 2>/dev/null || true)"
|
|
U="$(bash "$VAULT" get-field "$VP" credentials.username 2>/dev/null || true)"
|
|
PP="$(bash "$VAULT" get-field "$VP" credentials.password 2>/dev/null || true)"; export PP
|
|
if [ -z "$HOST" ] || [ -z "$U" ] || [ -z "$PP" ]; then
|
|
echo "[BLOCKED] need host + admin creds at vault:$VP (fields: host, credentials.username, credentials.password)"; exit 2; fi
|
|
if [ "$ACT" = "shell" ]; then echo "ssh ${U}@${HOST} # password in vault:$VP"; exit 0; fi
|
|
|
|
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
|
ASKP="$TMP/a.sh"; printf '#!/bin/sh\nprintf "%%s\\n" "$PP"\n' >"$ASKP"; chmod +x "$ASKP"
|
|
# pfssh: feed remote sh a script on stdin; password via askpass (stderr noise dropped)
|
|
pfssh(){ SSH_ASKPASS="$ASKP" SSH_ASKPASS_REQUIRE=force DISPLAY=:0 ssh \
|
|
-o ConnectTimeout=12 -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null \
|
|
-o PreferredAuthentications=password -o PubkeyAuthentication=no -o NumberOfPasswordPrompts=1 \
|
|
"$U@$HOST" 'sh -s' 2>/dev/null; }
|
|
|
|
echo "[INFO] pfSense $ACT @ $U@$HOST (vault:$VP)"
|
|
case "$ACT" in
|
|
run)
|
|
CMD="$*"; [ -n "$CMD" ] || { echo "[ERROR] run needs a command"; exit 1; }
|
|
printf '%s\n' "$CMD" | pfssh ;;
|
|
dhcp)
|
|
pfssh <<'RSCRIPT'
|
|
echo "## DHCP backend"; { pgrep -lf dhcpd >/dev/null && echo "ISC dhcpd active"; }; { pgrep -lf kea >/dev/null && echo "Kea active"; }
|
|
echo "## 'no free leases' events (exhaustion)"; clog /var/log/dhcpd.log 2>/dev/null | grep -ic 'no free leases'
|
|
echo "## active leases per /24 (top 20)"
|
|
awk '/^lease /{ip=$2} /binding state active/{a[ip]=1} END{for(i in a){n=i; sub(/\.[0-9]+$/,"",n); c[n]++} for(k in c) print c[k], k}' /var/dhcpd/var/db/dhcpd.leases 2>/dev/null | sort -rn | head -20
|
|
echo "## pool ranges (subnet -> range)"; grep -hE 'subnet|range ' /var/dhcpd/etc/dhcpd.conf 2>/dev/null | paste - - | head -40
|
|
RSCRIPT
|
|
;;
|
|
audit)
|
|
pfssh <<'RSCRIPT'
|
|
echo "## VERSION"; cat /etc/version 2>/dev/null
|
|
echo "## UPTIME/LOAD"; uptime
|
|
echo "## physical interfaces (media/status; VLAN sub-ifs skipped)"; for i in $(ifconfig -l); do case $i in *.*) continue;; igc[0-9]|em[0-9]|ix[0-9]|vmx[0-9]) m=$(ifconfig $i 2>/dev/null | grep -E 'media|status' | tr '\n' ' '); [ -n "$m" ] && echo " $i: $m";; esac; done
|
|
echo "## GATEWAY loss/down events (last 8)"; clog /var/log/gateways.log 2>/dev/null | tail -8
|
|
echo "## DHCP exhaustion ('no free leases' count)"; clog /var/log/dhcpd.log 2>/dev/null | grep -ic 'no free leases'
|
|
echo "## DHCP busiest /24s (top 8)"; awk '/^lease /{ip=$2} /binding state active/{a[ip]=1} END{for(i in a){n=i; sub(/\.[0-9]+$/,"",n); c[n]++} for(k in c) print c[k], k}' /var/dhcpd/var/db/dhcpd.leases 2>/dev/null | sort -rn | head -8
|
|
echo "## PF states"; pfctl -si 2>/dev/null | grep -iE 'current entries|searches'; pfctl -sm 2>/dev/null | grep -E '^states'
|
|
echo "## DNS resolver"; pgrep -lf unbound >/dev/null && echo "unbound running" || echo "unbound NOT running"
|
|
echo "## mbuf"; netstat -m 2>/dev/null | head -1
|
|
echo "## NIC errors (Ierrs/Oerrs/Coll)"; netstat -i 2>/dev/null | awk 'NR==1 || ($1 ~ /^(igc|em|ix|vmx)[0-9]$/)'
|
|
RSCRIPT
|
|
;;
|
|
*) echo "action: audit|dhcp|run|shell"; exit 1;;
|
|
esac
|