Setting Up pf with VLANs

Network segmentation is one of the first things to get right. Once it's working, everything else builds on top of it. Once it's broken, debugging why ssh works but rsync doesn't becomes a special kind of misery.

This post covers the VLAN setup and the pf rules that go with it.

The VLAN Layout

Five VLANs, each on a different subnet:

VLAN ID Subnet Purpose
mgmt 1 10.0.1.0/24 Switches, OOB, firewall mgmt
srv 10 10.0.10.0/24 Servers (srv01, srv02)
desk 20 10.0.20.0/24 Desktop and personal devices
game 30 10.0.30.0/24 Game clients and VMs
iot 40 10.0.40.0/24 Untrusted / IoT / Guest

The physical layout: one NIC on fw01 is trunked to the main switch. OpenBSD VLAN interfaces (vlan10, vlan20, etc.) are configured on top of it. Each VLAN interface gets an IP address in its respective subnet and acts as the default gateway for devices in that VLAN.

Configuring VLAN Interfaces

In /etc/hostname.em1 (the trunked NIC):

up

Then individual VLAN interface files, e.g. /etc/hostname.vlan10:

vlandev em1 vlanid 10
inet 10.0.10.1 255.255.255.0
up

Repeat for each VLAN. After a reboot (or sh /etc/netstart), ifconfig should show all the VLAN interfaces up with their addresses.

The pf Configuration

This is a simplified version of the actual pf.conf. The real one has more rules, but this captures the structure.

# /etc/pf.conf

# --- Interfaces ---
ext_if = "em0"         # WAN
vlan_mgmt = "vlan1"
vlan_srv  = "vlan10"
vlan_desk = "vlan20"
vlan_game = "vlan30"
vlan_iot  = "vlan40"

# --- Tables ---
table <martians> const { 0.0.0.0/8, 10.0.0.0/8, 127.0.0.0/8, \
  172.16.0.0/12, 192.168.0.0/16, 224.0.0.0/4, 240.0.0.0/4 }

# --- Options ---
set block-policy drop
set loginterface $ext_if
set skip on lo

# --- Normalization ---
match in all scrub (no-df random-id max-mss 1440)

# --- Default deny ---
block all

# --- Antispoofing ---
antispoof for $ext_if inet

# --- Block martians on WAN ---
block in quick on $ext_if from <martians> to any
block out quick on $ext_if from any to <martians>

# --- Inbound: allow public traffic to services ---
pass in on $ext_if proto tcp to port 80  keep state
pass in on $ext_if proto tcp to port 443 keep state
pass in on $ext_if proto tcp to port 22  keep state
pass in on $ext_if proto udp to port 51820 keep state  # WireGuard

# --- Allow all outbound from firewall ---
pass out on $ext_if all keep state

# --- Management VLAN: full access ---
pass in on $vlan_mgmt all keep state
pass out on $vlan_mgmt all keep state

# --- Server VLAN: allow inter-VLAN from desk to srv ---
pass in on $vlan_srv all keep state
pass in on $vlan_desk to $vlan_srv keep state
# Block srv -> desk (servers shouldn't initiate to desktop)
block in on $vlan_srv to 10.0.20.0/24

# --- Desktop VLAN: full internet, access to servers ---
pass in on $vlan_desk all keep state
pass out on $vlan_desk all keep state

# --- Game VLAN: internet only, no access to other VLANs ---
pass in on $vlan_game proto { tcp udp } to port { 80 443 } keep state
pass in on $vlan_game proto udp keep state  # game protocols
block in on $vlan_game to 10.0.0.0/8        # no access to RFC1918

# --- IoT VLAN: internet only, fully isolated ---
pass in on $vlan_iot proto tcp to port { 80 443 } keep state
pass in on $vlan_iot proto udp to port 53 keep state  # DNS
block in on $vlan_iot to 10.0.0.0/8

The Key Design Decisions

Default deny with explicit allows is the only sane approach. Start with block all and add passes for what you actually need. Never the other way around.

IoT is fully isolated. Smart home devices get internet access and nothing else. They cannot reach any other VLAN. If one of them is compromised, the blast radius is just "attacker can make API calls from your IP." Annoying, not catastrophic.

Game VLAN blocks RFC1918. Game clients and VMs get internet but cannot reach any private address space. This isolates them from everything internal.

Servers can't initiate to desktop. A compromised service on srv01 shouldn't be able to reach my desktop. The server VMs serve; they don't call home.

relayd for Reverse Proxying

External traffic hits fw01 on port 80/443. relayd(8) forwards it to srv01. The pf rules above allow the initial connection in; relayd handles the rest.

Minimal /etc/relayd.conf:

# TLS termination and forwarding
http protocol "https" {
    tls { keypair ridgwaysystems.org }
    pass request header "X-Forwarded-For" value "$REMOTE_ADDR"
    pass
}

relay "web" {
    listen on egress port 443 tls
    protocol "https"
    forward to 10.0.10.10 port 8080 check http "/" code 200
}

Let's Encrypt certificates via acme-client(1) handle the TLS side. A daily cron job runs acme-client and sends SIGHUP to relayd when certs are renewed.

Debugging

When rules aren't working as expected, pfctl -ss shows current state table entries. tcpdump -i pflog0 shows what pf is logging. pfctl -sr shows the active ruleset.

The most common mistake: forgetting that block rules need quick to stop rule evaluation immediately, while without quick pf continues evaluating and the last matching rule wins. Learn this early.

What's Next

With the network segmented, the next step is getting services deployed on srv01 — starting with httpd and this website.