OpenBSD Firewalls

These days, there are quite a few open source solutions for firewalls including pfSense and OPNsense, which provide easy to use web interfaces to configure tools such as the PF Firewall, and Squid web proxy.

However, it’s relatively easy to configure these services directly using OpenBSD without needing a web interface.

OpenBSD is an open-source operating system that focuses on security, code correctness, and simplicity. It has a strong focus on code auditing ensures that the code base is as secure as possible. Every part of the operating system is subjected to scrutiny to avoid vulnerabilities.

As such, it makes a good fit for a firewall system.


Installing OpenBSD

Installing OpenBSD is straightforward, provided your hardware supports it. The official documentation provides detailed installation instructions, although for the most part it just consists of writing the install image to a USB drive, and selecting next on most of the default options. The only selection of note is the packages installed. Installing X11 and games increase the attack surface of the system, so I would recommend not installing them.

PackageDescriptionInstall
bsdThe kernelTrue
bsd.mpThe multi-processor kernel (only on some platforms)True
bsd.rdThe ramdisk kernelTrue
base78.tgzThe base systemTrue
comp78.tgzThe compiler collection, headers and librariesTrue
man78.tgzManual pagesTrue
game78.tgzText-based gamesFalse
xbase78.tgzBase libraries and utilities for X11 (requires xshare78.tgz)False
xfont78.tgzFonts used by X11False
xserv78.tgzX11’s X serversFalse
xshare78.tgzX11’s man pages, locale settings and includesFalse

Networking

Configuring networking is just a matter of modifying hostname files. My firewall has 4 network interfaces, em0 – em3. Create /etc/hostname.<interface> files for each interface, and include IP addressing of your choosing.

puffy# cat /etc/hostname.em0 
inet 192.168.1.105 255.255.255.0 192.168.1.255 description "WAN"
puffy# cat /etc/hostname.em1 
inet 172.16.1.1 255.255.255.0 172.16.1.255 description "LAN"
puffy# cat /etc/hostname.em2 
inet 172.16.2.1 255.255.255.0 172.16.2.255 description "DMZ1"
puffy# cat /etc/hostname.em3 
inet 172.16.3.1 255.255.255.0 172.16.3.255 description "DMZ2"

A default gateway can be configured by entering it into /etc/mygate.

puffy# cat /etc/mygate                                                                                                                                                                                                                                                                                                                         
192.168.1.254

DNS Configuration

With the network interfaces online you can configure a DNS server. Unbound DNS can be be configured to ensure DNS over TLS is used. Modify /var/unbound/etc/unbound.conf to allow access from local subnets, and set a forward-zone to point to Cloudflare’s DNS over TLS servers.

server:
	interface: 127.0.0.1
	interface: 172.16.1.1
	interface: 172.16.2.1
	interface: 172.16.3.1
	interface: ::1

    access-control: 127.0.0.0/8 allow
    access-control: 172.16.1.0/24 allow
    access-control: 172.16.2.0/24 allow
    access-control: 172.16.3.0/24 allow

	access-control: 0.0.0.0/0 refuse
	access-control: 127.0.0.0/8 allow
	access-control: ::0/0 refuse
	access-control: ::1 allow

	hide-identity: yes
	hide-version: yes

	auto-trust-anchor-file: "/var/unbound/db/root.key"
	val-log-level: 2

	aggressive-nsec: yes

remote-control:
	control-enable: yes
	control-interface: /var/run/unbound.sock

forward-zone:
    	name: "."
    	forward-tls-upstream: yes
    	forward-addr: 1.1.1.1@853
    	forward-addr: 1.0.0.1@853

Enable the unbound service, and start it using the rcctl command.

puffy# rcctl enable unbound
puffy# rcctl start unbound

NTP

To configure the Network Time Protocol Daemon, modify /etc/ntpd.conf and set it to listen on all interfaces.

# $OpenBSD: ntpd.conf,v 1.16 2019/11/06 19:04:12 deraadt Exp $
#
# See ntpd.conf(5) and /etc/examples/ntpd.conf

listen on *

