e2e/ssh_test.go

6b01ef3788b21e3a4c027a2afe7e7a5bda9ca000
gitbay/e2e/ssh_test.go history · blame · raw

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