From 301077cffde386ad9b091268ff9fc040900b61bb Mon Sep 17 00:00:00 2001 From: VectoDE Date: Tue, 13 Oct 2026 20:43:53 +0200 Subject: [PATCH 01/92] Stage 2 foundation: service sandbox, web layer, docker-mode requirement - svc_sandbox UNIT [DIRECTIVE...] writes a systemd drop-in that confines a service (read-only system, private /tmp and /dev, no new privileges, no kernel tunables, modules or logs); services add the paths they write or relax what they cannot live with. - lib/web.sh holds the conventions the web, runtime and database modules share: the web group, the PHP-FPM socket, the TLS and ACME directories and the admin port, plus web_server and web_group_ensure. - A module that runs in docker mode requires docker or podman. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- lib/module.sh | 9 +++-- lib/service.sh | 21 +++++++++++ lib/step.sh | 2 +- lib/web.sh | 51 +++++++++++++++++++++++++++ lss | 2 +- tests/fixtures/modules/test/docker.sh | 10 ++++++ tests/unit/helpers.bash | 2 +- tests/unit/module.bats | 9 +++++ 8 files changed, 101 insertions(+), 5 deletions(-) create mode 100644 lib/web.sh create mode 100644 tests/fixtures/modules/test/docker.sh diff --git a/lib/module.sh b/lib/module.sh index b0eec96..a2b9cfc 100644 --- a/lib/module.sh +++ b/lib/module.sh @@ -6,7 +6,7 @@ # subshell, so its variables and functions never leak into the core or into another module. # # MOD_REQUIRES entries are module names; "a|b|c" means one of them (the first one is chosen if none is installed or -# requested). +# requested). A module that runs in docker mode also requires a container engine (docker or podman). LSS_MODULE_DIRS=${LSS_MODULE_DIRS:-} @@ -96,7 +96,12 @@ module_resolve() { [[ -z ${visiting[${module}]+x} ]] || die "Dependency cycle involving '${module}'." module_exists "${module}" || die "Unknown module '${module}'. See 'lss list'." visiting[${module}]=1 - for requirement in $(module_meta "${module}" MOD_REQUIRES); do + local requirements + requirements=$(module_meta "${module}" MOD_REQUIRES) + if [[ $(module_mode "${module}") == docker ]]; then + requirements+=" docker|podman" + fi + for requirement in ${requirements}; do picked=$(_module_pick "${requirement}" "${requested}") requested+="${picked} " _visit "${picked}" diff --git a/lib/service.sh b/lib/service.sh index bc38714..6748348 100644 --- a/lib/service.sh +++ b/lib/service.sh @@ -121,6 +121,27 @@ svc_scanner_sandbox() { return } +# svc_sandbox UNIT [DIRECTIVE...]: confines a service with a drop-in (lss-sandbox.conf): read-only system, no home +# directories, a private /tmp and /dev, no kernel tunables, modules or logs, no new privileges. DIRECTIVEs follow the +# baseline, so a service adds the paths it writes (ReadWritePaths=/var/lib/NAME) or relaxes a setting it cannot live +# with (PrivateDevices=no); for single-value settings the last assignment wins. +svc_sandbox() { + local unit=$1 directive + shift + { + printf '# Managed by lss (module %s). Sandbox of %s.\n[Service]\n' "${LSS_MODULE:-lss}" "${unit}" + printf '%s\n' NoNewPrivileges=yes ProtectSystem=strict ProtectHome=yes PrivateTmp=yes PrivateDevices=yes \ + ProtectKernelTunables=yes ProtectKernelModules=yes ProtectKernelLogs=yes ProtectControlGroups=yes \ + ProtectClock=yes ProtectHostname=yes RestrictSUIDSGID=yes RestrictRealtime=yes RestrictNamespaces=yes \ + LockPersonality=yes SystemCallArchitectures=native \ + 'RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK' ReadWritePaths=-/var/log ReadWritePaths=-/run + for directive in "$@"; do + printf '%s\n' "${directive}" + done + } | svc_dropin "${unit}" lss-sandbox + return +} + # svc_unit UNIT: writes /etc/systemd/system/UNIT (a unit of lss's own) from stdin and reloads systemd when it # changed. svc_unit() { diff --git a/lib/step.sh b/lib/step.sh index fba0fc8..1a245c5 100755 --- a/lib/step.sh +++ b/lib/step.sh @@ -13,7 +13,7 @@ set -Eeuo pipefail shopt -s inherit_errexit -for library in log os config files template secrets state pkg repo fetch service firewall module; do +for library in log os config files template secrets state pkg repo fetch service firewall web module; do # shellcheck source=/dev/null source "${LSS_ROOT}/lib/${library}.sh" done diff --git a/lib/web.sh b/lib/web.sh new file mode 100644 index 0000000..b523fa7 --- /dev/null +++ b/lib/web.sh @@ -0,0 +1,51 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# Web layer shared by the web server modules (nginx, apache, caddy) and everything that publishes something through +# them (tls, vhost, php, phpmyadmin, adminer, pgadmin, apps). One web server owns ports 80 and 443; the others +# conflict with it. +# +# Conventions: +# WEB_GROUP group of every web server user; PHP-FPM's socket and the TLS keys are readable by it +# WEB_PHP_SOCKET the PHP-FPM pool of the php module listens here (owner root, group WEB_GROUP, mode 0660) +# WEB_TLS_DIR one directory per certificate (NAME/fullchain.pem, NAME/privkey.pem); "default" is a +# self-signed certificate for the host name that every web server creates +# WEB_ACME_ROOT webroot for ACME http-01 challenges, served on port 80 for every host name +# WEB_ADMIN_PORT admin interfaces (phpMyAdmin, Adminer, pgAdmin) live on this port, on 127.0.0.1 by default + +WEB_GROUP=lss-web +WEB_PHP_SOCKET=/run/lss-php-fpm.sock +WEB_TLS_DIR=/etc/lss/tls +WEB_ACME_ROOT=/var/lib/lss/acme +WEB_DEFAULT_ROOT=/var/www/lss-default +WEB_ADMIN_PORT=8090 + +# The installed web server (nginx, apache or caddy), or nothing. +web_server() { + local server + for server in nginx apache caddy; do + if state_installed "${server}"; then + printf '%s' "${server}" + return 0 + fi + done + return 0 +} + +# web_group_ensure [USER...]: creates WEB_GROUP and adds the users to it. +web_group_ensure() { + local user + if ! getent group "${WEB_GROUP}" >/dev/null; then + groupadd --system "${WEB_GROUP}" + files_note_change "created group ${WEB_GROUP}" + fi + for user in "$@"; do + getent passwd "${user}" >/dev/null || continue + if ! id -nG "${user}" | tr ' ' '\n' | grep -qxF "${WEB_GROUP}"; then + usermod --append --groups "${WEB_GROUP}" "${user}" + files_note_change "added ${user} to ${WEB_GROUP}" + fi + done + return +} diff --git a/lss b/lss index 7e9fd1b..e6957b4 100755 --- a/lss +++ b/lss @@ -12,7 +12,7 @@ LSS_ROOT=$(cd -- "$(dirname -- "$(readlink -f -- "${BASH_SOURCE[0]}")")" && pwd) LSS_VERSION=$(<"${LSS_ROOT}/VERSION") export LSS_ROOT LSS_VERSION -for library in log os config files template secrets state pkg repo fetch service firewall module ui; do +for library in log os config files template secrets state pkg repo fetch service firewall web module ui; do # shellcheck source=/dev/null source "${LSS_ROOT}/lib/${library}.sh" done diff --git a/tests/fixtures/modules/test/docker.sh b/tests/fixtures/modules/test/docker.sh new file mode 100644 index 0000000..9384b40 --- /dev/null +++ b/tests/fixtures/modules/test/docker.sh @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +MOD_NAME=docker +MOD_GROUP="test" +MOD_DESCRIPTION="Container engine for modules in docker mode" +mod_verify() { + return 0 +} diff --git a/tests/unit/helpers.bash b/tests/unit/helpers.bash index fc63d35..9d85fa5 100644 --- a/tests/unit/helpers.bash +++ b/tests/unit/helpers.bash @@ -22,7 +22,7 @@ lss_test_setup() { export OWNER : >"${STUB_LOG}" local library - for library in log os config files template secrets state pkg repo fetch service firewall module ui; do + for library in log os config files template secrets state pkg repo fetch service firewall web module ui; do # shellcheck source=/dev/null source "${REPO_ROOT}/lib/${library}.sh" done diff --git a/tests/unit/module.bats b/tests/unit/module.bats index c888391..4431be1 100644 --- a/tests/unit/module.bats +++ b/tests/unit/module.bats @@ -126,6 +126,15 @@ setup() { [ "$(module_mode unverified)" = native ] } +@test "a module in docker mode requires a container engine first" { + config_set LSS_MODE docker + run module_resolve unverified + [ "${status}" -eq 0 ] + [ "${output}" = $'docker\nunverified' ] + run module_resolve alpha + [[ ${output} != *docker* ]] +} + @test "metadata, the plain list and the default module directory" { [ "$(module_meta alpha MOD_DESCRIPTION)" = 'Writes one file' ] run module_meta nope MOD_DESCRIPTION From 146252b06aac98d244089cad8f596ca6a9092f8f Mon Sep 17 00:00:00 2001 From: VectoDE Date: Tue, 13 Oct 2026 21:01:00 +0200 Subject: [PATCH 02/92] Add the nginx, apache and caddy modules and certificate helpers The three web servers share the web layer: HTTPS only with TLS 1.2/1.3, security headers, a closed default host that answers ACME challenges only, an admin port on 127.0.0.1 (or public with TLS and basic auth), a systemd sandbox and the fail2ban or CrowdSec integration. Caddy comes from its signed upstream repositories with pinned key fingerprints. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- data/keys/caddy.asc | 128 ++++++++ data/repos/caddy.conf | 17 + lib/web.sh | 719 +++++++++++++++++++++++++++++++++++++++++- modules/web/apache.sh | 256 +++++++++++++++ modules/web/caddy.sh | 175 ++++++++++ modules/web/nginx.sh | 175 ++++++++++ 6 files changed, 1466 insertions(+), 4 deletions(-) create mode 100644 data/keys/caddy.asc create mode 100644 data/repos/caddy.conf create mode 100644 modules/web/apache.sh create mode 100644 modules/web/caddy.sh create mode 100644 modules/web/nginx.sh diff --git a/data/keys/caddy.asc b/data/keys/caddy.asc new file mode 100644 index 0000000..dd43af1 --- /dev/null +++ b/data/keys/caddy.asc @@ -0,0 +1,128 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +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 +=wl92 +-----END PGP PUBLIC KEY BLOCK----- +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQENBF2dqh8BCADGeRFQBu8pTbqLfB5mstKTf/TUHC0E524hduhBRjHSOh/NpP/H +njWc7xddXnN1/OfhMOSfaDQGkI9UdlnbUvWhC6oTsBm5gGUBOGDw6n4fWLNRwlU7 +jwCltTY+HGIi8oD6R6ELkTi+FfxbFuS2iLLqmTGNSYEPTadsZT6FLYguazbThnpW +wjOfAn1hgGTOQM8OfyliBPo8d+OkA32AxQARsAoPUmHN3PTOLlkB6FMC4O69nwCx +9pJfu+Oh4RRE5mrHoUQuDLQFVRDne7IoC9zaLEtr1UcUw9TgPhFZ5KO1/du/zGTX +zGWCwzszKY+iQAePJ5nxm5K3p6jx38xU2JYxABEBAAG0OEBjYWRkeV9jYWRkeSAo +Tm9uZSkgPEBjYWRkeSNjYWRkeUBjb3ByLmZlZG9yYWhvc3RlZC5vcmc+iQFUBBMB +CAA+AhsvBQsJCAcCBhUKCQgLAgQWAgMBAh4BAheAFiEESnb5L/bUBED4/E82xSGR +sdYFFH4FAmUje7cFCRDr0xgACgkQxSGRsdYFFH7zqwgAt8Q/n0JKHR/f/Hxgi9iI +zu5IDs7VCqCCOiWdDcSkMdqFDAIfaq9hB82KDGwm0ZEiylsVLoQjWkH5ol5BoSit +MOBFX84lMBn9Sw6WhtvWqlVKiVwZ2Y+1UCCLredhPrWb9ykHrp2qwHrjuv5q+eUF +2vIos1JrJ6UEMtz99kpw+z71CUJNr8sLSG9WZmHkTXVrM9TwbtC/RZYmLOOSIguT +C/2hfuW4sEawo3kQUOqTqbffusFk8s4V+zjp3RWpoY+LVxzlKOD3lxSx/dBjzUcq +7oKXy9K4rPVHuG/ygVE+Mye+JKyA9MuyZtLPRgbDsgr7bR8fHvqML2Z77xPcKqT1 +Ow== +=O51i +-----END PGP PUBLIC KEY BLOCK----- diff --git a/data/repos/caddy.conf b/data/repos/caddy.conf new file mode 100644 index 0000000..d4717c5 --- /dev/null +++ b/data/repos/caddy.conf @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# Caddy from its official repositories: Cloudsmith for Debian and Ubuntu (one "any-version" suite for every release, +# key https://dl.cloudsmith.io/public/caddy/stable/gpg.key, matches keyserver.ubuntu.com) and the @caddy COPR for the +# RHEL family (epel-9 and epel-10 builds for x86_64 and aarch64, key +# https://download.copr.fedorainfracloud.org/results/@caddy/caddy/pubkey.gpg). Both fetched on 2026-10-13. COPR signs +# the packages, not the metadata. +KEY=caddy.asc +FINGERPRINTS=65760C51EDEA2017CEA2CA15155B6D79CA56EA34 4A76F92FF6D40440F8FC4F36C52191B1D605147E +DEB_URL=https://dl.cloudsmith.io/public/caddy/stable/deb/debian +DEB_SUITE=any-version +DEB_COMPONENTS=main +RPM_BASEURL=https://download.copr.fedorainfracloud.org/results/@caddy/caddy/epel-{{major}}-$basearch/ +RPM_GPGCHECK=1 +RPM_REPO_GPGCHECK=0 diff --git a/lib/web.sh b/lib/web.sh index b523fa7..d4fb28b 100644 --- a/lib/web.sh +++ b/lib/web.sh @@ -8,18 +8,28 @@ # # Conventions: # WEB_GROUP group of every web server user; PHP-FPM's socket and the TLS keys are readable by it -# WEB_PHP_SOCKET the PHP-FPM pool of the php module listens here (owner root, group WEB_GROUP, mode 0660) +# WEB_PHP_SOCKET the PHP-FPM pool of the php module listens here (owner root, group WEB_GROUP, mode 0660); the +# directory carries the SELinux label web servers may connect to on the RHEL family, and the +# php module creates it on Debian and Ubuntu # WEB_TLS_DIR one directory per certificate (NAME/fullchain.pem, NAME/privkey.pem); "default" is a # self-signed certificate for the host name that every web server creates -# WEB_ACME_ROOT webroot for ACME http-01 challenges, served on port 80 for every host name +# WEB_ACME_ROOT webroot for ACME http-01 challenges, served on port 80 for every host name (below /var/www, +# where SELinux lets web servers read) # WEB_ADMIN_PORT admin interfaces (phpMyAdmin, Adminer, pgAdmin) live on this port, on 127.0.0.1 by default +# +# A site or an admin path is written for the installed web server by web_site_write / web_admin_write; the web +# server modules include the directories below WEB_CONF (sites/ and admin/). WEB_GROUP=lss-web -WEB_PHP_SOCKET=/run/lss-php-fpm.sock +WEB_PHP_SOCKET=/run/php-fpm/lss.sock WEB_TLS_DIR=/etc/lss/tls -WEB_ACME_ROOT=/var/lib/lss/acme +WEB_ACME_ROOT=/var/www/lss-acme WEB_DEFAULT_ROOT=/var/www/lss-default WEB_ADMIN_PORT=8090 +WEB_ADMIN_HTPASSWD=/etc/lss/web/admin.htpasswd +WEB_ADMIN_CADDY_AUTH=/etc/lss/web/admin-auth.caddy +# Mozilla "intermediate" (TLS 1.2 and 1.3) without the DHE suites, which would need a DH parameter file. +WEB_TLS_CIPHERS=ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305 # The installed web server (nginx, apache or caddy), or nothing. web_server() { @@ -33,6 +43,60 @@ web_server() { return 0 } +# web_unit SERVER: systemd unit of a web server. +web_unit() { + local server=$1 + case ${server} in + apache) if os_is_debian; then printf 'apache2'; else printf 'httpd'; fi ;; + *) printf '%s' "${server}" ;; + esac + return +} + +# web_user SERVER: the account the web server's workers run as. +web_user() { + local server=$1 + case ${server} in + nginx) if os_is_debian; then printf 'www-data'; else printf 'nginx'; fi ;; + apache) if os_is_debian; then printf 'www-data'; else printf 'apache'; fi ;; + *) printf '%s' "${server}" ;; + esac + return +} + +# web_conf_dir SERVER: directory with lss's configuration for that web server (sites/, admin/, snippets). +web_conf_dir() { + local server=$1 + case ${server} in + nginx) printf '/etc/nginx/lss' ;; + apache) if os_is_debian; then printf '/etc/apache2/lss'; else printf '/etc/httpd/lss'; fi ;; + *) printf '/etc/caddy/lss' ;; + esac + return +} + +# web_log_dir SERVER: where the web server writes its access and error logs. +web_log_dir() { + local server=$1 + case ${server} in + apache) if os_is_debian; then printf '/var/log/apache2'; else printf '/var/log/httpd'; fi ;; + *) printf '/var/log/%s' "${server}" ;; + esac + return +} + +# web_log_file SERVER access|error: the access or error log of the web server (Apache on the RHEL family writes +# access_log and error_log). +web_log_file() { + local server=$1 kind=$2 + if [[ ${server} == apache ]] && os_is_rhel; then + printf '%s/%s_log' "$(web_log_dir "${server}")" "${kind}" + else + printf '%s/%s.log' "$(web_log_dir "${server}")" "${kind}" + fi + return +} + # web_group_ensure [USER...]: creates WEB_GROUP and adds the users to it. web_group_ensure() { local user @@ -49,3 +113,650 @@ web_group_ensure() { done return } + +# True when the kernel has IPv6; web servers only listen on [::] then (kernel-hardening can switch it off). +web_ipv6() { + [[ -f /proc/net/if_inet6 ]] + return +} + +# web_cert_dir NAME: the certificate directory for NAME, or the default certificate when NAME has none yet. +web_cert_dir() { + local name=$1 + if [[ -s ${WEB_TLS_DIR}/${name}/fullchain.pem && -s ${WEB_TLS_DIR}/${name}/privkey.pem ]]; then + printf '%s/%s' "${WEB_TLS_DIR}" "${name}" + else + printf '%s/default' "${WEB_TLS_DIR}" + fi + return +} + +# web_cert_has_ocsp CERT: the certificate names an OCSP responder. Let's Encrypt stopped OCSP in 2025; stapling is +# switched on only for certificates that still have one, so the web servers log no warning about it. +web_cert_has_ocsp() { + local cert=$1 + [[ -n $(openssl x509 -noout -ocsp_uri -in "${cert}" 2>/dev/null) ]] + return +} + +# web_default_cert: a self-signed certificate for the host name in WEB_TLS_DIR/default, so HTTPS answers before a +# real certificate exists. Renewed when it expires within 30 days; the key is readable by WEB_GROUP. +web_default_cert() { + local dir="${WEB_TLS_DIR}/default" name tmp + if [[ -s ${dir}/fullchain.pem && -s ${dir}/privkey.pem ]] && + openssl x509 -checkend $((30 * 86400)) -noout -in "${dir}/fullchain.pem" >/dev/null 2>&1; then + return 0 + fi + name=$(hostname -f 2>/dev/null || hostname) + tmp=$(mktemp -d) + openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -days 825 -subj "/CN=${name}" \ + -addext "subjectAltName=DNS:${name}" -keyout "${tmp}/privkey.pem" -out "${tmp}/fullchain.pem" >/dev/null 2>&1 || + die "web: could not create the self-signed default certificate." + install -d -m 0755 "${WEB_TLS_DIR}" + file_write 0640 "root:${WEB_GROUP}" "${dir}/privkey.pem" <"${tmp}/privkey.pem" + file_write 0644 root:root "${dir}/fullchain.pem" <"${tmp}/fullchain.pem" + rm -rf -- "${tmp}" + return +} + +WEB_CERT_HOOK=/etc/letsencrypt/renewal-hooks/deploy/lss-deploy + +# web_cert_placeholder NAME: until NAME has a certificate of its own, its directory holds a copy of the default +# certificate, so a site can point at WEB_TLS_DIR/NAME from the start and keeps that path after the real one arrives. +web_cert_placeholder() { + local name=$1 dir + dir="${WEB_TLS_DIR}/${name}" + [[ -s ${dir}/fullchain.pem && -s ${dir}/privkey.pem ]] && return 0 + install -d -m 0755 "${dir}" + install -m 0644 -o root -g root "${WEB_TLS_DIR}/default/fullchain.pem" "${dir}/fullchain.pem" + install -m 0640 -o root -g "${WEB_GROUP}" "${WEB_TLS_DIR}/default/privkey.pem" "${dir}/privkey.pem" + files_note_change "placeholder certificate for ${name}" + return +} + +# web_cert_domains CERT: the DNS names of a certificate, sorted, one per line. +web_cert_domains() { + local cert=$1 + openssl x509 -noout -ext subjectAltName -in "${cert}" 2>/dev/null | grep -oE 'DNS:[^,[:space:]]+' | cut -c 5- | sort -u + return +} + +# web_cert_issue NAME DOMAIN...: a certificate from the ACME server the tls module set up (/etc/letsencrypt/cli.ini: +# server, account, webroot), issued once and again only when the names change; certbot renews it, and the deploy +# hook copies it to WEB_TLS_DIR/NAME and reloads the web server. +web_cert_issue() { + local name=$1 live wanted current + shift + live="/etc/letsencrypt/live/${name}/fullchain.pem" + wanted=$(printf '%s\n' "$@" | sort -u) + current='' + [[ ! -s ${live} ]] || current=$(web_cert_domains "${live}") + if [[ ${current} != "${wanted}" ]]; then + local -a args=() + local domain + for domain in "$@"; do + args+=(-d "${domain}") + done + log_info "Requesting a certificate for $*" + log_command certbot certonly --cert-name "${name}" "${args[@]}" || + die "tls: the ACME server refused a certificate for $*; the names must resolve to this server and port 80 must be reachable." + files_note_change "certificate ${name}" + fi + if ! cmp -s "${live}" "${WEB_TLS_DIR}/${name}/fullchain.pem"; then + RENEWED_LINEAGE="/etc/letsencrypt/live/${name}" "${WEB_CERT_HOOK}" + files_note_change "installed certificate ${name}" + fi + return +} + +# web_prepare SERVER: what every web server needs before its configuration is written: the web group with the +# server's user in it, the default certificate, the ACME webroot and an empty default document root. +web_prepare() { + local server=$1 + web_group_ensure "$(web_user "${server}")" + web_default_cert + install -d -m 0755 "${WEB_ACME_ROOT}" "${WEB_ACME_ROOT}/.well-known" "${WEB_ACME_ROOT}/.well-known/acme-challenge" + install -d -m 0755 "${WEB_DEFAULT_ROOT}" + install -d -m 0755 "$(web_conf_dir "${server}")" "$(web_conf_dir "${server}")/sites" "$(web_conf_dir "${server}")/admin" + return +} + +# web_test SERVER: checks the configuration; prints the problem and fails when it does not load. +web_test() { + local server=$1 output + case ${server} in + nginx) output=$(nginx -t -q 2>&1) ;; + apache) output=$(apachectl -t 2>&1) ;; + *) output=$(caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile 2>&1) ;; + esac || { + printf '%s\n' "${output}" >&2 + return 1 + } + return 0 +} + +# web_reload: tests and reloads the installed web server after a site or admin path changed. +web_reload() { + local server + server=$(web_server) + [[ -n ${server} ]] || return 0 + web_test "${server}" || die "web: the ${server} configuration does not load; see above." + if svc_is_active "$(web_unit "${server}")"; then + svc_reload "$(web_unit "${server}")" + fi + return +} + +# web_headers SERVER: the security headers every HTTPS site sends. +web_headers() { + local server=$1 header + local -a headers=( + 'Strict-Transport-Security "max-age=63072000"' + 'X-Content-Type-Options "nosniff"' + 'X-Frame-Options "SAMEORIGIN"' + 'Referrer-Policy "strict-origin-when-cross-origin"' + 'Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()"' + 'Cross-Origin-Opener-Policy "same-origin"' + ) + for header in "${headers[@]}"; do + case ${server} in + nginx) printf '\tadd_header %s always;\n' "${header}" ;; + apache) printf '\tHeader always set %s\n' "${header}" ;; + *) printf '\t\t%s\n' "${header}" ;; + esac + done + return +} + +# True for nginx 1.25.1 and later, which takes "http2 on;" instead of the http2 flag on listen. +web_nginx_http2_directive() { + local version + version=$(nginx -v 2>&1 | sed -nE 's|.*nginx/([0-9]+\.[0-9]+\.[0-9]+).*|\1|p') + [[ -n ${version} ]] || return 1 + printf '%s\n1.25.1\n' "${version}" | sort -V -C -r + return +} + +# web_nginx_ssl_flags: flags of an HTTPS listen line ("ssl" or "ssl http2" on nginx before 1.25.1). +web_nginx_ssl_flags() { + if web_nginx_http2_directive; then printf 'ssl'; else printf 'ssl http2'; fi + return +} + +# _web_listen_nginx PORT [FLAGS]: listen lines for IPv4 and, when available, IPv6. +_web_listen_nginx() { + local port=$1 flags=${2:-} + printf '\tlisten %s%s;\n' "${port}" "${flags:+ ${flags}}" + if web_ipv6; then + printf '\tlisten [::]:%s%s;\n' "${port}" "${flags:+ ${flags}}" + fi + return +} + +# _web_tls_nginx CERTDIR: certificate lines of an nginx server block. +_web_tls_nginx() { + local dir=$1 + printf '\tssl_certificate %s/fullchain.pem;\n\tssl_certificate_key %s/privkey.pem;\n' "${dir}" "${dir}" + if web_cert_has_ocsp "${dir}/fullchain.pem"; then + printf '\tssl_stapling on;\n\tssl_stapling_verify on;\n' + fi + return +} + +# _web_body_nginx KIND TARGET: what an nginx site serves (static files, PHP through PHP-FPM, or a proxied URL). +_web_body_nginx() { + local kind=$1 target=$2 + case ${kind} in + proxy) + cat < + Options -Indexes +FollowSymLinks + AllowOverride All + Require all granted + +EOF + if [[ ${kind} == php ]]; then + cat < + SetHandler "proxy:unix:${WEB_PHP_SOCKET}|fcgi://localhost" + +EOF + fi + ;; + esac + return +} + +# _web_site_apache NAME KIND TARGET DOMAIN... +_web_site_apache() { + local name=$1 kind=$2 target=$3 + shift 3 + local first=$1 cert + shift + local aliases="$*" + cert=$(web_cert_dir "${name}") + cat < + ServerName ${first} +${aliases:+ ServerAlias ${aliases} +} Alias /.well-known/acme-challenge/ ${WEB_ACME_ROOT}/.well-known/acme-challenge/ + RewriteEngine On + RewriteCond %{REQUEST_URI} !^/\\.well-known/acme-challenge/ + RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L] + + + + ServerName ${first} +${aliases:+ ServerAlias ${aliases} +} Protocols h2 http/1.1 + SSLEngine on + SSLCertificateFile ${cert}/fullchain.pem + SSLCertificateKeyFile ${cert}/privkey.pem +EOF + if web_cert_has_ocsp "${cert}/fullchain.pem"; then + printf '\tSSLUseStapling on\n' + fi + web_headers apache + printf '\tErrorLog %s\n\tCustomLog %s combined\n' "$(web_log_file apache error)" "$(web_log_file apache access)" + _web_body_apache "${kind}" "${target}" + printf '\n' + return +} + +# _web_admin_apache NAME PATH KIND TARGET +_web_admin_apache() { + local name=$1 path=$2 kind=$3 target=$4 + printf '# Managed by lss (admin path of %s).\n' "${name}" + if [[ ${kind} == proxy ]]; then + cat < + ProxyPass ${target} + ProxyPassReverse ${target} + RequestHeader set X-Script-Name /${path} + +EOF + else + cat < + Options -Indexes +FollowSymLinks + AllowOverride None + Require all granted + DirectoryIndex index.php + + SetHandler "proxy:unix:${WEB_PHP_SOCKET}|fcgi://localhost" + + +EOF + fi + return +} + +# _web_site_caddy NAME KIND TARGET DOMAIN... +_web_site_caddy() { + local name=$1 kind=$2 target=$3 + shift 3 + local domains="$*" cert http_names='' https_names='' domain + cert=$(web_cert_dir "${name}") + for domain in ${domains}; do + http_names+="http://${domain} " + https_names+="https://${domain} " + done + printf '# Managed by lss (site %s).\n%s{\n' "${name}" "${http_names}" + printf '\thandle /.well-known/acme-challenge/* {\n\t\troot * %s\n\t\tfile_server\n\t}\n' "${WEB_ACME_ROOT}" + printf '\thandle {\n\t\tredir https://{host}{uri} permanent\n\t}\n}\n\n%s{\n' "${https_names}" + printf '\ttls %s/fullchain.pem %s/privkey.pem\n\timport lss_tls_headers\n\tlog\n' "${cert}" "${cert}" + case ${kind} in + proxy) printf '\treverse_proxy %s\n' "${target}" ;; + php) printf '\troot * %s\n\tphp_fastcgi unix/%s\n\tfile_server\n' "${target}" "${WEB_PHP_SOCKET}" ;; + *) printf '\troot * %s\n\tfile_server\n' "${target}" ;; + esac + printf '}\n' + return +} + +# _web_admin_caddy NAME PATH KIND TARGET +_web_admin_caddy() { + local name=$1 path=$2 kind=$3 target=$4 + printf '# Managed by lss (admin path of %s).\n' "${name}" + if [[ ${kind} == proxy ]]; then + printf 'handle /%s/* {\n\treverse_proxy %s {\n\t\theader_up X-Script-Name /%s\n\t}\n}\n' "${path}" "${target}" "${path}" + else + printf 'redir /%s /%s/\nhandle_path /%s/* {\n\troot * %s\n\tphp_fastcgi unix/%s\n\tfile_server\n}\n' \ + "${path}" "${path}" "${path}" "${target}" "${WEB_PHP_SOCKET}" + fi + return +} + +# _web_name_check NAME: site and admin names become file names. +_web_name_check() { + local name=$1 + [[ ${name} =~ ^[a-z0-9][a-z0-9.-]*$ ]] || die "web: '${name}' is not a valid site name." + return +} + +_web_ext() { + local server=$1 + if [[ ${server} == caddy ]]; then printf 'caddy'; else printf 'conf'; fi + return +} + +# web_selinux_proxy: with SELinux enforcing, web servers may only connect to other ports once the boolean +# httpd_can_network_connect is on; sites and admin paths that proxy turn it on. +web_selinux_proxy() { + command -v getsebool >/dev/null 2>&1 || return 0 + [[ $(getenforce 2>/dev/null) == Enforcing ]] || return 0 + if [[ $(getsebool httpd_can_network_connect 2>/dev/null) != *' on' ]]; then + setsebool -P httpd_can_network_connect 1 + files_note_change "SELinux boolean httpd_can_network_connect" + fi + return +} + +# web_site_write NAME static|php|proxy TARGET DOMAIN...: an HTTPS site for the installed web server, with HTTP +# redirected to HTTPS and the ACME challenge path served. TARGET is a document root or an upstream URL. Sets +# LSS_FILE_CHANGED; call web_reload afterwards. +web_site_write() { + local name=$1 kind=$2 target=$3 server + shift 3 + _web_name_check "${name}" + server=$(web_server) + [[ -n ${server} ]] || die "web: no web server is installed (nginx, apache or caddy)." + [[ ${kind} != proxy ]] || web_selinux_proxy + "_web_site_${server}" "${name}" "${kind}" "${target}" "$@" | + file_write 0644 root:root "$(web_conf_dir "${server}")/sites/${name}.$(_web_ext "${server}")" + return +} + +web_site_remove() { + local name=$1 server + server=$(web_server) + [[ -n ${server} ]] || return 0 + file_remove "$(web_conf_dir "${server}")/sites/${name}.$(_web_ext "${server}")" + return +} + +# web_admin_write NAME PATH php|proxy TARGET: publishes an admin interface below /PATH/ on the admin server +# (WEB_ADMIN_PORT). TARGET is a directory with PHP files or an upstream URL. +web_admin_write() { + local name=$1 path=$2 kind=$3 target=$4 server + _web_name_check "${name}" + [[ ${path} =~ ^[a-z0-9-]+$ ]] || die "web: '${path}' is not a valid admin path." + server=$(web_server) + [[ -n ${server} ]] || die "web: no web server is installed (nginx, apache or caddy)." + [[ ${kind} != proxy ]] || web_selinux_proxy + "_web_admin_${server}" "${name}" "${path}" "${kind}" "${target}" | + file_write 0644 root:root "$(web_conf_dir "${server}")/admin/${name}.$(_web_ext "${server}")" + return +} + +web_admin_remove() { + local name=$1 server + server=$(web_server) + [[ -n ${server} ]] || return 0 + file_remove "$(web_conf_dir "${server}")/admin/${name}.$(_web_ext "${server}")" + return +} + +# web_admin_htpasswd SERVER: the basic-auth file for a public admin server, user "admin" with the secret +# web-admin-password. SHA-512 crypt for nginx and apache, bcrypt for caddy in its own format (WEB_ADMIN_CADDY_AUTH). +web_admin_htpasswd() { + local server=$1 password hash + secret_load web-admin-password password 32 + if [[ ${server} == caddy ]]; then + [[ -s ${WEB_ADMIN_CADDY_AUTH} ]] && return 0 + hash=$(caddy hash-password <<<"${password}" 2>/dev/null) || die "web: caddy could not hash the admin password." + file_write 0640 "root:${WEB_GROUP}" "${WEB_ADMIN_CADDY_AUTH}" <<<"admin ${hash}" + return 0 + fi + if [[ -s ${WEB_ADMIN_HTPASSWD} ]] && [[ $(cut -d ':' -f 2- "${WEB_ADMIN_HTPASSWD}") == \$6\$* ]]; then + return 0 + fi + hash=$(openssl passwd -6 -stdin <<<"${password}") || die "web: openssl could not hash the admin password." + file_write 0640 "root:${WEB_GROUP}" "${WEB_ADMIN_HTPASSWD}" <<<"admin:${hash}" + return +} + +# web_ids_configure: lets fail2ban or CrowdSec read the logs of the installed web server. Called by the web server +# modules and by fail2ban and crowdsec, whichever is installed last. +web_ids_configure() { + local server logs + server=$(web_server) + if [[ -z ${server} ]]; then + file_remove /etc/fail2ban/jail.d/lss-web.local + file_remove /etc/fail2ban/filter.d/lss-caddy-auth.conf + file_remove /etc/crowdsec/acquis.d/lss-web.yaml + return 0 + fi + logs=$(web_log_dir "${server}") + install -d -m 0755 "${logs}" + if state_installed fail2ban || [[ ${LSS_MODULE:-} == fail2ban ]]; then + _web_fail2ban "${server}" "${logs}" + fi + if state_installed crowdsec || [[ ${LSS_MODULE:-} == crowdsec ]]; then + _web_crowdsec "${server}" "${logs}" + fi + return +} + +# web_ids_remove: drops the web server's jails and log acquisition (when the web server is removed). +web_ids_remove() { + if [[ -e /etc/fail2ban/jail.d/lss-web.local ]]; then + file_remove /etc/fail2ban/jail.d/lss-web.local + file_remove /etc/fail2ban/filter.d/lss-caddy-auth.conf + if svc_is_active fail2ban; then + svc_restart fail2ban + fi + fi + if [[ -e /etc/crowdsec/acquis.d/lss-web.yaml ]]; then + file_remove /etc/crowdsec/acquis.d/lss-web.yaml + if svc_is_active crowdsec; then + svc_restart crowdsec + fi + fi + return +} + +# _web_fail2ban SERVER LOGDIR: jails for failed basic-auth logins and vulnerability scanners. +_web_fail2ban() { + local server=$1 logs=$2 access error changed=0 + install -d -m 0755 "${logs}" + access=$(web_log_file "${server}" access) + error=$(web_log_file "${server}" error) + [[ -e ${access} ]] || install -m 0640 /dev/null "${access}" + [[ -e ${error} ]] || install -m 0640 /dev/null "${error}" + case ${server} in + nginx) + file_write 0644 root:root /etc/fail2ban/jail.d/lss-web.local <".*"status":401\b +ignoreregex = +datepattern = "ts":{EPOCH} +EOF + [[ ${LSS_FILE_CHANGED} == 0 ]] || changed=1 + file_write 0644 root:root /etc/fail2ban/jail.d/lss-web.local </dev/null 2>&1 || die "web: the fail2ban jails for ${server} do not load." + svc_restart fail2ban + fi + return +} + +# _web_crowdsec SERVER LOGDIR: the CrowdSec collection and log acquisition of the web server. +_web_crowdsec() { + local server=$1 logs=$2 collection type changed=0 + local -a files=("$(web_log_file "${server}" access)") + [[ -d ${logs} ]] || install -d -m 0755 "${logs}" + case ${server} in + nginx) collection=crowdsecurity/nginx type=nginx files+=("$(web_log_file nginx error)") ;; + apache) collection=crowdsecurity/apache2 type=apache2 files+=("$(web_log_file apache error)") ;; + *) collection=crowdsecurity/caddy type=caddy ;; + esac + if ! cscli collections list -o raw 2>/dev/null | cut -d ',' -f 1 | grep -qxF "${collection}"; then + cscli collections install "${collection}" >/dev/null 2>&1 || die "web: CrowdSec could not install ${collection}." + files_note_change "crowdsec collection ${collection}" + changed=1 + fi + { + printf '# Managed by lss (web server %s).\nfilenames:\n' "${server}" + printf ' - %s\n' "${files[@]}" + printf 'labels:\n type: %s\n' "${type}" + } | file_write 0644 root:root /etc/crowdsec/acquis.d/lss-web.yaml + [[ ${LSS_FILE_CHANGED} == 0 ]] || changed=1 + if [[ ${changed} == 1 ]] && svc_is_active crowdsec; then + svc_restart crowdsec + fi + return +} diff --git a/modules/web/apache.sh b/modules/web/apache.sh new file mode 100644 index 0000000..4d63672 --- /dev/null +++ b/modules/web/apache.sh @@ -0,0 +1,256 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +MOD_NAME="apache" +MOD_GROUP="web" +MOD_DESCRIPTION="Apache httpd web server: HTTPS only (TLS 1.2 and 1.3), HTTP/2, security headers, admin port on localhost" +MOD_REQUIRES="system" +MOD_CONFLICTS="nginx caddy" +MOD_OPTIONS="max_body|64m|Largest request body (uploads), for example 64m or 1g +admin_public|false|Serve the admin interfaces (phpMyAdmin, Adminer, pgAdmin) on port 8090 to everyone, with TLS and a password (user admin, lss secrets show web-admin-password); otherwise only on 127.0.0.1 (use an SSH tunnel)" +MOD_SECRETS="web-admin-password" + +APACHE_MODULES_DEB="ssl headers rewrite proxy proxy_http proxy_fcgi proxy_wstunnel setenvif http2 socache_shmcb auth_basic authn_file" + +_apache_root() { + if os_is_debian; then printf '/etc/apache2'; else printf '/etc/httpd'; fi + return +} + +# The global configuration: conf-available/zz-lss.conf on Debian (loaded after security.conf, so its settings win), +# conf.d/lss.conf on the RHEL family. +_apache_global_file() { + if os_is_debian; then printf '/etc/apache2/conf-available/zz-lss.conf'; else printf '/etc/httpd/conf.d/lss.conf'; fi + return +} + +# _apache_bytes SIZE: 64m -> bytes for LimitRequestBody. +_apache_bytes() { + local size=$1 number unit + number=${size%[kKmMgG]} + unit=${size#"${number}"} + case ${unit,,} in + k) printf '%s' $((number * 1024)) ;; + m) printf '%s' $((number * 1024 * 1024)) ;; + g) printf '%s' $((number * 1024 * 1024 * 1024)) ;; + *) printf '%s' "${number}" ;; + esac + return +} + +mod_check() { + [[ $(opt max_body) =~ ^[0-9]+[kKmMgG]?$ ]] || die "LSS_APACHE_MAX_BODY '$(opt max_body)' is not a size like 64m or 1g." + opt_bool admin_public || true + return +} + +mod_install() { + pkg_install_family "apache2 openssl" "httpd mod_ssl mod_http2 openssl" + return +} + +_apache_stapling_cache() { + if os_is_debian; then + # shellcheck disable=SC2016 # ${APACHE_RUN_DIR} is expanded by Apache from /etc/apache2/envvars + printf 'shmcb:${APACHE_RUN_DIR}/ssl_stapling(32768)' + else + printf 'shmcb:/run/httpd/ssl_stapling(32768)' + fi + return +} + +_apache_global_conf() { + cat < + Options None + AllowOverride None + Require all denied + + + Options None + AllowOverride None + Require all granted + + + Require all denied + + +Include lss/default.conf +Include lss/admin.conf +IncludeOptional lss/sites/*.conf +EOF + return +} + +# The first virtual hosts answer requests for host names without a site: ACME challenges on port 80, nothing else. +_apache_default_conf() { + cat < + ServerName lss-default.invalid + DocumentRoot ${WEB_DEFAULT_ROOT} + Alias /.well-known/acme-challenge/ ${WEB_ACME_ROOT}/.well-known/acme-challenge/ + + Require all denied + + + + + ServerName lss-default.invalid + SSLEngine on + SSLCertificateFile ${WEB_TLS_DIR}/default/fullchain.pem + SSLCertificateKeyFile ${WEB_TLS_DIR}/default/privkey.pem + DocumentRoot ${WEB_DEFAULT_ROOT} + + Require all denied + + +EOF + return +} + +_apache_admin_conf() { + local address="127.0.0.1:${WEB_ADMIN_PORT}" + opt_bool admin_public && address="*:${WEB_ADMIN_PORT}" + printf '# Managed by lss (module apache). Admin interfaces on port %s.\n' "${WEB_ADMIN_PORT}" + if opt_bool admin_public; then + printf 'Listen %s https\n' "${WEB_ADMIN_PORT}" + else + printf 'Listen %s\n' "${address}" + fi + printf '\n\tServerName lss-admin.invalid\n\tDocumentRoot %s\n' "${address}" "${WEB_DEFAULT_ROOT}" + if opt_bool admin_public; then + cat < + AuthType Basic + AuthName "lss admin" + AuthUserFile ${WEB_ADMIN_HTPASSWD} + Require valid-user + +EOF + fi + web_headers apache | grep -v Strict-Transport-Security + printf '\tErrorLog %s\n\tCustomLog %s combined\n' "$(web_log_file apache error)" "$(web_log_file apache access)" + printf '\tIncludeOptional lss/admin/*.conf\n\n' + return +} + +# Debian: modules and the configuration are switched on with a2enmod and a2enconf; the stock site goes away. +_apache_debian_enable() { + local module + for module in ${APACHE_MODULES_DEB}; do + if ! a2query -q -m "${module}" 2>/dev/null; then + a2enmod -q "${module}" >/dev/null + files_note_change "apache module ${module}" + fi + done + if ! a2query -q -c zz-lss 2>/dev/null; then + a2enconf -q zz-lss >/dev/null + files_note_change "apache configuration zz-lss" + fi + if a2query -q -s 000-default 2>/dev/null; then + a2dissite -q 000-default >/dev/null + files_note_change "apache site 000-default off" + fi + return +} + +# RHEL family: mod_ssl's ssl.conf carries a test virtual host and welcome.conf the test page; lss replaces both. +_apache_rhel_files() { + file_write 0644 root:root /etc/httpd/conf.d/ssl.conf <<'EOF' +# Managed by lss (module apache). The TLS settings and virtual hosts are in conf.d/lss.conf and lss/. +Listen 443 https +SSLPassPhraseDialog exec:/usr/libexec/httpd-ssl-pass-dialog +SSLSessionCache shmcb:/run/httpd/sslcache(512000) +SSLSessionCacheTimeout 300 +SSLCryptoDevice builtin +EOF + file_write 0644 root:root /etc/httpd/conf.d/welcome.conf <<<'# Managed by lss (module apache). No test page.' + return +} + +mod_configure() { + local dir unit changed=0 + dir=$(web_conf_dir apache) + unit=$(web_unit apache) + web_prepare apache + _apache_global_conf | file_write 0644 root:root "$(_apache_global_file)" + [[ ${LSS_FILE_CHANGED} == 0 ]] || changed=1 + _apache_default_conf | file_write 0644 root:root "${dir}/default.conf" + [[ ${LSS_FILE_CHANGED} == 0 ]] || changed=1 + _apache_admin_conf | file_write 0644 root:root "${dir}/admin.conf" + [[ ${LSS_FILE_CHANGED} == 0 ]] || changed=1 + file_write 0644 root:root "${WEB_ACME_ROOT}/.well-known/acme-challenge/lss-probe" <<<"lss" + if os_is_debian; then + local before + before=$(files_changes_of apache) + _apache_debian_enable + [[ $(files_changes_of apache) == "${before}" ]] || changed=1 + else + _apache_rhel_files + [[ ${LSS_FILE_CHANGED} == 0 ]] || changed=1 + fi + if opt_bool admin_public; then + web_admin_htpasswd apache + fw_allow "${WEB_ADMIN_PORT}/tcp" + fi + fw_allow 80/tcp 443/tcp + if os_is_debian; then + svc_sandbox "${unit}.service" ReadWritePaths=-/var/lib/apache2 ReadWritePaths=-/var/cache/apache2 + else + svc_sandbox "${unit}.service" ReadWritePaths=-/var/lib/httpd ReadWritePaths=-/var/cache/httpd + fi + [[ ${LSS_FILE_CHANGED} == 0 ]] || changed=1 + web_test apache || die "apache: the configuration does not load; see above." + svc_enable "${unit}" + if [[ ${changed} == 1 ]]; then + svc_restart "${unit}" + fi + web_ids_configure + return +} + +mod_verify() { + svc_is_active "$(web_unit apache)" || die "apache: the service is not running." + web_test apache || die "apache: the configuration does not load." + [[ $(curl -fsS --max-time 10 http://127.0.0.1/.well-known/acme-challenge/lss-probe 2>/dev/null) == lss ]] || + die "apache: port 80 does not serve the ACME challenge path." + openssl s_client -connect 127.0.0.1:443 -servername lss-probe /dev/null | grep -q 'BEGIN CERTIFICATE' || + die "apache: port 443 does not answer with TLS." + curl -s --max-time 10 -o /dev/null "http://127.0.0.1:${WEB_ADMIN_PORT}/" || opt_bool admin_public || + die "apache: the admin port ${WEB_ADMIN_PORT} does not answer on 127.0.0.1." + return +} + +mod_remove() { + local unit + unit=$(web_unit apache) + svc_disable "${unit}" + svc_dropin_remove "${unit}.service" lss-sandbox + web_ids_remove + file_remove "$(web_conf_dir apache)" + file_remove "$(_apache_global_file)" + file_remove "${WEB_ACME_ROOT}/.well-known/acme-challenge/lss-probe" + pkg_remove_family "apache2 apache2-bin apache2-data apache2-utils" "httpd mod_ssl mod_http2" + return +} diff --git a/modules/web/caddy.sh b/modules/web/caddy.sh new file mode 100644 index 0000000..e97ca03 --- /dev/null +++ b/modules/web/caddy.sh @@ -0,0 +1,175 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +MOD_NAME="caddy" +MOD_GROUP="web" +MOD_DESCRIPTION="Caddy web server from its official repository: HTTPS only, security headers, JSON access log, admin port on localhost" +MOD_REQUIRES="system" +MOD_CONFLICTS="nginx apache" +MOD_OPTIONS="max_body|64m|Largest request body (uploads), for example 64m or 1g +admin_public|false|Serve the admin interfaces (phpMyAdmin, Adminer, pgAdmin) on port 8090 to everyone, with TLS and a password (user admin, lss secrets show web-admin-password); otherwise only on 127.0.0.1 (use an SSH tunnel)" +MOD_SECRETS="web-admin-password" + +CADDY_FILE=/etc/caddy/Caddyfile +CADDY_DIR=/etc/caddy/lss + +mod_check() { + [[ $(opt max_body) =~ ^[0-9]+[kKmMgG]?$ ]] || die "LSS_CADDY_MAX_BODY '$(opt max_body)' is not a size like 64m or 1g." + opt_bool admin_public || true + return +} + +# Certificates come from the tls module (one ACME client for every web server), so Caddy's own automatic HTTPS is +# off; the configuration is written explicitly instead. +mod_install() { + repo_add caddy + pkg_install caddy openssl + return +} + +# _caddy_size SIZE: 64m -> 64MB, the unit Caddy's request_body directive expects. +_caddy_size() { + local size=$1 number unit + number=${size%[kKmMgG]} + unit=${size#"${number}"} + printf '%s%s' "${number}" "$(tr '[:lower:]' '[:upper:]' <<<"${unit}")" + [[ -z ${unit} ]] || printf 'B' + return +} + +_caddy_headers() { + web_headers caddy + printf '\t\t-Server\n' + return +} + +_caddy_file() { + cat </dev/null) == lss ]] && break + sleep 1 + done + [[ $(curl -fsS --max-time 10 http://127.0.0.1/.well-known/acme-challenge/lss-probe 2>/dev/null) == lss ]] || + die "caddy: port 80 does not serve the ACME challenge path." + openssl s_client -connect 127.0.0.1:443 -servername lss-probe /dev/null | grep -q 'BEGIN CERTIFICATE' || + die "caddy: port 443 does not answer with TLS." + curl -s --max-time 10 -o /dev/null "http://127.0.0.1:${WEB_ADMIN_PORT}/" || opt_bool admin_public || + die "caddy: the admin port ${WEB_ADMIN_PORT} does not answer on 127.0.0.1." + return +} + +mod_remove() { + svc_disable caddy + svc_dropin_remove caddy.service lss-sandbox + web_ids_remove + file_remove "${CADDY_DIR}" + file_remove "${WEB_ACME_ROOT}/.well-known/acme-challenge/lss-probe" + pkg_remove caddy + file_remove "${CADDY_FILE}" + if [[ ${LSS_PURGE:-0} == 1 ]]; then + repo_remove caddy + fi + return +} diff --git a/modules/web/nginx.sh b/modules/web/nginx.sh new file mode 100644 index 0000000..6880ab7 --- /dev/null +++ b/modules/web/nginx.sh @@ -0,0 +1,175 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +MOD_NAME="nginx" +MOD_GROUP="web" +MOD_DESCRIPTION="nginx web server: HTTPS only (TLS 1.2 and 1.3), security headers, closed default server, admin port on localhost" +MOD_REQUIRES="system" +MOD_CONFLICTS="apache caddy" +MOD_OPTIONS="max_body|64m|Largest request body (uploads), for example 64m or 1g +admin_public|false|Serve the admin interfaces (phpMyAdmin, Adminer, pgAdmin) on port 8090 to everyone, with TLS and a password (user admin, lss secrets show web-admin-password); otherwise only on 127.0.0.1 (use an SSH tunnel)" +MOD_SECRETS="web-admin-password" + +NGINX_CONF=/etc/nginx/nginx.conf +NGINX_DIR=/etc/nginx/lss + +mod_check() { + [[ $(opt max_body) =~ ^[0-9]+[kKmMgG]?$ ]] || die "LSS_NGINX_MAX_BODY '$(opt max_body)' is not a size like 64m or 1g." + opt_bool admin_public || true + return +} + +mod_install() { + pkg_install nginx openssl + return +} + +_nginx_modules_dir() { + if os_is_debian; then printf '/etc/nginx/modules-enabled'; else printf '/usr/share/nginx/modules'; fi + return +} + +_nginx_main_conf() { + cat </dev/null) == lss ]] || + die "nginx: port 80 does not serve the ACME challenge path." + openssl s_client -connect 127.0.0.1:443 -servername lss-probe /dev/null | grep -q 'BEGIN CERTIFICATE' || + die "nginx: port 443 does not answer with TLS." + curl -s --max-time 10 -o /dev/null "http://127.0.0.1:${WEB_ADMIN_PORT}/" || opt_bool admin_public || + die "nginx: the admin port ${WEB_ADMIN_PORT} does not answer on 127.0.0.1." + return +} + +mod_remove() { + svc_disable nginx + svc_dropin_remove nginx.service lss-sandbox + file_remove "${NGINX_DIR}" + file_remove "${WEB_ACME_ROOT}/.well-known/acme-challenge/lss-probe" + web_ids_remove + pkg_remove_family "nginx nginx-common nginx-core" "nginx" + file_remove "${NGINX_CONF}" + return +} From 7eef6a1d97d3259e2f63f358ec1f0b4124b14be9 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Tue, 13 Oct 2026 21:07:32 +0200 Subject: [PATCH 03/92] Close three gaps in the Caddy configuration The admin API listened on localhost:2019, where every local account could replace the running configuration; it now uses a unix socket readable by the caddy user only. The private admin site matched the Host header but listened on every interface; bind keeps it on the loopback interface. file_server served dot files such as .git and .env, which nginx and Apache deny; a shared snippet now answers 404. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- lib/web.sh | 3 ++- modules/web/caddy.sh | 22 ++++++++++++++++++---- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/lib/web.sh b/lib/web.sh index d4fb28b..ea00cd0 100644 --- a/lib/web.sh +++ b/lib/web.sh @@ -518,7 +518,8 @@ _web_site_caddy() { printf '# Managed by lss (site %s).\n%s{\n' "${name}" "${http_names}" printf '\thandle /.well-known/acme-challenge/* {\n\t\troot * %s\n\t\tfile_server\n\t}\n' "${WEB_ACME_ROOT}" printf '\thandle {\n\t\tredir https://{host}{uri} permanent\n\t}\n}\n\n%s{\n' "${https_names}" - printf '\ttls %s/fullchain.pem %s/privkey.pem\n\timport lss_tls_headers\n\tlog\n' "${cert}" "${cert}" + printf '\ttls %s/fullchain.pem %s/privkey.pem\n\timport lss_tls_headers\n\timport lss_hidden\n\tlog\n' \ + "${cert}" "${cert}" case ${kind} in proxy) printf '\treverse_proxy %s\n' "${target}" ;; php) printf '\troot * %s\n\tphp_fastcgi unix/%s\n\tfile_server\n' "${target}" "${WEB_PHP_SOCKET}" ;; diff --git a/modules/web/caddy.sh b/modules/web/caddy.sh index e97ca03..049f763 100644 --- a/modules/web/caddy.sh +++ b/modules/web/caddy.sh @@ -13,6 +13,9 @@ MOD_SECRETS="web-admin-password" CADDY_FILE=/etc/caddy/Caddyfile CADDY_DIR=/etc/caddy/lss +# The admin API changes the running configuration, so it listens on a socket only the caddy user (and root) can open, +# not on a TCP port every local account can reach. +CADDY_ADMIN_SOCKET=/run/caddy/admin.sock mod_check() { [[ $(opt max_body) =~ ^[0-9]+[kKmMgG]?$ ]] || die "LSS_CADDY_MAX_BODY '$(opt max_body)' is not a size like 64m or 1g." @@ -49,7 +52,7 @@ _caddy_file() { # Managed by lss (module caddy). Sites: ${CADDY_DIR}/sites, admin paths: ${CADDY_DIR}/admin. { auto_https off - admin localhost:2019 + admin unix/${CADDY_ADMIN_SOCKET}|0600 servers { protocols h1 h2 } @@ -75,6 +78,15 @@ $(_caddy_headers) } } +# Dot files and directories (.git, .env, .htpasswd) are never served; ACME challenges below .well-known are. +(lss_hidden) { + @lss_hidden { + path */.* + not path /.well-known/* + } + respond @lss_hidden 404 +} + (lss_headers) { header { $(_caddy_headers | grep -v Strict-Transport-Security) @@ -113,9 +125,10 @@ _caddy_admin_block() { "${WEB_TLS_DIR}" "${WEB_TLS_DIR}" printf '\tbasic_auth {\n\t\timport %s\n\t}\n' "${WEB_ADMIN_CADDY_AUTH}" else - printf 'http://127.0.0.1:%s {\n' "${WEB_ADMIN_PORT}" + # The site address only matches the Host header; bind keeps the listener itself on the loopback interface. + printf 'http://127.0.0.1:%s {\n\tbind 127.0.0.1\n' "${WEB_ADMIN_PORT}" fi - printf '\timport lss_headers\n\tlog\n\timport %s/admin/*.caddy\n\thandle {\n\t\trespond 404\n\t}\n}\n' "${CADDY_DIR}" + printf '\timport lss_headers\n\timport lss_hidden\n\tlog\n\timport %s/admin/*.caddy\n\thandle {\n\t\trespond 404\n\t}\n}\n' "${CADDY_DIR}" return } @@ -131,7 +144,8 @@ mod_configure() { fw_allow "${WEB_ADMIN_PORT}/tcp" fi fw_allow 80/tcp 443/tcp - svc_sandbox caddy.service ReadWritePaths=-/var/lib/caddy ReadWritePaths=-/var/log/caddy + svc_sandbox caddy.service ReadWritePaths=-/var/lib/caddy ReadWritePaths=-/var/log/caddy RuntimeDirectory=caddy \ + RuntimeDirectoryMode=0750 [[ ${LSS_FILE_CHANGED} == 0 ]] || changed=1 web_test caddy || die "caddy: the configuration does not load; see above." svc_enable caddy From fc7dfb171655e38ad4eeffe659471a887d6fc154 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Tue, 13 Oct 2026 21:13:55 +0200 Subject: [PATCH 04/92] Add the tls and vhost modules tls sets up certbot for every web server: webroot challenges on the shared ACME directory, ECDSA keys, any ACME server (a private CA such as Pebble is trusted through LSS_TLS_CA_BUNDLE), the renewal timer and a deploy hook that installs each certificate below /etc/lss/tls and reloads the web server. LSS_TLS_DOMAINS replaces the self-signed default certificate with a real one. vhost publishes static, PHP and reverse-proxy sites. Each site starts with a copy of the default certificate and gets its own from the tls module; sites that leave LSS_VHOST_SITES are removed. The daily guard reports certificates that expire within 14 days. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- lib/web.sh | 11 ++- libexec/lss-guard | 20 +++++- modules/web/tls.sh | 157 ++++++++++++++++++++++++++++++++++++++++++ modules/web/vhost.sh | 126 +++++++++++++++++++++++++++++++++ tests/unit/guard.bats | 13 ++++ 5 files changed, 323 insertions(+), 4 deletions(-) create mode 100644 modules/web/tls.sh create mode 100644 modules/web/vhost.sh diff --git a/lib/web.sh b/lib/web.sh index ea00cd0..92000e1 100644 --- a/lib/web.sh +++ b/lib/web.sh @@ -29,6 +29,9 @@ WEB_ADMIN_PORT=8090 WEB_ADMIN_HTPASSWD=/etc/lss/web/admin.htpasswd WEB_ADMIN_CADDY_AUTH=/etc/lss/web/admin-auth.caddy # Mozilla "intermediate" (TLS 1.2 and 1.3) without the DHE suites, which would need a DH parameter file. +WEB_CERT_HOOK=/etc/letsencrypt/renewal-hooks/deploy/lss-deploy +# CA certificate of a private ACME server (LSS_TLS_CA_BUNDLE, for example Pebble or step-ca); certbot trusts it. +WEB_ACME_CA=/etc/letsencrypt/lss-acme-ca.pem WEB_TLS_CIPHERS=ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305 # The installed web server (nginx, apache or caddy), or nothing. @@ -143,6 +146,8 @@ web_cert_has_ocsp() { # real certificate exists. Renewed when it expires within 30 days; the key is readable by WEB_GROUP. web_default_cert() { local dir="${WEB_TLS_DIR}/default" name tmp + # The tls module's certificate for the host names (lineage "default") takes this place; certbot renews it. + [[ ! -s /etc/letsencrypt/live/default/fullchain.pem ]] || return 0 if [[ -s ${dir}/fullchain.pem && -s ${dir}/privkey.pem ]] && openssl x509 -checkend $((30 * 86400)) -noout -in "${dir}/fullchain.pem" >/dev/null 2>&1; then return 0 @@ -159,8 +164,6 @@ web_default_cert() { return } -WEB_CERT_HOOK=/etc/letsencrypt/renewal-hooks/deploy/lss-deploy - # web_cert_placeholder NAME: until NAME has a certificate of its own, its directory holds a copy of the default # certificate, so a site can point at WEB_TLS_DIR/NAME from the start and keeps that path after the real one arrives. web_cert_placeholder() { @@ -197,8 +200,10 @@ web_cert_issue() { for domain in "$@"; do args+=(-d "${domain}") done + local -a env=() + [[ ! -s ${WEB_ACME_CA} ]] || env=("REQUESTS_CA_BUNDLE=${WEB_ACME_CA}") log_info "Requesting a certificate for $*" - log_command certbot certonly --cert-name "${name}" "${args[@]}" || + log_command env "${env[@]}" certbot certonly --cert-name "${name}" "${args[@]}" || die "tls: the ACME server refused a certificate for $*; the names must resolve to this server and port 80 must be reachable." files_note_change "certificate ${name}" fi diff --git a/libexec/lss-guard b/libexec/lss-guard index 67b4cc2..cd27cfc 100755 --- a/libexec/lss-guard +++ b/libexec/lss-guard @@ -9,7 +9,7 @@ # Checks: lss verify of every installed module (failed modules and files changed outside lss are configured again # when GUARD_AUTOFIX=true), failed systemd units, disk space, pending security updates, vulnerable packages (debsecan # on Debian, dnf updateinfo on the RHEL family), failed SSH logins and active bans, the results of the daily AIDE, -# rkhunter and ClamAV runs, the Lynis hardening index. +# rkhunter and ClamAV runs, the Lynis hardening index, certificates that expire soon. # Every finding goes to the journal (identifier lss-guard); a summary goes to the webhook and the e-mail address. # Notes (logins, bans) are part of the summary but do not make the run fail. set -Eeuo pipefail @@ -19,6 +19,7 @@ CONF=${LSS_GUARD_CONF:-/etc/lss/guard.conf} LSS=${LSS_GUARD_LSS:-/usr/local/sbin/lss} STATE_DIR=${LSS_STATE_DIR:-/var/lib/lss} OS_RELEASE=${LSS_OS_RELEASE:-/etc/os-release} +TLS_DIR=${LSS_TLS_DIR:-/etc/lss/tls} # Global also when the script is sourced inside a function (the unit tests). declare -ga FINDINGS=() FIXED=() NOTES=() @@ -246,6 +247,22 @@ check_lynis() { fi } +# Certificates below the TLS directory that expire within 14 days: certbot renews 30 days ahead, so this means the +# renewal fails (DNS, port 80, rate limit). +check_certificates() { + local cert name end + for cert in "${TLS_DIR}"/*/fullchain.pem; do + [[ -f ${cert} ]] || continue + if ! openssl x509 -checkend $((14 * 86400)) -noout -in "${cert}" >/dev/null 2>&1; then + name=${cert%/fullchain.pem} + name=${name##*/} + end=$(openssl x509 -enddate -noout -in "${cert}" 2>/dev/null | cut -d '=' -f 2-) + finding "Certificate ${name} expires ${end:-soon}; renewal does not work (journalctl -u certbot*)." + fi + done + return +} + json_escape() { local text=$1 text=${text//\\/\\\\} @@ -293,6 +310,7 @@ main() { check_bans check_scanners check_lynis + check_certificates local summary host host=$(hostname -f 2>/dev/null || hostname) if [[ ${#FINDINGS[@]} -eq 0 && ${#FIXED[@]} -eq 0 ]]; then diff --git a/modules/web/tls.sh b/modules/web/tls.sh new file mode 100644 index 0000000..cea674c --- /dev/null +++ b/modules/web/tls.sh @@ -0,0 +1,157 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +MOD_NAME="tls" +MOD_GROUP="web" +MOD_DESCRIPTION="Certificates from Let's Encrypt (or another ACME server) for every site, renewed by a timer and installed for the web server" +MOD_REQUIRES="nginx|apache|caddy" +MOD_OPTIONS="domains||Host names of this server, separated by spaces: they get a certificate that replaces the self-signed default (admin port, unknown names); the names must point to this server +email||Address for the ACME account (optional) +server|https://acme-v02.api.letsencrypt.org/directory|ACME directory URL (Let's Encrypt staging: https://acme-staging-v02.api.letsencrypt.org/directory) +ca_bundle||CA certificate to trust for a private ACME server (Pebble, step-ca); empty for Let's Encrypt" + +TLS_CLI_INI=/etc/letsencrypt/cli.ini + +_tls_unit() { + if os_is_debian; then printf 'certbot'; else printf 'certbot-renew'; fi + return +} + +mod_check() { + local domain email server bundle + for domain in $(opt domains); do + [[ ${domain} =~ ^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z][a-z0-9-]*[a-z0-9]$ ]] || + die "LSS_TLS_DOMAINS: '${domain}' is not a host name like www.example.com." + done + email=$(opt email) + [[ -z ${email} || ${email} =~ ^[^@[:space:]]+@[^@[:space:]]+\.[^@[:space:]]+$ ]] || + die "LSS_TLS_EMAIL '${email}' is not an e-mail address." + server=$(opt server) + [[ ${server} =~ ^https://[^[:space:]]+$ ]] || die "LSS_TLS_SERVER must be an https:// URL." + bundle=$(opt ca_bundle) + [[ -z ${bundle} || -s ${bundle} ]] || die "LSS_TLS_CA_BUNDLE: ${bundle} does not exist." + return +} + +mod_install() { + pkg_enable_epel + pkg_install certbot openssl + return +} + +# Settings for every certbot run, including the renewals of the timer: webroot challenges below WEB_ACME_ROOT (every +# web server serves it on port 80 for every host name), ECDSA keys, no questions. +_tls_cli_ini() { + cat <"\${dir}/combined.pem.new" +chgrp ${WEB_GROUP} "\${dir}/combined.pem.new" +chmod 0640 "\${dir}/combined.pem.new" +for file in privkey.pem fullchain.pem combined.pem; do + mv -f "\${dir}/\${file}.new" "\${dir}/\${file}" +done +for unit in nginx apache2 httpd haproxy; do + if systemctl is-active --quiet "\${unit}"; then + systemctl reload "\${unit}" + fi +done +# Caddy keeps certificates it loaded from files until it restarts. +if systemctl is-active --quiet caddy; then + systemctl restart caddy +fi +EOF + return +} + +mod_configure() { + local bundle + local -a domains + web_group_ensure + install -d -m 0755 /etc/letsencrypt /etc/letsencrypt/renewal-hooks /etc/letsencrypt/renewal-hooks/deploy + _tls_cli_ini | file_write 0644 root:root "${TLS_CLI_INI}" + _tls_hook | file_write 0755 root:root "${WEB_CERT_HOOK}" + bundle=$(opt ca_bundle) + if [[ -n ${bundle} ]]; then + file_write 0644 root:root "${WEB_ACME_CA}" <"${bundle}" + printf '# Managed by lss (module tls).\n[Service]\nEnvironment=REQUESTS_CA_BUNDLE=%s\n' "${WEB_ACME_CA}" | + svc_dropin "$(_tls_unit).service" lss-acme-ca + else + file_remove "${WEB_ACME_CA}" + svc_dropin_remove "$(_tls_unit).service" lss-acme-ca + fi + svc_enable "$(_tls_unit).timer" + read -ra domains <<<"$(opt domains)" + if ((${#domains[@]} > 0)); then + web_cert_issue default "${domains[@]}" + fi + return +} + +mod_verify() { + local live name wanted + local -a domains + svc_is_enabled "$(_tls_unit).timer" || die "tls: $(_tls_unit).timer is not enabled." + [[ -x ${WEB_CERT_HOOK} ]] || die "tls: the deploy hook ${WEB_CERT_HOOK} is missing." + for live in /etc/letsencrypt/live/*/fullchain.pem; do + [[ -f ${live} ]] || continue + name=${live%/fullchain.pem} + name=${name##*/} + cmp -s "${live}" "${WEB_TLS_DIR}/${name}/fullchain.pem" || + die "tls: the certificate ${name} is not installed for the web server (run lss configure tls)." + openssl x509 -checkend $((14 * 86400)) -noout -in "${live}" >/dev/null || + die "tls: the certificate ${name} expires within 14 days; renewal does not work (journalctl -u $(_tls_unit))." + done + read -ra domains <<<"$(opt domains)" + if ((${#domains[@]} > 0)); then + wanted=$(printf '%s\n' "${domains[@]}" | sort -u) + [[ $(web_cert_domains "${WEB_TLS_DIR}/default/fullchain.pem") == "${wanted}" ]] || + die "tls: the default certificate does not cover ${wanted//$'\n'/ }." + fi + return +} + +# Certificates and the ACME account stay unless the removal purges (lss remove --purge tls). +mod_remove() { + svc_disable "$(_tls_unit).timer" + svc_dropin_remove "$(_tls_unit).service" lss-acme-ca + file_remove "${WEB_CERT_HOOK}" + file_remove "${TLS_CLI_INI}" + file_remove "${WEB_ACME_CA}" + pkg_remove certbot + if [[ ${LSS_PURGE:-0} == 1 ]]; then + file_remove /etc/letsencrypt + fi + return +} diff --git a/modules/web/vhost.sh b/modules/web/vhost.sh new file mode 100644 index 0000000..fcfb05a --- /dev/null +++ b/modules/web/vhost.sh @@ -0,0 +1,126 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +MOD_NAME="vhost" +MOD_GROUP="web" +MOD_DESCRIPTION="Web sites for the installed web server: static files, PHP or a reverse proxy, HTTPS with a certificate per site" +MOD_REQUIRES="nginx|apache|caddy" +MOD_OPTIONS="sites||Sites, separated by spaces: NAME:static|php|proxy:DOMAIN[,DOMAIN...][:TARGET]. TARGET is the document root (default /var/www/NAME) or, for proxy, the backend URL such as http://127.0.0.1:3000. PHP sites need the php module, certificates the tls module" + +VHOST_ROOT=/var/www + +# _vhost_parse SPEC: sets VHOST_NAME, VHOST_KIND, VHOST_TARGET and VHOST_DOMAINS (array). +_vhost_parse() { + local spec=$1 domains rest domain + IFS=':' read -r VHOST_NAME VHOST_KIND domains rest <<<"${spec}" + [[ ${VHOST_NAME} =~ ^[a-z0-9][a-z0-9-]*$ ]] || die "LSS_VHOST_SITES: '${spec}' does not start with a site name (a-z, 0-9, -)." + case ${VHOST_KIND} in + static | php) + VHOST_TARGET=${rest:-${VHOST_ROOT}/${VHOST_NAME}} + [[ ${VHOST_TARGET} =~ ^/[A-Za-z0-9._/-]+$ && ${VHOST_TARGET} != *..* ]] || + die "LSS_VHOST_SITES: '${VHOST_TARGET}' is not an absolute directory." + ;; + proxy) + VHOST_TARGET=${rest} + [[ ${VHOST_TARGET} =~ ^https?://[A-Za-z0-9.:\[\]-]+(/[A-Za-z0-9._~/-]*)?$ ]] || + die "LSS_VHOST_SITES: the proxy site ${VHOST_NAME} needs a backend URL like http://127.0.0.1:3000." + ;; + *) die "LSS_VHOST_SITES: '${VHOST_KIND}' in '${spec}' is not static, php or proxy." ;; + esac + IFS=',' read -ra VHOST_DOMAINS <<<"${domains}" + ((${#VHOST_DOMAINS[@]} > 0)) || die "LSS_VHOST_SITES: the site ${VHOST_NAME} has no domain." + for domain in "${VHOST_DOMAINS[@]}"; do + [[ ${domain} =~ ^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z][a-z0-9-]*[a-z0-9]$ ]] || + die "LSS_VHOST_SITES: '${domain}' is not a host name like www.example.com." + done + return +} + +mod_check() { + local spec + local -A seen=() + for spec in $(opt sites); do + _vhost_parse "${spec}" + [[ -z ${seen[${VHOST_NAME}]+x} ]] || die "LSS_VHOST_SITES: the site name ${VHOST_NAME} appears twice." + seen[${VHOST_NAME}]=1 + done + return +} + +mod_install() { + pkg_install curl openssl + return +} + +# A new document root gets a start page, so the site answers right away; existing content is never touched. +_vhost_docroot() { + local name=$1 root=$2 + [[ -d ${root} ]] && return 0 + install -d -m 0755 "${root}" + file_write 0644 root:root "${root}/index.html" < +${name} +

${name}

This site is ready. Put its files into ${root}.

+EOF + return +} + +mod_configure() { + local spec name previous current='' + local -A wanted=() + for spec in $(opt sites); do + _vhost_parse "${spec}" + [[ ${VHOST_KIND} != php ]] || state_installed php || + die "vhost: the site ${VHOST_NAME} runs PHP; install the php module first (lss install php)." + [[ ${VHOST_KIND} == proxy ]] || _vhost_docroot "${VHOST_NAME}" "${VHOST_TARGET}" + web_cert_placeholder "${VHOST_NAME}" + web_site_write "${VHOST_NAME}" "${VHOST_KIND}" "${VHOST_TARGET}" "${VHOST_DOMAINS[@]}" + wanted[${VHOST_NAME}]=1 + current+="${VHOST_NAME} " + done + # Sites that were removed from LSS_VHOST_SITES since the last run. + previous=$(state_get vhost sites) + for name in ${previous}; do + [[ -n ${wanted[${name}]+x} ]] || web_site_remove "${name}" + done + state_set vhost sites "${current% }" + if [[ $(files_changes_of vhost) != 0 ]]; then + web_reload + fi + if state_installed tls; then + for spec in $(opt sites); do + _vhost_parse "${spec}" + web_cert_issue "${VHOST_NAME}" "${VHOST_DOMAINS[@]}" + done + fi + return +} + +mod_verify() { + local spec status served + for spec in $(opt sites); do + _vhost_parse "${spec}" + [[ ${VHOST_KIND} == proxy || -d ${VHOST_TARGET} ]] || die "vhost: the document root ${VHOST_TARGET} is missing." + # Any HTTP answer counts: a proxy site answers 502 while its backend is down, which is the backend's business. + status=$(curl -ks --max-time 10 -o /dev/null -w '%{http_code}' \ + --resolve "${VHOST_DOMAINS[0]}:443:127.0.0.1" "https://${VHOST_DOMAINS[0]}/" || true) + [[ ${status} =~ ^[1-5][0-9][0-9]$ ]] || die "vhost: https://${VHOST_DOMAINS[0]}/ does not answer on this server." + served=$(openssl s_client -connect 127.0.0.1:443 -servername "${VHOST_DOMAINS[0]}" /dev/null | + openssl x509 -noout -fingerprint -sha256 2>/dev/null || true) + [[ ${served} == "$(openssl x509 -noout -fingerprint -sha256 -in "${WEB_TLS_DIR}/${VHOST_NAME}/fullchain.pem")" ]] || + die "vhost: ${VHOST_DOMAINS[0]} is not served with the certificate in ${WEB_TLS_DIR}/${VHOST_NAME}." + done + return +} + +# Document roots and their content stay; the configuration and certificates of the sites go. +mod_remove() { + local name + for name in $(state_get vhost sites); do + web_site_remove "${name}" + file_remove "${WEB_TLS_DIR}/${name}" + done + web_reload + return +} diff --git a/tests/unit/guard.bats b/tests/unit/guard.bats index 77e4208..915ecf9 100644 --- a/tests/unit/guard.bats +++ b/tests/unit/guard.bats @@ -122,3 +122,16 @@ journalctl() { grep -q -- '_COMM=sshd _COMM=sshd-session _UID=0' "${STUB_LOG}" run ! grep -q -- '-t sshd' "${STUB_LOG}" } + +@test "a certificate close to its end is a finding, a fresh one is not" { + export LSS_TLS_DIR=${BATS_TEST_TMPDIR}/tls + TLS_DIR=${LSS_TLS_DIR} + mkdir -p "${TLS_DIR}/old" "${TLS_DIR}/fresh" + openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -days 3 -subj /CN=old.test \ + -keyout "${TLS_DIR}/old/privkey.pem" -out "${TLS_DIR}/old/fullchain.pem" 2>/dev/null + openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -days 90 -subj /CN=fresh.test \ + -keyout "${TLS_DIR}/fresh/privkey.pem" -out "${TLS_DIR}/fresh/fullchain.pem" 2>/dev/null + check_certificates + [ "${#FINDINGS[@]}" -eq 1 ] + [[ ${FINDINGS[0]} == "Certificate old expires "* ]] +} From 093b761a7b1c395a2472641f686de25b6aab4993 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Tue, 13 Oct 2026 21:17:10 +0200 Subject: [PATCH 05/92] Add the haproxy module for balanced TCP services HAProxy balances services on their own ports (80 and 443 stay with the web server) with health checks, a statistics page on 127.0.0.1, a sandbox, SELinux's haproxy_connect_any where needed and firewall rules that follow LSS_HAPROXY_SERVICES. The vhost backend URL check now accepts IPv6 addresses. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- modules/web/haproxy.sh | 189 +++++++++++++++++++++++++++++++++++++++++ modules/web/vhost.sh | 5 +- 2 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 modules/web/haproxy.sh diff --git a/modules/web/haproxy.sh b/modules/web/haproxy.sh new file mode 100644 index 0000000..fc1380d --- /dev/null +++ b/modules/web/haproxy.sh @@ -0,0 +1,189 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +MOD_NAME="haproxy" +MOD_GROUP="web" +MOD_DESCRIPTION="HAProxy load balancer for TCP services (databases, mail, APIs): health checks, failover, statistics on localhost" +MOD_REQUIRES="system" +MOD_OPTIONS="services||Balanced services, separated by spaces: NAME:PORT[/public|vpn|local]:HOST:PORT[,HOST:PORT...], for example pg:5432/vpn:10.0.0.2:5432,10.0.0.3:5432 (default scope public) +balance|leastconn|How connections are spread: leastconn, roundrobin or source (the same client to the same server) +stats_port|8404|Port of the statistics page on 127.0.0.1" + +HAPROXY_CONF=/etc/haproxy/haproxy.cfg +HAPROXY_SOCKET=/var/lib/haproxy/admin.sock + +# _haproxy_parse SPEC: sets HAPROXY_NAME, HAPROXY_PORT, HAPROXY_SCOPE and HAPROXY_SERVERS (array of HOST:PORT). +_haproxy_parse() { + local spec=$1 listen servers server + HAPROXY_NAME=${spec%%:*} + listen=${spec#*:} + servers=${listen#*:} + listen=${listen%%:*} + HAPROXY_PORT=${listen%%/*} + HAPROXY_SCOPE=public + [[ ${listen} != */* ]] || HAPROXY_SCOPE=${listen#*/} + [[ ${HAPROXY_NAME} =~ ^[a-z0-9][a-z0-9-]*$ ]] || die "LSS_HAPROXY_SERVICES: '${spec}' does not start with a name (a-z, 0-9, -)." + if [[ ! ${HAPROXY_PORT} =~ ^[0-9]+$ ]] || ((HAPROXY_PORT < 1 || HAPROXY_PORT > 65535)); then + die "LSS_HAPROXY_SERVICES: '${HAPROXY_PORT}' in '${spec}' is not a port." + fi + [[ ${HAPROXY_SCOPE} =~ ^(public|vpn|local)$ ]] || die "LSS_HAPROXY_SERVICES: '${HAPROXY_SCOPE}' is not public, vpn or local." + IFS=',' read -ra HAPROXY_SERVERS <<<"${servers}" + if [[ ${servers} == "${spec#*:}" ]] || ((${#HAPROXY_SERVERS[@]} == 0)); then + die "LSS_HAPROXY_SERVICES: the service ${HAPROXY_NAME} has no servers (HOST:PORT)." + fi + for server in "${HAPROXY_SERVERS[@]}"; do + [[ ${server} =~ ^([A-Za-z0-9.-]+|\[[0-9A-Fa-f:]+\]):[0-9]+$ ]] || + die "LSS_HAPROXY_SERVICES: '${server}' is not HOST:PORT (IPv6 as [address]:port)." + done + return +} + +mod_check() { + local spec stats + local -A ports=() + [[ $(opt balance) =~ ^(leastconn|roundrobin|source)$ ]] || die "LSS_HAPROXY_BALANCE must be leastconn, roundrobin or source." + stats=$(opt_int stats_port) + ((stats >= 1 && stats <= 65535)) || die "LSS_HAPROXY_STATS_PORT must be a port." + ports[${stats}]=stats + for spec in $(opt services); do + _haproxy_parse "${spec}" + [[ -z ${ports[${HAPROXY_PORT}]+x} ]] || die "LSS_HAPROXY_SERVICES: port ${HAPROXY_PORT} is used twice." + ports[${HAPROXY_PORT}]=${HAPROXY_NAME} + done + return +} + +mod_install() { + pkg_install haproxy curl + return +} + +_haproxy_bind() { + local port=$1 scope=$2 + if [[ ${scope} == local ]]; then + printf '\tbind 127.0.0.1:%s\n' "${port}" + elif web_ipv6; then + printf '\tbind :::%s v4v6\n' "${port}" + else + printf '\tbind 0.0.0.0:%s\n' "${port}" + fi + return +} + +_haproxy_conf() { + local spec server i + cat </dev/null 2>&1 || return 0 + [[ $(getenforce 2>/dev/null) == Enforcing ]] || return 0 + if [[ $(getsebool haproxy_connect_any 2>/dev/null) != *' on' ]]; then + setsebool -P haproxy_connect_any 1 + files_note_change "SELinux boolean haproxy_connect_any" + fi + return +} + +# The firewall rules follow LSS_HAPROXY_SERVICES: rules of services that were removed or moved are closed first. +_haproxy_firewall() { + local spec registry wanted='' + for spec in $(opt services); do + _haproxy_parse "${spec}" + wanted+="${HAPROXY_PORT}/tcp:${HAPROXY_SCOPE}"$'\n' + done + registry="${LSS_STATE_DIR:-/var/lib/lss}/firewall/haproxy" + if [[ -f ${registry} && $(sort "${registry}") != "$(printf '%s' "${wanted}" | grep -v ':local$' | sort)" ]]; then + fw_remove_module + fi + for spec in ${wanted}; do + fw_allow "${spec}" + done + return +} + +mod_configure() { + local changed=0 + _haproxy_conf | file_write 0644 root:root "${HAPROXY_CONF}" + [[ ${LSS_FILE_CHANGED} == 0 ]] || changed=1 + _haproxy_selinux + _haproxy_firewall + svc_sandbox haproxy.service ReadWritePaths=-/var/lib/haproxy + [[ ${LSS_FILE_CHANGED} == 0 ]] || changed=1 + haproxy -c -q -f "${HAPROXY_CONF}" || die "haproxy: the configuration does not load; see above." + svc_enable haproxy + if [[ ${changed} == 1 ]]; then + svc_reload haproxy + fi + return +} + +mod_verify() { + local spec + svc_is_active haproxy || die "haproxy: the service is not running." + haproxy -c -q -f "${HAPROXY_CONF}" || die "haproxy: the configuration does not load." + svc_wait_port 127.0.0.1 "$(opt_int stats_port)" 15 || die "haproxy: the statistics page does not answer." + curl -fsS --max-time 10 -o /dev/null "http://127.0.0.1:$(opt_int stats_port)/" || + die "haproxy: the statistics page on 127.0.0.1:$(opt_int stats_port) does not load." + for spec in $(opt services); do + _haproxy_parse "${spec}" + svc_wait_port 127.0.0.1 "${HAPROXY_PORT}" 15 || die "haproxy: ${HAPROXY_NAME} does not listen on port ${HAPROXY_PORT}." + done + return +} + +mod_remove() { + svc_disable haproxy + svc_dropin_remove haproxy.service lss-sandbox + fw_remove_module + pkg_remove haproxy + file_remove "${HAPROXY_CONF}" + return +} diff --git a/modules/web/vhost.sh b/modules/web/vhost.sh index fcfb05a..87802d2 100644 --- a/modules/web/vhost.sh +++ b/modules/web/vhost.sh @@ -12,7 +12,7 @@ VHOST_ROOT=/var/www # _vhost_parse SPEC: sets VHOST_NAME, VHOST_KIND, VHOST_TARGET and VHOST_DOMAINS (array). _vhost_parse() { - local spec=$1 domains rest domain + local spec=$1 domains rest domain url IFS=':' read -r VHOST_NAME VHOST_KIND domains rest <<<"${spec}" [[ ${VHOST_NAME} =~ ^[a-z0-9][a-z0-9-]*$ ]] || die "LSS_VHOST_SITES: '${spec}' does not start with a site name (a-z, 0-9, -)." case ${VHOST_KIND} in @@ -23,7 +23,8 @@ _vhost_parse() { ;; proxy) VHOST_TARGET=${rest} - [[ ${VHOST_TARGET} =~ ^https?://[A-Za-z0-9.:\[\]-]+(/[A-Za-z0-9._~/-]*)?$ ]] || + url='^https?://([A-Za-z0-9.-]+|\[[0-9A-Fa-f:]+\])(:[0-9]+)?(/[A-Za-z0-9._~/-]*)?$' + [[ ${VHOST_TARGET} =~ ${url} ]] || die "LSS_VHOST_SITES: the proxy site ${VHOST_NAME} needs a backend URL like http://127.0.0.1:3000." ;; *) die "LSS_VHOST_SITES: '${VHOST_KIND}' in '${spec}' is not static, php or proxy." ;; From 138511a131a6b0ae6060223667548e840e876ef2 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Tue, 13 Oct 2026 21:20:43 +0200 Subject: [PATCH 06/92] Test the web layer and hook the web servers into fail2ban and CrowdSec Unit tests cover the rendering of sites and admin paths for nginx, Apache and Caddy, the HTTP/2 switch at nginx 1.25.1, OCSP stapling and the certificate helpers. Apache proxies keep the request path the same way nginx and Caddy do and upgrade WebSockets. fail2ban and CrowdSec add the web server jails and log sources when they are installed after the web server. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- lib/web.sh | 8 +-- modules/security/crowdsec.sh | 2 + modules/security/fail2ban.sh | 2 + tests/unit/web.bats | 105 +++++++++++++++++++++++++++++++++++ 4 files changed, 113 insertions(+), 4 deletions(-) create mode 100644 tests/unit/web.bats diff --git a/lib/web.sh b/lib/web.sh index 92000e1..ce7159c 100644 --- a/lib/web.sh +++ b/lib/web.sh @@ -418,8 +418,8 @@ _web_body_apache() { cat < - ProxyPass ${target} - ProxyPassReverse ${target} + ProxyPass ${target%/}/${path}/ + ProxyPassReverse ${target%/}/${path}/ RequestHeader set X-Script-Name /${path} EOF diff --git a/modules/security/crowdsec.sh b/modules/security/crowdsec.sh index 6100db9..c467d93 100644 --- a/modules/security/crowdsec.sh +++ b/modules/security/crowdsec.sh @@ -58,6 +58,8 @@ EOF cscli console enroll --name "$(hostname)" "${key}" >/dev/null files_note_change "crowdsec console enrolment" fi + # Web server logs, when a web server is installed (lib/web.sh). + web_ids_configure # A restart, not a reload: the reload (SIGHUP) restarts the local API in the background, and the bouncer and # the checks below would race it. if [[ ${changed} == 1 || $(files_changes_of crowdsec) != 0 ]]; then diff --git a/modules/security/fail2ban.sh b/modules/security/fail2ban.sh index 1400859..e839bd4 100644 --- a/modules/security/fail2ban.sh +++ b/modules/security/fail2ban.sh @@ -81,6 +81,8 @@ bantime = 1w findtime = 1d EOF [[ ${LSS_FILE_CHANGED} == 0 ]] || changed=1 + # Jails for the web server, when one is installed (lib/web.sh). + web_ids_configure # The Debian default jail reads /var/log/auth.log, which does not exist on journal-only systems. fail2ban-client --test >/dev/null 2>&1 || { fail2ban-client --test >&2 || true diff --git a/tests/unit/web.bats b/tests/unit/web.bats new file mode 100644 index 0000000..8868cfe --- /dev/null +++ b/tests/unit/web.bats @@ -0,0 +1,105 @@ +#!/usr/bin/env bats +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# The web layer (lib/web.sh): what the sites, admin paths and headers look like for nginx, Apache and Caddy, and the +# certificate helpers. + +setup() { + load helpers + lss_test_setup + use_os debian-13 + WEB_TLS_DIR=${BATS_TEST_TMPDIR}/tls + mkdir -p "${WEB_TLS_DIR}/default" + NGINX_VERSION=1.26.3 +} + +# shellcheck disable=SC2329 # called by the functions under test +nginx() { + printf 'nginx version: nginx/%s\n' "${NGINX_VERSION}" >&2 +} + +new_cert() { + local dir=$1 days=$2 + shift 2 + local san + san=$(printf 'DNS:%s,' "$@") + mkdir -p "${dir}" + openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -days "${days}" -subj "/CN=$1" \ + -addext "subjectAltName=${san%,}" -keyout "${dir}/privkey.pem" -out "${dir}/fullchain.pem" 2>/dev/null +} + +@test "nginx 1.25.1 and later get the http2 directive, older versions the listen flag" { + NGINX_VERSION=1.26.3 + web_nginx_http2_directive + [ "$(web_nginx_ssl_flags)" = ssl ] + NGINX_VERSION=1.25.1 + web_nginx_http2_directive + NGINX_VERSION=1.22.1 + run ! web_nginx_http2_directive + [ "$(web_nginx_ssl_flags)" = 'ssl http2' ] +} + +@test "an nginx PHP site redirects port 80, keeps ACME challenges there and passes PHP to the lss pool" { + run _web_site_nginx shop php /var/www/shop shop.example.com www.shop.example.com + [ "${status}" -eq 0 ] + [[ ${output} == *'server_name shop.example.com www.shop.example.com;'* ]] + [[ ${output} == *'return 301 https://$host$request_uri;'* ]] + [[ ${output} == *"root ${WEB_ACME_ROOT};"* ]] + [[ ${output} == *'http2 on;'* ]] + [[ ${output} == *"fastcgi_pass unix:${WEB_PHP_SOCKET};"* ]] + [[ ${output} == *'location ~ /\.(?!well-known)'* ]] + [[ ${output} == *'Strict-Transport-Security "max-age=63072000" always;'* ]] +} + +@test "a site uses its own certificate once it has one and the default certificate before" { + run _web_site_nginx blog static /var/www/blog blog.example.com + [[ ${output} == *"ssl_certificate ${WEB_TLS_DIR}/default/fullchain.pem;"* ]] + new_cert "${WEB_TLS_DIR}/blog" 90 blog.example.com + run _web_site_nginx blog static /var/www/blog blog.example.com + [[ ${output} == *"ssl_certificate ${WEB_TLS_DIR}/blog/fullchain.pem;"* ]] +} + +@test "OCSP stapling is only switched on for certificates that name a responder" { + new_cert "${WEB_TLS_DIR}/blog" 90 blog.example.com + run _web_tls_nginx "${WEB_TLS_DIR}/blog" + [[ ${output} != *ssl_stapling* ]] +} + +@test "an Apache proxy site forwards WebSockets and the client address" { + run _web_site_apache app proxy http://127.0.0.1:3000 app.example.com + [ "${status}" -eq 0 ] + [[ ${output} == *'ServerName app.example.com'* ]] + [[ ${output} == *'ProxyPass / http://127.0.0.1:3000/ upgrade=websocket'* ]] + [[ ${output} == *'ProxyPassReverse / http://127.0.0.1:3000/'* ]] + [[ ${output} == *'Header always set X-Content-Type-Options "nosniff"'* ]] +} + +@test "a Caddy site hides dot files and uses the certificate from the TLS directory, not its own ACME client" { + run _web_site_caddy blog static /var/www/blog blog.example.com + [ "${status}" -eq 0 ] + [[ ${output} == *'https://blog.example.com {'* ]] + [[ ${output} == *"tls ${WEB_TLS_DIR}/default/fullchain.pem ${WEB_TLS_DIR}/default/privkey.pem"* ]] + [[ ${output} == *'import lss_hidden'* ]] + [[ ${output} == *'redir https://{host}{uri} permanent'* ]] +} + +@test "site names that could leave the configuration directory are refused" { + run _web_name_check '../etc' + [ "${status}" -ne 0 ] + run _web_name_check 'shop-2' + [ "${status}" -eq 0 ] +} + +@test "the names of a certificate come back sorted, one per line" { + new_cert "${WEB_TLS_DIR}/shop" 90 www.shop.example.com shop.example.com + [ "$(web_cert_domains "${WEB_TLS_DIR}/shop/fullchain.pem")" = $'shop.example.com\nwww.shop.example.com' ] +} + +@test "the admin port is the same for every web server and stays on localhost in the private setup" { + run _web_admin_nginx pma phpmyadmin php /usr/share/phpmyadmin + [[ ${output} == *'location ^~ /phpmyadmin/'* ]] + run _web_admin_caddy pma phpmyadmin php /usr/share/phpmyadmin + [[ ${output} == *'handle_path /phpmyadmin/*'* ]] +} From 88e59085d347d1d2689b7fdb7f0ebf7e7dd1ced2 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Tue, 13 Oct 2026 21:25:55 +0200 Subject: [PATCH 07/92] Test the web modules with Pebble on every system The new integration group web installs each web server alone and together with tls and vhost, with certificates from Pebble (pinned by SHA-256) running inside the target, then HAProxy. run.sh tests comma-joined modules together and removes them in reverse order. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- .github/workflows/integration.yml | 5 ++- lib/web.sh | 2 +- tests/integration/pebble.sh | 65 +++++++++++++++++++++++++++++++ tests/integration/run.sh | 29 ++++++++++---- tests/integration/settings.conf | 6 +++ 5 files changed, 97 insertions(+), 10 deletions(-) create mode 100644 tests/integration/pebble.sh diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 7a08473..e87e567 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -31,7 +31,7 @@ jobs: fail-fast: false matrix: system: [debian-12, debian-13, ubuntu-22.04, ubuntu-24.04, ubuntu-26.04, rocky-9, almalinux-10] - group: [base, security, scanners] + group: [base, security, scanners, web] arch: [x64, arm64] include: - group: base @@ -40,6 +40,9 @@ jobs: modules: ssh-hardening ssh-2fa fail2ban crowdsec password-policy auto-updates guard - group: scanners modules: aide rkhunter clamav lynis + # Each web server alone, then with certificates (Pebble) and sites; then HAProxy. + - group: web + modules: nginx,tls,vhost apache,tls,vhost caddy,tls,vhost haproxy steps: - uses: step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25 # v2.22.1 with: diff --git a/lib/web.sh b/lib/web.sh index ce7159c..7d9e57d 100644 --- a/lib/web.sh +++ b/lib/web.sh @@ -147,7 +147,7 @@ web_cert_has_ocsp() { web_default_cert() { local dir="${WEB_TLS_DIR}/default" name tmp # The tls module's certificate for the host names (lineage "default") takes this place; certbot renews it. - [[ ! -s /etc/letsencrypt/live/default/fullchain.pem ]] || return 0 + [[ ! -s /etc/letsencrypt/live/default/fullchain.pem || ! -s ${dir}/fullchain.pem ]] || return 0 if [[ -s ${dir}/fullchain.pem && -s ${dir}/privkey.pem ]] && openssl x509 -checkend $((30 * 86400)) -noout -in "${dir}/fullchain.pem" >/dev/null 2>&1; then return 0 diff --git a/tests/integration/pebble.sh b/tests/integration/pebble.sh new file mode 100644 index 0000000..53beb6f --- /dev/null +++ b/tests/integration/pebble.sh @@ -0,0 +1,65 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# Pebble, the ACME test server of Let's Encrypt, inside the test target: the tls module gets real certificates from it +# without a public domain. PEBBLE_VA_ALWAYS_VALID skips the challenge check, so any host name works. +# Sourced by run.sh, which provides in_target. Every download is pinned by its SHA-256. + +PEBBLE_VERSION=v2.10.1 +# Commit of the tag v2.10.1, for the CA certificate and the TLS certificate of the server (test files of the release). +PEBBLE_COMMIT=b1e1ca4f3c30abb64111adaca4544bc5374cc306 +PEBBLE_SHA256_amd64=4f2fcb5bca8c85c9cf73ad140fccfc0d2be40bd81ab99879c79b7b8a0b4f70ed +PEBBLE_SHA256_arm64=b53fd072a69eb7692451de4e8b0667e0bdf5cccd7e36fc51b8eaf2fcc135ed9f +PEBBLE_FILES="0c502e52627ff7de972c7e9e065640103b4589b4e2d23d3fcf21bef1e3c8c67c test/certs/pebble.minica.pem +c87fb918d9bac8db11aa54493b5f0cf6e0725a3fe73f617d1c64737e1ab4caf9 test/certs/localhost/cert.pem +0977979255b0e0721c17335c056b627f66a43cf56e5a52f3cda81fb111ecf00e test/certs/localhost/key.pem" +# Where the target keeps Pebble; LSS_TLS_CA_BUNDLE in settings.conf points at the CA certificate here. +PEBBLE_DIR=/etc/lss-pebble + +pebble_start() { + local arch sum work file + case $(in_target 'uname -m') in + x86_64) arch=amd64 ;; + aarch64) arch=arm64 ;; + *) + echo "Pebble has no build for $(in_target 'uname -m')." >&2 + return 1 + ;; + esac + sum=PEBBLE_SHA256_${arch} + work=$(mktemp -d) + curl -fsSL --retry 3 -o "${work}/pebble.tar.gz" \ + "https://github.com/letsencrypt/pebble/releases/download/${PEBBLE_VERSION}/pebble-linux-${arch}.tar.gz" + printf '%s %s\n' "${!sum}" "${work}/pebble.tar.gz" | sha256sum --quiet -c - + tar -xzf "${work}/pebble.tar.gz" -C "${work}" + in_target "install -d -m 0755 ${PEBBLE_DIR} && cat > ${PEBBLE_DIR}/pebble && chmod 0755 ${PEBBLE_DIR}/pebble" \ + <"${work}/pebble-linux-${arch}/linux/${arch}/pebble" + while read -r sum file; do + curl -fsSL --retry 3 -o "${work}/${file##*/}" "https://raw.githubusercontent.com/letsencrypt/pebble/${PEBBLE_COMMIT}/${file}" + printf '%s %s\n' "${sum}" "${work}/${file##*/}" | sha256sum --quiet -c - + in_target "cat > ${PEBBLE_DIR}/${file##*/}" <"${work}/${file##*/}" + done <<<"${PEBBLE_FILES}" + rm -rf -- "${work}" + in_target "cat > ${PEBBLE_DIR}/config.json" <= 0; i--)); do + in_target "lss --yes --no-color remove ${set[i]}" + in_target "! lss status | grep -q '^${set[i]} '" + done fi group_end done diff --git a/tests/integration/settings.conf b/tests/integration/settings.conf index 0d4ce0c..f73307f 100644 --- a/tests/integration/settings.conf +++ b/tests/integration/settings.conf @@ -10,3 +10,9 @@ LSS_SYSTEM_HOSTNAME=lss-test LSS_SYSTEM_UPGRADE=false # The GitHub runner VM carries tens of GB of toolchains; AIDE would hash them all. Missing paths are harmless. LSS_AIDE_EXCLUDE=/opt /usr/local /usr/share/dotnet /usr/share/swift /usr/share/miniconda /usr/lib/jvm /usr/lib/google-cloud-sdk /home /snap /var/lib/snapd +# Web group: certificates from Pebble inside the target (tests/integration/pebble.sh), which accepts any host name. +LSS_TLS_SERVER=https://localhost:14000/dir +LSS_TLS_CA_BUNDLE=/etc/lss-pebble/pebble.minica.pem +LSS_TLS_DOMAINS=lss-test.example.com +LSS_VHOST_SITES=demo:static:demo.example.com,www.demo.example.com app:proxy:app.example.com:http://127.0.0.1:9 +LSS_HAPROXY_SERVICES=echo:9100/local:127.0.0.1:9,127.0.0.1:10 From 5037374ed6e2f83179d92c625a31edf6aed037ae Mon Sep 17 00:00:00 2001 From: VectoDE Date: Tue, 13 Oct 2026 21:29:56 +0200 Subject: [PATCH 08/92] Document the web modules Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- CHANGELOG.md | 3 +++ README.md | 21 +++++++++++++++++++-- docs/hardening.md | 3 +++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 919f403..86c212c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,9 @@ now or later. `rkhunter`, `clamav`, `lynis`, `usbguard` and `guard`, which checks the server on a timer, repairs drift and sends notifications. - Profile `minimal-secure`. +- Web modules: `nginx`, `apache` and `caddy` (one of them owns ports 80 and 443), `tls` (certbot with a deploy hook + that installs every certificate for the web server and reloads it), `vhost` (static, PHP and reverse-proxy sites, + each with its own certificate) and `haproxy` (load balancing of TCP services with health checks). - A warning for every setting that no module reads, so a misspelt key does not silently fall back to the default. - Quiet output: the output of apt, dnf and long-running tools such as the AIDE database build goes to the run log, and appears on the terminal only with `--verbose` or, with its last 40 lines, when the command fails. diff --git a/README.md b/README.md index 83d4e74..a3bb08f 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,8 @@ LSS_SSH_HARDENING_PORT=2222 LSS_TIME_TIMEZONE=Europe/Berlin LSS_FIREWALL_ALLOW=8443/tcp 51820/udp LSS_GUARD_WEBHOOK=https://ntfy.sh/your-topic +LSS_TLS_DOMAINS=server.example.com +LSS_VHOST_SITES=shop:php:shop.example.com,www.shop.example.com api:proxy:api.example.com:http://127.0.0.1:3000 ``` `--set` wins over the environment, which wins over the file, which wins over the profile, which wins over the @@ -133,9 +135,24 @@ change, run `lss configure MODULE`. (AppArmor or SELinux), `auditd`, `auto-updates`, `password-policy`, `aide`, `rkhunter`, `clamav`, `lynis`, `usbguard`, `guard`. +**Web:** `nginx`, `apache` or `caddy`, `tls` (certificates from Let's Encrypt), `vhost` (sites), `haproxy`. + The [hardening guide](docs/hardening.md) lists what each security module changes, the CIS Benchmark areas it covers -and where lss deliberately deviates. Web servers, runtimes, databases, containers, backup, monitoring and mail follow -as further module groups. +and where lss deliberately deviates. Containers, backup, monitoring and mail follow as further module groups. + +### Web sites + +One web server owns ports 80 and 443. It answers only for the sites you set up; requests for any other host name get +no answer, and port 80 serves nothing but ACME challenges and redirects to HTTPS. A site is one entry in +`LSS_VHOST_SITES`: a name, `static`, `php` or `proxy`, its host names and, if needed, a document root or backend URL. +With the `tls` module each site gets its own certificate from Let's Encrypt; until it arrives, and without `tls`, the +site uses a self-signed certificate. The names must point to the server and port 80 must be reachable from the +internet for Let's Encrypt to issue one. + +Admin interfaces such as phpMyAdmin listen on `127.0.0.1:8090`. Reach them through an SSH tunnel +(`ssh -L 8090:127.0.0.1:8090 server`, then `http://localhost:8090/phpmyadmin/`) or set +`LSS__ADMIN_PUBLIC=true` to publish port 8090 with TLS and the password from +`lss secrets show web-admin-password`. ## Guard diff --git a/docs/hardening.md b/docs/hardening.md index 0469456..9fd091c 100644 --- a/docs/hardening.md +++ b/docs/hardening.md @@ -32,6 +32,9 @@ Docker, databases and its own sudo rules) 78. CI checks that it stays at 82 on D | `logging` | Persistent journal with size and age limits, optional forwarding to a syslog server over TLS | Logging | | `time` | chrony with NTS-authenticated servers, no NTP port open | Time synchronisation | | `guard` | Runs `lss verify` every day, repairs modules that failed or were changed outside lss, reports failed units, full disks, pending security updates, vulnerable packages, a needed reboot, a low Lynis index, AIDE/rkhunter/ClamAV findings and failed SSH logins; notes logins and bans | (operations) | +| `nginx`, `apache`, `caddy` | One web server owns ports 80 and 443: HTTPS only with TLS 1.2 and 1.3 (Mozilla intermediate, ECDHE only), HSTS for two years, `nosniff`, `SAMEORIGIN` framing, a strict referrer and permissions policy, no version banner; unknown host names get no answer; dot files are never served; admin interfaces only on 127.0.0.1:8090 unless `LSS__ADMIN_PUBLIC=true` (then TLS and a password); the service runs in a systemd sandbox; fail2ban or CrowdSec read its logs | CIS Apache/nginx benchmarks, Mozilla TLS guidelines, OWASP Secure Headers | +| `tls` | Certificates from Let's Encrypt (or any ACME server) with ECDSA keys, renewed by a timer; private keys readable by root and the web server group only; OCSP stapling where the certificate names a responder; guard warns 14 days before a certificate expires | Mozilla TLS guidelines | +| `haproxy` | Statistics only on 127.0.0.1, admin socket readable by root only, chroot, systemd sandbox, ports in the firewall only for services that are not `local` | (operations) | ## Deliberate deviations From f7df34c3ec9ed667d96d5bd2dc0fdea6aba3127e Mon Sep 17 00:00:00 2001 From: VectoDE Date: Tue, 13 Oct 2026 21:23:00 +0200 Subject: [PATCH 09/92] Add the php and composer runtime modules php installs PHP-FPM with the common extensions from the distribution (on RHEL 9 from its newest module stream) and runs one pool "lss" as its own user on WEB_PHP_SOCKET (root:lss-web, 0660). The distribution's www pool is switched off, php.ini settings are hardened in a drop-in, dangerous functions are disabled for the web pool only, and the FPM unit runs in the lss sandbox. LSS_PHP_VERSION can ask for another version, which Debian and Ubuntu get from Ondrej Sury's repositories (pinned keys). verify sends a real FastCGI request over the socket. composer installs a checksum-verified composer.phar (2.10.3) as /usr/local/bin/composer. Refs #4 Signed-off-by: VectoDE Co-Authored-By: Claude Opus 5.5 --- data/checksums.txt | 9 + data/keys/ondrej-php.asc | 29 +++ data/keys/sury-php.asc | 71 +++++++ data/repos/ondrej-php.conf | 14 ++ data/repos/sury-php.conf | 13 ++ modules/runtime/composer.sh | 49 +++++ modules/runtime/php.sh | 377 ++++++++++++++++++++++++++++++++++++ templates/php/fcgi-ping.php | 64 ++++++ templates/php/lss.ini | 35 ++++ templates/php/pool.conf | 40 ++++ 10 files changed, 701 insertions(+) create mode 100644 data/checksums.txt create mode 100644 data/keys/ondrej-php.asc create mode 100644 data/keys/sury-php.asc create mode 100644 data/repos/ondrej-php.conf create mode 100644 data/repos/sury-php.conf create mode 100644 modules/runtime/composer.sh create mode 100644 modules/runtime/php.sh create mode 100644 templates/php/fcgi-ping.php create mode 100644 templates/php/lss.ini create mode 100644 templates/php/pool.conf diff --git a/data/checksums.txt b/data/checksums.txt new file mode 100644 index 0000000..b602781 --- /dev/null +++ b/data/checksums.txt @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# Direct downloads (lib/fetch.sh): . Every checksum is the one the vendor publishes; +# the source of each is noted above its lines. Raising a version means replacing the URL and the checksum together. + +# Composer 2.10.3: https://getcomposer.org/download/2.10.3/composer.phar.sha256sum (2026-10-13). +composer any 7a2d379d5b8ffdaa028580ef26494c36d2feef4b178d3dd1473a4dbc5e17c8d6 https://getcomposer.org/download/2.10.3/composer.phar diff --git a/data/keys/ondrej-php.asc b/data/keys/ondrej-php.asc new file mode 100644 index 0000000..962aecd --- /dev/null +++ b/data/keys/ondrej-php.asc @@ -0,0 +1,29 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBGYo0vEBEAC0Semxy5I2b8exRUxJfTKkHR4f5uyS0dTd9vYgMI5T3gsa7ypH +HtE+GiZC+T9m/F9h66+XJMxhuNsKRs7T2In5NSeso9H/ytlSTayUaBtCFfRp6y6b +6ozuRBfqYJGxhjAnIzvNF/Wpp2BvfQm3OrQ7uJJrt5IvzLDC4jPxl/Xs3sTT+Hbk +bkKKprZ3xmy2enuwBaNWR/CUtAz3hbkzL1kGbhX9m3QidFJagVVdDw3aNEwo8ush +djWfF+BajNvpDFYJKBGQbCeagB753Baa5yIN62x+THLnLiKTMDS1e7U0ZDiV9671 +noTbtN5TeZeyfsEmeZ8X60x11JIP3yYHYZT70/DyTYX3WC9yQFyIgVOfRlGklMKI +k3TLMmtq8w5Hz1vovwzV7PzaQnmY+uNP2ZbAP4fJ3iFAj0L+u0i1nOFgTy0Lq058 +O/FjRrQxuceDDCF+9ThspXMw3Puvz8giuBDCdEda84uC7XWMdqgz/maLfFQjAmyP +Ixi1EMxMlHYyZajpR1cdCfrAIQlnQjHSWmyeCFgXPPfRA71aCcJ7oSrDjogW6Ahd +HRkQRKf1FF9BFzycgSQotfR+7CKfPQh1kghufM9W/spARzA709nGZjXJzgEJLQd3 +CDB6dIIxT/0YI36h3Qgfmiiw4twO24MMEqEEPIELz2WJKeWGkdQdcekpxQARAQAB +tB9MYXVuY2hwYWQgUFBBIGZvciBPbmTFmWVqIFN1csO9iQJOBBMBCgA4FiEEuNx+ +U5RmVu+85MHdcdrqq0rUyrYFAmYo0vECGwMFCwkIBwIGFQoJCAsCBBYCAwECHgEC +F4AACgkQcdrqq0rUyrYOPQ/+IArA4s1J3op/w7cXek0ieFHWHFDrxPYS+78/LF/J +LoYZw0nIU5Ovr+LzehFMIQU6esgPXwbeCVgwLwat57augAkAYWT0UzH5dE6RKAGr +C2vsHWVfPhQn6UndfzwXc0mTLGQni25aQaZ6k60Dbm/vblejrTQrtAUWoMO3Z1cr +NDGJ3Z9DCxtr2o9gRYUI6HwLHJtobTIeI5xsr5x+GvXiIAVCPa3ZEuRL6jMQfqfS +C43mpuiS1kGgsnQLs2DbN7EFCfiJoNX1QzZu25zg+IS9PXbCJnheZWnH0rwUSb/N +hZPcSefGlNlhr824OfT30v79hQnw59XbsfV270O9jPbD4kttN+OiszbU66zsuiOh +BO46XCckQPqDkBMw56GPFuVrQgGb1thXvn67URJgPyJhwauBWKPNAJ9Ojuo+yVq/ +hdR1VNWThXQbZgaGSWrbjt6FdYtQb9VX88uu5gFDmr180HogHNUDUcqNLLdnjfFs +4DyJlusQ5I/a7cQ7nlkNgxAmHszwO/mGLBuGljDUYkwZDW9nqP1Q5Q2jMtrhgXvR +2SOtufvecUbB7+eoRSaOnu7CNMATG6LocFEMzhKUde1uZTfWSqnYEcdqoFJMi46y +qaNxhiNLsQ5OBMbgSp2zCbQxRBdITMVvBR5YjCetUIGEs6T1yQ5wh5Xpoi34ShHn +v38= +=kFlZ +-----END PGP PUBLIC KEY BLOCK----- diff --git a/data/keys/sury-php.asc b/data/keys/sury-php.asc new file mode 100644 index 0000000..08f3973 --- /dev/null +++ b/data/keys/sury-php.asc @@ -0,0 +1,71 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +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 +dIWqjhhcyJCHsdyUkP0= +=uUxL +-----END PGP PUBLIC KEY BLOCK----- diff --git a/data/repos/ondrej-php.conf b/data/repos/ondrej-php.conf new file mode 100644 index 0000000..aa6caf6 --- /dev/null +++ b/data/repos/ondrej-php.conf @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# PHP packages by Ondřej Surý for Ubuntu (Launchpad PPA ondrej/php), used only when LSS_PHP_VERSION asks for a PHP +# version that Ubuntu does not ship. Fingerprint from the Launchpad API +# (https://api.launchpad.net/1.0/~ondrej/+archive/ubuntu/php, signing_key_fingerprint), key fetched from +# https://keyserver.ubuntu.com on 2026-10-13. The PPA builds for Ubuntu 22.04 and 24.04; it has no 26.04 suite yet, +# so Ubuntu 26.04 gets only its own PHP 8.5 (noble builds need libraries 26.04 no longer has). +KEY=ondrej-php.asc +FINGERPRINTS=B8DC7E53946656EFBCE4C1DD71DAEAAB4AD4CAB6 +DEB_URL=https://ppa.launchpadcontent.net/ondrej/php/ubuntu +DEB_SUITE={{codename}} +DEB_COMPONENTS=main diff --git a/data/repos/sury-php.conf b/data/repos/sury-php.conf new file mode 100644 index 0000000..8d2888a --- /dev/null +++ b/data/repos/sury-php.conf @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# PHP packages by Ondřej Surý for Debian (https://deb.sury.org), used only when LSS_PHP_VERSION asks for a PHP version +# that Debian does not ship. Key fetched from https://packages.sury.org/php/apt.gpg on 2026-10-13 and stored ASCII +# armoured; the repository's InRelease files verify against it. The key expires in February 2028; sury publishes its +# successor at the same URL. +KEY=sury-php.asc +FINGERPRINTS=15058500A0235D97F5D10063B188E2B695BD4743 +DEB_URL=https://packages.sury.org/php +DEB_SUITE={{codename}} +DEB_COMPONENTS=main diff --git a/modules/runtime/composer.sh b/modules/runtime/composer.sh new file mode 100644 index 0000000..5fcfe63 --- /dev/null +++ b/modules/runtime/composer.sh @@ -0,0 +1,49 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +MOD_NAME="composer" +MOD_GROUP="runtime" +MOD_DESCRIPTION="Composer, the PHP dependency manager, as a checksum-verified release in /usr/local/bin" +MOD_REQUIRES="system php" +MOD_OPTIONS="" + +COMPOSER_BIN=/usr/local/bin/composer + +# The pinned release (data/checksums.txt) replaces the binary only when it differs, so "composer self-update" by an +# administrator is undone on the next lss run; raise the pinned version in lss instead. +mod_install() { + local entry sum tmp + # Composer unpacks archives with unzip when PHP has no zip extension (the RHEL family) and git for sources. + # Both are left in place by remove: other modules and the administrator use them too. + pkg_install unzip git + entry=$(fetch_entry composer) || die "composer: data/checksums.txt has no entry for composer." + sum=${entry%% *} + if [[ -f ${COMPOSER_BIN} && ! -L ${COMPOSER_BIN} ]] && [[ $(sha256sum "${COMPOSER_BIN}" | cut -d ' ' -f 1) == "${sum}" ]] && + [[ $(stat -c '%a %U:%G' "${COMPOSER_BIN}") == '755 root:root' ]]; then + return 0 + fi + tmp=$(mktemp -d) + fetch_verified composer "${tmp}/composer.phar" + file_write 0755 root:root "${COMPOSER_BIN}" <"${tmp}/composer.phar" + rm -rf -- "${tmp}" + return +} + +mod_verify() { + local version home status=0 + [[ -x ${COMPOSER_BIN} ]] || die "composer: ${COMPOSER_BIN} is missing." + # Composer refuses to run as root without COMPOSER_ALLOW_SUPERUSER; a throwaway home keeps root's untouched. + home=$(mktemp -d) + version=$(COMPOSER_ALLOW_SUPERUSER=1 COMPOSER_HOME=${home} COMPOSER_NO_INTERACTION=1 \ + "${COMPOSER_BIN}" --version --no-ansi 2>/dev/null) || status=$? + rm -rf -- "${home}" + [[ ${status} == 0 ]] || die "composer: ${COMPOSER_BIN} does not run." + [[ ${version} == Composer* ]] || die "composer: unexpected version output '${version}'." + return +} + +mod_remove() { + file_remove "${COMPOSER_BIN}" + return +} diff --git a/modules/runtime/php.sh b/modules/runtime/php.sh new file mode 100644 index 0000000..3f7c2bc --- /dev/null +++ b/modules/runtime/php.sh @@ -0,0 +1,377 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +MOD_NAME="php" +MOD_GROUP="runtime" +MOD_DESCRIPTION="PHP-FPM with the common extensions and one hardened pool on a Unix socket for the web server" +MOD_REQUIRES="system" +MOD_OPTIONS="version||PHP version such as 8.4; empty takes the distribution's (on RHEL 9 its newest module stream) +memory_limit|256M|Memory a PHP request may use +upload_max_filesize|64M|Largest file a form may upload +post_max_size|64M|Largest request body (at least upload_max_filesize) +max_children|10|Most PHP worker processes at the same time +disable_functions|exec,passthru,shell_exec,system,proc_open,popen,pcntl_exec|Functions the web pool refuses, separated by commas (the command line keeps them); empty allows all +writable_paths|/var/www /srv|Directories PHP may write to (uploads, caches), separated by spaces; the sandbox keeps the rest read-only" + +PHP_USER=lss-php +# Below /var/lib/php, which carries the SELinux label PHP-FPM may write to on the RHEL family. +PHP_SESSIONS=/var/lib/php/lss-sessions +PHP_PING=/lss-ping +PHP_TMPFILES=/etc/tmpfiles.d/lss-php-fpm.conf +# Ubuntu releases the ondrej/php PPA builds for (it has no suite for 26.04 yet). +PHP_PPA_SUITES="jammy noble" + +# _php_bytes SIZE: a php.ini size (64M) in bytes. +_php_bytes() { + local size=$1 number unit + number=${size%[KMG]} + unit=${size#"${number}"} + case ${unit} in + K) printf '%s' "$((number * 1024))" ;; + M) printf '%s' "$((number * 1024 * 1024))" ;; + G) printf '%s' "$((number * 1024 * 1024 * 1024))" ;; + *) printf '%s' "${number}" ;; + esac + return +} + +mod_check() { + local version size path + version=$(opt version) + [[ -z ${version} || ${version} =~ ^[78]\.[0-9]$ ]] || die "LSS_PHP_VERSION must look like 8.4, got '${version}'." + for size in "$(opt memory_limit)" "$(opt upload_max_filesize)" "$(opt post_max_size)"; do + [[ ${size} =~ ^[1-9][0-9]{0,5}[KMG]?$ ]] || die "php: '${size}' is not a size like 256M." + done + (($(_php_bytes "$(opt post_max_size)") >= $(_php_bytes "$(opt upload_max_filesize)"))) || + die "LSS_PHP_POST_MAX_SIZE must be at least LSS_PHP_UPLOAD_MAX_FILESIZE." + (($(opt_int max_children) >= 1)) || die "LSS_PHP_MAX_CHILDREN must be at least 1." + [[ $(opt disable_functions) =~ ^[a-z0-9_,]*$ ]] || die "LSS_PHP_DISABLE_FUNCTIONS must be function names separated by commas." + for path in $(opt writable_paths); do + [[ ${path} =~ ^/[A-Za-z0-9._/-]+$ && ${path} != *..* ]] || die "LSS_PHP_WRITABLE_PATHS: '${path}' is not an absolute path." + done + return +} + +# True when apt can install PACKAGE from the configured sources. +_php_apt_has() { + local package=$1 candidate + candidate=$(apt-cache policy "${package}" 2>/dev/null | awk '$1 == "Candidate:" { print $2 }') + [[ -n ${candidate} && ${candidate} != '(none)' ]] + return +} + +# Ondřej Surý's packages: a repository for Debian, a PPA for Ubuntu. +_php_extra_repo() { + if [[ ${OS_ID} == ubuntu ]]; then + printf 'ondrej-php' + else + printf 'sury-php' + fi + return +} + +# Sets PHP_VERSION on Debian and Ubuntu: the distribution's, or the one asked for (from Ondřej Surý's packages when +# the distribution has another one). +_php_resolve_debian() { + local wanted repo + wanted=$(opt version) + repo=$(_php_extra_repo) + pkg_update_once + if [[ -z ${wanted} ]]; then + # With the extra repository in place, php-fpm would point at its newest PHP instead of the distribution's. + if [[ -e /etc/apt/sources.list.d/lss-${repo}.sources ]]; then + repo_remove "${repo}" + pkg_update + fi + PHP_VERSION=$(apt-cache depends php-fpm 2>/dev/null | + awk '!found && $1 == "Depends:" && $2 ~ /^php[0-9.]+-fpm$/ { sub(/^php/, "", $2); sub(/-fpm$/, "", $2); print $2; found = 1 }') + [[ -n ${PHP_VERSION} ]] || die "php: the package sources offer no php-fpm." + return 0 + fi + if ! _php_apt_has "php${wanted}-fpm"; then + if [[ ${OS_ID} == ubuntu && " ${PHP_PPA_SUITES} " != *" ${OS_CODENAME} "* ]]; then + die "php: PHP ${wanted} is not available for ${OS_NAME}; leave LSS_PHP_VERSION empty for the distribution's PHP." + fi + repo_add "${repo}" + _php_apt_has "php${wanted}-fpm" || die "php: PHP ${wanted} is not available for ${OS_NAME}." + fi + PHP_VERSION=${wanted} + return +} + +# The enabled PHP module stream on the RHEL family, or nothing. +_php_enabled_stream() { + { dnf -q module list --enabled php 2>/dev/null || true; } | awk '$1 == "php" { print $2; exit }' + return +} + +# Installed PHP packages follow a change of the module stream. +_php_sync_stream() { + if pkg_is_installed php-common; then + log_command dnf -y -q distro-sync 'php*' + fi + return +} + +# Sets PHP_VERSION on the RHEL family. RHEL 9 ships PHP 8.0 plus newer versions as module streams; RHEL 10 ships one +# version without streams. +_php_resolve_rhel() { + local wanted streams enabled available + wanted=$(opt version) + streams=$({ dnf -q module list php 2>/dev/null || true; } | awk '$1 == "php" && $2 ~ /^[0-9]+\.[0-9]+$/ { print $2 }' | sort -uV) + if [[ -z ${wanted} && -n ${streams} ]]; then + wanted=$(tail -n 1 <<<"${streams}") + fi + enabled=$(_php_enabled_stream) + if [[ -n ${wanted} ]] && grep -qxF "${wanted}" <<<"${streams}"; then + if [[ ${enabled} != "${wanted}" ]]; then + log_info "Enabling the PHP ${wanted} module stream" + log_command dnf -y -q module reset php + log_command dnf -y -q module enable "php:${wanted}" + files_note_change "enabled module stream php:${wanted}" + _php_sync_stream + fi + elif [[ -n ${enabled} ]]; then + # The version asked for is the one without a stream (PHP 8.0 on RHEL 9). + log_command dnf -y -q module reset php + files_note_change "reset module stream php:${enabled}" + _php_sync_stream + fi + available=$({ dnf -q repoquery --latest-limit 1 --queryformat '%{version}\n' php-fpm 2>/dev/null || true; } | head -n 1) + available=${available%.*} + [[ -n ${available} ]] || die "php: the package sources offer no php-fpm." + if [[ -n ${wanted} && ${wanted} != "${available}" ]]; then + die "php: PHP ${wanted} is not available for ${OS_NAME} (it offers ${available}${streams:+ and the streams $(tr '\n' ' ' <<<"${streams}")})." + fi + PHP_VERSION=${available} + return +} + +# Installs the packages that are missing and remembers them, so remove takes away only what lss installed. +_php_install() { + local -a wanted=("$@") missing=() + mapfile -t missing < <(pkg_missing "${wanted[@]}") + [[ ${#missing[@]} -gt 0 ]] || return 0 + pkg_install "${missing[@]}" + state_set "${LSS_MODULE}" packages "$(state_get "${LSS_MODULE}" packages) ${missing[*]}" + return +} + +mod_install() { + local extension + local -a packages=() + if os_is_debian; then + _php_resolve_debian + for extension in fpm cli curl gd intl mbstring xml zip mysql pgsql bcmath; do + packages+=("php${PHP_VERSION}-${extension}") + done + # PHP 8.5 has OPcache built in; older versions package it separately. + if _php_apt_has "php${PHP_VERSION}-opcache"; then + packages+=("php${PHP_VERSION}-opcache") + fi + else + _php_resolve_rhel + # php-common carries curl. The zip extension is in neither AppStream nor EPEL; composer unpacks with unzip. + packages=(php-fpm php-cli php-common php-gd php-intl php-mbstring php-xml php-mysqlnd php-pgsql php-opcache + php-bcmath php-pdo) + fi + _php_install "${packages[@]}" + state_set "${LSS_MODULE}" php_version "${PHP_VERSION}" + return +} + +# Paths of the installed PHP version (recorded by mod_install). +_php_load() { + PHP_VERSION=$(state_get "${LSS_MODULE}" php_version) + [[ -n ${PHP_VERSION} ]] || die "php: the installed PHP version is unknown; run lss install php." + if os_is_debian; then + PHP_UNIT="php${PHP_VERSION}-fpm" + PHP_FPM="/usr/sbin/php-fpm${PHP_VERSION}" + PHP_CLI="/usr/bin/php${PHP_VERSION}" + PHP_POOL_DIR="/etc/php/${PHP_VERSION}/fpm/pool.d" + PHP_INI="/etc/php/${PHP_VERSION}/fpm/conf.d/99-lss.ini" + else + PHP_UNIT=php-fpm + PHP_FPM=/usr/sbin/php-fpm + PHP_CLI=/usr/bin/php + PHP_POOL_DIR=/etc/php-fpm.d + # Shared with the command line on the RHEL family, which has no FPM-only directory. + PHP_INI=/etc/php.d/99-lss.ini + fi + return +} + +_php_user() { + if ! getent passwd "${PHP_USER}" >/dev/null; then + useradd --system --user-group --no-create-home --home-dir /nonexistent --shell /usr/sbin/nologin \ + --comment "PHP-FPM pool (lss)" "${PHP_USER}" + files_note_change "created user ${PHP_USER}" + fi + if [[ $(stat -c '%U:%G %a' "${PHP_SESSIONS}" 2>/dev/null) != "${PHP_USER}:${PHP_USER} 700" ]]; then + [[ ! -L ${PHP_SESSIONS} ]] || die "php: ${PHP_SESSIONS} is a symbolic link." + install -d -m 0700 -o "${PHP_USER}" -g "${PHP_USER}" "${PHP_SESSIONS}" + if command -v restorecon >/dev/null 2>&1; then + restorecon -F "${PHP_SESSIONS}" 2>/dev/null || true + fi + files_note_change "created ${PHP_SESSIONS}" + fi + return +} + +# The distribution's "www" pool would listen on a socket of its own; lss keeps a copy of the original and leaves a +# comment in its place (deleting it would make an RPM update put it back). Sets PHP_WWW_CHANGED. +_php_disable_www() { + local www="${PHP_POOL_DIR}/www.conf" + PHP_WWW_CHANGED=0 + if [[ -f ${www} ]] && ! grep -q '^; Managed by lss' "${www}" && [[ ! -e ${www}.lss-orig ]]; then + cp -a "${www}" "${www}.lss-orig" + fi + file_write 0644 root:root "${www}" <<'EOF' +; Managed by lss (module php). The distribution's "www" pool is switched off; lss runs its pool "lss" (lss.conf). +; The original is kept as www.conf.lss-orig and comes back with "lss remove php". +EOF + PHP_WWW_CHANGED=${LSS_FILE_CHANGED} + return +} + +# After a change of LSS_PHP_VERSION the old version's FPM would still serve the lss pool on the same socket. +_php_retire_other_versions() { + local pool version + os_is_debian || return 0 + for pool in /etc/php/*/fpm/pool.d/lss.conf; do + [[ -f ${pool} ]] || continue + version=${pool#/etc/php/} + version=${version%%/*} + [[ ${version} != "${PHP_VERSION}" ]] || continue + svc_disable "php${version}-fpm" + file_remove "${pool}" + file_remove "/etc/php/${version}/fpm/conf.d/99-lss.ini" + svc_dropin_remove "php${version}-fpm.service" lss-sandbox + done + return +} + +mod_configure() { + local changed=0 path socket_dir + local -a writable=() + _php_load + # The socket group must exist before PHP-FPM creates the socket. + web_group_ensure + _php_user + _php_retire_other_versions + + # The RHEL family's php-fpm unit creates /run/php-fpm (with the SELinux label web servers may connect to); + # Debian and Ubuntu have no such directory, so lss creates it at boot. + socket_dir=$(dirname "${WEB_PHP_SOCKET}") + if os_is_debian && [[ ${socket_dir} != /run ]]; then + file_write 0644 root:root "${PHP_TMPFILES}" <<<"d ${socket_dir} 0755 root root -" + if [[ ${LSS_FILE_CHANGED} == 1 || ! -d ${socket_dir} ]]; then + systemd-tmpfiles --create "${PHP_TMPFILES}" + fi + fi + + PHP_MAX_CHILDREN=$(opt_int max_children) + PHP_MEMORY_LIMIT=$(opt memory_limit) + PHP_UPLOAD_MAX=$(opt upload_max_filesize) + PHP_POST_MAX=$(opt post_max_size) + if [[ -n $(opt disable_functions) ]]; then + PHP_DISABLE_LINE="php_admin_value[disable_functions] = $(opt disable_functions)" + else + PHP_DISABLE_LINE='; LSS_PHP_DISABLE_FUNCTIONS is empty: no function is disabled.' + fi + template_install php pool.conf 0644 root:root "${PHP_POOL_DIR}/lss.conf" + [[ ${LSS_FILE_CHANGED} == 0 ]] || changed=1 + template_install php lss.ini 0644 root:root "${PHP_INI}" + [[ ${LSS_FILE_CHANGED} == 0 ]] || changed=1 + _php_disable_www + [[ ${PHP_WWW_CHANGED} == 0 ]] || changed=1 + + # The workers write sessions and whatever the applications in LSS_PHP_WRITABLE_PATHS write; the rest of the system + # stays read-only for them. + writable=("ReadWritePaths=-/var/lib/php") + for path in $(opt writable_paths); do + writable+=("ReadWritePaths=-${path}") + done + svc_sandbox "${PHP_UNIT}.service" "${writable[@]}" + [[ ${LSS_FILE_CHANGED} == 0 ]] || changed=1 + + if ! "${PHP_FPM}" -t >/dev/null 2>&1; then + "${PHP_FPM}" -t >&2 || true + die "php: the PHP-FPM configuration does not load." + fi + svc_enable "${PHP_UNIT}" + if [[ ${changed} == 1 ]]; then + svc_restart "${PHP_UNIT}" || die "php: ${PHP_UNIT} does not start." + fi + return +} + +mod_verify() { + local i extension modules answer + _php_load + svc_is_active "${PHP_UNIT}" || die "php: ${PHP_UNIT} is not running." + for ((i = 0; i < 10; i++)); do + [[ -S ${WEB_PHP_SOCKET} ]] && break + sleep 1 + done + [[ -S ${WEB_PHP_SOCKET} ]] || die "php: the socket ${WEB_PHP_SOCKET} is missing." + [[ $(stat -c '%U:%G %a' "${WEB_PHP_SOCKET}") == "root:${WEB_GROUP} 660" ]] || + die "php: ${WEB_PHP_SOCKET} is not owned by root:${WEB_GROUP} with mode 0660." + "${PHP_FPM}" -t >/dev/null 2>&1 || die "php: the PHP-FPM configuration does not load." + [[ $("${PHP_CLI}" -r 'echo PHP_MAJOR_VERSION, ".", PHP_MINOR_VERSION;' 2>/dev/null) == "${PHP_VERSION}" ]] || + die "php: the PHP command line does not run PHP ${PHP_VERSION}." + modules=$("${PHP_FPM}" -m 2>/dev/null) + for extension in curl gd intl mbstring xml mysqli pdo_pgsql bcmath 'Zend OPcache'; do + grep -qixF "${extension}" <<<"${modules}" || die "php: the extension ${extension} is not loaded." + done + "${PHP_FPM}" -i 2>/dev/null | grep -q '^expose_php => Off' || die "php: the lss settings (${PHP_INI}) are not in effect." + # A real FastCGI request over the socket; the pool answers its ping path without running a script. + answer=$("${PHP_CLI}" -n "${LSS_ROOT}/templates/php/fcgi-ping.php" "${WEB_PHP_SOCKET}" "${PHP_PING}" 2>&1) || true + [[ ${answer} == *pong* ]] || die "php: the pool does not answer FastCGI requests on ${WEB_PHP_SOCKET}: ${answer}" + return +} + +mod_remove() { + local www package + local -a installed=() + PHP_VERSION=$(state_get "${LSS_MODULE}" php_version) + if [[ -n ${PHP_VERSION} ]]; then + _php_load + svc_disable "${PHP_UNIT}" + svc_dropin_remove "${PHP_UNIT}.service" lss-sandbox + file_remove "${PHP_POOL_DIR}/lss.conf" + file_remove "${PHP_INI}" + www="${PHP_POOL_DIR}/www.conf" + if [[ -f ${www}.lss-orig ]]; then + files_backup "${www}" + mv -f -- "${www}.lss-orig" "${www}" + files_note_change "restored ${www}" + fi + fi + file_remove "${PHP_TMPFILES}" + read -ra installed <<<"$(state_get "${LSS_MODULE}" packages)" + if [[ ${#installed[@]} -gt 0 ]]; then + pkg_remove "${installed[@]}" + fi + if os_is_rhel && [[ -n $(_php_enabled_stream) ]]; then + log_command dnf -y -q module reset php + files_note_change "reset module stream php" + fi + if os_is_debian; then + for package in sury-php ondrej-php; do + if [[ -e /etc/apt/sources.list.d/lss-${package}.sources ]]; then + repo_remove "${package}" + pkg_update + fi + done + fi + if [[ ${LSS_PURGE:-0} == 1 ]]; then + rm -rf -- "${PHP_SESSIONS}" + if getent passwd "${PHP_USER}" >/dev/null; then + userdel "${PHP_USER}" + files_note_change "removed user ${PHP_USER}" + fi + fi + return +} diff --git a/templates/php/fcgi-ping.php b/templates/php/fcgi-ping.php new file mode 100644 index 0000000..e3ddb9a --- /dev/null +++ b/templates/php/fcgi-ping.php @@ -0,0 +1,64 @@ + 'FastCGI/1.0', + 'REQUEST_METHOD' => 'GET', + 'SCRIPT_NAME' => $path, + 'SCRIPT_FILENAME' => $path, + 'REQUEST_URI' => $path, + 'QUERY_STRING' => '', + 'SERVER_PROTOCOL' => 'HTTP/1.1', +]; +foreach ($values as $name => $value) { + $params .= fcgi_pair($name, $value); +} + +// BEGIN_REQUEST (role responder), PARAMS, end of PARAMS, end of STDIN. +fwrite($connection, fcgi_record(1, pack('nCx5', 1, 0)) . fcgi_record(4, $params) . fcgi_record(4, '') + . fcgi_record(5, '')); + +$output = ''; +while (true) { + $header = stream_get_contents($connection, 8); + if ($header === false || strlen($header) < 8) { + break; + } + $record = unpack('Cversion/Ctype/nid/nlength/Cpadding/Creserved', $header); + $content = $record['length'] > 0 ? stream_get_contents($connection, $record['length']) : ''; + if ($record['padding'] > 0) { + stream_get_contents($connection, $record['padding']); + } + if ($record['type'] === 6) { + $output .= $content; + } + if ($record['type'] === 3) { + break; + } +} +fclose($connection); +echo $output; diff --git a/templates/php/lss.ini b/templates/php/lss.ini new file mode 100644 index 0000000..1444932 --- /dev/null +++ b/templates/php/lss.ini @@ -0,0 +1,35 @@ +; Managed by lss (module php). Local changes are reported by "lss verify" and replaced on the next run; change the +; LSS_PHP_* settings in /etc/lss/lss.conf instead. open_basedir is left to each application's pool or virtual host: +; a global one would lock every application into the same directories. + +; Tell nobody which PHP runs here, and never show errors to visitors (they go to the log). +expose_php = Off +display_errors = Off +display_startup_errors = Off +log_errors = On + +; Code is never included from a URL. +allow_url_include = Off +; With nginx, a request for /upload/image.jpg/x.php must not run image.jpg as PHP. +cgi.fix_pathinfo = 0 + +memory_limit = {{PHP_MEMORY_LIMIT}} +upload_max_filesize = {{PHP_UPLOAD_MAX}} +post_max_size = {{PHP_POST_MAX}} +max_execution_time = 60 + +; Session cookies only over HTTPS, out of reach of JavaScript, not sent along with cross-site requests, and only +; with IDs the server issued itself. +session.cookie_secure = On +session.cookie_httponly = On +session.cookie_samesite = Lax +session.use_strict_mode = 1 +session.use_only_cookies = 1 + +opcache.enable = 1 +opcache.enable_cli = 0 +opcache.memory_consumption = 128 +opcache.interned_strings_buffer = 16 +opcache.max_accelerated_files = 20000 +opcache.validate_timestamps = 1 +opcache.revalidate_freq = 2 diff --git a/templates/php/pool.conf b/templates/php/pool.conf new file mode 100644 index 0000000..1206f7e --- /dev/null +++ b/templates/php/pool.conf @@ -0,0 +1,40 @@ +; Managed by lss (module php). Local changes are reported by "lss verify" and replaced on the next run; change the +; LSS_PHP_* settings in /etc/lss/lss.conf instead. +; +; The one PHP-FPM pool of lss. Web servers reach it through the socket, which only root and the group {{WEB_GROUP}} +; may open. The workers run as {{PHP_USER}}, a user without a shell or a home directory. +[lss] +user = {{PHP_USER}} +group = {{PHP_USER}} + +listen = {{WEB_PHP_SOCKET}} +listen.owner = root +listen.group = {{WEB_GROUP}} +listen.mode = 0660 + +; Workers start on demand and stop when idle, so an idle server keeps only the master process. +pm = ondemand +pm.max_children = {{PHP_MAX_CHILDREN}} +pm.process_idle_timeout = 10s +pm.max_requests = 500 + +; Answers "pong" without running PHP; "lss verify" uses it to prove the pool serves FastCGI requests. +ping.path = {{PHP_PING}} +ping.response = pong + +; Only .php files are executed, never an uploaded image with PHP code in it. +security.limit_extensions = .php +clear_env = yes +catch_workers_output = yes +decorate_workers_output = no + +; For the web pool only: the command line keeps these functions for composer, cron jobs and queue workers. +{{PHP_DISABLE_LINE}} +php_admin_flag[allow_url_include] = off +php_admin_flag[expose_php] = off + +; Sessions of this pool in a directory only its user can read. PHP removes expired ones itself, because the cleanup +; jobs of the distributions know only their own session directory. +php_admin_value[session.save_path] = {{PHP_SESSIONS}} +php_admin_value[session.gc_probability] = 1 +php_admin_value[session.gc_divisor] = 1000 From 89e88b02917983e6307b36bed606e2e79590425f Mon Sep 17 00:00:00 2001 From: VectoDE Date: Tue, 13 Oct 2026 21:23:26 +0200 Subject: [PATCH 10/92] Add the node runtime module Node.js from the NodeSource repository (pinned keys for apt and RPM), major version 22, 24 (default, the active LTS line) or 26, on every supported system and both architectures. An apt pin keeps the distribution's older nodejs out; switching LSS_NODE_VERSION moves the installed package over. Refs #4 Signed-off-by: VectoDE Co-Authored-By: Claude Opus 5.5 --- data/keys/nodesource.asc | 77 ++++++++++++++++++++++++++++ data/repos/nodesource-22.conf | 19 +++++++ data/repos/nodesource-24.conf | 19 +++++++ data/repos/nodesource-26.conf | 19 +++++++ modules/runtime/node.sh | 96 +++++++++++++++++++++++++++++++++++ 5 files changed, 230 insertions(+) create mode 100644 data/keys/nodesource.asc create mode 100644 data/repos/nodesource-22.conf create mode 100644 data/repos/nodesource-24.conf create mode 100644 data/repos/nodesource-26.conf create mode 100644 modules/runtime/node.sh diff --git a/data/keys/nodesource.asc b/data/keys/nodesource.asc new file mode 100644 index 0000000..acf704c --- /dev/null +++ b/data/keys/nodesource.asc @@ -0,0 +1,77 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +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 +d2WSQABvIKElZM0/zMKfcxVULuc= +=qxA1 +-----END PGP PUBLIC KEY BLOCK----- diff --git a/data/repos/nodesource-22.conf b/data/repos/nodesource-22.conf new file mode 100644 index 0000000..2f469d5 --- /dev/null +++ b/data/repos/nodesource-22.conf @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# Node.js 22.x from NodeSource (https://github.com/nodesource/distributions), one file per major version because +# the major is part of the URL. The "nodistro" builds serve every Debian, Ubuntu and +# RHEL-family release, each for amd64/x86_64 and arm64/aarch64. nodesource.asc holds the apt key +# (https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key) and the RPM key +# (https://rpm.nodesource.com/gpgkey/ns-operations-public.key), both fetched on 2026-10-13 from the URLs NodeSource's +# setup scripts use. NodeSource publishes no fingerprint elsewhere, so they were checked against the signatures of the +# apt repository and of the RPM packages. The RPM metadata is unsigned; the packages are signed. +KEY=nodesource.asc +FINGERPRINTS=6F71F525282841EEDAF851B42F59B5F99B1BE0B4 242B813831AF09562B6C46F76B88DA4E3AF28A14 +DEB_URL=https://deb.nodesource.com/node_22.x +DEB_SUITE=nodistro +DEB_COMPONENTS=main +RPM_BASEURL=https://rpm.nodesource.com/pub_22.x/nodistro/nodejs/$basearch +RPM_GPGCHECK=1 +RPM_REPO_GPGCHECK=0 diff --git a/data/repos/nodesource-24.conf b/data/repos/nodesource-24.conf new file mode 100644 index 0000000..dc9fb57 --- /dev/null +++ b/data/repos/nodesource-24.conf @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# Node.js 24.x from NodeSource (https://github.com/nodesource/distributions), one file per major version because +# the major is part of the URL. The "nodistro" builds serve every Debian, Ubuntu and +# RHEL-family release, each for amd64/x86_64 and arm64/aarch64. nodesource.asc holds the apt key +# (https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key) and the RPM key +# (https://rpm.nodesource.com/gpgkey/ns-operations-public.key), both fetched on 2026-10-13 from the URLs NodeSource's +# setup scripts use. NodeSource publishes no fingerprint elsewhere, so they were checked against the signatures of the +# apt repository and of the RPM packages. The RPM metadata is unsigned; the packages are signed. +KEY=nodesource.asc +FINGERPRINTS=6F71F525282841EEDAF851B42F59B5F99B1BE0B4 242B813831AF09562B6C46F76B88DA4E3AF28A14 +DEB_URL=https://deb.nodesource.com/node_24.x +DEB_SUITE=nodistro +DEB_COMPONENTS=main +RPM_BASEURL=https://rpm.nodesource.com/pub_24.x/nodistro/nodejs/$basearch +RPM_GPGCHECK=1 +RPM_REPO_GPGCHECK=0 diff --git a/data/repos/nodesource-26.conf b/data/repos/nodesource-26.conf new file mode 100644 index 0000000..ad77c83 --- /dev/null +++ b/data/repos/nodesource-26.conf @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# Node.js 26.x from NodeSource (https://github.com/nodesource/distributions), one file per major version because +# the major is part of the URL. The "nodistro" builds serve every Debian, Ubuntu and +# RHEL-family release, each for amd64/x86_64 and arm64/aarch64. nodesource.asc holds the apt key +# (https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key) and the RPM key +# (https://rpm.nodesource.com/gpgkey/ns-operations-public.key), both fetched on 2026-10-13 from the URLs NodeSource's +# setup scripts use. NodeSource publishes no fingerprint elsewhere, so they were checked against the signatures of the +# apt repository and of the RPM packages. The RPM metadata is unsigned; the packages are signed. +KEY=nodesource.asc +FINGERPRINTS=6F71F525282841EEDAF851B42F59B5F99B1BE0B4 242B813831AF09562B6C46F76B88DA4E3AF28A14 +DEB_URL=https://deb.nodesource.com/node_26.x +DEB_SUITE=nodistro +DEB_COMPONENTS=main +RPM_BASEURL=https://rpm.nodesource.com/pub_26.x/nodistro/nodejs/$basearch +RPM_GPGCHECK=1 +RPM_REPO_GPGCHECK=0 diff --git a/modules/runtime/node.sh b/modules/runtime/node.sh new file mode 100644 index 0000000..95cc6e3 --- /dev/null +++ b/modules/runtime/node.sh @@ -0,0 +1,96 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +MOD_NAME="node" +MOD_GROUP="runtime" +MOD_DESCRIPTION="Node.js (LTS) with npm from the NodeSource repository" +MOD_REQUIRES="system" +MOD_OPTIONS="version|24|Node.js major version: 22, 24 (the active LTS line until 26 takes over in late October 2026) or 26" + +# Major versions NodeSource builds for every supported system and both architectures (data/repos/nodesource-*.conf). +NODE_MAJORS="22 24 26" +# The distributions ship their own, older nodejs; the NodeSource package must win even where its version is lower. +NODE_PIN=/etc/apt/preferences.d/lss-nodesource + +mod_check() { + local version + version=$(opt version) + [[ " ${NODE_MAJORS} " == *" ${version} "* ]] || die "LSS_NODE_VERSION must be one of ${NODE_MAJORS}, got '${version}'." + return +} + +_node_repo_present() { + local name=$1 + [[ -e /etc/apt/sources.list.d/lss-${name}.sources || -e /etc/yum.repos.d/lss-${name}.repo ]] + return +} + +# Major version of the installed node, or nothing. +_node_major() { + local version + command -v node >/dev/null 2>&1 || return 0 + version=$(node --version 2>/dev/null) || return 0 + version=${version#v} + printf '%s' "${version%%.*}" + return +} + +mod_install() { + local major other installed + major=$(opt version) + # One NodeSource repository at a time: the one of the major version asked for. + for other in ${NODE_MAJORS}; do + if [[ ${other} != "${major}" ]] && _node_repo_present "nodesource-${other}"; then + repo_remove "nodesource-${other}" + fi + done + if os_is_debian; then + file_write 0644 root:root "${NODE_PIN}" <<'EOF' +# Managed by lss (module node). nodejs comes from NodeSource, never from the distribution. +Package: nodejs +Pin: origin deb.nodesource.com +Pin-Priority: 600 +EOF + fi + repo_add "nodesource-${major}" + if ! pkg_is_installed nodejs; then + pkg_install nodejs + # Remembered, so remove takes away only a nodejs that lss installed. + state_set "${LSS_MODULE}" packages nodejs + return 0 + fi + installed=$(_node_major) + if [[ ${installed} != "${major}" ]]; then + log_info "Switching Node.js ${installed:-?} to ${major}" + if os_is_debian; then + log_command _pkg_apt install --allow-downgrades nodejs + else + log_command dnf -y -q distro-sync nodejs + fi + files_note_change "nodejs ${major}" + fi + return +} + +mod_verify() { + local major output + major=$(opt version) + [[ $(_node_major) == "${major}" ]] || die "node: node --version does not report Node.js ${major}." + output=$(node -e 'process.stdout.write(String(6 * 7))' 2>/dev/null) || true + [[ ${output} == 42 ]] || die "node: node does not run JavaScript." + NPM_CONFIG_UPDATE_NOTIFIER=false npm --version >/dev/null 2>&1 || die "node: npm does not run." + return +} + +mod_remove() { + local major + if [[ $(state_get "${LSS_MODULE}" packages) == nodejs ]]; then + pkg_remove nodejs + fi + for major in ${NODE_MAJORS}; do + repo_remove "nodesource-${major}" + done + file_remove "${NODE_PIN}" + return +} From 7fd1218abf21a8a82a3b79f98715bf7f95866c82 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Tue, 13 Oct 2026 21:23:27 +0200 Subject: [PATCH 11/92] Add the python and ruby runtime modules python installs python3 with venv, pip and pipx from the distribution (pipx from EPEL on the RHEL family); verify builds a virtual environment. ruby installs ruby, Bundler and the headers plus a compiler for native gems. Both remove only the packages lss installed, never python3 itself. Refs #4 Signed-off-by: VectoDE Co-Authored-By: Claude Opus 5.5 --- modules/runtime/python.sh | 60 +++++++++++++++++++++++++++++++++++++++ modules/runtime/ruby.sh | 53 ++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 modules/runtime/python.sh create mode 100644 modules/runtime/ruby.sh diff --git a/modules/runtime/python.sh b/modules/runtime/python.sh new file mode 100644 index 0000000..c6b2188 --- /dev/null +++ b/modules/runtime/python.sh @@ -0,0 +1,60 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +MOD_NAME="python" +MOD_GROUP="runtime" +MOD_DESCRIPTION="Python 3 from the distribution with venv, pip and pipx" +MOD_REQUIRES="system" +MOD_OPTIONS="" + +# The RHEL family has venv in python3 itself and pipx in EPEL. +PYTHON_DEB="python3 python3-venv python3-pip pipx" +PYTHON_RPM="python3 python3-pip pipx" + +mod_install() { + local -a wanted=() missing=() remembered=() + local package + if os_is_debian; then + read -ra wanted <<<"${PYTHON_DEB}" + else + pkg_enable_epel + read -ra wanted <<<"${PYTHON_RPM}" + fi + mapfile -t missing < <(pkg_missing "${wanted[@]}") + [[ ${#missing[@]} -gt 0 ]] || return 0 + pkg_install "${missing[@]}" + # Remembered, so remove takes away only what lss installed. python3 itself stays: the system and other packages + # (fail2ban, dnf, ...) depend on it, and removing it would take them along. + for package in "${missing[@]}"; do + [[ ${package} == python3 ]] || remembered+=("${package}") + done + state_set "${LSS_MODULE}" packages "$(state_get "${LSS_MODULE}" packages) ${remembered[*]}" + return +} + +mod_verify() { + local tmp status=0 + python3 -c 'import sys; sys.exit(0 if sys.version_info >= (3, 9) else 1)' || die "python: python3 does not run or is older than 3.9." + # A virtual environment with its own pip proves venv and ensurepip work. + tmp=$(mktemp -d) + { + python3 -m venv "${tmp}/venv" && + "${tmp}/venv/bin/python" -m pip --version && + "${tmp}/venv/bin/python" -c 'import sys; sys.exit(0 if sys.prefix != sys.base_prefix else 1)' + } >/dev/null 2>&1 || status=1 + rm -rf -- "${tmp}" + [[ ${status} == 0 ]] || die "python: python3 -m venv does not create a working environment." + python3 -m pip --version >/dev/null 2>&1 || die "python: pip does not run." + pipx --version >/dev/null 2>&1 || die "python: pipx does not run." + return +} + +mod_remove() { + local -a installed=() + read -ra installed <<<"$(state_get "${LSS_MODULE}" packages)" + if [[ ${#installed[@]} -gt 0 ]]; then + pkg_remove "${installed[@]}" + fi + return +} diff --git a/modules/runtime/ruby.sh b/modules/runtime/ruby.sh new file mode 100644 index 0000000..956c6ec --- /dev/null +++ b/modules/runtime/ruby.sh @@ -0,0 +1,53 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +MOD_NAME="ruby" +MOD_GROUP="runtime" +MOD_DESCRIPTION="Ruby from the distribution with Bundler and the headers and compiler for native gems" +MOD_REQUIRES="system" +MOD_OPTIONS="" + +# Debian's "bundler" package carries the bundle command (ruby-bundler only the library). +RUBY_DEB="ruby ruby-dev bundler" +RUBY_RPM="ruby ruby-devel rubygem-bundler" +# Native gems need a compiler; it stays on remove, other software builds with it too. +RUBY_BUILD_DEB="gcc make libc6-dev" +RUBY_BUILD_RPM="gcc make redhat-rpm-config" + +mod_install() { + local -a wanted=() missing=() + if os_is_debian; then + read -ra wanted <<<"${RUBY_DEB}" + else + read -ra wanted <<<"${RUBY_RPM}" + fi + mapfile -t missing < <(pkg_missing "${wanted[@]}") + if [[ ${#missing[@]} -gt 0 ]]; then + pkg_install "${missing[@]}" + # Remembered, so remove takes away only what lss installed. + state_set "${LSS_MODULE}" packages "$(state_get "${LSS_MODULE}" packages) ${missing[*]}" + fi + pkg_install_family "${RUBY_BUILD_DEB}" "${RUBY_BUILD_RPM}" + return +} + +mod_verify() { + local output + output=$(ruby -e 'print 6 * 7' 2>/dev/null) || true + [[ ${output} == 42 ]] || die "ruby: ruby does not run." + bundle --version >/dev/null 2>&1 || die "ruby: bundle does not run." + # The headers for native extensions. + ruby -rrbconfig -e 'exit(File.exist?(File.join(RbConfig::CONFIG["rubyhdrdir"], "ruby.h")) ? 0 : 1)' || + die "ruby: the Ruby headers (ruby.h) are missing." + return +} + +mod_remove() { + local -a installed=() + read -ra installed <<<"$(state_get "${LSS_MODULE}" packages)" + if [[ ${#installed[@]} -gt 0 ]]; then + pkg_remove "${installed[@]}" + fi + return +} From fc9e9b64041c2bcf917cd133ef9a77ba5d5cfd1a Mon Sep 17 00:00:00 2001 From: VectoDE Date: Tue, 13 Oct 2026 21:25:11 +0200 Subject: [PATCH 12/92] php: read php-fpm -i before matching it grep -q stops reading at the first match, so php-fpm got SIGPIPE and the pipeline failed under pipefail although the setting was in effect. Refs #4 Signed-off-by: VectoDE Co-Authored-By: Claude Opus 5.5 --- modules/runtime/php.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/modules/runtime/php.sh b/modules/runtime/php.sh index 3f7c2bc..61532fe 100644 --- a/modules/runtime/php.sh +++ b/modules/runtime/php.sh @@ -308,7 +308,7 @@ mod_configure() { } mod_verify() { - local i extension modules answer + local i extension modules info answer _php_load svc_is_active "${PHP_UNIT}" || die "php: ${PHP_UNIT} is not running." for ((i = 0; i < 10; i++)); do @@ -325,7 +325,9 @@ mod_verify() { for extension in curl gd intl mbstring xml mysqli pdo_pgsql bcmath 'Zend OPcache'; do grep -qixF "${extension}" <<<"${modules}" || die "php: the extension ${extension} is not loaded." done - "${PHP_FPM}" -i 2>/dev/null | grep -q '^expose_php => Off' || die "php: the lss settings (${PHP_INI}) are not in effect." + # Captured first: grep -q would stop reading early and fail the pipeline with SIGPIPE. + info=$("${PHP_FPM}" -i 2>/dev/null) || true + grep -q '^expose_php => Off' <<<"${info}" || die "php: the lss settings (${PHP_INI}) are not in effect." # A real FastCGI request over the socket; the pool answers its ping path without running a script. answer=$("${PHP_CLI}" -n "${LSS_ROOT}/templates/php/fcgi-ping.php" "${WEB_PHP_SOCKET}" "${PHP_PING}" 2>&1) || true [[ ${answer} == *pong* ]] || die "php: the pool does not answer FastCGI requests on ${WEB_PHP_SOCKET}: ${answer}" From 7c88bed4240c2a6ce61afd33c77a46d0376418b6 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Tue, 13 Oct 2026 21:25:13 +0200 Subject: [PATCH 13/92] Add the java runtime module OpenJDK from the distribution (headless JDK on Debian and Ubuntu, the -devel JDK on the RHEL family), version 21 by default. Where the distribution has no OpenJDK of that version (Debian 12 stops at 17), the module installs Eclipse Temurin from the Adoptium repository with a pinned key. verify compiles and runs a test class. Refs #4 Signed-off-by: VectoDE Co-Authored-By: Claude Opus 5.5 --- data/keys/adoptium.asc | 31 ++++++++++++ data/repos/adoptium.conf | 16 ++++++ modules/runtime/java.sh | 104 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 151 insertions(+) create mode 100644 data/keys/adoptium.asc create mode 100644 data/repos/adoptium.conf create mode 100644 modules/runtime/java.sh diff --git a/data/keys/adoptium.asc b/data/keys/adoptium.asc new file mode 100644 index 0000000..7d76f7a --- /dev/null +++ b/data/keys/adoptium.asc @@ -0,0 +1,31 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQENBGGTvTQBCAC6ey144n7CG8foafF6mwgIBN1fIm1ILZDuGS4tMr0/XI8pgJnT +QvsPxZWEvtSm7bEMObzEoZJcXwjBcJl1B0ui8k5kHMTI75gCmZPsoKLFWIEpuRBQ +PBocusw80apDmLnNDQLVQvDFtEua5gaNa/fRw9YsmBoXBqvgrjFUIdGyWoQvH5+a +9OYlWD9n5VV0gnVMb+aclwVzB/zJw3kHGSgzuMtlAHeQiah7Y8yomQn/UIX8yqDf ++11sP3+c87YcjkRqImRTtmKEDcEtGPAIXC6SYA+uEEkbYE0Fy0chkvtnVWJ597fa +Epai4rnICU8zoJ6X5z3v1aM2WerhX9oq9X8PABEBAAG0QEFkb3B0aXVtIEdQRyBL +ZXkgKERFQi9SUE0gU2lnbmluZyBLZXkpIDx0ZW11cmluLWRldkBlY2xpcHNlLm9y +Zz6JAVIEEwEIADwWIQQ7BNdTyQUNml00PzmEPEilZfjwSwUCYZO9NAIbAwULCQgH +AgMiAgEGFQoJCAsCBBYCAwECHgcCF4AACgkQhDxIpWX48Et4AggAjjJzYWuKV3nG +7ngInngl8G/m9JoHr7BmwgcQXYhdy5hVkMcUx5JLeXz2LMBUH/F2nD595hgjMabk +kVib20X8lq9RsNbdfc2hBcWU6qyHKxsIqT4boI2/XDyEzzMyyZWWNGo/27Ci7Xmj +pWu31nh0pDdPqdyWDIKojbVVnxlCRY8as8Sm+1ufi709KCi4MuwHNsUlCSwb/fju +NKeHkrHbLcHKUUIEcmTSKRWrpMYBzm1HYOGBz4xPuELwUfUp71ehfoyBZlp6RDRf +l5TYI1FmCyHuvjNhrJgWv7bOTcf8yObGY+TEUhzc4xQqCrF4ur9d3opvsuPBQsv+ +Klqi5KSZgrkBDQRhk700AQgAq14okly8cFrpYVenEQPiB75AUZfKRpMduiR6IxAj +SKcH7aSoFZ9AubUEBVpZsyT5svxoEPe1i4TdbF+m9FGy42EcOlLa3ArLTj5H8FRl +UdGZB9I5mk4GptOzPM+aHMMu92vW/ZwjuS8DvOiQSp+cUmG1EqOMJSM7e/4BM71z +E+OKaVJCj79pEzhG3SK/IC/OlxxyETT66NSfYJd7Sw5R6Vr19am/uNU690W0CJ+q +VQeFpmDMr7LnfdFRIh+lJe05+PvWXeidkGjox5cbG52wf8aRIR/FgkfcFvqRMN1f +B+dVOWueloUeVAnzcUznOKmUEs7LP9ObJhYHHgup4IAU2wARAQABiQE2BBgBCAAg +FiEEOwTXU8kFDZpdND85hDxIpWX48EsFAmGTvTQCGwwACgkQhDxIpWX48EvXHQf/ +Q0nZsGDXnZHiBoojeSdpkO7WBjMIP3w1GdLvRpPQrS8TfOPbZuoevzCNh38Y3gwF +yelJspvzDQrBXhgkzAGlucYg8Y7KHa5Ebm7iDgMzc37L1hYSZTYCqwd7aowfgy34 +hOk3B67LffkJpIh738Oa9CtlwxQ9xcytmBmQ1fBBOwm/9IhAwHPQuydYIs4DxWbj +0MGSP4fDntU7e4UjsHNmhudDcYol0FaqdHHIIB9C/G4CzetRwHFOn3b4JwXMU7YU +6aJA3mXhi3hggMC3wkT2HHZ/TquuOdNc02fypWOCDOHz0alBBJNqoVUNFNqU3tfJ +wI4qF/KKq9BfyfucAs0ykA== +=szki +-----END PGP PUBLIC KEY BLOCK----- \ No newline at end of file diff --git a/data/repos/adoptium.conf b/data/repos/adoptium.conf new file mode 100644 index 0000000..6266bf3 --- /dev/null +++ b/data/repos/adoptium.conf @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# Eclipse Temurin (OpenJDK builds of Adoptium, https://adoptium.net/installation/linux/), used only when the +# distribution has no OpenJDK of the version asked for (Debian 12 stops at 17, so the default 21 comes from here). +# Key fetched from https://packages.adoptium.net/artifactory/api/gpg/key/public on 2026-10-13; the apt InRelease files +# and the signed RPM metadata (repomd.xml.asc) verify against it. +KEY=adoptium.asc +FINGERPRINTS=3B04D753C9050D9A5D343F39843C48A565F8F04B +DEB_URL=https://packages.adoptium.net/artifactory/deb +DEB_SUITE={{codename}} +DEB_COMPONENTS=main +RPM_BASEURL=https://packages.adoptium.net/artifactory/rpm/rhel/{{major}}/$basearch +RPM_GPGCHECK=1 +RPM_REPO_GPGCHECK=1 diff --git a/modules/runtime/java.sh b/modules/runtime/java.sh new file mode 100644 index 0000000..f677ad8 --- /dev/null +++ b/modules/runtime/java.sh @@ -0,0 +1,104 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +MOD_NAME="java" +MOD_GROUP="runtime" +MOD_DESCRIPTION="OpenJDK (JDK without the desktop parts) from the distribution, Eclipse Temurin where it has none" +MOD_REQUIRES="system" +MOD_OPTIONS="version|21|Java major version, for example 17, 21 or 25" + +mod_check() { + [[ $(opt version) =~ ^(8|1[1-9]|[2-9][0-9])$ ]] || die "LSS_JAVA_VERSION must be a Java major version like 21, got '$(opt version)'." + return +} + +# True when the package sources offer PACKAGE. +_java_available() { + local package=$1 candidate + if os_is_debian; then + candidate=$(apt-cache policy "${package}" 2>/dev/null | awk '$1 == "Candidate:" { print $2 }') + [[ -n ${candidate} && ${candidate} != '(none)' ]] + else + [[ -n $(dnf -q repoquery "${package}" 2>/dev/null) ]] + fi + return +} + +# The distribution's JDK package: headless on Debian and Ubuntu; the RHEL family has no headless JDK, its -devel +# package pulls in the full runtime (a few font and X11 libraries). +_java_distribution_package() { + local version=$1 + if os_is_debian; then + printf 'openjdk-%s-jdk-headless' "${version}" + else + printf 'java-%s-openjdk-devel' "${version}" + fi + return +} + +mod_install() { + local version package temurin + version=$(opt version) + package=$(_java_distribution_package "${version}") + temurin="temurin-${version}-jdk" + pkg_is_installed "${package}" && return 0 + pkg_is_installed "${temurin}" && return 0 + if os_is_debian; then + pkg_update_once + fi + if ! _java_available "${package}"; then + # Debian 12 ships OpenJDK 17 only; Adoptium builds every version for every supported system. + log_info "java: ${OS_NAME} has no OpenJDK ${version}; using Eclipse Temurin from Adoptium." + repo_add adoptium + _java_available "${temurin}" || die "java: Java ${version} is available neither from ${OS_NAME} nor from Adoptium." + package=${temurin} + fi + pkg_install "${package}" + # Remembered, so remove takes away only what lss installed. + state_set "${LSS_MODULE}" packages "$(state_get "${LSS_MODULE}" packages) ${package}" + return +} + +# Prints the home of the installed JDK of VERSION (Debian: java-21-openjdk-amd64, RHEL: java-21-openjdk, Temurin: +# temurin-21-jdk[-amd64]). +_java_home() { + local version=$1 home + for home in /usr/lib/jvm/java-"${version}"-* /usr/lib/jvm/temurin-"${version}"-*; do + if [[ -x ${home}/bin/java && -x ${home}/bin/javac ]]; then + printf '%s' "${home}" + return 0 + fi + done + return 1 +} + +mod_verify() { + local version home expected output tmp status=0 + version=$(opt version) + home=$(_java_home "${version}") || die "java: no JDK ${version} below /usr/lib/jvm." + expected=${version} + [[ ${version} != 8 ]] || expected=1.8 + output=$("${home}/bin/java" -version 2>&1) || true + [[ ${output} =~ version\ \"${expected}[.\"] ]] || die "java: ${home}/bin/java is not Java ${version}." + output=$("${home}/bin/javac" -version 2>&1) || true + [[ ${output} == "javac ${expected}"* ]] || die "java: ${home}/bin/javac is not Java ${version}." + command -v java >/dev/null 2>&1 || die "java: java is not on the PATH." + # Compiling and running a class proves the JDK works, not only that it starts. + tmp=$(mktemp -d) + printf 'class Probe { public static void main(String[] a) { System.out.print(6 * 7); } }\n' >"${tmp}/Probe.java" + output=$("${home}/bin/javac" -d "${tmp}" "${tmp}/Probe.java" 2>&1 && "${home}/bin/java" -cp "${tmp}" Probe 2>&1) || status=1 + rm -rf -- "${tmp}" + [[ ${status} == 0 && ${output} == 42 ]] || die "java: compiling and running a test class failed: ${output}" + return +} + +mod_remove() { + local -a installed=() + read -ra installed <<<"$(state_get "${LSS_MODULE}" packages)" + if [[ ${#installed[@]} -gt 0 ]]; then + pkg_remove "${installed[@]}" + fi + repo_remove adoptium + return +} From 7305cf0dec81bc6f2361dbdaebc2d9d58fff6827 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Tue, 13 Oct 2026 21:26:39 +0200 Subject: [PATCH 14/92] Add the go runtime module Go 1.27.2 from the official release archive, checked against the SHA-256 the Go team publishes (amd64 and arm64), unpacked into /usr/local/go with a profile.d entry for the PATH. A pinned version is replaced only when it changes; verify runs go version and checks a login shell finds it. Refs #4 Signed-off-by: VectoDE Co-Authored-By: Claude Opus 5.5 --- data/checksums.txt | 4 +++ modules/runtime/go.sh | 75 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 modules/runtime/go.sh diff --git a/data/checksums.txt b/data/checksums.txt index b602781..4ce5021 100644 --- a/data/checksums.txt +++ b/data/checksums.txt @@ -7,3 +7,7 @@ # Composer 2.10.3: https://getcomposer.org/download/2.10.3/composer.phar.sha256sum (2026-10-13). composer any 7a2d379d5b8ffdaa028580ef26494c36d2feef4b178d3dd1473a4dbc5e17c8d6 https://getcomposer.org/download/2.10.3/composer.phar + +# Go 1.27.2: https://go.dev/dl/?mode=json and https://dl.google.com/go/go1.27.2.linux-.tar.gz.sha256 (2026-10-13). +go x86_64 ecbadb99091a3f46e31f5f934b068b1864eafa7995211b39eaddf76996045fe5 https://go.dev/dl/go1.27.2.linux-amd64.tar.gz +go aarch64 94f3e30b8e374bc285e7dadc11e0865726b9bc6e85b841ccceaabc0214c6b7c8 https://go.dev/dl/go1.27.2.linux-arm64.tar.gz diff --git a/modules/runtime/go.sh b/modules/runtime/go.sh new file mode 100644 index 0000000..1fb6209 --- /dev/null +++ b/modules/runtime/go.sh @@ -0,0 +1,75 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +MOD_NAME="go" +MOD_GROUP="runtime" +MOD_DESCRIPTION="Go from the official release archive (checksum-verified) in /usr/local/go" +MOD_REQUIRES="system" +MOD_OPTIONS="" + +# The distributions lag behind the Go releases (the Go team supports the two newest only), so lss installs the +# official archive. Raising the version means updating GO_VERSION and both lines in data/checksums.txt. +GO_VERSION=1.27.2 +GO_ROOT=/usr/local/go +GO_PROFILE=/etc/profile.d/lss-go.sh + +_go_installed_version() { + [[ -f ${GO_ROOT}/VERSION ]] || return 0 + head -n 1 "${GO_ROOT}/VERSION" + return +} + +mod_install() { + local tmp + if [[ $(_go_installed_version) == "go${GO_VERSION}" ]]; then + return 0 + fi + log_info "Installing Go ${GO_VERSION}" + # Unpacked next to the target and swapped in with one rename, so a failed download never leaves half a Go behind. + tmp=$(mktemp -d /usr/local/.lss-go.XXXXXX) + fetch_verified go "${tmp}/go.tar.gz" + tar -xzf "${tmp}/go.tar.gz" -C "${tmp}" --no-same-owner --no-same-permissions + [[ -x ${tmp}/go/bin/go ]] || { + rm -rf -- "${tmp}" + die "go: the archive holds no go/bin/go." + } + chmod -R u=rwX,go=rX "${tmp}/go" + rm -rf -- "${GO_ROOT}" + mv -- "${tmp}/go" "${GO_ROOT}" + rm -rf -- "${tmp}" + files_note_change "installed Go ${GO_VERSION} in ${GO_ROOT}" + return +} + +mod_configure() { + file_write 0644 root:root "${GO_PROFILE}" </dev/null) || die "go: ${GO_ROOT}/bin/go does not run." + [[ ${output} == "go version go${GO_VERSION} "* ]] || die "go: ${GO_ROOT}/bin/go is not Go ${GO_VERSION} (${output})." + # A login shell finds go through the profile entry. + output=$(env -i HOME=/root PATH=/usr/bin:/bin bash -lc 'command -v go' 2>/dev/null) || true + # The last line: a profile script may print something before. + [[ ${output##*$'\n'} == "${GO_ROOT}/bin/go" ]] || die "go: ${GO_PROFILE} does not put go on the PATH." + return +} + +mod_remove() { + if [[ -d ${GO_ROOT} ]]; then + rm -rf -- "${GO_ROOT}" + files_note_change "removed ${GO_ROOT}" + fi + file_remove "${GO_PROFILE}" + return +} From b2265ac139d5e7cd06ab032089f99049c7307d10 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Tue, 13 Oct 2026 21:28:05 +0200 Subject: [PATCH 15/92] Add the dotnet runtime module The .NET SDK, 10.0 (LTS) by default, from the source each system has: Ubuntu's archive, Canonical's backports PPA where the archive lacks the version (.NET 10 on 22.04), AppStream on the RHEL family and Microsoft's repository on Debian (both pinned keys). DOTNET_CLI_TELEMETRY_OPTOUT=1 is set for login shells, PAM sessions and systemd user services. Refs #4 Signed-off-by: VectoDE Co-Authored-By: Claude Opus 5.5 --- data/keys/dotnet-backports.asc | 29 +++++++++ data/keys/microsoft.asc | 42 ++++++++++++ data/repos/dotnet-backports.conf | 14 ++++ data/repos/microsoft.conf | 14 ++++ modules/runtime/dotnet.sh | 107 +++++++++++++++++++++++++++++++ 5 files changed, 206 insertions(+) create mode 100644 data/keys/dotnet-backports.asc create mode 100644 data/keys/microsoft.asc create mode 100644 data/repos/dotnet-backports.conf create mode 100644 data/repos/microsoft.conf create mode 100644 modules/runtime/dotnet.sh diff --git a/data/keys/dotnet-backports.asc b/data/keys/dotnet-backports.asc new file mode 100644 index 0000000..31ce006 --- /dev/null +++ b/data/keys/dotnet-backports.asc @@ -0,0 +1,29 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBGWzzawBEAD0r2+MRyiDtGRmCZ+S15spTUY9/l/0bKIZp5S+WTfd1NlSzkCl ++XYQa4cy9+jzeAgosiN0Brx8X1ohYo2uRc4+yEZaODpuR4X5roAabLEE9/R3n9XL +HJ0pqLiieqmsHcICqMFNYLc7eG3ttN3knRRbKQBk/P6UpJhcWRIex4Oeo5RK5pS3 +zCJzRCwqf/rZFfcHgssXSXjDqnYONOuNNyxp0qHG3PG3WVUjnnjx3tWI33T/Qiat +FNJh3wW3Y+wMuRUuB04DymVgVoFBB2xWu158GtpuKrFQ7xf3ZSD4JgcwCx2pXQ+E +P7GEm+S7ARk1hnN4vto3oqg8h6QdVbjaO+E+u7snmneC0GPCLlj9cvrKnRXEwJag +oj+t0g7sR8iSbjfnLxAAPfPRpHnLCk/NjtB6kASJxggUEsRe3mcO+9WgvF/w85np +or0L+AvPSnnUxrLybwKLT+0TbSOA3CKGAOfSAqCS77hbRjdjuzSoh5O7IQUZOrJJ +XzfKZylSbW/t1mSZbmLMxB3aD/HfDeWLS3JWOQVMDCGCVE9yh6P9X0B/5UEYp5mu +nQ34rttqMCu9gBPR93f21kz3dUoA5m/OSkvqrAwqgarlPKdQnjcthJGI0l+xU1fI +fqC3wDjYIgJ+DCGl7OyLW7htMj+ZY7eJbHHXN4AMAwDFQcicW42VVnrIVwARAQAB +tCJMYXVuY2hwYWQgUFBBIGZvciAuTkVUIEAgQ2Fub25pY2FsiQJOBBMBCgA4FiEE +RaPxJxWb6eUBeBHGISWxZOjl0/oFAmWzzawCGwMFCwkIBwIGFQoJCAsCBBYCAwEC +HgECF4AACgkQISWxZOjl0/poJA//U5RZZxAAWxWGj9qyelawo7KecLWImcFUDcnI +2mfQOB9havZYcOcjenYiJTxKoJ4lRQzGbA/ssGea07zRtdaP/pyWv6iWfVsX3Dvl +hwetndbkllbUKoFEQndyxKuK+JBaM3YLDqUVJhJp3w5Jn2IqfnWkZnGf3vZWFCtY +Alobynkqnv0EVX4FteHG+/T4ouTXCkJVwMtekDdCkYXOE7mWWcRW7KAeHq/wI85T +HaWR6jWjhsF9U0wjludVGeeoL6F8FGaHxbTbn7pQR8Epz92hl9+tQkyxjtjuXDtj +TWvI1buewpzHujk5AXknLabmuFCGl/AHxYB6i4briBvar0SRy3x9XszvMiEtb69V +ApepUcG/PmlUb5X0KofDoQ7btS56HxPRnScOyWX/tUccod41s7Tsx5eGYTEcZjRh +FBYgjqhnMkcFoEpFx9Wt2Z2cRpZXzc07uz3WAF8eb3QcOkFrwySogVDhdF+cHvGI +RyD27ZbxEsQDIr2hCgOdDg/Cvz9uvN5WhiFL/xM82idIWfczF1rkFb9p6AxR9c26 +99oVl+UOLTSPlPmsgtDwOTwc0EYjspEit30BQWQUEN4VaQlGpGLJxshs8hGf57fq +5i/EmixUuQvkThlBZIdSkAeFhwS0mnGmcOQmfCE/6hBqBCgDEDspJPW56SGOVZMo +yRDdXGU= +=zcgX +-----END PGP PUBLIC KEY BLOCK----- diff --git a/data/keys/microsoft.asc b/data/keys/microsoft.asc new file mode 100644 index 0000000..6209cb5 --- /dev/null +++ b/data/keys/microsoft.asc @@ -0,0 +1,42 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +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 +=3eyX +-----END PGP PUBLIC KEY BLOCK----- diff --git a/data/repos/dotnet-backports.conf b/data/repos/dotnet-backports.conf new file mode 100644 index 0000000..db99ec7 --- /dev/null +++ b/data/repos/dotnet-backports.conf @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# Canonical's .NET backports PPA (ppa:dotnet/backports), used on Ubuntu only for a .NET version its archive lacks: +# Ubuntu 22.04 has .NET 8 in its archive and .NET 9 and 10 only here (amd64 and arm64), as Microsoft documents on +# https://learn.microsoft.com/dotnet/core/install/linux-ubuntu-install. Fingerprint from the Launchpad API +# (https://api.launchpad.net/1.0/~dotnet/+archive/ubuntu/backports, signing_key_fingerprint), key fetched from +# https://keyserver.ubuntu.com on 2026-10-13. +KEY=dotnet-backports.asc +FINGERPRINTS=45A3F127159BE9E5017811C62125B164E8E5D3FA +DEB_URL=https://ppa.launchpadcontent.net/dotnet/backports/ubuntu +DEB_SUITE={{codename}} +DEB_COMPONENTS=main diff --git a/data/repos/microsoft.conf b/data/repos/microsoft.conf new file mode 100644 index 0000000..a467398 --- /dev/null +++ b/data/repos/microsoft.conf @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# Microsoft's package repository for Debian (https://learn.microsoft.com/linux/packages), the source of .NET there: +# Debian ships no .NET, Ubuntu and the RHEL family do. It has every supported .NET for amd64 and .NET 10 for arm64. +# Debian 12 is signed with the key "Microsoft (Release signing)", Debian 13 with "Microsoft Corporation - General GPG +# Signer"; microsoft.asc holds both (https://packages.microsoft.com/keys/microsoft.asc and microsoft-2025.asc, fetched +# on 2026-10-13), their fingerprints are the ones Microsoft publishes on the page above. +KEY=microsoft.asc +FINGERPRINTS=BC528686B50D79E339D3721CEB3E94ADBE1229CF AA86F75E427A19DD33346403EE4D7792F748182B +DEB_URL=https://packages.microsoft.com/debian/{{major}}/prod +DEB_SUITE={{codename}} +DEB_COMPONENTS=main diff --git a/modules/runtime/dotnet.sh b/modules/runtime/dotnet.sh new file mode 100644 index 0000000..ee9d47b --- /dev/null +++ b/modules/runtime/dotnet.sh @@ -0,0 +1,107 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +MOD_NAME="dotnet" +MOD_GROUP="runtime" +MOD_DESCRIPTION=".NET SDK (LTS) with telemetry switched off system-wide" +MOD_REQUIRES="system" +MOD_OPTIONS="version|10.0|.NET SDK version: 10.0 (LTS, the default), 9.0 or 8.0 (LTS until November 2026)" + +# Sources: Ubuntu's archive (24.04 and 26.04 ship .NET 10), Canonical's backports PPA for what the archive lacks +# (.NET 10 on 22.04), AppStream on the RHEL family, Microsoft's repository on Debian (amd64: every version, arm64: +# .NET 10 only). +DOTNET_ENV_LINE=DOTNET_CLI_TELEMETRY_OPTOUT=1 +DOTNET_PROFILE=/etc/profile.d/lss-dotnet.sh +DOTNET_ENVIRONMENT_D=/etc/environment.d/90-lss-dotnet.conf + +mod_check() { + [[ $(opt version) =~ ^[0-9]{1,2}\.0$ ]] || die "LSS_DOTNET_VERSION must look like 10.0, got '$(opt version)'." + return +} + +# True when the package sources offer PACKAGE. +_dotnet_available() { + local package=$1 candidate + if os_is_debian; then + candidate=$(apt-cache policy "${package}" 2>/dev/null | awk '$1 == "Candidate:" { print $2 }') + [[ -n ${candidate} && ${candidate} != '(none)' ]] + else + [[ -n $(dnf -q repoquery "${package}" 2>/dev/null) ]] + fi + return +} + +mod_install() { + local version package + version=$(opt version) + package="dotnet-sdk-${version}" + pkg_is_installed "${package}" && return 0 + if os_is_debian; then + pkg_update_once + if ! _dotnet_available "${package}"; then + if [[ ${OS_ID} == ubuntu ]]; then + repo_add dotnet-backports + else + repo_add microsoft + fi + fi + fi + _dotnet_available "${package}" || die "dotnet: .NET ${version} is not available for ${OS_NAME} on $(os_deb_arch)." + pkg_install "${package}" + # Remembered, so remove takes away only what lss installed. + state_set "${LSS_MODULE}" packages "$(state_get "${LSS_MODULE}" packages) ${package}" + return +} + +# The .NET CLI sends usage data to Microsoft unless DOTNET_CLI_TELEMETRY_OPTOUT is set. Set for login shells +# (profile.d), for every PAM session including sudo, su and cron (/etc/environment) and for systemd user services +# (environment.d). +mod_configure() { + file_write 0644 root:root "${DOTNET_PROFILE}" </dev/null) || die "dotnet: dotnet does not run." + grep -q "^${version//./\\.}\." <<<"${sdks}" || die "dotnet: dotnet --list-sdks shows no .NET ${version} SDK." + output=$(env -i HOME=/root PATH=/usr/bin:/bin bash -lc 'printf "%s" "${DOTNET_CLI_TELEMETRY_OPTOUT:-}"' 2>/dev/null) || true + [[ ${output##*$'\n'} == 1 ]] || die "dotnet: DOTNET_CLI_TELEMETRY_OPTOUT is not set for login shells." + grep -qxF "${DOTNET_ENV_LINE}" /etc/environment || die "dotnet: /etc/environment does not switch telemetry off." + return +} + +mod_remove() { + local -a installed=() + local remaining + read -ra installed <<<"$(state_get "${LSS_MODULE}" packages)" + if [[ ${#installed[@]} -gt 0 ]]; then + pkg_remove "${installed[@]}" + fi + repo_remove microsoft + repo_remove dotnet-backports + file_remove "${DOTNET_PROFILE}" + file_remove "${DOTNET_ENVIRONMENT_D}" + if [[ -f /etc/environment ]] && grep -qxF "${DOTNET_ENV_LINE}" /etc/environment; then + files_backup /etc/environment + remaining=$(grep -vxF "${DOTNET_ENV_LINE}" /etc/environment || true) + # Rewritten in place, so the file keeps its owner, mode and SELinux label. + if [[ -n ${remaining} ]]; then + printf '%s\n' "${remaining}" >/etc/environment + else + : >/etc/environment + fi + files_note_change "removed ${DOTNET_ENV_LINE} from /etc/environment" + fi + return +} From 4e8cce19fe761d7582229687d1f5f0f26bacd52d Mon Sep 17 00:00:00 2001 From: VectoDE Date: Tue, 13 Oct 2026 21:31:13 +0200 Subject: [PATCH 16/92] Runtime modules: name the system by ID and version in messages ShellCheck (all optional checks) reads OS_NAME as a misspelt MOD_NAME; OS_ID and OS_VERSION say the same. The login-shell probe of dotnet keeps its single quotes on purpose. Refs #4 Signed-off-by: VectoDE Co-Authored-By: Claude Opus 5.5 --- modules/runtime/dotnet.sh | 3 ++- modules/runtime/java.sh | 4 ++-- modules/runtime/php.sh | 6 +++--- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/modules/runtime/dotnet.sh b/modules/runtime/dotnet.sh index ee9d47b..2934155 100644 --- a/modules/runtime/dotnet.sh +++ b/modules/runtime/dotnet.sh @@ -47,7 +47,7 @@ mod_install() { fi fi fi - _dotnet_available "${package}" || die "dotnet: .NET ${version} is not available for ${OS_NAME} on $(os_deb_arch)." + _dotnet_available "${package}" || die "dotnet: .NET ${version} is not available for ${OS_ID} ${OS_VERSION} on $(os_deb_arch)." pkg_install "${package}" # Remembered, so remove takes away only what lss installed. state_set "${LSS_MODULE}" packages "$(state_get "${LSS_MODULE}" packages) ${package}" @@ -75,6 +75,7 @@ mod_verify() { version=$(opt version) sdks=$(DOTNET_CLI_TELEMETRY_OPTOUT=1 DOTNET_NOLOGO=1 dotnet --list-sdks 2>/dev/null) || die "dotnet: dotnet does not run." grep -q "^${version//./\\.}\." <<<"${sdks}" || die "dotnet: dotnet --list-sdks shows no .NET ${version} SDK." + # shellcheck disable=SC2016 # expanded by the login shell output=$(env -i HOME=/root PATH=/usr/bin:/bin bash -lc 'printf "%s" "${DOTNET_CLI_TELEMETRY_OPTOUT:-}"' 2>/dev/null) || true [[ ${output##*$'\n'} == 1 ]] || die "dotnet: DOTNET_CLI_TELEMETRY_OPTOUT is not set for login shells." grep -qxF "${DOTNET_ENV_LINE}" /etc/environment || die "dotnet: /etc/environment does not switch telemetry off." diff --git a/modules/runtime/java.sh b/modules/runtime/java.sh index f677ad8..140abc6 100644 --- a/modules/runtime/java.sh +++ b/modules/runtime/java.sh @@ -49,9 +49,9 @@ mod_install() { fi if ! _java_available "${package}"; then # Debian 12 ships OpenJDK 17 only; Adoptium builds every version for every supported system. - log_info "java: ${OS_NAME} has no OpenJDK ${version}; using Eclipse Temurin from Adoptium." + log_info "java: ${OS_ID} ${OS_VERSION} has no OpenJDK ${version}; using Eclipse Temurin from Adoptium." repo_add adoptium - _java_available "${temurin}" || die "java: Java ${version} is available neither from ${OS_NAME} nor from Adoptium." + _java_available "${temurin}" || die "java: Java ${version} is available neither from ${OS_ID} ${OS_VERSION} nor from Adoptium." package=${temurin} fi pkg_install "${package}" diff --git a/modules/runtime/php.sh b/modules/runtime/php.sh index 61532fe..ab4412a 100644 --- a/modules/runtime/php.sh +++ b/modules/runtime/php.sh @@ -91,10 +91,10 @@ _php_resolve_debian() { fi if ! _php_apt_has "php${wanted}-fpm"; then if [[ ${OS_ID} == ubuntu && " ${PHP_PPA_SUITES} " != *" ${OS_CODENAME} "* ]]; then - die "php: PHP ${wanted} is not available for ${OS_NAME}; leave LSS_PHP_VERSION empty for the distribution's PHP." + die "php: PHP ${wanted} is not available for ${OS_ID} ${OS_VERSION}; leave LSS_PHP_VERSION empty for the distribution's PHP." fi repo_add "${repo}" - _php_apt_has "php${wanted}-fpm" || die "php: PHP ${wanted} is not available for ${OS_NAME}." + _php_apt_has "php${wanted}-fpm" || die "php: PHP ${wanted} is not available for ${OS_ID} ${OS_VERSION}." fi PHP_VERSION=${wanted} return @@ -142,7 +142,7 @@ _php_resolve_rhel() { available=${available%.*} [[ -n ${available} ]] || die "php: the package sources offer no php-fpm." if [[ -n ${wanted} && ${wanted} != "${available}" ]]; then - die "php: PHP ${wanted} is not available for ${OS_NAME} (it offers ${available}${streams:+ and the streams $(tr '\n' ' ' <<<"${streams}")})." + die "php: PHP ${wanted} is not available for ${OS_ID} ${OS_VERSION} (it offers ${available}${streams:+ and the streams $(tr '\n' ' ' <<<"${streams}")})." fi PHP_VERSION=${available} return From c678852409b34b7311197d25bdbe7c882f7b34b4 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Tue, 13 Oct 2026 21:37:58 +0200 Subject: [PATCH 17/92] Document the runtime modules and test them on every system Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- .github/workflows/integration.yml | 4 +++- CHANGELOG.md | 4 ++++ README.md | 5 +++++ docs/hardening.md | 2 ++ 4 files changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index e87e567..be6013f 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -31,7 +31,7 @@ jobs: fail-fast: false matrix: system: [debian-12, debian-13, ubuntu-22.04, ubuntu-24.04, ubuntu-26.04, rocky-9, almalinux-10] - group: [base, security, scanners, web] + group: [base, security, scanners, web, runtime] arch: [x64, arm64] include: - group: base @@ -43,6 +43,8 @@ jobs: # Each web server alone, then with certificates (Pebble) and sites; then HAProxy. - group: web modules: nginx,tls,vhost apache,tls,vhost caddy,tls,vhost haproxy + - group: runtime + modules: php,composer nginx,php node python ruby java go dotnet steps: - uses: step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25 # v2.22.1 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 86c212c..4779461 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,10 @@ now or later. - Web modules: `nginx`, `apache` and `caddy` (one of them owns ports 80 and 443), `tls` (certbot with a deploy hook that installs every certificate for the web server and reloads it), `vhost` (static, PHP and reverse-proxy sites, each with its own certificate) and `haproxy` (load balancing of TCP services with health checks). +- Runtime modules: `php` (PHP-FPM with a hardened, sandboxed pool on the web servers' socket), `composer`, `node` + (NodeSource), `python` (venv, pip, pipx), `ruby` (Bundler), `java` (OpenJDK, Temurin where missing), `go` (official + archive) and `dotnet` (.NET SDK, telemetry off), all from repositories with pinned keys or checksum-verified + downloads. - A warning for every setting that no module reads, so a misspelt key does not silently fall back to the default. - Quiet output: the output of apt, dnf and long-running tools such as the AIDE database build goes to the run log, and appears on the terminal only with `--verbose` or, with its last 40 lines, when the command fails. diff --git a/README.md b/README.md index a3bb08f..036eb35 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,11 @@ change, run `lss configure MODULE`. **Web:** `nginx`, `apache` or `caddy`, `tls` (certificates from Let's Encrypt), `vhost` (sites), `haproxy`. +**Runtimes:** `php` (PHP-FPM with a hardened pool for the web server), `composer`, `node` (Node.js 22, 24 or 26 from +NodeSource), `python` (venv, pip, pipx), `ruby` (Bundler), `java` (OpenJDK 21, Temurin where the distribution has +none), `go` (official archive), `dotnet` (.NET SDK 10, telemetry off). `LSS_PHP_VERSION`, `LSS_NODE_VERSION`, +`LSS_JAVA_VERSION` and `LSS_DOTNET_VERSION` pick another version. + The [hardening guide](docs/hardening.md) lists what each security module changes, the CIS Benchmark areas it covers and where lss deliberately deviates. Containers, backup, monitoring and mail follow as further module groups. diff --git a/docs/hardening.md b/docs/hardening.md index 9fd091c..696f8ae 100644 --- a/docs/hardening.md +++ b/docs/hardening.md @@ -34,6 +34,8 @@ Docker, databases and its own sudo rules) 78. CI checks that it stays at 82 on D | `guard` | Runs `lss verify` every day, repairs modules that failed or were changed outside lss, reports failed units, full disks, pending security updates, vulnerable packages, a needed reboot, a low Lynis index, AIDE/rkhunter/ClamAV findings and failed SSH logins; notes logins and bans | (operations) | | `nginx`, `apache`, `caddy` | One web server owns ports 80 and 443: HTTPS only with TLS 1.2 and 1.3 (Mozilla intermediate, ECDHE only), HSTS for two years, `nosniff`, `SAMEORIGIN` framing, a strict referrer and permissions policy, no version banner; unknown host names get no answer; dot files are never served; admin interfaces only on 127.0.0.1:8090 unless `LSS__ADMIN_PUBLIC=true` (then TLS and a password); the service runs in a systemd sandbox; fail2ban or CrowdSec read its logs | CIS Apache/nginx benchmarks, Mozilla TLS guidelines, OWASP Secure Headers | | `tls` | Certificates from Let's Encrypt (or any ACME server) with ECDSA keys, renewed by a timer; private keys readable by root and the web server group only; OCSP stapling where the certificate names a responder; guard warns 14 days before a certificate expires | Mozilla TLS guidelines | +| `php` | Own pool user `lss-php`, socket `0660 root:lss-web` only, the distribution's `www` pool off, `expose_php` and `display_errors` off, `allow_url_include` off, `cgi.fix_pathinfo=0`, secure, HTTP-only and SameSite session cookies with strict mode, a private session directory, `exec`, `system`, `proc_open` and similar functions disabled for the web pool, only `.php` executed, systemd sandbox (read-only system except `/var/lib/php` and `LSS_PHP_WRITABLE_PATHS`) | OWASP PHP Configuration Cheat Sheet | +| `dotnet` | `DOTNET_CLI_TELEMETRY_OPTOUT=1` for every login | (privacy) | | `haproxy` | Statistics only on 127.0.0.1, admin socket readable by root only, chroot, systemd sandbox, ports in the firewall only for services that are not `local` | (operations) | ## Deliberate deviations From 0bcc8dacf9dee669c368d46a300d0d35522937ff Mon Sep 17 00:00:00 2001 From: VectoDE Date: Tue, 13 Oct 2026 21:53:03 +0200 Subject: [PATCH 18/92] Add the phpmyadmin, adminer and pgadmin modules The three database admin interfaces live on the admin port of the installed web server (127.0.0.1:8090 unless admin_public is set): phpMyAdmin under /phpmyadmin/, Adminer under /adminer/ and pgAdmin under /pgadmin4/. phpMyAdmin 5.2.3 and Adminer 6.1.2 are downloaded with the SHA-256 their vendors publish (new data/checksums.txt). phpMyAdmin gets a config.inc.php with a blowfish secret, cookie login against 127.0.0.1 only, no arbitrary servers, no root login by default, no version check or error reports, and its template cache in PHP-FPM's private /tmp; the setup wizard is not installed. Adminer gets a login plugin that offers and allows only the configured local servers. pgAdmin 4 9.18 runs from a virtual environment on every system and architecture (the vendor packages are x86_64-only and have no WSGI server), with pinned, checksummed wheels of pgadmin4 and gunicorn, as its own sandboxed service lss-pgadmin on 127.0.0.1:5050. The first user is created non-interactively; its password is the secret pgadmin-admin-password. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- data/checksums.txt | 11 ++ modules/database/adminer.sh | 129 ++++++++++++ modules/database/pgadmin.sh | 270 ++++++++++++++++++++++++++ modules/database/phpmyadmin.sh | 227 ++++++++++++++++++++++ templates/adminer/adminer-plugins.php | 47 +++++ 5 files changed, 684 insertions(+) create mode 100644 modules/database/adminer.sh create mode 100644 modules/database/pgadmin.sh create mode 100644 modules/database/phpmyadmin.sh create mode 100644 templates/adminer/adminer-plugins.php diff --git a/data/checksums.txt b/data/checksums.txt index 4ce5021..adff2f1 100644 --- a/data/checksums.txt +++ b/data/checksums.txt @@ -11,3 +11,14 @@ composer any 7a2d379d5b8ffdaa028580ef26494c36d2feef4b178d3dd1473a4dbc5e17c8d6 ht # Go 1.27.2: https://go.dev/dl/?mode=json and https://dl.google.com/go/go1.27.2.linux-.tar.gz.sha256 (2026-10-13). go x86_64 ecbadb99091a3f46e31f5f934b068b1864eafa7995211b39eaddf76996045fe5 https://go.dev/dl/go1.27.2.linux-amd64.tar.gz go aarch64 94f3e30b8e374bc285e7dadc11e0865726b9bc6e85b841ccceaabc0214c6b7c8 https://go.dev/dl/go1.27.2.linux-arm64.tar.gz + +# phpMyAdmin 5.2.3: SHA-256 from https://files.phpmyadmin.net/phpMyAdmin/5.2.3/phpMyAdmin-5.2.3-all-languages.tar.xz.sha256 +# (checked 2026-10-13). +phpmyadmin-5.2.3 any 57881348297c4412f86c410547cf76b4d8a236574dd2c6b7d6a2beebe7fc44e3 https://files.phpmyadmin.net/phpMyAdmin/5.2.3/phpMyAdmin-5.2.3-all-languages.tar.xz +# Adminer 6.1.2: SHA-256 digest of the release asset on GitHub (gh api repos/vrana/adminer/releases/tags/v6.1.2, +# checked 2026-10-13). +adminer-6.1.2 any b32316156bd18ffa99c1a27617142c2e5ad72260e37f15da4e9937a3dbb15f57 https://github.com/vrana/adminer/releases/download/v6.1.2/adminer-6.1.2.php +# pgAdmin 4 9.18 and gunicorn 26.2.0: SHA-256 of the wheels as published by PyPI (https://pypi.org/pypi/pgadmin4/9.18/json, +# https://pypi.org/pypi/gunicorn/26.2.0/json, checked 2026-10-13). +pgadmin4-9.18 any 9ea084fc416265e4fc95c9fb6e6ae0f6668d34da8670c9b65c79cea054124356 https://files.pythonhosted.org/packages/0e/80/86d7e7123e2b6de41890bd1edc3e27348e2572acea337088faa0ec41c7f5/pgadmin4-9.18-py3-none-any.whl +gunicorn-26.2.0 any bd249d0b3f7972f7432f0a6b6ff3b3ee2d129f70cd1ff6c09a9dd9e29a2b88e3 https://files.pythonhosted.org/packages/fe/85/7522a52e5e2f42faf1a129113ab63e548c42e103e9af395b7bfe65e403e2/gunicorn-26.2.0-py3-none-any.whl diff --git a/modules/database/adminer.sh b/modules/database/adminer.sh new file mode 100644 index 0000000..f091c06 --- /dev/null +++ b/modules/database/adminer.sh @@ -0,0 +1,129 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +MOD_NAME="adminer" +MOD_GROUP="database" +MOD_DESCRIPTION="Adminer 6.1.2, one-file database manager for MariaDB, MySQL and PostgreSQL, on the admin port (127.0.0.1:8090/adminer/), local servers only" +MOD_REQUIRES="system nginx|apache|caddy php" +MOD_OPTIONS="servers|mysql:127.0.0.1:3306 pgsql:127.0.0.1:5432|Database servers offered at login as driver:host:port (driver mysql for MariaDB and MySQL, or pgsql), separated by spaces; Adminer refuses every other server" + +# A new version is a new entry adminer- in data/checksums.txt and a new ADMINER_VERSION. +ADMINER_VERSION=6.1.2 +ADMINER_DIR=/usr/share/lss/adminer +ADMINER_CACHE=/var/cache/lss/adminer +ADMINER_PATH=adminer + +mod_check() { + local entry port count=0 + for entry in $(opt servers); do + [[ ${entry} =~ ^(mysql|pgsql):[A-Za-z0-9.-]+:([0-9]{1,5})$ ]] || + die "LSS_ADMINER_SERVERS: '${entry}' is not driver:host:port (driver mysql or pgsql)." + port=${BASH_REMATCH[2]} + ((port >= 1 && port <= 65535)) || die "LSS_ADMINER_SERVERS: '${entry}' has no valid port." + count=$((count + 1)) + done + ((count > 0)) || die "LSS_ADMINER_SERVERS must name at least one database server." + fetch_entry "adminer-${ADMINER_VERSION}" >/dev/null || + die "adminer: data/checksums.txt has no checksum for Adminer ${ADMINER_VERSION}." + return +} + +# The single-file release from GitHub, checked against the SHA-256 GitHub publishes for the asset. The copy in the +# cache saves a download on the next run (fetch_verified keeps a file whose checksum matches). +mod_install() { + local file + pkg_install curl + fetch_verified "adminer-${ADMINER_VERSION}" "${ADMINER_CACHE}/adminer-${ADMINER_VERSION}.php" + for file in "${ADMINER_CACHE}"/*; do + if [[ -f ${file} && ${file} != "${ADMINER_CACHE}/adminer-${ADMINER_VERSION}.php" ]]; then + rm -f -- "${file}" + fi + done + return +} + +# _adminer_servers: the PHP array entries of the login plugin, one per server; the key is what the login form shows. +_adminer_servers() { + local entry driver address + for entry in $(opt servers); do + driver=${entry%%:*} + address=${entry#*:} + # Adminer calls its MySQL driver "server". + [[ ${driver} != mysql ]] || driver=server + printf "\t\t'%s' => array('server' => '%s', 'driver' => '%s'),\n" "${address}" "${address}" "${driver}" + done + return +} + +mod_configure() { + # Downloads again only when the cached copy is gone or damaged. + fetch_verified "adminer-${ADMINER_VERSION}" "${ADMINER_CACHE}/adminer-${ADMINER_VERSION}.php" + file_write 0644 root:root "${ADMINER_DIR}/index.php" <"${ADMINER_CACHE}/adminer-${ADMINER_VERSION}.php" + ADMINER_SERVERS=$(_adminer_servers) + template_install adminer adminer-plugins.php 0644 root:root "${ADMINER_DIR}/adminer-plugins.php" + web_admin_write adminer "${ADMINER_PATH}" php "${ADMINER_DIR}" + if [[ ${LSS_FILE_CHANGED} == 1 ]]; then + web_reload + fi + _adminer_selinux_db + return +} + +# With SELinux enforcing, PHP-FPM (httpd_t) may only connect to database ports once httpd_can_network_connect_db is +# on. The boolean stays on after a removal; phpMyAdmin and other PHP applications need it as well. +_adminer_selinux_db() { + command -v getsebool >/dev/null 2>&1 || return 0 + [[ $(getenforce 2>/dev/null) == Enforcing ]] || return 0 + if [[ $(getsebool httpd_can_network_connect_db 2>/dev/null) != *' on' ]]; then + setsebool -P httpd_can_network_connect_db 1 + files_note_change "SELinux boolean httpd_can_network_connect_db" + fi + return +} + +# _adminer_get PATH: the page PATH on the admin port as the web server serves it, followed by a line with the HTTP +# status. A public admin port speaks HTTPS with the default certificate and asks for the admin password, which curl +# reads from standard input, so it never shows in the process list. +_adminer_get() { + local path=$1 password + if config_bool "$(config_key "$(web_server)" admin_public)"; then + secret_load web-admin-password password 32 + curl -sk --max-time 30 -K - -w '\n%{http_code}' "https://127.0.0.1:${WEB_ADMIN_PORT}/${path}" \ + <<<"user = \"admin:${password}\"" + else + curl -s --max-time 30 -w '\n%{http_code}' "http://127.0.0.1:${WEB_ADMIN_PORT}/${path}" + fi + return +} + +mod_verify() { + local page status first + [[ $(sha256sum "${ADMINER_DIR}/index.php" | cut -d ' ' -f 1) == "$(fetch_entry "adminer-${ADMINER_VERSION}" | cut -d ' ' -f 1)" ]] || + die "adminer: ${ADMINER_DIR}/index.php is not Adminer ${ADMINER_VERSION} as published." + page=$(_adminer_get "${ADMINER_PATH}/") || true + status=${page##*$'\n'} + [[ ${status} == 200 ]] || + die "adminer: the admin port answers ${status:-nothing} for /${ADMINER_PATH}/ instead of the login page." + grep -qF 'auth[username]' <<<"${page}" || die "adminer: /${ADMINER_PATH}/ shows no login form." + # The server list of the login plugin proves that Adminer loaded adminer-plugins.php. + first=$(opt servers) + first=${first%% *} + grep -qF "${first#*:}" <<<"${page}" || die "adminer: the login form does not offer the configured servers." + if grep -qE '(Deprecated|Warning|Notice|Fatal error):|^(Deprecated|Warning|Fatal error): ' <<<"${page}"; then + die "adminer: the login page shows PHP errors; display_errors must be off." + fi + return +} + +mod_remove() { + web_admin_remove adminer + web_reload + file_remove "${ADMINER_DIR}/adminer-plugins.php" + file_remove "${ADMINER_DIR}/index.php" + rmdir "${ADMINER_DIR}" 2>/dev/null || true + rmdir "$(dirname "${ADMINER_DIR}")" 2>/dev/null || true + rm -rf -- "${ADMINER_CACHE}" + rmdir "$(dirname "${ADMINER_CACHE}")" 2>/dev/null || true + return +} diff --git a/modules/database/pgadmin.sh b/modules/database/pgadmin.sh new file mode 100644 index 0000000..600c167 --- /dev/null +++ b/modules/database/pgadmin.sh @@ -0,0 +1,270 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +MOD_NAME="pgadmin" +MOD_GROUP="database" +MOD_DESCRIPTION="pgAdmin 4 9.18 in server mode for PostgreSQL, behind the admin port (127.0.0.1:8090/pgadmin4/), own sandboxed service" +MOD_REQUIRES="system nginx|apache|caddy postgresql" +MOD_OPTIONS="email|admin@localhost.localdomain|Login e-mail of the first pgAdmin user (password: lss secrets show pgadmin-admin-password); used when pgAdmin's database is created +port|5050|Port pgAdmin listens on, on 127.0.0.1 only; the web server forwards /pgadmin4/ on the admin port to it" +MOD_SECRETS="pgadmin-admin-password" + +# Why a virtual environment from PyPI on every system: pgAdmin's own packages exist for x86_64 only (apt and dnf), +# so aarch64 needs this path anyway, and one path is tested the same way on all systems. The packages also leave out +# a WSGI server. pgadmin4 and gunicorn are pinned wheels checked against the SHA-256 PyPI publishes +# (data/checksums.txt); pip resolves their dependencies over HTTPS within the narrow version ranges pgAdmin pins. +# Python 3.12 on the RHEL 9 family, because the system Python 3.9 is too old for gunicorn. +PGADMIN_VERSION=9.18 +PGADMIN_GUNICORN=26.2.0 +PGADMIN_VENV=/opt/lss/pgadmin +PGADMIN_DATA=/var/lib/lss-pgadmin +PGADMIN_LOGDIR=/var/log/lss-pgadmin +# pgAdmin reads /etc/pgadmin/config_system.py last, after the configuration files inside its installation, so the +# settings survive a rebuild of the virtual environment. +PGADMIN_CONFIG=/etc/pgadmin/config_system.py +PGADMIN_USER=lss-pgadmin +PGADMIN_UNIT=lss-pgadmin +PGADMIN_PATH=pgadmin4 + +mod_check() { + local email port + email=$(opt email) + [[ ${email} =~ ^[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$ ]] || + die "LSS_PGADMIN_EMAIL '${email}' is not an e-mail address with a dotted domain (pgAdmin needs one)." + port=$(opt_int port) + ((port >= 1024 && port <= 65535)) || die "LSS_PGADMIN_PORT must be a port number from 1024 to 65535." + [[ ${port} != "${WEB_ADMIN_PORT}" ]] || die "LSS_PGADMIN_PORT must differ from the admin port ${WEB_ADMIN_PORT}." + fetch_entry "pgadmin4-${PGADMIN_VERSION}" >/dev/null || + die "pgadmin: data/checksums.txt has no checksum for pgAdmin ${PGADMIN_VERSION}." + fetch_entry "gunicorn-${PGADMIN_GUNICORN}" >/dev/null || + die "pgadmin: data/checksums.txt has no checksum for gunicorn ${PGADMIN_GUNICORN}." + return +} + +_pgadmin_python() { + if os_is_rhel && [[ ${OS_MAJOR} == 9 ]]; then printf 'python3.12'; else printf 'python3'; fi + return +} + +# What the virtual environment was built from; a change (new pins, new Python) rebuilds it. +_pgadmin_release() { + local python=$1 version + version=$("${python}" -I -c 'import sys; print("%d.%d" % sys.version_info[:2])') + printf 'pgadmin4 %s gunicorn %s python %s' "${PGADMIN_VERSION}" "${PGADMIN_GUNICORN}" "${version}" + return +} + +mod_install() { + local python release tmp + if os_is_debian; then + pkg_install curl python3 python3-venv + elif [[ ${OS_MAJOR} == 9 ]]; then + pkg_install curl python3.12 python3.12-pip + else + pkg_install curl python3 python3-pip + fi + python=$(_pgadmin_python) + release=$(_pgadmin_release "${python}") + if [[ -f ${PGADMIN_VENV}/.lss-release && $(<"${PGADMIN_VENV}/.lss-release") == "${release}" ]]; then + return 0 + fi + tmp=$(mktemp -d) + # pip takes the version from the file name, so the wheels keep their published names. + fetch_verified "pgadmin4-${PGADMIN_VERSION}" "${tmp}/pgadmin4-${PGADMIN_VERSION}-py3-none-any.whl" + fetch_verified "gunicorn-${PGADMIN_GUNICORN}" "${tmp}/gunicorn-${PGADMIN_GUNICORN}-py3-none-any.whl" + # A virtual environment cannot be moved, so the old one goes first; pgAdmin's data stays in PGADMIN_DATA. + if svc_is_active "${PGADMIN_UNIT}"; then + systemctl stop "${PGADMIN_UNIT}" + fi + rm -rf -- "${PGADMIN_VENV}" + install -d -m 0755 "$(dirname "${PGADMIN_VENV}")" + log_info "Installing pgAdmin ${PGADMIN_VERSION} into ${PGADMIN_VENV} (a few minutes)" + log_command "${python}" -m venv "${PGADMIN_VENV}" || + die "pgadmin: ${python} could not create the virtual environment ${PGADMIN_VENV}." + if ! PIP_ROOT_USER_ACTION=ignore log_command "${PGADMIN_VENV}/bin/pip" install --no-cache-dir --no-input \ + --disable-pip-version-check --prefer-binary "${tmp}/pgadmin4-${PGADMIN_VERSION}-py3-none-any.whl" \ + "${tmp}/gunicorn-${PGADMIN_GUNICORN}-py3-none-any.whl"; then + rm -rf -- "${PGADMIN_VENV}" "${tmp}" + die "pgadmin: pip could not install pgAdmin ${PGADMIN_VERSION} and its dependencies; see the log." + fi + printf '%s\n' "${release}" >"${PGADMIN_VENV}/.lss-release" + rm -rf -- "${tmp}" + files_note_change "installed pgAdmin ${PGADMIN_VERSION} in ${PGADMIN_VENV}" + return +} + +# The directory with pgAdmin4.py (the WSGI application) inside the virtual environment. +_pgadmin_app_dir() { + local dir + for dir in "${PGADMIN_VENV}"/lib/python3*/site-packages/pgadmin4; do + if [[ -f ${dir}/pgAdmin4.py ]]; then + printf '%s' "${dir}" + return 0 + fi + done + die "pgadmin: pgAdmin is missing in ${PGADMIN_VENV}; run lss install pgadmin." +} + +_pgadmin_user() { + getent passwd "${PGADMIN_USER}" >/dev/null && return 0 + useradd --system --user-group --home-dir "${PGADMIN_DATA}" --no-create-home --shell /usr/sbin/nologin \ + --comment "pgAdmin (lss)" "${PGADMIN_USER}" + files_note_change "created user ${PGADMIN_USER}" + return +} + +# Session cookies only over HTTPS when the admin port is public (it speaks HTTPS then); on 127.0.0.1 it is plain HTTP +# through an SSH tunnel. +_pgadmin_cookie_secure() { + if config_bool "$(config_key "$(web_server)" admin_public)"; then printf 'True'; else printf 'False'; fi + return +} + +# The configuration database and the first user, created once, as the service user. The password reaches pgAdmin +# through the environment of that one command, never through its command line. +_pgadmin_setup_db() { + local app=$1 email password + [[ ! -s ${PGADMIN_DATA}/pgadmin4.db ]] || return 0 + email=$(opt email) + secret_load pgadmin-admin-password password 32 + log_info "Creating pgAdmin's database with the user ${email} (lss secrets show pgadmin-admin-password)" + ( + cd "${PGADMIN_DATA}" + export PGADMIN_SETUP_EMAIL=${email} + export PGADMIN_SETUP_PASSWORD=${password} + log_command runuser -u "${PGADMIN_USER}" -- "${PGADMIN_VENV}/bin/python3" -B "${app}/setup.py" setup-db + ) || die "pgadmin: pgAdmin could not create its database in ${PGADMIN_DATA}; see the log." + [[ -s ${PGADMIN_DATA}/pgadmin4.db ]] || die "pgadmin: pgAdmin created no database in ${PGADMIN_DATA}." + files_note_change "pgAdmin database and user ${email}" + return +} + +mod_configure() { + local app port secure + app=$(_pgadmin_app_dir) + port=$(opt_int port) + secure=$(_pgadmin_cookie_secure) + _pgadmin_user + install -d -m 0700 -o "${PGADMIN_USER}" -g "${PGADMIN_USER}" "${PGADMIN_DATA}" + install -d -m 0750 -o "${PGADMIN_USER}" -g "${PGADMIN_USER}" "${PGADMIN_LOGDIR}" + install -d -m 0755 "$(dirname "${PGADMIN_CONFIG}")" + file_write 0640 "root:${PGADMIN_USER}" "${PGADMIN_CONFIG}" </dev/null) || true + [[ ${answer} != PING ]] || break + sleep 2 + done + [[ ${answer} == PING ]] || die "pgadmin: pgAdmin does not answer on 127.0.0.1:${port}; see journalctl -u ${PGADMIN_UNIT}." + page=$(_pgadmin_get "${PGADMIN_PATH}/login") || true + status=${page##*$'\n'} + [[ ${status} == 200 ]] || + die "pgadmin: the admin port answers ${status:-nothing} for /${PGADMIN_PATH}/login instead of the login page." + grep -qi 'pgadmin' <<<"${page}" || die "pgadmin: /${PGADMIN_PATH}/login is not pgAdmin's login page." + return +} + +mod_remove() { + web_admin_remove pgadmin + web_reload + svc_disable "${PGADMIN_UNIT}" + svc_dropin_remove "${PGADMIN_UNIT}.service" lss-sandbox + file_remove "/etc/systemd/system/${PGADMIN_UNIT}.service" + svc_daemon_reload + file_remove "${PGADMIN_CONFIG}" + rmdir "$(dirname "${PGADMIN_CONFIG}")" 2>/dev/null || true + if [[ -d ${PGADMIN_VENV} ]]; then + rm -rf -- "${PGADMIN_VENV}" + files_note_change "removed ${PGADMIN_VENV}" + fi + rmdir "$(dirname "${PGADMIN_VENV}")" 2>/dev/null || true + # Users, saved servers and preferences stay unless purged; the service user keeps owning them. + if [[ ${LSS_PURGE:-0} == 1 ]]; then + rm -rf -- "${PGADMIN_DATA}" "${PGADMIN_LOGDIR}" + if getent passwd "${PGADMIN_USER}" >/dev/null; then + userdel "${PGADMIN_USER}" >/dev/null 2>&1 || true + fi + if getent group "${PGADMIN_USER}" >/dev/null; then + groupdel "${PGADMIN_USER}" >/dev/null 2>&1 || true + fi + secret_remove pgadmin-admin-password + files_note_change "removed pgAdmin's data" + fi + return +} diff --git a/modules/database/phpmyadmin.sh b/modules/database/phpmyadmin.sh new file mode 100644 index 0000000..b5fef27 --- /dev/null +++ b/modules/database/phpmyadmin.sh @@ -0,0 +1,227 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +MOD_NAME="phpmyadmin" +MOD_GROUP="database" +MOD_DESCRIPTION="phpMyAdmin 5.2.3 for the local MariaDB or MySQL server, on the admin port (127.0.0.1:8090/phpmyadmin/), login with the database users" +MOD_REQUIRES="system nginx|apache|caddy php mariadb|mysql" +MOD_OPTIONS="port|3306|Port of the MariaDB or MySQL server on 127.0.0.1 (the only server phpMyAdmin connects to) +allow_root|false|Allow logins as the database user root (root should use the socket on the server instead) +login_timeout|1440|Seconds without activity before phpMyAdmin asks for the password again" +MOD_SECRETS="phpmyadmin-blowfish" + +# A new version is a new entry phpmyadmin- in data/checksums.txt and a new PMA_VERSION; the next install +# replaces the whole tree, so no file of the old version stays behind. +PMA_VERSION=5.2.3 +PMA_DIR=/usr/share/lss/phpmyadmin +PMA_CONFIG=${PMA_DIR}/config.inc.php +PMA_PATH=phpmyadmin +# Template cache and uploads. PHP-FPM creates the directory itself (mode 0770, owned by the pool user) inside its +# private /tmp, which no other account can see; see _phpmyadmin_private_tmp. +PMA_TEMP=/tmp/lss-phpmyadmin + +mod_check() { + local port timeout + port=$(opt_int port) + ((port >= 1 && port <= 65535)) || die "LSS_PHPMYADMIN_PORT must be a port number (1-65535)." + opt_bool allow_root || true + timeout=$(opt_int login_timeout) + ((timeout >= 60)) || die "LSS_PHPMYADMIN_LOGIN_TIMEOUT must be at least 60 seconds." + fetch_entry "phpmyadmin-${PMA_VERSION}" >/dev/null || + die "phpmyadmin: data/checksums.txt has no checksum for phpMyAdmin ${PMA_VERSION}." + return +} + +# The release archive from files.phpmyadmin.net, checked against the published SHA-256, unpacked next to the old tree +# and swapped in with a rename. The setup wizard (it writes configurations through the browser) and the examples are +# left out; lss writes config.inc.php itself. +mod_install() { + local tmp + pkg_install_family "curl tar xz-utils" "curl tar xz" + [[ ! -f ${PMA_DIR}/RELEASE-DATE-${PMA_VERSION} ]] || return 0 + install -d -m 0755 "$(dirname "${PMA_DIR}")" + tmp=$(mktemp -d "$(dirname "${PMA_DIR}")/.phpmyadmin.XXXXXX") + fetch_verified "phpmyadmin-${PMA_VERSION}" "${tmp}/phpmyadmin.tar.xz" + install -d -m 0755 "${tmp}/tree" + tar -xJf "${tmp}/phpmyadmin.tar.xz" -C "${tmp}/tree" --strip-components=1 --no-same-owner --no-same-permissions || + die "phpmyadmin: the archive of phpMyAdmin ${PMA_VERSION} does not unpack." + [[ -f ${tmp}/tree/RELEASE-DATE-${PMA_VERSION} ]] || die "phpmyadmin: the archive does not contain phpMyAdmin ${PMA_VERSION}." + rm -rf -- "${tmp}/tree/setup" "${tmp}/tree/examples" "${tmp}/tree/config.sample.inc.php" + chown -R root:root "${tmp}/tree" + chmod -R u=rwX,go=rX "${tmp}/tree" + rm -rf -- "${PMA_DIR}.old" + if [[ -d ${PMA_DIR} ]]; then + mv -- "${PMA_DIR}" "${PMA_DIR}.old" + fi + mv -- "${tmp}/tree" "${PMA_DIR}" + rm -rf -- "${PMA_DIR}.old" "${tmp}" + if command -v restorecon >/dev/null 2>&1; then + restorecon -R "${PMA_DIR}" 2>/dev/null || true + fi + files_note_change "installed phpMyAdmin ${PMA_VERSION}" + return +} + +mod_configure() { + local secret group port timeout allow_root=false unit + _phpmyadmin_private_tmp + secret_load phpmyadmin-blowfish secret 32 + group=$(_phpmyadmin_fpm_group) + port=$(opt_int port) + timeout=$(opt_int login_timeout) + opt_bool allow_root && allow_root=true + # Read by PHP-FPM only: the blowfish secret encrypts the database password in the login cookie. + file_write 0640 "root:${group}" "${PMA_CONFIG}" </dev/null | awk '{ print $1 }' + return +} + +# phpMyAdmin compiles its templates into PHP files below TempDir and includes them. Inside the private /tmp of the +# PHP-FPM service (svc_sandbox sets PrivateTmp) nobody else can plant such a file; a shared /tmp is refused. +_phpmyadmin_private_tmp() { + local unit found=0 + while IFS= read -r unit; do + [[ -n ${unit} ]] || continue + found=1 + case $(systemctl show -p PrivateTmp --value "${unit}" 2>/dev/null) in + yes | disconnected) ;; + *) die "phpmyadmin: ${unit} shares /tmp with other services; phpMyAdmin needs the private /tmp of the php module's sandbox (lss install php)." ;; + esac + done < <(_phpmyadmin_fpm_units) + if [[ ${found} == 0 ]]; then + log_warn "phpmyadmin: no PHP-FPM service found; phpMyAdmin needs the php module." + fi + return +} + +# _phpmyadmin_fpm_group: the group of the PHP-FPM pool on WEB_PHP_SOCKET, which has to read config.inc.php; WEB_GROUP +# when the pool configuration does not name one. +_phpmyadmin_fpm_group() { + local file pool_user='' pool_group='' + for file in /etc/php/*/fpm/pool.d/*.conf /etc/php-fpm.d/*.conf; do + [[ -f ${file} ]] || continue + grep -qE "^[[:space:]]*listen[[:space:]]*=[[:space:]]*${WEB_PHP_SOCKET}[[:space:]]*\$" "${file}" || continue + pool_user=$(sed -nE 's/^[[:space:]]*user[[:space:]]*=[[:space:]]*([A-Za-z0-9_.-]+).*/\1/p' "${file}" | head -n 1) + pool_group=$(sed -nE 's/^[[:space:]]*group[[:space:]]*=[[:space:]]*([A-Za-z0-9_.-]+).*/\1/p' "${file}" | head -n 1) + break + done + if [[ -z ${pool_group} && -n ${pool_user} ]]; then + pool_group=$(id -gn "${pool_user}" 2>/dev/null || true) + fi + if [[ -z ${pool_group} ]] || ! getent group "${pool_group}" >/dev/null; then + pool_group=${WEB_GROUP} + fi + printf '%s' "${pool_group}" + return +} + +# With SELinux enforcing, PHP-FPM (httpd_t) may only connect to database ports once httpd_can_network_connect_db is +# on. The boolean stays on after a removal; Adminer and other PHP applications need it as well. +_phpmyadmin_selinux_db() { + command -v getsebool >/dev/null 2>&1 || return 0 + [[ $(getenforce 2>/dev/null) == Enforcing ]] || return 0 + if [[ $(getsebool httpd_can_network_connect_db 2>/dev/null) != *' on' ]]; then + setsebool -P httpd_can_network_connect_db 1 + files_note_change "SELinux boolean httpd_can_network_connect_db" + fi + return +} + +# _phpmyadmin_get PATH: the page PATH on the admin port as the web server serves it, followed by a line with the HTTP +# status. A public admin port speaks HTTPS with the default certificate and asks for the admin password, which curl +# reads from standard input, so it never shows in the process list. +_phpmyadmin_get() { + local path=$1 password + if config_bool "$(config_key "$(web_server)" admin_public)"; then + secret_load web-admin-password password 32 + curl -sk --max-time 30 -K - -w '\n%{http_code}' "https://127.0.0.1:${WEB_ADMIN_PORT}/${path}" \ + <<<"user = \"admin:${password}\"" + else + curl -s --max-time 30 -w '\n%{http_code}' "http://127.0.0.1:${WEB_ADMIN_PORT}/${path}" + fi + return +} + +mod_verify() { + local page status + [[ -f ${PMA_DIR}/RELEASE-DATE-${PMA_VERSION} ]] || die "phpmyadmin: phpMyAdmin ${PMA_VERSION} is missing in ${PMA_DIR}." + [[ ! -e ${PMA_DIR}/setup ]] || die "phpmyadmin: the setup wizard ${PMA_DIR}/setup must not exist." + [[ $(stat -c '%a %U' "${PMA_CONFIG}") == '640 root' ]] || die "phpmyadmin: ${PMA_CONFIG} must be mode 640, owned by root." + page=$(_phpmyadmin_get "${PMA_PATH}/") || true + status=${page##*$'\n'} + [[ ${status} == 200 ]] || + die "phpmyadmin: the admin port answers ${status:-nothing} for /${PMA_PATH}/ instead of the login page." + grep -qF 'name="pma_username"' <<<"${page}" || + die "phpmyadmin: /${PMA_PATH}/ shows no login form; PHP may lack the mysqli extension." + # PHP 8.4 is newer than the PHP versions phpMyAdmin 5.2 names; a deprecation notice in the page means PHP shows + # errors to visitors (display_errors, set by the php module). + if grep -qE '(Deprecated|Warning|Notice|Fatal error):|^(Deprecated|Warning|Fatal error): ' <<<"${page}"; then + die "phpmyadmin: the login page shows PHP errors; display_errors must be off." + fi + return +} + +mod_remove() { + web_admin_remove phpmyadmin + web_reload + file_remove "${PMA_CONFIG}" + if [[ -d ${PMA_DIR} ]]; then + rm -rf -- "${PMA_DIR}" + files_note_change "removed ${PMA_DIR}" + fi + rmdir "$(dirname "${PMA_DIR}")" 2>/dev/null || true + if [[ ${LSS_PURGE:-0} == 1 ]]; then + secret_remove phpmyadmin-blowfish + fi + return +} diff --git a/templates/adminer/adminer-plugins.php b/templates/adminer/adminer-plugins.php new file mode 100644 index 0000000..68464c6 --- /dev/null +++ b/templates/adminer/adminer-plugins.php @@ -0,0 +1,47 @@ + */ + protected $servers = array( +{{ADMINER_SERVERS}} + ); + + function __construct() { + if (isset($_POST['auth']['server'])) { + $server = (string) $_POST['auth']['server']; + $_POST['auth']['driver'] = isset($this->servers[$server]) ? $this->servers[$server]['driver'] : ''; + } + } + + // Shown in Adminer's list of plugins; the parent class would look up a translation this plugin does not have. + function description() { + return 'Offers only the database servers lss configured'; + } + + function credentials() { + $server = isset($this->servers[Adminer\SERVER]) ? $this->servers[Adminer\SERVER]['server'] : ''; + $username = isset($_GET['username']) ? (string) $_GET['username'] : ''; + return array($server, $username, Adminer\get_password()); + } + + function login($login, $password) { + if (!isset($this->servers[Adminer\SERVER]) || $this->servers[Adminer\SERVER]['driver'] !== Adminer\DRIVER) { + return false; + } + } + + function loginFormField($name, $heading, $value) { + if ($name == 'driver') { + return ''; + } + if ($name == 'server') { + return $heading . Adminer\html_select('auth[server]', array_keys($this->servers), Adminer\SERVER) . "\n"; + } + } +} + +return array(new AdminerLssServers()); From fb8501988811207d3e264aad6f1372f5a4e4b960 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Tue, 13 Oct 2026 21:55:47 +0200 Subject: [PATCH 19/92] Document the database tools; WebSockets through Apache's admin proxy pgAdmin uses Socket.IO; Apache now upgrades those connections on the admin port as nginx and Caddy do. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- CHANGELOG.md | 2 ++ README.md | 3 +++ docs/hardening.md | 3 +++ lib/web.sh | 2 +- 4 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4779461..b3f059f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,8 @@ now or later. (NodeSource), `python` (venv, pip, pipx), `ruby` (Bundler), `java` (OpenJDK, Temurin where missing), `go` (official archive) and `dotnet` (.NET SDK, telemetry off), all from repositories with pinned keys or checksum-verified downloads. +- Database tools: `phpmyadmin`, `adminer` and `pgadmin` on the admin port of the web server, from checksum-verified + downloads, limited to the local database servers; pgAdmin runs as its own sandboxed service. - A warning for every setting that no module reads, so a misspelt key does not silently fall back to the default. - Quiet output: the output of apt, dnf and long-running tools such as the AIDE database build goes to the run log, and appears on the terminal only with `--verbose` or, with its last 40 lines, when the command fails. diff --git a/README.md b/README.md index 036eb35..ca5294c 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,9 @@ NodeSource), `python` (venv, pip, pipx), `ruby` (Bundler), `java` (OpenJDK 21, T none), `go` (official archive), `dotnet` (.NET SDK 10, telemetry off). `LSS_PHP_VERSION`, `LSS_NODE_VERSION`, `LSS_JAVA_VERSION` and `LSS_DOTNET_VERSION` pick another version. +**Database tools:** `phpmyadmin`, `adminer` (only the local database servers) and `pgadmin` (its own service; the +first password with `lss secrets show pgadmin-admin-password`), all on the admin port. + The [hardening guide](docs/hardening.md) lists what each security module changes, the CIS Benchmark areas it covers and where lss deliberately deviates. Containers, backup, monitoring and mail follow as further module groups. diff --git a/docs/hardening.md b/docs/hardening.md index 696f8ae..c2b8f35 100644 --- a/docs/hardening.md +++ b/docs/hardening.md @@ -36,6 +36,9 @@ Docker, databases and its own sudo rules) 78. CI checks that it stays at 82 on D | `tls` | Certificates from Let's Encrypt (or any ACME server) with ECDSA keys, renewed by a timer; private keys readable by root and the web server group only; OCSP stapling where the certificate names a responder; guard warns 14 days before a certificate expires | Mozilla TLS guidelines | | `php` | Own pool user `lss-php`, socket `0660 root:lss-web` only, the distribution's `www` pool off, `expose_php` and `display_errors` off, `allow_url_include` off, `cgi.fix_pathinfo=0`, secure, HTTP-only and SameSite session cookies with strict mode, a private session directory, `exec`, `system`, `proc_open` and similar functions disabled for the web pool, only `.php` executed, systemd sandbox (read-only system except `/var/lib/php` and `LSS_PHP_WRITABLE_PATHS`) | OWASP PHP Configuration Cheat Sheet | | `dotnet` | `DOTNET_CLI_TELEMETRY_OPTOUT=1` for every login | (privacy) | +| `phpmyadmin` | Checksum-verified release without the setup wizard; configuration readable by root and PHP-FPM only, with a generated cookie secret; only the local database server, no root and no empty-password logins; no version check and no error reports; only on the admin port | (operations) | +| `adminer` | Checksum-verified release; a login plugin allows only the configured local servers and drivers; only on the admin port | (operations) | +| `pgadmin` | Pinned, checksum-verified wheels in an isolated environment; own system user; systemd sandbox without capabilities; bound to 127.0.0.1; no upgrade check, no psql tool, no browsing of binary paths; secure cookies when the admin port is public | (operations) | | `haproxy` | Statistics only on 127.0.0.1, admin socket readable by root only, chroot, systemd sandbox, ports in the firewall only for services that are not `local` | (operations) | ## Deliberate deviations diff --git a/lib/web.sh b/lib/web.sh index 7d9e57d..c1aa076 100644 --- a/lib/web.sh +++ b/lib/web.sh @@ -488,7 +488,7 @@ _web_admin_apache() { if [[ ${kind} == proxy ]]; then cat < - ProxyPass ${target%/}/${path}/ + ProxyPass ${target%/}/${path}/ upgrade=websocket ProxyPassReverse ${target%/}/${path}/ RequestHeader set X-Script-Name /${path} From 40e7193658aae73639532814747b375d4cfee593 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Tue, 13 Oct 2026 21:30:44 +0200 Subject: [PATCH 20/92] Add the mariadb module MariaDB from the distribution (the 10.11 LTS module stream on RHEL 9) on 127.0.0.1:3306, configured through a drop-in: no LOAD DATA LOCAL, no symbolic links, utf8mb4. Every run keeps what mysql_secure_installation does true in SQL: root only through the Unix socket, no anonymous or remote root accounts, no test database. Optional databases and application users with generated passwords, a systemd sandbox, a firewall rule only when access is vpn or public, and a SELinux port label for a non-default port. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- modules/database/mariadb.sh | 300 ++++++++++++++++++++++++++++++++++++ 1 file changed, 300 insertions(+) create mode 100644 modules/database/mariadb.sh diff --git a/modules/database/mariadb.sh b/modules/database/mariadb.sh new file mode 100644 index 0000000..9173815 --- /dev/null +++ b/modules/database/mariadb.sh @@ -0,0 +1,300 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +MOD_NAME="mariadb" +MOD_GROUP="database" +MOD_DESCRIPTION="MariaDB server on 127.0.0.1, secured like mysql_secure_installation, root through the Unix socket, optional databases and users" +MOD_REQUIRES="system" +MOD_CONFLICTS="mysql" +MOD_OPTIONS="bind|127.0.0.1|Address the server listens on (0.0.0.0 or :: for every address) +port|3306|TCP port +access|local|Firewall: local (no rule), vpn (WireGuard network only) or public +databases||Databases to create, separated by spaces +users||Application users as user:database, separated by spaces; each gets all privileges on its database from localhost and a generated password (lss secrets show mariadb-user-USER)" +MOD_PORTS="3306/tcp on 127.0.0.1" +MOD_SECRETS="mariadb-user-" + +MARIADB_UNIT=mariadb +MARIADB_DATA=/var/lib/mysql + +_mariadb_conf() { + if os_is_debian; then + printf '/etc/mysql/mariadb.conf.d/zz-lss.cnf' + else + printf '/etc/my.cnf.d/zz-lss.cnf' + fi + return +} + +_mariadb_name_ok() { + local name=$1 + [[ ${name} =~ ^[a-z][a-z0-9_]{0,31}$ ]] + return +} + +_mariadb_is_loopback() { + local address=$1 + [[ ${address} == 127.* || ${address} == ::1 ]] + return +} + +mod_check() { + local bind port access database entry user + bind=$(opt bind) + port=$(opt_int port) + access=$(opt access) + [[ ${bind} =~ ^[0-9A-Fa-f:.]+$ ]] || die "LSS_MARIADB_BIND '${bind}' is not an IP address." + [[ ${port} -ge 1 && ${port} -le 65535 ]] || die "LSS_MARIADB_PORT must be between 1 and 65535." + [[ ${access} =~ ^(local|vpn|public)$ ]] || die "LSS_MARIADB_ACCESS must be local, vpn or public." + if [[ ${access} != local ]] && _mariadb_is_loopback "${bind}"; then + die "LSS_MARIADB_ACCESS=${access} needs LSS_MARIADB_BIND set to an address other hosts can reach." + fi + for database in $(opt databases); do + _mariadb_name_ok "${database}" || die "LSS_MARIADB_DATABASES: '${database}' is not a valid name (a-z, 0-9, _)." + [[ ! ${database} =~ ^(mysql|information_schema|performance_schema|sys|test)$ ]] || + die "LSS_MARIADB_DATABASES: '${database}' is a reserved database." + done + for entry in $(opt users); do + [[ ${entry} == *:* ]] || die "LSS_MARIADB_USERS: '${entry}' must look like user:database." + user=${entry%%:*} + database=${entry#*:} + _mariadb_name_ok "${user}" || die "LSS_MARIADB_USERS: '${user}' is not a valid user name (a-z, 0-9, _)." + _mariadb_name_ok "${database}" || die "LSS_MARIADB_USERS: '${database}' is not a valid database name." + [[ ! ${user} =~ ^(root|mysql|mariadb)$ ]] || die "LSS_MARIADB_USERS: '${user}' is a reserved account." + [[ ! ${database} =~ ^(mysql|information_schema|performance_schema|sys|test)$ ]] || + die "LSS_MARIADB_USERS: '${database}' is a reserved database." + done + # MySQL 8 keeps its data dictionary in mysql.ibd; MariaDB cannot open such a data directory. + [[ ! -e ${MARIADB_DATA}/mysql.ibd ]] || + die "mariadb: ${MARIADB_DATA} holds MySQL data. Remove it with 'lss remove --purge mysql' or move it away first." + return +} + +# RHEL 9 installs MariaDB 10.5 by default, whose upstream support ended in 2025; the 10.11 LTS stream is maintained +# for the life of RHEL 9. An existing installation keeps its version. +_mariadb_el9_stream() { + local enabled + pkg_is_installed mariadb-server && return 0 + enabled=$(dnf -q module list --enabled mariadb 2>/dev/null || true) + [[ ${enabled} != *10.11* ]] || return 0 + log_command dnf -y -q module enable mariadb:10.11 + files_note_change "enabled the mariadb:10.11 module stream" + return +} + +mod_install() { + if os_is_rhel && [[ ${OS_MAJOR} == 9 ]]; then + _mariadb_el9_stream + fi + pkg_install_family "mariadb-server mariadb-client" "mariadb-server mariadb" + return +} + +_mariadb_client() { + if command -v mariadb >/dev/null 2>&1; then + printf 'mariadb' + else + printf 'mysql' + fi + return +} + +# _mariadb_sql: runs the SQL on standard input as root through the Unix socket and prints the rows, tab-separated. +_mariadb_sql() { + local client + client=$(_mariadb_client) + "${client}" --protocol=socket --user=root --batch --skip-column-names + return +} + +_mariadb_query() { + local sql=$1 + _mariadb_sql <<<"${sql}" + return +} + +_mariadb_wait() { + local i + for ((i = 0; i < 60; i++)); do + if _mariadb_query 'SELECT 1' >/dev/null 2>&1; then + return 0 + fi + sleep 1 + done + die "mariadb: root cannot log in through the Unix socket." +} + +# A port other than 3306 needs the mysqld_port_t label on SELinux systems, or the server may not bind it. +_mariadb_selinux_port() { + local port=$1 custom + [[ ${port} != 3306 ]] || return 0 + os_is_rhel || return 0 + if ! command -v selinuxenabled >/dev/null 2>&1 || ! selinuxenabled; then + return 0 + fi + pkg_install policycoreutils-python-utils + custom=$(semanage port -l -C 2>/dev/null | awk '$1 == "mysqld_port_t" && $2 == "tcp" { print $3 }') + grep -qxF "${port}" <<<"${custom}" && return 0 + semanage port -a -t mysqld_port_t -p tcp "${port}" 2>/dev/null || + semanage port -m -t mysqld_port_t -p tcp "${port}" + files_note_change "SELinux port ${port}/tcp for MariaDB" + return +} + +# What mysql_secure_installation does, kept true on every run: root only through the Unix socket, no anonymous +# accounts, root only on localhost, no test database. +_mariadb_secure() { + local create accounts account sql + create=$(_mariadb_query "SHOW CREATE USER 'root'@'localhost'") + if [[ ${create} != *unix_socket* ]]; then + _mariadb_query "ALTER USER 'root'@'localhost' IDENTIFIED VIA unix_socket" + files_note_change "root@localhost authenticates through the Unix socket" + fi + accounts=$(_mariadb_query "SELECT CONCAT(QUOTE(User), '@', QUOTE(Host)) FROM mysql.user + WHERE User = '' OR (User = 'root' AND Host <> 'localhost')") + while IFS= read -r account; do + [[ -n ${account} ]] || continue + _mariadb_query "DROP USER ${account}" + files_note_change "dropped the account ${account}" + done <<<"${accounts}" + if [[ -n $(_mariadb_query "SELECT SCHEMA_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = 'test'") ]]; then + _mariadb_query 'DROP DATABASE test' + files_note_change "dropped the test database" + fi + # The stored pattern is the literal test\_% (the escaped underscore of the old default grant). + sql="FROM mysql.db WHERE Db = 'test' OR Db = 'test\\\\_%'" + if [[ $(_mariadb_query "SELECT COUNT(*) ${sql}") != 0 ]]; then + _mariadb_query "DELETE ${sql}; FLUSH PRIVILEGES" + files_note_change "removed the grants on test databases" + fi + return +} + +# Databases of LSS_MARIADB_DATABASES and of LSS_MARIADB_USERS, each once. +_mariadb_wanted_databases() { + local entry + local -a list=() + read -ra list <<<"$(opt databases)" + for entry in $(opt users); do + list+=("${entry#*:}") + done + if [[ ${#list[@]} -gt 0 ]]; then + printf '%s\n' "${list[@]}" | awk '!seen[$0]++' + fi + return +} + +_mariadb_databases() { + local database databases + databases=$(_mariadb_wanted_databases) + for database in ${databases}; do + if [[ -z $(_mariadb_query "SELECT SCHEMA_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = '${database}'") ]]; then + _mariadb_query "CREATE DATABASE \`${database}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + files_note_change "created database ${database}" + fi + done + return +} + +# Each user is created once with its generated password (sent on standard input, never on a command line) and keeps +# all privileges on its database. +_mariadb_users() { + local entry user database password grants + for entry in $(opt users); do + user=${entry%%:*} + database=${entry#*:} + if [[ -z $(_mariadb_query "SELECT User FROM mysql.user WHERE User = '${user}' AND Host = 'localhost'") ]]; then + secret_load "mariadb-user-${user}" password + _mariadb_sql <<<"CREATE USER '${user}'@'localhost' IDENTIFIED BY '${password}';" + files_note_change "created database user ${user}" + log_info "Password of ${user}: lss secrets show mariadb-user-${user}" + fi + grants=$(_mariadb_query "SHOW GRANTS FOR '${user}'@'localhost'") + if [[ ${grants} != *"ON \`${database}\`.*"* ]]; then + _mariadb_query "GRANT ALL PRIVILEGES ON \`${database}\`.* TO '${user}'@'localhost'" + files_note_change "granted ${user} all privileges on ${database}" + fi + done + return +} + +mod_configure() { + local before changed=0 port + port=$(opt_int port) + before=$(files_changes_of mariadb) + _mariadb_selinux_port "${port}" + file_write 0644 root:root "$(_mariadb_conf)" < 'localhost')") + [[ ${result} == 0 ]] || die "mariadb: anonymous or remote root accounts exist." + _mariadb_check_listen "$(opt_int port)" "$(opt bind)" + return +} + +mod_remove() { + local path + svc_disable "${MARIADB_UNIT}" + svc_dropin_remove "${MARIADB_UNIT}.service" lss-sandbox + file_remove "$(_mariadb_conf)" + if [[ ${LSS_PURGE:-0} == 1 ]]; then + pkg_remove_family "mariadb-server mariadb-client" "mariadb-server mariadb" + if [[ -d ${MARIADB_DATA} ]]; then + rm -rf -- "${MARIADB_DATA}" + files_note_change "deleted ${MARIADB_DATA}" + fi + for path in "${LSS_SECRETS_DIR}"/mariadb-user-*; do + [[ -f ${path} ]] || continue + secret_remove "${path##*/}" + done + fi + return +} From 7bc4046c3ff4f509b0addf84c77f730da91a8d00 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Tue, 13 Oct 2026 21:35:19 +0200 Subject: [PATCH 21/92] Add the mysql module MySQL on 127.0.0.1:3306, the 8.4 LTS release wherever the system offers it: Oracle's APT repository on Debian amd64 (Debian ships no MySQL), Oracle's generic aarch64 build on Debian arm64 (the repository has no arm64 packages; the archive is pinned by SHA-256 in data/checksums.txt after its MD5 and GnuPG signature were checked), the mysql:8.4 module stream on RHEL 9 and mysql8.4 on RHEL 10, and the distribution package on Ubuntu. Root logs in through auth_socket on Debian and Ubuntu; on the RHEL family it gets a generated password in /root/.my.cnf (mode 0600). Every run removes anonymous and remote root accounts and the test database, LOAD DATA LOCAL is off, the X Protocol stays on localhost. Optional databases and application users, a systemd sandbox and a firewall rule only for vpn or public access. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- data/checksums.txt | 9 + data/keys/mysql.asc | 52 ++++ data/repos/mysql.conf | 13 + modules/database/mysql.sh | 538 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 612 insertions(+) create mode 100644 data/keys/mysql.asc create mode 100644 data/repos/mysql.conf create mode 100644 modules/database/mysql.sh diff --git a/data/checksums.txt b/data/checksums.txt index adff2f1..dd3f61b 100644 --- a/data/checksums.txt +++ b/data/checksums.txt @@ -22,3 +22,12 @@ adminer-6.1.2 any b32316156bd18ffa99c1a27617142c2e5ad72260e37f15da4e9937a3dbb15f # https://pypi.org/pypi/gunicorn/26.2.0/json, checked 2026-10-13). pgadmin4-9.18 any 9ea084fc416265e4fc95c9fb6e6ae0f6668d34da8670c9b65c79cea054124356 https://files.pythonhosted.org/packages/0e/80/86d7e7123e2b6de41890bd1edc3e27348e2572acea337088faa0ec41c7f5/pgadmin4-9.18-py3-none-any.whl gunicorn-26.2.0 any bd249d0b3f7972f7432f0a6b6ff3b3ee2d129f70cd1ff6c09a9dd9e29a2b88e3 https://files.pythonhosted.org/packages/fe/85/7522a52e5e2f42faf1a129113ab63e548c42e103e9af395b7bfe65e403e2/gunicorn-26.2.0-py3-none-any.whl + +# Files lss downloads directly, read by fetch_verified (lib/fetch.sh): . + +# MySQL 8.4.11 LTS, Oracle's generic glibc 2.28 build for Debian on arm64 (repo.mysql.com has amd64 packages only). +# Oracle publishes an MD5 sum and a GnuPG signature, no SHA-256: the archive matched the MD5 on +# https://dev.mysql.com/downloads/mysql/8.4.html (b22fb0240514099ff95251b1c8ed2127) and its signature by the MySQL +# Release Engineering key BCA43417C3B485DD128EC6D4B7B3B788A8D3785C (data/keys/mysql.asc); the SHA-256 below was taken +# from that verified file on 2026-10-13. +mysql-generic aarch64 04b2f9791d314167a9eb83abcb476f45a7cd9e4aa88fa7a638cba40d1bc2a109 https://cdn.mysql.com/Downloads/MySQL-8.4/mysql-8.4.11-linux-glibc2.28-aarch64.tar.xz diff --git a/data/keys/mysql.asc b/data/keys/mysql.asc new file mode 100644 index 0000000..98cb82e --- /dev/null +++ b/data/keys/mysql.asc @@ -0,0 +1,52 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBGU2rNoBEACSi5t0nL6/Hj3d0PwsbdnbY+SqLUIZ3uWZQm6tsNhvTnahvPPZ +BGdl99iWYTt2KmXp0KeN2s9pmLKkGAbacQP1RqzMFnoHawSMf0qTUVjAvhnI4+qz +MDjTNSBq9fa3nHmOYxownnrRkpiQUM/yD7/JmVENgwWb6akZeGYrXch9jd4XV3t8 +OD6TGzTedTki0TDNr6YZYhC7jUm9fK9Zs299pzOXSxRRNGd+3H9gbXizrBu4L/3l +UrNf//rM7OvV9Ho7u9YYyAQ3L3+OABK9FKHNhrpi8Q0cbhvWkD4oCKJ+YZ54XrOG +0YTg/YUAs5/3//FATI1sWdtLjJ5pSb0onV3LIbarRTN8lC4Le/5kd3lcot9J8b3E +MXL5p9OGW7wBfmNVRSUI74Vmwt+v9gyp0Hd0keRCUn8lo/1V0YD9i92KsE+/IqoY +Tjnya/5kX41jB8vr1ebkHFuJ404+G6ETd0owwxq64jLIcsp/GBZHGU0RKKAo9DRL +H7rpQ7PVlnw8TDNlOtWt5EJlBXFcPL+NgWbqkADAyA/XSNeWlqonvPlYfmasnAHA +pMd9NhPQhC7hJTjCiAwG8UyWpV8Dj07DHFQ5xBbkTnKH2OrJtguPqSNYtTASbsWz +09S8ujoTDXFT17NbFM2dMIiq0a4VQB3SzH13H2io9Cbg/TzJrJGmwgoXgwARAQAB +tDZNeVNRTCBSZWxlYXNlIEVuZ2luZWVyaW5nIDxteXNxbC1idWlsZEBvc3Mub3Jh +Y2xlLmNvbT6JAlQEEwEIAD4CGwMFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AWIQS8 +pDQXw7SF3RKOxtS3s7eIqNN4XAUCaPoZowUJB4XTyQAKCRC3s7eIqNN4XAIED/9F +8cSgF+VHilpXe8gSTbVn5sNRnAsIYgMonsGqsrzUOv+3Gy4+e4guhRLe3m1PpQJq +yIQ/upbGptP48YsIY8ix2pyzYr1dB8W1TcNUYcQvTdb8/Exd1nDpLzdwoil7b5W2 +r3jpsor/b1cou7vju/ObBbkU5xai4waCMqO9llp3ePQTJBa1RwV01taryGZJa2xR +Ke7k1lwdWINALICIQ0aSfy3Q24lWlj0CRiDxAE7UdbtBaqyr5omqUnOXR5kZdnOf +jyAbsofMuQNSLTUg1hoSunp9llv/ayeaCu54qkmkqG8U5gKUDNnYhLTIto7uf2A8 +6Ufr2/P1hiJ6MzvHKEI+xtvalKDm5M+/kwSXTnT4e2ERJ0eBnfxwfJlThcYCWOsy +M1jyRaFqXYKxF+r/bfvXga/C+n7VbDEV9VdXfTEjDiSjoeLzaNkNNaDqrp5k4VSk +ekeGluOhYdXOiBI2oSDAP2dvIcpQYuQIrU3TW2YHRLhrN57IaTeFYCA7ij6k8GdQ +YL15Hub9SavhMQ1qwLTLRp0QeKTvw2y1cZ9yJD3rih3NZq0Ul3rZel7TfDG+TX6n +57mBk2z0zmNGuqLirQr6TUUM0Fvl26Zael5w4n5wRKsUdj3/GjchMGWLlu52s+0M +KuB9nNowTIejuhT57x7H67Ho88eIZaWmFC9psvCHJLkCDQRlNqzaARAAsdvBo8WR +qZ5WVVk6lReD8b6Zx83eJUkV254YX9zn5t8KDRjYOySwS75mJIaZLsv0YQjJk+5r +t10tejyCrJIFo9CMvCmjUKtVbgmhfS5+fUDRrYCEZBBSa0Dvn68EBLiHugr+SPXF +6o1hXEUqdMCpB6oVp6X45JVQroCKIH5vsCtw2jU8S2/IjjV0V+E/zitGCiZaoZ1f +6NG7ozyFep1CSAReZu/sssk0pCLlfCebRd9Rz3QjSrQhWYuJa+eJmiF4oahnpUGk +txMD632I9aG+IMfjtNJNtX32MbO+Se+cCtVc3cxSa/pR+89a3cb9IBA5tFF2Qoek +hqo/1mmLi93Xn6uDUhl5tVxTnB217dBT27tw+p0hjd9hXZRQbrIZUTyh3+8EMfmA +jNSIeR+th86xRd9XFRr9EOqrydnALOUr9cT7TfXWGEkFvn6ljQX7f4RvjJOTbc4j +JgVFyu8K+VU6u1NnFJgDiNGsWvnYxAf7gDDbUSXEuC2anhWvxPvpLGmsspngge4y +l+3nv+UqZ9sm6LCebR/7UZ67tYz3p6xzAOVgYsYcxoIUuEZXjHQtsYfTZZhrjUWB +J09jrMvlKUHLnS437SLbgoXVYZmcqwAWpVNOLZf+fFm4IE5aGBG5Dho2CZ6ujngW +9Zkn98T1d4N0MEwwXa2V6T1ijzcqD7GApZUAEQEAAYkCPAQYAQgAJgIbDBYhBLyk +NBfDtIXdEo7G1Lezt4io03hcBQJo+hmtBQkHhdPTAAoJELezt4io03hcOTAP/2Js +Mj7a1xIeWN35+lvnsVE1t68hhipLUO0/Cj7pV8QsBUlIrs9u6cQ2Qzz5VGTHTd6Y +hrX5xsPP8TUh50DWBx74IeFf8o5WxKlZ3eH0WnO0O96qNKW5BpQRsWNjF1kBWx6l +nSyduMZRUTV4+2EeEciwXiBDPl5kHqW/Q7bGoV0YokwF1CC2igdCmHM+MY97Fpt8 +cbzakl8kp2U4Z+fJ9oX467FF355pnEAxO0msZqjgyxolP/EcgIiqufzuRSYXk8te +RsaC7elR+Bpi51CBgyl9EIEpoX/PfIBN3buEbb5zwMNL0PGw6b44oams6P5cMpbz +GWikFGnDJyikVXlJuvaQdAQv7xMBvYU7HcLiYcM4Pt9uVGNEU321QIovFLhx/vH5 +7Df+Fxx8FfHFP3MjVPzmldGHL67tUvquCTSxB/8fwEfA4b5abZwNy3E10DYhL4w5 +PjzXl4/kbnVpZwtuyS5qMNg9n6cEWiSo15ldzV5iHTyprXx3RhO6krpJUFAcbCEw +r2LmI2XYZguvGCSFm3LCuf4g7GDJ1u3RAtivCNCQ4sVgTLPoCNGW90Unf44s3vzm +WDREXgkzSZthslxJHPE5y3Kh0qM1jQSuN+VNVHLGriOaOlYRtZoGGStONYhlBCoJ +udMv77etKsN/mPdhJotVLMUpzeespcu5G2qqc5zt +=6wRS +-----END PGP PUBLIC KEY BLOCK----- diff --git a/data/repos/mysql.conf b/data/repos/mysql.conf new file mode 100644 index 0000000..d861c88 --- /dev/null +++ b/data/repos/mysql.conf @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# MySQL 8.4 LTS from Oracle's APT repository (https://repo.mysql.com/apt/), used on Debian, which ships no MySQL. +# Key fetched from https://repo.mysql.com/RPM-GPG-KEY-mysql-2025 on 2026-10-13; the fingerprint matches the one in +# https://dev.mysql.com/doc/refman/8.4/en/checking-gpg-signature.html, and the bookworm Release file is signed with it. +# The repository carries amd64 packages only; the mysql module installs Oracle's generic aarch64 build on Debian arm64. +KEY=mysql.asc +FINGERPRINTS=BCA43417C3B485DD128EC6D4B7B3B788A8D3785C +DEB_URL=https://repo.mysql.com/apt/debian +DEB_SUITE={{codename}} +DEB_COMPONENTS=mysql-8.4-lts diff --git a/modules/database/mysql.sh b/modules/database/mysql.sh new file mode 100644 index 0000000..ad287f4 --- /dev/null +++ b/modules/database/mysql.sh @@ -0,0 +1,538 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +MOD_NAME="mysql" +MOD_GROUP="database" +MOD_DESCRIPTION="MySQL server (8.4 LTS where available) on 127.0.0.1, secured like mysql_secure_installation, optional databases and users" +MOD_REQUIRES="system" +MOD_CONFLICTS="mariadb" +MOD_OPTIONS="bind|127.0.0.1|Address the server listens on (0.0.0.0 or :: for every address) +port|3306|TCP port +access|local|Firewall: local (no rule), vpn (WireGuard network only) or public +databases||Databases to create, separated by spaces +users||Application users as user:database, separated by spaces; each gets all privileges on its database from localhost and a generated password (lss secrets show mysql-user-USER)" +MOD_PORTS="3306/tcp on 127.0.0.1, X Protocol 33060/tcp on 127.0.0.1" +MOD_SECRETS="mysql-root (RHEL family) mysql-user-" + +# Sources, newest LTS where the system offers it: +# Ubuntu the distribution (8.0 on 22.04 and 24.04, 8.4 on 26.04); its root logs in through auth_socket +# Debian amd64 Oracle's APT repository, 8.4 LTS (Debian ships no MySQL) +# Debian arm64 Oracle's generic glibc 2.28 build of 8.4 LTS in /opt (the APT repository is amd64 only) +# RHEL family the distribution: the mysql:8.4 module stream on 9, mysql8.4 on 10; root has a password +MYSQL_DATA=/var/lib/mysql +MYSQL_GENERIC_VERSION=8.4.11 +MYSQL_GENERIC_DIR=/opt/mysql-${MYSQL_GENERIC_VERSION} +MYSQL_GENERIC_LINK=/opt/mysql +MYSQL_GENERIC_CNF=/etc/mysql/my.cnf +MYSQL_ROOT_CNF=/root/.my.cnf + +_mysql_source() { + if os_is_rhel; then + printf 'rhel' + elif [[ ${OS_ID} == ubuntu ]]; then + printf 'ubuntu' + elif [[ ${OS_ARCH} == aarch64 ]]; then + printf 'generic' + else + printf 'oracle' + fi + return +} + +_mysql_unit() { + if os_is_rhel; then printf 'mysqld'; else printf 'mysql'; fi + return +} + +_mysql_conf() { + if os_is_rhel; then + printf '/etc/my.cnf.d/zz-lss.cnf' + else + printf '/etc/mysql/mysql.conf.d/zz-lss.cnf' + fi + return +} + +_mysql_socket() { + if os_is_rhel; then + printf '/var/lib/mysql/mysql.sock' + else + printf '/run/mysqld/mysqld.sock' + fi + return +} + +# _mysql_bin NAME: the program from the generic build when it is installed, else from the PATH. +_mysql_bin() { + local name=$1 + if [[ -x ${MYSQL_GENERIC_LINK}/bin/${name} ]]; then + printf '%s' "${MYSQL_GENERIC_LINK}/bin/${name}" + else + printf '%s' "${name}" + fi + return +} + +_mysql_packages() { + case $(_mysql_source) in + rhel) + if [[ ${OS_MAJOR} == 9 ]]; then printf 'mysql-server mysql'; else printf 'mysql8.4-server mysql8.4'; fi + ;; + generic) printf '' ;; + *) printf 'mysql-server mysql-client' ;; + esac + return +} + +_mysql_name_ok() { + local name=$1 + [[ ${name} =~ ^[a-z][a-z0-9_]{0,31}$ ]] + return +} + +_mysql_is_loopback() { + local address=$1 + [[ ${address} == 127.* || ${address} == ::1 ]] + return +} + +mod_check() { + local bind port access database entry user + bind=$(opt bind) + port=$(opt_int port) + access=$(opt access) + [[ ${bind} =~ ^[0-9A-Fa-f:.]+$ ]] || die "LSS_MYSQL_BIND '${bind}' is not an IP address." + [[ ${port} -ge 1 && ${port} -le 65535 && ${port} != 33060 ]] || + die "LSS_MYSQL_PORT must be between 1 and 65535 (33060 is the X Protocol port)." + [[ ${access} =~ ^(local|vpn|public)$ ]] || die "LSS_MYSQL_ACCESS must be local, vpn or public." + if [[ ${access} != local ]] && _mysql_is_loopback "${bind}"; then + die "LSS_MYSQL_ACCESS=${access} needs LSS_MYSQL_BIND set to an address other hosts can reach." + fi + for database in $(opt databases); do + _mysql_name_ok "${database}" || die "LSS_MYSQL_DATABASES: '${database}' is not a valid name (a-z, 0-9, _)." + [[ ! ${database} =~ ^(mysql|information_schema|performance_schema|sys|test)$ ]] || + die "LSS_MYSQL_DATABASES: '${database}' is a reserved database." + done + for entry in $(opt users); do + [[ ${entry} == *:* ]] || die "LSS_MYSQL_USERS: '${entry}' must look like user:database." + user=${entry%%:*} + database=${entry#*:} + _mysql_name_ok "${user}" || die "LSS_MYSQL_USERS: '${user}' is not a valid user name (a-z, 0-9, _)." + _mysql_name_ok "${database}" || die "LSS_MYSQL_USERS: '${database}' is not a valid database name." + [[ ! ${user} =~ ^(root|mysql)$ ]] || die "LSS_MYSQL_USERS: '${user}' is a reserved account." + [[ ! ${database} =~ ^(mysql|information_schema|performance_schema|sys|test)$ ]] || + die "LSS_MYSQL_USERS: '${database}' is a reserved database." + done + # MariaDB leaves Aria control files in its data directory; MySQL cannot open it. + [[ ! -e ${MYSQL_DATA}/aria_log_control ]] || + die "mysql: ${MYSQL_DATA} holds MariaDB data. Remove it with 'lss remove --purge mariadb' or move it away first." + return +} + +# RHEL 9 defaults to MySQL 8.0, whose upstream support ended in April 2026; the 8.4 LTS stream replaces it. An +# existing installation keeps its version. +_mysql_el9_stream() { + local enabled + pkg_is_installed mysql-server && return 0 + enabled=$(dnf -q module list --enabled mysql 2>/dev/null || true) + [[ ${enabled} != *8.4* ]] || return 0 + log_command dnf -y -q module enable mysql:8.4 + files_note_change "enabled the mysql:8.4 module stream" + return +} + +# Oracle's generic build: unpacked once per version into /opt (without debug binaries, static libraries, headers and +# manuals), linked as /opt/mysql, run by an lss unit. The archive is checked against data/checksums.txt. +_mysql_generic_install() { + local archive=/var/cache/lss/mysql-generic.tar.xz aio=libaio1 aio_target + # trixie renamed libaio's soname in the 64-bit time_t transition; the ABI on arm64 did not change. + [[ ${OS_CODENAME} == bookworm ]] || aio=libaio1t64 + pkg_install "${aio}" libnuma1 libncurses6 xz-utils + if [[ ! -x ${MYSQL_GENERIC_DIR}/bin/mysqld ]]; then + fetch_verified mysql-generic "${archive}" + rm -rf -- "${MYSQL_GENERIC_DIR}.part" + mkdir -p "${MYSQL_GENERIC_DIR}.part" + log_command tar -xJf "${archive}" -C "${MYSQL_GENERIC_DIR}.part" --strip-components=1 --no-same-owner \ + --exclude='*/bin/mysqld-debug' --exclude='*/lib/plugin/debug' --exclude='*/include' \ + --exclude='*/man' --exclude='*/docs' --exclude='*.a' + mv -- "${MYSQL_GENERIC_DIR}.part" "${MYSQL_GENERIC_DIR}" + rm -f -- "${archive}" + files_note_change "unpacked MySQL ${MYSQL_GENERIC_VERSION} to ${MYSQL_GENERIC_DIR}" + fi + if [[ ${aio} == libaio1t64 ]]; then + aio_target="/usr/lib/aarch64-linux-gnu/libaio.so.1t64" + if [[ $(readlink "${MYSQL_GENERIC_DIR}/lib/private/libaio.so.1" 2>/dev/null) != "${aio_target}" ]]; then + ln -sfn "${aio_target}" "${MYSQL_GENERIC_DIR}/lib/private/libaio.so.1" + files_note_change "linked libaio.so.1 for MySQL" + fi + fi + if [[ $(readlink "${MYSQL_GENERIC_LINK}" 2>/dev/null) != "${MYSQL_GENERIC_DIR}" ]]; then + ln -sfn "${MYSQL_GENERIC_DIR}" "${MYSQL_GENERIC_LINK}" + files_note_change "linked ${MYSQL_GENERIC_LINK} to ${MYSQL_GENERIC_DIR}" + fi + _mysql_generic_links + if ! getent passwd mysql >/dev/null; then + useradd --system --user-group --home-dir /nonexistent --no-create-home --shell /usr/sbin/nologin mysql + files_note_change "created user mysql" + fi + return +} + +_mysql_generic_links() { + local program + for program in mysql mysqladmin mysqldump mysqlcheck mysqlbinlog; do + if [[ $(readlink "/usr/local/bin/${program}" 2>/dev/null) != "${MYSQL_GENERIC_LINK}/bin/${program}" ]]; then + ln -sfn "${MYSQL_GENERIC_LINK}/bin/${program}" "/usr/local/bin/${program}" + files_note_change "linked /usr/local/bin/${program}" + fi + done + return +} + +mod_install() { + local packages + case $(_mysql_source) in + rhel) + if [[ ${OS_MAJOR} == 9 ]]; then + _mysql_el9_stream + fi + ;; + oracle) repo_add mysql ;; + generic) _mysql_generic_install ;; + *) ;; + esac + packages=$(_mysql_packages) + if [[ -n ${packages} ]]; then + pkg_install_family "${packages}" "${packages}" + fi + return +} + +# The generic build has no package scripts: lss writes its base configuration and unit and creates the data +# directory once (root starts with an empty password and is switched to auth_socket right after). +_mysql_generic_configure() { + install -d -m 0750 -o mysql -g mysql "${MYSQL_DATA}" /var/lib/mysql-files + install -d -m 0750 -o mysql -g adm /var/log/mysql + file_write 0644 root:root "${MYSQL_GENERIC_CNF}" </dev/null 2>&1; then + return 0 + fi + sleep 1 + done + die "mysql: the server does not answer on $(_mysql_socket)." +} + +# Debian and Ubuntu: root logs in through auth_socket, as the distribution and Oracle's packages set it up. +_mysql_root_socket() { + local plugin active + plugin=$(_mysql_query "SELECT plugin FROM mysql.user WHERE User = 'root' AND Host = 'localhost'") || + die "mysql: root cannot log in through the Unix socket." + [[ ${plugin} != auth_socket ]] || return 0 + active=$(_mysql_query "SELECT PLUGIN_STATUS FROM information_schema.PLUGINS WHERE PLUGIN_NAME = 'auth_socket'") + if [[ ${active} != ACTIVE ]]; then + _mysql_query "INSTALL PLUGIN auth_socket SONAME 'auth_socket.so'" + fi + _mysql_query "ALTER USER 'root'@'localhost' IDENTIFIED WITH auth_socket" + files_note_change "root@localhost authenticates through the Unix socket" + return +} + +# RHEL family: the server creates root with an empty password. lss sets a generated one and writes it to +# /root/.my.cnf (mode 0600), so the client logs in without a password on the command line. +_mysql_root_password() { + local password client + secret_load mysql-root password + file_write 0600 root:root "${MYSQL_ROOT_CNF}" </dev/null 2>&1; then + return 0 + fi + client=$(_mysql_bin mysql) + if "${client}" --no-defaults --socket="$(_mysql_socket)" --user=root --batch <<<'SELECT 1' >/dev/null 2>&1; then + "${client}" --no-defaults --socket="$(_mysql_socket)" --user=root --batch \ + <<<"ALTER USER 'root'@'localhost' IDENTIFIED BY '${password}';" + files_note_change "set the MySQL root password" + log_info "Password of the MySQL root account: lss secrets show mysql-root (also in ${MYSQL_ROOT_CNF})" + return 0 + fi + die "mysql: root cannot log in with the password in ${MYSQL_ROOT_CNF} (lss secrets show mysql-root)." +} + +# What mysql_secure_installation does, kept true on every run: no anonymous accounts, root only on localhost, no +# test database. +_mysql_secure() { + local accounts account sql + accounts=$(_mysql_query "SELECT CONCAT(QUOTE(User), '@', QUOTE(Host)) FROM mysql.user + WHERE User = '' OR (User = 'root' AND Host <> 'localhost')") + while IFS= read -r account; do + [[ -n ${account} ]] || continue + _mysql_query "DROP USER ${account}" + files_note_change "dropped the account ${account}" + done <<<"${accounts}" + if [[ -n $(_mysql_query "SELECT SCHEMA_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = 'test'") ]]; then + _mysql_query 'DROP DATABASE test' + files_note_change "dropped the test database" + fi + # The stored pattern is the literal test\_% (the escaped underscore of the old default grant). + sql="FROM mysql.db WHERE Db = 'test' OR Db = 'test\\\\_%'" + if [[ $(_mysql_query "SELECT COUNT(*) ${sql}") != 0 ]]; then + _mysql_query "DELETE ${sql}; FLUSH PRIVILEGES" + files_note_change "removed the grants on test databases" + fi + return +} + +# Databases of LSS_MYSQL_DATABASES and of LSS_MYSQL_USERS, each once. +_mysql_wanted_databases() { + local entry + local -a list=() + read -ra list <<<"$(opt databases)" + for entry in $(opt users); do + list+=("${entry#*:}") + done + if [[ ${#list[@]} -gt 0 ]]; then + printf '%s\n' "${list[@]}" | awk '!seen[$0]++' + fi + return +} + +_mysql_databases() { + local database databases + databases=$(_mysql_wanted_databases) + for database in ${databases}; do + if [[ -z $(_mysql_query "SELECT SCHEMA_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = '${database}'") ]]; then + _mysql_query "CREATE DATABASE \`${database}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci" + files_note_change "created database ${database}" + fi + done + return +} + +# Each user is created once with its generated password (sent on standard input, never on a command line) and keeps +# all privileges on its database. +_mysql_users() { + local entry user database password grants + for entry in $(opt users); do + user=${entry%%:*} + database=${entry#*:} + if [[ -z $(_mysql_query "SELECT User FROM mysql.user WHERE User = '${user}' AND Host = 'localhost'") ]]; then + secret_load "mysql-user-${user}" password + _mysql_sql <<<"CREATE USER '${user}'@'localhost' IDENTIFIED BY '${password}';" + files_note_change "created database user ${user}" + log_info "Password of ${user}: lss secrets show mysql-user-${user}" + fi + grants=$(_mysql_query "SHOW GRANTS FOR '${user}'@'localhost'") + if [[ ${grants} != *"ON \`${database}\`.*"* ]]; then + _mysql_query "GRANT ALL PRIVILEGES ON \`${database}\`.* TO '${user}'@'localhost'" + files_note_change "granted ${user} all privileges on ${database}" + fi + done + return +} + +# A port other than 3306 needs the mysqld_port_t label on SELinux systems, or the server may not bind it. +_mysql_selinux_port() { + local port=$1 custom + [[ ${port} != 3306 ]] || return 0 + os_is_rhel || return 0 + if ! command -v selinuxenabled >/dev/null 2>&1 || ! selinuxenabled; then + return 0 + fi + pkg_install policycoreutils-python-utils + custom=$(semanage port -l -C 2>/dev/null | awk '$1 == "mysqld_port_t" && $2 == "tcp" { print $3 }') + grep -qxF "${port}" <<<"${custom}" && return 0 + semanage port -a -t mysqld_port_t -p tcp "${port}" 2>/dev/null || + semanage port -m -t mysqld_port_t -p tcp "${port}" + files_note_change "SELinux port ${port}/tcp for MySQL" + return +} + +mod_configure() { + local before changed=0 port unit + port=$(opt_int port) + unit=$(_mysql_unit) + before=$(files_changes_of mysql) + _mysql_selinux_port "${port}" + # The X Protocol (33060) stays on localhost whatever LSS_MYSQL_BIND says; nothing in lss uses it. + file_write 0644 root:root "$(_mysql_conf)" < 'localhost')") + [[ ${result} == 0 ]] || die "mysql: anonymous or remote root accounts exist." + if ! os_is_rhel; then + result=$(_mysql_query "SELECT plugin FROM mysql.user WHERE User = 'root' AND Host = 'localhost'") + [[ ${result} == auth_socket ]] || die "mysql: root does not authenticate through the Unix socket." + fi + _mysql_check_listen "$(opt_int port)" "$(opt bind)" + _mysql_check_listen 33060 127.0.0.1 + return +} + +mod_remove() { + local unit path packages + unit=$(_mysql_unit) + svc_disable "${unit}" + svc_dropin_remove "${unit}.service" lss-sandbox + file_remove "$(_mysql_conf)" + # /root/.my.cnf stays unless purged: the data stays too, and root needs the password to reach it. + [[ ${LSS_PURGE:-0} == 1 ]] || return 0 + packages=$(_mysql_packages) + if [[ -n ${packages} ]]; then + pkg_remove_family "${packages}" "${packages}" + fi + repo_remove mysql + if [[ $(_mysql_source) == generic ]]; then + file_remove /etc/systemd/system/mysql.service + file_remove "${MYSQL_GENERIC_CNF}" + for path in mysql mysqladmin mysqldump mysqlcheck mysqlbinlog; do + file_remove "/usr/local/bin/${path}" + done + svc_daemon_reload + # The unpacked build (about 1 GB) is deleted, not backed up like a configuration file. + if [[ -e ${MYSQL_GENERIC_DIR} || -L ${MYSQL_GENERIC_LINK} ]]; then + rm -rf -- "${MYSQL_GENERIC_LINK}" "${MYSQL_GENERIC_DIR}" + files_note_change "deleted ${MYSQL_GENERIC_DIR}" + fi + fi + file_remove "${MYSQL_ROOT_CNF}" + for path in "${MYSQL_DATA}" /var/lib/mysql-files /var/lib/mysql-keyring; do + if [[ -d ${path} ]]; then + rm -rf -- "${path}" + files_note_change "deleted ${path}" + fi + done + secret_remove mysql-root + for path in "${LSS_SECRETS_DIR}"/mysql-user-*; do + [[ -f ${path} ]] || continue + secret_remove "${path##*/}" + done + return +} From a5cabd35ccab4b3550fd0317b4d9ab17f6d15a20 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Tue, 13 Oct 2026 21:40:32 +0200 Subject: [PATCH 22/92] mysql: never let root exist without a password The generic build was initialised with --initialize-insecure, and on the RHEL family mysql-prepare-db-dir does the same on the first start, so root had an empty password until the configure step changed it; a run that failed in between left it that way. lss now creates the data directory itself with --initialize and an --init-file that sets root in the same bootstrap, before the server accepts any connection: auth_socket for the generic build, the generated password on the RHEL family (mysql-prepare-db-dir then finds the directory initialised). The init file lives in a private directory readable by root and the mysql group and is deleted right away; a failed bootstrap empties the data directory again. Existing data directories with an empty root password are still repaired. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- modules/database/mysql.sh | 52 ++++++++++++++++++++++++++++++++++----- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/modules/database/mysql.sh b/modules/database/mysql.sh index ad287f4..ce7c539 100644 --- a/modules/database/mysql.sh +++ b/modules/database/mysql.sh @@ -210,7 +210,7 @@ mod_install() { } # The generic build has no package scripts: lss writes its base configuration and unit and creates the data -# directory once (root starts with an empty password and is switched to auth_socket right after). +# directory once, with root on auth_socket from the start. _mysql_generic_configure() { install -d -m 0750 -o mysql -g mysql "${MYSQL_DATA}" /var/lib/mysql-files install -d -m 0750 -o mysql -g adm /var/log/mysql @@ -254,13 +254,50 @@ RestartPreventExitStatus=1 WantedBy=multi-user.target EOF if [[ ! -e ${MYSQL_DATA}/mysql.ibd ]]; then - log_command "${MYSQL_GENERIC_LINK}/bin/mysqld" "--defaults-file=${MYSQL_GENERIC_CNF}" --initialize-insecure \ - --user=mysql || die "mysql: initialising ${MYSQL_DATA} failed (see /var/log/mysql/error.log)." - files_note_change "initialised ${MYSQL_DATA}" + _mysql_initialize "INSTALL PLUGIN auth_socket SONAME 'auth_socket.so'; +ALTER USER 'root'@'localhost' IDENTIFIED WITH auth_socket;" \ + "${MYSQL_GENERIC_LINK}/bin/mysqld" "--defaults-file=${MYSQL_GENERIC_CNF}" fi return } +# _mysql_initialize SQL MYSQLD [OPTION...]: creates the data directory with --initialize (never --initialize-insecure) +# and runs SQL in the same bootstrap, before the server ever accepts a connection, so root never exists with an empty +# password. The SQL file (it may hold the root password) lives in a private directory only root and the mysql group +# can read and is deleted right after. A failed bootstrap leaves no half-initialised data directory behind. +_mysql_initialize() { + local sql=$1 dir status=0 + shift + install -d -m 0755 -o mysql -g mysql /run/mysqld + dir=$(mktemp -d /run/mysqld/lss-init.XXXXXX) + chgrp mysql "${dir}" + chmod 0750 "${dir}" + ( + umask 027 + printf '%s\n' "${sql}" >"${dir}/init.sql" + ) + chgrp mysql "${dir}/init.sql" + log_command "$@" --initialize --user=mysql "--init-file=${dir}/init.sql" || status=$? + rm -rf -- "${dir}" + if [[ ${status} -ne 0 ]]; then + find "${MYSQL_DATA}" -mindepth 1 -delete + die "mysql: initialising ${MYSQL_DATA} failed (see the MySQL error log)." + fi + files_note_change "initialised ${MYSQL_DATA}" + return +} + +# RHEL family: lss creates the data directory itself, with the generated root password, before the first start; the +# unit's mysql-prepare-db-dir then finds it initialised and skips its --initialize-insecure. +_mysql_rhel_initialize() { + local password + [[ ! -e ${MYSQL_DATA}/mysql.ibd ]] || return 0 + secret_load mysql-root password + install -d -m 0751 -o mysql -g mysql "${MYSQL_DATA}" + _mysql_initialize "ALTER USER 'root'@'localhost' IDENTIFIED BY '${password}';" /usr/libexec/mysqld + return +} + # _mysql_sql: runs the SQL on standard input as root through the Unix socket and prints the rows, tab-separated. # On the RHEL family the password comes from /root/.my.cnf. _mysql_sql() { @@ -305,8 +342,9 @@ _mysql_root_socket() { return } -# RHEL family: the server creates root with an empty password. lss sets a generated one and writes it to -# /root/.my.cnf (mode 0600), so the client logs in without a password on the command line. +# RHEL family: root has the generated password (set at initialisation) and /root/.my.cnf (mode 0600) holds it, so the +# client logs in without a password on the command line. A data directory an older installation created with an +# empty root password is repaired here. _mysql_root_password() { local password client secret_load mysql-root password @@ -441,6 +479,8 @@ default-character-set = utf8mb4 EOF if [[ $(_mysql_source) == generic ]]; then _mysql_generic_configure + elif os_is_rhel; then + _mysql_rhel_initialize fi svc_sandbox "${unit}.service" "ReadWritePaths=${MYSQL_DATA}" ReadWritePaths=-/var/lib/mysql-files \ ReadWritePaths=-/var/lib/mysql-keyring From e6a22640aeae2592f3545f28688ca8155a5e3614 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Tue, 13 Oct 2026 21:43:57 +0200 Subject: [PATCH 23/92] mysql, mariadb: delete only fixed paths, and nothing on a failed start The bootstrap of a MySQL data directory now refuses a directory that is a symbolic link or has any content, so emptying it after a failed bootstrap only removes what that bootstrap wrote; an existing data directory is never touched. The private directory of the init file is checked before it is deleted. The purge paths delete only the fixed directories of the module (data, files, keyring, the unpacked build), never through a symbolic link, and stop the step on anything else. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- modules/database/mariadb.sh | 5 ++++- modules/database/mysql.sh | 38 ++++++++++++++++++++++++++++++------- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/modules/database/mariadb.sh b/modules/database/mariadb.sh index 9173815..4996395 100644 --- a/modules/database/mariadb.sh +++ b/modules/database/mariadb.sh @@ -287,7 +287,10 @@ mod_remove() { file_remove "$(_mariadb_conf)" if [[ ${LSS_PURGE:-0} == 1 ]]; then pkg_remove_family "mariadb-server mariadb-client" "mariadb-server mariadb" - if [[ -d ${MARIADB_DATA} ]]; then + # Only the fixed data directory, never through a symbolic link. + if [[ -L ${MARIADB_DATA} ]]; then + die "mariadb: refusing to delete ${MARIADB_DATA}: it is a symbolic link." + elif [[ -d ${MARIADB_DATA} ]]; then rm -rf -- "${MARIADB_DATA}" files_note_change "deleted ${MARIADB_DATA}" fi diff --git a/modules/database/mysql.sh b/modules/database/mysql.sh index ce7c539..8f7c55f 100644 --- a/modules/database/mysql.sh +++ b/modules/database/mysql.sh @@ -264,12 +264,17 @@ ALTER USER 'root'@'localhost' IDENTIFIED WITH auth_socket;" \ # _mysql_initialize SQL MYSQLD [OPTION...]: creates the data directory with --initialize (never --initialize-insecure) # and runs SQL in the same bootstrap, before the server ever accepts a connection, so root never exists with an empty # password. The SQL file (it may hold the root password) lives in a private directory only root and the mysql group -# can read and is deleted right after. A failed bootstrap leaves no half-initialised data directory behind. +# can read and is deleted right after. The data directory must be empty (lss refuses to touch one with content), so +# emptying it again after a failed bootstrap removes only what this bootstrap wrote. _mysql_initialize() { local sql=$1 dir status=0 shift + [[ -d ${MYSQL_DATA} && ! -L ${MYSQL_DATA} ]] || die "mysql: ${MYSQL_DATA} is missing or a symbolic link." + [[ -z $(ls -A -- "${MYSQL_DATA}") ]] || + die "mysql: ${MYSQL_DATA} is not empty but holds no MySQL data dictionary; move its content away first." install -d -m 0755 -o mysql -g mysql /run/mysqld dir=$(mktemp -d /run/mysqld/lss-init.XXXXXX) + [[ ${dir} == /run/mysqld/lss-init.?* ]] || die "mysql: could not create a private directory in /run/mysqld." chgrp mysql "${dir}" chmod 0750 "${dir}" ( @@ -536,6 +541,28 @@ mod_verify() { return } +# _mysql_delete PATH: deletes one of the fixed paths of this module (purge only). Anything else, an empty path or a +# directory that is a symbolic link stops the step instead; the /opt/mysql link itself is removed as a link. +_mysql_delete() { + local path=$1 + case ${path} in + "${MYSQL_DATA}" | /var/lib/mysql-files | /var/lib/mysql-keyring | "${MYSQL_GENERIC_DIR}") ;; + "${MYSQL_GENERIC_LINK}") + if [[ -L ${path} ]]; then + rm -f -- "${path}" + files_note_change "removed the link ${path}" + fi + return 0 + ;; + *) die "mysql: refusing to delete '${path}'." ;; + esac + [[ -e ${path} || -L ${path} ]] || return 0 + [[ ! -L ${path} ]] || die "mysql: refusing to delete ${path}: it is a symbolic link." + rm -rf -- "${path}" + files_note_change "deleted ${path}" + return +} + mod_remove() { local unit path packages unit=$(_mysql_unit) @@ -558,16 +585,13 @@ mod_remove() { svc_daemon_reload # The unpacked build (about 1 GB) is deleted, not backed up like a configuration file. if [[ -e ${MYSQL_GENERIC_DIR} || -L ${MYSQL_GENERIC_LINK} ]]; then - rm -rf -- "${MYSQL_GENERIC_LINK}" "${MYSQL_GENERIC_DIR}" - files_note_change "deleted ${MYSQL_GENERIC_DIR}" + _mysql_delete "${MYSQL_GENERIC_LINK}" + _mysql_delete "${MYSQL_GENERIC_DIR}" fi fi file_remove "${MYSQL_ROOT_CNF}" for path in "${MYSQL_DATA}" /var/lib/mysql-files /var/lib/mysql-keyring; do - if [[ -d ${path} ]]; then - rm -rf -- "${path}" - files_note_change "deleted ${path}" - fi + _mysql_delete "${path}" done secret_remove mysql-root for path in "${LSS_SECRETS_DIR}"/mysql-user-*; do From b9a8e8631b9cb6301231d9e18e42f14aeb169542 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Tue, 13 Oct 2026 21:46:13 +0200 Subject: [PATCH 24/92] Add the postgresql module PostgreSQL from the distribution (the 16 module stream on RHEL 9, whose default 13 is past its end of life) or, with LSS_POSTGRESQL_VERSION, 14 to 18 from the PostgreSQL project's repositories (apt.postgresql.org and download.postgresql.org, keys pinned; the RPM repositories check packages and metadata). Settings go into a conf.d file and an lss pg_hba file, so removing the module returns the cluster to the distribution's configuration: listen_addresses localhost, SCRAM passwords, TLS on (the snakeoil certificate on Debian and Ubuntu, a self-signed one generated by the postgres user elsewhere) with TLS 1.2 at least. Only the postgres system user logs in through peer; every other role needs its SCRAM password, also on the local socket; remote networks only through hostssl. initdb on RHEL runs with peer and SCRAM, never trust. Optional databases and roles with generated passwords, a systemd sandbox, a firewall rule only for vpn or public access. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- data/keys/pgdg-rpm.asc | 82 ++++++ data/keys/pgdg.asc | 77 ++++++ data/repos/pgdg-14.conf | 13 + data/repos/pgdg-15.conf | 13 + data/repos/pgdg-16.conf | 13 + data/repos/pgdg-17.conf | 13 + data/repos/pgdg-18.conf | 13 + data/repos/pgdg.conf | 13 + modules/database/postgresql.sh | 453 +++++++++++++++++++++++++++++++++ 9 files changed, 690 insertions(+) create mode 100644 data/keys/pgdg-rpm.asc create mode 100644 data/keys/pgdg.asc create mode 100644 data/repos/pgdg-14.conf create mode 100644 data/repos/pgdg-15.conf create mode 100644 data/repos/pgdg-16.conf create mode 100644 data/repos/pgdg-17.conf create mode 100644 data/repos/pgdg-18.conf create mode 100644 data/repos/pgdg.conf create mode 100644 modules/database/postgresql.sh diff --git a/data/keys/pgdg-rpm.asc b/data/keys/pgdg-rpm.asc new file mode 100644 index 0000000..758dc15 --- /dev/null +++ b/data/keys/pgdg-rpm.asc @@ -0,0 +1,82 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +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 +=Elfg +-----END PGP PUBLIC KEY BLOCK----- +-----BEGIN PGP PUBLIC KEY BLOCK----- + +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 +=aMFr +-----END PGP PUBLIC KEY BLOCK----- diff --git a/data/keys/pgdg.asc b/data/keys/pgdg.asc new file mode 100644 index 0000000..8480576 --- /dev/null +++ b/data/keys/pgdg.asc @@ -0,0 +1,77 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +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 +=DA1T +-----END PGP PUBLIC KEY BLOCK----- diff --git a/data/repos/pgdg-14.conf b/data/repos/pgdg-14.conf new file mode 100644 index 0000000..c0c6568 --- /dev/null +++ b/data/repos/pgdg-14.conf @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# PostgreSQL 14 from the PGDG RPM repository (https://download.postgresql.org/pub/repos/yum/), used on the RHEL +# family when LSS_POSTGRESQL_VERSION=14. Keys fetched from https://download.postgresql.org/pub/repos/yum/keys/ +# (PGDG-RPM-GPG-KEY-RHEL for x86_64, PGDG-RPM-GPG-KEY-AARCH64-RHEL for aarch64) on 2026-10-13; both sign the +# packages and the repository metadata of their architecture. +KEY=pgdg-rpm.asc +FINGERPRINTS=D4BF08AE67A0B4C7A1DBCCD240BCA2B408B40D20 B031F89FC983E98262906B6E177B343BB9738825 +RPM_BASEURL=https://download.postgresql.org/pub/repos/yum/14/redhat/rhel-{{major}}-$basearch +RPM_GPGCHECK=1 +RPM_REPO_GPGCHECK=1 diff --git a/data/repos/pgdg-15.conf b/data/repos/pgdg-15.conf new file mode 100644 index 0000000..b02b687 --- /dev/null +++ b/data/repos/pgdg-15.conf @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# PostgreSQL 15 from the PGDG RPM repository (https://download.postgresql.org/pub/repos/yum/), used on the RHEL +# family when LSS_POSTGRESQL_VERSION=15. Keys fetched from https://download.postgresql.org/pub/repos/yum/keys/ +# (PGDG-RPM-GPG-KEY-RHEL for x86_64, PGDG-RPM-GPG-KEY-AARCH64-RHEL for aarch64) on 2026-10-13; both sign the +# packages and the repository metadata of their architecture. +KEY=pgdg-rpm.asc +FINGERPRINTS=D4BF08AE67A0B4C7A1DBCCD240BCA2B408B40D20 B031F89FC983E98262906B6E177B343BB9738825 +RPM_BASEURL=https://download.postgresql.org/pub/repos/yum/15/redhat/rhel-{{major}}-$basearch +RPM_GPGCHECK=1 +RPM_REPO_GPGCHECK=1 diff --git a/data/repos/pgdg-16.conf b/data/repos/pgdg-16.conf new file mode 100644 index 0000000..03a682e --- /dev/null +++ b/data/repos/pgdg-16.conf @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# PostgreSQL 16 from the PGDG RPM repository (https://download.postgresql.org/pub/repos/yum/), used on the RHEL +# family when LSS_POSTGRESQL_VERSION=16. Keys fetched from https://download.postgresql.org/pub/repos/yum/keys/ +# (PGDG-RPM-GPG-KEY-RHEL for x86_64, PGDG-RPM-GPG-KEY-AARCH64-RHEL for aarch64) on 2026-10-13; both sign the +# packages and the repository metadata of their architecture. +KEY=pgdg-rpm.asc +FINGERPRINTS=D4BF08AE67A0B4C7A1DBCCD240BCA2B408B40D20 B031F89FC983E98262906B6E177B343BB9738825 +RPM_BASEURL=https://download.postgresql.org/pub/repos/yum/16/redhat/rhel-{{major}}-$basearch +RPM_GPGCHECK=1 +RPM_REPO_GPGCHECK=1 diff --git a/data/repos/pgdg-17.conf b/data/repos/pgdg-17.conf new file mode 100644 index 0000000..a388ba1 --- /dev/null +++ b/data/repos/pgdg-17.conf @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# PostgreSQL 17 from the PGDG RPM repository (https://download.postgresql.org/pub/repos/yum/), used on the RHEL +# family when LSS_POSTGRESQL_VERSION=17. Keys fetched from https://download.postgresql.org/pub/repos/yum/keys/ +# (PGDG-RPM-GPG-KEY-RHEL for x86_64, PGDG-RPM-GPG-KEY-AARCH64-RHEL for aarch64) on 2026-10-13; both sign the +# packages and the repository metadata of their architecture. +KEY=pgdg-rpm.asc +FINGERPRINTS=D4BF08AE67A0B4C7A1DBCCD240BCA2B408B40D20 B031F89FC983E98262906B6E177B343BB9738825 +RPM_BASEURL=https://download.postgresql.org/pub/repos/yum/17/redhat/rhel-{{major}}-$basearch +RPM_GPGCHECK=1 +RPM_REPO_GPGCHECK=1 diff --git a/data/repos/pgdg-18.conf b/data/repos/pgdg-18.conf new file mode 100644 index 0000000..d3600f3 --- /dev/null +++ b/data/repos/pgdg-18.conf @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# PostgreSQL 18 from the PGDG RPM repository (https://download.postgresql.org/pub/repos/yum/), used on the RHEL +# family when LSS_POSTGRESQL_VERSION=18. Keys fetched from https://download.postgresql.org/pub/repos/yum/keys/ +# (PGDG-RPM-GPG-KEY-RHEL for x86_64, PGDG-RPM-GPG-KEY-AARCH64-RHEL for aarch64) on 2026-10-13; both sign the +# packages and the repository metadata of their architecture. +KEY=pgdg-rpm.asc +FINGERPRINTS=D4BF08AE67A0B4C7A1DBCCD240BCA2B408B40D20 B031F89FC983E98262906B6E177B343BB9738825 +RPM_BASEURL=https://download.postgresql.org/pub/repos/yum/18/redhat/rhel-{{major}}-$basearch +RPM_GPGCHECK=1 +RPM_REPO_GPGCHECK=1 diff --git a/data/repos/pgdg.conf b/data/repos/pgdg.conf new file mode 100644 index 0000000..edf3a50 --- /dev/null +++ b/data/repos/pgdg.conf @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# PostgreSQL Global Development Group APT repository (https://apt.postgresql.org), used when LSS_POSTGRESQL_VERSION +# asks for a version. Key fetched from https://www.postgresql.org/media/keys/ACCC4CF8.asc on 2026-10-13; the +# fingerprint matches the one on https://wiki.postgresql.org/wiki/Apt. All supported releases have a suite, on amd64 +# and arm64. +KEY=pgdg.asc +FINGERPRINTS=B97B0AFCAA1A47F044F244A07FCC7D46ACCC4CF8 +DEB_URL=https://apt.postgresql.org/pub/repos/apt +DEB_SUITE={{codename}}-pgdg +DEB_COMPONENTS=main diff --git a/modules/database/postgresql.sh b/modules/database/postgresql.sh new file mode 100644 index 0000000..f59ba48 --- /dev/null +++ b/modules/database/postgresql.sh @@ -0,0 +1,453 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +MOD_NAME="postgresql" +MOD_GROUP="database" +MOD_DESCRIPTION="PostgreSQL on localhost with SCRAM passwords (peer only for postgres), TLS, optional databases and users" +MOD_REQUIRES="system" +MOD_OPTIONS="version||Major version from the PostgreSQL project's repository (14 to 18); empty installs the distribution's version +listen|localhost|listen_addresses: localhost, * or addresses separated by commas +port|5432|TCP port +access|local|Firewall: local (no rule), vpn (WireGuard network only) or public +allow||Networks that may connect from other hosts (TLS and password only), CIDR, separated by spaces +databases||Databases to create, separated by spaces +users||Application users as user:database, separated by spaces; each owns its database and gets a generated password (lss secrets show postgresql-user-USER)" +MOD_PORTS="5432/tcp on localhost" +MOD_SECRETS="postgresql-user-" + +# Versions without a version option: Debian 12: 15, Debian 13: 17, Ubuntu 22.04: 14, 24.04: 16, 26.04: 18, +# RHEL 9: 16 (module stream; the default 13 is past its upstream end of life), RHEL 10: 16. + +PG_SETTINGS=zz-lss.conf +PG_HBA=lss_hba.conf +PG_INITDB_OPTIONS="--auth-local=peer --auth-host=scram-sha-256" + +_pg_name_ok() { + local name=$1 + [[ ${name} =~ ^[a-z][a-z0-9_]{0,62}$ ]] + return +} + +# _pg_version: the major version in use (Debian family and PGDG installs; empty for the RHEL distribution package). +_pg_version() { + local version dir + version=$(opt version) + if [[ -z ${version} ]] && os_is_debian; then + for dir in /usr/lib/postgresql/*/bin/postgres; do + [[ -x ${dir} ]] || continue + dir=${dir%/bin/postgres} + dir=${dir##*/} + if [[ -z ${version} || ${dir} -gt ${version} ]]; then + version=${dir} + fi + done + fi + printf '%s' "${version}" + return +} + +_pg_unit() { + local version + version=$(_pg_version) + if os_is_debian; then + printf 'postgresql@%s-main' "${version}" + elif [[ -n $(opt version) ]]; then + printf 'postgresql-%s' "${version}" + else + printf 'postgresql' + fi + return +} + +_pg_data() { + local version + version=$(_pg_version) + if os_is_debian; then + printf '/var/lib/postgresql/%s/main' "${version}" + elif [[ -n $(opt version) ]]; then + printf '/var/lib/pgsql/%s/data' "${version}" + else + printf '/var/lib/pgsql/data' + fi + return +} + +# Directory of the configuration files: /etc/postgresql/VERSION/main on the Debian family, the data directory on RHEL. +_pg_confdir() { + if os_is_debian; then + printf '/etc/postgresql/%s/main' "$(_pg_version)" + else + _pg_data + fi + return +} + +_pg_packages() { + local version + version=$(opt version) + if os_is_debian; then + if [[ -n ${version} ]]; then printf 'postgresql-%s' "${version}"; else printf 'postgresql'; fi + elif [[ -n ${version} ]]; then + printf 'postgresql%s-server postgresql%s' "${version}" "${version}" + else + printf 'postgresql-server postgresql' + fi + return +} + +_pg_psql() { + local version + version=$(opt version) + if os_is_rhel && [[ -n ${version} ]]; then + printf '/usr/pgsql-%s/bin/psql' "${version}" + else + printf 'psql' + fi + return +} + +mod_check() { + local version listen port access network database entry user dir + version=$(opt version) + listen=$(opt listen) + port=$(opt_int port) + access=$(opt access) + [[ -z ${version} || ${version} =~ ^1[4-8]$ ]] || die "LSS_POSTGRESQL_VERSION must be empty or 14 to 18." + [[ ${listen} =~ ^[A-Za-z0-9.:*-]+(,[A-Za-z0-9.:*-]+)*$ ]] || + die "LSS_POSTGRESQL_LISTEN must be localhost, * or addresses separated by commas." + [[ ${port} -ge 1 && ${port} -le 65535 ]] || die "LSS_POSTGRESQL_PORT must be between 1 and 65535." + [[ ${access} =~ ^(local|vpn|public)$ ]] || die "LSS_POSTGRESQL_ACCESS must be local, vpn or public." + for network in $(opt allow); do + [[ ${network} =~ ^[0-9A-Fa-f:.]+/[0-9]{1,3}$ ]] || + die "LSS_POSTGRESQL_ALLOW: '${network}' is not a network like 10.66.0.0/24." + done + if [[ ${access} != local ]]; then + [[ ${listen} != localhost && ${listen} != 127.0.0.1 ]] || + die "LSS_POSTGRESQL_ACCESS=${access} needs LSS_POSTGRESQL_LISTEN set to an address other hosts can reach." + [[ -n $(opt allow) ]] || die "LSS_POSTGRESQL_ACCESS=${access} needs the client networks in LSS_POSTGRESQL_ALLOW." + fi + for database in $(opt databases); do + _pg_name_ok "${database}" || die "LSS_POSTGRESQL_DATABASES: '${database}' is not a valid name (a-z, 0-9, _)." + [[ ! ${database} =~ ^(postgres|template0|template1)$ ]] || + die "LSS_POSTGRESQL_DATABASES: '${database}' is a reserved database." + done + for entry in $(opt users); do + [[ ${entry} == *:* ]] || die "LSS_POSTGRESQL_USERS: '${entry}' must look like user:database." + user=${entry%%:*} + database=${entry#*:} + _pg_name_ok "${user}" || die "LSS_POSTGRESQL_USERS: '${user}' is not a valid user name (a-z, 0-9, _)." + _pg_name_ok "${database}" || die "LSS_POSTGRESQL_USERS: '${database}' is not a valid database name." + [[ ! ${user} =~ ^(postgres|root|pg_.*)$ ]] || die "LSS_POSTGRESQL_USERS: '${user}' is a reserved role." + [[ ! ${database} =~ ^(postgres|template0|template1)$ ]] || + die "LSS_POSTGRESQL_USERS: '${database}' is a reserved database." + done + [[ $(opt users | tr ' ' '\n' | cut -d ':' -f 2 | sort | uniq -d) == '' ]] || + die "LSS_POSTGRESQL_USERS: each database can belong to one user only." + # Two clusters would fight for the port; moving to another version is a pg_upgrade, not a setting. + if [[ -n ${version} ]]; then + if os_is_debian; then + for dir in /etc/postgresql/*/main; do + [[ -d ${dir} && ${dir} != "/etc/postgresql/${version}/main" ]] || continue + die "postgresql: a cluster of another version exists (${dir});" \ + "upgrade it with pg_upgradecluster or remove it first." + done + elif [[ -s /var/lib/pgsql/data/PG_VERSION ]]; then + die "postgresql: the distribution's cluster in /var/lib/pgsql/data exists; upgrade it or remove it first." + fi + fi + return +} + +# RHEL 9 installs PostgreSQL 13 by default, whose upstream support ended in November 2025; lss takes the 16 stream, +# the version RHEL 10 ships. An existing installation keeps its version. +_pg_el9_stream() { + local enabled + pkg_is_installed postgresql-server && return 0 + enabled=$(dnf -q module list --enabled postgresql 2>/dev/null || true) + [[ ${enabled} != *16* ]] || return 0 + log_command dnf -y -q module enable postgresql:16 + files_note_change "enabled the postgresql:16 module stream" + return +} + +mod_install() { + local version + version=$(opt version) + if [[ -n ${version} ]]; then + if os_is_debian; then repo_add pgdg; else repo_add "pgdg-${version}"; fi + elif os_is_rhel && [[ ${OS_MAJOR} == 9 ]]; then + _pg_el9_stream + fi + pkg_install_family "$(_pg_packages)" "$(_pg_packages)" + if os_is_debian; then + pkg_install ssl-cert + else + pkg_install openssl + fi + return +} + +# The Debian family creates the main cluster with the package (peer and SCRAM, no trust); the RHEL family needs an +# explicit initdb, told not to fall back to trust. lss replaces the authentication with its own pg_hba before the +# first start either way. +_pg_cluster() { + local version data + version=$(_pg_version) + data=$(_pg_data) + [[ -s ${data}/PG_VERSION ]] && return 0 + if os_is_debian; then + log_command pg_createcluster "${version}" main || die "postgresql: pg_createcluster ${version} main failed." + elif [[ -n $(opt version) ]]; then + PGSETUP_INITDB_OPTIONS=${PG_INITDB_OPTIONS} \ + log_command "/usr/pgsql-${version}/bin/postgresql-${version}-setup" initdb || + die "postgresql: initdb failed (see /var/lib/pgsql/initdb_postgresql-${version}.log)." + else + PGSETUP_INITDB_OPTIONS=${PG_INITDB_OPTIONS} log_command postgresql-setup --initdb || + die "postgresql: initdb failed (see /var/lib/pgsql/initdb_postgresql.log)." + fi + files_note_change "created the PostgreSQL cluster in ${data}" + return +} + +# Prints "CERT KEY": the snakeoil certificate of the ssl-cert package on the Debian family, else a self-signed one in +# the data directory, generated once by the postgres user (so root never writes into a directory postgres owns). +_pg_tls() { + local data name + if [[ -r /etc/ssl/certs/ssl-cert-snakeoil.pem && -r /etc/ssl/private/ssl-cert-snakeoil.key ]]; then + printf '/etc/ssl/certs/ssl-cert-snakeoil.pem /etc/ssl/private/ssl-cert-snakeoil.key' + return 0 + fi + data=$(_pg_data) + if [[ ! -s ${data}/server.crt || ! -s ${data}/server.key ]]; then + name=$(hostname -f 2>/dev/null || hostname) + ( + umask 077 + cd / && log_command runuser -u postgres -- openssl req -x509 -nodes -newkey rsa:3072 -sha256 -days 3650 \ + -subj "/CN=${name}" -keyout "${data}/server.key" -out "${data}/server.crt" + ) || die "postgresql: generating the TLS certificate failed." + files_note_change "generated a self-signed TLS certificate in ${data}" + fi + printf '%s %s' "${data}/server.crt" "${data}/server.key" + return +} + +_pg_hba_content() { + local network + cat <<'EOF' +# Managed by lss (module postgresql). Only the postgres system user logs in without a password (peer, as the +# postgres role); every other role needs its SCRAM password, also on the local socket. No trust anywhere. Change +# LSS_POSTGRESQL_ALLOW and run lss configure postgresql. +# TYPE DATABASE USER ADDRESS METHOD +local all postgres peer +local all all scram-sha-256 +host all all 127.0.0.1/32 scram-sha-256 +host all all ::1/128 scram-sha-256 +local replication postgres peer +host replication all 127.0.0.1/32 scram-sha-256 +host replication all ::1/128 scram-sha-256 +EOF + for network in $(opt allow); do + printf 'hostssl all all %-14s scram-sha-256\n' "${network}" + done + return +} + +# _pg_sql: runs the SQL on standard input as the postgres user over the local socket and prints the rows. +_pg_sql() { + local psql port + psql=$(_pg_psql) + port=$(opt_int port) + (cd / && runuser -u postgres -- "${psql}" -X -q -A -t -v ON_ERROR_STOP=1 -p "${port}" -d postgres) + return +} + +_pg_query() { + local sql=$1 + _pg_sql <<<"${sql}" + return +} + +_pg_wait() { + local i + for ((i = 0; i < 60; i++)); do + if [[ $(_pg_query 'SELECT 1' 2>/dev/null) == 1 ]]; then + return 0 + fi + sleep 1 + done + die "postgresql: the server does not answer on its local socket." +} + +# A port other than 5432 needs the postgresql_port_t label on SELinux systems. +_pg_selinux_port() { + local port=$1 custom + [[ ${port} != 5432 ]] || return 0 + os_is_rhel || return 0 + if ! command -v selinuxenabled >/dev/null 2>&1 || ! selinuxenabled; then + return 0 + fi + pkg_install policycoreutils-python-utils + custom=$(semanage port -l -C 2>/dev/null | awk '$1 == "postgresql_port_t" && $2 == "tcp" { print $3 }') + grep -qxF "${port}" <<<"${custom}" && return 0 + semanage port -a -t postgresql_port_t -p tcp "${port}" 2>/dev/null || + semanage port -m -t postgresql_port_t -p tcp "${port}" + files_note_change "SELinux port ${port}/tcp for PostgreSQL" + return +} + +# Databases of LSS_POSTGRESQL_DATABASES (owned by postgres) and of LSS_POSTGRESQL_USERS (owned by their user). +_pg_databases() { + local database entry owner current + for database in $(opt databases); do + if [[ -z $(_pg_query "SELECT 1 FROM pg_database WHERE datname = '${database}'") ]]; then + _pg_query "CREATE DATABASE \"${database}\"" + files_note_change "created database ${database}" + fi + done + for entry in $(opt users); do + owner=${entry%%:*} + database=${entry#*:} + current=$(_pg_query "SELECT pg_catalog.pg_get_userbyid(datdba) FROM pg_database WHERE datname = '${database}'") + if [[ -z ${current} ]]; then + _pg_query "CREATE DATABASE \"${database}\" OWNER \"${owner}\"" + files_note_change "created database ${database}" + elif [[ ${current} != "${owner}" ]]; then + _pg_query "ALTER DATABASE \"${database}\" OWNER TO \"${owner}\"" + files_note_change "database ${database} belongs to ${owner}" + fi + done + return +} + +# Each role is created once with its generated password, sent on standard input and hashed by the server with SCRAM. +_pg_users() { + local entry user password + for entry in $(opt users); do + user=${entry%%:*} + [[ -z $(_pg_query "SELECT 1 FROM pg_roles WHERE rolname = '${user}'") ]] || continue + secret_load "postgresql-user-${user}" password + _pg_sql <<<"SET password_encryption = 'scram-sha-256'; CREATE ROLE \"${user}\" LOGIN PASSWORD '${password}';" + files_note_change "created database role ${user}" + log_info "Password of ${user}: lss secrets show postgresql-user-${user}" + done + return +} + +mod_configure() { + local before changed=0 port unit confdir data tls cert key + port=$(opt_int port) + before=$(files_changes_of postgresql) + _pg_cluster + unit=$(_pg_unit) + confdir=$(_pg_confdir) + data=$(_pg_data) + _pg_selinux_port "${port}" + tls=$(_pg_tls) + cert=${tls% *} + key=${tls#* } + if os_is_rhel; then + # The RHEL configuration has no include directory; one line in postgresql.conf adds conf.d. + [[ ! -L ${data}/conf.d ]] || die "postgresql: ${data}/conf.d is a symbolic link." + install -d -m 0700 -o postgres -g postgres "${data}/conf.d" + file_ensure_lines 0600 postgres:postgres "${data}/postgresql.conf" <<<"include_dir = 'conf.d'" + fi + file_write 0640 postgres:postgres "${confdir}/${PG_HBA}" < <(_pg_hba_content) + file_write 0644 postgres:postgres "${confdir}/conf.d/${PG_SETTINGS}" < '{postgres}')") + [[ ${result} == 0 ]] || die "postgresql: pg_hba allows more than peer for postgres and SCRAM passwords." + _pg_check_listen "$(opt_int port)" "$(opt listen)" + return +} + +mod_remove() { + local unit confdir path version + version=$(opt version) + unit=$(_pg_unit) + confdir=$(_pg_confdir) + svc_disable "${unit}" + svc_dropin_remove "${unit}.service" lss-sandbox + # Without these two files the cluster falls back to the configuration and pg_hba.conf of the distribution. + file_remove "${confdir}/conf.d/${PG_SETTINGS}" + file_remove "${confdir}/${PG_HBA}" + [[ ${LSS_PURGE:-0} == 1 ]] || return 0 + pkg_remove_family "$(_pg_packages) postgresql-common" "$(_pg_packages)" + if [[ -n ${version} ]]; then + if os_is_debian; then repo_remove pgdg; else repo_remove "pgdg-${version}"; fi + fi + # Only these fixed directories, never through a symbolic link. + for path in /var/lib/postgresql /etc/postgresql /var/lib/pgsql; do + [[ ! -L ${path} ]] || die "postgresql: refusing to delete ${path}: it is a symbolic link." + if [[ -d ${path} ]]; then + rm -rf -- "${path}" + files_note_change "deleted ${path}" + fi + done + for path in "${LSS_SECRETS_DIR}"/postgresql-user-*; do + [[ -f ${path} ]] || continue + secret_remove "${path##*/}" + done + return +} From e3a2302f4e33766e38b88633a5c516d6f72646e1 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Tue, 13 Oct 2026 21:50:51 +0200 Subject: [PATCH 25/92] postgresql: isolate application roles, keep root out of postgres-owned paths Tenant isolation: pg_hba names each role with its own database (local socket, loopback and, TLS only, the LSS_POSTGRESQL_ALLOW networks); no "all" rule exists except peer for the postgres system user. Every database lss creates has CONNECT revoked from PUBLIC, CREATE on its public schema revoked from PUBLIC (needed before PostgreSQL 15) and the schema handed to the owning role. Roles are created and kept without SUPERUSER, CREATEDB, CREATEROLE, REPLICATION and BYPASSRLS. verify rejects any other pg_hba rule and a pg_hba with errors. Root no longer writes into directories postgres owns: the settings and pg_hba live in /etc/lss/postgresql (root-owned, group postgres), the include line in postgresql.conf is appended by the postgres user, and every check or command in the data directory runs as postgres. The include is include_if_exists, so a removed module leaves a cluster that starts with the distribution's configuration. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- modules/database/postgresql.sh | 173 +++++++++++++++++++++++---------- 1 file changed, 122 insertions(+), 51 deletions(-) diff --git a/modules/database/postgresql.sh b/modules/database/postgresql.sh index f59ba48..03c723f 100644 --- a/modules/database/postgresql.sh +++ b/modules/database/postgresql.sh @@ -11,16 +11,16 @@ listen|localhost|listen_addresses: localhost, * or addresses separated by commas port|5432|TCP port access|local|Firewall: local (no rule), vpn (WireGuard network only) or public allow||Networks that may connect from other hosts (TLS and password only), CIDR, separated by spaces -databases||Databases to create, separated by spaces -users||Application users as user:database, separated by spaces; each owns its database and gets a generated password (lss secrets show postgresql-user-USER)" +databases||Databases to create, owned by postgres, separated by spaces +users||Application users as user:database, separated by spaces; each owns its database, can reach only that one and gets a generated password (lss secrets show postgresql-user-USER)" MOD_PORTS="5432/tcp on localhost" MOD_SECRETS="postgresql-user-" # Versions without a version option: Debian 12: 15, Debian 13: 17, Ubuntu 22.04: 14, 24.04: 16, 26.04: 18, # RHEL 9: 16 (module stream; the default 13 is past its upstream end of life), RHEL 10: 16. -PG_SETTINGS=zz-lss.conf -PG_HBA=lss_hba.conf +PG_SETTINGS=/etc/lss/postgresql/postgresql.conf +PG_HBA=/etc/lss/postgresql/pg_hba.conf PG_INITDB_OPTIONS="--auth-local=peer --auth-host=scram-sha-256" _pg_name_ok() { @@ -126,6 +126,7 @@ mod_check() { [[ ${listen} != localhost && ${listen} != 127.0.0.1 ]] || die "LSS_POSTGRESQL_ACCESS=${access} needs LSS_POSTGRESQL_LISTEN set to an address other hosts can reach." [[ -n $(opt allow) ]] || die "LSS_POSTGRESQL_ACCESS=${access} needs the client networks in LSS_POSTGRESQL_ALLOW." + [[ -n $(opt users) ]] || die "LSS_POSTGRESQL_ACCESS=${access} needs the remote roles in LSS_POSTGRESQL_USERS." fi for database in $(opt databases); do _pg_name_ok "${database}" || die "LSS_POSTGRESQL_DATABASES: '${database}' is not a valid name (a-z, 0-9, _)." @@ -219,7 +220,7 @@ _pg_tls() { return 0 fi data=$(_pg_data) - if [[ ! -s ${data}/server.crt || ! -s ${data}/server.key ]]; then + if ! _pg_as_postgres test -s "${data}/server.crt" || ! _pg_as_postgres test -s "${data}/server.key"; then name=$(hostname -f 2>/dev/null || hostname) ( umask 077 @@ -232,39 +233,63 @@ _pg_tls() { return } +# _pg_as_postgres COMMAND...: runs a command as the postgres user from /. Everything lss does inside directories that +# postgres owns (data directory, /etc/postgresql/VERSION/main) goes through here, so a link planted there can never +# make root write, chmod or chown another file. +_pg_as_postgres() { + (cd / && runuser -u postgres -- "$@") + return +} + +# _pg_include FILE: appends "include_if_exists = '/etc/lss/postgresql/postgresql.conf'" to the cluster's +# postgresql.conf once, as the postgres user. The line is harmless after the module is removed (the file is gone). +_pg_include() { + local file=$1 line + line="include_if_exists = '${PG_SETTINGS}'" + _pg_as_postgres grep -qxF -- "${line}" "${file}" && return 0 + _pg_as_postgres sh -c 'printf "%s\n" "$2" >>"$1"' sh "${file}" "${line}" || + die "postgresql: could not add the lss settings to ${file}." + files_note_change "included ${PG_SETTINGS} in ${file}" + return +} + +# pg_hba: only the postgres system user logs in without a password (peer); each application role reaches only its +# own database, with its SCRAM password, on the local socket, over loopback and (TLS only) from LSS_POSTGRESQL_ALLOW. +# No "all" for application roles and no trust anywhere. _pg_hba_content() { - local network + local entry user database network cat <<'EOF' -# Managed by lss (module postgresql). Only the postgres system user logs in without a password (peer, as the -# postgres role); every other role needs its SCRAM password, also on the local socket. No trust anywhere. Change -# LSS_POSTGRESQL_ALLOW and run lss configure postgresql. -# TYPE DATABASE USER ADDRESS METHOD -local all postgres peer -local all all scram-sha-256 -host all all 127.0.0.1/32 scram-sha-256 -host all all ::1/128 scram-sha-256 -local replication postgres peer -host replication all 127.0.0.1/32 scram-sha-256 -host replication all ::1/128 scram-sha-256 +# Managed by lss (module postgresql). Change LSS_POSTGRESQL_USERS or LSS_POSTGRESQL_ALLOW and run +# lss configure postgresql. +# TYPE DATABASE USER ADDRESS METHOD +local all postgres peer +local replication postgres peer EOF - for network in $(opt allow); do - printf 'hostssl all all %-14s scram-sha-256\n' "${network}" + for entry in $(opt users); do + user=${entry%%:*} + database=${entry#*:} + printf '%-8s %-12s %-9s %-16s %s\n' local "${database}" "${user}" '' scram-sha-256 \ + host "${database}" "${user}" 127.0.0.1/32 scram-sha-256 host "${database}" "${user}" ::1/128 scram-sha-256 + for network in $(opt allow); do + printf '%-8s %-12s %-9s %-16s %s\n' hostssl "${database}" "${user}" "${network}" scram-sha-256 + done done return } -# _pg_sql: runs the SQL on standard input as the postgres user over the local socket and prints the rows. +# _pg_sql [DATABASE]: runs the SQL on standard input as the postgres user over the local socket and prints the rows. _pg_sql() { - local psql port + local database=${1:-postgres} psql port psql=$(_pg_psql) port=$(opt_int port) - (cd / && runuser -u postgres -- "${psql}" -X -q -A -t -v ON_ERROR_STOP=1 -p "${port}" -d postgres) + _pg_as_postgres "${psql}" -X -q -A -t -v ON_ERROR_STOP=1 -p "${port}" -d "${database}" return } +# _pg_query SQL [DATABASE] _pg_query() { - local sql=$1 - _pg_sql <<<"${sql}" + local sql=$1 database=${2:-postgres} + _pg_sql "${database}" <<<"${sql}" return } @@ -296,7 +321,39 @@ _pg_selinux_port() { return } -# Databases of LSS_POSTGRESQL_DATABASES (owned by postgres) and of LSS_POSTGRESQL_USERS (owned by their user). +# Nobody but the owner (and superusers) may connect to a database lss created: CONNECT is revoked from PUBLIC. +_pg_private() { + local database=$1 public + public=$(_pg_query "SELECT count(*) FROM pg_database d, aclexplode(COALESCE(d.datacl, acldefault('d', d.datdba))) a + WHERE d.datname = '${database}' AND a.grantee = 0 AND a.privilege_type = 'CONNECT'") + if [[ ${public} != 0 ]]; then + _pg_query "REVOKE CONNECT ON DATABASE \"${database}\" FROM PUBLIC" + files_note_change "revoked CONNECT on ${database} from PUBLIC" + fi + return +} + +# Before PostgreSQL 15 every role may create objects in the public schema of every database; lss revokes that and +# hands the schema to the database's user, which is what PostgreSQL 15 and later do by default. +_pg_public_schema() { + local database=$1 owner=$2 result + result=$(_pg_query "SELECT has_schema_privilege('public', 'public', 'CREATE')" "${database}") + if [[ ${result} == t ]]; then + _pg_query 'REVOKE CREATE ON SCHEMA public FROM PUBLIC' "${database}" + files_note_change "revoked CREATE on the public schema of ${database} from PUBLIC" + fi + [[ -n ${owner} ]] || return 0 + result=$(_pg_query "SELECT pg_catalog.pg_get_userbyid(nspowner) FROM pg_namespace WHERE nspname = 'public'" \ + "${database}") + if [[ ${result} != "${owner}" && ${result} != pg_database_owner ]]; then + _pg_query "ALTER SCHEMA public OWNER TO \"${owner}\"" "${database}" + files_note_change "the public schema of ${database} belongs to ${owner}" + fi + return +} + +# Databases of LSS_POSTGRESQL_DATABASES (owned by postgres) and of LSS_POSTGRESQL_USERS (owned by their user), each +# private to its owner. _pg_databases() { local database entry owner current for database in $(opt databases); do @@ -304,6 +361,8 @@ _pg_databases() { _pg_query "CREATE DATABASE \"${database}\"" files_note_change "created database ${database}" fi + _pg_private "${database}" + _pg_public_schema "${database}" '' done for entry in $(opt users); do owner=${entry%%:*} @@ -316,54 +375,61 @@ _pg_databases() { _pg_query "ALTER DATABASE \"${database}\" OWNER TO \"${owner}\"" files_note_change "database ${database} belongs to ${owner}" fi + _pg_private "${database}" + _pg_public_schema "${database}" "${owner}" done return } # Each role is created once with its generated password, sent on standard input and hashed by the server with SCRAM. +# Application roles never get superuser, CREATEDB, CREATEROLE, REPLICATION or BYPASSRLS; every run takes them away again. _pg_users() { - local entry user password + local entry user password powers for entry in $(opt users); do user=${entry%%:*} - [[ -z $(_pg_query "SELECT 1 FROM pg_roles WHERE rolname = '${user}'") ]] || continue - secret_load "postgresql-user-${user}" password - _pg_sql <<<"SET password_encryption = 'scram-sha-256'; CREATE ROLE \"${user}\" LOGIN PASSWORD '${password}';" - files_note_change "created database role ${user}" - log_info "Password of ${user}: lss secrets show postgresql-user-${user}" + if [[ -z $(_pg_query "SELECT 1 FROM pg_roles WHERE rolname = '${user}'") ]]; then + secret_load "postgresql-user-${user}" password + _pg_sql <<<"SET password_encryption = 'scram-sha-256'; +CREATE ROLE \"${user}\" LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS PASSWORD '${password}';" + files_note_change "created database role ${user}" + log_info "Password of ${user}: lss secrets show postgresql-user-${user}" + fi + powers=$(_pg_query "SELECT rolsuper OR rolcreatedb OR rolcreaterole OR rolreplication OR rolbypassrls + FROM pg_roles WHERE rolname = '${user}'") + if [[ ${powers} == t ]]; then + _pg_query "ALTER ROLE \"${user}\" NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS" + files_note_change "took the extra powers of role ${user} away" + fi done return } mod_configure() { - local before changed=0 port unit confdir data tls cert key + local before changed=0 port unit tls cert key hba port=$(opt_int port) before=$(files_changes_of postgresql) _pg_cluster unit=$(_pg_unit) - confdir=$(_pg_confdir) - data=$(_pg_data) _pg_selinux_port "${port}" tls=$(_pg_tls) cert=${tls% *} key=${tls#* } - if os_is_rhel; then - # The RHEL configuration has no include directory; one line in postgresql.conf adds conf.d. - [[ ! -L ${data}/conf.d ]] || die "postgresql: ${data}/conf.d is a symbolic link." - install -d -m 0700 -o postgres -g postgres "${data}/conf.d" - file_ensure_lines 0600 postgres:postgres "${data}/postgresql.conf" <<<"include_dir = 'conf.d'" - fi - file_write 0640 postgres:postgres "${confdir}/${PG_HBA}" < <(_pg_hba_content) - file_write 0644 postgres:postgres "${confdir}/conf.d/${PG_SETTINGS}" < '{postgres}')") - [[ ${result} == 0 ]] || die "postgresql: pg_hba allows more than peer for postgres and SCRAM passwords." + [[ $(_pg_query 'SHOW hba_file') == "${PG_HBA}" ]] || die "postgresql: the server does not use ${PG_HBA}." + result=$(_pg_query "SELECT count(*) FROM pg_hba_file_rules WHERE error IS NOT NULL + OR auth_method NOT IN ('peer', 'scram-sha-256') + OR (auth_method = 'peer' AND user_name <> '{postgres}') + OR (user_name <> '{postgres}' AND ('all' = ANY (user_name) OR 'all' = ANY (database)))") + [[ ${result} == 0 ]] || + die "postgresql: pg_hba allows more than peer for postgres and SCRAM for each role on its own database." _pg_check_listen "$(opt_int port)" "$(opt listen)" return } mod_remove() { - local unit confdir path version + local unit path version version=$(opt version) unit=$(_pg_unit) - confdir=$(_pg_confdir) svc_disable "${unit}" svc_dropin_remove "${unit}.service" lss-sandbox - # Without these two files the cluster falls back to the configuration and pg_hba.conf of the distribution. - file_remove "${confdir}/conf.d/${PG_SETTINGS}" - file_remove "${confdir}/${PG_HBA}" + # Without these files the include line in postgresql.conf finds nothing, and the cluster falls back to the + # configuration and pg_hba.conf of the distribution. + file_remove "${PG_SETTINGS}" + file_remove "${PG_HBA}" + rmdir "${PG_SETTINGS%/*}" 2>/dev/null || true [[ ${LSS_PURGE:-0} == 1 ]] || return 0 pkg_remove_family "$(_pg_packages) postgresql-common" "$(_pg_packages)" if [[ -n ${version} ]]; then From 1c7f07f9e67c16f0d88c72a8bd1bbd6022fe0053 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Tue, 13 Oct 2026 21:51:31 +0200 Subject: [PATCH 26/92] mysql: keep the bootstrap file out of a directory mysql owns The private directory for the init file was created inside /run/mysqld, which belongs to the mysql user, who could have replaced it with a link between mktemp and chgrp/chmod. It now lives directly in root-owned /run (labelled mysqld_var_run_t where SELinux is on). Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- modules/database/mysql.sh | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/modules/database/mysql.sh b/modules/database/mysql.sh index 8f7c55f..ff3852d 100644 --- a/modules/database/mysql.sh +++ b/modules/database/mysql.sh @@ -272,16 +272,22 @@ _mysql_initialize() { [[ -d ${MYSQL_DATA} && ! -L ${MYSQL_DATA} ]] || die "mysql: ${MYSQL_DATA} is missing or a symbolic link." [[ -z $(ls -A -- "${MYSQL_DATA}") ]] || die "mysql: ${MYSQL_DATA} is not empty but holds no MySQL data dictionary; move its content away first." - install -d -m 0755 -o mysql -g mysql /run/mysqld - dir=$(mktemp -d /run/mysqld/lss-init.XXXXXX) - [[ ${dir} == /run/mysqld/lss-init.?* ]] || die "mysql: could not create a private directory in /run/mysqld." + # The directory sits in root-owned /run, so the mysql user cannot swap it for a link between the steps below. + dir=$(mktemp -d /run/lss-mysql-init.XXXXXX) + [[ ${dir} == /run/lss-mysql-init.?* ]] || die "mysql: could not create a private directory in /run." chgrp mysql "${dir}" chmod 0750 "${dir}" + if command -v selinuxenabled >/dev/null 2>&1 && selinuxenabled; then + chcon -t mysqld_var_run_t "${dir}" 2>/dev/null || true + fi ( umask 027 printf '%s\n' "${sql}" >"${dir}/init.sql" ) chgrp mysql "${dir}/init.sql" + if command -v selinuxenabled >/dev/null 2>&1 && selinuxenabled; then + chcon -t mysqld_var_run_t "${dir}/init.sql" 2>/dev/null || true + fi log_command "$@" --initialize --user=mysql "--init-file=${dir}/init.sql" || status=$? rm -rf -- "${dir}" if [[ ${status} -ne 0 ]]; then From 038f0a0d9bdc3a051e6691c444d6cb1b8072ee44 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Tue, 13 Oct 2026 21:51:52 +0200 Subject: [PATCH 27/92] Add the redis module Redis from the distribution (redis-server on Debian and Ubuntu, redis on RHEL 9) and, on RHEL 10, which ships Valkey instead, from the Redis project's RPM repository (packages.redis.io, key pinned, packages signed). lss writes its own configuration to /etc/lss/redis.conf and points the unit at it with a drop-in: bind 127.0.0.1 and ::1 (left out without IPv6), protected mode, journal logging, optional maxmemory and policy. The default user's password is a generated secret, stored in the configuration only as its SHA-256 and in clear text in /etc/lss/redis-password (0640, group lss-redis) for clients. FLUSHALL, CONFIG, DEBUG, SHUTDOWN, MODULE, MONITOR, replication, MIGRATE and ACL are blocked through the ACL, not rename-command. verify pings with the password from the environment (REDISCLI_AUTH), checks that a command without it is refused and that CONFIG is blocked. systemd sandbox. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- data/keys/redis.asc | 52 ++++++++ data/repos/redis.conf | 12 ++ modules/database/redis.sh | 241 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 305 insertions(+) create mode 100644 data/keys/redis.asc create mode 100644 data/repos/redis.conf create mode 100644 modules/database/redis.sh diff --git a/data/keys/redis.asc b/data/keys/redis.asc new file mode 100644 index 0000000..e46a2a7 --- /dev/null +++ b/data/keys/redis.asc @@ -0,0 +1,52 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +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 +Oi5R+3n0UUbz020kbQ== +=1K+h +-----END PGP PUBLIC KEY BLOCK----- diff --git a/data/repos/redis.conf b/data/repos/redis.conf new file mode 100644 index 0000000..fd27857 --- /dev/null +++ b/data/repos/redis.conf @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# Redis Open Source from https://packages.redis.io, used on RHEL 10, which ships Valkey but no Redis. Key fetched from +# https://packages.redis.io/gpg on 2026-10-13; Redis publishes no fingerprint next to it, the one pinned here is the +# key the RPM packages are signed with. The repository metadata is not signed, so dnf checks the packages instead. +KEY=redis.asc +FINGERPRINTS=54318FA4052D1E61A6B6F7BB5F4349D6BF53AA0C +RPM_BASEURL=https://packages.redis.io/rpm/rockylinux{{major}} +RPM_GPGCHECK=1 +RPM_REPO_GPGCHECK=0 diff --git a/modules/database/redis.sh b/modules/database/redis.sh new file mode 100644 index 0000000..f2fbf59 --- /dev/null +++ b/modules/database/redis.sh @@ -0,0 +1,241 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +MOD_NAME="redis" +MOD_GROUP="database" +MOD_DESCRIPTION="Redis on localhost with a generated password and dangerous commands blocked by ACL" +MOD_REQUIRES="system" +MOD_CONFLICTS="valkey" +MOD_OPTIONS="bind|127.0.0.1 ::1|Addresses Redis listens on, separated by spaces (::1 is left out where IPv6 is off) +port|6379|TCP port +access|local|Firewall: local (no rule), vpn (WireGuard network only) or public +maxmemory||Memory limit such as 256mb or 2gb; empty means no limit +maxmemory_policy|noeviction|What happens at the limit: noeviction, allkeys-lru, volatile-lru, allkeys-lfu, volatile-lfu, allkeys-random, volatile-random or volatile-ttl +blocked_commands|FLUSHALL CONFIG DEBUG SHUTDOWN MODULE MONITOR REPLICAOF SLAVEOF SYNC PSYNC MIGRATE ACL|Commands the clients may not run, separated by spaces" +MOD_PORTS="6379/tcp on localhost" +MOD_SECRETS="redis-password" + +# Sources: the distribution on Debian, Ubuntu and RHEL 9 (redis-server, redis); on RHEL 10, which replaced Redis with +# Valkey, the Redis project's own RPM repository (packages.redis.io, data/repos/redis.conf). +# The configuration lives in root-owned /etc/lss: /etc/redis belongs to the redis user on some systems, and root does +# not write into a directory a service account controls. +REDIS_CONF=/etc/lss/redis.conf +REDIS_PASSWORD_FILE=/etc/lss/redis-password +REDIS_GROUP=lss-redis +REDIS_DATA=/var/lib/redis + +_redis_unit() { + if os_is_debian; then printf 'redis-server'; else printf 'redis'; fi + return +} + +_redis_is_loopback() { + local address=$1 + [[ ${address} == 127.* || ${address} == ::1 || ${address} == localhost ]] + return +} + +mod_check() { + local address port access command remote=0 + for address in $(opt bind); do + [[ ${address} =~ ^[0-9A-Fa-f:.]+$ ]] || die "LSS_REDIS_BIND: '${address}' is not an IP address." + _redis_is_loopback "${address}" || remote=1 + done + [[ -n $(opt bind) ]] || die "LSS_REDIS_BIND needs at least one address." + port=$(opt_int port) + access=$(opt access) + [[ ${port} -ge 1 && ${port} -le 65535 ]] || die "LSS_REDIS_PORT must be between 1 and 65535." + [[ ${access} =~ ^(local|vpn|public)$ ]] || die "LSS_REDIS_ACCESS must be local, vpn or public." + if [[ ${access} != local && ${remote} == 0 ]]; then + die "LSS_REDIS_ACCESS=${access} needs an address in LSS_REDIS_BIND that other hosts can reach." + fi + [[ -z $(opt maxmemory) || $(opt maxmemory) =~ ^[0-9]+([kmg]b?)?$ ]] || + die "LSS_REDIS_MAXMEMORY must look like 256mb or 2gb." + [[ $(opt maxmemory_policy) =~ ^(noeviction|(allkeys|volatile)-(lru|lfu|random)|volatile-ttl)$ ]] || + die "LSS_REDIS_MAXMEMORY_POLICY '$(opt maxmemory_policy)' is not a Redis eviction policy." + for command in $(opt blocked_commands); do + [[ ${command} =~ ^[A-Za-z]+$ ]] || die "LSS_REDIS_BLOCKED_COMMANDS: '${command}' is not a command name." + done + return +} + +mod_install() { + if os_is_rhel && [[ ${OS_MAJOR} -ge 10 ]]; then + repo_add redis + fi + pkg_install_family "redis-server redis-tools" "redis" + return +} + +# The addresses to bind: ::1 only where the loopback interface has it (Redis before 6.2 cannot skip a missing one). +_redis_bind() { + local address + local -a list=() + for address in $(opt bind); do + if [[ ${address} == ::1 ]] && ! grep -q '^0\{31\}1 ' /proc/net/if_inet6 2>/dev/null; then + continue + fi + list+=("${address}") + done + printf '%s' "${list[*]}" + return +} + +# Major.minor of the installed server, for ACL syntax that older versions lack. +_redis_version() { + local output + output=$(redis-server --version) + [[ ${output} =~ v=([0-9]+)\.([0-9]+) ]] || die "redis: cannot read the version from '${output}'." + printf '%s' "$((BASH_REMATCH[1] * 100 + BASH_REMATCH[2]))" + return +} + +# The ACL rule of the default user: the password as its SHA-256 (so the configuration holds no clear text), all keys, +# all channels (Redis 6.2 and later), all commands except the blocked ones. +_redis_acl() { + local password=$1 hash command rule + hash=$(printf '%s' "${password}" | sha256sum | cut -d ' ' -f 1) + rule="user default on #${hash} ~*" + if [[ $(_redis_version) -ge 602 ]]; then + rule+=' &*' + fi + rule+=' +@all' + for command in $(opt blocked_commands); do + rule+=" -${command,,}" + done + printf '%s' "${rule}" + return +} + +_redis_config() { + local password=$1 maxmemory + maxmemory=$(opt maxmemory) + cat </dev/null; then + groupadd --system "${REDIS_GROUP}" + files_note_change "created group ${REDIS_GROUP}" + fi + # Clients read the password from this file (add their user to the group); it never appears on a command line. + file_write 0640 "root:${REDIS_GROUP}" "${REDIS_PASSWORD_FILE}" <<<"${password}" + config=$(_redis_config "${password}") + file_write 0640 root:redis "${REDIS_CONF}" <<<"${config}" + svc_dropin "${unit}.service" lss-config <&1 || true) + [[ ${output} != PONG ]] || break + sleep 1 + done + [[ ${output} == PONG ]] || die "redis: PING with the password returned '${output}'." + output=$(redis-cli -h "${host}" -p "${port}" get lss-probe 2>&1 || true) + [[ ${output} == *NOAUTH* ]] || die "redis: a command without the password was accepted ('${output}')." + if [[ " $(opt blocked_commands) " == *" CONFIG "* ]]; then + output=$(REDISCLI_AUTH=${password} redis-cli -h "${host}" -p "${port}" config get maxmemory 2>&1 || true) + [[ ${output} == *NOPERM* ]] || die "redis: CONFIG is not blocked ('${output}')." + fi + _redis_check_listen "${port}" + return +} + +mod_remove() { + local unit + unit=$(_redis_unit) + svc_disable "${unit}" + svc_dropin_remove "${unit}.service" lss-sandbox + svc_dropin_remove "${unit}.service" lss-config + file_remove "${REDIS_CONF}" + file_remove "${REDIS_PASSWORD_FILE}" + [[ ${LSS_PURGE:-0} == 1 ]] || return 0 + pkg_remove_family "redis-server redis-tools" "redis" + repo_remove redis + [[ ! -L ${REDIS_DATA} ]] || die "redis: refusing to delete ${REDIS_DATA}: it is a symbolic link." + if [[ -d ${REDIS_DATA} ]]; then + rm -rf -- "${REDIS_DATA}" + files_note_change "deleted ${REDIS_DATA}" + fi + if getent group "${REDIS_GROUP}" >/dev/null; then + groupdel "${REDIS_GROUP}" + files_note_change "deleted group ${REDIS_GROUP}" + fi + secret_remove redis-password + return +} From ba501e9afe38f3a82fa579d4bb1f6b1ebb07e335 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Tue, 13 Oct 2026 21:58:19 +0200 Subject: [PATCH 28/92] Add the valkey module Valkey from the distribution on Debian 13, Ubuntu 24.04 (7.2 from noble-updates) and 26.04 and RHEL 9 and 10 (AppStream), from bookworm-backports on Debian 12 (Debian archive key, an apt pin limits backports to the valkey packages) and on Ubuntu 22.04, which has no package, the Valkey project's jammy build (SHA-256 in data/checksums.txt) in /opt with a valkey user and a unit of lss's own. Hardening and checks as in the redis module: lss configuration in /etc/lss/valkey.conf, localhost only, password as SHA-256 in the ACL of the default user, dangerous commands blocked, password for clients in /etc/lss/valkey-password, verify with authenticated PING, refused unauthenticated command and blocked CONFIG. systemd sandbox. Purge deletes only the module's fixed directories and refuses symbolic links. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- data/checksums.txt | 5 + modules/database/valkey.sh | 401 +++++++++++++++++++++++++++++++++++++ 2 files changed, 406 insertions(+) create mode 100644 modules/database/valkey.sh diff --git a/data/checksums.txt b/data/checksums.txt index dd3f61b..35273ce 100644 --- a/data/checksums.txt +++ b/data/checksums.txt @@ -25,6 +25,11 @@ gunicorn-26.2.0 any bd249d0b3f7972f7432f0a6b6ff3b3ee2d129f70cd1ff6c09a9dd9e29a2b # Files lss downloads directly, read by fetch_verified (lib/fetch.sh): . +# Valkey 8.1.10, upstream build for Ubuntu 22.04, which has no Valkey package. SHA-256 from the .sha256 files next to +# the archives (https://valkey.io/download/), 2026-10-13. +valkey-jammy x86_64 23400087a684b6e87f66332e0a836a2674c24c7c482361766777149499c08e50 https://download.valkey.io/releases/valkey-8.1.10-jammy-x86_64.tar.gz +valkey-jammy aarch64 44abd1a738fdda3831bb3d84f19e4bcd45bdb537d0cee24dace364f511193eaf https://download.valkey.io/releases/valkey-8.1.10-jammy-arm64.tar.gz + # MySQL 8.4.11 LTS, Oracle's generic glibc 2.28 build for Debian on arm64 (repo.mysql.com has amd64 packages only). # Oracle publishes an MD5 sum and a GnuPG signature, no SHA-256: the archive matched the MD5 on # https://dev.mysql.com/downloads/mysql/8.4.html (b22fb0240514099ff95251b1c8ed2127) and its signature by the MySQL diff --git a/modules/database/valkey.sh b/modules/database/valkey.sh new file mode 100644 index 0000000..4a1ae69 --- /dev/null +++ b/modules/database/valkey.sh @@ -0,0 +1,401 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +MOD_NAME="valkey" +MOD_GROUP="database" +MOD_DESCRIPTION="Valkey on localhost with a generated password and dangerous commands blocked by ACL" +MOD_REQUIRES="system" +MOD_CONFLICTS="redis" +MOD_OPTIONS="bind|127.0.0.1 ::1|Addresses Valkey listens on, separated by spaces (::1 is left out where IPv6 is off) +port|6379|TCP port +access|local|Firewall: local (no rule), vpn (WireGuard network only) or public +maxmemory||Memory limit such as 256mb or 2gb; empty means no limit +maxmemory_policy|noeviction|What happens at the limit: noeviction, allkeys-lru, volatile-lru, allkeys-lfu, volatile-lfu, allkeys-random, volatile-random or volatile-ttl +blocked_commands|FLUSHALL CONFIG DEBUG SHUTDOWN MODULE MONITOR REPLICAOF SLAVEOF SYNC PSYNC MIGRATE ACL|Commands the clients may not run, separated by spaces" +MOD_PORTS="6379/tcp on localhost" +MOD_SECRETS="valkey-password" + +# Sources: the distribution where it has Valkey (Debian 13, Ubuntu 24.04 and 26.04, RHEL 9 and 10 AppStream), +# bookworm-backports on Debian 12 and the Valkey project's build for Ubuntu 22.04 (data/checksums.txt), which has no +# package at all. +# The configuration lives in root-owned /etc/lss: root does not write into /etc/valkey, which the valkey user may +# control. +VALKEY_CONF=/etc/lss/valkey.conf +VALKEY_PASSWORD_FILE=/etc/lss/valkey-password +VALKEY_GROUP=lss-valkey +VALKEY_DATA=/var/lib/valkey +VALKEY_UPSTREAM_VERSION=8.1.10 +VALKEY_UPSTREAM_DIR=/opt/valkey-${VALKEY_UPSTREAM_VERSION} +VALKEY_UPSTREAM_LINK=/opt/valkey +VALKEY_BACKPORTS_SOURCE=/etc/apt/sources.list.d/lss-bookworm-backports.sources +VALKEY_BACKPORTS_PIN=/etc/apt/preferences.d/lss-valkey + +# upstream (Ubuntu 22.04), backports (Debian 12) or distribution. +_valkey_source() { + if [[ ${OS_ID} == ubuntu && ${OS_CODENAME} == jammy ]]; then + printf 'upstream' + elif [[ ${OS_ID} == debian && ${OS_CODENAME} == bookworm ]]; then + printf 'backports' + else + printf 'distribution' + fi + return +} + +_valkey_unit() { + if os_is_debian; then printf 'valkey-server'; else printf 'valkey'; fi + return +} + +_valkey_bin() { + if [[ $(_valkey_source) == upstream ]]; then + printf '%s/bin' "${VALKEY_UPSTREAM_LINK}" + else + printf '/usr/bin' + fi + return +} + +_valkey_is_loopback() { + local address=$1 + [[ ${address} == 127.* || ${address} == ::1 || ${address} == localhost ]] + return +} + +mod_check() { + local address port access command remote=0 + for address in $(opt bind); do + [[ ${address} =~ ^[0-9A-Fa-f:.]+$ ]] || die "LSS_VALKEY_BIND: '${address}' is not an IP address." + _valkey_is_loopback "${address}" || remote=1 + done + [[ -n $(opt bind) ]] || die "LSS_VALKEY_BIND needs at least one address." + port=$(opt_int port) + access=$(opt access) + [[ ${port} -ge 1 && ${port} -le 65535 ]] || die "LSS_VALKEY_PORT must be between 1 and 65535." + [[ ${access} =~ ^(local|vpn|public)$ ]] || die "LSS_VALKEY_ACCESS must be local, vpn or public." + if [[ ${access} != local && ${remote} == 0 ]]; then + die "LSS_VALKEY_ACCESS=${access} needs an address in LSS_VALKEY_BIND that other hosts can reach." + fi + [[ -z $(opt maxmemory) || $(opt maxmemory) =~ ^[0-9]+([kmg]b?)?$ ]] || + die "LSS_VALKEY_MAXMEMORY must look like 256mb or 2gb." + [[ $(opt maxmemory_policy) =~ ^(noeviction|(allkeys|volatile)-(lru|lfu|random)|volatile-ttl)$ ]] || + die "LSS_VALKEY_MAXMEMORY_POLICY '$(opt maxmemory_policy)' is not a Valkey eviction policy." + for command in $(opt blocked_commands); do + [[ ${command} =~ ^[A-Za-z]+$ ]] || die "LSS_VALKEY_BLOCKED_COMMANDS: '${command}' is not a command name." + done + return +} + +# Debian 12 has Valkey only in bookworm-backports, signed with the Debian archive key the system already trusts. The +# pin raises just the valkey packages to the normal priority, so nothing else comes from backports. +_valkey_backports() { + local changed=0 file + for file in /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; do + [[ -f ${file} && ${file} != "${VALKEY_BACKPORTS_SOURCE}" ]] || continue + if grep -qs '^[^#]*bookworm-backports' "${file}"; then + changed=-1 + break + fi + done + if [[ ${changed} == 0 ]]; then + file_write 0644 root:root "${VALKEY_BACKPORTS_SOURCE}" </dev/null) != "${VALKEY_UPSTREAM_DIR}" ]]; then + ln -sfn "${VALKEY_UPSTREAM_DIR}" "${VALKEY_UPSTREAM_LINK}" + files_note_change "linked ${VALKEY_UPSTREAM_LINK} to ${VALKEY_UPSTREAM_DIR}" + fi + for program in valkey-server valkey-cli; do + if [[ $(readlink "/usr/local/bin/${program}" 2>/dev/null) != "${VALKEY_UPSTREAM_LINK}/bin/${program}" ]]; then + ln -sfn "${VALKEY_UPSTREAM_LINK}/bin/${program}" "/usr/local/bin/${program}" + files_note_change "linked /usr/local/bin/${program}" + fi + done + if ! getent passwd valkey >/dev/null; then + useradd --system --user-group --home-dir "${VALKEY_DATA}" --no-create-home --shell /usr/sbin/nologin valkey + files_note_change "created user valkey" + fi + if [[ ! -d ${VALKEY_DATA} ]]; then + install -d -m 0750 -o valkey -g valkey "${VALKEY_DATA}" + files_note_change "created ${VALKEY_DATA}" + fi + svc_unit valkey-server.service </dev/null; then + continue + fi + list+=("${address}") + done + printf '%s' "${list[*]}" + return +} + +# The ACL rule of the default user: the password as its SHA-256 (so the configuration holds no clear text), all keys +# and channels, all commands except the blocked ones. +_valkey_acl() { + local password=$1 hash command rule + hash=$(printf '%s' "${password}" | sha256sum | cut -d ' ' -f 1) + rule="user default on #${hash} ~* &* +@all" + for command in $(opt blocked_commands); do + rule+=" -${command,,}" + done + printf '%s' "${rule}" + return +} + +_valkey_config() { + local password=$1 maxmemory + maxmemory=$(opt maxmemory) + cat </dev/null; then + groupadd --system "${VALKEY_GROUP}" + files_note_change "created group ${VALKEY_GROUP}" + fi + # Clients read the password from this file (add their user to the group); it never appears on a command line. + file_write 0640 "root:${VALKEY_GROUP}" "${VALKEY_PASSWORD_FILE}" <<<"${password}" + config=$(_valkey_config "${password}") + file_write 0640 root:valkey "${VALKEY_CONF}" <<<"${config}" + svc_dropin "${unit}.service" lss-config <&1 || true) + [[ ${output} != PONG ]] || break + sleep 1 + done + [[ ${output} == PONG ]] || die "valkey: PING with the password returned '${output}'." + output=$("$(_valkey_bin)/valkey-cli" -h "${host}" -p "${port}" get lss-probe 2>&1 || true) + [[ ${output} == *NOAUTH* ]] || die "valkey: a command without the password was accepted ('${output}')." + if [[ " $(opt blocked_commands) " == *" CONFIG "* ]]; then + output=$(_valkey_cli_auth "${password}" -h "${host}" -p "${port}" config get maxmemory 2>&1 || true) + [[ ${output} == *NOPERM* ]] || die "valkey: CONFIG is not blocked ('${output}')." + fi + _valkey_check_listen "${port}" + return +} + +# Deletes a fixed directory of the module; anything else, or a symbolic link in its place, stops the removal. +_valkey_delete() { + local path=$1 + case "${path}" in + "${VALKEY_DATA}" | "${VALKEY_UPSTREAM_DIR}") ;; + *) die "valkey: refusing to delete ${path}: it is not a directory of the module." ;; + esac + [[ ! -L ${path} ]] || die "valkey: refusing to delete ${path}: it is a symbolic link." + [[ -d ${path} ]] || return 0 + rm -rf -- "${path}" + files_note_change "deleted ${path}" + return +} + +_valkey_upstream_remove() { + local program + _valkey_delete "${VALKEY_UPSTREAM_DIR}" + if [[ -L ${VALKEY_UPSTREAM_LINK} ]]; then + rm -f -- "${VALKEY_UPSTREAM_LINK}" + files_note_change "deleted ${VALKEY_UPSTREAM_LINK}" + fi + for program in valkey-server valkey-cli; do + if [[ $(readlink "/usr/local/bin/${program}" 2>/dev/null) == "${VALKEY_UPSTREAM_LINK}/bin/${program}" ]]; then + rm -f -- "/usr/local/bin/${program}" + files_note_change "deleted /usr/local/bin/${program}" + fi + done + if getent passwd valkey >/dev/null; then + userdel valkey + files_note_change "deleted user valkey" + fi + return +} + +mod_remove() { + local unit source + unit=$(_valkey_unit) + source=$(_valkey_source) + svc_disable "${unit}" + svc_dropin_remove "${unit}.service" lss-sandbox + svc_dropin_remove "${unit}.service" lss-config + file_remove "${VALKEY_CONF}" + file_remove "${VALKEY_PASSWORD_FILE}" + [[ ${LSS_PURGE:-0} == 1 ]] || return 0 + case "${source}" in + upstream) + file_remove /etc/systemd/system/valkey-server.service + svc_daemon_reload + _valkey_upstream_remove + ;; + backports) + pkg_remove valkey-server valkey-tools + file_remove "${VALKEY_BACKPORTS_PIN}" + file_remove "${VALKEY_BACKPORTS_SOURCE}" + ;; + *) pkg_remove_family "valkey-server valkey-tools" "valkey" ;; + esac + _valkey_delete "${VALKEY_DATA}" + if getent group "${VALKEY_GROUP}" >/dev/null; then + groupdel "${VALKEY_GROUP}" + files_note_change "deleted group ${VALKEY_GROUP}" + fi + secret_remove valkey-password + return +} From 7896e7dad43892127da3145aa46ed48589800a8b Mon Sep 17 00:00:00 2001 From: VectoDE Date: Tue, 13 Oct 2026 22:02:31 +0200 Subject: [PATCH 29/92] Add the mongodb module MongoDB Community 9.0 (default) or 8.0 from repo.mongodb.org with the release keys pinned: the Debian suites on Debian amd64, the Ubuntu suites on Ubuntu, on Debian arm64 (the Debian suites carry amd64 only; jammy build on Debian 12, noble build on Debian 13) and for 8.0 on Debian 13 and Ubuntu 26.04, which have no 8.0 build. RHEL 9 and 10 use the redhat repository, packages and metadata signed. lss's configuration in /etc/lss/mongod.conf, read through a drop-in: bind 127.0.0.1, Unix socket off, authorization on, server-side JavaScript off (option). The administrator lss-admin is created once through the localhost exception; application users get readWrite on their own database only. Passwords reach mongosh through script files in a private root-owned directory, never on a command line. mongosh telemetry off. mod_check refuses CPUs without AVX or ARMv8.2-A and a change of the release series over an existing installation. verify logs in, checks authorization and JavaScript in the running options and that listDatabases without a login is refused. systemd sandbox, SELinux port label for a non-default port. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- data/keys/mongodb-8.0.asc | 29 +++ data/keys/mongodb-9.asc | 55 +++++ data/repos/mongodb-8.0-ubuntu.conf | 17 ++ data/repos/mongodb-8.0.conf | 17 ++ data/repos/mongodb-9.0-ubuntu.conf | 15 ++ data/repos/mongodb-9.0.conf | 16 ++ modules/database/mongodb.sh | 385 +++++++++++++++++++++++++++++ 7 files changed, 534 insertions(+) create mode 100644 data/keys/mongodb-8.0.asc create mode 100644 data/keys/mongodb-9.asc create mode 100644 data/repos/mongodb-8.0-ubuntu.conf create mode 100644 data/repos/mongodb-8.0.conf create mode 100644 data/repos/mongodb-9.0-ubuntu.conf create mode 100644 data/repos/mongodb-9.0.conf create mode 100644 modules/database/mongodb.sh diff --git a/data/keys/mongodb-8.0.asc b/data/keys/mongodb-8.0.asc new file mode 100644 index 0000000..3202042 --- /dev/null +++ b/data/keys/mongodb-8.0.asc @@ -0,0 +1,29 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBGWgEhwBEADJpjwR+5n6na3tqZ6ueHsW/U8lvcvMFZ1DYNo+/JhrNjHkZ7HR +Wbc2IzWej1zqTtctSKZvrCkPGZxiDsKB5xta/NVtnpjSuV02Gp0F6hf0gnvark04 +HnEFaV2w15Tyr8Z4KHRDbdja6h/24t4tR0KkRzxh5U7FwLL8BpK2drbTog9FBMy+ +lqYDfOLHx6JDeOMC7eSNe/jJsAiuVcP/y+vQbLuMYAaMPSvJoidRIQ88oFLoUlVZ +NxRt3Z+7w5HMIN2laKp+ItxloPWGBdcHU4o2ZnWgsVT8Y/a+RED75DDbAQ6lS3fV +sSlmQLExcf75qOPy34XNv3gWP4tbfIXXt8olflF8hwHggmKZzEImnzEozPabDsN7 +nkhHZEWhGcPRcuHbFOqcirV1sfsKK1gOsTbxS00iD3OivOFCQqujF196cal/utTd +WvyJvY2o35eE0WFcDdstU7UiP39usE+jk4jbQS5WbMYk9yyZCCbd74T7eYAfSEXg +GqrE1O6pjMmwbEjHwHDkbn/2WGvOSgWKHJVSh8V1K5ijlAd/9SCbsY0Yh5K3G16k +gnzHZ7OuQItfvMlPLQA7P2cPj/bGkO2ayyZU4+9rCsXlHw4Cee+u1APFSO2rj1TE +vX80grtqXNmj6nV21nIiXASvBKRO3kU4t8yV9i8EEREKYx/gLIl5i3PYGwARAQAB +tDdNb25nb0RCIDguMCBSZWxlYXNlIFNpZ25pbmcgS2V5IDxwYWNrYWdpbmdAbW9u +Z29kYi5jb20+iQJOBBMBCgA4FiEESwdSwbyiOMC07hTcQd4Fik59ygUFAmWgEhwC +GwMFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AACgkQQd4Fik59ygWy4w//e+IQ5eFT +rlowx196DaInUTiv+aMkkN5hAtJDMicV9+ZDChEfqqQH1WJuUUKfX00AeEDocQnI +LgESy0+rp2FoRPG5bXaJXTv6xQkqIMQQMNMkG4Nx3AxggRVkzd2arOr9FBwcnmf0 +7xu9EsMJndmzTsDO+ohWnNb0ILSdPVKDafpfg4ycBWDZT7ynD6TT0JpG8WWJi8F+ +9GR4k4CpBujk49POZbjeVDOuP/o/tosmEO9jo03C/u1qNuVVXy6vvTB6WjO79QTX +OlSTLHAiu9N/VknG1B7lW15X1yl3jl3vZ33N68ncXUW2gAJi7Nh6H6RSm288IC4i +hSmSBFabffQtwOTVE0CaKge2nU4Oc3Tp2h8moEgi81vYT/CioMt6wmHTzY0grcfF +WLwtDMFJ0VQYRrUIOMmYBFyRp2jdRYYkA+vlL+6DNAAjCeuvwCs3PqUhgFvHNxVv +bumKiRMIOoNUwpLEKsEq8jBs+U+gUfa+CmBn67G9mjDRu4cXXrtItooxnbfM/m0i +hVnssTC1arrx273zFepLosPvgrT0TS7tnyXbzuq5mo0zD1fSj4kuSS9V/SSy9fWF +LAtHiNQJkjzGFxu0/9dyQyX6C523uvfdcOzpObTyjBeGKqmEEf0lF5OYLDlkk2Sm +iGa6i2oLaGzGaQZDpdqyQZiYpQEYw9xN+8g= +=J31U +-----END PGP PUBLIC KEY BLOCK----- diff --git a/data/keys/mongodb-9.asc b/data/keys/mongodb-9.asc new file mode 100644 index 0000000..5efc27c --- /dev/null +++ b/data/keys/mongodb-9.asc @@ -0,0 +1,55 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +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 +fqdDE+7nkBIPEZgUfrrPRs0= +=uvKw +-----END PGP PUBLIC KEY BLOCK----- \ No newline at end of file diff --git a/data/repos/mongodb-8.0-ubuntu.conf b/data/repos/mongodb-8.0-ubuntu.conf new file mode 100644 index 0000000..ac40f78 --- /dev/null +++ b/data/repos/mongodb-8.0-ubuntu.conf @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# MongoDB Community 8.0 from the Ubuntu suites of https://repo.mongodb.org. Key fetched from +# https://pgp.mongodb.com/server-8.0.asc on 2026-10-13 (see mongodb-8.0.conf). Debian on arm64 uses these packages +# too: the Debian suites carry amd64 builds only. The jammy build needs glibc 2.35 and libssl3 (bookworm has 2.36 and +# libssl3), the noble build glibc 2.39, libssl3t64 and libcurl4t64 (trixie has 2.41 and both). +KEY=mongodb-8.0.asc +FINGERPRINTS=4B0752C1BCA238C0B4EE14DC41DE058A4E7DCA05 +DEB_URL=https://repo.mongodb.org/apt/ubuntu +DEB_SUITE={{codename}}/mongodb-org/8.0 +DEB_SUITE_bookworm=jammy/mongodb-org/8.0 +DEB_SUITE_trixie=noble/mongodb-org/8.0 +# The resolute suite of 8.0 holds no server packages; the noble build runs on Ubuntu 26.04. +DEB_SUITE_resolute=noble/mongodb-org/8.0 +DEB_COMPONENTS=multiverse diff --git a/data/repos/mongodb-8.0.conf b/data/repos/mongodb-8.0.conf new file mode 100644 index 0000000..77bfab0 --- /dev/null +++ b/data/repos/mongodb-8.0.conf @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# MongoDB Community 8.0 from https://repo.mongodb.org (Debian and the RHEL family; Ubuntu and Debian on arm64 use +# mongodb-8.0-ubuntu). Key fetched from https://pgp.mongodb.com/server-8.0.asc on 2026-10-13. MongoDB publishes no +# fingerprint next to the key; the one pinned here is the key that signs the Release files and repomd.xml of this +# repository. +KEY=mongodb-8.0.asc +FINGERPRINTS=4B0752C1BCA238C0B4EE14DC41DE058A4E7DCA05 +DEB_URL=https://repo.mongodb.org/apt/debian +DEB_SUITE={{codename}}/mongodb-org/8.0 +# The trixie suite of 8.0 holds no server packages; the module takes the noble build from mongodb-8.0-ubuntu there. +DEB_COMPONENTS=main +RPM_BASEURL=https://repo.mongodb.org/yum/redhat/{{major}}/mongodb-org/8.0/$basearch +RPM_GPGCHECK=1 +RPM_REPO_GPGCHECK=1 diff --git a/data/repos/mongodb-9.0-ubuntu.conf b/data/repos/mongodb-9.0-ubuntu.conf new file mode 100644 index 0000000..62c7364 --- /dev/null +++ b/data/repos/mongodb-9.0-ubuntu.conf @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# MongoDB Community 9.0 from the Ubuntu suites of https://repo.mongodb.org. Key fetched from +# https://pgp.mongodb.com/server-9.asc on 2026-10-13 (see mongodb-9.0.conf). Debian on arm64 uses these packages too: +# the Debian suites carry amd64 builds only. The jammy build needs glibc 2.35 and libssl3 (bookworm has 2.36 and +# libssl3), the noble build glibc 2.39, libssl3t64 and libcurl4t64 (trixie has 2.41 and both). +KEY=mongodb-9.asc +FINGERPRINTS=B3B42B6C39E5CDDEC0A27E3CF366D55B602E502D +DEB_URL=https://repo.mongodb.org/apt/ubuntu +DEB_SUITE={{codename}}/mongodb-org/9.0 +DEB_SUITE_bookworm=jammy/mongodb-org/9.0 +DEB_SUITE_trixie=noble/mongodb-org/9.0 +DEB_COMPONENTS=multiverse diff --git a/data/repos/mongodb-9.0.conf b/data/repos/mongodb-9.0.conf new file mode 100644 index 0000000..19e0325 --- /dev/null +++ b/data/repos/mongodb-9.0.conf @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# MongoDB Community 9.0 from https://repo.mongodb.org (Debian and the RHEL family; Ubuntu and Debian on arm64 use +# mongodb-9.0-ubuntu). Key fetched from https://pgp.mongodb.com/server-9.asc on 2026-10-13. MongoDB publishes no +# fingerprint next to the key; the one pinned here is the key that signs the Release files and repomd.xml of this +# repository. +KEY=mongodb-9.asc +FINGERPRINTS=B3B42B6C39E5CDDEC0A27E3CF366D55B602E502D +DEB_URL=https://repo.mongodb.org/apt/debian +DEB_SUITE={{codename}}/mongodb-org/9.0 +DEB_COMPONENTS=main +RPM_BASEURL=https://repo.mongodb.org/yum/redhat/{{major}}/mongodb-org/9.0/$basearch +RPM_GPGCHECK=1 +RPM_REPO_GPGCHECK=1 diff --git a/modules/database/mongodb.sh b/modules/database/mongodb.sh new file mode 100644 index 0000000..35da699 --- /dev/null +++ b/modules/database/mongodb.sh @@ -0,0 +1,385 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +MOD_NAME="mongodb" +MOD_GROUP="database" +MOD_DESCRIPTION="MongoDB Community on 127.0.0.1 with authorization on, an administrator and optional application users" +MOD_REQUIRES="system" +MOD_CONFLICTS="" +MOD_OPTIONS="version|9.0|MongoDB release series: 9.0 or 8.0 (changing it later needs a manual upgrade) +bind|127.0.0.1|Addresses mongod listens on, separated by spaces; 127.0.0.1 must stay in the list +port|27017|TCP port +access|local|Firewall: local (no rule), vpn (WireGuard network only) or public +javascript|false|Allow server-side JavaScript (\$where, \$function, mapReduce); off closes a whole class of injections +users||Application users as user:database, separated by spaces; each gets readWrite on its database and a generated password (lss secrets show mongodb-user-USER)" +MOD_PORTS="27017/tcp on localhost" +MOD_SECRETS="mongodb-admin mongodb-user-" + +# Source: MongoDB's own repository (repo.mongodb.org, keys pinned in data/repos/mongodb-*.conf). Its Debian suites +# carry amd64 builds only and the 8.0 series has no trixie build, so Ubuntu, Debian on arm64 and 8.0 on Debian 13 use +# the Ubuntu suites (jammy build on Debian 12, noble build on Debian 13). +# lss's configuration lives in root-owned /etc/lss, the unit reads it through a drop-in; /etc/mongod.conf of the +# package stays untouched and unused. +MONGO_CONF=/etc/lss/mongod.conf +MONGO_UNIT=mongod +MONGO_ADMIN=lss-admin +MONGO_ROOTRC=/root/.mongoshrc.js +MONGO_SHELL_CONF=/etc/mongosh.conf +MONGO_REPOS="mongodb-8.0 mongodb-8.0-ubuntu mongodb-9.0 mongodb-9.0-ubuntu" + +_mongo_name_ok() { + local name=$1 + [[ ${name} =~ ^[a-z][a-z0-9_]{0,31}$ ]] + return +} + +_mongo_data() { + if os_is_debian; then printf '/var/lib/mongodb'; else printf '/var/lib/mongo'; fi + return +} + +_mongo_user() { + if os_is_debian; then printf 'mongodb'; else printf 'mongod'; fi + return +} + +_mongo_repo() { + local version + version=$(opt version) + if os_is_rhel; then + printf 'mongodb-%s' "${version}" + elif [[ ${OS_ID} == ubuntu || ${OS_ARCH} == aarch64 || (${version} == 8.0 && ${OS_CODENAME} == trixie) ]]; then + printf 'mongodb-%s-ubuntu' "${version}" + else + printf 'mongodb-%s' "${version}" + fi + return +} + +# The installed release series (9.0, 8.0), empty without mongod. +_mongo_installed_series() { + local output + command -v mongod >/dev/null 2>&1 || return 0 + output=$(mongod --version 2>/dev/null || true) + [[ ${output} =~ db\ version\ v([0-9]+\.[0-9]+) ]] || return 0 + printf '%s' "${BASH_REMATCH[1]}" + return +} + +mod_check() { + local version address port access entry user database installed flags + version=$(opt version) + port=$(opt_int port) + access=$(opt access) + [[ ${version} =~ ^(9\.0|8\.0)$ ]] || die "LSS_MONGODB_VERSION must be 9.0 or 8.0." + [[ ${port} -ge 1 && ${port} -le 65535 ]] || die "LSS_MONGODB_PORT must be between 1 and 65535." + [[ ${access} =~ ^(local|vpn|public)$ ]] || die "LSS_MONGODB_ACCESS must be local, vpn or public." + # Checks the value; an invalid one stops here. + opt_bool javascript || true + for address in $(opt bind); do + [[ ${address} =~ ^[0-9A-Fa-f:.]+$ ]] || die "LSS_MONGODB_BIND: '${address}' is not an IP address." + done + # lss creates the administrator through MongoDB's localhost exception and checks the server over 127.0.0.1. + [[ " $(opt bind) " == *" 127.0.0.1 "* || " $(opt bind) " == *" 0.0.0.0 "* ]] || + die "LSS_MONGODB_BIND must contain 127.0.0.1." + if [[ ${access} != local && " $(opt bind) " != *" 0.0.0.0 "* && $(wc -w <<<"$(opt bind)") -lt 2 ]]; then + die "LSS_MONGODB_ACCESS=${access} needs an address in LSS_MONGODB_BIND that other hosts can reach." + fi + for entry in $(opt users); do + [[ ${entry} == *:* ]] || die "LSS_MONGODB_USERS: '${entry}' must look like user:database." + user=${entry%%:*} + database=${entry#*:} + _mongo_name_ok "${user}" || die "LSS_MONGODB_USERS: '${user}' is not a valid user name (a-z, 0-9, _)." + _mongo_name_ok "${database}" || die "LSS_MONGODB_USERS: '${database}' is not a valid database name." + [[ ! ${database} =~ ^(admin|local|config)$ ]] || die "LSS_MONGODB_USERS: '${database}' is a reserved database." + done + # MongoDB 5.0 and later need AVX on x86_64; the arm64 builds need ARMv8.2-A, whose large-system extensions + # show up as the atomics flag. Older CPUs stop mongod with an illegal instruction. + flags=$(grep -m 1 -E '^(flags|Features)[[:space:]]*:' /proc/cpuinfo 2>/dev/null || true) + case "${OS_ARCH}" in + x86_64) [[ " ${flags} " == *" avx "* ]] || die "mongodb: this CPU lacks AVX, which MongoDB needs on x86_64." ;; + aarch64) [[ " ${flags} " == *" atomics "* ]] || die "mongodb: this CPU predates ARMv8.2-A, which MongoDB needs on arm64." ;; + *) die "mongodb: MongoDB has no build for ${OS_ARCH}." ;; + esac + # A downgrade or an upgrade across series needs setFeatureCompatibilityVersion steps lss does not take. + installed=$(_mongo_installed_series) + if [[ -n ${installed} && ${installed} != "${version}" ]]; then + die "mongodb: MongoDB ${installed} is installed; moving to ${version} needs a manual upgrade (see MongoDB's upgrade guide)." + fi + return +} + +mod_install() { + local repo name + repo=$(_mongo_repo) + for name in ${MONGO_REPOS}; do + [[ ${name} == "${repo}" ]] || repo_remove "${name}" + done + repo_add "${repo}" + pkg_install mongodb-org-server mongodb-mongosh + return +} + +_mongo_selinux_port() { + local port=$1 custom + [[ ${port} != 27017 ]] || return 0 + os_is_rhel || return 0 + if ! command -v selinuxenabled >/dev/null 2>&1 || ! selinuxenabled; then + return 0 + fi + pkg_install policycoreutils-python-utils + custom=$(semanage port -l -C 2>/dev/null | awk '$1 == "mongod_port_t" && $2 == "tcp" { print $3 }') + grep -qxF "${port}" <<<"${custom}" && return 0 + semanage port -a -t mongod_port_t -p tcp "${port}" 2>/dev/null || + semanage port -m -t mongod_port_t -p tcp "${port}" + files_note_change "SELinux port ${port}/tcp for MongoDB" + return +} + +_mongo_config() { + local address ipv6=false javascript=false bind_list + local -a list=() + for address in $(opt bind); do + list+=("${address}") + [[ ${address} != *:* ]] || ipv6=true + done + if opt_bool javascript; then + javascript=true + fi + bind_list=$( + IFS=, + printf '%s' "${list[*]}" + ) + cat <"${dir}/script.js" + ) + output=$(mongosh --quiet --norc "$(_mongo_uri)" "${dir}/script.js" 2>&1) || status=$? + rm -rf -- "${dir}" + printf '%s' "${output}" + return "${status}" +} + +_mongo_wait() { + local i output + for ((i = 0; i < 60; i++)); do + output=$(mongosh --quiet --norc "$(_mongo_uri)" --eval 'db.runCommand({ ping: 1 }).ok' 2>/dev/null || true) + [[ ${output} != 1 ]] || return 0 + sleep 1 + done + die "mongodb: mongod does not answer on 127.0.0.1:$(opt_int port)." +} + +# The script prefix that logs in as the lss administrator. +_mongo_login() { + local password=$1 + printf "db.getSiblingDB('admin').auth('%s', %s);\n" "${MONGO_ADMIN}" "$(_mongo_quote "${password}")" + return +} + +# The administrator is created once, through the localhost exception that MongoDB grants while no user exists. +_mongo_admin() { + local password=$1 output + output=$(_mongo_js <&1 || true) + [[ ${output} == *requires\ authentication* || ${output} == *nauthorized* ]] || + die "mongodb: listDatabases without a login was accepted ('${output}')." + _mongo_check_listen "$(opt_int port)" + return +} + +# Deletes the data directory of the module; anything else, or a symbolic link in its place, stops the removal. +_mongo_delete_data() { + local path + path=$(_mongo_data) + case "${path}" in + /var/lib/mongodb | /var/lib/mongo) ;; + *) die "mongodb: refusing to delete ${path}: it is not a directory of the module." ;; + esac + [[ ! -L ${path} ]] || die "mongodb: refusing to delete ${path}: it is a symbolic link." + [[ -d ${path} ]] || return 0 + rm -rf -- "${path}" + files_note_change "deleted ${path}" + return +} + +mod_remove() { + local entry name + svc_disable "${MONGO_UNIT}" + svc_dropin_remove "${MONGO_UNIT}.service" lss-sandbox + svc_dropin_remove "${MONGO_UNIT}.service" lss-config + file_remove "${MONGO_CONF}" + file_remove "${MONGO_SHELL_CONF}" + [[ ${LSS_PURGE:-0} == 1 ]] || return 0 + pkg_remove mongodb-org-server mongodb-mongosh + for name in ${MONGO_REPOS}; do + repo_remove "${name}" + done + _mongo_delete_data + file_remove "${MONGO_ROOTRC}" + secret_remove mongodb-admin + for entry in $(opt users); do + secret_remove "mongodb-user-${entry%%:*}" + done + return +} From d6c851b03ca0e594583874dc9d8d8b67261a309a Mon Sep 17 00:00:00 2001 From: VectoDE Date: Tue, 13 Oct 2026 22:03:24 +0200 Subject: [PATCH 30/92] redis, valkey: label a non-default port for SELinux The RHEL policy confines redis-server and valkey-server as redis_t, which may bind redis_port_t only. A port other than 6379 is added to that type with semanage, as the database modules do for their own ports. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- modules/database/redis.sh | 18 ++++++++++++++++++ modules/database/valkey.sh | 18 ++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/modules/database/redis.sh b/modules/database/redis.sh index f2fbf59..61d9e00 100644 --- a/modules/database/redis.sh +++ b/modules/database/redis.sh @@ -139,10 +139,28 @@ EOF return } +# A non-default port needs the redis_port_t label under SELinux (the RHEL policy confines Redis as redis_t). +_redis_selinux_port() { + local port=$1 custom + [[ ${port} != 6379 ]] || return 0 + os_is_rhel || return 0 + if ! command -v selinuxenabled >/dev/null 2>&1 || ! selinuxenabled; then + return 0 + fi + pkg_install policycoreutils-python-utils + custom=$(semanage port -l -C 2>/dev/null | awk '$1 == "redis_port_t" && $2 == "tcp" { print $3 }') + grep -qxF "${port}" <<<"${custom}" && return 0 + semanage port -a -t redis_port_t -p tcp "${port}" 2>/dev/null || + semanage port -m -t redis_port_t -p tcp "${port}" + files_note_change "SELinux port ${port}/tcp for Redis" + return +} + mod_configure() { local before changed=0 password unit config unit=$(_redis_unit) before=$(files_changes_of redis) + _redis_selinux_port "$(opt_int port)" secret_load redis-password password if ! getent group "${REDIS_GROUP}" >/dev/null; then groupadd --system "${REDIS_GROUP}" diff --git a/modules/database/valkey.sh b/modules/database/valkey.sh index 4a1ae69..9d8d931 100644 --- a/modules/database/valkey.sh +++ b/modules/database/valkey.sh @@ -250,10 +250,28 @@ EOF return } +# A non-default port needs the redis_port_t label under SELinux (the RHEL policy confines Valkey as redis_t). +_valkey_selinux_port() { + local port=$1 custom + [[ ${port} != 6379 ]] || return 0 + os_is_rhel || return 0 + if ! command -v selinuxenabled >/dev/null 2>&1 || ! selinuxenabled; then + return 0 + fi + pkg_install policycoreutils-python-utils + custom=$(semanage port -l -C 2>/dev/null | awk '$1 == "redis_port_t" && $2 == "tcp" { print $3 }') + grep -qxF "${port}" <<<"${custom}" && return 0 + semanage port -a -t redis_port_t -p tcp "${port}" 2>/dev/null || + semanage port -m -t redis_port_t -p tcp "${port}" + files_note_change "SELinux port ${port}/tcp for Valkey" + return +} + mod_configure() { local before changed=0 password unit config unit=$(_valkey_unit) before=$(files_changes_of valkey) + _valkey_selinux_port "$(opt_int port)" secret_load valkey-password password if ! getent group "${VALKEY_GROUP}" >/dev/null; then groupadd --system "${VALKEY_GROUP}" From 03047b2acb5adf42ea8f619b4f79afdc9a77218f Mon Sep 17 00:00:00 2001 From: VectoDE Date: Tue, 13 Oct 2026 22:03:53 +0200 Subject: [PATCH 31/92] Integration settings for the database modules Each database module creates one application user with its database in the integration tests, so verify exercises users and grants; redis and valkey get a small memory limit. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- tests/integration/settings.conf | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/integration/settings.conf b/tests/integration/settings.conf index f73307f..f967c7e 100644 --- a/tests/integration/settings.conf +++ b/tests/integration/settings.conf @@ -16,3 +16,14 @@ LSS_TLS_CA_BUNDLE=/etc/lss-pebble/pebble.minica.pem LSS_TLS_DOMAINS=lss-test.example.com LSS_VHOST_SITES=demo:static:demo.example.com,www.demo.example.com app:proxy:app.example.com:http://127.0.0.1:9 LSS_HAPROXY_SERVICES=echo:9100/local:127.0.0.1:9,127.0.0.1:10 + +# Database modules: one application user each, so verify also covers creating users, databases and their grants. +# A small memory limit keeps redis and valkey from growing on the shared runner. +LSS_MARIADB_DATABASES=lss_test +LSS_MARIADB_USERS=lss_app:lss_app +LSS_MYSQL_DATABASES=lss_test +LSS_MYSQL_USERS=lss_app:lss_app +LSS_POSTGRESQL_USERS=lss_app:lss_app +LSS_REDIS_MAXMEMORY=64mb +LSS_VALKEY_MAXMEMORY=64mb +LSS_MONGODB_USERS=lss_app:lss_app From 11e07fe6150080cf80673b117e5b91748422a58e Mon Sep 17 00:00:00 2001 From: VectoDE Date: Tue, 13 Oct 2026 22:06:15 +0200 Subject: [PATCH 32/92] mongodb: authorization on from the first start The lss configuration (authorization enabled, bindIp from the options, Unix socket off) and the unit drop-in that points mongod at it are now written before the package is installed, so no start of mongod, by a package script, a reboot or after a failed step, ever uses the package's default configuration without authorization. An already running mongod is restarted at once when that configuration changed. The configuration is refused if authorization or bindIp is missing. The first administrator comes from the localhost exception with authorization already on; passwords reach mongosh only in script files created with umask 077 in a fresh root-only directory under /run, never on a command line, and root writes nothing into mongod's directories. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- modules/database/mongodb.sh | 40 +++++++++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/modules/database/mongodb.sh b/modules/database/mongodb.sh index 35da699..96bb1fb 100644 --- a/modules/database/mongodb.sh +++ b/modules/database/mongodb.sh @@ -111,16 +111,42 @@ mod_check() { } mod_install() { - local repo name + local repo name before repo=$(_mongo_repo) for name in ${MONGO_REPOS}; do [[ ${name} == "${repo}" ]] || repo_remove "${name}" done repo_add "${repo}" + # The configuration and the unit drop-in are in place before the package arrives, so whatever starts mongod first + # (a package script, a reboot, a failed later step) starts it with authorization on and bound to localhost; it + # never runs once with the package's open default configuration. + before=$(files_changes_of mongodb) + _mongo_write_config + if [[ $(files_changes_of mongodb) != "${before}" ]] && svc_is_active "${MONGO_UNIT}"; then + svc_restart "${MONGO_UNIT}" + fi pkg_install mongodb-org-server mongodb-mongosh return } +# Writes lss's configuration and points the unit at it. Authorization and the bind list are fixed parts of the file, +# never left to a default, and nothing is written if they are missing. +_mongo_write_config() { + local config + config=$(_mongo_config) || die "mongodb: cannot build the configuration." + [[ ${config} == *$'\n authorization: enabled\n'* && ${config} == *$'\n bindIp: '[0-9A-Fa-f:.]* ]] || + die "mongodb: the configuration lacks authorization or bindIp; refusing to write it." + file_write 0644 root:root "${MONGO_CONF}" <<<"${config}" + svc_dropin "${MONGO_UNIT}.service" lss-config < Date: Tue, 13 Oct 2026 22:09:42 +0200 Subject: [PATCH 33/92] Run the last command of a pipeline in the current shell Modules write generated files with "generator | file_write ..." and then read LSS_FILE_CHANGED to decide on a restart; svc_sandbox does the same internally. Without lastpipe, file_write ran in a subshell and the flag never reached the module, so a changed configuration could go without a restart. Found by the modsecurity review. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- lib/step.sh | 4 +++- lss | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/step.sh b/lib/step.sh index 1a245c5..4980f72 100755 --- a/lib/step.sh +++ b/lib/step.sh @@ -11,7 +11,9 @@ # settings of the calling lss process, written by module_run). set -Eeuo pipefail -shopt -s inherit_errexit +# lastpipe: in "generator | file_write ..." the last command runs in this shell, so LSS_FILE_CHANGED and the +# change notes it sets reach the caller. +shopt -s inherit_errexit lastpipe for library in log os config files template secrets state pkg repo fetch service firewall web module; do # shellcheck source=/dev/null diff --git a/lss b/lss index e6957b4..bacc59b 100755 --- a/lss +++ b/lss @@ -6,7 +6,9 @@ # lss: modular, hardened Linux server setup. See README.md and docs/. set -Eeuo pipefail -shopt -s inherit_errexit +# lastpipe: in "generator | file_write ..." the last command runs in this shell, so LSS_FILE_CHANGED and the +# change notes it sets reach the caller. +shopt -s inherit_errexit lastpipe LSS_ROOT=$(cd -- "$(dirname -- "$(readlink -f -- "${BASH_SOURCE[0]}")")" && pwd) LSS_VERSION=$(<"${LSS_ROOT}/VERSION") From 8f52b2de4139241070dd0afe2e1fc21b49615831 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Tue, 13 Oct 2026 22:07:58 +0200 Subject: [PATCH 34/92] Add the modsecurity module: a web application firewall for nginx and Apache ModSecurity with the OWASP Core Rule Set for the installed web server (Caddy has no maintained connector). Apache uses the distribution's mod_security2 (EPEL on RHEL 10); nginx uses the packaged connector where one exists and otherwise a dynamic module built with --with-compat for the installed nginx: on Ubuntu 22.04 from the verified nginx.org sources, on RHEL 10 from nginx-mod-devel together with a self-built libmodsecurity. Builds run again only when the nginx version changes, and their build packages are removed afterwards. The rules come pinned and verified from the CRS releases: the 4.25 long-term-support line everywhere, CRS 3.3 on Ubuntu 22.04, whose engines are too old for CRS 4. Options cover the engine mode, paranoia level, inbound anomaly threshold, allowed methods, application exclusions (CRS 4 plugins, pinned by commit) and the body limit, which follows max_body of the web server. The audit log is JSON where the engine has YAJL, sits below the web server's log directory (SELinux) and is rotated with copytruncate. verify sends a harmless and an attacking request to the ACME probe path and expects 200 and 403 plus an audit log entry. nginx.conf now includes /etc/nginx/lss/modules/*.conf at the top and /etc/nginx/lss/http/*.conf inside http{} for settings of other modules. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- data/checksums.txt | 34 ++ modules/web/modsecurity.sh | 751 ++++++++++++++++++++++++++++++++ modules/web/nginx.sh | 3 + tests/integration/settings.conf | 3 + 4 files changed, 791 insertions(+) create mode 100644 modules/web/modsecurity.sh diff --git a/data/checksums.txt b/data/checksums.txt index 35273ce..aaaf4cc 100644 --- a/data/checksums.txt +++ b/data/checksums.txt @@ -36,3 +36,37 @@ valkey-jammy aarch64 44abd1a738fdda3831bb3d84f19e4bcd45bdb537d0cee24dace364f5111 # Release Engineering key BCA43417C3B485DD128EC6D4B7B3B788A8D3785C (data/keys/mysql.asc); the SHA-256 below was taken # from that verified file on 2026-10-13. mysql-generic aarch64 04b2f9791d314167a9eb83abcb476f45a7cd9e4aa88fa7a638cba40d1bc2a109 https://cdn.mysql.com/Downloads/MySQL-8.4/mysql-8.4.11-linux-glibc2.28-aarch64.tar.xz + +# modsecurity: OWASP Core Rule Set, digests of the GitHub release files (the releases are also PGP-signed by +# security@coreruleset.org, 3600 6F0E 0BA1 6783 2158 8211 38EE ACA1 AB8A 6E72). 4.25 is the long-term-support line; +# 3.3 serves the ModSecurity versions of Ubuntu 22.04. +coreruleset-4.25.2 any a2d4b2a5bb4f397f7e40d4e22d0346350706a5f17c1cfefc09e8b9740e00d87e https://github.com/coreruleset/coreruleset/releases/download/v4.25.2/coreruleset-4.25.2-minimal.tar.gz +coreruleset-3.3.10 any 0c35a9e14f8af3cf02fc29eaae807312c6555d0f9bfa306274268ab5913c1110 https://github.com/coreruleset/coreruleset/releases/download/v3.3.10/coreruleset-3.3.10-minimal.tar.gz +# modsecurity: libmodsecurity (RHEL 10) and the nginx connector (Ubuntu 22.04, RHEL 10), from the .sha256 files the +# OWASP ModSecurity project publishes with each release. +libmodsecurity-3.0.17 any f283b33d5c21130fd3a15c84a93a1abe12bd1949edaf44288b12af571b671a50 https://github.com/owasp-modsecurity/ModSecurity/releases/download/v3.0.17/modsecurity-v3.0.17.tar.gz +modsecurity-nginx-1.0.4 any 6bdc7570911be884c1e43aaf85046137f9fde0cfa0dd4a55b853c81c45a13313 https://github.com/owasp-modsecurity/ModSecurity-nginx/releases/download/v1.0.4/ModSecurity-nginx-v1.0.4.tar.gz +# modsecurity: nginx sources of the version Ubuntu 22.04 ships, for the connector build. nginx.org publishes PGP +# signatures only; the digest is of the file whose signature (Maxim Dounin, B0F4 2533 73F8 F6F5 10D4 2178 520A 9993 +# A1C0 52F8) verified. +nginx-1.18.0 any 4c373e7ab5bf91d34a4f11a0c9496561061ba5eee6020db272a17a7228d35f99 https://nginx.org/download/nginx-1.18.0.tar.gz +# modsecurity: CRS 4 application exclusion plugins. Their repositories publish no release files; the files are pinned +# by the commit of the release tag (cpanel, dokuwiki, drupal, phpbb, xenforo v1.0.0, nextcloud v1.7.1, phpmyadmin +# v1.1.0, wordpress v1.2.0). +crs-plugin-cpanel-config any f4c93c6b8c404237b4ac7a01332476827982216f3e39dbb1f9735045e0cc6ce4 https://raw.githubusercontent.com/coreruleset/cpanel-rule-exclusions-plugin/2fd1cab8706c760fe9cdccf55afe75b94a66f89f/plugins/cpanel-rule-exclusions-config.conf +crs-plugin-cpanel-before any 8433fce90b44fd6adfdda9b68f2cb9d1c8f81c4c26af586eeaf1d0648d510726 https://raw.githubusercontent.com/coreruleset/cpanel-rule-exclusions-plugin/2fd1cab8706c760fe9cdccf55afe75b94a66f89f/plugins/cpanel-rule-exclusions-before.conf +crs-plugin-dokuwiki-config any 921dc270f6709e8c27db23b2d845bb8702bf8862584497f4c6a6ee99dab5c762 https://raw.githubusercontent.com/coreruleset/dokuwiki-rule-exclusions-plugin/00996762a746ecfe85d6ccc022f2a3efb44377a7/plugins/dokuwiki-rule-exclusions-config.conf +crs-plugin-dokuwiki-before any ba93cc37f8c6a101b13fa8681eff740fd0c4235a9dcf6a0a1fb63be98dc5aa74 https://raw.githubusercontent.com/coreruleset/dokuwiki-rule-exclusions-plugin/00996762a746ecfe85d6ccc022f2a3efb44377a7/plugins/dokuwiki-rule-exclusions-before.conf +crs-plugin-drupal-config any 01ee7ff94b101c42dbb7117a73af6d3bc3327fc24a92cd774be3623f45d8df9f https://raw.githubusercontent.com/coreruleset/drupal-rule-exclusions-plugin/6771319cf184690d33d3251e3dac76e39c54c7f6/plugins/drupal-rule-exclusions-config.conf +crs-plugin-drupal-before any 1c529d96f4df82bf57edc64ce7541b25923b608d020ebf42fd635d0717d5dcac https://raw.githubusercontent.com/coreruleset/drupal-rule-exclusions-plugin/6771319cf184690d33d3251e3dac76e39c54c7f6/plugins/drupal-rule-exclusions-before.conf +crs-plugin-nextcloud-config any 17ed8c7826c3351d8f86683a9898b88aca89311fc140f8e319d10c797d5be11a https://raw.githubusercontent.com/coreruleset/nextcloud-rule-exclusions-plugin/d907a20c7ca9c27d5666185c8ffab58f859d4832/plugins/nextcloud-rule-exclusions-config.conf +crs-plugin-nextcloud-before any 5f398812bfec2c9d21dcad3b7898065cdfa4721cdf980bfe487e988cc4729aab https://raw.githubusercontent.com/coreruleset/nextcloud-rule-exclusions-plugin/d907a20c7ca9c27d5666185c8ffab58f859d4832/plugins/nextcloud-rule-exclusions-before.conf +crs-plugin-phpbb-config any 22923096d7705354d29619f62f7dbc93a8e7ea59d2fe98f1b986665fb588c3f4 https://raw.githubusercontent.com/coreruleset/phpbb-rule-exclusions-plugin/5f1e03417cd73f1a5f22040bf06680e86e49d71e/plugins/phpbb-rule-exclusions-config.conf +crs-plugin-phpbb-before any 60ab969d01444d1e3dd634057d8a823a34bf53ec46290d383d740784e34c887a https://raw.githubusercontent.com/coreruleset/phpbb-rule-exclusions-plugin/5f1e03417cd73f1a5f22040bf06680e86e49d71e/plugins/phpbb-rule-exclusions-before.conf +crs-plugin-phpmyadmin-config any 3e96eacb813a6fd9b70c8854c59c5a57b05044a8edbac172fb3fe0880c40c001 https://raw.githubusercontent.com/coreruleset/phpmyadmin-rule-exclusions-plugin/6629e888b9f9fee9ae8288a335233e2f37a5c0a5/plugins/phpmyadmin-rule-exclusions-config.conf +crs-plugin-phpmyadmin-before any 955b2804d353990b1a855c07335a14cc20cf837a5f1176c7196ee12dbad644e2 https://raw.githubusercontent.com/coreruleset/phpmyadmin-rule-exclusions-plugin/6629e888b9f9fee9ae8288a335233e2f37a5c0a5/plugins/phpmyadmin-rule-exclusions-before.conf +crs-plugin-phpmyadmin-after any 278bc3558d66289510aff703a7fc9d42e1c032baf09eebe767c89aeec373454c https://raw.githubusercontent.com/coreruleset/phpmyadmin-rule-exclusions-plugin/6629e888b9f9fee9ae8288a335233e2f37a5c0a5/plugins/phpmyadmin-rule-exclusions-after.conf +crs-plugin-wordpress-config any cdffad1a5410057a06cc2ce0489c470f77c69af876d79536f5f34e88c00bced2 https://raw.githubusercontent.com/coreruleset/wordpress-rule-exclusions-plugin/161bb90bb0d543f805fc6cd966a7c53a2e5b08de/plugins/wordpress-rule-exclusions-config.conf +crs-plugin-wordpress-before any d6bb0e6a2848e6d39e73818537b21058c61a2b80a7b9e0de79fe2b3238eb9076 https://raw.githubusercontent.com/coreruleset/wordpress-rule-exclusions-plugin/161bb90bb0d543f805fc6cd966a7c53a2e5b08de/plugins/wordpress-rule-exclusions-before.conf +crs-plugin-xenforo-config any dcd4091a4b611931715f9b6a4c10a02210d617a810f044ef8e7d16fc9a6b6253 https://raw.githubusercontent.com/coreruleset/xenforo-rule-exclusions-plugin/193288ac2faa8ccbc4d86cdf829047f477812f28/plugins/xenforo-rule-exclusions-config.conf +crs-plugin-xenforo-before any f65f5600d9cb2004291013461fd206dd7cf2743398e8e40e5285f0588c0d6df3 https://raw.githubusercontent.com/coreruleset/xenforo-rule-exclusions-plugin/193288ac2faa8ccbc4d86cdf829047f477812f28/plugins/xenforo-rule-exclusions-before.conf diff --git a/modules/web/modsecurity.sh b/modules/web/modsecurity.sh new file mode 100644 index 0000000..7bee628 --- /dev/null +++ b/modules/web/modsecurity.sh @@ -0,0 +1,751 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +MOD_NAME="modsecurity" +MOD_GROUP="web" +MOD_DESCRIPTION="ModSecurity web application firewall with the OWASP Core Rule Set for nginx or Apache (Caddy has no maintained ModSecurity connector)" +MOD_REQUIRES="apache|nginx" +MOD_OPTIONS="mode|On|On blocks attacks; DetectionOnly only logs them (to try the rules on a live site first) +paranoia|1|Paranoia level 1 to 4: higher levels catch more attacks and also block more legitimate requests +anomaly_inbound|5|Anomaly score from which a request is blocked (5: one critical rule matched; higher values block less) +exclusions||Rule exclusions for applications, separated by spaces: cpanel dokuwiki drupal nextcloud phpbb phpmyadmin wordpress xenforo (phpbb and phpmyadmin need CRS 4, which Ubuntu 22.04 does not get) +allowed_methods|GET HEAD POST OPTIONS PUT PATCH DELETE|HTTP methods the rules let through, separated by spaces +max_body||Largest request body ModSecurity accepts and inspects, for example 64m (at most 1g); empty takes max_body of the web server" + +# Where the engine and the rules come from: +# Apache libapache2-mod-security2 (Debian, Ubuntu), mod_security (RHEL 9 AppStream, RHEL 10 EPEL). +# nginx libnginx-mod-http-modsecurity (Debian 12/13, Ubuntu 24.04/26.04), nginx-mod-modsecurity with +# libmodsecurity (RHEL 9 EPEL). Ubuntu 22.04 packages libmodsecurity3 but no nginx connector, RHEL 10 +# packages neither: lss builds what is missing from checksum-verified sources as a dynamic module +# (--with-compat) for the installed nginx, and builds again only when the nginx version changes. +# Rules The OWASP Core Rule Set from its signed GitHub releases, pinned in data/checksums.txt, so every system +# runs the same rules (the distributions ship CRS 3.3 of differing ages, RHEL 10 none). CRS 4 needs +# ModSecurity 2.9.6 or 3.0.8; its long-term-support line 4.25 changes less between updates than the +# monthly releases. Ubuntu 22.04 has 2.9.5 and 3.0.6 and therefore gets CRS 3.3, which is still maintained. +MODSEC_CRS_VERSION=4.25.2 +MODSEC_CRS_LEGACY_VERSION=3.3.10 +MODSEC_LIB_VERSION=3.0.17 +MODSEC_CONNECTOR_VERSION=1.0.4 +# Below /usr/local/lib, so SELinux labels the self-built libraries as libraries. +MODSEC_PREFIX=/usr/local/lib/lss-modsecurity +MODSEC_CRS_ROOT=/usr/local/share/lss-modsecurity +MODSEC_PLUGINS="cpanel dokuwiki drupal nextcloud phpbb phpmyadmin wordpress xenforo" +MODSEC_LEGACY_EXCLUSIONS="cpanel dokuwiki drupal nextcloud wordpress xenforo" +# The web server modules serve this file on port 80 for every host name; verify sends its probes there. +MODSEC_PROBE=/.well-known/acme-challenge/lss-probe +MODSEC_BUILD_DEPS=() +MODSEC_WORK='' + +# The web server ModSecurity is added to. +_modsecurity_server() { + local server + server=$(web_server) + case ${server} in + nginx | apache) printf '%s' "${server}" ;; + *) die "modsecurity: needs nginx or Apache (Caddy has no maintained ModSecurity connector)." ;; + esac + return +} + +# True on Ubuntu 22.04, whose ModSecurity (2.9.5, 3.0.6) is too old for CRS 4. +_modsecurity_legacy() { + [[ ${OS_ID} == ubuntu && ${OS_MAJOR} == 22 ]] + return +} + +_modsecurity_crs_version() { + if _modsecurity_legacy; then printf '%s' "${MODSEC_CRS_LEGACY_VERSION}"; else printf '%s' "${MODSEC_CRS_VERSION}"; fi + return +} + +_modsecurity_crs_dir() { + printf '%s/coreruleset-%s' "${MODSEC_CRS_ROOT}" "$(_modsecurity_crs_version)" + return +} + +# _modsecurity_conf_dir SERVER: lss's ModSecurity settings, next to the web server's other lss files. +_modsecurity_conf_dir() { + local server=$1 + printf '%s/modsecurity' "$(web_conf_dir "${server}")" + return +} + +# _modsecurity_audit_log SERVER: in a directory of its own below the web server's logs, so SELinux allows the web +# server to write it and the distribution's logrotate rule for *.log (which only signals nginx) leaves it alone. +_modsecurity_audit_log() { + local server=$1 + printf '%s/modsecurity/audit.log' "$(web_log_dir "${server}")" + return +} + +# Persistent collections of ModSecurity 2 (the directory the packages create). +_modsecurity_data_dir() { + if os_is_debian; then printf '/var/cache/modsecurity'; else printf '/var/lib/mod_security'; fi + return +} + +_modsecurity_mode() { + local mode + mode=$(opt mode) + case ${mode,,} in + on) printf 'On' ;; + detectiononly) printf 'DetectionOnly' ;; + *) die "LSS_MODSECURITY_MODE must be On or DetectionOnly, got '${mode}'." ;; + esac + return +} + +_modsecurity_max_body() { + local value server + value=$(opt max_body) + if [[ -z ${value} ]]; then + server=$(web_server) + value=$(config_option "${server:-nginx}" max_body 64m) + fi + printf '%s' "${value}" + return +} + +# _modsecurity_bytes SIZE: 64m -> bytes, at most 1 GiB (the largest body ModSecurity 2 accepts). +_modsecurity_bytes() { + local size=$1 number unit bytes + number=${size%[kKmMgG]} + unit=${size#"${number}"} + case ${unit,,} in + k) bytes=$((10#${number} * 1024)) ;; + m) bytes=$((10#${number} * 1048576)) ;; + g) bytes=$((10#${number} * 1073741824)) ;; + *) bytes=$((10#${number})) ;; + esac + ((bytes <= 1073741824)) || bytes=1073741824 + printf '%s' "${bytes}" + return +} + +_modsecurity_wanted() { + local name=$1 + [[ " $(opt exclusions) " == *" ${name} "* ]] + return +} + +# True where lss builds the nginx connector itself (Ubuntu 22.04, RHEL 10). +_modsecurity_nginx_build() { + if os_is_debian; then + _modsecurity_legacy + else + [[ ${OS_MAJOR} -ge 10 ]] + fi + return +} + +_modsecurity_nginx_so() { + if _modsecurity_nginx_build; then + printf '%s/nginx/ngx_http_modsecurity_module.so' "${MODSEC_PREFIX}" + elif os_is_debian; then + printf '/usr/lib/nginx/modules/ngx_http_modsecurity_module.so' + else + printf '/usr/lib64/nginx/modules/ngx_http_modsecurity_module.so' + fi + return +} + +_modsecurity_nginx_version() { + nginx -v 2>&1 | sed -nE 's|.*nginx/([0-9]+\.[0-9]+\.[0-9]+).*|\1|p' + return +} + +# What the self-built nginx connector was built for; a different value means building it again. +_modsecurity_connector_id() { + local library=distribution + os_is_debian || library=${MODSEC_LIB_VERSION} + printf 'nginx %s, connector %s, libmodsecurity %s' "$(_modsecurity_nginx_version)" "${MODSEC_CONNECTOR_VERSION}" "${library}" + return +} + +# _modsecurity_json SERVER: true when the engine was built with YAJL, which parses JSON request bodies and writes the +# JSON audit log. RHEL 10 has no YAJL; ModSecurity there cannot split JSON bodies into arguments and logs natively. +_modsecurity_json() { + local server=$1 engine + if [[ ${server} == nginx ]]; then + engine=$(_modsecurity_nginx_so) + elif os_is_debian; then + engine=/usr/lib/apache2/modules/mod_security2.so + else + engine=/usr/lib64/httpd/modules/mod_security2.so + fi + ldd "${engine}" 2>/dev/null | grep -q 'libyajl' + return +} + +_modsecurity_restorecon() { + local path=$1 + if command -v restorecon >/dev/null 2>&1; then + restorecon -RF "${path}" 2>/dev/null || true + fi + return +} + +# A private build directory below /var/tmp (sources and objects need more room than a tmpfs /tmp may have), deleted +# when the step ends. +_modsecurity_workdir() { + [[ -z ${MODSEC_WORK} ]] || return 0 + MODSEC_WORK=$(mktemp -d /var/tmp/lss-modsecurity.XXXXXX) + # shellcheck disable=SC2064 # the path is fixed now + trap "rm -rf -- '${MODSEC_WORK}'" EXIT + return +} + +# CodeReady Builder holds nginx-mod-devel (the nginx sources for modules). +_modsecurity_crb_repo() { + if [[ ${OS_ID} == rhel ]]; then + printf 'codeready-builder-for-rhel-%s-%s-rpms' "${OS_MAJOR}" "$(uname -m)" + else + printf 'crb' + fi + return +} + +# _modsecurity_build_deps PACKAGE...: installs build packages and remembers the ones that were missing, so +# _modsecurity_build_cleanup removes exactly those again. On the RHEL family CodeReady Builder is switched on for +# this transaction only. +_modsecurity_build_deps() { + local -a missing=() + mapfile -t missing < <(pkg_missing "$@") + [[ ${#missing[@]} -gt 0 ]] || return 0 + MODSEC_BUILD_DEPS+=("${missing[@]}") + if os_is_debian; then + pkg_install "${missing[@]}" + else + log_info "Installing ${missing[*]}" + log_command dnf -y -q --enablerepo="$(_modsecurity_crb_repo)" install "${missing[@]}" || + die "modsecurity: cannot install the build packages ${missing[*]}." + files_note_change "installed ${missing[*]}" + fi + return +} + +_modsecurity_build_cleanup() { + [[ ${#MODSEC_BUILD_DEPS[@]} -eq 0 ]] || pkg_remove "${MODSEC_BUILD_DEPS[@]}" + MODSEC_BUILD_DEPS=() + return +} + +# Parallel compiler runs, limited by memory: a single libmodsecurity file needs about 1.5 GiB. +_modsecurity_jobs() { + local cpus memory jobs + cpus=$(nproc) + memory=$(awk '/^MemAvailable:/ { print int($2 / 1572864) }' /proc/meminfo) + jobs=$((memory < cpus ? memory : cpus)) + ((jobs >= 1)) || jobs=1 + printf '%s' "${jobs}" + return +} + +# libmodsecurity for RHEL 10, which packages none. Without curl, GeoIP, LMDB, Lua and ssdeep: the Core Rule Set needs +# none of them. Built again only for a new version. +_modsecurity_build_library() { + local stamp="${MODSEC_PREFIX}/.libmodsecurity" src + if [[ -f ${MODSEC_PREFIX}/lib/libmodsecurity.so.3 && -f ${stamp} && $(<"${stamp}") == "${MODSEC_LIB_VERSION}" ]]; then + return 0 + fi + # The runtime libraries first, so removing the build packages afterwards keeps them. + pkg_install pcre2 libxml2 + _modsecurity_build_deps gcc-c++ make pkgconf-pkg-config pcre2-devel libxml2-devel + _modsecurity_workdir + fetch_verified "libmodsecurity-${MODSEC_LIB_VERSION}" "${MODSEC_WORK}/libmodsecurity.tar.gz" + tar -xzf "${MODSEC_WORK}/libmodsecurity.tar.gz" -C "${MODSEC_WORK}" --no-same-owner + src="${MODSEC_WORK}/modsecurity-v${MODSEC_LIB_VERSION}" + log_info "Building libmodsecurity ${MODSEC_LIB_VERSION}; this takes a few minutes" + (cd "${src}" && log_command ./configure --prefix="${MODSEC_PREFIX}" --disable-static --disable-examples \ + --disable-doxygen-doc --with-pcre2 --without-curl --without-geoip --without-maxmind --without-lmdb \ + --without-lua --without-ssdeep --without-yajl) || die "modsecurity: libmodsecurity does not configure; see the log." + log_command make -C "${src}" -j "$(_modsecurity_jobs)" || die "modsecurity: libmodsecurity does not build; see the log." + log_command make -C "${src}" install-strip || die "modsecurity: libmodsecurity does not install; see the log." + printf '%s\n' "${MODSEC_LIB_VERSION}" >"${stamp}" + _modsecurity_restorecon "${MODSEC_PREFIX}" + files_note_change "built libmodsecurity ${MODSEC_LIB_VERSION}" + return +} + +# The nginx connector as a dynamic module for the installed nginx: from the nginx.org sources of that version on +# Ubuntu 22.04 (verified by data/checksums.txt), from nginx-mod-devel on RHEL 10. --with-compat makes it load into the +# distribution's nginx, which is built with the same switch. +_modsecurity_build_connector() { + local so="${MODSEC_PREFIX}/nginx/ngx_http_modsecurity_module.so" stamp="${MODSEC_PREFIX}/nginx/.build" + local version src tree + local -a env=() + if [[ -f ${so} && -f ${stamp} && $(<"${stamp}") == "$(_modsecurity_connector_id)" ]]; then + return 0 + fi + if os_is_debian; then + _modsecurity_build_deps gcc make libc6-dev libpcre3-dev zlib1g-dev libmodsecurity-dev + else + # nginx-mod-devel requires the nginx of its own release and may update nginx; the version is read afterwards. + _modsecurity_build_deps gcc make pcre2-devel zlib-devel nginx-mod-devel + env=("MODSECURITY_INC=${MODSEC_PREFIX}/include" "MODSECURITY_LIB=${MODSEC_PREFIX}/lib") + fi + version=$(_modsecurity_nginx_version) + [[ -n ${version} ]] || die "modsecurity: cannot read the version of nginx." + _modsecurity_workdir + fetch_verified "modsecurity-nginx-${MODSEC_CONNECTOR_VERSION}" "${MODSEC_WORK}/connector.tar.gz" + tar -xzf "${MODSEC_WORK}/connector.tar.gz" -C "${MODSEC_WORK}" --no-same-owner + if os_is_debian; then + fetch_verified "nginx-${version}" "${MODSEC_WORK}/nginx.tar.gz" + tar -xzf "${MODSEC_WORK}/nginx.tar.gz" -C "${MODSEC_WORK}" --no-same-owner + src="${MODSEC_WORK}/nginx-${version}" + else + tree=$(rpm -E '%{_nginx_srcdir}') + [[ -f ${tree}/configure ]] || die "modsecurity: nginx-mod-devel has no nginx sources in ${tree}." + [[ $(rpm -E '%{_nginx_abiversion}') == "${version}" ]] || + die "modsecurity: nginx-mod-devel is for nginx $(rpm -E '%{_nginx_abiversion}'), but nginx ${version} is installed." + src="${MODSEC_WORK}/nginx-src" + cp -a "${tree}" "${src}" + fi + log_info "Building the ModSecurity module for nginx ${version}" + (cd "${src}" && log_command env "${env[@]}" sh ./configure --with-compat \ + --add-dynamic-module="${MODSEC_WORK}/ModSecurity-nginx-v${MODSEC_CONNECTOR_VERSION}" \ + --with-cc-opt='-O2 -fstack-protector-strong -D_FORTIFY_SOURCE=2' --with-ld-opt='-Wl,-z,relro -Wl,-z,now') || + die "modsecurity: the nginx module does not configure; see the log." + log_command make -C "${src}" -j "$(_modsecurity_jobs)" modules || die "modsecurity: the nginx module does not build; see the log." + # A new file and a rename: a running nginx keeps the old module mapped. + install -d -m 0755 "${MODSEC_PREFIX}/nginx" + install -m 0644 "${src}/objs/ngx_http_modsecurity_module.so" "${so}.new" + mv -f -- "${so}.new" "${so}" + printf '%s\n' "$(_modsecurity_connector_id)" >"${stamp}" + _modsecurity_restorecon "${MODSEC_PREFIX}" + files_note_change "built the ModSecurity module for nginx ${version}" + return +} + +_modsecurity_install_apache() { + if os_is_debian; then + pkg_install libapache2-mod-security2 + else + # RHEL 10 has mod_security only in EPEL. + [[ ${OS_MAJOR} -lt 10 ]] || pkg_enable_epel + pkg_install mod_security + fi + return +} + +_modsecurity_install_nginx() { + if os_is_debian; then + if _modsecurity_nginx_build; then + pkg_install libmodsecurity3 + _modsecurity_build_connector + else + pkg_install libnginx-mod-http-modsecurity + fi + elif _modsecurity_nginx_build; then + _modsecurity_build_library + _modsecurity_build_connector + else + pkg_enable_epel + pkg_install nginx-mod-modsecurity + fi + return +} + +# The Core Rule Set below MODSEC_CRS_ROOT, one directory per version; configure removes older ones. +_modsecurity_crs_install() { + local version dir staging + version=$(_modsecurity_crs_version) + dir=$(_modsecurity_crs_dir) + [[ ! -f ${dir}/rules/REQUEST-949-BLOCKING-EVALUATION.conf ]] || return 0 + _modsecurity_workdir + fetch_verified "coreruleset-${version}" "${MODSEC_WORK}/coreruleset.tar.gz" + install -d -m 0755 "${MODSEC_CRS_ROOT}" + staging=$(mktemp -d "${MODSEC_CRS_ROOT}/.staging.XXXXXX") + tar -xzf "${MODSEC_WORK}/coreruleset.tar.gz" -C "${staging}" --no-same-owner --no-same-permissions + [[ -f ${staging}/coreruleset-${version}/rules/REQUEST-949-BLOCKING-EVALUATION.conf ]] || + die "modsecurity: the Core Rule Set archive has an unexpected layout." + chmod -R u=rwX,go=rX "${staging}/coreruleset-${version}" + rm -rf -- "${dir}" + mv -- "${staging}/coreruleset-${version}" "${dir}" + rm -rf -- "${staging}" + _modsecurity_restorecon "${MODSEC_CRS_ROOT}" + files_note_change "OWASP Core Rule Set ${version}" + return +} + +mod_check() { + local name size + _modsecurity_mode >/dev/null + [[ $(opt paranoia) =~ ^[1-4]$ ]] || die "LSS_MODSECURITY_PARANOIA must be 1, 2, 3 or 4, got '$(opt paranoia)'." + [[ $(opt anomaly_inbound) =~ ^[1-9][0-9]{0,3}$ ]] || + die "LSS_MODSECURITY_ANOMALY_INBOUND must be a number from 1 to 9999, got '$(opt anomaly_inbound)'." + for name in $(opt exclusions); do + [[ " ${MODSEC_PLUGINS} " == *" ${name} "* ]] || + die "LSS_MODSECURITY_EXCLUSIONS: '${name}' is unknown; available: ${MODSEC_PLUGINS}." + done + [[ -n $(opt allowed_methods) ]] || die "LSS_MODSECURITY_ALLOWED_METHODS is empty." + for name in $(opt allowed_methods); do + [[ ${name} =~ ^[A-Z][A-Z-]*$ ]] || die "LSS_MODSECURITY_ALLOWED_METHODS: '${name}' is not an HTTP method." + done + size=$(_modsecurity_max_body) + [[ ${size} =~ ^[0-9]+[kKmMgG]?$ ]] || die "LSS_MODSECURITY_MAX_BODY '${size}' is not a size like 64m or 1g." + return +} + +mod_install() { + local server + server=$(_modsecurity_server) + pkg_install curl logrotate + if [[ ${server} == apache ]]; then + _modsecurity_install_apache + else + _modsecurity_install_nginx + fi + _modsecurity_build_cleanup + _modsecurity_crs_install + return +} + +# _modsecurity_engine_conf SERVER: the engine settings after modsecurity.conf-recommended of ModSecurity, limited to +# what the oldest engine in use understands (2.9.5 for Apache, 3.0.6 for nginx on Ubuntu 22.04). +_modsecurity_engine_conf() { + local server=$1 bytes nofiles json=0 modern=1 + bytes=$(_modsecurity_bytes "$(_modsecurity_max_body)") + # Bodies without uploads (forms, JSON) up to 1 MiB: the recommended 128 KiB turns away ordinary JSON documents. + nofiles=$((bytes < 1048576 ? bytes : 1048576)) + if _modsecurity_json "${server}"; then + json=1 + fi + if _modsecurity_legacy; then + modern=0 + fi + printf '# Managed by lss (module modsecurity). Engine settings after modsecurity.conf-recommended.\n' + printf 'SecRuleEngine %s\nSecRequestBodyAccess On\n' "$(_modsecurity_mode)" + cat <<'EOF' +SecRule REQUEST_HEADERS:Content-Type "^(?:application(?:/soap\+|/)|text/)xml" "id:'200000',phase:1,t:none,t:lowercase,pass,nolog,ctl:requestBodyProcessor=XML" +EOF + if [[ ${json} == 1 ]]; then + cat <<'EOF' +SecRule REQUEST_HEADERS:Content-Type "^application/json" "id:'200001',phase:1,t:none,t:lowercase,pass,nolog,ctl:requestBodyProcessor=JSON" +EOF + if [[ ${server} == apache || ${modern} == 1 ]]; then + printf 'SecRequestBodyJsonDepthLimit 512\n' + fi + fi + printf 'SecRequestBodyLimit %s\nSecRequestBodyNoFilesLimit %s\nSecRequestBodyLimitAction Reject\n' "${bytes}" "${nofiles}" + if [[ ${server} == nginx && ${modern} == 1 ]]; then + cat <<'EOF' +SecArgumentsLimit 1000 +SecRule &ARGS "@ge 1000" "id:'200007',phase:2,t:none,log,deny,status:400,msg:'Failed to fully parse request body due to large argument count',severity:2" +EOF + fi + cat <<'EOF' +SecRule REQBODY_ERROR "!@eq 0" "id:'200002',phase:2,t:none,log,deny,status:400,msg:'Failed to parse request body.',logdata:'%{reqbody_error_msg}',severity:2" +SecRule MULTIPART_STRICT_ERROR "!@eq 0" "id:'200003',phase:2,t:none,log,deny,status:400,msg:'Multipart request body failed strict validation: PE %{REQBODY_PROCESSOR_ERROR}, BQ %{MULTIPART_BOUNDARY_QUOTED}, BW %{MULTIPART_BOUNDARY_WHITESPACE}, DB %{MULTIPART_DATA_BEFORE}, DA %{MULTIPART_DATA_AFTER}, HF %{MULTIPART_HEADER_FOLDING}, LF %{MULTIPART_LF_LINE}, SM %{MULTIPART_MISSING_SEMICOLON}, IQ %{MULTIPART_INVALID_QUOTING}, IP %{MULTIPART_INVALID_PART}, IH %{MULTIPART_INVALID_HEADER_FOLDING}, FL %{MULTIPART_FILE_LIMIT_EXCEEDED}'" +SecRule MULTIPART_UNMATCHED_BOUNDARY "!@eq 0" "id:'200004',phase:2,t:none,log,deny,msg:'Multipart parser detected a possible unmatched boundary.'" +SecPcreMatchLimit 1000 +SecPcreMatchLimitRecursion 1000 +SecRule TX:/^MSC_/ "!@streq 0" "id:'200005',phase:2,t:none,log,deny,msg:'ModSecurity internal error flagged: %{MATCHED_VAR_NAME}'" +SecResponseBodyAccess On +SecResponseBodyMimeType text/plain text/html text/xml +SecResponseBodyLimit 524288 +SecResponseBodyLimitAction ProcessPartial +EOF + if [[ ${server} == apache ]]; then + printf 'SecTmpDir /tmp/\nSecDataDir %s\n' "$(_modsecurity_data_dir)" + fi + # Transactions with rule matches or errors (404 aside), without the bodies: they carry passwords and personal data. + cat <<'EOF' +SecAuditEngine RelevantOnly +SecAuditLogRelevantStatus "^(?:5|4(?!04))" +SecAuditLogParts ABFHZ +SecAuditLogType Serial +EOF + printf 'SecAuditLog %s\n' "$(_modsecurity_audit_log "${server}")" + if [[ ${json} == 1 ]]; then + printf 'SecAuditLogFormat JSON\n' + fi + printf 'SecArgumentSeparator &\nSecCookieFormat 0\nSecStatusEngine Off\n' + return +} + +# The CRS settings that crs-setup.conf.example of the release documents; everything else keeps its default. +_modsecurity_crs_setup() { + local version tag='' paranoia=tx.blocking_paranoia_level phase name exclusions='' + local -a methods=() + version=$(_modsecurity_crs_version) + read -ra methods <<<"$(opt allowed_methods)" + printf '# Managed by lss (module modsecurity). OWASP CRS %s settings.\n' "${version}" + if _modsecurity_legacy; then + paranoia=tx.paranoia_level + printf 'SecDefaultAction "phase:%s,log,auditlog,pass"\n' 1 2 + for name in $(opt exclusions); do + if [[ " ${MODSEC_LEGACY_EXCLUSIONS} " == *" ${name} "* ]]; then + exclusions+=",setvar:tx.crs_exclusions_${name}=1" + fi + done + else + tag=",tag:'OWASP_CRS',ver:'OWASP_CRS/${version}'" + for phase in 1 2 3 4 5; do + printf 'SecDefaultAction "phase:%s,log,auditlog,pass"\n' "${phase}" + done + fi + printf 'SecAction "id:900000,phase:1,pass,t:none,nolog%s,setvar:%s=%s"\n' "${tag}" "${paranoia}" "$(opt paranoia)" + printf 'SecAction "id:900110,phase:1,pass,t:none,nolog%s,setvar:tx.inbound_anomaly_score_threshold=%s,setvar:tx.outbound_anomaly_score_threshold=4"\n' \ + "${tag}" "$(opt anomaly_inbound)" + if [[ -n ${exclusions} ]]; then + printf 'SecAction "id:900130,phase:1,pass,t:none,nolog%s"\n' "${exclusions}" + fi + printf "SecAction \"id:900200,phase:1,pass,t:none,nolog%s,setvar:'tx.allowed_methods=%s'\"\n" "${tag}" "${methods[*]}" + printf 'SecAction "id:900990,phase:1,pass,t:none,nolog%s,setvar:tx.crs_setup_version=%s"\n' "${tag}" "${version//./}" + return +} + +# CRS 4 ships the application exclusions as plugins, one repository each without release files; data/checksums.txt +# pins their files by commit. CRS 3.3 has them built in (crs-setup.conf switches them on). +_modsecurity_plugins() { + local dir=$1 name kind file entry + if _modsecurity_legacy; then + file_remove "${dir}/plugins" + for name in $(opt exclusions); do + [[ " ${MODSEC_LEGACY_EXCLUSIONS} " == *" ${name} "* ]] || + log_warn "modsecurity: CRS 3.3 (Ubuntu 22.04) has no rule exclusions for ${name}; skipped." + done + return 0 + fi + install -d -m 0755 "${dir}/plugins" + for name in ${MODSEC_PLUGINS}; do + for kind in config before after; do + file="${dir}/plugins/${name}-rule-exclusions-${kind}.conf" + entry=$(fetch_entry "crs-plugin-${name}-${kind}") || entry='' + if [[ -z ${entry} ]] || ! _modsecurity_wanted "${name}"; then + file_remove "${file}" + continue + fi + if [[ -f ${file} && $(sha256sum "${file}" | cut -d ' ' -f 1) == "${entry%% *}" ]]; then + continue + fi + _modsecurity_workdir + fetch_verified "crs-plugin-${name}-${kind}" "${MODSEC_WORK}/${name}-${kind}.conf" + file_write 0644 root:root "${file}" <"${MODSEC_WORK}/${name}-${kind}.conf" + done + done + return +} + +_modsecurity_plugin_includes() { + local dir=$1 kind=$2 file + for file in "${dir}/plugins/"*"-rule-exclusions-${kind}.conf"; do + if [[ -f ${file} ]]; then + printf 'Include %s\n' "${file}" + fi + done + return +} + +# The order CRS documents: engine, CRS setup, plugin settings, plugin rules before CRS, CRS, plugin rules after CRS. +# Every file is named, because ModSecurity 3 has no IncludeOptional for patterns without a match. +_modsecurity_main_conf() { + local dir=$1 + printf '# Managed by lss (module modsecurity). ModSecurity with the OWASP Core Rule Set %s.\n' "$(_modsecurity_crs_version)" + printf 'Include %s\n' "${dir}/modsecurity.conf" "${dir}/crs-setup.conf" + _modsecurity_plugin_includes "${dir}" config + _modsecurity_plugin_includes "${dir}" before + find "$(_modsecurity_crs_dir)/rules" -maxdepth 1 -type f -name '*.conf' | LC_ALL=C sort | sed 's/^/Include /' + _modsecurity_plugin_includes "${dir}" after + return +} + +# copytruncate: nginx and Apache keep the audit log open, and the reopen signal of nginx's own logrotate rule does not +# reach ModSecurity. +_modsecurity_logrotate() { + local log=$1 + cat < + Include ${dir}/main.conf + +EOF + if os_is_debian && ! a2query -q -m security2 2>/dev/null; then + a2enmod -q security2 >/dev/null + files_note_change "apache module security2" + fi + return +} + +# nginx: the module is loaded at the top of nginx.conf (by the distribution's module directory or, for a self-built +# module, by /etc/nginx/lss/modules) and switched on for every server in http{} (/etc/nginx/lss/http). +_modsecurity_nginx_enable() { + local dir=$1 so modules + grep -qF '/etc/nginx/lss/http/' /etc/nginx/nginx.conf || + die "modsecurity: /etc/nginx/nginx.conf does not include /etc/nginx/lss/http; run 'lss install nginx' with this lss version first." + so=$(_modsecurity_nginx_so) + [[ -f ${so} ]] || die "modsecurity: the nginx module ${so} is missing." + if os_is_debian; then modules=/etc/nginx/modules-enabled; else modules=/usr/share/nginx/modules; fi + if _modsecurity_nginx_build || ! grep -qs 'ngx_http_modsecurity_module' "${modules}"/*.conf; then + file_write 0644 root:root /etc/nginx/lss/modules/modsecurity.conf </dev/null || true) + grep -q 'security2_module' <<<"${config}" || die "modsecurity: Apache has not loaded mod_security2." + else + config=$(nginx -T 2>/dev/null || true) + grep -q 'ngx_http_modsecurity_module' <<<"${config}" || die "modsecurity: nginx does not load the ModSecurity module." + grep -q '^modsecurity on;' <<<"${config}" || die "modsecurity: ModSecurity is not switched on in nginx." + fi + url="http://127.0.0.1${MODSEC_PROBE}" + [[ $(curl -fsS --max-time 10 -H 'Host: localhost' "${url}" 2>/dev/null) == lss ]] || + die "modsecurity: a harmless request to ${MODSEC_PROBE} does not get through." + marker="lss-verify-${RANDOM}${RANDOM}" + status=$(curl -s --max-time 10 -o /dev/null -w '%{http_code}' -H 'Host: localhost' \ + "${url}?probe=${marker}&q=%3Cscript%3Ealert(1)%3C%2Fscript%3E" || true) + if [[ $(_modsecurity_mode) == On ]]; then + [[ ${status} == 403 ]] || die "modsecurity: a request with a script tag got ${status}, not 403." + else + [[ ${status} == 200 ]] || die "modsecurity: in DetectionOnly mode a request with a script tag got ${status}, not 200." + fi + # The audit log is written after the response. + for ((i = 0; i < 10; i++)); do + if grep -qF -- "${marker}" "${log}" 2>/dev/null; then + found=1 + break + fi + sleep 1 + done + [[ ${found} == 1 ]] || die "modsecurity: the request with a script tag is missing from the audit log ${log}." + return +} + +mod_remove() { + local server unit + server=$(web_server) + case ${server} in + nginx) + file_remove /etc/nginx/lss/http/modsecurity.conf + file_remove /etc/nginx/lss/modules/modsecurity.conf + ;; + apache) + if os_is_debian && a2query -q -m security2 2>/dev/null; then + a2dismod -q -f security2 >/dev/null + files_note_change "apache module security2 off" + fi + file_remove "$(_modsecurity_apache_file)" + svc_dropin_remove "$(web_unit apache).service" lss-modsecurity + ;; + *) ;; + esac + if [[ ${server} == nginx || ${server} == apache ]]; then + file_remove "$(_modsecurity_conf_dir "${server}")" + unit=$(web_unit "${server}") + web_test "${server}" || die "modsecurity: the ${server} configuration does not load without ModSecurity; see above." + if svc_is_active "${unit}"; then + svc_restart "${unit}" || die "modsecurity: ${unit} does not start without ModSecurity." + fi + fi + file_remove /etc/logrotate.d/lss-modsecurity + if [[ ${server} == apache ]]; then + pkg_remove_family "libapache2-mod-security2" "mod_security" + else + pkg_remove_family "libnginx-mod-http-modsecurity libmodsecurity3" "nginx-mod-modsecurity libmodsecurity" + fi + # Downloads and builds, reproducible from data/checksums.txt; the audit logs stay like the web server's own logs. + if [[ -e ${MODSEC_PREFIX} || -e ${MODSEC_CRS_ROOT} ]]; then + rm -rf -- "${MODSEC_PREFIX}" "${MODSEC_CRS_ROOT}" + files_note_change "removed ${MODSEC_PREFIX} and ${MODSEC_CRS_ROOT}" + fi + return +} diff --git a/modules/web/nginx.sh b/modules/web/nginx.sh index 6880ab7..228be8c 100644 --- a/modules/web/nginx.sh +++ b/modules/web/nginx.sh @@ -39,6 +39,8 @@ worker_rlimit_nofile 8192; pid /run/nginx.pid; error_log /var/log/nginx/error.log warn; include $(_nginx_modules_dir)/*.conf; +# Modules and http settings that other lss modules add (modsecurity); empty otherwise. +include ${NGINX_DIR}/modules/*.conf; events { worker_connections 2048; @@ -72,6 +74,7 @@ http { '' close; } + include ${NGINX_DIR}/http/*.conf; include ${NGINX_DIR}/default.conf; include ${NGINX_DIR}/admin.conf; include ${NGINX_DIR}/sites/*.conf; diff --git a/tests/integration/settings.conf b/tests/integration/settings.conf index f967c7e..6fe8cab 100644 --- a/tests/integration/settings.conf +++ b/tests/integration/settings.conf @@ -27,3 +27,6 @@ LSS_POSTGRESQL_USERS=lss_app:lss_app LSS_REDIS_MAXMEMORY=64mb LSS_VALKEY_MAXMEMORY=64mb LSS_MONGODB_USERS=lss_app:lss_app + +# modsecurity: two application exclusions (CRS 4 plugins; built into CRS 3.3 on Ubuntu 22.04). +LSS_MODSECURITY_EXCLUSIONS=wordpress nextcloud From 949ef7d44babdea368e8b052b5a5bbb68703c03a Mon Sep 17 00:00:00 2001 From: VectoDE Date: Tue, 13 Oct 2026 22:11:28 +0200 Subject: [PATCH 35/92] Profiles webhosting and all; test databases and modsecurity on every system New integration groups database and database-alt (conflicting engines in separate jobs), modsecurity in the web group, and a Debian 13 VM that installs every module at once (profiles/all.conf) and audits it. Docs for the database and modsecurity modules. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- .github/workflows/integration.yml | 19 ++++++++++++++----- CHANGELOG.md | 5 +++++ README.md | 7 ++++++- data/checksums.txt | 2 -- docs/hardening.md | 6 ++++++ profiles/all.conf | 8 ++++++++ profiles/webhosting.conf | 9 +++++++++ 7 files changed, 48 insertions(+), 8 deletions(-) create mode 100644 profiles/all.conf create mode 100644 profiles/webhosting.conf diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index be6013f..64857a2 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -26,12 +26,12 @@ jobs: container: name: ${{ matrix.system }}${{ matrix.arch == 'arm64' && ' arm64' || '' }} / ${{ matrix.group }} runs-on: ${{ matrix.arch == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }} - timeout-minutes: 45 + timeout-minutes: 90 strategy: fail-fast: false matrix: system: [debian-12, debian-13, ubuntu-22.04, ubuntu-24.04, ubuntu-26.04, rocky-9, almalinux-10] - group: [base, security, scanners, web, runtime] + group: [base, security, scanners, web, runtime, database, database-alt] arch: [x64, arm64] include: - group: base @@ -42,9 +42,14 @@ jobs: modules: aide rkhunter clamav lynis # Each web server alone, then with certificates (Pebble) and sites; then HAProxy. - group: web - modules: nginx,tls,vhost apache,tls,vhost caddy,tls,vhost haproxy + modules: nginx,tls,vhost apache,tls,vhost caddy,tls,vhost haproxy nginx,modsecurity apache,modsecurity - group: runtime modules: php,composer nginx,php node python ruby java go dotnet + # Conflicting engines (mariadb/mysql, redis/valkey) run in separate jobs: a removal keeps their data. + - group: database + modules: mariadb postgresql redis nginx,php,mariadb,phpmyadmin caddy,php,adminer apache,postgresql,pgadmin + - group: database-alt + modules: mysql valkey mongodb steps: - uses: step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25 # v2.22.1 with: @@ -103,12 +108,16 @@ jobs: vm: name: ${{ matrix.system }} VM / ${{ matrix.run }} runs-on: ubuntu-24.04 - timeout-minutes: 60 + timeout-minutes: 120 strategy: fail-fast: false matrix: system: [debian-12, debian-13, rocky-9, almalinux-10] run: [kernel, profile minimal-secure] + include: + # Every module at once (profiles/all.conf), on a clean system without the runner's preinstalled databases. + - system: debian-13 + run: profile all steps: - uses: step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25 # v2.22.1 with: @@ -139,5 +148,5 @@ jobs: vm-debian-*) minimum=82 ;; *) minimum=60 ;; esac - tests/integration/run.sh "$SYSTEM" --profile minimal-secure "$minimum" + tests/integration/run.sh "$SYSTEM" --profile "${RUN#profile }" "$minimum" fi diff --git a/CHANGELOG.md b/CHANGELOG.md index b3f059f..daf1bf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,11 @@ now or later. (NodeSource), `python` (venv, pip, pipx), `ruby` (Bundler), `java` (OpenJDK, Temurin where missing), `go` (official archive) and `dotnet` (.NET SDK, telemetry off), all from repositories with pinned keys or checksum-verified downloads. +- `modsecurity`: the ModSecurity web application firewall with the OWASP Core Rule Set for nginx and Apache on every + supported system, with a module built from verified sources where the distribution has none. +- Database modules: `mariadb`, `mysql`, `postgresql`, `redis`, `valkey` and `mongodb`, on localhost with hardened + authentication, optional databases and least-privilege users; root or admin accounts never exist without a password. +- Profiles `webhosting` and `all`. - Database tools: `phpmyadmin`, `adminer` and `pgadmin` on the admin port of the web server, from checksum-verified downloads, limited to the local database servers; pgAdmin runs as its own sandboxed service. - A warning for every setting that no module reads, so a misspelt key does not silently fall back to the default. diff --git a/README.md b/README.md index ca5294c..d46538e 100644 --- a/README.md +++ b/README.md @@ -135,13 +135,18 @@ change, run `lss configure MODULE`. (AppArmor or SELinux), `auditd`, `auto-updates`, `password-policy`, `aide`, `rkhunter`, `clamav`, `lynis`, `usbguard`, `guard`. -**Web:** `nginx`, `apache` or `caddy`, `tls` (certificates from Let's Encrypt), `vhost` (sites), `haproxy`. +**Web:** `nginx`, `apache` or `caddy`, `tls` (certificates from Let's Encrypt), `vhost` (sites), `haproxy`, +`modsecurity` (web application firewall with the OWASP Core Rule Set for nginx or Apache). **Runtimes:** `php` (PHP-FPM with a hardened pool for the web server), `composer`, `node` (Node.js 22, 24 or 26 from NodeSource), `python` (venv, pip, pipx), `ruby` (Bundler), `java` (OpenJDK 21, Temurin where the distribution has none), `go` (official archive), `dotnet` (.NET SDK 10, telemetry off). `LSS_PHP_VERSION`, `LSS_NODE_VERSION`, `LSS_JAVA_VERSION` and `LSS_DOTNET_VERSION` pick another version. +**Databases:** `mariadb` or `mysql`, `postgresql`, `redis` or `valkey`, `mongodb`. Each listens on localhost only, +with hardened authentication and optional databases and users (`LSS__USERS=user:database`, the password with +`lss secrets show -user-`). `access=vpn` or `public` opens the port. + **Database tools:** `phpmyadmin`, `adminer` (only the local database servers) and `pgadmin` (its own service; the first password with `lss secrets show pgadmin-admin-password`), all on the admin port. diff --git a/data/checksums.txt b/data/checksums.txt index aaaf4cc..5ff3e69 100644 --- a/data/checksums.txt +++ b/data/checksums.txt @@ -23,8 +23,6 @@ adminer-6.1.2 any b32316156bd18ffa99c1a27617142c2e5ad72260e37f15da4e9937a3dbb15f pgadmin4-9.18 any 9ea084fc416265e4fc95c9fb6e6ae0f6668d34da8670c9b65c79cea054124356 https://files.pythonhosted.org/packages/0e/80/86d7e7123e2b6de41890bd1edc3e27348e2572acea337088faa0ec41c7f5/pgadmin4-9.18-py3-none-any.whl gunicorn-26.2.0 any bd249d0b3f7972f7432f0a6b6ff3b3ee2d129f70cd1ff6c09a9dd9e29a2b88e3 https://files.pythonhosted.org/packages/fe/85/7522a52e5e2f42faf1a129113ab63e548c42e103e9af395b7bfe65e403e2/gunicorn-26.2.0-py3-none-any.whl -# Files lss downloads directly, read by fetch_verified (lib/fetch.sh): . - # Valkey 8.1.10, upstream build for Ubuntu 22.04, which has no Valkey package. SHA-256 from the .sha256 files next to # the archives (https://valkey.io/download/), 2026-10-13. valkey-jammy x86_64 23400087a684b6e87f66332e0a836a2674c24c7c482361766777149499c08e50 https://download.valkey.io/releases/valkey-8.1.10-jammy-x86_64.tar.gz diff --git a/docs/hardening.md b/docs/hardening.md index c2b8f35..1fd5dbd 100644 --- a/docs/hardening.md +++ b/docs/hardening.md @@ -39,6 +39,12 @@ Docker, databases and its own sudo rules) 78. CI checks that it stays at 82 on D | `phpmyadmin` | Checksum-verified release without the setup wizard; configuration readable by root and PHP-FPM only, with a generated cookie secret; only the local database server, no root and no empty-password logins; no version check and no error reports; only on the admin port | (operations) | | `adminer` | Checksum-verified release; a login plugin allows only the configured local servers and drivers; only on the admin port | (operations) | | `pgadmin` | Pinned, checksum-verified wheels in an isolated environment; own system user; systemd sandbox without capabilities; bound to 127.0.0.1; no upgrade check, no psql tool, no browsing of binary paths; secure cookies when the admin port is public | (operations) | +| `modsecurity` | Requests filtered by ModSecurity with the OWASP Core Rule Set (anomaly scoring, paranoia level 1 by default) and blocked with 403; matches go to an audit log without request bodies | OWASP CRS | +| `mariadb` | Bound to 127.0.0.1, `local-infile=0`, root only through the Unix socket, no anonymous accounts and no test database, one least-privilege user per application, systemd sandbox | CIS MariaDB | +| `mysql` | Bound to 127.0.0.1 (the X protocol too), `local-infile=0`, root through `auth_socket` (RHEL: a generated password in `/root/.my.cnf` 0600); root never exists without a password; systemd sandbox | CIS MySQL | +| `postgresql` | localhost, `scram-sha-256`, TLS 1.2 or later, peer login only for `postgres`, each role reaches only its own database, PUBLIC loses CONNECT and CREATE, systemd sandbox | CIS PostgreSQL | +| `redis`, `valkey` | localhost, ACL password stored as SHA-256, `FLUSHALL`, `CONFIG`, `DEBUG`, `MODULE` and similar commands blocked, systemd sandbox | (vendor guide) | +| `mongodb` | Authorization from the first start, bound to 127.0.0.1, no Unix socket, server-side JavaScript off, mongosh telemetry off, least-privilege application users, systemd sandbox | CIS MongoDB | | `haproxy` | Statistics only on 127.0.0.1, admin socket readable by root only, chroot, systemd sandbox, ports in the firewall only for services that are not `local` | (operations) | ## Deliberate deviations diff --git a/profiles/all.conf b/profiles/all.conf new file mode 100644 index 0000000..d025db3 --- /dev/null +++ b/profiles/all.conf @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# Profile: Every module, one choice per conflict group (nginx, fail2ban, mariadb, redis) +# +# For the nightly test and as a reference. ssh-2fa, clamav and usbguard stay opt-in as in minimal-secure. +LSS_MODULES=system time swap admin-user tools logging ssh-hardening firewall fail2ban kernel-hardening mac auditd auto-updates password-policy aide rkhunter lynis guard nginx tls vhost haproxy modsecurity php composer node python ruby java go dotnet mariadb postgresql redis mongodb phpmyadmin adminer pgadmin diff --git a/profiles/webhosting.conf b/profiles/webhosting.conf new file mode 100644 index 0000000..116b30f --- /dev/null +++ b/profiles/webhosting.conf @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# Profile: Web hosting: minimal-secure plus nginx, certificates, PHP, MariaDB, phpMyAdmin and sites +# +# Sites go into LSS_VHOST_SITES, the host names of the server into LSS_TLS_DOMAINS. Backups (restic) join the profile +# with the backup modules. +LSS_MODULES=system time swap admin-user tools logging ssh-hardening firewall fail2ban kernel-hardening mac auditd auto-updates password-policy aide rkhunter lynis guard nginx tls php mariadb phpmyadmin vhost modsecurity From 6ccfc7a64e0237ef6ee4acf1c16d569dc6d16f49 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Tue, 13 Oct 2026 22:16:29 +0200 Subject: [PATCH 36/92] Fix the first CI findings of the web modules The default certificate is state that the tls deploy hook replaces, so it is no longer recorded as a managed file of the web server (verify reported every new certificate as changed outside lss). vhost verify waits for the reload that installs a new certificate. ShellCheck notes in lib/web.sh are explained. The coverage run prints failing tests and skips the certificate tests when its image has no openssl. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- lib/web.sh | 11 +++++++++-- modules/web/vhost.sh | 14 ++++++++++---- scripts/dev/coverage.sh | 6 +++++- tests/unit/guard.bats | 1 + tests/unit/web.bats | 2 ++ 5 files changed, 27 insertions(+), 7 deletions(-) diff --git a/lib/web.sh b/lib/web.sh index c1aa076..1ff0530 100644 --- a/lib/web.sh +++ b/lib/web.sh @@ -25,6 +25,7 @@ WEB_PHP_SOCKET=/run/php-fpm/lss.sock WEB_TLS_DIR=/etc/lss/tls WEB_ACME_ROOT=/var/www/lss-acme WEB_DEFAULT_ROOT=/var/www/lss-default +# shellcheck disable=SC2034 # read by the web server and admin interface modules WEB_ADMIN_PORT=8090 WEB_ADMIN_HTPASSWD=/etc/lss/web/admin.htpasswd WEB_ADMIN_CADDY_AUTH=/etc/lss/web/admin-auth.caddy @@ -32,6 +33,7 @@ WEB_ADMIN_CADDY_AUTH=/etc/lss/web/admin-auth.caddy WEB_CERT_HOOK=/etc/letsencrypt/renewal-hooks/deploy/lss-deploy # CA certificate of a private ACME server (LSS_TLS_CA_BUNDLE, for example Pebble or step-ca); certbot trusts it. WEB_ACME_CA=/etc/letsencrypt/lss-acme-ca.pem +# shellcheck disable=SC2034 # read by the web server and haproxy modules WEB_TLS_CIPHERS=ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305 # The installed web server (nginx, apache or caddy), or nothing. @@ -158,8 +160,12 @@ web_default_cert() { -addext "subjectAltName=DNS:${name}" -keyout "${tmp}/privkey.pem" -out "${tmp}/fullchain.pem" >/dev/null 2>&1 || die "web: could not create the self-signed default certificate." install -d -m 0755 "${WEB_TLS_DIR}" - file_write 0640 "root:${WEB_GROUP}" "${dir}/privkey.pem" <"${tmp}/privkey.pem" - file_write 0644 root:root "${dir}/fullchain.pem" <"${tmp}/fullchain.pem" + # Certificates are state, not configuration: the tls module's deploy hook replaces them, so they are not + # recorded as managed files (lss verify would report every renewal as a change made outside lss). + install -d -m 0755 "${dir}" + install -m 0640 -o root -g "${WEB_GROUP}" "${tmp}/privkey.pem" "${dir}/privkey.pem" + install -m 0644 -o root -g root "${tmp}/fullchain.pem" "${dir}/fullchain.pem" + files_note_change "self-signed default certificate" rm -rf -- "${tmp}" return } @@ -365,6 +371,7 @@ _web_site_nginx() { _web_listen_nginx 80 printf '\tserver_name %s;\n' "${domains}" printf '\tlocation /.well-known/acme-challenge/ {\n\t\troot %s;\n\t}\n' "${WEB_ACME_ROOT}" + # shellcheck disable=SC2016 # $host and $request_uri are nginx variables printf '\tlocation / {\n\t\treturn 301 https://$host$request_uri;\n\t}\n}\n\nserver {\n' _web_listen_nginx 443 "$(web_nginx_ssl_flags)" if web_nginx_http2_directive; then diff --git a/modules/web/vhost.sh b/modules/web/vhost.sh index 87802d2..75b79d0 100644 --- a/modules/web/vhost.sh +++ b/modules/web/vhost.sh @@ -99,7 +99,7 @@ mod_configure() { } mod_verify() { - local spec status served + local spec status expected served i for spec in $(opt sites); do _vhost_parse "${spec}" [[ ${VHOST_KIND} == proxy || -d ${VHOST_TARGET} ]] || die "vhost: the document root ${VHOST_TARGET} is missing." @@ -107,9 +107,15 @@ mod_verify() { status=$(curl -ks --max-time 10 -o /dev/null -w '%{http_code}' \ --resolve "${VHOST_DOMAINS[0]}:443:127.0.0.1" "https://${VHOST_DOMAINS[0]}/" || true) [[ ${status} =~ ^[1-5][0-9][0-9]$ ]] || die "vhost: https://${VHOST_DOMAINS[0]}/ does not answer on this server." - served=$(openssl s_client -connect 127.0.0.1:443 -servername "${VHOST_DOMAINS[0]}" /dev/null | - openssl x509 -noout -fingerprint -sha256 2>/dev/null || true) - [[ ${served} == "$(openssl x509 -noout -fingerprint -sha256 -in "${WEB_TLS_DIR}/${VHOST_NAME}/fullchain.pem")" ]] || + expected=$(openssl x509 -noout -fingerprint -sha256 -in "${WEB_TLS_DIR}/${VHOST_NAME}/fullchain.pem") + # A reload after a new certificate finishes in the background; old workers serve the old one for a moment. + for ((i = 0; i < 15; i++)); do + served=$(openssl s_client -connect 127.0.0.1:443 -servername "${VHOST_DOMAINS[0]}" /dev/null | + openssl x509 -noout -fingerprint -sha256 2>/dev/null || true) + [[ ${served} != "${expected}" ]] || break + sleep 1 + done + [[ ${served} == "${expected}" ]] || die "vhost: ${VHOST_DOMAINS[0]} is not served with the certificate in ${WEB_TLS_DIR}/${VHOST_NAME}." done return diff --git a/scripts/dev/coverage.sh b/scripts/dev/coverage.sh index 8a02c6e..8d6ef30 100755 --- a/scripts/dev/coverage.sh +++ b/scripts/dev/coverage.sh @@ -16,7 +16,11 @@ rm -rf -- "${out}" mkdir -p "${out}" docker run --rm --security-opt seccomp=unconfined \ -v "${PWD}:/src" -v "${bats_dir}:/bats:ro" -v "$(cd "${out}" && pwd):/out" -w /src \ - "${image}" kcov --include-path=/src/lib --bash-dont-parse-binary-dir /out /bats/bin/bats tests/unit >/dev/null + "${image}" kcov --include-path=/src/lib --bash-dont-parse-binary-dir /out /bats/bin/bats tests/unit >"${out}/bats.tap" || { + # The failing tests with their output; the rest of the TAP stream is in OUT_DIR/bats.tap. + grep -A 20 '^not ok' "${out}/bats.tap" >&2 || true + exit 1 +} report=$(find "${out}" -name coverage.json -path '*bats*' | head -1) python3 - "${report}" <<'PY' import json, sys diff --git a/tests/unit/guard.bats b/tests/unit/guard.bats index 915ecf9..b866dc4 100644 --- a/tests/unit/guard.bats +++ b/tests/unit/guard.bats @@ -124,6 +124,7 @@ journalctl() { } @test "a certificate close to its end is a finding, a fresh one is not" { + command -v openssl >/dev/null || skip 'openssl is not installed' export LSS_TLS_DIR=${BATS_TEST_TMPDIR}/tls TLS_DIR=${LSS_TLS_DIR} mkdir -p "${TLS_DIR}/old" "${TLS_DIR}/fresh" diff --git a/tests/unit/web.bats b/tests/unit/web.bats index 8868cfe..e87a55f 100644 --- a/tests/unit/web.bats +++ b/tests/unit/web.bats @@ -22,6 +22,8 @@ nginx() { new_cert() { local dir=$1 days=$2 + # The kcov image of the coverage run has no openssl. + command -v openssl >/dev/null || skip 'openssl is not installed' shift 2 local san san=$(printf 'DNS:%s,' "$@") From 73bb165d8f14ff0740516918e7b9853297683fd8 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Tue, 13 Oct 2026 22:44:23 +0200 Subject: [PATCH 37/92] fix(security): stable unit paths on RHEL, quiet augenrules and firewalld start On RHEL /sbin and /bin are symlinks, so command -v returned different paths depending on PATH and a second run rewrote the aide and rkhunter units. augenrules output and firewalld's dbus notice now go to the log only. Refs #12 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- modules/security/aide.sh | 2 +- modules/security/auditd.sh | 4 ++-- modules/security/clamav.sh | 2 +- modules/security/firewall.sh | 7 ++++++- modules/security/rkhunter.sh | 2 +- 5 files changed, 11 insertions(+), 6 deletions(-) diff --git a/modules/security/aide.sh b/modules/security/aide.sh index 844328c..8acf958 100644 --- a/modules/security/aide.sh +++ b/modules/security/aide.sh @@ -97,7 +97,7 @@ Description=AIDE file integrity check (lss) Type=oneshot Nice=19 IOSchedulingClass=idle -ExecStart=$(command -v aide) --check --config $(_aide_conf) +ExecStart=$(readlink -f "$(command -v aide)") --check --config $(_aide_conf) PrivateNetwork=yes $(svc_scanner_sandbox strict /var/log /var/lib/aide) SuccessExitStatus=1 2 3 4 5 6 7 diff --git a/modules/security/auditd.sh b/modules/security/auditd.sh index 1abb918..ca41e9f 100644 --- a/modules/security/auditd.sh +++ b/modules/security/auditd.sh @@ -137,8 +137,8 @@ mod_configure() { if _auditd_immutable_now; then log_warn "auditd: the loaded rules are locked (-e 2); the new rules apply after the next reboot." else - augenrules --check >/dev/null 2>&1 || true - augenrules --load >/dev/null || die "auditd: the kernel rejected the audit rules." + # augenrules reports slow rule forms on RHEL; that detail belongs in the log, not on the screen. + log_command augenrules --load || die "auditd: the kernel rejected the audit rules." fi fi return diff --git a/modules/security/clamav.sh b/modules/security/clamav.sh index 6c173a4..da72efb 100644 --- a/modules/security/clamav.sh +++ b/modules/security/clamav.sh @@ -66,7 +66,7 @@ Description=ClamAV scan of writable directories (lss) Type=oneshot Nice=19 IOSchedulingClass=idle -ExecStart=/bin/sh -c 'for d in $(opt scan_paths); do [ -d "\$\$d" ] && set -- "\$\$@" "\$\$d"; done; [ \$\$# -eq 0 ] || exec $(command -v clamscan) --recursive --infected --cross-fs=no --max-filesize=100M --max-scansize=400M "\$\$@"' +ExecStart=/bin/sh -c 'for d in $(opt scan_paths); do [ -d "\$\$d" ] && set -- "\$\$@" "\$\$d"; done; [ \$\$# -eq 0 ] || exec $(readlink -f "$(command -v clamscan)") --recursive --infected --cross-fs=no --max-filesize=100M --max-scansize=400M "\$\$@"' SuccessExitStatus=1 PrivateNetwork=yes $(svc_scanner_sandbox strict /tmp /var/tmp) diff --git a/modules/security/firewall.sh b/modules/security/firewall.sh index 0b276bc..ecf29ce 100644 --- a/modules/security/firewall.sh +++ b/modules/security/firewall.sh @@ -74,7 +74,7 @@ _fw_defaults_firewalld() { } mod_configure() { - local ssh_port spec + local ssh_port spec i ssh_port=$(fw_ssh_port) # SSH first: recorded and applied before the firewall is switched on. fw_allow "${ssh_port}/tcp" @@ -96,6 +96,11 @@ mod_configure() { svc_enable ufw else svc_enable firewalld + # firewall-cmd prints a waiting notice while firewalld is still starting. + for ((i = 0; i < 30; i++)); do + firewall-cmd --state >/dev/null 2>&1 && break + sleep 1 + done fi files_note_change "firewall enabled" _fw_is_open "${ssh_port}" || die "firewall: SSH port ${ssh_port} is not open; the firewall stays off." diff --git a/modules/security/rkhunter.sh b/modules/security/rkhunter.sh index 711ebbd..b2a9b53 100644 --- a/modules/security/rkhunter.sh +++ b/modules/security/rkhunter.sh @@ -73,7 +73,7 @@ Description=Rootkit Hunter scan (lss) Type=oneshot Nice=19 IOSchedulingClass=idle -ExecStart=$(command -v rkhunter) --check --skip-keypress --report-warnings-only --nocolors +ExecStart=$(readlink -f "$(command -v rkhunter)") --check --skip-keypress --report-warnings-only --nocolors $(svc_scanner_sandbox strict /var/lib/rkhunter /var/log /tmp /var/tmp) SuccessExitStatus=1 EOF From 45247ce561557735d0a472cf1bd281f40361e7a7 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Tue, 13 Oct 2026 22:46:37 +0200 Subject: [PATCH 38/92] ci(dco): skip merge commits like the DCO GitHub App Refs #12 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- .github/workflows/dco.yml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/dco.yml b/.github/workflows/dco.yml index 144580c..5940037 100644 --- a/.github/workflows/dco.yml +++ b/.github/workflows/dco.yml @@ -3,7 +3,7 @@ # SPDX-License-Identifier: MIT # Every commit in a pull request must be signed off (Developer Certificate of Origin): the author certifies the right -# to submit it under the project's license. Commits by bots such as Dependabot are exempt. +# to submit it under the project's license. Commits by bots such as Dependabot and merge commits are exempt. name: DCO on: pull_request @@ -33,8 +33,13 @@ jobs: PR: ${{ github.event.pull_request.number }} run: | missing=0 - while IFS=$'\t' read -r sha login email; do + while IFS=$'\t' read -r sha login email parents; do case "$login" in *'[bot]') continue ;; esac + # Merge commits bring in work that was signed off on its own branch, as with the DCO GitHub App. + if ((parents > 1)); then + echo "${sha:0:7} is a merge commit" + continue + fi message="$(gh api "repos/$GITHUB_REPOSITORY/commits/$sha" --jq .commit.message)" if printf '%s\n' "$message" | grep -i '^Signed-off-by:' | grep -qiF "<$email>"; then echo "${sha:0:7} signed off by $email" @@ -43,5 +48,5 @@ jobs: missing=1 fi done < <(gh api --paginate "repos/$GITHUB_REPOSITORY/pulls/$PR/commits" \ - --jq '.[] | [.sha, (.author.login // ""), .commit.author.email] | @tsv') + --jq '.[] | [.sha, (.author.login // ""), .commit.author.email, (.parents | length)] | @tsv') exit "$missing" From d18570163eb95fe0217028d7abb8187aea4ac0b9 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Wed, 14 Oct 2026 00:33:57 +0200 Subject: [PATCH 39/92] fix(service): let scanners enter their SELinux domain NoNewPrivileges blocks the transition from init_t into aide_t, so the AIDE check on RHEL ran as init_t and could not write /var/log/aide/aide.log. Refs #12 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- lib/service.sh | 9 +++++++-- tests/fixtures/bin/selinuxenabled | 8 ++++++++ tests/unit/helpers.bash | 2 ++ tests/unit/system.bats | 8 ++++++++ 4 files changed, 25 insertions(+), 2 deletions(-) create mode 100755 tests/fixtures/bin/selinuxenabled diff --git a/lib/service.sh b/lib/service.sh index 6748348..02fe6a6 100644 --- a/lib/service.sh +++ b/lib/service.sh @@ -110,9 +110,14 @@ svc_wait_port() { # kernel modules and the kernel log stay visible: a private /tmp would hide it from ClamAV and rkhunter, and rkhunter, # AIDE and Lynis check the others. svc_scanner_sandbox() { - local level=$1 path + local level=$1 path nnp=yes shift - printf '%s\n' "ProtectSystem=${level}" ProtectHome=read-only NoNewPrivileges=yes ProtectControlGroups=yes \ + # Under SELinux a scanner enters its own domain (aide_t, antivirus_t) on exec. NoNewPrivileges blocks that change; + # the scanner then stays in init_t, which may not write its reports. + if command -v selinuxenabled >/dev/null 2>&1 && selinuxenabled; then + nnp=no + fi + printf '%s\n' "ProtectSystem=${level}" ProtectHome=read-only "NoNewPrivileges=${nnp}" ProtectControlGroups=yes \ ProtectClock=yes ProtectHostname=yes RestrictSUIDSGID=yes RestrictRealtime=yes RestrictNamespaces=yes \ LockPersonality=yes SystemCallArchitectures=native for path in "$@"; do diff --git a/tests/fixtures/bin/selinuxenabled b/tests/fixtures/bin/selinuxenabled new file mode 100755 index 0000000..e6990ff --- /dev/null +++ b/tests/fixtures/bin/selinuxenabled @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# shellcheck source=tests/fixtures/bin/_stub.bash +source "$(dirname "$0")/_stub.bash" +stub_main selinuxenabled "$@" diff --git a/tests/unit/helpers.bash b/tests/unit/helpers.bash index 9d85fa5..52bc142 100644 --- a/tests/unit/helpers.bash +++ b/tests/unit/helpers.bash @@ -17,6 +17,8 @@ lss_test_setup() { export LSS_COLOR=never export LSS_VERSION=0.0.0-test export STUB_LOG=${BATS_TEST_TMPDIR}/stub.log + # Systems without SELinux unless a test switches it on. + export STUB_EXIT_selinuxenabled=1 export PATH="${REPO_ROOT}/tests/fixtures/bin:${PATH}" OWNER="$(id -un):$(id -gn)" export OWNER diff --git a/tests/unit/system.bats b/tests/unit/system.bats index e285379..7bd7d52 100644 --- a/tests/unit/system.bats +++ b/tests/unit/system.bats @@ -565,6 +565,14 @@ EOF run ! grep -q ReadWritePaths <<<"${output}" } +@test "svc_scanner_sandbox lets scanners enter their SELinux domain" { + export STUB_EXIT_selinuxenabled=0 + run svc_scanner_sandbox strict /var/lib/aide + [ "${status}" -eq 0 ] + grep -qxF 'NoNewPrivileges=no' <<<"${output}" + run ! grep -qxF 'NoNewPrivileges=yes' <<<"${output}" +} + @test "fw_ssh_port falls back to 22 when sshd fails" { STUB_EXIT_sshd=255 run fw_ssh_port [ "${status}" -eq 0 ] From 6fc0a0685148fd7cf94d6772ada2a733f25acf28 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Wed, 14 Oct 2026 00:33:58 +0200 Subject: [PATCH 40/92] style(postgresql): explain the single-quoted inner script for ShellCheck Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- modules/database/postgresql.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/database/postgresql.sh b/modules/database/postgresql.sh index 03c723f..0e85231 100644 --- a/modules/database/postgresql.sh +++ b/modules/database/postgresql.sh @@ -247,6 +247,7 @@ _pg_include() { local file=$1 line line="include_if_exists = '${PG_SETTINGS}'" _pg_as_postgres grep -qxF -- "${line}" "${file}" && return 0 + # shellcheck disable=SC2016 # $1 and $2 belong to the inner sh, which gets the file and the line as arguments. _pg_as_postgres sh -c 'printf "%s\n" "$2" >>"$1"' sh "${file}" "${line}" || die "postgresql: could not add the lss settings to ${file}." files_note_change "included ${PG_SETTINGS} in ${file}" From d61b9249e6a9f00c7473bdf0842ce836a2e43f53 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Wed, 14 Oct 2026 00:45:28 +0200 Subject: [PATCH 41/92] fix(repo): separate RPM key for NodeSource rpm imports only the first key of a file, and RHEL 9 rejects the SHA-1 self-signature of NodeSource's apt key, so dnf could not verify nodejs. repo_add now takes RPM_KEY and RPM_FINGERPRINTS for vendors that sign their packages with another key than their apt repository. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- data/keys/nodesource-rpm.asc | 52 +++++++++++++++++++++++++++++++++++ data/keys/nodesource.asc | 51 ++-------------------------------- data/repos/nodesource-22.conf | 7 +++-- data/repos/nodesource-24.conf | 7 +++-- data/repos/nodesource-26.conf | 7 +++-- lib/repo.sh | 8 +++++- tests/unit/system.bats | 17 ++++++++++++ 7 files changed, 93 insertions(+), 56 deletions(-) create mode 100644 data/keys/nodesource-rpm.asc diff --git a/data/keys/nodesource-rpm.asc b/data/keys/nodesource-rpm.asc new file mode 100644 index 0000000..0618885 --- /dev/null +++ b/data/keys/nodesource-rpm.asc @@ -0,0 +1,52 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +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 +oSVkzT/Mwp9zFVQu5w== +=drMP +-----END PGP PUBLIC KEY BLOCK----- diff --git a/data/keys/nodesource.asc b/data/keys/nodesource.asc index acf704c..ca8b338 100644 --- a/data/keys/nodesource.asc +++ b/data/keys/nodesource.asc @@ -25,53 +25,6 @@ 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 -d2WSQABvIKElZM0/zMKfcxVULuc= -=qxA1 +/4fBqejAWg== +=wh5k -----END PGP PUBLIC KEY BLOCK----- diff --git a/data/repos/nodesource-22.conf b/data/repos/nodesource-22.conf index 2f469d5..296175b 100644 --- a/data/repos/nodesource-22.conf +++ b/data/repos/nodesource-22.conf @@ -5,12 +5,15 @@ # Node.js 22.x from NodeSource (https://github.com/nodesource/distributions), one file per major version because # the major is part of the URL. The "nodistro" builds serve every Debian, Ubuntu and # RHEL-family release, each for amd64/x86_64 and arm64/aarch64. nodesource.asc holds the apt key -# (https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key) and the RPM key +# (https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key), nodesource-rpm.asc the RPM key # (https://rpm.nodesource.com/gpgkey/ns-operations-public.key), both fetched on 2026-10-13 from the URLs NodeSource's # setup scripts use. NodeSource publishes no fingerprint elsewhere, so they were checked against the signatures of the # apt repository and of the RPM packages. The RPM metadata is unsigned; the packages are signed. KEY=nodesource.asc -FINGERPRINTS=6F71F525282841EEDAF851B42F59B5F99B1BE0B4 242B813831AF09562B6C46F76B88DA4E3AF28A14 +FINGERPRINTS=6F71F525282841EEDAF851B42F59B5F99B1BE0B4 +# rpm imports only the first key of a file, and RHEL 9 rejects the SHA-1 self-signature of the apt key. +RPM_KEY=nodesource-rpm.asc +RPM_FINGERPRINTS=242B813831AF09562B6C46F76B88DA4E3AF28A14 DEB_URL=https://deb.nodesource.com/node_22.x DEB_SUITE=nodistro DEB_COMPONENTS=main diff --git a/data/repos/nodesource-24.conf b/data/repos/nodesource-24.conf index dc9fb57..695669e 100644 --- a/data/repos/nodesource-24.conf +++ b/data/repos/nodesource-24.conf @@ -5,12 +5,15 @@ # Node.js 24.x from NodeSource (https://github.com/nodesource/distributions), one file per major version because # the major is part of the URL. The "nodistro" builds serve every Debian, Ubuntu and # RHEL-family release, each for amd64/x86_64 and arm64/aarch64. nodesource.asc holds the apt key -# (https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key) and the RPM key +# (https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key), nodesource-rpm.asc the RPM key # (https://rpm.nodesource.com/gpgkey/ns-operations-public.key), both fetched on 2026-10-13 from the URLs NodeSource's # setup scripts use. NodeSource publishes no fingerprint elsewhere, so they were checked against the signatures of the # apt repository and of the RPM packages. The RPM metadata is unsigned; the packages are signed. KEY=nodesource.asc -FINGERPRINTS=6F71F525282841EEDAF851B42F59B5F99B1BE0B4 242B813831AF09562B6C46F76B88DA4E3AF28A14 +FINGERPRINTS=6F71F525282841EEDAF851B42F59B5F99B1BE0B4 +# rpm imports only the first key of a file, and RHEL 9 rejects the SHA-1 self-signature of the apt key. +RPM_KEY=nodesource-rpm.asc +RPM_FINGERPRINTS=242B813831AF09562B6C46F76B88DA4E3AF28A14 DEB_URL=https://deb.nodesource.com/node_24.x DEB_SUITE=nodistro DEB_COMPONENTS=main diff --git a/data/repos/nodesource-26.conf b/data/repos/nodesource-26.conf index ad77c83..78e95f7 100644 --- a/data/repos/nodesource-26.conf +++ b/data/repos/nodesource-26.conf @@ -5,12 +5,15 @@ # Node.js 26.x from NodeSource (https://github.com/nodesource/distributions), one file per major version because # the major is part of the URL. The "nodistro" builds serve every Debian, Ubuntu and # RHEL-family release, each for amd64/x86_64 and arm64/aarch64. nodesource.asc holds the apt key -# (https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key) and the RPM key +# (https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key), nodesource-rpm.asc the RPM key # (https://rpm.nodesource.com/gpgkey/ns-operations-public.key), both fetched on 2026-10-13 from the URLs NodeSource's # setup scripts use. NodeSource publishes no fingerprint elsewhere, so they were checked against the signatures of the # apt repository and of the RPM packages. The RPM metadata is unsigned; the packages are signed. KEY=nodesource.asc -FINGERPRINTS=6F71F525282841EEDAF851B42F59B5F99B1BE0B4 242B813831AF09562B6C46F76B88DA4E3AF28A14 +FINGERPRINTS=6F71F525282841EEDAF851B42F59B5F99B1BE0B4 +# rpm imports only the first key of a file, and RHEL 9 rejects the SHA-1 self-signature of the apt key. +RPM_KEY=nodesource-rpm.asc +RPM_FINGERPRINTS=242B813831AF09562B6C46F76B88DA4E3AF28A14 DEB_URL=https://deb.nodesource.com/node_26.x DEB_SUITE=nodistro DEB_COMPONENTS=main diff --git a/lib/repo.sh b/lib/repo.sh index 3632b8a..8cf3445 100644 --- a/lib/repo.sh +++ b/lib/repo.sh @@ -58,8 +58,14 @@ repo_add() { local name=$1 conf key fingerprints keyring conf="${LSS_ROOT}/data/repos/${name}.conf" [[ -r ${conf} ]] || die "Unknown repository ${name}." - key="${LSS_ROOT}/data/keys/$(_kv "${conf}" KEY)" + key=$(_kv "${conf}" KEY) fingerprints=$(_kv "${conf}" FINGERPRINTS) + # RPM_KEY and RPM_FINGERPRINTS: vendors that sign their RPM packages with another key than their apt repository. + if ! os_is_debian && [[ -n $(_kv "${conf}" RPM_KEY) ]]; then + key=$(_kv "${conf}" RPM_KEY) + fingerprints=$(_kv "${conf}" RPM_FINGERPRINTS) + fi + key="${LSS_ROOT}/data/keys/${key}" pkg_install_family 'gnupg ca-certificates' 'gnupg2 ca-certificates' repo_key_verify "${key}" "${fingerprints}" || die "Refusing repository ${name}: its key does not match the pinned fingerprints." diff --git a/tests/unit/system.bats b/tests/unit/system.bats index 7bd7d52..fd977b0 100644 --- a/tests/unit/system.bats +++ b/tests/unit/system.bats @@ -443,6 +443,23 @@ EOF grep -qxF 'gpgkey=file:///etc/pki/rpm-gpg/lss-demo.asc' "${writes}" } +@test "repo_add uses the RPM key of vendors that sign packages with another key" { + repo_root + printf 'RPMKEYDATA\n' >"${LSS_ROOT}/data/keys/demo-rpm.asc" + printf 'KEY=demo.asc\nFINGERPRINTS=BBBB2222\nRPM_KEY=demo-rpm.asc\nRPM_FINGERPRINTS=AAAA1111\n' >"${LSS_ROOT}/data/repos/split.conf" + printf 'DEB_URL=https://repo.example.invalid\nDEB_SUITE=stable\nDEB_COMPONENTS=main\n' >>"${LSS_ROOT}/data/repos/split.conf" + printf 'RPM_BASEURL=https://repo.example.invalid/el\n' >>"${LSS_ROOT}/data/repos/split.conf" + use_os rocky-9 + repo_add split + grep -qxF 'RPMKEYDATA' "${BATS_TEST_TMPDIR}/writes" + run ! grep -qxF 'KEYDATA' "${BATS_TEST_TMPDIR}/writes" + # The apt key is pinned to BBBB2222, which the key stub does not contain. + use_os debian-12 + run repo_add split + [ "${status}" -eq 1 ] + [[ ${output} == *"Refusing repository split"* ]] +} + @test "repo_add refuses unknown repositories, foreign keys and missing packages" { repo_root use_os debian-12 From 6fdb59d03c70a08bbe5a565f55fc84649257b677 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Wed, 14 Oct 2026 00:45:29 +0200 Subject: [PATCH 42/92] fix(vhost): delete the certificate of a removed site The certbot lineage stayed behind while its copy for the web server went, so the next tls verify reported the certificate as not installed. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- modules/web/vhost.sh | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/modules/web/vhost.sh b/modules/web/vhost.sh index 75b79d0..569609b 100644 --- a/modules/web/vhost.sh +++ b/modules/web/vhost.sh @@ -67,6 +67,19 @@ EOF return } +# _vhost_drop NAME: removes a site with its certificate, so certbot neither renews it nor leaves it behind. +_vhost_drop() { + local name=$1 + web_site_remove "${name}" + file_remove "${WEB_TLS_DIR}/${name}" + if [[ -d /etc/letsencrypt/live/${name} ]] && command -v certbot >/dev/null 2>&1; then + log_command certbot delete --non-interactive --cert-name "${name}" || + die "vhost: certbot could not delete the certificate ${name}." + files_note_change "certificate ${name} deleted" + fi + return +} + mod_configure() { local spec name previous current='' local -A wanted=() @@ -83,7 +96,7 @@ mod_configure() { # Sites that were removed from LSS_VHOST_SITES since the last run. previous=$(state_get vhost sites) for name in ${previous}; do - [[ -n ${wanted[${name}]+x} ]] || web_site_remove "${name}" + [[ -n ${wanted[${name}]+x} ]] || _vhost_drop "${name}" done state_set vhost sites "${current% }" if [[ $(files_changes_of vhost) != 0 ]]; then @@ -125,8 +138,7 @@ mod_verify() { mod_remove() { local name for name in $(state_get vhost sites); do - web_site_remove "${name}" - file_remove "${WEB_TLS_DIR}/${name}" + _vhost_drop "${name}" done web_reload return From 6ac87f25a10bce5bcec52c83cb0e13287ed33538 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Wed, 14 Oct 2026 00:45:30 +0200 Subject: [PATCH 43/92] fix(database): data directory before the sandbox, wait for reloads Ubuntu 26.04's MariaDB package creates /var/lib/mysql on the first start, too late for the sandbox's ReadWritePaths. The admin tools now wait up to 15 s for the web server's old workers to finish after a reload. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- modules/database/adminer.sh | 11 ++++++++--- modules/database/mariadb.sh | 4 +++- modules/database/pgadmin.sh | 9 +++++++-- modules/database/phpmyadmin.sh | 11 ++++++++--- 4 files changed, 26 insertions(+), 9 deletions(-) diff --git a/modules/database/adminer.sh b/modules/database/adminer.sh index f091c06..a76d8f7 100644 --- a/modules/database/adminer.sh +++ b/modules/database/adminer.sh @@ -98,11 +98,16 @@ _adminer_get() { } mod_verify() { - local page status first + local page status first try [[ $(sha256sum "${ADMINER_DIR}/index.php" | cut -d ' ' -f 1) == "$(fetch_entry "adminer-${ADMINER_VERSION}" | cut -d ' ' -f 1)" ]] || die "adminer: ${ADMINER_DIR}/index.php is not Adminer ${ADMINER_VERSION} as published." - page=$(_adminer_get "${ADMINER_PATH}/") || true - status=${page##*$'\n'} + # Right after a reload the old workers of the web server answer for a moment without the new admin path. + for ((try = 0; try < 15; try++)); do + page=$(_adminer_get "${ADMINER_PATH}/") || true + status=${page##*$'\n'} + [[ ${status} != 200 ]] || break + sleep 1 + done [[ ${status} == 200 ]] || die "adminer: the admin port answers ${status:-nothing} for /${ADMINER_PATH}/ instead of the login page." grep -qF 'auth[username]' <<<"${page}" || die "adminer: /${ADMINER_PATH}/ shows no login form." diff --git a/modules/database/mariadb.sh b/modules/database/mariadb.sh index 4996395..8e8a966 100644 --- a/modules/database/mariadb.sh +++ b/modules/database/mariadb.sh @@ -237,7 +237,9 @@ collation-server = utf8mb4_unicode_ci [client] default-character-set = utf8mb4 EOF - # The data directory is the only place the server writes outside /run and /var/log. + # The data directory is the only place the server writes outside /run and /var/log. Newer packages (Ubuntu 26.04) + # create it on the first start, but the sandbox needs it before. + [[ -d ${MARIADB_DATA} ]] || install -d -m 0750 -o mysql -g mysql "${MARIADB_DATA}" svc_sandbox "${MARIADB_UNIT}.service" "ReadWritePaths=${MARIADB_DATA}" [[ $(files_changes_of mariadb) == "${before}" ]] || changed=1 svc_enable "${MARIADB_UNIT}" diff --git a/modules/database/pgadmin.sh b/modules/database/pgadmin.sh index 600c167..64b3ae1 100644 --- a/modules/database/pgadmin.sh +++ b/modules/database/pgadmin.sh @@ -232,8 +232,13 @@ mod_verify() { sleep 2 done [[ ${answer} == PING ]] || die "pgadmin: pgAdmin does not answer on 127.0.0.1:${port}; see journalctl -u ${PGADMIN_UNIT}." - page=$(_pgadmin_get "${PGADMIN_PATH}/login") || true - status=${page##*$'\n'} + # Right after a reload the old workers of the web server answer for a moment without the new admin path. + for ((i = 0; i < 15; i++)); do + page=$(_pgadmin_get "${PGADMIN_PATH}/login") || true + status=${page##*$'\n'} + [[ ${status} != 200 ]] || break + sleep 1 + done [[ ${status} == 200 ]] || die "pgadmin: the admin port answers ${status:-nothing} for /${PGADMIN_PATH}/login instead of the login page." grep -qi 'pgadmin' <<<"${page}" || die "pgadmin: /${PGADMIN_PATH}/login is not pgAdmin's login page." diff --git a/modules/database/phpmyadmin.sh b/modules/database/phpmyadmin.sh index b5fef27..ccfadfb 100644 --- a/modules/database/phpmyadmin.sh +++ b/modules/database/phpmyadmin.sh @@ -193,12 +193,17 @@ _phpmyadmin_get() { } mod_verify() { - local page status + local page status try [[ -f ${PMA_DIR}/RELEASE-DATE-${PMA_VERSION} ]] || die "phpmyadmin: phpMyAdmin ${PMA_VERSION} is missing in ${PMA_DIR}." [[ ! -e ${PMA_DIR}/setup ]] || die "phpmyadmin: the setup wizard ${PMA_DIR}/setup must not exist." [[ $(stat -c '%a %U' "${PMA_CONFIG}") == '640 root' ]] || die "phpmyadmin: ${PMA_CONFIG} must be mode 640, owned by root." - page=$(_phpmyadmin_get "${PMA_PATH}/") || true - status=${page##*$'\n'} + # Right after a reload the old workers of the web server answer for a moment without the new admin path. + for ((try = 0; try < 15; try++)); do + page=$(_phpmyadmin_get "${PMA_PATH}/") || true + status=${page##*$'\n'} + [[ ${status} != 200 ]] || break + sleep 1 + done [[ ${status} == 200 ]] || die "phpmyadmin: the admin port answers ${status:-nothing} for /${PMA_PATH}/ instead of the login page." grep -qF 'name="pma_username"' <<<"${page}" || From ed41adf4acbd655c69e4e7864db6a8347aa24cd0 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Wed, 14 Oct 2026 00:45:32 +0200 Subject: [PATCH 44/92] test(integration): Pebble for profiles with tls, robust diagnostics Diagnostics now run without errexit, with stdin from /dev/null, and show the journal of every failed unit. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- tests/integration/run.sh | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/tests/integration/run.sh b/tests/integration/run.sh index 7d7c791..2c5658c 100755 --- a/tests/integration/run.sh +++ b/tests/integration/run.sh @@ -103,11 +103,19 @@ install_again() { } stop_target() { - local status=$? + local status=$? diagnostics=0 + # Diagnostics must never end the cleanup early. + set +e if [[ ${status} -ne 0 ]]; then - group_start "Diagnostics" - in_target 'tail -n 200 /var/log/lss/*.log 2>/dev/null; systemctl --failed --no-pager; journalctl -p warning -n 80 --no-pager' || true - group_end + echo "Diagnostics (exit status ${status}):" + in_target 'tail -n 200 /var/log/lss/*.log 2>/dev/null + systemctl --failed --no-pager + for unit in $(systemctl list-units --failed --plain --no-legend | cut -d " " -f 1); do + echo "--- ${unit}" + journalctl -u "${unit}" -n 40 --no-pager + done + journalctl -p warning -n 80 --no-pager' &1 || diagnostics=$? + echo "Diagnostics finished (status ${diagnostics})." fi if [[ ${system} == vm-* ]]; then vm_stop @@ -156,6 +164,11 @@ start_target if [[ $1 == --profile ]]; then profile=${2:?usage: run.sh SYSTEM|host --profile NAME [LYNIS_MIN_INDEX]} + if grep -qE '^LSS_MODULES=.*tls' "profiles/${profile}.conf"; then + group_start "${system}: Pebble (ACME test server)" + pebble_start + group_end + fi group_start "${system}: profile ${profile}" before=$(resources_snapshot) started=${SECONDS} From 9882fa05bcff77476822d9f147180117c83a7d80 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Wed, 14 Oct 2026 01:22:51 +0200 Subject: [PATCH 45/92] test(web): cover sites, admin paths, certificates and reload for all web servers The unit tests now exercise every function of lib/web.sh for nginx, Apache and Caddy on the Debian and RHEL families: generated site and admin configurations, refusals, idempotent rewrites, the certificate helpers, web_prepare, web_test, web_reload and the fail2ban and CrowdSec wiring. certbot's live directory and the fail2ban and CrowdSec file paths become WEB_* variables (defaults unchanged), so the tests can point them at their own directory. New stubs: apachectl, caddy, certbot, cscli, fail2ban-client, getenforce, getent, getsebool, groupadd, setsebool, usermod. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- lib/web.sh | 38 +- tests/fixtures/bin/apachectl | 8 + tests/fixtures/bin/caddy | 8 + tests/fixtures/bin/certbot | 8 + tests/fixtures/bin/cscli | 8 + tests/fixtures/bin/fail2ban-client | 8 + tests/fixtures/bin/getenforce | 8 + tests/fixtures/bin/getent | 10 + tests/fixtures/bin/getsebool | 8 + tests/fixtures/bin/groupadd | 8 + tests/fixtures/bin/setsebool | 8 + tests/fixtures/bin/usermod | 8 + tests/unit/web.bats | 791 ++++++++++++++++++++++++++++- 13 files changed, 900 insertions(+), 19 deletions(-) create mode 100755 tests/fixtures/bin/apachectl create mode 100755 tests/fixtures/bin/caddy create mode 100755 tests/fixtures/bin/certbot create mode 100755 tests/fixtures/bin/cscli create mode 100755 tests/fixtures/bin/fail2ban-client create mode 100755 tests/fixtures/bin/getenforce create mode 100755 tests/fixtures/bin/getent create mode 100755 tests/fixtures/bin/getsebool create mode 100755 tests/fixtures/bin/groupadd create mode 100755 tests/fixtures/bin/setsebool create mode 100755 tests/fixtures/bin/usermod diff --git a/lib/web.sh b/lib/web.sh index 1ff0530..30e1a0d 100644 --- a/lib/web.sh +++ b/lib/web.sh @@ -33,6 +33,12 @@ WEB_ADMIN_CADDY_AUTH=/etc/lss/web/admin-auth.caddy WEB_CERT_HOOK=/etc/letsencrypt/renewal-hooks/deploy/lss-deploy # CA certificate of a private ACME server (LSS_TLS_CA_BUNDLE, for example Pebble or step-ca); certbot trusts it. WEB_ACME_CA=/etc/letsencrypt/lss-acme-ca.pem +# certbot's certificate lineages (one directory per certificate name). +WEB_CERT_LIVE=/etc/letsencrypt/live +# The fail2ban jails and the CrowdSec log acquisition for the installed web server. +WEB_FAIL2BAN_JAIL=/etc/fail2ban/jail.d/lss-web.local +WEB_FAIL2BAN_FILTER=/etc/fail2ban/filter.d/lss-caddy-auth.conf +WEB_CROWDSEC_ACQUIS=/etc/crowdsec/acquis.d/lss-web.yaml # shellcheck disable=SC2034 # read by the web server and haproxy modules WEB_TLS_CIPHERS=ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305 @@ -149,7 +155,7 @@ web_cert_has_ocsp() { web_default_cert() { local dir="${WEB_TLS_DIR}/default" name tmp # The tls module's certificate for the host names (lineage "default") takes this place; certbot renews it. - [[ ! -s /etc/letsencrypt/live/default/fullchain.pem || ! -s ${dir}/fullchain.pem ]] || return 0 + [[ ! -s ${WEB_CERT_LIVE}/default/fullchain.pem || ! -s ${dir}/fullchain.pem ]] || return 0 if [[ -s ${dir}/fullchain.pem && -s ${dir}/privkey.pem ]] && openssl x509 -checkend $((30 * 86400)) -noout -in "${dir}/fullchain.pem" >/dev/null 2>&1; then return 0 @@ -196,7 +202,7 @@ web_cert_domains() { web_cert_issue() { local name=$1 live wanted current shift - live="/etc/letsencrypt/live/${name}/fullchain.pem" + live="${WEB_CERT_LIVE}/${name}/fullchain.pem" wanted=$(printf '%s\n' "$@" | sort -u) current='' [[ ! -s ${live} ]] || current=$(web_cert_domains "${live}") @@ -214,7 +220,7 @@ web_cert_issue() { files_note_change "certificate ${name}" fi if ! cmp -s "${live}" "${WEB_TLS_DIR}/${name}/fullchain.pem"; then - RENEWED_LINEAGE="/etc/letsencrypt/live/${name}" "${WEB_CERT_HOOK}" + RENEWED_LINEAGE="${WEB_CERT_LIVE}/${name}" "${WEB_CERT_HOOK}" files_note_change "installed certificate ${name}" fi return @@ -649,9 +655,9 @@ web_ids_configure() { local server logs server=$(web_server) if [[ -z ${server} ]]; then - file_remove /etc/fail2ban/jail.d/lss-web.local - file_remove /etc/fail2ban/filter.d/lss-caddy-auth.conf - file_remove /etc/crowdsec/acquis.d/lss-web.yaml + file_remove "${WEB_FAIL2BAN_JAIL}" + file_remove "${WEB_FAIL2BAN_FILTER}" + file_remove "${WEB_CROWDSEC_ACQUIS}" return 0 fi logs=$(web_log_dir "${server}") @@ -667,15 +673,15 @@ web_ids_configure() { # web_ids_remove: drops the web server's jails and log acquisition (when the web server is removed). web_ids_remove() { - if [[ -e /etc/fail2ban/jail.d/lss-web.local ]]; then - file_remove /etc/fail2ban/jail.d/lss-web.local - file_remove /etc/fail2ban/filter.d/lss-caddy-auth.conf + if [[ -e ${WEB_FAIL2BAN_JAIL} ]]; then + file_remove "${WEB_FAIL2BAN_JAIL}" + file_remove "${WEB_FAIL2BAN_FILTER}" if svc_is_active fail2ban; then svc_restart fail2ban fi fi - if [[ -e /etc/crowdsec/acquis.d/lss-web.yaml ]]; then - file_remove /etc/crowdsec/acquis.d/lss-web.yaml + if [[ -e ${WEB_CROWDSEC_ACQUIS} ]]; then + file_remove "${WEB_CROWDSEC_ACQUIS}" if svc_is_active crowdsec; then svc_restart crowdsec fi @@ -693,7 +699,7 @@ _web_fail2ban() { [[ -e ${error} ]] || install -m 0640 /dev/null "${error}" case ${server} in nginx) - file_write 0644 root:root /etc/fail2ban/jail.d/lss-web.local <".*"status":401\b @@ -729,7 +735,7 @@ ignoreregex = datepattern = "ts":{EPOCH} EOF [[ ${LSS_FILE_CHANGED} == 0 ]] || changed=1 - file_write 0644 root:root /etc/fail2ban/jail.d/lss-web.local <>"${STUB_LOG:-/dev/null}" +exit "${STUB_EXIT_certbot:-0}" diff --git a/tests/fixtures/bin/cscli b/tests/fixtures/bin/cscli new file mode 100755 index 0000000..847a278 --- /dev/null +++ b/tests/fixtures/bin/cscli @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# shellcheck source=tests/fixtures/bin/_stub.bash +source "$(dirname "$0")/_stub.bash" +stub_main cscli "$@" diff --git a/tests/fixtures/bin/fail2ban-client b/tests/fixtures/bin/fail2ban-client new file mode 100755 index 0000000..1881e8b --- /dev/null +++ b/tests/fixtures/bin/fail2ban-client @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# shellcheck source=tests/fixtures/bin/_stub.bash +source "$(dirname "$0")/_stub.bash" +stub_main fail2ban-client "$@" diff --git a/tests/fixtures/bin/getenforce b/tests/fixtures/bin/getenforce new file mode 100755 index 0000000..7df0571 --- /dev/null +++ b/tests/fixtures/bin/getenforce @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# shellcheck source=tests/fixtures/bin/_stub.bash +source "$(dirname "$0")/_stub.bash" +stub_main getenforce "$@" diff --git a/tests/fixtures/bin/getent b/tests/fixtures/bin/getent new file mode 100755 index 0000000..924b1c4 --- /dev/null +++ b/tests/fixtures/bin/getent @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# getent stub: "getent DATABASE KEY" succeeds for the DATABASE:KEY pairs listed in $STUB_GETENT. +printf 'getent %s\n' "$*" >>"${STUB_LOG:-/dev/null}" +[[ " ${STUB_GETENT:-} " == *" $1:$2 "* ]] || exit 2 +printf '%s:x:0:\n' "$2" +exit 0 diff --git a/tests/fixtures/bin/getsebool b/tests/fixtures/bin/getsebool new file mode 100755 index 0000000..cb3313f --- /dev/null +++ b/tests/fixtures/bin/getsebool @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# shellcheck source=tests/fixtures/bin/_stub.bash +source "$(dirname "$0")/_stub.bash" +stub_main getsebool "$@" diff --git a/tests/fixtures/bin/groupadd b/tests/fixtures/bin/groupadd new file mode 100755 index 0000000..3b7125b --- /dev/null +++ b/tests/fixtures/bin/groupadd @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# shellcheck source=tests/fixtures/bin/_stub.bash +source "$(dirname "$0")/_stub.bash" +stub_main groupadd "$@" diff --git a/tests/fixtures/bin/setsebool b/tests/fixtures/bin/setsebool new file mode 100755 index 0000000..e2dec77 --- /dev/null +++ b/tests/fixtures/bin/setsebool @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# shellcheck source=tests/fixtures/bin/_stub.bash +source "$(dirname "$0")/_stub.bash" +stub_main setsebool "$@" diff --git a/tests/fixtures/bin/usermod b/tests/fixtures/bin/usermod new file mode 100755 index 0000000..38f5093 --- /dev/null +++ b/tests/fixtures/bin/usermod @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# shellcheck source=tests/fixtures/bin/_stub.bash +source "$(dirname "$0")/_stub.bash" +stub_main usermod "$@" diff --git a/tests/unit/web.bats b/tests/unit/web.bats index e87a55f..af0a26a 100644 --- a/tests/unit/web.bats +++ b/tests/unit/web.bats @@ -10,14 +10,41 @@ setup() { load helpers lss_test_setup use_os debian-13 + # As in lss: "generator | file_write" sets LSS_FILE_CHANGED in this shell. + shopt -s lastpipe + export TMPDIR=${BATS_TEST_TMPDIR} WEB_TLS_DIR=${BATS_TEST_TMPDIR}/tls mkdir -p "${WEB_TLS_DIR}/default" + WEB_ACME_ROOT=${BATS_TEST_TMPDIR}/www/acme + WEB_DEFAULT_ROOT=${BATS_TEST_TMPDIR}/www/default + WEB_ACME_CA=${BATS_TEST_TMPDIR}/letsencrypt/ca.pem + WEB_CERT_LIVE=${BATS_TEST_TMPDIR}/letsencrypt/live + WEB_CERT_HOOK=${BATS_TEST_TMPDIR}/letsencrypt/deploy-hook + WEB_ADMIN_HTPASSWD=${BATS_TEST_TMPDIR}/web/admin.htpasswd + WEB_ADMIN_CADDY_AUTH=${BATS_TEST_TMPDIR}/web/admin-auth.caddy + WEB_FAIL2BAN_JAIL=${BATS_TEST_TMPDIR}/fail2ban/jail.d/lss-web.local + WEB_FAIL2BAN_FILTER=${BATS_TEST_TMPDIR}/fail2ban/filter.d/lss-caddy-auth.conf + WEB_CROWDSEC_ACQUIS=${BATS_TEST_TMPDIR}/crowdsec/acquis.d/lss-web.yaml + WRITES=${BATS_TEST_TMPDIR}/writes NGINX_VERSION=1.26.3 } +# nginx stub: "nginx -v" reports NGINX_VERSION, "nginx -t" prints $STUB_OUT_nginx and exits with $STUB_EXIT_nginx. # shellcheck disable=SC2329 # called by the functions under test nginx() { + printf 'nginx %s\n' "$*" >>"${STUB_LOG}" + if [[ $1 == -t ]]; then + [[ -z ${STUB_OUT_nginx:-} ]] || printf '%s\n' "${STUB_OUT_nginx}" >&2 + return "${STUB_EXIT_nginx:-0}" + fi printf 'nginx version: nginx/%s\n' "${NGINX_VERSION}" >&2 + return +} + +# shellcheck disable=SC2329 # called by the functions under test +hostname() { + printf 'web.example.com\n' + return } new_cert() { @@ -32,6 +59,369 @@ new_cert() { -addext "subjectAltName=${san%,}" -keyout "${dir}/privkey.pem" -out "${dir}/fullchain.pem" 2>/dev/null } +# Replaces openssl for one test (the coverage image has none): "req" writes the -keyout and -out files and exits +# with $STUB_EXIT_openssl_req, "x509 -checkend" exits with $STUB_EXIT_openssl_checkend, "-ocsp_uri" prints +# $STUB_OCSP_URI, "-ext subjectAltName" prints the names in $STUB_SAN and "passwd" a SHA-512 crypt hash. +fake_openssl() { + # shellcheck disable=SC2329 # called by the functions under test + openssl() { + local arg previous='' + printf 'openssl %s\n' "$*" >>"${STUB_LOG}" + case $1 in + req) + for arg in "$@"; do + case ${previous} in + -keyout | -out) printf 'PEM %s\n' "${previous}" >"${arg}" ;; + *) ;; + esac + previous=${arg} + done + return "${STUB_EXIT_openssl_req:-0}" + ;; + passwd) printf '$6$salt$hash\n' ;; + *) + case " $* " in + *' -checkend '*) return "${STUB_EXIT_openssl_checkend:-0}" ;; + *' -ocsp_uri '*) printf '%s\n' "${STUB_OCSP_URI:-}" ;; + *' subjectAltName '*) printf 'X509v3 Subject Alternative Name: \n %s\n' "${STUB_SAN:-}" ;; + *) ;; + esac + ;; + esac + return 0 + } + return +} + +# Lets the writing functions run without root: file_write writes as the test user and records "== MODE OWNER DEST" +# in $WRITES, install drops -o and -g (and is recorded in the stub log), and the web server configuration lives +# below the test directory. +as_test_user() { + eval "_lss_file_write() $(declare -f file_write | tail -n +2)" + # shellcheck disable=SC2329 # called by the functions under test + file_write() { + printf '== %s %s %s\n' "$1" "$2" "$3" >>"${WRITES}" + _lss_file_write "$1" "${OWNER}" "$3" + return + } + # shellcheck disable=SC2329 # called by the functions under test + install() { + local -a args=() + printf 'install %s\n' "$*" >>"${STUB_LOG}" + while [[ $# -gt 0 ]]; do + case $1 in + -o | -g) shift ;; + *) args+=("$1") ;; + esac + shift + done + command install "${args[@]}" + return + } + # shellcheck disable=SC2329 # called by the functions under test + web_conf_dir() { + printf '%s/conf/%s' "${BATS_TEST_TMPDIR}" "$1" + return + } + return +} + +# The logs of the web servers below the test directory as well. +test_log_dir() { + # shellcheck disable=SC2329 # called by the functions under test + web_log_dir() { + printf '%s/log/%s' "${BATS_TEST_TMPDIR}" "$1" + return + } + return +} + +use_server() { + state_set "$1" status installed + return +} + +# A deploy hook that only records the lineage it was called for. +cert_hook() { + mkdir -p "$(dirname "${WEB_CERT_HOOK}")" + cat >"${WEB_CERT_HOOK}" <<'EOF' +#!/usr/bin/env bash +printf 'hook %s\n' "${RENEWED_LINEAGE}" >>"${STUB_LOG}" +EOF + chmod +x "${WEB_CERT_HOOK}" + return +} + +# --- servers, users and paths ----------------------------------------------------------------------------------- + +@test "the installed web server is found, and nothing when none is installed" { + [ -z "$(web_server)" ] + use_server caddy + [ "$(web_server)" = caddy ] + use_server apache + [ "$(web_server)" = apache ] + use_server nginx + [ "$(web_server)" = nginx ] +} + +@test "units, users, configuration and log directories follow the Debian family" { + use_os debian-12 + [ "$(web_unit nginx)" = nginx ] + [ "$(web_unit apache)" = apache2 ] + [ "$(web_unit caddy)" = caddy ] + [ "$(web_user nginx)" = www-data ] + [ "$(web_user apache)" = www-data ] + [ "$(web_user caddy)" = caddy ] + [ "$(web_conf_dir nginx)" = /etc/nginx/lss ] + [ "$(web_conf_dir apache)" = /etc/apache2/lss ] + [ "$(web_conf_dir caddy)" = /etc/caddy/lss ] + [ "$(web_log_dir nginx)" = /var/log/nginx ] + [ "$(web_log_dir apache)" = /var/log/apache2 ] + [ "$(web_log_dir caddy)" = /var/log/caddy ] + [ "$(web_log_file nginx error)" = /var/log/nginx/error.log ] + [ "$(web_log_file apache access)" = /var/log/apache2/access.log ] + [ "$(web_log_file caddy access)" = /var/log/caddy/access.log ] +} + +@test "units, users, configuration and log directories follow the RHEL family" { + use_os rocky-9 + [ "$(web_unit nginx)" = nginx ] + [ "$(web_unit apache)" = httpd ] + [ "$(web_unit caddy)" = caddy ] + [ "$(web_user nginx)" = nginx ] + [ "$(web_user apache)" = apache ] + [ "$(web_user caddy)" = caddy ] + [ "$(web_conf_dir nginx)" = /etc/nginx/lss ] + [ "$(web_conf_dir apache)" = /etc/httpd/lss ] + [ "$(web_conf_dir caddy)" = /etc/caddy/lss ] + [ "$(web_log_dir apache)" = /var/log/httpd ] + [ "$(web_log_file apache access)" = /var/log/httpd/access_log ] + [ "$(web_log_file apache error)" = /var/log/httpd/error_log ] + [ "$(web_log_file nginx access)" = /var/log/nginx/access.log ] +} + +@test "the web group is created once and gets every existing web server user" { + export STUB_GETENT='passwd:www-data' + # shellcheck disable=SC2329 # called by web_group_ensure + id() { + printf '%s\n' "${TEST_GROUPS}" + return + } + TEST_GROUPS=www-data + web_group_ensure www-data ghost + grep -qxF "groupadd --system ${WEB_GROUP}" "${STUB_LOG}" + grep -qxF "usermod --append --groups ${WEB_GROUP} www-data" "${STUB_LOG}" + run ! grep -q 'usermod.*ghost' "${STUB_LOG}" + [ "$(files_changes_of core)" = 2 ] + : >"${STUB_LOG}" + export STUB_GETENT="group:${WEB_GROUP} passwd:www-data" + TEST_GROUPS="www-data ${WEB_GROUP}" + web_group_ensure www-data + run ! grep -qE '^(groupadd|usermod)' "${STUB_LOG}" + [ "$(files_changes_of core)" = 2 ] +} + +# --- certificates ----------------------------------------------------------------------------------------------- + +@test "a site points at its placeholder, a copy of the default certificate, until its own one arrives" { + as_test_user + printf 'default chain\n' >"${WEB_TLS_DIR}/default/fullchain.pem" + printf 'default key\n' >"${WEB_TLS_DIR}/default/privkey.pem" + [ "$(web_cert_dir shop)" = "${WEB_TLS_DIR}/default" ] + web_cert_placeholder shop + [ "$(web_cert_dir shop)" = "${WEB_TLS_DIR}/shop" ] + cmp "${WEB_TLS_DIR}/default/fullchain.pem" "${WEB_TLS_DIR}/shop/fullchain.pem" + cmp "${WEB_TLS_DIR}/default/privkey.pem" "${WEB_TLS_DIR}/shop/privkey.pem" + grep -qxF "install -m 0640 -o root -g ${WEB_GROUP} ${WEB_TLS_DIR}/default/privkey.pem ${WEB_TLS_DIR}/shop/privkey.pem" "${STUB_LOG}" + [ "$(files_changes_of core)" = 1 ] + # The real certificate is never replaced by the placeholder again. + printf 'real chain\n' >"${WEB_TLS_DIR}/shop/fullchain.pem" + web_cert_placeholder shop + grep -qxF 'real chain' "${WEB_TLS_DIR}/shop/fullchain.pem" + [ "$(files_changes_of core)" = 1 ] +} + +@test "the default certificate is self-signed for the host name with a key for the web group" { + as_test_user + fake_openssl + web_default_cert + grep -q '^openssl req -x509 .*-subj /CN=web.example.com -addext subjectAltName=DNS:web.example.com ' "${STUB_LOG}" + [ -s "${WEB_TLS_DIR}/default/fullchain.pem" ] + [ -s "${WEB_TLS_DIR}/default/privkey.pem" ] + grep -q "^install -m 0640 -o root -g ${WEB_GROUP} .*/privkey.pem ${WEB_TLS_DIR}/default/privkey.pem$" "${STUB_LOG}" + grep -q "^install -m 0644 -o root -g root .*/fullchain.pem ${WEB_TLS_DIR}/default/fullchain.pem$" "${STUB_LOG}" + [ "$(files_changes_of core)" = 1 ] +} + +@test "a valid default certificate is kept, one that expires within 30 days is renewed" { + as_test_user + fake_openssl + printf 'chain\n' >"${WEB_TLS_DIR}/default/fullchain.pem" + printf 'key\n' >"${WEB_TLS_DIR}/default/privkey.pem" + web_default_cert + grep -q "^openssl x509 -checkend $((30 * 86400)) " "${STUB_LOG}" + run ! grep -q '^openssl req' "${STUB_LOG}" + [ "$(files_changes_of core)" = 0 ] + export STUB_EXIT_openssl_checkend=1 + web_default_cert + grep -q '^openssl req' "${STUB_LOG}" + [ "$(files_changes_of core)" = 1 ] +} + +@test "the default certificate of the tls module is left to certbot" { + fake_openssl + mkdir -p "${WEB_CERT_LIVE}/default" + printf 'issued\n' >"${WEB_CERT_LIVE}/default/fullchain.pem" + printf 'issued\n' >"${WEB_TLS_DIR}/default/fullchain.pem" + web_default_cert + [ ! -s "${STUB_LOG}" ] +} + +@test "a failed self-signed certificate stops the run" { + fake_openssl + export STUB_EXIT_openssl_req=1 + run web_default_cert + [ "${status}" -eq 1 ] + [[ ${output} == *'web: could not create the self-signed default certificate.'* ]] +} + +@test "the names of a certificate are read from its subjectAltName" { + fake_openssl + export STUB_SAN='DNS:www.shop.example.com, DNS:shop.example.com, DNS:shop.example.com' + [ "$(web_cert_domains "${WEB_TLS_DIR}/shop/fullchain.pem")" = $'shop.example.com\nwww.shop.example.com' ] +} + +@test "a certificate is requested once for its names and through the private CA when there is one" { + fake_openssl + cert_hook + printf 'ca\n' >"${WEB_ACME_CA}" + web_cert_issue shop shop.example.com www.shop.example.com + grep -qxF "certbot REQUESTS_CA_BUNDLE=${WEB_ACME_CA} certonly --cert-name shop -d shop.example.com -d www.shop.example.com" "${STUB_LOG}" + grep -qxF "hook ${WEB_CERT_LIVE}/shop" "${STUB_LOG}" + [ "$(files_changes_of core)" = 2 ] + # What certbot and the deploy hook leave behind. + mkdir -p "${WEB_CERT_LIVE}/shop" "${WEB_TLS_DIR}/shop" + printf 'chain 1\n' >"${WEB_CERT_LIVE}/shop/fullchain.pem" + cp "${WEB_CERT_LIVE}/shop/fullchain.pem" "${WEB_TLS_DIR}/shop/fullchain.pem" + export STUB_SAN='DNS:shop.example.com, DNS:www.shop.example.com' + : >"${STUB_LOG}" + web_cert_issue shop www.shop.example.com shop.example.com + run ! grep -qE '^(certbot|hook)' "${STUB_LOG}" + [ "$(files_changes_of core)" = 2 ] + # New names: a new request, without the CA bundle once the CA is gone; the copy is current, so no hook. + rm "${WEB_ACME_CA}" + web_cert_issue shop shop.example.com api.example.com + grep -qxF 'certbot certonly --cert-name shop -d shop.example.com -d api.example.com' "${STUB_LOG}" + run ! grep -q '^hook' "${STUB_LOG}" + [ "$(files_changes_of core)" = 3 ] +} + +@test "a renewed certificate that differs from the installed copy goes through the deploy hook" { + fake_openssl + cert_hook + mkdir -p "${WEB_CERT_LIVE}/shop" "${WEB_TLS_DIR}/shop" + printf 'chain 2\n' >"${WEB_CERT_LIVE}/shop/fullchain.pem" + printf 'chain 1\n' >"${WEB_TLS_DIR}/shop/fullchain.pem" + export STUB_SAN='DNS:shop.example.com' + web_cert_issue shop shop.example.com + run ! grep -q '^certbot' "${STUB_LOG}" + grep -qxF "hook ${WEB_CERT_LIVE}/shop" "${STUB_LOG}" + [ "$(files_changes_of core)" = 1 ] +} + +@test "a certificate the ACME server refuses stops the run" { + export STUB_EXIT_certbot=1 + run web_cert_issue shop shop.example.com + [ "${status}" -eq 1 ] + [[ ${output} == *'tls: the ACME server refused a certificate for shop.example.com;'* ]] +} + +# --- preparation, test and reload ------------------------------------------------------------------------------- + +@test "preparing a web server puts its user in the web group and creates the default certificate and directories" { + as_test_user + fake_openssl + use_os rocky-9 + export STUB_GETENT='passwd:nginx' + web_prepare nginx + grep -qxF "groupadd --system ${WEB_GROUP}" "${STUB_LOG}" + grep -qxF "usermod --append --groups ${WEB_GROUP} nginx" "${STUB_LOG}" + [ -s "${WEB_TLS_DIR}/default/fullchain.pem" ] + [ -d "${WEB_ACME_ROOT}/.well-known/acme-challenge" ] + [ -d "${WEB_DEFAULT_ROOT}" ] + [ -d "${BATS_TEST_TMPDIR}/conf/nginx/sites" ] + [ -d "${BATS_TEST_TMPDIR}/conf/nginx/admin" ] + use_os debian-12 + export STUB_GETENT="group:${WEB_GROUP} passwd:www-data" + web_prepare apache + grep -qxF "usermod --append --groups ${WEB_GROUP} www-data" "${STUB_LOG}" + [ -d "${BATS_TEST_TMPDIR}/conf/apache/sites" ] + [ "$(grep -c '^openssl req' "${STUB_LOG}")" = 1 ] +} + +@test "each web server checks its configuration with its own tool" { + web_test nginx + grep -qxF 'nginx -t -q' "${STUB_LOG}" + web_test apache + grep -qxF 'apachectl -t' "${STUB_LOG}" + web_test caddy + grep -qxF 'caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile' "${STUB_LOG}" +} + +@test "a configuration that does not load fails the test and shows the problem" { + export STUB_EXIT_apachectl=1 STUB_OUT_apachectl='AH00526: Syntax error on line 3' + run web_test apache + [ "${status}" -eq 1 ] + [[ ${output} == *'AH00526: Syntax error on line 3'* ]] + export STUB_EXIT_nginx=1 STUB_OUT_nginx='unknown directive "lss"' + run web_test nginx + [ "${status}" -eq 1 ] + [[ ${output} == *'unknown directive "lss"'* ]] +} + +@test "reload tests the configuration and reloads only a running web server" { + web_reload + [ ! -s "${STUB_LOG}" ] + use_server nginx + export STUB_ACTIVE_UNITS=nginx + web_reload + grep -qxF 'nginx -t -q' "${STUB_LOG}" + grep -qxF 'systemctl reload-or-restart nginx' "${STUB_LOG}" + state_forget nginx + use_os rocky-9 + use_server apache + : >"${STUB_LOG}" + web_reload + grep -qxF 'apachectl -t' "${STUB_LOG}" + grep -qxF 'systemctl is-active --quiet httpd' "${STUB_LOG}" + run ! grep -q reload-or-restart "${STUB_LOG}" +} + +@test "a configuration that does not load is not reloaded" { + use_server caddy + export STUB_ACTIVE_UNITS=caddy STUB_EXIT_caddy=1 + run web_reload + [ "${status}" -eq 1 ] + [[ ${output} == *'web: the caddy configuration does not load; see above.'* ]] + run ! grep -q reload-or-restart "${STUB_LOG}" +} + +@test "every web server sends the same six security headers in its own syntax" { + run web_headers nginx + [ "${#lines[@]}" -eq 6 ] + [ "${lines[0]}" = $'\tadd_header Strict-Transport-Security "max-age=63072000" always;' ] + [ "${lines[5]}" = $'\tadd_header Cross-Origin-Opener-Policy "same-origin" always;' ] + run web_headers apache + [ "${#lines[@]}" -eq 6 ] + [ "${lines[1]}" = $'\tHeader always set X-Content-Type-Options "nosniff"' ] + run web_headers caddy + [ "${#lines[@]}" -eq 6 ] + [ "${lines[2]}" = $'\t\tX-Frame-Options "SAMEORIGIN"' ] + [[ ${output} == *'Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()"'* ]] +} + +# --- sites ------------------------------------------------------------------------------------------------------ + @test "nginx 1.25.1 and later get the http2 directive, older versions the listen flag" { NGINX_VERSION=1.26.3 web_nginx_http2_directive @@ -41,6 +431,8 @@ new_cert() { NGINX_VERSION=1.22.1 run ! web_nginx_http2_directive [ "$(web_nginx_ssl_flags)" = 'ssl http2' ] + NGINX_VERSION=unknown + run ! web_nginx_http2_directive } @test "an nginx PHP site redirects port 80, keeps ACME challenges there and passes PHP to the lss pool" { @@ -69,6 +461,72 @@ new_cert() { [[ ${output} != *ssl_stapling* ]] } +@test "certificates with an OCSP responder get stapling on nginx and Apache" { + fake_openssl + export STUB_OCSP_URI=http://ocsp.example.com + run _web_tls_nginx "${WEB_TLS_DIR}/default" + [[ ${output} == *$'\tssl_stapling on;\n\tssl_stapling_verify on;'* ]] + run _web_site_apache blog static /var/www/blog blog.example.com + [[ ${output} == *$'\tSSLUseStapling on'* ]] +} + +@test "nginx sites are written once per change for static files and proxies, on IPv4 and IPv6" { + as_test_user + use_server nginx + # shellcheck disable=SC2329 # called by the functions under test + web_ipv6() { return 0; } + local conf=${BATS_TEST_TMPDIR}/conf/nginx/sites + web_site_write blog static /var/www/blog blog.example.com + [ "${LSS_FILE_CHANGED}" = 1 ] + grep -qxF "== 0644 root:root ${conf}/blog.conf" "${WRITES}" + grep -qxF '# Managed by lss (site blog).' "${conf}/blog.conf" + grep -qxF $'\tlisten [::]:80;' "${conf}/blog.conf" + grep -qxF $'\tlisten [::]:443 ssl;' "${conf}/blog.conf" + grep -qxF $'\troot /var/www/blog;' "${conf}/blog.conf" + grep -qxF $'\tindex index.html;' "${conf}/blog.conf" + grep -qxF $'\t\ttry_files $uri $uri/ =404;' "${conf}/blog.conf" + grep -qxF $'\taccess_log /var/log/nginx/access.log;' "${conf}/blog.conf" + web_site_write blog static /var/www/blog blog.example.com + [ "${LSS_FILE_CHANGED}" = 0 ] + NGINX_VERSION=1.22.1 + web_site_write app proxy http://127.0.0.1:3000 app.example.com + [ "${LSS_FILE_CHANGED}" = 1 ] + grep -qxF $'\tlisten 443 ssl http2;' "${conf}/app.conf" + run ! grep -q 'http2 on;' "${conf}/app.conf" + grep -qxF $'\t\tproxy_pass http://127.0.0.1:3000;' "${conf}/app.conf" + grep -qxF $'\t\tinclude /etc/nginx/lss/proxy.conf;' "${conf}/app.conf" + [ "$(files_changes_of core)" = 2 ] +} + +@test "Apache sites serve static files and PHP and log where the OS family expects it" { + as_test_user + use_os rocky-9 + use_server apache + local conf=${BATS_TEST_TMPDIR}/conf/apache/sites + web_site_write blog static /var/www/blog blog.example.com + grep -qxF "== 0644 root:root ${conf}/blog.conf" "${WRITES}" + grep -qxF $'\tServerName blog.example.com' "${conf}/blog.conf" + run ! grep -q ServerAlias "${conf}/blog.conf" + grep -qxF $'\tDocumentRoot /var/www/blog' "${conf}/blog.conf" + grep -qxF $'\t\tOptions -Indexes +FollowSymLinks' "${conf}/blog.conf" + grep -qxF $'\tErrorLog /var/log/httpd/error_log' "${conf}/blog.conf" + grep -qxF $'\tCustomLog /var/log/httpd/access_log combined' "${conf}/blog.conf" + grep -qxF $'\tSSLCertificateFile '"${WEB_TLS_DIR}/default/fullchain.pem" "${conf}/blog.conf" + run ! grep -q SetHandler "${conf}/blog.conf" + web_site_write shop php /var/www/shop shop.example.com www.shop.example.com + [ "${LSS_FILE_CHANGED}" = 1 ] + grep -qxF $'\tServerAlias www.shop.example.com' "${conf}/shop.conf" + grep -qxF $'\tDirectoryIndex index.php index.html' "${conf}/shop.conf" + grep -qxF $'\t\tSetHandler "proxy:unix:'"${WEB_PHP_SOCKET}"'|fcgi://localhost"' "${conf}/shop.conf" + [ "$(grep -c '^"${STUB_LOG}" + export STUB_OUT_getsebool='httpd_can_network_connect --> on' + web_admin_write pgadmin pgadmin proxy http://127.0.0.1:5050 + grep -q '^getsebool' "${STUB_LOG}" + run ! grep -q setsebool "${STUB_LOG}" + : >"${STUB_LOG}" + export STUB_OUT_getenforce=Permissive + web_site_write other proxy http://127.0.0.1:3001 other.example.com + run ! grep -q getsebool "${STUB_LOG}" + web_site_write blog static /var/www/blog blog.example.com + run ! grep -q getsebool "${STUB_LOG}" +} + @test "site names that could leave the configuration directory are refused" { run _web_name_check '../etc' [ "${status}" -ne 0 ] @@ -94,14 +591,302 @@ new_cert() { [ "${status}" -eq 0 ] } -@test "the names of a certificate come back sorted, one per line" { - new_cert "${WEB_TLS_DIR}/shop" 90 www.shop.example.com shop.example.com - [ "$(web_cert_domains "${WEB_TLS_DIR}/shop/fullchain.pem")" = $'shop.example.com\nwww.shop.example.com' ] +@test "a site needs a valid name and an installed web server" { + as_test_user + run web_site_write ../etc static /var/www/blog blog.example.com + [ "${status}" -eq 1 ] + [[ ${output} == *"web: '../etc' is not a valid site name."* ]] + run web_site_write blog static /var/www/blog blog.example.com + [ "${status}" -eq 1 ] + [[ ${output} == *'web: no web server is installed (nginx, apache or caddy).'* ]] + [ ! -e "${WRITES}" ] +} + +@test "removing a site deletes the file of the installed web server and nothing without one" { + as_test_user + web_site_remove blog + [ "$(files_changes_of core)" = 0 ] + use_server caddy + web_site_write blog static /var/www/blog blog.example.com + [ -f "${BATS_TEST_TMPDIR}/conf/caddy/sites/blog.caddy" ] + web_site_remove blog + [ ! -e "${BATS_TEST_TMPDIR}/conf/caddy/sites/blog.caddy" ] + [ "$(files_changes_of core)" = 2 ] + web_site_remove blog + [ "$(files_changes_of core)" = 2 ] } +# --- admin paths ------------------------------------------------------------------------------------------------ + @test "the admin port is the same for every web server and stays on localhost in the private setup" { run _web_admin_nginx pma phpmyadmin php /usr/share/phpmyadmin [[ ${output} == *'location ^~ /phpmyadmin/'* ]] run _web_admin_caddy pma phpmyadmin php /usr/share/phpmyadmin [[ ${output} == *'handle_path /phpmyadmin/*'* ]] } + +@test "nginx admin paths alias PHP directories and proxy upstreams with their script name" { + as_test_user + use_server nginx + local conf=${BATS_TEST_TMPDIR}/conf/nginx/admin + web_admin_write phpmyadmin phpmyadmin php /usr/share/phpmyadmin + grep -qxF "== 0644 root:root ${conf}/phpmyadmin.conf" "${WRITES}" + grep -qxF '# Managed by lss (admin path of phpmyadmin).' "${conf}/phpmyadmin.conf" + grep -qxF 'location = /phpmyadmin {' "${conf}/phpmyadmin.conf" + grep -qxF $'\treturn 301 /phpmyadmin/;' "${conf}/phpmyadmin.conf" + grep -qxF $'\talias /usr/share/phpmyadmin/;' "${conf}/phpmyadmin.conf" + grep -qxF $'\t\tfastcgi_pass unix:'"${WEB_PHP_SOCKET};" "${conf}/phpmyadmin.conf" + web_admin_write phpmyadmin phpmyadmin php /usr/share/phpmyadmin + [ "${LSS_FILE_CHANGED}" = 0 ] + web_admin_write pgadmin pgadmin proxy http://127.0.0.1:5050 + [ "${LSS_FILE_CHANGED}" = 1 ] + grep -qxF 'location ^~ /pgadmin/ {' "${conf}/pgadmin.conf" + grep -qxF $'\tproxy_pass http://127.0.0.1:5050;' "${conf}/pgadmin.conf" + grep -qxF $'\tproxy_set_header X-Script-Name /pgadmin;' "${conf}/pgadmin.conf" +} + +@test "Apache admin paths alias PHP directories and proxy upstreams with their script name" { + as_test_user + use_os rocky-9 + use_server apache + local conf=${BATS_TEST_TMPDIR}/conf/apache/admin + web_admin_write adminer adminer php /usr/share/adminer + grep -qxF "== 0644 root:root ${conf}/adminer.conf" "${WRITES}" + grep -qxF 'Alias /adminer /usr/share/adminer' "${conf}/adminer.conf" + grep -qxF '' "${conf}/adminer.conf" + grep -qxF $'\tAllowOverride None' "${conf}/adminer.conf" + grep -qxF $'\t\tSetHandler "proxy:unix:'"${WEB_PHP_SOCKET}"'|fcgi://localhost"' "${conf}/adminer.conf" + web_admin_write pgadmin pgadmin proxy http://127.0.0.1:5050/ + grep -qxF '' "${conf}/pgadmin.conf" + grep -qxF $'\tProxyPass http://127.0.0.1:5050/pgadmin/ upgrade=websocket' "${conf}/pgadmin.conf" + grep -qxF $'\tProxyPassReverse http://127.0.0.1:5050/pgadmin/' "${conf}/pgadmin.conf" + grep -qxF $'\tRequestHeader set X-Script-Name /pgadmin' "${conf}/pgadmin.conf" + web_admin_write pgadmin pgadmin proxy http://127.0.0.1:5050/ + [ "${LSS_FILE_CHANGED}" = 0 ] +} + +@test "Caddy admin paths strip the prefix for PHP and pass the script name to proxies" { + as_test_user + use_server caddy + local conf=${BATS_TEST_TMPDIR}/conf/caddy/admin + web_admin_write adminer adminer php /usr/share/adminer + grep -qxF "== 0644 root:root ${conf}/adminer.caddy" "${WRITES}" + grep -qxF 'redir /adminer /adminer/' "${conf}/adminer.caddy" + grep -qxF 'handle_path /adminer/* {' "${conf}/adminer.caddy" + grep -qxF $'\troot * /usr/share/adminer' "${conf}/adminer.caddy" + grep -qxF $'\tphp_fastcgi unix/'"${WEB_PHP_SOCKET}" "${conf}/adminer.caddy" + web_admin_write pgadmin pgadmin proxy http://127.0.0.1:5050 + grep -qxF 'handle /pgadmin/* {' "${conf}/pgadmin.caddy" + grep -qxF $'\treverse_proxy http://127.0.0.1:5050 {' "${conf}/pgadmin.caddy" + grep -qxF $'\t\theader_up X-Script-Name /pgadmin' "${conf}/pgadmin.caddy" + web_admin_write pgadmin pgadmin proxy http://127.0.0.1:5050 + [ "${LSS_FILE_CHANGED}" = 0 ] + web_admin_remove pgadmin + [ ! -e "${conf}/pgadmin.caddy" ] + [ -f "${conf}/adminer.caddy" ] +} + +@test "admin paths need a valid name, a plain path and an installed web server" { + as_test_user + run web_admin_write pgadmin ../pgadmin proxy http://127.0.0.1:5050 + [ "${status}" -eq 1 ] + [[ ${output} == *"web: '../pgadmin' is not a valid admin path."* ]] + run web_admin_write 'pg admin' pgadmin proxy http://127.0.0.1:5050 + [ "${status}" -eq 1 ] + [[ ${output} == *"web: 'pg admin' is not a valid site name."* ]] + run web_admin_write pgadmin pgadmin proxy http://127.0.0.1:5050 + [ "${status}" -eq 1 ] + [[ ${output} == *'web: no web server is installed (nginx, apache or caddy).'* ]] + web_admin_remove pgadmin + [ ! -e "${WRITES}" ] +} + +@test "the admin password is hashed once with SHA-512 crypt for nginx and Apache" { + as_test_user + fake_openssl + web_admin_htpasswd nginx + grep -qxF "== 0640 root:${WEB_GROUP} ${WEB_ADMIN_HTPASSWD}" "${WRITES}" + grep -qxF 'admin:$6$salt$hash' "${WEB_ADMIN_HTPASSWD}" + secret_exists web-admin-password + web_admin_htpasswd apache + [ "$(grep -c '^==' "${WRITES}")" = 1 ] + # A file in another format is replaced. + printf 'admin:{SHA}old\n' >"${WEB_ADMIN_HTPASSWD}" + web_admin_htpasswd apache + [ "$(grep -c '^==' "${WRITES}")" = 2 ] +} + +@test "the admin password is hashed once by Caddy in its own format" { + as_test_user + export STUB_OUT_caddy='$2a$14$caddyhash' + web_admin_htpasswd caddy + grep -qxF "== 0640 root:${WEB_GROUP} ${WEB_ADMIN_CADDY_AUTH}" "${WRITES}" + grep -qxF 'admin $2a$14$caddyhash' "${WEB_ADMIN_CADDY_AUTH}" + grep -qxF 'caddy hash-password' "${STUB_LOG}" + web_admin_htpasswd caddy + [ "$(grep -c '^caddy' "${STUB_LOG}")" = 1 ] + rm "${WEB_ADMIN_CADDY_AUTH}" + export STUB_EXIT_caddy=1 + run web_admin_htpasswd caddy + [ "${status}" -eq 1 ] + [[ ${output} == *'web: caddy could not hash the admin password.'* ]] +} + +# --- fail2ban and CrowdSec -------------------------------------------------------------------------------------- + +@test "without a web server the jails and the log acquisition are removed" { + mkdir -p "$(dirname "${WEB_FAIL2BAN_JAIL}")" "$(dirname "${WEB_FAIL2BAN_FILTER}")" "$(dirname "${WEB_CROWDSEC_ACQUIS}")" + touch "${WEB_FAIL2BAN_JAIL}" "${WEB_FAIL2BAN_FILTER}" "${WEB_CROWDSEC_ACQUIS}" + web_ids_configure + [ ! -e "${WEB_FAIL2BAN_JAIL}" ] + [ ! -e "${WEB_FAIL2BAN_FILTER}" ] + [ ! -e "${WEB_CROWDSEC_ACQUIS}" ] + [ "$(files_changes_of core)" = 3 ] +} + +@test "nginx gets the fail2ban auth and bot jails, and fail2ban restarts only when they change" { + as_test_user + test_log_dir + use_server nginx + use_server fail2ban + export STUB_ACTIVE_UNITS=fail2ban + local logs=${BATS_TEST_TMPDIR}/log/nginx + web_ids_configure + grep -qxF "== 0644 root:root ${WEB_FAIL2BAN_JAIL}" "${WRITES}" + grep -qxF '[nginx-http-auth]' "${WEB_FAIL2BAN_JAIL}" + grep -qxF "logpath = ${logs}/error.log" "${WEB_FAIL2BAN_JAIL}" + grep -qxF '[nginx-botsearch]' "${WEB_FAIL2BAN_JAIL}" + grep -qxF "logpath = ${logs}/access.log" "${WEB_FAIL2BAN_JAIL}" + # fail2ban refuses jails whose log files are missing. + [ -e "${logs}/access.log" ] + [ -e "${logs}/error.log" ] + grep -qxF 'fail2ban-client --test' "${STUB_LOG}" + grep -qxF 'systemctl restart fail2ban' "${STUB_LOG}" + run ! grep -q cscli "${STUB_LOG}" + : >"${STUB_LOG}" + web_ids_configure + run ! grep -qE 'fail2ban-client|restart' "${STUB_LOG}" +} + +@test "Apache on the RHEL family gets its jails while fail2ban itself is being installed" { + as_test_user + test_log_dir + use_os rocky-9 + use_server apache + LSS_MODULE=fail2ban + web_ids_configure + grep -qxF '# Managed by lss (web server apache).' "${WEB_FAIL2BAN_JAIL}" + grep -qxF '[apache-auth]' "${WEB_FAIL2BAN_JAIL}" + grep -qxF "logpath = ${BATS_TEST_TMPDIR}/log/apache/error_log" "${WEB_FAIL2BAN_JAIL}" + grep -qxF '[apache-botsearch]' "${WEB_FAIL2BAN_JAIL}" + run ! grep -qE 'fail2ban-client|restart' "${STUB_LOG}" +} + +@test "Caddy gets its own basic-auth filter and jail" { + as_test_user + test_log_dir + use_server caddy + use_server fail2ban + export STUB_ACTIVE_UNITS=fail2ban + web_ids_configure + grep -qxF "== 0644 root:root ${WEB_FAIL2BAN_FILTER}" "${WRITES}" + grep -qF '"status":401' "${WEB_FAIL2BAN_FILTER}" + grep -qxF '[lss-caddy-auth]' "${WEB_FAIL2BAN_JAIL}" + grep -qxF 'filter = lss-caddy-auth' "${WEB_FAIL2BAN_JAIL}" + grep -qxF "logpath = ${BATS_TEST_TMPDIR}/log/caddy/access.log" "${WEB_FAIL2BAN_JAIL}" + grep -qxF 'systemctl restart fail2ban' "${STUB_LOG}" + # Only the filter changed: fail2ban still restarts. + printf '# changed\n' >>"${WEB_FAIL2BAN_FILTER}" + : >"${STUB_LOG}" + web_ids_configure + grep -qxF 'systemctl restart fail2ban' "${STUB_LOG}" +} + +@test "jails that do not load stop the run before fail2ban restarts" { + as_test_user + test_log_dir + use_server nginx + use_server fail2ban + export STUB_ACTIVE_UNITS=fail2ban STUB_EXIT_fail2ban_client=1 + run web_ids_configure + [ "${status}" -eq 1 ] + [[ ${output} == *'web: the fail2ban jails for nginx do not load.'* ]] + run ! grep -q 'systemctl restart' "${STUB_LOG}" +} + +@test "CrowdSec gets the collection and the logs of nginx and restarts only on a change" { + as_test_user + test_log_dir + use_server nginx + use_server crowdsec + export STUB_ACTIVE_UNITS=crowdsec + local logs=${BATS_TEST_TMPDIR}/log/nginx + web_ids_configure + grep -qxF 'cscli collections install crowdsecurity/nginx' "${STUB_LOG}" + grep -qxF "== 0644 root:root ${WEB_CROWDSEC_ACQUIS}" "${WRITES}" + grep -qxF " - ${logs}/access.log" "${WEB_CROWDSEC_ACQUIS}" + grep -qxF " - ${logs}/error.log" "${WEB_CROWDSEC_ACQUIS}" + grep -qxF ' type: nginx' "${WEB_CROWDSEC_ACQUIS}" + grep -qxF 'systemctl restart crowdsec' "${STUB_LOG}" + run ! grep -q fail2ban "${STUB_LOG}" + : >"${STUB_LOG}" + export STUB_OUT_cscli='crowdsecurity/nginx,enabled,1.0' + web_ids_configure + run ! grep -qE 'collections install|restart' "${STUB_LOG}" +} + +@test "CrowdSec reads the Apache logs of the OS family and only the access log of Caddy" { + as_test_user + test_log_dir + use_os rocky-9 + use_server apache + LSS_MODULE=crowdsec + web_ids_configure + grep -qxF 'cscli collections install crowdsecurity/apache2' "${STUB_LOG}" + grep -qxF " - ${BATS_TEST_TMPDIR}/log/apache/error_log" "${WEB_CROWDSEC_ACQUIS}" + grep -qxF ' type: apache2' "${WEB_CROWDSEC_ACQUIS}" + run ! grep -q restart "${STUB_LOG}" + state_forget apache + use_server caddy + web_ids_configure + grep -qxF 'cscli collections install crowdsecurity/caddy' "${STUB_LOG}" + grep -qxF " - ${BATS_TEST_TMPDIR}/log/caddy/access.log" "${WEB_CROWDSEC_ACQUIS}" + [ "$(grep -c '^ - ' "${WEB_CROWDSEC_ACQUIS}")" = 1 ] + grep -qxF ' type: caddy' "${WEB_CROWDSEC_ACQUIS}" +} + +@test "a CrowdSec collection that cannot be installed stops the run" { + as_test_user + test_log_dir + use_server caddy + use_server crowdsec + export STUB_EXIT_cscli=1 + run web_ids_configure + [ "${status}" -eq 1 ] + [[ ${output} == *'web: CrowdSec could not install crowdsecurity/caddy.'* ]] +} + +@test "removing the web server drops its jails and acquisition and restarts what runs" { + web_ids_remove + [ ! -s "${STUB_LOG}" ] + mkdir -p "$(dirname "${WEB_FAIL2BAN_JAIL}")" "$(dirname "${WEB_FAIL2BAN_FILTER}")" "$(dirname "${WEB_CROWDSEC_ACQUIS}")" + touch "${WEB_FAIL2BAN_JAIL}" "${WEB_FAIL2BAN_FILTER}" "${WEB_CROWDSEC_ACQUIS}" + export STUB_ACTIVE_UNITS='fail2ban crowdsec' + web_ids_remove + [ ! -e "${WEB_FAIL2BAN_JAIL}" ] + [ ! -e "${WEB_FAIL2BAN_FILTER}" ] + [ ! -e "${WEB_CROWDSEC_ACQUIS}" ] + grep -qxF 'systemctl restart fail2ban' "${STUB_LOG}" + grep -qxF 'systemctl restart crowdsec' "${STUB_LOG}" + touch "${WEB_FAIL2BAN_JAIL}" "${WEB_CROWDSEC_ACQUIS}" + export STUB_ACTIVE_UNITS='' + : >"${STUB_LOG}" + web_ids_remove + [ ! -e "${WEB_FAIL2BAN_JAIL}" ] + run ! grep -q restart "${STUB_LOG}" +} + +@test "the names of a certificate come back sorted, one per line" { + new_cert "${WEB_TLS_DIR}/shop" 90 www.shop.example.com shop.example.com + [ "$(web_cert_domains "${WEB_TLS_DIR}/shop/fullchain.pem")" = $'shop.example.com\nwww.shop.example.com' ] +} From 972c0f6d468952d9c09d365ded4b851cf6c8b87e Mon Sep 17 00:00:00 2001 From: VectoDE Date: Wed, 14 Oct 2026 01:54:05 +0200 Subject: [PATCH 46/92] fix(caddy): access log owned by caddy before the configuration test caddy validate runs as root and created /var/log/caddy/access.log as root's, so the service, running as caddy, failed to open it and did not start. Empty imports also stop the warnings about import patterns that match nothing. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- modules/web/caddy.sh | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/modules/web/caddy.sh b/modules/web/caddy.sh index 049f763..13825f2 100644 --- a/modules/web/caddy.sh +++ b/modules/web/caddy.sh @@ -136,6 +136,16 @@ mod_configure() { local changed=0 web_prepare caddy install -d -m 0750 -o caddy -g caddy /var/log/caddy + # caddy validate runs as root and opens the access log; a file it creates would be root's, and the service, which + # runs as caddy, could not write it. + [[ -e /var/log/caddy/access.log ]] || install -m 0640 -o caddy -g caddy /dev/null /var/log/caddy/access.log + chown caddy:caddy /var/log/caddy/access.log + # Empty imports keep Caddy from warning about import patterns that match nothing yet. + install -d -m 0755 "${CADDY_DIR}/sites" "${CADDY_DIR}/admin" + printf '# Managed by lss: keeps the import pattern from matching nothing.\n' | + file_write 0644 root:root "${CADDY_DIR}/sites/00-lss.caddy" + printf '# Managed by lss: keeps the import pattern from matching nothing.\n' | + file_write 0644 root:root "${CADDY_DIR}/admin/00-lss.caddy" _caddy_file | file_write 0644 root:root "${CADDY_FILE}" [[ ${LSS_FILE_CHANGED} == 0 ]] || changed=1 file_write 0644 root:root "${WEB_ACME_ROOT}/.well-known/acme-challenge/lss-probe" <<<"lss" From 8605388c31d43e878a41effd455e3a0105137032 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Wed, 14 Oct 2026 01:54:07 +0200 Subject: [PATCH 47/92] style(mongodb): shfmt layout of the here-document substitutions Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- modules/database/mongodb.sh | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/modules/database/mongodb.sh b/modules/database/mongodb.sh index 96bb1fb..179fd5a 100644 --- a/modules/database/mongodb.sh +++ b/modules/database/mongodb.sh @@ -251,12 +251,14 @@ _mongo_login() { # The administrator is created once, through the localhost exception that MongoDB grants while no user exists. _mongo_admin() { local password=$1 output - output=$(_mongo_js < Signed-off-by: VectoDE --- tests/integration/run.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/run.sh b/tests/integration/run.sh index 2c5658c..642c7e2 100755 --- a/tests/integration/run.sh +++ b/tests/integration/run.sh @@ -164,7 +164,7 @@ start_target if [[ $1 == --profile ]]; then profile=${2:?usage: run.sh SYSTEM|host --profile NAME [LYNIS_MIN_INDEX]} - if grep -qE '^LSS_MODULES=.*tls' "profiles/${profile}.conf"; then + if grep -qE '^LSS_MODULES=(.* )?tls( |$)' "profiles/${profile}.conf"; then group_start "${system}: Pebble (ACME test server)" pebble_start group_end From 1c6700d1bd6a164847dedc215146a2477ab09f7d Mon Sep 17 00:00:00 2001 From: VectoDE Date: Wed, 14 Oct 2026 01:56:19 +0200 Subject: [PATCH 49/92] ci(integration): Lynis minimum 80 for the RHEL family Rocky Linux 9 reached 85 and AlmaLinux 10 84 after minimal-secure. Refs #12 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- .github/workflows/integration.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 64857a2..d4c5605 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -134,8 +134,8 @@ jobs: sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm # Kernel modules as on the runner VM, then the whole profile with a Lynis audit. minimal-secure reached a - # hardening index of 87 on Debian 12 and 85 on Debian 13 (October 2026); the RHEL family keeps a low minimum - # until its first measurements are in (stage 1b, issue #12). + # hardening index of 87 on Debian 12 and 85 on Debian 13, 85 on Rocky Linux 9 and 84 on AlmaLinux 10 (October + # 2026). The minimums leave room for small changes between Lynis versions. - name: Install, verify, reinstall, remove env: SYSTEM: vm-${{ matrix.system }} @@ -146,7 +146,7 @@ jobs: else case $SYSTEM in vm-debian-*) minimum=82 ;; - *) minimum=60 ;; + *) minimum=80 ;; esac tests/integration/run.sh "$SYSTEM" --profile "${RUN#profile }" "$minimum" fi From d52e5836d1dd6d479c9221128271f1fd1e9bf328 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Wed, 14 Oct 2026 02:37:19 +0200 Subject: [PATCH 50/92] fix(mysql): cap the open file limit for the package scripts The amd64 test containers hung for 90 minutes in the mysql-server install: its scripts run mysqld outside systemd with the container's open file limit. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- modules/database/mysql.sh | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/modules/database/mysql.sh b/modules/database/mysql.sh index ff3852d..1411b38 100644 --- a/modules/database/mysql.sh +++ b/modules/database/mysql.sh @@ -191,7 +191,7 @@ _mysql_generic_links() { } mod_install() { - local packages + local packages limit case $(_mysql_source) in rhel) if [[ ${OS_MAJOR} == 9 ]]; then @@ -203,6 +203,12 @@ mod_install() { *) ;; esac packages=$(_mysql_packages) + # The package scripts initialise the data directory with mysqld itself, outside systemd's limits. With the open file + # limit of a container (about a billion) mysqld sizes its tables after it and stalls; 65536 is plenty. + limit=$(ulimit -n) + if [[ ${limit} == unlimited ]] || ((limit > 65536)); then + ulimit -n 65536 + fi if [[ -n ${packages} ]]; then pkg_install_family "${packages}" "${packages}" fi From f429076e51624e3bdb8d4682f30f6edee5ccea89 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Wed, 14 Oct 2026 05:20:24 +0200 Subject: [PATCH 51/92] fix(web): build libmodsecurity and the nginx connector on every distribution CRS 4.25 needs libmodsecurity 3.0.16; the distributions ship 3.0.6 to 3.0.14, so nginx failed with 'Expecting an action, got: @*'. lss now builds the engine and the connector against the verified nginx source of the installed version. Apache on RHEL is checked with httpd -M, because apachectl there rejects -M. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- data/checksums.txt | 19 ++++--- modules/web/modsecurity.sh | 107 ++++++++++++++++--------------------- 2 files changed, 58 insertions(+), 68 deletions(-) diff --git a/data/checksums.txt b/data/checksums.txt index 5ff3e69..bbeda70 100644 --- a/data/checksums.txt +++ b/data/checksums.txt @@ -37,17 +37,24 @@ mysql-generic aarch64 04b2f9791d314167a9eb83abcb476f45a7cd9e4aa88fa7a638cba40d1b # modsecurity: OWASP Core Rule Set, digests of the GitHub release files (the releases are also PGP-signed by # security@coreruleset.org, 3600 6F0E 0BA1 6783 2158 8211 38EE ACA1 AB8A 6E72). 4.25 is the long-term-support line; -# 3.3 serves the ModSecurity versions of Ubuntu 22.04. +# 3.3 serves the ModSecurity v2 of Ubuntu 22.04 (Apache). coreruleset-4.25.2 any a2d4b2a5bb4f397f7e40d4e22d0346350706a5f17c1cfefc09e8b9740e00d87e https://github.com/coreruleset/coreruleset/releases/download/v4.25.2/coreruleset-4.25.2-minimal.tar.gz coreruleset-3.3.10 any 0c35a9e14f8af3cf02fc29eaae807312c6555d0f9bfa306274268ab5913c1110 https://github.com/coreruleset/coreruleset/releases/download/v3.3.10/coreruleset-3.3.10-minimal.tar.gz -# modsecurity: libmodsecurity (RHEL 10) and the nginx connector (Ubuntu 22.04, RHEL 10), from the .sha256 files the -# OWASP ModSecurity project publishes with each release. +# modsecurity: libmodsecurity and the nginx connector (every system with nginx), from the .sha256 files the OWASP +# ModSecurity project publishes with each release. libmodsecurity-3.0.17 any f283b33d5c21130fd3a15c84a93a1abe12bd1949edaf44288b12af571b671a50 https://github.com/owasp-modsecurity/ModSecurity/releases/download/v3.0.17/modsecurity-v3.0.17.tar.gz modsecurity-nginx-1.0.4 any 6bdc7570911be884c1e43aaf85046137f9fde0cfa0dd4a55b853c81c45a13313 https://github.com/owasp-modsecurity/ModSecurity-nginx/releases/download/v1.0.4/ModSecurity-nginx-v1.0.4.tar.gz -# modsecurity: nginx sources of the version Ubuntu 22.04 ships, for the connector build. nginx.org publishes PGP -# signatures only; the digest is of the file whose signature (Maxim Dounin, B0F4 2533 73F8 F6F5 10D4 2178 520A 9993 -# A1C0 52F8) verified. +# modsecurity: nginx sources of the versions the distributions ship (Ubuntu 22.04 1.18.0, Debian 12 1.22.1, Ubuntu +# 24.04 1.24.0, Debian 13 1.26.3, Ubuntu 26.04 1.28.3), for the connector build. nginx.org publishes PGP signatures +# only; each digest is of the file whose signature verified: 1.18.0 Maxim Dounin (B0F4 2533 73F8 F6F5 10D4 2178 520A +# 9993 A1C0 52F8), 1.22.1 and 1.24.0 Konstantin Pavlov (13C8 2A63 B603 5761 56E3 0A4E A0EA 981B 66B0 D967), 1.26.3 +# Sergey Kandaurov (D678 6CE3 03D9 A902 2998 DC6C C846 4D54 9AF7 5C0A), 1.28.3 Roman Arutyunyan (4338 7825 DDB1 BB97 +# EC36 BA5D 007C 8D7C 15D8 7369); keys from https://nginx.org/en/pgp_keys.html, checked 2026-10-14. nginx-1.18.0 any 4c373e7ab5bf91d34a4f11a0c9496561061ba5eee6020db272a17a7228d35f99 https://nginx.org/download/nginx-1.18.0.tar.gz +nginx-1.22.1 any 9ebb333a9e82b952acd3e2b4aeb1d4ff6406f72491bab6cd9fe69f0dea737f31 https://nginx.org/download/nginx-1.22.1.tar.gz +nginx-1.24.0 any 77a2541637b92a621e3ee76776c8b7b40cf6d707e69ba53a940283e30ff2f55d https://nginx.org/download/nginx-1.24.0.tar.gz +nginx-1.26.3 any 69ee2b237744036e61d24b836668aad3040dda461fe6f570f1787eab570c75aa https://nginx.org/download/nginx-1.26.3.tar.gz +nginx-1.28.3 any 2c96a946bfb0882a21744ed429770a2123ae1828c7c48665092993ddee91a918 https://nginx.org/download/nginx-1.28.3.tar.gz # modsecurity: CRS 4 application exclusion plugins. Their repositories publish no release files; the files are pinned # by the commit of the release tag (cpanel, dokuwiki, drupal, phpbb, xenforo v1.0.0, nextcloud v1.7.1, phpmyadmin # v1.1.0, wordpress v1.2.0). diff --git a/modules/web/modsecurity.sh b/modules/web/modsecurity.sh index 7bee628..4f6db65 100644 --- a/modules/web/modsecurity.sh +++ b/modules/web/modsecurity.sh @@ -9,20 +9,21 @@ MOD_REQUIRES="apache|nginx" MOD_OPTIONS="mode|On|On blocks attacks; DetectionOnly only logs them (to try the rules on a live site first) paranoia|1|Paranoia level 1 to 4: higher levels catch more attacks and also block more legitimate requests anomaly_inbound|5|Anomaly score from which a request is blocked (5: one critical rule matched; higher values block less) -exclusions||Rule exclusions for applications, separated by spaces: cpanel dokuwiki drupal nextcloud phpbb phpmyadmin wordpress xenforo (phpbb and phpmyadmin need CRS 4, which Ubuntu 22.04 does not get) +exclusions||Rule exclusions for applications, separated by spaces: cpanel dokuwiki drupal nextcloud phpbb phpmyadmin wordpress xenforo (phpbb and phpmyadmin need CRS 4, which Apache on Ubuntu 22.04 does not get) allowed_methods|GET HEAD POST OPTIONS PUT PATCH DELETE|HTTP methods the rules let through, separated by spaces max_body||Largest request body ModSecurity accepts and inspects, for example 64m (at most 1g); empty takes max_body of the web server" # Where the engine and the rules come from: # Apache libapache2-mod-security2 (Debian, Ubuntu), mod_security (RHEL 9 AppStream, RHEL 10 EPEL). -# nginx libnginx-mod-http-modsecurity (Debian 12/13, Ubuntu 24.04/26.04), nginx-mod-modsecurity with -# libmodsecurity (RHEL 9 EPEL). Ubuntu 22.04 packages libmodsecurity3 but no nginx connector, RHEL 10 -# packages neither: lss builds what is missing from checksum-verified sources as a dynamic module -# (--with-compat) for the installed nginx, and builds again only when the nginx version changes. +# nginx libmodsecurity and the nginx connector, built by lss from checksum-verified sources on every system: +# CRS 4.25.1 and 3.3.10 need libmodsecurity 3.0.16 (the fix against XML attribute injection), and the +# distributions ship 3.0.6 to 3.0.14 (RHEL 10 none). The connector is a dynamic module (--with-compat) +# for the installed nginx, built again only when the nginx version changes. # Rules The OWASP Core Rule Set from its signed GitHub releases, pinned in data/checksums.txt, so every system # runs the same rules (the distributions ship CRS 3.3 of differing ages, RHEL 10 none). CRS 4 needs # ModSecurity 2.9.6 or 3.0.8; its long-term-support line 4.25 changes less between updates than the -# monthly releases. Ubuntu 22.04 has 2.9.5 and 3.0.6 and therefore gets CRS 3.3, which is still maintained. +# monthly releases. Apache on Ubuntu 22.04 has ModSecurity 2.9.5 and therefore gets CRS 3.3, which is still +# maintained. MODSEC_CRS_VERSION=4.25.2 MODSEC_CRS_LEGACY_VERSION=3.3.10 MODSEC_LIB_VERSION=3.0.17 @@ -48,9 +49,9 @@ _modsecurity_server() { return } -# True on Ubuntu 22.04, whose ModSecurity (2.9.5, 3.0.6) is too old for CRS 4. +# True for Apache on Ubuntu 22.04, whose ModSecurity 2.9.5 is too old for CRS 4. _modsecurity_legacy() { - [[ ${OS_ID} == ubuntu && ${OS_MAJOR} == 22 ]] + [[ ${OS_ID} == ubuntu && ${OS_MAJOR} == 22 && $(web_server) == apache ]] return } @@ -129,24 +130,8 @@ _modsecurity_wanted() { return } -# True where lss builds the nginx connector itself (Ubuntu 22.04, RHEL 10). -_modsecurity_nginx_build() { - if os_is_debian; then - _modsecurity_legacy - else - [[ ${OS_MAJOR} -ge 10 ]] - fi - return -} - _modsecurity_nginx_so() { - if _modsecurity_nginx_build; then - printf '%s/nginx/ngx_http_modsecurity_module.so' "${MODSEC_PREFIX}" - elif os_is_debian; then - printf '/usr/lib/nginx/modules/ngx_http_modsecurity_module.so' - else - printf '/usr/lib64/nginx/modules/ngx_http_modsecurity_module.so' - fi + printf '%s/nginx/ngx_http_modsecurity_module.so' "${MODSEC_PREFIX}" return } @@ -157,9 +142,8 @@ _modsecurity_nginx_version() { # What the self-built nginx connector was built for; a different value means building it again. _modsecurity_connector_id() { - local library=distribution - os_is_debian || library=${MODSEC_LIB_VERSION} - printf 'nginx %s, connector %s, libmodsecurity %s' "$(_modsecurity_nginx_version)" "${MODSEC_CONNECTOR_VERSION}" "${library}" + printf 'nginx %s, connector %s, libmodsecurity %s' "$(_modsecurity_nginx_version)" "${MODSEC_CONNECTOR_VERSION}" \ + "${MODSEC_LIB_VERSION}" return } @@ -242,16 +226,22 @@ _modsecurity_jobs() { return } -# libmodsecurity for RHEL 10, which packages none. Without curl, GeoIP, LMDB, Lua and ssdeep: the Core Rule Set needs -# none of them. Built again only for a new version. +# libmodsecurity for nginx. Without curl, GeoIP, LMDB, Lua and ssdeep: the Core Rule Set needs none of them. With YAJL +# (JSON request bodies) where the distribution has it; RHEL 10 has none. Built again only for a new version. _modsecurity_build_library() { - local stamp="${MODSEC_PREFIX}/.libmodsecurity" src + local stamp="${MODSEC_PREFIX}/.libmodsecurity" src yajl=--without-yajl if [[ -f ${MODSEC_PREFIX}/lib/libmodsecurity.so.3 && -f ${stamp} && $(<"${stamp}") == "${MODSEC_LIB_VERSION}" ]]; then return 0 fi # The runtime libraries first, so removing the build packages afterwards keeps them. - pkg_install pcre2 libxml2 - _modsecurity_build_deps gcc-c++ make pkgconf-pkg-config pcre2-devel libxml2-devel + if os_is_debian; then + pkg_install libpcre2-8-0 libxml2 libyajl2 + _modsecurity_build_deps g++ make pkg-config libpcre2-dev libxml2-dev libyajl-dev + yajl=--with-yajl + else + pkg_install pcre2 libxml2 + _modsecurity_build_deps gcc-c++ make pkgconf-pkg-config pcre2-devel libxml2-devel + fi _modsecurity_workdir fetch_verified "libmodsecurity-${MODSEC_LIB_VERSION}" "${MODSEC_WORK}/libmodsecurity.tar.gz" tar -xzf "${MODSEC_WORK}/libmodsecurity.tar.gz" -C "${MODSEC_WORK}" --no-same-owner @@ -259,7 +249,7 @@ _modsecurity_build_library() { log_info "Building libmodsecurity ${MODSEC_LIB_VERSION}; this takes a few minutes" (cd "${src}" && log_command ./configure --prefix="${MODSEC_PREFIX}" --disable-static --disable-examples \ --disable-doxygen-doc --with-pcre2 --without-curl --without-geoip --without-maxmind --without-lmdb \ - --without-lua --without-ssdeep --without-yajl) || die "modsecurity: libmodsecurity does not configure; see the log." + --without-lua --without-ssdeep "${yajl}") || die "modsecurity: libmodsecurity does not configure; see the log." log_command make -C "${src}" -j "$(_modsecurity_jobs)" || die "modsecurity: libmodsecurity does not build; see the log." log_command make -C "${src}" install-strip || die "modsecurity: libmodsecurity does not install; see the log." printf '%s\n' "${MODSEC_LIB_VERSION}" >"${stamp}" @@ -269,21 +259,20 @@ _modsecurity_build_library() { } # The nginx connector as a dynamic module for the installed nginx: from the nginx.org sources of that version on -# Ubuntu 22.04 (verified by data/checksums.txt), from nginx-mod-devel on RHEL 10. --with-compat makes it load into the -# distribution's nginx, which is built with the same switch. +# Debian and Ubuntu (verified by data/checksums.txt), from nginx-mod-devel on the RHEL family. --with-compat makes it +# load into the distribution's nginx, which is built with the same switch. _modsecurity_build_connector() { local so="${MODSEC_PREFIX}/nginx/ngx_http_modsecurity_module.so" stamp="${MODSEC_PREFIX}/nginx/.build" local version src tree - local -a env=() + local -a env=("MODSECURITY_INC=${MODSEC_PREFIX}/include" "MODSECURITY_LIB=${MODSEC_PREFIX}/lib") if [[ -f ${so} && -f ${stamp} && $(<"${stamp}") == "$(_modsecurity_connector_id)" ]]; then return 0 fi if os_is_debian; then - _modsecurity_build_deps gcc make libc6-dev libpcre3-dev zlib1g-dev libmodsecurity-dev + _modsecurity_build_deps gcc make libc6-dev libpcre2-dev zlib1g-dev else # nginx-mod-devel requires the nginx of its own release and may update nginx; the version is read afterwards. _modsecurity_build_deps gcc make pcre2-devel zlib-devel nginx-mod-devel - env=("MODSECURITY_INC=${MODSEC_PREFIX}/include" "MODSECURITY_LIB=${MODSEC_PREFIX}/lib") fi version=$(_modsecurity_nginx_version) [[ -n ${version} ]] || die "modsecurity: cannot read the version of nginx." @@ -330,20 +319,10 @@ _modsecurity_install_apache() { } _modsecurity_install_nginx() { - if os_is_debian; then - if _modsecurity_nginx_build; then - pkg_install libmodsecurity3 - _modsecurity_build_connector - else - pkg_install libnginx-mod-http-modsecurity - fi - elif _modsecurity_nginx_build; then - _modsecurity_build_library - _modsecurity_build_connector - else - pkg_enable_epel - pkg_install nginx-mod-modsecurity - fi + # Packages of earlier lss versions: their module would be loaded next to the self-built one. + pkg_remove_family "libnginx-mod-http-modsecurity" "nginx-mod-modsecurity" + _modsecurity_build_library + _modsecurity_build_connector return } @@ -403,7 +382,7 @@ mod_install() { } # _modsecurity_engine_conf SERVER: the engine settings after modsecurity.conf-recommended of ModSecurity, limited to -# what the oldest engine in use understands (2.9.5 for Apache, 3.0.6 for nginx on Ubuntu 22.04). +# what the oldest engine in use understands (2.9.5 for Apache on Ubuntu 22.04). _modsecurity_engine_conf() { local server=$1 bytes nofiles json=0 modern=1 bytes=$(_modsecurity_bytes "$(_modsecurity_max_body)") @@ -505,7 +484,7 @@ _modsecurity_plugins() { file_remove "${dir}/plugins" for name in $(opt exclusions); do [[ " ${MODSEC_LEGACY_EXCLUSIONS} " == *" ${name} "* ]] || - log_warn "modsecurity: CRS 3.3 (Ubuntu 22.04) has no rule exclusions for ${name}; skipped." + log_warn "modsecurity: CRS 3.3 (Apache on Ubuntu 22.04) has no rule exclusions for ${name}; skipped." done return 0 fi @@ -602,14 +581,12 @@ _modsecurity_nginx_enable() { so=$(_modsecurity_nginx_so) [[ -f ${so} ]] || die "modsecurity: the nginx module ${so} is missing." if os_is_debian; then modules=/etc/nginx/modules-enabled; else modules=/usr/share/nginx/modules; fi - if _modsecurity_nginx_build || ! grep -qs 'ngx_http_modsecurity_module' "${modules}"/*.conf; then - file_write 0644 root:root /etc/nginx/lss/modules/modsecurity.conf </dev/null || true) + # RHEL's apachectl takes only the service commands; httpd itself lists the modules there. + if os_is_debian; then + config=$(apache2ctl -M 2>/dev/null || true) + else + config=$(httpd -M 2>/dev/null || true) + fi grep -q 'security2_module' <<<"${config}" || die "modsecurity: Apache has not loaded mod_security2." else config=$(nginx -T 2>/dev/null || true) @@ -740,7 +722,8 @@ mod_remove() { if [[ ${server} == apache ]]; then pkg_remove_family "libapache2-mod-security2" "mod_security" else - pkg_remove_family "libnginx-mod-http-modsecurity libmodsecurity3" "nginx-mod-modsecurity libmodsecurity" + # Packages of earlier lss versions; the self-built engine goes with MODSEC_PREFIX below. + pkg_remove_family "libnginx-mod-http-modsecurity" "nginx-mod-modsecurity" fi # Downloads and builds, reproducible from data/checksums.txt; the audit logs stay like the web server's own logs. if [[ -e ${MODSEC_PREFIX} || -e ${MODSEC_CRS_ROOT} ]]; then From 7dacaf38e491da7f48aae4b73479e94a9bad1496 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Wed, 14 Oct 2026 05:20:25 +0200 Subject: [PATCH 52/92] fix(base): install locales so a second run leaves /etc/default/locale alone postgresql pulls in locales, which rewrote /etc/default/locale and made the second run of system report a change. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- modules/base/system.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/modules/base/system.sh b/modules/base/system.sh index 6636aab..435d501 100644 --- a/modules/base/system.sh +++ b/modules/base/system.sh @@ -10,7 +10,8 @@ locale|C.UTF-8|System locale, for example C.UTF-8, en_US.UTF-8 or de_DE.UTF-8 upgrade|true|Install all available updates on every run" # Packages lss itself needs on every server. They are never removed again. -SYSTEM_PACKAGES_DEB="ca-certificates curl gnupg sudo cron logrotate procps iproute2 openssl" +# locales: its package scripts write /etc/default/locale, so it is installed before lss sets the locale there. +SYSTEM_PACKAGES_DEB="ca-certificates curl gnupg sudo cron logrotate procps iproute2 openssl locales" SYSTEM_PACKAGES_RPM="ca-certificates curl gnupg2 sudo cronie logrotate procps-ng iproute openssl" mod_check() { @@ -54,6 +55,7 @@ _system_hostname() { file_ensure_lines 0644 root:root /etc/hosts <<<"127.0.1.1 ${hostname} ${short}" fi fi + return } _system_locale() { From 126b576ecc9941047c84d871a057f5ed869679dd Mon Sep 17 00:00:00 2001 From: VectoDE Date: Wed, 14 Oct 2026 05:20:26 +0200 Subject: [PATCH 53/92] refactor: resolve SonarQube findings in valkey and the test stubs Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- modules/database/valkey.sh | 8 +++++--- tests/fixtures/bin/selinuxenabled | 6 +++--- tests/unit/helpers.bash | 2 -- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/modules/database/valkey.sh b/modules/database/valkey.sh index 9d8d931..9908c56 100644 --- a/modules/database/valkey.sh +++ b/modules/database/valkey.sh @@ -19,6 +19,8 @@ MOD_SECRETS="valkey-password" # Sources: the distribution where it has Valkey (Debian 13, Ubuntu 24.04 and 26.04, RHEL 9 and 10 AppStream), # bookworm-backports on Debian 12 and the Valkey project's build for Ubuntu 22.04 (data/checksums.txt), which has no # package at all. +# The package and service name on the RHEL family (Debian and Ubuntu call both valkey-server). +VALKEY_RPM=valkey # The configuration lives in root-owned /etc/lss: root does not write into /etc/valkey, which the valkey user may # control. VALKEY_CONF=/etc/lss/valkey.conf @@ -44,7 +46,7 @@ _valkey_source() { } _valkey_unit() { - if os_is_debian; then printf 'valkey-server'; else printf 'valkey'; fi + if os_is_debian; then printf 'valkey-server'; else printf '%s' "${VALKEY_RPM}"; fi return } @@ -188,7 +190,7 @@ mod_install() { backports) _valkey_backports ;; *) ;; esac - pkg_install_family "valkey-server valkey-tools" "valkey" + pkg_install_family "valkey-server valkey-tools" "${VALKEY_RPM}" return } @@ -407,7 +409,7 @@ mod_remove() { file_remove "${VALKEY_BACKPORTS_PIN}" file_remove "${VALKEY_BACKPORTS_SOURCE}" ;; - *) pkg_remove_family "valkey-server valkey-tools" "valkey" ;; + *) pkg_remove_family "valkey-server valkey-tools" "${VALKEY_RPM}" ;; esac _valkey_delete "${VALKEY_DATA}" if getent group "${VALKEY_GROUP}" >/dev/null; then diff --git a/tests/fixtures/bin/selinuxenabled b/tests/fixtures/bin/selinuxenabled index e6990ff..b568142 100755 --- a/tests/fixtures/bin/selinuxenabled +++ b/tests/fixtures/bin/selinuxenabled @@ -3,6 +3,6 @@ # # SPDX-License-Identifier: MIT -# shellcheck source=tests/fixtures/bin/_stub.bash -source "$(dirname "$0")/_stub.bash" -stub_main selinuxenabled "$@" +# selinuxenabled: SELinux is off unless a test sets STUB_EXIT_selinuxenabled=0. +printf 'selinuxenabled %s\n' "$*" >>"${STUB_LOG:-/dev/null}" +exit "${STUB_EXIT_selinuxenabled:-1}" diff --git a/tests/unit/helpers.bash b/tests/unit/helpers.bash index 52bc142..9d85fa5 100644 --- a/tests/unit/helpers.bash +++ b/tests/unit/helpers.bash @@ -17,8 +17,6 @@ lss_test_setup() { export LSS_COLOR=never export LSS_VERSION=0.0.0-test export STUB_LOG=${BATS_TEST_TMPDIR}/stub.log - # Systems without SELinux unless a test switches it on. - export STUB_EXIT_selinuxenabled=1 export PATH="${REPO_ROOT}/tests/fixtures/bin:${PATH}" OWNER="$(id -un):$(id -gn)" export OWNER From d6f2185237284648c4e879d8d9682dced1007904 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Wed, 14 Oct 2026 05:20:28 +0200 Subject: [PATCH 54/92] fix(database): sandbox the data directory MariaDB is configured for MariaDB 11.8 on Ubuntu 26.04 keeps its data in /var/lib/mariadb, which the unit could not write ('Read-only file system'). lss reads the datadir from the server defaults and keeps the MySQL check on /var/lib/mysql. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- modules/database/mariadb.sh | 45 +++++++++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/modules/database/mariadb.sh b/modules/database/mariadb.sh index 8e8a966..ca7a09f 100644 --- a/modules/database/mariadb.sh +++ b/modules/database/mariadb.sh @@ -16,7 +16,25 @@ MOD_PORTS="3306/tcp on 127.0.0.1" MOD_SECRETS="mariadb-user-" MARIADB_UNIT=mariadb -MARIADB_DATA=/var/lib/mysql +# Where MySQL keeps its data; MariaDB used the same directory until Ubuntu 26.04 moved it to /var/lib/mariadb. +MARIADB_MYSQL_DATA=/var/lib/mysql + +# _mariadb_data: the data directory the installed server is configured for, one of the two the packages use. +_mariadb_data() { + local dir='' tool + for tool in mariadb-print-defaults my_print_defaults; do + command -v "${tool}" >/dev/null 2>&1 || continue + dir=$("${tool}" --mysqld 2>/dev/null | sed -n 's/^--datadir=//p' | tail -n 1) + break + done + dir=${dir%/} + case ${dir} in + '') printf '%s' "${MARIADB_MYSQL_DATA}" ;; + /var/lib/mysql | /var/lib/mariadb) printf '%s' "${dir}" ;; + *) die "mariadb: the data directory ${dir} is not one lss manages (/var/lib/mysql or /var/lib/mariadb)." ;; + esac + return +} _mariadb_conf() { if os_is_debian; then @@ -66,8 +84,8 @@ mod_check() { die "LSS_MARIADB_USERS: '${database}' is a reserved database." done # MySQL 8 keeps its data dictionary in mysql.ibd; MariaDB cannot open such a data directory. - [[ ! -e ${MARIADB_DATA}/mysql.ibd ]] || - die "mariadb: ${MARIADB_DATA} holds MySQL data. Remove it with 'lss remove --purge mysql' or move it away first." + [[ ! -e ${MARIADB_MYSQL_DATA}/mysql.ibd ]] || + die "mariadb: ${MARIADB_MYSQL_DATA} holds MySQL data. Remove it with 'lss remove --purge mysql' or move it away first." return } @@ -220,7 +238,7 @@ _mariadb_users() { } mod_configure() { - local before changed=0 port + local before changed=0 port data port=$(opt_int port) before=$(files_changes_of mariadb) _mariadb_selinux_port "${port}" @@ -239,8 +257,9 @@ default-character-set = utf8mb4 EOF # The data directory is the only place the server writes outside /run and /var/log. Newer packages (Ubuntu 26.04) # create it on the first start, but the sandbox needs it before. - [[ -d ${MARIADB_DATA} ]] || install -d -m 0750 -o mysql -g mysql "${MARIADB_DATA}" - svc_sandbox "${MARIADB_UNIT}.service" "ReadWritePaths=${MARIADB_DATA}" + data=$(_mariadb_data) + [[ -d ${data} ]] || install -d -m 0750 -o mysql -g mysql "${data}" + svc_sandbox "${MARIADB_UNIT}.service" "ReadWritePaths=${data}" [[ $(files_changes_of mariadb) == "${before}" ]] || changed=1 svc_enable "${MARIADB_UNIT}" if [[ ${changed} == 1 ]]; then @@ -283,18 +302,20 @@ mod_verify() { } mod_remove() { - local path + local path data + # Read while the server's tools are still installed. + data=$(_mariadb_data) svc_disable "${MARIADB_UNIT}" svc_dropin_remove "${MARIADB_UNIT}.service" lss-sandbox file_remove "$(_mariadb_conf)" if [[ ${LSS_PURGE:-0} == 1 ]]; then pkg_remove_family "mariadb-server mariadb-client" "mariadb-server mariadb" # Only the fixed data directory, never through a symbolic link. - if [[ -L ${MARIADB_DATA} ]]; then - die "mariadb: refusing to delete ${MARIADB_DATA}: it is a symbolic link." - elif [[ -d ${MARIADB_DATA} ]]; then - rm -rf -- "${MARIADB_DATA}" - files_note_change "deleted ${MARIADB_DATA}" + if [[ -L ${data} ]]; then + die "mariadb: refusing to delete ${data}: it is a symbolic link." + elif [[ -d ${data} ]]; then + rm -rf -- "${data}" + files_note_change "deleted ${data}" fi for path in "${LSS_SECRETS_DIR}"/mariadb-user-*; do [[ -f ${path} ]] || continue From 6cafbbd285546049b0474723ed5cd69719ac955b Mon Sep 17 00:00:00 2001 From: VectoDE Date: Wed, 14 Oct 2026 05:22:33 +0200 Subject: [PATCH 55/92] fix(pkg): retry dnf transactions when a mirror drops out AlmaLinux 10 failed with 'Cannot download, all mirrors were already tried' during a short mirror outage. A failed dnf transaction changes nothing, so lss tries it twice more with fresh metadata. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- lib/pkg.sh | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/lib/pkg.sh b/lib/pkg.sh index 37a344a..806a796 100644 --- a/lib/pkg.sh +++ b/lib/pkg.sh @@ -15,11 +15,25 @@ _pkg_apt() { return } +# Mirrors of the RHEL family drop out for minutes at a time; dnf gives up on the first broken mirror list, so a +# failed transaction is tried again with fresh metadata. dnf changes nothing when a transaction fails. +_pkg_dnf() { + local attempt + for attempt in 1 2 3; do + dnf -y -q "$@" && return 0 + ((attempt < 3)) || break + printf 'dnf %s failed (attempt %s of 3); trying again in 20 seconds with fresh metadata.\n' "$1" "${attempt}" + dnf -q clean metadata || true + sleep 20 + done + return 1 +} + pkg_update() { if os_is_debian; then log_command _pkg_apt update else - log_command dnf -y -q makecache + log_command _pkg_dnf makecache fi LSS_PKG_UPDATED=1 if [[ -n ${LSS_RUN_ID:-} ]]; then @@ -63,7 +77,7 @@ pkg_install() { pkg_update_once log_command _pkg_apt install --no-install-recommends "${missing[@]}" else - log_command dnf -y -q install "${missing[@]}" + log_command _pkg_dnf install "${missing[@]}" fi files_note_change "installed ${missing[*]}" } @@ -92,7 +106,7 @@ pkg_remove() { log_command _pkg_apt purge "${present[@]}" log_command _pkg_apt autoremove --purge else - log_command dnf -y -q remove "${present[@]}" + log_command _pkg_dnf remove "${present[@]}" fi files_note_change "removed ${present[*]}" } @@ -115,7 +129,7 @@ pkg_upgrade() { pkg_update log_command _pkg_apt full-upgrade else - log_command dnf -y -q upgrade --refresh + log_command _pkg_dnf upgrade --refresh fi return } @@ -126,10 +140,10 @@ pkg_enable_epel() { pkg_is_installed epel-release && return 0 if [[ ${OS_ID} == rhel ]]; then subscription-manager repos --enable "codeready-builder-for-rhel-${OS_MAJOR}-$(uname -m)-rpms" >/dev/null 2>&1 || true - log_command dnf -y -q install "https://dl.fedoraproject.org/pub/epel/epel-release-latest-${OS_MAJOR}.noarch.rpm" + log_command _pkg_dnf install "https://dl.fedoraproject.org/pub/epel/epel-release-latest-${OS_MAJOR}.noarch.rpm" else dnf config-manager --set-enabled crb >/dev/null 2>&1 || true - log_command dnf -y -q install epel-release + log_command _pkg_dnf install epel-release fi files_note_change "enabled EPEL" } From 8e5c5d88655fdf411471c52fc245af295a481ef5 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Wed, 14 Oct 2026 05:25:00 +0200 Subject: [PATCH 56/92] fix(pkg): keep apt notices that report nothing to do out of the log needrestart ('No services need to be restarted.') and the man-db trigger ('Not building database; ...') print lines that start with No or Not on every apt run. lss drops exactly these lines; errors and apt's exit status are kept. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- lib/pkg.sh | 7 +++++-- tests/unit/system.bats | 9 +++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/lib/pkg.sh b/lib/pkg.sh index 806a796..23e9d73 100644 --- a/lib/pkg.sh +++ b/lib/pkg.sh @@ -9,10 +9,13 @@ LSS_PKG_UPDATED=${LSS_PKG_UPDATED:-0} # DPkg::Lock::Timeout waits for unattended-upgrades or apt-daily instead of failing on their lock; they start on # their own on a fresh server and as soon as the auto-updates module enables them. +# The filter drops the notices that report nothing to do: the needrestart summaries and the man-db trigger on images +# that switched off man-db/auto-update. Errors and everything else stay in the log. _pkg_apt() { DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get -y -q -o DPkg::Lock::Timeout=600 \ - -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold "$@" - return + -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold "$@" 2>&1 | + sed -E '/^(No (services|containers) need to be restarted\.|No user sessions are running outdated binaries\.|No VM guests are running outdated hypervisor|Not building database; man-db\/auto-update is not)/d' + return "${PIPESTATUS[0]}" } # Mirrors of the RHEL family drop out for minutes at a time; dnf gives up on the first broken mirror list, so a diff --git a/tests/unit/system.bats b/tests/unit/system.bats index fd977b0..4511594 100644 --- a/tests/unit/system.bats +++ b/tests/unit/system.bats @@ -33,6 +33,15 @@ calls() { [ "$(files_changes_of core)" = 0 ] } +@test "apt output drops the notices that report nothing to do and keeps the exit status" { + use_os ubuntu-24.04 + export STUB_OUT_apt_get=$'No services need to be restarted.\nSetting up nginx (1.24.0)\nNo containers need to be restarted.\nNo user sessions are running outdated binaries.\nNo VM guests are running outdated hypervisor (qemu) binaries on this host.\nNot building database; man-db/auto-update is not \'true\'.' + export STUB_EXIT_apt_get=100 + run _pkg_apt install nginx + [ "${status}" -eq 100 ] + [ "${output}" = 'Setting up nginx (1.24.0)' ] +} + @test "dnf is used on the RHEL family and family lists pick the right names" { use_os rocky-9 export STUB_EXIT_rpm=1 From 590c9bc096cb43ed98d1b52c80988a678c33c23e Mon Sep 17 00:00:00 2001 From: VectoDE Date: Wed, 14 Oct 2026 14:16:18 +0200 Subject: [PATCH 57/92] fix(web): pick the libxml2 runtime package apt offers Ubuntu 26.04 ships libxml2 as libxml2-16, so installing 'libxml2' failed before the ModSecurity build. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- modules/web/modsecurity.sh | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/modules/web/modsecurity.sh b/modules/web/modsecurity.sh index 4f6db65..584cf4b 100644 --- a/modules/web/modsecurity.sh +++ b/modules/web/modsecurity.sh @@ -228,6 +228,21 @@ _modsecurity_jobs() { # libmodsecurity for nginx. Without curl, GeoIP, LMDB, Lua and ssdeep: the Core Rule Set needs none of them. With YAJL # (JSON request bodies) where the distribution has it; RHEL 10 has none. Built again only for a new version. +# _modsecurity_deb_candidate PACKAGE...: the first of the names apt can install. Runtime libraries change their name +# with a new ABI (libxml2 is libxml2-16 from Ubuntu 26.04 on). +_modsecurity_deb_candidate() { + local package candidate + for package in "$@"; do + candidate=$(apt-cache policy "${package}" 2>/dev/null | awk '$1 == "Candidate:" { print $2 }') + if [[ -n ${candidate} && ${candidate} != '(none)' ]]; then + printf '%s' "${package}" + return 0 + fi + done + die "modsecurity: apt offers none of $*." + return +} + _modsecurity_build_library() { local stamp="${MODSEC_PREFIX}/.libmodsecurity" src yajl=--without-yajl if [[ -f ${MODSEC_PREFIX}/lib/libmodsecurity.so.3 && -f ${stamp} && $(<"${stamp}") == "${MODSEC_LIB_VERSION}" ]]; then @@ -235,7 +250,8 @@ _modsecurity_build_library() { fi # The runtime libraries first, so removing the build packages afterwards keeps them. if os_is_debian; then - pkg_install libpcre2-8-0 libxml2 libyajl2 + pkg_update_once + pkg_install libpcre2-8-0 "$(_modsecurity_deb_candidate libxml2-16 libxml2)" libyajl2 _modsecurity_build_deps g++ make pkg-config libpcre2-dev libxml2-dev libyajl-dev yajl=--with-yajl else From b26632308b8098e791c43ec6926a28ad804b4420 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Wed, 14 Oct 2026 16:55:09 +0200 Subject: [PATCH 58/92] fix(database): ask the MariaDB server for its data directory Ubuntu 26.04 compiles /var/lib/mariadb in and sets no datadir in a configuration file, so print-defaults found nothing and the sandbox allowed /var/lib/mysql instead. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- modules/database/mariadb.sh | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/modules/database/mariadb.sh b/modules/database/mariadb.sh index ca7a09f..4b29fc3 100644 --- a/modules/database/mariadb.sh +++ b/modules/database/mariadb.sh @@ -19,12 +19,13 @@ MARIADB_UNIT=mariadb # Where MySQL keeps its data; MariaDB used the same directory until Ubuntu 26.04 moved it to /var/lib/mariadb. MARIADB_MYSQL_DATA=/var/lib/mysql -# _mariadb_data: the data directory the installed server is configured for, one of the two the packages use. +# _mariadb_data: the data directory the installed server uses, one of the two the packages use. The server reports +# it itself, because Ubuntu 26.04 compiles /var/lib/mariadb in instead of setting datadir in a configuration file. _mariadb_data() { - local dir='' tool - for tool in mariadb-print-defaults my_print_defaults; do - command -v "${tool}" >/dev/null 2>&1 || continue - dir=$("${tool}" --mysqld 2>/dev/null | sed -n 's/^--datadir=//p' | tail -n 1) + local dir='' server + for server in /usr/sbin/mariadbd /usr/libexec/mariadbd /usr/sbin/mysqld /usr/libexec/mysqld; do + [[ -x ${server} ]] || continue + dir=$("${server}" --verbose --help 2>/dev/null | awk '$1 == "datadir" { print $2 }') break done dir=${dir%/} From 462482b6c0b5e7fa80814084b693f1a41ac71b63 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Wed, 14 Oct 2026 16:55:11 +0200 Subject: [PATCH 59/92] fix(web): build the nginx connector with PCRE1 for nginx before 1.21.5 nginx 1.18 on Ubuntu 22.04 cannot use PCRE2, so its configure stopped with 'the HTTP rewrite module requires the PCRE library'. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- modules/web/modsecurity.sh | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/modules/web/modsecurity.sh b/modules/web/modsecurity.sh index 584cf4b..ff808a0 100644 --- a/modules/web/modsecurity.sh +++ b/modules/web/modsecurity.sh @@ -279,13 +279,18 @@ _modsecurity_build_library() { # load into the distribution's nginx, which is built with the same switch. _modsecurity_build_connector() { local so="${MODSEC_PREFIX}/nginx/ngx_http_modsecurity_module.so" stamp="${MODSEC_PREFIX}/nginx/.build" - local version src tree + local version src tree pcre=libpcre2-dev local -a env=("MODSECURITY_INC=${MODSEC_PREFIX}/include" "MODSECURITY_LIB=${MODSEC_PREFIX}/lib") if [[ -f ${so} && -f ${stamp} && $(<"${stamp}") == "$(_modsecurity_connector_id)" ]]; then return 0 fi if os_is_debian; then - _modsecurity_build_deps gcc make libc6-dev libpcre2-dev zlib1g-dev + # nginx reads PCRE2 from 1.21.5 on; the nginx 1.18 of Ubuntu 22.04 still needs PCRE1. + version=$(_modsecurity_nginx_version) + if [[ $(printf '%s\n' 1.21.5 "${version}" | sort -V | head -n 1) != 1.21.5 ]]; then + pcre=libpcre3-dev + fi + _modsecurity_build_deps gcc make libc6-dev "${pcre}" zlib1g-dev else # nginx-mod-devel requires the nginx of its own release and may update nginx; the version is read afterwards. _modsecurity_build_deps gcc make pcre2-devel zlib-devel nginx-mod-devel From 479d427b16ed78b324211ad8939031e7c85254a3 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Wed, 14 Oct 2026 16:55:13 +0200 Subject: [PATCH 60/92] fix(web): write the fail2ban jails while the web server is being installed web_server reads the recorded state, which a module gets only after it ran. nginx therefore removed the jails on its first install and the second run wrote them, so profile all was not idempotent. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- lib/web.sh | 7 ++++++- tests/unit/web.bats | 10 ++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/lib/web.sh b/lib/web.sh index 30e1a0d..3ed1039 100644 --- a/lib/web.sh +++ b/lib/web.sh @@ -650,10 +650,15 @@ web_admin_htpasswd() { } # web_ids_configure: lets fail2ban or CrowdSec read the logs of the installed web server. Called by the web server -# modules and by fail2ban and crowdsec, whichever is installed last. +# modules and by fail2ban and crowdsec, whichever is installed last. A web server module counts while it is being +# installed, because its state is recorded only after it. web_ids_configure() { local server logs server=$(web_server) + case ${LSS_MODULE:-} in + nginx | apache | caddy) server=${LSS_MODULE} ;; + *) ;; + esac if [[ -z ${server} ]]; then file_remove "${WEB_FAIL2BAN_JAIL}" file_remove "${WEB_FAIL2BAN_FILTER}" diff --git a/tests/unit/web.bats b/tests/unit/web.bats index af0a26a..bfa14ed 100644 --- a/tests/unit/web.bats +++ b/tests/unit/web.bats @@ -782,6 +782,16 @@ EOF run ! grep -qE 'fail2ban-client|restart' "${STUB_LOG}" } +@test "nginx writes the jails during its own first install, before its state is recorded" { + as_test_user + test_log_dir + use_server fail2ban + LSS_MODULE=nginx + web_ids_configure + grep -qxF '[nginx-http-auth]' "${WEB_FAIL2BAN_JAIL}" + grep -qxF '[nginx-botsearch]' "${WEB_FAIL2BAN_JAIL}" +} + @test "Caddy gets its own basic-auth filter and jail" { as_test_user test_log_dir From b78a6e010c9a76dfc0937f487ee4a13383283fda Mon Sep 17 00:00:00 2001 From: VectoDE Date: Wed, 14 Oct 2026 16:55:15 +0200 Subject: [PATCH 61/92] test: end hanging lss runs after 40 minutes and keep kcov's trace out of the log A hanging install (MySQL in containers) used up the 90 minutes of the job and left no diagnostics; the diagnostics now also show the process tree. kcov printed pieces of its bash trace, including test data that starts with No, into the job log. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- scripts/dev/coverage.sh | 6 ++++-- tests/integration/run.sh | 17 ++++++++++------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/scripts/dev/coverage.sh b/scripts/dev/coverage.sh index 8d6ef30..cfd2da8 100755 --- a/scripts/dev/coverage.sh +++ b/scripts/dev/coverage.sh @@ -16,9 +16,11 @@ rm -rf -- "${out}" mkdir -p "${out}" docker run --rm --security-opt seccomp=unconfined \ -v "${PWD}:/src" -v "${bats_dir}:/bats:ro" -v "$(cd "${out}" && pwd):/out" -w /src \ - "${image}" kcov --include-path=/src/lib --bash-dont-parse-binary-dir /out /bats/bin/bats tests/unit >"${out}/bats.tap" || { - # The failing tests with their output; the rest of the TAP stream is in OUT_DIR/bats.tap. + "${image}" kcov --include-path=/src/lib --bash-dont-parse-binary-dir /out /bats/bin/bats tests/unit >"${out}/bats.tap" 2>"${out}/kcov.log" || { + # The failing tests with their output; the rest of the TAP stream is in OUT_DIR/bats.tap. kcov's stderr carries + # pieces of its bash trace, so it stays in OUT_DIR/kcov.log unless the run fails. grep -A 20 '^not ok' "${out}/bats.tap" >&2 || true + tail -n 40 "${out}/kcov.log" >&2 exit 1 } report=$(find "${out}" -name coverage.json -path '*bats*' | head -1) diff --git a/tests/integration/run.sh b/tests/integration/run.sh index 642c7e2..3794a26 100755 --- a/tests/integration/run.sh +++ b/tests/integration/run.sh @@ -23,6 +23,8 @@ shift } settings=tests/integration/settings.conf container="lss-test-${system}" +# A run that hangs ends after 40 minutes, so the diagnostics below still show what it waited for. +LSS_RUN="timeout --kill-after=60 2400 lss" # shellcheck source=tests/integration/vm.sh source tests/integration/vm.sh # shellcheck source=tests/integration/pebble.sh @@ -82,7 +84,7 @@ start_target() { # Fails when a second install changed anything: every step must be idempotent. install_again() { local second - second=$(in_target "lss --yes --no-color install $* 2>&1") || { + second=$(in_target "${LSS_RUN} --yes --no-color install $* 2>&1") || { printf '%s\n' "${second}" exit 1 } @@ -114,6 +116,7 @@ stop_target() { echo "--- ${unit}" journalctl -u "${unit}" -n 40 --no-pager done + ps -eo pid,ppid,etime,args --forest | tail -n 60 journalctl -p warning -n 80 --no-pager' &1 || diagnostics=$? echo "Diagnostics finished (status ${diagnostics})." fi @@ -173,13 +176,13 @@ if [[ $1 == --profile ]]; then before=$(resources_snapshot) started=${SECONDS} memory_watch_start - in_target "lss --yes --no-color install --profile ${profile}" - in_target "lss --yes --no-color verify" + in_target "${LSS_RUN} --yes --no-color install --profile ${profile}" + in_target "${LSS_RUN} --yes --no-color verify" resources_report "${system} profile ${profile}" "${before}" "${started}" install_again --profile "${profile}" group_end group_start "${system}: Lynis audit" - in_target "lss --yes --no-color --set LSS_LYNIS_MIN_INDEX=${3:-0} audit" + in_target "${LSS_RUN} --yes --no-color --set LSS_LYNIS_MIN_INDEX=${3:-0} audit" group_end # The daily and weekly scans run once inside their systemd sandbox; a path the sandbox blocks fails the unit. group_start "${system}: scheduled scans" @@ -214,13 +217,13 @@ for item in "$@"; do before=$(resources_snapshot) started=${SECONDS} memory_watch_start - in_target "lss --yes --no-color install ${set[*]}" - in_target "lss --yes --no-color verify" + in_target "${LSS_RUN} --yes --no-color install ${set[*]}" + in_target "${LSS_RUN} --yes --no-color verify" resources_report "${system} ${set[*]}" "${before}" "${started}" install_again "${set[@]}" if [[ ${LSS_TEST_KEEP:-0} != 1 ]]; then for ((i = ${#set[@]} - 1; i >= 0; i--)); do - in_target "lss --yes --no-color remove ${set[i]}" + in_target "${LSS_RUN} --yes --no-color remove ${set[i]}" in_target "! lss status | grep -q '^${set[i]} '" done fi From 61d0f48286b4ed4d47722982cd53feef838a4a12 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Wed, 14 Oct 2026 17:14:22 +0200 Subject: [PATCH 62/92] fix(base): flush the swap file before swapon On XFS (AlmaLinux 10) a freshly written swap file can still consist of delayed allocations, which swapon rejects with 'Invalid argument'. Refs #12 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- modules/base/swap.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/modules/base/swap.sh b/modules/base/swap.sh index 7879bb2..89a0833 100644 --- a/modules/base/swap.sh +++ b/modules/base/swap.sh @@ -70,6 +70,8 @@ mod_configure() { chmod 600 "${path}" # mkswap reports on stdout; --quiet exists only from util-linux 2.38 on (Rocky 9 has 2.37). mkswap "${path}" >/dev/null + # XFS keeps freshly written blocks as delayed allocations, and swapon refuses those ("Invalid argument"). + sync -- "${path}" files_note_change "swap file ${path} (${size} MiB)" fi chmod 600 "${path}" From 2a49aabdce3f6c492fc1d8c362ecd5cd77c15776 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Wed, 14 Oct 2026 18:54:01 +0200 Subject: [PATCH 63/92] fix(service): stop waiting for a unit after five minutes MySQL's units wait for their service without a limit. A service that never reported ready kept lss waiting until the CI job was cancelled; now lss shows the unit's status and journal and stops the step. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- lib/service.sh | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/service.sh b/lib/service.sh index 02fe6a6..5ce5035 100644 --- a/lib/service.sh +++ b/lib/service.sh @@ -4,6 +4,10 @@ # systemd helpers. +# Seconds lss waits for a unit to start. Some units (MySQL) wait for their service without a limit; a service that +# never reports ready would stop the run for good. +SVC_START_TIMEOUT=${SVC_START_TIMEOUT:-300} + svc_exists() { systemctl list-unit-files --no-legend "$1" "$1.service" 2>/dev/null | grep -q . return @@ -25,7 +29,7 @@ svc_enable() { if svc_is_enabled "${unit}" && svc_is_active "${unit}"; then return 0 fi - systemctl enable --now "${unit}" >/dev/null 2>&1 || { + timeout "${SVC_START_TIMEOUT}" systemctl enable --now "${unit}" >/dev/null 2>&1 || { systemctl status --no-pager "${unit}" >&2 || true journalctl -u "${unit}" -n 30 --no-pager >&2 || true die "Could not start ${unit}." @@ -55,7 +59,7 @@ svc_disable() { } svc_restart() { - systemctl restart "$1" || { + timeout "${SVC_START_TIMEOUT}" systemctl restart "$1" || { systemctl status --no-pager "$1" >&2 || true journalctl -u "$1" -n 30 --no-pager >&2 || true return 1 @@ -63,7 +67,7 @@ svc_restart() { } svc_reload() { - systemctl reload-or-restart "$1" || { + timeout "${SVC_START_TIMEOUT}" systemctl reload-or-restart "$1" || { systemctl status --no-pager "$1" >&2 || true return 1 } From 3a60905918b69ab054fc4f2d2abba7eea4141b0d Mon Sep 17 00:00:00 2001 From: VectoDE Date: Wed, 14 Oct 2026 18:54:03 +0200 Subject: [PATCH 64/92] fix(database): let MySQL write its error log and socket directory The Debian and Ubuntu packages log to /var/log/mysql and create /run/mysqld in ExecStartPre; under the sandbox both were read-only. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- modules/database/mysql.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/modules/database/mysql.sh b/modules/database/mysql.sh index 1411b38..b0c3973 100644 --- a/modules/database/mysql.sh +++ b/modules/database/mysql.sh @@ -499,8 +499,10 @@ EOF elif os_is_rhel; then _mysql_rhel_initialize fi + # The error log and the socket directory as well: the Debian and Ubuntu packages log to /var/log/mysql and create + # /run/mysqld in ExecStartPre, without RuntimeDirectory. svc_sandbox "${unit}.service" "ReadWritePaths=${MYSQL_DATA}" ReadWritePaths=-/var/lib/mysql-files \ - ReadWritePaths=-/var/lib/mysql-keyring + ReadWritePaths=-/var/lib/mysql-keyring ReadWritePaths=-/var/log/mysql ReadWritePaths=-/run/mysqld [[ $(files_changes_of mysql) == "${before}" ]] || changed=1 svc_enable "${unit}" if [[ ${changed} == 1 ]]; then From dbd9b4686ea546519fa2a90b1efc8aefe5510062 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Wed, 14 Oct 2026 18:54:05 +0200 Subject: [PATCH 65/92] feat(web): build mod_security2 for Apache on Ubuntu 22.04 and use CRS 4 everywhere Ubuntu 22.04 ships ModSecurity 2.9.5, which cannot load CRS 3.3.10 ('Unknown variable: &MULTIPART_PART_HEADERS') nor CRS 4. lss builds 2.9.15 from the release signed by OWASP ModSecurity, so every system runs CRS 4.25 with every rule exclusion plugin; the CRS 3.3 path is gone. A missing audit log entry now shows the web server's error log. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- data/checksums.txt | 4 +- modules/web/modsecurity.sh | 132 +++++++++++++++++++++---------------- 2 files changed, 78 insertions(+), 58 deletions(-) diff --git a/data/checksums.txt b/data/checksums.txt index bbeda70..1d01722 100644 --- a/data/checksums.txt +++ b/data/checksums.txt @@ -39,10 +39,12 @@ mysql-generic aarch64 04b2f9791d314167a9eb83abcb476f45a7cd9e4aa88fa7a638cba40d1b # security@coreruleset.org, 3600 6F0E 0BA1 6783 2158 8211 38EE ACA1 AB8A 6E72). 4.25 is the long-term-support line; # 3.3 serves the ModSecurity v2 of Ubuntu 22.04 (Apache). coreruleset-4.25.2 any a2d4b2a5bb4f397f7e40d4e22d0346350706a5f17c1cfefc09e8b9740e00d87e https://github.com/coreruleset/coreruleset/releases/download/v4.25.2/coreruleset-4.25.2-minimal.tar.gz -coreruleset-3.3.10 any 0c35a9e14f8af3cf02fc29eaae807312c6555d0f9bfa306274268ab5913c1110 https://github.com/coreruleset/coreruleset/releases/download/v3.3.10/coreruleset-3.3.10-minimal.tar.gz # modsecurity: libmodsecurity and the nginx connector (every system with nginx), from the .sha256 files the OWASP # ModSecurity project publishes with each release. libmodsecurity-3.0.17 any f283b33d5c21130fd3a15c84a93a1abe12bd1949edaf44288b12af571b671a50 https://github.com/owasp-modsecurity/ModSecurity/releases/download/v3.0.17/modsecurity-v3.0.17.tar.gz +# mod_security2 for Apache on Ubuntu 22.04, signed by OWASP ModSecurity +# (0B2B A192 4065 B446 9120 2A2A D286 E022 149F 0F6E), checked 2026-10-14. +modsecurity-apache-2.9.15 any 4fae2d684672a24541607cb0b563d88b88eb4543b88cd090345149bb47309477 https://github.com/owasp-modsecurity/ModSecurity/releases/download/v2.9.15/modsecurity-v2.9.15.tar.gz modsecurity-nginx-1.0.4 any 6bdc7570911be884c1e43aaf85046137f9fde0cfa0dd4a55b853c81c45a13313 https://github.com/owasp-modsecurity/ModSecurity-nginx/releases/download/v1.0.4/ModSecurity-nginx-v1.0.4.tar.gz # modsecurity: nginx sources of the versions the distributions ship (Ubuntu 22.04 1.18.0, Debian 12 1.22.1, Ubuntu # 24.04 1.24.0, Debian 13 1.26.3, Ubuntu 26.04 1.28.3), for the connector build. nginx.org publishes PGP signatures diff --git a/modules/web/modsecurity.sh b/modules/web/modsecurity.sh index ff808a0..4c5a886 100644 --- a/modules/web/modsecurity.sh +++ b/modules/web/modsecurity.sh @@ -9,30 +9,29 @@ MOD_REQUIRES="apache|nginx" MOD_OPTIONS="mode|On|On blocks attacks; DetectionOnly only logs them (to try the rules on a live site first) paranoia|1|Paranoia level 1 to 4: higher levels catch more attacks and also block more legitimate requests anomaly_inbound|5|Anomaly score from which a request is blocked (5: one critical rule matched; higher values block less) -exclusions||Rule exclusions for applications, separated by spaces: cpanel dokuwiki drupal nextcloud phpbb phpmyadmin wordpress xenforo (phpbb and phpmyadmin need CRS 4, which Apache on Ubuntu 22.04 does not get) +exclusions||Rule exclusions for applications, separated by spaces: cpanel dokuwiki drupal nextcloud phpbb phpmyadmin wordpress xenforo allowed_methods|GET HEAD POST OPTIONS PUT PATCH DELETE|HTTP methods the rules let through, separated by spaces max_body||Largest request body ModSecurity accepts and inspects, for example 64m (at most 1g); empty takes max_body of the web server" # Where the engine and the rules come from: -# Apache libapache2-mod-security2 (Debian, Ubuntu), mod_security (RHEL 9 AppStream, RHEL 10 EPEL). +# Apache libapache2-mod-security2 (Debian, Ubuntu), mod_security (RHEL 9 AppStream, RHEL 10 EPEL). Ubuntu 22.04 +# ships 2.9.5, too old for CRS 4 (2.9.6), so lss builds mod_security2 there from the signed OWASP release. # nginx libmodsecurity and the nginx connector, built by lss from checksum-verified sources on every system: -# CRS 4.25.1 and 3.3.10 need libmodsecurity 3.0.16 (the fix against XML attribute injection), and the +# CRS 4.25.1 needs libmodsecurity 3.0.16 (the fix against XML attribute injection), and the # distributions ship 3.0.6 to 3.0.14 (RHEL 10 none). The connector is a dynamic module (--with-compat) # for the installed nginx, built again only when the nginx version changes. # Rules The OWASP Core Rule Set from its signed GitHub releases, pinned in data/checksums.txt, so every system # runs the same rules (the distributions ship CRS 3.3 of differing ages, RHEL 10 none). CRS 4 needs # ModSecurity 2.9.6 or 3.0.8; its long-term-support line 4.25 changes less between updates than the -# monthly releases. Apache on Ubuntu 22.04 has ModSecurity 2.9.5 and therefore gets CRS 3.3, which is still -# maintained. +# monthly releases. MODSEC_CRS_VERSION=4.25.2 -MODSEC_CRS_LEGACY_VERSION=3.3.10 MODSEC_LIB_VERSION=3.0.17 +MODSEC_APACHE_VERSION=2.9.15 MODSEC_CONNECTOR_VERSION=1.0.4 # Below /usr/local/lib, so SELinux labels the self-built libraries as libraries. MODSEC_PREFIX=/usr/local/lib/lss-modsecurity MODSEC_CRS_ROOT=/usr/local/share/lss-modsecurity MODSEC_PLUGINS="cpanel dokuwiki drupal nextcloud phpbb phpmyadmin wordpress xenforo" -MODSEC_LEGACY_EXCLUSIONS="cpanel dokuwiki drupal nextcloud wordpress xenforo" # The web server modules serve this file on port 80 for every host name; verify sends its probes there. MODSEC_PROBE=/.well-known/acme-challenge/lss-probe MODSEC_BUILD_DEPS=() @@ -49,19 +48,19 @@ _modsecurity_server() { return } -# True for Apache on Ubuntu 22.04, whose ModSecurity 2.9.5 is too old for CRS 4. -_modsecurity_legacy() { - [[ ${OS_ID} == ubuntu && ${OS_MAJOR} == 22 && $(web_server) == apache ]] +# True where lss builds mod_security2 for Apache itself: Ubuntu 22.04 ships 2.9.5, and CRS 4 needs 2.9.6. +_modsecurity_apache_built() { + [[ ${OS_ID} == ubuntu && ${OS_MAJOR} == 22 ]] return } -_modsecurity_crs_version() { - if _modsecurity_legacy; then printf '%s' "${MODSEC_CRS_LEGACY_VERSION}"; else printf '%s' "${MODSEC_CRS_VERSION}"; fi +_modsecurity_apache_so() { + printf '%s/apache/mod_security2.so' "${MODSEC_PREFIX}" return } _modsecurity_crs_dir() { - printf '%s/coreruleset-%s' "${MODSEC_CRS_ROOT}" "$(_modsecurity_crs_version)" + printf '%s/coreruleset-%s' "${MODSEC_CRS_ROOT}" "${MODSEC_CRS_VERSION}" return } @@ -153,6 +152,8 @@ _modsecurity_json() { local server=$1 engine if [[ ${server} == nginx ]]; then engine=$(_modsecurity_nginx_so) + elif _modsecurity_apache_built; then + engine=$(_modsecurity_apache_so) elif os_is_debian; then engine=/usr/lib/apache2/modules/mod_security2.so else @@ -226,8 +227,6 @@ _modsecurity_jobs() { return } -# libmodsecurity for nginx. Without curl, GeoIP, LMDB, Lua and ssdeep: the Core Rule Set needs none of them. With YAJL -# (JSON request bodies) where the distribution has it; RHEL 10 has none. Built again only for a new version. # _modsecurity_deb_candidate PACKAGE...: the first of the names apt can install. Runtime libraries change their name # with a new ABI (libxml2 is libxml2-16 from Ubuntu 26.04 on). _modsecurity_deb_candidate() { @@ -243,6 +242,8 @@ _modsecurity_deb_candidate() { return } +# libmodsecurity for nginx. Without curl, GeoIP, LMDB, Lua and ssdeep: the Core Rule Set needs none of them. With YAJL +# (JSON request bodies) where the distribution has it; RHEL 10 has none. Built again only for a new version. _modsecurity_build_library() { local stamp="${MODSEC_PREFIX}/.libmodsecurity" src yajl=--without-yajl if [[ -f ${MODSEC_PREFIX}/lib/libmodsecurity.so.3 && -f ${stamp} && $(<"${stamp}") == "${MODSEC_LIB_VERSION}" ]]; then @@ -328,8 +329,38 @@ _modsecurity_build_connector() { return } +# mod_security2 for Apache where the distribution's is too old (_modsecurity_apache_built), from the signed OWASP +# release pinned in data/checksums.txt. With YAJL, without curl, Lua and ssdeep, like libmodsecurity. Built again only +# for a new version. +_modsecurity_build_apache() { + local so stamp="${MODSEC_PREFIX}/apache/.build" src + so=$(_modsecurity_apache_so) + if [[ -f ${so} && -f ${stamp} && $(<"${stamp}") == "${MODSEC_APACHE_VERSION}" ]]; then + return 0 + fi + pkg_update_once + pkg_install libpcre2-8-0 "$(_modsecurity_deb_candidate libxml2-16 libxml2)" libyajl2 + _modsecurity_build_deps gcc make apache2-dev libpcre2-dev libxml2-dev libyajl-dev + _modsecurity_workdir + fetch_verified "modsecurity-apache-${MODSEC_APACHE_VERSION}" "${MODSEC_WORK}/modsecurity2.tar.gz" + tar -xzf "${MODSEC_WORK}/modsecurity2.tar.gz" -C "${MODSEC_WORK}" --no-same-owner + src="${MODSEC_WORK}/modsecurity-v${MODSEC_APACHE_VERSION}" + log_info "Building ModSecurity ${MODSEC_APACHE_VERSION} for Apache; this takes a few minutes" + (cd "${src}" && log_command ./configure --with-apxs=/usr/bin/apxs --with-pcre2 --with-yajl --without-curl \ + --without-lua --without-ssdeep --disable-mlogc) || die "modsecurity: mod_security2 does not configure; see the log." + log_command make -C "${src}" -j "$(_modsecurity_jobs)" || die "modsecurity: mod_security2 does not build; see the log." + install -D -m 0644 "${src}/apache2/.libs/mod_security2.so" "${so}" + printf '%s\n' "${MODSEC_APACHE_VERSION}" >"${stamp}" + files_note_change "built ModSecurity ${MODSEC_APACHE_VERSION} for Apache" + return +} + _modsecurity_install_apache() { - if os_is_debian; then + if _modsecurity_apache_built; then + # The package of earlier lss versions would load its 2.9.5 instead of the self-built module. + pkg_remove libapache2-mod-security2 + _modsecurity_build_apache + elif os_is_debian; then pkg_install libapache2-mod-security2 else # RHEL 10 has mod_security only in EPEL. @@ -402,19 +433,15 @@ mod_install() { return } -# _modsecurity_engine_conf SERVER: the engine settings after modsecurity.conf-recommended of ModSecurity, limited to -# what the oldest engine in use understands (2.9.5 for Apache on Ubuntu 22.04). +# _modsecurity_engine_conf SERVER: the engine settings after modsecurity.conf-recommended of ModSecurity. _modsecurity_engine_conf() { - local server=$1 bytes nofiles json=0 modern=1 + local server=$1 bytes nofiles json=0 bytes=$(_modsecurity_bytes "$(_modsecurity_max_body)") # Bodies without uploads (forms, JSON) up to 1 MiB: the recommended 128 KiB turns away ordinary JSON documents. nofiles=$((bytes < 1048576 ? bytes : 1048576)) if _modsecurity_json "${server}"; then json=1 fi - if _modsecurity_legacy; then - modern=0 - fi printf '# Managed by lss (module modsecurity). Engine settings after modsecurity.conf-recommended.\n' printf 'SecRuleEngine %s\nSecRequestBodyAccess On\n' "$(_modsecurity_mode)" cat <<'EOF' @@ -424,12 +451,10 @@ EOF cat <<'EOF' SecRule REQUEST_HEADERS:Content-Type "^application/json" "id:'200001',phase:1,t:none,t:lowercase,pass,nolog,ctl:requestBodyProcessor=JSON" EOF - if [[ ${server} == apache || ${modern} == 1 ]]; then - printf 'SecRequestBodyJsonDepthLimit 512\n' - fi + printf 'SecRequestBodyJsonDepthLimit 512\n' fi printf 'SecRequestBodyLimit %s\nSecRequestBodyNoFilesLimit %s\nSecRequestBodyLimitAction Reject\n' "${bytes}" "${nofiles}" - if [[ ${server} == nginx && ${modern} == 1 ]]; then + if [[ ${server} == nginx ]]; then cat <<'EOF' SecArgumentsLimit 1000 SecRule &ARGS "@ge 1000" "id:'200007',phase:2,t:none,log,deny,status:400,msg:'Failed to fully parse request body due to large argument count',severity:2" @@ -467,48 +492,26 @@ EOF # The CRS settings that crs-setup.conf.example of the release documents; everything else keeps its default. _modsecurity_crs_setup() { - local version tag='' paranoia=tx.blocking_paranoia_level phase name exclusions='' + local version=${MODSEC_CRS_VERSION} tag phase local -a methods=() - version=$(_modsecurity_crs_version) read -ra methods <<<"$(opt allowed_methods)" printf '# Managed by lss (module modsecurity). OWASP CRS %s settings.\n' "${version}" - if _modsecurity_legacy; then - paranoia=tx.paranoia_level - printf 'SecDefaultAction "phase:%s,log,auditlog,pass"\n' 1 2 - for name in $(opt exclusions); do - if [[ " ${MODSEC_LEGACY_EXCLUSIONS} " == *" ${name} "* ]]; then - exclusions+=",setvar:tx.crs_exclusions_${name}=1" - fi - done - else - tag=",tag:'OWASP_CRS',ver:'OWASP_CRS/${version}'" - for phase in 1 2 3 4 5; do - printf 'SecDefaultAction "phase:%s,log,auditlog,pass"\n' "${phase}" - done - fi - printf 'SecAction "id:900000,phase:1,pass,t:none,nolog%s,setvar:%s=%s"\n' "${tag}" "${paranoia}" "$(opt paranoia)" + tag=",tag:'OWASP_CRS',ver:'OWASP_CRS/${version}'" + for phase in 1 2 3 4 5; do + printf 'SecDefaultAction "phase:%s,log,auditlog,pass"\n' "${phase}" + done + printf 'SecAction "id:900000,phase:1,pass,t:none,nolog%s,setvar:tx.blocking_paranoia_level=%s"\n' "${tag}" "$(opt paranoia)" printf 'SecAction "id:900110,phase:1,pass,t:none,nolog%s,setvar:tx.inbound_anomaly_score_threshold=%s,setvar:tx.outbound_anomaly_score_threshold=4"\n' \ "${tag}" "$(opt anomaly_inbound)" - if [[ -n ${exclusions} ]]; then - printf 'SecAction "id:900130,phase:1,pass,t:none,nolog%s"\n' "${exclusions}" - fi printf "SecAction \"id:900200,phase:1,pass,t:none,nolog%s,setvar:'tx.allowed_methods=%s'\"\n" "${tag}" "${methods[*]}" printf 'SecAction "id:900990,phase:1,pass,t:none,nolog%s,setvar:tx.crs_setup_version=%s"\n' "${tag}" "${version//./}" return } # CRS 4 ships the application exclusions as plugins, one repository each without release files; data/checksums.txt -# pins their files by commit. CRS 3.3 has them built in (crs-setup.conf switches them on). +# pins their files by commit. _modsecurity_plugins() { local dir=$1 name kind file entry - if _modsecurity_legacy; then - file_remove "${dir}/plugins" - for name in $(opt exclusions); do - [[ " ${MODSEC_LEGACY_EXCLUSIONS} " == *" ${name} "* ]] || - log_warn "modsecurity: CRS 3.3 (Apache on Ubuntu 22.04) has no rule exclusions for ${name}; skipped." - done - return 0 - fi install -d -m 0755 "${dir}/plugins" for name in ${MODSEC_PLUGINS}; do for kind in config before after; do @@ -543,7 +546,7 @@ _modsecurity_plugin_includes() { # Every file is named, because ModSecurity 3 has no IncludeOptional for patterns without a match. _modsecurity_main_conf() { local dir=$1 - printf '# Managed by lss (module modsecurity). ModSecurity with the OWASP Core Rule Set %s.\n' "$(_modsecurity_crs_version)" + printf '# Managed by lss (module modsecurity). ModSecurity with the OWASP Core Rule Set %s.\n' "${MODSEC_CRS_VERSION}" printf 'Include %s\n' "${dir}/modsecurity.conf" "${dir}/crs-setup.conf" _modsecurity_plugin_includes "${dir}" config _modsecurity_plugin_includes "${dir}" before @@ -580,6 +583,13 @@ _modsecurity_apache_file() { _modsecurity_apache_enable() { local dir=$1 + if _modsecurity_apache_built; then + file_write 0644 root:root /etc/apache2/mods-available/security2.load < @@ -709,7 +719,12 @@ mod_verify() { fi sleep 1 done - [[ ${found} == 1 ]] || die "modsecurity: the request with a script tag is missing from the audit log ${log}." + if [[ ${found} == 0 ]]; then + # The engine reports why it cannot write the audit log in the error log of the web server. + ls -l "${log%/*}" >&2 || true + tail -n 20 "$(web_log_file "${server}" error)" >&2 || true + die "modsecurity: the request with a script tag is missing from the audit log ${log}." + fi return } @@ -727,6 +742,9 @@ mod_remove() { files_note_change "apache module security2 off" fi file_remove "$(_modsecurity_apache_file)" + if _modsecurity_apache_built; then + file_remove /etc/apache2/mods-available/security2.load + fi svc_dropin_remove "$(web_unit apache).service" lss-modsecurity ;; *) ;; From 614d6228a8290cd7aa585bd591f68d14965438fe Mon Sep 17 00:00:00 2001 From: VectoDE Date: Wed, 14 Oct 2026 21:21:15 +0200 Subject: [PATCH 66/92] fix(web): use the pinned CRS version; show MySQL and AppArmor diagnostics The CRS install still called the removed version helper. The MySQL start under the sandbox hangs on the amd64 runners only; their error log and the runner's AppArmor denials show why. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- modules/web/modsecurity.sh | 2 +- tests/integration/run.sh | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/modules/web/modsecurity.sh b/modules/web/modsecurity.sh index 4c5a886..9a5ad50 100644 --- a/modules/web/modsecurity.sh +++ b/modules/web/modsecurity.sh @@ -381,7 +381,7 @@ _modsecurity_install_nginx() { # The Core Rule Set below MODSEC_CRS_ROOT, one directory per version; configure removes older ones. _modsecurity_crs_install() { local version dir staging - version=$(_modsecurity_crs_version) + version=${MODSEC_CRS_VERSION} dir=$(_modsecurity_crs_dir) [[ ! -f ${dir}/rules/REQUEST-949-BLOCKING-EVALUATION.conf ]] || return 0 _modsecurity_workdir diff --git a/tests/integration/run.sh b/tests/integration/run.sh index 3794a26..cd05a4a 100755 --- a/tests/integration/run.sh +++ b/tests/integration/run.sh @@ -117,7 +117,12 @@ stop_target() { journalctl -u "${unit}" -n 40 --no-pager done ps -eo pid,ppid,etime,args --forest | tail -n 60 + tail -n 30 /var/log/mysql/error.log /var/log/mariadb/mariadb.log 2>/dev/null journalctl -p warning -n 80 --no-pager' &1 || diagnostics=$? + # Containers share the kernel of the runner, whose AppArmor profiles still apply to them. + if [[ ${system} != vm-* && ${system} != host ]]; then + sudo dmesg 2>/dev/null | grep -F 'apparmor=' | tail -n 30 + fi echo "Diagnostics finished (status ${diagnostics})." fi if [[ ${system} == vm-* ]]; then From ae9e99d022219d63621cf7ff111e8b21ca39a0e2 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Wed, 14 Oct 2026 22:23:01 +0200 Subject: [PATCH 67/92] fix(mysql): keep mysqld's start notification working under AppArmor Ubuntu's AppArmor profile of mysqld has no attach_disconnected, so in the mount namespace of the sandbox it denies mysqld the systemd notification socket and the start never completes. Where that profile confines mysqld (or AppArmor is on and the profile list is hidden, as in a container), the sandbox leaves out the settings that need a mount namespace; the profile confines mysqld's files instead. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- lib/service.sh | 17 +++++++++++++++++ modules/database/mysql.sh | 9 +++++++-- tests/unit/system.bats | 14 ++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/lib/service.sh b/lib/service.sh index 5ce5035..64384f6 100644 --- a/lib/service.sh +++ b/lib/service.sh @@ -151,6 +151,23 @@ svc_sandbox() { return } +# Settings that turn off every part of the svc_sandbox baseline that needs a mount namespace (the empty ReadWritePaths= +# clears the list). An AppArmor profile without attach_disconnected cannot name paths seen from such a namespace and +# refuses the service its systemd notification socket; that profile confines the files of the service instead. +# shellcheck disable=SC2034 # read by modules +SVC_SANDBOX_NO_MOUNTS=(ProtectSystem=no ProtectHome=no PrivateTmp=no PrivateDevices=no ProtectKernelTunables=no + ProtectKernelModules=no ProtectKernelLogs=no ProtectControlGroups=no ProtectHostname=no ReadWritePaths=) + +# svc_apparmor_confined PROGRAM: whether an AppArmor profile confines PROGRAM. Without a readable profile list (in +# some containers) an enabled AppArmor counts as confining. +svc_apparmor_confined() { + local enabled=${SVC_APPARMOR_ENABLED:-/sys/module/apparmor/parameters/enabled} + local profiles=${SVC_APPARMOR_PROFILES:-/sys/kernel/security/apparmor/profiles} + [[ $(cat "${enabled}" 2>/dev/null) == Y ]] || return 1 + [[ -r ${profiles} ]] || return 0 + grep -qF "$1 (" "${profiles}" +} + # svc_unit UNIT: writes /etc/systemd/system/UNIT (a unit of lss's own) from stdin and reloads systemd when it # changed. svc_unit() { diff --git a/modules/database/mysql.sh b/modules/database/mysql.sh index b0c3973..d6c6a84 100644 --- a/modules/database/mysql.sh +++ b/modules/database/mysql.sh @@ -500,9 +500,14 @@ EOF _mysql_rhel_initialize fi # The error log and the socket directory as well: the Debian and Ubuntu packages log to /var/log/mysql and create - # /run/mysqld in ExecStartPre, without RuntimeDirectory. + # /run/mysqld in ExecStartPre, without RuntimeDirectory. Ubuntu's AppArmor profile of mysqld (also the host's, + # for a container) confines its files itself and breaks its start notification in a mount namespace. + local -a relax=() + if svc_apparmor_confined /usr/sbin/mysqld; then + relax=("${SVC_SANDBOX_NO_MOUNTS[@]}") + fi svc_sandbox "${unit}.service" "ReadWritePaths=${MYSQL_DATA}" ReadWritePaths=-/var/lib/mysql-files \ - ReadWritePaths=-/var/lib/mysql-keyring ReadWritePaths=-/var/log/mysql ReadWritePaths=-/run/mysqld + ReadWritePaths=-/var/lib/mysql-keyring ReadWritePaths=-/var/log/mysql ReadWritePaths=-/run/mysqld "${relax[@]}" [[ $(files_changes_of mysql) == "${before}" ]] || changed=1 svc_enable "${unit}" if [[ ${changed} == 1 ]]; then diff --git a/tests/unit/system.bats b/tests/unit/system.bats index 4511594..ecb3fe9 100644 --- a/tests/unit/system.bats +++ b/tests/unit/system.bats @@ -599,6 +599,20 @@ EOF run ! grep -qxF 'NoNewPrivileges=yes' <<<"${output}" } +@test "svc_apparmor_confined finds the profile of a program, and assumes one where the list is hidden" { + export SVC_APPARMOR_ENABLED=${BATS_TEST_TMPDIR}/enabled SVC_APPARMOR_PROFILES=${BATS_TEST_TMPDIR}/profiles + run svc_apparmor_confined /usr/sbin/mysqld + [ "${status}" -eq 1 ] + echo Y >"${SVC_APPARMOR_ENABLED}" + svc_apparmor_confined /usr/sbin/mysqld + printf '/usr/sbin/chronyd (enforce)\n' >"${SVC_APPARMOR_PROFILES}" + run svc_apparmor_confined /usr/sbin/mysqld + [ "${status}" -eq 1 ] + printf '/usr/sbin/mysqld (enforce)\n' >>"${SVC_APPARMOR_PROFILES}" + svc_apparmor_confined /usr/sbin/mysqld + [[ " ${SVC_SANDBOX_NO_MOUNTS[*]} " == *' ProtectSystem=no '*' ReadWritePaths= '* ]] +} + @test "fw_ssh_port falls back to 22 when sshd fails" { STUB_EXIT_sshd=255 run fw_ssh_port [ "${status}" -eq 0 ] From d6fcf29b82bb08b6a31d19a54c8e244847959f7d Mon Sep 17 00:00:00 2001 From: VectoDE Date: Wed, 14 Oct 2026 22:28:28 +0200 Subject: [PATCH 68/92] fix(service): decide AppArmor confinement fail-closed Only an enforcing profile counts. Without a readable profile list the AppArmor label of a running process decides; with neither, the program counts as unconfined and keeps the full sandbox. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- lib/service.sh | 17 +++++++++++++---- tests/unit/system.bats | 20 +++++++++++++++++--- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/lib/service.sh b/lib/service.sh index 64384f6..c837b28 100644 --- a/lib/service.sh +++ b/lib/service.sh @@ -158,14 +158,23 @@ svc_sandbox() { SVC_SANDBOX_NO_MOUNTS=(ProtectSystem=no ProtectHome=no PrivateTmp=no PrivateDevices=no ProtectKernelTunables=no ProtectKernelModules=no ProtectKernelLogs=no ProtectControlGroups=no ProtectHostname=no ReadWritePaths=) -# svc_apparmor_confined PROGRAM: whether an AppArmor profile confines PROGRAM. Without a readable profile list (in -# some containers) an enabled AppArmor counts as confining. +# svc_apparmor_confined PROGRAM: whether an enforcing AppArmor profile confines PROGRAM. Without a readable profile +# list (in a container, whose processes the host's profiles still confine) the label of a running PROGRAM tells; +# anything else counts as unconfined, so the full sandbox stays. svc_apparmor_confined() { local enabled=${SVC_APPARMOR_ENABLED:-/sys/module/apparmor/parameters/enabled} local profiles=${SVC_APPARMOR_PROFILES:-/sys/kernel/security/apparmor/profiles} + local proc=${SVC_PROC:-/proc} pid label [[ $(cat "${enabled}" 2>/dev/null) == Y ]] || return 1 - [[ -r ${profiles} ]] || return 0 - grep -qF "$1 (" "${profiles}" + if [[ -r ${profiles} ]]; then + grep -qxF "$1 (enforce)" "${profiles}" + return + fi + for pid in $(pidof "$1" 2>/dev/null); do + label=$(cat "${proc}/${pid}/attr/apparmor/current" 2>/dev/null || cat "${proc}/${pid}/attr/current" 2>/dev/null) || continue + [[ ${label} == "$1 (enforce)" ]] && return 0 + done + return 1 } # svc_unit UNIT: writes /etc/systemd/system/UNIT (a unit of lss's own) from stdin and reloads systemd when it diff --git a/tests/unit/system.bats b/tests/unit/system.bats index ecb3fe9..6c28e1b 100644 --- a/tests/unit/system.bats +++ b/tests/unit/system.bats @@ -599,13 +599,27 @@ EOF run ! grep -qxF 'NoNewPrivileges=yes' <<<"${output}" } -@test "svc_apparmor_confined finds the profile of a program, and assumes one where the list is hidden" { +@test "svc_apparmor_confined needs an enforcing profile, from the list or the label of a running process" { export SVC_APPARMOR_ENABLED=${BATS_TEST_TMPDIR}/enabled SVC_APPARMOR_PROFILES=${BATS_TEST_TMPDIR}/profiles + export SVC_PROC=${BATS_TEST_TMPDIR}/proc + # shellcheck disable=SC2329 # called by svc_apparmor_confined + pidof() { + printf '%s\n' "${TEST_PIDS:-}" + return + } run svc_apparmor_confined /usr/sbin/mysqld [ "${status}" -eq 1 ] echo Y >"${SVC_APPARMOR_ENABLED}" - svc_apparmor_confined /usr/sbin/mysqld - printf '/usr/sbin/chronyd (enforce)\n' >"${SVC_APPARMOR_PROFILES}" + # No profile list and no running process: unconfined, the full sandbox stays. + run svc_apparmor_confined /usr/sbin/mysqld + [ "${status}" -eq 1 ] + mkdir -p "${SVC_PROC}/42/attr" + echo '/usr/sbin/mysqld (complain)' >"${SVC_PROC}/42/attr/current" + TEST_PIDS=42 run svc_apparmor_confined /usr/sbin/mysqld + [ "${status}" -eq 1 ] + echo '/usr/sbin/mysqld (enforce)' >"${SVC_PROC}/42/attr/current" + TEST_PIDS=42 svc_apparmor_confined /usr/sbin/mysqld + printf '/usr/sbin/chronyd (enforce)\n/usr/sbin/mysqld (complain)\n' >"${SVC_APPARMOR_PROFILES}" run svc_apparmor_confined /usr/sbin/mysqld [ "${status}" -eq 1 ] printf '/usr/sbin/mysqld (enforce)\n' >>"${SVC_APPARMOR_PROFILES}" From d57e012a86146aa62ae29dafd0c7df0296b58e88 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Wed, 14 Oct 2026 22:32:07 +0200 Subject: [PATCH 69/92] test(integration): list the Lynis tests that cost hardening points Refs #12 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- tests/integration/run.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/integration/run.sh b/tests/integration/run.sh index cd05a4a..06f4d9e 100755 --- a/tests/integration/run.sh +++ b/tests/integration/run.sh @@ -204,6 +204,11 @@ if [[ $1 == --profile ]]; then # What Lynis would still change, as the starting point for raising the hardening index. group_start "${system}: Lynis details" in_target "grep -iE 'different value than expected|weak configuration state|incorrect file permissions|permissions .* (not|too)|Result: .*(unsafe|not hardened|exposed)' /var/log/lynis.log | sed -E 's/^[0-9: -]+//' | sort -u | head -n 150" || true + # The tests that cost hardening points, which is what the index is made of. + in_target "awk '/Performing test ID/ { test = \$0; sub(/.*Performing test ID /, \"\", test) } + /Hardening: assigned/ { match(\$0, /assigned [0-9]+/); got = substr(\$0, RSTART + 9, RLENGTH - 9) + match(\$0, /max for this item: [0-9]+/); max = substr(\$0, RSTART + 19, RLENGTH - 19) + if (got + 0 < max + 0) print \" -\" (max - got) \" \" test }' /var/log/lynis.log | sort | uniq -c" || true group_end echo "${system}: profile ${profile} passed" exit 0 From 72d1f1884f1501777d30721e441fdad762354ae3 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Thu, 15 Oct 2026 00:29:53 +0200 Subject: [PATCH 70/92] fix(swap, modsecurity): flush the swap file last; pkg-config for mod_security2 swapon on XFS refused the file as not committed: the chmod after the flush dirtied its inode again. The flush now comes right before the unit starts. The mod_security2 build on Ubuntu 22.04 needs pkg-config. A missing audit log entry now also shows the web server processes, the holders of the log and kernel denials. Refs #4, #12 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- modules/base/swap.sh | 5 +++-- modules/web/modsecurity.sh | 6 +++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/modules/base/swap.sh b/modules/base/swap.sh index 89a0833..aa4b251 100644 --- a/modules/base/swap.sh +++ b/modules/base/swap.sh @@ -70,11 +70,12 @@ mod_configure() { chmod 600 "${path}" # mkswap reports on stdout; --quiet exists only from util-linux 2.38 on (Rocky 9 has 2.37). mkswap "${path}" >/dev/null - # XFS keeps freshly written blocks as delayed allocations, and swapon refuses those ("Invalid argument"). - sync -- "${path}" files_note_change "swap file ${path} (${size} MiB)" fi chmod 600 "${path}" + # XFS refuses swapon ("Invalid argument", "is not committed") while the file's blocks or its inode, which chmod + # touches as well, are not yet in the journal; the flush comes last. + sync -- "${path}" svc_unit "${unit}" <&2 || true tail -n 20 "$(web_log_file "${server}" error)" >&2 || true + # Which process holds the audit log open, as which user, and what the kernel refused. + ps -o pid,user,args -p "$(pgrep -d , -x 'nginx|apache2|httpd')" >&2 || true + find /proc/[0-9]*/fd -lname "${log}*" 2>/dev/null >&2 || true + journalctl -k -n 400 --no-pager 2>/dev/null | grep -iE 'denied|audit' | tail -n 20 >&2 || true die "modsecurity: the request with a script tag is missing from the audit log ${log}." fi return From 824d69926a40b859d834b62db495b76b7b4f4c1c Mon Sep 17 00:00:00 2001 From: VectoDE Date: Thu, 15 Oct 2026 01:33:48 +0200 Subject: [PATCH 71/92] fix(modsecurity): let the nginx workers append to the audit log libmodsecurity 3.0.17 opens the audit log in the worker processes, which run as the web server user and could not reach the root-only directory, so nothing was logged on Ubuntu 26.04. The directory now lets that user in (0710) and the log belongs to it (0640). Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- modules/web/modsecurity.sh | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/modules/web/modsecurity.sh b/modules/web/modsecurity.sh index 2e93b6d..2f594d9 100644 --- a/modules/web/modsecurity.sh +++ b/modules/web/modsecurity.sh @@ -646,7 +646,14 @@ mod_configure() { log=$(_modsecurity_audit_log "${server}") # Packages, builds and rules from mod_install in this run bring a new module: restart. Settings alone: reload. installed=$(files_changes_of modsecurity) - if os_is_debian; then + if [[ ${server} == nginx ]]; then + # libmodsecurity 3 opens the audit log in the worker processes, which run as the web server user: they may + # reach the directory and append to the file, nothing else. + install -d -m 0710 -o root -g "$(web_user nginx)" "$(dirname "${log}")" + [[ -f ${log} ]] || : >"${log}" + chown "$(web_user nginx):root" "${log}" + chmod 0640 "${log}" + elif os_is_debian; then install -d -m 0750 -o root -g adm "$(dirname "${log}")" else install -d -m 0700 -o root -g root "$(dirname "${log}")" From e1ea116163a769ccd28aad0437601ff89f911e74 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Thu, 15 Oct 2026 01:43:32 +0200 Subject: [PATCH 72/92] test(integration): read Lynis' partial hardening points correctly Lynis logs "assigned partial number of hardening points (X of Y)", not the format the first version of the listing expected. Refs #12 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- tests/integration/run.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/integration/run.sh b/tests/integration/run.sh index 06f4d9e..e70b891 100755 --- a/tests/integration/run.sh +++ b/tests/integration/run.sh @@ -206,9 +206,9 @@ if [[ $1 == --profile ]]; then in_target "grep -iE 'different value than expected|weak configuration state|incorrect file permissions|permissions .* (not|too)|Result: .*(unsafe|not hardened|exposed)' /var/log/lynis.log | sed -E 's/^[0-9: -]+//' | sort -u | head -n 150" || true # The tests that cost hardening points, which is what the index is made of. in_target "awk '/Performing test ID/ { test = \$0; sub(/.*Performing test ID /, \"\", test) } - /Hardening: assigned/ { match(\$0, /assigned [0-9]+/); got = substr(\$0, RSTART + 9, RLENGTH - 9) - match(\$0, /max for this item: [0-9]+/); max = substr(\$0, RSTART + 19, RLENGTH - 19) - if (got + 0 < max + 0) print \" -\" (max - got) \" \" test }' /var/log/lynis.log | sort | uniq -c" || true + /Hardening: assigned partial number/ { match(\$0, /[(][0-9]+ of [0-9]+[)]/) + split(substr(\$0, RSTART + 1, RLENGTH - 2), points, \" \") + print \" -\" (points[3] - points[1]) \" \" test }' /var/log/lynis.log | sort | uniq -c" || true group_end echo "${system}: profile ${profile} passed" exit 0 From 54a3bef8ab9de763018d8502a2e9a932b1a454f0 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Thu, 15 Oct 2026 02:45:28 +0200 Subject: [PATCH 73/92] fix(modsecurity): debug the missing nginx audit entry instead of guessing The audit log of nginx on Ubuntu 26.04 stays empty although the worker may write it and the request is blocked. A failed check now repeats the request with the engine's debug log on and shows what it says about audit logging. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- modules/web/modsecurity.sh | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/modules/web/modsecurity.sh b/modules/web/modsecurity.sh index 2f594d9..9876ce1 100644 --- a/modules/web/modsecurity.sh +++ b/modules/web/modsecurity.sh @@ -730,15 +730,35 @@ mod_verify() { # The engine reports why it cannot write the audit log in the error log of the web server. ls -l "${log%/*}" >&2 || true tail -n 20 "$(web_log_file "${server}" error)" >&2 || true - # Which process holds the audit log open, as which user, and what the kernel refused. - ps -o pid,user,args -p "$(pgrep -d , -x 'nginx|apache2|httpd')" >&2 || true - find /proc/[0-9]*/fd -lname "${log}*" 2>/dev/null >&2 || true - journalctl -k -n 400 --no-pager 2>/dev/null | grep -iE 'denied|audit' | tail -n 20 >&2 || true + [[ ${server} != nginx ]] || _modsecurity_debug_probe "${url}" "${unit}" "${log}" >&2 || true die "modsecurity: the request with a script tag is missing from the audit log ${log}." fi return } +# _modsecurity_debug_probe URL UNIT LOG: sends the blocked request again with the engine's debug log switched on and +# prints what the engine says about its audit log; the configuration is put back afterwards. +_modsecurity_debug_probe() { + local url=$1 unit=$2 log=$3 main debug + main=$(_modsecurity_conf_dir nginx)/main.conf + debug=${log%/*}/debug.log + install -m 0640 -o "$(web_user nginx)" -g root /dev/null "${debug}" + cp -p -- "${main}" "${main}.lss-debug" + printf 'SecDebugLog %s\nSecDebugLogLevel 9\n' "${debug}" >>"${main}" + if svc_reload "${unit}"; then + sleep 2 + curl -s --max-time 10 -o /dev/null -H 'Host: localhost' "${url}?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E" || true + sleep 2 + echo "--- ModSecurity debug log (audit and logging lines)" + grep -iE 'audit|relevant|logging|save' "${debug}" | tail -n 40 + echo "--- last lines" + tail -n 15 "${debug}" + fi + mv -f -- "${main}.lss-debug" "${main}" + rm -f -- "${debug}" + svc_reload "${unit}" +} + mod_remove() { local server unit server=$(web_server) From e33ad07c67fc6f41221e1ce8b89a3997aa5703ec Mon Sep 17 00:00:00 2001 From: VectoDE Date: Thu, 15 Oct 2026 04:55:59 +0200 Subject: [PATCH 74/92] test(integration): install pending updates in the profile runs The module tests skip upgrades for time, so the Lynis audit after a profile counted every pending security update (PKGS-7392, 25 points on the Ubuntu images). A profile now runs with the default upgrade=true, as on a real server. Refs #12 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- tests/integration/run.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/integration/run.sh b/tests/integration/run.sh index e70b891..5060e6b 100755 --- a/tests/integration/run.sh +++ b/tests/integration/run.sh @@ -178,6 +178,9 @@ if [[ $1 == --profile ]]; then group_end fi group_start "${system}: profile ${profile}" + # A profile runs as on a real server, with the pending updates installed (the module tests skip them for time); + # Lynis counts every missing security update. + in_target "sed -i 's/^LSS_SYSTEM_UPGRADE=false$/LSS_SYSTEM_UPGRADE=true/' /etc/lss/lss.conf" before=$(resources_snapshot) started=${SECONDS} memory_watch_start From 56bbf2677d2ae9df18a1df8a03cf937cc6298b66 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Thu, 15 Oct 2026 05:23:04 +0200 Subject: [PATCH 75/92] fix(modsecurity): build the nginx module against the distribution's patched sources Ubuntu 26.04 patches nginx 1.28.3 with a port field in ngx_http_request_t (backport of $request_port). --with-compat does not detect a changed struct layout, so the connector built from the plain nginx.org sources decremented the wrong field: requests were never freed, and the log phase with the audit log never ran, although the rules still blocked. nginx-dev of exactly the installed package version now overlays the nginx.org tree where the release has it. The build stamp also records the package version, so a distribution update of the same nginx release rebuilds the module. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- modules/web/modsecurity.sh | 47 ++++++++++++++++++++++++++++++++--- tests/unit/web.bats | 51 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 3 deletions(-) diff --git a/modules/web/modsecurity.sh b/modules/web/modsecurity.sh index 9876ce1..284cd58 100644 --- a/modules/web/modsecurity.sh +++ b/modules/web/modsecurity.sh @@ -35,6 +35,8 @@ MODSEC_PLUGINS="cpanel dokuwiki drupal nextcloud phpbb phpmyadmin wordpress xenf # The web server modules serve this file on port 80 for every host name; verify sends its probes there. MODSEC_PROBE=/.well-known/acme-challenge/lss-probe MODSEC_BUILD_DEPS=() +# Where nginx-dev puts the patched nginx sources of Debian and Ubuntu. +MODSEC_NGINX_DEV_SRC=/usr/share/nginx/src MODSEC_WORK='' # The web server ModSecurity is added to. @@ -139,10 +141,48 @@ _modsecurity_nginx_version() { return } -# What the self-built nginx connector was built for; a different value means building it again. +# _modsecurity_nginx_package: the package and its version that hold the nginx binary ("nginx 1.24.0-2ubuntu7"). +_modsecurity_nginx_package() { + local binary package + binary=$(readlink -f "$(command -v nginx)") || return 0 + if os_is_debian; then + package=$(dpkg-query -S "${binary}" 2>/dev/null | head -n 1 | cut -d : -f 1) + [[ -z ${package} ]] || printf '%s %s' "${package}" "$(dpkg-query -W -f '${Version}' "${package}" 2>/dev/null)" + else + rpm -qf --qf '%{NAME} %{VERSION}-%{RELEASE}' "${binary}" 2>/dev/null || true + fi + return +} + +# What the self-built nginx connector was built for; a different value means building it again. The package version +# counts as well: a distribution update of the same nginx release can change the structures the module uses. _modsecurity_connector_id() { - printf 'nginx %s, connector %s, libmodsecurity %s' "$(_modsecurity_nginx_version)" "${MODSEC_CONNECTOR_VERSION}" \ - "${MODSEC_LIB_VERSION}" + printf 'nginx %s (%s), connector %s, libmodsecurity %s' "$(_modsecurity_nginx_version)" \ + "$(_modsecurity_nginx_package)" "${MODSEC_CONNECTOR_VERSION}" "${MODSEC_LIB_VERSION}" + return +} + +# _modsecurity_nginx_headers TREE: Debian and Ubuntu patch nginx, and a patch can change the layout of its structures +# (Ubuntu 26.04 adds a port field to ngx_http_request_t), which --with-compat does not detect. A module built from the +# plain nginx.org sources then writes beside the fields it means; on Ubuntu 26.04 requests were never freed, so the log +# phase and with it the audit log never ran. nginx-dev holds the patched sources of exactly the installed package; +# they replace those of the nginx.org TREE. Releases without nginx-dev build from the nginx.org sources alone. +_modsecurity_nginx_headers() { + local tree=$1 package version + read -r package version <<<"$(_modsecurity_nginx_package)" + [[ -n ${version} ]] || return 0 + pkg_update_once + if ! apt-cache show "nginx-dev=${version}" >/dev/null 2>&1; then + log_info "modsecurity: nginx-dev ${version} is unavailable for ${package}; the module builds from the nginx.org sources." + return 0 + fi + if ! pkg_is_installed nginx-dev; then + MODSEC_BUILD_DEPS+=(nginx-dev) + fi + pkg_install "nginx-dev=${version}" + [[ -d ${MODSEC_NGINX_DEV_SRC}/src ]] || + die "modsecurity: nginx-dev ${version} has no nginx sources in ${MODSEC_NGINX_DEV_SRC}." + cp -a "${MODSEC_NGINX_DEV_SRC}/src/." "${tree}/src/" return } @@ -305,6 +345,7 @@ _modsecurity_build_connector() { fetch_verified "nginx-${version}" "${MODSEC_WORK}/nginx.tar.gz" tar -xzf "${MODSEC_WORK}/nginx.tar.gz" -C "${MODSEC_WORK}" --no-same-owner src="${MODSEC_WORK}/nginx-${version}" + _modsecurity_nginx_headers "${src}" else tree=$(rpm -E '%{_nginx_srcdir}') [[ -f ${tree}/configure ]] || die "modsecurity: nginx-mod-devel has no nginx sources in ${tree}." diff --git a/tests/unit/web.bats b/tests/unit/web.bats index bfa14ed..ba85f93 100644 --- a/tests/unit/web.bats +++ b/tests/unit/web.bats @@ -900,3 +900,54 @@ EOF new_cert "${WEB_TLS_DIR}/shop" 90 www.shop.example.com shop.example.com [ "$(web_cert_domains "${WEB_TLS_DIR}/shop/fullchain.pem")" = $'shop.example.com\nwww.shop.example.com' ] } + +@test "the nginx module builds against the patched sources of the installed Debian package" { + # shellcheck source=modules/web/modsecurity.sh + source "${REPO_ROOT}/modules/web/modsecurity.sh" + local tree=${BATS_TEST_TMPDIR}/nginx-1.28.3 available=1.28.3-2ubuntu1.11 installed='' + MODSEC_NGINX_DEV_SRC=${BATS_TEST_TMPDIR}/nginx-dev + mkdir -p "${tree}/src/http" "${MODSEC_NGINX_DEV_SRC}/src/http" + echo upstream >"${tree}/src/http/ngx_http_request.h" + echo patched >"${MODSEC_NGINX_DEV_SRC}/src/http/ngx_http_request.h" + # shellcheck disable=SC2329 # called by the functions under test + _modsecurity_nginx_package() { + printf 'nginx 1.28.3-2ubuntu1.11' + } + # shellcheck disable=SC2329 + pkg_update_once() { + return 0 + } + # shellcheck disable=SC2329 + apt-cache() { + [[ $2 == "nginx-dev=${available}" ]] + } + # shellcheck disable=SC2329 + pkg_is_installed() { + [[ -n ${installed} ]] + } + # shellcheck disable=SC2329 + pkg_install() { + printf 'pkg_install %s +' "$*" >>"${STUB_LOG}" + } + _modsecurity_nginx_headers "${tree}" + [ "$(<"${tree}/src/http/ngx_http_request.h")" = patched ] + grep -qxF 'pkg_install nginx-dev=1.28.3-2ubuntu1.11' "${STUB_LOG}" + [ "${MODSEC_BUILD_DEPS[*]}" = nginx-dev ] + # An nginx-dev installed before stays; a release without nginx-dev of that version keeps the nginx.org sources. + MODSEC_BUILD_DEPS=() + installed=1 + _modsecurity_nginx_headers "${tree}" + [ "${#MODSEC_BUILD_DEPS[@]}" -eq 0 ] + echo upstream >"${tree}/src/http/ngx_http_request.h" + available=1.28.3-2ubuntu1.12 + : >"${STUB_LOG}" + _modsecurity_nginx_headers "${tree}" + [ "$(<"${tree}/src/http/ngx_http_request.h")" = upstream ] + run ! grep -q pkg_install "${STUB_LOG}" + rm -r "${MODSEC_NGINX_DEV_SRC}/src" + available=1.28.3-2ubuntu1.11 + run _modsecurity_nginx_headers "${tree}" + [ "${status}" -eq 1 ] + [[ ${output} == *"nginx-dev 1.28.3-2ubuntu1.11 has no nginx sources in ${MODSEC_NGINX_DEV_SRC}."* ]] +} From 01c573e24dc9d56da66b166726e70c1eaf5d27ef Mon Sep 17 00:00:00 2001 From: VectoDE Date: Thu, 15 Oct 2026 07:16:14 +0200 Subject: [PATCH 76/92] chore(modsecurity): drop the nginx debug probe It served to find why Ubuntu 26.04 wrote no audit entries (the nginx struct layout, fixed by building against nginx-dev). The verify step keeps the directory listing and the error log tail. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- modules/web/modsecurity.sh | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/modules/web/modsecurity.sh b/modules/web/modsecurity.sh index 284cd58..9f6927a 100644 --- a/modules/web/modsecurity.sh +++ b/modules/web/modsecurity.sh @@ -771,35 +771,11 @@ mod_verify() { # The engine reports why it cannot write the audit log in the error log of the web server. ls -l "${log%/*}" >&2 || true tail -n 20 "$(web_log_file "${server}" error)" >&2 || true - [[ ${server} != nginx ]] || _modsecurity_debug_probe "${url}" "${unit}" "${log}" >&2 || true die "modsecurity: the request with a script tag is missing from the audit log ${log}." fi return } -# _modsecurity_debug_probe URL UNIT LOG: sends the blocked request again with the engine's debug log switched on and -# prints what the engine says about its audit log; the configuration is put back afterwards. -_modsecurity_debug_probe() { - local url=$1 unit=$2 log=$3 main debug - main=$(_modsecurity_conf_dir nginx)/main.conf - debug=${log%/*}/debug.log - install -m 0640 -o "$(web_user nginx)" -g root /dev/null "${debug}" - cp -p -- "${main}" "${main}.lss-debug" - printf 'SecDebugLog %s\nSecDebugLogLevel 9\n' "${debug}" >>"${main}" - if svc_reload "${unit}"; then - sleep 2 - curl -s --max-time 10 -o /dev/null -H 'Host: localhost' "${url}?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E" || true - sleep 2 - echo "--- ModSecurity debug log (audit and logging lines)" - grep -iE 'audit|relevant|logging|save' "${debug}" | tail -n 40 - echo "--- last lines" - tail -n 15 "${debug}" - fi - mv -f -- "${main}.lss-debug" "${main}" - rm -f -- "${debug}" - svc_reload "${unit}" -} - mod_remove() { local server unit server=$(web_server) From 941dcce7a11852f04dea1ff892dfc9c95f9c179b Mon Sep 17 00:00:00 2001 From: VectoDE Date: Thu, 15 Oct 2026 09:37:27 +0200 Subject: [PATCH 77/92] docs(readme): resource figures of the web, runtime and database modules Measured by the integration run of 01c573e on all seven systems, x86_64 and arm64; modules that only work together are listed as the set they were tested in. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- README.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/README.md b/README.md index d46538e..57affc2 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,38 @@ up). Peak RAM covers install and `lss verify`; "while running" is what stays res | `usbguard` | 10 s | 90 MiB | 10 MiB | usbguard-daemon | | `guard` | 5 s | 90 MiB | – | daily check | +Web, runtime and database modules, measured the same way. Modules that only work together are tested together; their +row is the cost of the whole set, including the web server or PHP it needs. + +| Modules tested together | Install and verify | Peak RAM | Disk | While running | +| ---------------------------------- | ------------------ | --------------------------- | ------- | ------------------------------------- | +| `nginx` `tls` `vhost` | 50 s | 640 MiB (dnf), else 270 MiB | 235 MiB | nginx, renewal timer | +| `apache` `tls` `vhost` | 60 s | 370 MiB | 170 MiB | Apache, renewal timer | +| `caddy` `tls` `vhost` | 30 s | 210 MiB | 110 MiB | Caddy, renewal timer | +| `haproxy` | 10 s | 215 MiB | 10 MiB | haproxy | +| `nginx` `modsecurity` | 155 s (build) | 1.5 GiB | 55 MiB | nginx with the Core Rule Set | +| `apache` `modsecurity` | 45 s | 310 MiB | 110 MiB | Apache with the Core Rule Set | +| `nginx` `php` | 30 s | 275 MiB | 125 MiB | nginx, PHP-FPM | +| `php` `composer` | 40 s | 305 MiB | 285 MiB | PHP-FPM | +| `node` | 15 s | 435 MiB | 285 MiB | – | +| `python` | 30 s | 755 MiB (dnf), else 145 MiB | 90 MiB | – | +| `java` | 30 s | 485 MiB | 735 MiB | – | +| `go` | 10 s | 255 MiB | 285 MiB | – | +| `dotnet` | 25 s | 480 MiB | 690 MiB | – | +| `ruby` | 15 s | 265 MiB | 265 MiB | – | +| `mariadb` | 25 s | 470 MiB | 455 MiB | mariadbd | +| `mysql` | 40 s | 900 MiB | 1.2 GiB | mysqld | +| `postgresql` | 25 s | 300 MiB | 305 MiB | postgres | +| `redis` | 15 s | 165 MiB | 95 MiB | redis-server | +| `valkey` | 10 s | 165 MiB | 85 MiB | valkey-server | +| `mongodb` | 25 s | 820 MiB | 780 MiB | mongod | +| `nginx` `php` `mariadb` `phpmyadmin` | 45 s | 385 MiB | 170 MiB | nginx, PHP-FPM, mariadbd | +| `caddy` `php` `adminer` | 35 s | 240 MiB | 135 MiB | Caddy, PHP-FPM | +| `apache` `postgresql` `pgadmin` | 70 s | 500 MiB | 705 MiB | Apache, pgAdmin (gunicorn), postgres | + +`nginx` `modsecurity` builds libmodsecurity and the nginx module from source on the first install; that is where the +time and the peak memory go. Later runs reuse the build. + The integration jobs print these figures as `RESOURCES` lines, so every new module and every new system adds its own. ## Quick start From 3fbefaf523539517e9ebda497b15272b71af5b1a Mon Sep 17 00:00:00 2001 From: VectoDE Date: Thu, 15 Oct 2026 09:37:29 +0200 Subject: [PATCH 78/92] ci(integration): profile all on every VM distribution at night Pull requests keep the single Debian 13 run; the nightly and manual runs install every module at once on Debian 12 and 13, Rocky Linux 9 and AlmaLinux 10 as well. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- .github/workflows/integration.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index d4c5605..f06b2e2 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -114,10 +114,12 @@ jobs: matrix: system: [debian-12, debian-13, rocky-9, almalinux-10] run: [kernel, profile minimal-secure] - include: - # Every module at once (profiles/all.conf), on a clean system without the runner's preinstalled databases. - - system: debian-13 - run: profile all + # Every module at once (profiles/all.conf), on a clean system without the runner's preinstalled databases: on + # Debian 13 for every change, on every distribution in the nightly and the manual runs. + include: >- + ${{ fromJSON((github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') + && '[{"system":"debian-12","run":"profile all"},{"system":"debian-13","run":"profile all"},{"system":"rocky-9","run":"profile all"},{"system":"almalinux-10","run":"profile all"}]' + || '[{"system":"debian-13","run":"profile all"}]') }} steps: - uses: step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25 # v2.22.1 with: From e454618703f8397e1ad1d296139503a3031ce002 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Thu, 15 Oct 2026 09:40:31 +0200 Subject: [PATCH 79/92] ci(integration): nightly profile all on Ubuntu VMs too Ubuntu 22.04, 24.04 and 26.04 cloud images, checked against the SHA256SUMS Canonical publishes, complete profile all on every supported distribution; the runners themselves carry preinstalled databases and cannot run it cleanly. The checksum error of vm.sh no longer starts with "No". Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- .github/workflows/integration.yml | 4 +++- tests/integration/vm-images.conf | 3 +++ tests/integration/vm.sh | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index f06b2e2..937bb1f 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -118,7 +118,7 @@ jobs: # Debian 13 for every change, on every distribution in the nightly and the manual runs. include: >- ${{ fromJSON((github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') - && '[{"system":"debian-12","run":"profile all"},{"system":"debian-13","run":"profile all"},{"system":"rocky-9","run":"profile all"},{"system":"almalinux-10","run":"profile all"}]' + && '[{"system":"debian-12","run":"profile all"},{"system":"debian-13","run":"profile all"},{"system":"rocky-9","run":"profile all"},{"system":"almalinux-10","run":"profile all"},{"system":"ubuntu-22.04","run":"profile all"},{"system":"ubuntu-24.04","run":"profile all"},{"system":"ubuntu-26.04","run":"profile all"}]' || '[{"system":"debian-13","run":"profile all"}]') }} steps: - uses: step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25 # v2.22.1 @@ -148,6 +148,8 @@ jobs: else case $SYSTEM in vm-debian-*) minimum=82 ;; + # The same minimum as the host job on the Ubuntu runners. + vm-ubuntu-*) minimum=75 ;; *) minimum=80 ;; esac tests/integration/run.sh "$SYSTEM" --profile "${RUN#profile }" "$minimum" diff --git a/tests/integration/vm-images.conf b/tests/integration/vm-images.conf index 3af8eaa..bcd9e12 100644 --- a/tests/integration/vm-images.conf +++ b/tests/integration/vm-images.conf @@ -10,3 +10,6 @@ debian-12|https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-generi debian-13|https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2|https://cloud.debian.org/images/cloud/trixie/latest/SHA512SUMS|sha512|sudo rocky-9|https://dl.rockylinux.org/pub/rocky/9/images/x86_64/Rocky-9-GenericCloud-Base.latest.x86_64.qcow2|https://dl.rockylinux.org/pub/rocky/9/images/x86_64/CHECKSUM|sha256|wheel almalinux-10|https://repo.almalinux.org/almalinux/10/cloud/x86_64/images/AlmaLinux-10-GenericCloud-latest.x86_64.qcow2|https://repo.almalinux.org/almalinux/10/cloud/x86_64/images/CHECKSUM|sha256|wheel +ubuntu-22.04|https://cloud-images.ubuntu.com/releases/jammy/release/ubuntu-22.04-server-cloudimg-amd64.img|https://cloud-images.ubuntu.com/releases/jammy/release/SHA256SUMS|sha256|sudo +ubuntu-24.04|https://cloud-images.ubuntu.com/releases/noble/release/ubuntu-24.04-server-cloudimg-amd64.img|https://cloud-images.ubuntu.com/releases/noble/release/SHA256SUMS|sha256|sudo +ubuntu-26.04|https://cloud-images.ubuntu.com/releases/resolute/release/ubuntu-26.04-server-cloudimg-amd64.img|https://cloud-images.ubuntu.com/releases/resolute/release/SHA256SUMS|sha256|sudo diff --git a/tests/integration/vm.sh b/tests/integration/vm.sh index 638a3c5..2603702 100644 --- a/tests/integration/vm.sh +++ b/tests/integration/vm.sh @@ -30,7 +30,7 @@ vm_image() { expected=$(curl --proto '=https' --tlsv1.2 -fsSL --retry 3 "${sums}" | grep -F "${url##*/}" | grep -oE '[0-9a-f]{64,128}' | head -n 1) [[ -n ${expected} ]] || { - echo "No checksum for ${url##*/} in ${sums}" >&2 + echo "Checksum missing for ${url##*/} in ${sums}" >&2 return 1 } if [[ ! -f ${file} ]] || ! "${algo}sum" --check --quiet <<<"${expected} ${file}" >/dev/null 2>&1; then From 3bdc1f19edfdd684f3f4ab8b7d23d8f085c90901 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Thu, 15 Oct 2026 10:06:35 +0200 Subject: [PATCH 80/92] fix(web): label the admin port for web servers under SELinux The admin interfaces listen on port 8090, which the RHEL policy does not count as http_port_t, so nginx could not bind it (Permission denied) once SELinux enforced; the containers of the module tests run without SELinux, and profile all on the Rocky Linux 9 and AlmaLinux 10 VMs found it. nginx, Apache and Caddy now label the port first. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- lib/web.sh | 17 +++++++++++++++++ modules/web/apache.sh | 1 + modules/web/caddy.sh | 1 + modules/web/nginx.sh | 1 + tests/unit/web.bats | 31 +++++++++++++++++++++++++++++-- 5 files changed, 49 insertions(+), 2 deletions(-) diff --git a/lib/web.sh b/lib/web.sh index 3ed1039..ca01074 100644 --- a/lib/web.sh +++ b/lib/web.sh @@ -585,6 +585,23 @@ web_selinux_proxy() { return } +# web_selinux_admin_port: under SELinux a web server may only listen on ports labelled http_port_t; the admin port +# WEB_ADMIN_PORT is none by default, so nginx and Apache could not bind it (Permission denied). +web_selinux_admin_port() { + local custom + os_is_rhel || return 0 + if ! command -v selinuxenabled >/dev/null 2>&1 || ! selinuxenabled; then + return 0 + fi + pkg_install policycoreutils-python-utils + custom=$(semanage port -l -C 2>/dev/null | awk '$1 == "http_port_t" && $2 == "tcp" { $1 = ""; $2 = ""; print }') + grep -qE "(^|[ ,])${WEB_ADMIN_PORT}(,|$)" <<<"${custom}" && return 0 + semanage port -a -t http_port_t -p tcp "${WEB_ADMIN_PORT}" 2>/dev/null || + semanage port -m -t http_port_t -p tcp "${WEB_ADMIN_PORT}" + files_note_change "SELinux port ${WEB_ADMIN_PORT}/tcp for the admin interfaces" + return +} + # web_site_write NAME static|php|proxy TARGET DOMAIN...: an HTTPS site for the installed web server, with HTTP # redirected to HTTPS and the ACME challenge path served. TARGET is a document root or an upstream URL. Sets # LSS_FILE_CHANGED; call web_reload afterwards. diff --git a/modules/web/apache.sh b/modules/web/apache.sh index 4d63672..2140dc3 100644 --- a/modules/web/apache.sh +++ b/modules/web/apache.sh @@ -221,6 +221,7 @@ mod_configure() { svc_sandbox "${unit}.service" ReadWritePaths=-/var/lib/httpd ReadWritePaths=-/var/cache/httpd fi [[ ${LSS_FILE_CHANGED} == 0 ]] || changed=1 + web_selinux_admin_port web_test apache || die "apache: the configuration does not load; see above." svc_enable "${unit}" if [[ ${changed} == 1 ]]; then diff --git a/modules/web/caddy.sh b/modules/web/caddy.sh index 13825f2..14626fc 100644 --- a/modules/web/caddy.sh +++ b/modules/web/caddy.sh @@ -157,6 +157,7 @@ mod_configure() { svc_sandbox caddy.service ReadWritePaths=-/var/lib/caddy ReadWritePaths=-/var/log/caddy RuntimeDirectory=caddy \ RuntimeDirectoryMode=0750 [[ ${LSS_FILE_CHANGED} == 0 ]] || changed=1 + web_selinux_admin_port web_test caddy || die "caddy: the configuration does not load; see above." svc_enable caddy # A restart, not a reload: the caddy user has to pick up its new group (the TLS keys are readable by it). diff --git a/modules/web/nginx.sh b/modules/web/nginx.sh index 228be8c..d0c7d66 100644 --- a/modules/web/nginx.sh +++ b/modules/web/nginx.sh @@ -145,6 +145,7 @@ mod_configure() { fw_allow 80/tcp 443/tcp svc_sandbox nginx.service ReadWritePaths=-/var/lib/nginx ReadWritePaths=-/var/cache/nginx [[ ${LSS_FILE_CHANGED} == 0 ]] || changed=1 + web_selinux_admin_port web_test nginx || die "nginx: the configuration does not load; see above." svc_enable nginx if [[ ${changed} == 1 ]]; then diff --git a/tests/unit/web.bats b/tests/unit/web.bats index ba85f93..c2d0dbe 100644 --- a/tests/unit/web.bats +++ b/tests/unit/web.bats @@ -584,6 +584,34 @@ EOF run ! grep -q getsebool "${STUB_LOG}" } +@test "under SELinux the admin port is labelled for web servers once" { + use_os rocky-9 + local labelled='' + # shellcheck disable=SC2329 # called by the functions under test + pkg_install() { + printf 'pkg_install %s\n' "$*" >>"${STUB_LOG}" + } + # shellcheck disable=SC2329 + semanage() { + printf 'semanage %s\n' "$*" >>"${STUB_LOG}" + [[ $2 != -l ]] || printf '%s\n' "${labelled}" + } + web_selinux_admin_port + run ! grep -q semanage "${STUB_LOG}" + export STUB_EXIT_selinuxenabled=0 + web_selinux_admin_port + grep -qxF 'pkg_install policycoreutils-python-utils' "${STUB_LOG}" + grep -qxF 'semanage port -a -t http_port_t -p tcp 8090' "${STUB_LOG}" + : >"${STUB_LOG}" + labelled='http_port_t tcp 8443, 8090' + web_selinux_admin_port + run ! grep -q 'semanage port -a' "${STUB_LOG}" + use_os debian-13 + : >"${STUB_LOG}" + web_selinux_admin_port + [ ! -s "${STUB_LOG}" ] +} + @test "site names that could leave the configuration directory are refused" { run _web_name_check '../etc' [ "${status}" -ne 0 ] @@ -927,8 +955,7 @@ EOF } # shellcheck disable=SC2329 pkg_install() { - printf 'pkg_install %s -' "$*" >>"${STUB_LOG}" + printf 'pkg_install %s\n' "$*" >>"${STUB_LOG}" } _modsecurity_nginx_headers "${tree}" [ "$(<"${tree}/src/http/ngx_http_request.h")" = patched ] From c2c86423832178050e59456e9cc2752063607a7a Mon Sep 17 00:00:00 2001 From: VectoDE Date: Thu, 15 Oct 2026 10:06:36 +0200 Subject: [PATCH 81/92] test(integration): show AppArmor and SELinux denials from the audit log Once auditd runs, the kernel's denials go to its log, not the journal, so the diagnostics of a failed run missed them (php-fpm timed out on the Ubuntu 24.04 VM in profile all without a visible cause). Refs #12 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- tests/integration/run.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/integration/run.sh b/tests/integration/run.sh index 5060e6b..746043e 100755 --- a/tests/integration/run.sh +++ b/tests/integration/run.sh @@ -118,6 +118,8 @@ stop_target() { done ps -eo pid,ppid,etime,args --forest | tail -n 60 tail -n 30 /var/log/mysql/error.log /var/log/mariadb/mariadb.log 2>/dev/null + # With auditd running, AppArmor and SELinux denials go to its log instead of the journal. + grep -hE "apparmor=\"DENIED\"|avc: +denied" /var/log/audit/audit.log 2>/dev/null | tail -n 30 journalctl -p warning -n 80 --no-pager' &1 || diagnostics=$? # Containers share the kernel of the runner, whose AppArmor profiles still apply to them. if [[ ${system} != vm-* && ${system} != host ]]; then From d5f34022a060502b51912a51c995ab4092f5bd04 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Thu, 15 Oct 2026 10:44:21 +0200 Subject: [PATCH 82/92] fix(database): show the web server's error log when an admin page fails profile all on the Ubuntu 26.04 VM got 403 for phpMyAdmin with no hint why; PHP-FPM, file permissions and ModSecurity all answer 403. phpMyAdmin, Adminer and pgAdmin now print the error log tail before they stop. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- modules/database/adminer.sh | 5 ++++- modules/database/pgadmin.sh | 5 ++++- modules/database/phpmyadmin.sh | 5 ++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/modules/database/adminer.sh b/modules/database/adminer.sh index a76d8f7..1ea934c 100644 --- a/modules/database/adminer.sh +++ b/modules/database/adminer.sh @@ -108,8 +108,11 @@ mod_verify() { [[ ${status} != 200 ]] || break sleep 1 done - [[ ${status} == 200 ]] || + if [[ ${status} != 200 ]]; then + # The web server's error log names the cause: PHP-FPM, file permissions or a ModSecurity rule. + tail -n 20 "$(web_log_file "$(web_server)" error)" >&2 || true die "adminer: the admin port answers ${status:-nothing} for /${ADMINER_PATH}/ instead of the login page." + fi grep -qF 'auth[username]' <<<"${page}" || die "adminer: /${ADMINER_PATH}/ shows no login form." # The server list of the login plugin proves that Adminer loaded adminer-plugins.php. first=$(opt servers) diff --git a/modules/database/pgadmin.sh b/modules/database/pgadmin.sh index 64b3ae1..f7c37a0 100644 --- a/modules/database/pgadmin.sh +++ b/modules/database/pgadmin.sh @@ -239,8 +239,11 @@ mod_verify() { [[ ${status} != 200 ]] || break sleep 1 done - [[ ${status} == 200 ]] || + if [[ ${status} != 200 ]]; then + # The web server's error log names the cause: the proxy to gunicorn, permissions or a ModSecurity rule. + tail -n 20 "$(web_log_file "$(web_server)" error)" >&2 || true die "pgadmin: the admin port answers ${status:-nothing} for /${PGADMIN_PATH}/login instead of the login page." + fi grep -qi 'pgadmin' <<<"${page}" || die "pgadmin: /${PGADMIN_PATH}/login is not pgAdmin's login page." return } diff --git a/modules/database/phpmyadmin.sh b/modules/database/phpmyadmin.sh index ccfadfb..3c32c12 100644 --- a/modules/database/phpmyadmin.sh +++ b/modules/database/phpmyadmin.sh @@ -204,8 +204,11 @@ mod_verify() { [[ ${status} != 200 ]] || break sleep 1 done - [[ ${status} == 200 ]] || + if [[ ${status} != 200 ]]; then + # The web server's error log names the cause: PHP-FPM, file permissions or a ModSecurity rule. + tail -n 20 "$(web_log_file "$(web_server)" error)" >&2 || true die "phpmyadmin: the admin port answers ${status:-nothing} for /${PMA_PATH}/ instead of the login page." + fi grep -qF 'name="pma_username"' <<<"${page}" || die "phpmyadmin: /${PMA_PATH}/ shows no login form; PHP may lack the mysqli extension." # PHP 8.4 is newer than the PHP versions phpMyAdmin 5.2 names; a deprecation notice in the page means PHP shows From 620979bcce941490829e0b5810c0d6cecbf54ad2 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Thu, 15 Oct 2026 12:01:44 +0200 Subject: [PATCH 83/92] fix(php): extend PHP-FPM's AppArmor profile on Ubuntu 24.04 and later The apparmor package confines PHP-FPM to its own files. On Ubuntu 24.04 it also lacks the systemd notification socket (LP: #2061113), so the package's own start timed out; on 26.04 the lss pool could not read phpMyAdmin under /usr/share/lss, and no web root or database socket would have been readable either. The module writes the profile's local include before the packages start PHP-FPM and reloads the profile. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- modules/runtime/php.sh | 44 ++++++++++++++++++++++++++++++++++++++++++ tests/unit/web.bats | 33 +++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/modules/runtime/php.sh b/modules/runtime/php.sh index ab4412a..cd5386b 100644 --- a/modules/runtime/php.sh +++ b/modules/runtime/php.sh @@ -158,9 +158,44 @@ _php_install() { return } +# The AppArmor profile php-fpm of the apparmor package (Ubuntu 24.04 and later) confines PHP-FPM to its own files. +# The lss pool reads the applications lss installs and the web roots, writes its sessions and LSS_PHP_WRITABLE_PATHS +# and reaches the databases over their sockets; Ubuntu 24.04's version also lacks the systemd notification socket, so +# PHP-FPM timed out on its first start. The additions go to the profile's local include, before the package starts +# PHP-FPM, and the profile is loaded again. +PHP_APPARMOR_PROFILE=/etc/apparmor.d/php-fpm +PHP_APPARMOR_LOCAL=/etc/apparmor.d/local/php-fpm + +_php_apparmor_rules() { + local path + printf '# Managed by lss (module php). Change LSS_PHP_WRITABLE_PATHS in /etc/lss/lss.conf and run lss configure php.\n' + printf 'owner @{run}/systemd/notify w,\n/systemd/notify w,\n' + printf 'include \n@{run}/postgresql/.s.PGSQL.* rw,\n@{run}/redis/*.sock rw,\n@{run}/valkey/*.sock rw,\n' + printf '/usr/share/lss/ r,\n/usr/share/lss/** r,\n' + printf '%s/ r,\n%s/** rwk,\n' "${PHP_SESSIONS}" "${PHP_SESSIONS}" + printf 'owner /tmp/** rwk,\n' + for path in $(opt writable_paths); do + printf '%s/ r,\n%s/** rwk,\n' "${path%/}" "${path%/}" + done + return +} + +_php_apparmor() { + [[ -f ${PHP_APPARMOR_PROFILE} ]] || return 0 + file_write 0644 root:root "${PHP_APPARMOR_LOCAL}" < <(_php_apparmor_rules) + [[ ${LSS_FILE_CHANGED} == 1 ]] || return 0 + if [[ $(cat "${SVC_APPARMOR_ENABLED:-/sys/module/apparmor/parameters/enabled}" 2>/dev/null) == Y ]]; then + log_command apparmor_parser -r "${PHP_APPARMOR_PROFILE}" || + die "php: AppArmor rejects ${PHP_APPARMOR_PROFILE} with the lss additions; see the log." + fi + LSS_FILE_CHANGED=1 + return +} + mod_install() { local extension local -a packages=() + _php_apparmor if os_is_debian; then _php_resolve_debian for extension in fpm cli curl gd intl mbstring xml zip mysql pgsql bcmath; do @@ -295,6 +330,8 @@ mod_configure() { done svc_sandbox "${PHP_UNIT}.service" "${writable[@]}" [[ ${LSS_FILE_CHANGED} == 0 ]] || changed=1 + _php_apparmor + [[ ${LSS_FILE_CHANGED} == 0 ]] || changed=1 if ! "${PHP_FPM}" -t >/dev/null 2>&1; then "${PHP_FPM}" -t >&2 || true @@ -352,6 +389,13 @@ mod_remove() { fi fi file_remove "${PHP_TMPFILES}" + if [[ -f ${PHP_APPARMOR_LOCAL} ]]; then + file_remove "${PHP_APPARMOR_LOCAL}" + if [[ -f ${PHP_APPARMOR_PROFILE} && $(cat "${SVC_APPARMOR_ENABLED:-/sys/module/apparmor/parameters/enabled}" 2>/dev/null) == Y ]]; then + log_command apparmor_parser -r "${PHP_APPARMOR_PROFILE}" || + log_warn "php: AppArmor did not reload ${PHP_APPARMOR_PROFILE}; see the log." + fi + fi read -ra installed <<<"$(state_get "${LSS_MODULE}" packages)" if [[ ${#installed[@]} -gt 0 ]]; then pkg_remove "${installed[@]}" diff --git a/tests/unit/web.bats b/tests/unit/web.bats index c2d0dbe..be4bd18 100644 --- a/tests/unit/web.bats +++ b/tests/unit/web.bats @@ -612,6 +612,39 @@ EOF [ ! -s "${STUB_LOG}" ] } +@test "PHP-FPM's AppArmor profile gets the lss paths before the package starts it, and is reloaded only on a change" { + as_test_user + # shellcheck source=modules/runtime/php.sh + source "${REPO_ROOT}/modules/runtime/php.sh" + LSS_MODULE=php + export LSS_PHP_WRITABLE_PATHS='/var/www /srv/app/' + PHP_APPARMOR_PROFILE=${BATS_TEST_TMPDIR}/apparmor.d/php-fpm + PHP_APPARMOR_LOCAL=${BATS_TEST_TMPDIR}/apparmor.d/local/php-fpm + export SVC_APPARMOR_ENABLED=${BATS_TEST_TMPDIR}/apparmor-enabled + echo Y >"${SVC_APPARMOR_ENABLED}" + # shellcheck disable=SC2329 # called by the functions under test + apparmor_parser() { + printf 'apparmor_parser %s\n' "$*" >>"${STUB_LOG}" + } + # Without the profile (Debian, Ubuntu 22.04, the RHEL family) nothing is written. + _php_apparmor + [ ! -e "${PHP_APPARMOR_LOCAL}" ] + mkdir -p "$(dirname "${PHP_APPARMOR_LOCAL}")" + : >"${PHP_APPARMOR_PROFILE}" + _php_apparmor + grep -qxF 'owner @{run}/systemd/notify w,' "${PHP_APPARMOR_LOCAL}" + grep -qxF '/usr/share/lss/** r,' "${PHP_APPARMOR_LOCAL}" + grep -qxF '/var/lib/php/lss-sessions/** rwk,' "${PHP_APPARMOR_LOCAL}" + grep -qxF '/var/www/** rwk,' "${PHP_APPARMOR_LOCAL}" + grep -qxF '/srv/app/** rwk,' "${PHP_APPARMOR_LOCAL}" + grep -qxF "apparmor_parser -r ${PHP_APPARMOR_PROFILE}" "${STUB_LOG}" + [ "${LSS_FILE_CHANGED}" = 1 ] + : >"${STUB_LOG}" + _php_apparmor + [ "${LSS_FILE_CHANGED}" = 0 ] + [ ! -s "${STUB_LOG}" ] +} + @test "site names that could leave the configuration directory are refused" { run _web_name_check '../etc' [ "${status}" -ne 0 ] From d17a1e54e1a3ba22a2ed1e174f252e7936b2bda8 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Thu, 15 Oct 2026 12:01:45 +0200 Subject: [PATCH 84/92] fix(haproxy): let haproxy enter its SELinux domain NoNewPrivileges blocks the transition from init_t to haproxy_t (avc nnp_transition), which the RHEL policy allows for nginx and Apache but not for haproxy; in init_t haproxy could not create its admin socket and did not start in profile all on Rocky Linux 9 and AlmaLinux 10. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- modules/web/haproxy.sh | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/modules/web/haproxy.sh b/modules/web/haproxy.sh index fc1380d..2438337 100644 --- a/modules/web/haproxy.sh +++ b/modules/web/haproxy.sh @@ -151,11 +151,17 @@ _haproxy_firewall() { mod_configure() { local changed=0 + local -a selinux=() _haproxy_conf | file_write 0644 root:root "${HAPROXY_CONF}" [[ ${LSS_FILE_CHANGED} == 0 ]] || changed=1 _haproxy_selinux _haproxy_firewall - svc_sandbox haproxy.service ReadWritePaths=-/var/lib/haproxy + # Under SELinux haproxy runs in its own domain (haproxy_t). NoNewPrivileges blocks the change from init_t, unlike for + # nginx and Apache, whose domains the policy allows it; in init_t haproxy may not create its admin socket. + if command -v selinuxenabled >/dev/null 2>&1 && selinuxenabled; then + selinux=(NoNewPrivileges=no) + fi + svc_sandbox haproxy.service ReadWritePaths=-/var/lib/haproxy "${selinux[@]}" [[ ${LSS_FILE_CHANGED} == 0 ]] || changed=1 haproxy -c -q -f "${HAPROXY_CONF}" || die "haproxy: the configuration does not load; see above." svc_enable haproxy From 4dce9f88b4af952a761f4083577ae0e67ffe3618 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Thu, 15 Oct 2026 12:01:46 +0200 Subject: [PATCH 85/92] fix(admin-user): keep the run lock away from passwd and chpasswd Both inherited descriptor 9, the lock of the lss run, which SELinux denies passwd_t (avc write /run/lss.lock). Refs #3 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- modules/base/admin-user.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/modules/base/admin-user.sh b/modules/base/admin-user.sh index 7479e8d..ccf131a 100644 --- a/modules/base/admin-user.sh +++ b/modules/base/admin-user.sh @@ -119,10 +119,11 @@ mod_configure() { files_note_change "added ${name} to ${group}" fi # A password exists for sudo and the console; it is generated once and readable with "lss secrets show". - status=$(passwd --status "${name}" 2>/dev/null | awk '{ print $2 }') + # The lock of the lss run (descriptor 9) stays with lss: SELinux denies passwd_t the inherited file (avc). + status=$(passwd --status "${name}" 2>/dev/null 9>&- | awk '{ print $2 }') if [[ ! ${status} =~ ^PS?$ ]]; then secret_load admin-user-password password 24 - chpasswd <<<"${name}:${password}" + chpasswd <<<"${name}:${password}" 9>&- files_note_change "set password of ${name}" log_info "Password of ${name}: lss secrets show admin-user-password" fi From 5bf98cc99778ee92fa687e44b029b36573e1771e Mon Sep 17 00:00:00 2001 From: VectoDE Date: Thu, 15 Oct 2026 13:56:11 +0200 Subject: [PATCH 86/92] fix(service): let sandboxed services enter their SELinux domain After haproxy, postgresql failed the same way in profile all on Rocky Linux 9 and AlmaLinux 10: NoNewPrivileges blocks the change from init_t into the service's domain (avc nnp_transition) wherever the policy does not allow it, and in init_t the service may not read its own files. Under SELinux every lss sandbox now leaves NoNewPrivileges off, as the scanner sandbox already did; the domain confines the service more than init_t would. The haproxy-only exception goes away. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- lib/service.sh | 29 ++++++++++++++++++----------- modules/web/haproxy.sh | 8 +------- tests/unit/system.bats | 20 ++++++++++++++++++++ 3 files changed, 39 insertions(+), 18 deletions(-) diff --git a/lib/service.sh b/lib/service.sh index c837b28..0291b2e 100644 --- a/lib/service.sh +++ b/lib/service.sh @@ -109,19 +109,26 @@ svc_wait_port() { return 1 } +# _svc_nnp: yes, or no under SELinux. A confined service enters its own domain on exec (httpd_t, postgresql_t, +# aide_t); NoNewPrivileges blocks that change wherever the policy does not allow it (avc: nnp_transition), and the +# service then runs in init_t, which may not touch its own files and is less confined than its domain. +_svc_nnp() { + if command -v selinuxenabled >/dev/null 2>&1 && selinuxenabled; then + printf 'no' + else + printf 'yes' + fi + return +} + # svc_scanner_sandbox full|strict [PATH...]: [Service] lines that confine lss's own scanner units. They read the whole # system but write only to the PATHs (with strict) or below /var (with full). The real /tmp, /proc/kallsyms, the # kernel modules and the kernel log stay visible: a private /tmp would hide it from ClamAV and rkhunter, and rkhunter, # AIDE and Lynis check the others. svc_scanner_sandbox() { - local level=$1 path nnp=yes + local level=$1 path shift - # Under SELinux a scanner enters its own domain (aide_t, antivirus_t) on exec. NoNewPrivileges blocks that change; - # the scanner then stays in init_t, which may not write its reports. - if command -v selinuxenabled >/dev/null 2>&1 && selinuxenabled; then - nnp=no - fi - printf '%s\n' "ProtectSystem=${level}" ProtectHome=read-only "NoNewPrivileges=${nnp}" ProtectControlGroups=yes \ + printf '%s\n' "ProtectSystem=${level}" ProtectHome=read-only "NoNewPrivileges=$(_svc_nnp)" ProtectControlGroups=yes \ ProtectClock=yes ProtectHostname=yes RestrictSUIDSGID=yes RestrictRealtime=yes RestrictNamespaces=yes \ LockPersonality=yes SystemCallArchitectures=native for path in "$@"; do @@ -131,15 +138,15 @@ svc_scanner_sandbox() { } # svc_sandbox UNIT [DIRECTIVE...]: confines a service with a drop-in (lss-sandbox.conf): read-only system, no home -# directories, a private /tmp and /dev, no kernel tunables, modules or logs, no new privileges. DIRECTIVEs follow the -# baseline, so a service adds the paths it writes (ReadWritePaths=/var/lib/NAME) or relaxes a setting it cannot live -# with (PrivateDevices=no); for single-value settings the last assignment wins. +# directories, a private /tmp and /dev, no kernel tunables, modules or logs, no new privileges (see _svc_nnp for +# SELinux). DIRECTIVEs follow the baseline, so a service adds the paths it writes (ReadWritePaths=/var/lib/NAME) or +# relaxes a setting it cannot live with (PrivateDevices=no); for single-value settings the last assignment wins. svc_sandbox() { local unit=$1 directive shift { printf '# Managed by lss (module %s). Sandbox of %s.\n[Service]\n' "${LSS_MODULE:-lss}" "${unit}" - printf '%s\n' NoNewPrivileges=yes ProtectSystem=strict ProtectHome=yes PrivateTmp=yes PrivateDevices=yes \ + printf '%s\n' "NoNewPrivileges=$(_svc_nnp)" ProtectSystem=strict ProtectHome=yes PrivateTmp=yes PrivateDevices=yes \ ProtectKernelTunables=yes ProtectKernelModules=yes ProtectKernelLogs=yes ProtectControlGroups=yes \ ProtectClock=yes ProtectHostname=yes RestrictSUIDSGID=yes RestrictRealtime=yes RestrictNamespaces=yes \ LockPersonality=yes SystemCallArchitectures=native \ diff --git a/modules/web/haproxy.sh b/modules/web/haproxy.sh index 2438337..fc1380d 100644 --- a/modules/web/haproxy.sh +++ b/modules/web/haproxy.sh @@ -151,17 +151,11 @@ _haproxy_firewall() { mod_configure() { local changed=0 - local -a selinux=() _haproxy_conf | file_write 0644 root:root "${HAPROXY_CONF}" [[ ${LSS_FILE_CHANGED} == 0 ]] || changed=1 _haproxy_selinux _haproxy_firewall - # Under SELinux haproxy runs in its own domain (haproxy_t). NoNewPrivileges blocks the change from init_t, unlike for - # nginx and Apache, whose domains the policy allows it; in init_t haproxy may not create its admin socket. - if command -v selinuxenabled >/dev/null 2>&1 && selinuxenabled; then - selinux=(NoNewPrivileges=no) - fi - svc_sandbox haproxy.service ReadWritePaths=-/var/lib/haproxy "${selinux[@]}" + svc_sandbox haproxy.service ReadWritePaths=-/var/lib/haproxy [[ ${LSS_FILE_CHANGED} == 0 ]] || changed=1 haproxy -c -q -f "${HAPROXY_CONF}" || die "haproxy: the configuration does not load; see above." svc_enable haproxy diff --git a/tests/unit/system.bats b/tests/unit/system.bats index 6c28e1b..dfefc4a 100644 --- a/tests/unit/system.bats +++ b/tests/unit/system.bats @@ -599,6 +599,26 @@ EOF run ! grep -qxF 'NoNewPrivileges=yes' <<<"${output}" } +@test "svc_sandbox writes the baseline and the service's own lines, without new privileges outside SELinux" { + # shellcheck disable=SC2329 # called by the function under test + svc_dropin() { + printf '== %s %s\n' "$1" "$2" + cat + } + run svc_sandbox demo.service ReadWritePaths=-/var/lib/demo PrivateDevices=no + [ "${status}" -eq 0 ] + [ "${lines[0]}" = '== demo.service lss-sandbox' ] + grep -qxF 'NoNewPrivileges=yes' <<<"${output}" + grep -qxF 'ProtectSystem=strict' <<<"${output}" + [ "${lines[-2]}" = 'ReadWritePaths=-/var/lib/demo' ] + [ "${lines[-1]}" = 'PrivateDevices=no' ] + # Under SELinux the service enters its own domain (postgresql_t, haproxy_t), which NoNewPrivileges would block. + export STUB_EXIT_selinuxenabled=0 + run svc_sandbox demo.service + grep -qxF 'NoNewPrivileges=no' <<<"${output}" + run ! grep -qxF 'NoNewPrivileges=yes' <<<"${output}" +} + @test "svc_apparmor_confined needs an enforcing profile, from the list or the label of a running process" { export SVC_APPARMOR_ENABLED=${BATS_TEST_TMPDIR}/enabled SVC_APPARMOR_PROFILES=${BATS_TEST_TMPDIR}/profiles export SVC_PROC=${BATS_TEST_TMPDIR}/proc From 3246f69b5b1960a0335affd137bd0f97425d4b20 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Thu, 15 Oct 2026 13:56:12 +0200 Subject: [PATCH 87/92] fix(modsecurity): read YAJL support from the engine file itself On Ubuntu 26.04 the second install of profile all added the JSON settings that the first had left out: ldd depends on the dynamic linker finding every library, and the two runs disagreed. The library names an engine needs are strings in the file; for nginx the engine is the self-built libmodsecurity, which links YAJL, not the connector. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- modules/web/modsecurity.sh | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/modules/web/modsecurity.sh b/modules/web/modsecurity.sh index 9f6927a..dc69d2e 100644 --- a/modules/web/modsecurity.sh +++ b/modules/web/modsecurity.sh @@ -191,7 +191,8 @@ _modsecurity_nginx_headers() { _modsecurity_json() { local server=$1 engine if [[ ${server} == nginx ]]; then - engine=$(_modsecurity_nginx_so) + # The connector links libmodsecurity, which links YAJL. + engine=${MODSEC_PREFIX}/lib/libmodsecurity.so elif _modsecurity_apache_built; then engine=$(_modsecurity_apache_so) elif os_is_debian; then @@ -199,7 +200,9 @@ _modsecurity_json() { else engine=/usr/lib64/httpd/modules/mod_security2.so fi - ldd "${engine}" 2>/dev/null | grep -q 'libyajl' + # The library names the engine needs are strings in the file itself. ldd depends on the dynamic linker finding them; + # on Ubuntu 26.04 the first and the second install of profile all came to different answers. + grep -qaF 'libyajl.so' "${engine}" 2>/dev/null return } From 0e529bdd3c38bc0850085e62f090105b3995bb95 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Thu, 15 Oct 2026 13:56:13 +0200 Subject: [PATCH 88/92] fix(php): allow the lss pool socket in PHP-FPM's AppArmor profile Ubuntu 24.04's profile knows only the sockets below /run/php, so PHP-FPM could not create /run/php-fpm/lss.sock. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- modules/runtime/php.sh | 2 ++ tests/unit/web.bats | 1 + 2 files changed, 3 insertions(+) diff --git a/modules/runtime/php.sh b/modules/runtime/php.sh index cd5386b..19da658 100644 --- a/modules/runtime/php.sh +++ b/modules/runtime/php.sh @@ -170,6 +170,8 @@ _php_apparmor_rules() { local path printf '# Managed by lss (module php). Change LSS_PHP_WRITABLE_PATHS in /etc/lss/lss.conf and run lss configure php.\n' printf 'owner @{run}/systemd/notify w,\n/systemd/notify w,\n' + # The socket of the lss pool (WEB_PHP_SOCKET); Ubuntu 24.04's profile knows only the sockets below /run/php. + printf '%s/ r,\n%s/* rwlk,\n' "$(dirname "${WEB_PHP_SOCKET}")" "$(dirname "${WEB_PHP_SOCKET}")" printf 'include \n@{run}/postgresql/.s.PGSQL.* rw,\n@{run}/redis/*.sock rw,\n@{run}/valkey/*.sock rw,\n' printf '/usr/share/lss/ r,\n/usr/share/lss/** r,\n' printf '%s/ r,\n%s/** rwk,\n' "${PHP_SESSIONS}" "${PHP_SESSIONS}" diff --git a/tests/unit/web.bats b/tests/unit/web.bats index be4bd18..b48bca8 100644 --- a/tests/unit/web.bats +++ b/tests/unit/web.bats @@ -634,6 +634,7 @@ EOF _php_apparmor grep -qxF 'owner @{run}/systemd/notify w,' "${PHP_APPARMOR_LOCAL}" grep -qxF '/usr/share/lss/** r,' "${PHP_APPARMOR_LOCAL}" + grep -qxF '/run/php-fpm/* rwlk,' "${PHP_APPARMOR_LOCAL}" grep -qxF '/var/lib/php/lss-sessions/** rwk,' "${PHP_APPARMOR_LOCAL}" grep -qxF '/var/www/** rwk,' "${PHP_APPARMOR_LOCAL}" grep -qxF '/srv/app/** rwk,' "${PHP_APPARMOR_LOCAL}" From 30f894776d4d7ae45ddedfd8a32e32a559e49a20 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Thu, 15 Oct 2026 14:00:19 +0200 Subject: [PATCH 89/92] fix(service): keep NoNewPrivileges for programs without an SELinux domain The previous change turned NoNewPrivileges off for every sandbox under SELinux. A program without an entrypoint label of its own (bin_t, such as a gunicorn from PyPI) stays in init_t anyway and lost the setting for nothing. Only units whose program carries a TYPE_exec_t label, and so enters a confined domain, now go without it. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- lib/service.sh | 24 +++++++++++++++++++----- tests/unit/system.bats | 21 +++++++++++++++++++-- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/lib/service.sh b/lib/service.sh index 0291b2e..e1e93e0 100644 --- a/lib/service.sh +++ b/lib/service.sh @@ -109,11 +109,25 @@ svc_wait_port() { return 1 } -# _svc_nnp: yes, or no under SELinux. A confined service enters its own domain on exec (httpd_t, postgresql_t, -# aide_t); NoNewPrivileges blocks that change wherever the policy does not allow it (avc: nnp_transition), and the -# service then runs in init_t, which may not touch its own files and is less confined than its domain. +# _svc_nnp [UNIT]: the NoNewPrivileges value of a sandbox. Under SELinux a confined service enters its own domain on +# exec (httpd_t, postgresql_t, aide_t); NoNewPrivileges blocks that change wherever the policy does not allow it (avc: +# nnp_transition), and the service then runs in init_t, which may not touch its own files and is less confined than +# its domain. So it is "no" for a UNIT whose program carries an entrypoint label of its own (TYPE_exec_t), and "yes" +# for every other unit, whose program would stay in init_t anyway. Without UNIT (the scanner units, whose wrappers run +# the scanners) it is "no" under SELinux. _svc_nnp() { - if command -v selinuxenabled >/dev/null 2>&1 && selinuxenabled; then + local unit=${1:-} program type + if ! command -v selinuxenabled >/dev/null 2>&1 || ! selinuxenabled; then + printf 'yes' + return + fi + if [[ -z ${unit} ]]; then + printf 'no' + return + fi + program=$(systemctl show -P ExecStart "${unit}" 2>/dev/null | sed -n 's/^{ path=\([^ ;]*\) .*/\1/p' | head -n 1) + type=$(stat -c %C "${program:-/nonexistent}" 2>/dev/null | cut -d : -f 3) + if [[ ${type} == ?*_exec_t ]]; then printf 'no' else printf 'yes' @@ -146,7 +160,7 @@ svc_sandbox() { shift { printf '# Managed by lss (module %s). Sandbox of %s.\n[Service]\n' "${LSS_MODULE:-lss}" "${unit}" - printf '%s\n' "NoNewPrivileges=$(_svc_nnp)" ProtectSystem=strict ProtectHome=yes PrivateTmp=yes PrivateDevices=yes \ + printf '%s\n' "NoNewPrivileges=$(_svc_nnp "${unit}")" ProtectSystem=strict ProtectHome=yes PrivateTmp=yes PrivateDevices=yes \ ProtectKernelTunables=yes ProtectKernelModules=yes ProtectKernelLogs=yes ProtectControlGroups=yes \ ProtectClock=yes ProtectHostname=yes RestrictSUIDSGID=yes RestrictRealtime=yes RestrictNamespaces=yes \ LockPersonality=yes SystemCallArchitectures=native \ diff --git a/tests/unit/system.bats b/tests/unit/system.bats index dfefc4a..e4bcab0 100644 --- a/tests/unit/system.bats +++ b/tests/unit/system.bats @@ -612,11 +612,28 @@ EOF grep -qxF 'ProtectSystem=strict' <<<"${output}" [ "${lines[-2]}" = 'ReadWritePaths=-/var/lib/demo' ] [ "${lines[-1]}" = 'PrivateDevices=no' ] - # Under SELinux the service enters its own domain (postgresql_t, haproxy_t), which NoNewPrivileges would block. + # Under SELinux a program with an entrypoint label of its own enters its domain (postgresql_t, haproxy_t), which + # NoNewPrivileges would block; any other program keeps NoNewPrivileges. export STUB_EXIT_selinuxenabled=0 - run svc_sandbox demo.service + local label=system_u:object_r:postgresql_exec_t:s0 + # shellcheck disable=SC2329 # called by the function under test + systemctl() { + printf '{ path=/usr/bin/postgres ; argv[]=/usr/bin/postgres -D /var/lib/pgsql/data ; ignore_errors=no }\n' + } + # shellcheck disable=SC2329 + stat() { + [[ ${*: -1} == /usr/bin/postgres ]] && printf '%s\n' "${label}" + } + run svc_sandbox postgresql.service grep -qxF 'NoNewPrivileges=no' <<<"${output}" run ! grep -qxF 'NoNewPrivileges=yes' <<<"${output}" + label=system_u:object_r:bin_t:s0 + run svc_sandbox pgadmin.service + grep -qxF 'NoNewPrivileges=yes' <<<"${output}" + export STUB_EXIT_selinuxenabled=1 + label=system_u:object_r:postgresql_exec_t:s0 + run svc_sandbox postgresql.service + grep -qxF 'NoNewPrivileges=yes' <<<"${output}" } @test "svc_apparmor_confined needs an enforcing profile, from the list or the label of a running process" { From b6276daecb400877f9c32ecc631fd6871f2b954f Mon Sep 17 00:00:00 2001 From: VectoDE Date: Thu, 15 Oct 2026 15:59:56 +0200 Subject: [PATCH 90/92] fix(service): allow SELinux domain transitions under NoNewPrivileges with a policy module systemd implies NoNewPrivileges for User= services with the sandbox's system call settings, so turning it off per service does not help (Rocky 9: postgresql stayed in init_t, avc nnp_transition). A small CIL module per confined program (lss_nnp_TYPE) lets init_t enter TYPE_t with NoNewPrivileges, so the sandbox keeps NoNewPrivileges=yes everywhere. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- lib/service.sh | 60 +++++++++++++++++++++++++----------------- tests/unit/system.bats | 36 ++++++++++++++++--------- 2 files changed, 60 insertions(+), 36 deletions(-) diff --git a/lib/service.sh b/lib/service.sh index e1e93e0..e19d52c 100644 --- a/lib/service.sh +++ b/lib/service.sh @@ -109,29 +109,35 @@ svc_wait_port() { return 1 } -# _svc_nnp [UNIT]: the NoNewPrivileges value of a sandbox. Under SELinux a confined service enters its own domain on -# exec (httpd_t, postgresql_t, aide_t); NoNewPrivileges blocks that change wherever the policy does not allow it (avc: -# nnp_transition), and the service then runs in init_t, which may not touch its own files and is less confined than -# its domain. So it is "no" for a UNIT whose program carries an entrypoint label of its own (TYPE_exec_t), and "yes" -# for every other unit, whose program would stay in init_t anyway. Without UNIT (the scanner units, whose wrappers run -# the scanners) it is "no" under SELinux. -_svc_nnp() { - local unit=${1:-} program type - if ! command -v selinuxenabled >/dev/null 2>&1 || ! selinuxenabled; then - printf 'yes' - return - fi - if [[ -z ${unit} ]]; then - printf 'no' - return - fi +_svc_selinux_on() { + command -v selinuxenabled >/dev/null 2>&1 && selinuxenabled +} + +# _svc_selinux_nnp UNIT: under SELinux a confined service enters its own domain on exec (httpd_t, postgresql_t). +# NoNewPrivileges blocks that change wherever the policy does not allow it (avc: nnp_transition); systemd also implies +# NoNewPrivileges for a service with User= and the sandbox's system call settings. The service then stays in init_t, +# which may not read its own files and is less confined than its domain. For a UNIT whose program carries an +# entrypoint label (TYPE_exec_t), a policy module of lss (lss_nnp_TYPE) lets systemd start TYPE_t with +# NoNewPrivileges, so the sandbox stays whole. +_svc_selinux_nnp() { + local unit=$1 program type domain module dir + _svc_selinux_on || return 0 program=$(systemctl show -P ExecStart "${unit}" 2>/dev/null | sed -n 's/^{ path=\([^ ;]*\) .*/\1/p' | head -n 1) type=$(stat -c %C "${program:-/nonexistent}" 2>/dev/null | cut -d : -f 3) - if [[ ${type} == ?*_exec_t ]]; then - printf 'no' - else - printf 'yes' + [[ ${type} == ?*_exec_t ]] || return 0 + domain=${type%_exec_t}_t + module=lss_nnp_${type%_exec_t} + if semodule -l 2>/dev/null | awk '{ print $1 }' | grep -qxF "${module}"; then + return 0 + fi + dir=$(mktemp -d) + printf '(allow init_t %s (process2 (nnp_transition nosuid_transition)))\n' "${domain}" >"${dir}/${module}.cil" + if ! log_command semodule -i "${dir}/${module}.cil"; then + rm -rf -- "${dir}" + die "${unit}: SELinux does not take the policy module ${module} that lets systemd start ${domain}; see the log." fi + rm -rf -- "${dir}" + files_note_change "SELinux module ${module}: systemd starts ${domain} with NoNewPrivileges" return } @@ -140,9 +146,14 @@ _svc_nnp() { # kernel modules and the kernel log stay visible: a private /tmp would hide it from ClamAV and rkhunter, and rkhunter, # AIDE and Lynis check the others. svc_scanner_sandbox() { - local level=$1 path + local level=$1 path nnp=yes shift - printf '%s\n' "ProtectSystem=${level}" ProtectHome=read-only "NoNewPrivileges=$(_svc_nnp)" ProtectControlGroups=yes \ + # Under SELinux a scanner enters its own domain (aide_t, antivirus_t) when lss's wrapper runs it. NoNewPrivileges + # blocks that change; the scanner then stays in init_t, which may not write its reports. + if _svc_selinux_on; then + nnp=no + fi + printf '%s\n' "ProtectSystem=${level}" ProtectHome=read-only "NoNewPrivileges=${nnp}" ProtectControlGroups=yes \ ProtectClock=yes ProtectHostname=yes RestrictSUIDSGID=yes RestrictRealtime=yes RestrictNamespaces=yes \ LockPersonality=yes SystemCallArchitectures=native for path in "$@"; do @@ -152,15 +163,16 @@ svc_scanner_sandbox() { } # svc_sandbox UNIT [DIRECTIVE...]: confines a service with a drop-in (lss-sandbox.conf): read-only system, no home -# directories, a private /tmp and /dev, no kernel tunables, modules or logs, no new privileges (see _svc_nnp for +# directories, a private /tmp and /dev, no kernel tunables, modules or logs, no new privileges (see _svc_selinux_nnp for # SELinux). DIRECTIVEs follow the baseline, so a service adds the paths it writes (ReadWritePaths=/var/lib/NAME) or # relaxes a setting it cannot live with (PrivateDevices=no); for single-value settings the last assignment wins. svc_sandbox() { local unit=$1 directive shift + _svc_selinux_nnp "${unit}" { printf '# Managed by lss (module %s). Sandbox of %s.\n[Service]\n' "${LSS_MODULE:-lss}" "${unit}" - printf '%s\n' "NoNewPrivileges=$(_svc_nnp "${unit}")" ProtectSystem=strict ProtectHome=yes PrivateTmp=yes PrivateDevices=yes \ + printf '%s\n' NoNewPrivileges=yes ProtectSystem=strict ProtectHome=yes PrivateTmp=yes PrivateDevices=yes \ ProtectKernelTunables=yes ProtectKernelModules=yes ProtectKernelLogs=yes ProtectControlGroups=yes \ ProtectClock=yes ProtectHostname=yes RestrictSUIDSGID=yes RestrictRealtime=yes RestrictNamespaces=yes \ LockPersonality=yes SystemCallArchitectures=native \ diff --git a/tests/unit/system.bats b/tests/unit/system.bats index e4bcab0..a2f2f88 100644 --- a/tests/unit/system.bats +++ b/tests/unit/system.bats @@ -612,10 +612,10 @@ EOF grep -qxF 'ProtectSystem=strict' <<<"${output}" [ "${lines[-2]}" = 'ReadWritePaths=-/var/lib/demo' ] [ "${lines[-1]}" = 'PrivateDevices=no' ] - # Under SELinux a program with an entrypoint label of its own enters its domain (postgresql_t, haproxy_t), which - # NoNewPrivileges would block; any other program keeps NoNewPrivileges. + # Under SELinux a program with an entrypoint label of its own enters its domain (postgresql_t), which NoNewPrivileges + # would block: a policy module lets systemd start that domain, once, and NoNewPrivileges stays. export STUB_EXIT_selinuxenabled=0 - local label=system_u:object_r:postgresql_exec_t:s0 + local label=system_u:object_r:postgresql_exec_t:s0 modules='' # shellcheck disable=SC2329 # called by the function under test systemctl() { printf '{ path=/usr/bin/postgres ; argv[]=/usr/bin/postgres -D /var/lib/pgsql/data ; ignore_errors=no }\n' @@ -624,16 +624,28 @@ EOF stat() { [[ ${*: -1} == /usr/bin/postgres ]] && printf '%s\n' "${label}" } - run svc_sandbox postgresql.service - grep -qxF 'NoNewPrivileges=no' <<<"${output}" - run ! grep -qxF 'NoNewPrivileges=yes' <<<"${output}" + # shellcheck disable=SC2329 + semodule() { + if [[ $1 == -l ]]; then + printf '%s\n' ${modules} + else + printf 'semodule %s %s\n' "$1" "${2##*/}" >>"${STUB_LOG}" + cat "$2" >>"${STUB_LOG}" + fi + } + svc_sandbox postgresql.service >"${BATS_TEST_TMPDIR}/dropin" + grep -qxF 'NoNewPrivileges=yes' "${BATS_TEST_TMPDIR}/dropin" + grep -qxF 'semodule -i lss_nnp_postgresql.cil' "${STUB_LOG}" + grep -qxF '(allow init_t postgresql_t (process2 (nnp_transition nosuid_transition)))' "${STUB_LOG}" + : >"${STUB_LOG}" + modules='lss_nnp_postgresql' + svc_sandbox postgresql.service >/dev/null + run ! grep -q semodule "${STUB_LOG}" + # A program without a domain of its own (bin_t) stays in init_t; there is nothing to allow. label=system_u:object_r:bin_t:s0 - run svc_sandbox pgadmin.service - grep -qxF 'NoNewPrivileges=yes' <<<"${output}" - export STUB_EXIT_selinuxenabled=1 - label=system_u:object_r:postgresql_exec_t:s0 - run svc_sandbox postgresql.service - grep -qxF 'NoNewPrivileges=yes' <<<"${output}" + modules='' + svc_sandbox pgadmin.service >/dev/null + run ! grep -q semodule "${STUB_LOG}" } @test "svc_apparmor_confined needs an enforcing profile, from the list or the label of a running process" { From b7a601a8f94e91e1d87592a3873e9dab0b450ae2 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Thu, 15 Oct 2026 18:22:31 +0200 Subject: [PATCH 91/92] fix: SELinux policy module once the program exists; yescrypt cost only with yescrypt mongodb sandboxes its unit before the package brings mongod; under pipefail the failed stat ended the run without a message (profile all on Rocky 9 and AlmaLinux 10). The module follows when the module calls svc_sandbox again after the installation. RHEL 9's shadow tools know no YESCRYPT_COST_FACTOR and complained on every useradd; password-policy now writes it only where yescrypt is the method. Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- lib/service.sh | 7 +++++-- modules/security/password-policy.sh | 20 +++++++++++++++++++- tests/unit/system.bats | 10 +++++++--- 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/lib/service.sh b/lib/service.sh index e19d52c..2302e93 100644 --- a/lib/service.sh +++ b/lib/service.sh @@ -122,8 +122,11 @@ _svc_selinux_on() { _svc_selinux_nnp() { local unit=$1 program type domain module dir _svc_selinux_on || return 0 - program=$(systemctl show -P ExecStart "${unit}" 2>/dev/null | sed -n 's/^{ path=\([^ ;]*\) .*/\1/p' | head -n 1) - type=$(stat -c %C "${program:-/nonexistent}" 2>/dev/null | cut -d : -f 3) + program=$(systemctl show -P ExecStart "${unit}" 2>/dev/null | sed -n 's/^{ path=\([^ ;]*\) .*/\1/p' | head -n 1) || true + # A module may sandbox its unit before the package brings the program; it calls svc_sandbox again after the + # installation, and the module follows then. + [[ -n ${program} && -e ${program} ]] || return 0 + type=$(stat -c %C "${program}" 2>/dev/null | cut -d : -f 3) || true [[ ${type} == ?*_exec_t ]] || return 0 domain=${type%_exec_t}_t module=lss_nnp_${type%_exec_t} diff --git a/modules/security/password-policy.sh b/modules/security/password-policy.sh index 0269dac..ecdbd27 100644 --- a/modules/security/password-policy.sh +++ b/modules/security/password-policy.sh @@ -48,6 +48,15 @@ _pp_login_defs() { return } +# _pp_login_defs_drop KEY: removes KEY from /etc/login.defs. +_pp_login_defs_drop() { + local content + grep -qE "^[[:space:]]*$1[[:space:]]" /etc/login.defs || return 0 + content=$(grep -vE "^[[:space:]]*$1[[:space:]]" /etc/login.defs) + file_write 0644 root:root /etc/login.defs <<<"${content}" + return +} + _pp_faillock_debian() { file_write 0644 root:root /usr/share/pam-configs/lss-faillock <<'EOF' Name: Lock accounts after repeated failed logins (lss) @@ -118,8 +127,17 @@ EOF else _pp_faillock_rhel fi + local hash + hash=$(_pp_hash) _pp_login_defs PASS_MAX_DAYS "$(opt_int max_days)" PASS_MIN_DAYS 1 PASS_WARN_AGE 14 UMASK 027 \ - ENCRYPT_METHOD "$(_pp_hash)" SHA_CRYPT_MIN_ROUNDS 100000 SHA_CRYPT_MAX_ROUNDS 100000 YESCRYPT_COST_FACTOR 7 + ENCRYPT_METHOD "${hash}" SHA_CRYPT_MIN_ROUNDS 100000 SHA_CRYPT_MAX_ROUNDS 100000 + # YESCRYPT_COST_FACTOR only where yescrypt is the method: the shadow tools of RHEL 9 know neither and complain + # about the unknown item on every call (useradd, passwd). + if [[ ${hash} == YESCRYPT ]]; then + _pp_login_defs YESCRYPT_COST_FACTOR 7 + else + _pp_login_defs_drop YESCRYPT_COST_FACTOR + fi local timeout timeout=$(opt_int shell_timeout) file_write 0644 root:root "${PP_PROFILE}" < <( diff --git a/tests/unit/system.bats b/tests/unit/system.bats index a2f2f88..aeb4f64 100644 --- a/tests/unit/system.bats +++ b/tests/unit/system.bats @@ -615,15 +615,19 @@ EOF # Under SELinux a program with an entrypoint label of its own enters its domain (postgresql_t), which NoNewPrivileges # would block: a policy module lets systemd start that domain, once, and NoNewPrivileges stays. export STUB_EXIT_selinuxenabled=0 - local label=system_u:object_r:postgresql_exec_t:s0 modules='' + local label=system_u:object_r:postgresql_exec_t:s0 modules='' postgres=${BATS_TEST_TMPDIR}/postgres # shellcheck disable=SC2329 # called by the function under test systemctl() { - printf '{ path=/usr/bin/postgres ; argv[]=/usr/bin/postgres -D /var/lib/pgsql/data ; ignore_errors=no }\n' + printf '{ path=%s ; argv[]=%s -D /var/lib/pgsql/data ; ignore_errors=no }\n' "${postgres}" "${postgres}" } # shellcheck disable=SC2329 stat() { - [[ ${*: -1} == /usr/bin/postgres ]] && printf '%s\n' "${label}" + [[ ${*: -1} == "${postgres}" ]] && printf '%s\n' "${label}" } + # Before the package brings the program there is nothing to label; the module calls svc_sandbox again later. + svc_sandbox postgresql.service >/dev/null + run ! grep -q semodule "${STUB_LOG}" + touch "${postgres}" # shellcheck disable=SC2329 semodule() { if [[ $1 == -l ]]; then From c18a1a1e52126d30df359acbf9cd601d63302524 Mon Sep 17 00:00:00 2001 From: VectoDE Date: Thu, 15 Oct 2026 20:23:33 +0200 Subject: [PATCH 92/92] style: quote test values for the newer shellcheck, name the key in _pp_login_defs_drop The CI's shellcheck reads TEST_GROUPS=www-data as arithmetic (SC2100) and wants the stub's module list quoted (SC2086); SonarCloud wants the positional parameter in a named local (S7679). Refs #4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: VectoDE --- modules/security/password-policy.sh | 6 +++--- tests/unit/system.bats | 2 +- tests/unit/web.bats | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/modules/security/password-policy.sh b/modules/security/password-policy.sh index ecdbd27..faeeeef 100644 --- a/modules/security/password-policy.sh +++ b/modules/security/password-policy.sh @@ -50,9 +50,9 @@ _pp_login_defs() { # _pp_login_defs_drop KEY: removes KEY from /etc/login.defs. _pp_login_defs_drop() { - local content - grep -qE "^[[:space:]]*$1[[:space:]]" /etc/login.defs || return 0 - content=$(grep -vE "^[[:space:]]*$1[[:space:]]" /etc/login.defs) + local key=$1 content + grep -qE "^[[:space:]]*${key}[[:space:]]" /etc/login.defs || return 0 + content=$(grep -vE "^[[:space:]]*${key}[[:space:]]" /etc/login.defs) file_write 0644 root:root /etc/login.defs <<<"${content}" return } diff --git a/tests/unit/system.bats b/tests/unit/system.bats index aeb4f64..10d319a 100644 --- a/tests/unit/system.bats +++ b/tests/unit/system.bats @@ -631,7 +631,7 @@ EOF # shellcheck disable=SC2329 semodule() { if [[ $1 == -l ]]; then - printf '%s\n' ${modules} + printf '%s\n' "${modules}" else printf 'semodule %s %s\n' "$1" "${2##*/}" >>"${STUB_LOG}" cat "$2" >>"${STUB_LOG}" diff --git a/tests/unit/web.bats b/tests/unit/web.bats index b48bca8..e7375f8 100644 --- a/tests/unit/web.bats +++ b/tests/unit/web.bats @@ -207,7 +207,7 @@ EOF printf '%s\n' "${TEST_GROUPS}" return } - TEST_GROUPS=www-data + TEST_GROUPS='www-data' web_group_ensure www-data ghost grep -qxF "groupadd --system ${WEB_GROUP}" "${STUB_LOG}" grep -qxF "usermod --append --groups ${WEB_GROUP} www-data" "${STUB_LOG}"