Commit Graph
2780 Commits
Author SHA1 Message Date
kevinveenbirkenbach cc588d7950 Merge remote-tracking branch 'origin/main' into fix/worktree/swarm-lab-subnet-overflow 2026-10-29 01:49:29 +01:00
kevinveenbirkenbachandClaude Opus 5.5 551ee32291 style: apply the autoformat output to two files on main
`make autoformat` rewrites roles/web-app-seaweedfs/vars/main.yml and
tests/unit/python/roles/web-app-stalwart/test_store_guard.py on every
checkout of main, so every quality gate on every branch leaves them as
unstaged changes, and the gate lints a tree the commit does not carry.

The YAML change folds one line of the `>-` scalar
SEAWEEDFS_S3_ADMIN_CURL; the parsed value is identical. The Python
change is ruff's line wrapping only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 01:41:51 +01:00
kevinveenbirkenbachandClaude Opus 5.5 191afe48b2 fix(mail): harden the DMARC rua checks after review
The Mailu assert accepted any non-empty forward on the rua user, so a
forward to a user without a mailbox let the deploy publish an rua address
whose alias delivers nowhere. A forward now satisfies the assert only when
its target carries a mailbox account.

production_facts.py let a later duplicate tag overwrite an earlier one, so
a record with two rua tags passed although receivers discard it (DKIM
tag-list rules, adopted by RFC 7489). dmarc_tags now raises on a duplicate
tag and check_dmarc reports the record as invalid. The rua comparison also
took the raw URI text, so a percent-encoded address or one followed by
mailto header fields never matched; the address is now cut at '?' and
percent-decoded after the size suffix is removed (RFC 6068).

Refs #1125

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 01:17:51 +01:00
kevinveenbirkenbachandClaude Opus 5.5 8132fed660 fix(worktree): move the swarm lab prefix out of the Tor virtual range
a7685e3b21 derived the worktree lab subnet as 10.244.<slot>.0/24, which
lies inside NETWORK_TOR_VIRTUAL_CIDR (10.240.0.0/12). On a swarm with
svc-net-tor the nft output chain redirects every TCP connection to that
range into Tor's TransPort, so the nodes could not reach each other: in
the slot-114 worktree `curl http://10.244.114.10:5000/v2/` timed out on
the manager itself and svc-prx-openresty died pushing to the stack
registry with Client.Timeout.

The prefix is now 10.160.<slot>, outside every range the platform
reserves in group_vars/all/08_networks.yml.

The lint now reads the reserved ranges from that file instead of a
hardcoded list: NETWORK_TOR_VIRTUAL_CIDR, NETWORK_MESH_POOL,
NETWORK_DOCKER_ADDRESS_POOLS, NETWORK_NESTED_DOCKER_ADDRESS_POOLS and
NETWORK_NESTED_DOCKER_BIP, plus the primary lab subnet and the worktree
INFINITO_SUBNET range. Against the 10.244 prefix it fails every slot on
the Tor range.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 01:03:04 +01:00
kevinveenbirkenbachandClaude Opus 5.5 d721e887a5 fix(web-app-dashboard): run inline scripts by hash instead of 'unsafe-inline'
The dashboard sent script-src 'unsafe-inline' from two sources. Its own
meta/csp.yml granted it to script-src-attr, and build_csp_header adds it
to script-src-elem and script-src-attr for every app with
services.logout.enabled. Both reach script-src through the family union,
and the token in script-src-elem also closes the hash gate, so the
hashes that sys-front-inj-javascript, -dashboard, -logout and -matomo
register for their inline snippets were discarded.

build_csp_header now skips the logout grant on a directive whose
meta/csp.yml sets unsafe-inline: false. Only web-app-dashboard sets it:
on script-src and script-src-elem. Its injected snippets now run by hash
and the logout one-liner is hashed like the others. A render of
build_csp_header for every role, variant, logout on/off and tor on/off
(1712 headers) differs from origin/main only in the 8 web-app-dashboard
headers; the 86 other roles that declare a logout service render
byte-identical policies.

script-src-attr keeps 'unsafe-inline': port-ui 2.2.2 still renders
inline event handlers (menu onclick entries, openDynamicPopup,
closeAllModals, the card image onerror). It goes once the port-ui
release without inline handlers is pinned and header.yml.j2 moves from
onclick to action.

The dashboard Playwright spec asserts that script-src and script-src-elem
carry no 'unsafe-inline' on the landing page, next to its existing check
for CSP violations.

Refs #1122

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 00:27:49 +01:00
kevinveenbirkenbachandClaude Opus 5.5 3c743736a8 feat(sys-ctl-hlth-runtime): alert on EOL or unpatched PHP and missing PHP hardening
Answering "which deployed containers run a vulnerable PHP?" after
CVE-2026-109618/109619 took a manual inventory of registry metadata, and
nothing noticed a hardening ini that an image never loads.

- New role sys-ctl-hlth-runtime, routed through sys-svc-compose next to
  sys-ctl-hlth-container. A daily timer runs `php` in every running
  container and fails, alerting through sys-ctl-alm-compose, when PHP is
  below the floor of its line or on a line older than every floor (7.4,
  8.1). Every service that mounts php_hardening in meta/volumes.yml fails
  while expose_php or session.upload_progress.enabled is on; other PHP
  containers of such a stack are listed as info. Compose and swarm
  containers are matched by their project/stack and service labels. The
  check is not run at deploy time, so a finding never fails a deploy.
- The floors (8.2.35, 8.3.36, 8.4.27, 8.5.12) live once, in
  HEALTH_RUNTIME_FLOORS of the role's vars/main.yml.
