OpenBSD PF - Building a Router [Contents]



Background

This guide will show you how to turn an ordinary OpenBSD box into a router. First, we'll define what our router (also called a gateway) will actually do, since everyone has different requirements. In this example, there are three computers that need to share an internet connection. One of them is a web server that needs to be accessed remotely, but otherwise we don't want any of the local systems open to the internet. The router will be doing the following things: This guide will be using hardware with four NICs that use the em(4) driver, so replace em0, em1, em2 and em3 with the names of the interfaces in your system. Using this example, em0 will be for the WAN and the others for the LAN. Some sample configuration files are provided, but you're encouraged to read the man pages of each one to understand their full capability.

Networking

Two possible hardware setups are described on this page. The first details an "all-in-one" solution in which clients will be directly connected to the gateway, without the need for a switch. Software-based switching is done by bridging NICs with the vether(4) virtual interface. The second details the same thing, but using an external (hardware) switch, rather than bridging. Only two interfaces are required in that case. We'll begin with some initial network configuration, using a 192.168.1.0/24 subnet.
# echo dhcp > /etc/hostname.em0 # if you have a static IP, use that instead
# echo up   > /etc/hostname.em1
# echo up   > /etc/hostname.em2
# echo up   > /etc/hostname.em3
If you plan on using bridge(4) for software-based switching, create the interfaces.
# echo 'inet 192.168.1.1 255.255.255.0 192.168.1.255' > /etc/hostname.vether0
# vi /etc/hostname.bridge0
And add the following lines:
add vether0
add em1
add em2
add em3
blocknonip vether0
blocknonip em1
blocknonip em2
blocknonip em3
up
If you plan on using a physical switch, give your secondary interface the network information instead.
# echo 'inet 192.168.1.1 255.255.255.0 192.168.1.255' > /etc/hostname.em1
In either case, IP forwarding must be enabled to allow passing packets between interfaces.
# echo 'net.inet.ip.forwarding=1' >> /etc/sysctl.conf
This change will take effect upon the next reboot.

DHCP

Clients need IP addresses, so we'll set dhcpd(8) to start on boot. Configuration is done via the dhcpd.conf(5) file.
# rcctl enable dhcpd
# rcctl set dhcpd flags vether0 # replace vether0 with your secondary interface if not bridging
# vi /etc/dhcpd.conf
Take this example and adjust to fit your needs:
option domain-name-servers 192.168.1.1;
subnet 192.168.1.0 netmask 255.255.255.0 {
	option routers 192.168.1.1;
	range 192.168.1.4 192.168.1.254;
	host myserver {
		fixed-address 192.168.1.2;
		hardware ethernet 00:00:00:00:00:00;
		}
	host mylaptop {
		fixed-address 192.168.1.3;
		hardware ethernet 11:11:11:11:11:11;
		}
}
Use the MAC addresses of certain clients if they require static IPs. You can specify another RFC 1918 address space here if you prefer, or even a public IP block if you have one. Using this example, clients will query a local DNS server, detailed in a later section. If you don't plan on using a local DNS server, replace 192.168.1.1 in the domain-name-servers line with the address of your preferred upstream DNS server (8.8.8.8, for example). If you want to use multiple subnets, the networking FAQ has an example of that.

Firewall

The centerpiece of this guide is the pf.conf(5) file. It's highly recommended to familiarize yourself with it, and PF in general, before copying this example. Each section will be explained in more detail.
# vi /etc/pf.conf
A configuration for a gateway system might look like this:
int_if="{ vether0 em1 em2 em3 }"
table <martians> { 0.0.0.0/8 10.0.0.0/8 127.0.0.0/8 169.254.0.0/16     \
	 	   172.16.0.0/12 192.0.0.0/24 192.0.2.0/24 224.0.0.0/3 \
	 	   192.168.0.0/16 198.18.0.0/15 198.51.100.0/24        \
	 	   203.0.113.0/24 }
