internal/sshd/sshd.go

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

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