- `make security-runtimes` lists the PHP of every pinned role image
  without pulling: Docker Hub tags through the Hub API, which no pull rate
  limit meters, other registries through the OCI config blob (new
  registry.config_blob, which the healthcheck probe now reuses).
  PHP_VERSION, distro PHP packages and a PHP_BASE build argument count. It
  exits 1 on an image below its floor or one the registry did not answer.
  Today it flags nfrastack/fusiondirectory:1.5 (line 7.4) and
  shopware/docker-base:8.3 (8.3.25).

The role TODO.md keeps the follow-ups: further runtimes, the split web
SAPI of web-app-lam, a scheduled CI run of the report and the CVE scan.

Refs #1127

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 00:12:25 +01:00
kevinveenbirkenbachandClaude Opus 5.5 c8c3f96bf9 feat(web-app-keycloak): serve /admin only to declared source networks
The Keycloak vhost proxied every path to Keycloak, so the admin console
and the admin REST API under /admin/realms answered any client on the
internet (observed on auth.veen.world and auth.infinito.nexus), and
sys-svc-proxy had no way to limit a path by source address.

services.keycloak.admin.allowed_networks now takes the networks the
operator declares in the inventory. When the list is non-empty, the
vhost gets its own `location ^~ /admin`, rendered through the shared
html.conf.j2 like `/`, with one `allow` per entry and `deny all`. The
`^~` modifier keeps a regex location from taking an admin path over.
The OIDC endpoints under /realms/ and the assets under /resources/ stay
public. The role ships the list empty and adds no network of its own,
so an inventory that declares nothing renders today's vhost unchanged.
kc_admin_networks rejects anything but a list of addresses or CIDR
blocks without host bits, so a typo fails the render instead of the
nginx reload.

The deploy's own admin calls do not cross the vhost: every kcadm.sh
login, including web-app-opentalk's and web-app-mattermost's, runs
inside the Keycloak container against a loopback URL, and a unit test
now pins that. The README names the callers that do cross it (the
OpenTalk controller's user search, onion visitors, the Playwright
suites) and so need an entry. The TODO.md item is dropped.

Closes #1121

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 23:06:21 +01:00
kevinveenbirkenbachandClaude Opus 5.5 2d0016d27b fix(svc-net-tor): let a port probe attempt outlast Tor's SocksTimeout
The onion port probe gave every SOCKS connect 60s, half of Tor's
SocksTimeout (120s by default; torrc does not set it), although its own
docstring argued that abandoning an attempt before SocksTimeout is
destructive. Every attempt authenticates with fresh credentials, so Tor
builds a whole new rendezvous for it and keeps working on it until
SocksTimeout. The probe walked away first and called the port dead. The
other raw onion connects in the repository, the svc-db-* CLI tests and
test-e2e-playwright's wait_onion_reachable.sh, wait 120s per attempt.

Run 40128751852 shows how often an attempt needs more than that. Of 178
svc-net-tor CLI runs in its 89 compose onion jobs, three lost their
first attempt to the 60s timeout and connected on the second within
seconds (Shopware#1, Hugo#1, n8n#1; the CLI test took 77-79s against
the usual 1-16s), and Hugo#1's other run took 40s on a single attempt. When
the stall outlasts the 70s until the second attempt, the probe fails
while the HTTP probe reaches the same onion port seconds earlier:
OpenBao#1 (run 39166850273, job 118050491920, the original failure of
#1048) and Socialhome#1 (run 40088473634, job 121019758461). In the
Socialhome job tor.log holds no line for either attempt, because Tor
never reached its own verdict.

ONION_PORT_TIMEOUT is now 130: SocksTimeout plus room for Tor's
once-per-second expiry sweep, so an attempt ends with Tor's own answer
(reply 0x06, and a "Giving up" notice in tor.log, which the rescue
artifact captures) instead of the probe's. The second attempt now starts
at 140s and ends at 270s, so the probe rides out every stall the old
budget did, and any stall shorter than about 260s instead of about 120s.
A green run still costs one connect per port, and a refused or unmapped
port still fails at once. A unit test pins the window above Tor's
SocksTimeout and fails as soon as torrc sets its own.

That doubles what a dead port costs, and meta/tests.yml caps the whole
CLI test. The largest compose onion deploy in run 40128751852 probes 19
domains and 5 ports; with everything dead that is 19 x 350 + 5 x 260 +
10 = 7960s, beyond the 7200s that 069df39027 set for the same reason.
9000 keeps the full failure output for up to 22 domains.

Closes #1214

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 22:39:00 +01:00
kevinveenbirkenbachandClaude Opus 5.5 a7685e3b21 fix(worktree): derive the swarm lab subnet without an octet overflow
scripts/system/worktree/up.sh pinned INFINITO_SWARM_LAB_SUBNET to
192.168.$((244 + slot)).0/24. From slot 12 on that third octet exceeds
255, so every worktree past slot 11 carries an invalid subnet
(192.168.358.0/24 for slot 114), and make swarm-zombie dies at the lab
network creation with `invalid subnet: netip.ParsePrefix(...)`.

The lab prefix is now 10.244.<slot>, valid and distinct for every slot
up to max_slot 255, and outside both the primary lab subnet
192.168.244.0/24 and the 172.30.0.0/16 worktree subnets. The host
daemon allocates nothing from 10.0.0.0/8.

A new lint renders the prefix for every slot and fails on an invalid,
repeated or overlapping subnet; against the old derivation it fails 247
of 255 slots.

Worktrees that were pinned before this fix keep their custom.env; the
ones at slot 12 or above need their INFINITO_SWARM_*_IP and
INFINITO_SWARM_LAB_SUBNET pins rewritten to 10.244.<slot>.x and a
make dotenv before they can run swarm.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 22:38:54 +01:00
kevinveenbirkenbachandClaude Opus 5.5 1f8fc336e6 feat(proxy): per-app nosniff, Referrer-Policy, Permissions-Policy and HSTS directives
#1115 raised HSTS to one year but set no X-Content-Type-Options,
Referrer-Policy, Permissions-Policy or HSTS includeSubDomains/preload,
because none of them is safe globally: nosniff lets ORB block no-cors
responses typed text/html, includeSubDomains on the apex covers hosts
the platform does not serve, and a server-level add_header is dropped
in every location that sets its own.

