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