# Configure NTP servers to synchronize with
servers pool.ntp.org

Then enable and start the service.

puffy# rcctl enable ntpd                                                                                                                                                                                                                                                                                                                       
puffy# rcctl start ntpd  
ntpd(ok)

The ntpctl command can be used to verify the clock is correctly synchronised.

puffy# ntpctl -s all 
4/4 peers valid, clock synced, stratum 3

peer
   wt tl st  next  poll          offset       delay      jitter
176.58.127.131 from pool pool.ntp.org
 *  1 10  2   18s   33s         0.347ms    14.658ms     8.413ms
176.58.115.34 from pool pool.ntp.org
    1 10  2    5s   31s         0.003ms    15.764ms     5.552ms
193.57.159.118 from pool pool.ntp.org
    1 10  2   15s   30s        -9.474ms    41.747ms    58.618ms
109.74.197.50 from pool pool.ntp.org
    1 10  2    9s   30s        -2.041ms    18.651ms    11.991ms

DHCP Services

Next, we can configure a DHCP server to operate on our internal interfaces by modifying /etc/dhcpd.conf. This will set the the firewall as the local subnets DNS & NTP server.

subnet 172.16.1.0 netmask 255.255.255.0 {
    range 172.16.1.10 172.16.1.100;
    option routers 172.16.1.1;
    option domain-name-servers 172.16.1.1;
}

subnet 172.16.2.0 netmask 255.255.255.0 {
    range 172.16.2.10 172.16.2.100;
    option routers 172.16.2.1;
    option domain-name-servers 172.16.2.1;
}

subnet 172.16.3.0 netmask 255.255.255.0 {
    range 172.16.3.10 172.16.3.100;
    option routers 172.16.3.1;
    option domain-name-servers 172.16.3.1;
}

Modify rc.conf.local to include the interface addresses for the DHCP daemon, and start the service.

puffy# cat /etc/rc.conf.local  
dhcpd_flags="em1 em2 em3"
puffy# rcctl start dhcpd          
dhcpd(ok)

Firewall Configuration

Modify sysctl.conf to enable IP Forwarding so the firewall can pass traffic, and disable IPv6.

puffy# cat /etc/sysctl.conf  
net.inet.ip.forwarding=1
net.inet6.ip6.forwarding=0
net.inet6.ip6.use_tempaddr=0
net.inet6.ip6.auto_linklocal=0

Edit /etc/pf.conf to include your firewall rules. The ruleset syntax should be self evident. For example, the below rule allows tcp traffic on port 80 to 172.16.1.2.

pass in on egress proto tcp from any to 172.16.1.2 port 80
ActionDirectionInterfaceProtocolSourceDestinationPort
passinon egressproto tcpfrom anyto 172.16.1.2port 80

Rules are processed top down. The quick keyword will prevent further rule processing.

wan_if   = "em0"
lan_if   = "em1"  	 
dmz1_if  = "em2"   
dmz2_if  = "em3" 
vpn_if   = "tun"

wan_net  = "192.168.1.0/24"
lan_net  = "172.16.1.0/24"
dmz1_net = "172.16.2.0/24"
dmz2_net = "172.16.3.0/24"

table <internal_nets>  const { $wan_net $lan_net $dmz1_net $dmz2_net }
set block-policy drop
set skip on lo

#Anti Spoofing
block in quick from urpf-failed

#########################################################
#                     FW TRAFFIC                        #
#########################################################
pass in quick on $lan_if proto tcp from $lan_net to ($lan_if) port ssh keep state
pass in quick on {$lan_if $dmz1_if $dmz2_if}  proto { udp tcp } from $lan_net to (self) port {domain ntp} keep state

#########################################################
#                     WAN NAT                           #
#########################################################
match out on $wan_if inet from {$lan_net $dmz1_net $dmz2_net} to any nat-to ($wan_if)
pass out quick on $wan_if from {$lan_net $dmz1_net $dmz2_net} to any keep state

