diff --git a/package.json b/package.json index 257c712..924c94d 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "@octokit/auth-app": "^8.2.0", "@octokit/webhooks-methods": "^6.0.0", "drizzle-orm": "^0.45.2", + "execa": "^9.6.1", "nuxt": "^4.4.4", "nuxt-og-image": "^6.4.11", "rolldown": "^1.0.0-rc.18", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6da3eb8..48f40a7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,6 +54,9 @@ importers: drizzle-orm: specifier: ^0.45.2 version: 0.45.2(@electric-sql/pglite@0.4.5)(@neondatabase/serverless@1.1.0) + execa: + specifier: ^9.6.1 + version: 9.6.1 nuxt: specifier: ^4.4.4 version: 4.4.4(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@electric-sql/pglite@0.4.5)(@parcel/watcher@2.5.6)(@types/node@25.6.0)(@vue/compiler-sfc@3.5.33)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.5)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.5)(@neondatabase/serverless@1.1.0)))(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.5)(@neondatabase/serverless@1.1.0))(esbuild@0.28.0)(eslint@10.3.0(jiti@2.6.1))(ioredis@5.10.1)(magicast@0.5.2)(optionator@0.9.4)(oxlint@1.61.0(oxlint-tsgolint@0.22.0))(rolldown@1.0.0-rc.18)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.18)(rollup@4.60.2))(rollup@4.60.2)(srvx@0.11.15)(terser@5.46.2)(tsx@4.21.0)(typescript@6.0.3)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.4))(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.8.4) @@ -2706,6 +2709,9 @@ packages: cpu: [x64] os: [win32] + '@sec-ant/readable-stream@0.4.1': + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + '@sidvind/better-ajv-errors@3.0.1': resolution: {integrity: sha512-++1mEYIeozfnwWI9P1ECvOPoacy+CgDASrmGvXPMCcqgx0YUzB01vZ78uHdQ443V6sTY+e9MzHqmN9DOls02aw==} engines: {node: '>= 16.14'} @@ -3845,6 +3851,10 @@ packages: resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} engines: {node: '>=16.17'} + execa@9.6.1: + resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} + engines: {node: ^18.19.0 || >=20.5.0} + exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} @@ -3908,6 +3918,10 @@ packages: picomatch: optional: true + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -3997,6 +4011,10 @@ packages: resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} engines: {node: '>=16'} + get-stream@9.0.1: + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} + engines: {node: '>=18'} + get-tsconfig@4.14.0: resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} @@ -4108,6 +4126,10 @@ packages: resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} engines: {node: '>=16.17.0'} + human-signals@8.0.1: + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} + engines: {node: '>=18.18.0'} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -4204,6 +4226,10 @@ packages: resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==} engines: {node: '>=12'} + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + is-reference@1.2.1: resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} @@ -4215,6 +4241,14 @@ packages: resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + is-wsl@2.2.0: resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} engines: {node: '>=8'} @@ -4784,6 +4818,10 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -5048,6 +5086,10 @@ packages: resolution: {integrity: sha512-nODzvTiYVRGRqAOvE84Vk5JDPyyxsVk0/fbA/bq7RqlnhksGpset09XTxbpvLTIjoaF7K8Z8DG8yHtKGTPSYRw==} engines: {node: '>=20'} + pretty-ms@9.3.0: + resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} + engines: {node: '>=18'} + process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} @@ -5332,6 +5374,10 @@ packages: resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} engines: {node: '>=12'} + strip-final-newline@4.0.0: + resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} + engines: {node: '>=18'} + strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} @@ -5876,6 +5922,10 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yoctocolors@2.1.2: + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} + engines: {node: '>=18'} + youch-core@0.3.3: resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} @@ -8129,6 +8179,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.2': optional: true + '@sec-ant/readable-stream@0.4.1': {} + '@sidvind/better-ajv-errors@3.0.1(ajv@8.20.0)': dependencies: ajv: 8.20.0 @@ -9272,6 +9324,21 @@ snapshots: signal-exit: 4.1.0 strip-final-newline: 3.0.0 + execa@9.6.1: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + cross-spawn: 7.0.6 + figures: 6.1.0 + get-stream: 9.0.1 + human-signals: 8.0.1 + is-plain-obj: 4.1.0 + is-stream: 4.0.1 + npm-run-path: 6.0.0 + pretty-ms: 9.3.0 + signal-exit: 4.1.0 + strip-final-newline: 4.0.0 + yoctocolors: 2.1.2 + exsolve@1.0.8: {} fake-indexeddb@6.2.5: {} @@ -9326,6 +9393,10 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -9431,6 +9502,11 @@ snapshots: get-stream@8.0.1: {} + get-stream@9.0.1: + dependencies: + '@sec-ant/readable-stream': 0.4.1 + is-stream: 4.0.1 + get-tsconfig@4.14.0: dependencies: resolve-pkg-maps: 1.0.0 @@ -9556,6 +9632,8 @@ snapshots: human-signals@5.0.0: {} + human-signals@8.0.1: {} + ieee754@1.2.1: {} ignore@5.3.2: {} @@ -9672,6 +9750,8 @@ snapshots: is-path-inside@4.0.0: {} + is-plain-obj@4.1.0: {} + is-reference@1.2.1: dependencies: '@types/estree': 1.0.8 @@ -9680,6 +9760,10 @@ snapshots: is-stream@3.0.0: {} + is-stream@4.0.1: {} + + is-unicode-supported@2.1.0: {} + is-wsl@2.2.0: dependencies: is-docker: 2.2.1 @@ -10558,6 +10642,8 @@ snapshots: package-json-from-dist@1.0.1: {} + parse-ms@4.0.0: {} + parseurl@1.3.3: {} path-browserify@1.0.1: {} @@ -10790,6 +10876,10 @@ snapshots: pretty-bytes@7.1.0: {} + pretty-ms@9.3.0: + dependencies: + parse-ms: 4.0.0 + process-nextick-args@2.0.1: {} process@0.11.10: {} @@ -11129,6 +11219,8 @@ snapshots: strip-final-newline@3.0.0: {} + strip-final-newline@4.0.0: {} + strip-literal@3.1.0: dependencies: js-tokens: 9.0.1 @@ -11688,6 +11780,8 @@ snapshots: yocto-queue@0.1.0: {} + yoctocolors@2.1.2: {} + youch-core@0.3.3: dependencies: '@poppinss/exception': 1.2.3 diff --git a/server/utils/git.ts b/server/utils/git.ts new file mode 100644 index 0000000..eacd526 --- /dev/null +++ b/server/utils/git.ts @@ -0,0 +1,55 @@ +import { execa, type Options } from 'execa' + +/** + * Thin wrapper over `execa` for invoking the system `git` binary with + * predictable defaults. + * + * - Forces non-interactive mode so a misconfigured ssh setup never hangs + * waiting for a passphrase or `yes/no` prompt. + * - Captures stderr so callers can produce useful error messages. + * - Adds a default 60s timeout; callers can override via `options.timeout`. + */ +export async function git(args: string[], options: Options = {}): Promise<{ stdout: string, stderr: string }> { + const result = await execa('git', args, { + timeout: 60_000, + ...options, + env: { + // Belt and braces against interactive prompts. `GIT_TERMINAL_PROMPT=0` + // makes git fail rather than hang if it would otherwise ask for input + // (e.g. credentials). + GIT_TERMINAL_PROMPT: '0', + // Don't pick up the running user's ssh config / known_hosts. The caller + // supplies a complete GIT_SSH_COMMAND for ssh transports. + GIT_CONFIG_NOSYSTEM: '1', + ...options.env, + }, + // Buffer (default) is fine for small operations; for very large fetches + // we'd want to stream stderr instead. + reject: true, + all: true, + }) + return { stdout: String(result.stdout), stderr: String(result.stderr) } +} + +/** + * Recognised remote rejection patterns from the knot when a repo no longer + * exists or our key has been revoked. Surfaces as a typed error so the + * worker can mark the mapping as terminally failed rather than retry forever. + */ +export class RemoteRejectedPushError extends Error { + constructor(message: string, public readonly reason: 'repo-gone' | 'auth-rejected' | 'other') { + super(message) + this.name = 'RemoteRejectedPushError' + } +} + +export function classifyPushFailure(stderr: string): RemoteRejectedPushError | null { + const lc = stderr.toLowerCase() + if (lc.includes('repository not found') || lc.includes('does not exist') || lc.includes('does not appear to be a git repository')) { + return new RemoteRejectedPushError(stderr.trim(), 'repo-gone') + } + if (lc.includes('permission denied') || lc.includes('publickey') && lc.includes('denied')) { + return new RemoteRejectedPushError(stderr.trim(), 'auth-rejected') + } + return null +} diff --git a/server/utils/job-handlers.ts b/server/utils/job-handlers.ts index 25783b4..c8f5780 100644 --- a/server/utils/job-handlers.ts +++ b/server/utils/job-handlers.ts @@ -5,17 +5,18 @@ import { useDb } from './db' import { installationOctokit } from './github-app' import type { JobEnvelope } from './queue' import { enqueue } from './queue' +import { syncPush, type PushPayload } from './sync-push' import { generateAndPublishKey } from './tangled-pubkey' import { enrollRepo } from './tangled-repo' /** * Map of job kind → handler. Each commit fills in its slice: - * - 'github.push' → commit 12 (sync push events) + * - 'github.push' → this commit (sync push events) * - 'github.create' / 'github.delete' → commit 13 (branch/tag ref ops) * - 'github.repository' → commit 14/15 (description, lifecycle) - * - 'github.installation_repositories' → this commit (fan-out enrolment) - * - 'tangled.backfill-installation' → this commit (paginate + fan-out) - * - 'tangled.create-repo' → this commit (per-repo enrolment) + * - 'github.installation_repositories' → commit 10 (fan-out enrolment) + * - 'tangled.backfill-installation' → commit 10 (paginate + fan-out) + * - 'tangled.create-repo' → commit 10 (per-repo enrolment) * - 'atproto.publish-pubkey' → commit 9 * * Unknown kinds throw so they surface as job failures rather than silent @@ -110,6 +111,11 @@ export async function dispatch(envelope: JobEnvelope): Promise { throw new Error(`unknown job kind: ${envelope.kind}`) } + if (envelope.kind === 'github.push') { + await syncPush(envelope.payload as PushPayload) + return + } + if (envelope.kind === 'atproto.publish-pubkey') { const { did, installationId } = publishPubkeyPayload(envelope.payload) const client = await useOAuthClient() diff --git a/server/utils/ssh-cmd.ts b/server/utils/ssh-cmd.ts new file mode 100644 index 0000000..8378f87 --- /dev/null +++ b/server/utils/ssh-cmd.ts @@ -0,0 +1,87 @@ +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { sql } from 'drizzle-orm' +import { sshKey } from '../db/schema' +import { useDb } from './db' +import { decrypt } from './encryption' +import { pkcs8ToOpenSshPrivate } from './ssh-keypair' + +/** + * Materialise the install's SSH private key as an OpenSSH-format file on disk + * and return: + * - the `GIT_SSH_COMMAND` string to point `git` at it + * - a `cleanup()` callback that synchronously removes the temp dir + * + * The key file lives in `os.tmpdir()` with 0600 perms, has a random filename + * (collision-resistant for concurrent worker invocations on the same instance), + * and is removed in `cleanup()`. Callers must invoke `cleanup()` in a `finally` + * — leaking the key on disk is the worst failure mode here. + * + * Host key checking: tangled knots are addressed by hostname; v1 uses + * `StrictHostKeyChecking=accept-new` (TOFU) with a per-call empty known_hosts, + * which is effectively "trust the DNS for the configured knot". A future + * commit can ship pinned host keys for the canonical knots once we know what + * those are. + */ +export async function loadSshCommandForInstall(installationId: number): Promise<{ + gitSshCommand: string + cleanup: () => void +}> { + const db = useDb() + const rows = await db.select({ + privateKeyCiphertext: sshKey.privateKeyCiphertext, + privateKeyNonce: sshKey.privateKeyNonce, + }) + .from(sshKey) + .where(sql`${sshKey.installationId} = ${installationId}`) + .limit(1) + + if (rows.length === 0) { + throw new Error(`no ssh key for installation ${installationId}`) + } + const row = rows[0]! + + const pem = decrypt(row.privateKeyCiphertext, row.privateKeyNonce) + const openSsh = pkcs8ToOpenSshPrivate(pem, `synchub.to/${installationId}`) + + // Distinct dir per call so concurrent pushes within one process don't race. + const dir = mkdtempSync(path.join(os.tmpdir(), 'synchub-ssh-')) + const keyPath = path.join(dir, 'id_ed25519') + const knownHostsPath = path.join(dir, 'known_hosts') + + writeFileSync(keyPath, openSsh, { mode: 0o600 }) + chmodSync(keyPath, 0o600) + writeFileSync(knownHostsPath, '', { mode: 0o600 }) + + const gitSshCommand = [ + 'ssh', + '-i', shellQuote(keyPath), + '-o', `UserKnownHostsFile=${shellQuote(knownHostsPath)}`, + '-o', 'StrictHostKeyChecking=accept-new', + '-o', 'IdentitiesOnly=yes', + '-o', 'BatchMode=yes', + '-o', 'ConnectTimeout=15', + ].join(' ') + + return { + gitSshCommand, + cleanup: () => { + try { + rmSync(dir, { recursive: true, force: true }) + } + catch { + // best-effort; the temp dir will be cleaned up on process restart. + } + }, + } +} + +/** Minimal shell-quoting for paths inside GIT_SSH_COMMAND. */ +function shellQuote(s: string): string { + // GIT_SSH_COMMAND is split on whitespace by git, so escape spaces. We don't + // bother with full shell-quoting here because the paths we generate (in + // os.tmpdir()) won't contain quotes/backslashes; this is defense in depth. + if (!/[\s"'\\]/.test(s)) return s + return `"${s.replace(/(["\\])/g, '\\$1')}"` +} diff --git a/server/utils/ssh-keypair.ts b/server/utils/ssh-keypair.ts index 88b7cfa..974bd65 100644 --- a/server/utils/ssh-keypair.ts +++ b/server/utils/ssh-keypair.ts @@ -6,8 +6,8 @@ import crypto from 'node:crypto' * and the PKCS#8-PEM-encoded private key (suitable for storage). * * We store PKCS#8 because Node loads it natively via `crypto.createPrivateKey`. - * Conversion to OpenSSH private key format (what `git`/`ssh-agent` consumes) is - * deferred until commit 12, where it lives next to the SSH push code. + * The OpenSSH-private-key format used by `git`/`ssh` for authentication is + * produced on demand by `pkcs8ToOpenSshPrivate` below. */ export interface GeneratedKeypair { publicKeyOpenSsh: string @@ -55,3 +55,105 @@ function sshString(buf: Buffer): Buffer { len.writeUInt32BE(buf.length, 0) return Buffer.concat([len, buf]) } + +/** + * Convert an ed25519 PKCS#8 PEM private key (what we store) to the OpenSSH + * private key format (what `ssh`/`git` consume). Format spec: OpenSSH's + * PROTOCOL.key. No passphrase — the file we hand to ssh is plaintext and + * lives only for the duration of one push, in a 0600 temp file. + * + * Structure for an unencrypted ed25519 key: + * "openssh-key-v1\0" + * string ciphername = "none" + * string kdfname = "none" + * string kdfoptions = "" + * uint32 nkeys = 1 + * string public-key-blob (ssh-ed25519 wire format: algo + raw32) + * string private-section (padded to a multiple of 8): + * uint32 checkint + * uint32 checkint (same value, sanity check for decryption) + * string "ssh-ed25519" + * string public (raw 32) + * string private (64 bytes: seed(32) || public(32)) + * string comment + * padding bytes 1,2,3,...,n + * + * The whole binary blob is then base64-wrapped in + * `-----BEGIN OPENSSH PRIVATE KEY-----` / `-----END OPENSSH PRIVATE KEY-----` + * with 70-char line breaks. + */ +export function pkcs8ToOpenSshPrivate(privateKeyPem: string, comment: string): string { + const keyObj = crypto.createPrivateKey(privateKeyPem) + if (keyObj.asymmetricKeyType !== 'ed25519') { + throw new Error(`expected ed25519 private key, got ${String(keyObj.asymmetricKeyType)}`) + } + + // PKCS#8 DER for ed25519 is a fixed 48-byte ASN.1 structure with the + // 32-byte seed as the trailing bytes. (RFC 8410 §7.) + const pkcs8Der = keyObj.export({ type: 'pkcs8', format: 'der' }) + const seed = pkcs8Der.subarray(-32) + + // Derive the matching public key by re-extracting from the same key object. + const publicKeyDer = crypto.createPublicKey(keyObj).export({ type: 'spki', format: 'der' }) + const rawPublic = publicKeyDer.subarray(-32) + + const algo = Buffer.from('ssh-ed25519', 'utf8') + const publicKeyBlob = Buffer.concat([sshString(algo), sshString(rawPublic)]) + + // checkint: a random uint32 written twice. ssh verifies the two are equal + // after decryption — cheap integrity check. For an unencrypted key it's + // still required but doesn't really verify anything; use random bytes. + const checkint = crypto.randomBytes(4) + + // OpenSSH's private key format stores the seed concatenated with the public + // key as one 64-byte "private" string. Looks redundant but is what ssh + // parses. + const privateMaterial = Buffer.concat([seed, rawPublic]) + + let privateSection = Buffer.concat([ + checkint, + checkint, + sshString(algo), + sshString(rawPublic), + sshString(privateMaterial), + sshString(Buffer.from(comment, 'utf8')), + ]) + + // Pad to a multiple of 8 (the "none" cipher's block size). Padding bytes + // are 1, 2, 3, … not zeros. + const padLen = (8 - (privateSection.length % 8)) % 8 + if (padLen > 0) { + const pad = Buffer.alloc(padLen) + for (let i = 0; i < padLen; i++) pad[i] = i + 1 + privateSection = Buffer.concat([privateSection, pad]) + } + + const blob = Buffer.concat([ + Buffer.from('openssh-key-v1\0', 'utf8'), + sshString(Buffer.from('none', 'utf8')), + sshString(Buffer.from('none', 'utf8')), + sshString(Buffer.alloc(0)), + uint32BE(1), + sshString(publicKeyBlob), + sshString(privateSection), + ]) + + const base64 = blob.toString('base64') + const lines: string[] = [] + for (let i = 0; i < base64.length; i += 70) { + lines.push(base64.slice(i, i + 70)) + } + + return [ + '-----BEGIN OPENSSH PRIVATE KEY-----', + ...lines, + '-----END OPENSSH PRIVATE KEY-----', + '', + ].join('\n') +} + +function uint32BE(n: number): Buffer { + const buf = Buffer.alloc(4) + buf.writeUInt32BE(n, 0) + return buf +} diff --git a/server/utils/sync-push.ts b/server/utils/sync-push.ts new file mode 100644 index 0000000..0f0162a --- /dev/null +++ b/server/utils/sync-push.ts @@ -0,0 +1,155 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { and, eq, sql } from 'drizzle-orm' +import { repoMapping } from '../db/schema' +import { useDb } from './db' +import { classifyPushFailure, git, RemoteRejectedPushError } from './git' +import { installationOctokit } from './github-app' +import { loadSshCommandForInstall } from './ssh-cmd' + +const ZERO_SHA = '0000000000000000000000000000000000000000' + +export interface PushPayload { + installationId: number + githubRepoId: number + ref: string + before: string + after: string +} + +export interface PushResult { + status: 'synced' | 'skipped' + reason?: 'no-mapping' | 'disabled' | 'already-synced' | 'deletion' | 'repo-gone' +} + +/** + * Mirror a single push from GitHub to the configured knot. + * + * 1. Look up the repo_mapping (installationId, githubRepoId). Skip if absent + * or disabled. + * 2. Ref-tip dedupe: if lastSyncedRefs[ref] === after, no-op. Guards against + * GitHub redeliveries and v1.1's tangled-primary loop (PLAN.md). + * 3. Skip ref deletions (after = 0000…). Handled by github.delete in commit 13. + * 4. Bare-init /tmp scratch; fetch `after` from GitHub via smart-HTTP using + * the install token; push that ref to the knot over SSH with the + * install's key, force-with-lease against our last known tip. + * 5. Update lastSyncedRefs[ref] = after. + * + * On terminal failures (repo gone from knot, auth rejected) we mark the + * mapping as `status='error'` so the worker stops retrying. Transient + * failures (network blips, missing objects) re-throw and the queue retries + * with backoff. + */ +export async function syncPush(payload: PushPayload): Promise { + const db = useDb() + + const mapping = await db.select().from(repoMapping).where( + and( + eq(repoMapping.installationId, payload.installationId), + eq(repoMapping.githubRepoId, payload.githubRepoId), + ), + ).limit(1) + if (mapping.length === 0) return { status: 'skipped', reason: 'no-mapping' } + const row = mapping[0]! + + if (row.disabledAt) return { status: 'skipped', reason: 'disabled' } + if (!row.tangledRepoDid || !row.knot) return { status: 'skipped', reason: 'no-mapping' } + + if (payload.after === ZERO_SHA) return { status: 'skipped', reason: 'deletion' } + + const lastSynced = (row.lastSyncedRefs as Record)[payload.ref] + if (lastSynced === payload.after) return { status: 'skipped', reason: 'already-synced' } + + const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'synchub-push-')) + let sshCleanup: (() => void) | undefined + + try { + // 1. Bare init. No working tree, no objects until we fetch. + await git(['init', '--bare', '-q'], { cwd: tmpDir }) + + // 2. Install-token-authed clone URL. The `x-access-token` username is + // GitHub's convention for installation tokens. + const octokit = await installationOctokit(payload.installationId) + const { token } = (await octokit.auth({ type: 'installation' })) as { token: string } + const githubUrl = `https://x-access-token:${token}@github.com/${row.githubFullName}.git` + + // 3. Fetch exactly the new ref. The `:` refspec asks git to + // fetch the object reachable from `after` and store it under our + // local refs/heads/... or refs/tags/... at the same name. + await git( + ['fetch', '--no-tags', '-q', githubUrl, `+${payload.after}:${payload.ref}`], + { cwd: tmpDir, timeout: 120_000 }, + ) + + // 4. Push to the knot. `force-with-lease` means "only update the ref if + // its current tip on the knot still matches what we last saw". Without + // a lease value we fall back to plain `--force` because we have no + // way to know the knot's current tip otherwise (we don't `ls-remote`). + // The lease is `` when we have one; on first + // sync we use plain force. + const { gitSshCommand, cleanup } = await loadSshCommandForInstall(payload.installationId) + sshCleanup = cleanup + + const knotUrl = `ssh://git@${row.knot}/${row.tangledRepoDid}` + const pushRefspec = lastSynced + ? `--force-with-lease=${payload.ref}:${lastSynced} ${payload.after}:${payload.ref}` + : `+${payload.after}:${payload.ref}` + + try { + await git( + ['push', '-q', knotUrl, ...pushRefspec.split(' ')], + { + cwd: tmpDir, + env: { GIT_SSH_COMMAND: gitSshCommand }, + timeout: 120_000, + }, + ) + } + catch (err) { + const stderr = err instanceof Error && 'stderr' in err ? String((err as { stderr: unknown }).stderr) : '' + const classified = classifyPushFailure(stderr) + if (classified?.reason === 'repo-gone') { + await markMappingError(row.id, 'knot reports repo no longer exists; stopping sync') + return { status: 'skipped', reason: 'repo-gone' } + } + throw classified ?? err + } + + // 5. Update last-synced tip for this ref. Use jsonb_set to leave other + // refs untouched. + await db.update(repoMapping) + .set({ + lastSyncedRefs: sql`jsonb_set(${repoMapping.lastSyncedRefs}, ${`{${jsonbPath(payload.ref)}}`}::text[], ${`"${payload.after}"`}::jsonb, true)`, + updatedAt: new Date(), + }) + .where(eq(repoMapping.id, row.id)) + + return { status: 'synced' } + } + finally { + sshCleanup?.() + try { + rmSync(tmpDir, { recursive: true, force: true }) + } + catch { + // best-effort + } + } +} + +/** jsonb_set path argument: `refs/heads/main` becomes a single text array element. */ +function jsonbPath(ref: string): string { + // Escape any double-quotes inside the ref. We only support standard git ref + // names which never contain quotes, but be defensive. + return `"${ref.replaceAll('"', '\\"')}"` +} + +async function markMappingError(mappingId: number, message: string): Promise { + const db = useDb() + await db.update(repoMapping) + .set({ status: 'error', lastError: message, updatedAt: new Date() }) + .where(eq(repoMapping.id, mappingId)) +} + +export { RemoteRejectedPushError } diff --git a/test/unit/ssh-keypair.spec.ts b/test/unit/ssh-keypair.spec.ts index 1d5879a..342ce50 100644 --- a/test/unit/ssh-keypair.spec.ts +++ b/test/unit/ssh-keypair.spec.ts @@ -1,6 +1,10 @@ +import { execFileSync } from 'node:child_process' import crypto from 'node:crypto' +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' import { describe, expect, it } from 'vitest' -import { generateKeypair } from '../../server/utils/ssh-keypair' +import { generateKeypair, pkcs8ToOpenSshPrivate } from '../../server/utils/ssh-keypair' describe('ssh-keypair', () => { it('produces an OpenSSH-formatted ed25519 public key', () => { @@ -46,4 +50,50 @@ describe('ssh-keypair', () => { const { publicKeyOpenSsh } = generateKeypair('comment with spaces ok') expect(publicKeyOpenSsh).toContain('comment with spaces ok') }) + + describe('pkcs8ToOpenSshPrivate', () => { + it('produces a PEM-wrapped OpenSSH private key block', () => { + const { privateKeyPem } = generateKeypair('test') + const openssh = pkcs8ToOpenSshPrivate(privateKeyPem, 'test-key') + expect(openssh).toMatch(/^-----BEGIN OPENSSH PRIVATE KEY-----\n/) + expect(openssh).toMatch(/-----END OPENSSH PRIVATE KEY-----\n$/) + // Lines between markers should be base64 and <=70 chars. + const innerLines = openssh.split('\n').slice(1, -2) + for (const line of innerLines) { + expect(line.length).toBeLessThanOrEqual(70) + expect(line).toMatch(/^[A-Za-z0-9+/=]+$/) + } + }) + + it('rejects non-ed25519 keys', () => { + const { privateKey } = crypto.generateKeyPairSync('rsa', { + modulusLength: 2048, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + }) + expect(() => pkcs8ToOpenSshPrivate(privateKey, 'x')) + .toThrow(/expected ed25519/) + }) + + it('is parseable by the real ssh-keygen, with derived public matching ours', () => { + const { publicKeyOpenSsh, privateKeyPem } = generateKeypair('synchub-test') + const openssh = pkcs8ToOpenSshPrivate(privateKeyPem, 'synchub-test') + + const dir = mkdtempSync(path.join(os.tmpdir(), 'synchub-ssh-test-')) + try { + const keyPath = path.join(dir, 'id_ed25519') + writeFileSync(keyPath, openssh, { mode: 0o600 }) + chmodSync(keyPath, 0o600) + + const derived = execFileSync('ssh-keygen', ['-y', '-f', keyPath], { encoding: 'utf8' }).trim() + // ssh-keygen -y emits `ssh-ed25519 ` (no comment). Compare + // ignoring the comment we put on `publicKeyOpenSsh`. + const ourBase64 = publicKeyOpenSsh.split(' ')[1] + const derivedBase64 = derived.split(' ')[1] + expect(derivedBase64).toBe(ourBase64) + } + finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + }) })