Skip to content
Open
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
13 changes: 11 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

**Highlights:** Apache Solr runs on Debian, Ubuntu and RHEL 10 and supports Solr 9.11. Hosts that deploy the OWASP Core Rule Set with apache_httpd need `apache_httpd__mod_security_coreruleset_version` in their inventory, since the role no longer picks a release on its own and verifies the download against a checksum. The `systemd_unit` role also manages socket units, so a host can forward a TCP port with `systemd-socket-proxyd` without installing a proxy server.
**Highlights:** Apache Solr runs on Debian, Ubuntu and RHEL 10 and supports Solr 9.11. Hosts that deploy the OWASP Core Rule Set with apache_httpd need `apache_httpd__mod_security_coreruleset_version` in their inventory, since the role no longer picks a release on its own and verifies the download against a checksum. The `systemd_unit` role also manages socket units, so a host can forward a TCP port with `systemd-socket-proxyd` without installing a proxy server. Hosts set up with `setup_basic` follow more of the CIS Benchmarks out of the box: root can no longer log in over SSH, USB storage is blocked, a sysctl hardening baseline is applied, and the cron configuration is readable by root only. Before updating, make sure Ansible logs in with an unprivileged user, and allow USB storage on hosts that need it.

### Breaking Changes

* **role:sshd, playbook:setup_basic**: `PermitRootLogin` defaults to `no`, as the CIS Benchmarks require. A run in which Ansible logs in as root aborts before sshd is changed, instead of locking Ansible out. Log in as an unprivileged user with sudo, or set `sshd__permit_root_login: 'prohibit-password'` for hosts that Ansible has to reach as root.
* **role:sshd**: sshd disconnects a client that has not answered for 30 seconds (`ClientAliveInterval 15`), as the CIS Benchmarks require. Set `sshd__client_alive_interval: 0` to keep idle sessions with an unresponsive client open.
* **role:kernel_modules**: The `usb-storage` kernel module is blocked by default, as the CIS Benchmarks require, which stops USB flash drives and USB disks after the next reboot. The run aborts on hosts with a USB disk attached, since a host booting from one would no longer boot. Add `kernel_modules__modules__host_var: [{name: 'usb-storage', enabled: true}]` to the inventory of hosts that need USB storage.
* **role:kernel_settings**: A sysctl hardening baseline following the CIS Benchmarks is applied by default: ICMP redirects are neither accepted nor sent, source-routed packets are dropped, martian packets are logged, unprivileged users can no longer read the kernel log or attach a debugger to processes they did not start themselves, and unprivileged BPF is off for good. Override single values in `kernel_settings__sysctl__host_var`, or remove them with `state: 'absent'`.
* **role:postgresql_server**: Databases are created with the `C.UTF-8` locale instead of `en_US.UTF-8`, which failed on hosts with only the minimal glibc language pack. `C.UTF-8` sorts text by code point instead of by language rules. Existing databases are unaffected. To create new databases as before, set `lc_collate: 'en_US.UTF-8'` and `lc_ctype: 'en_US.UTF-8'` on their entries in `postgresql_server__databases__*_var`.
* **role:postgresql_server, playbook:setup_mastodon**: `postgresql_server__version` is mandatory, and PostgreSQL is only installed from the PostgreSQL Yum Repository instead of optionally from the distribution packages. Set the major version in your inventory, for example `postgresql_server__version: '18'`. On a host that already runs PostgreSQL from the distribution packages, the run aborts before it installs anything; move the data to the PostgreSQL Yum Repository release first, as described in the role README.
* **role:postgresql_server**: `postgresql_server__pg_hba_entries` is replaced by `postgresql_server__pg_hba_local_entries__host_var` / `__group_var` for `local` records and `postgresql_server__pg_hba_host_entries__host_var` / `__group_var` for all `host*` records. Both are merged with the role defaults instead of replacing them, and an entry with `state: 'absent'` removes a default. Move your `local` entries to the first variable without their `type` key, and all other entries to the second, where `address` is mandatory. Entries from the inventory are written before the role defaults. The run aborts as long as the old variable is set.
Expand Down Expand Up @@ -50,6 +54,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

