Skip to main content

Setting up OpenBSD (home) DHCP with dynamic DNS updates and split DNS

·2158 words·11 mins

Diving in to OpenBSD
#

Ground Zero. I want to move some of my existing services to openBSD. For several reasons; mainly because of the mess some linux distubitions are becoming. Also because OpenBSD is appealing to me as a ‘simpler’ platform. Especially when it comes to networking. It is also nice to do something new, keeps me on edge.

Currently I am running my prod(home)lab based on LXC and VM’s with Proxmox. Which works well in it’s own right. However. I’ve been running some containers for years. Most of them were based on CentOS. Everyone knows how that went… So here I am. Setting up a DHCP, recursive and dynamic DNS server on OpenBSD!

This did not turn out to be very OpenBSD centric because of some requirements I had. Most if this (if not all) configuration will probably work just fine on any linux distro with the same software I ended up using.

Choice of Tools and requirement
#

I’m hosting several domains behind the same IP. Without the need for “Hairpin-NAT” I would need “split-dns”. Since i’m already doing this, this is a requirement. The same goes for dynamic updates.

OpenBSD and dhcpd
#

The OpenBSD’s version of dhcdp is lean and does, well, DHCP. Since it is an old fork of ISCs dhcpd, RFC2136 - that describes the mechanism for doing dynamic updates to dns, is not implemented in OpenBSD’s version. I found out fairly quick after loading in my existing /etc/dhcpd.conf, throwing errors at me for stating ddns-updates on;

NSD, Unbound and BIND
#

All three together? No! Here be dragons.

The LXC currently providing dynamic-and-recursive dns using ISC’s BIND and DHCPD. Going in to this I was planning to use Unbound for recursion, NSD for providing the PTR’s and A records of my lan zones. Mainly because of these two are in the base of OpenBSD.

NSD
#

Part of the reason i’m writing this whole blog is this very point. NSD does not support RFC2136. I was smart enough to figure that out with dhcp, but NSD cought me off-guard. I was under the impression NSD had me covered. Apparently dynamic-dns, well, really needs software implementation (hence the RFC, doh!). This took me too long to figure out, honestly.

So here is a PSA: You will get a ‘NOTIMP’ -not implemented- message back from nsd when you try to ddns. You could write wrapper-scripts around NSD to update the zonefile with dhcp-leases, reload zones etc. Maybe in the future ;-)

For me personally, at this moment, NSD is off the table for internal zones with this requirement.

ISC’s BIND
#

Bind is… Well, BIND. Configuration can be a bit overwhelming. Mainly because bind is such an old program that has grown with DNS. On some systems it is called named, bind or both. Despite that, it is battle-tested and stable. Also because I know BIND a bit already, i’m going with BIND. However, BIND is not going to perform the recursive function (more on that later). This will simplify the configuration and in turn easier to manage (or migrate).

Unbound
#

OpenBSD has this in base. It is not compliant with RFC2136 either. So BIND is required. But i’m also looking at ways to block advertisements before they reach clients. Sure, there is PiHole, AdGuard etc. But why manage a separate recursing DNS server for blocking ad’s, while Unbound can do this? Unbound has local-zone options and includes, which is great.

TuM’Fatig wrote about blocking ad’s with unbound. This was very appealing to me to implement later on the recursor. Instead of doing RPZs (Response Policy Zones) with BIND. Such config would become messy very quickly.

But you don’t want to create scripts around dhcpd and nsd???

Not now…

Implementation
#

OK, so I settled on the following:

  • BIND for serving reverse and forward lookups of the LANs
  • Unbound for doing the recursion to WWW and forwarding the internal zone to BIND.
  • ISC DHCPD to serve leases to clients and update BIND with those.

It would look like this:

