b26060bae203a139fd62e60e4760d78e71ab389c

Author
Ayman Bagabas <ayman.bagabas@gmail.com>
Committer
Ayman Bagabas <ayman.bagabas@gmail.com>
Date

Message

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

- 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

Diff

This diff is truncated to protect this page.

  1diff --git a/server/ssh/cmd.go b/server/ssh/cmd.go
  2deleted file mode 100644
  3index f2563f84208aaed4932bc12b40ab89950537758b..0000000000000000000000000000000000000000
  4--- a/server/ssh/cmd.go
  5+++ /dev/null
  6@@ -1,17 +0,0 @@
  7-package ssh
  8-
  9-import (
 10-	"github.com/charmbracelet/log"
 11-	"github.com/charmbracelet/soft-serve/server/ssh/cmd"
 12-	"github.com/charmbracelet/ssh"
 13-)
 14-
 15-func handleCli(s ssh.Session) {
 16-	ctx := s.Context()
 17-	logger := log.FromContext(ctx)
 18-	rootCmd := cmd.RootCommand(s)
 19-	if err := rootCmd.ExecuteContext(ctx); err != nil {
 20-		logger.Error("error executing command", "err", err)
 21-		_ = s.Exit(1)
 22-	}
 23-}
 24diff --git a/server/ssh/cmd/cmd.go b/server/ssh/cmd/cmd.go
 25index 099102a650fa330d789896703ff81e1dc9d05d7b..08ac8f9b4312297fd704bc296117ba05c1e8091d 100644
 26--- a/server/ssh/cmd/cmd.go
 27+++ b/server/ssh/cmd/cmd.go
 28@@ -14,18 +14,9 @@ import (
 29 	"github.com/charmbracelet/soft-serve/server/sshutils"
 30 	"github.com/charmbracelet/soft-serve/server/utils"
 31 	"github.com/charmbracelet/ssh"
 32-	"github.com/prometheus/client_golang/prometheus"
 33-	"github.com/prometheus/client_golang/prometheus/promauto"
 34 	"github.com/spf13/cobra"
 35 )
 36 
 37-var cliCommandCounter = promauto.NewCounterVec(prometheus.CounterOpts{
 38-	Namespace: "soft_serve",
 39-	Subsystem: "cli",
 40-	Name:      "commands_total",
 41-	Help:      "Total times each command was called",
 42-}, []string{"command"})
 43-
 44 var templateFuncs = template.FuncMap{
 45 	"trim":                    strings.TrimSpace,
 46 	"trimRightSpace":          trimRightSpace,
 47@@ -36,7 +27,8 @@ var templateFuncs = template.FuncMap{
 48 }
 49 
 50 const (
 51-	usageTmpl = `Usage:{{if .Runnable}}
 52+	// UsageTemplate is the template used for the help output.
 53+	UsageTemplate = `Usage:{{if .Runnable}}
 54   {{.UseLine}}{{end}}{{if .HasAvailableSubCommands}}
 55   {{.SSHCommand}}{{.CommandPath}} [command]{{end}}{{if gt (len .Aliases) 0}}
 56 
 57@@ -68,35 +60,11 @@ Use "{{.SSHCommand}}{{.CommandPath}} [command] --help" for more information abou
 58 `
 59 )
 60 
 61-func trimRightSpace(s string) string {
 62-	return strings.TrimRightFunc(s, unicode.IsSpace)
 63-}
 64-
 65-// rpad adds padding to the right of a string.
 66-func rpad(s string, padding int) string {
 67-	template := fmt.Sprintf("%%-%ds", padding)
 68-	return fmt.Sprintf(template, s)
 69-}
 70-
 71-func cmdName(args []string) string {
 72-	if len(args) == 0 {
 73-		return ""
 74-	}
 75-	return args[0]
 76-}
 77-
 78-// RootCommand returns a new cli root command.
 79-func RootCommand(s ssh.Session) *cobra.Command {
 80-	ctx := s.Context()
 81+// UsageFunc is a function that can be used as a cobra.Command's
 82+// UsageFunc to render the help output.
 83+func UsageFunc(c *cobra.Command) error {
 84+	ctx := c.Context()
 85 	cfg := config.FromContext(ctx)
 86-
 87-	args := s.Command()
 88-	cliCommandCounter.WithLabelValues(cmdName(args)).Inc()
 89-	rootCmd := &cobra.Command{
 90-		Short:        "Soft Serve is a self-hostable Git server for the command line.",
 91-		SilenceUsage: true,
 92-	}
 93-
 94 	hostname := "localhost"
 95 	port := "23231"
 96 	url, err := url.Parse(cfg.SSH.PublicURL)
 97@@ -111,54 +79,34 @@ func RootCommand(s ssh.Session) *cobra.Command {
 98 	}
 99 
100 	sshCmd += " " + hostname
101-	rootCmd.SetUsageTemplate(usageTmpl)
102-	rootCmd.SetUsageFunc(func(c *cobra.Command) error {
103-		t := template.New("usage")
104-		t.Funcs(templateFuncs)
105-		template.Must(t.Parse(c.UsageTemplate()))
106-		return t.Execute(c.OutOrStderr(), struct {
107-			*cobra.Command
108-			SSHCommand string
109-		}{
110-			Command:    c,
111-			SSHCommand: sshCmd,
112-		})
113+	t := template.New("usage")
114+	t.Funcs(templateFuncs)
115+	template.Must(t.Parse(c.UsageTemplate()))
116+	return t.Execute(c.OutOrStderr(), struct {
117+		*cobra.Command
118+		SSHCommand string
119+	}{
120+		Command:    c,
121+		SSHCommand: sshCmd,
122 	})
123-	rootCmd.CompletionOptions.DisableDefaultCmd = true
124-	rootCmd.AddCommand(
125-		repoCommand(),
126-	)
127+}
128diff --git a/server/ssh/cmd/git.go b/server/ssh/cmd/git.go
129new file mode 100644
130index 0000000000000000000000000000000000000000..d2886514a9b4b7a8d7c9b0fbf752804353dbc5d8
131--- /dev/null
132+++ b/server/ssh/cmd/git.go
133@@ -0,0 +1,333 @@
134+package cmd
135+
136+import (
137+	"errors"
138+	"path/filepath"
139+	"time"
140+
141+	"github.com/charmbracelet/log"
142+	"github.com/charmbracelet/soft-serve/server/access"
143+	"github.com/charmbracelet/soft-serve/server/backend"
144+	"github.com/charmbracelet/soft-serve/server/config"
145+	"github.com/charmbracelet/soft-serve/server/git"
146+	"github.com/charmbracelet/soft-serve/server/lfs"
147+	"github.com/charmbracelet/soft-serve/server/proto"
148+	"github.com/charmbracelet/soft-serve/server/sshutils"
149+	"github.com/charmbracelet/soft-serve/server/utils"
150+	"github.com/prometheus/client_golang/prometheus"
151+	"github.com/prometheus/client_golang/prometheus/promauto"
152+	"github.com/spf13/cobra"
153+)
154+
155+var (
156+	uploadPackCounter = promauto.NewCounterVec(prometheus.CounterOpts{
157+		Namespace: "soft_serve",
158+		Subsystem: "git",
159+		Name:      "upload_pack_total",
160+		Help:      "The total number of git-upload-pack requests",
161+	}, []string{"repo"})
162+
163+	receivePackCounter = promauto.NewCounterVec(prometheus.CounterOpts{
164+		Namespace: "soft_serve",
165+		Subsystem: "git",
166+		Name:      "receive_pack_total",
167+		Help:      "The total number of git-receive-pack requests",
168+	}, []string{"repo"})
169+
170+	uploadArchiveCounter = promauto.NewCounterVec(prometheus.CounterOpts{
171+		Namespace: "soft_serve",
172+		Subsystem: "git",
173+		Name:      "upload_archive_total",
174+		Help:      "The total number of git-upload-archive requests",
175+	}, []string{"repo"})
176+
177+	lfsAuthenticateCounter = promauto.NewCounterVec(prometheus.CounterOpts{
178+		Namespace: "soft_serve",
179+		Subsystem: "git",
180+		Name:      "lfs_authenticate_total",
181+		Help:      "The total number of git-lfs-authenticate requests",
182+	}, []string{"repo", "operation"})
183+
184+	lfsTransferCounter = promauto.NewCounterVec(prometheus.CounterOpts{
185+		Namespace: "soft_serve",
186+		Subsystem: "git",
187+		Name:      "lfs_transfer_total",
188+		Help:      "The total number of git-lfs-transfer requests",
189+	}, []string{"repo", "operation"})
190+
191+	uploadPackSeconds = promauto.NewCounterVec(prometheus.CounterOpts{
192+		Namespace: "soft_serve",
193+		Subsystem: "git",
194+		Name:      "upload_pack_seconds_total",
195+		Help:      "The total time spent on git-upload-pack requests",
196+	}, []string{"repo"})
197+
198+	receivePackSeconds = promauto.NewCounterVec(prometheus.CounterOpts{
199+		Namespace: "soft_serve",
200+		Subsystem: "git",
201+		Name:      "receive_pack_seconds_total",
202+		Help:      "The total time spent on git-receive-pack requests",
203+	}, []string{"repo"})
204+
205+	uploadArchiveSeconds = promauto.NewCounterVec(prometheus.CounterOpts{
206+		Namespace: "soft_serve",
207+		Subsystem: "git",
208+		Name:      "upload_archive_seconds_total",
209+		Help:      "The total time spent on git-upload-archive requests",
210+	}, []string{"repo", "operation"})
211+
212+	lfsAuthenticateSeconds = promauto.NewCounterVec(prometheus.CounterOpts{
213+		Namespace: "soft_serve",
214+		Subsystem: "git",
215+		Name:      "lfs_authenticate_seconds_total",
216+		Help:      "The total time spent on git-lfs-authenticate requests",
217+	}, []string{"repo", "operation"})
218+
219+	lfsTransferSeconds = promauto.NewCounterVec(prometheus.CounterOpts{
220+		Namespace: "soft_serve",
221+		Subsystem: "git",
222+		Name:      "lfs_transfer_seconds_total",
223+		Help:      "The total time spent on git-lfs-transfer requests",
224+	}, []string{"repo"})
225+
226+	createRepoCounter = promauto.NewCounterVec(prometheus.CounterOpts{
227+		Namespace: "soft_serve",
228+		Subsystem: "ssh",
229+		Name:      "create_repo_total",
230+		Help:      "The total number of create repo requests",
231+	}, []string{"repo"})
232+)
233diff --git a/server/ssh/cmd/info.go b/server/ssh/cmd/info.go
234index 7b2ac0aeff29402323345fdfd284cea919c03cd0..43f0c15ec91eaa29322381fd2676fa6b35c55ddb 100644
235--- a/server/ssh/cmd/info.go
236+++ b/server/ssh/cmd/info.go
237@@ -6,12 +6,13 @@ import (
238 	"github.com/spf13/cobra"
239 )
240 
241-func infoCommand() *cobra.Command {
242+// InfoCommand returns a command that shows the user's info
243+func InfoCommand() *cobra.Command {
244 	cmd := &cobra.Command{
245 		Use:   "info",
246 		Short: "Show your info",
247 		Args:  cobra.NoArgs,
248-		RunE: func(cmd *cobra.Command, args []string) error {
249+		RunE: func(cmd *cobra.Command, _ []string) error {
250 			ctx := cmd.Context()
251 			be := backend.FromContext(ctx)
252 			pk := sshutils.PublicKeyFromContext(ctx)
253diff --git a/server/ssh/cmd/jwt.go b/server/ssh/cmd/jwt.go
254index b574889f5a56f9f3e56ae7c1daf76828bc4957ab..f5dec4cbf175fd2f7bfee87d410959236c723bd2 100644
255--- a/server/ssh/cmd/jwt.go
256+++ b/server/ssh/cmd/jwt.go
257@@ -11,7 +11,8 @@ import (
258 	"github.com/spf13/cobra"
259 )
260 
261-func jwtCommand() *cobra.Command {
262+// JWTCommand returns a command that generates a JSON Web Token.
263+func JWTCommand() *cobra.Command {
264 	cmd := &cobra.Command{
265 		Use:   "jwt [repository1 repository2...]",
266 		Short: "Generate a JSON Web Token",
267diff --git a/server/ssh/cmd/list.go b/server/ssh/cmd/list.go
268index 62334c871046f732d741bdb632adc07f0517ed06..539a4dfbcb4d98d297808b12ce8305d83532edbe 100644
269--- a/server/ssh/cmd/list.go
270+++ b/server/ssh/cmd/list.go
271@@ -16,7 +16,7 @@ func listCommand() *cobra.Command {
272 		Aliases: []string{"ls"},
273 		Short:   "List repositories",
274 		Args:    cobra.NoArgs,
275-		RunE: func(cmd *cobra.Command, args []string) error {
276+		RunE: func(cmd *cobra.Command, _ []string) error {
277 			ctx := cmd.Context()
278 			be := backend.FromContext(ctx)
279 			pk := sshutils.PublicKeyFromContext(ctx)
280diff --git a/server/ssh/cmd/pubkey.go b/server/ssh/cmd/pubkey.go
281index e2200b1dc7d05dd878687c339654b5bfb9b4d2f7..11edd386d09980a2b2d6b33302839f8aa85932ff 100644
282--- a/server/ssh/cmd/pubkey.go
283+++ b/server/ssh/cmd/pubkey.go
284@@ -8,7 +8,8 @@ import (
285 	"github.com/spf13/cobra"
286 )
287 
288-func pubkeyCommand() *cobra.Command {
289+// PubkeyCommand returns a command that manages user public keys.
290+func PubkeyCommand() *cobra.Command {
291 	cmd := &cobra.Command{
292 		Use:     "pubkey",
293 		Aliases: []string{"pubkeys", "publickey", "publickeys"},
294@@ -64,7 +65,7 @@ func pubkeyCommand() *cobra.Command {
295 		Aliases: []string{"ls"},
296 		Short:   "List public keys",
297 		Args:    cobra.NoArgs,
298-		RunE: func(cmd *cobra.Command, args []string) error {
299+		RunE: func(cmd *cobra.Command, _ []string) error {
300 			ctx := cmd.Context()
301 			be := backend.FromContext(ctx)
302 			pk := sshutils.PublicKeyFromContext(ctx)
303diff --git a/server/ssh/cmd/repo.go b/server/ssh/cmd/repo.go
304index fc23844fb1494268d6f352091617cdca6f3d8c44..0b5ff3e5db40e4f672349174a01ea05daa7fcae2 100644
305--- a/server/ssh/cmd/repo.go
306+++ b/server/ssh/cmd/repo.go
307@@ -9,7 +9,8 @@ import (
308 	"github.com/spf13/cobra"
309 )
310 
311-func repoCommand() *cobra.Command {
312+// RepoCommand returns a command for managing repositories.
313+func RepoCommand() *cobra.Command {
314 	cmd := &cobra.Command{
315 		Use:     "repo",
316 		Aliases: []string{"repos", "repository", "repositories"},
317diff --git a/server/ssh/cmd/set_username.go b/server/ssh/cmd/set_username.go
318index 71243ac5f5c39b7f2308473d64adec009639d53a..c3841f82ea7b480d36eb6f735e8bd0101014e92a 100644
319--- a/server/ssh/cmd/set_username.go
320+++ b/server/ssh/cmd/set_username.go
321@@ -6,7 +6,8 @@ import (
322 	"github.com/spf13/cobra"
323 )
324 
325-func setUsernameCommand() *cobra.Command {
326+// SetUsernameCommand returns a command that sets the user's username.
327+func SetUsernameCommand() *cobra.Command {
328 	cmd := &cobra.Command{
329 		Use:   "set-username USERNAME",
330 		Short: "Set your username",
331diff --git a/server/ssh/cmd/settings.go b/server/ssh/cmd/settings.go
332index 0fa4cace84aa42684d595f269706eea82bf39530..4131fb2b1b32353d4636505aa4263feed845eab0 100644
333--- a/server/ssh/cmd/settings.go
334+++ b/server/ssh/cmd/settings.go
335@@ -9,7 +9,8 @@ import (
336 	"github.com/spf13/cobra"
337 )
338 
339-func settingsCommand() *cobra.Command {
340+// SettingsCommand returns a command that manages server settings.
341+func SettingsCommand() *cobra.Command {
342 	cmd := &cobra.Command{
343 		Use:   "settings",
344 		Short: "Manage server settings",
345diff --git a/server/ssh/cmd/token.go b/server/ssh/cmd/token.go
346index cb3daad6fe8ef42e0b2bb1c463e45e9d03723bc8..3fefaa8cd6321b80a0cb5955f58334f46414b420 100644
347--- a/server/ssh/cmd/token.go
348+++ b/server/ssh/cmd/token.go
349@@ -13,7 +13,8 @@ import (
350 	"github.com/spf13/cobra"
351 )
352 
353-func tokenCommand() *cobra.Command {
354+// TokenCommand returns a command that manages user access tokens.
355+func TokenCommand() *cobra.Command {
356 	cmd := &cobra.Command{
357 		Use:     "token",
358 		Aliases: []string{"access-token"},
359diff --git a/server/ssh/cmd/user.go b/server/ssh/cmd/user.go
360index 639bf909adb773db202f7b95fae0aa8629b14425..f8b0a986d9fd135a8c242ca3e0dad9c972d59b13 100644
361--- a/server/ssh/cmd/user.go
362+++ b/server/ssh/cmd/user.go
363@@ -11,7 +11,8 @@ import (
364 	"golang.org/x/crypto/ssh"
365 )
366 
367-func userCommand() *cobra.Command {
368+// UserCommand returns the user subcommand.
369+func UserCommand() *cobra.Command {
370 	cmd := &cobra.Command{
371 		Use:     "user",
372 		Aliases: []string{"users"},
373diff --git a/server/ssh/git.go b/server/ssh/git.go
374deleted file mode 100644
375index b1a269a4999fe21181d327bf372f763701ca172b..0000000000000000000000000000000000000000
376--- a/server/ssh/git.go
377+++ /dev/null
378@@ -1,165 +0,0 @@
379-package ssh
380-
381-import (
382-	"errors"
383-	"path/filepath"
384-	"time"
385-
386-	"github.com/charmbracelet/log"
387-	"github.com/charmbracelet/soft-serve/server/access"
388-	"github.com/charmbracelet/soft-serve/server/backend"
389-	"github.com/charmbracelet/soft-serve/server/config"
390-	"github.com/charmbracelet/soft-serve/server/git"
391-	"github.com/charmbracelet/soft-serve/server/lfs"
392-	"github.com/charmbracelet/soft-serve/server/proto"
393-	"github.com/charmbracelet/soft-serve/server/sshutils"
394-	"github.com/charmbracelet/soft-serve/server/utils"
395-	"github.com/charmbracelet/ssh"
396-)
397-
398-func handleGit(s ssh.Session) {
399-	ctx := s.Context()
400-	cfg := config.FromContext(ctx)
401-	be := backend.FromContext(ctx)
402-	logger := log.FromContext(ctx)
403-	cmdLine := s.Command()
404-	start := time.Now()
405-
406-	// repo should be in the form of "repo.git"
407-	name := utils.SanitizeRepo(cmdLine[1])
408-	pk := s.PublicKey()
409-	ak := sshutils.MarshalAuthorizedKey(pk)
410-	user := proto.UserFromContext(ctx)
411-	accessLevel := be.AccessLevelForUser(ctx, name, user)
412-	// git bare repositories should end in ".git"
413-	// https://git-scm.com/docs/gitrepository-layout
414-	repoDir := name + ".git"
415-	reposDir := filepath.Join(cfg.DataPath, "repos")
416-	if err := git.EnsureWithin(reposDir, repoDir); err != nil {
417-		sshFatal(s, err)
418-		return
419-	}
420-
421-	// Set repo in context
422-	repo, _ := be.Repository(ctx, name)
423-	ctx.SetValue(proto.ContextKeyRepository, repo)
424-
425-	// Environment variables to pass down to git hooks.
426-	envs := []string{
427-		"SOFT_SERVE_REPO_NAME=" + name,
428-		"SOFT_SERVE_REPO_PATH=" + filepath.Join(reposDir, repoDir),
429-		"SOFT_SERVE_PUBLIC_KEY=" + ak,
430-		"SOFT_SERVE_LOG_PATH=" + filepath.Join(cfg.DataPath, "log", "hooks.log"),
431-	}
432-
433-	if user != nil {
434-		envs = append(envs,
435-			"SOFT_SERVE_USERNAME="+user.Username(),
436-		)
437-	}
438-
439-	// Add ssh session & config environ
440-	envs = append(envs, s.Environ()...)
441-	envs = append(envs, cfg.Environ()...)
442-
443-	repoPath := filepath.Join(reposDir, repoDir)
444-	service := git.Service(cmdLine[0])
445-	cmd := git.ServiceCommand{
446-		Stdin:  s,
447-		Stdout: s,
448-		Stderr: s.Stderr(),
449-		Env:    envs,
450-		Dir:    repoPath,
451-	}
452-
453-	logger.Debug("git middleware", "cmd", service, "access", accessLevel.String())
454-
455-	switch service {
456-	case git.ReceivePackService:
457-		receivePackCounter.WithLabelValues(name).Inc()
458-		defer func() {
459-			receivePackSeconds.WithLabelValues(name).Add(time.Since(start).Seconds())
460-		}()
461-		if accessLevel < access.ReadWriteAccess {
462-			sshFatal(s, git.ErrNotAuthed)
463-			return
464-		}
465-		if repo == nil {
466-			if _, err := be.CreateRepository(ctx, name, user, proto.RepositoryOptions{Private: false}); err != nil {
467-				log.Errorf("failed to create repo: %s", err)
468-				sshFatal(s, err)
469-				return
470-			}
471-			createRepoCounter.WithLabelValues(name).Inc()
472-		}
473-
474-		if err := service.Handler(ctx, cmd); err != nil {
475-			sshFatal(s, git.ErrSystemMalfunction)
476-		}
477-
478diff --git a/server/ssh/logger.go b/server/ssh/logger.go
479deleted file mode 100644
480index c5b972f0b9b6237e56cfbddcc2e6fa897f59c13c..0000000000000000000000000000000000000000
481--- a/server/ssh/logger.go
482+++ /dev/null
483@@ -1,25 +0,0 @@
484-package ssh
485-
486-import "github.com/charmbracelet/log"
487-
488-type loggerAdapter struct {
489-	*log.Logger
490-	log.Level
491-}
492-
493-func (l *loggerAdapter) Printf(format string, args ...interface{}) {
494-	switch l.Level {
495-	case log.DebugLevel:
496-		l.Logger.Debugf(format, args...)
497-	case log.InfoLevel:
498-		l.Logger.Infof(format, args...)
499-	case log.WarnLevel:
500-		l.Logger.Warnf(format, args...)
501-	case log.ErrorLevel:
502-		l.Logger.Errorf(format, args...)
503-	case log.FatalLevel:
504-		l.Logger.Fatalf(format, args...)
505-	default:
506-		l.Logger.Printf(format, args...)
507-	}
508-}
509diff --git a/server/ssh/middleware.go b/server/ssh/middleware.go
510index 909c499d4ddffa5f2454c10185e7057e9283bb74..209ed1dd048a275c159125772d5a0a9ca2fea2b0 100644
511--- a/server/ssh/middleware.go
512+++ b/server/ssh/middleware.go
513@@ -1,20 +1,25 @@
514 package ssh
515 
516 import (
517-	"strings"
518-
519 	"github.com/charmbracelet/log"
520 	"github.com/charmbracelet/soft-serve/server/backend"
521 	"github.com/charmbracelet/soft-serve/server/config"
522 	"github.com/charmbracelet/soft-serve/server/db"
523+	"github.com/charmbracelet/soft-serve/server/proto"
524+	"github.com/charmbracelet/soft-serve/server/ssh/cmd"
525+	"github.com/charmbracelet/soft-serve/server/sshutils"
526 	"github.com/charmbracelet/soft-serve/server/store"
527 	"github.com/charmbracelet/ssh"
528+	"github.com/prometheus/client_golang/prometheus"
529+	"github.com/prometheus/client_golang/prometheus/promauto"
530+	"github.com/spf13/cobra"
531 )
532 
533 // ContextMiddleware adds the config, backend, and logger to the session context.
534 func ContextMiddleware(cfg *config.Config, dbx *db.DB, datastore store.Store, be *backend.Backend, logger *log.Logger) func(ssh.Handler) ssh.Handler {
535 	return func(sh ssh.Handler) ssh.Handler {
536 		return func(s ssh.Session) {
537+			s.Context().SetValue(sshutils.ContextKeySession, s)
538 			s.Context().SetValue(config.ContextKey, cfg)
539 			s.Context().SetValue(db.ContextKey, dbx)
540 			s.Context().SetValue(store.ContextKey, datastore)
541@@ -25,22 +30,89 @@ func ContextMiddleware(cfg *config.Config, dbx *db.DB, datastore store.Store, be
542 	}
543 }
544 
545+var cliCommandCounter = promauto.NewCounterVec(prometheus.CounterOpts{
546+	Namespace: "soft_serve",
547+	Subsystem: "cli",
548+	Name:      "commands_total",
549+	Help:      "Total times each command was called",
550+}, []string{"command"})
551+
552 // CommandMiddleware handles git commands and CLI commands.
553 // This middleware must be run after the ContextMiddleware.
554 func CommandMiddleware(sh ssh.Handler) ssh.Handler {
555 	return func(s ssh.Session) {
556 		func() {
557-			cmdLine := s.Command()
558 			_, _, ptyReq := s.Pty()
559 			if ptyReq {
560 				return
561 			}
562 
563-			switch {
564-			case len(cmdLine) >= 2 && strings.HasPrefix(cmdLine[0], "git-"):
565-				handleGit(s)
566-			default:
567-				handleCli(s)
568+			ctx := s.Context()
569+			cfg := config.FromContext(ctx)
570+			logger := log.FromContext(ctx)
571+
572+			args := s.Command()
573+			cliCommandCounter.WithLabelValues(cmd.CommandName(args)).Inc()
574+			rootCmd := &cobra.Command{
575+				Short:        "Soft Serve is a self-hostable Git server for the command line.",
576+				SilenceUsage: true,
577+			}
578+			rootCmd.CompletionOptions.DisableDefaultCmd = true
579+
580+			rootCmd.SetUsageTemplate(cmd.UsageTemplate)
581+			rootCmd.SetUsageFunc(cmd.UsageFunc)
582+			rootCmd.AddCommand(
583+				cmd.GitUploadPackCommand(),
584+				cmd.GitUploadArchiveCommand(),
585+				cmd.GitReceivePackCommand(),
586+				cmd.RepoCommand(),
587+			)
588+
589+			if cfg.LFS.Enabled {
590+				rootCmd.AddCommand(
591+					cmd.GitLFSAuthenticateCommand(),
592+				)
593+
594+				if cfg.LFS.SSHEnabled {
595+					rootCmd.AddCommand(
596+						cmd.GitLFSTransfer(),
597+					)
598+				}
599+			}
600+
601+			rootCmd.SetArgs(args)
602+			if len(args) == 0 {
603+				// otherwise it'll default to os.Args, which is not what we want.
604+				rootCmd.SetArgs([]string{"--help"})
605+			}
606+			rootCmd.SetIn(s)
607+			rootCmd.SetOut(s)
608+			rootCmd.SetErr(s.Stderr())
609+			rootCmd.SetContext(ctx)
610+
611+			user := proto.UserFromContext(ctx)
612+			isAdmin := cmd.IsPublicKeyAdmin(cfg, s.PublicKey()) || (user != nil && user.IsAdmin())
613diff --git a/server/ssh/ssh.go b/server/ssh/ssh.go
614index 297973ebf24d0be41b66fa4c8329310858ca4970..bd4fd4a899d2831886050a043056ba9779be2ae1 100644
615--- a/server/ssh/ssh.go
616+++ b/server/ssh/ssh.go
617@@ -13,7 +13,6 @@ import (
618 	"github.com/charmbracelet/soft-serve/server/backend"
619 	"github.com/charmbracelet/soft-serve/server/config"
620 	"github.com/charmbracelet/soft-serve/server/db"
621-	"github.com/charmbracelet/soft-serve/server/git"
622 	"github.com/charmbracelet/soft-serve/server/proto"
623 	"github.com/charmbracelet/soft-serve/server/store"
624 	"github.com/charmbracelet/ssh"
625@@ -41,55 +40,6 @@ var (
626 		Name:      "keyboard_interactive_auth_total",
627 		Help:      "The total number of keyboard interactive auth requests",
628 	}, []string{"allowed"})
629-
630-	uploadPackCounter = promauto.NewCounterVec(prometheus.CounterOpts{
631-		Namespace: "soft_serve",
632-		Subsystem: "git",
633-		Name:      "upload_pack_total",
634-		Help:      "The total number of git-upload-pack requests",
635-	}, []string{"repo"})
636-
637-	receivePackCounter = promauto.NewCounterVec(prometheus.CounterOpts{
638-		Namespace: "soft_serve",
639-		Subsystem: "git",
640-		Name:      "receive_pack_total",
641-		Help:      "The total number of git-receive-pack requests",
642-	}, []string{"repo"})
643-
644-	uploadArchiveCounter = promauto.NewCounterVec(prometheus.CounterOpts{
645-		Namespace: "soft_serve",
646-		Subsystem: "git",
647-		Name:      "upload_archive_total",
648-		Help:      "The total number of git-upload-archive requests",
649-	}, []string{"repo"})
650-
651-	uploadPackSeconds = promauto.NewCounterVec(prometheus.CounterOpts{
652-		Namespace: "soft_serve",
653-		Subsystem: "git",
654-		Name:      "upload_pack_seconds_total",
655-		Help:      "The total time spent on git-upload-pack requests",
656-	}, []string{"repo"})
657-
658-	receivePackSeconds = promauto.NewCounterVec(prometheus.CounterOpts{
659-		Namespace: "soft_serve",
660-		Subsystem: "git",
661-		Name:      "receive_pack_seconds_total",
662-		Help:      "The total time spent on git-receive-pack requests",
663-	}, []string{"repo"})
664-
665-	uploadArchiveSeconds = promauto.NewCounterVec(prometheus.CounterOpts{
666-		Namespace: "soft_serve",
667-		Subsystem: "git",
668-		Name:      "upload_archive_seconds_total",
669-		Help:      "The total time spent on git-upload-archive requests",
670-	}, []string{"repo"})
671-
672-	createRepoCounter = promauto.NewCounterVec(prometheus.CounterOpts{
673-		Namespace: "soft_serve",
674-		Subsystem: "ssh",
675-		Name:      "create_repo_total",
676-		Help:      "The total number of create repo requests",
677-	}, []string{"repo"})
678 )
679 
680 // SSHServer is a SSH server that implements the git protocol.
681@@ -209,9 +159,3 @@ func (s *SSHServer) KeyboardInteractiveHandler(ctx ssh.Context, _ gossh.Keyboard
682 	keyboardInteractiveCounter.WithLabelValues(strconv.FormatBool(ac)).Inc()
683 	return ac
684 }
685-
686-// sshFatal prints to the session's STDOUT as a git response and exit 1.
687-func sshFatal(s ssh.Session, err error) {
688-	git.WritePktlineErr(s, err) // nolint: errcheck
689-	s.Exit(1)                   // nolint: errcheck
690-}
691diff --git a/server/sshutils/utils.go b/server/sshutils/utils.go
692index 6bba64578adff81c72bd9f8bb89eee2a3887b6ad..2686ac7bc306e4cdbf40687087d1ed15375c0b5c 100644
693--- a/server/sshutils/utils.go
694+++ b/server/sshutils/utils.go
695@@ -38,3 +38,14 @@ func PublicKeyFromContext(ctx context.Context) gossh.PublicKey {
696 	}
697 	return nil
698 }
699+
700+// ContextKeySession is the context key for the SSH session.
701+var ContextKeySession = &struct{ string }{"session"}
702+
703+// SessionFromContext returns the SSH session from the context.
704+func SessionFromContext(ctx context.Context) ssh.Session {
705+	if s, ok := ctx.Value(ContextKeySession).(ssh.Session); ok {
706+		return s
707+	}
708+	return nil
709+}
710diff --git a/server/web/auth.go b/server/web/auth.go
711index fb090fa6311e73cc816e7c3bd024834b9be431f6..bbbe88b3f915479aedce60b58ff68bab248b7402 100644
712--- a/server/web/auth.go
713+++ b/server/web/auth.go
714@@ -51,7 +51,7 @@ func parseUsernamePassword(ctx context.Context, username, password string) (prot
715 		return nil, ErrInvalidPassword
716 	} else if username != "" {
717 		// Try to authenticate using access token as the username
718-		logger.Info("trying to authenticate using access token as username", "username", username)
719+		logger.Debug("trying to authenticate using access token as username", "username", username)
720 		user, err := be.UserByAccessToken(ctx, username)
721 		if err == nil {
722 			return user, nil
723diff --git a/server/web/git.go b/server/web/git.go
724index d2d640c5f730b0d4eec28c4cf09d6dcbc0e44f52..121b85795d2e4a0dfa7aa2dafd6f6e0c8990b5ea 100644
725--- a/server/web/git.go
726+++ b/server/web/git.go
727@@ -94,7 +94,6 @@ func withParams(h http.Handler) http.Handler {
728 		repo = utils.SanitizeRepo(repo)
729 		vars["repo"] = repo
730 		vars["dir"] = filepath.Join(cfg.DataPath, "repos", repo+".git")
731-		logger.Info("request vars", "vars", vars)
732 
733 		// Add repo suffix (.git)
734 		r.URL.Path = fmt.Sprintf("%s.git/%s", repo, vars["file"])
735@@ -233,7 +232,7 @@ func withAccess(next http.Handler) http.HandlerFunc {
736 		r = r.WithContext(ctx)
737 
738 		if user != nil {
739-			logger.Info("found user", "username", user.Username())
740+			logger.Debug("authenticated", "username", user.Username())
741 		}
742 
743 		service := git.Service(mux.Vars(r)["service"])
744diff --git a/testscript/testdata/help.txtar b/testscript/testdata/help.txtar
745index 58868b7cc750df0c591117f608978605b38e3da4..257ad244799114f510f9c470075575d8d0ba411d 100644
746--- a/testscript/testdata/help.txtar
747+++ b/testscript/testdata/help.txtar
748@@ -11,15 +11,15 @@ Usage:
749   ssh -p $SSH_PORT localhost [command]
750 
751 Available Commands:
752-  help         Help about any command
753-  info         Show your info
754-  jwt          Generate a JSON Web Token
755-  pubkey       Manage your public keys
756-  repo         Manage repositories
757-  set-username Set your username
758-  settings     Manage server settings
759-  token        Manage access tokens
760-  user         Manage users
761+  help                 Help about any command
762+  info                 Show your info
763+  jwt                  Generate a JSON Web Token
764+  pubkey               Manage your public keys
765+  repo                 Manage repositories
766+  set-username         Set your username
767+  settings             Manage server settings
768+  token                Manage access tokens
769+  user                 Manage users
770 
771 Flags:
772   -h, --help   help for this command
773diff --git a/testscript/testdata/ssh.txtar b/testscript/testdata/ssh.txtar
774new file mode 100644
775index 0000000000000000000000000000000000000000..b4e2237399232e91394bb0d26b172c0d166b0ad6
776--- /dev/null
777+++ b/testscript/testdata/ssh.txtar
778@@ -0,0 +1,99 @@
779+# vi: set ft=conf
780+
781+[windows] dos2unix argserr1.txt argserr2.txt argserr3.txt invalidrepoerr.txt notauthorizederr.txt
782+
783+# create a user
784+soft user create foo --key "$USER1_AUTHORIZED_KEY"
785+
786+# create a repo
787+soft repo create repo1
788+soft repo create repo1p -p
789+usoft repo create repo2
790+usoft repo create repo2p -p
791+
792+# SSH Git commands as admin
793+! soft git-upload-pack
794+cmp stderr argserr1.txt
795+! soft git-upload-pack foobar
796+cmp stderr invalidrepoerr.txt
797+! soft git-upload-archive
798+cmp stderr argserr1.txt
799+! soft git-upload-archive foobar
800+cmp stderr invalidrepoerr.txt
801+! soft git-receive-pack
802+cmp stderr argserr1.txt
803+! soft git-receive-pack foobar
804+stdout '.*0000 capabilities.*git.*' # git pack response
805+stderr '.*something went wrong.*'
806+! soft git-lfs-authenticate
807+cmp stderr argserr2.txt
808+! soft git-lfs-authenticate foobar
809+cmp stderr argserr3.txt
810+! soft git-lfs-authenticate foobar download
811+cmp stderr invalidrepoerr.txt
812+! soft git-lfs-authenticate foobar upload
813+cmp stderr invalidrepoerr.txt
814+soft git-lfs-authenticate repo1 download
815+stdout '.*header.*Bearer.*href.*expires_in.*expires_at.*'
816+soft git-lfs-authenticate repo1 upload
817+stdout '.*header.*Bearer.*href.*expires_in.*expires_at.*'
818+soft git-lfs-authenticate repo1p download
819+stdout '.*header.*Bearer.*href.*expires_in.*expires_at.*'
820+soft git-lfs-authenticate repo1p upload
821+stdout '.*header.*Bearer.*href.*expires_in.*expires_at.*'
822+soft git-lfs-authenticate repo2 download
823+stdout '.*header.*Bearer.*href.*expires_in.*expires_at.*'
824+soft git-lfs-authenticate repo2 upload
825+stdout '.*header.*Bearer.*href.*expires_in.*expires_at.*'
826+soft git-lfs-authenticate repo2p download
827+stdout '.*header.*Bearer.*href.*expires_in.*expires_at.*'
828+soft git-lfs-authenticate repo2p upload
829+stdout '.*header.*Bearer.*href.*expires_in.*expires_at.*'
830+
831+# SSH Git commands as user
832+! usoft git-upload-pack
833+cmp stderr argserr1.txt
834+! usoft git-upload-pack foobar
835+cmp stderr invalidrepoerr.txt
836+! usoft git-upload-archive
837+cmp stderr argserr1.txt
838+! usoft git-upload-archive foobar
839+cmp stderr invalidrepoerr.txt
840+! usoft git-receive-pack
841+cmp stderr argserr1.txt
842+! usoft git-receive-pack foobar
843+stdout '.*0000 capabilities.*git.*' # git pack response
844+stderr '.*something went wrong.*'
845+! usoft git-lfs-authenticate
846+cmp stderr argserr2.txt
847+! usoft git-lfs-authenticate foobar download
848+cmp stderr invalidrepoerr.txt
849+! usoft git-lfs-authenticate foobar upload
850+cmp stderr invalidrepoerr.txt
851+usoft git-lfs-authenticate repo1 download
852+stdout '.*header.*Bearer.*href.*expires_in.*expires_at.*'
853+! usoft git-lfs-authenticate repo1 upload
854+cmp stderr notauthorizederr.txt
855+! usoft git-lfs-authenticate repo1p download
856+cmp stderr notauthorizederr.txt
857+! usoft git-lfs-authenticate repo1p upload
858+cmp stderr notauthorizederr.txt
859+usoft git-lfs-authenticate repo2 download
860+stdout '.*header.*Bearer.*href.*expires_in.*expires_at.*'
861+usoft git-lfs-authenticate repo2 upload
862+stdout '.*header.*Bearer.*href.*expires_in.*expires_at.*'
863+usoft git-lfs-authenticate repo2p download
864+stdout '.*header.*Bearer.*href.*expires_in.*expires_at.*'
865+usoft git-lfs-authenticate repo2p upload
866+stdout '.*header.*Bearer.*href.*expires_in.*expires_at.*'
867+
868+-- argserr1.txt --
869+Error: accepts 1 arg(s), received 0
870+-- argserr2.txt --
871+Error: accepts 2 arg(s), received 0
872+-- argserr3.txt --
873+Error: accepts 2 arg(s), received 1
874+-- invalidrepoerr.txt --
875+Error: invalid repo
876+-- notauthorizederr.txt --
877+Error: you are not authorized to do this