Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions extensions/molecule/bind/converge.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
- name: 'Converge bind playbook'
ansible.builtin.import_playbook: 'linuxfabrik.lfops.bind'
Original file line number Diff line number Diff line change
@@ -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 `<host>.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 ; <SERNO>
1H ; <TIME-TO-REFRESH>
1H ; <TIME-TO-RETRY>
1W ; <TIME-TO-EXPIRE>
1D ) ; <minimum-TTL>

@ 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 [])
}}'
19 changes: 19 additions & 0 deletions extensions/molecule/bind/inventory/hosts.yml
Original file line number Diff line number Diff line change
@@ -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:
3 changes: 3 additions & 0 deletions extensions/molecule/bind/molecule.yml
Original file line number Diff line number Diff line change
@@ -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.
141 changes: 141 additions & 0 deletions extensions/molecule/bind/verify.yml
Original file line number Diff line number Diff line change
@@ -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'
28 changes: 25 additions & 3 deletions roles/bind/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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']`

Expand Down Expand Up @@ -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`

Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -323,7 +345,7 @@ bind__zones:
bind__zones:
- name: 'example.com'
file: 'forward.zone'
type: 'master'
type: 'slave'
masters:
- '192.0.2.2'

Expand Down
6 changes: 4 additions & 2 deletions roles/bind/defaults/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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") }}'
Expand Down
Loading
Loading