internal/sshd/pushlimit_test.go
275 lines · 8636 bytes
1package sshd
2
3import (
4 "crypto/ed25519"
5 "crypto/rand"
6 "encoding/pem"
7 "fmt"
8 "io"
9 "net"
10 "os"
11 "os/exec"
12 "path/filepath"
13 "strings"
14 "testing"
15 "time"
16
17 "golang.org/x/crypto/ssh"
18
19 "gitbay.org/gitbay/internal/config"
20 "gitbay.org/gitbay/internal/control"
21 "gitbay.org/gitbay/internal/gitutil"
22 "gitbay.org/gitbay/internal/packlimit"
23 "gitbay.org/gitbay/internal/store"
24)
25
26// A client that connects and sends nothing is cut at
27// push_receive_timeout: the idle rule has not started, since no pack
28// has begun. Its slot comes back.
29func TestSilentPushKilledAtReceiveTimeout(t *testing.T) {
30 cfg, st, alice := cloneFixture(t)
31 cfg.Limits.PushIdle = "200ms"
32 cfg.Limits.PushReceiveTimeout = "1s"
33 key := store.SSHKey{ID: 1, Scope: "full", Fingerprint: "SHA256:test"}
34 pushes := packlimit.New(1, 1, 0, time.Second)
35 codec := make(chan int, 1)
36 start := time.Now()
37 go func() {
38 codec <- Exec(cfg, st, nil, pushes, alice, key, control.Term{}, "git-receive-pack alice/app",
39 silentStdin(t), io.Discard, io.Discard, nil, nil, nil)
40 }()
41 slotTaken(t, pushes)
42 pushEnded(t, codec, pushes, 5*time.Second)
43 if d := time.Since(start); d < time.Second {
44 t.Fatalf("killed after %s, before push_receive_timeout", d)
45 }
46}
47
48// pktLine frames s as one pkt-line.
49func pktLine(s string) string { return fmt.Sprintf("%04x%s", len(s)+4, s) }
50
51const zeroSHA = "0000000000000000000000000000000000000000"
52
53// Once the pack has begun, a client that goes silent is cut at
54// push_idle, long before push_receive_timeout.
55func TestPushKilledAtIdleOncePackStarts(t *testing.T) {
56 cfg, st, alice := cloneFixture(t)
57 cfg.Limits.PushIdle = "300ms"
58 key := store.SSHKey{ID: 1, Scope: "full", Fingerprint: "SHA256:test"}
59 pushes := packlimit.New(1, 1, 0, time.Second)
60 r, w, err := os.Pipe()
61 if err != nil {
62 t.Fatal(err)
63 }
64 t.Cleanup(func() { r.Close(); w.Close() })
65 io.WriteString(w, pktLine(zeroSHA+" "+strings.Repeat("1", 40)+" refs/heads/main\x00report-status\n")+"0000PA")
66 codec := make(chan int, 1)
67 start := time.Now()
68 go func() {
69 codec <- Exec(cfg, st, nil, pushes, alice, key, control.Term{}, "git-receive-pack alice/app",
70 r, io.Discard, io.Discard, nil, nil, nil)
71 }()
72 slotTaken(t, pushes)
73 // The signature split across two writes still arms the rule.
74 time.Sleep(100 * time.Millisecond)
75 io.WriteString(w, "CK")
76 pushEnded(t, codec, pushes, 5*time.Second)
77 if d := time.Since(start); d > 5*time.Second {
78 t.Fatalf("killed after %s", d)
79 }
80}
81
82// A client silent for longer than push_idle between its commands and
83// its pack, as while pack-objects compresses a large first push, still
84// completes.
85func TestPushSilentBeforePackCompletes(t *testing.T) {
86 cfg, st, alice := cloneFixture(t)
87 cfg.Limits.PushIdle = "300ms"
88 key := store.SSHKey{ID: 1, Scope: "full", Fingerprint: "SHA256:test"}
89 pushes := packlimit.New(1, 1, 0, time.Second)
90
91 src := t.TempDir()
92 git := func(stdin string, args ...string) []byte {
93 t.Helper()
94 cmd := exec.Command("git", append([]string{"-C", src, "-c", "user.name=t", "-c", "user.email=t@t"}, args...)...)
95 cmd.Stdin = strings.NewReader(stdin)
96 out, err := cmd.Output()
97 if err != nil {
98 t.Fatalf("git %v: %v", args, err)
99 }
100 return out
101 }
102 git("", "init", "-q", "-b", "main")
103 if err := os.WriteFile(filepath.Join(src, "README"), []byte("x\n"), 0o644); err != nil {
104 t.Fatal(err)
105 }
106 git("", "add", ".")
107 git("", "commit", "-q", "-m", "one")
108 sha := strings.TrimSpace(string(git("", "rev-parse", "HEAD")))
109 pack := git(sha+"\n", "pack-objects", "--revs", "--stdout", "-q")
110
111 pr, pw := io.Pipe()
112 go func() {
113 io.WriteString(pw, pktLine(zeroSHA+" "+sha+" refs/heads/main\x00report-status\n")+"0000")
114 time.Sleep(time.Second)
115 pw.Write(pack)
116 pw.Close()
117 }()
118 var errOut strings.Builder
119 if code := Exec(cfg, st, nil, pushes, alice, key, control.Term{}, "git-receive-pack alice/app",
120 pr, io.Discard, &errOut, nil, nil, nil); code != 0 {
121 t.Fatalf("exit %d: %s", code, errOut.String())
122 }
123 out, err := exec.Command("git", "-C", control.RepoDir(cfg.Server.Root, "alice", "app"), "rev-parse", "refs/heads/main").Output()
124 if err != nil || strings.TrimSpace(string(out)) != sha {
125 t.Fatalf("main is %q (%v), want %s", out, err, sha)
126 }
127}
128
129// A client that trickles bytes stays clear of push_idle but is cut when
130// pre-receive has not started push_receive_timeout after the slot.
131func TestTricklingPushKilledAtReceiveTimeout(t *testing.T) {
132 cfg, st, alice := cloneFixture(t)
133 cfg.Limits.PushIdle = "300ms"
134 cfg.Limits.PushReceiveTimeout = "1s"
135 key := store.SSHKey{ID: 1, Scope: "full", Fingerprint: "SHA256:test"}
136 pushes := packlimit.New(1, 1, 0, time.Second)
137 r, w, err := os.Pipe()
138 if err != nil {
139 t.Fatal(err)
140 }
141 t.Cleanup(func() { r.Close(); w.Close() })
142 stop := make(chan struct{})
143 defer close(stop)
144 go func() {
145 // One long pkt-line whose payload arrives a byte at a time.
146 if _, err := io.WriteString(w, "fff0"); err != nil {
147 return
148 }
149 for {
150 select {
151 case <-stop:
152 return
153 case <-time.After(50 * time.Millisecond):
154 if _, err := w.Write([]byte("a")); err != nil {
155 return
156 }
157 }
158 }
159 }()
160 codec := make(chan int, 1)
161 start := time.Now()
162 go func() {
163 codec <- Exec(cfg, st, nil, pushes, alice, key, control.Term{}, "git-receive-pack alice/app",
164 r, io.Discard, io.Discard, nil, nil, nil)
165 }()
166 slotTaken(t, pushes)
167 pushEnded(t, codec, pushes, 5*time.Second)
168 if d := time.Since(start); d < time.Second {
169 t.Fatalf("killed after %s, before push_receive_timeout", d)
170 }
171}
172
173// A real push whose post-receive outlasts push_idle completes: git's
174// side-band keepalives count as bytes to the client.
175func TestPushSurvivesLongPostReceive(t *testing.T) {
176 if _, err := exec.LookPath("ssh"); err != nil {
177 t.Skip("no ssh client")
178 }
179 root := t.TempDir()
180 st, err := store.Open(filepath.Join(root, "gitbay.db"))
181 if err != nil {
182 t.Fatal(err)
183 }
184 t.Cleanup(func() { st.Close() })
185 if err := st.MigrateUp(); err != nil {
186 t.Fatal(err)
187 }
188 uid, err := st.CreateUser("alice", false)
189 if err != nil {
190 t.Fatal(err)
191 }
192 if _, err := st.CreateRepo("user", uid, "app", "public"); err != nil {
193 t.Fatal(err)
194 }
195 hooks := t.TempDir()
196 if err := os.WriteFile(filepath.Join(hooks, "post-receive"), []byte("#!/bin/sh\nsleep 4\n"), 0o755); err != nil {
197 t.Fatal(err)
198 }
199 if err := gitutil.InitBare(control.RepoDir(root, "alice", "app"), "main", hooks); err != nil {
200 t.Fatal(err)
201 }
202
203 pub, priv, err := ed25519.GenerateKey(rand.Reader)
204 if err != nil {
205 t.Fatal(err)
206 }
207 sshPub, err := ssh.NewPublicKey(pub)
208 if err != nil {
209 t.Fatal(err)
210 }
211 if err := st.AddSSHKey(uid, ssh.FingerprintSHA256(sshPub), sshPub.Type(), sshPub.Marshal(), "full", "test"); err != nil {
212 t.Fatal(err)
213 }
214 block, err := ssh.MarshalPrivateKey(priv, "")
215 if err != nil {
216 t.Fatal(err)
217 }
218 keyFile := filepath.Join(t.TempDir(), "id_ed25519")
219 if err := os.WriteFile(keyFile, pem.EncodeToMemory(block), 0o600); err != nil {
220 t.Fatal(err)
221 }
222
223 cfg := config.Default()
224 cfg.Server.Root = root
225 cfg.Limits.PushIdle = "2s"
226 pushes := packlimit.New(1, 1, 0, time.Second)
227 srv, err := New(cfg, st, nil, pushes)
228 if err != nil {
229 t.Fatal(err)
230 }
231 ln, err := net.Listen("tcp", "127.0.0.1:0")
232 if err != nil {
233 t.Fatal(err)
234 }
235 go srv.Serve(ln)
236 t.Cleanup(func() { ln.Close() })
237 port := ln.Addr().(*net.TCPAddr).Port
238
239 src := t.TempDir()
240 git := func(args ...string) string {
241 t.Helper()
242 cmd := exec.Command("git", args...)
243 cmd.Dir = src
244 cmd.Env = append(os.Environ(),
245 "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_NOSYSTEM=1",
246 "GIT_AUTHOR_NAME=a", "GIT_AUTHOR_EMAIL=a@example.test",
247 "GIT_COMMITTER_NAME=a", "GIT_COMMITTER_EMAIL=a@example.test",
248 fmt.Sprintf("GIT_SSH_COMMAND=ssh -F /dev/null -i %s -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -p %d", keyFile, port))
249 out, err := cmd.CombinedOutput()
250 if err != nil {
251 t.Fatalf("git %v: %v\n%s", args, err, out)
252 }
253 return string(out)
254 }
255 git("init", "-q", "-b", "main")
256 if err := os.WriteFile(filepath.Join(src, "README"), []byte("x\n"), 0o644); err != nil {
257 t.Fatal(err)
258 }
259 git("add", ".")
260 git("commit", "-q", "-m", "one")
261 start := time.Now()
262 git("push", "git@127.0.0.1:alice/app", "main")
263 if d := time.Since(start); d < 4*time.Second {
264 t.Fatalf("push took %s; the post-receive did not run", d)
265 }
266 out, err := exec.Command("git", "-C", control.RepoDir(root, "alice", "app"), "rev-parse", "refs/heads/main").CombinedOutput()
267 if err != nil || strings.TrimSpace(string(out)) == "" {
268 t.Fatalf("main not pushed: %v %s", err, out)
269 }
270 r, err := pushes.Acquire(nil, "elsewhere")
271 if err != nil {
272 t.Fatalf("slot not released: %v", err)
273 }
274 r()
275}