Personal DNS Setup with Bind Views

Spencer Colton
10 min readNov 16, 2022

All of the domain names and IP addresses in this article have been changed from the actual domains/IPs in use in my networks.

I have many servers that I host locally in my apartment, and it’s nice to access many of these externally. At the moment, I accomplish this with a combination of port-forwarding, custom DNS records, and Apache proxying.

The current state of things.

My router (a UDM Pro) has an internal DNS server that allows me to set up a local domain (local.example.com) and will resolve hostnames that it recognizes against that domain. Internally, I connect to each server directly, but from outside, I connect to them all via one server to which all HTTP(S) requests are forwarded by the router, which then proxies to the internal servers based on the provided HTTP “Host” header.

I manage to use the same domain name inside and outside the private network by setting up a wildcard CNAME record for the local domain at my registrar that will resolve any *.local.example.com to my external IP.

Really, there’s another layer of indirection here. *.local.example.com is a CNAME pointing to a DDNS record (courtesy of No-IP) so that subdomains of local.example.com are always correctly resolved to my external IP.

For now, this works fine, but there are a few issues:

  1. That external wildcard CNAME record on *.local.example.com causes some weird resolution issues on the private network. What is a host to do when it tries to resolve server1.local.example.com and it gets a direct answer of 192.168.1.20 but also sees that *.local.example.com ends up resolving to 2.2.2.2 ? The behavior is inconsistent and often undesired.
  2. Adding custom DNS records is very difficult with this setup. The UDM Pro doesn’t have any mechanism by which I can do this — it’s all based on hostname alone.
  3. This setup doesn’t work for reverse DNS lookups — the UDM Pro doesn’t serve up PTR records.

The ideal would be that records unambiguously resolve to internal IPs for any DNS request that comes from the internal network and all resolve to the public IP if they come from the public internet.

My conclusion is that I need to set up a custom DNS server and delegate responsibility for local.example.com to this DNS server.

Concerns

I’ve considered the custom DNS solution before, but it has always seemed risky to me. Hosting a DNS server on my internal network would be great for speedy DNS resolutions within the network, but the broader internet will also be reliant on this server being consistently accessible in order for these look-ups to work properly.

Moreover, the major advantage of using the UDM Pro for DNS is that all devices that connect to the network are automatically resolvable by their hostname without my intervention. Having to manually manage the records for each host on the internal network would be unacceptable.

Luckily, I have been able to come up with a solution that addresses both of these issues.

Solution Design

The proposed solution. (Internal web application servers eliminated from this view for simplicity.)

The DNS server bind has three key features and qualities that make it a good fit to solve these problems.

  1. bind allows replicating zone data between sites.
  2. bind supports “views,” which can change what data is returned for a given query based on information about the client.
  3. bind backend data files are in plaintext, so writing a script to generate A/PTR records for hosts already on the network should be possible.

High-availability is achievable with this setup by hosting one nameserver locally within the private network and using this as the primary, but also putting one nameserver in the cloud that will accept updates from this primary nameserver. Both will be listed as nameservers for local.example.com so that they can provide redundancy.

bind views will be set up on each so that clients internal to my private network will be served private addresses for queries under local.example.com and clients outside of the internal network will see only the public IP — regardless of which nameserver is queried.

A script can be set up to run periodically that will use the (unofficial) UDM Pro API to query devices currently on the network, regenerate the zone files, reload the zones in the primary DNS server, and cause the data to replicate out to the remote server.

Implementation

Bind — Local Server

I took a lot of my bind setup from this fantastic guide, since I hadn’t touched bind at all before doing this project.

Once bind is installed, the very first things to do are adjust the logging (to save some grief later) and get rid of some of the default configuration.

// https://oitibs.com/bind9-logs-on-debian-ubuntu/
// /etc/bind/named.conf.log

logging {
channel bind_log {
file "/var/log/bind/bind.log" versions 3 size 5m;
severity info;
print-category yes;
print-severity yes;
print-time yes;
};

category default { bind_log; };
category update { bind_log; };
category update-security { bind_log; };
category security { bind_log; };
category queries { bind_log; };
category lame-servers { null; };
};
// /etc/bind/named.conf

include "/etc/bind/named.conf.log";
include "/etc/bind/named.conf.options";
include "/etc/bind/named.conf.local";

Note that I got rid of the line include "/etc/bind/named.conf.default-zones"; since we don’t need those for this project. Create /var/log/bind as well and ensure that the bind service account has read/write privileges.

If you’re using a machine that uses apparmor, be sure to adjust the apparmor settings to allow bind to write to the logs that we just set up, then sudo systemctl restart apparmor:

# /etc/apparmor.d/usr.sbin.named

# ...

