backup: the panel's archive is the command's, private key included

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>
This commit is contained in:
mix
2026-10-11 20:43:10 +03:00
co-authored by Claude Opus 5.5
parent 75ebbf6a57
commit dd0fefdcb7
10 changed files with 138 additions and 93 deletions
+19 -10
View File
@@ -70,14 +70,11 @@ func (h *Handlers) HandleBackup(w http.ResponseWriter, r *http.Request) {
h.renderBackupPageWith(w, r, http.StatusBadRequest, "", pwErr)
return
}
// The panel is not root: its archive leaves out the Postfix queue and a
// certificate file it may not open (backup.Params.Unprivileged).
params := backup.Params{
DataDir: h.cfg.DataDir,
DBPath: h.cfg.DBPath,
Version: h.cfg.Version,
DeployRoot: h.cfg.DeployRoot,
Unprivileged: true,
DataDir: h.cfg.DataDir,
DBPath: h.cfg.DBPath,
Version: h.cfg.Version,
DeployRoot: h.cfg.DeployRoot,
OnWarn: func(msg string) {
logf("panel: full backup: %s", msg)
},
@@ -90,11 +87,11 @@ func (h *Handlers) HandleBackup(w http.ResponseWriter, r *http.Request) {
// Everything is read through once before the first byte goes out. Past the
// headers a failure can only cut the download short - which is what the
// button did from 1.3.0 to 2.0.0, with a 200 and ten bytes, because the
// queue was not the panel's to read.
// queue was not the panel's to read. Nothing is skipped to make the archive
// possible: it is the one selfpost-backup makes, or it is refused.
if err := backup.Check(params); err != nil {
logf("panel: full backup: %v", err)
h.renderBackupPageWith(w, r, http.StatusInternalServerError, "",
"Could not read everything a backup holds. Nothing was downloaded; the reason is in the panel's log.")
h.renderBackupPageWith(w, r, http.StatusInternalServerError, "", unreadableBackupErr(err, h.cfg.DeployRoot))
return
}
@@ -333,6 +330,18 @@ func decryptErrorMessage(err error) string {
}
}
// unreadableBackupErr phrases what backup.Check found. The usual case is the
// private key: the panel runs as its own user, and a key readable by root
// alone is not its to read. The way out is on the host, so the message says
// what to do there (guide § Taking a full backup).
func unreadableBackupErr(err error, deployRoot string) string {
if name, ok := backup.UnreadableCert(err, deployRoot); ok {
return "The panel is not allowed to read " + name + ", and a backup without it would not bring the server back. " +
"On the host, give the file to group 998 and make it group-readable (chgrp 998, chmod 640), then try again. Nothing was downloaded."
}
return "Could not read everything a backup holds. Nothing was downloaded; the reason is in the panel's log."
}
// deployBackupErr phrases a pre-flight backup failure for the operator.
func deployBackupErr(err error) string {
if strings.Contains(err.Error(), "DeployRoot") || strings.Contains(err.Error(), "deploy root") {