#########################################################
#                     LAN TRAFFIC                       #
#########################################################
pass in quick on $lan_if proto tcp from $lan_net to any port {http https} keep state
pass in quick on $lan_if proto tcp from $lan_net to $wan_net port {ssh https} keep state
pass in quick on $lan_if proto tcp from $lan_net to 172.16.2.200 port {8006} keep state
pass in quick on $lan_if proto tcp from $lan_net to $dmz1_net port {ssh rdp} keep state

#########################################################
#                     DMZ TRAFFIC                       #
#########################################################
pass in quick on $dmz1_if proto tcp from $dmz1_net to !<internal_nets> port {http https} keep state
pass in quick on $dmz2_if from $dmz2_net to !<internal_nets> keep state

# Allow traffic from firewall
pass out quick inet from self keep state

# Default deny
block drop log all

Use pfctl to load, and view the current ruleset.

puffy# pfctl -sr                                                                                                                                                                                                                                                                                                                               
pass in quick on em1 inet proto udp from 172.16.1.0/24 to (self) port = 53
pass in quick on em2 inet proto udp from 172.16.1.0/24 to (self) port = 53
pass in quick on em3 inet proto udp from 172.16.1.0/24 to (self) port = 53
pass in quick on em1 inet proto udp from 172.16.1.0/24 to (self) port = 123
pass in quick on em2 inet proto udp from 172.16.1.0/24 to (self) port = 123
pass in quick on em3 inet proto udp from 172.16.1.0/24 to (self) port = 123
pass in quick on em1 inet proto tcp from 172.16.1.0/24 to (self) port = 53 flags S/SA
pass in quick on em2 inet proto tcp from 172.16.1.0/24 to (self) port = 53 flags S/SA
pass in quick on em3 inet proto tcp from 172.16.1.0/24 to (self) port = 53 flags S/SA
pass in quick on em1 inet proto tcp from 172.16.1.0/24 to (self) port = 123 flags S/SA
pass in quick on em2 inet proto tcp from 172.16.1.0/24 to (self) port = 123 flags S/SA
pass in quick on em3 inet proto tcp from 172.16.1.0/24 to (self) port = 123 flags S/SA
pass in quick on em1 inet proto tcp from 172.16.1.0/24 to (em1) port = 22 flags S/SA
match out on em0 inet from 172.16.1.0/24 to any nat-to (em0) round-robin
match out on em0 inet from 172.16.2.0/24 to any nat-to (em0) round-robin
match out on em0 inet from 172.16.3.0/24 to any nat-to (em0) round-robin
pass in quick on em1 inet proto tcp from 172.16.1.0/24 to 192.168.1.0/24 port = 22 flags S/SA
pass in quick on em1 inet proto tcp from 172.16.1.0/24 to 192.168.1.0/24 port = 443 flags S/SA
pass in quick on em1 inet proto tcp from 172.16.1.0/24 to 172.16.2.0/24 port = 22 flags S/SA
pass in quick on em1 inet proto tcp from 172.16.1.0/24 to 172.16.2.0/24 port = 3389 flags S/SA
pass in quick on em1 inet proto tcp from 172.16.1.0/24 to 172.16.2.200 port = 8006 flags S/SA
pass in quick on em2 inet proto tcp from 172.16.2.0/24 to ! &lt;internal_nets> port = 80 flags S/SA
pass in quick on em2 inet proto tcp from 172.16.2.0/24 to ! &lt;internal_nets> port = 443 flags S/SA
pass in quick on em3 inet from 172.16.3.0/24 to ! &lt;internal_nets> flags S/SA
pass out quick on em0 inet from 172.16.1.0/24 to any flags S/SA
pass out quick on em0 inet from 172.16.2.0/24 to any flags S/SA
pass out quick on em0 inet from 172.16.3.0/24 to any flags S/SA
match out on tun inet from 172.16.1.0/24 to ! &lt;internal_nets> nat-to (tun0) round-robin
pass out on tun inet from 172.16.1.0/24 to ! &lt;internal_nets> flags S/SA
pass in quick on em1 inet from 172.16.1.0/24 to ! &lt;internal_nets> flags S/SA route-to 10.100.0.2
pass out quick inet all flags S/SA
block drop log all

