A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit fff6fb16e4

fff6fb16e4a7173ab00ffc469b90b1223d1297f1

parent: 2480262fcd

Verified · cmc ci/build: success ci/test: success ci/vuln: success

cmc <hello@cleberg.net> · 2026-09-01T04:39:02Z

Make host monitoring speak, scan for vulnerabilities in CI, snapshot the database hourly

The three items #28 was narrowed to.

gitbay-monitor.sh exited 0 at its second line whenever /etc/gitbay/monitor.url
was absent, which it has been on bay1 since the script landed: disk, service
and cert results were computed hourly and dropped, and an unset webhook looked
exactly like a healthy host. It now writes the reading to journald every run,
says so when a webhook post fails instead of swallowing it, and exits non-zero
on an alert so the unit surfaces in systemctl --failed. The webhook is a second
channel rather than the only one.

govulncheck lived only in deploy/audit.sh, which is run by hand. It is a CI job
now, separate from test so an advisory published against unchanged code cannot
be mistaken for a test failure.

store.Open chmods the database to 0640. The directory above it is 0750, so 0644
was not a live exposure, but the file carries token hashes and addresses.
SQLite gives the -wal and -shm files the main file's mode, so all three follow.

admin backup --db-only writes the SQLite snapshot without the repositories,
and gitbay-db-backup.timer runs it hourly keeping 48. The full nightly archive
and its 7-day retention are untouched. Hourly full archives would have been
550MB each, and holding the existing 7-day window at that rate needs 92GB;
splitting it costs ~250MB and puts the tighter recovery point on the data that
warrants it, since repositories are git and have other copies while issues,
merge requests and comments do not. Continuous replication was considered and
rejected: it would leave the repositories on the nightly archive, so a restore
could produce a database referencing commits the repository backup lacks.
Recorded on the wiki's Admin page.

