1package ssh
  2
  3import (
  4	"fmt"
  5	"os"
  6	"time"
  7
  8	"github.com/charmbracelet/log"
  9	"github.com/charmbracelet/soft-serve/pkg/backend"
 10	"github.com/charmbracelet/soft-serve/pkg/config"
 11	"github.com/charmbracelet/soft-serve/pkg/db"
 12	"github.com/charmbracelet/soft-serve/pkg/proto"
 13	"github.com/charmbracelet/soft-serve/pkg/ssh/cmd"
 14	"github.com/charmbracelet/soft-serve/pkg/sshutils"
 15	"github.com/charmbracelet/soft-serve/pkg/store"
 16	"github.com/charmbracelet/ssh"
 17	"github.com/charmbracelet/wish"
 18	bm "github.com/charmbracelet/wish/bubbletea"
 19	"github.com/muesli/termenv"
 20	"github.com/prometheus/client_golang/prometheus"
 21	"github.com/prometheus/client_golang/prometheus/promauto"
 22	"github.com/spf13/cobra"
 23	gossh "golang.org/x/crypto/ssh"
 24)
 25
 26// ErrPermissionDenied is returned when a user is not allowed connect.
 27var ErrPermissionDenied = fmt.Errorf("permission denied")
 28
 29// AuthenticationMiddleware handles authentication.
 30func AuthenticationMiddleware(sh ssh.Handler) ssh.Handler {
 31	return func(s ssh.Session) {
 32		// XXX: The authentication key is set in the context but gossh doesn't
 33		// validate the authentication. We need to verify that the _last_ key
 34		// that was approved is the one that's being used.
 35
 36		pk := s.PublicKey()
 37		if pk != nil {
 38			// There is no public key stored in the context, public-key auth
 39			// was never requested, skip
 40			perms := s.Permissions().Permissions
 41			if perms == nil {
 42				wish.Fatalln(s, ErrPermissionDenied)
 43				return
 44			}
 45
 46			// Check if the key is the same as the one we have in context
 47			fp := perms.Extensions["pubkey-fp"]
 48			if fp == "" || fp != gossh.FingerprintSHA256(pk) {
 49				wish.Fatalln(s, ErrPermissionDenied)
 50				return
 51			}
 52		}
 53
 54		sh(s)
 55	}
 56}
 57
 58// ContextMiddleware adds the config, backend, and logger to the session context.
 59func ContextMiddleware(cfg *config.Config, dbx *db.DB, datastore store.Store, be *backend.Backend, logger *log.Logger) func(ssh.Handler) ssh.Handler {
 60	return func(sh ssh.Handler) ssh.Handler {
 61		return func(s ssh.Session) {
 62			ctx := s.Context()
 63			ctx.SetValue(sshutils.ContextKeySession, s)
 64			ctx.SetValue(config.ContextKey, cfg)
 65			ctx.SetValue(db.ContextKey, dbx)
 66			ctx.SetValue(store.ContextKey, datastore)
 67			ctx.SetValue(backend.ContextKey, be)
 68			ctx.SetValue(log.ContextKey, logger.WithPrefix("ssh"))
 69			sh(s)
 70		}
 71	}
 72}
 73
 74var cliCommandCounter = promauto.NewCounterVec(prometheus.CounterOpts{
 75	Namespace: "soft_serve",
 76	Subsystem: "cli",
 77	Name:      "commands_total",
 78	Help:      "Total times each command was called",
 79}, []string{"command"})
 80
 81// CommandMiddleware handles git commands and CLI commands.
 82// This middleware must be run after the ContextMiddleware.
 83func CommandMiddleware(sh ssh.Handler) ssh.Handler {
 84	return func(s ssh.Session) {
 85		_, _, ptyReq := s.Pty()
 86		if ptyReq {
 87			sh(s)
 88			return
 89		}
 90
 91		ctx := s.Context()
 92		cfg := config.FromContext(ctx)
 93
 94		renderer := bm.MakeRenderer(s)
 95		if testrun, ok := os.LookupEnv("SOFT_SERVE_NO_COLOR"); ok && testrun == "1" {
 96			// Disable colors when running tests.
 97			renderer.SetColorProfile(termenv.Ascii)
 98		}
 99
100		args := s.Command()
101		cliCommandCounter.WithLabelValues(cmd.CommandName(args)).Inc()
102		rootCmd := &cobra.Command{
103			Short:        "Soft Serve is a self-hostable Git server for the command line.",
104			SilenceUsage: true,
105		}
106		rootCmd.CompletionOptions.DisableDefaultCmd = true
107
108		rootCmd.SetUsageTemplate(cmd.UsageTemplate)
109		rootCmd.SetUsageFunc(cmd.UsageFunc)
110		rootCmd.AddCommand(
111			cmd.GitUploadPackCommand(),
112			cmd.GitUploadArchiveCommand(),
113			cmd.GitReceivePackCommand(),
114			cmd.RepoCommand(renderer),
115			cmd.SettingsCommand(),
116			cmd.UserCommand(),
117			cmd.InfoCommand(),
118			cmd.PubkeyCommand(),
119			cmd.SetUsernameCommand(),
120			cmd.JWTCommand(),
121			cmd.TokenCommand(),
122		)
123
124		if cfg.LFS.Enabled {
125			rootCmd.AddCommand(
126				cmd.GitLFSAuthenticateCommand(),
127			)
128
129			if cfg.LFS.SSHEnabled {
130				rootCmd.AddCommand(
131					cmd.GitLFSTransfer(),
132				)
133			}
134		}
135
136		rootCmd.SetArgs(args)
137		if len(args) == 0 {
138			// otherwise it'll default to os.Args, which is not what we want.
139			rootCmd.SetArgs([]string{"--help"})
140		}
141		rootCmd.SetIn(s)
142		rootCmd.SetOut(s)
143		rootCmd.SetErr(s.Stderr())
144		rootCmd.SetContext(ctx)
145
146		if err := rootCmd.ExecuteContext(ctx); err != nil {
147			s.Exit(1) // nolint: errcheck
148			return
149		}
150	}
151}
152
153// LoggingMiddleware logs the ssh connection and command.
154func LoggingMiddleware(sh ssh.Handler) ssh.Handler {
155	return func(s ssh.Session) {
156		ctx := s.Context()
157		logger := log.FromContext(ctx).WithPrefix("ssh")
158		ct := time.Now()
159		hpk := sshutils.MarshalAuthorizedKey(s.PublicKey())
160		ptyReq, _, isPty := s.Pty()
161		addr := s.RemoteAddr().String()
162		user := proto.UserFromContext(ctx)
163		logArgs := []interface{}{
164			"addr",
165			addr,
166			"cmd",
167			s.Command(),
168		}
169
170		if user != nil {
171			logArgs = append([]interface{}{
172				"username",
173				user.Username(),
174			}, logArgs...)
175		}
176
177		if isPty {
178			logArgs = []interface{}{
179				"term", ptyReq.Term,
180				"width", ptyReq.Window.Width,
181				"height", ptyReq.Window.Height,
182			}
183		}
184
185		if config.IsVerbose() {
186			logArgs = append(logArgs,
187				"key", hpk,
188				"envs", s.Environ(),
189			)
190		}
191
192		msg := fmt.Sprintf("user %q", s.User())
193		logger.Debug(msg+" connected", logArgs...)
194		sh(s)
195		logger.Debug(msg+" disconnected", append(logArgs, "duration", time.Since(ct))...)
196	}
197}