Skip to content

feat(ops): backup and restore the whole installation - #88

Merged
malickyeu merged 1 commit into
mainfrom
feat/backup-restore
Jul 30, 2026
Merged

feat(ops): backup and restore the whole installation#88
malickyeu merged 1 commit into
mainfrom
feat/backup-restore

Conversation

@malickyeu

Copy link
Copy Markdown
Contributor

Summary

You were right that this was missing — there was no way to snapshot or rebuild an
installation, and the docs only said "back up DC_DATA_DIR".

dockercmd --backup /var/backups/dc.tar.gz [--passphrase]
dockercmd --restore /var/backups/dc.tar.gz [--passphrase] [--force]

A backup contains the database plus projects/ and project-templates/ — which is
everything, because both secret keys are rows inside that database. So an
archive restores onto a fresh machine and simply works: original password, roles,
projects, and decryptable secrets.

Type of change

  • Bug fix
  • New feature
  • Docs only
  • Refactor / chore

Checklist

  • go test -short ./... and go vet ./... pass
  • gofmt gate is clean (gofmt -l $(git ls-files '*.go') after staging)
  • Frontend type-checks — N/A (no UI; see "Deliberately CLI-only")
  • Rebuilt and committed web/dist — N/A (nothing under web/src changed)
  • Added/updated tests for the change
  • Updated docs/ and added a CHANGELOG.md entry for user-facing changes

Notes for reviewers

Two findings drove the design; both are worth checking.

1. VACUUM INTO, not a file copy. The database runs in WAL mode, so
copying docker-commander.db can miss committed data still in the -wal file, or
catch a write mid-flight and yield a torn database. The snapshot goes through a
live connection instead, which makes --backup safe while the server is
running
— the normal case for a scheduled job.

2. The archive is equivalent to your secrets in plaintext. The at-rest
encryption key is a row in the database being backed up, sitting next to every
ciphertext it protects — host TLS keys, SMTP and LDAP passwords, registry
credentials. Hence 0600 always, a loud warning when unencrypted, and
--passphrase (AES-256-GCM, Argon2id at deliberately heavier parameters than the
login hash, since a backup is attacked offline at leisure). The format magic is
used as AAD so the header can't be swapped.

The passphrase is read from the terminal, or from stdin when piped. I hit this
during testing: term.ReadPassword fails on a non-TTY, which would have meant
scheduled backups could never be encrypted — the case that matters most for the
OPS scenario. It's never an argument, so it stays out of shell history and
/proc/<pid>/cmdline.

12 tests, 5 pentests. The ones to read: tar slip (../pwned,
projects/../../pwned, absolute paths) and an escaping symlink whose target
climbs out of the data dir — restore runs as the service account, and a backup may
have travelled. Plus wrong passphrase, tampered ciphertext, and
encrypted-without-passphrase, all failing closed with nothing written.

Verified end to end against a real instance, not just unit tests: set an SMTP
password, a custom role and a project; backed up while the server was running;
restored into a fresh data dir; started it; logged in with the original password;
confirmed the role and project were there; and confirmed the restored server could
decrypt the stored SMTP secret (the test reached a DNS lookup rather than
failing at decryption). Then repeated the whole thing encrypted, checked the
plaintext really is absent from the archive, and that --force-less restore over a
live install is refused.

Deliberately CLI-only. A UI button would mean an HTTP endpoint that streams
every secret to a browser session — I'd rather not add that surface without you
asking for it. --restore also has to replace the database wholesale, which means
the server must be stopped anyway.

Dependency: golang.org/x/term moves from indirect to direct. No new module
enters the graph.

Not covered

Only projects/ and project-templates/ are archived. Anything else under the
data dir — e.g. tls/ from --make-certs — is left out deliberately: it is
reproducible and machine-specific. Say the word if you'd rather it travelled too.

dockercmd --backup <file> writes a portable snapshot (database +
projects/ + project-templates/); --restore puts it back.

The database snapshot goes through a live connection with VACUUM INTO, so a
backup is safe while the server is running. Copying the .db file is not: it
runs in WAL mode, so committed data can still sit in the -wal file and a copy
taken mid-write is torn.

Both secret keys are rows inside that database, which is what makes a backup
self-contained — it restores onto a fresh machine as-is. It also means the
archive is equivalent to the plaintext of every stored secret, since the key
travels next to the ciphertext. So the file is written 0600 and --passphrase
encrypts it with AES-256-GCM under an Argon2id-derived key, with the format
magic as AAD.

The passphrase is read from the terminal with echo off, or from stdin when
piped — otherwise scheduled backups could not be encrypted at all, which is
the case that matters most for a server. It is never an argument, where it
would land in shell history and /proc/<pid>/cmdline.

Restore refuses to overwrite an existing installation without --force, so a
mistyped path cannot destroy a running instance, and every archive entry is
jailed to the data dir: a tampered backup cannot write elsewhere, including
through a symlink whose target escapes.

