Dynamic 1:1 NAT for pfSense
Automatically configures 1:1 NAT based on DHCP-assigned WAN subnet. Maps the LAN subnet to the external WAN /24 network.
Installation
scp dynamic_1to1_nat.php root@<pfsense>:/usr/local/bin/
scp dynamic_1to1_nat.sh root@<pfsense>:/usr/local/etc/rc.d/
ssh root@<pfsense> "chmod +x /usr/local/bin/dynamic_1to1_nat.php /usr/local/etc/rc.d/dynamic_1to1_nat.sh"
Configuration
Edit constants in dynamic_1to1_nat.php:
define('WAN_INTERFACE', 'wan'); // WAN interface name
define('MAX_WAIT_SECS', 60); // DHCP wait timeout
Logs
tail -f /var/log/dynamic_1to1_nat.log
Uninstallation
rm /usr/local/bin/dynamic_1to1_nat.php
rm /usr/local/etc/rc.d/dynamic_1to1_nat.sh
rm /var/log/dynamic_1to1_nat.log
Remove the 1:1 NAT rule from Firewall > NAT > 1:1.
Files
dynamic_1to1_nat.php
#!/usr/local/bin/php-cgi -q
<?php
/*
* dynamic_1to1_nat.php
*
* Automatically configures 1:1 NAT based on DHCP-assigned WAN subnet.
* Maps LAN subnet to external WAN /24 network.
*
* Deploy to: /usr/local/bin/dynamic_1to1_nat.php
* Run via rc.d script at boot
*/
require_once("config.inc");
require_once("util.inc");
require_once("filter.inc");
require_once("interfaces.inc");
define('WAN_INTERFACE', 'wan');
define('NAT_DESCRIPTION', 'Auto 1:1 NAT');
define('LOCK_FILE', '/tmp/dynamic_1to1_nat.lock');
define('LOG_FILE', '/var/log/dynamic_1to1_nat.log');
define('MAX_WAIT_SECS', 60);
function log_msg($msg) {
$timestamp = date('Y-m-d H:i:s');
$line = "[{$timestamp}] {$msg}\n";
file_put_contents(LOG_FILE, $line, FILE_APPEND);
echo $line;
}
function get_wan_ip() {
$wan_if = get_real_interface(WAN_INTERFACE);
if (empty($wan_if)) {
return null;
}
return find_interface_ip($wan_if);
}
function get_wan_network($ip) {
if (empty($ip)) {
return null;
}
$octets = explode('.', $ip);
if (count($octets) !== 4) {
return null;
}
return "{$octets[0]}.{$octets[1]}.{$octets[2]}.0";
}
function find_existing_rule_index() {
global $config;
if (!isset($config['nat']['onetoone']) || !is_array($config['nat']['onetoone'])) {
return -1;
}
foreach ($config['nat']['onetoone'] as $idx => $rule) {
if (isset($rule['descr']) && $rule['descr'] === NAT_DESCRIPTION) {
return $idx;
}
}
return -1;
}
function create_nat_rule($external_network) {
return array(
'interface' => WAN_INTERFACE,
'ipprotocol' => 'inet',
'external' => $external_network,
'source' => array(
'network' => 'lan'
),
'destination' => array(
'any' => ''
),
'descr' => NAT_DESCRIPTION,
'natreflection' => 'disable'
);
}
function update_nat_config($external_network) {
global $config;
if (!isset($config['nat'])) {
$config['nat'] = array();
}
if (!isset($config['nat']['onetoone'])) {
$config['nat']['onetoone'] = array();
}
$existing_idx = find_existing_rule_index();
$new_rule = create_nat_rule($external_network);
if ($existing_idx >= 0) {
$old_external = $config['nat']['onetoone'][$existing_idx]['external'];
if ($old_external === $external_network) {
log_msg("Rule already exists with correct external network {$external_network}");
return false;
}
log_msg("Updating existing rule: {$old_external} -> {$external_network}");
$config['nat']['onetoone'][$existing_idx] = $new_rule;
} else {
log_msg("Creating new 1:1 NAT rule for {$external_network}");
array_unshift($config['nat']['onetoone'], $new_rule);
}
return true;
}
function acquire_lock() {
$fp = fopen(LOCK_FILE, 'c');
if (!$fp) {
return false;
}
if (!flock($fp, LOCK_EX | LOCK_NB)) {
fclose($fp);
return false;
}
return $fp;
}
function release_lock($fp) {
if ($fp) {
flock($fp, LOCK_UN);
fclose($fp);
@unlink(LOCK_FILE);
}
}
function main() {
global $config;
log_msg("=== Starting dynamic 1:1 NAT configuration ===");
$lock = acquire_lock();
if (!$lock) {
log_msg("Another instance is running, exiting");
return 1;
}
log_msg("Waiting for WAN IP assignment (max " . MAX_WAIT_SECS . "s)...");
$wan_ip = null;
for ($i = 0; $i < MAX_WAIT_SECS; $i++) {
$wan_ip = get_wan_ip();
if (!empty($wan_ip)) {
break;
}
sleep(1);
}
if (empty($wan_ip)) {
log_msg("ERROR: Failed to get WAN IP after " . MAX_WAIT_SECS . " seconds");
release_lock($lock);
return 1;
}
log_msg("WAN IP: {$wan_ip}");
$external_network = get_wan_network($wan_ip);
if (empty($external_network)) {
log_msg("ERROR: Could not determine external network from IP {$wan_ip}");
release_lock($lock);
return 1;
}
log_msg("External network: {$external_network}/24");
$changed = update_nat_config($external_network);
if (!isset($config['nat']['outbound']['mode']) || $config['nat']['outbound']['mode'] !== 'hybrid') {
log_msg("Setting outbound NAT mode to hybrid");
$config['nat']['outbound']['mode'] = 'hybrid';
$changed = true;
}
if ($changed) {
log_msg("Writing configuration...");
write_config("Dynamic 1:1 NAT: LAN <-> " . $external_network . "/24");
log_msg("Reloading filter rules...");
filter_configure();
log_msg("Configuration applied successfully");
} else {
log_msg("No changes needed");
}
release_lock($lock);
log_msg("=== Completed ===");
return 0;
}
exit(main());
?>
dynamic_1to1_nat.sh
#!/bin/sh
#
# dynamic_1to1_nat.sh
#
# Wrapper script to trigger dynamic 1:1 NAT configuration
# Can be called from rc.d at boot or from rc.newwanip on DHCP renewal
#
# Deploy to: /usr/local/etc/rc.d/dynamic_1to1_nat.sh
# Make executable: chmod +x /usr/local/etc/rc.d/dynamic_1to1_nat.sh
#
SCRIPT="/usr/local/bin/dynamic_1to1_nat.php"
LOGFILE="/var/log/dynamic_1to1_nat.log"
log_event() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] [wrapper] $1" >> "$LOGFILE"
}
case "$1" in
start|"")
log_event "Triggered with arg: ${1:-none}"
if [ -x "$SCRIPT" ]; then
/usr/local/bin/php-cgi -q "$SCRIPT" 2>&1
# Ensure WAN firewall rule allows inbound to NAT'd network
easyrule pass wan any any 192.168.220.0/24 any >/dev/null 2>&1 || true
else
log_event "ERROR: Script not found or not executable: $SCRIPT"
exit 1
fi
;;
stop)
log_event "Stop called - nothing to do"
;;
restart)
log_event "Restart called - running script"
if [ -x "$SCRIPT" ]; then
/usr/local/bin/php-cgi -q "$SCRIPT" 2>&1
fi
;;
*)
echo "Usage: $0 {start|stop|restart}"
exit 1
;;
esac
exit 0
Read other posts