[ci skip] Add pfSense firewall management skill

This commit is contained in:
Viktor Barzin 2026-02-14 12:42:10 +00:00
parent ca43b97fa0
commit c473663b98
2 changed files with 626 additions and 0 deletions

432
.claude/pfsense.py Normal file
View file

@ -0,0 +1,432 @@
#!/usr/bin/env python3
"""pfSense CLI tool for managing the firewall via SSH.
Usage:
python pfsense.py <command> [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 <name> 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 <action> <name> Start/stop/restart a service
logs [n] Show last N log lines (default 50)
logs-filter <text> Search logs for text
pfctl <args> Run arbitrary pfctl command
php <code> Run PHP code on pfSense shell
diag <host> 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 <host> 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 <command> 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()

View file

@ -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 <command> [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 <host> # DNS lookup via pfSense
python3 .claude/pfsense.py diag <host> # 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 <name> # 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 "<command>"
```
### 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 <key>'`
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 <host>` |
| 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/<logfile>`