From e33e3c7a7dcc1b76e5b05134389370b3213d1ff3 Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Wed, 2 Sep 2026 15:05:07 +0200 Subject: [PATCH 01/19] fix(playbooks/setup_librenms): apply the SELinux file contexts The selinux role was handed `selinux__fcontexcts__dependent_var`, a name it never reads. The role has no argument_specs to reject an unknown parameter, so the run continued without a word and /opt/librenms kept the default /opt label, leaving the RRD tree unlabelled for anything that is not the owning user. --- CHANGELOG.md | 1 + playbooks/setup_librenms.yml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7aa4624..ce32adee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +* **playbook:setup_librenms**: The SELinux file contexts for `/opt/librenms` are applied. * **role:apache_httpd**: Enabling an Apache module restarts httpd instead of reloading it, so the module actually takes effect. A reload loads the module but skips its initialization, which left HTTP/2 configured but silently inactive on RHEL 8 ([#339](https://github.com/Linuxfabrik/lfops/issues/339)). * **role:kvm_vm**: Two VMs created from the same inventory host no longer get the same MAC address, which made the second one fail with `The MAC address ... is in use by another virtual machine`. The generated MAC follows the VM name now. Hosts that leave `kvm_vm__name` at its default keep the MAC they had; on a host that sets it, a VM created from now on gets a different MAC, so check DHCP reservations and firewall rules keyed on it before recreating such a VM. Running VMs are not touched. * **role:keycloak**: A run against an unchanged host reports no changes any more. The tarball is only downloaded and extracted when the installed version differs from `keycloak__version`, and `kc.sh build` only runs when the installation or `keycloak.conf` actually changed, which also takes minutes off an ordinary run. diff --git a/playbooks/setup_librenms.yml b/playbooks/setup_librenms.yml index 6dea1a3d..7820913c 100644 --- a/playbooks/setup_librenms.yml +++ b/playbooks/setup_librenms.yml @@ -56,7 +56,7 @@ selinux__booleans__dependent_var: '{{ librenms__selinux__booleans__dependent_var }}' - selinux__fcontexcts__dependent_var: '{{ + selinux__fcontexts__dependent_var: '{{ librenms__selinux__fcontexts__dependent_var }}' selinux__modules__dependent_var: '{{ From e658f85c16df450677125f26e4cd7069386598f6 Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Wed, 2 Sep 2026 15:08:08 +0200 Subject: [PATCH 02/19] fix(playbooks/setup_librenms): create the librenms user before PHP-FPM starts php-fpm resolves the user of every pool while starting up and aborts the whole daemon when one of them is unknown ("cannot get uid for user", exit code 78). The php role deploys the LibreNMS pool, which runs as `librenms`, and starts php-fpm before the librenms role creates that user, so a fresh installation never got past it. The group and user tasks move into their own file, imported by the role where they sat before and by the playbook's pre_tasks. Tagged `librenms` and `php` there, so a run limited to either tag still creates the user. Verified on Rocky 8: a pool with an unknown user fails the unit with status=78/CONFIG, not just that pool. --- CHANGELOG.md | 1 + playbooks/setup_librenms.yml | 10 ++++++++++ roles/librenms/tasks/main.yml | 16 +--------------- roles/librenms/tasks/user.yml | 21 +++++++++++++++++++++ 4 files changed, 33 insertions(+), 15 deletions(-) create mode 100644 roles/librenms/tasks/user.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index ce32adee..2a3e0194 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +* **playbook:setup_librenms**: A fresh installation no longer aborts while starting PHP-FPM. * **playbook:setup_librenms**: The SELinux file contexts for `/opt/librenms` are applied. * **role:apache_httpd**: Enabling an Apache module restarts httpd instead of reloading it, so the module actually takes effect. A reload loads the module but skips its initialization, which left HTTP/2 configured but silently inactive on RHEL 8 ([#339](https://github.com/Linuxfabrik/lfops/issues/339)). * **role:kvm_vm**: Two VMs created from the same inventory host no longer get the same MAC address, which made the second one fail with `The MAC address ... is in use by another virtual machine`. The generated MAC follows the VM name now. Hosts that leave `kvm_vm__name` at its default keep the MAC they had; on a host that sets it, a VM created from now on gets a different MAC, so check DHCP reservations and firewall rules keyed on it before recreating such a VM. Running VMs are not touched. diff --git a/playbooks/setup_librenms.yml b/playbooks/setup_librenms.yml index 7820913c..b2e312b2 100644 --- a/playbooks/setup_librenms.yml +++ b/playbooks/setup_librenms.yml @@ -15,6 +15,16 @@ tags: - 'always' + # The php role deploys the LibreNMS FPM pool and starts php-fpm before the librenms role + # creates the user that pool runs as, which aborts php-fpm's startup on a fresh host. The + # tasks are idempotent, so the librenms role running them again later costs nothing. + - ansible.builtin.import_role: + name: 'librenms' + tasks_from: 'user.yml' + tags: + - 'librenms' + - 'php' + roles: diff --git a/roles/librenms/tasks/main.yml b/roles/librenms/tasks/main.yml index 5aa50c1e..a3f923e5 100644 --- a/roles/librenms/tasks/main.yml +++ b/roles/librenms/tasks/main.yml @@ -28,21 +28,7 @@ force: false version: '{{ librenms__github_release["json"]["tag_name"] }}' - - name: 'groupadd librenms' - ansible.builtin.group: - name: 'librenms' - state: 'present' - - - name: 'useradd librenms -d /opt/librenms -M -r -s /bin/bash' - ansible.builtin.user: - comment: 'LibreNMS Service User' - name: 'librenms' - group: 'librenms' - home: '/opt/librenms' - createhome: false - system: true - shell: '/bin/bash' - state: 'present' + - ansible.builtin.import_tasks: 'user.yml' # this is magnitudes faster than using the file module - name: 'chown -R librenms:librenms /opt/librenms' diff --git a/roles/librenms/tasks/user.yml b/roles/librenms/tasks/user.yml new file mode 100644 index 00000000..d16693ca --- /dev/null +++ b/roles/librenms/tasks/user.yml @@ -0,0 +1,21 @@ +# Kept in its own file because two callers need it: tasks/main.yml, where the account belongs, +# and playbooks/setup_librenms.yml, which has to create it before the php role runs. The php role +# deploys the LibreNMS FPM pool with "user = librenms", and php-fpm resolves the user of every +# pool while starting up: an unknown name aborts the whole daemon with "cannot get uid for user" +# and exit code 78, not just that one pool. + +- name: 'groupadd librenms' + ansible.builtin.group: + name: 'librenms' + state: 'present' + +- name: 'useradd librenms -d /opt/librenms -M -r -s /bin/bash' + ansible.builtin.user: + comment: 'LibreNMS Service User' + name: 'librenms' + group: 'librenms' + home: '/opt/librenms' + createhome: false + system: true + shell: '/bin/bash' + state: 'present' From 7f3d5d92db5e736206f7e6f97b7ea5cacfdbdcfa Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Wed, 2 Sep 2026 15:13:07 +0200 Subject: [PATCH 03/19] fix(roles/librenms): make a second run of the role work The checkout was cloned as root and the tree handed to `librenms` afterwards. Every later run then hit git's safe.directory protection, "detected dubious ownership in repository at '/opt/librenms'", so the role could never update an installation it had created. LibreNMS updates itself as its own user, and the role now clones the same way, into a directory created for that user beforehand. Three tasks reported a change on every run and made the molecule idempotence step impossible: - `chown -R` and `chmod -R` had no changed_when. The chown now derives it from `--changes` output, as CONTRIBUTING prescribes. - `chmod -R g=rwx` on rrd, logs, bootstrap/cache and storage can never settle: the application creates files below them with umask 022 as it runs, 294 of them on a host a few minutes after installation. The group bits are dropped instead of chased. PHP-FPM, cron and the scheduler all run as `librenms`, which owns those trees, so nothing needs them. - `composer install` now keys its changed_when on composer's own "Nothing to install, update or remove". `restorecon` loses its --force. A file the application creates gets the right type but the SELinux user `unconfined_u`, and -F rewrites that back to `system_u` on every run. Without it there is nothing to relabel, and the type, which is what the targeted policy enforces, is unaffected. Verified on Rocky 8, 9 and 10: converge, verify and idempotence pass. --- CHANGELOG.md | 2 ++ roles/librenms/tasks/main.yml | 51 ++++++++++++++++++++++++----------- 2 files changed, 37 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a3e0194..0a363181 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +* **role:librenms**: The RRD, log, cache and storage directories are no longer made group-writable. * **role:apache_httpd**: HTTP/2 is enabled and is the preferred protocol on every connection that terminates TLS, while a client that does not offer it is still served HTTP/1.1 and cleartext HTTP/2 (h2c) is not offered; on RedHat the `mod_http2` package is installed for this. * **role:keycloak**: `keycloak__https_cipher_suites`, `keycloak__https_protocols`, `keycloak__log` and `keycloak__proxy_trusted_addresses` are YAML lists instead of comma-separated strings. A comma-separated value already in an inventory keeps working, Ansible splits it into the same list. * **role:keycloak**: The Keycloak tarball is downloaded on the Ansible controller and copied to the target from there, so a target without internet access can be installed. The controller has to reach `github.com`. @@ -60,6 +61,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +* **role:librenms**: A second run of the role updates an existing installation instead of aborting at the git checkout. * **playbook:setup_librenms**: A fresh installation no longer aborts while starting PHP-FPM. * **playbook:setup_librenms**: The SELinux file contexts for `/opt/librenms` are applied. * **role:apache_httpd**: Enabling an Apache module restarts httpd instead of reloading it, so the module actually takes effect. A reload loads the module but skips its initialization, which left HTTP/2 configured but silently inactive on RHEL 8 ([#339](https://github.com/Linuxfabrik/lfops/issues/339)). diff --git a/roles/librenms/tasks/main.yml b/roles/librenms/tasks/main.yml index a3f923e5..d2c0b5c9 100644 --- a/roles/librenms/tasks/main.yml +++ b/roles/librenms/tasks/main.yml @@ -21,27 +21,40 @@ check_mode: false # run task even if `--check` is specified run_once: true + - ansible.builtin.import_tasks: 'user.yml' + + - name: 'mkdir -p /opt/librenms' + ansible.builtin.file: + path: '/opt/librenms' + state: 'directory' + owner: 'librenms' + group: 'librenms' + mode: 0o771 + + # Cloned as librenms, the way LibreNMS updates itself. Cloning as root and handing the tree + # over afterwards leaves git refusing to touch it on every later run ("detected dubious + # ownership"), which is git's safe.directory protection and cannot be worked around by a chown. - name: 'git clone https://github.com/librenms/librenms.git /opt/librenms' ansible.builtin.git: repo: 'https://github.com/librenms/librenms.git' dest: '/opt/librenms' force: false version: '{{ librenms__github_release["json"]["tag_name"] }}' + become: true + become_user: 'librenms' - - ansible.builtin.import_tasks: 'user.yml' - - # this is magnitudes faster than using the file module - - name: 'chown -R librenms:librenms /opt/librenms' - ansible.builtin.command: 'chown -R librenms:librenms /opt/librenms' - - - name: 'chmod 771 /opt/librenms' - ansible.builtin.file: - path: '/opt/librenms' - mode: 0o771 + # this is magnitudes faster than using the file module. --changes reports every file it + # touched, which keeps the task honest about whether it changed anything (installations that + # predate the clone above still carry root-owned files). + - name: 'chown -R --changes librenms:librenms /opt/librenms' + ansible.builtin.command: 'chown -R --changes librenms:librenms /opt/librenms' + register: '__librenms__chown_result' + changed_when: '__librenms__chown_result["stdout"] | length > 0' - # using in preference to the acls from the docs: 'setfacl -R -m g::rwx /opt/librenms/rrd /opt/librenms/logs /opt/librenms/bootstrap/cache/ /opt/librenms/storage/' - - name: 'chmod -R g=rwx /opt/librenms/rrd /opt/librenms/logs /opt/librenms/bootstrap/cache /opt/librenms/storage' - ansible.builtin.command: 'chmod -R g=rwx /opt/librenms/rrd /opt/librenms/logs /opt/librenms/bootstrap/cache /opt/librenms/storage' + # The upstream docs make rrd, logs, bootstrap/cache and storage group-writable, for setups + # where the web server runs under its own account. Here PHP-FPM, cron and the scheduler all + # run as librenms, which owns those trees, so the group bits buy nothing and cannot be kept + # anyway: the application creates files below them with umask 022 as it runs. - name: 'ln -s /opt/librenms/lnms /usr/local/bin/lnms' ansible.builtin.file: @@ -64,9 +77,15 @@ chdir: '/opt/librenms' become: true become_user: 'librenms' - - - name: 'restorecon -RFv /opt/librenms' - ansible.builtin.command: 'restorecon -RFv /opt/librenms' + register: '__librenms__composer_result' + # composer says so whenever the lock file is already satisfied + changed_when: '"Nothing to install, update or remove" not in __librenms__composer_result["stdout"]' + + # No --force: it would also rewrite the SELinux user of every file the application created + # since the last run (unconfined_u back to system_u), which relabels on every run without + # changing what the targeted policy enforces, namely the type. + - name: 'restorecon -Rv /opt/librenms' + ansible.builtin.command: 'restorecon -Rv /opt/librenms' register: 'librenms__restorecon_data_result' changed_when: 'librenms__restorecon_data_result["stdout"] | length > 0' From ddca84b06d5dbc1a325f9e187f9a4b288be7d235 Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Wed, 2 Sep 2026 15:14:32 +0200 Subject: [PATCH 04/19] refactor(roles/librenms): deploy http_fping through the selinux role The module was compiled and installed by the role itself, with checkmodule and semodule_package driven from a template in templates/tmp/, and only when `semodule --list-modules` did not know the name yet. An edit to the source therefore never reached a host that already had an older version. The selinux role gained a `content_te` subkey for exactly this case, so the module moves into librenms__selinux__modules__dependent_var next to the role's booleans and file contexts, and the playbook hands it over. That role compares the compiled package against the installed one, so a changed source is deployed and an unchanged one reports nothing. --- CHANGELOG.md | 1 + playbooks/setup_librenms.yml | 1 + roles/librenms/README.md | 1 + roles/librenms/defaults/main.yml | 17 +++++++++ roles/librenms/tasks/main.yml | 38 ++----------------- roles/librenms/templates/tmp/http_fping.tt.j2 | 14 ------- 6 files changed, 23 insertions(+), 49 deletions(-) delete mode 100644 roles/librenms/templates/tmp/http_fping.tt.j2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a363181..790f97ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +* **role:librenms**: The `http_fping` SELinux policy module is deployed by the `selinux` role, so a playbook run that skips that role no longer installs it. * **role:librenms**: The RRD, log, cache and storage directories are no longer made group-writable. * **role:apache_httpd**: HTTP/2 is enabled and is the preferred protocol on every connection that terminates TLS, while a client that does not offer it is still served HTTP/1.1 and cleartext HTTP/2 (h2c) is not offered; on RedHat the `mod_http2` package is installed for this. * **role:keycloak**: `keycloak__https_cipher_suites`, `keycloak__https_protocols`, `keycloak__log` and `keycloak__proxy_trusted_addresses` are YAML lists instead of comma-separated strings. A comma-separated value already in an inventory keeps working, Ansible splits it into the same list. diff --git a/playbooks/setup_librenms.yml b/playbooks/setup_librenms.yml index b2e312b2..ec0f7e95 100644 --- a/playbooks/setup_librenms.yml +++ b/playbooks/setup_librenms.yml @@ -70,6 +70,7 @@ librenms__selinux__fcontexts__dependent_var }}' selinux__modules__dependent_var: '{{ + librenms__selinux__modules__dependent_var + php__selinux__modules__dependent_var }}' when: diff --git a/roles/librenms/README.md b/roles/librenms/README.md index ce13c264..cbd0cfb0 100644 --- a/roles/librenms/README.md +++ b/roles/librenms/README.md @@ -16,6 +16,7 @@ Any [LFOps playbook](https://github.com/Linuxfabrik/lfops/blob/main/playbooks/RE * PHP version >= 7.3 must be installed (role: [linuxfabrik.lfops.php](https://github.com/Linuxfabrik/lfops/tree/main/roles/php)). * On RHEL-compatible systems, the `httpd_can_connect_ldap` and `httpd_setrlimit` SELinux booleans must be enabled (role: [linuxfabrik.lfops.selinux](https://github.com/Linuxfabrik/lfops/tree/main/roles/selinux)). * On RHEL-compatible systems, the appropriate SELinux file contexts must be set (have a look at `librenms__selinux__fcontexts__dependent_var` in the `defaults/main.yml`) (role: [linuxfabrik.lfops.selinux](https://github.com/Linuxfabrik/lfops/tree/main/roles/selinux)). +* On RHEL-compatible systems, the `http_fping` SELinux policy module must be installed (have a look at `librenms__selinux__modules__dependent_var` in the `defaults/main.yml`) (role: [linuxfabrik.lfops.selinux](https://github.com/Linuxfabrik/lfops/tree/main/roles/selinux)). ## Tags diff --git a/roles/librenms/defaults/main.yml b/roles/librenms/defaults/main.yml index 67fd39dc..c8a093ca 100644 --- a/roles/librenms/defaults/main.yml +++ b/roles/librenms/defaults/main.yml @@ -121,3 +121,20 @@ librenms__selinux__fcontexts__dependent_var: - setype: 'httpd_sys_rw_content_t' target: '/opt/librenms/.env' state: 'present' + +librenms__selinux__modules__dependent_var: + # fping is called by the web interface, which runs in the httpd_t domain and may not open a + # raw socket there. + - name: 'http_fping' + content_te: | + module http_fping 1.0; + + require { + type httpd_t; + class capability net_raw; + class rawip_socket { getopt create setopt write read }; + } + + #============= httpd_t ============== + allow httpd_t self:capability net_raw; + allow httpd_t self:rawip_socket { getopt create setopt write read }; diff --git a/roles/librenms/tasks/main.yml b/roles/librenms/tasks/main.yml index d2c0b5c9..a43f4da8 100644 --- a/roles/librenms/tasks/main.yml +++ b/roles/librenms/tasks/main.yml @@ -89,41 +89,9 @@ register: 'librenms__restorecon_data_result' changed_when: 'librenms__restorecon_data_result["stdout"] | length > 0' - - name: 'semodule --list-modules | grep --quiet http_fping # check for http_fping' - ansible.builtin.shell: 'semodule --list-modules | grep --quiet http_fping' - args: - executable: '/bin/bash' - register: 'librenms__http_fping_exists_result' - failed_when: 'librenms__http_fping_exists_result["rc"] >= 2' - changed_when: false # just gathering information - - - block: - - - name: 'Deploy /tmp/http_fping.tt' - ansible.builtin.template: - backup: true - src: 'tmp/http_fping.tt.j2' - dest: '/tmp/http_fping.tt' - - - name: 'checkmodule -M -m -o /tmp/http_fping.mod /tmp/http_fping.tt' - ansible.builtin.command: 'checkmodule -M -m -o /tmp/http_fping.mod /tmp/http_fping.tt' - - - name: 'semodule_package -o /tmp/http_fping.pp -m /tmp/http_fping.mod' - ansible.builtin.command: 'semodule_package -o /tmp/http_fping.pp -m /tmp/http_fping.mod' - - - name: 'semodule --install /tmp/http_fping.pp' - ansible.builtin.command: 'semodule --install /tmp/http_fping.pp' - - - name: 'rm /tmp/http_fping.tt /tmp/http_fping.mod /tmp/http_fping.pp' - ansible.builtin.file: - path: - - '/tmp/http_fping.tt' - - '/tmp/http_fping.mod' - - '/tmp/http_fping.pp' - state: 'absent' - - # block - when: 'librenms__http_fping_exists_result["rc"] != 0' + # The SELinux policy module this role needs (http_fping) is declared in + # librenms__selinux__modules__dependent_var and deployed by the selinux role, like its booleans + # and file contexts. # TODO: currently librenms cannot find the rrdcached socket, skipping for now # - name: 'semodule --list-modules | grep --quiet rrdcached_librenms # check for rrdcached_librenms' diff --git a/roles/librenms/templates/tmp/http_fping.tt.j2 b/roles/librenms/templates/tmp/http_fping.tt.j2 deleted file mode 100644 index 08c78fb2..00000000 --- a/roles/librenms/templates/tmp/http_fping.tt.j2 +++ /dev/null @@ -1,14 +0,0 @@ -# {{ ansible_managed }} -# 2022071201 - -module http_fping 1.0; - -require { -type httpd_t; -class capability net_raw; -class rawip_socket { getopt create setopt write read }; -} - -#============= httpd_t ============== -allow httpd_t self:capability net_raw; -allow httpd_t self:rawip_socket { getopt create setopt write read }; From 9b31ea79a772353ef25d9a81e4638e75d3da8f2f Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Wed, 2 Sep 2026 15:15:52 +0200 Subject: [PATCH 05/19] feat(roles/librenms): store the RRD files through RRDCached LibreNMS writes one RRD update per metric per poll. RRDCached collects them in memory and writes them out every 30 minutes, which upstream puts at 30% to 40% less disk I/O on the poller. The earlier attempt sat commented out in the role with "currently librenms cannot find the rrdcached socket". The reason is that it listened on /tmp/rrdcached.sock, and both httpd.service and php-fpm.service ship PrivateTmp=true, so the socket the daemon creates is not the one the web interface looks for. It now listens on /run/rrdcached.sock. Details worth knowing: - rrdcached.service and rrdcached.socket both come with the rrdtool package on RHEL 8, 9 and 10, contrary to what the LibreNMS docs say. The service is configured with a drop-in rather than by templating the unit, and the socket unit is disabled: it binds /tmp/rrdcached.sock, and socket activation would make rrdcached ignore the listen address of the drop-in. - The rrdcached_librenms policy module goes through the selinux role. Without it rrdcached_t may not touch the RRD tree (httpd_sys_rw_content_t) or create its socket in /run (var_run_t), and httpd_t may not connect to it. - $config['rrdtool_version'] is set from the installed rrdtool. LibreNMS assumes 1.4 otherwise and then creates and tunes the files itself instead of letting rrdcached do it. - The journal goes to /var/tmp, where the base policy lets rrdcached_t write (type_transition to rrdcached_tmp_t), which is also what upstream's unit does. Verified on Rocky 8, 9 and 10 under enforcing SELinux: rrdcached runs in rrdcached_t, the RRD files it writes carry httpd_sys_rw_content_t, PHP-FPM reaches the socket from httpd_t, and ausearch reports no denials. --- CHANGELOG.md | 4 +- roles/librenms/README.md | 39 ++++- roles/librenms/defaults/main.yml | 35 +++++ roles/librenms/handlers/main.yml | 8 ++ roles/librenms/tasks/main.yml | 134 ++++++++++-------- .../rrdcached.service.d/librenms.conf.j2 | 6 - .../rrdcached.service.d/z00-librenms.conf.j2 | 12 ++ .../templates/opt/librenms/config.php.j2 | 11 +- .../templates/tmp/rrdcached_librenms.tt.j2 | 18 --- roles/librenms/vars/main.yml | 1 + 10 files changed, 182 insertions(+), 86 deletions(-) create mode 100644 roles/librenms/handlers/main.yml delete mode 100644 roles/librenms/templates/etc/systemd/system/rrdcached.service.d/librenms.conf.j2 create mode 100644 roles/librenms/templates/etc/systemd/system/rrdcached.service.d/z00-librenms.conf.j2 delete mode 100644 roles/librenms/templates/tmp/rrdcached_librenms.tt.j2 create mode 100644 roles/librenms/vars/main.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index 790f97ee..eb6f761c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -**Highlights:** On RHEL 8, a MariaDB package upgrade no longer cuts applications on the same host off from their database. Apache no longer loads `mod_info`, which served the complete configuration including other modules' credentials. A broken PHP-FPM configuration aborts the run instead of taking the service down on the restart. Sudo rules deployed by `freeipa_server` can carry their commands again. The Bitwarden lookup can be told to abort instead of silently generating a new password, for runs against hosts whose credentials must already exist. The Grafana graph configuration for the Monitoring Plugins is no longer deployed on every ordinary run and has to be requested explicitly by its tag. Apache serves HTTP/2 to every client that offers it over TLS, which in a typical setup is the reverse proxy in front of an application; the hop from that proxy to the backend is unchanged. +**Highlights:** On RHEL 8, a MariaDB package upgrade no longer cuts applications on the same host off from their database. Apache no longer loads `mod_info`, which served the complete configuration including other modules' credentials. A broken PHP-FPM configuration aborts the run instead of taking the service down on the restart. Sudo rules deployed by `freeipa_server` can carry their commands again. The Bitwarden lookup can be told to abort instead of silently generating a new password, for runs against hosts whose credentials must already exist. The Grafana graph configuration for the Monitoring Plugins is no longer deployed on every ordinary run and has to be requested explicitly by its tag. Apache serves HTTP/2 to every client that offers it over TLS, which in a typical setup is the reverse proxy in front of an application; the hop from that proxy to the backend is unchanged. LibreNMS keeps its RRD updates in RRDCached and writes them out every 30 minutes, taking a large share of the poller's disk I/O off the host. ### Breaking Changes @@ -28,6 +28,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +* **role:librenms**: LibreNMS stores its RRD files through RRDCached, which cuts the disk I/O of the poller by roughly a third. +* **role:librenms**: The `librenms:rrdcached` tag deploys and configures RRDCached without touching the rest of the installation. * **role:php**: PHP-FPM pools are now fully configurable, each with its own user and group, process-manager tuning, timeouts and `php_admin_value` overrides. Every pool gets an isolated session directory, its own error and slow logs, and its own socket, so several applications can share a host without sharing a PHP process, a session store or a memory limit. One pool template now serves both distribution families. * **role:php**: Add `meta/argument_specs.yml` declaring the user-facing variables, so role-entry validation catches type mismatches and unknown variables, and an explicit `vars/Ubuntu.yml`. * **role:apache_httpd**: `apache_httpd__mod_http2_protocols` sets the protocols offered server-wide, the `conf_protocols` vHost key overrides it for a single vHost, and the remaining `apache_httpd__mod_http2_*` variables size the HTTP/2 worker pool and its per-connection buffers. diff --git a/roles/librenms/README.md b/roles/librenms/README.md index cbd0cfb0..3ed44c97 100644 --- a/roles/librenms/README.md +++ b/roles/librenms/README.md @@ -6,6 +6,17 @@ This role installs and configures [LibreNMS](https://www.librenms.org/). *Available since LFOps `2.0.0`.* +## How the Role Behaves + +LibreNMS stores its time series in RRD files below `/opt/librenms/rrd`. By default the role puts [RRDCached](https://docs.librenms.org/Extensions/RRDCached/) in front of them, which collects the updates of a poll cycle in memory and writes them out every 30 minutes instead of on every update. This typically cuts the disk I/O of the poller by 30% to 40%. + +* The RRD files stay where they are and keep their format, so enabling or disabling RRDCached needs no data migration. Only who writes them changes. +* RRDCached runs as `librenms` and listens on the Unix socket `/run/rrdcached.sock`. It is not reachable over the network, and the socket is deliberately not in `/tmp`, which `httpd` and `php-fpm` cannot see because both run with `PrivateTmp=true`. +* The role configures the `rrdcached.service` of the `rrdtool` package with a systemd drop-in and disables the `rrdcached.socket` unit shipped alongside it, because socket activation would make RRDCached ignore the configured socket path. +* On RHEL-compatible systems RRDCached needs the `rrdcached_librenms` SELinux policy module, without which it cannot write the RRD files and the web interface cannot draw graphs. The module is declared in `librenms__selinux__modules__dependent_var` and deployed by the `selinux` role, so skipping that role in the playbook leaves RRDCached without it. +* Up to 30 minutes of collected data live in memory only. RRDCached journals them to `/var/tmp` and replays the journal after a crash. + + ## 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. @@ -16,7 +27,7 @@ Any [LFOps playbook](https://github.com/Linuxfabrik/lfops/blob/main/playbooks/RE * PHP version >= 7.3 must be installed (role: [linuxfabrik.lfops.php](https://github.com/Linuxfabrik/lfops/tree/main/roles/php)). * On RHEL-compatible systems, the `httpd_can_connect_ldap` and `httpd_setrlimit` SELinux booleans must be enabled (role: [linuxfabrik.lfops.selinux](https://github.com/Linuxfabrik/lfops/tree/main/roles/selinux)). * On RHEL-compatible systems, the appropriate SELinux file contexts must be set (have a look at `librenms__selinux__fcontexts__dependent_var` in the `defaults/main.yml`) (role: [linuxfabrik.lfops.selinux](https://github.com/Linuxfabrik/lfops/tree/main/roles/selinux)). -* On RHEL-compatible systems, the `http_fping` SELinux policy module must be installed (have a look at `librenms__selinux__modules__dependent_var` in the `defaults/main.yml`) (role: [linuxfabrik.lfops.selinux](https://github.com/Linuxfabrik/lfops/tree/main/roles/selinux)). +* On RHEL-compatible systems, the `http_fping` and `rrdcached_librenms` SELinux policy modules must be installed (have a look at `librenms__selinux__modules__dependent_var` in the `defaults/main.yml`) (role: [linuxfabrik.lfops.selinux](https://github.com/Linuxfabrik/lfops/tree/main/roles/selinux)). ## Tags @@ -31,6 +42,11 @@ Any [LFOps playbook](https://github.com/Linuxfabrik/lfops/blob/main/playbooks/RE * Configures LibreNMS. * Triggers: none. +`librenms:rrdcached` + +* Installs and configures RRDCached, and manages its service. +* Triggers: rrdcached.service restart. + ## Mandatory Role Variables @@ -98,6 +114,24 @@ librenms__fqdn: 'librenms.example.com' * Type: String. * Default: `'librenms'` +`librenms__rrdcached_enabled` + +* Whether LibreNMS reads and writes its RRD files through RRDCached. Set this to `false` to have LibreNMS access the files directly. Have a look at "How the Role Behaves" above. +* Type: Boolean. +* Default: `true` + +`librenms__rrdcached_service_enabled` + +* Enables or disables the RRDCached service, analogous to `systemctl enable/disable --now`. Only used if `librenms__rrdcached_enabled` is `true`. +* Type: Bool. +* Default: `true` + +`librenms__rrdcached_service_state` + +* Changes the state of the RRDCached service, analogous to `systemctl start/stop/restart/reload`. Only used if `librenms__rrdcached_enabled` is `true`. +* Type: String. One of `reloaded`, `restarted`, `started`, `stopped`. +* Default: `'started'` if `librenms__rrdcached_service_enabled` is `true`, else `'stopped'` + Example: ```yaml # optional @@ -110,6 +144,9 @@ librenms__config_rrd_purge: 730 librenms__config_update_channel: 'release' librenms__database_host: 'localhost' librenms__database_name: 'librenms' +librenms__rrdcached_enabled: true +librenms__rrdcached_service_enabled: true +librenms__rrdcached_service_state: 'started' ``` diff --git a/roles/librenms/defaults/main.yml b/roles/librenms/defaults/main.yml index c8a093ca..ac8b47d7 100644 --- a/roles/librenms/defaults/main.yml +++ b/roles/librenms/defaults/main.yml @@ -5,6 +5,9 @@ librenms__config_rrd_purge: 0 librenms__config_update_channel: 'release' librenms__database_host: 'localhost' librenms__database_name: 'librenms' +librenms__rrdcached_enabled: true +librenms__rrdcached_service_enabled: true +librenms__rrdcached_service_state: '{{ librenms__rrdcached_service_enabled | bool | ternary("started", "stopped") }}' # ----------------------------------------------------------------------------- @@ -138,3 +141,35 @@ librenms__selinux__modules__dependent_var: #============= httpd_t ============== allow httpd_t self:capability net_raw; allow httpd_t self:rawip_socket { getopt create setopt write read }; + + # Taken verbatim from dist/rrdcached/rrdcached_librenms.te of the LibreNMS repository, so a + # diff against upstream stays trivial. It lets rrdcached_t work on the RRD tree + # (httpd_sys_rw_content_t) and create its socket in /run (var_run_t), and lets httpd_t, which + # is what PHP-FPM runs as, connect to that socket. + - name: 'rrdcached_librenms' + content_te: | + # SELinux Policy File for rrdcached + module rrdcached_librenms 1.0; + + require { + type var_run_t; + type tmp_t; + type httpd_t; + type rrdcached_t; + type httpd_sys_rw_content_t; + class dir { add_name getattr open read remove_name rmdir search write }; + class file { create getattr open read rename setattr unlink write map lock }; + class sock_file { create setattr unlink write }; + class capability { fsetid sys_resource }; + class unix_stream_socket connectto; + } + + #============= rrdcached_t ============== + + allow rrdcached_t httpd_sys_rw_content_t:dir { add_name getattr remove_name search write }; + allow rrdcached_t httpd_sys_rw_content_t:file { create getattr open read rename setattr unlink write map lock }; + allow rrdcached_t self:capability fsetid; + allow rrdcached_t var_run_t:sock_file { create setattr unlink }; + allow httpd_t var_run_t:sock_file write; + allow httpd_t rrdcached_t:unix_stream_socket connectto; + state: '{{ librenms__rrdcached_enabled | bool | ternary("present", "absent") }}' diff --git a/roles/librenms/handlers/main.yml b/roles/librenms/handlers/main.yml new file mode 100644 index 00000000..33ba4db3 --- /dev/null +++ b/roles/librenms/handlers/main.yml @@ -0,0 +1,8 @@ +- name: 'librenms: restart rrdcached' + ansible.builtin.service: + name: 'rrdcached.service' + state: 'restarted' + when: + - 'not (lfops__skip_restart_handlers | d(false) | bool)' + - '__librenms__rrdcached_service_state_result is not defined or __librenms__rrdcached_service_state_result is not changed' + - 'librenms__rrdcached_service_state != "stopped"' diff --git a/roles/librenms/tasks/main.yml b/roles/librenms/tasks/main.yml index a43f4da8..4ef867b9 100644 --- a/roles/librenms/tasks/main.yml +++ b/roles/librenms/tasks/main.yml @@ -89,70 +89,72 @@ register: 'librenms__restorecon_data_result' changed_when: 'librenms__restorecon_data_result["stdout"] | length > 0' - # The SELinux policy module this role needs (http_fping) is declared in + # The SELinux policy modules this role needs (http_fping, rrdcached_librenms) are declared in # librenms__selinux__modules__dependent_var and deployed by the selinux role, like its booleans # and file contexts. - # TODO: currently librenms cannot find the rrdcached socket, skipping for now - # - name: 'semodule --list-modules | grep --quiet rrdcached_librenms # check for rrdcached_librenms' - # ansible.builtin.shell: 'semodule --list-modules | grep --quiet rrdcached_librenms' - # register: 'librenms__rrdcached_librenms_exists_result' - # failed_when: 'librenms__rrdcached_librenms_exists_result["rc"] >= 2' - # changed_when: false # just gathering information - - # - block: - - # - name: 'Deploy /tmp/rrdcached_librenms.tt' - # ansible.builtin.template: - # backup: true - # src: 'tmp/rrdcached_librenms.tt.j2' - # dest: '/tmp/rrdcached_librenms.tt' - - # - name: 'checkmodule -M -m -o /tmp/rrdcached_librenms.mod /tmp/rrdcached_librenms.tt' - # ansible.builtin.command: 'checkmodule -M -m -o /tmp/rrdcached_librenms.mod /tmp/rrdcached_librenms.tt' - - # - name: 'semodule_package -o /tmp/rrdcached_librenms.pp -m /tmp/rrdcached_librenms.mod' - # ansible.builtin.command: 'semodule_package -o /tmp/rrdcached_librenms.pp -m /tmp/rrdcached_librenms.mod' - - # - name: 'semodule --install /tmp/rrdcached_librenms.pp' - # ansible.builtin.command: 'semodule --install /tmp/rrdcached_librenms.pp' - - # - name: 'rm /tmp/rrdcached_librenms.tt /tmp/rrdcached_librenms.mod /tmp/rrdcached_librenms.pp' - # ansible.builtin.file: - # path: - # - '/tmp/rrdcached_librenms.tt' - # - '/tmp/rrdcached_librenms.mod' - # - '/tmp/rrdcached_librenms.pp' - # state: 'absent' - - # # block - # when: 'librenms__rrdcached_librenms_exists_result["rc"] != 0' - - # - name: 'mkdir -p /etc/systemd/system/rrdcached.service.d/' - # ansible.builtin.file: - # path: '/etc/systemd/system/rrdcached.service.d/' - # state: 'directory' - - # - name: 'Deploy /etc/systemd/system/rrdcached.service.d/librenms.conf' - # ansible.builtin.template: - # backup: true - # src: 'etc/systemd/system/rrdcached.service.d/librenms.conf.j2' - # dest: '/etc/systemd/system/rrdcached.service.d/librenms.conf' - # owner: 'root' - # group: 'root' - # mode: 0o644 - # register: 'librenms__rrdcached_systemd_override_result' - - # - name: 'systemctl daemon-reload; systemctl restart rrdcached' - # ansible.builtin.systemd: - # name: 'rrdcached.service' - # state: 'started' - # enabled: true - # daemon_reload: '{{ librenms__rrdcached_systemd_override_result is changed }}' - # + tags: + - 'librenms' + + +- block: + + # The socket unit of the rrdtool package binds /tmp/rrdcached.sock, which httpd + # and php-fpm cannot reach because both run with PrivateTmp=true. Socket + # activation also makes rrdcached ignore the listen address of the drop-in. + - name: 'systemctl disable --now rrdcached.socket' + ansible.builtin.service: + name: 'rrdcached.socket' + enabled: false + state: 'stopped' + + - name: 'mkdir -p /etc/systemd/system/rrdcached.service.d' + ansible.builtin.file: + path: '/etc/systemd/system/rrdcached.service.d' + state: 'directory' + owner: 'root' + group: 'root' + mode: 0o755 + # named after the role, not z00-linuxfabrik.conf: rrdcached.service belongs to the rrdtool + # package, and a role that manages RRDCached in its own right would collide with that name + - name: 'Deploy /etc/systemd/system/rrdcached.service.d/z00-librenms.conf' + ansible.builtin.template: + backup: true + src: 'etc/systemd/system/rrdcached.service.d/z00-librenms.conf.j2' + dest: '/etc/systemd/system/rrdcached.service.d/z00-librenms.conf' + owner: 'root' + group: 'root' + mode: 0o644 + register: '__librenms__rrdcached_dropin_result' + notify: 'librenms: restart rrdcached' + + - name: 'systemctl daemon-reload' # noqa no-handler would require flush_handlers here anyway + ansible.builtin.systemd: + daemon_reload: true + when: '__librenms__rrdcached_dropin_result is changed' + + # split enabled and state into separate tasks so that the register on the state task + # only reflects actual state changes (started/stopped), not enabled/disabled changes. + # this allows the handler to correctly skip a restart when the service was just started, + # without false-positives from enabled-only changes. + - name: 'systemctl {{ librenms__rrdcached_service_enabled | bool | ternary("enable", "disable") }} rrdcached.service' + ansible.builtin.service: + name: 'rrdcached.service' + enabled: '{{ librenms__rrdcached_service_enabled | bool }}' + + - name: 'systemctl {{ librenms__rrdcached_service_state | regex_replace("p?ed$", "") }} rrdcached.service' + ansible.builtin.service: + name: 'rrdcached.service' + state: '{{ librenms__rrdcached_service_state }}' + register: '__librenms__rrdcached_service_state_result' + + # block + when: + - 'librenms__rrdcached_enabled | bool' tags: - 'librenms' + - 'librenms:rrdcached' - block: @@ -199,6 +201,22 @@ enabled: true state: 'started' + # LibreNMS assumes rrdtool 1.4 unless told otherwise, and then creates and + # tunes RRD files itself instead of letting rrdcached do it. + - name: 'rrdtool --version' + ansible.builtin.command: 'rrdtool --version' + register: '__librenms__rrdtool_version_result' + changed_when: false # just gathering information + check_mode: false # run task even if `--check` is specified + when: 'librenms__rrdcached_enabled | bool' + + - name: 'Set the installed rrdtool version' + ansible.builtin.set_fact: + # "RRDtool 1.7.2 Copyright by Tobias Oetiker ..." + # variable is used in config.php.j2 + __librenms__rrdtool_version: '{{ __librenms__rrdtool_version_result["stdout_lines"][0] | regex_search("[0-9]+\.[0-9]+\.[0-9]+") }}' + when: 'librenms__rrdcached_enabled | bool' + - name: 'Deploy /opt/librenms/config.php' ansible.builtin.template: backup: true diff --git a/roles/librenms/templates/etc/systemd/system/rrdcached.service.d/librenms.conf.j2 b/roles/librenms/templates/etc/systemd/system/rrdcached.service.d/librenms.conf.j2 deleted file mode 100644 index fb8a56f8..00000000 --- a/roles/librenms/templates/etc/systemd/system/rrdcached.service.d/librenms.conf.j2 +++ /dev/null @@ -1,6 +0,0 @@ -# {{ ansible_managed }} -# 2022071201 - -[Service] -ExecStart= -ExecStart=/usr/bin/rrdcached -g -w 1800 -z 1800 -f 3600 -s librenms -U librenms -G librenms -B -R -j /var/tmp -l unix:/tmp/rrdcached.sock -t 4 -F -b /opt/librenms/rrd/ diff --git a/roles/librenms/templates/etc/systemd/system/rrdcached.service.d/z00-librenms.conf.j2 b/roles/librenms/templates/etc/systemd/system/rrdcached.service.d/z00-librenms.conf.j2 new file mode 100644 index 00000000..b91538b5 --- /dev/null +++ b/roles/librenms/templates/etc/systemd/system/rrdcached.service.d/z00-librenms.conf.j2 @@ -0,0 +1,12 @@ +# {{ ansible_managed }} +# 2026090101 + +[Service] +# The distribution unit runs "rrdcached -g" without a base directory, and its +# companion rrdcached.socket binds /tmp/rrdcached.sock, which httpd and php-fpm +# cannot reach because both run with PrivateTmp=true. Options as documented in +# https://docs.librenms.org/Extensions/RRDCached/, with the socket in /run. +ExecStart= +ExecStart=/usr/bin/rrdcached -g -w 1800 -z 1800 -f 3600 -s librenms -U librenms -G librenms -B -R -j /var/tmp -l unix:{{ __librenms__rrdcached_socket }} -t 4 -F -b /opt/librenms/rrd/ +Restart=always +RestartSec=10 diff --git a/roles/librenms/templates/opt/librenms/config.php.j2 b/roles/librenms/templates/opt/librenms/config.php.j2 index a190bbee..16a61503 100644 --- a/roles/librenms/templates/opt/librenms/config.php.j2 +++ b/roles/librenms/templates/opt/librenms/config.php.j2 @@ -1,6 +1,6 @@ Date: Wed, 2 Sep 2026 15:16:08 +0200 Subject: [PATCH 06/19] docs(CONTRIBUTING): name drop-ins after the role for foreign units z00-linuxfabrik.conf is unambiguous while the role and the unit are the same software, as in mariadb_server and redis. A role configuring a unit that belongs to something else has no such claim on the name: roles/librenms writes into rrdcached.service.d, and a role managing RRDCached in its own right would overwrite it with its own z00-linuxfabrik.conf. --- CONTRIBUTING.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3b7fba1b..637f9047 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -741,6 +741,7 @@ Make sure to use the following format when passing multiple injections to avoid #### systemd Drop-ins and Service Ordering * Deploy overrides as a drop-in under `/etc/systemd/system/.d/`, never by templating the unit file itself. Name the file after what it does (`z00-linuxfabrik.conf` for the role's own `[Service]` settings, `z00-after-.conf` for an ordering dependency), and run `systemctl daemon-reload` when it changed. +* Where a role configures a unit that belongs to *another* piece of software, name the drop-in `z00-.conf` instead. `roles/librenms` writes `rrdcached.service.d/z00-librenms.conf`: the unit ships with the `rrdtool` package, the settings in it exist only because LibreNMS is on the host, and a role managing RRDCached in its own right would otherwise overwrite them with its own `z00-linuxfabrik.conf`. * If a role declares a `__kernel_settings__*__dependent_var`, check whether its service reads that value once at startup. If it does, the role also has to order the service after TuneD. TuneD applies the profile when its daemon starts and systemd starts `tuned.service` in parallel with everything else, so without the ordering the service can come up first and keep the old value for its whole runtime, while `sysctl` and `tuned-adm verify` already report the new one. The classic case is `net.core.somaxconn`, which the kernel clamps the accept queue to inside `listen()`. Not every kernel setting needs this. `vm.swappiness` or `net.bridge.bridge-nf-call-iptables` are honoured by the kernel continuously, so ordering buys nothing there. Decide per parameter, not per role. From 70001d3ad05d89616228c12756461d682afa36a9 Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Wed, 2 Sep 2026 15:17:39 +0200 Subject: [PATCH 07/19] test(extensions/molecule): add the setup_librenms scenario Asserts what only the running system can confirm: rrdcached is up and confined to rrdcached_t, its socket sits outside /tmp and belongs to the librenms group, data written through the daemon lands in the RRD tree with the httpd_sys_rw_content_t label, PHP-FPM answers from httpd_t through the vHost, and ausearch reports no denials. A plain "the service is running" check would pass while every write is denied, which is how the earlier attempt at this failed. The PHP probe served through the LibreNMS vHost is the only way to exercise the httpd_t side: the domain follows from who executes the code, not from the user it runs as, and runcon cannot get there because httpd_t may not execute the interpreter. COMPATIBILITY.md moves librenms on RHEL 9 and 10 from (x) to x, which this scenario is what proves. --- COMPATIBILITY.md | 2 +- .../molecule/setup_librenms/converge.yml | 2 + .../group_vars/systems_under_test.yml | 14 ++ .../setup_librenms/inventory/hosts.yml | 15 ++ .../molecule/setup_librenms/molecule.yml | 1 + extensions/molecule/setup_librenms/verify.yml | 199 ++++++++++++++++++ 6 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 extensions/molecule/setup_librenms/converge.yml create mode 100644 extensions/molecule/setup_librenms/inventory/group_vars/systems_under_test.yml create mode 100644 extensions/molecule/setup_librenms/inventory/hosts.yml create mode 100644 extensions/molecule/setup_librenms/molecule.yml create mode 100644 extensions/molecule/setup_librenms/verify.yml diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index e87a4e6e..958cddb5 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -89,7 +89,7 @@ Which Ansible role is proven to run on which OS? | kvm_host | (x) | (x) | x | (x) | (x) | (x) | x | (x) | | | kvm_vm | (x) | (x) | x | (x) | (x) | (x) | x | (x) | | | libmaxminddb | (x) | (x) | x | (x) | (x) | (x) | (x) | (x) | | -| librenms | | | x | (x) | (x) | | | | | +| librenms | | | x | x | x | | | | | | libreoffice | | | x | (x) | (x) | | | | | | login | (x) | (x) | x | x | x | (x) | (x) | (x) | Fedora 35+ | | logrotate | (x) | (x) | x | x | x | (x) | (x) | (x) | Fedora | diff --git a/extensions/molecule/setup_librenms/converge.yml b/extensions/molecule/setup_librenms/converge.yml new file mode 100644 index 00000000..260c0754 --- /dev/null +++ b/extensions/molecule/setup_librenms/converge.yml @@ -0,0 +1,2 @@ +- name: 'Converge setup_librenms playbook' + ansible.builtin.import_playbook: 'linuxfabrik.lfops.setup_librenms' diff --git a/extensions/molecule/setup_librenms/inventory/group_vars/systems_under_test.yml b/extensions/molecule/setup_librenms/inventory/group_vars/systems_under_test.yml new file mode 100644 index 00000000..d215bd87 --- /dev/null +++ b/extensions/molecule/setup_librenms/inventory/group_vars/systems_under_test.yml @@ -0,0 +1,14 @@ +apache_httpd__conf_server_admin: 'root@localhost' + +librenms__database_login: + username: 'librenms' + password: 'linuxfabrik' +librenms__fqdn: 'librenms.example.com' + +mariadb_server__admin_user: + username: 'mariadb-admin' + password: 'linuxfabrik' + +repo_mariadb__version: '11.4' + +repo_remi__enabled_php_version: '8.4' diff --git a/extensions/molecule/setup_librenms/inventory/hosts.yml b/extensions/molecule/setup_librenms/inventory/hosts.yml new file mode 100644 index 00000000..84fcf41e --- /dev/null +++ b/extensions/molecule/setup_librenms/inventory/hosts.yml @@ -0,0 +1,15 @@ +# yamllint disable rule:empty-values + +# setup_librenms targets 'lfops_setup_librenms' (see playbooks/setup_librenms.yml: hosts). +# Only Red Hat-family hosts run: the playbook installs PHP from Remi (repo_remi, RedHat-gated) +# and pulls in repo_epel and repo_mydumper the same way, and the librenms role installs SELinux +# policy modules. COMPATIBILITY.md lists librenms on RHEL 8, 9 and 10 only. +lfops_setup_librenms: + children: + systems_under_test: + +systems_under_test: + hosts: + rocky8-vm: + rocky9-vm: + rocky10-vm: diff --git a/extensions/molecule/setup_librenms/molecule.yml b/extensions/molecule/setup_librenms/molecule.yml new file mode 100644 index 00000000..1e47cbff --- /dev/null +++ b/extensions/molecule/setup_librenms/molecule.yml @@ -0,0 +1 @@ +# Molecule scenario marker diff --git a/extensions/molecule/setup_librenms/verify.yml b/extensions/molecule/setup_librenms/verify.yml new file mode 100644 index 00000000..084690db --- /dev/null +++ b/extensions/molecule/setup_librenms/verify.yml @@ -0,0 +1,199 @@ +# verify.yml runs after converge and again after the idempotence step. It asserts what only the +# running system can confirm: that RRDCached runs confined, that data actually reaches the RRD +# tree through it, and that PHP-FPM can talk to its socket. The role's own tasks cannot tell any +# of that, and a plain "the service is running" check would still pass while every write is +# denied by SELinux, which is exactly how the earlier attempt at this failed. +- name: 'Verify RRDCached runs and writes the RRD tree' + hosts: 'systems_under_test' + gather_facts: true + become: true + tasks: + + - name: 'systemctl list-units' + ansible.builtin.service_facts: # yamllint disable-line rule:empty-values + + - name: 'Assert rrdcached.service is running and enabled' + ansible.builtin.assert: + that: + - 'ansible_facts["services"]["rrdcached.service"]["state"] | d("") == "running"' + - 'ansible_facts["services"]["rrdcached.service"]["status"] | d("") == "enabled"' + fail_msg: 'rrdcached.service is not running and enabled: {{ ansible_facts["services"]["rrdcached.service"] | d("not present") }}' + + - name: 'stat /run/rrdcached.sock' + ansible.builtin.stat: + path: '/run/rrdcached.sock' + register: '__molecule__rrdcached_socket_result' + + # The socket has to be outside /tmp: httpd and php-fpm run with PrivateTmp=true and would + # not see it there. Its group has to be librenms, otherwise the poller cannot write to it. + - name: 'Assert the RRDCached socket belongs to the librenms group' + ansible.builtin.assert: + that: + - '__molecule__rrdcached_socket_result["stat"]["exists"] | bool' + - '__molecule__rrdcached_socket_result["stat"]["issock"] | bool' + - '__molecule__rrdcached_socket_result["stat"]["gr_name"] == "librenms"' + fail_msg: 'No usable RRDCached socket at /run/rrdcached.sock: {{ __molecule__rrdcached_socket_result["stat"] }}' + + # Assert the domain of the running process, not the label on the binary: only that tells + # rrdcached_t apart from the unconfined_service_t a missing transition would leave behind. + - name: 'ps -o label= -C rrdcached' + ansible.builtin.command: 'ps -o label= -C rrdcached' + register: '__molecule__rrdcached_label_result' + changed_when: false + # ps exits 1 when no process matches, which would otherwise fail the task with a bare rc=1 + # and no indication of what is wrong. The assert below turns it into a sentence. + failed_when: '__molecule__rrdcached_label_result["rc"] not in [0, 1]' + when: + - 'ansible_facts["selinux"]["status"] != "disabled"' + + - name: 'Assert rrdcached runs in the rrdcached_t SELinux domain' + ansible.builtin.assert: + that: + - '__molecule__rrdcached_label_result["rc"] == 0' + - '"rrdcached_t" in __molecule__rrdcached_label_result["stdout"]' + fail_msg: 'rrdcached runs as "{{ __molecule__rrdcached_label_result["stdout"] | trim }}" instead of rrdcached_t.' + when: + - '__molecule__rrdcached_label_result is not skipped' + + - name: 'semodule --list-modules | grep --quiet rrdcached_librenms' + ansible.builtin.shell: 'set -o pipefail; semodule --list-modules | grep --quiet rrdcached_librenms' + args: + executable: '/bin/bash' + register: '__molecule__rrdcached_semodule_result' + changed_when: false + failed_when: '__molecule__rrdcached_semodule_result["rc"] >= 2' + when: + - 'ansible_facts["selinux"]["status"] != "disabled"' + + - name: 'Assert the rrdcached_librenms policy module is installed' + ansible.builtin.assert: + that: + - '__molecule__rrdcached_semodule_result["rc"] == 0' + fail_msg: 'The rrdcached_librenms SELinux policy module is not installed.' + when: + - '__molecule__rrdcached_semodule_result is not skipped' + + - name: 'rm /opt/librenms/rrd/molecule-rrdcached.rrd # leftover of an earlier verify run' + ansible.builtin.file: + path: '/opt/librenms/rrd/molecule-rrdcached.rrd' + state: 'absent' + + # Create, update and flush through the daemon as the LibreNMS user does. This is the whole + # point of the exercise: it only succeeds if rrdcached_t may write the httpd_sys_rw_content_t + # tree below /opt/librenms/rrd, which needs both the file contexts and the policy module. + - name: 'rrdtool create / update / flushcached --daemon unix:/run/rrdcached.sock' + ansible.builtin.command: '{{ item }}' + loop: + - 'rrdtool create molecule-rrdcached.rrd --daemon unix:/run/rrdcached.sock --step 300 DS:test:GAUGE:600:0:U RRA:AVERAGE:0.5:1:10' + - 'rrdtool update molecule-rrdcached.rrd --daemon unix:/run/rrdcached.sock N:42' + - 'rrdtool flushcached molecule-rrdcached.rrd --daemon unix:/run/rrdcached.sock' + args: + chdir: '/opt/librenms/rrd' + become: true + become_user: 'librenms' + changed_when: false # test data, removed again below + + - name: 'stat /opt/librenms/rrd/molecule-rrdcached.rrd' + ansible.builtin.stat: + path: '/opt/librenms/rrd/molecule-rrdcached.rrd' + register: '__molecule__rrdcached_rrd_result' + + - name: 'Assert RRDCached wrote the file into the RRD tree' + ansible.builtin.assert: + that: + - '__molecule__rrdcached_rrd_result["stat"]["exists"] | bool' + - '__molecule__rrdcached_rrd_result["stat"]["pw_name"] == "librenms"' + fail_msg: 'RRDCached did not flush molecule-rrdcached.rrd to /opt/librenms/rrd.' + + # The file inherits the label of the directory it is created in, so this proves the file + # contexts of the role reached the host and were applied. + - name: 'stat --format=%C /opt/librenms/rrd/molecule-rrdcached.rrd' + ansible.builtin.command: 'stat --format=%C /opt/librenms/rrd/molecule-rrdcached.rrd' + register: '__molecule__rrdcached_rrd_context_result' + changed_when: false + when: + - 'ansible_facts["selinux"]["status"] != "disabled"' + + - name: 'Assert the RRD files are labelled httpd_sys_rw_content_t' + ansible.builtin.assert: + that: + - '"httpd_sys_rw_content_t" in __molecule__rrdcached_rrd_context_result["stdout"]' + fail_msg: 'The RRD files are labelled "{{ __molecule__rrdcached_rrd_context_result["stdout"] | trim }}" instead of httpd_sys_rw_content_t.' + when: + - '__molecule__rrdcached_rrd_context_result is not skipped' + + - name: 'rm /opt/librenms/rrd/molecule-rrdcached.rrd' + ansible.builtin.file: + path: '/opt/librenms/rrd/molecule-rrdcached.rrd' + state: 'absent' + + - name: 'ausearch --message avc --start boot' + ansible.builtin.shell: 'set -o pipefail; ausearch --message avc --start boot | grep --extended-regexp "rrdcached|httpd_sys_rw_content_t" || true' + args: + executable: '/bin/bash' + register: '__molecule__rrdcached_avc_result' + changed_when: false + # "status" is enabled / disabled, the enforcing vs permissive distinction lives in "mode" + when: + - 'ansible_facts["selinux"]["mode"] | d("") == "enforcing"' + + - name: 'Assert nothing was denied around RRDCached' + ansible.builtin.assert: + that: + - '__molecule__rrdcached_avc_result["stdout"] | length == 0' + fail_msg: 'SELinux denials around RRDCached: {{ __molecule__rrdcached_avc_result["stdout"] }}' + when: + - '__molecule__rrdcached_avc_result is not skipped' + + +# The web interface draws its graphs from PHP-FPM, which runs in the httpd_t domain. Reaching the +# socket from there needs the connectto and sock_file rules of the policy module, and a socket +# outside /tmp. A probe served through the LibreNMS vHost is the only way to exercise that path, +# because the domain follows from who executes the code, not from the user it runs as. +- name: 'Verify PHP-FPM can talk to RRDCached' + hosts: 'systems_under_test' + gather_facts: true + become: true + tasks: + + - name: 'Deploy /opt/librenms/html/molecule-rrdcached-probe.php' + ansible.builtin.copy: + content: | + Date: Wed, 2 Sep 2026 17:21:03 +0200 Subject: [PATCH 08/19] fix(roles/librenms): install the Python modules the poller imports poller-wrapper.py and discovery-wrapper.py import the LibreNMS package, whose queuemanager imports pymysql at module level, so a host without PyMySQL loses every poll cycle before it starts. dotenv reads /opt/librenms/.env, psutil reaps the worker processes. Until now PyMySQL only reached the host through mariadb_server__python__modules__dependent_var, which the playbook feeds into the python role. A host running with librenms__skip_mariadb_server, an external database, therefore never got it. redis is deliberately left out: LibreNMS imports it for distributed polling only, which this role does not set up. --- CHANGELOG.md | 1 + extensions/molecule/setup_librenms/verify.yml | 27 +++++++++++++++++++ playbooks/setup_librenms.yml | 1 + roles/librenms/README.md | 1 + roles/librenms/defaults/main.yml | 14 ++++++++++ 5 files changed, 44 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb6f761c..e97f0cd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +* **role:librenms**: The poller works on a host whose database is not local, because the Python modules it imports are installed by the role instead of arriving through the MariaDB role. * **role:librenms**: A second run of the role updates an existing installation instead of aborting at the git checkout. * **playbook:setup_librenms**: A fresh installation no longer aborts while starting PHP-FPM. * **playbook:setup_librenms**: The SELinux file contexts for `/opt/librenms` are applied. diff --git a/extensions/molecule/setup_librenms/verify.yml b/extensions/molecule/setup_librenms/verify.yml index 084690db..fa480ea8 100644 --- a/extensions/molecule/setup_librenms/verify.yml +++ b/extensions/molecule/setup_librenms/verify.yml @@ -197,3 +197,30 @@ that: - '__molecule__rrdcached_probe_result["content"] is match("OK 0 PONG")' fail_msg: 'PHP-FPM could not talk to RRDCached: {{ __molecule__rrdcached_probe_result["content"] | d("") }}' + + +# The poller, the discovery and the graphs only work if the interpreters actually find what they +# import and load at runtime. None of that follows from the packages being installed: the modules +# have to be importable for the LibreNMS user, and PHP has to load the extension in the CLI SAPI +# the cron jobs use. +- name: 'Verify the runtime dependencies of the poller and the graphs' + hosts: 'systems_under_test' + gather_facts: true + become: true + tasks: + + # pymysql is imported at module level by LibreNMS/queuemanager.py, so a poll cycle dies + # without it. dotenv reads /opt/librenms/.env, psutil reaps the worker processes. + - name: 'python3 -c "import dotenv, psutil, pymysql"' + ansible.builtin.command: 'python3 -c "import dotenv, psutil, pymysql"' + become: true + become_user: 'librenms' + register: '__molecule__python_modules_result' + changed_when: false + failed_when: false + + - name: 'Assert the poller can import its Python modules' + ansible.builtin.assert: + that: + - '__molecule__python_modules_result["rc"] == 0' + fail_msg: 'The LibreNMS user cannot import the poller modules: {{ __molecule__python_modules_result["stderr"] | d("") }}' diff --git a/playbooks/setup_librenms.yml b/playbooks/setup_librenms.yml index ec0f7e95..a038ab36 100644 --- a/playbooks/setup_librenms.yml +++ b/playbooks/setup_librenms.yml @@ -80,6 +80,7 @@ - role: 'linuxfabrik.lfops.python' python__modules__dependent_var: '{{ apache_httpd__python__modules__dependent_var + + librenms__python__modules__dependent_var + mariadb_server__python__modules__dependent_var }}' when: diff --git a/roles/librenms/README.md b/roles/librenms/README.md index 3ed44c97..251f9233 100644 --- a/roles/librenms/README.md +++ b/roles/librenms/README.md @@ -25,6 +25,7 @@ Any [LFOps playbook](https://github.com/Linuxfabrik/lfops/blob/main/playbooks/RE * MariaDB must be installed, with a database and a user for said database created (role: [linuxfabrik.lfops.mariadb_server](https://github.com/Linuxfabrik/lfops/tree/main/roles/mariadb_server)). * A web server (for example Apache httpd) must be installed, with a virtual host for LibreNMS configured (role: [linuxfabrik.lfops.apache_httpd](https://github.com/Linuxfabrik/lfops/tree/main/roles/apache_httpd)). * PHP version >= 7.3 must be installed (role: [linuxfabrik.lfops.php](https://github.com/Linuxfabrik/lfops/tree/main/roles/php)). +* The Python modules the poller and the discovery need must be installed (have a look at `librenms__python__modules__dependent_var` in the `defaults/main.yml`) (role: [linuxfabrik.lfops.python](https://github.com/Linuxfabrik/lfops/tree/main/roles/python)). * On RHEL-compatible systems, the `httpd_can_connect_ldap` and `httpd_setrlimit` SELinux booleans must be enabled (role: [linuxfabrik.lfops.selinux](https://github.com/Linuxfabrik/lfops/tree/main/roles/selinux)). * On RHEL-compatible systems, the appropriate SELinux file contexts must be set (have a look at `librenms__selinux__fcontexts__dependent_var` in the `defaults/main.yml`) (role: [linuxfabrik.lfops.selinux](https://github.com/Linuxfabrik/lfops/tree/main/roles/selinux)). * On RHEL-compatible systems, the `http_fping` and `rrdcached_librenms` SELinux policy modules must be installed (have a look at `librenms__selinux__modules__dependent_var` in the `defaults/main.yml`) (role: [linuxfabrik.lfops.selinux](https://github.com/Linuxfabrik/lfops/tree/main/roles/selinux)). diff --git a/roles/librenms/defaults/main.yml b/roles/librenms/defaults/main.yml index ac8b47d7..84e3dea3 100644 --- a/roles/librenms/defaults/main.yml +++ b/roles/librenms/defaults/main.yml @@ -105,6 +105,20 @@ librenms__php__modules__dependent_var: - name: 'php-zip' state: 'present' + +# The poller and the discovery run as Python: LibreNMS/queuemanager.py imports pymysql at module +# level, so without PyMySQL every poll cycle dies before it starts. dotenv reads +# /opt/librenms/.env, and psutil reaps the worker processes ("polling gap possible" without it). +# redis is only required for distributed polling, which this role does not set up. +librenms__python__modules__dependent_var: + - name: 'python3-dotenv' + state: 'present' + - name: 'python3-psutil' + state: 'present' + - name: 'python3-PyMySQL' + state: 'present' + + librenms__selinux__booleans__dependent_var: - key: 'httpd_can_connect_ldap' value: 'on' From be8ccc7d3d584b2ed23662ad81394025f5326fb1 Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Wed, 2 Sep 2026 17:41:36 +0200 Subject: [PATCH 09/19] fix(roles/librenms): install php-gmp for exact 64-bit counter rates LibreNMS/Util/Number.php computes counter deltas with GMP, falls back to bcmath, and then to float. We installed neither, so every rate derived from a 64-bit interface counter went through a 53-bit mantissa. The precision warning in that fallback only fires above PHP_FLOAT_MAX, which a uint64 never reaches, so the loss was silent. The upstream package list carries php-gmp on every RHEL version. --- CHANGELOG.md | 1 + extensions/molecule/setup_librenms/verify.yml | 13 +++++++++++++ roles/librenms/defaults/main.yml | 4 ++++ 3 files changed, 18 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e97f0cd6..c7858cf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +* **role:librenms**: PHP GMP is installed, so rates derived from 64-bit interface counters stay exact instead of silently losing precision in float arithmetic. * **role:librenms**: The `http_fping` SELinux policy module is deployed by the `selinux` role, so a playbook run that skips that role no longer installs it. * **role:librenms**: The RRD, log, cache and storage directories are no longer made group-writable. * **role:apache_httpd**: HTTP/2 is enabled and is the preferred protocol on every connection that terminates TLS, while a client that does not offer it is still served HTTP/1.1 and cleartext HTTP/2 (h2c) is not offered; on RedHat the `mod_http2` package is installed for this. diff --git a/extensions/molecule/setup_librenms/verify.yml b/extensions/molecule/setup_librenms/verify.yml index fa480ea8..7cebbe55 100644 --- a/extensions/molecule/setup_librenms/verify.yml +++ b/extensions/molecule/setup_librenms/verify.yml @@ -224,3 +224,16 @@ that: - '__molecule__python_modules_result["rc"] == 0' fail_msg: 'The LibreNMS user cannot import the poller modules: {{ __molecule__python_modules_result["stderr"] | d("") }}' + + # Without gmp (or bcmath) LibreNMS computes 64-bit counter deltas in float and loses + # precision without saying so. + - name: 'php -r "var_export(extension_loaded(\"gmp\"));"' + ansible.builtin.command: 'php -r "var_export(extension_loaded(\"gmp\"));"' + register: '__molecule__php_gmp_result' + changed_when: false + + - name: 'Assert the PHP CLI loads the gmp extension' + ansible.builtin.assert: + that: + - '__molecule__php_gmp_result["stdout"] | trim == "true"' + fail_msg: 'The PHP CLI does not load the gmp extension.' diff --git a/roles/librenms/defaults/main.yml b/roles/librenms/defaults/main.yml index 84e3dea3..18281a98 100644 --- a/roles/librenms/defaults/main.yml +++ b/roles/librenms/defaults/main.yml @@ -88,6 +88,10 @@ librenms__php__modules__dependent_var: state: 'present' - name: 'php-gd' state: 'present' + # without gmp (or bcmath) LibreNMS falls back to float arithmetic for counter deltas, which + # silently loses precision on 64-bit interface counters + - name: 'php-gmp' + state: 'present' - name: 'php-json' state: 'present' - name: 'php-ldap' From 8b72ffd9697bbb45a6b103b78a80d6339c956c5c Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Wed, 2 Sep 2026 17:54:22 +0200 Subject: [PATCH 10/19] fix(roles/librenms): widen http_fping to the current upstream policy Our copy was the version the LibreNMS install docs carried until PR #20308 (2026-08-15) replaced it. The current one additionally allows httpd_t to create an ICMP socket, to bind a raw socket, and node_bind on node_t, which fping needs depending on how it opens its socket. Taken verbatim from the docs again, and compiled with checkmodule plus semodule_package on Rocky 8, 9 and 10. The molecule scenario pings 127.0.0.1 through a probe served by the LibreNMS vHost, so the rules are exercised in the httpd_t domain they are written for. --- CHANGELOG.md | 1 + extensions/molecule/setup_librenms/verify.yml | 42 +++++++++++++++++++ roles/librenms/defaults/main.yml | 17 +++++--- 3 files changed, 54 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7858cf6..b46f1ad0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +* **role:librenms**: The `http_fping` SELinux policy module carries the rules the LibreNMS documentation now lists, so pinging a device from the web interface also works where fping uses an ICMP socket or binds a source address. * **role:librenms**: PHP GMP is installed, so rates derived from 64-bit interface counters stay exact instead of silently losing precision in float arithmetic. * **role:librenms**: The `http_fping` SELinux policy module is deployed by the `selinux` role, so a playbook run that skips that role no longer installs it. * **role:librenms**: The RRD, log, cache and storage directories are no longer made group-writable. diff --git a/extensions/molecule/setup_librenms/verify.yml b/extensions/molecule/setup_librenms/verify.yml index 7cebbe55..58ffac46 100644 --- a/extensions/molecule/setup_librenms/verify.yml +++ b/extensions/molecule/setup_librenms/verify.yml @@ -198,6 +198,48 @@ - '__molecule__rrdcached_probe_result["content"] is match("OK 0 PONG")' fail_msg: 'PHP-FPM could not talk to RRDCached: {{ __molecule__rrdcached_probe_result["content"] | d("") }}' + # fping is what the web interface pings a device with, and it runs in httpd_t like the code + # that calls it. Only the http_fping policy module lets that domain create, bind and use the + # raw and ICMP sockets fping needs. The absolute path is deliberate: /usr/sbin is not in the + # PATH of the FPM pool, and LibreNMS itself resolves the binary with "whereis -b". + - name: 'Deploy /opt/librenms/html/molecule-fping-probe.php' + ansible.builtin.copy: + content: | + &1', $output, $exit_code); + echo ($exit_code === 0 ? 'OK ' : 'FAIL ') . implode(' ', $output); + dest: '/opt/librenms/html/molecule-fping-probe.php' + owner: 'librenms' + group: 'librenms' + mode: 0o644 + + - name: 'restorecon /opt/librenms/html/molecule-fping-probe.php' + ansible.builtin.command: 'restorecon /opt/librenms/html/molecule-fping-probe.php' + changed_when: false + when: + - 'ansible_facts["selinux"]["status"] != "disabled"' + + - name: 'GET the fping probe through Apache and PHP-FPM (Host: {{ librenms__fqdn }})' + ansible.builtin.uri: + url: 'http://localhost/molecule-fping-probe.php' + headers: + Host: '{{ librenms__fqdn }}' + return_content: true + register: '__molecule__fping_probe_result' + + - name: 'rm /opt/librenms/html/molecule-fping-probe.php' + ansible.builtin.file: + path: '/opt/librenms/html/molecule-fping-probe.php' + state: 'absent' + + - name: 'Assert PHP-FPM could ping through fping' + ansible.builtin.assert: + that: + - '__molecule__fping_probe_result["content"] is match("OK ")' + fail_msg: 'PHP-FPM could not run fping: {{ __molecule__fping_probe_result["content"] | d("") }}' + # The poller, the discovery and the graphs only work if the interpreters actually find what they # import and load at runtime. None of that follows from the packages being installed: the modules diff --git a/roles/librenms/defaults/main.yml b/roles/librenms/defaults/main.yml index 18281a98..427c8255 100644 --- a/roles/librenms/defaults/main.yml +++ b/roles/librenms/defaults/main.yml @@ -144,21 +144,26 @@ librenms__selinux__fcontexts__dependent_var: state: 'present' librenms__selinux__modules__dependent_var: - # fping is called by the web interface, which runs in the httpd_t domain and may not open a - # raw socket there. + # Taken verbatim from the "Allow fping" section of the LibreNMS installation docs, so a diff + # against upstream stays trivial. fping is called by the web interface, which runs in the + # httpd_t domain and may neither create a raw or ICMP socket nor bind one there. - name: 'http_fping' content_te: | module http_fping 1.0; require { - type httpd_t; - class capability net_raw; - class rawip_socket { getopt create setopt write read }; + type node_t; + type httpd_t; + class capability net_raw; + class icmp_socket create; + class rawip_socket { getopt create setopt write read bind node_bind }; } #============= httpd_t ============== + allow httpd_t node_t:rawip_socket node_bind; allow httpd_t self:capability net_raw; - allow httpd_t self:rawip_socket { getopt create setopt write read }; + allow httpd_t self:icmp_socket create; + allow httpd_t self:rawip_socket { getopt create setopt write read bind }; # Taken verbatim from dist/rrdcached/rrdcached_librenms.te of the LibreNMS repository, so a # diff against upstream stays trivial. It lets rrdcached_t work on the RRD tree From 7a7b4e34a23196ba33be9a6ae77b11034203e63b Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Wed, 2 Sep 2026 17:58:51 +0200 Subject: [PATCH 11/19] fix(roles/librenms): stop and disable RRDCached when it is switched off librenms__rrdcached_enabled: false only rewrote config.php. The daemon kept running with the role's drop-in and with up to 30 minutes of unflushed updates, while LibreNMS started writing the same files directly. Stopping it shuts it down cleanly, which flushes what it holds. The rrdcached_librenms policy module now stays installed instead of following the toggle: the selinux role runs before this one, so removing it in the same run would deny exactly that shutdown flush. The drop-in and the RRD files are left alone, so the switch can be reversed with another run of the role. --- roles/librenms/README.md | 3 ++- roles/librenms/defaults/main.yml | 5 ++++- roles/librenms/tasks/main.yml | 19 +++++++++++++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/roles/librenms/README.md b/roles/librenms/README.md index 251f9233..d8d716ae 100644 --- a/roles/librenms/README.md +++ b/roles/librenms/README.md @@ -15,6 +15,7 @@ LibreNMS stores its time series in RRD files below `/opt/librenms/rrd`. By defau * The role configures the `rrdcached.service` of the `rrdtool` package with a systemd drop-in and disables the `rrdcached.socket` unit shipped alongside it, because socket activation would make RRDCached ignore the configured socket path. * On RHEL-compatible systems RRDCached needs the `rrdcached_librenms` SELinux policy module, without which it cannot write the RRD files and the web interface cannot draw graphs. The module is declared in `librenms__selinux__modules__dependent_var` and deployed by the `selinux` role, so skipping that role in the playbook leaves RRDCached without it. * Up to 30 minutes of collected data live in memory only. RRDCached journals them to `/var/tmp` and replays the journal after a crash. +* Setting `librenms__rrdcached_enabled` to `false` stops and disables `rrdcached.service`, which flushes the collected data to the RRD files on the way out, and points LibreNMS at the files directly. The systemd drop-in, the `rrdcached_librenms` SELinux policy module and the RRD files themselves stay in place, so the switch can be reversed with another run of the role. ## Dependent Roles @@ -117,7 +118,7 @@ librenms__fqdn: 'librenms.example.com' `librenms__rrdcached_enabled` -* Whether LibreNMS reads and writes its RRD files through RRDCached. Set this to `false` to have LibreNMS access the files directly. Have a look at "How the Role Behaves" above. +* Whether LibreNMS reads and writes its RRD files through RRDCached. Set this to `false` to stop and disable RRDCached and have LibreNMS access the files directly. Have a look at "How the Role Behaves" above. * Type: Boolean. * Default: `true` diff --git a/roles/librenms/defaults/main.yml b/roles/librenms/defaults/main.yml index 427c8255..f189522f 100644 --- a/roles/librenms/defaults/main.yml +++ b/roles/librenms/defaults/main.yml @@ -195,4 +195,7 @@ librenms__selinux__modules__dependent_var: allow rrdcached_t var_run_t:sock_file { create setattr unlink }; allow httpd_t var_run_t:sock_file write; allow httpd_t rrdcached_t:unix_stream_socket connectto; - state: '{{ librenms__rrdcached_enabled | bool | ternary("present", "absent") }}' + # Stays installed even when librenms__rrdcached_enabled is false. The selinux role runs + # before this one, so removing the module in the same run would deny RRDCached the writes of + # the shutdown flush that this role then triggers, losing up to 30 minutes of collected data. + state: 'present' diff --git a/roles/librenms/tasks/main.yml b/roles/librenms/tasks/main.yml index 4ef867b9..8ce41e6f 100644 --- a/roles/librenms/tasks/main.yml +++ b/roles/librenms/tasks/main.yml @@ -157,6 +157,25 @@ - 'librenms:rrdcached' +- block: + + # A clean shutdown writes everything still held in memory to the RRD files, so switching to + # direct writes does not lose the poll cycles RRDCached has not flushed yet. The drop-in and + # the policy module stay in place, so the daemon can be switched back on without a full run. + - name: 'systemctl disable --now rrdcached.service' + ansible.builtin.service: + name: 'rrdcached.service' + enabled: false + state: 'stopped' + + # block + when: + - 'not (librenms__rrdcached_enabled | bool)' + tags: + - 'librenms' + - 'librenms:rrdcached' + + - block: - name: 'cp /opt/librenms/misc/librenms.logrotate /etc/logrotate.d/librenms' From d7426623819a182be3797918020f0ea8f00c48f3 Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Wed, 2 Sep 2026 18:00:44 +0200 Subject: [PATCH 12/19] chore(roles/librenms): align packages and SELinux settings with upstream * acl: composer's postInstall runs setfacl when no .env exists yet. It is not part of a minimal RHEL 10 installation, so that call failed there while it succeeded on 8 and 9. The comment about the group bits described something the role does not do and was corrected. * ImageMagick: not referenced anywhere in the LibreNMS tree and in none of the upstream package lists. * httpd_cache_t for /opt/librenms/cache, which upstream labels and we did not. * httpd_can_sendmail, and httpd_can_network_connect_db as soon as the database is on another host. httpd_execmem, which upstream also sets, stays off: it is only needed with OPcache JIT, which the php role does not enable. * README: the minimum PHP version LibreNMS supports is 8.4, not 7.3. --- CHANGELOG.md | 4 ++++ extensions/molecule/setup_librenms/verify.yml | 15 +++++++++++++ roles/librenms/README.md | 4 ++-- roles/librenms/defaults/main.yml | 22 ++++++++++++++----- roles/librenms/tasks/main.yml | 11 ++++++---- 5 files changed, 45 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b46f1ad0..107f2bb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,7 +50,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * **role:librenms**: The `http_fping` SELinux policy module carries the rules the LibreNMS documentation now lists, so pinging a device from the web interface also works where fping uses an ICMP socket or binds a source address. * **role:librenms**: PHP GMP is installed, so rates derived from 64-bit interface counters stay exact instead of silently losing precision in float arithmetic. +* **role:librenms**: The `httpd_can_sendmail` SELinux boolean is enabled, and `httpd_can_network_connect_db` in addition when the database is on another host. +* **role:librenms**: The SELinux file context of `/opt/librenms/cache` is set. +* **role:librenms**: ImageMagick is no longer installed, LibreNMS does not use it. * **role:librenms**: The `http_fping` SELinux policy module is deployed by the `selinux` role, so a playbook run that skips that role no longer installs it. +* **role:librenms**: The role no longer makes the RRD, log, cache and storage directories group-writable, LibreNMS grants the access it needs itself while installing. * **role:librenms**: The RRD, log, cache and storage directories are no longer made group-writable. * **role:apache_httpd**: HTTP/2 is enabled and is the preferred protocol on every connection that terminates TLS, while a client that does not offer it is still served HTTP/1.1 and cleartext HTTP/2 (h2c) is not offered; on RedHat the `mod_http2` package is installed for this. * **role:keycloak**: `keycloak__https_cipher_suites`, `keycloak__https_protocols`, `keycloak__log` and `keycloak__proxy_trusted_addresses` are YAML lists instead of comma-separated strings. A comma-separated value already in an inventory keeps working, Ansible splits it into the same list. diff --git a/extensions/molecule/setup_librenms/verify.yml b/extensions/molecule/setup_librenms/verify.yml index 58ffac46..8437e287 100644 --- a/extensions/molecule/setup_librenms/verify.yml +++ b/extensions/molecule/setup_librenms/verify.yml @@ -279,3 +279,18 @@ that: - '__molecule__php_gmp_result["stdout"] | trim == "true"' fail_msg: 'The PHP CLI does not load the gmp extension.' + + - name: 'stat --format=%C /opt/librenms/cache' + ansible.builtin.command: 'stat --format=%C /opt/librenms/cache' + register: '__molecule__cache_context_result' + changed_when: false + when: + - 'ansible_facts["selinux"]["status"] != "disabled"' + + - name: 'Assert the cache directory is labelled httpd_cache_t' + ansible.builtin.assert: + that: + - '"httpd_cache_t" in __molecule__cache_context_result["stdout"]' + fail_msg: 'The cache directory is labelled "{{ __molecule__cache_context_result["stdout"] | trim }}" instead of httpd_cache_t.' + when: + - '__molecule__cache_context_result is not skipped' diff --git a/roles/librenms/README.md b/roles/librenms/README.md index d8d716ae..c6fab7bc 100644 --- a/roles/librenms/README.md +++ b/roles/librenms/README.md @@ -25,9 +25,9 @@ Any [LFOps playbook](https://github.com/Linuxfabrik/lfops/blob/main/playbooks/RE * Python 3, and the python3-policycoreutils module (required for the SELinux Ansible tasks) must be installed (role: [linuxfabrik.lfops.policycoreutils](https://github.com/Linuxfabrik/lfops/tree/main/roles/policycoreutils)). * MariaDB must be installed, with a database and a user for said database created (role: [linuxfabrik.lfops.mariadb_server](https://github.com/Linuxfabrik/lfops/tree/main/roles/mariadb_server)). * A web server (for example Apache httpd) must be installed, with a virtual host for LibreNMS configured (role: [linuxfabrik.lfops.apache_httpd](https://github.com/Linuxfabrik/lfops/tree/main/roles/apache_httpd)). -* PHP version >= 7.3 must be installed (role: [linuxfabrik.lfops.php](https://github.com/Linuxfabrik/lfops/tree/main/roles/php)). +* PHP version >= 8.4 must be installed (role: [linuxfabrik.lfops.php](https://github.com/Linuxfabrik/lfops/tree/main/roles/php)). * The Python modules the poller and the discovery need must be installed (have a look at `librenms__python__modules__dependent_var` in the `defaults/main.yml`) (role: [linuxfabrik.lfops.python](https://github.com/Linuxfabrik/lfops/tree/main/roles/python)). -* On RHEL-compatible systems, the `httpd_can_connect_ldap` and `httpd_setrlimit` SELinux booleans must be enabled (role: [linuxfabrik.lfops.selinux](https://github.com/Linuxfabrik/lfops/tree/main/roles/selinux)). +* On RHEL-compatible systems, the SELinux booleans in `librenms__selinux__booleans__dependent_var` must be enabled (role: [linuxfabrik.lfops.selinux](https://github.com/Linuxfabrik/lfops/tree/main/roles/selinux)). * On RHEL-compatible systems, the appropriate SELinux file contexts must be set (have a look at `librenms__selinux__fcontexts__dependent_var` in the `defaults/main.yml`) (role: [linuxfabrik.lfops.selinux](https://github.com/Linuxfabrik/lfops/tree/main/roles/selinux)). * On RHEL-compatible systems, the `http_fping` and `rrdcached_librenms` SELinux policy modules must be installed (have a look at `librenms__selinux__modules__dependent_var` in the `defaults/main.yml`) (role: [linuxfabrik.lfops.selinux](https://github.com/Linuxfabrik/lfops/tree/main/roles/selinux)). diff --git a/roles/librenms/defaults/main.yml b/roles/librenms/defaults/main.yml index f189522f..96fc40a7 100644 --- a/roles/librenms/defaults/main.yml +++ b/roles/librenms/defaults/main.yml @@ -123,11 +123,20 @@ librenms__python__modules__dependent_var: state: 'present' -librenms__selinux__booleans__dependent_var: - - key: 'httpd_can_connect_ldap' - value: 'on' - - key: 'httpd_setrlimit' - value: 'on' +# httpd_can_network_connect_db is only added for a database on another host. With a local one PHP +# connects through the MariaDB socket, so the boolean would let every web application on the host +# open database connections without buying LibreNMS anything. httpd_execmem, which the upstream +# docs also set, is only needed with OPcache JIT, which the php role does not enable. +librenms__selinux__booleans__dependent_var: '{{ + [ + {"key": "httpd_can_connect_ldap", "value": "on"}, + {"key": "httpd_can_sendmail", "value": "on"}, + {"key": "httpd_setrlimit", "value": "on"} + ] + ( + (librenms__database_host not in ["127.0.0.1", "::1", "localhost"]) + | ternary([{"key": "httpd_can_network_connect_db", "value": "on"}], []) + ) + }}' librenms__selinux__fcontexts__dependent_var: - setype: 'httpd_sys_content_t' target: '/opt/librenms/html(/.*)?' @@ -136,6 +145,9 @@ librenms__selinux__fcontexts__dependent_var: - setype: 'httpd_log_t' target: '/opt/librenms/logs(/.*)?' state: 'present' + - setype: 'httpd_cache_t' + target: '/opt/librenms/cache(/.*)?' + state: 'present' - setype: 'httpd_sys_rw_content_t' target: '/opt/librenms/(rrd|storage)(/.*)?' state: 'present' diff --git a/roles/librenms/tasks/main.yml b/roles/librenms/tasks/main.yml index 8ce41e6f..6c304826 100644 --- a/roles/librenms/tasks/main.yml +++ b/roles/librenms/tasks/main.yml @@ -1,11 +1,11 @@ - block: - - name: 'Install cronie fping ImageMagick mtr net-snmp net-snmp-utils nmap rrdtool unzip' + - name: 'Install acl cronie fping mtr net-snmp net-snmp-utils nmap rrdtool unzip' ansible.builtin.package: name: + - 'acl' - 'cronie' - 'fping' - - 'ImageMagick' - 'mtr' - 'net-snmp' - 'net-snmp-utils' @@ -53,8 +53,11 @@ # The upstream docs make rrd, logs, bootstrap/cache and storage group-writable, for setups # where the web server runs under its own account. Here PHP-FPM, cron and the scheduler all - # run as librenms, which owns those trees, so the group bits buy nothing and cannot be kept - # anyway: the application creates files below them with umask 022 as it runs. + # run as librenms, which owns those trees, so the role does not set the group bits itself. + # Composer still does on a first install: LibreNMS/ComposerHelper.php runs setfacl whenever + # there is no .env yet. That is why acl is installed above, it is not part of a minimal + # installation on RHEL 10 and the call would fail with "command not found" there, while it + # succeeds on 8 and 9. Verified against LibreNMS 26.8 on Rocky 9 and Rocky 10. - name: 'ln -s /opt/librenms/lnms /usr/local/bin/lnms' ansible.builtin.file: From abe90d9af6b79c9712b50e59f9d135e072399b7d Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Wed, 2 Sep 2026 18:03:14 +0200 Subject: [PATCH 13/19] feat(roles/librenms): add meta/argument_specs.yml The role had no argument spec, so a missing librenms__database_login only surfaced when a task dereferenced it. Declares the variables documented in the README, with librenms__database_login and librenms__fqdn required. librenms__rrdcached_service_state carries no default: defaults/main.yml derives it from librenms__rrdcached_service_enabled with a Jinja2 expression, which argument_specs cannot evaluate. --- roles/librenms/meta/argument_specs.yml | 90 ++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 roles/librenms/meta/argument_specs.yml diff --git a/roles/librenms/meta/argument_specs.yml b/roles/librenms/meta/argument_specs.yml new file mode 100644 index 00000000..a49d1f60 --- /dev/null +++ b/roles/librenms/meta/argument_specs.yml @@ -0,0 +1,90 @@ +argument_specs: + main: + options: + + librenms__config_app_trusted_proxies: + type: 'list' + elements: 'str' + required: false + default: [] + description: 'Trusted reverse proxy IPs or CIDR ranges, written to APP_TRUSTED_PROXIES in /opt/librenms/.env.' + + librenms__config_app_url: + type: 'str' + required: false + default: '' + description: 'The base URL used for generated URLs, written to APP_URL in /opt/librenms/.env.' + + librenms__config_auth_mechanism: + type: 'str' + required: false + default: 'mysql' + choices: + - 'active_directory' + - 'http-auth' + - 'ldap' + - 'ldap-authorization' + - 'mysql' + - 'sso' + description: 'Which authentication mechanism LibreNMS should use.' + + librenms__config_rrd_purge: + type: 'int' + required: false + default: 0 + description: 'Number in days of how long to keep old rrd files. 0 disables this feature.' + + librenms__config_update_channel: + type: 'str' + required: false + default: 'release' + choices: + - 'master' + - 'release' + description: 'Which update channel LibreNMS should use during automatic updates.' + + librenms__database_host: + type: 'str' + required: false + default: 'localhost' + description: 'The host on which the MySQL database is reachable.' + + librenms__database_login: + type: 'dict' + required: true + description: 'The user account for accessing the MySQL database.' + + librenms__database_name: + type: 'str' + required: false + default: 'librenms' + description: 'The name of the SQL database.' + + librenms__fqdn: + type: 'str' + required: true + description: 'The fully qualified domain name under which LibreNMS is accessible.' + + librenms__rrdcached_enabled: + type: 'bool' + required: false + default: true + description: 'Whether LibreNMS reads and writes its RRD files through RRDCached.' + + librenms__rrdcached_service_enabled: + type: 'bool' + required: false + default: true + description: 'Enables or disables the RRDCached service.' + + # no default: defaults/main.yml derives it from librenms__rrdcached_service_enabled with a + # Jinja2 expression, which argument_specs cannot evaluate + librenms__rrdcached_service_state: + type: 'str' + required: false + choices: + - 'reloaded' + - 'restarted' + - 'started' + - 'stopped' + description: 'Changes the state of the RRDCached service.' From 10f2468cb6344584e654a6ffb82abfe6551c72cf Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Wed, 2 Sep 2026 18:52:08 +0200 Subject: [PATCH 14/19] feat(roles/librenms)!: stop trusting a reverse proxy by default APP_TRUSTED_PROXIES was only written when the inventory listed a proxy, so a host with an empty list kept LibreNMS's own default, which trusts 127.0.0.1 since upstream 26.5.0. A host that trusts a proxy accepts its X-Forwarded-* headers, so anything able to reach LibreNMS from that address decides what LibreNMS records as the client and as the protocol. The setting is now written on every run, including with an empty value. LibreNMS treats the empty string as a deliberate "no proxies": EnvHelper::parseArray keeps "" out of the comma split, and the upstream security docs use exactly that value. Writing it unconditionally also means the role converges the setting instead of leaving a stale line behind when a proxy is dropped from the inventory. APP_URL moves into its own task, because it has no meaningful empty value: writing it empty sets Laravel's app.url to the empty string rather than falling back to anything, and LibreNMS then reports it as misconfigured. --- CHANGELOG.md | 1 + extensions/molecule/setup_librenms/verify.yml | 15 +++++++++++++++ roles/librenms/README.md | 3 ++- roles/librenms/defaults/main.yml | 2 +- roles/librenms/tasks/main.yml | 19 ++++++++++++++++--- 5 files changed, 35 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 107f2bb3..b0d76dad 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 ### Breaking Changes +* **role:librenms**: LibreNMS trusts no reverse proxy any more, where it previously trusted one running on the LibreNMS host itself. It therefore ignores the `X-Forwarded-*` headers of such a proxy, and logs, access control and the detected protocol use the proxy's address instead of the client's. Hosts with a proxy in front of LibreNMS list it in `librenms__config_app_trusted_proxies`; `['127.0.0.1']` restores the previous behaviour. * **role:php**: The PHP-FPM pool configuration changed for existing hosts. Sessions now live in a per-pool directory (the default `www` pool moves from `/var/lib/php/session` to `/var/lib/php/session/www`), so logged-in users are signed out once after the upgrade. `memory_limit`, `max_execution_time`, `max_input_vars`, `post_max_size`, `upload_max_filesize`, `session.save_handler` and `session.save_path` are now enforced as `php_admin_value`, so applications can no longer change them at runtime via `ini_set()`. A pool that stores its sessions in redis or memcached sets `php_admin_value_session_save_handler` accordingly. `soap.wsdl_cache_dir` moves from the shared `/var/lib/php/wsdlcache` to a per-pool directory below it. On Debian the pool socket now belongs to `root` and grants the web server access through a POSIX ACL entry, where it used to be owned by the web server user; anything of your own that checks the socket's ownership rather than connecting to it needs adjusting. Worker processes recycle after 500 requests (`pm.max_requests`) instead of running indefinitely, and a worker still serving a single request after 60 seconds is killed (`request_terminate_timeout`, previously off). Hosts with legitimately long-running web requests raise `php__fpm_pool_conf_request_terminate_timeout__group_var`. * **role:keycloak**: Rename `keycloak__state` to `keycloak__service_state`, the name every other LFOps role uses. The value `reloaded` is gone: Keycloak's systemd unit has no `ExecReload`, so a reload never worked; use `restarted` instead. * **role:keycloak**: The role installs the OpenJDK its Keycloak version needs (OpenJDK 17 for Keycloak 24, OpenJDK 21 for 25 and newer) instead of relying on the `apps` role, which `setup_keycloak` no longer runs. Hosts that used `apps__apps__*_var` through this playbook to install further packages have to run the `apps` playbook for them. diff --git a/extensions/molecule/setup_librenms/verify.yml b/extensions/molecule/setup_librenms/verify.yml index 8437e287..84a9e34f 100644 --- a/extensions/molecule/setup_librenms/verify.yml +++ b/extensions/molecule/setup_librenms/verify.yml @@ -280,6 +280,21 @@ - '__molecule__php_gmp_result["stdout"] | trim == "true"' fail_msg: 'The PHP CLI does not load the gmp extension.' + # A floor, not a runtime check: the setting is only observable on the wire, from behind a + # proxy, and the scenario has none. Asserting the line at least proves that the empty proxy + # list is written rather than skipped, and that it reached the .env LibreNMS reads. + - name: 'grep APP_TRUSTED_PROXIES /opt/librenms/.env' + ansible.builtin.command: "grep --count --extended-regexp \"^APP_TRUSTED_PROXIES=''$\" /opt/librenms/.env" + register: '__molecule__trusted_proxies_result' + changed_when: false + failed_when: false + + - name: 'Assert the empty trusted proxy list reached the LibreNMS environment' + ansible.builtin.assert: + that: + - '__molecule__trusted_proxies_result["stdout"] | trim == "1"' + fail_msg: "APP_TRUSTED_PROXIES='' is not set in /opt/librenms/.env." + - name: 'stat --format=%C /opt/librenms/cache' ansible.builtin.command: 'stat --format=%C /opt/librenms/cache' register: '__molecule__cache_context_result' diff --git a/roles/librenms/README.md b/roles/librenms/README.md index c6fab7bc..835727aa 100644 --- a/roles/librenms/README.md +++ b/roles/librenms/README.md @@ -76,9 +76,10 @@ librenms__fqdn: 'librenms.example.com' `librenms__config_app_trusted_proxies` -* A list of trusted reverse proxy IPs or CIDR ranges, joined into the comma separated `APP_TRUSTED_PROXIES` setting in `/opt/librenms/.env`. Have a look at https://docs.librenms.org/Support/Environment-Variables/. An empty list leaves the setting untouched. +* A list of trusted reverse proxy IPs or CIDR ranges, joined into the comma separated `APP_TRUSTED_PROXIES` setting in `/opt/librenms/.env`, and written on every run. Have a look at https://docs.librenms.org/Support/Environment-Variables/. The empty default trusts no proxy at all, so LibreNMS ignores the `X-Forwarded-*` headers of any host: list your proxy here if one sits in front of LibreNMS, otherwise client addresses and the detected protocol are those of the proxy. * Type: List. * Default: `[]` +* Deviates from the upstream default `127.0.0.1`: a proxy on the LibreNMS host itself is not the common case in LFOps, and a host that trusts one accepts spoofed `X-Forwarded-For` headers from anything able to reach it locally. `librenms__config_app_url` diff --git a/roles/librenms/defaults/main.yml b/roles/librenms/defaults/main.yml index 96fc40a7..d4773e95 100644 --- a/roles/librenms/defaults/main.yml +++ b/roles/librenms/defaults/main.yml @@ -1,4 +1,4 @@ -librenms__config_app_trusted_proxies: [] +librenms__config_app_trusted_proxies: [] # upstream default: 127.0.0.1 librenms__config_app_url: '' librenms__config_auth_mechanism: 'mysql' librenms__config_rrd_purge: 0 diff --git a/roles/librenms/tasks/main.yml b/roles/librenms/tasks/main.yml index 6c304826..4e77937c 100644 --- a/roles/librenms/tasks/main.yml +++ b/roles/librenms/tasks/main.yml @@ -265,6 +265,10 @@ - key: 'DB_PASSWORD' value: '{{ librenms__database_login["password"] }}' + # Written on every run, including with an empty value, so the setting follows the inventory + # in both directions instead of leaving a stale line behind. LibreNMS reads the empty string + # as a deliberate value: EnvHelper::parseArray keeps "" out of the comma split, which is how + # the upstream security docs switch trusted proxies off. - name: 'Set app settings in /opt/librenms/.env' ansible.builtin.lineinfile: path: '/opt/librenms/.env' @@ -275,9 +279,18 @@ loop: - key: 'APP_TRUSTED_PROXIES' value: '{{ librenms__config_app_trusted_proxies | join(",") }}' - - key: 'APP_URL' - value: '{{ librenms__config_app_url }}' - when: 'item["value"] | length > 0' + + # APP_URL has no meaningful empty value: writing it empty sets Laravel's app.url to the empty + # string rather than falling back to anything, and LibreNMS then reports it as misconfigured. + - name: 'Set the base URL in /opt/librenms/.env' + ansible.builtin.lineinfile: + path: '/opt/librenms/.env' + regexp: '^#?APP_URL' + line: 'APP_URL={{ librenms__config_app_url | quote }}' + mode: 0o600 # file contains secrets + state: 'present' + when: + - 'librenms__config_app_url | length > 0' tags: - 'librenms' From 104f3eb47fd62e1b08ea6943043631b77b2aa358 Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Wed, 2 Sep 2026 18:53:38 +0200 Subject: [PATCH 15/19] feat(roles/librenms): mark the session cookie as secure on HTTPS hosts LibreNMS is a Laravel application: the session cookie is issued by the framework's session middleware, which reads config('session.secure'). LibreNMS config/session.php defines only driver, lifetime and same_site, so that key falls through to Laravel's base config, env('SESSION_SECURE_COOKIE') from /opt/librenms/.env. Without it, validate.php fails with "Secure session cookies are not enabled" on every HTTPS install. php__ini_session_cookie_secure of the php role cannot cover this. It renders session.cookie_secure into php.ini, which governs the native session handler that Laravel never uses. The default follows librenms__config_app_url rather than being on outright: the role ships a port 80 vHost, and the flag on a plain-HTTP host means the browser stops sending the cookie back and the login fails with 419 Page Expired. An https base URL is the host's own statement that it has TLS, and the setting is written on every run, so it follows that URL in both directions. --- CHANGELOG.md | 1 + .../group_vars/systems_under_test.yml | 4 +++ extensions/molecule/setup_librenms/verify.yml | 26 ++++++++++++------- roles/librenms/README.md | 7 +++++ roles/librenms/defaults/main.yml | 1 + roles/librenms/meta/argument_specs.yml | 7 +++++ roles/librenms/tasks/main.yml | 12 ++++++--- 7 files changed, 45 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0d76dad..4a3e73bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +* **role:librenms**: The session cookie is marked as secure on hosts whose `librenms__config_app_url` is an `https://` URL, so a browser only sends it over HTTPS. LibreNMS reports the missing flag as a failure in its own validation. Set `librenms__config_session_secure_cookie` to override. * **role:librenms**: LibreNMS stores its RRD files through RRDCached, which cuts the disk I/O of the poller by roughly a third. * **role:librenms**: The `librenms:rrdcached` tag deploys and configures RRDCached without touching the rest of the installation. * **role:php**: PHP-FPM pools are now fully configurable, each with its own user and group, process-manager tuning, timeouts and `php_admin_value` overrides. Every pool gets an isolated session directory, its own error and slow logs, and its own socket, so several applications can share a host without sharing a PHP process, a session store or a memory limit. One pool template now serves both distribution families. diff --git a/extensions/molecule/setup_librenms/inventory/group_vars/systems_under_test.yml b/extensions/molecule/setup_librenms/inventory/group_vars/systems_under_test.yml index d215bd87..390ffd4a 100644 --- a/extensions/molecule/setup_librenms/inventory/group_vars/systems_under_test.yml +++ b/extensions/molecule/setup_librenms/inventory/group_vars/systems_under_test.yml @@ -1,5 +1,9 @@ apache_httpd__conf_server_admin: 'root@localhost' +# an https base URL is what makes the role mark the session cookie as secure. The vHost itself +# serves plain HTTP, which the probes in verify.yml talk to directly, so this only exercises the +# derived default of librenms__config_session_secure_cookie. +librenms__config_app_url: 'https://librenms.example.com' librenms__database_login: username: 'librenms' password: 'linuxfabrik' diff --git a/extensions/molecule/setup_librenms/verify.yml b/extensions/molecule/setup_librenms/verify.yml index 84a9e34f..0c2cf269 100644 --- a/extensions/molecule/setup_librenms/verify.yml +++ b/extensions/molecule/setup_librenms/verify.yml @@ -280,20 +280,28 @@ - '__molecule__php_gmp_result["stdout"] | trim == "true"' fail_msg: 'The PHP CLI does not load the gmp extension.' - # A floor, not a runtime check: the setting is only observable on the wire, from behind a - # proxy, and the scenario has none. Asserting the line at least proves that the empty proxy - # list is written rather than skipped, and that it reached the .env LibreNMS reads. - - name: 'grep APP_TRUSTED_PROXIES /opt/librenms/.env' - ansible.builtin.command: "grep --count --extended-regexp \"^APP_TRUSTED_PROXIES=''$\" /opt/librenms/.env" - register: '__molecule__trusted_proxies_result' + # A floor, not a runtime check: both settings are only observable on the wire, over HTTPS and + # from behind a proxy, and the scenario has neither. Asserting the lines at least proves that + # the https APP_URL of the inventory switched the cookie on, that the empty proxy list is + # written rather than skipped, and that both reached the .env Laravel reads instead of a + # php.ini the framework never consults. + - name: 'grep APP_TRUSTED_PROXIES SESSION_SECURE_COOKIE /opt/librenms/.env' + ansible.builtin.command: 'grep --count --extended-regexp "^({{ item }})$" /opt/librenms/.env' + register: '__molecule__env_setting_result' changed_when: false failed_when: false + loop: + - "APP_TRUSTED_PROXIES=''" + - 'SESSION_SECURE_COOKIE=true' - - name: 'Assert the empty trusted proxy list reached the LibreNMS environment' + - name: 'Assert both settings reached the LibreNMS environment' ansible.builtin.assert: that: - - '__molecule__trusted_proxies_result["stdout"] | trim == "1"' - fail_msg: "APP_TRUSTED_PROXIES='' is not set in /opt/librenms/.env." + - 'item["stdout"] | trim == "1"' + fail_msg: '{{ item["item"] }} is not set in /opt/librenms/.env.' + loop: '{{ __molecule__env_setting_result["results"] }}' + loop_control: + label: '{{ item["item"] }}' - name: 'stat --format=%C /opt/librenms/cache' ansible.builtin.command: 'stat --format=%C /opt/librenms/cache' diff --git a/roles/librenms/README.md b/roles/librenms/README.md index 835727aa..b86d0c49 100644 --- a/roles/librenms/README.md +++ b/roles/librenms/README.md @@ -99,6 +99,12 @@ librenms__fqdn: 'librenms.example.com' * Type: Number. * Default: `0` +`librenms__config_session_secure_cookie` + +* Whether LibreNMS marks its session cookie as secure, so a browser only sends it over HTTPS. Sets `SESSION_SECURE_COOKIE` in `/opt/librenms/.env` on every run. Defaults to `true` as soon as `librenms__config_app_url` names an `https://` URL, which is the host's own statement that it serves HTTPS. Do not set it to `true` on a host that is reachable over plain HTTP only: the browser then never sends the cookie back and the login fails with "419 Page Expired". LibreNMS is a Laravel application and issues this cookie itself, so `php__ini_session_cookie_secure` of the `php` role does not reach it. +* Type: Boolean. +* Default: `true` if `librenms__config_app_url` starts with `https://`, else `false` + `librenms__config_update_channel` * Which update channel LibreNMS should use during automatic updates. Possible options: `master`, `release`. @@ -144,6 +150,7 @@ librenms__config_app_trusted_proxies: librenms__config_app_url: 'https://librenms.example.com' librenms__config_auth_mechanism: 'mysql' librenms__config_rrd_purge: 730 +librenms__config_session_secure_cookie: true librenms__config_update_channel: 'release' librenms__database_host: 'localhost' librenms__database_name: 'librenms' diff --git a/roles/librenms/defaults/main.yml b/roles/librenms/defaults/main.yml index d4773e95..39bc5947 100644 --- a/roles/librenms/defaults/main.yml +++ b/roles/librenms/defaults/main.yml @@ -2,6 +2,7 @@ librenms__config_app_trusted_proxies: [] # upstream default: 127.0.0.1 librenms__config_app_url: '' librenms__config_auth_mechanism: 'mysql' librenms__config_rrd_purge: 0 +librenms__config_session_secure_cookie: '{{ (librenms__config_app_url is match("https://")) | ternary(true, false) }}' librenms__config_update_channel: 'release' librenms__database_host: 'localhost' librenms__database_name: 'librenms' diff --git a/roles/librenms/meta/argument_specs.yml b/roles/librenms/meta/argument_specs.yml index a49d1f60..c06ad695 100644 --- a/roles/librenms/meta/argument_specs.yml +++ b/roles/librenms/meta/argument_specs.yml @@ -34,6 +34,13 @@ argument_specs: default: 0 description: 'Number in days of how long to keep old rrd files. 0 disables this feature.' + # no default: defaults/main.yml derives it from librenms__config_app_url with a Jinja2 + # expression, which argument_specs cannot evaluate + librenms__config_session_secure_cookie: + type: 'bool' + required: false + description: 'Whether LibreNMS marks its session cookie as secure, written to SESSION_SECURE_COOKIE in /opt/librenms/.env.' + librenms__config_update_channel: type: 'str' required: false diff --git a/roles/librenms/tasks/main.yml b/roles/librenms/tasks/main.yml index 4e77937c..19e1b0d6 100644 --- a/roles/librenms/tasks/main.yml +++ b/roles/librenms/tasks/main.yml @@ -265,10 +265,10 @@ - key: 'DB_PASSWORD' value: '{{ librenms__database_login["password"] }}' - # Written on every run, including with an empty value, so the setting follows the inventory - # in both directions instead of leaving a stale line behind. LibreNMS reads the empty string - # as a deliberate value: EnvHelper::parseArray keeps "" out of the comma split, which is how - # the upstream security docs switch trusted proxies off. + # Written on every run, including with an empty value, so both settings follow the inventory in + # both directions instead of leaving a stale line behind. LibreNMS reads the empty string as a + # deliberate value: EnvHelper::parseArray keeps "" out of the comma split, which is how the + # upstream security docs switch trusted proxies off. - name: 'Set app settings in /opt/librenms/.env' ansible.builtin.lineinfile: path: '/opt/librenms/.env' @@ -279,6 +279,10 @@ loop: - key: 'APP_TRUSTED_PROXIES' value: '{{ librenms__config_app_trusted_proxies | join(",") }}' + # Laravel reads its session cookie flags from .env, not from php.ini: the cookie is issued + # by the framework, so session.cookie_secure of the php role does not reach it. + - key: 'SESSION_SECURE_COOKIE' + value: '{{ librenms__config_session_secure_cookie | bool | ternary("true", "false") }}' # APP_URL has no meaningful empty value: writing it empty sets Laravel's app.url to the empty # string rather than falling back to anything, and LibreNMS then reports it as misconfigured. From 2fee5343030947e74bcd0e218a8f8d781ce18d06 Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Thu, 3 Sep 2026 13:36:25 +0200 Subject: [PATCH 16/19] feat(roles/librenms): manage the scheduler timer and follow the tag vocabulary librenms__scheduler_service_enabled and librenms__scheduler_service_state make the LibreNMS scheduler timer behave like every other service in the collection, with enabled and state as separate tasks instead of one "systemctl enable --now". The blocks are split along the controlled tag vocabulary of CONTRIBUTING.md, so librenms:cron, librenms:logrotate and librenms:state each manage their own area. The scheduler units come out of the git checkout and an update of LibreNMS can change them, but nothing told systemd to read them again: it kept running the version it had loaded before. Both units now go through one loop whose result gates a daemon-reload. git is installed by the role itself. It arrived through the general-purpose apps role via librenms__apps__apps__dependent_var, which CONTRIBUTING.md rules out as a dependency, and setup_librenms.yml therefore no longer runs apps. Its skip variables are named after the playbook now (setup_librenms__skip_*), the form the README documents; two of them already were. Further alignment, none of it changing what a run does: * restorecon is guarded by the SELinux status check * the role-internal registers carry the __ prefix * the .env key regexps are anchored on the "=", with a note on why the file is edited with lineinfile rather than rendered from a template * config.php.j2 loses three d() fallbacks whose variables all have a default, and whose update_channel value contradicted it ('master' vs 'release') The README gains the Requirements and Post-Installation Steps sections, the git-checkout and self-update behaviour, the subkeys of librenms__database_login and the corrected trigger of the librenms tag. The Molecule scenario asserts the scheduler timer is active and enabled. --- CHANGELOG.md | 5 ++ extensions/molecule/setup_librenms/verify.yml | 16 ++++ playbooks/README.md | 21 +++-- playbooks/setup_librenms.yml | 31 +++---- roles/librenms/README.md | 57 +++++++++++- roles/librenms/defaults/main.yml | 9 +- roles/librenms/meta/argument_specs.yml | 18 ++++ roles/librenms/tasks/main.yml | 86 ++++++++++++++----- .../templates/opt/librenms/config.php.j2 | 6 +- 9 files changed, 185 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a3e73bd..8c47ef78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +* **role:librenms**: `librenms__scheduler_service_enabled` and `librenms__scheduler_service_state` start, stop or disable the timer of the LibreNMS scheduler on its own. +* **role:librenms**: The `librenms:cron`, `librenms:logrotate` and `librenms:state` tags deploy the scheduled jobs, deploy the logrotate configuration and manage the service state without touching the rest of the installation. * **role:librenms**: The session cookie is marked as secure on hosts whose `librenms__config_app_url` is an `https://` URL, so a browser only sends it over HTTPS. LibreNMS reports the missing flag as a failure in its own validation. Set `librenms__config_session_secure_cookie` to override. * **role:librenms**: LibreNMS stores its RRD files through RRDCached, which cuts the disk I/O of the poller by roughly a third. * **role:librenms**: The `librenms:rrdcached` tag deploys and configures RRDCached without touching the rest of the installation. @@ -50,6 +52,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +* **playbook:setup_librenms**: The skip variables of the playbook are all named after the playbook, `setup_librenms__skip_php` for example, instead of after the `librenms` role. +* **playbook:setup_librenms**: The playbook no longer runs the `apps` role, since the `librenms` role installs `git` itself. * **role:librenms**: The `http_fping` SELinux policy module carries the rules the LibreNMS documentation now lists, so pinging a device from the web interface also works where fping uses an ICMP socket or binds a source address. * **role:librenms**: PHP GMP is installed, so rates derived from 64-bit interface counters stay exact instead of silently losing precision in float arithmetic. * **role:librenms**: The `httpd_can_sendmail` SELinux boolean is enabled, and `httpd_can_network_connect_db` in addition when the database is on another host. @@ -72,6 +76,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +* **role:librenms**: An update of LibreNMS that changes the units of its scheduler takes effect, because systemd is told to read them again. * **role:librenms**: The poller works on a host whose database is not local, because the Python modules it imports are installed by the role instead of arriving through the MariaDB role. * **role:librenms**: A second run of the role updates an existing installation instead of aborting at the git checkout. * **playbook:setup_librenms**: A fresh installation no longer aborts while starting PHP-FPM. diff --git a/extensions/molecule/setup_librenms/verify.yml b/extensions/molecule/setup_librenms/verify.yml index 0c2cf269..75a8e32a 100644 --- a/extensions/molecule/setup_librenms/verify.yml +++ b/extensions/molecule/setup_librenms/verify.yml @@ -317,3 +317,19 @@ fail_msg: 'The cache directory is labelled "{{ __molecule__cache_context_result["stdout"] | trim }}" instead of httpd_cache_t.' when: - '__molecule__cache_context_result is not skipped' + + # The scheduler runs the maintenance and the alerting jobs of LibreNMS. service_facts only + # lists units of type service, so a timer has to be asked for directly. Enabled matters as + # much as active here: the units come out of the git checkout, and only a daemon-reload after + # they change keeps what systemd runs in sync with what is on disk. + - name: 'systemctl show librenms-scheduler.timer' + ansible.builtin.systemd: + name: 'librenms-scheduler.timer' + register: '__molecule__scheduler_timer_result' + + - name: 'Assert the LibreNMS scheduler timer is running and enabled' + ansible.builtin.assert: + that: + - '__molecule__scheduler_timer_result["status"]["ActiveState"] == "active"' + - '__molecule__scheduler_timer_result["status"]["UnitFileState"] == "enabled"' + fail_msg: 'The LibreNMS scheduler timer is {{ __molecule__scheduler_timer_result["status"]["ActiveState"] }} and {{ __molecule__scheduler_timer_result["status"]["UnitFileState"] }}.' diff --git a/playbooks/README.md b/playbooks/README.md index a7dcfd86..a61be0a5 100644 --- a/playbooks/README.md +++ b/playbooks/README.md @@ -1194,21 +1194,20 @@ Calls the following roles (in order): Calls the following roles (in order): -* [yum_utils](https://github.com/Linuxfabrik/lfops/tree/main/roles/yum_utils): `librenms__skip_yum_utils` +* [yum_utils](https://github.com/Linuxfabrik/lfops/tree/main/roles/yum_utils): `setup_librenms__skip_yum_utils` * [repo_baseos](https://github.com/Linuxfabrik/lfops/tree/main/roles/repo_baseos): `setup_librenms__skip_repo_baseos` -* [repo_epel](https://github.com/Linuxfabrik/lfops/tree/main/roles/repo_epel): `librenms__skip_repo_epel` -* [repo_mydumper](https://github.com/Linuxfabrik/lfops/tree/main/roles/repo_mydumper): `librenms__skip_repo_mydumper` -* [repo_mariadb](https://github.com/Linuxfabrik/lfops/tree/main/roles/repo_mariadb): `librenms__skip_repo_mariadb` +* [repo_epel](https://github.com/Linuxfabrik/lfops/tree/main/roles/repo_epel): `setup_librenms__skip_repo_epel` +* [repo_mydumper](https://github.com/Linuxfabrik/lfops/tree/main/roles/repo_mydumper): `setup_librenms__skip_repo_mydumper` +* [repo_mariadb](https://github.com/Linuxfabrik/lfops/tree/main/roles/repo_mariadb): `setup_librenms__skip_repo_mariadb` * [policycoreutils](https://github.com/Linuxfabrik/lfops/tree/main/roles/policycoreutils): `setup_librenms__skip_policycoreutils` * [selinux](https://github.com/Linuxfabrik/lfops/tree/main/roles/selinux): `setup_librenms__skip_selinux` -* [python](https://github.com/Linuxfabrik/lfops/tree/main/roles/python): `librenms__skip_python` -* [kernel_settings](https://github.com/Linuxfabrik/lfops/tree/main/roles/kernel_settings): `librenms__skip_kernel_settings` -* [mariadb_server](https://github.com/Linuxfabrik/lfops/tree/main/roles/mariadb_server): `librenms__skip_mariadb_server` -* [repo_remi](https://github.com/Linuxfabrik/lfops/tree/main/roles/repo_remi): `librenms__skip_repo_remi` -* [php](https://github.com/Linuxfabrik/lfops/tree/main/roles/php): `librenms__skip_php` -* [apps](https://github.com/Linuxfabrik/lfops/tree/main/roles/apps): `librenms__skip_apps` +* [python](https://github.com/Linuxfabrik/lfops/tree/main/roles/python): `setup_librenms__skip_python` +* [kernel_settings](https://github.com/Linuxfabrik/lfops/tree/main/roles/kernel_settings): `setup_librenms__skip_kernel_settings` +* [mariadb_server](https://github.com/Linuxfabrik/lfops/tree/main/roles/mariadb_server): `setup_librenms__skip_mariadb_server` +* [repo_remi](https://github.com/Linuxfabrik/lfops/tree/main/roles/repo_remi): `setup_librenms__skip_repo_remi` +* [php](https://github.com/Linuxfabrik/lfops/tree/main/roles/php): `setup_librenms__skip_php` * [librenms](https://github.com/Linuxfabrik/lfops/tree/main/roles/librenms) -* [apache_httpd](https://github.com/Linuxfabrik/lfops/tree/main/roles/apache_httpd): `librenms__skip_apache_httpd` +* [apache_httpd](https://github.com/Linuxfabrik/lfops/tree/main/roles/apache_httpd): `setup_librenms__skip_apache_httpd` ## setup_mastodon.yml diff --git a/playbooks/setup_librenms.yml b/playbooks/setup_librenms.yml index a038ab36..37a5c556 100644 --- a/playbooks/setup_librenms.yml +++ b/playbooks/setup_librenms.yml @@ -32,7 +32,7 @@ when: - 'ansible_facts["os_family"] == "RedHat"' - 'ansible_facts["distribution_major_version"] in ["7"]' - - 'not librenms__skip_yum_utils | default(false)' + - 'not setup_librenms__skip_yum_utils | d(false)' - role: 'linuxfabrik.lfops.repo_baseos' repo_baseos__crb_repo_enabled__dependent_var: '{{ @@ -46,21 +46,21 @@ - role: 'linuxfabrik.lfops.repo_epel' when: - 'ansible_facts["os_family"] == "RedHat" and ansible_facts["distribution"] != "Fedora"' - - 'not librenms__skip_repo_epel | default(false)' + - 'not setup_librenms__skip_repo_epel | d(false)' - role: 'linuxfabrik.lfops.repo_mydumper' when: - 'ansible_facts["os_family"] == "RedHat" and ansible_facts["distribution"] != "Fedora"' - - 'not librenms__skip_repo_mydumper | default(false)' + - 'not setup_librenms__skip_repo_mydumper | d(false)' - role: 'linuxfabrik.lfops.repo_mariadb' when: - - 'not librenms__skip_repo_mariadb | default(false)' + - 'not setup_librenms__skip_repo_mariadb | d(false)' - role: 'linuxfabrik.lfops.policycoreutils' when: - 'ansible_facts["os_family"] == "RedHat"' - - 'not setup_librenms__skip_policycoreutils | default(false)' + - 'not setup_librenms__skip_policycoreutils | d(false)' - role: 'linuxfabrik.lfops.selinux' selinux__booleans__dependent_var: '{{ @@ -75,7 +75,7 @@ }}' when: - 'ansible_facts["os_family"] == "RedHat"' - - 'not setup_librenms__skip_selinux | default(false)' + - 'not setup_librenms__skip_selinux | d(false)' - role: 'linuxfabrik.lfops.python' python__modules__dependent_var: '{{ @@ -84,12 +84,12 @@ mariadb_server__python__modules__dependent_var }}' when: - - 'not librenms__skip_python | default(false)' + - 'not setup_librenms__skip_python | d(false)' - role: 'linuxfabrik.lfops.kernel_settings' kernel_settings__sysctl__dependent_var: '{{ mariadb_server__kernel_settings__sysctl__dependent_var | d([]) }}' when: - - 'not librenms__skip_kernel_settings | d(false)' + - 'not setup_librenms__skip_kernel_settings | d(false)' - role: 'linuxfabrik.lfops.mariadb_server' mariadb_server__databases__dependent_var: '{{ @@ -99,12 +99,12 @@ librenms__mariadb_server__users__dependent_var }}' when: - - 'not librenms__skip_mariadb_server | default(false)' + - 'not setup_librenms__skip_mariadb_server | d(false)' - role: 'linuxfabrik.lfops.repo_remi' when: - 'ansible_facts["os_family"] == "RedHat" and ansible_facts["distribution"] != "Fedora"' - - 'not librenms__skip_repo_remi | default(false)' + - 'not setup_librenms__skip_repo_remi | d(false)' - role: 'linuxfabrik.lfops.php' php__modules__dependent_var: '{{ @@ -114,21 +114,14 @@ librenms__php__fpm_pools__dependent_var }}' when: - - 'not librenms__skip_php | default(false)' - - - role: 'linuxfabrik.lfops.apps' - apps__apps__dependent_var: '{{ - librenms__apps__apps__dependent_var - }}' - when: - - 'not librenms__skip_apps | default(false)' + - 'not setup_librenms__skip_php | d(false)' - role: 'linuxfabrik.lfops.librenms' - role: 'linuxfabrik.lfops.apache_httpd' apache_httpd__vhosts__dependent_var: '{{ librenms__apache_httpd__vhosts__dependent_var | d([]) }}' when: - - 'not librenms__skip_apache_httpd | default(false)' + - 'not setup_librenms__skip_apache_httpd | d(false)' post_tasks: diff --git a/roles/librenms/README.md b/roles/librenms/README.md index b86d0c49..f790a0d8 100644 --- a/roles/librenms/README.md +++ b/roles/librenms/README.md @@ -8,6 +8,8 @@ This role installs and configures [LibreNMS](https://www.librenms.org/). ## How the Role Behaves +The role installs LibreNMS from a git checkout on the target host, not on the Ansible controller, and checks out the latest upstream release on every run. The version is not pinned, so a run updates an existing installation to whatever upstream currently offers. Between runs LibreNMS keeps itself up to date as well: the cron jobs are the ones upstream ships, and their nightly `daily.sh` updates the code on its own. `librenms__config_update_channel` selects the channel it follows. + LibreNMS stores its time series in RRD files below `/opt/librenms/rrd`. By default the role puts [RRDCached](https://docs.librenms.org/Extensions/RRDCached/) in front of them, which collects the updates of a poll cycle in memory and writes them out every 30 minutes instead of on every update. This typically cuts the disk I/O of the poller by 30% to 40%. * The RRD files stay where they are and keep their format, so enabling or disabling RRDCached needs no data migration. Only who writes them changes. @@ -32,16 +34,36 @@ Any [LFOps playbook](https://github.com/Linuxfabrik/lfops/blob/main/playbooks/RE * On RHEL-compatible systems, the `http_fping` and `rrdcached_librenms` SELinux policy modules must be installed (have a look at `librenms__selinux__modules__dependent_var` in the `defaults/main.yml`) (role: [linuxfabrik.lfops.selinux](https://github.com/Linuxfabrik/lfops/tree/main/roles/selinux)). +## Requirements + +* Outbound HTTPS access from the target host to `github.com`, both for the release lookup and for the git checkout, and to `packagist.org` for the PHP dependencies Composer installs. The role does no downloading on the Ansible controller. + + +## Post-Installation Steps + +* The role prepares the database credentials in `/opt/librenms/.env`, but creates neither the database schema nor an account to log in with. Open `/install` and follow the web installer, which does both. An administrator can also be added on the host with `lnms user:add `. + + ## Tags `librenms` * Installs and configures LibreNMS. -* Triggers: none. +* Triggers: rrdcached.service restart. `librenms:configure` -* Configures LibreNMS. +* Deploys the LibreNMS configuration, the scheduled jobs and the logrotate configuration. +* Triggers: none. + +`librenms:cron` + +* Deploys the cron jobs and the units of the LibreNMS scheduler. +* Triggers: none. + +`librenms:logrotate` + +* Deploys the logrotate configuration. * Triggers: none. `librenms:rrdcached` @@ -49,6 +71,11 @@ Any [LFOps playbook](https://github.com/Linuxfabrik/lfops/blob/main/playbooks/RE * Installs and configures RRDCached, and manages its service. * Triggers: rrdcached.service restart. +`librenms:state` + +* Manages the state of the LibreNMS scheduler timer (start, stop, enable, disable). +* Triggers: none. + ## Mandatory Role Variables @@ -56,6 +83,17 @@ Any [LFOps playbook](https://github.com/Linuxfabrik/lfops/blob/main/playbooks/RE * The user account for accessing the MySQL database. * Type: Dictionary. +* Subkeys: + + * `username`: + + * Mandatory. The database user. + * Type: String. + + * `password`: + + * Mandatory. The password of the database user. + * Type: String. `librenms__fqdn` @@ -128,6 +166,7 @@ librenms__fqdn: 'librenms.example.com' * Whether LibreNMS reads and writes its RRD files through RRDCached. Set this to `false` to stop and disable RRDCached and have LibreNMS access the files directly. Have a look at "How the Role Behaves" above. * Type: Boolean. * Default: `true` +* Deviates from the upstream default `false`: LibreNMS writes every RRD file on every poll cycle without it, which is the bulk of the disk I/O of a poller and the first thing to hurt once a host monitors more than a handful of devices. `librenms__rrdcached_service_enabled` @@ -141,6 +180,18 @@ librenms__fqdn: 'librenms.example.com' * Type: String. One of `reloaded`, `restarted`, `started`, `stopped`. * Default: `'started'` if `librenms__rrdcached_service_enabled` is `true`, else `'stopped'` +`librenms__scheduler_service_enabled` + +* Enables or disables the timer of the LibreNMS scheduler, analogous to `systemctl enable/disable --now`. The scheduler runs the maintenance and alerting jobs of LibreNMS. +* Type: Bool. +* Default: `true` + +`librenms__scheduler_service_state` + +* Changes the state of the timer of the LibreNMS scheduler, analogous to `systemctl start/stop/restart/reload`. +* Type: String. One of `reloaded`, `restarted`, `started`, `stopped`. +* Default: `'started'` if `librenms__scheduler_service_enabled` is `true`, else `'stopped'` + Example: ```yaml # optional @@ -157,6 +208,8 @@ librenms__database_name: 'librenms' librenms__rrdcached_enabled: true librenms__rrdcached_service_enabled: true librenms__rrdcached_service_state: 'started' +librenms__scheduler_service_enabled: true +librenms__scheduler_service_state: 'started' ``` diff --git a/roles/librenms/defaults/main.yml b/roles/librenms/defaults/main.yml index 39bc5947..94fa31ad 100644 --- a/roles/librenms/defaults/main.yml +++ b/roles/librenms/defaults/main.yml @@ -6,9 +6,11 @@ librenms__config_session_secure_cookie: '{{ (librenms__config_app_url is match(" librenms__config_update_channel: 'release' librenms__database_host: 'localhost' librenms__database_name: 'librenms' -librenms__rrdcached_enabled: true +librenms__rrdcached_enabled: true # upstream default: false librenms__rrdcached_service_enabled: true librenms__rrdcached_service_state: '{{ librenms__rrdcached_service_enabled | bool | ternary("started", "stopped") }}' +librenms__scheduler_service_enabled: true +librenms__scheduler_service_state: '{{ librenms__scheduler_service_enabled | bool | ternary("started", "stopped") }}' # ----------------------------------------------------------------------------- @@ -56,11 +58,6 @@ librenms__apache_httpd__vhosts__dependent_var: AllowEncodedSlashes NoDecode -librenms__apps__apps__dependent_var: - - name: 'git' - state: 'present' - - librenms__mariadb_server__databases__dependent_var: - name: '{{ librenms__database_name }}' collation: 'utf8mb4_unicode_ci' diff --git a/roles/librenms/meta/argument_specs.yml b/roles/librenms/meta/argument_specs.yml index c06ad695..735c467e 100644 --- a/roles/librenms/meta/argument_specs.yml +++ b/roles/librenms/meta/argument_specs.yml @@ -95,3 +95,21 @@ argument_specs: - 'started' - 'stopped' description: 'Changes the state of the RRDCached service.' + + librenms__scheduler_service_enabled: + type: 'bool' + required: false + default: true + description: 'Enables or disables the LibreNMS scheduler timer.' + + # no default: defaults/main.yml derives it from librenms__scheduler_service_enabled with a + # Jinja2 expression, which argument_specs cannot evaluate + librenms__scheduler_service_state: + type: 'str' + required: false + choices: + - 'reloaded' + - 'restarted' + - 'started' + - 'stopped' + description: 'Changes the state of the LibreNMS scheduler timer.' diff --git a/roles/librenms/tasks/main.yml b/roles/librenms/tasks/main.yml index 19e1b0d6..91cd1ba7 100644 --- a/roles/librenms/tasks/main.yml +++ b/roles/librenms/tasks/main.yml @@ -1,11 +1,13 @@ - block: - - name: 'Install acl cronie fping mtr net-snmp net-snmp-utils nmap rrdtool unzip' + - name: 'Install acl cronie fping git mtr net-snmp net-snmp-utils nmap rrdtool unzip' ansible.builtin.package: name: - 'acl' - 'cronie' - 'fping' + # LibreNMS is installed from a git checkout and updates itself the same way + - 'git' - 'mtr' - 'net-snmp' - 'net-snmp-utils' @@ -17,11 +19,12 @@ - name: 'Get latest release version' ansible.builtin.uri: url: 'https://api.github.com/repos/librenms/librenms/releases/latest' - register: 'librenms__github_release' + register: '__librenms__github_release' check_mode: false # run task even if `--check` is specified run_once: true - - ansible.builtin.import_tasks: 'user.yml' + - name: 'Create the librenms user and group' + ansible.builtin.import_tasks: 'user.yml' - name: 'mkdir -p /opt/librenms' ansible.builtin.file: @@ -39,7 +42,7 @@ repo: 'https://github.com/librenms/librenms.git' dest: '/opt/librenms' force: false - version: '{{ librenms__github_release["json"]["tag_name"] }}' + version: '{{ __librenms__github_release["json"]["tag_name"] }}' become: true become_user: 'librenms' @@ -89,8 +92,10 @@ # changing what the targeted policy enforces, namely the type. - name: 'restorecon -Rv /opt/librenms' ansible.builtin.command: 'restorecon -Rv /opt/librenms' - register: 'librenms__restorecon_data_result' - changed_when: 'librenms__restorecon_data_result["stdout"] | length > 0' + register: '__librenms__restorecon_result' + changed_when: '__librenms__restorecon_result["stdout"] | length > 0' + when: + - 'ansible_facts["selinux"]["status"] != "disabled"' # The SELinux policy modules this role needs (http_fping, rrdcached_librenms) are declared in # librenms__selinux__modules__dependent_var and deployed by the selinux role, like its booleans @@ -190,6 +195,15 @@ mode: 0o644 remote_src: true + tags: + - 'librenms' + - 'librenms:configure' + - 'librenms:logrotate' + + +- block: + + # Keeps the daily self-update of LibreNMS running, among the poller and discovery jobs. - name: 'cp /opt/librenms/dist/librenms.cron /etc/cron.d/librenms' ansible.builtin.copy: src: '/opt/librenms/dist/librenms.cron' @@ -199,29 +213,51 @@ mode: 0o644 remote_src: true - - name: 'cp /opt/librenms/dist/librenms-scheduler.service /etc/systemd/system/' + - name: 'cp /opt/librenms/dist/{{ item }} /etc/systemd/system/' ansible.builtin.copy: - src: '/opt/librenms/dist/librenms-scheduler.service' - dest: '/etc/systemd/system/librenms-scheduler.service' + src: '/opt/librenms/dist/{{ item }}' + dest: '/etc/systemd/system/{{ item }}' owner: 'root' group: 'root' mode: 0o644 remote_src: true + loop: + - 'librenms-scheduler.service' + - 'librenms-scheduler.timer' + register: '__librenms__scheduler_units_result' - - name: 'cp /opt/librenms/dist/librenms-scheduler.timer /etc/systemd/system/' - ansible.builtin.copy: - src: '/opt/librenms/dist/librenms-scheduler.timer' - dest: '/etc/systemd/system/librenms-scheduler.timer' - owner: 'root' - group: 'root' - mode: 0o644 - remote_src: true + # The units come from the git checkout, so an update of LibreNMS can change them. Without the + # reload systemd keeps running the version it read before. + - name: 'systemctl daemon-reload' # noqa no-handler would require flush_handlers here anyway + ansible.builtin.systemd: + daemon_reload: true + when: '__librenms__scheduler_units_result is changed' - - name: 'systemctl enable --now librenms-scheduler.timer' + tags: + - 'librenms' + - 'librenms:configure' + - 'librenms:cron' + + +- block: + + # split enabled and state into separate tasks, consistent with the rrdcached service above. + - name: 'systemctl {{ librenms__scheduler_service_enabled | bool | ternary("enable", "disable") }} librenms-scheduler.timer' + ansible.builtin.service: + name: 'librenms-scheduler.timer' + enabled: '{{ librenms__scheduler_service_enabled | bool }}' + + - name: 'systemctl {{ librenms__scheduler_service_state | regex_replace("p?ed$", "") }} librenms-scheduler.timer' ansible.builtin.service: name: 'librenms-scheduler.timer' - enabled: true - state: 'started' + state: '{{ librenms__scheduler_service_state }}' + + tags: + - 'librenms' + - 'librenms:state' + + +- block: # LibreNMS assumes rrdtool 1.4 unless told otherwise, and then creates and # tunes RRD files itself instead of letting rrdcached do it. @@ -248,10 +284,14 @@ group: 'librenms' mode: 0o644 + # lineinfile rather than a template, which is what LFOps otherwise uses for config files: + # /opt/librenms/.env is generated by the composer install and carries values this role does not + # know, APP_KEY among them, so rendering the whole file would throw them away. The regexps are + # anchored on the "=" so a key never matches a longer one that starts with the same characters. - name: 'Set DB connection settings in /opt/librenms/.env' ansible.builtin.lineinfile: path: '/opt/librenms/.env' - regexp: '^#?{{ item["key"] }}' + regexp: '^#?{{ item["key"] }}=' line: '{{ item["key"] }}={{ item["value"] | quote }}' mode: 0o600 # file contains secrets state: 'present' @@ -272,7 +312,7 @@ - name: 'Set app settings in /opt/librenms/.env' ansible.builtin.lineinfile: path: '/opt/librenms/.env' - regexp: '^#?{{ item["key"] }}' + regexp: '^#?{{ item["key"] }}=' line: '{{ item["key"] }}={{ item["value"] | quote }}' mode: 0o600 # file contains secrets state: 'present' @@ -289,7 +329,7 @@ - name: 'Set the base URL in /opt/librenms/.env' ansible.builtin.lineinfile: path: '/opt/librenms/.env' - regexp: '^#?APP_URL' + regexp: '^#?APP_URL=' line: 'APP_URL={{ librenms__config_app_url | quote }}' mode: 0o600 # file contains secrets state: 'present' diff --git a/roles/librenms/templates/opt/librenms/config.php.j2 b/roles/librenms/templates/opt/librenms/config.php.j2 index 16a61503..c75fd339 100644 --- a/roles/librenms/templates/opt/librenms/config.php.j2 +++ b/roles/librenms/templates/opt/librenms/config.php.j2 @@ -27,7 +27,7 @@ $config['rrdtool_version'] = "{{ __librenms__rrdtool_version }}"; #$config['snmp']['community'] = array('public'); ### Authentication Model -$config['auth_mechanism'] = "{{ librenms__config_auth_mechanism | d('mysql') }}"; # options: mysql, ldap, http-auth +$config['auth_mechanism'] = "{{ librenms__config_auth_mechanism }}"; # options: mysql, ldap, http-auth #$config['http_auth_guest'] = "guest"; # remember to configure this user if you use http-auth ### List of RFC1918 networks to allow scanning-based discovery @@ -39,7 +39,7 @@ $config['auth_mechanism'] = "{{ librenms__config_auth_mechanism | d('mysql') }}" #$config['update'] = 0; # Number in days of how long to keep old rrd files. 0 disables this feature -$config['rrd_purge'] = {{ librenms__config_rrd_purge | d(0) }}; +$config['rrd_purge'] = {{ librenms__config_rrd_purge }}; # Uncomment to submit callback stats via proxy #$config['callback_proxy'] = "hostname:port"; @@ -53,4 +53,4 @@ $config['enable_billing'] = 1; # Enable the in-built services support (Nagios plugins) $config['show_services'] = 1; -$config["update_channel"] = "{{ librenms__config_update_channel | d('master') }}"; +$config["update_channel"] = "{{ librenms__config_update_channel }}"; From 2f03c89ce228bd5f20521cb1663a77585ff08df8 Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Thu, 3 Sep 2026 14:15:57 +0200 Subject: [PATCH 17/19] fix(roles/librenms): keep the Ansible tmp dir out of /opt/librenms The clone runs as librenms, whose home is /opt/librenms, and Ansible creates the become user's remote_tmp before it runs the module. git clone therefore found ~/.ansible in the directory it was about to fill and aborted with "already exists and is not an empty directory" on every fresh host, which the Molecule scenario reproduced on Rocky 8, 9 and 10. Pointing remote_tmp outside the home fixes it. The composer task and the two verify tasks that become librenms get the same treatment, which also keeps ~/.ansible out of the git working tree that the chown and the restorecon walk. No CHANGELOG entry: cloning as librenms arrived earlier in this same branch, so no release ever carried the broken fresh install. --- extensions/molecule/setup_librenms/verify.yml | 8 ++++++++ roles/librenms/tasks/main.yml | 9 +++++++++ 2 files changed, 17 insertions(+) diff --git a/extensions/molecule/setup_librenms/verify.yml b/extensions/molecule/setup_librenms/verify.yml index 75a8e32a..5a357449 100644 --- a/extensions/molecule/setup_librenms/verify.yml +++ b/extensions/molecule/setup_librenms/verify.yml @@ -91,6 +91,10 @@ chdir: '/opt/librenms/rrd' become: true become_user: 'librenms' + vars: + # same reason as in the role: the home of librenms is /opt/librenms, and the default + # remote_tmp would leave a stray ~/.ansible in the checkout under test + ansible_remote_tmp: '/tmp/.ansible-librenms' changed_when: false # test data, removed again below - name: 'stat /opt/librenms/rrd/molecule-rrdcached.rrd' @@ -257,6 +261,10 @@ ansible.builtin.command: 'python3 -c "import dotenv, psutil, pymysql"' become: true become_user: 'librenms' + vars: + # same reason as in the role: the home of librenms is /opt/librenms, and the default + # remote_tmp would leave a stray ~/.ansible in the checkout under test + ansible_remote_tmp: '/tmp/.ansible-librenms' register: '__molecule__python_modules_result' changed_when: false failed_when: false diff --git a/roles/librenms/tasks/main.yml b/roles/librenms/tasks/main.yml index 91cd1ba7..ffbf8118 100644 --- a/roles/librenms/tasks/main.yml +++ b/roles/librenms/tasks/main.yml @@ -45,6 +45,12 @@ version: '{{ __librenms__github_release["json"]["tag_name"] }}' become: true become_user: 'librenms' + vars: + # Ansible creates the become user's remote_tmp before it runs the module, and the home of + # librenms is /opt/librenms itself. Left at its default the clone would find ~/.ansible in + # the directory it is about to fill and abort with "already exists and is not an empty + # directory". Verified against ansible-core 2.16 on Rocky 8, 9 and 10. + ansible_remote_tmp: '/tmp/.ansible-librenms' # this is magnitudes faster than using the file module. --changes reports every file it # touched, which keeps the task honest about whether it changed anything (installations that @@ -83,6 +89,9 @@ chdir: '/opt/librenms' become: true become_user: 'librenms' + vars: + # keeps ~/.ansible out of the git working tree below /opt/librenms, see the clone above + ansible_remote_tmp: '/tmp/.ansible-librenms' register: '__librenms__composer_result' # composer says so whenever the lock file is already satisfied changed_when: '"Nothing to install, update or remove" not in __librenms__composer_result["stdout"]' From 96860daf025b5f3fa22880db83a7700b6e150fb9 Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Thu, 3 Sep 2026 14:24:10 +0200 Subject: [PATCH 18/19] docs(CHANGELOG): drop the superseded group-writable entry The fuller wording replaced this line in "chore(roles/librenms): align packages and SELinux settings with upstream". Rebasing the branch onto main resurrected the older one next to it. --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c47ef78..3bda4355 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,7 +61,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * **role:librenms**: ImageMagick is no longer installed, LibreNMS does not use it. * **role:librenms**: The `http_fping` SELinux policy module is deployed by the `selinux` role, so a playbook run that skips that role no longer installs it. * **role:librenms**: The role no longer makes the RRD, log, cache and storage directories group-writable, LibreNMS grants the access it needs itself while installing. -* **role:librenms**: The RRD, log, cache and storage directories are no longer made group-writable. * **role:apache_httpd**: HTTP/2 is enabled and is the preferred protocol on every connection that terminates TLS, while a client that does not offer it is still served HTTP/1.1 and cleartext HTTP/2 (h2c) is not offered; on RedHat the `mod_http2` package is installed for this. * **role:keycloak**: `keycloak__https_cipher_suites`, `keycloak__https_protocols`, `keycloak__log` and `keycloak__proxy_trusted_addresses` are YAML lists instead of comma-separated strings. A comma-separated value already in an inventory keeps working, Ansible splits it into the same list. * **role:keycloak**: The Keycloak tarball is downloaded on the Ansible controller and copied to the target from there, so a target without internet access can be installed. The controller has to reach `github.com`. From 33bae7ebf196532060f444e8625104cec46b9e3a Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Thu, 3 Sep 2026 14:45:58 +0200 Subject: [PATCH 19/19] docs(roles/librenms): document how to verify the trusted proxies The setting is only observable in what LibreNMS records as the client address, and the Auth History page shows exactly that value: authlog.address is Request::ip(), which is what the TrustProxies middleware decides. Also notes the negative test from an unlisted host, and that an unset APP_TRUSTED_PROXIES falls back to trusting 127.0.0.1 while an empty one trusts nothing, which the deployed .env line alone does not tell apart. --- roles/librenms/README.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/roles/librenms/README.md b/roles/librenms/README.md index f790a0d8..52b9e708 100644 --- a/roles/librenms/README.md +++ b/roles/librenms/README.md @@ -114,7 +114,7 @@ librenms__fqdn: 'librenms.example.com' `librenms__config_app_trusted_proxies` -* A list of trusted reverse proxy IPs or CIDR ranges, joined into the comma separated `APP_TRUSTED_PROXIES` setting in `/opt/librenms/.env`, and written on every run. Have a look at https://docs.librenms.org/Support/Environment-Variables/. The empty default trusts no proxy at all, so LibreNMS ignores the `X-Forwarded-*` headers of any host: list your proxy here if one sits in front of LibreNMS, otherwise client addresses and the detected protocol are those of the proxy. +* A list of trusted reverse proxy IPs or CIDR ranges, joined into the comma separated `APP_TRUSTED_PROXIES` setting in `/opt/librenms/.env`, and written on every run. Have a look at https://docs.librenms.org/Support/Environment-Variables/. The empty default trusts no proxy at all, so LibreNMS ignores the `X-Forwarded-*` headers of any host: list your proxy here if one sits in front of LibreNMS, otherwise client addresses and the detected protocol are those of the proxy. Have a look at "Troubleshooting" below for how to check what a running instance makes of the setting. * Type: List. * Default: `[]` * Deviates from the upstream default `127.0.0.1`: a proxy on the LibreNMS host itself is not the common case in LFOps, and a host that trusts one accepts spoofed `X-Forwarded-For` headers from anything able to reach it locally. @@ -213,6 +213,15 @@ librenms__scheduler_service_state: 'started' ``` +## Troubleshooting + +**Logs and access control show the address of the reverse proxy instead of the client's** + +* LibreNMS honours the `X-Forwarded-*` headers only from an address listed in `librenms__config_app_trusted_proxies`, and the empty default trusts none. To check what a running instance does, log in through the proxy and open "Auth History" under Settings in the web interface: the "IP Address" column of the top row is the address LibreNMS derived from the request, so it has to show the client and not the proxy. Reading that page requires the admin or the global-read role. +* To confirm that a host which is not listed cannot forge an address, repeat the login from such a host with an `X-Forwarded-For` header of its own. The row in "Auth History" has to carry that host's own address; if it carries the forged one, the host is trusted. +* `sudo -u librenms lnms config:show trustedproxy` prints the list as the application resolved it. This tells an unset `APP_TRUSTED_PROXIES`, where LibreNMS falls back to trusting `127.0.0.1`, apart from one that is deliberately empty. + + ## License [The Unlicense](https://unlicense.org/)