174 lines
5.6 KiB
Markdown
174 lines
5.6 KiB
Markdown
---
|
|
title: "Setting Up pf with VLANs"
|
|
date: 2026-03-11
|
|
tags: [pf, networking, openbsd]
|
|
slug: pf-vlans
|
|
description: "Configuring OpenBSD pf.conf with VLAN segmentation — separating servers, desktop, IoT, and game traffic with sensible firewall rules."
|
|
draft: false
|
|
---
|
|
|
|
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.
|