import { type ChildProcess, spawn } from "node:child_process"; import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; import path from "node:path"; import { Server, utils } from "ssh2"; import config from "../config.ts"; import { ADMIN_USERNAME, paths } from "../constants.ts"; import { db } from "../db/index.ts"; import { parseCiConfig, shouldTriggerPush, shouldTriggerTag, triggerRun, } from "./ci.ts"; import { git, invalidateRefCache } from "./git.ts"; /** * Parse pkt-line ref updates from the leading bytes of a receive-pack * upload. Returns oldSha/newSha/refname triples. Mirrors `parseRefUpdates` * in `routes/git.ts` — kept duplicated to avoid coupling the route file * to the SSH path. Both only ever look at the first ~4 KB. */ function parsePktLineRefUpdates( text: string, ): Array<{ oldSha: string; newSha: string; refname: string }> { const refs: Array<{ oldSha: string; newSha: string; refname: string }> = []; let pos = 0; while (pos + 4 <= text.length) { const lenStr = text.slice(pos, pos + 4); const len = parseInt(lenStr, 16); if (Number.isNaN(len) || len === 0) break; if (len < 4 || pos + len > text.length) break; const line = text .slice(pos + 4, pos + len) .replace(/\0.*$/, "") .trim(); pos += len; const parts = line.split(" "); if (parts.length >= 3) { const oldSha = parts[0] ?? ""; const newSha = parts[1] ?? ""; const refname = parts[2] ?? ""; if (refname) refs.push({ oldSha, newSha, refname }); } } return refs; } async function triggerCiForPush( repoName: string, refUpdates: Array<{ oldSha: string; newSha: string; refname: string }>, ): Promise { for (const { newSha, refname } of refUpdates) { if (/^0+$/.test(newSha)) continue; const isBranch = refname.startsWith("refs/heads/"); const isTag = refname.startsWith("refs/tags/"); if (!isBranch && !isTag) continue; const tomlBuf = await git .show(repoName, newSha, ".hearthforge-ci.toml") .catch(() => null); if (!tomlBuf) continue; const cfg = parseCiConfig(tomlBuf.toString("utf-8")); if (!cfg) continue; if (isBranch) { const branch = refname.slice("refs/heads/".length); if (shouldTriggerPush(cfg, branch)) { triggerRun(repoName, { triggerSource: "push", commitSha: newSha, commitBranch: branch, }).catch((e) => console.error(`CI push trigger failed for ${repoName}:`, e), ); } } else if (isTag && shouldTriggerTag(cfg)) { const tag = refname.slice("refs/tags/".length); triggerRun(repoName, { triggerSource: "tag", commitSha: newSha, commitTag: tag, }).catch((e) => console.error(`CI tag trigger failed for ${repoName}:`, e), ); } } } /** Compute SHA256 fingerprint from raw SSH public key bytes (the wire-format bytes). */ function fingerprintFromBytes(keyBytes: Buffer): string { const hash = createHash("sha256") .update(keyBytes) .digest("base64") .replace(/=+$/, ""); return `SHA256:${hash}`; } /** * Compute fingerprint from a full public key line * (e.g. "ssh-ed25519 AAAA... comment"). * Returns null if the line is malformed. */ export function fingerprintFromLine(pubkeyLine: string): string | null { const parts = pubkeyLine.trim().split(/\s+/); if (parts.length < 2) return null; try { const keyBytes = Buffer.from(parts[1] ?? "", "base64"); return fingerprintFromBytes(keyBytes); } catch { return null; } } export async function startSshServer() { const hostKey = readFileSync(paths.SSH_HOST_KEY_PATH); const server = new Server({ hostKeys: [hostKey] }, (client) => { let authedUser: { id: number; username: string } | null = null; client.on("authentication", async (ctx) => { if (ctx.method !== "publickey") { return ctx.reject(["publickey"]); } // Probe phase: accept so the client proceeds to send a signature if (!ctx.signature) return ctx.accept(); // Signature phase: look up the stored key by fingerprint const fingerprint = fingerprintFromBytes(ctx.key.data); const sshKey = await db .selectFrom("ssh_keys") .innerJoin("users", "users.id", "ssh_keys.user_id") .select([ "users.id as userId", "users.username", "ssh_keys.public_key", ]) .where("ssh_keys.fingerprint", "=", fingerprint) .where("users.is_pending", "=", 0) .executeTakeFirst(); if (!sshKey) return ctx.reject(); // Verify signature using the stored public key text (parseKey needs key file format, not raw bytes) const parsed = utils.parseKey(sshKey.public_key); if (parsed instanceof Error || Array.isArray(parsed)) return ctx.reject(); const verifyResult = parsed.verify(ctx.blob!, ctx.signature); if (verifyResult !== true) return ctx.reject(); authedUser = { id: sshKey.userId, username: sshKey.username }; ctx.accept(); }); client.on("ready", () => { client.on("session", (accept) => { const session = accept(); session.on("exec", async (accept, reject, info) => { // git sends: git-upload-pack '/reponame.git' const match = info.command.match( /^(git-upload-pack|git-receive-pack)\s+'?\/?([a-zA-Z0-9_.-]+?)(?:\.git)?'?$/, ); if (!match) return reject(); const command = match[1]!; const repoName = match[2]!; const repo = await db .selectFrom("repositories") .select(["name", "is_private"]) .where("name", "=", repoName) .executeTakeFirst(); if (!repo) return reject(); const repoPath = path.join( paths.REPOS_DIR, `${repo.name}.git`, ); const stream = accept(); if (command === "git-receive-pack") { if ( !authedUser || authedUser.username !== ADMIN_USERNAME ) { stream.stderr.write("error: push access denied\n"); stream.exit(128); stream.end(); return; } } if ( repo.is_private && authedUser?.username !== ADMIN_USERNAME ) { // Match the UI and HTTP smart-git: private repos // are admin-only. Without this, any user with a // registered SSH key could clone repos hidden from // them in the web UI. stream.stderr.write( "error: repository access denied\n", ); stream.exit(128); stream.end(); return; } const proc: ChildProcess = spawn(command, [repoPath]); // For receive-pack, capture the first ~4 KB of pkt-line // data so we can extract the ref updates after git // finishes (mirroring the HTTP path in routes/git.ts). // CI was previously not triggered on SSH pushes at all. let preamble: Buffer | null = null; if (command === "git-receive-pack") { preamble = Buffer.alloc(0); stream.on("data", (chunk: Buffer) => { if (preamble && preamble.length < 4096) { preamble = Buffer.concat([ preamble, chunk.subarray(0, 4096 - preamble.length), ]); } }); } stream.pipe(proc.stdin!); proc.stdout?.pipe(stream, { end: false }); proc.stderr?.pipe(stream.stderr as NodeJS.WritableStream, { end: false, }); proc.on("close", (code: number | null) => { if (command === "git-receive-pack") { invalidateRefCache(repo.name); if (code === 0 && preamble) { const refUpdates = parsePktLineRefUpdates( preamble.toString("utf-8"), ); triggerCiForPush(repo.name, refUpdates).catch( () => {}, ); } } stream.exit(code ?? 0); stream.end(); }); stream.on("close", () => proc.kill()); }); }); }); client.on("error", () => { /* absorb ECONNRESET etc. */ }); }); server.listen(config.SSH_PORT, "0.0.0.0", () => { console.log(`SSH server listening on port ${config.SSH_PORT}`); }); }