From 553532b40b562abcc051df6c547837f0c1b9614c Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Thu, 28 May 2026 23:18:16 -0400 Subject: [PATCH] feat(ssh): add git-over-SSH server and command dispatch Adds newSSHServer, handleSSH, and gitServiceFor to ssh.go, wiring gliderlabs/ssh into the daemon so git clone/push over SSH mirrors the git:// handler: persistent-stream no-op closers, streamingStorer for receive-pack, and auth.Authorizer gating via pubKeyContextKey. Adds TestGitServiceFor covering all valid service names and invalid inputs. --- cmd/objgitd/ssh.go | 131 ++++++++++++++++++++++++++++++++++++++++ cmd/objgitd/ssh_test.go | 52 ++++++++++++++++ go.mod | 2 + 3 files changed, 185 insertions(+) diff --git a/cmd/objgitd/ssh.go b/cmd/objgitd/ssh.go index 71dc50b..6e811d4 100644 --- a/cmd/objgitd/ssh.go +++ b/cmd/objgitd/ssh.go @@ -8,11 +8,17 @@ import ( "fmt" "io" "log/slog" + "net/url" "os" "path/filepath" + "strings" + ssh "github.com/gliderlabs/ssh" "github.com/go-git/go-billy/v6" + "github.com/go-git/go-git/v6/plumbing/transport" + "github.com/go-git/go-git/v6/utils/ioutil" gossh "golang.org/x/crypto/ssh" + "tangled.org/xeiaso.net/objgit/internal/auth" ) const hostKeyPath = ".objgit/ssh_host_ed25519_key" @@ -74,3 +80,128 @@ func loadOrCreateHostKey(fs billy.Filesystem) (gossh.Signer, error) { slog.Info("created ssh host key", "path", hostKeyPath) return signer, nil } + +// gitServiceFor maps an SSH exec command to the go-git service it selects. The +// bool is false for anything that is not a git transport command. +func gitServiceFor(command string) (string, bool) { + switch command { + case "git-upload-pack": + return transport.UploadPackService, true + case "git-upload-archive": + return transport.UploadArchiveService, true + case "git-receive-pack": + return transport.ReceivePackService, true + default: + return "", false + } +} + +// pubKeyContextKey keys the authenticated public key stashed on the SSH context +// by PublicKeyHandler, for handleSSH to read when authorizing. +type pubKeyContextKey struct{} + +// newSSHServer builds the git-over-SSH server. It accepts every public key at +// connect time and defers real authorization to handleSSH via daemon.authz. +func newSSHServer(d *daemon, addr string) (*ssh.Server, error) { + signer, err := loadOrCreateHostKey(d.fs) + if err != nil { + return nil, fmt.Errorf("ssh host key: %w", err) + } + srv := &ssh.Server{ + Addr: addr, + Handler: d.handleSSH, + PublicKeyHandler: func(ctx ssh.Context, key ssh.PublicKey) bool { + ctx.SetValue(pubKeyContextKey{}, key) + return true + }, + } + srv.AddHostKey(signer) + return srv, nil +} + +// handleSSH services one git-over-SSH exec request: parse the command, authorize, +// resolve the repository, and hand the session to the matching go-git transport +// command. The session is the protocol stream (reader and writer). +func (d *daemon) handleSSH(s ssh.Session) { + cmd := s.Command() + if len(cmd) != 2 { + fmt.Fprintln(s.Stderr(), "objgitd: this is a git SSH endpoint; interactive shells are not supported") + _ = s.Exit(1) + return + } + + service, ok := gitServiceFor(cmd[0]) + if !ok { + fmt.Fprintf(s.Stderr(), "objgitd: unsupported command %q\n", cmd[0]) + _ = s.Exit(1) + return + } + + // ssh://host/foo.git sends "/foo.git"; scp-style host:foo.git sends "foo.git". + repoPath := strings.TrimPrefix(cmd[1], "/") + + var cred auth.Credential = auth.Anonymous{} + if key, ok := s.Context().Value(pubKeyContextKey{}).(ssh.PublicKey); ok && key != nil { + cred = auth.PublicKey{Key: key} + } + if d.authz.Authorize(s.Context(), auth.Request{ + Repo: repoPath, + Operation: operationFor(service), + Cred: cred, + Transport: "ssh", + }) != auth.Allow { + fmt.Fprintln(s.Stderr(), "objgitd: access denied") + _ = s.Exit(1) + return + } + + slog.Info("serving ssh request", + "service", service, + "path", repoPath, + "remote", s.RemoteAddr().String(), + ) + + // SSH is a persistent stream like git://: the transport commands call Close + // between negotiation rounds, which would tear down the channel, so wrap the + // session in no-op closers. + r := io.NopCloser(s) + w := ioutil.WriteNopCloser(s) + ctx := s.Context() + + switch service { + case transport.UploadPackService: + st, err := d.loader.Load(&url.URL{Path: repoPath}) + if err != nil { + fmt.Fprintf(s.Stderr(), "objgitd: repository %q not found\n", repoPath) + _ = s.Exit(1) + return + } + if err := transport.UploadPack(ctx, st, r, w, &transport.UploadPackRequest{}); err != nil { + slog.Error("ssh upload-pack failed", "path", repoPath, "err", err) + } + + case transport.UploadArchiveService: + st, err := d.loader.Load(&url.URL{Path: repoPath}) + if err != nil { + fmt.Fprintf(s.Stderr(), "objgitd: repository %q not found\n", repoPath) + _ = s.Exit(1) + return + } + if err := transport.UploadArchive(ctx, st, r, w, &transport.UploadArchiveRequest{}); err != nil { + slog.Error("ssh upload-archive failed", "path", repoPath, "err", err) + } + + case transport.ReceivePackService: + st, err := d.loadOrInit(repoPath) + if err != nil { + fmt.Fprintf(s.Stderr(), "objgitd: cannot open repository %q\n", repoPath) + _ = s.Exit(1) + return + } + // streamingStorer hides PackfileWriter (the io.CopyBuffer-until-EOF path + // deadlocks on a live socket); d.receivePack runs push hooks afterward. + if err := d.receivePack(ctx, streamingStorer{Storer: st}, st, repoPath, r, w, &transport.ReceivePackRequest{}); err != nil { + slog.Error("ssh receive-pack failed", "path", repoPath, "err", err) + } + } +} diff --git a/cmd/objgitd/ssh_test.go b/cmd/objgitd/ssh_test.go index e9dfb42..6e3a4d3 100644 --- a/cmd/objgitd/ssh_test.go +++ b/cmd/objgitd/ssh_test.go @@ -6,8 +6,60 @@ import ( "testing" "github.com/go-git/go-billy/v6/memfs" + "github.com/go-git/go-git/v6/plumbing/transport" ) +func TestGitServiceFor(t *testing.T) { + tt := []struct { + name string + command string + service string + ok bool + }{ + { + name: "upload-pack", + command: "git-upload-pack", + service: transport.UploadPackService, + ok: true, + }, + { + name: "upload-archive", + command: "git-upload-archive", + service: transport.UploadArchiveService, + ok: true, + }, + { + name: "receive-pack", + command: "git-receive-pack", + service: transport.ReceivePackService, + ok: true, + }, + { + name: "git-shell is unsupported", + command: "git-shell", + service: "", + ok: false, + }, + { + name: "empty string is unsupported", + command: "", + service: "", + ok: false, + }, + } + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + got, ok := gitServiceFor(tc.command) + if ok != tc.ok { + t.Errorf("gitServiceFor(%q) ok=%v, want %v", tc.command, ok, tc.ok) + } + if got != tc.service { + t.Errorf("gitServiceFor(%q) service=%q, want %q", tc.command, got, tc.service) + } + }) + } +} + func TestLoadOrCreateHostKey(t *testing.T) { fs := memfs.New() diff --git a/go.mod b/go.mod index b23e464..e045eb5 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/s3 v1.102.0 github.com/aws/smithy-go v1.26.0 github.com/facebookgo/flagenv v0.0.0-20160425205200-fcd59fca7456 + github.com/gliderlabs/ssh v0.3.8 github.com/go-git/go-billy/v6 v6.0.0-alpha.1 github.com/go-git/go-git/v6 v6.0.0-alpha.4 github.com/joho/godotenv v1.5.1 @@ -21,6 +22,7 @@ require ( require ( github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.4.1 // indirect + github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect github.com/aws/aws-sdk-go-v2/config v1.32.17 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.19.16 // indirect -- 2.51.2