internal/sshd/sshd.go

70dc0648f931f6f7112c6b71b0be485eeae4077f
gitbay/internal/sshd/sshd.go history · blame · raw

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