Firewall logs are stored in binary form. To read them, use TCPDump.

tcpdump -n -e -ttt -r /var/log/pflog  | head
tcpdump: WARNING: snaplen raised from 116 to 160
Feb 18 18:23:06.188951 rule 4/(match) block out on em1: 172.16.1.1.22 > 172.16.1.10.43084: P 471680753:471680797(44) ack 3882900921 win 271 &lt;nop,nop,timestamp 4265840327 3060963154> [tos 0xb8]
Feb 18 18:23:06.191893 rule 4/(match) block out on em1: 172.16.1.1.22 > 172.16.1.10.43084: F 44:44(0) ack 1 win 271 &lt;nop,nop,timestamp 4265840327 3060963154> [tos 0xb8]
Feb 18 18:23:06.192534 rule 4/(match) block in on em1: 172.16.1.10.43084 > 172.16.1.1.22: P 1:37(36) ack 0 win 85 &lt;nop,nop,timestamp 3060963173 4265840307> (DF) [tos 0x10]
Feb 18 18:23:06.211997 rule 4/(match) block in on em1: 172.16.1.10.43084 > 172.16.1.1.22: P 37:73(36) ack 0 win 85 &lt;nop,nop,timestamp 3060963193 4265840307> (DF) [tos 0x10]

OpenVPN & Policy Based Routing

Next, we will look at using policy based routing with OpenVPN. This will allow us to direct traffic from certain subnets over a VPN tunnel.

Install the openvpn using pkg_add.

pkg_add openvpn

Policy based routing it not particularly easy to configure. The main issue is the firewall rules will rely on the tun0 VPN interface being online – or they will fail to compile. If they fail to compile, the firewall will never communicate with the outside world, so the VPN won’t connect.

To workaround this issue, we can create separate firewall configurations based on if the VPN is connected or not.

Create an /etc/openvpn/ directory that we will use to store the configuration. Put your OpenVPN configuration in openbsd.ovpn, and add the following lines into the configuration.

route-nopull will prevent the remote server from pushing a default route to our system. The script references are used to load different firewall configurations.

route-nopull
script-security 2
up /etc/openvpn/up.sh
down /etc/openvpn/down.sh
Include the following interface up/down scripts.
puffy# cat up.sh     
#!/bin/sh
if [ "$dev" = "tun0" ]; then
    echo "OpenVPN tunnel is up!"
    pfctl -f /etc/pf_vpn.conf
    /sbin/route add 1.1.1.1 10.100.0.1 # DNS Server 1
    /sbin/route add 1.0.0.1 10.100.0.1 # DNS Server 2
fi
puffy# cat down.sh                                                                                                                                                    
#!/bin/sh                                                                                                                                                    
echo "OpenVPN tunnel is down!"
pfctl -f /etc/pf.conf
/sbin/route del 1.1.1.1 10.100.0.1 # DNS Server 1
/sbin/route del 1.0.0.1 10.100.0.1 # DNS Server 2

Create /etc/pf_vpn.conf. This configuration will only start once the VPN has connected.

Lines 47-49 will ensure that VPN traffic from the LAN is routed and natted via the tun interface. Rules that allowed the LAN direct access to the Internet are commented out.

wan_if   = "em0"
lan_if   = "em1"  	 
dmz1_if  = "em2"   
dmz2_if  = "em3" 
vpn_if   = "tun"

wan_net  = "192.168.1.0/24"
lan_net  = "172.16.1.0/24"
dmz1_net = "172.16.2.0/24"
dmz2_net = "172.16.3.0/24"

table <internal_nets>  const { $wan_net $lan_net $dmz1_net $dmz2_net }
set block-policy drop
set skip on lo

#Anti Spoofing
block in quick from urpf-failed

#########################################################
#                     FW TRAFFIC                        #
#########################################################
pass in quick on $lan_if proto tcp from $lan_net to ($lan_if) port ssh keep state
pass in quick on {$lan_if $dmz1_if $dmz2_if}  proto { udp tcp } from $lan_net to (self) port {domain ntp} keep state

