feat: soft serve internal api

Ayman Bagabas created

* Add internal ssh keypair path
* Introduce soft serve internal api client
* Create server git hooks shell scripts on config reload
* Use soft serve client to run git server hooks
* Use spf13/cobra to handle internal api ssh requests

Change summary

cmd/soft/internal.go      | 187 +++++++++++++++++++++++++++++++++++++++++
cmd/soft/root.go          |   1 
cmd/soft/serve.go         |  67 ++++++++++++++
config/config.go          |  19 ++++
go.mod                    |   5 
go.sum                    |   9 +
internal/config/config.go |  88 +++++++++++++++++++
internal/config/git.go    |  18 +++
server/cmd/cmd.go         |   1 
server/cmd/internal.go    | 154 +++++++++++++++++++++++++++++++++
10 files changed, 544 insertions(+), 5 deletions(-)

Detailed changes

cmd/soft/internal.go 🔗

@@ -0,0 +1,187 @@
+package main
+
+import (
+	"bufio"
+	"fmt"
+	"os"
+	"strings"
+
+	"github.com/charmbracelet/soft-serve/config"
+	"github.com/spf13/cobra"
+	gossh "golang.org/x/crypto/ssh"
+)
+
+var (
+	internalCmd = &cobra.Command{
+		Use:   "internal",
+		Short: "Internal Soft Serve API",
+		Long: `Soft Serve internal API.
+This command is used to communicate with the Soft Serve SSH server.`,
+		Hidden: true,
+	}
+
+	hookCmd = &cobra.Command{
+		Use:   "hook",
+		Short: "Run git server hooks",
+		Long:  "Handles git server hooks. This includes pre-receive, update, and post-receive.",
+	}
+
+	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("internal 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("internal 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("internal hook post-receive")
+			if err != nil {
+				return err
+			}
+			cmd.Print(string(b))
+			return nil
+		},
+	}
+)
+
+func init() {
+	hookCmd.AddCommand(
+		preReceiveCmd,
+		updateCmd,
+		postReceiveCmd,
+	)
+	internalCmd.AddCommand(
+		hookCmd,
+	)
+}
+
+func commonInit() (c *gossh.Client, s *gossh.Session, err error) {
+	cfg := config.DefaultConfig()
+	// 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
+	}
+	if !strings.HasPrefix(wd, cfg.RepoPath) {
+		err = fmt.Errorf("hook must be run from within repository directory")
+		return
+	}
+	repoName := strings.TrimPrefix(wd, cfg.RepoPath)
+	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.
+	pubKey, err := os.ReadFile(cfg.KeyPath)
+	if err != nil {
+		return nil, err
+	}
+	hostKey, err := gossh.ParsePrivateKey(pubKey)
+	if err != nil {
+		return nil, err
+	}
+	pemKey, err := os.ReadFile(cfg.InternalKeyPath)
+	if err != nil {
+		return nil, err
+	}
+	k, err := gossh.ParsePrivateKey(pemKey)
+	if err != nil {
+		return nil, err
+	}
+	cc := &gossh.ClientConfig{
+		User: "internal",
+		Auth: []gossh.AuthMethod{
+			gossh.PublicKeys(k),
+		},
+		HostKeyCallback: gossh.FixedHostKey(hostKey.PublicKey()),
+	}
+	addr := fmt.Sprintf("%s:%d", cfg.BindAddr, cfg.Port)
+	c, err := gossh.Dial("tcp", addr, 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
+}

cmd/soft/root.go 🔗

