tenseleyflow/shithub / f810a0c

Browse files

S13: ssh-shell cobra cmd — DB lookup, dispatch, close pool, syscall.Exec git plumbing

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
f810a0ca5af995dfc37f2da652ae6dad423081b2
Parents
bbe5c6a
Tree
393ab01

4 changed files

StatusFile+-
M cmd/shithubd/ssh.go 107 11
M internal/git/protocol/ssh_command_test.go 10 10
M internal/git/protocol/ssh_dispatch.go 5 5
M internal/git/protocol/ssh_dispatch_test.go 8 8
cmd/shithubd/ssh.gomodified
@@ -5,15 +5,22 @@ package main
55
 import (
66
 	"context"
77
 	"fmt"
8
+	"log/slog"
89
 	"net/netip"
910
 	"os"
11
+	"os/exec"
12
+	"path/filepath"
13
+	"strconv"
1014
 	"strings"
15
+	"syscall"
1116
 	"time"
1217
 
1318
 	"github.com/spf13/cobra"
1419
 
20
+	"github.com/tenseleyFlow/shithub/internal/git/protocol"
1521
 	"github.com/tenseleyFlow/shithub/internal/infra/config"
1622
 	"github.com/tenseleyFlow/shithub/internal/infra/db"
23
+	"github.com/tenseleyFlow/shithub/internal/infra/storage"
1724
 	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
1825
 )
1926
 
@@ -81,27 +88,116 @@ var sshAuthkeysCmd = &cobra.Command{
8188
 	},
8289
 }
8390
 
