Compose files keep only a short header; the reasoning lives in
docs/install. truenas.compose.yaml is gone (the TrueNAS catalog app
replaces it) and so is compose.prod.yaml (compose.yaml already runs the
published prod image). compose.override.yaml.dist becomes compose.dev.yaml,
passed with -f, so there is no untracked copy that compose loads on its
own. Docs, CODESTYLE, CONTRIBUTING and the entrypoint's error text follow.
Every remaining file resolves to the same `docker compose config` as
before.
Closes#30
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The stock compose file served https://localhost and nothing else. Reached
by address or hostname, Caddy had no certificate to offer and ended the
handshake, which a browser reports as an SSL protocol error with no way
to proceed. A headless server could not be set up without first setting
SERVER_NAME.
The default now answers every name and address with the self-signed
localhost certificate (default_sni/fallback_sni, and `:443` in the default
SERVER_NAME), so the browser shows its usual warning.
Live updates follow: compose.yaml's https://localhost hub address is
treated as the placeholder it is and gives way to the public address
stored during setup. Any other MERCURE_PUBLIC_URL, and any SERVER_NAME an
operator sets, is left alone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Starting plMail took over two minutes on a NAS with spinning disks, and
the log was silent for all of it — between "Statement statistics are
enabled" and the web server's first line. An empty install on the same
box, from the same image, was up in seconds.
The entrypoint ended with two `setfacl -R` passes over var/cache and
var/log. var/cache is part of the image: about 2,200 files. Changing
anything about an image file from inside a container, an ACL included,
makes the storage driver copy the whole file into the container's layer
and flush it. So every new container rewrote its cache one synchronous
file at a time, in every container of the stack at once, on one pool.
Measured in a fresh container on an SSD: 1.2 s for the first pass,
0.03 s for the second. On the warm production container, 0.06 s — which
is why timing it there showed nothing.
It ran only as root, so the catalogue install (uid 568) never paid it.
Nothing needed the entries: nothing runs as www-data, root needs no
grant, and the Dockerfile already makes both directories writable.
This is the third time that block has been the answer to a slow start.
It walked all of var/, then var/cache and var/log, and now nothing.
Also, on the compose files here: the database health check had no
interval, so Docker's first probe came thirty seconds after start and
the web server and workers could not begin before it. Five seconds now,
with retries raised so a database in trouble is called unhealthy after
the same two and a half minutes as before.
The README sends TrueNAS users to the catalogue app instead of walking
them through truenas.compose.yaml. The file stays: installs made from
it are still running.
Not confirmed on the box that reported it; the copy-up cost is measured
locally and inferred there.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nothing here needs root at run time, and a platform that chooses the uid
itself and drops every capability could not start it. Three things were
in the way, found by running it as uid 568 with nothing granted:
frankenphp carried cap_net_bind_service as a file capability, and
a process without that capability may not execute such
a binary at all. Removed: in a container the
unprivileged port range starts at 0 anyway.
var/ cache, log and share were root's. Writable by anyone
now, since the uid is not known when the image is built.
/data, /config root's as well, so the hub could not open its database
on a fresh volume. Same answer.
The entrypoint's setfacl step is skipped for a non-root user, who can
grant nothing and has no name for whoami to print.
Installs that run as root, which is every compose file here, are
unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Packaging plMail for a catalogue that forbids per-app workarounds turned
up four places where the image expected its compose file to compensate.
cookie name Chosen from the public address: the __Secure- name on
https, a bare one otherwise. It used to be prefixed
unless an operator knew to set MERCURE_COOKIE_NAME, and
an install opened on http://ip:port never got a live
update. The hub always reads the bare name and the web
server renames the cookie in the proxy, so the two
cannot disagree after only one of them restarted.
storage dir An absolute APP_STORAGE_DIR inside the project is
accepted and normalised to the relative form, so the
stored attachment paths do not change. One outside it
is refused at start.
hub address MERCURE_UPSTREAM names the container the web server
proxies the hub to, for a stack that does not call it
mercure.
database secret No POSTGRES_PASSWORD is minted, and no password file
written, when DATABASE_URL already carries a password:
that database is somebody else's.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"Restart now" in the admin panel stopped PHP and then hung. Caddy's
default on shutdown is to wait for every open connection, and the web
container proxies the live-update stream, which never closes by itself.
With one browser tab open the process neither served nor exited, and
compose cannot restart a container that has not exited; on TrueNAS the
app sat in "deploying" until it was stopped by hand.
It went unseen because `docker stop` kills the process after its own
timeout, and because the button is most useful exactly when live
updates were not working, so no stream was open.
Five seconds, as the hub already has. Reproduced against the test stack
with a stream held open: before, still running 25 seconds after SIGTERM;
after, exited within six.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
postgres:18-alpine runs as uid 70; 999 belongs to the Debian image. The
install worked anyway because the Postgres entrypoint re-chowns PGDATA as
root, but the chown, the setfacl fallback and the docs named the wrong user.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The php container took about a minute to start on TrueNAS, and 58 of
those 61 seconds were one line of the entrypoint: setfacl -R over all of
var/. On the TrueNAS layout var/data is the whole data dataset - the
Postgres files, one .eml per stored message, every attachment - and the
dataset refuses POSIX ACLs, so the walk read the metadata of every file
only to be told no. The second pass took 0.3s because the first had
warmed the cache. Every service running the entrypoint did the same walk
at the same moment: seven of them in that layout.
The ACLs were for www-data, which nothing in the image runs as - the
line came with the Symfony Docker template. They are set on var/cache
and var/log alone now, the two directories Symfony writes.
Measured in the released image: the step took 1.4-1.9s with 100,000
message files under var/data on RAM-backed storage against 0.2s with
none; it now takes 0.2s either way, and the mail files are left without
ACL entries.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The admin log showed
CRITICAL console (web) Error thrown while running command
"dbal:run-sql -q 'SELECT 1'". Message: "... could not translate host
name "database" to address: Name or service not known"
for a php container that had started while the database container did
not exist yet. The entrypoint waited for the database with a shell loop
that ran `dbal:run-sql -q "SELECT 1"` once a second, and every miss was
an uncaught console exception, which Symfony logs as CRITICAL. Only a
miss that fell just before the database answered could be stored - the
log is a table in that database - so the admin log showed exactly the
misses that did not matter, and never a database that stayed away.
app:db:wait is that loop in one process. It asks through
PostgresStatusRepository::whyUnreachable(), retries a miss without
logging it, and logs once, with the last error, when all sixty attempts
have failed; the container log reads as before. isReachable() uses the
same probe, so /healthz also hangs up after a failed check and dials
afresh on the next one.
Measured against a Postgres that refuses every other login, which makes
"answered a moment later" deterministic: the old probe stored exactly
the reported row, app:db:wait missed once, answered, and stored nothing.
The real entrypoint in the app image, started before its database,
waited through two misses and came up with nothing logged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CODE-REVIEW S-03. The reading frame carries a carefully built policy and the
image proxy carries one; the document holding the session — composer, settings,
admin — carried none. That is the amplifier that turned S-01 and S-02 from
"script in a contenteditable" into "take the account". With a policy both would
have been malfunctions.
Enforced in production, reported under debug, because the profiler toolbar and
VarDumper inject inline scripts nothing can nonce and they exist only when debug
is on. The always-safe directives — object-src 'none', base-uri 'none',
form-action 'self', frame-ancestors 'self' — are enforced everywhere. base-uri
is the one worth naming: an injected <base> silently re-points every relative
URL on the page, module imports included, and is invisible in the markup.
It got to "enforced" by removing the violations rather than allowing them, and
the last one was the application's own stylesheets. AssetMapper implements
`import './app.css'` as a `data:application/javascript,` module that appends a
<link> at runtime, so every stylesheet was a script as far as the browser was
concerned. Permitting them would have meant `script-src … data:`, which readmits
one of the oldest injection vectors there is and gives the directive away. Both
stylesheets are <link>ed from the layout now — which is what the AssetMapper
documentation recommends for CSS anyway, and removes a flash of unstyled content
as a side effect, since the browser no longer has to run the module graph to
discover them.
The nonce is one per request, shared by the header and the layout through a
service rather than generated at each end — two nonces is the classic way this
is got wrong. It implements ResetInterface because FrankenPHP's worker mode
keeps the container alive between requests, and a nonce that survives into the
next visitor's page is not a nonce. The spec asserts both: same value in header
and document, different value on the next request.
Also, unrelated but reported while this was in flight: the entrypoint's
symfony/skeleton branch is gone. It was stock scaffolding under a comment saying
it could be deleted, it could only fire when /app held no application, and what
it did then was fail with
Invalid stability string "", expected one of stable, RC, beta, alpha or dev
from six containers in a restart loop — because $SYMFONY_VERSION and $STABILITY
are unset in this image. Nothing in that names the actual problem. It now exits
with a message naming both ways to reach it: running the dev override from
outside the checkout (it bind-mounts ./ to /app), or a stale local
pl_mail-dev:latest being used without the override that builds it —
compose.override.yaml.dist predicted exactly this at its `image:` line.
Reproduced against an empty /app before and after.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CODE-REVIEW S-02 (high), S-04, S-08, S-05, S-06.
S-02 — three controllers decided what may render inline with
`str_starts_with($contentType, 'image/')`, and `image/svg+xml` satisfies it
while executing script as a top-level document. nosniff does not help: nothing
is mislabelled, the type genuinely is what it says. The type comes from the MIME
headers of an incoming mail.
Reachable from the mail itself. MailBodySanitizer::resolveCids() rewrites EVERY
`cid:` in the document rather than only `img src`, and the sanitiser allows
relative links — so `<a href="cid:logo">Rechnung ansehen</a>` has the part id
filled in by the application, and the click opens the attachment route without
the `?download=1` that every template adds.
Now an allow-list (InlineDisposition), which is the difference between
"everything not yet known to be dangerous" and "the formats actually wanted",
plus `default-src 'none'; sandbox` on the response — so a later widening of the
list is a bug rather than a vulnerability. Parameters are stripped before
comparing, because `image/svg+xml; charset=utf-8` would otherwise walk past the
one entry meant to stop it.
S-08 pulled along: the JMAP download had the same prefix test. Guarded today
only by the jmap firewall being stateless and wanting a header a browser
navigation does not send, which is a thin thing to depend on.
S-04 pulled along: the file-picker preview carried no Content-Disposition at
all — implicitly inline, in our origin, with a type chosen by Nextcloud, Immich,
Dropbox, OneDrive or Drive. A file an attacker drops in a shared folder is a
realistic shape for the setups these integrations exist for. A non-image now
falls back to the placeholder rather than to a download: a download prompt fired
by a thumbnail is worse than a grey box.
S-05 — the Caddyfile now sends HSTS, X-Frame-Options, nosniff, Referrer-Policy
and Permissions-Policy, and drops the Server banner. None of these existed
anywhere in the repository, so the ones a live instance showed were its
operator's proxy — while the README's own install instructions are `docker
compose up -d` and "open https://localhost". `defer`, so the per-response
policies on the attachment and image-proxy routes still win. Referrer-Policy is
the one that matters most for a mail client: it sends its users to strangers'
links all day.
S-06 — mercure subscribe defaults to [] rather than '*'. Never reached today,
since both cookie-minting paths pass explicit topics, but a future
createCookie($request) without a topic argument would have issued a subscription
to every user's mail events and looked entirely ordinary in review.
Also: mailpit removed from the dev override. MailerInterface is injected in no
service in src/ — plMail's own outgoing mail leaves through the user's account
via MailSenderRegistry — and the override never set MAILER_DSN either, so the
container has been catching nothing. Both configuration pages said the dev stack
pointed at it; they now say what is actually true.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A deploy carrying a migration brought up one service out of four. php,
imap-supervisor, messenger-worker and scheduler are the same image running the
same entrypoint, they start within milliseconds of each other against one
database, and every one of them migrates on boot. All four read the ledger
before any of them writes to it, so all four decide the same migration is
pending; one wins and the rest die on
SQLSTATE[42701]: Duplicate column: 7
ERROR: column "timezone" of relation "user" already exists
which under `set -e` is a container that never comes up. The timezone column in
v0.0.6 is what exposed it, not what caused it -- any migration would have done,
and `--all-or-nothing` makes it likelier rather than safer, because the losers
are held for as long as the whole batch takes instead of one statement.
Reproduced before it was fixed: four concurrent migrates against one database,
three exited 7 with that message verbatim. The same four under the lock all exit
0, one migrates and three find the ledger current.
The losers still run the migration rather than skipping it. Skipping would mean
a container coming up against a schema nobody in that process verified, which is
the failure this is supposed to prevent, one step later.
The lock cannot be a line of shell, which is the whole reason there is a command
here. `pg_advisory_lock()` is scoped to the session, so taking it with
`dbal:run-sql` hands it straight back when that process exits and leaves the
migration it was meant to protect running unguarded. It has to be held by the
connection that migrates, which means one PHP process doing both.
Waiting is bounded. A container OOM-killed mid-migration would otherwise hang
every other service in the stack forever with nothing on stdout to say why, so a
timeout reports what it was waiting for -- and refuses to migrate anyway, which
is the only answer that is not a guess about what the holder was doing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>