#########################################################
#                     WAN NAT                           #
#########################################################
match out on $wan_if inet from {$lan_net $dmz1_net $dmz2_net} to any nat-to ($wan_if)
pass out quick on $wan_if from {$lan_net $dmz1_net $dmz2_net} to any keep state

#########################################################
#                     LAN TRAFFIC                       #
#########################################################
pass in quick on $lan_if proto tcp from $lan_net to $wan_net port {ssh https} keep state
pass in quick on $lan_if proto tcp from $lan_net to 172.16.2.200 port {8006} keep state
pass in quick on $lan_if proto tcp from $lan_net to $dmz1_net port {ssh rdp} keep state

#########################################################
#                     DMZ TRAFFIC                       #
#########################################################
pass in quick on $dmz1_if proto tcp from $dmz1_net to !<internal_nets> port {http https} keep state
pass in quick on $dmz2_if from $dmz2_net to !<internal_nets> keep state

#########################################################
#                     VPN TRAFFIC                       #
#########################################################
match out on $vpn_if inet from {$lan_net} to !<internal_nets> nat-to (tun0)
pass out on $vpn_if from {$lan_net} to !<internal_nets> keep state
pass in quick on $lan_if from $lan_net to !<internal_nets> route-to $vpn_if keep state

# Allow traffic from firewall
pass out quick inet from self keep state

# Default deny
block drop log all

Create a /etc/hostname.tun0 interface file to allow OpenVPN to run our configuration on boot.

up
!/usr/local/sbin/openvpn --daemon --config /etc/openvpn/openbsd.ovpn --dev tun0

Suricata Configuration

Suricata is an open-source Network IDS (Intrusion Detection System). It is designed to detect and prevent security threats on networks by monitoring network traffic and analysing it for malicious activity.

Add the suricata package using the following command.

pkg_add suricata

Then run suricata-update to download the latest ruleset. Edit /etc/suricata/suricata.yaml to ensure the rulepath matches with the one you just downloaded.

default-rule-path: /var/lib/suricata/rules
rule-files:
  - suricata.rules

Modify rc.conf.local to add the interfaces Suricata will be listening on.

cat /etc/rc.conf.local                                                                                                                                                                                                                                
suricata_flags="-i em1 em2"

Finally, enable the service.

puffy# rcctl enable suricata
puffy# rcctl start suricata

Plaintext alerts are stored in fast.log.

puffy# cat /var/log/suricata/fast.log  | grep Clou     
02/19/2026-11:36:13.658213  [**] [1:2027695:5] ET INFO Observed Cloudflare DNS over HTTPS Domain (cloudflare-dns .com in TLS SNI) [**] [Classification: Misc activity] [Priority: 3] {TCP} 172.16.1.12:55906 -> 172.64.41.3:443

System Maintenance

New versions of OpenBSD are generally released every six months. To update software on the system (patching known security vulnerabilities) the following commands can be used.

CommandPurposeWhat it updates
syspatchApplies binary security patchesBase OS (Kernel & base packages)
pkg_add -uUpgrades installed packagesThird party packages
sysupgradeUpgrades the system to the latest releaseBase OS (Kernel & base packages)
fw_updateApplies firmware updatesFirmware

To further improve the security of the system, you can disable remote administration of the device and instead login to the system using a direct serial connection.

To configure this, set a TTY and it’s baud rate in /etc/ttys.

tty00   "/usr/libexec/getty std.115200"   vt220   on secure

To get console output whilst the system is booting, modify /etc/boot.conf.

puffy# cat  /etc/boot.conf  
stty com0 115200
set tty com0

You should now be able to connect into the system using a serial cable using GNU screen with the required baud rate.

screen /dev/ttyUSB0 115200

To further lower the systems attack surface, unnecessary services can be disabled by using the rcctl disable command.

puffy# rcctl disable slaacd
puffy# rcctl disable smtpd
puffy# rcctl disable sndiod

In Conclusion

OpenBSD provides a solid foundation for a firewall platform. It’s worth noting when you are looking at online documentation that the version of PF shipped with OpenBSD differs from the FreeBSD variant.