Detailed changes
@@ -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)
- }
-}
@@ -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
}
@@ -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")
+}
@@ -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)
@@ -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",
@@ -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)
@@ -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)
@@ -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"},
@@ -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",
@@ -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",
@@ -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"},
@@ -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"},
@@ -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
- }
- }
-}
@@ -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...)
- }
-}
@@ -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)
@@ -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
-}
@@ -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
+}
@@ -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
@@ -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"])
@@ -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
@@ -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