internal/sshd/sshd.go

9df917e73a67d15adecc3f45976690f6fcd4e47a
gitbay/internal/sshd/sshd.go history · blame · raw

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}