sshServer.ts
Raw
1import { type ChildProcess, spawn } from "node:child_process";
2import { createHash } from "node:crypto";
3import { readFileSync } from "node:fs";
4import path from "node:path";
5import { Server, utils } from "ssh2";
6import config from "../config.ts";
7import { ADMIN_USERNAME, paths } from "../constants.ts";
8import { db } from "../db/index.ts";
9import {
10 parseCiConfig,
11 shouldTriggerPush,
12 shouldTriggerTag,
13 triggerRun,
14} from "./ci.ts";
15import { git, invalidateRefCache } from "./git.ts";
16
17/**
18 * Parse pkt-line ref updates from the leading bytes of a receive-pack
19 * upload. Returns oldSha/newSha/refname triples. Mirrors `parseRefUpdates`
20 * in `routes/git.ts` — kept duplicated to avoid coupling the route file
21 * to the SSH path. Both only ever look at the first ~4 KB.
22 */
23function parsePktLineRefUpdates(
24 text: string,
25): Array<{ oldSha: string; newSha: string; refname: string }> {
26 const refs: Array<{ oldSha: string; newSha: string; refname: string }> = [];
27 let pos = 0;
28 while (pos + 4 <= text.length) {
29 const lenStr = text.slice(pos, pos + 4);
30 const len = parseInt(lenStr, 16);
31 if (Number.isNaN(len) || len === 0) break;
32 if (len < 4 || pos + len > text.length) break;
33 const line = text
34 .slice(pos + 4, pos + len)
35 .replace(/\0.*$/, "")
36 .trim();
37 pos += len;
38 const parts = line.split(" ");
39 if (parts.length >= 3) {
40 const oldSha = parts[0] ?? "";
41 const newSha = parts[1] ?? "";
42 const refname = parts[2] ?? "";
43 if (refname) refs.push({ oldSha, newSha, refname });
44 }
45 }
46 return refs;
47}
48
49async function triggerCiForPush(
50 repoName: string,
51 refUpdates: Array<{ oldSha: string; newSha: string; refname: string }>,
52): Promise<void> {
53 for (const { newSha, refname } of refUpdates) {
54 if (/^0+$/.test(newSha)) continue;
55
56 const isBranch = refname.startsWith("refs/heads/");
57 const isTag = refname.startsWith("refs/tags/");
58 if (!isBranch && !isTag) continue;
59
60 const tomlBuf = await git
61 .show(repoName, newSha, ".hearthforge-ci.toml")
62 .catch(() => null);
63 if (!tomlBuf) continue;
64
65 const cfg = parseCiConfig(tomlBuf.toString("utf-8"));
66 if (!cfg) continue;
67
68 if (isBranch) {
69 const branch = refname.slice("refs/heads/".length);
70 if (shouldTriggerPush(cfg, branch)) {
71 triggerRun(repoName, {
72 triggerSource: "push",
73 commitSha: newSha,
74 commitBranch: branch,
75 }).catch((e) =>
76 console.error(`CI push trigger failed for ${repoName}:`, e),
77 );
78 }
79 } else if (isTag && shouldTriggerTag(cfg)) {
80 const tag = refname.slice("refs/tags/".length);
81 triggerRun(repoName, {
82 triggerSource: "tag",
83 commitSha: newSha,
84 commitTag: tag,
85 }).catch((e) =>
86 console.error(`CI tag trigger failed for ${repoName}:`, e),
87 );
88 }
89 }
90}
91
92/** Compute SHA256 fingerprint from raw SSH public key bytes (the wire-format bytes). */
93function fingerprintFromBytes(keyBytes: Buffer): string {
94 const hash = createHash("sha256")
95 .update(keyBytes)
96 .digest("base64")
97 .replace(/=+$/, "");
98 return `SHA256:${hash}`;
99}
100
101/**
102 * Compute fingerprint from a full public key line
103 * (e.g. "ssh-ed25519 AAAA... comment").
104 * Returns null if the line is malformed.
105 */
106export function fingerprintFromLine(pubkeyLine: string): string | null {
107 const parts = pubkeyLine.trim().split(/\s+/);
108 if (parts.length < 2) return null;
109 try {
110 const keyBytes = Buffer.from(parts[1] ?? "", "base64");
111 return fingerprintFromBytes(keyBytes);
112 } catch {
113 return null;
114 }
115}
116
117export async function startSshServer() {
118 const hostKey = readFileSync(paths.SSH_HOST_KEY_PATH);
119
120 const server = new Server({ hostKeys: [hostKey] }, (client) => {
121 let authedUser: { id: number; username: string } | null = null;
122
123 client.on("authentication", async (ctx) => {
124 if (ctx.method !== "publickey") {
125 return ctx.reject(["publickey"]);
126 }
127
128 // Probe phase: accept so the client proceeds to send a signature
129 if (!ctx.signature) return ctx.accept();
130
131 // Signature phase: look up the stored key by fingerprint
132 const fingerprint = fingerprintFromBytes(ctx.key.data);
133 const sshKey = await db
134 .selectFrom("ssh_keys")
135 .innerJoin("users", "users.id", "ssh_keys.user_id")
136 .select([
137 "users.id as userId",
138 "users.username",
139 "ssh_keys.public_key",
140 ])
141 .where("ssh_keys.fingerprint", "=", fingerprint)
142 .where("users.is_pending", "=", 0)
143 .executeTakeFirst();
144
145 if (!sshKey) return ctx.reject();
146
147 // Verify signature using the stored public key text (parseKey needs key file format, not raw bytes)
148 const parsed = utils.parseKey(sshKey.public_key);
149 if (parsed instanceof Error || Array.isArray(parsed))
150 return ctx.reject();
151
152 const verifyResult = parsed.verify(ctx.blob!, ctx.signature);
153 if (verifyResult !== true) return ctx.reject();
154
155 authedUser = { id: sshKey.userId, username: sshKey.username };
156 ctx.accept();
157 });
158
159 client.on("ready", () => {
160 client.on("session", (accept) => {
161 const session = accept();
162
163 session.on("exec", async (accept, reject, info) => {
164 // git sends: git-upload-pack '/reponame.git'
165 const match = info.command.match(
166 /^(git-upload-pack|git-receive-pack)\s+'?\/?([a-zA-Z0-9_.-]+?)(?:\.git)?'?$/,
167 );
168 if (!match) return reject();
169
170 const command = match[1]!;
171 const repoName = match[2]!;
172
173 const repo = await db
174 .selectFrom("repositories")
175 .select(["name", "is_private"])
176 .where("name", "=", repoName)
177 .executeTakeFirst();
178 if (!repo) return reject();
179
180 const repoPath = path.join(
181 paths.REPOS_DIR,
182 `${repo.name}.git`,
183 );
184 const stream = accept();
185
186 if (command === "git-receive-pack") {
187 if (
188 !authedUser ||
189 authedUser.username !== ADMIN_USERNAME
190 ) {
191 stream.stderr.write("error: push access denied\n");
192 stream.exit(128);
193 stream.end();
194 return;
195 }
196 }
197
198 if (
199 repo.is_private &&
200 authedUser?.username !== ADMIN_USERNAME
201 ) {
202 // Match the UI and HTTP smart-git: private repos
203 // are admin-only. Without this, any user with a
204 // registered SSH key could clone repos hidden from
205 // them in the web UI.
206 stream.stderr.write(
207 "error: repository access denied\n",
208 );
209 stream.exit(128);
210 stream.end();
211 return;
212 }
213
214 const proc: ChildProcess = spawn(command, [repoPath]);
215
216 // For receive-pack, capture the first ~4 KB of pkt-line
217 // data so we can extract the ref updates after git
218 // finishes (mirroring the HTTP path in routes/git.ts).
219 // CI was previously not triggered on SSH pushes at all.
220 let preamble: Buffer | null = null;
221 if (command === "git-receive-pack") {
222 preamble = Buffer.alloc(0);
223 stream.on("data", (chunk: Buffer) => {
224 if (preamble && preamble.length < 4096) {
225 preamble = Buffer.concat([
226 preamble,
227 chunk.subarray(0, 4096 - preamble.length),
228 ]);
229 }
230 });
231 }
232
233 stream.pipe(proc.stdin!);
234 proc.stdout?.pipe(stream, { end: false });
235 proc.stderr?.pipe(stream.stderr as NodeJS.WritableStream, {
236 end: false,
237 });
238
239 proc.on("close", (code: number | null) => {
240 if (command === "git-receive-pack") {
241 invalidateRefCache(repo.name);
242 if (code === 0 && preamble) {
243 const refUpdates = parsePktLineRefUpdates(
244 preamble.toString("utf-8"),
245 );
246 triggerCiForPush(repo.name, refUpdates).catch(
247 () => {},
248 );
249 }
250 }
251 stream.exit(code ?? 0);
252 stream.end();
253 });
254 stream.on("close", () => proc.kill());
255 });
256 });
257 });
258
259 client.on("error", () => {
260 /* absorb ECONNRESET etc. */
261 });
262 });
263
264 server.listen(config.SSH_PORT, "0.0.0.0", () => {
265 console.log(`SSH server listening on port ${config.SSH_PORT}`);
266 });
267}
268