refactor,fix(ssh): use cobra for git commands

Ayman Bagabas created

- Fix git commands errors on invalid args and permissions
- Use Cobra to handle git commands
- Add Git SSH tests
- Better ssh and git pktline error handling

Change summary

server/ssh/cmd.go              |  17 -
server/ssh/cmd/cmd.go          | 116 +++--------
server/ssh/cmd/git.go          | 333 ++++++++++++++++++++++++++++++++++++
server/ssh/cmd/info.go         |   5 
server/ssh/cmd/jwt.go          |   3 
server/ssh/cmd/list.go         |   2 
server/ssh/cmd/pubkey.go       |   5 
server/ssh/cmd/repo.go         |   3 
server/ssh/cmd/set_username.go |   3 
server/ssh/cmd/settings.go     |   3 
server/ssh/cmd/token.go        |   3 
server/ssh/cmd/user.go         |   3 
server/ssh/git.go              | 165 -----------------
server/ssh/logger.go           |  25 --
server/ssh/middleware.go       |  88 ++++++++
server/ssh/ssh.go              |  56 ------
server/sshutils/utils.go       |  11 +
server/web/auth.go             |   2 
server/web/git.go              |   3 
testscript/testdata/help.txtar |  18 
testscript/testdata/ssh.txtar  |  99 ++++++++++
21 files changed, 586 insertions(+), 377 deletions(-)

Detailed changes

server/ssh/cmd.go 🔗

@@ -1,17 +0,0 @@
-package ssh
-
-import (
-	"github.com/charmbracelet/log"
-	"github.com/charmbracelet/soft-serve/server/ssh/cmd"
-	"github.com/charmbracelet/ssh"
-)
-
-func handleCli(s ssh.Session) {
-	ctx := s.Context()
-	logger := log.FromContext(ctx)
-	rootCmd := cmd.RootCommand(s)
-	if err := rootCmd.ExecuteContext(ctx); err != nil {
-		logger.Error("error executing command", "err", err)
-		_ = s.Exit(1)
-	}
-}

server/ssh/cmd/cmd.go 🔗

@@ -14,18 +14,9 @@ import (
 	"github.com/charmbracelet/soft-serve/server/sshutils"
 	"github.com/charmbracelet/soft-serve/server/utils"
 	"github.com/charmbracelet/ssh"
-	"github.com/prometheus/client_golang/prometheus"
-	"github.com/prometheus/client_golang/prometheus/promauto"
 	"github.com/spf13/cobra"
 )
 
