diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ac0a9b20..28cf5bca4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,11 @@ 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 +* **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. @@ -28,6 +29,11 @@ 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. * **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. @@ -46,6 +52,15 @@ 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. +* **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:apache_httpd**: Restarting Apache no longer takes 90 seconds on a host that holds long-lived connections, such as a proxy for WebSockets. The server now waits 3 seconds for running requests to finish instead of waiting for the last connection to close by itself and being killed by systemd. Apache stops accepting new connections for the whole of that wait, so this shortens the outage rather than extending it. Hosts with legitimately long-running requests raise `apache_httpd__conf_graceful_shutdown_timeout`. * **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. @@ -61,6 +76,11 @@ 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. +* **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/COMPATIBILITY.md b/COMPATIBILITY.md index e87a4e6e4..958cddb52 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/CONTRIBUTING.md b/CONTRIBUTING.md index 3b7fba1b8..637f90474 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. diff --git a/extensions/molecule/setup_librenms/converge.yml b/extensions/molecule/setup_librenms/converge.yml new file mode 100644 index 000000000..260c0754a --- /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 000000000..390ffd4a0 --- /dev/null +++ b/extensions/molecule/setup_librenms/inventory/group_vars/systems_under_test.yml @@ -0,0 +1,18 @@ +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' +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 000000000..84fcf41e7 --- /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 000000000..1e47cbff8 --- /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 000000000..5a3574494 --- /dev/null +++ b/extensions/molecule/setup_librenms/verify.yml @@ -0,0 +1,343 @@ +# 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' + 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' + 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: | + &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 +# 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' + 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 + + - 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("") }}' + + # 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.' + + # 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 both settings reached the LibreNMS environment' + ansible.builtin.assert: + that: + - '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' + 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' + + # 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 a7dcfd86f..a61be0a5c 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 6dea1a3d7..37a5c556b 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: @@ -22,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: '{{ @@ -36,48 +46,50 @@ - 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: '{{ librenms__selinux__booleans__dependent_var }}' - selinux__fcontexcts__dependent_var: '{{ + selinux__fcontexts__dependent_var: '{{ librenms__selinux__fcontexts__dependent_var }}' selinux__modules__dependent_var: '{{ + librenms__selinux__modules__dependent_var + php__selinux__modules__dependent_var }}' 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: '{{ apache_httpd__python__modules__dependent_var + + librenms__python__modules__dependent_var + 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: '{{ @@ -87,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: '{{ @@ -102,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 ce13c2643..52b9e7089 100644 --- a/roles/librenms/README.md +++ b/roles/librenms/README.md @@ -6,6 +6,20 @@ This role installs and configures [LibreNMS](https://www.librenms.org/). *Available since LFOps `2.0.0`.* +## 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. +* 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. +* 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 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. @@ -13,9 +27,21 @@ 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)). -* 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)). +* 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 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)). + + +## 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 @@ -23,11 +49,31 @@ Any [LFOps playbook](https://github.com/Linuxfabrik/lfops/blob/main/playbooks/RE `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` + +* 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. @@ -37,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` @@ -57,9 +114,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. 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. `librenms__config_app_url` @@ -79,6 +137,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`. @@ -97,6 +161,37 @@ 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 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` + +* 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'` + +`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 @@ -106,12 +201,27 @@ 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' +librenms__rrdcached_enabled: true +librenms__rrdcached_service_enabled: true +librenms__rrdcached_service_state: 'started' +librenms__scheduler_service_enabled: true +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/) diff --git a/roles/librenms/defaults/main.yml b/roles/librenms/defaults/main.yml index 67fd39dca..94fa31ad3 100644 --- a/roles/librenms/defaults/main.yml +++ b/roles/librenms/defaults/main.yml @@ -1,10 +1,16 @@ -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 +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' +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") }}' # ----------------------------------------------------------------------------- @@ -52,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' @@ -85,6 +86,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' @@ -102,11 +107,34 @@ librenms__php__modules__dependent_var: - name: 'php-zip' state: 'present' -librenms__selinux__booleans__dependent_var: - - key: 'httpd_can_connect_ldap' - value: 'on' - - key: 'httpd_setrlimit' - value: 'on' + +# 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' + + +# 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(/.*)?' @@ -115,9 +143,69 @@ 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' - setype: 'httpd_sys_rw_content_t' target: '/opt/librenms/.env' state: 'present' + +librenms__selinux__modules__dependent_var: + # 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 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: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 + # (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; + # 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/handlers/main.yml b/roles/librenms/handlers/main.yml new file mode 100644 index 000000000..33ba4db3d --- /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/meta/argument_specs.yml b/roles/librenms/meta/argument_specs.yml new file mode 100644 index 000000000..735c467e9 --- /dev/null +++ b/roles/librenms/meta/argument_specs.yml @@ -0,0 +1,115 @@ +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.' + + # 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 + 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.' + + 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 5aa50c1ee..ffbf81188 100644 --- a/roles/librenms/tasks/main.yml +++ b/roles/librenms/tasks/main.yml @@ -1,11 +1,13 @@ - block: - - name: 'Install cronie fping ImageMagick 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' - - 'ImageMagick' + # LibreNMS is installed from a git checkout and updates itself the same way + - 'git' - 'mtr' - 'net-snmp' - 'net-snmp-utils' @@ -17,45 +19,54 @@ - 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 - - 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"] }}' - - - 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' - - # 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: 'Create the librenms user and group' + ansible.builtin.import_tasks: 'user.yml' - - name: 'chmod 771 /opt/librenms' + - name: 'mkdir -p /opt/librenms' ansible.builtin.file: path: '/opt/librenms' + state: 'directory' + owner: 'librenms' + group: 'librenms' mode: 0o771 - # 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' + # 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' + 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 + # 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' + + # 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 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: @@ -78,108 +89,108 @@ 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"]' + + # 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_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 + # and file contexts. - - name: 'restorecon -RFv /opt/librenms' - ansible.builtin.command: 'restorecon -RFv /opt/librenms' - register: 'librenms__restorecon_data_result' - changed_when: 'librenms__restorecon_data_result["stdout"] | length > 0' + tags: + - 'librenms' - - 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' - - # 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 }}' - # +- 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: + + # 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: @@ -193,6 +204,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' @@ -202,29 +222,67 @@ 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' + + tags: + - 'librenms' + - 'librenms:configure' + - 'librenms:cron' + + +- block: - - name: 'systemctl enable --now librenms-scheduler.timer' + # 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: true - state: 'started' + 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' + 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. + - 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: @@ -235,10 +293,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' @@ -252,19 +314,36 @@ - key: 'DB_PASSWORD' value: '{{ librenms__database_login["password"] }}' + # 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' - regexp: '^#?{{ item["key"] }}' + regexp: '^#?{{ item["key"] }}=' line: '{{ item["key"] }}={{ item["value"] | quote }}' mode: 0o600 # file contains secrets state: 'present' 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' + # 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. + - 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' diff --git a/roles/librenms/tasks/user.yml b/roles/librenms/tasks/user.yml new file mode 100644 index 000000000..d16693ca6 --- /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' 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 fb8a56f83..000000000 --- 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 000000000..b91538b5f --- /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 a190bbee0..c75fd3397 100644 --- a/roles/librenms/templates/opt/librenms/config.php.j2 +++ b/roles/librenms/templates/opt/librenms/config.php.j2 @@ -1,6 +1,6 @@