e2e/system_test.go

e6cd75b5f28bacf51620bb531320c30fd4e66bfd
gitbay/e2e/system_test.go history · blame · raw

213 lines · 7285 bytes

1 symbol in this file
  1package e2e
  2
  3import (
  4	"fmt"
  5	"net"
  6	"os"
  7	"os/exec"
  8	"os/user"
  9	"path/filepath"
 10	"strings"
 11	"testing"
 12	"time"
 13)
 14
 15// TestSystemSSHMode runs the M1/M2 scenarios against a real host sshd using
 16// AuthorizedKeysCommand + forced command instead of the embedded listener.
 17func TestSystemSSHMode(t *testing.T) {
 18	t.Parallel()
 19	sshdBin := "/usr/sbin/sshd"
 20	if _, err := os.Stat(sshdBin); err != nil {
 21		t.Skipf("no host sshd at %s", sshdBin)
 22	}
 23	me, err := user.Current()
 24	if err != nil {
 25		t.Fatal(err)
 26	}
 27
 28	// gitbayd in system mode: no embedded SSH listener; hookd + http still run.
 29	inst := startInstanceWith(t, "") // placeholder to reuse helpers; killed below
 30	inst.proc.Process.Kill()
 31	inst.proc.Wait()
 32	cfg := fmt.Sprintf(`
 33[server]
 34root = %q
 35site_url = "https://gitbay.test"
 36secret_key_file = %q
 37[ssh]
 38mode = "system"
 39[http]
 40addr = "127.0.0.1:%d"
 41tls = "off"
 42`, inst.root, inst.keyFile, inst.httpPort)
 43	if err := os.WriteFile(inst.config, []byte(cfg), 0o600); err != nil {
 44		t.Fatal(err)
 45	}
 46	inst.proc = exec.Command(inst.gitbayd, "--config", inst.config, "serve")
 47	inst.proc.Stderr = os.Stderr
 48	if err := inst.proc.Start(); err != nil {
 49		t.Fatal(err)
 50	}
 51	t.Cleanup(func() { inst.proc.Process.Kill(); inst.proc.Wait() })
 52
 53	aliceKey := inst.newKey(t, "alice")
 54	bobKey := inst.newKey(t, "bob")
 55	strangerKey := inst.newKey(t, "stranger")
 56	inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
 57	inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
 58
 59	// Host sshd on a high port as the current user.
 60	sshdDir := t.TempDir()
 61	hostKey := filepath.Join(sshdDir, "host_ed25519")
 62	if out, err := exec.Command("ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", hostKey).CombinedOutput(); err != nil {
 63		t.Fatalf("host keygen: %v\n%s", err, out)
 64	}
 65	// sshd requires the AuthorizedKeysCommand program itself to be owned by
 66	// root; a test-built binary is not. Use root-owned /bin/sh with a
 67	// wrapper script argument — only the command path is ownership-checked.
 68	wrapper := filepath.Join(sshdDir, "akc.sh")
 69	script := fmt.Sprintf("#!/bin/sh\nexec %q --config %q authorized-keys \"$1\" \"$2\"\n",
 70		inst.gitbayd, inst.config)
 71	if err := os.WriteFile(wrapper, []byte(script), 0o755); err != nil {
 72		t.Fatal(err)
 73	}
 74
 75	sshdPort := freePort(t)
 76	sshdConf := filepath.Join(sshdDir, "sshd_config")
 77	conf := fmt.Sprintf(`Port %d
 78ListenAddress 127.0.0.1
 79HostKey %s
 80PasswordAuthentication no
 81KbdInteractiveAuthentication no
 82PubkeyAuthentication yes
 83AuthorizedKeysFile none
 84AuthorizedKeysCommand /bin/sh %s %%t %%k
 85AuthorizedKeysCommandUser %s
 86StrictModes no
 87UsePAM no
 88PidFile %s
 89LogLevel ERROR
 90`, sshdPort, hostKey, wrapper, me.Username, filepath.Join(sshdDir, "sshd.pid"))
 91	if err := os.WriteFile(sshdConf, []byte(conf), 0o600); err != nil {
 92		t.Fatal(err)
 93	}
 94	sshd := exec.Command(sshdBin, "-D", "-e", "-f", sshdConf)
 95	sshd.Stderr = os.Stderr
 96	if err := sshd.Start(); err != nil {
 97		t.Fatal(err)
 98	}
 99	t.Cleanup(func() { sshd.Process.Kill(); sshd.Wait() })
100
101	deadline := time.Now().Add(10 * time.Second)
102	for {
103		conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", sshdPort), 200*time.Millisecond)
104		if err == nil {
105			conn.Close()
106			break
107		}
108		if time.Now().After(deadline) {
109			t.Fatal("sshd did not start")
110		}
111		time.Sleep(100 * time.Millisecond)
112	}
113
114	// ssh helper against the host sshd (login user = current user; identity
115	// still comes from the key).
116	sysSSH := func(key, stdin string, args ...string) (string, string, int) {
117		base := []string{
118			"-p", fmt.Sprint(sshdPort),
119			"-i", key,
120			"-o", "IdentitiesOnly=yes",
121			"-o", "StrictHostKeyChecking=no",
122			"-o", "UserKnownHostsFile=" + filepath.Join(sshdDir, "kh"),
123			"-o", "BatchMode=yes",
124			me.Username + "@127.0.0.1",
125		}
126		cmd := exec.Command("ssh", append(base, args...)...)
127		if stdin != "" {
128			cmd.Stdin = strings.NewReader(stdin)
129		}
130		var out, errOut strings.Builder
131		cmd.Stdout = &out
132		cmd.Stderr = &errOut
133		err := cmd.Run()
134		code := 0
135		if ee, ok := err.(*exec.ExitError); ok {
136			code = ee.ExitCode()
137		} else if err != nil {
138			t.Fatalf("ssh: %v", err)
139		}
140		return out.String(), errOut.String(), code
141	}
142
143	// M1: whoami over the host sshd.
144	out, errOut, code := sysSSH(aliceKey, "", "whoami", "--json")
145	if code != 0 {
146		t.Fatalf("whoami via sshd: exit %d\nstdout: %s\nstderr: %s", code, out, errOut)
147	}
148	if !strings.Contains(out, `"username":"alice"`) || !strings.Contains(out, `"protocol_version":1`) {
149		t.Fatalf("whoami output: %s", out)
150	}
151
152	// Unknown key: authentication fails inside sshd (authorized-keys emits
153	// nothing), before any forge code runs.
154	_, _, code = sysSSH(strangerKey, "", "whoami")
155	if code == 0 {
156		t.Fatal("stranger authenticated via host sshd")
157	}
158
159	// Scoped key: registered with git-only scope, denied control commands.
160	scopedKey := inst.newKey(t, "scoped")
161	pub, _ := os.ReadFile(scopedKey + ".pub")
162	if _, errOut, code := sysSSH(aliceKey, string(pub), "keys", "add", "--scope", "git"); code != 0 {
163		t.Fatalf("keys add: %s", errOut)
164	}
165	_, errOut, code = sysSSH(scopedKey, "", "whoami")
166	if code != 4 || !strings.Contains(errOut, "does not allow control commands") {
167		t.Fatalf("scoped denial via sshd: exit %d, %s", code, errOut)
168	}
169
170	// M2: private repo, push, denial, protected branch — through host sshd.
171	if _, errOut, code = sysSSH(aliceKey, "", "repo", "create", "alice/proj", "--private"); code != 0 {
172		t.Fatalf("repo create: %s", errOut)
173	}
174	sysGitEnv := func(key string) []string {
175		return append(os.Environ(),
176			fmt.Sprintf("GIT_SSH_COMMAND=ssh -i %s -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=%s -o BatchMode=yes",
177				key, filepath.Join(sshdDir, "kh")),
178			"GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null",
179			"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.test",
180			"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.test",
181		)
182	}
183	urlFor := func(repo string) string {
184		return fmt.Sprintf("ssh://%s@127.0.0.1:%d/%s.git", me.Username, sshdPort, repo)
185	}
186
187	work := t.TempDir()
188	aliceEnv := sysGitEnv(aliceKey)
189	mustGit(t, work, aliceEnv, "clone", urlFor("alice/proj"), "w")
190	dir := filepath.Join(work, "w")
191	os.WriteFile(filepath.Join(dir, "f"), []byte("x\n"), 0o644)
192	mustGit(t, dir, aliceEnv, "checkout", "-q", "-b", "main")
193	mustGit(t, dir, aliceEnv, "add", ".")
194	mustGit(t, dir, aliceEnv, "commit", "-q", "-m", "init")
195	mustGit(t, dir, aliceEnv, "push", "-q", "origin", "main")
196
197	// Bob: authenticated but no access — not-found, not permission-denied.
198	cloneOut, cloneCode := gitRun(t, t.TempDir(), sysGitEnv(bobKey), "clone", urlFor("alice/proj"))
199	if cloneCode == 0 || !strings.Contains(cloneOut, "repository not found") {
200		t.Fatalf("bob clone via sshd: %d\n%s", cloneCode, cloneOut)
201	}
202
203	// Protected branch: the hook path (forced command -> git -> pre-receive
204	// -> daemon unix socket) refuses the force-push.
205	if _, errOut, code = sysSSH(aliceKey, "", "repo", "settings", "protect", "alice/proj", "main"); code != 0 {
206		t.Fatalf("protect: %s", errOut)
207	}
208	mustGit(t, dir, aliceEnv, "commit", "-q", "--amend", "-m", "rewritten")
209	pushOut, pushCode := gitRun(t, dir, aliceEnv, "push", "--force", "origin", "main")
210	if pushCode == 0 || !strings.Contains(pushOut, "force-push refused") {
211		t.Fatalf("force-push via sshd: %d\n%s", pushCode, pushOut)
212	}
213}