Automatically create bind service with forward and reverse zones out of ansible inventory

Ansible has a list of hosts (inventory) and may be configured to store not only the FQDN but also the IP address of the exact host. I thought of this fact when creating my private DNS resolver and decided to fully automate the creation of a dns service and creation and maintainance of the forward and reverse zone files. This is what I achieved when working with a bind9 dns server.

The following files represent my dns ansible role; therefore the inventory where the role gets information, additional variables, the tasks of the role and the configuration templates. I will explain them step by step.

Inventory

The inventory that is relevant for the role consists of two groups: managed and dns. The group managed contains all servers of this ansible repository and their ip addresses. The group dns contains all dns servers. The first host in the group dns will be the master dns server.

[managed]
mgmt.pve.lithilion.at ansible_host="10.158.151.10"
squid.pve.lithilion.at ansible_host="10.158.150.10"
dsn-computing-m.dmz.lithilion.at ansible_host="192.168.178.7"
ns.pve.lithilion.at ansible_host="10.158.151.11"
ns2.pve.lithilion.at ansible_host="10.158.151.12"
ns3.pve.lithilion.at ansible_host="10.158.151.13"

[dns]
ns.pve.lithilion.at
ns2.pve.lithilion.at
ns3.pve.lithilion.at

Vars

These variables serve different purposes.

The variable group_var_admin_mail may be used in multiple roles. It is used in the dns role as the administrative email address of the zone files. Any "@" will be converted properly to "." as required in the zone file.

The variable role_var_dns_manualentry may be used to further configure dns entries to the server. The role automatically configures A and PTR records for all hosts in the managed group. Any additional A and PTR records, as well as any other type of record (e.g.: CNAME, CAA, etc.) may be configured here. Please note, that only records are created, where zones are created through the role. Zones are created for hosts in the managed group. Any entries that do not fit into any of these zones will be ignored. If you want to create zones that have no managed hosts, but the only entries for that zone are in the role_var_dns_manualentry variable, you need to set role_var_dns_manualzonecreation: true. This is an optional variable
The given example will create a dns entry of example.dmz.lithilion.at pointing to the A record 1.1.1.1. The CNAME record of test.example.com to example.dmz.lithilion.at will be ignored (if role_var_dns_manualzonecreation is not set to true) because there is no zone example.com in the managed hosts.

group_var_admin_mail: admin@example.at
role_var_dns_manualzonecreation: true # optional 
role_var_dns_manualentry:
  ## Examples
  - hostname: test.example.com
    type: CNAME
    entry: example.dmz.lithilion.at
  - hostname: example.dmz.lithilion.at
    entry: 1.1.1.1

Tasks

Lets take a look at the tasks.

The set_fact tasks gather information from the inventory about the forward and reverse zones, flatten the json and remove duplicates. With the given inventory the variable dns_zones results in [pve.lithilion.at] and [dmz.lithilion.at], the variable dns_reverse_zones results in [10.158.150], [10.158.151], and [192.168.178]. The variable is only interested in the group managed. This group contains all my managed hosts. Please note, that these are the only zones you could edit with the variable role_var_dns_manualentry.

The package task ensures the installation of the bind9 service and useful additional packages.

copy config template persists configuration files for the bind9 service. named.conf.options serves as global configuration while named.conf.local configures the available zones and their locations. The configuration files will be explained in section Templates. The validate parameter serves as config check. When editing the config, it tries to validate the config. If the configuration is bad (e.g.: syntax error) it will fail the ansible run, therefore it does not alter the remote files and keep the bind9 in a working state. Please note, that the validate parameter does not work in check mode!

Next comes the correct configuration of the zone files itself. The bind service requires the serial number in the zone files to increment in order to notify changes in the zone file. The command therefore tries to read the current serial number per zonefile. This will also be executed when in check mode (check_mode: false), as otherwise the zone file update will display wrong results in checkmode. Next, the zone files will be configured based on the gathered variables dns_zones and dns_reverse_zones. After that, the serial numbers will be incremented, but only if a change in the zone update task was noticed. Please note, that all zone file serials will get updated, no matter how many files got updated. This is a bug in my implementation, but I have no urge to fix this. If you have a quick fix, please let me know.

---

