krz/gitbay

A CLI-first git forge.

clone: git clone https://gitbay.org/krz/gitbay.git

main: internal/sshd/sshd.go · raw

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