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).

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). Outgoing lookups can optionally be encrypted with dnscrypt-proxy, installed from the package system. When clients issue a DNS query, they'll first hit the cache. If unbound doesn't have the answer, it goes out (via an encrypted channel, if using dnscrypt-proxy) 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. If you choose to use DNSCrypt, be aware that you're still trusting whichever upstream resolver is used to provide accurate responses.

# 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: 127.0.0.1@40
Further configuration options can be found in unbound.conf(5). Now we'll set up dnscrypt-proxy. It's not part of the base system, so you need to install it from ports or packages. If you'd like to use a normal authoritative DNS server instead, replace 127.0.0.1@40 in the forward-addr line with the address of the server you want to use (8.8.8.8@53, for example). Otherwise, it's just a matter of installing the tool and choosing a resolver.
# export PKG_PATH=http://ftp.openbsd.org/pub/OpenBSD/$(uname -r)/packages/$(uname -m)
# pkg_add dnscrypt-proxy
# rcctl enable dnscrypt_proxy
We'll set it to listen for connections on localhost, port 40. Note that the dnscrypt-proxy utility won't use any server (and thus won't work) by default; you need to specify one with the -R flag.
# rcctl set dnscrypt_proxy flags -E -m 1 -R someserver -a 127.0.0.1:40
Replace someserver with an upstream resolver of your choice. The package includes a list of servers in the /usr/local/share/dnscrypt-proxy/dnscrypt-resolvers.csv file.

If you're using DNSCrypt to prevent information leakage, the following pf.conf(5) rule may be used as an additional safety belt:

block return in log on $int_if inet proto { tcp udp } from any to ! 192.168.1.1 port 53
As configured in a previous section, our DHCP server will give users a default DNS server to query. Unless they explicitly define another public DNS server, all the outgoing lookups should be encrypted. This PF rule will block any outgoing connections on port 53 if they aren't destined for our DNS cache. The log keyword is used so that the admin can easily investigate such connections with pflogd(8).

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.