mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
Carry an instance's configuration in one password-sealed file
Export from the admin area: env secrets, the JWT keypair and the database-held provider credentials - FCM included, decrypted inside the envelope because the restore target holds a different encryption key - sealed with Argon2id and secretbox at parameters a Raspberry Pi can afford, in a documented format a php one-liner can open without plMail. Import shows its plan before touching anything: what it can write is written and re-encrypted under this instance's key, what it cannot - process environment, the Postgres role's own password - is rendered as the exact lines to place by hand. The install wizard offers the same restore before the first account exists, behind the same guard that closes /install forever afterwards. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
84a5eed8b3
commit
1adac1fe40
38 files changed
+5204
-5
No files matched your search
@@ -48,6 +48,7 @@ mechanism behind it.
|
||||
| [Behind a reverse proxy](install/reverse-proxy.md) | TLS, `APP_PUBLIC_URL`, `TRUSTED_PROXIES`, and what breaks without them |
|
||||
| [Configuration reference](install/configuration.md) | Every environment variable, what it does and what happens if it is wrong |
|
||||
| [Backup and restore](install/backup-restore.md) | What to back up, the encryption key, and restoring onto a new host |
|
||||
| [Configuration backup](install/config-backup.md) | Carrying an install's settings and credentials to another one, as one encrypted file |
|
||||
| [Upgrading](install/upgrading.md) | Migrations run on boot, what that means, and how to tell which build is running |
|
||||
| [Troubleshooting](install/troubleshooting.md) | Health checks, the queue, logs, and the failures that have actually happened |
|
||||
|
||||
|
||||
+2
-1
@@ -1,4 +1,4 @@
|
||||
<!-- translated-from: README.md sha1:09db3ee4cac9d287547887f24392f28a6899342e -->
|
||||
<!-- translated-from: README.md sha1:51634b0e6859cd1836b263f678af628da764270f -->
|
||||
|
||||
# plMail-Dokumentation
|
||||
|
||||
@@ -50,6 +50,7 @@ wo der Mechanismus dahinter steht.
|
||||
| [Hinter einem Reverse Proxy](install/reverse-proxy.md) | TLS, `APP_PUBLIC_URL`, `TRUSTED_PROXIES` — und was ohne sie kaputtgeht |
|
||||
| [Konfigurationsreferenz](install/configuration.md) | Jede Umgebungsvariable, was sie tut und was passiert, wenn sie falsch steht |
|
||||
| [Sichern und Wiederherstellen](install/backup-restore.md) | Was zu sichern ist, der Verschlüsselungsschlüssel und die Wiederherstellung auf einem neuen Host |
|
||||
| [Konfigurationssicherung](install/config-backup.md) | Einstellungen und Zugangsdaten einer Installation als eine verschlüsselte Datei zu einer anderen tragen |
|
||||
| [Aktualisieren](install/upgrading.md) | Migrationen laufen beim Start, was das bedeutet, und wie du erkennst, welcher Build läuft |
|
||||
| [Fehlersuche](install/troubleshooting.md) | Health-Checks, die Warteschlange, Protokolle und die Fehler, die tatsächlich vorgekommen sind |
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- translated-from: features/admin.md sha1:9754c799bab994f83af0846421b46b232d2f0b28 -->
|
||||
<!-- translated-from: features/admin.md sha1:01f2e2e79164990302b960285a2343fa09c8e256 -->
|
||||
|
||||
# Administration
|
||||
|
||||
@@ -289,6 +289,41 @@ Administratorin. Die zweite ist die, die im Moment gut aussieht — jemand entfe
|
||||
und niemand merkt es, bis das nächste Mal jemand das Panel braucht. Dich selbst oder die letzte
|
||||
Administratorin herabzustufen wird aus demselben Grund abgelehnt.
|
||||
|
||||
## Sicherung
|
||||
|
||||
Alles, womit diese Installation *konfiguriert* ist, in einer passwortverschlüsselten Datei — und der
|
||||
Weg zurück. Keine E-Mails: keine Nachrichten, keine Kalender, keine Benutzerkonten. Die ganze
|
||||
Geschichte samt Dateiformat steht in der [Konfigurationssicherung](../install/config-backup.md); das
|
||||
Format ist dokumentiert, damit die Datei nie davon abhängt, dass plMail läuft.
|
||||
|
||||
**Exportieren** fragt zweimal nach einem Passwort und lädt `plmail-config-<datum>.backup` herunter.
|
||||
Das Passwort wird nirgends gespeichert, und es gibt keine Wiederherstellung dafür. Die Datei enthält
|
||||
jedes Geheimnis der Installation, auch den entschlüsselten Inhalt der verschlüsselten Spalten — das
|
||||
ist Absicht, denn die Installation, auf der sie geöffnet wird, hat einen anderen
|
||||
`APP_ENCRYPTION_KEY`, und Chiffrat wäre dort unlesbar. Bewahre die Datei so sorgfältig auf wie den
|
||||
Schlüssel selbst.
|
||||
|
||||
**Importieren** nimmt Datei und Passwort und zeigt eine Prüfung, bevor irgendetwas geschrieben wird.
|
||||
Die Prüfung hat zwei Hälften, und genau darauf kommt es an:
|
||||
|
||||
- Was plMail selbst schreibt: das Firebase-Projekt, die Mail-OAuth-Registrierungen und die
|
||||
Integrationsanbieter, neu verschlüsselt mit dem Schlüssel *dieser* Installation. Dazu das
|
||||
JWT-Schlüsselpaar, sofern der Prozess das Secrets-Volume tatsächlich schreiben kann — das wird
|
||||
gemessen, nicht angenommen.
|
||||
- Was es nicht kann: jede Umgebungsvariable, allen voran `APP_ENCRYPTION_KEY`, und
|
||||
`postgres_password`. Diese kommen als exakte Zeilen zum Einfügen und exakte Pfade zurück, jeweils
|
||||
mit dem Grund, warum sie in dieser Hälfte stehen. Ein laufender Prozess kann seine eigene Umgebung
|
||||
nicht ändern, und ein Import, der etwas anderes behauptete, fiele an dem Tag auf, an dem es darauf
|
||||
ankäme.
|
||||
|
||||
Jede Zeile sagt außerdem, ob der Wert hier neu ist, etwas anderes ersetzt oder bereits
|
||||
übereinstimmt. Bei diesem mittleren Zustand lohnt es sich innezuhalten: Eine Wiederherstellung auf
|
||||
einer laufenden Installation ersetzt lebende Zugangsdaten.
|
||||
|
||||
Derselbe Import läuft auch bei der Ersteinrichtung, unter dem Kontoformular auf `/install`, sodass
|
||||
eine neue Installation konfiguriert hochgezogen werden kann, bevor es ihren Administrator gibt.
|
||||
Dieser Einstiegspunkt schließt mit 404, sobald das erste Konto angelegt ist.
|
||||
|
||||
## Zurücksetzen
|
||||
|
||||
`app:reset`, als Knöpfe. Sechs Stufen, von denen jede alles löscht, was die darüber löscht, und
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- translated-from: install/backup-restore.md sha1:917baa648ae18fb826274b6994f8fdcc3abdb28f -->
|
||||
<!-- translated-from: install/backup-restore.md sha1:5eeb6136c8276242d84ca034efa8286261f42607 -->
|
||||
# Sichern und Wiederherstellen
|
||||
|
||||
Eine plMail-Installation besteht aus drei Dingen, und eine Sicherung, die nur zwei davon enthält,
|
||||
@@ -18,6 +18,11 @@ Die Datei mit den Geheimnissen enthält mehr als den Schlüssel — `APP_SECRET`
|
||||
`APP_PUBLIC_URL` —, und das JWT-Schlüsselpaar liegt daneben in `var/secrets/jwt/`. All das lässt
|
||||
sich neu erzeugen; der Verschlüsselungsschlüssel nicht.
|
||||
|
||||
Die *Konfiguration* — die Umgebungswerte, jene Dateien im Secrets-Volume und die Zugangsdaten, die
|
||||
ein Administrator in ein Formular getippt hat — lässt sich auch für sich allein mitnehmen, ohne jede
|
||||
E-Mail, als eine einzige verschlüsselte Datei: siehe [Konfigurationssicherung](config-backup.md).
|
||||
Das ist die Datei, die du willst, wenn du eine Installation neu aufbaust statt eine wiederherstellst.
|
||||
|
||||
Zwei Volumes brauchen überhaupt keine Sicherung: `caddy_data`/`caddy_config` und
|
||||
`mercure_data`/`mercure_config` enthalten TLS-Material und Hub-Zustand, die sich von selbst wieder
|
||||
herstellen.
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
<!-- translated-from: install/config-backup.md sha1:912bce495800941997697024e1dc19043758604c -->
|
||||
# Konfigurationssicherung
|
||||
|
||||
Die *Konfiguration* einer Installation ist nicht dasselbe wie ihre *Daten*, und beide gehen auf
|
||||
unterschiedliche Weise verloren. Die Daten zu verlieren ist eine Katastrophe; die Konfiguration zu
|
||||
verlieren ist ein Dienstagnachmittag, an dem du herausfindest, wo der Firebase-Schlüssel geblieben
|
||||
ist, zu welchem Google-Projekt der OAuth-Client gehörte und wie `APP_PUBLIC_URL` früher lautete.
|
||||
[Sichern und Wiederherstellen](backup-restore.md) behandelt die Daten. Diese Seite behandelt das
|
||||
andere: **Administration → Sicherung**, wo jede Einstellung und jede Zugangsdatei dieser Installation
|
||||
in einer einzigen passwortverschlüsselten Datei landet — und wieder zurückkommt.
|
||||
|
||||
Keine einzige Nachricht, kein Kontakt, kein Kalender und kein Benutzerkonto steckt darin. Das ist
|
||||
Absicht: Die Datei ist wenige Kilobyte groß, passt also in einen Passwortmanager, und sie auf eine
|
||||
frische Installation zurückzuspielen ist etwas Ungefährliches statt etwas, das man einmal macht und
|
||||
nie wieder.
|
||||
|
||||
## Wo Konfiguration tatsächlich liegt
|
||||
|
||||
An drei Orten, und zu wissen, welcher welcher ist, ist der größte Teil davon zu verstehen, was der
|
||||
Import kann und was nicht.
|
||||
|
||||
| Wo | Was dort liegt | Kann plMail das schreiben? |
|
||||
|---|---|---|
|
||||
| **Die Umgebung** | `APP_SECRET`, `APP_ENCRYPTION_KEY`, `DATABASE_URL`, `MAILER_DSN`, die VAPID-Schlüssel, die Mercure-Geheimnisse, die OAuth-Zugangsdaten — gesetzt in `.env.local`, in Compose oder einmalig erzeugt in `var/secrets/generated.env` | **Nein.** Ein laufender Prozess kann die Variablen, mit denen er gestartet wurde, nicht ändern |
|
||||
| **Das Secrets-Volume** | `jwt/private.pem`, `jwt/public.pem`, `postgres_password` — die Dateien unter `var/secrets/`, auf dem Volume `app_secrets`, das jeder Dienst einbindet | **Manchmal.** Pro Datei und pro Installation gemessen |
|
||||
| **Die Datenbank** | Das Firebase-Projekt, die Mail-OAuth-Registrierungen, die Einstellungen der Integrationsanbieter — alles, was ein Administrator in ein Formular getippt hat statt in eine Datei | **Ja** |
|
||||
|
||||
Der Export enthält alle drei. Der Import wendet den dritten an, bietet den zweiten dort an, wo das
|
||||
Dateisystem es zulässt, und gibt den ersten als Zeilen zum Einfügen zurück. Er tut nie so, als wäre
|
||||
es anders; siehe [Was der Import kann und was nicht](#was-der-import-kann-und-was-nicht).
|
||||
|
||||
## Exportieren
|
||||
|
||||
**Administration → Sicherung → Konfiguration exportieren.** Passwort zweimal eintippen und
|
||||
**Sicherung herunterladen** drücken. Heraus kommt `plmail-config-<datum>.backup`.
|
||||
|
||||
Das Passwort wird nirgends gespeichert, und es gibt keine Wiederherstellung dafür. Genau deshalb wird
|
||||
es zweimal getippt: Ein vertipptes Passwort ergibt eine Datei, die in Ordnung aussieht und sich an
|
||||
dem Tag als nicht zu öffnen erweist, an dem sie gebraucht wird.
|
||||
|
||||
Die Datei wird im Arbeitsspeicher gebaut und direkt an den Browser gestreamt — nichts Entschlüsseltes
|
||||
wird jemals auf dem Server auf die Platte geschrieben, und die Antwort ist als `no-store` markiert,
|
||||
damit kein Proxy eine Kopie behält.
|
||||
|
||||
### Was darin steckt
|
||||
|
||||
Nur Namen; die Werte gehören dir.
|
||||
|
||||
**Umgebung** — jede dieser Variablen, für die diese Installation tatsächlich einen Wert gesetzt hat.
|
||||
Leere werden weggelassen statt als Leerstring exportiert.
|
||||
|
||||
```
|
||||
APP_ENCRYPTION_KEY APP_SECRET DATABASE_URL POSTGRES_PASSWORD
|
||||
MERCURE_JWT_SECRET MERCURE_PUBLIC_URL JWT_PASSPHRASE
|
||||
MAILER_DSN MESSENGER_TRANSPORT_DSN APP_PUBLIC_URL
|
||||
VAPID_SUBJECT VAPID_PUBLIC_KEY VAPID_PRIVATE_KEY
|
||||
GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET
|
||||
GMAIL_PUBSUB_TOPIC GMAIL_PUBSUB_VERIFICATION_TOKEN
|
||||
MICROSOFT_OAUTH_CLIENT_ID MICROSOFT_OAUTH_CLIENT_SECRET MICROSOFT_OAUTH_TENANT
|
||||
INTEGRATIONS_ALLOW_HTTP INTEGRATIONS_ALLOWED_HOSTS
|
||||
TRUSTED_PROXIES APP_DEFAULT_TIMEZONE APP_DB_LOG_LEVEL DEFAULT_URI
|
||||
```
|
||||
|
||||
**Bewusst nicht exportiert**, weil sie die Maschine beschreiben und nicht die Installation, und weil
|
||||
sie mitzunehmen das Ziel eher kaputtmacht als konfiguriert: `APP_ENV`, `APP_DEBUG`, die
|
||||
`APP_DEV_USER_*`-Fixtures, `APP_CONTAINER_NAME`, `APP_SECRETS_FILE`, `JWT_SECRET_KEY`,
|
||||
`JWT_PUBLIC_KEY`, `APP_STORAGE_DIR`, `APP_SHARE_DIR` und `MERCURE_URL`. Der *Inhalt* der
|
||||
JWT-Schlüssel reist mit; die Pfade, unter denen sie liegen, gehören der jeweils lesenden Installation,
|
||||
und `MERCURE_URL` ist die netzinterne Adresse eines Nachbarcontainers. Jede davon ist in der
|
||||
[Konfigurationsreferenz](configuration.md) beschrieben.
|
||||
|
||||
**Dateien**, adressiert über einen logischen Namen statt über einen Pfad, damit das Ziel sie dorthin
|
||||
legt, wo seine eigene Konfiguration sie erwartet:
|
||||
|
||||
```
|
||||
jwt/private.pem jwt/public.pem postgres_password
|
||||
```
|
||||
|
||||
**Datenbank**:
|
||||
|
||||
```
|
||||
fcmConfig das Firebase-Projekt: der Service-Account-Schlüssel, die aus
|
||||
google-services.json geparste Client-Konfiguration und ob
|
||||
Push eingeschaltet ist
|
||||
mailProviders pro Anbieter (google, microsoft): Client-ID, Client-Secret,
|
||||
der Pub/Sub-Verifizierungstoken, der Einstellungs-Bag
|
||||
integrationProviders pro Anbieter (nextcloud, immich, googleDrive, …): aktiviert,
|
||||
Basis-URL, Client-ID, Client-Secret, der Einstellungs-Bag
|
||||
```
|
||||
|
||||
**Diese werden entschlüsselt exportiert.** In der Datenbank liegen sie in
|
||||
`encrypted_string`-Spalten, lesbar nur mit dem `APP_ENCRYPTION_KEY`, der sie geschrieben hat — und
|
||||
der ganze Sinn einer Konfigurationssicherung ist, dass sie anderswo geöffnet wird, von einer
|
||||
Installation mit einem anderen Schlüssel. Chiffrat wäre totes Gewicht. Das Passwort des Umschlags ist
|
||||
der Schutz; die Spaltenverschlüsselung ist ein anderer Schutz gegen eine andere Bedrohung, und beides
|
||||
übereinanderzulegen ergäbe eine Datei, die sicher und nutzlos ist.
|
||||
|
||||
Daraus folgt: **Die Datei enthält jedes Geheimnis dieser Installation, und das Passwort, das du
|
||||
eintippst, ist das Einzige, was sie schützt.** Behandle sie genau so, wie du `APP_ENCRYPTION_KEY`
|
||||
behandelst.
|
||||
|
||||
## Das Dateiformat
|
||||
|
||||
Hier dokumentiert, damit die Datei nie davon abhängt, dass plMail läuft. Sie ist ein einziges
|
||||
JSON-Objekt:
|
||||
|
||||
```json
|
||||
{
|
||||
"format": "plmail-config-backup",
|
||||
"version": 1,
|
||||
"kdf": {
|
||||
"name": "argon2id",
|
||||
"opslimit": 3,
|
||||
"memlimit": 67108864,
|
||||
"salt": "<base64, 16 Bytes>"
|
||||
},
|
||||
"cipher": {
|
||||
"name": "xsalsa20poly1305",
|
||||
"nonce": "<base64, 24 Bytes>"
|
||||
},
|
||||
"ciphertext": "<base64 von crypto_secretbox(dokument, nonce, key)>"
|
||||
}
|
||||
```
|
||||
|
||||
- **`key` = `crypto_pwhash(32, passwort, salt, opslimit, memlimit, ALG_ARGON2ID13)`** — libsodiums
|
||||
Argon2id, mit den Parametern aus der Datei statt aus Annahmen, damit ein späteres Anheben alte
|
||||
Sicherungen weiterhin öffenbar lässt.
|
||||
- **`ciphertext` = `crypto_secretbox(klartext, nonce, key)`** — XSalsa20-Poly1305. Der
|
||||
Poly1305-Tag ist der Grund, warum eine manipulierte Datei sich nicht öffnen lässt, statt zu Müll zu
|
||||
entschlüsseln — und warum ein falsches Passwort und eine veränderte Datei als dasselbe gemeldet
|
||||
werden: Sie sind nicht unterscheidbar.
|
||||
- **`opslimit` 3 mit `memlimit` 64 MiB** ist libsodiums MODERATE-Iterationszahl mit dessen
|
||||
INTERACTIVE-Speicher. Die Referenzinstallation von plMail ist ein Raspberry Pi, auf dem eine
|
||||
Allokation von 256 MiB ein Viertel der Maschine ist und aus einer langsamen Seite einen vom
|
||||
OOM-Killer erledigten Worker macht; Iterationen kosten nur Wanduhrzeit, also wird diese Hälfte
|
||||
angehoben, um einen Teil dessen zurückzukaufen, was die Speicherhälfte aufgibt.
|
||||
|
||||
Der Klartext darin ist ein zweites JSON-Objekt und trägt sein eigenes `format` und `version` — die
|
||||
Version des Umschlags beschreibt, wie die Bytes verschlüsselt sind, die des Dokuments beschreibt, was
|
||||
die Felder bedeuten, und eine künftige plMail-Version kann jede für sich anheben:
|
||||
|
||||
```json
|
||||
{
|
||||
"format": "plmail-config-backup",
|
||||
"version": 1,
|
||||
"exportedAt": "2026-08-06T12:00:00+00:00",
|
||||
"instance": "https://mail.example.com",
|
||||
"env": { "APP_SECRET": "…" },
|
||||
"files": { "jwt/private.pem": "<base64>" },
|
||||
"database": { "fcmConfig": { "serviceAccountJson": "…" } }
|
||||
}
|
||||
```
|
||||
|
||||
### Eine Sicherung ohne plMail öffnen
|
||||
|
||||
Jede libsodium-Anbindung genügt. Mit dem PHP, das ohnehin im Container steckt:
|
||||
|
||||
```sh
|
||||
php -r '
|
||||
$e = json_decode(file_get_contents($argv[1]), true);
|
||||
$k = sodium_crypto_pwhash(
|
||||
SODIUM_CRYPTO_SECRETBOX_KEYBYTES, $argv[2],
|
||||
base64_decode($e["kdf"]["salt"]),
|
||||
$e["kdf"]["opslimit"], $e["kdf"]["memlimit"],
|
||||
SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13);
|
||||
echo sodium_crypto_secretbox_open(
|
||||
base64_decode($e["ciphertext"]),
|
||||
base64_decode($e["cipher"]["nonce"]), $k), "\n";
|
||||
' plmail-config-2026-08-06.backup 'dein passwort'
|
||||
```
|
||||
|
||||
Schick es durch `jq`, wenn du es lesbar willst. Beachte, dass das jede Zugangsdatei der Installation
|
||||
in dein Terminal schreibt und, je nach Shell, samt Passwort in dessen Verlauf — mach es in einem
|
||||
Verzeichnis, das du gleich verlässt.
|
||||
|
||||
## Importieren
|
||||
|
||||
**Administration → Sicherung → Konfiguration importieren.** Datei auswählen, Passwort eintippen,
|
||||
**Sicherung prüfen** drücken.
|
||||
|
||||
**Die Prüfung schreibt nichts.** Sie liest die Datei und zeigt zwei Listen: was plMail tun wird und
|
||||
was es nicht kann. Jede Zeile sagt, ob der Wert hier neu ist, ob er etwas anderes ersetzt oder ob er
|
||||
bereits übereinstimmt — dieser mittlere Zustand ist der, bei dem es sich innezuhalten lohnt, denn
|
||||
eine Wiederherstellung auf einer laufenden Installation ersetzt lebende Zugangsdaten durch andere
|
||||
lebende Zugangsdaten.
|
||||
|
||||
**Schreibbare Teile anwenden** fragt das Passwort noch einmal ab und führt dann genau die gezeigte
|
||||
Liste aus. Die Datenbankschreibvorgänge laufen in einer Transaktion, sodass ein Dokument mit
|
||||
kaputtem Firebase-Schlüssel nicht drei Anbieterregistrierungen aus einer fremden Installation
|
||||
zurücklässt.
|
||||
|
||||
Die hochgeladene Datei wird nie gespeichert — weder in einer temporären Datei noch in der Session.
|
||||
Zwischen Prüfung und Anwendung reist sie als genau das Chiffrat, das du hochgeladen hast, durch die
|
||||
Seite zurück; deshalb muss das Passwort ein zweites Mal getippt werden.
|
||||
|
||||
### Was der Import kann und was nicht
|
||||
|
||||
| Abschnitt | Von plMail angewendet | Warum |
|
||||
|---|---|---|
|
||||
| `database` (Firebase, Mail-OAuth, Integrationen) | **Ja**, neu verschlüsselt mit dem `APP_ENCRYPTION_KEY` *dieser* Installation | plMail besitzt diese Zeilen vollständig |
|
||||
| `files` → `jwt/private.pem`, `jwt/public.pem` | **Dort, wo der Prozess sie schreiben kann.** Zur Prüfzeit mit `is_writable` gemessen, nicht angenommen | Das Secrets-Volume darf legitim schreibgeschützt eingebunden sein |
|
||||
| `files` → `postgres_password` | **Nie** | Der Inhalt dieser Datei muss mit dem Passwort einer Rolle innerhalb von Postgres übereinstimmen, und plMail kann die Rolle nicht ändern. Eins ohne das andere zu schreiben ergibt eine Installation, die nicht startet |
|
||||
| `env` → `APP_ENCRYPTION_KEY` | **Nie** | Ihn zu schreiben würde jede hier bereits gespeicherte Zugangsdatei — auch die gerade importierten — beim nächsten Start unlesbar machen |
|
||||
| `env` → alles Übrige | **Nie** | Ein laufender PHP-Prozess kann seine eigene Umgebung nicht ändern, und eine Datei zu schreiben, die der Entrypoint beim *nächsten* Containerstart liest, ist nicht das, was „angewendet“ bedeutet |
|
||||
|
||||
Alles aus den letzten drei Zeilen kommt als exakte Zeile zum Einfügen oder als exakter Pfad zurück.
|
||||
Das ist die ehrliche Antwort, und die Prüfung sagt zu jedem Wert, welcher der vier Gründe zutrifft,
|
||||
damit du „kann nicht“ von „darf nicht“ unterscheiden kannst.
|
||||
|
||||
## Auf eine neue Installation zurückspielen
|
||||
|
||||
Die Reihenfolge, die funktioniert:
|
||||
|
||||
1. **Den Stack leer hochfahren.** Er erzeugt beim ersten Start seine eigenen `APP_SECRET`,
|
||||
`APP_ENCRYPTION_KEY`, `POSTGRES_PASSWORD` und `MERCURE_JWT_SECRET` in `var/secrets/generated.env`.
|
||||
2. **`/install` öffnen.** Unter dem Kontoformular steht *Stelle zuerst eine Konfigurationssicherung
|
||||
wieder her* — Datei hochladen, Passwort eintippen, prüfen, anwenden.
|
||||
3. **Das Administratorkonto anlegen.** Die Wiederherstellung tut das nicht: Eine
|
||||
Konfigurationssicherung enthält Konfiguration, niemals Personen. Die Seite führt anschließend
|
||||
hierher zurück.
|
||||
4. **Die von der Prüfung aufgelisteten Umgebungszeilen einfügen**, in `.env.local` oder in deine
|
||||
Compose-Datei, und den Stack neu starten.
|
||||
|
||||
Schritt 2 kommt vor Schritt 3, weil `/install` endgültig schließt, sobald das erste Konto existiert —
|
||||
und weil die beiden Administratorschritte des Einrichtungsassistenten anhand von „ist schon etwas
|
||||
konfiguriert?“ entscheiden, ob sie zutreffen; eine zuvor durchgeführte Wiederherstellung führt den
|
||||
neuen Administrator also direkt an ihnen vorbei.
|
||||
|
||||
Der Einstiegspunkt für die Wiederherstellung ist durch genau dasselbe geschützt wie `/install`:
|
||||
dadurch, dass die Installation keine Benutzer hat. Sobald einer existiert, antwortet er mit 404, und
|
||||
von da an ist die Seite **Administration → Sicherung**.
|
||||
|
||||
### Speziell zu `APP_ENCRYPTION_KEY`
|
||||
|
||||
Du hast zwei Möglichkeiten, und sie sind nicht gleichwertig.
|
||||
|
||||
- **Den Schlüssel der neuen Installation behalten** (der Normalfall, wenn du nichts tust). Die
|
||||
Zugangsdaten aus der Sicherung werden beim Schreiben damit neu verschlüsselt. Das ist der Fall, für
|
||||
den der gesamte Entwurf mit entschlüsseltem Umschlag existiert, und der, der einfach funktioniert.
|
||||
- **Den alten Schlüssel mitnehmen**, indem du den exportierten `APP_ENCRYPTION_KEY` in `.env.local`
|
||||
einträgst, **bevor irgendetwas gespeichert wurde**, und neu startest. Tu das nur, wenn du auch die
|
||||
alte *Datenbank* wiederherstellst, deren Zeilen damit verschlüsselt sind — siehe
|
||||
[Sichern und Wiederherstellen](backup-restore.md).
|
||||
|
||||
Das Zweite *nach* einem Import zu tun, der bereits Zugangsdaten geschrieben hat, hinterlässt Zeilen
|
||||
unter dem einen Schlüssel und einen Prozess mit dem anderen — was `app:secrets:init` beim nächsten
|
||||
Start erkennt und woraufhin es den Start verweigert.
|
||||
|
||||
## Fallstricke
|
||||
|
||||
**Die Sicherung ist keine Datensicherung und gibt auch nicht vor, eine zu sein.** Keine E-Mails, keine
|
||||
Kalender, keine Benutzer, keine Anhänge. Wenn du eine auf einen frischen Host zurückspielst und dort
|
||||
aufhörst, hast du ein korrekt konfiguriertes plMail ohne Inhalt. `app:backup` ist die andere Hälfte;
|
||||
beide Seiten sind aus gutem Grund im Index verlinkt.
|
||||
|
||||
**Verlierst du das Passwort, ist die Datei weg.** Es gibt keine Wiederherstellung, keinen Hinweis und
|
||||
kein Zurücksetzen. Argon2id macht Raten teuer; es macht es nicht möglich. Bewahre das Passwort dort
|
||||
auf, wo du die Datei aufbewahrst — oder dort, wo du in einem Jahr noch hinkommst.
|
||||
|
||||
**Ein falsches Passwort und eine beschädigte Datei sind derselbe Fehler.** Poly1305 authentifiziert
|
||||
das gesamte Chiffrat, plMail kann dir also wirklich nicht sagen, was von beidem es war. Wenn du dir
|
||||
beim Passwort sicher bist, verdächtige die Übertragung, aus der die Datei stammt.
|
||||
|
||||
**Eine Wiederherstellung auf einer *laufenden* Installation ersetzt Zugangsdaten.** Die Prüfung
|
||||
markiert diese Zeilen mit „ersetzt einen anderen Wert“ statt mit „hier noch nicht gesetzt“. Lies
|
||||
diese Spalte: Ein Import ist keine Zusammenführung, und der überschriebene Firebase-Schlüssel ist
|
||||
derjenige, gegen den die Geräte in den Hosentaschen der Leute registriert sind.
|
||||
|
||||
**Anwenden startet nichts neu.** Die Datenbankhälfte wirkt sofort. Die Umgebungshälfte wirkt, wenn du
|
||||
sie eingetragen *und* den Stack neu gestartet hast — und bis dahin läuft die Installation auf den
|
||||
erzeugten Geheimnissen der neuen Maschine, was ein funktionierender Zustand ist, der wie eine
|
||||
fertige Wiederherstellung aussieht.
|
||||
|
||||
**Das `postgres_password` in der Sicherung ist nicht das Passwort der Datenbank, in die du
|
||||
wiederherstellst.** Es ist das vom alten Host. Es wird mitgeführt, weil eine Wiederherstellung des
|
||||
alten *Volumes* es byteweise braucht; es in einen neuen Stack einzutragen, dessen Postgres mit einem
|
||||
anderen initialisiert wurde, bringt dir beim nächsten Start „password authentication failed“ und
|
||||
sonst nichts.
|
||||
|
||||
**Exportieren ist nicht folgenlos.** Die entstehende Datei ist eine vollständige Offline-Kopie jedes
|
||||
Geheimnisses der Installation, mit unbegrenzt vielen Rateversuchen. Eine Sicherung, die im
|
||||
Download-Ordner liegen bleibt, ist eine schlimmere Preisgabe als alles, wogegen diese Funktion
|
||||
schützt.
|
||||
@@ -256,6 +256,37 @@ second is the one that looks fine at the time — an admin removes a colleague,
|
||||
until the next time somebody needs the panel. Demoting yourself, or the last administrator, is
|
||||
refused for the same reason.
|
||||
|
||||
## Backup
|
||||
|
||||
Everything this installation is *configured* with, in one password-encrypted file — and the way to
|
||||
put it back. Not mail: no messages, no calendars, no user accounts. See
|
||||
[Configuration backup](../install/config-backup.md) for the whole story, including the file format,
|
||||
which is documented so the file never depends on plMail being able to run.
|
||||
|
||||
**Export** asks for a password twice and downloads `plmail-config-<date>.backup`. The password is
|
||||
never stored and there is no recovery for it. The file carries every secret the install has,
|
||||
including the decrypted contents of the encrypted columns — that is deliberate, because the install
|
||||
it will be opened on has a different `APP_ENCRYPTION_KEY` and ciphertext would be unreadable there.
|
||||
Keep the file as carefully as you keep the key itself.
|
||||
|
||||
**Import** takes the file and the password and shows a review before anything is written. The review
|
||||
has two halves and the split is the point:
|
||||
|
||||
- What plMail will write itself: the Firebase project, the mail OAuth registrations and the
|
||||
integration providers, re-encrypted with *this* install's key. Plus the JWT keypair, where the
|
||||
process can actually write the secrets volume — that is measured, not assumed.
|
||||
- What it cannot: every environment variable, `APP_ENCRYPTION_KEY` above all, and
|
||||
`postgres_password`. Those come back as exact lines to paste and exact paths to write to, with the
|
||||
reason each one is in this half. A running process cannot change its own environment, and an
|
||||
import that claimed otherwise would be discovered on the day it mattered.
|
||||
|
||||
Each line also says whether it is new here, replaces something different, or already matches. That
|
||||
middle state is worth stopping at: restoring onto a running install replaces live credentials.
|
||||
|
||||
The same import runs during first-time setup, below the account form on `/install`, so a new
|
||||
installation can be brought up configured before its administrator exists. That entry point closes
|
||||
with a 404 the moment the first account is created.
|
||||
|
||||
## Reset
|
||||
|
||||
`app:reset`, as buttons. Six stages, each deleting everything the one above it does and more:
|
||||
|
||||
@@ -16,6 +16,11 @@ The secrets file holds more than the key — `APP_SECRET`, `POSTGRES_PASSWORD`,
|
||||
the VAPID keypair and the `APP_PUBLIC_URL` saved during setup — and the JWT keypair sits beside it
|
||||
in `var/secrets/jwt/`. All of those are regenerable; the encryption key is not.
|
||||
|
||||
The *configuration* — the environment values, those secrets-volume files and the credentials an
|
||||
admin typed into a form — can also be carried on its own, without any mail, as a single encrypted
|
||||
file: see [Configuration backup](config-backup.md). That is the file you want when you are rebuilding
|
||||
an install rather than recovering one.
|
||||
|
||||
Two volumes need no backup at all: `caddy_data`/`caddy_config` and `mercure_data`/`mercure_config`
|
||||
hold TLS material and hub state that are recreated on their own.
|
||||
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
# Configuration backup
|
||||
|
||||
An installation's *configuration* is not the same thing as its *data*, and the two are lost in
|
||||
different ways. Losing the data is a disaster; losing the configuration is a Tuesday afternoon spent
|
||||
finding out where a Firebase key went, which Google project the OAuth client belonged to, and what
|
||||
`APP_PUBLIC_URL` used to be. [Backup and restore](backup-restore.md) covers the data. This page
|
||||
covers the other one: **Admin → Backup**, which puts every setting and credential this installation
|
||||
runs on into a single password-encrypted file, and takes one back.
|
||||
|
||||
Not one message, contact, calendar or user account is in it. That is deliberate — the file is
|
||||
kilobytes, so it fits in a password manager, and restoring it onto a fresh install is a safe thing to
|
||||
do rather than a thing you do once and never again.
|
||||
|
||||
## Where configuration actually lives
|
||||
|
||||
Three places, and knowing which is which is most of understanding what the import can and cannot do.
|
||||
|
||||
| Where | What is there | Can plMail write it? |
|
||||
|---|---|---|
|
||||
| **The environment** | `APP_SECRET`, `APP_ENCRYPTION_KEY`, `DATABASE_URL`, `MAILER_DSN`, the VAPID keys, the Mercure secrets, the OAuth client credentials — set in `.env.local`, in compose, or generated once into `var/secrets/generated.env` | **No.** A running process cannot change the variables it was started with |
|
||||
| **The secrets volume** | `jwt/private.pem`, `jwt/public.pem`, `postgres_password` — the files under `var/secrets/`, on the `app_secrets` volume every service mounts | **Sometimes.** Measured per file, per install |
|
||||
| **The database** | The Firebase project, the mail OAuth registrations, the integration provider settings — everything an admin typed into a form rather than into a file | **Yes** |
|
||||
|
||||
The export carries all three. The import applies the third, offers the second where the filesystem
|
||||
allows it, and hands the first back as lines to paste. It never pretends otherwise; see
|
||||
[What the import can and cannot do](#what-the-import-can-and-cannot-do).
|
||||
|
||||
## Exporting
|
||||
|
||||
**Admin → Backup → Export configuration.** Type a password twice and press **Download backup**. You
|
||||
get `plmail-config-<date>.backup`.
|
||||
|
||||
The password is not stored anywhere and there is no recovery for it. That is why it is typed twice:
|
||||
a mistyped password produces a file that looks fine and is discovered to be unopenable on the day it
|
||||
is needed.
|
||||
|
||||
The file is built in memory and streamed straight to the browser — nothing decrypted is ever written
|
||||
to disk on the server, and the response is marked `no-store` so no proxy keeps a copy.
|
||||
|
||||
### What is in it
|
||||
|
||||
Names only; the values are yours.
|
||||
|
||||
**Environment** — every one of these that this installation actually has a value set for. Empty ones
|
||||
are omitted rather than exported as blanks.
|
||||
|
||||
```
|
||||
APP_ENCRYPTION_KEY APP_SECRET DATABASE_URL POSTGRES_PASSWORD
|
||||
MERCURE_JWT_SECRET MERCURE_PUBLIC_URL JWT_PASSPHRASE
|
||||
MAILER_DSN MESSENGER_TRANSPORT_DSN APP_PUBLIC_URL
|
||||
VAPID_SUBJECT VAPID_PUBLIC_KEY VAPID_PRIVATE_KEY
|
||||
GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET
|
||||
GMAIL_PUBSUB_TOPIC GMAIL_PUBSUB_VERIFICATION_TOKEN
|
||||
MICROSOFT_OAUTH_CLIENT_ID MICROSOFT_OAUTH_CLIENT_SECRET MICROSOFT_OAUTH_TENANT
|
||||
INTEGRATIONS_ALLOW_HTTP INTEGRATIONS_ALLOWED_HOSTS
|
||||
TRUSTED_PROXIES APP_DEFAULT_TIMEZONE APP_DB_LOG_LEVEL DEFAULT_URI
|
||||
```
|
||||
|
||||
**Deliberately not exported**, because they describe the machine rather than the installation, and
|
||||
carrying them would break the target rather than configure it: `APP_ENV`, `APP_DEBUG`, the
|
||||
`APP_DEV_USER_*` fixtures, `APP_CONTAINER_NAME`, `APP_SECRETS_FILE`, `JWT_SECRET_KEY`,
|
||||
`JWT_PUBLIC_KEY`, `APP_STORAGE_DIR`, `APP_SHARE_DIR` and `MERCURE_URL`. The JWT keys' *contents*
|
||||
travel; the paths they live at belong to whichever install is reading them, and `MERCURE_URL` is the
|
||||
in-network address of a sibling container. Every one of these is described in the
|
||||
[configuration reference](configuration.md).
|
||||
|
||||
**Files**, addressed by a logical name rather than by a path, so the target puts them where its own
|
||||
configuration says they go:
|
||||
|
||||
```
|
||||
jwt/private.pem jwt/public.pem postgres_password
|
||||
```
|
||||
|
||||
**Database**:
|
||||
|
||||
```
|
||||
fcmConfig the Firebase project: the service-account key, the client
|
||||
configuration parsed out of google-services.json, and
|
||||
whether push is switched on
|
||||
mailProviders per provider (google, microsoft): client id, client secret,
|
||||
the Pub/Sub verification token, the settings bag
|
||||
integrationProviders per provider (nextcloud, immich, googleDrive, …): enabled,
|
||||
base URL, client id, client secret, the settings bag
|
||||
```
|
||||
|
||||
**These are exported decrypted.** In the database they sit in `encrypted_string` columns, readable
|
||||
only with the `APP_ENCRYPTION_KEY` that wrote them — and the whole point of a config backup is that
|
||||
it is opened somewhere else, by an install with a different key. Ciphertext would be dead weight.
|
||||
The envelope's password is the protection; the column encryption is a different protection against a
|
||||
different threat, and stacking them would produce a file that is safe and useless.
|
||||
|
||||
Which is why: **the file is every secret this installation has, and the password you type is the only
|
||||
thing protecting it.** Treat it exactly as you treat `APP_ENCRYPTION_KEY`.
|
||||
|
||||
## The file format
|
||||
|
||||
Documented here so that the file is never hostage to plMail being able to run. It is one JSON object:
|
||||
|
||||
```json
|
||||
{
|
||||
"format": "plmail-config-backup",
|
||||
"version": 1,
|
||||
"kdf": {
|
||||
"name": "argon2id",
|
||||
"opslimit": 3,
|
||||
"memlimit": 67108864,
|
||||
"salt": "<base64, 16 bytes>"
|
||||
},
|
||||
"cipher": {
|
||||
"name": "xsalsa20poly1305",
|
||||
"nonce": "<base64, 24 bytes>"
|
||||
},
|
||||
"ciphertext": "<base64 of crypto_secretbox(document, nonce, key)>"
|
||||
}
|
||||
```
|
||||
|
||||
- **`key` = `crypto_pwhash(32, password, salt, opslimit, memlimit, ALG_ARGON2ID13)`** — libsodium's
|
||||
Argon2id, with the parameters read from the file rather than assumed, so raising them later leaves
|
||||
old backups openable.
|
||||
- **`ciphertext` = `crypto_secretbox(plaintext, nonce, key)`** — XSalsa20-Poly1305. The Poly1305 tag
|
||||
is why a tampered file fails to open rather than decrypting to rubbish, and why a wrong password
|
||||
and an altered file are reported as the same thing: they are indistinguishable.
|
||||
- **`opslimit` 3 with `memlimit` 64 MiB** is libsodium's MODERATE iteration count with its
|
||||
INTERACTIVE memory. plMail's reference deployment is a Raspberry Pi, where a 256 MiB allocation is
|
||||
a quarter of the machine and turns a slow page into an OOM-killed worker; iterations only cost
|
||||
wall-clock time, so that half is raised to buy back some of what the memory half gives up.
|
||||
|
||||
The plaintext inside is a second JSON object, and it carries its own `format` and `version` — the
|
||||
envelope's version describes how the bytes are encrypted, the document's describes what the fields
|
||||
mean, and a future plMail can bump either alone:
|
||||
|
||||
```json
|
||||
{
|
||||
"format": "plmail-config-backup",
|
||||
"version": 1,
|
||||
"exportedAt": "2026-08-06T12:00:00+00:00",
|
||||
"instance": "https://mail.example.com",
|
||||
"env": { "APP_SECRET": "…" },
|
||||
"files": { "jwt/private.pem": "<base64>" },
|
||||
"database": { "fcmConfig": { "serviceAccountJson": "…" } }
|
||||
}
|
||||
```
|
||||
|
||||
### Opening one without plMail
|
||||
|
||||
Any libsodium binding will do. With the PHP that is already in the container:
|
||||
|
||||
```sh
|
||||
php -r '
|
||||
$e = json_decode(file_get_contents($argv[1]), true);
|
||||
$k = sodium_crypto_pwhash(
|
||||
SODIUM_CRYPTO_SECRETBOX_KEYBYTES, $argv[2],
|
||||
base64_decode($e["kdf"]["salt"]),
|
||||
$e["kdf"]["opslimit"], $e["kdf"]["memlimit"],
|
||||
SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13);
|
||||
echo sodium_crypto_secretbox_open(
|
||||
base64_decode($e["ciphertext"]),
|
||||
base64_decode($e["cipher"]["nonce"]), $k), "\n";
|
||||
' plmail-config-2026-08-06.backup 'your password'
|
||||
```
|
||||
|
||||
Pipe it through `jq` if you want it readable. Note that this prints every credential the
|
||||
installation has to your terminal and, depending on your shell, into its history along with the
|
||||
password — do it in a directory you are about to leave.
|
||||
|
||||
## Importing
|
||||
|
||||
**Admin → Backup → Import configuration.** Choose the file, type its password, press **Review this
|
||||
backup**.
|
||||
|
||||
**Nothing is written by the review.** It reads the file and shows two lists: what plMail is going to
|
||||
do, and what it cannot. Every line says whether it is new here, whether it replaces something
|
||||
different, or whether it already matches — that middle state is the one worth stopping at, because
|
||||
restoring onto a running install means replacing live credentials with other live credentials.
|
||||
|
||||
Pressing **Apply the writable parts** asks for the password once more and then executes exactly the
|
||||
list that was shown. The database writes happen in one transaction, so a document with a broken
|
||||
Firebase key does not leave three provider registrations from someone else's install behind.
|
||||
|
||||
The uploaded file is never stored — not in a temporary file, not in the session. Between the review
|
||||
and the apply it travels back through the page as the same ciphertext you uploaded, which is why the
|
||||
password has to be typed a second time.
|
||||
|
||||
### What the import can and cannot do
|
||||
|
||||
| Section | Applied by plMail | Why |
|
||||
|---|---|---|
|
||||
| `database` (Firebase, mail OAuth, integrations) | **Yes**, re-encrypted with *this* install's `APP_ENCRYPTION_KEY` | plMail owns these rows outright |
|
||||
| `files` → `jwt/private.pem`, `jwt/public.pem` | **Where the process can write them.** Checked with `is_writable` at review time, not assumed | The secrets volume can legitimately be mounted read-only |
|
||||
| `files` → `postgres_password` | **Never** | The string in that file has to agree with the password of a role inside Postgres, and plMail cannot change the role. Writing one without the other gives you an install that will not start |
|
||||
| `env` → `APP_ENCRYPTION_KEY` | **Never** | Writing it would make every credential already stored here — including the ones the import just wrote — unreadable on the next start |
|
||||
| `env` → everything else | **Never** | A running PHP process cannot change its own environment, and writing a file the entrypoint reads at the *next* container start is not what "applied" means |
|
||||
|
||||
Everything in the last three rows comes back as an exact line to paste or an exact path to write to.
|
||||
That is the honest answer, and the review says which of the four reasons applies to each value so you
|
||||
can tell "cannot" from "must not".
|
||||
|
||||
## Restoring onto a new installation
|
||||
|
||||
The order that works:
|
||||
|
||||
1. **Bring the stack up empty.** It generates its own `APP_SECRET`, `APP_ENCRYPTION_KEY`,
|
||||
`POSTGRES_PASSWORD` and `MERCURE_JWT_SECRET` into `var/secrets/generated.env` on first start.
|
||||
2. **Open `/install`.** Below the account form there is *Restore a configuration backup first* —
|
||||
upload the file, type the password, review, apply.
|
||||
3. **Create the administrator account.** The restore does not do this: a config backup carries
|
||||
configuration, never people. The page leads back here when it is done.
|
||||
4. **Paste the environment lines the review listed**, into `.env.local` or your compose file, and
|
||||
restart the stack.
|
||||
|
||||
Steps 2 and 3 are in that order because `/install` closes for good the moment the first account
|
||||
exists — and because the setup wizard's two administrator steps ask "is anything configured yet?" to
|
||||
decide whether they apply, so a restore done first walks the new administrator straight past them.
|
||||
|
||||
The restore entry point is guarded by exactly the same thing `/install` is: the installation having
|
||||
no users. The moment one exists it answers 404, and from then on the page is **Admin → Backup**.
|
||||
|
||||
### About `APP_ENCRYPTION_KEY` specifically
|
||||
|
||||
You have two choices, and they are not equivalent.
|
||||
|
||||
- **Keep the new install's key** (the default if you do nothing). The credentials in the backup are
|
||||
re-encrypted with it as they are written. This is the case the whole decrypted-envelope design
|
||||
exists for and it is the one that just works.
|
||||
- **Carry the old key over**, by putting the exported `APP_ENCRYPTION_KEY` into `.env.local` **before
|
||||
anything has been stored** and restarting. Do this only if you are also restoring the old
|
||||
*database*, whose rows are encrypted with it — see [Backup and restore](backup-restore.md).
|
||||
|
||||
Doing the second one *after* an import has already written credentials leaves you with rows written
|
||||
under one key and a process holding another, which `app:secrets:init` detects on the next start and
|
||||
refuses to run on.
|
||||
|
||||
## Things that bite
|
||||
|
||||
**The backup is not a data backup and does not pretend to be.** No mail, no calendars, no users, no
|
||||
attachments. If you restore one onto a fresh host and stop there, you have a correctly configured
|
||||
plMail with nothing in it. `app:backup` is the other half; both pages are linked from the index for
|
||||
a reason.
|
||||
|
||||
**Lose the password and the file is gone.** There is no recovery, no hint and no reset. Argon2id makes
|
||||
guessing expensive; it does not make it possible. Store the password where you store the file, or
|
||||
somewhere you will still have in a year.
|
||||
|
||||
**A wrong password and a corrupted file are the same error.** Poly1305 authenticates the whole
|
||||
ciphertext, so plMail genuinely cannot tell you which it was. If you are certain of the password,
|
||||
suspect the transfer that produced the file.
|
||||
|
||||
**Restoring onto a *running* install replaces credentials.** The review marks those lines "replaces a
|
||||
different value" rather than "not set here yet". Read that column: an import is not a merge, and the
|
||||
Firebase key that gets overwritten is the one the devices in people's pockets are registered against.
|
||||
|
||||
**Applying does not restart anything.** The database half takes effect immediately. The environment
|
||||
half takes effect when you have pasted it in *and* restarted the stack — and until you do, the
|
||||
install is running on the new machine's generated secrets, which is a working state that looks like
|
||||
a finished restore.
|
||||
|
||||
**`postgres_password` in the backup is not the password of the database you are restoring into.** It
|
||||
is the one from the old host. It is carried because a restore of the old *volume* needs it byte for
|
||||
byte; pasting it into a new stack whose Postgres was initialised with a different one gets you
|
||||
"password authentication failed" on the next start and nothing else.
|
||||
|
||||
**Exporting is not free of consequence.** The file that comes out is a complete, offline, unlimited-
|
||||
attempt copy of every secret the installation has. A backup left in a downloads folder is a worse
|
||||
exposure than anything this feature protects against.
|
||||
@@ -23,7 +23,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
final class AdminDashboardController extends AbstractController
|
||||
{
|
||||
private const array SECTIONS = ['system', 'database', 'logs', 'integrations', 'push', 'users', 'reset'];
|
||||
private const array SECTIONS = ['system', 'database', 'logs', 'integrations', 'push', 'users', 'backup', 'reset'];
|
||||
private const int LOGS_PER_PAGE = 100;
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use App\Domain\DTO\Backup\ConfigBackupPlan;
|
||||
use App\Domain\Enum\Backup\ConfigBackupFailure;
|
||||
use App\Domain\Exception\ConfigBackupException;
|
||||
use App\Domain\Exception\InvalidFirebaseCredentialsException;
|
||||
use App\Infrastructure\Backup\ConfigBackupCipher;
|
||||
use App\Service\Backup\ConfigBackupExporter;
|
||||
use App\Service\Backup\ConfigBackupImporter;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* Admin → Config backup: carrying one installation's setup to another, as a
|
||||
* single password-encrypted file.
|
||||
*
|
||||
* **Four actions and no GET that does anything.** The panel renders; export,
|
||||
* import and apply are POST-only and CSRF-checked, because each of them either
|
||||
* hands out every credential the install owns or replaces them. A GET that
|
||||
* downloaded the file would be a URL that could be embedded in an image tag on
|
||||
* another site and fetched with the admin's own cookies.
|
||||
*
|
||||
* **Import is two requests on purpose**, and the second one carries the
|
||||
* envelope back rather than the server remembering it. Nothing about the upload
|
||||
* is stored between them — not in a temporary file, not in the session — which
|
||||
* is the promise the page makes and the only one worth making about a file that
|
||||
* decrypts to every secret in the install. What travels in the hidden field is
|
||||
* the ciphertext exactly as uploaded; it is no more readable in the page than
|
||||
* it was on the admin's disk, and the password has to be typed again to open
|
||||
* it. That retype is not friction for its own sake: it is what makes "apply"
|
||||
* a second deliberate act rather than a button next to a list.
|
||||
*
|
||||
* **Errors re-render the panel rather than redirecting.** A wrong password has
|
||||
* to come back with the import form open and nothing changed, and a redirect
|
||||
* would need somewhere to carry the reason — a query string, which is where an
|
||||
* admin's failed attempt would end up in a proxy log.
|
||||
*/
|
||||
#[Route('/admin/config-backup', name: 'app_admin_config_backup_')]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
final class ConfigBackupController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* Largest upload accepted, before anything is decoded.
|
||||
*
|
||||
* A real backup is a few kilobytes: a couple of PEMs, a Firebase key and
|
||||
* two dozen environment lines. One megabyte is three orders of magnitude of
|
||||
* headroom and still small enough that a mistaken upload of a mailbox
|
||||
* archive is refused as the mistake it is rather than parsed as JSON.
|
||||
*/
|
||||
private const int MAX_UPLOAD_BYTES = 1048576;
|
||||
|
||||
public function __construct(
|
||||
private readonly ConfigBackupExporter $exporter,
|
||||
private readonly ConfigBackupImporter $importer,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Sent back on the redirect when the export form was filled in wrongly.
|
||||
*
|
||||
* On the query string rather than in the flash bag, the way the reset panel
|
||||
* does it: this frame is loaded by a request of its own and would not see a
|
||||
* flash set for the page around it.
|
||||
*/
|
||||
public const string ERROR_PASSWORD_TOO_SHORT = 'password-too-short';
|
||||
|
||||
public const string ERROR_PASSWORD_MISMATCH = 'password-mismatch';
|
||||
|
||||
/** @var array<string, string> */
|
||||
private const array EXPORT_ERRORS = [
|
||||
self::ERROR_PASSWORD_TOO_SHORT => 'admin.config_backup.export.error.too_short',
|
||||
self::ERROR_PASSWORD_MISMATCH => 'admin.config_backup.export.error.mismatch',
|
||||
];
|
||||
|
||||
#[Route('', name: 'panel', methods: ['GET'])]
|
||||
public function panel(Request $request): Response
|
||||
{
|
||||
return $this->renderPanel(
|
||||
error: self::EXPORT_ERRORS[(string) $request->query->get('error', '')] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the file and send it, without it ever existing anywhere but in this
|
||||
* response.
|
||||
*
|
||||
* The password is typed twice here and nowhere else. There is no recovery
|
||||
* for a mistyped one — the file is sealed with what was sent, and nothing
|
||||
* remembers it — so the confirmation is the only thing standing between an
|
||||
* admin and a backup they will discover is unopenable on the day they need
|
||||
* it.
|
||||
*
|
||||
* The refusals redirect rather than re-rendering the frame, which is the
|
||||
* opposite of what every other action here does and is forced by the
|
||||
* download. Turbo cannot render an octet-stream, so this form carries
|
||||
* data-turbo="false" and the browser submits it as a plain navigation —
|
||||
* which means a 200 carrying frame markup would be displayed as a bare
|
||||
* <turbo-frame> on a blank page. A redirect lands back on the admin
|
||||
* section, where the frame reloads itself and reads the reason off the
|
||||
* query string.
|
||||
*/
|
||||
#[Route('/export', name: 'export', methods: ['POST'])]
|
||||
public function export(Request $request): Response
|
||||
{
|
||||
$this->validateCsrf($request, 'config_backup_export');
|
||||
|
||||
$password = (string) $request->request->get('password', '');
|
||||
$repeated = (string) $request->request->get('password_repeat', '');
|
||||
|
||||
if (strlen($password) < ConfigBackupCipher::MINIMUM_PASSWORD_LENGTH) {
|
||||
return $this->redirectToSection(self::ERROR_PASSWORD_TOO_SHORT);
|
||||
}
|
||||
|
||||
if (false === hash_equals($password, $repeated)) {
|
||||
return $this->redirectToSection(self::ERROR_PASSWORD_MISMATCH);
|
||||
}
|
||||
|
||||
$response = new Response(
|
||||
$this->exporter->export($password),
|
||||
Response::HTTP_OK,
|
||||
['Content-Type' => 'application/octet-stream'],
|
||||
);
|
||||
|
||||
$response->headers->set(
|
||||
'Content-Disposition',
|
||||
$response->headers->makeDisposition('attachment', $this->exporter->filename()),
|
||||
);
|
||||
|
||||
// Every intermediary told, in both dialects: this is one instance's
|
||||
// entire credential set and it must not sit in a proxy cache.
|
||||
$response->headers->set('Cache-Control', 'no-store, private');
|
||||
$response->headers->set('Pragma', 'no-cache');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/** Decrypt and show what would happen. Nothing is written by this action. */
|
||||
#[Route('/import', name: 'import', methods: ['POST'])]
|
||||
public function import(Request $request): Response
|
||||
{
|
||||
$this->validateCsrf($request, 'config_backup_import');
|
||||
|
||||
return $this->review($request, apply: false);
|
||||
}
|
||||
|
||||
/** The second press: do the automatic half of what the review promised. */
|
||||
#[Route('/apply', name: 'apply', methods: ['POST'])]
|
||||
public function apply(Request $request): Response
|
||||
{
|
||||
$this->validateCsrf($request, 'config_backup_apply');
|
||||
|
||||
return $this->review($request, apply: true);
|
||||
}
|
||||
|
||||
// ── Private ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The shared half of import and apply: get the envelope, open it, and
|
||||
* either plan or execute.
|
||||
*
|
||||
* One method because the failure handling is the whole subject and must be
|
||||
* identical in both — an apply that reported a wrong password differently
|
||||
* from an import would be a way to learn something about a file by pressing
|
||||
* the other button.
|
||||
*/
|
||||
private function review(Request $request, bool $apply): Response
|
||||
{
|
||||
$envelope = $apply ? $this->envelopeFromField($request) : $this->envelopeFromUpload($request);
|
||||
|
||||
if (null === $envelope) {
|
||||
return $this->panelWithError('admin.config_backup.import.error.no_file');
|
||||
}
|
||||
|
||||
$password = (string) $request->request->get('password', '');
|
||||
|
||||
if ('' === $password) {
|
||||
return $this->panelWithError('admin.config_backup.import.error.no_password', $envelope);
|
||||
}
|
||||
|
||||
try {
|
||||
$document = $this->importer->open($envelope, $password);
|
||||
|
||||
$plan = $apply ? $this->importer->apply($document) : $this->importer->plan($document);
|
||||
} catch (ConfigBackupException $e) {
|
||||
// The enum's sentence, never the exception's message: that one is
|
||||
// for the log and says more about the file than a page should.
|
||||
return $this->panelWithError(
|
||||
$e->failure->transKey(),
|
||||
ConfigBackupFailure::WrongPassword === $e->failure ? $envelope : null,
|
||||
);
|
||||
} catch (InvalidFirebaseCredentialsException $e) {
|
||||
// The one refusal that comes from inside the document rather than
|
||||
// from the envelope, and the one whose message IS the instruction —
|
||||
// it names the two projects that disagree. The transaction in
|
||||
// ConfigBackupImporter::apply() has already rolled back.
|
||||
return $this->renderPanel(errorLiteral: $e->getMessage(), envelope: $envelope);
|
||||
}
|
||||
|
||||
// The envelope is carried on so the review's own "apply" button has
|
||||
// something to send, and dropped once applied — there is nothing left
|
||||
// to do with it, and a page that keeps it invites a second apply.
|
||||
return $this->renderPanel(plan: $plan, envelope: $apply ? null : $envelope);
|
||||
}
|
||||
|
||||
private function envelopeFromUpload(Request $request): ?string
|
||||
{
|
||||
$file = $request->files->get('backup');
|
||||
|
||||
if (false === $file instanceof UploadedFile || false === $file->isValid()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($file->getSize() > self::MAX_UPLOAD_BYTES) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// getContent() rather than move(): the file is read out of PHP's own
|
||||
// upload temp and never lands anywhere plMail chose, and PHP unlinks it
|
||||
// when the request ends.
|
||||
$contents = $file->getContent();
|
||||
|
||||
return '' === $contents ? null : $contents;
|
||||
}
|
||||
|
||||
private function envelopeFromField(Request $request): ?string
|
||||
{
|
||||
$envelope = (string) $request->request->get('envelope', '');
|
||||
|
||||
return '' === $envelope || strlen($envelope) > self::MAX_UPLOAD_BYTES ? null : $envelope;
|
||||
}
|
||||
|
||||
/**
|
||||
* $envelope is kept only for a wrong password: the admin has the right file
|
||||
* and is one retype away, and making them pick it again is a punishment for
|
||||
* a typo. Any other failure means the file itself is the problem, and
|
||||
* holding on to it would invite a third attempt at something that cannot
|
||||
* work.
|
||||
*/
|
||||
private function panelWithError(string $messageKey, ?string $envelope = null): Response
|
||||
{
|
||||
return $this->renderPanel(error: $messageKey, envelope: $envelope);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every render of this frame goes through here, so the template's four
|
||||
* inputs are always all present. A Twig `default` filter standing in for a
|
||||
* variable one branch forgot is how a page comes to show an empty review
|
||||
* instead of an error.
|
||||
*/
|
||||
private function renderPanel(
|
||||
?ConfigBackupPlan $plan = null,
|
||||
?string $error = null,
|
||||
?string $errorLiteral = null,
|
||||
?string $envelope = null,
|
||||
): Response {
|
||||
return $this->render('admin/backup/_frame.html.twig', [
|
||||
'plan' => $plan,
|
||||
'error' => $error,
|
||||
'errorLiteral' => $errorLiteral,
|
||||
'envelope' => $envelope,
|
||||
'minPasswordLength' => ConfigBackupCipher::MINIMUM_PASSWORD_LENGTH,
|
||||
]);
|
||||
}
|
||||
|
||||
private function redirectToSection(string $error): Response
|
||||
{
|
||||
return $this->redirectToRoute('app_admin_dashboard', ['section' => 'backup', 'error' => $error]);
|
||||
}
|
||||
|
||||
private function validateCsrf(Request $request, string $tokenId): void
|
||||
{
|
||||
if (false === $this->isCsrfTokenValid($tokenId, (string) $request->request->get('_token', ''))) {
|
||||
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Setup;
|
||||
|
||||
use App\Domain\DTO\Backup\ConfigBackupPlan;
|
||||
use App\Domain\Enum\AppLocale;
|
||||
use App\Domain\Enum\Backup\ConfigBackupFailure;
|
||||
use App\Domain\Exception\ConfigBackupException;
|
||||
use App\Domain\Exception\InvalidFirebaseCredentialsException;
|
||||
use App\Service\Backup\ConfigBackupImporter;
|
||||
use App\Service\Setup\InstallGuard;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Translation\LocaleSwitcher;
|
||||
|
||||
/**
|
||||
* The other way to start an install: restore a config backup before creating
|
||||
* the first account.
|
||||
*
|
||||
* **A second entry point to ConfigBackupImporter, not a second importer.** The
|
||||
* classification of what can be applied and what has to be pasted by hand is
|
||||
* the substance of this feature, and having it decided twice — once for admins
|
||||
* and once for setup — is how the two would come to disagree about a file. Only
|
||||
* the guard and the surrounding page differ, which is exactly what a second
|
||||
* controller should be.
|
||||
*
|
||||
* **Before the account, not after**, and the flow decides that rather than
|
||||
* taste. /install exists only while the install has no users and 404s the
|
||||
* moment one appears, so the restore has to happen while that is still true or
|
||||
* it would need a different door. And it pays off immediately downstream: the
|
||||
* setup wizard's two admin steps — the mail OAuth registration and the
|
||||
* integration providers — ask "is anything configured yet?" to decide whether
|
||||
* they apply, so an install restored first walks the new administrator past
|
||||
* both instead of asking them for credentials the file already carried.
|
||||
*
|
||||
* **Unauthenticated, and guarded by the one thing that makes that safe.**
|
||||
* InstallGuard, on every action including the POSTs — the same predicate
|
||||
* InstallController leans on. Anyone who can reach this page can already make
|
||||
* themselves an administrator of this install by filling in the form next to
|
||||
* it, so restoring configuration into it grants nothing further; the moment
|
||||
* that stops being true, the guard closes this route with a 404 rather than a
|
||||
* redirect, which is the same refusal /install gives and says nothing about
|
||||
* what is behind it.
|
||||
*
|
||||
* **A failure leaves the setup restartable.** Nothing here writes users,
|
||||
* nothing marks the install as started, and every refusal renders the same page
|
||||
* again with the form on it. The worst case for a wrong password is a page with
|
||||
* a red line on it and an install still waiting for its first account.
|
||||
*/
|
||||
final class ConfigRestoreController extends AbstractController
|
||||
{
|
||||
/** The same ceiling the admin page applies; see ConfigBackupController. */
|
||||
private const int MAX_UPLOAD_BYTES = 1048576;
|
||||
|
||||
public function __construct(
|
||||
private readonly InstallGuard $guard,
|
||||
private readonly ConfigBackupImporter $importer,
|
||||
private readonly LocaleSwitcher $localeSwitcher,
|
||||
) {}
|
||||
|
||||
#[Route('/install/restore', name: 'app_install_restore', methods: ['GET', 'POST'])]
|
||||
public function restore(Request $request): Response
|
||||
{
|
||||
$this->guard->assertAvailable();
|
||||
$this->honourTheLanguageSelector($request);
|
||||
|
||||
if (false === $request->isMethod('POST')) {
|
||||
return $this->renderPage();
|
||||
}
|
||||
|
||||
$this->validateCsrf($request, 'install_restore');
|
||||
|
||||
return $this->openAndShow($request, $this->envelopeFromUpload($request), apply: false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Its own route rather than a flag on the one above, so that a review can
|
||||
* never become an apply by a resubmission the browser decided to repeat.
|
||||
*/
|
||||
#[Route('/install/restore/apply', name: 'app_install_restore_apply', methods: ['POST'])]
|
||||
public function apply(Request $request): Response
|
||||
{
|
||||
$this->guard->assertAvailable();
|
||||
$this->honourTheLanguageSelector($request);
|
||||
$this->validateCsrf($request, 'install_restore_apply');
|
||||
|
||||
return $this->openAndShow($request, $this->envelopeFromField($request), apply: true);
|
||||
}
|
||||
|
||||
// ── Private ───────────────────────────────────────────────────────────────
|
||||
|
||||
private function openAndShow(Request $request, ?string $envelope, bool $apply): Response
|
||||
{
|
||||
if (null === $envelope) {
|
||||
return $this->renderPage(error: 'admin.config_backup.import.error.no_file');
|
||||
}
|
||||
|
||||
$password = (string) $request->request->get('password', '');
|
||||
|
||||
if ('' === $password) {
|
||||
return $this->renderPage(error: 'admin.config_backup.import.error.no_password', envelope: $envelope);
|
||||
}
|
||||
|
||||
try {
|
||||
$document = $this->importer->open($envelope, $password);
|
||||
|
||||
$plan = $apply ? $this->importer->apply($document) : $this->importer->plan($document);
|
||||
} catch (ConfigBackupException $e) {
|
||||
return $this->renderPage(
|
||||
error: $e->failure->transKey(),
|
||||
envelope: ConfigBackupFailure::WrongPassword === $e->failure ? $envelope : null,
|
||||
);
|
||||
} catch (InvalidFirebaseCredentialsException $e) {
|
||||
return $this->renderPage(errorLiteral: $e->getMessage(), envelope: $envelope);
|
||||
}
|
||||
|
||||
return $this->renderPage(plan: $plan, envelope: $apply ? null : $envelope);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nobody is signed in, so UserLocaleSubscriber has no user to read a
|
||||
* language from — the same note InstallController makes, and the same fix.
|
||||
*
|
||||
* Through LocaleSwitcher rather than by calling setLocale() on the
|
||||
* translator, which is what InstallController does and what put a line in
|
||||
* the PHPStan baseline: TranslatorInterface does not declare that method,
|
||||
* and the implementation only happens to have it.
|
||||
*/
|
||||
private function honourTheLanguageSelector(Request $request): void
|
||||
{
|
||||
$locale = AppLocale::tryFrom((string) $request->query->get('_locale', ''));
|
||||
|
||||
if (null !== $locale) {
|
||||
$request->setLocale($locale->value);
|
||||
$this->localeSwitcher->setLocale($locale->value);
|
||||
}
|
||||
}
|
||||
|
||||
private function envelopeFromUpload(Request $request): ?string
|
||||
{
|
||||
$file = $request->files->get('backup');
|
||||
|
||||
if (false === $file instanceof UploadedFile || false === $file->isValid() || $file->getSize() > self::MAX_UPLOAD_BYTES) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$contents = $file->getContent();
|
||||
|
||||
return '' === $contents ? null : $contents;
|
||||
}
|
||||
|
||||
private function envelopeFromField(Request $request): ?string
|
||||
{
|
||||
$envelope = (string) $request->request->get('envelope', '');
|
||||
|
||||
return '' === $envelope || strlen($envelope) > self::MAX_UPLOAD_BYTES ? null : $envelope;
|
||||
}
|
||||
|
||||
private function renderPage(
|
||||
?ConfigBackupPlan $plan = null,
|
||||
?string $error = null,
|
||||
?string $errorLiteral = null,
|
||||
?string $envelope = null,
|
||||
): Response {
|
||||
return $this->render('setup/restore.html.twig', [
|
||||
'plan' => $plan,
|
||||
'error' => $error,
|
||||
'errorLiteral' => $errorLiteral,
|
||||
'envelope' => $envelope,
|
||||
]);
|
||||
}
|
||||
|
||||
private function validateCsrf(Request $request, string $tokenId): void
|
||||
{
|
||||
if (false === $this->isCsrfTokenValid($tokenId, (string) $request->request->get('_token', ''))) {
|
||||
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\DTO\Backup;
|
||||
|
||||
use App\Domain\Enum\Backup\ConfigBackupSection;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Everything a decrypted backup would do to this instance, before any of it is
|
||||
* done — and, after applying, the record of what was.
|
||||
*
|
||||
* One class for both because they are the same list read at two moments, and
|
||||
* two classes would invite them to drift: the review would promise one set of
|
||||
* changes and the confirmation would report another, which is the failure this
|
||||
* whole two-step exists to prevent. `$applied` is the only difference, and the
|
||||
* page says which of the two it is showing.
|
||||
*
|
||||
* `$instance` and `$exportedAt` come out of the envelope rather than from the
|
||||
* request. An admin restoring the wrong file usually holds two backups from two
|
||||
* machines, and the only thing that tells them apart is what is written inside.
|
||||
*/
|
||||
final readonly class ConfigBackupPlan
|
||||
{
|
||||
/**
|
||||
* @param list<ConfigBackupPlanItem> $items
|
||||
*/
|
||||
public function __construct(
|
||||
public array $items,
|
||||
/** The public address of the instance the backup was made from, or null when it had none. */
|
||||
public ?string $instance,
|
||||
public ?DateTimeImmutable $exportedAt,
|
||||
/** False for a review, true once the automatic items have actually been written. */
|
||||
public bool $applied = false,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<ConfigBackupPlanItem>
|
||||
*/
|
||||
public function automatic(): array
|
||||
{
|
||||
return array_values(array_filter($this->items, static fn (ConfigBackupPlanItem $item): bool => $item->isAutomatic()));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<ConfigBackupPlanItem>
|
||||
*/
|
||||
public function manual(): array
|
||||
{
|
||||
return array_values(array_filter($this->items, static fn (ConfigBackupPlanItem $item): bool => false === $item->isAutomatic()));
|
||||
}
|
||||
|
||||
/**
|
||||
* The manual items grouped the way the page shows them, empty sections
|
||||
* dropped.
|
||||
*
|
||||
* Here rather than in Twig: a template that filters a list twice per
|
||||
* section is a template nobody can read, and "which sections have anything
|
||||
* to say" is a question about the plan.
|
||||
*
|
||||
* @return array<string, list<ConfigBackupPlanItem>>
|
||||
*/
|
||||
public function manualBySection(): array
|
||||
{
|
||||
$grouped = [];
|
||||
|
||||
foreach (ConfigBackupSection::cases() as $section) {
|
||||
$items = array_values(array_filter(
|
||||
$this->manual(),
|
||||
static fn (ConfigBackupPlanItem $item): bool => $section === $item->section,
|
||||
));
|
||||
|
||||
if ([] !== $items) {
|
||||
$grouped[$section->value] = $items;
|
||||
}
|
||||
}
|
||||
|
||||
return $grouped;
|
||||
}
|
||||
|
||||
/** Whether applying this plan would leave the instance different from how it is now. */
|
||||
public function hasMaterialChanges(): bool
|
||||
{
|
||||
foreach ($this->automatic() as $item) {
|
||||
if (true === $item->change->isMaterial()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\DTO\Backup;
|
||||
|
||||
use App\Domain\Enum\Backup\ConfigBackupChange;
|
||||
use App\Domain\Enum\Backup\ConfigBackupObstacle;
|
||||
use App\Domain\Enum\Backup\ConfigBackupSection;
|
||||
|
||||
/**
|
||||
* One line of the import review: a thing the backup carries, where it goes, and
|
||||
* whether plMail is going to put it there or tell the admin to.
|
||||
*
|
||||
* A DTO rather than an array because it crosses two boundaries — the importer
|
||||
* builds it and both the admin page and the setup page render it — and because
|
||||
* `$obstacle === null` is the single test for "we will do this ourselves". An
|
||||
* array would let a caller half-answer that.
|
||||
*
|
||||
* `$instruction` carries the SECRET for an environment value, deliberately: an
|
||||
* admin who is told "set APP_SECRET yourself" and not told to what has been
|
||||
* given a chore, not an instruction. They are reading their own backup on their
|
||||
* own admin page, having just typed the password that opened it. For a file
|
||||
* the instruction is a path only — a PEM pasted into a review page is not
|
||||
* copy-pasteable in any useful sense, and the backup file the admin is holding
|
||||
* is where the bytes already are.
|
||||
*/
|
||||
final readonly class ConfigBackupPlanItem
|
||||
{
|
||||
public function __construct(
|
||||
public ConfigBackupSection $section,
|
||||
/** The env variable name, the file's path within the secrets directory, or the table's label. */
|
||||
public string $key,
|
||||
public ConfigBackupChange $change,
|
||||
/** Null when plMail applies this itself. */
|
||||
public ?ConfigBackupObstacle $obstacle = null,
|
||||
/** Exactly what to paste or where to put it; null when nothing is asked of the admin. */
|
||||
public ?string $instruction = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public function isAutomatic(): bool
|
||||
{
|
||||
return null === $this->obstacle;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Enum\Backup;
|
||||
|
||||
/**
|
||||
* What the backup would do to one value, compared with what is there now.
|
||||
*
|
||||
* Three states rather than "will change: yes/no", because the middle one is the
|
||||
* one an admin needs to see before they agree to anything. Restoring onto a
|
||||
* fresh instance is all Absent and reads as obviously safe; restoring onto an
|
||||
* instance that is already running is a list of Differs, and each of those is a
|
||||
* credential being replaced by another one. An import that showed a single
|
||||
* count would hide exactly the case worth stopping at.
|
||||
*
|
||||
* Unchanged is kept and shown rather than filtered out, so the review is a
|
||||
* complete account of the file. A row missing from a list of "what will happen"
|
||||
* is indistinguishable from a value the backup did not carry.
|
||||
*/
|
||||
enum ConfigBackupChange: string
|
||||
{
|
||||
/** Nothing is configured here now; the backup supplies it. */
|
||||
case Absent = 'absent';
|
||||
|
||||
/** Something is configured, and it is not what the backup carries. */
|
||||
case Differs = 'differs';
|
||||
|
||||
/** Already exactly what the backup carries. */
|
||||
case Unchanged = 'unchanged';
|
||||
|
||||
public function transKey(): string
|
||||
{
|
||||
return 'admin.config_backup.change.' . $this->value;
|
||||
}
|
||||
|
||||
/** Whether applying this would actually alter the instance. */
|
||||
public function isMaterial(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::Absent, self::Differs => true,
|
||||
self::Unchanged => false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Enum\Backup;
|
||||
|
||||
/**
|
||||
* Why a config backup could not be opened.
|
||||
*
|
||||
* Four cases and no fifth, because the recovery differs for each and the page
|
||||
* has to say which one happened. "Could not read the file" is the message that
|
||||
* makes an admin try the same password twice; "that password does not open this
|
||||
* file" is the one that makes them go and find the right one.
|
||||
*
|
||||
* Deliberately coarse about the difference between a corrupt envelope and a
|
||||
* wrong password. secretbox cannot tell them apart — a wrong key and a flipped
|
||||
* byte both fail the Poly1305 tag — so the enum does not pretend to. What it
|
||||
* separates is the checks that happen BEFORE decryption (is this even a plMail
|
||||
* backup, is the version one we know) from the one that happens during it,
|
||||
* because those genuinely are distinguishable and mean different things.
|
||||
*/
|
||||
enum ConfigBackupFailure: string
|
||||
{
|
||||
/** The file is not a plMail backup envelope at all. */
|
||||
case NotABackup = 'not-a-backup';
|
||||
|
||||
/** It is one, from a future plMail that writes a format this cannot read. */
|
||||
case UnsupportedVersion = 'unsupported-version';
|
||||
|
||||
/** The envelope opened with nothing inside it that we recognise. */
|
||||
case MalformedDocument = 'malformed-document';
|
||||
|
||||
/** The password did not open it — or the ciphertext has been altered. */
|
||||
case WrongPassword = 'wrong-password';
|
||||
|
||||
/** Translation key for the sentence shown to the admin. */
|
||||
public function transKey(): string
|
||||
{
|
||||
return 'admin.config_backup.error.' . $this->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Enum\Backup;
|
||||
|
||||
/**
|
||||
* Why one value out of a backup cannot be applied by clicking a button.
|
||||
*
|
||||
* The reason this enum exists rather than a boolean: "plMail cannot write this"
|
||||
* is true of a dozen values for four genuinely different causes, and an admin
|
||||
* who is told only that will do the wrong repair. Somebody who sees
|
||||
* "APP_ENCRYPTION_KEY: cannot write" edits .env.local and restarts, and every
|
||||
* credential this instance has stored since becomes unreadable — because the
|
||||
* real answer was not "cannot", it was "must not, unless you do it before
|
||||
* anything is stored".
|
||||
*
|
||||
* So each case names the cause, and the translation attached to it says what to
|
||||
* do about that cause. There is no `default` anywhere that matches on this: a
|
||||
* fifth reason has to be given a sentence before it can be shown.
|
||||
*/
|
||||
enum ConfigBackupObstacle: string
|
||||
{
|
||||
/**
|
||||
* A variable the process was started with. Nothing PHP does now changes it,
|
||||
* and the file it would be written into is read at container start.
|
||||
*/
|
||||
case ProcessEnvironment = 'process-environment';
|
||||
|
||||
/**
|
||||
* APP_ENCRYPTION_KEY, and only it. Writing the backup's key here would make
|
||||
* every credential this instance has already encrypted — including the ones
|
||||
* this very import is about to write — unreadable on the next start. The
|
||||
* decrypted-inside-the-envelope design exists precisely so this does not
|
||||
* have to happen: the values are re-encrypted under the key in force.
|
||||
*/
|
||||
case EncryptionKeyInUse = 'encryption-key-in-use';
|
||||
|
||||
/**
|
||||
* A value that also has to change in something plMail does not administer —
|
||||
* POSTGRES_PASSWORD is a role in a database, not a string in a file, and
|
||||
* writing the file alone produces an instance that cannot start.
|
||||
*/
|
||||
case ExternalSystem = 'external-system';
|
||||
|
||||
/**
|
||||
* The path is right and this process cannot write it. Read-only mount,
|
||||
* wrong uid, or a secrets volume the web service was never given.
|
||||
*/
|
||||
case NotWritable = 'not-writable';
|
||||
|
||||
public function transKey(): string
|
||||
{
|
||||
return 'admin.config_backup.obstacle.' . $this->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Enum\Backup;
|
||||
|
||||
/**
|
||||
* The three places an instance's configuration actually lives.
|
||||
*
|
||||
* They are separate cases and not one flat list because the answer to "can
|
||||
* plMail write this?" is decided per place, not per value: the database it owns
|
||||
* outright, the secrets volume it may or may not be able to write depending on
|
||||
* how the volume was mounted, and the environment it can never write at all —
|
||||
* a running PHP process cannot change the variables it was started with, and
|
||||
* writing them somewhere for the NEXT start is a different promise from the one
|
||||
* an admin clicking "apply" thinks they are getting.
|
||||
*
|
||||
* The review page groups by this, so the grouping is a domain fact rather than
|
||||
* a template's arrangement.
|
||||
*/
|
||||
enum ConfigBackupSection: string
|
||||
{
|
||||
/** Environment variables: .env.local, compose, or the shell. */
|
||||
case Environment = 'env';
|
||||
|
||||
/** Files under the shared secrets directory — the JWT keypair and friends. */
|
||||
case SecretsFile = 'files';
|
||||
|
||||
/** Rows plMail owns: the Firebase project and the provider registrations. */
|
||||
case Database = 'database';
|
||||
|
||||
public function transKey(): string
|
||||
{
|
||||
return 'admin.config_backup.section.' . $this->value;
|
||||
}
|
||||
|
||||
public function icon(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Environment => 'fa-solid fa-terminal',
|
||||
self::SecretsFile => 'fa-solid fa-file-shield',
|
||||
self::Database => 'fa-solid fa-database',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Exception;
|
||||
|
||||
use App\Domain\Enum\Backup\ConfigBackupFailure;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* A config backup that could not be opened, carrying what the admin should do
|
||||
* about it.
|
||||
*
|
||||
* One class rather than four, because the recovery is always the same shape —
|
||||
* show a sentence, change nothing, let them try again — and the part that
|
||||
* varies is which sentence. That variation lives in the enum, which the
|
||||
* template translates; the exception exists to carry it out of the cipher and
|
||||
* the parser without either of them knowing what a template is.
|
||||
*
|
||||
* The message is deliberately never shown to the user. It is for the log, and
|
||||
* it says more than the enum does — a leaked "expected 16 salt bytes, got 3"
|
||||
* on the page would be a small oracle over a file somebody uploaded, and it
|
||||
* would not help anyone holding the right file anyway.
|
||||
*/
|
||||
final class ConfigBackupException extends RuntimeException
|
||||
{
|
||||
public function __construct(
|
||||
public readonly ConfigBackupFailure $failure,
|
||||
string $message,
|
||||
) {
|
||||
parent::__construct($message);
|
||||
}
|
||||
|
||||
public static function notABackup(string $detail): self
|
||||
{
|
||||
return new self(ConfigBackupFailure::NotABackup, $detail);
|
||||
}
|
||||
|
||||
public static function unsupportedVersion(int $version, int $supported): self
|
||||
{
|
||||
return new self(ConfigBackupFailure::UnsupportedVersion, sprintf(
|
||||
'Backup declares format version %d; this plMail reads up to %d.',
|
||||
$version,
|
||||
$supported,
|
||||
));
|
||||
}
|
||||
|
||||
public static function malformed(string $detail): self
|
||||
{
|
||||
return new self(ConfigBackupFailure::MalformedDocument, $detail);
|
||||
}
|
||||
|
||||
public static function wrongPassword(): self
|
||||
{
|
||||
return new self(
|
||||
ConfigBackupFailure::WrongPassword,
|
||||
'secretbox refused the ciphertext: wrong password, or the file has been altered.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -176,6 +176,78 @@ class FcmConfig
|
||||
$this->projectId = $serviceProject ?? $clientProject;
|
||||
}
|
||||
|
||||
/**
|
||||
* Put back a configuration that was exported from another install.
|
||||
*
|
||||
* **A second entry point beside useCredentials(), not a replacement for
|
||||
* it.** That one takes the two files a human downloaded and derives the
|
||||
* columns from them; a config backup no longer has the google-services.json
|
||||
* — it is not a credential, it was parsed to five values at paste time, and
|
||||
* carrying a copy of the raw file would mean the document held two
|
||||
* descriptions of the client half with nothing deciding which wins. So the
|
||||
* client values arrive as values, and only the service-account key arrives
|
||||
* as the file it still is.
|
||||
*
|
||||
* The key is re-parsed rather than trusted, which is what keeps projectId
|
||||
* and clientEmail derived from one place however the row was made — and
|
||||
* incidentally refuses a document somebody has edited by hand. The
|
||||
* project-match rule is then applied exactly as useCredentials() applies
|
||||
* it, because a backup made before that rule existed could carry a mismatch
|
||||
* that has never been checked.
|
||||
*
|
||||
* Nothing is assigned until every check has passed: a refused restore
|
||||
* leaves the row as it was, so an import that fails here has not half-moved
|
||||
* an install onto someone else's Firebase project.
|
||||
*
|
||||
* @throws InvalidFirebaseCredentialsException when the key is not a service
|
||||
* account, or the two halves
|
||||
* name different projects
|
||||
*/
|
||||
public function restore(
|
||||
?string $serviceAccountJson,
|
||||
?string $projectId,
|
||||
?string $applicationId,
|
||||
?string $apiKey,
|
||||
?string $senderId,
|
||||
?string $androidPackage,
|
||||
bool $isEnabled,
|
||||
): void {
|
||||
$account = null === $serviceAccountJson || '' === trim($serviceAccountJson)
|
||||
? null
|
||||
: ServiceAccount::fromJson($serviceAccountJson);
|
||||
|
||||
$client = null !== $applicationId && null !== $apiKey && null !== $senderId;
|
||||
|
||||
// The client half's project comes from the stored column rather than
|
||||
// from a file, because the file it was parsed out of is not carried.
|
||||
// Compared all the same: a document assembled by hand out of two
|
||||
// installs' rows is exactly the mismatch nothing downstream can detect.
|
||||
if (null !== $account && true === $client && null !== $projectId && $account->projectId !== $projectId) {
|
||||
throw new InvalidFirebaseCredentialsException(sprintf(
|
||||
'This backup\'s two Firebase halves disagree: the service-account key is for "%s" '
|
||||
. 'and the stored client configuration is for "%s". Restoring it would produce an '
|
||||
. 'install whose app can never receive what its server sends.',
|
||||
$account->projectId,
|
||||
$projectId,
|
||||
));
|
||||
}
|
||||
|
||||
$this->serviceAccountJson = null === $account ? null : $serviceAccountJson;
|
||||
$this->clientEmail = $account?->clientEmail;
|
||||
$this->projectId = null !== $account ? $account->projectId : ($client ? $projectId : null);
|
||||
|
||||
$this->applicationId = $client ? $applicationId : null;
|
||||
$this->apiKey = $client ? $apiKey : null;
|
||||
$this->senderId = $client ? $senderId : null;
|
||||
$this->androidPackage = $client ? $androidPackage : null;
|
||||
|
||||
// Same rule isActive() enforces at read time, applied at write time so
|
||||
// a restored row cannot advertise `fcm: true` with half a setup behind
|
||||
// it. An enabled flag with no credentials is silently a disabled one,
|
||||
// rather than a refusal: the flag is not the thing being restored.
|
||||
$this->isEnabled = $isEnabled && null !== $account && $client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget the service-account key, and stop — leaving isEnabled true with
|
||||
* nothing behind it would advertise `fcm: true` in the Session and refuse
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Backup;
|
||||
|
||||
use App\Domain\Exception\ConfigBackupException;
|
||||
use JsonException;
|
||||
use SensitiveParameter;
|
||||
|
||||
/**
|
||||
* The envelope a config backup travels in: Argon2id over the admin's password,
|
||||
* secretbox over the document.
|
||||
*
|
||||
* **Why not the Encryptor next door.** That one encrypts with
|
||||
* APP_ENCRYPTION_KEY, which is exactly the thing a config backup exists to
|
||||
* carry somewhere else. A file only the instance that wrote it can open is not
|
||||
* a backup, it is a second copy of the database. So the key here comes from a
|
||||
* password a human chose and typed, and nothing about it is stored.
|
||||
*
|
||||
* **The format is public on purpose.** These are the admin's own secrets and
|
||||
* they must not be locked inside plMail: the header names the KDF, its
|
||||
* parameters, the salt and the nonce, so the file can be opened by anything
|
||||
* that has libsodium — including a one-liner, which docs/install/config-backup.md
|
||||
* spells out. Nothing is derived implicitly and nothing is versioned by
|
||||
* position; a reader that can parse JSON has everything.
|
||||
*
|
||||
* {
|
||||
* "format": "plmail-config-backup",
|
||||
* "version": 1,
|
||||
* "kdf": {"name":"argon2id","opslimit":3,"memlimit":67108864,"salt":"<base64>"},
|
||||
* "cipher": {"name":"xsalsa20poly1305","nonce":"<base64>"},
|
||||
* "ciphertext": "<base64 of secretbox(document, nonce, key)>"
|
||||
* }
|
||||
*
|
||||
* **opslimit 3, memlimit 64 MiB** — libsodium's MODERATE ops with INTERACTIVE
|
||||
* memory, and the asymmetry is deliberate. plMail's reference deployment is a
|
||||
* Raspberry Pi, where memory is the scarce resource and the parameter that
|
||||
* turns a slow page into an OOM-killed PHP worker; MODERATE's 256 MiB is a
|
||||
* quarter of a 1 GB Pi and is requested in one allocation. Iterations cost only
|
||||
* wall-clock, which on a page somebody clicked a button on is affordable, so
|
||||
* the ops half is raised to buy back part of what the memory half gives up. The
|
||||
* numbers are written into every file rather than assumed by the reader, so
|
||||
* raising them later opens old backups unchanged.
|
||||
*
|
||||
* **No plaintext ever reaches disk.** Everything here is strings in memory; the
|
||||
* caller streams the result out as a download. The derived key and the
|
||||
* plaintext are wiped with sodium_memzero as soon as they are spent — which is
|
||||
* not a guarantee against a core dump, but it is the difference between a
|
||||
* secret living for microseconds and living until the request's arena is
|
||||
* reused.
|
||||
*/
|
||||
final readonly class ConfigBackupCipher
|
||||
{
|
||||
/** Marks the file as ours, before anything else is believed about it. */
|
||||
public const string FORMAT = 'plmail-config-backup';
|
||||
|
||||
/** The envelope layout this class writes, and the newest it can read. */
|
||||
public const int VERSION = 1;
|
||||
|
||||
public const string KDF = 'argon2id';
|
||||
|
||||
public const string CIPHER = 'xsalsa20poly1305';
|
||||
|
||||
/** See the class docblock: MODERATE ops, INTERACTIVE memory. */
|
||||
public const int OPSLIMIT = SODIUM_CRYPTO_PWHASH_OPSLIMIT_MODERATE;
|
||||
|
||||
public const int MEMLIMIT = SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE;
|
||||
|
||||
/**
|
||||
* The shortest password this will seal a file with.
|
||||
*
|
||||
* Argon2id makes a short password expensive to guess, not safe: this file
|
||||
* is offline, so an attacker who has it can try forever at 64 MiB a go. A
|
||||
* floor is the only part of that plMail controls.
|
||||
*/
|
||||
public const int MINIMUM_PASSWORD_LENGTH = 12;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $document
|
||||
*
|
||||
* @return string the complete file, ready to be sent as a download
|
||||
*/
|
||||
public function seal(array $document, #[SensitiveParameter] string $password): string
|
||||
{
|
||||
$salt = random_bytes(SODIUM_CRYPTO_PWHASH_SALTBYTES);
|
||||
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
|
||||
$key = $this->deriveKey($password, $salt);
|
||||
|
||||
$plaintext = json_encode($document, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
$sealed = sodium_crypto_secretbox($plaintext, $nonce, $key);
|
||||
|
||||
sodium_memzero($plaintext);
|
||||
sodium_memzero($key);
|
||||
|
||||
return json_encode([
|
||||
'format' => self::FORMAT,
|
||||
'version' => self::VERSION,
|
||||
'kdf' => [
|
||||
'name' => self::KDF,
|
||||
'opslimit' => self::OPSLIMIT,
|
||||
'memlimit' => self::MEMLIMIT,
|
||||
'salt' => base64_encode($salt),
|
||||
],
|
||||
'cipher' => [
|
||||
'name' => self::CIPHER,
|
||||
'nonce' => base64_encode($nonce),
|
||||
],
|
||||
'ciphertext' => base64_encode($sealed),
|
||||
], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* The reverse, with every check stated before the expensive one.
|
||||
*
|
||||
* The KDF parameters are read from the file rather than from the constants
|
||||
* above — that is the whole reason they are written into it — but they are
|
||||
* also bounded. A file claiming memlimit 8 GB is not a backup from an older
|
||||
* plMail, it is a way to make one HTTP request exhaust the host, and it is
|
||||
* refused before sodium is asked to honour it.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*
|
||||
* @throws ConfigBackupException
|
||||
*/
|
||||
public function open(string $envelope, #[SensitiveParameter] string $password): array
|
||||
{
|
||||
$header = $this->decodeJson($envelope, 'envelope');
|
||||
|
||||
if (self::FORMAT !== ($header['format'] ?? null)) {
|
||||
throw ConfigBackupException::notABackup('Missing or unrecognised "format" field.');
|
||||
}
|
||||
|
||||
$version = $header['version'] ?? null;
|
||||
|
||||
if (false === is_int($version)) {
|
||||
throw ConfigBackupException::notABackup('The "version" field is missing or not a number.');
|
||||
}
|
||||
|
||||
if ($version > self::VERSION) {
|
||||
throw ConfigBackupException::unsupportedVersion($version, self::VERSION);
|
||||
}
|
||||
|
||||
$kdf = is_array($header['kdf'] ?? null) ? $header['kdf'] : [];
|
||||
$cipher = is_array($header['cipher'] ?? null) ? $header['cipher'] : [];
|
||||
|
||||
if (self::KDF !== ($kdf['name'] ?? null) || self::CIPHER !== ($cipher['name'] ?? null)) {
|
||||
throw ConfigBackupException::notABackup('The envelope names a KDF or cipher this version cannot use.');
|
||||
}
|
||||
|
||||
$salt = $this->decodeBase64($kdf['salt'] ?? null, SODIUM_CRYPTO_PWHASH_SALTBYTES, 'salt');
|
||||
$nonce = $this->decodeBase64($cipher['nonce'] ?? null, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, 'nonce');
|
||||
$ciphertext = $this->decodeBase64($header['ciphertext'] ?? null, null, 'ciphertext');
|
||||
|
||||
$key = $this->deriveKey($password, $salt, $this->bounded($kdf, 'opslimit'), $this->bounded($kdf, 'memlimit'));
|
||||
|
||||
$plaintext = sodium_crypto_secretbox_open($ciphertext, $nonce, $key);
|
||||
|
||||
sodium_memzero($key);
|
||||
|
||||
// False for a wrong password AND for a tampered file, and there is no
|
||||
// way to tell which — the tag covers both. Reported as the one an admin
|
||||
// can act on.
|
||||
if (false === $plaintext) {
|
||||
throw ConfigBackupException::wrongPassword();
|
||||
}
|
||||
|
||||
$document = $this->decodeJson($plaintext, 'document');
|
||||
|
||||
sodium_memzero($plaintext);
|
||||
|
||||
return $document;
|
||||
}
|
||||
|
||||
// ── Private ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A parameter as the file states it, refused rather than clamped when it is
|
||||
* outside what this host will spend on one upload.
|
||||
*
|
||||
* Refused, because clamping down would make the derivation produce a
|
||||
* different key and report it as a wrong password — the single most
|
||||
* confusing failure this feature could have.
|
||||
*
|
||||
* @param array<string, mixed> $kdf
|
||||
*/
|
||||
private function bounded(array $kdf, string $name): int
|
||||
{
|
||||
$value = $kdf[$name] ?? null;
|
||||
|
||||
if (false === is_int($value) || $value < 1) {
|
||||
throw ConfigBackupException::notABackup(sprintf('The KDF "%s" is missing or not a positive integer.', $name));
|
||||
}
|
||||
|
||||
// Four times what this writes: room for a backup made by a future
|
||||
// plMail that raised the cost, and nowhere near enough to be a lever.
|
||||
$ceiling = 'memlimit' === $name ? 4 * self::MEMLIMIT : 4 * self::OPSLIMIT;
|
||||
|
||||
if ($value > $ceiling) {
|
||||
throw ConfigBackupException::notABackup(sprintf(
|
||||
'The KDF "%s" of %d exceeds what this host will spend on one upload (%d).',
|
||||
$name,
|
||||
$value,
|
||||
$ceiling,
|
||||
));
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function deriveKey(
|
||||
#[SensitiveParameter] string $password,
|
||||
string $salt,
|
||||
int $opslimit = self::OPSLIMIT,
|
||||
int $memlimit = self::MEMLIMIT,
|
||||
): string {
|
||||
return sodium_crypto_pwhash(
|
||||
SODIUM_CRYPTO_SECRETBOX_KEYBYTES,
|
||||
$password,
|
||||
$salt,
|
||||
$opslimit,
|
||||
$memlimit,
|
||||
SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function decodeJson(string $raw, string $what): array
|
||||
{
|
||||
try {
|
||||
$decoded = json_decode($raw, true, 64, JSON_THROW_ON_ERROR);
|
||||
} catch (JsonException $e) {
|
||||
// The envelope not being JSON means "this is not a backup"; the
|
||||
// DOCUMENT not being JSON means the password was right and the
|
||||
// contents are wrong, which is a different sentence.
|
||||
throw 'envelope' === $what
|
||||
? ConfigBackupException::notABackup('The file is not JSON: ' . $e->getMessage())
|
||||
: ConfigBackupException::malformed('The decrypted document is not JSON: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
if (false === is_array($decoded)) {
|
||||
throw 'envelope' === $what
|
||||
? ConfigBackupException::notABackup('The file is JSON but not an object.')
|
||||
: ConfigBackupException::malformed('The decrypted document is JSON but not an object.');
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
private function decodeBase64(mixed $value, ?int $expectedBytes, string $what): string
|
||||
{
|
||||
if (false === is_string($value)) {
|
||||
throw ConfigBackupException::notABackup(sprintf('The "%s" field is missing or not a string.', $what));
|
||||
}
|
||||
|
||||
$raw = base64_decode($value, true);
|
||||
|
||||
if (false === $raw || '' === $raw) {
|
||||
throw ConfigBackupException::notABackup(sprintf('The "%s" field is not valid base64.', $what));
|
||||
}
|
||||
|
||||
if (null !== $expectedBytes && $expectedBytes !== strlen($raw)) {
|
||||
throw ConfigBackupException::notABackup(sprintf(
|
||||
'The "%s" field decodes to %d bytes; %d expected.',
|
||||
$what,
|
||||
strlen($raw),
|
||||
$expectedBytes,
|
||||
));
|
||||
}
|
||||
|
||||
return $raw;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Backup;
|
||||
|
||||
use App\Domain\Enum\Account\MailProvider;
|
||||
use App\Domain\Enum\Integration\Provider;
|
||||
use App\Entity\Integration\IntegrationProviderConfig;
|
||||
use App\Entity\Integration\MailProviderConfig;
|
||||
use App\Entity\Push\FcmConfig;
|
||||
use App\Repository\Integration\IntegrationProviderConfigRepository;
|
||||
use App\Repository\Integration\MailProviderConfigRepository;
|
||||
use App\Repository\Push\FcmConfigRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* The three tables that hold admin-configured settings, read out in the clear
|
||||
* and written back under whatever key is in force here.
|
||||
*
|
||||
* **Decrypted on the way out, and that is the entire point.** Every credential
|
||||
* in these tables is stored through EncryptedStringType, which means the bytes
|
||||
* in the column can only be read with the APP_ENCRYPTION_KEY that wrote them. A
|
||||
* backup carrying those bytes would be carrying noise to any install that is
|
||||
* not the one it came from — which is every install a backup is ever restored
|
||||
* onto. So the values leave here as plaintext, inside an envelope the admin's
|
||||
* password seals, and go back in through the ORM, which re-encrypts them with
|
||||
* the target's key on the way to the column. The envelope is the protection;
|
||||
* the column encryption is a different one, for a different threat, and
|
||||
* stacking them would have produced a file that is safe and useless.
|
||||
*
|
||||
* **Keyed by provider, not listed.** `mailProviders` and
|
||||
* `integrationProviders` are objects whose keys are the enum's backing values,
|
||||
* so a restore looks each one up rather than trusting array order, and a
|
||||
* provider the target build does not know is skipped by name instead of by
|
||||
* position. That is what lets a backup from a newer plMail — one with a
|
||||
* provider this build has never heard of — restore the rest of itself instead
|
||||
* of failing.
|
||||
*
|
||||
* **Absent means absent.** A provider with no row is not exported, and a
|
||||
* provider not in the document is not touched on import. An import is a restore
|
||||
* of what the backup holds, never a mirror: deleting rows the file happens not
|
||||
* to mention would make "restore my Firebase key" a way to lose every
|
||||
* integration.
|
||||
*/
|
||||
final readonly class ConfigBackupDatabase
|
||||
{
|
||||
public const string FCM_CONFIG = 'fcmConfig';
|
||||
|
||||
public const string MAIL_PROVIDERS = 'mailProviders';
|
||||
|
||||
public const string INTEGRATION_PROVIDERS = 'integrationProviders';
|
||||
|
||||
public function __construct(
|
||||
private FcmConfigRepository $fcmConfigs,
|
||||
private MailProviderConfigRepository $mailProviders,
|
||||
private IntegrationProviderConfigRepository $integrationProviders,
|
||||
private EntityManagerInterface $entityManager,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function export(): array
|
||||
{
|
||||
return [
|
||||
self::FCM_CONFIG => $this->exportFcmConfig(),
|
||||
self::MAIL_PROVIDERS => $this->exportMailProviders(),
|
||||
self::INTEGRATION_PROVIDERS => $this->exportIntegrationProviders(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The same shape the export produces, read back from the live rows, so the
|
||||
* review can compare two arrays rather than an array against an entity
|
||||
* graph.
|
||||
*
|
||||
* One method for both directions rather than a comparer per table: "is this
|
||||
* row already what the backup says" is one question, and answering it three
|
||||
* times in three shapes is how a review comes to claim a change it will not
|
||||
* make.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function current(): array
|
||||
{
|
||||
return $this->export();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $fcm
|
||||
*/
|
||||
public function restoreFcmConfig(array $fcm): void
|
||||
{
|
||||
$config = $this->fcmConfigs->current() ?? new FcmConfig();
|
||||
|
||||
$config->restore(
|
||||
$this->string($fcm, 'serviceAccountJson'),
|
||||
$this->string($fcm, 'projectId'),
|
||||
$this->string($fcm, 'applicationId'),
|
||||
$this->string($fcm, 'apiKey'),
|
||||
$this->string($fcm, 'senderId'),
|
||||
$this->string($fcm, 'androidPackage'),
|
||||
true === ($fcm['isEnabled'] ?? false),
|
||||
);
|
||||
|
||||
if (null === $config->id) {
|
||||
$this->entityManager->persist($config);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $values
|
||||
*/
|
||||
public function restoreMailProvider(MailProvider $provider, array $values): void
|
||||
{
|
||||
$config = $this->mailProviders->findOneByProvider($provider);
|
||||
|
||||
if (null === $config) {
|
||||
$config = new MailProviderConfig($provider);
|
||||
$this->entityManager->persist($config);
|
||||
}
|
||||
|
||||
$config->clientId = $this->string($values, 'clientId');
|
||||
$config->clientSecret = $this->string($values, 'clientSecret');
|
||||
$config->pushVerificationToken = $this->string($values, 'pushVerificationToken');
|
||||
$config->settings = $this->settings($values);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $values
|
||||
*/
|
||||
public function restoreIntegrationProvider(Provider $provider, array $values): void
|
||||
{
|
||||
$config = $this->integrationProviders->findOneByProvider($provider);
|
||||
|
||||
if (null === $config) {
|
||||
$config = new IntegrationProviderConfig($provider);
|
||||
$this->entityManager->persist($config);
|
||||
}
|
||||
|
||||
$config->isEnabled = true === ($values['isEnabled'] ?? false);
|
||||
$config->baseUrl = $this->string($values, 'baseUrl');
|
||||
$config->clientId = $this->string($values, 'clientId');
|
||||
$config->clientSecret = $this->string($values, 'clientSecret');
|
||||
$config->settings = $this->settings($values);
|
||||
}
|
||||
|
||||
// ── Private ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function exportFcmConfig(): ?array
|
||||
{
|
||||
$config = $this->fcmConfigs->current();
|
||||
|
||||
if (null === $config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'isEnabled' => $config->isEnabled,
|
||||
// Read through the entity, so the property hook that decrypts it
|
||||
// has run. This is the value that would otherwise be dead weight in
|
||||
// the file — see the class docblock.
|
||||
'serviceAccountJson' => $config->serviceAccountJson,
|
||||
'projectId' => $config->projectId,
|
||||
// clientEmail is derived from the key on restore and is exported
|
||||
// only so a human reading a decrypted document can tell two
|
||||
// backups apart without parsing a 2 kB blob.
|
||||
'clientEmail' => $config->clientEmail,
|
||||
'applicationId' => $config->applicationId,
|
||||
'apiKey' => $config->apiKey,
|
||||
'senderId' => $config->senderId,
|
||||
'androidPackage' => $config->androidPackage,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
private function exportMailProviders(): array
|
||||
{
|
||||
$exported = [];
|
||||
|
||||
foreach ($this->mailProviders->findAllIndexedByProvider() as $key => $config) {
|
||||
$exported[$key] = [
|
||||
'clientId' => $config->clientId,
|
||||
'clientSecret' => $config->clientSecret,
|
||||
'pushVerificationToken' => $config->pushVerificationToken,
|
||||
'settings' => $config->settings,
|
||||
];
|
||||
}
|
||||
|
||||
return $exported;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
private function exportIntegrationProviders(): array
|
||||
{
|
||||
$exported = [];
|
||||
|
||||
foreach ($this->integrationProviders->findAllIndexedByProvider() as $key => $config) {
|
||||
$exported[$key] = [
|
||||
'isEnabled' => $config->isEnabled,
|
||||
'baseUrl' => $config->baseUrl,
|
||||
'clientId' => $config->clientId,
|
||||
'clientSecret' => $config->clientSecret,
|
||||
'settings' => $config->settings,
|
||||
];
|
||||
}
|
||||
|
||||
return $exported;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $values
|
||||
*/
|
||||
private function string(array $values, string $key): ?string
|
||||
{
|
||||
$value = $values[$key] ?? null;
|
||||
|
||||
return is_string($value) && '' !== $value ? $value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $values
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function settings(array $values): array
|
||||
{
|
||||
$settings = $values['settings'] ?? null;
|
||||
|
||||
return is_array($settings) ? $settings : [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Backup;
|
||||
|
||||
use App\Domain\Enum\Backup\ConfigBackupObstacle;
|
||||
use App\Infrastructure\Setup\GeneratedSecretsFile;
|
||||
|
||||
/**
|
||||
* The environment half of an instance's configuration: which variables a
|
||||
* backup carries, where each one's current value is read from, and why not one
|
||||
* of them can be applied by clicking a button.
|
||||
*
|
||||
* **The list is explicit and not derived from .env.** Parsing .env would look
|
||||
* cleverer and would be wrong twice over: it would sweep in APP_ENV,
|
||||
* APP_DEV_USER_PASSWORD and the container's own name — settings that belong to
|
||||
* the machine rather than to the installation, and that a restore onto a
|
||||
* different machine must not carry — and it would silently start exporting any
|
||||
* variable a future .env gained, including one whose value is a path into a
|
||||
* layout the target does not have. So the set is named here, and adding to it
|
||||
* is a decision somebody makes.
|
||||
*
|
||||
* **Deliberately absent**, each for a stated reason:
|
||||
* - APP_ENV, APP_DEBUG — what this container is, not what the install is.
|
||||
* - APP_DEV_USER_EMAIL / _PASSWORD — a development fixture.
|
||||
* - APP_CONTAINER_NAME, APP_SECRETS_FILE, JWT_SECRET_KEY, JWT_PUBLIC_KEY,
|
||||
* APP_STORAGE_DIR, APP_SHARE_DIR — filesystem and topology layout. The
|
||||
* target install has its own, and overwriting it with the source's is how
|
||||
* an instance ends up looking for its attachments on a volume that does not
|
||||
* exist. The JWT key CONTENTS travel; the paths they live at do not.
|
||||
* - MERCURE_URL — the in-network address of a sibling container, which is the
|
||||
* target stack's business. MERCURE_PUBLIC_URL does travel: it is what a
|
||||
* browser is told to connect to, and it belongs to the installation.
|
||||
*
|
||||
* **Nothing here is ever written by an import**, and the class says so per
|
||||
* variable rather than leaving it to a comment. A PHP process cannot change its
|
||||
* own environment in any way the next request would see, and the file the
|
||||
* entrypoint reads at container start is not the same promise as "applied".
|
||||
* The obstacle each variable carries is what the review page shows.
|
||||
*/
|
||||
final readonly class ConfigBackupEnvironment
|
||||
{
|
||||
/**
|
||||
* Every variable a config backup carries.
|
||||
*
|
||||
* Order is the order the review page lists them in, which is roughly "what
|
||||
* breaks worst first": the two that make stored data unreadable, then the
|
||||
* infrastructure credentials, then the provider registrations, then the
|
||||
* things that are merely settings.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
private const array VARIABLES = [
|
||||
'APP_ENCRYPTION_KEY',
|
||||
'APP_SECRET',
|
||||
'DATABASE_URL',
|
||||
'POSTGRES_PASSWORD',
|
||||
'MERCURE_JWT_SECRET',
|
||||
'MERCURE_PUBLIC_URL',
|
||||
'JWT_PASSPHRASE',
|
||||
'MAILER_DSN',
|
||||
'MESSENGER_TRANSPORT_DSN',
|
||||
'APP_PUBLIC_URL',
|
||||
'VAPID_SUBJECT',
|
||||
'VAPID_PUBLIC_KEY',
|
||||
'VAPID_PRIVATE_KEY',
|
||||
'GOOGLE_OAUTH_CLIENT_ID',
|
||||
'GOOGLE_OAUTH_CLIENT_SECRET',
|
||||
'GMAIL_PUBSUB_TOPIC',
|
||||
'GMAIL_PUBSUB_VERIFICATION_TOKEN',
|
||||
'MICROSOFT_OAUTH_CLIENT_ID',
|
||||
'MICROSOFT_OAUTH_CLIENT_SECRET',
|
||||
'MICROSOFT_OAUTH_TENANT',
|
||||
'INTEGRATIONS_ALLOW_HTTP',
|
||||
'INTEGRATIONS_ALLOWED_HOSTS',
|
||||
'TRUSTED_PROXIES',
|
||||
'APP_DEFAULT_TIMEZONE',
|
||||
'APP_DB_LOG_LEVEL',
|
||||
'DEFAULT_URI',
|
||||
];
|
||||
|
||||
/**
|
||||
* The two that are not merely unwritable but actively dangerous to write,
|
||||
* and the reason each carries its own obstacle rather than the generic one.
|
||||
*
|
||||
* @var array<string, ConfigBackupObstacle>
|
||||
*/
|
||||
private const array SPECIAL_OBSTACLES = [
|
||||
'APP_ENCRYPTION_KEY' => ConfigBackupObstacle::EncryptionKeyInUse,
|
||||
'POSTGRES_PASSWORD' => ConfigBackupObstacle::ExternalSystem,
|
||||
'DATABASE_URL' => ConfigBackupObstacle::ExternalSystem,
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private GeneratedSecretsFile $generated,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function variables(): array
|
||||
{
|
||||
return self::VARIABLES;
|
||||
}
|
||||
|
||||
/**
|
||||
* The values worth putting in a backup: every variable in the inventory
|
||||
* that this instance actually has one for.
|
||||
*
|
||||
* Empty ones are dropped rather than exported as "". A blank line in a
|
||||
* backup is indistinguishable from a value somebody cleared on purpose, and
|
||||
* the review would show two dozen rows of nothing on an install that
|
||||
* configures three things.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function export(): array
|
||||
{
|
||||
$values = [];
|
||||
|
||||
foreach (self::VARIABLES as $name) {
|
||||
$value = $this->current($name);
|
||||
|
||||
if (null !== $value) {
|
||||
$values[$name] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
/**
|
||||
* What this instance is running with right now, or null when nothing set it.
|
||||
*
|
||||
* Real environment first, generated file second — the same precedence the
|
||||
* entrypoint applies when it loads that file, and the same one
|
||||
* PublicUrlSetting::current() states: an operator who manages a value
|
||||
* themselves never has a generated one substituted underneath them.
|
||||
*/
|
||||
public function current(string $name): ?string
|
||||
{
|
||||
$fromEnvironment = trim((string) ($_SERVER[$name] ?? $_ENV[$name] ?? ''));
|
||||
|
||||
if ('' !== $fromEnvironment) {
|
||||
return $fromEnvironment;
|
||||
}
|
||||
|
||||
$stored = trim($this->generated->read()[$name] ?? '');
|
||||
|
||||
return '' === $stored ? null : $stored;
|
||||
}
|
||||
|
||||
public function obstacleFor(string $name): ConfigBackupObstacle
|
||||
{
|
||||
return self::SPECIAL_OBSTACLES[$name] ?? ConfigBackupObstacle::ProcessEnvironment;
|
||||
}
|
||||
|
||||
/**
|
||||
* The line to paste, in the form the target actually reads it.
|
||||
*
|
||||
* Quoted whenever the value contains anything a shell or a dotenv parser
|
||||
* would take an interest in — a DSN with a `#` in the password is the case
|
||||
* that bites, because Symfony's dotenv treats it as a comment and the
|
||||
* operator gets a truncated password with no error anywhere.
|
||||
*/
|
||||
public function instructionFor(string $name, string $value): string
|
||||
{
|
||||
$needsQuoting = 1 === preg_match('/[\s#"\'$`\\\\]/', $value);
|
||||
|
||||
return sprintf('%s=%s', $name, $needsQuoting ? '"' . addcslashes($value, '"\\$`') . '"' : $value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Backup;
|
||||
|
||||
use App\Infrastructure\Backup\ConfigBackupCipher;
|
||||
use App\Service\Setup\PublicUrlSetting;
|
||||
use DateTimeImmutable;
|
||||
use SensitiveParameter;
|
||||
|
||||
/**
|
||||
* One instance's configuration, gathered from the three places it lives and
|
||||
* sealed with a password the admin just typed.
|
||||
*
|
||||
* **Configuration, never mail.** Not one row of a message, a contact or a
|
||||
* calendar is touched here, and the file is small enough — kilobytes — that an
|
||||
* admin can keep it in a password manager. `app:backup` remains the way to move
|
||||
* the data; this is the way to move the SETUP, and the two are separate because
|
||||
* they are needed at different moments: the data restore is a disaster, the
|
||||
* config restore is a Tuesday.
|
||||
*
|
||||
* **Nothing is written to disk.** The document is assembled in memory, sealed
|
||||
* in memory, and handed to the controller as a string for a download response.
|
||||
* A temporary file holding a decrypted APP_ENCRYPTION_KEY would be a worse
|
||||
* exposure than anything this feature protects against, and it would survive
|
||||
* the request that made it.
|
||||
*
|
||||
* **The document carries its own format and version, and so does the
|
||||
* envelope.** They are not the same claim: the envelope's says how the bytes
|
||||
* are encrypted, and has to be readable without the password; the document's
|
||||
* says what the fields inside mean, and cannot be read until it is open. A
|
||||
* future plMail that keeps the crypto and reorganises the contents bumps one
|
||||
* and not the other, and a reader that only knows the old contents can still
|
||||
* tell that it is a plMail backup and that it cannot use it.
|
||||
*/
|
||||
final readonly class ConfigBackupExporter
|
||||
{
|
||||
/** The content schema of the decrypted document. */
|
||||
public const int DOCUMENT_VERSION = 1;
|
||||
|
||||
public function __construct(
|
||||
private ConfigBackupCipher $cipher,
|
||||
private ConfigBackupEnvironment $environment,
|
||||
private ConfigBackupFiles $files,
|
||||
private ConfigBackupDatabase $database,
|
||||
private PublicUrlSetting $publicUrl,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function document(): array
|
||||
{
|
||||
return [
|
||||
'format' => ConfigBackupCipher::FORMAT,
|
||||
'version' => self::DOCUMENT_VERSION,
|
||||
'exportedAt' => new DateTimeImmutable()->format(DATE_ATOM),
|
||||
// Which install this came from. Not a secret and not load-bearing —
|
||||
// it is shown on the review page so somebody holding two backups
|
||||
// can tell them apart, which is the whole reason it is here.
|
||||
'instance' => $this->publicUrl->current(),
|
||||
'env' => $this->environment->export(),
|
||||
'files' => $this->files->export(),
|
||||
'database' => $this->database->export(),
|
||||
];
|
||||
}
|
||||
|
||||
/** The sealed file, ready to be sent. */
|
||||
public function export(#[SensitiveParameter] string $password): string
|
||||
{
|
||||
return $this->cipher->seal($this->document(), $password);
|
||||
}
|
||||
|
||||
/**
|
||||
* `plmail-config-2026-08-06.backup`.
|
||||
*
|
||||
* A date and not a timestamp: an admin who exports twice in a day wants the
|
||||
* second file to be recognisably the same kind of thing, and the browser
|
||||
* appends "(1)" perfectly well. `.backup` rather than `.json` because it is
|
||||
* not JSON to anything that would try to open it — it is an envelope, and a
|
||||
* text editor showing base64 is the least useful thing that could happen.
|
||||
*/
|
||||
public function filename(): string
|
||||
{
|
||||
return sprintf('plmail-config-%s.backup', new DateTimeImmutable()->format('Y-m-d'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Backup;
|
||||
|
||||
use App\Domain\Enum\Backup\ConfigBackupObstacle;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
/**
|
||||
* The secrets volume, as a config backup sees it: three files, addressed by a
|
||||
* name rather than by a path.
|
||||
*
|
||||
* **Logical names, local paths.** The document says `jwt/private.pem`; where
|
||||
* that lands is decided by the instance opening it, from its own
|
||||
* JWT_SECRET_KEY. Exporting absolute paths would be exporting the source's
|
||||
* layout, and a TrueNAS install restoring a backup made on a Pi would be told
|
||||
* to write into a directory that is not there. The bytes are the portable part;
|
||||
* the address is not.
|
||||
*
|
||||
* **What is here, and what is deliberately not.** The JWT keypair, because it
|
||||
* is generated per install and every service in the stack has to verify tokens
|
||||
* the others signed — an instance that regenerates it invalidates every JMAP
|
||||
* client's session at once. `postgres_password`, because it is a real file the
|
||||
* Postgres image reads at container-create time and a restore that forgets it
|
||||
* is a stack that will not come up. NOT generated.env: everything in it is
|
||||
* already in the env section by name, and a second copy as an opaque blob would
|
||||
* be two descriptions of one thing with no way to tell which the importer used.
|
||||
*
|
||||
* **Writability is measured, not assumed.** is_writable on the directory, per
|
||||
* file, at the moment the review is built. The web service and the workers
|
||||
* mount this volume in configurations plMail does not control, and one that
|
||||
* mounts it read-only is a supported deployment — the review has to say so
|
||||
* rather than fail at the write.
|
||||
*/
|
||||
final readonly class ConfigBackupFiles
|
||||
{
|
||||
public const string JWT_PRIVATE = 'jwt/private.pem';
|
||||
|
||||
public const string JWT_PUBLIC = 'jwt/public.pem';
|
||||
|
||||
/**
|
||||
* The Postgres image's own copy of the password, written beside the
|
||||
* generated secrets by frankenphp/generate-secrets.sh.
|
||||
*
|
||||
* Carried so a restore is complete, never applied. The string in this file
|
||||
* has to agree with the password of a role inside a database plMail is a
|
||||
* client of; writing one without the other produces an instance that cannot
|
||||
* connect on its next start, and plMail cannot do the other half.
|
||||
*/
|
||||
public const string POSTGRES_PASSWORD = 'postgres_password';
|
||||
|
||||
/** @var list<string> */
|
||||
private const array NAMES = [
|
||||
self::JWT_PRIVATE,
|
||||
self::JWT_PUBLIC,
|
||||
self::POSTGRES_PASSWORD,
|
||||
];
|
||||
|
||||
/**
|
||||
* Largest file this will carry, per file.
|
||||
*
|
||||
* A PEM is two kilobytes and a password is twenty-four bytes. The ceiling
|
||||
* exists because these paths are configuration: an install that pointed
|
||||
* JWT_SECRET_KEY at something enormous would otherwise turn an export into
|
||||
* an out-of-memory, base64-encoded, at 4/3 the size.
|
||||
*/
|
||||
private const int MAX_BYTES = 262144;
|
||||
|
||||
public function __construct(
|
||||
// resolve:, because these carry %kernel.project_dir% the way lexik's
|
||||
// own configuration does — the same note InitSecretsCommand makes.
|
||||
#[Autowire('%env(resolve:JWT_SECRET_KEY)%')]
|
||||
private string $jwtPrivatePath,
|
||||
#[Autowire('%env(resolve:JWT_PUBLIC_KEY)%')]
|
||||
private string $jwtPublicPath,
|
||||
#[Autowire(env: 'resolve:APP_SECRETS_FILE')]
|
||||
private string $generatedSecretsPath,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function names(): array
|
||||
{
|
||||
return self::NAMES;
|
||||
}
|
||||
|
||||
public function pathFor(string $name): string
|
||||
{
|
||||
return match ($name) {
|
||||
self::JWT_PRIVATE => $this->jwtPrivatePath,
|
||||
self::JWT_PUBLIC => $this->jwtPublicPath,
|
||||
self::POSTGRES_PASSWORD => \dirname($this->generatedSecretsPath) . '/' . self::POSTGRES_PASSWORD,
|
||||
default => throw new RuntimeException(sprintf('%s is not a file a config backup carries.', $name)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Every carried file that exists here, base64-encoded.
|
||||
*
|
||||
* base64 rather than raw: the document is JSON, a PEM is text today and the
|
||||
* postgres password file is bytes, and one encoding for both means the
|
||||
* importer has no per-file rule to get wrong.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function export(): array
|
||||
{
|
||||
$files = [];
|
||||
|
||||
foreach (self::NAMES as $name) {
|
||||
$contents = $this->read($name);
|
||||
|
||||
if (null !== $contents) {
|
||||
$files[$name] = base64_encode($contents);
|
||||
}
|
||||
}
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
/** The bytes on this instance, or null when the file is absent or unreadable. */
|
||||
public function read(string $name): ?string
|
||||
{
|
||||
$path = $this->pathFor($name);
|
||||
|
||||
if (false === is_file($path) || false === is_readable($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$size = filesize($path);
|
||||
|
||||
if (false === $size || $size > self::MAX_BYTES) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$contents = file_get_contents($path);
|
||||
|
||||
return false === $contents ? null : $contents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why this file cannot be written, or null when it can be.
|
||||
*
|
||||
* The order matters: the postgres password is refused on principle before
|
||||
* anybody looks at permissions, so an instance that happens to have a
|
||||
* writable secrets volume is not offered a write that would break it.
|
||||
*/
|
||||
public function obstacleFor(string $name): ?ConfigBackupObstacle
|
||||
{
|
||||
if (self::POSTGRES_PASSWORD === $name) {
|
||||
return ConfigBackupObstacle::ExternalSystem;
|
||||
}
|
||||
|
||||
return $this->isWritable($name) ? null : ConfigBackupObstacle::NotWritable;
|
||||
}
|
||||
|
||||
/**
|
||||
* An existing file has to be writable itself; an absent one needs a
|
||||
* directory that can be created, which is not the same test and is the one
|
||||
* that decides the case that actually happens — a fresh install restoring a
|
||||
* backup before `app:secrets:init` has ever made var/secrets/jwt/.
|
||||
*
|
||||
* So the walk upwards: the nearest ancestor that exists is the one whose
|
||||
* permissions decide whether the rest can be made. Stopping at the
|
||||
* immediate parent answered "no" for every fresh install and sent the JWT
|
||||
* keypair to the manual list, which is a lie in the safe direction but
|
||||
* still a lie.
|
||||
*/
|
||||
public function isWritable(string $name): bool
|
||||
{
|
||||
$path = $this->pathFor($name);
|
||||
|
||||
if (true === is_file($path)) {
|
||||
return is_writable($path);
|
||||
}
|
||||
|
||||
$directory = \dirname($path);
|
||||
|
||||
while (false === is_dir($directory)) {
|
||||
$parent = \dirname($directory);
|
||||
|
||||
// Reached the filesystem root without finding anything: there is no
|
||||
// ancestor left to ask, so the answer is no.
|
||||
if ($parent === $directory) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$directory = $parent;
|
||||
}
|
||||
|
||||
return is_writable($directory);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws RuntimeException when the write fails despite isWritable() having
|
||||
* said it would not — a race, or a full disk
|
||||
*/
|
||||
public function write(string $name, string $contents): void
|
||||
{
|
||||
$path = $this->pathFor($name);
|
||||
$directory = \dirname($path);
|
||||
|
||||
if (false === is_dir($directory) && false === @mkdir($directory, 0700, true) && false === is_dir($directory)) {
|
||||
throw new RuntimeException(sprintf('Could not create the secrets directory %s.', $directory));
|
||||
}
|
||||
|
||||
if (false === @file_put_contents($path, $contents)) {
|
||||
throw new RuntimeException(sprintf('Could not write %s.', $path));
|
||||
}
|
||||
|
||||
// The same mode lexik's own generator leaves behind. A private key that
|
||||
// arrives world-readable because it came out of a backup rather than
|
||||
// out of openssl is a difference nobody would look for.
|
||||
@chmod($path, 0600);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Backup;
|
||||
|
||||
use App\Domain\DTO\Backup\ConfigBackupPlan;
|
||||
use App\Domain\DTO\Backup\ConfigBackupPlanItem;
|
||||
use App\Domain\Enum\Account\MailProvider;
|
||||
use App\Domain\Enum\Backup\ConfigBackupChange;
|
||||
use App\Domain\Enum\Backup\ConfigBackupObstacle;
|
||||
use App\Domain\Enum\Backup\ConfigBackupSection;
|
||||
use App\Domain\Enum\Integration\Provider;
|
||||
use App\Domain\Exception\ConfigBackupException;
|
||||
use App\Infrastructure\Backup\ConfigBackupCipher;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use RuntimeException;
|
||||
use SensitiveParameter;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Opening a config backup, saying honestly what it would do, and then doing the
|
||||
* part that is genuinely ours to do.
|
||||
*
|
||||
* **Three methods and a hard line between the second and the third.** open()
|
||||
* decrypts and validates and touches nothing. plan() compares and touches
|
||||
* nothing. apply() writes. The admin sees the output of plan() and has to press
|
||||
* a second button before apply() runs, because the interesting case is not the
|
||||
* empty install — it is the running one, where every line of the plan is a
|
||||
* credential about to be replaced by a different credential, and the only
|
||||
* moment anybody can notice that is before.
|
||||
*
|
||||
* **The plan is not advice, it is the thing that gets executed.** apply() walks
|
||||
* the same list plan() produced and does exactly its automatic items; it does
|
||||
* not re-decide anything. A review that says one thing and an apply that does
|
||||
* another is the specific failure this shape exists to make impossible.
|
||||
*
|
||||
* **What is honestly writable is a short list**, and it is short for reasons
|
||||
* that are properties of the deployment rather than of this code:
|
||||
*
|
||||
* - the three configuration tables, which plMail owns outright, and whose
|
||||
* credentials are re-encrypted with THIS instance's key on the way in —
|
||||
* the whole reason the envelope carries them decrypted;
|
||||
* - files under the shared secrets directory, and only when this process can
|
||||
* actually write them, measured at review time;
|
||||
* - nothing in the environment, ever. A PHP process cannot change the
|
||||
* variables it was started with, and quietly writing them into a file the
|
||||
* entrypoint reads at the NEXT container start is not what an admin who
|
||||
* pressed "apply" was told would happen. Those come back as exact lines to
|
||||
* paste, which is the only truthful thing to offer.
|
||||
*
|
||||
* **A failed write demotes rather than aborts.** If a file that looked writable
|
||||
* turns out not to be — a race, a full disk — that item is reported as manual
|
||||
* in the plan that comes back, with the same instruction it would have had. An
|
||||
* import that threw here would leave the admin with a committed database and no
|
||||
* account of what happened to the rest.
|
||||
*/
|
||||
final readonly class ConfigBackupImporter
|
||||
{
|
||||
public function __construct(
|
||||
private ConfigBackupCipher $cipher,
|
||||
private ConfigBackupEnvironment $environment,
|
||||
private ConfigBackupFiles $files,
|
||||
private ConfigBackupDatabase $database,
|
||||
private EntityManagerInterface $entityManager,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt, then prove the contents are a document this build understands.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*
|
||||
* @throws ConfigBackupException
|
||||
*/
|
||||
public function open(string $envelope, #[SensitiveParameter] string $password): array
|
||||
{
|
||||
$document = $this->cipher->open($envelope, $password);
|
||||
|
||||
// The envelope already said this, and it says it again from inside:
|
||||
// the outer claim is unauthenticated — anyone can write a header — and
|
||||
// the inner one has come through a Poly1305 tag.
|
||||
if (ConfigBackupCipher::FORMAT !== ($document['format'] ?? null)) {
|
||||
throw ConfigBackupException::malformed('The decrypted document does not identify itself as a plMail config backup.');
|
||||
}
|
||||
|
||||
$version = $document['version'] ?? null;
|
||||
|
||||
if (false === is_int($version)) {
|
||||
throw ConfigBackupException::malformed('The decrypted document has no numeric version.');
|
||||
}
|
||||
|
||||
if ($version > ConfigBackupExporter::DOCUMENT_VERSION) {
|
||||
throw ConfigBackupException::unsupportedVersion($version, ConfigBackupExporter::DOCUMENT_VERSION);
|
||||
}
|
||||
|
||||
return $document;
|
||||
}
|
||||
|
||||
/**
|
||||
* What this backup would do to this instance, with nothing done.
|
||||
*
|
||||
* @param array<string, mixed> $document
|
||||
*/
|
||||
public function plan(array $document): ConfigBackupPlan
|
||||
{
|
||||
$items = [
|
||||
...$this->environmentItems($this->section($document, 'env')),
|
||||
...$this->fileItems($this->section($document, 'files')),
|
||||
...$this->databaseItems($this->section($document, 'database')),
|
||||
];
|
||||
|
||||
return new ConfigBackupPlan($items, $this->instance($document), $this->exportedAt($document));
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the automatic half of the plan and report what happened.
|
||||
*
|
||||
* The database writes go inside one transaction, so a document whose
|
||||
* Firebase key is refused by FcmConfig::restore() does not leave three
|
||||
* provider registrations from someone else's install behind — the review
|
||||
* showed one operation and it stays one operation. Files are written after
|
||||
* the commit and individually, because they are not transactional and
|
||||
* pretending otherwise would only move the failure.
|
||||
*
|
||||
* @param array<string, mixed> $document
|
||||
*/
|
||||
public function apply(array $document): ConfigBackupPlan
|
||||
{
|
||||
$plan = $this->plan($document);
|
||||
|
||||
$this->entityManager->wrapInTransaction(function () use ($plan, $document): void {
|
||||
$this->applyDatabase($plan, $this->section($document, 'database'));
|
||||
});
|
||||
|
||||
return new ConfigBackupPlan(
|
||||
$this->applyFiles($plan, $this->section($document, 'files')),
|
||||
$plan->instance,
|
||||
$plan->exportedAt,
|
||||
applied: true,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Private: planning ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $env
|
||||
*
|
||||
* @return list<ConfigBackupPlanItem>
|
||||
*/
|
||||
private function environmentItems(array $env): array
|
||||
{
|
||||
$items = [];
|
||||
|
||||
// Inventory order first, so the two variables whose mishandling costs
|
||||
// the most are the two an admin reads first; anything the file carries
|
||||
// that this build does not know about follows, in the file's own order.
|
||||
$names = [
|
||||
...array_values(array_filter($this->environment->variables(), static fn (string $name): bool => array_key_exists($name, $env))),
|
||||
...array_values(array_filter(array_keys($env), fn (string $name): bool => false === in_array($name, $this->environment->variables(), true))),
|
||||
];
|
||||
|
||||
foreach ($names as $name) {
|
||||
$value = $env[$name] ?? null;
|
||||
|
||||
if (false === is_string($value) || '' === $value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$items[] = new ConfigBackupPlanItem(
|
||||
ConfigBackupSection::Environment,
|
||||
$name,
|
||||
$this->changeFor($this->environment->current($name), $value),
|
||||
$this->environment->obstacleFor($name),
|
||||
$this->environment->instructionFor($name, $value),
|
||||
);
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $files
|
||||
*
|
||||
* @return list<ConfigBackupPlanItem>
|
||||
*/
|
||||
private function fileItems(array $files): array
|
||||
{
|
||||
$items = [];
|
||||
|
||||
foreach ($this->files->names() as $name) {
|
||||
$encoded = $files[$name] ?? null;
|
||||
|
||||
if (false === is_string($encoded)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$contents = base64_decode($encoded, true);
|
||||
|
||||
if (false === $contents) {
|
||||
throw ConfigBackupException::malformed(sprintf('The file "%s" is not valid base64.', $name));
|
||||
}
|
||||
|
||||
$obstacle = $this->files->obstacleFor($name);
|
||||
|
||||
$items[] = new ConfigBackupPlanItem(
|
||||
ConfigBackupSection::SecretsFile,
|
||||
$name,
|
||||
$this->changeFor($this->files->read($name), $contents),
|
||||
$obstacle,
|
||||
// The path on THIS instance, which is the only part of a file a
|
||||
// review page can usefully hand over — the bytes are already in
|
||||
// the backup the admin is holding.
|
||||
null === $obstacle ? null : $this->files->pathFor($name),
|
||||
);
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $database
|
||||
*
|
||||
* @return list<ConfigBackupPlanItem>
|
||||
*/
|
||||
private function databaseItems(array $database): array
|
||||
{
|
||||
$current = $this->database->current();
|
||||
$items = [];
|
||||
|
||||
$fcm = $database[ConfigBackupDatabase::FCM_CONFIG] ?? null;
|
||||
$currentFcm = $current[ConfigBackupDatabase::FCM_CONFIG] ?? null;
|
||||
|
||||
if (is_array($fcm)) {
|
||||
$items[] = new ConfigBackupPlanItem(
|
||||
ConfigBackupSection::Database,
|
||||
ConfigBackupDatabase::FCM_CONFIG,
|
||||
$this->changeForArray(is_array($currentFcm) ? $currentFcm : null, $fcm),
|
||||
);
|
||||
}
|
||||
|
||||
// A provider this build has never heard of is skipped rather than
|
||||
// refused: a backup from a newer plMail must restore the parts this one
|
||||
// understands instead of failing whole. It is skipped SILENTLY only
|
||||
// because the review lists what will happen — an unlisted provider is
|
||||
// visibly not in the plan.
|
||||
foreach ([ConfigBackupDatabase::MAIL_PROVIDERS, ConfigBackupDatabase::INTEGRATION_PROVIDERS] as $table) {
|
||||
$live = $this->providerNames($current, $table);
|
||||
|
||||
foreach ($this->providerNames($database, $table) as $key => $values) {
|
||||
if (false === $this->isKnownProvider($table, $key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$items[] = new ConfigBackupPlanItem(
|
||||
ConfigBackupSection::Database,
|
||||
$table . '.' . $key,
|
||||
$this->changeForArray($live[$key] ?? null, $values),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
// ── Private: applying ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $database
|
||||
*/
|
||||
private function applyDatabase(ConfigBackupPlan $plan, array $database): void
|
||||
{
|
||||
foreach ($plan->automatic() as $item) {
|
||||
if (ConfigBackupSection::Database !== $item->section || false === $item->change->isMaterial()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
[$table, $key] = array_pad(explode('.', $item->key, 2), 2, '');
|
||||
|
||||
if (ConfigBackupDatabase::FCM_CONFIG === $table) {
|
||||
$fcm = $database[ConfigBackupDatabase::FCM_CONFIG] ?? [];
|
||||
$this->database->restoreFcmConfig(is_array($fcm) ? $fcm : []);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$values = $this->providerNames($database, (string) $table)[(string) $key] ?? [];
|
||||
|
||||
if (ConfigBackupDatabase::MAIL_PROVIDERS === $table && null !== $provider = MailProvider::tryFrom((string) $key)) {
|
||||
$this->database->restoreMailProvider($provider, $values);
|
||||
}
|
||||
|
||||
if (ConfigBackupDatabase::INTEGRATION_PROVIDERS === $table && null !== $integration = Provider::tryFrom((string) $key)) {
|
||||
$this->database->restoreIntegrationProvider($integration, $values);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this build has a driver for the provider a document names.
|
||||
*
|
||||
* Two enums and one question, so the planner and the applier cannot come to
|
||||
* different answers about the same key — which would show up as a review
|
||||
* promising a restore that silently does nothing.
|
||||
*/
|
||||
private function isKnownProvider(string $table, string $key): bool
|
||||
{
|
||||
return match ($table) {
|
||||
ConfigBackupDatabase::MAIL_PROVIDERS => null !== MailProvider::tryFrom($key),
|
||||
ConfigBackupDatabase::INTEGRATION_PROVIDERS => null !== Provider::tryFrom($key),
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $files
|
||||
*
|
||||
* @return list<ConfigBackupPlanItem>
|
||||
*/
|
||||
private function applyFiles(ConfigBackupPlan $plan, array $files): array
|
||||
{
|
||||
$items = [];
|
||||
|
||||
foreach ($plan->items as $item) {
|
||||
if (ConfigBackupSection::SecretsFile !== $item->section
|
||||
|| false === $item->isAutomatic()
|
||||
|| false === $item->change->isMaterial()
|
||||
) {
|
||||
$items[] = $item;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$encoded = $files[$item->key] ?? null;
|
||||
|
||||
// Already proved decodable while planning; re-decoded rather than
|
||||
// carried on the item, because a plan is a description of what will
|
||||
// happen and putting a private key inside one would mean the review
|
||||
// page was holding it.
|
||||
$contents = is_string($encoded) ? base64_decode($encoded, true) : false;
|
||||
|
||||
try {
|
||||
if (false === $contents) {
|
||||
throw new RuntimeException('the file is not valid base64');
|
||||
}
|
||||
|
||||
$this->files->write($item->key, $contents);
|
||||
|
||||
$items[] = $item;
|
||||
} catch (Throwable $e) {
|
||||
// Demoted, not thrown: see the class docblock. The admin gets
|
||||
// the path and can put it there themselves, which is exactly
|
||||
// what they would have been told had is_writable said no.
|
||||
$this->logger->warning('Could not restore a secrets file from a config backup.', [
|
||||
'file' => $item->key,
|
||||
'exception' => $e,
|
||||
]);
|
||||
|
||||
$items[] = new ConfigBackupPlanItem(
|
||||
$item->section,
|
||||
$item->key,
|
||||
$item->change,
|
||||
ConfigBackupObstacle::NotWritable,
|
||||
$this->files->pathFor($item->key),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
// ── Private: reading the document ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $document
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function section(array $document, string $name): array
|
||||
{
|
||||
$section = $document[$name] ?? null;
|
||||
|
||||
return is_array($section) ? $section : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $source
|
||||
*
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
private function providerNames(array $source, string $table): array
|
||||
{
|
||||
$rows = $source[$table] ?? null;
|
||||
$result = [];
|
||||
|
||||
if (false === is_array($rows)) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
foreach ($rows as $key => $values) {
|
||||
if (is_string($key) && is_array($values)) {
|
||||
$result[$key] = $values;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $document
|
||||
*/
|
||||
private function instance(array $document): ?string
|
||||
{
|
||||
$instance = $document['instance'] ?? null;
|
||||
|
||||
return is_string($instance) && '' !== $instance ? $instance : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $document
|
||||
*/
|
||||
private function exportedAt(array $document): ?DateTimeImmutable
|
||||
{
|
||||
$exportedAt = $document['exportedAt'] ?? null;
|
||||
|
||||
if (false === is_string($exportedAt)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// A date the file states and nothing depends on. An unparseable one is
|
||||
// shown as "unknown" rather than failing an import — the credentials
|
||||
// are still good, and refusing them over a timestamp would be absurd.
|
||||
try {
|
||||
return new DateTimeImmutable($exportedAt);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function changeFor(?string $current, string $incoming): ConfigBackupChange
|
||||
{
|
||||
if (null === $current) {
|
||||
return ConfigBackupChange::Absent;
|
||||
}
|
||||
|
||||
// hash_equals rather than ===, because half of these comparisons are
|
||||
// over credentials and a timing-variable compare over a secret is a
|
||||
// habit worth not having, even where the attacker would have to be
|
||||
// holding the file already.
|
||||
return hash_equals($current, $incoming) ? ConfigBackupChange::Unchanged : ConfigBackupChange::Differs;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $current
|
||||
* @param array<string, mixed> $incoming
|
||||
*/
|
||||
private function changeForArray(?array $current, array $incoming): ConfigBackupChange
|
||||
{
|
||||
if (null === $current) {
|
||||
return ConfigBackupChange::Absent;
|
||||
}
|
||||
|
||||
// Sorted by key before encoding: the two sides come from an ORM read
|
||||
// and from a JSON document, and their key order has no reason to agree.
|
||||
ksort($current);
|
||||
ksort($incoming);
|
||||
|
||||
return json_encode($current) === json_encode($incoming)
|
||||
? ConfigBackupChange::Unchanged
|
||||
: ConfigBackupChange::Differs;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
{#
|
||||
_config_backup_plan.html.twig — what a config backup would do, or has just done.
|
||||
|
||||
Accepts:
|
||||
plan — App\Domain\DTO\Backup\ConfigBackupPlan
|
||||
|
||||
Shared by the admin page and the first-run restore screen, because the review is
|
||||
the substance of an import and two copies of it would be two accounts of the
|
||||
same file. The pages around it differ; this does not.
|
||||
|
||||
Two lists, and the split is the point. The first is what plMail is going to do
|
||||
by itself and is therefore short. The second is everything it cannot do, with
|
||||
the exact line to paste or the exact path to write — an import that quietly
|
||||
listed those as "restored" would send an operator away believing their
|
||||
APP_ENCRYPTION_KEY had travelled, which it has not and must not.
|
||||
|
||||
`plan.applied` decides tense throughout: the same list is a promise before the
|
||||
button and a receipt after it, and a receipt written in the future tense is how
|
||||
somebody comes to press apply twice.
|
||||
#}
|
||||
<div class="space-y-4">
|
||||
|
||||
{# Which file this is. An admin restoring the wrong backup is usually
|
||||
holding two, and the only thing telling them apart is what is inside. #}
|
||||
<dl class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-xs">
|
||||
<dt class="text-ink-faint">{{ 'admin.config_backup.review.from'|trans }}</dt>
|
||||
<dd class="font-mono text-ink-soft break-all">
|
||||
{{ plan.instance ?? ('admin.config_backup.review.unknown_instance'|trans) }}
|
||||
</dd>
|
||||
<dt class="text-ink-faint">{{ 'admin.config_backup.review.exported_at'|trans }}</dt>
|
||||
<dd class="font-mono text-ink-soft">
|
||||
{{ plan.exportedAt ? plan.exportedAt|date('Y-m-d H:i') : ('admin.config_backup.review.unknown_instance'|trans) }}
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
{# ── What plMail does itself ──────────────────────────────────────── #}
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-ink">
|
||||
{{ (plan.applied ? 'admin.config_backup.review.applied' : 'admin.config_backup.review.automatic')|trans }}
|
||||
</h3>
|
||||
|
||||
{% if plan.automatic is empty %}
|
||||
<p class="mt-1.5 text-xs text-ink-faint">{{ 'admin.config_backup.review.automatic_none'|trans }}</p>
|
||||
{% else %}
|
||||
<ul class="mt-2 divide-y divide-line rounded-lg border border-line">
|
||||
{% for item in plan.automatic %}
|
||||
<li class="flex items-center gap-3 px-3.5 py-2">
|
||||
<i class="{{ item.section.icon }} text-xs text-zinc-400 w-4 shrink-0" aria-hidden="true"></i>
|
||||
<span class="font-mono text-xs text-ink-soft break-all min-w-0">{{ item.key }}</span>
|
||||
<span class="ml-auto shrink-0 text-[11px] font-medium px-2 py-0.5 rounded-full
|
||||
{{ item.change.value == 'differs' ? 'bg-amber-500/15 text-amber-500'
|
||||
: (item.change.value == 'absent' ? 'bg-emerald-500/15 text-emerald-500'
|
||||
: 'bg-raised text-ink-faint') }}">
|
||||
{{ item.change.transKey|trans }}
|
||||
</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# ── What the admin has to do ─────────────────────────────────────── #}
|
||||
{% set manual = plan.manualBySection %}
|
||||
|
||||
{% if manual is not empty %}
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-ink">{{ 'admin.config_backup.review.manual'|trans }}</h3>
|
||||
<p class="mt-1 text-xs text-ink-muted leading-relaxed">{{ 'admin.config_backup.review.manual_lead'|trans }}</p>
|
||||
|
||||
<div class="mt-2 space-y-3">
|
||||
{% for sectionValue, items in manual %}
|
||||
{# Off the first item rather than off the loop key: the key
|
||||
is the enum's backing value and a template that turned it
|
||||
back into a case would be re-implementing from_value. #}
|
||||
{% set firstItem = items|first %}
|
||||
{% set section = firstItem.section %}
|
||||
|
||||
<div class="rounded-lg border border-line overflow-hidden">
|
||||
<div class="flex items-center gap-2 px-3.5 h-9 border-b border-line bg-raised/40">
|
||||
<i class="{{ section.icon }} text-xs text-zinc-400" aria-hidden="true"></i>
|
||||
<h4 class="text-xs font-semibold text-ink">{{ section.transKey|trans }}</h4>
|
||||
</div>
|
||||
|
||||
<ul class="divide-y divide-line">
|
||||
{% for item in items %}
|
||||
<li class="px-3.5 py-2.5">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="font-mono text-xs text-ink-soft break-all min-w-0">{{ item.key }}</span>
|
||||
<span class="ml-auto shrink-0 text-[11px] text-ink-faint">{{ item.change.transKey|trans }}</span>
|
||||
</div>
|
||||
|
||||
{# select-all on the line itself: this is the
|
||||
thing the operator came for, and a triple
|
||||
click that catches the label instead of the
|
||||
value is how a truncated secret ends up in
|
||||
a .env.local. #}
|
||||
{% if item.instruction %}
|
||||
<pre class="mt-1.5 overflow-x-auto rounded bg-zinc-500/10 px-2.5 py-1.5 text-[11px] font-mono text-ink-soft select-all whitespace-pre-wrap break-all">{{ item.instruction }}</pre>
|
||||
{% endif %}
|
||||
|
||||
<p class="mt-1 text-[11px] text-ink-faint leading-relaxed">{{ item.obstacle.transKey|trans }}</p>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -0,0 +1,182 @@
|
||||
{#
|
||||
admin/backup/_frame.html.twig — Admin → Config backup.
|
||||
|
||||
Accepts:
|
||||
plan — App\Domain\DTO\Backup\ConfigBackupPlan, or null when there is nothing to review
|
||||
error — a translation key for a refusal, or null
|
||||
errorLiteral — an already-worded refusal (only FcmConfig's project-mismatch message), or null
|
||||
envelope — the uploaded ciphertext, carried into the apply step, or null
|
||||
minPasswordLength — what the export form refuses to go below
|
||||
|
||||
Two cards, and they are in the order an admin needs them: export first, because
|
||||
the person who has never used this page is here to make a file, and the person
|
||||
importing already knows where they are going.
|
||||
|
||||
The export form carries data-turbo="false". Turbo cannot render a download —
|
||||
it would swap an octet-stream into the frame and leave a blank panel — so the
|
||||
browser has to perform this submission itself. Every other form here posts into
|
||||
the frame as usual.
|
||||
|
||||
No auto-refresh wraps this frame, for the reason the push page states and more
|
||||
so: there are three password fields on it, and a poll would empty whichever one
|
||||
is half-typed.
|
||||
#}
|
||||
<turbo-frame id="admin-backup" class="block space-y-3">
|
||||
|
||||
{% if error or errorLiteral %}
|
||||
<div role="alert" class="flex items-start gap-2.5 rounded-pane alert-danger px-4 py-3 text-sm text-danger">
|
||||
<i class="fa-solid fa-circle-exclamation mt-0.5 shrink-0" aria-hidden="true"></i>
|
||||
<p class="leading-snug">{{ errorLiteral ?? (error|trans) }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Review, when there is one ─────────────────────────────────────── #}
|
||||
{% if plan is not null %}
|
||||
<div class="rounded-pane border {{ plan.applied ? 'border-emerald-500/40' : 'border-line' }} bg-pane-soft overflow-hidden">
|
||||
<div class="flex items-center gap-2.5 px-5 h-11 border-b border-line">
|
||||
<i class="fa-solid {{ plan.applied ? 'fa-circle-check text-emerald-500' : 'fa-list-check text-zinc-400' }} text-xs" aria-hidden="true"></i>
|
||||
<h2 class="text-sm font-semibold text-ink">
|
||||
{{ (plan.applied ? 'admin.config_backup.review.applied' : 'admin.config_backup.review.heading')|trans }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="px-5 py-4">
|
||||
{% include '_partials/_config_backup_plan.html.twig' with { plan: plan } only %}
|
||||
|
||||
{% if not plan.applied %}
|
||||
<form method="post"
|
||||
action="{{ path('app_admin_config_backup_apply') }}"
|
||||
data-turbo-frame="admin-backup"
|
||||
class="mt-5 flex flex-col gap-2 sm:flex-row sm:items-end">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('config_backup_apply') }}">
|
||||
{# The ciphertext, exactly as uploaded. Nothing about it
|
||||
is stored server-side between the two requests — see
|
||||
App\Controller\Admin\ConfigBackupController — and it
|
||||
is no more readable here than it was on disk. #}
|
||||
<input type="hidden" name="envelope" value="{{ envelope }}">
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<label for="apply-password" class="block text-xs text-ink-muted">
|
||||
{{ 'admin.config_backup.review.confirm_password'|trans }}
|
||||
</label>
|
||||
<input type="password" id="apply-password" name="password" required autocomplete="off"
|
||||
class="mt-1 w-full h-9 rounded-lg border border-line bg-surface px-3 text-sm text-ink
|
||||
focus:outline-none focus:ring-2 focus:ring-accent">
|
||||
</div>
|
||||
|
||||
<button type="submit" id="config-backup-apply"
|
||||
class="shrink-0 inline-flex items-center gap-2 h-9 px-4 rounded-lg text-sm font-medium
|
||||
text-white bg-accent hover:bg-accent-strong transition-colors cursor-pointer">
|
||||
<i class="fa-solid fa-download text-xs" aria-hidden="true"></i>
|
||||
{{ 'admin.config_backup.review.apply'|trans }}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Export ────────────────────────────────────────────────────────── #}
|
||||
<div class="rounded-pane border border-line bg-pane-soft overflow-hidden">
|
||||
<div class="flex items-center gap-2.5 px-5 h-11 border-b border-line">
|
||||
<i class="fa-solid fa-file-export text-xs text-zinc-400" aria-hidden="true"></i>
|
||||
<h2 class="text-sm font-semibold text-ink">{{ 'admin.config_backup.export.heading'|trans }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="px-5 py-4 space-y-4">
|
||||
<p class="text-sm text-ink-soft leading-relaxed">{{ 'admin.config_backup.export.intro'|trans }}</p>
|
||||
<p class="text-xs text-ink-faint leading-relaxed">
|
||||
<i class="fa-solid fa-triangle-exclamation mr-1 text-warning" aria-hidden="true"></i>
|
||||
{{ 'admin.config_backup.export.warning'|trans }}
|
||||
</p>
|
||||
|
||||
<form method="post"
|
||||
action="{{ path('app_admin_config_backup_export') }}"
|
||||
data-turbo="false"
|
||||
class="space-y-3">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('config_backup_export') }}">
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="export-password" class="block text-xs text-ink-muted">
|
||||
{{ 'admin.config_backup.export.password'|trans }}
|
||||
</label>
|
||||
<input type="password" id="export-password" name="password" required autocomplete="new-password"
|
||||
minlength="{{ minPasswordLength }}"
|
||||
class="mt-1 w-full h-9 rounded-lg border border-line bg-surface px-3 text-sm text-ink
|
||||
focus:outline-none focus:ring-2 focus:ring-accent">
|
||||
</div>
|
||||
<div>
|
||||
<label for="export-password-repeat" class="block text-xs text-ink-muted">
|
||||
{{ 'admin.config_backup.export.password_repeat'|trans }}
|
||||
</label>
|
||||
<input type="password" id="export-password-repeat" name="password_repeat" required autocomplete="new-password"
|
||||
minlength="{{ minPasswordLength }}"
|
||||
class="mt-1 w-full h-9 rounded-lg border border-line bg-surface px-3 text-sm text-ink
|
||||
focus:outline-none focus:ring-2 focus:ring-accent">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-ink-faint">{{ 'admin.config_backup.export.password_help'|trans({ '%length%': minPasswordLength }) }}</p>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" id="config-backup-export"
|
||||
class="inline-flex items-center gap-2 h-9 px-4 rounded-lg text-sm font-medium
|
||||
text-white bg-accent hover:bg-accent-strong transition-colors cursor-pointer">
|
||||
<i class="fa-solid fa-file-arrow-down text-xs" aria-hidden="true"></i>
|
||||
{{ 'admin.config_backup.export.submit'|trans }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Import ────────────────────────────────────────────────────────── #}
|
||||
<div class="rounded-pane border border-line bg-pane-soft overflow-hidden">
|
||||
<div class="flex items-center gap-2.5 px-5 h-11 border-b border-line">
|
||||
<i class="fa-solid fa-file-import text-xs text-zinc-400" aria-hidden="true"></i>
|
||||
<h2 class="text-sm font-semibold text-ink">{{ 'admin.config_backup.import.heading'|trans }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="px-5 py-4 space-y-4">
|
||||
<p class="text-sm text-ink-soft leading-relaxed">{{ 'admin.config_backup.import.intro'|trans }}</p>
|
||||
|
||||
<form method="post"
|
||||
action="{{ path('app_admin_config_backup_import') }}"
|
||||
enctype="multipart/form-data"
|
||||
data-turbo-frame="admin-backup"
|
||||
class="space-y-3">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('config_backup_import') }}">
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="import-file" class="block text-xs text-ink-muted">
|
||||
{{ 'admin.config_backup.import.file'|trans }}
|
||||
</label>
|
||||
<input type="file" id="import-file" name="backup" required accept=".backup,application/octet-stream"
|
||||
class="mt-1 w-full text-sm text-ink-soft file:mr-3 file:h-8 file:rounded-lg file:border-0
|
||||
file:bg-raised file:px-3 file:text-xs file:text-ink file:cursor-pointer">
|
||||
</div>
|
||||
<div>
|
||||
<label for="import-password" class="block text-xs text-ink-muted">
|
||||
{{ 'admin.config_backup.import.password'|trans }}
|
||||
</label>
|
||||
<input type="password" id="import-password" name="password" required autocomplete="off"
|
||||
class="mt-1 w-full h-9 rounded-lg border border-line bg-surface px-3 text-sm text-ink
|
||||
focus:outline-none focus:ring-2 focus:ring-accent">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" id="config-backup-import"
|
||||
class="inline-flex items-center gap-2 h-9 px-4 rounded-lg text-sm font-medium
|
||||
btn-quiet hover:bg-hover transition-colors cursor-pointer">
|
||||
<i class="fa-solid fa-magnifying-glass text-xs" aria-hidden="true"></i>
|
||||
{{ 'admin.config_backup.import.submit'|trans }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</turbo-frame>
|
||||
@@ -57,7 +57,7 @@
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% if section not in ['logs', 'integrations', 'push', 'reset'] %}
|
||||
{% if section not in ['logs', 'integrations', 'push', 'backup', 'reset'] %}
|
||||
<span class="ml-auto text-xs text-ink-faint">{{ 'admin.refresh_hint'|trans }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -109,6 +109,17 @@
|
||||
<i class="fa-solid fa-users w-4 {{ section == 'users' ? 'text-zinc-400' : '' }}" aria-hidden="true"></i>
|
||||
{{ 'admin.nav.users'|trans }}
|
||||
</a>
|
||||
{# After users and before reset. It is the
|
||||
only entry that is about the install as a
|
||||
whole rather than a part of it, and it
|
||||
belongs beside the other irreversible
|
||||
thing rather than among the panels an
|
||||
admin opens to look at something. #}
|
||||
<a href="{{ path('app_admin_dashboard', { section: 'backup' }) }}"
|
||||
class="{{ section == 'backup' ? navActive : navInactive }}">
|
||||
<i class="fa-solid fa-box-archive w-4 {{ section == 'backup' ? 'text-zinc-400' : '' }}" aria-hidden="true"></i>
|
||||
{{ 'admin.nav.backup'|trans }}
|
||||
</a>
|
||||
{# Last, and the only one tinted: everything above reads or
|
||||
repairs, this one deletes. #}
|
||||
<a href="{{ path('app_admin_dashboard', { section: 'reset' }) }}"
|
||||
@@ -205,6 +216,25 @@
|
||||
</div>
|
||||
</turbo-frame>
|
||||
|
||||
{% elseif section == 'backup' %}
|
||||
|
||||
{# No auto-refresh, and more emphatically
|
||||
than anywhere else: there are three
|
||||
password fields in this frame and a
|
||||
poll would empty whichever one is
|
||||
half-typed. The error travels on the
|
||||
query string for the reason the reset
|
||||
frame records — the frame is loaded by
|
||||
its own request and would not see a
|
||||
flash set for the page around it. #}
|
||||
<turbo-frame id="admin-backup"
|
||||
src="{{ path('app_admin_config_backup_panel', app.request.query.get('error') ? { error: app.request.query.get('error') } : {}) }}"
|
||||
loading="lazy">
|
||||
<div class="py-10 text-center text-sm text-ink-faint">
|
||||
<i class="fa-solid fa-circle-notch fa-spin mr-2" aria-hidden="true"></i>{{ 'admin.loading'|trans }}
|
||||
</div>
|
||||
</turbo-frame>
|
||||
|
||||
{% elseif section == 'reset' %}
|
||||
|
||||
{# Emphatically no auto-refresh: one of the forms in
|
||||
|
||||
@@ -61,6 +61,17 @@ password in a max-w-sm card reads as a wall.
|
||||
</button>
|
||||
|
||||
{{ form_end(form) }}
|
||||
|
||||
{# Beside the ordinary path rather than instead of it, and below
|
||||
the button rather than above: an install that has no backup —
|
||||
which is most of them — must not be made to read past an
|
||||
option it cannot use. The account is still created here
|
||||
afterwards; a config backup carries configuration, not
|
||||
people, and the link says so. #}
|
||||
<p class="mt-6 border-t border-line pt-5 text-center text-xs text-ink-muted leading-relaxed">
|
||||
{{ 'setup.install.restore_prompt'|trans }}
|
||||
<a href="{{ path('app_install_restore') }}" class="text-accent hover:underline">{{ 'setup.install.restore_link'|trans }}</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p class="text-center text-xs text-ink-faint mt-6">
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
{#
|
||||
setup/restore.html.twig — the alternative first screen: bring an old install's
|
||||
configuration with you.
|
||||
|
||||
Accepts:
|
||||
plan — App\Domain\DTO\Backup\ConfigBackupPlan, or null before anything is uploaded
|
||||
error — a translation key for a refusal, or null
|
||||
errorLiteral — an already-worded refusal, or null
|
||||
envelope — the uploaded ciphertext, carried into the apply step, or null
|
||||
|
||||
Built on install.html.twig's card, because it is the same moment in the same
|
||||
flow and the two screens link to each other. Wider than that one (max-w-2xl):
|
||||
the review is a table of what will happen, and a list of .env.local lines in a
|
||||
max-w-md card wraps into nonsense.
|
||||
|
||||
The whole page is data-turbo="false" for the reason install.html.twig gives —
|
||||
Turbo swaps only the <body>, so the appearance rendered onto <html> is ignored
|
||||
until a real navigation, and the next navigation from here is into the app.
|
||||
|
||||
Restoring does NOT create an account, and the page says so at every step: this
|
||||
carries configuration, not people. The link back to /install is the actual next
|
||||
action and is never hidden.
|
||||
#}
|
||||
{% extends '_layout/app.html.twig' %}
|
||||
|
||||
{% block title %}{{ 'setup.restore.page_title'|trans }} — {{ 'app.name'|trans }}{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="min-h-screen flex items-center justify-center app-bg px-4 py-10">
|
||||
<div class="w-full max-w-2xl">
|
||||
|
||||
<div class="flex flex-col items-center mb-8">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<i class="fa-solid fa-envelope text-accent text-2xl" aria-hidden="true"></i>
|
||||
<span class="text-xl font-semibold text-ink">{{ 'app.name'|trans }}</span>
|
||||
</div>
|
||||
<p class="text-sm text-ink-muted">{{ 'setup.install.tagline'|trans }}</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-surface rounded-pane shadow-sm border border-line p-8">
|
||||
|
||||
<h1 class="text-base font-semibold text-ink">{{ 'setup.restore.heading'|trans }}</h1>
|
||||
<p class="mt-1.5 text-sm text-ink-muted leading-relaxed">{{ 'setup.restore.lead'|trans }}</p>
|
||||
|
||||
{% if error or errorLiteral %}
|
||||
<div role="alert" class="mt-6 flex items-start gap-2.5 rounded-lg alert-danger px-3.5 py-3 text-sm text-danger">
|
||||
<i class="fa-solid fa-circle-exclamation mt-0.5 shrink-0 text-danger" aria-hidden="true"></i>
|
||||
<p class="leading-snug">{{ errorLiteral ?? (error|trans) }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if plan is not null %}
|
||||
<div class="mt-6">
|
||||
{% include '_partials/_config_backup_plan.html.twig' with { plan: plan } only %}
|
||||
</div>
|
||||
|
||||
{% if plan.applied %}
|
||||
{# The account is the next step and the only one left.
|
||||
Said plainly, because "restored" reads like "finished"
|
||||
and this install still has no users. #}
|
||||
<div class="mt-6 flex items-start gap-2.5 rounded-lg bg-emerald-500/10 px-3.5 py-3 text-sm text-emerald-500">
|
||||
<i class="fa-solid fa-circle-check mt-0.5 shrink-0" aria-hidden="true"></i>
|
||||
<p class="leading-snug">{{ 'setup.restore.applied_lead'|trans }}</p>
|
||||
</div>
|
||||
|
||||
<a href="{{ path('app_install') }}"
|
||||
class="mt-6 block w-full text-center bg-accent hover:bg-accent-strong text-white text-sm font-medium py-2 rounded-lg transition-colors">
|
||||
{{ 'setup.restore.continue'|trans }}
|
||||
</a>
|
||||
{% else %}
|
||||
<form method="post" action="{{ path('app_install_restore_apply') }}" data-turbo="false" class="mt-6 space-y-3">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('install_restore_apply') }}">
|
||||
<input type="hidden" name="envelope" value="{{ envelope }}">
|
||||
|
||||
<div>
|
||||
<label for="restore-apply-password" class="block text-xs text-ink-muted">
|
||||
{{ 'admin.config_backup.review.confirm_password'|trans }}
|
||||
</label>
|
||||
<input type="password" id="restore-apply-password" name="password" required autocomplete="off"
|
||||
class="mt-1 w-full h-9 rounded-lg border border-line bg-surface px-3 text-sm text-ink
|
||||
focus:outline-none focus:ring-2 focus:ring-accent">
|
||||
</div>
|
||||
|
||||
<button type="submit" id="restore-apply"
|
||||
class="w-full bg-accent hover:bg-accent-strong text-white text-sm font-medium py-2 rounded-lg transition-colors">
|
||||
{{ 'admin.config_backup.review.apply'|trans }}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<form method="post" action="{{ path('app_install_restore') }}" enctype="multipart/form-data" data-turbo="false" class="mt-6 space-y-4">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('install_restore') }}">
|
||||
|
||||
<div>
|
||||
<label for="restore-file" class="block text-xs text-ink-muted">
|
||||
{{ 'admin.config_backup.import.file'|trans }}
|
||||
</label>
|
||||
<input type="file" id="restore-file" name="backup" required accept=".backup,application/octet-stream"
|
||||
class="mt-1 w-full text-sm text-ink-soft file:mr-3 file:h-8 file:rounded-lg file:border-0
|
||||
file:bg-raised file:px-3 file:text-xs file:text-ink file:cursor-pointer">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="restore-password" class="block text-xs text-ink-muted">
|
||||
{{ 'admin.config_backup.import.password'|trans }}
|
||||
</label>
|
||||
<input type="password" id="restore-password" name="password" required autocomplete="off"
|
||||
class="mt-1 w-full h-9 rounded-lg border border-line bg-surface px-3 text-sm text-ink
|
||||
focus:outline-none focus:ring-2 focus:ring-accent">
|
||||
</div>
|
||||
|
||||
<button type="submit" id="restore-submit"
|
||||
class="w-full bg-accent hover:bg-accent-strong text-white text-sm font-medium py-2 rounded-lg transition-colors">
|
||||
{{ 'setup.restore.submit'|trans }}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<p class="mt-6 text-center text-xs text-ink-faint">
|
||||
<a href="{{ path('app_install') }}" class="text-accent hover:underline">{{ 'setup.restore.back'|trans }}</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p class="text-center text-xs text-ink-faint mt-6">
|
||||
{{ 'setup.restore.footnote'|trans }}
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,357 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller\Admin;
|
||||
|
||||
use App\Controller\Admin\ConfigBackupController;
|
||||
use App\Entity\User\User;
|
||||
use App\Infrastructure\Backup\ConfigBackupCipher;
|
||||
use App\Service\Backup\ConfigBackupDatabase;
|
||||
use App\Service\Backup\ConfigBackupExporter;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
/**
|
||||
* The four doors on the config backup page.
|
||||
*
|
||||
* This is the one endpoint in plMail that hands out every credential the
|
||||
* installation owns in a single response, and the one that writes them back.
|
||||
* Three things therefore have to be true of it and are asserted rather than
|
||||
* reasoned about: an ordinary user cannot reach it, a request without a CSRF
|
||||
* token cannot drive it, and the review step really does write nothing.
|
||||
*
|
||||
* That last one is the interesting assertion. A reviewer reading the controller
|
||||
* sees import() call plan() and apply() call apply(), and it looks obvious — but
|
||||
* the failure mode is one refactor away and completely silent, because a review
|
||||
* that had already applied its changes would render exactly the same page.
|
||||
*
|
||||
* Everything runs inside a transaction that is rolled back, so the suite's
|
||||
* shared database is left as it was found. Files are deliberately not part of
|
||||
* any fixture here: an apply that wrote a PEM into var/secrets would escape the
|
||||
* transaction, and what a file write does is ConfigBackupImporterTest's subject
|
||||
* anyway.
|
||||
*/
|
||||
final class ConfigBackupControllerTest extends WebTestCase
|
||||
{
|
||||
private const string PASSWORD = 'a properly long backup password';
|
||||
|
||||
private EntityManagerInterface $em;
|
||||
|
||||
private Connection $connection;
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (isset($this->connection) && true === $this->connection->isTransactionActive()) {
|
||||
$this->connection->rollBack();
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testAnOrdinaryUserCanNeitherSeeTheBackupPageNorExportFromIt(): void
|
||||
{
|
||||
$client = $this->boot();
|
||||
$client->loginUser($this->seedUser());
|
||||
|
||||
$client->request('GET', '/admin/config-backup');
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
$client->request('POST', '/admin/config-backup/export', ['_token' => 'irrelevant', 'password' => self::PASSWORD, 'password_repeat' => self::PASSWORD]);
|
||||
self::assertResponseStatusCodeSame(403, 'a non-admin was handed the installation\'s credentials');
|
||||
|
||||
$client->request('POST', '/admin/config-backup/import', ['_token' => 'irrelevant', 'password' => self::PASSWORD]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
$client->request('POST', '/admin/config-backup/apply', ['_token' => 'irrelevant', 'password' => self::PASSWORD]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* A signed-in admin is not enough. Without this, any page an admin visits
|
||||
* could make their browser fetch the whole credential set and post it away.
|
||||
*/
|
||||
public function testATokenlessPostIsRefusedOnEveryAction(): void
|
||||
{
|
||||
$client = $this->signInAsAdmin();
|
||||
|
||||
foreach (['export', 'import', 'apply'] as $action) {
|
||||
$client->request('POST', '/admin/config-backup/' . $action, [
|
||||
'_token' => 'nonsense',
|
||||
'password' => self::PASSWORD,
|
||||
'password_repeat' => self::PASSWORD,
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(403, $action . ' ran without a CSRF token');
|
||||
}
|
||||
}
|
||||
|
||||
public function testAnAdminGetsADownloadThatOpensWithThePasswordTheyTyped(): void
|
||||
{
|
||||
$client = $this->signInAsAdmin();
|
||||
|
||||
$client->request('POST', '/admin/config-backup/export', [
|
||||
'_token' => $this->token($client, 'config_backup_export'),
|
||||
'password' => self::PASSWORD,
|
||||
'password_repeat' => self::PASSWORD,
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertResponseHeaderSame('Content-Type', 'application/octet-stream');
|
||||
self::assertStringContainsString(
|
||||
'attachment; filename=plmail-config-',
|
||||
(string) $client->getResponse()->headers->get('Content-Disposition'),
|
||||
);
|
||||
// A proxy holding this in a shared cache would be the whole feature
|
||||
// undone.
|
||||
self::assertStringContainsString('no-store', (string) $client->getResponse()->headers->get('Cache-Control'));
|
||||
|
||||
$document = static::getContainer()->get(ConfigBackupCipher::class)
|
||||
->open((string) $client->getResponse()->getContent(), self::PASSWORD);
|
||||
|
||||
self::assertSame(ConfigBackupCipher::FORMAT, $document['format']);
|
||||
self::assertArrayHasKey('env', $document);
|
||||
self::assertArrayHasKey('database', $document);
|
||||
}
|
||||
|
||||
public function testAMistypedRepeatExportsNothing(): void
|
||||
{
|
||||
$client = $this->signInAsAdmin();
|
||||
|
||||
$client->request('POST', '/admin/config-backup/export', [
|
||||
'_token' => $this->token($client, 'config_backup_export'),
|
||||
'password' => self::PASSWORD,
|
||||
'password_repeat' => self::PASSWORD . '!',
|
||||
]);
|
||||
|
||||
self::assertResponseRedirects();
|
||||
self::assertStringContainsString(
|
||||
'error=' . ConfigBackupController::ERROR_PASSWORD_MISMATCH,
|
||||
(string) $client->getResponse()->headers->get('Location'),
|
||||
'the admin was not told why no file arrived',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The review renders the plan and leaves the database exactly as it was.
|
||||
*/
|
||||
public function testReviewingABackupWritesNothing(): void
|
||||
{
|
||||
$client = $this->signInAsAdmin();
|
||||
$this->connection->executeStatement('TRUNCATE TABLE fcm_config CASCADE');
|
||||
|
||||
$client->request(
|
||||
'POST',
|
||||
'/admin/config-backup/import',
|
||||
['_token' => $this->token($client, 'config_backup_import'), 'password' => self::PASSWORD],
|
||||
['backup' => $this->upload($this->sealedFixture())],
|
||||
);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$body = (string) $client->getResponse()->getContent();
|
||||
|
||||
self::assertStringContainsString(ConfigBackupDatabase::FCM_CONFIG, $body, 'the review does not say what it would do');
|
||||
// Every environment value has to appear under the manual heading, with
|
||||
// its line. This one is the check that the page is honest.
|
||||
self::assertStringContainsString('APP_SECRET=a-restored-secret', $body);
|
||||
|
||||
self::assertSame(0, (int) $this->connection->fetchOne('SELECT count(*) FROM fcm_config'), 'the review applied itself');
|
||||
}
|
||||
|
||||
/**
|
||||
* Driven through the form the review rendered rather than by posting to
|
||||
* /apply directly, and that is not politeness — it is the only way the
|
||||
* second step is reachable. The apply token is minted while rendering a
|
||||
* review, so a test that forged one would be exercising a path no browser
|
||||
* can take.
|
||||
*/
|
||||
public function testApplyingWritesTheDatabaseHalfAndNothingElse(): void
|
||||
{
|
||||
$client = $this->signInAsAdmin();
|
||||
$this->connection->executeStatement('TRUNCATE TABLE fcm_config CASCADE');
|
||||
|
||||
$review = $client->request(
|
||||
'POST',
|
||||
'/admin/config-backup/import',
|
||||
['_token' => $this->token($client, 'config_backup_import'), 'password' => self::PASSWORD],
|
||||
['backup' => $this->upload($this->sealedFixture())],
|
||||
);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$client->submit($review->filter('#config-backup-apply')->form(['password' => self::PASSWORD]));
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
self::assertSame(
|
||||
'plmail-controller-test',
|
||||
(string) $this->connection->fetchOne('SELECT project_id FROM fcm_config LIMIT 1'),
|
||||
'the credentials the review promised were not written',
|
||||
);
|
||||
|
||||
// And what it wrote is readable through the ORM, which is the whole
|
||||
// point of re-encrypting rather than copying: the column now holds
|
||||
// ciphertext this instance's key produced.
|
||||
self::assertStringStartsWith(
|
||||
'enc:v1:',
|
||||
(string) $this->connection->fetchOne('SELECT service_account_json FROM fcm_config LIMIT 1'),
|
||||
);
|
||||
}
|
||||
|
||||
public function testAWrongPasswordSaysSoAndChangesNothing(): void
|
||||
{
|
||||
$client = $this->signInAsAdmin();
|
||||
$this->connection->executeStatement('TRUNCATE TABLE fcm_config CASCADE');
|
||||
|
||||
$client->request(
|
||||
'POST',
|
||||
'/admin/config-backup/import',
|
||||
['_token' => $this->token($client, 'config_backup_import'), 'password' => 'not the password at all'],
|
||||
['backup' => $this->upload($this->sealedFixture())],
|
||||
);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertStringContainsString(
|
||||
'does not open this file',
|
||||
(string) $client->getResponse()->getContent(),
|
||||
'the admin was not told the password was wrong',
|
||||
);
|
||||
self::assertSame(0, (int) $this->connection->fetchOne('SELECT count(*) FROM fcm_config'));
|
||||
}
|
||||
|
||||
public function testAFileThatIsNotABackupIsRefusedWithoutTouchingAnything(): void
|
||||
{
|
||||
$client = $this->signInAsAdmin();
|
||||
$this->connection->executeStatement('TRUNCATE TABLE fcm_config CASCADE');
|
||||
|
||||
$client->request(
|
||||
'POST',
|
||||
'/admin/config-backup/import',
|
||||
['_token' => $this->token($client, 'config_backup_import'), 'password' => self::PASSWORD],
|
||||
['backup' => $this->upload('this is somebody\'s shopping list')],
|
||||
);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertStringContainsString('not a plMail configuration backup', (string) $client->getResponse()->getContent());
|
||||
self::assertSame(0, (int) $this->connection->fetchOne('SELECT count(*) FROM fcm_config'));
|
||||
}
|
||||
|
||||
// ── Fixtures ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A backup with one row's worth of database and one environment line, and
|
||||
* deliberately no `files` section — see the class docblock.
|
||||
*/
|
||||
private function sealedFixture(): string
|
||||
{
|
||||
return static::getContainer()->get(ConfigBackupCipher::class)->seal([
|
||||
'format' => ConfigBackupCipher::FORMAT,
|
||||
'version' => ConfigBackupExporter::DOCUMENT_VERSION,
|
||||
'exportedAt' => '2026-01-01T00:00:00+00:00',
|
||||
'instance' => 'https://elsewhere.plmail.test',
|
||||
'env' => ['APP_SECRET' => 'a-restored-secret'],
|
||||
'files' => [],
|
||||
'database' => [
|
||||
ConfigBackupDatabase::FCM_CONFIG => [
|
||||
'isEnabled' => true,
|
||||
'serviceAccountJson' => '{"type":"service_account","project_id":"plmail-controller-test",'
|
||||
. '"client_email":"p@plmail-controller-test.iam.gserviceaccount.com",'
|
||||
. '"private_key":"-----BEGIN PRIVATE KEY-----\nMIIB\n-----END PRIVATE KEY-----\n"}',
|
||||
'projectId' => 'plmail-controller-test',
|
||||
'clientEmail' => 'p@plmail-controller-test.iam.gserviceaccount.com',
|
||||
'applicationId' => '1:1:android:abc',
|
||||
'apiKey' => 'AIza-key',
|
||||
'senderId' => '1',
|
||||
'androidPackage' => 'test.plmail',
|
||||
],
|
||||
],
|
||||
], self::PASSWORD);
|
||||
}
|
||||
|
||||
/**
|
||||
* An upload PHP never moved anywhere. `test: true` is what lets the
|
||||
* framework accept a file that did not arrive through a real multipart
|
||||
* request; the bytes still travel exactly as they would.
|
||||
*/
|
||||
private function upload(string $contents): UploadedFile
|
||||
{
|
||||
$path = (string) tempnam(sys_get_temp_dir(), 'plmail-backup-');
|
||||
file_put_contents($path, $contents);
|
||||
|
||||
return new UploadedFile($path, 'plmail-config-2026-01-01.backup', 'application/octet-stream', null, true);
|
||||
}
|
||||
|
||||
private function boot(): KernelBrowser
|
||||
{
|
||||
$client = static::createClient();
|
||||
// Without this the kernel is rebooted between requests and the new
|
||||
// container's connection cannot see the uncommitted work.
|
||||
$client->disableReboot();
|
||||
|
||||
$container = static::getContainer();
|
||||
$this->em = $container->get(EntityManagerInterface::class);
|
||||
$this->connection = $container->get(Connection::class);
|
||||
|
||||
$this->connection->beginTransaction();
|
||||
|
||||
return $client;
|
||||
}
|
||||
|
||||
private function signInAsAdmin(): KernelBrowser
|
||||
{
|
||||
$client = $this->boot();
|
||||
|
||||
$admin = $this->seedUser();
|
||||
$admin->addRole(User::ROLE_ADMIN);
|
||||
$this->em->flush();
|
||||
|
||||
$client->loginUser($admin);
|
||||
|
||||
return $client;
|
||||
}
|
||||
|
||||
private function seedUser(): User
|
||||
{
|
||||
$user = new User();
|
||||
$user->email = 'config-backup-' . uniqid('', true) . '@example.test';
|
||||
$user->nameFirst = 'Fixture';
|
||||
$user->nameLast = 'Person';
|
||||
$user->roles = ['ROLE_USER'];
|
||||
$user->password = '$2y$04$abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOP';
|
||||
|
||||
$this->em->persist($user);
|
||||
$this->em->flush();
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* A real CSRF token for $id.
|
||||
*
|
||||
* The GET first is load-bearing — same trick, and the same reason,
|
||||
* AdminDataResetTest records.
|
||||
*/
|
||||
private function token(KernelBrowser $client, string $id): string
|
||||
{
|
||||
$client->request('GET', '/admin/config-backup');
|
||||
|
||||
$stack = static::getContainer()->get('request_stack');
|
||||
$carrier = new Request();
|
||||
$carrier->setSession($client->getRequest()->getSession());
|
||||
$stack->push($carrier);
|
||||
|
||||
try {
|
||||
return (string) static::getContainer()
|
||||
->get('security.csrf.token_manager')
|
||||
->getToken($id)
|
||||
->getValue();
|
||||
} finally {
|
||||
$stack->pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller;
|
||||
|
||||
use App\Infrastructure\Backup\ConfigBackupCipher;
|
||||
use App\Infrastructure\Doctrine\Type\EncryptedStringType;
|
||||
use App\Infrastructure\Encryption\Encryptor;
|
||||
use App\Repository\Push\FcmConfigRepository;
|
||||
use App\Repository\User\UserRepository;
|
||||
use App\Service\Backup\ConfigBackupDatabase;
|
||||
use App\Service\Backup\ConfigBackupExporter;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
/**
|
||||
* Restoring a config backup as the first thing a new install does.
|
||||
*
|
||||
* The second door onto /install, and it inherits that page's whole security
|
||||
* story: it creates nothing and authenticates nobody, and it is safe only for
|
||||
* as long as the install has no users. So the guard is asserted first and in
|
||||
* both directions — a restore endpoint that stayed open after setup would be an
|
||||
* unauthenticated way to rewrite an installation's Firebase and OAuth
|
||||
* credentials, which is a considerably worse door than the one it was modelled
|
||||
* on.
|
||||
*
|
||||
* The interesting half is the encryption key. A fresh install has minted its
|
||||
* own at first container start, so a backup restored here is by definition
|
||||
* being opened by a different key from the one that wrote it — which is the
|
||||
* exact case the decrypted-inside-the-envelope design exists for and the exact
|
||||
* case nothing else exercises end to end. The swap is done through
|
||||
* EncryptedStringType's static seam, the one Kernel::boot() uses, because that
|
||||
* is as close to "a different machine" as one process gets.
|
||||
*
|
||||
* Everything is done inside a transaction that is rolled back — without it this
|
||||
* test destroys the seed every other suite and the e2e run depend on, which is
|
||||
* the note InstallEmptyInstallTest makes for the same reason.
|
||||
*/
|
||||
final class InstallRestoreTest extends WebTestCase
|
||||
{
|
||||
private const string PASSWORD = 'a properly long backup password';
|
||||
|
||||
private ?Connection $connection = null;
|
||||
|
||||
private ?Encryptor $originalEncryptor = null;
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (null !== $this->originalEncryptor) {
|
||||
EncryptedStringType::setEncryptor($this->originalEncryptor);
|
||||
$this->originalEncryptor = null;
|
||||
}
|
||||
|
||||
if (null !== $this->connection && $this->connection->isTransactionActive()) {
|
||||
$this->connection->rollBack();
|
||||
}
|
||||
|
||||
$this->connection = null;
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* The seeded database has users, so this is the ordinary state of every
|
||||
* install that has ever been set up.
|
||||
*/
|
||||
public function testAnInstallThatHasUsersDoesNotExposeTheRestoreAtAll(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
self::assertGreaterThan(
|
||||
0,
|
||||
static::getContainer()->get(UserRepository::class)->countAll(),
|
||||
'the fixture is only valid on an install that has been set up',
|
||||
);
|
||||
|
||||
foreach ([['GET', '/install/restore'], ['POST', '/install/restore'], ['POST', '/install/restore/apply']] as [$method, $path]) {
|
||||
$client->request($method, $path);
|
||||
|
||||
// 404 rather than a redirect, for the reason InstallGuard states: a
|
||||
// redirect confirms the endpoint exists and is merely closed.
|
||||
self::assertSame(404, $client->getResponse()->getStatusCode(), $method . ' ' . $path . ' is still open');
|
||||
}
|
||||
}
|
||||
|
||||
public function testTheFirstScreenOffersTheRestoreBesideTheOrdinaryPath(): void
|
||||
{
|
||||
$client = $this->bootOnAnEmptyInstall();
|
||||
|
||||
$client->request('GET', '/install');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertStringContainsString('/install/restore', (string) $client->getResponse()->getContent());
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole path, end to end, with the key changed underneath it: upload,
|
||||
* review, apply, and then the account creation that the restore did not do.
|
||||
*/
|
||||
public function testARestoredInstallKeepsItsFirebaseCredentialsUnderItsOwnNewKey(): void
|
||||
{
|
||||
$client = $this->bootOnAnEmptyInstall();
|
||||
|
||||
// The backup was sealed by another install. This one has its own key,
|
||||
// and it is not that one.
|
||||
$envelope = $this->sealedFixture();
|
||||
$this->becomeADifferentInstallation();
|
||||
|
||||
$review = $client->request('POST', '/install/restore', [
|
||||
'_token' => $this->token($client, 'install_restore'),
|
||||
'password' => self::PASSWORD,
|
||||
], [
|
||||
'backup' => $this->upload($envelope),
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
// Nothing has happened yet — the review is a review here too.
|
||||
self::assertSame(0, (int) $this->connection->fetchOne('SELECT count(*) FROM fcm_config'));
|
||||
// And the honest half is on the page: the environment values the app
|
||||
// will not write, as lines to paste.
|
||||
self::assertStringContainsString('APP_SECRET=a-restored-secret', (string) $client->getResponse()->getContent());
|
||||
|
||||
$client->submit($review->filter('#restore-apply')->form(['password' => self::PASSWORD]));
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
static::getContainer()->get(EntityManagerInterface::class)->clear();
|
||||
$restored = static::getContainer()->get(FcmConfigRepository::class)->current();
|
||||
|
||||
self::assertNotNull($restored, 'the restore wrote nothing');
|
||||
self::assertSame('plmail-onboarding-test', $restored->projectId);
|
||||
self::assertStringContainsString('private_key', (string) $restored->serviceAccountJson);
|
||||
|
||||
// The account is still to be created — a config backup carries
|
||||
// configuration, not people — and the page has to say so and lead there.
|
||||
self::assertSame(0, static::getContainer()->get(UserRepository::class)->countAll());
|
||||
self::assertStringContainsString('/install', (string) $client->getResponse()->getContent());
|
||||
|
||||
// And the ordinary path still works afterwards, which is what "the
|
||||
// restore did not half-configure the install" means in practice.
|
||||
$client->request('GET', '/install');
|
||||
self::assertResponseIsSuccessful();
|
||||
}
|
||||
|
||||
/**
|
||||
* A wrong password at setup must not consume the attempt: nothing written,
|
||||
* nothing marked done, and the two setup pages still there.
|
||||
*/
|
||||
public function testAWrongPasswordLeavesTheSetupExactlyWhereItWas(): void
|
||||
{
|
||||
$client = $this->bootOnAnEmptyInstall();
|
||||
|
||||
$client->request('POST', '/install/restore', [
|
||||
'_token' => $this->token($client, 'install_restore'),
|
||||
'password' => 'not the password at all',
|
||||
], [
|
||||
'backup' => $this->upload($this->sealedFixture()),
|
||||
]);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertStringContainsString('does not open this file', (string) $client->getResponse()->getContent());
|
||||
|
||||
self::assertSame(0, (int) $this->connection->fetchOne('SELECT count(*) FROM fcm_config'));
|
||||
self::assertSame(0, static::getContainer()->get(UserRepository::class)->countAll());
|
||||
|
||||
$client->request('GET', '/install');
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$client->request('GET', '/install/restore');
|
||||
self::assertResponseIsSuccessful();
|
||||
}
|
||||
|
||||
/**
|
||||
* A 302 rather than a 403, and that is not the assertion being loose. This
|
||||
* page has no session to be forbidden from, so the firewall's entry point
|
||||
* turns the refusal into a redirect at the login page — which on a userless
|
||||
* install redirects here again. What matters is that the request did not go
|
||||
* through, and that is what is asserted twice: the response and the table.
|
||||
*/
|
||||
public function testAPostWithoutACsrfTokenRestoresNothing(): void
|
||||
{
|
||||
$client = $this->bootOnAnEmptyInstall();
|
||||
|
||||
$client->request('POST', '/install/restore', ['password' => self::PASSWORD], ['backup' => $this->upload($this->sealedFixture())]);
|
||||
|
||||
self::assertResponseRedirects();
|
||||
self::assertSame(0, (int) $this->connection->fetchOne('SELECT count(*) FROM fcm_config'));
|
||||
}
|
||||
|
||||
// ── Fixtures ──────────────────────────────────────────────────────────────
|
||||
|
||||
private function bootOnAnEmptyInstall(): KernelBrowser
|
||||
{
|
||||
$client = static::createClient();
|
||||
// Without this the kernel is rebooted between requests, and the new
|
||||
// container's connection cannot see the uncommitted truncate.
|
||||
$client->disableReboot();
|
||||
|
||||
$entityManager = static::getContainer()->get(EntityManagerInterface::class);
|
||||
$this->originalEncryptor = static::getContainer()->get(Encryptor::class);
|
||||
$this->connection = $entityManager->getConnection();
|
||||
|
||||
$this->connection->beginTransaction();
|
||||
|
||||
$tables = array_filter(
|
||||
$this->connection->createSchemaManager()->listTableNames(),
|
||||
static fn (string $table): bool => 'doctrine_migration_versions' !== $table,
|
||||
);
|
||||
|
||||
$this->connection->executeStatement(sprintf('TRUNCATE TABLE %s CASCADE', implode(', ', $tables)));
|
||||
$entityManager->clear();
|
||||
|
||||
return $client;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the target instance's key would be: its own, minted at first start
|
||||
* and unrelated to whatever sealed the file.
|
||||
*/
|
||||
private function becomeADifferentInstallation(): void
|
||||
{
|
||||
EncryptedStringType::setEncryptor(new Encryptor('YW5vdGhlciBpbnN0YWxsYXRpb25zIGtleSwgMzIgYnk='));
|
||||
}
|
||||
|
||||
/**
|
||||
* A backup with no `files` section on purpose: a write into var/secrets
|
||||
* would escape the transaction this test rolls back, and what a file write
|
||||
* does is ConfigBackupImporterTest's subject.
|
||||
*/
|
||||
private function sealedFixture(): string
|
||||
{
|
||||
return static::getContainer()->get(ConfigBackupCipher::class)->seal([
|
||||
'format' => ConfigBackupCipher::FORMAT,
|
||||
'version' => ConfigBackupExporter::DOCUMENT_VERSION,
|
||||
'exportedAt' => '2026-01-01T00:00:00+00:00',
|
||||
'instance' => 'https://the-old-machine.plmail.test',
|
||||
'env' => ['APP_SECRET' => 'a-restored-secret', 'APP_ENCRYPTION_KEY' => 'dGhlIG9sZCBtYWNoaW5lcyBrZXksIHRoaXJ0eS10d28='],
|
||||
'files' => [],
|
||||
'database' => [
|
||||
ConfigBackupDatabase::FCM_CONFIG => [
|
||||
'isEnabled' => true,
|
||||
'serviceAccountJson' => '{"type":"service_account","project_id":"plmail-onboarding-test",'
|
||||
. '"client_email":"p@plmail-onboarding-test.iam.gserviceaccount.com",'
|
||||
. '"private_key":"-----BEGIN PRIVATE KEY-----\nMIIB\n-----END PRIVATE KEY-----\n"}',
|
||||
'projectId' => 'plmail-onboarding-test',
|
||||
'clientEmail' => 'p@plmail-onboarding-test.iam.gserviceaccount.com',
|
||||
'applicationId' => '1:1:android:abc',
|
||||
'apiKey' => 'AIza-key',
|
||||
'senderId' => '1',
|
||||
'androidPackage' => 'test.plmail',
|
||||
],
|
||||
],
|
||||
], self::PASSWORD);
|
||||
}
|
||||
|
||||
private function upload(string $contents): UploadedFile
|
||||
{
|
||||
$path = (string) tempnam(sys_get_temp_dir(), 'plmail-backup-');
|
||||
file_put_contents($path, $contents);
|
||||
|
||||
return new UploadedFile(
|
||||
$path,
|
||||
'plmail-config-2026-01-01.backup',
|
||||
'application/octet-stream',
|
||||
null,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A real CSRF token for $id, minted against the session the page it belongs
|
||||
* to already established — the same carrier-request trick AdminDataResetTest
|
||||
* records, and for the same reason.
|
||||
*/
|
||||
private function token(KernelBrowser $client, string $id): string
|
||||
{
|
||||
$client->request('GET', '/install/restore');
|
||||
|
||||
$stack = static::getContainer()->get('request_stack');
|
||||
$carrier = new Request();
|
||||
$carrier->setSession($client->getRequest()->getSession());
|
||||
$stack->push($carrier);
|
||||
|
||||
try {
|
||||
return (string) static::getContainer()
|
||||
->get('security.csrf.token_manager')
|
||||
->getToken($id)
|
||||
->getValue();
|
||||
} finally {
|
||||
$stack->pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,12 @@ final class PageRendersTest extends WebTestCase
|
||||
// 400 — and a page that 500s on a typo is a page nobody diagnoses
|
||||
// anything with.
|
||||
yield 'admin push deliveries, nonsense filters' => ['/admin/push/deliveries?usr=nope&transport=fcmm&outcome=exploded&page=-3'];
|
||||
yield 'admin config backup' => ['/admin/config-backup'];
|
||||
// The one query string this frame reads. An unknown value has to render
|
||||
// the panel without an error rather than 500 on a missing translation
|
||||
// key, for the same reason the delivery filters above fall back.
|
||||
yield 'admin config backup, export refused' => ['/admin/config-backup?error=password-mismatch'];
|
||||
yield 'admin config backup, nonsense error' => ['/admin/config-backup?error=made-up'];
|
||||
// The wizard renders into the modal frame, so these are fragments
|
||||
// rather than pages — but a broken step template still shows up as a
|
||||
// 500 here, which is the point.
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Infrastructure\Backup;
|
||||
|
||||
use App\Domain\Enum\Backup\ConfigBackupFailure;
|
||||
use App\Domain\Exception\ConfigBackupException;
|
||||
use App\Infrastructure\Backup\ConfigBackupCipher;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* The envelope opens with the right password and with nothing else.
|
||||
*
|
||||
* This is the one class in the feature whose failure is silent: a seal that
|
||||
* does not really encrypt, a header whose parameters are ignored, or an open()
|
||||
* that accepts a tampered ciphertext all produce a file that LOOKS like a
|
||||
* backup and is either readable by anyone or trusted when it should not be. So
|
||||
* the assertions are about the bytes, not about the round trip alone — the
|
||||
* round trip passes for a cipher that does nothing.
|
||||
*
|
||||
* A plain TestCase and no container: this class takes no services, and booting
|
||||
* a kernel to derive an Argon2id key would only make the suite slower.
|
||||
*/
|
||||
final class ConfigBackupCipherTest extends TestCase
|
||||
{
|
||||
private const string PASSWORD = 'correct horse battery staple';
|
||||
|
||||
private ConfigBackupCipher $cipher;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->cipher = new ConfigBackupCipher();
|
||||
}
|
||||
|
||||
public function testTheDocumentComesBackExactlyAsItWentIn(): void
|
||||
{
|
||||
$document = [
|
||||
'format' => ConfigBackupCipher::FORMAT,
|
||||
'version' => 1,
|
||||
'env' => ['APP_SECRET' => 'a-secret', 'MAILER_DSN' => 'smtp://user:pa$$@host:587'],
|
||||
'files' => ['jwt/private.pem' => base64_encode("-----BEGIN PRIVATE KEY-----\n")],
|
||||
// Nested, because the database section is two levels deep and a
|
||||
// shallow copy would pass a flat fixture.
|
||||
'database' => ['mailProviders' => ['google' => ['clientSecret' => 'GOCSPX-x', 'settings' => ['tenant' => null]]]],
|
||||
];
|
||||
|
||||
self::assertSame($document, $this->cipher->open($this->cipher->seal($document, self::PASSWORD), self::PASSWORD));
|
||||
}
|
||||
|
||||
/**
|
||||
* The property a round-trip test cannot see: that the file is actually
|
||||
* encrypted rather than base64 with extra steps.
|
||||
*/
|
||||
public function testNoValueFromTheDocumentAppearsInTheSealedFile(): void
|
||||
{
|
||||
$sealed = $this->cipher->seal(['env' => ['APP_SECRET' => 'sentinel-value-9f3a']], self::PASSWORD);
|
||||
|
||||
self::assertStringNotContainsString('sentinel-value-9f3a', $sealed);
|
||||
self::assertStringNotContainsString(base64_encode('sentinel-value-9f3a'), $sealed);
|
||||
self::assertStringNotContainsString('APP_SECRET', $sealed);
|
||||
}
|
||||
|
||||
public function testTheSameDocumentSealedTwiceProducesDifferentFiles(): void
|
||||
{
|
||||
$document = ['env' => ['APP_SECRET' => 'a-secret']];
|
||||
|
||||
// A fresh salt and nonce per seal. Without it, two backups of one
|
||||
// install are byte-identical, which leaks that nothing changed and
|
||||
// reuses a nonce under the same derived key — the one mistake
|
||||
// XSalsa20-Poly1305 does not survive.
|
||||
self::assertNotSame(
|
||||
$this->cipher->seal($document, self::PASSWORD),
|
||||
$this->cipher->seal($document, self::PASSWORD),
|
||||
);
|
||||
}
|
||||
|
||||
public function testAWrongPasswordIsRefusedRatherThanReturningRubbish(): void
|
||||
{
|
||||
$sealed = $this->cipher->seal(['env' => ['APP_SECRET' => 'a-secret']], self::PASSWORD);
|
||||
|
||||
$this->expectException(ConfigBackupException::class);
|
||||
|
||||
try {
|
||||
$this->cipher->open($sealed, self::PASSWORD . '!');
|
||||
} catch (ConfigBackupException $e) {
|
||||
self::assertSame(ConfigBackupFailure::WrongPassword, $e->failure);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A flipped byte in the ciphertext has to fail the same way a wrong
|
||||
* password does. Poly1305 is what makes that true, and a version of this
|
||||
* class that used raw XSalsa20 would pass every other test here.
|
||||
*/
|
||||
public function testATamperedCiphertextIsRefused(): void
|
||||
{
|
||||
$sealed = $this->cipher->seal(['env' => ['APP_SECRET' => 'a-secret']], self::PASSWORD);
|
||||
|
||||
/** @var array{ciphertext: string} $header */
|
||||
$header = json_decode($sealed, true, 512, JSON_THROW_ON_ERROR);
|
||||
$raw = (string) base64_decode($header['ciphertext'], true);
|
||||
|
||||
$raw[10] = $raw[10] === "\x00" ? "\x01" : "\x00";
|
||||
$header['ciphertext'] = base64_encode($raw);
|
||||
|
||||
$this->expectExceptionObject(ConfigBackupException::wrongPassword());
|
||||
|
||||
$this->cipher->open(json_encode($header, JSON_THROW_ON_ERROR), self::PASSWORD);
|
||||
}
|
||||
|
||||
/**
|
||||
* The header is public and therefore attacker-controlled. Each of these is
|
||||
* a way to get sodium to do something on behalf of an unauthenticated blob,
|
||||
* and each has to be refused before the KDF is asked to run.
|
||||
*
|
||||
* @return iterable<string, array{array<string, mixed>, ConfigBackupFailure}>
|
||||
*/
|
||||
public static function badEnvelopes(): iterable
|
||||
{
|
||||
$valid = [
|
||||
'format' => ConfigBackupCipher::FORMAT,
|
||||
'version' => 1,
|
||||
'kdf' => ['name' => 'argon2id', 'opslimit' => 3, 'memlimit' => 67108864, 'salt' => base64_encode(str_repeat('s', 16))],
|
||||
'cipher' => ['name' => 'xsalsa20poly1305', 'nonce' => base64_encode(str_repeat('n', 24))],
|
||||
'ciphertext' => base64_encode('not really a ciphertext'),
|
||||
];
|
||||
|
||||
yield 'someone else\'s file' => [['format' => 'borg-repo', 'version' => 1], ConfigBackupFailure::NotABackup];
|
||||
|
||||
yield 'no version' => [['format' => ConfigBackupCipher::FORMAT], ConfigBackupFailure::NotABackup];
|
||||
|
||||
yield 'from a newer plMail' => [
|
||||
['format' => ConfigBackupCipher::FORMAT, 'version' => ConfigBackupCipher::VERSION + 1],
|
||||
ConfigBackupFailure::UnsupportedVersion,
|
||||
];
|
||||
|
||||
yield 'a KDF we do not have' => [[...$valid, 'kdf' => [...$valid['kdf'], 'name' => 'pbkdf2']], ConfigBackupFailure::NotABackup];
|
||||
|
||||
// The lever: memlimit is an allocation this host performs on request.
|
||||
yield 'a memlimit that would exhaust the host' => [
|
||||
[...$valid, 'kdf' => [...$valid['kdf'], 'memlimit' => 8589934592]],
|
||||
ConfigBackupFailure::NotABackup,
|
||||
];
|
||||
|
||||
yield 'an opslimit that would never finish' => [
|
||||
[...$valid, 'kdf' => [...$valid['kdf'], 'opslimit' => 4000000]],
|
||||
ConfigBackupFailure::NotABackup,
|
||||
];
|
||||
|
||||
yield 'a truncated salt' => [
|
||||
[...$valid, 'kdf' => [...$valid['kdf'], 'salt' => base64_encode('short')]],
|
||||
ConfigBackupFailure::NotABackup,
|
||||
];
|
||||
|
||||
yield 'a nonce of the wrong size' => [
|
||||
[...$valid, 'cipher' => [...$valid['cipher'], 'nonce' => base64_encode(str_repeat('n', 12))]],
|
||||
ConfigBackupFailure::NotABackup,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $envelope
|
||||
*/
|
||||
#[DataProvider('badEnvelopes')]
|
||||
public function testAnUntrustworthyHeaderIsRefusedBeforeTheKdfRuns(array $envelope, ConfigBackupFailure $expected): void
|
||||
{
|
||||
try {
|
||||
$this->cipher->open(json_encode($envelope, JSON_THROW_ON_ERROR), self::PASSWORD);
|
||||
|
||||
self::fail('the envelope was accepted');
|
||||
} catch (ConfigBackupException $e) {
|
||||
self::assertSame($expected, $e->failure);
|
||||
}
|
||||
}
|
||||
|
||||
public function testSomethingThatIsNotJsonAtAllIsNotABackup(): void
|
||||
{
|
||||
try {
|
||||
$this->cipher->open("\x7fELF\x02\x01\x01", self::PASSWORD);
|
||||
|
||||
self::fail('a binary blob was accepted');
|
||||
} catch (ConfigBackupException $e) {
|
||||
self::assertSame(ConfigBackupFailure::NotABackup, $e->failure);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The parameters in the file are what is used, not the constants — which is
|
||||
* the promise that lets an old backup keep opening after they are raised.
|
||||
* Sealed here at the cheapest legal setting and opened by the same code
|
||||
* that would otherwise use 64 MiB.
|
||||
*/
|
||||
public function testAFileIsOpenedWithTheParametersItStatesRatherThanTheCurrentOnes(): void
|
||||
{
|
||||
$salt = random_bytes(SODIUM_CRYPTO_PWHASH_SALTBYTES);
|
||||
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
|
||||
$opslimit = 1;
|
||||
// libsodium's floor for argon2id, spelled out because PHP exposes no
|
||||
// constant for it — and deliberately nowhere near what seal() uses, so
|
||||
// a version of open() that ignored the header would derive a different
|
||||
// key and this test would fail rather than pass by coincidence.
|
||||
$memlimit = 8192;
|
||||
$key = sodium_crypto_pwhash(
|
||||
SODIUM_CRYPTO_SECRETBOX_KEYBYTES,
|
||||
self::PASSWORD,
|
||||
$salt,
|
||||
$opslimit,
|
||||
$memlimit,
|
||||
SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13,
|
||||
);
|
||||
|
||||
$envelope = [
|
||||
'format' => ConfigBackupCipher::FORMAT,
|
||||
'version' => 1,
|
||||
'kdf' => ['name' => 'argon2id', 'opslimit' => $opslimit, 'memlimit' => $memlimit, 'salt' => base64_encode($salt)],
|
||||
'cipher' => ['name' => 'xsalsa20poly1305', 'nonce' => base64_encode($nonce)],
|
||||
'ciphertext' => base64_encode(sodium_crypto_secretbox('{"format":"plmail-config-backup","version":1}', $nonce, $key)),
|
||||
];
|
||||
|
||||
self::assertSame(
|
||||
['format' => 'plmail-config-backup', 'version' => 1],
|
||||
$this->cipher->open(json_encode($envelope, JSON_THROW_ON_ERROR), self::PASSWORD),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service\Backup;
|
||||
|
||||
use App\Domain\Enum\Account\MailProvider;
|
||||
use App\Entity\Integration\MailProviderConfig;
|
||||
use App\Entity\Push\FcmConfig;
|
||||
use App\Infrastructure\Backup\ConfigBackupCipher;
|
||||
use App\Service\Backup\ConfigBackupDatabase;
|
||||
use App\Service\Backup\ConfigBackupEnvironment;
|
||||
use App\Service\Backup\ConfigBackupExporter;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
|
||||
/**
|
||||
* The export carries the whole inventory, and carries the database half in the
|
||||
* clear.
|
||||
*
|
||||
* Both halves of that claim are the feature. A backup that quietly omits a
|
||||
* variable is discovered on the day somebody rebuilds an install from it and
|
||||
* push silently stops working; and a backup that carries `enc:v1:…` ciphertext
|
||||
* is a file only the machine that wrote it can use, which is every machine
|
||||
* except the one it will be restored onto.
|
||||
*
|
||||
* Against the real container and the real database, because the value under
|
||||
* test is the one the Doctrine type produced — a mock of the repository would
|
||||
* assert that this class copies a string, which is not the thing that can go
|
||||
* wrong.
|
||||
*
|
||||
* The seeded rows are made and rolled back inside one transaction, so the
|
||||
* suite's shared database is left as it was found.
|
||||
*/
|
||||
final class ConfigBackupExporterTest extends KernelTestCase
|
||||
{
|
||||
private const string SERVICE_ACCOUNT = '{"type":"service_account","project_id":"plmail-export-test",'
|
||||
. '"client_email":"pusher@plmail-export-test.iam.gserviceaccount.com",'
|
||||
. '"private_key":"-----BEGIN PRIVATE KEY-----\nMIIB\n-----END PRIVATE KEY-----\n"}';
|
||||
|
||||
private Connection $connection;
|
||||
|
||||
private EntityManagerInterface $entityManager;
|
||||
|
||||
private ConfigBackupExporter $exporter;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
|
||||
$container = static::getContainer();
|
||||
$this->entityManager = $container->get(EntityManagerInterface::class);
|
||||
$this->exporter = $container->get(ConfigBackupExporter::class);
|
||||
$this->connection = $this->entityManager->getConnection();
|
||||
|
||||
// Never committed, so the suite leaves nothing behind and can be run
|
||||
// repeatedly against the same database.
|
||||
$this->connection->beginTransaction();
|
||||
$this->connection->executeStatement('TRUNCATE TABLE fcm_config CASCADE');
|
||||
$this->connection->executeStatement('TRUNCATE TABLE mail_provider_config CASCADE');
|
||||
$this->entityManager->clear();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (true === $this->connection->isTransactionActive()) {
|
||||
$this->connection->rollBack();
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testTheDocumentNamesItselfAndSaysWhenItWasMade(): void
|
||||
{
|
||||
$document = $this->exporter->document();
|
||||
|
||||
self::assertSame(ConfigBackupCipher::FORMAT, $document['format']);
|
||||
self::assertSame(ConfigBackupExporter::DOCUMENT_VERSION, $document['version']);
|
||||
self::assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2}T/', (string) $document['exportedAt']);
|
||||
self::assertArrayHasKey('instance', $document);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every variable in the inventory that this instance has a value for is in
|
||||
* the file, and nothing outside the inventory has crept in.
|
||||
*
|
||||
* The second half is the one worth asserting: the tempting implementation
|
||||
* sweeps $_SERVER, which on a container is a hundred entries deep and
|
||||
* includes the shell's PATH, HOSTNAME and whatever the orchestrator
|
||||
* injected.
|
||||
*/
|
||||
public function testTheEnvSectionIsExactlyTheInventoryMinusWhatIsUnset(): void
|
||||
{
|
||||
$inventory = static::getContainer()->get(ConfigBackupEnvironment::class);
|
||||
$document = $this->exporter->document();
|
||||
|
||||
/** @var array<string, string> $env */
|
||||
$env = $document['env'];
|
||||
|
||||
self::assertSame([], array_diff(array_keys($env), $inventory->variables()), 'nothing outside the inventory');
|
||||
|
||||
// The test stack sets these three; if the harness ever stops, this
|
||||
// assertion is the thing that says so rather than a silently empty file.
|
||||
foreach (['APP_SECRET', 'APP_ENCRYPTION_KEY', 'DATABASE_URL'] as $expected) {
|
||||
self::assertArrayHasKey($expected, $env, $expected . ' is set in this environment and belongs in the file');
|
||||
}
|
||||
|
||||
foreach ($env as $name => $value) {
|
||||
self::assertNotSame('', $value, $name . ' was exported as an empty string rather than omitted');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The claim the whole envelope design rests on: what comes out of the
|
||||
* database is the credential, not the ciphertext the column holds.
|
||||
*/
|
||||
public function testDatabaseCredentialsLeaveDecrypted(): void
|
||||
{
|
||||
$this->seedFcmConfig();
|
||||
$this->seedMailProvider();
|
||||
|
||||
/** @var array{fcmConfig: array<string, mixed>, mailProviders: array<string, array<string, mixed>>} $database */
|
||||
$database = $this->exporter->document()['database'];
|
||||
|
||||
self::assertSame(self::SERVICE_ACCOUNT, $database[ConfigBackupDatabase::FCM_CONFIG]['serviceAccountJson']);
|
||||
self::assertSame('GOCSPX-a-client-secret', $database[ConfigBackupDatabase::MAIL_PROVIDERS]['google']['clientSecret']);
|
||||
self::assertSame('a-pubsub-token', $database[ConfigBackupDatabase::MAIL_PROVIDERS]['google']['pushVerificationToken']);
|
||||
|
||||
// And the column really was encrypted, so the line above is a
|
||||
// decryption rather than a column that never protected anything.
|
||||
$stored = (string) $this->connection->fetchOne('SELECT service_account_json FROM fcm_config LIMIT 1');
|
||||
self::assertStringStartsWith('enc:v1:', $stored);
|
||||
}
|
||||
|
||||
public function testAProviderWithNoRowIsAbsentRatherThanNull(): void
|
||||
{
|
||||
$this->seedMailProvider();
|
||||
|
||||
/** @var array{mailProviders: array<string, mixed>} $database */
|
||||
$database = $this->exporter->document()['database'];
|
||||
|
||||
self::assertArrayHasKey('google', $database[ConfigBackupDatabase::MAIL_PROVIDERS]);
|
||||
self::assertArrayNotHasKey('microsoft', $database[ConfigBackupDatabase::MAIL_PROVIDERS]);
|
||||
}
|
||||
|
||||
public function testTheSealedFileIsOpenableAndTheFilenameIsDated(): void
|
||||
{
|
||||
$sealed = $this->exporter->export('a long enough password');
|
||||
|
||||
self::assertSame(
|
||||
ConfigBackupCipher::FORMAT,
|
||||
static::getContainer()->get(ConfigBackupCipher::class)->open($sealed, 'a long enough password')['format'],
|
||||
);
|
||||
|
||||
self::assertMatchesRegularExpression('/^plmail-config-\d{4}-\d{2}-\d{2}\.backup$/', $this->exporter->filename());
|
||||
}
|
||||
|
||||
// ── Seeding ───────────────────────────────────────────────────────────────
|
||||
|
||||
private function seedFcmConfig(): void
|
||||
{
|
||||
$config = new FcmConfig();
|
||||
$config->restore(self::SERVICE_ACCOUNT, 'plmail-export-test', '1:1:android:abc', 'AIza-key', '1', 'test.plmail', true);
|
||||
|
||||
$this->entityManager->persist($config);
|
||||
$this->entityManager->flush();
|
||||
$this->entityManager->clear();
|
||||
}
|
||||
|
||||
private function seedMailProvider(): void
|
||||
{
|
||||
$config = new MailProviderConfig(MailProvider::Google);
|
||||
$config->clientId = 'a-client-id.apps.googleusercontent.com';
|
||||
$config->clientSecret = 'GOCSPX-a-client-secret';
|
||||
$config->pushVerificationToken = 'a-pubsub-token';
|
||||
$config->pubsubTopic = 'projects/plmail/topics/gmail-push';
|
||||
|
||||
$this->entityManager->persist($config);
|
||||
$this->entityManager->flush();
|
||||
$this->entityManager->clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service\Backup;
|
||||
|
||||
use App\Domain\DTO\Backup\ConfigBackupPlan;
|
||||
use App\Domain\DTO\Backup\ConfigBackupPlanItem;
|
||||
use App\Domain\Enum\Account\MailProvider;
|
||||
use App\Domain\Enum\Backup\ConfigBackupChange;
|
||||
use App\Domain\Enum\Backup\ConfigBackupFailure;
|
||||
use App\Domain\Enum\Backup\ConfigBackupObstacle;
|
||||
use App\Domain\Enum\Backup\ConfigBackupSection;
|
||||
use App\Domain\Enum\Integration\Provider;
|
||||
use App\Domain\Exception\ConfigBackupException;
|
||||
use App\Entity\Push\FcmConfig;
|
||||
use App\Infrastructure\Backup\ConfigBackupCipher;
|
||||
use App\Infrastructure\Doctrine\Type\EncryptedStringType;
|
||||
use App\Infrastructure\Encryption\Encryptor;
|
||||
use App\Repository\Integration\IntegrationProviderConfigRepository;
|
||||
use App\Repository\Integration\MailProviderConfigRepository;
|
||||
use App\Repository\Push\FcmConfigRepository;
|
||||
use App\Service\Backup\ConfigBackupDatabase;
|
||||
use App\Service\Backup\ConfigBackupExporter;
|
||||
use App\Service\Backup\ConfigBackupFiles;
|
||||
use App\Service\Backup\ConfigBackupImporter;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
|
||||
/**
|
||||
* An import applies what is genuinely plMail's to write, says so honestly about
|
||||
* everything else, and re-encrypts what it writes under the key in force here.
|
||||
*
|
||||
* The last part is the reason this feature exists and is the only claim here
|
||||
* that cannot be checked by reading the code: the exporting install and the
|
||||
* importing one have different APP_ENCRYPTION_KEYs by construction — a fresh
|
||||
* install mints its own at first start — so a restore that moved ciphertext
|
||||
* would produce rows that decrypt to nothing on the first read. The test swaps
|
||||
* the Encryptor between export and import through the same static seam
|
||||
* Kernel::boot uses, which is as close to "a different machine" as one process
|
||||
* gets, and then proves the stored bytes are unreadable with the OLD key. That
|
||||
* last assertion is what makes this a test rather than a round trip: a version
|
||||
* of the importer that wrote the ciphertext straight through would satisfy
|
||||
* everything above it.
|
||||
*
|
||||
* The classification half is asserted per section rather than by counting.
|
||||
* "Sixteen automatic, four manual" passes for an importer that put
|
||||
* APP_ENCRYPTION_KEY in the wrong list, and that mistake is the one with the
|
||||
* worst consequence in the whole feature.
|
||||
*/
|
||||
final class ConfigBackupImporterTest extends KernelTestCase
|
||||
{
|
||||
private const string SERVICE_ACCOUNT = '{"type":"service_account","project_id":"plmail-import-test",'
|
||||
. '"client_email":"pusher@plmail-import-test.iam.gserviceaccount.com",'
|
||||
. '"private_key":"-----BEGIN PRIVATE KEY-----\nMIIB\n-----END PRIVATE KEY-----\n"}';
|
||||
|
||||
/** A second install's key: 32 bytes, base64, and not the one the test stack runs on. */
|
||||
private const string OTHER_KEY = 'YW5vdGhlciBpbnN0YWxsYXRpb25zIGtleSwgMzIgYnk=';
|
||||
|
||||
private Connection $connection;
|
||||
|
||||
private EntityManagerInterface $entityManager;
|
||||
|
||||
private ConfigBackupImporter $importer;
|
||||
|
||||
private ConfigBackupExporter $exporter;
|
||||
|
||||
private Encryptor $originalEncryptor;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
|
||||
$container = static::getContainer();
|
||||
$this->entityManager = $container->get(EntityManagerInterface::class);
|
||||
$this->importer = $container->get(ConfigBackupImporter::class);
|
||||
$this->exporter = $container->get(ConfigBackupExporter::class);
|
||||
$this->originalEncryptor = $container->get(Encryptor::class);
|
||||
$this->connection = $this->entityManager->getConnection();
|
||||
|
||||
$this->connection->beginTransaction();
|
||||
|
||||
foreach (['fcm_config', 'mail_provider_config', 'integration_provider_config'] as $table) {
|
||||
$this->connection->executeStatement(sprintf('TRUNCATE TABLE %s CASCADE', $table));
|
||||
}
|
||||
|
||||
$this->entityManager->clear();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
// Before the rollback: a test that swapped the key must not leave the
|
||||
// static type holding it, or every suite that runs after this one in
|
||||
// the same process reads the database with the wrong one.
|
||||
EncryptedStringType::setEncryptor($this->originalEncryptor);
|
||||
|
||||
if (true === $this->connection->isTransactionActive()) {
|
||||
$this->connection->rollBack();
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Export here, throw the key away, import as a different installation
|
||||
* would, and read the credential back.
|
||||
*/
|
||||
public function testFirebaseCredentialsSurviveAnImportIntoAStoreWithADifferentKey(): void
|
||||
{
|
||||
$this->seedFcmConfig();
|
||||
|
||||
$document = $this->exporter->document();
|
||||
|
||||
// Everything past this line is "the other install": a different key,
|
||||
// and nothing configured.
|
||||
$this->becomeADifferentInstallation();
|
||||
|
||||
$plan = $this->importer->apply($document);
|
||||
$this->entityManager->flush();
|
||||
$this->entityManager->clear();
|
||||
|
||||
$restored = static::getContainer()->get(FcmConfigRepository::class)->current();
|
||||
|
||||
self::assertNotNull($restored, 'the row was not created');
|
||||
self::assertSame(self::SERVICE_ACCOUNT, $restored->serviceAccountJson, 'the key did not survive the key change');
|
||||
self::assertSame('plmail-import-test', $restored->projectId);
|
||||
self::assertSame('pusher@plmail-import-test.iam.gserviceaccount.com', $restored->clientEmail);
|
||||
self::assertTrue($restored->isActive(), 'a restored install must come back live, not half-configured');
|
||||
|
||||
self::assertSame(
|
||||
ConfigBackupChange::Absent,
|
||||
$this->itemFor($plan, ConfigBackupSection::Database, ConfigBackupDatabase::FCM_CONFIG)->change,
|
||||
);
|
||||
|
||||
// The proof that it was re-encrypted rather than copied: the bytes in
|
||||
// the column are unreadable to the install that made the backup.
|
||||
$stored = (string) $this->connection->fetchOne('SELECT service_account_json FROM fcm_config LIMIT 1');
|
||||
|
||||
self::assertStringStartsWith('enc:v1:', $stored);
|
||||
self::expectExceptionMessageMatches('/APP_ENCRYPTION_KEY has changed/');
|
||||
$this->originalEncryptor->decrypt($stored);
|
||||
}
|
||||
|
||||
/**
|
||||
* The same for the two provider tables, since they carry the credentials an
|
||||
* install is most likely to be restored FOR.
|
||||
*/
|
||||
public function testProviderRegistrationsSurviveTheSameJourney(): void
|
||||
{
|
||||
$document = [
|
||||
'format' => 'plmail-config-backup',
|
||||
'version' => 1,
|
||||
'database' => [
|
||||
ConfigBackupDatabase::MAIL_PROVIDERS => [
|
||||
'google' => [
|
||||
'clientId' => 'an-id.apps.googleusercontent.com',
|
||||
'clientSecret' => 'GOCSPX-secret',
|
||||
'pushVerificationToken' => 'a-pubsub-token',
|
||||
'settings' => ['pubsub.topic' => 'projects/x/topics/y'],
|
||||
],
|
||||
],
|
||||
ConfigBackupDatabase::INTEGRATION_PROVIDERS => [
|
||||
'nextcloud' => [
|
||||
'isEnabled' => true,
|
||||
'baseUrl' => 'https://cloud.example.test',
|
||||
'clientId' => null,
|
||||
'clientSecret' => null,
|
||||
'settings' => [],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$this->becomeADifferentInstallation();
|
||||
|
||||
$this->importer->apply($document);
|
||||
$this->entityManager->flush();
|
||||
$this->entityManager->clear();
|
||||
|
||||
$google = static::getContainer()->get(MailProviderConfigRepository::class)->findOneByProvider(MailProvider::Google);
|
||||
|
||||
self::assertNotNull($google);
|
||||
self::assertSame('GOCSPX-secret', $google->clientSecret);
|
||||
self::assertSame('a-pubsub-token', $google->pushVerificationToken);
|
||||
self::assertSame('projects/x/topics/y', $google->pubsubTopic);
|
||||
|
||||
$nextcloud = static::getContainer()->get(IntegrationProviderConfigRepository::class)->findOneByProvider(Provider::Nextcloud);
|
||||
|
||||
self::assertNotNull($nextcloud);
|
||||
self::assertTrue($nextcloud->isEnabled);
|
||||
self::assertSame('https://cloud.example.test', $nextcloud->baseUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every environment value is manual, and the two whose mishandling is worst
|
||||
* carry their own reason rather than the generic one.
|
||||
*/
|
||||
public function testEveryEnvironmentValueIsManualAndCarriesTheRightReason(): void
|
||||
{
|
||||
$plan = $this->importer->plan([
|
||||
'env' => [
|
||||
'APP_ENCRYPTION_KEY' => 'c29tZS1vdGhlci1rZXktdGhpcnR5LXR3by1ieXRlcw==',
|
||||
'APP_SECRET' => 'deadbeef',
|
||||
'POSTGRES_PASSWORD' => 'hunter2',
|
||||
'VAPID_PRIVATE_KEY' => 'a-vapid-private-key',
|
||||
],
|
||||
]);
|
||||
|
||||
foreach ($plan->items as $item) {
|
||||
self::assertFalse($item->isAutomatic(), $item->key . ' must never be written by an import');
|
||||
}
|
||||
|
||||
self::assertSame(
|
||||
ConfigBackupObstacle::EncryptionKeyInUse,
|
||||
$this->itemFor($plan, ConfigBackupSection::Environment, 'APP_ENCRYPTION_KEY')->obstacle,
|
||||
);
|
||||
self::assertSame(
|
||||
ConfigBackupObstacle::ExternalSystem,
|
||||
$this->itemFor($plan, ConfigBackupSection::Environment, 'POSTGRES_PASSWORD')->obstacle,
|
||||
);
|
||||
self::assertSame(
|
||||
ConfigBackupObstacle::ProcessEnvironment,
|
||||
$this->itemFor($plan, ConfigBackupSection::Environment, 'VAPID_PRIVATE_KEY')->obstacle,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The instruction is the deliverable for a manual item, so it has to be the
|
||||
* literal line — quoted when the value would otherwise be mangled by the
|
||||
* dotenv parser, which is the case that actually bites.
|
||||
*/
|
||||
public function testManualInstructionsArePasteableLines(): void
|
||||
{
|
||||
$plan = $this->importer->plan([
|
||||
'env' => [
|
||||
'APP_SECRET' => 'deadbeef',
|
||||
'MAILER_DSN' => 'smtp://user:pa ss#word@host:587',
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertSame('APP_SECRET=deadbeef', $this->itemFor($plan, ConfigBackupSection::Environment, 'APP_SECRET')->instruction);
|
||||
self::assertSame(
|
||||
'MAILER_DSN="smtp://user:pa ss#word@host:587"',
|
||||
$this->itemFor($plan, ConfigBackupSection::Environment, 'MAILER_DSN')->instruction,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The postgres password file is the case that separates "can we write it"
|
||||
* from "should we": a writable secrets volume must not make it automatic,
|
||||
* because the other half of the change is a role inside a database.
|
||||
*/
|
||||
public function testThePostgresPasswordFileStaysManualEvenWhereItIsWritable(): void
|
||||
{
|
||||
$files = static::getContainer()->get(ConfigBackupFiles::class);
|
||||
|
||||
$plan = $this->importer->plan([
|
||||
'files' => [ConfigBackupFiles::POSTGRES_PASSWORD => base64_encode('a-password')],
|
||||
]);
|
||||
|
||||
$item = $this->itemFor($plan, ConfigBackupSection::SecretsFile, ConfigBackupFiles::POSTGRES_PASSWORD);
|
||||
|
||||
self::assertSame(ConfigBackupObstacle::ExternalSystem, $item->obstacle);
|
||||
self::assertSame($files->pathFor(ConfigBackupFiles::POSTGRES_PASSWORD), $item->instruction);
|
||||
}
|
||||
|
||||
/**
|
||||
* A file is automatic exactly when this process can write it — measured,
|
||||
* not assumed. Both directions, so an implementation that hard-coded either
|
||||
* answer fails one of them.
|
||||
*/
|
||||
public function testAFileIsAutomaticOnlyWhereThisProcessCanWriteIt(): void
|
||||
{
|
||||
$files = static::getContainer()->get(ConfigBackupFiles::class);
|
||||
|
||||
self::assertTrue(
|
||||
$files->isWritable(ConfigBackupFiles::JWT_PRIVATE),
|
||||
'the fixture is only meaningful where the secrets directory can be written',
|
||||
);
|
||||
|
||||
$plan = $this->importer->plan([
|
||||
'files' => [ConfigBackupFiles::JWT_PRIVATE => base64_encode("-----BEGIN PRIVATE KEY-----\n")],
|
||||
]);
|
||||
|
||||
self::assertTrue($this->itemFor($plan, ConfigBackupSection::SecretsFile, ConfigBackupFiles::JWT_PRIVATE)->isAutomatic());
|
||||
|
||||
// And the other direction, which this stack cannot produce but every
|
||||
// read-only secrets mount does: the same class, pointed under a
|
||||
// directory nothing can create a file in, must say so rather than
|
||||
// promise a write it would fail at.
|
||||
//
|
||||
// /sys, because it is read-only even to uid 0 — which this container
|
||||
// is, so a chmod-ed temporary directory would prove nothing.
|
||||
$readOnly = new ConfigBackupFiles('/sys/plmail-nowhere/jwt/private.pem', '/sys/plmail-nowhere/jwt/public.pem', '/sys/plmail-nowhere/generated.env');
|
||||
|
||||
self::assertFalse($readOnly->isWritable(ConfigBackupFiles::JWT_PRIVATE));
|
||||
self::assertSame(ConfigBackupObstacle::NotWritable, $readOnly->obstacleFor(ConfigBackupFiles::JWT_PRIVATE));
|
||||
}
|
||||
|
||||
/**
|
||||
* A restore onto a configured install has to say which values it is
|
||||
* replacing. Showing "will be applied" without that distinction is how an
|
||||
* admin overwrites a live Firebase key believing they are filling a gap.
|
||||
*/
|
||||
public function testAValueThatAlreadyMatchesIsUnchangedAndOneThatDoesNotDiffers(): void
|
||||
{
|
||||
$this->seedFcmConfig();
|
||||
|
||||
$document = $this->exporter->document();
|
||||
|
||||
self::assertSame(
|
||||
ConfigBackupChange::Unchanged,
|
||||
$this->itemFor($this->importer->plan($document), ConfigBackupSection::Database, ConfigBackupDatabase::FCM_CONFIG)->change,
|
||||
);
|
||||
|
||||
/** @var array{database: array<string, mixed>} $document */
|
||||
$document['database'][ConfigBackupDatabase::FCM_CONFIG]['apiKey'] = 'AIza-a-different-key';
|
||||
|
||||
self::assertSame(
|
||||
ConfigBackupChange::Differs,
|
||||
$this->itemFor($this->importer->plan($document), ConfigBackupSection::Database, ConfigBackupDatabase::FCM_CONFIG)->change,
|
||||
);
|
||||
}
|
||||
|
||||
/** A provider this build has no enum case for is skipped, not fatal. */
|
||||
public function testAProviderFromANewerPlmailIsSkippedRatherThanFailingTheImport(): void
|
||||
{
|
||||
$plan = $this->importer->plan([
|
||||
'database' => [
|
||||
ConfigBackupDatabase::INTEGRATION_PROVIDERS => [
|
||||
'nextcloud' => ['isEnabled' => true],
|
||||
'somethingNewer' => ['isEnabled' => true],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$keys = array_map(static fn (ConfigBackupPlanItem $item): string => $item->key, $plan->items);
|
||||
|
||||
self::assertContains(ConfigBackupDatabase::INTEGRATION_PROVIDERS . '.nextcloud', $keys);
|
||||
self::assertNotContains(ConfigBackupDatabase::INTEGRATION_PROVIDERS . '.somethingNewer', $keys);
|
||||
}
|
||||
|
||||
/**
|
||||
* open() is the only thing standing between a stranger's file and the
|
||||
* planner, so it refuses a document that decrypted successfully but is not
|
||||
* one of ours.
|
||||
*/
|
||||
public function testADecryptedDocumentThatIsNotOursIsRefused(): void
|
||||
{
|
||||
$cipher = static::getContainer()->get(ConfigBackupCipher::class);
|
||||
$sealed = $cipher->seal(['format' => 'something-else', 'version' => 1], 'a long enough password');
|
||||
|
||||
try {
|
||||
$this->importer->open($sealed, 'a long enough password');
|
||||
|
||||
self::fail('a foreign document was accepted');
|
||||
} catch (ConfigBackupException $e) {
|
||||
self::assertSame(ConfigBackupFailure::MalformedDocument, $e->failure);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A wrong password stops at the envelope, so nothing downstream ever sees a
|
||||
* document and nothing is written.
|
||||
*/
|
||||
public function testAWrongPasswordWritesNothing(): void
|
||||
{
|
||||
$this->seedFcmConfig();
|
||||
|
||||
$sealed = $this->exporter->export('the right password entirely');
|
||||
|
||||
$this->connection->executeStatement('TRUNCATE TABLE fcm_config CASCADE');
|
||||
$this->entityManager->clear();
|
||||
|
||||
try {
|
||||
$this->importer->apply($this->importer->open($sealed, 'the wrong password entirely'));
|
||||
|
||||
self::fail('a wrong password was accepted');
|
||||
} catch (ConfigBackupException $e) {
|
||||
self::assertSame(ConfigBackupFailure::WrongPassword, $e->failure);
|
||||
}
|
||||
|
||||
self::assertSame(0, (int) $this->connection->fetchOne('SELECT count(*) FROM fcm_config'));
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Everything a different machine would have: another encryption key, and
|
||||
* none of this one's rows.
|
||||
*
|
||||
* The static seam is the one Kernel::boot() uses — see EncryptedStringType,
|
||||
* which documents why Doctrine leaves no other way in. Swapping it here is
|
||||
* what makes "a store with a different encryption key" a thing one process
|
||||
* can be.
|
||||
*/
|
||||
private function becomeADifferentInstallation(): void
|
||||
{
|
||||
EncryptedStringType::setEncryptor(new Encryptor(self::OTHER_KEY));
|
||||
|
||||
foreach (['fcm_config', 'mail_provider_config', 'integration_provider_config'] as $table) {
|
||||
$this->connection->executeStatement(sprintf('TRUNCATE TABLE %s CASCADE', $table));
|
||||
}
|
||||
|
||||
$this->entityManager->clear();
|
||||
}
|
||||
|
||||
private function seedFcmConfig(): void
|
||||
{
|
||||
$config = new FcmConfig();
|
||||
$config->restore(self::SERVICE_ACCOUNT, 'plmail-import-test', '1:1:android:abc', 'AIza-key', '1', 'test.plmail', true);
|
||||
|
||||
$this->entityManager->persist($config);
|
||||
$this->entityManager->flush();
|
||||
$this->entityManager->clear();
|
||||
}
|
||||
|
||||
private function itemFor(ConfigBackupPlan $plan, ConfigBackupSection $section, string $key): ConfigBackupPlanItem
|
||||
{
|
||||
foreach ($plan->items as $item) {
|
||||
if ($section === $item->section && $key === $item->key) {
|
||||
return $item;
|
||||
}
|
||||
}
|
||||
|
||||
self::fail(sprintf('the plan has no %s item for %s', $section->value, $key));
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,93 @@ admin:
|
||||
messages: 'Nachrichten'
|
||||
threads: 'Konversationen'
|
||||
ago: 'her'
|
||||
config_backup:
|
||||
change:
|
||||
absent: 'hier noch nicht gesetzt'
|
||||
differs: 'ersetzt einen anderen Wert'
|
||||
unchanged: 'bereits identisch'
|
||||
error:
|
||||
malformed-document: >-
|
||||
Das Passwort hat die Datei geöffnet, aber ihr Inhalt ist keine
|
||||
Konfigurationssicherung, die diese plMail-Version lesen kann. Es wurde
|
||||
nichts geändert.
|
||||
not-a-backup: >-
|
||||
Das ist keine plMail-Konfigurationssicherung. Es wurde nichts geändert.
|
||||
unsupported-version: >-
|
||||
Diese Sicherung stammt aus einer neueren plMail-Version und liegt in einem
|
||||
Format vor, das diese Version nicht lesen kann. Aktualisiere zuerst, dann
|
||||
importiere sie.
|
||||
wrong-password: >-
|
||||
Mit diesem Passwort lässt sich die Datei nicht öffnen — oder die Datei
|
||||
wurde seit dem Schreiben verändert. Es wurde nichts geändert.
|
||||
export:
|
||||
error:
|
||||
mismatch: 'Die beiden Passwörter stimmten nicht überein, es wurde nichts exportiert.'
|
||||
too_short: 'Dieses Passwort ist zu kurz, es wurde nichts exportiert.'
|
||||
heading: 'Konfiguration exportieren'
|
||||
intro: >-
|
||||
Alle Einstellungen und Zugangsdaten, mit denen diese Installation läuft, in
|
||||
einer passwortverschlüsselten Datei: die Umgebungswerte, die Dateien im
|
||||
Secrets-Volume sowie die Anbieter- und Firebase-Zugangsdaten aus der
|
||||
Datenbank. Keine E-Mails, keine Kalender, keine Benutzerkonten — dafür ist
|
||||
`app:backup` da.
|
||||
password: 'Passwort'
|
||||
password_help: >-
|
||||
Mindestens %length% Zeichen. Es wird nirgends gespeichert, und ohne es
|
||||
lässt sich die Datei nicht öffnen.
|
||||
password_repeat: 'Passwort wiederholen'
|
||||
submit: 'Sicherung herunterladen'
|
||||
warning: >-
|
||||
Die entstehende Datei enthält jedes Geheimnis dieser Installation, geschützt
|
||||
durch nichts als das Passwort, das du hier eingibst. Bewahre sie so auf, wie
|
||||
du den Verschlüsselungsschlüssel selbst aufbewahren würdest.
|
||||
import:
|
||||
error:
|
||||
no_file: 'Es kam keine Datei an, oder sie war weit größer, als eine Konfigurationssicherung sein kann.'
|
||||
no_password: 'Gib das Passwort ein, mit dem diese Sicherung versiegelt wurde.'
|
||||
file: 'Sicherungsdatei'
|
||||
heading: 'Konfiguration importieren'
|
||||
intro: >-
|
||||
Lade eine Sicherung hoch und gib ihr Passwort ein. Es wird nichts
|
||||
geschrieben, bevor du gesehen und bestätigt hast, was passieren würde.
|
||||
password: 'Passwort'
|
||||
submit: 'Sicherung prüfen'
|
||||
obstacle:
|
||||
encryption-key-in-use: >-
|
||||
Diesen Wert zu schreiben würde jede bereits hier gespeicherte Zugangsdatei —
|
||||
auch die, die dieser Import gerade geschrieben hat — beim nächsten Start
|
||||
unlesbar machen. Setze ihn nur auf einer Installation, in der noch nichts
|
||||
gespeichert ist.
|
||||
external-system: >-
|
||||
plMail ist hier Client und nicht Eigentümer. Den Wert hier zu ändern, ohne
|
||||
ihn in der Datenbank selbst zu ändern, ergibt eine Installation, die nicht
|
||||
mehr startet.
|
||||
not-writable: >-
|
||||
Dieser Prozess kann diesen Pfad nicht schreiben. Lege die Datei selbst dort
|
||||
ab — sie steckt in der Sicherung, die du gerade hochgeladen hast.
|
||||
process-environment: >-
|
||||
Ein laufender Prozess kann die Umgebung, mit der er gestartet wurde, nicht
|
||||
ändern. Trage diese Zeile ein und starte den Stack neu.
|
||||
review:
|
||||
apply: 'Schreibbare Teile anwenden'
|
||||
applied: 'Was geschrieben wurde'
|
||||
automatic: 'Das schreibt plMail selbst'
|
||||
automatic_none: 'Aus dieser Sicherung kann plMail nichts selbst schreiben.'
|
||||
confirm_password: 'Passwort, noch einmal'
|
||||
exported_at: 'Exportiert'
|
||||
from: 'Von'
|
||||
heading: 'Sicherung prüfen'
|
||||
manual: 'Das musst du selbst erledigen'
|
||||
manual_lead: >-
|
||||
Nichts davon kann plMail schreiben, und etwas anderes zu behaupten wäre eine
|
||||
Lüge, die ein Betreiber erst an dem Tag bemerkt, an dem es darauf ankommt.
|
||||
Jede Zeile ist genau das, was einzutragen ist, oder genau der Ort, an den
|
||||
die Datei gehört.
|
||||
unknown_instance: 'nicht vermerkt'
|
||||
section:
|
||||
database: 'Datenbank'
|
||||
env: 'Umgebungsvariablen'
|
||||
files: 'Dateien im Secrets-Volume'
|
||||
db:
|
||||
active:
|
||||
empty: 'Zurzeit keine aktiven Abfragen.'
|
||||
@@ -234,6 +321,7 @@ admin:
|
||||
page: 'Seite'
|
||||
refresh: 'Aktualisieren'
|
||||
nav:
|
||||
backup: Sicherung
|
||||
database: Datenbank
|
||||
integrations: Integrationen
|
||||
logs: Protokolle
|
||||
@@ -1270,8 +1358,26 @@ setup:
|
||||
heading: Konto anlegen
|
||||
lead: Das ist das erste Konto auf dieser plMail-Instanz und bekommt daher volle Administratorrechte.
|
||||
page_title: plMail einrichten
|
||||
restore_link: Stelle zuerst eine Konfigurationssicherung wieder her.
|
||||
restore_prompt: Kommst du von einer anderen plMail-Instanz?
|
||||
submit: Konto anlegen und anmelden
|
||||
tagline: Hier hat sich noch niemand angemeldet
|
||||
restore:
|
||||
applied_lead: >-
|
||||
Die Konfiguration steht. Diese Installation hat noch keine Benutzer — lege das
|
||||
Administratorkonto an, um fertig zu werden.
|
||||
back: 'Zurück zum Anlegen des Administrators'
|
||||
continue: 'Weiter — Administrator anlegen'
|
||||
footnote: >-
|
||||
Eine Konfigurationssicherung enthält Einstellungen und Zugangsdaten, niemals
|
||||
E-Mails und niemals Benutzerkonten.
|
||||
heading: 'Konfigurationssicherung wiederherstellen'
|
||||
lead: >-
|
||||
Bring die Einstellungen und Zugangsdaten einer anderen plMail-Instanz hierher,
|
||||
bevor du das erste Konto anlegst. Alles, was diese Installation nicht selbst
|
||||
schreiben kann, wird danach aufgelistet — mit den genauen Zeilen zum Eintragen.
|
||||
page_title: 'Konfiguration wiederherstellen'
|
||||
submit: 'Sicherung öffnen'
|
||||
|
||||
settings:
|
||||
accounts:
|
||||
|
||||
@@ -40,6 +40,89 @@ admin:
|
||||
messages: 'Messages'
|
||||
threads: 'Threads'
|
||||
ago: 'ago'
|
||||
config_backup:
|
||||
change:
|
||||
absent: 'nothing set here yet'
|
||||
differs: 'replaces a different value'
|
||||
unchanged: 'already identical'
|
||||
error:
|
||||
malformed-document: >-
|
||||
The password opened that file, but what is inside is not a configuration
|
||||
backup this plMail can read. Nothing has been changed.
|
||||
not-a-backup: >-
|
||||
That is not a plMail configuration backup. Nothing has been changed.
|
||||
unsupported-version: >-
|
||||
That backup was written by a newer plMail than this one, in a format this
|
||||
version cannot read. Upgrade first, then import it.
|
||||
wrong-password: >-
|
||||
That password does not open this file — or the file has been altered since
|
||||
it was written. Nothing has been changed.
|
||||
export:
|
||||
error:
|
||||
mismatch: 'The two passwords did not match, so nothing was exported.'
|
||||
too_short: 'That password is too short, so nothing was exported.'
|
||||
heading: 'Export configuration'
|
||||
intro: >-
|
||||
Every setting and credential this installation runs on, in one
|
||||
password-encrypted file: the environment values, the files in the secrets
|
||||
volume, and the provider and Firebase credentials from the database. No
|
||||
mail, no calendars and no user accounts — those are what `app:backup` is
|
||||
for.
|
||||
password: 'Password'
|
||||
password_help: >-
|
||||
At least %length% characters. It is never stored anywhere, and there is no
|
||||
way to open the file without it.
|
||||
password_repeat: 'Repeat password'
|
||||
submit: 'Download backup'
|
||||
warning: >-
|
||||
The file that comes out is every secret this installation has, protected by
|
||||
nothing but the password you type here. Keep it the way you would keep the
|
||||
encryption key itself.
|
||||
import:
|
||||
error:
|
||||
no_file: 'No file arrived, or it was far larger than a configuration backup can be.'
|
||||
no_password: 'Enter the password this backup was sealed with.'
|
||||
file: 'Backup file'
|
||||
heading: 'Import configuration'
|
||||
intro: >-
|
||||
Upload a backup and enter its password. Nothing is written until you have
|
||||
seen what it would do and confirmed it.
|
||||
password: 'Password'
|
||||
submit: 'Review this backup'
|
||||
obstacle:
|
||||
encryption-key-in-use: >-
|
||||
Writing this would make every credential already stored here — including
|
||||
the ones this import just wrote — unreadable on the next start. Set it only
|
||||
on an install that has nothing stored yet.
|
||||
external-system: >-
|
||||
plMail is a client of this, not its owner. Changing the value here without
|
||||
changing it in the database itself produces an installation that cannot
|
||||
start.
|
||||
not-writable: >-
|
||||
This process cannot write that path. Put the file there yourself — it is in
|
||||
the backup you just uploaded.
|
||||
process-environment: >-
|
||||
A running process cannot change the environment it was started with. Add
|
||||
this line, then restart the stack.
|
||||
review:
|
||||
apply: 'Apply the writable parts'
|
||||
applied: 'What was written'
|
||||
automatic: 'plMail will write these'
|
||||
automatic_none: 'Nothing in this backup is plMail''s own to write.'
|
||||
confirm_password: 'Password, once more'
|
||||
exported_at: 'Exported'
|
||||
from: 'From'
|
||||
heading: 'Review this backup'
|
||||
manual: 'These are yours to do'
|
||||
manual_lead: >-
|
||||
plMail cannot write any of the following, and saying otherwise would be a
|
||||
lie an operator only discovers on the day it matters. Each line is exactly
|
||||
what to paste, or exactly where to put it.
|
||||
unknown_instance: 'not recorded'
|
||||
section:
|
||||
database: 'Database'
|
||||
env: 'Environment variables'
|
||||
files: 'Files in the secrets volume'
|
||||
db:
|
||||
active:
|
||||
empty: 'No active queries right now.'
|
||||
@@ -234,6 +317,7 @@ admin:
|
||||
page: 'Page'
|
||||
refresh: 'Refresh'
|
||||
nav:
|
||||
backup: Backup
|
||||
database: Database
|
||||
integrations: Integrations
|
||||
logs: Logs
|
||||
@@ -1246,8 +1330,26 @@ setup:
|
||||
heading: Create your account
|
||||
lead: This is the first account on this plMail, so it gets full administrator rights.
|
||||
page_title: Set up plMail
|
||||
restore_link: Restore a configuration backup first.
|
||||
restore_prompt: Moving from another plMail?
|
||||
submit: Create account and sign in
|
||||
tagline: Nobody has signed up yet — let's fix that
|
||||
restore:
|
||||
applied_lead: >-
|
||||
The configuration is in place. This installation still has no users — create
|
||||
the administrator account to finish.
|
||||
back: 'Back to creating the administrator'
|
||||
continue: 'Continue — create the administrator'
|
||||
footnote: >-
|
||||
A configuration backup carries settings and credentials, never mail and never
|
||||
user accounts.
|
||||
heading: 'Restore a configuration backup'
|
||||
lead: >-
|
||||
Bring another plMail's settings and credentials here before you create the
|
||||
first account. Anything this installation cannot write itself is listed
|
||||
afterwards, with the exact lines to add.
|
||||
page_title: 'Restore configuration'
|
||||
submit: 'Open the backup'
|
||||
|
||||
settings:
|
||||
accounts:
|
||||
|
||||
Reference in new issue
Block a user