My home network observes bedtime with OpenBSD and pf

36 points by jbauer a day ago on lobsters | 4 comments

watercolor of puffy the fearsome openbsd mascot guarding a pathway that enters its open mouth

(Sketchbook ink and watercolor by the author: A fearsome Puffy determines which packets shall pass.)

The centerpiece of my setup is the pf packet filter, which is built into the OpenBSD kernel and originated, like many good things, from OpenBSD.

The bulk of pf configuration is done through /etc/pf.conf.

I constructed mine from scratch while reading The Book of PF, 4th Ed. (see the references section at the bottom of this page).

You can view my full conf in the repo here: pf.conf.

(I like to thoroughly document things I won’t be touching frequently, so there are a lot of comments in that file, including instructions on updating pf after I make changes.)

Anyway, I set this up in the recommended fashion: block all traffic and then let only selected traffic through.

When it’s daytime, I use the rule:

pass proto tcp from <leased_ips>

When it’s bedtime, that rule changes to:

pass proto tcp from <bedtime_exempt>

There are two IP address tables being used:

  • <leased_ips> is maintained by dhcpd when it leases addresses to clients on the local network.

  • <bedtime_exempt> is maintained manually by me. I store the addresses in a text file and load them into the table with a script whenever I make a change.

When it’s bedtime, I only explicitly allow traffic to the exempt computers. This blocks traffic to everything else because, as you may recall, the default is block all!

You’ll notice that I’m only doing this for TCP traffic. I’m handling ICMP and UDP packets in a strict fashion in accordance with the wisdom of the book. We’ll see if I end up needing to make any exceptions.

(Update: Sure enough, I’m going to need to experiment with the daytime rule - the above doesn’t allow Discord voice chat or Roblox to function, which…​was not appreciated by certain members of this house.)

Updating tables

Since this is all predicated on the two address tables, how do these tables get updated?

The <leased_ips> table is initially created in pf.conf with this placeholder:

table <leased_ips> persist counters

It is populated automatically by dhcpd from this command line option set in /etc/rc.conf.local (also in the repo):

dhcpd_flags="-L leased_ips"

I think it’s great how tables are built right into the OpenBSD kernel and all the tooling understands them. It feels very cohesive and, dare I say it, planned and thought-out?

I store the <bedtime_exempt> addresses in a text file and update the table from the file contents with pfctl:

pfctl -t bedtime_exempt -T replace -f no_bedtime.txt

The text file is a simple list with one address per line. It can also have standard Unix-style comments (line starts with '#'). Again, all of this feels very cohesive and flexible to me. It’s the good parts of the Unix Philosophy.

When you or a program update a table, the changes take place immediately in the running kernel’s tables and you don’t have to tell pf about them.

Anchors

The crux of bedtime enforcement is the ability to schedule a change to the rules that allow traffic from local computers.

Anchors are a grouping for rules in pf.conf. There are a couple different uses for them, but in my case, I’m using an anchor as a named chunk of rules which I can change from the command line without having to reload anything else.

Initializing an anchor can be as simple as giving it a name:

In my case, I’m pre-populating my 'bedtime' anchor with the unrestricted Internet access rule so access works when pf starts up with the assumption that it’s currently "daytime":

anchor bedtime {
        # the default "awake" rule, bedtime not enforced
        pass proto tcp from <leased_ips>
}

Once you have an anchor, you can swap out its rules on the fly (they’ll be parsed and added to the ruleset) from a file or even STDIN at the command line. Here’s an example that uses echo to replace the rules of a 'foo' anchor:

echo "block all" | pfctl -a foo -f -

As soon as you replace an anchor’s rules, pf will start using them immediately.

By the way, if you made a mistake and have a syntax error in the rules you’re trying to load into an anchor, you’ll just get a syntax error and the rule won’t be loaded. Nothing bad happens and everything keeps running.

Kill active connections in the state table

One of the hardest problems I ran into was the fact that by default, pf keeps track of active connections and stores them in a state table. It keeps these connections alive even if a new rule would have forbidden it.

This is normally desirable for two reasons:

  1. pf doesn’t have to expend cycles examining each packet that follows in the same connection.

  2. It keeps existing connections (like SSH sessions!) alive even when rules change or address tables update.

However, in my case, I want bedtime to cut off traffic, especially long-lived connections like YouTube streams!

So when bedtime starts and I change the anchor rules, I’m also killing connections on the local network with pfctl:

(Ideally, I would be able to kill connection states for only the entries in <leased_ips> minus the entries in <bedtime_exempt>. But I’ll admit, after reading the man pages front-and-back, searching the wasteland that is the modern Web, and even diving into the source code, I don’t see an easy way to do that. One possible avenue would be to use the ability to label pf rules and then kill connections by label, so maybe I can figure out how to craft a 'match' rule that uses the tables to get the correct list of connections to kill? It’s that or figure out the difference of the two tables in my script, which…​no thanks.)

Lastly, I put all the above together in a shell script.