diff --git a/CHANGELOG.md b/CHANGELOG.md index 9400175a3..7133197c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Breaking Changes +* **role:bind**: named validates DNSSEC by default, as the bind package does, and follows the system-wide crypto policy. Forged or broken answers for signed zones are answered with `SERVFAIL` instead of being passed on. The `forward`, `static-stub` and `stub` zones in `bind__zones` are excluded from validation; list other internal zones below a signed domain or TLD in `bind__dnssec_validate_except`, or set `bind__dnssec_validation: false`. On RHEL 8 the role fails if it has zones to exclude, since BIND 9.11 cannot; set `bind__dnssec_validation: false` there ([#355](https://github.com/Linuxfabrik/lfops/issues/355), [#356](https://github.com/Linuxfabrik/lfops/issues/356)). * **role:system_update**: On Debian and Ubuntu, the system update no longer updates the database of an AIDE installed by hand, since it also accepted changes that were pending before the update. On such hosts the daily AIDE mail now lists the files each update changed. Deploy the aide role to have its database updated after updates again. * **role:grav**: The `grav:cron` tag is gone. Run the role with `--tags grav` to deploy the timers and their services, or with `--tags grav:state` to enable or disable the timers. * **role:firewall**: With `firewall__firewall: 'fwbuilder'`, the default, the run aborts on a host that has neither `/etc/fwb.sh` nor `firewall__fwbuilder_repo_url`, before the role stops any firewall. Until now `fwb.service` failed there and the host ran without a firewall. Deploy `/etc/fwb.sh`, set `firewall__fwbuilder_repo_url`, or set `firewall__firewall` to the firewall the host uses. @@ -37,6 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +* **role:bind**: Add `meta/argument_specs.yml` declaring the user-facing variables, so role-entry validation catches type mismatches and invalid values before any task runs. * **role:duplicity**: The backup includes the data of the applications LFOps deploys by default: `/data`, `/srv`, `/var/lib/grafana`, `/var/lib/icinga2` (including the Icinga2 CA), `/var/lib/shiny-server`, `/var/lib/turn`, `/var/mail`, `/var/named`, `/var/solr/data`, `/var/spool/mail` and `/var/www` (without the repository mirrors). Hosts without these directories are not affected. On hosts with large data, for example VM images in `/data`, check the backup size or set the path to `state: 'absent'`. * **playbook:setup_basic**: Installs AIDE on every host, which checks file integrity twice a day and after every boot; skip it with `setup_basic__skip_aide`. * **role:aide, playbook:aide**: Add a role and playbook that install AIDE on Debian 12 and 13, RHEL 8, 9 and 10 and Ubuntu 22.04, 24.04 and 26.04 as the CIS benchmarks recommend, leaving `aidecheck.service` failed on any finding and keeping the database in step with `system_update` and `unattended-upgrades`. @@ -84,6 +86,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +* **role:bind**: A secondary zone with `type: 'slave'` is saved to its file again, so the secondary answers it after a restart without waiting for the primary. +* **role:bind**: Reverse lookups for private and special-use addresses, such as `10.0.0.0/8` or `fd00::/8`, are answered locally, as BIND does by default, instead of waiting for the forwarders, which also no longer see the internal addressing. * **role:kernel_settings**: The role works with fedora.linux_system_roles 2.5.0 and later, which a fresh installation of LFOps pulls in. Until now the run aborted with "kernel_settings_transparent_hugepages must be null, one of always, madvise, never" unless `kernel_settings__transparent_hugepages__*_var` and `kernel_settings__transparent_hugepages_defrag__*_var` were set. * **role:system_update**: The AIDE database is only updated after an update if a check right before the update comes out clean, instead of relying on the last scheduled check, so changes made since then are no longer accepted along with the update; a check that cannot run at all is reported in a mail of its own. * **role:fangfrisch**: `--tags fangfrisch:state` no longer aborts on an undefined variable. diff --git a/extensions/molecule/bind/converge.yml b/extensions/molecule/bind/converge.yml new file mode 100644 index 000000000..36afaf5ae --- /dev/null +++ b/extensions/molecule/bind/converge.yml @@ -0,0 +1,2 @@ +- name: 'Converge bind playbook' + ansible.builtin.import_playbook: 'linuxfabrik.lfops.bind' diff --git a/extensions/molecule/bind/inventory/group_vars/systems_under_test.yml b/extensions/molecule/bind/inventory/group_vars/systems_under_test.yml new file mode 100644 index 000000000..3711181c9 --- /dev/null +++ b/extensions/molecule/bind/inventory/group_vars/systems_under_test.yml @@ -0,0 +1,80 @@ +# Variables the playbook under test needs, applied to every system under test. +# +# Each host is the primary of its own zone `.example` and the secondary of a neighbour's +# zone, so the three hosts form a ring and every BIND version runs both sides of a zone transfer. +# Rocky 9 and 10 also forward a third host's zone to its primary. `.example` is not delegated +# in the signed root, so validating that forwarded answer fails unless the role excludes the +# forward zone from DNSSEC validation. BIND 9.11 cannot exclude zones, so Rocky 8 forwards +# nothing and validates everything. +# +# The `primary`/`secondary` keywords need BIND 9.12 or later, so Rocky 8 uses `master`/`slave`. +__molecule__bind_peers: + rocky8-vm: + forward_to: '' + primary_type: 'master' + secondary_of: 'rocky10-vm' + secondary_type: 'slave' + rocky9-vm: + forward_to: 'rocky10-vm' + primary_type: 'primary' + secondary_of: 'rocky8-vm' + secondary_type: 'secondary' + rocky10-vm: + forward_to: 'rocky8-vm' + primary_type: 'primary' + secondary_of: 'rocky9-vm' + secondary_type: 'secondary' + +__molecule__bind_peer: '{{ __molecule__bind_peers[inventory_hostname] }}' + +# A peer that LFOPS_TEST_TARGETS left out has no address to transfer from or forward to. +__molecule__bind_forward_to: '{{ + __molecule__bind_peer["forward_to"] + if __molecule__bind_peer["forward_to"] in ansible_play_hosts_all else "" + }}' +__molecule__bind_secondary_of: '{{ + __molecule__bind_peer["secondary_of"] + if __molecule__bind_peer["secondary_of"] in ansible_play_hosts_all else "" + }}' + +# The TXT record names the host that serves the zone as its primary, so an answer proves which +# server it came from. +__molecule__bind_primary_zone: + name: '{{ inventory_hostname | replace("-vm", "") }}.example' + type: '{{ __molecule__bind_peer["primary_type"] }}' + allow_transfer: + - 'localnets' + raw: |- + $TTL 1H + + @ IN SOA ns.{{ inventory_hostname | replace("-vm", "") }}.example. root.example.com. ( + 2026100201 ; + 1H ; + 1H ; + 1W ; + 1D ) ; + + @ IN NS ns + + ns IN A 192.0.2.53 + owner IN TXT "{{ inventory_hostname }}" + +__molecule__bind_secondary_zone: + name: '{{ __molecule__bind_secondary_of | replace("-vm", "") }}.example' + type: '{{ __molecule__bind_peer["secondary_type"] }}' + masters: + - '{{ hostvars[__molecule__bind_secondary_of]["ansible_host"] | d("") }}' + +__molecule__bind_forward_zone: + name: '{{ __molecule__bind_forward_to | replace("-vm", "") }}.example' + type: 'forward' + forwarders: + - '{{ hostvars[__molecule__bind_forward_to]["ansible_host"] | d("") }}' + +bind__trusted_networks: + - '192.0.2.0/24' +bind__zones: '{{ + [__molecule__bind_primary_zone] + + ([__molecule__bind_secondary_zone] if __molecule__bind_secondary_of | length > 0 else []) + + ([__molecule__bind_forward_zone] if __molecule__bind_forward_to | length > 0 else []) + }}' diff --git a/extensions/molecule/bind/inventory/hosts.yml b/extensions/molecule/bind/inventory/hosts.yml new file mode 100644 index 000000000..401abc82d --- /dev/null +++ b/extensions/molecule/bind/inventory/hosts.yml @@ -0,0 +1,19 @@ +# yamllint disable rule:empty-values + +# bind targets 'lfops_bind' (see playbooks/bind.yml: hosts). +# +# Debian and Ubuntu are deliberately absent: the role installs the Red Hat package names and +# paths only (see COMPATIBILITY.md). +# +# The three hosts serve each other's zones (see group_vars), so every BIND version the role +# supports runs both as primary and as secondary. Running a subset with LFOPS_TEST_TARGETS +# drops the zones of the hosts that are left out. +lfops_bind: + children: + systems_under_test: + +systems_under_test: + hosts: + rocky8-vm: + rocky9-vm: + rocky10-vm: diff --git a/extensions/molecule/bind/molecule.yml b/extensions/molecule/bind/molecule.yml new file mode 100644 index 000000000..475a6b1ae --- /dev/null +++ b/extensions/molecule/bind/molecule.yml @@ -0,0 +1,3 @@ +# Molecule scenario marker. The shared config.yml already provisions libvirt/KVM VMs: the +# secondaries fetch their zones from the primaries over the network, which needs each host to +# be reachable on its own address. diff --git a/extensions/molecule/bind/verify.yml b/extensions/molecule/bind/verify.yml new file mode 100644 index 000000000..cf1102e2e --- /dev/null +++ b/extensions/molecule/bind/verify.yml @@ -0,0 +1,141 @@ +# verify.yml runs after converge and again after the idempotence step. It asserts the observable +# end state, not that individual tasks ran. +# +# Every check asks the running named with dig and reads the status and the flags of the answer: +# `aa` tells an answer from a local zone apart from one that came through the forwarders, `ad` +# tells a DNSSEC-validated answer apart from an unvalidated one. Zones, peers and expectations +# come from the ring in group_vars. +- name: 'Verify named is running and answers as configured' + hosts: 'systems_under_test' + gather_facts: false + + vars: + + __molecule__bind_own_zone: '{{ inventory_hostname | replace("-vm", "") }}.example' + __molecule__bind_secondary_zone_name: '{{ __molecule__bind_secondary_of | replace("-vm", "") }}.example' + + # status: the rcode, aa: answered from a local zone, ad: validated, answer: substring of + # the dig output (empty: not checked). + __molecule__bind_checks: '{{ + [ + { + "label": "own primary zone answered locally", + "query": "owner." ~ __molecule__bind_own_zone ~ " TXT", + "status": "NOERROR", + "aa": true, + "answer": inventory_hostname, + }, + { + "label": "private reverse lookup answered from the empty zone", + "query": "-x 10.1.2.3", + "status": "NXDOMAIN", + "aa": true, + "answer": "", + }, + { + "label": "broken DNSSEC signature rejected", + "query": "dnssec-failed.org A", + "status": "SERVFAIL", + "aa": false, + "answer": "", + }, + { + "label": "signed zone validated", + "query": "+dnssec isc.org SOA", + "status": "NOERROR", + "aa": false, + "ad": true, + "answer": "", + }, + ] + + ([ + { + "label": "forward zone below an unsigned TLD excluded from validation", + "query": "owner." ~ (__molecule__bind_forward_to | replace("-vm", "")) ~ ".example TXT", + "status": "NOERROR", + "aa": false, + "answer": __molecule__bind_forward_to, + }, + ] if __molecule__bind_forward_to | length > 0 else []) + }}' + + tasks: + + - name: 'Get the service facts' + ansible.builtin.service_facts: # yamllint disable-line rule:empty-values + + - name: 'Assert named is enabled and running' + ansible.builtin.assert: + that: + - 'ansible_facts["services"]["named.service"]["state"] == "running"' + - 'ansible_facts["services"]["named.service"]["status"] == "enabled"' + fail_msg: 'named.service is {{ ansible_facts["services"]["named.service"] | d("absent") }}' + + - name: 'dig @127.0.0.1 ...' + ansible.builtin.command: 'dig @127.0.0.1 +tries=2 +time=5 {{ item["query"] }}' + register: '__molecule__bind_dig_result' + changed_when: false + loop: '{{ __molecule__bind_checks }}' + loop_control: + label: '{{ item["query"] }}' + + - name: 'Assert the status, the flags and the answer' + ansible.builtin.assert: + that: + - '__molecule__bind_status == item["item"]["status"]' + - '("aa" in __molecule__bind_flags) == item["item"]["aa"]' + - '("ad" in __molecule__bind_flags) == item["item"]["ad"] | d("ad" in __molecule__bind_flags)' + - 'item["item"]["answer"] in item["stdout"]' + fail_msg: '{{ item["item"]["label"] }}: got status {{ __molecule__bind_status }} with flags "{{ __molecule__bind_flags | join(" ") }}"; dig output: {{ item["stdout"] }}' + quiet: true + loop: '{{ __molecule__bind_dig_result["results"] }}' + loop_control: + label: '{{ item["item"]["label"] }}' + vars: + __molecule__bind_status: '{{ item["stdout"] | regex_search("status: (\w+)", "\1") | first | d("") }}' + __molecule__bind_flags: '{{ (item["stdout"] | regex_search(";; flags: ([a-z ]+);", "\1") | first | d("")).split() }}' + + + - block: + + # All three hosts start named within the same task, so the secondary's first transfer + # usually runs before its primary listens, and the next attempt only follows minutes later. + # Trigger it now instead of waiting for the retry timer. + - name: 'rndc retransfer {{ __molecule__bind_secondary_zone_name }}' + ansible.builtin.command: 'rndc retransfer {{ __molecule__bind_secondary_zone_name }}' + changed_when: false + + - name: 'dig @127.0.0.1 owner.{{ __molecule__bind_secondary_zone_name }} TXT' + ansible.builtin.command: 'dig @127.0.0.1 +norecurse owner.{{ __molecule__bind_secondary_zone_name }} TXT' + register: '__molecule__bind_secondary_dig_result' + changed_when: false + until: '"status: NOERROR" in __molecule__bind_secondary_dig_result["stdout"]' + retries: 10 + delay: 3 + + - name: 'Assert the secondary answers from the transferred zone' + ansible.builtin.assert: + that: + - '" aa" in (__molecule__bind_secondary_dig_result["stdout"] | regex_search(";; flags: [a-z ]+;"))' + - '__molecule__bind_secondary_of in __molecule__bind_secondary_dig_result["stdout"]' + fail_msg: 'owner.{{ __molecule__bind_secondary_zone_name }} did not come from {{ __molecule__bind_secondary_of }}: {{ __molecule__bind_secondary_dig_result["stdout"] }}' + + # The secondary keeps a copy of the zone, so it can answer after a restart while the + # primary is down. + - name: 'stat /var/named/{{ __molecule__bind_secondary_zone_name }}.zone' + ansible.builtin.stat: + path: '/var/named/{{ __molecule__bind_secondary_zone_name }}.zone' + register: '__molecule__bind_secondary_file_result' + until: '__molecule__bind_secondary_file_result["stat"]["exists"]' + retries: 10 + delay: 3 + + - name: 'Assert the secondary saved the transferred zone' + ansible.builtin.assert: + that: + - '__molecule__bind_secondary_file_result["stat"]["exists"]' + - '__molecule__bind_secondary_file_result["stat"]["pw_name"] == "named"' + fail_msg: '/var/named/{{ __molecule__bind_secondary_zone_name }}.zone is {{ __molecule__bind_secondary_file_result["stat"] }}' + + when: + - '__molecule__bind_secondary_of | length > 0' diff --git a/roles/bind/README.md b/roles/bind/README.md index 5baab3e74..330753020 100644 --- a/roles/bind/README.md +++ b/roles/bind/README.md @@ -6,6 +6,11 @@ This role installs and configures [bind](https://www.isc.org/bind/) as a DNS ser *Available since LFOps `2.0.0`.* +## How the Role Behaves + +* `/etc/named.conf` follows the file that the bind package ships, with two deliberate differences: named listens on all IPv4 addresses instead of only on `127.0.0.1` (see `bind__listen_on_addresses`), and it does not restrict queries globally to `localhost` with `allow-query`. Instead, the `trusted` ACL (localhost, the local networks and `bind__trusted_networks`) controls who may use the cache and recursion (`bind__allow_query_cache`, `bind__allow_recursion`) and who may query the zones in `bind__zones`. Zones that named builds in itself, such as the empty reverse zones for private addresses, answer every client that can reach the server. + + ## Tags `bind` @@ -143,9 +148,21 @@ bind__zones: * Type: List of strings. * Default: `['none']` +`bind__dnssec_validate_except` + +* List of domains below which named does not validate DNSSEC. Needed for internal zones that named gets from other servers through `bind__named_conf_raw`, and for forwarders that strip the DNSSEC records. The names of the `forward`, `static-stub` and `stub` zones in `bind__zones` are excluded automatically. Not supported on RHEL 8, which has to use `bind__dnssec_validation: false` instead. +* Type: List of strings. +* Default: `[]` + +`bind__dnssec_validation` + +* Enables or disables DNSSEC validation of the answers named resolves, as the bind package does. Forged or broken answers for signed zones are answered with `SERVFAIL`. Zones below a signed parent that are only served internally have to be excluded, see `bind__dnssec_validate_except`. named uses the algorithms that the system-wide crypto policy allows. +* Type: Bool. +* Default: `true` + `bind__forwarders` -* List of DNS servers to which DNS queries to unknown domain names should be forwarded. +* List of DNS servers to which DNS queries to unknown domain names should be forwarded. Reverse lookups for private and special-use addresses (RFC 1918, RFC 6303), such as `10.in-addr.arpa`, are answered locally from BIND's built-in empty zones instead (faster, independent of the forwarders, and without revealing the internal addressing to them), unless `bind__zones` contains the zone itself or a `forward`, `static-stub` or `stub` zone below it. * Type: List of strings. * Default: `['1.0.0.1', '1.1.1.1']` @@ -174,15 +191,17 @@ bind__zones: `bind__listen_ipv6` -* Enables or disables listening on IPv6. +* Enables or disables listening on IPv6. If `true`, named listens on all IPv6 addresses. * Type: Bool. * Default: `false` +* Deviates from the upstream default, which listens on `::1` only: the role serves IPv4 clients by default, and `true` covers the IPv6 clients of a network instead of only the local host. `bind__listen_on_addresses` * List of addresses on which the server will listen. This indirectly sets the listening interface(s). * Type: List of strings. * Default: `['any']` +* Deviates from the upstream default `['127.0.0.1']`: the role sets up a DNS server for the network, which the clients cannot reach on the loopback address. `bind__named_conf_raw` @@ -224,6 +243,9 @@ bind__allow_recursion: - 'none' bind__allow_transfer: - '192.0.2.0/24' +bind__dnssec_validate_except: + - 'corp.example.com' +bind__dnssec_validation: true bind__forwarders: - '1.0.0.1' - '1.1.1.1' @@ -323,7 +345,7 @@ bind__zones: bind__zones: - name: 'example.com' file: 'forward.zone' - type: 'master' + type: 'slave' masters: - '192.0.2.2' diff --git a/roles/bind/defaults/main.yml b/roles/bind/defaults/main.yml index 113493b91..1a68ad7fa 100644 --- a/roles/bind/defaults/main.yml +++ b/roles/bind/defaults/main.yml @@ -5,12 +5,14 @@ bind__allow_recursion: - 'trusted' bind__allow_transfer: - 'none' +bind__dnssec_validate_except: [] +bind__dnssec_validation: true bind__forwarders: - '1.0.0.1' - '1.1.1.1' bind__keys: [] -bind__listen_ipv6: false -bind__listen_on_addresses: +bind__listen_ipv6: false # upstream default: ::1 +bind__listen_on_addresses: # upstream default: ['127.0.0.1'] - 'any' bind__named_service_enabled: true bind__named_service_state: '{{ bind__named_service_enabled | bool | ternary("started", "stopped") }}' diff --git a/roles/bind/meta/argument_specs.yml b/roles/bind/meta/argument_specs.yml new file mode 100644 index 000000000..cde90145a --- /dev/null +++ b/roles/bind/meta/argument_specs.yml @@ -0,0 +1,120 @@ +argument_specs: + main: + options: + + bind__allow_new_zones: + type: 'bool' + required: false + default: false + description: 'If true, zones can be added at runtime via `rndc addzone`.' + + bind__allow_query_cache: + type: 'list' + elements: 'str' + required: false + default: + - 'trusted' + description: 'ACLs or address match lists which are allowed to query the cache.' + + bind__allow_recursion: + type: 'list' + elements: 'str' + required: false + default: + - 'trusted' + description: 'ACLs or address match lists which are allowed to initiate recursive queries.' + + bind__allow_transfer: + type: 'list' + elements: 'str' + required: false + default: + - 'none' + description: 'The global allow-transfer option. Can be overwritten per zone.' + + bind__dnssec_validate_except: + type: 'list' + elements: 'str' + required: false + default: [] + description: 'Domains below which named does not validate DNSSEC.' + + bind__dnssec_validation: + type: 'bool' + required: false + default: true + description: 'Enables or disables DNSSEC validation of the answers named resolves.' + + bind__forwarders: + type: 'list' + elements: 'str' + required: false + default: + - '1.0.0.1' + - '1.1.1.1' + description: 'DNS servers to which queries for unknown domain names are forwarded.' + + bind__keys: + type: 'list' + elements: 'dict' + required: false + default: [] + description: 'Keys for use with TSIG or the command channel (rndc).' + + bind__listen_ipv6: + type: 'bool' + required: false + default: false + description: 'Enables or disables listening on IPv6.' + + bind__listen_on_addresses: + type: 'list' + elements: 'str' + required: false + default: + - 'any' + description: 'Addresses on which the server listens.' + + bind__named_conf_raw: + type: 'str' + required: false + description: 'Raw content which is appended to the end of /etc/named.conf.' + + bind__named_service_enabled: + type: 'bool' + required: false + default: true + description: 'Enables or disables the named service.' + + bind__named_service_state: + type: 'str' + required: false + choices: + - 'reloaded' + - 'restarted' + - 'started' + - 'stopped' + description: 'The state of the named service.' + + bind__recursion: + type: 'bool' + required: false + default: true + description: 'Enables or disables recursion.' + + bind__rpz_zone: + type: 'str' + required: false + description: 'Name of the response policy zone.' + + bind__trusted_networks: + type: 'list' + elements: 'str' + required: true + description: 'Networks from which DNS queries are allowed. Results in the trusted ACL.' + + bind__zones: + type: 'list' + elements: 'dict' + required: true + description: 'The zones named serves, forwards or fetches from other servers.' diff --git a/roles/bind/tasks/main.yml b/roles/bind/tasks/main.yml index b891a9de3..c837880b7 100644 --- a/roles/bind/tasks/main.yml +++ b/roles/bind/tasks/main.yml @@ -1,3 +1,21 @@ +- block: + + # validate-except was added in BIND 9.13; `named-checkconf` on BIND 9.11 rejects it as an unknown + # option. + - name: 'Assert that no zone has to be excluded from DNSSEC validation on RHEL 8' + ansible.builtin.assert: + that: + - '__bind__dnssec_validate_except | length == 0' + fail_msg: 'BIND 9.11 on RHEL 8 cannot exclude zones from DNSSEC validation, which the forward, static-stub and stub zones in bind__zones and the zones in bind__dnssec_validate_except need: {{ __bind__dnssec_validate_except | join(", ") }}. Set bind__dnssec_validation: false.' + quiet: true + when: + - 'ansible_facts["distribution_major_version"] | int == 8' + - 'bind__dnssec_validation | bool' + + tags: + - 'always' + + - block: - name: 'Install bind bind-utils' @@ -63,12 +81,12 @@ - block: - name: 'systemctl {{ bind__named_service_enabled | bool | ternary("enable", "disable") }} named.service' - ansible.builtin.systemd: + ansible.builtin.service: name: 'named.service' - enabled: '{{ bind__named_service_enabled }}' + enabled: '{{ bind__named_service_enabled | bool }}' - name: 'systemctl {{ bind__named_service_state | regex_replace("p?ed$", "") }} named.service' - ansible.builtin.systemd: + ansible.builtin.service: name: 'named.service' state: '{{ bind__named_service_state }}' register: '__bind__named_service_state_result' diff --git a/roles/bind/templates/etc/named.conf.j2 b/roles/bind/templates/etc/named.conf.j2 index 5ab7916cb..ce55e3e0b 100644 --- a/roles/bind/templates/etc/named.conf.j2 +++ b/roles/bind/templates/etc/named.conf.j2 @@ -1,62 +1,86 @@ # {{ ansible_managed }} -# 2025070301 +# 2026100203 +# # https://bind9.readthedocs.io/en/latest/reference.html +# Follows the /etc/named.conf shipped by the bind package, with the Linuxfabrik additions at the +# end of the options block. Deliberately not taken over: the loopback-only listen-on and +# listen-on-v6 and `allow-query { localhost; }`, see "How the Role Behaves" in the README. acl "trusted" { localhost; localnets; - {% for item in bind__trusted_networks %} +{% for item in bind__trusted_networks %} {{ item }}; - {% endfor %} +{% endfor %} }; options { - allow-new-zones {{ bind__allow_new_zones | ternary('yes', 'no') }}; + listen-on port 53 { +{% for item in bind__listen_on_addresses %} + {{ item }}; +{% endfor %} + }; + listen-on-v6 port 53 { {{ bind__listen_ipv6 | bool | ternary('any', 'none') }}; }; + directory "/var/named"; + dump-file "/var/named/data/cache_dump.db"; + statistics-file "/var/named/data/named_stats.txt"; + memstatistics-file "/var/named/data/named_mem_stats.txt"; + secroots-file "/var/named/data/named.secroots"; + recursing-file "/var/named/data/named.recursing"; + + recursion {{ bind__recursion | bool | ternary('yes', 'no') }}; + +{% if ansible_facts['distribution_major_version'] | int == 8 %} +{# BIND 9.11 only; later versions removed the option and always serve DNSSEC data. #} + dnssec-enable yes; +{% endif %} + dnssec-validation {{ bind__dnssec_validation | bool | ternary('yes', 'no') }}; +{% if bind__dnssec_validation | bool and __bind__dnssec_validate_except | length > 0 %} + validate-except { +{% for item in __bind__dnssec_validate_except %} + "{{ item }}"; +{% endfor %} + }; +{% endif %} + + managed-keys-directory "/var/named/dynamic"; +{% if ansible_facts['distribution_major_version'] | int >= 9 %} + geoip-directory "/usr/share/GeoIP"; +{% endif %} + + pid-file "/run/named/named.pid"; + session-keyfile "/run/named/session.key"; + + // Linuxfabrik: internal DNS server, forwarder and cache + allow-new-zones {{ bind__allow_new_zones | bool | ternary('yes', 'no') }}; allow-query-cache { - {% for item in bind__allow_query_cache %} +{% for item in bind__allow_query_cache %} {{ item }}; - {% endfor %} +{% endfor %} }; allow-recursion { - {% for item in bind__allow_recursion %} +{% for item in bind__allow_recursion %} {{ item }}; - {% endfor %} +{% endfor %} }; allow-transfer { - {% for item in bind__allow_transfer %} +{% for item in bind__allow_transfer %} {{ item }}; - {% endfor %} +{% endfor %} }; - directory "/var/named"; - disable-empty-zone yes; - {% if ansible_facts['os_family'] == 'RedHat' and ansible_facts['distribution_major_version'] | int <= 8 %} - {# The dnssec-enable option has been obsoleted and no longer has any effect. DNSSEC responses are always enabled if signatures and other DNSSEC data are present. See https://bind9.readthedocs.io/en/v9.16.16/notes.html#removed-features #} - dnssec-enable no; - {% endif %} - dnssec-validation no; - dump-file "/var/named/data/cache_dump.db"; forward only; forwarders { - {% for item in bind__forwarders %} - {{ item }}; - {% endfor %} - }; - listen-on port 53 { - {% for item in bind__listen_on_addresses %} +{% for item in bind__forwarders %} {{ item }}; - {% endfor %} +{% endfor %} }; - listen-on-v6 { {{ bind__listen_ipv6 | ternary('any', 'none') }}; }; - managed-keys-directory "/var/named/dynamic"; - memstatistics-file "/var/named/data/named_mem_stats.txt"; - pid-file "/run/named/named.pid"; - recursion {{ bind__recursion | ternary('yes', 'no') }}; - {% if bind__rpz_zone is defined and bind__rpz_zone | length %} +{% if bind__rpz_zone is defined and bind__rpz_zone | length > 0 %} response-policy { zone "{{ bind__rpz_zone }}"; }; - {% endif %} - session-keyfile "/run/named/session.key"; - statistics-file "/var/named/data/named_stats.txt"; +{% endif %} version "Linuxfabrik"; + + /* https://fedoraproject.org/wiki/Changes/CryptoPolicy */ + include "/etc/crypto-policies/back-ends/bind.config"; }; logging { @@ -74,11 +98,22 @@ zone "." IN { include "/etc/named.rfc1912.zones"; include "/etc/named.root.key"; +{% if bind__recursion | bool %} +// Linuxfabrik: `forward only` keeps named from creating its automatic empty zones, so lookups for +// private and special-use reverse zones would go to the forwarders. Not forwarding these zones +// brings the empty zones back. +{# Without recursion, named creates no empty zones at all (empty-zones-enable defaults to the + recursion setting, bin/named/server.c), so these zones would have no effect. #} +{% for item in __bind__empty_zones_without_forwarding %} +zone "{{ item }}" IN { type forward; forwarders { }; }; +{% endfor %} +{% endif %} + {% for item in bind__zones %} zone "{{ item['name'] }}" IN { type {{ item['type'] | d('master') }}; - {% if item['type'] | d('master') in ['hint', 'master', 'mirror', 'primary', 'redirect', 'secondary', 'stub'] %} + {% if item['type'] | d('master') in ['hint', 'master', 'mirror', 'primary', 'redirect', 'secondary', 'slave', 'stub'] %} file "{{ item['file'] | d(item['name'] ~ '.zone') }}"; {% endif %} @@ -95,7 +130,7 @@ zone "{{ item['name'] }}" IN { allow-query { trusted; }; {% endif %} - {% if item['allow_transfer'] | d([]) | length and item['type'] | d('master') in ['mirror', 'primary', 'master', 'secondary', 'slave'] %} + {% if item['allow_transfer'] | d([]) | length > 0 and item['type'] | d('master') in ['mirror', 'primary', 'master', 'secondary', 'slave'] %} allow-transfer { {% for item in item['allow_transfer'] | d(['none']) %} {{ item }}; @@ -121,7 +156,7 @@ key "{{ item ['name'] }}" { }; {% endfor %} -{% if bind__named_conf_raw is defined and bind__named_conf_raw | length %} +{% if bind__named_conf_raw is defined and bind__named_conf_raw | length > 0 %} # raw {{ bind__named_conf_raw }} {% endif %} diff --git a/roles/bind/templates/etc/sysconfig/named.j2 b/roles/bind/templates/etc/sysconfig/named.j2 index 34528bc13..cae27d903 100644 --- a/roles/bind/templates/etc/sysconfig/named.j2 +++ b/roles/bind/templates/etc/sysconfig/named.j2 @@ -2,4 +2,4 @@ # 2024082901 # disable IPv6 -OPTIONS='{{ bind__listen_ipv6 | ternary("", "-4") }}' +OPTIONS='{{ bind__listen_ipv6 | bool | ternary("", "-4") }}' diff --git a/roles/bind/vars/main.yml b/roles/bind/vars/main.yml new file mode 100644 index 000000000..7451a55f5 --- /dev/null +++ b/roles/bind/vars/main.yml @@ -0,0 +1,149 @@ +# Answers for zones that named forwards to or stubs from other servers are validated like any +# other, so an internal zone below a signed parent (a name under a public domain, or a TLD such +# as .internal that the signed root proves not to exist) fails with SERVFAIL. Exclude these zones +# from DNSSEC validation, together with the zones listed by the user. +__bind__dnssec_validate_except: '{{ + ( + __bind__forwarded_zones + + bind__dnssec_validate_except + ) + | unique + }}' + +# BIND answers lookups for private and special-use zones (RFC 1918, RFC 6303 and others) from +# built-in "automatic empty zones", but skips every zone that the global `forward only` would forward, +# which is all of them. named.conf therefore switches forwarding off for exactly these zones, which +# brings the empty zones back. The list is `empty_zones[]` in bin/named/server.c of BIND 9.18; +# BIND 9.11 and 9.16 lack resolver.arpa. 0.in-addr.arpa and the ::1 reverse zone are left out, +# /etc/named.rfc1912.zones defines them. +__bind__empty_zones: + - '10.in-addr.arpa' + - '16.172.in-addr.arpa' + - '17.172.in-addr.arpa' + - '18.172.in-addr.arpa' + - '19.172.in-addr.arpa' + - '20.172.in-addr.arpa' + - '21.172.in-addr.arpa' + - '22.172.in-addr.arpa' + - '23.172.in-addr.arpa' + - '24.172.in-addr.arpa' + - '25.172.in-addr.arpa' + - '26.172.in-addr.arpa' + - '27.172.in-addr.arpa' + - '28.172.in-addr.arpa' + - '29.172.in-addr.arpa' + - '30.172.in-addr.arpa' + - '31.172.in-addr.arpa' + - '168.192.in-addr.arpa' + - '64.100.in-addr.arpa' + - '65.100.in-addr.arpa' + - '66.100.in-addr.arpa' + - '67.100.in-addr.arpa' + - '68.100.in-addr.arpa' + - '69.100.in-addr.arpa' + - '70.100.in-addr.arpa' + - '71.100.in-addr.arpa' + - '72.100.in-addr.arpa' + - '73.100.in-addr.arpa' + - '74.100.in-addr.arpa' + - '75.100.in-addr.arpa' + - '76.100.in-addr.arpa' + - '77.100.in-addr.arpa' + - '78.100.in-addr.arpa' + - '79.100.in-addr.arpa' + - '80.100.in-addr.arpa' + - '81.100.in-addr.arpa' + - '82.100.in-addr.arpa' + - '83.100.in-addr.arpa' + - '84.100.in-addr.arpa' + - '85.100.in-addr.arpa' + - '86.100.in-addr.arpa' + - '87.100.in-addr.arpa' + - '88.100.in-addr.arpa' + - '89.100.in-addr.arpa' + - '90.100.in-addr.arpa' + - '91.100.in-addr.arpa' + - '92.100.in-addr.arpa' + - '93.100.in-addr.arpa' + - '94.100.in-addr.arpa' + - '95.100.in-addr.arpa' + - '96.100.in-addr.arpa' + - '97.100.in-addr.arpa' + - '98.100.in-addr.arpa' + - '99.100.in-addr.arpa' + - '100.100.in-addr.arpa' + - '101.100.in-addr.arpa' + - '102.100.in-addr.arpa' + - '103.100.in-addr.arpa' + - '104.100.in-addr.arpa' + - '105.100.in-addr.arpa' + - '106.100.in-addr.arpa' + - '107.100.in-addr.arpa' + - '108.100.in-addr.arpa' + - '109.100.in-addr.arpa' + - '110.100.in-addr.arpa' + - '111.100.in-addr.arpa' + - '112.100.in-addr.arpa' + - '113.100.in-addr.arpa' + - '114.100.in-addr.arpa' + - '115.100.in-addr.arpa' + - '116.100.in-addr.arpa' + - '117.100.in-addr.arpa' + - '118.100.in-addr.arpa' + - '119.100.in-addr.arpa' + - '120.100.in-addr.arpa' + - '121.100.in-addr.arpa' + - '122.100.in-addr.arpa' + - '123.100.in-addr.arpa' + - '124.100.in-addr.arpa' + - '125.100.in-addr.arpa' + - '126.100.in-addr.arpa' + - '127.100.in-addr.arpa' + - '127.in-addr.arpa' + - '254.169.in-addr.arpa' + - '2.0.192.in-addr.arpa' + - '100.51.198.in-addr.arpa' + - '113.0.203.in-addr.arpa' + - '255.255.255.255.in-addr.arpa' + - '0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.ip6.arpa' + - 'd.f.ip6.arpa' + - '8.e.f.ip6.arpa' + - '9.e.f.ip6.arpa' + - 'a.e.f.ip6.arpa' + - 'b.e.f.ip6.arpa' + - '8.b.d.0.1.0.0.2.ip6.arpa' + - 'empty.as112.arpa' + - 'home.arpa' + +# An empty zone answers authoritatively for everything below it, so it would also answer for a +# forward, static-stub or stub zone in bind__zones below it instead of passing the query on. Such +# parents keep being forwarded, as do zones that bind__zones defines itself. +__bind__empty_zones_without_forwarding: '{{ + ( + __bind__empty_zones + + (["resolver.arpa"] if ansible_facts["distribution_major_version"] | int >= 10 else []) + ) + | reject("in", bind__zones | map(attribute="name") | map("lower") | list) + | reject("in", __bind__forwarded_zones_and_parents) + | list + }}' + +__bind__forwarded_zones: '{{ + bind__zones + | selectattr("type", "defined") + | selectattr("type", "in", ["forward", "static-stub", "stub"]) + | map(attribute="name") + | map("lower") + | list + }}' + +# The forward, static-stub and stub zones of bind__zones, each followed by all of its parent +# domains. +__bind__forwarded_zones_and_parents: '{%- set result = [] -%} + {%- for name in __bind__forwarded_zones -%} + {%- set labels = name.split(".") -%} + {%- for index in range(labels | length) -%} + {%- set _ = result.append(labels[index:] | join(".")) -%} + {%- endfor -%} + {%- endfor -%} + {{ result }}'