Detailed changes
@@ -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
+}
@@ -31,6 +31,7 @@ func init() {
rootCmd.AddCommand(
serveCmd,
manCmd,
+ hookCmd,
)
rootCmd.CompletionOptions.HiddenDefaultCmd = true
@@ -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 (
@@ -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
+}
@@ -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 {
@@ -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)
@@ -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 {
@@ -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 {
@@ -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)
@@ -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
+}
@@ -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)
@@ -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)
@@ -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 {
@@ -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
@@ -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 {
@@ -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
@@ -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)
@@ -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
}
@@ -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
+ }
+}
@@ -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)
+}
@@ -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
}
@@ -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)
@@ -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()
@@ -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
}