internal/sshd/sshd.go
601 lines · 18381 bytes
1// Package sshd implements the embedded SSH listener: public-key auth against
2// registered keys, then dispatch to git transport or control commands.
3package sshd
4
5import (
6 "context"
7 "crypto/ed25519"
8 "crypto/rand"
9 "encoding/base64"
10 "encoding/pem"
11 "errors"
12 "fmt"
13 "io"
14 "log/slog"
15 "maps"
16 "net"
17 "os"
18 "path/filepath"
19 "slices"
20 "strconv"
21 "sync"
22 "sync/atomic"
23 "time"
24
25 "golang.org/x/crypto/ssh"
26
27 "gitbay.org/gitbay/internal/config"
28 "gitbay.org/gitbay/internal/control"
29 "gitbay.org/gitbay/internal/gitutil"
30 "gitbay.org/gitbay/internal/hookd"
31 "gitbay.org/gitbay/internal/policy"
32 "gitbay.org/gitbay/internal/protocol"
33 "gitbay.org/gitbay/internal/store"
34)
35
36type Server struct {
37 cfg config.Config
38 st *store.Store
39 sshCfg *ssh.ServerConfig
40 authLimiter *rateLimiter
41 sessions sync.WaitGroup // accepted connections still being served
42 mu sync.Mutex
43 conns map[*conn]struct{}
44 stopping chan struct{} // closed by Stop
45 stopOnce sync.Once
46}
47
48// conn is one accepted connection and how many sessions it is running.
49// A CLI's shared connection sits idle between commands; on shutdown an
50// idle connection is closed at once and only a session mid-command is
51// waited for (#141).
52type conn struct {
53 net net.Conn
54 active atomic.Int32
55 // keyID and userID are the key that authenticated the connection and
56 // its account: 0 before the handshake and for an unregistered key.
57 // Guarded by Server.mu.
58 keyID, userID int64
59 revoked chan struct{} // closed by cut
60 cutOnce sync.Once
61}
62
63// cut ends the connection because its key was revoked: a git transport
64// on it is killed, and every other command loses its channel.
65func (c *conn) cut() {
66 c.cutOnce.Do(func() { close(c.revoked) })
67 c.net.Close()
68}
69
70func New(cfg config.Config, st *store.Store) (*Server, error) {
71 s := &Server{cfg: cfg, st: st, authLimiter: newRateLimiter(cfg.Limits.SSHAuthRate, time.Minute), conns: map[*conn]struct{}{}, stopping: make(chan struct{})}
72
73 sc := &ssh.ServerConfig{
74 PublicKeyCallback: s.authenticate,
75 ServerVersion: "SSH-2.0-gitbayd",
76 }
77 signers, err := loadHostKeys(cfg)
78 if err != nil {
79 return nil, err
80 }
81 for _, sg := range signers {
82 sc.AddHostKey(sg)
83 }
84 s.sshCfg = sc
85 st.OnRevoke(s.revoke)
86 return s, nil
87}
88
89// loadHostKeys loads the configured host keys, or generates an ed25519 key
90// under server.root/ssh/ when none are configured.
91func loadHostKeys(cfg config.Config) ([]ssh.Signer, error) {
92 paths := cfg.SSH.HostKeys
93 if len(paths) == 0 {
94 p := filepath.Join(cfg.Server.Root, "ssh", "host_ed25519")
95 if _, err := os.Stat(p); errors.Is(err, os.ErrNotExist) {
96 if err := generateHostKey(p); err != nil {
97 return nil, fmt.Errorf("generating host key: %w", err)
98 }
99 slog.Info("generated ssh host key", "path", p)
100 }
101 paths = []string{p}
102 }
103 var signers []ssh.Signer
104 for _, p := range paths {
105 raw, err := os.ReadFile(p)
106 if err != nil {
107 return nil, fmt.Errorf("host key %s: %w", p, err)
108 }
109 sg, err := ssh.ParsePrivateKey(raw)
110 if err != nil {
111 return nil, fmt.Errorf("host key %s: %w", p, err)
112 }
113 signers = append(signers, sg)
114 }
115 return signers, nil
116}
117
118func generateHostKey(path string) error {
119 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
120 return err
121 }
122 _, priv, err := ed25519.GenerateKey(rand.Reader)
123 if err != nil {
124 return err
125 }
126 block, err := ssh.MarshalPrivateKey(priv, "")
127 if err != nil {
128 return err
129 }
130 return os.WriteFile(path, pem.EncodeToMemory(block), 0o600)
131}
132
133// authenticate resolves the presented key to a registered account. The SSH
134// username is ignored; identity comes from the key alone. When registration
135// is open or invite-based, unknown keys are admitted to run exactly one
136// command: register.
137func (s *Server) authenticate(meta ssh.ConnMetadata, pub ssh.PublicKey) (*ssh.Permissions, error) {
138 ip := remoteIP(meta.RemoteAddr())
139 if !s.authLimiter.allow(ip) {
140 // One audit entry per throttled window, not per rejected attempt.
141 if s.authLimiter.firstThrottle(ip) {
142 s.st.Audit(0, "auth.throttled", map[string]any{"ip": ip, "rate": s.cfg.Limits.SSHAuthRate})
143 }
144 return nil, fmt.Errorf("too many authentication attempts; try again shortly")
145 }
146 fp := ssh.FingerprintSHA256(pub)
147 key, err := s.st.SSHKeyByFingerprint(fp)
148 if err != nil && !errors.Is(err, store.ErrNotFound) {
149 // The store, not the key, failed. Neither a failure against the
150 // limiter nor "unknown key": a busy database during a restart
151 // would otherwise lock every client out for a minute.
152 slog.Error("ssh auth: key lookup", "err", err)
153 return nil, fmt.Errorf("authentication temporarily unavailable")
154 }
155 if err != nil {
156 if s.cfg.Registration.Mode != "closed" {
157 return &ssh.Permissions{Extensions: map[string]string{
158 "anon-key": base64.StdEncoding.EncodeToString(pub.Marshal()),
159 }}, nil
160 }
161 s.authLimiter.fail(ip)
162 s.st.Audit(0, "auth.failed", map[string]any{"ip": ip, "fingerprint": fp})
163 return nil, fmt.Errorf("unknown key %s", fp)
164 }
165 if key.Expired(time.Now()) {
166 s.authLimiter.fail(ip)
167 s.st.Audit(key.UserID, "auth.expired", map[string]any{"ip": ip, "fingerprint": fp})
168 return nil, fmt.Errorf("key %s has expired", fp)
169 }
170 s.authLimiter.success(ip)
171 return &ssh.Permissions{Extensions: map[string]string{
172 "user-id": strconv.FormatInt(key.UserID, 10),
173 "key-id": strconv.FormatInt(key.ID, 10),
174 }}, nil
175}
176
177// Serve accepts connections on ln until it is closed.
178func (s *Server) Serve(ln net.Listener) error {
179 served := make(chan struct{})
180 defer close(served)
181 go s.sweep(served)
182 for {
183 nc, err := ln.Accept()
184 if err != nil {
185 return err
186 }
187 c := &conn{net: nc, revoked: make(chan struct{})}
188 s.mu.Lock()
189 s.conns[c] = struct{}{}
190 s.mu.Unlock()
191 s.sessions.Add(1)
192 go func() {
193 defer s.sessions.Done()
194 defer func() {
195 s.mu.Lock()
196 delete(s.conns, c)
197 s.mu.Unlock()
198 }()
199 s.handleConn(c)
200 }()
201 }
202}
203
204// revoke closes the connections opened by the keys r names.
205func (s *Server) revoke(r store.Revoked) {
206 var cut []*conn
207 s.mu.Lock()
208 for c := range s.conns {
209 if c.keyID == 0 {
210 continue
211 }
212 if (r.UserID != 0 && c.userID == r.UserID) || slices.Contains(r.KeyIDs, c.keyID) {
213 cut = append(cut, c)
214 }
215 }
216 s.mu.Unlock()
217 for _, c := range cut {
218 c.cut()
219 }
220}
221
222// sweepInterval bounds how long a revocation this process was not told
223// about (gitbayd admin on the host) leaves a connection open.
224const sweepInterval = 15 * time.Second
225
226func (s *Server) sweep(served <-chan struct{}) {
227 t := time.NewTicker(sweepInterval)
228 defer t.Stop()
229 for {
230 select {
231 case <-t.C:
232 s.sweepOnce()
233 case <-served:
234 return
235 case <-s.stopping:
236 return
237 }
238 }
239}
240
241// sweepOnce cuts every connection whose key is no longer live. Only
242// connections whose key was asked about are judged: one that
243// authenticated while the query ran waits for the next sweep.
244func (s *Server) sweepOnce() {
245 asked := map[int64]bool{}
246 s.mu.Lock()
247 for c := range s.conns {
248 if c.keyID != 0 {
249 asked[c.keyID] = true
250 }
251 }
252 s.mu.Unlock()
253 if len(asked) == 0 {
254 return
255 }
256 live, err := s.st.LiveSSHKeys(slices.Collect(maps.Keys(asked)))
257 if err != nil {
258 slog.Error("ssh sweep: key lookup", "err", err)
259 return
260 }
261 var cut []*conn
262 s.mu.Lock()
263 for c := range s.conns {
264 if asked[c.keyID] && !live[c.keyID] {
265 cut = append(cut, c)
266 }
267 }
268 s.mu.Unlock()
269 for _, c := range cut {
270 c.cut()
271 }
272}
273
274// Stop ends the commands that run until something happens (build log
275// --follow), so a shutdown drain waits only for work that finishes. It
276// does not close connections; Shutdown does.
277func (s *Server) Stop() {
278 s.stopOnce.Do(func() { close(s.stopping) })
279}
280
281// Shutdown closes every idle connection, then waits for the ones with a
282// session running, or for ctx. The caller closes the listener first; a
283// push in flight completes rather than being cut mid-pack.
284func (s *Server) Shutdown(ctx context.Context) error {
285 s.Stop()
286 s.mu.Lock()
287 for c := range s.conns {
288 if c.active.Load() == 0 {
289 c.net.Close()
290 }
291 }
292 s.mu.Unlock()
293 done := make(chan struct{})
294 go func() {
295 s.sessions.Wait()
296 close(done)
297 }()
298 select {
299 case <-done:
300 return nil
301 case <-ctx.Done():
302 return ctx.Err()
303 }
304}
305
306func (s *Server) handleConn(c *conn) {
307 defer c.net.Close()
308 sconn, chans, reqs, err := ssh.NewServerConn(c.net, s.sshCfg)
309 if err != nil {
310 return
311 }
312 defer sconn.Close()
313 ext := sconn.Permissions.Extensions
314 s.mu.Lock()
315 c.keyID, _ = strconv.ParseInt(ext["key-id"], 10, 64)
316 c.userID, _ = strconv.ParseInt(ext["user-id"], 10, 64)
317 s.mu.Unlock()
318 go ssh.DiscardRequests(reqs)
319
320 for newCh := range chans {
321 if newCh.ChannelType() != "session" {
322 newCh.Reject(ssh.UnknownChannelType, "only session channels are supported")
323 continue
324 }
325 ch, chReqs, err := newCh.Accept()
326 if err != nil {
327 continue
328 }
329 c.active.Add(1)
330 go func() {
331 defer c.active.Add(-1)
332 s.handleSession(c, sconn, ch, chReqs)
333 }()
334 }
335}
336
337func (s *Server) handleSession(c *conn, sconn *ssh.ServerConn, ch ssh.Channel, reqs <-chan *ssh.Request) {
338 defer ch.Close()
339 var term control.Term
340 for req := range reqs {
341 switch req.Type {
342 case "exec":
343 var payload struct{ Command string }
344 if err := ssh.Unmarshal(req.Payload, &payload); err != nil {
345 req.Reply(false, nil)
346 continue
347 }
348 req.Reply(true, nil)
349 // x/crypto closes reqs when the client closes the channel. That
350 // is how a follow learns nobody is reading: the CLI's shared
351 // connection outlives a Ctrl-C, the channel does not. Stop
352 // ends it too, for a restart.
353 closed := make(chan struct{})
354 go func() {
355 for r := range reqs {
356 r.Reply(false, nil)
357 }
358 close(closed)
359 }()
360 done := make(chan struct{})
361 go func() {
362 select {
363 case <-closed:
364 case <-s.stopping:
365 }
366 close(done)
367 }()
368 code := s.runExec(c, sconn, ch, term, payload.Command, done)
369 sendExit(ch, code)
370 return
371 case "shell":
372 req.Reply(true, nil)
373 fmt.Fprintf(ch, "gitbay control plane: interactive shells are not available.\nTry: ssh %s help\n", s.cfg.Server.SiteURL)
374 sendExit(ch, protocol.ExitUsage)
375 return
376 case "env":
377 var kv struct{ Name, Value string }
378 if ssh.Unmarshal(req.Payload, &kv) == nil && kv.Name == "GITBAY_TERM" {
379 term = control.ParseTerm(kv.Value)
380 }
381 req.Reply(true, nil)
382 case "pty-req":
383 // Harmless; accept and ignore.
384 req.Reply(true, nil)
385 default:
386 req.Reply(false, nil)
387 }
388 }
389}
390
391func sendExit(ch ssh.Channel, code int) {
392 var msg = struct{ Status uint32 }{uint32(code)}
393 ch.SendRequest("exit-status", false, ssh.Marshal(&msg))
394}
395
396func (s *Server) runExec(c *conn, sconn *ssh.ServerConn, ch ssh.Channel, term control.Term, cmdline string, done <-chan struct{}) int {
397 ext := sconn.Permissions.Extensions
398 if blob := ext["anon-key"]; blob != "" {
399 return s.runAnonymous(ch, blob, cmdline)
400 }
401 userID, _ := strconv.ParseInt(ext["user-id"], 10, 64)
402 keyID, _ := strconv.ParseInt(ext["key-id"], 10, 64)
403 // A connection outlives its commands, so the key is read again for
404 // each one: what it may do is what it may do now (#256).
405 key, err := s.st.SSHKeyByID(keyID)
406 if errors.Is(err, store.ErrNotFound) || (err == nil && key.UserID != userID) {
407 fmt.Fprintln(ch.Stderr(), "this key is no longer registered")
408 return protocol.ExitDenied
409 }
410 if err != nil {
411 slog.Error("ssh exec: key lookup", "err", err)
412 fmt.Fprintln(ch.Stderr(), "authentication temporarily unavailable")
413 return protocol.ExitFailure
414 }
415 if key.Expired(time.Now()) {
416 fmt.Fprintln(ch.Stderr(), "this key has expired; remove it and add a new one")
417 return protocol.ExitDenied
418 }
419 user, err := s.st.UserByID(userID)
420 if err != nil {
421 fmt.Fprintln(ch.Stderr(), "account no longer exists")
422 return protocol.ExitDenied
423 }
424 _ = s.st.TouchSSHKey(keyID)
425 return Exec(s.cfg, s.st, user, key, term, cmdline, ch, ch, ch.Stderr(), done, s.stopping, c.revoked)
426}
427
428// runAnonymous handles a session from an unregistered key: the register
429// command and nothing else.
430func (s *Server) runAnonymous(ch ssh.Channel, keyB64, cmdline string) int {
431 raw, err := base64.StdEncoding.DecodeString(keyB64)
432 if err != nil {
433 return protocol.ExitFailure
434 }
435 pub, err := ssh.ParsePublicKey(raw)
436 if err != nil {
437 return protocol.ExitFailure
438 }
439 argv, err := protocol.Tokenize(cmdline)
440 if err != nil {
441 fmt.Fprintf(ch.Stderr(), "cannot parse command: %v\n", err)
442 return protocol.ExitUsage
443 }
444 if len(argv) == 0 || argv[0] != "register" {
445 fmt.Fprintf(ch.Stderr(), "this key is not registered here. Create an account with:\n ssh <host> register --username <name> %s\n",
446 map[string]string{"open": "--email <address>", "invite": "--invite <code>"}[s.cfg.Registration.Mode])
447 return protocol.ExitDenied
448 }
449 return control.RunRegister(s.cfg, s.st, pub, argv, ch, ch.Stderr())
450}
451
452// Exec runs one SSH exec command line for an authenticated key. It is the
453// single dispatch path shared by the embedded listener and the system-sshd
454// forced command (gitbayd shell). Closing revoked kills a git transport.
455func Exec(cfg config.Config, st *store.Store, user store.User, key store.SSHKey, term control.Term, cmdline string,
456 stdin io.Reader, stdout, stderr io.Writer, done, stopping, revoked <-chan struct{}) int {
457 if user.Disabled {
458 fmt.Fprintln(stderr, "this account is disabled; contact the instance admin")
459 return protocol.ExitDenied
460 }
461 argv, err := protocol.Tokenize(cmdline)
462 if err != nil {
463 fmt.Fprintf(stderr, "cannot parse command: %v\n", err)
464 return protocol.ExitUsage
465 }
466 if len(argv) > 0 {
467 switch argv[0] {
468 case "git-upload-pack", "git-receive-pack", "git-upload-archive":
469 code := protocol.ExitDenied
470 if user.Pending {
471 fmt.Fprintln(stderr, "your account is not active yet: verify your email first")
472 } else {
473 code = runGit(cfg, st, user, key.Scope, argv, stdin, stdout, stderr, revoked)
474 }
475 // A refused push is a refused write, audited like one. runGit
476 // refuses only with the path as the one argument, so argv[1:]
477 // holds no value beyond the target.
478 if argv[0] == "git-receive-pack" && (code == protocol.ExitDenied || code == protocol.ExitNotFound) {
479 control.AuditRefused(st, user.ID, "refused git-receive-pack",
480 map[string]any{"argv": argv[1:], "source": key.Fingerprint, "exit": code})
481 }
482 return code
483 case "git-lfs-authenticate":
484 // Part of the git transport, not the control plane: usable by
485 // git-scoped and deploy keys, with the transports' access rules.
486 if user.Pending {
487 fmt.Fprintln(stderr, "your account is not active yet: verify your email first")
488 return protocol.ExitDenied
489 }
490 return runLFSAuthenticate(cfg, st, user, key.Scope, argv, stdout, stderr)
491 }
492 }
493 ctx := &control.Ctx{
494 User: user,
495 Scope: key.Scope,
496 Source: key.Fingerprint,
497 Term: term,
498 Store: st,
499 Cfg: cfg,
500 Stdin: stdin,
501 Stdout: stdout,
502 Stderr: stderr,
503 Done: done,
504 Stopping: stopping,
505 Expires: key.ExpiresAt,
506 }
507 return control.Dispatch(ctx, argv)
508}
509
510// runGit streams a git transport service after access checks.
511func runGit(cfg config.Config, st *store.Store, user store.User, scope string, argv []string,
512 stdin io.Reader, stdout, stderr io.Writer, revoked <-chan struct{}) int {
513 service := argv[0]
514 if len(argv) != 2 {
515 fmt.Fprintf(stderr, "usage: %s <path>\n", service)
516 return protocol.ExitUsage
517 }
518 write := service == "git-receive-pack"
519
520 repo, err := st.RepoByPath(argv[1])
521 if err != nil {
522 fmt.Fprintln(stderr, "repository not found")
523 return protocol.ExitNotFound
524 }
525 if policy.IsDeployScope(scope) {
526 // A deploy key authorizes by its binding alone: one repository,
527 // its mode, nothing inherited from whoever registered it. Any
528 // mismatch reads as nonexistence, same as the access rules.
529 if !policy.DeployScopeAllows(scope, repo.ID, write) {
530 fmt.Fprintln(stderr, "repository not found")
531 return protocol.ExitNotFound
532 }
533 } else {
534 grant, err := st.AccessRole(repo.ID, user.ID)
535 if err != nil {
536 fmt.Fprintln(stderr, "internal error")
537 return protocol.ExitFailure
538 }
539 if !policy.CanRead(user, repo, grant) {
540 // Same answer as nonexistence: private repos must not be enumerable.
541 fmt.Fprintln(stderr, "repository not found")
542 return protocol.ExitNotFound
543 }
544 if !policy.ScopeAllowsGit(scope, repo.Path(), write) {
545 fmt.Fprintf(stderr, "this key's scope (%s) does not allow %s on %s\n", scope, service, repo.Path())
546 return protocol.ExitDenied
547 }
548 if write && !policy.CanWrite(user, repo, grant) {
549 fmt.Fprintf(stderr, "write access to %s denied\n", repo.Path())
550 return protocol.ExitDenied
551 }
552 }
553 if write && repo.Settings.Archived {
554 fmt.Fprintf(stderr, "%s is archived and read-only\n", repo.Path())
555 return protocol.ExitDenied
556 }
557 if write {
558 if mirrored, err := st.PullMirrored(repo.ID); err == nil && mirrored {
559 fmt.Fprintf(stderr, "%s is a pull mirror: its refs come from the upstream; push there instead\n", repo.Path())
560 return protocol.ExitDenied
561 }
562 }
563
564 dir := control.RepoDir(cfg.Server.Root, repo.OwnerName, repo.Name)
565 env := []string{
566 hookd.EnvSocket + "=" + hookd.SocketPath(cfg.Server.Root),
567 hookd.EnvRepoID + "=" + strconv.FormatInt(repo.ID, 10),
568 hookd.EnvUserID + "=" + strconv.FormatInt(user.ID, 10),
569 hookd.EnvScope + "=" + scope,
570 }
571 // A storage quota on the owner rides the same mechanism as the pack
572 // cap: the pack may be no larger than what the owner has left.
573 maxPack := cfg.Limits.MaxPackBytes
574 if write && repo.OwnerKind == "user" {
575 if limit := control.ByteLimit(st, control.QuotaConfig(cfg), repo.OwnerID); limit > 0 {
576 used := control.OwnedBytes(st, cfg.Server.Root, repo.OwnerID)
577 left := limit - used
578 if left <= 0 {
579 fmt.Fprintf(stderr, "%s's storage quota is used up (%d of %d bytes); delete something, or ask an admin to raise the limit\n", repo.OwnerName, used, limit)
580 return protocol.ExitDenied
581 }
582 if maxPack == 0 || left < maxPack {
583 maxPack = left
584 }
585 }
586 }
587 if write {
588 // hookd answers only a hook that names this receive-pack.
589 token, err := st.CreatePushToken(repo.ID, user.ID, scope)
590 if err != nil {
591 fmt.Fprintln(stderr, "internal error")
592 return protocol.ExitFailure
593 }
594 defer st.DeletePushToken(token)
595 env = append(env, hookd.EnvToken+"="+token)
596 }
597 if err := gitutil.Transport(service, dir, stdin, stdout, stderr, env, maxPack, revoked); err != nil {
598 return protocol.ExitFailure
599 }
600 return protocol.ExitOK
601}