tenseleyflow/shithub / bbe5c6a

Browse files

S13: SSH dispatcher core — owner-only authz, hook env, friendly stderr messages

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
bbe5c6a9ac3ec2eca65ba9b937103fb82a5efb1b
Parents
7d778dc
Tree
1868df4

2 changed files

StatusFile+-
A internal/git/protocol/ssh_dispatch.go 217 0
A internal/git/protocol/ssh_dispatch_test.go 236 0
internal/git/protocol/ssh_dispatch.goadded
@@ -0,0 +1,217 @@
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
+}
internal/git/protocol/ssh_dispatch_test.goadded
@@ -0,0 +1,236 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package protocol_test
4
+
5
+import (
6
+	"context"
7
+	"errors"
8
+	"strings"
9
+	"testing"
10
+
11
+	"github.com/jackc/pgx/v5/pgtype"
12
+	"github.com/jackc/pgx/v5/pgxpool"
13
+
14
+	"github.com/tenseleyFlow/shithub/internal/auth/audit"
15
+	"github.com/tenseleyFlow/shithub/internal/auth/throttle"
16
+	"github.com/tenseleyFlow/shithub/internal/git/protocol"
17
+	"github.com/tenseleyFlow/shithub/internal/infra/storage"
18
+	"github.com/tenseleyFlow/shithub/internal/repos"
19
+	"github.com/tenseleyFlow/shithub/internal/testing/dbtest"
20
+	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
21
+)
22
+
23
+const fixtureHash = "$argon2id$v=19$m=16384,t=1,p=1$" +
24
+	"AAAAAAAAAAAAAAAA$" +
25
+	"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
26
+
27
+// dispatchEnv constructs deps + 2 users (alice owns repos, eve is a
28
+// non-owner) + a public repo + a private repo against a fresh test DB.
29
+type dispatchEnv struct {
30
+	pool   *pgxpool.Pool
31
+	deps   protocol.SSHDispatchDeps
32
+	alice  int64
33
+	eve    int64
34
+	root   string
35
+}
36
+
37
+func setupDispatch(t *testing.T) *dispatchEnv {
38
+	t.Helper()
39
+	pool := dbtest.NewTestDB(t)
40
+	root := t.TempDir()
41
+	rfs, err := storage.NewRepoFS(root)
42
+	if err != nil {
43
+		t.Fatalf("NewRepoFS: %v", err)
44
+	}
45
+	uq := usersdb.New()
46
+
47
+	makeUser := func(name string) usersdb.User {
48
+		u, err := uq.CreateUser(context.Background(), pool, usersdb.CreateUserParams{
49
+			Username: name, DisplayName: name, PasswordHash: fixtureHash,
50
+		})
51
+		if err != nil {
52
+			t.Fatalf("CreateUser %s: %v", name, err)
53
+		}
54
+		em, err := uq.CreateUserEmail(context.Background(), pool, usersdb.CreateUserEmailParams{
55
+			UserID: u.ID, Email: name + "@example.com", IsPrimary: true, Verified: true,
56
+		})
57
+		if err != nil {
58
+			t.Fatalf("CreateUserEmail %s: %v", name, err)
59
+		}
60
+		if err := uq.LinkUserPrimaryEmail(context.Background(), pool, usersdb.LinkUserPrimaryEmailParams{
61
+			ID: u.ID, PrimaryEmailID: pgtype.Int8{Int64: em.ID, Valid: true},
62
+		}); err != nil {
63
+			t.Fatalf("LinkUserPrimaryEmail %s: %v", name, err)
64
+		}
65
+		return u
66
+	}
67
+	alice := makeUser("alice")
68
+	eve := makeUser("eve")
69
+
70
+	rdeps := repos.Deps{
71
+		Pool: pool, RepoFS: rfs, Audit: audit.NewRecorder(), Limiter: throttle.NewLimiter(),
72
+	}
73
+	if _, err := repos.Create(context.Background(), rdeps, repos.Params{
74
+		OwnerUserID: alice.ID, OwnerUsername: alice.Username,
75
+		Name: "public", Visibility: "public", InitReadme: true,
76
+	}); err != nil {
77
+		t.Fatalf("create public: %v", err)
78
+	}
79
+	if _, err := repos.Create(context.Background(), rdeps, repos.Params{
80
+		OwnerUserID: alice.ID, OwnerUsername: alice.Username,
81
+		Name: "private", Visibility: "private", InitReadme: true,
82
+	}); err != nil {
83
+		t.Fatalf("create private: %v", err)
84
+	}
85
+
86
+	return &dispatchEnv{
87
+		pool: pool, deps: protocol.SSHDispatchDeps{Pool: pool, RepoFS: rfs},
88
+		alice: alice.ID, eve: eve.ID, root: root,
89
+	}
90
+}
91
+
92
+func TestDispatch_PublicCloneByOwner(t *testing.T) {
93
+	t.Parallel()
94
+	env := setupDispatch(t)
95
+	res, parsed, err := protocol.PrepareDispatch(context.Background(), env.deps, protocol.SSHDispatchInput{
96
+		OriginalCommand: "git-upload-pack 'alice/public'",
97
+		UserID:          env.alice,
98
+		RemoteIP:        "127.0.0.1",
99
+	})
100
+	if err != nil {
101
+		t.Fatalf("PrepareDispatch: %v", err)
102
+	}
103
+	if parsed.Service != protocol.UploadPack {
104
+		t.Errorf("Service = %q", parsed.Service)
105
+	}
106
+	if !strings.HasSuffix(res.Argv0Args[1], "/public.git") {
107
+		t.Errorf("Argv0Args[1] = %q", res.Argv0Args[1])
108
+	}
109
+	wantEnvSubstrings := []string{
110
+		"SHITHUB_USER_ID=", "SHITHUB_USERNAME=alice",
111
+		"SHITHUB_REPO_FULL_NAME=alice/public", "SHITHUB_PROTOCOL=ssh",
112
+		"SHITHUB_REMOTE_IP=127.0.0.1",
113
+	}
114
+	envBlob := strings.Join(res.Env, "\n")
115
+	for _, w := range wantEnvSubstrings {
116
+		if !strings.Contains(envBlob, w) {
117
+			t.Errorf("env missing %q in:\n%s", w, envBlob)
118
+		}
119
+	}
120
+}
121
+
122
+func TestDispatch_PublicCloneByOther(t *testing.T) {
123
+	t.Parallel()
124
+	env := setupDispatch(t)
125
+	_, _, err := protocol.PrepareDispatch(context.Background(), env.deps, protocol.SSHDispatchInput{
126
+		OriginalCommand: "git-upload-pack 'alice/public'",
127
+		UserID:          env.eve, // not owner
128
+	})
129
+	if err != nil {
130
+		t.Fatalf("non-owner pull of public: %v", err)
131
+	}
132
+}
133
+
134
+func TestDispatch_PrivateCloneByOtherIsNotFound(t *testing.T) {
135
+	t.Parallel()
136
+	env := setupDispatch(t)
137
+	_, _, err := protocol.PrepareDispatch(context.Background(), env.deps, protocol.SSHDispatchInput{
138
+		OriginalCommand: "git-upload-pack 'alice/private'",
139
+		UserID:          env.eve,
140
+	})
141
+	if !errors.Is(err, protocol.ErrSSHRepoNotFound) {
142
+		t.Fatalf("err = %v, want ErrSSHRepoNotFound", err)
143
+	}
144
+}
145
+
146
+func TestDispatch_PushByNonOwnerIsPermDenied(t *testing.T) {
147
+	t.Parallel()
148
+	env := setupDispatch(t)
149
+	_, _, err := protocol.PrepareDispatch(context.Background(), env.deps, protocol.SSHDispatchInput{
150
+		OriginalCommand: "git-receive-pack 'alice/public'",
151
+		UserID:          env.eve,
152
+	})
153
+	if !errors.Is(err, protocol.ErrSSHPermDenied) {
154
+		t.Fatalf("err = %v, want ErrSSHPermDenied", err)
155
+	}
156
+}
157
+
158
+func TestDispatch_PushToArchivedIsArchived(t *testing.T) {
159
+	t.Parallel()
160
+	env := setupDispatch(t)
161
+	if _, err := env.pool.Exec(context.Background(),
162
+		"UPDATE repos SET is_archived = true WHERE name = 'public'"); err != nil {
163
+		t.Fatalf("archive: %v", err)
164
+	}
165
+	_, _, err := protocol.PrepareDispatch(context.Background(), env.deps, protocol.SSHDispatchInput{
166
+		OriginalCommand: "git-receive-pack 'alice/public'",
167
+		UserID:          env.alice,
168
+	})
169
+	if !errors.Is(err, protocol.ErrSSHArchived) {
170
+		t.Fatalf("err = %v, want ErrSSHArchived", err)
171
+	}
172
+}
173
+
174
+func TestDispatch_SuspendedUserSuspended(t *testing.T) {
175
+	t.Parallel()
176
+	env := setupDispatch(t)
177
+	if _, err := env.pool.Exec(context.Background(),
178
+		"UPDATE users SET suspended_at = now(), suspended_reason = 'test' WHERE id = $1",
179
+		env.alice,
180
+	); err != nil {
181
+		t.Fatalf("suspend: %v", err)
182
+	}
183
+	_, _, err := protocol.PrepareDispatch(context.Background(), env.deps, protocol.SSHDispatchInput{
184
+		OriginalCommand: "git-upload-pack 'alice/public'",
185
+		UserID:          env.alice,
186
+	})
187
+	if !errors.Is(err, protocol.ErrSSHSuspended) {
188
+		t.Fatalf("err = %v, want ErrSSHSuspended", err)
189
+	}
190
+}
191
+
192
+func TestDispatch_UnknownCommandIsRejected(t *testing.T) {
193
+	t.Parallel()
194
+	env := setupDispatch(t)
195
+	_, _, err := protocol.PrepareDispatch(context.Background(), env.deps, protocol.SSHDispatchInput{
196
+		OriginalCommand: "ls -la /etc",
197
+		UserID:          env.alice,
198
+	})
199
+	if !errors.Is(err, protocol.ErrUnknownSSHCommand) {
200
+		t.Fatalf("err = %v, want ErrUnknownSSHCommand", err)
201
+	}
202
+}
203
+
204
+func TestFriendlyMessageFor(t *testing.T) {
205
+	t.Parallel()
206
+	cases := []struct {
207
+		err  error
208
+		want string
209
+	}{
210
+		{protocol.ErrSSHRepoNotFound, "shithub: repository not found"},
211
+		{protocol.ErrSSHPermDenied, "shithub: permission denied"},
212
+		{protocol.ErrSSHArchived, "shithub: this repository is archived; pushes are disabled"},
213
+		{protocol.ErrSSHSuspended, "shithub: your account is suspended"},
214
+		{protocol.ErrUnknownSSHCommand, "shithub does not allow shell access"},
215
+		{protocol.ErrInvalidSSHPath, "shithub: repository not found"},
216
+	}
217
+	for _, c := range cases {
218
+		if got := protocol.FriendlyMessageFor(c.err, "abc123"); got != c.want {
219
+			t.Errorf("FriendlyMessageFor(%v) = %q, want %q", c.err, got, c.want)
220
+		}
221
+	}
222
+}
223
+
224
+func TestParseRemoteIP(t *testing.T) {
225
+	t.Parallel()
226
+	cases := map[string]string{
227
+		"":                                 "",
228
+		"203.0.113.7 12345 192.0.2.1 22":   "203.0.113.7",
229
+		"  203.0.113.8  ":                  "203.0.113.8",
230
+	}
231
+	for in, want := range cases {
232
+		if got := protocol.ParseRemoteIP(in); got != want {
233
+			t.Errorf("ParseRemoteIP(%q) = %q, want %q", in, got, want)
234
+		}
235
+	}
236
+}