Skip to content

lss 2.0 stage 6: app modules - #26

Draft
VectoDE wants to merge 28 commits into
feat/lss-5from
feat/lss-6
Draft

VectoDE wants to merge 28 commits into
feat/lss-5from
feat/lss-6

Conversation

@VectoDE

@VectoDE VectoDE commented Oct 15, 2026

Copy link
Copy Markdown
Owner

Stage 6 of the lss 2.0 rebuild: the app modules.

  • lib/apps.sh: shared code for app stacks. It covers the Compose project per module and secrets as read-only files for the (userns-mapped) container user. Each app gets an HTTPS site of the installed web server. It also adds PostgreSQL/MariaDB services on an internal network, timers, a functional-check helper and removal.
  • Modules (modules/apps/):
    • nextcloud, collabora (Nextcloud Office links itself in either order), forgejo, uptime-kuma, vaultwarden, keycloak, matomo, wordpress and n8n as Docker Compose stacks.
    • minio, running PGSTY Silo either natively from the package or in docker mode.
    • self-updater, running natively from the npm release.
  • Hardening:
    • Images are pinned by multi-arch digest, with no capabilities and no-new-privileges.
    • The root filesystem is read-only, except for Collabora and Keycloak, which are marked writable.
    • Memory and pids limits apply, and ports bind to 127.0.0.1 only.
    • No secrets in Compose files, environment variables or command lines.
  • mod_verify exercises each app: WebDAV round trip, document conversion, API create/delete, tracking/reporting, S3 bucket/object.
  • Profiles: office and devops gain their apps; all lists every app.
  • CI:
    • A new apps VM job (7 systems × 3 sets: nginx, apache and caddy+tls as base).
    • A container group apps (self-updater, minio native) on x64 and arm64.
    • VM runs of profile office.
    • VM size is configurable.
  • Unit tests: tests/unit/apps.bats.

Resource figures in the README are filled in from a green run.

Refs #8

🤖 Generated with Claude Code

VectoDE and others added 27 commits October 15, 2026 19:07
…web server

Stage 6 brings the applications a small company runs itself: Nextcloud with
Collabora Online, Forgejo, Uptime Kuma, Vaultwarden, Keycloak, Matomo,
WordPress, n8n, MinIO (as the maintained fork Silo, since MinIO archived its
community edition) and Self-Updater.

