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.
| Package | Description | Install |
| bsd | The kernel | True |
| bsd.mp | The multi-processor kernel (only on some platforms) | True |
| bsd.rd | The ramdisk kernel | True |
| base78.tgz | The base system | True |
| comp78.tgz | The compiler collection, headers and libraries | True |
| man78.tgz | Manual pages | True |
| game78.tgz | Text-based games | False |
| xbase78.tgz | Base libraries and utilities for X11 (requires xshare78.tgz) | False |
| xfont78.tgz | Fonts used by X11 | False |
| xserv78.tgz | X11’s X servers | False |
| xshare78.tgz | X11’s man pages, locale settings and includes | False |
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
| Action | Direction | Interface | Protocol | Source | Destination | Port |
|---|---|---|---|---|---|---|
| pass | in | on egress | proto tcp | from any | to 172.16.1.2 | port 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 ! <internal_nets> port = 80 flags S/SA
pass in quick on em2 inet proto tcp from 172.16.2.0/24 to ! <internal_nets> port = 443 flags S/SA
pass in quick on em3 inet from 172.16.3.0/24 to ! <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 ! <internal_nets> nat-to (tun0) round-robin
pass out on tun inet from 172.16.1.0/24 to ! <internal_nets> flags S/SA
pass in quick on em1 inet from 172.16.1.0/24 to ! <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 <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 <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 <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 <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.
| Command | Purpose | What it updates |
|---|---|---|
| syspatch | Applies binary security patches | Base OS (Kernel & base packages) |
| pkg_add -u | Upgrades installed packages | Third party packages |
| sysupgrade | Upgrades the system to the latest release | Base OS (Kernel & base packages) |
| fw_update | Applies firmware updates | Firmware |
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.