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 * - the `GIT_SSH_COMMAND` string to point `git` at it
14 * - a `cleanup()` callback that synchronously removes the temp dir
15 *
16 * The key file lives in `os.tmpdir()` with 0600 perms, has a random filename
17 * (collision-resistant for concurrent worker invocations on the same instance),
18 * and is removed in `cleanup()`. Callers must invoke `cleanup()` in a `finally`
19 * — leaking the key on disk is the worst failure mode here.
20 *
21 * Host key checking: tangled knots are addressed by hostname; v1 uses
22 * `StrictHostKeyChecking=accept-new` (TOFU) with a per-call empty known_hosts,
23 * which is effectively "trust the DNS for the configured knot". A future
24 * commit can ship pinned host keys for the canonical knots once we know what
25 * those are.
26 */
27export async function loadSshCommandForInstall(installationId: number): Promise<{
28 gitSshCommand: string
29 cleanup: () => void
30}> {
31 const db = useDb()
32 const rows = await db.select({
33 privateKeyCiphertext: sshKey.privateKeyCiphertext,
34 privateKeyNonce: sshKey.privateKeyNonce,
35 })
36 .from(sshKey)
37 .where(sql`${sshKey.installationId} = ${installationId}`)
38 .limit(1)
39
40 if (rows.length === 0) {
41 throw new Error(`no ssh key for installation ${installationId}`)
42 }
43 const row = rows[0]!
44
45 const pem = decrypt(row.privateKeyCiphertext, row.privateKeyNonce)
46 const openSsh = pkcs8ToOpenSshPrivate(pem, `synchub.to/${installationId}`)
47
48 // Distinct dir per call so concurrent pushes within one process don't race.
49 const dir = mkdtempSync(path.join(os.tmpdir(), 'synchub-ssh-'))
50 const keyPath = path.join(dir, 'id_ed25519')
51 const knownHostsPath = path.join(dir, 'known_hosts')
52
53 writeFileSync(keyPath, openSsh, { mode: 0o600 })
54 chmodSync(keyPath, 0o600)
55 writeFileSync(knownHostsPath, '', { mode: 0o600 })
56
57 const gitSshCommand = [
58 'ssh',
59 '-i', shellQuote(keyPath),
60 '-o', `UserKnownHostsFile=${shellQuote(knownHostsPath)}`,
61 '-o', 'StrictHostKeyChecking=accept-new',
62 '-o', 'IdentitiesOnly=yes',
63 '-o', 'BatchMode=yes',
64 '-o', 'ConnectTimeout=15',
65 ].join(' ')
66
67 return {
68 gitSshCommand,
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}
79
80/** Minimal shell-quoting for paths inside GIT_SSH_COMMAND. */
81function shellQuote(s: string): string {
82 // GIT_SSH_COMMAND is split on whitespace by git, so escape spaces. We don't
83 // bother with full shell-quoting here because the paths we generate (in
84 // os.tmpdir()) won't contain quotes/backslashes; this is defense in depth.
85 if (!/[\s"'\\]/.test(s)) return s
86 return `"${s.replace(/(["\\])/g, '\\$1')}"`
87}