diff --git a/server/ssh/cmd.go b/server/ssh/cmd.go deleted file mode 100644 index f2563f842..000000000 --- a/server/ssh/cmd.go +++ /dev/null @@ -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) - } -} diff --git a/server/ssh/cmd/cmd.go b/server/ssh/cmd/cmd.go index 099102a65..08ac8f9b4 100644 --- a/server/ssh/cmd/cmd.go +++ b/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 } diff --git a/server/ssh/cmd/git.go b/server/ssh/cmd/git.go new file mode 100644 index 000000000..d2886514a --- /dev/null +++ b/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") +} diff --git a/server/ssh/cmd/info.go b/server/ssh/cmd/info.go index 7b2ac0aef..43f0c15ec 100644 --- a/server/ssh/cmd/info.go +++ b/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) diff --git a/server/ssh/cmd/jwt.go b/server/ssh/cmd/jwt.go index b574889f5..f5dec4cbf 100644 --- a/server/ssh/cmd/jwt.go +++ b/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", diff --git a/server/ssh/cmd/list.go b/server/ssh/cmd/list.go index 62334c871..539a4dfbc 100644 --- a/server/ssh/cmd/list.go +++ b/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) diff --git a/server/ssh/cmd/pubkey.go b/server/ssh/cmd/pubkey.go index e2200b1dc..11edd386d 100644 --- a/server/ssh/cmd/pubkey.go +++ b/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) diff --git a/server/ssh/cmd/repo.go b/server/ssh/cmd/repo.go index fc23844fb..0b5ff3e5d 100644 --- a/server/ssh/cmd/repo.go +++ b/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"}, diff --git a/server/ssh/cmd/set_username.go b/server/ssh/cmd/set_username.go index 71243ac5f..c3841f82e 100644 --- a/server/ssh/cmd/set_username.go +++ b/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", diff --git a/server/ssh/cmd/settings.go b/server/ssh/cmd/settings.go index 0fa4cace8..4131fb2b1 100644 --- a/server/ssh/cmd/settings.go +++ b/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", diff --git a/server/ssh/cmd/token.go b/server/ssh/cmd/token.go index cb3daad6f..3fefaa8cd 100644 --- a/server/ssh/cmd/token.go +++ b/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"}, diff --git a/server/ssh/cmd/user.go b/server/ssh/cmd/user.go index 639bf909a..f8b0a986d 100644 --- a/server/ssh/cmd/user.go +++ b/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"}, diff --git a/server/ssh/git.go b/server/ssh/git.go deleted file mode 100644 index b1a269a49..000000000 --- a/server/ssh/git.go +++ /dev/null @@ -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 - } - } -} diff --git a/server/ssh/logger.go b/server/ssh/logger.go deleted file mode 100644 index c5b972f0b..000000000 --- a/server/ssh/logger.go +++ /dev/null @@ -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...) - } -} diff --git a/server/ssh/middleware.go b/server/ssh/middleware.go index 909c499d4..209ed1dd0 100644 --- a/server/ssh/middleware.go +++ b/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) diff --git a/server/ssh/ssh.go b/server/ssh/ssh.go index 297973ebf..bd4fd4a89 100644 --- a/server/ssh/ssh.go +++ b/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 -} diff --git a/server/sshutils/utils.go b/server/sshutils/utils.go index 6bba64578..2686ac7bc 100644 --- a/server/sshutils/utils.go +++ b/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 +} diff --git a/server/web/auth.go b/server/web/auth.go index fb090fa63..bbbe88b3f 100644 --- a/server/web/auth.go +++ b/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 diff --git a/server/web/git.go b/server/web/git.go index d2d640c5f..121b85795 100644 --- a/server/web/git.go +++ b/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"]) diff --git a/testscript/testdata/help.txtar b/testscript/testdata/help.txtar index 58868b7cc..257ad2447 100644 --- a/testscript/testdata/help.txtar +++ b/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 diff --git a/testscript/testdata/ssh.txtar b/testscript/testdata/ssh.txtar new file mode 100644 index 000000000..b4e223739 --- /dev/null +++ b/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