···11-import { type ChildProcessWithoutNullStreams, spawn } from 'node:child_process'
22-import { Readable } from 'node:stream'
11+import { PassThrough, Readable } from 'node:stream'
22+import { Client } from 'ssh2'
33import { classifyNgReason, classifySshStderr, WireError } from './errors'
44import { encodePktLine, flushPkt, lineToString, PktLineReader } from './pkt-line'
55import { type Advertisement, parseAdvertisement } from './refs'
···2424}
25252626/**
2727- * A spawned process exposing `git-receive-pack`'s stdio. The default factory
2828- * runs ssh to the knot; tests inject a factory that spawns the binary against
2929- * a local bare repo, so the stdio protocol is identical either way.
2727+ * A process exposing `git-receive-pack`'s stdio. The default factory runs an
2828+ * in-process `ssh2` channel to the knot; tests inject a factory that spawns the
2929+ * binary against a local bare repo, so the stdio protocol is identical either
3030+ * way.
3031 */
3132export interface ReceivePackProcess {
3233 stdin: NodeJS.WritableStream
···4445 host: string
4546 port?: number
4647 repoPath: string
4747- sshArgs: string[]
4848+ /** Decrypted OpenSSH-format private key for this install. */
4949+ privateKey: string
4850}
49515050-/** Default transport: ssh to the knot and invoke its `git-receive-pack`. */
5151-export function sshReceivePackFactory(target: SshTarget): ReceivePackFactory {
5252+/**
5353+ * Default transport: open an in-process `ssh2` connection to the knot and run
5454+ * its `git-receive-pack`. No `ssh` binary (the Vercel runtime has none); the
5555+ * key stays in memory.
5656+ *
5757+ * Host keys: tangled knots are addressed by hostname over TLS-fronted DNS, and
5858+ * v1 has no pinned host keys, so `hostVerifier` accepts any (TOFU-equivalent to
5959+ * the previous `StrictHostKeyChecking=accept-new`). Pin once the canonical knot
6060+ * keys are known.
6161+ */
6262+export function ssh2ReceivePackFactory(target: SshTarget): ReceivePackFactory {
5263 return () => {
5353- const portArgs = target.port ? ['-p', String(target.port)] : []
5454- // ssh:// transports invoke the remote command with the path including its
5555- // leading slash, single-quoted. The knot resolves repos by that path.
6464+ // The knot resolves repos by the leading-slash path, single-quoted.
5665 const remoteCmd = `git-receive-pack '${target.repoPath}'`
5757- const child = spawn('ssh', [...target.sshArgs, ...portArgs, `git@${target.host}`, remoteCmd], {
5858- stdio: ['pipe', 'pipe', 'pipe'],
6666+ const client = new Client()
6767+6868+ const stdin = new PassThrough()
6969+ const stdout = new PassThrough()
7070+ let stderrBuf = Buffer.alloc(0)
7171+ let connError: Error | null = null
7272+ let killed = false
7373+7474+ const appendStderr = (chunk: Buffer) => {
7575+ stderrBuf = Buffer.concat([stderrBuf, chunk]).subarray(-STDERR_CAP)
7676+ }
7777+7878+ const done = new Promise<number | null>(resolve => {
7979+ let settled = false
8080+ const settle = (code: number | null) => {
8181+ if (settled) return
8282+ settled = true
8383+ resolve(code)
8484+ }
8585+8686+ client.on('ready', () => {
8787+ client.exec(remoteCmd, (err, channel) => {
8888+ if (err) {
8989+ connError = err
9090+ stdout.end()
9191+ client.end()
9292+ settle(null)
9393+ return
9494+ }
9595+ stdin.pipe(channel)
9696+ channel.pipe(stdout)
9797+ channel.stderr.on('data', appendStderr)
9898+ channel.on('exit', code => settle(typeof code === 'number' ? code : null))
9999+ channel.on('close', () => { client.end(); stdout.end() })
100100+ })
101101+ })
102102+103103+ // A connection / auth failure surfaces here. Capturing it (rather than
104104+ // leaving 'error' unhandled, which crashes the process) folds the message
105105+ // into the stderr band so open() reports a WireError the job handler
106106+ // catches. Ending stdout unblocks the advertisement read.
107107+ client.on('error', err => {
108108+ if (!killed) connError = err
109109+ stdout.end()
110110+ settle(null)
111111+ })
112112+113113+ // Always-fires backstop: `client.end()` (from kill(), a channel close, or
114114+ // an exec error) emits 'close', so `done` resolves even if the channel
115115+ // already exited and won't emit another event.
116116+ client.on('close', () => {
117117+ stdout.end()
118118+ settle(null)
119119+ })
59120 })
6060- return wrapChild(child)
6161- }
6262-}
631216464-function wrapChild(child: ChildProcessWithoutNullStreams): ReceivePackProcess {
6565- let stderrBuf = Buffer.alloc(0)
6666- child.stderr.on('data', (chunk: Buffer) => {
6767- stderrBuf = Buffer.concat([stderrBuf, chunk]).subarray(-STDERR_CAP)
6868- })
6969- const done = new Promise<number | null>(resolve => child.on('close', resolve))
7070- return {
7171- stdin: child.stdin,
7272- stdout: child.stdout,
7373- stderr: () => stderrBuf.toString('utf8'),
7474- kill: () => child.kill('SIGKILL'),
7575- done,
122122+ // stdin EPIPE-style errors once the channel goes away are expected.
123123+ stdin.on('error', () => {})
124124+125125+ client.connect({
126126+ host: target.host,
127127+ port: target.port ?? 22,
128128+ username: 'git',
129129+ privateKey: target.privateKey,
130130+ readyTimeout: 15_000,
131131+ hostVerifier: () => true,
132132+ })
133133+134134+ return {
135135+ stdin,
136136+ stdout,
137137+ stderr: () => {
138138+ const captured = stderrBuf.toString('utf8')
139139+ if (connError) return `${captured}${captured ? '\n' : ''}ssh error: ${connError.message}`.trim()
140140+ return captured
141141+ },
142142+ kill: () => {
143143+ killed = true
144144+ client.end()
145145+ stdout.end()
146146+ },
147147+ done,
148148+ }
76149 }
77150}
78151
+9-23
server/utils/splice.ts
···22 type ReceivePackFactory,
33 ReceivePackSession,
44 type RefUpdate,
55- sshReceivePackFactory,
55+ ssh2ReceivePackFactory,
66} from './git-wire/receive-pack'
77import { ZERO_SHA } from './git-wire/refs'
88import { fetchPack } from './git-wire/upload-pack'
99-import { loadSshArgsForInstall } from './ssh-cmd'
99+import { loadSshKeyForInstall } from './ssh-cmd'
1010import { sshEndpointForKnot } from './sync-push-host'
11111212const DEFAULT_MAX_PACK_BYTES = 1024 * 1024 * 1024
···2020 return Number.isNaN(n) || n <= 0 ? DEFAULT_MAX_PACK_BYTES : n
2121}
22222323-async function sshFactory(installationId: number, knot: string, repoDid: string): Promise<{
2424- factory: ReceivePackFactory
2525- cleanup: () => void
2626-}> {
2727- const { args, cleanup } = await loadSshArgsForInstall(installationId)
2323+async function sshFactory(installationId: number, knot: string, repoDid: string): Promise<ReceivePackFactory> {
2424+ const privateKey = await loadSshKeyForInstall(installationId)
2825 const { host, port } = sshEndpointForKnot(knot)
2926 // ssh:// path form: leading slash, the knot resolves the repo by DID.
3030- const factory = sshReceivePackFactory({ host, port, repoPath: `/${repoDid}`, sshArgs: args })
3131- return { factory, cleanup }
2727+ return ssh2ReceivePackFactory({ host, port, repoPath: `/${repoDid}`, privateKey })
3228}
33293430export interface SplicePushParams {
···6460 * keeps the knot's advertised tip as the authoritative compare-and-swap base.
6561 */
6662export async function splicePush(params: SplicePushParams): Promise<SplicePushResult> {
6767- const { factory, cleanup } = await sshFactory(params.installationId, params.knot, params.repoDid)
6868- try {
6969- return await runSplice(factory, params)
7070- }
7171- finally {
7272- cleanup()
7373- }
6363+ const factory = await sshFactory(params.installationId, params.knot, params.repoDid)
6464+ return runSplice(factory, params)
7465}
75667667/** The fetch + push exchange over an open session. Split out for the wire test. */
···127118 repoDid: string
128119 ref: string
129120}): Promise<SpliceDeleteResult> {
130130- const { factory, cleanup } = await sshFactory(params.installationId, params.knot, params.repoDid)
131131- try {
132132- return await runSpliceDelete(factory, params.ref)
133133- }
134134- finally {
135135- cleanup()
136136- }
121121+ const factory = await sshFactory(params.installationId, params.knot, params.repoDid)
122122+ return runSpliceDelete(factory, params.ref)
137123}
138124139125/** The delete exchange over an open session. Split out for the wire test. */
+7-53
server/utils/ssh-cmd.ts
···11-import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
22-import os from 'node:os'
33-import path from 'node:path'
41import { sql } from 'drizzle-orm'
52import { sshKey } from '../db/schema'
63import { useDb } from './db'
···85import { pkcs8ToOpenSshPrivate } from './ssh-keypair'
96107/**
1111- * Materialise the install's SSH private key as an OpenSSH-format file on disk
1212- * and return:
1313- * - `args`: the ssh option list (`-i <key> -o ...`) ready to splice into a
1414- * `spawn('ssh', [...args, target, command])` call
1515- * - a `cleanup()` callback that synchronously removes the temp dir
1616- *
1717- * The key file lives in `os.tmpdir()` with 0600 perms, has a random filename
1818- * (collision-resistant for concurrent worker invocations on the same instance),
1919- * and is removed in `cleanup()`. Callers must invoke `cleanup()` in a `finally`
2020- * — leaking the key on disk is the worst failure mode here.
88+ * Decrypt the install's SSH private key and return it as an in-memory
99+ * OpenSSH-format string, ready to hand to the `ssh2` client.
2110 *
2222- * Host key checking: tangled knots are addressed by hostname; v1 uses
2323- * `StrictHostKeyChecking=accept-new` (TOFU) with a per-call empty known_hosts,
2424- * which is effectively "trust the DNS for the configured knot". A future
2525- * commit can ship pinned host keys for the canonical knots once we know what
2626- * those are.
1111+ * The push transport runs in-process via `ssh2` (no `ssh` binary, which the
1212+ * Vercel runtime doesn't provide), so the key never touches disk: it's
1313+ * decrypted, used for one connection, and dropped when the function returns.
2714 */
2828-export async function loadSshArgsForInstall(installationId: number): Promise<{
2929- args: string[]
3030- cleanup: () => void
3131-}> {
1515+export async function loadSshKeyForInstall(installationId: number): Promise<string> {
3216 const db = useDb()
3317 const rows = await db.select({
3418 privateKeyCiphertext: sshKey.privateKeyCiphertext,
···4428 const row = rows[0]!
45294630 const pem = decrypt(row.privateKeyCiphertext, row.privateKeyNonce)
4747- const openSsh = pkcs8ToOpenSshPrivate(pem, `synchub.to/${installationId}`)
4848-4949- // Distinct dir per call so concurrent pushes within one process don't race.
5050- const dir = mkdtempSync(path.join(os.tmpdir(), 'synchub-ssh-'))
5151- const keyPath = path.join(dir, 'id_ed25519')
5252- const knownHostsPath = path.join(dir, 'known_hosts')
5353-5454- writeFileSync(keyPath, openSsh, { mode: 0o600 })
5555- chmodSync(keyPath, 0o600)
5656- writeFileSync(knownHostsPath, '', { mode: 0o600 })
5757-5858- const args = [
5959- '-i', keyPath,
6060- '-o', `UserKnownHostsFile=${knownHostsPath}`,
6161- '-o', 'StrictHostKeyChecking=accept-new',
6262- '-o', 'IdentitiesOnly=yes',
6363- '-o', 'BatchMode=yes',
6464- '-o', 'ConnectTimeout=15',
6565- ]
6666-6767- return {
6868- args,
6969- cleanup: () => {
7070- try {
7171- rmSync(dir, { recursive: true, force: true })
7272- }
7373- catch {
7474- // best-effort; the temp dir will be cleaned up on process restart.
7575- }
7676- },
7777- }
3131+ return pkcs8ToOpenSshPrivate(pem, `synchub.to/${installationId}`)
7832}
+130
test/unit/ssh2-receive-pack.spec.ts
···11+import { spawn } from 'node:child_process'
22+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
33+import { Server, utils as sshUtils } from 'ssh2'
44+import { ReceivePackSession } from '../../server/utils/git-wire/receive-pack'
55+import { ssh2ReceivePackFactory } from '../../server/utils/git-wire/receive-pack'
66+import { ZERO_SHA } from '../../server/utils/git-wire/refs'
77+import { generateKeypair, pkcs8ToOpenSshPrivate } from '../../server/utils/ssh-keypair'
88+import { fakeGithubFetch, GitFixture } from '../utils/git-wire'
99+import { fetchPack } from '../../server/utils/git-wire/upload-pack'
1010+1111+async function* fromBuffer(b: Buffer): AsyncGenerator<Buffer> {
1212+ yield b
1313+}
1414+1515+async function drain(gen: AsyncGenerator<Buffer>): Promise<Buffer> {
1616+ const parts: Buffer[] = []
1717+ for await (const c of gen) parts.push(c)
1818+ return Buffer.concat(parts)
1919+}
2020+2121+/**
2222+ * An in-process ssh2 server that authorises one public key and runs the real
2323+ * `git-receive-pack` against the given bare repo on exec. Mirrors the knot's
2424+ * `git@host: git-receive-pack '<path>'` surface so the ssh2 factory is exercised
2525+ * end to end.
2626+ */
2727+function startKnotServer(authorizedPubKey: string, repoFor: (path: string) => string | null) {
2828+ const hostKey = sshUtils.generateKeyPairSync('ed25519').private
2929+ const parsed = sshUtils.parseKey(authorizedPubKey)
3030+ if (parsed instanceof Error) throw parsed
3131+ const allowed = Array.isArray(parsed) ? parsed[0]! : parsed
3232+ const allowedSSH = allowed.getPublicSSH()
3333+3434+ const server = new Server({ hostKeys: [hostKey] }, client => {
3535+ client.on('authentication', ctx => {
3636+ if (ctx.method === 'publickey' && ctx.key.algo === allowed.type && ctx.key.data.equals(allowedSSH)) {
3737+ ctx.accept()
3838+ return
3939+ }
4040+ ctx.reject()
4141+ })
4242+ client.on('ready', () => {
4343+ client.on('session', accept => {
4444+ accept().once('exec', (acceptExec, _reject, info) => {
4545+ const match = info.command.match(/^git-receive-pack '(.+)'$/)
4646+ const repo = match ? repoFor(match[1]!) : null
4747+ const stream = acceptExec()
4848+ if (!repo) {
4949+ stream.stderr.write('repository not found\n')
5050+ stream.exit(128)
5151+ stream.end()
5252+ return
5353+ }
5454+ const child = spawn('git-receive-pack', [repo], { stdio: ['pipe', 'pipe', 'pipe'] })
5555+ stream.pipe(child.stdin)
5656+ child.stdout.pipe(stream)
5757+ child.stderr.on('data', (d: Buffer) => stream.stderr.write(d))
5858+ child.on('close', code => { stream.exit(code ?? 0); stream.end() })
5959+ })
6060+ })
6161+ })
6262+ })
6363+6464+ return new Promise<{ port: number, close: () => void }>(resolve => {
6565+ server.listen(0, '127.0.0.1', () => {
6666+ resolve({ port: (server.address() as { port: number }).port, close: () => server.close() })
6767+ })
6868+ })
6969+}
7070+7171+describe('ssh2ReceivePackFactory (against an in-process ssh2 knot)', () => {
7272+ let fx: GitFixture
7373+ let realFetch: typeof globalThis.fetch
7474+ let knotServer: { port: number, close: () => void } | null = null
7575+7676+ beforeEach(() => {
7777+ fx = new GitFixture()
7878+ realFetch = globalThis.fetch
7979+ })
8080+8181+ afterEach(() => {
8282+ globalThis.fetch = realFetch
8383+ knotServer?.close()
8484+ knotServer = null
8585+ fx.cleanup()
8686+ })
8787+8888+ async function packFor(ghBare: string, want: string, haves: string[]): Promise<Buffer> {
8989+ globalThis.fetch = fakeGithubFetch(new Map([['owner/repo', ghBare]])) as unknown as typeof globalThis.fetch
9090+ const { pack } = await fetchPack({ repoFullName: 'owner/repo', token: 't', want, haves, maxBytes: 1 << 30 })
9191+ return drain(pack)
9292+ }
9393+9494+ it('pushes a ref to the knot over a real ssh2 connection', async () => {
9595+ const gh = fx.initBare('gh.git')
9696+ const work = fx.initWork('work')
9797+ const sha = fx.commit(work, 'a.txt', 'hello')
9898+ fx.pushTo(work, gh, 'HEAD:refs/heads/main')
9999+ const knot = fx.initBare('knot.git')
100100+101101+ const key = generateKeypair('synchub.to/1')
102102+ knotServer = await startKnotServer(key.publicKeyOpenSsh, p => (p === '/repo-did' ? knot : null))
103103+104104+ const factory = ssh2ReceivePackFactory({
105105+ host: '127.0.0.1',
106106+ port: knotServer.port,
107107+ repoPath: '/repo-did',
108108+ // Mirror production: the worker hands ssh2 the OpenSSH-format key that
109109+ // `loadSshKeyForInstall` derives from the stored PKCS#8 PEM.
110110+ privateKey: pkcs8ToOpenSshPrivate(key.privateKeyPem, 'synchub.to/1'),
111111+ })
112112+113113+ const session = await ReceivePackSession.open(factory)
114114+ await session.push([{ ref: 'refs/heads/main', old: ZERO_SHA, next: sha }], fromBuffer(await packFor(gh, sha, [])))
115115+116116+ expect(fx.revParse(knot, 'refs/heads/main')).toBe(sha)
117117+ })
118118+119119+ it('surfaces a connection failure as a caught WireError, not an uncaught throw', async () => {
120120+ const key = generateKeypair('synchub.to/1')
121121+ // Nothing listening on this port: connect() emits 'error' (ECONNREFUSED).
122122+ const factory = ssh2ReceivePackFactory({
123123+ host: '127.0.0.1',
124124+ port: 1,
125125+ repoPath: '/repo-did',
126126+ privateKey: pkcs8ToOpenSshPrivate(key.privateKeyPem, 'synchub.to/1'),
127127+ })
128128+ await expect(ReceivePackSession.open(factory)).rejects.toThrow(/ssh error|ECONNREFUSED|advertisement|end of stream/)
129129+ })
130130+})