diff --git a/CHANGELOG.md b/CHANGELOG.md index d8128648..77a9be9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,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:graylog_datanode, role:graylog_server**: Add template for Graylog 7.1. * **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. @@ -67,6 +68,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +* **role:redis**: Added missing paths for running against Debian. +* **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:graylog_datanode**: Fix the `Conditional result ... was of type 'str'` deprecation warning. * **role:graylog_server**: Validate that each `graylog_server__system_inputs` entry sets `global: true` or assigns a `node`. Key was marked as mandatory but not enforced. The role now aborts the `graylog_server:configure_defaults` run with a clear message. * **role:graylog_server**: Fix the `graylog_server:configure_defaults` run aborting on Graylog 7.0+ with `Status code was 400 and not [200]` / `Unable to map property can_be_default` while creating the default index set, by removing the property from the role. Graylog 7.x dropped it and 6.x ignored it. diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 021c3f69..132f6645 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 | 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 344eb32f..ba0e7a6e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -708,6 +708,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 beba382e..1babb9b2 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): @@ -447,6 +455,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) @@ -1105,6 +1115,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..ce4ca69a --- /dev/null +++ b/playbooks/chromium_headless.yml @@ -0,0 +1,28 @@ +- 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: + - 'ansible_facts["os_family"] == "RedHat"' + - '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..4724eee3 100644 --- a/playbooks/icingaweb2_module_pdfexport.yml +++ b/playbooks/icingaweb2_module_pdfexport.yml @@ -12,6 +12,15 @@ roles: + - role: 'linuxfabrik.lfops.repo_epel' + when: + - 'ansible_facts["os_family"] == "RedHat"' + - '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 7d39e234..c8073077 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) }}' @@ -313,6 +314,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..295522f0 --- /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 in the next LFOps release.* + + +## 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. + + +## Dependent Roles + +Any [LFOps playbook](https://github.com/Linuxfabrik/lfops/blob/main/playbooks/README.md) that installs this role runs these for you. Optional ones can be disabled via the playbook's skip variables. + +* On RHEL-compatible systems, the EPEL repository must be enabled (role: [linuxfabrik.lfops.repo_epel](https://github.com/Linuxfabrik/lfops/tree/main/roles/repo_epel)). It provides the `chromium-headless` package. On Debian, the `chromium-headless-shell` package ships in the default repositories, so no extra repository is required. + + +## 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..8e773f99 --- /dev/null +++ b/roles/chromium_headless/templates/etc/systemd/system/chromium-headless-proxy.service.j2 @@ -0,0 +1,19 @@ +# {{ ansible_managed }} +# 2026052103 + +[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. Platforms whose systemd predates it (EL8 ships 239) omit the option via __chromium_headless__proxy_exit_idle_time_supported; the proxy (and the Chromium service bound to it) then stays resident once activated. #} +{% if __chromium_headless__proxy_exit_idle_time_supported | bool %} +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/Debian.yml b/roles/chromium_headless/vars/Debian.yml new file mode 100644 index 00000000..4f995b7a --- /dev/null +++ b/roles/chromium_headless/vars/Debian.yml @@ -0,0 +1,6 @@ +__chromium_headless__binary_path: '/usr/bin/chromium-headless-shell' +__chromium_headless__packages: + - 'chromium-headless-shell' + - 'fonts-freefont-ttf' +# Debian 12 (systemd 252) and newer support systemd-socket-proxyd --exit-idle-time (added in systemd 246). +__chromium_headless__proxy_exit_idle_time_supported: true diff --git a/roles/chromium_headless/vars/RedHat.yml b/roles/chromium_headless/vars/RedHat.yml new file mode 100644 index 00000000..f045ac30 --- /dev/null +++ b/roles/chromium_headless/vars/RedHat.yml @@ -0,0 +1,6 @@ +__chromium_headless__binary_path: '/usr/lib64/chromium-browser/headless_shell' +__chromium_headless__packages: + - 'chromium-headless' + - 'gnu-free-sans-fonts' +# systemd-socket-proxyd --exit-idle-time (systemd 246+) is available on EL9 and newer; EL8 overrides this to false in RedHat8.yml. +__chromium_headless__proxy_exit_idle_time_supported: true diff --git a/roles/chromium_headless/vars/RedHat8.yml b/roles/chromium_headless/vars/RedHat8.yml new file mode 100644 index 00000000..24046187 --- /dev/null +++ b/roles/chromium_headless/vars/RedHat8.yml @@ -0,0 +1,2 @@ +# EL8 ships systemd 239, which lacks systemd-socket-proxyd --exit-idle-time (added in systemd 246). +__chromium_headless__proxy_exit_idle_time_supported: false diff --git a/roles/icingaweb2_module_pdfexport/README.md b/roles/icingaweb2_module_pdfexport/README.md index b980a311..48473250 100644 --- a/roles/icingaweb2_module_pdfexport/README.md +++ b/roles/icingaweb2_module_pdfexport/README.md @@ -15,7 +15,15 @@ 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. + + +## Dependent Roles + +Any [LFOps playbook](https://github.com/Linuxfabrik/lfops/blob/main/playbooks/README.md) that installs this role runs these for you. Optional ones can be disabled via the playbook's skip variables. + +* A headless Chromium backend must provide the remote debugging interface this module connects to (role: [linuxfabrik.lfops.chromium_headless](https://github.com/Linuxfabrik/lfops/tree/main/roles/chromium_headless)). ## Requirements @@ -25,7 +33,6 @@ This role is tested with the following IcingaWeb2 PDF Export Module versions: Manual steps: * Deploy a configured IcingaWeb2 by running the [icingaweb2](https://github.com/Linuxfabrik/lfops/blob/main/playbooks/icingaweb2.yml) playbook (role: [linuxfabrik.lfops.icingaweb2](https://github.com/linuxfabrik/lfops/tree/main/roles/icingaweb2)). -* Install and configure the runtime dependencies listed in the [module documentation](https://github.com/Icinga/icingaweb2-module-pdfexport#requirements) (typically a headless browser binary). ## Tags @@ -33,6 +40,12 @@ Manual steps: `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. @@ -51,6 +64,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') }}" diff --git a/roles/redis/vars/Debian.yml b/roles/redis/vars/Debian.yml index dcbd8acc..86f76a62 100644 --- a/roles/redis/vars/Debian.yml +++ b/roles/redis/vars/Debian.yml @@ -1,2 +1,9 @@ +__redis__config_dir: '/etc/redis' +__redis__config_file: 'redis.conf' +__redis__data_dir: '/var/lib/redis' +__redis__module_dir: '/etc/redis/modules' +__redis__package: 'redis-server' +__redis__runtime_dir: '/var/run/redis' + redis__conf_logfile: '/var/log/redis/redis-server.log' redis__service_name: 'redis-server.service'