1package ssh23import (4 "fmt"5 "time"67 "github.com/charmbracelet/log/v2"8 "github.com/charmbracelet/soft-serve/pkg/backend"9 "github.com/charmbracelet/soft-serve/pkg/config"10 "github.com/charmbracelet/soft-serve/pkg/db"11 "github.com/charmbracelet/soft-serve/pkg/proto"12 "github.com/charmbracelet/soft-serve/pkg/ssh/cmd"13 "github.com/charmbracelet/soft-serve/pkg/sshutils"14 "github.com/charmbracelet/soft-serve/pkg/store"15 "github.com/charmbracelet/ssh"16 "github.com/charmbracelet/wish/v2"17 "github.com/prometheus/client_golang/prometheus"18 "github.com/prometheus/client_golang/prometheus/promauto"19 "github.com/spf13/cobra"20 gossh "golang.org/x/crypto/ssh"21)2223// ErrPermissionDenied is returned when a user is not allowed connect.24var ErrPermissionDenied = fmt.Errorf("permission denied")2526// AuthenticationMiddleware handles authentication.27func AuthenticationMiddleware(sh ssh.Handler) ssh.Handler {28 return func(s ssh.Session) {29 // XXX: The authentication key is set in the context but gossh doesn't30 // validate the authentication. We need to verify that the _last_ key31 // that was approved is the one that's being used.3233 pk := s.PublicKey()34 if pk != nil {35 // There is no public key stored in the context, public-key auth36 // was never requested, skip37 perms := s.Permissions().Permissions38 if perms == nil {39 wish.Fatalln(s, ErrPermissionDenied)40 return41 }4243 // Check if the key is the same as the one we have in context44 fp := perms.Extensions["pubkey-fp"]45 if fp == "" || fp != gossh.FingerprintSHA256(pk) {46 wish.Fatalln(s, ErrPermissionDenied)47 return48 }49 }5051 sh(s)52 }53}5455// ContextMiddleware adds the config, backend, and logger to the session context.56func ContextMiddleware(cfg *config.Config, dbx *db.DB, datastore store.Store, be *backend.Backend, logger *log.Logger) func(ssh.Handler) ssh.Handler {57 return func(sh ssh.Handler) ssh.Handler {58 return func(s ssh.Session) {59 ctx := s.Context()60 ctx.SetValue(sshutils.ContextKeySession, s)61 ctx.SetValue(config.ContextKey, cfg)62 ctx.SetValue(db.ContextKey, dbx)63 ctx.SetValue(store.ContextKey, datastore)64 ctx.SetValue(backend.ContextKey, be)65 ctx.SetValue(log.ContextKey, logger.WithPrefix("ssh"))66 sh(s)67 }68 }69}7071var cliCommandCounter = promauto.NewCounterVec(prometheus.CounterOpts{72 Namespace: "soft_serve",73 Subsystem: "cli",74 Name: "commands_total",75 Help: "Total times each command was called",76}, []string{"command"})7778// CommandMiddleware handles git commands and CLI commands.79// This middleware must be run after the ContextMiddleware.80func CommandMiddleware(sh ssh.Handler) ssh.Handler {81 return func(s ssh.Session) {82 ctx := s.Context()83 cfg := config.FromContext(ctx)8485 args := s.Command()86 cliCommandCounter.WithLabelValues(cmd.CommandName(args)).Inc()87 rootCmd := &cobra.Command{88 Short: "Soft Serve is a self-hostable Git server for the command line.",89 SilenceUsage: true,90 }91 rootCmd.CompletionOptions.DisableDefaultCmd = true9293 rootCmd.SetUsageTemplate(cmd.UsageTemplate)94 rootCmd.SetUsageFunc(cmd.UsageFunc)95 rootCmd.AddCommand(96 cmd.GitUploadPackCommand(),97 cmd.GitUploadArchiveCommand(),98 cmd.GitReceivePackCommand(),99 cmd.RepoCommand(),100 cmd.SettingsCommand(),101 cmd.UserCommand(),102 cmd.InfoCommand(),103 cmd.PubkeyCommand(),104 cmd.SetUsernameCommand(),105 cmd.JWTCommand(),106 cmd.TokenCommand(),107 )108109 if cfg.LFS.Enabled {110 rootCmd.AddCommand(111 cmd.GitLFSAuthenticateCommand(),112 )113114 if cfg.LFS.SSHEnabled {115 rootCmd.AddCommand(116 cmd.GitLFSTransfer(),117 )118 }119 }120121 rootCmd.SetArgs(args)122 if len(args) == 0 {123 // otherwise it'll default to os.Args, which is not what we want.124 rootCmd.SetArgs([]string{"--help"})125 }126 rootCmd.SetIn(s)127 rootCmd.SetOut(s)128 rootCmd.SetErr(s.Stderr())129 rootCmd.SetContext(ctx)130131 if err := rootCmd.ExecuteContext(ctx); err != nil {132 s.Exit(1) // nolint: errcheck133 return134 }135 }136}137138// LoggingMiddleware logs the ssh connection and command.139func LoggingMiddleware(sh ssh.Handler) ssh.Handler {140 return func(s ssh.Session) {141 ctx := s.Context()142 logger := log.FromContext(ctx).WithPrefix("ssh")143 ct := time.Now()144 hpk := sshutils.MarshalAuthorizedKey(s.PublicKey())145 ptyReq, _, isPty := s.Pty()146 addr := s.RemoteAddr().String()147 user := proto.UserFromContext(ctx)148 logArgs := []interface{}{149 "addr",150 addr,151 "cmd",152 s.Command(),153 }154155 if user != nil {156 logArgs = append([]interface{}{157 "username",158 user.Username(),159 }, logArgs...)160 }161162 if isPty {163 logArgs = []interface{}{164 "term", ptyReq.Term,165 "width", ptyReq.Window.Width,166 "height", ptyReq.Window.Height,167 }168 }169170 if config.IsVerbose() {171 logArgs = append(logArgs,172 "key", hpk,173 "envs", s.Environ(),174 )175 }176177 msg := fmt.Sprintf("user %q", s.User())178 logger.Debug(msg+" connected", logArgs...)179 sh(s)180 logger.Debug(msg+" disconnected", append(logArgs, "duration", time.Since(ct))...)181 }182}