internal/sshd/sshd.go

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

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