Go · 9333 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 "github.com/tenseleyFlow/shithub/internal/orgs"
22 reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
23 usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
24 )
25
26 // SSHDispatchDeps wires the dispatcher. The DB pool is sized small at
27 // the call site (max 2 conns) — every SSH connection runs through here
28 // and the latency floor matters.
29 type SSHDispatchDeps struct {
30 Pool *pgxpool.Pool
31 RepoFS *storage.RepoFS
32 }
33
34 // SSHDispatchInput is everything that comes from the OS environment at
35 // dispatch time. The cobra command in cmd/shithubd/ssh.go reads
36 // SSH_ORIGINAL_COMMAND / SSH_CONNECTION / argv and passes them in.
37 type SSHDispatchInput struct {
38 OriginalCommand string
39 UserID int64
40 RemoteIP string // parsed from SSH_CONNECTION first field
41 }
42
43 // SSHDispatchResult is what the caller needs to syscall.Exec the right
44 // git plumbing binary. Argv0 is the canonical binary path resolved from
45 // PATH; Argv0Args is just `[Argv0, GitDir]` (git-upload-pack and
46 // git-receive-pack each take exactly one positional argument). Env is
47 // the full environment for the new process — already includes PATH and
48 // the SHITHUB_* vars.
49 type SSHDispatchResult struct {
50 Argv0 string
51 Argv0Args []string
52 Env []string
53 }
54
55 // Friendly stderr messages — these are what the user sees in their
56 // terminal when `git clone` fails. Keep them actionable.
57 const (
58 MsgRepoNotFound = "shithub: repository not found"
59 MsgPermDenied = "shithub: permission denied"
60 MsgArchived = "shithub: this repository is archived; pushes are disabled"
61 MsgSuspended = "shithub: your account is suspended"
62 )
63
64 // Sentinel errors callers can errors.Is to map to friendly messages.
65 var (
66 ErrSSHRepoNotFound = errors.New("ssh dispatch: repo not found")
67 ErrSSHPermDenied = errors.New("ssh dispatch: permission denied")
68 ErrSSHArchived = errors.New("ssh dispatch: archived")
69 ErrSSHSuspended = errors.New("ssh dispatch: suspended")
70 ErrSSHInternal = errors.New("ssh dispatch: internal error")
71 )
72
73 // PrepareDispatch is the brains of the SSH-shell command. It parses
74 // SSH_ORIGINAL_COMMAND, resolves user + repo, runs the inline owner-
75 // only authorization, and builds the env + argv the caller needs to
76 // exec git. It does NOT call exec itself — the caller does, after
77 // closing the DB pool (syscall.Exec preserves all open FDs and we
78 // don't want a leaked Postgres connection).
79 func PrepareDispatch(ctx context.Context, deps SSHDispatchDeps, in SSHDispatchInput) (*SSHDispatchResult, ParsedSSHCommand, error) {
80 parsed, err := ParseSSHCommand(in.OriginalCommand)
81 if err != nil {
82 return nil, ParsedSSHCommand{}, err
83 }
84
85 uq := usersdb.New()
86 rq := reposdb.New()
87
88 user, err := uq.GetUserByID(ctx, deps.Pool, in.UserID)
89 if err != nil {
90 return nil, parsed, fmt.Errorf("%w: %v", ErrSSHInternal, err)
91 }
92 if user.SuspendedAt.Valid {
93 return nil, parsed, ErrSSHSuspended
94 }
95 if user.DeletedAt.Valid {
96 return nil, parsed, ErrSSHSuspended
97 }
98
99 // Owner can be a user OR an org; orgs.Resolve hits the principals
100 // table to dispatch on kind. Mirrors the same lookup the HTTP git
101 // handler uses (web/handlers/githttp/handler.go::lookupRepo) — see
102 // the regression that #20 fixed for the HTTPS path; this is the SSH
103 // twin of that bug.
104 principal, err := orgs.Resolve(ctx, deps.Pool, parsed.Owner)
105 if err != nil {
106 return nil, parsed, ErrSSHRepoNotFound
107 }
108 var repo reposdb.Repo
109 switch principal.Kind {
110 case orgs.PrincipalUser:
111 repo, err = rq.GetRepoByOwnerUserAndName(ctx, deps.Pool, reposdb.GetRepoByOwnerUserAndNameParams{
112 OwnerUserID: pgtype.Int8{Int64: principal.ID, Valid: true},
113 Name: parsed.Repo,
114 })
115 case orgs.PrincipalOrg:
116 repo, err = rq.GetRepoByOwnerOrgAndName(ctx, deps.Pool, reposdb.GetRepoByOwnerOrgAndNameParams{
117 OwnerOrgID: pgtype.Int8{Int64: principal.ID, Valid: true},
118 Name: parsed.Repo,
119 })
120 default:
121 return nil, parsed, ErrSSHRepoNotFound
122 }
123 if err != nil {
124 if errors.Is(err, pgx.ErrNoRows) {
125 return nil, parsed, ErrSSHRepoNotFound
126 }
127 return nil, parsed, fmt.Errorf("%w: %v", ErrSSHInternal, err)
128 }
129
130 // Authz via policy.Can. Map the policy decision back to the typed
131 // SSH errors so the friendly-message catalogue keeps working.
132 repoRef := policy.NewRepoRefFromRepo(repo)
133 actor := policy.UserActor(user.ID, user.Username, user.SuspendedAt.Valid, false)
134 action := policy.ActionRepoRead
135 if parsed.Service == ReceivePack {
136 action = policy.ActionRepoWrite
137 }
138 decision := policy.Can(ctx, policy.Deps{Pool: deps.Pool}, actor, action, repoRef)
139 if !decision.Allow {
140 switch decision.Code {
141 case policy.DenyActorSuspended:
142 return nil, parsed, ErrSSHSuspended
143 case policy.DenyArchived:
144 return nil, parsed, ErrSSHArchived
145 case policy.DenyVisibility, policy.DenyRepoDeleted:
146 // Existence-leak guard: pretend the repo doesn't exist.
147 return nil, parsed, ErrSSHRepoNotFound
148 default:
149 return nil, parsed, ErrSSHPermDenied
150 }
151 }
152
153 gitDir, err := deps.RepoFS.RepoPath(parsed.Owner, parsed.Repo)
154 if err != nil {
155 return nil, parsed, fmt.Errorf("%w: %v", ErrSSHInternal, err)
156 }
157
158 requestID, err := newRequestID()
159 if err != nil {
160 return nil, parsed, fmt.Errorf("%w: %v", ErrSSHInternal, err)
161 }
162 env := buildSSHEnv(user, parsed.Owner, repo, in.RemoteIP, requestID)
163
164 return &SSHDispatchResult{
165 Argv0: string(parsed.Service),
166 Argv0Args: []string{string(parsed.Service), gitDir},
167 Env: env,
168 }, parsed, nil
169 }
170
171 // FriendlyMessageFor returns the user-facing stderr line for a typed
172 // dispatch error. Unknown errors collapse to a generic "internal error
173 // (request_id=...)" message — never leak the underlying cause.
174 func FriendlyMessageFor(err error, requestID string) string {
175 switch {
176 case errors.Is(err, ErrSSHRepoNotFound):
177 return MsgRepoNotFound
178 case errors.Is(err, ErrSSHPermDenied):
179 return MsgPermDenied
180 case errors.Is(err, ErrSSHArchived):
181 return MsgArchived
182 case errors.Is(err, ErrSSHSuspended):
183 return MsgSuspended
184 case errors.Is(err, ErrUnknownSSHCommand):
185 return "shithub does not allow shell access"
186 case errors.Is(err, ErrInvalidSSHPath):
187 return MsgRepoNotFound
188 }
189 if requestID == "" {
190 return "shithub: internal error"
191 }
192 return "shithub: internal error (request_id=" + requestID + ")"
193 }
194
195 // buildSSHEnv assembles the env we exec git-{upload,receive}-pack
196 // with. The shape matches the HTTP path so receive-pack hooks see
197 // identical vars regardless of transport.
198 //
199 // We INHERIT os.Environ() rather than building from scratch — the
200 // receive-pack process forks the pre-receive / post-receive hooks
201 // (`shithubd hook ...`), which call config.Load() and need the
202 // SHITHUB_* config keys (DATABASE_URL, REPOS_ROOT, etc.) sourced
203 // from /etc/shithub/web.env via the git-shell-commands wrapper.
204 //
205 // Pre-fix this function returned a minimal explicit list, so the
206 // hook's loadHookCtx exited with "DB URL not set" the moment a
207 // push tried to commit anything. The HTTPS-git path didn't see
208 // this because shithubd-web's systemd unit sources web.env and
209 // receive-pack inherits it directly.
210 //
211 // Push-event metadata (SHITHUB_USER_ID/REPO_ID/...) is appended
212 // AFTER inherited env so the explicit values win on collision.
213 func buildSSHEnv(user usersdb.User, ownerName string, repo reposdb.Repo, remoteIP, requestID string) []string {
214 env := os.Environ()
215 env = append(
216 env,
217 "SHITHUB_USER_ID="+strconv.FormatInt(user.ID, 10),
218 "SHITHUB_USERNAME="+user.Username,
219 "SHITHUB_REPO_ID="+strconv.FormatInt(repo.ID, 10),
220 "SHITHUB_REPO_FULL_NAME="+ownerName+"/"+repo.Name,
221 "SHITHUB_PROTOCOL=ssh",
222 "SHITHUB_REMOTE_IP="+remoteIP,
223 "SHITHUB_REQUEST_ID="+requestID,
224 // safe.directory: when sshd runs ssh-shell as the `git` user
225 // but the bare repo dir is owned by `shithub`, git's
226 // dubious-ownership check rejects the invocation. We're
227 // invoking on a path shithubd resolved (not user input), so
228 // the trust gate already happened upstream; tell git to
229 // trust this directory. Inline GIT_CONFIG_* avoids touching
230 // /etc/gitconfig and confines the override to this exec.
231 "GIT_CONFIG_COUNT=1",
232 "GIT_CONFIG_KEY_0=safe.directory",
233 "GIT_CONFIG_VALUE_0=*",
234 )
235 return env
236 }
237
238 // newRequestID returns a 16-byte hex token suitable for log
239 // correlation. Production callers don't need cryptographic strength
240 // here, but using crypto/rand keeps the codebase consistent.
241 func newRequestID() (string, error) {
242 var buf [16]byte
243 if _, err := rand.Read(buf[:]); err != nil {
244 return "", err
245 }
246 return hex.EncodeToString(buf[:]), nil
247 }
248
249 // ParseRemoteIP extracts the connecting client's IP from the
250 // SSH_CONNECTION env var ("<client> <cport> <server> <sport>"). Returns
251 // "" when malformed; callers may pass the empty string through to the
252 // hook env.
253 func ParseRemoteIP(sshConnection string) string {
254 if sshConnection == "" {
255 return ""
256 }
257 fields := strings.Fields(sshConnection)
258 if len(fields) == 0 {
259 return ""
260 }
261 return fields[0]
262 }
263