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