+-----------+   dhcp-request   +---------+      DDNS-update int.example.com
|dhcp-client| -------------->  |isc_dhcpd+ --------------------------------------+
+--------+--+                  +---------+                                       |
         |                                                                       |
         |                                                                       |
         |                                     stub-zone: example.com.           |
         |                                     stub-zone: int.example.com.       |
         |                                                                       v
         | default-dns-server  +-------+ IF request =  stub-zone              +------+
         +------------------>  |unbound| -----------------------------------> | BIND |
                               +---+---+                                      +------+
                                   | ELSE
                                   |
                                   v
                           +---------------+
                           |recurse to www |
                           +---------------+

Installation
#

Easy:

 # pkg_add isc-dhcp-server isc-bind

Configuration
#

Assumptions:

  • internal zone = int.example.com
  • external zone = example.com (for split dns)
  • lan CIDR = 192.168.1.0/24
  • the host running this has a static IP 192.168.1.10

Configure BIND:
#

BIND is running inside a chroot. It will only listen on localhost. Unbound will listen on egress.

Create rndc key
#

rndc - Remote name daemon control. The key generated here is to secure the dhcpd updates to bind, but also allow for easier management.

 # rndc-confgen

This will print out config you use in BIND, replace as stated in the output with the config below.

/var/named/etc/named.conf

# Allow rndc management
key "rndc-key" {
	algorithm hmac-sha256;
	secret "{ your rndc key }";
};

controls {
	inet 127.0.0.1 port 953
		allow { 127.0.0.1; } keys { "rndc-key"; };
};

acl "loopback" {
	127.0.0.0/8;
};

options {
	directory "/tmp";
	listen-on port 53 { 127.0.0.1; }; 
	listen-on-v6 { none; };

	tcp-clients 50;

	# Disable built-in server information zones
	version none;
	hostname none;
	server-id none;

	recursion no; # recursion by unbound
	allow-query { loopback; }; # stub zone coming from unbound
	allow-transfer { localhost; }; # isc_dhcpd update

	auth-nxdomain no;
	notify no;
};

# Internal zone definitions
zone "int.example.com" {
	type master;
	file "/master/int.example.com.dns";
	allow-update { key rndc-key; };
	notify yes;
};

zone "168.192.in-addr.arpa" {
	type master;
	file "/master/168.192.in-addr.arpa.dns";
	allow-update { key rndc-key; };
	notify yes;
};

# This is the split dns zone
zone "example.com" {
	type master;
	file "/master/example.com.dns";
};
BIND Zonefiles
#

Mind you, for me the ‘master’ folder did not exists. Create it and chown it properly for root:_bind BIND zonefile RFC1035

Change your filenames for your actual zone name!

/var/named/master/example.com.dns:

$ORIGIN .
$TTL 86400	; 1 day
example.com		IN SOA	dns.example.com root.example.com (
				1 ; serial
				3600       ; refresh (1 hour)
				3600       ; retry (1 hour)
				604800     ; expire (1 week)
				3600       ; minimum (1 hour)
				)
			NS	dns.example.com
			A	192.168.1.10

$ORIGIN example.com
$TTL 3600	; 1 hour
dns			            A	192.168.1.10
other-service			A 	192.168.1.11

The ‘int’ domain will be updated by dhcpd

/var/named/master/int.example.com.dns:

$ORIGIN .
$TTL 86400	; 1 day
int.example.com		IN SOA	dns.int.example.com. root.int.example.com. (
				1       ; serial
				86400      ; refresh (1 day)
				3600       ; retry (1 hour)
				604800     ; expire (1 week)
				3600       ; minimum (1 hour)
				)
			NS	dns.int.example.com.
			A	10.10.30.10
            
$ORIGIN int.example.com.
$TTL 3600	; 1 hour
dns			A	10.10.30.10

ARPA is reversing the octets. So 192.168.1.2 = 2.1.168.192. You notice i’m missing an octet? I have multiple vlans with each a /24 where vlan-id represents the third octet: vlan 2 = 192.168.2.0 etc.

/var/named/master/168.192.in-addr.arpa.dns:

