6efe53122728af4c9d5bc7af7829e90d6ad33b45

Author
Ayman Bagabas <ayman.bagabas@gmail.com>
Committer
Ayman Bagabas <ayman.bagabas@gmail.com>
Date

Message

feat(server): use a separate ssh server for internal commands

Diff

This diff is truncated to protect this page.

  1diff --git a/cmd/soft/hook.go b/cmd/soft/hook.go
  2index 4054303c5f927c7fb464b19ed18475d8f1686619..99a26d86f37463e5ae1dab05afc624aa30516996 100644
  3--- a/cmd/soft/hook.go
  4+++ b/cmd/soft/hook.go
  5@@ -135,6 +135,7 @@ func init() {
  6 	hookCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "", "path to config file")
  7 }
  8 
  9+// TODO: use ssh controlmaster
 10 func commonInit() (c *gossh.Client, s *gossh.Session, err error) {
 11 	cfg, err := config.ParseConfig(configPath)
 12 	if err != nil {
 13@@ -173,11 +174,11 @@ func commonInit() (c *gossh.Client, s *gossh.Session, err error) {
 14 
 15 func newClient(cfg *config.Config) (*gossh.Client, error) {
 16 	// Only accept the server's host key.
 17-	pk, err := keygen.New(cfg.SSH.KeyPath, keygen.WithKeyType(keygen.Ed25519))
 18+	pk, err := keygen.New(cfg.Internal.KeyPath, keygen.WithKeyType(keygen.Ed25519))
 19 	if err != nil {
 20 		return nil, err
 21 	}
 22-	ik, err := keygen.New(cfg.SSH.InternalKeyPath, keygen.WithKeyType(keygen.Ed25519))
 23+	ik, err := keygen.New(cfg.Internal.InternalKeyPath, keygen.WithKeyType(keygen.Ed25519))
 24 	if err != nil {
 25 		return nil, err
 26 	}
 27@@ -188,7 +189,7 @@ func newClient(cfg *config.Config) (*gossh.Client, error) {
 28 		},
 29 		HostKeyCallback: gossh.FixedHostKey(pk.PublicKey()),
 30 	}
 31-	c, err := gossh.Dial("tcp", cfg.SSH.ListenAddr, cc)
 32+	c, err := gossh.Dial("tcp", cfg.Internal.ListenAddr, cc)
 33 	if err != nil {
 34 		return nil, err
 35 	}
 36diff --git a/cmd/soft/serve.go b/cmd/soft/serve.go
 37index f0d3a42151137795ef3d650b75fc50f60644f876..55e0d2aca2c24c381d795c754149ad269edd5f83 100644
 38--- a/cmd/soft/serve.go
 39+++ b/cmd/soft/serve.go
 40@@ -2,6 +2,7 @@ package main
 41 
 42 import (
 43 	"context"
 44+	"fmt"
 45 	"os"
 46 	"os/signal"
 47 	"syscall"
 48@@ -24,7 +25,7 @@ var (
 49 			cfg := config.DefaultConfig()
 50 			s, err := server.NewServer(ctx, cfg)
 51 			if err != nil {
 52-				return err
 53+				return fmt.Errorf("start server: %w", err)
 54 			}
 55 
 56 			done := make(chan os.Signal, 1)
 57diff --git a/server/backend/sqlite/db.go b/server/backend/sqlite/db.go
 58index a7f971890281eb19cf2ed67c163586b7fecabcfc..839d9105db96bc4a624cca5c1dca357758fd5a8a 100644
 59--- a/server/backend/sqlite/db.go
 60+++ b/server/backend/sqlite/db.go
 61@@ -61,6 +61,8 @@ func (d *SqliteBackend) init() error {
 62 			}
 63 
 64 			// Add initial keys
 65+			// Don't use cfg.AdminKeys since it also includes the internal key
 66+			// used for internal api access.
 67 			for _, k := range d.cfg.InitialAdminKeys {
 68 				pk, _, err := backend.ParseAuthorizedKey(k)
 69 				if err != nil {
 70diff --git a/server/backend/sqlite/sqlite.go b/server/backend/sqlite/sqlite.go
 71index 0d1f1c015dc6134715ca67367f623d3200f5f61a..aca1f641132cb585af5d8c739d5815bf3cf60544 100644
 72--- a/server/backend/sqlite/sqlite.go
 73+++ b/server/backend/sqlite/sqlite.go
 74@@ -186,8 +186,7 @@ func (d *SqliteBackend) ImportRepository(name string, remote string, opts backen
 75 			Envs: []string{
 76 				fmt.Sprintf(`GIT_SSH_COMMAND=ssh -o UserKnownHostsFile="%s" -o StrictHostKeyChecking=no -i "%s"`,
 77 					filepath.Join(d.cfg.DataPath, "ssh", "known_hosts"),
 78-					// FIXME: upstream keygen appends _ed25519 to the key path.
 79-					d.cfg.SSH.ClientKeyPath+"_ed25519",
 80+					d.cfg.Internal.ClientKeyPath,
 81 				),
 82 			},
 83 		},
 84diff --git a/server/backend/sqlite/user.go b/server/backend/sqlite/user.go
 85index 9f7e48273d3e12ffa31821debae11ca1855ae5db..456581d4d74c43a507d33194089db8d0611c98dd 100644
 86--- a/server/backend/sqlite/user.go
 87+++ b/server/backend/sqlite/user.go
 88@@ -118,9 +118,8 @@ func (d *SqliteBackend) AccessLevel(repo string, username string) backend.Access
 89 //
 90 // It implements backend.Backend.
 91 func (d *SqliteBackend) AccessLevelByPublicKey(repo string, pk ssh.PublicKey) backend.AccessLevel {
 92-	for _, k := range append(d.cfg.InitialAdminKeys, d.cfg.InternalPublicKey) {
 93-		ik, _, err := backend.ParseAuthorizedKey(k)
 94-		if err == nil && backend.KeysEqual(pk, ik) {
 95+	for _, k := range d.cfg.AdminKeys() {
 96+		if backend.KeysEqual(pk, k) {
 97 			return backend.AdminAccess
 98 		}
 99 	}
100diff --git a/server/cmd/cmd.go b/server/cmd/cmd.go
101index 340670408f85db787ec11968ac897bcd7555f059..a1d36d893bdf8b9f9e3a00ad6f77561c5328db36 100644
102--- a/server/cmd/cmd.go
103+++ b/server/cmd/cmd.go
104@@ -11,7 +11,7 @@ import (
105 	"github.com/charmbracelet/log"
106 	"github.com/charmbracelet/soft-serve/server/backend"
107 	"github.com/charmbracelet/soft-serve/server/config"
108-	"github.com/charmbracelet/soft-serve/server/hooks"
109+	"github.com/charmbracelet/soft-serve/server/errors"
110 	"github.com/charmbracelet/soft-serve/server/utils"
111 	"github.com/charmbracelet/ssh"
112 	"github.com/charmbracelet/wish"
113@@ -35,15 +35,6 @@ var (
114 	HooksCtxKey = ContextKey("hooks")
115 )
116 
117-var (
118-	// ErrUnauthorized is returned when the user is not authorized to perform action.
119-	ErrUnauthorized = fmt.Errorf("Unauthorized")
120-	// ErrRepoNotFound is returned when the repo is not found.
121-	ErrRepoNotFound = fmt.Errorf("Repository not found")
122-	// ErrFileNotFound is returned when the file is not found.
123-	ErrFileNotFound = fmt.Errorf("File not found")
124-)
125-
126 var (
127 	logger = log.WithPrefix("server.cmd")
128 )
129@@ -136,7 +127,6 @@ func rootCommand(cfg *config.Config, s ssh.Session) *cobra.Command {
130 	})
131 	rootCmd.CompletionOptions.DisableDefaultCmd = true
132 	rootCmd.AddCommand(
133-		hookCommand(),
134 		repoCommand(),
135 	)
136 
137@@ -176,15 +166,14 @@ func checkIfReadable(cmd *cobra.Command, args []string) error {
138 	rn := utils.SanitizeRepo(repo)
139 	auth := cfg.Backend.AccessLevelByPublicKey(rn, s.PublicKey())
140 	if auth < backend.ReadOnlyAccess {
141-		return ErrUnauthorized
142+		return errors.ErrUnauthorized
143 	}
144 	return nil
145 }
146 
147 func isPublicKeyAdmin(cfg *config.Config, pk ssh.PublicKey) bool {
148-	for _, k := range cfg.InitialAdminKeys {
149-		pk2, _, err := backend.ParseAuthorizedKey(k)
150-		if err == nil && backend.KeysEqual(pk, pk2) {
151+	for _, k := range cfg.AdminKeys() {
152+		if backend.KeysEqual(pk, k) {
153 			return true
154 		}
155 	}
156@@ -199,11 +188,11 @@ func checkIfAdmin(cmd *cobra.Command, _ []string) error {
157 
158 	user, _ := cfg.Backend.UserByPublicKey(s.PublicKey())
159 	if user == nil {
160-		return ErrUnauthorized
161+		return errors.ErrUnauthorized
162 	}
163 
164 	if !user.IsAdmin() {
165-		return ErrUnauthorized
166+		return errors.ErrUnauthorized
167 	}
168 
169 	return nil
170@@ -218,13 +207,13 @@ func checkIfCollab(cmd *cobra.Command, args []string) error {
171 	rn := utils.SanitizeRepo(repo)
172 	auth := cfg.Backend.AccessLevelByPublicKey(rn, s.PublicKey())
173 	if auth < backend.ReadWriteAccess {
174-		return ErrUnauthorized
175+		return errors.ErrUnauthorized
176 	}
177 	return nil
178 }
179 
180 // Middleware is the Soft Serve middleware that handles SSH commands.
181-func Middleware(cfg *config.Config, hooks hooks.Hooks) wish.Middleware {
182+func Middleware(cfg *config.Config) wish.Middleware {
183 	return func(sh ssh.Handler) ssh.Handler {
184 		return func(s ssh.Session) {
185 			func() {
186@@ -245,7 +234,6 @@ func Middleware(cfg *config.Config, hooks hooks.Hooks) wish.Middleware {
187 
188 				ctx := context.WithValue(s.Context(), ConfigCtxKey, cfg)
189 				ctx = context.WithValue(ctx, SessionCtxKey, s)
190-				ctx = context.WithValue(ctx, HooksCtxKey, hooks)
191 
192 				rootCmd := rootCommand(cfg, s)
193 				rootCmd.SetArgs(args)
194diff --git a/server/cmd/tree.go b/server/cmd/tree.go
195index 0ca08a922d90d3a2dd88631454d15411d841a80a..19ea3720d4d99e10887944b1eff12129b516e927 100644
196--- a/server/cmd/tree.go
197+++ b/server/cmd/tree.go
198@@ -4,6 +4,7 @@ import (
199 	"fmt"
200 
201 	"github.com/charmbracelet/soft-serve/git"
202+	"github.com/charmbracelet/soft-serve/server/errors"
203 	"github.com/dustin/go-humanize"
204 	"github.com/spf13/cobra"
205 )
206@@ -58,7 +59,7 @@ func treeCommand() *cobra.Command {
207 			if path != "" && path != "/" {
208 				te, err := tree.TreeEntry(path)
209 				if err == git.ErrRevisionNotExist {
210-					return ErrFileNotFound
211+					return errors.ErrFileNotFound
212 				}
213 				if err != nil {
214 					return err
215diff --git a/server/cmd/user.go b/server/cmd/user.go
216index 8c9a904d71428dd5d0e5e1510e2cfae8a4a959e7..2cb4f9aa179dd15e17fec133c11024cc980bbd93 100644
217--- a/server/cmd/user.go
218+++ b/server/cmd/user.go
219@@ -142,7 +142,6 @@ func userCommand() *cobra.Command {
220 		PersistentPreRunE: checkIfAdmin,
221 		RunE: func(cmd *cobra.Command, args []string) error {
222 			cfg, s := fromContext(cmd)
223-			ak := backend.MarshalAuthorizedKey(s.PublicKey())
224 			username := args[0]
225 
226 			user, err := cfg.Backend.User(username)
227@@ -151,8 +150,8 @@ func userCommand() *cobra.Command {
228 			}
229 
230 			isAdmin := user.IsAdmin()
231-			for _, k := range cfg.InitialAdminKeys {
232-				if ak == k {
233+			for _, k := range cfg.AdminKeys() {
234+				if backend.KeysEqual(k, s.PublicKey()) {
235 					isAdmin = true
236 					break
237 				}
238diff --git a/server/config/config.go b/server/config/config.go
239index 13ecd1268a79c32ebf67cf7551e922c090ba29cf..c9665755bf246cfbc63c78d58ee9ed455f180d59 100644
240--- a/server/config/config.go
241+++ b/server/config/config.go
242@@ -10,6 +10,7 @@ import (
243 	"github.com/caarlos0/env/v7"
244 	"github.com/charmbracelet/log"
245 	"github.com/charmbracelet/soft-serve/server/backend"
246+	"golang.org/x/crypto/ssh"
247 	"gopkg.in/yaml.v3"
248 )
249 
250@@ -24,12 +25,6 @@ type SSHConfig struct {
251 	// KeyPath is the path to the SSH server's private key.
252 	KeyPath string `env:"KEY_PATH" yaml:"key_path"`
253 
254-	// ClientKeyPath is the path to the SSH server's client private key.
255-	ClientKeyPath string `env:"CLIENT_KEY_PATH" yaml:"client_key_path"`
256-
257-	// InternalKeyPath is the path to the SSH server's internal private key.
258-	InternalKeyPath string `env:"INTERNAL_KEY_PATH" yaml:"internal_key_path"`
259-
260 	// MaxTimeout is the maximum number of seconds a connection can take.
261 	MaxTimeout int `env:"MAX_TIMEOUT" yaml:"max_timeout`
262 
263@@ -73,6 +68,22 @@ type StatsConfig struct {
264 	ListenAddr string `env:"LISTEN_ADDR" yaml:"listen_addr"`
265 }
266 
267+// InternalConfig is the configuration for the internal server.
268+// This is used for internal communication between the Soft Serve client and server.
269+type InternalConfig struct {
270+	// ListenAddr is the address on which the internal server will listen.
271+	ListenAddr string `env:"LISTEN_ADDR" yaml:"listen_addr"`
272+
273+	// KeyPath is the path to the SSH server's host private key.
274+	KeyPath string `env:"KEY_PATH" yaml:"key_path"`
275+
276+	// InternalKeyPath is the path to the server's internal private key.
277+	InternalKeyPath string `env:"INTERNAL_KEY_PATH" yaml:"internal_key_path"`
278+
279+	// ClientKeyPath is the path to the server's client private key.
280+	ClientKeyPath string `env:"CLIENT_KEY_PATH" yaml:"client_key_path"`
281+}
282+
283 // Config is the configuration for Soft Serve.
284 type Config struct {
285 	// Name is the name of the server.
286@@ -90,6 +101,9 @@ type Config struct {
287 	// Stats is the configuration for the stats server.
288 	Stats StatsConfig `envPrefix:"STATS_" yaml:"stats"`
289 
290+	// Internal is the configuration for the internal server.
291+	Internal InternalConfig `envPrefix:"INTERNAL_" yaml:"internal"`
292+
293 	// InitialAdminKeys is a list of public keys that will be added to the list of admins.
294 	InitialAdminKeys []string `env:"INITIAL_ADMIN_KEYS" envSeparator:"\n" yaml:"initial_admin_keys"`
295 
296@@ -98,12 +112,6 @@ type Config struct {
297 
298 	// Backend is the Git backend to use.
299 	Backend backend.Backend `yaml:"-"`
300-
301-	// InternalPublicKey is the public key of the internal SSH key.
302-	InternalPublicKey string `yaml:"-"`
303-
304-	// ClientPublicKey is the public key of the client SSH key.
305-	ClientPublicKey string `yaml:"-"`
306 }
307 
308 func parseConfig(path string) (*Config, error) {
309@@ -112,13 +120,11 @@ func parseConfig(path string) (*Config, error) {
310 		Name:     "Soft Serve",
311 		DataPath: dataPath,
312 		SSH: SSHConfig{
313-			ListenAddr:      ":23231",
314-			PublicURL:       "ssh://localhost:23231",
315-			KeyPath:         filepath.Join("ssh", "soft_serve_host_ed25519"),
316-			ClientKeyPath:   filepath.Join("ssh", "soft_serve_client_ed25519"),
317-			InternalKeyPath: filepath.Join("ssh", "soft_serve_internal_ed25519"),
318-			MaxTimeout:      0,
319-			IdleTimeout:     120,
320+			ListenAddr:  ":23231",
321+			PublicURL:   "ssh://localhost:23231",
322+			KeyPath:     filepath.Join("ssh", "soft_serve_host_ed25519"),
323+			MaxTimeout:  0,
324+			IdleTimeout: 120,
325 		},
326 		Git: GitConfig{
327 			ListenAddr:     ":9418",
328@@ -127,11 +133,17 @@ func parseConfig(path string) (*Config, error) {
329 			MaxConnections: 32,
330 		},
331 		HTTP: HTTPConfig{
332-			ListenAddr: ":8080",
333-			PublicURL:  "http://localhost:8080",
334+			ListenAddr: ":23232",
335+			PublicURL:  "http://localhost:23232",
336 		},
337 		Stats: StatsConfig{
338-			ListenAddr: ":8081",
339+			ListenAddr: "localhost:23233",
340+		},
341+		Internal: InternalConfig{
342diff --git a/server/config/file.go b/server/config/file.go
343index d436e14b1022bcfa339aab8194f6b32e6f2957a4..81a0931e3bc489a1f502ca355ef309733577f836 100644
344--- a/server/config/file.go
345+++ b/server/config/file.go
346@@ -24,14 +24,6 @@ ssh:
347   # The path to the SSH server's private key.
348   key_path: "{{ .SSH.KeyPath }}"
349 
350-  # The path to the SSH server's client private key.
351-  # This key will be used to authenticate the server to make git requests to
352-  # ssh remotes.
353-  client_key_path: "{{ .SSH.ClientKeyPath }}"
354-
355-  # The path to the SSH server's internal api private key.
356-  internal_key_path: "{{ .SSH.InternalKeyPath }}"
357-
358   # The maximum number of seconds a connection can take.
359   # A value of 0 means no timeout.
360   max_timeout: {{ .SSH.MaxTimeout }}
361@@ -75,6 +67,22 @@ stats:
362   # The address on which the stats server will listen.
363   listen_addr: "{{ .Stats.ListenAddr }}"
364 
365+# The internal server configuration.
366+internal:
367+  # The address on which the internal server will listen.
368+  listen_addr: "{{ .Internal.ListenAddr }}"
369+
370+  # The path to the Internal server's host private key.
371+  key_path: "{{ .Internal.KeyPath }}"
372+
373+  # The path to the Internal server's client private key.
374+  # This key will be used to authenticate the server to make git requests to
375+  # ssh remotes.
376+  client_key_path: "{{ .Internal.ClientKeyPath }}"
377+
378+  # The path to the Internal server's internal api private key.
379+  internal_key_path: "{{ .Internal.InternalKeyPath }}"
380+
381 # Additional admin keys.
382 #initial_admin_keys:
383 #  - "ssh-rsa AAAAB3NzaC1yc2..."
384diff --git a/server/errors/errors.go b/server/errors/errors.go
385new file mode 100644
386index 0000000000000000000000000000000000000000..20399d09c42b8b13fbaaa69a0ac8df1a1986ad56
387--- /dev/null
388+++ b/server/errors/errors.go
389@@ -0,0 +1,12 @@
390+package errors
391+
392+import "fmt"
393+
394+var (
395+	// ErrUnauthorized is returned when the user is not authorized to perform action.
396+	ErrUnauthorized = fmt.Errorf("Unauthorized")
397+	// ErrRepoNotFound is returned when the repo is not found.
398+	ErrRepoNotFound = fmt.Errorf("Repository not found")
399+	// ErrFileNotFound is returned when the file is not found.
400+	ErrFileNotFound = fmt.Errorf("File not found")
401+)
402diff --git a/server/hooks.go b/server/hooks.go
403index a4f9ed5683f2c728cdf38854dd53f256ad780c22..5258901c06585fc0cfff8b543f830d0a5338c692 100644
404--- a/server/hooks.go
405+++ b/server/hooks.go
406@@ -3,6 +3,7 @@ package server
407 import (
408 	"io"
409 
410+	"github.com/charmbracelet/log"
411 	"github.com/charmbracelet/soft-serve/server/hooks"
412 )
413 
414@@ -11,42 +12,43 @@ var _ hooks.Hooks = (*Server)(nil)
415 // PostReceive is called by the git post-receive hook.
416 //
417 // It implements Hooks.
418-func (*Server) PostReceive(stdout io.Writer, stderr io.Writer, repo string, args []hooks.HookArg) {
419-	logger.Debug("post-receive hook called", "repo", repo, "args", args)
420+func (*Server) PostReceive(stdin io.Reader, stdout io.Writer, stderr io.Writer, repo string, args []hooks.HookArg) {
421+	io.WriteString(stdout, "Hello, world!\n")
422+	log.WithPrefix("server.hooks").Debug("post-receive hook called", "repo", repo, "args", args)
423 }
424 
425 // PreReceive is called by the git pre-receive hook.
426 //
427 // It implements Hooks.
428-func (*Server) PreReceive(stdout io.Writer, stderr io.Writer, repo string, args []hooks.HookArg) {
429-	logger.Debug("pre-receive hook called", "repo", repo, "args", args)
430+func (*Server) PreReceive(stdin io.Reader, stdout io.Writer, stderr io.Writer, repo string, args []hooks.HookArg) {
431+	log.WithPrefix("server.hooks").Debug("pre-receive hook called", "repo", repo, "args", args)
432 }
433 
434 // Update is called by the git update hook.
435 //
436 // It implements Hooks.
437-func (*Server) Update(stdout io.Writer, stderr io.Writer, repo string, arg hooks.HookArg) {
438-	logger.Debug("update hook called", "repo", repo, "arg", arg)
439+func (*Server) Update(stdin io.Reader, stdout io.Writer, stderr io.Writer, repo string, arg hooks.HookArg) {
440+	log.WithPrefix("server.hooks").Debug("update hook called", "repo", repo, "arg", arg)
441 }
442 
443 // PostUpdate is called by the git post-update hook.
444 //
445 // It implements Hooks.
446-func (s *Server) PostUpdate(stdout io.Writer, stderr io.Writer, repo string, args ...string) {
447+func (s *Server) PostUpdate(stdin io.Reader, stdout io.Writer, stderr io.Writer, repo string, args ...string) {
448 	rr, err := s.Config.Backend.Repository(repo)
449 	if err != nil {
450-		logger.WithPrefix("server.hooks.post-update").Error("error getting repository", "repo", repo, "err", err)
451+		log.WithPrefix("server.hooks.post-update").Error("error getting repository", "repo", repo, "err", err)
452 		return
453 	}
454 
455 	r, err := rr.Open()
456 	if err != nil {
457-		logger.WithPrefix("server.hooks.post-update").Error("error opening repository", "repo", repo, "err", err)
458+		log.WithPrefix("server.hooks.post-update").Error("error opening repository", "repo", repo, "err", err)
459 		return
460 	}
461 
462 	if err := r.UpdateServerInfo(); err != nil {
463-		logger.WithPrefix("server.hooks.post-update").Error("error updating server info", "repo", repo, "err", err)
464+		log.WithPrefix("server.hooks.post-update").Error("error updating server info", "repo", repo, "err", err)
465 		return
466 	}
467 }
468diff --git a/server/hooks/hooks.go b/server/hooks/hooks.go
469index fb47b729b9a99a7df02edcf3cfe3fa955ca73ac7..639950413741d4cebd47eebfcffc47b0e5107303 100644
470--- a/server/hooks/hooks.go
471+++ b/server/hooks/hooks.go
472@@ -11,8 +11,8 @@ type HookArg struct {
473 
474 // Hooks provides an interface for git server-side hooks.
475 type Hooks interface {
476-	PreReceive(stdout io.Writer, stderr io.Writer, repo string, args []HookArg)
477-	Update(stdout io.Writer, stderr io.Writer, repo string, arg HookArg)
478-	PostReceive(stdout io.Writer, stderr io.Writer, repo string, args []HookArg)
479-	PostUpdate(stdout io.Writer, stderr io.Writer, repo string, args ...string)
480+	PreReceive(stdin io.Reader, stdout io.Writer, stderr io.Writer, repo string, args []HookArg)
481+	Update(stdin io.Reader, stdout io.Writer, stderr io.Writer, repo string, arg HookArg)
482+	PostReceive(stdin io.Reader, stdout io.Writer, stderr io.Writer, repo string, args []HookArg)
483+	PostUpdate(stdin io.Reader, stdout io.Writer, stderr io.Writer, repo string, args ...string)
484 }
485diff --git a/server/internal/cmd.go b/server/internal/cmd.go
486new file mode 100644
487index 0000000000000000000000000000000000000000..62709bed11248fa73e540decbb53ca03a437921e
488--- /dev/null
489+++ b/server/internal/cmd.go
490@@ -0,0 +1,84 @@
491+package internal
492+
493+import (
494+	"context"
495+
496+	"github.com/charmbracelet/soft-serve/server/config"
497+	"github.com/charmbracelet/soft-serve/server/hooks"
498+	"github.com/charmbracelet/ssh"
499+	"github.com/charmbracelet/wish"
500+	"github.com/spf13/cobra"
501+)
502+
503+var (
504+	hooksCtxKey   = "hooks"
505+	sessionCtxKey = "session"
506+	configCtxKey  = "config"
507+)
508+
509+// rootCommand is the root command for the server.
510+func rootCommand(cfg *config.Config, s ssh.Session) *cobra.Command {
511+	rootCmd := &cobra.Command{
512+		Short:        "Soft Serve internal API.",
513+		SilenceUsage: true,
514+	}
515+
516+	rootCmd.SetIn(s)
517+	rootCmd.SetOut(s)
518+	rootCmd.SetErr(s)
519+	rootCmd.CompletionOptions.DisableDefaultCmd = true
520+
521+	rootCmd.AddCommand(
522+		hookCommand(),
523+	)
524+
525+	return rootCmd
526+}
527+
528+// Middleware returns the middleware for the server.
529+func (i *InternalServer) Middleware(hooks hooks.Hooks) wish.Middleware {
530+	return func(sh ssh.Handler) ssh.Handler {
531+		return func(s ssh.Session) {
532+			_, _, active := s.Pty()
533+			if active {
534+				return
535+			}
536+
537+			// Ignore git server commands.
538+			args := s.Command()
539+			if len(args) > 0 {
540+				if args[0] == "git-receive-pack" ||
541+					args[0] == "git-upload-pack" ||
542+					args[0] == "git-upload-archive" {
543+					return
544+				}
545+			}
546+
547+			ctx := context.WithValue(s.Context(), hooksCtxKey, hooks)
548+			ctx = context.WithValue(ctx, sessionCtxKey, s)
549+			ctx = context.WithValue(ctx, configCtxKey, i.cfg)
550+
551+			rootCmd := rootCommand(i.cfg, s)
552+			rootCmd.SetArgs(args)
553+			if len(args) == 0 {
554+				// otherwise it'll default to os.Args, which is not what we want.
555+				rootCmd.SetArgs([]string{"--help"})
556+			}
557+			rootCmd.SetIn(s)
558+			rootCmd.SetOut(s)
559+			rootCmd.CompletionOptions.DisableDefaultCmd = true
560+			rootCmd.SetErr(s.Stderr())
561+			if err := rootCmd.ExecuteContext(ctx); err != nil {
562+				_ = s.Exit(1)
563+			}
564+			sh(s)
565+		}
566+	}
567+}
568+
569+func fromContext(cmd *cobra.Command) (*config.Config, ssh.Session) {
570+	ctx := cmd.Context()
571+	cfg := ctx.Value(configCtxKey).(*config.Config)
572+	s := ctx.Value(sessionCtxKey).(ssh.Session)
573+	return cfg, s
574+}
575diff --git a/server/cmd/hook.go b/server/internal/hook.go
576rename from server/cmd/hook.go
577rename to server/internal/hook.go
578index 7e520e5c8dc0a774c25a0eb0a15845219fbf820d..a4d4ecbaf7254f85ea798f0026009b8154724ba7 100644
579--- a/server/cmd/hook.go
580+++ b/server/internal/hook.go
581@@ -1,4 +1,4 @@
582-package cmd
583+package internal
584 
585 import (
586 	"bufio"
587@@ -6,7 +6,9 @@ import (
588 	"strings"
589 
590 	"github.com/charmbracelet/keygen"
591+	"github.com/charmbracelet/log"
592 	"github.com/charmbracelet/soft-serve/server/backend"
593+	"github.com/charmbracelet/soft-serve/server/errors"
594 	"github.com/charmbracelet/soft-serve/server/hooks"
595 	"github.com/charmbracelet/ssh"
596 	"github.com/spf13/cobra"
597@@ -15,12 +17,11 @@ import (
598 // hookCommand handles Soft Serve internal API git hook requests.
599 func hookCommand() *cobra.Command {
600 	preReceiveCmd := &cobra.Command{
601-		Use:               "pre-receive",
602-		Short:             "Run git pre-receive hook",
603-		PersistentPreRunE: checkIfInternal,
604+		Use:   "pre-receive",
605+		Short: "Run git pre-receive hook",
606 		RunE: func(cmd *cobra.Command, args []string) error {
607 			_, s := fromContext(cmd)
608-			hks := cmd.Context().Value(HooksCtxKey).(hooks.Hooks)
609+			hks := cmd.Context().Value(hooksCtxKey).(hooks.Hooks)
610 			repoName := getRepoName(s)
611 			opts := make([]hooks.HookArg, 0)
612 			scanner := bufio.NewScanner(s)
613@@ -35,21 +36,20 @@ func hookCommand() *cobra.Command {
614 					RefName: fields[2],
615 				})
616 			}
617-			hks.PreReceive(s, s.Stderr(), repoName, opts)
618+			hks.PreReceive(s, s, s.Stderr(), repoName, opts)
619 			return nil
620 		},
621 	}
622 
623 	updateCmd := &cobra.Command{
624-		Use:               "update",
625-		Short:             "Run git update hook",
626-		Args:              cobra.ExactArgs(3),
627-		PersistentPreRunE: checkIfInternal,
628+		Use:   "update",
629+		Short: "Run git update hook",
630+		Args:  cobra.ExactArgs(3),
631 		RunE: func(cmd *cobra.Command, args []string) error {
632 			_, s := fromContext(cmd)
633-			hks := cmd.Context().Value(HooksCtxKey).(hooks.Hooks)
634+			hks := cmd.Context().Value(hooksCtxKey).(hooks.Hooks)
635 			repoName := getRepoName(s)
636-			hks.Update(s, s.Stderr(), repoName, hooks.HookArg{
637+			hks.Update(s, s, s.Stderr(), repoName, hooks.HookArg{
638 				RefName: args[0],
639 				OldSha:  args[1],
640 				NewSha:  args[2],
641@@ -59,12 +59,11 @@ func hookCommand() *cobra.Command {
642 	}
643 
644 	postReceiveCmd := &cobra.Command{
645-		Use:               "post-receive",
646-		Short:             "Run git post-receive hook",
647-		PersistentPreRunE: checkIfInternal,
648+		Use:   "post-receive",
649+		Short: "Run git post-receive hook",
650 		RunE: func(cmd *cobra.Command, _ []string) error {
651 			_, s := fromContext(cmd)
652-			hks := cmd.Context().Value(HooksCtxKey).(hooks.Hooks)
653+			hks := cmd.Context().Value(hooksCtxKey).(hooks.Hooks)
654 			repoName := getRepoName(s)
655 			opts := make([]hooks.HookArg, 0)
656 			scanner := bufio.NewScanner(s)
657@@ -79,20 +78,19 @@ func hookCommand() *cobra.Command {
658 					RefName: fields[2],
659 				})
660 			}
661-			hks.PostReceive(s, s.Stderr(), repoName, opts)
662+			hks.PostReceive(s, s, s.Stderr(), repoName, opts)
663 			return nil
664 		},
665 	}
666 
667 	postUpdateCmd := &cobra.Command{
668-		Use:               "post-update",
669-		Short:             "Run git post-update hook",
670-		PersistentPreRunE: checkIfInternal,
671+		Use:   "post-update",
672+		Short: "Run git post-update hook",
673 		RunE: func(cmd *cobra.Command, args []string) error {
674 			_, s := fromContext(cmd)
675-			hks := cmd.Context().Value(HooksCtxKey).(hooks.Hooks)
676+			hks := cmd.Context().Value(hooksCtxKey).(hooks.Hooks)
677 			repoName := getRepoName(s)
678-			hks.PostUpdate(s, s.Stderr(), repoName, args...)
679+			hks.PostUpdate(s, s, s.Stderr(), repoName, args...)
680 			return nil
681diff --git a/server/internal/internal.go b/server/internal/internal.go
682new file mode 100644
683index 0000000000000000000000000000000000000000..52114c4ca0afbfdc2fd1da7fa81d914c68139777
684--- /dev/null
685+++ b/server/internal/internal.go
686@@ -0,0 +1,86 @@
687+package internal
688+
689+import (
690+	"context"
691+	"fmt"
692+
693+	"github.com/charmbracelet/keygen"
694+	"github.com/charmbracelet/soft-serve/server/backend"
695+	"github.com/charmbracelet/soft-serve/server/config"
696+	"github.com/charmbracelet/soft-serve/server/hooks"
697+	"github.com/charmbracelet/ssh"
698+	"github.com/charmbracelet/wish"
699+)
700+
701+// InternalServer is a internal interface to communicate with the server.
702+type InternalServer struct {
703+	cfg *config.Config
704+	s   *ssh.Server
705+	kp  *keygen.SSHKeyPair
706+	ckp *keygen.SSHKeyPair
707+}
708+
709+// NewInternalServer returns a new internal server.
710+func NewInternalServer(cfg *config.Config, hooks hooks.Hooks) (*InternalServer, error) {
711+	i := &InternalServer{cfg: cfg}
712+
713+	// Create internal key.
714+	ikp, err := keygen.New(
715+		cfg.Internal.InternalKeyPath,
716+		keygen.WithKeyType(keygen.Ed25519),
717+		keygen.WithWrite(),
718+	)
719+	if err != nil {
720+		return nil, fmt.Errorf("internal key: %w", err)
721+	}
722+
723+	i.kp = ikp
724+
725+	// Create client key.
726+	ckp, err := keygen.New(
727+		cfg.Internal.ClientKeyPath,
728+		keygen.WithKeyType(keygen.Ed25519),
729+		keygen.WithWrite(),
730+	)
731+	if err != nil {
732+		return nil, fmt.Errorf("client key: %w", err)
733+	}
734+
735+	i.ckp = ckp
736+
737+	s, err := wish.NewServer(
738+		wish.WithAddress(cfg.Internal.ListenAddr),
739+		wish.WithHostKeyPath(cfg.Internal.KeyPath),
740+		wish.WithPublicKeyAuth(i.PublicKeyHandler),
741+		wish.WithMiddleware(
742+			i.Middleware(hooks),
743+		),
744+	)
745+	if err != nil {
746+		return nil, fmt.Errorf("wish: %w", err)
747+	}
748+
749+	i.s = s
750+
751+	return i, nil
752+}
753+
754+// PublicKeyHandler handles public key authentication.
755+func (i *InternalServer) PublicKeyHandler(ctx ssh.Context, pk ssh.PublicKey) bool {
756+	return backend.KeysEqual(i.kp.PublicKey(), pk)
757+}
758+
759+// Start starts the internal server.
760+func (i *InternalServer) Start() error {
761+	return i.s.ListenAndServe()
762+}
763+
764+// Shutdown shuts down the internal server.
765+func (i *InternalServer) Shutdown(ctx context.Context) error {
766+	return i.s.Shutdown(ctx)
767+}
768+
769+// Close closes the internal server.
770+func (i *InternalServer) Close() error {
771+	return i.s.Close()
772+}
773diff --git a/server/jobs.go b/server/jobs.go
774index e68c7b83c4527462ef89220c3c46a43f0cd66b16..37ecbeb36a33c540a38bfd5ace33615f0cce05ce 100644
775--- a/server/jobs.go
776+++ b/server/jobs.go
777@@ -38,8 +38,7 @@ func mirrorJob(cfg *config.Config) func() {
778 				cmd.AddEnvs(
779 					fmt.Sprintf(`GIT_SSH_COMMAND=ssh -o UserKnownHostsFile="%s" -o StrictHostKeyChecking=no -i "%s"`,
780 						filepath.Join(cfg.DataPath, "ssh", "known_hosts"),
781-						// FIXME: upstream keygen appends _ed25519 to the key path.
782-						cfg.SSH.ClientKeyPath+"_ed25519",
783+						cfg.Internal.ClientKeyPath,
784 					),
785 				)
786 				if _, err := cmd.RunInDir(r.Path); err != nil {
787diff --git a/server/server.go b/server/server.go
788index 84fe0fda6292303e9610fb63fe7b76b1d16bd498..ecc2c7c357897d71adc4610fb252ca357a291470 100644
789--- a/server/server.go
790+++ b/server/server.go
791@@ -3,9 +3,9 @@ package server
792 import (
793 	"context"
794 	"errors"
795+	"fmt"
796 	"net/http"
797 
798-	"github.com/charmbracelet/keygen"
799 	"github.com/charmbracelet/log"
800 
801 	"github.com/charmbracelet/soft-serve/server/backend"
802@@ -13,6 +13,7 @@ import (
803 	"github.com/charmbracelet/soft-serve/server/config"
804 	"github.com/charmbracelet/soft-serve/server/cron"
805 	"github.com/charmbracelet/soft-serve/server/daemon"
806+	"github.com/charmbracelet/soft-serve/server/internal"
807 	sshsrv "github.com/charmbracelet/soft-serve/server/ssh"
808 	"github.com/charmbracelet/soft-serve/server/stats"
809 	"github.com/charmbracelet/soft-serve/server/web"
810@@ -26,14 +27,15 @@ var (
811 
812 // Server is the Soft Serve server.
813 type Server struct {
814-	SSHServer   *sshsrv.SSHServer
815-	GitDaemon   *daemon.GitDaemon
816-	HTTPServer  *web.HTTPServer
817-	StatsServer *stats.StatsServer
818-	Cron        *cron.CronScheduler
819-	Config      *config.Config
820-	Backend     backend.Backend
821-	ctx         context.Context
822+	SSHServer      *sshsrv.SSHServer
823+	GitDaemon      *daemon.GitDaemon
824+	HTTPServer     *web.HTTPServer
825+	StatsServer    *stats.StatsServer
826+	InternalServer *internal.InternalServer
827+	Cron           *cron.CronScheduler
828+	Config         *config.Config
829+	Backend        backend.Backend
830+	ctx            context.Context
831 }
832 
833 // NewServer returns a new *ssh.Server configured to serve Soft Serve. The SSH
834@@ -46,32 +48,10 @@ func NewServer(ctx context.Context, cfg *config.Config) (*Server, error) {
835 	if cfg.Backend == nil {
836 		sb, err := sqlite.NewSqliteBackend(ctx, cfg)
837 		if err != nil {
838-			logger.Fatal(err)
839+			return nil, fmt.Errorf("create backend: %w", err)
840 		}
841 
842 		cfg = cfg.WithBackend(sb)
843-
844-		// Create internal key.
845-		ikp, err := keygen.New(
846-			cfg.SSH.InternalKeyPath,
847-			keygen.WithKeyType(keygen.Ed25519),
848-			keygen.WithWrite(),
849-		)
850-		if err != nil {
851-			return nil, err
852-		}
853-		cfg.InternalPublicKey = ikp.AuthorizedKey()
854-
855-		// Create client key.
856-		ckp, err := keygen.New(
857-			cfg.SSH.ClientKeyPath,
858-			keygen.WithKeyType(keygen.Ed25519),
859-			keygen.WithWrite(),
860-		)
861-		if err != nil {
862-			return nil, err
863-		}
864-		cfg.ClientPublicKey = ckp.AuthorizedKey()
865 	}
866 
867 	srv := &Server{
868@@ -84,24 +64,29 @@ func NewServer(ctx context.Context, cfg *config.Config) (*Server, error) {
869 	// Add cron jobs.
870 	srv.Cron.AddFunc(jobSpecs["mirror"], mirrorJob(cfg))
871 
872-	srv.SSHServer, err = sshsrv.NewSSHServer(cfg, srv)
873+	srv.SSHServer, err = sshsrv.NewSSHServer(cfg)
874 	if err != nil {
875-		return nil, err
876+		return nil, fmt.Errorf("create ssh server: %w", err)
877 	}
878 
879 	srv.GitDaemon, err = daemon.NewGitDaemon(cfg)
880 	if err != nil {
881-		return nil, err
882+		return nil, fmt.Errorf("create git daemon: %w", err)
883 	}
884 
885 	srv.HTTPServer, err = web.NewHTTPServer(cfg)
886 	if err != nil {
887-		return nil, err
888+		return nil, fmt.Errorf("create http server: %w", err)
889 	}
890 
891diff --git a/server/ssh/session.go b/server/ssh/session.go
892index 754fdbe68aeaeef79c379127339da9450d51d509..92b65dde70e93b393a30b5259fefe29de71ab40b 100644
893--- a/server/ssh/session.go
894+++ b/server/ssh/session.go
895@@ -6,8 +6,8 @@ import (
896 	"github.com/aymanbagabas/go-osc52"
897 	tea "github.com/charmbracelet/bubbletea"
898 	"github.com/charmbracelet/soft-serve/server/backend"
899-	cm "github.com/charmbracelet/soft-serve/server/cmd"
900 	"github.com/charmbracelet/soft-serve/server/config"
901+	"github.com/charmbracelet/soft-serve/server/errors"
902 	"github.com/charmbracelet/soft-serve/ui"
903 	"github.com/charmbracelet/soft-serve/ui/common"
904 	"github.com/charmbracelet/ssh"
905@@ -41,7 +41,7 @@ func SessionHandler(cfg *config.Config) bm.ProgramHandler {
906 			initialRepo = cmd[0]
907 			auth := cfg.Backend.AccessLevelByPublicKey(initialRepo, s.PublicKey())
908 			if auth < backend.ReadOnlyAccess {
909-				wish.Fatalln(s, cm.ErrUnauthorized)
910+				wish.Fatalln(s, errors.ErrUnauthorized)
911 				return nil
912 			}
913 		}
914diff --git a/server/ssh/ssh.go b/server/ssh/ssh.go
915index 3477419fd596c9cfd3d8e124babcb459c0166fe0..a407d8fc929541f26ff18baba0e511163a247775 100644
916--- a/server/ssh/ssh.go
917+++ b/server/ssh/ssh.go
918@@ -14,7 +14,6 @@ import (
919 	cm "github.com/charmbracelet/soft-serve/server/cmd"
920 	"github.com/charmbracelet/soft-serve/server/config"
921 	"github.com/charmbracelet/soft-serve/server/git"
922-	"github.com/charmbracelet/soft-serve/server/hooks"
923 	"github.com/charmbracelet/soft-serve/server/utils"
924 	"github.com/charmbracelet/ssh"
925 	"github.com/charmbracelet/wish"
926@@ -82,7 +81,7 @@ type SSHServer struct {
927 }
928 
929 // NewSSHServer returns a new SSHServer.
930-func NewSSHServer(cfg *config.Config, hooks hooks.Hooks) (*SSHServer, error) {
931+func NewSSHServer(cfg *config.Config) (*SSHServer, error) {
932 	var err error
933 	s := &SSHServer{cfg: cfg}
934 	logger := logger.StandardLog(log.StandardLogOptions{ForceLevel: log.DebugLevel})
935@@ -92,7 +91,7 @@ func NewSSHServer(cfg *config.Config, hooks hooks.Hooks) (*SSHServer, error) {
936 			// BubbleTea middleware.
937 			bm.MiddlewareWithProgramHandler(SessionHandler(cfg), termenv.ANSI256),
938 			// CLI middleware.
939-			cm.Middleware(cfg, hooks),
940+			cm.Middleware(cfg),
941 			// Git middleware.
942 			s.Middleware(cfg),
943 			// Logging middleware.