diff --git a/CHANGELOG.md b/CHANGELOG.md index 780e021db..8ac63d091 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Breaking Changes +* **role:nextcloud**: The role removes the PHP extensions APCu, bcmath, IMAP and memcached, which Nextcloud does not use in the setup the role creates: all caches live in Redis or Valkey. An installation that needs one of them, such as the IMAP backend of the External user authentication app, lists it in `php__modules__host_var` with `state: 'present'`. +* **role:nextcloud**: `nextcloud__database_login` is mandatory, and `nextcloud__mariadb_login` is gone. A new installation connects to MariaDB as this user, which `setup_nextcloud` creates with access to the Nextcloud database from `localhost` only, instead of handing the database administrator to the installer, which created an `oc_` user that may connect from any host. Existing installations keep their database user. +* **role:nextcloud**: `nextcloud__version` is mandatory, for example `'latest-35'`. It only picks the release that a new installation downloads. +* **role:nextcloud**: Nextcloud reaches Redis or Valkey through its Unix socket instead of over TCP, and the web server user joins the `redis` or `valkey` group, which may use the socket. Run the complete `setup_nextcloud` playbook rather than the `nextcloud` role alone, so that Redis or Valkey open the socket first. Set `nextcloud__redis_unixsocket: ''` to stay with TCP. +* **role:nextcloud**: The role aborts before it changes anything when the installed PHP or MariaDB does not fit the installed Nextcloud major version, or when it does not know that major version (30 to 35 are known). For example, Nextcloud 35 needs PHP 8.3 to 8.5 and MariaDB 10.11 or newer. Upgrade PHP or MariaDB, as the error message says, before running the role again. +* **role:nextcloud**: Nextcloud logs to `/var/log/nextcloud/nextcloud.log` instead of `nextcloud.log` in the data directory, since fail2ban cannot read the data directory under SELinux. Adjust log shippers and monitoring that read the old file, and remove it once it is no longer needed. +* **role:nextcloud**: Nextcloud verifies the certificate of the SMTP server, where the role had switched the check off. If your mail server presents a self-signed certificate, make Nextcloud trust it, or set `mail_smtpstreamoptions ssl allow_self_signed` to `true` and `mail_smtpstreamoptions ssl verify_peer` and `mail_smtpstreamoptions ssl verify_peer_name` to `false` in `nextcloud__sysconfig__host_var` to keep the previous behaviour. * **role:kernel_modules**: The `tun` kernel module is blocked by default (CVE-2026-81000, [RHSB-2026-011](https://access.redhat.com/security/vulnerabilities/RHSB-2026-011)). This stops OpenVPN, WireGuard in userspace, rootless Podman and Docker networking and libvirt VM networking after the next reboot, which the role requests on hosts where `tun` is loaded. Before running the role, add `kernel_modules__modules__host_var: [{name: 'tun', enabled: true}]` to the inventory of every such host. Rootful Docker and Podman with bridge networking are not affected. * **role:grafana**: `grafana__users_case_insensitive_login` is gone; remove it from your inventory. Grafana has ignored the setting since v11.0.0 and always matches logins case-insensitively. * **role:system_update**: `system_update__pre_update_code` and `system_update__post_update_code` now also run in the daily security lane on Rocky, around the transaction that installs the hot-fixes, where until now only the weekly lane ran them. Set `system_update__security_pre_update_code: ''` and `system_update__security_post_update_code: ''` to keep the security lane free of it, or set either to a codeblock of its own to have the two lanes do different things. @@ -32,6 +39,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +* **role:collabora**: Support Collabora Online CODE 26.04.4. +* **role:redis**: The role's inventory variables are type-checked when it starts, so a mistyped value fails the run right away instead of surfacing further in as a confusing error. +* **playbook:setup_nextcloud**: `setup_nextcloud__skip_fail2ban: false` runs fail2ban on a Nextcloud host that clients reach directly, where it bans an IP for 8 hours after 5 failed Nextcloud logins within 10 minutes and never bans the reverse proxies listed in the Nextcloud setting `trusted_proxies`. +* **role:fail2ban**: The `nextcloud` filter and the `z10-nextcloud` jail ban IPs with too many failed Nextcloud logins or two-factor challenges, following the Nextcloud hardening guide. * **role:wordpress**: Entries in `wordpress__plugins` accept `enabled: false`, which keeps a plugin installed but deactivated. * **role:system_update**: The role's inventory variables are type-checked when it starts, so a mistyped value fails the run right away instead of surfacing further in as a confusing error. * **role:wordpress**: Several WordPress instances can share a host as pseudo hosts in the inventory, under different host names as well as under different paths of one host name, such as `https://example.com/blog`. @@ -48,6 +59,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +* **role:redis, role:valkey**: Redis and Valkey also listen on a Unix socket that only the members of their group may use, on RHEL at the path the package ships (`/run/redis/redis.sock`, `/run/valkey/valkey.sock`). * **role:grafana**: `grafana.ini` follows the file that current Grafana packages ship, so deploying it only changes the settings LFOps manages. As a side effect, recording rules time out after 30 seconds instead of 10. * **plugin:bitwarden_item, module:bitwarden_item**: A run against a vault that contains no items at all aborts instead of creating the first one, because `bw serve` briefly reports an empty vault after every sync ([bitwarden/clients#23283](https://github.com/bitwarden/clients/issues/23283)). * **role:repo_postgresql**: The PostgreSQL version repositories take precedence over the distribution's packages of the same name, so on RHEL 10 an install or update no longer switches a PostgreSQL server from the PGDG build to the AppStream build, which uses a different file layout. @@ -60,6 +72,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +* **role:borg_local**: The Icinga downtime for `clamd@scan` during a backup is set, where Icinga had rejected the request as invalid JSON. +* **role:redis, role:valkey**: On RHEL, a run after a reboot no longer reports the configuration file as changed, since the role keeps the ownership that the package's tmpfiles.d rule restores at every boot. +* **role:nextcloud**: A run after a failed installation installs Nextcloud, where it skipped the installer because the failed attempt had left a `config.php` behind. +* **role:nextcloud**: A run against an unchanged host no longer restarts PHP-FPM and reports no change at all. +* **role:nextcloud**: `nextcloud-update` adds missing primary keys and runs the pending mimetype migrations after an update, which Nextcloud leaves to the administrator. +* **role:nextcloud**: A `nextcloud__datadir` other than `/data` gets the ownership and the SELinux label Nextcloud needs, which the role only ever set on `/data`. +* **role:nextcloud**: The monthly LDAP remnants report runs, where its timer started the app update instead. * **role:grafana**: The `from_name` of `grafana__smtp_config` is used as the sender name of emails, instead of the value of `skip_verify`. * **module:bitwarden_item**: The module works with the Mitogen strategy, where it aborted with `MODULE FAILURE` on every run, for example when the `grafana` role stores its service account tokens. * **plugin:bitwarden_item, module:bitwarden_item**: Running against several hosts in parallel no longer creates duplicates of a Bitwarden item, whether the item is new or has existed for a long time, so the next run no longer aborts with "Found multiple Bitwarden items". @@ -95,6 +114,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +* **role:borg_local, role:schedule_reboot, role:tools**: Neither the password of the Icinga API user nor the Rocket.Chat webhook of `schedule_reboot` shows up in the process list or in a file that every local user can read, which moves `schedule-icinga-downtime` from a shell function in `/etc/profile.d/alias.sh` to a command in `/usr/local/sbin` that only root may run. +* **role:nextcloud**: The password of the Icinga API user no longer shows up in the process list while `nextcloud-update` sets or removes the downtime. +* **role:nextcloud**: `/usr/local/bin/nextcloud-update`, which holds the credentials of the Icinga API user, is readable by root only. +* **role:nextcloud**: The database and admin passwords no longer show up in the process list during the installation. * **role:kernel_modules**: Blocks further rarely used kernel modules by default that unprivileged users can get loaded and that are prone to local privilege escalations, among them `ah6`, `pppoe` and `sctp_diag` from [RHSB-2026-011](https://access.redhat.com/security/vulnerabilities/RHSB-2026-011). This stops Bluetooth, L2TP/IPsec, PPPoE, PPTP and IPsec AH; set `enabled: true` for the modules a host needs. The role README lists them all. * **role:wordpress**: `--tags wordpress:export` writes to `/backup/wordpress-export/`, 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/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/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_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 }}' 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..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,12 +16,25 @@ mariadb_server__admin_user: username: 'mariadb-admin' password: 'linuxfabrik' +nextcloud__database_login: + username: 'nextcloud' + password: 'linuxfabrik' nextcloud__fqdn: 'nextcloud.example.com' nextcloud__skip_notify_push: true +# fail2ban is off by default, since it belongs on hosts that clients reach directly. Switched on +# here so verify.yml can prove the jail sees failed logins and never bans the proxy. +setup_nextcloud__skip_fail2ban: false +# The host itself stands in for the reverse proxy, so verify.yml can send a failed login "through +# the proxy" with X-Forwarded-For and check that fail2ban counts the client, not the proxy. +nextcloud__sysconfig__group_var: + - key: 'trusted_proxies 0' + value: '127.0.0.1' + state: 'present' nextcloud__users: - username: 'nextcloud-admin' password: 'linuxfabrik' group: 'admin' +nextcloud__version: 'latest-35' repo_mariadb__version: '11.4' diff --git a/extensions/molecule/setup_nextcloud/verify.yml b/extensions/molecule/setup_nextcloud/verify.yml index 7735e45cd..164577528 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. @@ -73,3 +97,217 @@ 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' + + +# 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: '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: + 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 + + +# 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' + 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' + 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"] }}' + + +# 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/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) diff --git a/playbooks/README.md b/playbooks/README.md index 5ca83bc3a..2565b8414 100644 --- a/playbooks/README.md +++ b/playbooks/README.md @@ -1312,8 +1312,8 @@ 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` -* [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` @@ -1323,9 +1323,9 @@ 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`) * [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..718e30847 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 + @@ -61,10 +80,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(): @@ -126,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)' @@ -141,20 +162,6 @@ # === Nextcloud Application - - role: 'linuxfabrik.lfops.selinux' - selinux__booleans__dependent_var: '{{ - nextcloud__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 @@ -164,6 +171,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/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 f8885cad9..66a0f974a 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() { @@ -17,14 +17,16 @@ 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)", + "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 %} @@ -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 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' diff --git a/roles/fail2ban/README.md b/roles/fail2ban/README.md index 339055afc..40872ff8a 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`: @@ -127,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: `[]` @@ -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..8e873c223 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' @@ -32,7 +35,12 @@ 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' +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..72a963e61 100644 --- a/roles/fail2ban/meta/argument_specs.yml +++ b/roles/fail2ban/meta/argument_specs.yml @@ -84,12 +84,43 @@ 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 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/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/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 diff --git a/roles/nextcloud/README.md b/roles/nextcloud/README.md index 6801b53ac..ac60d082b 100644 --- a/roles/nextcloud/README.md +++ b/roles/nextcloud/README.md @@ -16,6 +16,9 @@ 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 @@ -24,15 +27,16 @@ 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)). -* 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)). +* 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: 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 @@ -43,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` @@ -83,6 +127,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. @@ -116,9 +176,17 @@ 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 +nextcloud__database_login: + username: 'nextcloud' + password: 'linuxfabrik' nextcloud__fqdn: 'cloud.example.com' nextcloud__users: # first user has to be the admin account @@ -132,6 +200,7 @@ nextcloud__users: - 'files quota "50 MB"' - 'firstrunwizard show 0' - 'settings email info@example.org' +nextcloud__version: 'latest-35' ``` @@ -146,7 +215,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 +223,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 +247,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` @@ -189,6 +265,12 @@ nextcloud__users: * 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. @@ -197,7 +279,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'` @@ -237,12 +319,6 @@ nextcloud__users: * 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. @@ -261,6 +337,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. @@ -338,12 +426,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. @@ -372,6 +454,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' @@ -383,7 +466,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' @@ -426,22 +508,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' @@ -449,11 +531,22 @@ 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' ``` + +## Troubleshooting + +**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. + +**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`. + + ## License [The Unlicense](https://unlicense.org/) diff --git a/roles/nextcloud/defaults/main.yml b/roles/nextcloud/defaults/main.yml index ab7d39d9a..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,12 +178,13 @@ 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 +nextcloud__redis_group: '{{ __nextcloud__redis_group }}' +nextcloud__redis_unixsocket: '{{ __nextcloud__redis_unixsocket }}' + nextcloud__skip_apps: false nextcloud__skip_notify_push: false @@ -236,21 +238,29 @@ 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' 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' @@ -276,28 +286,34 @@ 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' 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' @@ -313,8 +329,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' # ----------------------------------------------------------------------------- @@ -471,6 +485,44 @@ 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' + +# 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, @@ -489,8 +541,11 @@ 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: '/data(/.*)?' + target: '{{ nextcloud__datadir }}(/.*)?' state: 'present' - setype: 'httpd_sys_rw_content_t' target: '/var/www/html/nextcloud/.htaccess' diff --git a/roles/nextcloud/meta/argument_specs.yml b/roles/nextcloud/meta/argument_specs.yml index 067725a0b..66dbefc6b 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 @@ -192,9 +203,8 @@ argument_specs: nextcloud__version: type: 'str' - required: false - default: 'latest' - description: "Which version to install. One of 'latest', 'latest-XX' or 'nextcloud-XX.X.XX'." + required: true + 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 4887b0125..49a349e80 100644 --- a/roles/nextcloud/tasks/main.yml +++ b/roles/nextcloud/tasks/main.yml @@ -28,6 +28,63 @@ remote_src: true creates: '/var/www/html/nextcloud/config' + # 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' + 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__database_login["username"] }}' + login_password: '{{ nextcloud__database_login["password"] }}' + login_host: '{{ nextcloud__database_host }}' + 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 + + - 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 @@ -54,38 +111,81 @@ group: '{{ __shared__apache_httpd_group }}' mode: 0o750 loop: - - '/data' + - '{{ 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 /data /var/www/html/nextcloud' - ansible.builtin.command: 'restorecon -Fvr /data /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: - '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 + # 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__database_login["username"] }}' args: chdir: '/var/www/html/nextcloud/' - creates: '/var/www/html/nextcloud/config/config.php' + stdin: |- + {{ nextcloud__database_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: | @@ -104,6 +204,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 @@ -122,6 +233,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 @@ -129,6 +241,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' @@ -154,6 +268,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' @@ -174,11 +299,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' @@ -265,14 +393,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: @@ -391,6 +532,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 @@ -398,7 +541,7 @@ dest: '/usr/local/bin/nextcloud-update' owner: 'root' group: 'root' - mode: 0o755 + mode: 0o700 tags: - 'nextcloud' 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 diff --git a/roles/nextcloud/templates/usr/local/bin/nextcloud-update.j2 b/roles/nextcloud/templates/usr/local/bin/nextcloud-update.j2 index 36d6545a5..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 }} -# 2026052001 +# 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 @@ -220,6 +223,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 +243,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 '------------------------' @@ -247,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 diff --git a/roles/nextcloud/vars/Debian.yml b/roles/nextcloud/vars/Debian.yml index 49d2a9df2..334a390c5 100644 --- a/roles/nextcloud/vars/Debian.yml +++ b/roles/nextcloud/vars/Debian.yml @@ -3,3 +3,7 @@ __nextcloud__required_packages: - 'jq' - '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 cef93f116..1dbf6c0d6 100644 --- a/roles/nextcloud/vars/RedHat.yml +++ b/roles/nextcloud/vars/RedHat.yml @@ -3,3 +3,7 @@ __nextcloud__required_packages: - 'jq' - '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/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..334a390c5 100644 --- a/roles/nextcloud/vars/Ubuntu.yml +++ b/roles/nextcloud/vars/Ubuntu.yml @@ -3,3 +3,7 @@ __nextcloud__required_packages: - 'jq' - '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/main.yml b/roles/nextcloud/vars/main.yml index 307dfea0b..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,23 +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: 'present' + state: 'absent' - name: 'php-process' # posix module for oc state: 'present' - name: 'php-redis' @@ -57,9 +63,9 @@ __nextcloud__php__modules__dependent_var: # no version branch is needed here. Debian: - name: 'php{{ __php__installed_version }}-apcu' - state: 'present' + 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' @@ -67,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' @@ -75,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' @@ -96,3 +102,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' 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/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.' 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/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..9fb811b40 100644 --- a/roles/redis/vars/Debian.yml +++ b/roles/redis/vars/Debian.yml @@ -1,6 +1,9 @@ __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 511c4ccd7..92288fa38 100644 --- a/roles/redis/vars/RedHat.yml +++ b/roles/redis/vars/RedHat.yml @@ -1,6 +1,11 @@ __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' 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)", 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 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/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/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..027384060 100644 --- a/roles/valkey/vars/Debian.yml +++ b/roles/valkey/vars/Debian.yml @@ -1,6 +1,9 @@ __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 ce3d2317e..bdf6a0e68 100644 --- a/roles/valkey/vars/RedHat.yml +++ b/roles/valkey/vars/RedHat.yml @@ -1,6 +1,11 @@ __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 ac0764469..027384060 100644 --- a/roles/valkey/vars/Ubuntu.yml +++ b/roles/valkey/vars/Ubuntu.yml @@ -1,6 +1,9 @@ __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'