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.tsBlame563 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";
ec39211Claude47import { evaluatePushPolicy, formatPolicyError, installPackInspectionHookForRepo } from "./push-policy";
60323c5Claude48import {
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,
ec39211Claude259 channel: ServerChannel,
260 extraEnv?: Record<string, string>
60323c5Claude261): Promise<number> {
262 return new Promise((resolve) => {
263 const gitProc = spawn(service, [absRepoPath], {
ec39211Claude264 env: { ...process.env, HOME: process.env.HOME ?? "/tmp", ...extraEnv },
60323c5Claude265 });
266
267 // Client → git stdin
268 channel.pipe(gitProc.stdin);
269
270 // git stdout → client (end:false so we control channel teardown)
271 gitProc.stdout.pipe(channel, { end: false });
272
273 // git stderr → client stderr (end:false for same reason)
274 gitProc.stderr.pipe(channel.stderr, { end: false });
275
276 // If the client disconnects mid-stream, kill the git process.
277 channel.on("close", () => {
278 try {
279 gitProc.kill();
280 } catch {}
281 });
282
283 gitProc.on("error", (err) => {
284 console.error(`[ssh-git] spawn failed for ${service}:`, err.message);
285 try {
286 channel.stderr.write(`remote: git error: ${err.message}\n`);
287 channel.exit(1);
288 channel.end();
289 } catch {}
290 resolve(1);
291 });
292
293 gitProc.on("close", (code) => {
294 const exitCode = code ?? 0;
295 try {
296 channel.exit(exitCode);
297 channel.end();
298 } catch {}
299 resolve(exitCode);
300 });
301 });
302}
303
304// ---------------------------------------------------------------------------
305// Main command handler
306// ---------------------------------------------------------------------------
307
308async function handleGitCommand(
309 service: "git-upload-pack" | "git-receive-pack",
310 owner: string,
311 repo: string,
312 userId: string | null,
313 channel: ServerChannel
314): Promise<void> {
315 const absRepoPath = join(config.gitReposPath, owner, `${repo}.git`);
316
317 // 1. Repo must exist
318 if (!(await repoExists(owner, repo))) {
319 channel.stderr.write("remote: Repository not found.\n");
320 channel.exit(128);
321 channel.end();
322 return;
323 }
324
325 // 2. Load repo metadata for access control
326 const repoInfo = await loadRepoInfo(owner, repo);
327 if (!repoInfo) {
328 channel.stderr.write("remote: Repository not found.\n");
329 channel.exit(128);
330 channel.end();
331 return;
332 }
333
334 // 3. Resolve access level
335 const access = await resolveRepoAccess({
336 repoId: repoInfo.id,
337 userId,
338 isPublic: !repoInfo.isPrivate,
339 });
340
341 if (service === "git-receive-pack") {
342 // Push requires write
343 if (!satisfiesAccess(access, "write")) {
344 const msg = userId
345 ? "remote: Permission denied.\n"
346 : "remote: Authentication required.\n";
347 channel.stderr.write(msg);
348 channel.exit(128);
349 channel.end();
350 return;
351 }
352
ec39211Claude353 // Ref-name push policies run before the pack lands (name-only checks).
354 // Pack-content inspection is wired via a pre-receive hook so it runs
355 // inside git's quarantine window — same approach as the HTTP path.
60323c5Claude356 const refsBefore = await getShowRef(absRepoPath);
357
358 audit({
359 userId,
360 repositoryId: repoInfo.id,
361 action: "git.push.ssh",
362 targetType: "repository",
363 targetId: repoInfo.id,
364 }).catch(() => {});
365
ec39211Claude366 let hookEnv: Record<string, string> | undefined;
367 let hookCleanup: (() => Promise<void>) | undefined;
368 try {
369 const hook = await installPackInspectionHookForRepo(repoInfo.id);
370 if (hook) {
371 hookEnv = hook.env;
372 hookCleanup = hook.cleanup;
373 }
374 } catch {
375 // fail-open
376 }
377
378 let exitCode: number;
379 try {
380 exitCode = await pipeGitToChannel(service, absRepoPath, channel, hookEnv);
381 } finally {
382 hookCleanup?.().catch(() => {});
383 }
60323c5Claude384
385 if (exitCode === 0) {
386 const refsAfter = await getShowRef(absRepoPath);
387 const pushedRefs = computePushedRefs(refsBefore, refsAfter);
388 invalidateRepoCache(owner, repo);
389 if (pushedRefs.length > 0) {
390 onPostReceive(owner, repo, pushedRefs).catch((err) =>
391 console.error("[ssh-post-receive]", err)
392 );
393 }
394 }
395 } else {
396 // Clone / fetch requires read
397 if (!satisfiesAccess(access, "read")) {
398 // Intentionally vague — don't reveal that the repo exists
399 channel.stderr.write("remote: Repository not found.\n");
400 channel.exit(128);
401 channel.end();
402 return;
403 }
404 await pipeGitToChannel(service, absRepoPath, channel);
405 }
406}
407
408// ---------------------------------------------------------------------------
409// Signature verification helper
410// ---------------------------------------------------------------------------
411
412/** Extract the hash algorithm hint for RSA keys from the ctx.key.algo string. */
413function rsaHashAlgo(algo: string): string | undefined {
414 if (/sha2?-?512/i.test(algo)) return "sha512";
415 if (/sha2?-?256/i.test(algo)) return "sha256";
416 return undefined;
417}
418
419// ---------------------------------------------------------------------------
420// Server factory
421// ---------------------------------------------------------------------------
422
423export function createSshServer(): ReturnType<typeof SshServer> {
424 const server = new SshServer(
425 { hostKeys: [getHostKey()] },
426 (client: Connection) => {
427 // Per-connection auth state
428 let authUser: { userId: string; username: string; keyId: string } | null =
429 null;
430
431 client.on("authentication", async (ctx: AuthContext) => {
432 if (ctx.method === "none") {
433 return ctx.reject(["publickey"]);
434 }
435 if (ctx.method !== "publickey") {
436 return ctx.reject(["publickey"]);
437 }
438
439 // Phase 1 or 2 — always check DB first
440 const user = await resolveUserByKeyBlob(ctx.key.data);
441 if (!user) {
442 return ctx.reject();
443 }
444
445 if (!ctx.signature) {
446 // Phase 1: key is known — tell git client to proceed with signature
447 return ctx.accept();
448 }
449
450 // Phase 2: verify the signature
451 const pubKeyStr = `${ctx.key.algo} ${ctx.key.data.toString("base64")}`;
452 let parsedKey: ReturnType<typeof sshUtils.parseKey>;
453 try {
454 parsedKey = sshUtils.parseKey(pubKeyStr);
455 } catch {
456 return ctx.reject();
457 }
458 if (!parsedKey || parsedKey instanceof Error) {
459 return ctx.reject();
460 }
461
462 // RSA keys need an explicit hash algorithm hint
463 const hashAlgo = rsaHashAlgo(ctx.key.algo);
464 const verified = hashAlgo
465 ? parsedKey.verify(ctx.blob, ctx.signature, hashAlgo)
466 : parsedKey.verify(ctx.blob, ctx.signature);
467
468 if (verified !== true) {
469 return ctx.reject();
470 }
471
472 // Touch last_used_at for the key — fire and forget
473 db.update(sshKeys)
474 .set({ lastUsedAt: new Date() })
475 .where(eq(sshKeys.id, user.keyId))
476 .catch(() => {});
477
478 authUser = user;
479 ctx.accept();
480 });
481
482 client.on("ready", () => {
f5b9ef5Claude483 client.on("session", (accept: () => ServerChannel) => {
60323c5Claude484 const session = accept();
485
f5b9ef5Claude486 session.on("exec", async (accept: () => ServerChannel, _reject: () => void, info: { command: string }) => {
60323c5Claude487 const parsed = parseGitCommand(info.command);
488 const channel = accept();
489
490 if (!parsed) {
491 channel.stderr.write(
492 `remote: Unsupported command: ${info.command}\n`
493 );
494 channel.stderr.write(
495 "remote: Only git-upload-pack and git-receive-pack are allowed.\n"
496 );
497 channel.exit(1);
498 channel.end();
499 return;
500 }
501
502 await handleGitCommand(
503 parsed.service,
504 parsed.owner,
505 parsed.repo,
506 authUser?.userId ?? null,
507 channel
508 );
509 });
510
511 // Interactive shell — reject gracefully
f5b9ef5Claude512 session.on("shell", (accept: () => ServerChannel) => {
60323c5Claude513 const channel = accept();
514 channel.write(
515 "Hi there! Gluecron is a git-only service. Shell access is not available.\r\n"
516 );
517 channel.write(
518 "Clone a repo: git clone git@gluecron.com:owner/repo.git\r\n"
519 );
520 channel.exit(0);
521 channel.end();
522 });
523 });
524 });
525
f5b9ef5Claude526 client.on("error", (err: Error) => {
60323c5Claude527 if (process.env.SSH_DEBUG) {
528 console.debug("[ssh] client error:", err.message);
529 }
530 });
531 }
532 );
533
f5b9ef5Claude534 server.on("error", (err: Error) => {
60323c5Claude535 console.error("[ssh] server error:", err);
536 });
537
538 return server;
539}
540
541/**
542 * Start the SSH server on the configured port.
543 * A no-op when SSH_PORT=0.
544 */
545export function startSshServer(port?: number): void {
546 const sshPort = port ?? config.sshPort;
547 if (sshPort === 0) {
548 console.log(" ssh disabled (SSH_PORT=0)");
549 return;
550 }
551
552 try {
553 const server = createSshServer();
554 server.listen(sshPort, "0.0.0.0", () => {
555 console.log(` ssh:// git@<host>:owner/repo.git (port ${sshPort})`);
556 });
557 } catch (err) {
558 console.error(
559 "[ssh] failed to start:",
560 err instanceof Error ? err.message : err
561 );
562 }
563}