soft-serve

Fork https://github.com/charmbracelet/soft-serve

git clone git://git.lin.moe/go/soft-serve.git

  1package ssh
  2
  3import (
  4	"fmt"
  5	"time"
  6
  7	"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)
 22
 23// ErrPermissionDenied is returned when a user is not allowed connect.
 24var ErrPermissionDenied = fmt.Errorf("permission denied")
 25
 26// 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't
 30		// validate the authentication. We need to verify that the _last_ key
 31		// that was approved is the one that's being used.
 32
 33		pk := s.PublicKey()
 34		if pk != nil {
 35			// There is no public key stored in the context, public-key auth
 36			// was never requested, skip
 37			perms := s.Permissions().Permissions
 38			if perms == nil {
 39				wish.Fatalln(s, ErrPermissionDenied)
 40				return
 41			}
 42
 43			// Check if the key is the same as the one we have in context
 44			fp := perms.Extensions["pubkey-fp"]
 45			if fp == "" || fp != gossh.FingerprintSHA256(pk) {
 46				wish.Fatalln(s, ErrPermissionDenied)
 47				return
 48			}
 49		}
 50
 51		sh(s)
 52	}
 53}
 54
 55// 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}
 70
 71var 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"})
 77
 78// 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)
 84
 85		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 = true
 92
 93		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		)
108
109		if cfg.LFS.Enabled {
110			rootCmd.AddCommand(
111				cmd.GitLFSAuthenticateCommand(),
112			)
113
114			if cfg.LFS.SSHEnabled {
115				rootCmd.AddCommand(
116					cmd.GitLFSTransfer(),
117				)
118			}
119		}
120
121		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)
130
131		if err := rootCmd.ExecuteContext(ctx); err != nil {
132			s.Exit(1) // nolint: errcheck
133			return
134		}
135	}
136}
137
138// 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		}
154
155		if user != nil {
156			logArgs = append([]interface{}{
157				"username",
158				user.Username(),
159			}, logArgs...)
160		}
161
162		if isPty {
163			logArgs = []interface{}{
164				"term", ptyReq.Term,
165				"width", ptyReq.Window.Width,
166				"height", ptyReq.Window.Height,
167			}
168		}
169
170		if config.IsVerbose() {
171			logArgs = append(logArgs,
172				"key", hpk,
173				"envs", s.Environ(),
174			)
175		}
176
177		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}