12 tests, 5 of them pentests (tar slip, escaping symlink, wrong passphrase,
tampered ciphertext, encrypted-without-passphrase). Also verified end to end
against a real instance: backed up while running, restored to a fresh data
dir, logged in with the original password, and confirmed the restored server
could decrypt the stored SMTP secret.

golang.org/x/term moves from an indirect to a direct dependency; no new
module enters the graph.
Copilot AI review requested due to automatic review settings July 30, 2026 15:32
Comment thread internal/backup/backup.go
if err != nil {
return err
}
defer f.Close()
Comment thread internal/backup/backup.go
return err
}
if _, err := io.Copy(out, tr); err != nil {
out.Close()
Comment thread internal/backup/backup.go
if err != nil {
return err
}
defer out.Close()

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Not ready to approve

There is a compile-breaking allocation in encrypted restore length handling and several security/operational hardening gaps (bounds-checking encrypted lengths, forced restore target sanitization, and ensuring archive perms on overwrite).

Once you've addressed the issues Copilot identified, you can request another Copilot review.

This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.

Pull request overview

Adds an ops-focused, CLI-only mechanism to create/restore a full installation snapshot (SQLite DB + projects/ + project-templates/), optionally encrypting the archive with a user-supplied passphrase and documenting the security implications.

Changes:

  • Add Store.BackupTo() using VACUUM INTO for WAL-safe live DB snapshots.
  • Introduce internal/backup to create/restore archives, including AES-GCM + Argon2id encryption and tar-slip protections.
  • Wire dockercmd --backup/--restore [--passphrase] [--force] and update docs/changelog.
File summaries
File Description
internal/store/store.go Adds BackupTo helper for consistent DB snapshots via VACUUM INTO.
internal/backup/backup.go Implements backup archive creation, optional encryption, and restore/extraction logic.
internal/backup/backup_test.go Adds unit tests + pentests for restore hardening (tar slip, symlink escape, tamper/wrong passphrase, etc.).
cmd/dockercmd/main.go Adds CLI parsing and execution for --backup / --restore, including passphrase reading from TTY/stdin.
docs/deployment.md Documents backup/restore usage, safety constraints, and security warning.
CHANGELOG.md Notes the new backup & restore feature and its security posture.
go.mod Adds direct dependency on golang.org/x/term.
Review details
  • Files reviewed: 7/7 changed files
  • Comments generated: 8
  • Review effort level: Low

We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.

Comment thread internal/backup/backup.go
Comment on lines +396 to +403
var n [8]byte
if _, err := io.ReadFull(r, n[:]); err != nil {
return nil, ErrNotABackup
}
sealed := make([]byte, binary.BigEndian.Uint64(n[:]))
if _, err := io.ReadFull(r, sealed); err != nil {
return nil, ErrNotABackup
}
Comment thread internal/backup/backup.go
Comment on lines +255 to +263
func checkTarget(dataDir string, force bool) error {
if force {
return os.MkdirAll(dataDir, 0o700)
}
if _, err := os.Stat(filepath.Join(dataDir, dbFileName)); err == nil {
return fmt.Errorf("backup: %s already contains an installation — stop the server and pass --force to overwrite it", dataDir)
}
return os.MkdirAll(dataDir, 0o700)
}
Comment thread internal/store/store.go
Comment on lines +336 to +340
if _, err := os.Stat(path); err == nil {
// VACUUM INTO refuses to overwrite, and a stale file would fail the backup
// for a confusing reason.
return fmt.Errorf("store: %s already exists", path)
}
Comment on lines +254 to +257
err := Restore(archive, dataDir, "", true)
if err == nil && name != "/etc/pwned.txt" {
t.Errorf("SECURITY: entry %q was accepted", name)
}
Comment thread docs/deployment.md
Comment on lines +300 to +305
```bash
dockercmd --backup /var/backups/dc-$(date +%F).tar.gz # plain
dockercmd --backup /var/backups/dc.tar.gz --passphrase # encrypted (prompts)
echo "$PASS" | dockercmd --backup /var/backups/dc.tar.gz --passphrase # for cron
```

Comment thread cmd/dockercmd/main.go
Comment on lines +204 to +211
// Snapshot through a live connection so the WAL is accounted for. This is
// safe with the server running.
st, err := store.Open(filepath.Join(dataDir, "docker-commander.db"))
if err != nil {
return err
}
defer st.Close()
if err := backup.Create(dataDir, file, storeBackuper{st}, passphrase); err != nil {
Comment thread internal/backup/backup.go
}
}

// 2. Build the tar.gz payload in memory-free streaming fashion to a temp file.
Comment thread internal/backup/backup.go
Comment on lines +105 to +114
// 3. Emit, encrypting if asked. 0600 either way: even encrypted, this file is
// the whole installation.
f, err := os.OpenFile(out, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return err
}
defer f.Close()
if _, err := f.Write(magic); err != nil {
return err
}
@malickyeu
malickyeu merged commit 832d367 into main Jul 30, 2026
4 checks passed
@malickyeu
malickyeu deleted the feat/backup-restore branch July 30, 2026 15:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants