e2e/ssh_test.go

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

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