e2e/ssh_test.go
246 lines · 7184 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
39func freePort(t *testing.T) int {
40 t.Helper()
41 ln, err := net.Listen("tcp", "127.0.0.1:0")
42 if err != nil {
43 t.Fatal(err)
44 }
45 defer ln.Close()
46 return ln.Addr().(*net.TCPAddr).Port
47}
48
49func startInstance(t *testing.T) *instance {
50 return startInstanceWith(t, "")
51}
52
53// startInstanceWith appends extra TOML to the instance config.
54func startInstanceWith(t *testing.T, extra string) *instance {
55 t.Helper()
56 inst := &instance{
57 gitbayd: buildGitbayd(t),
58 root: t.TempDir(),
59 port: freePort(t),
60 httpPort: freePort(t),
61 gitPort: freePort(t),
62 sshDir: t.TempDir(),
63 }
64 inst.config = filepath.Join(inst.root, "config.toml")
65 cfg := fmt.Sprintf(`
66[server]
67root = %q
68site_url = "https://gitbay.test"
69[ssh]
70port = %d
71[http]
72addr = "127.0.0.1:%d"
73tls = "off"
74[git_daemon]
75enabled = true
76port = %d
77`, inst.root, inst.port, inst.httpPort, inst.gitPort)
78 cfg += extra + "\n"
79 if err := os.WriteFile(inst.config, []byte(cfg), 0o600); err != nil {
80 t.Fatal(err)
81 }
82
83 inst.proc = exec.Command(inst.gitbayd, "--config", inst.config, "serve")
84 inst.proc.Stderr = os.Stderr
85 if err := inst.proc.Start(); err != nil {
86 t.Fatal(err)
87 }
88 t.Cleanup(func() {
89 inst.proc.Process.Kill()
90 inst.proc.Wait()
91 })
92
93 // Wait for the listener.
94 deadline := time.Now().Add(10 * time.Second)
95 for {
96 conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", inst.port), 200*time.Millisecond)
97 if err == nil {
98 conn.Close()
99 return inst
100 }
101 if time.Now().After(deadline) {
102 t.Fatal("gitbayd did not start listening")
103 }
104 time.Sleep(50 * time.Millisecond)
105 }
106}
107
108// admin runs a gitbayd admin command against the instance's database.
109func (i *instance) admin(t *testing.T, args ...string) string {
110 t.Helper()
111 cmd := exec.Command(i.gitbayd, append([]string{"--config", i.config}, args...)...)
112 out, err := cmd.CombinedOutput()
113 if err != nil {
114 t.Fatalf("gitbayd %v: %v\n%s", args, err, out)
115 }
116 return string(out)
117}
118
119// forgedAdminErr runs an admin command expected to fail, returning output.
120func (i *instance) forgedAdminErr(t *testing.T, args ...string) string {
121 t.Helper()
122 cmd := exec.Command(i.gitbayd, append([]string{"--config", i.config}, args...)...)
123 out, err := cmd.CombinedOutput()
124 if err == nil {
125 t.Fatalf("gitbayd %v unexpectedly succeeded:\n%s", args, out)
126 }
127 return string(out)
128}
129
130// newKey generates a client keypair and returns the private key path.
131func (i *instance) newKey(t *testing.T, name string) string {
132 t.Helper()
133 priv := filepath.Join(i.sshDir, name)
134 cmd := exec.Command("ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", name, "-f", priv)
135 if out, err := cmd.CombinedOutput(); err != nil {
136 t.Fatalf("ssh-keygen: %v\n%s", err, out)
137 }
138 return priv
139}
140
141// ssh runs the real OpenSSH client against the instance with the given key.
142func (i *instance) ssh(t *testing.T, key string, stdin string, args ...string) (string, string, int) {
143 t.Helper()
144 base := []string{
145 "-p", fmt.Sprint(i.port),
146 "-i", key,
147 "-o", "IdentitiesOnly=yes",
148 "-o", "StrictHostKeyChecking=no",
149 "-o", "UserKnownHostsFile=" + filepath.Join(i.sshDir, "known_hosts"),
150 "-o", "BatchMode=yes",
151 "git@127.0.0.1",
152 }
153 cmd := exec.Command("ssh", append(base, args...)...)
154 if stdin != "" {
155 cmd.Stdin = strings.NewReader(stdin)
156 }
157 var out, errOut strings.Builder
158 cmd.Stdout = &out
159 cmd.Stderr = &errOut
160 err := cmd.Run()
161 code := 0
162 if ee, ok := err.(*exec.ExitError); ok {
163 code = ee.ExitCode()
164 } else if err != nil {
165 t.Fatalf("ssh: %v", err)
166 }
167 return out.String(), errOut.String(), code
168}
169
170func TestControlPlaneOverBareSSH(t *testing.T) {
171 inst := startInstance(t)
172
173 aliceKey := inst.newKey(t, "alice")
174 inst.admin(t, "admin", "user", "create", "alice",
175 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
176
177 // whoami --json from bare OpenSSH.
178 out, errOut, code := inst.ssh(t, aliceKey, "", "whoami", "--json")
179 if code != 0 {
180 t.Fatalf("whoami exit %d, stderr: %s", code, errOut)
181 }
182 var env struct {
183 ProtocolVersion int `json:"protocol_version"`
184 Data struct {
185 Username string `json:"username"`
186 KeyScope string `json:"key_scope"`
187 } `json:"data"`
188 }
189 if err := json.Unmarshal([]byte(out), &env); err != nil {
190 t.Fatalf("whoami output not JSON: %v\n%s", err, out)
191 }
192 if env.Data.Username != "alice" || env.ProtocolVersion != 1 || env.Data.KeyScope != "full" {
193 t.Fatalf("whoami = %+v", env)
194 }
195
196 // Unknown key is refused at auth.
197 strangerKey := inst.newKey(t, "stranger")
198 _, _, code = inst.ssh(t, strangerKey, "", "whoami")
199 if code == 0 {
200 t.Fatal("unknown key was authenticated")
201 }
202
203 // keys add over stdin, then list shows both.
204 secondKey := inst.newKey(t, "alice2")
205 pub, _ := os.ReadFile(secondKey + ".pub")
206 out, errOut, code = inst.ssh(t, aliceKey, string(pub), "keys", "add", "--scope", "git")
207 if code != 0 {
208 t.Fatalf("keys add exit %d, stderr: %s", code, errOut)
209 }
210 out, _, code = inst.ssh(t, aliceKey, "", "keys", "list")
211 if code != 0 || len(strings.Split(strings.TrimSpace(out), "\n")) != 2 {
212 t.Fatalf("keys list exit %d:\n%s", code, out)
213 }
214
215 // The git-scoped key authenticates but is denied control commands.
216 out, errOut, code = inst.ssh(t, secondKey, "", "whoami")
217 if code != 4 {
218 t.Fatalf("git-scoped whoami: exit %d (want 4), stdout %q stderr %q", code, out, errOut)
219 }
220 if !strings.Contains(errOut, "does not allow control commands") {
221 t.Fatalf("scope denial message missing: %q", errOut)
222 }
223
224 // Duplicate key registration: bob cannot claim alice's key, and the
225 // message is the exact spec text, naming no account.
226 bobKey := inst.newKey(t, "bob")
227 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
228 alicePub, _ := os.ReadFile(aliceKey + ".pub")
229 _, errOut, code = inst.ssh(t, bobKey, string(alicePub), "keys", "add")
230 if code != 2 {
231 t.Fatalf("duplicate key add: exit %d, want 2", code)
232 }
233 want := "that key is already registered to another account; remove it there first or use a different key"
234 if !strings.Contains(errOut, want) {
235 t.Fatalf("duplicate key message = %q, want %q", errOut, want)
236 }
237 if strings.Contains(errOut, "alice") {
238 t.Fatalf("duplicate key message leaks account name: %q", errOut)
239 }
240
241 // Arguments with spaces survive the tokenizer round trip.
242 _, errOut, code = inst.ssh(t, aliceKey, "", "keys", "remove", "'no such fingerprint'")
243 if code != 3 {
244 t.Fatalf("keys remove with spaced arg: exit %d (want 3), stderr %q", code, errOut)
245 }
246}