diff --git a/CHANGELOG.md b/CHANGELOG.md index f4994fb3..8a93e9a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +* **role:chromium_headless**: New role. Provides a hardened, socket-activated headless Chromium backend (started on the first request, stopped again after an idle timeout, so it uses no RAM while unused) for tools such as the Icinga Web 2 PDF Export Module. Installs `chromium-headless` from EPEL instead of Google's proprietary repository. * **role:sshd**: Add Debian 13 support. * **role:mirror**: Document the new per-repository `newest_only` subkey on `mirror__reposync_repos` entries. Defaults to `true` (only the newest version of each package is mirrored). Set to `false` for repositories that publish multiple versions in parallel, such as Icinga, where older versions must remain available. * **role:repo_remi**: Add RHEL 10 / Rocky 10 support (new GPG key, repo templates, and module-stream tasks for EL 10). @@ -63,6 +64,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +* **role:icingaweb2_module_pdfexport**: PDF export now works out of the box. The headless browser backend the module needs is installed and configured automatically via the new `chromium_headless` role (wired into the `icingaweb2_module_pdfexport` and `setup_icinga2_master` playbooks); previously it had to be set up by hand, so fresh deployments ended up without working PDF export. * **role:nextcloud**: The `nextcloud-update` script now owns the maintenance mode lifecycle itself instead of expecting callers to enable it beforehand. Previously, callers enabled maintenance mode before invoking the script (to protect the DB dump), which disables the LDAP user provider and causes the `before-update` export (`occ user:list`, `config:list`, `app:list`) to silently omit LDAP users. The script now assumes maintenance mode is **off** at start, runs the `before-update` export with apps loaded, lets `updater.phar` manage maintenance mode itself, and explicitly disables it again before `occ upgrade` and `occ app:update` (since `occ upgrade` does not turn it off on its own) — so all post-upgrade commands (`app:update`, `db:add-missing-*`, `db:convert-filecache-bigint`, the `after-update` export) also run with apps loaded. Callers must drop the manual `maintenance:mode --on` step from their pre-script workflow; the DB dump should rely on `--single-transaction` instead. * **roles**: Set `become: false` on tasks delegated to localhost across the collection. Previously these tasks inherited `become: true` from the playbook level and tried to call `sudo` on the Ansible controller, which fails on controllers without a passwordless sudo setup with `sudo: a password is required`. Affected are all `repo_*` roles, the `*_vm` cloud roles (`exoscale_vm`, `hetzner_vm`, `infomaniak_vm`), all `icingaweb2_module_*` roles that download artefacts, `monitoring_plugins`, `shared`, plus several others. Existing playbooks that were working without playbook-level `become: true` are unaffected ([#242](https://github.com/Linuxfabrik/lfops/issues/242)). diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 021c3f69..99413fbd 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -16,6 +16,7 @@ Which Ansible role is proven to run on which OS? | bind | | | x | x | x | | | | | | blocky | | | x | x | (x) | | | | | | borg_local | | | x | (x) | (x) | | | | | +| chromium_headless | | | x | x | (x) | | | | | | chrony | | | x | x | x | | | | | | clamav | | | x | x | (x) | | | | | | cloud_init | (x) | (x) | x | x | x | (x) | (x) | (x) | | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 71196ae8..a76985b2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -672,6 +672,11 @@ The following roles use techniques that are unusual within LFOps. Roles not in t * [grav](https://github.com/Linuxfabrik/lfops/tree/main/roles/grav): Four separate `chmod` passes (files `664`, `bin/` `775`, directories `775`, plus a setgid pass on directories), each registered with `changed_when` based on the `--changes` output for idempotency. +#### systemd socket activation with an on-demand backend + +* [chromium_headless](https://github.com/Linuxfabrik/lfops/tree/main/roles/chromium_headless): Fronts a long-running daemon (Chromium, which does not implement the systemd socket-activation protocol) with a `systemd-socket-proxyd`. A `.socket` unit binds the public port, the proxy forwards to the backend on `127.0.0.1` and exits after an idle timeout, and `BindsTo=` ties the backend's lifecycle to the proxy so it starts on the first request and stops when idle. + + #### Other * [apache_solr](https://github.com/Linuxfabrik/lfops/tree/main/roles/apache_solr): Picks the matching OpenJDK package for the configured Solr major version (Solr 9 → OpenJDK 17, Solr 8 → OpenJDK 8) via a per-major-version lookup in `vars/main.yml`. diff --git a/playbooks/README.md b/playbooks/README.md index 3d95e807..810a660f 100644 --- a/playbooks/README.md +++ b/playbooks/README.md @@ -109,6 +109,14 @@ Calls the following roles (in order): * [borg_local](https://github.com/Linuxfabrik/lfops/tree/main/roles/borg_local) +## chromium_headless.yml + +Calls the following roles (in order): + +* [repo_epel](https://github.com/Linuxfabrik/lfops/tree/main/roles/repo_epel): `chromium_headless__skip_repo_epel` +* [chromium_headless](https://github.com/Linuxfabrik/lfops/tree/main/roles/chromium_headless) + + ## chrony.yml Calls the following roles (in order): @@ -445,6 +453,8 @@ Calls the following roles (in order): Calls the following roles (in order): +* [repo_epel](https://github.com/Linuxfabrik/lfops/tree/main/roles/repo_epel): `icingaweb2_module_pdfexport__skip_repo_epel` +* [chromium_headless](https://github.com/Linuxfabrik/lfops/tree/main/roles/chromium_headless): `icingaweb2_module_pdfexport__skip_chromium_headless` * [icingaweb2_module_pdfexport](https://github.com/Linuxfabrik/lfops/tree/main/roles/icingaweb2_module_pdfexport) @@ -1097,6 +1107,7 @@ Calls the following roles (in order): * [icingaweb2_theme_linuxfabrik](https://github.com/Linuxfabrik/lfops/tree/main/roles/icingaweb2_theme_linuxfabrik): `setup_icinga2_master__icingaweb2_theme_linuxfabrik__skip_role` * [icingaweb2_module_incubator](https://github.com/Linuxfabrik/lfops/tree/main/roles/icingaweb2_module_incubator): `setup_icinga2_master__icingaweb2_module_incubator__skip_role` * [icingaweb2_module_jira](https://github.com/Linuxfabrik/lfops/tree/main/roles/icingaweb2_module_jira): `setup_icinga2_master__icingaweb2_module_jira__skip_role` (default: `true`) +* [chromium_headless](https://github.com/Linuxfabrik/lfops/tree/main/roles/chromium_headless): `setup_icinga2_master__chromium_headless__skip_role` (default: tracks `icingaweb2_module_pdfexport__skip_role`) * [icingaweb2_module_pdfexport](https://github.com/Linuxfabrik/lfops/tree/main/roles/icingaweb2_module_pdfexport): `setup_icinga2_master__icingaweb2_module_pdfexport__skip_role` (default: `true`) * [icingaweb2_module_vspheredb](https://github.com/Linuxfabrik/lfops/tree/main/roles/icingaweb2_module_vspheredb): `setup_icinga2_master__icingaweb2_module_vspheredb__skip_role` (default: `true`) * [icingaweb2_module_director](https://github.com/Linuxfabrik/lfops/tree/main/roles/icingaweb2_module_director): `setup_icinga2_master__icingaweb2_module_director__skip_role` diff --git a/playbooks/all.yml b/playbooks/all.yml index 067d14fd..5d66638c 100644 --- a/playbooks/all.yml +++ b/playbooks/all.yml @@ -10,6 +10,7 @@ - import_playbook: 'bind.yml' - import_playbook: 'blocky.yml' - import_playbook: 'borg_local.yml' +- import_playbook: 'chromium_headless.yml' - import_playbook: 'chrony.yml' - import_playbook: 'clamav.yml' - import_playbook: 'cloud_init.yml' diff --git a/playbooks/chromium_headless.yml b/playbooks/chromium_headless.yml new file mode 100644 index 00000000..0500b191 --- /dev/null +++ b/playbooks/chromium_headless.yml @@ -0,0 +1,27 @@ +- name: 'Playbook linuxfabrik.lfops.chromium_headless' + hosts: + - 'lfops_chromium_headless' + + pre_tasks: + - ansible.builtin.import_role: + name: 'shared' + tasks_from: 'log-start.yml' + tags: + - 'always' + + + roles: + + - role: 'linuxfabrik.lfops.repo_epel' + when: + - 'not chromium_headless__skip_repo_epel | d(false) | bool' + + - role: 'linuxfabrik.lfops.chromium_headless' + + + post_tasks: + - ansible.builtin.import_role: + name: 'shared' + tasks_from: 'log-end.yml' + tags: + - 'always' diff --git a/playbooks/icingaweb2_module_pdfexport.yml b/playbooks/icingaweb2_module_pdfexport.yml index 82ab5a9d..57ce3f4e 100644 --- a/playbooks/icingaweb2_module_pdfexport.yml +++ b/playbooks/icingaweb2_module_pdfexport.yml @@ -12,6 +12,14 @@ roles: + - role: 'linuxfabrik.lfops.repo_epel' + when: + - 'not icingaweb2_module_pdfexport__skip_repo_epel | d(false) | bool' + + - role: 'linuxfabrik.lfops.chromium_headless' + when: + - 'not icingaweb2_module_pdfexport__skip_chromium_headless | d(false) | bool' + - role: 'linuxfabrik.lfops.icingaweb2_module_pdfexport' diff --git a/playbooks/setup_icinga2_master.yml b/playbooks/setup_icinga2_master.yml index c795bdd4..68a66a9a 100644 --- a/playbooks/setup_icinga2_master.yml +++ b/playbooks/setup_icinga2_master.yml @@ -7,6 +7,7 @@ setup_icinga2_master__apache_httpd__skip_injections__internal_var: '{{ setup_icinga2_master__apache_httpd__skip_injections | d(setup_icinga2_master__apache_httpd__skip_role__internal_var) }}' setup_icinga2_master__apache_httpd__skip_role__internal_var: '{{ setup_icinga2_master__apache_httpd__skip_role | d(false) }}' + setup_icinga2_master__chromium_headless__skip_role__internal_var: '{{ setup_icinga2_master__chromium_headless__skip_role | d(setup_icinga2_master__icingaweb2_module_pdfexport__skip_role__internal_var) }}' setup_icinga2_master__grafana__skip_role__internal_var: '{{ setup_icinga2_master__grafana__skip_role | d(false) }}' setup_icinga2_master__grafana_grizzly__skip_injections__internal_var: '{{ setup_icinga2_master__grafana_grizzly__skip_injections | d(setup_icinga2_master__grafana_grizzly__skip_role__internal_var) }}' setup_icinga2_master__grafana_grizzly__skip_role__internal_var: '{{ setup_icinga2_master__grafana_grizzly__skip_role | d(false) }}' @@ -312,6 +313,10 @@ when: - 'not setup_icinga2_master__icingaweb2_module_jira__skip_role__internal_var' + - role: 'linuxfabrik.lfops.chromium_headless' + when: + - 'not setup_icinga2_master__chromium_headless__skip_role__internal_var' + - role: 'linuxfabrik.lfops.icingaweb2_module_pdfexport' when: - 'not setup_icinga2_master__icingaweb2_module_pdfexport__skip_role__internal_var' diff --git a/roles/chromium_headless/README.md b/roles/chromium_headless/README.md new file mode 100644 index 00000000..b2935150 --- /dev/null +++ b/roles/chromium_headless/README.md @@ -0,0 +1,135 @@ +# Ansible Role linuxfabrik.lfops.chromium_headless + +This role installs the headless [Chromium](https://www.chromium.org/) shell together with the runtime libraries and fonts required for headless rendering, and sets up a socket-activated `chromium-headless` systemd service. Clients connect to a configurable TCP socket; Chromium is started on the first request via `systemd-socket-proxyd` and stopped again after a configurable idle timeout, so no RAM is wasted while the backend is unused. + +The setup is used as a headless browser backend for tools such as the [Icinga Web 2 PDF Export Module](https://github.com/Icinga/icingaweb2-module-pdfexport). + + +*Available since LFOps `6.0.2`.* + + +## How the Role Behaves + +* Three systemd units are deployed: + * `chromium-headless-proxy.socket` listens on `listen_address:listen_port` (default `127.0.0.1:9222`). + * `chromium-headless-proxy.service` runs `systemd-socket-proxyd`, which bridges the activated socket to Chromium (Chromium itself does not implement the systemd socket-activation protocol), forwarding traffic to `backend_port` (default `9223`). On `idle_timeout` seconds without traffic it exits. On EL8 (systemd 239) the underlying `--exit-idle-time` option does not exist, so the idle shutdown is skipped there and the backend stays resident once activated; on-demand start still works. + * `chromium-headless.service` runs the actual Chromium process under the `chromium` system user. Its start job is held until Chromium's debugging port actually accepts connections, so the proxy (ordered after it) never races ahead and fails the first request. It is bound to the proxy via `BindsTo=`, so when the proxy exits on idle, Chromium stops too; the SIGTERM-triggered exit is treated as clean so this does not mark the unit failed. It is **not** enabled on boot and must not be started directly. The proxy triggers it via `Requires=`. +* On SELinux-enforcing hosts, two booleans are enabled: `systemd_socket_proxyd_bind_any` so the `chromium-headless-proxy.socket` unit may bind the listen port even when it carries an unexpected SELinux port type (on Rocky/RHEL 9 the default `9222` is registered as `hplip_port_t`), and `systemd_socket_proxyd_connect_any` so the proxy may connect to Chromium's non-standard backend port. +* The service-lifecycle variables (`chromium_headless__service_enabled`, `__service_state`) manage the `chromium-headless-proxy.socket` unit, not the Chromium service directly. + + +## Mandatory Requirements + +* Enable the EPEL repository, which provides `chromium-headless`. This can be done using the [linuxfabrik.lfops.repo_epel](https://github.com/Linuxfabrik/lfops/tree/main/roles/repo_epel) role. + +If you use the [Chromium Headless Playbook](https://github.com/Linuxfabrik/lfops/blob/main/playbooks/chromium_headless.yml), this is automatically done for you. + + +## Tags + +`chromium_headless` + +* Creates the `chromium` system user and group. +* Installs Chromium along with the required runtime libraries and fonts. +* Sets the `systemd_socket_proxyd_bind_any` and `systemd_socket_proxyd_connect_any` SELinux booleans. +* Deploys all three systemd units (`chromium-headless-proxy.socket`, `chromium-headless-proxy.service`, `chromium-headless.service`). +* Ensures the `chromium-headless-proxy.socket` is in the desired state. +* Triggers: daemon-reload on any unit-file change; socket restart only on `chromium-headless-proxy.socket` changes. Changes to the proxy or Chromium service unit file take effect on the next socket-activation cycle. + +`chromium_headless:configure` + +* Deploys the three systemd units (`chromium-headless-proxy.socket`, `chromium-headless-proxy.service`, `chromium-headless.service`). +* Triggers: daemon-reload on any unit-file change; socket restart only on `chromium-headless-proxy.socket` changes. Changes to the proxy or Chromium service unit file take effect on the next socket-activation cycle. + +`chromium_headless:state` + +* Manages the `chromium-headless-proxy.socket` state (start, stop, enable, disable). +* Triggers: none. + + +## Optional Role Variables + +`chromium_headless__backend_port` + +* Internal port Chromium itself listens on. The proxy forwards traffic from `listen_port` to this port. Only meaningful to change if `listen_port` and `backend_port` would otherwise collide. +* Type: Number. +* Default: `9223` + +`chromium_headless__extra_args__host_var` / `chromium_headless__extra_args__group_var` + +* Additional Chromium CLI flags appended to the `ExecStart` line of `chromium-headless.service`, in the order listed. Useful for tuning behavior without overwriting the whole unit. +* Type: List of dictionaries. +* Default: `[]` +* Subkeys: + + * `name`: + + * Mandatory. The CLI flag, including any leading dashes and value (e.g. `--window-size=1920,1080`). + * Type: String. + + * `state`: + + * Optional. `present` or `absent`. + * Type: String. + * Default: `'present'` + +`chromium_headless__idle_timeout` + +* Seconds the `systemd-socket-proxyd` waits without active connections before exiting. When it exits, the bound `chromium-headless.service` stops automatically. The next inbound connection re-activates the whole chain, paying ~1-2 seconds of cold-start latency. Has no effect on EL8, whose systemd (239) lacks the `--exit-idle-time` option; the backend stays resident there. +* Type: Number. +* Default: `300` + +`chromium_headless__listen_address` + +* Address the `chromium-headless-proxy.socket` binds to. Chromium's own debugging port always stays on `127.0.0.1` regardless of this setting, so only the proxy is reachable here. Keep this on `127.0.0.1` unless you intentionally want to expose the proxy to other hosts; it enforces neither TLS nor authentication. +* Type: String. +* Default: `'127.0.0.1'` + +`chromium_headless__listen_port` + +* Port the proxy socket listens on. This is the endpoint clients connect to. +* Type: Number. +* Default: `9222` + +`chromium_headless__service_enabled` + +* Enables or disables the `chromium-headless-proxy.socket` at boot, analogous to `systemctl enable/disable --now`. +* Type: Bool. +* Default: `true` + +`chromium_headless__service_state` + +* Changes the state of the `chromium-headless-proxy.socket`, analogous to `systemctl start/stop/restart/reload`. +* Type: String. One of `reloaded`, `restarted`, `started`, `stopped`. +* Default: `'started'` + +`chromium_headless__user_data_dir` + +* Home directory of the `chromium` system user and Chromium user data directory. Used both as the user's `home`, as the `--user-data-dir` value for Chromium, and as the writable path exposed via systemd `ReadWritePaths=`. +* Type: String. +* Default: `'/var/lib/chromium-headless'` + +Example: +```yaml +# optional +chromium_headless__backend_port: 9223 +chromium_headless__extra_args__host_var: + - name: '--window-size=1920,1080' + - name: '--lang=de-CH' +chromium_headless__idle_timeout: 600 +chromium_headless__listen_address: '127.0.0.1' +chromium_headless__listen_port: 9222 +chromium_headless__service_enabled: true +chromium_headless__service_state: 'started' +chromium_headless__user_data_dir: '/var/lib/chromium-headless' +``` + + +## License + +[The Unlicense](https://unlicense.org/) + + +## Author Information + +[Linuxfabrik GmbH, Zurich](https://www.linuxfabrik.ch) diff --git a/roles/chromium_headless/defaults/main.yml b/roles/chromium_headless/defaults/main.yml new file mode 100644 index 00000000..52fa488d --- /dev/null +++ b/roles/chromium_headless/defaults/main.yml @@ -0,0 +1,18 @@ +chromium_headless__backend_port: 9223 +chromium_headless__extra_args__dependent_var: [] +chromium_headless__extra_args__group_var: [] +chromium_headless__extra_args__host_var: [] +chromium_headless__extra_args__role_var: [] +chromium_headless__extra_args__combined_var: '{{ ( + chromium_headless__extra_args__role_var + + chromium_headless__extra_args__dependent_var + + chromium_headless__extra_args__group_var + + chromium_headless__extra_args__host_var + ) | linuxfabrik.lfops.combine_lod + }}' +chromium_headless__idle_timeout: 300 +chromium_headless__listen_address: '127.0.0.1' +chromium_headless__listen_port: 9222 +chromium_headless__service_enabled: true +chromium_headless__service_state: 'started' +chromium_headless__user_data_dir: '/var/lib/chromium-headless' diff --git a/roles/chromium_headless/handlers/main.yml b/roles/chromium_headless/handlers/main.yml new file mode 100644 index 00000000..ef8cb21b --- /dev/null +++ b/roles/chromium_headless/handlers/main.yml @@ -0,0 +1,17 @@ +# Only socket-template changes trigger an immediate restart, because the socket unit +# is what binds the externally-visible listen_address:listen_port. Changes to the +# proxy or Chromium service templates only need daemon-reload: the running proxy and +# Chromium process keep going with the old settings until the next idle timeout, and +# the next socket-activation cycle re-spawns them with the updated unit files. +# +# `is not defined` covers the `--tags chromium_headless:configure` run, where the state +# block is skipped and __chromium_headless__service_state_result is never registered. +# `is not changed` covers the normal flow: skip the restart if the state task just +# (re-)started the socket. +- name: 'chromium_headless: restart chromium-headless-proxy.socket' + ansible.builtin.service: + name: 'chromium-headless-proxy.socket' + state: 'restarted' + when: + - '__chromium_headless__service_state_result is not defined or __chromium_headless__service_state_result is not changed' + - 'chromium_headless__service_state != "stopped"' diff --git a/roles/chromium_headless/meta/argument_specs.yml b/roles/chromium_headless/meta/argument_specs.yml new file mode 100644 index 00000000..f77d9561 --- /dev/null +++ b/roles/chromium_headless/meta/argument_specs.yml @@ -0,0 +1,71 @@ +argument_specs: + main: + options: + + chromium_headless__backend_port: + type: 'int' + required: false + default: 9223 + description: 'Internal port Chromium itself listens on. The proxy forwards traffic from listen_port to this port.' + + chromium_headless__extra_args__dependent_var: + type: 'list' + elements: 'dict' + required: false + default: [] + description: 'Extra Chromium CLI flags. Dependent-role injection.' + + chromium_headless__extra_args__group_var: + type: 'list' + elements: 'dict' + required: false + default: [] + description: 'Extra Chromium CLI flags. Group-level override.' + + chromium_headless__extra_args__host_var: + type: 'list' + elements: 'dict' + required: false + default: [] + description: 'Extra Chromium CLI flags. Host-level override.' + + chromium_headless__idle_timeout: + type: 'int' + required: false + default: 300 + description: 'Seconds the systemd-socket-proxyd waits without traffic before exiting (and stopping Chromium via BindsTo).' + + chromium_headless__listen_address: + type: 'str' + required: false + default: '127.0.0.1' + description: 'Listen address for the Chromium remote debugging interface.' + + chromium_headless__listen_port: + type: 'int' + required: false + default: 9222 + description: 'Listen port for the Chromium remote debugging interface.' + + chromium_headless__service_enabled: + type: 'bool' + required: false + default: true + description: 'Enables or disables the chromium-headless-proxy.socket unit at boot. The Chromium service itself is triggered on demand by the proxy and is not managed directly.' + + chromium_headless__service_state: + type: 'str' + required: false + default: 'started' + choices: + - 'reloaded' + - 'restarted' + - 'started' + - 'stopped' + description: 'Desired state of the chromium-headless-proxy.socket unit. The Chromium service itself is triggered on demand by the proxy and is not managed directly.' + + chromium_headless__user_data_dir: + type: 'str' + required: false + default: '/var/lib/chromium-headless' + description: 'Home directory of the chromium system user and Chromium user data directory.' diff --git a/roles/chromium_headless/tasks/main.yml b/roles/chromium_headless/tasks/main.yml new file mode 100644 index 00000000..c7294aaa --- /dev/null +++ b/roles/chromium_headless/tasks/main.yml @@ -0,0 +1,157 @@ +- block: + + - name: 'Set platform/version specific variables' + ansible.builtin.import_role: + name: 'shared' + tasks_from: 'platform-variables.yml' + + tags: + - 'always' + + +- block: + + - name: 'groupadd --system chromium' + ansible.builtin.group: + name: 'chromium' + state: 'present' + system: true + + - name: 'useradd --system chromium' + ansible.builtin.user: + name: 'chromium' + comment: 'Headless Chromium' + group: 'chromium' + home: '{{ chromium_headless__user_data_dir }}' + shell: '/sbin/nologin' + system: true + state: 'present' + + - name: 'install --directory --owner chromium --group chromium --mode 0750 {{ chromium_headless__user_data_dir }}' + ansible.builtin.file: + path: '{{ chromium_headless__user_data_dir }}' + state: 'directory' + owner: 'chromium' + group: 'chromium' + mode: 0o750 + + - name: 'Install required packages' + ansible.builtin.package: + name: '{{ __chromium_headless__packages }}' + state: 'present' + + tags: + - 'chromium_headless' + + +# Chromium itself does not implement the systemd socket-activation protocol (sd_listen_fds() / LISTEN_FDS); it always binds its own --remote-debugging-port. Direct socket activation is therefore not possible, so we deploy a systemd-socket-proxyd in front of Chromium: the .socket unit binds listen_port, the proxy accepts on that activated fd and forwards to backend_port (the port Chromium opens itself). --exit-idle-time on the proxy plus BindsTo= on chromium-headless.service ties Chromium's lifecycle to the proxy, so Chromium stops together with the proxy after the idle timeout. +# Two SELinux booleans are required: bind_any so the .socket can bind the listen port even when it has an unexpected port type (e.g. on Rocky 9, port 9222 maps to hplip_port_t and would otherwise reject the bind); connect_any so the proxy can connect to Chromium's non-standard backend port, which has no matching SELinux port type. +- block: + + - name: 'setsebool -P systemd_socket_proxyd_bind_any on' + ansible.posix.seboolean: + name: 'systemd_socket_proxyd_bind_any' + persistent: true + state: true + + - name: 'setsebool -P systemd_socket_proxyd_connect_any on' + ansible.posix.seboolean: + name: 'systemd_socket_proxyd_connect_any' + persistent: true + state: true + + # block + when: + - 'ansible_facts["selinux"]["status"] != "disabled"' + tags: + - 'chromium_headless' + + +- block: + + - name: 'Deploy /etc/systemd/system/chromium-headless-proxy.socket' + ansible.builtin.template: + backup: true + src: 'etc/systemd/system/chromium-headless-proxy.socket.j2' + dest: '/etc/systemd/system/chromium-headless-proxy.socket' + owner: 'root' + group: 'root' + mode: 0o644 + register: '__chromium_headless__deploy_socket_result' + notify: + - 'chromium_headless: restart chromium-headless-proxy.socket' + + - name: 'Deploy /etc/systemd/system/chromium-headless-proxy.service' + ansible.builtin.template: + backup: true + src: 'etc/systemd/system/chromium-headless-proxy.service.j2' + dest: '/etc/systemd/system/chromium-headless-proxy.service' + owner: 'root' + group: 'root' + mode: 0o644 + register: '__chromium_headless__deploy_proxy_result' + + - name: 'Deploy /etc/systemd/system/chromium-headless.service' + ansible.builtin.template: + backup: true + src: 'etc/systemd/system/chromium-headless.service.j2' + dest: '/etc/systemd/system/chromium-headless.service' + owner: 'root' + group: 'root' + mode: 0o644 + register: '__chromium_headless__deploy_service_result' + + - name: 'Remove rpmnew / rpmsave (and Debian equivalents)' + ansible.builtin.include_role: + name: 'shared' + tasks_from: 'remove-rpmnew-rpmsave.yml' + vars: + shared__remove_rpmnew_rpmsave_config_file: '{{ item }}' + loop: + - '/etc/systemd/system/chromium-headless-proxy.socket' + - '/etc/systemd/system/chromium-headless-proxy.service' + - '/etc/systemd/system/chromium-headless.service' + + # Run daemon-reload as a regular task (not as a handler), so it runs before the + # state block below and so the restart-socket handler can rely on the registered + # __chromium_headless__service_state_result to skip redundant restarts. + - name: 'systemctl daemon-reload' + ansible.builtin.systemd: + daemon_reload: true + when: + - '__chromium_headless__deploy_socket_result is changed or + __chromium_headless__deploy_proxy_result is changed or + __chromium_headless__deploy_service_result is changed' + + tags: + - 'chromium_headless' + - 'chromium_headless:configure' + + +- block: + + - name: 'systemctl {{ chromium_headless__service_enabled | bool | ternary("enable", "disable") }} chromium-headless-proxy.socket' + ansible.builtin.service: + name: 'chromium-headless-proxy.socket' + enabled: '{{ chromium_headless__service_enabled | bool }}' + + - name: 'systemctl {{ chromium_headless__service_state }} chromium-headless-proxy.socket' + ansible.builtin.service: + name: 'chromium-headless-proxy.socket' + state: '{{ chromium_headless__service_state }}' + register: '__chromium_headless__service_state_result' + + tags: + - 'chromium_headless' + - 'chromium_headless:state' + + +- block: + + - name: 'Flush handlers so that the service is ready for dependent roles' + ansible.builtin.meta: 'flush_handlers' + + tags: + - 'chromium_headless' + - 'chromium_headless:configure' + - 'chromium_headless:state' diff --git a/roles/chromium_headless/templates/etc/systemd/system/chromium-headless-proxy.service.j2 b/roles/chromium_headless/templates/etc/systemd/system/chromium-headless-proxy.service.j2 new file mode 100644 index 00000000..cf7c5238 --- /dev/null +++ b/roles/chromium_headless/templates/etc/systemd/system/chromium-headless-proxy.service.j2 @@ -0,0 +1,19 @@ +# {{ ansible_managed }} +# 2026052102 + +[Unit] +Description=Proxy to on-demand Headless Chromium +Requires=chromium-headless.service +After=chromium-headless.service + +[Service] +{# The proxy always connects to the backend on 127.0.0.1, never chromium_headless__listen_address: Chromium binds its unauthenticated debugging port locally only (see chromium-headless.service). #} +{# --exit-idle-time was added in systemd 246. EL8 ships systemd 239 and rejects the option, so we omit it there; the proxy (and the Chromium service bound to it) then stays resident once activated. EL9 (systemd 252) and newer support it. #} +{% if ansible_facts['distribution_major_version'] | int >= 9 %} +ExecStart=/usr/lib/systemd/systemd-socket-proxyd --exit-idle-time={{ chromium_headless__idle_timeout }} 127.0.0.1:{{ chromium_headless__backend_port }} +{% else %} +ExecStart=/usr/lib/systemd/systemd-socket-proxyd 127.0.0.1:{{ chromium_headless__backend_port }} +{% endif %} +PrivateTmp=true +Restart=on-failure +RestartSec=5 diff --git a/roles/chromium_headless/templates/etc/systemd/system/chromium-headless-proxy.socket.j2 b/roles/chromium_headless/templates/etc/systemd/system/chromium-headless-proxy.socket.j2 new file mode 100644 index 00000000..f20198f2 --- /dev/null +++ b/roles/chromium_headless/templates/etc/systemd/system/chromium-headless-proxy.socket.j2 @@ -0,0 +1,11 @@ +# {{ ansible_managed }} +# 2026052002 + +[Unit] +Description=Socket for on-demand Headless Chromium + +[Socket] +ListenStream={{ chromium_headless__listen_address }}:{{ chromium_headless__listen_port }} + +[Install] +WantedBy=sockets.target diff --git a/roles/chromium_headless/templates/etc/systemd/system/chromium-headless.service.j2 b/roles/chromium_headless/templates/etc/systemd/system/chromium-headless.service.j2 new file mode 100644 index 00000000..92b06af0 --- /dev/null +++ b/roles/chromium_headless/templates/etc/systemd/system/chromium-headless.service.j2 @@ -0,0 +1,59 @@ +# {{ ansible_managed }} +# 2026052102 + +[Unit] +Description=Headless Chromium +BindsTo=chromium-headless-proxy.service + +[Service] +Type=simple +User=chromium +Group=chromium +# The backend always binds 127.0.0.1, never chromium_headless__listen_address. Only the proxy socket is the public endpoint (it may bind a routable listen_address); Chromium's raw remote-debugging port has no authentication, so it must never be reachable off-host. The proxy connects to it locally. +ExecStart={{ __chromium_headless__binary_path }} \ + --headless=new \ + --disable-gpu \ + --no-first-run \ + --no-default-browser-check \ + --hide-scrollbars \ + --disable-dev-shm-usage \ + --remote-debugging-address=127.0.0.1 \ + --remote-debugging-port={{ chromium_headless__backend_port }} \ + --remote-allow-origins=http://{{ chromium_headless__listen_address }}:{{ chromium_headless__listen_port }} \ +{% for arg in chromium_headless__extra_args__combined_var if arg['state'] | d('present') != 'absent' %} + {{ arg['name'] }} \ +{% endfor %} + --user-data-dir={{ chromium_headless__user_data_dir }} +# Chromium binds its debugging port a moment after the process starts and does not implement systemd socket activation. Block the start job until the port accepts connections, otherwise systemd-socket-proxyd (ordered After= this unit) connects too early and the first request fails with "Connection refused". +ExecStartPost=/bin/bash -c 'until (echo > /dev/tcp/127.0.0.1/{{ chromium_headless__backend_port }}) 2>/dev/null; do sleep 0.1; done' +Restart=on-failure +RestartSec=5 +# Chromium traps SIGTERM and exits 143 (128 + SIGTERM); treat that as a clean shutdown so stopping the bound proxy on idle does not mark this unit failed. +SuccessExitStatus=143 + +PrivateDevices=true +ProtectClock=true +NoNewPrivileges=true +RemoveIPC=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths={{ chromium_headless__user_data_dir }} +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX +RestrictNamespaces=~cgroup uts ipc +LockPersonality=true +# Must stay false: Chromium's V8 JIT maps writable+executable pages, which MemoryDenyWriteExecute=true would kill at startup. +MemoryDenyWriteExecute=false +CapabilityBoundingSet= +AmbientCapabilities= +SystemCallArchitectures=native +ProtectKernelLogs=true +ProtectHostname=true +ProtectProc=invisible +ProcSubset=pid +RestrictSUIDSGID=true +RestrictRealtime=true +UMask=0077 diff --git a/roles/chromium_headless/vars/RedHat.yml b/roles/chromium_headless/vars/RedHat.yml new file mode 100644 index 00000000..d31bf926 --- /dev/null +++ b/roles/chromium_headless/vars/RedHat.yml @@ -0,0 +1,4 @@ +__chromium_headless__binary_path: '/usr/lib64/chromium-browser/headless_shell' +__chromium_headless__packages: + - 'chromium-headless' + - 'gnu-free-sans-fonts' diff --git a/roles/icingaweb2_module_pdfexport/README.md b/roles/icingaweb2_module_pdfexport/README.md index 2e077d81..51a028bc 100644 --- a/roles/icingaweb2_module_pdfexport/README.md +++ b/roles/icingaweb2_module_pdfexport/README.md @@ -15,14 +15,17 @@ This role is tested with the following IcingaWeb2 PDF Export Module versions: * The Tarball for `icingaweb2_module_pdfexport__version` is downloaded on the Ansible controller (`delegate_to: 'localhost'`, `run_once: true`), then copied to the target. The controller therefore needs Internet access to GitHub; the target does not. * On every role run the directory `/usr/share/icingaweb2/modules/pdfexport` is overwritten with the contents of the configured version. To upgrade or downgrade the module, change `icingaweb2_module_pdfexport__version` and re-run the role. * `icingacli module enable pdfexport` is only invoked when `/etc/icingaweb2/enabledModules/pdfexport` does not yet exist (idempotent). -* This role only installs the IcingaWeb2 module itself. Any runtime dependencies of the module (see the [module documentation](https://github.com/Icinga/icingaweb2-module-pdfexport#requirements)) have to be installed and configured separately. +* `/etc/icingaweb2/modules/pdfexport/config.ini` is deployed on every run. By default the module is wired to a running headless Chromium over the Chrome DevTools Protocol (CDP); set `icingaweb2_module_pdfexport__chrome_binary` to fall back to spawning Chromium locally on every export. +* This role only installs and configures the IcingaWeb2 module itself. The headless browser backend it talks to (see the [module documentation](https://github.com/Icinga/icingaweb2-module-pdfexport#requirements)) is provided separately by the [linuxfabrik.lfops.chromium_headless](https://github.com/Linuxfabrik/lfops/tree/main/roles/chromium_headless) role. ## Mandatory Requirements * A configured IcingaWeb2. This can be done using the [linuxfabrik.lfops.icingaweb2](https://github.com/linuxfabrik/lfops/tree/main/roles/icingaweb2) role. * Internet access from the Ansible controller (downloads from `https://github.com/Icinga/icingaweb2-module-pdfexport/archive/`). -* The runtime dependencies listed in the [module documentation](https://github.com/Icinga/icingaweb2-module-pdfexport#requirements) (typically a headless browser binary). Install and configure them separately. +* A running headless Chromium instance providing the remote debugging interface this module talks to. This can be done using the [linuxfabrik.lfops.chromium_headless](https://github.com/Linuxfabrik/lfops/tree/main/roles/chromium_headless) role. + +If you use the [IcingaWeb2 PDF Export Playbook](https://github.com/Linuxfabrik/lfops/blob/main/playbooks/icingaweb2_module_pdfexport.yml), the headless Chromium backend is automatically installed for you. ## Tags @@ -30,6 +33,12 @@ This role is tested with the following IcingaWeb2 PDF Export Module versions: `icingaweb2_module_pdfexport` * Installs and enables the IcingaWeb2 PDF Export Module. +* Deploys `/etc/icingaweb2/modules/pdfexport/config.ini`. +* Triggers: none. + +`icingaweb2_module_pdfexport:configure` + +* Deploys `/etc/icingaweb2/modules/pdfexport/config.ini`. * Triggers: none. @@ -48,6 +57,43 @@ icingaweb2_module_pdfexport__version: 'v0.11.0' ``` +## Optional Role Variables + +`icingaweb2_module_pdfexport__chrome_binary` + +* Path to a local Chrome / Chromium binary. If set, the module spawns Chrome locally on every PDF export and the `chrome_host` / `chrome_port` settings are ignored. Leave empty (the default) to use the remote CDP mode. +* Type: String. +* Default: `''` + +`icingaweb2_module_pdfexport__chrome_host` + +* Address of the headless Chromium instance the module connects to via the Chrome DevTools Protocol. +* Type: String. +* Default: `'{{ chromium_headless__listen_address | d("127.0.0.1") }}'` + +`icingaweb2_module_pdfexport__chrome_port` + +* Port of the headless Chromium instance the module connects to via the Chrome DevTools Protocol. +* Type: Number. +* Default: `'{{ chromium_headless__listen_port | d(9222) }}'` + +`icingaweb2_module_pdfexport__force_temp_storage` + +* When `true`, the module renders every PDF to a temporary file on disk before sending it to the browser instead of streaming it directly. Useful as a workaround on memory-constrained hosts. +* Type: Bool. +* Default: `false` + +Example: + +```yaml +# optional +icingaweb2_module_pdfexport__chrome_binary: '/usr/lib64/chromium-browser/headless_shell' +icingaweb2_module_pdfexport__chrome_host: '127.0.0.1' +icingaweb2_module_pdfexport__chrome_port: 9222 +icingaweb2_module_pdfexport__force_temp_storage: false +``` + + ## License [The Unlicense](https://unlicense.org/) diff --git a/roles/icingaweb2_module_pdfexport/defaults/main.yml b/roles/icingaweb2_module_pdfexport/defaults/main.yml new file mode 100644 index 00000000..bc0b8ba7 --- /dev/null +++ b/roles/icingaweb2_module_pdfexport/defaults/main.yml @@ -0,0 +1,4 @@ +icingaweb2_module_pdfexport__chrome_binary: '' +icingaweb2_module_pdfexport__chrome_host: '{{ chromium_headless__listen_address | d("127.0.0.1") }}' +icingaweb2_module_pdfexport__chrome_port: '{{ chromium_headless__listen_port | d(9222) }}' +icingaweb2_module_pdfexport__force_temp_storage: false diff --git a/roles/icingaweb2_module_pdfexport/meta/argument_specs.yml b/roles/icingaweb2_module_pdfexport/meta/argument_specs.yml new file mode 100644 index 00000000..593bfc3e --- /dev/null +++ b/roles/icingaweb2_module_pdfexport/meta/argument_specs.yml @@ -0,0 +1,30 @@ +argument_specs: + main: + options: + + icingaweb2_module_pdfexport__chrome_binary: + type: 'str' + required: false + default: '' + description: 'Path to a local Chrome/Chromium binary. If set, the module spawns Chromium locally on every export and the host/port settings are ignored.' + + icingaweb2_module_pdfexport__chrome_host: + type: 'str' + required: false + description: 'Listen address of the headless Chromium instance the module connects to. Defaults to chromium_headless__listen_address.' + + icingaweb2_module_pdfexport__chrome_port: + type: 'raw' + required: false + description: 'Listen port of the headless Chromium instance the module connects to. Defaults to chromium_headless__listen_port.' + + icingaweb2_module_pdfexport__force_temp_storage: + type: 'bool' + required: false + default: false + description: 'When true, the module renders every PDF to a temporary file on disk before sending it to the browser instead of streaming it directly. Useful as a workaround on memory-constrained hosts.' + + icingaweb2_module_pdfexport__version: + type: 'str' + required: true + description: 'The IcingaWeb2 PDF Export Module version to install. See https://github.com/Icinga/icingaweb2-module-pdfexport/releases.' diff --git a/roles/icingaweb2_module_pdfexport/tasks/main.yml b/roles/icingaweb2_module_pdfexport/tasks/main.yml index e486d0a7..60b519e2 100644 --- a/roles/icingaweb2_module_pdfexport/tasks/main.yml +++ b/roles/icingaweb2_module_pdfexport/tasks/main.yml @@ -5,6 +5,12 @@ name: 'shared' tasks_from: 'platform-variables.yml' + tags: + - 'always' + + +- block: + - name: 'mkdir -p /usr/share/icingaweb2/modules/pdfexport' ansible.builtin.file: path: '/usr/share/icingaweb2/modules/pdfexport' @@ -13,7 +19,7 @@ group: 'icingaweb2' mode: 0o755 - - name: 'curl https://github.com/Icinga/icingaweb2-module-pdfexport/archive/{{ icingaweb2_module_pdfexport__version }}.tar.gz --output /tmp/ansible.icingaweb2-module-pdfexport-{{ icingaweb2_module_pdfexport__version }}.tar.gz' + - name: 'curl https://github.com/Icinga/icingaweb2-module-pdfexport/archive/{{ icingaweb2_module_pdfexport__version }}.tar.gz --output /tmp/ansible.icingaweb2-module-pdfexport-{{ icingaweb2_module_pdfexport__version }}.tar.gz' # noqa risky-file-permissions (temporary file) ansible.builtin.get_url: url: 'https://github.com/Icinga/icingaweb2-module-pdfexport/archive/{{ icingaweb2_module_pdfexport__version }}.tar.gz' dest: '/tmp/ansible.icingaweb2-module-pdfexport-{{ icingaweb2_module_pdfexport__version }}.tar.gz' @@ -22,7 +28,7 @@ run_once: true check_mode: false # run task even if `--check` is specified - - name: 'copy /tmp/ansible.icingaweb2-module-pdfexport-{{ icingaweb2_module_pdfexport__version }}.tar.gz to the server' + - name: 'copy /tmp/ansible.icingaweb2-module-pdfexport-{{ icingaweb2_module_pdfexport__version }}.tar.gz to the server' # noqa risky-file-permissions (temporary file) ansible.builtin.copy: src: '/tmp/ansible.icingaweb2-module-pdfexport-{{ icingaweb2_module_pdfexport__version }}.tar.gz' dest: '/tmp/ansible.icingaweb2-module-pdfexport-{{ icingaweb2_module_pdfexport__version }}.tar.gz' @@ -47,3 +53,34 @@ tags: - 'icingaweb2_module_pdfexport' + + +- block: + + - name: 'mkdir -p /etc/icingaweb2/modules/pdfexport' + ansible.builtin.file: + path: '/etc/icingaweb2/modules/pdfexport' + state: 'directory' + owner: '{{ __icingaweb2_module_pdfexport__icingaweb2_owner }}' + group: 'icingaweb2' + mode: 0o2770 + + - name: 'Deploy /etc/icingaweb2/modules/pdfexport/config.ini' + ansible.builtin.template: + backup: true + src: 'etc/icingaweb2/modules/pdfexport/config.ini.j2' + dest: '/etc/icingaweb2/modules/pdfexport/config.ini' + owner: '{{ __icingaweb2_module_pdfexport__icingaweb2_owner }}' + group: 'icingaweb2' + mode: 0o660 + + - name: 'Remove rpmnew / rpmsave (and Debian equivalents)' + ansible.builtin.include_role: + name: 'shared' + tasks_from: 'remove-rpmnew-rpmsave.yml' + vars: + shared__remove_rpmnew_rpmsave_config_file: '/etc/icingaweb2/modules/pdfexport/config.ini' + + tags: + - 'icingaweb2_module_pdfexport' + - 'icingaweb2_module_pdfexport:configure' diff --git a/roles/icingaweb2_module_pdfexport/templates/etc/icingaweb2/modules/pdfexport/config.ini.j2 b/roles/icingaweb2_module_pdfexport/templates/etc/icingaweb2/modules/pdfexport/config.ini.j2 new file mode 100644 index 00000000..d12b6162 --- /dev/null +++ b/roles/icingaweb2_module_pdfexport/templates/etc/icingaweb2/modules/pdfexport/config.ini.j2 @@ -0,0 +1,11 @@ +; {{ ansible_managed }} +; 2026051201 + +[chrome] +{% if icingaweb2_module_pdfexport__chrome_binary | length > 0 %} +binary = "{{ icingaweb2_module_pdfexport__chrome_binary }}" +{% else %} +host = "{{ icingaweb2_module_pdfexport__chrome_host }}" +port = "{{ icingaweb2_module_pdfexport__chrome_port }}" +{% endif %} +force_temp_storage = "{{ icingaweb2_module_pdfexport__force_temp_storage | bool | ternary('1', '0') }}"