Cluster, Part 3: Laying groundwork with Unbound as a DNS server
(Above: A picture from my wallpapers folder. If you took this, comment below and I'll credit you)
Welcome to another blog post about my cluster! Although I had to replace the ATX PSU I was planning on using to power the thing with a USB power supply instead, I've got all the Pis powered up and networked together now - so it's time to finally start on the really interesting bit!
In this post, I'm going to show you how to install Unbound as a DNS server. This will underpin the entire stack we're going to be setting up - and over the next few posts in this series, I'll take you through the entire process.
Starting with DNS is a logical choice, which comes with several benefits:
- We get a minor performance boost by caching DNS queries locally
- We get to configure our DNS server to make encrypted DNS-over-TLS queries on behalf of the entire network (even if some of the connected devices - e.g. phones don't support doing so)
- If we later change our minds and want to shuffle around the IP addresses, it's not such a big deal as if we used IP addresses in all our config files
With this in mind, I starting with DNS before moving on to Docker and the Hashicorp stack:
Before we begin, let's set out our goals:
- We want a caching resolver, to avoid repeated requests across the Internet for the same query
- We want to encrypt queries that leave the network via DNS-over-TLS
- We want to be able to add our own custom DNS records for a domain of our choosing, for internal resolution only.
The last point there is particularly important. We want to resolve something like 172.16.230.100
to server1.bobsrockets.com
internally, but not externally outside the network. This way we can include server1.bobsrockets.com
in config files, and if the IP changes then we don't have to go back and edit all our config files - just reload or restart the relevant services.
Without further delay, let's start by installing unbound
:
sudo apt install unbound
If you're on another system, translate this for your package manager. See this amazing wiki page for help on translating package manager commands :-)
Next, we need to write the config file. The default config file for me it located at /etc/unbound/unbound.conf
:
# Unbound configuration file for Debian.
#
# See the unbound.conf(5) man page.
#
# See /usr/share/doc/unbound/examples/unbound.conf for a commented
# reference config file.
#
# The following line includes additional configuration files from the
# /etc/unbound/unbound.conf.d directory.
include: "/etc/unbound/unbound.conf.d/*.conf"
There's not a lot in the /etc/unbound/unbound.conf.d/
directory, so I'm going to be extending /etc/unbound/unbound.conf
. First, we're going to define a section to get Unbound to forward requests via DNS-over-TLS:
forward-zone:
name: "."
# Cloudflare DNS
forward-addr: 1.0.0.1@853
# DNSlify - ref https://www.dnslify.com/services/resolver/
forward-addr: 185.235.81.1@853
forward-ssl-upstream: yes
The .
name there simply means "everything". If you haven't seen it before, the fully-qualified domain name for seanssatellites.io
for example is as follows:
seanssatellites.io.
Notice the extra trailing dot .
there. That's really important, as it signifies the DNS root (not sure on it's technical name. Comment if you know it, and I'll update this). The io
bit is the top-level domain (commonly abbreviated as TLD). seanssatellites
is the actual domain bit that you buy.
It's a hierarchical structure, and apparently used to be inverted here in the UK before the formal standard was defined by the IETF (Internet Engineering Task Force) - of which RFC 1034 was a part.
Anyway, now that we've told Unbound to forward queries, the next order of business is to define a bunch of server settings to get it to behave the way we want it to:
server:
interface: 0.0.0.0
interface: ::0
ip-freebind: yes
# Access control - default is to deny everything apparently
# The local network
access-control: 172.16.230.0/24 allow
# The docker interface
access-control: 172.17.0.1/16 allow
username: "unbound"
harden-algo-downgrade: yes
unwanted-reply-threshold: 10000000
prefetch: yes
There's a lot going on here, so let's break it down.
Property | Meaning |
---|---|
interface |
Tells unbound what interfaces to listen on. In this case I tell it to listen on all interfaces on both IPv4 and IPv6. |
ip-freebind |
Tells it to try and listen on interfaces that aren't yet up. You probably don't need this, so you can remove it. I'm including it here because I'm currently unsure whether unbound will start before docker, which I'm going to be using extensively. In the future I'll probably test this and remove this directive. |
access-control |
unbound has an access control system, which functions rather like a firewall from what I can tell. I haven't had the time yet to experiment (you'll be seeing that a lot), but once I've got my core cluster up and running I intend to experiment and play with it extensively, so expect more in the future from this. |
username |
The name of the user on the system that unbound should run as. |
harden-algo-downgrade |
Protect against downgrade attacks when making encrypted connections. For some reason the default is to set this to no , so I enable it here. |
unwanted-reply-threshold |
Another security-hardening directive. If this many DNS replies are received that unbound didn't ask for, then take protective actions such as emptying the cache just in case of a DNS cache poisoning attack |
prefetch |
Causes unbound to prefetch updated DNS records for cache entries that are about to expire. Should improve performance slightly. |
If you have a flaky Internet connection, you can also get Unbound to return stale DNS cache entries if it can't reach the remote DNS server. Do that like this:
server:
# Service expired cached responses, but only after a failed
# attempt to fetch from upstream, and 10 seconds after
# expiration. Retry every 10s to see if we can get a
# response from upstream.
serve-expired: yes
serve-expired-ttl: 10
serve-expired-ttl-reset: yes
With this, we should have a fully-functional DNS server. Enable it to start on boot and (re)start it now:
sudo systemctl enable unbound.service
sudo systemctl restart unbound.service
If it's not already started, the restart
action will start it.
Internal DNS records
If you're like me and want some custom DNS records, then continue reading. Unbound has a pretty nifty way of declaring custom internal DNS records. Let's enable that now. First, you'll need a domain name that you want to return custom internal DNS records for. I recommend buying one - don't use an unregistered one, just in case someone else comes along and registers it.
Gandi are a pretty cool registrar - I can recommend them. Cloudflare are also cool, but they don't allow you to register several years at once yet - so they are probably best set as the name servers for your domain (that's a free service), leaving your domain name with a registrar like Gandi.
To return custom DNS records for a domain name, we need to tell unbound that it may contain private DNS records. Let's do that now:
server:
private-domain: "mooncarrot.space"
This of course goes in /etc/unbound/unbound.conf
, as before. See the bottom of this post for the completed configuration file.
Next, we need to define some DNS records:
server:
local-zone: "mooncarrot.space." transparent
local-data: "controller.mooncarrot.space. IN A 172.16.230.100"
local-data: "piano.mooncarrot.space. IN A 172.16.230.101"
local-data: "harpsichord.mooncarrot.space. IN A 172.16.230.102"
local-data: "saxophone.mooncarrot.space. IN A 172.16.230.103"
local-data: "bag.mooncarrot.space. IN A 172.16.230.104"
local-data-ptr: "172.16.230.100 controller.mooncarrot.space."
local-data-ptr: "172.16.230.101 piano.mooncarrot.space."
local-data-ptr: "172.16.230.102 harpsichord.mooncarrot.space."
local-data-ptr: "172.16.230.103 saxophone.mooncarrot.space."
local-data-ptr: "172.16.230.104 bag.mooncarrot.space."
The local-zone
directive tells it that we're defining custom DNS records for the given domain name. The transparent
bit tells it that if it can't resolve using the custom records, to forward it out to the Internet instead. Other interesting values include:
Value | Meaning |
---|---|
deny | Serve local data (if any), otherwise drop the query. |
refuse | Serve local data (if any), otherwise reply with an error. |
static | Serve local data, otherwise reply with an nxdomain or nodata answer (similar to the reponses you'd expect from a DNS server that's authoritative for the domain). |
transparent | Respond with local data, but resolve other queries normally if the answer isn't found locally. |
redirect | serves the zone data for any subdomain in the zone. |
inform | The same as transparent, but logs client IP addresses |
inform_deny | Drops queries and logs client IP addresses |
Adapted from /usr/share/doc/unbound/examples/unbound.conf
, the example Unbound configuration file.
Others exist too if you need even more control, like always_refuse
(which always responds with an error message).
The local-data
directives define the custom DNS records we want Unbound to return, in DNS records syntax (again, if there's an official name for the syntax leave a comment below). The local-data-ptr
directive is a shortcut for defining PTR, or reverse DNS records - which resolve IP addresses to their respective domain names (useful for various things, but also commonly used as a step to verify email servers - comment below and I'll blog on lots of other shady and not so shady techniques used here).
With that, our configuration file is complete. Here's the full configuration file in it's entirety:
# Unbound configuration file for Debian.
#
# See the unbound.conf(5) man page.
#
# See /usr/share/doc/unbound/examples/unbound.conf for a commented
# reference config file.
#
# The following line includes additional configuration files from the
# /etc/unbound/unbound.conf.d directory.
include: "/etc/unbound/unbound.conf.d/*.conf"
server:
interface: 0.0.0.0
interface: ::0
ip-freebind: yes
# Access control - default is to deny everything apparently
# The local network
access-control: 172.16.230.0/24 allow
# The docker interface
access-control: 172.17.0.1/16 allow
username: "unbound"
harden-algo-downgrade: yes
unwanted-reply-threshold: 10000000
private-domain: "mooncarrot.space"
prefetch: yes
# ?????? https://www.internic.net/domain/named.cache
# Service expired cached responses, but only after a failed
# attempt to fetch from upstream, and 10 seconds after
# expiration. Retry every 10s to see if we can get a
# response from upstream.
serve-expired: yes
serve-expired-ttl: 10
serve-expired-ttl-reset: yes
local-zone: "mooncarrot.space." transparent
local-data: "controller.mooncarrot.space. IN A 172.16.230.100"
local-data: "piano.mooncarrot.space. IN A 172.16.230.101"
local-data: "harpsichord.mooncarrot.space. IN A 172.16.230.102"
local-data: "saxophone.mooncarrot.space. IN A 172.16.230.103"
local-data: "bag.mooncarrot.space. IN A 172.16.230.104"
local-data-ptr: "172.16.230.100 controller.mooncarrot.space."
local-data-ptr: "172.16.230.101 piano.mooncarrot.space."
local-data-ptr: "172.16.230.102 harpsichord.mooncarrot.space."
local-data-ptr: "172.16.230.103 saxophone.mooncarrot.space."
local-data-ptr: "172.16.230.104 bag.mooncarrot.space."
fast-server-permil: 500
forward-zone:
name: "."
# Cloudflare DNS
forward-addr: 1.0.0.1@853
# DNSlify - ref https://www.dnslify.com/services/resolver/
forward-addr: 185.235.81.1@853
forward-ssl-upstream: yes
Where do we go from here?
Remember that it's important to not just copy and paste a configuration file, but to understand what every single line of it does.
In a future post in this series, we'll be revising this to forward requests for *.service.mooncarrot.space
to Consul, a clustered service discovery system that keeps track of what is running where and presents a DNS server as an interface (there are others).
In the next post, we'll (probably) be looking at setting up Consul - unless something else happens first :P Nomad should be next after that, followed closely by Vault.
Once I've got all that set up (which is a blog post series in and of itself!), I'll then look at encrypting all communications between all nodes in the cluster. After that, we'll (finally?) get to Docker and my plans there. Other things include an apt and apk (the Alpine Linux package manager) caching servers - which will have to be tackled separately.
Oh yeah, and I want to explore Traefik, which is apparently like Nginx, but intended for containerised infrastructure.
All this is definitely going to keep me very busy!
Found this interesting? Got a suggestion? Comment below!