- name: configure dns server
  block:

  - name: create list of dns zones by managed hosts
    set_fact:
      dns_zones:
        - "{{ dns_zones | default([]) + [item.split('.')[1:]|join('.')] }}"
    with_items:
      - "{{ groups['managed'] }}"

  - name: create list of dns zones by manual entries
    set_fact:
      dns_zones:
        - "{{ dns_zones | default([]) + [item.hostname.split('.')[1:]|join('.')] }}"
    with_items:
      - "{{ role_var_dns_manualentry }}"
    when:
      - role_var_dns_manualzonecreation is defined and role_var_dns_manualzonecreation is true
      - (not item.type is defined) or (item.type is defined and item.type  != "PTR")

  - name: create list of dns reverse zones by managed hosts
    set_fact:
      dns_reverse_zones:
        - "{{ dns_reverse_zones | default([]) + [hostvars[item]['ansible_host'].split('.')[:3]|join('.')] }}"
    with_items: "{{ groups['managed'] }}"

  - name: create list of dns reverse zones by manual entries
    set_fact:
      dns_reverse_zones:
        - "{{ dns_reverse_zones | default([]) + [item.hostname.split('.')[:3]|join('.')] }}"
    with_items:
      - "{{ role_var_dns_manualentry }}"
    when:
      - role_var_dns_manualzonecreation is defined and role_var_dns_manualzonecreation is true
      - item.type is defined and item.type  == "PTR"

  - name: flatten and unique zones
    set_fact:
      dns_zones: "{{ dns_zones | flatten | unique }}"
      dns_reverse_zones: "{{ dns_reverse_zones | flatten | unique }}"

  - name: install package
    package:
      name: "{{ item }}"
    with_items:
      - bind9
      - bind9utils
      - bind9-doc

  - name: copy config templates
    template:
      src: "{{ item }}.j2"
      dest: "/etc/bind/{{ item }}"
      mode: 0644
      owner: root
      group: root
      validate: named-checkconf %s
    with_items:
      - named.conf.options
      - named.conf.local
    vars:
      zones: "{{ dns_zones | default([]) + dns_reverse_zones }}"
    notify: restart dns

  - name: get serial number
    command:
      cmd: /bin/bash -c "echo $(grep Serial /etc/bind/db.{{ item }} | grep -oP '\d+');"
    register: current_zone_serial
    changed_when: false
    check_mode: false
    with_items:
      - "{{ dns_zones }}"
      - "{{ dns_reverse_zones }}"

  - name: copy zone templates
    template:
      src: "db.generic.j2"
      dest: "/etc/bind/db.{{ item }}"
      mode: 0644
      owner: root
      group: root
      validate: "named-checkzone {{ item }} %s"
    vars:
      current_serial: "{{ current_zone_serial.results }}"
      current_zone: "{{ item }}"
    with_items:
      - "{{ dns_zones }}"
      - "{{ dns_reverse_zones }}"
    register: increment_serials

  - name: increment serial
    shell: 'old=$(grep Serial {{ item.invocation.module_args.dest }} | grep -oP "\d+"); new=$((old+1)); sed -i "s/ ${old} / ${new} /g" {{ item.invocation.module_args.dest }}'
    with_items: "{{ increment_serials.results }}"
    when: item.changed
    register: increment
    changed_when: true
    notify: reload dns

  when:
    - "'dns' in group_names"

Handlers

The handlers are quite straight forward. The first one restarts (not reloads) the bind service. The second one reloads the zone files into the bind service.

---

- name: restart dns
  service:
    name: bind9
    state: restarted
    enabled: true

- name: reload dns
  command:
    cmd: rndc reload

Templates

The templates get explained that are transferred to the remote hosts and filled with variable values.

named.conf.options.j2

This file configures the bind service globally. I guess the configuration speaks for itself. The only variable magic going on is the setting of the listening address, which always results in the primary IPv4 address.

# {{ ansible_managed }}

options {
	directory "/var/cache/bind";

	// If there is a firewall between you and nameservers you want
	// to talk to, you may need to fix the firewall to allow multiple
	// ports to talk.  See http://www.kb.cert.org/vuls/id/800113

	// If your ISP provided one or more IP addresses for stable
	// nameservers, you probably want to use them as forwarders.
	// Uncomment the following block, and insert the addresses replacing
	// the all-0's placeholder.

	// forwarders {
	//	0.0.0.0;
	// };

	//========================================================================
	// If BIND logs error messages about the root key being expired,
	// you will need to update your keys.  See https://www.isc.org/bind-keys
	//========================================================================
	dnssec-validation auto;

	listen-on-v6 { any; };

	recursion yes;
	allow-recursion { trusted; };
	listen-on { {{ ansible_default_ipv4.address }}; };
        allow-transfer { none; };
};