@@ -30,6 +30,7 @@ func init() {
 	rootCmd.AddCommand(
 		serveCmd,
 		manCmd,
+		internalCmd,
 	)
 	rootCmd.CompletionOptions.HiddenDefaultCmd = true
 

cmd/soft/serve.go 🔗

@@ -1,18 +1,28 @@
 package main
 
 import (
+	"bytes"
 	"context"
+	"fmt"
 	"log"
 	"os"
 	"os/signal"
+	"strings"
 	"syscall"
+	"text/template"
 	"time"
 
+	"github.com/charmbracelet/keygen"
 	"github.com/charmbracelet/soft-serve/config"
 	"github.com/charmbracelet/soft-serve/server"
 	"github.com/spf13/cobra"
 )
 
+var (
+	hookTmpl  *template.Template
+	initHooks bool
+)
+
 var (
 	serveCmd = &cobra.Command{
 		Use:   "serve",
@@ -21,6 +31,48 @@ var (
 		Args:  cobra.NoArgs,
 		RunE: func(cmd *cobra.Command, args []string) error {
 			cfg := config.DefaultConfig()
+			// Internal API keypair
+			_, err := keygen.NewWithWrite(
+				strings.TrimSuffix(cfg.InternalKeyPath, "_ed25519"),
+				nil,
+				keygen.Ed25519,
+			)
+			if err != nil {
+				return err
+			}
+			// Create git server hooks
+			if initHooks {
+				ex, err := os.Executable()
+				if err != nil {
+					return err
+				}
+				repos, err := os.ReadDir(cfg.RepoPath)
+				if err != nil {
+					return err
+				}
+				for _, repo := range repos {
+					for _, hook := range []string{"pre-receive", "update", "post-receive"} {
+						var data bytes.Buffer
+						var args string
+						hp := fmt.Sprintf("%s/%s/hooks/%s", cfg.RepoPath, repo.Name(), hook)
+						if hook == "update" {
+							args = "$1 $2 $3"
+						}
+						err = hookTmpl.Execute(&data, hookScript{
+							Executable: ex,
+							Hook:       hook,
+							Args:       args,
+						})
+						if err != nil {
+							return err
+						}
+						err = os.WriteFile(hp, data.Bytes(), 0755) //nolint:gosec
+						if err != nil {
+							return err
+						}
+					}
+				}
+			}
 			s := server.NewServer(cfg)
 
 			done := make(chan os.Signal, 1)
@@ -42,3 +94,18 @@ var (
 		},
 	}
 )
+
+type hookScript struct {
+	Executable string
+	Hook       string
+	Args       string
+}
+
+func init() {
+	hookTmpl = template.New("hook")
+	hookTmpl, _ = hookTmpl.Parse(`#!/usr/bin/env bash
+# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
+{{ .Executable }} internal hook {{ .Hook }} {{ .Args }}
+`)
+	serveCmd.Flags().BoolVarP(&initHooks, "init-hooks", "i", false, "Initialize git hooks")
+}

config/config.go 🔗

@@ -7,6 +7,20 @@ import (
 	"github.com/caarlos0/env/v6"
 )
 
+//
+type GitHookOption struct {
+	OldSha  string
+	NewSha  string
+	RefName string
+}
+
+// GitHooks provides an interface for git server-side hooks.
+type GitHooks interface {
+	PreReceive(string, []GitHookOption)
+	Update(string, GitHookOption)
+	PostReceive(string, []GitHookOption)
+}
+
 // Callbacks provides an interface that can be used to run callbacks on different events.
 type Callbacks interface {
 	Tui(action string)
@@ -20,6 +34,7 @@ type Config struct {
 	Host             string   `env:"SOFT_SERVE_HOST" envDefault:"localhost"`
 	Port             int      `env:"SOFT_SERVE_PORT" envDefault:"23231"`
 	KeyPath          string   `env:"SOFT_SERVE_KEY_PATH"`
+	InternalKeyPath  string   `env:"SOFT_SERVE_INTERNAL_KEY_PATH"`
 	RepoPath         string   `env:"SOFT_SERVE_REPO_PATH" envDefault:".repos"`
 	InitialAdminKeys []string `env:"SOFT_SERVE_INITIAL_ADMIN_KEY" envSeparator:"\n"`
 	Callbacks        Callbacks
@@ -37,6 +52,10 @@ func DefaultConfig() *Config {
 		// NB: cross-platform-compatible path
 		cfg.KeyPath = filepath.Join(".ssh", "soft_serve_server_ed25519")
 	}
+	if cfg.InternalKeyPath == "" {
+		// NB: cross-platform-compatible path
+		cfg.InternalKeyPath = filepath.Join(".ssh", "soft_serve_internal_ed25519")
+	}
 	return cfg.WithCallbacks(nil)
 }
 

go.mod 🔗

@@ -8,7 +8,7 @@ require (
 	github.com/charmbracelet/bubbles v0.11.0
 	github.com/charmbracelet/bubbletea v0.22.0
 	github.com/charmbracelet/glamour v0.4.0
-	github.com/charmbracelet/lipgloss v0.5.0
+	github.com/charmbracelet/lipgloss v0.5.1-0.20220615005615-2e17a8a06096
 	github.com/charmbracelet/wish v0.5.0
 	github.com/dustin/go-humanize v1.0.0
 	github.com/gliderlabs/ssh v0.3.4
@@ -16,7 +16,7 @@ require (
 	github.com/go-git/go-git/v5 v5.4.3-0.20210630082519-b4368b2a2ca4
 	github.com/matryer/is v1.4.0
 	github.com/muesli/reflow v0.3.0
-	github.com/muesli/termenv v0.12.0
+	github.com/muesli/termenv v0.12.1-0.20220615005108-4e9068de9898
 	github.com/sergi/go-diff v1.1.0
 	golang.org/x/crypto v0.0.0-20220307211146-efcb8507fb70
 )
@@ -38,6 +38,7 @@ require (
 	github.com/acomagu/bufpipe v1.0.3 // indirect
 	github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
 	github.com/atotto/clipboard v0.1.4 // indirect
+	github.com/aymanbagabas/go-osc52 v1.0.3 // indirect
 	github.com/aymerick/douceur v0.2.0 // indirect
 	github.com/caarlos0/sshmarshal v0.1.0 // indirect
 	github.com/containerd/console v1.0.3 // indirect

go.sum 🔗

@@ -17,6 +17,8 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd
 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
 github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
 github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
+github.com/aymanbagabas/go-osc52 v1.0.3 h1:DTwqENW7X9arYimJrPeGZcV0ln14sGMt3pHZspWD+Mg=
+github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4=
 github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
 github.com/caarlos0/env/v6 v6.9.1 h1:zOkkjM0F6ltnQ5eBX6IPI41UP/KDGEK7rRPwGCNos8k=
@@ -36,8 +38,9 @@ github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJ
 github.com/charmbracelet/keygen v0.3.0 h1:mXpsQcH7DDlST5TddmXNXjS0L7ECk4/kLQYyBcsan2Y=
 github.com/charmbracelet/keygen v0.3.0/go.mod h1:1ukgO8806O25lUZ5s0IrNur+RlwTBERlezdgW71F5rM=
 github.com/charmbracelet/lipgloss v0.4.0/go.mod h1:vmdkHvce7UzX6xkyf4cca8WlwdQ5RQr8fzta+xl7BOM=
-github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8=
 github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs=
+github.com/charmbracelet/lipgloss v0.5.1-0.20220615005615-2e17a8a06096 h1:ai19sA3Zyg3DARevWCbdLOWt+MfWiE3e8voBqzFOgP8=
+github.com/charmbracelet/lipgloss v0.5.1-0.20220615005615-2e17a8a06096/go.mod h1:D7uPgcyfB9T1Ug2mfJOnES17o47nz5oqIzSSVrpcviU=
 github.com/charmbracelet/wish v0.5.0 h1:FkkdNBFqrLABR1ciNrAL2KCxoyWfKhXnIGZw6GfAtPg=
 github.com/charmbracelet/wish v0.5.0/go.mod h1:5GAn5SrDSZ7cgKjnC+3kDmiIo7I6k4/AYiRzC4+tpCk=
 github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
@@ -138,8 +141,8 @@ github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB
 github.com/muesli/termenv v0.9.0/go.mod h1:R/LzAKf+suGs4IsO95y7+7DpFHO0KABgnZqtlyx2mBw=
 github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
 github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
-github.com/muesli/termenv v0.12.0 h1:KuQRUE3PgxRFWhq4gHvZtPSLCGDqM5q/cYr1pZ39ytc=
-github.com/muesli/termenv v0.12.0/go.mod h1:WCCv32tusQ/EEZ5S8oUIIrC/nIuBcxCVqlN4Xfkv+7A=
+github.com/muesli/termenv v0.12.1-0.20220615005108-4e9068de9898 h1:0j+cbZdhLgpNxjg0nWCasHUA82fgWOXxxGgWNVOLS1I=
+github.com/muesli/termenv v0.12.1-0.20220615005108-4e9068de9898/go.mod h1:bN6sPNtkiahdhHv2Xm6RGU16LSCxfbIZvMfqjOCfrR4=
 github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
 github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
 github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=

internal/config/config.go 🔗

@@ -170,6 +170,10 @@ func (cfg *Config) Reload() error {
 			rm = md
 		}
 		r.SetReadme(rm, fp)
+		err := cfg.createHooks(r)
+		if err != nil {
+			return err
+		}
 	}
 	return nil
 }
@@ -275,3 +279,87 @@ func templatize(mdt string, tmpl interface{}) (string, error) {
 	}
 	return buf.String(), nil
 }
+
+type hookScript struct {
+	Executable string
+	Hook       string
+	Args       string
+	Envs       []string
+}
+
+var hookTmpl *template.Template
+
+func (cfg *Config) createHooks(repo *git.Repo) error {
+	if hookTmpl == nil {
+		var err 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 }} internal hook {{ .Hook }} {{ .Args }}
+`)
+		if err != nil {
+			return err
+		}
+	}
+
+	err := ensureDir(filepath.Join(repo.Path(), "hooks"))
+	if err != nil {
+		return err
+	}
+	ex, err := os.Executable()
+	if err != nil {
+		return err
+	}
+	rp, err := filepath.Abs(cfg.Cfg.RepoPath)
+	if err != nil {
+		return err
+	}
+	kp, err := filepath.Abs(cfg.Cfg.KeyPath)
+	if err != nil {
+		return err
+	}
+	ikp, err := filepath.Abs(cfg.Cfg.InternalKeyPath)
+	if err != nil {
+		return err
+	}
+	envs := []string{
+		fmt.Sprintf("SOFT_SERVE_BIND_ADDRESS=%s", cfg.Cfg.BindAddr),
+		fmt.Sprintf("SOFT_SERVE_PORT=%d", cfg.Cfg.Port),
+		fmt.Sprintf("SOFT_SERVE_HOST=%s", cfg.Cfg.Host),
+		fmt.Sprintf("SOFT_SERVE_REPO_PATH=%s", rp),
+		fmt.Sprintf("SOFT_SERVE_KEY_PATH=%s", kp),
+		fmt.Sprintf("SOFT_SERVE_INTERNAL_KEY_PATH=%s", ikp),
+	}
+	for _, hook := range []string{"pre-receive", "update", "post-receive"} {
+		var data bytes.Buffer
+		var args string
+		hp := filepath.Join(repo.Path(), "hooks", hook)
+		if hook == "update" {
+			args = "$1 $2 $3"
+		}
+		err = hookTmpl.Execute(&data, hookScript{
+			Executable: ex,
+			Hook:       hook,
+			Args:       args,
+			Envs:       envs,
+		})
+		if err != nil {
+			return err
+		}
+		err = os.WriteFile(hp, data.Bytes(), 0755) //nolint:gosec
+		if err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func ensureDir(path string) error {
+	_, err := os.Stat(path)
+	if os.IsNotExist(err) {
+		return os.MkdirAll(path, 0755)
+	}
+	return err
+}

internal/config/git.go 🔗

@@ -4,10 +4,28 @@ import (
 	"log"
 	"strings"
 
+	"github.com/charmbracelet/soft-serve/config"
 	gm "github.com/charmbracelet/wish/git"
 	"github.com/gliderlabs/ssh"
 )
 
+type HookOption = config.GitHookOption
+
+// PreReceive implements GitHooks interface. It is called before a git push is
+// performed.
+func (cfg *Config) PreReceive(repo string, args []HookOption) {
+}
+
+// Update implements GitHooks interface. It is called during a git push once for
+// each reference.
+func (cfg *Config) Update(repo string, arg HookOption) {
+}
+
+// PostReceive implements GitHooks interface. It is called after a git push is
+// performed.
+func (cfg *Config) PostReceive(repo string, args []HookOption) {
+}
+
 // Push registers Git push functionality for the given repo and key.
 func (cfg *Config) Push(repo string, pk ssh.PublicKey) {
 	go func() {

server/cmd/cmd.go 🔗

@@ -57,6 +57,7 @@ func RootCommand() *cobra.Command {
 		CatCommand(),
 		ListCommand(),
 		GitCommand(),
+		InternalCommand(),
 	)
 
 	return rootCmd

server/cmd/internal.go 🔗

@@ -0,0 +1,154 @@
+package cmd
+
+import (
+	"bufio"
+	"fmt"
+	"os"
+	"strings"
+
+	"github.com/charmbracelet/soft-serve/internal/config"
+	"github.com/gliderlabs/ssh"
+	"github.com/spf13/cobra"
+	gossh "golang.org/x/crypto/ssh"
+)
+
+// InternalCommand handles Soft Serve internal API requests.
+func InternalCommand() *cobra.Command {
+	preReceiveCmd := &cobra.Command{
+		Use:   "pre-receive",
+		Short: "Run git pre-receive hook",
+		RunE: func(cmd *cobra.Command, args []string) error {
+			ac, s := fromContext(cmd)
+			repoName := getRepoName(s)
+			opts := make([]config.HookOption, 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, config.HookOption{
+					OldSha:  fields[0],
+					NewSha:  fields[1],
+					RefName: fields[2],
+				})
+			}
+			ac.PreReceive(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 {
+			ac, s := fromContext(cmd)
+			repoName := getRepoName(s)
+			ac.Update(repoName, config.HookOption{
+				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, args []string) error {
+			ac, s := fromContext(cmd)
+			repoName := getRepoName(s)
+			opts := make([]config.HookOption, 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, config.HookOption{
+					OldSha:  fields[0],
+					NewSha:  fields[1],
+					RefName: fields[2],
+				})
+			}
+			ac.PostReceive(repoName, opts)
+			return nil
+		},
+	}
+
+	hookCmd := &cobra.Command{
+		Use:   "hook",
+		Short: "Run git server hooks",
+	}
+
+	hookCmd.AddCommand(
+		preReceiveCmd,
+		updateCmd,
+		postReceiveCmd,
+	)
+
+	// Check if the session's public key matches the internal API key.
+	authorized := func(cmd *cobra.Command) (bool, error) {
+		ac, s := fromContext(cmd)
+		pk := s.PublicKey()
+		kp := ac.Cfg.InternalKeyPath
+		pemKey, err := os.ReadFile(kp)
+		if err != nil {
+			return false, err
+		}
+		priv, err := gossh.ParsePrivateKey(pemKey)
+		if err != nil {
+			return false, err
+		}
+		if !ssh.KeysEqual(pk, priv.PublicKey()) {
+			return false, ErrUnauthorized
+		}
+		return true, nil
+	}
+	internalCmd := &cobra.Command{
+		Use:          "internal",
+		Short:        "Internal Soft Serve API",
+		Hidden:       true,
+		SilenceUsage: true,
+		PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
+			cmd.SilenceUsage = false
+			authd, err := authorized(cmd)
+			if err != nil {
+				cmd.SilenceUsage = true
+				return err
+			}
+			if !authd {
+			}
+			return nil
+		},
+		RunE: func(cmd *cobra.Command, args []string) error {
+			authd, err := authorized(cmd)
+			if err != nil {
+				return err
+			}
+			if !authd {
+				return ErrUnauthorized
+			}
+			return cmd.Help()
+		},
+	}
+
+	internalCmd.AddCommand(
+		hookCmd,
+	)
+
+	return internalCmd
+}
+
+func getRepoName(s ssh.Session) string {
+	var repoName string
+	for _, env := range s.Environ() {
+		if strings.HasPrefix(env, "SOFT_SERVE_REPO_NAME=") {
+			repoName = strings.TrimPrefix(env, "SOFT_SERVE_REPO_NAME=")
+			break
+		}
+	}
+	return repoName
+}