diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 937bb1f..84f9fc8 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -31,7 +31,7 @@ jobs: fail-fast: false matrix: system: [debian-12, debian-13, ubuntu-22.04, ubuntu-24.04, ubuntu-26.04, rocky-9, almalinux-10] - group: [base, security, scanners, web, runtime, database, database-alt] + group: [base, security, scanners, web, runtime, database, database-alt, container] arch: [x64, arm64] include: - group: base @@ -50,6 +50,10 @@ jobs: modules: mariadb postgresql redis nginx,php,mariadb,phpmyadmin caddy,php,adminer apache,postgresql,pgadmin - group: database-alt modules: mysql valkey mongodb + # Podman runs inside a test container (its storage on a volume, not on overlayfs). Docker, Portainer and + # k3s need a kernel of their own (bridges, iptables, cgroups) and run in the VM jobs below. + - group: container + modules: podman steps: - uses: step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25 # v2.22.1 with: @@ -115,11 +119,14 @@ jobs: 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. + # Debian 13 for every change, on every distribution in the nightly and the manual runs. Docker with Portainer, + # then k3s (after Docker is removed; they conflict) on every supported system, in a clean VM because the runner + # already has Docker; minimal-secure on the newest Ubuntu release; the Kubernetes node profile with a Lynis audit. 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"}]') }} + ${{ fromJSON(format('[{0},{1}]', '{"system":"debian-12","run":"containers"},{"system":"debian-13","run":"containers"},{"system":"ubuntu-22.04","run":"containers"},{"system":"ubuntu-24.04","run":"containers"},{"system":"ubuntu-26.04","run":"containers"},{"system":"rocky-9","run":"containers"},{"system":"almalinux-10","run":"containers"},{"system":"ubuntu-26.04","run":"profile minimal-secure"},{"system":"debian-13","run":"profile k8s-node"},{"system":"almalinux-10","run":"profile k8s-node"}', + (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: @@ -137,7 +144,9 @@ jobs: sudo udevadm trigger --name-match=kvm # Kernel modules as on the runner VM, then the whole profile with a Lynis audit. minimal-secure reached a # hardening index of 87 on Debian 12 and 85 on Debian 13, 85 on Rocky Linux 9 and 84 on AlmaLinux 10 (October - # 2026). The minimums leave room for small changes between Lynis versions. + # 2026). The minimums leave room for small changes between Lynis versions. Docker and k3s cost points (IP + # forwarding, Lynis' container checks); the minimums of Ubuntu and of the profiles all and k8s-node are estimates + # until the first runs. - name: Install, verify, reinstall, remove env: SYSTEM: vm-${{ matrix.system }} @@ -145,12 +154,17 @@ jobs: run: | if [[ $RUN == kernel ]]; then tests/integration/run.sh "$SYSTEM" time swap firewall kernel-hardening mac auditd usbguard + elif [[ $RUN == containers ]]; then + tests/integration/run.sh "$SYSTEM" firewall,docker,portainer firewall,k3s else - case $SYSTEM in - vm-debian-*) minimum=82 ;; + case $SYSTEM:$RUN in + vm-debian-*:"profile minimal-secure") minimum=82 ;; + vm-rocky-*:"profile minimal-secure" | vm-almalinux-*:"profile minimal-secure") minimum=80 ;; + *:"profile minimal-secure") minimum=75 ;; + vm-rocky-*:"profile all" | vm-almalinux-*:"profile all") minimum=80 ;; # The same minimum as the host job on the Ubuntu runners. - vm-ubuntu-*) minimum=75 ;; - *) minimum=80 ;; + vm-debian-* | vm-ubuntu-*) minimum=75 ;; + *) minimum=60 ;; esac tests/integration/run.sh "$SYSTEM" --profile "${RUN#profile }" "$minimum" fi diff --git a/CHANGELOG.md b/CHANGELOG.md index daf1bf7..ff08c50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,17 @@ now or later. - 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. +- Container modules: `docker` (Docker CE from Docker's repository with a hardened daemon after CIS Docker), `podman` + (from the distribution), `portainer` (pinned image, on 127.0.0.1 or the WireGuard network, the only container with + the engine socket) and `k3s` (pinned, checksum-verified single-server Kubernetes after the k3s CIS hardening guide, + with k3s-selinux on the RHEL family). +- Container firewall: published container ports pass only where a module opened the port in the lss firewall, on ufw + and firewalld alike; `fw_trust` binds trusted networks to their interface. +- `lib/container.sh`: containers of lss run with a digest-pinned image, no new privileges, no capabilities, a + read-only root, memory and process limits and never the engine socket; host paths are checked after following + symbolic links. Compose files are checked against the same rules. +- guard scans the images of Docker and Podman daily with Trivy and reports HIGH and CRITICAL vulnerabilities. +- Profile `k8s-node` (minimal-secure plus k3s); `all` includes docker and portainer. - 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 57affc2..6bf6a08 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,10 @@ up). Peak RAM covers install and `lss verify`; "while running" is what stays res | `lynis` | 25 s | 80 MiB | 5 MiB | weekly audit | | `usbguard` | 10 s | 90 MiB | 10 MiB | usbguard-daemon | | `guard` | 5 s | 90 MiB | – | daily check | +| `docker` | not measured yet | not measured yet | not measured yet | dockerd and containerd | +| `podman` | not measured yet | not measured yet | not measured yet | – (no daemon) | +| `portainer` | not measured yet | not measured yet | not measured yet | Portainer container, up to 512 MiB | +| `k3s` | not measured yet | not measured yet | not measured yet | k3s server, about 1 GiB with Traefik | 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. @@ -152,6 +156,10 @@ 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 +LSS_DOCKER_USERNS_REMAP=true +LSS_PORTAINER_ACCESS=vpn +LSS_K3S_API_ACCESS=vpn +LSS_K3S_TRAEFIK=false ``` `--set` wins over the environment, which wins over the file, which wins over the profile, which wins over the @@ -182,8 +190,11 @@ with hardened authentication and optional databases and users (`LSS__USE **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. +**Containers:** `docker` or `podman`, `portainer` (needs one of them) and `k3s` (single-server Kubernetes; not +together with `docker`). See [Containers](#containers). + The [hardening guide](docs/hardening.md) lists what each security module changes, the CIS Benchmark areas it covers -and where lss deliberately deviates. Containers, backup, monitoring and mail follow as further module groups. +and where lss deliberately deviates. Backup, monitoring and mail follow as further module groups. ### Web sites @@ -199,11 +210,48 @@ Admin interfaces such as phpMyAdmin listen on `127.0.0.1:8090`. Reach them throu `LSS__ADMIN_PUBLIC=true` to publish port 8090 with TLS and the password from `lss secrets show web-admin-password`. +### Containers + +`docker` installs Docker CE from Docker's repository (Debian 12 and 13, Ubuntu 22.04, 24.04 and 26.04, the RHEL 9 and +10 family; `docker.io` from the distribution where Docker has no packages), `podman` the distribution's Podman. Both +run with limits for processes, logs and open files and the default seccomp profile; Docker also with `icc` off, +`live-restore`, `no-new-privileges` and a user namespace for containers (`LSS_DOCKER_USERNS_REMAP`). + +A published port (`docker run -p 8080:80`) bypasses ufw and firewalld: the kernel redirects it to the container before +their rules apply. lss therefore adds a container firewall (`lss-container-firewall.service`, an nftables table of its +own) that lets a published port through only when a module opened it in the lss firewall, with the same scope. To +publish a port of your own container, bind it to 127.0.0.1 and put a site of the web server in front of it, or open +it with `LSS_FIREWALL_ALLOW=8080/tcp`. Without a host address Docker binds published ports to `LSS_DOCKER_BIND` +(127.0.0.1). + +Containers that lss starts itself follow fixed rules: the image pinned by digest, no new privileges, no capabilities, +a read-only root file system, memory and process limits, never the engine's socket. Modules use `ctr_run` and +`ctr_compose_up` from `lib/container.sh` for that; `ctr_compose_check` checks a Compose file of your own against the +same rules. + +`portainer` is the one container with the engine's socket, so it is as powerful as root and listens only on +`127.0.0.1:9443` (`LSS_PORTAINER_ACCESS=local`, through an SSH tunnel) or on the WireGuard network (`vpn`). The admin +password is `lss secrets show portainer-admin-password`. `LSS_PORTAINER_WEB_ADMIN=true` also publishes it below +`/portainer/` on the admin port of the web server. + +`k3s` runs a single-server Kubernetes from a pinned, checksum-verified binary, hardened along the k3s CIS guide: +encrypted secrets, Pod Security `restricted` by default, an audit log, protected kernel defaults and SELinux on the +RHEL family. The API stays on 127.0.0.1 until `LSS_K3S_API_ACCESS` opens it; `kubectl` is installed next to it. The +profile `k8s-node` installs `minimal-secure` plus `k3s`: + +```bash +sudo lss install --profile k8s-node --set LSS_ADMIN_USER_NAME=alice --set LSS_ADMIN_USER_GITHUB_USER=alice-on-github +``` + +With `docker` or `podman` installed, the guard scans every image daily with Trivy and reports HIGH and CRITICAL +vulnerabilities per image. + ## Guard The `guard` module checks the server every day: it runs `lss verify`, repairs modules that fail or whose files were changed (`LSS_GUARD_AUTOFIX`), and reports failed services, full disks, pending security updates, a needed reboot, a -falling Lynis index and vulnerable packages (debsecan on Debian, `dnf updateinfo` on the RHEL family). It passes on +falling Lynis index, vulnerable packages (debsecan on Debian, `dnf updateinfo` on the RHEL family) and container +images with HIGH or CRITICAL vulnerabilities (Trivy, with `docker` or `podman`). It passes on what the daily AIDE, rkhunter and ClamAV runs found, and reports more failed SSH logins than `LSS_GUARD_LOGIN_THRESHOLD` with their main sources. Successful SSH logins and active fail2ban or CrowdSec bans are listed as notes. Findings go to a webhook (Slack, Teams, Mattermost, Discord, ntfy) or by e-mail. diff --git a/data/checksums.txt b/data/checksums.txt index 1d01722..2e3464a 100644 --- a/data/checksums.txt +++ b/data/checksums.txt @@ -77,3 +77,19 @@ crs-plugin-wordpress-config any cdffad1a5410057a06cc2ce0489c470f77c69af876d79536 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 + +# k3s v1.36.5+k3s1 (the stable channel of https://update.k3s.io on 2026-10-13): SHA-256 from sha256sum-amd64.txt and +# sha256sum-arm64.txt of the release, the same as the asset digests GitHub reports. +k3s-v1.36.5+k3s1 x86_64 d73847bcd3c5fccef0115b372e2f9a91f3032dc84bbf71518a4617565294d313 https://github.com/k3s-io/k3s/releases/download/v1.36.5%2Bk3s1/k3s +k3s-v1.36.5+k3s1 aarch64 135e34cb9e8a1cfae3cb55577789e93501efe7edc080a8b2630458a85b07721e https://github.com/k3s-io/k3s/releases/download/v1.36.5%2Bk3s1/k3s-arm64 +# k3s-selinux, the SELinux policy for k3s on the RHEL family (noarch, signed by Rancher). EL 9: the stable release +# 1.6-1 (SHA-256 of the downloaded file; GitHub reports no digest for this older release). EL 10: 1.7-4, the first +# release with an EL 10 build, published on GitHub as a testing release (rpm.rancher.io has no EL 10 repository yet); +# SHA-256 as GitHub reports it (gh api repos/k3s-io/k3s-selinux/releases/tags/v1.7.testing.4). Checked 2026-10-13. +k3s-selinux-el9 any 23d0095a766b89317d2bdbe8773825987db7221e48743417930a994ca1096793 https://github.com/k3s-io/k3s-selinux/releases/download/v1.6.stable.1/k3s-selinux-1.6-1.el9.noarch.rpm +k3s-selinux-el10 any 7ab503adacf07c3235ba543b94b6a109ec7b192596b887c200c90ab7a8bc22c5 https://github.com/k3s-io/k3s-selinux/releases/download/v1.7.testing.4/k3s-selinux-1.7-4.el10.noarch.rpm + +# Trivy 0.74.0 for the daily image scan of the guard: SHA-256 from trivy_0.74.0_checksums.txt of the release, the same +# as the asset digests GitHub reports (2026-10-13). 0.75.0 was out for less than two weeks then. +trivy-0.74.0 x86_64 2ae6fe3ee734b7fdf11335663e18c75ea12dccc76062f09f164a3b0f8be4371a https://github.com/aquasecurity/trivy/releases/download/v0.74.0/trivy_0.74.0_Linux-64bit.tar.gz +trivy-0.74.0 aarch64 b94ce1976bbf3c15b514b605ee88be7c6d94a29be2302847ff01cb794d47aad5 https://github.com/aquasecurity/trivy/releases/download/v0.74.0/trivy_0.74.0_Linux-ARM64.tar.gz diff --git a/data/keys/docker.asc b/data/keys/docker.asc new file mode 100644 index 0000000..e376a7a --- /dev/null +++ b/data/keys/docker.asc @@ -0,0 +1,90 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBFit2ioBEADhWpZ8/wvZ6hUTiXOwQHXMAlaFHcPH9hAtr4F1y2+OYdbtMuth +lqqwp028AqyY+PRfVMtSYMbjuQuu5byyKR01BbqYhuS3jtqQmljZ/bJvXqnmiVXh +38UuLa+z077PxyxQhu5BbqntTPQMfiyqEiU+BKbq2WmANUKQf+1AmZY/IruOXbnq +L4C1+gJ8vfmXQt99npCaxEjaNRVYfOS8QcixNzHUYnb6emjlANyEVlZzeqo7XKl7 +UrwV5inawTSzWNvtjEjj4nJL8NsLwscpLPQUhTQ+7BbQXAwAmeHCUTQIvvWXqw0N +cmhh4HgeQscQHYgOJjjDVfoY5MucvglbIgCqfzAHW9jxmRL4qbMZj+b1XoePEtht +ku4bIQN1X5P07fNWzlgaRL5Z4POXDDZTlIQ/El58j9kp4bnWRCJW0lya+f8ocodo +vZZ+Doi+fy4D5ZGrL4XEcIQP/Lv5uFyf+kQtl/94VFYVJOleAv8W92KdgDkhTcTD +G7c0tIkVEKNUq48b3aQ64NOZQW7fVjfoKwEZdOqPE72Pa45jrZzvUFxSpdiNk2tZ +XYukHjlxxEgBdC/J3cMMNRE1F4NCA3ApfV1Y7/hTeOnmDuDYwr9/obA8t016Yljj +q5rdkywPf4JF8mXUW5eCN1vAFHxeg9ZWemhBtQmGxXnw9M+z6hWwc6ahmwARAQAB +tCtEb2NrZXIgUmVsZWFzZSAoQ0UgZGViKSA8ZG9ja2VyQGRvY2tlci5jb20+iQI3 +BBMBCgAhBQJYrefAAhsvBQsJCAcDBRUKCQgLBRYCAwEAAh4BAheAAAoJEI2BgDwO +v82IsskP/iQZo68flDQmNvn8X5XTd6RRaUH33kXYXquT6NkHJciS7E2gTJmqvMqd +tI4mNYHCSEYxI5qrcYV5YqX9P6+Ko+vozo4nseUQLPH/ATQ4qL0Zok+1jkag3Lgk +jonyUf9bwtWxFp05HC3GMHPhhcUSexCxQLQvnFWXD2sWLKivHp2fT8QbRGeZ+d3m +6fqcd5Fu7pxsqm0EUDK5NL+nPIgYhN+auTrhgzhK1CShfGccM/wfRlei9Utz6p9P +XRKIlWnXtT4qNGZNTN0tR+NLG/6Bqd8OYBaFAUcue/w1VW6JQ2VGYZHnZu9S8LMc +FYBa5Ig9PxwGQOgq6RDKDbV+PqTQT5EFMeR1mrjckk4DQJjbxeMZbiNMG5kGECA8 +g383P3elhn03WGbEEa4MNc3Z4+7c236QI3xWJfNPdUbXRaAwhy/6rTSFbzwKB0Jm +ebwzQfwjQY6f55MiI/RqDCyuPj3r3jyVRkK86pQKBAJwFHyqj9KaKXMZjfVnowLh +9svIGfNbGHpucATqREvUHuQbNnqkCx8VVhtYkhDb9fEP2xBu5VvHbR+3nfVhMut5 +G34Ct5RS7Jt6LIfFdtcn8CaSas/l1HbiGeRgc70X/9aYx/V/CEJv0lIe8gP6uDoW +FPIZ7d6vH+Vro6xuWEGiuMaiznap2KhZmpkgfupyFmplh0s6knymuQINBFit2ioB +EADneL9S9m4vhU3blaRjVUUyJ7b/qTjcSylvCH5XUE6R2k+ckEZjfAMZPLpO+/tF +M2JIJMD4SifKuS3xck9KtZGCufGmcwiLQRzeHF7vJUKrLD5RTkNi23ydvWZgPjtx +Q+DTT1Zcn7BrQFY6FgnRoUVIxwtdw1bMY/89rsFgS5wwuMESd3Q2RYgb7EOFOpnu +w6da7WakWf4IhnF5nsNYGDVaIHzpiqCl+uTbf1epCjrOlIzkZ3Z3Yk5CM/TiFzPk +z2lLz89cpD8U+NtCsfagWWfjd2U3jDapgH+7nQnCEWpROtzaKHG6lA3pXdix5zG8 +eRc6/0IbUSWvfjKxLLPfNeCS2pCL3IeEI5nothEEYdQH6szpLog79xB9dVnJyKJb +VfxXnseoYqVrRz2VVbUI5Blwm6B40E3eGVfUQWiux54DspyVMMk41Mx7QJ3iynIa +1N4ZAqVMAEruyXTRTxc9XW0tYhDMA/1GYvz0EmFpm8LzTHA6sFVtPm/ZlNCX6P1X +zJwrv7DSQKD6GGlBQUX+OeEJ8tTkkf8QTJSPUdh8P8YxDFS5EOGAvhhpMBYD42kQ +pqXjEC+XcycTvGI7impgv9PDY1RCC1zkBjKPa120rNhv/hkVk/YhuGoajoHyy4h7 +ZQopdcMtpN2dgmhEegny9JCSwxfQmQ0zK0g7m6SHiKMwjwARAQABiQQ+BBgBCAAJ +BQJYrdoqAhsCAikJEI2BgDwOv82IwV0gBBkBCAAGBQJYrdoqAAoJEH6gqcPyc/zY +1WAP/2wJ+R0gE6qsce3rjaIz58PJmc8goKrir5hnElWhPgbq7cYIsW5qiFyLhkdp +YcMmhD9mRiPpQn6Ya2w3e3B8zfIVKipbMBnke/ytZ9M7qHmDCcjoiSmwEXN3wKYI +mD9VHONsl/CG1rU9Isw1jtB5g1YxuBA7M/m36XN6x2u+NtNMDB9P56yc4gfsZVES +KA9v+yY2/l45L8d/WUkUi0YXomn6hyBGI7JrBLq0CX37GEYP6O9rrKipfz73XfO7 +JIGzOKZlljb/D9RX/g7nRbCn+3EtH7xnk+TK/50euEKw8SMUg147sJTcpQmv6UzZ +cM4JgL0HbHVCojV4C/plELwMddALOFeYQzTif6sMRPf+3DSj8frbInjChC3yOLy0 +6br92KFom17EIj2CAcoeq7UPhi2oouYBwPxh5ytdehJkoo+sN7RIWua6P2WSmon5 +U888cSylXC0+ADFdgLX9K2zrDVYUG1vo8CX0vzxFBaHwN6Px26fhIT1/hYUHQR1z +VfNDcyQmXqkOnZvvoMfz/Q0s9BhFJ/zU6AgQbIZE/hm1spsfgvtsD1frZfygXJ9f +irP+MSAI80xHSf91qSRZOj4Pl3ZJNbq4yYxv0b1pkMqeGdjdCYhLU+LZ4wbQmpCk +SVe2prlLureigXtmZfkqevRz7FrIZiu9ky8wnCAPwC7/zmS18rgP/17bOtL4/iIz +QhxAAoAMWVrGyJivSkjhSGx1uCojsWfsTAm11P7jsruIL61ZzMUVE2aM3Pmj5G+W +9AcZ58Em+1WsVnAXdUR//bMmhyr8wL/G1YO1V3JEJTRdxsSxdYa4deGBBY/Adpsw +24jxhOJR+lsJpqIUeb999+R8euDhRHG9eFO7DRu6weatUJ6suupoDTRWtr/4yGqe +dKxV3qQhNLSnaAzqW/1nA3iUB4k7kCaKZxhdhDbClf9P37qaRW467BLCVO/coL3y +Vm50dwdrNtKpMBh3ZpbB1uJvgi9mXtyBOMJ3v8RZeDzFiG8HdCtg9RvIt/AIFoHR +H3S+U79NT6i0KPzLImDfs8T7RlpyuMc4Ufs8ggyg9v3Ae6cN3eQyxcK3w0cbBwsh +/nQNfsA6uu+9H7NhbehBMhYnpNZyrHzCmzyXkauwRAqoCbGCNykTRwsur9gS41TQ +M8ssD1jFheOJf3hODnkKU+HKjvMROl1DK7zdmLdNzA1cvtZH/nCC9KPj1z8QC47S +xx+dTZSx4ONAhwbS/LN3PoKtn8LPjY9NP9uDWI+TWYquS2U+KHDrBDlsgozDbs/O +jCxcpDzNmXpWQHEtHU7649OXHP7UeNST1mCUCH5qdank0V1iejF6/CfTFU4MfcrG +YT90qFF93M3v01BbxP+EIY2/9tiIPbrd +=0YYh +-----END PGP PUBLIC KEY BLOCK----- +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBFit5IEBEADDt86QpYKz5flnCsOyZ/fk3WwBKxfDjwHf/GIflo+4GWAXS7wJ +1PSzPsvSDATV10J44i5WQzh99q+lZvFCVRFiNhRmlmcXG+rk1QmDh3fsCCj9Q/yP +w8jn3Hx0zDtz8PIB/18ReftYJzUo34COLiHn8WiY20uGCF2pjdPgfxE+K454c4G7 +gKFqVUFYgPug2CS0quaBB5b0rpFUdzTeI5RCStd27nHCpuSDCvRYAfdv+4Y1yiVh +KKdoe3Smj+RnXeVMgDxtH9FJibZ3DK7WnMN2yeob6VqXox+FvKYJCCLkbQgQmE50 +uVK0uN71A1mQDcTRKQ2q3fFGlMTqJbbzr3LwnCBE6hV0a36t+DABtZTmz5O69xdJ +WGdBeePCnWVqtDb/BdEYz7hPKskcZBarygCCe2Xi7sZieoFZuq6ltPoCsdfEdfbO ++VBVKJnExqNZCcFUTEnbH4CldWROOzMS8BGUlkGpa59Sl1t0QcmWlw1EbkeMQNrN +spdR8lobcdNS9bpAJQqSHRZh3cAM9mA3Yq/bssUS/P2quRXLjJ9mIv3dky9C3udM ++q2unvnbNpPtIUly76FJ3s8g8sHeOnmYcKqNGqHq2Q3kMdA2eIbI0MqfOIo2+Xk0 +rNt3ctq3g+cQiorcN3rdHPsTRSAcp+NCz1QF9TwXYtH1XV24A6QMO0+CZwARAQAB +tCtEb2NrZXIgUmVsZWFzZSAoQ0UgcnBtKSA8ZG9ja2VyQGRvY2tlci5jb20+iQI3 +BBMBCgAhBQJYrep4AhsvBQsJCAcDBRUKCQgLBRYCAwEAAh4BAheAAAoJEMUv62ti +Hp816C0P/iP+1uhSa6Qq3TIc5sIFE5JHxOO6y0R97cUdAmCbEqBiJHUPNQDQaaRG +VYBm0K013Q1gcJeUJvS32gthmIvhkstw7KTodwOM8Kl11CCqZ07NPFef1b2SaJ7l +TYpyUsT9+e343ph+O4C1oUQw6flaAJe+8ATCmI/4KxfhIjD2a/Q1voR5tUIxfexC +/LZTx05gyf2mAgEWlRm/cGTStNfqDN1uoKMlV+WFuB1j2oTUuO1/dr8mL+FgZAM3 +ntWFo9gQCllNV9ahYOON2gkoZoNuPUnHsf4Bj6BQJnIXbAhMk9H2sZzwUi9bgObZ +XO8+OrP4D4B9kCAKqqaQqA+O46LzO2vhN74lm/Fy6PumHuviqDBdN+HgtRPMUuao +xnuVJSvBu9sPdgT/pR1N9u/KnfAnnLtR6g+fx4mWz+ts/riB/KRHzXd+44jGKZra +IhTMfniguMJNsyEOO0AN8Tqcl0eRBxcOArcri7xu8HFvvl+e+ILymu4buusbYEVL +GBkYP5YMmScfKn+jnDVN4mWoN1Bq2yMhMGx6PA3hOvzPNsUoYy2BwDxNZyflzuAi +g59mgJm2NXtzNbSRJbMamKpQ69mzLWGdFNsRd4aH7PT7uPAURaf7B5BVp3UyjERW +5alSGnBqsZmvlRnVH5BDUhYsWZMPRQS9rRr4iGW0l+TH+O2VJ8aQ +=0Zqq +-----END PGP PUBLIC KEY BLOCK----- diff --git a/data/repos/docker.conf b/data/repos/docker.conf new file mode 100644 index 0000000..abf43b9 --- /dev/null +++ b/data/repos/docker.conf @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# Docker CE from Docker's own repositories (https://download.docker.com). Keys fetched on 2026-10-13 from +# https://download.docker.com/linux/debian/gpg (the same file as .../ubuntu/gpg; "Docker Release (CE deb)") and +# https://download.docker.com/linux/rhel/gpg (the same file as .../centos/gpg; "Docker Release (CE rpm)"); the +# fingerprints match the ones in Docker's install guides. Checked the same day: suites for Debian 12 and 13 and Ubuntu +# 22.04, 24.04 and 26.04, RHEL 9 and 10 repositories (used for Rocky Linux and AlmaLinux too), each for x86_64 and +# arm64. Docker signs the RPM packages, not the repository metadata. +KEY=docker.asc +FINGERPRINTS=9DC858229FC7DD38854AE2D88D81803C0EBFCD88 060A61C51B558A7F742B77AAC52FEB6B621E9F35 +DEB_URL=https://download.docker.com/linux/{{id}} +DEB_SUITE={{codename}} +DEB_COMPONENTS=stable +RPM_BASEURL=https://download.docker.com/linux/rhel/{{major}}/$basearch/stable +RPM_GPGCHECK=1 +RPM_REPO_GPGCHECK=0 diff --git a/docs/architecture.md b/docs/architecture.md index 5b375b3..d1f0c5f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -18,7 +18,8 @@ lib/ template.sh {{NAME}} placeholders, without eval pkg.sh repo.sh fetch.sh packages, vendor repositories with pinned keys, downloads with pinned checksums service.sh systemd units, drop-ins and timers - firewall.sh ufw or firewalld; ports recorded per module + firewall.sh ufw or firewalld; ports and trusted networks recorded per module + container.sh hardened container runs, Compose check, container firewall, Trivy secrets.sh state.sh generated secrets; which modules are installed, in which version and mode ui.sh the interactive menu (whiptail, or plain prompts) modules// one file per module @@ -26,6 +27,7 @@ templates// configuration files that modules render data/repos data/keys vendor repositories and their public keys profiles/ module sets with settings libexec/lss-guard the daily check of the guard module +libexec/lss-container-firewall nftables rules for published container ports ``` ## A run @@ -81,6 +83,20 @@ recorded rule. Removing a module closes its ports. While the firewall module ena that switches it off again after two minutes unless lss confirms that SSH still answers, so a wrong rule cannot lock you out. +Published container ports never reach the INPUT rules of ufw or firewalld: Docker, Podman and kube-proxy redirect +them to the container address (DNAT), so the packet is forwarded. The container modules (docker, podman, k3s) +therefore install `lss-container-firewall.service`. It loads the nftables table `inet lss_containers` with a forward +chain of its own, which every forwarded packet has to pass whatever the other tables say, and which no other tool +flushes. A connection redirected to a container network passes only when its original port is listed in +`/var/lib/lss/container-ports`; lss writes that list from the `public` and `vpn` rules of every module (`vpn` keeps +its source network), and reloads the chain whenever a rule is added or removed. New connections straight to a Docker +or Podman address are dropped. Traffic the containers start themselves passes. + +`fw_trust NETWORK INTERFACE` accepts everything from a network on one interface, for networks that belong to the +server, such as the k3s pod network on `cni0`. ufw matches network and interface; firewalld puts the interface into its +trusted zone. Networks broader than /8 (IPv4) or /32 (IPv6) and networks that contain the server's own address are +refused. + ## Security of lss itself - Settings files are parsed, never sourced. Templates replace `{{NAME}}` literally, without `eval` or `sed`. diff --git a/docs/hardening.md b/docs/hardening.md index 1fd5dbd..50000b8 100644 --- a/docs/hardening.md +++ b/docs/hardening.md @@ -45,6 +45,13 @@ Docker, databases and its own sudo rules) 78. CI checks that it stays at 82 on D | `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 | +| `docker` | Docker CE from Docker's repository with pinned keys (`docker.io` where Docker builds nothing); `daemon.json`: no traffic between containers on the default bridge (`icc` off), published ports on 127.0.0.1 unless a container names an address, `live-restore`, `userland-proxy` off, `no-new-privileges`, the local log driver with size limits, `nofile` limits, user namespace remapping, SELinux labels on the RHEL family, the default seccomp profile; the API only on its Unix socket (mode 660, root); published ports behind the container firewall | CIS Docker, sections 2 (daemon) and 3 (socket) | +| `podman` | Podman from the distribution, no daemon; process limit, log size limit and `nofile` limits for every container; seccomp checked, SELinux labels checked when SELinux enforces; published ports behind the container firewall | CIS Docker, sections 2 and 5, applied to Podman | +| Containers of lss (`lib/container.sh`) | Image pinned by digest, `no-new-privileges`, all capabilities dropped (the dangerous ones never added), read-only root with a small `/tmp`, memory and process limits, never the host's network, PID or IPC namespace, published ports with an address, never the engine socket (Portainer is the one exception) or system paths, also through symbolic links; Compose files are checked against the same rules | CIS Docker, section 5 (container runtime) | +| Container firewall | Published container ports (Docker, Podman, Kubernetes NodePorts and hostPorts) are forwarded past the INPUT rules of ufw and firewalld; an own nftables chain lets a redirected connection through only when a module opened that port with the same scope, and drops new connections straight to container addresses | CIS Docker, section 5 (published ports); host firewall | +| `portainer` | Pinned image digest, on 127.0.0.1:9443 or the WireGuard network only, the admin password from the lss secrets (a container sees only its own directory below `/etc/lss/containers`), data on a named volume, read-only root, no capabilities; optionally below `/portainer/` on the admin port | (operations; deliberate socket exception, see below) | +| `k3s` | Pinned, checksum-verified binary; secrets encrypted at rest, kubeconfig 0600, `protect-kernel-defaults` with the kernel values the kubelet expects, Pod Security admission (`restricted` everywhere except `kube-system`), an audit log (metadata, 30 days), idle streaming connections closed after 5 minutes, finished pods collected; k3s-selinux on the RHEL family; the API on 127.0.0.1 unless opened; pod traffic to the host only from the pod network on its bridge | CIS Kubernetes Benchmark as applied by the k3s CIS Hardening Guide (control plane, kubelet, Pod Security) | +| `guard` with a container engine | Trivy scans every image daily and reports HIGH and CRITICAL vulnerabilities per image | CIS Docker, section 4 (images) | | `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 @@ -61,6 +68,12 @@ These settings differ from a strict reading of the benchmarks. Each one has a se | USB device allow-list | opt-in | Virtual servers have no USB ports; on hardware it blocks keyboards plugged in later. | install `usbguard` | | Password expiry | 365 days | NIST SP 800-63B advises against forced rotation; logins use SSH keys, so the password is only for the console and sudo. | `LSS_PASSWORD_POLICY_MAX_DAYS` | | Automatic reboot | on, 04:00 | Kernel and libc fixes only take effect after a reboot; a server that never reboots stays vulnerable. | `LSS_AUTO_UPDATES_REBOOT=false` | +| Docker restart policy | `unless-stopped` for containers of lss | CIS Docker suggests `on-failure` with at most 5 restarts; a management container that stops after five crashes leaves the server unmanaged. | – | +| Engine socket in Portainer | mounted | CIS Docker forbids mounting it into containers; Portainer cannot manage the engine without it. It is the only container that gets it, it runs on 127.0.0.1 or the WireGuard network only, and it runs in the host's user namespace and, on the RHEL family, without SELinux separation to reach the socket. | do not install `portainer` | +| Docker user namespace remapping | on | Images that expect to own host paths as root need it off. | `LSS_DOCKER_USERNS_REMAP=false` | +| Docker and k3s together | not possible | Each brings its own containerd and claims the forwarding rules; `profiles/all.conf` has Docker, `profiles/k8s-node.conf` has k3s. | – | +| Kubernetes API | 127.0.0.1 | kubectl through an SSH tunnel; a cluster with more nodes or remote tooling needs `vpn` or `public`. | `LSS_K3S_API_ACCESS` | +| Traefik in k3s | on | The bundled ingress controller serves ports 80 and 443 for the cluster; without it you bring your own. | `LSS_K3S_TRAEFIK=false` | ## What lss does not do @@ -71,5 +84,11 @@ Some benchmark items cannot be applied safely to a running server, so lss report hoster; Lynis lists the missing ones. - **A boot loader password.** It stops an unattended reboot of a remote server at the boot menu. - **Disk encryption.** Like partitions, it is set up when the system is installed. +- **Docker's auditd rules** (CIS Docker, section 1). The `auditd` module watches identity files, sudo and the + kernel; rules for every Docker file are not part of it yet. +- **Docker content trust and image signatures** (CIS Docker, section 4). lss pins its own images by digest; images you run + yourself are your choice. The daily Trivy scan reports their known vulnerabilities. +- **Kubernetes workloads.** k3s gets a hardened control plane and Pod Security admission; network policies, RBAC for + your users and the images of your pods are part of what you deploy. - **Removing packages of the distribution** that a benchmark calls unnecessary. lss installs only what its modules need and leaves the rest to the image you started from. diff --git a/docs/module-guide.md b/docs/module-guide.md index fe64d0c..009ec8a 100644 --- a/docs/module-guide.md +++ b/docs/module-guide.md @@ -99,6 +99,19 @@ All of `lib/` is loaded in every step. The ones modules use most: - `fw_allow 443/tcp 51820/udp:vpn` opens ports for the module. The scope after the colon is `public` (default), `vpn` (only from the VPN network) or `local` (nothing is opened). lss records the rule for the module, applies it when the firewall module is installed (also later), and closes it again when the module is removed. +- `fw_trust NETWORK INTERFACE` accepts all traffic from NETWORK arriving on INTERFACE, recorded and removed like a + port. Only for networks of the server itself (a pod network on its bridge). + +### Containers + +- `ctr_run NAME IMAGE [OPTION...] [-- COMMAND...]` runs a container with the engine of the server (docker or podman) + under the rules of `lib/container.sh`: the image as `registry/name@sha256:...`, `--publish ADDRESS:PORT:PORT` + with an address, `--volume` with named volumes or host paths outside system directories (a container may mount its + own `/etc/lss/containers/NAME`), `--cap` for single capabilities, `--memory` and `--pids` for other limits, + `--writable` only when the image cannot run with a read-only root. A container with the same definition is left + alone. `ctr_remove NAME` deletes it; named volumes stay. +- `ctr_compose_check FILE` and `ctr_compose_up PROJECT FILE` do the same for Compose files (Docker only). +- `ctr_firewall_setup` is for engine modules only; published ports are opened with `fw_allow` like any other port. ### Secrets, system and logging diff --git a/lib/container.sh b/lib/container.sh new file mode 100644 index 0000000..b6cc622 --- /dev/null +++ b/lib/container.sh @@ -0,0 +1,575 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# Containers: the engine of the server (docker or podman), hardened container runs for modules that ship a service as +# a container, a check for Compose files, the container firewall and the Trivy scanner. +# +# Every container lss starts follows the same rules (CIS Docker Benchmark, section 5): +# - the image names its registry and is pinned by digest (registry/name@sha256:...), so a moved tag cannot change +# what runs +# - no new privileges, every capability dropped except the ones named, never --privileged, never the network, PID +# or IPC namespace of the host +# - a read-only root file system with a small /tmp, unless the container needs a writable one +# - memory and process limits +# - published ports name their address; the container firewall decides who reaches them (lib/firewall.sh) +# - the socket of a container engine is never mounted, except into the management tool (CTR_SOCKET_CONTAINERS) + +# Containers that may mount the socket of the engine. Whoever controls that socket controls the host; Portainer is a +# management tool and needs it, nothing else does. +CTR_SOCKET_CONTAINERS="lss-portainer" +# Capabilities no container of lss gets, whatever a module asks for: each one hands over the kernel or other processes. +CTR_DENIED_CAPS="SYS_ADMIN SYS_MODULE SYS_RAWIO SYS_PTRACE SYS_BOOT SYS_TIME DAC_READ_SEARCH MAC_ADMIN MAC_OVERRIDE BPF PERFMON NET_ADMIN" +CTR_DEFAULT_MEMORY=512m +CTR_DEFAULT_PIDS=256 +CTR_TMPFS_OPTIONS=rw,noexec,nosuid,nodev,size=64m +CTR_FIREWALL_SCRIPT=/usr/local/lib/lss/container-firewall +CTR_MODULES="docker podman k3s" +# Trivy scans the images of the server every day (libexec/lss-guard). Pinned release, checksum in data/checksums.txt. +CTR_TRIVY=/usr/local/bin/trivy +CTR_TRIVY_VERSION=0.74.0 +CTR_TRIVY_CACHE=/var/cache/lss-trivy + +# ctr_engine: docker or podman, whichever is installed (or being installed); fails when there is none. +ctr_engine() { + local engine + for engine in docker podman; do + if [[ ${LSS_MODULE:-} == "${engine}" ]] || state_installed "${engine}"; then + printf '%s' "${engine}" + return 0 + fi + done + return 1 +} + +# ctr_socket ENGINE: the API socket of the engine on the host. +ctr_socket() { + local engine=$1 + case ${engine} in + docker) printf '/var/run/docker.sock' ;; + podman) printf '/run/podman/podman.sock' ;; + *) die "container: unknown engine '${engine}'." ;; + esac + return +} + +# _ctr_engine_path PATH: PATH is the socket of a container engine or a directory that holds one. +_ctr_engine_path() { + local path=$1 + path=${path%/} + case ${path} in + '' | /run | /var | /var/run | /var/lib) return 0 ;; + /run/docker* | /var/run/docker* | /run/podman* | /var/run/podman* | /run/containerd* | /var/run/containerd*) return 0 ;; + /run/crio* | /var/run/crio* | /run/k3s* | /var/run/k3s* | /var/lib/docker* | /var/lib/containers*) return 0 ;; + /var/lib/containerd* | /var/lib/rancher*) return 0 ;; + */docker.sock | */podman.sock | */containerd.sock) return 0 ;; + *) return 1 ;; + esac +} + +# Host files a container may mount from /etc: only its own directory /etc/lss/containers/NAME (configuration and +# secrets lss hands to that one container). +CTR_CONFIG_DIR=/etc/lss/containers + +# _ctr_host_path_denied NAME PATH: system directories, credentials, the state and logs of lss and the sockets of +# systemd and D-Bus, which no container gets. All of /etc is denied except the directory of container NAME. +_ctr_host_path_denied() { + local name=$1 path=$2 + path=${path%/} + if [[ ${path} == "${CTR_CONFIG_DIR}/${name}" || ${path} == "${CTR_CONFIG_DIR}/${name}/"* ]]; then + return 1 + fi + case ${path} in + '' | /etc | /etc/* | /usr | /bin | /sbin | /lib | /lib64 | /home | /root | /boot | /proc | /sys | /dev) return 0 ;; + /root/* | /boot/* | /proc/* | /sys/* | /dev/*) return 0 ;; + /var/lib/lss | /var/lib/lss/* | /var/lib/lss-* | /var/log/lss | /var/log/lss/* | /var/log/lss-*) return 0 ;; + /run/systemd | /run/systemd/* | /var/run/systemd | /var/run/systemd/*) return 0 ;; + /run/dbus | /run/dbus/* | /var/run/dbus | /var/run/dbus/*) return 0 ;; + *) return 1 ;; + esac +} + +# _ctr_host_source_problem NAME SOURCE: prints why the host path SOURCE may not be mounted into container NAME, or +# nothing. Both the path as written and the path it resolves to (symbolic links followed) are checked, so a link to a +# socket or a system directory does not slip through. Fails when the path cannot be resolved. +_ctr_host_source_problem() { + local name=$1 source=$2 resolved path + if [[ ! ${source} =~ ^/[A-Za-z0-9._/-]*$ || /${source}/ == */../* ]]; then + printf '%s is not a valid host path' "${source}" + return 0 + fi + resolved=$(realpath -m -- "${source}") || return 1 + [[ ${resolved} == /* ]] || return 1 + for path in "${source}" "${resolved}"; do + if _ctr_engine_path "${path}"; then + printf 'mounting %s hands over the container engine; only %s may have its socket' "${source}" "${CTR_SOCKET_CONTAINERS}" + return 0 + fi + if _ctr_host_path_denied "${name}" "${path}"; then + printf '%s may not be mounted into a container' "${source}" + [[ ${path} == "${source}" ]] || printf ' (it leads to %s)' "${path}" + return 0 + fi + done + return 0 +} + +_ctr_check_publish() { + local name=$1 spec=$2 + [[ ${spec} =~ ^([0-9]{1,3}(\.[0-9]{1,3}){3}|\[[0-9A-Fa-f:]+\]):[0-9]{1,5}:[0-9]{1,5}(/(tcp|udp))?$ ]] || + die "container ${name}: publish '${spec}' must be ADDRESS:HOST_PORT:CONTAINER_PORT[/tcp|udp], for example 127.0.0.1:9443:9443." + return +} + +_ctr_check_volume() { + local name=$1 spec=$2 source target rest options='' problem + [[ ${spec} == *:* ]] || die "container ${name}: volume '${spec}' must be SOURCE:TARGET[:OPTIONS]." + source=${spec%%:*} + rest=${spec#*:} + target=${rest%%:*} + if [[ ${rest} == *:* ]]; then + options=${rest#*:} + fi + [[ ${target} =~ ^/[A-Za-z0-9._/-]*$ && /${target}/ != */../* ]] || + die "container ${name}: '${target}' is not a valid path inside the container." + [[ -z ${options} || ${options} =~ ^(ro|rw|z|Z)(,(ro|rw|z|Z))*$ ]] || + die "container ${name}: volume options '${options}' (allowed: ro, rw, z, Z)." + if [[ ${source} != /* ]]; then + [[ ${source} =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]] || die "container ${name}: '${source}' is not a volume name." + return 0 + fi + problem=$(_ctr_host_source_problem "${name}" "${source}") || die "container ${name}: cannot resolve the host path ${source}." + [[ -z ${problem} ]] || die "container ${name}: ${problem}." + return +} + +_ctr_check_cap() { + local name=$1 cap=$2 + cap=${cap#CAP_} + [[ ${cap} =~ ^[A-Z_]+$ ]] || die "container ${name}: '${cap}' is not a capability." + [[ " ${CTR_DENIED_CAPS} " != *" ${cap} "* ]] || die "container ${name}: the capability ${cap} is not allowed." + return +} + +# ctr_args ENGINE NAME IMAGE [OPTION...] [-- COMMAND...]: checks a container against the rules above and sets CTR_ARGS +# to the arguments of "ENGINE run" or "ENGINE create" that enforce them (options, image, command). Options: +# --publish ADDRESS:HOST_PORT:CONTAINER_PORT[/PROTO] --volume SOURCE:TARGET[:ro] --tmpfs PATH +# --cap CAPABILITY --env KEY=VALUE --env-file FILE --user UID[:GID] --network NAME +# --memory SIZE (default 512m) --pids N (default 256) --writable (no read-only root) +# --socket (the engine's socket as /var/run/docker.sock; only for CTR_SOCKET_CONTAINERS) +ctr_args() { + local engine=$1 name=$2 image=$3 option value registry memory=${CTR_DEFAULT_MEMORY} pids=${CTR_DEFAULT_PIDS} + local writable=0 socket=0 tmp=0 + local -a args=() command=() volumes=() + shift 3 + [[ ${engine} == docker || ${engine} == podman ]] || die "container: unknown engine '${engine}'." + [[ ${name} =~ ^[a-z0-9][a-z0-9_.-]*$ ]] || die "container: '${name}' is not a valid container name." + registry=${image%%/*} + [[ ${image} =~ ^[a-z0-9.:-]+/[a-z0-9._/-]+@sha256:[0-9a-f]{64}$ && (${registry} == *.* || ${registry} == localhost*) ]] || + die "container ${name}: the image '${image}' must name its registry and be pinned by digest (registry/name@sha256:...)." + args=(--name "${name}" --label lss.managed=true --security-opt no-new-privileges --cap-drop ALL) + while [[ $# -gt 0 ]]; do + option=$1 + shift + case ${option} in + --) + command=("$@") + break + ;; + --writable) + writable=1 + continue + ;; + --socket) + socket=1 + continue + ;; + *) ;; + esac + [[ $# -gt 0 ]] || die "container ${name}: ${option} needs a value." + value=$1 + shift + case ${option} in + --publish) + _ctr_check_publish "${name}" "${value}" + args+=(--publish "${value}") + ;; + --volume) + _ctr_check_volume "${name}" "${value}" + args+=(--volume "${value}") + if [[ ${value} != /* ]]; then + volumes+=("${value%%:*}") + fi + ;; + --tmpfs) + [[ ${value} =~ ^/[A-Za-z0-9._/-]+$ ]] || die "container ${name}: '${value}' is not a valid tmpfs path." + [[ ${value} != /tmp ]] || tmp=1 + args+=(--tmpfs "${value}:${CTR_TMPFS_OPTIONS}") + ;; + --cap) + _ctr_check_cap "${name}" "${value}" + args+=(--cap-add "${value#CAP_}") + ;; + --env) + [[ ${value} =~ ^[A-Za-z_][A-Za-z0-9_]*= ]] || die "container ${name}: '${value%%=*}' is not KEY=VALUE." + args+=(--env "${value}") + ;; + --env-file) + [[ ${value} == /* ]] || die "container ${name}: the environment file '${value}' needs an absolute path." + args+=(--env-file "${value}") + ;; + --user) + [[ ${value} =~ ^[0-9]+(:[0-9]+)?$ ]] || die "container ${name}: --user takes UID or UID:GID, got '${value}'." + args+=(--user "${value}") + ;; + --network) + [[ ${value} =~ ^[a-z0-9][a-z0-9_.-]*$ && ${value} != host ]] || + die "container ${name}: the network '${value}' is not allowed (never the host network)." + args+=(--network "${value}") + ;; + --memory) + [[ ${value} =~ ^[0-9]+[kmg]$ ]] || die "container ${name}: --memory takes a size such as 256m or 1g, got '${value}'." + memory=${value} + ;; + --pids) + [[ ${value} =~ ^[1-9][0-9]*$ ]] || die "container ${name}: --pids takes a positive number, got '${value}'." + pids=${value} + ;; + *) die "container ${name}: unknown option '${option}'." ;; + esac + done + if [[ ${socket} == 1 ]]; then + [[ " ${CTR_SOCKET_CONTAINERS} " == *" ${name} "* ]] || + die "container ${name}: only ${CTR_SOCKET_CONTAINERS} may have the socket of the container engine." + # The engine's API is root on the host, so the container runs in the host's user namespace to reach it; on the + # RHEL family SELinux separation is lifted for it, as the socket carries the label of the engine. + args+=(--volume "$(ctr_socket "${engine}"):/var/run/docker.sock" --userns host) + if os_is_rhel; then + args+=(--security-opt label=disable) + fi + fi + if [[ ${writable} == 0 ]]; then + args+=(--read-only) + if [[ ${tmp} == 0 ]]; then + args+=(--tmpfs "/tmp:${CTR_TMPFS_OPTIONS}") + fi + fi + args+=(--memory "${memory}" --memory-swap "${memory}" --pids-limit "${pids}") + if [[ ${engine} == docker ]]; then + # Back after a reboot or a crash; Podman containers are started by their systemd unit instead. + args+=(--restart unless-stopped) + fi + CTR_ARGS=("${args[@]}" "${image}" "${command[@]}") + # Named volumes of a container in the host user namespace (see _ctr_root_volumes). + CTR_ROOT_VOLUMES=() + if [[ ${socket} == 1 ]]; then + CTR_ROOT_VOLUMES=("${volumes[@]}") + fi + return +} + +# _ctr_root_volumes ENGINE: hands the volumes in CTR_ROOT_VOLUMES to root. With Docker's user namespace remapping a +# new volume belongs to the remapped root, but a container in the host user namespace runs as the real root, which +# without capabilities may not write there. +_ctr_root_volumes() { + local engine=$1 volume path owner=${CTR_VOLUME_OWNER:-0:0} + for volume in "${CTR_ROOT_VOLUMES[@]}"; do + if ! "${engine}" volume inspect "${volume}" >/dev/null 2>&1; then + log_command "${engine}" volume create "${volume}" || die "container: ${engine} cannot create the volume ${volume}." + files_note_change "created the volume ${volume}" + fi + path=$("${engine}" volume inspect --format '{{.Mountpoint}}' "${volume}") || + die "container: ${engine} cannot inspect the volume ${volume}." + [[ -d ${path} ]] || die "container: the volume ${volume} has no directory (${path:-none})." + if [[ $(stat -c "%u:%g %a" "${path}") != "${owner} 700" ]]; then + chown -R "${owner}" -- "${path}" + chmod 0700 -- "${path}" + files_note_change "gave the volume ${volume} to root" + fi + done + return +} + +_ctr_unit() { + local name=$1 + printf 'lss-ctr-%s.service' "${name}" + return +} + +# ctr_running NAME: the container exists and runs. +ctr_running() { + local name=$1 engine + engine=$(ctr_engine) || return 1 + [[ $("${engine}" container inspect --format '{{.State.Running}}' "${name}" 2>/dev/null) == true ]] + return +} + +_ctr_delete() { + local engine=$1 name=$2 + "${engine}" container inspect "${name}" >/dev/null 2>&1 || return 0 + if [[ ${engine} == podman ]]; then + systemctl stop "$(_ctr_unit "${name}")" >/dev/null 2>&1 || true + fi + log_command "${engine}" rm --force "${name}" || die "container ${name}: ${engine} cannot remove the old container." + return +} + +# Podman has no daemon that restarts containers; a systemd unit of lss starts and stops each one. +_ctr_podman_unit() { + local name=$1 podman + podman=$(command -v podman) + svc_unit "$(_ctr_unit "${name}")" </dev/null || true) + if [[ ${current} != "${hash}" ]]; then + log_info "Pulling ${image}" + log_command "${engine}" pull "${image}" || die "container ${name}: ${engine} cannot pull ${image}." + _ctr_delete "${engine}" "${name}" + if [[ ${engine} == docker ]]; then + log_command docker run --detach --label "lss.hash=${hash}" "${CTR_ARGS[@]}" || + die "container ${name}: docker cannot start it; see the log." + else + log_command podman create --label "lss.hash=${hash}" "${CTR_ARGS[@]}" || + die "container ${name}: podman cannot create it; see the log." + fi + files_note_change "container ${name}" + fi + if [[ ${engine} == podman ]]; then + _ctr_podman_unit "${name}" + if [[ ${current} != "${hash}" ]] && svc_is_active "${unit}"; then + svc_restart "${unit}" || die "container ${name}: ${unit} does not start." + fi + svc_enable "${unit}" + elif ! ctr_running "${name}"; then + log_command docker start "${name}" || die "container ${name}: docker cannot start it; see the log." + files_note_change "started container ${name}" + fi + return +} + +# ctr_remove NAME: stops and deletes the container (its named volumes stay). +ctr_remove() { + local name=$1 engine unit + unit=$(_ctr_unit "${name}") + if [[ -f /etc/systemd/system/${unit} ]]; then + svc_disable "${unit}" + file_remove "/etc/systemd/system/${unit}" + svc_daemon_reload + fi + engine=$(ctr_engine) || return 0 + if "${engine}" container inspect "${name}" >/dev/null 2>&1; then + _ctr_delete "${engine}" "${name}" + files_note_change "removed container ${name}" + fi + return +} + +# The rules of ctr_args for Compose files, as a jq program over the output of "docker compose config --format json": +# one line "SERVICE: problem" per violation. $denied: CTR_DENIED_CAPS. Host paths of bind mounts are checked by the +# shell afterwards (CTR_COMPOSE_BINDS), because only realpath can follow their symbolic links. +# shellcheck disable=SC2016 # jq variables, not shell variables +CTR_COMPOSE_RULES=' +($denied | split(" ")) as $deny | +.services // {} | to_entries[] | .key as $name | .value as $s | +( + (if (($s.image // "") | test("^[a-z0-9.:-]+/[a-z0-9._/-]+@sha256:[0-9a-f]{64}$")) then empty + else "the image is not pinned by digest (registry/name@sha256:...)" end), + (if $s.privileged == true then "runs privileged" else empty end), + (if any($s.cap_drop[]?; . == "ALL") then empty else "keeps capabilities (cap_drop: [ALL])" end), + ($s.cap_add[]? | ascii_upcase | ltrimstr("CAP_") | select(. == "ALL" or (. as $c | $deny | index($c))) + | "adds the capability \(.)"), + (if any($s.security_opt[]?; test("^no-new-privileges([:=]true)?$")) then empty + else "may gain new privileges (security_opt: [no-new-privileges:true])" end), + (if any($s.security_opt[]?; test("unconfined|^label[:=]disable|^no-new-privileges[:=]false")) + then "lifts a security profile (security_opt)" else empty end), + (if ($s.devices // []) | length > 0 then "has host devices (devices)" else empty end), + (if $s.read_only == true or ($s.labels["lss.writable"]? // "") == "true" then empty + else "has a writable root file system (read_only: true, or the label lss.writable: \"true\")" end), + (if ($s.mem_limit // $s.deploy.resources.limits.memory // null) == null then "has no memory limit (mem_limit)" else empty end), + (if ($s.pids_limit // $s.deploy.resources.limits.pids // null) == null then "has no process limit (pids_limit)" else empty end), + (if ([$s.network_mode, $s.pid, $s.ipc, $s.uts, $s.userns_mode, $s.cgroup] | any(. == "host")) + or (($s.network_mode // "") | startswith("container:")) or (($s.pid // "") | startswith("container:")) + then "shares a namespace of the host or another container" else empty end), + (if any($s.ports[]?; (.host_ip // "") == "") then "publishes a port without an address (host_ip)" else empty end) +) | "\($name): \(.)"' +# "SERVICESOURCE" for every bind mount. +# shellcheck disable=SC2016 # jq variables, not shell variables +CTR_COMPOSE_BINDS='.services // {} | to_entries[] | .key as $name | .value.volumes[]? | select(.type == "bind") | "\($name)\t\(.source // "")"' + +# ctr_compose_check FILE: checks every service of a Compose file against the container rules. docker compose resolves +# the file first (extends, anchors, defaults), so the checks see what Compose would run. Prints each violation and fails +# when there is one; a file it cannot read or check fails as well. +ctr_compose_check() { + local file=$1 json problems binds service source problem line + [[ -r ${file} ]] || die "container: cannot read the Compose file ${file}." + json=$(docker compose -f "${file}" config --format json 2>/dev/null) || + die "container: docker compose cannot read ${file} (docker compose -f ${file} config shows why)." + problems=$(jq -r --arg denied "${CTR_DENIED_CAPS}" "${CTR_COMPOSE_RULES}" <<<"${json}") || + die "container: jq cannot check ${file}." + binds=$(jq -r "${CTR_COMPOSE_BINDS}" <<<"${json}") || die "container: jq cannot read the mounts of ${file}." + while IFS=$'\t' read -r service source; do + [[ -n ${service} ]] || continue + problem=$(_ctr_host_source_problem "compose-${service}" "${source}") || + die "container: cannot resolve the host path ${source} of ${service} in ${file}." + [[ -z ${problem} ]] || problems+=$'\n'"${service}: ${problem}" + done <<<"${binds}" + problems=$(sed '/^$/d' <<<"${problems}") + [[ -n ${problems} ]] || return 0 + while IFS= read -r line; do + log_error "${file}: ${line}" + done <<<"${problems}" + return 1 +} + +# ctr_compose_up PROJECT FILE: starts a Compose project after ctr_compose_check passed (Docker only). +ctr_compose_up() { + local project=$1 file=$2 + [[ $(ctr_engine) == docker ]] || die "container: Compose projects need the docker module." + [[ ${project} =~ ^[a-z0-9][a-z0-9_-]*$ ]] || die "container: '${project}' is not a valid Compose project name." + ctr_compose_check "${file}" || die "container: ${file} breaks the container rules; see above." + log_command docker compose --project-name "${project}" -f "${file}" up --detach --remove-orphans --quiet-pull || + die "container: docker compose cannot start ${project}; see the log." + return +} + +# ctr_firewall_setup: installs the container firewall (libexec/lss-container-firewall). docker, podman and k3s share it +# and write the same files, so the managed copies never differ. +ctr_firewall_setup() { + local list changed=0 unit=${FW_CONTAINER_UNIT}.service + pkg_install nftables + install -d -m 0755 "$(dirname "${CTR_FIREWALL_SCRIPT}")" + file_write 0755 root:root "${CTR_FIREWALL_SCRIPT}" <"${LSS_ROOT}/libexec/lss-container-firewall" + [[ ${LSS_FILE_CHANGED} == 0 ]] || changed=1 + svc_unit "${unit}" <"${list}" + fi + fw_container_sync + svc_enable "${unit}" + if [[ ${changed} == 1 ]]; then + svc_restart "${unit}" || die "${LSS_MODULE}: the container firewall does not start; see journalctl -u ${unit}." + fi + return +} + +# ctr_firewall_verify: the container firewall runs and drops what no module opened. +ctr_firewall_verify() { + local ruleset proto port _source + svc_is_active "${FW_CONTAINER_UNIT}.service" || + die "${LSS_MODULE}: the container firewall ${FW_CONTAINER_UNIT} is not active." + ruleset=$(nft list table inet lss_containers 2>/dev/null) || + die "${LSS_MODULE}: the nftables table inet lss_containers of the container firewall is missing." + grep -q 'oifname "docker0" ct status dnat jump published' <<<"${ruleset}" || + die "${LSS_MODULE}: the container firewall does not check redirected connections to containers." + grep -q 'oifname "docker0" ct state new drop' <<<"${ruleset}" || + die "${LSS_MODULE}: the container firewall lets direct connections to containers through." + [[ $(fw_container_ports) == "$(<"$(fw_container_list)")" ]] || + die "${LSS_MODULE}: the container firewall lists other ports than the firewall rules; run lss configure ${LSS_MODULE}." + while read -r proto port _source; do + [[ -n ${proto} ]] || continue + grep -q "meta l4proto ${proto} ct original proto-dst ${port} accept" <<<"${ruleset}" || + die "${LSS_MODULE}: the container firewall does not let ${port}/${proto} through, which a module opened." + done <"$(fw_container_list)" + return +} + +# ctr_firewall_remove: removes the container firewall once no other container module needs it. +ctr_firewall_remove() { + local module unit=${FW_CONTAINER_UNIT}.service + for module in ${CTR_MODULES}; do + [[ ${module} != "${LSS_MODULE:-}" ]] || continue + if state_installed "${module}"; then + return 0 + fi + done + svc_disable "${unit}" + file_remove "/etc/systemd/system/${unit}" + svc_daemon_reload + file_remove "${CTR_FIREWALL_SCRIPT}" + rm -f -- "$(fw_container_list)" + return +} + +# ctr_trivy_install: Trivy for the daily image scan of the guard, from the pinned, checksum-verified release. +ctr_trivy_install() { + local tmp current='' + if [[ -x ${CTR_TRIVY} ]]; then + current=$("${CTR_TRIVY}" --version 2>/dev/null | awk '$1 == "Version:" && !found { print $2; found = 1 }') || true + fi + [[ ${current} != "${CTR_TRIVY_VERSION}" ]] || return 0 + tmp=$(mktemp -d) + fetch_verified "trivy-${CTR_TRIVY_VERSION}" "${tmp}/trivy.tar.gz" + tar -xzf "${tmp}/trivy.tar.gz" -C "${tmp}" trivy || die "container: the Trivy archive has no trivy binary." + install -m 0755 -o root -g root "${tmp}/trivy" "${CTR_TRIVY}" + rm -rf -- "${tmp}" + install -d -m 0700 "${CTR_TRIVY_CACHE}" + files_note_change "installed Trivy ${CTR_TRIVY_VERSION}" + return +} + +# ctr_trivy_remove: removes Trivy once neither docker nor podman stays installed. +ctr_trivy_remove() { + local engine + for engine in docker podman; do + [[ ${engine} != "${LSS_MODULE:-}" ]] || continue + if state_installed "${engine}"; then + return 0 + fi + done + if [[ -e ${CTR_TRIVY} ]]; then + rm -f -- "${CTR_TRIVY}" + files_note_change "removed Trivy" + fi + rm -rf -- "${CTR_TRIVY_CACHE}" + return +} diff --git a/lib/firewall.sh b/lib/firewall.sh index 362e769..80098c0 100644 --- a/lib/firewall.sh +++ b/lib/firewall.sh @@ -9,6 +9,11 @@ # public open to everyone # vpn open to the WireGuard network only (LSS_WIREGUARD_NETWORK, default 10.66.0.0/24) # local no rule; the service listens on localhost only +# +# Published container ports (Docker, Podman, Kubernetes NodePorts and hostPorts) are redirected to the container +# before the INPUT rules of ufw or firewalld see them. The container modules therefore install the container firewall +# (libexec/lss-container-firewall), which lets such a port through only when a module recorded it here; see +# fw_container_ports. fw_backend() { if os_is_debian; then printf 'ufw'; else printf 'firewalld'; fi @@ -109,7 +114,7 @@ fw_available() { # Applies the rules every module recorded (used by the firewall module after installing the backend). fw_apply_all() { - local registry spec module + local registry spec module network interface for registry in "${LSS_STATE_DIR:-/var/lib/lss}"/firewall/*; do [[ -f ${registry} ]] || continue module=${registry##*/} @@ -119,6 +124,14 @@ fw_apply_all() { LSS_MODULE=${module} _fw_apply add done <"${registry}" done + for registry in "${LSS_STATE_DIR:-/var/lib/lss}"/firewall-trusted/*; do + [[ -f ${registry} ]] || continue + module=${registry##*/} + while read -r network interface; do + [[ -n ${network} && -n ${interface} ]] || continue + LSS_MODULE=${module} _fw_trust_apply add "${network}" "${interface}" + done <"${registry}" + done return } @@ -138,12 +151,14 @@ fw_allow() { printf '%s\n' "${spec}" >>"${registry}" files_note_change "firewall allow ${spec}" done + fw_container_sync return } -# fw_remove_module: closes every port the current module opened. +# fw_remove_module: closes every port the current module opened and drops the networks it trusted. fw_remove_module() { local registry spec + _fw_trust_remove_module registry=$(_fw_registry) [[ -f ${registry} ]] || return 0 if fw_available; then @@ -156,6 +171,177 @@ fw_remove_module() { fi # Only reached when every rule is gone (or no firewall tool is installed, so no rule can exist). rm -f -- "${registry}" + fw_container_sync + return +} + +_fw_trust_registry() { + printf '%s/firewall-trusted/%s' "${LSS_STATE_DIR:-/var/lib/lss}" "${LSS_MODULE:-core}" + return +} + +# _fw_ipv4_int ADDRESS: the IPv4 address as a number. +_fw_ipv4_int() { + local address=$1 a b c d + IFS=. read -r a b c d <<<"${address}" + printf '%s' "$(((10#${a} << 24) | (10#${b} << 16) | (10#${c} << 8) | 10#${d}))" + return +} + +# _fw_host_addresses: the IPv4 address this server sends from on its default route (its public or LAN address). +_fw_host_addresses() { + local route + route=$(ip -4 -o route get 192.0.2.1 2>/dev/null) || return 0 + sed -n 's/.* src \([0-9.]*\).*/\1/p' <<<"${route}" + return +} + +# _fw_check_network CIDR: an IPv4 network with a prefix of 8 to 32 or an IPv6 network with a prefix of 32 to 128. +# Broader networks (0.0.0.0/0, ::/0) and networks that contain the address of this server's default route are +# refused: trusting them would open the server to the internet or the local network. +_fw_check_network() { + local network=$1 address prefix octet host base value + local -a octets=() + [[ ${network} =~ ^[0-9A-Fa-f.:]+/[0-9]{1,3}$ ]] || + die "Invalid network '${network}' (expected an address with a prefix length, such as 10.42.0.0/16)." + address=${network%/*} + prefix=$((10#${network##*/})) + if [[ ${address} =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]]; then + IFS=. read -ra octets <<<"${address}" + for octet in "${octets[@]}"; do + ((10#${octet} <= 255)) || die "Invalid network '${network}': ${octet} is not an address byte." + done + ((prefix <= 32)) || die "Invalid network '${network}': an IPv4 prefix is at most 32." + ((prefix >= 8)) || die "The network '${network}' is too broad to trust (a prefix of at least /8 is needed)." + base=$(_fw_ipv4_int "${address}") + for host in $(_fw_host_addresses); do + [[ ${host} =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]] || continue + value=$(_fw_ipv4_int "${host}") + ((value >> (32 - prefix) != base >> (32 - prefix))) || + die "The network '${network}' contains ${host}, the address of this server; it cannot be trusted." + done + elif [[ ${address} =~ ^[0-9A-Fa-f:]+$ && ${address} == *:*:* ]]; then + ((prefix <= 128)) || die "Invalid network '${network}': an IPv6 prefix is at most 128." + ((prefix >= 32)) || die "The network '${network}' is too broad to trust (a prefix of at least /32 is needed)." + else + die "Invalid network '${network}' (expected an address with a prefix length, such as 10.42.0.0/16)." + fi + return +} + +# _fw_check_interface NAME: a network interface name (at most 15 characters, no wildcards). +_fw_check_interface() { + local interface=$1 + [[ ${interface} =~ ^[A-Za-z0-9_.-]{1,15}$ ]] || die "Invalid network interface '${interface}'." + return +} + +# _fw_trust_apply add|remove NETWORK INTERFACE: accepts traffic that arrives on INTERFACE from NETWORK. ufw matches +# both; firewalld binds INTERFACE to its trusted zone (a zone cannot match both, and the interface is the part a +# neighbour cannot forge). Adding fails loudly; removing a rule that is already gone is fine. +_fw_trust_apply() { + local action=$1 network=$2 interface=$3 output + local -a rule=(in on "${interface}" from "${network}") + if [[ $(fw_backend) == ufw ]]; then + if [[ ${action} == add ]]; then + output=$(ufw allow "${rule[@]}" comment "lss:${LSS_MODULE:-core}" 2>&1) || + die "Could not trust ${network} on ${interface}: ${output}" + elif ! output=$(ufw delete allow "${rule[@]}" 2>&1) && [[ ${output} != *non-existent* ]]; then + die "Could not remove the firewall rule for ${network} on ${interface}: ${output}" + fi + else + if [[ ${action} == add ]]; then + output=$(_fw_firewalld --zone=trusted "--change-interface=${interface}" 2>&1) || die "firewalld: ${output}" + elif ! output=$(_fw_firewalld --zone=trusted "--remove-interface=${interface}" 2>&1) && + [[ ${output} != *NOT_ENABLED* && ${output} != *UNKNOWN_INTERFACE* ]]; then + die "firewalld: ${output}" + fi + if svc_is_active firewalld; then + firewall-cmd --reload >/dev/null + fi + fi + return +} + +# fw_trust NETWORK INTERFACE: accepts all traffic from NETWORK that arrives on INTERFACE, for networks that belong to +# the server itself, such as the pod network of Kubernetes on its bridge. Recorded per module like ports ("NETWORK +# INTERFACE"): the firewall module applies the record when it comes later, and removing the module drops the rule. +fw_trust() { + local network=${1:-} interface=${2:-} registry entry + _fw_check_network "${network}" + _fw_check_interface "${interface}" + entry="${network} ${interface}" + registry=$(_fw_trust_registry) + if [[ -f ${registry} ]] && grep -qxF -- "${entry}" "${registry}"; then + return 0 + fi + if fw_available; then + _fw_trust_apply add "${network}" "${interface}" + fi + mkdir -p "$(dirname "${registry}")" + printf '%s\n' "${entry}" >>"${registry}" + files_note_change "firewall trust ${network} on ${interface}" + return +} + +_fw_trust_remove_module() { + local registry network interface + registry=$(_fw_trust_registry) + [[ -f ${registry} ]] || return 0 + if fw_available; then + while read -r network interface; do + [[ -n ${network} && -n ${interface} ]] || continue + _fw_trust_apply remove "${network}" "${interface}" + files_note_change "firewall distrust ${network} on ${interface}" + done <"${registry}" + fi + rm -f -- "${registry}" + return +} + +# The container firewall: lss-container-firewall.service loads its rules from the list fw_container_list names. +FW_CONTAINER_UNIT=lss-container-firewall + +fw_container_list() { + printf '%s/container-ports' "${LSS_STATE_DIR:-/var/lib/lss}" + return +} + +# fw_container_ports: "PROTO PORT SOURCE" for every public (SOURCE any) and vpn (SOURCE the WireGuard network) rule +# any module recorded, sorted. A published container port is reachable from outside exactly when it is listed here. +fw_container_ports() { + local registry spec vpn + vpn=$(fw_vpn_network) + for registry in "${LSS_STATE_DIR:-/var/lib/lss}"/firewall/*; do + [[ -f ${registry} ]] || continue + while IFS= read -r spec; do + [[ -n ${spec} ]] || continue + _fw_parse "${spec}" + case ${FW_SCOPE} in + public) printf '%s %s any\n' "${FW_PROTO}" "${FW_PORT}" ;; + vpn) printf '%s %s %s\n' "${FW_PROTO}" "${FW_PORT}" "${vpn}" ;; + *) ;; + esac + done <"${registry}" + done | sort -u + return +} + +# fw_container_sync: brings the list of the container firewall up to date and reloads the firewall. Does nothing until +# a container module has created the list (ctr_firewall_setup in lib/container.sh). +fw_container_sync() { + local list ports current + list=$(fw_container_list) + [[ -f ${list} ]] || return 0 + ports=$(fw_container_ports) + current=$(<"${list}") + [[ ${current} != "${ports}" ]] || return 0 + printf '%s\n' "${ports}" >"${list}" + files_note_change "container firewall ports" + if svc_is_active "${FW_CONTAINER_UNIT}.service"; then + systemctl reload "${FW_CONTAINER_UNIT}.service" || die "Could not reload ${FW_CONTAINER_UNIT}.service." + fi + return } # Port sshd listens on (first one), from the effective configuration. diff --git a/lib/step.sh b/lib/step.sh index 4980f72..fca3847 100755 --- a/lib/step.sh +++ b/lib/step.sh @@ -15,7 +15,7 @@ set -Eeuo pipefail # change notes it sets reach the caller. shopt -s inherit_errexit lastpipe -for library in log os config files template secrets state pkg repo fetch service firewall web module; do +for library in log os config files template secrets state pkg repo fetch service firewall container web module; do # shellcheck source=/dev/null source "${LSS_ROOT}/lib/${library}.sh" done diff --git a/libexec/lss-container-firewall b/libexec/lss-container-firewall new file mode 100644 index 0000000..a7a16e7 --- /dev/null +++ b/libexec/lss-container-firewall @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# lss container firewall: keeps published container ports behind the firewall rules of lss. Installed by the +# container modules (docker, podman, k3s) as /usr/local/lib/lss/container-firewall and run by +# lss-container-firewall.service: "start" (and reload) loads the rules, "stop" removes them, "print" shows them. +# +# Why: a published port (docker run -p, podman run -p, a Kubernetes NodePort or hostPort) is rewritten to the +# container's address before the host firewall sees it (DNAT), so it is forwarded, not delivered. The INPUT rules of +# ufw and firewalld never apply to it, and the port is open to the world even though the firewall says otherwise. +# +# How: the nftables table "inet lss_containers" has its own forward chain. A packet must be accepted by every base +# chain on a hook, so this chain filters forwarded traffic whatever Docker, Podman, kube-proxy, ufw or firewalld do in +# their own tables, and none of them flushes it on a reload. It drops every new connection that was redirected to a +# container network unless the port it was sent to (before the redirect) is in the list of ports lss opened +# (/var/lib/lss/container-ports, written from the firewall rules every module recorded with fw_allow). Connections +# straight to a container address (from a neighbour on the same network) are dropped for the Docker and Podman +# networks. Traffic the containers start themselves and answers to it pass. +set -Eeuo pipefail +shopt -s inherit_errexit + +PORTS=${LSS_CONTAINER_PORTS:-/var/lib/lss/container-ports} +TABLE=lss_containers +# Docker (docker0, br-), Podman (netavark podman, CNI cni-podman): new connections from outside need a +# redirected, allowed port. +ENGINE_BRIDGES=(docker0 'br-*' 'podman*' 'cni-podman*') +# k3s (cni0 for pods on this node, flannel for pods on other nodes): only redirected connections (NodePort, hostPort) +# are checked; pod traffic between nodes is routed to pod addresses directly and passes. +CLUSTER_BRIDGES=(cni0 'flannel*') + +# rule PROTO PORT SOURCE: the accept rule for one allowed port; prints nothing for an invalid line. +rule() { + local proto=$1 port=$2 source=$3 match='' + [[ ${proto} == tcp || ${proto} == udp ]] || return 0 + [[ ${port} =~ ^[0-9]{1,5}(-[0-9]{1,5})?$ ]] || return 0 + if [[ ${source} =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}(/[0-9]{1,2})?$ ]]; then + match="ip saddr ${source} " + elif [[ ${source} =~ ^[0-9A-Fa-f:]+(/[0-9]{1,3})?$ && ${source} == *:* ]]; then + match="ip6 saddr ${source} " + elif [[ ${source} != any ]]; then + return 0 + fi + printf '\t\t%smeta l4proto %s ct original proto-dst %s accept\n' "${match}" "${proto}" "${port}" + return +} + +ruleset() { + local bridge proto port source + # Creating the table first makes the delete succeed on the first run; the three lines run as one transaction. + printf 'table inet %s\ndelete table inet %s\n' "${TABLE}" "${TABLE}" + printf 'table inet %s {\n\tchain forward {\n' "${TABLE}" + printf '\t\ttype filter hook forward priority -10; policy accept;\n' + printf '\t\tct state established,related accept\n' + for bridge in "${ENGINE_BRIDGES[@]}" "${CLUSTER_BRIDGES[@]}"; do + printf '\t\tiifname "%s" accept\n' "${bridge}" + done + for bridge in "${ENGINE_BRIDGES[@]}" "${CLUSTER_BRIDGES[@]}"; do + printf '\t\toifname "%s" ct status dnat jump published\n' "${bridge}" + done + for bridge in "${ENGINE_BRIDGES[@]}"; do + printf '\t\toifname "%s" ct state new drop\n' "${bridge}" + done + printf '\t}\n\tchain published {\n' + if [[ -r ${PORTS} ]]; then + while read -r proto port source; do + [[ -n ${proto} ]] || continue + rule "${proto}" "${port}" "${source:-any}" + done <"${PORTS}" + fi + printf '\t\tdrop\n\t}\n}\n' + return +} + +main() { + local action=${1:-start} + case ${action} in + start | reload) ruleset | nft -f - ;; + stop) nft delete table inet "${TABLE}" 2>/dev/null || true ;; + print) ruleset ;; + *) + echo "usage: container-firewall start|reload|stop|print" >&2 + return 2 + ;; + esac + return +} + +# The unit tests source this file without running it. +if [[ ${BASH_SOURCE[0]} == "$0" ]]; then + main "$@" +fi diff --git a/libexec/lss-guard b/libexec/lss-guard index cd27cfc..274e055 100755 --- a/libexec/lss-guard +++ b/libexec/lss-guard @@ -9,7 +9,8 @@ # 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, certificates that expire soon. +# rkhunter and ClamAV runs, the Lynis hardening index, certificates that expire soon, HIGH and CRITICAL vulnerabilities +# in the container images of Docker and Podman (Trivy). # 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 @@ -20,6 +21,9 @@ 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} +# Installed by the docker and podman modules (lib/container.sh). +TRIVY=${LSS_GUARD_TRIVY:-/usr/local/bin/trivy} +TRIVY_CACHE=${LSS_GUARD_TRIVY_CACHE:-/var/cache/lss-trivy} # Global also when the script is sourced inside a function (the unit tests). declare -ga FINDINGS=() FIXED=() NOTES=() @@ -263,6 +267,65 @@ check_certificates() { return } +# images ENGINE: "ID REFERENCE" for every image of ENGINE, once per image; REFERENCE is repository:tag, or the ID for +# an image without a tag. +images() { + local engine=$1 id repository tag + "${engine}" images --format '{{.ID}} {{.Repository}} {{.Tag}}' 2>/dev/null | + while read -r id repository tag; do + [[ -n ${id} ]] || continue + if [[ ${repository} == '' || ${tag} == '' || -z ${tag} ]]; then + printf '%s %s\n' "${id}" "${id}" + else + printf '%s %s:%s\n' "${id}" "${repository}" "${tag}" + fi + done | awk '!seen[$1]++' + return +} + +# scan_image ENGINE ID REFERENCE: one line per HIGH or CRITICAL vulnerability of the image, its severity. Trivy reads +# Docker images through the daemon; a Podman image is saved to an archive first, which needs no socket. +scan_image() { + local engine=$1 id=$2 ref=$3 tmp status=0 + # shellcheck disable=SC2016 # a Go template, not a shell expansion + local -a scan=("${TRIVY}" image --cache-dir "${TRIVY_CACHE}" --scanners vuln --severity "HIGH,CRITICAL" --quiet + --timeout 15m --format template --template '{{range .Results}}{{range .Vulnerabilities}}{{println .Severity}}{{end}}{{end}}') + if [[ ${engine} == docker ]]; then + "${scan[@]}" --image-src docker "${ref}" + return + fi + tmp=$(mktemp -d) + if podman image save --quiet -o "${tmp}/image.tar" "${id}" >/dev/null 2>&1; then + "${scan[@]}" --input "${tmp}/image.tar" || status=$? + else + status=1 + fi + rm -rf -- "${tmp}" + return "${status}" +} + +# Container images with known HIGH or CRITICAL vulnerabilities: one finding per image with the numbers. An image Trivy +# cannot scan (no network for its database, a broken image) is a note. +check_containers() { + local engine id ref output high critical + [[ -x ${TRIVY} ]] || return 0 + for engine in docker podman; do + command -v "${engine}" >/dev/null 2>&1 || continue + while read -r id ref; do + if ! output=$(scan_image "${engine}" "${id}" "${ref}" 2>/dev/null); then + note "Trivy could not scan the ${engine} image ${ref}." + continue + fi + critical=$(grep -c '^CRITICAL$' <<<"${output}" || true) + high=$(grep -c '^HIGH$' <<<"${output}" || true) + if ((critical + high > 0)); then + finding "The ${engine} image ${ref} has ${critical} critical and ${high} high vulnerabilities (trivy image ${ref}); update the image." + fi + done < <(images "${engine}") + done + return +} + json_escape() { local text=$1 text=${text//\\/\\\\} @@ -311,6 +374,7 @@ main() { check_scanners check_lynis check_certificates + check_containers 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 bacc59b..64f3393 100755 --- a/lss +++ b/lss @@ -14,7 +14,7 @@ LSS_ROOT=$(cd -- "$(dirname -- "$(readlink -f -- "${BASH_SOURCE[0]}")")" && pwd) LSS_VERSION=$(<"${LSS_ROOT}/VERSION") export LSS_ROOT LSS_VERSION -for library in log os config files template secrets state pkg repo fetch service firewall web module ui; do +for library in log os config files template secrets state pkg repo fetch service firewall container web module ui; do # shellcheck source=/dev/null source "${LSS_ROOT}/lib/${library}.sh" done diff --git a/modules/container/docker.sh b/modules/container/docker.sh new file mode 100644 index 0000000..99b51ad --- /dev/null +++ b/modules/container/docker.sh @@ -0,0 +1,208 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +MOD_NAME="docker" +MOD_GROUP="container" +MOD_DESCRIPTION="Docker Engine from Docker's repository with a hardened daemon (CIS Docker), published ports behind the lss firewall, daily image scan" +MOD_REQUIRES="system" +MOD_CONFLICTS="podman k3s" +MOD_OPTIONS="source|auto|Packages: auto (Docker CE from download.docker.com where Docker builds for the release, otherwise the distribution's docker.io), docker-ce or distribution (Debian and Ubuntu) +bind|127.0.0.1|Address a published port binds to when the container names none (docker run -p 8080:80); 0.0.0.0 for all addresses, which the container firewall still guards +userns_remap|true|Run containers in a separate user namespace (root in a container is an unprivileged user on the host); switching it hides the images and containers of the other mode +log_max_size|20m|Largest log file of a container before it rotates (local log driver) +log_max_files|5|Rotated log files kept per container" +MOD_PORTS="none; published container ports only where a module opened them (container firewall)" + +DOCKER_CONFIG=/etc/docker/daemon.json +# Releases Docker CE has packages for (checked 2026-10-13, data/repos/docker.conf); the RHEL family uses the RHEL 9 +# and 10 repositories. +DOCKER_CE_SUITES="bookworm trixie jammy noble resolute" +DOCKER_CE_PACKAGES="docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin" + +mod_check() { + local source + source=$(opt source) + [[ ${source} =~ ^(auto|docker-ce|distribution)$ ]] || die "LSS_DOCKER_SOURCE must be auto, docker-ce or distribution." + if [[ ${source} == distribution ]] && os_is_rhel; then + die "LSS_DOCKER_SOURCE=distribution: the RHEL family ships no Docker Engine; use auto (Docker CE) or the podman module." + fi + [[ $(opt bind) =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]] || die "LSS_DOCKER_BIND must be an IPv4 address such as 127.0.0.1." + opt_bool userns_remap || true + [[ $(opt log_max_size) =~ ^[1-9][0-9]*[kmg]$ ]] || die "LSS_DOCKER_LOG_MAX_SIZE must be a size such as 20m." + (($(opt_int log_max_files) >= 1)) || die "LSS_DOCKER_LOG_MAX_FILES must be at least 1." + return +} + +# _docker_source: docker-ce or distribution. +_docker_source() { + local source + source=$(opt source) + if [[ ${source} == auto ]]; then + if os_is_rhel || [[ " ${DOCKER_CE_SUITES} " == *" ${OS_CODENAME} "* ]]; then + source=docker-ce + else + source=distribution + fi + fi + printf '%s' "${source}" + return +} + +# The daemon settings (CIS Docker Benchmark, section 2): no traffic between containers on the default bridge, published +# ports on BIND unless a container names an address, containers keep running while the daemon restarts, size-limited +# local logs, no new privileges, no userland proxy (iptables forwards instead), open-file limits, a user namespace, +# SELinux labels on the RHEL family. The default seccomp profile stays on. +_docker_config() { + local userns='' selinux='' + if opt_bool userns_remap; then + userns=$'\n "userns-remap": "default",' + fi + if os_is_rhel; then + selinux=$'\n "selinux-enabled": true,' + fi + cat <&1 || true) + if [[ ${changed} == 1 && ${usage} == *--validate* ]]; then + log_command dockerd --validate --config-file "${DOCKER_CONFIG}" || + die "docker: dockerd rejects ${DOCKER_CONFIG}; see the log." + fi + ctr_firewall_setup + svc_enable docker.socket + svc_enable docker.service + if [[ ${changed} == 1 ]]; then + # live-restore keeps running containers through the restart. + svc_restart docker.service || die "docker: the daemon does not start with ${DOCKER_CONFIG}; see above." + fi + return +} + +_docker_wait() { + local i + for ((i = 0; i < 30; i++)); do + if docker info >/dev/null 2>&1; then + return 0 + fi + sleep 1 + done + return 1 +} + +mod_verify() { + local options pid + svc_is_active docker.service || die "docker: the daemon is not running; see journalctl -u docker." + _docker_wait || die "docker: the daemon does not answer on its socket." + [[ $(docker info --format '{{.LiveRestoreEnabled}}') == true ]] || die "docker: live-restore is off." + [[ $(docker info --format '{{.LoggingDriver}}') == local ]] || die "docker: the log driver is not local." + options=$(docker info --format '{{join .SecurityOptions " "}}') + [[ ${options} == *name=seccomp* && ${options} != *profile=unconfined* ]] || + die "docker: containers run without the default seccomp profile (${options})." + if opt_bool userns_remap; then + [[ ${options} == *name=userns* ]] || die "docker: user namespace remapping is off (${options})." + fi + if os_is_debian && [[ $(cat /sys/module/apparmor/parameters/enabled 2>/dev/null) == Y ]]; then + [[ ${options} == *name=apparmor* ]] || die "docker: containers run without AppArmor (${options})." + fi + # The API is root on the host: only the Unix socket, never TCP. dockerd itself holds the published ports of the + # containers, so its listening sockets say nothing; its command line and daemon.json do. + pid=$(systemctl show --property=MainPID --value docker.service) + if [[ $(tr '\0' ' ' <"/proc/${pid}/cmdline" 2>/dev/null) == *tcp://* ]] || + jq -e '(.hosts // []) | any(startswith("tcp://"))' /etc/docker/daemon.json >/dev/null 2>&1; then + die "docker: dockerd listens on a TCP port; remove tcp:// from its -H options and from hosts in daemon.json." + fi + [[ $(stat -c '%a %U' /var/run/docker.sock) == '660 root' ]] || die "docker: /var/run/docker.sock must be mode 660 and owned by root." + ctr_firewall_verify + [[ -x ${CTR_TRIVY} ]] || die "docker: Trivy for the daily image scan is missing (${CTR_TRIVY})." + return +} + +mod_remove() { + log_warn "docker: stopping Docker; containers stay on disk and start again when the module is installed again." + svc_disable docker.socket + svc_disable docker.service + svc_disable containerd.service + file_remove "${DOCKER_CONFIG}" + ctr_firewall_remove + ctr_trivy_remove + [[ ${LSS_PURGE:-0} == 1 ]] || return 0 + local -a packages=() + read -ra packages <<<"${DOCKER_CE_PACKAGES}" + pkg_remove "${packages[@]}" docker.io + repo_remove docker + local dir + for dir in /var/lib/docker /var/lib/containerd; do + [[ ! -L ${dir} ]] || die "docker: refusing to delete ${dir}: it is a symbolic link." + if [[ -d ${dir} ]]; then + rm -rf -- "${dir}" + files_note_change "deleted ${dir}" + fi + done + rmdir /etc/docker 2>/dev/null || true + return +} diff --git a/modules/container/k3s.sh b/modules/container/k3s.sh new file mode 100644 index 0000000..1e7b285 --- /dev/null +++ b/modules/container/k3s.sh @@ -0,0 +1,341 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +MOD_NAME="k3s" +MOD_GROUP="container" +MOD_DESCRIPTION="k3s single-server Kubernetes (pinned, checksum-verified) hardened along the k3s CIS guide: encrypted secrets, Pod Security, audit log, protected kernel defaults" +MOD_REQUIRES="system" +MOD_CONFLICTS="docker" +MOD_OPTIONS="api_access|local|Kubernetes API on 6443/tcp: local (no rule; kubectl through an SSH tunnel), vpn (WireGuard network only) or public +traefik|true|Run the bundled Traefik ingress controller; opens 80/tcp and 443/tcp +pod_security|restricted|Pod Security Standard enforced in every namespace except kube-system: restricted, baseline or privileged" +MOD_PORTS="6443/tcp with api_access vpn or public; 80/tcp and 443/tcp with traefik" + +K3S_VERSION=v1.36.5+k3s1 +K3S_BIN=/usr/local/bin/k3s +K3S_LINKS="kubectl crictl ctr" +K3S_UNIT=k3s.service +K3S_DIR=/etc/rancher/k3s +K3S_CONFIG=${K3S_DIR}/config.yaml +K3S_PSA=${K3S_DIR}/lss-pod-security.yaml +K3S_AUDIT_POLICY=${K3S_DIR}/lss-audit-policy.yaml +K3S_KUBECONFIG=${K3S_DIR}/k3s.yaml +K3S_DATA=/var/lib/rancher/k3s +K3S_AUDIT_DIR=${K3S_DATA}/server/logs +K3S_SYSCTL=/etc/sysctl.d/70-lss-k3s.conf +K3S_MODULES=/etc/modules-load.d/lss-k3s.conf +# The pod network (the k3s default) and the bridge its pods use on this node. The host accepts what pods send it (DNS, +# the API through the kubernetes service), as the k3s documentation asks, but only when it arrives on that bridge. +K3S_POD_NETWORK=10.42.0.0/16 +K3S_POD_BRIDGE=cni0 + +mod_check() { + [[ $(opt api_access) =~ ^(local|vpn|public)$ ]] || die "LSS_K3S_API_ACCESS must be local, vpn or public." + opt_bool traefik || true + [[ $(opt pod_security) =~ ^(restricted|baseline|privileged)$ ]] || + die "LSS_K3S_POD_SECURITY must be restricted, baseline or privileged." + fetch_entry "k3s-${K3S_VERSION}" >/dev/null || die "k3s: data/checksums.txt has no checksum for k3s ${K3S_VERSION} on ${OS_ARCH}." + return +} + +# The kubelet refuses to start with protect-kernel-defaults unless the kernel has exactly these values (CIS Kubernetes +# 4.2.6); the rest lets pods reach each other and the outside. +_k3s_sysctl() { + cat <<'EOF' +vm.panic_on_oom = 0 +vm.overcommit_memory = 1 +kernel.panic = 10 +kernel.panic_on_oops = 1 +kernel.keys.root_maxkeys = 1000000 +kernel.keys.root_maxbytes = 25000000 +net.ipv4.ip_forward = 1 +net.bridge.bridge-nf-call-iptables = 1 +net.bridge.bridge-nf-call-ip6tables = 1 +EOF + return +} + +# SELinux on the RHEL family: k3s-selinux labels the k3s binary and its data, and containerd labels every pod. Rancher's +# repository has no EL 10 build yet, so both releases come as pinned, checksum-verified packages (data/checksums.txt). +_k3s_selinux_install() { + local tmp + os_is_rhel || return 0 + [[ $(getenforce 2>/dev/null || printf 'Disabled') != Disabled ]] || return 0 + pkg_install container-selinux + pkg_is_installed k3s-selinux && return 0 + tmp=$(mktemp -d) + fetch_verified "k3s-selinux-el${OS_MAJOR}" "${tmp}/k3s-selinux.noarch.rpm" + log_command dnf -y -q install "${tmp}/k3s-selinux.noarch.rpm" || die "k3s: dnf cannot install k3s-selinux; see the log." + rm -rf -- "${tmp}" + files_note_change "installed k3s-selinux" + return +} + +_k3s_selinux_on() { + os_is_rhel && pkg_is_installed k3s-selinux && [[ $(getenforce 2>/dev/null || true) =~ ^(Enforcing|Permissive)$ ]] + return +} + +_k3s_binary() { + local entry tmp link + entry=$(fetch_entry "k3s-${K3S_VERSION}") || die "k3s: no checksum for k3s ${K3S_VERSION}." + if [[ ! -f ${K3S_BIN} || $(sha256sum "${K3S_BIN}" | cut -d ' ' -f 1) != "${entry%% *}" ]]; then + tmp=$(mktemp -d) + fetch_verified "k3s-${K3S_VERSION}" "${tmp}/k3s" + install -m 0755 -o root -g root "${tmp}/k3s" "${K3S_BIN}" + rm -rf -- "${tmp}" + if command -v restorecon >/dev/null 2>&1; then + restorecon -F "${K3S_BIN}" 2>/dev/null || true + fi + files_note_change "installed k3s ${K3S_VERSION}" + if svc_is_active "${K3S_UNIT}"; then + svc_restart "${K3S_UNIT}" || die "k3s: k3s ${K3S_VERSION} does not start; see journalctl -u k3s." + fi + fi + # kubectl, crictl and ctr are k3s itself under another name; a binary of the same name from elsewhere stays. + for link in ${K3S_LINKS}; do + if [[ ! -e /usr/local/bin/${link} ]]; then + ln -s k3s "/usr/local/bin/${link}" + files_note_change "linked /usr/local/bin/${link}" + fi + done + return +} + +mod_install() { + # containerd loads its AppArmor profile with apparmor_parser on Debian and Ubuntu. + pkg_install_family "apparmor" "" + _k3s_selinux_install + _k3s_binary + return +} + +_k3s_config() { + local protect=true + # A container shares the kernel of its host; its values cannot be set from inside. + if os_in_container; then + protect=false + fi + printf '# Managed by lss (module k3s). Change LSS_K3S_* in /etc/lss/lss.conf and run lss configure k3s.\n' + printf '# Hardened along the k3s CIS Hardening Guide.\n' + printf 'write-kubeconfig-mode: "0600"\nsecrets-encryption: true\nprotect-kernel-defaults: %s\n' "${protect}" + if _k3s_selinux_on; then + printf 'selinux: true\n' + fi + if ! opt_bool traefik; then + printf 'disable:\n - traefik\n' + fi + cat </dev/null || true + modprobe overlay 2>/dev/null || true + file_write 0644 root:root "${K3S_SYSCTL}" < <( + printf '# Managed by lss (module k3s). Kernel values the kubelet expects (protect-kernel-defaults).\n' + _k3s_sysctl + ) + sysctl -q -e -p "${K3S_SYSCTL}" >/dev/null + fi + svc_unit "${K3S_UNIT}" </dev/null || true) + if [[ ${output} == ok || ${output} == True ]]; then + return 0 + fi + sleep 2 + done + return 1 +} + +mod_verify() { + local key value actual status i + svc_is_active "${K3S_UNIT}" || die "k3s: the server is not running; see journalctl -u k3s." + _k3s_wait 180 "${K3S_BIN}" kubectl get --raw=/readyz || die "k3s: the Kubernetes API is not ready." + _k3s_wait 180 "${K3S_BIN}" kubectl get nodes -o 'jsonpath={.items[0].status.conditions[?(@.type=="Ready")].status}' || + die "k3s: the node does not become Ready; see k3s kubectl describe nodes." + [[ $(stat -c '%a %U' "${K3S_KUBECONFIG}") == '600 root' ]] || die "k3s: ${K3S_KUBECONFIG} must be mode 600 and owned by root." + # The status comes from the server's encryption controller, which can report later than the API on a busy host. + for ((i = 0; i < 60; i += 2)); do + status=$("${K3S_BIN}" secrets-encrypt status 2>&1 || true) + [[ ${status} != *'Encryption Status: Enabled'* ]] || break + sleep 2 + done + if [[ ${status} != *'Encryption Status: Enabled'* ]]; then + # The status names the keys and whether their hashes match, never the key material. + printf 'k3s: secrets-encrypt status: %s\n' "${status//$'\n'/$'\n'k3s: secrets-encrypt status: }" >&2 + die "k3s: secrets are not encrypted at rest." + fi + if ! os_in_container; then + while IFS='=' read -r key value; do + key=${key// /} + value=${value# } + [[ -n ${key} ]] || continue + actual=$(sysctl -n "${key}" 2>/dev/null) || continue + [[ ${actual} == "${value}" ]] || die "k3s: ${key} is ${actual}, the kubelet expects ${value}." + done < <(_k3s_sysctl) + fi + ctr_firewall_verify + return +} + +# What the uninstall script of k3s does (k3s-killall.sh): k3s leaves its pods running when it stops (KillMode=process), +# so the container shims, the mounts and network interfaces of the pods and the iptables rules of kube-proxy, flannel +# and the network policy controller go separately. +_k3s_stop() { + local tool rules target link + svc_disable "${K3S_UNIT}" + pkill -9 -f "${K3S_DATA}/data/[^/]+/bin/containerd-shim" 2>/dev/null || true + awk '$2 ~ "^(/run/k3s|/var/lib/kubelet/pods|/var/lib/kubelet/plugins|/run/netns/cni-)" { print $2 }' /proc/self/mounts | + sort -r | while IFS= read -r target; do + umount -l "${target}" 2>/dev/null || true + done + for link in cni0 flannel.1 flannel-v6.1 flannel-wg flannel-wg-v6 kube-ipvs0; do + ip link delete "${link}" 2>/dev/null || true + done + for tool in iptables ip6tables; do + command -v "${tool}-save" >/dev/null 2>&1 || continue + rules=$("${tool}-save" 2>/dev/null) || continue + grep -Eiv 'KUBE-|CNI-|flannel' <<<"${rules}" | "${tool}-restore" 2>/dev/null || true + done + return +} + +mod_remove() { + local link + _k3s_stop + file_remove "/etc/systemd/system/${K3S_UNIT}" + svc_daemon_reload + file_remove "${K3S_CONFIG}" + file_remove "${K3S_PSA}" + file_remove "${K3S_AUDIT_POLICY}" + file_remove "${K3S_MODULES}" + file_remove "${K3S_SYSCTL}" + sysctl -q --system >/dev/null 2>&1 || true + for link in ${K3S_LINKS}; do + if [[ -L /usr/local/bin/${link} && $(readlink "/usr/local/bin/${link}") == k3s ]]; then + rm -f -- "/usr/local/bin/${link}" + fi + done + if [[ -f ${K3S_BIN} ]]; then + rm -f -- "${K3S_BIN}" + files_note_change "removed ${K3S_BIN}" + fi + ctr_firewall_remove + [[ ${LSS_PURGE:-0} == 1 ]] || return 0 + local dir + for dir in "${K3S_DATA}" /var/lib/kubelet /etc/rancher /var/lib/cni/networks/cbr0 /run/k3s; do + [[ ! -L ${dir} ]] || die "k3s: refusing to delete ${dir}: it is a symbolic link." + if [[ -e ${dir} ]]; then + rm -rf -- "${dir}" + files_note_change "deleted ${dir}" + fi + done + if os_is_rhel; then + pkg_remove k3s-selinux + fi + return +} diff --git a/modules/container/podman.sh b/modules/container/podman.sh new file mode 100644 index 0000000..0e03a89 --- /dev/null +++ b/modules/container/podman.sh @@ -0,0 +1,91 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +MOD_NAME="podman" +MOD_GROUP="container" +MOD_DESCRIPTION="Podman from the distribution: containers without a daemon, process and log limits, published ports behind the lss firewall, daily image scan" +MOD_REQUIRES="system" +MOD_CONFLICTS="docker" +MOD_OPTIONS="pids_limit|2048|Most processes one container may start, unless lss sets a lower limit for it" +MOD_PORTS="none; published container ports only where a module opened them (container firewall)" + +# Podman 4 and later read drop-ins from containers.conf.d; Podman 3 (Ubuntu 22.04) only the file itself. Settings in +# either one override the defaults in /usr/share/containers/containers.conf key by key. +PODMAN_DROPIN=/etc/containers/containers.conf.d/60-lss.conf +PODMAN_CONF=/etc/containers/containers.conf + +mod_check() { + (($(opt_int pids_limit) >= 64)) || die "LSS_PODMAN_PIDS_LIMIT must be at least 64." + return +} + +mod_install() { + pkg_install podman + # Podman loads its AppArmor profile with apparmor_parser, which Debian installs only as a recommendation. jq: the + # Compose check of lib/container.sh. + pkg_install_family "apparmor jq" "jq" + ctr_trivy_install + return +} + +# The configuration file lss manages: the drop-in where Podman reads one. +_podman_conf() { + local version + version=$(podman version --format '{{.Client.Version}}' 2>/dev/null || true) + [[ ${version} =~ ^[0-9]+ ]] || version=0 + if ((${version%%.*} >= 4)); then + printf '%s' "${PODMAN_DROPIN}" + else + printf '%s' "${PODMAN_CONF}" + fi + return +} + +mod_configure() { + local conf + conf=$(_podman_conf) + install -d -m 0755 "$(dirname "${conf}")" + file_write 0644 root:root "${conf}" </dev/null 2>&1 || die "podman: podman info fails; run it to see why." + seccomp=$(podman info --format '{{.Host.Security.SECCOMPEnabled}}' 2>/dev/null || true) + [[ ${seccomp} == true ]] || die "podman: containers run without seccomp." + if os_is_rhel && [[ $(getenforce 2>/dev/null) == Enforcing ]]; then + [[ $(podman info --format '{{.Host.Security.SELinuxEnabled}}' 2>/dev/null) == true ]] || + die "podman: SELinux is enforcing, but Podman does not label containers." + fi + ctr_firewall_verify + [[ -x ${CTR_TRIVY} ]] || die "podman: Trivy for the daily image scan is missing (${CTR_TRIVY})." + return +} + +mod_remove() { + # Only the file lss wrote: on Podman 4 and later containers.conf belongs to the administrator. + file_remove "$(_podman_conf)" + ctr_firewall_remove + ctr_trivy_remove + [[ ${LSS_PURGE:-0} == 1 ]] || return 0 + # Every container, image and volume of root. + if command -v podman >/dev/null 2>&1; then + log_command podman system reset --force || die "podman: podman system reset failed; see the log." + files_note_change "deleted the containers, images and volumes of Podman" + fi + pkg_remove podman + return +} diff --git a/modules/container/portainer.sh b/modules/container/portainer.sh new file mode 100644 index 0000000..66c028b --- /dev/null +++ b/modules/container/portainer.sh @@ -0,0 +1,138 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +MOD_NAME="portainer" +MOD_GROUP="container" +MOD_DESCRIPTION="Portainer CE 2.45 LTS (pinned image) to manage Docker or Podman in the browser, on 127.0.0.1:9443 or the WireGuard network, optionally below /portainer/ on the admin port" +MOD_REQUIRES="system docker|podman" +MOD_OPTIONS="access|local|Who reaches https://SERVER:9443: local (127.0.0.1 only; SSH tunnel) or vpn (WireGuard network only) +web_admin|false|Also publish Portainer below /portainer/ on the admin port of the web server (needs nginx, apache or caddy) +http_port|9000|Port of Portainer's plain HTTP listener on 127.0.0.1, which the web server forwards /portainer/ to (web_admin only)" +MOD_SECRETS="portainer-admin-password" +MOD_PORTS="9443/tcp from the WireGuard network with access vpn; none with access local" + +# Portainer CE 2.45.2 LTS, the multi-architecture index (amd64 and arm64), checked 2026-10-13. A new release means a +# new digest here. +PORTAINER_IMAGE=docker.io/portainer/portainer-ce@sha256:03f94a406496dc84d37b552795fc424ccad8d0d7200353b6a82915f6e8f73ab0 +PORTAINER_NAME=lss-portainer +PORTAINER_VOLUME=lss-portainer-data +PORTAINER_PORT=9443 +# The one directory of /etc the container may mount (lib/container.sh, CTR_CONFIG_DIR). +PORTAINER_DIR=${CTR_CONFIG_DIR}/${PORTAINER_NAME} +PORTAINER_PASSWORD_FILE=${PORTAINER_DIR}/admin-password +PORTAINER_PATH=portainer + +mod_check() { + local port + [[ $(opt access) =~ ^(local|vpn)$ ]] || die "LSS_PORTAINER_ACCESS must be local or vpn." + opt_bool web_admin || true + port=$(opt_int http_port) + ((port >= 1024 && port <= 65535)) || die "LSS_PORTAINER_HTTP_PORT must be a port number from 1024 to 65535." + [[ ${port} != "${WEB_ADMIN_PORT}" && ${port} != "${PORTAINER_PORT}" ]] || + die "LSS_PORTAINER_HTTP_PORT must differ from ${WEB_ADMIN_PORT} and ${PORTAINER_PORT}." + return +} + +# _portainer_web: Portainer is published on the admin port of the web server. +_portainer_web() { + opt_bool web_admin && [[ -n $(web_server) ]] + return +} + +mod_install() { + # Podman serves the Docker API only through its socket unit. + if [[ $(ctr_engine) == podman ]]; then + svc_enable podman.socket + fi + return +} + +mod_configure() { + local password address=127.0.0.1 changed=0 + local -a options=() command=() + # The first start creates the admin user with this password (lss secrets show portainer-admin-password). The file + # is the only part of the secrets a container sees; Portainer reads it from a read-only mount. + secret_load portainer-admin-password password 32 + install -d -m 0700 "${PORTAINER_DIR}" + file_write 0600 root:root "${PORTAINER_PASSWORD_FILE}" <<<"${password}" + [[ ${LSS_FILE_CHANGED} == 0 ]] || changed=1 + if [[ $(opt access) == vpn ]]; then + # Every address; the container firewall lets only the WireGuard network through (fw_allow below). + address=0.0.0.0 + fi + command=(--admin-password-file /run/lss/admin-password) + options=(--publish "${address}:${PORTAINER_PORT}:${PORTAINER_PORT}" + --volume "${PORTAINER_VOLUME}:/data" + --volume "${PORTAINER_PASSWORD_FILE}:/run/lss/admin-password:ro" + --socket) + if _portainer_web; then + options+=(--publish "127.0.0.1:$(opt_int http_port):9000") + command+=(--base-url "/${PORTAINER_PATH}") + else + command+=(--http-disabled) + fi + # Exception to the container rules, on purpose (lib/container.sh, CTR_SOCKET_CONTAINERS): Portainer manages the + # engine through its socket, which makes it as powerful as root. So it stays on 127.0.0.1 or the WireGuard network. + ctr_run "${PORTAINER_NAME}" "${PORTAINER_IMAGE}" "${options[@]}" -- "${command[@]}" + fw_allow "${PORTAINER_PORT}/tcp:$(opt access)" + if _portainer_web; then + web_admin_write portainer "${PORTAINER_PATH}" proxy "http://127.0.0.1:$(opt_int http_port)" + [[ ${LSS_FILE_CHANGED} == 0 ]] || web_reload + elif [[ -n $(web_server) ]]; then + web_admin_remove portainer + [[ ${LSS_FILE_CHANGED} == 0 ]] || web_reload + fi + if [[ ${changed} == 1 ]]; then + log_info "portainer: the admin password applies when Portainer creates its admin user; later changes go through Portainer." + fi + return +} + +# _portainer_get PATH: the status code of https://127.0.0.1:9443/PATH, or 000 while Portainer starts. +_portainer_get() { + local path=$1 base='' + if _portainer_web; then + base=/${PORTAINER_PATH} + fi + curl -sk --max-time 10 -o /dev/null -w '%{http_code}' "https://127.0.0.1:${PORTAINER_PORT}${base}/${path}" 2>/dev/null || true + return +} + +mod_verify() { + local i status='' + ctr_running "${PORTAINER_NAME}" || die "portainer: the container ${PORTAINER_NAME} is not running." + for ((i = 0; i < 60; i++)); do + status=$(_portainer_get api/system/status) + [[ ${status} != 200 ]] || break + sleep 2 + done + [[ ${status} == 200 ]] || die "portainer: https://127.0.0.1:${PORTAINER_PORT} does not answer (${status:-nothing})." + # 204: the admin user exists, so nobody can claim the instance with a password of their own. + status=$(_portainer_get api/users/admin/check) + [[ ${status} == 204 ]] || die "portainer: Portainer has no admin user yet (${status})." + if [[ $(opt access) == vpn ]]; then + [[ $(<"$(fw_container_list)") == *"tcp ${PORTAINER_PORT} "* ]] || + die "portainer: the container firewall does not limit ${PORTAINER_PORT}/tcp to the WireGuard network." + fi + return +} + +mod_remove() { + if [[ -n $(web_server) ]]; then + web_admin_remove portainer + web_reload + fi + ctr_remove "${PORTAINER_NAME}" + file_remove "${PORTAINER_PASSWORD_FILE}" + rmdir "${PORTAINER_DIR}" "${CTR_CONFIG_DIR}" 2>/dev/null || true + [[ ${LSS_PURGE:-0} == 1 ]] || return 0 + local engine + engine=$(ctr_engine) || engine='' + if [[ -n ${engine} ]] && "${engine}" volume inspect "${PORTAINER_VOLUME}" >/dev/null 2>&1; then + log_command "${engine}" volume rm "${PORTAINER_VOLUME}" || die "portainer: ${engine} cannot delete ${PORTAINER_VOLUME}." + files_note_change "deleted the volume ${PORTAINER_VOLUME}" + fi + secret_remove portainer-admin-password + return +} diff --git a/modules/security/aide.sh b/modules/security/aide.sh index 8acf958..7311b1e 100644 --- a/modules/security/aide.sh +++ b/modules/security/aide.sh @@ -48,6 +48,9 @@ _aide_excludes() { !/var/lib/docker !/var/lib/containerd !/var/lib/rancher +!/var/lib/containers +!/var/lib/kubelet +!/var/lib/cni !/var/lib/clamav !/var/lib/rkhunter !/var/lib/fail2ban diff --git a/modules/security/guard.sh b/modules/security/guard.sh index 84e1444..307c78f 100644 --- a/modules/security/guard.sh +++ b/modules/security/guard.sh @@ -4,7 +4,7 @@ MOD_NAME="guard" MOD_GROUP="security" -MOD_DESCRIPTION="Keeps the server at the level lss set up: daily verify and repair, failed services, disk, updates, vulnerable packages, logins, scanner findings, alerts" +MOD_DESCRIPTION="Keeps the server at the level lss set up: daily verify and repair, failed services, disk, updates, vulnerable packages and container images, logins, scanner findings, alerts" MOD_REQUIRES="system" MOD_OPTIONS="autofix|true|Configure modules again that fail verify or were changed outside lss time|07:00|Time of the daily check (HH:MM) diff --git a/profiles/all.conf b/profiles/all.conf index d025db3..3a84e6d 100644 --- a/profiles/all.conf +++ b/profiles/all.conf @@ -2,7 +2,9 @@ # # SPDX-License-Identifier: MIT -# Profile: Every module, one choice per conflict group (nginx, fail2ban, mariadb, redis) +# Profile: Every module, one choice per conflict group (nginx, fail2ban, mariadb, redis, docker) # -# 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 +# For the nightly test and as a reference. ssh-2fa, clamav and usbguard stay opt-in as in minimal-secure. k3s +# conflicts with docker (both bring their own containerd and claim the forwarding rules) and has its own profile, +# k8s-node; podman is the alternative to docker. +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 docker portainer diff --git a/profiles/k8s-node.conf b/profiles/k8s-node.conf new file mode 100644 index 0000000..8724440 --- /dev/null +++ b/profiles/k8s-node.conf @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# Profile: Hardened base plus k3s: a single-server Kubernetes node after the k3s CIS hardening guide +# +# The minimal-secure modules and k3s. The Kubernetes API stays on 127.0.0.1 (kubectl through an SSH tunnel) until +# LSS_K3S_API_ACCESS says otherwise; Traefik serves ports 80 and 443 unless LSS_K3S_TRAEFIK=false. +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 k3s diff --git a/tests/integration/run.sh b/tests/integration/run.sh index 746043e..d6c3447 100755 --- a/tests/integration/run.sh +++ b/tests/integration/run.sh @@ -69,6 +69,7 @@ start_target() { docker rm -f "${container}" >/dev/null 2>&1 || true docker run -d --name "${container}" --hostname lss-test --privileged --cgroupns=host \ -v /sys/fs/cgroup:/sys/fs/cgroup:rw --tmpfs /run --tmpfs /run/lock --tmpfs /tmp:exec \ + -v /var/lib/containers \ -v "${PWD}:/src:ro" "lss-test:${system}" >/dev/null in_target 'for i in $(seq 60); do s=$(systemctl is-system-running 2>/dev/null || true); [[ $s == running || $s == degraded ]] && exit 0; sleep 1; done; exit 1' in_target 'cp -a /src /opt/lss-src' @@ -116,6 +117,15 @@ stop_target() { echo "--- ${unit}" journalctl -u "${unit}" -n 40 --no-pager done + for engine in docker podman; do + command -v "${engine}" >/dev/null 2>&1 || continue + "${engine}" ps --all + for name in $("${engine}" ps --all --format "{{.Names}}"); do + echo "--- ${engine} logs ${name}" + "${engine}" logs --tail 40 "${name}" + done + done + ss -ltnp 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. diff --git a/tests/integration/settings.conf b/tests/integration/settings.conf index 6fe8cab..d8041aa 100644 --- a/tests/integration/settings.conf +++ b/tests/integration/settings.conf @@ -30,3 +30,6 @@ 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 + +# Container modules: k3s without Traefik (fewer image pulls from Docker Hub on the shared runner). +LSS_K3S_TRAEFIK=false diff --git a/tests/unit/container.bats b/tests/unit/container.bats new file mode 100644 index 0000000..0394e32 --- /dev/null +++ b/tests/unit/container.bats @@ -0,0 +1,448 @@ +#!/usr/bin/env bats +# SPDX-FileCopyrightText: 2025-2026 VectoDE +# +# SPDX-License-Identifier: MIT + +# lib/container.sh: the container rules, the run helpers against stub engines, the Compose check, the container +# firewall and its rule set (libexec/lss-container-firewall). + +IMAGE=docker.io/library/nginx@sha256:0000000000000000000000000000000000000000000000000000000000000000 + +setup() { + load helpers + lss_test_setup + use_os debian-12 + LSS_MODULE=docker +} + +# --- ctr_args ----------------------------------------------------------------------------------------------------- + +@test "every container gets the hardening options and limits" { + ctr_args docker web "${IMAGE}" --publish 127.0.0.1:8080:80 --volume web-data:/data:ro --cap NET_BIND_SERVICE -- nginx -g 'daemon off;' + local args="${CTR_ARGS[*]}" + [[ ${args} == "--name web --label lss.managed=true --security-opt no-new-privileges --cap-drop ALL "* ]] + [[ ${args} == *"--publish 127.0.0.1:8080:80"* ]] + [[ ${args} == *"--volume web-data:/data:ro"* ]] + [[ ${args} == *"--cap-add NET_BIND_SERVICE"* ]] + [[ ${args} == *"--read-only --tmpfs /tmp:rw,noexec,nosuid,nodev,size=64m"* ]] + [[ ${args} == *"--memory 512m --memory-swap 512m --pids-limit 256 --restart unless-stopped ${IMAGE} nginx -g daemon off;" ]] +} + +@test "podman containers get no restart policy; --writable and own limits" { + ctr_args podman app "${IMAGE}" --writable --memory 1g --pids 64 + local args="${CTR_ARGS[*]}" + [[ ${args} != *--read-only* ]] + [[ ${args} != *--restart* ]] + [[ ${args} == *"--memory 1g --memory-swap 1g --pids-limit 64 ${IMAGE}" ]] +} + +@test "images must name their registry and be pinned by digest" { + run ctr_args docker web nginx:latest + [ "${status}" -eq 1 ] + run ctr_args docker web docker.io/library/nginx:1.27 + [ "${status}" -eq 1 ] + run ctr_args docker web library/nginx@sha256:0000000000000000000000000000000000000000000000000000000000000000 + [ "${status}" -eq 1 ] + [[ ${output} == *"pinned by digest"* ]] +} + +@test "dangerous options are refused" { + run ctr_args docker web "${IMAGE}" --cap SYS_ADMIN + [[ ${output} == *"SYS_ADMIN is not allowed"* ]] + run ctr_args docker web "${IMAGE}" --network host + [[ ${output} == *"never the host network"* ]] + run ctr_args docker web "${IMAGE}" --publish 8080:80 + [[ ${output} == *"ADDRESS:HOST_PORT:CONTAINER_PORT"* ]] + run ctr_args docker web "${IMAGE}" --privileged true + [[ ${output} == *"unknown option '--privileged'"* ]] + run ctr_args docker web "${IMAGE}" --user root + [ "${status}" -eq 1 ] + run ctr_args docker web "${IMAGE}" --volume data + [ "${status}" -eq 1 ] + run ctr_args docker web "${IMAGE}" --volume /srv/data:/../etc + [ "${status}" -eq 1 ] + run ctr_args docker web "${IMAGE}" --volume /srv/data:/data:shared + [ "${status}" -eq 1 ] + run ctr_args docker web "${IMAGE}" --memory + [[ ${output} == *"--memory needs a value"* ]] +} + +@test "the engine socket and system paths are never mounted" { + local path + for path in /var/run/docker.sock /run/podman/podman.sock /run /var/lib/docker /srv/x/containerd.sock \ + /etc /etc/passwd /etc/lss/secrets/db /etc/lss/lss.conf /etc/letsencrypt/live/a/privkey.pem /root/.ssh \ + /var/lib/lss /var/lib/lss-pgadmin /var/log/lss /run/systemd/private /run/dbus/system_bus_socket /proc/1 /; do + run ctr_args docker web "${IMAGE}" --volume "${path}:/mnt" + [ "${status}" -eq 1 ] + done + run ctr_args docker web "${IMAGE}" --volume /var/run/docker.sock:/var/run/docker.sock + [[ ${output} == *"hands over the container engine"* ]] + run ctr_args docker web "${IMAGE}" --volume /etc/passwd:/etc/passwd:ro + [[ ${output} == *"/etc/passwd may not be mounted into a container"* ]] +} + +@test "a container may mount its own directory below /etc/lss/containers, no other" { + ctr_args docker web "${IMAGE}" --volume /etc/lss/containers/web/app.conf:/app.conf:ro --volume /srv/web:/srv + [[ ${CTR_ARGS[*]} == *"--volume /etc/lss/containers/web/app.conf:/app.conf:ro --volume /srv/web:/srv"* ]] + run ctr_args docker web "${IMAGE}" --volume /etc/lss/containers/other/app.conf:/app.conf:ro + [ "${status}" -eq 1 ] + run ctr_args docker web "${IMAGE}" --volume /etc/lss/containers:/c:ro + [ "${status}" -eq 1 ] +} + +@test "symbolic links are followed: a link to the socket or a system directory is refused" { + local dir=${BATS_TEST_TMPDIR}/links + mkdir -p "${dir}" + ln -s /var/run/docker.sock "${dir}/sock" + ln -s /etc "${dir}/etc" + ln -s /root "${dir}/home" + ln -s "${BATS_TEST_TMPDIR}" "${dir}/fine" + run ctr_args docker web "${IMAGE}" --volume "${dir}/sock:/data" + [ "${status}" -eq 1 ] + [[ ${output} == *"hands over the container engine"* ]] + run ctr_args docker web "${IMAGE}" --volume "${dir}/etc/ssh:/data:ro" + [ "${status}" -eq 1 ] + [[ ${output} == *"may not be mounted into a container (it leads to /etc/ssh)"* ]] + run ctr_args docker web "${IMAGE}" --volume "${dir}/home:/data" + [ "${status}" -eq 1 ] + ctr_args docker web "${IMAGE}" --volume "${dir}/fine:/data" +} + +@test "only Portainer gets the socket, in the host user namespace, without SELinux separation on RHEL" { + run ctr_args docker web "${IMAGE}" --socket + [ "${status}" -eq 1 ] + [[ ${output} == *"only lss-portainer may have the socket"* ]] + ctr_args docker lss-portainer "${IMAGE}" --socket + [[ ${CTR_ARGS[*]} == *"--volume /var/run/docker.sock:/var/run/docker.sock --userns host"* ]] + [[ ${CTR_ARGS[*]} != *label=disable* ]] + use_os rocky-9 + ctr_args podman lss-portainer "${IMAGE}" --socket + [[ ${CTR_ARGS[*]} == *"--volume /run/podman/podman.sock:/var/run/docker.sock --userns host --security-opt label=disable"* ]] +} + +@test "the named volumes of the socket container belong to root, so the host user namespace can write them" { + local data=${BATS_TEST_TMPDIR}/volume + CTR_VOLUME_OWNER="$(id -u):$(id -g)" + mkdir -p "${data}" + chmod 0755 "${data}" + # shellcheck disable=SC2329 # called by _ctr_root_volumes + docker() { + printf '%s\n' "$*" >>"${BATS_TEST_TMPDIR}/engine.log" + [[ $* != "volume inspect --format {{.Mountpoint}} lss-portainer-data" ]] || printf '%s\n' "${data}" + return 0 + } + ctr_args docker web "${IMAGE}" --volume web-data:/data + [ "${#CTR_ROOT_VOLUMES[@]}" -eq 0 ] + ctr_args docker lss-portainer "${IMAGE}" --socket --volume lss-portainer-data:/data --volume "${BATS_TEST_TMPDIR}/pw:/run/pw:ro" + [ "${CTR_ROOT_VOLUMES[*]}" = lss-portainer-data ] + _ctr_root_volumes docker + [ "$(stat -c '%a' "${data}")" = 700 ] + grep -qF "gave the volume lss-portainer-data to root" "$(_files_run_dir)/changes" +} + +# --- ctr_run and ctr_remove --------------------------------------------------------------------------------------- + +# A stub engine: "container inspect" answers from CTR_HASH and CTR_RUNNING; every call is logged. +fake_engine() { + printf '%s\n' "$*" >>"${BATS_TEST_TMPDIR}/engine.log" + case "$1 $2" in + "container inspect") + [[ -n ${CTR_HASH:-} ]] || return 1 + case $* in + *lss.hash*) printf '%s\n' "${CTR_HASH}" ;; + *Running*) printf '%s\n' "${CTR_RUNNING:-true}" ;; + *) ;; + esac + ;; + *) ;; + esac + return 0 +} + +@test "ctr_run creates a container once and keeps an unchanged one" { + # shellcheck disable=SC2329 # called by ctr_run + docker() { + fake_engine "$@" + } + ctr_run web "${IMAGE}" --publish 127.0.0.1:8080:80 + grep -q "^pull ${IMAGE}$" "${BATS_TEST_TMPDIR}/engine.log" + grep -q '^run --detach --label lss.hash=' "${BATS_TEST_TMPDIR}/engine.log" + CTR_HASH=$(sed -n 's/^run --detach --label lss.hash=\([0-9a-f]*\) .*/\1/p' "${BATS_TEST_TMPDIR}/engine.log") + : >"${BATS_TEST_TMPDIR}/engine.log" + ctr_run web "${IMAGE}" --publish 127.0.0.1:8080:80 + run ! grep -q -e '^pull' -e '^run' -e '^start' "${BATS_TEST_TMPDIR}/engine.log" + CTR_RUNNING=false + ctr_run web "${IMAGE}" --publish 127.0.0.1:8080:80 + grep -q '^start web$' "${BATS_TEST_TMPDIR}/engine.log" + CTR_RUNNING=true + : >"${BATS_TEST_TMPDIR}/engine.log" + ctr_run web "${IMAGE}" --publish 127.0.0.1:8081:80 + grep -q '^rm --force web$' "${BATS_TEST_TMPDIR}/engine.log" + grep -q '^run --detach' "${BATS_TEST_TMPDIR}/engine.log" +} + +@test "ctr_run with Podman creates the container and a systemd unit; ctr_remove deletes both" { + LSS_MODULE=podman + # shellcheck disable=SC2329 # called by ctr_run + podman() { + fake_engine "$@" + } + local units=${BATS_TEST_TMPDIR}/units + mkdir -p "${units}" + # shellcheck disable=SC2329 # called by ctr_run + svc_unit() { + cat >"${units}/$1" + } + ctr_run app "${IMAGE}" + grep -q '^create --label lss.hash=' "${BATS_TEST_TMPDIR}/engine.log" + grep -q 'start --attach app' "${units}/lss-ctr-app.service" + grep -q 'systemctl enable --now lss-ctr-app.service' "${STUB_LOG}" + CTR_HASH=x + ctr_remove app + grep -q '^rm --force app$' "${BATS_TEST_TMPDIR}/engine.log" +} + +@test "ctr_run needs an engine" { + LSS_MODULE=portainer + run ctr_run web "${IMAGE}" + [ "${status}" -eq 1 ] + [[ ${output} == *"neither docker nor podman is installed"* ]] +} + +# --- Compose ------------------------------------------------------------------------------------------------------ + +compose_json() { + cat </dev/null || skip "jq is not installed" + LINK=${BATS_TEST_TMPDIR}/etc-link + ln -s /etc "${LINK}" + local file=${BATS_TEST_TMPDIR}/compose.yaml + : >"${file}" + # shellcheck disable=SC2329 # called by ctr_compose_check + docker() { + compose_json + } + run ctr_compose_check "${file}" + [ "${status}" -eq 1 ] + [[ ${output} != *"good:"* ]] + local problem + for problem in "bad: the image is not pinned by digest" "bad: runs privileged" "bad: keeps capabilities" \ + "bad: adds the capability SYS_ADMIN" "bad: adds the capability ALL" "bad: may gain new privileges" \ + "bad: lifts a security profile" "bad: has host devices" "bad: has a writable root file system" \ + "bad: has no memory limit" "bad: has no process limit" "bad: shares a namespace" \ + "bad: publishes a port without an address" "bad: mounting /var/run/docker.sock hands over the container engine" \ + "bad: ${LINK} may not be mounted into a container (it leads to /etc)"; do + [[ ${output} == *"${problem}"* ]] + done + [[ ${output} != *"capability CHOWN"* ]] +} + +@test "a Compose file that cannot be read fails the check" { + run ctr_compose_check "${BATS_TEST_TMPDIR}/missing.yaml" + [ "${status}" -eq 1 ] + : >"${BATS_TEST_TMPDIR}/compose.yaml" + # shellcheck disable=SC2329 # called by ctr_compose_check + docker() { + return 1 + } + run ctr_compose_check "${BATS_TEST_TMPDIR}/compose.yaml" + [ "${status}" -eq 1 ] + [[ ${output} == *"docker compose cannot read"* ]] +} + +# --- container firewall ------------------------------------------------------------------------------------------- + +@test "the container firewall lists the public and vpn ports of every module and reloads on a change" { + LSS_MODULE=web + fw_allow 443/tcp 3000/tcp:vpn 5432/tcp:local + LSS_MODULE=mail + fw_allow 25/tcp + [ "$(fw_container_ports)" = $'tcp 25 any\ntcp 3000 10.66.0.0/24\ntcp 443 any' ] + # No container module yet: nothing is written. + fw_container_sync + [ ! -e "$(fw_container_list)" ] + : >"$(fw_container_list)" + export STUB_ACTIVE_UNITS=lss-container-firewall.service + fw_container_sync + [ "$(cat "$(fw_container_list)")" = $'tcp 25 any\ntcp 3000 10.66.0.0/24\ntcp 443 any' ] + grep -q 'systemctl reload lss-container-firewall.service' "${STUB_LOG}" + : >"${STUB_LOG}" + fw_container_sync + run ! grep -q reload "${STUB_LOG}" + LSS_MODULE=web fw_remove_module + [ "$(cat "$(fw_container_list)")" = 'tcp 25 any' ] +} + +# Records file_write calls in $BATS_TEST_TMPDIR/writes and reports a change. +record_writes() { + # shellcheck disable=SC2329 # called by the functions under test + file_write() { + printf '== %s %s %s\n' "$1" "$2" "$3" >>"${BATS_TEST_TMPDIR}/writes" + cat >>"${BATS_TEST_TMPDIR}/writes" + LSS_FILE_CHANGED=1 + } + return +} + +@test "ctr_firewall_setup installs the script, the unit and the list; removal waits for the last container module" { + record_writes + CTR_FIREWALL_SCRIPT=${BATS_TEST_TMPDIR}/lib/container-firewall + ctr_firewall_setup + local writes=${BATS_TEST_TMPDIR}/writes + grep -qxF "== 0755 root:root ${CTR_FIREWALL_SCRIPT}" "${writes}" + grep -qxF '== 0644 root:root /etc/systemd/system/lss-container-firewall.service' "${writes}" + grep -qxF "ExecStart=${CTR_FIREWALL_SCRIPT} start" "${writes}" + grep -qxF 'Before=network-pre.target docker.service podman.service k3s.service shutdown.target' "${writes}" + [ -f "$(fw_container_list)" ] + grep -q 'systemctl enable --now lss-container-firewall.service' "${STUB_LOG}" + grep -q 'systemctl restart lss-container-firewall.service' "${STUB_LOG}" + state_set k3s status installed + : >"${STUB_LOG}" + ctr_firewall_remove + [ -f "$(fw_container_list)" ] + run ! grep -q daemon-reload "${STUB_LOG}" + state_set k3s status removed + ctr_firewall_remove + [ ! -e "$(fw_container_list)" ] + grep -q 'systemctl daemon-reload' "${STUB_LOG}" +} + +@test "ctr_firewall_verify needs the active unit, the table and every listed port" { + TEST_RULESET=$(LSS_CONTAINER_PORTS=/dev/null bash "${REPO_ROOT}/libexec/lss-container-firewall" print) + # A variable of the test: ctr_firewall_verify has a local "ruleset" that would hide one of that name. + # shellcheck disable=SC2329 # called by ctr_firewall_verify + nft() { + printf '%s\n' "${TEST_RULESET}" + } + run ctr_firewall_verify + [[ ${output} == *"is not active"* ]] + export STUB_ACTIVE_UNITS=lss-container-firewall.service + mkdir -p "${LSS_STATE_DIR}" + : >"$(fw_container_list)" + ctr_firewall_verify + fw_allow 8080/tcp + run ctr_firewall_verify + [[ ${output} == *"does not let 8080/tcp through"* ]] + TEST_RULESET=$(LSS_CONTAINER_PORTS=$(fw_container_list) bash "${REPO_ROOT}/libexec/lss-container-firewall" print) + ctr_firewall_verify + printf 'tcp 9 any\n' >>"$(fw_container_list)" + run ctr_firewall_verify + [[ ${output} == *"lists other ports than the firewall rules"* ]] +} + +@test "the container firewall rule set: redirected connections only to listed ports, nothing straight to containers" { + local ports=${BATS_TEST_TMPDIR}/ports output + printf 'tcp 443 any\ntcp 3000 10.66.0.0/24\nudp 51820-51830 fd00::/64\nicmp 1 any\ntcp 80 nonsense\n\n' >"${ports}" + output=$(LSS_CONTAINER_PORTS=${ports} bash "${REPO_ROOT}/libexec/lss-container-firewall" print) + [[ ${output} == $'table inet lss_containers\ndelete table inet lss_containers\ntable inet lss_containers {'* ]] + [[ ${output} == *$'\t\ttype filter hook forward priority -10; policy accept;\n\t\tct state established,related accept\n'* ]] + [[ ${output} == *$'\t\tiifname "br-*" accept\n'* ]] + [[ ${output} == *$'\t\toifname "cni0" ct status dnat jump published\n'* ]] + [[ ${output} == *$'\t\toifname "podman*" ct state new drop\n'* ]] + [[ ${output} != *'oifname "cni0" ct state new drop'* ]] + [[ ${output} == *$'\t\tmeta l4proto tcp ct original proto-dst 443 accept\n'* ]] + [[ ${output} == *$'\t\tip saddr 10.66.0.0/24 meta l4proto tcp ct original proto-dst 3000 accept\n'* ]] + [[ ${output} == *$'\t\tip6 saddr fd00::/64 meta l4proto udp ct original proto-dst 51820-51830 accept\n'* ]] + [[ ${output} != *icmp* && ${output} != *nonsense* && ${output} != *'proto-dst 80 '* ]] + [[ ${output} == *$'\t\tdrop\n\t}\n}' ]] + run bash "${REPO_ROOT}/libexec/lss-container-firewall" bogus + [ "${status}" -eq 2 ] +} + +# --- engine, Compose, Trivy --------------------------------------------------------------------------------------- + +@test "the engine is the module being installed or the installed one" { + LSS_MODULE=portainer + run ctr_engine + [ "${status}" -eq 1 ] + state_set podman status installed + [ "$(ctr_engine)" = podman ] + LSS_MODULE=docker + [ "$(ctr_engine)" = docker ] + [ "$(ctr_socket podman)" = /run/podman/podman.sock ] + run ctr_socket lxc + [ "${status}" -eq 1 ] +} + +@test "Compose projects start only with docker and after the check" { + command -v jq >/dev/null || skip "jq is not installed" + local file=${BATS_TEST_TMPDIR}/compose.yaml + : >"${file}" + # shellcheck disable=SC2329 # called by ctr_compose_up + docker() { + printf 'docker %s\n' "$*" >>"${BATS_TEST_TMPDIR}/engine.log" + if [[ $* == *config* ]]; then + printf '{"services": {}}\n' + fi + return 0 + } + run ctr_compose_up 'Bad Name' "${file}" + [ "${status}" -eq 1 ] + ctr_compose_up shop "${file}" + grep -qxF "docker compose --project-name shop -f ${file} up --detach --remove-orphans --quiet-pull" "${BATS_TEST_TMPDIR}/engine.log" + LSS_MODULE=podman + run ctr_compose_up shop "${file}" + [[ ${output} == *"need the docker module"* ]] +} + +@test "Trivy is kept when it is current and removed with the last engine" { + CTR_TRIVY=${BATS_TEST_TMPDIR}/trivy + CTR_TRIVY_CACHE=${BATS_TEST_TMPDIR}/trivy-cache + printf '#!/bin/sh\necho "Version: %s"\n' "${CTR_TRIVY_VERSION}" >"${CTR_TRIVY}" + chmod +x "${CTR_TRIVY}" + mkdir -p "${CTR_TRIVY_CACHE}" + ctr_trivy_install + run ! grep -q curl "${STUB_LOG}" + state_set podman status installed + ctr_trivy_remove + [ -x "${CTR_TRIVY}" ] + state_set podman status removed + ctr_trivy_remove + [ ! -e "${CTR_TRIVY}" ] + [ ! -e "${CTR_TRIVY_CACHE}" ] +} + +# --- modules ------------------------------------------------------------------------------------------------------ + +@test "the container modules declare their group, requirements and mutual conflicts" { + [ "$(module_meta docker MOD_GROUP)" = container ] + [ "$(module_meta portainer MOD_REQUIRES)" = "system docker|podman" ] + [ "$(module_meta docker MOD_CONFLICTS)" = "podman k3s" ] + [ "$(module_meta podman MOD_CONFLICTS)" = docker ] + [ "$(module_meta k3s MOD_CONFLICTS)" = docker ] + [ "$(module_meta portainer MOD_SECRETS)" = portainer-admin-password ] + [[ $(module_meta k3s MOD_OPTIONS) == *"api_access|local|"* ]] + run module_resolve docker podman + [ "${status}" -eq 1 ] + run module_resolve k3s docker + [ "${status}" -eq 1 ] + run module_resolve podman portainer + [ "${status}" -eq 0 ] + [[ ${output} == *$'podman\nportainer'* ]] +} + +@test "the downloads of k3s, k3s-selinux and Trivy are pinned for both architectures" { + local arch name + for arch in x86_64 aarch64; do + use_os debian-12 "${arch}" + for name in k3s-v1.36.5+k3s1 "trivy-${CTR_TRIVY_VERSION}" k3s-selinux-el9 k3s-selinux-el10; do + [[ $(fetch_entry "${name}") =~ ^[0-9a-f]{64}\ https://github.com/ ]] + done + done +} diff --git a/tests/unit/guard.bats b/tests/unit/guard.bats index b866dc4..176833a 100644 --- a/tests/unit/guard.bats +++ b/tests/unit/guard.bats @@ -136,3 +136,40 @@ journalctl() { [ "${#FINDINGS[@]}" -eq 1 ] [[ ${FINDINGS[0]} == "Certificate old expires "* ]] } + +@test "container images with HIGH or CRITICAL vulnerabilities are a finding with the numbers; a failed scan is a note" { + TRIVY=${BATS_TEST_TMPDIR}/trivy + cat >"${TRIVY}" <<'STUB' +#!/usr/bin/env bash +case ${*: -1} in +docker.io/library/nginx:1.27) printf 'HIGH\nCRITICAL\nHIGH\n' ;; +ccc) exit 1 ;; +*) ;; +esac +STUB + chmod +x "${TRIVY}" + # shellcheck disable=SC2329 # called by check_containers + docker() { + printf 'aaa docker.io/library/nginx 1.27\naaa docker.io/library/nginx latest\nbbb docker.io/library/redis 8\nccc \n' + } + # shellcheck disable=SC2329 # called by check_containers + podman() { + return 0 + } + check_containers + [ "${#FINDINGS[@]}" -eq 1 ] + [ "${FINDINGS[0]}" = "The docker image docker.io/library/nginx:1.27 has 1 critical and 2 high vulnerabilities (trivy image docker.io/library/nginx:1.27); update the image." ] + [ "${#NOTES[@]}" -eq 1 ] + [ "${NOTES[0]}" = "Trivy could not scan the docker image ccc." ] +} + +@test "the container image scan is skipped without Trivy" { + TRIVY=${BATS_TEST_TMPDIR}/missing + # shellcheck disable=SC2329 # would be called by check_containers + docker() { + printf 'aaa docker.io/library/nginx 1.27\n' + } + check_containers + [ "${#FINDINGS[@]}" -eq 0 ] + [ "${#NOTES[@]}" -eq 0 ] +} diff --git a/tests/unit/helpers.bash b/tests/unit/helpers.bash index 9d85fa5..2e3a849 100644 --- a/tests/unit/helpers.bash +++ b/tests/unit/helpers.bash @@ -22,7 +22,7 @@ lss_test_setup() { export OWNER : >"${STUB_LOG}" local library - for library in log os config files template secrets state pkg repo fetch service firewall web module ui; do + for library in log os config files template secrets state pkg repo fetch service firewall container web module ui; do # shellcheck source=/dev/null source "${REPO_ROOT}/lib/${library}.sh" done diff --git a/tests/unit/system.bats b/tests/unit/system.bats index 10d319a..b8bf7e4 100644 --- a/tests/unit/system.bats +++ b/tests/unit/system.bats @@ -290,6 +290,63 @@ time.sleep(30)' >"${port_file}" 2>/dev/null 3>&- & [ "${status}" -eq 1 ] } +@test "trusted networks must be valid, narrow and bound to an interface" { + use_os debian-12 + LSS_MODULE=k3s + # shellcheck disable=SC2329 # called by fw_trust + _fw_host_addresses() { + printf '192.168.1.10\n' + } + local spec + for spec in '0.0.0.0/0 cni0' '::/0 cni0' '300.1.1.1/24 cni0' '10.0.0.0/33 cni0' '10.0.0.0/7 cni0' '2001:db8::/16 cni0' \ + 'fd00::/129 cni0' '10.42.0.0 cni0' '10.42.0.0/16' '10.42.0.0/16 cni*' '10.42.0.0/16 a-very-long-interface' \ + '192.168.0.0/16 eth0' '192.168.1.10/32 eth0'; do + # shellcheck disable=SC2086 # NETWORK and INTERFACE as two words + run fw_trust ${spec} + [ "${status}" -eq 1 ] + done + run fw_trust 192.168.0.0/16 eth0 + [[ ${output} == *"contains 192.168.1.10, the address of this server"* ]] + run fw_trust 0.0.0.0/0 cni0 + [[ ${output} == *"too broad to trust"* ]] + [ ! -e "${LSS_STATE_DIR}/firewall-trusted/k3s" ] + fw_trust 10.42.0.0/16 cni0 + fw_trust fd00:42::/48 cni0 + [ "$(cat "${LSS_STATE_DIR}/firewall-trusted/k3s")" = $'10.42.0.0/16 cni0\nfd00:42::/48 cni0' ] +} + +@test "ufw: a trusted network is accepted on its interface only, once, and removed with the module" { + use_os ubuntu-24.04 + LSS_MODULE=k3s + fw_trust 10.42.0.0/16 cni0 + fw_trust 10.42.0.0/16 cni0 + [ "$(grep -c 'ufw allow in on cni0 from 10.42.0.0/16 comment lss:k3s' "${STUB_LOG}")" = 1 ] + fw_remove_module + grep -q 'ufw delete allow in on cni0 from 10.42.0.0/16' "${STUB_LOG}" + [ ! -e "${LSS_STATE_DIR}/firewall-trusted/k3s" ] +} + +@test "a trust rule that ufw rejects stops the run and is not recorded" { + use_os debian-13 + LSS_MODULE=k3s + export STUB_EXIT_ufw=1 STUB_OUT_ufw='ERROR: Bad interface name' + run fw_trust 10.42.0.0/16 cni0 + [ "${status}" -eq 1 ] + [[ ${output} == *"Could not trust 10.42.0.0/16 on cni0: ERROR: Bad interface name"* ]] + [ ! -e "${LSS_STATE_DIR}/firewall-trusted/k3s" ] +} + +@test "firewalld: the interface joins the trusted zone; removing it again is idempotent" { + use_os almalinux-10 + LSS_MODULE=k3s + fw_trust 10.42.0.0/16 cni0 + grep -q 'firewall-offline-cmd --zone=trusted --change-interface=cni0' "${STUB_LOG}" + export STUB_EXIT_firewall_offline_cmd=1 STUB_OUT_firewall_offline_cmd='Error: NOT_ENABLED: cni0' + fw_remove_module + grep -q 'firewall-offline-cmd --zone=trusted --remove-interface=cni0' "${STUB_LOG}" + [ ! -e "${LSS_STATE_DIR}/firewall-trusted/k3s" ] +} + @test "the rollback timer is armed only over SSH" { use_os debian-12 unset SSH_CONNECTION