internal/sshd/sshd.go

24c0aad9bf75f925497b0f6de2c8e831e67eaac9
gitbay/internal/sshd/sshd.go history · blame · raw

263 lines · 7338 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/pem"
  9	"errors"
 10	"fmt"
 11	"io"
 12	"log/slog"
 13	"net"
 14	"os"
 15	"path/filepath"
 16	"strconv"
 17
 18	"golang.org/x/crypto/ssh"
 19
 20	"gitbay.org/gitbay/internal/config"
 21	"gitbay.org/gitbay/internal/control"
 22	"gitbay.org/gitbay/internal/gitutil"
 23	"gitbay.org/gitbay/internal/hookd"
 24	"gitbay.org/gitbay/internal/policy"
 25	"gitbay.org/gitbay/internal/protocol"
 26	"gitbay.org/gitbay/internal/store"
 27)
 28
 29type Server struct {
 30	cfg   config.Config
 31	st    *store.Store
 32	sshCfg *ssh.ServerConfig
 33}
 34
 35func New(cfg config.Config, st *store.Store) (*Server, error) {
 36	s := &Server{cfg: cfg, st: st}
 37
 38	sc := &ssh.ServerConfig{
 39		PublicKeyCallback: s.authenticate,
 40		ServerVersion:     "SSH-2.0-gitbayd",
 41	}
 42	signers, err := loadHostKeys(cfg)
 43	if err != nil {
 44		return nil, err
 45	}
 46	for _, sg := range signers {
 47		sc.AddHostKey(sg)
 48	}
 49	s.sshCfg = sc
 50	return s, nil
 51}
 52
 53// loadHostKeys loads the configured host keys, or generates an ed25519 key
 54// under server.root/ssh/ when none are configured.
 55func loadHostKeys(cfg config.Config) ([]ssh.Signer, error) {
 56	paths := cfg.SSH.HostKeys
 57	if len(paths) == 0 {
 58		p := filepath.Join(cfg.Server.Root, "ssh", "host_ed25519")
 59		if _, err := os.Stat(p); errors.Is(err, os.ErrNotExist) {
 60			if err := generateHostKey(p); err != nil {
 61				return nil, fmt.Errorf("generating host key: %w", err)
 62			}
 63			slog.Info("generated ssh host key", "path", p)
 64		}
 65		paths = []string{p}
 66	}
 67	var signers []ssh.Signer
 68	for _, p := range paths {
 69		raw, err := os.ReadFile(p)
 70		if err != nil {
 71			return nil, fmt.Errorf("host key %s: %w", p, err)
 72		}
 73		sg, err := ssh.ParsePrivateKey(raw)
 74		if err != nil {
 75			return nil, fmt.Errorf("host key %s: %w", p, err)
 76		}
 77		signers = append(signers, sg)
 78	}
 79	return signers, nil
 80}
 81
 82func generateHostKey(path string) error {
 83	if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
 84		return err
 85	}
 86	_, priv, err := ed25519.GenerateKey(rand.Reader)
 87	if err != nil {
 88		return err
 89	}
 90	block, err := ssh.MarshalPrivateKey(priv, "")
 91	if err != nil {
 92		return err
 93	}
 94	return os.WriteFile(path, pem.EncodeToMemory(block), 0o600)
 95}
 96
 97// authenticate resolves the presented key to a registered account. The SSH
 98// username is ignored; identity comes from the key alone.
 99func (s *Server) authenticate(_ ssh.ConnMetadata, pub ssh.PublicKey) (*ssh.Permissions, error) {
100	fp := ssh.FingerprintSHA256(pub)
101	key, err := s.st.SSHKeyByFingerprint(fp)
102	if err != nil {
103		return nil, fmt.Errorf("unknown key %s", fp)
104	}
105	return &ssh.Permissions{Extensions: map[string]string{
106		"user-id": strconv.FormatInt(key.UserID, 10),
107		"key-id":  strconv.FormatInt(key.ID, 10),
108		"scope":   key.Scope,
109	}}, nil
110}
111
112// Serve accepts connections on ln until it is closed.
113func (s *Server) Serve(ln net.Listener) error {
114	for {
115		conn, err := ln.Accept()
116		if err != nil {
117			return err
118		}
119		go s.handleConn(conn)
120	}
121}
122
123func (s *Server) handleConn(conn net.Conn) {
124	defer conn.Close()
125	sconn, chans, reqs, err := ssh.NewServerConn(conn, s.sshCfg)
126	if err != nil {
127		return
128	}
129	defer sconn.Close()
130	go ssh.DiscardRequests(reqs)
131
132	for newCh := range chans {
133		if newCh.ChannelType() != "session" {
134			newCh.Reject(ssh.UnknownChannelType, "only session channels are supported")
135			continue
136		}
137		ch, chReqs, err := newCh.Accept()
138		if err != nil {
139			continue
140		}
141		go s.handleSession(sconn, ch, chReqs)
142	}
143}
144
145func (s *Server) handleSession(sconn *ssh.ServerConn, ch ssh.Channel, reqs <-chan *ssh.Request) {
146	defer ch.Close()
147	for req := range reqs {
148		switch req.Type {
149		case "exec":
150			var payload struct{ Command string }
151			if err := ssh.Unmarshal(req.Payload, &payload); err != nil {
152				req.Reply(false, nil)
153				continue
154			}
155			req.Reply(true, nil)
156			code := s.runExec(sconn, ch, payload.Command)
157			sendExit(ch, code)
158			return
159		case "shell":
160			req.Reply(true, nil)
161			fmt.Fprintf(ch, "gitbay control plane: interactive shells are not available.\nTry: ssh %s help\n", s.cfg.Server.SiteURL)
162			sendExit(ch, protocol.ExitUsage)
163			return
164		case "pty-req", "env":
165			// Harmless; accept and ignore.
166			req.Reply(true, nil)
167		default:
168			req.Reply(false, nil)
169		}
170	}
171}
172
173func sendExit(ch ssh.Channel, code int) {
174	var msg = struct{ Status uint32 }{uint32(code)}
175	ch.SendRequest("exit-status", false, ssh.Marshal(&msg))
176}
177
178func (s *Server) runExec(sconn *ssh.ServerConn, ch ssh.Channel, cmdline string) int {
179	ext := sconn.Permissions.Extensions
180	userID, _ := strconv.ParseInt(ext["user-id"], 10, 64)
181	keyID, _ := strconv.ParseInt(ext["key-id"], 10, 64)
182	user, err := s.st.UserByID(userID)
183	if err != nil {
184		fmt.Fprintln(ch.Stderr(), "account no longer exists")
185		return protocol.ExitDenied
186	}
187	_ = s.st.TouchSSHKey(keyID)
188	return Exec(s.cfg, s.st, user, ext["scope"], cmdline, ch, ch, ch.Stderr())
189}
190
191// Exec runs one SSH exec command line for an authenticated key. It is the
192// single dispatch path shared by the embedded listener and the system-sshd
193// forced command (gitbayd shell).
194func Exec(cfg config.Config, st *store.Store, user store.User, scope, cmdline string,
195	stdin io.Reader, stdout, stderr io.Writer) int {
196	argv, err := protocol.Tokenize(cmdline)
197	if err != nil {
198		fmt.Fprintf(stderr, "cannot parse command: %v\n", err)
199		return protocol.ExitUsage
200	}
201	if len(argv) > 0 {
202		switch argv[0] {
203		case "git-upload-pack", "git-receive-pack", "git-upload-archive":
204			return runGit(cfg, st, user, scope, argv, stdin, stdout, stderr)
205		}
206	}
207	ctx := &control.Ctx{
208		User:   user,
209		Scope:  scope,
210		Store:  st,
211		Cfg:    cfg,
212		Stdin:  stdin,
213		Stdout: stdout,
214		Stderr: stderr,
215	}
216	return control.Dispatch(ctx, argv)
217}
218
219// runGit streams a git transport service after access checks.
220func runGit(cfg config.Config, st *store.Store, user store.User, scope string, argv []string,
221	stdin io.Reader, stdout, stderr io.Writer) int {
222	service := argv[0]
223	if len(argv) != 2 {
224		fmt.Fprintf(stderr, "usage: %s <path>\n", service)
225		return protocol.ExitUsage
226	}
227	write := service == "git-receive-pack"
228
229	repo, err := st.RepoByPath(argv[1])
230	if err != nil {
231		fmt.Fprintln(stderr, "repository not found")
232		return protocol.ExitNotFound
233	}
234	grant, err := st.AccessRole(repo.ID, user.ID)
235	if err != nil {
236		fmt.Fprintln(stderr, "internal error")
237		return protocol.ExitFailure
238	}
239	if !policy.CanRead(user, repo, grant) {
240		// Same answer as nonexistence: private repos must not be enumerable.
241		fmt.Fprintln(stderr, "repository not found")
242		return protocol.ExitNotFound
243	}
244	if !policy.ScopeAllowsGit(scope, repo.Path(), write) {
245		fmt.Fprintf(stderr, "this key's scope (%s) does not allow %s on %s\n", scope, service, repo.Path())
246		return protocol.ExitDenied
247	}
248	if write && !policy.CanWrite(user, repo, grant) {
249		fmt.Fprintf(stderr, "write access to %s denied\n", repo.Path())
250		return protocol.ExitDenied
251	}
252
253	dir := control.RepoDir(cfg.Server.Root, repo.OwnerName, repo.Name)
254	env := []string{
255		hookd.EnvSocket + "=" + hookd.SocketPath(cfg.Server.Root),
256		hookd.EnvRepoID + "=" + strconv.FormatInt(repo.ID, 10),
257		hookd.EnvUserID + "=" + strconv.FormatInt(user.ID, 10),
258	}
259	if err := gitutil.Transport(service, dir, stdin, stdout, stderr, env); err != nil {
260		return protocol.ExitFailure
261	}
262	return protocol.ExitOK
263}