* **role:cron_permissions, playbook:cron_permissions, playbook:setup_basic**: Add a role and playbook that make `/etc/crontab`, `/etc/cron.deny` and the `/etc/cron.*` directories readable by root only on hosts with cron, as the CIS Benchmarks require.
* **role:apache_tomcat**: With `lfops__remove_rpmnew_rpmsave`, the role also removes the `.rpmnew` and `.rpmsave` files next to the Tomcat configuration files it deploys.
* **role:apache_tomcat**: Add support for Tomcat 10.1 on RHEL 10.
* **role:mongodb**: Supports MongoDB 8.0 on Debian and Ubuntu, so MongoDB runs on Ubuntu 24.04, where upstream offers 8.0 only.
Expand All @@ -72,7 +77,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* **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`.
* **role:lynis, playbook:lynis, playbook:setup_basic**: Add a role and playbook that install Lynis on every `setup_basic` host and audit it once a day for the lynis-logfile monitoring plugin, with findings accepted through `lynis__skip_tests` instead of a hand-edited `/etc/lynis/custom.prf`.
* **role:lynis, playbook:lynis, playbook:setup_basic**: Add a role and playbook that install Lynis on every `setup_basic` host and audit it once a day for the lynis-logfile monitoring plugin, with findings accepted through `lynis__skip_tests` instead of a hand-edited `/etc/lynis/custom.prf`. By default the role skips the tests whose suggestion LFOps deliberately does not follow, giving the reason for each in `custom.prf`.
* **role:repo_monitoring_plugins**: `--tags repo_monitoring_plugins:remove` removes the repository and its signing key.
* **role:grafana**: `grafana__preinstall_auto_update` controls whether Grafana updates its preinstalled plugins on every start.
* **role:icingaweb2**: `icingaweb2__cookie_path` sets the path of the session and remember-me cookies, for example `/`.
Expand All @@ -93,6 +98,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

* **role:mariadb_server**: The role no longer sets `sunrpc.tcp_slot_table_entries`, which only concerns NFS clients, and removes it from the kernel settings of hosts that got it from an earlier version.
* **role:kernel_modules**: Every blocked module is also blacklisted, so the CIS audit, which checks for both lines, accepts the configuration.
* **role:at**: On the Red Hat family, `/etc/at.deny` is readable by root only, as the CIS Benchmarks require.
* **role:postgresql_server**: A changed `pg_hba.conf` or `postgresql_server__conf_password_encryption` reloads PostgreSQL instead of restarting it, so open connections are kept; the first run after the update still restarts it once.
* **role:postgresql_server**: The configuration is checked before PostgreSQL is restarted, so a broken setting in `postgresql.conf` or `pg_hba.conf` aborts the run with the error message instead of leaving PostgreSQL down after the restart.
* **role:postfix**: The SMTP greeting banner no longer names Postfix or the OS (`<hostname> ESMTP LFOps`), which satisfies the Lynis check MAIL-8818.
Expand Down Expand Up @@ -144,6 +152,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

* **role:kernel_settings**: A run no longer fails on hosts whose kernel settings still contain a `sunrpc.*` value from an earlier run while the `sunrpc` module is not loaded.
* **role:postgresql_server**: `postgresql-dump` also saves the roles and tablespaces (`globals.sql.gz`), without which a restored database fails on every object owner, no longer skips the `postgres` database and databases whose name starts with `template`, and uses the client tools of the installed PostgreSQL version.
* **role:postgresql_server**: Clients connecting to `localhost` are accepted over IPv6 as well, where they were rejected with `no pg_hba.conf entry for host "::1"`.
* **module:bitwarden_item**: The module no longer edits the item and reports a change on every run when `collection_id` is set.
Expand Down
1 change: 1 addition & 0 deletions COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ Which Ansible role is proven to run on which OS?
| collect_rpmnew_rpmsave | (x) | (x) | x | x | (x) | (x) | (x) | (x) | Fedora 40 |
| core_dumps | x | x | x | x | x | x | x | x | |
| coturn | | | x | x | (x) | | | | |
| cron_permissions | x | x | x | x | x | x | x | x | |
| crypto_policy | | | x | x | x | | | | |
| dnf_makecache | | | x | x | x | | | | |
| dnf_versionlock | | | x | x | (x) | | | | |
Expand Down
22 changes: 22 additions & 0 deletions extensions/molecule/cron_permissions/converge.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# The role only restricts an existing cron, so give every system under test one first, as a host
# set up by its distribution or by an application such as LibreNMS has.
- name: 'Install cron as the role expects to find it'
hosts: 'systems_under_test'
tasks:

- name: 'Install cronie / cron'
ansible.builtin.package:
name: '{{ (ansible_facts["os_family"] == "RedHat") | ternary("cronie", "cron") }}'
state: 'present'
retries: 3
delay: 10

- name: 'systemctl enable --now crond.service / cron.service'
ansible.builtin.service:
name: '{{ (ansible_facts["os_family"] == "RedHat") | ternary("crond", "cron") }}.service'
enabled: true
state: 'started'


- name: 'Converge cron_permissions playbook'
ansible.builtin.import_playbook: 'linuxfabrik.lfops.cron_permissions'
16 changes: 16 additions & 0 deletions extensions/molecule/cron_permissions/inventory/hosts.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# yamllint disable rule:empty-values

lfops_cron_permissions:
children:
systems_under_test:

systems_under_test:
hosts:
debian12-vm:
debian13-vm:
rocky8-vm:
rocky9-vm:
rocky10-vm:
ubuntu2204-vm:
ubuntu2404-vm:
ubuntu2604-vm:
1 change: 1 addition & 0 deletions extensions/molecule/cron_permissions/molecule.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Molecule scenario marker
70 changes: 70 additions & 0 deletions extensions/molecule/cron_permissions/verify.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
- name: 'Verify the cron_permissions playbook left a working, root-only cron'
hosts: 'systems_under_test'
gather_facts: false
tasks:

# The daemon reads /etc/cron.d as root, so a job there has to run although the directory is
# 0700 now.
- name: 'Deploy /etc/cron.d/molecule-probe'
ansible.builtin.copy:
content: "* * * * * root touch /run/molecule-cron-probe\n"
dest: '/etc/cron.d/molecule-probe'
owner: 'root'
group: 'root'
mode: 0o644
changed_when: false

- name: 'Wait for the cron job to run'
ansible.builtin.wait_for:
path: '/run/molecule-cron-probe'
timeout: 150

- name: 'rm -f /etc/cron.d/molecule-probe /run/molecule-cron-probe'
ansible.builtin.file:
path: '{{ item }}'
state: 'absent'
loop:
- '/etc/cron.d/molecule-probe'
- '/run/molecule-cron-probe'
changed_when: false

- name: 'runuser --user nobody -- ls /etc/cron.d'
ansible.builtin.command: 'runuser --user nobody -- ls /etc/cron.d'
changed_when: false
failed_when: false
register: '__molecule__cron_nobody_ls_result'

- name: 'Assert that an unprivileged user can no longer list the system cron jobs'
ansible.builtin.assert:
that:
- '__molecule__cron_nobody_ls_result["rc"] != 0'
- '"Permission denied" in __molecule__cron_nobody_ls_result["stderr"]'
fail_msg: '{{ __molecule__cron_nobody_ls_result | to_json }}'

# crontab(1) of an unprivileged user still has to work: it reads /etc/cron.deny, which is
# 0600 on the Red Hat family now.
- name: 'runuser --user nobody -- crontab -l'
ansible.builtin.command: 'runuser --user nobody -- crontab -l'
changed_when: false
failed_when: false
register: '__molecule__cron_nobody_crontab_result'

- name: 'Assert that crontab answers an unprivileged user with "no crontab", not with a refusal'
ansible.builtin.assert:
that:
- '"no crontab for nobody" in __molecule__cron_nobody_crontab_result["stderr"]'
fail_msg: '{{ __molecule__cron_nobody_crontab_result | to_json }}'

- name: 'stat /etc/crontab /etc/cron.d'
ansible.builtin.stat:
path: '{{ item }}'
loop:
- '/etc/crontab'
- '/etc/cron.d'
register: '__molecule__cron_stat_result'

- name: 'Assert that /etc/crontab is 0600 and /etc/cron.d is 0700'
ansible.builtin.assert:
that:
- '__molecule__cron_stat_result["results"] | map(attribute="stat.mode") | list == ["0600", "0700"]'
fail_msg: '{{ __molecule__cron_stat_result["results"] | map(attribute="stat.mode") | list }}'
5 changes: 5 additions & 0 deletions extensions/molecule/inventory/group_vars/all.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,8 @@ __molecule__instance_name: >-
# on its own. A pool in a directory no package owns keeps that grant, and the rootless VM
# creation it buys, working across upgrades. vm-create.yml asserts that the pool exists.
kvm_vm__pool: '{{ lookup("env", "LFOPS_TEST_POOL") | d("lfops-molecule", true) }}'

# Molecule logs in to every system under test as root (host_vars/*-vm.yml). The sshd role
# defaults to `PermitRootLogin no` and refuses to deploy it while Ansible logs in as root, so
# the scenarios that run sshd keep key-based root logins.
sshd__permit_root_login: 'prohibit-password'
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
kernel_settings__sysctl__group_var:
# overrides the value of the hardening baseline, to check that an inventory entry replaces
# the default entry of the same name
- name: 'kernel.kptr_restrict'
value: 1
- name: 'sunrpc.tcp_slot_table_entries'
value: 128
- name: 'vm.overcommit_memory'
Expand Down
27 changes: 27 additions & 0 deletions extensions/molecule/kernel_settings/sysctl/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,30 @@
ansible.builtin.command: 'tuned-adm verify --ignore-missing'
become: true
changed_when: false

# after the reboot, so the values prove that TuneD applies the baseline at boot
- name: 'Load the hardening baseline of the kernel_settings role'
ansible.builtin.include_vars:
file: '{{ playbook_dir }}/../../../../roles/kernel_settings/vars/main.yml'

- name: 'sysctl --values <the keys of the hardening baseline>'
ansible.builtin.command: 'sysctl --values {{ __kernel_settings__sysctl__hardening | map(attribute="name") | join(" ") }}'
changed_when: false
register: '__molecule__sysctl_values_result'

- name: 'Assert that the kernel uses the hardening baseline, with the value the inventory overrides'
ansible.builtin.assert:
that:
- '__molecule__sysctl_values_result["stdout_lines"] == __molecule__expected'
fail_msg: '{{ __kernel_settings__sysctl__hardening | map(attribute="name") | zip(__molecule__sysctl_values_result["stdout_lines"]) | map("join", "=") | list }}'
vars:
# the baseline as name -> value, with the value the inventory overrides
__molecule__baseline: '{{
dict(__kernel_settings__sysctl__hardening | map(attribute="name")
| zip(__kernel_settings__sysctl__hardening | map(attribute="value")))
| combine({"kernel.kptr_restrict": 1})
}}'
__molecule__expected: '{{
__kernel_settings__sysctl__hardening | map(attribute="name")
| map("extract", __molecule__baseline) | map("string") | list
}}'
Loading
Loading