Skip to content

Instantly share code, notes, and snippets.

@mgedmin
Created September 4, 2024 11:24
Show Gist options
  • Save mgedmin/e446fa30c8a02f4a51fa5d1ebdec9ab6 to your computer and use it in GitHub Desktop.
Save mgedmin/e446fa30c8a02f4a51fa5d1ebdec9ab6 to your computer and use it in GitHub Desktop.
Managing DNS servers with Ansible
# filter_plugins/dns.py
import dns.zone # pip install dnspython or pipx inject ansible dnspython
import dns.resolver
import dns.flags
def get_dns_serial(zone, ns=None):
resolver = dns.resolver.Resolver(configure=True)
resolver.use_edns(0, ednsflags=dns.flags.DO, payload=4096)
if ns:
resolver.nameservers = [
dns.resolver.query(ns)[0].address
]
answer = resolver.query(zone, 'SOA', rdclass='IN')
return answer[0].serial
def get_dns_serials(nameservers, zone):
return {
ns: get_dns_serial(zone, ns)
for ns in nameservers
}
def parse_zone(zone_text, origin=None):
"""Parse a DNS zone file."""
zone = dns.zone.from_text(zone_text, origin=origin)
soa = zone.find_rdataset('@', 'SOA')[0]
serial = soa.serial
primary_ns = soa.mname.derelativize(zone.origin).to_text()
nameservers = [
record.to_text(zone.origin, relativize=False)
for record in zone.find_rdataset('@', 'NS')
]
return dict(serial=serial, primary_ns=primary_ns,
nameservers=nameservers)
class FilterModule(object):
def filters(self):
return {
'get_dns_serial': get_dns_serial,
'get_dns_serials': get_dns_serials,
'parse_zone': parse_zone,
}
# roles/dns-server/defaults/main.yml
---
# List of DNS zones, e.g.
# dns_zones:
# - example.com
# - example.net
# Each should have a corresponding zone file in files/db/dns.{{ zone }}
dns_zones: []
# List of domains for which this server is a secondary NS.
# Each list item needs to specify three things: the zone, the IP of
# the primary NS, and, optionally, the name of the primary NS.
# YAML mapping syntax is abused for this: the key is the zone name,
# the value contains the IP and the hostname, separated by spaces.
# Example:
# secondary_ns_for:
# - example.com: 10.10.10.10 ns.example.com
# - example.net: 192.168.1.1
secondary_ns_for: {}
# roles/dns-server/handlers/main.yml
---
- name: touch changed zone files
# workaround for https://github.com/ansible/ansible/issues/83013
file: path=/etc/bind/db.{{ item }} state=touch
with_items: "{{ zone_file_result.results | selectattr('changed') | map(attribute='item') }}"
- name: reload bind9
service: name=bind9 state=reloaded
# roles/dns-server/tasks/check.yml
---
- name: compare zone files
copy: src=dns/db.{{ item }} dest=/etc/bind/db.{{ item }}
check_mode: yes
with_items: "{{ dns_zones }}"
register: zone_result
tags: [ bind, check ]
- name: get current serial
assert:
quiet: yes
that:
- new_serial|int > current_serial|int
msg: >
{{ zone }} new serial ({{ new_serial }}) must be greater than the current
serial ({{ current_serial }})
vars:
zone: "{{ item.item }}"
abs_zone: "{{ zone }}."
zone_file: "dns/db.{{ zone }}"
new_zone: "{{ lookup('file', zone_file)|parse_zone(abs_zone) }}"
new_serial: "{{ new_zone.serial }}"
primary_ns: "{{ ansible_fqdn }}"
current_serial: "{{ lookup('dig', abs_zone, 'qtype=SOA', '@' ~ primary_ns).split()[2]|int }}"
when: item.changed
with_items: "{{ zone_result.results }}"
loop_control:
label: "{{ zone }}"
tags: [ bind, check ]
# roles/dns-server/tasks/main.yml
---
- name: install bind9
apt: name=bind9 state=present
tags: apt
- import_tasks: check.yml
tags: [ bind, check ]
- name: zone files
copy: src=dns/db.{{ item }} dest=/etc/bind/db.{{ item }}
with_items: "{{ dns_zones }}"
register: zone_file_result
notify:
- touch changed zone files
# TBH I'm not sure this is needed
- reload bind9
tags: bind
- name: /etc/bind/named.conf.local
template: dest=/etc/bind/named.conf.local src=named.conf.local.j2
notify:
- reload bind9
tags: bind
- name: make sure bind9 is enabled
service: name=bind9 state=started
tags: bind
- meta: flush_handlers
- import_tasks: validate.yml
ignore_errors: "{{ ansible_check_mode }}"
tags: [ bind, validate ]
# roles/dns-server/tasks/validate.yml
---
- name: validate DNS servers
assert:
that:
- parsed_zone.primary_ns == primary_ns
- primary_ns in parsed_zone.nameservers
- dns_serial == zone_serial
- all_serials.values()|select('!=', zone_serial|int)|list == []
msg: |
Expected primary_ns to be '{{ primary_ns }}', it was '{{ parsed_zone.primary_ns }}'.
Serial in zone file is {{ zone_serial }}, {{ primary_ns }} reports {{ dns_serial }}.
Nameservers reported in the zone file are {{ parsed_zone.nameservers }}.
Serials reported by the nameservers are {{ all_serials }}.
quiet: yes
success_msg: |
Expected primary_ns to be '{{ primary_ns }}', it was '{{ parsed_zone.primary_ns }}'.
Serial in zone file is {{ zone_serial }}, {{ primary_ns }} reports {{ dns_serial }}.
Nameservers reported in the zone file are {{ parsed_zone.nameservers }}.
Serials reported by the nameservers are {{ all_serials }}.
vars:
zone: "{{ item }}"
abs_zone: "{{ zone }}."
zone_file: "dns/db.{{ zone }}"
parsed_zone: "{{ lookup('file', zone_file)|parse_zone(abs_zone) }}"
zone_serial: "{{ parsed_zone.serial }}"
primary_ns: "{{ ansible_fqdn }}."
dns_serial: "{{ abs_zone|get_dns_serial(primary_ns) }}"
all_serials: "{{ parsed_zone.nameservers|get_dns_serials(abs_zone) }}"
with_items: "{{ dns_zones }}"
register: validation_result
tags: [ bind, validate ]
// roles/dns-server/templates/named.conf.local.j2
//
// Do any local configuration here
//
// Consider adding the 1918 zones here, if they are not used in your
// organization
//include "/etc/bind/zones.rfc1918";
{% for zone in dns_zones %}
zone "{{ zone }}" {
type master;
file "/etc/bind/db.{{ zone }}";
};
{% endfor %}
{% for zoneinfo in secondary_ns_for %}
{% set zone = zoneinfo.keys()|first %}
{% set master = zoneinfo.values()|first %}
{% set master_ip = master.partition(' ')[0] %}
{% set master_name = master.partition(' ')[-1].strip() %}
zone "{{ zone }}" {
type slave;
file "db.{{ zone }}";
{% if master_name %}
masters { {{ master_ip }}; }; // {{ master_name }}
{% else %}
masters { {{ master_ip }}; };
{% endif %}
};
{% endfor %}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment