diff --git a/CHANGELOG.md b/CHANGELOG.md index ccdc36cd2..ac14e880d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -**Highlights:** Apache Solr runs on Debian, Ubuntu and RHEL 10 and supports Solr 9.11. Hosts that deploy the OWASP Core Rule Set with apache_httpd need `apache_httpd__mod_security_coreruleset_version` in their inventory, since the role no longer picks a release on its own and verifies the download against a checksum. The `systemd_unit` role also manages socket units, so a host can forward a TCP port with `systemd-socket-proxyd` without installing a proxy server. +**Highlights:** Apache Solr runs on Debian, Ubuntu and RHEL 10 and supports Solr 9.11. Hosts that deploy the OWASP Core Rule Set with apache_httpd need `apache_httpd__mod_security_coreruleset_version` in their inventory, since the role no longer picks a release on its own and verifies the download against a checksum. The `systemd_unit` role also manages socket units, so a host can forward a TCP port with `systemd-socket-proxyd` without installing a proxy server. Hosts set up with `setup_basic` follow more of the CIS Benchmarks out of the box: root can no longer log in over SSH, USB storage is blocked, a sysctl hardening baseline is applied, and the cron configuration is readable by root only. Before updating, make sure Ansible logs in with an unprivileged user, and allow USB storage on hosts that need it. ### Breaking Changes +* **role:sshd, playbook:setup_basic**: `PermitRootLogin` defaults to `no`, as the CIS Benchmarks require. A run in which Ansible logs in as root aborts before sshd is changed, instead of locking Ansible out. Log in as an unprivileged user with sudo, or set `sshd__permit_root_login: 'prohibit-password'` for hosts that Ansible has to reach as root. +* **role:sshd**: sshd disconnects a client that has not answered for 30 seconds (`ClientAliveInterval 15`), as the CIS Benchmarks require. Set `sshd__client_alive_interval: 0` to keep idle sessions with an unresponsive client open. +* **role:kernel_modules**: The `usb-storage` kernel module is blocked by default, as the CIS Benchmarks require, which stops USB flash drives and USB disks after the next reboot. The run aborts on hosts with a USB disk attached, since a host booting from one would no longer boot. Add `kernel_modules__modules__host_var: [{name: 'usb-storage', enabled: true}]` to the inventory of hosts that need USB storage. +* **role:kernel_settings**: A sysctl hardening baseline following the CIS Benchmarks is applied by default: ICMP redirects are neither accepted nor sent, source-routed packets are dropped, martian packets are logged, unprivileged users can no longer read the kernel log or attach a debugger to processes they did not start themselves, and unprivileged BPF is off for good. Override single values in `kernel_settings__sysctl__host_var`, or remove them with `state: 'absent'`. * **role:postgresql_server**: Databases are created with the `C.UTF-8` locale instead of `en_US.UTF-8`, which failed on hosts with only the minimal glibc language pack. `C.UTF-8` sorts text by code point instead of by language rules. Existing databases are unaffected. To create new databases as before, set `lc_collate: 'en_US.UTF-8'` and `lc_ctype: 'en_US.UTF-8'` on their entries in `postgresql_server__databases__*_var`. * **role:postgresql_server, playbook:setup_mastodon**: `postgresql_server__version` is mandatory, and PostgreSQL is only installed from the PostgreSQL Yum Repository instead of optionally from the distribution packages. Set the major version in your inventory, for example `postgresql_server__version: '18'`. On a host that already runs PostgreSQL from the distribution packages, the run aborts before it installs anything; move the data to the PostgreSQL Yum Repository release first, as described in the role README. * **role:postgresql_server**: `postgresql_server__pg_hba_entries` is replaced by `postgresql_server__pg_hba_local_entries__host_var` / `__group_var` for `local` records and `postgresql_server__pg_hba_host_entries__host_var` / `__group_var` for all `host*` records. Both are merged with the role defaults instead of replacing them, and an entry with `state: 'absent'` removes a default. Move your `local` entries to the first variable without their `type` key, and all other entries to the second, where `address` is mandatory. Entries from the inventory are written before the role defaults. The run aborts as long as the old variable is set. @@ -50,6 +54,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +* **role:cron_permissions, playbook:cron_permissions, playbook:setup_basic**: Add a role and playbook that make `/etc/crontab`, `/etc/cron.deny` and the `/etc/cron.*` directories readable by root only on hosts with cron, as the CIS Benchmarks require. * **role:apache_tomcat**: With `lfops__remove_rpmnew_rpmsave`, the role also removes the `.rpmnew` and `.rpmsave` files next to the Tomcat configuration files it deploys. * **role:apache_tomcat**: Add support for Tomcat 10.1 on RHEL 10. * **role:mongodb**: Supports MongoDB 8.0 on Debian and Ubuntu, so MongoDB runs on Ubuntu 24.04, where upstream offers 8.0 only. @@ -72,7 +77,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * **role:duplicity**: The backup includes the data of the applications LFOps deploys by default: `/data`, `/srv`, `/var/lib/grafana`, `/var/lib/icinga2` (including the Icinga2 CA), `/var/lib/shiny-server`, `/var/lib/turn`, `/var/mail`, `/var/named`, `/var/solr/data`, `/var/spool/mail` and `/var/www` (without the repository mirrors). Hosts without these directories are not affected. On hosts with large data, for example VM images in `/data`, check the backup size or set the path to `state: 'absent'`. * **playbook:setup_basic**: Installs AIDE on every host, which checks file integrity twice a day and after every boot; skip it with `setup_basic__skip_aide`. * **role:aide, playbook:aide**: Add a role and playbook that install AIDE on Debian 12 and 13, RHEL 8, 9 and 10 and Ubuntu 22.04, 24.04 and 26.04 as the CIS benchmarks recommend, leaving `aidecheck.service` failed on any finding and keeping the database in step with `system_update` and `unattended-upgrades`. -* **role:lynis, playbook:lynis, playbook:setup_basic**: Add a role and playbook that install Lynis on every `setup_basic` host and audit it once a day for the lynis-logfile monitoring plugin, with findings accepted through `lynis__skip_tests` instead of a hand-edited `/etc/lynis/custom.prf`. +* **role:lynis, playbook:lynis, playbook:setup_basic**: Add a role and playbook that install Lynis on every `setup_basic` host and audit it once a day for the lynis-logfile monitoring plugin, with findings accepted through `lynis__skip_tests` instead of a hand-edited `/etc/lynis/custom.prf`. By default the role skips the tests whose suggestion LFOps deliberately does not follow, giving the reason for each in `custom.prf`. * **role:repo_monitoring_plugins**: `--tags repo_monitoring_plugins:remove` removes the repository and its signing key. * **role:grafana**: `grafana__preinstall_auto_update` controls whether Grafana updates its preinstalled plugins on every start. * **role:icingaweb2**: `icingaweb2__cookie_path` sets the path of the session and remember-me cookies, for example `/`. @@ -93,6 +98,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +* **role:mariadb_server**: The role no longer sets `sunrpc.tcp_slot_table_entries`, which only concerns NFS clients, and removes it from the kernel settings of hosts that got it from an earlier version. +* **role:kernel_modules**: Every blocked module is also blacklisted, so the CIS audit, which checks for both lines, accepts the configuration. +* **role:at**: On the Red Hat family, `/etc/at.deny` is readable by root only, as the CIS Benchmarks require. * **role:postgresql_server**: A changed `pg_hba.conf` or `postgresql_server__conf_password_encryption` reloads PostgreSQL instead of restarting it, so open connections are kept; the first run after the update still restarts it once. * **role:postgresql_server**: The configuration is checked before PostgreSQL is restarted, so a broken setting in `postgresql.conf` or `pg_hba.conf` aborts the run with the error message instead of leaving PostgreSQL down after the restart. * **role:postfix**: The SMTP greeting banner no longer names Postfix or the OS (` ESMTP LFOps`), which satisfies the Lynis check MAIL-8818. @@ -144,6 +152,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +* **role:kernel_settings**: A run no longer fails on hosts whose kernel settings still contain a `sunrpc.*` value from an earlier run while the `sunrpc` module is not loaded. * **role:postgresql_server**: `postgresql-dump` also saves the roles and tablespaces (`globals.sql.gz`), without which a restored database fails on every object owner, no longer skips the `postgres` database and databases whose name starts with `template`, and uses the client tools of the installed PostgreSQL version. * **role:postgresql_server**: Clients connecting to `localhost` are accepted over IPv6 as well, where they were rejected with `no pg_hba.conf entry for host "::1"`. * **module:bitwarden_item**: The module no longer edits the item and reports a change on every run when `collection_id` is set. diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index d6410f0cc..03eb361f0 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -31,6 +31,7 @@ Which Ansible role is proven to run on which OS? | collect_rpmnew_rpmsave | (x) | (x) | x | x | (x) | (x) | (x) | (x) | Fedora 40 | | core_dumps | x | x | x | x | x | x | x | x | | | coturn | | | x | x | (x) | | | | | +| cron_permissions | x | x | x | x | x | x | x | x | | | crypto_policy | | | x | x | x | | | | | | dnf_makecache | | | x | x | x | | | | | | dnf_versionlock | | | x | x | (x) | | | | | diff --git a/extensions/molecule/cron_permissions/converge.yml b/extensions/molecule/cron_permissions/converge.yml new file mode 100644 index 000000000..0e8e9cd45 --- /dev/null +++ b/extensions/molecule/cron_permissions/converge.yml @@ -0,0 +1,22 @@ +# The role only restricts an existing cron, so give every system under test one first, as a host +# set up by its distribution or by an application such as LibreNMS has. +- name: 'Install cron as the role expects to find it' + hosts: 'systems_under_test' + tasks: + + - name: 'Install cronie / cron' + ansible.builtin.package: + name: '{{ (ansible_facts["os_family"] == "RedHat") | ternary("cronie", "cron") }}' + state: 'present' + retries: 3 + delay: 10 + + - name: 'systemctl enable --now crond.service / cron.service' + ansible.builtin.service: + name: '{{ (ansible_facts["os_family"] == "RedHat") | ternary("crond", "cron") }}.service' + enabled: true + state: 'started' + + +- name: 'Converge cron_permissions playbook' + ansible.builtin.import_playbook: 'linuxfabrik.lfops.cron_permissions' diff --git a/extensions/molecule/cron_permissions/inventory/hosts.yml b/extensions/molecule/cron_permissions/inventory/hosts.yml new file mode 100644 index 000000000..d7117a843 --- /dev/null +++ b/extensions/molecule/cron_permissions/inventory/hosts.yml @@ -0,0 +1,16 @@ +# yamllint disable rule:empty-values + +lfops_cron_permissions: + children: + systems_under_test: + +systems_under_test: + hosts: + debian12-vm: + debian13-vm: + rocky8-vm: + rocky9-vm: + rocky10-vm: + ubuntu2204-vm: + ubuntu2404-vm: + ubuntu2604-vm: diff --git a/extensions/molecule/cron_permissions/molecule.yml b/extensions/molecule/cron_permissions/molecule.yml new file mode 100644 index 000000000..1e47cbff8 --- /dev/null +++ b/extensions/molecule/cron_permissions/molecule.yml @@ -0,0 +1 @@ +# Molecule scenario marker diff --git a/extensions/molecule/cron_permissions/verify.yml b/extensions/molecule/cron_permissions/verify.yml new file mode 100644 index 000000000..4029607fc --- /dev/null +++ b/extensions/molecule/cron_permissions/verify.yml @@ -0,0 +1,70 @@ +- name: 'Verify the cron_permissions playbook left a working, root-only cron' + hosts: 'systems_under_test' + gather_facts: false + tasks: + + # The daemon reads /etc/cron.d as root, so a job there has to run although the directory is + # 0700 now. + - name: 'Deploy /etc/cron.d/molecule-probe' + ansible.builtin.copy: + content: "* * * * * root touch /run/molecule-cron-probe\n" + dest: '/etc/cron.d/molecule-probe' + owner: 'root' + group: 'root' + mode: 0o644 + changed_when: false + + - name: 'Wait for the cron job to run' + ansible.builtin.wait_for: + path: '/run/molecule-cron-probe' + timeout: 150 + + - name: 'rm -f /etc/cron.d/molecule-probe /run/molecule-cron-probe' + ansible.builtin.file: + path: '{{ item }}' + state: 'absent' + loop: + - '/etc/cron.d/molecule-probe' + - '/run/molecule-cron-probe' + changed_when: false + + - name: 'runuser --user nobody -- ls /etc/cron.d' + ansible.builtin.command: 'runuser --user nobody -- ls /etc/cron.d' + changed_when: false + failed_when: false + register: '__molecule__cron_nobody_ls_result' + + - name: 'Assert that an unprivileged user can no longer list the system cron jobs' + ansible.builtin.assert: + that: + - '__molecule__cron_nobody_ls_result["rc"] != 0' + - '"Permission denied" in __molecule__cron_nobody_ls_result["stderr"]' + fail_msg: '{{ __molecule__cron_nobody_ls_result | to_json }}' + + # crontab(1) of an unprivileged user still has to work: it reads /etc/cron.deny, which is + # 0600 on the Red Hat family now. + - name: 'runuser --user nobody -- crontab -l' + ansible.builtin.command: 'runuser --user nobody -- crontab -l' + changed_when: false + failed_when: false + register: '__molecule__cron_nobody_crontab_result' + + - name: 'Assert that crontab answers an unprivileged user with "no crontab", not with a refusal' + ansible.builtin.assert: + that: + - '"no crontab for nobody" in __molecule__cron_nobody_crontab_result["stderr"]' + fail_msg: '{{ __molecule__cron_nobody_crontab_result | to_json }}' + + - name: 'stat /etc/crontab /etc/cron.d' + ansible.builtin.stat: + path: '{{ item }}' + loop: + - '/etc/crontab' + - '/etc/cron.d' + register: '__molecule__cron_stat_result' + + - name: 'Assert that /etc/crontab is 0600 and /etc/cron.d is 0700' + ansible.builtin.assert: + that: + - '__molecule__cron_stat_result["results"] | map(attribute="stat.mode") | list == ["0600", "0700"]' + fail_msg: '{{ __molecule__cron_stat_result["results"] | map(attribute="stat.mode") | list }}' diff --git a/extensions/molecule/inventory/group_vars/all.yml b/extensions/molecule/inventory/group_vars/all.yml index 9d8748878..320169529 100644 --- a/extensions/molecule/inventory/group_vars/all.yml +++ b/extensions/molecule/inventory/group_vars/all.yml @@ -27,3 +27,8 @@ __molecule__instance_name: >- # on its own. A pool in a directory no package owns keeps that grant, and the rootless VM # creation it buys, working across upgrades. vm-create.yml asserts that the pool exists. kvm_vm__pool: '{{ lookup("env", "LFOPS_TEST_POOL") | d("lfops-molecule", true) }}' + +# Molecule logs in to every system under test as root (host_vars/*-vm.yml). The sshd role +# defaults to `PermitRootLogin no` and refuses to deploy it while Ansible logs in as root, so +# the scenarios that run sshd keep key-based root logins. +sshd__permit_root_login: 'prohibit-password' diff --git a/extensions/molecule/kernel_settings/sysctl/inventory/group_vars/systems_under_test.yml b/extensions/molecule/kernel_settings/sysctl/inventory/group_vars/systems_under_test.yml index 264104ae2..422542e60 100644 --- a/extensions/molecule/kernel_settings/sysctl/inventory/group_vars/systems_under_test.yml +++ b/extensions/molecule/kernel_settings/sysctl/inventory/group_vars/systems_under_test.yml @@ -1,4 +1,8 @@ kernel_settings__sysctl__group_var: + # overrides the value of the hardening baseline, to check that an inventory entry replaces + # the default entry of the same name + - name: 'kernel.kptr_restrict' + value: 1 - name: 'sunrpc.tcp_slot_table_entries' value: 128 - name: 'vm.overcommit_memory' diff --git a/extensions/molecule/kernel_settings/sysctl/verify.yml b/extensions/molecule/kernel_settings/sysctl/verify.yml index 8598c77a9..0cd372187 100644 --- a/extensions/molecule/kernel_settings/sysctl/verify.yml +++ b/extensions/molecule/kernel_settings/sysctl/verify.yml @@ -50,3 +50,30 @@ ansible.builtin.command: 'tuned-adm verify --ignore-missing' become: true changed_when: false + + # after the reboot, so the values prove that TuneD applies the baseline at boot + - name: 'Load the hardening baseline of the kernel_settings role' + ansible.builtin.include_vars: + file: '{{ playbook_dir }}/../../../../roles/kernel_settings/vars/main.yml' + + - name: 'sysctl --values ' + ansible.builtin.command: 'sysctl --values {{ __kernel_settings__sysctl__hardening | map(attribute="name") | join(" ") }}' + changed_when: false + register: '__molecule__sysctl_values_result' + + - name: 'Assert that the kernel uses the hardening baseline, with the value the inventory overrides' + ansible.builtin.assert: + that: + - '__molecule__sysctl_values_result["stdout_lines"] == __molecule__expected' + fail_msg: '{{ __kernel_settings__sysctl__hardening | map(attribute="name") | zip(__molecule__sysctl_values_result["stdout_lines"]) | map("join", "=") | list }}' + vars: + # the baseline as name -> value, with the value the inventory overrides + __molecule__baseline: '{{ + dict(__kernel_settings__sysctl__hardening | map(attribute="name") + | zip(__kernel_settings__sysctl__hardening | map(attribute="value"))) + | combine({"kernel.kptr_restrict": 1}) + }}' + __molecule__expected: '{{ + __kernel_settings__sysctl__hardening | map(attribute="name") + | map("extract", __molecule__baseline) | map("string") | list + }}' diff --git a/extensions/molecule/setup_basic/verify.yml b/extensions/molecule/setup_basic/verify.yml index 8f4925bbf..aee9ba5f7 100644 --- a/extensions/molecule/setup_basic/verify.yml +++ b/extensions/molecule/setup_basic/verify.yml @@ -348,3 +348,153 @@ - '"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 }}' + + +# The CIS defaults of setup_basic, asked of the running system. On the Red Hat family the play +# above has rebooted the host by now, so the sysctl values prove that TuneD applies the +# hardening baseline at boot as well, not only while the role runs. +- name: 'Verify the CIS hardening defaults of setup_basic' + hosts: 'systems_under_test' + gather_facts: false + become: true + vars: + __molecule__sysctl_defaults: '{{ + __kernel_settings__sysctl__role_var + | linuxfabrik.lfops.combine_lod + | rejectattr("name", "eq", "drop") + | list + }}' + tasks: + + - name: 'sshd -T' + ansible.builtin.command: 'sshd -T' + changed_when: false + register: '__molecule__sshd_effective_config_result' + + # permitrootlogin comes from the shared Molecule inventory, which keeps root logins with a + # key for Molecule itself; it proves the variable reaches sshd. + - name: 'Assert that sshd runs with the hardened options' + ansible.builtin.assert: + that: + - '"clientalivecountmax 2" in __molecule__sshd_effective_config_result["stdout_lines"]' + - '"clientaliveinterval 15" in __molecule__sshd_effective_config_result["stdout_lines"]' + - '"permitrootlogin without-password" in __molecule__sshd_effective_config_result["stdout_lines"] + or "permitrootlogin prohibit-password" in __molecule__sshd_effective_config_result["stdout_lines"]' + fail_msg: '{{ __molecule__sshd_effective_config_result["stdout_lines"] | select("search", "^(clientalive|permitrootlogin)") | list }}' + + - name: 'Gather the distribution facts' + ansible.builtin.setup: + gather_subset: + - '!all' + + # the role's defaults as the role computes them: the baseline plus the platform values + - name: 'Load the sysctl defaults of the kernel_settings role' + ansible.builtin.include_vars: + file: '{{ playbook_dir }}/../../../roles/kernel_settings/vars/{{ item }}' + loop: + - 'main.yml' + - '{{ ansible_facts["distribution"] }}{{ ansible_facts["distribution_major_version"] }}.yml' + when: '(playbook_dir ~ "/../../../roles/kernel_settings/vars/" ~ item) is file' + + - name: 'sysctl --values ' + ansible.builtin.command: 'sysctl --values {{ __molecule__sysctl_defaults | map(attribute="name") | join(" ") }}' + changed_when: false + register: '__molecule__sysctl_values_result' + + - name: 'Assert that the running kernel uses every value of the kernel_settings defaults' + ansible.builtin.assert: + that: + - '__molecule__sysctl_values_result["stdout_lines"] == __molecule__sysctl_defaults | map(attribute="value") | map("string") | list' + fail_msg: '{{ __molecule__sysctl_defaults | map(attribute="name") | zip(__molecule__sysctl_values_result["stdout_lines"]) | map("join", "=") | list }}' + + # `install usb-storage /bin/true` makes modprobe run /bin/true instead of loading the module. + - name: 'modprobe usb-storage' + ansible.builtin.command: 'modprobe usb-storage' + changed_when: false + + - name: 'slurp /proc/modules' + ansible.builtin.slurp: + src: '/proc/modules' + register: '__molecule__proc_modules_result' + + - name: 'modprobe --showconfig' + ansible.builtin.command: 'modprobe --showconfig' + changed_when: false + register: '__molecule__modprobe_showconfig_result' + + - name: 'Assert that usb-storage cannot be loaded and carries both lines the CIS audit asks for' + ansible.builtin.assert: + that: + - '"usb_storage " not in (__molecule__proc_modules_result["content"] | ansible.builtin.b64decode)' + - '"blacklist usb_storage" in __molecule__modprobe_showconfig_result["stdout_lines"]' + - '"install usb_storage /bin/true" in __molecule__modprobe_showconfig_result["stdout_lines"]' + + # cron_permissions neither installs nor starts cron, so this only applies where the image + # brings it along. cron reads /etc/cron.d as root, so a job there has to run although the + # directory is 0700. + - name: 'Gather the service facts' + ansible.builtin.service_facts: # yamllint disable-line rule:empty-values + + - name: 'Verify that a cron job runs although /etc/cron.d is 0700' + when: + - '"crond.service" in ansible_facts["services"] or "cron.service" in ansible_facts["services"]' + block: + + - name: 'Deploy /etc/cron.d/molecule-probe' + ansible.builtin.copy: + content: "* * * * * root touch /run/molecule-cron-probe\n" + dest: '/etc/cron.d/molecule-probe' + owner: 'root' + group: 'root' + mode: 0o644 + changed_when: false + + - name: 'Wait for the cron job to run' + ansible.builtin.wait_for: + path: '/run/molecule-cron-probe' + timeout: 150 + + - name: 'rm -f /etc/cron.d/molecule-probe /run/molecule-cron-probe' + ansible.builtin.file: + path: '{{ item }}' + state: 'absent' + loop: + - '/etc/cron.d/molecule-probe' + - '/run/molecule-cron-probe' + changed_when: false + + # Lynis has to accept the LFOps defaults: either they satisfy it, or roles/lynis skips the + # check with a reason. + # Lynis looks for each test ID as a substring of --tests (Register() in include/functions). + # Before 3.1.6 it rejects a space in any argument (SafeInput()), as the release on Debian 13 + # does, so the IDs are separated by a slash. + - name: 'lynis audit system --quick --tests FILE-7524/KRNL-6000/SSH-7408' + ansible.builtin.command: 'lynis audit system --quick --no-colors --tests FILE-7524/KRNL-6000/SSH-7408' + args: + chdir: '/' + changed_when: false + register: '__molecule__lynis_cis_audit_result' + + - name: 'cat /var/log/lynis-report.dat' + ansible.builtin.command: 'cat /var/log/lynis-report.dat' + changed_when: false + register: '__molecule__lynis_cis_report_result' + + - name: 'Assert that Lynis accepts the sshd, file permission and sysctl defaults' + ansible.builtin.assert: + that: + - '__molecule__lynis_cis_report_result["stdout_lines"] + | select("match", "suggestion") | select("search", "=(FILE-7524|SSH-7408)[|]") + | list | length == 0' + - '__molecule__lynis_kernel_fields + | intersect(__molecule__sysctl_defaults | map(attribute="name") | list) + | length == 0' + fail_msg: '{{ __molecule__lynis_cis_report_result["stdout_lines"] | select("search", "=(FILE-7524|KRNL-6000|SSH-7408)[|]") | list }}' + vars: + # details[]=KRNL-6000|sysctl|desc:...;field:;prefval:...;value:...;| + __molecule__lynis_kernel_fields: '{{ + __molecule__lynis_cis_report_result["stdout_lines"] + | select("match", "details") | select("search", "=KRNL-6000[|]") + | map("regex_replace", "^.*;field:([^;]+);.*$", "\\1") + | list + }}' diff --git a/playbooks/README.md b/playbooks/README.md index 205dfa261..d57ea309e 100644 --- a/playbooks/README.md +++ b/playbooks/README.md @@ -199,6 +199,13 @@ Calls the following roles (in order): * [coturn](https://github.com/Linuxfabrik/lfops/tree/main/roles/coturn) +## cron_permissions.yml + +Calls the following roles (in order): + +* [cron_permissions](https://github.com/Linuxfabrik/lfops/tree/main/roles/cron_permissions) + + ## crypto_policy.yml Calls the following roles (in order): @@ -1096,7 +1103,7 @@ Calls the following roles (in order): ## setup_basic.yml -Before the first role changes the host, the playbook validates the variables of every role it runs, checks that the Firewall Builder repository has a compiled firewall for the host, and that the Icinga2 master is reachable. A missing prerequisite aborts the run there, while the host is still reachable with the bootstrap user. +Before the first role changes the host, the playbook validates the variables of every role it runs, checks that the Firewall Builder repository has a compiled firewall for the host, that the Icinga2 master is reachable, and that the new `sshd_config` still admits the user Ansible logs in as. A missing prerequisite aborts the run there, while the host is still reachable with the bootstrap user. Calls the following roles (in order): @@ -1126,6 +1133,7 @@ Calls the following roles (in order): * [chrony](https://github.com/Linuxfabrik/lfops/tree/main/roles/chrony): `setup_basic__skip_chrony` * [motd](https://github.com/Linuxfabrik/lfops/tree/main/roles/motd): `setup_basic__skip_motd` * [at](https://github.com/Linuxfabrik/lfops/tree/main/roles/at): `setup_basic__skip_at` +* [cron_permissions](https://github.com/Linuxfabrik/lfops/tree/main/roles/cron_permissions): `setup_basic__skip_cron_permissions` * [python](https://github.com/Linuxfabrik/lfops/tree/main/roles/python): `setup_basic__skip_python`, `setup_basic__skip_python_venv` * [glances](https://github.com/Linuxfabrik/lfops/tree/main/roles/glances): `setup_basic__skip_glances` * [tools](https://github.com/Linuxfabrik/lfops/tree/main/roles/tools): `setup_basic__skip_tools` diff --git a/playbooks/all.yml b/playbooks/all.yml index 64a340dfe..20bbb544a 100644 --- a/playbooks/all.yml +++ b/playbooks/all.yml @@ -20,6 +20,7 @@ - import_playbook: 'collabora.yml' - import_playbook: 'core_dumps.yml' - import_playbook: 'coturn.yml' +- import_playbook: 'cron_permissions.yml' - import_playbook: 'crypto_policy.yml' - import_playbook: 'dnf_makecache.yml' - import_playbook: 'dnf_versionlock.yml' diff --git a/playbooks/cron_permissions.yml b/playbooks/cron_permissions.yml new file mode 100644 index 000000000..17d892341 --- /dev/null +++ b/playbooks/cron_permissions.yml @@ -0,0 +1,36 @@ +- name: 'Playbook linuxfabrik.lfops.cron_permissions' + hosts: + - 'lfops_cron_permissions' + force_handlers: true + + pre_tasks: + - ansible.builtin.import_role: + name: 'shared' + tasks_from: 'log-start.yml' + tags: + - 'always' + + - ansible.builtin.import_role: + name: 'shared' + tasks_from: 'global-variables.yml' + tags: + - 'always' + + + roles: + + - role: 'linuxfabrik.lfops.cron_permissions' + + + post_tasks: + - ansible.builtin.import_role: + name: 'shared' + tasks_from: 'log-end.yml' + tags: + - 'always' + + - ansible.builtin.import_role: + name: 'shared' + tasks_from: 'print-messages.yml' + tags: + - 'always' diff --git a/playbooks/setup_basic.yml b/playbooks/setup_basic.yml index 35625b7cd..d58c81518 100644 --- a/playbooks/setup_basic.yml +++ b/playbooks/setup_basic.yml @@ -61,6 +61,8 @@ skip: '{{ setup_basic__skip_motd | d(false) }}' - name: 'at' skip: '{{ setup_basic__skip_at | d(false) }}' + - name: 'cron_permissions' + skip: '{{ setup_basic__skip_cron_permissions | d(false) }}' - name: 'python' skip: '{{ setup_basic__skip_python | d(false) }}' - name: 'python_venv' @@ -143,6 +145,15 @@ - 'icinga2_agent' - 'icinga2_agent:node_setup' + - name: 'Run the preflight checks of the sshd role' + ansible.builtin.include_role: + name: 'linuxfabrik.lfops.sshd' + tasks_from: 'preflight.yml' + when: + - 'not setup_basic__skip_sshd | d(false)' + tags: + - 'sshd' + roles: @@ -293,6 +304,10 @@ when: - 'not setup_basic__skip_at | d(false)' + - role: 'linuxfabrik.lfops.cron_permissions' + when: + - 'not setup_basic__skip_cron_permissions | d(false)' + - role: 'linuxfabrik.lfops.python' when: - 'not setup_basic__skip_python | d(false)' diff --git a/roles/at/README.md b/roles/at/README.md index 01a12d786..a4080deff 100644 --- a/roles/at/README.md +++ b/roles/at/README.md @@ -6,11 +6,17 @@ This role installs at, a daemon that allows commands to be run at a specified ti *Available since LFOps `2.0.0`.* +## How the Role Behaves + +* On the Red Hat family, `/etc/at.deny` gets mode `0600`, owned by `root:root`, as the CIS Benchmarks require. On Debian and Ubuntu it keeps the `root:daemon` `0640` the package ships, since `at` reads it as group `daemon` there. + + ## Tags `at` * Installs and configures at/atd. +* Restricts the mode of `/etc/at.deny`. * Triggers: none. `at:state` diff --git a/roles/at/tasks/main.yml b/roles/at/tasks/main.yml index 9d77d9574..76c6cfc5b 100644 --- a/roles/at/tasks/main.yml +++ b/roles/at/tasks/main.yml @@ -1,3 +1,11 @@ +- name: 'Set platform/version specific variables' + ansible.builtin.import_role: + name: 'shared' + tasks_from: 'platform-variables.yml' + tags: + - 'always' + + - name: 'Install at' ansible.builtin.package: name: 'at' @@ -8,6 +16,35 @@ - 'at' +- block: + + - name: 'stat {{ item["path"] }}' + ansible.builtin.stat: + path: '{{ item["path"] }}' + loop: '{{ __at__paths }}' + loop_control: + label: '{{ item["path"] }}' + register: '__at__paths_stat_result' + + # A package update may reset the mode, so every run sets it again. + - name: 'chmod {{ item["mode"] | replace("0o", "") }} {{ item["path"] }}' + ansible.builtin.file: + path: '{{ item["path"] }}' + owner: 'root' + group: 'root' + mode: '{{ item["mode"] }}' + loop: '{{ __at__paths }}' + loop_control: + label: '{{ item["path"] }}' + when: + - 'item["path"] in __at__existing_paths' + vars: + __at__existing_paths: '{{ __at__paths_stat_result["results"] | selectattr("stat.exists") | map(attribute="item.path") | list }}' + + tags: + - 'at' + + - block: - name: 'systemctl {{ at__service_enabled | bool | ternary("enable", "disable") }} atd.service' diff --git a/roles/at/vars/Debian.yml b/roles/at/vars/Debian.yml new file mode 100644 index 000000000..218c4da83 --- /dev/null +++ b/roles/at/vars/Debian.yml @@ -0,0 +1,4 @@ +# at(1) is setuid and setgid daemon on Debian and Ubuntu and reads /etc/at.deny as group daemon, +# so the root:daemon 0640 the package ships stays. The CIS Debian Linux 12 Benchmark v1.0.1 +# (2.4.2.1) accepts it. Verified with at on Debian 12 and 13 and Ubuntu 22.04, 24.04 and 26.04. +__at__paths: [] diff --git a/roles/at/vars/RedHat.yml b/roles/at/vars/RedHat.yml new file mode 100644 index 000000000..27b376156 --- /dev/null +++ b/roles/at/vars/RedHat.yml @@ -0,0 +1,6 @@ +# at(1) is setuid root on the Red Hat family, so /etc/at.deny only needs to be readable by root. +# The CIS Red Hat Enterprise Linux 10 Benchmark v1.0.1 (2.4.2.1) asks for 0640 or stricter, +# Lynis (FILE-7524) for 0600. Verified with at on Rocky 8, 9 and 10. +__at__paths: + - path: '/etc/at.deny' + mode: 0o600 # upstream default: 0o644 diff --git a/roles/at/vars/Ubuntu.yml b/roles/at/vars/Ubuntu.yml new file mode 100644 index 000000000..218c4da83 --- /dev/null +++ b/roles/at/vars/Ubuntu.yml @@ -0,0 +1,4 @@ +# at(1) is setuid and setgid daemon on Debian and Ubuntu and reads /etc/at.deny as group daemon, +# so the root:daemon 0640 the package ships stays. The CIS Debian Linux 12 Benchmark v1.0.1 +# (2.4.2.1) accepts it. Verified with at on Debian 12 and 13 and Ubuntu 22.04, 24.04 and 26.04. +__at__paths: [] diff --git a/roles/cron_permissions/README.md b/roles/cron_permissions/README.md new file mode 100644 index 000000000..946cacc5e --- /dev/null +++ b/roles/cron_permissions/README.md @@ -0,0 +1,32 @@ +# Ansible Role linuxfabrik.lfops.cron_permissions + +This role restricts the system-wide cron configuration to root, as the CIS Benchmarks require for hosts on which cron is installed. It neither installs nor starts cron: LFOps schedules its own jobs with systemd timers. + + +*Available in the next LFOps release.* + + +## How the Role Behaves + +* `/etc/crontab` and `/etc/cron.deny` get mode `0600`, and `/etc/cron.d`, `/etc/cron.hourly`, `/etc/cron.daily`, `/etc/cron.weekly`, `/etc/cron.monthly` and `/etc/cron.yearly` get mode `0700`, all owned by `root:root`. Paths that do not exist are left alone: on a host without cron the role changes nothing, and not every platform ships every path (for example `/etc/cron.yearly` on the Red Hat family, `/etc/cron.deny` on Debian and Ubuntu). +* Unprivileged users can no longer list the system cron jobs. The cron daemon reads these paths as root, and `crontab` only reads `/etc/cron.deny`, as root (it is setuid root on the Red Hat family), so both keep working. +* A package update may restore the modes the package ships. The next run of this role sets them again. +* The role does not manage cron jobs, and it does not create `/etc/cron.allow`, so every user may still keep a personal crontab. + + +## Tags + +`cron_permissions` + +* Restricts the modes of the system-wide cron configuration. +* Triggers: none. + + +## License + +[The Unlicense](https://unlicense.org/) + + +## Author Information + +[Linuxfabrik GmbH, Zurich](https://www.linuxfabrik.ch) diff --git a/roles/cron_permissions/tasks/main.yml b/roles/cron_permissions/tasks/main.yml new file mode 100644 index 000000000..6c9cd6503 --- /dev/null +++ b/roles/cron_permissions/tasks/main.yml @@ -0,0 +1,30 @@ +- block: + + # The role neither installs nor starts cron: LFOps schedules its jobs with systemd timers, and + # CIS 2.4.1 only applies where cron is installed. A path that does not exist (no cron on the + # host, or not shipped by the platform) is left alone. + - name: 'stat {{ item["path"] }}' + ansible.builtin.stat: + path: '{{ item["path"] }}' + loop: '{{ __cron_permissions__paths }}' + loop_control: + label: '{{ item["path"] }}' + register: '__cron_permissions__paths_stat_result' + + # A package update may reset the mode of the paths the package owns, so every run sets it again. + - name: 'chmod {{ item["mode"] | replace("0o", "") }} {{ item["path"] }}' + ansible.builtin.file: + path: '{{ item["path"] }}' + owner: 'root' + group: 'root' + mode: '{{ item["mode"] }}' + loop: '{{ __cron_permissions__paths }}' + loop_control: + label: '{{ item["path"] }}' + when: + - 'item["path"] in __cron_permissions__existing_paths' + vars: + __cron_permissions__existing_paths: '{{ __cron_permissions__paths_stat_result["results"] | selectattr("stat.exists") | map(attribute="item.path") | list }}' + + tags: + - 'cron_permissions' diff --git a/roles/cron_permissions/vars/main.yml b/roles/cron_permissions/vars/main.yml new file mode 100644 index 000000000..146ad7ee3 --- /dev/null +++ b/roles/cron_permissions/vars/main.yml @@ -0,0 +1,27 @@ +# The cron configuration that only root needs to read, with the mode the CIS Benchmarks ask for +# (CIS Red Hat Enterprise Linux 10 Benchmark v1.0.1, 2.4.1.2 to 2.4.1.9; the CIS Debian Linux 12 +# Benchmark v1.0.1 asks for the same). Lynis (FILE-7524) asks for the same modes. The cron +# daemons read these paths as root. crontab(1), which reads /etc/cron.deny, is setuid root on +# the Red Hat family; Debian and Ubuntu ship no /etc/cron.deny. +# Not every platform ships every path (no /etc/cron.yearly on the Red Hat family and Ubuntu +# 22.04, no /etc/cron.deny on Debian and Ubuntu), and a host may have no cron at all, so the role +# only changes the ones that exist. +# Verified with cronie on Rocky 8, 9 and 10 and cron on Debian 12 and 13 and Ubuntu 22.04, +# 24.04 and 26.04. +__cron_permissions__paths: + - path: '/etc/cron.d' + mode: 0o700 # upstream default: 0o755 + - path: '/etc/cron.daily' + mode: 0o700 # upstream default: 0o755 + - path: '/etc/cron.deny' + mode: 0o600 # upstream default: 0o644 + - path: '/etc/cron.hourly' + mode: 0o700 # upstream default: 0o755 + - path: '/etc/cron.monthly' + mode: 0o700 # upstream default: 0o755 + - path: '/etc/cron.weekly' + mode: 0o700 # upstream default: 0o755 + - path: '/etc/cron.yearly' + mode: 0o700 # upstream default: 0o755 + - path: '/etc/crontab' + mode: 0o600 # upstream default: 0o644 diff --git a/roles/kernel_modules/README.md b/roles/kernel_modules/README.md index 91ed19480..e3699dd38 100644 --- a/roles/kernel_modules/README.md +++ b/roles/kernel_modules/README.md @@ -8,11 +8,12 @@ This role disables kernel modules by deploying `/etc/modprobe.d/linuxfabrik-kern ## How the Role Behaves -* For each module, the role writes an `install /bin/true` line. This prevents the module from being loaded, both automatically (e.g. on device hotplug) and via a manual `modprobe`. This is stronger than `blacklist`, which only prevents automatic loading. -* By default the role disables the modules that the CIS Benchmarks recommend disabling and that are safe to disable on a typical server: the FireWire storage stack (`firewire-core`, `firewire-ohci`, `firewire-sbp2`), the legacy / obscure filesystems `cramfs`, `freevxfs`, `hfs`, `hfsplus` and `jffs2`, and the uncommon network protocols `atm`, `can`, `dccp`, `rds`, `sctp` and `tipc`. +* For each module, the role writes a `blacklist ` and an `install /bin/true` line, the pair the CIS Benchmarks check for. The `install` line prevents the module from being loaded, both automatically (e.g. on device hotplug) and via a manual `modprobe`; `blacklist` alone only prevents loading by alias. +* By default the role disables the modules that the CIS Benchmarks recommend disabling and that are safe to disable on a typical server: the FireWire storage stack (`firewire-core`, `firewire-ohci`, `firewire-sbp2`), USB mass storage (`usb-storage`), the legacy / obscure filesystems `cramfs`, `freevxfs`, `hfs`, `hfsplus` and `jffs2`, and the uncommon network protocols `atm`, `can`, `dccp`, `rds`, `sctp` and `tipc`. * On top of that, the role disables rarely used code that an unprivileged user can get loaded, directly or through a user namespace, and that has a history of local privilege escalations: IPsec AH (`ah4`, `ah6`) and the legacy `af_key`, `bluetooth`, the CAN protocols (`can-bcm`, `can-gw`, `can-isotp`, `can-j1939`, `can-raw`), IEEE 802.15.4 radio (`6lowpan`, `ieee802154`, `ieee802154_socket`), kernel L2TP (`l2tp_*`), MPLS (`mpls_iptunnel`, `mpls_router`), the TTY line disciplines `n_gsm` and `n_hdlc`, `pppoe`, `pptp`, `sctp_diag` and `tun`. This stops Bluetooth on laptops and workstations, L2TP/IPsec, PPPoE and PPTP connections, and IPsec setups using AH; re-enable what a host needs. OpenVPN, WireGuard, IPsec with ESP and other PPP users such as openfortivpn keep working. `defaults/main.yml` names the impact of each module. * Blocking `tun` stops OpenVPN, WireGuard in userspace, rootless Podman and Docker networking (pasta, slirp4netns), libvirt VM networking and containers that use `/dev/net/tun`. Rootful Docker and Podman with bridge networking keep working. The role does not detect these workloads: set `enabled: true` for `tun` in the inventory of every host that needs it, for example as `kernel_modules__modules__host_var` on every [OpenVPN server](https://github.com/Linuxfabrik/lfops/tree/main/roles/openvpn_server) or [KVM host](https://github.com/Linuxfabrik/lfops/tree/main/roles/kvm_host). On a host where `tun` is loaded, the block takes effect with the reboot the role requests, so the workload keeps running until then. -* Some modules that CIS also lists are **not** disabled by default, because doing so would break common workloads: `overlay` (used by Docker / Podman), `squashfs` (used by snap on Ubuntu and by live / appliance images), `udf` (mounting DVDs / UDF images) and `usb-storage` (USB flash drives). Disable any of these explicitly where wanted. +* Blocking `usb-storage` stops USB flash drives and USB disks, including UAS disks, since `uas` depends on it. dracut copies `/etc/modprobe.d` into the initramfs, so a host that boots from a USB disk or an SD card reader would no longer boot after the next kernel update. The role therefore aborts before it writes the configuration if a USB disk is attached while `usb-storage` is to be blocked. Set `enabled: true` for `usb-storage` on such hosts. +* Some modules that the CIS Benchmarks list for Level 2 are **not** disabled by default, because doing so would break common workloads: `overlay` (used by Docker / Podman), `squashfs` (used by snap on Ubuntu and by live / appliance images) and `udf` (mounting DVDs / UDF images). Disable any of these explicitly where wanted. * A module that is already loaded when the role runs stays loaded until the next reboot. When the configuration changed and at least one of the blocked modules is loaded, the role requests a reboot at the next maintenance window through the [schedule_reboot](https://github.com/Linuxfabrik/lfops/tree/main/roles/schedule_reboot) mechanism (spool entry `kernel_modules`); without that mechanism it only prints a message and leaves the reboot to the operator. Unloading the module by hand with `modprobe -r` applies the change without a reboot. A host that has none of the blocked modules loaded is already in the target state and gets no request, which is why deploying the defaults to a fresh host does not reboot it. * To re-enable a module that the role disables by default, set its `enabled` to `true` in your inventory. * Only the run that changes the configuration requests the reboot. A later run finds the file already correct and stays quiet, even while the modules are still loaded and the reboot is still pending. @@ -41,14 +42,15 @@ Any [LFOps playbook](https://github.com/Linuxfabrik/lfops/blob/main/playbooks/RE * List of kernel modules to manage. Each item has a `name` and an optional `enabled`: `false` (the default; the module is blocked from loading) or `true` (the module is left loadable, e.g. to override a module the role disables by default). * Type: List of dictionaries. -* Default: `6lowpan`, `af_key`, `ah4`, `ah6`, `atm`, `bluetooth`, `can`, `can-bcm`, `can-gw`, `can-isotp`, `can-j1939`, `can-raw`, `cramfs`, `dccp`, `firewire-core`, `firewire-ohci`, `firewire-sbp2`, `freevxfs`, `hfs`, `hfsplus`, `ieee802154`, `ieee802154_socket`, `jffs2`, `l2tp_core`, `l2tp_eth`, `l2tp_ip`, `l2tp_ip6`, `l2tp_netlink`, `l2tp_ppp`, `mpls_iptunnel`, `mpls_router`, `n_gsm`, `n_hdlc`, `pppoe`, `pptp`, `rds`, `sctp`, `sctp_diag`, `tipc` and `tun` are disabled. +* Default: `6lowpan`, `af_key`, `ah4`, `ah6`, `atm`, `bluetooth`, `can`, `can-bcm`, `can-gw`, `can-isotp`, `can-j1939`, `can-raw`, `cramfs`, `dccp`, `firewire-core`, `firewire-ohci`, `firewire-sbp2`, `freevxfs`, `hfs`, `hfsplus`, `ieee802154`, `ieee802154_socket`, `jffs2`, `l2tp_core`, `l2tp_eth`, `l2tp_ip`, `l2tp_ip6`, `l2tp_netlink`, `l2tp_ppp`, `mpls_iptunnel`, `mpls_router`, `n_gsm`, `n_hdlc`, `pppoe`, `pptp`, `rds`, `sctp`, `sctp_diag`, `tipc`, `tun` and `usb-storage` are disabled. Example: ```yaml # optional kernel_modules__modules__group_var: - # additionally disable USB storage ('enabled: false' is the default and can be omitted) - - name: 'usb-storage' + # additionally disable the overlay filesystem ('enabled: false' is the default and can be + # omitted) + - name: 'overlay' enabled: false # re-enable SCTP, which the role disables by default - name: 'sctp' diff --git a/roles/kernel_modules/defaults/main.yml b/roles/kernel_modules/defaults/main.yml index 93408fbc1..eab3c0afc 100644 --- a/roles/kernel_modules/defaults/main.yml +++ b/roles/kernel_modules/defaults/main.yml @@ -142,3 +142,7 @@ kernel_modules__modules__role_var: # keep working. Re-enable it in the inventory of hosts that need it. - name: 'tun' enabled: false + # CIS. USB mass storage. Breaks: USB flash drives and USB disks, and booting from a USB + # or SD card reader (the role refuses to block it while such a disk is attached). + - name: 'usb-storage' + enabled: false diff --git a/roles/kernel_modules/tasks/main.yml b/roles/kernel_modules/tasks/main.yml index 4bf90013c..fda488ac2 100644 --- a/roles/kernel_modules/tasks/main.yml +++ b/roles/kernel_modules/tasks/main.yml @@ -17,6 +17,30 @@ ansible.builtin.debug: var: 'kernel_modules__modules__combined_var' + # dracut copies /etc/modprobe.d into the initramfs, so blocking usb-storage on a host that + # boots from a USB disk or an SD card reader makes it unbootable after the next kernel update. + # uas depends on usb-storage, so UAS disks are affected as well. + - name: 'lsblk --nodeps --noheadings --raw --output TRAN,NAME' + ansible.builtin.command: 'lsblk --nodeps --noheadings --raw --output TRAN,NAME' + register: '__kernel_modules__lsblk_result' + changed_when: false # read-only command, never changes anything + check_mode: false # also run in check mode + when: + - '"usb_storage" in __kernel_modules__blocked_module_names | map("replace", "-", "_") | list' + + - name: 'Assert that no USB disk is attached while usb-storage gets blocked' + ansible.builtin.assert: + that: + - '__kernel_modules__lsblk_result["stdout_lines"] | select("match", "usb ") | list | length == 0' + quiet: true + fail_msg: >- + usb-storage is to be blocked, but the host has USB disks attached + ({{ __kernel_modules__lsblk_result["stdout_lines"] | select("match", "usb ") | map("split", " ") | map("last") | join(", ") }}). + If the host boots from one of them, it would no longer boot. Set `enabled: true` for + usb-storage in kernel_modules__modules__host_var, or detach the disks. + when: + - '__kernel_modules__lsblk_result is not skipped' + # An `install /bin/true` line prevents the module from being loaded, # both automatically and via a manual `modprobe`. Already-loaded modules stay # loaded until the next reboot (see README). diff --git a/roles/kernel_modules/templates/etc/modprobe.d/linuxfabrik-kernel-modules.conf.j2 b/roles/kernel_modules/templates/etc/modprobe.d/linuxfabrik-kernel-modules.conf.j2 index 78fa638a0..ac276961f 100644 --- a/roles/kernel_modules/templates/etc/modprobe.d/linuxfabrik-kernel-modules.conf.j2 +++ b/roles/kernel_modules/templates/etc/modprobe.d/linuxfabrik-kernel-modules.conf.j2 @@ -1,6 +1,7 @@ # {{ ansible_managed }} -# 2026060601 +# 2026101601 {% for item in kernel_modules__modules__combined_var if not (item['enabled'] | d(false) | bool) %} +blacklist {{ item['name'] }} install {{ item['name'] }} /bin/true {% endfor %} diff --git a/roles/kernel_settings/README.md b/roles/kernel_settings/README.md index e6f40ed29..3b6b1b31f 100644 --- a/roles/kernel_settings/README.md +++ b/roles/kernel_settings/README.md @@ -10,6 +10,10 @@ The role does nothing on its own and relies on the [linux_system_roles.kernel_se ## How the Role Behaves +* By default the role applies a sysctl hardening baseline that follows the CIS Benchmarks: `kernel.dmesg_restrict`, `kernel.kptr_restrict` and `kernel.yama.ptrace_scope` restrict kernel information and process tracing to root and parent processes, ICMP redirects are neither accepted nor sent, source-routed packets are dropped, and martian packets are logged. On top of that, BPF programs are hardened for every user and unprivileged `bpf()` is switched off for good. The full list with the reasoning is in `vars/main.yml`. Forwarding, strict reverse path filtering and IPv6 router advertisements are left alone, since changing them breaks container hosts, routers, asymmetric routing and SLAAC. +* With `kernel.yama.ptrace_scope = 1`, a process without `CAP_SYS_PTRACE` can only attach to its own descendants, so an unprivileged user can no longer run `gdb -p` or `strace -p` against a running process of its own. root is not affected. +* TuneD applies `/etc/sysctl.d/*.conf` and `/etc/sysctl.conf` again after its profile (`reapply_sysctl` in `/etc/tuned/tuned-main.conf`), so a value set there wins over the same key set by this role, and TuneD's verification of the role's value fails. Set such values through this role instead. Ubuntu 22.04 and 24.04 ship `kernel.kptr_restrict = 1` in `/etc/sysctl.d/10-kernel-hardening.conf`, which is why the baseline uses `1` there instead of `2`. +* `kernel.unprivileged_bpf_disabled = 1` cannot be changed again on a running kernel. Overriding it in the inventory, or removing it with `state: 'absent'`, takes effect with the next reboot. * If any `sunrpc.*` setting is configured (the mariadb_server role sets `sunrpc.tcp_slot_table_entries`), the role loads the `sunrpc` kernel module and lists it in `/etc/modules-load.d/sunrpc.conf`, since the `sunrpc.*` settings only exist while the module is loaded and nothing else loads it at boot on a host without NFS. `options sunrpc` lines in `/etc/modprobe.d/` are commented out in the process. The file stays in place when the `sunrpc.*` settings are removed later. * On Ubuntu 22.04 the role removes `kernel.sched_min_granularity_ns` and `kernel.sched_wakeup_granularity_ns` from the TuneD profile it builds on (a `drop` entry in its own profile). The TuneD release of Ubuntu 22.04 sets them although its 5.15 kernel has neither, and TuneD's own verification would otherwise fail on every run. @@ -59,21 +63,83 @@ These variables are intended to be used in a host / group variable file in the A * sysctl settings. Entries are identified by `name`, so an entry from the inventory is merged into the default entry of the same name. * Type: List of dictionaries. -* Default: on Ubuntu 22.04 +* Default: the hardening baseline from `vars/main.yml`: + + ```yaml + - name: 'fs.protected_hardlinks' + value: 1 + - name: 'fs.protected_symlinks' + value: 1 + - name: 'kernel.dmesg_restrict' + value: 1 + - name: 'kernel.kptr_restrict' + value: 2 + - name: 'kernel.randomize_va_space' + value: 2 + - name: 'kernel.unprivileged_bpf_disabled' + value: 1 + - name: 'kernel.yama.ptrace_scope' + value: 1 + - name: 'net.core.bpf_jit_harden' + value: 2 + - name: 'net.ipv4.conf.all.accept_redirects' + value: 0 + - name: 'net.ipv4.conf.all.accept_source_route' + value: 0 + - name: 'net.ipv4.conf.all.log_martians' + value: 1 + - name: 'net.ipv4.conf.all.secure_redirects' + value: 0 + - name: 'net.ipv4.conf.all.send_redirects' + value: 0 + - name: 'net.ipv4.conf.default.accept_redirects' + value: 0 + - name: 'net.ipv4.conf.default.accept_source_route' + value: 0 + - name: 'net.ipv4.conf.default.log_martians' + value: 1 + - name: 'net.ipv4.conf.default.secure_redirects' + value: 0 + - name: 'net.ipv4.conf.default.send_redirects' + value: 0 + - name: 'net.ipv4.icmp_echo_ignore_broadcasts' + value: 1 + - name: 'net.ipv4.icmp_ignore_bogus_error_responses' + value: 1 + - name: 'net.ipv4.tcp_syncookies' + value: 1 + - name: 'net.ipv6.conf.all.accept_redirects' + value: 0 + - name: 'net.ipv6.conf.all.accept_source_route' + value: 0 + - name: 'net.ipv6.conf.default.accept_redirects' + value: 0 + - name: 'net.ipv6.conf.default.accept_source_route' + value: 0 + ``` + + On Ubuntu 22.04 additionally: ```yaml - name: 'drop' value: 'kernel.sched_min_granularity_ns,kernel.sched_wakeup_granularity_ns' + - name: 'kernel.kptr_restrict' + value: 1 ``` - `[]` on all other platforms. + On Ubuntu 24.04 additionally: -* Deviates from the upstream default `[]` on Ubuntu 22.04: the TuneD profile there sets two scheduler sysctls that the 5.15 kernel does not have, and TuneD's own verification would fail on every run (see "How the Role Behaves"). + ```yaml + - name: 'kernel.kptr_restrict' + value: 1 + ``` + +* Deviates from the upstream default `[]`: a host deployed with LFOps should pass the sysctl checks of the CIS Benchmarks without further inventory. On Ubuntu 22.04 the TuneD profile also sets two scheduler sysctls that the 5.15 kernel does not have, and TuneD's own verification would fail on every run (see "How the Role Behaves"). * Subkeys: * `name`: - * Mandatory. Name of the sysctl, or `drop` to remove options from the TuneD profile the role builds on. An inventory entry named `drop` replaces the value of the default one, so list the two scheduler sysctls there as well on Ubuntu 22.04. + * Mandatory. Name of the sysctl, or `drop` to remove options from the TuneD profile the role builds on. An inventory entry replaces the value of the default entry of the same name; an entry named `drop` therefore has to list the two scheduler sysctls as well on Ubuntu 22.04. * Type: String. * `state`: @@ -147,6 +213,13 @@ kernel_settings__transparent_hugepages_defrag__group_var: 'defer' ``` +## Troubleshooting + +**The run aborts in `fedora.linux_system_roles.kernel_settings`, and the TuneD log names a missing `net.ipv6.*` sysctl** + +* IPv6 is switched off on the kernel command line (`ipv6.disable=1`), so the `net.ipv6.*` sysctls of the hardening baseline do not exist, and TuneD's verification fails on them. Remove them for this host with `state: 'absent'` entries in `kernel_settings__sysctl__host_var`. + + ## License [The Unlicense](https://unlicense.org/) diff --git a/roles/kernel_settings/tasks/main.yml b/roles/kernel_settings/tasks/main.yml index 338e91f07..56f4f65f5 100644 --- a/roles/kernel_settings/tasks/main.yml +++ b/roles/kernel_settings/tasks/main.yml @@ -18,6 +18,22 @@ - 'Combined transparent_hugepages: {{ kernel_settings__transparent_hugepages__combined_var }}' - 'Combined transparent_hugepages_defrag: {{ kernel_settings__transparent_hugepages_defrag__combined_var }}' + # fedora.linux_system_roles.kernel_settings merges the given sysctls into the profile it wrote + # before (kernel_settings_purge is false), so the profile keeps the settings of earlier runs of + # other playbooks, e.g. the sunrpc.* setting of mariadb_server, and `tuned-adm verify` checks + # them in every run. Verified with fedora.linux_system_roles 1.121.0 + # (roles/kernel_settings/tasks/main.yml). Depending on the TuneD release, that role writes the + # profile below /etc/tuned or /etc/tuned/profiles. grep exits with 2 for the path that does not + # exist, so only its output counts. + - name: 'grep sunrpc.* in the TuneD profile of fedora.linux_system_roles.kernel_settings' + ansible.builtin.command: >- + grep --no-filename --no-messages --only-matching --extended-regexp '^sunrpc\.[^ =]+' + /etc/tuned/kernel_settings/tuned.conf /etc/tuned/profiles/kernel_settings/tuned.conf + register: '__kernel_settings__profile_sunrpc_result' + changed_when: false # read-only command, never changes anything + failed_when: false # see above + check_mode: false # also run in check mode + # prevent errors like "Failed to read sysctl parameter 'sunrpc.tcp_slot_table_entries', the parameter does not exist" on RHEL. # persistent, because nothing else loads sunrpc on a host without NFS: after a reboot, TuneD could # not apply the setting and `tuned-adm verify` failed until the next run of this role. @@ -27,7 +43,29 @@ name: 'sunrpc' persistent: 'present' state: 'present' - when: 'kernel_settings__sysctl__combined_var | selectattr("name", "match", "^sunrpc\.") | list | length > 0' # if there is at least one key starting with "sunrpc." + register: '__kernel_settings__modprobe_sunrpc_result' + when: '__kernel_settings__sunrpc_names | length > 0' + vars: + # the sunrpc.* keys the profile will hold after this run: those of this run and those the + # profile already has, minus the ones this run removes + __kernel_settings__sunrpc_names: '{{ + (kernel_settings__sysctl__combined_var | selectattr("name", "match", "^sunrpc\.") | map(attribute="name") | list + + __kernel_settings__profile_sunrpc_result["stdout_lines"]) + | unique + | difference(kernel_settings__sysctl__combined_var | selectattr("state", "defined") | selectattr("state", "eq", "absent") | map(attribute="name") | list) + }}' + + # The sunrpc.* setting is already in the profile, but the module was missing, so TuneD could + # not apply it. fedora.linux_system_roles.kernel_settings only applies the profile when the + # profile changes, which it does not here, so `tuned-adm verify` would keep failing. A task and + # not a handler, since the verification inside that role runs before any handler would. + - name: 'systemctl restart tuned.service' + ansible.builtin.service: + name: 'tuned.service' + state: 'restarted' + when: + - '__kernel_settings__modprobe_sunrpc_result is changed' + - '__kernel_settings__profile_sunrpc_result["stdout_lines"] | length > 0' # fedora.linux_system_roles >= 2.5.0 accepts only null (leave the setting alone) for an unset # value and rejects the empty string our defaults carry, while a template cannot hand a role a diff --git a/roles/kernel_settings/vars/Ubuntu22.yml b/roles/kernel_settings/vars/Ubuntu22.yml index 016e3c85f..49e7b7c9c 100644 --- a/roles/kernel_settings/vars/Ubuntu22.yml +++ b/roles/kernel_settings/vars/Ubuntu22.yml @@ -10,6 +10,9 @@ # bug is still present in tuned 2.28.0 (reproduced on Fedora 44 with a profile setting a missing # sysctl). Debian 12/13 and Ubuntu 24.04+ only avoid it because their tuned profiles do not set # these two sysctls. -__kernel_settings__sysctl__role_var: +__kernel_settings__sysctl__platform: - name: 'drop' value: 'kernel.sched_min_granularity_ns,kernel.sched_wakeup_granularity_ns' + # see vars/Ubuntu24.yml + - name: 'kernel.kptr_restrict' + value: 1 diff --git a/roles/kernel_settings/vars/Ubuntu24.yml b/roles/kernel_settings/vars/Ubuntu24.yml new file mode 100644 index 000000000..c55cb416d --- /dev/null +++ b/roles/kernel_settings/vars/Ubuntu24.yml @@ -0,0 +1,8 @@ +# Ubuntu 22.04 and 24.04 ship kernel.kptr_restrict = 1 in /etc/sysctl.d/10-kernel-hardening.conf +# (procps). TuneD re-applies /etc/sysctl.d over its profile (reapply_sysctl = 1, +# _apply_system_sysctl() in tuned/plugins/plugin_sysctl.py), so the 2 of the hardening baseline +# would never stick and `tuned-adm verify` fails on it. 1 is what CIS 1.5.6 accepts as well. +# Verified with tuned 2.15.0 on Ubuntu 22.04; Ubuntu 26.04 no longer ships the file. +__kernel_settings__sysctl__platform: + - name: 'kernel.kptr_restrict' + value: 1 diff --git a/roles/kernel_settings/vars/main.yml b/roles/kernel_settings/vars/main.yml index b1f8781e6..8b15f6823 100644 --- a/roles/kernel_settings/vars/main.yml +++ b/roles/kernel_settings/vars/main.yml @@ -1,3 +1,102 @@ -# The role's own sysctl defaults. A vars/.yml overrides this where the TuneD of that -# platform needs a correction, see vars/Ubuntu22.yml. -__kernel_settings__sysctl__role_var: [] +# The role's own sysctl defaults: the hardening baseline below, plus what a vars/.yml +# adds in __kernel_settings__sysctl__platform where the TuneD of that platform needs a +# correction, see vars/Ubuntu22.yml. +__kernel_settings__sysctl__role_var: '{{ __kernel_settings__sysctl__hardening + __kernel_settings__sysctl__platform }}' +__kernel_settings__sysctl__platform: [] + +# Hardening baseline, sorted by name. The numbers are the recommendations of the CIS Red Hat +# Enterprise Linux 10 Benchmark v1.0.1; the CIS Debian Linux 12 Benchmark v1.0.1 covers ASLR, +# ptrace_scope and the network part with the same values. Values that only Lynis (KRNL-6000) asks +# for are marked as such. Left out on purpose: +# * net.ipv4.ip_forward, net.ipv4.conf.*.forwarding, net.ipv6.conf.*.forwarding: forwarding off +# breaks container hosts, KVM hosts, VPN servers and routers (CIS Level 2 on RHEL 10, Level 1 +# on Debian 12). +# * net.ipv4.conf.*.rp_filter = 1 and net.ipv6.conf.*.accept_ra = 0 (CIS 3.3.1.12, 3.3.1.13, +# 3.3.2.7, 3.3.2.8): strict reverse path filtering drops asymmetrically routed traffic, and +# without router advertisements a host configured by SLAAC loses its IPv6 default route. +# * fs.protected_fifos = 2, fs.protected_regular = 2, kernel.modules_disabled = 1 and +# kernel.sysrq = 0, which only Lynis asks for: the first two also refuse an O_CREAT open of +# a file another user owns in group-writable sticky directories, kernel.modules_disabled +# blocks every module load until the next reboot, and kernel.sysrq = 0 takes away the +# emergency keys on the console. +# fs.suid_dumpable belongs to the core_dumps role, which injects it. +__kernel_settings__sysctl__hardening: + # CIS 1.5.2 + - name: 'fs.protected_hardlinks' + value: 1 + # CIS 1.5.3 + - name: 'fs.protected_symlinks' + value: 1 + # CIS 1.5.5 + - name: 'kernel.dmesg_restrict' + value: 1 + # CIS 1.5.6 accepts 1 or 2, Lynis asks for 2: %pK pointers read as 0 for root as well. + # 1 on Ubuntu 22.04 and 24.04, see vars/Ubuntu24.yml. + - name: 'kernel.kptr_restrict' + value: 2 + # CIS 1.5.8 + - name: 'kernel.randomize_va_space' + value: 2 + # Lynis. Unprivileged bpf() off, like the default 2 of CONFIG_BPF_UNPRIV_DEFAULT_OFF, but + # without a way back: once it is 1, the kernel refuses to change it until the next reboot + # (bpf_unpriv_handler() in kernel/bpf/syscall.c, Linux 7.3). + - name: 'kernel.unprivileged_bpf_disabled' + value: 1 + # CIS 1.5.7 + - name: 'kernel.yama.ptrace_scope' + value: 1 + # Lynis. Constant blinding for the BPF programs of privileged users as well, at a small + # cost in JIT performance (bpf_jit_blinding_enabled() in include/linux/filter.h, Linux 7.3). + - name: 'net.core.bpf_jit_harden' + value: 2 + # CIS 3.3.1.8 + - name: 'net.ipv4.conf.all.accept_redirects' + value: 0 + # CIS 3.3.1.14 + - name: 'net.ipv4.conf.all.accept_source_route' + value: 0 + # CIS 3.3.1.16 + - name: 'net.ipv4.conf.all.log_martians' + value: 1 + # CIS 3.3.1.10 + - name: 'net.ipv4.conf.all.secure_redirects' + value: 0 + # CIS 3.3.1.4 + - name: 'net.ipv4.conf.all.send_redirects' + value: 0 + # CIS 3.3.1.9 + - name: 'net.ipv4.conf.default.accept_redirects' + value: 0 + # CIS 3.3.1.15 + - name: 'net.ipv4.conf.default.accept_source_route' + value: 0 + # CIS 3.3.1.17 + - name: 'net.ipv4.conf.default.log_martians' + value: 1 + # CIS 3.3.1.11 + - name: 'net.ipv4.conf.default.secure_redirects' + value: 0 + # CIS 3.3.1.5 + - name: 'net.ipv4.conf.default.send_redirects' + value: 0 + # CIS 3.3.1.7 + - name: 'net.ipv4.icmp_echo_ignore_broadcasts' + value: 1 + # CIS 3.3.1.6 + - name: 'net.ipv4.icmp_ignore_bogus_error_responses' + value: 1 + # CIS 3.3.1.18 + - name: 'net.ipv4.tcp_syncookies' + value: 1 + # CIS 3.3.2.3 + - name: 'net.ipv6.conf.all.accept_redirects' + value: 0 + # CIS 3.3.2.5 + - name: 'net.ipv6.conf.all.accept_source_route' + value: 0 + # CIS 3.3.2.4 + - name: 'net.ipv6.conf.default.accept_redirects' + value: 0 + # CIS 3.3.2.6 + - name: 'net.ipv6.conf.default.accept_source_route' + value: 0 diff --git a/roles/lynis/README.md b/roles/lynis/README.md index 72cf1aa23..cf3a3661a 100644 --- a/roles/lynis/README.md +++ b/roles/lynis/README.md @@ -14,6 +14,7 @@ This role installs [Lynis](https://cisofy.com/lynis/), the security auditing too * The role deploys both units to `/etc/systemd/system` on every platform. On Debian and Ubuntu they replace the `lynis.timer` and `lynis.service` the package ships, so the audit runs at the same time and with the same options everywhere. * Every audit overwrites `/var/log/lynis.log` and `/var/log/lynis-report.dat`. `lynis show details ` explains a finding from that log. * `/etc/lynis/custom.prf` is fully templated from `lynis__skip_tests`. On every run it is re-rendered (a timestamped backup is kept), so a hand-edited `custom.prf` is overwritten. Every audit on the host reads it: the daily one of `lynis.timer`, and a network scan with the lynis monitoring plugin from a management host. +* By default `/etc/lynis/custom.prf` skips the Lynis tests whose suggestion LFOps deliberately does not follow, so the monitoring plugin only reports what is left to do. Where Lynis and the CIS Benchmarks cover the same setting and disagree, LFOps follows CIS (for example `MaxSessions 10` in sshd, where Lynis wants 2). A suggestion only Lynis makes is skipped only where following it would break something (for example `allow_url_fopen = Off` in PHP). Each entry carries its reason as a comment in `custom.prf`. A test that checks several values is only skipped for the one value, for example `KRNL-6000:kernel.sysrq` instead of all of `KRNL-6000`. To have Lynis report one of them again, add it to `lynis__skip_tests__host_var` or `lynis__skip_tests__group_var` with `state: 'absent'`. * To accept a finding the monitoring plugin reports, take its test ID from the plugin output. Where the plugin says "add `skip-test=NETW-3015` to `/etc/lynis/custom.prf`", add `- name: 'NETW-3015'` to `lynis__skip_tests__host_var` or `lynis__skip_tests__group_var`, and state the reason in `comment`. The finding disappears with the next audit. * The role validates each test ID before it writes the file. Lynis refuses to run at all if a setting line of a profile contains a character outside of letters, digits and `/[]()_|,.:;=-`, so a typo would otherwise silence the whole audit instead of one test. @@ -59,7 +60,8 @@ Any [LFOps playbook](https://github.com/Linuxfabrik/lfops/blob/main/playbooks/RE * Lynis tests to skip on the host, written to `/etc/lynis/custom.prf` as `skip-test=` lines. Use this to accept a finding or a suggestion, for example one the lynis-logfile monitoring plugin reports. * Type: List of dictionaries. -* Default: `[]` +* Default: `ACCT-9630`, `AUTH-9282`, `AUTH-9284`, `AUTH-9328`, `BOOT-5264`, `DBS-1884`, `DBS-1886`, `HRDN-7222`, `HRDN-7230`, `HTTP-6640`, `KRNL-6000:fs.protected_fifos`, `KRNL-6000:fs.protected_regular`, `KRNL-6000:kernel.modules_disabled`, `KRNL-6000:kernel.sysrq`, `KRNL-6000:net.ipv4.conf.all.forwarding`, `LOGG-2190`, `PHP-2372`, `PHP-2376`, `SSH-7408:allowtcpforwarding`, `SSH-7408:maxsessions` and `SSH-7408:port`, on Debian and Ubuntu also `FILE-7524`, on Ubuntu 22.04 and 24.04 also `KRNL-6000:kernel.kptr_restrict`. `roles/lynis/vars/main.yml` gives the reason for each. +* Deviates from the upstream default `[]`: Lynis would otherwise keep reporting every deliberate LFOps default as a suggestion (see "How the Role Behaves"). * Subkeys: * `name`: diff --git a/roles/lynis/defaults/main.yml b/roles/lynis/defaults/main.yml index c186b7f1c..4936bf7a2 100644 --- a/roles/lynis/defaults/main.yml +++ b/roles/lynis/defaults/main.yml @@ -10,7 +10,7 @@ lynis__skip_tests__combined_var: '{{ ( lynis__skip_tests__dependent_var: [] lynis__skip_tests__group_var: [] lynis__skip_tests__host_var: [] -lynis__skip_tests__role_var: [] +lynis__skip_tests__role_var: '{{ __lynis__skip_tests__role_var }}' # upstream default: [] lynis__timer_enabled: true lynis__timer_state: 'started' diff --git a/roles/lynis/vars/Debian.yml b/roles/lynis/vars/Debian.yml index 574d3491a..7b71c19ed 100644 --- a/roles/lynis/vars/Debian.yml +++ b/roles/lynis/vars/Debian.yml @@ -1 +1,8 @@ __lynis__binary: '/usr/sbin/lynis' + +# at(1) is setuid and setgid daemon on Debian and Ubuntu and reads /etc/at.deny as group daemon, +# so the at role keeps the root:daemon 0640 the package ships, as CIS 2.4.2.1 allows. Lynis +# wants 0600 and cannot skip a single file of FILE-7524. +__lynis__skip_tests__platform: + - name: 'FILE-7524' + comment: 'at reads /etc/at.deny as group daemon, so it keeps mode 0640, which CIS 2.4.2.1 allows; Lynis wants 0600.' diff --git a/roles/lynis/vars/Ubuntu.yml b/roles/lynis/vars/Ubuntu.yml index 1ff4d211c..64b6898e0 100644 --- a/roles/lynis/vars/Ubuntu.yml +++ b/roles/lynis/vars/Ubuntu.yml @@ -3,3 +3,8 @@ # later Ubuntu-specific drift has a dedicated home instead of silently # inheriting Debian values. __lynis__binary: '/usr/sbin/lynis' + +# See vars/Debian.yml. +__lynis__skip_tests__platform: + - name: 'FILE-7524' + comment: 'at reads /etc/at.deny as group daemon, so it keeps mode 0640, which CIS 2.4.2.1 allows; Lynis wants 0600.' diff --git a/roles/lynis/vars/Ubuntu22.yml b/roles/lynis/vars/Ubuntu22.yml new file mode 100644 index 000000000..0e8ec3e51 --- /dev/null +++ b/roles/lynis/vars/Ubuntu22.yml @@ -0,0 +1,7 @@ +# Replaces the list of vars/Ubuntu.yml. See vars/Debian.yml for FILE-7524, and +# roles/kernel_settings/vars/Ubuntu24.yml for kernel.kptr_restrict. +__lynis__skip_tests__platform: + - name: 'FILE-7524' + comment: 'at reads /etc/at.deny as group daemon, so it keeps mode 0640, which CIS 2.4.2.1 allows; Lynis wants 0600.' + - name: 'KRNL-6000:kernel.kptr_restrict' + comment: 'Ubuntu sets 1 in /etc/sysctl.d/10-kernel-hardening.conf, which TuneD re-applies over its profile; CIS 1.5.6 accepts 1.' diff --git a/roles/lynis/vars/Ubuntu24.yml b/roles/lynis/vars/Ubuntu24.yml new file mode 100644 index 000000000..0e8ec3e51 --- /dev/null +++ b/roles/lynis/vars/Ubuntu24.yml @@ -0,0 +1,7 @@ +# Replaces the list of vars/Ubuntu.yml. See vars/Debian.yml for FILE-7524, and +# roles/kernel_settings/vars/Ubuntu24.yml for kernel.kptr_restrict. +__lynis__skip_tests__platform: + - name: 'FILE-7524' + comment: 'at reads /etc/at.deny as group daemon, so it keeps mode 0640, which CIS 2.4.2.1 allows; Lynis wants 0600.' + - name: 'KRNL-6000:kernel.kptr_restrict' + comment: 'Ubuntu sets 1 in /etc/sysctl.d/10-kernel-hardening.conf, which TuneD re-applies over its profile; CIS 1.5.6 accepts 1.' diff --git a/roles/lynis/vars/main.yml b/roles/lynis/vars/main.yml new file mode 100644 index 000000000..d6857aa44 --- /dev/null +++ b/roles/lynis/vars/main.yml @@ -0,0 +1,54 @@ +# The role's own skip-test entries: what LFOps deliberately does differently from what Lynis +# suggests, plus what a vars/.yml adds in __lynis__skip_tests__platform. Where Lynis +# and the CIS Benchmarks (Red Hat Enterprise Linux 10 v1.0.1, Debian Linux 12 v1.0.1) cover the +# same setting and disagree, LFOps follows CIS. A suggestion only Lynis makes is skipped only for +# a technical reason. Each entry names the reason in its comment, which ends up in custom.prf on +# the host. Where Lynis checks several values in one test, only the single value is skipped +# (SkipAtomicTest() in include/functions; KRNL-6000 per sysctl key, SSH-7408 per option). +# Verified against the Lynis source (3.1.8). +__lynis__skip_tests__role_var: '{{ __lynis__skip_tests__common + __lynis__skip_tests__platform }}' +__lynis__skip_tests__platform: [] + +__lynis__skip_tests__common: + - name: 'ACCT-9630' + comment: 'Without rules auditd still records SELinux denials and logins; useful rules depend on what the host runs, and a generic set costs I/O and disk on busy hosts.' + - name: 'AUTH-9282' + comment: 'An expiry date locks an account out on a fixed day, including the root password needed on the console.' + - name: 'AUTH-9284' + comment: 'Locked accounts are reported for information only.' + - name: 'AUTH-9328' + comment: 'LFOps keeps the umask 022 the distributions ship; a stricter umask breaks applications such as Icinga Web 2.' + - name: 'BOOT-5264' + comment: 'Lynis suggests this on every host with systemd 240 or newer, whatever systemd-analyze security reports; hardening a unit has to be decided per service, in the role that deploys it.' + - name: 'DBS-1884' + comment: 'The redis role binds to localhost with protected-mode; a password needs a secret from the inventory (redis__conf_requirepass).' + - name: 'DBS-1886' + comment: 'Renaming CONFIG breaks clients that use it; the redis role binds to localhost with protected-mode.' + - name: 'HRDN-7222' + comment: 'Compilers restricted to root break builds by other users, such as pip installing source packages into a venv, and package updates restore the mode.' + - name: 'HRDN-7230' + comment: 'AIDE checks file integrity; deploy ClamAV with the clamav role where a malware scanner is needed.' + - name: 'HTTP-6640' + comment: 'mod_evasive only sees HTTP requests to Apache httpd, counts them per TCP peer and per child process, and thus blocks every user behind a reverse proxy or NAT; it is unmaintained upstream since 2017, and fail2ban covers brute force.' + - name: 'KRNL-6000:fs.protected_fifos' + comment: '2 also refuses O_CREAT opens of files another user owns in group-writable sticky directories, which shared directories rely on.' + - name: 'KRNL-6000:fs.protected_regular' + comment: '2 also refuses O_CREAT opens of files another user owns in group-writable sticky directories, which shared directories rely on.' + - name: 'KRNL-6000:kernel.modules_disabled' + comment: '1 blocks every module load until the next reboot, including drivers for hot-plugged hardware.' + - name: 'KRNL-6000:kernel.sysrq' + comment: '0 takes away the emergency keys (sync, remount read-only, reboot) on the console of a hung host.' + - name: 'KRNL-6000:net.ipv4.conf.all.forwarding' + comment: 'Forwarding off breaks container hosts, KVM hosts, VPN servers and routers.' + - name: 'LOGG-2190' + comment: 'Processes that still use deleted files after an update are reported by the needs-restarting monitoring plugin.' + - name: 'PHP-2372' + comment: 'Lynis reads expose_php from the php.ini the package ships; the php role turns it off in a drop-in that overrides it.' + - name: 'PHP-2376' + comment: 'allow_url_fopen = Off breaks applications that fetch URLs with file_get_contents(), for example app stores and update checks.' + - name: 'SSH-7408:allowtcpforwarding' + comment: 'Administrators and tools use SSH tunnels to reach services that only listen on localhost, such as databases and web consoles.' + - name: 'SSH-7408:maxsessions' + comment: 'The sshd role sets MaxSessions 10, as CIS 5.1.18 asks for; 2 breaks connection multiplexing.' + - name: 'SSH-7408:port' + comment: 'Another port only reduces log noise from scanners, while every client, jump host and firewall rule has to know it.' diff --git a/roles/mariadb_server/README.md b/roles/mariadb_server/README.md index 4a7471f0e..668ff900e 100644 --- a/roles/mariadb_server/README.md +++ b/roles/mariadb_server/README.md @@ -5,7 +5,6 @@ This role installs and configures a [MariaDB](https://mariadb.org/) server. It also tunes the following Kernel settings: * `fs.aio-max-nr = 1048576` -* `sunrpc.tcp_slot_table_entries = 128` * `vm.swapiness = 10` Note that this role does NOT let you specify a particular MariaDB server version. It simply installs the latest available MariaDB server version from the repos configured in the system. If you want or need to install a specific MariaDB server version, use the [linuxfabrik.lfops.repo_mariadb](https://github.com/Linuxfabrik/lfops/tree/main/roles/repo_mariadb) beforehand. diff --git a/roles/mariadb_server/defaults/main.yml b/roles/mariadb_server/defaults/main.yml index 96067cc81..8c2581769 100644 --- a/roles/mariadb_server/defaults/main.yml +++ b/roles/mariadb_server/defaults/main.yml @@ -1040,7 +1040,12 @@ mariadb_server__users__role_var: [] mariadb_server__kernel_settings__sysctl__dependent_var: - name: 'fs.aio-max-nr' value: 1048576 + # Removes the setting from the TuneD profile of hosts that got it from an earlier version of + # this role. It only concerns the NFS client: the number of RPC slots preallocated per TCP + # transport, while further slots are allocated on demand up to tcp_max_slot_table_entries + # (xprt_dynamic_alloc_slot() in net/sunrpc/xprt.c, Linux 7.3). MariaDB has no use for it, and + # it loaded the sunrpc module on every database server. - name: 'sunrpc.tcp_slot_table_entries' - value: 128 + state: 'absent' - name: 'vm.swappiness' value: 10 diff --git a/roles/sshd/README.md b/roles/sshd/README.md index f87f2816a..9101d7fad 100644 --- a/roles/sshd/README.md +++ b/roles/sshd/README.md @@ -12,7 +12,9 @@ The option descriptions below are condensed from `sshd_config(5)`. ## How the Role Behaves -The role ships hardened defaults for several options that differ from the OpenSSH (or distribution) defaults: `X11Forwarding`, agent forwarding and TCP keepalives are off, `MaxAuthTries` and `ClientAliveCountMax` are stricter, and `LogLevel` is `VERBOSE` (which logs the key fingerprint used for each login). Each is overridable via the variables below. +The role ships hardened defaults for several options that differ from the OpenSSH (or distribution) defaults: root may not log in, `X11Forwarding`, agent forwarding and TCP keepalives are off, `MaxAuthTries` and `ClientAliveCountMax` are stricter, an unresponsive client is disconnected after `ClientAliveInterval` × `ClientAliveCountMax` seconds (30 by default), and `LogLevel` is `VERBOSE` (which logs the key fingerprint used for each login). These follow the CIS Benchmarks. Each is overridable via the variables below. + +`PermitRootLogin` defaults to `no`. Before it deploys `sshd_config`, the role checks which user Ansible logs in as over SSH. If that is root and `sshd__permit_root_login` does not admit root logins with a key, the run aborts before anything is changed, since the reload would lock Ansible out of the host. Log in as an unprivileged user with sudo (see the [login](https://github.com/Linuxfabrik/lfops/tree/main/roles/login) role), or set `sshd__permit_root_login: 'prohibit-password'` for hosts that Ansible has to reach as root. `MaxAuthTries` defaults to `3`. Every public key an SSH agent offers counts as one attempt, so a client with more than three keys loaded in its agent may be rejected before the matching key is tried. In that case use `IdentitiesOnly yes` / an explicit `IdentityFile` on the client, or raise `sshd__max_auth_tries`. @@ -61,10 +63,17 @@ Any [LFOps playbook](https://github.com/Linuxfabrik/lfops/blob/main/playbooks/RE `sshd__client_alive_count_max` -* Number of client alive messages sshd may send without receiving any back from the client before it disconnects the session. Unlike `TCPKeepAlive`, these messages go through the encrypted channel and cannot be spoofed. Only takes effect together with a non-zero `ClientAliveInterval` (`0` by default, set it via `sshd__raw`). +* Number of client alive messages sshd may send without receiving any back from the client before it disconnects the session. Unlike `TCPKeepAlive`, these messages go through the encrypted channel and cannot be spoofed. Only takes effect together with a non-zero `sshd__client_alive_interval`. * Type: Number. * Default: `2` (OpenSSH default: `3`) +`sshd__client_alive_interval` + +* Seconds of inactivity after which sshd asks the client for a sign of life through the encrypted channel. `0` disables these messages, and with them the disconnect after `sshd__client_alive_count_max` unanswered ones. +* Type: Number. +* Default: `15` +* Deviates from the upstream default `0`: without it, a session whose client vanished stays open until TCP gives up, which can take hours. The CIS Benchmarks require a non-zero value. + `sshd__gssapi_authentication` * Specifies whether user authentication based on GSSAPI is allowed. @@ -97,9 +106,10 @@ Any [LFOps playbook](https://github.com/Linuxfabrik/lfops/blob/main/playbooks/RE `sshd__permit_root_login` -* Specifies whether root can log in using ssh. Possible options: `yes`, `prohibit-password`, `forced-commands-only`, `no`. +* Specifies whether root can log in using ssh. Possible options: `yes`, `prohibit-password`, `forced-commands-only`, `no`. The role refuses to set a value that locks out the user Ansible logs in as (see "How the Role Behaves"). * Type: String. -* Default: `'yes'` +* Default: `'no'` +* Deviates from the upstream default `prohibit-password` (some distributions ship `yes`): administrators log in with a personal account and use sudo, so every privileged action can be traced to a person. The CIS Benchmarks require `no`. `sshd__port` @@ -157,12 +167,13 @@ sshd__address_family: 'inet' sshd__allow_agent_forwarding: false sshd__allow_tcp_forwarding: true sshd__client_alive_count_max: 2 +sshd__client_alive_interval: 15 sshd__gssapi_authentication: false sshd__log_level__host_var: 'VERBOSE' sshd__max_auth_tries: 3 sshd__max_sessions: 10 sshd__password_authentication: false -sshd__permit_root_login: 'yes' +sshd__permit_root_login: 'prohibit-password' sshd__port: 22 sshd__raw: |- Match Group sftpusers diff --git a/roles/sshd/defaults/main.yml b/roles/sshd/defaults/main.yml index 3c4e7f19c..87289ea17 100644 --- a/roles/sshd/defaults/main.yml +++ b/roles/sshd/defaults/main.yml @@ -2,6 +2,7 @@ sshd__address_family: 'any' sshd__allow_agent_forwarding: false sshd__allow_tcp_forwarding: true sshd__client_alive_count_max: 2 +sshd__client_alive_interval: 15 # upstream default: 0 sshd__gssapi_authentication: true sshd__log_level__combined_var: '{{ sshd__log_level__host_var if (sshd__log_level__host_var | string | length) else @@ -17,7 +18,7 @@ sshd__log_level__role_var: 'VERBOSE' sshd__max_auth_tries: 3 sshd__max_sessions: 10 sshd__password_authentication: false -sshd__permit_root_login: 'yes' +sshd__permit_root_login: 'no' # upstream default: 'prohibit-password' sshd__port: 22 sshd__service_enabled: true sshd__service_state: 'started' diff --git a/roles/sshd/meta/argument_specs.yml b/roles/sshd/meta/argument_specs.yml index 659b8f97a..fe88dd891 100644 --- a/roles/sshd/meta/argument_specs.yml +++ b/roles/sshd/meta/argument_specs.yml @@ -37,6 +37,15 @@ argument_specs: Number of client alive messages sent without a reply before sshd disconnects the session. + sshd__client_alive_interval: + type: 'int' + required: false + default: 15 + description: >- + Seconds of client inactivity after which sshd sends a client + alive message through the encrypted channel. `0` disables + these messages. + sshd__gssapi_authentication: type: 'bool' required: false @@ -94,7 +103,7 @@ argument_specs: sshd__permit_root_login: type: 'str' required: false - default: 'yes' + default: 'no' description: >- Whether root can log in via ssh: `yes`, `prohibit-password`, `forced-commands-only` or `no`. diff --git a/roles/sshd/tasks/main.yml b/roles/sshd/tasks/main.yml index 0492f9c83..130504eab 100644 --- a/roles/sshd/tasks/main.yml +++ b/roles/sshd/tasks/main.yml @@ -6,6 +6,10 @@ - 'always' +- name: 'Run the preflight checks' + ansible.builtin.import_tasks: 'preflight.yml' + + - block: - name: 'Deploy /etc/ssh/sshd_config' diff --git a/roles/sshd/tasks/preflight.yml b/roles/sshd/tasks/preflight.yml new file mode 100644 index 000000000..9068401e2 --- /dev/null +++ b/roles/sshd/tasks/preflight.yml @@ -0,0 +1,36 @@ +# Checks that need nothing changed on the host and abort before the role deploys sshd_config. +# Imported at the top of tasks/main.yml, and included by playbooks that run many roles (e.g. +# setup_basic) in their pre_tasks, so a missing prerequisite aborts the run before any role +# changes the host. +# +# Ansible itself may log in as root, typically on the first run against a fresh cloud VM. With +# `PermitRootLogin no` the reload at the end of this run would lock it out: every following +# connection, including the next playbook run, fails with "Permission denied". `id` without +# become reports the user the SSH connection logged in as. Only SSH connections are checked, +# since sshd_config has no effect on a local or container connection. +- block: + + - name: 'id --user --name' + ansible.builtin.command: 'id --user --name' + register: '__sshd__login_user_result' + become: false + vars: + ansible_become: false # noqa var-naming[pattern] + changed_when: false # read-only command, never changes anything + check_mode: false # also run in check mode + + - name: 'Assert that sshd_config still admits the user Ansible logs in as' + ansible.builtin.assert: + that: + - '__sshd__login_user_result["stdout"] != "root" or sshd__permit_root_login in ["prohibit-password", "without-password", "yes"]' + quiet: true + fail_msg: >- + Ansible logs in to this host as root, but sshd__permit_root_login is + '{{ sshd__permit_root_login }}', which would lock Ansible out once sshd reloads. Log in as + an unprivileged user with sudo instead (ansible_user, see the login role), or set + sshd__permit_root_login to 'prohibit-password' for this host. + + when: + - 'ansible_connection | d("ssh") is search("ssh|paramiko|smart")' + tags: + - 'sshd' diff --git a/roles/sshd/templates/etc/ssh/Debian12-sshd_config.j2 b/roles/sshd/templates/etc/ssh/Debian12-sshd_config.j2 index 533229d47..dd7e42ef0 100644 --- a/roles/sshd/templates/etc/ssh/Debian12-sshd_config.j2 +++ b/roles/sshd/templates/etc/ssh/Debian12-sshd_config.j2 @@ -1,5 +1,5 @@ # {{ ansible_managed }} -# 2026060601 +# 2026101601 # This is the sshd server system-wide configuration file. See # sshd_config(5) for more information. @@ -98,7 +98,7 @@ PrintMotd no TCPKeepAlive {{ sshd__tcp_keep_alive | bool | ternary("yes", "no") }} #PermitUserEnvironment no #Compression delayed -#ClientAliveInterval 0 +ClientAliveInterval {{ sshd__client_alive_interval }} ClientAliveCountMax {{ sshd__client_alive_count_max }} UseDNS {{ sshd__use_dns | bool | ternary("yes", "no") }} #PidFile /run/sshd.pid diff --git a/roles/sshd/templates/etc/ssh/Debian13-sshd_config.j2 b/roles/sshd/templates/etc/ssh/Debian13-sshd_config.j2 index 95e272ce5..cc02caa52 100644 --- a/roles/sshd/templates/etc/ssh/Debian13-sshd_config.j2 +++ b/roles/sshd/templates/etc/ssh/Debian13-sshd_config.j2 @@ -1,5 +1,5 @@ # {{ ansible_managed }} -# 2026060601 +# 2026101601 # This is the sshd server system-wide configuration file. See # sshd_config(5) for more information. @@ -100,7 +100,7 @@ PrintMotd no TCPKeepAlive {{ sshd__tcp_keep_alive | bool | ternary("yes", "no") }} #PermitUserEnvironment no #Compression delayed -#ClientAliveInterval 0 +ClientAliveInterval {{ sshd__client_alive_interval }} ClientAliveCountMax {{ sshd__client_alive_count_max }} UseDNS {{ sshd__use_dns | bool | ternary('yes', 'no') }} #PidFile /run/sshd.pid diff --git a/roles/sshd/templates/etc/ssh/RedHat-sshd_config.j2 b/roles/sshd/templates/etc/ssh/RedHat-sshd_config.j2 index 975a36f35..c6d41002d 100644 --- a/roles/sshd/templates/etc/ssh/RedHat-sshd_config.j2 +++ b/roles/sshd/templates/etc/ssh/RedHat-sshd_config.j2 @@ -1,5 +1,5 @@ # {{ ansible_managed }} -# 2026060601 +# 2026101601 # $OpenBSD: sshd_config,v 1.104 2021/07/02 05:11:21 dtucker Exp $ @@ -110,7 +110,7 @@ X11Forwarding {{ sshd__x11_forwarding | bool | ternary("yes", "no") }} TCPKeepAlive {{ sshd__tcp_keep_alive | bool | ternary("yes", "no") }} #PermitUserEnvironment no #Compression delayed -#ClientAliveInterval 0 +ClientAliveInterval {{ sshd__client_alive_interval }} ClientAliveCountMax {{ sshd__client_alive_count_max }} UseDNS {{ sshd__use_dns | bool | ternary("yes", "no") }} #PidFile /var/run/sshd.pid diff --git a/roles/sshd/templates/etc/ssh/RedHat10-sshd_config.j2 b/roles/sshd/templates/etc/ssh/RedHat10-sshd_config.j2 index 975a36f35..c6d41002d 100644 --- a/roles/sshd/templates/etc/ssh/RedHat10-sshd_config.j2 +++ b/roles/sshd/templates/etc/ssh/RedHat10-sshd_config.j2 @@ -1,5 +1,5 @@ # {{ ansible_managed }} -# 2026060601 +# 2026101601 # $OpenBSD: sshd_config,v 1.104 2021/07/02 05:11:21 dtucker Exp $ @@ -110,7 +110,7 @@ X11Forwarding {{ sshd__x11_forwarding | bool | ternary("yes", "no") }} TCPKeepAlive {{ sshd__tcp_keep_alive | bool | ternary("yes", "no") }} #PermitUserEnvironment no #Compression delayed -#ClientAliveInterval 0 +ClientAliveInterval {{ sshd__client_alive_interval }} ClientAliveCountMax {{ sshd__client_alive_count_max }} UseDNS {{ sshd__use_dns | bool | ternary("yes", "no") }} #PidFile /var/run/sshd.pid diff --git a/roles/sshd/templates/etc/ssh/RedHat8-sshd_config.j2 b/roles/sshd/templates/etc/ssh/RedHat8-sshd_config.j2 index 7defe89b1..9009e9119 100644 --- a/roles/sshd/templates/etc/ssh/RedHat8-sshd_config.j2 +++ b/roles/sshd/templates/etc/ssh/RedHat8-sshd_config.j2 @@ -1,5 +1,5 @@ # {{ ansible_managed }} -# 2026060601 +# 2026101601 # $OpenBSD: sshd_config,v 1.103 2018/04/09 20:41:22 tj Exp $ @@ -120,7 +120,7 @@ PrintMotd no TCPKeepAlive {{ sshd__tcp_keep_alive | bool | ternary("yes", "no") }} #PermitUserEnvironment no #Compression delayed -#ClientAliveInterval 0 +ClientAliveInterval {{ sshd__client_alive_interval }} ClientAliveCountMax {{ sshd__client_alive_count_max }} UseDNS {{ sshd__use_dns | bool | ternary("yes", "no") }} #PidFile /var/run/sshd.pid diff --git a/roles/sshd/templates/etc/ssh/RedHat9-sshd_config.j2 b/roles/sshd/templates/etc/ssh/RedHat9-sshd_config.j2 index 6e52d68a8..61c768795 100644 --- a/roles/sshd/templates/etc/ssh/RedHat9-sshd_config.j2 +++ b/roles/sshd/templates/etc/ssh/RedHat9-sshd_config.j2 @@ -1,5 +1,5 @@ # {{ ansible_managed }} -# 2026061601 +# 2026101601 # $OpenBSD: sshd_config,v 1.104 2021/07/02 05:11:21 dtucker Exp $ @@ -110,7 +110,7 @@ X11Forwarding {{ sshd__x11_forwarding | bool | ternary("yes", "no") }} TCPKeepAlive {{ sshd__tcp_keep_alive | bool | ternary("yes", "no") }} #PermitUserEnvironment no #Compression delayed -#ClientAliveInterval 0 +ClientAliveInterval {{ sshd__client_alive_interval }} ClientAliveCountMax {{ sshd__client_alive_count_max }} UseDNS {{ sshd__use_dns | bool | ternary("yes", "no") }} #PidFile /var/run/sshd.pid diff --git a/roles/sshd/templates/etc/ssh/Ubuntu22-sshd_config.j2 b/roles/sshd/templates/etc/ssh/Ubuntu22-sshd_config.j2 index 3dfd9e9b7..1533c3a47 100644 --- a/roles/sshd/templates/etc/ssh/Ubuntu22-sshd_config.j2 +++ b/roles/sshd/templates/etc/ssh/Ubuntu22-sshd_config.j2 @@ -1,5 +1,5 @@ # {{ ansible_managed }} -# 2026060601 +# 2026101601 # This is the sshd server system-wide configuration file. See # sshd_config(5) for more information. @@ -100,7 +100,7 @@ PrintMotd no TCPKeepAlive {{ sshd__tcp_keep_alive | bool | ternary("yes", "no") }} #PermitUserEnvironment no #Compression delayed -#ClientAliveInterval 0 +ClientAliveInterval {{ sshd__client_alive_interval }} ClientAliveCountMax {{ sshd__client_alive_count_max }} UseDNS {{ sshd__use_dns | bool | ternary('yes', 'no') }} #PidFile /run/sshd.pid diff --git a/roles/sshd/templates/etc/ssh/Ubuntu24-sshd_config.j2 b/roles/sshd/templates/etc/ssh/Ubuntu24-sshd_config.j2 index 3dfd9e9b7..1533c3a47 100644 --- a/roles/sshd/templates/etc/ssh/Ubuntu24-sshd_config.j2 +++ b/roles/sshd/templates/etc/ssh/Ubuntu24-sshd_config.j2 @@ -1,5 +1,5 @@ # {{ ansible_managed }} -# 2026060601 +# 2026101601 # This is the sshd server system-wide configuration file. See # sshd_config(5) for more information. @@ -100,7 +100,7 @@ PrintMotd no TCPKeepAlive {{ sshd__tcp_keep_alive | bool | ternary("yes", "no") }} #PermitUserEnvironment no #Compression delayed -#ClientAliveInterval 0 +ClientAliveInterval {{ sshd__client_alive_interval }} ClientAliveCountMax {{ sshd__client_alive_count_max }} UseDNS {{ sshd__use_dns | bool | ternary('yes', 'no') }} #PidFile /run/sshd.pid diff --git a/roles/sshd/templates/etc/ssh/Ubuntu26-sshd_config.j2 b/roles/sshd/templates/etc/ssh/Ubuntu26-sshd_config.j2 index 95e272ce5..cc02caa52 100644 --- a/roles/sshd/templates/etc/ssh/Ubuntu26-sshd_config.j2 +++ b/roles/sshd/templates/etc/ssh/Ubuntu26-sshd_config.j2 @@ -1,5 +1,5 @@ # {{ ansible_managed }} -# 2026060601 +# 2026101601 # This is the sshd server system-wide configuration file. See # sshd_config(5) for more information. @@ -100,7 +100,7 @@ PrintMotd no TCPKeepAlive {{ sshd__tcp_keep_alive | bool | ternary("yes", "no") }} #PermitUserEnvironment no #Compression delayed -#ClientAliveInterval 0 +ClientAliveInterval {{ sshd__client_alive_interval }} ClientAliveCountMax {{ sshd__client_alive_count_max }} UseDNS {{ sshd__use_dns | bool | ternary('yes', 'no') }} #PidFile /run/sshd.pid