e2e/ssh_test.go
338 lines · 10993 bytes
1// Package e2e drives a real gitbayd with the real ssh and git clients.
2package e2e
3
4import (
5 "encoding/json"
6 "fmt"
7 "io"
8 "math/rand/v2"
9 "net"
10 "os"
11 "os/exec"
12 "path/filepath"
13 "strings"
14 "sync/atomic"
15 "testing"
16)
17
18type instance struct {
19 gitbayd string // path to built binary
20 runner string // path to built gitbay-runner (CI tests)
21 root string
22 config string
23 port int
24 httpPort int
25 gitPort int
26 proc *exec.Cmd
27 sshDir string // per-user client keys live here
28 keyFile string // server.secret_key_file, outside root
29 stderr *tailBuffer // the end of the first serve's stderr
30}
31
32// nextPort hands out candidate ports below 32768, where Linux and macOS
33// start the ports they give outgoing connections: a git or SMTP client in
34// another test cannot take one between the bind test and gitbayd's bind.
35// Seeded randomly so two test processes on one machine — `go test ./...`
36// runs packages concurrently — start in different places.
37const lastPort = 32000
38
39var nextPort = func() *atomic.Int32 {
40 var n atomic.Int32
41 n.Store(int32(10000 + rand.IntN(10000)))
42 return &n
43}()
44
45// freePorts reserves n distinct ports, counting up rather than asking the
46// kernel for :0.
47//
48// :0 cannot be made safe once tests run in parallel. A port is picked by
49// binding, reading the number back and closing, and between that close and
50// the bind inside gitbayd the kernel is free to hand the same port to
51// another instance picking at that moment. The loser does not fail
52// cleanly: waitForPort only asks whether something is listening, so a test
53// whose port was taken talks to a different test's server and reports
54// whatever that one says.
55//
56// A counter cannot collide within a process, whatever the interleaving.
57// Each candidate is still bind-tested, which skips ports other programs
58// hold. An outside process taking one in the close-to-bind window remains
59// possible, as it was before; that race is not ours to close.
60func freePorts(t *testing.T, n int) []int {
61 t.Helper()
62 ports := make([]int, 0, n)
63 for len(ports) < n {
64 p := int(nextPort.Add(1))
65 if p > lastPort {
66 t.Fatal("ran out of ports")
67 }
68 ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", p))
69 if err != nil {
70 continue // somebody else has it
71 }
72 ln.Close()
73 ports = append(ports, p)
74 }
75 return ports
76}
77
78func freePort(t *testing.T) int {
79 t.Helper()
80 return freePorts(t, 1)[0]
81}
82
83func startInstance(t *testing.T) *instance {
84 return startInstanceWith(t, "")
85}
86
87// startInstanceWith appends extra TOML to the instance config.
88func startInstanceWith(t *testing.T, extra string) *instance {
89 t.Helper()
90 ports := freePorts(t, 3)
91 inst := &instance{
92 gitbayd: buildGitbayd(t),
93 root: t.TempDir(),
94 port: ports[0],
95 httpPort: ports[1],
96 gitPort: ports[2],
97 sshDir: t.TempDir(),
98 }
99 inst.keyFile = filepath.Join(t.TempDir(), "secret.key")
100 inst.config = filepath.Join(inst.root, "config.toml")
101 cfg := fmt.Sprintf(`
102[server]
103root = %q
104site_url = "https://gitbay.test"
105secret_key_file = %q
106[ssh]
107port = %d
108[http]
109addr = "127.0.0.1:%d"
110tls = "off"
111[git_daemon]
112enabled = true
113port = %d
114`, inst.root, inst.keyFile, inst.port, inst.httpPort, inst.gitPort)
115 cfg += extra + "\n"
116 if err := os.WriteFile(inst.config, []byte(cfg), 0o600); err != nil {
117 t.Fatal(err)
118 }
119
120 inst.admin(t, "admin", "secrets", "init")
121
122 inst.stderr = &tailBuffer{max: 16 << 10}
123 inst.proc = exec.Command(inst.gitbayd, "--config", inst.config, "serve")
124 inst.proc.Stderr = io.MultiWriter(os.Stderr, inst.stderr)
125 if err := inst.proc.Start(); err != nil {
126 t.Fatal(err)
127 }
128 t.Cleanup(func() {
129 inst.proc.Process.Kill()
130 inst.proc.Wait()
131 })
132
133 // Every listener, not just SSH: the HTTP and git ones come up in their
134 // own goroutines, and a test whose first act is an HTTP request used
135 // to race them and be refused.
136 inst.waitListening(t, inst.port, inst.httpPort, inst.gitPort)
137 return inst
138}
139
140// admin runs a gitbayd admin command against the instance's database.
141func (i *instance) admin(t *testing.T, args ...string) string {
142 t.Helper()
143 cmd := exec.Command(i.gitbayd, append([]string{"--config", i.config}, args...)...)
144 out, err := cmd.CombinedOutput()
145 if err != nil {
146 t.Fatalf("gitbayd %v: %v\n%s", args, err, out)
147 }
148 return string(out)
149}
150
151// forgedAdminErr runs an admin command expected to fail, returning output.
152func (i *instance) forgedAdminErr(t *testing.T, args ...string) string {
153 t.Helper()
154 cmd := exec.Command(i.gitbayd, append([]string{"--config", i.config}, args...)...)
155 out, err := cmd.CombinedOutput()
156 if err == nil {
157 t.Fatalf("gitbayd %v unexpectedly succeeded:\n%s", args, out)
158 }
159 return string(out)
160}
161
162// newKey generates a client keypair and returns the private key path.
163func (i *instance) newKey(t *testing.T, name string) string {
164 t.Helper()
165 priv := filepath.Join(i.sshDir, name)
166 cmd := exec.Command("ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", name, "-f", priv)
167 if out, err := cmd.CombinedOutput(); err != nil {
168 t.Fatalf("ssh-keygen: %v\n%s", err, out)
169 }
170 return priv
171}
172
173// sshCmd is the ssh invocation ssh runs, for a test that reads the output
174// as it arrives.
175func (i *instance) sshCmd(key string, args ...string) *exec.Cmd {
176 base := []string{
177 "-p", fmt.Sprint(i.port),
178 "-i", key,
179 "-o", "IdentitiesOnly=yes",
180 "-o", "StrictHostKeyChecking=no",
181 "-o", "UserKnownHostsFile=" + filepath.Join(i.sshDir, "known_hosts"),
182 "-o", "BatchMode=yes",
183 "git@127.0.0.1",
184 }
185 return exec.Command("ssh", append(base, args...)...)
186}
187
188// ssh runs the real OpenSSH client against the instance with the given key.
189func (i *instance) ssh(t *testing.T, key string, stdin string, args ...string) (string, string, int) {
190 t.Helper()
191 cmd := i.sshCmd(key, args...)
192 if stdin != "" {
193 cmd.Stdin = strings.NewReader(stdin)
194 }
195 var out, errOut strings.Builder
196 cmd.Stdout = &out
197 cmd.Stderr = &errOut
198 err := cmd.Run()
199 code := 0
200 if ee, ok := err.(*exec.ExitError); ok {
201 code = ee.ExitCode()
202 } else if err != nil {
203 t.Fatalf("ssh: %v", err)
204 }
205 return out.String(), errOut.String(), code
206}
207
208// sshTerm is ssh with a leading --term=<v> on the command line, as the
209// CLI sends it at a terminal: OpenSSH's ControlMaster does not forward a
210// new session's SetEnv, so the term travels in argv instead. An empty
211// term sends nothing.
212func (i *instance) sshTerm(t *testing.T, key, term string, args ...string) (string, string, int) {
213 t.Helper()
214 if term != "" {
215 // "--" stops the local ssh client from parsing --term=... as one of
216 // its own options; it is not part of the remote command line.
217 args = append([]string{"--", "--term=" + term}, args...)
218 }
219 cmd := i.sshCmd(key, args...)
220 var out, errOut strings.Builder
221 cmd.Stdout, cmd.Stderr = &out, &errOut
222 err := cmd.Run()
223 code := 0
224 if ee, ok := err.(*exec.ExitError); ok {
225 code = ee.ExitCode()
226 } else if err != nil {
227 t.Fatalf("ssh: %v", err)
228 }
229 return out.String(), errOut.String(), code
230}
231
232func TestControlPlaneOverBareSSH(t *testing.T) {
233 t.Parallel()
234 inst := startInstance(t)
235
236 aliceKey := inst.newKey(t, "alice")
237 inst.admin(t, "admin", "user", "create", "alice",
238 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
239
240 // whoami --json from bare OpenSSH.
241 out, errOut, code := inst.ssh(t, aliceKey, "", "whoami", "--json")
242 if code != 0 {
243 t.Fatalf("whoami exit %d, stderr: %s", code, errOut)
244 }
245 var env struct {
246 ProtocolVersion int `json:"protocol_version"`
247 Data struct {
248 Username string `json:"username"`
249 KeyScope string `json:"key_scope"`
250 } `json:"data"`
251 }
252 if err := json.Unmarshal([]byte(out), &env); err != nil {
253 t.Fatalf("whoami output not JSON: %v\n%s", err, out)
254 }
255 if env.Data.Username != "alice" || env.ProtocolVersion != 1 || env.Data.KeyScope != "full" {
256 t.Fatalf("whoami = %+v", env)
257 }
258
259 // Unknown key is refused at auth.
260 strangerKey := inst.newKey(t, "stranger")
261 _, _, code = inst.ssh(t, strangerKey, "", "whoami")
262 if code == 0 {
263 t.Fatal("unknown key was authenticated")
264 }
265
266 // keys add over stdin, then list shows both.
267 secondKey := inst.newKey(t, "alice2")
268 pub, _ := os.ReadFile(secondKey + ".pub")
269 out, errOut, code = inst.ssh(t, aliceKey, string(pub), "keys", "add", "--scope", "git")
270 if code != 0 {
271 t.Fatalf("keys add exit %d, stderr: %s", code, errOut)
272 }
273 out, _, code = inst.ssh(t, aliceKey, "", "keys", "list")
274 if code != 0 || len(strings.Split(strings.TrimSpace(out), "\n")) != 2 {
275 t.Fatalf("keys list exit %d:\n%s", code, out)
276 }
277 // The key's comment (ssh-keygen -C) is its label; keys label renames it.
278 if !strings.Contains(out, "\tgit\talice2\t") {
279 t.Fatalf("keys list lacks the comment as label:\n%s", out)
280 }
281 secondFP := strings.Fields(strings.Split(strings.TrimSpace(out), "\n")[1])[0]
282 if _, errOut, code := inst.ssh(t, aliceKey, "", "keys", "label", secondFP, "'build box'"); code != 0 {
283 t.Fatalf("keys label exit %d, stderr: %s", code, errOut)
284 }
285 out, _, _ = inst.ssh(t, aliceKey, "", "keys", "list")
286 if !strings.Contains(out, "\tgit\tbuild box\t") {
287 t.Fatalf("keys list after label:\n%s", out)
288 }
289
290 // The git-scoped key authenticates but is denied control commands.
291 out, errOut, code = inst.ssh(t, secondKey, "", "whoami")
292 if code != 4 {
293 t.Fatalf("git-scoped whoami: exit %d (want 4), stdout %q stderr %q", code, out, errOut)
294 }
295 if !strings.Contains(errOut, "does not allow control commands") {
296 t.Fatalf("scope denial message missing: %q", errOut)
297 }
298
299 // Duplicate key registration: bob cannot claim alice's key, and the
300 // message is the exact spec text, naming no account.
301 bobKey := inst.newKey(t, "bob")
302 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
303 alicePub, _ := os.ReadFile(aliceKey + ".pub")
304 _, errOut, code = inst.ssh(t, bobKey, string(alicePub), "keys", "add")
305 if code != 1 {
306 t.Fatalf("duplicate key add: exit %d, want 1", code)
307 }
308 want := "that key is already registered to another account; remove it there first or use a different key"
309 if !strings.Contains(errOut, want) {
310 t.Fatalf("duplicate key message = %q, want %q", errOut, want)
311 }
312 if strings.Contains(errOut, "alice") {
313 t.Fatalf("duplicate key message leaks account name: %q", errOut)
314 }
315
316 // Arguments with spaces survive the tokenizer round trip.
317 _, errOut, code = inst.ssh(t, aliceKey, "", "keys", "remove", "'no such fingerprint'")
318 if code != 3 {
319 t.Fatalf("keys remove with spaced arg: exit %d (want 3), stderr %q", code, errOut)
320 }
321}
322
323// freePort used to close its listener before returning, so the kernel was
324// free to hand the same port to the next call. An instance asks for three in
325// a row and then fails to bind its second listener, which surfaces as an
326// unrelated test timing out on "gitbayd did not start listening".
327func TestFreePortsAreDistinct(t *testing.T) {
328 t.Parallel()
329 for round := 0; round < 50; round++ {
330 seen := map[int]bool{}
331 for _, p := range freePorts(t, 8) {
332 if seen[p] {
333 t.Fatalf("round %d: port %d issued twice in one request", round, p)
334 }
335 seen[p] = true
336 }
337 }
338}