Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
13dfb00
fix(roles/nextcloud): start the LDAP remnants report from its own timer
markuslf Sep 22, 2026
adf6a7b
fix(roles/nextcloud): apply ownership and SELinux label to nextcloud_…
markuslf Sep 22, 2026
c0e003a
fix(roles/nextcloud): add missing primary keys and run expensive repa…
markuslf Sep 22, 2026
d8f7c82
fix(roles/nextcloud): pass the passwords to occ on stdin
markuslf Sep 22, 2026
1cdeb5a
fix(roles/nextcloud): make nextcloud-update readable by root only
markuslf Sep 22, 2026
e154792
fix(roles/nextcloud)!: verify the certificate of the SMTP server
markuslf Sep 22, 2026
00bc8ff
docs(roles/nextcloud): document the subkeys of nextcloud__apps and ne…
markuslf Sep 22, 2026
6ab6527
refactor(roles/nextcloud): drop forbidden filename characters Nextclo…
markuslf Sep 22, 2026
0d3929b
feat(roles/fail2ban): add a nextcloud filter and jail
markuslf Sep 22, 2026
5c5d02d
feat(roles/nextcloud)!: log to /var/log/nextcloud
markuslf Sep 22, 2026
dff53bb
feat(playbooks/setup_nextcloud): optionally run fail2ban, never ban t…
markuslf Sep 22, 2026
dcad17f
feat(roles/nextcloud)!: abort when PHP or MariaDB do not fit the inst…
markuslf Sep 22, 2026
5b13c69
feat(roles/valkey): listen on a Unix socket that the valkey group may…
markuslf Sep 22, 2026
0e0db50
feat(roles/redis): listen on a Unix socket that the redis group may use
markuslf Sep 22, 2026
1428c2d
feat(roles/redis): add meta/argument_specs.yml
markuslf Sep 22, 2026
499c898
fix(playbooks/setup_nextcloud): configure SELinux before the services
markuslf Sep 22, 2026
1c11778
feat(roles/nextcloud)!: connect to Redis / Valkey through the Unix so…
markuslf Sep 22, 2026
e2b8728
fix(roles/nextcloud): restart PHP-FPM and switch to cron only on a ch…
markuslf Sep 22, 2026
59ce043
fix(roles/nextcloud): retry the installer after a failed attempt
markuslf Sep 22, 2026
06b4b30
fix(roles/valkey): keep the ownership of valkey.conf that the package…
markuslf Sep 22, 2026
2e37d37
fix(roles/redis): keep the ownership of redis.conf that the package sets
markuslf Sep 22, 2026
0dd8f49
fix(roles/nextcloud): keep the Icinga API password out of the process…
markuslf Sep 22, 2026
07e93d7
feat(roles/nextcloud)!: make nextcloud__version mandatory
markuslf Sep 22, 2026
e9596a9
feat(roles/nextcloud)!: connect to MariaDB as a dedicated user
markuslf Sep 22, 2026
1000f62
docs(roles/nextcloud): describe how to connect Collabora Online
markuslf Sep 22, 2026
6d847db
feat(roles/collabora): support CODE 26.04.4
markuslf Sep 22, 2026
ae23319
fix(roles/borg_local): send valid JSON for the clamd@scan downtime
markuslf Sep 22, 2026
b640f83
fix(roles/borg_local): keep the Icinga API credentials away from loca…
markuslf Sep 22, 2026
abd0b78
fix(roles/schedule_reboot): keep credentials and webhook away from lo…
markuslf Sep 22, 2026
649fa11
fix(roles/tools): move the Icinga API credentials out of /etc/profile.d
markuslf Sep 22, 2026
0f1b1b4
fix(roles/nextcloud): stop installing the APCu PHP extension
markuslf Sep 22, 2026
093877c
feat(roles/nextcloud)!: remove the PHP modules Nextcloud does not use
markuslf Sep 22, 2026
c481b71
test(molecule): assert the socket, the config ownership and the root-…
markuslf Sep 22, 2026
4cdd7ca
docs(roles/nextcloud): describe the requirements without naming versions
markuslf Sep 22, 2026
71cc797
fix(roles/nextcloud): look up the MariaDB socket for the platform
NavidSassan Sep 23, 2026
bcb5e59
docs(roles/nextcloud): list fail2ban as off by default and align the …
NavidSassan Sep 23, 2026
2b2a204
test(molecule): assert the Redis / Valkey socket and config ownership…
NavidSassan Sep 23, 2026
cf5368e
test(molecule): assert setup_basic keeps the Icinga API credentials t…
NavidSassan Sep 23, 2026
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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Breaking Changes

