#!/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()