internal/sshd/sshd.go

1ed9fb9399b21da8e6cf45be8389792aac82d5bc
gitbay/internal/sshd/sshd.go history · blame · raw

581 lines · 17590 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.st.Audit(key.UserID, "auth.expired", map[string]any{"ip": ip, "fingerprint": fp})
167		return nil, fmt.Errorf("key %s has expired", fp)
168	}
169	s.authLimiter.success(ip)
170	return &ssh.Permissions{Extensions: map[string]string{
171		"user-id": strconv.FormatInt(key.UserID, 10),
172		"key-id":  strconv.FormatInt(key.ID, 10),
173	}}, nil
174}
175
176// Serve accepts connections on ln until it is closed.
177func (s *Server) Serve(ln net.Listener) error {
178	served := make(chan struct{})
179	defer close(served)
180	go s.sweep(served)
181	for {
182		nc, err := ln.Accept()
183		if err != nil {
184			return err
185		}
186		c := &conn{net: nc, revoked: make(chan struct{})}
187		s.mu.Lock()
188		s.conns[c] = struct{}{}
189		s.mu.Unlock()
190		s.sessions.Add(1)
191		go func() {
192			defer s.sessions.Done()
193			defer func() {
194				s.mu.Lock()
195				delete(s.conns, c)
196				s.mu.Unlock()
197			}()
198			s.handleConn(c)
199		}()
200	}
201}
202
203// revoke closes the connections opened by the keys r names.
204func (s *Server) revoke(r store.Revoked) {
205	var cut []*conn
206	s.mu.Lock()
207	for c := range s.conns {
208		if c.keyID == 0 {
209			continue
210		}
211		if (r.UserID != 0 && c.userID == r.UserID) || slices.Contains(r.KeyIDs, c.keyID) {
212			cut = append(cut, c)
213		}
214	}
215	s.mu.Unlock()
216	for _, c := range cut {
217		c.cut()
218	}
219}
220
221// sweepInterval bounds how long a revocation this process was not told
222// about (gitbayd admin on the host) leaves a connection open.
223const sweepInterval = 15 * time.Second
224
225func (s *Server) sweep(served <-chan struct{}) {
226	t := time.NewTicker(sweepInterval)
227	defer t.Stop()
228	for {
229		select {
230		case <-t.C:
231			s.sweepOnce()
232		case <-served:
233			return
234		case <-s.stopping:
235			return
236		}
237	}
238}
239
240// sweepOnce cuts every connection whose key is no longer live. Only
241// connections whose key was asked about are judged: one that
242// authenticated while the query ran waits for the next sweep.
243func (s *Server) sweepOnce() {
244	asked := map[int64]bool{}
245	s.mu.Lock()
246	for c := range s.conns {
247		if c.keyID != 0 {
248			asked[c.keyID] = true
249		}
250	}
251	s.mu.Unlock()
252	if len(asked) == 0 {
253		return
254	}
255	live, err := s.st.LiveSSHKeys(slices.Collect(maps.Keys(asked)))
256	if err != nil {
257		slog.Error("ssh sweep: key lookup", "err", err)
258		return
259	}
260	var cut []*conn
261	s.mu.Lock()
262	for c := range s.conns {
263		if asked[c.keyID] && !live[c.keyID] {
264			cut = append(cut, c)
265		}
266	}
267	s.mu.Unlock()
268	for _, c := range cut {
269		c.cut()
270	}
271}
272
273// Stop ends the commands that run until something happens (build log
274// --follow), so a shutdown drain waits only for work that finishes. It
275// does not close connections; Shutdown does.
276func (s *Server) Stop() {
277	s.stopOnce.Do(func() { close(s.stopping) })
278}
279
280// Shutdown closes every idle connection, then waits for the ones with a
281// session running, or for ctx. The caller closes the listener first; a
282// push in flight completes rather than being cut mid-pack.
283func (s *Server) Shutdown(ctx context.Context) error {
284	s.Stop()
285	s.mu.Lock()
286	for c := range s.conns {
287		if c.active.Load() == 0 {
288			c.net.Close()
289		}
290	}
291	s.mu.Unlock()
292	done := make(chan struct{})
293	go func() {
294		s.sessions.Wait()
295		close(done)
296	}()
297	select {
298	case <-done:
299		return nil
300	case <-ctx.Done():
301		return ctx.Err()
302	}
303}
304
305func (s *Server) handleConn(c *conn) {
306	defer c.net.Close()
307	sconn, chans, reqs, err := ssh.NewServerConn(c.net, s.sshCfg)
308	if err != nil {
309		return
310	}
311	defer sconn.Close()
312	ext := sconn.Permissions.Extensions
313	s.mu.Lock()
314	c.keyID, _ = strconv.ParseInt(ext["key-id"], 10, 64)
315	c.userID, _ = strconv.ParseInt(ext["user-id"], 10, 64)
316	s.mu.Unlock()
317	go ssh.DiscardRequests(reqs)
318
319	for newCh := range chans {
320		if newCh.ChannelType() != "session" {
321			newCh.Reject(ssh.UnknownChannelType, "only session channels are supported")
322			continue
323		}
324		ch, chReqs, err := newCh.Accept()
325		if err != nil {
326			continue
327		}
328		c.active.Add(1)
329		go func() {
330			defer c.active.Add(-1)
331			s.handleSession(c, sconn, ch, chReqs)
332		}()
333	}
334}
335
336func (s *Server) handleSession(c *conn, sconn *ssh.ServerConn, ch ssh.Channel, reqs <-chan *ssh.Request) {
337	defer ch.Close()
338	var term control.Term
339	for req := range reqs {
340		switch req.Type {
341		case "exec":
342			var payload struct{ Command string }
343			if err := ssh.Unmarshal(req.Payload, &payload); err != nil {
344				req.Reply(false, nil)
345				continue
346			}
347			req.Reply(true, nil)
348			// x/crypto closes reqs when the client closes the channel. That
349			// is how a follow learns nobody is reading: the CLI's shared
350			// connection outlives a Ctrl-C, the channel does not. Stop
351			// ends it too, for a restart.
352			closed := make(chan struct{})
353			go func() {
354				for r := range reqs {
355					r.Reply(false, nil)
356				}
357				close(closed)
358			}()
359			done := make(chan struct{})
360			go func() {
361				select {
362				case <-closed:
363				case <-s.stopping:
364				}
365				close(done)
366			}()
367			code := s.runExec(c, sconn, ch, term, payload.Command, done)
368			sendExit(ch, code)
369			return
370		case "shell":
371			req.Reply(true, nil)
372			fmt.Fprintf(ch, "gitbay control plane: interactive shells are not available.\nTry: ssh %s help\n", s.cfg.Server.SiteURL)
373			sendExit(ch, protocol.ExitUsage)
374			return
375		case "env":
376			var kv struct{ Name, Value string }
377			if ssh.Unmarshal(req.Payload, &kv) == nil && kv.Name == "GITBAY_TERM" {
378				term = control.ParseTerm(kv.Value)
379			}
380			req.Reply(true, nil)
381		case "pty-req":
382			// Harmless; accept and ignore.
383			req.Reply(true, nil)
384		default:
385			req.Reply(false, nil)
386		}
387	}
388}
389
390func sendExit(ch ssh.Channel, code int) {
391	var msg = struct{ Status uint32 }{uint32(code)}
392	ch.SendRequest("exit-status", false, ssh.Marshal(&msg))
393}
394
395func (s *Server) runExec(c *conn, sconn *ssh.ServerConn, ch ssh.Channel, term control.Term, cmdline string, done <-chan struct{}) int {
396	ext := sconn.Permissions.Extensions
397	if blob := ext["anon-key"]; blob != "" {
398		return s.runAnonymous(ch, blob, cmdline)
399	}
400	userID, _ := strconv.ParseInt(ext["user-id"], 10, 64)
401	keyID, _ := strconv.ParseInt(ext["key-id"], 10, 64)
402	// A connection outlives its commands, so the key is read again for
403	// each one: what it may do is what it may do now (#256).
404	key, err := s.st.SSHKeyByID(keyID)
405	if errors.Is(err, store.ErrNotFound) || (err == nil && key.UserID != userID) {
406		fmt.Fprintln(ch.Stderr(), "this key is no longer registered")
407		return protocol.ExitDenied
408	}
409	if err != nil {
410		slog.Error("ssh exec: key lookup", "err", err)
411		fmt.Fprintln(ch.Stderr(), "authentication temporarily unavailable")
412		return protocol.ExitFailure
413	}
414	if key.Expired(time.Now()) {
415		fmt.Fprintln(ch.Stderr(), "this key has expired; remove it and add a new one")
416		return protocol.ExitDenied
417	}
418	user, err := s.st.UserByID(userID)
419	if err != nil {
420		fmt.Fprintln(ch.Stderr(), "account no longer exists")
421		return protocol.ExitDenied
422	}
423	_ = s.st.TouchSSHKey(keyID)
424	return Exec(s.cfg, s.st, user, key, term, cmdline, ch, ch, ch.Stderr(), done, s.stopping, c.revoked)
425}
426
427// runAnonymous handles a session from an unregistered key: the register
428// command and nothing else.
429func (s *Server) runAnonymous(ch ssh.Channel, keyB64, cmdline string) int {
430	raw, err := base64.StdEncoding.DecodeString(keyB64)
431	if err != nil {
432		return protocol.ExitFailure
433	}
434	pub, err := ssh.ParsePublicKey(raw)
435	if err != nil {
436		return protocol.ExitFailure
437	}
438	argv, err := protocol.Tokenize(cmdline)
439	if err != nil {
440		fmt.Fprintf(ch.Stderr(), "cannot parse command: %v\n", err)
441		return protocol.ExitUsage
442	}
443	if len(argv) == 0 || argv[0] != "register" {
444		fmt.Fprintf(ch.Stderr(), "this key is not registered here. Create an account with:\n  ssh <host> register --username <name> %s\n",
445			map[string]string{"open": "--email <address>", "invite": "--invite <code>"}[s.cfg.Registration.Mode])
446		return protocol.ExitDenied
447	}
448	return control.RunRegister(s.cfg, s.st, pub, argv, ch, ch.Stderr())
449}
450
451// Exec runs one SSH exec command line for an authenticated key. It is the
452// single dispatch path shared by the embedded listener and the system-sshd
453// forced command (gitbayd shell). Closing revoked kills a git transport.
454func Exec(cfg config.Config, st *store.Store, user store.User, key store.SSHKey, term control.Term, cmdline string,
455	stdin io.Reader, stdout, stderr io.Writer, done, stopping, revoked <-chan struct{}) int {
456	if user.Disabled {
457		fmt.Fprintln(stderr, "this account is disabled; contact the instance admin")
458		return protocol.ExitDenied
459	}
460	argv, err := protocol.Tokenize(cmdline)
461	if err != nil {
462		fmt.Fprintf(stderr, "cannot parse command: %v\n", err)
463		return protocol.ExitUsage
464	}
465	if len(argv) > 0 {
466		switch argv[0] {
467		case "git-upload-pack", "git-receive-pack", "git-upload-archive":
468			if user.Pending {
469				fmt.Fprintln(stderr, "your account is not active yet: verify your email first")
470				return protocol.ExitDenied
471			}
472			return runGit(cfg, st, user, key.Scope, argv, stdin, stdout, stderr, revoked)
473		case "git-lfs-authenticate":
474			// Part of the git transport, not the control plane: usable by
475			// git-scoped and deploy keys, with the transports' access rules.
476			if user.Pending {
477				fmt.Fprintln(stderr, "your account is not active yet: verify your email first")
478				return protocol.ExitDenied
479			}
480			return runLFSAuthenticate(cfg, st, user, key.Scope, argv, stdout, stderr)
481		}
482	}
483	ctx := &control.Ctx{
484		User:     user,
485		Scope:    key.Scope,
486		Source:   key.Fingerprint,
487		Term:     term,
488		Store:    st,
489		Cfg:      cfg,
490		Stdin:    stdin,
491		Stdout:   stdout,
492		Stderr:   stderr,
493		Done:     done,
494		Stopping: stopping,
495		Expires:  key.ExpiresAt,
496	}
497	return control.Dispatch(ctx, argv)
498}
499
500// runGit streams a git transport service after access checks.
501func runGit(cfg config.Config, st *store.Store, user store.User, scope string, argv []string,
502	stdin io.Reader, stdout, stderr io.Writer, revoked <-chan struct{}) int {
503	service := argv[0]
504	if len(argv) != 2 {
505		fmt.Fprintf(stderr, "usage: %s <path>\n", service)
506		return protocol.ExitUsage
507	}
508	write := service == "git-receive-pack"
509
510	repo, err := st.RepoByPath(argv[1])
511	if err != nil {
512		fmt.Fprintln(stderr, "repository not found")
513		return protocol.ExitNotFound
514	}
515	if policy.IsDeployScope(scope) {
516		// A deploy key authorizes by its binding alone: one repository,
517		// its mode, nothing inherited from whoever registered it. Any
518		// mismatch reads as nonexistence, same as the access rules.
519		if !policy.DeployScopeAllows(scope, repo.ID, write) {
520			fmt.Fprintln(stderr, "repository not found")
521			return protocol.ExitNotFound
522		}
523	} else {
524		grant, err := st.AccessRole(repo.ID, user.ID)
525		if err != nil {
526			fmt.Fprintln(stderr, "internal error")
527			return protocol.ExitFailure
528		}
529		if !policy.CanRead(user, repo, grant) {
530			// Same answer as nonexistence: private repos must not be enumerable.
531			fmt.Fprintln(stderr, "repository not found")
532			return protocol.ExitNotFound
533		}
534		if !policy.ScopeAllowsGit(scope, repo.Path(), write) {
535			fmt.Fprintf(stderr, "this key's scope (%s) does not allow %s on %s\n", scope, service, repo.Path())
536			return protocol.ExitDenied
537		}
538		if write && !policy.CanWrite(user, repo, grant) {
539			fmt.Fprintf(stderr, "write access to %s denied\n", repo.Path())
540			return protocol.ExitDenied
541		}
542	}
543	if write && repo.Settings.Archived {
544		fmt.Fprintf(stderr, "%s is archived and read-only\n", repo.Path())
545		return protocol.ExitDenied
546	}
547	if write {
548		if mirrored, err := st.PullMirrored(repo.ID); err == nil && mirrored {
549			fmt.Fprintf(stderr, "%s is a pull mirror: its refs come from the upstream; push there instead\n", repo.Path())
550			return protocol.ExitDenied
551		}
552	}
553
554	dir := control.RepoDir(cfg.Server.Root, repo.OwnerName, repo.Name)
555	env := []string{
556		hookd.EnvSocket + "=" + hookd.SocketPath(cfg.Server.Root),
557		hookd.EnvRepoID + "=" + strconv.FormatInt(repo.ID, 10),
558		hookd.EnvUserID + "=" + strconv.FormatInt(user.ID, 10),
559		hookd.EnvScope + "=" + scope,
560	}
561	// A storage quota on the owner rides the same mechanism as the pack
562	// cap: the pack may be no larger than what the owner has left.
563	maxPack := cfg.Limits.MaxPackBytes
564	if write && repo.OwnerKind == "user" {
565		if limit := control.ByteLimit(st, control.QuotaConfig(cfg), repo.OwnerID); limit > 0 {
566			used := control.OwnedBytes(st, cfg.Server.Root, repo.OwnerID)
567			left := limit - used
568			if left <= 0 {
569				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)
570				return protocol.ExitDenied
571			}
572			if maxPack == 0 || left < maxPack {
573				maxPack = left
574			}
575		}
576	}
577	if err := gitutil.Transport(service, dir, stdin, stdout, stderr, env, maxPack, revoked); err != nil {
578		return protocol.ExitFailure
579	}
580	return protocol.ExitOK
581}