* **role:nextcloud**: The role removes the PHP extensions APCu, bcmath, IMAP and memcached, which Nextcloud does not use in the setup the role creates: all caches live in Redis or Valkey. An installation that needs one of them, such as the IMAP backend of the External user authentication app, lists it in `php__modules__host_var` with `state: 'present'`.
* **role:nextcloud**: `nextcloud__database_login` is mandatory, and `nextcloud__mariadb_login` is gone. A new installation connects to MariaDB as this user, which `setup_nextcloud` creates with access to the Nextcloud database from `localhost` only, instead of handing the database administrator to the installer, which created an `oc_` user that may connect from any host. Existing installations keep their database user.
* **role:nextcloud**: `nextcloud__version` is mandatory, for example `'latest-35'`. It only picks the release that a new installation downloads.
* **role:nextcloud**: Nextcloud reaches Redis or Valkey through its Unix socket instead of over TCP, and the web server user joins the `redis` or `valkey` group, which may use the socket. Run the complete `setup_nextcloud` playbook rather than the `nextcloud` role alone, so that Redis or Valkey open the socket first. Set `nextcloud__redis_unixsocket: ''` to stay with TCP.
* **role:nextcloud**: The role aborts before it changes anything when the installed PHP or MariaDB does not fit the installed Nextcloud major version, or when it does not know that major version (30 to 35 are known). For example, Nextcloud 35 needs PHP 8.3 to 8.5 and MariaDB 10.11 or newer. Upgrade PHP or MariaDB, as the error message says, before running the role again.
* **role:nextcloud**: Nextcloud logs to `/var/log/nextcloud/nextcloud.log` instead of `nextcloud.log` in the data directory, since fail2ban cannot read the data directory under SELinux. Adjust log shippers and monitoring that read the old file, and remove it once it is no longer needed.
* **role:nextcloud**: Nextcloud verifies the certificate of the SMTP server, where the role had switched the check off. If your mail server presents a self-signed certificate, make Nextcloud trust it, or set `mail_smtpstreamoptions ssl allow_self_signed` to `true` and `mail_smtpstreamoptions ssl verify_peer` and `mail_smtpstreamoptions ssl verify_peer_name` to `false` in `nextcloud__sysconfig__host_var` to keep the previous behaviour.
* **role:kernel_modules**: The `tun` kernel module is blocked by default (CVE-2026-81000, [RHSB-2026-011](https://access.redhat.com/security/vulnerabilities/RHSB-2026-011)). This stops OpenVPN, WireGuard in userspace, rootless Podman and Docker networking and libvirt VM networking after the next reboot, which the role requests on hosts where `tun` is loaded. Before running the role, add `kernel_modules__modules__host_var: [{name: 'tun', enabled: true}]` to the inventory of every such host. Rootful Docker and Podman with bridge networking are not affected.
* **role:grafana**: `grafana__users_case_insensitive_login` is gone; remove it from your inventory. Grafana has ignored the setting since v11.0.0 and always matches logins case-insensitively.
* **role:system_update**: `system_update__pre_update_code` and `system_update__post_update_code` now also run in the daily security lane on Rocky, around the transaction that installs the hot-fixes, where until now only the weekly lane ran them. Set `system_update__security_pre_update_code: ''` and `system_update__security_post_update_code: ''` to keep the security lane free of it, or set either to a codeblock of its own to have the two lanes do different things.
Expand All @@ -32,6 +39,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

* **role:collabora**: Support Collabora Online CODE 26.04.4.
* **role:redis**: The role's inventory variables are type-checked when it starts, so a mistyped value fails the run right away instead of surfacing further in as a confusing error.
* **playbook:setup_nextcloud**: `setup_nextcloud__skip_fail2ban: false` runs fail2ban on a Nextcloud host that clients reach directly, where it bans an IP for 8 hours after 5 failed Nextcloud logins within 10 minutes and never bans the reverse proxies listed in the Nextcloud setting `trusted_proxies`.
* **role:fail2ban**: The `nextcloud` filter and the `z10-nextcloud` jail ban IPs with too many failed Nextcloud logins or two-factor challenges, following the Nextcloud hardening guide.
* **role:wordpress**: Entries in `wordpress__plugins` accept `enabled: false`, which keeps a plugin installed but deactivated.
* **role:system_update**: The role's inventory variables are type-checked when it starts, so a mistyped value fails the run right away instead of surfacing further in as a confusing error.
* **role:wordpress**: Several WordPress instances can share a host as pseudo hosts in the inventory, under different host names as well as under different paths of one host name, such as `https://example.com/blog`.
Expand All @@ -48,6 +59,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

* **role:redis, role:valkey**: Redis and Valkey also listen on a Unix socket that only the members of their group may use, on RHEL at the path the package ships (`/run/redis/redis.sock`, `/run/valkey/valkey.sock`).
* **role:grafana**: `grafana.ini` follows the file that current Grafana packages ship, so deploying it only changes the settings LFOps manages. As a side effect, recording rules time out after 30 seconds instead of 10.
* **plugin:bitwarden_item, module:bitwarden_item**: A run against a vault that contains no items at all aborts instead of creating the first one, because `bw serve` briefly reports an empty vault after every sync ([bitwarden/clients#23283](https://github.com/bitwarden/clients/issues/23283)).
* **role:repo_postgresql**: The PostgreSQL version repositories take precedence over the distribution's packages of the same name, so on RHEL 10 an install or update no longer switches a PostgreSQL server from the PGDG build to the AppStream build, which uses a different file layout.
Expand All @@ -60,6 +72,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

* **role:borg_local**: The Icinga downtime for `clamd@scan` during a backup is set, where Icinga had rejected the request as invalid JSON.
* **role:redis, role:valkey**: On RHEL, a run after a reboot no longer reports the configuration file as changed, since the role keeps the ownership that the package's tmpfiles.d rule restores at every boot.
* **role:nextcloud**: A run after a failed installation installs Nextcloud, where it skipped the installer because the failed attempt had left a `config.php` behind.
* **role:nextcloud**: A run against an unchanged host no longer restarts PHP-FPM and reports no change at all.
* **role:nextcloud**: `nextcloud-update` adds missing primary keys and runs the pending mimetype migrations after an update, which Nextcloud leaves to the administrator.
* **role:nextcloud**: A `nextcloud__datadir` other than `/data` gets the ownership and the SELinux label Nextcloud needs, which the role only ever set on `/data`.
* **role:nextcloud**: The monthly LDAP remnants report runs, where its timer started the app update instead.
* **role:grafana**: The `from_name` of `grafana__smtp_config` is used as the sender name of emails, instead of the value of `skip_verify`.
* **module:bitwarden_item**: The module works with the Mitogen strategy, where it aborted with `MODULE FAILURE` on every run, for example when the `grafana` role stores its service account tokens.
* **plugin:bitwarden_item, module:bitwarden_item**: Running against several hosts in parallel no longer creates duplicates of a Bitwarden item, whether the item is new or has existed for a long time, so the next run no longer aborts with "Found multiple Bitwarden items".
Expand Down Expand Up @@ -95,6 +114,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Security

* **role:borg_local, role:schedule_reboot, role:tools**: Neither the password of the Icinga API user nor the Rocket.Chat webhook of `schedule_reboot` shows up in the process list or in a file that every local user can read, which moves `schedule-icinga-downtime` from a shell function in `/etc/profile.d/alias.sh` to a command in `/usr/local/sbin` that only root may run.
* **role:nextcloud**: The password of the Icinga API user no longer shows up in the process list while `nextcloud-update` sets or removes the downtime.
* **role:nextcloud**: `/usr/local/bin/nextcloud-update`, which holds the credentials of the Icinga API user, is readable by root only.
* **role:nextcloud**: The database and admin passwords no longer show up in the process list during the installation.
* **role:kernel_modules**: Blocks further rarely used kernel modules by default that unprivileged users can get loaded and that are prone to local privilege escalations, among them `ah6`, `pppoe` and `sctp_diag` from [RHSB-2026-011](https://access.redhat.com/security/vulnerabilities/RHSB-2026-011). This stops Bluetooth, L2TP/IPsec, PPPoE, PPTP and IPsec AH; set `enabled: true` for the modules a host needs. The role README lists them all.
* **role:wordpress**: `--tags wordpress:export` writes to `/backup/wordpress-export/<instance>`, readable by `apache` and `root` only, instead of to `/tmp`.
* **role:wordpress**: The database and admin passwords no longer show up in the process list during the installation.
Expand Down
36 changes: 36 additions & 0 deletions extensions/molecule/fail2ban/install/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,42 @@
loop_control:
label: '{{ item["item"]["label"] }}'

# The nextcloud filter ships with the role defaults as well. Its jail is wired in by the
# setup_nextcloud playbook, which tests it against a running Nextcloud; here the filter is fed
# log lines in the format of Nextcloud 35, one of each message the filter matches.
- name: 'fail2ban-regex against a {{ item["label"] }}'
ansible.builtin.command:
argv:
- 'fail2ban-regex'
- '{{ item["line"] }}'
- '/etc/fail2ban/filter.d/nextcloud.conf'
register: '__molecule__fail2ban_nextcloud_regex_result'
changed_when: false
check_mode: false
loop:
- label: 'failed WebDAV login'
line: '{"reqId":"arJoPKlNsR1zwnbJUr2K7AAAANE","level":2,"time":"2026-09-22T13:36:28+02:00","remoteAddr":"198.51.100.1","user":"--","app":"core","method":"GET","url":"/remote.php/dav/files/admin/","scriptName":"/remote.php","message":"Login failed: ''admin'' (Remote IP: ''198.51.100.1'')","userAgent":"curl/8.9.1","version":"35.0.0.10","data":{"app":"core"}}'
matched: 1
- label: 'failed login from an IPv6 address'
line: '{"reqId":"arJoPXvkDJYTkOiNa2hqmQAAAJM","level":2,"time":"2026-09-22T13:36:29+02:00","remoteAddr":"2001:db8::1","user":"--","app":"core","method":"GET","url":"/ocs/v2.php/cloud/user?format=json","scriptName":"/ocs/v2.php","message":"Login failed: ''admin'' (Remote IP: ''2001:db8::1'')","userAgent":"curl/8.9.1","version":"35.0.0.10","data":{"app":"core"}}'
matched: 1
- label: 'failed two-factor challenge'
line: '{"reqId":"arJoPXvkDJYTkOiNa2hqmQAAAJN","level":2,"time":"2026-09-22T13:40:00+02:00","remoteAddr":"198.51.100.2","user":"alice","app":"no app in context","method":"POST","url":"/index.php/login/challenge/totp","scriptName":"/index.php","message":"Two-factor challenge failed: alice (Remote IP: 198.51.100.2)","userAgent":"Mozilla/5.0","version":"35.0.0.10","data":[]}'
matched: 1
- label: 'unrelated warning'
line: '{"reqId":"arJoPXvkDJYTkOiNa2hqmQAAAJO","level":2,"time":"2026-09-22T13:42:00+02:00","remoteAddr":"198.51.100.4","user":"admin","app":"PHP","method":"GET","url":"/index.php/core/preview?fileId=8","scriptName":"/index.php","message":"sem_get(): Failed for key 0x7ea: Permission denied","userAgent":"Mozilla/5.0","version":"35.0.0.10","data":{"app":"PHP"}}'
matched: 0
loop_control:
label: '{{ item["label"] }}'

- name: 'Assert the nextcloud filter matches only the failed logins'
ansible.builtin.assert:
that: 'item["stdout"] is search("Lines: 1 lines, 0 ignored, " ~ item["item"]["matched"] ~ " matched")'
fail_msg: '{{ item["item"]["label"] }}: {{ item["stdout"] }}'
loop: '{{ __molecule__fail2ban_nextcloud_regex_result["results"] }}'
loop_control:
label: '{{ item["item"]["label"] }}'

# Proves a value from the inventory reaches the running jail, rather than the server falling
# back to the 10m that jail.conf ships.
- name: 'fail2ban-client get sshd bantime'
Expand Down
14 changes: 14 additions & 0 deletions extensions/molecule/schedule_reboot/no_reboot/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,20 @@
- '__molecule__spool_stat_result["stat"]["isdir"]'
- '__molecule__spool_stat_result["stat"]["mode"] == "0755"'

# do-reboot carries the credentials of the Icinga API user and the Rocket.Chat webhook, and
# only schedule-reboot.service runs it.
- name: 'stat /usr/local/libexec/do-reboot'
ansible.builtin.stat:
path: '/usr/local/libexec/do-reboot'
register: '__molecule__do_reboot_stat_result'

- name: 'Assert that do-reboot is readable by root only'
ansible.builtin.assert:
that:
- '__molecule__do_reboot_stat_result["stat"]["mode"] == "0700"'
- '__molecule__do_reboot_stat_result["stat"]["pw_name"] == "root"'
fail_msg: '/usr/local/libexec/do-reboot is {{ __molecule__do_reboot_stat_result["stat"] | d("missing") | to_json }}'

# Run the actor against the empty post-converge spool. do-reboot must exit 0
# without rebooting; that it runs at all proves the usr_t libexec script is
# executable under systemd/SELinux (the FHS-placement concern).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,10 @@ postfix__sender_canonicals__host_var:
setup_basic__skip_duplicity: true
setup_basic__skip_repo_icinga: true
setup_basic__skip_icinga2_agent: true

# Deploys schedule-icinga-downtime. Nothing listens on the URL, the script only has to exist and
# keep the credentials to root. The password is unique so verify.yml can search for it.
tools__icinga2_api_url: 'https://127.0.0.1:5665'
tools__icinga2_api_user_login:
username: 'downtime-api'
password: 'linuxfabrik-downtime-api'
45 changes: 45 additions & 0 deletions extensions/molecule/setup_basic/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -147,3 +147,48 @@

when:
- 'ansible_facts["os_family"] == "RedHat"'


# The Icinga API credentials live in /usr/local/sbin/schedule-icinga-downtime, which only root may
# read. A login shell of an unprivileged user sources /etc/profile.d/alias.sh, where they used to
# be, so it must neither see them nor be able to run the script.
- name: 'Verify the Icinga API credentials of the tools role are readable by root only'
hosts: 'systems_under_test'
gather_facts: false
become: true
tasks:

- name: 'stat /usr/local/sbin/schedule-icinga-downtime'
ansible.builtin.stat:
path: '/usr/local/sbin/schedule-icinga-downtime'
register: '__molecule__schedule_icinga_downtime_stat_result'

- name: 'Assert that schedule-icinga-downtime is readable by root only'
ansible.builtin.assert:
that:
- '__molecule__schedule_icinga_downtime_stat_result["stat"]["mode"] == "0700"'
- '__molecule__schedule_icinga_downtime_stat_result["stat"]["pw_name"] == "root"'
fail_msg: '/usr/local/sbin/schedule-icinga-downtime is {{ __molecule__schedule_icinga_downtime_stat_result["stat"] | d("missing") | to_json }}'

- name: 'runuser --user nobody -- bash --login -c "alias; declare -f; /usr/local/sbin/schedule-icinga-downtime 60"'
ansible.builtin.command:
argv:
- 'runuser'
- '--user'
- 'nobody'
- '--'
- 'bash'
- '--login'
- '-c'
- 'alias; declare -f; /usr/local/sbin/schedule-icinga-downtime 60'
register: '__molecule__nobody_login_shell_result'
changed_when: false
failed_when: false

- name: 'Assert that an unprivileged login shell neither sees nor uses the credentials'
ansible.builtin.assert:
that:
- '"alias reboot=" in __molecule__nobody_login_shell_result["stdout"]'
- '"linuxfabrik-downtime-api" not in __molecule__nobody_login_shell_result["stdout"]'
- '"Permission denied" in __molecule__nobody_login_shell_result["stderr"]'
fail_msg: '{{ __molecule__nobody_login_shell_result | to_json }}'
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,25 @@ mariadb_server__admin_user:
username: 'mariadb-admin'
password: 'linuxfabrik'

nextcloud__database_login:
username: 'nextcloud'
password: 'linuxfabrik'
nextcloud__fqdn: 'nextcloud.example.com'
nextcloud__skip_notify_push: true
# fail2ban is off by default, since it belongs on hosts that clients reach directly. Switched on
# here so verify.yml can prove the jail sees failed logins and never bans the proxy.
setup_nextcloud__skip_fail2ban: false
# The host itself stands in for the reverse proxy, so verify.yml can send a failed login "through
# the proxy" with X-Forwarded-For and check that fail2ban counts the client, not the proxy.
nextcloud__sysconfig__group_var:
- key: 'trusted_proxies 0'
value: '127.0.0.1'
state: 'present'
nextcloud__users:
- username: 'nextcloud-admin'
password: 'linuxfabrik'
group: 'admin'
nextcloud__version: 'latest-35'

repo_mariadb__version: '11.4'

Expand Down
Loading