1package server
  2
  3import (
  4	"context"
  5	"errors"
  6	"net"
  7	"path/filepath"
  8	"strconv"
  9	"strings"
 10	"time"
 11
 12	"github.com/charmbracelet/log"
 13	"github.com/charmbracelet/soft-serve/server/backend"
 14	cm "github.com/charmbracelet/soft-serve/server/cmd"
 15	"github.com/charmbracelet/soft-serve/server/config"
 16	"github.com/charmbracelet/soft-serve/server/hooks"
 17	"github.com/charmbracelet/soft-serve/server/utils"
 18	"github.com/charmbracelet/ssh"
 19	"github.com/charmbracelet/wish"
 20	bm "github.com/charmbracelet/wish/bubbletea"
 21	lm "github.com/charmbracelet/wish/logging"
 22	rm "github.com/charmbracelet/wish/recover"
 23	"github.com/muesli/termenv"
 24	"github.com/prometheus/client_golang/prometheus"
 25	"github.com/prometheus/client_golang/prometheus/promauto"
 26	gossh "golang.org/x/crypto/ssh"
 27)
 28
 29var (
 30	publicKeyCounter = promauto.NewCounterVec(prometheus.CounterOpts{
 31		Namespace: "soft_serve",
 32		Subsystem: "ssh",
 33		Name:      "public_key_auth_total",
 34		Help:      "The total number of public key auth requests",
 35	}, []string{"key", "user", "access", "allowed"})
 36
 37	keyboardInteractiveCounter = promauto.NewCounterVec(prometheus.CounterOpts{
 38		Namespace: "soft_serve",
 39		Subsystem: "ssh",
 40		Name:      "keyboard_interactive_auth_total",
 41		Help:      "The total number of keyboard interactive auth requests",
 42	}, []string{"user", "allowed"})
 43
 44	uploadPackCounter = promauto.NewCounterVec(prometheus.CounterOpts{
 45		Namespace: "soft_serve",
 46		Subsystem: "ssh",
 47		Name:      "git_upload_pack_total",
 48		Help:      "The total number of git-upload-pack requests",
 49	}, []string{"key", "user", "repo"})
 50
 51	receivePackCounter = promauto.NewCounterVec(prometheus.CounterOpts{
 52		Namespace: "soft_serve",
 53		Subsystem: "ssh",
 54		Name:      "git_receive_pack_total",
 55		Help:      "The total number of git-receive-pack requests",
 56	}, []string{"key", "user", "repo"})
 57
 58	uploadArchiveCounter = promauto.NewCounterVec(prometheus.CounterOpts{
 59		Namespace: "soft_serve",
 60		Subsystem: "ssh",
 61		Name:      "git_upload_archive_total",
 62		Help:      "The total number of git-upload-archive requests",
 63	}, []string{"key", "user", "repo"})
 64
 65	createRepoCounter = promauto.NewCounterVec(prometheus.CounterOpts{
 66		Namespace: "soft_serve",
 67		Subsystem: "ssh",
 68		Name:      "create_repo_total",
 69		Help:      "The total number of create repo requests",
 70	}, []string{"key", "user", "repo"})
 71)
 72
 73// SSHServer is a SSH server that implements the git protocol.
 74type SSHServer struct {
 75	srv *ssh.Server
 76	cfg *config.Config
 77}
 78
 79// NewSSHServer returns a new SSHServer.
 80func NewSSHServer(cfg *config.Config, hooks hooks.Hooks) (*SSHServer, error) {
 81	var err error
 82	s := &SSHServer{cfg: cfg}
 83	logger := logger.StandardLog(log.StandardLogOptions{ForceLevel: log.DebugLevel})
 84	mw := []wish.Middleware{
 85		rm.MiddlewareWithLogger(
 86			logger,
 87			// BubbleTea middleware.
 88			bm.MiddlewareWithProgramHandler(SessionHandler(cfg), termenv.ANSI256),
 89			// CLI middleware.
 90			cm.Middleware(cfg, hooks),
 91			// Git middleware.
 92			s.Middleware(cfg),
 93			// Logging middleware.
 94			lm.MiddlewareWithLogger(logger),
 95		),
 96	}
 97	s.srv, err = wish.NewServer(
 98		ssh.PublicKeyAuth(s.PublicKeyHandler),
 99		ssh.KeyboardInteractiveAuth(s.KeyboardInteractiveHandler),
100		wish.WithAddress(cfg.SSH.ListenAddr),
101		wish.WithHostKeyPath(filepath.Join(cfg.DataPath, cfg.SSH.KeyPath)),
102		wish.WithMiddleware(mw...),
103	)
104	if err != nil {
105		return nil, err
106	}
107
108	if cfg.SSH.MaxTimeout > 0 {
109		s.srv.MaxTimeout = time.Duration(cfg.SSH.MaxTimeout) * time.Second
110	}
111	if cfg.SSH.IdleTimeout > 0 {
112		s.srv.IdleTimeout = time.Duration(cfg.SSH.IdleTimeout) * time.Second
113	}
114
115	return s, nil
116}
117
118// ListenAndServe starts the SSH server.
119func (s *SSHServer) ListenAndServe() error {
120	return s.srv.ListenAndServe()
121}
122
123// Serve starts the SSH server on the given net.Listener.
124func (s *SSHServer) Serve(l net.Listener) error {
125	return s.srv.Serve(l)
126}
127
128// Close closes the SSH server.
129func (s *SSHServer) Close() error {
130	return s.srv.Close()
131}
132
133// Shutdown gracefully shuts down the SSH server.
134func (s *SSHServer) Shutdown(ctx context.Context) error {
135	return s.srv.Shutdown(ctx)
136}
137
138// PublicKeyAuthHandler handles public key authentication.
139func (s *SSHServer) PublicKeyHandler(ctx ssh.Context, pk ssh.PublicKey) bool {
140	ac := s.cfg.Backend.AccessLevel("", pk)
141	allowed := ac >= backend.ReadOnlyAccess
142	ak := backend.MarshalAuthorizedKey(pk)
143	publicKeyCounter.WithLabelValues(ak, ctx.User(), ac.String(), strconv.FormatBool(allowed)).Inc()
144	return allowed
145}
146
147// KeyboardInteractiveHandler handles keyboard interactive authentication.
148func (s *SSHServer) KeyboardInteractiveHandler(ctx ssh.Context, _ gossh.KeyboardInteractiveChallenge) bool {
149	ac := s.cfg.Backend.AllowKeyless() && s.PublicKeyHandler(ctx, nil)
150	keyboardInteractiveCounter.WithLabelValues(ctx.User(), strconv.FormatBool(ac)).Inc()
151	return ac
152}
153
154// Middleware adds Git server functionality to the ssh.Server. Repos are stored
155// in the specified repo directory. The provided Hooks implementation will be
156// checked for access on a per repo basis for a ssh.Session public key.
157// Hooks.Push and Hooks.Fetch will be called on successful completion of
158// their commands.
159func (s *SSHServer) Middleware(cfg *config.Config) wish.Middleware {
160	return func(sh ssh.Handler) ssh.Handler {
161		return func(s ssh.Session) {
162			func() {
163				cmd := s.Command()
164				if len(cmd) >= 2 && strings.HasPrefix(cmd[0], "git") {
165					gc := cmd[0]
166					// repo should be in the form of "repo.git"
167					name := utils.SanitizeRepo(cmd[1])
168					pk := s.PublicKey()
169					ak := backend.MarshalAuthorizedKey(pk)
170					access := cfg.Backend.AccessLevel(name, pk)
171					// git bare repositories should end in ".git"
172					// https://git-scm.com/docs/gitrepository-layout
173					repo := name + ".git"
174					reposDir := filepath.Join(cfg.DataPath, "repos")
175					if err := ensureWithin(reposDir, repo); err != nil {
176						sshFatal(s, err)
177						return
178					}
179
180					repoDir := filepath.Join(reposDir, repo)
181					switch gc {
182					case receivePackBin:
183						if access < backend.ReadWriteAccess {
184							sshFatal(s, ErrNotAuthed)
185							return
186						}
187						if _, err := cfg.Backend.Repository(name); err != nil {
188							if _, err := cfg.Backend.CreateRepository(name, backend.RepositoryOptions{Private: false}); err != nil {
189								log.Errorf("failed to create repo: %s", err)
190								sshFatal(s, err)
191								return
192							}
193							createRepoCounter.WithLabelValues(ak, s.User(), name).Inc()
194						}
195						if err := receivePack(s, s, s.Stderr(), repoDir); err != nil {
196							sshFatal(s, ErrSystemMalfunction)
197						}
198						receivePackCounter.WithLabelValues(ak, s.User(), name).Inc()
199						return
200					case uploadPackBin, uploadArchiveBin:
201						if access < backend.ReadOnlyAccess {
202							sshFatal(s, ErrNotAuthed)
203							return
204						}
205
206						gitPack := uploadPack
207						counter := uploadPackCounter
208						if gc == uploadArchiveBin {
209							gitPack = uploadArchive
210							counter = uploadArchiveCounter
211						}
212
213						err := gitPack(s, s, s.Stderr(), repoDir)
214						if errors.Is(err, ErrInvalidRepo) {
215							sshFatal(s, ErrInvalidRepo)
216						} else if err != nil {
217							sshFatal(s, ErrSystemMalfunction)
218						}
219
220						counter.WithLabelValues(ak, s.User(), name).Inc()
221					}
222				}
223			}()
224			sh(s)
225		}
226	}
227}
228
229// sshFatal prints to the session's STDOUT as a git response and exit 1.
230func sshFatal(s ssh.Session, v ...interface{}) {
231	writePktline(s, v...)
232	s.Exit(1) // nolint: errcheck
233}