diff --git a/CHANGELOG.md b/CHANGELOG.md index a4361a998..a772458b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +* **role:system_update**: Add a security lane for Rocky Linux. A second timer (twice a day by default) installs only Rocky Linux security hot-fixes from the dedicated `security` repository (provided by `repo_baseos`) and reboots the host if needed. The reboot time is steered per host group (for example immediately on test hosts, deferred to the evening on production hosts). Enabled by default; a no-op where the `security` repository is not enabled, and can be turned off with `system_update__security_enabled: false`. This keeps critical security fixes flowing daily while the regular update lane stays on its weekly schedule. * **role:mariadb_server**: Add `mariadb_server__cnf_innodb_snapshot_isolation` variable (MariaDB 10.6+), defaulting to `'ON'`. ### Security diff --git a/roles/system_update/README.md b/roles/system_update/README.md index 30e841142..f7e0d3372 100644 --- a/roles/system_update/README.md +++ b/roles/system_update/README.md @@ -6,6 +6,8 @@ This role configures the server to do (weekly) system updates by deploying two s * applies all updates * and, if necessary, automatically reboots the host after the updates. +On Rocky Linux hosts the role additionally runs a separate security lane: a second timer (`security-update`, twice a day by default) that installs only Rocky Linux security hot-fixes from the dedicated `security` repository (provided by the `repo_baseos` role) and reboots the host if needed. It is enabled by default and is a no-op on hosts where the `security` repository is not present; turn it off with `system_update__security_enabled: false`. The reboot time is steered per host group via `system_update__security_reboot_time__*` (for example immediately on test hosts, deferred to the evening on production hosts). + *Available since LFOps `2.0.0`.* @@ -24,18 +26,19 @@ Any [LFOps playbook](https://github.com/Linuxfabrik/lfops/blob/main/playbooks/RE Manual steps: * On Debian, install needrestart by running the [apps](https://github.com/Linuxfabrik/lfops/blob/main/playbooks/apps.yml) playbook (role: [linuxfabrik.lfops.apps](https://github.com/Linuxfabrik/lfops/tree/main/roles/apps)). +* The security lane installs from the Rocky `security` repository, which is provided by the [repo_baseos](https://github.com/Linuxfabrik/lfops/tree/main/roles/repo_baseos) role (enabled by default). On hosts where that repository is not present, the security lane is a no-op. ## Tags `system_update` -* Sets up automatic system update via systemd timer. +* Sets up automatic system update via systemd timer, and on Rocky Linux hosts the optional security-update timer. * Triggers: none. `system_update:state` -* Determines whether notify-and-schedule.timer is enabled. +* Determines whether notify-and-schedule.timer and security-update.timer are enabled. * Triggers: none. @@ -125,6 +128,30 @@ Manual steps: * Type: String. * Default: unset +`system_update__security_enabled` + +* Enables or disables the security lane (the `security-update` timer), analogous to `systemctl enable/disable --now`. Rocky Linux only. When enabled but the `security` repository is not enabled on the host, the lane is a no-op. +* Type: Bool. +* Default: `true` + +`system_update__security_on_calendar` + +* When the security lane checks for and installs security hot-fixes. Have a look at [systemd.time(7)](https://www.freedesktop.org/software/systemd/man/systemd.time.html) for the format. +* Type: String. +* Default: `'*-*-* 10,16:00'` + +`system_update__security_reboot_time__host_var` / `system_update__security_reboot_time__group_var` + +* When to reboot after a security hot-fix that requires it. Passed verbatim to `at`. Use this to steer test versus production hosts via inventory group membership: `'now'` reboots immediately, a time such as `'19:00'` defers the reboot. +* Type: String. +* Default: `'now'` + +`system_update__security_repos` + +* The repositories the security lane installs from. All other repositories are disabled for the security transaction, keeping it separate from the regular update lane. +* Type: List. +* Default: `['security']` + `system_update__update_enabled` * Enables or disables the system-update timer, analogous to `systemctl enable/disable --now`. @@ -173,6 +200,11 @@ system_update__pre_update_code: |- check_dns 192.0.2.11 system_update__rocketchat_msg_suffix: '@administrator' system_update__rocketchat_url: 'https://chat.example.com/hooks/abcd1234' +system_update__security_enabled: true +system_update__security_on_calendar: '*-*-* 10,16:00' +system_update__security_reboot_time__group_var: '19:00' +system_update__security_repos: + - 'security' system_update__update_enabled: true system_update__update_time: '04:{{ 59 | random(seed=inventory_hostname) }} + 1 days' ``` diff --git a/roles/system_update/defaults/main.yml b/roles/system_update/defaults/main.yml index 35e05a99b..33de96709 100644 --- a/roles/system_update/defaults/main.yml +++ b/roles/system_update/defaults/main.yml @@ -8,5 +8,19 @@ system_update__mail_subject_hostname: '$(hostname --short)' system_update__mail_subject_prefix: '' system_update__notify_and_schedule_on_calendar: 'mon 10:00' system_update__rocketchat_msg_suffix: '' +system_update__security_enabled: true +system_update__security_on_calendar: '*-*-* 10,16:00' +system_update__security_reboot_time__dependent_var: '' +system_update__security_reboot_time__group_var: '' +system_update__security_reboot_time__host_var: '' +system_update__security_reboot_time__role_var: 'now' +system_update__security_reboot_time__combined_var: '{{ + system_update__security_reboot_time__host_var if (system_update__security_reboot_time__host_var | string | length) else + system_update__security_reboot_time__group_var if (system_update__security_reboot_time__group_var | string | length) else + system_update__security_reboot_time__dependent_var if (system_update__security_reboot_time__dependent_var | string | length) else + system_update__security_reboot_time__role_var + }}' +system_update__security_repos: + - 'security' system_update__update_enabled: true system_update__update_time: '04:{{ 59 | random(seed=inventory_hostname) }} + 1 days' diff --git a/roles/system_update/tasks/main.yml b/roles/system_update/tasks/main.yml index 0343da504..e32612344 100644 --- a/roles/system_update/tasks/main.yml +++ b/roles/system_update/tasks/main.yml @@ -72,3 +72,68 @@ tags: - 'system_update' - 'system_update:state' + + +# The security lane applies Rocky Linux security hot-fixes from the dedicated +# `security` repository (provided by the repo_baseos role) twice a day, +# independent of the regular (weekly) update lane. Rocky Linux only. +- block: + + - name: 'Deploy /usr/local/bin/security-update' + ansible.builtin.template: + backup: true + src: 'usr/local/bin/security-update.j2' + dest: '/usr/local/bin/security-update' + owner: 'root' + group: 'root' + mode: 0o744 + + - name: 'Deploy /usr/local/bin/security-reboot' + ansible.builtin.template: + backup: true + src: 'usr/local/bin/security-reboot.j2' + dest: '/usr/local/bin/security-reboot' + owner: 'root' + group: 'root' + mode: 0o744 + + - name: 'Deploy /etc/systemd/system/security-update.service' + ansible.builtin.template: + backup: true + src: 'etc/systemd/system/security-update.service.j2' + dest: '/etc/systemd/system/security-update.service' + owner: 'root' + group: 'root' + mode: 0o644 + + - name: 'Deploy /etc/systemd/system/security-update.timer' + ansible.builtin.template: + backup: true + src: 'etc/systemd/system/security-update.timer.j2' + dest: '/etc/systemd/system/security-update.timer' + owner: 'root' + group: 'root' + mode: 0o644 + + when: + - 'ansible_facts["distribution"] == "Rocky"' + + tags: + - 'system_update' + + +- block: + + - name: 'systemctl {{ system_update__security_enabled | bool | ternary("enable", "disable") }} security-update.timer --now' + ansible.builtin.systemd: + name: 'security-update.timer' + state: '{{ system_update__security_enabled | bool | ternary("started", "stopped") }}' + enabled: '{{ system_update__security_enabled }}' + daemon_reload: true + + when: + - 'ansible_facts["distribution"] == "Rocky"' + + tags: + - 'system_update' + - 'system_update:state' diff --git a/roles/system_update/templates/etc/systemd/system/security-update.service.j2 b/roles/system_update/templates/etc/systemd/system/security-update.service.j2 new file mode 100644 index 000000000..65d0199e9 --- /dev/null +++ b/roles/system_update/templates/etc/systemd/system/security-update.service.j2 @@ -0,0 +1,14 @@ +# {{ ansible_managed }} +# 2026052401 + +[Unit] +Description=security-update Service + +[Service] +ExecStart=/usr/local/bin/security-update +Type=oneshot +User=root +KillMode=process + +[Install] +WantedBy=basic.target diff --git a/roles/system_update/templates/etc/systemd/system/security-update.timer.j2 b/roles/system_update/templates/etc/systemd/system/security-update.timer.j2 new file mode 100644 index 000000000..be6ca5bff --- /dev/null +++ b/roles/system_update/templates/etc/systemd/system/security-update.timer.j2 @@ -0,0 +1,12 @@ +# {{ ansible_managed }} +# 2026052401 + +[Unit] +Description=security-update Timer + +[Timer] +OnCalendar={{ system_update__security_on_calendar }} +Unit=security-update.service + +[Install] +WantedBy=timers.target diff --git a/roles/system_update/templates/usr/local/bin/security-reboot.j2 b/roles/system_update/templates/usr/local/bin/security-reboot.j2 new file mode 100644 index 000000000..e5124133a --- /dev/null +++ b/roles/system_update/templates/usr/local/bin/security-reboot.j2 @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# {{ ansible_managed }} +# 2026052401 + +# Set an Icinga downtime and reboot. Invoked via `at` by /usr/local/bin/security-update +# so that the downtime is set at reboot time, not when the update was applied. + +export LC_ALL=C + +REBOOT=$(which reboot) + +{% if system_update__icinga2_api_user_login is defined and system_update__icinga2_api_user_login | length %} +# 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 '{{ system_update__icinga2_api_user_login["username"] }}:{{ system_update__icinga2_api_user_login["password"] }}' --header 'Accept: application/json' --request POST '{{ system_update__icinga2_api_url }}/v1/actions/schedule-downtime' --data-binary @- 1> /dev/null << EOF +{ + "type": "Host", + "filter": "match(\"{{ system_update__icinga2_hostname }}\", host.name)", + "start_time": "$START_TIME", + "end_time": "$END_TIME", + "author": "{{ system_update__icinga2_hostname }}", + "comment": "Automatic reboot due to security updates.", + "all_services": true +} +EOF +{% endif %} + +"$REBOOT" diff --git a/roles/system_update/templates/usr/local/bin/security-update.j2 b/roles/system_update/templates/usr/local/bin/security-update.j2 new file mode 100644 index 000000000..845945e3f --- /dev/null +++ b/roles/system_update/templates/usr/local/bin/security-update.j2 @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# {{ ansible_managed }} +# 2026052401 + +# Apply Rocky Linux security hot-fixes from the dedicated `security` repository +# only, then reboot if needed. Runs on a timer (default twice daily). The reboot +# is scheduled via `at`; the time comes from the host's group/inventory +# (e.g. 'now' on test hosts, '19:00' on production hosts). + +export LC_ALL=C + +# dedicated at queue, so the 10:00 and 16:00 runs don't stack multiple reboots +AT_QUEUE='s' +REPOS="{{ system_update__security_repos | join(',') }}" + +send_msg () { +{# note: do not quote the $RECIPIENTS, else they are passed as a single argument #} + echo -n "$MSGBODY" | mail -s "$SUBJECT" -r "$SENDER" $RECIPIENTS +{% if system_update__rocketchat_url is defined and system_update__rocketchat_url | length %} + /usr/bin/curl --silent --output /dev/null --data-urlencode \ + "text=${SUBJECT} + + ${MSGBODY} + {{ system_update__rocketchat_msg_suffix }}" \ + --data-urlencode "parse_mode=HTML" --data-urlencode "disable_web_page_preview=true" \ + "{{ system_update__rocketchat_url }}" +{% endif %} +} + +SUBJECT_PREFIX="{{ system_update__mail_subject_prefix }}{{ system_update__mail_subject_hostname }}" +SENDER="$SUBJECT_PREFIX <{{ system_update__mail_from }}>" +RECIPIENTS="{{ system_update__mail_recipients_updates | join(' ') }}" + +# the security lane only acts when at least one of the configured security +# repositories is enabled on this host (provided by the repo_baseos role). skip +# silently otherwise, so the timer can default to enabled without acting on hosts +# where the repo is absent or was opted out via repo_baseos__security_repo_enabled. +have_repo=0 +for repo in {{ system_update__security_repos | join(' ') }}; do + if dnf repolist --enabled 2> /dev/null | awk 'NR > 1 { print $1 }' | grep -qxF "$repo"; then + have_repo=1 + fi +done +if [ "$have_repo" -eq 0 ]; then + exit 0 +fi + +# install security hot-fixes from the security repo only. --disablerepo / --enablerepo +# keeps this lane separate from the regular (weekly) update lane and from the frozen +# mirror snapshot. +yum --disablerepo="*" --enablerepo="$REPOS" -y upgrade 1> /tmp/security-update 2> /tmp/security-update-stderr +if [ $? -ne 0 ]; then + SUBJECT="$SUBJECT_PREFIX - Security update failed" + MSGBODY=$( /dev/null +reboothint_rc=$? +needs-restarting 2> /dev/null > /tmp/security-needs-restarting + +if [ "$reboothint_rc" -eq 1 ] || [ -s /tmp/security-needs-restarting ]; then + # drop a reboot scheduled by an earlier run today before scheduling a new one + for job in $(atq -q "$AT_QUEUE" | awk '{print $1}'); do + atrm "$job" + done + echo "/usr/local/bin/security-reboot" | at -q "$AT_QUEUE" {{ system_update__security_reboot_time__combined_var }} 2> /dev/null + SUBJECT="$SUBJECT_PREFIX - Security update installed, reboot scheduled at {{ system_update__security_reboot_time__combined_var }}" + MSGBODY=$(yum history info) + send_msg + exit 0 +fi + +SUBJECT="$SUBJECT_PREFIX - Security update installed without reboot" +MSGBODY=$(cat /tmp/security-update) +send_msg