internal/sshd/sshd.go

6821a6f76082b1e10ff899ff51021b11695c4ad6
gitbay/internal/sshd/sshd.go history · blame · raw

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