WEBSERVER_SECURITY_HEADERS (group_vars/all/04_webserver.yml) now holds
the platform values, all opt-in: nosniff false, no Referrer-Policy, no
Permissions-Policy, HSTS max-age one year without includeSubDomains or
preload. A role overrides any key under security_headers in its
meta/server.yml, an inventory under
applications.<role>.server.security_headers.

The new security_headers filter merges both, rejects unknown keys,
wrong types, unknown Referrer-Policy tokens, non-printable header values
and preload without includeSubDomains or below a one-year max-age, and
composes the HSTS value.

- nosniff, Referrer-Policy and Permissions-Policy are set in the Lua
  header filter of content_security_policy.conf.j2, next to CSP, so a
  location's own add_header cannot drop them. Referrer-Policy is only
  set when the upstream sends none; Permissions-Policy only on HTML
  documents.
- HSTS stays the single server-level add_header in nginx_ssl_header.j2,
  now with the per-app value. A second emitter in Lua would duplicate
  the header. It stays inside the TLS branch, which onion vhosts never
  enter, so an onion vhost gets no HSTS even when its app opts in.

No role opts in yet, so rendered vhosts are unchanged.

Closes #1123

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 21:38:38 +01:00
kevinveenbirkenbachandClaude Opus 5.5 c4d0283300 feat(mail): publish a DMARC aggregate report address (rua)
Both mail providers published "v=DMARC1; p=reject; ruf=...; adkim=s;
aspf=s" without a rua tag, so the domain owner received no aggregate
reports and could not see who sends in the domain's name or whether
legitimate mail fails alignment.

services.<entity>.dmarc.rua_user in web-app-stalwart and web-app-mailu
now names the users-catalogue key whose mailbox receives the aggregate
reports. It is set to administrator, the one user every deployment
provisions with a mailbox (user-administrator), and an inventory may
name another key. The address is built as <username>@<mail domain>,
which is the exact account or alias the provider creates, so the record
never points at an address the server does not serve. An assert in the
DNS task fails the deploy before any record is written when the user
carries no mailbox account (Stalwart), or neither a mailbox account nor
a forward alias (Mailu), so reports cannot bounce. Mailu's DMARC_RUA
env, which only feeds the record its admin UI suggests, now carries the
same local part instead of the unprovisioned "admin".

production_facts.py takes a required --dmarc-rua. check_dmarc parses
the record's tags and fails unless rua names that mailbox (size
suffixes and several URIs are accepted). The policy check now reads the
p tag instead of matching the substring "p=", which "sp=" also matched.

Closes #1125

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 20:56:40 +01:00
kevinveenbirkenbach 939513219d Merge pull request #1227 from infinito-nexus/update/docker-image-versions-web-app-stalwart-20261026-f99d2b6
update(images): update Docker image versions (web-app-stalwart)
2026-10-28 16:15:36 +01:00
kevinveenbirkenbachandClaude Opus 5.5 4d9c3c1cfc refactor(web-app-stalwart): group the store SQL files and use the vars SPOT constant
- test_prefix_grouped_files: files/sql/store_database.sql and
  store_layout.sql become files/sql/store/database.sql and layout.sql.
- test_file_spot: test_store_guard.py builds the vars path with
  ROLE_FILE_VARS_MAIN instead of a hard-coded "vars" / "main.yml" chain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 03:46:55 +01:00
kevinveenbirkenbach 66c120c613 Merge branch 'main' into update/docker-image-versions-web-app-stalwart-20261026-f99d2b6 2026-10-27 17:45:31 +01:00
kevinveenbirkenbachandClaude Opus 5.5 47060a72d3 fix(web-app-stalwart): register the WebUI OAuth client and harden the 0.16 store guard
Follow-up to the v1.0.0 port, closing holes a review and a local run found:

- WebUI sign-in: WebUI v2 sends an http:// client-metadata-document id
  on the onion origin, which Stalwart v1.0.0 rejects (unknown http ids
  get no CIMD fallback, trusted hosts require TLS). The recovery admin
  under SSO hit the same wall with the bare id 'stalwart-webui'. Register
  a public first-party x:OAuthClient 'stalwart-webui' with the WebUI's
  callback and login URIs on the served origin, and always inject it as
  the Application's oauthClientId. Native sign-in now shows Stalwart's
  consent step; test-login-native.js clicks it and also runs under SSO.
- Store guard: the previous guard only checked a running 0.16 server; a
  local run showed v1 writing into a 0.16 store whose server was stopped.
  The guard now reads the store whenever one exists (shared: pg_database,
  dedicated: volume) and fails on the 0.16 bigint layout. A host marker
  written at the end of a successful v1 deploy lets restores and
  redeploys with a stopped dedicated database pass; without the marker,
  a stopped dedicated database fails with an explicit message instead of
  a masked psql error. Queries retry while the database recovers.
- README: WebUI sign-in, guard semantics, restore and redeploy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:45:31 +01:00
kevinveenbirkenbach b8846d9942 Merge branch 'main' into fix/web-app-seaweedfs/anonymous-deny 2026-10-27 17:45:23 +01:00
kevinveenbirkenbachandClaude Opus 5.5 d18feb9dae fix(web-app-seaweedfs): converge the anonymous-read deny on every provider deploy
Follow-up to the bucket-policy deny, closing holes two reviews found:

