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" diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 5fb1b63..937bb1f 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] + group: [base, security, scanners, web, runtime, database, database-alt] arch: [x64, arm64] include: - group: base @@ -40,6 +40,16 @@ 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 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: @@ -98,12 +108,18 @@ 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] + # 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":"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 with: @@ -132,7 +148,9 @@ 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 minimal-secure "$minimum" + tests/integration/run.sh "$SYSTEM" --profile "${RUN#profile }" "$minimum" fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 919f403..daf1bf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,20 @@ 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). +- 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. +- `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. - 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..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 @@ -118,6 +150,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 +167,37 @@ 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`, +`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. + 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/data/checksums.txt b/data/checksums.txt new file mode 100644 index 0000000..1d01722 --- /dev/null +++ b/data/checksums.txt @@ -0,0 +1,79 @@ +# 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 + +# 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 + +# 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 +# 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 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 +# 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 +# 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). +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/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/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/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/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/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/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 new file mode 100644 index 0000000..ca8b338 --- /dev/null +++ b/data/keys/nodesource.asc @@ -0,0 +1,30 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQENBFdDN1ABCADaNd/I3j3tn40deQNgz7hB2NvT+syXe6k4ZmdiEcOfBvFrkS8B +hNS67t93etHsxEy7E0qwsZH32bKazMqe9zDwoa3aVImryjh6SHC9lMtW27JPHFeM +Srkt9YmH1WMwWcRO6eSY9B3PpazquhnvbammLuUojXRIxkDroy6Fw4UKmUNSRr32 +9Ej87jRoR1B2/57Kfp2Y4+vFGGzSvh3AFQpBHq51qsNHALU6+8PjLfIt+5TPvaWR +TB+kAZnQZkaIQM2nr1n3oj6ak2RATY/+kjLizgFWzgEfbCrbsyq68UoY5FPBnu4Z +E3iDZpaIqwKr0seUC7iA1xM5eHi5kty1oB7HABEBAAG0Ik5Tb2xpZCA8bnNvbGlk +LWdwZ0Bub2Rlc291cmNlLmNvbT6JAU4EEwEKADgCGwMCHgECF4AWIQRvcfUlKChB +7tr4UbQvWbX5mxvgtAUCaWfGnwULCQgHAgYVCgkICwIEFgIDAQAKCRAvWbX5mxvg +tL0CB/0TTur63uXTPbq+JBQET6jnEVaDrNmpHI/z2RMuj163gmy9CtCwG060c+TI +qzmzlPacbTkGc5C50teX2mhs+5JIthfhMibR6Dgy0zeSVuvt9WJ3TNWBpoW5Ezka +17SA3Xv9UEJS2eyRd7ALf7TGGAj8DG+xRABXl6bJIXh1s7Hki7kwgVrpNxm/EqyX +OrzExm0g2UjgdkbnZrnTyZMB6X8f8fZPo0QcByPvLee2DtkKeE+SZlFXyvnCzcw0 +94K45vlWkEdRzmiiWAG7guzZyZYGcCO5EoJ3LUP90qhrmQUfsbtczeoEW17EW1jM +RohLo8BqVyYc1CAFlULIDNeS3aipuQENBFdDN1ABCACq02gutmss6jiD2u50vdAR +x3JOGd92trWRf7qH9/MTToslhoU/LiXhaE0x5+UkKuRbcruCtytn226brvwD0BdO +QGpY81ZAxjTFuKUpIDmrHcrI/N9XLrLKx19kWEttsIPNJOgvx8JoeReOX2hLjcbF +fmz8e6D1K7eZ+hjMfOLxD94tfhQwYtKbfwf0JwxQV5ItQRryd+X+TcA3nyvFWiJ8 +Vz92Tf1iG7Z+XasaaZFf2JDuxoiZaSZY7va395ClCEMykFsRbGwgDyB6Fr+6x/UU +sYA4rYV7RumIOUdWngWyWjzVy/cKSWASW4jI5ABWs+AfeRTqmZt6+6B7oIghrB19 +ABEBAAGJAR8EGAECAAkFAldDN1ACGwwACgkQL1m1+Zsb4LRPzgf9FTfPA0d2N37q +su5av3bYZJ+w6pvWF3aK2Zdy9ednhCBfPNmhvBRN6mXWixo0DuKEoQMjVpkX0hrq +B9FxEVwCPZ9K/JuJgzMROq7T+7R3rcv+TPMdwk8rjIMeRiw2hFLwbrJ0w/v7/3wT +MESv3GfXbuT0huEedK7bLfqGMgtmqMeCNkoI7Rf3GO2xArGQ6Zc2rTd4L9+D5HHS +IPEixwFZS+Debc7pg3os0bKXEsqMtitvocClOAuPnXpCy04PK450UTr6ZVdirWhJ +Oq8SrSERQwqEJ4PMHybiDKwzSr6mcBURre3G71uTAp7h/DISLTpfVMadcB6eoLWr +/4fBqejAWg== +=wh5k +-----END PGP PUBLIC KEY BLOCK----- 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/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/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/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/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/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/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/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/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/data/repos/nodesource-22.conf b/data/repos/nodesource-22.conf new file mode 100644 index 0000000..296175b --- /dev/null +++ b/data/repos/nodesource-22.conf @@ -0,0 +1,22 @@ +# 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), 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 +# 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 +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..695669e --- /dev/null +++ b/data/repos/nodesource-24.conf @@ -0,0 +1,22 @@ +# 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), 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 +# 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 +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..78e95f7 --- /dev/null +++ b/data/repos/nodesource-26.conf @@ -0,0 +1,22 @@ +# 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), 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 +# 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 +RPM_BASEURL=https://rpm.nodesource.com/pub_26.x/nodistro/nodejs/$basearch +RPM_GPGCHECK=1 +RPM_REPO_GPGCHECK=0 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/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/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/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/docs/hardening.md b/docs/hardening.md index 0469456..1fd5dbd 100644 --- a/docs/hardening.md +++ b/docs/hardening.md @@ -32,6 +32,20 @@ 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 | +| `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) | +| `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/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/pkg.sh b/lib/pkg.sh index 37a344a..23e9d73 100644 --- a/lib/pkg.sh +++ b/lib/pkg.sh @@ -9,17 +9,34 @@ 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 +# 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 +80,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 +109,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 +132,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 +143,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" } 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/lib/service.sh b/lib/service.sh index 8108282..2302e93 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 } @@ -105,6 +109,41 @@ svc_wait_port() { return 1 } +_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) || 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} + 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 +} + # 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, @@ -112,9 +151,9 @@ svc_wait_port() { svc_scanner_sandbox() { local level=$1 path nnp=yes 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 + # 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 \ @@ -126,6 +165,54 @@ 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 (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=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 +} + +# 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 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 + 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 # changed. svc_unit() { diff --git a/lib/step.sh b/lib/step.sh index fba0fc8..4980f72 100755 --- a/lib/step.sh +++ b/lib/step.sh @@ -11,9 +11,11 @@ # 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 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..ca01074 --- /dev/null +++ b/lib/web.sh @@ -0,0 +1,803 @@ +# 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); 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 (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/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 +# 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 +# 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 + +# 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_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 + 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 +} + +# 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 + # The tls module's certificate for the host names (lineage "default") takes this place; certbot renews it. + [[ ! -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 + 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}" + # 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 +} + +# 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="${WEB_CERT_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 + local -a env=() + [[ ! -s ${WEB_ACME_CA} ]] || env=("REQUESTS_CA_BUNDLE=${WEB_ACME_CA}") + log_info "Requesting a certificate for $*" + 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 + if ! cmp -s "${live}" "${WEB_TLS_DIR}/${name}/fullchain.pem"; then + RENEWED_LINEAGE="${WEB_CERT_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%/}/${path}/ upgrade=websocket + ProxyPassReverse ${target%/}/${path}/ + 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\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}" ;; + *) 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_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. +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. 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}" + file_remove "${WEB_CROWDSEC_ACQUIS}" + 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 ${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 ${WEB_CROWDSEC_ACQUIS} ]]; then + file_remove "${WEB_CROWDSEC_ACQUIS}" + 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 "${WEB_FAIL2BAN_JAIL}" <".*"status":401\b +ignoreregex = +datepattern = "ts":{EPOCH} +EOF + [[ ${LSS_FILE_CHANGED} == 0 ]] || changed=1 + file_write 0644 root:root "${WEB_FAIL2BAN_JAIL}" </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 "${WEB_CROWDSEC_ACQUIS}" + [[ ${LSS_FILE_CHANGED} == 0 ]] || changed=1 + if [[ ${changed} == 1 ]] && svc_is_active crowdsec; then + svc_restart crowdsec + fi + return +} 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/lss b/lss index 7e9fd1b..bacc59b 100755 --- a/lss +++ b/lss @@ -6,13 +6,15 @@ # 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") 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/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 diff --git a/modules/base/swap.sh b/modules/base/swap.sh index 7879bb2..aa4b251 100644 --- a/modules/base/swap.sh +++ b/modules/base/swap.sh @@ -73,6 +73,9 @@ mod_configure() { 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}" < 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 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." + # 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 + 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) + 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/mariadb.sh b/modules/database/mariadb.sh new file mode 100644 index 0000000..4b29fc3 --- /dev/null +++ b/modules/database/mariadb.sh @@ -0,0 +1,327 @@ +# 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 +# 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 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='' 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%/} + 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 + 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_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 +} + +# 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 data + 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 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 ${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 + secret_remove "${path##*/}" + done + fi + return +} diff --git a/modules/database/mongodb.sh b/modules/database/mongodb.sh new file mode 100644 index 0000000..179fd5a --- /dev/null +++ b/modules/database/mongodb.sh @@ -0,0 +1,407 @@ +# 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 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 </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 +} diff --git a/modules/database/mysql.sh b/modules/database/mysql.sh new file mode 100644 index 0000000..d6c6a84 --- /dev/null +++ b/modules/database/mysql.sh @@ -0,0 +1,621 @@ +# 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 limit + 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) + # 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 + return +} + +# The generic build has no package scripts: lss writes its base configuration and unit and creates the data +# 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 + file_write 0644 root:root "${MYSQL_GENERIC_CNF}" </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 + 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() { + local -a defaults=() + if os_is_rhel; then + defaults=("--defaults-extra-file=${MYSQL_ROOT_CNF}") + fi + "$(_mysql_bin mysql)" "${defaults[@]}" --socket="$(_mysql_socket)" --user=root --batch --skip-column-names + return +} + +_mysql_query() { + local sql=$1 + _mysql_sql <<<"${sql}" + return +} + +# mysqladmin ping succeeds once the server accepts connections, even when the login itself is refused. +_mysql_wait() { + local i + for ((i = 0; i < 90; i++)); do + if "$(_mysql_bin mysqladmin)" --no-defaults --socket="$(_mysql_socket)" ping >/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: 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 + 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 +} + +# _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) + 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 + _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 + _mysql_delete "${path}" + done + secret_remove mysql-root + for path in "${LSS_SECRETS_DIR}"/mysql-user-*; do + [[ -f ${path} ]] || continue + secret_remove "${path##*/}" + done + return +} diff --git a/modules/database/pgadmin.sh b/modules/database/pgadmin.sh new file mode 100644 index 0000000..f7c37a0 --- /dev/null +++ b/modules/database/pgadmin.sh @@ -0,0 +1,278 @@ +# 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}." + # 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 + 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 +} + +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..3c32c12 --- /dev/null +++ b/modules/database/phpmyadmin.sh @@ -0,0 +1,235 @@ +# 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 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." + # 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 + 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 + # 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/modules/database/postgresql.sh b/modules/database/postgresql.sh new file mode 100644 index 0000000..0e85231 --- /dev/null +++ b/modules/database/postgresql.sh @@ -0,0 +1,525 @@ +# 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, 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=/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() { + 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." + [[ -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, _)." + [[ ! ${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 ! _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 + 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_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 + # 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}" + 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 entry user database network + cat <<'EOF' +# 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 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 [DATABASE]: runs the SQL on standard input as the postgres user over the local socket and prints the rows. +_pg_sql() { + local database=${1:-postgres} psql port + psql=$(_pg_psql) + port=$(opt_int port) + _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 database=${2:-postgres} + _pg_sql "${database}" <<<"${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 +} + +# 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 + 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 + _pg_private "${database}" + _pg_public_schema "${database}" '' + 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 + _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 powers + for entry in $(opt users); do + user=${entry%%:*} + 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 tls cert key hba + port=$(opt_int port) + before=$(files_changes_of postgresql) + _pg_cluster + unit=$(_pg_unit) + _pg_selinux_port "${port}" + tls=$(_pg_tls) + cert=${tls% *} + key=${tls#* } + # The lss files live in /etc/lss/postgresql (root-owned, readable by the postgres group); only the include line + # goes into postgresql.conf, written as the postgres user. + hba=$(_pg_hba_content) + file_write 0640 root:postgres "${PG_HBA}" <<<"${hba}" + file_write 0640 root:postgres "${PG_SETTINGS}" < '{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 path version + version=$(opt version) + unit=$(_pg_unit) + svc_disable "${unit}" + svc_dropin_remove "${unit}.service" lss-sandbox + # 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 + 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 +} diff --git a/modules/database/redis.sh b/modules/database/redis.sh new file mode 100644 index 0000000..61d9e00 --- /dev/null +++ b/modules/database/redis.sh @@ -0,0 +1,259 @@ +# 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 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}" + 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 +} diff --git a/modules/database/valkey.sh b/modules/database/valkey.sh new file mode 100644 index 0000000..9908c56 --- /dev/null +++ b/modules/database/valkey.sh @@ -0,0 +1,421 @@ +# 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 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 +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 '%s' "${VALKEY_RPM}"; 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 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}" + 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_RPM}" ;; + 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 +} 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/dotnet.sh b/modules/runtime/dotnet.sh new file mode 100644 index 0000000..2934155 --- /dev/null +++ b/modules/runtime/dotnet.sh @@ -0,0 +1,108 @@ +# 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_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}" + 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." + # 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." + 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 +} 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 +} diff --git a/modules/runtime/java.sh b/modules/runtime/java.sh new file mode 100644 index 0000000..140abc6 --- /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_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_ID} ${OS_VERSION} 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 +} 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 +} diff --git a/modules/runtime/php.sh b/modules/runtime/php.sh new file mode 100644 index 0000000..19da658 --- /dev/null +++ b/modules/runtime/php.sh @@ -0,0 +1,425 @@ +# 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_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_ID} ${OS_VERSION}." + 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_ID} ${OS_VERSION} (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 +} + +# 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' + # 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}" + 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 + 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 + _php_apparmor + [[ ${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 info 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 + # 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}" + 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}" + 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[@]}" + 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/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 +} 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/modules/security/password-policy.sh b/modules/security/password-policy.sh index 0269dac..faeeeef 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 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 +} + _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/modules/web/apache.sh b/modules/web/apache.sh new file mode 100644 index 0000000..2140dc3 --- /dev/null +++ b/modules/web/apache.sh @@ -0,0 +1,257 @@ +# 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_selinux_admin_port + 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..14626fc --- /dev/null +++ b/modules/web/caddy.sh @@ -0,0 +1,200 @@ +# 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 +# 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." + 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/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/modsecurity.sh b/modules/web/modsecurity.sh new file mode 100644 index 0000000..dc69d2e --- /dev/null +++ b/modules/web/modsecurity.sh @@ -0,0 +1,824 @@ +# 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 +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). 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 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. +MODSEC_CRS_VERSION=4.25.2 +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" +# 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. +_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 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_apache_so() { + printf '%s/apache/mod_security2.so' "${MODSEC_PREFIX}" + return +} + +_modsecurity_crs_dir() { + printf '%s/coreruleset-%s' "${MODSEC_CRS_ROOT}" "${MODSEC_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 +} + +_modsecurity_nginx_so() { + printf '%s/nginx/ngx_http_modsecurity_module.so' "${MODSEC_PREFIX}" + return +} + +_modsecurity_nginx_version() { + nginx -v 2>&1 | sed -nE 's|.*nginx/([0-9]+\.[0-9]+\.[0-9]+).*|\1|p' + return +} + +# _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 (%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 +} + +# _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 + # 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 + engine=/usr/lib/apache2/modules/mod_security2.so + else + engine=/usr/lib64/httpd/modules/mod_security2.so + fi + # 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 +} + +_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 +} + +# _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 +} + +# 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 + return 0 + fi + # The runtime libraries first, so removing the build packages afterwards keeps them. + if os_is_debian; then + 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 + 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 + 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 "${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 +# 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 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 + # 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 + 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}" + _modsecurity_nginx_headers "${src}" + 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 +} + +# 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 pkg-config 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 _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. + [[ ${OS_MAJOR} -lt 10 ]] || pkg_enable_epel + pkg_install mod_security + fi + return +} + +_modsecurity_install_nginx() { + # 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 +} + +# The Core Rule Set below MODSEC_CRS_ROOT, one directory per version; configure removes older ones. +_modsecurity_crs_install() { + local version dir staging + version=${MODSEC_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. +_modsecurity_engine_conf() { + 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 + 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 + printf 'SecRequestBodyJsonDepthLimit 512\n' + fi + printf 'SecRequestBodyLimit %s\nSecRequestBodyNoFilesLimit %s\nSecRequestBodyLimitAction Reject\n' "${bytes}" "${nofiles}" + 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" +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=${MODSEC_CRS_VERSION} tag phase + local -a methods=() + read -ra methods <<<"$(opt allowed_methods)" + printf '# Managed by lss (module modsecurity). OWASP CRS %s settings.\n' "${version}" + 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)" + 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. +_modsecurity_plugins() { + local dir=$1 name kind file entry + 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' "${MODSEC_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 + ! grep -qs 'ngx_http_modsecurity_module' "${modules}"/*.conf || + die "modsecurity: ${modules} still loads another ModSecurity module for nginx." + file_write 0644 root:root /etc/nginx/lss/modules/modsecurity.conf <"${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}")" + fi + _modsecurity_restorecon "$(dirname "${log}")" + install -d -m 0755 "${dir}" + file_write 0644 root:root "${dir}/modsecurity.conf" < <(_modsecurity_engine_conf "${server}") + file_write 0644 root:root "${dir}/crs-setup.conf" < <(_modsecurity_crs_setup) + _modsecurity_plugins "${dir}" + file_write 0644 root:root "${dir}/main.conf" < <(_modsecurity_main_conf "${dir}") + file_write 0644 root:root /etc/logrotate.d/lss-modsecurity < <(_modsecurity_logrotate "${log}") + if [[ ${server} == apache ]]; then + _modsecurity_apache_enable "${dir}" + # Under the sandbox of the apache module /var is read-only; ModSecurity 2 writes its collections here. + [[ -d $(_modsecurity_data_dir) ]] || install -d -m 0750 -o "$(web_user apache)" -g root "$(_modsecurity_data_dir)" + svc_dropin "${unit}.service" lss-modsecurity < <(printf '# Managed by lss (module modsecurity).\n[Service]\nReadWritePaths=-%s\n' "$(_modsecurity_data_dir)") + [[ ${LSS_FILE_CHANGED} == 0 ]] || restart=1 + else + _modsecurity_nginx_enable "${dir}" + fi + web_test "${server}" || die "modsecurity: the ${server} configuration does not load; see above." + _modsecurity_crs_prune + if [[ ${installed} != 0 || ${restart} == 1 ]]; then + svc_restart "${unit}" || die "modsecurity: ${unit} does not start with ModSecurity." + elif [[ $(files_changes_of modsecurity) != "${installed}" ]]; then + svc_reload "${unit}" || die "modsecurity: ${unit} does not reload." + fi + return +} + +# A harmless request must pass and one with a script tag must be blocked with 403 (or, in DetectionOnly, pass and be +# logged); both go to the ACME probe file, which every host name reaches on port 80 without a site. +mod_verify() { + local server unit log url marker status config i found=0 + server=$(_modsecurity_server) + unit=$(web_unit "${server}") + log=$(_modsecurity_audit_log "${server}") + svc_is_active "${unit}" || die "modsecurity: ${unit} is not running." + web_test "${server}" || die "modsecurity: the ${server} configuration does not load." + # Captured first: grep -q ends early, and the web server's output would hit a closed pipe (pipefail). + if [[ ${server} == apache ]]; then + # 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) + 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 + 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 +} + +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)" + if _modsecurity_apache_built; then + file_remove /etc/apache2/mods-available/security2.load + fi + 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 + # 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 + 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 new file mode 100644 index 0000000..d0c7d66 --- /dev/null +++ b/modules/web/nginx.sh @@ -0,0 +1,179 @@ +# 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 +} 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..569609b --- /dev/null +++ b/modules/web/vhost.sh @@ -0,0 +1,145 @@ +# 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 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 + 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} + 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." ;; + 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 +} + +# _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=() + 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} ]] || _vhost_drop "${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 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." + # 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." + 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 +} + +# 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 + _vhost_drop "${name}" + done + web_reload + return +} 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 diff --git a/scripts/dev/coverage.sh b/scripts/dev/coverage.sh index 8a02c6e..cfd2da8 100755 --- a/scripts/dev/coverage.sh +++ b/scripts/dev/coverage.sh @@ -16,7 +16,13 @@ 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" 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) python3 - "${report}" <<'PY' import json, sys 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()); 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 diff --git a/tests/fixtures/bin/apachectl b/tests/fixtures/bin/apachectl new file mode 100755 index 0000000..027ef3a --- /dev/null +++ b/tests/fixtures/bin/apachectl @@ -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 apachectl "$@" diff --git a/tests/fixtures/bin/caddy b/tests/fixtures/bin/caddy new file mode 100755 index 0000000..632f528 --- /dev/null +++ b/tests/fixtures/bin/caddy @@ -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 caddy "$@" diff --git a/tests/fixtures/bin/certbot b/tests/fixtures/bin/certbot new file mode 100755 index 0000000..c7d90aa --- /dev/null +++ b/tests/fixtures/bin/certbot @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# certbot stub: records the call, with the CA bundle certbot was told to trust, and exits with $STUB_EXIT_certbot. +printf 'certbot %s%s\n' "${REQUESTS_CA_BUNDLE:+REQUESTS_CA_BUNDLE=${REQUESTS_CA_BUNDLE} }" "$*" >>"${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/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/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/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/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" <&1") || { + second=$(in_target "${LSS_RUN} --yes --no-color install $* 2>&1") || { printf '%s\n' "${second}" exit 1 } @@ -100,11 +105,27 @@ 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 + 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 + sudo dmesg 2>/dev/null | grep -F 'apparmor=' | tail -n 30 + fi + echo "Diagnostics finished (status ${diagnostics})." fi if [[ ${system} == vm-* ]]; then vm_stop @@ -153,17 +174,25 @@ 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}" + # 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 - 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" @@ -180,23 +209,38 @@ 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 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 fi -for module in "$@"; do - group_start "${system}: ${module}" +# The tls module gets its certificates from Pebble. +if [[ " $* " == *[\ ,]tls[\ ,]* ]]; then + group_start "${system}: Pebble (ACME test server)" + pebble_start + group_end +fi + +for item in "$@"; do + IFS=',' read -ra set <<<"${item}" + group_start "${system}: ${set[*]}" before=$(resources_snapshot) started=${SECONDS} memory_watch_start - in_target "lss --yes --no-color install ${module}" - in_target "lss --yes --no-color verify" - resources_report "${system} ${module}" "${before}" "${started}" - install_again "${module}" + 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 - in_target "lss --yes --no-color remove ${module}" - in_target "! lss status | grep -q '^${module} '" + for ((i = ${#set[@]} - 1; i >= 0; i--)); do + in_target "${LSS_RUN} --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..6fe8cab 100644 --- a/tests/integration/settings.conf +++ b/tests/integration/settings.conf @@ -10,3 +10,23 @@ 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 + +# 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 + +# modsecurity: two application exclusions (CRS 4 plugins; built into CRS 3.3 on Ubuntu 22.04). +LSS_MODSECURITY_EXCLUSIONS=wordpress nextcloud 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 diff --git a/tests/unit/guard.bats b/tests/unit/guard.bats index 77e4208..b866dc4 100644 --- a/tests/unit/guard.bats +++ b/tests/unit/guard.bats @@ -122,3 +122,17 @@ 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" { + 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" + 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 "* ]] +} diff --git a/tests/unit/helpers.bash b/tests/unit/helpers.bash index 607c143..9d85fa5 100644 --- a/tests/unit/helpers.bash +++ b/tests/unit/helpers.bash @@ -17,14 +17,12 @@ 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 : >"${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 diff --git a/tests/unit/system.bats b/tests/unit/system.bats index 7bd7d52..10d319a 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 @@ -443,6 +452,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 @@ -573,6 +599,87 @@ 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 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='' postgres=${BATS_TEST_TMPDIR}/postgres + # shellcheck disable=SC2329 # called by the function under test + systemctl() { + printf '{ path=%s ; argv[]=%s -D /var/lib/pgsql/data ; ignore_errors=no }\n' "${postgres}" "${postgres}" + } + # shellcheck disable=SC2329 + stat() { + [[ ${*: -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 + 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 + 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" { + 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}" + # 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}" + 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 ] diff --git a/tests/unit/web.bats b/tests/unit/web.bats new file mode 100644 index 0000000..e7375f8 --- /dev/null +++ b/tests/unit/web.bats @@ -0,0 +1,1014 @@ +#!/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 + # 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() { + 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,' "$@") + 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 +} + +# 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 + [ "$(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' ] + 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" { + 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 "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 "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 "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 '/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}" + 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 ] + run _web_name_check 'shop-2' + [ "${status}" -eq 0 ] +} + +@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 "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 + 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' ] +} + +@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\n' "$*" >>"${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}."* ]] +}