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