Adding DNS role

This commit is contained in:
Ian Roddis
2026-04-21 10:07:06 -04:00
parent 3a873051e7
commit 043d315b80
14 changed files with 464 additions and 1 deletions
+6 -1
View File
@@ -25,7 +25,12 @@
"Bash(ansible-playbook:*)", "Bash(ansible-playbook:*)",
"Bash(just test:*)", "Bash(just test:*)",
"Bash(pip show:*)", "Bash(pip show:*)",
"Bash(molecule test:*)" "Bash(molecule test:*)",
"Bash(python3 -m molecule test -s default)",
"Bash(ansible-lint roles/dns/)",
"Bash(ansible-lint roles/graylog/)",
"Bash(ansible-lint .)",
"Bash(grep -v \"^WARNING\\\\|^$\\\\|^A new\\\\|^Upgrade\\\\|^Read\")"
] ]
} }
} }
+20
View File
@@ -136,3 +136,23 @@ smtp_from: "noreply@{{ base_domain }}"
smtp_user: "noreply@{{ base_domain }}" smtp_user: "noreply@{{ base_domain }}"
smtp_password: "changeme_smtp" smtp_password: "changeme_smtp"
smtp_tls: "starttls" smtp_tls: "starttls"
# DNS / BIND9 — authoritative nameserver
bind_version: "9.18-22.04_beta"
# dns_server_ip must be the public IPv4 address of this server.
# Register ns1.{{ base_domain }} as a glue record at your domain registrar
# pointing to this IP, then set your domain's nameservers to ns1.{{ base_domain }}.
dns_server_ip: "changeme_server_public_ip"
dns_ns_hostname: "ns1.{{ base_domain }}"
dns_ttl: 3600
# DKIM — retrieve the public key from the Stalwart admin UI at
# mail.{{ base_domain }} → Settings → DKIM keys after first deployment.
# Leave empty to skip the DKIM TXT record until the key is available.
stalwart_dkim_selector: "default"
stalwart_dkim_public_key: "" # e.g. "MIGfMA0GCSqGSIb3DQEB..."
# DMARC — email authentication policy
dmarc_policy: "quarantine" # none | quarantine | reject
dmarc_rua: "mailto:dmarc-reports@{{ base_domain }}"
dmarc_ruf: "mailto:dmarc-forensics@{{ base_domain }}"
+13
View File
@@ -139,3 +139,16 @@ forgejo_data_dir: /tmp/sovereign_test/forgejo
# Website # Website
website_nginx_version: "alpine" website_nginx_version: "alpine"
website_data_dir: /tmp/sovereign_test/website website_data_dir: /tmp/sovereign_test/website
# DNS / BIND9
bind_version: "9.18-22.04_beta"
dns_server_ip: "192.0.2.1"
dns_ns_hostname: "ns1.test.example.com"
dns_ttl: 3600
dkim_selector: "default"
stalwart_dkim_selector: "default"
stalwart_dkim_public_key: ""
dmarc_policy: "quarantine"
dmarc_rua: "mailto:dmarc-reports@test.example.com"
dmarc_ruf: "mailto:dmarc-forensics@test.example.com"
dns_data_dir: /tmp/sovereign_test/dns
+2
View File
@@ -5,6 +5,8 @@
roles: roles:
- role: common - role: common
tags: [common, traefik] tags: [common, traefik]
- role: dns
tags: [dns, nameserver]
- role: graylog - role: graylog
tags: [graylog, logging] tags: [graylog, logging]
- role: authentik - role: authentik
+2
View File
@@ -0,0 +1,2 @@
---
dns_data_dir: "{{ sovereign_base_dir }}/dns"
+7
View File
@@ -0,0 +1,7 @@
---
- name: Restart BIND9
community.docker.docker_compose_v2:
project_src: "{{ dns_data_dir }}"
state: present
recreate: always
when: not (molecule_test_mode | default(false))
+8
View File
@@ -0,0 +1,8 @@
---
- name: Converge
hosts: localhost
gather_facts: false
vars_files:
- ../../../molecule/shared/vars.yml
roles:
- role: dns
+21
View File
@@ -0,0 +1,21 @@
---
dependency:
name: galaxy
options:
requirements-file: requirements.yml
driver:
name: default
platforms:
- name: localhost
groups:
- sovereign
provisioner:
name: ansible
env:
ANSIBLE_ROLES_PATH: "${MOLECULE_PROJECT_DIRECTORY}/.."
inventory:
host_vars:
localhost:
ansible_connection: local
verifier:
name: ansible
+194
View File
@@ -0,0 +1,194 @@
---
- name: Verify dns role
hosts: localhost
gather_facts: false
vars:
dns_data_dir: /tmp/sovereign_test/dns
tasks:
- name: Check dns data directory exists
ansible.builtin.stat:
path: /tmp/sovereign_test/dns
register: data_dir_stat
- name: Assert dns data directory is present
ansible.builtin.assert:
that: data_dir_stat.stat.isdir
fail_msg: "Data directory /tmp/sovereign_test/dns was not created"
- name: Check dns config directory exists
ansible.builtin.stat:
path: /tmp/sovereign_test/dns/config
register: config_dir_stat
- name: Assert dns config directory is present
ansible.builtin.assert:
that: config_dir_stat.stat.isdir
fail_msg: "Directory /tmp/sovereign_test/dns/config was not created"
- name: Check dns zones directory exists
ansible.builtin.stat:
path: /tmp/sovereign_test/dns/zones
register: zones_dir_stat
- name: Assert dns zones directory is present
ansible.builtin.assert:
that: zones_dir_stat.stat.isdir
fail_msg: "Directory /tmp/sovereign_test/dns/zones was not created"
- name: Check dns cache directory exists
ansible.builtin.stat:
path: /tmp/sovereign_test/dns/cache
register: cache_dir_stat
- name: Assert dns cache directory is present
ansible.builtin.assert:
that: cache_dir_stat.stat.isdir
fail_msg: "Directory /tmp/sovereign_test/dns/cache was not created"
- name: Check named.conf exists
ansible.builtin.stat:
path: /tmp/sovereign_test/dns/config/named.conf
register: named_conf_stat
- name: Assert named.conf was rendered
ansible.builtin.assert:
that: named_conf_stat.stat.exists
fail_msg: "named.conf was not rendered"
- name: Read named.conf
ansible.builtin.slurp:
src: /tmp/sovereign_test/dns/config/named.conf
register: named_conf_raw
- name: Set named.conf content fact
ansible.builtin.set_fact:
named_conf: "{{ named_conf_raw.content | b64decode }}"
- name: Assert zone declaration for base domain in named.conf
ansible.builtin.assert:
that: "'zone \"test.example.com\"' in named_conf"
fail_msg: "Expected zone declaration for test.example.com not found in named.conf"
- name: Assert recursion disabled in named.conf
ansible.builtin.assert:
that: "'recursion no' in named_conf"
fail_msg: "Expected 'recursion no' not found in named.conf"
- name: Check zone file exists
ansible.builtin.stat:
path: /tmp/sovereign_test/dns/zones/test.example.com.zone
register: zone_stat
- name: Assert zone file was rendered
ansible.builtin.assert:
that: zone_stat.stat.exists
fail_msg: "Zone file test.example.com.zone was not rendered"
- name: Read zone file
ansible.builtin.slurp:
src: /tmp/sovereign_test/dns/zones/test.example.com.zone
register: zone_raw
- name: Set zone content fact
ansible.builtin.set_fact:
zone: "{{ zone_raw.content | b64decode }}"
- name: Assert SOA record in zone
ansible.builtin.assert:
that: "'SOA' in zone"
fail_msg: "SOA record not found in zone file"
- name: Assert NS record in zone
ansible.builtin.assert:
that: "'IN NS' in zone"
fail_msg: "NS record not found in zone file"
- name: Assert server IP in zone for ns1 A record
ansible.builtin.assert:
that: "'192.0.2.1' in zone"
fail_msg: "Expected dns_server_ip 192.0.2.1 not found in zone file"
- name: Assert mail A record in zone
ansible.builtin.assert:
that: "'mail IN A' in zone"
fail_msg: "mail A record not found in zone file"
- name: Assert MX record in zone
ansible.builtin.assert:
that: "'IN MX' in zone"
fail_msg: "MX record not found in zone file"
- name: Assert SPF TXT record in zone
ansible.builtin.assert:
that: "'v=spf1 mx ~all' in zone"
fail_msg: "SPF record not found in zone file"
- name: Assert DMARC TXT record in zone
ansible.builtin.assert:
that: "'v=DMARC1' in zone"
fail_msg: "DMARC record not found in zone file"
- name: Assert DMARC policy in zone
ansible.builtin.assert:
that: "'p=quarantine' in zone"
fail_msg: "Expected DMARC policy 'quarantine' not found in zone file"
- name: Assert DMARC rua address in zone
ansible.builtin.assert:
that: "'mailto:dmarc-reports@test.example.com' in zone"
fail_msg: "Expected DMARC rua address not found in zone file"
- name: Assert auth A record in zone
ansible.builtin.assert:
that: "'auth IN A' in zone"
fail_msg: "auth A record not found in zone file"
- name: Assert git A record in zone
ansible.builtin.assert:
that: "'git IN A' in zone"
fail_msg: "git A record not found in zone file"
- name: Check docker-compose.yml exists
ansible.builtin.stat:
path: /tmp/sovereign_test/dns/docker-compose.yml
register: compose_stat
- name: Assert docker-compose.yml was rendered
ansible.builtin.assert:
that: compose_stat.stat.exists
fail_msg: "docker-compose.yml was not rendered for dns"
- name: Read docker-compose.yml
ansible.builtin.slurp:
src: /tmp/sovereign_test/dns/docker-compose.yml
register: compose_raw
- name: Set compose content fact
ansible.builtin.set_fact:
compose: "{{ compose_raw.content | b64decode }}"
- name: Assert bind9 image in compose
ansible.builtin.assert:
that: "'ubuntu/bind9:9.18-22.04_beta' in compose"
fail_msg: "Expected bind9 image not found in docker-compose.yml"
- name: Assert port 53 TCP in compose
ansible.builtin.assert:
that: "'53:53/tcp' in compose"
fail_msg: "Expected port 53/tcp mapping not found in docker-compose.yml"
- name: Assert port 53 UDP in compose
ansible.builtin.assert:
that: "'53:53/udp' in compose"
fail_msg: "Expected port 53/udp mapping not found in docker-compose.yml"
- name: Assert GELF logging address in compose
ansible.builtin.assert:
that: "'udp://127.0.0.1:12201' in compose"
fail_msg: "Expected GELF address udp://127.0.0.1:12201 not found in docker-compose.yml"
- name: Assert sovereign network is external in compose
ansible.builtin.assert:
that: "'external: true' in compose"
fail_msg: "Expected 'external: true' not found in docker-compose.yml"
+49
View File
@@ -0,0 +1,49 @@
---
- name: Create BIND9 directories
ansible.builtin.file:
path: "{{ item }}"
state: directory
mode: '0755'
loop:
- "{{ dns_data_dir }}"
- "{{ dns_data_dir }}/config"
- "{{ dns_data_dir }}/zones"
- "{{ dns_data_dir }}/cache"
- name: Set DNS zone serial from current timestamp
ansible.builtin.set_fact:
dns_zone_serial: "{{ lookup('pipe', 'date +%Y%m%d%H') | int }}"
- name: Deploy named.conf
ansible.builtin.template:
src: named.conf.j2
dest: "{{ dns_data_dir }}/config/named.conf"
mode: '0644'
notify: Restart BIND9
- name: Deploy zone file
ansible.builtin.template:
src: zone.j2
dest: "{{ dns_data_dir }}/zones/{{ base_domain }}.zone"
mode: '0644'
notify: Restart BIND9
- name: Deploy BIND9 docker-compose
ansible.builtin.template:
src: docker-compose.yml.j2
dest: "{{ dns_data_dir }}/docker-compose.yml"
mode: '0644'
notify: Restart BIND9
- name: Start BIND9
community.docker.docker_compose_v2:
project_src: "{{ dns_data_dir }}"
state: present
when: not (molecule_test_mode | default(false))
- name: Wait for BIND9 to be ready
ansible.builtin.wait_for:
host: 127.0.0.1
port: 53
timeout: 30
when: not (molecule_test_mode | default(false))
+25
View File
@@ -0,0 +1,25 @@
services:
bind9:
image: ubuntu/bind9:{{ bind_version }}
container_name: bind9
restart: unless-stopped
environment:
TZ: UTC
ports:
- "53:53/tcp"
- "53:53/udp"
volumes:
- {{ dns_data_dir }}/config/named.conf:/etc/bind/named.conf:ro
- {{ dns_data_dir }}/zones:/var/lib/bind:ro
- {{ dns_data_dir }}/cache:/var/cache/bind
networks:
- {{ sovereign_network_name }}
logging:
driver: gelf
options:
gelf-address: "udp://{{ graylog_host }}:{{ graylog_gelf_port }}"
tag: "bind9"
networks:
{{ sovereign_network_name }}:
external: true
+29
View File
@@ -0,0 +1,29 @@
// named.conf — authoritative-only configuration for {{ base_domain }}
// Managed by Ansible — do not edit manually.
options {
directory "/var/cache/bind";
// Authoritative only — no recursion to prevent DNS amplification attacks
recursion no;
allow-recursion { none; };
// Accept queries from any source
allow-query { any; };
// Only allow zone transfers to trusted hosts (none by default)
allow-transfer { none; };
// Listen on all interfaces
listen-on { any; };
listen-on-v6 { any; };
dnssec-validation no;
};
// Authoritative zone for the base domain
zone "{{ base_domain }}" IN {
type master;
file "/var/lib/bind/{{ base_domain }}.zone";
allow-update { none; };
};
+77
View File
@@ -0,0 +1,77 @@
; Zone file for {{ base_domain }}
; Managed by Ansible — do not edit manually.
; Serial: {{ dns_zone_serial }} (YYYYMMDDHH — increment on manual edits)
$ORIGIN {{ base_domain }}.
$TTL {{ dns_ttl | default(3600) }}
; ---------------------------------------------------------------------------
; SOA record
; ---------------------------------------------------------------------------
@ IN SOA {{ dns_ns_hostname | default('ns1.' + base_domain) }}. hostmaster.{{ base_domain }}. (
{{ dns_zone_serial }} ; Serial
3600 ; Refresh (1 hour)
900 ; Retry (15 min)
604800 ; Expire (7 days)
300 ; Negative TTL (5 min)
)
; ---------------------------------------------------------------------------
; Name servers
; ---------------------------------------------------------------------------
@ IN NS {{ dns_ns_hostname | default('ns1.' + base_domain) }}.
ns1 IN A {{ dns_server_ip }}
; ---------------------------------------------------------------------------
; Root domain
; ---------------------------------------------------------------------------
@ IN A {{ dns_server_ip }}
; ---------------------------------------------------------------------------
; Service A records
; ---------------------------------------------------------------------------
traefik IN A {{ dns_server_ip }}
logs IN A {{ dns_server_ip }}
auth IN A {{ dns_server_ip }}
s3 IN A {{ dns_server_ip }}
minio IN A {{ dns_server_ip }}
cloud IN A {{ dns_server_ip }}
mail IN A {{ dns_server_ip }}
webmail IN A {{ dns_server_ip }}
matrix IN A {{ dns_server_ip }}
chat IN A {{ dns_server_ip }}
meet IN A {{ dns_server_ip }}
headscale IN A {{ dns_server_ip }}
wazuh IN A {{ dns_server_ip }}
vault IN A {{ dns_server_ip }}
git IN A {{ dns_server_ip }}
; ---------------------------------------------------------------------------
; Mail exchange
; ---------------------------------------------------------------------------
@ IN MX 10 mail.{{ base_domain }}.
; ---------------------------------------------------------------------------
; SPF — authorise the mail server to send on behalf of the domain
; ---------------------------------------------------------------------------
@ IN TXT "v=spf1 mx ~all"
; ---------------------------------------------------------------------------
; DMARC — policy: {{ dmarc_policy | default('quarantine') }}
; ---------------------------------------------------------------------------
_dmarc IN TXT "v=DMARC1; p={{ dmarc_policy | default('quarantine') }}; rua={{ dmarc_rua | default('mailto:dmarc-reports@' + base_domain) }}; ruf={{ dmarc_ruf | default('mailto:dmarc-forensics@' + base_domain) }}; fo=1; adkim=r; aspf=r"
; ---------------------------------------------------------------------------
; DKIM — public key from the Stalwart mail server
; Set stalwart_dkim_public_key in group_vars/all.yml (retrieve from the
; Stalwart admin UI at mail.{{ base_domain }} → Settings → DKIM keys).
; RSA-2048 keys exceed the 255-byte TXT string limit so they are split into
; multiple quoted strings, which compliant resolvers concatenate.
; ---------------------------------------------------------------------------
{% if stalwart_dkim_public_key | default('') != '' %}
{% set dkim_full = "v=DKIM1; k=rsa; p=" + stalwart_dkim_public_key %}
{% set dkim_chunks = dkim_full | regex_findall('.{1,255}') %}
{{ stalwart_dkim_selector | default('default') }}._domainkey IN TXT ( {% for chunk in dkim_chunks %}"{{ chunk }}" {% endfor %})
{% else %}
; DKIM record not configured — set stalwart_dkim_public_key to enable.
{% endif %}
+11
View File
@@ -110,6 +110,17 @@ forgejo_admin_password: "test_forgejo_admin"
forgejo_admin_email: "admin@test.example.com" forgejo_admin_email: "admin@test.example.com"
forgejo_ssh_port: 2222 forgejo_ssh_port: 2222
# DNS / BIND9
bind_version: "9.18-22.04_beta"
dns_server_ip: "192.0.2.1"
dns_ns_hostname: "ns1.test.example.com"
dns_ttl: 3600
stalwart_dkim_selector: "default"
stalwart_dkim_public_key: ""
dmarc_policy: "quarantine"
dmarc_rua: "mailto:dmarc-reports@test.example.com"
dmarc_ruf: "mailto:dmarc-forensics@test.example.com"
# Website # Website
website_nginx_version: "alpine" website_nginx_version: "alpine"