ssh.go

  1package server
  2
  3import (
  4	"errors"
  5	"fmt"
  6	"path/filepath"
  7	"strings"
  8	"time"
  9
 10	"github.com/charmbracelet/log"
 11	"github.com/charmbracelet/soft-serve/server/backend"
 12	cm "github.com/charmbracelet/soft-serve/server/cmd"
 13	"github.com/charmbracelet/soft-serve/server/config"
 14	"github.com/charmbracelet/ssh"
 15	"github.com/charmbracelet/wish"
 16	bm "github.com/charmbracelet/wish/bubbletea"
 17	lm "github.com/charmbracelet/wish/logging"
 18	rm "github.com/charmbracelet/wish/recover"
 19	"github.com/muesli/termenv"
 20	gossh "golang.org/x/crypto/ssh"
 21)
 22
 23// SSHServer is a SSH server that implements the git protocol.
 24type SSHServer struct {
 25	*ssh.Server
 26	cfg *config.Config
 27}
 28
 29// NewSSHServer returns a new SSHServer.
 30func NewSSHServer(cfg *config.Config) (*SSHServer, error) {
 31	var err error
 32	s := &SSHServer{cfg: cfg}
 33	logger := logger.StandardLog(log.StandardLogOptions{ForceLevel: log.DebugLevel})
 34	mw := []wish.Middleware{
 35		rm.MiddlewareWithLogger(
 36			logger,
 37			// BubbleTea middleware.
 38			bm.MiddlewareWithProgramHandler(SessionHandler(cfg), termenv.ANSI256),
 39			// Command middleware must come after the git middleware.
 40			cm.Middleware(cfg),
 41			// Git middleware.
 42			s.Middleware(cfg),
 43			lm.MiddlewareWithLogger(logger),
 44		),
 45	}
 46	s.Server, err = wish.NewServer(
 47		ssh.PublicKeyAuth(s.PublicKeyHandler),
 48		ssh.KeyboardInteractiveAuth(s.KeyboardInteractiveHandler),
 49		wish.WithAddress(cfg.SSH.ListenAddr),
 50		wish.WithHostKeyPath(cfg.SSH.KeyPath),
 51		wish.WithMiddleware(mw...),
 52	)
 53	if err != nil {
 54		return nil, err
 55	}
 56
 57	if cfg.SSH.MaxTimeout > 0 {
 58		s.Server.MaxTimeout = time.Duration(cfg.SSH.MaxTimeout) * time.Second
 59	}
 60	if cfg.SSH.IdleTimeout > 0 {
 61		s.Server.IdleTimeout = time.Duration(cfg.SSH.IdleTimeout) * time.Second
 62	}
 63
 64	return s, nil
 65}
 66
 67// PublicKeyAuthHandler handles public key authentication.
 68func (s *SSHServer) PublicKeyHandler(ctx ssh.Context, pk ssh.PublicKey) bool {
 69	al := s.cfg.Access.AccessLevel("", pk)
 70	logger.Debug("publickey handler", "level", al)
 71	return al > backend.NoAccess
 72}
 73
 74// KeyboardInteractiveHandler handles keyboard interactive authentication.
 75func (s *SSHServer) KeyboardInteractiveHandler(_ ssh.Context, _ gossh.KeyboardInteractiveChallenge) bool {
 76	return true
 77}
 78
 79// Middleware adds Git server functionality to the ssh.Server. Repos are stored
 80// in the specified repo directory. The provided Hooks implementation will be
 81// checked for access on a per repo basis for a ssh.Session public key.
 82// Hooks.Push and Hooks.Fetch will be called on successful completion of
 83// their commands.
 84func (s *SSHServer) Middleware(cfg *config.Config) wish.Middleware {
 85	return func(sh ssh.Handler) ssh.Handler {
 86		return func(s ssh.Session) {
 87			func() {
 88				cmd := s.Command()
 89				if len(cmd) >= 2 && strings.HasPrefix(cmd[0], "git") {
 90					gc := cmd[0]
 91					// repo should be in the form of "repo.git"
 92					repo := sanitizeRepoName(cmd[1])
 93					name := repo
 94					if strings.Contains(repo, "/") {
 95						log.Printf("invalid repo: %s", repo)
 96						sshFatal(s, fmt.Errorf("%s: %s", ErrInvalidRepo, "user repos not supported"))
 97						return
 98					}
 99					pk := s.PublicKey()
100					access := cfg.Access.AccessLevel(name, pk)
101					// git bare repositories should end in ".git"
102					// https://git-scm.com/docs/gitrepository-layout
103					repo = strings.TrimSuffix(repo, ".git") + ".git"
104					// FIXME: determine repositories path
105					repoDir := filepath.Join(cfg.DataPath, "repos", repo)
106					switch gc {
107					case ReceivePackBin:
108						if access < backend.ReadWriteAccess {
109							sshFatal(s, ErrNotAuthed)
110							return
111						}
112						if _, err := cfg.Backend.Repository(name); err != nil {
113							if _, err := cfg.Backend.CreateRepository(name, false); err != nil {
114								log.Printf("failed to create repo: %s", err)
115								sshFatal(s, err)
116								return
117							}
118						}
119						if err := ReceivePack(s, s, s.Stderr(), repoDir); err != nil {
120							sshFatal(s, ErrSystemMalfunction)
121						}
122						return
123					case UploadPackBin, UploadArchiveBin:
124						if access < backend.ReadOnlyAccess {
125							sshFatal(s, ErrNotAuthed)
126							return
127						}
128						gitPack := UploadPack
129						if gc == UploadArchiveBin {
130							gitPack = UploadArchive
131						}
132						err := gitPack(s, s, s.Stderr(), repoDir)
133						if errors.Is(err, ErrInvalidRepo) {
134							sshFatal(s, ErrInvalidRepo)
135						} else if err != nil {
136							sshFatal(s, ErrSystemMalfunction)
137						}
138					}
139				}
140			}()
141			sh(s)
142		}
143	}
144}
145
146// sshFatal prints to the session's STDOUT as a git response and exit 1.
147func sshFatal(s ssh.Session, v ...interface{}) {
148	WritePktline(s, v...)
149	s.Exit(1) // nolint: errcheck
150}
151
152func sanitizeRepoName(repo string) string {
153	repo = strings.TrimPrefix(repo, "/")
154	repo = filepath.Clean(repo)
155	repo = strings.TrimSuffix(repo, ".git")
156	return repo
157}