84
-// sshShellCmd is the placeholder for the forced-command target. S13 swaps
85
-// this for the real git-over-SSH dispatcher; for S07 we just log the
86
-// inbound command and exit non-zero with a friendly message so an
87
-// operator (or test) can confirm the wiring works end-to-end.
91
+// sshShellCmd is the forced-command target sshd invokes after the
92
+// AuthorizedKeysCommand handshake binds the connection to a user.
93
+//
94
+// Flow on a successful clone/push:
95
+//
96
+//	sshd ──► shithubd ssh-shell <user_id>
97
+//	         ├─ ParseSSHCommand(SSH_ORIGINAL_COMMAND)
98
+//	         ├─ Resolve user + repo against the DB
99
+//	         ├─ Inline owner-only authz (S15 will refactor)
100
+//	         ├─ Build SHITHUB_* env (so post-receive hooks identify the actor)
101
+//	         ├─ Close the DB pool (syscall.Exec preserves all open FDs)
102
+//	         └─ syscall.Exec git-{upload,receive}-pack <bare-repo>
103
+//
104
+// On any error: write a friendly line to stderr (the user sees it in
105
+// their git client), log structured, exit non-zero. defer does NOT
106
+// fire on syscall.Exec — every cleanup happens BEFORE the exec call.
88107
 var sshShellCmd = &cobra.Command{
89108
 	Use:    "ssh-shell <user_id>",
90109
 	Short:  "Forced-command target invoked by sshd via AuthorizedKeysCommand",
91110
 	Args:   cobra.ExactArgs(1),
92111
 	Hidden: true,
93112
 	RunE: func(cmd *cobra.Command, args []string) error {
94
-		userID := args[0]
113
+		userID, err := strconv.ParseInt(args[0], 10, 64)
114
+		if err != nil {
115
+			_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "shithub: invalid user")
116
+			return fmt.Errorf("ssh-shell: bad user_id %q: %w", args[0], err)
117
+		}
95118
 		original := os.Getenv("SSH_ORIGINAL_COMMAND")
96
-		// Log to stderr so it's captured by sshd's session log without
97
-		// polluting the (silent-on-empty) stdout contract.
98
-		_, _ = fmt.Fprintf(cmd.ErrOrStderr(),
99
-			"shithubd ssh-shell: user_id=%s original_command=%q (git-over-SSH lands in S13)\n",
100
-			userID, original)
101
-		return fmt.Errorf("git over SSH not enabled yet")
119
+		remoteIP := protocol.ParseRemoteIP(os.Getenv("SSH_CONNECTION"))
120
+		logger := slog.New(slog.NewTextHandler(cmd.ErrOrStderr(), &slog.HandlerOptions{Level: slog.LevelInfo}))
121
+
122
+		cfg, err := config.Load(nil)
123
+		if err != nil || cfg.DB.URL == "" || cfg.Storage.ReposRoot == "" {
124
+			_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "shithub: server misconfigured")
125
+			return fmt.Errorf("ssh-shell: cfg: %w", err)
126
+		}
127
+		root, err := filepath.Abs(cfg.Storage.ReposRoot)
128
+		if err != nil {
129
+			_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "shithub: server misconfigured")
130
+			return fmt.Errorf("ssh-shell: repos_root: %w", err)
131
+		}
132
+		rfs, err := storage.NewRepoFS(root)
133
+		if err != nil {
134
+			_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "shithub: server misconfigured")
135
+			return fmt.Errorf("ssh-shell: NewRepoFS: %w", err)
136
+		}
137
+
138
+		ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second)
139
+		defer cancel()
140
+		pool, err := db.Open(ctx, db.Config{
141
+			URL: cfg.DB.URL, MaxConns: 2, MinConns: 0,
142
+			ConnectTimeout: 1500 * time.Millisecond,
143
+		})
144
+		if err != nil {
145
+			_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "shithub: temporary failure (try again)")
146
+			return fmt.Errorf("ssh-shell: db open: %w", err)
147
+		}
148
+
149
+		res, parsed, dispatchErr := protocol.PrepareDispatch(ctx, protocol.SSHDispatchDeps{
150
+			Pool: pool, RepoFS: rfs,
151
+		}, protocol.SSHDispatchInput{
152
+			OriginalCommand: original,
153
+			UserID:          userID,
154
+			RemoteIP:        remoteIP,
155
+		})
156
+		if dispatchErr != nil {
157
+			pool.Close()
158
+			_, _ = fmt.Fprintln(cmd.ErrOrStderr(), protocol.FriendlyMessageFor(dispatchErr, ""))
159
+			logger.WarnContext(ctx, "ssh-shell: denied",
160
+				"user_id", userID,
161
+				"original", original,
162
+				"remote_ip", remoteIP,
163
+				"error", dispatchErr,
164
+			)
165
+			return dispatchErr
166
+		}
167
+		logger.InfoContext(ctx, "ssh-shell: dispatch",
168
+			"user_id", userID,
169
+			"op", string(parsed.Service),
170
+			"owner", parsed.Owner,
171
+			"repo", parsed.Repo,
172
+			"remote_ip", remoteIP,
173
+		)
174
+
175
+		// CRITICAL: close DB pool before syscall.Exec. defer doesn't
176
+		// fire on exec, and the pgx pool's connections would otherwise
177
+		// leak into the new process's FD table.
178
+		pool.Close()
179
+
180
+		bin, err := exec.LookPath(res.Argv0)
181
+		if err != nil {
182
+			_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "shithub: server misconfigured")
183
+			return fmt.Errorf("ssh-shell: lookup %s: %w", res.Argv0, err)
184
+		}
185
+		if err := sysExec(bin, res.Argv0Args, res.Env); err != nil {
186
+			_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "shithub: internal error")
187
+			return fmt.Errorf("ssh-shell: exec %s: %w", bin, err)
188
+		}
189
+		// Unreachable on success — syscall.Exec replaces this process.
190
+		return nil
102191
 	},
103192
 }
104193
 
194
+// sysExec is split out so tests can stub it. bin is exec.LookPath of a
195
+// fixed service name (git-{upload,receive}-pack); argv[1] is the
196
+// sanitized bare-repo path from storage.RepoFS.
197
+//
198
+//nolint:gosec // G204: inputs are constrained as documented above.
199
+var sysExec = syscall.Exec
200
+
105201
 // authorizedKeysLine builds the single line sshd consumes. The forced
106202
 // command runs `shithubd ssh-shell <user_id>`; the option set strips
107203
 // every interactive affordance.
