Go · 7261 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package protocol
4
5 import (
6 "context"
7 "crypto/rand"
8 "encoding/hex"
9 "errors"
10 "fmt"
11 "os"
12 "strconv"
13 "strings"
14
15 "github.com/jackc/pgx/v5"
16 "github.com/jackc/pgx/v5/pgtype"
17 "github.com/jackc/pgx/v5/pgxpool"
18
19 "github.com/tenseleyFlow/shithub/internal/infra/storage"
20 reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
21 usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
22 )
23
24 // SSHDispatchDeps wires the dispatcher. The DB pool is sized small at
25 // the call site (max 2 conns) — every SSH connection runs through here
26 // and the latency floor matters.
27 type SSHDispatchDeps struct {
28 Pool *pgxpool.Pool
29 RepoFS *storage.RepoFS
30 }
31
32 // SSHDispatchInput is everything that comes from the OS environment at
33 // dispatch time. The cobra command in cmd/shithubd/ssh.go reads
34 // SSH_ORIGINAL_COMMAND / SSH_CONNECTION / argv and passes them in.
35 type SSHDispatchInput struct {
36 OriginalCommand string
37 UserID int64
38 RemoteIP string // parsed from SSH_CONNECTION first field
39 }
40
41 // SSHDispatchResult is what the caller needs to syscall.Exec the right
42 // git plumbing binary. Argv0 is the canonical binary path resolved from
43 // PATH; Argv0Args is just `[Argv0, GitDir]` (git-upload-pack and
44 // git-receive-pack each take exactly one positional argument). Env is
45 // the full environment for the new process — already includes PATH and
46 // the SHITHUB_* vars.
47 type SSHDispatchResult struct {
48 Argv0 string
49 Argv0Args []string
50 Env []string
51 }
52
53 // Friendly stderr messages — these are what the user sees in their
54 // terminal when `git clone` fails. Keep them actionable.
55 const (
56 MsgRepoNotFound = "shithub: repository not found"
57 MsgPermDenied = "shithub: permission denied"
58 MsgArchived = "shithub: this repository is archived; pushes are disabled"
59 MsgSuspended = "shithub: your account is suspended"
60 )
61
62 // Sentinel errors callers can errors.Is to map to friendly messages.
63 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")
69 )
70
71 // PrepareDispatch is the brains of the SSH-shell command. It parses
72 // SSH_ORIGINAL_COMMAND, resolves user + repo, runs the inline owner-
73 // only authorization, and builds the env + argv the caller needs to
74 // exec git. It does NOT call exec itself — the caller does, after
75 // closing the DB pool (syscall.Exec preserves all open FDs and we
76 // don't want a leaked Postgres connection).
77 func PrepareDispatch(ctx context.Context, deps SSHDispatchDeps, in SSHDispatchInput) (*SSHDispatchResult, ParsedSSHCommand, error) {
78 parsed, err := ParseSSHCommand(in.OriginalCommand)
79 if err != nil {
80 return nil, ParsedSSHCommand{}, err
81 }
82
83 uq := usersdb.New()
84 rq := reposdb.New()
85
86 user, err := uq.GetUserByID(ctx, deps.Pool, in.UserID)
87 if err != nil {
88 return nil, parsed, fmt.Errorf("%w: %v", ErrSSHInternal, err)
89 }
90 if user.SuspendedAt.Valid {
91 return nil, parsed, ErrSSHSuspended
92 }
93 if user.DeletedAt.Valid {
94 return nil, parsed, ErrSSHSuspended
95 }
96
97 owner, err := uq.GetUserByUsername(ctx, deps.Pool, parsed.Owner)
98 if err != nil {
99 // Unknown owner — surface as not-found regardless of whether
100 // the row never existed or was soft-deleted.
101 return nil, parsed, ErrSSHRepoNotFound
102 }
103 repo, err := rq.GetRepoByOwnerUserAndName(ctx, deps.Pool, reposdb.GetRepoByOwnerUserAndNameParams{
104 OwnerUserID: pgtype.Int8{Int64: owner.ID, Valid: true},
105 Name: parsed.Repo,
106 })
107 if err != nil {
108 if errors.Is(err, pgx.ErrNoRows) {
109 return nil, parsed, ErrSSHRepoNotFound
110 }
111 return nil, parsed, fmt.Errorf("%w: %v", ErrSSHInternal, err)
112 }
113
114 // Inline authz — same shape as the HTTP handler. Public repos can
115 // be pulled by anyone; everything else requires owner identity.
116 // (S15 lifts this into policy.Can.)
117 if parsed.Service == UploadPack {
118 if repo.Visibility == reposdb.RepoVisibilityPrivate && user.ID != owner.ID {
119 return nil, parsed, ErrSSHRepoNotFound
120 }
121 } else {
122 if user.ID != owner.ID {
123 return nil, parsed, ErrSSHPermDenied
124 }
125 if repo.IsArchived {
126 return nil, parsed, ErrSSHArchived
127 }
128 if repo.DeletedAt.Valid {
129 return nil, parsed, ErrSSHRepoNotFound
130 }
131 }
132
133 gitDir, err := deps.RepoFS.RepoPath(parsed.Owner, parsed.Repo)
134 if err != nil {
135 return nil, parsed, fmt.Errorf("%w: %v", ErrSSHInternal, err)
136 }
137
138 requestID, err := newRequestID()
139 if err != nil {
140 return nil, parsed, fmt.Errorf("%w: %v", ErrSSHInternal, err)
141 }
142 env := buildSSHEnv(user, owner, repo, in.RemoteIP, requestID)
143
144 return &SSHDispatchResult{
145 Argv0: string(parsed.Service),
146 Argv0Args: []string{string(parsed.Service), gitDir},
147 Env: env,
148 }, parsed, nil
149 }
150
151 // FriendlyMessageFor returns the user-facing stderr line for a typed
152 // dispatch error. Unknown errors collapse to a generic "internal error
153 // (request_id=...)" message — never leak the underlying cause.
154 func FriendlyMessageFor(err error, requestID string) string {
155 switch {
156 case errors.Is(err, ErrSSHRepoNotFound):
157 return MsgRepoNotFound
158 case errors.Is(err, ErrSSHPermDenied):
159 return MsgPermDenied
160 case errors.Is(err, ErrSSHArchived):
161 return MsgArchived
162 case errors.Is(err, ErrSSHSuspended):
163 return MsgSuspended
164 case errors.Is(err, ErrUnknownSSHCommand):
165 return "shithub does not allow shell access"
166 case errors.Is(err, ErrInvalidSSHPath):
167 return MsgRepoNotFound
168 }
169 if requestID == "" {
170 return "shithub: internal error"
171 }
172 return "shithub: internal error (request_id=" + requestID + ")"
173 }
174
175 // buildSSHEnv assembles the SHITHUB_* env vars that S14's hooks read.
176 // The shape matches the HTTP path so receive-pack hooks see identical
177 // vars regardless of transport.
178 func buildSSHEnv(user usersdb.User, owner usersdb.User, repo reposdb.Repo, remoteIP, requestID string) []string {
179 return []string{
180 "SHITHUB_USER_ID=" + strconv.FormatInt(user.ID, 10),
181 "SHITHUB_USERNAME=" + user.Username,
182 "SHITHUB_REPO_ID=" + strconv.FormatInt(repo.ID, 10),
183 "SHITHUB_REPO_FULL_NAME=" + owner.Username + "/" + repo.Name,
184 "SHITHUB_PROTOCOL=ssh",
185 "SHITHUB_REMOTE_IP=" + remoteIP,
186 "SHITHUB_REQUEST_ID=" + requestID,
187 // PATH must be inherited so the exec'd git binary can find its
188 // sub-helpers (git-pack-objects, git-index-pack, etc.).
189 "PATH=" + os.Getenv("PATH"),
190 }
191 }
192
193 // newRequestID returns a 16-byte hex token suitable for log
194 // correlation. Production callers don't need cryptographic strength
195 // here, but using crypto/rand keeps the codebase consistent.
196 func newRequestID() (string, error) {
197 var buf [16]byte
198 if _, err := rand.Read(buf[:]); err != nil {
199 return "", err
200 }
201 return hex.EncodeToString(buf[:]), nil
202 }
203
204 // ParseRemoteIP extracts the connecting client's IP from the
205 // SSH_CONNECTION env var ("<client> <cport> <server> <sport>"). Returns
206 // "" when malformed; callers may pass the empty string through to the
207 // hook env.
208 func ParseRemoteIP(sshConnection string) string {
209 if sshConnection == "" {
210 return ""
211 }
212 fields := strings.Fields(sshConnection)
213 if len(fields) == 0 {
214 return ""
215 }
216 return fields[0]
217 }
218