diff --git a/.env b/.env index f2536376..2bc62509 100644 --- a/.env +++ b/.env @@ -16,12 +16,37 @@ ###> symfony/framework-bundle ### APP_ENV=dev -# Dev default only — deployments override it through the environment. -# Must be non-empty: remember-me cookies are signed with it, so changing it -# logs everyone out. -APP_SECRET=d828015e819548676701972e0f855372 +# ───────────────────────────────────────────────────────────────────────────── +# READ THIS BEFORE PUTTING A SECRET IN THIS FILE. +# +# docker compose reads .env — this file — to fill in ${VAR} in compose.yaml. +# That makes anything set here an environment variable inside every container, +# which in turn tells the entrypoint "the operator supplied this" and switches +# off the first-run generation for it. A committed value here is therefore a +# secret shared by every plMail install in existence. +# +# So the three that matter are blank, and generated on first start into +# var/secrets/generated.env. Fill one in only if you mean to manage it +# yourself — in your shell, or here — and know that it then applies to every +# service. +# ───────────────────────────────────────────────────────────────────────────── + +# Signs remember-me cookies, among other things; changing it logs everyone out. +APP_SECRET= APP_SHARE_DIR=var/share -APP_PUBLIC_URL=https://mail.example.com + +# Where the generated secrets live. Must be on a volume EVERY plMail service can +# see; one that cannot read it mints its own key and stops being able to read the +# others' data, which `app:secrets:init` detects and refuses to start on. Back +# this up: losing APP_ENCRYPTION_KEY makes every stored mail credential +# permanently unreadable. +APP_SECRETS_FILE=var/secrets/generated.env +# The address plMail is reached at from outside — what Google and Microsoft +# call back to with push notifications. Blank on purpose: it is asked for on the +# setup screen and written to var/secrets/generated.env, because a worker +# building a push subscription has no request to infer a hostname from. Set it +# here or in the environment to manage it yourself. +APP_PUBLIC_URL= ###< symfony/framework-bundle ### ###> symfony/routing ### @@ -37,7 +62,10 @@ DEFAULT_URI=http://localhost # DATABASE_URL="sqlite:///%kernel.project_dir%/var/data_%kernel.environment%.db" # DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4" # DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4" -DATABASE_URL="postgresql://app:!ChangeMe!@database:5432/app?serverVersion=18&charset=utf8" +# Blank on purpose — see the note at the top of this file. The entrypoint builds +# it from the generated POSTGRES_PASSWORD. Set it to point at a database of your +# own, and nothing overrides you. +DATABASE_URL="" ###< doctrine/doctrine-bundle ### ###> symfony/messenger ### @@ -54,11 +82,11 @@ MAILER_DSN=null://null ###> app/encryption ### # Base64-encoded 32-byte libsodium secretbox key used by the `encrypted_string` # Doctrine type to encrypt account credentials at rest. SEPARATE from APP_SECRET. -# Generate a fresh one with: -# openssl rand -base64 32 -# This default is for LOCAL DEV ONLY — override with a real secret in production -# (e.g. via real env var or `symfony console secrets:set APP_ENCRYPTION_KEY`). -APP_ENCRYPTION_KEY=chHhLxNFHCRA7exigGBw08i/PWQfIxyWxn63oDUC4/s= +# +# Blank on purpose — see the note at the top of this file. Generated on first +# start, and worth backing up: it is the only thing that can read the mailbox +# passwords and OAuth tokens in your database. +APP_ENCRYPTION_KEY= ###< app/encryption ### APP_DEV_USER_EMAIL= APP_DEV_USER_PASSWORD= @@ -68,7 +96,9 @@ APP_DEV_USER_PASSWORD= # The URL of the Mercure hub, used by the app to publish updates (can be a local URL) MERCURE_URL=http://mercure/.well-known/mercure MERCURE_PUBLIC_URL=https://localhost/.well-known/mercure -MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!" +# Blank on purpose — see the note at the top of this file. Generated on first +# start, and read by both the app and the hub container from the same file. +MERCURE_JWT_SECRET= ###< symfony/mercure-bundle ### ###> google oauth ### @@ -94,8 +124,11 @@ APP_CONTAINER_NAME=web TRUSTED_PROXIES=127.0.0.1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 ###> lexik/jwt-authentication-bundle ### -JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem -JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem +# Beside the generated secrets rather than in config/jwt: the keys are not in +# the image (they are gitignored), so each service would otherwise generate its +# own and reject tokens the others signed. Generated by `app:secrets:init`. +JWT_SECRET_KEY=%kernel.project_dir%/var/secrets/jwt/private.pem +JWT_PUBLIC_KEY=%kernel.project_dir%/var/secrets/jwt/public.pem # Real passphrase lives in .env.local (untracked); config/jwt/*.pem is gitignored. JWT_PASSPHRASE= ###< lexik/jwt-authentication-bundle ### diff --git a/Dockerfile b/Dockerfile index 4a5b6de7..1da05b1b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -53,6 +53,9 @@ ENV COMPOSER_ALLOW_SUPERUSER=1 COPY --link frankenphp/conf.d/10-app.ini $PHP_INI_DIR/conf.d/ COPY --link --chmod=755 frankenphp/docker-entrypoint.sh /usr/local/bin/docker-entrypoint +# Its own binary, not just a step inside the entrypoint: the secrets-init +# service runs it on its own, before Postgres and Mercure start. +COPY --link --chmod=755 frankenphp/generate-secrets.sh /usr/local/bin/generate-secrets COPY --link frankenphp/Caddyfile /etc/frankenphp/Caddyfile ENTRYPOINT ["docker-entrypoint"] diff --git a/README.md b/README.md index 1f30f446..5d66a883 100644 --- a/README.md +++ b/README.md @@ -86,31 +86,11 @@ Microsoft — see [Connecting Gmail](#connecting-gmail) below. git clone https://github.com/karatektus/pl_mail.git ``` -```bash -cd pl_mail && cp .env .env.local -``` +There is nothing to fill in before starting. The secrets are generated on first start, and the one +setting that has no sensible default — the address people reach plMail at — is asked for on the setup +screen, prefilled with the address you opened it on. -Open `.env.local` and set at least these three before starting: - -| Setting | What to put there | -|---|---| -| `APP_PUBLIC_URL` | The address you'll reach plMail at, e.g. `https://mail.example.com` | -| `APP_SECRET` | Any long random string | -| `APP_ENCRYPTION_KEY` | The key your mailbox credentials are encrypted with — generate one below | - -Generate the encryption key with: - -```bash -openssl rand -base64 32 -``` - -plMail encrypts every mailbox password and OAuth token with this key before writing it to the -database, so a stolen database dump or backup discloses no credentials on its own. That cuts both -ways: **if you lose the key, the stored credentials cannot be recovered** and you'll have to re-enter -them. Back it up separately from the database — storing both in the same place defeats the purpose. -The app refuses to start without a valid key rather than quietly falling back to plain text. - -Then start everything: +Start everything: ```bash docker compose up -d @@ -119,6 +99,35 @@ docker compose up -d The database is prepared automatically on first boot. Open [https://localhost](https://localhost), create your account, and add your first mailbox. +### The secrets it generates for you + +On first start plMail mints its own secrets rather than running on the ones committed to this +repository — those are readable by anyone who has cloned it. They go into a single file on a volume +every plMail service shares: + +``` +var/secrets/generated.env +``` + +It holds `APP_SECRET`, the `APP_ENCRYPTION_KEY` your mailbox credentials are encrypted with, the +database password, the Mercure hub secret and a VAPID keypair for push notifications. Nothing has a +working default any more — there is no `!ChangeMe!` left to forget about. Two things follow: + +**Back that volume up, separately from the database.** plMail encrypts every mailbox password and +OAuth token with the key before writing it, so a stolen database dump discloses no credentials on +its own. The same property cuts the other way: *lose the key and the stored credentials cannot be +recovered.* Storing the backup of both in one place defeats the point of either. + +**Set any of them yourself and yours wins.** Pass `APP_ENCRYPTION_KEY` (or any of the others) as an +environment variable and nothing is generated for it. That is the path to take if you already manage +secrets somewhere else. One thing to know if you do: `docker compose` reads the project's `.env` to +fill in `${VAR}`, so a value put there reaches every container — which is exactly why the committed +`.env` leaves these blank. + +If a service comes up holding a key that cannot decrypt what is already stored — usually because the +`app_secrets` volume was left off one of the services — it refuses to start and says so, rather than +saving accounts that nothing else can read. + ### Adding an IMAP mailbox **Settings → Mail accounts → Add account.** Enter the address and password; plMail recognises common diff --git a/assets/controllers/onboarding/autostart_controller.js b/assets/controllers/onboarding/autostart_controller.js new file mode 100644 index 00000000..57a3a42e --- /dev/null +++ b/assets/controllers/onboarding/autostart_controller.js @@ -0,0 +1,75 @@ +import { Controller } from "@hotwired/stimulus" + +/** + * Opens the setup wizard by itself, once, for a user who has not been through it. + * + * Sits on the same element as ui--modal and drives it, rather than duplicating + * any of the dialog handling: the src, title and size all live in that + * controller's values, set in _boot.html.twig. + * + * Two things it is careful about: + * + * Turbo caches page snapshots and replays them on a restoration visit, so a + * plain "have I run" flag on the instance would re-open the wizard every time + * the user pressed Back. The guard is in sessionStorage, keyed per tab, so it + * survives those replays and still lets a new session start fresh. + * + * Dismissing the dialog counts as finishing. The wizard opens by itself + * exactly once, so a user who closes it has answered the question — anything + * else means nagging them on every page load. The user menu is how they get + * back to it. + */ +export default class extends Controller { + static values = { + finishUrl: String, + token: String, + } + + connect() { + this._onClosed = this._handleClosed.bind(this) + document.addEventListener("ui--modal:closed", this._onClosed) + + if (sessionStorage.getItem(GUARD_KEY)) { + return + } + + const modal = this.application.getControllerForElementAndIdentifier(this.element, "ui--modal") + + if (!modal) { + console.warn("[onboarding] no ui--modal controller on the boot element") + return + } + + sessionStorage.setItem(GUARD_KEY, "1") + this._opened = true + modal.open() + } + + disconnect() { + document.removeEventListener("ui--modal:closed", this._onClosed) + } + + _handleClosed() { + // Only our own dialog: the modal shell is shared, and closing a label + // form must not end anyone's setup. + if (!this._opened) { + return + } + + this._opened = false + + const body = new FormData() + body.append("_token", this.tokenValue) + + // keepalive so the write still lands if the close was the user + // navigating away in the same breath. + fetch(this.finishUrlValue, { method: "POST", body, keepalive: true }) + .catch(() => { + // Nothing to recover to — the wizard reopens on the next page + // load, which is the safe direction to fail in. + }) + } +} + +/** Per-tab, so a Turbo restoration visit does not re-open the wizard. */ +const GUARD_KEY = "plmail:onboarding-autostarted" diff --git a/assets/controllers/ui/modal_controller.js b/assets/controllers/ui/modal_controller.js index 1be274be..b1f9d194 100644 --- a/assets/controllers/ui/modal_controller.js +++ b/assets/controllers/ui/modal_controller.js @@ -83,6 +83,11 @@ export default class extends Controller { // Clear the frame so the next open always fetches fresh if (frame) frame.src = "" + + // Announced on document, not on this.element: whoever cares about a + // dialog closing is rarely inside the dialog. The onboarding wizard + // treats a close as "finished", since it only ever opens by itself once. + this.dispatch("closed", { target: document }) } backdropClick(event) { diff --git a/compose.override.yaml b/compose.override.yaml index a644867c..a197dace 100644 --- a/compose.override.yaml +++ b/compose.override.yaml @@ -93,6 +93,10 @@ services: target: frankenphp_dev volumes: - .:/app + # Runs bin/console, so it goes through the app entrypoint and needs the + # same secrets every other service uses. Without this it would generate + # its own and refuse to start. + - app_secrets:/app/var/secrets - app_var:/app/var command: php bin/console tailwind:build --watch depends_on: diff --git a/compose.override.yaml.dist b/compose.override.yaml.dist index 5f4d76e1..9de09d09 100644 --- a/compose.override.yaml.dist +++ b/compose.override.yaml.dist @@ -68,6 +68,10 @@ services: target: frankenphp_dev volumes: - .:/app + # Runs bin/console, so it goes through the app entrypoint and needs the + # same secrets every other service uses. Without this it would generate + # its own and refuse to start. + - app_secrets:/app/var/secrets command: php bin/console tailwind:build --watch depends_on: - php diff --git a/compose.test.yaml b/compose.test.yaml index 1d250907..c9da103a 100644 --- a/compose.test.yaml +++ b/compose.test.yaml @@ -29,6 +29,13 @@ x-test-app: &test-app environment: &test-env APP_ENV: test APP_DEBUG: "1" + # Pinned, and passed as real environment variables rather than left to + # .env.test: the entrypoint generates these when the environment does not + # carry them, and a generated value would win over the dotenv one. The + # suite wants them fixed — stable CSRF tokens, and a seed encrypted with + # the same key every run. Same values as .env.test. + APP_SECRET: 282065e0f71bf96c0dbcf0496c1f5d3e + APP_ENCRYPTION_KEY: emL6R8zs0pSXCVV2HvVvbFubriWJIla7kgnqSGqCJ8E= # Host is `database`, unlike .env.test which targets 127.0.0.1 for CI. # Doctrine's when@test dbname_suffix (config/packages/doctrine.yaml) turns # this into `app_test` automatically. diff --git a/compose.yaml b/compose.yaml index 816459d6..aef07a2d 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,16 +1,44 @@ x-app-image: &app-image ghcr.io/karatektus/pl_mail:latest +# Every secret below is blank by default and generated on first start by the +# secrets-init service, into the shared app_secrets volume. Set any of them in +# your shell or a .env beside this file and yours is used instead — nothing is +# ever generated over the top of a value you supplied. +# +# DATABASE_URL is deliberately absent: the app entrypoint assembles it from the +# generated POSTGRES_PASSWORD. Set it explicitly to point at a database of your +# own, and the entrypoint leaves it alone. x-app-env: &app-env APP_ENV: ${APP_ENV:-prod} - APP_SECRET: ${APP_SECRET} - DATABASE_URL: postgresql://${POSTGRES_USER:-app}:${POSTGRES_PASSWORD:-!ChangeMe!}@database:5432/${POSTGRES_DB:-app}?serverVersion=${POSTGRES_VERSION:-18}&charset=${POSTGRES_CHARSET:-utf8} + APP_SECRET: ${APP_SECRET:-} + APP_ENCRYPTION_KEY: ${APP_ENCRYPTION_KEY:-} + DATABASE_URL: ${DATABASE_URL:-} + POSTGRES_USER: ${POSTGRES_USER:-app} + POSTGRES_DB: ${POSTGRES_DB:-app} + POSTGRES_VERSION: ${POSTGRES_VERSION:-18} 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} - MERCURE_JWT_SECRET: ${MERCURE_JWT_SECRET:-'!ChangeThisMercureHubJWTSecretKey!'} - APP_ENCRYPTION_KEY: ${APP_ENCRYPTION_KEY} services: + # Mints APP_SECRET, APP_ENCRYPTION_KEY, POSTGRES_PASSWORD and + # MERCURE_JWT_SECRET into app_secrets, then exits. Runs before everything + # else because Postgres and Mercure read their secrets when their containers + # are created — they cannot wait for the app to hand them one. + # + # Idempotent: on every later start it finds the file and does nothing. + secrets-init: + image: *app-image + restart: "no" + entrypoint: ["generate-secrets"] + environment: + APP_SECRET: ${APP_SECRET:-} + APP_ENCRYPTION_KEY: ${APP_ENCRYPTION_KEY:-} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-} + MERCURE_JWT_SECRET: ${MERCURE_JWT_SECRET:-} + volumes: + - app_secrets:/app/var/secrets + php: image: *app-image restart: unless-stopped @@ -22,6 +50,10 @@ services: volumes: - caddy_data:/data - caddy_config:/config + # Every app service mounts this. The secrets generated on first run live + # here, and a service that cannot see them generates its own and stops + # being able to read what the others wrote. + - app_secrets:/app/var/secrets ports: - target: 80 published: ${HTTP_PORT:-80} @@ -33,6 +65,8 @@ services: published: ${HTTP3_PORT:-443} protocol: udp depends_on: + secrets-init: + condition: service_completed_successfully database: condition: service_healthy @@ -44,9 +78,14 @@ services: environment: <<: *app-env APP_CONTAINER_NAME: imap-supervisor + volumes: + # See the note on the php service: this mount is not optional. + - app_secrets:/app/var/secrets healthcheck: disable: true depends_on: + secrets-init: + condition: service_completed_successfully database: condition: service_healthy @@ -58,9 +97,14 @@ services: environment: <<: *app-env APP_CONTAINER_NAME: messenger-worker + volumes: + # See the note on the php service: this mount is not optional. + - app_secrets:/app/var/secrets healthcheck: disable: true depends_on: + secrets-init: + condition: service_completed_successfully database: condition: service_healthy @@ -74,9 +118,14 @@ services: environment: <<: *app-env APP_CONTAINER_NAME: scheduler + volumes: + # See the note on the php service: this mount is not optional. + - app_secrets:/app/var/secrets healthcheck: disable: true depends_on: + secrets-init: + condition: service_completed_successfully database: condition: service_healthy @@ -84,8 +133,14 @@ services: image: postgres:${POSTGRES_VERSION:-18}-alpine environment: POSTGRES_DB: ${POSTGRES_DB:-app} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-!ChangeMe!} POSTGRES_USER: ${POSTGRES_USER:-app} + # The official image supports _FILE for any of its variables, which is the + # only way to hand it a password that did not exist when compose parsed + # this file. + POSTGRES_PASSWORD_FILE: /run/plmail/postgres_password + depends_on: + secrets-init: + condition: service_completed_successfully healthcheck: test: ["CMD", "pg_isready", "-d", "${POSTGRES_DB:-app}", "-U", "${POSTGRES_USER:-app}"] timeout: 5s @@ -93,6 +148,7 @@ services: start_period: 60s volumes: - database_data:/var/lib/postgresql + - app_secrets:/run/plmail:ro command: - "postgres" - "-c" @@ -105,9 +161,27 @@ services: restart: unless-stopped environment: SERVER_NAME: ':80' - MERCURE_PUBLISHER_JWT_KEY: ${MERCURE_JWT_SECRET:-'!ChangeThisMercureHubJWTSecretKey!'} - MERCURE_SUBSCRIBER_JWT_KEY: ${MERCURE_JWT_SECRET:-'!ChangeThisMercureHubJWTSecretKey!'} + # Left empty on purpose: the entrypoint below fills them from the + # generated file. The hub image has no _FILE support, so sourcing it in a + # shell before exec'ing Caddy is the way in. Setting MERCURE_JWT_SECRET + # yourself still wins — the generator never overwrites it, so both sides + # read the same value. + MERCURE_JWT_SECRET: ${MERCURE_JWT_SECRET:-} + entrypoint: + - /bin/sh + - -c + - | + if [ -z "$$MERCURE_JWT_SECRET" ] && [ -f /run/plmail/generated.env ]; then + MERCURE_JWT_SECRET="$$(sed -n 's/^MERCURE_JWT_SECRET=//p' /run/plmail/generated.env)" + fi + export MERCURE_PUBLISHER_JWT_KEY="$$MERCURE_JWT_SECRET" + export MERCURE_SUBSCRIBER_JWT_KEY="$$MERCURE_JWT_SECRET" + exec "$$@" + - -- command: /usr/bin/caddy run --config /etc/caddy/dev.Caddyfile + depends_on: + secrets-init: + condition: service_completed_successfully healthcheck: test: ["CMD", "curl", "-f", "http://localhost/healthz"] timeout: 5s @@ -116,8 +190,10 @@ services: volumes: - mercure_data:/data - mercure_config:/config + - app_secrets:/run/plmail:ro volumes: + app_secrets: caddy_data: caddy_config: database_data: diff --git a/composer.json b/composer.json index 27bc230d..b1d6b2fc 100644 --- a/composer.json +++ b/composer.json @@ -72,7 +72,10 @@ "autoload": { "psr-4": { "App\\": "src/" - } + }, + "files": [ + "config/bootstrap_generated_secrets.php" + ] }, "autoload-dev": { "psr-4": { diff --git a/config/bootstrap_generated_secrets.php b/config/bootstrap_generated_secrets.php new file mode 100644 index 00000000..cfd56a99 --- /dev/null +++ b/config/bootstrap_generated_secrets.php @@ -0,0 +1,81 @@ + '' !== trim((string) ($_SERVER[$name] ?? $_ENV[$name] ?? '')); + + $put = static function (string $name, string $value) use ($isSet): void { + if ($isSet($name)) { + return; + } + + $_SERVER[$name] = $value; + $_ENV[$name] = $value; + }; + + if (is_readable($path) && is_file($path)) { + foreach (file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) { + if (str_starts_with($line, '#')) { + continue; + } + + [$name, $value] = array_pad(explode('=', $line, 2), 2, ''); + + if ('' !== $name) { + $put($name, $value); + } + } + } + + // The database password is generated too, so the connection string has to + // be assembled after the fact — the same rule the entrypoint applies, kept + // here as well so an `exec` session reaches the same database. + if (false === $isSet('DATABASE_URL') && true === $isSet('POSTGRES_PASSWORD')) { + $get = static fn (string $name, string $default): string => '' !== trim((string) ($_SERVER[$name] ?? $_ENV[$name] ?? '')) + ? trim((string) ($_SERVER[$name] ?? $_ENV[$name])) + : $default; + + $put('DATABASE_URL', sprintf( + 'postgresql://%s:%s@%s:5432/%s?serverVersion=%s&charset=%s', + $get('POSTGRES_USER', 'app'), + rawurlencode($get('POSTGRES_PASSWORD', '')), + $get('POSTGRES_HOST', 'database'), + $get('POSTGRES_DB', 'app'), + $get('POSTGRES_VERSION', '18'), + $get('POSTGRES_CHARSET', 'utf8'), + )); + } +})(); diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 0204c905..9c62b345 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -49,6 +49,10 @@ security: - { path: ^/jmap, roles: IS_AUTHENTICATED } - { path: ^/admin, roles: ROLE_ADMIN } - { path: ^/login, roles: PUBLIC_ACCESS } + # Creates the first administrator, so it cannot require one. Safe only + # because App\Service\Setup\InstallGuard 404s it the moment any user + # exists — see that class before touching this line. + - { path: ^/install, roles: PUBLIC_ACCESS } - { path: ^/, roles: ROLE_USER } when@test: diff --git a/config/packages/twig.yaml b/config/packages/twig.yaml index 0cb1511f..d6ba3def 100644 --- a/config/packages/twig.yaml +++ b/config/packages/twig.yaml @@ -7,6 +7,7 @@ twig: sidebarCounts: '@App\Twig\SidebarCounts' ruleDescriber: '@App\Service\Rule\FilterDescriber' integrations: '@App\Twig\IntegrationsGlobal' + onboarding: '@App\Twig\OnboardingGlobal' when@test: twig: diff --git a/config/reference.php b/config/reference.php index f03faf0c..72d386a3 100644 --- a/config/reference.php +++ b/config/reference.php @@ -1515,8 +1515,8 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * } * @psalm-type MercureConfig = array{ * hubs?: array/dev/null || true + +# Hex, not base64: POSTGRES_PASSWORD is spliced into a DATABASE_URL and +# MERCURE_JWT_SECRET into a Caddy config, and neither wants +, / or =. +random_hex() { + head -c "$1" /dev/urandom | od -An -tx1 | tr -d ' \n' +} + +random_base64() { + head -c "$1" /dev/urandom | base64 | tr -d '\n' +} + +# generate NAME VALUE — append NAME to the secrets file unless the environment +# already carries it or the file already has it. +generate() { + name="$1" + value="$2" + + if [ -n "$(printenv "$name" || true)" ]; then + return 0 + fi + + if grep -q "^$name=" "$SECRETS_FILE"; then + return 0 + fi + + echo "$name=$value" >>"$SECRETS_FILE" + echo "Generated $name." +} + +( + flock 9 + + [ -f "$SECRETS_FILE" ] || : >"$SECRETS_FILE" + chmod 600 "$SECRETS_FILE" 2>/dev/null || true + + generate APP_SECRET "$(random_hex 32)" + + # 32 bytes, base64 — the size libsodium secretbox requires; see + # App\Infrastructure\Encryption\Encryptor. Losing this one loses every + # 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 999, 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 +) 9>"$SECRETS_DIR/.lock" diff --git a/phpunit.dist.xml b/phpunit.dist.xml index 276262d6..f2d10a77 100644 --- a/phpunit.dist.xml +++ b/phpunit.dist.xml @@ -14,6 +14,10 @@ + +