From 227f178ad2ca13ae909065cce734d77172664930 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 29 Mar 2023 16:41:15 -0400 Subject: [PATCH] feat(server): add git hooks --- cmd/soft/hook.go | 215 ++++++++++++++++++++++++++++++++++++ cmd/soft/root.go | 1 + go.mod | 1 + server/backend/file/file.go | 179 +++++++++++++++++++++++++++++- server/cmd/blob.go | 2 +- server/cmd/cmd.go | 15 ++- server/cmd/create.go | 2 +- server/cmd/delete.go | 2 +- server/cmd/description.go | 2 +- server/cmd/hook.go | 145 ++++++++++++++++++++++++ server/cmd/list.go | 2 +- server/cmd/private.go | 2 +- server/cmd/rename.go | 3 +- server/cmd/setting.go | 7 +- server/cmd/tree.go | 2 +- server/daemon.go | 13 +-- server/daemon_test.go | 2 +- server/git.go | 64 +++-------- server/hooks.go | 52 +++++++++ server/hooks/hooks.go | 18 +++ server/server.go | 4 +- server/server_test.go | 3 - server/session_test.go | 2 +- server/ssh.go | 26 ++--- 24 files changed, 674 insertions(+), 90 deletions(-) create mode 100644 cmd/soft/hook.go create mode 100644 server/cmd/hook.go create mode 100644 server/hooks.go create mode 100644 server/hooks/hooks.go diff --git a/cmd/soft/hook.go b/cmd/soft/hook.go new file mode 100644 index 000000000..ec51aac50 --- /dev/null +++ b/cmd/soft/hook.go @@ -0,0 +1,215 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/charmbracelet/keygen" + "github.com/charmbracelet/soft-serve/server/config" + "github.com/spf13/cobra" + gossh "golang.org/x/crypto/ssh" +) + +var ( + configPath string + + hookCmd = &cobra.Command{ + Use: "hook", + Short: "Run git server hooks", + Long: "Handles git server hooks. This includes pre-receive, update, and post-receive.", + Hidden: true, + } + + preReceiveCmd = &cobra.Command{ + Use: "pre-receive", + Short: "Run git pre-receive hook", + RunE: func(cmd *cobra.Command, args []string) error { + c, s, err := commonInit() + if err != nil { + return err + } + defer c.Close() //nolint:errcheck + defer s.Close() //nolint:errcheck + in, err := s.StdinPipe() + if err != nil { + return err + } + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + in.Write([]byte(scanner.Text())) + in.Write([]byte("\n")) + } + in.Close() //nolint:errcheck + b, err := s.Output("hook pre-receive") + if err != nil { + return err + } + cmd.Print(string(b)) + return nil + }, + } + + updateCmd = &cobra.Command{ + Use: "update", + Short: "Run git update hook", + Args: cobra.ExactArgs(3), + RunE: func(cmd *cobra.Command, args []string) error { + refName := args[0] + oldSha := args[1] + newSha := args[2] + c, s, err := commonInit() + if err != nil { + return err + } + defer c.Close() //nolint:errcheck + defer s.Close() //nolint:errcheck + b, err := s.Output(fmt.Sprintf("hook update %s %s %s", refName, oldSha, newSha)) + if err != nil { + return err + } + cmd.Print(string(b)) + return nil + }, + } + + postReceiveCmd = &cobra.Command{ + Use: "post-receive", + Short: "Run git post-receive hook", + RunE: func(cmd *cobra.Command, args []string) error { + c, s, err := commonInit() + if err != nil { + return err + } + defer c.Close() //nolint:errcheck + defer s.Close() //nolint:errcheck + in, err := s.StdinPipe() + if err != nil { + return err + } + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + in.Write([]byte(scanner.Text())) + in.Write([]byte("\n")) + } + in.Close() //nolint:errcheck + b, err := s.Output("hook post-receive") + if err != nil { + return err + } + cmd.Print(string(b)) + return nil + }, + } + + postUpdateCmd = &cobra.Command{ + Use: "post-update", + Short: "Run git post-update hook", + RunE: func(cmd *cobra.Command, args []string) error { + c, s, err := commonInit() + if err != nil { + return err + } + defer c.Close() //nolint:errcheck + defer s.Close() //nolint:errcheck + b, err := s.Output(fmt.Sprintf("hook post-update %s", strings.Join(args, " "))) + if err != nil { + return err + } + cmd.Print(string(b)) + return nil + }, + } +) + +func init() { + hookCmd.AddCommand( + preReceiveCmd, + updateCmd, + postReceiveCmd, + postUpdateCmd, + ) + + hookCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "", "path to config file") +} + +func commonInit() (c *gossh.Client, s *gossh.Session, err error) { + cfg, err := config.ParseConfig(configPath) + if err != nil { + return + } + + // Use absolute path. + cfg.DataPath = filepath.Dir(configPath) + + // Git runs the hook within the repository's directory. + // Get the working directory to determine the repository name. + wd, err := os.Getwd() + if err != nil { + return + } + + rs, err := filepath.Abs(filepath.Join(cfg.DataPath, "repos")) + if err != nil { + return + } + + if !strings.HasPrefix(wd, rs) { + err = fmt.Errorf("hook must be run from within repository directory") + return + } + repoName := strings.TrimPrefix(wd, rs) + repoName = strings.TrimPrefix(repoName, fmt.Sprintf("%c", os.PathSeparator)) + c, err = newClient(cfg) + if err != nil { + return + } + s, err = newSession(c) + if err != nil { + return + } + s.Setenv("SOFT_SERVE_REPO_NAME", repoName) + return +} + +func newClient(cfg *config.Config) (*gossh.Client, error) { + // Only accept the server's host key. + pk, err := keygen.New(filepath.Join(cfg.DataPath, cfg.SSH.KeyPath), nil, keygen.Ed25519) + if err != nil { + return nil, err + } + hostKey, err := gossh.ParsePrivateKey(pk.PrivateKeyPEM()) + if err != nil { + return nil, err + } + ik, err := keygen.New(filepath.Join(cfg.DataPath, cfg.SSH.InternalKeyPath), nil, keygen.Ed25519) + if err != nil { + return nil, err + } + k, err := gossh.ParsePrivateKey(ik.PrivateKeyPEM()) + if err != nil { + return nil, err + } + cc := &gossh.ClientConfig{ + User: "internal", + Auth: []gossh.AuthMethod{ + gossh.PublicKeys(k), + }, + HostKeyCallback: gossh.FixedHostKey(hostKey.PublicKey()), + } + c, err := gossh.Dial("tcp", cfg.SSH.ListenAddr, cc) + if err != nil { + return nil, err + } + return c, nil +} + +func newSession(c *gossh.Client) (*gossh.Session, error) { + s, err := c.NewSession() + if err != nil { + return nil, err + } + return s, nil +} diff --git a/cmd/soft/root.go b/cmd/soft/root.go index 1f4cbeacd..fe7cb6540 100644 --- a/cmd/soft/root.go +++ b/cmd/soft/root.go @@ -31,6 +31,7 @@ func init() { rootCmd.AddCommand( serveCmd, manCmd, + hookCmd, ) rootCmd.CompletionOptions.HiddenDefaultCmd = true diff --git a/go.mod b/go.mod index f2088542e..ea287b8a7 100644 --- a/go.mod +++ b/go.mod @@ -32,6 +32,7 @@ require ( goji.io v2.0.2+incompatible golang.org/x/crypto v0.7.0 golang.org/x/sync v0.1.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( diff --git a/server/backend/file/file.go b/server/backend/file/file.go index aa0465b3f..9f855db57 100644 --- a/server/backend/file/file.go +++ b/server/backend/file/file.go @@ -20,8 +20,10 @@ package file import ( "bufio" + "bytes" "errors" "fmt" + "html/template" "io" "io/fs" "os" @@ -584,18 +586,31 @@ func (fb *FileBackend) CreateRepository(repo string, private bool) (backend.Repo return nil, os.ErrExist } - if _, err := git.Init(rp, true); err != nil { + rr, err := git.Init(rp, true) + if err != nil { logger.Debug("failed to create repository", "err", err) return nil, err } - fb.SetPrivate(repo, private) - fb.SetDescription(repo, "") + if err := rr.UpdateServerInfo(); err != nil { + logger.Debug("failed to update server info", "err", err) + return nil, err + } + + if err := fb.SetPrivate(repo, private); err != nil { + logger.Debug("failed to set private status", "err", err) + return nil, err + } + + if err := fb.SetDescription(repo, ""); err != nil { + logger.Debug("failed to set description", "err", err) + return nil, err + } r := &Repo{path: rp, root: fb.reposPath()} // Add to cache. fb.repos[name] = r - return r, nil + return r, fb.InitializeHooks(name) } // DeleteRepository deletes the given repository. @@ -687,6 +702,9 @@ func (fb *FileBackend) initRepos() error { r := &Repo{path: path, root: fb.reposPath()} fb.repos[r.Name()] = r repos = append(repos, r) + if err := fb.InitializeHooks(r.Name()); err != nil { + logger.Warn("failed to initialize hooks", "err", err, "repo", r.Name()) + } } return nil @@ -709,3 +727,156 @@ func (fb *FileBackend) Repositories() ([]backend.Repository, error) { return repos, nil } + +var ( + hookNames = []string{"pre-receive", "update", "post-update", "post-receive"} + hookTpls = []string{ + // for pre-receive + `#!/usr/bin/env bash +# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY +data=$(cat) +exitcodes="" +hookname=$(basename $0) +GIT_DIR=${GIT_DIR:-$(dirname $0)/..} +for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do + test -x "${hook}" && test -f "${hook}" || continue + echo "${data}" | "${hook}" + exitcodes="${exitcodes} $?" +done +for i in ${exitcodes}; do + [ ${i} -eq 0 ] || exit ${i} +done +`, + + // for update + `#!/usr/bin/env bash +# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY +exitcodes="" +hookname=$(basename $0) +GIT_DIR=${GIT_DIR:-$(dirname $0/..)} +for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do + test -x "${hook}" && test -f "${hook}" || continue + "${hook}" $1 $2 $3 + exitcodes="${exitcodes} $?" +done +for i in ${exitcodes}; do + [ ${i} -eq 0 ] || exit ${i} +done +`, + + // for post-update + `#!/usr/bin/env bash +# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY +data=$(cat) +exitcodes="" +hookname=$(basename $0) +GIT_DIR=${GIT_DIR:-$(dirname $0)/..} +for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do + test -x "${hook}" && test -f "${hook}" || continue + "${hook}" $@ + exitcodes="${exitcodes} $?" +done +for i in ${exitcodes}; do + [ ${i} -eq 0 ] || exit ${i} +done +`, + + // for post-receive + `#!/usr/bin/env bash +# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY +data=$(cat) +exitcodes="" +hookname=$(basename $0) +GIT_DIR=${GIT_DIR:-$(dirname $0)/..} +for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do + test -x "${hook}" && test -f "${hook}" || continue + echo "${data}" | "${hook}" + exitcodes="${exitcodes} $?" +done +for i in ${exitcodes}; do + [ ${i} -eq 0 ] || exit ${i} +done +`, + } +) + +// InitializeHooks updates the hooks for the given repository. +// +// It implements backend.Backend. +func (fb *FileBackend) InitializeHooks(repo string) error { + hookTmpl, err := template.New("hook").Parse(`#!/usr/bin/env bash +# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY +{{ range $_, $env := .Envs }} +{{ $env }} \{{ end }} +{{ .Executable }} hook --config "{{ .Config }}" {{ .Hook }} {{ .Args }} +`) + if err != nil { + return err + } + + repo = utils.SanitizeRepo(repo) + ".git" + hooksPath := filepath.Join(fb.reposPath(), repo, "hooks") + if err := os.MkdirAll(hooksPath, 0755); err != nil { + return err + } + + ex, err := os.Executable() + if err != nil { + return err + } + + dp, err := filepath.Abs(fb.path) + if err != nil { + return fmt.Errorf("failed to get absolute path for data path: %w", err) + } + + cp := filepath.Join(dp, "config.yaml") + envs := []string{} + for i, hook := range hookNames { + var data bytes.Buffer + var args string + hp := filepath.Join(hooksPath, hook) + if err := os.WriteFile(hp, []byte(hookTpls[i]), 0755); err != nil { + return err + } + + // Create hook.d directory. + hp += ".d" + if err := os.MkdirAll(hp, 0755); err != nil { + return err + } + + if hook == "update" { + args = "$1 $2 $3" + } else if hook == "post-update" { + args = "$@" + } + + err = hookTmpl.Execute(&data, struct { + Executable string + Hook string + Args string + Envs []string + Config string + }{ + Executable: ex, + Hook: hook, + Args: args, + Envs: envs, + Config: cp, + }) + if err != nil { + logger.Error("failed to execute hook template", "err", err) + continue + } + + hp = filepath.Join(hp, "soft-serve") + err = os.WriteFile(hp, data.Bytes(), 0755) //nolint:gosec + if err != nil { + logger.Error("failed to write hook", "err", err) + continue + } + } + + return nil +} diff --git a/server/cmd/blob.go b/server/cmd/blob.go index 58e2361f9..187ee07b2 100644 --- a/server/cmd/blob.go +++ b/server/cmd/blob.go @@ -30,7 +30,7 @@ func blobCommand() *cobra.Command { cmd := &cobra.Command{ Use: "blob REPOSITORY [REFERENCE] [PATH]", Aliases: []string{"cat", "show"}, - Short: "Print out the contents of file at path.", + Short: "Print out the contents of file at path", Args: cobra.RangeArgs(1, 3), PersistentPreRunE: checkIfReadable, RunE: func(cmd *cobra.Command, args []string) error { diff --git a/server/cmd/cmd.go b/server/cmd/cmd.go index a23ee8afd..461a9041f 100644 --- a/server/cmd/cmd.go +++ b/server/cmd/cmd.go @@ -4,8 +4,10 @@ import ( "context" "fmt" + "github.com/charmbracelet/log" "github.com/charmbracelet/soft-serve/server/backend" "github.com/charmbracelet/soft-serve/server/config" + "github.com/charmbracelet/soft-serve/server/hooks" "github.com/charmbracelet/soft-serve/server/utils" "github.com/charmbracelet/ssh" "github.com/charmbracelet/wish" @@ -25,6 +27,8 @@ var ( ConfigCtxKey = ContextKey("config") // SessionCtxKey is the key for the session in the context. SessionCtxKey = ContextKey("session") + // HooksCtxKey is the key for the git hooks in the context. + HooksCtxKey = ContextKey("hooks") ) var ( @@ -36,6 +40,10 @@ var ( ErrFileNotFound = fmt.Errorf("File not found") ) +var ( + logger = log.WithPrefix("server.cmd") +) + // rootCommand is the root command for the server. func rootCommand() *cobra.Command { rootCmd := &cobra.Command{ @@ -47,15 +55,17 @@ func rootCommand() *cobra.Command { rootCmd.CompletionOptions.DisableDefaultCmd = true rootCmd.AddCommand( adminCommand(), + blobCommand(), branchCommand(), collabCommand(), createCommand(), deleteCommand(), descriptionCommand(), + hookCommand(), listCommand(), privateCommand(), renameCommand(), - blobCommand(), + settingCommand(), tagCommand(), treeCommand(), ) @@ -107,7 +117,7 @@ func checkIfCollab(cmd *cobra.Command, args []string) error { } // Middleware is the Soft Serve middleware that handles SSH commands. -func Middleware(cfg *config.Config) wish.Middleware { +func Middleware(cfg *config.Config, hooks hooks.Hooks) wish.Middleware { return func(sh ssh.Handler) ssh.Handler { return func(s ssh.Session) { func() { @@ -128,6 +138,7 @@ func Middleware(cfg *config.Config) wish.Middleware { ctx := context.WithValue(s.Context(), ConfigCtxKey, cfg) ctx = context.WithValue(ctx, SessionCtxKey, s) + ctx = context.WithValue(ctx, HooksCtxKey, hooks) rootCmd := rootCommand() rootCmd.SetArgs(args) diff --git a/server/cmd/create.go b/server/cmd/create.go index 89c261d12..d7cfdb04d 100644 --- a/server/cmd/create.go +++ b/server/cmd/create.go @@ -8,7 +8,7 @@ func createCommand() *cobra.Command { var description string cmd := &cobra.Command{ Use: "create REPOSITORY", - Short: "Create a new repository.", + Short: "Create a new repository", Args: cobra.ExactArgs(1), PersistentPreRunE: checkIfAdmin, RunE: func(cmd *cobra.Command, args []string) error { diff --git a/server/cmd/delete.go b/server/cmd/delete.go index 8dd94a645..7c335ac6c 100644 --- a/server/cmd/delete.go +++ b/server/cmd/delete.go @@ -6,7 +6,7 @@ func deleteCommand() *cobra.Command { cmd := &cobra.Command{ Use: "delete REPOSITORY", Aliases: []string{"del", "remove", "rm"}, - Short: "Delete a repository.", + Short: "Delete a repository", Args: cobra.ExactArgs(1), PersistentPreRunE: checkIfAdmin, RunE: func(cmd *cobra.Command, args []string) error { diff --git a/server/cmd/description.go b/server/cmd/description.go index 2eb59c5eb..bafabbfe8 100644 --- a/server/cmd/description.go +++ b/server/cmd/description.go @@ -10,7 +10,7 @@ func descriptionCommand() *cobra.Command { cmd := &cobra.Command{ Use: "description REPOSITORY [DESCRIPTION]", Aliases: []string{"desc"}, - Short: "Set or get the description for a repository.", + Short: "Set or get the description for a repository", Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { cfg, _ := fromContext(cmd) diff --git a/server/cmd/hook.go b/server/cmd/hook.go new file mode 100644 index 000000000..afd4ae2f1 --- /dev/null +++ b/server/cmd/hook.go @@ -0,0 +1,145 @@ +package cmd + +import ( + "bufio" + "fmt" + "path/filepath" + "strings" + + "github.com/charmbracelet/keygen" + "github.com/charmbracelet/soft-serve/server/hooks" + "github.com/charmbracelet/ssh" + "github.com/spf13/cobra" + gossh "golang.org/x/crypto/ssh" +) + +// hookCommand handles Soft Serve internal API git hook requests. +func hookCommand() *cobra.Command { + preReceiveCmd := &cobra.Command{ + Use: "pre-receive", + Short: "Run git pre-receive hook", + PersistentPreRunE: checkIfInternal, + RunE: func(cmd *cobra.Command, args []string) error { + _, s := fromContext(cmd) + hks := cmd.Context().Value(HooksCtxKey).(hooks.Hooks) + repoName := getRepoName(s) + opts := make([]hooks.HookArg, 0) + scanner := bufio.NewScanner(s) + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + if len(fields) != 3 { + return fmt.Errorf("invalid pre-receive hook input: %s", scanner.Text()) + } + opts = append(opts, hooks.HookArg{ + OldSha: fields[0], + NewSha: fields[1], + RefName: fields[2], + }) + } + hks.PreReceive(s, s.Stderr(), repoName, opts) + return nil + }, + } + + updateCmd := &cobra.Command{ + Use: "update", + Short: "Run git update hook", + Args: cobra.ExactArgs(3), + PersistentPreRunE: checkIfInternal, + RunE: func(cmd *cobra.Command, args []string) error { + _, s := fromContext(cmd) + hks := cmd.Context().Value(HooksCtxKey).(hooks.Hooks) + repoName := getRepoName(s) + hks.Update(s, s.Stderr(), repoName, hooks.HookArg{ + RefName: args[0], + OldSha: args[1], + NewSha: args[2], + }) + return nil + }, + } + + postReceiveCmd := &cobra.Command{ + Use: "post-receive", + Short: "Run git post-receive hook", + PersistentPreRunE: checkIfInternal, + RunE: func(cmd *cobra.Command, _ []string) error { + _, s := fromContext(cmd) + hks := cmd.Context().Value(HooksCtxKey).(hooks.Hooks) + repoName := getRepoName(s) + opts := make([]hooks.HookArg, 0) + scanner := bufio.NewScanner(s) + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + if len(fields) != 3 { + return fmt.Errorf("invalid post-receive hook input: %s", scanner.Text()) + } + opts = append(opts, hooks.HookArg{ + OldSha: fields[0], + NewSha: fields[1], + RefName: fields[2], + }) + } + hks.PostReceive(s, s.Stderr(), repoName, opts) + return nil + }, + } + + postUpdateCmd := &cobra.Command{ + Use: "post-update", + Short: "Run git post-update hook", + PersistentPreRunE: checkIfInternal, + RunE: func(cmd *cobra.Command, args []string) error { + _, s := fromContext(cmd) + hks := cmd.Context().Value(HooksCtxKey).(hooks.Hooks) + repoName := getRepoName(s) + hks.PostUpdate(s, s.Stderr(), repoName, args...) + return nil + }, + } + + hookCmd := &cobra.Command{ + Use: "hook", + Short: "Run git server hooks", + Hidden: true, + SilenceUsage: true, + } + + hookCmd.AddCommand( + preReceiveCmd, + updateCmd, + postReceiveCmd, + postUpdateCmd, + ) + + return hookCmd +} + +// Check if the session's public key matches the internal API key. +func checkIfInternal(cmd *cobra.Command, _ []string) error { + cfg, s := fromContext(cmd) + pk := s.PublicKey() + kp, err := keygen.New(filepath.Join(cfg.DataPath, cfg.SSH.InternalKeyPath), nil, keygen.Ed25519) + if err != nil { + logger.Errorf("failed to read internal key: %v", err) + return err + } + priv, err := gossh.ParsePrivateKey(kp.PrivateKeyPEM()) + if err != nil { + return err + } + if !ssh.KeysEqual(pk, priv.PublicKey()) { + return ErrUnauthorized + } + return nil +} + +func getRepoName(s ssh.Session) string { + var repoName string + for _, env := range s.Environ() { + if strings.HasPrefix(env, "SOFT_SERVE_REPO_NAME=") { + return strings.TrimPrefix(env, "SOFT_SERVE_REPO_NAME=") + } + } + return repoName +} diff --git a/server/cmd/list.go b/server/cmd/list.go index 8ed558a15..7b6626c56 100644 --- a/server/cmd/list.go +++ b/server/cmd/list.go @@ -10,7 +10,7 @@ func listCommand() *cobra.Command { listCmd := &cobra.Command{ Use: "list", Aliases: []string{"ls"}, - Short: "List repositories.", + Short: "List repositories", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { cfg, s := fromContext(cmd) diff --git a/server/cmd/private.go b/server/cmd/private.go index 593845dfb..83e8e77ca 100644 --- a/server/cmd/private.go +++ b/server/cmd/private.go @@ -10,7 +10,7 @@ import ( func privateCommand() *cobra.Command { cmd := &cobra.Command{ Use: "private REPOSITORY [true|false]", - Short: "Set or get a repository private property.", + Short: "Set or get a repository private property", Args: cobra.RangeArgs(1, 2), RunE: func(cmd *cobra.Command, args []string) error { cfg, _ := fromContext(cmd) diff --git a/server/cmd/rename.go b/server/cmd/rename.go index a5b88d83a..d3ab7b0ac 100644 --- a/server/cmd/rename.go +++ b/server/cmd/rename.go @@ -5,7 +5,8 @@ import "github.com/spf13/cobra" func renameCommand() *cobra.Command { cmd := &cobra.Command{ Use: "rename REPOSITORY NEW_NAME", - Short: "Rename an existing repository.", + Aliases: []string{"mv", "move"}, + Short: "Rename an existing repository", Args: cobra.ExactArgs(2), PersistentPreRunE: checkIfCollab, RunE: func(cmd *cobra.Command, args []string) error { diff --git a/server/cmd/setting.go b/server/cmd/setting.go index 8faea89c5..eaa55942e 100644 --- a/server/cmd/setting.go +++ b/server/cmd/setting.go @@ -11,7 +11,7 @@ import ( func settingCommand() *cobra.Command { cmd := &cobra.Command{ Use: "setting", - Short: "Manage settings", + Short: "Manage server settings", } cmd.AddCommand( @@ -37,12 +37,13 @@ func settingCommand() *cobra.Command { }, ) + als := []string{backend.NoAccess.String(), backend.ReadOnlyAccess.String(), backend.ReadWriteAccess.String(), backend.AdminAccess.String()} cmd.AddCommand( &cobra.Command{ Use: "anon-access [ACCESS_LEVEL]", Short: "Set or get the default access level for anonymous users", Args: cobra.RangeArgs(0, 1), - ValidArgs: []string{backend.NoAccess.String(), backend.ReadOnlyAccess.String(), backend.ReadWriteAccess.String(), backend.AdminAccess.String()}, + ValidArgs: als, PersistentPreRunE: checkIfAdmin, RunE: func(cmd *cobra.Command, args []string) error { cfg, _ := fromContext(cmd) @@ -52,7 +53,7 @@ func settingCommand() *cobra.Command { case 1: al := backend.ParseAccessLevel(args[0]) if al < 0 { - return fmt.Errorf("invalid access level: %s", args[0]) + return fmt.Errorf("invalid access level: %s. Please choose one of the following: %s", args[0], als) } if err := cfg.Backend.SetAnonAccess(al); err != nil { return err diff --git a/server/cmd/tree.go b/server/cmd/tree.go index 43be304c6..0ca08a922 100644 --- a/server/cmd/tree.go +++ b/server/cmd/tree.go @@ -12,7 +12,7 @@ import ( func treeCommand() *cobra.Command { cmd := &cobra.Command{ Use: "tree REPOSITORY [REFERENCE] [PATH]", - Short: "Print repository tree at path.", + Short: "Print repository tree at path", Args: cobra.RangeArgs(1, 3), PersistentPreRunE: checkIfReadable, RunE: func(cmd *cobra.Command, args []string) error { diff --git a/server/daemon.go b/server/daemon.go index 86ad93e41..6424988d7 100644 --- a/server/daemon.go +++ b/server/daemon.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "fmt" - "io" "net" "path/filepath" "sync" @@ -129,7 +128,7 @@ func (d *GitDaemon) Start() error { } func fatal(c net.Conn, err error) { - WritePktline(c, err) + writePktline(c, err) if err := c.Close(); err != nil { logger.Debugf("git: error closing connection: %v", err) } @@ -184,13 +183,13 @@ func (d *GitDaemon) handleClient(conn net.Conn) { return } - var gitPack func(io.Reader, io.Writer, io.Writer, string) error + gitPack := uploadPack cmd := string(split[0]) switch cmd { - case UploadPackBin: - gitPack = UploadPack - case UploadArchiveBin: - gitPack = UploadArchive + case uploadPackBin: + gitPack = uploadPack + case uploadArchiveBin: + gitPack = uploadArchive default: fatal(c, ErrInvalidRequest) return diff --git a/server/daemon_test.go b/server/daemon_test.go index 220624000..d78165372 100644 --- a/server/daemon_test.go +++ b/server/daemon_test.go @@ -35,7 +35,7 @@ func TestMain(m *testing.M) { if err != nil { log.Fatal(err) } - cfg := config.DefaultConfig().WithBackend(fb).WithAccessMethod(fb) + cfg := config.DefaultConfig().WithBackend(fb) d, err := NewGitDaemon(cfg) if err != nil { log.Fatal(err) diff --git a/server/git.go b/server/git.go index 1748f1ec4..41a1609f1 100644 --- a/server/git.go +++ b/server/git.go @@ -36,13 +36,13 @@ var ( // Git protocol commands. const ( - ReceivePackBin = "git-receive-pack" - UploadPackBin = "git-upload-pack" - UploadArchiveBin = "git-upload-archive" + receivePackBin = "git-receive-pack" + uploadPackBin = "git-upload-pack" + uploadArchiveBin = "git-upload-archive" ) -// UploadPack runs the git upload-pack protocol against the provided repo. -func UploadPack(in io.Reader, out io.Writer, er io.Writer, repoDir string) error { +// uploadPack runs the git upload-pack protocol against the provided repo. +func uploadPack(in io.Reader, out io.Writer, er io.Writer, repoDir string) error { exists, err := fileExists(repoDir) if !exists { return ErrInvalidRepo @@ -50,11 +50,11 @@ func UploadPack(in io.Reader, out io.Writer, er io.Writer, repoDir string) error if err != nil { return err } - return RunGit(in, out, er, "", UploadPackBin[4:], repoDir) + return runGit(in, out, er, "", uploadPackBin[4:], repoDir) } -// UploadArchive runs the git upload-archive protocol against the provided repo. -func UploadArchive(in io.Reader, out io.Writer, er io.Writer, repoDir string) error { +// uploadArchive runs the git upload-archive protocol against the provided repo. +func uploadArchive(in io.Reader, out io.Writer, er io.Writer, repoDir string) error { exists, err := fileExists(repoDir) if !exists { return ErrInvalidRepo @@ -62,22 +62,19 @@ func UploadArchive(in io.Reader, out io.Writer, er io.Writer, repoDir string) er if err != nil { return err } - return RunGit(in, out, er, "", UploadArchiveBin[4:], repoDir) + return runGit(in, out, er, "", uploadArchiveBin[4:], repoDir) } -// ReceivePack runs the git receive-pack protocol against the provided repo. -func ReceivePack(in io.Reader, out io.Writer, er io.Writer, repoDir string) error { - if err := ensureRepo(repoDir, ""); err != nil { - return err - } - if err := RunGit(in, out, er, "", ReceivePackBin[4:], repoDir); err != nil { +// receivePack runs the git receive-pack protocol against the provided repo. +func receivePack(in io.Reader, out io.Writer, er io.Writer, repoDir string) error { + if err := runGit(in, out, er, "", receivePackBin[4:], repoDir); err != nil { return err } return ensureDefaultBranch(in, out, er, repoDir) } -// RunGit runs a git command in the given repo. -func RunGit(in io.Reader, out io.Writer, err io.Writer, dir string, args ...string) error { +// runGit runs a git command in the given repo. +func runGit(in io.Reader, out io.Writer, err io.Writer, dir string, args ...string) error { c := git.NewCommand(args...) return c.RunInDirWithOptions(dir, git.RunInDirOptions{ Stdin: in, @@ -86,8 +83,8 @@ func RunGit(in io.Reader, out io.Writer, err io.Writer, dir string, args ...stri }) } -// WritePktline encodes and writes a pktline to the given writer. -func WritePktline(w io.Writer, v ...interface{}) { +// writePktline encodes and writes a pktline to the given writer. +func writePktline(w io.Writer, v ...interface{}) { msg := fmt.Sprintln(v...) pkt := pktline.NewEncoder(w) if err := pkt.EncodeString(msg); err != nil { @@ -132,32 +129,6 @@ func fileExists(path string) (bool, error) { return true, err } -func ensureRepo(dir string, repo string) error { - exists, err := fileExists(dir) - if err != nil { - return err - } - if !exists { - err = os.MkdirAll(dir, os.ModeDir|os.FileMode(0700)) - if err != nil { - return err - } - } - rp := filepath.Join(dir, repo) - exists, err = fileExists(rp) - if err != nil { - return err - } - // FIXME: use backend.CreateRepository - if !exists { - _, err := git.Init(rp, true) - if err != nil { - return err - } - } - return nil -} - func ensureDefaultBranch(in io.Reader, out io.Writer, er io.Writer, repoPath string) error { r, err := git.Open(repoPath) if err != nil { @@ -173,8 +144,7 @@ func ensureDefaultBranch(in io.Reader, out io.Writer, er io.Writer, repoPath str // Rename the default branch to the first branch available _, err = r.HEAD() if err == git.ErrReferenceNotExist { - // FIXME: use backend.SetDefaultBranch - err = RunGit(in, out, er, repoPath, "branch", "-M", brs[0]) + err = runGit(in, out, er, repoPath, "branch", "-M", brs[0]) if err != nil { return err } diff --git a/server/hooks.go b/server/hooks.go new file mode 100644 index 000000000..a4f9ed568 --- /dev/null +++ b/server/hooks.go @@ -0,0 +1,52 @@ +package server + +import ( + "io" + + "github.com/charmbracelet/soft-serve/server/hooks" +) + +var _ hooks.Hooks = (*Server)(nil) + +// PostReceive is called by the git post-receive hook. +// +// It implements Hooks. +func (*Server) PostReceive(stdout io.Writer, stderr io.Writer, repo string, args []hooks.HookArg) { + logger.Debug("post-receive hook called", "repo", repo, "args", args) +} + +// PreReceive is called by the git pre-receive hook. +// +// It implements Hooks. +func (*Server) PreReceive(stdout io.Writer, stderr io.Writer, repo string, args []hooks.HookArg) { + logger.Debug("pre-receive hook called", "repo", repo, "args", args) +} + +// Update is called by the git update hook. +// +// It implements Hooks. +func (*Server) Update(stdout io.Writer, stderr io.Writer, repo string, arg hooks.HookArg) { + logger.Debug("update hook called", "repo", repo, "arg", arg) +} + +// PostUpdate is called by the git post-update hook. +// +// It implements Hooks. +func (s *Server) PostUpdate(stdout io.Writer, stderr io.Writer, repo string, args ...string) { + rr, err := s.Config.Backend.Repository(repo) + if err != nil { + logger.WithPrefix("server.hooks.post-update").Error("error getting repository", "repo", repo, "err", err) + return + } + + r, err := rr.Open() + if err != nil { + logger.WithPrefix("server.hooks.post-update").Error("error opening repository", "repo", repo, "err", err) + return + } + + if err := r.UpdateServerInfo(); err != nil { + logger.WithPrefix("server.hooks.post-update").Error("error updating server info", "repo", repo, "err", err) + return + } +} diff --git a/server/hooks/hooks.go b/server/hooks/hooks.go new file mode 100644 index 000000000..fb47b729b --- /dev/null +++ b/server/hooks/hooks.go @@ -0,0 +1,18 @@ +package hooks + +import "io" + +// HookArg is an argument to a git hook. +type HookArg struct { + OldSha string + NewSha string + RefName string +} + +// Hooks provides an interface for git server-side hooks. +type Hooks interface { + PreReceive(stdout io.Writer, stderr io.Writer, repo string, args []HookArg) + Update(stdout io.Writer, stderr io.Writer, repo string, arg HookArg) + PostReceive(stdout io.Writer, stderr io.Writer, repo string, args []HookArg) + PostUpdate(stdout io.Writer, stderr io.Writer, repo string, args ...string) +} diff --git a/server/server.go b/server/server.go index e3f66d3c5..eb62692f1 100644 --- a/server/server.go +++ b/server/server.go @@ -3,7 +3,9 @@ package server import ( "context" "net/http" + "path/filepath" + "github.com/charmbracelet/keygen" "github.com/charmbracelet/log" "github.com/charmbracelet/soft-serve/server/backend" @@ -57,7 +59,7 @@ func NewServer(cfg *config.Config) (*Server, error) { Config: cfg, Backend: cfg.Backend, } - srv.SSHServer, err = NewSSHServer(cfg) + srv.SSHServer, err = NewSSHServer(cfg, srv) if err != nil { return nil, err } diff --git a/server/server_test.go b/server/server_test.go index b3bdb9ebb..5505bec56 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -8,7 +8,6 @@ import ( "testing" "github.com/charmbracelet/keygen" - "github.com/charmbracelet/soft-serve/server/backend/noop" "github.com/charmbracelet/soft-serve/server/config" "github.com/charmbracelet/ssh" "github.com/matryer/is" @@ -32,9 +31,7 @@ func setupServer(tb testing.TB) (*Server, *config.Config, string) { tb.Setenv("SOFT_SERVE_SSH_LISTEN_ADDR", sshPort) tb.Setenv("SOFT_SERVE_GIT_LISTEN_ADDR", fmt.Sprintf(":%d", randomPort())) cfg := config.DefaultConfig() - nop := &noop.Noop{Port: sshPort[1:]} tb.Log("configuring server") - cfg = cfg.WithBackend(nop).WithAccessMethod(nop) s, err := NewServer(cfg) if err != nil { tb.Fatal(err) diff --git a/server/session_test.go b/server/session_test.go index a120321a2..542d312c6 100644 --- a/server/session_test.go +++ b/server/session_test.go @@ -56,7 +56,7 @@ func setup(tb testing.TB) *gossh.Session { if err != nil { log.Fatal(err) } - cfg := config.DefaultConfig().WithBackend(fb).WithAccessMethod(fb) + cfg := config.DefaultConfig().WithBackend(fb) return testsession.New(tb, &ssh.Server{ Handler: bm.MiddlewareWithProgramHandler(SessionHandler(cfg), termenv.ANSI256)(func(s ssh.Session) { _, _, active := s.Pty() diff --git a/server/ssh.go b/server/ssh.go index 61e8f7f54..6a73e935a 100644 --- a/server/ssh.go +++ b/server/ssh.go @@ -12,6 +12,7 @@ import ( "github.com/charmbracelet/soft-serve/server/backend" cm "github.com/charmbracelet/soft-serve/server/cmd" "github.com/charmbracelet/soft-serve/server/config" + "github.com/charmbracelet/soft-serve/server/hooks" "github.com/charmbracelet/soft-serve/server/utils" "github.com/charmbracelet/ssh" "github.com/charmbracelet/wish" @@ -29,7 +30,7 @@ type SSHServer struct { } // NewSSHServer returns a new SSHServer. -func NewSSHServer(cfg *config.Config) (*SSHServer, error) { +func NewSSHServer(cfg *config.Config, hooks hooks.Hooks) (*SSHServer, error) { var err error s := &SSHServer{cfg: cfg} logger := logger.StandardLog(log.StandardLogOptions{ForceLevel: log.DebugLevel}) @@ -39,7 +40,7 @@ func NewSSHServer(cfg *config.Config) (*SSHServer, error) { // BubbleTea middleware. bm.MiddlewareWithProgramHandler(SessionHandler(cfg), termenv.ANSI256), // CLI middleware. - cm.Middleware(cfg), + cm.Middleware(cfg, hooks), // Git middleware. s.Middleware(cfg), // Logging middleware. @@ -50,7 +51,7 @@ func NewSSHServer(cfg *config.Config) (*SSHServer, error) { ssh.PublicKeyAuth(s.PublicKeyHandler), ssh.KeyboardInteractiveAuth(s.KeyboardInteractiveHandler), wish.WithAddress(cfg.SSH.ListenAddr), - wish.WithHostKeyPath(cfg.SSH.KeyPath), + wish.WithHostKeyPath(filepath.Join(cfg.DataPath, cfg.SSH.KeyPath)), wish.WithMiddleware(mw...), ) if err != nil { @@ -89,7 +90,7 @@ func (s *SSHServer) Shutdown(ctx context.Context) error { // PublicKeyAuthHandler handles public key authentication. func (s *SSHServer) PublicKeyHandler(ctx ssh.Context, pk ssh.PublicKey) bool { - return s.cfg.Backend.AccessLevel("", pk) > backend.NoAccess + return s.cfg.Backend.AccessLevel("", pk) >= backend.ReadOnlyAccess } // KeyboardInteractiveHandler handles keyboard interactive authentication. @@ -116,7 +117,6 @@ func (s *SSHServer) Middleware(cfg *config.Config) wish.Middleware { // git bare repositories should end in ".git" // https://git-scm.com/docs/gitrepository-layout repo := name + ".git" - reposDir := filepath.Join(cfg.DataPath, "repos") if err := ensureWithin(reposDir, repo); err != nil { sshFatal(s, err) @@ -125,30 +125,30 @@ func (s *SSHServer) Middleware(cfg *config.Config) wish.Middleware { repoDir := filepath.Join(reposDir, repo) switch gc { - case ReceivePackBin: + case receivePackBin: if access < backend.ReadWriteAccess { sshFatal(s, ErrNotAuthed) return } if _, err := cfg.Backend.Repository(name); err != nil { if _, err := cfg.Backend.CreateRepository(name, false); err != nil { - log.Printf("failed to create repo: %s", err) + log.Errorf("failed to create repo: %s", err) sshFatal(s, err) return } } - if err := ReceivePack(s, s, s.Stderr(), repoDir); err != nil { + if err := receivePack(s, s, s.Stderr(), repoDir); err != nil { sshFatal(s, ErrSystemMalfunction) } return - case UploadPackBin, UploadArchiveBin: + case uploadPackBin, uploadArchiveBin: if access < backend.ReadOnlyAccess { sshFatal(s, ErrNotAuthed) return } - gitPack := UploadPack - if gc == UploadArchiveBin { - gitPack = UploadArchive + gitPack := uploadPack + if gc == uploadArchiveBin { + gitPack = uploadArchive } err := gitPack(s, s, s.Stderr(), repoDir) if errors.Is(err, ErrInvalidRepo) { @@ -166,6 +166,6 @@ func (s *SSHServer) Middleware(cfg *config.Config) wish.Middleware { // sshFatal prints to the session's STDOUT as a git response and exit 1. func sshFatal(s ssh.Session, v ...interface{}) { - WritePktline(s, v...) + writePktline(s, v...) s.Exit(1) // nolint: errcheck }