/var/log/bind/** rw,
/var/log/bind/ rw,

# ...

Now let’s actually do the fun part — set up the zones. Replace 3.3.3.3 with the correct IP of the remote server:

; /etc/bind/named.conf.local

key internal-key {
algorithm HMAC-MD5;
secret a-secret;
};

key external-key {
algorithm HMAC-MD5;
secret another-secret;
};

acl internal {
!key external-key;
key internal-key;
192.168.0.0/24;
127.0.0.0/8;
localhost;
};

view "internal" {
match-clients { internal; };

allow-recursion { any; };

allow-transfer { 3.3.3.3; key internal-key; };

zone "local.example.com" {
type primary;
file "/etc/bind/internal/db.local.example.com";
also-notify { 3.3.3.3 key internal-key; };
};

zone "1.168.192.in-addr.arpa" {
type master;
file "/etc/bind/internal/db.192.168.1";
also-notify { 3.3.3.3 key internal-key; };
};
};

view "external" {
match-clients { any; };

allow-recursion { any; };

allow-transfer { 3.3.3.3; key external-key; };

zone "local.example.com" {
type primary;
file "/etc/bind/external/db.local.example.com";
also-notify { 3.3.3.3 key external-key; };
};
};

Pretty simply, we’ve got an ACL here that should match on all the IPs on our internal network, and then we have two different views — the one presented will depend on whether or not the internal ACL matches.

The allow-transfer directives are what allow our zones to be replicated out to the remote bind server. 3.3.3.3 is the IP of that server.

Anything dealing with the internal-key and external-key is well-explained in the article I used as a source for this, in Example 3.

We’ll also add a couple of options in named.conf.options :

/// /etc/bind/named.conf.options

/// ...
allow-transfer { none; };
notify explicit;
/// ...

It is also prudent to set up forwarders at this point. I used 8.8.8.8 and 8.8.4.4.

This ensures that we’re not allowing replication of our zones by default (we manually re-enable it in each of the zones that we created in the previous step) and notify-explicit ensures that only hosts listed in the also-notify blocks are notified when zones have changes.

Now we need to create the zone files. We create two directories called internal and external within /etc/bind. Our internal view will also serve up PTR records, so it needs a reverse lookup zone in addition to a forward lookup zone. Externally, we’ll only need the forward zone since we don’t own the public IP. We’ll start with the internal zones:

; internal/db.local.example.com
$ORIGIN local.example.com.
$TTL 60
local.example.com. IN SOA ns1.local.example.com. admin.example.com. 1668575356 300 900 604800 60
local.example.com. IN NS ns1.local.example.com.
local.example.com. IN NS ns2.example.com.
ns1 IN A 192.168.1.20
test IN A 192.168.1.40
; internal/db.192.168.1
$TTL 60
@ IN SOA ns1.local.example.com. admin.example.com. 1668575538 300 900 604800 60
IN NS ns1.local.example.com.
IN NS ns2.example.com.
40 IN PTR test.local.example.com.
20 IN PTR ns1.local.example.com.

For more information about the structure of an SOA record, the Wikipedia article is quite good.

Now for the external zone:

; external/db.local.example.com
$TTL 60
$ORIGIN local.example.com.
@ IN SOA examplecom.ddns.net. admin.example.com. 1668575900 300 900 604800 60
@ IN NS examplecom.ddns.net.
@ IN A 2.2.2.2
* IN CNAME examplecom.ddns.net.

We’ll start up bind and run some test queries to see if it works:

$ dig @127.0.0.1 A test.local.example.com.

; <<>> DiG 9.16.1-Ubuntu <<>> @127.0.0.1 A test.local.example.com.
; (1 server found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 56876
;; flags: qr aa rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 4096
; COOKIE: dd4205d5e9f12ac30100000063747510e431aeb3d0d0e8ef (good)
;; QUESTION SECTION:
;test.local.example.com. IN A

;; ANSWER SECTION:
test.local.example.com. 60 IN A 192.168.1.40

;; Query time: 0 msec
;; SERVER: 127.0.0.1#53(127.0.0.1)
;; WHEN: Tue Nov 15 23:28:48 CST 2022
;; MSG SIZE rcvd: 95


$ dig @127.0.0.1 -x 192.168.1.40

; <<>> DiG 9.16.1-Ubuntu <<>> @127.0.0.1 -x 192.168.1.40
; (1 server found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 42305
;; flags: qr aa rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 4096
; COOKIE: 45d77184890452f80100000063747580804116545f8a83e4 (good)
;; QUESTION SECTION:
;40.1.168.192.in-addr.arpa. IN PTR

;; ANSWER SECTION:
40.1.168.192.in-addr.arpa. 60 IN PTR test.local.example.com.

;; Query time: 0 msec
;; SERVER: 127.0.0.1#53(127.0.0.1)
;; WHEN: Tue Nov 15 23:30:40 CST 2022
;; MSG SIZE rcvd: 118

Looking good!

Bind — Remote Server

Now we have to set up the remote server. We’ll make the same changes to logging (including apparmor), the forwarders, the default zones, and the directives about allow-transfer and notify explicit. You’ll need to create the /etc/bind/internal and /etc/bind/external folders, and then add the following lines to /etc/apparmor.d/usr.sbin.named (and restart apparmor, if that applies to you):

# ...  
/etc/bind/internal/** rw,
/etc/bind/external/** rw,
# ...

And here’s the zone configuration (replace 2.2.2.2 with the correct public IP):

; /etc/bind/named.conf.local

key internal-key {
algorithm HMAC-MD5;
secret a-secret;
};

key external-key {
algorithm HMAC-MD5;
secret another-secret;
};

acl internal {
!key external-key;
key internal-key;
2.2.2.2/32;
localhost;
};

view "internal" {
match-clients { internal; };

allow-recursion { any; };

zone "local.example.com" {
type secondary;
masters { 2.2.2.2 key internal-key; };
file "/etc/bind/internal/db.local.example.com";
};

zone "1.168.192.in-addr.arpa" {
type secondary;
masters { 2.2.2.2 key internal-key; };
file "/etc/bind/internal/db.192.168.1";
};
};

view "external" {
match-clients { any; };

allow-recursion { any; };

zone "local.example.com" {
type secondary;
masters { 2.2.2.2 key external-key; };
file "/etc/bind/external/db.local.example.com";
};
};

And now start bind ! Almost immediately, /etc/bind/internal and /etc/bind/external should fill with data copied from the primary server. We can run some test queries:

$ dig @127.0.0.1 A test.local.example.com.

; <<>> DiG 9.16.1-Ubuntu <<>> @127.0.0.1 A test.local.example.com.
; (1 server found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 19092
;; flags: qr aa rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 4096
; COOKIE: a237861b4d6a01d30100000063747a06731868214f333fec (good)
;; QUESTION SECTION:
;test.local.example.com. IN A

;; ANSWER SECTION:
test.local.example.com. 60 IN A 192.168.1.40

;; Query time: 0 msec
;; SERVER: 127.0.0.1#53(127.0.0.1)
;; WHEN: Tue Nov 15 23:49:58 CST 2022
;; MSG SIZE rcvd: 95

$ dig @127.0.0.1 -x 192.168.1.40

; <<>> DiG 9.16.1-Ubuntu <<>> @127.0.0.1 -x 192.168.1.40
; (1 server found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 5714
;; flags: qr aa rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 4096
; COOKIE: 32be5cb3db82822b0100000063747a10b18f42ad32fe9a61 (good)
;; QUESTION SECTION:
;40.1.168.192.in-addr.arpa. IN PTR

;; ANSWER SECTION:
40.1.168.192.in-addr.arpa. 60 IN PTR test.local.example.com.

;; Query time: 0 msec
;; SERVER: 127.0.0.1#53(127.0.0.1)
;; WHEN: Tue Nov 15 23:50:08 CST 2022
;; MSG SIZE rcvd: 118

We can also confirm that running forward queries from the primary server against the remote server still shows internal records, but running from any machine outside the local network (except for the remote nameserver) shows only the external records.

Registrar Configuration

In order to make this work, we’ll need to go to our domain’s DNS settings and add a new NS record that points local.example.com to examplecom.ddns.net (a pointer to our public IP). It is probably also worth adding an A record (e.g., ns2.example.com) to point at the secondary DNS server and then add another NS record to refer to this A record.

Automating Updating the Zone File

I wrote a quick and dirty script to automate fetching data from the UDM Pro to generate forward and reverse zone files for both the internal and external views. I am not going to dwell on the details here since this part will be very specific to one’s own setup and will require massive customization for different routers/DHCP servers.

I run this script every three minutes. Another machine on my private network happened to have TeamCity running on it, so I added this job to that so that I can track the runs. The job also runs these commands to reload the zone and cause it to replicate:

rndc reload local.example.com in external
rndc reload local.example.com in internal
rndc reload 1.168.192.in-addr.arpa in internal

Results

I have only had this up for a few days but it seems to be working very well! I have been experimenting with making DNS requests from different locations and checking the results, and removing devices from my network to see if their DNS records disappear. Nothing unexpected so far.

Future Development

  • Clean up the script. Make it cleaner and more abstracted.
  • Create some sort of web interface to add custom DNS records so that the script doesn’t have to be modified to include them.
  • Don’t run the script so often — only when data have changed.

--

--