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 { 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 const hostKey = readFileSync(SSH_HOST_KEY_PATH);
37
38 const server = new Server({ hostKeys: [hostKey] }, (client) => {
39 let authedUser: { id: number; username: string } | null = null;
40
41 client.on("authentication", async (ctx) => {
42 if (ctx.method !== "publickey") {
43 return ctx.reject(["publickey"]);
44 }
45
46 // Probe phase: accept so the client proceeds to send a signature
47 if (!ctx.signature) return ctx.accept();
48
49 // Signature phase: look up the stored key by fingerprint
50 const fingerprint = fingerprintFromBytes(ctx.key.data);
51 const sshKey = await db
52 .selectFrom("ssh_keys")
53 .innerJoin("users", "users.id", "ssh_keys.user_id")
54 .select([
55 "users.id as userId",
56 "users.username",
57 "ssh_keys.public_key",
58 ])
59 .where("ssh_keys.fingerprint", "=", fingerprint)
60 .executeTakeFirst();
61
62 if (!sshKey) return ctx.reject();
63
64 // Verify signature using the stored public key text (parseKey needs key file format, not raw bytes)
65 const parsed = utils.parseKey(sshKey.public_key);
66 if (parsed instanceof Error || Array.isArray(parsed))
67 return ctx.reject();
68
69 const verifyResult = parsed.verify(ctx.blob!, ctx.signature);
70 if (verifyResult !== true) return ctx.reject();
71
72 authedUser = { id: sshKey.userId, username: sshKey.username };
73 ctx.accept();
74 });
75
76 client.on("ready", () => {
77 client.on("session", (accept) => {
78 const session = accept();
79
80 session.on("exec", async (accept, reject, info) => {
81 // git sends: git-upload-pack '/reponame.git'
82 const match = info.command.match(
83 /^(git-upload-pack|git-receive-pack)\s+'?\/?([a-zA-Z0-9_.-]+?)(?:\.git)?'?$/,
84 );
85 if (!match) return reject();
86
87 const command = match[1]!;
88 const repoName = match[2]!;
89
90 const repo = await db
91 .selectFrom("repositories")
92 .select(["name", "is_private"])
93 .where("name", "=", repoName)
94 .executeTakeFirst();
95 if (!repo) return reject();
96
97 const repoPath = path.join(REPOS_DIR, `${repo.name}.git`);
98 const stream = accept();
99
100 if (command === "git-receive-pack") {
101 if (
102 !authedUser ||
103 authedUser.username !== ADMIN_USERNAME
104 ) {
105 stream.stderr.write("error: push access denied\n");
106 stream.exit(128);
107 stream.end();
108 return;
109 }
110 }
111
112 if (repo.is_private && !authedUser) {
113 stream.stderr.write(
114 "error: repository access denied\n",
115 );
116 stream.exit(128);
117 stream.end();
118 return;
119 }
120
121 const proc: ChildProcess = spawn(command, [repoPath]);
122 stream.pipe(proc.stdin!);
123 proc.stdout?.pipe(stream, { end: false });
124 proc.stderr?.pipe(stream.stderr as NodeJS.WritableStream, {
125 end: false,
126 });
127
128 proc.on("close", (code: number | null) => {
129 stream.exit(code ?? 0);
130 stream.end();
131 });
132 stream.on("close", () => proc.kill());
133 });
134 });
135 });
136
137 client.on("error", () => {
138 /* absorb ECONNRESET etc. */
139 });
140 });
141
142 server.listen(SSH_PORT, "0.0.0.0", () => {
143 console.log(`SSH server listening on port ${SSH_PORT}`);
144 });
145}
146