OpenBSD as Firewall
This is and old blogpost but might be useful to someone.
When I started with OpenBSD I surfed the web trying to find some help with the configuration for my fairly complex network. That was not easy. I believe that my pf.conf before founding out about the concept of interface groups was about 1000–1500 lines. Then I discovered interface groups. We will come to that later.
I have tried many firewalls, VyOS, pfSense, OPNsense, Linux with Shorewall, FreeBSD with PF, but I always return to OpenBSD. You have all that you need in the base system. NSD, Unbound, PF, Httpd, mrouted, and if you miss something you download it. Nowadays OpenBSD is so simple. They publish updates of packages, they have syspatch for system upgrades, and sysupgrade for upgrades between releases, and they never fail. Not once have an upgrade failed me. These guys know what they do.
I have now used OpenBSD as firewall many years and will try to post parts of my config in hope that it will be helpful to someone. So my pf.conf consist of many blocks. I will illustrate down below some of them:
ports
### ports
server_tcp_ports=" { 22, 43, 80, 443, 873, 8888, 9418, 11371 } "
media_tcp_ports=" { 22, 80, 443, 587, 993, 5222:5223, 5228, 8000, 8080, 8588, 11371 } "
essential_tcp_ports=" { 80, 443 } "
icmp= " { echoreq, unreach } "
clients
1="192.168.0.1"
2="192.168.0.3"
3="192.168.4.1"
tables
table persist counterstable persist file "/etc/pf-badhost.txt"
table <blocked_dns_servers> persist file "/etc/blocked_dns_servers"
scrub
match in all scrub (no-df random-id max-mss 1440) match out all scrub (random-id)
anchor ftp
anchor "ftp-proxy/*"
nat
match out on egress inet from !$xbox to any nat-to (egress:0) port 1024:65535 set prio (5, 6 ) label "nat for for internal net"
match out on egress inet from $xbox to any nat-to (egress:0) static-port set prio (5, 6) label "nat for xbox"
filtration
block in quick log from no-route to any label "block in from NO-ROUTE"
block in quick log from urpf-failed to any label "block in from URPF-FAILED"
pass out all
pass out inet proto { tcp, udp, icmp } all modulate state set prio (5, 6) label "pass out all"
dnat
pass in quick log on egress inet proto udp from to egress port 51820 keep state set prio (3, 5) label "DNAT_WIREGUARD_MGMT"
pass in quick log on egress inet proto udp from to egress port 51821 keep state set prio (2, 4) label "DNAT_WIREGUARD_WORK"
floating rules
pass quick on mgmt inet from <trusted_nets> to modulate state label "pass all from TRUSTED to lan"
pass quick on mdns inet from { <sonos_speakers>, <sonos_controllers> } to 239.255.255.250
pass quick on mdns inet proto igmp from { <sonos_speakers>, <sonos_controllers> } allow-opts label "allow sonos discosvery"
So I have ports, clients, tables, network scrub, filtration, nat and so on at the top, then rules for my internal network and at last rules for internet access. To have all these rules in one single file that you can read and understand is great. Have you ever tried to create this complex network filtration in VyOS or by hand with IPTables? Good luck with that; or for that matter in pfSense or OPNsense? They both have the concept of interface groups but doing all by hand in the terminal is much faster than doing all this in a webinterface. The strain on my firewall and its temperature is much lower when I got rid of the whole web-interface and php, and run only OpenBSD.
I love the concept of interface groups, it is a really nice feature of Packet Filter (PF). That means that in the configuration of the interface, for example in /etc/hostname.em0, you put the groups that the interface shall belong to. From the man:
Network interfaces may be collected together into interface groups. An interface group is a container that can be used generically when referring to any interface related by some criteria. When an action is performed on an interface group, such as packet filtering by thepf(4) subsystem, the operation will be applied to each member interface in the group, if supported by the subsystem. The ifconfig(8)utility can be used to view and assign membership of an interface to an interface group with the group modifier. netintro(4) - OpenBSD manual pages
I have the following interfaces on my firewall: em0-em5, and tied to these interfaces are also five vlans and two WireGuard interfaces, all in all thirteen subnets. Let us look at some examples, my /etc/hostname.em0 looks like this:
inet 192.168.0.1 0xfffffff0 NONE description “MGMT”
group mgmt
group services
group syslog
group ftpproxy
group mdns
group smb
So I have a few groups that I use on my internal network block. You can also put and remove groups via ifconfig,
doas ifconfig vlan3 group smtp
To add the group smtp, or,
doas ifconfig vlan3 -group smtp
To remove the group smtp from the vlan3 interface. If you wan’t these groups to be persistent through a reboot you also have to put them in hostname.vlan3.
Let us view that part of the firewall that contains some of these groups. The group is defined as an interface in your pf.conf. You see in the first rule down below: services is an interface group that give access to my Unbound DNS server and NTP server on my OpenBSD firewall. All my subnets except my IoT network have access to my DNS and NTP server through this group. So instead of writing an access rule on every subnet you put them in a group, it is beautiful and simple. By using these interface groups, you can reduce your firewall rules considerably.
dns rules
pass in quick on services inet proto { tcp, udp } from ! $ns1 to port { 53, 853 } rdr-to $ns1 port 53 modulate state label "RDR all DNS"
pass in quick on services inet proto udp from ! $ns1 to port 123 rdr-to $ntp keep state label "internal NTP"
Here we are trying to catch all DNS and NTP traffic, we want it in house. In the top rules we also have block of known DoH-servers. Let us continue.
ftp proxy rules
pass in quick on ftpproxy inet proto tcp to port ftp divert-to 127.0.0.1 port 8021 label "FTPPROXY"
smtp tcp ports
pass in quick on smtp inet proto tcp to $mail port 25 modulate state set prio 2 label "internal SMTP"
syslog tcp/udp ports
pass in quick on syslog inet proto { tcp, udp } to $logserver port 514 modulate state set prio 3 label "internal SYSLOG"
nfs tcp ports
pass in quick on nfs inet proto tcp from <media_servers> to $nas port $nfs_tcp_ports modulate state set prio 6 label "internal NFS"
smb
pass in quick on smb inet proto tcp to $nas port 445 modulate state set prio 6 label "Internal SMB"
snmp udp ports
pass in quick on snmp inet proto udp from <snmp_clients> to port 161 keep state set prio 2 label "internal SNMP"
pass in quick on snmp inet proto udp from <snmp_clients> to <ipmi_clients> port 623 keep state set prio 2 label "internal IPMI"
dell
pass in quick on wg1 inet proto tcp from $dell to { $librenms, $jellyfin, $subsonic } port 443 modulate state label "allow WGWORK Dell to view webbservers"
pass in quick on wg1 inet proto tcp from $dell to $fw port 22 modulate state label "allow WGWORK Dell to ssh to fw"### block everything else on internal NET
block return in log quick from any to self
block return in log quick from any to <blocked>
So every rule above have interface groups to filter my internal network in an easy way. If you look at the last rule where we have a quick block return to the firewall itself and to the internal network . This is what OpenBSD calls a table, that table looks like this at the top op pf.conf:
table <blocked> { 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 }
These network blocks comprise all my subnets; I am very fond of tables and use it extensively. After this comes the internet rules. Let us look at a few.
mgmt
pass in quick on mgmt inet proto tcp from <trusted_nets> to ! <blocked> modulate state set prio 7 label "allow tcp traffic from MGMT"
pass in quick on mgmt inet proto udp from $1 to ! <blocked> port 1024:65535 keep state set prio 7 label "allow Geforce Now"
So every interface that has the group mgmt has full access to both the internal network and internet. I does not allow udp, I want all such traffic to be in house except for one client.
server
pass in quick on server inet proto tcp to ! <blocked> port $server_tcp_ports modulate state (if-bound) set prio 3 label "allow SERVER tcp traffic"
block return in log quick on server from any to any label "REJECT all else on SERVER"
iot
pass in quick on iot inet proto udp to ! <blocked> port { 53, 123 } keep state (if-bound) set prio 2 label "allow DNS and NTP on IOT"
pass in quick on iot inet proto tcp to ! <blocked> port $essential_tcp_ports modulate state (if-bound) set prio 2 label "allow IOT tcp traffic"
block return in log quick on iot from any to any label "REJECT all else on IOT"
The other networks has more restrictions, you see my server network has access to only the tcp ports it has need of and everything else is blocked.
If you look at my iot network, it has access to udp ports 53 and 123 (DNS and NTP) and two tcp ports, 80 and 443. Everything else is blocked and it has a low priority.
The rule to ! port http means that there is a pass to port 80 to every conceivable subnet except 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16. That is all private addresses that exist, so in short, it allows access to any web server on the internet.
PF is really versatile and this is one way to protect a rather large and complex home network. Do not forget to read the PF FAQ at OpenBSD: OpenBSD PF: User’s Guide