internal/git/protocol/ssh_command_test.gomodified
@@ -12,10 +12,10 @@ import (
1212
 func TestParseSSHCommand_Accepts(t *testing.T) {
1313
 	t.Parallel()
1414
 	cases := []struct {
15
-		in           string
16
-		wantService  protocol.Service
17
-		wantOwner    string
18
-		wantRepo     string
15
+		in          string
16
+		wantService protocol.Service
17
+		wantOwner   string
18
+		wantRepo    string
1919
 	}{
2020
 		{"git-upload-pack 'alice/foo'", protocol.UploadPack, "alice", "foo"},
2121
 		{"git-upload-pack 'alice/foo.git'", protocol.UploadPack, "alice", "foo"},
@@ -44,13 +44,13 @@ func TestParseSSHCommand_RejectsUnknown(t *testing.T) {
4444
 		"ls",
4545
 		"bash",
4646
 		"git-archive 'alice/foo'",
47
-		"git-upload-pack",                            // no path
48
-		" git-upload-pack 'a/b'",                     // leading space
49
-		"git-upload-pack 'a/b' ",                     // trailing space
50
-		"git-upload-pack 'a/b' && rm -rf /",          // command injection
47
+		"git-upload-pack",                   // no path
48
+		" git-upload-pack 'a/b'",            // leading space
49
+		"git-upload-pack 'a/b' ",            // trailing space
50
+		"git-upload-pack 'a/b' && rm -rf /", // command injection
5151
 		`git-upload-pack 'a/b'; cat /etc/passwd`,
52
-		"GIT-UPLOAD-PACK 'a/b'",                       // case
53
-		"git-upload-pack a/b'",                        // mismatched quotes
52
+		"GIT-UPLOAD-PACK 'a/b'", // case
53
+		"git-upload-pack a/b'",  // mismatched quotes
5454
 	} {
5555
 		_, err := protocol.ParseSSHCommand(in)
5656
 		if !errors.Is(err, protocol.ErrUnknownSSHCommand) {
internal/git/protocol/ssh_dispatch.gomodified
@@ -61,11 +61,11 @@ const (
6161
 
6262
 // Sentinel errors callers can errors.Is to map to friendly messages.
6363
 var (
64
-	ErrSSHRepoNotFound  = errors.New("ssh dispatch: repo not found")
65
-	ErrSSHPermDenied    = errors.New("ssh dispatch: permission denied")
66
-	ErrSSHArchived      = errors.New("ssh dispatch: archived")
67
-	ErrSSHSuspended     = errors.New("ssh dispatch: suspended")
68
-	ErrSSHInternal      = errors.New("ssh dispatch: internal error")
64
+	ErrSSHRepoNotFound = errors.New("ssh dispatch: repo not found")
65
+	ErrSSHPermDenied   = errors.New("ssh dispatch: permission denied")
66
+	ErrSSHArchived     = errors.New("ssh dispatch: archived")
67
+	ErrSSHSuspended    = errors.New("ssh dispatch: suspended")
68
+	ErrSSHInternal     = errors.New("ssh dispatch: internal error")
6969
 )
7070
 
7171
 // PrepareDispatch is the brains of the SSH-shell command. It parses
internal/git/protocol/ssh_dispatch_test.gomodified
@@ -27,11 +27,11 @@ const fixtureHash = "$argon2id$v=19$m=16384,t=1,p=1$" +
2727
 // dispatchEnv constructs deps + 2 users (alice owns repos, eve is a
2828
 // non-owner) + a public repo + a private repo against a fresh test DB.
2929
 type dispatchEnv struct {
30
-	pool   *pgxpool.Pool
31
-	deps   protocol.SSHDispatchDeps
32
-	alice  int64
33
-	eve    int64
34
-	root   string
30
+	pool  *pgxpool.Pool
31
+	deps  protocol.SSHDispatchDeps
32
+	alice int64
33
+	eve   int64
34
+	root  string
3535
 }
3636
 
3737
 func setupDispatch(t *testing.T) *dispatchEnv {
@@ -224,9 +224,9 @@ func TestFriendlyMessageFor(t *testing.T) {
224224
 func TestParseRemoteIP(t *testing.T) {
225225
 	t.Parallel()
226226
 	cases := map[string]string{
227
-		"":                                 "",
228
-		"203.0.113.7 12345 192.0.2.1 22":   "203.0.113.7",
229
-		"  203.0.113.8  ":                  "203.0.113.8",
227
+		"":                               "",
228
+		"203.0.113.7 12345 192.0.2.1 22": "203.0.113.7",
229
+		"  203.0.113.8  ":                "203.0.113.8",
230230
 	}
231231
 	for in, want := range cases {
232232
 		if got := protocol.ParseRemoteIP(in); got != want {