internal/sshd/sshd.go

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

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