backup: encrypt archives to age recipients !498

merged merged by cmc on 2026-09-28 22:41 UTC · krz/gitbay:backup-age into main

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
179pushes) is capped the same way the mail queue is, by =[retention] 179pushes) is capped the same way the mail queue is, by =[retention]
180push=. 180push=.
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** [api] 191** [api]
183- =enabled= (false) — the JSON API surface; see [[API]]. Off 192- =enabled= (false) — the JSON API surface; see [[API]]. Off
184 means no credential-bearing HTTP endpoint exists at all. 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
442archive. A database-only archive is checked for integrity and says so. 451archive. A database-only archive is checked for integrity and says so.
443Exit is non-zero on damage or a missing repository. 452Exit is non-zero on damage or a missing repository.
444 453
454With =[backup] age_recipients= set the archive is =<name>.tar.gz.age=
455and =--verify= needs the private key:
456
457#+begin_src sh
458gitbayd admin backup --verify gitbay-20260927-090000.tar.gz.age --identity ~/.config/gitbay/backup-identity.txt
459age -d -i ~/.config/gitbay/backup-identity.txt gitbay-20260927-090000.tar.gz.age | tar -xz -C /new/root
460#+end_src
461
462The identity lives off the host (with the secret key file and the
463restic credentials), so verifying an encrypted archive happens there
464or on a restore host.
465
445Restore: extract into an empty directory, point =server.root= at it, 466Restore: extract into an empty directory, point =server.root= at it,
446start gitbayd. Host keys are preserved, so clients keep their 467start gitbayd. Host keys are preserved, so clients keep their
447known_hosts entries; hooks regenerate at startup. 468known_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| 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=) | 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| 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=) | 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| SQLite file | mode 0640, directory 0750 | 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| Disk | no application-level encryption; any disk encryption is the host's | 49| Disk | no application-level encryption; any disk encryption is the host's |
50 50
51The database file or a backup read by anyone other than the =gitbay= 51The 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| Item | Schedule | Kept | Contents | 49| Item | Schedule | Kept | Contents |
50|-----------------+----------+------+-----------------------------------------------------------------| 50|-----------------+----------+------+-----------------------------------------------------------------|
51| Full archive | nightly | 7 | SQLite snapshot (=VACUUM INTO=), all repositories, LFS, SSH host keys | 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 | 52| Database only | hourly | 48 | SQLite snapshot; age-encrypted when =[backup] age_recipients= is set |
53| Offsite (restic)| nightly | per prune policy | =/var/lib/gitbay= and a staged database copy, to object storage | 53| Offsite (restic)| nightly | per prune policy | =/var/lib/gitbay= and a staged database copy, to object storage |
54 54
55- The database snapshot is taken before repositories are read, so a 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| TLS for all authenticated HTTP | in place | ACME or certificate files; HSTS | 58| TLS for all authenticated HTTP | in place | ACME or certificate files; HSTS |
59| Secrets encrypted at rest | in place | AES-256-GCM, key file outside the database and the main backups (=internal/seal=) | 59| Secrets encrypted at rest | in place | AES-256-GCM, key file outside the database and the main backups (=internal/seal=) |
60| Secrets kept out of argv, logs and output | in place | =ReadsStdin=, pruned audit argv, write-only secret commands | 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| Data retention configurable | in place | =[retention]= (=internal/config/config.go=) | 62| Data retention configurable | in place | =[retention]= (=internal/config/config.go=) |
63| User data export | in place | =account export= | 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| #260 | CI network | Builds share the runner's source address; no egress policy | medium | 14| #260 | CI network | Builds share the runner's source address; no egress policy | medium |
15| #261 | Various | Migration foreign-key check after commit; three web writes bypass dispatch; documentation drift | medium | 15| #261 | Various | Migration foreign-key check after commit; three web writes bypass dispatch; documentation drift | medium |
16| #262 | Availability | No limit on concurrent git pack generation | high | 16| #262 | Availability | No limit on concurrent git pack generation | high |
17| #274 | Backups | The local backup archive is not encrypted | medium | 17| #298 | SSRF | =repo import --from= fetches without an address check | medium |
18| #298 | SSRF | =repo import --from= fetches without an address check | medium |
19| #297 | Credentials | A browser session can mint tokens and keys that outlive it | low | 18| #297 | Credentials | A browser session can mint tokens and keys that outlive it | low |
20 19
21* Not filed 20* Not filed
CHANGELOG.org +3
@@ -161,6 +161,9 @@ secret.
161 older server refuses the runner's =--step= and =--reason= (exit 2), 161 older server refuses the runner's =--step= and =--reason= (exit 2),
162 and its failed builds stay running until the reaper fails them. 162 and its failed builds stay running until the reaper fails them.
163 (#266) 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* v1.36.0 — 2026-09-23 168* v1.36.0 — 2026-09-23
166 169
cmd/gitbayd/backup.go +134 −18
@@ -2,6 +2,7 @@ package main
2 2
3import ( 3import (
4 "archive/tar" 4 "archive/tar"
5 "bufio"
5 "compress/gzip" 6 "compress/gzip"
6 "fmt" 7 "fmt"
7 "io" 8 "io"
@@ -11,6 +12,7 @@ import (
11 "strings" 12 "strings"
12 "time" 13 "time"
13 14
15 "filippo.io/age"
14 "github.com/spf13/cobra" 16 "github.com/spf13/cobra"
15 17
16 "gitbay.org/gitbay/internal/config" 18 "gitbay.org/gitbay/internal/config"
@@ -26,7 +28,7 @@ import (
26// objects in the archive (harmless); the reverse order could leave database 28// objects in the archive (harmless); the reverse order could leave database
27// rows pointing at objects the archive never captured. 29// rows pointing at objects the archive never captured.
28func backupCmd() *cobra.Command { 30func backupCmd() *cobra.Command {
29 var out, verify string 31 var out, verify, identity string
30 var dbOnly bool 32 var dbOnly bool
31 cmd := &cobra.Command{ 33 cmd := &cobra.Command{
32 Use: "backup", 34 Use: "backup",
@@ -42,48 +44,93 @@ comments that exists nowhere else. Repositories are not in such an archive,
42so it supplements a full backup and does not replace one. 44so it supplements a full backup and does not replace one.
43 45
44Restore: extract into an empty directory, point server.root at it, start 46Restore: extract into an empty directory, point server.root at it, start
45gitbayd. Host keys are preserved, so clients keep their known_hosts entries.`, 47gitbayd. Host keys are preserved, so clients keep their known_hosts entries.
48
49With [backup] age_recipients set, the archive is encrypted to those age
50public 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 RunE: func(cmd *cobra.Command, args []string) error { 52 RunE: func(cmd *cobra.Command, args []string) error {
47 if verify != "" { 53 if verify != "" {
48 return verifyBackup(verify) 54 return verifyBackup(verify, identity)
49 } 55 }
50 cfg, err := config.Load(configPath) 56 cfg, err := config.Load(configPath)
51 if err != nil { 57 if err != nil {
52 return err 58 return err
53 } 59 }
54 if out == "" { 60 return runBackup(cfg, archivePath(out, cfg, time.Now()), dbOnly)
55 out = fmt.Sprintf("gitbay-backup-%s.tar.gz", time.Now().UTC().Format("20060102-150405"))
56 }
57 return runBackup(cfg, out, 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 cmd.Flags().BoolVar(&dbOnly, "db-only", false, "archive the database snapshot alone, without repositories") 64 cmd.Flags().BoolVar(&dbOnly, "db-only", false, "archive the database snapshot alone, without repositories")
62 cmd.Flags().StringVar(&verify, "verify", "", "check an archive instead of writing one: database integrity, and its repositories against the archive's") 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 return cmd 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.
72func 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
66func runBackup(cfg config.Config, out string, dbOnly bool) error { 82func 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 st, err := openStore(cfg) 93 st, err := openStore(cfg)
68 if err != nil { 94 if err != nil {
69 return err 95 return err
70 } 96 }
71 defer st.Close() 97 defer st.Close()
72 98
73 // 1. Consistent database snapshot, before any repository is read. 99 // 1. Consistent database snapshot, before any repository is read. It
74 snap := filepath.Join(os.TempDir(), fmt.Sprintf("gitbay-snap-%d.db", os.Getpid())) 100 // goes in a fresh 0700 directory beside the archive.
75 os.Remove(snap) 101 dir := filepath.Dir(out)
76 defer os.Remove(snap) 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 if err := snapshotDB(st, snap); err != nil { 108 if err := snapshotDB(st, snap); err != nil {
78 return fmt.Errorf("database snapshot: %w", err) 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 if err != nil { 115 if err != nil {
83 return err 116 return err
84 } 117 }
85 defer f.Close() 118 done := false
86 gz := gzip.NewWriter(f) 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 tw := tar.NewWriter(gz) 134 tw := tar.NewWriter(gz)
88 135
89 if err := addFile(tw, snap, "gitbay.db"); err != nil { 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 if err := gz.Close(); err != nil { 184 if err := gz.Close(); err != nil {
138 return err 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 if err := f.Close(); err != nil { 195 if err := f.Close(); err != nil {
141 return err 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 info, _ := os.Stat(out) 206 info, _ := os.Stat(out)
145 if dbOnly { 207 if dbOnly {
@@ -150,6 +212,16 @@ func runBackup(cfg config.Config, out string, dbOnly bool) error {
150 return nil 212 return nil
151} 213}
152 214
215// syncDir makes a rename in dir durable.
216func 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// snapshotDB writes a consistent copy of the live database. VACUUM INTO 225// snapshotDB writes a consistent copy of the live database. VACUUM INTO
154// takes a read snapshot, so concurrent daemon writes are safe under WAL. 226// takes a read snapshot, so concurrent daemon writes are safe under WAL.
155func snapshotDB(st *store.Store, dest string) error { 227func snapshotDB(st *store.Store, dest string) error {
@@ -180,17 +252,22 @@ func addFile(tw *tar.Writer, path, name string) error {
180 return err 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// SQLite's integrity check, and every repository it names must be in the 257// SQLite's integrity check, and every repository it names must be in the
185// archive. A database-only archive is checked for integrity alone and 258// archive. A database-only archive is checked for integrity alone and
186// says so. Nothing is written except a temporary copy of the database. 259// says so. Nothing is written except a temporary copy of the database.
187func verifyBackup(path string) error { 260func verifyBackup(path, identity string) error {
188 f, err := os.Open(path) 261 f, err := os.Open(path)
189 if err != nil { 262 if err != nil {
190 return err 263 return err
191 } 264 }
192 defer f.Close() 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 if err != nil { 271 if err != nil {
195 return fmt.Errorf("%s: not a gzip archive: %w", path, err) 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 if dbPath == "" { 319 if dbPath == "" {
236 return fmt.Errorf("%s: no gitbay.db in the archive", path) 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 return nil 356 return nil
273} 357}
358
359const 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.
363func 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
3import ( 3import (
4 "archive/tar" 4 "archive/tar"
5 "compress/gzip" 5 "compress/gzip"
6 "errors"
6 "io" 7 "io"
7 "os" 8 "os"
8 "path/filepath" 9 "path/filepath"
9 "sort" 10 "sort"
11 "strings"
10 "testing" 12 "testing"
13 "time"
14
15 "filippo.io/age"
16
17 "gitbay.org/gitbay/internal/config"
11) 18)
12 19
13// members lists the archive's entries by name. 20// members lists the archive's entries by name.
@@ -92,3 +99,228 @@ func TestBackupDBOnlyOmitsRepositories(t *testing.T) {
92 t.Error("db-only backup is empty") 99 t.Error("db-only backup is empty")
93 } 100 }
94} 101}
102
103func 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.
159func 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.
176func 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
239func 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.
250func 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
306func 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 # archive and the newest database snapshot, in hours. 96 # archive and the newest database snapshot, in hours.
97 now=$(date -u +%s) 97 now=$(date -u +%s)
98 age_h() { 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 [ -n "$f" ] || { echo ""; return; } 100 [ -n "$f" ] || { echo ""; return; }
101 echo $(( (now - $(stat -c %Y "$f")) / 3600 )) 101 echo $(( (now - $(stat -c %Y "$f")) / 3600 ))
102 } 102 }
@@ -251,12 +251,12 @@ write_files:
251 # Nightly consistent backup; keeps the last 7 locally. 251 # Nightly consistent backup; keeps the last 7 locally.
252 # To ship offsite, add an rclone/s3 upload of $out here. 252 # To ship offsite, add an rclone/s3 upload of $out here.
253 set -eu 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 umask 027 255 umask 027
256 dir=/var/backups/gitbay 256 dir=/var/backups/gitbay
257 out="$dir/gitbay-$(date -u +%Y%m%d-%H%M%S).tar.gz" 257 out="$dir/gitbay-$(date -u +%Y%m%d-%H%M%S).tar.gz"
258 /usr/local/bin/gitbayd --config /etc/gitbay/config.toml admin backup --out "$out" 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 # Hourly database-only snapshot. The nightly full backup below is the one 261 # Hourly database-only snapshot. The nightly full backup below is the one
262 # that can rebuild the host; this one exists because the database holds 262 # that can rebuild the host; this one exists because the database holds
@@ -267,14 +267,14 @@ write_files:
267 content: | 267 content: |
268 #!/bin/sh 268 #!/bin/sh
269 set -eu 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 umask 027 271 umask 027
272 dir=/var/backups/gitbay/db 272 dir=/var/backups/gitbay/db
273 mkdir -p "$dir" 273 mkdir -p "$dir"
274 chmod 0750 "$dir" 274 chmod 0750 "$dir"
275 out="$dir/gitbay-db-$(date -u +%Y%m%d-%H%M%S).tar.gz" 275 out="$dir/gitbay-db-$(date -u +%Y%m%d-%H%M%S).tar.gz"
276 /usr/local/bin/gitbayd --config /etc/gitbay/config.toml admin backup --db-only --out "$out" 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 - path: /etc/systemd/system/gitbay-db-backup.service 279 - path: /etc/systemd/system/gitbay-db-backup.service
280 content: | 280 content: |
go.mod +2
@@ -3,6 +3,7 @@ module gitbay.org/gitbay
3go 1.27.0 3go 1.27.0
4 4
5require ( 5require (
6 filippo.io/age v1.3.2
6 github.com/BurntSushi/toml v1.6.0 7 github.com/BurntSushi/toml v1.6.0
7 github.com/ProtonMail/go-crypto v1.5.1 8 github.com/ProtonMail/go-crypto v1.5.1
8 github.com/alecthomas/chroma/v2 v2.27.0 9 github.com/alecthomas/chroma/v2 v2.27.0
@@ -21,6 +22,7 @@ require (
21) 22)
22 23
23require ( 24require (
25 filippo.io/hpke v0.4.0 // indirect
24 github.com/aymerick/douceur v0.2.0 // indirect 26 github.com/aymerick/douceur v0.2.0 // indirect
25 github.com/cloudflare/circl v1.6.3 // indirect 27 github.com/cloudflare/circl v1.6.3 // indirect
26 github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect 28 github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
go.sum +6
@@ -1,3 +1,9 @@
1c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d h1:Blprhc2SbChNZtWcU+BLTM4YdoqYAS9V7cJgOwJKyAs=
2c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo=
3filippo.io/age v1.3.2 h1:r6RSZLFSMm6rzKepZ7ZAYkKCu14f3/Me8c7uKYh7C8c=
4filippo.io/age v1.3.2/go.mod h1:TH/Yr2sSRhCKbaH4XPxpUV0Us8Gv6txYUpiZQWz8Evk=
5filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A=
6filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY=
1github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= 7github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
2github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 8github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
3github.com/ProtonMail/go-crypto v1.5.1 h1:pTrLDQHyOT8y3DFYIpijgPBTw/7E2GLMimutvOlceuE= 9github.com/ProtonMail/go-crypto v1.5.1 h1:pTrLDQHyOT8y3DFYIpijgPBTw/7E2GLMimutvOlceuE=
internal/config/config.go +27
@@ -14,6 +14,7 @@ import (
14 "strings" 14 "strings"
15 "time" 15 "time"
16 16
17 "filippo.io/age"
17 "github.com/BurntSushi/toml" 18 "github.com/BurntSushi/toml"
18) 19)
19 20
@@ -40,6 +41,7 @@ type Config struct {
40 Deps Deps `toml:"deps"` 41 Deps Deps `toml:"deps"`
41 Retention Retention `toml:"retention"` 42 Retention Retention `toml:"retention"`
42 Push Push `toml:"push"` 43 Push Push `toml:"push"`
44 Backup Backup `toml:"backup"`
43 // GoImport maps vanity Go module paths to repositories, e.g. 45 // GoImport maps vanity Go module paths to repositories, e.g.
44 // "gitbay.org/gitbay" = "krz/gitbay". Requests carrying ?go-get=1 46 // "gitbay.org/gitbay" = "krz/gitbay". Requests carrying ?go-get=1
45 // under a mapped path get a go-import meta tag. 47 // under a mapped path get a go-import meta tag.
@@ -297,6 +299,27 @@ func LoadAPNSKey(path string) (*ecdsa.PrivateKey, error) {
297 return key, nil 299 return key, nil
298} 300}
299 301
302// Backup configures gitbayd admin backup.
303type 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.
311func (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// Default returns the configuration used when a key is absent from the file. 323// Default returns the configuration used when a key is absent from the file.
301func Default() Config { 324func Default() Config {
302 return Config{ 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 // Contradictions. 509 // Contradictions.
483 if c.Mail.SMTPHost != "" && c.Mail.From == "" { 510 if c.Mail.SMTPHost != "" && c.Mail.From == "" {
484 errs = append(errs, errors.New("[mail] from is required when smtp_host is set")) 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 "path/filepath" 10 "path/filepath"
11 "strings" 11 "strings"
12 "testing" 12 "testing"
13
14 "filippo.io/age"
13) 15)
14 16
15func writeConfig(t *testing.T, body string) string { 17func writeConfig(t *testing.T, body string) string {
@@ -366,3 +368,24 @@ func TestSecretKeyFileSymlinks(t *testing.T) {
366 } 368 }
367 }) 369 })
368} 370}
371
372func 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
95func TestDisableCutsConnection(t *testing.T) { 95func TestDisableCutsConnection(t *testing.T) {
96 ts := newTestServer(t) 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 if err := ts.st.SetUserDisabled(ts.uid, true); err != nil { 102 if err := ts.st.SetUserDisabled(ts.uid, true); err != nil {
98 t.Fatal(err) 103 t.Fatal(err)
99 } 104 }