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 != 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			s.Context().SetValue(sshutils.ContextKeySession, s)
 63			s.Context().SetValue(config.ContextKey, cfg)
 64			s.Context().SetValue(db.ContextKey, dbx)
 65			s.Context().SetValue(store.ContextKey, datastore)
 66			s.Context().SetValue(backend.ContextKey, be)
 67			s.Context().SetValue(log.ContextKey, logger.WithPrefix("ssh"))
 68			sh(s)
 69		}
 70	}
 71}
 72
 73var cliCommandCounter = promauto.NewCounterVec(prometheus.CounterOpts{
 74	Namespace: "soft_serve",
 75	Subsystem: "cli",
 76	Name:      "commands_total",
 77	Help:      "Total times each command was called",
 78}, []string{"command"})
 79
 80// CommandMiddleware handles git commands and CLI commands.
 81// This middleware must be run after the ContextMiddleware.
 82func CommandMiddleware(sh ssh.Handler) ssh.Handler {
 83	return func(s ssh.Session) {
 84		_, _, ptyReq := s.Pty()
 85		if ptyReq {
 86			sh(s)
 87			return
 88		}
 89
 90		ctx := s.Context()
 91		cfg := config.FromContext(ctx)
 92
 93		renderer := bm.MakeRenderer(s)
 94		if testrun, ok := os.LookupEnv("SOFT_SERVE_NO_COLOR"); ok && testrun == "1" {
 95			// Disable colors when running tests.
 96			renderer.SetColorProfile(termenv.Ascii)
 97		}
 98
 99		args := s.Command()
100		cliCommandCounter.WithLabelValues(cmd.CommandName(args)).Inc()
101		rootCmd := &cobra.Command{
102			Short:        "Soft Serve is a self-hostable Git server for the command line.",
103			SilenceUsage: true,
104		}
105		rootCmd.CompletionOptions.DisableDefaultCmd = true
106
107		rootCmd.SetUsageTemplate(cmd.UsageTemplate)
108		rootCmd.SetUsageFunc(cmd.UsageFunc)
109		rootCmd.AddCommand(
110			cmd.GitUploadPackCommand(),
111			cmd.GitUploadArchiveCommand(),
112			cmd.GitReceivePackCommand(),
113			cmd.RepoCommand(renderer),
114			cmd.SettingsCommand(),
115			cmd.UserCommand(),
116			cmd.InfoCommand(),
117			cmd.PubkeyCommand(),
118			cmd.SetUsernameCommand(),
119			cmd.JWTCommand(),
120			cmd.TokenCommand(),
121		)
122
123		if cfg.LFS.Enabled {
124			rootCmd.AddCommand(
125				cmd.GitLFSAuthenticateCommand(),
126			)
127
128			if cfg.LFS.SSHEnabled {
129				rootCmd.AddCommand(
130					cmd.GitLFSTransfer(),
131				)
132			}
133		}
134
135		rootCmd.SetArgs(args)
136		if len(args) == 0 {
137			// otherwise it'll default to os.Args, which is not what we want.
138			rootCmd.SetArgs([]string{"--help"})
139		}
140		rootCmd.SetIn(s)
141		rootCmd.SetOut(s)
142		rootCmd.SetErr(s.Stderr())
143		rootCmd.SetContext(ctx)
144
145		if err := rootCmd.ExecuteContext(ctx); err != nil {
146			s.Exit(1) // nolint: errcheck
147			return
148		}
149	}
150}
151
152// LoggingMiddleware logs the ssh connection and command.
153func LoggingMiddleware(sh ssh.Handler) ssh.Handler {
154	return func(s ssh.Session) {
155		ctx := s.Context()
156		logger := log.FromContext(ctx).WithPrefix("ssh")
157		ct := time.Now()
158		hpk := sshutils.MarshalAuthorizedKey(s.PublicKey())
159		ptyReq, _, isPty := s.Pty()
160		addr := s.RemoteAddr().String()
161		user := proto.UserFromContext(ctx)
162		logArgs := []interface{}{
163			"addr",
164			addr,
165			"cmd",
166			s.Command(),
167		}
168
169		if user != nil {
170			logArgs = append([]interface{}{
171				"username",
172				user.Username(),
173			}, logArgs...)
174		}
175
176		if isPty {
177			logArgs = []interface{}{
178				"term", ptyReq.Term,
179				"width", ptyReq.Window.Width,
180				"height", ptyReq.Window.Height,
181			}
182		}
183
184		if config.IsVerbose() {
185			logArgs = append(logArgs,
186				"key", hpk,
187				"envs", s.Environ(),
188			)
189		}
190
191		msg := fmt.Sprintf("user %q", s.User())
192		logger.Debug(msg+" connected", logArgs...)
193		sh(s)
194		logger.Debug(msg+" disconnected", append(logArgs, "duration", time.Since(ct))...)
195	}
196}