-var cliCommandCounter = promauto.NewCounterVec(prometheus.CounterOpts{
-	Namespace: "soft_serve",
-	Subsystem: "cli",
-	Name:      "commands_total",
-	Help:      "Total times each command was called",
-}, []string{"command"})
-
 var templateFuncs = template.FuncMap{
 	"trim":                    strings.TrimSpace,
 	"trimRightSpace":          trimRightSpace,
@@ -36,7 +27,8 @@ var templateFuncs = template.FuncMap{
 }
 
 const (
-	usageTmpl = `Usage:{{if .Runnable}}
+	// UsageTemplate is the template used for the help output.
+	UsageTemplate = `Usage:{{if .Runnable}}
   {{.UseLine}}{{end}}{{if .HasAvailableSubCommands}}
   {{.SSHCommand}}{{.CommandPath}} [command]{{end}}{{if gt (len .Aliases) 0}}
 
@@ -68,35 +60,11 @@ Use "{{.SSHCommand}}{{.CommandPath}} [command] --help" for more information abou
 `
 )
 
-func trimRightSpace(s string) string {
-	return strings.TrimRightFunc(s, unicode.IsSpace)
-}
-
-// rpad adds padding to the right of a string.
-func rpad(s string, padding int) string {
-	template := fmt.Sprintf("%%-%ds", padding)
-	return fmt.Sprintf(template, s)
-}
-
-func cmdName(args []string) string {
-	if len(args) == 0 {
-		return ""
-	}
-	return args[0]
-}
-
-// RootCommand returns a new cli root command.
-func RootCommand(s ssh.Session) *cobra.Command {
-	ctx := s.Context()
+// UsageFunc is a function that can be used as a cobra.Command's
+// UsageFunc to render the help output.
+func UsageFunc(c *cobra.Command) error {
+	ctx := c.Context()
 	cfg := config.FromContext(ctx)
-
-	args := s.Command()
-	cliCommandCounter.WithLabelValues(cmdName(args)).Inc()
-	rootCmd := &cobra.Command{
-		Short:        "Soft Serve is a self-hostable Git server for the command line.",
-		SilenceUsage: true,
-	}
-
 	hostname := "localhost"
 	port := "23231"
 	url, err := url.Parse(cfg.SSH.PublicURL)
@@ -111,54 +79,34 @@ func RootCommand(s ssh.Session) *cobra.Command {
 	}
 
 	sshCmd += " " + hostname
-	rootCmd.SetUsageTemplate(usageTmpl)
-	rootCmd.SetUsageFunc(func(c *cobra.Command) error {
-		t := template.New("usage")
-		t.Funcs(templateFuncs)
-		template.Must(t.Parse(c.UsageTemplate()))
-		return t.Execute(c.OutOrStderr(), struct {
-			*cobra.Command
-			SSHCommand string
-		}{
-			Command:    c,
-			SSHCommand: sshCmd,
-		})
+	t := template.New("usage")
+	t.Funcs(templateFuncs)
+	template.Must(t.Parse(c.UsageTemplate()))
+	return t.Execute(c.OutOrStderr(), struct {
+		*cobra.Command
+		SSHCommand string
+	}{
+		Command:    c,
+		SSHCommand: sshCmd,
 	})
-	rootCmd.CompletionOptions.DisableDefaultCmd = true
-	rootCmd.AddCommand(
-		repoCommand(),
-	)
+}
 
-	rootCmd.SetArgs(args)
-	if len(args) == 0 {
-		// otherwise it'll default to os.Args, which is not what we want.
-		rootCmd.SetArgs([]string{"--help"})
-	}
-	rootCmd.SetIn(s)
-	rootCmd.SetOut(s)
-	rootCmd.CompletionOptions.DisableDefaultCmd = true
-	rootCmd.SetErr(s.Stderr())
+func trimRightSpace(s string) string {
+	return strings.TrimRightFunc(s, unicode.IsSpace)
+}
 
-	user := proto.UserFromContext(ctx)
-	isAdmin := isPublicKeyAdmin(cfg, s.PublicKey()) || (user != nil && user.IsAdmin())
-	if user != nil || isAdmin {
-		if isAdmin {
-			rootCmd.AddCommand(
-				settingsCommand(),
-				userCommand(),
-			)
-		}
+// rpad adds padding to the right of a string.
+func rpad(s string, padding int) string {
+	template := fmt.Sprintf("%%-%ds", padding)
+	return fmt.Sprintf(template, s)
+}
 
-		rootCmd.AddCommand(
-			infoCommand(),
-			pubkeyCommand(),
-			setUsernameCommand(),
-			jwtCommand(),
-			tokenCommand(),
-		)
+// CommandName returns the name of the command from the args.
+func CommandName(args []string) string {
+	if len(args) == 0 {
+		return ""
 	}
-
-	return rootCmd
+	return args[0]
 }
 
 func checkIfReadable(cmd *cobra.Command, args []string) error {
@@ -178,7 +126,9 @@ func checkIfReadable(cmd *cobra.Command, args []string) error {
 	return nil
 }
 
-func isPublicKeyAdmin(cfg *config.Config, pk ssh.PublicKey) bool {
+// IsPublicKeyAdmin returns true if the given public key is an admin key from
+// the initial_admin_keys config or environment field.
+func IsPublicKeyAdmin(cfg *config.Config, pk ssh.PublicKey) bool {
 	for _, k := range cfg.AdminKeys() {
 		if sshutils.KeysEqual(pk, k) {
 			return true
@@ -191,7 +141,7 @@ func checkIfAdmin(cmd *cobra.Command, _ []string) error {
 	ctx := cmd.Context()
 	cfg := config.FromContext(ctx)
 	pk := sshutils.PublicKeyFromContext(ctx)
-	if isPublicKeyAdmin(cfg, pk) {
+	if IsPublicKeyAdmin(cfg, pk) {
 		return nil
 	}
 

server/ssh/cmd/git.go 🔗

@@ -0,0 +1,333 @@
+package cmd
+
+import (
+	"errors"
+	"path/filepath"
+	"time"
+
+	"github.com/charmbracelet/log"
+	"github.com/charmbracelet/soft-serve/server/access"
+	"github.com/charmbracelet/soft-serve/server/backend"
+	"github.com/charmbracelet/soft-serve/server/config"
+	"github.com/charmbracelet/soft-serve/server/git"
+	"github.com/charmbracelet/soft-serve/server/lfs"
+	"github.com/charmbracelet/soft-serve/server/proto"
+	"github.com/charmbracelet/soft-serve/server/sshutils"
+	"github.com/charmbracelet/soft-serve/server/utils"
+	"github.com/prometheus/client_golang/prometheus"
+	"github.com/prometheus/client_golang/prometheus/promauto"
+	"github.com/spf13/cobra"
+)
+
+var (
+	uploadPackCounter = promauto.NewCounterVec(prometheus.CounterOpts{
+		Namespace: "soft_serve",
+		Subsystem: "git",
+		Name:      "upload_pack_total",
+		Help:      "The total number of git-upload-pack requests",
+	}, []string{"repo"})
+
+	receivePackCounter = promauto.NewCounterVec(prometheus.CounterOpts{
+		Namespace: "soft_serve",
+		Subsystem: "git",
+		Name:      "receive_pack_total",
+		Help:      "The total number of git-receive-pack requests",
+	}, []string{"repo"})
+
+	uploadArchiveCounter = promauto.NewCounterVec(prometheus.CounterOpts{
+		Namespace: "soft_serve",
+		Subsystem: "git",
+		Name:      "upload_archive_total",
+		Help:      "The total number of git-upload-archive requests",
+	}, []string{"repo"})
+
+	lfsAuthenticateCounter = promauto.NewCounterVec(prometheus.CounterOpts{
+		Namespace: "soft_serve",
+		Subsystem: "git",
+		Name:      "lfs_authenticate_total",
+		Help:      "The total number of git-lfs-authenticate requests",
+	}, []string{"repo", "operation"})
+
+	lfsTransferCounter = promauto.NewCounterVec(prometheus.CounterOpts{
+		Namespace: "soft_serve",
+		Subsystem: "git",
+		Name:      "lfs_transfer_total",
+		Help:      "The total number of git-lfs-transfer requests",
+	}, []string{"repo", "operation"})
+
+	uploadPackSeconds = promauto.NewCounterVec(prometheus.CounterOpts{
+		Namespace: "soft_serve",
+		Subsystem: "git",
+		Name:      "upload_pack_seconds_total",
+		Help:      "The total time spent on git-upload-pack requests",
+	}, []string{"repo"})
+
+	receivePackSeconds = promauto.NewCounterVec(prometheus.CounterOpts{
+		Namespace: "soft_serve",
+		Subsystem: "git",
+		Name:      "receive_pack_seconds_total",
+		Help:      "The total time spent on git-receive-pack requests",
+	}, []string{"repo"})
+
+	uploadArchiveSeconds = promauto.NewCounterVec(prometheus.CounterOpts{
+		Namespace: "soft_serve",
+		Subsystem: "git",
+		Name:      "upload_archive_seconds_total",
+		Help:      "The total time spent on git-upload-archive requests",
+	}, []string{"repo", "operation"})
+
+	lfsAuthenticateSeconds = promauto.NewCounterVec(prometheus.CounterOpts{
+		Namespace: "soft_serve",
+		Subsystem: "git",
+		Name:      "lfs_authenticate_seconds_total",
+		Help:      "The total time spent on git-lfs-authenticate requests",
+	}, []string{"repo", "operation"})
+
+	lfsTransferSeconds = promauto.NewCounterVec(prometheus.CounterOpts{
+		Namespace: "soft_serve",
+		Subsystem: "git",
+		Name:      "lfs_transfer_seconds_total",
+		Help:      "The total time spent on git-lfs-transfer requests",
+	}, []string{"repo"})
+
+	createRepoCounter = promauto.NewCounterVec(prometheus.CounterOpts{
+		Namespace: "soft_serve",
+		Subsystem: "ssh",
+		Name:      "create_repo_total",
+		Help:      "The total number of create repo requests",
+	}, []string{"repo"})
+)
+
+// GitUploadPackCommand returns a cobra command for git-upload-pack.
+func GitUploadPackCommand() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:    "git-upload-pack REPO",
+		Short:  "Git upload pack",
+		Args:   cobra.ExactArgs(1),
+		Hidden: true,
+		RunE:   gitRunE,
+	}
+
+	return cmd
+}
+
+// GitUploadArchiveCommand returns a cobra command for git-upload-archive.
+func GitUploadArchiveCommand() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:    "git-upload-archive REPO",
+		Short:  "Git upload archive",
+		Args:   cobra.ExactArgs(1),
+		Hidden: true,
+		RunE:   gitRunE,
+	}
+
+	return cmd
+}
+
+// GitReceivePackCommand returns a cobra command for git-receive-pack.
+func GitReceivePackCommand() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:    "git-receive-pack REPO",
+		Short:  "Git receive pack",
+		Args:   cobra.ExactArgs(1),
+		Hidden: true,
+		RunE:   gitRunE,
+	}
+
+	return cmd
+}
+
+// GitLFSAuthenticateCommand returns a cobra command for git-lfs-authenticate.
+func GitLFSAuthenticateCommand() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:    "git-lfs-authenticate REPO OPERATION",
+		Short:  "Git LFS authenticate",
+		Args:   cobra.ExactArgs(2),
+		Hidden: true,
+		RunE:   gitRunE,
+	}
+
+	return cmd
+}
+
+// GitLFSTransfer returns a cobra command for git-lfs-transfer.
+func GitLFSTransfer() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:    "git-lfs-transfer REPO OPERATION",
+		Short:  "Git LFS transfer",
+		Args:   cobra.ExactArgs(2),
+		Hidden: true,
+		RunE:   gitRunE,
+	}
+
+	return cmd
+}
+
+func gitRunE(cmd *cobra.Command, args []string) error {
+	ctx := cmd.Context()
+	cfg := config.FromContext(ctx)
+	be := backend.FromContext(ctx)
+	logger := log.FromContext(ctx)
+	start := time.Now()
+
+	// repo should be in the form of "repo.git"
+	name := utils.SanitizeRepo(args[0])
+	pk := sshutils.PublicKeyFromContext(ctx)
+	ak := sshutils.MarshalAuthorizedKey(pk)
+	user := proto.UserFromContext(ctx)
+	accessLevel := be.AccessLevelForUser(ctx, name, user)
+	// git bare repositories should end in ".git"
+	// https://git-scm.com/docs/gitrepository-layout
+	repoDir := name + ".git"
+	reposDir := filepath.Join(cfg.DataPath, "repos")
+	if err := git.EnsureWithin(reposDir, repoDir); err != nil {
+		return err
+	}
+
+	// Set repo in context
+	repo, _ := be.Repository(ctx, name)
+	ctx = proto.WithRepositoryContext(ctx, repo)
+
+	// Environment variables to pass down to git hooks.
+	envs := []string{
+		"SOFT_SERVE_REPO_NAME=" + name,
+		"SOFT_SERVE_REPO_PATH=" + filepath.Join(reposDir, repoDir),
+		"SOFT_SERVE_PUBLIC_KEY=" + ak,
+		"SOFT_SERVE_LOG_PATH=" + filepath.Join(cfg.DataPath, "log", "hooks.log"),
+	}
+
+	if user != nil {
+		envs = append(envs,
+			"SOFT_SERVE_USERNAME="+user.Username(),
+		)
+	}
+
+	// Add ssh session & config environ
+	s := sshutils.SessionFromContext(ctx)
+	envs = append(envs, s.Environ()...)
+	envs = append(envs, cfg.Environ()...)
+
+	repoPath := filepath.Join(reposDir, repoDir)
+	service := git.Service(cmd.Name())
+	scmd := git.ServiceCommand{
+		Stdin:  cmd.InOrStdin(),
+		Stdout: s,
+		Stderr: s.Stderr(),
+		Env:    envs,
+		Dir:    repoPath,
+	}
+
+	logger.Debug("git middleware", "cmd", service, "access", accessLevel.String())
+
+	switch service {
+	case git.ReceivePackService:
+		receivePackCounter.WithLabelValues(name).Inc()
+		defer func() {
+			receivePackSeconds.WithLabelValues(name).Add(time.Since(start).Seconds())
+		}()
+		if accessLevel < access.ReadWriteAccess {
+			return git.ErrNotAuthed
+		}
+		if repo == nil {
+			if _, err := be.CreateRepository(ctx, name, user, proto.RepositoryOptions{Private: false}); err != nil {
+				log.Errorf("failed to create repo: %s", err)
+				return err
+			}
+			createRepoCounter.WithLabelValues(name).Inc()
+		}
+
+		if err := service.Handler(ctx, scmd); err != nil {
+			defer func() {
+				if repo == nil {
+					// If the repo was created, but the request failed, delete it.
+					be.DeleteRepository(ctx, name) // nolint: errcheck
+				}
+			}()
+			return git.ErrSystemMalfunction
+		}
+
+		if err := git.EnsureDefaultBranch(ctx, scmd); err != nil {
+			return git.ErrSystemMalfunction
+		}
+
+		receivePackCounter.WithLabelValues(name).Inc()
+
+		return nil
+	case git.UploadPackService, git.UploadArchiveService:
+		if accessLevel < access.ReadOnlyAccess {
+			return git.ErrNotAuthed
+		}
+
+		if repo == nil {
+			return git.ErrInvalidRepo
+		}
+
+		switch service {
+		case git.UploadArchiveService:
+			uploadArchiveCounter.WithLabelValues(name).Inc()
+			defer func() {
+				uploadArchiveSeconds.WithLabelValues(name).Add(time.Since(start).Seconds())
+			}()
+		default:
+			uploadPackCounter.WithLabelValues(name).Inc()
+			defer func() {
+				uploadPackSeconds.WithLabelValues(name).Add(time.Since(start).Seconds())
+			}()
+		}
+
+		err := service.Handler(ctx, scmd)
+		if errors.Is(err, git.ErrInvalidRepo) {
+			return git.ErrInvalidRepo
+		} else if err != nil {
+			logger.Error("git middleware", "err", err)
+			return git.ErrSystemMalfunction
+		}
+
+		return nil
+	case git.LFSTransferService, git.LFSAuthenticateService:
+		operation := args[1]
+		switch operation {
+		case lfs.OperationDownload:
+			if accessLevel < access.ReadOnlyAccess {
+				return git.ErrNotAuthed
+			}
+		case lfs.OperationUpload:
+			if accessLevel < access.ReadWriteAccess {
+				return git.ErrNotAuthed
+			}
+		default:
+			return git.ErrInvalidRequest
+		}
+
+		if repo == nil {
+			return git.ErrInvalidRepo
+		}
+
+		scmd.Args = []string{
+			name,
+			args[1],
+		}
+
+		switch service {
+		case git.LFSTransferService:
+			lfsTransferCounter.WithLabelValues(name, operation).Inc()
+			defer func() {
+				lfsTransferSeconds.WithLabelValues(name, operation).Add(time.Since(start).Seconds())
+			}()
+		default:
+			lfsAuthenticateCounter.WithLabelValues(name, operation).Inc()
+			defer func() {
+				lfsAuthenticateSeconds.WithLabelValues(name, operation).Add(time.Since(start).Seconds())
+			}()
+		}
+
+		if err := service.Handler(ctx, scmd); err != nil {
+			logger.Error("git middleware", "err", err)
+			return git.ErrSystemMalfunction
+		}
+
+		return nil
+	}
+
+	return errors.New("unsupported git service")
+}

server/ssh/cmd/info.go 🔗

@@ -6,12 +6,13 @@ import (
 	"github.com/spf13/cobra"
 )
 
-func infoCommand() *cobra.Command {
+// InfoCommand returns a command that shows the user's info
+func InfoCommand() *cobra.Command {
 	cmd := &cobra.Command{
 		Use:   "info",
 		Short: "Show your info",
 		Args:  cobra.NoArgs,
-		RunE: func(cmd *cobra.Command, args []string) error {
+		RunE: func(cmd *cobra.Command, _ []string) error {
 			ctx := cmd.Context()
 			be := backend.FromContext(ctx)
 			pk := sshutils.PublicKeyFromContext(ctx)

server/ssh/cmd/jwt.go 🔗

@@ -11,7 +11,8 @@ import (
 	"github.com/spf13/cobra"
 )
 
-func jwtCommand() *cobra.Command {
+// JWTCommand returns a command that generates a JSON Web Token.
+func JWTCommand() *cobra.Command {
 	cmd := &cobra.Command{
 		Use:   "jwt [repository1 repository2...]",
 		Short: "Generate a JSON Web Token",

server/ssh/cmd/list.go 🔗

@@ -16,7 +16,7 @@ func listCommand() *cobra.Command {
 		Aliases: []string{"ls"},
 		Short:   "List repositories",
 		Args:    cobra.NoArgs,
-		RunE: func(cmd *cobra.Command, args []string) error {
+		RunE: func(cmd *cobra.Command, _ []string) error {
 			ctx := cmd.Context()
 			be := backend.FromContext(ctx)
 			pk := sshutils.PublicKeyFromContext(ctx)

server/ssh/cmd/pubkey.go 🔗

@@ -8,7 +8,8 @@ import (
 	"github.com/spf13/cobra"
 )
 
-func pubkeyCommand() *cobra.Command {
+// PubkeyCommand returns a command that manages user public keys.
+func PubkeyCommand() *cobra.Command {
 	cmd := &cobra.Command{
 		Use:     "pubkey",
 		Aliases: []string{"pubkeys", "publickey", "publickeys"},
@@ -64,7 +65,7 @@ func pubkeyCommand() *cobra.Command {
 		Aliases: []string{"ls"},
 		Short:   "List public keys",
 		Args:    cobra.NoArgs,
-		RunE: func(cmd *cobra.Command, args []string) error {
+		RunE: func(cmd *cobra.Command, _ []string) error {
 			ctx := cmd.Context()
 			be := backend.FromContext(ctx)
 			pk := sshutils.PublicKeyFromContext(ctx)

server/ssh/cmd/repo.go 🔗

@@ -9,7 +9,8 @@ import (
 	"github.com/spf13/cobra"
 )
 
-func repoCommand() *cobra.Command {
+// RepoCommand returns a command for managing repositories.
+func RepoCommand() *cobra.Command {
 	cmd := &cobra.Command{
 		Use:     "repo",
 		Aliases: []string{"repos", "repository", "repositories"},

server/ssh/cmd/set_username.go 🔗

@@ -6,7 +6,8 @@ import (
 	"github.com/spf13/cobra"
 )
 
-func setUsernameCommand() *cobra.Command {
+// SetUsernameCommand returns a command that sets the user's username.
+func SetUsernameCommand() *cobra.Command {
 	cmd := &cobra.Command{
 		Use:   "set-username USERNAME",
 		Short: "Set your username",

server/ssh/cmd/settings.go 🔗

@@ -9,7 +9,8 @@ import (
 	"github.com/spf13/cobra"
 )
 
-func settingsCommand() *cobra.Command {
+// SettingsCommand returns a command that manages server settings.
+func SettingsCommand() *cobra.Command {
 	cmd := &cobra.Command{
 		Use:   "settings",
 		Short: "Manage server settings",

server/ssh/cmd/token.go 🔗

@@ -13,7 +13,8 @@ import (
 	"github.com/spf13/cobra"
 )
 
-func tokenCommand() *cobra.Command {
+// TokenCommand returns a command that manages user access tokens.
+func TokenCommand() *cobra.Command {
 	cmd := &cobra.Command{
 		Use:     "token",
 		Aliases: []string{"access-token"},

server/ssh/cmd/user.go 🔗

@@ -11,7 +11,8 @@ import (
 	"golang.org/x/crypto/ssh"
 )
 
-func userCommand() *cobra.Command {
+// UserCommand returns the user subcommand.
+func UserCommand() *cobra.Command {
 	cmd := &cobra.Command{
 		Use:     "user",
 		Aliases: []string{"users"},

server/ssh/git.go 🔗

@@ -1,165 +0,0 @@
-package ssh
-
-import (
-	"errors"
-	"path/filepath"
-	"time"
-
-	"github.com/charmbracelet/log"
-	"github.com/charmbracelet/soft-serve/server/access"
-	"github.com/charmbracelet/soft-serve/server/backend"
-	"github.com/charmbracelet/soft-serve/server/config"
-	"github.com/charmbracelet/soft-serve/server/git"
-	"github.com/charmbracelet/soft-serve/server/lfs"
-	"github.com/charmbracelet/soft-serve/server/proto"
-	"github.com/charmbracelet/soft-serve/server/sshutils"
-	"github.com/charmbracelet/soft-serve/server/utils"
-	"github.com/charmbracelet/ssh"
-)
-
-func handleGit(s ssh.Session) {
-	ctx := s.Context()
-	cfg := config.FromContext(ctx)
-	be := backend.FromContext(ctx)
-	logger := log.FromContext(ctx)
-	cmdLine := s.Command()
-	start := time.Now()
-
-	// repo should be in the form of "repo.git"
-	name := utils.SanitizeRepo(cmdLine[1])
-	pk := s.PublicKey()
-	ak := sshutils.MarshalAuthorizedKey(pk)
-	user := proto.UserFromContext(ctx)
-	accessLevel := be.AccessLevelForUser(ctx, name, user)
-	// git bare repositories should end in ".git"
-	// https://git-scm.com/docs/gitrepository-layout
-	repoDir := name + ".git"
-	reposDir := filepath.Join(cfg.DataPath, "repos")
-	if err := git.EnsureWithin(reposDir, repoDir); err != nil {
-		sshFatal(s, err)
-		return
-	}
-
-	// Set repo in context
-	repo, _ := be.Repository(ctx, name)
-	ctx.SetValue(proto.ContextKeyRepository, repo)
-
-	// Environment variables to pass down to git hooks.
-	envs := []string{
-		"SOFT_SERVE_REPO_NAME=" + name,
-		"SOFT_SERVE_REPO_PATH=" + filepath.Join(reposDir, repoDir),
-		"SOFT_SERVE_PUBLIC_KEY=" + ak,
-		"SOFT_SERVE_LOG_PATH=" + filepath.Join(cfg.DataPath, "log", "hooks.log"),
-	}
-
-	if user != nil {
-		envs = append(envs,
-			"SOFT_SERVE_USERNAME="+user.Username(),
-		)
-	}
-
-	// Add ssh session & config environ
-	envs = append(envs, s.Environ()...)
-	envs = append(envs, cfg.Environ()...)
-
-	repoPath := filepath.Join(reposDir, repoDir)
-	service := git.Service(cmdLine[0])
-	cmd := git.ServiceCommand{
-		Stdin:  s,
-		Stdout: s,
-		Stderr: s.Stderr(),
-		Env:    envs,
-		Dir:    repoPath,
-	}
-
-	logger.Debug("git middleware", "cmd", service, "access", accessLevel.String())
-
-	switch service {
-	case git.ReceivePackService:
-		receivePackCounter.WithLabelValues(name).Inc()
-		defer func() {
-			receivePackSeconds.WithLabelValues(name).Add(time.Since(start).Seconds())
-		}()
-		if accessLevel < access.ReadWriteAccess {
-			sshFatal(s, git.ErrNotAuthed)
-			return
-		}
-		if repo == nil {
-			if _, err := be.CreateRepository(ctx, name, user, proto.RepositoryOptions{Private: false}); err != nil {
-				log.Errorf("failed to create repo: %s", err)
-				sshFatal(s, err)
-				return
-			}
-			createRepoCounter.WithLabelValues(name).Inc()
-		}
-
-		if err := service.Handler(ctx, cmd); err != nil {
-			sshFatal(s, git.ErrSystemMalfunction)
-		}
-
-		if err := git.EnsureDefaultBranch(ctx, cmd); err != nil {
-			sshFatal(s, git.ErrSystemMalfunction)
-		}
-
-		receivePackCounter.WithLabelValues(name).Inc()
-		return
-	case git.UploadPackService, git.UploadArchiveService:
-		if accessLevel < access.ReadOnlyAccess {
-			sshFatal(s, git.ErrNotAuthed)
-			return
-		}
-
-		switch service {
-		case git.UploadArchiveService:
-			uploadArchiveCounter.WithLabelValues(name).Inc()
-			defer func() {
-				uploadArchiveSeconds.WithLabelValues(name).Add(time.Since(start).Seconds())
-			}()
-		default:
-			uploadPackCounter.WithLabelValues(name).Inc()
-			defer func() {
-				uploadPackSeconds.WithLabelValues(name).Add(time.Since(start).Seconds())
-			}()
-		}
-
-		err := service.Handler(ctx, cmd)
-		if errors.Is(err, git.ErrInvalidRepo) {
-			sshFatal(s, git.ErrInvalidRepo)
-		} else if err != nil {
-			logger.Error("git middleware", "err", err)
-			sshFatal(s, git.ErrSystemMalfunction)
-		}
-
-		return
-	case git.LFSTransferService, git.LFSAuthenticateService:
-		if !cfg.LFS.Enabled {
-			return
-		}
-
-		if service == git.LFSTransferService && !cfg.LFS.SSHEnabled {
-			return
-		}
-
-		if accessLevel < access.ReadWriteAccess {
-			sshFatal(s, git.ErrNotAuthed)
-			return
-		}
-
-		if len(cmdLine) != 3 ||
-			(cmdLine[2] != lfs.OperationDownload && cmdLine[2] != lfs.OperationUpload) {
-			sshFatal(s, git.ErrInvalidRequest)
-			return
-		}
-
-		cmd.Args = []string{
-			name,
-			cmdLine[2],
-		}
-
-		if err := service.Handler(ctx, cmd); err != nil {
-			logger.Error("git middleware", "err", err)
-			sshFatal(s, git.ErrSystemMalfunction)
-			return
-		}
-	}
-}

server/ssh/logger.go 🔗

@@ -1,25 +0,0 @@
-package ssh
-
-import "github.com/charmbracelet/log"
-
-type loggerAdapter struct {
-	*log.Logger
-	log.Level
-}
-
-func (l *loggerAdapter) Printf(format string, args ...interface{}) {
-	switch l.Level {
-	case log.DebugLevel:
-		l.Logger.Debugf(format, args...)
-	case log.InfoLevel:
-		l.Logger.Infof(format, args...)
-	case log.WarnLevel:
-		l.Logger.Warnf(format, args...)
-	case log.ErrorLevel:
-		l.Logger.Errorf(format, args...)
-	case log.FatalLevel:
-		l.Logger.Fatalf(format, args...)
-	default:
-		l.Logger.Printf(format, args...)
-	}
-}

server/ssh/middleware.go 🔗

@@ -1,20 +1,25 @@
 package ssh
 
 import (
-	"strings"
-
 	"github.com/charmbracelet/log"
 	"github.com/charmbracelet/soft-serve/server/backend"
 	"github.com/charmbracelet/soft-serve/server/config"
 	"github.com/charmbracelet/soft-serve/server/db"
+	"github.com/charmbracelet/soft-serve/server/proto"
+	"github.com/charmbracelet/soft-serve/server/ssh/cmd"
+	"github.com/charmbracelet/soft-serve/server/sshutils"
 	"github.com/charmbracelet/soft-serve/server/store"
 	"github.com/charmbracelet/ssh"
+	"github.com/prometheus/client_golang/prometheus"
+	"github.com/prometheus/client_golang/prometheus/promauto"
+	"github.com/spf13/cobra"
 )
 
 // ContextMiddleware adds the config, backend, and logger to the session context.
 func ContextMiddleware(cfg *config.Config, dbx *db.DB, datastore store.Store, be *backend.Backend, logger *log.Logger) func(ssh.Handler) ssh.Handler {
 	return func(sh ssh.Handler) ssh.Handler {
 		return func(s ssh.Session) {
+			s.Context().SetValue(sshutils.ContextKeySession, s)
 			s.Context().SetValue(config.ContextKey, cfg)
 			s.Context().SetValue(db.ContextKey, dbx)
 			s.Context().SetValue(store.ContextKey, datastore)
@@ -25,22 +30,89 @@ func ContextMiddleware(cfg *config.Config, dbx *db.DB, datastore store.Store, be
 	}
 }
 
+var cliCommandCounter = promauto.NewCounterVec(prometheus.CounterOpts{
+	Namespace: "soft_serve",
+	Subsystem: "cli",
+	Name:      "commands_total",
+	Help:      "Total times each command was called",
+}, []string{"command"})
+
 // CommandMiddleware handles git commands and CLI commands.
 // This middleware must be run after the ContextMiddleware.
 func CommandMiddleware(sh ssh.Handler) ssh.Handler {
 	return func(s ssh.Session) {
 		func() {
-			cmdLine := s.Command()
 			_, _, ptyReq := s.Pty()
 			if ptyReq {
 				return
 			}
 
-			switch {
-			case len(cmdLine) >= 2 && strings.HasPrefix(cmdLine[0], "git-"):
-				handleGit(s)
-			default:
-				handleCli(s)
+			ctx := s.Context()
+			cfg := config.FromContext(ctx)
+			logger := log.FromContext(ctx)
+
+			args := s.Command()
+			cliCommandCounter.WithLabelValues(cmd.CommandName(args)).Inc()
+			rootCmd := &cobra.Command{
+				Short:        "Soft Serve is a self-hostable Git server for the command line.",
+				SilenceUsage: true,
+			}
+			rootCmd.CompletionOptions.DisableDefaultCmd = true
+
+			rootCmd.SetUsageTemplate(cmd.UsageTemplate)
+			rootCmd.SetUsageFunc(cmd.UsageFunc)
+			rootCmd.AddCommand(
+				cmd.GitUploadPackCommand(),
+				cmd.GitUploadArchiveCommand(),
+				cmd.GitReceivePackCommand(),
+				cmd.RepoCommand(),
+			)
+
+			if cfg.LFS.Enabled {
+				rootCmd.AddCommand(
+					cmd.GitLFSAuthenticateCommand(),
+				)
+
+				if cfg.LFS.SSHEnabled {
+					rootCmd.AddCommand(
+						cmd.GitLFSTransfer(),
+					)
+				}
+			}
+
+			rootCmd.SetArgs(args)
+			if len(args) == 0 {
+				// otherwise it'll default to os.Args, which is not what we want.
+				rootCmd.SetArgs([]string{"--help"})
+			}
+			rootCmd.SetIn(s)
+			rootCmd.SetOut(s)
+			rootCmd.SetErr(s.Stderr())
+			rootCmd.SetContext(ctx)
+
+			user := proto.UserFromContext(ctx)
+			isAdmin := cmd.IsPublicKeyAdmin(cfg, s.PublicKey()) || (user != nil && user.IsAdmin())
+			if user != nil || isAdmin {
+				if isAdmin {
+					rootCmd.AddCommand(
+						cmd.SettingsCommand(),
+						cmd.UserCommand(),
+					)
+				}
+
+				rootCmd.AddCommand(
+					cmd.InfoCommand(),
+					cmd.PubkeyCommand(),
+					cmd.SetUsernameCommand(),
+					cmd.JWTCommand(),
+					cmd.TokenCommand(),
+				)
+			}
+
+			if err := rootCmd.ExecuteContext(ctx); err != nil {
+				logger.Error("error executing command", "err", err)
+				s.Exit(1) // nolint: errcheck
+				return
 			}
 		}()
 		sh(s)

server/ssh/ssh.go 🔗

@@ -13,7 +13,6 @@ import (
 	"github.com/charmbracelet/soft-serve/server/backend"
 	"github.com/charmbracelet/soft-serve/server/config"
 	"github.com/charmbracelet/soft-serve/server/db"
-	"github.com/charmbracelet/soft-serve/server/git"
 	"github.com/charmbracelet/soft-serve/server/proto"
 	"github.com/charmbracelet/soft-serve/server/store"
 	"github.com/charmbracelet/ssh"
@@ -41,55 +40,6 @@ var (
 		Name:      "keyboard_interactive_auth_total",
 		Help:      "The total number of keyboard interactive auth requests",
 	}, []string{"allowed"})
-
-	uploadPackCounter = promauto.NewCounterVec(prometheus.CounterOpts{
-		Namespace: "soft_serve",
-		Subsystem: "git",
-		Name:      "upload_pack_total",
-		Help:      "The total number of git-upload-pack requests",
-	}, []string{"repo"})
-
-	receivePackCounter = promauto.NewCounterVec(prometheus.CounterOpts{
-		Namespace: "soft_serve",
-		Subsystem: "git",
-		Name:      "receive_pack_total",
-		Help:      "The total number of git-receive-pack requests",
-	}, []string{"repo"})
-
-	uploadArchiveCounter = promauto.NewCounterVec(prometheus.CounterOpts{
-		Namespace: "soft_serve",
-		Subsystem: "git",
-		Name:      "upload_archive_total",
-		Help:      "The total number of git-upload-archive requests",
-	}, []string{"repo"})
-
-	uploadPackSeconds = promauto.NewCounterVec(prometheus.CounterOpts{
-		Namespace: "soft_serve",
-		Subsystem: "git",
-		Name:      "upload_pack_seconds_total",
-		Help:      "The total time spent on git-upload-pack requests",
-	}, []string{"repo"})
-
-	receivePackSeconds = promauto.NewCounterVec(prometheus.CounterOpts{
-		Namespace: "soft_serve",
-		Subsystem: "git",
-		Name:      "receive_pack_seconds_total",
-		Help:      "The total time spent on git-receive-pack requests",
-	}, []string{"repo"})
-
-	uploadArchiveSeconds = promauto.NewCounterVec(prometheus.CounterOpts{
-		Namespace: "soft_serve",
-		Subsystem: "git",
-		Name:      "upload_archive_seconds_total",
-		Help:      "The total time spent on git-upload-archive requests",
-	}, []string{"repo"})
-
-	createRepoCounter = promauto.NewCounterVec(prometheus.CounterOpts{
-		Namespace: "soft_serve",
-		Subsystem: "ssh",
-		Name:      "create_repo_total",
-		Help:      "The total number of create repo requests",
-	}, []string{"repo"})
 )
 
 // SSHServer is a SSH server that implements the git protocol.
@@ -209,9 +159,3 @@ func (s *SSHServer) KeyboardInteractiveHandler(ctx ssh.Context, _ gossh.Keyboard
 	keyboardInteractiveCounter.WithLabelValues(strconv.FormatBool(ac)).Inc()
 	return ac
 }
-
-// sshFatal prints to the session's STDOUT as a git response and exit 1.
-func sshFatal(s ssh.Session, err error) {
-	git.WritePktlineErr(s, err) // nolint: errcheck
-	s.Exit(1)                   // nolint: errcheck
-}

server/sshutils/utils.go 🔗

@@ -38,3 +38,14 @@ func PublicKeyFromContext(ctx context.Context) gossh.PublicKey {
 	}
 	return nil
 }
+
+// ContextKeySession is the context key for the SSH session.
+var ContextKeySession = &struct{ string }{"session"}
+
+// SessionFromContext returns the SSH session from the context.
+func SessionFromContext(ctx context.Context) ssh.Session {
+	if s, ok := ctx.Value(ContextKeySession).(ssh.Session); ok {
+		return s
+	}
+	return nil
+}

server/web/auth.go 🔗

@@ -51,7 +51,7 @@ func parseUsernamePassword(ctx context.Context, username, password string) (prot
 		return nil, ErrInvalidPassword
 	} else if username != "" {
 		// Try to authenticate using access token as the username
-		logger.Info("trying to authenticate using access token as username", "username", username)
+		logger.Debug("trying to authenticate using access token as username", "username", username)
 		user, err := be.UserByAccessToken(ctx, username)
 		if err == nil {
 			return user, nil

server/web/git.go 🔗

@@ -94,7 +94,6 @@ func withParams(h http.Handler) http.Handler {
 		repo = utils.SanitizeRepo(repo)
 		vars["repo"] = repo
 		vars["dir"] = filepath.Join(cfg.DataPath, "repos", repo+".git")
-		logger.Info("request vars", "vars", vars)
 
 		// Add repo suffix (.git)
 		r.URL.Path = fmt.Sprintf("%s.git/%s", repo, vars["file"])
@@ -233,7 +232,7 @@ func withAccess(next http.Handler) http.HandlerFunc {
 		r = r.WithContext(ctx)
 
 		if user != nil {
-			logger.Info("found user", "username", user.Username())
+			logger.Debug("authenticated", "username", user.Username())
 		}
 
 		service := git.Service(mux.Vars(r)["service"])

testscript/testdata/help.txtar 🔗

@@ -11,15 +11,15 @@ Usage:
   ssh -p $SSH_PORT localhost [command]
 
 Available Commands:
-  help         Help about any command
-  info         Show your info
-  jwt          Generate a JSON Web Token
-  pubkey       Manage your public keys
-  repo         Manage repositories
-  set-username Set your username
-  settings     Manage server settings
-  token        Manage access tokens
-  user         Manage users
+  help                 Help about any command
+  info                 Show your info
+  jwt                  Generate a JSON Web Token
+  pubkey               Manage your public keys
+  repo                 Manage repositories
+  set-username         Set your username
+  settings             Manage server settings
+  token                Manage access tokens
+  user                 Manage users
 
 Flags:
   -h, --help   help for this command

testscript/testdata/ssh.txtar 🔗

@@ -0,0 +1,99 @@
+# vi: set ft=conf
+
+[windows] dos2unix argserr1.txt argserr2.txt argserr3.txt invalidrepoerr.txt notauthorizederr.txt
+
+# create a user
+soft user create foo --key "$USER1_AUTHORIZED_KEY"
+
+# create a repo
+soft repo create repo1
+soft repo create repo1p -p
+usoft repo create repo2
+usoft repo create repo2p -p
+
+# SSH Git commands as admin
+! soft git-upload-pack
+cmp stderr argserr1.txt
+! soft git-upload-pack foobar
+cmp stderr invalidrepoerr.txt
+! soft git-upload-archive
+cmp stderr argserr1.txt
+! soft git-upload-archive foobar
+cmp stderr invalidrepoerr.txt
+! soft git-receive-pack
+cmp stderr argserr1.txt
+! soft git-receive-pack foobar
+stdout '.*0000 capabilities.*git.*' # git pack response
+stderr '.*something went wrong.*'
+! soft git-lfs-authenticate
+cmp stderr argserr2.txt
+! soft git-lfs-authenticate foobar
+cmp stderr argserr3.txt
+! soft git-lfs-authenticate foobar download
+cmp stderr invalidrepoerr.txt
+! soft git-lfs-authenticate foobar upload
+cmp stderr invalidrepoerr.txt
+soft git-lfs-authenticate repo1 download
+stdout '.*header.*Bearer.*href.*expires_in.*expires_at.*'
+soft git-lfs-authenticate repo1 upload
+stdout '.*header.*Bearer.*href.*expires_in.*expires_at.*'
+soft git-lfs-authenticate repo1p download
+stdout '.*header.*Bearer.*href.*expires_in.*expires_at.*'
+soft git-lfs-authenticate repo1p upload
+stdout '.*header.*Bearer.*href.*expires_in.*expires_at.*'
+soft git-lfs-authenticate repo2 download
+stdout '.*header.*Bearer.*href.*expires_in.*expires_at.*'
+soft git-lfs-authenticate repo2 upload
+stdout '.*header.*Bearer.*href.*expires_in.*expires_at.*'
+soft git-lfs-authenticate repo2p download
+stdout '.*header.*Bearer.*href.*expires_in.*expires_at.*'
+soft git-lfs-authenticate repo2p upload
+stdout '.*header.*Bearer.*href.*expires_in.*expires_at.*'
+
+# SSH Git commands as user
+! usoft git-upload-pack
+cmp stderr argserr1.txt
+! usoft git-upload-pack foobar
+cmp stderr invalidrepoerr.txt
+! usoft git-upload-archive
+cmp stderr argserr1.txt
+! usoft git-upload-archive foobar
+cmp stderr invalidrepoerr.txt
+! usoft git-receive-pack
+cmp stderr argserr1.txt
+! usoft git-receive-pack foobar
+stdout '.*0000 capabilities.*git.*' # git pack response
+stderr '.*something went wrong.*'
+! usoft git-lfs-authenticate
+cmp stderr argserr2.txt
+! usoft git-lfs-authenticate foobar download
+cmp stderr invalidrepoerr.txt
+! usoft git-lfs-authenticate foobar upload
+cmp stderr invalidrepoerr.txt
+usoft git-lfs-authenticate repo1 download
+stdout '.*header.*Bearer.*href.*expires_in.*expires_at.*'
+! usoft git-lfs-authenticate repo1 upload
+cmp stderr notauthorizederr.txt
+! usoft git-lfs-authenticate repo1p download
+cmp stderr notauthorizederr.txt
+! usoft git-lfs-authenticate repo1p upload
+cmp stderr notauthorizederr.txt
+usoft git-lfs-authenticate repo2 download
+stdout '.*header.*Bearer.*href.*expires_in.*expires_at.*'
+usoft git-lfs-authenticate repo2 upload
+stdout '.*header.*Bearer.*href.*expires_in.*expires_at.*'
+usoft git-lfs-authenticate repo2p download
+stdout '.*header.*Bearer.*href.*expires_in.*expires_at.*'
+usoft git-lfs-authenticate repo2p upload
+stdout '.*header.*Bearer.*href.*expires_in.*expires_at.*'
+
+-- argserr1.txt --
+Error: accepts 1 arg(s), received 0
+-- argserr2.txt --
+Error: accepts 2 arg(s), received 0
+-- argserr3.txt --
+Error: accepts 2 arg(s), received 1
+-- invalidrepoerr.txt --
+Error: invalid repo
+-- notauthorizederr.txt --
+Error: you are not authorized to do this