e2e/ssh_test.go

ff759b53942049b2043e132f5482b442a7265b98
gitbay/e2e/ssh_test.go history · blame · raw

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