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