internal/sshd/sshd.go

251a71053c93d2621ad97fa7d1ce8efacb119406
gitbay/internal/sshd/sshd.go history · blame · raw

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