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