e2e/ssh_test.go

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

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