The apps share lib/apps.sh so that every stack gets the same guarantees
without each module repeating them: images pinned by digest, no capabilities,
no new privileges, read-only roots where the image allows, memory and process
limits, ports on 127.0.0.1 only and an HTTPS site of the installed web server
in front. Secrets are copied from /etc/lss/secrets as read-only files for the
container user (mapped through Docker's user namespace), so no password lands
in a Compose file, an environment variable or a command line; first
administrators get generated passwords that lss only points to.

mod_verify uses each app (WebDAV round trip, document conversion, API
create/delete, tracking and reporting, S3 bucket and object) instead of only
checking that a port answers. MinIO and Self-Updater also run natively, so
they are covered on arm64 in the container jobs; the Docker stacks run in new
apps VM jobs on all seven systems. The office and devops profiles gain the
apps they were meant to have.

Refs #8

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: VectoDE <system.bot.discord.utility@gmail.com>
An optional edge proxy (Traefik, #27) may later own ports 80 and 443 and
route to the apps itself. Every app now becomes reachable through
web_proxy_route/web_proxy_route_remove in lib/web.sh, which today writes the
reverse-proxy site of the web server, so that switch touches one place.

Also fixes found while reviewing the apps against their sources: Uptime
Kuma's deleteMonitor takes three arguments, Vaultwarden's user deletion
needs a JSON content type, and Matomo waits for a static file instead of
the tracker, which answers differently before the installation.

Refs #8

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: VectoDE <system.bot.discord.utility@gmail.com>
The coverage run uses a kcov image without jq, where the mapping of
container users silently fell back to no remapping. The docker module
writes "userns-remap" on a line of its own, so sed reads it everywhere.

Refs #8

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: VectoDE <system.bot.discord.utility@gmail.com>
… start

Three causes of the failing app jobs in CI:

- Caddy answered 404 for every dot path except /.well-known/ at the
  root, so Keycloak's /realms/master/.well-known/openid-configuration
  never got through. nginx and Apache already let .well-known through
  at any depth; Caddy now does the same, and other dot files stay
  hidden.
- Without the tls module, every route used a copy of the default
  certificate, which only names the host. Nextcloud checks the host
  name when it calls Collabora, so trusting that certificate could not
  work. A route now gets a self-signed placeholder for its own names
  (renewed when the names change or expiry is near; a certificate from
  the tls module is never replaced), and Nextcloud trusts the
  certificate of the collabora route.
- Uptime Kuma registers its Socket.IO handlers only after answering the
  connection, so the first question could get lost. The script now asks
  again until it gets an answer.

Refs #8

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: VectoDE <system.bot.discord.utility@gmail.com>
…cate helper

The Matomo and WordPress installers loaded the administrator password
into a variable they never used. curl reads the password from the
secret file, so the installers now only make sure the secret exists.
The helper for self-signed certificates names its subject in a local
variable instead of using $1 directly.

Refs #8

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: VectoDE <system.bot.discord.utility@gmail.com>
web_proxy_route now passes the host names of a route to its
placeholder certificate, and the self-signed helper names the subject
in its error message. Two tests still expected the old call and the old
message.

Refs #8

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: VectoDE <system.bot.discord.utility@gmail.com>
Signed-off-by: VectoDE <system.bot.discord.utility@gmail.com>
…ora, cover arm64

The restic backup left the app stacks out: their data sits in Docker
volumes, and a live copy of a database's files may not load again.
Every app now registers what lss-app-backup saves of it before each
restic run:
- PostgreSQL and MariaDB are dumped inside their container;
- the SQLite apps (Forgejo, Vaultwarden, Uptime Kuma) are copied while
  the app is paused for a moment;
- file volumes and native data directories go to restic as they are.
lss verify runs this step for each app, so a dump that breaks shows up
before the night's backup.

Collabora no longer turns off the certificate check without the tls
module. It loads the host's CAs and the trust anchor of the Nextcloud
route (the self-signed route certificate, or the private ACME CA) as
its storage CA file, and verify asks Collabora's own WOPI access check
whether it reaches Nextcloud with a valid certificate. When Nextcloud
is installed after Collabora, it hands its certificate over and starts
Collabora again.

The arm64 runners have no KVM, so the VM jobs cannot cover the apps
there. A new job runs the three app sets on the arm64 runner itself
(Ubuntu 24.04), after removing the runner's own Docker.

Refs #8

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: VectoDE <system.bot.discord.utility@gmail.com>
… S3 body hash

Three failures from the CI run of 65b906b:

- Matomo: the console writes a setting as a quoted string ("0"), so
  the check for the plain value never matched, and every install set
  enable_browser_archiving_triggering again. The check now accepts
  both forms.
- Vaultwarden on Rocky 9 and AlmaLinux 10: the init service could not
  chown the data volume. Each container of a stack gets its own SELinux
  MCS categories, and the volume carried the app's. init and the app
  now mount the shared volume with ":z" (shared label); this also
  affects MinIO in Docker mode.
- MinIO (native, Debian 12): storing an object answered 403. Older curl
  sends no x-amz-content-sha256 with --aws-sigv4, and the server then
  checks against the hash of an empty body. verify sends the hash of
  the body explicitly and puts the S3 error code in the message.

Refs #8

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: VectoDE <system.bot.discord.utility@gmail.com>
Docker Hub answered one pull of the arm64 apps job with HTTP 500, and the
n8n installation was rolled back over that single answer. ctr_compose_up
now pulls the missing images first, three attempts 15 s apart, and only
then starts the stack, so a passing registry hiccup no longer undoes an
installation while a registry that stays down still fails loudly.

Refs #8

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: VectoDE <system.bot.discord.utility@gmail.com>
SonarCloud asks for positional parameters to be bound to a named local
(shelldre:S7679); the other helpers of the module already do so.

Refs #8

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: VectoDE <system.bot.discord.utility@gmail.com>
Signed-off-by: VectoDE <system.bot.discord.utility@gmail.com>
…t the test cookie

A fresh site has plain permalinks, so /wp-json/ never reaches the REST
API and the check failed on every system. index.php?rest_route=/ works
with every permalink setting. The login no longer posts testcookie=1:
curl cannot send the jar and a fixed cookie together on every version,
and without the field WordPress skips that browser check. The start
check waits for a stylesheet instead of a PNG, which bash read with a
null-byte warning. The REST error now names the status and address.

Refs #8

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: VectoDE <system.bot.discord.utility@gmail.com>
Pebble serves its API with the minica certificate but signs issued
certificates with a root it creates at start. Nextcloud and Collabora
check each other's certificate against LSS_TLS_CA_BUNDLE, so with only
minica in it the office profile failed with "unable to get local issuer
certificate". The bundle now holds minica and every issuing root, as a
step-ca root would cover both in a real private ACME setup.

Refs #8

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: VectoDE <system.bot.discord.utility@gmail.com>
…er SELinux

On AlmaLinux 10 with SELinux enforcing the profile office hung in
mailserver: the sandbox sets NoNewPrivileges, and SELinux then denies
postfix_master_t the change into postfix_qmgr_t, postfix_pickup_t and
postfix_smtpd_t (avc: nnp_transition), so no daemon ever started.
Dovecot changes into dovecot_auth_t the same way. The mail tests run in
containers without SELinux, so this never showed there.

svc_spawner_directives turns NoNewPrivileges off for such masters under
SELinux only, as svc_scanner_sandbox already does for the scanners: the
masters run as root and drop their rights themselves, and the policy
confines every worker. The rest of the sandbox stays.

Refs #8

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: VectoDE <system.bot.discord.utility@gmail.com>
The conversion check of the profile all failed with a bare HTTP 403,
which Collabora, nginx and ModSecurity can all answer. The error now
carries the start of the answer and, with the modsecurity module, the
rule IDs and messages of the latest audit entries (app_waf_hits), so the
next failure points at its cause instead of needing another CI round.

Refs #8

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: VectoDE <system.bot.discord.utility@gmail.com>
The second install of the office profile on AlmaLinux 10 rewrote
lss-nextcloud-cron.service: command -v docker answered /bin/docker in
one run and /usr/bin/docker in the next, because /bin links to /usr/bin
and the PATH differed. The units now take the resolved path, as aide,
rkhunter and clamav already do, so a second run reports unchanged. The
Self-Updater unit resolves its directory the same way.

Refs #8

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: VectoDE <system.bot.discord.utility@gmail.com>
…load

The profile all failed the Collabora PDF conversion with 403; the new
diagnostics named rule 200004, "possible unmatched boundary". ModSecurity
3 sets MULTIPART_UNMATCHED_BOUNDARY to 2 for every well-formed body that
ends with its final boundary and to 1 only for a suspicious one, and its
own modsecurity.conf-recommended therefore tests "@eq 1". lss used the
"!@eq 0" of ModSecurity 2, so nginx turned away every multipart upload:
file uploads, forms with files and the conversion API alike. nginx now
gets "@eq 1"; Apache runs ModSecurity 2, which only ever sets 1, and
keeps the strict test.

Refs #8

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: VectoDE <system.bot.discord.utility@gmail.com>
…profiles

The figures come from the RESOURCES lines of the green apps, profile and
container jobs of runs 38840181620, 38849074335 and 38863689930: the
largest value across the seven systems (Rocky 9 outliers aside, as for
the other tables), rounded up, with the dnf peak shown apart where the
RHEL family needs clearly more. The note says where the apps are
measured and what "while running" means for a stack.

Refs #8

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: VectoDE <system.bot.discord.utility@gmail.com>
In the profile all, behind nginx with ModSecurity, the Matomo installer
stopped after two seconds with "see above" and nothing above: a GET step
had failed inside curl, whose error -s hides. Each step now logs its
curl exit status and error text, the failure adds the latest ModSecurity
rule hits, and a step may take five minutes, since creating the tables
on a server with sixty modules can outlast the 30 s of app_get.

Refs #8

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: VectoDE <system.bot.discord.utility@gmail.com>
The containers jobs with firewall and k3s fail now and then on the
second install with "ERROR: initcaps" (debian-13 and ubuntu-24.04, runs
38826334577 and 38874654698): before each change ufw tests iptables with
a chain of its own, and k3s rewriting its chains at the same moment makes
that test fail ("CHAIN_DEL failed: No such file or directory"). A ufw
function now repeats such a command up to three times, two seconds
apart; every other failure and all output pass through unchanged. The
function hides the binary from command -v, so the checks for an
installed ufw use type -P.

Refs #8

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: VectoDE <system.bot.discord.utility@gmail.com>
…53110 off

With the diagnostics from the last run the profile all named its cause:
the system check page of the Matomo installer contains PHP code, CRS rule
953110 ("PHP source code leakage") raised the outbound score over the
threshold, and ModSecurity cut the HTTP/2 answer off (curl 92).

web_waf_exclude NAME "RULE_ID..." DOMAIN... records, per route, which
CRS rules ModSecurity skips for the route's host names. The record is
data in /etc/lss/waf, so it applies whether the modsecurity module comes
before or after the app: the module renders the records into sites.conf
(host-matched phase-1 rules with ctl:ruleRemoveById) and includes it
before the CRS rules, and a new record rewrites that file and reloads
the web server. Removing the route drops its record. Matomo excludes
953110 for its own host names only; every other rule stays in force.

Refs #8

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: VectoDE <system.bot.discord.utility@gmail.com>
The fully green run 38880612325 measured some apps a little above the
figures from the earlier runs (Forgejo and Matomo peaks on the RHEL
family, WordPress and Forgejo times on Rocky 9). The table now takes the
largest value of all four green runs (38840181620, 38849074335,
38863689930, 38880612325), so it holds for every measurement so far.

Refs #8

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: VectoDE <system.bot.discord.utility@gmail.com>
The Postfix master and Dovecot start their workers in SELinux domains of
their own. Instead of turning NoNewPrivileges off for them, a policy
module (lss_nnp_postfix, lss_nnp_dovecot) allows exactly the domain
changes the policy defines for the programs of their daemon directories
(selinuxexeccon). It is loaded only when missing or changed and removed
again with the module.

Refs #8

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: VectoDE <system.bot.discord.utility@gmail.com>
The arm64 runners have no KVM. Docker runs inside the privileged systemd
test containers, its storage on volumes, the netfilter modules loaded on
the runner. The docker module expects AppArmor for its containers only
where securityfs shows it. The office profile also runs on Rocky Linux 9.

Refs #8

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: VectoDE <system.bot.discord.utility@gmail.com>
The policy module of the Dovecot workers was named like the one that lets
systemd start dovecot_t (lss_nnp_dovecot) and replaced it; Dovecot then
ran unconfined and Postfix could not reach its sockets. The modules of
svc_selinux_spawner are lss_spawn_NAME now. On the RHEL family
postfix.service also runs restorecon and newaliases (sendmail) from
init_t before the start; lss_spawn_postfix_start allows their domains.

Refs #8

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: VectoDE <system.bot.discord.utility@gmail.com>
profile all took 2386 s of the 2400 s limit in the last green run and
ran over it in the next one.

Refs #8

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: VectoDE <system.bot.discord.utility@gmail.com>
Comment thread tests/unit/system.bats Fixed
The second run of the office profile sorted the rules of
lss_spawn_postfix in another order and rewrote the module. The sort
uses the C locale now; the stub of a unit test has its default case.

Refs #8

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: VectoDE <system.bot.discord.utility@gmail.com>
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants