From 13dfb009f35312a26bc5cb69160c6d3502182c66 Mon Sep 17 00:00:00 2001 From: Markus Frei Date: Tue, 22 Sep 2026 15:06:44 +0200 Subject: [PATCH 01/38] fix(roles/nextcloud): start the LDAP remnants report from its own timer nextcloud-ldap-show-remnants.timer pointed to nextcloud-app-update.service, so the monthly report never ran and the apps were updated once more instead. --- CHANGELOG.md | 1 + .../molecule/setup_nextcloud/verify.yml | 20 +++++++++++++++++++ .../nextcloud-ldap-show-remnants.timer.j2 | 4 ++-- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 780e021db..a00388110 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +* **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". diff --git a/extensions/molecule/setup_nextcloud/verify.yml b/extensions/molecule/setup_nextcloud/verify.yml index 7735e45cd..3f2f5ca96 100644 --- a/extensions/molecule/setup_nextcloud/verify.yml +++ b/extensions/molecule/setup_nextcloud/verify.yml @@ -73,3 +73,23 @@ fail_msg: 'mariadbd runs as "{{ __molecule__mariadbd_label_result["stdout"] | trim }}" instead of mysqld_t, so the mysqld_exec_t label on the binary is missing.' when: - '__molecule__mariadbd_label_result is not skipped' + + +# systemd resolves the unit a timer starts, so this is the timer as systemd runs it, not as the +# file reads. +- name: 'Verify the LDAP remnants timer starts the LDAP remnants report' + hosts: 'systems_under_test' + gather_facts: false + become: true + tasks: + + - name: 'Get the properties of nextcloud-ldap-show-remnants.timer' + ansible.builtin.systemd: + name: 'nextcloud-ldap-show-remnants.timer' + register: '__molecule__nextcloud_ldap_timer_result' + check_mode: true + + - name: 'Assert the timer triggers nextcloud-ldap-show-remnants.service' + ansible.builtin.assert: + that: '__molecule__nextcloud_ldap_timer_result["status"]["Triggers"] == "nextcloud-ldap-show-remnants.service"' + fail_msg: 'the timer triggers {{ __molecule__nextcloud_ldap_timer_result["status"]["Triggers"] }}' diff --git a/roles/nextcloud/templates/etc/systemd/system/nextcloud-ldap-show-remnants.timer.j2 b/roles/nextcloud/templates/etc/systemd/system/nextcloud-ldap-show-remnants.timer.j2 index 00a3da191..cd6773b8f 100644 --- a/roles/nextcloud/templates/etc/systemd/system/nextcloud-ldap-show-remnants.timer.j2 +++ b/roles/nextcloud/templates/etc/systemd/system/nextcloud-ldap-show-remnants.timer.j2 @@ -1,12 +1,12 @@ # {{ ansible_managed }} -# 2022100401 +# 2026092201 [Unit] Description=Nextcloud LDAP Show Remnants Service [Timer] OnCalendar=monthly -Unit=nextcloud-app-update.service +Unit=nextcloud-ldap-show-remnants.service [Install] WantedBy=timers.target From adf6a7bb241b8b7d63bd5810f8a707f3e8f57ea4 Mon Sep 17 00:00:00 2001 From: Markus Frei Date: Tue, 22 Sep 2026 15:06:44 +0200 Subject: [PATCH 02/38] fix(roles/nextcloud): apply ownership and SELinux label to nextcloud__datadir mkdir, restorecon and the httpd_sys_rw_content_t fcontext were hardcoded to /data, so a different nextcloud__datadir got neither. --- CHANGELOG.md | 1 + roles/nextcloud/README.md | 2 +- roles/nextcloud/defaults/main.yml | 2 +- roles/nextcloud/tasks/main.yml | 6 +++--- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a00388110..a9f7aad38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +* **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. diff --git a/roles/nextcloud/README.md b/roles/nextcloud/README.md index 6801b53ac..cdb1d7a20 100644 --- a/roles/nextcloud/README.md +++ b/roles/nextcloud/README.md @@ -197,7 +197,7 @@ nextcloud__users: `nextcloud__datadir` -* Where to store the user files. +* Where to store the user files. Nextcloud takes it over at the installation; changing it afterwards does not move an existing data directory. * Type: String. * Default: `'/data'` diff --git a/roles/nextcloud/defaults/main.yml b/roles/nextcloud/defaults/main.yml index ab7d39d9a..1be947943 100644 --- a/roles/nextcloud/defaults/main.yml +++ b/roles/nextcloud/defaults/main.yml @@ -490,7 +490,7 @@ nextcloud__selinux__fcontexts__dependent_var: target: '/var/www/html/nextcloud/apps/notify_push/bin/x86_64/notify_push' state: 'present' - setype: 'httpd_sys_rw_content_t' - target: '/data(/.*)?' + target: '{{ nextcloud__datadir }}(/.*)?' state: 'present' - setype: 'httpd_sys_rw_content_t' target: '/var/www/html/nextcloud/.htaccess' diff --git a/roles/nextcloud/tasks/main.yml b/roles/nextcloud/tasks/main.yml index 4887b0125..87d15dc44 100644 --- a/roles/nextcloud/tasks/main.yml +++ b/roles/nextcloud/tasks/main.yml @@ -54,7 +54,7 @@ group: '{{ __shared__apache_httpd_group }}' mode: 0o750 loop: - - '/data' + - '{{ nextcloud__datadir }}' - '/var/www/html/nextcloud/data' - name: 'chmod +x /var/www/html/nextcloud/occ' @@ -62,8 +62,8 @@ path: '/var/www/html/nextcloud/occ' mode: 0o755 - - name: 'restorecon -Fvr /data /var/www/html/nextcloud' - ansible.builtin.command: 'restorecon -Fvr /data /var/www/html/nextcloud' + - name: 'restorecon -Fvr {{ nextcloud__datadir }} /var/www/html/nextcloud' + ansible.builtin.command: 'restorecon -Fvr {{ nextcloud__datadir | quote }} /var/www/html/nextcloud' register: 'nextcloud__restorecon_nextcloud_result' changed_when: 'nextcloud__restorecon_nextcloud_result["stdout"] | length > 0' when: From c0e003acf12198b32f88def953de3ff912c48483 Mon Sep 17 00:00:00 2001 From: Markus Frei Date: Tue, 22 Sep 2026 15:06:45 +0200 Subject: [PATCH 03/38] fix(roles/nextcloud): add missing primary keys and run expensive repairs in nextcloud-update nextcloud-app-update already ran db:add-missing-primary-keys, the server update did not. maintenance:repair --include-expensive applies the mimetype migrations that `occ setupchecks` reports after an upgrade, since Nextcloud leaves them to the administrator. --- CHANGELOG.md | 1 + .../usr/local/bin/nextcloud-update.j2 | 24 ++++++++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9f7aad38..cafe1b8ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +* **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`. diff --git a/roles/nextcloud/templates/usr/local/bin/nextcloud-update.j2 b/roles/nextcloud/templates/usr/local/bin/nextcloud-update.j2 index 36d6545a5..adf6e60a7 100644 --- a/roles/nextcloud/templates/usr/local/bin/nextcloud-update.j2 +++ b/roles/nextcloud/templates/usr/local/bin/nextcloud-update.j2 @@ -1,6 +1,6 @@ #!/usr/bin/env bash # {{ ansible_managed }} -# 2026052001 +# 2026092201 set -euo pipefail @@ -220,6 +220,16 @@ else echo 'skipping.' fi +echo +echo 'db:add-missing-primary-keys' +echo '---------------------------' +if ! grep -q "db_add_missing_primary_keys_done" "${STATE_FILE}"; then + sudo -u "${WEBSERVER_USER}" php "${NC_DIR}/occ" db:add-missing-primary-keys + echo "db_add_missing_primary_keys_done" >> "${STATE_FILE}" +else + echo 'skipping.' +fi + echo echo 'db:convert-filecache-bigint --no-interaction' echo '--------------------------------------------' @@ -230,6 +240,18 @@ else echo 'skipping.' fi +# The expensive repair steps include the mimetype migrations, which Nextcloud does not run +# during an upgrade because they can take long on large instances. +echo +echo 'maintenance:repair --include-expensive' +echo '--------------------------------------' +if ! grep -q "maintenance_repair_done" "${STATE_FILE}"; then + sudo -u "${WEBSERVER_USER}" php "${NC_DIR}/occ" maintenance:repair --include-expensive + echo "maintenance_repair_done" >> "${STATE_FILE}" +else + echo 'skipping.' +fi + echo echo 'export after-update list' echo '------------------------' From d8f7c82809def6cbe60263850cb21c23f5f11ec5 Mon Sep 17 00:00:00 2001 From: Markus Frei Date: Tue, 22 Sep 2026 15:06:46 +0200 Subject: [PATCH 04/38] fix(roles/nextcloud): pass the passwords to occ on stdin --database-pass and --admin-pass were readable in the process list. Without them occ maintenance:install asks for the database password first and the admin password second, one line of stdin each when there is no TTY (verified with Nextcloud 35.0.0 on Rocky 10, PHP 8.3 and 8.5). The stdin is a block scalar: '{{ a ~ "\n" ~ b }}' in single quotes reaches the command as a literal backslash-n with ansible-core 2.16. --- CHANGELOG.md | 1 + roles/nextcloud/tasks/main.yml | 14 +++++++++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cafe1b8ce..62e7f5011 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -98,6 +98,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +* **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/`, 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. diff --git a/roles/nextcloud/tasks/main.yml b/roles/nextcloud/tasks/main.yml index 87d15dc44..ae3ac4a5b 100644 --- a/roles/nextcloud/tasks/main.yml +++ b/roles/nextcloud/tasks/main.yml @@ -69,21 +69,25 @@ when: - 'ansible_facts["selinux"]["status"] != "disabled"' + # The passwords reach occ on stdin, not on its command line, where every local user could + # read them in the process list. Without --database-pass and --admin-pass, occ asks for + # the database password first and the admin password second, and without a TTY it reads + # one line of stdin per question (verified with Nextcloud 35.0.0 on Rocky 10). - name: 'Run the Nextcloud installer' - # installation hangs without "--admin-user" and "--admin-pass" ansible.builtin.command: >- php occ maintenance:install - --admin-pass '{{ nextcloud__users.0.password }}' - --admin-user '{{ nextcloud__users.0.username }}' + --admin-user '{{ nextcloud__users[0]["username"] }}' --data-dir '{{ nextcloud__datadir }}' --database 'mysql' --database-host '{{ nextcloud__database_host }}' --database-name '{{ nextcloud__database_name }}' - --database-user '{{ nextcloud__mariadb_login.username }}' - --database-pass '{{ nextcloud__mariadb_login.password }}' + --database-user '{{ nextcloud__mariadb_login["username"] }}' args: chdir: '/var/www/html/nextcloud/' creates: '/var/www/html/nextcloud/config/config.php' + stdin: |- + {{ nextcloud__mariadb_login["password"] }} + {{ nextcloud__users[0]["password"] }} become: true become_user: '{{ __shared__apache_httpd_user }}' From 1cdeb5ab26d611da0aa33c66dd10e4d79b21bda1 Mon Sep 17 00:00:00 2001 From: Markus Frei Date: Tue, 22 Sep 2026 15:06:47 +0200 Subject: [PATCH 05/38] fix(roles/nextcloud): make nextcloud-update readable by root only The script carries the credentials of the Icinga API user and was deployed with 0755. Only root can run it anyway, since it restarts services and switches to the web server user. --- CHANGELOG.md | 1 + roles/nextcloud/tasks/main.yml | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62e7f5011..75926c8e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -98,6 +98,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +* **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/`, readable by `apache` and `root` only, instead of to `/tmp`. diff --git a/roles/nextcloud/tasks/main.yml b/roles/nextcloud/tasks/main.yml index ae3ac4a5b..880c7898f 100644 --- a/roles/nextcloud/tasks/main.yml +++ b/roles/nextcloud/tasks/main.yml @@ -395,6 +395,8 @@ - block: + # 0o700: the script carries the credentials of the Icinga API user, and only root can run it + # anyway, since it restarts services and switches to the web server user. - name: 'Deploy /usr/local/bin/nextcloud-update' ansible.builtin.template: backup: true @@ -402,7 +404,7 @@ dest: '/usr/local/bin/nextcloud-update' owner: 'root' group: 'root' - mode: 0o755 + mode: 0o700 tags: - 'nextcloud' From e15479259f938ab2272625331cb4e179a6aed667 Mon Sep 17 00:00:00 2001 From: Markus Frei Date: Tue, 22 Sep 2026 15:06:47 +0200 Subject: [PATCH 06/38] fix(roles/nextcloud)!: verify the certificate of the SMTP server The role set mail_smtpstreamoptions ssl allow_self_signed=true, verify_peer=false and verify_peer_name=false on every host. The entries are now state: absent, so existing hosts get them removed; the empty 'ssl' array left behind is a no-op, since the Mailer merges the options into its own (verified against Nextcloud 35). --- CHANGELOG.md | 1 + .../molecule/setup_nextcloud/verify.yml | 31 +++++++++++++++++++ roles/nextcloud/defaults/main.yml | 9 ++++-- 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75926c8e9..ee3bb9191 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: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. diff --git a/extensions/molecule/setup_nextcloud/verify.yml b/extensions/molecule/setup_nextcloud/verify.yml index 3f2f5ca96..b436ef2ee 100644 --- a/extensions/molecule/setup_nextcloud/verify.yml +++ b/extensions/molecule/setup_nextcloud/verify.yml @@ -75,6 +75,37 @@ - '__molecule__mariadbd_label_result is not skipped' +# The SMTP TLS overrides that earlier versions of the role set are gone, so Nextcloud verifies the +# certificate of the mail server again. `config:system:get` exits 1 for a key that does not exist. +- name: 'Verify Nextcloud verifies the SMTP server certificate' + hosts: 'systems_under_test' + gather_facts: false + become: true + tasks: + + - name: 'php occ config:system:get mail_smtpstreamoptions ssl {{ item }}' + ansible.builtin.command: 'php /var/www/html/nextcloud/occ config:system:get mail_smtpstreamoptions ssl {{ item }}' + args: + chdir: '/var/www/html/nextcloud/' + become: true + become_user: 'apache' + register: '__molecule__nextcloud_smtp_result' + changed_when: false + failed_when: false + loop: + - 'allow_self_signed' + - 'verify_peer' + - 'verify_peer_name' + + - name: 'Assert no SMTP TLS check is switched off' + ansible.builtin.assert: + that: 'item["rc"] == 1' + fail_msg: 'mail_smtpstreamoptions ssl {{ item["item"] }} is set to {{ item["stdout"] }}' + loop: '{{ __molecule__nextcloud_smtp_result["results"] }}' + loop_control: + label: '{{ item["item"] }}' + + # systemd resolves the unit a timer starts, so this is the timer as systemd runs it, not as the # file reads. - name: 'Verify the LDAP remnants timer starts the LDAP remnants report' diff --git a/roles/nextcloud/defaults/main.yml b/roles/nextcloud/defaults/main.yml index 1be947943..f6228e380 100644 --- a/roles/nextcloud/defaults/main.yml +++ b/roles/nextcloud/defaults/main.yml @@ -287,17 +287,20 @@ nextcloud__sysconfig__role_var: value: '0.5' type: 'double' state: 'present' + # Removed from hosts that got them from earlier versions of this role, so Nextcloud verifies the + # certificate of the SMTP server again. The empty 'ssl' array left behind changes nothing, + # since the Mailer merges the stream options into its own (verified against Nextcloud 35). - key: 'mail_smtpstreamoptions ssl allow_self_signed' value: 'true' - state: 'present' + state: 'absent' type: 'boolean' - key: 'mail_smtpstreamoptions ssl verify_peer' value: 'false' - state: 'present' + state: 'absent' type: 'boolean' - key: 'mail_smtpstreamoptions ssl verify_peer_name' value: 'false' - state: 'present' + state: 'absent' type: 'boolean' - key: 'memcache.locking' value: '\OC\Memcache\Redis' From 00bc8ff6b2a068389f51067b60acc254531ca5bd Mon Sep 17 00:00:00 2001 From: Markus Frei Date: Tue, 22 Sep 2026 15:06:48 +0200 Subject: [PATCH 07/38] docs(roles/nextcloud): document the subkeys of nextcloud__apps and nextcloud__app_configs The force and state subkeys of nextcloud__apps were listed under nextcloud__app_configs, and nextcloud__apps claimed present/absent with a default of present, while the task defaults to enabled and the module knows four states. Also drop the trailing spaces from the keys of the reverse proxy example. --- roles/nextcloud/README.md | 37 ++++++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/roles/nextcloud/README.md b/roles/nextcloud/README.md index cdb1d7a20..f9a383d71 100644 --- a/roles/nextcloud/README.md +++ b/roles/nextcloud/README.md @@ -146,7 +146,7 @@ nextcloud__users: * `key`: - * Mandatory. The name of the config option to set. + * Mandatory. The app and the name of its config option, separated by a space, for example `password_policy minLength`. * Type: String. * `value`: @@ -154,16 +154,17 @@ nextcloud__users: * Mandatory. The configuration value. * Type: String. - * `force`: + * `state`: - * Optional. Set to `true` to install the app regardless of the Nextcloud version requirement. - * Type: Bool. + * Optional. Either `present` or `absent`. + * Type: String. + * Default: `'present'` - * `state`: + * `type`: - * Optional. Either `absent`, `disabled`, `enabled` or `present`. Note that `enabled` also installs the app. + * Optional. The type of the configuration value. One of `array`, `boolean`, `float`, `integer` or `string`. * Type: String. - * Default: `'enabled'` + * Default: `'string'` `nextcloud__apps__host_var` / `nextcloud__apps__group_var` @@ -177,11 +178,17 @@ nextcloud__users: * Mandatory. The app name. * Type: String. + * `force`: + + * Optional. Set to `true` to install the app regardless of the Nextcloud version requirement. + * Type: Bool. + * Default: `false` + * `state`: - * Optional. State of the app, either `present` or `absent`. + * Optional. One of `absent` (removes the app), `disabled` (disables an enabled app), `enabled` (installs the app if needed and enables it) or `present` (installs the app, but leaves it disabled). * Type: String. - * Default: `'present'` + * Default: `'enabled'` `nextcloud__database_host` @@ -426,22 +433,22 @@ nextcloud__sysconfig__host_var: type: 'double' state: 'present' # reverse proxy config - - key: 'overwrite.cli.url ' + - key: 'overwrite.cli.url' value: 'https://cloud.example.com' state: 'present' - - key: 'overwritecondaddr ' + - key: 'overwritecondaddr' value: '^192\.0\.2\.7$' # IP of the reverse proxy state: 'present' - - key: 'overwritehost ' + - key: 'overwritehost' value: 'cloud.example.com' state: 'present' - - key: 'overwriteprotocol ' + - key: 'overwriteprotocol' value: 'https' state: 'present' - - key: 'overwritewebroot ' + - key: 'overwritewebroot' value: '/' state: 'present' - - key: 'trusted_proxies 0 ' + - key: 'trusted_proxies 0' value: '192.0.2.7' # IP of the reverse proxy state: 'present' From 6ab6527e86fd91f2234024d10bc1e67a0377feee Mon Sep 17 00:00:00 2001 From: Markus Frei Date: Tue, 22 Sep 2026 15:06:49 +0200 Subject: [PATCH 08/38] refactor(roles/nextcloud): drop forbidden filename characters Nextcloud forbids anyway '\n', '\r' and '\u0000' in single quotes reached Nextcloud as literal backslash sequences, not as control characters. Nextcloud always forbids the backslash and the characters 0 to 31 (lib/private/Files/FilenameValidator.php, OCP\Constants::FILENAME_INVALID_CHARS), so the entries never had an effect. '|' moves up to index 6 and indexes 7 to 9 are removed from existing hosts. --- roles/nextcloud/defaults/main.yml | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/roles/nextcloud/defaults/main.yml b/roles/nextcloud/defaults/main.yml index f6228e380..4ddc2ba47 100644 --- a/roles/nextcloud/defaults/main.yml +++ b/roles/nextcloud/defaults/main.yml @@ -236,17 +236,21 @@ nextcloud__sysconfig__role_var: value: '"' state: 'present' - key: 'forbidden_filename_characters 6' - value: '\n' + value: '|' state: 'present' + # Removed from hosts that got them from earlier versions of this role: in single quotes, YAML + # passed '\n', '\r' and '\u0000' on as literal backslash sequences instead of control + # characters. Nextcloud forbids the backslash and the characters 0 to 31 in any case, so the + # entries never changed anything (verified against Nextcloud 35). - key: 'forbidden_filename_characters 7' - value: '\r' - state: 'present' + value: '' + state: 'absent' - key: 'forbidden_filename_characters 8' - value: '\u0000' - state: 'present' + value: '' + state: 'absent' - key: 'forbidden_filename_characters 9' - value: '|' - state: 'present' + value: '' + state: 'absent' - key: 'log_rotate_size' value: '10485760' type: 'integer' From 0d3929b3484b9b7922a716dd34f0b06f616ed580 Mon Sep 17 00:00:00 2001 From: Markus Frei Date: Tue, 22 Sep 2026 15:06:50 +0200 Subject: [PATCH 09/38] feat(roles/fail2ban): add a nextcloud filter and jail The filter is the one from the Nextcloud hardening guide. Verified with fail2ban-regex 1.1.0 on Rocky 10 against the log of Nextcloud 35.0.0: failed logins via web, WebDAV and OCS, disabled accounts, failed two-factor challenges, IPv4 and IPv6. The trusted domain error is logged at info level and only matches with loglevel 1 or lower. The regular expressions contain '{%', so the template renders them raw. --- CHANGELOG.md | 1 + .../molecule/fail2ban/install/verify.yml | 36 +++++++++++++++++++ roles/fail2ban/README.md | 34 ++++++++++++++++-- roles/fail2ban/defaults/main.yml | 7 ++++ roles/fail2ban/meta/argument_specs.yml | 24 +++++++++++++ .../etc/fail2ban/filter.d/nextcloud.conf.j2 | 20 +++++++++++ .../etc/fail2ban/jail.d/z10-nextcloud.conf.j2 | 10 ++++++ 7 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 roles/fail2ban/templates/etc/fail2ban/filter.d/nextcloud.conf.j2 create mode 100644 roles/fail2ban/templates/etc/fail2ban/jail.d/z10-nextcloud.conf.j2 diff --git a/CHANGELOG.md b/CHANGELOG.md index ee3bb9191..88472a300 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +* **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`. diff --git a/extensions/molecule/fail2ban/install/verify.yml b/extensions/molecule/fail2ban/install/verify.yml index 681e78e75..b561a8a1e 100644 --- a/extensions/molecule/fail2ban/install/verify.yml +++ b/extensions/molecule/fail2ban/install/verify.yml @@ -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' diff --git a/roles/fail2ban/README.md b/roles/fail2ban/README.md index 339055afc..a44750e10 100644 --- a/roles/fail2ban/README.md +++ b/roles/fail2ban/README.md @@ -4,11 +4,12 @@ This role installs and configures [fail2ban](https://www.fail2ban.org). Filters and jails are defined in the inventory (`fail2ban__filters__*_var` / `fail2ban__jails__*_var`). Each entry either references one of the templates shipped with the role, or uses the `raw` template to deploy an arbitrary filter or jail definition. -This role provides four additional filters: +This role provides five additional filters: * apache-404: Matches HTTP 404 responses in Apache access logs (combined, combinedio, common, fail2ban, linuxfabrikio, matomo, vhost_common). Can be used to ban IPs causing excessive 404 errors. **Important:** in order to capture the client ip for all formats, this filter requires the ServerName to be a domain instead of an ip address when using a LogFormat where the canonical ServerName `%v` precedes the client IP `%h` (matomo, vhost_common). * apache-dos: Matches all incoming requests to Apache. Can be used to limit the number of allowed requests per client. +* nextcloud: Matches failed logins and failed two-factor challenges in the Nextcloud log (`nextcloud.log`), the filter from the [Nextcloud hardening guide](https://docs.nextcloud.com/server/stable/admin_manual/installation/harden_server.html#setup-fail2ban). The `z10-nextcloud` jail bans IPs that fail too often. Nextcloud logs the address of the client, also behind a reverse proxy listed in its `trusted_proxies`, so the jail runs on the Nextcloud host. There it only keeps out clients that connect to the host directly: traffic that comes through the proxy arrives from the proxy's address, which the ban does not cover. * portscan: Instantly blocks an IP if it accesses a non-permitted port. * wordpress-login: Matches failed WordPress logins in Apache access logs (combined, common, linuxfabrikio, matomo, vhost_common), also for WordPress in a sub-path such as `/blog`, which WordPress answers with the login form again (HTTP 200) instead of a redirect. The `z10-wordpress-login` jail bans IPs that fail too often. It bans the address Apache logs as the client, so behind a reverse proxy it belongs on the proxy, where that is the visitor's address; on the WordPress host it would ban the proxy. @@ -38,6 +39,7 @@ Any [LFOps playbook](https://github.com/Linuxfabrik/lfops/blob/main/playbooks/RE ## Requirements * Optional: The `apache-*` jails read the Apache logs below `/var/log/httpd/`. +* Optional: The `nextcloud` jail reads `fail2ban__jail_nextcloud_logpath`. On SELinux systems, fail2ban may only read files labeled as logs, which is why the `nextcloud` role writes the Nextcloud log to `/var/log/nextcloud/` instead of the data directory. fail2ban does not start while the log file is missing. * Optional: The `portscan` filter matches the kernel log of an iptables firewall that logs denied packets, as fwbuilder generates it, read through the systemd journal. Without such a firewall the jail never bans. @@ -71,7 +73,7 @@ Any [LFOps playbook](https://github.com/Linuxfabrik/lfops/blob/main/playbooks/RE * The fail2ban filter definition. For the usage in `host_vars` / `group_vars` (can only be used in one group at a time). * Type: List of dictionaries. -* Default: `apache-404`, `apache-dos`, `portscan`, `wordpress-login` +* Default: `apache-404`, `apache-dos`, `nextcloud`, `portscan`, `wordpress-login` * Subkeys: * `filename`: @@ -137,6 +139,30 @@ Any [LFOps playbook](https://github.com/Linuxfabrik/lfops/blob/main/playbooks/RE * Type: String. * Default: `''` +`fail2ban__jail_nextcloud_bantime` + +* The ban duration for the nextcloud jail. +* Type: String. +* Default: `'8h'` + +`fail2ban__jail_nextcloud_findtime` + +* The find time for the nextcloud jail. An IP is banned if it fails to log in `fail2ban__jail_nextcloud_maxretry` times within this duration. +* Type: String. +* Default: `'10m'` + +`fail2ban__jail_nextcloud_logpath` + +* The Nextcloud log file the nextcloud jail reads. +* Type: String. +* Default: `'/var/log/nextcloud/nextcloud.log'` + +`fail2ban__jail_nextcloud_maxretry` + +* The number of failed Nextcloud logins within `fail2ban__jail_nextcloud_findtime` before an IP is banned. +* Type: Integer. +* Default: `5` + `fail2ban__jail_portscan_allowed_ports` * A list of ports which are allowed to be accessed. IPs accessing these ports will not be blocked. Note: This setting is for the portscan jail. @@ -243,6 +269,10 @@ fail2ban__jail_default_banaction: 'iptables-multiport' fail2ban__jail_default_ignoreip: - '192.0.2.1/32' # ansible deployment host fail2ban__jail_default_rocketchat_hook: '' +fail2ban__jail_nextcloud_bantime: '8h' +fail2ban__jail_nextcloud_findtime: '10m' +fail2ban__jail_nextcloud_logpath: '/var/log/nextcloud/nextcloud.log' +fail2ban__jail_nextcloud_maxretry: 5 fail2ban__jail_portscan_allowed_ports: - 22 fail2ban__jail_portscan_bantime: '8h' diff --git a/roles/fail2ban/defaults/main.yml b/roles/fail2ban/defaults/main.yml index 64aa798e1..1bce5726a 100644 --- a/roles/fail2ban/defaults/main.yml +++ b/roles/fail2ban/defaults/main.yml @@ -16,6 +16,9 @@ fail2ban__filters__role_var: - filename: 'apache-dos' state: 'present' template: 'apache-dos' + - filename: 'nextcloud' + state: 'present' + template: 'nextcloud' - filename: 'portscan' state: 'present' template: 'portscan' @@ -33,6 +36,10 @@ fail2ban__jail_default_action: |- fail2ban__jail_default_banaction: 'iptables-multiport' fail2ban__jail_default_ignoreip: [] fail2ban__jail_default_rocketchat_hook: '' +fail2ban__jail_nextcloud_bantime: '8h' +fail2ban__jail_nextcloud_findtime: '10m' +fail2ban__jail_nextcloud_logpath: '/var/log/nextcloud/nextcloud.log' +fail2ban__jail_nextcloud_maxretry: 5 fail2ban__jail_portscan_allowed_ports: - 22 fail2ban__jail_portscan_bantime: '8h' diff --git a/roles/fail2ban/meta/argument_specs.yml b/roles/fail2ban/meta/argument_specs.yml index 93c3f8271..3753f8654 100644 --- a/roles/fail2ban/meta/argument_specs.yml +++ b/roles/fail2ban/meta/argument_specs.yml @@ -90,6 +90,30 @@ argument_specs: default: '' description: 'The incoming Rocket.Chat hook used to send a notification on bans.' + fail2ban__jail_nextcloud_bantime: + type: 'str' + required: false + default: '8h' + description: 'The ban duration for the nextcloud jail.' + + fail2ban__jail_nextcloud_findtime: + type: 'str' + required: false + default: '10m' + description: 'The find time for the nextcloud jail.' + + fail2ban__jail_nextcloud_logpath: + type: 'str' + required: false + default: '/var/log/nextcloud/nextcloud.log' + description: 'The Nextcloud log file the nextcloud jail reads.' + + fail2ban__jail_nextcloud_maxretry: + type: 'int' + required: false + default: 5 + description: 'The number of failed Nextcloud logins within the find time before an IP is banned.' + fail2ban__jail_portscan_allowed_ports: type: 'list' required: false diff --git a/roles/fail2ban/templates/etc/fail2ban/filter.d/nextcloud.conf.j2 b/roles/fail2ban/templates/etc/fail2ban/filter.d/nextcloud.conf.j2 new file mode 100644 index 000000000..bdbff0a0d --- /dev/null +++ b/roles/fail2ban/templates/etc/fail2ban/filter.d/nextcloud.conf.j2 @@ -0,0 +1,20 @@ +# {{ ansible_managed }} +# 2026092201 + +# Matches failed logins in the JSON log of Nextcloud (nextcloud.log), taken unchanged from the +# Nextcloud hardening guide: failed logins (web interface, WebDAV, OCS API, disabled accounts), +# failed two-factor challenges, and requests for a host name missing from trusted_domains. +# Nextcloud logs the first two as warnings; the trusted domain error is an info message and +# only reaches the log with loglevel 1 or lower. Verified with fail2ban-regex 1.1.0 on Rocky 10 +# against the log of Nextcloud 35.0.0, IPv4 and IPv6 remote addresses included. + +# raw: the regular expressions contain a brace followed by a percent sign, which Jinja would +# otherwise take for the start of a block tag. +{% raw %} +[Definition] +_groupsre = (?:(?:,?\s*"\w+":(?:"[^"]+"|\w+))*) +failregex = ^\{%(_groupsre)s,?\s*"remoteAddr":""%(_groupsre)s,?\s*"message":"Login failed: + ^\{%(_groupsre)s,?\s*"remoteAddr":""%(_groupsre)s,?\s*"message":"Two-factor challenge failed: + ^\{%(_groupsre)s,?\s*"remoteAddr":""%(_groupsre)s,?\s*"message":"Trusted domain error. +datepattern = ,?\s*"time"\s*:\s*"%%Y-%%m-%%d[T ]%%H:%%M:%%S(%%z)?" +{% endraw %} diff --git a/roles/fail2ban/templates/etc/fail2ban/jail.d/z10-nextcloud.conf.j2 b/roles/fail2ban/templates/etc/fail2ban/jail.d/z10-nextcloud.conf.j2 new file mode 100644 index 000000000..76b84eff6 --- /dev/null +++ b/roles/fail2ban/templates/etc/fail2ban/jail.d/z10-nextcloud.conf.j2 @@ -0,0 +1,10 @@ +# {{ ansible_managed }} +# 2026092201 + +[nextcloud] +bantime = {{ fail2ban__jail_nextcloud_bantime }} +enabled = true +findtime = {{ fail2ban__jail_nextcloud_findtime }} +logpath = {{ fail2ban__jail_nextcloud_logpath }} +maxretry = {{ fail2ban__jail_nextcloud_maxretry }} +port = http,https From 5c5d02d57fda769d0522363329029bbc11d6f7e3 Mon Sep 17 00:00:00 2001 From: Markus Frei Date: Tue, 22 Sep 2026 15:06:51 +0200 Subject: [PATCH 10/38] feat(roles/nextcloud)!: log to /var/log/nextcloud fail2ban_t may read files of the logfile attribute, but not httpd_sys_rw_content_t in the data directory: the jail found no log file until fail2ban_t was made permissive (fail2ban 1.1.0 with fail2ban-selinux, selinux-policy 42.1.18 on Rocky 10). /var/log/nextcloud is labeled httpd_log_t, where httpd_t may create and append, and the log file is created by the role, since Nextcloud silently falls back to the data directory if it cannot create it itself. Rotation runs in nextcloud-jobs.service, outside httpd_t. --- CHANGELOG.md | 1 + roles/nextcloud/README.md | 1 + roles/nextcloud/defaults/main.yml | 7 +++++++ roles/nextcloud/tasks/main.yml | 24 ++++++++++++++++++++++-- 4 files changed, 31 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88472a300..5558fedf9 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: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. diff --git a/roles/nextcloud/README.md b/roles/nextcloud/README.md index f9a383d71..b2183df79 100644 --- a/roles/nextcloud/README.md +++ b/roles/nextcloud/README.md @@ -16,6 +16,7 @@ After installing Nextcloud, head over to your http(s)://nextcloud/index.php/sett * App updates are applied automatically by the `nextcloud-app-update.timer` (enabled by default, disable via `nextcloud__timer_app_update_enabled`). The timer runs `/usr/local/bin/nextcloud-app-update`, which first checks whether any app update is pending. Nextcloud is switched into maintenance mode only when there is something to update; when everything is up to date the instance keeps serving requests untouched. After updating, the recommended database migrations (`db:add-missing-indices`, `db:add-missing-columns`, `db:add-missing-primary-keys`) are applied. A failed run leaves maintenance mode disabled again, so the instance does not stay offline, and reports the failure to systemd. * This automatic update covers app updates only. Updating the Nextcloud server itself is a separate, manual step via `/usr/local/bin/nextcloud-update`. +* Nextcloud logs to `/var/log/nextcloud/nextcloud.log`, not to the data directory. On SELinux systems, fail2ban may only read files labeled as logs, and the fail2ban jail for Nextcloud reads this file. ## Dependent Roles diff --git a/roles/nextcloud/defaults/main.yml b/roles/nextcloud/defaults/main.yml index 4ddc2ba47..f591e3193 100644 --- a/roles/nextcloud/defaults/main.yml +++ b/roles/nextcloud/defaults/main.yml @@ -255,6 +255,10 @@ nextcloud__sysconfig__role_var: value: '10485760' type: 'integer' state: 'present' + # Outside the data directory, where the SELinux policy lets fail2ban read it. + - key: 'logfile' + value: '/var/log/nextcloud/nextcloud.log' # upstream default: /nextcloud.log + state: 'present' - key: 'loglevel' value: '2' type: 'integer' @@ -496,6 +500,9 @@ nextcloud__selinux__fcontexts__dependent_var: - setype: 'bin_t' target: '/var/www/html/nextcloud/apps/notify_push/bin/x86_64/notify_push' state: 'present' + - setype: 'httpd_log_t' + target: '/var/log/nextcloud(/.*)?' + state: 'present' - setype: 'httpd_sys_rw_content_t' target: '{{ nextcloud__datadir }}(/.*)?' state: 'present' diff --git a/roles/nextcloud/tasks/main.yml b/roles/nextcloud/tasks/main.yml index 880c7898f..5f899ef7c 100644 --- a/roles/nextcloud/tasks/main.yml +++ b/roles/nextcloud/tasks/main.yml @@ -57,13 +57,33 @@ - '{{ nextcloud__datadir }}' - '/var/www/html/nextcloud/data' + - name: 'mkdir -p /var/log/nextcloud' + ansible.builtin.file: + path: '/var/log/nextcloud' + state: 'directory' + owner: '{{ __shared__apache_httpd_user }}' + group: '{{ __shared__apache_httpd_group }}' + mode: 0o750 + + # Nextcloud silently falls back to the data directory if it cannot create the configured log + # file itself, which would leave the fail2ban jail without a log to read. + - name: 'touch /var/log/nextcloud/nextcloud.log' + ansible.builtin.file: + path: '/var/log/nextcloud/nextcloud.log' + state: 'touch' + owner: '{{ __shared__apache_httpd_user }}' + group: '{{ __shared__apache_httpd_group }}' + mode: 0o640 + access_time: 'preserve' + modification_time: 'preserve' + - name: 'chmod +x /var/www/html/nextcloud/occ' ansible.builtin.file: path: '/var/www/html/nextcloud/occ' mode: 0o755 - - name: 'restorecon -Fvr {{ nextcloud__datadir }} /var/www/html/nextcloud' - ansible.builtin.command: 'restorecon -Fvr {{ nextcloud__datadir | quote }} /var/www/html/nextcloud' + - name: 'restorecon -Fvr {{ nextcloud__datadir }} /var/log/nextcloud /var/www/html/nextcloud' + ansible.builtin.command: 'restorecon -Fvr {{ nextcloud__datadir | quote }} /var/log/nextcloud /var/www/html/nextcloud' register: 'nextcloud__restorecon_nextcloud_result' changed_when: 'nextcloud__restorecon_nextcloud_result["stdout"] | length > 0' when: From dff53bb41184ea5e4330cd170a0e628e950254f6 Mon Sep 17 00:00:00 2001 From: Markus Frei Date: Tue, 22 Sep 2026 15:06:51 +0200 Subject: [PATCH 11/38] feat(playbooks/setup_nextcloud): optionally run fail2ban, never ban the trusted proxies fail2ban belongs on hosts that clients reach directly, so it stays off by default (setup_nextcloud__skip_fail2ban). When enabled, the playbook runs it after nextcloud, since fail2ban does not start while the log of a jail is missing, and injects the nextcloud jail. The addresses from the trusted_proxies entries of nextcloud__sysconfig go to the new fail2ban__jail_default_ignoreip__dependent_var, so no jail bans the proxy. --- CHANGELOG.md | 1 + .../group_vars/systems_under_test.yml | 9 ++++ .../molecule/setup_nextcloud/verify.yml | 46 +++++++++++++++++++ playbooks/README.md | 2 +- playbooks/setup_nextcloud.yml | 20 ++++++-- roles/fail2ban/README.md | 2 +- roles/fail2ban/defaults/main.yml | 1 + roles/fail2ban/meta/argument_specs.yml | 7 +++ .../etc/fail2ban/jail.d/z00-defaults.conf.j2 | 4 +- roles/nextcloud/README.md | 3 ++ roles/nextcloud/defaults/main.yml | 22 +++++++++ 11 files changed, 108 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5558fedf9..f278a2790 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +* **playbook:setup_nextcloud**: `setup_nextcloud__skip_fail2ban: false` runs fail2ban on a Nextcloud host that clients reach directly, with a jail that bans an IP for 8 hours after 5 failed Nextcloud logins within 10 minutes. The reverse proxies listed in the Nextcloud setting `trusted_proxies` are never banned, in any jail. * **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. diff --git a/extensions/molecule/setup_nextcloud/inventory/group_vars/systems_under_test.yml b/extensions/molecule/setup_nextcloud/inventory/group_vars/systems_under_test.yml index aaccef6e0..976c76ff4 100644 --- a/extensions/molecule/setup_nextcloud/inventory/group_vars/systems_under_test.yml +++ b/extensions/molecule/setup_nextcloud/inventory/group_vars/systems_under_test.yml @@ -18,6 +18,15 @@ mariadb_server__admin_user: 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' diff --git a/extensions/molecule/setup_nextcloud/verify.yml b/extensions/molecule/setup_nextcloud/verify.yml index b436ef2ee..0d3c26b98 100644 --- a/extensions/molecule/setup_nextcloud/verify.yml +++ b/extensions/molecule/setup_nextcloud/verify.yml @@ -124,3 +124,49 @@ ansible.builtin.assert: that: '__molecule__nextcloud_ldap_timer_result["status"]["Triggers"] == "nextcloud-ldap-show-remnants.service"' fail_msg: 'the timer triggers {{ __molecule__nextcloud_ldap_timer_result["status"]["Triggers"] }}' + + +# A failed login sent through the "proxy" (the host itself, see trusted_proxies in the inventory) +# proves the whole chain at once: Nextcloud takes the client address from X-Forwarded-For, logs +# to /var/log/nextcloud, SELinux lets fail2ban read that file, and the jail counts the client. +# The proxy itself has to be in ignoreip, since banning it would lock out every client. +- name: 'Verify fail2ban counts failed Nextcloud logins and never bans the proxy' + hosts: 'systems_under_test' + gather_facts: false + become: true + tasks: + + - name: 'GET /remote.php/dav/ with wrong credentials, as 203.0.113.9 behind the proxy' + ansible.builtin.uri: + url: 'http://127.0.0.1/remote.php/dav/files/{{ nextcloud__users[0]["username"] }}/' + url_username: '{{ nextcloud__users[0]["username"] }}' + url_password: 'wrong-password' + force_basic_auth: true + headers: + Host: '{{ nextcloud__fqdn }}' + X-Forwarded-For: '203.0.113.9' + status_code: 401 + + - name: 'fail2ban-client status nextcloud' + ansible.builtin.command: 'fail2ban-client status nextcloud' + register: '__molecule__nextcloud_fail2ban_status_result' + changed_when: false + # fail2ban polls the log file, so the failure shows up with a short delay + until: '__molecule__nextcloud_fail2ban_status_result["stdout"] is search("Total failed:\\s+[1-9]")' + retries: 10 + delay: 2 + + - name: 'Assert the jail reads the Nextcloud log' + ansible.builtin.assert: + that: '__molecule__nextcloud_fail2ban_status_result["stdout"] is search("File list:\\s+/var/log/nextcloud/nextcloud.log")' + fail_msg: '{{ __molecule__nextcloud_fail2ban_status_result["stdout"] }}' + + - name: 'fail2ban-client get nextcloud ignoreip' + ansible.builtin.command: 'fail2ban-client get nextcloud ignoreip' + register: '__molecule__nextcloud_fail2ban_ignoreip_result' + changed_when: false + + - name: 'Assert the trusted proxy is never banned' + ansible.builtin.assert: + that: '"127.0.0.1" in __molecule__nextcloud_fail2ban_ignoreip_result["stdout"]' + fail_msg: '{{ __molecule__nextcloud_fail2ban_ignoreip_result["stdout"] }}' diff --git a/playbooks/README.md b/playbooks/README.md index 5ca83bc3a..44444545b 100644 --- a/playbooks/README.md +++ b/playbooks/README.md @@ -1313,7 +1313,6 @@ Calls the following roles (in order): * [repo_epel](https://github.com/Linuxfabrik/lfops/tree/main/roles/repo_epel): `setup_nextcloud__skip_repo_epel` * [policycoreutils](https://github.com/Linuxfabrik/lfops/tree/main/roles/policycoreutils): `setup_nextcloud__skip_policycoreutils` * [python](https://github.com/Linuxfabrik/lfops/tree/main/roles/python): `setup_nextcloud__skip_python` -* [fail2ban](https://github.com/Linuxfabrik/lfops/tree/main/roles/fail2ban): `setup_nextcloud__skip_fail2ban` * [kernel_settings](https://github.com/Linuxfabrik/lfops/tree/main/roles/kernel_settings): `setup_nextcloud__skip_kernel_settings` * [apache_httpd](https://github.com/Linuxfabrik/lfops/tree/main/roles/apache_httpd): `setup_nextcloud__skip_apache_httpd` * [repo_remi](https://github.com/Linuxfabrik/lfops/tree/main/roles/repo_remi): `setup_nextcloud__skip_repo_remi` @@ -1326,6 +1325,7 @@ Calls the following roles (in order): * [selinux](https://github.com/Linuxfabrik/lfops/tree/main/roles/selinux): `setup_nextcloud__skip_selinux` * [systemd_unit](https://github.com/Linuxfabrik/lfops/tree/main/roles/systemd_unit): `nextcloud__skip_systemd_unit` * [nextcloud](https://github.com/Linuxfabrik/lfops/tree/main/roles/nextcloud) +* [fail2ban](https://github.com/Linuxfabrik/lfops/tree/main/roles/fail2ban): `setup_nextcloud__skip_fail2ban` (default: `true`) * [repo_collabora_code](https://github.com/Linuxfabrik/lfops/tree/main/roles/repo_collabora_code): `setup_nextcloud__skip_repo_collabora_code` * [repo_collabora](https://github.com/Linuxfabrik/lfops/tree/main/roles/repo_collabora): `setup_nextcloud__skip_repo_collabora` (default: `true`), `setup_nextcloud__skip_repo_collabora_code` * [collabora](https://github.com/Linuxfabrik/lfops/tree/main/roles/collabora): `setup_nextcloud__skip_collabora` diff --git a/playbooks/setup_nextcloud.yml b/playbooks/setup_nextcloud.yml index b433dd53a..433c8d9a8 100644 --- a/playbooks/setup_nextcloud.yml +++ b/playbooks/setup_nextcloud.yml @@ -61,10 +61,6 @@ when: - 'not setup_nextcloud__skip_python | d(false)' -# - role: 'linuxfabrik.lfops.fail2ban' -# when: -# - 'not setup_nextcloud__skip_fail2ban | d(false)' - # The sysctl entries go in from both roles unconditionally: the two ship identical values and # combine_lod folds the duplicates back into one. Transparent hugepages is a scalar, so it has # to name the server that is actually installed. Nested rather than chained with default(): @@ -143,7 +139,8 @@ # === Nextcloud Application - role: 'linuxfabrik.lfops.selinux' selinux__booleans__dependent_var: '{{ - nextcloud__selinux__booleans__dependent_var + nextcloud__selinux__booleans__dependent_var + + (not setup_nextcloud__skip_fail2ban | d(true)) | ternary(fail2ban__selinux__booleans__dependent_var, []) }}' selinux__fcontexts__dependent_var: '{{ nextcloud__selinux__fcontexts__dependent_var @@ -164,6 +161,19 @@ - role: 'linuxfabrik.lfops.nextcloud' + # Off by default: fail2ban belongs on a host that clients reach directly, not on one behind a + # reverse proxy, where every client arrives from the proxy's address. After nextcloud, since + # fail2ban does not start while the log file of a jail is missing. + - role: 'linuxfabrik.lfops.fail2ban' + fail2ban__jail_default_ignoreip__dependent_var: '{{ + nextcloud__fail2ban__jail_default_ignoreip__dependent_var + }}' + fail2ban__jails__dependent_var: '{{ + nextcloud__fail2ban__jails__dependent_var + }}' + when: + - 'not setup_nextcloud__skip_fail2ban | d(true)' + # === Online Office - role: 'linuxfabrik.lfops.repo_collabora_code' when: diff --git a/roles/fail2ban/README.md b/roles/fail2ban/README.md index a44750e10..40872ff8a 100644 --- a/roles/fail2ban/README.md +++ b/roles/fail2ban/README.md @@ -129,7 +129,7 @@ Any [LFOps playbook](https://github.com/Linuxfabrik/lfops/blob/main/playbooks/RE `fail2ban__jail_default_ignoreip` -* List of IP addresses (in CIDR notation) that will be ignored from all jails (assuming the jail does not overwrite it). +* List of IP addresses (in CIDR notation) that will be ignored from all jails (assuming the jail does not overwrite it). Other roles add to this list, for example the `nextcloud` role adds the reverse proxies from its `trusted_proxies`, so a jail never bans them. * Type: List of strings. * Default: `[]` diff --git a/roles/fail2ban/defaults/main.yml b/roles/fail2ban/defaults/main.yml index 1bce5726a..8e873c223 100644 --- a/roles/fail2ban/defaults/main.yml +++ b/roles/fail2ban/defaults/main.yml @@ -35,6 +35,7 @@ fail2ban__jail_default_action: |- # 1.1.0-6.el9 on Rocky 9: `fail2ban-client get sshd actions` reports iptables-multiport. fail2ban__jail_default_banaction: 'iptables-multiport' fail2ban__jail_default_ignoreip: [] +fail2ban__jail_default_ignoreip__dependent_var: [] fail2ban__jail_default_rocketchat_hook: '' fail2ban__jail_nextcloud_bantime: '8h' fail2ban__jail_nextcloud_findtime: '10m' diff --git a/roles/fail2ban/meta/argument_specs.yml b/roles/fail2ban/meta/argument_specs.yml index 3753f8654..72a963e61 100644 --- a/roles/fail2ban/meta/argument_specs.yml +++ b/roles/fail2ban/meta/argument_specs.yml @@ -84,6 +84,13 @@ argument_specs: default: [] description: 'IP addresses in CIDR notation that are ignored by all jails.' + fail2ban__jail_default_ignoreip__dependent_var: + type: 'list' + elements: 'str' + required: false + default: [] + description: 'IP addresses in CIDR notation that are ignored by all jails. Dependent-role injection.' + fail2ban__jail_default_rocketchat_hook: type: 'str' required: false diff --git a/roles/fail2ban/templates/etc/fail2ban/jail.d/z00-defaults.conf.j2 b/roles/fail2ban/templates/etc/fail2ban/jail.d/z00-defaults.conf.j2 index 99eef2b4c..2194c07f2 100644 --- a/roles/fail2ban/templates/etc/fail2ban/jail.d/z00-defaults.conf.j2 +++ b/roles/fail2ban/templates/etc/fail2ban/jail.d/z00-defaults.conf.j2 @@ -1,10 +1,10 @@ # {{ ansible_managed }} -# 2022070401 +# 2026092201 [DEFAULT] action = {{ fail2ban__jail_default_action | indent(width=13, first=false) }} banaction = {{ fail2ban__jail_default_banaction }} chain = INPUT -ignoreip = {{ fail2ban__jail_default_ignoreip | join(' ') }} +ignoreip = {{ (fail2ban__jail_default_ignoreip + fail2ban__jail_default_ignoreip__dependent_var) | unique | join(' ') }} protocol = tcp rocketchat-hook = {{ fail2ban__jail_default_rocketchat_hook }} diff --git a/roles/nextcloud/README.md b/roles/nextcloud/README.md index b2183df79..d24ae54f3 100644 --- a/roles/nextcloud/README.md +++ b/roles/nextcloud/README.md @@ -17,6 +17,8 @@ After installing Nextcloud, head over to your http(s)://nextcloud/index.php/sett * App updates are applied automatically by the `nextcloud-app-update.timer` (enabled by default, disable via `nextcloud__timer_app_update_enabled`). The timer runs `/usr/local/bin/nextcloud-app-update`, which first checks whether any app update is pending. Nextcloud is switched into maintenance mode only when there is something to update; when everything is up to date the instance keeps serving requests untouched. After updating, the recommended database migrations (`db:add-missing-indices`, `db:add-missing-columns`, `db:add-missing-primary-keys`) are applied. A failed run leaves maintenance mode disabled again, so the instance does not stay offline, and reports the failure to systemd. * This automatic update covers app updates only. Updating the Nextcloud server itself is a separate, manual step via `/usr/local/bin/nextcloud-update`. * Nextcloud logs to `/var/log/nextcloud/nextcloud.log`, not to the data directory. On SELinux systems, fail2ban may only read files labeled as logs, and the fail2ban jail for Nextcloud reads this file. +* The vHost serves plain HTTP and expects a reverse proxy in front that terminates TLS. Configure Nextcloud for it via `nextcloud__sysconfig__*_var` (see the example below), above all `trusted_proxies`: without it, Nextcloud sees every client with the address of the proxy, so its brute-force protection slows down all clients together and its log names the proxy instead of the client. The proxy also has to forward `/push/` to notify_push on port 7867 of the Nextcloud host (`/push/ws` as a WebSocket). +* Behind a reverse proxy, Nextcloud's own brute-force protection slows down password guessing, which is why `trusted_proxies` matters. fail2ban cannot help there, since every client reaches the host from the proxy's address. On a Nextcloud host that clients reach directly, set `setup_nextcloud__skip_fail2ban: false`: the playbook then runs fail2ban with a jail that bans IPs with too many failed Nextcloud logins. The reverse proxies listed in `trusted_proxies` are never banned, in any jail. ## Dependent Roles @@ -29,6 +31,7 @@ Any [LFOps playbook](https://github.com/Linuxfabrik/lfops/blob/main/playbooks/RE * PHP 8.1+ must be installed (roles: [linuxfabrik.lfops.repo_remi](https://github.com/Linuxfabrik/lfops/tree/main/roles/repo_remi) and [linuxfabrik.lfops.php](https://github.com/Linuxfabrik/lfops/tree/main/roles/php)). * Redis 7+ must be installed (roles: [linuxfabrik.lfops.repo_redis](https://github.com/Linuxfabrik/lfops/tree/main/roles/repo_redis) and [linuxfabrik.lfops.redis](https://github.com/Linuxfabrik/lfops/tree/main/roles/redis)). * Optional: Collabora (role: [linuxfabrik.lfops.collabora](https://github.com/Linuxfabrik/lfops/tree/main/roles/collabora)) provides online document editing. +* Optional: fail2ban bans IPs with too many failed logins (role: [linuxfabrik.lfops.fail2ban](https://github.com/Linuxfabrik/lfops/tree/main/roles/fail2ban)). * Optional: Coturn (role: [linuxfabrik.lfops.coturn](https://github.com/Linuxfabrik/lfops/tree/main/roles/coturn)) provides the TURN server for Nextcloud Talk. These roles are not enabled by default; enable them via the playbook's skip variables if needed: diff --git a/roles/nextcloud/defaults/main.yml b/roles/nextcloud/defaults/main.yml index f591e3193..94eeaa8ed 100644 --- a/roles/nextcloud/defaults/main.yml +++ b/roles/nextcloud/defaults/main.yml @@ -482,6 +482,28 @@ nextcloud__collabora__coolwsd_storage_wopi__dependent_var: - name: '{{ nextcloud__fqdn | regex_replace("\.", "\.") }}' state: 'present' +# The reverse proxies Nextcloud trusts must never be banned: behind a proxy, every client +# reaches this host from the proxy's address, so a ban on it locks out everyone. Taken from +# the `trusted_proxies` entries, so an inventory that configures the proxy for Nextcloud +# protects it from fail2ban as well. +nextcloud__fail2ban__jail_default_ignoreip__dependent_var: '{{ + (nextcloud__sysconfig__combined_var + | selectattr("key", "match", "trusted_proxies ") + | selectattr("state", "undefined") + | map(attribute="value") + | list) + + (nextcloud__sysconfig__combined_var + | selectattr("key", "match", "trusted_proxies ") + | selectattr("state", "defined") + | selectattr("state", "ne", "absent") + | map(attribute="value") + | list) + }}' +nextcloud__fail2ban__jails__dependent_var: + - filename: 'z10-nextcloud' + state: 'present' + template: 'nextcloud' + # Has to stay above nextcloud__php__ini_max_execution_time__dependent_var so PHP's own limit # trips first and reports a fatal error, leaving PHP-FPM as the backstop for a worker stuck # in a system call, which max_execution_time cannot interrupt. The php role defaults to 60s, From dcad17f81f5f78881563a49cefcd2eadc5e3c5c9 Mon Sep 17 00:00:00 2001 From: Markus Frei Date: Tue, 22 Sep 2026 15:06:52 +0200 Subject: [PATCH 12/38] feat(roles/nextcloud)!: abort when PHP or MariaDB do not fit the installed Nextcloud The requirements per major version come from the server source at the release tags v30.0.0 to v35.0.0: the PHP range from lib/versioncheck.php, outside of which Nextcloud refuses to run, and MIN_MARIADB from apps/settings/lib/SetupChecks/SupportedDatabase.php. The major version is read from version.php of the unpacked code, since nextcloud__version may just say 'latest'. Nextcloud sets no minimum for the Redis or Valkey server, only phpredis >= 4.0.0. --- CHANGELOG.md | 1 + roles/nextcloud/README.md | 15 +++++++-- roles/nextcloud/tasks/main.yml | 56 ++++++++++++++++++++++++++++++++++ roles/nextcloud/vars/main.yml | 31 +++++++++++++++++++ 4 files changed, 101 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f278a2790..089f6416d 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: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. diff --git a/roles/nextcloud/README.md b/roles/nextcloud/README.md index d24ae54f3..506ba3542 100644 --- a/roles/nextcloud/README.md +++ b/roles/nextcloud/README.md @@ -27,8 +27,8 @@ Any [LFOps playbook](https://github.com/Linuxfabrik/lfops/blob/main/playbooks/RE * On RHEL-compatible systems, the EPEL repository must be enabled (role: [linuxfabrik.lfops.repo_epel](https://github.com/Linuxfabrik/lfops/tree/main/roles/repo_epel)). * A web server (for example Apache httpd) must be installed, with a virtual host for Nextcloud (role: [linuxfabrik.lfops.apache_httpd](https://github.com/Linuxfabrik/lfops/tree/main/roles/apache_httpd)). -* MariaDB 10.6+ must be installed (role: [linuxfabrik.lfops.mariadb_server](https://github.com/Linuxfabrik/lfops/tree/main/roles/mariadb_server)). -* PHP 8.1+ must be installed (roles: [linuxfabrik.lfops.repo_remi](https://github.com/Linuxfabrik/lfops/tree/main/roles/repo_remi) and [linuxfabrik.lfops.php](https://github.com/Linuxfabrik/lfops/tree/main/roles/php)). +* MariaDB must be installed, in a version the installed Nextcloud supports (10.11+ for Nextcloud 35) (role: [linuxfabrik.lfops.mariadb_server](https://github.com/Linuxfabrik/lfops/tree/main/roles/mariadb_server)). +* PHP must be installed, in a version the installed Nextcloud supports (8.3 to 8.5 for Nextcloud 35) (roles: [linuxfabrik.lfops.repo_remi](https://github.com/Linuxfabrik/lfops/tree/main/roles/repo_remi) and [linuxfabrik.lfops.php](https://github.com/Linuxfabrik/lfops/tree/main/roles/php)). * Redis 7+ must be installed (roles: [linuxfabrik.lfops.repo_redis](https://github.com/Linuxfabrik/lfops/tree/main/roles/repo_redis) and [linuxfabrik.lfops.redis](https://github.com/Linuxfabrik/lfops/tree/main/roles/redis)). * Optional: Collabora (role: [linuxfabrik.lfops.collabora](https://github.com/Linuxfabrik/lfops/tree/main/roles/collabora)) provides online document editing. * Optional: fail2ban bans IPs with too many failed logins (role: [linuxfabrik.lfops.fail2ban](https://github.com/Linuxfabrik/lfops/tree/main/roles/fail2ban)). @@ -465,6 +465,17 @@ nextcloud__vhost_virtualhost_ip: '127.0.0.1' nextcloud__vhost_virtualhost_port: '81' ``` +## Troubleshooting + +`Nextcloud 35 needs PHP 8.3 or newer, but older than 8.6, and MariaDB 10.11 or newer.` + +* The role compares the installed PHP and MariaDB with what the installed Nextcloud major version requires, before it changes anything, and aborts if they do not fit. Nextcloud itself refuses to run on a PHP outside that range. Upgrade PHP (for example via `repo_remi__enabled_php_version`) or MariaDB (`repo_mariadb__version`), then run the role again. + +`Nextcloud 36 is not supported by this role.` + +* The role knows the requirements of the Nextcloud major versions listed in the message only. Pin `nextcloud__version` to a supported major version for a new installation, or add the requirements of the new major version to `__nextcloud__requirements` in `vars/main.yml`. + + ## License [The Unlicense](https://unlicense.org/) diff --git a/roles/nextcloud/tasks/main.yml b/roles/nextcloud/tasks/main.yml index 5f899ef7c..c7c308c07 100644 --- a/roles/nextcloud/tasks/main.yml +++ b/roles/nextcloud/tasks/main.yml @@ -28,6 +28,62 @@ remote_src: true creates: '/var/www/html/nextcloud/config' + # Checked against the unpacked code rather than nextcloud__version, which may just say + # 'latest', and on every run, so a PHP or MariaDB that falls below what the installed + # Nextcloud needs stops the run before the role touches the instance. + - name: 'stat /var/www/html/nextcloud/version.php' + ansible.builtin.stat: + path: '/var/www/html/nextcloud/version.php' + register: '__nextcloud__version_php_result' + + - block: + + - name: 'Get the Nextcloud major version and the PHP version' + ansible.builtin.command: + argv: + - 'php' + - '--run' + - 'require "/var/www/html/nextcloud/version.php"; echo $OC_Version[0], " ", PHP_VERSION;' + register: '__nextcloud__versions_result' + changed_when: false + check_mode: false + + - name: 'Get the MariaDB version' + ansible.mariadb.mariadb_info: + login_user: '{{ nextcloud__mariadb_login["username"] }}' + login_password: '{{ nextcloud__mariadb_login["password"] }}' + login_host: '{{ nextcloud__database_host }}' + login_unix_socket: '{{ (nextcloud__database_host == "localhost") | ternary("/var/lib/mysql/mysql.sock", omit) }}' # https://github.com/PyMySQL/PyMySQL/issues/509#issuecomment-244072354 + filter: 'version' + register: '__nextcloud__mariadb_info_result' + check_mode: false + + - name: 'Assert that PHP and MariaDB meet the requirements of the installed Nextcloud' + ansible.builtin.assert: + that: + - '__nextcloud__major_version in __nextcloud__requirements' + - '__nextcloud__php_version is version(__nextcloud__requirements[__nextcloud__major_version]["php_min"], ">=")' + - '__nextcloud__php_version is version(__nextcloud__requirements[__nextcloud__major_version]["php_max"], "<")' + - '__nextcloud__mariadb_version is version(__nextcloud__requirements[__nextcloud__major_version]["mariadb_min"], ">=")' + quiet: true + fail_msg: >- + {% if __nextcloud__major_version not in __nextcloud__requirements -%} + Nextcloud {{ __nextcloud__major_version }} is not supported by this role. + Supported versions: {{ __nextcloud__requirements | list | join(", ") }}. + {%- else -%} + Nextcloud {{ __nextcloud__major_version }} needs + PHP {{ __nextcloud__requirements[__nextcloud__major_version]["php_min"] }} or newer, but older than {{ __nextcloud__requirements[__nextcloud__major_version]["php_max"] }}, + and MariaDB {{ __nextcloud__requirements[__nextcloud__major_version]["mariadb_min"] }} or newer. + Found PHP {{ __nextcloud__php_version }} and MariaDB {{ __nextcloud__mariadb_version }}. + {%- endif %} + vars: + __nextcloud__major_version: '{{ __nextcloud__versions_result["stdout"].split()[0] }}' + __nextcloud__mariadb_version: '{{ __nextcloud__mariadb_info_result["version"]["full"] | regex_search("^[0-9.]+") }}' + __nextcloud__php_version: '{{ __nextcloud__versions_result["stdout"].split()[1] }}' + + when: + - '__nextcloud__version_php_result["stat"]["exists"]' + - name: 'Deploy /var/www/html/nextcloud/config/objectstore.config.php (storage backend)' ansible.builtin.template: backup: true diff --git a/roles/nextcloud/vars/main.yml b/roles/nextcloud/vars/main.yml index 307dfea0b..e6fa2de54 100644 --- a/roles/nextcloud/vars/main.yml +++ b/roles/nextcloud/vars/main.yml @@ -96,3 +96,34 @@ nextcloud__php__modules__dependent_var: '{{ | linuxfabrik.lfops.platform_select(ansible_facts)) if __php__installed_version is defined else [] }}' + +# What each Nextcloud major version needs, taken from the Nextcloud server source at the release +# tag (v30.0.0 to v35.0.0): `php_min` and `php_max` (exclusive) from lib/versioncheck.php, outside +# of which Nextcloud refuses to run, and `mariadb_min` from MIN_MARIADB in +# apps/settings/lib/SetupChecks/SupportedDatabase.php. Nextcloud sets no minimum for the Redis or +# Valkey server, only for the phpredis extension (4.0.0), which every supported platform exceeds. +__nextcloud__requirements: + '30': + mariadb_min: '10.6' + php_max: '8.4' + php_min: '8.1' + '31': + mariadb_min: '10.6' + php_max: '8.5' + php_min: '8.1' + '32': + mariadb_min: '10.6' + php_max: '8.5' + php_min: '8.1' + '33': + mariadb_min: '10.6' + php_max: '8.6' + php_min: '8.2' + '34': + mariadb_min: '10.6' + php_max: '8.6' + php_min: '8.2' + '35': + mariadb_min: '10.11' + php_max: '8.6' + php_min: '8.3' From 5b13c69001a315493123008647a64b7c676d68cd Mon Sep 17 00:00:00 2001 From: Markus Frei Date: Tue, 22 Sep 2026 16:47:55 +0200 Subject: [PATCH 13/38] feat(roles/valkey): listen on a Unix socket that the valkey group may use The RHEL package enables /run/valkey/valkey.sock, the templates commented it out. valkey__conf_unixsocket follows the package path on RHEL and uses /run/valkey/valkey-server.sock on Debian and Ubuntu, whose package ships the socket off. valkey__conf_unixsocketperm defaults to 770: without it the mode follows the umask of the service (0755), so only valkey itself could connect. valkey__conf_unixsocket stays out of argument_specs, since its default comes from vars/.yml, which is loaded after the role-entry validation. --- roles/valkey/README.md | 16 ++++++++++++++++ roles/valkey/defaults/main.yml | 2 ++ roles/valkey/meta/argument_specs.yml | 6 ++++++ .../templates/etc/valkey/7.2-valkey.conf.j2 | 7 +++++-- .../templates/etc/valkey/8.0-valkey.conf.j2 | 7 +++++-- .../templates/etc/valkey/8.1-valkey.conf.j2 | 7 +++++-- .../templates/etc/valkey/9.0-valkey.conf.j2 | 7 +++++-- roles/valkey/vars/Debian.yml | 1 + roles/valkey/vars/RedHat.yml | 1 + roles/valkey/vars/Ubuntu.yml | 1 + 10 files changed, 47 insertions(+), 8 deletions(-) diff --git a/roles/valkey/README.md b/roles/valkey/README.md index 3e1e6e728..871cccb95 100644 --- a/roles/valkey/README.md +++ b/roles/valkey/README.md @@ -193,6 +193,20 @@ Variables for `valkey.conf` directives and their default values, defined and sup * Type: Number. * Default: unset +`valkey__conf_unixsocket` + +* Path of the Unix socket Valkey listens on, in addition to the TCP port. Set it to `''` to listen on no Unix socket. [valkey.conf](https://github.com/valkey-io/valkey/blob/8.0/valkey.conf) +* Type: String. +* Default: `'/run/valkey/valkey.sock'` on RHEL, `'/run/valkey/valkey-server.sock'` on Debian and Ubuntu +* Deviates from the upstream default on Debian and Ubuntu, whose package listens on no Unix socket: local clients such as Nextcloud connect faster through the socket than through TCP, and `valkey__conf_unixsocketperm` keeps it closed to anyone outside the `valkey` group. + +`valkey__conf_unixsocketperm` + +* Permissions of the Unix socket. Only the `valkey` user and the members of the `valkey` group can connect with `770`; a role that needs access adds its service user to the group. [valkey.conf](https://github.com/valkey-io/valkey/blob/8.0/valkey.conf) +* Type: String. +* Default: `'770'` +* Deviates from the upstream default, which leaves the mode to the umask of the service, so that the socket is writable, and thus usable, by the `valkey` user only. + Example: ```yaml @@ -216,6 +230,8 @@ valkey__conf_tls_ca_cert_file: '/etc/valkey/ca.pem' valkey__conf_tls_cert_file: '/etc/valkey/valkey.pem' valkey__conf_tls_key_file: '/etc/valkey/valkey.key' valkey__conf_tls_port: 6379 +valkey__conf_unixsocket: '/run/valkey/valkey.sock' +valkey__conf_unixsocketperm: '770' ``` diff --git a/roles/valkey/defaults/main.yml b/roles/valkey/defaults/main.yml index 6cd3aaeb1..28aba90af 100644 --- a/roles/valkey/defaults/main.yml +++ b/roles/valkey/defaults/main.yml @@ -8,6 +8,8 @@ valkey__conf_maxmemory_policy: 'noeviction' valkey__conf_port: 6379 valkey__conf_protected_mode: 'yes' valkey__conf_replica_serve_stale_data: 'yes' +valkey__conf_unixsocket: '{{ __valkey__conf_unixsocket }}' # upstream default: /run/valkey/valkey.sock on RHEL, off on Debian / Ubuntu +valkey__conf_unixsocketperm: '770' # upstream default: unset, the mode then follows the umask valkey__service_enabled: true valkey__service_limit_nofile: 10240 # upstream default: 10240 on RHEL, 65535 on Debian / Ubuntu diff --git a/roles/valkey/meta/argument_specs.yml b/roles/valkey/meta/argument_specs.yml index 3fcd38392..4b1dfd8cd 100644 --- a/roles/valkey/meta/argument_specs.yml +++ b/roles/valkey/meta/argument_specs.yml @@ -101,6 +101,12 @@ argument_specs: required: false description: 'TLS port to listen on. Set valkey__conf_port to 0 to only listen with TLS.' + valkey__conf_unixsocketperm: + type: 'str' + required: false + default: '770' + description: 'Permissions of the Unix socket.' + valkey__kernel_settings__sysctl__dependent_var: type: 'list' elements: 'dict' diff --git a/roles/valkey/templates/etc/valkey/7.2-valkey.conf.j2 b/roles/valkey/templates/etc/valkey/7.2-valkey.conf.j2 index f039e192c..b4bb4ac01 100644 --- a/roles/valkey/templates/etc/valkey/7.2-valkey.conf.j2 +++ b/roles/valkey/templates/etc/valkey/7.2-valkey.conf.j2 @@ -1,5 +1,5 @@ # {{ ansible_managed }} -# 2026081401 +# 2026092201 # Valkey v7.2 configuration file. # @@ -158,7 +158,10 @@ tcp-backlog 511 # # unixsocket /run/valkey/valkey-server.sock # unixsocketperm 700 -{# RHEL package default: unixsocket /run/valkey/valkey.sock #} +{% if valkey__conf_unixsocket | length > 0 %} +unixsocket {{ valkey__conf_unixsocket }} +unixsocketperm {{ valkey__conf_unixsocketperm }} +{% endif %} # Close the connection after a client is idle for N seconds (0 to disable) timeout 0 diff --git a/roles/valkey/templates/etc/valkey/8.0-valkey.conf.j2 b/roles/valkey/templates/etc/valkey/8.0-valkey.conf.j2 index a90f57cb9..1ea3eadae 100644 --- a/roles/valkey/templates/etc/valkey/8.0-valkey.conf.j2 +++ b/roles/valkey/templates/etc/valkey/8.0-valkey.conf.j2 @@ -1,5 +1,5 @@ # {{ ansible_managed }} -# 2026081401 +# 2026092201 # Valkey v8.0 configuration file. # @@ -160,7 +160,10 @@ tcp-backlog 511 # unixsocket /run/valkey/valkey.sock # unixsocketgroup wheel # unixsocketperm 700 -{# RHEL package default: unixsocket /run/valkey/valkey.sock #} +{% if valkey__conf_unixsocket | length > 0 %} +unixsocket {{ valkey__conf_unixsocket }} +unixsocketperm {{ valkey__conf_unixsocketperm }} +{% endif %} # Close the connection after a client is idle for N seconds (0 to disable) timeout 0 diff --git a/roles/valkey/templates/etc/valkey/8.1-valkey.conf.j2 b/roles/valkey/templates/etc/valkey/8.1-valkey.conf.j2 index 4b5221f28..c7df4d3a8 100644 --- a/roles/valkey/templates/etc/valkey/8.1-valkey.conf.j2 +++ b/roles/valkey/templates/etc/valkey/8.1-valkey.conf.j2 @@ -1,5 +1,5 @@ # {{ ansible_managed }} -# 2026081401 +# 2026092201 # Valkey v8.1 configuration file. # @@ -160,7 +160,10 @@ tcp-backlog 511 # unixsocket /run/valkey/valkey-server.sock # unixsocketgroup wheel # unixsocketperm 700 -{# RHEL package default: unixsocket /run/valkey/valkey.sock #} +{% if valkey__conf_unixsocket | length > 0 %} +unixsocket {{ valkey__conf_unixsocket }} +unixsocketperm {{ valkey__conf_unixsocketperm }} +{% endif %} # Close the connection after a client is idle for N seconds (0 to disable) timeout 0 diff --git a/roles/valkey/templates/etc/valkey/9.0-valkey.conf.j2 b/roles/valkey/templates/etc/valkey/9.0-valkey.conf.j2 index 83f3963e5..248a5fe77 100644 --- a/roles/valkey/templates/etc/valkey/9.0-valkey.conf.j2 +++ b/roles/valkey/templates/etc/valkey/9.0-valkey.conf.j2 @@ -1,5 +1,5 @@ # {{ ansible_managed }} -# 2026081401 +# 2026092201 # Valkey v9.0 configuration file. # @@ -170,7 +170,10 @@ tcp-backlog 511 # unixsocket /run/valkey/valkey-server.sock # unixsocketgroup wheel # unixsocketperm 700 -{# RHEL package default: unixsocket /run/valkey/valkey.sock #} +{% if valkey__conf_unixsocket | length > 0 %} +unixsocket {{ valkey__conf_unixsocket }} +unixsocketperm {{ valkey__conf_unixsocketperm }} +{% endif %} # Close the connection after a client is idle for N seconds (0 to disable) timeout 0 diff --git a/roles/valkey/vars/Debian.yml b/roles/valkey/vars/Debian.yml index ac0764469..621bac59d 100644 --- a/roles/valkey/vars/Debian.yml +++ b/roles/valkey/vars/Debian.yml @@ -1,4 +1,5 @@ __valkey__conf_logfile: '/var/log/valkey/valkey-server.log' +__valkey__conf_unixsocket: '/run/valkey/valkey-server.sock' __valkey__config_dir: '/etc/valkey' __valkey__config_file: 'valkey.conf' __valkey__data_dir: '/var/lib/valkey' diff --git a/roles/valkey/vars/RedHat.yml b/roles/valkey/vars/RedHat.yml index ce3d2317e..dc3db9721 100644 --- a/roles/valkey/vars/RedHat.yml +++ b/roles/valkey/vars/RedHat.yml @@ -1,4 +1,5 @@ __valkey__conf_logfile: '/var/log/valkey/valkey.log' +__valkey__conf_unixsocket: '/run/valkey/valkey.sock' __valkey__config_dir: '/etc/valkey' __valkey__config_file: 'valkey.conf' __valkey__data_dir: '/var/lib/valkey' diff --git a/roles/valkey/vars/Ubuntu.yml b/roles/valkey/vars/Ubuntu.yml index ac0764469..621bac59d 100644 --- a/roles/valkey/vars/Ubuntu.yml +++ b/roles/valkey/vars/Ubuntu.yml @@ -1,4 +1,5 @@ __valkey__conf_logfile: '/var/log/valkey/valkey-server.log' +__valkey__conf_unixsocket: '/run/valkey/valkey-server.sock' __valkey__config_dir: '/etc/valkey' __valkey__config_file: 'valkey.conf' __valkey__data_dir: '/var/lib/valkey' From 0e0db5025dc0a2d102f8e170b604694429a3edcc Mon Sep 17 00:00:00 2001 From: Markus Frei Date: Tue, 22 Sep 2026 16:47:56 +0200 Subject: [PATCH 14/38] feat(roles/redis): listen on a Unix socket that the redis group may use Same as for Valkey. The Remi package for EL9 (redis 8.8) enables /run/redis/redis.sock with the mode left to the umask. Redis has no unixsocketgroup (Valkey added it in 8.0), so access goes through the redis group on both. --- CHANGELOG.md | 1 + roles/redis/README.md | 16 ++++++++++++++++ roles/redis/defaults/main.yml | 2 ++ .../redis/templates/etc/redis/7.0-redis.conf.j2 | 6 +++++- .../redis/templates/etc/redis/7.2-redis.conf.j2 | 6 +++++- .../redis/templates/etc/redis/7.4-redis.conf.j2 | 6 +++++- .../redis/templates/etc/redis/8.0-redis.conf.j2 | 6 +++++- .../redis/templates/etc/redis/8.2-redis.conf.j2 | 6 +++++- .../redis/templates/etc/redis/8.8-redis.conf.j2 | 6 +++++- roles/redis/vars/Debian.yml | 1 + roles/redis/vars/RedHat.yml | 1 + 11 files changed, 51 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 089f6416d..562c0a927 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,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. diff --git a/roles/redis/README.md b/roles/redis/README.md index 2d8bb5002..b8c02947c 100644 --- a/roles/redis/README.md +++ b/roles/redis/README.md @@ -190,6 +190,20 @@ Variables for `redis.conf` directives and their default values, defined and supp * Type: Number. * Default: unset +`redis__conf_unixsocket` + +* Path of the Unix socket Redis listens on, in addition to the TCP port. Set it to `''` to listen on no Unix socket. [redis.conf](https://github.com/redis/redis/blob/8.8/redis.conf) +* Type: String. +* Default: `'/run/redis/redis.sock'` on RHEL, `'/run/redis/redis-server.sock'` on Debian and Ubuntu +* Deviates from the upstream default on Debian and Ubuntu, whose package listens on no Unix socket: local clients such as Nextcloud connect faster through the socket than through TCP, and `redis__conf_unixsocketperm` keeps it closed to anyone outside the `redis` group. + +`redis__conf_unixsocketperm` + +* Permissions of the Unix socket. Only the `redis` user and the members of the `redis` group can connect with `770`; a role that needs access adds its service user to the group. [redis.conf](https://github.com/redis/redis/blob/8.8/redis.conf) +* Type: String. +* Default: `'770'` +* Deviates from the upstream default, which leaves the mode to the umask of the service, so that the socket is writable, and thus usable, by the `redis` user only. + Example: ```yaml @@ -213,6 +227,8 @@ redis__conf_tls_ca_cert_file: '/etc/redis/ca.pem' redis__conf_tls_cert_file: '/etc/redis/redis.pem' redis__conf_tls_key_file: '/etc/redis/redis.key' redis__conf_tls_port: 6379 +redis__conf_unixsocket: '/run/redis/redis.sock' +redis__conf_unixsocketperm: '770' ``` diff --git a/roles/redis/defaults/main.yml b/roles/redis/defaults/main.yml index f6e17e8c2..7d056e181 100644 --- a/roles/redis/defaults/main.yml +++ b/roles/redis/defaults/main.yml @@ -8,6 +8,8 @@ redis__conf_maxmemory_policy: 'noeviction' redis__conf_port: 6379 redis__conf_protected_mode: 'yes' redis__conf_replica_serve_stale_data: 'yes' +redis__conf_unixsocket: '{{ __redis__conf_unixsocket }}' # upstream default: /run/redis/redis.sock on RHEL, off on Debian / Ubuntu +redis__conf_unixsocketperm: '770' # upstream default: unset, the mode then follows the umask redis__service_enabled: true redis__service_limit_nofile: 10240 diff --git a/roles/redis/templates/etc/redis/7.0-redis.conf.j2 b/roles/redis/templates/etc/redis/7.0-redis.conf.j2 index f69d18e82..e46461a49 100644 --- a/roles/redis/templates/etc/redis/7.0-redis.conf.j2 +++ b/roles/redis/templates/etc/redis/7.0-redis.conf.j2 @@ -1,5 +1,5 @@ # {{ ansible_managed }} -# 2024061301 +# 2026092201 # Redis v7.0 configuration file. # @@ -157,6 +157,10 @@ tcp-backlog 511 # # unixsocket /run/redis.sock # unixsocketperm 700 +{% if redis__conf_unixsocket | length > 0 %} +unixsocket {{ redis__conf_unixsocket }} +unixsocketperm {{ redis__conf_unixsocketperm }} +{% endif %} # Close the connection after a client is idle for N seconds (0 to disable) timeout 0 diff --git a/roles/redis/templates/etc/redis/7.2-redis.conf.j2 b/roles/redis/templates/etc/redis/7.2-redis.conf.j2 index 2598173cb..8438c95c9 100644 --- a/roles/redis/templates/etc/redis/7.2-redis.conf.j2 +++ b/roles/redis/templates/etc/redis/7.2-redis.conf.j2 @@ -1,5 +1,5 @@ # {{ ansible_managed }} -# 2024061301 +# 2026092201 # Redis v7.2 configuration file. # @@ -157,6 +157,10 @@ tcp-backlog 511 # # unixsocket /run/redis.sock # unixsocketperm 700 +{% if redis__conf_unixsocket | length > 0 %} +unixsocket {{ redis__conf_unixsocket }} +unixsocketperm {{ redis__conf_unixsocketperm }} +{% endif %} # Close the connection after a client is idle for N seconds (0 to disable) timeout 0 diff --git a/roles/redis/templates/etc/redis/7.4-redis.conf.j2 b/roles/redis/templates/etc/redis/7.4-redis.conf.j2 index 2f95eab1f..1ef56e436 100644 --- a/roles/redis/templates/etc/redis/7.4-redis.conf.j2 +++ b/roles/redis/templates/etc/redis/7.4-redis.conf.j2 @@ -1,5 +1,5 @@ # {{ ansible_managed }} -# 2024080701 +# 2026092201 # Redis v7.4 configuration file. # @@ -158,6 +158,10 @@ tcp-backlog 511 # # unixsocket /run/redis.sock # unixsocketperm 700 +{% if redis__conf_unixsocket | length > 0 %} +unixsocket {{ redis__conf_unixsocket }} +unixsocketperm {{ redis__conf_unixsocketperm }} +{% endif %} # Close the connection after a client is idle for N seconds (0 to disable) timeout 0 diff --git a/roles/redis/templates/etc/redis/8.0-redis.conf.j2 b/roles/redis/templates/etc/redis/8.0-redis.conf.j2 index 0e514563b..0b3a252cc 100644 --- a/roles/redis/templates/etc/redis/8.0-redis.conf.j2 +++ b/roles/redis/templates/etc/redis/8.0-redis.conf.j2 @@ -1,5 +1,5 @@ # {{ ansible_managed }} -# 2025051601 +# 2026092201 # Redis v8.0 configuration file. # @@ -165,6 +165,10 @@ tcp-backlog 511 # # unixsocket /run/redis.sock # unixsocketperm 700 +{% if redis__conf_unixsocket | length > 0 %} +unixsocket {{ redis__conf_unixsocket }} +unixsocketperm {{ redis__conf_unixsocketperm }} +{% endif %} # Close the connection after a client is idle for N seconds (0 to disable) timeout 0 diff --git a/roles/redis/templates/etc/redis/8.2-redis.conf.j2 b/roles/redis/templates/etc/redis/8.2-redis.conf.j2 index f41d2047a..73ad46a36 100644 --- a/roles/redis/templates/etc/redis/8.2-redis.conf.j2 +++ b/roles/redis/templates/etc/redis/8.2-redis.conf.j2 @@ -1,5 +1,5 @@ # {{ ansible_managed }} -# 2025110701 +# 2026092201 # Redis v8.2 configuration file. # @@ -160,6 +160,10 @@ tcp-backlog 511 # # unixsocket /run/redis.sock # unixsocketperm 700 +{% if redis__conf_unixsocket | length > 0 %} +unixsocket {{ redis__conf_unixsocket }} +unixsocketperm {{ redis__conf_unixsocketperm }} +{% endif %} # Close the connection after a client is idle for N seconds (0 to disable) timeout 0 diff --git a/roles/redis/templates/etc/redis/8.8-redis.conf.j2 b/roles/redis/templates/etc/redis/8.8-redis.conf.j2 index 8d2ace7e4..25e3e785e 100644 --- a/roles/redis/templates/etc/redis/8.8-redis.conf.j2 +++ b/roles/redis/templates/etc/redis/8.8-redis.conf.j2 @@ -1,5 +1,5 @@ # {{ ansible_managed }} -# 2026052801 +# 2026092201 # Redis v8.8 configuration file. # @@ -160,6 +160,10 @@ tcp-backlog 511 # # unixsocket /run/redis.sock # unixsocketperm 700 +{% if redis__conf_unixsocket | length > 0 %} +unixsocket {{ redis__conf_unixsocket }} +unixsocketperm {{ redis__conf_unixsocketperm }} +{% endif %} # Close the connection after a client is idle for N seconds (0 to disable) timeout 0 diff --git a/roles/redis/vars/Debian.yml b/roles/redis/vars/Debian.yml index 82e581deb..a18c9f805 100644 --- a/roles/redis/vars/Debian.yml +++ b/roles/redis/vars/Debian.yml @@ -1,4 +1,5 @@ __redis__conf_logfile: '/var/log/redis/redis-server.log' +__redis__conf_unixsocket: '/run/redis/redis-server.sock' __redis__config_dir: '/etc/redis' __redis__config_file: 'redis.conf' __redis__data_dir: '/var/lib/redis' diff --git a/roles/redis/vars/RedHat.yml b/roles/redis/vars/RedHat.yml index 511c4ccd7..b237f9c7a 100644 --- a/roles/redis/vars/RedHat.yml +++ b/roles/redis/vars/RedHat.yml @@ -1,4 +1,5 @@ __redis__conf_logfile: '/var/log/redis/redis.log' +__redis__conf_unixsocket: '/run/redis/redis.sock' __redis__config_dir: '/etc/redis' __redis__config_file: 'redis.conf' __redis__data_dir: '/var/lib/redis' From 1428c2dcae38ce43e02608158fd823816ca49cde Mon Sep 17 00:00:00 2001 From: Markus Frei Date: Tue, 22 Sep 2026 16:47:57 +0200 Subject: [PATCH 15/38] feat(roles/redis): add meta/argument_specs.yml Derived from the valkey role, whose variables and static defaults are the same; checked with validate_argument_spec against every static default of the role. --- CHANGELOG.md | 1 + roles/redis/meta/argument_specs.yml | 154 ++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 roles/redis/meta/argument_specs.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index 562c0a927..eb21bf4c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +* **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, with a jail that bans an IP for 8 hours after 5 failed Nextcloud logins within 10 minutes. The reverse proxies listed in the Nextcloud setting `trusted_proxies` are never banned, in any jail. * **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. diff --git a/roles/redis/meta/argument_specs.yml b/roles/redis/meta/argument_specs.yml new file mode 100644 index 000000000..1b87a4b88 --- /dev/null +++ b/roles/redis/meta/argument_specs.yml @@ -0,0 +1,154 @@ +# argument_specs validates required variables and types automatically at role entry. +# use this for simple "is defined" / type checks. for complex validations +# (value ranges, cross-variable logic), use ansible.builtin.assert in the tasks. +argument_specs: + main: + options: + + redis__conf_appendonly: + type: 'str' + required: false + default: 'no' + description: 'Enables the append only file (AOF) persistence mode.' + + redis__conf_auto_aof_rewrite_min_size: + type: 'str' + required: false + default: '64mb' + description: 'Minimum size of the append only file before a rewrite is triggered.' + + redis__conf_bind: + type: 'str' + required: false + default: '127.0.0.1 -::1' + description: 'Space-separated list of addresses Redis listens on.' + + redis__conf_databases: + type: 'int' + required: false + default: 16 + description: 'Number of databases.' + + redis__conf_loglevel: + type: 'str' + required: false + default: 'notice' + description: 'Server verbosity level.' + + redis__conf_maxmemory: + type: 'str' + required: false + default: '50M' + description: 'Memory limit after which the eviction policy takes effect.' + + redis__conf_maxmemory_policy: + type: 'str' + required: false + default: 'noeviction' + description: 'How Redis evicts keys once the memory limit is reached.' + + redis__conf_port: + type: 'int' + required: false + default: 6379 + description: 'TCP port to listen on. Set to 0 to disable the TCP socket.' + + redis__conf_protected_mode: + type: 'str' + required: false + default: 'yes' + description: 'Refuses queries from non-loopback addresses while no password is set.' + + redis__conf_replica_serve_stale_data: + type: 'str' + required: false + default: 'yes' + description: 'Lets a replica answer queries while the link to the primary is down.' + + redis__conf_requirepass: + type: 'str' + required: false + description: 'Password clients have to authenticate with.' + + redis__conf_save: + type: 'list' + elements: 'str' + required: false + description: 'Snapshotting rules, each entry being a " " pair.' + + redis__conf_tls_auth_clients: + type: 'str' + required: false + description: 'Whether clients have to authenticate with a certificate.' + + redis__conf_tls_ca_cert_file: + type: 'str' + required: false + description: 'Path to the CA certificate used to verify clients.' + + redis__conf_tls_cert_file: + type: 'str' + required: false + description: 'Path to the TLS certificate.' + + redis__conf_tls_key_file: + type: 'str' + required: false + description: 'Path to the TLS private key.' + + redis__conf_tls_port: + type: 'int' + required: false + description: 'TLS port to listen on. Set redis__conf_port to 0 to only listen with TLS.' + + redis__conf_unixsocketperm: + type: 'str' + required: false + default: '770' + description: 'Permissions of the Unix socket.' + + redis__kernel_settings__sysctl__dependent_var: + type: 'list' + elements: 'dict' + required: false + description: 'Kernel settings injected into the kernel_settings role.' + + redis__kernel_settings__transparent_hugepages__dependent_var: + type: 'str' + required: false + default: 'madvise' + description: 'Transparent hugepages setting injected into the kernel_settings role.' + + redis__service_enabled: + type: 'bool' + required: false + default: true + description: 'Enables or disables the Redis service.' + + redis__service_limit_nofile: + type: 'int' + required: false + default: 10240 + description: 'systemd: resource limit for the number of file descriptors.' + + redis__service_state: + type: 'str' + required: false + choices: + - 'reloaded' + - 'restarted' + - 'started' + - 'stopped' + description: 'Changes the state of the Redis service.' + + redis__service_timeout_start_sec: + type: 'str' + required: false + default: '90s' + description: 'systemd: time to wait for start-up.' + + redis__service_timeout_stop_sec: + type: 'str' + required: false + default: '90s' + description: 'systemd: time to wait for the service to stop.' From 499c89885de4301b00740dcfb3e1723eb299b833 Mon Sep 17 00:00:00 2001 From: Markus Frei Date: Tue, 22 Sep 2026 16:47:58 +0200 Subject: [PATCH 16/38] fix(playbooks/setup_nextcloud): configure SELinux before the services Installing the policy build dependencies of the selinux role can update the SELinux policy, and a daemon started under the older policy keeps its domain until it is restarted. On a Rocky 10.0 host (selinux-policy 40.13.26), the first start of Valkey labeled /usr/bin/valkey-server bin_t and ran it in unconfined_service_t; the later update to 42.1.18 relabeled the binary to redis_exec_t, but the process stayed unconfined, and httpd_t may not connect to its socket there. setup_basic already runs selinux right after policycoreutils. --- playbooks/README.md | 2 +- playbooks/setup_nextcloud.yml | 34 +++++++++++++++++++--------------- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/playbooks/README.md b/playbooks/README.md index 44444545b..2565b8414 100644 --- a/playbooks/README.md +++ b/playbooks/README.md @@ -1312,6 +1312,7 @@ Calls the following roles (in order): * [repo_baseos](https://github.com/Linuxfabrik/lfops/tree/main/roles/repo_baseos): `setup_nextcloud__skip_repo_baseos` * [repo_epel](https://github.com/Linuxfabrik/lfops/tree/main/roles/repo_epel): `setup_nextcloud__skip_repo_epel` * [policycoreutils](https://github.com/Linuxfabrik/lfops/tree/main/roles/policycoreutils): `setup_nextcloud__skip_policycoreutils` +* [selinux](https://github.com/Linuxfabrik/lfops/tree/main/roles/selinux): `setup_nextcloud__skip_selinux` * [python](https://github.com/Linuxfabrik/lfops/tree/main/roles/python): `setup_nextcloud__skip_python` * [kernel_settings](https://github.com/Linuxfabrik/lfops/tree/main/roles/kernel_settings): `setup_nextcloud__skip_kernel_settings` * [apache_httpd](https://github.com/Linuxfabrik/lfops/tree/main/roles/apache_httpd): `setup_nextcloud__skip_apache_httpd` @@ -1322,7 +1323,6 @@ Calls the following roles (in order): * [mariadb_server](https://github.com/Linuxfabrik/lfops/tree/main/roles/mariadb_server): `setup_nextcloud__skip_mariadb_server` * [redis](https://github.com/Linuxfabrik/lfops/tree/main/roles/redis): `setup_nextcloud__skip_redis` * [valkey](https://github.com/Linuxfabrik/lfops/tree/main/roles/valkey): `setup_nextcloud__skip_valkey` -* [selinux](https://github.com/Linuxfabrik/lfops/tree/main/roles/selinux): `setup_nextcloud__skip_selinux` * [systemd_unit](https://github.com/Linuxfabrik/lfops/tree/main/roles/systemd_unit): `nextcloud__skip_systemd_unit` * [nextcloud](https://github.com/Linuxfabrik/lfops/tree/main/roles/nextcloud) * [fail2ban](https://github.com/Linuxfabrik/lfops/tree/main/roles/fail2ban): `setup_nextcloud__skip_fail2ban` (default: `true`) diff --git a/playbooks/setup_nextcloud.yml b/playbooks/setup_nextcloud.yml index 433c8d9a8..13178be1f 100644 --- a/playbooks/setup_nextcloud.yml +++ b/playbooks/setup_nextcloud.yml @@ -53,6 +53,25 @@ - 'ansible_facts["os_family"] == "RedHat"' - 'not setup_nextcloud__skip_policycoreutils | default(false)' + # Before the services: installing the policy build dependencies can update the SELinux policy, + # and a daemon started under an older policy keeps its domain until it is restarted. On a Rocky + # 10.0 host the first start of Valkey ended up in unconfined_service_t, and httpd_t may not + # connect to its socket there. + - role: 'linuxfabrik.lfops.selinux' + selinux__booleans__dependent_var: '{{ + nextcloud__selinux__booleans__dependent_var + + (not setup_nextcloud__skip_fail2ban | d(true)) | ternary(fail2ban__selinux__booleans__dependent_var, []) + }}' + selinux__fcontexts__dependent_var: '{{ + nextcloud__selinux__fcontexts__dependent_var + }}' + selinux__modules__dependent_var: '{{ + php__selinux__modules__dependent_var + }}' + when: + - 'ansible_facts["os_family"] == "RedHat"' + - 'not setup_nextcloud__skip_selinux | default(false)' + - role: 'linuxfabrik.lfops.python' python__modules__dependent_var: '{{ apache_httpd__python__modules__dependent_var + @@ -137,21 +156,6 @@ # === Nextcloud Application - - role: 'linuxfabrik.lfops.selinux' - selinux__booleans__dependent_var: '{{ - nextcloud__selinux__booleans__dependent_var + - (not setup_nextcloud__skip_fail2ban | d(true)) | ternary(fail2ban__selinux__booleans__dependent_var, []) - }}' - selinux__fcontexts__dependent_var: '{{ - nextcloud__selinux__fcontexts__dependent_var - }}' - selinux__modules__dependent_var: '{{ - php__selinux__modules__dependent_var - }}' - when: - - 'ansible_facts["os_family"] == "RedHat"' - - 'not setup_nextcloud__skip_selinux | default(false)' - - role: 'linuxfabrik.lfops.systemd_unit' systemd_unit__services__dependent_var: '{{ nextcloud__systemd_unit__services__dependent_var From 1c11778404d41093ab5b50d7d4435490c1ea7f9d Mon Sep 17 00:00:00 2001 From: Markus Frei Date: Tue, 22 Sep 2026 16:47:59 +0200 Subject: [PATCH 17/38] feat(roles/nextcloud)!: connect to Redis / Valkey through the Unix socket nextcloud__redis_unixsocket and nextcloud__redis_group default to Valkey on RHEL 10 and Redis elsewhere, as setup_nextcloud installs them. The web server user joins the group; SELinux allows httpd_t to connect to redis_t and to write redis_var_run_t sockets without a boolean (selinux-policy 42.1.18 on Rocky 10). 'redis port' is removed instead of set to 0: RedisFactory picks 6379 for a host name and the socket for a path when the port is missing, so removing the port first and changing the host second keeps every step valid. Setting the host to the socket path first left port 6379 next to it, after which every occ call failed at bootstrap, including the one meant to fix the port (reproduced on Rocky 10 with Nextcloud 35.0.0). --- CHANGELOG.md | 1 + .../molecule/setup_nextcloud/verify.yml | 62 +++++++++++++++++++ roles/nextcloud/README.md | 14 ++++- roles/nextcloud/defaults/main.yml | 16 +++-- roles/nextcloud/tasks/main.yml | 11 ++++ roles/nextcloud/vars/Debian.yml | 3 + roles/nextcloud/vars/RedHat.yml | 3 + roles/nextcloud/vars/RedHat10.yml | 3 + roles/nextcloud/vars/Ubuntu.yml | 3 + 9 files changed, 110 insertions(+), 6 deletions(-) create mode 100644 roles/nextcloud/vars/RedHat10.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index eb21bf4c7..359b215ef 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: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. diff --git a/extensions/molecule/setup_nextcloud/verify.yml b/extensions/molecule/setup_nextcloud/verify.yml index 0d3c26b98..46234755d 100644 --- a/extensions/molecule/setup_nextcloud/verify.yml +++ b/extensions/molecule/setup_nextcloud/verify.yml @@ -75,6 +75,68 @@ - '__molecule__mariadbd_label_result is not skipped' +# Nextcloud keeps its caches and file locks in Redis / Valkey and reaches it through the Unix +# socket (a path in 'redis host'), which only the members of the server's group may use. Nextcloud's own memcache +# check writes a value and reads it back (CLI, as the web server user), and a PROPFIND takes a +# shared lock through Apache and PHP-FPM, so both paths have to get through the socket. +- name: 'Verify Nextcloud reaches Redis / Valkey through the Unix socket' + hosts: 'systems_under_test' + gather_facts: false + become: true + tasks: + + - name: 'php occ config:system:get redis host' + ansible.builtin.command: 'php /var/www/html/nextcloud/occ config:system:get redis host' + args: + chdir: '/var/www/html/nextcloud/' + become: true + become_user: 'apache' + register: '__molecule__nextcloud_redis_host_result' + changed_when: false + + - name: 'Assert Nextcloud connects through the Unix socket' + ansible.builtin.assert: + that: '__molecule__nextcloud_redis_host_result["stdout"] is match("/")' + fail_msg: 'redis host is {{ __molecule__nextcloud_redis_host_result["stdout"] }}, so Nextcloud connects over TCP' + + - name: 'php occ setupchecks --output=json' + ansible.builtin.command: 'php /var/www/html/nextcloud/occ setupchecks --output=json' + args: + chdir: '/var/www/html/nextcloud/' + become: true + become_user: 'apache' + register: '__molecule__nextcloud_setupchecks_result' + changed_when: false + # setupchecks exits non-zero as soon as any check warns, which is not what this play tests + failed_when: '__molecule__nextcloud_setupchecks_result["stdout"] | length == 0' + + - name: 'Assert the memcache check could write and read a value' + ansible.builtin.assert: + that: '__molecule__nextcloud_memcache_check["severity"] == "success"' + fail_msg: '{{ __molecule__nextcloud_memcache_check | to_json }}' + vars: + # selected by the end of its name, the key holds the PHP namespace with its backslashes + __molecule__nextcloud_memcache_check: '{{ + (__molecule__nextcloud_setupchecks_result["stdout"] | from_json)["system"] + | dict2items + | selectattr("key", "search", "MemcacheConfigured$") + | map(attribute="value") + | first + }}' + + - name: 'PROPFIND /remote.php/dav/files/{{ nextcloud__users[0]["username"] }}/ through Apache and PHP-FPM' + ansible.builtin.uri: + url: 'http://127.0.0.1/remote.php/dav/files/{{ nextcloud__users[0]["username"] }}/' + method: 'PROPFIND' + url_username: '{{ nextcloud__users[0]["username"] }}' + url_password: '{{ nextcloud__users[0]["password"] }}' + force_basic_auth: true + headers: + Depth: '1' + Host: '{{ nextcloud__fqdn }}' + status_code: 207 + + # The SMTP TLS overrides that earlier versions of the role set are gone, so Nextcloud verifies the # certificate of the mail server again. `config:system:get` exits 1 for a key that does not exist. - name: 'Verify Nextcloud verifies the SMTP server certificate' diff --git a/roles/nextcloud/README.md b/roles/nextcloud/README.md index 506ba3542..871aaf121 100644 --- a/roles/nextcloud/README.md +++ b/roles/nextcloud/README.md @@ -29,7 +29,7 @@ Any [LFOps playbook](https://github.com/Linuxfabrik/lfops/blob/main/playbooks/RE * A web server (for example Apache httpd) must be installed, with a virtual host for Nextcloud (role: [linuxfabrik.lfops.apache_httpd](https://github.com/Linuxfabrik/lfops/tree/main/roles/apache_httpd)). * MariaDB must be installed, in a version the installed Nextcloud supports (10.11+ for Nextcloud 35) (role: [linuxfabrik.lfops.mariadb_server](https://github.com/Linuxfabrik/lfops/tree/main/roles/mariadb_server)). * PHP must be installed, in a version the installed Nextcloud supports (8.3 to 8.5 for Nextcloud 35) (roles: [linuxfabrik.lfops.repo_remi](https://github.com/Linuxfabrik/lfops/tree/main/roles/repo_remi) and [linuxfabrik.lfops.php](https://github.com/Linuxfabrik/lfops/tree/main/roles/php)). -* Redis 7+ must be installed (roles: [linuxfabrik.lfops.repo_redis](https://github.com/Linuxfabrik/lfops/tree/main/roles/repo_redis) and [linuxfabrik.lfops.redis](https://github.com/Linuxfabrik/lfops/tree/main/roles/redis)). +* Valkey must be installed on RHEL 10, Redis on the other platforms, listening on the Unix socket in `nextcloud__redis_unixsocket` (roles: [linuxfabrik.lfops.valkey](https://github.com/Linuxfabrik/lfops/tree/main/roles/valkey), or [linuxfabrik.lfops.repo_remi](https://github.com/Linuxfabrik/lfops/tree/main/roles/repo_remi) and [linuxfabrik.lfops.redis](https://github.com/Linuxfabrik/lfops/tree/main/roles/redis)). * Optional: Collabora (role: [linuxfabrik.lfops.collabora](https://github.com/Linuxfabrik/lfops/tree/main/roles/collabora)) provides online document editing. * Optional: fail2ban bans IPs with too many failed logins (role: [linuxfabrik.lfops.fail2ban](https://github.com/Linuxfabrik/lfops/tree/main/roles/fail2ban)). * Optional: Coturn (role: [linuxfabrik.lfops.coturn](https://github.com/Linuxfabrik/lfops/tree/main/roles/coturn)) provides the TURN server for Nextcloud Talk. @@ -272,6 +272,18 @@ nextcloud__users: * Type: String. * Default: `'*:50:15'` +`nextcloud__redis_group` + +* Group that may connect to the Unix socket of Redis or Valkey. The role adds the web server user to it. +* Type: String. +* Default: `'valkey'` on RHEL 10, `'redis'` elsewhere + +`nextcloud__redis_unixsocket` + +* Unix socket through which Nextcloud reaches Redis or Valkey for caching and file locking, the one that `setup_nextcloud` installs. Set it to `''` to connect to `127.0.0.1:6379` over TCP instead. +* Type: String. +* Default: `'/run/valkey/valkey.sock'` on RHEL 10, `'/run/redis/redis.sock'` on RHEL 8 and 9, `'/run/redis/redis-server.sock'` on Debian and Ubuntu + `nextcloud__skip_apps` * Completely skips the management of Nextcloud apps. Set this to prevent changes via the WebGUI from being overwritten. diff --git a/roles/nextcloud/defaults/main.yml b/roles/nextcloud/defaults/main.yml index 94eeaa8ed..d1a59dbb8 100644 --- a/roles/nextcloud/defaults/main.yml +++ b/roles/nextcloud/defaults/main.yml @@ -183,6 +183,9 @@ nextcloud__on_calendar_app_update: '06,18,23:{{ 59 | random(seed=inventory_hostn nextcloud__on_calendar_jobs: '*:0/5' # every 5 minutes nextcloud__on_calendar_scan_files: '*:50:15' # every hour at hh:50:15 +nextcloud__redis_group: '{{ __nextcloud__redis_group }}' +nextcloud__redis_unixsocket: '{{ __nextcloud__redis_unixsocket }}' + nextcloud__skip_apps: false nextcloud__skip_notify_push: false @@ -284,12 +287,15 @@ nextcloud__sysconfig__role_var: value: '0' type: 'integer' state: 'present' - - key: 'redis host' - value: '127.0.0.1' - state: 'present' + # Without 'redis port', Nextcloud connects to port 6379 of a host name and to the Unix socket of + # a path (lib/private/RedisFactory.php). Removing the port before changing the host keeps every + # step valid: a socket path in 'host' next to port 6379 makes Nextcloud, and with it every occ + # call including the one that would fix the port, fail at bootstrap. - key: 'redis port' - value: '6379' - type: 'integer' + value: '' + state: 'absent' + - key: 'redis host' + value: '{{ (nextcloud__redis_unixsocket | length > 0) | ternary(nextcloud__redis_unixsocket, "127.0.0.1") }}' state: 'present' - key: 'redis timeout' value: '0.5' diff --git a/roles/nextcloud/tasks/main.yml b/roles/nextcloud/tasks/main.yml index c7c308c07..7fb898897 100644 --- a/roles/nextcloud/tasks/main.yml +++ b/roles/nextcloud/tasks/main.yml @@ -184,6 +184,17 @@ - block: + # The socket is open to the members of the Redis / Valkey group only. The PHP-FPM restart below + # gives the workers the new group. + - name: 'usermod --append --groups {{ nextcloud__redis_group }} {{ __shared__apache_httpd_user }}' + ansible.builtin.user: + name: '{{ __shared__apache_httpd_user }}' + groups: '{{ nextcloud__redis_group }}' + append: true + register: '__nextcloud__redis_group_result' + when: + - 'nextcloud__redis_unixsocket | length > 0' + - name: 'Get Nextcloud config list' ansible.builtin.command: 'php /var/www/html/nextcloud/occ --no-interaction --output=json config:list --private' become: true diff --git a/roles/nextcloud/vars/Debian.yml b/roles/nextcloud/vars/Debian.yml index 49d2a9df2..8a1b6b096 100644 --- a/roles/nextcloud/vars/Debian.yml +++ b/roles/nextcloud/vars/Debian.yml @@ -3,3 +3,6 @@ __nextcloud__required_packages: - 'jq' - 'ldap-utils' - 'smbclient' + +__nextcloud__redis_group: 'redis' +__nextcloud__redis_unixsocket: '/run/redis/redis-server.sock' diff --git a/roles/nextcloud/vars/RedHat.yml b/roles/nextcloud/vars/RedHat.yml index cef93f116..066067ba6 100644 --- a/roles/nextcloud/vars/RedHat.yml +++ b/roles/nextcloud/vars/RedHat.yml @@ -3,3 +3,6 @@ __nextcloud__required_packages: - 'jq' - 'openldap-clients' - 'samba-client' + +__nextcloud__redis_group: 'redis' +__nextcloud__redis_unixsocket: '/run/redis/redis.sock' diff --git a/roles/nextcloud/vars/RedHat10.yml b/roles/nextcloud/vars/RedHat10.yml new file mode 100644 index 000000000..d8aa148cf --- /dev/null +++ b/roles/nextcloud/vars/RedHat10.yml @@ -0,0 +1,3 @@ +# RHEL 10 ships no Redis, so setup_nextcloud installs Valkey there. +__nextcloud__redis_group: 'valkey' +__nextcloud__redis_unixsocket: '/run/valkey/valkey.sock' diff --git a/roles/nextcloud/vars/Ubuntu.yml b/roles/nextcloud/vars/Ubuntu.yml index 49d2a9df2..8a1b6b096 100644 --- a/roles/nextcloud/vars/Ubuntu.yml +++ b/roles/nextcloud/vars/Ubuntu.yml @@ -3,3 +3,6 @@ __nextcloud__required_packages: - 'jq' - 'ldap-utils' - 'smbclient' + +__nextcloud__redis_group: 'redis' +__nextcloud__redis_unixsocket: '/run/redis/redis-server.sock' From e2b8728a6fd6ef3404898facb54fdef99458eb12 Mon Sep 17 00:00:00 2001 From: Markus Frei Date: Tue, 22 Sep 2026 16:48:00 +0200 Subject: [PATCH 18/38] fix(roles/nextcloud): restart PHP-FPM and switch to cron only on a change Both PHP-FPM restarts ran on every run, and occ background:cron had no changed_when. restorecon now runs without --force, which reset the SELinux user of the files the installer and Nextcloud created since the last run; the targeted policy ignores the SELinux user. Apps installed during the run get a restorecon of their own, since the notify_push binary needs bin_t and the notify_push block that relabels it is skipped with nextcloud__skip_notify_push. --- CHANGELOG.md | 1 + roles/nextcloud/tasks/main.yml | 44 +++++++++++++++++++++++++++++----- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 359b215ef..7ac0269ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +* **role:nextcloud**: A run against an unchanged host no longer restarts PHP-FPM and reports no changes. * **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. diff --git a/roles/nextcloud/tasks/main.yml b/roles/nextcloud/tasks/main.yml index 7fb898897..8257f0530 100644 --- a/roles/nextcloud/tasks/main.yml +++ b/roles/nextcloud/tasks/main.yml @@ -138,8 +138,10 @@ path: '/var/www/html/nextcloud/occ' mode: 0o755 - - name: 'restorecon -Fvr {{ nextcloud__datadir }} /var/log/nextcloud /var/www/html/nextcloud' - ansible.builtin.command: 'restorecon -Fvr {{ nextcloud__datadir | quote }} /var/log/nextcloud /var/www/html/nextcloud' + # Without --force: that would also reset the SELinux user of every file the installer and + # Nextcloud create later, which the targeted policy ignores, and report a change on every run. + - name: 'restorecon -vr {{ nextcloud__datadir }} /var/log/nextcloud /var/www/html/nextcloud' + ansible.builtin.command: 'restorecon -vr {{ nextcloud__datadir | quote }} /var/log/nextcloud /var/www/html/nextcloud' register: 'nextcloud__restorecon_nextcloud_result' changed_when: 'nextcloud__restorecon_nextcloud_result["stdout"] | length > 0' when: @@ -213,6 +215,7 @@ become: true become_user: '{{ __shared__apache_httpd_user }}' loop: '{{ nextcloud__sysconfig__combined_var }}' + register: '__nextcloud__sysconfig_result' # do this straight after the installation to get NC up and running # otherwise subsequent occ commands might fail @@ -220,6 +223,8 @@ ansible.builtin.service: name: '{{ __nextcloud__php_fpm_service_name }}' state: 'restarted' + when: + - '__nextcloud__redis_group_result is changed or __nextcloud__sysconfig_result is changed' tags: - 'nextcloud' @@ -245,6 +250,17 @@ become: true become_user: '{{ __shared__apache_httpd_user }}' loop: '{{ nextcloud__apps__combined_var }}' + register: '__nextcloud__apps_result' + + # Apps installed just now miss the file contexts that differ from their directory, such as bin_t + # on the notify_push binary. + - name: 'restorecon -vr /var/www/html/nextcloud/apps' + ansible.builtin.command: 'restorecon -vr /var/www/html/nextcloud/apps' + register: '__nextcloud__restorecon_apps_result' + changed_when: '__nextcloud__restorecon_apps_result["stdout"] | length > 0' + when: + - 'ansible_facts["selinux"]["status"] != "disabled"' + - '__nextcloud__apps_result is changed' - name: 'Get Nextcloud config list' ansible.builtin.command: 'php /var/www/html/nextcloud/occ --no-interaction --output=json config:list --private' @@ -265,11 +281,14 @@ become: true become_user: '{{ __shared__apache_httpd_user }}' loop: '{{ nextcloud__app_configs__combined_var }}' + register: '__nextcloud__app_configs_result' - name: 'restart {{ __nextcloud__php_fpm_service_name }}' ansible.builtin.service: name: '{{ __nextcloud__php_fpm_service_name }}' state: 'restarted' + when: + - '__nextcloud__apps_result is changed or __nextcloud__app_configs_result is changed' when: - 'not nextcloud__skip_apps' @@ -356,14 +375,27 @@ mode: 0o644 notify: 'nextcloud: systemctl daemon-reload' - - name: 'Set background job to "cron"' - ansible.builtin.command: | - php occ background:cron + - name: 'php occ config:app:get core backgroundjobs_mode' + ansible.builtin.command: 'php occ config:app:get core backgroundjobs_mode' args: chdir: '/var/www/html/nextcloud/' become: true become_user: '{{ __shared__apache_httpd_user }}' - # changed_when: there is no easy way to check for changes + register: '__nextcloud__backgroundjobs_mode_result' + changed_when: false + # exits 1 while the key is unset, which is the "ajax" default + failed_when: '__nextcloud__backgroundjobs_mode_result["rc"] not in [0, 1]' + check_mode: false + + - name: 'php occ background:cron' + ansible.builtin.command: 'php occ background:cron' + args: + chdir: '/var/www/html/nextcloud/' + become: true + become_user: '{{ __shared__apache_httpd_user }}' + changed_when: true # only runs while the mode is not cron yet + when: + - '__nextcloud__backgroundjobs_mode_result["stdout"] != "cron"' - name: 'Deploy /usr/local/bin/nextcloud-app-update' ansible.builtin.template: From 59ce043b2cf51ce8757d1a8af82edd8beefba981 Mon Sep 17 00:00:00 2001 From: Markus Frei Date: Tue, 22 Sep 2026 16:48:01 +0200 Subject: [PATCH 19/38] fix(roles/nextcloud): retry the installer after a failed attempt Nextcloud writes config.php at the start of maintenance:install, so a failed attempt made `creates: config.php` skip the installer on every later run. The role asks `occ status` instead: it reports installed=false for an empty config.php (the notice goes to stderr), and the installer may overwrite the config as long as config/CAN_INSTALL from the tarball exists, which only a successful installation removes (lib/private/Config.php, verified with Nextcloud 35.0.0). --- CHANGELOG.md | 1 + roles/nextcloud/tasks/main.yml | 19 ++++++++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ac0269ac..61de626a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +* **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 changes. * **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`. diff --git a/roles/nextcloud/tasks/main.yml b/roles/nextcloud/tasks/main.yml index 8257f0530..a5871ae1c 100644 --- a/roles/nextcloud/tasks/main.yml +++ b/roles/nextcloud/tasks/main.yml @@ -147,6 +147,20 @@ when: - 'ansible_facts["selinux"]["status"] != "disabled"' + # Asked instead of checking for config.php, which Nextcloud writes at the start of the + # installation, so a failed attempt would otherwise make every later run skip the installer. + - name: 'php occ status --output=json' + ansible.builtin.command: 'php occ status --output=json' + args: + chdir: '/var/www/html/nextcloud/' + become: true + become_user: '{{ __shared__apache_httpd_user }}' + register: '__nextcloud__status_result' + changed_when: false + check_mode: false + when: + - '__nextcloud__version_php_result["stat"]["exists"]' + # The passwords reach occ on stdin, not on its command line, where every local user could # read them in the process list. Without --database-pass and --admin-pass, occ asks for # the database password first and the admin password second, and without a TTY it reads @@ -162,12 +176,15 @@ --database-user '{{ nextcloud__mariadb_login["username"] }}' args: chdir: '/var/www/html/nextcloud/' - creates: '/var/www/html/nextcloud/config/config.php' stdin: |- {{ nextcloud__mariadb_login["password"] }} {{ nextcloud__users[0]["password"] }} become: true become_user: '{{ __shared__apache_httpd_user }}' + changed_when: true # only runs while Nextcloud is not installed + when: + - '__nextcloud__status_result is not skipped' + - 'not (__nextcloud__status_result["stdout"] | from_json)["installed"]' - name: 'Convert some database columns to big int' ansible.builtin.command: | From 06b4b30a27f5e8359a1757e9ea6ffbd886584216 Mon Sep 17 00:00:00 2001 From: Markus Frei Date: Tue, 22 Sep 2026 16:48:02 +0200 Subject: [PATCH 20/38] fix(roles/valkey): keep the ownership of valkey.conf that the package sets The RHEL package ships `Z /etc/valkey ~0750 valkey root` in tmpfiles.d, which systemd-tmpfiles applies at every boot and on every RPM tmpfiles trigger, so the root:valkey the role set flipped back to valkey:root and the next run reported a change (verified on Rocky 10). Debian and Ubuntu keep root:valkey. With this, a second run of setup_nextcloud on a fresh Rocky 10 reports changed=0. --- roles/valkey/tasks/main.yml | 4 ++-- roles/valkey/vars/Debian.yml | 2 ++ roles/valkey/vars/RedHat.yml | 4 ++++ roles/valkey/vars/Ubuntu.yml | 2 ++ 4 files changed, 10 insertions(+), 2 deletions(-) diff --git a/roles/valkey/tasks/main.yml b/roles/valkey/tasks/main.yml index ef3fbe067..2ab523a51 100644 --- a/roles/valkey/tasks/main.yml +++ b/roles/valkey/tasks/main.yml @@ -32,8 +32,8 @@ backup: true src: 'etc/valkey/{{ valkey__installed_version }}-valkey.conf.j2' dest: '{{ __valkey__config_dir }}/{{ __valkey__config_file }}' - owner: 'root' - group: '{{ __valkey__group }}' + owner: '{{ __valkey__config_file_owner }}' + group: '{{ __valkey__config_file_group }}' mode: 0o640 notify: 'valkey: restart valkey' diff --git a/roles/valkey/vars/Debian.yml b/roles/valkey/vars/Debian.yml index 621bac59d..027384060 100644 --- a/roles/valkey/vars/Debian.yml +++ b/roles/valkey/vars/Debian.yml @@ -2,6 +2,8 @@ __valkey__conf_logfile: '/var/log/valkey/valkey-server.log' __valkey__conf_unixsocket: '/run/valkey/valkey-server.sock' __valkey__config_dir: '/etc/valkey' __valkey__config_file: 'valkey.conf' +__valkey__config_file_group: 'valkey' +__valkey__config_file_owner: 'root' __valkey__data_dir: '/var/lib/valkey' __valkey__group: 'valkey' __valkey__package: 'valkey-server' diff --git a/roles/valkey/vars/RedHat.yml b/roles/valkey/vars/RedHat.yml index dc3db9721..bdf6a0e68 100644 --- a/roles/valkey/vars/RedHat.yml +++ b/roles/valkey/vars/RedHat.yml @@ -2,6 +2,10 @@ __valkey__conf_logfile: '/var/log/valkey/valkey.log' __valkey__conf_unixsocket: '/run/valkey/valkey.sock' __valkey__config_dir: '/etc/valkey' __valkey__config_file: 'valkey.conf' +# The package's tmpfiles.d rule (Z /etc/valkey ~0750 valkey root) resets the ownership to this on +# every boot and every systemd-tmpfiles run, so the role keeps it rather than fighting it. +__valkey__config_file_group: 'root' +__valkey__config_file_owner: 'valkey' __valkey__data_dir: '/var/lib/valkey' __valkey__group: 'valkey' __valkey__package: 'valkey' diff --git a/roles/valkey/vars/Ubuntu.yml b/roles/valkey/vars/Ubuntu.yml index 621bac59d..027384060 100644 --- a/roles/valkey/vars/Ubuntu.yml +++ b/roles/valkey/vars/Ubuntu.yml @@ -2,6 +2,8 @@ __valkey__conf_logfile: '/var/log/valkey/valkey-server.log' __valkey__conf_unixsocket: '/run/valkey/valkey-server.sock' __valkey__config_dir: '/etc/valkey' __valkey__config_file: 'valkey.conf' +__valkey__config_file_group: 'valkey' +__valkey__config_file_owner: 'root' __valkey__data_dir: '/var/lib/valkey' __valkey__group: 'valkey' __valkey__package: 'valkey-server' From 2e37d370f775ce32f4ecd552e18592bc99ebf18c Mon Sep 17 00:00:00 2001 From: Markus Frei Date: Tue, 22 Sep 2026 16:48:02 +0200 Subject: [PATCH 21/38] fix(roles/redis): keep the ownership of redis.conf that the package sets Same as for Valkey: the Remi package ships `Z /etc/redis ~0750 redis root` in tmpfiles.d (redis 8.8 for EL9). --- CHANGELOG.md | 1 + roles/redis/tasks/main.yml | 4 ++-- roles/redis/vars/Debian.yml | 2 ++ roles/redis/vars/RedHat.yml | 4 ++++ 4 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61de626a6..514f3100f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +* **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 changes. * **role:nextcloud**: `nextcloud-update` adds missing primary keys and runs the pending mimetype migrations after an update, which Nextcloud leaves to the administrator. diff --git a/roles/redis/tasks/main.yml b/roles/redis/tasks/main.yml index 70ad5bbef..d8765cf83 100644 --- a/roles/redis/tasks/main.yml +++ b/roles/redis/tasks/main.yml @@ -33,8 +33,8 @@ backup: true src: 'etc/redis/{{ redis__installed_version }}-redis.conf.j2' dest: '{{ __redis__config_dir }}/{{ __redis__config_file }}' - owner: 'root' - group: 'redis' + owner: '{{ __redis__config_file_owner }}' + group: '{{ __redis__config_file_group }}' mode: 0o640 notify: 'redis: restart redis' diff --git a/roles/redis/vars/Debian.yml b/roles/redis/vars/Debian.yml index a18c9f805..9fb811b40 100644 --- a/roles/redis/vars/Debian.yml +++ b/roles/redis/vars/Debian.yml @@ -2,6 +2,8 @@ __redis__conf_logfile: '/var/log/redis/redis-server.log' __redis__conf_unixsocket: '/run/redis/redis-server.sock' __redis__config_dir: '/etc/redis' __redis__config_file: 'redis.conf' +__redis__config_file_group: 'redis' +__redis__config_file_owner: 'root' __redis__data_dir: '/var/lib/redis' __redis__module_dir: '/etc/redis/modules' __redis__package: 'redis-server' diff --git a/roles/redis/vars/RedHat.yml b/roles/redis/vars/RedHat.yml index b237f9c7a..92288fa38 100644 --- a/roles/redis/vars/RedHat.yml +++ b/roles/redis/vars/RedHat.yml @@ -2,6 +2,10 @@ __redis__conf_logfile: '/var/log/redis/redis.log' __redis__conf_unixsocket: '/run/redis/redis.sock' __redis__config_dir: '/etc/redis' __redis__config_file: 'redis.conf' +# The package's tmpfiles.d rule (Z /etc/redis ~0750 redis root) resets the ownership to this on +# every boot and every systemd-tmpfiles run, so the role keeps it rather than fighting it. +__redis__config_file_group: 'root' +__redis__config_file_owner: 'redis' __redis__data_dir: '/var/lib/redis' __redis__module_dir: '/etc/redis/modules' __redis__package: 'redis' From 0dd8f494eb18b40413ec9d6d33ff3ad7d3011580 Mon Sep 17 00:00:00 2001 From: Markus Frei Date: Tue, 22 Sep 2026 16:48:03 +0200 Subject: [PATCH 22/38] fix(roles/nextcloud): keep the Icinga API password out of the process list nextcloud-update passed the credentials to curl with --user. They now reach curl as a header file read from a pipe that the printf builtin fills (--header @file since curl 7.55, verified with curl 7.61.1 on Rocky 8). --- CHANGELOG.md | 1 + .../templates/usr/local/bin/nextcloud-update.j2 | 9 ++++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 514f3100f..49519be9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -109,6 +109,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +* **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. diff --git a/roles/nextcloud/templates/usr/local/bin/nextcloud-update.j2 b/roles/nextcloud/templates/usr/local/bin/nextcloud-update.j2 index adf6e60a7..a49ed6d50 100644 --- a/roles/nextcloud/templates/usr/local/bin/nextcloud-update.j2 +++ b/roles/nextcloud/templates/usr/local/bin/nextcloud-update.j2 @@ -1,6 +1,6 @@ #!/usr/bin/env bash # {{ ansible_managed }} -# 2026092201 +# 2026092202 set -euo pipefail @@ -29,6 +29,9 @@ END_TIME=$(( START_TIME + 1800 )) mkdir -p "${TMP_DIR}" touch "${STATE_FILE}" +# The Icinga API credentials reach curl as a header read from a pipe that the printf builtin +# fills, so they never show up in the process list the way `curl --user` would put them there. + {% if nextcloud__icinga2_api_user_login is defined and nextcloud__icinga2_api_user_login | length %} echo echo 'set icinga2 downtime' @@ -38,7 +41,7 @@ if ! grep -q "set_icinga2_downtime_done" "${STATE_FILE}"; then --connect-timeout 5 \ --insecure \ --silent \ - --user "{{ nextcloud__icinga2_api_user_login["username"] }}:{{ nextcloud__icinga2_api_user_login["password"] }}" \ + --header @<(printf 'Authorization: Basic %s\n' '{{ (nextcloud__icinga2_api_user_login["username"] ~ ":" ~ nextcloud__icinga2_api_user_login["password"]) | b64encode }}') \ --header 'Accept: application/json' \ --request POST '{{ nextcloud__icinga2_api_url }}/v1/actions/schedule-downtime' \ --data-binary @- 1> /dev/null << EOF @@ -269,7 +272,7 @@ if ! grep -q "remove_icinga2_downtime_done" "${STATE_FILE}"; then --connect-timeout 5 \ --insecure \ --silent \ - --user '{{ nextcloud__icinga2_api_user_login["username"] }}:{{ nextcloud__icinga2_api_user_login["password"] }}' \ + --header @<(printf 'Authorization: Basic %s\n' '{{ (nextcloud__icinga2_api_user_login["username"] ~ ":" ~ nextcloud__icinga2_api_user_login["password"]) | b64encode }}') \ --header 'Accept: application/json' \ --request POST '{{ nextcloud__icinga2_api_url }}/v1/actions/remove-downtime' \ --data-binary @- 1> /dev/null << EOF From 07e93d72353fe390176a79ced0d1410d32b75764 Mon Sep 17 00:00:00 2001 From: Markus Frei Date: Tue, 22 Sep 2026 18:09:40 +0200 Subject: [PATCH 23/38] feat(roles/nextcloud)!: make nextcloud__version mandatory CONTRIBUTING: software versions must always be mandatory variables. The variable only picks the tarball of a new installation; updates of an installed Nextcloud run through nextcloud-update. The Molecule scenario pins 'latest-35'. --- CHANGELOG.md | 1 + .../inventory/group_vars/systems_under_test.yml | 1 + roles/nextcloud/README.md | 13 ++++++------- roles/nextcloud/defaults/main.yml | 2 -- roles/nextcloud/meta/argument_specs.yml | 3 +-- 5 files changed, 9 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49519be9b..0a72fa586 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: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. diff --git a/extensions/molecule/setup_nextcloud/inventory/group_vars/systems_under_test.yml b/extensions/molecule/setup_nextcloud/inventory/group_vars/systems_under_test.yml index 976c76ff4..ac6b58f22 100644 --- a/extensions/molecule/setup_nextcloud/inventory/group_vars/systems_under_test.yml +++ b/extensions/molecule/setup_nextcloud/inventory/group_vars/systems_under_test.yml @@ -31,6 +31,7 @@ nextcloud__users: - username: 'nextcloud-admin' password: 'linuxfabrik' group: 'admin' +nextcloud__version: 'latest-35' repo_mariadb__version: '11.4' diff --git a/roles/nextcloud/README.md b/roles/nextcloud/README.md index 871aaf121..b26e60d37 100644 --- a/roles/nextcloud/README.md +++ b/roles/nextcloud/README.md @@ -120,6 +120,11 @@ Manual steps: * Type: List of strings. * Default: `[]` +`nextcloud__version` + +* Which version to download for a new installation. One of `'latest-XX'`, such as `'latest-35'`, or `'nextcloud-XX.X.XX'`. Have a look at https://download.nextcloud.com/server/releases/ for a list of available releases. Updates of an installed Nextcloud run through `/usr/local/bin/nextcloud-update`, not through this variable. +* Type: String. + Example: ```yaml # mandatory @@ -136,6 +141,7 @@ nextcloud__users: - 'files quota "50 MB"' - 'firstrunwizard show 0' - 'settings email info@example.org' +nextcloud__version: 'latest-35' ``` @@ -361,12 +367,6 @@ nextcloud__users: * Type: Bool. * Default: `true` -`nextcloud__version` - -* Which version to install. One of `'latest'`, `'latest-XX'` or `'nextcloud-XX.X.XX'`. Have a look at https://download.nextcloud.com/server/releases/ for a list of available releases. -* Type: String. -* Default: `'latest'` - `nextcloud__vhost_virtualhost_ip` * Used within the `` directive. @@ -472,7 +472,6 @@ nextcloud__timer_app_update_enabled: true nextcloud__timer_jobs_enabled: true nextcloud__timer_ldap_show_remnants_enabled: true nextcloud__timer_scan_files_enabled: true -nextcloud__version: 'latest' nextcloud__vhost_virtualhost_ip: '127.0.0.1' nextcloud__vhost_virtualhost_port: '81' ``` diff --git a/roles/nextcloud/defaults/main.yml b/roles/nextcloud/defaults/main.yml index d1a59dbb8..cb3966dc6 100644 --- a/roles/nextcloud/defaults/main.yml +++ b/roles/nextcloud/defaults/main.yml @@ -330,8 +330,6 @@ nextcloud__timer_jobs_enabled: true nextcloud__timer_ldap_show_remnants_enabled: true nextcloud__timer_scan_files_enabled: true -# 'latest', 'latest-XX' or 'nextcloud-XX.X.XX' -nextcloud__version: 'latest' # ----------------------------------------------------------------------------- diff --git a/roles/nextcloud/meta/argument_specs.yml b/roles/nextcloud/meta/argument_specs.yml index 067725a0b..93be77dce 100644 --- a/roles/nextcloud/meta/argument_specs.yml +++ b/roles/nextcloud/meta/argument_specs.yml @@ -192,8 +192,7 @@ argument_specs: nextcloud__version: type: 'str' - required: false - default: 'latest' + required: true description: "Which version to install. One of 'latest', 'latest-XX' or 'nextcloud-XX.X.XX'." nextcloud__vhost_virtualhost_ip: From e9596a9beffc930b68288db725033297f6aaa20a Mon Sep 17 00:00:00 2001 From: Markus Frei Date: Tue, 22 Sep 2026 18:09:41 +0200 Subject: [PATCH 24/38] feat(roles/nextcloud)!: connect to MariaDB as a dedicated user The role handed the database administrator to occ maintenance:install, which then created 'oc_'@'%' (lib/private/Setup/MySQL.php). setup_nextcloud now creates the database and nextcloud__database_login@localhost through mariadb_server, with the charset, collation and privileges the installer would use, and the installer gets that user. Without the privilege to read mysql.user, createSpecificUser() falls back to the provided credentials, so config.php holds nextcloud__database_login and no 'oc_' user is created (verified with Nextcloud 35.0.0 and MariaDB 11.4 on Rocky 10). nextcloud__mariadb_login is gone; the version check queries MariaDB as the new user as well. --- CHANGELOG.md | 1 + .../group_vars/systems_under_test.yml | 3 ++ .../molecule/setup_nextcloud/verify.yml | 24 +++++++++++++ playbooks/setup_nextcloud.yml | 6 ++++ roles/nextcloud/README.md | 35 ++++++++++++++----- roles/nextcloud/defaults/main.yml | 19 ++++++++-- roles/nextcloud/meta/argument_specs.yml | 11 ++++++ roles/nextcloud/tasks/main.yml | 8 ++--- 8 files changed, 93 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a72fa586..ad16dcb99 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: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. diff --git a/extensions/molecule/setup_nextcloud/inventory/group_vars/systems_under_test.yml b/extensions/molecule/setup_nextcloud/inventory/group_vars/systems_under_test.yml index ac6b58f22..ebb7fa35d 100644 --- a/extensions/molecule/setup_nextcloud/inventory/group_vars/systems_under_test.yml +++ b/extensions/molecule/setup_nextcloud/inventory/group_vars/systems_under_test.yml @@ -16,6 +16,9 @@ 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 diff --git a/extensions/molecule/setup_nextcloud/verify.yml b/extensions/molecule/setup_nextcloud/verify.yml index 46234755d..a1e29fd3c 100644 --- a/extensions/molecule/setup_nextcloud/verify.yml +++ b/extensions/molecule/setup_nextcloud/verify.yml @@ -38,6 +38,30 @@ fail_msg: 'status.php did not report installed. response: {{ __molecule__nextcloud_statusphp_result["content"] | d("") }}' +# Nextcloud connects as nextcloud__database_login, which the playbook creates for localhost only, +# instead of the "oc_" user that the installer creates for any host when it gets the database +# administrator. +- name: 'Verify Nextcloud uses its own database user' + hosts: 'systems_under_test' + gather_facts: false + become: true + tasks: + + - name: 'php occ config:system:get dbuser' + ansible.builtin.command: 'php /var/www/html/nextcloud/occ config:system:get dbuser' + args: + chdir: '/var/www/html/nextcloud/' + become: true + become_user: 'apache' + register: '__molecule__nextcloud_dbuser_result' + changed_when: false + + - name: 'Assert Nextcloud connects as nextcloud__database_login' + ansible.builtin.assert: + that: '__molecule__nextcloud_dbuser_result["stdout"] == nextcloud__database_login["username"]' + fail_msg: 'Nextcloud connects as {{ __molecule__nextcloud_dbuser_result["stdout"] }}' + + # The role installs mysql-selinux so mariadbd gets `mysqld_exec_t` (MDEV-30520). Assert the domain # of the running process, not the label on the file: only that tells `mysqld_t` apart from # `initrc_t` and the `unconfined_service_t` that used to mask a missing label. diff --git a/playbooks/setup_nextcloud.yml b/playbooks/setup_nextcloud.yml index 13178be1f..718e30847 100644 --- a/playbooks/setup_nextcloud.yml +++ b/playbooks/setup_nextcloud.yml @@ -141,6 +141,12 @@ - 'not setup_nextcloud__skip_repo_mariadb | d(false)' - role: 'linuxfabrik.lfops.mariadb_server' + mariadb_server__databases__dependent_var: '{{ + nextcloud__mariadb_server__databases__dependent_var + }}' + mariadb_server__users__dependent_var: '{{ + nextcloud__mariadb_server__users__dependent_var + }}' when: - 'not setup_nextcloud__skip_mariadb_server | d(false)' diff --git a/roles/nextcloud/README.md b/roles/nextcloud/README.md index b26e60d37..99b410492 100644 --- a/roles/nextcloud/README.md +++ b/roles/nextcloud/README.md @@ -27,7 +27,7 @@ Any [LFOps playbook](https://github.com/Linuxfabrik/lfops/blob/main/playbooks/RE * On RHEL-compatible systems, the EPEL repository must be enabled (role: [linuxfabrik.lfops.repo_epel](https://github.com/Linuxfabrik/lfops/tree/main/roles/repo_epel)). * A web server (for example Apache httpd) must be installed, with a virtual host for Nextcloud (role: [linuxfabrik.lfops.apache_httpd](https://github.com/Linuxfabrik/lfops/tree/main/roles/apache_httpd)). -* MariaDB must be installed, in a version the installed Nextcloud supports (10.11+ for Nextcloud 35) (role: [linuxfabrik.lfops.mariadb_server](https://github.com/Linuxfabrik/lfops/tree/main/roles/mariadb_server)). +* MariaDB must be installed, in a version the installed Nextcloud supports (10.11+ for Nextcloud 35), with the Nextcloud database and the user from `nextcloud__database_login` (role: [linuxfabrik.lfops.mariadb_server](https://github.com/Linuxfabrik/lfops/tree/main/roles/mariadb_server)). * PHP must be installed, in a version the installed Nextcloud supports (8.3 to 8.5 for Nextcloud 35) (roles: [linuxfabrik.lfops.repo_remi](https://github.com/Linuxfabrik/lfops/tree/main/roles/repo_remi) and [linuxfabrik.lfops.php](https://github.com/Linuxfabrik/lfops/tree/main/roles/php)). * Valkey must be installed on RHEL 10, Redis on the other platforms, listening on the Unix socket in `nextcloud__redis_unixsocket` (roles: [linuxfabrik.lfops.valkey](https://github.com/Linuxfabrik/lfops/tree/main/roles/valkey), or [linuxfabrik.lfops.repo_remi](https://github.com/Linuxfabrik/lfops/tree/main/roles/repo_remi) and [linuxfabrik.lfops.redis](https://github.com/Linuxfabrik/lfops/tree/main/roles/redis)). * Optional: Collabora (role: [linuxfabrik.lfops.collabora](https://github.com/Linuxfabrik/lfops/tree/main/roles/collabora)) provides online document editing. @@ -87,6 +87,22 @@ Manual steps: ## Mandatory Role Variables +`nextcloud__database_login` + +* The MariaDB user Nextcloud connects as. The playbook `setup_nextcloud` creates it with the privileges the Nextcloud installer would grant, on the Nextcloud database only. Nextcloud takes it over at the installation; an existing installation keeps the database user it was set up with. +* Type: Dictionary. +* Subkeys: + + * `username`: + + * Mandatory. Username. + * Type: String. + + * `password`: + + * Mandatory. Password. + * Type: String. + `nextcloud__fqdn` * The FQDN of the Nextcloud instance. @@ -128,6 +144,9 @@ Manual steps: Example: ```yaml # mandatory +nextcloud__database_login: + username: 'nextcloud' + password: 'linuxfabrik' nextcloud__fqdn: 'cloud.example.com' nextcloud__users: # first user has to be the admin account @@ -206,6 +225,12 @@ nextcloud__version: 'latest-35' * Type: String. * Default: `'localhost'` +`nextcloud__database_login_host` + +* Host from which the MariaDB user in `nextcloud__database_login` may connect. +* Type: String. +* Default: `'localhost'` + `nextcloud__database_name` * Name of the Nextcloud database in MariaDB. @@ -254,12 +279,6 @@ nextcloud__version: 'latest-35' * Type: List. * Default: `'{{ mailto_root__to | d([]) }}'` -`nextcloud__mariadb_login` - -* The user account for the database administrator. The Nextcloud setup will create its own database account. -* Type: Dictionary. -* Default: `'{{ mariadb_server__admin_user }}'` - `nextcloud__on_calendar_app_update` * Time to update the Nextcloud apps. Have a look at [systemd.time(7)](https://www.freedesktop.org/software/systemd/man/systemd.time.html) for the format. @@ -395,6 +414,7 @@ nextcloud__apps__host_var: - name: 'weather' state: 'absent' nextcloud__database_host: 'localhost' +nextcloud__database_login_host: 'localhost' nextcloud__database_name: 'nextcloud' nextcloud__datadir: '/data' nextcloud__icinga2_api_url: 'https://icinga.example.com:5665' @@ -406,7 +426,6 @@ nextcloud__jobs_timeout_start_sec: '10m' nextcloud__mail_from: '{{ mailto_root__from }}' nextcloud__mail_recipients: - 'info@example.com' -nextcloud__mariadb_login: '{{ mariadb_server__admin_user }}' nextcloud__on_calendar_app_update: '06,18,23:{{ 59 | random(seed=inventory_hostname) }}' nextcloud__on_calendar_jobs: '*:0/5' nextcloud__on_calendar_scan_files: '*:50:15' diff --git a/roles/nextcloud/defaults/main.yml b/roles/nextcloud/defaults/main.yml index cb3966dc6..018661f8d 100644 --- a/roles/nextcloud/defaults/main.yml +++ b/roles/nextcloud/defaults/main.yml @@ -164,6 +164,7 @@ nextcloud__apps__role_var: # - name: 'workflowengine' # state: 'disabled' nextcloud__database_host: 'localhost' +nextcloud__database_login_host: 'localhost' nextcloud__database_name: 'nextcloud' nextcloud__datadir: '/data' @@ -177,8 +178,6 @@ nextcloud__jobs_timeout_start_sec: '10m' nextcloud__mail_from: '{{ mailto_root__from }}' nextcloud__mail_recipients: '{{ mailto_root__to | d([]) }}' -nextcloud__mariadb_login: '{{ mariadb_server__admin_user }}' - nextcloud__on_calendar_app_update: '06,18,23:{{ 59 | random(seed=inventory_hostname) }}' nextcloud__on_calendar_jobs: '*:0/5' # every 5 minutes nextcloud__on_calendar_scan_files: '*:50:15' # every hour at hh:50:15 @@ -508,6 +507,22 @@ nextcloud__fail2ban__jails__dependent_var: state: 'present' template: 'nextcloud' +# The database exactly as the Nextcloud installer would create it, and a user with the privileges +# the installer would grant its own 'oc_' user (lib/private/Setup/MySQL.php), but bound to +# nextcloud__database_login_host instead of '%'. +nextcloud__mariadb_server__databases__dependent_var: + - name: '{{ nextcloud__database_name }}' + collation: 'utf8mb4_bin' + encoding: 'utf8mb4' + state: 'present' +nextcloud__mariadb_server__users__dependent_var: + - username: '{{ nextcloud__database_login["username"] }}' + password: '{{ nextcloud__database_login["password"] }}' + host: '{{ nextcloud__database_login_host }}' + priv: + - '{{ nextcloud__database_name | replace("_", "\_") | replace("%", "\%") }}.*:SELECT,INSERT,UPDATE,DELETE,CREATE,DROP,REFERENCES,INDEX,ALTER,CREATE TEMPORARY TABLES,LOCK TABLES,EXECUTE,CREATE VIEW,SHOW VIEW,CREATE ROUTINE,ALTER ROUTINE,EVENT,TRIGGER' + state: 'present' + # Has to stay above nextcloud__php__ini_max_execution_time__dependent_var so PHP's own limit # trips first and reports a fatal error, leaving PHP-FPM as the backstop for a worker stuck # in a system call, which max_execution_time cannot interrupt. The php role defaults to 60s, diff --git a/roles/nextcloud/meta/argument_specs.yml b/roles/nextcloud/meta/argument_specs.yml index 93be77dce..f7f7a6ff1 100644 --- a/roles/nextcloud/meta/argument_specs.yml +++ b/roles/nextcloud/meta/argument_specs.yml @@ -67,6 +67,17 @@ argument_specs: default: 'localhost' description: 'Host where MariaDB is located.' + nextcloud__database_login: + type: 'dict' + required: true + description: 'Login of the MariaDB user Nextcloud connects as, with the subkeys username and password.' + + nextcloud__database_login_host: + type: 'str' + required: false + default: 'localhost' + description: 'Host from which the MariaDB user may connect.' + nextcloud__database_name: type: 'str' required: false diff --git a/roles/nextcloud/tasks/main.yml b/roles/nextcloud/tasks/main.yml index a5871ae1c..dc691223e 100644 --- a/roles/nextcloud/tasks/main.yml +++ b/roles/nextcloud/tasks/main.yml @@ -50,8 +50,8 @@ - name: 'Get the MariaDB version' ansible.mariadb.mariadb_info: - login_user: '{{ nextcloud__mariadb_login["username"] }}' - login_password: '{{ nextcloud__mariadb_login["password"] }}' + login_user: '{{ nextcloud__database_login["username"] }}' + login_password: '{{ nextcloud__database_login["password"] }}' login_host: '{{ nextcloud__database_host }}' login_unix_socket: '{{ (nextcloud__database_host == "localhost") | ternary("/var/lib/mysql/mysql.sock", omit) }}' # https://github.com/PyMySQL/PyMySQL/issues/509#issuecomment-244072354 filter: 'version' @@ -173,11 +173,11 @@ --database 'mysql' --database-host '{{ nextcloud__database_host }}' --database-name '{{ nextcloud__database_name }}' - --database-user '{{ nextcloud__mariadb_login["username"] }}' + --database-user '{{ nextcloud__database_login["username"] }}' args: chdir: '/var/www/html/nextcloud/' stdin: |- - {{ nextcloud__mariadb_login["password"] }} + {{ nextcloud__database_login["password"] }} {{ nextcloud__users[0]["password"] }} become: true become_user: '{{ __shared__apache_httpd_user }}' From 1000f62c2402076560ba60552d4423d1d111fbf2 Mon Sep 17 00:00:00 2001 From: Markus Frei Date: Tue, 22 Sep 2026 18:09:42 +0200 Subject: [PATCH 25/38] docs(roles/nextcloud): describe how to connect Collabora Online The playbook installs Collabora on the Nextcloud host but leaves the reverse proxy and the richdocuments settings to the inventory. The walkthrough follows the setup in production: a hostname of its own for Collabora on the proxy, forwarded to port 9980, and the proxy in wopi_allowlist. --- roles/nextcloud/README.md | 40 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/roles/nextcloud/README.md b/roles/nextcloud/README.md index 99b410492..7c538793b 100644 --- a/roles/nextcloud/README.md +++ b/roles/nextcloud/README.md @@ -47,6 +47,46 @@ Manual steps: * Configure the systemd service for [notify_push](https://github.com/nextcloud/notify_push). +## Connecting Collabora Online + +`setup_nextcloud` installs Collabora Online (role `collabora`) on the Nextcloud host and allows `nextcloud__fqdn` as WOPI host, but leaves the connection to the reverse proxy and to Nextcloud to the inventory. The browser loads the editor from a hostname of its own, such as `office.example.com`, which the reverse proxy terminates like the Nextcloud hostname. Collabora itself serves plain HTTP on port 9980 (`collabora__coolwsd_ssl_termination: true`). + +On the reverse proxy, forward the Collabora hostname to port 9980 of the Nextcloud host, with the WebSockets and without decoding encoded slashes: + +```apache +AllowEncodedSlashes NoDecode +ProxyPreserveHost On +ProxyPass /browser http://nextcloud-host.example.com:9980/browser retry=0 +ProxyPassReverse /browser http://nextcloud-host.example.com:9980/browser +ProxyPass /hosting/discovery http://nextcloud-host.example.com:9980/hosting/discovery retry=0 +ProxyPassReverse /hosting/discovery http://nextcloud-host.example.com:9980/hosting/discovery +ProxyPass /hosting/capabilities http://nextcloud-host.example.com:9980/hosting/capabilities retry=0 +ProxyPassReverse /hosting/capabilities http://nextcloud-host.example.com:9980/hosting/capabilities +ProxyPassMatch "/cool/(.*)/ws$" ws://nextcloud-host.example.com:9980/cool/$1/ws nocanon +ProxyPass /cool/adminws ws://nextcloud-host.example.com:9980/cool/adminws +ProxyPass /cool http://nextcloud-host.example.com:9980/cool +ProxyPassReverse /cool http://nextcloud-host.example.com:9980/cool +``` + +In Nextcloud, enable the Nextcloud Office app and point it at the Collabora hostname. Collabora calls back into Nextcloud through the reverse proxy, so the allow list names the proxy: + +```yaml +nextcloud__apps__host_var: + - name: 'richdocuments' + state: 'enabled' +nextcloud__app_configs__host_var: + - key: 'richdocuments public_wopi_url' + value: 'https://office.example.com' + state: 'present' + - key: 'richdocuments wopi_allowlist' + value: '192.0.2.7' # IP of the reverse proxy + state: 'present' + - key: 'richdocuments wopi_url' + value: 'https://office.example.com' + state: 'present' +``` + + ## Tags `nextcloud` From 6d847dbadf017c6bad270058467a78048179d12c Mon Sep 17 00:00:00 2001 From: Markus Frei Date: Tue, 22 Sep 2026 18:09:59 +0200 Subject: [PATCH 26/38] feat(roles/collabora): support CODE 26.04.4 The template is the coolwsd.xml of coolwsd-26.04.4.1-1 with the adaptations of the 26.04.3 template carried over (experimental features, post_allow and lok_allow entries, WOPI alias groups and the settings the 26.04.3 patch already applied). An element-wise comparison against the shipped file shows exactly these differences. Upstream switched post_allow and lok_allow to CIDR notation; the entries from collabora__coolwsd_post_allow and collabora__coolwsd_lok_allow stay regular expressions, which coolwsd still accepts. --- CHANGELOG.md | 1 + .../etc/coolwsd/26.04.4-coolwsd-code.xml.j2 | 466 ++++++++++++++++++ roles/collabora/vars/main.yml | 1 + 3 files changed, 468 insertions(+) create mode 100644 roles/collabora/templates/etc/coolwsd/26.04.4-coolwsd-code.xml.j2 diff --git a/CHANGELOG.md b/CHANGELOG.md index ad16dcb99..bd54ffc49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ 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, with a jail that bans an IP for 8 hours after 5 failed Nextcloud logins within 10 minutes. The reverse proxies listed in the Nextcloud setting `trusted_proxies` are never banned, in any jail. * **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. diff --git a/roles/collabora/templates/etc/coolwsd/26.04.4-coolwsd-code.xml.j2 b/roles/collabora/templates/etc/coolwsd/26.04.4-coolwsd-code.xml.j2 new file mode 100644 index 000000000..b2012b176 --- /dev/null +++ b/roles/collabora/templates/etc/coolwsd/26.04.4-coolwsd-code.xml.j2 @@ -0,0 +1,466 @@ + + + + + + + + + + + + + false + + + {{ ((collabora__coolwsd_allowed_languages__combined_var | selectattr("state", "defined") | selectattr("state", "ne", "absent") | map(attribute="name")) + (collabora__coolwsd_allowed_languages__combined_var | selectattr("state", "undefined") | map(attribute="name"))) | join(' ') | d('de_DE en_GB en_US es_ES fr_FR it nl pt_BR pt_PT ru') }} + + + + false + + + + true + + + + + false + true + true + + + + + + + + + 30 + + + + + {{ collabora__coolwsd_deepl_enabled | d('false') | string | lower }} + {{ collabora__coolwsd_deepl_api_url | d('') }} + {{ collabora__coolwsd_deepl_auth_key | d('') }} + + + + + true + + + + false + {{ collabora__coolwsd_experimental_features | d('false') | string | lower }} + + + 4 + 10 + true + false + + 4 + 5 + 5 + 120 + false + arabic + 3600 + 30 + 300 + true + true + true + false + 0 + 8000 + 0 + 0 + 100 + 5 + 100 + 500 + 5000 + + 10000 + 60 + 300 + 3072 + 85 + 120 + + + + + {{ collabora__coolwsd_out_of_focus_timeout_secs | default(300) }} + 900 + + 6 + + + + + + true + + warning + trace + Socket,WebSocket,Admin,Pixel + notice + fatal + false + + -INFO-WARN + + + /var/log/coolwsd.log + never + timestamp + true + 10 days + 10 + true + false + + + false + 82589933 + false + + false + false + false + + + true + + + true + true + + + /var/log/coolwsd-ui-cmd.log + 10 + true + false + + + + + + /var/log/coolwsd.trace.json + + + false + + + + + + + + false + + + + + + all + any + + + + + {% for item in collabora__coolwsd_post_allow | d([]) %} + {{ item }} + {% endfor %} + 192.168.0.0/16 + 127.0.0.1/32 + ::1/128 + 172.16.0.0/12 + 10.0.0.0/8 + + + + {% for item in collabora__coolwsd_lok_allow %} + {{ item }} + {% endfor %} + 192.168.0.0/16 + 127.0.0.1/32 + ::1/128 + 172.16.0.0/12 + 10.0.0.0/8 + localhost + + {{ collabora__coolwsd_content_security_policy | join('; ') }} + + 30 + + + false + + + + + {{ collabora__coolwsd_ssl_enable | d('true') | string | lower }} + + {{ collabora__coolwsd_ssl_termination | d('true') | string | lower }} + {{ collabora__coolwsd_ssl_settings_cert_file_path | d('/etc/coolwsd/cert.pem') }} + {{ collabora__coolwsd_ssl_settings_key_file_path | d('/etc/coolwsd/key.pem') }} + {{ collabora__coolwsd_ssl_settings_ca_file_path | d('/etc/coolwsd/ca-chain.cert.pem') }} + {{ collabora__coolwsd_ssl_settings_ssl_verification | string | lower }} + + TLSv1.2 + + 1000 + + + + + + + false + 31536000 + + + + + true + + + true + + 1800 + false + 1 + false + false + + + + + + + + 0.2 + + + + + + + + + default + true + true + true + 100 + + + + + + + + + + + + + + 0 + + 900 + + + + + + + {# + coolwsd parses every alias_groups with Poco::URI and keeps only the host part + of the result. A value without a scheme parses to an empty host, which + HostUtil::addWopiHost() then drops without an error message, so the whole allow list + ends up empty and no WOPI host is trusted any more. The scheme itself is never used + for matching, only the host and the port are, which is why hard-coding https:// here + is safe no matter how the WOPI host is actually reached. Consequently the entries in + collabora__coolwsd_storage_wopi__* carry the bare hostname regex, without a scheme. + Verified against coolwsd 24.04, 25.04 and 26.04. + -#} + {% for item in collabora__coolwsd_storage_wopi__combined_var if item['state'] | d('present') != 'absent' %} + + https://{{ item['name'] }} + + {% endfor %} + + + false + + + 0 + 60 + + false + + + true + + + + + + + + + + true + false + + + + true + true + true + true + + + + + + + + + 250 + 5 + + 3000 + + + + + 1000 + + + + false + false + false + false + false + false + + + + 3600 + 5 + + + + + + + false + + + + + + + log + + + + + 180 + + false + + + + + + + + + + + false + + + 16 + 4 + 3 + 5 + + + + false + + + + + + + + true + + + + + + + + false + + + + false + + + + true + + + diff --git a/roles/collabora/vars/main.yml b/roles/collabora/vars/main.yml index 1ce5476b3..4315d31bd 100644 --- a/roles/collabora/vars/main.yml +++ b/roles/collabora/vars/main.yml @@ -19,6 +19,7 @@ __collabora__supported_versions_code: - '25.04.5' - '25.04.6' - '25.04.10' + - '26.04.4' - '26.04.3' - '26.04.2' - '26.04.1' From ae233193b23987a61b0e1434506a82f42b2228ee Mon Sep 17 00:00:00 2001 From: Markus Frei Date: Tue, 22 Sep 2026 18:48:01 +0200 Subject: [PATCH 27/38] fix(roles/borg_local): send valid JSON for the clamd@scan downtime The filter had a stray quote before the second match() and the body a trailing comma, so the Icinga API answered 400 and borg-backup, which runs curl with --silent and discards the output, never set the downtime (verified against Icinga 2.14.6: 400 before, 200 now). --- CHANGELOG.md | 1 + roles/borg_local/templates/usr/local/bin/borg-backup.j2 | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd54ffc49..3e8077b5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,7 @@ 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 changes. diff --git a/roles/borg_local/templates/usr/local/bin/borg-backup.j2 b/roles/borg_local/templates/usr/local/bin/borg-backup.j2 index f8885cad9..9a9fa946a 100644 --- a/roles/borg_local/templates/usr/local/bin/borg-backup.j2 +++ b/roles/borg_local/templates/usr/local/bin/borg-backup.j2 @@ -1,6 +1,6 @@ #!/usr/bin/env bash # {{ ansible_managed }} -# 2025020301 +# 2026092201 # no `set -e` because we want to finish writing the log file even if a borg command fails function exit_with_first_failed_retc() { @@ -20,11 +20,11 @@ if [[ $? -eq 0 ]]; then curl --connect-timeout 5 --insecure --silent --user '{{ borg_local__icinga2_api_user_login["username"] }}:{{ borg_local__icinga2_api_user_login["password"] }}' --header 'Accept: application/json' --request POST '{{ borg_local__icinga2_api_url }}/v1/actions/schedule-downtime' --data-binary @- 1> /dev/null << EOF { "type": "Service", - "filter": "match(\"{{ borg_local__icinga2_hostname }}\", host.name) && "match(\"*Systemd Unit - clamd@scan*\", service.name)", + "filter": "match(\"{{ borg_local__icinga2_hostname }}\", host.name) && match(\"*Systemd Unit - clamd@scan*\", service.name)", "start_time": "$START_TIME", "end_time": "$END_TIME", "author": "{{ borg_local__icinga2_hostname }}", - "comment": "Disabling clamd@scan during borg backup.", + "comment": "Disabling clamd@scan during borg backup." } EOF {% endif %} From b640f83fed0a0d962032a041828dee4c1e986fcc Mon Sep 17 00:00:00 2001 From: Markus Frei Date: Tue, 22 Sep 2026 18:48:02 +0200 Subject: [PATCH 28/38] fix(roles/borg_local): keep the Icinga API credentials away from local users borg-backup was deployed 0755 and passed the credentials to curl with --user. It is now 0700, since only root runs it (systemd timers), and the credentials reach curl as a header read from a pipe that the printf builtin fills. --- roles/borg_local/tasks/main.yml | 2 +- roles/borg_local/templates/usr/local/bin/borg-backup.j2 | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/roles/borg_local/tasks/main.yml b/roles/borg_local/tasks/main.yml index e817bd56a..77ed3ae70 100644 --- a/roles/borg_local/tasks/main.yml +++ b/roles/borg_local/tasks/main.yml @@ -58,7 +58,7 @@ dest: '/usr/local/bin/borg-backup' owner: 'root' group: 'root' - mode: 0o755 + mode: 0o700 # carries the credentials of the Icinga API user tags: - 'borg_local' diff --git a/roles/borg_local/templates/usr/local/bin/borg-backup.j2 b/roles/borg_local/templates/usr/local/bin/borg-backup.j2 index 9a9fa946a..66a0f974a 100644 --- a/roles/borg_local/templates/usr/local/bin/borg-backup.j2 +++ b/roles/borg_local/templates/usr/local/bin/borg-backup.j2 @@ -17,7 +17,9 @@ if [[ $? -eq 0 ]]; then # needed for Icinga to set clamd@scan service downtime (max. 90 minutes downtime) START_TIME=$(date +%s) END_TIME=$(( START_TIME + 5400 )) - curl --connect-timeout 5 --insecure --silent --user '{{ borg_local__icinga2_api_user_login["username"] }}:{{ borg_local__icinga2_api_user_login["password"] }}' --header 'Accept: application/json' --request POST '{{ borg_local__icinga2_api_url }}/v1/actions/schedule-downtime' --data-binary @- 1> /dev/null << EOF + # The credentials reach curl as a header read from a pipe that the printf builtin fills, so + # they never show up in the process list the way `curl --user` would put them there. + curl --connect-timeout 5 --insecure --silent --header @<(printf 'Authorization: Basic %s\n' '{{ (borg_local__icinga2_api_user_login["username"] ~ ":" ~ borg_local__icinga2_api_user_login["password"]) | b64encode }}') --header 'Accept: application/json' --request POST '{{ borg_local__icinga2_api_url }}/v1/actions/schedule-downtime' --data-binary @- 1> /dev/null << EOF { "type": "Service", "filter": "match(\"{{ borg_local__icinga2_hostname }}\", host.name) && match(\"*Systemd Unit - clamd@scan*\", service.name)", @@ -90,7 +92,7 @@ if [[ $start_clamd -eq 1 ]]; then # remove Icinga downtime DATA="{ \"type\": \"Service\", \"filter\": \"match(\\\"*{{ borg_local__icinga2_hostname }}*\\\", host.name) && match(\\\"*Systemd Unit - clamd@scan*\\\", service.name)\" }" - curl --insecure --silent --user {{ borg_local__icinga2_api_user_login["username"] }}:{{ borg_local__icinga2_api_user_login["password"] }} --header 'Accept: application/json' --request POST '{{ borg_local__icinga2_api_url }}/v1/actions/remove-downtime' --data "$DATA" 1> /dev/null + curl --insecure --silent --header @<(printf 'Authorization: Basic %s\n' '{{ (borg_local__icinga2_api_user_login["username"] ~ ":" ~ borg_local__icinga2_api_user_login["password"]) | b64encode }}') --header 'Accept: application/json' --request POST '{{ borg_local__icinga2_api_url }}/v1/actions/remove-downtime' --data "$DATA" 1> /dev/null {% endif %} fi From abd0b78d937afa0d7425e37d301d24db64ba694f Mon Sep 17 00:00:00 2001 From: Markus Frei Date: Tue, 22 Sep 2026 18:48:03 +0200 Subject: [PATCH 29/38] fix(roles/schedule_reboot): keep credentials and webhook away from local users do-reboot was deployed 0744 and passed the Icinga API credentials with --user and the Rocket.Chat webhook URL, which carries its token, as curl arguments. It is now 0700, since only schedule-reboot.service runs it, the credentials reach curl as a header from a pipe, and the URL through --config <(printf ...), verified with curl 7.61.1 on Rocky 8. The Molecule reboot scenario still sees the same Authorization header. --- roles/schedule_reboot/tasks/main.yml | 2 +- .../templates/usr/local/libexec/do-reboot.j2 | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/roles/schedule_reboot/tasks/main.yml b/roles/schedule_reboot/tasks/main.yml index 1f5b2b60d..8de24e83e 100644 --- a/roles/schedule_reboot/tasks/main.yml +++ b/roles/schedule_reboot/tasks/main.yml @@ -20,7 +20,7 @@ dest: '/usr/local/libexec/do-reboot' owner: 'root' group: 'root' - mode: 0o744 + mode: 0o700 # carries the credentials of the Icinga API user and the Rocket.Chat webhook - name: 'Deploy /usr/local/sbin/schedule-reboot' ansible.builtin.template: diff --git a/roles/schedule_reboot/templates/usr/local/libexec/do-reboot.j2 b/roles/schedule_reboot/templates/usr/local/libexec/do-reboot.j2 index 87c0e70ab..cf295e4a8 100644 --- a/roles/schedule_reboot/templates/usr/local/libexec/do-reboot.j2 +++ b/roles/schedule_reboot/templates/usr/local/libexec/do-reboot.j2 @@ -1,6 +1,6 @@ #!/usr/bin/env bash # {{ ansible_managed }} -# 2026082501 +# 2026092201 # The single reboot actor. Run by schedule-reboot.service (a timer at the reboot # window, ordered After= the update lanes) and by `schedule-reboot --now`. Reboots @@ -26,13 +26,15 @@ send_msg () { printf '\n%s\n' "$body" } | sendmail -oi -t -f "$MAIL_FROM" {% if schedule_reboot__rocketchat_url is defined and schedule_reboot__rocketchat_url | length %} + # The webhook URL carries its token, so it reaches curl through a pipe rather than the command + # line, which every local user can read in the process list. /usr/bin/curl --silent --output /dev/null --data-urlencode \ "text=${subject} ${body} {{ schedule_reboot__rocketchat_msg_suffix }}" \ --data-urlencode "parse_mode=HTML" --data-urlencode "disable_web_page_preview=true" \ - "{{ schedule_reboot__rocketchat_url }}" + --config <(printf 'url = "%s"\n' '{{ schedule_reboot__rocketchat_url }}') {% endif %} } @@ -67,7 +69,9 @@ send_msg "$SUBJECT_PREFIX - Automatic reboot due to ${reasons%, }" "$MSGBODY" # needed for Icinga to set downtime (max. 5 minutes downtime) START_TIME=$(date +%s) END_TIME=$(( START_TIME + 300 )) -curl --connect-timeout 5 --insecure --silent --user '{{ schedule_reboot__icinga2_api_user_login["username"] }}:{{ schedule_reboot__icinga2_api_user_login["password"] }}' --header 'Accept: application/json' --request POST '{{ schedule_reboot__icinga2_api_url }}/v1/actions/schedule-downtime' --data-binary @- 1> /dev/null << EOF +# The credentials reach curl as a header read from a pipe that the printf builtin fills, so they +# never show up in the process list the way `curl --user` would put them there. +curl --connect-timeout 5 --insecure --silent --header @<(printf 'Authorization: Basic %s\n' '{{ (schedule_reboot__icinga2_api_user_login["username"] ~ ":" ~ schedule_reboot__icinga2_api_user_login["password"]) | b64encode }}') --header 'Accept: application/json' --request POST '{{ schedule_reboot__icinga2_api_url }}/v1/actions/schedule-downtime' --data-binary @- 1> /dev/null << EOF { "type": "Host", "filter": "match(\"{{ schedule_reboot__icinga2_hostname }}\", host.name)", From 649fa11bb63bc771bf2a9da6069e98c1cfbcacec Mon Sep 17 00:00:00 2001 From: Markus Frei Date: Tue, 22 Sep 2026 18:48:03 +0200 Subject: [PATCH 30/38] fix(roles/tools): move the Icinga API credentials out of /etc/profile.d schedule-icinga-downtime was a shell function in /etc/profile.d/alias.sh, which every login shell reads, so every local user could read the credentials and set downtimes with them. It is now /usr/local/sbin/schedule-icinga-downtime, readable and executable by root only, which the reboot alias calls through root's PATH. The function also ran `exit 1` on a missing argument, which closed the calling shell. Verified on Rocky 10: the script sets the downtime for the host and all its services on Icinga 2.14.6, and strace shows the password in no execve argument. --- CHANGELOG.md | 1 + roles/tools/README.md | 2 +- roles/tools/tasks/main.yml | 18 +++++++++ .../tools/templates/etc/profile.d/alias.sh.j2 | 31 +-------------- .../local/sbin/schedule-icinga-downtime.j2 | 39 +++++++++++++++++++ 5 files changed, 61 insertions(+), 30 deletions(-) create mode 100644 roles/tools/templates/usr/local/sbin/schedule-icinga-downtime.j2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e8077b5f..66f0c3287 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -113,6 +113,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +* **role:borg_local, role:schedule_reboot, role:tools**: The password of the Icinga API user no longer shows up in the process list or in a file that every local user can read, and neither does the Rocket.Chat webhook of `schedule_reboot`. `schedule-icinga-downtime` is a command in `/usr/local/sbin` for root instead of a shell function in `/etc/profile.d/alias.sh`. * **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. diff --git a/roles/tools/README.md b/roles/tools/README.md index d87b95bdf..9a532def5 100644 --- a/roles/tools/README.md +++ b/roles/tools/README.md @@ -63,7 +63,7 @@ Any [LFOps playbook](https://github.com/Linuxfabrik/lfops/blob/main/playbooks/RE `tools__icinga2_api_user_login` -* The Icinga2 API User to set the downtime for the corresponding host and all its services in the `reboot` alias. Defaults to the downtime API user the `icinga2_master` role creates. When neither is set, the alias sets no downtime. +* The Icinga2 API User to set the downtime for the corresponding host and all its services in the `reboot` alias. The role writes it into `/usr/local/sbin/schedule-icinga-downtime`, which only root may read and run, and which the alias calls. Defaults to the downtime API user the `icinga2_master` role creates. When neither is set, the alias sets no downtime. * Type: Dictionary. * Default: `'{{ icinga2_master__downtime_api_user | d({}) }}'` diff --git a/roles/tools/tasks/main.yml b/roles/tools/tasks/main.yml index cda892ac7..ee0c9a8f7 100644 --- a/roles/tools/tasks/main.yml +++ b/roles/tools/tasks/main.yml @@ -44,5 +44,23 @@ - 'history.sh' - 'prompt.sh' + - name: 'Deploy /usr/local/sbin/schedule-icinga-downtime' + ansible.builtin.template: + backup: true + src: 'usr/local/sbin/schedule-icinga-downtime.j2' + dest: '/usr/local/sbin/schedule-icinga-downtime' + owner: 'root' + group: 'root' + mode: 0o700 + when: + - 'tools__icinga2_api_user_login is defined and tools__icinga2_api_user_login | length > 0' + + - name: 'rm -f /usr/local/sbin/schedule-icinga-downtime' + ansible.builtin.file: + path: '/usr/local/sbin/schedule-icinga-downtime' + state: 'absent' + when: + - 'not (tools__icinga2_api_user_login is defined and tools__icinga2_api_user_login | length > 0)' + tags: - 'tools' diff --git a/roles/tools/templates/etc/profile.d/alias.sh.j2 b/roles/tools/templates/etc/profile.d/alias.sh.j2 index b27685926..f92fc63b6 100644 --- a/roles/tools/templates/etc/profile.d/alias.sh.j2 +++ b/roles/tools/templates/etc/profile.d/alias.sh.j2 @@ -1,5 +1,5 @@ # {{ ansible_managed }} -# 2023062801 +# 2026092201 alias whatismyip="curl ipecho.net/plain" alias dmesg='dmesg --level=emerg,alert,crit,err --reltime' @@ -18,34 +18,7 @@ function lf-confirm() { } {% if tools__icinga2_api_user_login is defined and tools__icinga2_api_user_login | length %} -function schedule-icinga-downtime() { - if [ -z "$1" ]; then - echo 'arg 1 required! (downtime duration in s).' - exit 1 - fi - - if [ -z "$2" ]; then - comment="Downtime set per schedule-icinga-downtime." - else - comment="$2" - fi - - START_TIME=$(date +%s) - END_TIME=$(( START_TIME + $1 )) - curl --connect-timeout 5 --insecure --silent --user '{{ tools__icinga2_api_user_login["username"] }}:{{ tools__icinga2_api_user_login["password"] }}' --header 'Accept: application/json' --request POST '{{ tools__icinga2_api_url }}/v1/actions/schedule-downtime' --data-binary @- 1> /dev/null << EOF - { - "type": "Host", - "filter": "match(\"{{ tools__icinga2_hostname }}\", host.name)", - "start_time": "$START_TIME", - "end_time": "$END_TIME", - "author": "{{ tools__icinga2_hostname }}", - "comment": "$comment", - "all_services": true - } -EOF - -} - +# /usr/local/sbin/schedule-icinga-downtime is readable and executable by root only. alias reboot="lf-confirm && schedule-icinga-downtime 300 'Manual reboot' && /usr/sbin/reboot" {% else %} alias reboot="lf-confirm && /usr/sbin/reboot" diff --git a/roles/tools/templates/usr/local/sbin/schedule-icinga-downtime.j2 b/roles/tools/templates/usr/local/sbin/schedule-icinga-downtime.j2 new file mode 100644 index 000000000..129517a9d --- /dev/null +++ b/roles/tools/templates/usr/local/sbin/schedule-icinga-downtime.j2 @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# {{ ansible_managed }} +# 2026092201 + +# Sets an Icinga downtime for this host and all its services. Readable by root only, since it +# carries the credentials of the Icinga API user; /etc/profile.d/alias.sh, which every login +# shell reads, only calls it. + +set -o nounset + +if [ -z "${1:-}" ]; then + echo 'Usage: schedule-icinga-downtime SECONDS [COMMENT]' >&2 + exit 1 +fi +comment="${2:-Downtime set per schedule-icinga-downtime.}" + +START_TIME=$(date +%s) +END_TIME=$(( START_TIME + $1 )) + +# The credentials reach curl as a header read from a pipe that the printf builtin fills, so they +# never show up in the process list the way `curl --user` would put them there. +curl \ + --connect-timeout 5 \ + --insecure \ + --silent \ + --header @<(printf 'Authorization: Basic %s\n' '{{ (tools__icinga2_api_user_login["username"] ~ ":" ~ tools__icinga2_api_user_login["password"]) | b64encode }}') \ + --header 'Accept: application/json' \ + --request POST '{{ tools__icinga2_api_url }}/v1/actions/schedule-downtime' \ + --data-binary @- 1> /dev/null << EOT +{ + "type": "Host", + "filter": "match(\"{{ tools__icinga2_hostname }}\", host.name)", + "start_time": "$START_TIME", + "end_time": "$END_TIME", + "author": "{{ tools__icinga2_hostname }}", + "comment": "$comment", + "all_services": true +} +EOT From 0f1b1b4c9b0f6f20023f918d724719fae72b99fd Mon Sep 17 00:00:00 2001 From: Markus Frei Date: Tue, 22 Sep 2026 19:19:08 +0200 Subject: [PATCH 31/38] fix(roles/nextcloud): stop installing the APCu PHP extension The role keeps all Nextcloud caches (local, distributed, locking) in Redis or Valkey, so APCu was installed but never used. Hosts that already have it keep it: an inventory that sets memcache.local to APCu would break if the role removed the package. Verified on Rocky 10: without php-pecl-apcu the role reports no change and the Molecule verify passes. --- CHANGELOG.md | 1 + roles/nextcloud/vars/main.yml | 6 +----- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 66f0c3287..b71f345e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +* **role:nextcloud**: The role no longer installs the APCu PHP extension, which Nextcloud does not use, since the role keeps all its caches in Redis or Valkey. Hosts keep an APCu that is already installed. * **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)). diff --git a/roles/nextcloud/vars/main.yml b/roles/nextcloud/vars/main.yml index e6fa2de54..02e244d7e 100644 --- a/roles/nextcloud/vars/main.yml +++ b/roles/nextcloud/vars/main.yml @@ -34,8 +34,6 @@ __nextcloud__php__modules__dependent_var: state: 'present' - name: 'php-opcache' state: 'present' - - name: 'php-pecl-apcu' - state: 'present' - name: 'php-process' # posix module for oc state: 'present' - name: 'php-redis' @@ -49,15 +47,13 @@ __nextcloud__php__modules__dependent_var: # Sury's schedule - so they would drag a second PHP runtime onto the host, exactly as the # php-imap metapackage already did. Always build the name from the version the php role manages. # Differences from the RedHat list: - # * php-mysqlnd -> mysql, php-pecl-apcu -> apcu (different upstream package names) + # * php-mysqlnd -> mysql (different upstream package name) # * php-opcache dropped: php-opcache is pulled in by the php base package already # * php-process dropped: the posix extension is built into the Debian php-cli/-common packages # * php-json dropped: JSON is compiled into PHP 8 core (php-json is only a transitional dummy) # imap is core on <= 8.3 and PECL on >= 8.4, but Sury names the versioned package uniformly, so # no version branch is needed here. Debian: - - name: 'php{{ __php__installed_version }}-apcu' - state: 'present' - name: 'php{{ __php__installed_version }}-bcmath' state: 'present' - name: 'php{{ __php__installed_version }}-gd' From 093877cf5fda2c485f7b58a9c05d4e96518d9389 Mon Sep 17 00:00:00 2001 From: Markus Frei Date: Tue, 22 Sep 2026 19:33:34 +0200 Subject: [PATCH 32/38] feat(roles/nextcloud)!: remove the PHP modules Nextcloud does not use The module list now names every module the role used to install with the reason it stays or goes. present: what Nextcloud 35 requires or recommends (OC_Util::checkServer(), SetupChecks/PhpModules.php), redis, ldap for user_ldap, smbclient for SMB external storage, opcache and imagick, which have setup checks of their own. absent: apcu and memcached (all caches live in Redis / Valkey), bcmath (gmp covers WebAuthn and SFTP) and imap (only the IMAP backend of user_external). php-json is dropped from the list instead of set absent, since on RHEL it is only a name that php-common provides. Verified on Rocky 10 with Remi PHP 8.5: the first run removes the four packages, the second reports no change, and the Molecule verify passes. --- CHANGELOG.md | 2 +- roles/nextcloud/vars/main.yml | 28 +++++++++++++++++++--------- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b71f345e9..df32ee214 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: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. @@ -58,7 +59,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -* **role:nextcloud**: The role no longer installs the APCu PHP extension, which Nextcloud does not use, since the role keeps all its caches in Redis or Valkey. Hosts keep an APCu that is already installed. * **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)). diff --git a/roles/nextcloud/vars/main.yml b/roles/nextcloud/vars/main.yml index 02e244d7e..24c84131e 100644 --- a/roles/nextcloud/vars/main.yml +++ b/roles/nextcloud/vars/main.yml @@ -6,10 +6,18 @@ # See "OS-specific Dependent Variables" in CONTRIBUTING.md. # PHP modules installed by the linuxfabrik.lfops.php role via nextcloud__php__modules__dependent_var. +# 'present' are the modules Nextcloud 35 requires or recommends (OC_Util::checkServer(), +# apps/settings/lib/SetupChecks/PhpModules.php), the ones the role's features need (redis for all +# caches, ldap for user_ldap, smbclient for SMB external storage) and the ones with setup checks of +# their own (opcache, imagick). 'absent' are modules the role used to install and Nextcloud does +# not use here: apcu and memcached (all caches live in Redis / Valkey), bcmath (gmp covers +# WebAuthn and SFTP) and imap (only the IMAP backend of user_external needs it). php-json is not +# listed: it is part of PHP 8 itself, and on RHEL only a name that php-common provides, so +# 'absent' would take PHP with it. __nextcloud__php__modules__dependent_var: RedHat: - name: 'php-bcmath' - state: 'present' + state: 'absent' - name: 'php-gd' state: 'present' - name: 'php-gmp' @@ -19,21 +27,21 @@ __nextcloud__php__modules__dependent_var: # IMAP was removed from PHP core in 8.4 and ships as the PECL package php-pecl-imap there; on # <= 8.3 it is still the core php-imap. Pick the name from the PHP version the php role detected. - name: '{{ "php-pecl-imap" if __php__installed_version is version("8.4", ">=") else "php-imap" }}' - state: 'present' + state: 'absent' - name: 'php-intl' state: 'present' - - name: 'php-json' - state: 'present' - name: 'php-ldap' state: 'present' - name: 'php-mbstring' state: 'present' - name: 'php-memcached' - state: 'present' + state: 'absent' - name: 'php-mysqlnd' state: 'present' - name: 'php-opcache' state: 'present' + - name: 'php-pecl-apcu' + state: 'absent' - name: 'php-process' # posix module for oc state: 'present' - name: 'php-redis' @@ -47,15 +55,17 @@ __nextcloud__php__modules__dependent_var: # Sury's schedule - so they would drag a second PHP runtime onto the host, exactly as the # php-imap metapackage already did. Always build the name from the version the php role manages. # Differences from the RedHat list: - # * php-mysqlnd -> mysql (different upstream package name) + # * php-mysqlnd -> mysql, php-pecl-apcu -> apcu (different upstream package names) # * php-opcache dropped: php-opcache is pulled in by the php base package already # * php-process dropped: the posix extension is built into the Debian php-cli/-common packages # * php-json dropped: JSON is compiled into PHP 8 core (php-json is only a transitional dummy) # imap is core on <= 8.3 and PECL on >= 8.4, but Sury names the versioned package uniformly, so # no version branch is needed here. Debian: + - name: 'php{{ __php__installed_version }}-apcu' + state: 'absent' - name: 'php{{ __php__installed_version }}-bcmath' - state: 'present' + state: 'absent' - name: 'php{{ __php__installed_version }}-gd' state: 'present' - name: 'php{{ __php__installed_version }}-gmp' @@ -63,7 +73,7 @@ __nextcloud__php__modules__dependent_var: - name: 'php{{ __php__installed_version }}-imagick' state: 'present' - name: 'php{{ __php__installed_version }}-imap' - state: 'present' + state: 'absent' - name: 'php{{ __php__installed_version }}-intl' state: 'present' - name: 'php{{ __php__installed_version }}-ldap' @@ -71,7 +81,7 @@ __nextcloud__php__modules__dependent_var: - name: 'php{{ __php__installed_version }}-mbstring' state: 'present' - name: 'php{{ __php__installed_version }}-memcached' - state: 'present' + state: 'absent' - name: 'php{{ __php__installed_version }}-mysql' state: 'present' - name: 'php{{ __php__installed_version }}-redis' From c481b719f152535705d853e0e56eaf93af9cdc3d Mon Sep 17 00:00:00 2001 From: Markus Frei Date: Tue, 22 Sep 2026 20:05:36 +0200 Subject: [PATCH 33/38] test(molecule): assert the socket, the config ownership and the root-only scripts The valkey scenario asserted root:valkey on valkey.conf, which the role no longer sets on RHEL, where it keeps the valkey:root that the package's tmpfiles.d rule restores; the assertion now follows the platform and covers the Unix socket, read back from the running server and 0770 with the valkey group. The schedule_reboot and setup_nextcloud scenarios assert that do-reboot and nextcloud-update, which carry the Icinga API credentials, are readable by root only. Verified on Rocky 10. --- .../schedule_reboot/no_reboot/verify.yml | 14 ++++++ .../molecule/setup_nextcloud/verify.yml | 20 +++++++++ extensions/molecule/valkey/verify.yml | 44 ++++++++++++++++--- 3 files changed, 73 insertions(+), 5 deletions(-) diff --git a/extensions/molecule/schedule_reboot/no_reboot/verify.yml b/extensions/molecule/schedule_reboot/no_reboot/verify.yml index add07057f..a25bde006 100644 --- a/extensions/molecule/schedule_reboot/no_reboot/verify.yml +++ b/extensions/molecule/schedule_reboot/no_reboot/verify.yml @@ -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). diff --git a/extensions/molecule/setup_nextcloud/verify.yml b/extensions/molecule/setup_nextcloud/verify.yml index a1e29fd3c..befad1f72 100644 --- a/extensions/molecule/setup_nextcloud/verify.yml +++ b/extensions/molecule/setup_nextcloud/verify.yml @@ -161,6 +161,26 @@ status_code: 207 +# nextcloud-update carries the credentials of the Icinga API user, and only root runs it. +- name: 'Verify nextcloud-update is readable by root only' + hosts: 'systems_under_test' + gather_facts: false + become: true + tasks: + + - name: 'stat /usr/local/bin/nextcloud-update' + ansible.builtin.stat: + path: '/usr/local/bin/nextcloud-update' + register: '__molecule__nextcloud_update_stat_result' + + - name: 'Assert that nextcloud-update is readable by root only' + ansible.builtin.assert: + that: + - '__molecule__nextcloud_update_stat_result["stat"]["mode"] == "0700"' + - '__molecule__nextcloud_update_stat_result["stat"]["pw_name"] == "root"' + fail_msg: '/usr/local/bin/nextcloud-update is {{ __molecule__nextcloud_update_stat_result["stat"] | d("missing") | to_json }}' + + # The SMTP TLS overrides that earlier versions of the role set are gone, so Nextcloud verifies the # certificate of the mail server again. `config:system:get` exits 1 for a key that does not exist. - name: 'Verify Nextcloud verifies the SMTP server certificate' diff --git a/extensions/molecule/valkey/verify.yml b/extensions/molecule/valkey/verify.yml index a8ad971c6..0639f89a8 100644 --- a/extensions/molecule/valkey/verify.yml +++ b/extensions/molecule/valkey/verify.yml @@ -38,6 +38,35 @@ port: '{{ valkey__conf_port | d(6379) }}' timeout: 30 + # The socket is what local clients such as Nextcloud connect through. Its path is read back + # from the running server, and the PING over it proves the server actually listens there. + - name: 'valkey-cli CONFIG GET unixsocket' + ansible.builtin.command: '{{ __molecule__valkey_cli }} CONFIG GET unixsocket' + register: '__molecule__valkey_unixsocket_result' + changed_when: false + + - name: 'valkey-cli -s PING' + ansible.builtin.command: '{{ __molecule__valkey_cli }} -s {{ __molecule__valkey_unixsocket }} PING' + register: '__molecule__valkey_socket_ping_result' + changed_when: false + vars: + __molecule__valkey_unixsocket: '{{ __molecule__valkey_unixsocket_result["stdout_lines"][1] }}' + + - name: 'stat the Unix socket' + ansible.builtin.stat: + path: '{{ __molecule__valkey_unixsocket_result["stdout_lines"][1] }}' + register: '__molecule__valkey_socket_stat_result' + + # 0770 and the valkey group: a role that needs access adds its service user to that group, + # and nobody else reaches the socket. + - name: 'Assert valkey answers on its Unix socket, which only its group may use' + ansible.builtin.assert: + that: + - '__molecule__valkey_socket_ping_result["stdout"] == "PONG"' + - '__molecule__valkey_socket_stat_result["stat"]["mode"] == "0770"' + - '__molecule__valkey_socket_stat_result["stat"]["gr_name"] == "valkey"' + fail_msg: 'CONFIG GET unixsocket: {{ __molecule__valkey_unixsocket_result["stdout_lines"] | to_json }} - stat: {{ __molecule__valkey_socket_stat_result["stat"] | d({}) | to_json }}' + # The bind default carries the upstream '-::1', where the '-' makes the address optional. Two # halves to that: it answers on the IPv6 loopback where the host has one (asserted here), and # it merely warns where the host has none, instead of aborting - which the service assertion @@ -60,20 +89,25 @@ # The regression this role was split out for: on RHEL 10 the config file was deployed with a # hardcoded `redis` group while the package creates a `valkey` one, so the run died with - # "chgrp failed: failed to look up group redis". Assert the ownership the package expects. + # "chgrp failed: failed to look up group redis". Assert the ownership the package expects, + # which on RHEL is the valkey:root that its tmpfiles.d rule restores at every boot, and on + # Debian and Ubuntu the root:valkey their package ships. - name: 'stat /etc/valkey/valkey.conf' ansible.builtin.stat: path: '/etc/valkey/valkey.conf' register: '__molecule__valkey_conf_result' - - name: 'Assert the config file is owned by root:valkey and not world-readable' + - name: 'Assert the config file has the ownership of the package and is not world-readable' ansible.builtin.assert: that: - '__molecule__valkey_conf_result["stat"]["exists"]' - - '__molecule__valkey_conf_result["stat"]["pw_name"] == "root"' - - '__molecule__valkey_conf_result["stat"]["gr_name"] == "valkey"' + - '__molecule__valkey_conf_result["stat"]["pw_name"] == __molecule__valkey_conf_owner' + - '__molecule__valkey_conf_result["stat"]["gr_name"] == __molecule__valkey_conf_group' - '__molecule__valkey_conf_result["stat"]["mode"] == "0640"' - fail_msg: '/etc/valkey/valkey.conf is {{ __molecule__valkey_conf_result["stat"] | d("missing") }}' + fail_msg: '/etc/valkey/valkey.conf is {{ __molecule__valkey_conf_result["stat"] | d("missing") | to_json }}' + vars: + __molecule__valkey_conf_group: '{{ (ansible_facts["os_family"] == "RedHat") | ternary("root", "valkey") }}' + __molecule__valkey_conf_owner: '{{ (ansible_facts["os_family"] == "RedHat") | ternary("valkey", "root") }}' # The systemd drop-in is only proven by what the unit ends up with, not by the file on disk. - name: 'systemctl show {{ __molecule__valkey_service_name }} --property=LimitNOFILE' # noqa command-instead-of-module (read-only state query) From 4cdd7ca02c70be43011fffc961aa90e264164606 Mon Sep 17 00:00:00 2001 From: Markus Frei Date: Tue, 22 Sep 2026 20:05:37 +0200 Subject: [PATCH 34/38] docs(roles/nextcloud): describe the requirements without naming versions CONTRIBUTING: no upstream version numbers in texts that stay in the repo, they age with the next release. The role checks the requirements itself and names the versions it expects in the error message, so the README points at that instead of repeating "10.11+ for Nextcloud 35". The CHANGELOG entries outside Breaking Changes are one sentence again. --- CHANGELOG.md | 6 +++--- roles/nextcloud/README.md | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df32ee214..8ac63d091 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,7 +41,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * **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, with a jail that bans an IP for 8 hours after 5 failed Nextcloud logins within 10 minutes. The reverse proxies listed in the Nextcloud setting `trusted_proxies` are never banned, in any jail. +* **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. @@ -75,7 +75,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * **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 changes. +* **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. @@ -114,7 +114,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security -* **role:borg_local, role:schedule_reboot, role:tools**: The password of the Icinga API user no longer shows up in the process list or in a file that every local user can read, and neither does the Rocket.Chat webhook of `schedule_reboot`. `schedule-icinga-downtime` is a command in `/usr/local/sbin` for root instead of a shell function in `/etc/profile.d/alias.sh`. +* **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. diff --git a/roles/nextcloud/README.md b/roles/nextcloud/README.md index 7c538793b..99d985b56 100644 --- a/roles/nextcloud/README.md +++ b/roles/nextcloud/README.md @@ -27,8 +27,8 @@ Any [LFOps playbook](https://github.com/Linuxfabrik/lfops/blob/main/playbooks/RE * On RHEL-compatible systems, the EPEL repository must be enabled (role: [linuxfabrik.lfops.repo_epel](https://github.com/Linuxfabrik/lfops/tree/main/roles/repo_epel)). * A web server (for example Apache httpd) must be installed, with a virtual host for Nextcloud (role: [linuxfabrik.lfops.apache_httpd](https://github.com/Linuxfabrik/lfops/tree/main/roles/apache_httpd)). -* MariaDB must be installed, in a version the installed Nextcloud supports (10.11+ for Nextcloud 35), with the Nextcloud database and the user from `nextcloud__database_login` (role: [linuxfabrik.lfops.mariadb_server](https://github.com/Linuxfabrik/lfops/tree/main/roles/mariadb_server)). -* PHP must be installed, in a version the installed Nextcloud supports (8.3 to 8.5 for Nextcloud 35) (roles: [linuxfabrik.lfops.repo_remi](https://github.com/Linuxfabrik/lfops/tree/main/roles/repo_remi) and [linuxfabrik.lfops.php](https://github.com/Linuxfabrik/lfops/tree/main/roles/php)). +* MariaDB must be installed, in a version the installed Nextcloud supports, with the Nextcloud database and the user from `nextcloud__database_login` (role: [linuxfabrik.lfops.mariadb_server](https://github.com/Linuxfabrik/lfops/tree/main/roles/mariadb_server)). +* PHP must be installed, in a version the installed Nextcloud supports (roles: [linuxfabrik.lfops.repo_remi](https://github.com/Linuxfabrik/lfops/tree/main/roles/repo_remi) and [linuxfabrik.lfops.php](https://github.com/Linuxfabrik/lfops/tree/main/roles/php)). * Valkey must be installed on RHEL 10, Redis on the other platforms, listening on the Unix socket in `nextcloud__redis_unixsocket` (roles: [linuxfabrik.lfops.valkey](https://github.com/Linuxfabrik/lfops/tree/main/roles/valkey), or [linuxfabrik.lfops.repo_remi](https://github.com/Linuxfabrik/lfops/tree/main/roles/repo_remi) and [linuxfabrik.lfops.redis](https://github.com/Linuxfabrik/lfops/tree/main/roles/redis)). * Optional: Collabora (role: [linuxfabrik.lfops.collabora](https://github.com/Linuxfabrik/lfops/tree/main/roles/collabora)) provides online document editing. * Optional: fail2ban bans IPs with too many failed logins (role: [linuxfabrik.lfops.fail2ban](https://github.com/Linuxfabrik/lfops/tree/main/roles/fail2ban)). @@ -537,11 +537,11 @@ nextcloud__vhost_virtualhost_port: '81' ## Troubleshooting -`Nextcloud 35 needs PHP 8.3 or newer, but older than 8.6, and MariaDB 10.11 or newer.` +`Nextcloud XX needs PHP X.Y or newer, but older than X.Z, and MariaDB XX.Y or newer.` -* The role compares the installed PHP and MariaDB with what the installed Nextcloud major version requires, before it changes anything, and aborts if they do not fit. Nextcloud itself refuses to run on a PHP outside that range. Upgrade PHP (for example via `repo_remi__enabled_php_version`) or MariaDB (`repo_mariadb__version`), then run the role again. +* The role compares the installed PHP and MariaDB with what the installed Nextcloud major version requires, before it changes anything, and aborts if they do not fit. The message names the versions it expects and the ones it found. Nextcloud itself refuses to run on a PHP outside that range. Upgrade PHP (for example via `repo_remi__enabled_php_version`) or MariaDB (`repo_mariadb__version`), then run the role again. -`Nextcloud 36 is not supported by this role.` +`Nextcloud XX is not supported by this role.` * The role knows the requirements of the Nextcloud major versions listed in the message only. Pin `nextcloud__version` to a supported major version for a new installation, or add the requirements of the new major version to `__nextcloud__requirements` in `vars/main.yml`. From 71cc79767e5deca5c6f5f2ddac75b630e0ad4f77 Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Wed, 23 Sep 2026 10:34:41 +0200 Subject: [PATCH 35/38] fix(roles/nextcloud): look up the MariaDB socket for the platform The version check connected through /var/lib/mysql/mysql.sock on every platform, which does not exist on Debian and Ubuntu (/run/mysqld/mysqld.sock). Take the path from vars/.yml, as icingaweb2 does. --- roles/nextcloud/tasks/main.yml | 2 +- roles/nextcloud/vars/Debian.yml | 1 + roles/nextcloud/vars/RedHat.yml | 1 + roles/nextcloud/vars/Ubuntu.yml | 1 + 4 files changed, 4 insertions(+), 1 deletion(-) diff --git a/roles/nextcloud/tasks/main.yml b/roles/nextcloud/tasks/main.yml index dc691223e..f0ef9b7b0 100644 --- a/roles/nextcloud/tasks/main.yml +++ b/roles/nextcloud/tasks/main.yml @@ -53,7 +53,7 @@ login_user: '{{ nextcloud__database_login["username"] }}' login_password: '{{ nextcloud__database_login["password"] }}' login_host: '{{ nextcloud__database_host }}' - login_unix_socket: '{{ (nextcloud__database_host == "localhost") | ternary("/var/lib/mysql/mysql.sock", omit) }}' # https://github.com/PyMySQL/PyMySQL/issues/509#issuecomment-244072354 + login_unix_socket: '{{ (nextcloud__database_host == "localhost") | ternary(__nextcloud__mysql_login_unix_socket, omit) }}' # https://github.com/PyMySQL/PyMySQL/issues/509#issuecomment-244072354 filter: 'version' register: '__nextcloud__mariadb_info_result' check_mode: false diff --git a/roles/nextcloud/vars/Debian.yml b/roles/nextcloud/vars/Debian.yml index 8a1b6b096..334a390c5 100644 --- a/roles/nextcloud/vars/Debian.yml +++ b/roles/nextcloud/vars/Debian.yml @@ -4,5 +4,6 @@ __nextcloud__required_packages: - 'ldap-utils' - 'smbclient' +__nextcloud__mysql_login_unix_socket: '/run/mysqld/mysqld.sock' __nextcloud__redis_group: 'redis' __nextcloud__redis_unixsocket: '/run/redis/redis-server.sock' diff --git a/roles/nextcloud/vars/RedHat.yml b/roles/nextcloud/vars/RedHat.yml index 066067ba6..1dbf6c0d6 100644 --- a/roles/nextcloud/vars/RedHat.yml +++ b/roles/nextcloud/vars/RedHat.yml @@ -4,5 +4,6 @@ __nextcloud__required_packages: - 'openldap-clients' - 'samba-client' +__nextcloud__mysql_login_unix_socket: '/var/lib/mysql/mysql.sock' __nextcloud__redis_group: 'redis' __nextcloud__redis_unixsocket: '/run/redis/redis.sock' diff --git a/roles/nextcloud/vars/Ubuntu.yml b/roles/nextcloud/vars/Ubuntu.yml index 8a1b6b096..334a390c5 100644 --- a/roles/nextcloud/vars/Ubuntu.yml +++ b/roles/nextcloud/vars/Ubuntu.yml @@ -4,5 +4,6 @@ __nextcloud__required_packages: - 'ldap-utils' - 'smbclient' +__nextcloud__mysql_login_unix_socket: '/run/mysqld/mysqld.sock' __nextcloud__redis_group: 'redis' __nextcloud__redis_unixsocket: '/run/redis/redis-server.sock' From bcb5e59c5776c65c36f15fda5569b6d6edf28f5c Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Wed, 23 Sep 2026 10:34:42 +0200 Subject: [PATCH 36/38] docs(roles/nextcloud): list fail2ban as off by default and align the version description fail2ban moves to the roles that setup_nextcloud leaves off by default. The Troubleshooting entries use the bold heading of roles/example and get two blank lines above the section. argument_specs and the task comment no longer offer 'latest' for nextcloud__version, matching the README. --- roles/nextcloud/README.md | 7 ++++--- roles/nextcloud/meta/argument_specs.yml | 2 +- roles/nextcloud/tasks/main.yml | 7 ++++--- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/roles/nextcloud/README.md b/roles/nextcloud/README.md index 99d985b56..ac60d082b 100644 --- a/roles/nextcloud/README.md +++ b/roles/nextcloud/README.md @@ -31,12 +31,12 @@ Any [LFOps playbook](https://github.com/Linuxfabrik/lfops/blob/main/playbooks/RE * PHP must be installed, in a version the installed Nextcloud supports (roles: [linuxfabrik.lfops.repo_remi](https://github.com/Linuxfabrik/lfops/tree/main/roles/repo_remi) and [linuxfabrik.lfops.php](https://github.com/Linuxfabrik/lfops/tree/main/roles/php)). * Valkey must be installed on RHEL 10, Redis on the other platforms, listening on the Unix socket in `nextcloud__redis_unixsocket` (roles: [linuxfabrik.lfops.valkey](https://github.com/Linuxfabrik/lfops/tree/main/roles/valkey), or [linuxfabrik.lfops.repo_remi](https://github.com/Linuxfabrik/lfops/tree/main/roles/repo_remi) and [linuxfabrik.lfops.redis](https://github.com/Linuxfabrik/lfops/tree/main/roles/redis)). * Optional: Collabora (role: [linuxfabrik.lfops.collabora](https://github.com/Linuxfabrik/lfops/tree/main/roles/collabora)) provides online document editing. -* Optional: fail2ban bans IPs with too many failed logins (role: [linuxfabrik.lfops.fail2ban](https://github.com/Linuxfabrik/lfops/tree/main/roles/fail2ban)). * Optional: Coturn (role: [linuxfabrik.lfops.coturn](https://github.com/Linuxfabrik/lfops/tree/main/roles/coturn)) provides the TURN server for Nextcloud Talk. These roles are not enabled by default; enable them via the playbook's skip variables if needed: * The Collabora repository (role: [linuxfabrik.lfops.repo_collabora](https://github.com/Linuxfabrik/lfops/tree/main/roles/repo_collabora)) serves the Collabora packages from the official Collabora repository instead of the CODE repository. +* fail2ban (role: [linuxfabrik.lfops.fail2ban](https://github.com/Linuxfabrik/lfops/tree/main/roles/fail2ban)) bans IPs with too many failed logins, on a Nextcloud host that clients reach directly. ## Requirements @@ -535,13 +535,14 @@ nextcloud__vhost_virtualhost_ip: '127.0.0.1' nextcloud__vhost_virtualhost_port: '81' ``` + ## Troubleshooting -`Nextcloud XX needs PHP X.Y or newer, but older than X.Z, and MariaDB XX.Y or newer.` +**The run aborts with `Nextcloud XX needs PHP X.Y or newer, but older than X.Z, and MariaDB XX.Y or newer.`** * The role compares the installed PHP and MariaDB with what the installed Nextcloud major version requires, before it changes anything, and aborts if they do not fit. The message names the versions it expects and the ones it found. Nextcloud itself refuses to run on a PHP outside that range. Upgrade PHP (for example via `repo_remi__enabled_php_version`) or MariaDB (`repo_mariadb__version`), then run the role again. -`Nextcloud XX is not supported by this role.` +**The run aborts with `Nextcloud XX is not supported by this role.`** * The role knows the requirements of the Nextcloud major versions listed in the message only. Pin `nextcloud__version` to a supported major version for a new installation, or add the requirements of the new major version to `__nextcloud__requirements` in `vars/main.yml`. diff --git a/roles/nextcloud/meta/argument_specs.yml b/roles/nextcloud/meta/argument_specs.yml index f7f7a6ff1..66dbefc6b 100644 --- a/roles/nextcloud/meta/argument_specs.yml +++ b/roles/nextcloud/meta/argument_specs.yml @@ -204,7 +204,7 @@ argument_specs: nextcloud__version: type: 'str' required: true - description: "Which version to install. One of 'latest', 'latest-XX' or 'nextcloud-XX.X.XX'." + description: "Which version to download for a new installation. One of 'latest-XX' or 'nextcloud-XX.X.XX'." nextcloud__vhost_virtualhost_ip: type: 'str' diff --git a/roles/nextcloud/tasks/main.yml b/roles/nextcloud/tasks/main.yml index f0ef9b7b0..49a349e80 100644 --- a/roles/nextcloud/tasks/main.yml +++ b/roles/nextcloud/tasks/main.yml @@ -28,9 +28,10 @@ remote_src: true creates: '/var/www/html/nextcloud/config' - # Checked against the unpacked code rather than nextcloud__version, which may just say - # 'latest', and on every run, so a PHP or MariaDB that falls below what the installed - # Nextcloud needs stops the run before the role touches the instance. + # Checked against the unpacked code rather than nextcloud__version, which only picks the + # download of a new installation and names at most the major version ('latest-35'), and on + # every run, so a PHP or MariaDB that falls below what the installed Nextcloud needs stops + # the run before the role touches the instance. - name: 'stat /var/www/html/nextcloud/version.php' ansible.builtin.stat: path: '/var/www/html/nextcloud/version.php' From 2b2a20444b9d75655da3cbb62861a89c22773d7f Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Wed, 23 Sep 2026 10:34:42 +0200 Subject: [PATCH 37/38] test(molecule): assert the Redis / Valkey socket and config ownership in setup_nextcloud Stat the socket Nextcloud uses (mode 0770, group redis or valkey) and check that the config file keeps the :root ownership and 0640 that the package's tmpfiles.d rule restores at every boot. This covers the redis role on Rocky 8 and 9 and valkey on Rocky 10. Also re-wraps one comment. --- .../molecule/setup_nextcloud/verify.yml | 41 +++++++++++++++++-- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/extensions/molecule/setup_nextcloud/verify.yml b/extensions/molecule/setup_nextcloud/verify.yml index befad1f72..164577528 100644 --- a/extensions/molecule/setup_nextcloud/verify.yml +++ b/extensions/molecule/setup_nextcloud/verify.yml @@ -100,9 +100,10 @@ # Nextcloud keeps its caches and file locks in Redis / Valkey and reaches it through the Unix -# socket (a path in 'redis host'), which only the members of the server's group may use. Nextcloud's own memcache -# check writes a value and reads it back (CLI, as the web server user), and a PROPFIND takes a -# shared lock through Apache and PHP-FPM, so both paths have to get through the socket. +# socket (a path in 'redis host'), which only the members of the server's group may use. +# Nextcloud's own memcache check writes a value and reads it back (CLI, as the web server user), +# and a PROPFIND takes a shared lock through Apache and PHP-FPM, so both paths have to get +# through the socket. - name: 'Verify Nextcloud reaches Redis / Valkey through the Unix socket' hosts: 'systems_under_test' gather_facts: false @@ -123,6 +124,40 @@ that: '__molecule__nextcloud_redis_host_result["stdout"] is match("/")' fail_msg: 'redis host is {{ __molecule__nextcloud_redis_host_result["stdout"] }}, so Nextcloud connects over TCP' + - name: 'stat {{ __molecule__nextcloud_redis_host_result["stdout"] }}' + ansible.builtin.stat: + path: '{{ __molecule__nextcloud_redis_host_result["stdout"] }}' + register: '__molecule__nextcloud_redis_socket_stat_result' + + # 0770 and the server's group: the web server user gets in as a member of that group, nobody + # else does. Rocky 8 and 9 run Redis, Rocky 10 runs Valkey, so the group names the server. + - name: 'Assert the socket is open to the group of Redis / Valkey only' + ansible.builtin.assert: + that: + - '__molecule__nextcloud_redis_socket_stat_result["stat"]["issock"]' + - '__molecule__nextcloud_redis_socket_stat_result["stat"]["mode"] == "0770"' + - '__molecule__nextcloud_redis_socket_stat_result["stat"]["gr_name"] in ["redis", "valkey"]' + fail_msg: '{{ __molecule__nextcloud_redis_socket_stat_result["stat"] | d("missing") | to_json }}' + + - name: 'stat /etc/{{ __molecule__redis_server }}/{{ __molecule__redis_server }}.conf' + ansible.builtin.stat: + path: '/etc/{{ __molecule__redis_server }}/{{ __molecule__redis_server }}.conf' + register: '__molecule__nextcloud_redis_conf_stat_result' + vars: + __molecule__redis_server: '{{ __molecule__nextcloud_redis_socket_stat_result["stat"]["gr_name"] }}' + + # The package's tmpfiles.d rule restores :root at every boot. A role that deployed any + # other ownership would report the config file as changed on the first run after a reboot. + - name: 'Assert the config file keeps the ownership the package restores at boot' + ansible.builtin.assert: + that: + - '__molecule__nextcloud_redis_conf_stat_result["stat"]["pw_name"] == __molecule__redis_server' + - '__molecule__nextcloud_redis_conf_stat_result["stat"]["gr_name"] == "root"' + - '__molecule__nextcloud_redis_conf_stat_result["stat"]["mode"] == "0640"' + fail_msg: '{{ __molecule__nextcloud_redis_conf_stat_result["stat"] | d("missing") | to_json }}' + vars: + __molecule__redis_server: '{{ __molecule__nextcloud_redis_socket_stat_result["stat"]["gr_name"] }}' + - name: 'php occ setupchecks --output=json' ansible.builtin.command: 'php /var/www/html/nextcloud/occ setupchecks --output=json' args: From cf5368eb2558d5b9059be5c1853d940d9643bcc2 Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Wed, 23 Sep 2026 10:34:43 +0200 Subject: [PATCH 38/38] test(molecule): assert setup_basic keeps the Icinga API credentials to root Set tools__icinga2_api_* so that schedule-icinga-downtime gets deployed. Assert that it is 0700 root, and that a login shell of nobody sees the reboot alias but not the password, and cannot run the script. --- .../group_vars/systems_under_test.yml | 7 +++ extensions/molecule/setup_basic/verify.yml | 45 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/extensions/molecule/setup_basic/inventory/group_vars/systems_under_test.yml b/extensions/molecule/setup_basic/inventory/group_vars/systems_under_test.yml index 194e1dde7..0d7f54778 100644 --- a/extensions/molecule/setup_basic/inventory/group_vars/systems_under_test.yml +++ b/extensions/molecule/setup_basic/inventory/group_vars/systems_under_test.yml @@ -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' diff --git a/extensions/molecule/setup_basic/verify.yml b/extensions/molecule/setup_basic/verify.yml index f7c3f0501..4d638e384 100644 --- a/extensions/molecule/setup_basic/verify.yml +++ b/extensions/molecule/setup_basic/verify.yml @@ -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 }}'