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";
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(paths.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(
98 paths.REPOS_DIR,
99 `${repo.name}.git`,
100 );
101 const stream = accept();
102
103 if (command === "git-receive-pack") {
104 if (
105 !authedUser ||
106 authedUser.username !== ADMIN_USERNAME
107 ) {
108 stream.stderr.write("error: push access denied\n");
109 stream.exit(128);
110 stream.end();
111 return;
112 }
113 }
114
115 if (repo.is_private && !authedUser) {
116 stream.stderr.write(
117 "error: repository access denied\n",
118 );
119 stream.exit(128);
120 stream.end();
121 return;
122 }
123
124 const proc: ChildProcess = spawn(command, [repoPath]);
125 stream.pipe(proc.stdin!);
126 proc.stdout?.pipe(stream, { end: false });
127 proc.stderr?.pipe(stream.stderr as NodeJS.WritableStream, {
128 end: false,
129 });
130
131 proc.on("close", (code: number | null) => {
132 stream.exit(code ?? 0);
133 stream.end();
134 });
135 stream.on("close", () => proc.kill());
136 });
137 });
138 });
139
140 client.on("error", () => {
141 /* absorb ECONNRESET etc. */
142 });
143 });
144
145 server.listen(config.SSH_PORT, "0.0.0.0", () => {
146 console.log(`SSH server listening on port ${config.SSH_PORT}`);
147 });
148}
149