e2e/ssh_test.go

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

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