named.conf.local.j2

This configuration file configures the zone parameters, which role the server has (master/slave) and where the other peers are. If there is only one host in the dns group in the inventory, it will be the master host and no transfers will be allowed. If there are more hosts, the first one will be the master, all others will be slaves. Transfers will be allowed to all hosts, except the current host. All zone files will be placed at /etc/bind/db.<ZONE>

# {{ ansible_managed }}

{% for item in zones %}
{% if item | ansible.utils.ipaddr %}
zone "{{ ((item + '.1') | community.dns.reverse_pointer).split('.')[1:] | join('.') }}" {
{% else %}
zone "{{ item }}" {
{% endif %}
{% if inventory_hostname == groups['dns'][0] %}
	type master;
{% else %}
	type slave;
{% endif %}
	file "/etc/bind/db.{{ item }}";	# file path
{% if inventory_hostname == groups['dns'][0] %}
	allow-transfer {
{% for item in groups['dns'] %}
{% if groups['dns']|length == 1 %}
			none;
{% elif inventory_hostname != item %}
			{{ hostvars[item]['ansible_host'] }};
{% endif %}
{% endfor %}
			};
{% else %}
	masters {
			{{ hostvars[groups['dns'][0]]['ansible_host'] }};
		};
{% endif %}
};

{% endfor %}

db.generic.j2

This is where the major magic happens. The zone file. There is only one template for both, forward and reverse zones. It will always declare the master dns host as authority and enter the given emailaddress. All hosts in the dns group will be declared as name server in all zone files. Then the entries generated from the inventory are created. The reverse zones are always /24 zones. After the automatically generated entries the manually added entries from the variable are added if the fit into the zone. The template generates A and PTR entries for all hosts in the group managed. All other entries (e.g.: additional A and PTR records, CNAME, CAA, SRV, etc.) need to be added to the variable.

; {{ ansible_managed }}
$TTL    604800
@       IN      SOA     {{ groups['dns'][0] }}. {{ group_var_admin_mail | replace("@",".") }}. (
{% for item in current_serial %}
{% if item.item == current_zone %}
{% if item.stdout == "" %}
                              1         ; Serial
{% else %}
                              {{ item.stdout }}         ; Serial
{% endif %}
{% endif %}
{% endfor %}
                         604800         ; Refresh
                          86400         ; Retry
                        2419200         ; Expire
                         604800 )       ; Negative Cache TTL
;
{% for item in groups['dns'] %}
       IN      NS      {{ item }}.       ; name server
{% endfor %}
; automatically generated
{% for item in groups['managed'] %}
{% if current_zone | ansible.utils.ipaddr %}
{% if current_zone == hostvars[item]['ansible_host'].split('.')[:3]|join('.') %}
{{ hostvars[item]['ansible_host'].split('.')[-1][-2:] }}	IN	PTR 	{{ item }}	; {{ hostvars[item]['ansible_host'] }}
{% endif %}
{% else %}
{% if current_zone == item.split('.')[1:]|join('.') %}
{{ item }}.	IN	A	{{ hostvars[item]['ansible_host'] }}
{% endif %}
{% endif %}
{% endfor %}
; manually added through variable
{% if role_var_dns_manualentry is defined %}
{% for item in role_var_dns_manualentry %}
{% if item.hostname | ansible.utils.ipaddr %}
{% if current_zone == item.hostname.split('.')[:3]|join('.') %}
{{ item.hostname.split('.')[-1][-2:] }}	IN	PTR	{{ item.entry }}	; {{ item.hostname }}
{% endif %}
{% else %}
{% if current_zone == item.hostname.split('.')[1:]|join('.') %}
{{ item.hostname}}.	IN 	{{ item.type | default('A') }}	{{ item.entry }}
{% endif %}
{% endif %}
{% endfor %}
{% endif %}

(Known) Limitations

Read more

Previous Blog Entry


Last update: 2025-04-16