| name | pfsense-nat-rule-creation |
| description | Create NAT port forward rules on pfSense programmatically via PHP/SSH.
Use when: (1) adding port forwards for new K8s services, (2) NAT rules
added via PHP don't appear in pfctl output, (3) config_read_array() throws
"undefined function" error, (4) destination "wanip" not working in NAT rules,
(5) rules saved to config.xml but not loaded into pfctl. Covers the correct
PHP array structure, config API differences between pfSense versions, and
the required pfctl reload step.
|
| author | Claude Code |
| version | 1.0.0 |
| date | "2026-02-21T00:00:00.000Z" |
pfSense NAT Rule Creation via PHP
Problem
Creating NAT port forward rules on pfSense programmatically via SSH/PHP has
multiple gotchas around the config API, rule structure, and rule loading.
Context / Trigger Conditions
- Adding a port forward for a new Kubernetes service (e.g., TURN, game server)
- Using
ssh admin@10.0.20.1 + PHP to automate pfSense config
- NAT rules don't appear in
pfctl -sn after write_config() + filter_configure()
config_read_array() throws "Call to undefined function"
- Rules saved to config.xml but pfctl doesn't have them
Solution
Correct PHP for adding NAT rules
<?php
require_once("config.inc");
require_once("filter.inc");
global $config;
$config["nat"]["rule"][] = array(
"interface" => "wan",
"ipprotocol" => "inet",
"protocol" => "tcp/udp",
"source" => array("any" => ""),
"destination" => array(
"network" => "wanip",
"port" => "3478"
),
"target" => "10.0.20.200",
"local-port" => "3478",
"descr" => "My port forward",
"associated-rule-id" => "pass"
);
write_config("Description for config history");
filter_configure();
Key gotchas
-
config_read_array() doesn't exist in pfSense 2.7.x. Use global $config instead.
-
Destination format: Use "network" => "wanip", NOT "address" => "wanip" or "address" => "192.168.1.2". The "network" key with "wanip" tells pfSense to resolve the WAN IP dynamically.
-
ipprotocol is required: Must include "ipprotocol" => "inet" or rules won't generate in /tmp/rules.debug.
-
Port ranges: Use "port" => "49152:49252" for ranges. The "local-port" should be just the start port — pfSense maps the range automatically.
-
Rules may not load immediately: After write_config() + filter_configure(), rules appear in /tmp/rules.debug but may not be in pfctl until the next filter reload. Force with:
pfctl -f /tmp/rules.debug
-
SSH quoting: The pfsense.py php command breaks on \n in strings. For multi-line PHP, write a .php file, scp it, and execute:
scp script.php admin@10.0.20.1:/tmp/
ssh admin@10.0.20.1 "php /tmp/script.php"
Execution via pfsense.py
For simple single-line PHP (no newlines or backslashes):
python3 .claude/pfsense.py php 'require_once("config.inc"); ...; echo "Done";'
For complex scripts, use scp + ssh as above.
Verification
ssh admin@10.0.20.1 "grep 'YOUR_PORT' /cf/conf/config.xml"
ssh admin@10.0.20.1 "grep 'YOUR_PORT' /tmp/rules.debug"
python3 .claude/pfsense.py pfctl "-sn" | grep YOUR_PORT
Notes
- Existing working NAT rules on this pfSense use the same structure (check WireGuard port 51820 as reference)
- The
associated-rule-id: pass auto-creates a WAN firewall rule to allow the forwarded traffic
- pfSense applies NAT rules across ALL interfaces when using the web UI, but PHP-created rules only apply to the specified interface
- See also:
pfsense skill for general pfSense management