From c22f08eaccc176f16b5a6a38e34875facd0d54c5 Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Tue, 15 Sep 2026 14:34:29 +0200 Subject: [PATCH 01/10] fix(roles/bind): drop the no-op disable-empty-zone option `disable-empty-zone` takes the name of a built-in empty zone to disable (cfg_type_astring in lib/isccfg/namedconf.c), not a boolean, so `disable-empty-zone yes;` disabled a zone named "yes.", which does not exist. The boolean switch would be `empty-zones-enable`. Verified on Rocky 8, 9 and 10 (bind 9.11.36, 9.16.23, 9.18.33) with the template rendered before and after: with the role's `forward only;` named creates no automatic empty zones either way; without it, both render the same 97/97/98 empty zones and answer `dig -x 10.1.2.3` from 10.IN-ADDR.ARPA, while the control `disable-empty-zone "10.IN-ADDR.ARPA";` drops exactly that zone. --- roles/bind/templates/etc/named.conf.j2 | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/roles/bind/templates/etc/named.conf.j2 b/roles/bind/templates/etc/named.conf.j2 index 5ab7916cb..757d7aba8 100644 --- a/roles/bind/templates/etc/named.conf.j2 +++ b/roles/bind/templates/etc/named.conf.j2 @@ -1,5 +1,5 @@ # {{ ansible_managed }} -# 2025070301 +# 2026091501 # https://bind9.readthedocs.io/en/latest/reference.html acl "trusted" { @@ -28,7 +28,6 @@ options { {% 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; From da02c5b8707e0a2a30f0013dd457aca862b9ee0e Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Tue, 15 Sep 2026 15:22:29 +0200 Subject: [PATCH 02/10] fix(roles/bind)!: validate DNSSEC and follow the crypto policy (fix #355, fix #356) The template hardcoded `dnssec-validation no;` and dropped the crypto-policies include that the named.conf of the bind package carries on RHEL 8, 9 and 10. Rebuild the options block after the packaged file: same statements in the same order (secroots-file, recursing-file, geoip-directory on EL 9+, dnssec-enable yes on EL 8, the include), with the Linuxfabrik options (ACLs, forward only, forwarders, RPZ, version) grouped at the end. allow-query { localhost; } and the loopback-only listen-on are deliberately not taken over. bind__dnssec_validation (default true) renders dnssec-validation. Answers for zones forwarded to or stubbed from internal servers fail validation below a signed parent: host.corp.internal and host.corp.isc.org forwarded to a local named return SERVFAIL on bind 9.11.36, 9.16.23 and 9.18.33. The forward, stub and static-stub zones of bind__zones and the new bind__dnssec_validate_except therefore go into validate-except. BIND 9.11 rejects validate-except (unknown option), so on RHEL 8 the role asserts that nothing needs excluding. The crypto policy's disable-algorithms/disable-ds-digests only feed the validator and trust anchor loading (bin/named/server.c, lib/dns/validator.c, lib/ns/query.c in 9.18.33), so the include takes effect together with validation. Verified with the bind playbook on Rocky 8, 9 and 10 ubi-init containers: named active, local zone NOERROR, forwarded zones NOERROR, dnssec-failed.org SERVFAIL, isc.org with the ad flag, both answered without validation when bind__dnssec_validation is false; the EL 8 host with forward zones stops at the assert; rndc secroots/recursing write to /var/named/data; second run changed=0. --- CHANGELOG.md | 1 + roles/bind/README.md | 15 +++++ roles/bind/defaults/main.yml | 2 + roles/bind/tasks/main.yml | 18 ++++++ roles/bind/templates/etc/named.conf.j2 | 90 ++++++++++++++++---------- roles/bind/vars/main.yml | 15 +++++ 6 files changed, 108 insertions(+), 33 deletions(-) create mode 100644 roles/bind/vars/main.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index d8db950b5..170c4b832 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:dnf_makecache**: `dnf_makecache__service_enabled` and `dnf_makecache__service_state` are gone; remove them from your inventory. The role only manages `dnf-makecache.timer` now, since `dnf-makecache.service` cannot be enabled at boot and only runs when the timer triggers it. `dnf_makecache__service_enabled` never had an effect, but a run against an unchanged host reported a change for it. A host that set `dnf_makecache__service_state: 'started'` no longer runs `dnf makecache` on every run of the role. Use `dnf_makecache__timer_enabled` and `dnf_makecache__timer_state` for the periodic cache refresh. * **role:kibana**: The session cookie always carries the `Secure` flag, also behind a reverse proxy that terminates TLS, where Kibana left the flag off. A Kibana that browsers reach over plain HTTP no longer logs anyone in until `kibana__xpack_security_secure_cookies: false` is set. Remove `xpack.security.secureCookies` from `kibana__raw` if you set it there. * **role:icingaweb2**: The session and remember-me cookies always carry the `Secure` flag, also behind a reverse proxy that terminates TLS and talks plain HTTP to IcingaWeb2, where IcingaWeb2 left the flag off. An IcingaWeb2 that browsers reach over plain HTTP no longer logs anyone in until `icingaweb2__cookie_secure: false` is set. diff --git a/roles/bind/README.md b/roles/bind/README.md index 5baab3e74..5a467f550 100644 --- a/roles/bind/README.md +++ b/roles/bind/README.md @@ -143,6 +143,18 @@ 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. @@ -224,6 +236,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' diff --git a/roles/bind/defaults/main.yml b/roles/bind/defaults/main.yml index 113493b91..16d35f2ad 100644 --- a/roles/bind/defaults/main.yml +++ b/roles/bind/defaults/main.yml @@ -5,6 +5,8 @@ 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' diff --git a/roles/bind/tasks/main.yml b/roles/bind/tasks/main.yml index b891a9de3..74734a556 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' diff --git a/roles/bind/templates/etc/named.conf.j2 b/roles/bind/templates/etc/named.conf.j2 index 757d7aba8..c653072bf 100644 --- a/roles/bind/templates/etc/named.conf.j2 +++ b/roles/bind/templates/etc/named.conf.j2 @@ -1,61 +1,85 @@ # {{ ansible_managed }} -# 2026091501 +# 2026091502 +# # 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. 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"; - {% 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 %} 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 { diff --git a/roles/bind/vars/main.yml b/roles/bind/vars/main.yml new file mode 100644 index 000000000..da8dd968c --- /dev/null +++ b/roles/bind/vars/main.yml @@ -0,0 +1,15 @@ +# 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__zones + | selectattr("type", "defined") + | selectattr("type", "in", ["forward", "static-stub", "stub"]) + | map(attribute="name") + | list + + bind__dnssec_validate_except + ) + | unique + }}' From db4220c39087afef2c827432398bc7cfbabad06e Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Tue, 15 Sep 2026 16:46:06 +0200 Subject: [PATCH 03/10] fix(roles/bind): answer private reverse lookups from the empty zones BIND does not create an automatic empty zone (RFC 6303) for a name it would forward with policy `only` (bin/named/server.c in 9.18.33), and the global `forward only` plus forwarders covers every name. named therefore created none of its 97/98 empty zones, and reverse lookups for private and special-use addresses went to the forwarders: a logging forwarder received 5.2.42.10.in-addr.arpa, 7.7.168.192.in-addr.arpa and 1.0.16.172.in-addr.arpa on bind 9.11.36, 9.16.23 and 9.18.33, also with a 1.42.10.in-addr.arpa zone in bind__zones. `empty-zones-enable yes` does not change that; `forward first` does, but falls back to iterative resolution when the forwarders fail. Render a `type forward; forwarders { };` zone for each name of BIND's empty_zones[] list, which switches forwarding off for that name only and lets named create the empty zone. resolver.arpa is only in the 9.18 list, 0.in-addr.arpa and the ::1 reverse zone are defined by named.rfc1912.zones. Skipped are names that bind__zones defines (a duplicate zone is an error) and the parents of forward and stub zones in bind__zones, since an empty zone answered 10.99.0.1 with NXDOMAIN instead of passing it to the forwarders of a 99.10.in-addr.arpa zone. Verified on Rocky 8, 9 and 10 against a logging forwarder: private reverse lookups (10/8, 172.17/16, 100.64/10, 169.254/16, fd00::/8, fe80::/10) answered NXDOMAIN from the empty zones, master zones in bind__zones answered from their files, a forward zone and its parent still forwarded, public PTR and A lookups still forwarded, never resolved iteratively. The bind playbook answers `dig -x 10.1.2.3` from 10.IN-ADDR.ARPA and the second run reports changed=0. --- CHANGELOG.md | 1 + roles/bind/README.md | 2 +- roles/bind/templates/etc/named.conf.j2 | 9 +- roles/bind/vars/main.yml | 144 ++++++++++++++++++++++++- 4 files changed, 149 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 170c4b832..18eb7bf95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +* **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:monitoring_plugins**: A package install that fails no longer leaves the Monitoring Plugins unlocked, so a later system update cannot move them past `monitoring_plugins__version`. The lock that existed before the run is set again. * **role:monitoring_plugins**: A run against an unchanged host no longer reports changes for the package versionlock ([#353](https://github.com/Linuxfabrik/lfops/issues/353)). * **role:collabora**: A run against an unchanged host no longer reports changes for the coolwsd log file and the ownership of `/etc/coolwsd`. diff --git a/roles/bind/README.md b/roles/bind/README.md index 5a467f550..94abb14e2 100644 --- a/roles/bind/README.md +++ b/roles/bind/README.md @@ -157,7 +157,7 @@ bind__zones: `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']` diff --git a/roles/bind/templates/etc/named.conf.j2 b/roles/bind/templates/etc/named.conf.j2 index c653072bf..310177b55 100644 --- a/roles/bind/templates/etc/named.conf.j2 +++ b/roles/bind/templates/etc/named.conf.j2 @@ -1,5 +1,5 @@ # {{ ansible_managed }} -# 2026091502 +# 2026091503 # # https://bind9.readthedocs.io/en/latest/reference.html # Follows the /etc/named.conf shipped by the bind package, with the Linuxfabrik additions at the @@ -97,6 +97,13 @@ zone "." IN { include "/etc/named.rfc1912.zones"; include "/etc/named.root.key"; +// 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. +{% for item in __bind__empty_zones_without_forwarding %} +zone "{{ item }}" IN { type forward; forwarders { }; }; +{% endfor %} + {% for item in bind__zones %} zone "{{ item['name'] }}" IN { type {{ item['type'] | d('master') }}; diff --git a/roles/bind/vars/main.yml b/roles/bind/vars/main.yml index da8dd968c..7451a55f5 100644 --- a/roles/bind/vars/main.yml +++ b/roles/bind/vars/main.yml @@ -4,12 +4,146 @@ # from DNSSEC validation, together with the zones listed by the user. __bind__dnssec_validate_except: '{{ ( - bind__zones - | selectattr("type", "defined") - | selectattr("type", "in", ["forward", "static-stub", "stub"]) - | map(attribute="name") - | list + __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 }}' From 21279ea7b987667ba787d3fff4baffc3da17af33 Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Fri, 2 Oct 2026 10:59:14 +0200 Subject: [PATCH 04/10] docs(roles/bind): document where named.conf deviates from the package --- roles/bind/README.md | 9 ++++++++- roles/bind/defaults/main.yml | 4 ++-- roles/bind/templates/etc/named.conf.j2 | 5 +++-- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/roles/bind/README.md b/roles/bind/README.md index 94abb14e2..dfb613d0b 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` @@ -186,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` diff --git a/roles/bind/defaults/main.yml b/roles/bind/defaults/main.yml index 16d35f2ad..1a68ad7fa 100644 --- a/roles/bind/defaults/main.yml +++ b/roles/bind/defaults/main.yml @@ -11,8 +11,8 @@ 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/templates/etc/named.conf.j2 b/roles/bind/templates/etc/named.conf.j2 index 310177b55..4bdc191f1 100644 --- a/roles/bind/templates/etc/named.conf.j2 +++ b/roles/bind/templates/etc/named.conf.j2 @@ -1,9 +1,10 @@ # {{ ansible_managed }} -# 2026091503 +# 2026100201 # # 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. +# 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; From 1cc3b3650731154de1b736602c83c395d416eabb Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Fri, 2 Oct 2026 11:16:57 +0200 Subject: [PATCH 05/10] refactor(roles/bind): render the empty-zone forward blocks only with recursion --- roles/bind/templates/etc/named.conf.j2 | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/roles/bind/templates/etc/named.conf.j2 b/roles/bind/templates/etc/named.conf.j2 index 4bdc191f1..c6bccfd55 100644 --- a/roles/bind/templates/etc/named.conf.j2 +++ b/roles/bind/templates/etc/named.conf.j2 @@ -1,5 +1,5 @@ # {{ ansible_managed }} -# 2026100201 +# 2026100202 # # https://bind9.readthedocs.io/en/latest/reference.html # Follows the /etc/named.conf shipped by the bind package, with the Linuxfabrik additions at the @@ -98,12 +98,16 @@ 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 { From a679b7bda6b49362fbe9796d564280f9097115a8 Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Fri, 2 Oct 2026 11:18:03 +0200 Subject: [PATCH 06/10] fix(roles/bind): save slave zones to their zone file again --- CHANGELOG.md | 1 + roles/bind/templates/etc/named.conf.j2 | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18eb7bf95..820e32700 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ 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:monitoring_plugins**: A package install that fails no longer leaves the Monitoring Plugins unlocked, so a later system update cannot move them past `monitoring_plugins__version`. The lock that existed before the run is set again. * **role:monitoring_plugins**: A run against an unchanged host no longer reports changes for the package versionlock ([#353](https://github.com/Linuxfabrik/lfops/issues/353)). diff --git a/roles/bind/templates/etc/named.conf.j2 b/roles/bind/templates/etc/named.conf.j2 index c6bccfd55..be91aeb31 100644 --- a/roles/bind/templates/etc/named.conf.j2 +++ b/roles/bind/templates/etc/named.conf.j2 @@ -1,5 +1,5 @@ # {{ ansible_managed }} -# 2026100202 +# 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 @@ -113,7 +113,7 @@ zone "{{ item }}" IN { type forward; forwarders { }; }; 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 %} From 60d9421cdcb944befc2544d28e3fb3397e2a0876 Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Fri, 2 Oct 2026 11:18:09 +0200 Subject: [PATCH 07/10] docs(roles/bind): use the slave type for the secondary in the primary-secondary example --- roles/bind/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/roles/bind/README.md b/roles/bind/README.md index dfb613d0b..330753020 100644 --- a/roles/bind/README.md +++ b/roles/bind/README.md @@ -345,7 +345,7 @@ bind__zones: bind__zones: - name: 'example.com' file: 'forward.zone' - type: 'master' + type: 'slave' masters: - '192.0.2.2' From 2fb50d22948e747c72c33c26368a1412102d4fc5 Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Fri, 2 Oct 2026 11:18:10 +0200 Subject: [PATCH 08/10] feat(roles/bind): add meta/argument_specs.yml --- CHANGELOG.md | 1 + roles/bind/meta/argument_specs.yml | 120 +++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 roles/bind/meta/argument_specs.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index 820e32700..56b23e609 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,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:rstudio_server, playbook:rstudio_server**: Add a role and playbook to install RStudio Server Open Source, the browser-based R development environment. Users sign in with their account on the host and have to be a member of a group to be let in at all, the PAM profile covers directory users where the vendor's covers local ones only, and the R sessions can be given a memory and process budget. * **role:shiny_server, playbook:setup_shiny_server**: Add a role and playbook to install Shiny Server Open Source and serve several tenants from one host, each with its own hostname, password file, R worker and system account, behind an Apache httpd reverse proxy that also passes the authenticated user into the application. Shiny Server itself listens on the loopback only, because it authenticates nobody and hands every client header to the application. * **role:r, playbook:r**: Add a role and playbook to install R and pandoc, point R at the Posit Public Package Manager so that CRAN packages arrive as prebuilt binaries instead of being compiled on the host, and install the CRAN packages an application needs. 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.' From df3e348cb10be5865cc6f9a9f8767f7454e94672 Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Fri, 2 Oct 2026 11:18:10 +0200 Subject: [PATCH 09/10] test(roles/bind): add a molecule scenario --- extensions/molecule/bind/converge.yml | 2 + .../group_vars/systems_under_test.yml | 80 ++++++++++ extensions/molecule/bind/inventory/hosts.yml | 19 +++ extensions/molecule/bind/molecule.yml | 3 + extensions/molecule/bind/verify.yml | 141 ++++++++++++++++++ 5 files changed, 245 insertions(+) create mode 100644 extensions/molecule/bind/converge.yml create mode 100644 extensions/molecule/bind/inventory/group_vars/systems_under_test.yml create mode 100644 extensions/molecule/bind/inventory/hosts.yml create mode 100644 extensions/molecule/bind/molecule.yml create mode 100644 extensions/molecule/bind/verify.yml 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' From 62c1224aba902e42a1165b8c59e62f9f0125778f Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Fri, 2 Oct 2026 11:24:39 +0200 Subject: [PATCH 10/10] refactor(roles/bind): align the role with roles/example --- roles/bind/tasks/main.yml | 6 +++--- roles/bind/templates/etc/named.conf.j2 | 6 +++--- roles/bind/templates/etc/sysconfig/named.j2 | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/roles/bind/tasks/main.yml b/roles/bind/tasks/main.yml index 74734a556..c837880b7 100644 --- a/roles/bind/tasks/main.yml +++ b/roles/bind/tasks/main.yml @@ -81,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 be91aeb31..ce55e3e0b 100644 --- a/roles/bind/templates/etc/named.conf.j2 +++ b/roles/bind/templates/etc/named.conf.j2 @@ -74,7 +74,7 @@ options { {{ item }}; {% endfor %} }; -{% 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 %} version "Linuxfabrik"; @@ -130,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 }}; @@ -156,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") }}'