152 lines
9.8 KiB
Bash
152 lines
9.8 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,
|
|
# optional `port` — else `--port N`, else default 22; e.g. the ACG office box is on 2248).
|
|
# 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:
|
|
# READ (no gate):
|
|
# 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> pf-list # list NAT port-forwards (WAN exposure)
|
|
# pfsense-ssh.sh <slug> fw-list # list filter (firewall) rules
|
|
# pfsense-ssh.sh <slug> showblock [--if wan] # active easyrule blocks
|
|
# pfsense-ssh.sh <slug> run "<command>" # arbitrary command (CAN mutate — operator-gated)
|
|
# pfsense-ssh.sh <slug> shell # (prints the interactive ssh command to paste)
|
|
# WRITE (DRY-RUN by default; add --apply to commit — write_config + filter reload):
|
|
# pfsense-ssh.sh <slug> pf-disable|pf-enable|pf-delete <tracker|descr> [--apply]
|
|
# pfsense-ssh.sh <slug> pf-set-ports <tracker|descr> <dstport> [<localport>] [--apply]
|
|
# pfsense-ssh.sh <slug> pf-set-src <tracker|descr> <cidr|any> [--apply]
|
|
# pfsense-ssh.sh <slug> fw-disable|fw-enable <tracker|descr> [--apply]
|
|
# pfsense-ssh.sh <slug> block-ips|unblock <ip[,ip,...]> [--if wan] [--apply] # easyrule
|
|
# The pf-*/fw-* verbs drive scripts/pfsense-gwc.php (bootstraps $config); block/unblock use `easyrule`.
|
|
# NOTE: `run` executes whatever you pass, including changes — there is no dry-run for it. For repeatable
|
|
# changes prefer the named, reviewed verbs above over ad-hoc `run`.
|
|
set -uo pipefail
|
|
REPO="$(git rev-parse --show-toplevel 2>/dev/null || echo .)"
|
|
VAULT="$REPO/.claude/scripts/vault.sh"
|
|
HERE="$(cd "$(dirname "$0")" && pwd)"
|
|
GWC_PHP="$HERE/pfsense-gwc.php"
|
|
# Mandatory skill error logging (skill-creator rule): log GENUINE functional failures only
|
|
# (SSH connect/auth fail, php fatal, easyrule non-success) — NOT handled conditions (missing cred,
|
|
# bad args, site not found). Soft-fails so it never breaks the caller.
|
|
SKILL_ID="unifi-wifi/pfsense-ssh"
|
|
logerr(){ bash "$REPO/.claude/scripts/log-skill-error.sh" "$SKILL_ID" "$1" --context "${2:-}" >/dev/null 2>&1 || true; }
|
|
SLUG="${1:?usage: pfsense-ssh.sh <slug-or-vault-path> <action> [args] [--apply]}"
|
|
ACT="${2:?action: audit|dhcp|pf-list|fw-list|pf-*|fw-*|block-ips|unblock|showblock|run|shell}"; shift 2 || true
|
|
APPLY=0; BLOCK_IF="wan"; PORT=""; POS=()
|
|
while [ $# -gt 0 ]; do case "$1" in
|
|
--apply) APPLY=1; shift;;
|
|
--if) BLOCK_IF="${2:?--if needs an interface}"; shift 2;;
|
|
--port) PORT="${2:?--port needs a number}"; shift 2;;
|
|
*) POS+=("$1"); shift;;
|
|
esac; done
|
|
# Cred path (Mike 2026-06-21, option A): arg containing '/' = a full vault path (any infra/-vaulted
|
|
# device, e.g. infrastructure/pfsense-firewall); else clients/<slug>/pfsense-firewall.
|
|
case "$SLUG" in */*) VP="$SLUG" ;; *) VP="clients/$SLUG/pfsense-firewall" ;; esac
|
|
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
|
|
# SSH port precedence: --port flag > vault `port`/`credentials.port` field > default 22.
|
|
# (vault get-field returns the literal "null" for a missing field, so normalize "" and "null".)
|
|
if [ -z "$PORT" ]; then
|
|
for pf in port credentials.port; do
|
|
v="$(bash "$VAULT" get-field "$VP" "$pf" 2>/dev/null || true)"
|
|
case "$v" in ""|null) ;; *) PORT="$v"; break;; esac
|
|
done
|
|
fi
|
|
PORT="${PORT:-22}"
|
|
# Fail loud (Mike's add): a wholly-empty resolve = path typo/missing -> clear [ERROR], not a confusing
|
|
# SSH failure later. A partial resolve = the entry exists but is missing a field.
|
|
if [ -z "$HOST" ] && [ -z "$U" ] && [ -z "$PP" ]; then
|
|
echo "[ERROR] no cred at vault:$VP (path not found or empty — check the slug/vault-path)"; exit 2; fi
|
|
if [ -z "$HOST" ] || [ -z "$U" ] || [ -z "$PP" ]; then
|
|
echo "[BLOCKED] incomplete cred at vault:$VP (need fields: host, credentials.username, credentials.password)"; exit 2; fi
|
|
if [ "$ACT" = "shell" ]; then echo "ssh -p ${PORT} ${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 -p "$PORT" \
|
|
-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; local rc=$?
|
|
[ "$rc" = 255 ] && logerr "SSH connect/auth failed (rc=255)" "host=$HOST:$PORT slug=$SLUG act=$ACT"
|
|
return $rc; }
|
|
|
|
# shell-quote a value for safe embedding in the remote sh script
|
|
sq(){ printf "'%s'" "$(printf '%s' "${1:-}" | sed "s/'/'\\\\''/g")"; }
|
|
# ship pfsense-gwc.php to /tmp on the box (base64 over the wire) and run it with argv: action apply T V1 V2
|
|
run_gwc(){
|
|
[ -f "$GWC_PHP" ] || { echo "[ERROR] helper not found: $GWC_PHP"; logerr "gwc helper missing" "path=$GWC_PHP"; exit 1; }
|
|
local b64; b64="$(base64 "$GWC_PHP" | tr -d '\n')"
|
|
local out
|
|
out="$( { printf 'A=%s; AP=%s; T=%s; V1=%s; V2=%s\n' "$(sq "$1")" "$(sq "$2")" "$(sq "$3")" "$(sq "$4")" "$(sq "$5")"
|
|
printf 'printf %%s %s | openssl base64 -A -d > /tmp/pfsense-gwc.php\n' "$(sq "$b64")"
|
|
printf 'php /tmp/pfsense-gwc.php "$A" "$AP" "$T" "$V1" "$V2" 2>&1\n' # 2>&1: surface php fatals (display_errors is Off on pfSense)
|
|
printf 'rm -f /tmp/pfsense-gwc.php\n'
|
|
} | pfssh )"
|
|
printf '%s\n' "$out"
|
|
case "$out" in
|
|
*"Fatal error"*|*"[FAIL]"*) logerr "pfsense-gwc functional error" "slug=$SLUG act=$1 host=$HOST:$PORT" ;;
|
|
esac
|
|
}
|
|
|
|
echo "[INFO] pfSense $ACT @ $U@$HOST:$PORT (vault:$VP)"
|
|
case "$ACT" in
|
|
run)
|
|
CMD="${POS[*]}"; [ -n "$CMD" ] || { echo "[ERROR] run needs a command"; exit 1; }
|
|
printf '%s\n' "$CMD" | pfssh ;;
|
|
|
|
pf-list) run_gwc pf-list 0 "" "" "" ;;
|
|
fw-list) run_gwc fw-list 0 "" "" "" ;;
|
|
|
|
pf-disable|pf-enable|pf-delete|pf-set-ports|pf-set-src|fw-disable|fw-enable)
|
|
[ -n "${POS[0]:-}" ] || { echo "[ERROR] $ACT needs <tracker|descr>"; exit 1; }
|
|
echo "[INFO] mode=$([ "$APPLY" = 1 ] && echo APPLY || echo DRY-RUN)"
|
|
run_gwc "$ACT" "$APPLY" "${POS[0]:-}" "${POS[1]:-}" "${POS[2]:-}" ;;
|
|
|
|
block-ips|unblock)
|
|
IPS="${POS[0]:-}"; [ -n "$IPS" ] || { echo "[ERROR] $ACT needs <ip[,ip,...]>"; exit 1; }
|
|
VERB=$([ "$ACT" = block-ips ] && echo block || echo unblock)
|
|
echo "[INFO] easyrule $VERB if=$BLOCK_IF ips=$IPS mode=$([ "$APPLY" = 1 ] && echo APPLY || echo DRY-RUN)"
|
|
if [ "$APPLY" != 1 ]; then
|
|
echo " [dry-run] would run (per ip): easyrule $VERB $BLOCK_IF <ip>. Add --apply to write."; exit 0; fi
|
|
CMDS=""; IFS=',' read -ra ARR <<< "$IPS"
|
|
for ip in "${ARR[@]}"; do ip="$(printf '%s' "$ip" | tr -d ' ')"; [ -n "$ip" ] && CMDS+="echo \"-- $VERB $ip\"; easyrule $VERB $BLOCK_IF $ip; "; done
|
|
out="$(printf '%s\n' "$CMDS" | pfssh)"; printf '%s\n' "$out"
|
|
case "$out" in *[Ss]uccess*) ;; *) logerr "easyrule $VERB did not report success" "if=$BLOCK_IF ips=$IPS" ;; esac ;;
|
|
|
|
showblock)
|
|
printf 'easyrule showblock %s\n' "$BLOCK_IF" | 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|pf-list|fw-list|pf-disable|pf-enable|pf-delete|pf-set-ports|pf-set-src|fw-disable|fw-enable|block-ips|unblock|showblock|run|shell"; exit 1;;
|
|
esac
|