ssh.go

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