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>
- 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>
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>
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>
Stalwart server v1.0.0 and WebUI v2.0.0 only work as a pair:
- WebUI v2.0.0 declares minServerVersion 1.0.0.
- Server v1.0.0 rejects a bundle without the manifest that only WebUI 2.x
ships ("Application bundle does not contain a manifest").
The separate bumps in #1227 (server only) and #1223 (WebUI only) were
each broken on their own, so this commit carries both.
WebUI v2.0.0 also dropped the hardcoded 'stalwart-webui' fallback client
id. Without an oauth-client-id meta tag (or CIMD support at the IdP) it
shows "Sign-in is not configured" and never redirects. That is the
test-sso.js failure in Jenkins#0 of #1223. Set the Application record's
oauthClientId to the configured Keycloak client so the server injects the
meta tag on unpack.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Stalwart bans an address for good once it passes a failure rate: the four
*BanPeriod fields of its Security settings are null, and block_ip stores a
ban without a period with expires_at.unwrap_or(u64::MAX). Many clients reach
Stalwart through one address (the reverse proxy, published ports, Tor, the
swarm ingress), so one burst of failed logins locked that path out until
someone deleted the BlockedIp entry by hand.
The role now sets authBanPeriod, abuseBanPeriod, loiterBanPeriod and
scanBanPeriod to services.stalwart.ban_period_seconds, one hour by default,
and reloads the settings, as Stalwart's own security test does after it
changes a ban period. It reads the settings first and leaves them alone when
they already match, so a redeploy changes nothing.
Closes#916
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Two findings of the CodeRabbit review of #869.
jmap_call.yml counted notCreated and notUpdated as a refused call, but
not notDestroyed. A destroy that Stalwart rejected therefore passed, and
the post-load flush cleared stalwart_app_password_stale while the retired
app password stayed valid. A notDestroyed entry now fails the call,
except notFound, where the password is already gone. The password keeps
its retired description, so the next deploy collects it again and
retries the destroy.
tracing.yml took any Stdout tracer with an include policy for the ban
tracer, so one that includes other events kept the role from creating
its own and bans stayed off the container output. The selector now also
requires every event of STALWART_BAN_TRACER.
Verified with the real task files, a stubbed container command and
canned JMAP responses: a forbidden destroy now ends with failed=1, while
notFound and a clean destroy pass; with an unrelated include tracer the
role makes a second JMAP call that creates the ban tracer, with a
complete one it does not. The previous files pass the forbidden destroy
and skip the creation in both tracer cases.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Stalwart writes its log to a dated file in the data volume, which the rescue
diagnostics do not collect, so the failure of Baserow#0 in #869 showed the
banned address but not which account and listener tripped the ban. A Stdout
tracer limited to the four ban events and auth.failed now puts those lines
into `docker logs`, which the rescue already captures.
Verified on Stalwart v0.16.25: the tracer is accepted with these event ids and
buffered false, and after a settings reload a ban shows up on the container
output as "Banned due to authentication errors (security.authentication-ban)"
with listenerId, localPort, remoteIp and accountName.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The recover drill between the two CI passes restores /var/lib/infinito/secrets
with rsync --delete, so PASS 2 starts with an empty token store and the role
replaces the app password of every bot. It destroyed the old one at once,
while alertmanager, OpenWebUI and Baserow, rendered in PASS 1, kept sending
with it until the preload rendered them again 15 to 25 minutes later.
Stalwart counts every refused login against the client's address, which for
host-originated and cross-bridge clients is the Docker gateway 192.168.200.1.
After 100 refusals it banned that address for good, openresty's upstream,
alertmanager and the msmtp health check had their connections reset, and
Baserow#0 of #869 failed with exit 76. The rescue artifact shows 31 of those
refusals, all from alertmanager; Stalwart's own log, which names the account
behind the ban, was not collected.
A replaced app password is now renamed to
STALWART_BOT_APP_PASSWORD_RETIRED_DESCRIPTION once the new one is stored, and
destroyed in the service-loader post-load flush next to the deferred OIDC
restart, after the preload has rendered every consumer with the new one. Each
deploy also collects retired passwords an earlier deploy left behind, so a
deploy that stops before the flush neither replaces the password a second
time nor drops the one it handed out.
Verified: on Stalwart v0.16.25, 100 refused logins from one container address
banned it permanently (BlockedIp, reason authFailure, expiresAt null) and new
connections from it were reset, as in the CI log, and an app password's
description can be updated in place. A playbook running the changed task
files with JMAP stubbed (ansible-core 2.21.4) shows that a PASS 2 replacement
destroys the old ids only in the flush; that a deploy failing after the
Stalwart preload destroys nothing, while the next one mints nothing and
destroys only the retired ids; and that outside the service loader each bot's
old password is destroyed right after its new one is stored.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Under SSO the role tried to let applications relay without SMTP AUTH by
widening the relay rule to the private networks. The rule compared the
peer address to the text of a CIDR, which never matches, so an
application could reach local mailboxes but got "550 5.1.2 Relay not
allowed" for every external recipient. Repairing the comparison would
have opened a relay, because published ports, Tor and the swarm mesh all
hand outside clients an address inside those networks.
Stalwart checks app passwords before it asks the external directory, so
a bot can authenticate while the directory is OIDC. With SSO enabled the
role now creates one app password per bot, acting for that account as
the recovery admin, and stores it in the token store the SMTP password
is already read from. A bot gets a new one when its entry is deleted in
Stalwart or its token is removed.
The relay rule is back to authenticated sessions only. The lookup loses
its SSO relay branch and the role its submission_via_relay flag, so an
SSO deployment submits on 465 with AUTH like every other one.
The role's CLI test now fails when an unauthenticated session is given
an external recipient, or when the bot credential is refused one.
Closes#793
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Formatter output only: the parsed YAML and the Python AST of every touched file equal their previous versions.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>