backup: encrypt archives to age recipients !498
14 files changed, +463 −29
Layout: unified · split
.gitbay/wiki/Admin.org +21
| @@ -179,6 +179,15 @@ anything. The delivery queue (a device's undelivered and attempted | ||
| 179 | 179 | pushes) is capped the same way the mail queue is, by =[retention] |
| 180 | 180 | push=. |
| 181 | 181 | |
| 182 | ** [backup] | |
| 183 | - =age_recipients= (optional) — age public keys (=age1...=). When set, | |
| 184 | =admin backup= encrypts every archive to them and appends =.age= to | |
| 185 | its name. Generate the pair off the host with =age-keygen=; only the | |
| 186 | public key goes here, so the host writes archives it cannot read. | |
| 187 | The restic copy is unaffected: the offsite job stages its own | |
| 188 | =VACUUM INTO= of the live database and snapshots =/var/lib/gitbay=, | |
| 189 | not the archives. | |
| 190 | ||
| 182 | 191 | ** [api] |
| 183 | 192 | - =enabled= (false) — the JSON API surface; see [[API]]. Off |
| 184 | 193 | means no credential-bearing HTTP endpoint exists at all. |
| @@ -442,6 +451,18 @@ integrity check, and every repository the snapshot names must be in the | ||
| 442 | 451 | archive. A database-only archive is checked for integrity and says so. |
| 443 | 452 | Exit is non-zero on damage or a missing repository. |
| 444 | 453 | |
| 454 | With =[backup] age_recipients= set the archive is =<name>.tar.gz.age= | |
| 455 | and =--verify= needs the private key: | |
| 456 | ||
| 457 | #+begin_src sh | |
| 458 | gitbayd admin backup --verify gitbay-20260927-090000.tar.gz.age --identity ~/.config/gitbay/backup-identity.txt | |
| 459 | age -d -i ~/.config/gitbay/backup-identity.txt gitbay-20260927-090000.tar.gz.age | tar -xz -C /new/root | |
| 460 | #+end_src | |
| 461 | ||
| 462 | The identity lives off the host (with the secret key file and the | |
| 463 | restic credentials), so verifying an encrypted archive happens there | |
| 464 | or on a restore host. | |
| 465 | ||
| 445 | 466 | Restore: extract into an empty directory, point =server.root= at it, |
| 446 | 467 | start gitbayd. Host keys are preserved, so clients keep their |
| 447 | 468 | known_hosts entries; hooks regenerate at startup. |
.gitbay/wiki/Architecture/06-Data-and-Cryptography.org +1 −1
| @@ -45,7 +45,7 @@ throttling (=internal/sshd/sshd.go=). | ||
| 45 | 45 | | API tokens, sessions, login links, email codes, invites | SHA-256 of a 256-bit random value; the value is shown once and never stored (=internal/store/sessions.go=) | |
| 46 | 46 | | CI secrets, webhook secrets, mirror tokens, APNs device tokens | AES-256-GCM under a key file outside the database and outside =server.root=; additional data binds table, column and row (=internal/seal=, =internal/store/secrets.go=) | |
| 47 | 47 | | SQLite file | mode 0640, directory 0750 | |
| 48 | | Backups | the local archive is not encrypted; restic encrypts the offsite copy | | |
| 48 | | Backups | local archives age-encrypted when =[backup] age_recipients= is set; restic encrypts the offsite copy; neither carries the secret key file, whose offsite copy is a separate keys repository | | |
| 49 | 49 | | Disk | no application-level encryption; any disk encryption is the host's | |
| 50 | 50 | |
| 51 | 51 | The database file or a backup read by anyone other than the =gitbay= |
.gitbay/wiki/Architecture/08-Operations.org +2 −2
| @@ -48,8 +48,8 @@ the product activity feed, not an audit trail. | ||
| 48 | 48 | |
| 49 | 49 | | Item | Schedule | Kept | Contents | |
| 50 | 50 | |-----------------+----------+------+-----------------------------------------------------------------| |
| 51 | | Full archive | nightly | 7 | SQLite snapshot (=VACUUM INTO=), all repositories, LFS, SSH host keys | | |
| 52 | | Database only | hourly | 48 | SQLite snapshot | | |
| 51 | | Full archive | nightly | 7 | SQLite snapshot (=VACUUM INTO=), all repositories, LFS, SSH host keys; age-encrypted when =[backup] age_recipients= is set | | |
| 52 | | Database only | hourly | 48 | SQLite snapshot; age-encrypted when =[backup] age_recipients= is set | | |
| 53 | 53 | | Offsite (restic)| nightly | per prune policy | =/var/lib/gitbay= and a staged database copy, to object storage | |
| 54 | 54 | |
| 55 | 55 | - The database snapshot is taken before repositories are read, so a |
.gitbay/wiki/Architecture/09-Controls.org +1 −1
| @@ -58,7 +58,7 @@ chapter names of OWASP ASVS 4.0 where one fits. | ||
| 58 | 58 | | TLS for all authenticated HTTP | in place | ACME or certificate files; HSTS | |
| 59 | 59 | | Secrets encrypted at rest | in place | AES-256-GCM, key file outside the database and the main backups (=internal/seal=) | |
| 60 | 60 | | Secrets kept out of argv, logs and output | in place | =ReadsStdin=, pruned audit argv, write-only secret commands | |
| 61 | | Local backups encrypted | gap | tar.gz in clear; offsite copy encrypted by restic (#274) | | |
| 61 | | Local backups encrypted | in place | age to =[backup] age_recipients= (=cmd/gitbayd/backup.go=); offsite copy by restic | | |
| 62 | 62 | | Data retention configurable | in place | =[retention]= (=internal/config/config.go=) | |
| 63 | 63 | | User data export | in place | =account export= | |
| 64 | 64 | |
.gitbay/wiki/Architecture/10-Known-Gaps.org +1 −2
| @@ -14,8 +14,7 @@ what the 2026-09-27 review found; remove a row when its issue closes. | ||
| 14 | 14 | | #260 | CI network | Builds share the runner's source address; no egress policy | medium | |
| 15 | 15 | | #261 | Various | Migration foreign-key check after commit; three web writes bypass dispatch; documentation drift | medium | |
| 16 | 16 | | #262 | Availability | No limit on concurrent git pack generation | high | |
| 17 | | #274 | Backups | The local backup archive is not encrypted | medium | | |
| 18 | | #298 | SSRF | =repo import --from= fetches without an address check | medium | | |
| 17 | | #298 | SSRF | =repo import --from= fetches without an address check | medium | | |
| 19 | 18 | | #297 | Credentials | A browser session can mint tokens and keys that outlive it | low | |
| 20 | 19 | |
| 21 | 20 | * Not filed |
CHANGELOG.org +3
| @@ -161,6 +161,9 @@ secret. | ||
| 161 | 161 | older server refuses the runner's =--step= and =--reason= (exit 2), |
| 162 | 162 | and its failed builds stay running until the reaper fails them. |
| 163 | 163 | (#266) |
| 164 | - =gitbayd admin backup= encrypts archives to =[backup] age_recipients= | |
| 165 | when set (#274); =--verify= takes =--identity <file>=. Archive names | |
| 166 | gain =.age=; the shipped backup scripts and monitor match both. | |
| 164 | 167 | |
| 165 | 168 | * v1.36.0 — 2026-09-23 |
| 166 | 169 | |
cmd/gitbayd/backup.go +134 −18
| @@ -2,6 +2,7 @@ package main | ||
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | 4 | "archive/tar" |
| 5 | "bufio" | |
| 5 | 6 | "compress/gzip" |
| 6 | 7 | "fmt" |
| 7 | 8 | "io" |
| @@ -11,6 +12,7 @@ import ( | ||
| 11 | 12 | "strings" |
| 12 | 13 | "time" |
| 13 | 14 | |
| 15 | "filippo.io/age" | |
| 14 | 16 | "github.com/spf13/cobra" |
| 15 | 17 | |
| 16 | 18 | "gitbay.org/gitbay/internal/config" |
| @@ -26,7 +28,7 @@ import ( | ||
| 26 | 28 | // objects in the archive (harmless); the reverse order could leave database |
| 27 | 29 | // rows pointing at objects the archive never captured. |
| 28 | 30 | func backupCmd() *cobra.Command { |
| 29 | var out, verify string | |
| 31 | var out, verify, identity string | |
| 30 | 32 | var dbOnly bool |
| 31 | 33 | cmd := &cobra.Command{ |
| 32 | 34 | Use: "backup", |
| @@ -42,48 +44,93 @@ comments that exists nowhere else. Repositories are not in such an archive, | ||
| 42 | 44 | so it supplements a full backup and does not replace one. |
| 43 | 45 | |
| 44 | 46 | Restore: extract into an empty directory, point server.root at it, start |
| 45 | gitbayd. Host keys are preserved, so clients keep their known_hosts entries.`, | |
| 47 | gitbayd. Host keys are preserved, so clients keep their known_hosts entries. | |
| 48 | ||
| 49 | With [backup] age_recipients set, the archive is encrypted to those age | |
| 50 | public keys and its name ends in .age. --verify then needs --identity | |
| 51 | <file> holding a matching private key, which is kept off the host.`, | |
| 46 | 52 | RunE: func(cmd *cobra.Command, args []string) error { |
| 47 | 53 | if verify != "" { |
| 48 | return verifyBackup(verify) | |
| 54 | return verifyBackup(verify, identity) | |
| 49 | 55 | } |
| 50 | 56 | cfg, err := config.Load(configPath) |
| 51 | 57 | if err != nil { |
| 52 | 58 | return err |
| 53 | 59 | } |
| 54 | if out == "" { | |
| 55 | out = fmt.Sprintf("gitbay-backup-%s.tar.gz", time.Now().UTC().Format("20060102-150405")) | |
| 56 | } | |
| 57 | return runBackup(cfg, out, dbOnly) | |
| 60 | return runBackup(cfg, archivePath(out, cfg, time.Now()), dbOnly) | |
| 58 | 61 | }, |
| 59 | 62 | } |
| 60 | cmd.Flags().StringVar(&out, "out", "", "output archive path (default gitbay-backup-<utc timestamp>.tar.gz)") | |
| 63 | cmd.Flags().StringVar(&out, "out", "", "output archive path (default gitbay-backup-<utc timestamp>.tar.gz; .age is appended when [backup] age_recipients is set)") | |
| 61 | 64 | cmd.Flags().BoolVar(&dbOnly, "db-only", false, "archive the database snapshot alone, without repositories") |
| 62 | 65 | cmd.Flags().StringVar(&verify, "verify", "", "check an archive instead of writing one: database integrity, and its repositories against the archive's") |
| 66 | cmd.Flags().StringVar(&identity, "identity", "", "with --verify: an age identity file that opens an encrypted archive") | |
| 63 | 67 | return cmd |
| 64 | 68 | } |
| 65 | 69 | |
| 70 | // archivePath is where the archive goes: out, or a timestamped name, | |
| 71 | // ending in .age when the archive is encrypted. | |
| 72 | func archivePath(out string, cfg config.Config, now time.Time) string { | |
| 73 | if out == "" { | |
| 74 | out = fmt.Sprintf("gitbay-backup-%s.tar.gz", now.UTC().Format("20060102-150405")) | |
| 75 | } | |
| 76 | if len(cfg.Backup.AgeRecipients) > 0 && !strings.HasSuffix(out, ".age") { | |
| 77 | out += ".age" | |
| 78 | } | |
| 79 | return out | |
| 80 | } | |
| 81 | ||
| 66 | 82 | func runBackup(cfg config.Config, out string, dbOnly bool) error { |
| 83 | var rs []age.Recipient | |
| 84 | if len(cfg.Backup.AgeRecipients) > 0 { | |
| 85 | var err error | |
| 86 | if rs, err = cfg.Backup.Recipients(); err != nil { | |
| 87 | return err | |
| 88 | } | |
| 89 | } else if strings.HasSuffix(out, ".age") { | |
| 90 | return fmt.Errorf("%s ends in .age but [backup] age_recipients is not set, so the archive would not be encrypted", out) | |
| 91 | } | |
| 92 | ||
| 67 | 93 | st, err := openStore(cfg) |
| 68 | 94 | if err != nil { |
| 69 | 95 | return err |
| 70 | 96 | } |
| 71 | 97 | defer st.Close() |
| 72 | 98 | |
| 73 | // 1. Consistent database snapshot, before any repository is read. | |
| 74 | snap := filepath.Join(os.TempDir(), fmt.Sprintf("gitbay-snap-%d.db", os.Getpid())) | |
| 75 | os.Remove(snap) | |
| 76 | defer os.Remove(snap) | |
| 99 | // 1. Consistent database snapshot, before any repository is read. It | |
| 100 | // goes in a fresh 0700 directory beside the archive. | |
| 101 | dir := filepath.Dir(out) | |
| 102 | snapDir, err := os.MkdirTemp(dir, ".gitbay-snap-") | |
| 103 | if err != nil { | |
| 104 | return err | |
| 105 | } | |
| 106 | defer os.RemoveAll(snapDir) | |
| 107 | snap := filepath.Join(snapDir, "gitbay.db") | |
| 77 | 108 | if err := snapshotDB(st, snap); err != nil { |
| 78 | 109 | return fmt.Errorf("database snapshot: %w", err) |
| 79 | 110 | } |
| 80 | 111 | |
| 81 | f, err := os.Create(out) | |
| 112 | // The archive is written to a temporary name beside out and renamed | |
| 113 | // once complete, so a failed run leaves no partial archive behind. | |
| 114 | f, err := os.CreateTemp(dir, "."+filepath.Base(out)+".tmp-") | |
| 82 | 115 | if err != nil { |
| 83 | 116 | return err |
| 84 | 117 | } |
| 85 | defer f.Close() | |
| 86 | gz := gzip.NewWriter(f) | |
| 118 | done := false | |
| 119 | defer func() { | |
| 120 | if !done { | |
| 121 | f.Close() | |
| 122 | os.Remove(f.Name()) | |
| 123 | } | |
| 124 | }() | |
| 125 | var sink io.Writer = f | |
| 126 | var enc io.WriteCloser | |
| 127 | if len(rs) > 0 { | |
| 128 | if enc, err = age.Encrypt(f, rs...); err != nil { | |
| 129 | return err | |
| 130 | } | |
| 131 | sink = enc | |
| 132 | } | |
| 133 | gz := gzip.NewWriter(sink) | |
| 87 | 134 | tw := tar.NewWriter(gz) |
| 88 | 135 | |
| 89 | 136 | if err := addFile(tw, snap, "gitbay.db"); err != nil { |
| @@ -137,9 +184,24 @@ func runBackup(cfg config.Config, out string, dbOnly bool) error { | ||
| 137 | 184 | if err := gz.Close(); err != nil { |
| 138 | 185 | return err |
| 139 | 186 | } |
| 187 | if enc != nil { | |
| 188 | if err := enc.Close(); err != nil { | |
| 189 | return err | |
| 190 | } | |
| 191 | } | |
| 192 | if err := f.Sync(); err != nil { | |
| 193 | return err | |
| 194 | } | |
| 140 | 195 | if err := f.Close(); err != nil { |
| 141 | 196 | return err |
| 142 | 197 | } |
| 198 | if err := os.Rename(f.Name(), out); err != nil { | |
| 199 | return err | |
| 200 | } | |
| 201 | done = true | |
| 202 | if err := syncDir(dir); err != nil { | |
| 203 | return err | |
| 204 | } | |
| 143 | 205 | |
| 144 | 206 | info, _ := os.Stat(out) |
| 145 | 207 | if dbOnly { |
| @@ -150,6 +212,16 @@ func runBackup(cfg config.Config, out string, dbOnly bool) error { | ||
| 150 | 212 | return nil |
| 151 | 213 | } |
| 152 | 214 | |
| 215 | // syncDir makes a rename in dir durable. | |
| 216 | func syncDir(dir string) error { | |
| 217 | d, err := os.Open(dir) | |
| 218 | if err != nil { | |
| 219 | return err | |
| 220 | } | |
| 221 | defer d.Close() | |
| 222 | return d.Sync() | |
| 223 | } | |
| 224 | ||
| 153 | 225 | // snapshotDB writes a consistent copy of the live database. VACUUM INTO |
| 154 | 226 | // takes a read snapshot, so concurrent daemon writes are safe under WAL. |
| 155 | 227 | func snapshotDB(st *store.Store, dest string) error { |
| @@ -180,17 +252,22 @@ func addFile(tw *tar.Writer, path, name string) error { | ||
| 180 | 252 | return err |
| 181 | 253 | } |
| 182 | 254 | |
| 183 | // verifyBackup reads an archive back: the database snapshot must pass | |
| 255 | // verifyBackup reads an archive back, decrypting it with identity when it | |
| 256 | // is encrypted: the database snapshot must pass | |
| 184 | 257 | // SQLite's integrity check, and every repository it names must be in the |
| 185 | 258 | // archive. A database-only archive is checked for integrity alone and |
| 186 | 259 | // says so. Nothing is written except a temporary copy of the database. |
| 187 | func verifyBackup(path string) error { | |
| 260 | func verifyBackup(path, identity string) error { | |
| 188 | 261 | f, err := os.Open(path) |
| 189 | 262 | if err != nil { |
| 190 | 263 | return err |
| 191 | 264 | } |
| 192 | 265 | defer f.Close() |
| 193 | gz, err := gzip.NewReader(f) | |
| 266 | plain, err := archiveReader(f, path, identity) | |
| 267 | if err != nil { | |
| 268 | return err | |
| 269 | } | |
| 270 | gz, err := gzip.NewReader(plain) | |
| 194 | 271 | if err != nil { |
| 195 | 272 | return fmt.Errorf("%s: not a gzip archive: %w", path, err) |
| 196 | 273 | } |
| @@ -232,6 +309,13 @@ func verifyBackup(path string) error { | ||
| 232 | 309 | } |
| 233 | 310 | } |
| 234 | 311 | } |
| 312 | // Read to the end so gzip checks its trailer and age its final chunk. | |
| 313 | if _, err := io.Copy(io.Discard, gz); err != nil { | |
| 314 | return fmt.Errorf("%s: archive truncated or damaged: %w", path, err) | |
| 315 | } | |
| 316 | if err := gz.Close(); err != nil { | |
| 317 | return fmt.Errorf("%s: archive truncated or damaged: %w", path, err) | |
| 318 | } | |
| 235 | 319 | if dbPath == "" { |
| 236 | 320 | return fmt.Errorf("%s: no gitbay.db in the archive", path) |
| 237 | 321 | } |
| @@ -271,3 +355,35 @@ func verifyBackup(path string) error { | ||
| 271 | 355 | } |
| 272 | 356 | return nil |
| 273 | 357 | } |
| 358 | ||
| 359 | const ageHeader = "age-encryption.org/v1\n" | |
| 360 | ||
| 361 | // archiveReader returns the archive's gzip stream, decrypting it first | |
| 362 | // when it is an age file. | |
| 363 | func archiveReader(f io.Reader, path, identity string) (io.Reader, error) { | |
| 364 | br := bufio.NewReader(f) | |
| 365 | head, _ := br.Peek(len(ageHeader)) | |
| 366 | if string(head) != ageHeader { | |
| 367 | if identity != "" { | |
| 368 | fmt.Fprintf(os.Stderr, "%s is not encrypted; --identity was not used\n", path) | |
| 369 | } | |
| 370 | return br, nil | |
| 371 | } | |
| 372 | if identity == "" { | |
| 373 | return nil, fmt.Errorf("%s is encrypted; pass --identity <file> with the private key for one of its recipients", path) | |
| 374 | } | |
| 375 | idf, err := os.Open(identity) | |
| 376 | if err != nil { | |
| 377 | return nil, err | |
| 378 | } | |
| 379 | defer idf.Close() | |
| 380 | ids, err := age.ParseIdentities(idf) | |
| 381 | if err != nil { | |
| 382 | return nil, fmt.Errorf("%s: %w", identity, err) | |
| 383 | } | |
| 384 | r, err := age.Decrypt(br, ids...) | |
| 385 | if err != nil { | |
| 386 | return nil, fmt.Errorf("%s: decrypting: %w", path, err) | |
| 387 | } | |
| 388 | return r, nil | |
| 389 | } | |
cmd/gitbayd/backup_test.go +232
| @@ -3,11 +3,18 @@ package main | ||
| 3 | 3 | import ( |
| 4 | 4 | "archive/tar" |
| 5 | 5 | "compress/gzip" |
| 6 | "errors" | |
| 6 | 7 | "io" |
| 7 | 8 | "os" |
| 8 | 9 | "path/filepath" |
| 9 | 10 | "sort" |
| 11 | "strings" | |
| 10 | 12 | "testing" |
| 13 | "time" | |
| 14 | ||
| 15 | "filippo.io/age" | |
| 16 | ||
| 17 | "gitbay.org/gitbay/internal/config" | |
| 11 | 18 | ) |
| 12 | 19 | |
| 13 | 20 | // members lists the archive's entries by name. |
| @@ -92,3 +99,228 @@ func TestBackupDBOnlyOmitsRepositories(t *testing.T) { | ||
| 92 | 99 | t.Error("db-only backup is empty") |
| 93 | 100 | } |
| 94 | 101 | } |
| 102 | ||
| 103 | func TestBackupEncryptedToAgeRecipient(t *testing.T) { | |
| 104 | cfg := testConfig(t) | |
| 105 | id, err := age.GenerateX25519Identity() | |
| 106 | if err != nil { | |
| 107 | t.Fatal(err) | |
| 108 | } | |
| 109 | cfg.Backup.AgeRecipients = []string{id.Recipient().String()} | |
| 110 | s, err := openStore(cfg) | |
| 111 | if err != nil { | |
| 112 | t.Fatal(err) | |
| 113 | } | |
| 114 | s.Close() | |
| 115 | ||
| 116 | out := filepath.Join(t.TempDir(), "b.tar.gz.age") | |
| 117 | if err := runBackup(cfg, out, true); err != nil { | |
| 118 | t.Fatal(err) | |
| 119 | } | |
| 120 | head := make([]byte, 22) | |
| 121 | f, err := os.Open(out) | |
| 122 | if err != nil { | |
| 123 | t.Fatal(err) | |
| 124 | } | |
| 125 | _, err = io.ReadFull(f, head) | |
| 126 | f.Close() | |
| 127 | if err != nil { | |
| 128 | t.Fatal(err) | |
| 129 | } | |
| 130 | if string(head) != "age-encryption.org/v1\n" { | |
| 131 | t.Fatalf("archive is not age-encrypted: %q", head) | |
| 132 | } | |
| 133 | ||
| 134 | if err := verifyBackup(out, ""); err == nil || !strings.Contains(err.Error(), "--identity") { | |
| 135 | t.Fatalf("verify without an identity: %v", err) | |
| 136 | } | |
| 137 | idFile := filepath.Join(t.TempDir(), "backup-identity.txt") | |
| 138 | if err := os.WriteFile(idFile, []byte(id.String()+"\n"), 0o600); err != nil { | |
| 139 | t.Fatal(err) | |
| 140 | } | |
| 141 | if err := verifyBackup(out, idFile); err != nil { | |
| 142 | t.Fatalf("verify with the identity: %v", err) | |
| 143 | } | |
| 144 | other, err := age.GenerateX25519Identity() | |
| 145 | if err != nil { | |
| 146 | t.Fatal(err) | |
| 147 | } | |
| 148 | otherFile := filepath.Join(t.TempDir(), "other.txt") | |
| 149 | if err := os.WriteFile(otherFile, []byte(other.String()+"\n"), 0o600); err != nil { | |
| 150 | t.Fatal(err) | |
| 151 | } | |
| 152 | var noMatch *age.NoIdentityMatchError | |
| 153 | if err := verifyBackup(out, otherFile); !errors.As(err, &noMatch) { | |
| 154 | t.Fatalf("verify with another identity: %v, want a no-identity-match error", err) | |
| 155 | } | |
| 156 | } | |
| 157 | ||
| 158 | // leftovers lists what a backup run left in dir besides the archive. | |
| 159 | func leftovers(t *testing.T, dir string) []string { | |
| 160 | t.Helper() | |
| 161 | ents, err := os.ReadDir(dir) | |
| 162 | if err != nil { | |
| 163 | t.Fatal(err) | |
| 164 | } | |
| 165 | var names []string | |
| 166 | for _, e := range ents { | |
| 167 | if strings.HasPrefix(e.Name(), ".") { | |
| 168 | names = append(names, e.Name()) | |
| 169 | } | |
| 170 | } | |
| 171 | return names | |
| 172 | } | |
| 173 | ||
| 174 | // The snapshot directory and the archive's temporary file are removed | |
| 175 | // whether the run succeeds or fails, and a failed run leaves no archive. | |
| 176 | func TestBackupLeavesNoTemporaries(t *testing.T) { | |
| 177 | cfg := testConfig(t) | |
| 178 | id, err := age.GenerateX25519Identity() | |
| 179 | if err != nil { | |
| 180 | t.Fatal(err) | |
| 181 | } | |
| 182 | cfg.Backup.AgeRecipients = []string{id.Recipient().String()} | |
| 183 | s, err := openStore(cfg) | |
| 184 | if err != nil { | |
| 185 | t.Fatal(err) | |
| 186 | } | |
| 187 | s.Close() | |
| 188 | ||
| 189 | dir := t.TempDir() | |
| 190 | out := filepath.Join(dir, "ok.tar.gz.age") | |
| 191 | if err := runBackup(cfg, out, false); err != nil { | |
| 192 | t.Fatal(err) | |
| 193 | } | |
| 194 | if got := leftovers(t, dir); len(got) != 0 { | |
| 195 | t.Errorf("after a successful run: %v", got) | |
| 196 | } | |
| 197 | fi, err := os.Stat(out) | |
| 198 | if err != nil { | |
| 199 | t.Fatal(err) | |
| 200 | } | |
| 201 | if fi.Mode().Perm() != 0o600 { | |
| 202 | t.Errorf("archive mode %v, want 0600", fi.Mode().Perm()) | |
| 203 | } | |
| 204 | ||
| 205 | // A file the walk cannot read fails the run after the snapshot and | |
| 206 | // the temporary archive exist. Root reads a mode-0 file, so the case | |
| 207 | // needs an unprivileged user. | |
| 208 | if os.Geteuid() == 0 { | |
| 209 | t.Log("running as root: skipping the mid-walk failure case") | |
| 210 | } else { | |
| 211 | unreadable := filepath.Join(cfg.Server.Root, "unreadable") | |
| 212 | if err := os.WriteFile(unreadable, []byte("x"), 0o000); err != nil { | |
| 213 | t.Fatal(err) | |
| 214 | } | |
| 215 | failed := filepath.Join(dir, "failed.tar.gz.age") | |
| 216 | err := runBackup(cfg, failed, false) | |
| 217 | os.Remove(unreadable) | |
| 218 | if err == nil { | |
| 219 | t.Fatal("backup with an unreadable file succeeded") | |
| 220 | } | |
| 221 | if _, err := os.Stat(failed); !os.IsNotExist(err) { | |
| 222 | t.Errorf("failed run left an archive: %v", err) | |
| 223 | } | |
| 224 | if got := leftovers(t, dir); len(got) != 0 { | |
| 225 | t.Errorf("after a failed run: %v", got) | |
| 226 | } | |
| 227 | } | |
| 228 | ||
| 229 | bad := cfg | |
| 230 | bad.Backup.AgeRecipients = []string{"age1x"} | |
| 231 | if err := runBackup(bad, filepath.Join(dir, "bad.tar.gz.age"), true); err == nil { | |
| 232 | t.Fatal("backup with a bad recipient succeeded") | |
| 233 | } | |
| 234 | if got := leftovers(t, dir); len(got) != 0 { | |
| 235 | t.Errorf("after a bad recipient: %v", got) | |
| 236 | } | |
| 237 | } | |
| 238 | ||
| 239 | func TestBackupRefusesAgeNameWithoutRecipients(t *testing.T) { | |
| 240 | cfg := testConfig(t) | |
| 241 | out := filepath.Join(t.TempDir(), "b.tar.gz.age") | |
| 242 | err := runBackup(cfg, out, true) | |
| 243 | if err == nil || !strings.Contains(err.Error(), "age_recipients") { | |
| 244 | t.Fatalf("got %v, want a refusal naming age_recipients", err) | |
| 245 | } | |
| 246 | } | |
| 247 | ||
| 248 | // A truncated archive fails verification even when the tar stream's end | |
| 249 | // markers survive: gzip's trailer and age's final chunk are checked. | |
| 250 | func TestVerifyRejectsTruncatedArchive(t *testing.T) { | |
| 251 | cfg := testConfig(t) | |
| 252 | s, err := openStore(cfg) | |
| 253 | if err != nil { | |
| 254 | t.Fatal(err) | |
| 255 | } | |
| 256 | s.Close() | |
| 257 | dir := t.TempDir() | |
| 258 | plain := filepath.Join(dir, "p.tar.gz") | |
| 259 | if err := runBackup(cfg, plain, true); err != nil { | |
| 260 | t.Fatal(err) | |
| 261 | } | |
| 262 | id, err := age.GenerateX25519Identity() | |
| 263 | if err != nil { | |
| 264 | t.Fatal(err) | |
| 265 | } | |
| 266 | enc := cfg | |
| 267 | enc.Backup.AgeRecipients = []string{id.Recipient().String()} | |
| 268 | sealed := filepath.Join(dir, "e.tar.gz.age") | |
| 269 | if err := runBackup(enc, sealed, true); err != nil { | |
| 270 | t.Fatal(err) | |
| 271 | } | |
| 272 | idFile := filepath.Join(dir, "id.txt") | |
| 273 | if err := os.WriteFile(idFile, []byte(id.String()+"\n"), 0o600); err != nil { | |
| 274 | t.Fatal(err) | |
| 275 | } | |
| 276 | if err := verifyBackup(plain, ""); err != nil { | |
| 277 | t.Fatalf("intact plain archive: %v", err) | |
| 278 | } | |
| 279 | if err := verifyBackup(sealed, idFile); err != nil { | |
| 280 | t.Fatalf("intact encrypted archive: %v", err) | |
| 281 | } | |
| 282 | ||
| 283 | for _, c := range []struct { | |
| 284 | src string | |
| 285 | cut int | |
| 286 | identity string | |
| 287 | }{ | |
| 288 | {plain, 1, ""}, | |
| 289 | {sealed, 1, idFile}, | |
| 290 | {sealed, 100, idFile}, | |
| 291 | } { | |
| 292 | data, err := os.ReadFile(c.src) | |
| 293 | if err != nil { | |
| 294 | t.Fatal(err) | |
| 295 | } | |
| 296 | short := filepath.Join(dir, "short-"+filepath.Base(c.src)) | |
| 297 | if err := os.WriteFile(short, data[:len(data)-c.cut], 0o600); err != nil { | |
| 298 | t.Fatal(err) | |
| 299 | } | |
| 300 | if err := verifyBackup(short, c.identity); err == nil { | |
| 301 | t.Errorf("%s cut by %d bytes verified", filepath.Base(c.src), c.cut) | |
| 302 | } | |
| 303 | } | |
| 304 | } | |
| 305 | ||
| 306 | func TestArchivePath(t *testing.T) { | |
| 307 | now := time.Date(2026, 9, 27, 9, 0, 0, 0, time.UTC) | |
| 308 | plain := testConfig(t) | |
| 309 | enc := plain | |
| 310 | enc.Backup.AgeRecipients = []string{"age1x"} | |
| 311 | for _, c := range []struct { | |
| 312 | out string | |
| 313 | cfg config.Config | |
| 314 | want string | |
| 315 | }{ | |
| 316 | {"", plain, "gitbay-backup-20260927-090000.tar.gz"}, | |
| 317 | {"", enc, "gitbay-backup-20260927-090000.tar.gz.age"}, | |
| 318 | {"/b/x.tar.gz", enc, "/b/x.tar.gz.age"}, | |
| 319 | {"/b/x.tar.gz.age", enc, "/b/x.tar.gz.age"}, | |
| 320 | {"/b/x.tar.gz", plain, "/b/x.tar.gz"}, | |
| 321 | } { | |
| 322 | if got := archivePath(c.out, c.cfg, now); got != c.want { | |
| 323 | t.Errorf("archivePath(%q) = %q, want %q", c.out, got, c.want) | |
| 324 | } | |
| 325 | } | |
| 326 | } | |
deploy/cloud-init.yaml +5 −5
| @@ -96,7 +96,7 @@ write_files: | ||
| 96 | 96 | # archive and the newest database snapshot, in hours. |
| 97 | 97 | now=$(date -u +%s) |
| 98 | 98 | age_h() { |
| 99 | f=$(ls -t "$1"/*.tar.gz 2>/dev/null | head -1) | |
| 99 | f=$(ls -t "$1"/*.tar.gz "$1"/*.tar.gz.age 2>/dev/null | head -1) | |
| 100 | 100 | [ -n "$f" ] || { echo ""; return; } |
| 101 | 101 | echo $(( (now - $(stat -c %Y "$f")) / 3600 )) |
| 102 | 102 | } |
| @@ -251,12 +251,12 @@ write_files: | ||
| 251 | 251 | # Nightly consistent backup; keeps the last 7 locally. |
| 252 | 252 | # To ship offsite, add an rclone/s3 upload of $out here. |
| 253 | 253 | set -eu |
| 254 | # The archive carries the database, so it gets the database's mode. | |
| 254 | # gitbayd writes the archive 0600, owned by the backup user. | |
| 255 | 255 | umask 027 |
| 256 | 256 | dir=/var/backups/gitbay |
| 257 | 257 | out="$dir/gitbay-$(date -u +%Y%m%d-%H%M%S).tar.gz" |
| 258 | 258 | /usr/local/bin/gitbayd --config /etc/gitbay/config.toml admin backup --out "$out" |
| 259 | ls -1t "$dir"/gitbay-*.tar.gz | tail -n +8 | xargs -r rm -- | |
| 259 | ls -1t "$dir"/gitbay-*.tar.gz* | tail -n +8 | xargs -r rm -- | |
| 260 | 260 | |
| 261 | 261 | # Hourly database-only snapshot. The nightly full backup below is the one |
| 262 | 262 | # that can rebuild the host; this one exists because the database holds |
| @@ -267,14 +267,14 @@ write_files: | ||
| 267 | 267 | content: | |
| 268 | 268 | #!/bin/sh |
| 269 | 269 | set -eu |
| 270 | # The archive is the whole database, so it gets the database's mode. | |
| 270 | # gitbayd writes the archive 0600, owned by the backup user. | |
| 271 | 271 | umask 027 |
| 272 | 272 | dir=/var/backups/gitbay/db |
| 273 | 273 | mkdir -p "$dir" |
| 274 | 274 | chmod 0750 "$dir" |
| 275 | 275 | out="$dir/gitbay-db-$(date -u +%Y%m%d-%H%M%S).tar.gz" |
| 276 | 276 | /usr/local/bin/gitbayd --config /etc/gitbay/config.toml admin backup --db-only --out "$out" |
| 277 | ls -1t "$dir"/gitbay-db-*.tar.gz | tail -n +49 | xargs -r rm -- | |
| 277 | ls -1t "$dir"/gitbay-db-*.tar.gz* | tail -n +49 | xargs -r rm -- | |
| 278 | 278 | |
| 279 | 279 | - path: /etc/systemd/system/gitbay-db-backup.service |
| 280 | 280 | content: | |
go.mod +2
| @@ -3,6 +3,7 @@ module gitbay.org/gitbay | ||
| 3 | 3 | go 1.27.0 |
| 4 | 4 | |
| 5 | 5 | require ( |
| 6 | filippo.io/age v1.3.2 | |
| 6 | 7 | github.com/BurntSushi/toml v1.6.0 |
| 7 | 8 | github.com/ProtonMail/go-crypto v1.5.1 |
| 8 | 9 | github.com/alecthomas/chroma/v2 v2.27.0 |
| @@ -21,6 +22,7 @@ require ( | ||
| 21 | 22 | ) |
| 22 | 23 | |
| 23 | 24 | require ( |
| 25 | filippo.io/hpke v0.4.0 // indirect | |
| 24 | 26 | github.com/aymerick/douceur v0.2.0 // indirect |
| 25 | 27 | github.com/cloudflare/circl v1.6.3 // indirect |
| 26 | 28 | github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect |
go.sum +6
| @@ -1,3 +1,9 @@ | ||
| 1 | c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d h1:Blprhc2SbChNZtWcU+BLTM4YdoqYAS9V7cJgOwJKyAs= | |
| 2 | c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo= | |
| 3 | filippo.io/age v1.3.2 h1:r6RSZLFSMm6rzKepZ7ZAYkKCu14f3/Me8c7uKYh7C8c= | |
| 4 | filippo.io/age v1.3.2/go.mod h1:TH/Yr2sSRhCKbaH4XPxpUV0Us8Gv6txYUpiZQWz8Evk= | |
| 5 | filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A= | |
| 6 | filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY= | |
| 1 | 7 | github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= |
| 2 | 8 | github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= |
| 3 | 9 | github.com/ProtonMail/go-crypto v1.5.1 h1:pTrLDQHyOT8y3DFYIpijgPBTw/7E2GLMimutvOlceuE= |
internal/config/config.go +27
| @@ -14,6 +14,7 @@ import ( | ||
| 14 | 14 | "strings" |
| 15 | 15 | "time" |
| 16 | 16 | |
| 17 | "filippo.io/age" | |
| 17 | 18 | "github.com/BurntSushi/toml" |
| 18 | 19 | ) |
| 19 | 20 | |
| @@ -40,6 +41,7 @@ type Config struct { | ||
| 40 | 41 | Deps Deps `toml:"deps"` |
| 41 | 42 | Retention Retention `toml:"retention"` |
| 42 | 43 | Push Push `toml:"push"` |
| 44 | Backup Backup `toml:"backup"` | |
| 43 | 45 | // GoImport maps vanity Go module paths to repositories, e.g. |
| 44 | 46 | // "gitbay.org/gitbay" = "krz/gitbay". Requests carrying ?go-get=1 |
| 45 | 47 | // under a mapped path get a go-import meta tag. |
| @@ -297,6 +299,27 @@ func LoadAPNSKey(path string) (*ecdsa.PrivateKey, error) { | ||
| 297 | 299 | return key, nil |
| 298 | 300 | } |
| 299 | 301 | |
| 302 | // Backup configures gitbayd admin backup. | |
| 303 | type Backup struct { | |
| 304 | // AgeRecipients, when set, encrypts every archive to these age | |
| 305 | // public keys (age1...). The matching identities stay off the host, | |
| 306 | // so the host writes archives it cannot read. | |
| 307 | AgeRecipients []string `toml:"age_recipients"` | |
| 308 | } | |
| 309 | ||
| 310 | // Recipients parses AgeRecipients. | |
| 311 | func (b Backup) Recipients() ([]age.Recipient, error) { | |
| 312 | var rs []age.Recipient | |
| 313 | for _, s := range b.AgeRecipients { | |
| 314 | r, err := age.ParseX25519Recipient(s) | |
| 315 | if err != nil { | |
| 316 | return nil, fmt.Errorf("backup.age_recipients: %q: %w", s, err) | |
| 317 | } | |
| 318 | rs = append(rs, r) | |
| 319 | } | |
| 320 | return rs, nil | |
| 321 | } | |
| 322 | ||
| 300 | 323 | // Default returns the configuration used when a key is absent from the file. |
| 301 | 324 | func Default() Config { |
| 302 | 325 | return Config{ |
| @@ -479,6 +502,10 @@ func (c Config) Validate() error { | ||
| 479 | 502 | } |
| 480 | 503 | } |
| 481 | 504 | |
| 505 | if _, err := c.Backup.Recipients(); err != nil { | |
| 506 | errs = append(errs, err) | |
| 507 | } | |
| 508 | ||
| 482 | 509 | // Contradictions. |
| 483 | 510 | if c.Mail.SMTPHost != "" && c.Mail.From == "" { |
| 484 | 511 | errs = append(errs, errors.New("[mail] from is required when smtp_host is set")) |
internal/config/config_test.go +23
| @@ -10,6 +10,8 @@ import ( | ||
| 10 | 10 | "path/filepath" |
| 11 | 11 | "strings" |
| 12 | 12 | "testing" |
| 13 | ||
| 14 | "filippo.io/age" | |
| 13 | 15 | ) |
| 14 | 16 | |
| 15 | 17 | func writeConfig(t *testing.T, body string) string { |
| @@ -366,3 +368,24 @@ func TestSecretKeyFileSymlinks(t *testing.T) { | ||
| 366 | 368 | } |
| 367 | 369 | }) |
| 368 | 370 | } |
| 371 | ||
| 372 | func TestBackupRecipients(t *testing.T) { | |
| 373 | id, err := age.GenerateX25519Identity() | |
| 374 | if err != nil { | |
| 375 | t.Fatal(err) | |
| 376 | } | |
| 377 | cfg, err := Load(writeConfig(t, minimal+"[backup]\nage_recipients = [\""+id.Recipient().String()+"\"]\n")) | |
| 378 | if err != nil { | |
| 379 | t.Fatal(err) | |
| 380 | } | |
| 381 | rs, err := cfg.Backup.Recipients() | |
| 382 | if err != nil || len(rs) != 1 { | |
| 383 | t.Fatalf("Recipients = %v, %v", rs, err) | |
| 384 | } | |
| 385 | if _, err := Load(writeConfig(t, minimal+"[backup]\nage_recipients = [\"age1notakey\"]\n")); err == nil || !strings.Contains(err.Error(), "backup.age_recipients") { | |
| 386 | t.Fatalf("a malformed recipient: %v", err) | |
| 387 | } | |
| 388 | if cfg, err := Load(writeConfig(t, minimal)); err != nil || len(cfg.Backup.AgeRecipients) != 0 { | |
| 389 | t.Fatalf("default: %v, %v", cfg.Backup, err) | |
| 390 | } | |
| 391 | } | |
internal/sshd/revoke_test.go +5
| @@ -94,6 +94,11 @@ func TestRemoveKeyCutsConnection(t *testing.T) { | ||
| 94 | 94 | |
| 95 | 95 | func TestDisableCutsConnection(t *testing.T) { |
| 96 | 96 | ts := newTestServer(t) |
| 97 | // The client's handshake can finish before the server has recorded | |
| 98 | // the connection's account; a command answered proves it has. | |
| 99 | if code, errOut := execStatus(ts.client, "whoami"); code != 0 { | |
| 100 | t.Fatalf("whoami: %d %s", code, errOut) | |
| 101 | } | |
| 97 | 102 | if err := ts.st.SetUserDisabled(ts.uid, true); err != nil { |
| 98 | 103 | t.Fatal(err) |
| 99 | 104 | } |