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