mirror your GitHub repos to tangled.org automatically
1import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
2import os from 'node:os'
3import path from 'node:path'
4import { sql } from 'drizzle-orm'
5import { sshKey } from '../db/schema'
6import { useDb } from './db'
7import { decrypt } from './encryption'
8import { pkcs8ToOpenSshPrivate } from './ssh-keypair'
9
10/**
11 * Materialise the install's SSH private key as an OpenSSH-format file on disk
12 * and return:
13 * - `args`: the ssh option list (`-i <key> -o ...`) ready to splice into a
14 * `spawn('ssh', [...args, target, command])` call
15 * - a `cleanup()` callback that synchronously removes the temp dir
16 *
17 * The key file lives in `os.tmpdir()` with 0600 perms, has a random filename
18 * (collision-resistant for concurrent worker invocations on the same instance),
19 * and is removed in `cleanup()`. Callers must invoke `cleanup()` in a `finally`
20 * — leaking the key on disk is the worst failure mode here.
21 *
22 * Host key checking: tangled knots are addressed by hostname; v1 uses
23 * `StrictHostKeyChecking=accept-new` (TOFU) with a per-call empty known_hosts,
24 * which is effectively "trust the DNS for the configured knot". A future
25 * commit can ship pinned host keys for the canonical knots once we know what
26 * those are.
27 */
28export async function loadSshArgsForInstall(installationId: number): Promise<{
29 args: string[]
30 cleanup: () => void
31}> {
32 const db = useDb()
33 const rows = await db.select({
34 privateKeyCiphertext: sshKey.privateKeyCiphertext,
35 privateKeyNonce: sshKey.privateKeyNonce,
36 })
37 .from(sshKey)
38 .where(sql`${sshKey.installationId} = ${installationId}`)
39 .limit(1)
40
41 if (rows.length === 0) {
42 throw new Error(`no ssh key for installation ${installationId}`)
43 }
44 const row = rows[0]!
45
46 const pem = decrypt(row.privateKeyCiphertext, row.privateKeyNonce)
47 const openSsh = pkcs8ToOpenSshPrivate(pem, `synchub.to/${installationId}`)
48
49 // Distinct dir per call so concurrent pushes within one process don't race.
50 const dir = mkdtempSync(path.join(os.tmpdir(), 'synchub-ssh-'))
51 const keyPath = path.join(dir, 'id_ed25519')
52 const knownHostsPath = path.join(dir, 'known_hosts')
53
54 writeFileSync(keyPath, openSsh, { mode: 0o600 })
55 chmodSync(keyPath, 0o600)
56 writeFileSync(knownHostsPath, '', { mode: 0o600 })
57
58 const args = [
59 '-i', keyPath,
60 '-o', `UserKnownHostsFile=${knownHostsPath}`,
61 '-o', 'StrictHostKeyChecking=accept-new',
62 '-o', 'IdentitiesOnly=yes',
63 '-o', 'BatchMode=yes',
64 '-o', 'ConnectTimeout=15',
65 ]
66
67 return {
68 args,
69 cleanup: () => {
70 try {
71 rmSync(dir, { recursive: true, force: true })
72 }
73 catch {
74 // best-effort; the temp dir will be cleaned up on process restart.
75 }
76 },
77 }
78}