internal/sshd/sshd_test.go
413 lines · 11427 bytes
1package sshd
2
3import (
4 "bufio"
5 "bytes"
6 "crypto/ed25519"
7 "crypto/rand"
8 "errors"
9 "net"
10 "path/filepath"
11 "strings"
12 "testing"
13 "time"
14
15 "golang.org/x/crypto/ssh"
16
17 "gitbay.org/gitbay/internal/config"
18 "gitbay.org/gitbay/internal/store"
19)
20
21// testServer is an embedded server over a fresh store holding alice
22// with one full-scope key, and a client connected with that key.
23type testServer struct {
24 srv *Server
25 st *store.Store
26 client *ssh.Client
27 uid int64
28 keyID int64
29 fp string
30}
31
32func newTestServer(t *testing.T) testServer {
33 t.Helper()
34 root := t.TempDir()
35 st, err := store.Open(filepath.Join(root, "gitbay.db"))
36 if err != nil {
37 t.Fatal(err)
38 }
39 t.Cleanup(func() { st.Close() })
40 if err := st.MigrateUp(); err != nil {
41 t.Fatal(err)
42 }
43 uid, err := st.CreateUser("alice", false)
44 if err != nil {
45 t.Fatal(err)
46 }
47 _, priv, err := ed25519.GenerateKey(rand.Reader)
48 if err != nil {
49 t.Fatal(err)
50 }
51 signer, err := ssh.NewSignerFromKey(priv)
52 if err != nil {
53 t.Fatal(err)
54 }
55 pub := signer.PublicKey()
56 fp := ssh.FingerprintSHA256(pub)
57 if err := st.AddSSHKey(uid, fp, pub.Type(), pub.Marshal(), "full", "test"); err != nil {
58 t.Fatal(err)
59 }
60 key, err := st.SSHKeyByFingerprint(fp)
61 if err != nil {
62 t.Fatal(err)
63 }
64
65 cfg := config.Default()
66 cfg.Server.Root = root
67 srv, err := New(cfg, st, nil)
68 if err != nil {
69 t.Fatal(err)
70 }
71 ln, err := net.Listen("tcp", "127.0.0.1:0")
72 if err != nil {
73 t.Fatal(err)
74 }
75 go srv.Serve(ln)
76 t.Cleanup(func() { ln.Close() })
77
78 client, err := ssh.Dial("tcp", ln.Addr().String(), &ssh.ClientConfig{
79 User: "git",
80 Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
81 HostKeyCallback: ssh.InsecureIgnoreHostKey(),
82 Timeout: 5 * time.Second,
83 })
84 if err != nil {
85 t.Fatal(err)
86 }
87 t.Cleanup(func() { client.Close() })
88 return testServer{srv: srv, st: st, client: client, uid: uid, keyID: key.ID, fp: fp}
89}
90
91// withBuild gives alice the public repo alice/app and a queued build 1
92// whose log has one line.
93func withBuild(t *testing.T, ts testServer) {
94 t.Helper()
95 repoID, err := ts.st.CreateRepo("user", ts.uid, "app", "public")
96 if err != nil {
97 t.Fatal(err)
98 }
99 id, err := ts.st.CreateBuild(repoID, "unit", "abc", "main", `["true"]`, "", "", true)
100 if err != nil {
101 t.Fatal(err)
102 }
103 if err := ts.st.AppendBuildLog(id, []byte("queued\n")); err != nil {
104 t.Fatal(err)
105 }
106}
107
108// followServer starts an embedded server holding alice, her public repo
109// alice/app and a queued build 1 whose log has one line, and returns it
110// with a client connected as alice.
111func followServer(t *testing.T) (*Server, *ssh.Client) {
112 t.Helper()
113 ts := newTestServer(t)
114 withBuild(t, ts)
115 return ts.srv, ts.client
116}
117
118// startFollow runs build log --follow on a new session and returns once
119// the stored line has arrived, so the follow is past its first read.
120func startFollow(t *testing.T, client *ssh.Client, stderr *bytes.Buffer) *ssh.Session {
121 t.Helper()
122 sess, err := client.NewSession()
123 if err != nil {
124 t.Fatal(err)
125 }
126 t.Cleanup(func() { sess.Close() })
127 sess.Stderr = stderr
128 out, err := sess.StdoutPipe()
129 if err != nil {
130 t.Fatal(err)
131 }
132 if err := sess.Start("build log alice/app 1 --follow"); err != nil {
133 t.Fatal(err)
134 }
135 line := make(chan string, 1)
136 go func() {
137 l, _ := bufio.NewReader(out).ReadString('\n')
138 line <- l
139 }()
140 select {
141 case l := <-line:
142 if l != "queued\n" {
143 t.Fatalf("first line %q", l)
144 }
145 case <-time.After(5 * time.Second):
146 t.Fatal("the stored log never arrived")
147 }
148 return sess
149}
150
151func activeSessions(s *Server) int32 {
152 s.mu.Lock()
153 defer s.mu.Unlock()
154 var n int32
155 for c := range s.conns {
156 n += c.active.Load()
157 }
158 return n
159}
160
161// Closing the session channel ends a follow of a queued build while the
162// connection stays up, as a Ctrl-C does over the CLI's shared connection.
163// Nothing else would end it for ten minutes.
164func TestFollowEndsWhenChannelCloses(t *testing.T) {
165 srv, client := followServer(t)
166 var stderr bytes.Buffer
167 sess := startFollow(t, client, &stderr)
168 if n := activeSessions(srv); n != 1 {
169 t.Fatalf("%d sessions active while following, want 1", n)
170 }
171 sess.Close()
172
173 deadline := time.Now().Add(5 * time.Second)
174 for activeSessions(srv) != 0 {
175 if time.Now().After(deadline) {
176 t.Fatal("the follow outlived its channel")
177 }
178 time.Sleep(20 * time.Millisecond)
179 }
180 if _, err := client.NewSession(); err != nil {
181 t.Fatalf("the connection did not survive the channel: %v", err)
182 }
183}
184
185// A command that fails on its own while the server is stopping says
186// nothing about a restart: only a follow that Stop ended does.
187func TestStopLeavesOtherFailuresAlone(t *testing.T) {
188 srv, client := followServer(t)
189 srv.Stop()
190 sess, err := client.NewSession()
191 if err != nil {
192 t.Fatal(err)
193 }
194 defer sess.Close()
195 var stderr bytes.Buffer
196 sess.Stderr = &stderr
197 var exit *ssh.ExitError
198 if err := sess.Run("repo show nosuch/repo"); !errors.As(err, &exit) || exit.ExitStatus() != 3 {
199 t.Fatalf("repo show of a missing repository: %v, want exit 3", err)
200 }
201 if strings.Contains(stderr.String(), "restarting") {
202 t.Errorf("stderr %q", stderr.String())
203 }
204}
205
206// Stop ends a follow with exit 1 and says why, without closing the
207// connection.
208func TestStopEndsFollow(t *testing.T) {
209 srv, client := followServer(t)
210 var stderr bytes.Buffer
211 sess := startFollow(t, client, &stderr)
212 srv.Stop()
213
214 waited := make(chan error, 1)
215 go func() { waited <- sess.Wait() }()
216 select {
217 case err := <-waited:
218 var exit *ssh.ExitError
219 if !errors.As(err, &exit) || exit.ExitStatus() != 1 {
220 t.Fatalf("follow ended with %v, want exit 1", err)
221 }
222 case <-time.After(5 * time.Second):
223 t.Fatal("Stop did not end the follow")
224 }
225 if !strings.Contains(stderr.String(), "gitbay is restarting") {
226 t.Errorf("stderr %q", stderr.String())
227 }
228}
229
230// An unregistered key is told its own fingerprint and the real host, and
231// offered both the web and the ssh path to register.
232func TestUnregisteredKeyMessageNamesFingerprintAndHost(t *testing.T) {
233 root := t.TempDir()
234 st, err := store.Open(filepath.Join(root, "gitbay.db"))
235 if err != nil {
236 t.Fatal(err)
237 }
238 t.Cleanup(func() { st.Close() })
239 if err := st.MigrateUp(); err != nil {
240 t.Fatal(err)
241 }
242
243 cfg := config.Default()
244 cfg.Server.Root = root
245 // The settings link keeps the site URL's scheme and port.
246 cfg.Server.SiteURL = "http://forge.test:8080/"
247 cfg.Registration.Mode = "open"
248 srv, err := New(cfg, st, nil)
249 if err != nil {
250 t.Fatal(err)
251 }
252 ln, err := net.Listen("tcp", "127.0.0.1:0")
253 if err != nil {
254 t.Fatal(err)
255 }
256 go srv.Serve(ln)
257 t.Cleanup(func() { ln.Close() })
258
259 _, priv, err := ed25519.GenerateKey(rand.Reader)
260 if err != nil {
261 t.Fatal(err)
262 }
263 signer, err := ssh.NewSignerFromKey(priv)
264 if err != nil {
265 t.Fatal(err)
266 }
267
268 client, err := ssh.Dial("tcp", ln.Addr().String(), &ssh.ClientConfig{
269 User: "git",
270 Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
271 HostKeyCallback: ssh.InsecureIgnoreHostKey(),
272 Timeout: 5 * time.Second,
273 })
274 if err != nil {
275 t.Fatal(err)
276 }
277 t.Cleanup(func() { client.Close() })
278
279 sess, err := client.NewSession()
280 if err != nil {
281 t.Fatal(err)
282 }
283 defer sess.Close()
284 var stderr bytes.Buffer
285 sess.Stderr = &stderr
286
287 var exit *ssh.ExitError
288 if err := sess.Run("whoami"); !errors.As(err, &exit) || exit.ExitStatus() != 4 {
289 t.Fatalf("whoami ended with %v, want exit 4", err)
290 }
291
292 fp := ssh.FingerprintSHA256(signer.PublicKey())
293 for _, want := range []string{fp, "forge.test", "add it at http://forge.test:8080/settings#keys\n", "ssh git@forge.test register"} {
294 if !strings.Contains(stderr.String(), want) {
295 t.Errorf("message missing %q:\n%s", want, stderr.String())
296 }
297 }
298}
299
300// authMeta is the connection metadata authenticate reads: only the
301// remote address.
302type authMeta struct {
303 ssh.ConnMetadata
304 addr net.Addr
305}
306
307func (m authMeta) RemoteAddr() net.Addr { return m.addr }
308
309func authKey(t *testing.T) ssh.PublicKey {
310 t.Helper()
311 pub, _, err := ed25519.GenerateKey(rand.Reader)
312 if err != nil {
313 t.Fatal(err)
314 }
315 k, err := ssh.NewPublicKey(pub)
316 if err != nil {
317 t.Fatal(err)
318 }
319 return k
320}
321
322// authServer is a Server holding what authenticate uses: a store with a
323// runner account's key, the registration mode, and a limiter of three
324// failures a minute.
325func authServer(t *testing.T, mode string) (*Server, ssh.PublicKey) {
326 t.Helper()
327 st, err := store.Open(filepath.Join(t.TempDir(), "gitbay.db"))
328 if err != nil {
329 t.Fatal(err)
330 }
331 t.Cleanup(func() { st.Close() })
332 if err := st.MigrateUp(); err != nil {
333 t.Fatal(err)
334 }
335 uid, err := st.CreateUser("ci", false)
336 if err != nil {
337 t.Fatal(err)
338 }
339 runner := authKey(t)
340 if err := st.AddSSHKey(uid, ssh.FingerprintSHA256(runner), runner.Type(), runner.Marshal(), "runner", ""); err != nil {
341 t.Fatal(err)
342 }
343 cfg := config.Default()
344 cfg.Registration.Mode = mode
345 return &Server{cfg: cfg, st: st, authLimiter: newRateLimiter(3, time.Minute)}, runner
346}
347
348// With registration closed an unknown key counts against its address.
349// Below the limit a known key's success clears the count. At the limit
350// authenticate refuses before it looks at the key, so the runner's own
351// key from that address is refused too and its success never runs to
352// clear anything, until the window passes. Another address is not
353// affected. This is why a build must not share the runner's source
354// address (#260).
355func TestAuthLockoutHoldsAgainstTheRunnersKey(t *testing.T) {
356 s, runner := authServer(t, "closed")
357 stranger := authKey(t)
358 failTimes := func(n int) {
359 t.Helper()
360 for i := 0; i < n; i++ {
361 if _, err := s.authenticate(fromLoopback, stranger); err == nil {
362 t.Fatal("unknown key admitted with registration closed")
363 }
364 }
365 }
366
367 failTimes(2)
368 if _, err := s.authenticate(fromLoopback, runner); err != nil {
369 t.Fatalf("runner below the limit: %v", err)
370 }
371 failTimes(2)
372 if _, err := s.authenticate(fromLoopback, runner); err != nil {
373 t.Fatalf("runner after its success cleared the count: %v", err)
374 }
375
376 failTimes(3)
377 for i := 0; i < 2; i++ {
378 if _, err := s.authenticate(fromLoopback, runner); err == nil || !strings.Contains(err.Error(), "too many") {
379 t.Fatalf("attempt %d from a locked-out address: %v, want refused", i+1, err)
380 }
381 }
382 if _, err := s.authenticate(fromPublic, runner); err != nil {
383 t.Fatalf("another address was locked out too: %v", err)
384 }
385
386 s.authLimiter.seen["127.0.0.1"].start = time.Now().Add(-2 * time.Minute)
387 if _, err := s.authenticate(fromLoopback, runner); err != nil {
388 t.Fatalf("runner after the window passed: %v", err)
389 }
390}
391
392// With registration open or by invite, an unknown key is admitted to run
393// register and never counts, so no number of unknown-key attempts locks
394// the runner's address out. gitbay.org runs open registration (#260).
395func TestAuthUnknownKeyCountsOnlyWhenClosed(t *testing.T) {
396 for _, mode := range []string{"open", "invite"} {
397 s, runner := authServer(t, mode)
398 for i := 0; i < 10; i++ {
399 p, err := s.authenticate(fromLoopback, authKey(t))
400 if err != nil || p.Extensions["anon-key"] == "" {
401 t.Fatalf("%s: unknown key %d: %v %+v", mode, i+1, err, p)
402 }
403 }
404 if _, err := s.authenticate(fromLoopback, runner); err != nil {
405 t.Fatalf("%s: runner refused after unknown keys: %v", mode, err)
406 }
407 }
408}
409
410var (
411 fromLoopback = authMeta{addr: &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 40000}}
412 fromPublic = authMeta{addr: &net.TCPAddr{IP: net.IPv4(203, 0, 113, 7), Port: 40000}}
413)