RSS Amplifier

Morgan Davis · Feb 18, 2026

Scripting IPFW IoT Isolation on a Flat Network

0
Sign in to vote or save

Morgan Davis · Morgan Davis

By Morgan Davis

17 Feb 2026 • updated 44d

Man holding Guy Fawkes hacker mask, standing in front of server rack

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 /24 mask (255.255.255.0). They see the entire 172.22.0.x range as local and talk at wire speed.

  • IoT devices get a /26 mask (255.255.255.192), limiting their “local” range to .192-.255.

An IoT device with a /26 mask sees 172.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.

  1. Intra-subnet blind spot: Devices in .192-.255 share 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.

  2. Asymmetric return path: Trusted clients have a /24 mask and ARP directly to IoT devices. The IoT device's response (with its /26 mask) routes back through the gateway. IPFW keep-state handles this correctly, but keep it in mind when debugging.

  3. Brittle stacks: Some devices (RoboRock vacuums, Pentair ScreenLogic) reject any mask narrower than /24. Those get a /24 via DHCP exception. They're still subject to the IPFW iron curtain — the mask trick is a convenience layer, not the primary control.

  4. Broadcast/multicast leakage: mDNS, Spotify Connect, HomeKit discovery — all of it is heard by every bridge member regardless of subnet mask. The /26 only constrains unicast.

The approach is an 80/20 win: strong protection against the most common IoT threat vectors, no hardware overhaul required.

Crappy AI generated slop image of impossible switch and bad FreeBSD mascot

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."
Explore

Read the original on morgandavis.net

Comments

Nothing yet. Say the first thing.

    Sign in to join the conversation.