Ref #28
.gitbay/ci.yml +8
@@ -15,3 +15,11 @@ jobs:
1515 steps:
1616 - missing=""; for t in git git-lfs gpg; do command -v "$t" >/dev/null || missing="$missing $t"; done; test -x /usr/sbin/sshd || missing="$missing sshd"; test -z "$missing" || { echo "runner is missing:$missing"; exit 1; }
1717 - go test ./... -count=1 -timeout 20m
18 # Vulnerability scanning, separate from test so a newly published advisory
19 # against unchanged code does not mask a real test failure. It fails the
20 # build on purpose: an advisory that only lands in a report nobody reads is
21 # the state this replaced. @latest matches deploy/audit.sh, so a run scans
22 # against the database as it is today, not as it was at commit time.
23 vuln:
24 steps:
25 - go run golang.org/x/vuln/cmd/govulncheck@latest ./...
cmd/gitbayd/backup.go +44 −29
@@ -27,6 +27,7 @@ import (
2727// rows pointing at objects the archive never captured.
2828func backupCmd() *cobra.Command {
2929 var out string
30 var dbOnly bool
3031 cmd := &cobra.Command{
3132 Use: "backup",
3233 Short: "write a consistent backup archive (database snapshot first, then repositories)",
@@ -34,6 +35,12 @@ func backupCmd() *cobra.Command {
3435all repositories, and the SSH host keys. Transient state (hook socket,
3536regenerated hook scripts, askpass helper, WAL files) is excluded.
3637
38--db-only writes the database snapshot alone. It is seconds and megabytes
39rather than minutes and gigabytes, which is what makes a frequent schedule
40affordable, and the database is the copy of issues, merge requests and
41comments that exists nowhere else. Repositories are not in such an archive,
42so it supplements a full backup and does not replace one.
43
3744Restore: extract into an empty directory, point server.root at it, start
3845gitbayd. Host keys are preserved, so clients keep their known_hosts entries.`,
3946 RunE: func(cmd *cobra.Command, args []string) error {
@@ -44,14 +51,15 @@ gitbayd. Host keys are preserved, so clients keep their known_hosts entries.`,
4451 if out == "" {
4552 out = fmt.Sprintf("gitbay-backup-%s.tar.gz", time.Now().UTC().Format("20060102-150405"))
4653 }
47 return runBackup(cfg, out)
54 return runBackup(cfg, out, dbOnly)
4855 },
4956 }
5057 cmd.Flags().StringVar(&out, "out", "", "output archive path (default gitbay-backup-<utc timestamp>.tar.gz)")
58 cmd.Flags().BoolVar(&dbOnly, "db-only", false, "archive the database snapshot alone, without repositories")
5159 return cmd
5260}
5361
54func runBackup(cfg config.Config, out string) error {
62func runBackup(cfg config.Config, out string, dbOnly bool) error {
5563 st, err := openStore(cfg)
5664 if err != nil {
5765 return err
@@ -79,42 +87,45 @@ func runBackup(cfg config.Config, out string) error {
7987 }
8088
8189 // 2. Everything under the root except transient or regenerated state.
90 // Skipped entirely for --db-only.
8291 skip := map[string]bool{
8392 "gitbay.db": true, "gitbay.db-wal": true, "gitbay.db-shm": true,
8493 "hook.sock": true, "askpass.sh": true, "hooks": true,
8594 }
8695 repoCount := 0
8796 root := cfg.Server.Root
88 err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
89 if err != nil {
90 return err
91 }
92 rel, err := filepath.Rel(root, path)
93 if err != nil {
94 return err
95 }
96 if rel == "." {
97 return nil
98 }
99 if top, _, _ := strings.Cut(rel, string(filepath.Separator)); skip[top] {
100 if d.IsDir() {
101 return filepath.SkipDir
97 if !dbOnly {
98 err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
99 if err != nil {
100 return err
102101 }
103 return nil
104 }
105 if !d.Type().IsRegular() && !d.IsDir() {
106 return nil // sockets, symlinks
107 }
108 if d.IsDir() {
109 if strings.HasSuffix(rel, ".git") {
110 repoCount++
102 rel, err := filepath.Rel(root, path)
103 if err != nil {
104 return err
105 }
106 if rel == "." {
107 return nil
108 }
109 if top, _, _ := strings.Cut(rel, string(filepath.Separator)); skip[top] {
110 if d.IsDir() {
111 return filepath.SkipDir
112 }
113 return nil
114 }
115 if !d.Type().IsRegular() && !d.IsDir() {
116 return nil // sockets, symlinks
117 }
118 if d.IsDir() {
119 if strings.HasSuffix(rel, ".git") {
120 repoCount++
121 }
122 return nil // directories are implied by member paths
111123 }
112 return nil // directories are implied by member paths
124 return addFile(tw, path, filepath.ToSlash(rel))
125 })
126 if err != nil {
127 return err
113128 }
114 return addFile(tw, path, filepath.ToSlash(rel))
115 })
116 if err != nil {
117 return err
118129 }
119130 if err := tw.Close(); err != nil {
120131 return err
@@ -127,6 +138,10 @@ func runBackup(cfg config.Config, out string) error {
127138 }
128139
129140 info, _ := os.Stat(out)
141 if dbOnly {
142 fmt.Printf("wrote %s (database only, %.1f MB)\n", out, float64(info.Size())/1e6)
143 return nil
144 }
130145 fmt.Printf("wrote %s (%d repositories, %.1f MB)\n", out, repoCount, float64(info.Size())/1e6)
131146 return nil
132147}
cmd/gitbayd/backup_test.go added +96
@@ -0,0 +1,96 @@
1package main
2
3import (
4 "archive/tar"
5 "compress/gzip"
6 "io"
7 "os"
8 "path/filepath"
9 "sort"
10 "testing"
11
12 "gitbay.org/gitbay/internal/config"
13)
14
15// members lists the archive's entries by name.
16func members(t *testing.T, path string) []string {
17 t.Helper()
18 f, err := os.Open(path)
19 if err != nil {
20 t.Fatal(err)
21 }
22 defer f.Close()
23 gz, err := gzip.NewReader(f)
24 if err != nil {
25 t.Fatal(err)
26 }
27 var names []string
28 tr := tar.NewReader(gz)
29 for {
30 hdr, err := tr.Next()
31 if err == io.EOF {
32 break
33 }
34 if err != nil {
35 t.Fatal(err)
36 }
37 names = append(names, hdr.Name)
38 }
39 sort.Strings(names)
40 return names
41}
42
43// --db-only is what makes an hourly schedule affordable, so it has to leave
44// the repositories out and still carry a restorable database.
45func TestBackupDBOnlyOmitsRepositories(t *testing.T) {
46 root := t.TempDir()
47 cfg := config.Config{Server: config.Server{Root: root}}
48 s, err := openStore(cfg)
49 if err != nil {
50 t.Fatal(err)
51 }
52 s.Close()
53
54 repo := filepath.Join(root, "repos", "krz", "thing.git")
55 if err := os.MkdirAll(repo, 0o750); err != nil {
56 t.Fatal(err)
57 }
58 if err := os.WriteFile(filepath.Join(repo, "HEAD"), []byte("ref: refs/heads/main\n"), 0o640); err != nil {
59 t.Fatal(err)
60 }
61
62 full := filepath.Join(t.TempDir(), "full.tar.gz")
63 if err := runBackup(cfg, full, false); err != nil {
64 t.Fatalf("full backup: %v", err)
65 }
66 dbOnly := filepath.Join(t.TempDir(), "db.tar.gz")
67 if err := runBackup(cfg, dbOnly, true); err != nil {
68 t.Fatalf("db-only backup: %v", err)
69 }
70
71 fullNames := members(t, full)
72 if len(fullNames) < 2 {
73 t.Fatalf("full backup carries only %v", fullNames)
74 }
75 var sawRepo bool
76 for _, n := range fullNames {
77 if n == "repos/krz/thing.git/HEAD" {
78 sawRepo = true
79 }
80 }
81 if !sawRepo {
82 t.Errorf("full backup is missing the repository: %v", fullNames)
83 }
84
85 if got := members(t, dbOnly); len(got) != 1 || got[0] != "gitbay.db" {
86 t.Errorf("db-only backup carries %v, want [gitbay.db]", got)
87 }
88
89 fi, err := os.Stat(dbOnly)
90 if err != nil {
91 t.Fatal(err)
92 }
93 if fi.Size() == 0 {
94 t.Error("db-only backup is empty")
95 }
96}
deploy/cloud-init.yaml +63 −12
@@ -57,16 +57,14 @@ write_files:
5757 maxretry = 5
5858 bantime = 1h
5959
60 # Heartbeat: post disk/service/cert status to a webhook if one is set in
61 # /etc/gitbay/monitor.url. Silent when the file is absent.
60 # Heartbeat: disk/service/cert status to journald every run, and to a
61 # webhook as well if one is set in /etc/gitbay/monitor.url. Exits non-zero
62 # on an alert so the unit shows up in systemctl --failed.
6263 - path: /usr/local/bin/gitbay-monitor.sh
6364 permissions: "0755"
6465 content: |
6566 #!/bin/sh
6667 set -eu
67 url_file=/etc/gitbay/monitor.url
68 [ -f "$url_file" ] || exit 0
69 url=$(cat "$url_file")
7068 disk=$(df -P /var/lib/gitbay | awk 'NR==2{print $5}')
7169 svc=$(systemctl is-active gitbayd || true)
7270 # Days until the ACME cert expires, if autocert cached one.
@@ -74,14 +72,32 @@ write_files:
7472 exp="n/a"
7573 if [ -d "$cert" ]; then
7674 f=$(ls -1 "$cert" 2>/dev/null | grep -v acme_account | head -1 || true)
77 [ -n "$f" ] && exp=$(openssl x509 -enddate -noout -in "$cert/$f" 2>/dev/null | cut -d= -f2 || echo n/a)
75 if [ -n "$f" ]; then
76 exp=$(openssl x509 -enddate -noout -in "$cert/$f" 2>/dev/null | cut -d= -f2 || echo n/a)
77 fi
7878 fi
7979 alert=""
80 [ "$svc" != "active" ] && alert="gitbayd is $svc; "
80 if [ "$svc" != "active" ]; then
81 alert="gitbayd is $svc; "
82 fi
8183 pct=$(echo "$disk" | tr -d '%')
82 [ "$pct" -ge 85 ] && alert="${alert}disk ${disk}; "
83 body=$(printf '{"disk":"%s","service":"%s","cert_expires":"%s","alert":"%s"}' "$disk" "$svc" "$exp" "$alert")
84 curl -fsS -m 10 -H 'Content-Type: application/json' -d "$body" "$url" >/dev/null 2>&1 || true
84 if [ "$pct" -ge 85 ]; then
85 alert="${alert}disk ${disk}; "
86 fi
87 # journald always gets the reading, so an unset webhook cannot make a
88 # sick host look like a quiet one.
89 echo "disk=$disk service=$svc cert_expires=$exp"
90 url_file=/etc/gitbay/monitor.url
91 if [ -f "$url_file" ]; then
92 body=$(printf '{"disk":"%s","service":"%s","cert_expires":"%s","alert":"%s"}' "$disk" "$svc" "$exp" "$alert")
93 if ! curl -fsS -m 10 -H 'Content-Type: application/json' -d "$body" "$(cat "$url_file")" >/dev/null; then
94 echo "monitor webhook post failed" >&2
95 fi
96 fi
97 if [ -n "$alert" ]; then
98 echo "$alert" >&2
99 exit 1
100 fi
85101
86102 - path: /etc/systemd/system/gitbay-monitor.service
87103 content: |
@@ -186,6 +202,41 @@ write_files:
186202 /usr/local/bin/gitbayd --config /etc/gitbay/config.toml admin backup --out "$out"
187203 ls -1t "$dir"/gitbay-*.tar.gz | tail -n +8 | xargs -r rm --
188204
205 # Hourly database-only snapshot. The nightly full backup below is the one
206 # that can rebuild the host; this one exists because the database holds
207 # issues, merge requests and comments, which unlike the repositories have
208 # no second copy anywhere. 48 of them is two days at a few MB each.
209 - path: /usr/local/bin/gitbay-db-backup.sh
210 permissions: "0755"
211 content: |
212 #!/bin/sh
213 set -eu
214 dir=/var/backups/gitbay/db
215 mkdir -p "$dir"
216 out="$dir/gitbay-db-$(date -u +%Y%m%d-%H%M%S).tar.gz"
217 /usr/local/bin/gitbayd --config /etc/gitbay/config.toml admin backup --db-only --out "$out"
218 ls -1t "$dir"/gitbay-db-*.tar.gz | tail -n +49 | xargs -r rm --
219
220 - path: /etc/systemd/system/gitbay-db-backup.service
221 content: |
222 [Unit]
223 Description=gitbay hourly database backup
224 [Service]
225 Type=oneshot
226 User=gitbay
227 ExecStart=/usr/local/bin/gitbay-db-backup.sh
228
229 - path: /etc/systemd/system/gitbay-db-backup.timer
230 content: |
231 [Unit]
232 Description=gitbay hourly database backup
233 [Timer]
234 OnCalendar=*-*-* *:20:00 UTC
235 RandomizedDelaySec=5m
236 Persistent=true
237 [Install]
238 WantedBy=timers.target
239
189240 - path: /etc/systemd/system/gitbay-backup.service
190241 content: |
191242 [Unit]
@@ -237,6 +288,6 @@ runcmd:
237288 - ufw --force enable
238289 - systemctl daemon-reload
239290 - systemctl restart ssh.socket || systemctl restart ssh
240 - systemctl enable gitbayd gitbay-backup.timer gitbay-gc.timer gitbay-monitor.timer
241 - systemctl start gitbay-backup.timer gitbay-gc.timer gitbay-monitor.timer
291 - systemctl enable gitbayd gitbay-backup.timer gitbay-db-backup.timer gitbay-gc.timer gitbay-monitor.timer
292 - systemctl start gitbay-backup.timer gitbay-db-backup.timer gitbay-gc.timer gitbay-monitor.timer
242293 - systemctl enable --now unattended-upgrades fail2ban
internal/store/store.go +12
@@ -4,8 +4,10 @@ package store
44import (
55 "database/sql"
66 "embed"
7 "errors"
78 "fmt"
89 "io/fs"
10 "os"
911 "sort"
1012 "strconv"
1113 "strings"
@@ -35,6 +37,16 @@ func Open(path string) (*Store, error) {
3537 db.Close()
3638 return nil, err
3739 }
40 // SQLite creates the file 0666&~umask, so it lands 0644 by default. The
41 // directory above it is the real boundary, but the file holds token
42 // hashes, addresses and private repo names and has no business being
43 // world-readable on its own.
44 if path != ":memory:" {
45 if err := os.Chmod(path, 0o640); err != nil && !errors.Is(err, fs.ErrNotExist) {
46 db.Close()
47 return nil, err
48 }
49 }
3850 return &Store{DB: db}, nil
3951}
4052
internal/store/store_test.go +19
@@ -1,6 +1,7 @@
11package store
22
33import (
4 "os"
45 "path/filepath"
56 "strings"
67 "testing"
@@ -96,3 +97,21 @@ func TestForeignKeysEnforced(t *testing.T) {
9697 t.Fatal("insert with dangling user_id succeeded; foreign keys are off")
9798 }
9899}
100
101// The database file carries token hashes, addresses and private repo names.
102// The directory above it is the real boundary; this is the second one.
103func TestDatabaseFileIsNotWorldReadable(t *testing.T) {
104 path := filepath.Join(t.TempDir(), "gitbay.db")
105 s, err := Open(path)
106 if err != nil {
107 t.Fatal(err)
108 }
109 defer s.Close()
110 fi, err := os.Stat(path)
111 if err != nil {
112 t.Fatal(err)
113 }
114 if mode := fi.Mode().Perm(); mode&0o007 != 0 {
115 t.Errorf("database mode %04o is other-readable", mode)
116 }
117}