e2e/backup_test.go
215 lines · 8088 bytes
1package e2e
2
3import (
4 "bytes"
5 "fmt"
6 "net"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "regexp"
11 "strings"
12 "testing"
13 "time"
14)
15
16// secretsCheckOneSealed matches "admin secrets check" reporting the one
17// build secret set in TestAdminBackup as sealed under some key, e.g.
18// "build_secrets.value: key 98e412e4 1".
19var secretsCheckOneSealed = regexp.MustCompile(`(?m)build_secrets\.value: key \S+ 1$`)
20
21func TestAdminBackup(t *testing.T) {
22 t.Parallel()
23 inst := startInstance(t)
24 aliceKey := inst.newKey(t, "alice")
25 inst.admin(t, "admin", "user", "create", "alice",
26 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
27
28 // Content worth backing up: a repo with commits and a tag, and an issue.
29 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/keep"); code != 0 {
30 t.Fatalf("repo create: %s", errOut)
31 }
32 work := t.TempDir()
33 env := inst.gitEnv(aliceKey)
34 mustGit(t, work, env, "clone", inst.sshURL("alice/keep"), "w")
35 dir := filepath.Join(work, "w")
36 os.WriteFile(filepath.Join(dir, "data.txt"), []byte("precious\n"), 0o644)
37 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
38 mustGit(t, dir, env, "add", ".")
39 mustGit(t, dir, env, "commit", "-q", "-m", "keep me")
40 mustGit(t, dir, env, "tag", "v1")
41 mustGit(t, dir, env, "push", "-q", "origin", "main", "v1")
42 if _, _, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/keep", "--title", "'survives backup'"); code != 0 {
43 t.Fatal("issue create failed")
44 }
45 // A build secret, to show the archive carries it sealed and the key
46 // file not at all.
47 if _, errOut, code := inst.ssh(t, aliceKey, "hunter2-at-rest", "repo", "secret", "set", "alice/keep", "DEPLOY_TOKEN"); code != 0 {
48 t.Fatalf("secret set: %s", errOut)
49 }
50
51 // Back up while the daemon is running.
52 archive := filepath.Join(t.TempDir(), "backup.tar.gz")
53 out := inst.admin(t, "admin", "backup", "--out", archive)
54 if !strings.Contains(out, "1 repositories") {
55 t.Fatalf("backup summary: %s", out)
56 }
57
58 // The archive holds the snapshot, the repo, and the host key — and none
59 // of the transient state.
60 list, err := exec.Command("tar", "-tzf", archive).Output()
61 if err != nil {
62 t.Fatal(err)
63 }
64 names := string(list)
65 for _, want := range []string{"gitbay.db", "repos/alice/keep.git/", "ssh/host_ed25519"} {
66 if !strings.Contains(names, want) {
67 t.Fatalf("archive missing %s:\n%s", want, names)
68 }
69 }
70 for _, line := range strings.Split(strings.TrimSpace(names), "\n") {
71 // Top-level transient state must be absent; a repo's own inert
72 // sample hooks directory (keep.git/hooks/) is fine.
73 for _, banned := range []string{"hook.sock", "hooks/", "askpass.sh", "gitbay.db-wal"} {
74 if line == banned || strings.HasPrefix(line, banned) {
75 t.Fatalf("archive contains transient state %s:\n%s", line, names)
76 }
77 }
78 }
79 if strings.Contains(names, "secret.key") {
80 t.Fatalf("archive carries the key file:\n%s", names)
81 }
82 db, err := exec.Command("tar", "-xzOf", archive, "gitbay.db").Output()
83 if err != nil {
84 t.Fatal(err)
85 }
86 if bytes.Contains(db, []byte("hunter2-at-rest")) {
87 t.Fatal("the archived database carries the build secret in clear")
88 }
89
90 // Restore: extract into a fresh root and serve from it.
91 root2 := t.TempDir()
92 if outB, err := exec.Command("tar", "-xzf", archive, "-C", root2).CombinedOutput(); err != nil {
93 t.Fatalf("extract: %v\n%s", err, outB)
94 }
95 ports := freePorts(t, 2)
96 port2, httpPort2 := ports[0], ports[1]
97 config2 := filepath.Join(root2, "config.toml")
98 cfg := fmt.Sprintf(`
99[server]
100root = %q
101site_url = "https://gitbay.test"
102secret_key_file = %q
103[ssh]
104port = %d
105[http]
106addr = "127.0.0.1:%d"
107tls = "off"
108`, root2, inst.keyFile, port2, httpPort2)
109 if err := os.WriteFile(config2, []byte(cfg), 0o600); err != nil {
110 t.Fatal(err)
111 }
112 proc2 := exec.Command(inst.gitbayd, "--config", config2, "serve")
113 proc2.Stderr = os.Stderr
114 if err := proc2.Start(); err != nil {
115 t.Fatal(err)
116 }
117 t.Cleanup(func() { proc2.Process.Kill(); proc2.Wait() })
118 deadline := time.Now().Add(10 * time.Second)
119 for {
120 conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port2), 200*time.Millisecond)
121 if err == nil {
122 conn.Close()
123 break
124 }
125 if time.Now().After(deadline) {
126 t.Fatal("restored gitbayd did not start")
127 }
128 time.Sleep(50 * time.Millisecond)
129 }
130
131 // Strict host key checking against the ORIGINAL instance's host key:
132 // the preserved key means the restored server is cryptographically the
133 // same host. known_hosts entries are per host:port, so rebind the
134 // original entry to the new port.
135 khRaw, err := os.ReadFile(filepath.Join(inst.sshDir, "known_hosts"))
136 if err != nil {
137 t.Fatal(err)
138 }
139 fields := strings.Fields(strings.SplitN(string(khRaw), "\n", 2)[0])
140 if len(fields) < 3 {
141 t.Fatalf("unexpected known_hosts: %q", khRaw)
142 }
143 kh2 := filepath.Join(t.TempDir(), "known_hosts")
144 entry := fmt.Sprintf("[127.0.0.1]:%d %s %s\n", port2, fields[1], fields[2])
145 if err := os.WriteFile(kh2, []byte(entry), 0o600); err != nil {
146 t.Fatal(err)
147 }
148 ssh2 := func(args ...string) (string, string, int) {
149 base := []string{
150 "-p", fmt.Sprint(port2), "-i", aliceKey,
151 "-o", "IdentitiesOnly=yes",
152 "-o", "UserKnownHostsFile=" + kh2,
153 "-o", "StrictHostKeyChecking=yes",
154 "-o", "BatchMode=yes",
155 "git@127.0.0.1",
156 }
157 cmd := exec.Command("ssh", append(base, args...)...)
158 var o, e strings.Builder
159 cmd.Stdout, cmd.Stderr = &o, &e
160 err := cmd.Run()
161 code := 0
162 if ee, ok := err.(*exec.ExitError); ok {
163 code = ee.ExitCode()
164 } else if err != nil {
165 t.Fatalf("ssh: %v", err)
166 }
167 return o.String(), e.String(), code
168 }
169
170 // Identity, repo data, and issue all survived.
171 out2, errOut, code := ssh2("whoami")
172 if code != 0 || strings.TrimSpace(out2) != "alice" {
173 t.Fatalf("whoami on restored instance: exit %d, %q, %s", code, out2, errOut)
174 }
175 // With the original key the restored secrets open; with another key
176 // they do not.
177 if out, err := exec.Command(inst.gitbayd, "--config", config2, "admin", "secrets", "check").CombinedOutput(); err != nil || !secretsCheckOneSealed.Match(out) {
178 t.Fatalf("secrets check on the restored instance: %v\n%s", err, out)
179 }
180 config3 := filepath.Join(root2, "config-wrong-key.toml")
181 wrong := strings.Replace(cfg, fmt.Sprintf("secret_key_file = %q", inst.keyFile),
182 fmt.Sprintf("secret_key_file = %q", filepath.Join(t.TempDir(), "other.key")), 1)
183 if err := os.WriteFile(config3, []byte(wrong), 0o600); err != nil {
184 t.Fatal(err)
185 }
186 if out, err := exec.Command(inst.gitbayd, "--config", config3, "admin", "secrets", "init").CombinedOutput(); err != nil {
187 t.Fatalf("init the wrong key: %v\n%s", err, out)
188 }
189 if out, err := exec.Command(inst.gitbayd, "--config", config3, "admin", "secrets", "check").CombinedOutput(); err == nil || !strings.Contains(string(out), "does not hold") {
190 t.Fatalf("secrets check with the wrong key: %v\n%s", err, out)
191 }
192 if out2, _, code = ssh2("repo", "log", "alice/keep"); code != 0 || !strings.Contains(out2, "keep me") {
193 t.Fatalf("restored log: %d\n%s", code, out2)
194 }
195 if out2, _, code = ssh2("issue", "show", "alice/keep", "1"); code != 0 || !strings.Contains(out2, "survives backup") {
196 t.Fatalf("restored issue: %d\n%s", code, out2)
197 }
198
199 // The restored instance accepts new pushes: hooks were regenerated at
200 // startup, not restored from the archive.
201 env2 := append(os.Environ(),
202 fmt.Sprintf("GIT_SSH_COMMAND=ssh -i %s -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=%s -o BatchMode=yes",
203 aliceKey, kh2),
204 "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null",
205 "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.test",
206 "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.test")
207 work2 := t.TempDir()
208 mustGit(t, work2, env2, "clone", fmt.Sprintf("ssh://git@127.0.0.1:%d/alice/keep.git", port2), "w")
209 dir2 := filepath.Join(work2, "w")
210 if data, _ := os.ReadFile(filepath.Join(dir2, "data.txt")); string(data) != "precious\n" {
211 t.Fatalf("restored content: %q", data)
212 }
213 mustGit(t, dir2, env2, "commit", "-q", "--allow-empty", "-m", "post-restore")
214 mustGit(t, dir2, env2, "push", "-q", "origin", "main")
215}