How to run a split DNS server

Suppose that you're in an enterprise setting, where you want to run your own DNS server, without having to rely on an external party to do so. Furthermore, let's suppose that you want to return certain records only to the people within your internal network. Maybe you even want a configuration in which different records get returned for the same domain name, depending on whether someone is inside or outside of your network.

Actually, a while ago i wanted to implement something like that for GitLab in an enterprise setting, where we'd have a proxy server for external connections, to more finely control which API endpoints would be allowed to be accessed, as well as to add additional logging and rate limiting in the middle:

gitlab split dns example

Sadly, that tutorial never came to be, because we decided to use separate container registries for our needs, thus making the infrastructure more simple, but i do believe that at least the split DNS bits are worthy of a tutorial!

Setting up the nameserver DNS records

To be able to use split DNS, we'll set up our own DNS server and tell our registrar to use it for our domain. Normally, you'd have at least 2 nameservers for better resilience, but in this case, for testing purposes, we'll use just one, which will also be installed on the proxy server and thus will be publically accessible.

First, let's tell our domain provider (in this case i'm using Namecheap), that we'd like to use our own name server:

adding a name server

Then, the name server should also be registered in their UI, as follows:

our name server

What happened behind the scenes, was that we just created a glue record which will allow us to run our own name servers and manage DNS records for our domain by ourselves. Of course, doing just this won't make anything actually work, since we still need to install the actual software on the server that we just pointed the registrar to!

Also, it's worth noting that most of the time you'll want more than one DNS server, perhaps in a leader-follower topology, to have better redundancy and error tolerance. As a matter of fact, some registrars won't even let you use just one server! However, due to this being just an example, i created a second record ns2.catboi.net, however made it point to the same IP address. You probably will never want to do it in practice, but in my case i really don't need redundancy now.

Installing and configuring the DNS server

So, let's install bind9 and enable the services that we need:

yum install bind bind-utils
systemctl enable named
systemctl start named
systemctl status named

If it has launched successfully and everything looks okay in the status command output, then we can do a little bit of additional configuration by editing the "/etc/named.conf" configuration file:

listen-on port 53 { any; };
listen-on-v6 port 53 { any; };
...
allow-query     { any; };
...
acl internal {
    127.0.0.0/8;
    10.0.0.0/24;
    172.16.0.0/12;
    192.168.0.0/16;

    176.223.132.147/32;
    94.176.237.88/32;
    176.223.140.135/32;
};

First, we told the server to listen to not just the DNS queries from localhost, but from all available network interfaces, both on IPv4 and IPv6. Because our DNS server will be public, this perfectly suits our needs.

Furthermore, we added an access control list, which defines which IP addresses we'll consider to be internal. In this case, it is enough to take some bits from the list of the reserved IP addresses, but i also added the servers' IP addresses to the list so that they'd get forwarded to the servers directly, instead of being directed through the proxy.

Then, let's add views for serving different A records based on whether the request originates from inside of our network, or outside of it:

view "internal" {
    match-clients { internal; };
    zone "catboi.net" {
        type master;
        file "/etc/named/internal/gitlab-and-registry";
    };
};
view "external" {
    match-clients { any; };
    zone "catboi.net" {
        type master;
        file "/etc/named/external/gitlab-and-registry";
    };
};

Now, if the request comes from our internal network, then the DNS records from ".../internal/gitlab-and-registry" will be returned, whereas for all of the IP addresses that have not been matched, the below view will be used and the DNS records from ".../external/gitlab-and-registry" will be returned.

Note: if you need to include any other zones (such as default "/etc/named.rfc1912.zones" and "/etc/named.root.key"), then you'll also want to put them next to our zones, inside of the views! If any zones are found outside of the views, the name server will fail to start!

Then we need to create the mentioned files:

mkdir -p /etc/named/internal
touch /etc/named/internal/gitlab-and-registry
mkdir -p /etc/named/external
touch /etc/named/external/gitlab-and-registry

Once that's done, we can set up the actual DNS records that point to our servers. For example, here's my example internal file, which contains both the server IP addresses (if we ever want to connect to the servers directly, or perhaps have separate records for the actual servers and others for the stuff running on them), as well as the ones for our services:

