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