e2e/ssh_test.go

v1.16.0
gitbay/e2e/ssh_test.go history · blame · raw

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}