The restore manifest was consumed as soon as its version was checked,
before store.Open and the Resync; a start that failed in between left a
data directory the next start took for an ordinary one (M1). openData now
runs CheckRestore, store.Open, the Resync, and only then ConsumeRestore.
A truncated archive had nothing to refuse it: the manifest is the first
entry, so a download cut short or a tar stopped by a damaged block was a
restore on half a database. Every archive now ends with data/contents.json,
the size and SHA-256 of each data/ file (deploy files are the operator's to
edit), and CheckRestore compares /data with it before the database is
opened, then renames it contents.checked.json so a start after a failed one
does not compare again unless the manifest changed.
Tests: a start whose Resync fails, then a start that resyncs and consumes;
an archive cut in the database, before the deploy files and in its last
entry (refused, database untouched, no -wal); a damaged key named; the
record's names confined to data/; the domain import of a cut plain export,
a cut and an altered .spde (refused, nothing created). e2e on the test
server: full_backup_restore now also sees contents.json in the archive and
gone after the first start, through the real entrypoint and daemons (all
pass, 762 s); go vet + go test of 24 packages pass there.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owner, 2026-10-11: the private key is needed, and a backup from the
panel and one from the console have to be the same.
The commit that made the panel's backup work skipped a file under
certs/ the panel could not open - on the test server the private key,
which the certificate hook leaves to root and Postfix's group. That gave
an archive with a certificate and no key, and two kinds of archive.
- Params.Unprivileged is gone. Nothing is left out on account of who
makes the archive; the queue is out for everyone (previous commit).
- When the panel cannot read a file under certs/, backup.Check says so
before the download and the panel refuses with what to do on the host:
chgrp 998 certs/privkey.pem && chmod 640. backup.UnreadableCert tells
that case from any other failure.
- 998 is the container's selfpost group, which holds the panel and
Postfix. Its id was whatever groupadd gave; the Dockerfile now fixes
it at the value it has always had, because an operator addresses it
by number from the host.
- Guide: method 1 is the same archive as method 2, with the permission
the key needs and a note for whatever renews the certificate. Help and
the box follow the design commit before this one.
- e2e: the restore scenario first makes the key unreadable and expects
the refusal naming the file and the way out, then takes the backup and
expects the key in it; nothing is put back by hand before the restart.
go vet ./internal/backup/ ./internal/web/handlers/: ok
go test ./internal/backup/ ./cmd/... ./internal/web/...: ok
make e2e on the test server: ok
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After the rate-limit checks the run takes a full backup through the
panel, reads the archive through while the container can still say what
went wrong, then removes data/, certs/, docker-compose.yml and .env,
unpacks the archive into the project directory, puts the private key
back (the panel's archive comes without it and without the queue, both
asserted), and starts the stand again.
What has to hold: manifest.json is consumed by the first start, the
administrator signs in, the domain shows the same DKIM value, and a
message sent with the application's old password reaches the sink and
verifies against the record published before the backup.
The port-25 scenarios then run on the restored installation.
make e2e on the test server: ok.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>