import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; import path from "node:path"; import { Server, utils, type Connection } from "ssh2"; import { onRefsUpdated, startWorkflowsInBackground } from "./ci"; import { pathExists, snapshotRefs, spawnService } from "./git"; import { findKeyByBlob, touchSshKey } from "./keys"; import { DATA_ROOT } from "./paths"; import { canWrite, ensureWikiRepo, findGitTarget, markPushed, repoAccess } from "./repos"; import { SSH_LISTEN_HOST, SSH_PORT } from "./ssh-config"; import { getUserById, type User } from "./users"; function hostKey() { const file = path.join(DATA_ROOT, "ssh_host_ed25519_key"); if (!existsSync(file)) { mkdirSync(DATA_ROOT, { recursive: true }); const { private: priv } = utils.generateKeyPairSync("ed25519"); writeFileSync(file, priv, { mode: 0o600 }); } return readFileSync(file); } // git sends e.g.: git-upload-pack '/alice/demo.git' const COMMAND_RE = /^git[ -](upload-pack|receive-pack) '?\/?([^'/\s]+)\/([^'/\s]+?)'?$/; function handleConnection(client: Connection) { let user: User | null = null; let keyId: string | null = null; client.on("authentication", async (ctx) => { if (ctx.method !== "publickey") return ctx.reject(["publickey"]); const key = await findKeyByBlob(ctx.key.data).catch((e) => (console.error("[ssh] key lookup failed:", e), null)); if (!key) return ctx.reject(["publickey"]); // First the client asks whether a key is acceptable, then proves ownership with a signature. if (ctx.signature && ctx.blob) { const parsed = utils.parseKey(key.publicKey); if (parsed instanceof Error || parsed.verify(ctx.blob, ctx.signature, ctx.hashAlgo) !== true) { return ctx.reject(["publickey"]); } user = await getUserById(key.userId).catch(() => null); keyId = key.id; if (!user) return ctx.reject(["publickey"]); } ctx.accept(); }); client.on("ready", () => { if (keyId) touchSshKey(keyId).catch((e) => console.error("[ssh] could not record key use:", e)); client.on("session", (acceptSession) => { const session = acceptSession(); const env: Record = {}; session.on("env", (accept, _reject, info) => { if (info.key === "GIT_PROTOCOL") env.GIT_PROTOCOL = info.val; accept?.(); }); session.on("pty", (accept) => accept?.()); session.on("shell", (accept) => { const channel = accept(); channel.write( `Hi ${user?.username}! You've successfully authenticated, but this server does not provide shell access.\r\n`, ); channel.exit(1); channel.end(); }); session.on("exec", async (accept, _reject, info) => { const channel = accept(); const fail = (message: string) => { channel.stderr.write(`ERROR: ${message}\n`); channel.exit(1); channel.end(); }; const match = COMMAND_RE.exec(info.command.trim()); if (!match || !user) return fail("Only git clone, fetch and push are supported."); const [, service, owner, repoName] = match; const userId = user.id; // .wiki.git is the repository's wiki, with the repository's permissions. const target = await findGitTarget(owner, repoName).catch(() => null); const repo = target?.repo; const access = repo ? await repoAccess(repo, user.id).catch(() => null) : null; if (!target || !repo || !access) return fail("Repository not found."); const pushing = service === "receive-pack"; if (pushing && !canWrite(access)) { return fail(`Permission to ${repo.owner}/${repo.name} denied to ${user.username}.`); } if (target.wiki) { // The first push to a wiki creates it; until then there is nothing to fetch. if (pushing) await ensureWikiRepo(repo); else if (!(await pathExists(target.dir))) return fail("Repository not found."); } // Pushes to the code start workflows for the refs they changed; pushes to the wiki don't. const refsBefore = pushing && !target.wiki ? await snapshotRefs(repo.dir) : null; const child = spawnService(pushing ? "git-receive-pack" : "git-upload-pack", target.dir, { stateless: false, protocol: env.GIT_PROTOCOL, }); channel.pipe(child.stdin); child.stdout.pipe(channel, { end: false }); child.stderr.pipe(channel.stderr, { end: false }); child.on("close", (code) => { if (refsBefore && code === 0) { startWorkflowsInBackground( markPushed(repo.id).then(() => snapshotRefs(repo.dir)).then((after) => onRefsUpdated(repo, refsBefore, after, userId)), ); } channel.exit(code ?? 1); channel.end(); }); channel.on("close", () => child.kill()); }); }); }); client.on("error", () => { // Clients disconnecting mid-handshake are routine; ignore. }); } export function startSshServer() { const g = globalThis as unknown as { __sshServer?: Server }; if (g.__sshServer) return; const server = new Server({ hostKeys: [hostKey()] }, handleConnection); server.on("error", (err: NodeJS.ErrnoException) => { console.warn(`[ssh] could not start on port ${SSH_PORT}: ${err.code ?? err.message}`); }); server.listen(SSH_PORT, SSH_LISTEN_HOST, () => { console.log(`[ssh] git over SSH listening on ${SSH_LISTEN_HOST}:${SSH_PORT}`); }); g.__sshServer = server; }