sshServer.ts
Raw
1import { type ChildProcess, spawn } from "node:child_process";
2import { createHash } from "node:crypto";
3import { existsSync, readFileSync } from "node:fs";
4import path from "node:path";
5import { Server, utils } from "ssh2";
6import { SSH_PORT } from "../config.ts";
7import { ADMIN_USERNAME, REPOS_DIR, SSH_HOST_KEY_PATH } from "../constants.ts";
8import { db } from "../db/index.ts";
9
10/** Compute SHA256 fingerprint from raw SSH public key bytes (the wire-format bytes). */
11function fingerprintFromBytes(keyBytes: Buffer): string {
12 const hash = createHash("sha256")
13 .update(keyBytes)
14 .digest("base64")
15 .replace(/=+$/, "");
16 return `SHA256:${hash}`;
17}
18
19/**
20 * Compute fingerprint from a full public key line
21 * (e.g. "ssh-ed25519 AAAA... comment").
22 * Returns null if the line is malformed.
23 */
24export function fingerprintFromLine(pubkeyLine: string): string | null {
25 const parts = pubkeyLine.trim().split(/\s+/);
26 if (parts.length < 2) return null;
27 try {
28 const keyBytes = Buffer.from(parts[1] ?? "", "base64");
29 return fingerprintFromBytes(keyBytes);
30 } catch {
31 return null;
32 }
33}
34
35export async function startSshServer() {
36 if (!existsSync(SSH_HOST_KEY_PATH)) {
37 await Bun.spawn([
38 "ssh-keygen",
39 "-t",
40 "ed25519",
41 "-N",
42 "",
43 "-f",
44 SSH_HOST_KEY_PATH,
45 "-C",
46 "hearthforge-host",
47 ]).exited;
48 console.log("Generated SSH host key at", SSH_HOST_KEY_PATH);
49 }
50
51 const hostKey = readFileSync(SSH_HOST_KEY_PATH);
52
53 const server = new Server({ hostKeys: [hostKey] }, (client) => {
54 let authedUser: { id: number; username: string } | null = null;
55
56 client.on("authentication", async (ctx) => {
57 if (ctx.method !== "publickey") {
58 return ctx.reject(["publickey"]);
59 }
60
61 // Probe phase: accept so the client proceeds to send a signature
62 if (!ctx.signature) return ctx.accept();
63
64 // Signature phase: look up the stored key by fingerprint
65 const fingerprint = fingerprintFromBytes(ctx.key.data);
66 const sshKey = await db
67 .selectFrom("ssh_keys")
68 .innerJoin("users", "users.id", "ssh_keys.user_id")
69 .select([
70 "users.id as userId",
71 "users.username",
72 "ssh_keys.public_key",
73 ])
74 .where("ssh_keys.fingerprint", "=", fingerprint)
75 .executeTakeFirst();
76
77 if (!sshKey) return ctx.reject();
78
79 // Verify signature using the stored public key text (parseKey needs key file format, not raw bytes)
80 const parsed = utils.parseKey(sshKey.public_key);
81 if (parsed instanceof Error || Array.isArray(parsed))
82 return ctx.reject();
83
84 const verifyResult = parsed.verify(ctx.blob!, ctx.signature);
85 if (verifyResult !== true) return ctx.reject();
86
87 authedUser = { id: sshKey.userId, username: sshKey.username };
88 ctx.accept();
89 });
90
91 client.on("ready", () => {
92 client.on("session", (accept) => {
93 const session = accept();
94
95 session.on("exec", async (accept, reject, info) => {
96 // git sends: git-upload-pack '/reponame.git'
97 const match = info.command.match(
98 /^(git-upload-pack|git-receive-pack)\s+'?\/?([a-zA-Z0-9_.-]+?)(?:\.git)?'?$/,
99 );
100 if (!match) return reject();
101
102 const command = match[1]!;
103 const repoName = match[2]!;
104
105 const repo = await db
106 .selectFrom("repositories")
107 .select(["name", "is_private"])
108 .where("name", "=", repoName)
109 .executeTakeFirst();
110 if (!repo) return reject();
111
112 const repoPath = path.join(REPOS_DIR, `${repo.name}.git`);
113 const stream = accept();
114
115 if (command === "git-receive-pack") {
116 if (
117 !authedUser ||
118 authedUser.username !== ADMIN_USERNAME
119 ) {
120 stream.stderr.write("error: push access denied\n");
121 stream.exit(128);
122 stream.end();
123 return;
124 }
125 }
126
127 if (repo.is_private && !authedUser) {
128 stream.stderr.write(
129 "error: repository access denied\n",
130 );
131 stream.exit(128);
132 stream.end();
133 return;
134 }
135
136 const proc: ChildProcess = spawn(command, [repoPath]);
137 stream.pipe(proc.stdin!);
138 proc.stdout?.pipe(stream, { end: false });
139 proc.stderr?.pipe(stream.stderr as NodeJS.WritableStream, {
140 end: false,
141 });
142
143 proc.on("close", (code: number | null) => {
144 stream.exit(code ?? 0);
145 stream.end();
146 });
147 stream.on("close", () => proc.kill());
148 });
149 });
150 });
151
152 client.on("error", () => {
153 /* absorb ECONNRESET etc. */
154 });
155 });
156
157 server.listen(SSH_PORT, "0.0.0.0", () => {
158 console.log(`SSH server listening on port ${SSH_PORT}`);
159 });
160}
161