diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 65ed90b..54895cd 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -25,7 +25,12 @@ "Bash(ansible-playbook:*)", "Bash(just test:*)", "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\")" ] } } diff --git a/inventories/production/group_vars/all.yml b/inventories/production/group_vars/all.yml index 5f6a19b..c1e2b0e 100644 --- a/inventories/production/group_vars/all.yml +++ b/inventories/production/group_vars/all.yml @@ -136,3 +136,23 @@ smtp_from: "noreply@{{ base_domain }}" smtp_user: "noreply@{{ base_domain }}" smtp_password: "changeme_smtp" 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 }}" diff --git a/molecule/shared/vars.yml b/molecule/shared/vars.yml index f604393..fb80c5d 100644 --- a/molecule/shared/vars.yml +++ b/molecule/shared/vars.yml @@ -139,3 +139,16 @@ forgejo_data_dir: /tmp/sovereign_test/forgejo # Website website_nginx_version: "alpine" 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 diff --git a/playbooks/site.yml b/playbooks/site.yml index a749626..547c721 100644 --- a/playbooks/site.yml +++ b/playbooks/site.yml @@ -5,6 +5,8 @@ roles: - role: common tags: [common, traefik] + - role: dns + tags: [dns, nameserver] - role: graylog tags: [graylog, logging] - role: authentik diff --git a/roles/dns/defaults/main.yml b/roles/dns/defaults/main.yml new file mode 100644 index 0000000..02d592f --- /dev/null +++ b/roles/dns/defaults/main.yml @@ -0,0 +1,2 @@ +--- +dns_data_dir: "{{ sovereign_base_dir }}/dns" diff --git a/roles/dns/handlers/main.yml b/roles/dns/handlers/main.yml new file mode 100644 index 0000000..a6d2d2f --- /dev/null +++ b/roles/dns/handlers/main.yml @@ -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)) diff --git a/roles/dns/molecule/default/converge.yml b/roles/dns/molecule/default/converge.yml new file mode 100644 index 0000000..bd1c1b6 --- /dev/null +++ b/roles/dns/molecule/default/converge.yml @@ -0,0 +1,8 @@ +--- +- name: Converge + hosts: localhost + gather_facts: false + vars_files: + - ../../../molecule/shared/vars.yml + roles: + - role: dns diff --git a/roles/dns/molecule/default/molecule.yml b/roles/dns/molecule/default/molecule.yml new file mode 100644 index 0000000..5e0827d --- /dev/null +++ b/roles/dns/molecule/default/molecule.yml @@ -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 diff --git a/roles/dns/molecule/default/verify.yml b/roles/dns/molecule/default/verify.yml new file mode 100644 index 0000000..e078ce0 --- /dev/null +++ b/roles/dns/molecule/default/verify.yml @@ -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" diff --git a/roles/dns/tasks/main.yml b/roles/dns/tasks/main.yml new file mode 100644 index 0000000..1d603dd --- /dev/null +++ b/roles/dns/tasks/main.yml @@ -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)) diff --git a/roles/dns/templates/docker-compose.yml.j2 b/roles/dns/templates/docker-compose.yml.j2 new file mode 100644 index 0000000..6153505 --- /dev/null +++ b/roles/dns/templates/docker-compose.yml.j2 @@ -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 diff --git a/roles/dns/templates/named.conf.j2 b/roles/dns/templates/named.conf.j2 new file mode 100644 index 0000000..d7252ce --- /dev/null +++ b/roles/dns/templates/named.conf.j2 @@ -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; }; +}; diff --git a/roles/dns/templates/zone.j2 b/roles/dns/templates/zone.j2 new file mode 100644 index 0000000..ce1b7ec --- /dev/null +++ b/roles/dns/templates/zone.j2 @@ -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 %} diff --git a/roles/molecule/shared/vars.yml b/roles/molecule/shared/vars.yml index 4d05efa..eed8ac3 100644 --- a/roles/molecule/shared/vars.yml +++ b/roles/molecule/shared/vars.yml @@ -110,6 +110,17 @@ forgejo_admin_password: "test_forgejo_admin" forgejo_admin_email: "admin@test.example.com" 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_nginx_version: "alpine"