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