internal/sshd/refusal_test.go
241 lines · 7807 bytes
1package sshd
2
3import (
4 "bytes"
5 "io"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10 "time"
11
12 "gitbay.org/gitbay/internal/config"
13 "gitbay.org/gitbay/internal/control"
14 "gitbay.org/gitbay/internal/gitutil"
15 "gitbay.org/gitbay/internal/packlimit"
16 "gitbay.org/gitbay/internal/protocol"
17 "gitbay.org/gitbay/internal/store"
18)
19
20// execFixture: alice owns the public alice/app; bob has no grant on it.
21func execFixture(t *testing.T) (config.Config, *store.Store, store.User) {
22 t.Helper()
23 st, err := store.Open(filepath.Join(t.TempDir(), "gitbay.db"))
24 if err != nil {
25 t.Fatal(err)
26 }
27 t.Cleanup(func() { st.Close() })
28 if err := st.MigrateUp(); err != nil {
29 t.Fatal(err)
30 }
31 alice, err := st.CreateUser("alice", false)
32 if err != nil {
33 t.Fatal(err)
34 }
35 if _, err := st.CreateRepo("user", alice, "app", "public"); err != nil {
36 t.Fatal(err)
37 }
38 bobID, err := st.CreateUser("bob", false)
39 if err != nil {
40 t.Fatal(err)
41 }
42 bob, err := st.UserByID(bobID)
43 if err != nil {
44 t.Fatal(err)
45 }
46 cfg := config.Default()
47 cfg.Server.Root = t.TempDir()
48 return cfg, st, bob
49}
50
51// A refused push leaves one row holding the target, the key and the exit
52// code, whether runGit refused it or the account is not yet active.
53func TestRefusedPushIsAudited(t *testing.T) {
54 for _, pending := range []bool{false, true} {
55 cfg, st, bob := execFixture(t)
56 bob.Pending = pending
57 key := store.SSHKey{Scope: "full", Fingerprint: "SHA256:test"}
58 var out, errOut bytes.Buffer
59 code := Exec(cfg, st, nil, bob, key, control.Term{}, "git-receive-pack alice/app",
60 strings.NewReader(""), &out, &errOut, nil, nil, nil)
61 if code != protocol.ExitDenied {
62 t.Fatalf("pending %v: exit %d: %s", pending, code, errOut.String())
63 }
64 got, err := st.AuditEntries(store.AuditFilter{ActionPrefix: "refused git-receive-pack", Limit: 5})
65 if err != nil || len(got) != 1 || got[0].Actor != "bob" {
66 t.Fatalf("pending %v: entries %+v, %v", pending, got, err)
67 }
68 want := `{"argv":["alice/app"],"exit":4,"source":"SHA256:test"}`
69 if got[0].Data != want {
70 t.Fatalf("pending %v: data %s, want %s", pending, got[0].Data, want)
71 }
72 }
73}
74
75func TestCloneRefusedWhenPackSlotsAreFull(t *testing.T) {
76 cfg, st, bob := execFixture(t)
77 packs := packlimit.New(1, 0, 0, time.Second)
78 hold, err := packs.Acquire(nil, "ip:elsewhere")
79 if err != nil {
80 t.Fatal(err)
81 }
82 defer hold()
83 key := store.SSHKey{Scope: "full", Fingerprint: "SHA256:test"}
84 for _, service := range []string{"git-upload-pack", "git-upload-archive"} {
85 var out, errOut bytes.Buffer
86 code := Exec(cfg, st, packs, bob, key, control.Term{}, service+" alice/app",
87 strings.NewReader(""), &out, &errOut, nil, nil, nil)
88 if code != protocol.ExitFailure || !strings.Contains(errOut.String(), "busy") {
89 t.Fatalf("%s: exit %d: %q", service, code, errOut.String())
90 }
91 }
92}
93
94// A push takes no pack slot: it runs while every slot is held. A clone
95// gives its slot back once git has exited.
96func TestPushBypassesPackLimitAndCloneReleasesSlot(t *testing.T) {
97 cfg, st, _ := execFixture(t)
98 alice, err := st.UserByUsername("alice")
99 if err != nil {
100 t.Fatal(err)
101 }
102 if err := gitutil.InitBare(control.RepoDir(cfg.Server.Root, "alice", "app"), "main", t.TempDir()); err != nil {
103 t.Fatal(err)
104 }
105 key := store.SSHKey{Scope: "full", Fingerprint: "SHA256:test"}
106 packs := packlimit.New(1, 0, 0, time.Second)
107
108 var out, errOut bytes.Buffer
109 if code := Exec(cfg, st, packs, alice, key, control.Term{}, "git-upload-pack alice/app",
110 strings.NewReader("0000"), &out, &errOut, nil, nil, nil); code != protocol.ExitOK {
111 t.Fatalf("clone: exit %d: %s", code, errOut.String())
112 }
113 hold, err := packs.Acquire(nil, "ip:elsewhere")
114 if err != nil {
115 t.Fatalf("slot not released after the clone: %v", err)
116 }
117 defer hold()
118
119 out.Reset()
120 errOut.Reset()
121 if code := Exec(cfg, st, packs, alice, key, control.Term{}, "git-receive-pack alice/app",
122 strings.NewReader("0000"), &out, &errOut, nil, nil, nil); code != protocol.ExitOK {
123 t.Fatalf("push with slots full: exit %d: %s", code, errOut.String())
124 }
125}
126
127// cloneFixture adds an empty bare alice/app on disk and returns alice.
128func cloneFixture(t *testing.T) (config.Config, *store.Store, store.User) {
129 t.Helper()
130 cfg, st, _ := execFixture(t)
131 alice, err := st.UserByUsername("alice")
132 if err != nil {
133 t.Fatal(err)
134 }
135 if err := gitutil.InitBare(control.RepoDir(cfg.Server.Root, "alice", "app"), "main", t.TempDir()); err != nil {
136 t.Fatal(err)
137 }
138 return cfg, st, alice
139}
140
141// silentStdin is a client that sends nothing and never hangs up. It is
142// an *os.File, so git reads it directly: git exits only when killed.
143func silentStdin(t *testing.T) *os.File {
144 t.Helper()
145 r, w, err := os.Pipe()
146 if err != nil {
147 t.Fatal(err)
148 }
149 t.Cleanup(func() { r.Close(); w.Close() })
150 return r
151}
152
153// killedClone runs a clone of alice/app with the given channels and
154// requires it to end, killed, within five seconds, with its slot free.
155func killedClone(t *testing.T, stdout io.Writer, done, stopping, revoked <-chan struct{}) {
156 t.Helper()
157 cfg, st, alice := cloneFixture(t)
158 key := store.SSHKey{Scope: "full", Fingerprint: "SHA256:test"}
159 packs := packlimit.New(1, 0, 0, time.Second)
160 codec := make(chan int, 1)
161 go func() {
162 codec <- Exec(cfg, st, packs, alice, key, control.Term{}, "git-upload-pack alice/app",
163 silentStdin(t), stdout, io.Discard, done, stopping, revoked)
164 }()
165 select {
166 case code := <-codec:
167 if code != protocol.ExitFailure {
168 t.Fatalf("exit %d, want the clone killed", code)
169 }
170 case <-time.After(5 * time.Second):
171 t.Fatal("clone still running")
172 }
173 hold, err := packs.Acquire(nil, "ip:elsewhere")
174 if err != nil {
175 t.Fatalf("slot not released after the kill: %v", err)
176 }
177 hold()
178}
179
180func closed() <-chan struct{} {
181 c := make(chan struct{})
182 close(c)
183 return c
184}
185
186func TestCloneKilledWhenClientLeaves(t *testing.T) {
187 killedClone(t, io.Discard, closed(), nil, nil)
188}
189
190// A revoked key ends a clone even during a restart.
191func TestCloneKilledWhenKeyRevoked(t *testing.T) {
192 killedClone(t, io.Discard, nil, closed(), closed())
193}
194
195// A client that stops reading is cut after packlimit.StallDeadline.
196func TestCloneKilledWhenClientStopsReading(t *testing.T) {
197 old := packlimit.StallDeadline
198 packlimit.StallDeadline = 200 * time.Millisecond
199 t.Cleanup(func() { packlimit.StallDeadline = old })
200 r, w := io.Pipe()
201 t.Cleanup(func() { r.Close() })
202 killedClone(t, w, nil, nil, nil)
203}
204
205// On a restart (done and stopping both closed) a running clone finishes.
206func TestCloneRunsOnDuringRestart(t *testing.T) {
207 cfg, st, alice := cloneFixture(t)
208 key := store.SSHKey{Scope: "full", Fingerprint: "SHA256:test"}
209 packs := packlimit.New(1, 0, 0, time.Second)
210 var errOut bytes.Buffer
211 if code := Exec(cfg, st, packs, alice, key, control.Term{}, "git-upload-pack alice/app",
212 strings.NewReader("0000"), io.Discard, &errOut, closed(), closed(), nil); code != protocol.ExitOK {
213 t.Fatalf("exit %d: %s", code, errOut.String())
214 }
215}
216
217// A request the key may not make is refused before it reaches the
218// limiter: not found, never busy.
219func TestRefusedCloneStaysOffLimiter(t *testing.T) {
220 cfg, st, bob := execFixture(t)
221 alice, err := st.UserByUsername("alice")
222 if err != nil {
223 t.Fatal(err)
224 }
225 if _, err := st.CreateRepo("user", alice.ID, "secret", "private"); err != nil {
226 t.Fatal(err)
227 }
228 packs := packlimit.New(1, 0, 0, time.Second)
229 hold, err := packs.Acquire(nil, "ip:elsewhere")
230 if err != nil {
231 t.Fatal(err)
232 }
233 defer hold()
234 key := store.SSHKey{Scope: "full", Fingerprint: "SHA256:test"}
235 var out, errOut bytes.Buffer
236 code := Exec(cfg, st, packs, bob, key, control.Term{}, "git-upload-pack alice/secret",
237 strings.NewReader(""), &out, &errOut, nil, nil, nil)
238 if code != protocol.ExitNotFound || strings.Contains(errOut.String(), "busy") {
239 t.Fatalf("exit %d: %q", code, errOut.String())
240 }
241}