internal/sshd/refusal_test.go
415 lines · 13452 bytes
1package sshd
2
3import (
4 "bytes"
5 "errors"
6 "io"
7 "os"
8 "path/filepath"
9 "strconv"
10 "strings"
11 "testing"
12 "time"
13
14 "gitbay.org/gitbay/internal/config"
15 "gitbay.org/gitbay/internal/control"
16 "gitbay.org/gitbay/internal/gitutil"
17 "gitbay.org/gitbay/internal/packlimit"
18 "gitbay.org/gitbay/internal/protocol"
19 "gitbay.org/gitbay/internal/store"
20)
21
22// execFixture: alice owns the public alice/app; bob has no grant on it.
23func execFixture(t *testing.T) (config.Config, *store.Store, store.User) {
24 t.Helper()
25 st, err := store.Open(filepath.Join(t.TempDir(), "gitbay.db"))
26 if err != nil {
27 t.Fatal(err)
28 }
29 t.Cleanup(func() { st.Close() })
30 if err := st.MigrateUp(); err != nil {
31 t.Fatal(err)
32 }
33 alice, err := st.CreateUser("alice", false)
34 if err != nil {
35 t.Fatal(err)
36 }
37 if _, err := st.CreateRepo("user", alice, "app", "public"); err != nil {
38 t.Fatal(err)
39 }
40 bobID, err := st.CreateUser("bob", false)
41 if err != nil {
42 t.Fatal(err)
43 }
44 bob, err := st.UserByID(bobID)
45 if err != nil {
46 t.Fatal(err)
47 }
48 cfg := config.Default()
49 cfg.Server.Root = t.TempDir()
50 return cfg, st, bob
51}
52
53// A refused push leaves one row holding the target, the key and the exit
54// code, whether runGit refused it or the account is not yet active.
55func TestRefusedPushIsAudited(t *testing.T) {
56 for _, pending := range []bool{false, true} {
57 cfg, st, bob := execFixture(t)
58 bob.Pending = pending
59 key := store.SSHKey{Scope: "full", Fingerprint: "SHA256:test"}
60 var out, errOut bytes.Buffer
61 code := Exec(cfg, st, nil, nil, bob, key, control.Term{}, "git-receive-pack alice/app",
62 strings.NewReader(""), &out, &errOut, nil, nil, nil)
63 if code != protocol.ExitDenied {
64 t.Fatalf("pending %v: exit %d: %s", pending, code, errOut.String())
65 }
66 got, err := st.AuditEntries(store.AuditFilter{ActionPrefix: "refused git-receive-pack", Limit: 5})
67 if err != nil || len(got) != 1 || got[0].Actor != "bob" {
68 t.Fatalf("pending %v: entries %+v, %v", pending, got, err)
69 }
70 want := `{"argv":["alice/app"],"exit":4,"source":"SHA256:test"}`
71 if got[0].Data != want {
72 t.Fatalf("pending %v: data %s, want %s", pending, got[0].Data, want)
73 }
74 }
75}
76
77func TestCloneRefusedWhenPackSlotsAreFull(t *testing.T) {
78 cfg, st, bob := execFixture(t)
79 packs := packlimit.New(1, 0, 0, time.Second)
80 hold, err := packs.Acquire(nil, "ip:elsewhere")
81 if err != nil {
82 t.Fatal(err)
83 }
84 defer hold()
85 key := store.SSHKey{Scope: "full", Fingerprint: "SHA256:test"}
86 for _, service := range []string{"git-upload-pack", "git-upload-archive"} {
87 var out, errOut bytes.Buffer
88 code := Exec(cfg, st, packs, nil, bob, key, control.Term{}, service+" alice/app",
89 strings.NewReader(""), &out, &errOut, nil, nil, nil)
90 if code != protocol.ExitFailure || !strings.Contains(errOut.String(), "busy") {
91 t.Fatalf("%s: exit %d: %q", service, code, errOut.String())
92 }
93 }
94}
95
96// A push takes no pack slot: it runs while every slot is held. A clone
97// gives its slot back once git has exited.
98func TestPushBypassesPackLimitAndCloneReleasesSlot(t *testing.T) {
99 cfg, st, _ := execFixture(t)
100 alice, err := st.UserByUsername("alice")
101 if err != nil {
102 t.Fatal(err)
103 }
104 if err := gitutil.InitBare(control.RepoDir(cfg.Server.Root, "alice", "app"), "main", t.TempDir()); err != nil {
105 t.Fatal(err)
106 }
107 key := store.SSHKey{Scope: "full", Fingerprint: "SHA256:test"}
108 packs := packlimit.New(1, 0, 0, time.Second)
109
110 var out, errOut bytes.Buffer
111 if code := Exec(cfg, st, packs, nil, alice, key, control.Term{}, "git-upload-pack alice/app",
112 strings.NewReader("0000"), &out, &errOut, nil, nil, nil); code != protocol.ExitOK {
113 t.Fatalf("clone: exit %d: %s", code, errOut.String())
114 }
115 hold, err := packs.Acquire(nil, "ip:elsewhere")
116 if err != nil {
117 t.Fatalf("slot not released after the clone: %v", err)
118 }
119 defer hold()
120
121 out.Reset()
122 errOut.Reset()
123 if code := Exec(cfg, st, packs, nil, alice, key, control.Term{}, "git-receive-pack alice/app",
124 strings.NewReader("0000"), &out, &errOut, nil, nil, nil); code != protocol.ExitOK {
125 t.Fatalf("push with slots full: exit %d: %s", code, errOut.String())
126 }
127}
128
129// cloneFixture adds an empty bare alice/app on disk and returns alice.
130func cloneFixture(t *testing.T) (config.Config, *store.Store, store.User) {
131 t.Helper()
132 cfg, st, _ := execFixture(t)
133 alice, err := st.UserByUsername("alice")
134 if err != nil {
135 t.Fatal(err)
136 }
137 if err := gitutil.InitBare(control.RepoDir(cfg.Server.Root, "alice", "app"), "main", t.TempDir()); err != nil {
138 t.Fatal(err)
139 }
140 return cfg, st, alice
141}
142
143// silentStdin is a client that sends nothing and never hangs up. It is
144// an *os.File, so git reads it directly: git exits only when killed.
145func silentStdin(t *testing.T) *os.File {
146 t.Helper()
147 r, w, err := os.Pipe()
148 if err != nil {
149 t.Fatal(err)
150 }
151 t.Cleanup(func() { r.Close(); w.Close() })
152 return r
153}
154
155// killedClone runs a clone of alice/app with the given channels and
156// requires it to end, killed, within five seconds, with its slot free.
157func killedClone(t *testing.T, stdout io.Writer, done, stopping, revoked <-chan struct{}) {
158 t.Helper()
159 cfg, st, alice := cloneFixture(t)
160 key := store.SSHKey{Scope: "full", Fingerprint: "SHA256:test"}
161 packs := packlimit.New(1, 0, 0, time.Second)
162 codec := make(chan int, 1)
163 go func() {
164 codec <- Exec(cfg, st, packs, nil, alice, key, control.Term{}, "git-upload-pack alice/app",
165 silentStdin(t), stdout, io.Discard, done, stopping, revoked)
166 }()
167 select {
168 case code := <-codec:
169 if code != protocol.ExitFailure {
170 t.Fatalf("exit %d, want the clone killed", code)
171 }
172 case <-time.After(5 * time.Second):
173 t.Fatal("clone still running")
174 }
175 hold, err := packs.Acquire(nil, "ip:elsewhere")
176 if err != nil {
177 t.Fatalf("slot not released after the kill: %v", err)
178 }
179 hold()
180}
181
182func closed() <-chan struct{} {
183 c := make(chan struct{})
184 close(c)
185 return c
186}
187
188func TestCloneKilledWhenClientLeaves(t *testing.T) {
189 killedClone(t, io.Discard, closed(), nil, nil)
190}
191
192// A revoked key ends a clone even during a restart.
193func TestCloneKilledWhenKeyRevoked(t *testing.T) {
194 killedClone(t, io.Discard, nil, closed(), closed())
195}
196
197// A client that stops reading is cut after packlimit.StallDeadline.
198func TestCloneKilledWhenClientStopsReading(t *testing.T) {
199 old := packlimit.StallDeadline
200 packlimit.StallDeadline = 200 * time.Millisecond
201 t.Cleanup(func() { packlimit.StallDeadline = old })
202 r, w := io.Pipe()
203 t.Cleanup(func() { r.Close() })
204 killedClone(t, w, nil, nil, nil)
205}
206
207// On a restart (done and stopping both closed) a running clone finishes.
208func TestCloneRunsOnDuringRestart(t *testing.T) {
209 cfg, st, alice := cloneFixture(t)
210 key := store.SSHKey{Scope: "full", Fingerprint: "SHA256:test"}
211 packs := packlimit.New(1, 0, 0, time.Second)
212 var errOut bytes.Buffer
213 if code := Exec(cfg, st, packs, nil, alice, key, control.Term{}, "git-upload-pack alice/app",
214 strings.NewReader("0000"), io.Discard, &errOut, closed(), closed(), nil); code != protocol.ExitOK {
215 t.Fatalf("exit %d: %s", code, errOut.String())
216 }
217}
218
219// A request the key may not make is refused before it reaches the
220// limiter: not found, never busy.
221func TestRefusedCloneStaysOffLimiter(t *testing.T) {
222 cfg, st, bob := execFixture(t)
223 alice, err := st.UserByUsername("alice")
224 if err != nil {
225 t.Fatal(err)
226 }
227 if _, err := st.CreateRepo("user", alice.ID, "secret", "private"); err != nil {
228 t.Fatal(err)
229 }
230 packs := packlimit.New(1, 0, 0, time.Second)
231 hold, err := packs.Acquire(nil, "ip:elsewhere")
232 if err != nil {
233 t.Fatal(err)
234 }
235 defer hold()
236 key := store.SSHKey{Scope: "full", Fingerprint: "SHA256:test"}
237 var out, errOut bytes.Buffer
238 code := Exec(cfg, st, packs, nil, bob, key, control.Term{}, "git-upload-pack alice/secret",
239 strings.NewReader(""), &out, &errOut, nil, nil, nil)
240 if code != protocol.ExitNotFound || strings.Contains(errOut.String(), "busy") {
241 t.Fatalf("exit %d: %q", code, errOut.String())
242 }
243}
244
245// hungUpStdin is a client that sends nothing until hangUp, which ends
246// its stdin the way a closed channel does.
247func hungUpStdin(t *testing.T) (r *os.File, hangUp func()) {
248 t.Helper()
249 r, w, err := os.Pipe()
250 if err != nil {
251 t.Fatal(err)
252 }
253 t.Cleanup(func() { r.Close(); w.Close() })
254 return r, func() { w.Close() }
255}
256
257// queuedFor waits until principal has a push waiting on l: a probe that
258// gives up at once is then refused busy rather than queued.
259func queuedFor(t *testing.T, l *packlimit.Limiter, principal string) {
260 t.Helper()
261 deadline := time.Now().Add(5 * time.Second)
262 for time.Now().Before(deadline) {
263 if _, err := l.Acquire(closed(), principal); errors.Is(err, packlimit.ErrBusy) {
264 return
265 }
266 time.Sleep(10 * time.Millisecond)
267 }
268 t.Fatalf("no push queued for %s", principal)
269}
270
271// With push_per_principal 1 one account runs one push, queues a second
272// and is refused a third, while another principal still gets in. A
273// client hanging up frees its slot for the one queued behind it.
274func TestPushPerPrincipalCap(t *testing.T) {
275 cfg, st, alice := cloneFixture(t)
276 repo, err := st.RepoByPath("alice/app")
277 if err != nil {
278 t.Fatal(err)
279 }
280 key := store.SSHKey{ID: 1, Scope: "full", Fingerprint: "SHA256:test"}
281 pushes := packlimit.New(2, 1, 16, 10*time.Second)
282 push := func(k store.SSHKey, stdin io.Reader, errOut io.Writer) <-chan int {
283 codec := make(chan int, 1)
284 go func() {
285 codec <- Exec(cfg, st, nil, pushes, alice, k, control.Term{}, "git-receive-pack alice/app",
286 stdin, io.Discard, errOut, nil, nil, nil)
287 }()
288 return codec
289 }
290 exited := func(codec <-chan int, want int, what string) {
291 t.Helper()
292 select {
293 case code := <-codec:
294 if code != want {
295 t.Fatalf("%s: exit %d", what, code)
296 }
297 case <-time.After(5 * time.Second):
298 t.Fatalf("%s still running", what)
299 }
300 }
301 principal := "user:" + strconv.FormatInt(alice.ID, 10)
302
303 in1, hangUp1 := hungUpStdin(t)
304 first := push(key, in1, io.Discard)
305 // The first holds alice's one slot once a probe cannot take it.
306 deadline := time.Now().Add(5 * time.Second)
307 for {
308 r, err := pushes.Acquire(closed(), principal)
309 if err != nil {
310 break
311 }
312 r()
313 if time.Now().After(deadline) {
314 t.Fatal("first push never took a slot")
315 }
316 time.Sleep(10 * time.Millisecond)
317 }
318 in2, hangUp2 := hungUpStdin(t)
319 second := push(key, in2, io.Discard)
320 queuedFor(t, pushes, principal)
321
322 var errOut bytes.Buffer
323 if code := Exec(cfg, st, nil, pushes, alice, key, control.Term{}, "git-receive-pack alice/app",
324 strings.NewReader(""), io.Discard, &errOut, nil, nil, nil); code != protocol.ExitFailure ||
325 !strings.Contains(errOut.String(), "limit of concurrent pushes") {
326 t.Fatalf("third push: exit %d: %q", code, errOut.String())
327 }
328
329 // A deploy key on the same account is its own principal: it takes
330 // the second global slot while alice's push waits.
331 deploy := store.SSHKey{ID: 2, Scope: "deploy:" + strconv.FormatInt(repo.ID, 10) + ":rw", Fingerprint: "SHA256:deploy"}
332 exited(push(deploy, strings.NewReader("0000"), io.Discard), protocol.ExitOK, "deploy key push")
333
334 // receive-pack fails a client that hangs up before sending anything.
335 hangUp1()
336 exited(first, protocol.ExitFailure, "first push")
337 hangUp2()
338 exited(second, protocol.ExitFailure, "second push")
339 for _, p := range []string{"a", "b"} {
340 r, err := pushes.Acquire(nil, p)
341 if err != nil {
342 t.Fatalf("slot not released: %v", err)
343 }
344 defer r()
345 }
346}
347
348// A revoked key kills a push waiting on its client, and the slot it
349// held comes back.
350func TestPushKilledWhenKeyRevokedReleasesSlot(t *testing.T) {
351 cfg, st, alice := cloneFixture(t)
352 key := store.SSHKey{ID: 1, Scope: "full", Fingerprint: "SHA256:test"}
353 pushes := packlimit.New(1, 1, 0, time.Second)
354 revoked := make(chan struct{})
355 codec := make(chan int, 1)
356 go func() {
357 codec <- Exec(cfg, st, nil, pushes, alice, key, control.Term{}, "git-receive-pack alice/app",
358 silentStdin(t), io.Discard, io.Discard, nil, nil, revoked)
359 }()
360 slotTaken(t, pushes)
361 close(revoked)
362 pushEnded(t, codec, pushes, 5*time.Second)
363}
364
365// slotTaken waits until l's one slot is held.
366func slotTaken(t *testing.T, l *packlimit.Limiter) {
367 t.Helper()
368 deadline := time.Now().Add(5 * time.Second)
369 for time.Now().Before(deadline) {
370 r, err := l.Acquire(closed(), "probe")
371 if err != nil {
372 return
373 }
374 r()
375 time.Sleep(10 * time.Millisecond)
376 }
377 t.Fatal("the push never took the slot")
378}
379
380// pushEnded requires a push to end, killed, within within, with l's
381// slot free again.
382func pushEnded(t *testing.T, codec <-chan int, l *packlimit.Limiter, within time.Duration) {
383 t.Helper()
384 select {
385 case code := <-codec:
386 if code != protocol.ExitFailure {
387 t.Fatalf("exit %d, want the push killed", code)
388 }
389 case <-time.After(within):
390 t.Fatal("push still running")
391 }
392 r, err := l.Acquire(nil, "elsewhere")
393 if err != nil {
394 t.Fatalf("slot not released after the kill: %v", err)
395 }
396 r()
397}
398
399// A push the key may not make is refused before it reaches the limiter.
400func TestRefusedPushStaysOffLimiter(t *testing.T) {
401 cfg, st, bob := execFixture(t)
402 pushes := packlimit.New(1, 0, 0, time.Second)
403 hold, err := pushes.Acquire(nil, "elsewhere")
404 if err != nil {
405 t.Fatal(err)
406 }
407 defer hold()
408 key := store.SSHKey{Scope: "full", Fingerprint: "SHA256:test"}
409 var errOut bytes.Buffer
410 code := Exec(cfg, st, nil, pushes, bob, key, control.Term{}, "git-receive-pack alice/app",
411 strings.NewReader(""), io.Discard, &errOut, nil, nil, nil)
412 if code != protocol.ExitDenied || strings.Contains(errOut.String(), "busy") {
413 t.Fatalf("exit %d: %q", code, errOut.String())
414 }
415}