; catboi.net internal
$ORIGIN catboi.net
$TTL    300
@       IN      SOA     ns1.catboi.net. root.catboi.net. (
   1024 ; Serial
    300 ; Refresh
    300 ; Retry
   1200 ; Expire
    300 ; Minimum TTL
)
                  IN      NS      ns1.catboi.net.
                  IN      NS      ns2.catboi.net.
ns1               IN      A       94.176.237.88
ns2               IN      A       94.176.237.88
@                 IN      A       94.176.237.88
proxy.servers     IN      A       94.176.237.88
git.servers       IN      A       176.223.132.147
registry.servers  IN      A       176.223.140.135
git               IN      A       176.223.132.147
registry          IN      A       176.223.140.135

And here's my external file, which contains only the records for our services, both of which point to the proxy server, which will transparently handle the traffic based on how we desire it to:

; catboi.net external
$ORIGIN catboi.net
$TTL    300
@       IN      SOA     ns1.catboi.net. root.catboi.net. (
   1024 ; Serial
    300 ; Refresh
    300 ; Retry
   1200 ; Expire
    300 ; Minimum TTL
)
                  IN      NS      ns1.catboi.net.
                  IN      NS      ns2.catboi.net.
ns1               IN      A       94.176.237.88
ns2               IN      A       94.176.237.88
@                 IN      A       94.176.237.88
git               IN      A       94.176.237.88
registry          IN      A       94.176.237.88

Of course, your actual TTL, Refresh, Retry, Expire and Minimum TTL values all should probably be a lot bigger, not to put too much strain on your server, but in this particular case i'm setting rather low values so that testing would be more easy. After we've finished the configuration, we can restart the DNS server software and make sure that everything's working as expected:

systemctl restart named
systemctl status named

If it looks to be working, then we can also go over to our domain registrar and ask them to use the servers that we've registered previously and now have configured. After doing so, we'll be informed that the actual changes might take some time to take effect, so misconfiguration here could mean further delays.

name servers changed

We can actually check the propagation status of those records by using tools like DNS Checker to see whether everything is in order. It's likely that initially you'll only see the old records, but after some time they'll slowly be switched over to the new ones:

dns propagation example

Then, we need to check whether our domains resolve as we expect them to, which can be done by using our local computer or any other device which allows us to do DNS lookups. Here, i've set up one of the servers to fit into the ACL and the other to be excluded from it, to illustrate the difference.

Testing and further considerations

You will want to look up one of the DNS records that you've entered into the files above, though when testing the ACL you might want to also ask the name server directly, so that you get the authoritative response and see where the request comes from:

nslookup git.catboi.net # might ask other servers, response will be non-authoritative
nslookup git.catboi.net ns1.catboi.net # will ask directly, response will be authoritative

When you get the authoritative response, the output will look approximately as follows:

split dns example

But you'll notice that i didn't need to specify the name server, as in the example above! That is because you can set the DNS server manually, so that they'll ask it for records directly (thus fitting into the ACL and getting the internal records). If your distribution uses NetworkManager, then you'll probably want to have a look at the "/etc/NetworkManager/system-connections" directory, in which you'll need a file with something like the following:

[ipv4]
...
dns=94.176.237.88;
...

Alternatively, if your distribution doesn't use NetworkManager, you might need to have a look at the "/etc/resolv.conf" file, where the same configuration will be:

# Generated by NetworkManager
...
nameserver 94.176.237.88

You can also see the note, that this file has been generated by NetworkManager in my case, which means that i should not change it, because any changes that i make here might be overwritten.

Keep in mind that you might need to add a firewall rule to let the nameserver be accessed publically:

firewall-cmd --permanent --add-port=53/udp
firewall-cmd --reload

Summary

In the end, such a setup isn't too hard to do, yet is still probably time consuming and a little bit error prone. Nonetheless, it's pretty great that you can host your own split DNS solution by using free software. That said, i still hope that some day we'll get a DNS server that's the equivalent to Caddy in regards to the ease of use.