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