feat(server): rework git hooks and drop internal server

Ayman Bagabas created

Improve performance and drop ssh internal hooks server

Change summary

cmd/soft/hook.go                | 177 ++++++++++++++++++++--------------
server/backend/backend.go       |   1 
server/backend/hooks.go         |  20 +++
server/backend/repo.go          |   2 
server/backend/sqlite/hooks.go  |  64 ++++++++++++
server/backend/sqlite/sqlite.go | 162 +------------------------------
server/config/config.go         |  55 ++--------
server/config/file.go           |  20 ---
server/daemon/daemon.go         |   8 +
server/git/git.go               |  85 +++++++++++++--
server/hooks.go                 |  54 ----------
server/hooks/hooks.go           | 160 +++++++++++++++++++++++++++++--
server/internal/cmd.go          |  84 ----------------
server/internal/hook.go         | 138 ---------------------------
server/internal/internal.go     |  86 -----------------
server/jobs.go                  |   2 
server/server.go                |  34 +-----
server/ssh/ssh.go               |  11 +
server/test/test.go             |   2 
19 files changed, 454 insertions(+), 711 deletions(-)

Detailed changes

cmd/soft/hook.go 🔗

@@ -1,15 +1,23 @@
 package main
 
 import (
+	"bufio"
+	"bytes"
+	"context"
 	"fmt"
 	"os"
-	"path/filepath"
 	"strings"
 
-	"github.com/charmbracelet/keygen"
+	"github.com/charmbracelet/soft-serve/server/backend"
+	"github.com/charmbracelet/soft-serve/server/backend/sqlite"
 	"github.com/charmbracelet/soft-serve/server/config"
+	"github.com/charmbracelet/soft-serve/server/hooks"
 	"github.com/spf13/cobra"
-	gossh "golang.org/x/crypto/ssh"
+)
+
+var (
+	confixCtxKey  = "config"
+	backendCtxKey = "backend"
 )
 
 var (
@@ -20,94 +28,113 @@ var (
 		Short:  "Run git server hooks",
 		Long:   "Handles Soft Serve git server hooks.",
 		Hidden: true,
-		RunE: func(_ *cobra.Command, args []string) error {
-			c, s, err := commonInit()
+		PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
+			cfg, err := config.ParseConfig(configPath)
 			if err != nil {
-				return err
+				return fmt.Errorf("could not parse config: %w", err)
 			}
-			defer c.Close() //nolint:errcheck
-			defer s.Close() //nolint:errcheck
-			s.Stdin = os.Stdin
-			s.Stdout = os.Stdout
-			s.Stderr = os.Stderr
-			cmd := fmt.Sprintf("hook %s", strings.Join(args, " "))
-			if err := s.Run(cmd); err != nil {
-				return err
+
+			// Set up the backend
+			// TODO: support other backends
+			sb, err := sqlite.NewSqliteBackend(cmd.Context(), cfg)
+			if err != nil {
+				return fmt.Errorf("failed to create sqlite backend: %w", err)
 			}
+
+			cfg = cfg.WithBackend(sb)
+
+			cmd.SetContext(context.WithValue(cmd.Context(), confixCtxKey, cfg))
+			cmd.SetContext(context.WithValue(cmd.Context(), backendCtxKey, sb))
+
 			return nil
 		},
 	}
-)
 
-func init() {
-	hookCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "", "path to config file")
-}
+	hooksRunE = func(cmd *cobra.Command, args []string) error {
+		cfg := cmd.Context().Value(confixCtxKey).(*config.Config)
+		hks := cfg.Backend.(backend.Hooks)
 
-// TODO: use ssh controlmaster
-func commonInit() (c *gossh.Client, s *gossh.Session, err error) {
-	cfg, err := config.ParseConfig(configPath)
-	if err != nil {
-		return
-	}
+		// This is set in the server before invoking git-receive-pack/git-upload-pack
+		repoName := os.Getenv("SOFT_SERVE_REPO_NAME")
 
-	// 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
-	}
+		in := cmd.InOrStdin()
+		out := cmd.OutOrStdout()
+		err := cmd.ErrOrStderr()
 
-	rs, err := filepath.Abs(filepath.Join(cfg.DataPath, "repos"))
-	if err != nil {
-		return
-	}
+		cmdName := cmd.Name()
+		switch cmdName {
+		case hooks.PreReceiveHook, hooks.PostReceiveHook:
+			var buf bytes.Buffer
+			opts := make([]backend.HookArg, 0)
+			scanner := bufio.NewScanner(in)
+			for scanner.Scan() {
+				buf.Write(scanner.Bytes())
+				fields := strings.Fields(scanner.Text())
+				if len(fields) != 3 {
+					return fmt.Errorf("invalid pre-receive hook input: %s", scanner.Text())
+				}
+				opts = append(opts, backend.HookArg{
+					OldSha:  fields[0],
+					NewSha:  fields[1],
+					RefName: fields[2],
+				})
+			}
 
-	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, string(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
-}
+			switch cmdName {
+			case hooks.PreReceiveHook:
+				hks.PreReceive(out, err, repoName, opts)
+			case hooks.PostReceiveHook:
+				hks.PostReceive(out, err, repoName, opts)
+			}
+		case hooks.UpdateHook:
+			if len(args) != 3 {
+				return fmt.Errorf("invalid update hook input: %s", args)
+			}
 
-func newClient(cfg *config.Config) (*gossh.Client, error) {
-	// Only accept the server's host key.
-	pk, err := keygen.New(cfg.Internal.KeyPath, keygen.WithKeyType(keygen.Ed25519))
-	if err != nil {
-		return nil, err
+			hks.Update(out, err, repoName, backend.HookArg{
+				OldSha:  args[0],
+				NewSha:  args[1],
+				RefName: args[2],
+			})
+		case hooks.PostUpdateHook:
+			hks.PostUpdate(out, err, repoName, args...)
+		}
+
+		return nil
 	}
-	ik, err := keygen.New(cfg.Internal.InternalKeyPath, keygen.WithKeyType(keygen.Ed25519))
-	if err != nil {
-		return nil, err
+
+	preReceiveCmd = &cobra.Command{
+		Use:   "pre-receive",
+		Short: "Run git pre-receive hook",
+		RunE:  hooksRunE,
 	}
-	cc := &gossh.ClientConfig{
-		User: "internal",
-		Auth: []gossh.AuthMethod{
-			gossh.PublicKeys(ik.Signer()),
-		},
-		HostKeyCallback: gossh.FixedHostKey(pk.PublicKey()),
+
+	updateCmd = &cobra.Command{
+		Use:   "update",
+		Short: "Run git update hook",
+		Args:  cobra.ExactArgs(3),
+		RunE:  hooksRunE,
 	}
-	c, err := gossh.Dial("tcp", cfg.Internal.ListenAddr, cc)
-	if err != nil {
-		return nil, err
+
+	postReceiveCmd = &cobra.Command{
+		Use:   "post-receive",
+		Short: "Run git post-receive hook",
+		RunE:  hooksRunE,
 	}
-	return c, nil
-}
 
-func newSession(c *gossh.Client) (*gossh.Session, error) {
-	s, err := c.NewSession()
-	if err != nil {
-		return nil, err
+	postUpdateCmd = &cobra.Command{
+		Use:   "post-update",
+		Short: "Run git post-update hook",
+		RunE:  hooksRunE,
 	}
-	return s, nil
+)
+
+func init() {
+	hookCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "", "path to config file")
+	hookCmd.AddCommand(
+		preReceiveCmd,
+		updateCmd,
+		postReceiveCmd,
+		postUpdateCmd,
+	)
 }

server/backend/backend.go 🔗

@@ -16,6 +16,7 @@ type Backend interface {
 	RepositoryAccess
 	UserStore
 	UserAccess
+	Hooks
 }
 
 // ParseAuthorizedKey parses an authorized key string into a public key.

server/backend/hooks.go 🔗

@@ -0,0 +1,20 @@
+package backend
+
+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)
+}

server/backend/repo.go 🔗

@@ -29,8 +29,6 @@ type RepositoryStore interface {
 	DeleteRepository(name string) error
 	// RenameRepository renames a repository.
 	RenameRepository(oldName, newName string) error
-	// InitializeHooks initializes the hooks for the given repository.
-	InitializeHooks(repo string) error
 }
 
 // RepositoryMetadata is an interface for managing repository metadata.

server/backend/sqlite/hooks.go 🔗

@@ -0,0 +1,64 @@
+package sqlite
+
+import (
+	"io"
+	"sync"
+
+	"github.com/charmbracelet/log"
+	"github.com/charmbracelet/soft-serve/server/backend"
+)
+
+// PostReceive is called by the git post-receive hook.
+//
+// It implements Hooks.
+func (d *SqliteBackend) PostReceive(stdout io.Writer, stderr io.Writer, repo string, args []backend.HookArg) {
+	log.WithPrefix("backend.sqlite.hooks").Debug("post-receive hook called", "repo", repo, "args", args)
+}
+
+// PreReceive is called by the git pre-receive hook.
+//
+// It implements Hooks.
+func (d *SqliteBackend) PreReceive(stdout io.Writer, stderr io.Writer, repo string, args []backend.HookArg) {
+	log.WithPrefix("backend.sqlite.hooks").Debug("pre-receive hook called", "repo", repo, "args", args)
+}
+
+// Update is called by the git update hook.
+//
+// It implements Hooks.
+func (d *SqliteBackend) Update(stdout io.Writer, stderr io.Writer, repo string, arg backend.HookArg) {
+	log.WithPrefix("backend.sqlite.hooks").Debug("update hook called", "repo", repo, "arg", arg)
+}
+
+// PostUpdate is called by the git post-update hook.
+//
+// It implements Hooks.
+func (d *SqliteBackend) PostUpdate(stdout io.Writer, stderr io.Writer, repo string, args ...string) {
+	log.WithPrefix("backend.sqlite.hooks").Debug("post-update hook called", "repo", repo, "args", args)
+
+	var wg sync.WaitGroup
+
+	// Update server info
+	wg.Add(1)
+	go func() {
+		defer wg.Done()
+
+		rr, err := d.Repository(repo)
+		if err != nil {
+			log.WithPrefix("backend.sqlite.hooks").Error("error getting repository", "repo", repo, "err", err)
+			return
+		}
+
+		r, err := rr.Open()
+		if err != nil {
+			log.WithPrefix("backend.sqlite.hooks").Error("error opening repository", "repo", repo, "err", err)
+			return
+		}
+
+		if err := r.UpdateServerInfo(); err != nil {
+			log.WithPrefix("backend.sqlite.hooks").Error("error updating server-info", "repo", repo, "err", err)
+			return
+		}
+	}()
+
+	wg.Wait()
+}

server/backend/sqlite/sqlite.go 🔗

@@ -1,18 +1,17 @@
 package sqlite
 
 import (
-	"bytes"
 	"context"
 	"fmt"
 	"os"
 	"path/filepath"
 	"strings"
-	"text/template"
 
 	"github.com/charmbracelet/log"
 	"github.com/charmbracelet/soft-serve/git"
 	"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/jmoiron/sqlx"
 	_ "modernc.org/sqlite"
@@ -165,7 +164,7 @@ func (d *SqliteBackend) CreateRepository(name string, opts backend.RepositoryOpt
 		db:   d.db,
 	}
 
-	return r, d.InitializeHooks(name)
+	return r, d.initRepo(name)
 }
 
 // ImportRepository imports a repository from remote.
@@ -186,7 +185,7 @@ func (d *SqliteBackend) ImportRepository(name string, remote string, opts backen
 			Envs: []string{
 				fmt.Sprintf(`GIT_SSH_COMMAND=ssh -o UserKnownHostsFile="%s" -o StrictHostKeyChecking=no -i "%s"`,
 					filepath.Join(d.cfg.DataPath, "ssh", "known_hosts"),
-					d.cfg.Internal.ClientKeyPath,
+					d.cfg.SSH.ClientKeyPath,
 				),
 			},
 		},
@@ -551,157 +550,8 @@ func (d *SqliteBackend) RemoveCollaborator(repo string, username string) error {
 	)
 }
 
-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 (d *SqliteBackend) 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(d.reposPath(), repo, "hooks")
-	if err := os.MkdirAll(hooksPath, os.ModePerm); err != nil {
-		return err
-	}
-
-	ex, err := os.Executable()
-	if err != nil {
-		return err
-	}
-
-	dp, err := filepath.Abs(d.dp)
-	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]), os.ModePerm); err != nil {
-			return err
-		}
-
-		// Create hook.d directory.
-		hp += ".d"
-		if err := os.MkdirAll(hp, os.ModePerm); 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(), os.ModePerm) //nolint:gosec
-		if err != nil {
-			logger.Error("failed to write hook", "err", err)
-			continue
-		}
-	}
-
-	return nil
+func (d *SqliteBackend) initRepo(repo string) error {
+	return hooks.GenerateHooks(d.ctx, d.cfg, repo)
 }
 
 func (d *SqliteBackend) initRepos() error {
@@ -711,7 +561,7 @@ func (d *SqliteBackend) initRepos() error {
 	}
 
 	for _, repo := range repos {
-		if err := d.InitializeHooks(repo.Name()); err != nil {
+		if err := d.initRepo(repo.Name()); err != nil {
 			return err
 		}
 	}

server/config/config.go 🔗

@@ -25,6 +25,9 @@ type SSHConfig struct {
 	// KeyPath is the path to the SSH server's private key.
 	KeyPath string `env:"KEY_PATH" yaml:"key_path"`
 
+	// ClientKeyPath is the path to the server's client private key.
+	ClientKeyPath string `env:"CLIENT_KEY_PATH" yaml:"client_key_path"`
+
 	// MaxTimeout is the maximum number of seconds a connection can take.
 	MaxTimeout int `env:"MAX_TIMEOUT" yaml:"max_timeout"`
 
@@ -68,22 +71,6 @@ type StatsConfig struct {
 	ListenAddr string `env:"LISTEN_ADDR" yaml:"listen_addr"`
 }
 
-// InternalConfig is the configuration for the internal server.
-// This is used for internal communication between the Soft Serve client and server.
-type InternalConfig struct {
-	// ListenAddr is the address on which the internal server will listen.
-	ListenAddr string `env:"LISTEN_ADDR" yaml:"listen_addr"`
-
-	// KeyPath is the path to the SSH server's host private key.
-	KeyPath string `env:"KEY_PATH" yaml:"key_path"`
-
-	// InternalKeyPath is the path to the server's internal private key.
-	InternalKeyPath string `env:"INTERNAL_KEY_PATH" yaml:"internal_key_path"`
-
-	// ClientKeyPath is the path to the server's client private key.
-	ClientKeyPath string `env:"CLIENT_KEY_PATH" yaml:"client_key_path"`
-}
-
 // Config is the configuration for Soft Serve.
 type Config struct {
 	// Name is the name of the server.
@@ -101,9 +88,6 @@ type Config struct {
 	// Stats is the configuration for the stats server.
 	Stats StatsConfig `envPrefix:"STATS_" yaml:"stats"`
 
-	// Internal is the configuration for the internal server.
-	Internal InternalConfig `envPrefix:"INTERNAL_" yaml:"internal"`
-
 	// InitialAdminKeys is a list of public keys that will be added to the list of admins.
 	InitialAdminKeys []string `env:"INITIAL_ADMIN_KEYS" envSeparator:"\n" yaml:"initial_admin_keys"`
 
@@ -120,11 +104,12 @@ func parseConfig(path string) (*Config, error) {
 		Name:     "Soft Serve",
 		DataPath: dataPath,
 		SSH: SSHConfig{
-			ListenAddr:  ":23231",
-			PublicURL:   "ssh://localhost:23231",
-			KeyPath:     filepath.Join("ssh", "soft_serve_host_ed25519"),
-			MaxTimeout:  0,
-			IdleTimeout: 0,
+			ListenAddr:    ":23231",
+			PublicURL:     "ssh://localhost:23231",
+			KeyPath:       filepath.Join("ssh", "soft_serve_host_ed25519"),
+			ClientKeyPath: filepath.Join("ssh", "soft_serve_client_ed25519"),
+			MaxTimeout:    0,
+			IdleTimeout:   0,
 		},
 		Git: GitConfig{
 			ListenAddr:     ":9418",
@@ -139,12 +124,6 @@ func parseConfig(path string) (*Config, error) {
 		Stats: StatsConfig{
 			ListenAddr: "localhost:23233",
 		},
-		Internal: InternalConfig{
-			ListenAddr:      "localhost:23230",
-			KeyPath:         filepath.Join("ssh", "soft_serve_internal_host_ed25519"),
-			InternalKeyPath: filepath.Join("ssh", "soft_serve_internal_ed25519"),
-			ClientKeyPath:   filepath.Join("ssh", "soft_serve_client_ed25519"),
-		},
 	}
 
 	f, err := os.Open(path)
@@ -260,16 +239,8 @@ func (c *Config) validate() error {
 		c.SSH.KeyPath = filepath.Join(c.DataPath, c.SSH.KeyPath)
 	}
 
-	if c.Internal.KeyPath != "" && !filepath.IsAbs(c.Internal.KeyPath) {
-		c.Internal.KeyPath = filepath.Join(c.DataPath, c.Internal.KeyPath)
-	}
-
-	if c.Internal.ClientKeyPath != "" && !filepath.IsAbs(c.Internal.ClientKeyPath) {
-		c.Internal.ClientKeyPath = filepath.Join(c.DataPath, c.Internal.ClientKeyPath)
-	}
-
-	if c.Internal.InternalKeyPath != "" && !filepath.IsAbs(c.Internal.InternalKeyPath) {
-		c.Internal.InternalKeyPath = filepath.Join(c.DataPath, c.Internal.InternalKeyPath)
+	if c.SSH.ClientKeyPath != "" && !filepath.IsAbs(c.SSH.ClientKeyPath) {
+		c.SSH.ClientKeyPath = filepath.Join(c.DataPath, c.SSH.ClientKeyPath)
 	}
 
 	if c.HTTP.TLSKeyPath != "" && !filepath.IsAbs(c.HTTP.TLSKeyPath) {
@@ -298,7 +269,7 @@ func parseAuthKeys(aks []string) []ssh.PublicKey {
 	return pks
 }
 
-// AdminKeys returns the admin keys including the internal api key.
+// AdminKeys returns the server admin keys.
 func (c *Config) AdminKeys() []ssh.PublicKey {
-	return parseAuthKeys(append(c.InitialAdminKeys, c.Internal.InternalKeyPath))
+	return parseAuthKeys(c.InitialAdminKeys)
 }

server/config/file.go 🔗

@@ -24,6 +24,10 @@ ssh:
   # The path to the SSH server's private key.
   key_path: "{{ .SSH.KeyPath }}"
 
+  # The path to the server's client private key. This key will be used to
+  # authenticate the server to make git requests to ssh remotes.
+  client_key_path: "{{ .Internal.ClientKeyPath }}"
+
   # The maximum number of seconds a connection can take.
   # A value of 0 means no timeout.
   max_timeout: {{ .SSH.MaxTimeout }}
@@ -68,22 +72,6 @@ stats:
   # The address on which the stats server will listen.
   listen_addr: "{{ .Stats.ListenAddr }}"
 
-# The internal server configuration.
-internal:
-  # The address on which the internal server will listen.
-  listen_addr: "{{ .Internal.ListenAddr }}"
-
-  # The path to the Internal server's host private key.
-  key_path: "{{ .Internal.KeyPath }}"
-
-  # The path to the Internal server's client private key.
-  # This key will be used to authenticate the server to make git requests to
-  # ssh remotes.
-  client_key_path: "{{ .Internal.ClientKeyPath }}"
-
-  # The path to the Internal server's internal api private key.
-  internal_key_path: "{{ .Internal.InternalKeyPath }}"
-
 # Additional admin keys.
 #initial_admin_keys:
 #  - "ssh-rsa AAAAB3NzaC1yc2..."

server/daemon/daemon.go 🔗

@@ -253,7 +253,13 @@ func (d *GitDaemon) handleClient(conn net.Conn) {
 			return
 		}
 
-		if err := gitPack(c, c, c, filepath.Join(reposDir, repo)); err != nil {
+		// Environment variables to pass down to git hooks.
+		envs := []string{
+			"SOFT_SERVE_REPO_NAME=" + name,
+			"SOFT_SERVE_REPO_PATH=" + filepath.Join(reposDir, repo),
+		}
+
+		if err := gitPack(ctx, c, c, c, filepath.Join(reposDir, repo), envs...); err != nil {
 			fatal(c, err)
 			return
 		}

server/git/git.go 🔗

@@ -1,16 +1,19 @@
 package git
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"io"
 	"os"
+	"os/exec"
 	"path/filepath"
 	"strings"
 
 	"github.com/charmbracelet/log"
 	"github.com/charmbracelet/soft-serve/git"
 	"github.com/go-git/go-git/v5/plumbing/format/pktline"
+	"golang.org/x/sync/errgroup"
 )
 
 var (
@@ -42,7 +45,7 @@ const (
 )
 
 // 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 {
+func UploadPack(ctx context.Context, in io.Reader, out io.Writer, er io.Writer, repoDir string, envs ...string) error {
 	exists, err := fileExists(repoDir)
 	if !exists {
 		return ErrInvalidRepo
@@ -50,11 +53,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(ctx, in, out, er, "", envs, 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 {
+func UploadArchive(ctx context.Context, in io.Reader, out io.Writer, er io.Writer, repoDir string, envs ...string) error {
 	exists, err := fileExists(repoDir)
 	if !exists {
 		return ErrInvalidRepo
@@ -62,25 +65,77 @@ 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(ctx, in, out, er, "", envs, 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 := RunGit(in, out, er, "", ReceivePackBin[4:], repoDir); err != nil {
+func ReceivePack(ctx context.Context, in io.Reader, out io.Writer, er io.Writer, repoDir string, envs ...string) error {
+	if err := RunGit(ctx, in, out, er, "", envs, ReceivePackBin[4:], repoDir); err != nil {
 		return err
 	}
-	return EnsureDefaultBranch(in, out, er, repoDir)
+	return EnsureDefaultBranch(ctx, 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 {
-	c := git.NewCommand(args...)
-	return c.RunInDirWithOptions(dir, git.RunInDirOptions{
-		Stdin:  in,
-		Stdout: out,
-		Stderr: err,
+func RunGit(ctx context.Context, in io.Reader, out io.Writer, er io.Writer, dir string, envs []string, args ...string) error {
+	logger := log.WithPrefix("server.git")
+	c := exec.CommandContext(ctx, "git", args...)
+	c.Dir = dir
+	c.Env = append(c.Env, envs...)
+	c.Env = append(c.Env, "SOFT_SERVE_DEBUG="+os.Getenv("SOFT_SERVE_DEBUG"))
+	c.Env = append(c.Env, "PATH="+os.Getenv("PATH"))
+
+	stdin, err := c.StdinPipe()
+	if err != nil {
+		logger.Error("failed to get stdin pipe", "err", err)
+		return err
+	}
+
+	stdout, err := c.StdoutPipe()
+	if err != nil {
+		logger.Error("failed to get stdout pipe", "err", err)
+		return err
+	}
+
+	stderr, err := c.StderrPipe()
+	if err != nil {
+		logger.Error("failed to get stderr pipe", "err", err)
+		return err
+	}
+
+	if err := c.Start(); err != nil {
+		logger.Error("failed to start command", "err", err)
+		return err
+	}
+
+	errg, ctx := errgroup.WithContext(ctx)
+
+	// stdin
+	errg.Go(func() error {
+		defer stdin.Close()
+
+		_, err := io.Copy(stdin, in)
+		return err
+	})
+
+	// stdout
+	errg.Go(func() error {
+		_, err := io.Copy(out, stdout)
+		return err
+	})
+
+	// stderr
+	errg.Go(func() error {
+		_, err := io.Copy(er, stderr)
+		return err
 	})
+
+	if err := errg.Wait(); err != nil {
+		logger.Error("while running git command", "err", err)
+		return err
+	}
+
+	return nil
 }
 
 // WritePktline encodes and writes a pktline to the given writer.
@@ -129,7 +184,7 @@ func fileExists(path string) (bool, error) {
 	return true, err
 }
 
-func EnsureDefaultBranch(in io.Reader, out io.Writer, er io.Writer, repoPath string) error {
+func EnsureDefaultBranch(ctx context.Context, in io.Reader, out io.Writer, er io.Writer, repoPath string) error {
 	r, err := git.Open(repoPath)
 	if err != nil {
 		return err
@@ -144,7 +199,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 {
-		err = RunGit(in, out, er, repoPath, "branch", "-M", brs[0])
+		err = RunGit(ctx, in, out, er, repoPath, []string{}, "branch", "-M", brs[0])
 		if err != nil {
 			return err
 		}

server/hooks.go 🔗

@@ -1,54 +0,0 @@
-package server
-
-import (
-	"io"
-
-	"github.com/charmbracelet/log"
-	"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(stdin io.Reader, stdout io.Writer, stderr io.Writer, repo string, args []hooks.HookArg) {
-	log.WithPrefix("server.hooks").Debug("post-receive hook called", "repo", repo, "args", args)
-}
-
-// PreReceive is called by the git pre-receive hook.
-//
-// It implements Hooks.
-func (*Server) PreReceive(stdin io.Reader, stdout io.Writer, stderr io.Writer, repo string, args []hooks.HookArg) {
-	log.WithPrefix("server.hooks").Debug("pre-receive hook called", "repo", repo, "args", args)
-}
-
-// Update is called by the git update hook.
-//
-// It implements Hooks.
-func (*Server) Update(stdin io.Reader, stdout io.Writer, stderr io.Writer, repo string, arg hooks.HookArg) {
-	log.WithPrefix("server.hooks").Debug("update hook called", "repo", repo, "arg", arg)
-}
-
-// PostUpdate is called by the git post-update hook.
-//
-// It implements Hooks.
-func (s *Server) PostUpdate(stdin io.Reader, stdout io.Writer, stderr io.Writer, repo string, args ...string) {
-	log.WithPrefix("server.hooks").Debug("post-update hook called", "repo", repo, "args", args)
-	rr, err := s.Config.Backend.Repository(repo)
-	if err != nil {
-		log.WithPrefix("server.hooks.post-update").Error("error getting repository", "repo", repo, "err", err)
-		return
-	}
-
-	r, err := rr.Open()
-	if err != nil {
-		log.WithPrefix("server.hooks.post-update").Error("error opening repository", "repo", repo, "err", err)
-		return
-	}
-
-	if err := r.UpdateServerInfo(); err != nil {
-		log.WithPrefix("server.hooks.post-update").Error("error updating server info", "repo", repo, "err", err)
-		return
-	}
-}

server/hooks/hooks.go 🔗

@@ -1,18 +1,152 @@
 package hooks
 
-import "io"
+import (
+	"bytes"
+	"context"
+	"fmt"
+	"os"
+	"path/filepath"
+	"text/template"
 
-// HookArg is an argument to a git hook.
-type HookArg struct {
-	OldSha  string
-	NewSha  string
-	RefName string
-}
+	"github.com/charmbracelet/log"
+	"github.com/charmbracelet/soft-serve/server/config"
+	"github.com/charmbracelet/soft-serve/server/utils"
+)
+
+// The names of git server-side hooks.
+const (
+	PreReceiveHook  = "pre-receive"
+	UpdateHook      = "update"
+	PostReceiveHook = "post-receive"
+	PostUpdateHook  = "post-update"
+)
+
+// GenerateHooks generates git server-side hooks for a repository. Currently, it supports the following hooks:
+// - pre-receive
+// - update
+// - post-receive
+// - post-update
+//
+// This function should be called by the backend when a repository is created.
+// TODO: support context
+func GenerateHooks(ctx context.Context, cfg *config.Config, repo string) error {
+	repo = utils.SanitizeRepo(repo) + ".git"
+	hooksPath := filepath.Join(cfg.DataPath, "repos", repo, "hooks")
+	if err := os.MkdirAll(hooksPath, os.ModePerm); err != nil {
+		return err
+	}
+
+	ex, err := os.Executable()
+	if err != nil {
+		return err
+	}
+
+	dp, err := filepath.Abs(cfg.DataPath)
+	if err != nil {
+		return fmt.Errorf("failed to get absolute path for data path: %w", err)
+	}
+
+	cp := filepath.Join(dp, "config.yaml")
+	// Add extra environment variables to the hooks here.
+	envs := []string{}
+
+	for _, hook := range []string{
+		PreReceiveHook,
+		UpdateHook,
+		PostReceiveHook,
+		PostUpdateHook,
+	} {
+		var data bytes.Buffer
+		var args string
+
+		// Hooks script/directory path
+		hp := filepath.Join(hooksPath, hook)
+
+		// Write the hooks primary script
+		if err := os.WriteFile(hp, []byte(hookTemplate), os.ModePerm); err != nil {
+			return err
+		}
 
-// Hooks provides an interface for git server-side hooks.
-type Hooks interface {
-	PreReceive(stdin io.Reader, stdout io.Writer, stderr io.Writer, repo string, args []HookArg)
-	Update(stdin io.Reader, stdout io.Writer, stderr io.Writer, repo string, arg HookArg)
-	PostReceive(stdin io.Reader, stdout io.Writer, stderr io.Writer, repo string, args []HookArg)
-	PostUpdate(stdin io.Reader, stdout io.Writer, stderr io.Writer, repo string, args ...string)
+		// Create ${hook}.d directory.
+		hp += ".d"
+		if err := os.MkdirAll(hp, os.ModePerm); err != nil {
+			return err
+		}
+
+		switch hook {
+		case UpdateHook:
+			args = "$1 $2 $3"
+		case PostUpdateHook:
+			args = "$@"
+		}
+
+		if err := hooksTmpl.Execute(&data, struct {
+			Executable string
+			Config     string
+			Envs       []string
+			Hook       string
+			Args       string
+		}{
+			Executable: ex,
+			Config:     cp,
+			Envs:       envs,
+			Hook:       hook,
+			Args:       args,
+		}); err != nil {
+			log.WithPrefix("backend.hooks").Error("failed to execute hook template", "err", err)
+			continue
+		}
+
+		// Write the soft-serve hook inside ${hook}.d directory.
+		hp = filepath.Join(hp, "soft-serve")
+		err = os.WriteFile(hp, data.Bytes(), os.ModePerm) //nolint:gosec
+		if err != nil {
+			log.WithPrefix("backend.hooks").Error("failed to write hook", "err", err)
+			continue
+		}
+	}
+
+	return nil
 }
+
+const (
+	// hookTemplate allows us to run multiple hooks from a directory. It should
+	// support every type of git hook, as it proxies both stdin and arguments.
+	hookTemplate = `#!/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
+  # Avoid running non-executable hooks
+  test -x "${hook}" && test -f "${hook}" || continue
+
+  # Run the actual hook
+  echo "${data}" | "${hook}" "$@"
+
+  # Store the exit code for later use
+  exitcodes="${exitcodes} $?"
+done
+
+# Exit on the first non-zero exit code.
+for i in ${exitcodes}; do
+  [ ${i} -eq 0 ] || exit ${i}
+done
+`
+)
+
+var (
+	// hooksTmpl is the soft-serve hook that will be run by the git hooks
+	// inside the hooks directory.
+	hooksTmpl = template.Must(template.New("hooks").Parse(`#!/usr/bin/env bash
+# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
+if [ -z "$SOFT_SERVE_REPO_NAME" ]; then
+	echo "Warning: SOFT_SERVE_REPO_NAME not defined. Skipping hooks."
+	exit 0
+fi
+{{ range $_, $env := .Envs }}
+{{ $env }} \{{ end }}
+{{ .Executable }} hook --config "{{ .Config }}" {{ .Hook }} {{ .Args }}
+`))
+)

server/internal/cmd.go 🔗

@@ -1,84 +0,0 @@
-package internal
-
-import (
-	"context"
-
-	"github.com/charmbracelet/soft-serve/server/config"
-	"github.com/charmbracelet/soft-serve/server/hooks"
-	"github.com/charmbracelet/ssh"
-	"github.com/charmbracelet/wish"
-	"github.com/spf13/cobra"
-)
-
-var (
-	hooksCtxKey   = "hooks"
-	sessionCtxKey = "session"
-	configCtxKey  = "config"
-)
-
-// rootCommand is the root command for the server.
-func rootCommand(cfg *config.Config, s ssh.Session) *cobra.Command {
-	rootCmd := &cobra.Command{
-		Short:        "Soft Serve internal API.",
-		SilenceUsage: true,
-	}
-
-	rootCmd.SetIn(s)
-	rootCmd.SetOut(s)
-	rootCmd.SetErr(s)
-	rootCmd.CompletionOptions.DisableDefaultCmd = true
-
-	rootCmd.AddCommand(
-		hookCommand(),
-	)
-
-	return rootCmd
-}
-
-// Middleware returns the middleware for the server.
-func (i *InternalServer) Middleware(hooks hooks.Hooks) wish.Middleware {
-	return func(sh ssh.Handler) ssh.Handler {
-		return func(s ssh.Session) {
-			_, _, active := s.Pty()
-			if active {
-				return
-			}
-
-			// Ignore git server commands.
-			args := s.Command()
-			if len(args) > 0 {
-				if args[0] == "git-receive-pack" ||
-					args[0] == "git-upload-pack" ||
-					args[0] == "git-upload-archive" {
-					return
-				}
-			}
-
-			ctx := context.WithValue(s.Context(), hooksCtxKey, hooks)
-			ctx = context.WithValue(ctx, sessionCtxKey, s)
-			ctx = context.WithValue(ctx, configCtxKey, i.cfg)
-
-			rootCmd := rootCommand(i.cfg, s)
-			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())
-			if err := rootCmd.ExecuteContext(ctx); err != nil {
-				_ = s.Exit(1)
-			}
-			sh(s)
-		}
-	}
-}
-
-func fromContext(cmd *cobra.Command) (*config.Config, ssh.Session) {
-	ctx := cmd.Context()
-	cfg := ctx.Value(configCtxKey).(*config.Config)
-	s := ctx.Value(sessionCtxKey).(ssh.Session)
-	return cfg, s
-}

server/internal/hook.go 🔗

@@ -1,138 +0,0 @@
-package internal
-
-import (
-	"bufio"
-	"fmt"
-	"strings"
-
-	"github.com/charmbracelet/keygen"
-	"github.com/charmbracelet/log"
-	"github.com/charmbracelet/soft-serve/server/backend"
-	"github.com/charmbracelet/soft-serve/server/errors"
-	"github.com/charmbracelet/soft-serve/server/hooks"
-	"github.com/charmbracelet/ssh"
-	"github.com/spf13/cobra"
-)
-
-// 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",
-		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, s.Stderr(), repoName, opts)
-			return nil
-		},
-	}
-
-	updateCmd := &cobra.Command{
-		Use:   "update",
-		Short: "Run git update hook",
-		Args:  cobra.ExactArgs(3),
-		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, 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",
-		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, s.Stderr(), repoName, opts)
-			return nil
-		},
-	}
-
-	postUpdateCmd := &cobra.Command{
-		Use:   "post-update",
-		Short: "Run git post-update hook",
-		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, 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(cfg.Internal.InternalKeyPath, keygen.WithKeyType(keygen.Ed25519))
-	if err != nil {
-		log.WithPrefix("server.internal").Errorf("failed to read internal key: %v", err)
-		return err
-	}
-	if !backend.KeysEqual(pk, kp.PublicKey()) {
-		return errors.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
-}

server/internal/internal.go 🔗

@@ -1,86 +0,0 @@
-package internal
-
-import (
-	"context"
-	"fmt"
-
-	"github.com/charmbracelet/keygen"
-	"github.com/charmbracelet/soft-serve/server/backend"
-	"github.com/charmbracelet/soft-serve/server/config"
-	"github.com/charmbracelet/soft-serve/server/hooks"
-	"github.com/charmbracelet/ssh"
-	"github.com/charmbracelet/wish"
-)
-
-// InternalServer is a internal interface to communicate with the server.
-type InternalServer struct {
-	cfg *config.Config
-	s   *ssh.Server
-	kp  *keygen.SSHKeyPair
-	ckp *keygen.SSHKeyPair
-}
-
-// NewInternalServer returns a new internal server.
-func NewInternalServer(cfg *config.Config, hooks hooks.Hooks) (*InternalServer, error) {
-	i := &InternalServer{cfg: cfg}
-
-	// Create internal key.
-	ikp, err := keygen.New(
-		cfg.Internal.InternalKeyPath,
-		keygen.WithKeyType(keygen.Ed25519),
-		keygen.WithWrite(),
-	)
-	if err != nil {
-		return nil, fmt.Errorf("internal key: %w", err)
-	}
-
-	i.kp = ikp
-
-	// Create client key.
-	ckp, err := keygen.New(
-		cfg.Internal.ClientKeyPath,
-		keygen.WithKeyType(keygen.Ed25519),
-		keygen.WithWrite(),
-	)
-	if err != nil {
-		return nil, fmt.Errorf("client key: %w", err)
-	}
-
-	i.ckp = ckp
-
-	s, err := wish.NewServer(
-		wish.WithAddress(cfg.Internal.ListenAddr),
-		wish.WithHostKeyPath(cfg.Internal.KeyPath),
-		wish.WithPublicKeyAuth(i.PublicKeyHandler),
-		wish.WithMiddleware(
-			i.Middleware(hooks),
-		),
-	)
-	if err != nil {
-		return nil, fmt.Errorf("wish: %w", err)
-	}
-
-	i.s = s
-
-	return i, nil
-}
-
-// PublicKeyHandler handles public key authentication.
-func (i *InternalServer) PublicKeyHandler(ctx ssh.Context, pk ssh.PublicKey) bool {
-	return backend.KeysEqual(i.kp.PublicKey(), pk)
-}
-
-// Start starts the internal server.
-func (i *InternalServer) Start() error {
-	return i.s.ListenAndServe()
-}
-
-// Shutdown shuts down the internal server.
-func (i *InternalServer) Shutdown(ctx context.Context) error {
-	return i.s.Shutdown(ctx)
-}
-
-// Close closes the internal server.
-func (i *InternalServer) Close() error {
-	return i.s.Close()
-}

server/jobs.go 🔗

@@ -38,7 +38,7 @@ func mirrorJob(cfg *config.Config) func() {
 				cmd.AddEnvs(
 					fmt.Sprintf(`GIT_SSH_COMMAND=ssh -o UserKnownHostsFile="%s" -o StrictHostKeyChecking=no -i "%s"`,
 						filepath.Join(cfg.DataPath, "ssh", "known_hosts"),
-						cfg.Internal.ClientKeyPath,
+						cfg.SSH.ClientKeyPath,
 					),
 				)
 				if _, err := cmd.RunInDir(r.Path); err != nil {

server/server.go 🔗

@@ -13,7 +13,6 @@ import (
 	"github.com/charmbracelet/soft-serve/server/config"
 	"github.com/charmbracelet/soft-serve/server/cron"
 	"github.com/charmbracelet/soft-serve/server/daemon"
-	"github.com/charmbracelet/soft-serve/server/internal"
 	sshsrv "github.com/charmbracelet/soft-serve/server/ssh"
 	"github.com/charmbracelet/soft-serve/server/stats"
 	"github.com/charmbracelet/soft-serve/server/web"
@@ -27,15 +26,14 @@ var (
 
 // Server is the Soft Serve server.
 type Server struct {
-	SSHServer      *sshsrv.SSHServer
-	GitDaemon      *daemon.GitDaemon
-	HTTPServer     *web.HTTPServer
-	StatsServer    *stats.StatsServer
-	InternalServer *internal.InternalServer
-	Cron           *cron.CronScheduler
-	Config         *config.Config
-	Backend        backend.Backend
-	ctx            context.Context
+	SSHServer   *sshsrv.SSHServer
+	GitDaemon   *daemon.GitDaemon
+	HTTPServer  *web.HTTPServer
+	StatsServer *stats.StatsServer
+	Cron        *cron.CronScheduler
+	Config      *config.Config
+	Backend     backend.Backend
+	ctx         context.Context
 }
 
 // NewServer returns a new *ssh.Server configured to serve Soft Serve. The SSH
@@ -84,11 +82,6 @@ func NewServer(ctx context.Context, cfg *config.Config) (*Server, error) {
 		return nil, fmt.Errorf("create stats server: %w", err)
 	}
 
-	srv.InternalServer, err = internal.NewInternalServer(cfg, srv)
-	if err != nil {
-		return nil, fmt.Errorf("create internal server: %w", err)
-	}
-
 	return srv, nil
 }
 
@@ -143,13 +136,6 @@ func (s *Server) Start() error {
 		s.Cron.Start()
 		return nil
 	})
-	errg.Go(func() error {
-		logger.Print("Starting internal server", "addr", s.Config.Internal.ListenAddr)
-		if err := start(ctx, s.InternalServer.Start); !errors.Is(err, http.ErrServerClosed) {
-			return err
-		}
-		return nil
-	})
 	return errg.Wait()
 }
 
@@ -172,9 +158,6 @@ func (s *Server) Shutdown(ctx context.Context) error {
 		s.Cron.Stop()
 		return nil
 	})
-	errg.Go(func() error {
-		return s.InternalServer.Shutdown(ctx)
-	})
 	return errg.Wait()
 }
 
@@ -189,6 +172,5 @@ func (s *Server) Close() error {
 		s.Cron.Stop()
 		return nil
 	})
-	errg.Go(s.InternalServer.Close)
 	return errg.Wait()
 }

server/ssh/ssh.go 🔗

@@ -189,6 +189,13 @@ func (s *SSHServer) Middleware(cfg *config.Config) wish.Middleware {
 						return
 					}
 
+					// Environment variables to pass down to git hooks.
+					envs := []string{
+						"SOFT_SERVE_REPO_NAME=" + name,
+						"SOFT_SERVE_REPO_PATH=" + filepath.Join(reposDir, repo),
+						"SOFT_SERVE_PUBLIC_KEY=" + ak,
+					}
+
 					logger.Debug("git middleware", "cmd", gc, "access", access.String())
 					repoDir := filepath.Join(reposDir, repo)
 					switch gc {
@@ -205,7 +212,7 @@ func (s *SSHServer) Middleware(cfg *config.Config) wish.Middleware {
 							}
 							createRepoCounter.WithLabelValues(ak, s.User(), name).Inc()
 						}
-						if err := git.ReceivePack(s, s, s.Stderr(), repoDir); err != nil {
+						if err := git.ReceivePack(s.Context(), s, s, s.Stderr(), repoDir, envs...); err != nil {
 							sshFatal(s, git.ErrSystemMalfunction)
 						}
 						receivePackCounter.WithLabelValues(ak, s.User(), name).Inc()
@@ -223,7 +230,7 @@ func (s *SSHServer) Middleware(cfg *config.Config) wish.Middleware {
 							counter = uploadArchiveCounter
 						}
 
-						err := gitPack(s, s, s.Stderr(), repoDir)
+						err := gitPack(s.Context(), s, s, s.Stderr(), repoDir, envs...)
 						if errors.Is(err, git.ErrInvalidRepo) {
 							sshFatal(s, git.ErrInvalidRepo)
 						} else if err != nil {

server/test/test.go 🔗

@@ -2,6 +2,8 @@ package test
 
 import "net"
 
+// RandomPort returns a random port number.
+// This is mainly used for testing.
 func RandomPort() int {
 	addr, _ := net.Listen("tcp", ":0") //nolint:gosec
 	_ = addr.Close()