- Scope: the policy was only applied during a consumer's own deploy, so a
  provider-only or partial deploy left other buckets without it. Every
  provider deploy now converges all existing consumer buckets, and a
  consumer's provisioning applies it right after creating the bucket.
- Ownership: our statement is identified by Sid DenyAnonymousRead; a
  public bucket loses only that statement, operator statements survive.
- Errors: only a 404 NoSuchBucketPolicy means "no policy"; any other
  status fails the read loudly.
- Credentials: curl expands the admin credential itself via --variable
  instead of --user "$VAR", which put it on curl's argv and in the
  host's ps.
- Curl runs through the repository's curl filter with timeouts, the
  read-only tasks run in check mode, and bucket names are parsed exactly
  instead of by substring.
- README: corrected embedded-mode authentication and documented the
  convergence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:45:22 +01:00
kevinveenbirkenbachandClaude Opus 5.5 3b4ac40df5 fix(web-app-seaweedfs): deny unsigned reads on non-public consumer buckets
SeaweedFS 4.49+ honours a public-read object or bucket ACL on an unsigned
GET/HEAD/list even in a bucket the platform treats as private
(seaweedfs/seaweedfs#11605). Since consumers hold WriteAcp, an app could
expose objects through the public S3 API domain just by sending
x-amz-acl: public-read.

Put a bucket policy on every non-public consumer bucket that denies
s3:Get* and s3:List* to principal "*" when aws:username is null. SeaweedFS
evaluates that deny before any AllUsers grant; signed and presigned
requests carry an IAM user ARN, so aws:username is set and they are not
affected. Public buckets keep their anonymous Read grant, and a bucket
that turns public gets our deny lifted again. Embedded sidecars publish
no port and are left as they are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 13:46:17 +01:00
kevinveenbirkenbachandClaude Opus 5.5 c75648ccdd fix(web-app-stalwart): port the role to server v1.0.0 and guard 0.16 stores
Stalwart v1.0.0 rejects unknown JMAP properties ("Invalid property" ->
notCreated/notUpdated), validates OIDC tokens more strictly and refuses to
open a data store from before 1.0. Port the role and protect existing
installs:

- Milter: hostname/timeoutConnect/timeoutCommand/timeoutData become
  host/connectTimeout/commandTimeout/dataTimeout; the existing milter is
  looked up by host.
- Onion relay route: address becomes host.
- OIDC directory: requireAudience is gone. Set skipAudienceValidation and
  unscopedTokens=interoperable on create and update, so Keycloak tokens
  (no Stalwart audience, no mail scope) keep working for the WebUI and
  Roundcube XOAUTH2.
- WebUI: send oauthClientId only with SSO enabled; without SSO the WebUI
  falls back to its own client metadata document.
- Store guard: before anything changes, a running Stalwart whose table u
  still has the 0.16 bigint layout stops the deploy and points to the
  stalwart-migrate steps, which the README now documents under
  "Upgrading from 0.16".
- Updater: pin the WebUI git_tags source to v2.* to match server 1.x.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 13:46:08 +01:00
kevinveenbirkenbachandClaude Opus 5.5 ec7d4c9042 fix(web-app-seaweedfs): grant consumers WriteAcp on their bucket
SeaweedFS 4.49 (seaweedfs/seaweedfs#11592) authorizes a PutObject that
carries x-amz-acl as s3:PutObjectAcl as well. The consumer identities
only held Read/Write/List/Tagging, so every app that sends an ACL on
upload (Nextcloud, Fider and Pixelfed with private, Mastodon, PeerTube,
BookWyrm and Baserow with public-read) got AccessDenied after the 4.48 -> 4.51
bump in #1226. Main run 40401200083 failed Fider#0, BookWyrm#0,
Baserow#0, Pixelfed#0 and Nextcloud#0/#4/#8 on it.

Add WriteAcp to the s3.json identities and to the sidecar s3.configure
grant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 09:28:11 +01:00
kevinveenbirkenbach 62164178a2 Merge pull request #1204 from infinito-nexus/fix/web-app-matrix/probe-and-federation-delegation
fix(web-app-matrix): probe only served domains and serve the federation delegation
2026-10-24 13:04:32 +02:00
kevinveenbirkenbachandClaude Opus 5.5 bfd6f7c9b8 fix(install-lint): give chrome-headless-shell a font on dnf, yum and pacman
On a fresh Fedora dev container lint-mermaid failed most files with
ConnectionClosedError: the browser aborted with
`FATAL:third_party/skia/src/ports/SkFontMgr_FontConfigInterface.cpp:163]
Not implemented.` in its font fallback, because the host had no font at
all. install-lint provisions the browser's libraries by soname on dnf,
yum and pacman, and a font is not a soname; apt-get gets fonts through
puppeteer's --install-deps.

It looked load-dependent because lint-packages installs rpmlint in the
same parallel phase, which pulls fontconfig and Noto Sans: renders
crashed until that transaction landed, and every later run was green.
With every font removed, lint-mermaid alone fails 274 of 289 files the
same way.

ensure() now installs the distribution's font provide when
/usr/share/fonts holds no file (`font(:lang=en)` on dnf and yum,
`ttf-font` on pacman), and browser_installed() counts the font, so the
install-lint stamp is not fresh on a font-less host.

Closes #1154

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 12:59:42 +02:00
kevinveenbirkenbachandClaude Opus 5.5 7a82a92e84 fix(web-app-prometheus): probe only the domains an app serves itself
#1180 gave web-app-matrix a canonical `jitsi` domain for its own Jitsi
and listed it under `services.jitsi.domains`. When Jitsi is shared or
disabled nothing serves that domain, and the blackbox healthz job of
web-app-prometheus probed every canonical domain, so the probe of
https://jitsi.matrix.<domain>/healthz/ready failed with "tls:
unrecognized name" and `prometheus scrape: every consumer role reports
up=1 and passes its probe` went red (main run 40017166046: Matrix#2,
Nextcloud#12).

web_health_expectations already skipped such keys. The rule now lives
in utils/domains/unserved.py: a key claimed by a service entity that is
disabled, or enabled but shared, is not served by that app. The health
filter uses it unchanged, and prometheus.yml.j2 filters its targets
through the new `served_domains` filter, which also drops the
`<key>_onion` mirror of a skipped key.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 11:44:14 +02:00
kevinveenbirkenbach 0bca4bcd7f Merge remote-tracking branch 'origin/main' into fix/web-app-matrix/ansible-flavor-unwired-services/1165 2026-10-23 18:18:31 +02:00
kevinveenbirkenbachandClaude Opus 5.5 25ec8b0c59 fix(sys-ctl-hlth-webserver): skip a consumer's domains for a shared service
A service entity can list canonical domain keys under `domains`; the
web health expectations skipped them only while the service was
disabled. A consumer whose service is enabled but shared does not serve
its own domain for it either, since the provider serves the service on
its own domain, so the probe failed with an unrecognised TLS name.
Domains of an enabled and shared service are now skipped as well.

The only other entity with `domains` (web-app-seaweedfs frontend) is
never shared, so its behaviour is unchanged.

Refs #1165

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 17:16:19 +02:00
kevinveenbirkenbach e53751402f Merge pull request #1114 from infinito-nexus/fix/security/swarm-ingress-guard/1089
fix(svc-swarm-node): drop outside traffic to non-public routing-mesh ports
2026-10-23 01:03:04 +02:00
kevinveenbirkenbachandClaude Opus 5.5 8567cb6992 fix(svc-opt-ssd-hdd): recognise database images behind registry, namespace and custom builds
is_database() compared the whole repository part of the image reference
against postgres, mariadb, redis, memcached and mongo. Every reference
with a registry or namespace prefix, every derivative image and every
locally built `<engine>_custom` image therefore counted as application
data and went to the mass storage. On a local stack the central
databases report `postgres_custom:18` and `openldap_custom:13.7-slim`,
both classified False and moved to the HDD.

The check now takes the last path component of the reference, drops
digest, tag and a `_custom` suffix, and compares it against the engines
the svc-db-* providers and the app-local databases run: elasticsearch,
mariadb, memcached, mongo, mysql, openldap, opensearch, postgis,
postgres, qdrant, rabbitmq, redis, typesense, valkey.

Volumes the script already symlinked stay where they are; a database
volume misplaced on the HDD has to be moved back by hand.

Fixes #1163

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 21:02:06 +02:00
kevinveenbirkenbach b40c05dbbf Merge pull request #1177 from infinito-nexus/fix/install/shfmt-test-docstring
fix(install): drop the shfmt docstrings that misstate ensure()
2026-10-22 20:41:38 +02:00
kevinveenbirkenbachandClaude Opus 5.5 091c58307d fix(install): drop the shfmt docstrings that misstate ensure()
Three docstrings from #1173 claimed that ensure() leaves only the pinned
release on PATH or replaces whatever version PATH finds. It does neither:
it installs the release into /usr/local/bin and fails unless PATH then
resolves to it, so a distribution copy such as /usr/bin/shfmt stays on
disk behind it.

The test module and ensure() lose their docstrings; the module docstring
of the installer returns to the form its siblings use ("Install hadolint
via GitHub-release prebuilt binary.").

Reported by CodeRabbit on #1173.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 20:37:01 +02:00
kevinveenbirkenbachandClaude Opus 5.5 628e2587b8 fix(database_query): keep psql's command tags out of the returned rows
With `-tA` psql drops headers and the row-count footer but still prints
the command tag of every write statement. A guarded
`UPDATE … RETURNING` that matched nothing therefore came back as
`rows: [['UPDATE 0']]`, and web-app-peertube's oidc/enable.yml, which
judges its change by the row count, reported `changed` and restarted
PeerTube on every deploy after the first.

The PostgreSQL rows flags are now `-qtA`. Probed against a running
postgres container:

    -tA  -> CREATE TABLE / INSERT 0 1 / UPDATE 0 / 3 / UPDATE 1
    -qtA -> 3

SELECT output and the MariaDB path (`--batch --skip-column-names`, no
status line) are unchanged.

Fixes #1164

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 20:36:32 +02:00
kevinveenbirkenbach eeb3305e52 Merge remote-tracking branch 'origin/main' into fix/security/swarm-ingress-guard/1089 2026-10-22 19:17:39 +02:00
kevinveenbirkenbachandClaude Opus 5.5 a9a12da75c fix(install): install the pinned shfmt release on every distro
`shfmt.ensure()` returned as soon as any `shfmt` was on PATH and tried
the distribution package before the release pinned in
roles/sys-lint/meta/services.yml. The Debian dev container therefore
formats with its packaged 3.8.0 (/usr/bin/shfmt), Fedora with its packaged
3.7.0, the CentOS one with the pinned v3.14.1 (/usr/local/bin/shfmt), and
they disagree on
scripts/tests/deploy/swarm/utils/unmount/nfs_mounts.sh: every gate in a
CentOS stack moved the `then` of the heredoc `if` below the terminator.

ensure() now follows ruff's installer: it reads `shfmt --version`, keeps
the binary only when it reports the pinned release, and otherwise installs
the release binary into /usr/local/bin. It fails when PATH still resolves
another version afterwards instead of formatting with it. The system
package path is gone.

nfs_mounts.sh is committed in the pinned release's format (byte-identical
to the v3.14.1 output from the CentOS container), so the first gate on any
distro leaves the tree clean.

The install-lint stamp now also depends on roles/sys-lint/meta/services.yml,
the pin file of actionlint, hadolint and shfmt, so a bumped pin reaches
environments whose stamp predates it; until now only pyproject.toml and
scripts/install/lint.sh invalidated it.

Fixes #1155

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 19:16:22 +02:00
kevinveenbirkenbachandClaude Opus 5.5 93cbd2045e fix(web-app-penpot): pass the administrator password on stdin
manage.py create-profile and update-profile parse their options with
argparse, and `-p <value>` reads a value that starts with `-` as the next
option. A generated password with a leading dash therefore fails both
commands with exit 2 ("argument -p/--password: expected one argument")
before they reach the backend, and the bootstrap task retries until it
gives up. On main this broke the guide run of Penpot#2 in run
39668792538: the Docker events of the rescue snapshot show eight
create-profile execs ending with exit 2 while the backend was healthy
and its prepl received no call.

Without -p, manage.py reads the password with getpass, which falls back
to stdin when the exec has no TTY. Both tasks now feed the password to
`container exec -i` on stdin. This also keeps the password out of the
exec command line, which Docker writes into its event stream and
therefore into the rescue diagnostics artifacts.

The password-quote lint masked only block `stdin: |` / `stdin: >`
values and flagged the inline `stdin: "{{ PENPOT_ADMIN_PASSWORD }}"` as
an unquoted password in a shell command. stdin never reaches a shell, so
the lint now masks every `stdin:` form; an unquoted password in `cmd`
is still reported.

Fixes #1139

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 17:41:43 +02:00
kevinveenbirkenbachandClaude Opus 5.5 b83b361ea9 fix(web-app-bigbluebutton): run BBB v3.0.39 with the shared-notes server
The version-source update in #1146 moved the bigbluebutton/docker ref from
v3.0.23 to v3.0.39. That tag changes three things the role did not
handle, and it exposed a fourth defect in compose_file_args.

1. generate-compose renders etherpad and bbb-pads only behind
   USE_INSECURE_ETHERPAD and otherwise emits bbb-shared-notes-server with
   a `build:` block and no `image:` (upstream adds the image only after
   the tag, in 9a6a9a9a4c). compose_mods drops `build` only where an image
   is set, so the guard "Fail if any service still builds locally" stopped
   every BigBlueButton deploy (main run 39749899667, Nextcloud#11:
   `rc: 0`, `stdout: '    build:'`, bootstrap_compose_project.yml:35).
   meta/services.yml now pins alangecker/bbb-shared-notes-server:v3.0.39,
   compose_mods takes `pinned` (BBB_PINNED_SERVICES, every service the
   role config pins an image for) and drops their `build`, and the
   etherpad leftovers go (ep_redis_publisher patch, etherpad postgres
   entrypoint and initdb hook, etherpad database tasks, ETHERPAD_* env,
   etherpad_api_key secret).

2. The notes server connects to postgres once at start and exits with 2
   when it is not up yet; upstream gives it no restart policy. The
   override sets the role's restart policy, so it retries until postgres
   accepts connections.

3. Greenlight moves from v3.8.0 to v3.9.0.1, which sets
   `config.hosts = URL_HOST`, and the template passes
   `URL_HOST: https://${DOMAIN}`. Rails compares hostnames, so every
   request was blocked (`Blocked hosts: bbb.talk.infinito.test`, 403).
   Greenlight documents URL_HOST as the bare hostname; the override sets
   it to the role domain.

4. compose_file_args decided whether a role ships a compose override by
   templating "{{ application_id | abs_role_path_by_application_id }}"
   through the templar. ansible-core 2.21 does not render an untrusted
   string, so the check saw the literal and always returned False: the CA
   override generation and the pull handler never read BBB's
   compose.override.yml. That was harmless while compose.yml alone was
   valid; with the notes-server image living in the override, `compose
   config` failed ("has neither an image nor a build context"). The check
   now resolves the role path for the application id it is given. BBB is
   the only role that ships an override, so no other role's file list
   changes.

Existing installations drop the etherpad and bbb-pads containers on the
next `compose up --remove-orphans`; the old `etherpad` database stays in
PostgreSQL untouched.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 16:56:48 +02:00
kevinveenbirkenbach cbb29f898d Merge remote-tracking branch 'origin/main' into fix/security/swarm-ingress-guard/1089 2026-10-22 11:22:34 +02:00
kevinveenbirkenbach 11cf141696 Merge pull request #1156 from infinito-nexus/fix/web-app-prometheus/healthz-upstream-strips/1124
fix(proxy): strip upstream headers on every proxying location fragment
2026-10-22 10:42:11 +02:00
kevinveenbirkenbachandClaude Opus 5.5 0986957e52 fix(proxy): strip upstream headers on every proxying location fragment
proxy_hide_header is replaced, not merged, by a location that sets its own,
so upstream_strips.conf.j2 only takes effect where a location includes it.
The health locations of web-app-prometheus (/healthz/ready, /_live_probe),
which every standard vhost includes, and the extra locations of
web-app-bluesky (/config, /ipcc) and web-app-opentalk (/v1/, /signaling,
/livekit) proxied without it, so X-Powered-By and the upstream's
Strict-Transport-Security leaked on those paths.

All seven locations now include the strips. A new lint checks every role
template without a server block, i.e. every fragment the front proxy
renders into a vhost, and fails on a proxying location that lacks them.
Templates with their own server block are app-internal nginx configs
behind such a location and stay out of scope.

Closes #1124

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 10:33:53 +02:00
kevinveenbirkenbachandClaude Opus 5.5 c65c5ce48c fix(update): pin only image tags the registry carries
7e82ba6d71 moved web-app-bigbluebutton's bbb-pads to v1.5.11, the tag
https://raw.githubusercontent.com/bigbluebutton/docker/develop/repos/tags
names for the bbb-pads source. alangecker/bbb-docker-pads has no such
image (Docker Hub: newest v1.5.8), so every BigBlueButton deploy pulls a
missing tag and test-external fails "These pinned Docker images do not
exist in their registry (HTTP 404): web-app-bigbluebutton/bbb-pads".

bbb-pads goes back to v1.5.8. The version-source updater now knows the
image a `version` pin tags (pulled directly or as the base of a custom
build) and withholds a bump when the registry answers 404 for the new
tag; an indeterminate answer does not block it, the same line
test-external draws. Run against main, the updater now reports no
update for bbb-pads instead of v1.5.11.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 10:30:10 +02:00
kevinveenbirkenbachandClaude Opus 5.5 788eabffbf fix(svc-swarm-node): drop outside traffic to non-public routing-mesh ports
In swarm mode `docker stack deploy` ignores the host IP of a short port
mapping, so every `127.0.0.1:<port>:<port>` that container_ports renders for
a `ports.local` entry is published by the ingress routing mesh on every
node, via docker-proxy on 0.0.0.0 and [::] plus DNAT in nat PREROUTING.
Databases, LDAP and application backends answered from outside the cluster.

svc-swarm-node now loads an nftables table through svc-net-firewall. Its
prerouting filter chain (priority mangle, ahead of Docker's dstnat) drops
packets addressed to the node for any ingress-published port that no
application declares under `ports.public`. Loopback, docker0,
docker_gwbridge, the user bridges and the swarm nodes' own addresses stay
admitted. Host-side provisioning over 127.0.0.1, containers that reach a
host port through host-gateway, and node-to-node consumers such as the
registry-cache mirror keep working.

Both sets are read live on the manager (`docker service inspect` for
`.Endpoint.Ports`, `docker node inspect` for `.Status.Addr`), so ephemeral
ingress ports and nodes outside `--limit` are covered. The table is loaded
on a node before it joins, reloaded on every node after the join, and
reloaded after each stack deploy by the sys-svc-compose swarm handler.

Refs #1089

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-21 20:56:29 +02:00
kevinveenbirkenbachandClaude Opus 5.5 d4b3609488 fix(install): refuse the upstream Node.js install on non-Linux hosts
`tarball_name()` picked the archive from `platform.machine()` alone, so
an end-of-life Node.js on Intel macOS would have unpacked a Linux build
into /usr/local, Homebrew's prefix there. The upstream install now
supports Linux only and stops with a message to install a supported
Node.js through the package manager everywhere else.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-21 16:52:56 +02:00
kevinveenbirkenbachandClaude Opus 5.5 30268cf552 fix(install): replace an end-of-life distro Node.js with the current LTS
Debian trixie ships nodejs 20.19, whose release line reached its end of
life on 2026-04-30. `@mermaid-js/mermaid-cli` 12 and its puppeteer
declare `node >=22.13.0`, so `make install-lint` failed in every fresh
Debian stack container with `All providers failed for
chrome-headless-shell` (#1088).

`ensure_node_present()` still installs the distro package first, then
reads the release line of `node` on PATH and looks it up in the
nodejs/Release schedule. A line past its `end` date is replaced by the
newest LTS from nodejs.org/dist/index.json: SHASUMS256.txt is verified
with gpgv against the active Node.js release keys from
nodejs/release-keys, the tarball against its SHA-256 entry, and the
release is unpacked into /usr/local, after which `node` on PATH must be
that release. Nothing pins a major: the next LTS line is picked up the
same way. Ubuntu 26.04's Node.js 22, supported until 2027-04-30, stays.

`ensure_npm_present()` calls it first, so a container that already holds
the end-of-life node is upgraded too rather than skipped because npm
exists. The call runs once per process.

Closes #1088

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-21 16:02:52 +02:00
kevinveenbirkenbachandClaude Opus 5.5 6e776a5780 refactor(python): clear the open CodeQL code-quality alerts
- Declare `__all__` in every package `__init__.py` whose `PROJECT_ROOT`
  is imported by its modules (163 packages), so the constant counts as
  exported; list every public name where the package defines more.
- Empty the 15 package `__init__.py` files whose `PROJECT_ROOT` no module
  imports.
- Declare the public API of `utils.github.variant.axes` in `__all__`,
  re-exports included, instead of silencing them with `noqa: F401`; the
  list matches the one `feature/store-i18n` already carries.
- Use `import unittest.mock as mock` in `test_applications.py`, as the
  rest of the suite does.
- Spell `_NON_PRINTABLE` in `utils/yaml_bootstrap.py` as the equivalent
  BMP-only positive class; it matches exactly the same 2111 code points
  as the old negated class across U+0000..U+10FFFF.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 21:40:58 +02:00
kevinveenbirkenbachandClaude Opus 5.5 d48f8e9897 fix(bcrypt_hash): derive the seeded salt from the seed alone
The seeded salt was the first 16 bytes of sha256(seed + secret). bcrypt
stores its salt in clear inside the hash, so every seeded hash published
a fast sha256 digest of the password under a seed that is public in the
repository (`web-app-wazuh:indexer-admin`). Anyone holding the hash could
test password guesses at sha256 speed instead of paying bcrypt's cost.

The salt is now sha256(seed)[:16]. The hash stays deterministic for one
password, still changes when the password rotates, and its salt carries
nothing computed from the secret. Existing Wazuh deployments get a new
hash once; `securityadmin.sh` re-applies `internal_users.yml` on every
deploy and the hash still verifies the unchanged password.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 21:35:32 +02:00
kevinveenbirkenbachandClaude Opus 5.5 21eba1e88f fix(backup): carry hard links, ACLs and xattrs on every backup and recover hop
baudolo 7.1.x writes volume generations with `rsync -aHAX`, so the
producing host keeps `user.*` extended attributes (OpenCloud keeps its
storage metadata in `user.oc.*`). Every later copy dropped them again,
because none of the other rsync calls passed `-X` (nor `-A`/`-H`):

- NFS export snapshots (svc-bkp-nfs-2-local `script.sh`),
- the backup host's pull (svc-bkp-remote-2-local `pull_specific_host.py`),
- the device sync (svc-bkp-local-2-device `script.py`),
- every recover into a live target (`utils/recovery/base.py`, used by the
  volume, NFS and device recover scripts),
- the `recover full` and `recover remote` tree pulls.

A disaster recovery through the documented chain therefore restored
OpenCloud without `user.oc.*`, silently: the copies succeeded and the
dirval stamp does not cover attributes.

All of these now run `rsync -aHAX` (`-aHAXP` for the device sync). A
target filesystem without xattr support makes rsync exit non-zero as soon
as a source file carries attributes, so the hop fails loudly instead of
dropping them; there is no per-device opt-out. The single-image pull in
`recover remote` keeps `-a`. The provider's ssh-wrapper needs no change:
its glob accepts any server flags and its character guard allows them.

Two tests write a `user.oc.id` attribute and assert it survives the NFS
snapshot and the recover; with the old `-a` both fail with
`OSError: [Errno 61] No data available`.

Closes #1021

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 18:20:46 +02:00
kevinveenbirkenbachandClaude Opus 5.5 310663ae8c feat(compose): recreate the stack network without losing volumes or worktree stacks
When the IPAM config of a compose project's network changes (#999 added
`ip_range`), compose recreates the network on the next `up`. With a
foreign endpoint attached, as every worktree stack is on the primary's
network, compose first stops the project's own containers, then fails
with `error while removing network: ... has active endpoints`, and a
repeated `up` or `up --force-recreate` does not recover. On 2026-10-19
this took the shared registry/package cache and coredns down for every
local session.

`make compose-network-recreate` (alias `i8conere`) runs the sequence that
recovered it: detach every running container of another project from the
network, `docker compose down --remove-orphans` without `-v` so the
caches keep their volumes, the normal `up`, then attach the detached
containers again, which draw addresses from the dynamic pool. If `up`
fails, the still-detached containers are listed instead of reattached.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 13:45:37 +02:00
kevinveenbirkenbachandClaude Opus 5.5 a6c3c1cc91 fix(apparmor): keep docker-default loaded when tearing AppArmor down
`security-apparmor-teardown` ran `aa-teardown`, which unloads every
profile including dockerd's in-memory `docker-default`. dockerd only loads
that profile when it starts, so every container start afterwards that
requests it fails until dockerd restarts. Since #1067 runs the teardown on
the GitHub runners, BuildKit failed each RUN of the swarm node image three
times (run 39332343490, 39 of 40 swarm jobs):

    runc run failed: unable to start container process: error during
    container init: unable to apply apparmor profile: apparmor failed to
    apply profile: write .../attr/apparmor/exec: no such file or directory

The teardown now removes each loaded profile by name through securityfs
and skips `docker-default` and child profiles, which leave with their
parent. Dev hosts gain the same: no dockerd restart is needed after
`make environment-bootstrap`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 12:11:14 +02:00
kevinveenbirkenbachandClaude Opus 5.5 b055eece05 feat(ci): keep GitHub-hosted runner labels current through the updater
The workflows pin `runs-on: ubuntu-NN.NN`, so no job moves to a new Ubuntu
release behind our back, but nothing raised the pin either. A sixth job of
`🔄 Update versions` closes that: `utils/update/runner.py` reads the image
table of actions/runner-images, takes the newest x64 Ubuntu label whose
image carries no preview or beta badge, and raises every older bare
`runs-on: ubuntu-NN.NN` to it; arch-suffixed labels stay untouched. The job
opens the change as one PR over `.github/workflows`, so its CI run tests
the new image before it reaches main.

This commit carries the updater's first result: every job now runs on
ubuntu-26.04 (49 `runs-on`).

actionlint 1.7.12, the newest release, does not know `ubuntu-26.04` yet
(rhysd/actionlint#682, fix proposed in rhysd/actionlint#743), so
`.github/actionlint.yaml` declares the label until a release knows it.

act maps each `ubuntu-NN.NN` label the workflows use to the local platform
image, derived from `.github/workflows`, instead of a fixed list that did
not know ubuntu-26.04.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 16:56:52 +02:00
kevinveenbirkenbachandClaude Opus 5.5 52ad8549e0 fix(ci): pin GitHub-hosted runners to ubuntu-24.04
`ubuntu-latest` moved from ubuntu-24.04 to ubuntu-26.04 on its own
schedule, in the middle of run 39166850273, and every job that landed on
the new image met a different OS: sudo-rs instead of sudo, no ip_tables
loaded, and now the NFS probe-write of svc-storage-nfs-client failing on
26.04 swarm nodes. The same branch went red without a code change.

Every `runs-on` now names `ubuntu-24.04`. GitHub still rebuilds that image
weekly, so patches and tool updates keep arriving; only the release jump
becomes a deliberate change of the label. The CodeQL job drops its dead
swift/macos-latest branch, since no swift language is in its matrix. act
already maps ubuntu-24.04 to the local platform image.

A lint rejects any job whose `runs-on` contains a `*-latest` label.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 15:57:12 +02:00
kevinveenbirkenbach d0698daa5e Merge pull request #414 from pragepani/feature/web-app-wazuh/add-role
feat(web-app-wazuh): add Wazuh SIEM role with OIDC/LDAP RBAC
2026-10-19 14:35:24 +02:00
kevinveenbirkenbach 32216cc199 Merge pull request #999 from infinito-nexus/fix/compose/primary-net-ip-range
fix(compose): keep dynamic addresses off the stack's static IPs
2026-10-19 13:34:52 +02:00