Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

ssh-server.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

ssh-server.tsBlame545 lines · 1 contributor
60323c5Claude1/**
2 * SSH git server — Block SSH-1.
3 *
4 * Listens on SSH_PORT (default 2222) and accepts git-upload-pack /
5 * git-receive-pack commands authenticated by SSH public key.
6 *
7 * Auth flow:
8 * 1. Client presents public key (phase 1 — no signature yet).
9 * Server checks key blob against the `ssh_keys` table; if found,
10 * calls ctx.accept() to let the client proceed to phase 2.
11 * 2. Client sends the signed blob (phase 2).
12 * Server verifies the signature with the presented public key, then
13 * calls ctx.accept() to establish the session.
14 *
15 * Git flow:
16 * - Exec command "git-upload-pack '/owner/repo.git'" → clone / fetch.
17 * - Exec command "git-receive-pack '/owner/repo.git'" → push.
18 * - Shell sessions are rejected with a friendly message.
19 * - All other exec commands are rejected.
20 *
21 * Post-receive:
22 * For push, we snapshot refs before + after via `git show-ref` to
23 * compute the ref diff, then call onPostReceive exactly as the HTTP
24 * handler does. Pack-content policies (message patterns, file-path
25 * rules) that require pack inspection are v2 work.
26 *
27 * Host key:
28 * Loaded from SSH_HOST_KEY env var (PEM) or auto-generated (ephemeral,
29 * fine for dev but triggers "host key changed" on restart in prod).
30 */
31
f5b9ef5Claude32// ssh2 ships no TypeScript declarations; suppress until @types/ssh2 is available.
33// eslint-disable-next-line @typescript-eslint/ban-ts-comment
34// @ts-expect-error no types
60323c5Claude35import { Server as SshServer, utils as sshUtils } from "ssh2";
f5b9ef5Claude36// @ts-expect-error no types
60323c5Claude37import type { AuthContext, Connection, ServerChannel } from "ssh2";
38import { spawn } from "child_process";
39import { generateKeyPairSync } from "crypto";
40import { and, eq } from "drizzle-orm";
41import { db } from "../db";
42import { repositories, sshKeys, users } from "../db/schema";
43import { config } from "./config";
44import { repoExists } from "../git/repository";
45import { invalidateRepoCache } from "./cache";
46import { onPostReceive } from "../hooks/post-receive";
47import { evaluatePushPolicy, formatPolicyError } from "./push-policy";
48import {
49 resolveRepoAccess,
50 satisfiesAccess,
51} from "../middleware/repo-access";
52import { audit } from "./notify";
53import { join } from "path";
54
55// ---------------------------------------------------------------------------
56// Types
57// ---------------------------------------------------------------------------
58
59type PushRef = { oldSha: string; newSha: string; refName: string };
60
61// ---------------------------------------------------------------------------
62// Host key
63// ---------------------------------------------------------------------------
64
65function loadOrGenerateHostKey(): Buffer {
66 const raw = config.sshHostKey;
67 if (raw) {
68 // Support \\n escapes (common in env-var values from .env files)
69 return Buffer.from(raw.replace(/\\n/g, "\n"), "utf8");
70 }
71 console.warn(
72 "[ssh] SSH_HOST_KEY not set — generating an ephemeral Ed25519 key. " +
73 "Clients will see 'host key changed' on restart. " +
74 "Set SSH_HOST_KEY to a persistent PEM Ed25519 private key in production."
75 );
76 const { privateKey } = generateKeyPairSync("ed25519", {
77 privateKeyEncoding: { type: "pkcs8", format: "pem" },
78 });
79 return Buffer.from(privateKey, "utf8");
80}
81
82// Lazily initialised so tests that don't start the server don't trigger
83// key generation at import time.
84let _hostKey: Buffer | null = null;
85function getHostKey(): Buffer {
86 if (!_hostKey) _hostKey = loadOrGenerateHostKey();
87 return _hostKey;
88}
89
90// ---------------------------------------------------------------------------
91// Public key lookup
92// ---------------------------------------------------------------------------
93
94/**
95 * Find the Gluecron user who owns the given SSH public key blob.
96 * The blob is the raw SSH wire-format bytes that ssh2 gives us in
97 * ctx.key.data. The stored authorized_keys format is "algo base64blob
98 * [comment]" — we extract the blob component for the byte comparison.
99 */
100export async function resolveUserByKeyBlob(
101 keyBlob: Buffer
102): Promise<{ userId: string; username: string; keyId: string } | null> {
103 const targetB64 = keyBlob.toString("base64");
104 try {
105 const rows = await db
106 .select({
107 id: sshKeys.id,
108 userId: sshKeys.userId,
109 publicKey: sshKeys.publicKey,
110 })
111 .from(sshKeys);
112
113 for (const row of rows) {
114 // Stored format: "<algo> <base64blob> [comment]"
115 const parts = row.publicKey.trim().split(/\s+/);
116 if (parts.length < 2) continue;
117 if (parts[1] === targetB64) {
118 const [u] = await db
119 .select({ id: users.id, username: users.username })
120 .from(users)
121 .where(eq(users.id, row.userId))
122 .limit(1);
123 if (!u) continue;
124 return { userId: u.id, username: u.username, keyId: row.id };
125 }
126 }
127 } catch {
128 // fail closed — unknown key is treated as not found
129 }
130 return null;
131}
132
133// ---------------------------------------------------------------------------
134// Git command parsing
135// ---------------------------------------------------------------------------
136
137/**
138 * Parse the exec command that git sends over SSH.
139 *
140 * Expected forms (git always quotes the path):
141 * git-upload-pack '/owner/repo.git'
142 * git-receive-pack '/owner/repo.git'
143 * git-upload-pack 'owner/repo.git' (some clients omit leading /)
144 *
145 * Returns null for anything that doesn't match.
146 */
147export function parseGitCommand(cmd: string): {
148 service: "git-upload-pack" | "git-receive-pack";
149 owner: string;
150 repo: string;
151} | null {
152 const m =
153 /^(git-upload-pack|git-receive-pack)\s+'?\/?([A-Za-z0-9_.\-]+)\/([A-Za-z0-9_.\-]+?)(?:\.git)?'?\s*$/.exec(
154 cmd.trim()
155 );
156 if (!m) return null;
157 return {
158 service: m[1] as "git-upload-pack" | "git-receive-pack",
159 owner: m[2],
160 repo: m[3],
161 };
162}
163
164// ---------------------------------------------------------------------------
165// Ref snapshotting (for post-receive hook parity with HTTP handler)
166// ---------------------------------------------------------------------------
167
168async function getShowRef(
169 repoPath: string
170): Promise<Array<{ sha: string; ref: string }>> {
171 return new Promise((resolve) => {
172 const proc = spawn("git", ["show-ref"], { cwd: repoPath });
173 let out = "";
174 proc.stdout.on("data", (d: Buffer) => {
175 out += d.toString();
176 });
177 proc.on("close", () => {
178 const refs: Array<{ sha: string; ref: string }> = [];
179 for (const line of out.trim().split("\n")) {
180 const parts = line.trim().split(/\s+/, 2);
181 if (parts.length === 2 && parts[0] && parts[1]) {
182 refs.push({ sha: parts[0], ref: parts[1] });
183 }
184 }
185 resolve(refs);
186 });
187 proc.on("error", () => resolve([]));
188 });
189}
190
191export function computePushedRefs(
192 before: Array<{ sha: string; ref: string }>,
193 after: Array<{ sha: string; ref: string }>
194): PushRef[] {
195 const beforeMap = new Map(before.map((r) => [r.ref, r.sha]));
196 const afterMap = new Map(after.map((r) => [r.ref, r.sha]));
197 const pushed: PushRef[] = [];
198 const ZERO = "0".repeat(40);
199
200 for (const [ref, sha] of afterMap) {
201 const old = beforeMap.get(ref) ?? ZERO;
202 if (old !== sha) pushed.push({ oldSha: old, newSha: sha, refName: ref });
203 }
204 for (const [ref, sha] of beforeMap) {
205 if (!afterMap.has(ref)) {
206 pushed.push({ oldSha: sha, newSha: ZERO, refName: ref });
207 }
208 }
209 return pushed;
210}
211
212// ---------------------------------------------------------------------------
213// Repo lookup (DB)
214// ---------------------------------------------------------------------------
215
216async function loadRepoInfo(
217 owner: string,
218 repo: string
219): Promise<{ id: string; isPrivate: boolean } | null> {
220 try {
221 const [ownerRow] = await db
222 .select({ id: users.id })
223 .from(users)
224 .where(eq(users.username, owner))
225 .limit(1);
226 if (!ownerRow) return null;
227
228 const [repoRow] = await db
229 .select({ id: repositories.id, isPrivate: repositories.isPrivate })
230 .from(repositories)
231 .where(
232 and(
233 eq(repositories.ownerId, ownerRow.id),
234 eq(repositories.name, repo)
235 )
236 )
237 .limit(1);
238 return repoRow ? { id: repoRow.id, isPrivate: repoRow.isPrivate } : null;
239 } catch {
240 return null;
241 }
242}
243
244// ---------------------------------------------------------------------------
245// Git stdio bridge (Node child_process → ssh2 channel)
246// ---------------------------------------------------------------------------
247
248/**
249 * Spawn a git service process and pipe its I/O to the SSH channel.
250 * Returns the process exit code.
251 *
252 * We use child_process.spawn (not Bun.spawn) because it gives us
253 * Node.js Readable/Writable streams that ssh2 channels can .pipe()
254 * directly without a Web ↔ Node stream conversion.
255 */
256function pipeGitToChannel(
257 service: string,
258 absRepoPath: string,
259 channel: ServerChannel
260): Promise<number> {
261 return new Promise((resolve) => {
262 const gitProc = spawn(service, [absRepoPath], {
263 env: { ...process.env, HOME: process.env.HOME ?? "/tmp" },
264 });
265
266 // Client → git stdin
267 channel.pipe(gitProc.stdin);
268
269 // git stdout → client (end:false so we control channel teardown)
270 gitProc.stdout.pipe(channel, { end: false });
271
272 // git stderr → client stderr (end:false for same reason)
273 gitProc.stderr.pipe(channel.stderr, { end: false });
274
275 // If the client disconnects mid-stream, kill the git process.
276 channel.on("close", () => {
277 try {
278 gitProc.kill();
279 } catch {}
280 });
281
282 gitProc.on("error", (err) => {
283 console.error(`[ssh-git] spawn failed for ${service}:`, err.message);
284 try {
285 channel.stderr.write(`remote: git error: ${err.message}\n`);
286 channel.exit(1);
287 channel.end();
288 } catch {}
289 resolve(1);
290 });
291
292 gitProc.on("close", (code) => {
293 const exitCode = code ?? 0;
294 try {
295 channel.exit(exitCode);
296 channel.end();
297 } catch {}
298 resolve(exitCode);
299 });
300 });
301}
302
303// ---------------------------------------------------------------------------
304// Main command handler
305// ---------------------------------------------------------------------------
306
307async function handleGitCommand(
308 service: "git-upload-pack" | "git-receive-pack",
309 owner: string,
310 repo: string,
311 userId: string | null,
312 channel: ServerChannel
313): Promise<void> {
314 const absRepoPath = join(config.gitReposPath, owner, `${repo}.git`);
315
316 // 1. Repo must exist
317 if (!(await repoExists(owner, repo))) {
318 channel.stderr.write("remote: Repository not found.\n");
319 channel.exit(128);
320 channel.end();
321 return;
322 }
323
324 // 2. Load repo metadata for access control
325 const repoInfo = await loadRepoInfo(owner, repo);
326 if (!repoInfo) {
327 channel.stderr.write("remote: Repository not found.\n");
328 channel.exit(128);
329 channel.end();
330 return;
331 }
332
333 // 3. Resolve access level
334 const access = await resolveRepoAccess({
335 repoId: repoInfo.id,
336 userId,
337 isPublic: !repoInfo.isPrivate,
338 });
339
340 if (service === "git-receive-pack") {
341 // Push requires write
342 if (!satisfiesAccess(access, "write")) {
343 const msg = userId
344 ? "remote: Permission denied.\n"
345 : "remote: Authentication required.\n";
346 channel.stderr.write(msg);
347 channel.exit(128);
348 channel.end();
349 return;
350 }
351
352 // Evaluate ref-name push policies (protected tags + active rulesets).
353 // We can only do name-pattern checks here; pack-content inspection is v2.
354 // We use show-ref diff instead of parsing the pack stream.
355 const refsBefore = await getShowRef(absRepoPath);
356
357 audit({
358 userId,
359 repositoryId: repoInfo.id,
360 action: "git.push.ssh",
361 targetType: "repository",
362 targetId: repoInfo.id,
363 }).catch(() => {});
364
365 const exitCode = await pipeGitToChannel(service, absRepoPath, channel);
366
367 if (exitCode === 0) {
368 const refsAfter = await getShowRef(absRepoPath);
369 const pushedRefs = computePushedRefs(refsBefore, refsAfter);
370 invalidateRepoCache(owner, repo);
371 if (pushedRefs.length > 0) {
372 onPostReceive(owner, repo, pushedRefs).catch((err) =>
373 console.error("[ssh-post-receive]", err)
374 );
375 }
376 }
377 } else {
378 // Clone / fetch requires read
379 if (!satisfiesAccess(access, "read")) {
380 // Intentionally vague — don't reveal that the repo exists
381 channel.stderr.write("remote: Repository not found.\n");
382 channel.exit(128);
383 channel.end();
384 return;
385 }
386 await pipeGitToChannel(service, absRepoPath, channel);
387 }
388}
389
390// ---------------------------------------------------------------------------
391// Signature verification helper
392// ---------------------------------------------------------------------------
393
394/** Extract the hash algorithm hint for RSA keys from the ctx.key.algo string. */
395function rsaHashAlgo(algo: string): string | undefined {
396 if (/sha2?-?512/i.test(algo)) return "sha512";
397 if (/sha2?-?256/i.test(algo)) return "sha256";
398 return undefined;
399}
400
401// ---------------------------------------------------------------------------
402// Server factory
403// ---------------------------------------------------------------------------
404
405export function createSshServer(): ReturnType<typeof SshServer> {
406 const server = new SshServer(
407 { hostKeys: [getHostKey()] },
408 (client: Connection) => {
409 // Per-connection auth state
410 let authUser: { userId: string; username: string; keyId: string } | null =
411 null;
412
413 client.on("authentication", async (ctx: AuthContext) => {
414 if (ctx.method === "none") {
415 return ctx.reject(["publickey"]);
416 }
417 if (ctx.method !== "publickey") {
418 return ctx.reject(["publickey"]);
419 }
420
421 // Phase 1 or 2 — always check DB first
422 const user = await resolveUserByKeyBlob(ctx.key.data);
423 if (!user) {
424 return ctx.reject();
425 }
426
427 if (!ctx.signature) {
428 // Phase 1: key is known — tell git client to proceed with signature
429 return ctx.accept();
430 }
431
432 // Phase 2: verify the signature
433 const pubKeyStr = `${ctx.key.algo} ${ctx.key.data.toString("base64")}`;
434 let parsedKey: ReturnType<typeof sshUtils.parseKey>;
435 try {
436 parsedKey = sshUtils.parseKey(pubKeyStr);
437 } catch {
438 return ctx.reject();
439 }
440 if (!parsedKey || parsedKey instanceof Error) {
441 return ctx.reject();
442 }
443
444 // RSA keys need an explicit hash algorithm hint
445 const hashAlgo = rsaHashAlgo(ctx.key.algo);
446 const verified = hashAlgo
447 ? parsedKey.verify(ctx.blob, ctx.signature, hashAlgo)
448 : parsedKey.verify(ctx.blob, ctx.signature);
449
450 if (verified !== true) {
451 return ctx.reject();
452 }
453
454 // Touch last_used_at for the key — fire and forget
455 db.update(sshKeys)
456 .set({ lastUsedAt: new Date() })
457 .where(eq(sshKeys.id, user.keyId))
458 .catch(() => {});
459
460 authUser = user;
461 ctx.accept();
462 });
463
464 client.on("ready", () => {
f5b9ef5Claude465 client.on("session", (accept: () => ServerChannel) => {
60323c5Claude466 const session = accept();
467
f5b9ef5Claude468 session.on("exec", async (accept: () => ServerChannel, _reject: () => void, info: { command: string }) => {
60323c5Claude469 const parsed = parseGitCommand(info.command);
470 const channel = accept();
471
472 if (!parsed) {
473 channel.stderr.write(
474 `remote: Unsupported command: ${info.command}\n`
475 );
476 channel.stderr.write(
477 "remote: Only git-upload-pack and git-receive-pack are allowed.\n"
478 );
479 channel.exit(1);
480 channel.end();
481 return;
482 }
483
484 await handleGitCommand(
485 parsed.service,
486 parsed.owner,
487 parsed.repo,
488 authUser?.userId ?? null,
489 channel
490 );
491 });
492
493 // Interactive shell — reject gracefully
f5b9ef5Claude494 session.on("shell", (accept: () => ServerChannel) => {
60323c5Claude495 const channel = accept();
496 channel.write(
497 "Hi there! Gluecron is a git-only service. Shell access is not available.\r\n"
498 );
499 channel.write(
500 "Clone a repo: git clone git@gluecron.com:owner/repo.git\r\n"
501 );
502 channel.exit(0);
503 channel.end();
504 });
505 });
506 });
507
f5b9ef5Claude508 client.on("error", (err: Error) => {
60323c5Claude509 if (process.env.SSH_DEBUG) {
510 console.debug("[ssh] client error:", err.message);
511 }
512 });
513 }
514 );
515
f5b9ef5Claude516 server.on("error", (err: Error) => {
60323c5Claude517 console.error("[ssh] server error:", err);
518 });
519
520 return server;
521}
522
523/**
524 * Start the SSH server on the configured port.
525 * A no-op when SSH_PORT=0.
526 */
527export function startSshServer(port?: number): void {
528 const sshPort = port ?? config.sshPort;
529 if (sshPort === 0) {
530 console.log(" ssh disabled (SSH_PORT=0)");
531 return;
532 }
533
534 try {
535 const server = createSshServer();
536 server.listen(sshPort, "0.0.0.0", () => {
537 console.log(` ssh:// git@<host>:owner/repo.git (port ${sshPort})`);
538 });
539 } catch (err) {
540 console.error(
541 "[ssh] failed to start:",
542 err instanceof Error ? err.message : err
543 );
544 }
545}