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.