| 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 |