internal/sshd/sshd.go

8e64f8208b18dc40fb5c27c873802282e722255e
gitbay/internal/sshd/sshd.go history · blame · raw

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