From c473663b984da6ce01d2a9a5d34111060ebd3a44 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sat, 14 Feb 2026 12:42:10 +0000 Subject: [PATCH] [ci skip] Add pfSense firewall management skill --- .claude/pfsense.py | 432 ++++++++++++++++++++++++++++++++ .claude/skills/pfsense/SKILL.md | 194 ++++++++++++++ 2 files changed, 626 insertions(+) create mode 100644 .claude/pfsense.py create mode 100644 .claude/skills/pfsense/SKILL.md diff --git a/.claude/pfsense.py b/.claude/pfsense.py new file mode 100644 index 00000000..074e2210 --- /dev/null +++ b/.claude/pfsense.py @@ -0,0 +1,432 @@ +#!/usr/bin/env python3 +"""pfSense CLI tool for managing the firewall via SSH. + +Usage: + python pfsense.py [options] + +Commands: + status System status overview + interfaces List interfaces with IPs and status + gateways Show gateway status + rules [iface] List firewall rules (optional: filter by interface) + nat List NAT/port forward rules + aliases List firewall aliases + alias Show alias details (members) + states Show state table summary + states-top [n] Top N connections by state count (default 10) + dhcp-leases [iface] Show DHCP leases (optional: filter by interface) + arp Show ARP table + routes Show routing table + services List services and status + service Start/stop/restart a service + logs [n] Show last N log lines (default 50) + logs-filter Search logs for text + pfctl Run arbitrary pfctl command + php Run PHP code on pfSense shell + diag Ping diagnostic to host + backup Download config backup to stdout (XML) + uptime Show system uptime + cpu Show CPU usage + memory Show memory usage + disk Show disk usage + temp Show CPU temperature + pkg-list List installed packages + dns-resolve Resolve hostname via pfSense DNS + wireguard Show WireGuard status + bgp Show BGP summary (FRR) + ospf Show OSPF neighbors (FRR) + tailscale Show Tailscale status + snort Show Snort status + raw Run arbitrary shell command +""" + +import argparse +import json +import subprocess +import sys + + +PFSENSE_HOST = "admin@10.0.20.1" +SSH_OPTS = ["-o", "ConnectTimeout=10", "-o", "StrictHostKeyChecking=no"] + + +def ssh(cmd: str, timeout: int = 30) -> str: + """Execute a command on pfSense via SSH.""" + result = subprocess.run( + ["ssh"] + SSH_OPTS + [PFSENSE_HOST, cmd], + capture_output=True, + text=True, + timeout=timeout, + ) + if result.returncode != 0 and result.stderr: + print(f"Error: {result.stderr.strip()}", file=sys.stderr) + return result.stdout.strip() + + +def cmd_status(_args): + print(ssh(""" + echo "=== System ===" + uname -sr + echo "Version: $(cat /etc/version)" + uptime + echo "" + echo "=== CPU ===" + sysctl -n hw.model + echo "Load: $(sysctl -n vm.loadavg)" + echo "" + echo "=== Memory ===" + php -r ' + $mem = @file_get_contents("/proc/meminfo") ?: ""; + $total = (int)shell_exec("sysctl -n hw.physmem") / 1024 / 1024; + $free_pages = (int)shell_exec("sysctl -n vm.stats.vm.v_free_count"); + $page_size = (int)shell_exec("sysctl -n hw.pagesize"); + $free = $free_pages * $page_size / 1024 / 1024; + printf("Total: %.0f MB, Free: %.0f MB, Used: %.0f MB (%.1f%%)\n", + $total, $free, $total - $free, ($total - $free) / $total * 100); + ' + echo "" + echo "=== Disk ===" + df -h / /var /tmp 2>/dev/null | grep -v "^Filesystem" | awk '{print $6 ": " $3 "/" $1 " (" $5 " used)"}' + echo "" + echo "=== States ===" + pfctl -si 2>/dev/null | grep "current entries" + echo "" + echo "=== Temperature ===" + sysctl -a 2>/dev/null | grep temperature | head -5 + """)) + + +def cmd_interfaces(_args): + print(ssh(""" + php -r ' + require_once("config.inc"); + require_once("interfaces.inc"); + $cfg = parse_config(true); + foreach($cfg["interfaces"] as $k => $v) { + $if = $v["if"] ?? "?"; + $descr = $v["descr"] ?? $k; + $ip = $v["ipaddr"] ?? "dhcp"; + $subnet = $v["subnet"] ?? ""; + $enabled = isset($v["enable"]) || $k == "wan" || $k == "lan" ? "UP" : "DOWN"; + $gw = $v["gateway"] ?? "-"; + printf("%-8s %-20s %-10s %-18s gw:%-10s %s\n", $k, $descr, $if, $ip . ($subnet ? "/" . $subnet : ""), $gw, $enabled); + } + ' + """)) + + +def cmd_gateways(_args): + print(ssh("pfSsh.php playback gatewaystatus")) + + +def cmd_rules(args): + iface_filter = args.interface if hasattr(args, 'interface') and args.interface else "" + if iface_filter: + print(ssh(f"pfctl -sr 2>/dev/null | grep -i '{iface_filter}'")) + else: + print(ssh("pfctl -sr 2>/dev/null")) + + +def cmd_nat(_args): + print(ssh("pfctl -sn 2>/dev/null")) + + +def cmd_aliases(_args): + print(ssh("pfctl -sT 2>/dev/null")) + + +def cmd_alias(args): + print(ssh(f"pfctl -t {args.name} -T show 2>/dev/null")) + + +def cmd_states(_args): + print(ssh("pfctl -si 2>/dev/null")) + + +def cmd_states_top(args): + n = args.n if hasattr(args, 'n') and args.n else 10 + print(ssh(f"pfctl -ss 2>/dev/null | awk '{{print $3}}' | cut -d: -f1 | sort | uniq -c | sort -rn | head -{n}")) + + +def cmd_dhcp_leases(args): + iface = args.interface if hasattr(args, 'interface') and args.interface else "" + filter_clause = f'if($l["if"] == "{iface}")' if iface else "" + print(ssh(f""" + php -r ' + require_once("config.inc"); + require_once("interfaces.inc"); + $leases = system_get_dhcpleases(); + foreach($leases["lease"] as $l) {{ + {filter_clause} + printf("%-16s %-18s %-8s %-15s %-10s %s\n", + $l["ip"], $l["mac"] ?? "-", $l["act"] ?? "-", + $l["hostname"] ?? "-", $l["if"] ?? "-", + $l["online"] ?? "-"); + }} + ' + """)) + + +def cmd_arp(_args): + print(ssh("arp -an")) + + +def cmd_routes(_args): + print(ssh("netstat -rn")) + + +def cmd_services(_args): + print(ssh(""" + php -r ' + require_once("config.inc"); + require_once("service-utils.inc"); + $svcs = get_services(); + foreach($svcs as $s) { + $status = get_service_status($s) ? "RUNNING" : "STOPPED"; + printf("%-30s %s\n", $s["name"], $status); + } + ' + """)) + + +def cmd_service(args): + action = args.action + name = args.name + if action not in ("start", "stop", "restart"): + print(f"Invalid action: {action}. Use start/stop/restart.", file=sys.stderr) + sys.exit(1) + print(ssh(f"pfSsh.php playback svc {action} {name}")) + + +def cmd_logs(args): + n = args.n if hasattr(args, 'n') and args.n else 50 + print(ssh(f"clog -f /var/log/filter.log 2>/dev/null | tail -{n}")) + + +def cmd_logs_filter(args): + print(ssh(f"clog -f /var/log/filter.log 2>/dev/null | grep -i '{args.text}'")) + + +def cmd_pfctl(args): + print(ssh(f"pfctl {args.args}")) + + +def cmd_php(args): + print(ssh(f"php -r '{args.code}'")) + + +def cmd_diag(args): + print(ssh(f"ping -c 4 {args.host}")) + + +def cmd_backup(_args): + print(ssh("cat /cf/conf/config.xml")) + + +def cmd_uptime(_args): + print(ssh("uptime")) + + +def cmd_cpu(_args): + print(ssh(""" + echo "Load: $(sysctl -n vm.loadavg)" + echo "Model: $(sysctl -n hw.model)" + echo "Cores: $(sysctl -n hw.ncpu)" + top -b -d1 2>/dev/null | head -5 || vmstat 1 2 | tail -1 + """)) + + +def cmd_memory(_args): + print(ssh(""" + php -r ' + $total = (int)shell_exec("sysctl -n hw.physmem") / 1024 / 1024; + $free_pages = (int)shell_exec("sysctl -n vm.stats.vm.v_free_count"); + $inactive_pages = (int)shell_exec("sysctl -n vm.stats.vm.v_inactive_count"); + $cache_pages = (int)shell_exec("sysctl -n vm.stats.vm.v_cache_count"); + $page_size = (int)shell_exec("sysctl -n hw.pagesize"); + $free = $free_pages * $page_size / 1024 / 1024; + $inactive = $inactive_pages * $page_size / 1024 / 1024; + $cache = $cache_pages * $page_size / 1024 / 1024; + $used = $total - $free - $inactive - $cache; + printf("Total: %.0f MB\n", $total); + printf("Used: %.0f MB (%.1f%%)\n", $used, $used / $total * 100); + printf("Free: %.0f MB\n", $free); + printf("Inactive: %.0f MB\n", $inactive); + printf("Cache: %.0f MB\n", $cache); + ' + """)) + + +def cmd_disk(_args): + print(ssh("df -h")) + + +def cmd_temp(_args): + print(ssh("sysctl -a 2>/dev/null | grep -i temp")) + + +def cmd_pkg_list(_args): + print(ssh("pfSsh.php playback listpkg")) + + +def cmd_dns_resolve(args): + print(ssh(f"drill {args.host} @127.0.0.1 2>/dev/null || host {args.host} 127.0.0.1 2>/dev/null || nslookup {args.host} 127.0.0.1")) + + +def cmd_wireguard(_args): + print(ssh("wg show 2>/dev/null || echo 'WireGuard not active or wg command not found'")) + + +def cmd_bgp(_args): + print(ssh("/usr/local/bin/vtysh -c 'show bgp summary' 2>/dev/null || echo 'FRR/BGP not available'")) + + +def cmd_ospf(_args): + print(ssh("/usr/local/bin/vtysh -c 'show ip ospf neighbor' 2>/dev/null || echo 'FRR/OSPF not available'")) + + +def cmd_tailscale(_args): + print(ssh("tailscale status 2>/dev/null || echo 'Tailscale not available'")) + + +def cmd_snort(_args): + print(ssh(""" + php -r ' + require_once("config.inc"); + require_once("service-utils.inc"); + $svcs = get_services(); + foreach($svcs as $s) { + if(stripos($s["name"], "snort") !== false) { + $status = get_service_status($s) ? "RUNNING" : "STOPPED"; + printf("%-30s %s\n", $s["name"], $status); + } + } + ' + echo "---Alerts (last 20)---" + cat /var/log/snort/snort_*/alert 2>/dev/null | tail -20 || echo "No alert logs found" + """)) + + +def cmd_raw(args): + print(ssh(args.command)) + + +def main(): + parser = argparse.ArgumentParser(description="pfSense management via SSH") + sub = parser.add_subparsers(dest="command", help="Command to run") + + sub.add_parser("status", help="System status overview") + sub.add_parser("interfaces", help="List interfaces") + sub.add_parser("gateways", help="Show gateway status") + + p = sub.add_parser("rules", help="List firewall rules") + p.add_argument("interface", nargs="?", default="", help="Filter by interface") + + sub.add_parser("nat", help="List NAT rules") + sub.add_parser("aliases", help="List aliases") + + p = sub.add_parser("alias", help="Show alias members") + p.add_argument("name", help="Alias name") + + sub.add_parser("states", help="State table summary") + + p = sub.add_parser("states-top", help="Top connections by state count") + p.add_argument("n", nargs="?", type=int, default=10) + + p = sub.add_parser("dhcp-leases", help="Show DHCP leases") + p.add_argument("interface", nargs="?", default="", help="Filter by interface") + + sub.add_parser("arp", help="ARP table") + sub.add_parser("routes", help="Routing table") + sub.add_parser("services", help="List services") + + p = sub.add_parser("service", help="Control a service") + p.add_argument("action", choices=["start", "stop", "restart"]) + p.add_argument("name", help="Service name") + + p = sub.add_parser("logs", help="Show firewall logs") + p.add_argument("n", nargs="?", type=int, default=50) + + p = sub.add_parser("logs-filter", help="Search logs") + p.add_argument("text", help="Text to search for") + + p = sub.add_parser("pfctl", help="Run pfctl command") + p.add_argument("args", help="pfctl arguments") + + p = sub.add_parser("php", help="Run PHP code") + p.add_argument("code", help="PHP code to execute") + + p = sub.add_parser("diag", help="Ping diagnostic") + p.add_argument("host", help="Host to ping") + + sub.add_parser("backup", help="Download config backup (XML)") + sub.add_parser("uptime", help="System uptime") + sub.add_parser("cpu", help="CPU usage") + sub.add_parser("memory", help="Memory usage") + sub.add_parser("disk", help="Disk usage") + sub.add_parser("temp", help="CPU temperature") + sub.add_parser("pkg-list", help="List packages") + + p = sub.add_parser("dns-resolve", help="Resolve hostname") + p.add_argument("host", help="Hostname to resolve") + + sub.add_parser("wireguard", help="WireGuard status") + sub.add_parser("bgp", help="BGP summary") + sub.add_parser("ospf", help="OSPF neighbors") + sub.add_parser("tailscale", help="Tailscale status") + sub.add_parser("snort", help="Snort status") + + p = sub.add_parser("raw", help="Run arbitrary command") + p.add_argument("command", help="Command to run") + + args = parser.parse_args() + if not args.command: + parser.print_help() + sys.exit(1) + + cmd_map = { + "status": cmd_status, + "interfaces": cmd_interfaces, + "gateways": cmd_gateways, + "rules": cmd_rules, + "nat": cmd_nat, + "aliases": cmd_aliases, + "alias": cmd_alias, + "states": cmd_states, + "states-top": cmd_states_top, + "dhcp-leases": cmd_dhcp_leases, + "arp": cmd_arp, + "routes": cmd_routes, + "services": cmd_services, + "service": cmd_service, + "logs": cmd_logs, + "logs-filter": cmd_logs_filter, + "pfctl": cmd_pfctl, + "php": cmd_php, + "diag": cmd_diag, + "backup": cmd_backup, + "uptime": cmd_uptime, + "cpu": cmd_cpu, + "memory": cmd_memory, + "disk": cmd_disk, + "temp": cmd_temp, + "pkg-list": cmd_pkg_list, + "dns-resolve": cmd_dns_resolve, + "wireguard": cmd_wireguard, + "bgp": cmd_bgp, + "ospf": cmd_ospf, + "tailscale": cmd_tailscale, + "snort": cmd_snort, + "raw": cmd_raw, + } + + func = cmd_map.get(args.command) + if func: + func(args) + else: + parser.print_help() + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/pfsense/SKILL.md b/.claude/skills/pfsense/SKILL.md new file mode 100644 index 00000000..cd92a771 --- /dev/null +++ b/.claude/skills/pfsense/SKILL.md @@ -0,0 +1,194 @@ +--- +name: pfsense +description: | + Manage the pfSense firewall at 10.0.20.1 via SSH. Use when: + (1) User asks about firewall rules, NAT, port forwarding, + (2) User asks about network diagnostics (ARP, routing, DNS, ping), + (3) User asks about DHCP leases or static mappings, + (4) User asks about VPN status (WireGuard, Tailscale), + (5) User asks about pfSense services (Snort, FRR/BGP/OSPF, etc.), + (6) User asks about firewall states, connections, or traffic, + (7) User mentions "pfsense", "firewall", "gateway", or network troubleshooting, + (8) User wants to check system health (CPU, memory, disk, temp) of pfSense. + pfSense CE 2.7.2 on FreeBSD 14.0, VMID 101 on Proxmox. +author: Claude Code +version: 1.0.0 +date: 2026-02-14 +--- + +# pfSense Firewall Management + +## Overview +- **Host**: `10.0.20.1` (Kubernetes VLAN gateway) +- **SSH**: `ssh admin@10.0.20.1` +- **Version**: pfSense CE 2.7.2, FreeBSD 14.0 +- **Proxmox VMID**: 101 (8 CPU, 16GB RAM, 32G disk) +- **Web UI**: `https://pfsense.viktorbarzin.me` (via reverse proxy) or `https://10.0.20.1` +- **Installed packages**: FRR (BGP/OSPF), Tailscale, Snort, WireGuard, REST API, FreeRADIUS + +## Interfaces + +| Name | Description | Physical | IP | Network | +|------|-------------|----------|-----|---------| +| wan | WAN | vtnet0 | 192.168.1.2/24 | Physical network | +| lan | Management VMs | vtnet1 | 10.0.10.1/24 | VLAN 10 | +| opt1 | Kubernetes | vtnet2 | 10.0.20.1/24 | VLAN 20 | +| opt2 | WireGuard | tun_wg0 | 10.3.2.1/24 | VPN tunnel | +| tailscale0 | Tailscale | tailscale0 | 100.64.0.x | Headscale mesh | + +## CLI Script + +**Script**: `.claude/pfsense.py` + +### Execution Pattern +```bash +cd ~/code/infra && python3 .claude/pfsense.py [options] +``` + +### Available Commands + +#### System Information +```bash +python3 .claude/pfsense.py status # Full system overview +python3 .claude/pfsense.py uptime # Uptime +python3 .claude/pfsense.py cpu # CPU info and load +python3 .claude/pfsense.py memory # Memory breakdown +python3 .claude/pfsense.py disk # Disk usage +python3 .claude/pfsense.py temp # CPU temperature +python3 .claude/pfsense.py pkg-list # Installed packages +``` + +#### Network & Interfaces +```bash +python3 .claude/pfsense.py interfaces # Interface list with IPs +python3 .claude/pfsense.py gateways # Gateway status +python3 .claude/pfsense.py arp # ARP table +python3 .claude/pfsense.py routes # Routing table +python3 .claude/pfsense.py dns-resolve # DNS lookup via pfSense +python3 .claude/pfsense.py diag # Ping test +``` + +#### Firewall +```bash +python3 .claude/pfsense.py rules # All firewall rules +python3 .claude/pfsense.py rules opt1 # Rules for Kubernetes interface +python3 .claude/pfsense.py nat # NAT / port forwarding rules +python3 .claude/pfsense.py aliases # List all aliases +python3 .claude/pfsense.py alias # Show alias members +python3 .claude/pfsense.py states # State table summary +python3 .claude/pfsense.py states-top 20 # Top 20 IPs by connection count +``` + +#### DHCP +```bash +python3 .claude/pfsense.py dhcp-leases # All DHCP leases +python3 .claude/pfsense.py dhcp-leases opt1 # Kubernetes network leases only +``` + +#### Services +```bash +python3 .claude/pfsense.py services # List all services + status +python3 .claude/pfsense.py service restart snort # Restart a service +python3 .claude/pfsense.py service stop wireguard # Stop a service +python3 .claude/pfsense.py service start wireguard # Start a service +``` + +#### VPN & Routing +```bash +python3 .claude/pfsense.py wireguard # WireGuard tunnel status +python3 .claude/pfsense.py tailscale # Tailscale/Headscale status +python3 .claude/pfsense.py bgp # BGP summary (FRR) +python3 .claude/pfsense.py ospf # OSPF neighbors (FRR) +``` + +#### Security +```bash +python3 .claude/pfsense.py snort # Snort IDS status + recent alerts +python3 .claude/pfsense.py logs # Last 50 firewall log entries +python3 .claude/pfsense.py logs 200 # Last 200 entries +python3 .claude/pfsense.py logs-filter "blocked" # Search logs +``` + +#### Advanced +```bash +python3 .claude/pfsense.py pfctl "-sr" # Raw pfctl command +python3 .claude/pfsense.py php "echo phpversion();" # Run PHP on pfSense +python3 .claude/pfsense.py raw "ls /tmp" # Run arbitrary shell command +python3 .claude/pfsense.py backup # Dump config.xml to stdout +``` + +## Direct SSH Access + +For tasks not covered by the script, SSH directly: +```bash +ssh admin@10.0.20.1 "" +``` + +### Useful Direct Commands +```bash +# pfSense PHP shell (interactive config access) +ssh admin@10.0.20.1 "php -r 'require_once(\"config.inc\"); \$cfg = parse_config(true); echo json_encode(\$cfg[\"nat\"], JSON_PRETTY_PRINT);'" + +# pfSsh.php playback commands +ssh admin@10.0.20.1 "pfSsh.php playback gatewaystatus" +ssh admin@10.0.20.1 "pfSsh.php playback svc restart snort" +ssh admin@10.0.20.1 "pfSsh.php playback listpkg" + +# Config sections via PHP +ssh admin@10.0.20.1 "php -r 'require_once(\"config.inc\"); \$cfg = parse_config(true); print_r(\$cfg[\"filter\"][\"rule\"][0]);'" + +# FRR/vtysh for routing +ssh admin@10.0.20.1 "/usr/local/bin/vtysh -c 'show ip route'" +ssh admin@10.0.20.1 "/usr/local/bin/vtysh -c 'show bgp ipv4 unicast'" +``` + +## REST API (pfSense-pkg-RESTAPI v2.2) + +The REST API package is installed but **no API keys are configured**. To use it: +1. Create an API key in pfSense Web UI: System > REST API > Settings > Keys +2. Use Bearer token auth: `curl -sk https://10.0.20.1/api/v2/status/system -H 'Authorization: Bearer '` + +Until API keys are set up, use SSH for all operations. + +## Key Services + +| Service | Status | Notes | +|---------|--------|-------| +| FRR (BGP/OSPF) | Running | Routing daemon | +| Snort | Running | IDS/IPS | +| WireGuard | Running | VPN tunnel (10.3.2.0/24) | +| Tailscale | Running | Mesh VPN via Headscale | +| FreeRADIUS | Running | RADIUS auth | +| DHCP (Kea) | Running | kea-dhcp4 | +| SSH | Running | Admin access | +| NTP | Running | Time sync | + +## Firewall Stats +- **167 firewall rules** (pfctl -sr) +- **154 NAT rules** (pfctl -sn) +- **~784 active states** (varies) +- **10 aliases** (LAN, OPT1, OPT2, WAN networks + custom) + +## NFS Backup +Config backups stored at NFS: `/mnt/main/pfsense-backup` + +## Troubleshooting + +| Issue | Command | +|-------|---------| +| Can't reach internet from K8s | `python3 .claude/pfsense.py gateways` + `python3 .claude/pfsense.py diag 8.8.8.8` | +| K8s pod can't reach external | `python3 .claude/pfsense.py rules opt1` + check NAT | +| DHCP not working | `python3 .claude/pfsense.py dhcp-leases opt1` + `python3 .claude/pfsense.py service restart kea-dhcp4` | +| High connection count | `python3 .claude/pfsense.py states-top 20` | +| Snort blocking traffic | `python3 .claude/pfsense.py snort` + check alerts | +| DNS resolution failing | `python3 .claude/pfsense.py dns-resolve ` | +| BGP/OSPF routes missing | `python3 .claude/pfsense.py bgp` or `python3 .claude/pfsense.py ospf` | +| WireGuard tunnel down | `python3 .claude/pfsense.py wireguard` | + +## Notes +1. **FreeBSD-based**: Commands differ from Linux (no `ip`, use `ifconfig`, `netstat`, `arp`) +2. **pfctl is the firewall**: Rules loaded from config.xml via PHP, managed by pfctl +3. **Config file**: `/cf/conf/config.xml` — all pfSense config in one XML file +4. **PHP shell**: pfSense uses PHP for all config management; `config.inc` loads the config +5. **Do NOT edit config.xml directly** — use the Web UI or PHP functions that properly reload services +6. **Logs**: Binary circular logs, read with `clog -f /var/log/`