e2e/backup_test.go
219 lines · 8250 bytes
2 symbols in this file
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 if out := inst.admin(t, "admin", "backup", "--verify", archive); !strings.Contains(out, "connectivity ok on 1 repositories") {
91 t.Fatalf("verify: %s", out)
92 }
93
94 // Restore: extract into a fresh root and serve from it.
95 root2 := t.TempDir()
96 if outB, err := exec.Command("tar", "-xzf", archive, "-C", root2).CombinedOutput(); err != nil {
97 t.Fatalf("extract: %v\n%s", err, outB)
98 }
99 ports := freePorts(t, 2)
100 port2, httpPort2 := ports[0], ports[1]
101 config2 := filepath.Join(root2, "config.toml")
102 cfg := fmt.Sprintf(`
103[server]
104root = %q
105site_url = "https://gitbay.test"
106secret_key_file = %q
107[ssh]
108port = %d
109[http]
110addr = "127.0.0.1:%d"
111tls = "off"
112`, root2, inst.keyFile, port2, httpPort2)
113 if err := os.WriteFile(config2, []byte(cfg), 0o600); err != nil {
114 t.Fatal(err)
115 }
116 proc2 := exec.Command(inst.gitbayd, "--config", config2, "serve")
117 proc2.Stderr = os.Stderr
118 if err := proc2.Start(); err != nil {
119 t.Fatal(err)
120 }
121 t.Cleanup(func() { proc2.Process.Kill(); proc2.Wait() })
122 deadline := time.Now().Add(10 * time.Second)
123 for {
124 conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port2), 200*time.Millisecond)
125 if err == nil {
126 conn.Close()
127 break
128 }
129 if time.Now().After(deadline) {
130 t.Fatal("restored gitbayd did not start")
131 }
132 time.Sleep(50 * time.Millisecond)
133 }
134
135 // Strict host key checking against the ORIGINAL instance's host key:
136 // the preserved key means the restored server is cryptographically the
137 // same host. known_hosts entries are per host:port, so rebind the
138 // original entry to the new port.
139 khRaw, err := os.ReadFile(filepath.Join(inst.sshDir, "known_hosts"))
140 if err != nil {
141 t.Fatal(err)
142 }
143 fields := strings.Fields(strings.SplitN(string(khRaw), "\n", 2)[0])
144 if len(fields) < 3 {
145 t.Fatalf("unexpected known_hosts: %q", khRaw)
146 }
147 kh2 := filepath.Join(t.TempDir(), "known_hosts")
148 entry := fmt.Sprintf("[127.0.0.1]:%d %s %s\n", port2, fields[1], fields[2])
149 if err := os.WriteFile(kh2, []byte(entry), 0o600); err != nil {
150 t.Fatal(err)
151 }
152 ssh2 := func(args ...string) (string, string, int) {
153 base := []string{
154 "-p", fmt.Sprint(port2), "-i", aliceKey,
155 "-o", "IdentitiesOnly=yes",
156 "-o", "UserKnownHostsFile=" + kh2,
157 "-o", "StrictHostKeyChecking=yes",
158 "-o", "BatchMode=yes",
159 "git@127.0.0.1",
160 }
161 cmd := exec.Command("ssh", append(base, args...)...)
162 var o, e strings.Builder
163 cmd.Stdout, cmd.Stderr = &o, &e
164 err := cmd.Run()
165 code := 0
166 if ee, ok := err.(*exec.ExitError); ok {
167 code = ee.ExitCode()
168 } else if err != nil {
169 t.Fatalf("ssh: %v", err)
170 }
171 return o.String(), e.String(), code
172 }
173
174 // Identity, repo data, and issue all survived.
175 out2, errOut, code := ssh2("whoami")
176 if code != 0 || strings.TrimSpace(out2) != "alice" {
177 t.Fatalf("whoami on restored instance: exit %d, %q, %s", code, out2, errOut)
178 }
179 // With the original key the restored secrets open; with another key
180 // they do not.
181 if out, err := exec.Command(inst.gitbayd, "--config", config2, "admin", "secrets", "check").CombinedOutput(); err != nil || !secretsCheckOneSealed.Match(out) {
182 t.Fatalf("secrets check on the restored instance: %v\n%s", err, out)
183 }
184 config3 := filepath.Join(root2, "config-wrong-key.toml")
185 wrong := strings.Replace(cfg, fmt.Sprintf("secret_key_file = %q", inst.keyFile),
186 fmt.Sprintf("secret_key_file = %q", filepath.Join(t.TempDir(), "other.key")), 1)
187 if err := os.WriteFile(config3, []byte(wrong), 0o600); err != nil {
188 t.Fatal(err)
189 }
190 if out, err := exec.Command(inst.gitbayd, "--config", config3, "admin", "secrets", "init").CombinedOutput(); err != nil {
191 t.Fatalf("init the wrong key: %v\n%s", err, out)
192 }
193 if out, err := exec.Command(inst.gitbayd, "--config", config3, "admin", "secrets", "check").CombinedOutput(); err == nil || !strings.Contains(string(out), "does not hold") {
194 t.Fatalf("secrets check with the wrong key: %v\n%s", err, out)
195 }
196 if out2, _, code = ssh2("repo", "log", "alice/keep"); code != 0 || !strings.Contains(out2, "keep me") {
197 t.Fatalf("restored log: %d\n%s", code, out2)
198 }
199 if out2, _, code = ssh2("issue", "show", "alice/keep", "1"); code != 0 || !strings.Contains(out2, "survives backup") {
200 t.Fatalf("restored issue: %d\n%s", code, out2)
201 }
202
203 // The restored instance accepts new pushes: hooks were regenerated at
204 // startup, not restored from the archive.
205 env2 := append(os.Environ(),
206 fmt.Sprintf("GIT_SSH_COMMAND=ssh -i %s -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=%s -o BatchMode=yes",
207 aliceKey, kh2),
208 "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null",
209 "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.test",
210 "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.test")
211 work2 := t.TempDir()
212 mustGit(t, work2, env2, "clone", fmt.Sprintf("ssh://git@127.0.0.1:%d/alice/keep.git", port2), "w")
213 dir2 := filepath.Join(work2, "w")
214 if data, _ := os.ReadFile(filepath.Join(dir2, "data.txt")); string(data) != "precious\n" {
215 t.Fatalf("restored content: %q", data)
216 }
217 mustGit(t, dir2, env2, "commit", "-q", "--allow-empty", "-m", "post-restore")
218 mustGit(t, dir2, env2, "push", "-q", "origin", "main")
219}