$ORIGIN .
$TTL 3600	; 1 hour
168.192.in-addr.arpa	IN SOA	dns.int.example.com. root.int.example.com. (
				1         ; serial
				86400      ; refresh (1 day)
				3600       ; retry (1 hour)
				604800     ; expire (1 week)
				3600       ; minimum (1 hour)
				)
		IN 	NS	dns.int.example.com.
		IN	PTR	int.example.com.

$ORIGIN 168.192.in-addr.arpa.
10.1		IN	PTR	dns.int.example.com.

; if you want to, you could do this in this very same file
; leave this out if you don't want to.

$ORIGIN 1.168.192.in-addr.arpa.
10		IN	PTR 	dns.int.example.com
	

Configure Unbound:
#

Unbound will be the catch-all for DNS traffic. A lot of the example config is snipped. Please also take a look at those.

First set up unbound-control:

 # unbound-control-setup

Secondly get the root key for DNSSEC:

 # unbound-anchor

Then review your config:

/var/unbound/etc/unbound.conf

# $OpenBSD: unbound.conf,v 1.21 2020/10/28 11:35:58 sthen Exp $
server:
	interface: vio0 # replace with egress interface or IP 

	access-control: 0.0.0.0/0 refuse
	access-control: 127.0.0.0/8 allow
	access-control: 10.0.0.0/8 allow
	access-control: 172.16.0.0/12 allow
	access-control: 192.168.0.0/16 allow
	access-control: ::0/0 refuse
	access-control: ::1 allow

	hide-identity: yes
	hide-version: yes

	# Perform DNSSEC validation.
	#
	auto-trust-anchor-file: "/var/unbound/db/root.key"
	val-log-level: 2

	# Synthesize NXDOMAINs from DNSSEC NSEC chains.
	# https://tools.ietf.org/html/rfc8198
	#
	aggressive-nsec: yes

	
	# Define this or it will be blocked by DNSSEC
	insecure-lan-zones: yes
	domain-insecure: "int.example.com"
	domain-insecure: "168.192.in-addr.arpa."
	domain-insecure: "example.com"
	private-domain: "int.example.com"
	private-domain: "168.192.in-addr.arpa."
	private-domain: "example.com"

	private-address: 10.0.0.0/8
	private-address: 172.16.0.0/12 
	private-address: 192.168.0.0/26
	do-not-query-localhost: no
	local-zone: "168.192.in-addr.arpa" nodefault

# end server block

remote-control:
	control-enable: yes
	control-interface: 127.0.0.1

# define stub-zones to query bind
stub-zone:
	name: "int.example.com."
	stub-addr: 127.0.0.1
	stub-no-cache: yes

stub-zone:
	name: "example.com."
	stub-addr: 127.0.0.1
	stub-no-cache: yes

stub-zone:
	name: "168.192.in-addr.arpa."
	stub-addr: 127.0.0.1
	stub-no-cache: yes

Configure isc_dhcpd:
#

Do not forget to replace the RNDC key with the one you generated.

/etc/dhcpd.conf

# Logging setting
log-facility local0; # you can do some tuning in syslog

# Global options
authoritative;
allow booting;
allow bootp;
allow unknown-clients;
default-lease-time 86400; # 1 day
max-lease-time 86400; # 1 day

## DDNS settings
ddns-updates on;
ddns-update-style standard;
update-static-leases on;
one-lease-per-client on;

## rndc-key to update bind
key rndc-key {
  algorithm hmac-sha256;
  secret REPLACE_WITH_RNDC_KEY; # Replace rndc key with the one bind uses (w/o quotes).
}

## Zones to tell the dhcp server to update with key
zone 168.192.in-addr.arpa {
  primary 127.0.0.1;
  key rndc-key;
}

zone int.example.com {
  primary 127.0.0.1;
  key rndc-key;
}

