Stop needing to be told what a catalogue can work out

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>
This commit is contained in:
Paul LütznerandClaude Opus 5.5 committed 2026-10-03 12:17:47 +02:00
1 parent 32dc5eecd7
commit 533b773b33
11 files changed
+319 -36

No files matched your search

+3 -3
View File
@@ -39,9 +39,9 @@ x-app-env: &app-env
MESSENGER_TRANSPORT_DSN: ${MESSENGER_TRANSPORT_DSN:-doctrine://default?auto_setup=0}
MERCURE_URL: ${MERCURE_URL:-http://mercure/.well-known/mercure}
MERCURE_PUBLIC_URL: ${MERCURE_PUBLIC_URL:-https://localhost/.well-known/mercure}
# Blank on HTTPS, which is every real install. Serving plain HTTP, set it to a
# name without the `__Secure-` prefix (a browser drops such a cookie over
# HTTP). The app sets the cookie and the hub reads it, and both read this.
# Blank, and plMail picks: the `__Secure-` name when the public address is
# https, a name without the prefix otherwise (a browser drops a `__Secure-`
# cookie over HTTP). Set it only to use a name of your own.
MERCURE_COOKIE_NAME: ${MERCURE_COOKIE_NAME:-}
# The state that must outlive a container and be shared between them.
+39 -2
View File
@@ -117,9 +117,46 @@ declare(strict_types=1);
// Derived here rather than configured: it is one less thing to fill in, and
// it cannot drift from the public URL. An explicit MERCURE_PUBLIC_URL still
// wins, for the install whose hub really does live somewhere else.
if (false === $isSet('MERCURE_PUBLIC_URL') && true === $isSet('APP_PUBLIC_URL')) {
$publicUrl = trim((string) ($_SERVER['APP_PUBLIC_URL'] ?? $_ENV['APP_PUBLIC_URL'] ?? ''));
$publicUrl = trim((string) ($_SERVER['APP_PUBLIC_URL'] ?? $_ENV['APP_PUBLIC_URL'] ?? ''));
if (false === $isSet('MERCURE_PUBLIC_URL') && '' !== $publicUrl) {
$put('MERCURE_PUBLIC_URL', rtrim($publicUrl, '/').'/.well-known/mercure');
}
// The subscriber cookie takes the `__Secure-` prefix where a browser will
// accept it, which is exactly where the page arrives over HTTPS, and the
// public address is the one thing here that says whether it does. Without
// an https address the name is left unset and services.yaml's prefix-less
// default answers: an install opened on http://ip:port — every NAS app on
// its first day — would otherwise set a cookie the browser throws away, and
// live updates would never start. It used to take an operator who knew to
// set MERCURE_COOKIE_NAME. One who does still wins; this only fills a gap.
//
// The hub is not told about the prefix at all. The web server renames the
// cookie on the way through (frankenphp/Caddyfile) and the hub reads the
// bare name (BackgroundProcesses), so the two cannot disagree while one of
// them has restarted since the address changed and the other has not.
if (false === $isSet('MERCURE_COOKIE_NAME') && true === str_starts_with(strtolower($publicUrl), 'https://')) {
$put('MERCURE_COOKIE_NAME', '__Secure-mercure_access_token');
}
// APP_STORAGE_DIR is relative to the project root everywhere it is used,
// and the paths stored with every attachment begin with it. An absolute
// path is what a deployment that mounts one directory would rather write,
// and some catalogues insist on it, so one inside the project is accepted
// and brought back to the relative form here — before anything reads it,
// which is what keeps the stored paths the same either way. One outside the
// project has no relative form and is refused rather than half-honoured.
$storageDir = trim((string) ($_SERVER['APP_STORAGE_DIR'] ?? $_ENV['APP_STORAGE_DIR'] ?? ''));
if (true === str_starts_with($storageDir, '/')) {
$relative = trim(substr(rtrim($storageDir, '/'), \strlen($projectDir)), '/');
if (false === str_starts_with(rtrim($storageDir, '/').'/', $projectDir.'/') || '' === $relative) {
throw new RuntimeException(sprintf('APP_STORAGE_DIR is "%s", which is not a directory inside %s. Use a path below it, absolute or relative.', $storageDir, $projectDir));
}
$_SERVER['APP_STORAGE_DIR'] = $relative;
$_ENV['APP_STORAGE_DIR'] = $relative;
}
})();
+11 -7
View File
@@ -21,13 +21,17 @@ parameters:
# reverse. See App\Service\Demo\DemoMode.
app.demo_mode_default: false
# The subscriber cookie's name. The Mercure 1.0 default is `__Secure-`
# prefixed, and a browser refuses a `__Secure-` cookie that did not arrive
# over HTTPS — which is right for every real install and wrong for the test
# stack, which serves plain HTTP on 127.0.0.1. Read through `default:` so an
# install that has never heard of the variable gets the safe name, and only
# a deployment that knows it is running without TLS has to say so.
mercure.cookie_name_default: '__Secure-mercure_access_token'
# The subscriber cookie's name when nothing says otherwise: no prefix.
#
# The Mercure 1.0 default is `__Secure-` prefixed, and a browser refuses a
# `__Secure-` cookie that did not arrive over HTTPS. That was the default
# here too, on the reasoning that every real install has TLS — and then a
# NAS app opened on http://ip:port never got a live update, with nothing
# anywhere saying why. So the prefix is no longer assumed; it is chosen.
# config/bootstrap_generated_secrets.php sets MERCURE_COOKIE_NAME to the
# prefixed name when the install's public address is https, and leaves it
# alone otherwise, which lands here. An operator's own value wins over both.
mercure.cookie_name_default: 'mercure_access_token'
# Who plMail says issued its Mercure tokens, and which hub they are for.
#
+4 -3
View File
@@ -1,4 +1,4 @@
<!-- translated-from: install/configuration.md sha1:4891178bed3970f53bdec5785209ab33bbd47f59 -->
<!-- translated-from: install/configuration.md sha1:cb3491e232974eac33e32f1e9ef2fd9298c8a108 -->
# Konfigurationsreferenz
Jede Umgebungsvariable, die plMail liest, was sie bewirkt, welchen Vorgabewert sie hat und was
@@ -43,7 +43,7 @@ Das sind die Variablen aus `.env`, in der Reihenfolge, in der sie dort stehen.
| `APP_SECRET` | Symfonys Anwendungsgeheimnis. Signiert unter anderem Remember-me-Cookies. | leer — 32 zufällige Bytes, hexadezimal, beim ersten Start erzeugt | Ja, aber erzeugt | Eine Änderung macht jedes Remember-me-Cookie ungültig, alle werden also abgemeldet. |
| `APP_SHARE_DIR` | Nichts. Kein Code in diesem Repository liest sie. | `var/share` | Nein | Nichts. Sie bleibt bestehen, weil das Entfernen einer Variablen eine Änderung an `.env` wäre; behandle sie als wirkungslos. |
| `APP_SECRETS_FILE` | Pfad zur Datei der erzeugten Geheimnisse, relativ zum Projektstammverzeichnis, sofern er nicht mit `/` beginnt. | `var/secrets/generated.env` | Ja | Zeigt ein Dienst dorthin, wo die anderen nicht hinsehen, erzeugt er seinen eigenen Verschlüsselungsschlüssel. `EncryptionKeyProbe` bemerkt das beim Start und verweigert den Start des Servers. |
| `APP_STORAGE_DIR` | Wurzel für alles, was plMail als Datei schreibt, relativ zum Projektstammverzeichnis: `attachments/`, `raw/`, `uploads/` (Avatare liegen unter `uploads/avatars/`). | `var` | Ja | Anhangpfade werden relativ zum Projektstammverzeichnis in der Datenbank gespeichert; eine Änderung auf einer Installation, in der bereits Mail liegt, macht daher jede vorhandene Datei unauffindbar. |
| `APP_STORAGE_DIR` | Wurzel für alles, was plMail als Datei schreibt: `attachments/`, `raw/`, `uploads/` (Avatare liegen unter `uploads/avatars/`). Relativ zum Projektstammverzeichnis oder ein absoluter Pfad darin (`/app/var/data` ist dasselbe wie `var/data`); einer außerhalb wird beim Start abgelehnt. | `var` | Ja | Anhangpfade werden relativ zum Projektstammverzeichnis in der Datenbank gespeichert; eine Änderung auf einer Installation, in der bereits Mail liegt, macht daher jede vorhandene Datei unauffindbar. |
| `APP_PUBLIC_URL` | Die Adresse, unter der plMail von außen erreicht wird — die, die Google und Microsoft zurückrufen. | leer; wird auf dem Einrichtungsbildschirm abgefragt und in die erzeugte Datei geschrieben. Später unter Admin → Adresse änderbar, was einen Neustart braucht | Für Push: ja | Leer, `http://` oder ein Loopback-Host, und Kalender-Push sowie Graph-Mail-Push registrieren sich überhaupt nicht. Siehe [Hinter einem Reverse-Proxy](reverse-proxy.md). |
| `APP_DEFAULT_TIMEZONE` | Die Uhr, die jemandem angezeigt wird, der in den Einstellungen nie eine gewählt hat. | `Europe/Berlin` (zugleich der Parameter `app.fallback_timezone`, der greift, wenn die Variable fehlt) | Nein | Eine Installation mit falscher Zone zeigt allen diesen Personen Zeiten, die um ihren eigenen Versatz plausibel falsch sind. Zeitstempel werden in UTC gespeichert und nur zur Anzeige umgerechnet, eine Korrektur rendert also neu, statt umzuschreiben. Nicht zu verwechseln mit der PHP-Zeitzone des Containers, die in `frankenphp/conf.d/10-app.ini` fest auf `UTC` steht. |
| `DEFAULT_URI` | Basis-URI, die der Router beim Erzeugen von URLs außerhalb einer HTTP-Anfrage verwendet. | `http://localhost` | Nein | Links in Mail, die aus einem Konsolenbefehl entsteht, zeigen auf localhost. Push-Rückrufe verwenden das nicht — sie verwenden `APP_PUBLIC_URL`. |
@@ -62,7 +62,8 @@ Das sind die Variablen aus `.env`, in der Reihenfolge, in der sie dort stehen.
| `MERCURE_URL` | Die Hub-Adresse, an die die **Anwendung** innerhalb des Docker-Netzes veröffentlicht. | `http://mercure/.well-known/mercure` | Ja | Falsch, und nichts wird veröffentlicht: keine Live-Aktualisierungen, kein sichtbarer Fehler auf der Seite. |
| `MERCURE_PUBLIC_URL` | Die Hub-Adresse, die der **Browser** abonniert. | `https://localhost/.well-known/mercure`, sowohl in `.env` als auch in `compose.yaml` | Ja | Falsch, und der Browser öffnet einen Stream dorthin, wo er nicht hinkommt — Maillisten hören auf, sich selbst zu aktualisieren, während der Rest der Anwendung funktioniert. Wird nur dann aus `APP_PUBLIC_URL` abgeleitet, wenn sie nicht gesetzt oder leer ist, was die Standard-`compose.yaml` verhindert. Siehe [Hinter einem Reverse-Proxy](reverse-proxy.md). |
| `MERCURE_JWT_SECRET` | Signiert die Publisher- und Subscriber-JWTs. | leer — 32 zufällige Bytes, hexadezimal, beim ersten Start erzeugt | Ja, aber erzeugt | Anwendung und Hub müssen denselben Wert halten. Weichen sie voneinander ab, weist der Hub jeden Subscriber ab — aus Sicht des Browsers lautlos. |
| `MERCURE_COOKIE_NAME` | Der Name des Cookies, mit dem der Browser abonnieren darf. Die Anwendung setzt es und der Hub liest es, beide aus dieser einen Variablen. | leer — `__Secure-mercure_access_token` | Nur ohne TLS | Eine Installation, die schlichtes HTTP ausliefert und nirgends TLS vorgeschaltet hat, braucht einen Namen ohne das Präfix `__Secure-`, etwa `mercure_access_token`: Ein Browser verwirft ein `__Secure-`-Cookie, das nicht über HTTPS kam, und Live-Aktualisierungen starten nie. |
| `MERCURE_COOKIE_NAME` | Der Name des Cookies, mit dem der Browser abonnieren darf. | leer — plMail wählt `__Secure-mercure_access_token`, wenn `APP_PUBLIC_URL` https ist, und sonst `mercure_access_token`, weil ein Browser ein `__Secure-`-Cookie verwirft, das nicht über HTTPS kam | Nein | Nur setzen, wenn du einen eigenen Namen willst; die Anwendung setzt das Cookie und der Hub liest es, beide aus dieser einen Variablen. Gewählt wird beim Start des Web-Containers, eine geänderte öffentliche Adresse wirkt also ab dem nächsten Neustart. |
| `MERCURE_UPSTREAM` | Wohin der Webserver `/.well-known/mercure` weiterreicht: der Container, in dem der Hub läuft, als `host:port`. | `mercure:80` | Nein | Nur für einen Stack, der diesen Container nicht `mercure` nennt. Falsch, und der Stream des Browsers antwortet mit 502 — die Anzeige in der oberen Leiste bleibt rot, während Mail weiter ankommt. `MERCURE_URL` muss denselben Container nennen. |
| `GOOGLE_OAUTH_CLIENT_ID` | Google-OAuth-Client. | leer | Für Gmail | Ohne sie kann "Mit Google anmelden" nicht starten. Ein unter **Administration → Integrationen** gespeicherter Wert gewinnt gegen diesen. Siehe [Google](../providers/google.md). |
| `GOOGLE_OAUTH_CLIENT_SECRET` | Client-Secret des Google-OAuth-Clients. | leer | Für Gmail | Wie oben. |
| `GMAIL_PUBSUB_TOPIC` | Vollständiger Topic-Name, in den Gmail Watch-Benachrichtigungen veröffentlicht. | `projects/your-project-id/topics/gmail-push` — ein Platzhalter, kein funktionierender Wert | Für sofortige Gmail-Zustellung | Der Platzhalter ist kein Topic, das dir gehört, also scheitert jeder `watch`-Aufruf. Die Projekt-ID ist kleingeschrieben und weicht oft vom Anzeigenamen ab. |
+3 -2
View File
@@ -39,7 +39,7 @@ These are the variables in `.env`, in the order they appear there.
| `APP_SECRET` | Symfony's application secret. Signs remember-me cookies, among other things. | blank — 32 random bytes, hex, generated on first start | Yes, but generated | Changing it invalidates every remember-me cookie, so everyone is logged out. |
| `APP_SHARE_DIR` | Nothing. No code in this repository reads it. | `var/share` | No | Nothing. Left in place because removing a variable is a change to `.env`; treat it as inert. |
| `APP_SECRETS_FILE` | Path to the generated secrets file, relative to the project root unless it starts with `/`. | `var/secrets/generated.env` | Yes | Point one service somewhere the others cannot see and it mints its own encryption key. `EncryptionKeyProbe` catches that at boot and refuses to start the server. |
| `APP_STORAGE_DIR` | Root for everything plMail writes as a file, relative to the project root: `attachments/`, `raw/`, `uploads/` (avatars live under `uploads/avatars/`). | `var` | Yes | Attachment paths are stored in the database relative to the project root, so changing this on an install that already holds mail orphans every existing file. |
| `APP_STORAGE_DIR` | Root for everything plMail writes as a file: `attachments/`, `raw/`, `uploads/` (avatars live under `uploads/avatars/`). Relative to the project root, or an absolute path inside it (`/app/var/data` is the same as `var/data`); one outside it is refused at start. | `var` | Yes | Attachment paths are stored in the database relative to the project root, so changing this on an install that already holds mail orphans every existing file. |
| `APP_PUBLIC_URL` | The address plMail is reached at from outside — what Google and Microsoft call back to. | blank; asked for on the setup screen and written to the generated file. Changed afterwards under Admin → Address, which takes a restart to apply | For push, yes | Blank, `http://`, or a loopback host and calendar push and Graph mail push refuse to register at all. See [Behind a reverse proxy](reverse-proxy.md). |
| `APP_DEFAULT_TIMEZONE` | The clock a user who has never picked one in Settings is shown. | `Europe/Berlin` (also the `app.fallback_timezone` parameter, used when the variable is absent) | No | An install left on the wrong zone shows every such user times that are plausibly wrong by their own offset. Timestamps are stored in UTC and only converted for display, so fixing it re-renders rather than rewrites. Not the container's PHP timezone, which is pinned to `UTC` in `frankenphp/conf.d/10-app.ini`. |
| `DEFAULT_URI` | Base URI the router uses when generating URLs outside an HTTP request. | `http://localhost` | No | Links in mail generated from a console command point at localhost. Push callbacks do not use this — they use `APP_PUBLIC_URL`. |
@@ -58,7 +58,8 @@ These are the variables in `.env`, in the order they appear there.
| `MERCURE_URL` | The hub address the **app** publishes to, inside the Docker network. | `http://mercure/.well-known/mercure` | Yes | Wrong and nothing publishes: no live updates, no visible error on the page. |
| `MERCURE_PUBLIC_URL` | The hub address the **browser** subscribes to. | `https://localhost/.well-known/mercure` in both `.env` and `compose.yaml` | Yes | Wrong and the browser opens a stream to somewhere it cannot reach — mail lists stop updating by themselves while the rest of the app works. Derived from `APP_PUBLIC_URL` only when this is unset or empty, which the stock `compose.yaml` prevents. See [Behind a reverse proxy](reverse-proxy.md). |
| `MERCURE_JWT_SECRET` | Signs the publisher and subscriber JWTs. | blank — 32 random bytes, hex, generated on first start | Yes, but generated | The app and the hub must hold the same value. They disagree and the hub rejects every subscriber, silently, from the browser's point of view. |
| `MERCURE_COOKIE_NAME` | The name of the cookie that lets the browser subscribe. The app sets it and the hub reads it, both from this one variable. | blank — `__Secure-mercure_access_token` | Only without TLS | An install serving plain HTTP, with no TLS anywhere in front, needs a name without the `__Secure-` prefix, such as `mercure_access_token`: a browser drops a `__Secure-` cookie that did not arrive over HTTPS, and live updates never start. |
| `MERCURE_COOKIE_NAME` | The name of the cookie that lets the browser subscribe. | blank — plMail picks `__Secure-mercure_access_token` when `APP_PUBLIC_URL` is https and `mercure_access_token` otherwise, since a browser drops a `__Secure-` cookie that did not arrive over HTTPS | No | Set only to use a name of your own; the app sets it and the hub reads it, both from this one variable. The choice is made when the web container starts, so it follows a changed public address at the next restart. |
| `MERCURE_UPSTREAM` | Where the web server proxies `/.well-known/mercure` to: the container running the hub, as `host:port`. | `mercure:80` | No | Only for a stack that does not call that container `mercure`. Wrong and the browser's stream answers 502 — the indicator in the top bar stays red while mail still arrives. `MERCURE_URL` has to name the same container. |
| `GOOGLE_OAUTH_CLIENT_ID` | Google OAuth client. | blank | For Gmail | Without it, "Sign in with Google" cannot start. A value stored under **Admin → Integrations** wins over this one. See [Google](../providers/google.md). |
| `GOOGLE_OAUTH_CLIENT_SECRET` | Google OAuth client secret. | blank | For Gmail | As above. |
| `GMAIL_PUBSUB_TOPIC` | Full topic name Gmail publishes watch notifications to. | `projects/your-project-id/topics/gmail-push` — a placeholder, not a working value | For instant Gmail | The placeholder is not a topic you own, so every `watch` call fails. The project id is lowercase and often differs from the display name. |
+12 -2
View File
@@ -74,9 +74,19 @@
-Server
}
# Mercure hub — proxy to the mercure container
# Mercure hub — proxied to the container that runs it. `mercure` is the
# name plMail's own compose files give it; MERCURE_UPSTREAM is for a
# stack that names its containers by rules of its own.
#
# The subscriber cookie loses its `__Secure-` prefix on the way. The
# browser holds the prefixed one on an https install and the bare one
# otherwise, and which it is follows the public address, which can
# change under a running hub. So the hub always reads the bare name
# and this is where the two are made the same; see BackgroundProcesses.
handle /.well-known/mercure* {
reverse_proxy mercure:80
reverse_proxy {$MERCURE_UPSTREAM:mercure:80} {
header_up Cookie "__Secure-mercure_access_token=" "mercure_access_token="
}
}
php_server {
+39 -8
View File
@@ -39,6 +39,29 @@ random_base64() {
head -c "$1" /dev/urandom | base64 | tr -d '\n'
}
# True only for a DSN that carries a password. The same test, for the same
# reason, as the one in docker-entrypoint.sh: the placeholder DSN in .env has a
# user and no password, and must not count as a database somebody configured.
database_url_has_password() {
case "$1" in
*://*) ;;
*) return 1 ;;
esac
_userinfo="${1#*://}"
case "$_userinfo" in
*@*) _userinfo="${_userinfo%%@*}" ;;
*) return 1 ;;
esac
case "$_userinfo" in
*/*) return 1 ;;
*:*) return 0 ;;
*) return 1 ;;
esac
}
# generate NAME VALUE — append NAME to the secrets file unless the environment
# already carries it or the file already has it.
generate() {
@@ -70,16 +93,24 @@ generate() {
# stored mail credential, which is why the directory is worth backing up.
generate APP_ENCRYPTION_KEY "$(random_base64 32)"
generate POSTGRES_PASSWORD "$(random_hex 24)"
generate MERCURE_JWT_SECRET "$(random_hex 32)"
# 0644 rather than 0600: the Postgres image reads this as uid 70, and the
# file only ever exists inside a volume mounted into plMail's own services.
password="$(printenv POSTGRES_PASSWORD || true)"
[ -n "$password" ] || password="$(sed -n 's/^POSTGRES_PASSWORD=//p' "$SECRETS_FILE")"
# The database password, unless the database is somebody else's. A
# DATABASE_URL that carries a password names a database an operator set up
# and holds the credential for; minting a second one here leaves a secret in
# the file that nothing reads and a backup then carries around.
if ! database_url_has_password "${DATABASE_URL:-}"; then
generate POSTGRES_PASSWORD "$(random_hex 24)"
if [ -n "$password" ]; then
printf '%s' "$password" >"$POSTGRES_PASSWORD_FILE_PATH"
chmod 644 "$POSTGRES_PASSWORD_FILE_PATH" 2>/dev/null || true
# 0644 rather than 0600: the Postgres image reads this as uid 70, and
# the file only ever exists inside a volume mounted into plMail's own
# services.
password="$(printenv POSTGRES_PASSWORD || true)"
[ -n "$password" ] || password="$(sed -n 's/^POSTGRES_PASSWORD=//p' "$SECRETS_FILE")"
if [ -n "$password" ]; then
printf '%s' "$password" >"$POSTGRES_PASSWORD_FILE_PATH"
chmod 644 "$POSTGRES_PASSWORD_FILE_PATH" 2>/dev/null || true
fi
fi
) 9>"$SECRETS_DIR/.lock"
@@ -30,26 +30,32 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
* plain HTTP on :80, the generated JWT secret as both keys, and the issuer and
* audience pinned to `mercure.identifier` (config/services.yaml says why they
* cannot be derived from the install's own address). The container answers to
* `mercure` on the network, so the web container's proxy and MERCURE_URL reach
* it where they always reached the hub.
* `mercure` on the network in plMail's own compose files, so the web
* container's proxy and MERCURE_URL reach it where they always reached the
* hub; a stack that names it otherwise says so in MERCURE_UPSTREAM and
* MERCURE_URL.
*/
final readonly class BackgroundProcesses
{
public const string HUB = 'mercure';
/**
* The subscriber cookie with and without its prefix. The browser is handed
* the prefixed one on an https install; the hub only ever sees the bare one.
*/
private const string PREFIXED_COOKIE = '__Secure-mercure_access_token';
private const string BARE_COOKIE = 'mercure_access_token';
/** The consumers' recycling, as compose ran them: hourly, or at 256 MB. */
private const array CONSUMER_LIMITS = ['--time-limit=3600', '--memory-limit=256M'];
public function __construct(
#[Autowire('%mercure.identifier%')]
private string $hubIdentifier,
// The subscriber cookie's name, as the application sets it. The hub has
// to look for the same one: `__Secure-…` by default, and whatever
// MERCURE_COOKIE_NAME says on an install serving plain HTTP, where a
// browser drops a `__Secure-` cookie. One setting now, where the hub
// container needed its own copy of it.
// The subscriber cookie's name, as the application sets it. See
// hubCookieName() for the one name the hub does not take as it comes.
#[Autowire('%mercure.cookie_name%')]
private string $cookieName = '__Secure-mercure_access_token',
private string $cookieName = self::BARE_COOKIE,
#[Autowire('%env(default::MERCURE_JWT_SECRET)%')]
private ?string $hubSecret = null,
#[Autowire('%env(default::MERCURE_TRUSTED_ISSUERS)%')]
@@ -105,7 +111,7 @@ final readonly class BackgroundProcesses
// out per request would refuse every publish.
'MERCURE_EXTRA_DIRECTIVES' => $this->orDefault(
$this->extraDirectives,
sprintf("resource_identifier %s\ncookie_name %s", $this->hubIdentifier, $this->cookieName),
sprintf("resource_identifier %s\ncookie_name %s", $this->hubIdentifier, $this->hubCookieName()),
),
// Five seconds to close its connections when told to stop, then it
// closes them. Caddy's own default is to wait for every one, and a
@@ -120,6 +126,24 @@ final readonly class BackgroundProcesses
]);
}
/**
* The cookie the hub looks for.
*
* The application's own name, except the prefixed one, which reaches the
* hub bare: the web server renames it in the proxy (frankenphp/Caddyfile).
* Whether the browser's cookie carries the prefix follows the public
* address, which an administrator can change while this process runs — and
* a hub told the name at ITS start would then refuse every subscription
* until somebody restarted the worker as well. The hub's side never changes
* now, so the web container is the only one that has to notice.
*
* Any other name is an operator's own and is used as it is, on both sides.
*/
private function hubCookieName(): string
{
return self::PREFIXED_COOKIE === $this->cookieName ? self::BARE_COOKIE : $this->cookieName;
}
private function orDefault(?string $value, string $default): string
{
$value = trim((string) $value);
@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Tests\Infrastructure\Setup;
use PHPUnit\Framework\TestCase;
use RuntimeException;
/**
* What the first-run generator mints, and the one secret it leaves alone.
*
* A database an operator brought has its password in DATABASE_URL. The
* generator used to mint a POSTGRES_PASSWORD beside it regardless and write it
* to a file for a Postgres container that was not there: a credential nothing
* reads, sitting in the secrets directory and travelling in every backup of it.
*
* Run as the real script in a child shell, because the thing under test is a
* shell script and its only interface is the directory it leaves behind.
*/
final class GenerateSecretsScriptTest extends TestCase
{
private string $secretsDir;
protected function setUp(): void
{
$this->secretsDir = sys_get_temp_dir().'/plmail-generate-'.bin2hex(random_bytes(6));
}
protected function tearDown(): void
{
foreach (glob($this->secretsDir.'/{,.}*', GLOB_BRACE) ?: [] as $file) {
if (true === is_file($file)) {
unlink($file);
}
}
if (true === is_dir($this->secretsDir)) {
rmdir($this->secretsDir);
}
}
public function testAStackWithItsOwnPostgresGetsADatabasePassword(): void
{
// The DSN .env ships: a user and no password. That is nobody's
// database yet, and must not count as one.
$generated = $this->generate(['DATABASE_URL' => 'postgresql://app@database:5432/app?serverVersion=18&charset=utf8']);
self::assertArrayHasKey('POSTGRES_PASSWORD', $generated);
self::assertSame($generated['POSTGRES_PASSWORD'], file_get_contents($this->secretsDir.'/postgres_password'));
}
public function testADatabaseSomebodyElseSetUpGetsNoPasswordOfOurs(): void
{
$generated = $this->generate(['DATABASE_URL' => 'postgresql://app:chosen@postgres:5432/app']);
self::assertArrayNotHasKey('POSTGRES_PASSWORD', $generated);
self::assertFileDoesNotExist($this->secretsDir.'/postgres_password');
// Everything else is still plMail's to mint.
self::assertArrayHasKey('APP_SECRET', $generated);
self::assertArrayHasKey('APP_ENCRYPTION_KEY', $generated);
self::assertArrayHasKey('MERCURE_JWT_SECRET', $generated);
}
/**
* @param array<string, string> $env
*
* @return array<string, string> what the run left in generated.env
*/
private function generate(array $env): array
{
$process = proc_open(
['sh', \dirname(__DIR__, 3).'/frankenphp/generate-secrets.sh'],
[1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
$pipes,
null,
$env + ['APP_SECRETS_DIR' => $this->secretsDir, 'PATH' => (string) getenv('PATH')],
);
if (false === \is_resource($process)) {
throw new RuntimeException('Could not start the generator.');
}
stream_get_contents($pipes[1]);
$errors = (string) stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
if (0 !== proc_close($process)) {
throw new RuntimeException('The generator failed: '.$errors);
}
$values = [];
foreach (file($this->secretsDir.'/generated.env', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
[$name, $value] = explode('=', $line, 2);
$values[$name] = $value;
}
return $values;
}
}
@@ -88,6 +88,58 @@ final class GeneratedSecretsBootstrapTest extends TestCase
self::assertSame('(unset)', $this->mercurePublicUrlSeenBy([]));
}
/**
* The subscriber cookie's prefix follows the public address, because a
* browser only keeps a `__Secure-` cookie that arrived over HTTPS. An
* install on http://ip:port used to set one the browser threw away, and
* live updates never started; nothing anywhere said why.
*/
public function testTheCookieIsPrefixedOnlyWhereABrowserWillKeepIt(): void
{
file_put_contents($this->secretsFile, "APP_PUBLIC_URL=https://mail.example.com\n");
self::assertSame('__Secure-mercure_access_token', $this->seenBy('MERCURE_COOKIE_NAME', []));
file_put_contents($this->secretsFile, "APP_PUBLIC_URL=http://192.168.2.2:30504\n");
self::assertSame('(unset)', $this->seenBy('MERCURE_COOKIE_NAME', []), 'left to the prefix-less default in services.yaml');
}
public function testNoPrefixIsAssumedBeforeSetupHasStoredAnAddress(): void
{
self::assertSame('(unset)', $this->seenBy('MERCURE_COOKIE_NAME', []));
}
public function testAnOperatorsOwnCookieNameIsNeverOverridden(): void
{
file_put_contents($this->secretsFile, "APP_PUBLIC_URL=https://mail.example.com\n");
self::assertSame('plmail', $this->seenBy('MERCURE_COOKIE_NAME', ['MERCURE_COOKIE_NAME' => 'plmail']));
}
/**
* Stored attachment paths begin with the storage directory, relative to the
* project root. An absolute path inside the project has to come out as the
* same relative one, or an install that switched spelling would stop
* finding every file it had already written.
*/
public function testAnAbsoluteStorageDirInsideTheProjectBecomesTheRelativeOne(): void
{
$project = \dirname(__DIR__, 3);
self::assertSame('var/data', $this->seenBy('APP_STORAGE_DIR', ['APP_STORAGE_DIR' => $project.'/var/data']));
self::assertSame('var/data', $this->seenBy('APP_STORAGE_DIR', ['APP_STORAGE_DIR' => $project.'/var/data/']));
self::assertSame('var/data', $this->seenBy('APP_STORAGE_DIR', ['APP_STORAGE_DIR' => 'var/data']), 'a relative one is left alone');
}
public function testAStorageDirOutsideTheProjectIsRefusedRatherThanHalfHonoured(): void
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessageMatches('/APP_STORAGE_DIR/');
$this->seenBy('APP_STORAGE_DIR', ['APP_STORAGE_DIR' => '/mnt/elsewhere']);
}
/**
* Beside the generated secrets, wherever a deployment keeps them — the
* TrueNAS layouts point APP_SECRETS_FILE into their data dataset, and a
@@ -43,4 +43,24 @@ final class BackgroundProcessesTest extends TestCase
self::assertSame('https://issuer.example', $overridden->env['MERCURE_TRUSTED_ISSUERS'], 'what an operator sets wins');
self::assertSame('cookie_name plmail', $overridden->env['MERCURE_EXTRA_DIRECTIVES']);
}
/**
* The hub never hears about the `__Secure-` prefix.
*
* Whether the browser's cookie carries it follows the public address, and
* that can change while the hub is running. A hub told the prefixed name at
* its own start refused every subscription after the address moved to
* http, until somebody restarted the worker too. The web server strips the
* prefix in the proxy instead, so this side holds still.
*/
public function testTheHubReadsTheBareCookieWhateverTheBrowserIsHanded(): void
{
$hub = new BackgroundProcesses(self::IDENTIFIER, '__Secure-mercure_access_token', 's')->all()[0];
self::assertStringEndsWith("\ncookie_name mercure_access_token", $hub->env['MERCURE_EXTRA_DIRECTIVES']);
$own = new BackgroundProcesses(self::IDENTIFIER, 'plmail', 's')->all()[0];
self::assertStringEndsWith("\ncookie_name plmail", $own->env['MERCURE_EXTRA_DIRECTIVES'], 'an operator\'s own name is used as it is');
}
}