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
179179pushes) is capped the same way the mail queue is, by =[retention]
180180push=.
181181
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
182191** [api]
183192- =enabled= (false) — the JSON API surface; see [[API]]. Off
184193 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
442451archive. A database-only archive is checked for integrity and says so.
443452Exit is non-zero on damage or a missing repository.
444453
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
445466Restore: extract into an empty directory, point =server.root= at it,
446467start gitbayd. Host keys are preserved, so clients keep their
447468known_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=).
4545| 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=) |
4646| 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=) |
4747| 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 |
4949| Disk | no application-level encryption; any disk encryption is the host's |
5050
5151The 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.
4848
4949| Item | Schedule | Kept | Contents |
5050|-----------------+----------+------+-----------------------------------------------------------------|
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 |
5353| Offsite (restic)| nightly | per prune policy | =/var/lib/gitbay= and a staged database copy, to object storage |
5454
5555- 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.
5858| TLS for all authenticated HTTP | in place | ACME or certificate files; HSTS |
5959| Secrets encrypted at rest | in place | AES-256-GCM, key file outside the database and the main backups (=internal/seal=) |
6060| 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 |
6262| Data retention configurable | in place | =[retention]= (=internal/config/config.go=) |
6363| User data export | in place | =account export= |
6464
.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.
1414| #260 | CI network | Builds share the runner's source address; no egress policy | medium |
1515| #261 | Various | Migration foreign-key check after commit; three web writes bypass dispatch; documentation drift | medium |
1616| #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 |
1918| #297 | Credentials | A browser session can mint tokens and keys that outlive it | low |
2019
2120* Not filed
CHANGELOG.org +3
@@ -161,6 +161,9 @@ secret.
161161 older server refuses the runner's =--step= and =--reason= (exit 2),
162162 and its failed builds stay running until the reaper fails them.
163163 (#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.
164167
165168* v1.36.0 — 2026-09-23
166169
cmd/gitbayd/backup.go +134 −18
@@ -2,6 +2,7 @@ package main
22
33import (
44 "archive/tar"
5 "bufio"
56 "compress/gzip"
67 "fmt"
78 "io"
@@ -11,6 +12,7 @@ import (
1112 "strings"
1213 "time"
1314
15 "filippo.io/age"
1416 "github.com/spf13/cobra"
1517
1618 "gitbay.org/gitbay/internal/config"
@@ -26,7 +28,7 @@ import (
2628// objects in the archive (harmless); the reverse order could leave database
2729// rows pointing at objects the archive never captured.
2830func backupCmd() *cobra.Command {
29 var out, verify string
31 var out, verify, identity string
3032 var dbOnly bool
3133 cmd := &cobra.Command{
3234 Use: "backup",
@@ -42,48 +44,93 @@ comments that exists nowhere else. Repositories are not in such an archive,
4244so it supplements a full backup and does not replace one.
4345
4446Restore: 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.`,
4652 RunE: func(cmd *cobra.Command, args []string) error {
4753 if verify != "" {
48 return verifyBackup(verify)
54 return verifyBackup(verify, identity)
4955 }
5056 cfg, err := config.Load(configPath)
5157 if err != nil {
5258 return err
5359 }
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)
5861 },
5962 }
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)")
6164 cmd.Flags().BoolVar(&dbOnly, "db-only", false, "archive the database snapshot alone, without repositories")
6265 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")
6367 return cmd
6468}
6569
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
6682func 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
6793 st, err := openStore(cfg)
6894 if err != nil {
6995 return err
7096 }
7197 defer st.Close()
7298
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")
77108 if err := snapshotDB(st, snap); err != nil {
78109 return fmt.Errorf("database snapshot: %w", err)
79110 }
80111
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-")
82115 if err != nil {
83116 return err
84117 }
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)
87134 tw := tar.NewWriter(gz)
88135
89136 if err := addFile(tw, snap, "gitbay.db"); err != nil {
@@ -137,9 +184,24 @@ func runBackup(cfg config.Config, out string, dbOnly bool) error {
137184 if err := gz.Close(); err != nil {
138185 return err
139186 }
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 }
140195 if err := f.Close(); err != nil {
141196 return err
142197 }
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 }
143205
144206 info, _ := os.Stat(out)
145207 if dbOnly {
@@ -150,6 +212,16 @@ func runBackup(cfg config.Config, out string, dbOnly bool) error {
150212 return nil
151213}
152214
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
153225// snapshotDB writes a consistent copy of the live database. VACUUM INTO
154226// takes a read snapshot, so concurrent daemon writes are safe under WAL.
155227func snapshotDB(st *store.Store, dest string) error {
@@ -180,17 +252,22 @@ func addFile(tw *tar.Writer, path, name string) error {
180252 return err
181253}
182254
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
184257// SQLite's integrity check, and every repository it names must be in the
185258// archive. A database-only archive is checked for integrity alone and
186259// says so. Nothing is written except a temporary copy of the database.
187func verifyBackup(path string) error {
260func verifyBackup(path, identity string) error {
188261 f, err := os.Open(path)
189262 if err != nil {
190263 return err
191264 }
192265 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)
194271 if err != nil {
195272 return fmt.Errorf("%s: not a gzip archive: %w", path, err)
196273 }
@@ -232,6 +309,13 @@ func verifyBackup(path string) error {
232309 }
233310 }
234311 }
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 }
235319 if dbPath == "" {
236320 return fmt.Errorf("%s: no gitbay.db in the archive", path)
237321 }
@@ -271,3 +355,35 @@ func verifyBackup(path string) error {
271355 }
272356 return nil
273357}
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
33import (
44 "archive/tar"
55 "compress/gzip"
6 "errors"
67 "io"
78 "os"
89 "path/filepath"
910 "sort"
11 "strings"
1012 "testing"
13 "time"
14
15 "filippo.io/age"
16
17 "gitbay.org/gitbay/internal/config"
1118)
1219
1320// members lists the archive's entries by name.
@@ -92,3 +99,228 @@ func TestBackupDBOnlyOmitsRepositories(t *testing.T) {
9299 t.Error("db-only backup is empty")
93100 }
94101}
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:
9696 # archive and the newest database snapshot, in hours.
9797 now=$(date -u +%s)
9898 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)
100100 [ -n "$f" ] || { echo ""; return; }
101101 echo $(( (now - $(stat -c %Y "$f")) / 3600 ))
102102 }
@@ -251,12 +251,12 @@ write_files:
251251 # Nightly consistent backup; keeps the last 7 locally.
252252 # To ship offsite, add an rclone/s3 upload of $out here.
253253 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.
255255 umask 027
256256 dir=/var/backups/gitbay
257257 out="$dir/gitbay-$(date -u +%Y%m%d-%H%M%S).tar.gz"
258258 /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 --
260260
261261 # Hourly database-only snapshot. The nightly full backup below is the one
262262 # that can rebuild the host; this one exists because the database holds
@@ -267,14 +267,14 @@ write_files:
267267 content: |
268268 #!/bin/sh
269269 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.
271271 umask 027
272272 dir=/var/backups/gitbay/db
273273 mkdir -p "$dir"
274274 chmod 0750 "$dir"
275275 out="$dir/gitbay-db-$(date -u +%Y%m%d-%H%M%S).tar.gz"
276276 /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 --
278278
279279 - path: /etc/systemd/system/gitbay-db-backup.service
280280 content: |
go.mod +2
@@ -3,6 +3,7 @@ module gitbay.org/gitbay
33go 1.27.0
44
55require (
6 filippo.io/age v1.3.2
67 github.com/BurntSushi/toml v1.6.0
78 github.com/ProtonMail/go-crypto v1.5.1
89 github.com/alecthomas/chroma/v2 v2.27.0
@@ -21,6 +22,7 @@ require (
2122)
2223
2324require (
25 filippo.io/hpke v0.4.0 // indirect
2426 github.com/aymerick/douceur v0.2.0 // indirect
2527 github.com/cloudflare/circl v1.6.3 // indirect
2628 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=
17github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
28github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
39github.com/ProtonMail/go-crypto v1.5.1 h1:pTrLDQHyOT8y3DFYIpijgPBTw/7E2GLMimutvOlceuE=
internal/config/config.go +27
@@ -14,6 +14,7 @@ import (
1414 "strings"
1515 "time"
1616
17 "filippo.io/age"
1718 "github.com/BurntSushi/toml"
1819)
1920
@@ -40,6 +41,7 @@ type Config struct {
4041 Deps Deps `toml:"deps"`
4142 Retention Retention `toml:"retention"`
4243 Push Push `toml:"push"`
44 Backup Backup `toml:"backup"`
4345 // GoImport maps vanity Go module paths to repositories, e.g.
4446 // "gitbay.org/gitbay" = "krz/gitbay". Requests carrying ?go-get=1
4547 // under a mapped path get a go-import meta tag.
@@ -297,6 +299,27 @@ func LoadAPNSKey(path string) (*ecdsa.PrivateKey, error) {
297299 return key, nil
298300}
299301
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
300323// Default returns the configuration used when a key is absent from the file.
301324func Default() Config {
302325 return Config{
@@ -479,6 +502,10 @@ func (c Config) Validate() error {
479502 }
480503 }
481504
505 if _, err := c.Backup.Recipients(); err != nil {
506 errs = append(errs, err)
507 }
508
482509 // Contradictions.
483510 if c.Mail.SMTPHost != "" && c.Mail.From == "" {
484511 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 (
1010 "path/filepath"
1111 "strings"
1212 "testing"
13
14 "filippo.io/age"
1315)
1416
1517func writeConfig(t *testing.T, body string) string {
@@ -366,3 +368,24 @@ func TestSecretKeyFileSymlinks(t *testing.T) {
366368 }
367369 })
368370}
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) {
9494
9595func TestDisableCutsConnection(t *testing.T) {
9696 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 }
97102 if err := ts.st.SetUserDisabled(ts.uid, true); err != nil {
98103 t.Fatal(err)
99104 }