By Morgan Davis
17 Feb 2026 • updated 44d
Building a Firewall Script to Solve Hardware Limitations
My home network has run on a FreeBSD server for over 25 years. The hardware has changed — beige boxes to small-form-factor appliances — but the goal hasn't: total control over routing and security.
The combination of 2.5Gbps networking and nearly 60 chatty IoT devices forced a complete rewrite of my firewall logic. The core problem: how do you enforce security when the hardware is working against you?
Act I: Breaking the 480Mbps Ceiling
I had been using FreeBSD's legacy natd for NAT. On my Qotom hardware, natd topped out at roughly 480Mbps — a user-space bottleneck from context switching.
Migrating to In-Kernel NAT via IPFW eliminated that overhead. WAN throughput jumped to a full 1Gbps immediately.
Act II: The IoT Isolation Challenge
With the throughput fixed, I turned to security. I had recently moved my IoT devices from a restricted “Guest” SSID back onto the main SSID so Home Assistant (in a bhyve VM) could discover and manage them properly.
That created a problem: IoT devices now shared a physical segment with the privileged LAN. My Eero Pro 6E mesh runs in bridge mode — it acts as an unmanaged switch, strips VLAN tags, and flattens everything into a single broadcast domain (172.22.0.0/24).
On a flat network, devices use ARP to talk directly. Local traffic never hits the router, so my FreeBSD firewall was bypassed entirely.
Act III: The Subnet Mask Trick
Rather than buy new hardware, I used the TCP/IP stack against itself. If I couldn't isolate devices physically, I'd isolate them logically.
1. Logical Segmentation
I split the /24 into smaller chunks and used the FreeBSD DHCP server to assign masks based on trust level:
-
Privileged devices get a
/24mask (255.255.255.0). They see the entire172.22.0.xrange as local and talk at wire speed. -
IoT devices get a
/26mask (255.255.255.192), limiting their “local” range to.192-.255.
An IoT device with a
/26mask sees172.22.0.5(my printer) as outside its subnet. It has no choice but to send the packet to the default gateway — FreeBSD. IPFW intercepts it there and drops it.
2. Zero-Trust DHCP
I use a “restrictive by default” group in dhcpd.conf, with explicit exceptions for devices that break under a narrow mask:
group {
# DEFAULT: Force Gateway Routing & Public DNS
option subnet-mask 255.255.255.192;
option domain-name-servers 1.1.1.1, 8.8.8.8;
host toshi-iot { hardware ethernet ... } # Strictly Isolated
host office-fire-tv {
# EXCEPTION: Broaden mask for brittle IoT stacks
option subnet-mask 255.255.255.0;
option domain-name-servers ns;
}
}
3. 2.5G Fast Path
My main workstation is on a 2.5Gbps bridge interface. Traffic between it and the 1G LAN crosses the router. I added a fast-path rule at the top of the script to pass privileged-to-privileged traffic before it reaches the heavier NAT and isolation logic.
Building the Firewall
The script is modular. Order matters: environment checks and table builds run first, then the 2.5G fast path, then IoT isolation and NAT.
1. Address Tables
IPFW tables group IPs with no per-packet overhead. The create_tables function builds a PRIV table from the three trusted /26 segments — deliberately excluding the IoT range:
# Build PRIV Table (The "Trusted" list)
# IoT (.192/26) is intentionally excluded -- managed separately via table(IOT)
/sbin/ipfw -q table PRIV add "$segment_priv" # .0/26 Privileged LAN
/sbin/ipfw -q table PRIV add "$segment_vsp" # .64/26 Virtual Servers
/sbin/ipfw -q table PRIV add "$segment_dhcp" # .128/26 DHCP Pool
2. IoT Isolation Rules
First, allow privileged devices to reach IoT (management traffic). Then drop everything else IoT tries to send to the LAN:
add_iot_isolation_rules() {
# IoT Isolation -- Layer 3 enforcement via IPFW stateful rules.
#
# The IoT /26 (.192-.254) is isolated from all trusted LAN segments.
# Permitted: WAN access, DNS (ns), Home Assistant (ha), and optionally Jellyfin.
# Denied: All other access to table(LAN) (the full /24).
#
# Note: IoT-to-IoT traffic is switched at Layer 2 and is not filtered here.
# Allow trusted segments to reach IoT devices (management, push, etc.)
add allow ip from 'table(PRIV)' to 'table(IOT)' keep-state
# Allow IoT-initiated access to specific internal services
add allow udp from 'table(IOT)' to "$ns_ip" dst-port 53 keep-state
add allow tcp from 'table(IOT)' to "$ns_ip" dst-port 53 keep-state
add allow ip from 'table(IOT)' to "$ha_ip" keep-state
add allow ip from 'table(IOTMEDIA)' to "$jellyfin_ip" dst-port "$media_ports" keep-state
# Allow IoT WAN egress before the iron curtain fires
add allow ip from 'table(IOT)' to any out via "$wan_if" keep-state
# Iron curtain: block all remaining IoT traffic to any LAN address
# shellcheck disable=SC2046
add deny $(fwlog) ip from 'table(IOT)' to 'table(LAN)'
}
Limitations
This is a logical barrier, not a physical one.
-
Intra-subnet blind spot: Devices in
.192-.255share a physical wire. They can talk to each other at Layer 2, and IPFW never sees it. One IoT device can still snoop on its neighbor. -
Asymmetric return path: Trusted clients have a
/24mask and ARP directly to IoT devices. The IoT device's response (with its/26mask) routes back through the gateway. IPFWkeep-statehandles this correctly, but keep it in mind when debugging. -
Brittle stacks: Some devices (RoboRock vacuums, Pentair ScreenLogic) reject any mask narrower than
/24. Those get a/24via DHCP exception. They're still subject to the IPFW iron curtain — the mask trick is a convenience layer, not the primary control. -
Broadcast/multicast leakage: mDNS, Spotify Connect, HomeKit discovery — all of it is heard by every bridge member regardless of subnet mask. The
/26only constrains unicast.
The approach is an 80/20 win: strong protection against the most common IoT threat vectors, no hardware overhaul required.
The Full Script
#!/bin/sh
#
# ipfw-rules.sh -- IPFW + in-kernel NAT configuration for lab.space.lan
# Handles firewall logic, NAT, and IoT logical isolation.
#
# Rule Layout:
# 00050-09999 : Base rules (loopback, antispoof, NAT outbound)
# 10000-19999 : Reserved (blocksmith)
# 20000-29999 : Reserved (block_country.sh)
# 30000-39999 : Filter rules (IoT isolation, port forwards, final deny)
#
# Logic Flow:
# 1. Clear existing state and rules.
# 2. Define dynamic NAT redirects and resolve DNS targets.
# 3. Build IPFW tables (LAN, IOT, PRIV, plus data-driven tables).
# 4. Install base/NAT rules and optimized internal routing shortcut.
# 5. Install IoT isolation rules.
# 6. Install WAN filter rules and final deny.
#
# IoT Isolation:
# Restricts the IoT /26 to WAN, Home Assistant, and DNS only.
# Intra-subnet IoT traffic is switched at Layer 2 and cannot be
# filtered here.
#
# Network Map (172.22.0.0/24):
# .0 /26 ( .1 - .63 ) : Privileged LAN
# .64 /26 ( .65 - .126 ) : Virtual Servers
# .128 /26 ( .129 - .190 ) : DHCP Pool
# .192 /26 ( .193 - .254 ) : IoT Isolation Zone
#
# IPFW Named Tables:
# LAN : Entire home network (NAT source, IoT iron curtain target)
# IOT : IoT isolation zone (.192/26)
# PRIV : Trusted segments (Privileged, Virtual Servers, DHCP Pool)
# IOTMEDIA : IoT devices permitted Jellyfin access
# VSP : Trusted WAN source IPs (VPS/cloud hosts)
# ISP : Trusted WAN source IPs (ISP/static peers)
#
# Numbered Tables (managed externally by blocksmith/block_country.sh):
# 0 : Blocked IPs from /var/log/maillog (email spammers)
# 1 : Blocked IPs from /var/log/auth (auth attack sources)
# 2 : Blocked IPs from /var/log/messages (attack sources)
# 3 : Whitelisted country networks (bypass country block)
# 4 : Blocked country networks
#
# Logging Policy:
# fwlog() : Emits 'log' on deny rules only when DEBUG=1.
# Unquoted $(fwlog) is intentional -- zero words when empty.
# Always logged (regardless of DEBUG):
# Cable modem management interface deny (anomaly indicator).
#
# shellcheck disable=SC2154
# Set to 1 to enable per-rule verbose logging and per-packet deny logging.
# In production (0): verbose sysctl off, deny rules install without 'log'.
DEBUG=0
init_config() {
# Configure IPFW kernel verbosity to match DEBUG mode
if [ "$DEBUG" = "1" ]; then
sysctl net.inet.ip.fw.verbose=1
sysctl net.inet.ip.fw.verbose_limit=5
else
sysctl net.inet.ip.fw.verbose=0
sysctl net.inet.ip.fw.verbose_limit=0
fi
conf_dir="/usr/local/etc/ipfw-rules.d"
# Source rc.conf for host-specific variables
[ -f /etc/rc.conf ] && . /etc/rc.conf
# Require essential network variables from rc.conf
for var in home_network wan_if lan_ip; do
eval _val=\$$var
if [ -z "$_val" ]; then
echo "ERROR: $var is not defined in /etc/rc.conf"
exit 1
fi
done
# Derive the four /26 segments from home_network
# home_network must be a /24 (e.g. 172.22.0.0/24)
base_prefix=$(echo "$home_network" | cut -d. -f1-3)
segment_priv="${base_prefix}.0/26" # .1 - .62 : Privileged LAN
segment_vsp="${base_prefix}.64/26" # .65 - .126 : Virtual Servers
segment_dhcp="${base_prefix}.128/26" # .129 - .190 : DHCP Pool
segment_iot="${base_prefix}.192/26" # .193 - .254 : IoT Zone
iot_subnet="$segment_iot"
# Resolve internal service hosts via BIND at rule-load time.
# These IPs are embedded into rules directly, avoiding per-packet DNS.
ha_ip=$(getent hosts ha | awk '{ print $1 }')
ns_ip=$(getent hosts ns | awk '{ print $1 }')
jellyfin_ip=$(getent hosts jellyfin | awk '{ print $1 }')
[ -z "$ha_ip" ] && echo "ERROR: Could not resolve 'ha'" && exit 1
[ -z "$ns_ip" ] && echo "ERROR: Could not resolve 'ns'" && exit 1
[ -z "$jellyfin_ip" ] && echo "ERROR: Could not resolve 'jellyfin'" && exit 1
# Service port definitions
smtp_alt="2525"
wireguard="51820"
home_assistant="8123"
jellyfin="8096"
minidlna="1900,8200"
# Port groups used in filter rules
deny_ports="printer,ssh,imap,rdp,ftp,rsync,$smtp_alt,$home_assistant"
privileged_ports="ssh,imaps"
vm_ports="rsync,$smtp_alt"
media_ports="$jellyfin,$minidlna"
}
# Thin wrapper: suppress rule number output, keep errors visible
ipfw() {
/sbin/ipfw -q "$@"
}
# Emits 'log' only when DEBUG=1; emits nothing in production.
# Always use unquoted: add deny $(fwlog) ...
# The empty expansion is intentional -- zero words, not an empty argument.
# shellcheck disable=SC2317
fwlog() {
[ "$DEBUG" = "1" ] && printf 'log' || printf ''
}
# Preserve the active SSH session across a live firewall reload.
# Installs a temporary high-priority allow rule for the current client IP.
# Safe to leave enabled permanently; has no effect when not connected via SSH.
preserve_ssh() {
[ -n "$SSH_CLIENT" ] || return 0
ssh_ip=$(echo "$SSH_CLIENT" | awk '{print $1}')
ipfw add 222 allow tcp from "$ssh_ip" to me 22 keep-state
}
# Load a table from a data file.
# Supports blank lines, # comments, and multiple entries per line.
load_table_file() {
_table=$1
_file=$2
[ -f "$_file" ] || return 0
sed 's/#.*//' "$_file" | tr -s '[:space:]' '\n' | grep -v '^$' | while read -r entry; do
/sbin/ipfw -q table "$_table" add "$entry"
done
}
# Register a TCP port forward through in-kernel NAT
add_redirect_tcp() {
NAT_REDIRECTS="${NAT_REDIRECTS} redirect_port tcp $1 $2"
TCP_FWD_PORTS="${TCP_FWD_PORTS} $2"
}
# Register a UDP port forward through in-kernel NAT
add_redirect_udp() {
NAT_REDIRECTS="${NAT_REDIRECTS} redirect_port udp $1 $2"
UDP_FWD_PORTS="${UDP_FWD_PORTS} $2"
}
# Add a rule and auto-increment the rule counter by 10
add() {
ipfw add $next_rule "$@"
next_rule=$((next_rule + 10))
}
table() { ipfw table "$@"; }
del() { ipfw delete "$@"; }
init_rules() {
NAT_REDIRECTS=""
TCP_FWD_PORTS=""
UDP_FWD_PORTS=""
base_rule=50
filter_rule=30000
# Flush only our managed rule ranges; blocksmith/block_country ranges untouched
/sbin/ipfw -q delete $((base_rule))-$((base_rule + 9949)) 2>/dev/null
/sbin/ipfw -q delete $((filter_rule))-$((filter_rule + 9999)) 2>/dev/null
# Destroy the existing NAT instance (in-kernel NAT only)
[ "$natd_enable" != "YES" ] && /sbin/ipfw -q nat 1 delete 2>/dev/null
next_rule=$base_rule
}
init_redirects() {
[ "$natd_enable" = "YES" ] && return 0
# Forward inbound HA traffic to the VM
add_redirect_tcp "${ha_ip}:${home_assistant}" "${home_assistant}"
}
create_tables() {
# Discover data-driven tables from the conf directory (*.table files)
_dynamic_tables=""
for table_path in "$conf_dir"/*.table; do
[ -e "$table_path" ] || continue
_file="${table_path##*/}"
_dynamic_tables="$_dynamic_tables ${_file%.table}"
done
# Destroy and recreate all tables atomically (static + dynamic, deduplicated)
for t in $(printf "%s\n" LAN IOT PRIV $_dynamic_tables | sort -u); do
/sbin/ipfw -q table "$t" destroy 2>/dev/null
/sbin/ipfw -q table "$t" create type addr
done
# table(LAN): the entire home network -- used for NAT and IoT iron curtain
/sbin/ipfw -q table LAN add "$home_network"
# table(IOT): the IoT /26 -- subject to isolation rules
/sbin/ipfw -q table IOT add "$iot_subnet"
# table(PRIV): trusted segments -- full internal routing access
# Does not include segment_iot; IoT is managed separately
/sbin/ipfw -q table PRIV add "$segment_priv"
/sbin/ipfw -q table PRIV add "$segment_vsp"
/sbin/ipfw -q table PRIV add "$segment_dhcp"
# Load data-driven table files (blocksmith blocklists, country tables, etc.)
for _id in $_dynamic_tables; do
load_table_file "$_id" "$conf_dir/${_id}.table"
done
}
add_base_rules() {
# natd path (legacy fallback, not used in normal operation)
[ "$natd_enable" = "YES" ] && add divert natd ip4 from any to any via "$wan_if"
# Loopback: permit all, then antispoof
add allow ip from any to any via lo0
add deny ip from any to 127.0.0.0/8
add deny ip from 127.0.0.0/8 to any
# IPv6: allow only what is necessary for neighbor discovery and ICMPv6
add deny ip from any to ::1
add deny ip from ::1 to any
add allow ipv6-icmp from :: to ff02::/16
add allow ipv6-icmp from fe80::/10 to fe80::/10
add allow ipv6-icmp from fe80::/10 to ff02::/16
add allow ipv6-icmp from any to any icmp6types 1
add allow ipv6-icmp from any to any icmp6types 2,135,136
# In-kernel NAT: configure and install outbound translation rule
if [ "$natd_enable" != "YES" ]; then
# same_ports: preserve source ports where possible
# unreg_only: only translate RFC1918 sources (skip public IPs)
# reset: send TCP RST for expired NAT state
# shellcheck disable=SC2086
ipfw nat 1 config if "$wan_if" same_ports unreg_only reset $NAT_REDIRECTS
add nat 1 ip from 'table(LAN)' to any out via "$wan_if"
fi
}
add_internal_routing_rules() {
# Fast path: trusted-to-trusted traffic bypasses all filter rules below.
# Covers Privileged LAN, Virtual Servers, and DHCP Pool -- not IoT.
add allow ip from 'table(PRIV)' to 'table(PRIV)'
}
add_pre_nat_filters() {
# WAN inbound: allow specific ports from trusted source tables
add allow tcp from 'table(VSP)' to any dst-port "$privileged_ports" in via "$wan_if" keep-state
add allow tcp from 'table(ISP)' to any dst-port "$privileged_ports" in via "$wan_if" keep-state
add allow tcp from 'table(VSP)' to any dst-port "$vm_ports" in via "$wan_if" keep-state
add allow tcp from any to any dst-port imaps in via "$wan_if" keep-state
# WAN inbound: deny access to sensitive service ports from all other sources
# shellcheck disable=SC2046
add deny $(fwlog) tcp from any to any dst-port "$deny_ports" in via "$wan_if"
}
add_iot_isolation_rules() {
# IoT Isolation -- Layer 3 enforcement via IPFW stateful rules.
#
# The IoT /26 (.192-.254) is isolated from all trusted LAN segments.
# Permitted: WAN access, DNS (ns), Home Assistant (ha), and optionally Jellyfin.
# Denied: All other access to table(LAN) (the full /24).
#
# Note: IoT-to-IoT traffic is switched at Layer 2 and is not filtered here.
# Allow trusted segments to reach IoT devices (management, push, etc.)
add allow ip from 'table(PRIV)' to 'table(IOT)' keep-state
# Allow IoT-initiated access to specific internal services
add allow udp from 'table(IOT)' to "$ns_ip" dst-port 53 keep-state
add allow tcp from 'table(IOT)' to "$ns_ip" dst-port 53 keep-state
add allow ip from 'table(IOT)' to "$ha_ip" keep-state
add allow ip from 'table(IOTMEDIA)' to "$jellyfin_ip" dst-port "$media_ports" keep-state
# Allow IoT WAN egress before the iron curtain fires
add allow ip from 'table(IOT)' to any out via "$wan_if" keep-state
# Iron curtain: block all remaining IoT traffic to any LAN address
# shellcheck disable=SC2046
add deny $(fwlog) ip from 'table(IOT)' to 'table(LAN)'
}
add_other_pre_nat_filters() {
# WireGuard: allow inbound from any source (peers roam across public IPs)
add allow udp from any to any dst-port $wireguard in via "$wan_if" keep-state
# WireGuard: only trusted internal devices may initiate outbound tunnels
# Prevents VPN-inside-VPN loops and unauthorized tunneling
add allow udp from 'table(PRIV)' to any dst-port $wireguard out via "$wan_if" keep-state
}
add_nat_rules() {
[ "$natd_enable" = "YES" ] && return 0
# Apply inbound NAT (un-translate return traffic from WAN)
add nat 1 ip from any to any in via "$wan_if"
# Resume stateful connections established through NAT
add check-state
# Open ports for active NAT redirects (TCP and UDP)
for port in $TCP_FWD_PORTS; do
add allow tcp from any to any dst-port "$port" in via "$wan_if" keep-state
done
for port in $UDP_FWD_PORTS; do
add allow udp from any to any dst-port "$port" in via "$wan_if" keep-state
done
}
add_post_nat_filters() {
# Block access to the cable modem management interface.
# Always logged: if this fires, something on the LAN is probing the modem.
add deny log ip from any to 192.168.100.1 dst-port 8080
}
add_final_rules() {
# Deny all remaining unmatched inbound WAN traffic
# shellcheck disable=SC2046
add deny $(fwlog) ip from any to any in via "$wan_if"
}
#
# MAIN EXECUTION
#
echo "$0: Installing IPFW + NAT rules..."
init_config
init_rules
preserve_ssh
init_redirects
create_tables
# Base rules: loopback, antispoof, NAT outbound
add_base_rules
# Fast path for trusted-to-trusted traffic (before filter rules)
add_internal_routing_rules
# WAN filter rules (30000 range)
next_rule=$filter_rule
add_pre_nat_filters
add_iot_isolation_rules
# Skip ahead to NAT block, leaving room for blocksmith/block_country insertions
nat_rule=$((filter_rule + 1000))
add skipto $nat_rule ip from any to any in via "$wan_if"
# WireGuard filters (inserted before NAT block, after skipto)
add_other_pre_nat_filters
# NAT inbound, check-state, port forward holes, post-NAT filters, final deny
next_rule=$nat_rule
add_nat_rules
add_post_nat_filters
add_final_rules
echo "$0: Firewall rules installed."
Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.