set block-policy drop
set loginterface egress
set skip on lo0
match in all scrub (no-df random-id max-mss 1440)
match out on egress inet from !(egress:network) to any nat-to (egress:0)
block in quick on egress from <martians> to any
block return out quick on egress from any to <martians>
block all
pass out quick inet
pass in on $int_if inet
pass in on egress inet proto tcp from any to (egress) port 22
pass in on egress inet proto tcp from any to (egress) port { 80 443 } rdr-to 192.168.1.2
Now we'll break this ruleset down and explain what each line does.
int_if="{ vether0 em1 em2 em3 }"
This is a macro, used to make overall maintenance easier. Macros can be referenced throughout the ruleset after being defined. The int_if macro is a list of internal interfaces, including the virtual ethernet interface. If you're using an external switch instead of bridge(4), replace { vether0 em1 em2 em3 } with your secondary interface name.
table <martians> { 0.0.0.0/8 10.0.0.0/8 127.0.0.0/8 169.254.0.0/16     \
	 	   172.16.0.0/12 192.0.0.0/24 192.0.2.0/24 224.0.0.0/3 \
	 	   192.168.0.0/16 198.18.0.0/15 198.51.100.0/24        \
	 	   203.0.113.0/24 }
This is a table of non-routable private addresses that will be used later.
set block-policy drop
set loginterface egress
set skip on lo0
PF allows certain options to be set at runtime. The block-policy decides whether rejected packets should return a TCP RST or be silently dropped. The loginterface is exactly what it sounds like: which interface should have packet and byte statistics collection enabled. These statistics can be viewed with the pfctl -si command. In this case, we're using the egress group rather than a specific interface. The egress keyword automatically chooses the interface that holds the default route, or the em0 WAN interface in our example. Finally, skip allows you to completely omit a given interface from packet processing. The firewall will ignore loopback traffic on the lo(4) interface.
match in all scrub (no-df random-id max-mss 1440)
match out on egress inet from !(egress:network) to any nat-to (egress:0)
The match rules used here accomplish two things: normalizing incoming packets and performing network address translation, with the egress interface between the LAN and the public internet. For a more detailed explanation of match rules and their different options, refer to the pf.conf(5) manual.
block in quick on egress from <martians> to any
block return out quick on egress from any to <martians>
Packets coming in on the egress interface should be dropped if they appear to be from the list of unroutable addresses we defined. Such packets were likely sent due to misconfiguration, or possibly as part of a spoofing attack. Similarly, our clients should not attempt to connect to such addresses. We'll specify the "return" action to prevent annoying timeouts for users.
block all
The firewall will set a "default deny" policy for all traffic. This means we will only allow incoming and outgoing connections that we explicitly put in our ruleset.
pass out quick inet
With the previous rule in mind, there are obviously some types of traffic we want to pass in our setup. This line allows outgoing IPv4 traffic from both the gateway itself and the LAN clients.
pass in on $int_if inet
Allow all internal LAN traffic to pass.
pass in on egress inet proto tcp from any to (egress) port 22
pass in on egress inet proto tcp from any to (egress) port { 80 443 } rdr-to 192.168.1.2
Allow incoming connections to the router itself (on TCP port 22, for SSH) and forward incoming connections (on TCP ports 80 and 443, for a web server) to our machine at 192.168.1.2. This is merely an example of port forwarding.

DNS

At this point, clients should be assigned IP addresses and granted access to the internet, while being protected by the firewall. If that's all you need, you're done and can reboot now. With that said, a common addition to a gateway system is a DNS cache.

Setting up local DNS caching is very simple with unbound(8). When clients issue a DNS query, they'll first hit the cache. If unbound doesn't have the answer, it goes out to the upstream resolver that you've configured. Results are then fed to the client and cached for a period of time, making future lookups of the same address much quicker.

# rcctl enable unbound
# vi /var/unbound/etc/unbound.conf
Something like this should work for most setups:
server:
	interface: 192.168.1.1
	interface: 127.0.0.1
	access-control: 192.168.1.0/24 allow
	do-not-query-localhost: no
	hide-identity: yes
	hide-version: yes

forward-zone:
        name: "."
        forward-addr: IP.OF.UPSTREAM.RESOLVER
Further configuration options can be found in unbound.conf(5). Outgoing DNS lookups may also be encrypted with the dnscrypt-proxy package -- see its included README file for details.

If you want the gateway to use the caching resolver for lookups too, don't forget to change its /etc/resolv.conf file to point to 127.0.0.1. Since the majority of routers won't be doing many DNS queries, this is likely not needed. Also note that it will need to be changed back to an actual resolver when using bsd.rd for network-based upgrades if you do so.