Setup a secure, OpenBSD router box for your local network.
[ISP Modem]
|
|
(WAN Ethernet cable)
|
|
[OpenBSD Router]
/ \
/ \
(LAN Ethernet) (LAN Ethernet)
/ \
[OpenWrt AP] [Pi-hole]
You can find the same machine I am currently using as my OpenBSD router here: AliExpress. I have the 4GB RAM variation, but I am unsure if that model is still available. Almost any device with multiple network ports should work fine though!
I’m not going to walk through the entire install process for setting up OpenBSD. The built-in installer is actually quite good and going with the defaults on most configuration options is a safe bet. Make sure your device’s main/first ethernet port is connected to your ISP’s modem. This will make autoconfiguring your network much easier in the next steps.
All the programs we will be using are built into the base OpenBSD install except for miniupnpd. You will need to install that separately:
doas pkg_add miniupnpd
For reference, here are the neofetch stats of my own router to compare against your own hardware:
OS: OpenBSD 7.8 amd64
Host: INTEL J1900
Packages: 63 (pkg_info)
CPU: Intel Celeron N2840 (2) @ 2.159GHz
Memory: 46MiB / 3948MiB
With the router setup and running, test that your internet connection is working by running the following in the terminal:
ping openbsd.org
You should see something similar to:
PING openbsd.org (199.185.178.80) 56(84) bytes of data.
64 bytes from 199.185.178.80: icmp_seq=1 ttl=240 time=101 ms
64 bytes from 199.185.178.80: icmp_seq=2 ttl=240 time=123 ms
64 bytes from 199.185.178.80: icmp_seq=3 ttl=240 time=64.4 ms
...
Nice work!
Now our very first step is creating and building out our pf.conf file. Using your editor of choice (I recommend vim, which you can install via doas pkg_add vim) make a new file located at /etc/pf.conf.
The contents of the /etc/pf.conf config file:
ext_if = "igc0" # Internet modem (incoming)
int_if = "igc1" # OpenWrt DLink
int2_if = "igc2" # Pi-hole
set skip on lo
anchor "miniupnpd"
# Block everything by default
block all
# NAT rules
match out on $ext_if from 192.168.1.0/24 to any nat-to ($ext_if)
match out on $ext_if from 192.168.2.0/24 to any nat-to ($ext_if)
# Normalize incoming packets
match in on $ext_if scrub (no-df random-id max-mss 1440)
# Protect against spoofing
antispoof quick for { lo $int_if $int2_if }
# Allow LAN clients to initiate connections to router
pass in on $int_if
pass in on $int2_if
pass out on $int_if from any to 192.168.1.0/24
pass out on $int2_if from any to 192.168.2.0/24
# Allow all outbound traffic (router + LAN)
pass out on $ext_if keep state
That’s the entire router config. Beautifully simple, right?
igc0, etc) might differ on your machine. Be sure to double check the naming of your network ports by running ifconfig.
Let’s breakdown each block for better understanding of what’s going on.
ext_if = "igc0" # Internet modem (incoming)
int_if = "igc1" # OpenWrt DLink
int2_if = "igc2" # Pi-hole
igc0 is the WAN-facing interface connected to the internet modem, igc1 connects to an OpenWrt D-Link router (more on that later) managing the primary LAN, and igc2 connects to a Pi-hole for network-wide ad blocking. Naming them makes these easier further down in the config file.set skip on lo
pf to ignore the loopback interface (lo0). There’s no point filtering traffic that never leaves the router itself (avoids unnecessary overhead).anchor "miniupnpd"
block all
block all is the meat and potatoes of the whole firewall. We deny everything unless explicitly permitted. This “default deny” philosophy means a mis-configured or missing rule fails safely rather than accidentally allowing unwanted traffic through.match out on $ext_if from 192.168.1.0/24 to any nat-to ($ext_if)
match out on $ext_if from 192.168.2.0/24 to any nat-to ($ext_if)
match out NAT rules handle address translation for both of our subnets. When traffic from the primary LAN (192.168.1.0/24) or the Pi-hole LAN (192.168.2.0/24) heads out to the internet, pf rewrites the source address to our router’s public IP, so returning traffic knows how to find its way back.match in on $ext_if scrub (no-df random-id max-mss 1440)
match in on $ext_if scrub cleans incoming packets from the internet before they’re processed further. It strips the “don’t fragment” flag, randomizes IP IDs to make the firewall harder to fingerprint, and clamps the max segment size to 1440 bytes to stop fragmentation issues (common with PPPoE or VPN tunnels).antispoof quick for { lo $int_if $int2_if }
antispoof quick for { lo $int_if $int2_if } blocks packets that arrive on an interface but claim to originate from an address that should only appear on a different interface. This prevents IP spoofing attacks where a “bad actor” tries to impersonate a trusted internal address.pass in on $int_if
pass in on $int2_if
pass in on $int_if and pass in on $int2_if allow both internal networks to send traffic to the router. This includes things like DNS queries, gateway pings, or management access. Without these, our default deny would silently drop everything from our own local devices.pass out on $int_if from any to 192.168.1.0/24
pass out on $int2_if from any to 192.168.2.0/24
pass out rules on the internal interfaces ensure that traffic going to each subnet can actually leave the router towards the right network. These are the return paths that make the whole setup work as an actual router. Pretty important!pass out on $ext_if keep state
pass out on $ext_if keep state opens up all outbound internet traffic. The keep state part is the key. This tells pf to remember each connection so that reply packets are automatically allowed back in, without needing a separate inbound rule for every possible response.That’s everything on the pf.conf side of things! Next we move on to dhcpd.conf.
Next, we need to setup a very simple dhcp configuration. Create or edit the file found at /etc/dhcpd.conf
The /etc/dhcpd.conf config file:
# default
subnet 192.168.1.0 netmask 255.255.255.0 {
range 192.168.1.100 192.168.1.199;
option routers 192.168.1.1;
option domain-name-servers 192.168.1.1;
}
# Pi-hole
subnet 192.168.2.0 netmask 255.255.255.0 {
option routers 192.168.2.1;
option domain-name-servers 192.168.2.1;
range 192.168.2.100 192.168.2.150;
}
host pihole {
hardware ethernet 00:e0:4c:36:01:2b;
fixed-address 192.168.2.100;
}
There isn’t much to explain here. This config just sets our default subnet, along with a custom one for the Pi-hole. Below that we set a custom IP for our Pi-hole which will be used in our unbound configuration file for our network’s custom DNS.
The /var/unbound/etc/unbound.conf config file:
server:
interface: 192.168.1.1
interface: 192.168.2.1
access-control: 192.168.1.0/24 allow
access-control: 192.168.2.0/24 allow
do-ip6: no
verbosity: 1
hide-identity: yes
hide-version: yes
harden-glue: yes
harden-dnssec-stripped: yes
use-caps-for-id: yes
prefetch: yes
forward-zone:
name: "."
#forward-addr: 192.168.2.100
forward-addr: 9.9.9.9
Again, let’s breakdown each block for better understanding of what’s going on.
interface: 192.168.1.1
interface: 192.168.2.1
192.168.1.1 and 192.168.2.1 for the router’s addresses on each internal subnet. This means both the OpenWrt LAN and the Pi-hole LAN can reach the DNS resolver, but it’s never exposed on the WAN.access-control: 192.168.1.0/24 allow
access-control: 192.168.2.0/24 allow
unbound is somehow reachable from the outside, it won’t answer.do-ip6: no
verbosity: 1
hide-identity: yes
hide-version: yes
unbound from revealing its hostname and software version in response to id.server and version.bind queries.harden-glue: yes
unbound to reject glue records that fall outside the delegated zone. I researched that this seals off a classic DNS attack where someone can try to slip in fake referral data. I’m no expert, but this was highly suggested through most documentation.harden-dnssec-stripped: yes
unbound refuse to fall back silently if DNSSEC signatures are stripped in transit. Without this, a man-in-the-middle could remove DNSSEC records and unbound would quietly accept the unsigned response.use-caps-for-id: yes
unbound randomly capitalize letters in outgoing queries and expects the same casing mirrored back. Another highly recommended rule that I found through my initial research.prefetch: yes
unbound to refresh popular DNS records just before they expire, so frequently visited domains never go stale.forward-zone:
name: "."
#forward-addr: 192.168.2.100
forward-addr: 9.9.9.9
unbound for our use case. The forward-zone block with name: “.” means all queries get forwarded upstream. The first address 192.168.2.100 is the (commented out but soon to be setup) Pi-hole, so ad blocking happens before anything hits the public internet. 9.9.9.9 (Quad9) is setup as my personal fallback in case the Pi-hole is unreachable. Feel free to use any other DNS provider if you don’t like Quad9.Now we just need to enable all these services to run on boot and start them in our current session:
doas pfctl -f /etc/pf.conf
doas rcctl enable dhcpd unbound
doas rcctl start dhcpd unbound
If everything was configured correctly, you should see no errors at all. Congrats!
If you've found this guide helpful or even sparked an interest in OpenBSD, please consider donating directly to the OpenBSD Project. A little goes a long way, and the OpenBSD Project is a volunteer-driven software group funded by donations!
I'm far from an OpenBSD expert! Please help improve this project!