One of the foundational services for any network is the Domain Name System (DNS), and the bigger the network, the harder DNS becomes to manage.
You may have heard the saying, âThe shoemaker's son always goes barefootâ⊠or maybe you havenât
If youâre anything like me, youâre always looking for ways to make your home network more âprofessionalâ but never quite get around to it.
Automating DNS is a great introduction to Infrastructure as Code if you want to dip your toes in. Provided you put safeguards in place, and ideally, youâre not editing the files directly!
I once had to manage Bind9 DNS entries for the service provider's management network. This was semi-automated. We edited the configuration file in Git, which was then synced to the DNS servers using Puppet after 30 minutes.
It worked well, until it didnât.
The file we were editing was the Bind9 configuration file, which gets real fussy with spacing or formatting errors
Letâs say, one day I made a change (and left a trailing whitespace) which stopped the service from restarting and took down DNS for the whole management network đ
Letâs try not to do that againâŠ
In this post, Iâm going to run through how I set up DNS on my home network using Ansible
A quick note on Terraform
I chose to use Terraform to manage the underlying infrastructure in my local network, which in this case is a Proxmox Virtual Environment. Terraform creates the containers, installs the OS, sets the IP address, and pushes SSH keys. From this point, Ansible takes over and does the configuration.
I havenât finished writing the post on using Terraform to manage the underlying container, but I will soon!
Why Automate DNS?
Before we dive into the how, here are some reasons you might want to automate your local DNS.
Consistency: Ensure all your servers and devices resolve names the same way, every time.
Repeatability: Easily rebuild or replicate your DNS setup in a new environment.
Version Control: Track changes to your DNS configuration using Git, allowing easy rollbacks and collaboration.
Time Savings: Automate adding, modifying, and removing DNS records.
And learning, of course!
But which DNS service?
There are so many DNS products out there to choose from. I originally had a Pi-Hole setup for this purpose, but I found it difficult to maintain the sync between package upgrades when set up in a redundant setup.
So I went back to an old friend - Bind9 (Berkeley Internet Name Domain)
This allowed me to run two lightweight DNS containers on a service I was already familiar with and automate the creation of entries within the configuration file.
Enough yapping, letâs jump in
Some of this may get complicated if you're unfamiliar with Ansible. I suggest having a look at some basic Ansible labs before proceeding.
The Ansible Workflow
Since weâre using Ansible to manage the configuration files for BIND9. Here's a high-level overview of the workflow:
Inventory: Define the server(s) where we are running BIND9.
Roles: Organise our logic into a reusable Ansible role named (unsurprisingly)
dns
.Variables: Store our DNS-specific data (like domain names, server IPs, and DNS records) in organised variable files.
Templates: Jinja2 templates are used to generate the BIND9 configuration files based on our variables dynamically.
Tasks: Define the steps Ansible needs to take, such as installing BIND9, copying the configuration files, and ensuring the service runs.
Handlers: Define actions to take when specific events occur, like restarting the BIND9 service after a configuration change.
The Directory Structure
âââ iac
â âââ README
â âââ environments
â âââ production
â âââ ansible
â â âââ dns_servers
â â â âââ group_vars
â â â â âââ dns_servers.yml
â â â âââ playbooks
â â â â âââ main.yml
â â â âââ roles
â â â âââ dns
â â â âââ handlers
â â â â âââ main.yml
â â â âââ tasks
â â â â âââ main.yml
â â â âââ templates
â â â âââ db.lando.home.j2
â â â âââ named.conf.local.j2
â â â âââ named.conf.options.j2
â â âââ inventory.yml
The Inventory
For now, the inventory file is just a static file that contains a group of dns_servers
.
---
dns_servers:
hosts:
dns1:
ansible_host: <ip_address>
ansible_user: root #default for newly created containers
dns2:
ansible_host: <ip_address>
ansible_user: root #default for newly created containers
Building the Role
A role is not needed for a simple DNS playbook; the tasks outlined in the role could be used directly in the playbook. However, I intend for this codebase to become complex pretty quickly( with multiple environments), so to save rework time, I am building the tasks into a reusable role.
Check out the documentation on Roles here đ Ansible Roles
Here is the role structure:
roles
âââ dns
âââ handlers
â âââ main.yml # For restarting the bind9 service
âââ tasks
â âââ main.yml # Main tasks for configuring DNS
âââ templates
âââ db.lando.home.j2 # Forward zone template
âââ named.conf.local.j2 # Main BIND9 configuration template
âââ named.conf.options.j2 # Setting Bind9 Options
Letâs look at each file individually:
handlers/main.yml
This file contains a simple task to restart the bind9 service. It is defined in a separate handler file to ensure that the service is not restarted every time we run the playbook, just when something changes.
We use this with the notify
flag in the main task to trigger when it changes.
---
- name: restart bind9
service:
name: bind9
state: restarted
tasks/main.yml
This file defines the main tasks to set up DNS on the servers.
---
- name: Set serial number
set_fact:
zone_serial: "{{ ansible_date_time.epoch }}"
- name: Install Bind9
apt:
update_cache: true
name: bind9
state: present
- name: Create named.conf.local
template:
src: named.conf.local.j2
dest: /etc/bind/named.conf.local
# notify: restart bind9
- name: Create named.conf.options
template:
src: named.conf.options.j2
dest: /etc/bind/named.conf.options
- name: Create db.example.com zone file
template:
src: db.lando.home.j2
dest: /etc/bind/db.lando.home
notify:
- restart bind9
- name: Ensure Bind9 is running and enabled
service:
name: bind9
state: started
enabled: true
templates/db.lando.home.j2
If you are familiar with bind9, you will recognise the naming of this J2 file. This file will create the db.lando.home
bind9 database to hold the DNS records.
đ Lando.home is the internal domain name here
Notice I am using variables in this file; we will define the values as part of the group_vars file later.
$TTL 86400
@ IN SOA {{ansible_hostname}}.{{domain_name}}. {{ dns_admin_email }}. (
{{ zone_serial }} ; Serial
3H ; Refresh after 3 hours
1H ; Retry after 1 hour
1W ; Expire after 1 week
1D ) ; Minimum TTL of 1 day
;
@ IN NS {{ansible_hostname}}.{{domain_name}}.
{{ansible_hostname}} IN A {{ ansible_default_ipv4.address }} ; DNS server's IP
@ IN A {{ ansible_default_ipv4.address }} ; DNS server's IP
{% if a_records %}
{% for record in a_records %}
{{ record.name }} IN A {{ record.ip }}
{% endfor %}
{% endif %}
templates/named.conf.local.j2
This is our template for the Bind9 local zone declaration file.
I could templatise this to define the zones in variables, but I donât need to right now.
zone "lando.home" {
type master;
file "/etc/bind/db.lando.home";
};
templates/named.conf.options.j2
This is the template file for options such as setting up the allowed networks to query this DNS server.
acl allowednetworks {
{% if allowed_networks %}
{% for net in allowed_networks -%}
{{ net }};
{% endfor %}
{% endif %}
localhost;
localnets;
};
options {
directory "/var/cache/bind";
recursion yes;
allow-query { allowednetworks; };
{% if dns_forwarders %}
forwarders {
{% for forwarder_ip in dns_forwarders %}
{{ forwarder_ip }};
{% endfor %}
};
forward only;
{% endif %}
};
Defining the variables
Given we have templatised this to look for variables when we run the playbook, we need to give Ansible some variables.
Variables can be defined in several places, but I am using group variables because both my DNS servers will be configured the same way.
group_vars/dns_servers.yml
dns_server_ip: "{{ ansible_host }}"
dns_forwarders: # Choose your preferred public DNS servers
- "1.1.1.1"
- "8.8.8.8"
allowed_networks:# Set the networks that can query the DNS server.
- "10.0.0.0/8"
- "172.16.0.0/12"
- "192.168.0.0/16"
dns_admin_email: hostmaster.lando.home
domain_name: lando.home
a_records:
- name: fw1
ip: <ip_address>
- name: sw1
ip: <ip_address>
- name: ap1
ip: <ip_address>
Running the Playbook
To apply this configuration to your DNS server, run the Ansible playbook:
ansible-playbook -i inventory.yml your_playbook.yml
Replace inventory.yml
with your Ansible inventory file and your_playbook.yml
with the playbook that applies the dns
role to your DNS server(s).
Conclusion
There we have it. This creates two DNS servers with consistent configuration for high availability that can be set up with a single command.
This is just a starting point, and I plan to build my inventory dynamically.
My next task is to expand this to incorporate managing my Unifi Wireless through code, and I'll share my progress in a post!
Thanks for reading, and I hope you found this helpful.