···11+import { execa, type Options } from 'execa'
22+33+/**
44+ * Thin wrapper over `execa` for invoking the system `git` binary with
55+ * predictable defaults.
66+ *
77+ * - Forces non-interactive mode so a misconfigured ssh setup never hangs
88+ * waiting for a passphrase or `yes/no` prompt.
99+ * - Captures stderr so callers can produce useful error messages.
1010+ * - Adds a default 60s timeout; callers can override via `options.timeout`.
1111+ */
1212+export async function git(args: string[], options: Options = {}): Promise<{ stdout: string, stderr: string }> {
1313+ const result = await execa('git', args, {
1414+ timeout: 60_000,
1515+ ...options,
1616+ env: {
1717+ // Belt and braces against interactive prompts. `GIT_TERMINAL_PROMPT=0`
1818+ // makes git fail rather than hang if it would otherwise ask for input
1919+ // (e.g. credentials).
2020+ GIT_TERMINAL_PROMPT: '0',
2121+ // Don't pick up the running user's ssh config / known_hosts. The caller
2222+ // supplies a complete GIT_SSH_COMMAND for ssh transports.
2323+ GIT_CONFIG_NOSYSTEM: '1',
2424+ ...options.env,
2525+ },
2626+ // Buffer (default) is fine for small operations; for very large fetches
2727+ // we'd want to stream stderr instead.
2828+ reject: true,
2929+ all: true,
3030+ })
3131+ return { stdout: String(result.stdout), stderr: String(result.stderr) }
3232+}
3333+3434+/**
3535+ * Recognised remote rejection patterns from the knot when a repo no longer
3636+ * exists or our key has been revoked. Surfaces as a typed error so the
3737+ * worker can mark the mapping as terminally failed rather than retry forever.
3838+ */
3939+export class RemoteRejectedPushError extends Error {
4040+ constructor(message: string, public readonly reason: 'repo-gone' | 'auth-rejected' | 'other') {
4141+ super(message)
4242+ this.name = 'RemoteRejectedPushError'
4343+ }
4444+}
4545+4646+export function classifyPushFailure(stderr: string): RemoteRejectedPushError | null {
4747+ const lc = stderr.toLowerCase()
4848+ if (lc.includes('repository not found') || lc.includes('does not exist') || lc.includes('does not appear to be a git repository')) {
4949+ return new RemoteRejectedPushError(stderr.trim(), 'repo-gone')
5050+ }
5151+ if (lc.includes('permission denied') || lc.includes('publickey') && lc.includes('denied')) {
5252+ return new RemoteRejectedPushError(stderr.trim(), 'auth-rejected')
5353+ }
5454+ return null
5555+}
+10-4
server/utils/job-handlers.ts
···55import { installationOctokit } from './github-app'
66import type { JobEnvelope } from './queue'
77import { enqueue } from './queue'
88+import { syncPush, type PushPayload } from './sync-push'
89import { generateAndPublishKey } from './tangled-pubkey'
910import { enrollRepo } from './tangled-repo'
10111112/**
1213 * Map of job kind → handler. Each commit fills in its slice:
1313- * - 'github.push' → commit 12 (sync push events)
1414+ * - 'github.push' → this commit (sync push events)
1415 * - 'github.create' / 'github.delete' → commit 13 (branch/tag ref ops)
1516 * - 'github.repository' → commit 14/15 (description, lifecycle)
1616- * - 'github.installation_repositories' → this commit (fan-out enrolment)
1717- * - 'tangled.backfill-installation' → this commit (paginate + fan-out)
1818- * - 'tangled.create-repo' → this commit (per-repo enrolment)
1717+ * - 'github.installation_repositories' → commit 10 (fan-out enrolment)
1818+ * - 'tangled.backfill-installation' → commit 10 (paginate + fan-out)
1919+ * - 'tangled.create-repo' → commit 10 (per-repo enrolment)
1920 * - 'atproto.publish-pubkey' → commit 9
2021 *
2122 * Unknown kinds throw so they surface as job failures rather than silent
···108109export async function dispatch(envelope: JobEnvelope): Promise<void> {
109110 if (!KNOWN_KINDS.has(envelope.kind)) {
110111 throw new Error(`unknown job kind: ${envelope.kind}`)
112112+ }
113113+114114+ if (envelope.kind === 'github.push') {
115115+ await syncPush(envelope.payload as PushPayload)
116116+ return
111117 }
112118113119 if (envelope.kind === 'atproto.publish-pubkey') {
+87
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'
44+import { sql } from 'drizzle-orm'
55+import { sshKey } from '../db/schema'
66+import { useDb } from './db'
77+import { decrypt } from './encryption'
88+import { pkcs8ToOpenSshPrivate } from './ssh-keypair'
99+1010+/**
1111+ * Materialise the install's SSH private key as an OpenSSH-format file on disk
1212+ * and return:
1313+ * - the `GIT_SSH_COMMAND` string to point `git` at it
1414+ * - a `cleanup()` callback that synchronously removes the temp dir
1515+ *
1616+ * The key file lives in `os.tmpdir()` with 0600 perms, has a random filename
1717+ * (collision-resistant for concurrent worker invocations on the same instance),
1818+ * and is removed in `cleanup()`. Callers must invoke `cleanup()` in a `finally`
1919+ * — leaking the key on disk is the worst failure mode here.
2020+ *
2121+ * Host key checking: tangled knots are addressed by hostname; v1 uses
2222+ * `StrictHostKeyChecking=accept-new` (TOFU) with a per-call empty known_hosts,
2323+ * which is effectively "trust the DNS for the configured knot". A future
2424+ * commit can ship pinned host keys for the canonical knots once we know what
2525+ * those are.
2626+ */
2727+export async function loadSshCommandForInstall(installationId: number): Promise<{
2828+ gitSshCommand: string
2929+ cleanup: () => void
3030+}> {
3131+ const db = useDb()
3232+ const rows = await db.select({
3333+ privateKeyCiphertext: sshKey.privateKeyCiphertext,
3434+ privateKeyNonce: sshKey.privateKeyNonce,
3535+ })
3636+ .from(sshKey)
3737+ .where(sql`${sshKey.installationId} = ${installationId}`)
3838+ .limit(1)
3939+4040+ if (rows.length === 0) {
4141+ throw new Error(`no ssh key for installation ${installationId}`)
4242+ }
4343+ const row = rows[0]!
4444+4545+ const pem = decrypt(row.privateKeyCiphertext, row.privateKeyNonce)
4646+ const openSsh = pkcs8ToOpenSshPrivate(pem, `synchub.to/${installationId}`)
4747+4848+ // Distinct dir per call so concurrent pushes within one process don't race.
4949+ const dir = mkdtempSync(path.join(os.tmpdir(), 'synchub-ssh-'))
5050+ const keyPath = path.join(dir, 'id_ed25519')
5151+ const knownHostsPath = path.join(dir, 'known_hosts')
5252+5353+ writeFileSync(keyPath, openSsh, { mode: 0o600 })
5454+ chmodSync(keyPath, 0o600)
5555+ writeFileSync(knownHostsPath, '', { mode: 0o600 })
5656+5757+ const gitSshCommand = [
5858+ 'ssh',
5959+ '-i', shellQuote(keyPath),
6060+ '-o', `UserKnownHostsFile=${shellQuote(knownHostsPath)}`,
6161+ '-o', 'StrictHostKeyChecking=accept-new',
6262+ '-o', 'IdentitiesOnly=yes',
6363+ '-o', 'BatchMode=yes',
6464+ '-o', 'ConnectTimeout=15',
6565+ ].join(' ')
6666+6767+ return {
6868+ gitSshCommand,
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+ }
7878+}
7979+8080+/** Minimal shell-quoting for paths inside GIT_SSH_COMMAND. */
8181+function shellQuote(s: string): string {
8282+ // GIT_SSH_COMMAND is split on whitespace by git, so escape spaces. We don't
8383+ // bother with full shell-quoting here because the paths we generate (in
8484+ // os.tmpdir()) won't contain quotes/backslashes; this is defense in depth.
8585+ if (!/[\s"'\\]/.test(s)) return s
8686+ return `"${s.replace(/(["\\])/g, '\\$1')}"`
8787+}
+104-2
server/utils/ssh-keypair.ts
···66 * and the PKCS#8-PEM-encoded private key (suitable for storage).
77 *
88 * We store PKCS#8 because Node loads it natively via `crypto.createPrivateKey`.
99- * Conversion to OpenSSH private key format (what `git`/`ssh-agent` consumes) is
1010- * deferred until commit 12, where it lives next to the SSH push code.
99+ * The OpenSSH-private-key format used by `git`/`ssh` for authentication is
1010+ * produced on demand by `pkcs8ToOpenSshPrivate` below.
1111 */
1212export interface GeneratedKeypair {
1313 publicKeyOpenSsh: string
···5555 len.writeUInt32BE(buf.length, 0)
5656 return Buffer.concat([len, buf])
5757}
5858+5959+/**
6060+ * Convert an ed25519 PKCS#8 PEM private key (what we store) to the OpenSSH
6161+ * private key format (what `ssh`/`git` consume). Format spec: OpenSSH's
6262+ * PROTOCOL.key. No passphrase — the file we hand to ssh is plaintext and
6363+ * lives only for the duration of one push, in a 0600 temp file.
6464+ *
6565+ * Structure for an unencrypted ed25519 key:
6666+ * "openssh-key-v1\0"
6767+ * string ciphername = "none"
6868+ * string kdfname = "none"
6969+ * string kdfoptions = ""
7070+ * uint32 nkeys = 1
7171+ * string public-key-blob (ssh-ed25519 wire format: algo + raw32)
7272+ * string private-section (padded to a multiple of 8):
7373+ * uint32 checkint
7474+ * uint32 checkint (same value, sanity check for decryption)
7575+ * string "ssh-ed25519"
7676+ * string public (raw 32)
7777+ * string private (64 bytes: seed(32) || public(32))
7878+ * string comment
7979+ * padding bytes 1,2,3,...,n
8080+ *
8181+ * The whole binary blob is then base64-wrapped in
8282+ * `-----BEGIN OPENSSH PRIVATE KEY-----` / `-----END OPENSSH PRIVATE KEY-----`
8383+ * with 70-char line breaks.
8484+ */
8585+export function pkcs8ToOpenSshPrivate(privateKeyPem: string, comment: string): string {
8686+ const keyObj = crypto.createPrivateKey(privateKeyPem)
8787+ if (keyObj.asymmetricKeyType !== 'ed25519') {
8888+ throw new Error(`expected ed25519 private key, got ${String(keyObj.asymmetricKeyType)}`)
8989+ }
9090+9191+ // PKCS#8 DER for ed25519 is a fixed 48-byte ASN.1 structure with the
9292+ // 32-byte seed as the trailing bytes. (RFC 8410 §7.)
9393+ const pkcs8Der = keyObj.export({ type: 'pkcs8', format: 'der' })
9494+ const seed = pkcs8Der.subarray(-32)
9595+9696+ // Derive the matching public key by re-extracting from the same key object.
9797+ const publicKeyDer = crypto.createPublicKey(keyObj).export({ type: 'spki', format: 'der' })
9898+ const rawPublic = publicKeyDer.subarray(-32)
9999+100100+ const algo = Buffer.from('ssh-ed25519', 'utf8')
101101+ const publicKeyBlob = Buffer.concat([sshString(algo), sshString(rawPublic)])
102102+103103+ // checkint: a random uint32 written twice. ssh verifies the two are equal
104104+ // after decryption — cheap integrity check. For an unencrypted key it's
105105+ // still required but doesn't really verify anything; use random bytes.
106106+ const checkint = crypto.randomBytes(4)
107107+108108+ // OpenSSH's private key format stores the seed concatenated with the public
109109+ // key as one 64-byte "private" string. Looks redundant but is what ssh
110110+ // parses.
111111+ const privateMaterial = Buffer.concat([seed, rawPublic])
112112+113113+ let privateSection = Buffer.concat([
114114+ checkint,
115115+ checkint,
116116+ sshString(algo),
117117+ sshString(rawPublic),
118118+ sshString(privateMaterial),
119119+ sshString(Buffer.from(comment, 'utf8')),
120120+ ])
121121+122122+ // Pad to a multiple of 8 (the "none" cipher's block size). Padding bytes
123123+ // are 1, 2, 3, … not zeros.
124124+ const padLen = (8 - (privateSection.length % 8)) % 8
125125+ if (padLen > 0) {
126126+ const pad = Buffer.alloc(padLen)
127127+ for (let i = 0; i < padLen; i++) pad[i] = i + 1
128128+ privateSection = Buffer.concat([privateSection, pad])
129129+ }
130130+131131+ const blob = Buffer.concat([
132132+ Buffer.from('openssh-key-v1\0', 'utf8'),
133133+ sshString(Buffer.from('none', 'utf8')),
134134+ sshString(Buffer.from('none', 'utf8')),
135135+ sshString(Buffer.alloc(0)),
136136+ uint32BE(1),
137137+ sshString(publicKeyBlob),
138138+ sshString(privateSection),
139139+ ])
140140+141141+ const base64 = blob.toString('base64')
142142+ const lines: string[] = []
143143+ for (let i = 0; i < base64.length; i += 70) {
144144+ lines.push(base64.slice(i, i + 70))
145145+ }
146146+147147+ return [
148148+ '-----BEGIN OPENSSH PRIVATE KEY-----',
149149+ ...lines,
150150+ '-----END OPENSSH PRIVATE KEY-----',
151151+ '',
152152+ ].join('\n')
153153+}
154154+155155+function uint32BE(n: number): Buffer {
156156+ const buf = Buffer.alloc(4)
157157+ buf.writeUInt32BE(n, 0)
158158+ return buf
159159+}
+155
server/utils/sync-push.ts
···11+import { mkdtempSync, rmSync } from 'node:fs'
22+import os from 'node:os'
33+import path from 'node:path'
44+import { and, eq, sql } from 'drizzle-orm'
55+import { repoMapping } from '../db/schema'
66+import { useDb } from './db'
77+import { classifyPushFailure, git, RemoteRejectedPushError } from './git'
88+import { installationOctokit } from './github-app'
99+import { loadSshCommandForInstall } from './ssh-cmd'
1010+1111+const ZERO_SHA = '0000000000000000000000000000000000000000'
1212+1313+export interface PushPayload {
1414+ installationId: number
1515+ githubRepoId: number
1616+ ref: string
1717+ before: string
1818+ after: string
1919+}
2020+2121+export interface PushResult {
2222+ status: 'synced' | 'skipped'
2323+ reason?: 'no-mapping' | 'disabled' | 'already-synced' | 'deletion' | 'repo-gone'
2424+}
2525+2626+/**
2727+ * Mirror a single push from GitHub to the configured knot.
2828+ *
2929+ * 1. Look up the repo_mapping (installationId, githubRepoId). Skip if absent
3030+ * or disabled.
3131+ * 2. Ref-tip dedupe: if lastSyncedRefs[ref] === after, no-op. Guards against
3232+ * GitHub redeliveries and v1.1's tangled-primary loop (PLAN.md).
3333+ * 3. Skip ref deletions (after = 0000…). Handled by github.delete in commit 13.
3434+ * 4. Bare-init /tmp scratch; fetch `after` from GitHub via smart-HTTP using
3535+ * the install token; push that ref to the knot over SSH with the
3636+ * install's key, force-with-lease against our last known tip.
3737+ * 5. Update lastSyncedRefs[ref] = after.
3838+ *
3939+ * On terminal failures (repo gone from knot, auth rejected) we mark the
4040+ * mapping as `status='error'` so the worker stops retrying. Transient
4141+ * failures (network blips, missing objects) re-throw and the queue retries
4242+ * with backoff.
4343+ */
4444+export async function syncPush(payload: PushPayload): Promise<PushResult> {
4545+ const db = useDb()
4646+4747+ const mapping = await db.select().from(repoMapping).where(
4848+ and(
4949+ eq(repoMapping.installationId, payload.installationId),
5050+ eq(repoMapping.githubRepoId, payload.githubRepoId),
5151+ ),
5252+ ).limit(1)
5353+ if (mapping.length === 0) return { status: 'skipped', reason: 'no-mapping' }
5454+ const row = mapping[0]!
5555+5656+ if (row.disabledAt) return { status: 'skipped', reason: 'disabled' }
5757+ if (!row.tangledRepoDid || !row.knot) return { status: 'skipped', reason: 'no-mapping' }
5858+5959+ if (payload.after === ZERO_SHA) return { status: 'skipped', reason: 'deletion' }
6060+6161+ const lastSynced = (row.lastSyncedRefs as Record<string, string>)[payload.ref]
6262+ if (lastSynced === payload.after) return { status: 'skipped', reason: 'already-synced' }
6363+6464+ const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'synchub-push-'))
6565+ let sshCleanup: (() => void) | undefined
6666+6767+ try {
6868+ // 1. Bare init. No working tree, no objects until we fetch.
6969+ await git(['init', '--bare', '-q'], { cwd: tmpDir })
7070+7171+ // 2. Install-token-authed clone URL. The `x-access-token` username is
7272+ // GitHub's convention for installation tokens.
7373+ const octokit = await installationOctokit(payload.installationId)
7474+ const { token } = (await octokit.auth({ type: 'installation' })) as { token: string }
7575+ const githubUrl = `https://x-access-token:${token}@github.com/${row.githubFullName}.git`
7676+7777+ // 3. Fetch exactly the new ref. The `<sha>:<ref>` refspec asks git to
7878+ // fetch the object reachable from `after` and store it under our
7979+ // local refs/heads/... or refs/tags/... at the same name.
8080+ await git(
8181+ ['fetch', '--no-tags', '-q', githubUrl, `+${payload.after}:${payload.ref}`],
8282+ { cwd: tmpDir, timeout: 120_000 },
8383+ )
8484+8585+ // 4. Push to the knot. `force-with-lease` means "only update the ref if
8686+ // its current tip on the knot still matches what we last saw". Without
8787+ // a lease value we fall back to plain `--force` because we have no
8888+ // way to know the knot's current tip otherwise (we don't `ls-remote`).
8989+ // The lease is `<our last synced sha>` when we have one; on first
9090+ // sync we use plain force.
9191+ const { gitSshCommand, cleanup } = await loadSshCommandForInstall(payload.installationId)
9292+ sshCleanup = cleanup
9393+9494+ const knotUrl = `ssh://git@${row.knot}/${row.tangledRepoDid}`
9595+ const pushRefspec = lastSynced
9696+ ? `--force-with-lease=${payload.ref}:${lastSynced} ${payload.after}:${payload.ref}`
9797+ : `+${payload.after}:${payload.ref}`
9898+9999+ try {
100100+ await git(
101101+ ['push', '-q', knotUrl, ...pushRefspec.split(' ')],
102102+ {
103103+ cwd: tmpDir,
104104+ env: { GIT_SSH_COMMAND: gitSshCommand },
105105+ timeout: 120_000,
106106+ },
107107+ )
108108+ }
109109+ catch (err) {
110110+ const stderr = err instanceof Error && 'stderr' in err ? String((err as { stderr: unknown }).stderr) : ''
111111+ const classified = classifyPushFailure(stderr)
112112+ if (classified?.reason === 'repo-gone') {
113113+ await markMappingError(row.id, 'knot reports repo no longer exists; stopping sync')
114114+ return { status: 'skipped', reason: 'repo-gone' }
115115+ }
116116+ throw classified ?? err
117117+ }
118118+119119+ // 5. Update last-synced tip for this ref. Use jsonb_set to leave other
120120+ // refs untouched.
121121+ await db.update(repoMapping)
122122+ .set({
123123+ lastSyncedRefs: sql`jsonb_set(${repoMapping.lastSyncedRefs}, ${`{${jsonbPath(payload.ref)}}`}::text[], ${`"${payload.after}"`}::jsonb, true)`,
124124+ updatedAt: new Date(),
125125+ })
126126+ .where(eq(repoMapping.id, row.id))
127127+128128+ return { status: 'synced' }
129129+ }
130130+ finally {
131131+ sshCleanup?.()
132132+ try {
133133+ rmSync(tmpDir, { recursive: true, force: true })
134134+ }
135135+ catch {
136136+ // best-effort
137137+ }
138138+ }
139139+}
140140+141141+/** jsonb_set path argument: `refs/heads/main` becomes a single text array element. */
142142+function jsonbPath(ref: string): string {
143143+ // Escape any double-quotes inside the ref. We only support standard git ref
144144+ // names which never contain quotes, but be defensive.
145145+ return `"${ref.replaceAll('"', '\\"')}"`
146146+}
147147+148148+async function markMappingError(mappingId: number, message: string): Promise<void> {
149149+ const db = useDb()
150150+ await db.update(repoMapping)
151151+ .set({ status: 'error', lastError: message, updatedAt: new Date() })
152152+ .where(eq(repoMapping.id, mappingId))
153153+}
154154+155155+export { RemoteRejectedPushError }
+51-1
test/unit/ssh-keypair.spec.ts
···11+import { execFileSync } from 'node:child_process'
12import crypto from 'node:crypto'
33+import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
44+import os from 'node:os'
55+import path from 'node:path'
26import { describe, expect, it } from 'vitest'
33-import { generateKeypair } from '../../server/utils/ssh-keypair'
77+import { generateKeypair, pkcs8ToOpenSshPrivate } from '../../server/utils/ssh-keypair'
4859describe('ssh-keypair', () => {
610 it('produces an OpenSSH-formatted ed25519 public key', () => {
···4549 // (Tested indirectly via the keypair generator.)
4650 const { publicKeyOpenSsh } = generateKeypair('comment with spaces ok')
4751 expect(publicKeyOpenSsh).toContain('comment with spaces ok')
5252+ })
5353+5454+ describe('pkcs8ToOpenSshPrivate', () => {
5555+ it('produces a PEM-wrapped OpenSSH private key block', () => {
5656+ const { privateKeyPem } = generateKeypair('test')
5757+ const openssh = pkcs8ToOpenSshPrivate(privateKeyPem, 'test-key')
5858+ expect(openssh).toMatch(/^-----BEGIN OPENSSH PRIVATE KEY-----\n/)
5959+ expect(openssh).toMatch(/-----END OPENSSH PRIVATE KEY-----\n$/)
6060+ // Lines between markers should be base64 and <=70 chars.
6161+ const innerLines = openssh.split('\n').slice(1, -2)
6262+ for (const line of innerLines) {
6363+ expect(line.length).toBeLessThanOrEqual(70)
6464+ expect(line).toMatch(/^[A-Za-z0-9+/=]+$/)
6565+ }
6666+ })
6767+6868+ it('rejects non-ed25519 keys', () => {
6969+ const { privateKey } = crypto.generateKeyPairSync('rsa', {
7070+ modulusLength: 2048,
7171+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
7272+ })
7373+ expect(() => pkcs8ToOpenSshPrivate(privateKey, 'x'))
7474+ .toThrow(/expected ed25519/)
7575+ })
7676+7777+ it('is parseable by the real ssh-keygen, with derived public matching ours', () => {
7878+ const { publicKeyOpenSsh, privateKeyPem } = generateKeypair('synchub-test')
7979+ const openssh = pkcs8ToOpenSshPrivate(privateKeyPem, 'synchub-test')
8080+8181+ const dir = mkdtempSync(path.join(os.tmpdir(), 'synchub-ssh-test-'))
8282+ try {
8383+ const keyPath = path.join(dir, 'id_ed25519')
8484+ writeFileSync(keyPath, openssh, { mode: 0o600 })
8585+ chmodSync(keyPath, 0o600)
8686+8787+ const derived = execFileSync('ssh-keygen', ['-y', '-f', keyPath], { encoding: 'utf8' }).trim()
8888+ // ssh-keygen -y emits `ssh-ed25519 <base64>` (no comment). Compare
8989+ // ignoring the comment we put on `publicKeyOpenSsh`.
9090+ const ourBase64 = publicKeyOpenSsh.split(' ')[1]
9191+ const derivedBase64 = derived.split(' ')[1]
9292+ expect(derivedBase64).toBe(ourBase64)
9393+ }
9494+ finally {
9595+ rmSync(dir, { recursive: true, force: true })
9696+ }
9797+ })
4898 })
4999})