Make host monitoring speak, scan for vulnerabilities in CI, snapshot the database hourly !145
6 files changed, +242 −41
Layout: unified · split
.gitbay/ci.yml +8
| @@ -15,3 +15,11 @@ jobs: | |||
| 15 | steps: | 15 | steps: |
| 16 | - 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; } | 16 | - 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; } |
| 17 | - go test ./... -count=1 -timeout 20m | 17 | - 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 ( | |||
| 27 | // rows pointing at objects the archive never captured. | 27 | // rows pointing at objects the archive never captured. |
| 28 | func backupCmd() *cobra.Command { | 28 | func backupCmd() *cobra.Command { |
| 29 | var out string | 29 | var out string |
| 30 | var dbOnly bool | ||
| 30 | cmd := &cobra.Command{ | 31 | cmd := &cobra.Command{ |
| 31 | Use: "backup", | 32 | Use: "backup", |
| 32 | Short: "write a consistent backup archive (database snapshot first, then repositories)", | 33 | Short: "write a consistent backup archive (database snapshot first, then repositories)", |
| @@ -34,6 +35,12 @@ func backupCmd() *cobra.Command { | |||
| 34 | all repositories, and the SSH host keys. Transient state (hook socket, | 35 | all repositories, and the SSH host keys. Transient state (hook socket, |
| 35 | regenerated hook scripts, askpass helper, WAL files) is excluded. | 36 | regenerated hook scripts, askpass helper, WAL files) is excluded. |
| 36 | 37 | ||
| 38 | --db-only writes the database snapshot alone. It is seconds and megabytes | ||
| 39 | rather than minutes and gigabytes, which is what makes a frequent schedule | ||
| 40 | affordable, and the database is the copy of issues, merge requests and | ||
| 41 | comments that exists nowhere else. Repositories are not in such an archive, | ||
| 42 | so it supplements a full backup and does not replace one. | ||
| 43 | |||
| 37 | Restore: extract into an empty directory, point server.root at it, start | 44 | Restore: extract into an empty directory, point server.root at it, start |
| 38 | gitbayd. Host keys are preserved, so clients keep their known_hosts entries.`, | 45 | gitbayd. Host keys are preserved, so clients keep their known_hosts entries.`, |
| 39 | RunE: func(cmd *cobra.Command, args []string) error { | 46 | RunE: func(cmd *cobra.Command, args []string) error { |
| @@ -44,14 +51,15 @@ gitbayd. Host keys are preserved, so clients keep their known_hosts entries.`, | |||
| 44 | if out == "" { | 51 | if out == "" { |
| 45 | out = fmt.Sprintf("gitbay-backup-%s.tar.gz", time.Now().UTC().Format("20060102-150405")) | 52 | out = fmt.Sprintf("gitbay-backup-%s.tar.gz", time.Now().UTC().Format("20060102-150405")) |
| 46 | } | 53 | } |
| 47 | return runBackup(cfg, out) | 54 | return runBackup(cfg, out, dbOnly) |
| 48 | }, | 55 | }, |
| 49 | } | 56 | } |
| 50 | cmd.Flags().StringVar(&out, "out", "", "output archive path (default gitbay-backup-<utc timestamp>.tar.gz)") | 57 | 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") | ||
| 51 | return cmd | 59 | return cmd |
| 52 | } | 60 | } |
| 53 | 61 | ||
| 54 | func runBackup(cfg config.Config, out string) error { | 62 | func runBackup(cfg config.Config, out string, dbOnly bool) error { |
| 55 | st, err := openStore(cfg) | 63 | st, err := openStore(cfg) |
| 56 | if err != nil { | 64 | if err != nil { |
| 57 | return err | 65 | return err |
| @@ -79,42 +87,45 @@ func runBackup(cfg config.Config, out string) error { | |||
| 79 | } | 87 | } |
| 80 | 88 | ||
| 81 | // 2. Everything under the root except transient or regenerated state. | 89 | // 2. Everything under the root except transient or regenerated state. |
| 90 | // Skipped entirely for --db-only. | ||
| 82 | skip := map[string]bool{ | 91 | skip := map[string]bool{ |
| 83 | "gitbay.db": true, "gitbay.db-wal": true, "gitbay.db-shm": true, | 92 | "gitbay.db": true, "gitbay.db-wal": true, "gitbay.db-shm": true, |
| 84 | "hook.sock": true, "askpass.sh": true, "hooks": true, | 93 | "hook.sock": true, "askpass.sh": true, "hooks": true, |
| 85 | } | 94 | } |
| 86 | repoCount := 0 | 95 | repoCount := 0 |
| 87 | root := cfg.Server.Root | 96 | root := cfg.Server.Root |
| 88 | err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { | 97 | if !dbOnly { |
| 89 | if err != nil { | 98 | err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { |
| 90 | return err | 99 | if err != nil { |
| 91 | } | 100 | return err |
| 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 | ||
| 102 | } | 101 | } |
| 103 | return nil | 102 | rel, err := filepath.Rel(root, path) |
| 104 | } | 103 | if err != nil { |
| 105 | if !d.Type().IsRegular() && !d.IsDir() { | 104 | return err |
| 106 | return nil // sockets, symlinks | 105 | } |
| 107 | } | 106 | if rel == "." { |
| 108 | if d.IsDir() { | 107 | return nil |
| 109 | if strings.HasSuffix(rel, ".git") { | 108 | } |
| 110 | repoCount++ | 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 | ||
| 111 | } | 123 | } |
| 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 | ||
| 113 | } | 128 | } |
| 114 | return addFile(tw, path, filepath.ToSlash(rel)) | ||
| 115 | }) | ||
| 116 | if err != nil { | ||
| 117 | return err | ||
| 118 | } | 129 | } |
| 119 | if err := tw.Close(); err != nil { | 130 | if err := tw.Close(); err != nil { |
| 120 | return err | 131 | return err |
| @@ -127,6 +138,10 @@ func runBackup(cfg config.Config, out string) error { | |||
| 127 | } | 138 | } |
| 128 | 139 | ||
| 129 | info, _ := os.Stat(out) | 140 | 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 | } | ||
| 130 | fmt.Printf("wrote %s (%d repositories, %.1f MB)\n", out, repoCount, float64(info.Size())/1e6) | 145 | fmt.Printf("wrote %s (%d repositories, %.1f MB)\n", out, repoCount, float64(info.Size())/1e6) |
| 131 | return nil | 146 | return nil |
| 132 | } | 147 | } |
cmd/gitbayd/backup_test.go added +96
| @@ -0,0 +1,96 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 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. | ||
| 16 | func 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. | ||
| 45 | func 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: | |||
| 57 | maxretry = 5 | 57 | maxretry = 5 |
| 58 | bantime = 1h | 58 | bantime = 1h |
| 59 | 59 | ||
| 60 | # Heartbeat: post disk/service/cert status to a webhook if one is set in | 60 | # Heartbeat: disk/service/cert status to journald every run, and to a |
| 61 | # /etc/gitbay/monitor.url. Silent when the file is absent. | 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. | ||
| 62 | - path: /usr/local/bin/gitbay-monitor.sh | 63 | - path: /usr/local/bin/gitbay-monitor.sh |
| 63 | permissions: "0755" | 64 | permissions: "0755" |
| 64 | content: | | 65 | content: | |
| 65 | #!/bin/sh | 66 | #!/bin/sh |
| 66 | set -eu | 67 | set -eu |
| 67 | url_file=/etc/gitbay/monitor.url | ||
| 68 | [ -f "$url_file" ] || exit 0 | ||
| 69 | url=$(cat "$url_file") | ||
| 70 | disk=$(df -P /var/lib/gitbay | awk 'NR==2{print $5}') | 68 | disk=$(df -P /var/lib/gitbay | awk 'NR==2{print $5}') |
| 71 | svc=$(systemctl is-active gitbayd || true) | 69 | svc=$(systemctl is-active gitbayd || true) |
| 72 | # Days until the ACME cert expires, if autocert cached one. | 70 | # Days until the ACME cert expires, if autocert cached one. |
| @@ -74,14 +72,32 @@ write_files: | |||
| 74 | exp="n/a" | 72 | exp="n/a" |
| 75 | if [ -d "$cert" ]; then | 73 | if [ -d "$cert" ]; then |
| 76 | f=$(ls -1 "$cert" 2>/dev/null | grep -v acme_account | head -1 || true) | 74 | 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 | ||
| 78 | fi | 78 | fi |
| 79 | alert="" | 79 | alert="" |
| 80 | [ "$svc" != "active" ] && alert="gitbayd is $svc; " | 80 | if [ "$svc" != "active" ]; then |
| 81 | alert="gitbayd is $svc; " | ||
| 82 | fi | ||
| 81 | pct=$(echo "$disk" | tr -d '%') | 83 | pct=$(echo "$disk" | tr -d '%') |
| 82 | [ "$pct" -ge 85 ] && alert="${alert}disk ${disk}; " | 84 | if [ "$pct" -ge 85 ]; then |
| 83 | body=$(printf '{"disk":"%s","service":"%s","cert_expires":"%s","alert":"%s"}' "$disk" "$svc" "$exp" "$alert") | 85 | alert="${alert}disk ${disk}; " |
| 84 | curl -fsS -m 10 -H 'Content-Type: application/json' -d "$body" "$url" >/dev/null 2>&1 || true | 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 | ||
| 85 | 101 | ||
| 86 | - path: /etc/systemd/system/gitbay-monitor.service | 102 | - path: /etc/systemd/system/gitbay-monitor.service |
| 87 | content: | | 103 | content: | |
| @@ -186,6 +202,41 @@ write_files: | |||
| 186 | /usr/local/bin/gitbayd --config /etc/gitbay/config.toml admin backup --out "$out" | 202 | /usr/local/bin/gitbayd --config /etc/gitbay/config.toml admin backup --out "$out" |
| 187 | ls -1t "$dir"/gitbay-*.tar.gz | tail -n +8 | xargs -r rm -- | 203 | ls -1t "$dir"/gitbay-*.tar.gz | tail -n +8 | xargs -r rm -- |
| 188 | 204 | ||
| 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 | |||
| 189 | - path: /etc/systemd/system/gitbay-backup.service | 240 | - path: /etc/systemd/system/gitbay-backup.service |
| 190 | content: | | 241 | content: | |
| 191 | [Unit] | 242 | [Unit] |
| @@ -237,6 +288,6 @@ runcmd: | |||
| 237 | - ufw --force enable | 288 | - ufw --force enable |
| 238 | - systemctl daemon-reload | 289 | - systemctl daemon-reload |
| 239 | - systemctl restart ssh.socket || systemctl restart ssh | 290 | - systemctl restart ssh.socket || systemctl restart ssh |
| 240 | - systemctl enable gitbayd 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 |
| 241 | - systemctl start gitbay-backup.timer gitbay-gc.timer gitbay-monitor.timer | 292 | - systemctl start gitbay-backup.timer gitbay-db-backup.timer gitbay-gc.timer gitbay-monitor.timer |
| 242 | - systemctl enable --now unattended-upgrades fail2ban | 293 | - systemctl enable --now unattended-upgrades fail2ban |
internal/store/store.go +12
| @@ -4,8 +4,10 @@ package store | |||
| 4 | import ( | 4 | import ( |
| 5 | "database/sql" | 5 | "database/sql" |
| 6 | "embed" | 6 | "embed" |
| 7 | "errors" | ||
| 7 | "fmt" | 8 | "fmt" |
| 8 | "io/fs" | 9 | "io/fs" |
| 10 | "os" | ||
| 9 | "sort" | 11 | "sort" |
| 10 | "strconv" | 12 | "strconv" |
| 11 | "strings" | 13 | "strings" |
| @@ -35,6 +37,16 @@ func Open(path string) (*Store, error) { | |||
| 35 | db.Close() | 37 | db.Close() |
| 36 | return nil, err | 38 | return nil, err |
| 37 | } | 39 | } |
| 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 | } | ||
| 38 | return &Store{DB: db}, nil | 50 | return &Store{DB: db}, nil |
| 39 | } | 51 | } |
| 40 | 52 | ||
internal/store/store_test.go +19
| @@ -1,6 +1,7 @@ | |||
| 1 | package store | 1 | package store |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "os" | ||
| 4 | "path/filepath" | 5 | "path/filepath" |
| 5 | "strings" | 6 | "strings" |
| 6 | "testing" | 7 | "testing" |
| @@ -96,3 +97,21 @@ func TestForeignKeysEnforced(t *testing.T) { | |||
| 96 | t.Fatal("insert with dangling user_id succeeded; foreign keys are off") | 97 | t.Fatal("insert with dangling user_id succeeded; foreign keys are off") |
| 97 | } | 98 | } |
| 98 | } | 99 | } |
| 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. | ||
| 103 | func 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 | } | ||