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