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