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<string, string> = {};
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;
// <name>.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;
}