internal/sshd/sshd.go

ba0a7d33f3a65ce53aafb074fda1682cf1cecfdf
gitbay/internal/sshd/sshd.go history · blame · raw

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}