Go · 9330 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package main
4
5 import (
6 "context"
7 "fmt"
8 "log/slog"
9 "net/netip"
10 "os"
11 "os/exec"
12 "path/filepath"
13 "strconv"
14 "strings"
15 "syscall"
16 "time"
17
18 "github.com/spf13/cobra"
19
20 "github.com/tenseleyFlow/shithub/internal/git/protocol"
21 "github.com/tenseleyFlow/shithub/internal/infra/config"
22 "github.com/tenseleyFlow/shithub/internal/infra/db"
23 "github.com/tenseleyFlow/shithub/internal/infra/storage"
24 usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
25 )
26
27 // sshAuthkeysCmd implements sshd's AuthorizedKeysCommand contract:
28 //
29 // - On a known fingerprint, write a single authorized_keys line on stdout
30 // with a forced command and restrictive options.
31 // - On an unknown fingerprint OR any error, write nothing and exit 0.
32 // sshd uses STDOUT as the auth answer; non-zero exit is a config error,
33 // not a deny. Failing closed is the right model: better to deny a
34 // legitimate connection than accidentally authorize the wrong user.
35 //
36 // Latency is critical — every SSH connection waits on this. The pool is
37 // sized small (max 4 conns) to bound startup cost and tail-latency.
38 var sshAuthkeysCmd = &cobra.Command{
39 Use: "ssh-authkeys <fingerprint>",
40 Short: "AuthorizedKeysCommand handler for sshd",
41 Args: cobra.ExactArgs(1),
42 Hidden: true, // not for direct human use
43 RunE: func(cmd *cobra.Command, args []string) error {
44 // Fail-closed wrapper: anything below that returns an error or
45 // panics writes nothing to stdout. The exit code stays 0.
46 defer func() {
47 _ = recover()
48 }()
49 fp := strings.TrimSpace(args[0])
50 if !isWellFormedFingerprint(fp) {
51 return nil
52 }
53
54 cfg, err := config.Load(nil)
55 if err != nil || cfg.DB.URL == "" {
56 return nil
57 }
58
59 ctx, cancel := context.WithTimeout(cmd.Context(), 1500*time.Millisecond)
60 defer cancel()
61
62 pool, err := db.Open(ctx, db.Config{
63 URL: cfg.DB.URL, MaxConns: 4, MinConns: 0,
64 ConnectTimeout: 750 * time.Millisecond,
65 })
66 if err != nil {
67 return nil
68 }
69 defer pool.Close()
70
71 q := usersdb.New()
72 row, err := q.GetUserSSHKeyByFingerprint(ctx, pool, fp)
73 if err != nil {
74 // pgx.ErrNoRows or any other error → silently empty.
75 return nil
76 }
77
78 _, _ = fmt.Fprintln(cmd.OutOrStdout(), authorizedKeysLine(row))
79
80 // Best-effort last-used update. 500ms cap; any error is dropped.
81 updateCtx, updateCancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
82 defer updateCancel()
83 _ = q.TouchSSHKeyLastUsed(updateCtx, pool, usersdb.TouchSSHKeyLastUsedParams{
84 ID: row.ID,
85 LastUsedIp: clientAddrFromEnv(),
86 })
87 return nil
88 },
89 }
90
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.
107 var sshShellCmd = &cobra.Command{
108 Use: "ssh-shell <user_id>",
109 Short: "Forced-command target invoked by sshd via AuthorizedKeysCommand",
110 Args: cobra.ExactArgs(1),
111 Hidden: true,
112 RunE: func(cmd *cobra.Command, args []string) error {
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 }
118 original := os.Getenv("SSH_ORIGINAL_COMMAND")
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(
160 ctx, "ssh-shell: denied",
161 "user_id", userID,
162 "original", original,
163 "remote_ip", remoteIP,
164 "error", dispatchErr,
165 )
166 return dispatchErr
167 }
168 logger.InfoContext(
169 ctx, "ssh-shell: dispatch",
170 "user_id", userID,
171 "op", string(parsed.Service),
172 "owner", parsed.Owner,
173 "repo", parsed.Repo,
174 "remote_ip", remoteIP,
175 )
176
177 // CRITICAL: close DB pool before syscall.Exec. defer doesn't
178 // fire on exec, and the pgx pool's connections would otherwise
179 // leak into the new process's FD table.
180 pool.Close()
181
182 bin, err := exec.LookPath(res.Argv0)
183 if err != nil {
184 _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "shithub: server misconfigured")
185 return fmt.Errorf("ssh-shell: lookup %s: %w", res.Argv0, err)
186 }
187 if err := sysExec(bin, res.Argv0Args, res.Env); err != nil {
188 _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "shithub: internal error")
189 return fmt.Errorf("ssh-shell: exec %s: %w", bin, err)
190 }
191 // Unreachable on success — syscall.Exec replaces this process.
192 return nil
193 },
194 }
195
196 // sysExec is split out so tests can stub it. bin is exec.LookPath of a
197 // fixed service name (git-{upload,receive}-pack); argv[1] is the
198 // sanitized bare-repo path from storage.RepoFS.
199 //
200 //nolint:gosec // G204: inputs are constrained as documented above.
201 var sysExec = syscall.Exec
202
203 // authorizedKeysLine builds the single line sshd consumes. The forced
204 // command runs `shithubd ssh-shell <user_id>`; the option set strips
205 // every interactive affordance.
206 //
207 // The command uses the BARE binary name (`shithubd`), not the absolute
208 // path of os.Args[0]. Reason: when the git user's login shell is
209 // /usr/bin/git-shell (kept as a defense-in-depth wall — git-shell
210 // only allows git's own commands plus entries in ~/git-shell-commands/),
211 // git-shell rejects any first token that contains a slash. With a
212 // bare token, git-shell looks for ~git/git-shell-commands/shithubd
213 // (must be a symlink to /usr/local/bin/shithubd installed by ansible)
214 // and execs it.
215 //
216 // PATH on the AKC's child process inherits sshd's env, which on
217 // Debian includes /usr/local/bin — so a fallback `shithubd` lookup
218 // would also find the binary even without git-shell-commands. The
219 // git-shell-commands symlink is the authoritative path.
220 func authorizedKeysLine(row usersdb.UserSshKey) string {
221 binary := filepath.Base(os.Args[0])
222 // user_id is a digit string so it can never contain shell metacharacters.
223 cmd := fmt.Sprintf(`%s ssh-shell %d`, binary, row.UserID)
224 options := strings.Join([]string{
225 fmt.Sprintf(`command="%s"`, cmd),
226 "no-port-forwarding",
227 "no-X11-forwarding",
228 "no-agent-forwarding",
229 "no-pty",
230 }, ",")
231 return options + " " + row.PublicKey
232 }
233
234 // clientAddrFromEnv extracts the connecting client's address from
235 // $SSH_CONNECTION (sshd sets it to "<client> <cport> <server> <sport>").
236 // Returns nil when unavailable, which sqlc encodes as a SQL NULL.
237 func clientAddrFromEnv() *netip.Addr {
238 conn := os.Getenv("SSH_CONNECTION")
239 if conn == "" {
240 return nil
241 }
242 parts := strings.Fields(conn)
243 if len(parts) < 1 {
244 return nil
245 }
246 addr, err := netip.ParseAddr(parts[0])
247 if err != nil {
248 return nil
249 }
250 return &addr
251 }
252
253 // isWellFormedFingerprint accepts only the canonical SHA256:<b64> shape
254 // our codebase emits. Defense against an attacker passing crafted strings
255 // to influence the SQL plan.
256 func isWellFormedFingerprint(s string) bool {
257 if !strings.HasPrefix(s, "SHA256:") {
258 return false
259 }
260 rest := s[len("SHA256:"):]
261 if len(rest) < 30 || len(rest) > 80 {
262 return false
263 }
264 for _, r := range rest {
265 switch {
266 case r >= 'A' && r <= 'Z',
267 r >= 'a' && r <= 'z',
268 r >= '0' && r <= '9',
269 r == '+', r == '/', r == '=':
270 default:
271 return false
272 }
273 }
274 return true
275 }
276
277 func init() {
278 rootCmd.AddCommand(sshAuthkeysCmd)
279 rootCmd.AddCommand(sshShellCmd)
280 }
281