## Subnet settings
subnet 192.168.1.0 netmask 255.255.255.0 {
  option routers 192.168.1.1; # replace router
  
  option subnet-mask 255.255.255.0;
  option broadcast-address 192.168.1.255;
  option domain-name-servers dns.int.example.com;
  option domain-name "int.example.com";
  option domain-search "example.com";
  pool {
    range 192.168.30.100 192.168.30.200;
  }
}

group {
    # This is how you update the dns records for hosts you 'statically' assing an IP
    # have the host you want 'static' gain a lease like such.

    host dns.int.example.com {
        hardware ethernet aa:bb:cc:00:11:22;
        fixed-address 192.168.1.10;
        option host-name "dns";
    }

# end group block
}

Priming for startup
#

All configs are there, almost ready to start.

Configure resolver
#

Go in to /etc/resolv.conf and edit the nameserver to be your localhost (on the egress ip). You might want to disable resolvd with rcctl. Doing so will stop this deamon editing this file.

Validate BIND configuration
#

First validate the config, then the zones:

 # named-checkconf -t /var/named /etc/named.conf

 # named-checkzone -t /var/named int.example.com master/int.example.com.dns
 # named-checkzone -t /var/named example.com master/example.com.dns
 # named-checkzone -t /var/named 168.192.in-addr.arpa master/168.192.in-addr.arpa.dns

How this works:

  • The -t switch tells named to chroot in to the BIND directory
  • Second argument is the name of the dns zone you want to validate
  • Last argument is the actual zone file for said zone.

Check your domains for any errors and fix them.

Other Validations
#

 # unbound-checkconf
 # /usr/local/sbin/dhcpd -t -cf /etc/dhcpd.conf

Starting
#

Lets first start Unbound and BIND to check their functions

 # rcctl enable isc_named unbound
 # rcctl start isc_named unbound 

This should return two OK’s since we validated the configs.

Let’s dig in
#

Pun intended.

You should be able to query unbound for:

  • recursion
  • int.example.com
  • example.com
 # dig openbsd.org +short @192.168.1.10
 # dig dns.int.example.com +short @192.168.1.10
 # dig dns.example.com +short @192.168.1.10
 # dig -x 192.168.1.10 +short @192.168.1.10
  • If the first one does not work; check if unbound works at all.
  • If the other request do not return anything either, check with @127.0.0.1 – If that does not work either, check logs. You must have skipped some steps :)

Remember, this is set up so unbound listens on egress, bind on localhost. When this all works, you can start dhcpd!

DHCP
#

Now, make sure you do not have an other dhcp server running anymore on the same network.

 # rcctl enable isc_dhcpd
 # rcctl start isc_dhcpd

Done! Now initiate a dhcp lease request - i.e. disable/enable WiFi on your phone. To verify if this all works you must now dig for the hostname of that device that just got a lease from this server. If you have no clue, check the /var/db/dhcpd.leases file.

 # dig {hostname}.int.example.com +short @192.168.1.10 
 ## once you know the address, you can do a reverse lookup
 # dig -x 192.168.1.x +short @192.168.1.10 

Tips
#

I might update this with more stuff along the way.

write changes to zones
#

You might have noticed the actual zonefile does not get updated with the lease. Instead there are .jnl - journal files which hold the dhcp updated info. If you want your actual zone to be updated with the lease info, you can use rndc sync. You could even put that in to crontab.

bind
#

When you are changing bind zonefiles, reloading them etc. Always increase the serial. I chose to just to simple number. But a very common format is yyyyddmmxx example: 2006220800.

rndc is usefull. use it. Also, when you use rndc-confgen (by accident) it will change the key in /etc/rndc.key. Make sure those match with bind’s ad dhcpd’s config.

You could also update zones with rndc. Make sure you sync the data to the file with rndc sync

Unbound
#

There is unbound-control handy too. You can flush data etc in case you have some issues. A unbound-control reload will reload the daemon and flush data.

Aaaand you are set!