mirror your GitHub repos to tangled.org automatically
1

Configure Feed

Select the types of activity you want to include in your feed.

feat: sync push events from github to tangled via git over ssh

+557 -7
+1
package.json
··· 49 49 "@octokit/auth-app": "^8.2.0", 50 50 "@octokit/webhooks-methods": "^6.0.0", 51 51 "drizzle-orm": "^0.45.2", 52 + "execa": "^9.6.1", 52 53 "nuxt": "^4.4.4", 53 54 "nuxt-og-image": "^6.4.11", 54 55 "rolldown": "^1.0.0-rc.18",
+94
pnpm-lock.yaml
··· 54 54 drizzle-orm: 55 55 specifier: ^0.45.2 56 56 version: 0.45.2(@electric-sql/pglite@0.4.5)(@neondatabase/serverless@1.1.0) 57 + execa: 58 + specifier: ^9.6.1 59 + version: 9.6.1 57 60 nuxt: 58 61 specifier: ^4.4.4 59 62 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 2709 cpu: [x64] 2707 2710 os: [win32] 2708 2711 2712 + '@sec-ant/readable-stream@0.4.1': 2713 + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} 2714 + 2709 2715 '@sidvind/better-ajv-errors@3.0.1': 2710 2716 resolution: {integrity: sha512-++1mEYIeozfnwWI9P1ECvOPoacy+CgDASrmGvXPMCcqgx0YUzB01vZ78uHdQ443V6sTY+e9MzHqmN9DOls02aw==} 2711 2717 engines: {node: '>= 16.14'} ··· 3845 3851 resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} 3846 3852 engines: {node: '>=16.17'} 3847 3853 3854 + execa@9.6.1: 3855 + resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} 3856 + engines: {node: ^18.19.0 || >=20.5.0} 3857 + 3848 3858 exsolve@1.0.8: 3849 3859 resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} 3850 3860 ··· 3907 3917 peerDependenciesMeta: 3908 3918 picomatch: 3909 3919 optional: true 3920 + 3921 + figures@6.1.0: 3922 + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} 3923 + engines: {node: '>=18'} 3910 3924 3911 3925 file-entry-cache@8.0.0: 3912 3926 resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} ··· 3997 4011 resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} 3998 4012 engines: {node: '>=16'} 3999 4013 4014 + get-stream@9.0.1: 4015 + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} 4016 + engines: {node: '>=18'} 4017 + 4000 4018 get-tsconfig@4.14.0: 4001 4019 resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} 4002 4020 ··· 4108 4126 resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} 4109 4127 engines: {node: '>=16.17.0'} 4110 4128 4129 + human-signals@8.0.1: 4130 + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} 4131 + engines: {node: '>=18.18.0'} 4132 + 4111 4133 ieee754@1.2.1: 4112 4134 resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} 4113 4135 ··· 4204 4226 resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==} 4205 4227 engines: {node: '>=12'} 4206 4228 4229 + is-plain-obj@4.1.0: 4230 + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} 4231 + engines: {node: '>=12'} 4232 + 4207 4233 is-reference@1.2.1: 4208 4234 resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} 4209 4235 ··· 4214 4240 is-stream@3.0.0: 4215 4241 resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} 4216 4242 engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} 4243 + 4244 + is-stream@4.0.1: 4245 + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} 4246 + engines: {node: '>=18'} 4247 + 4248 + is-unicode-supported@2.1.0: 4249 + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} 4250 + engines: {node: '>=18'} 4217 4251 4218 4252 is-wsl@2.2.0: 4219 4253 resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} ··· 4784 4818 package-json-from-dist@1.0.1: 4785 4819 resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} 4786 4820 4821 + parse-ms@4.0.0: 4822 + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} 4823 + engines: {node: '>=18'} 4824 + 4787 4825 parseurl@1.3.3: 4788 4826 resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} 4789 4827 engines: {node: '>= 0.8'} ··· 5047 5085 pretty-bytes@7.1.0: 5048 5086 resolution: {integrity: sha512-nODzvTiYVRGRqAOvE84Vk5JDPyyxsVk0/fbA/bq7RqlnhksGpset09XTxbpvLTIjoaF7K8Z8DG8yHtKGTPSYRw==} 5049 5087 engines: {node: '>=20'} 5088 + 5089 + pretty-ms@9.3.0: 5090 + resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} 5091 + engines: {node: '>=18'} 5050 5092 5051 5093 process-nextick-args@2.0.1: 5052 5094 resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} ··· 5332 5374 resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} 5333 5375 engines: {node: '>=12'} 5334 5376 5377 + strip-final-newline@4.0.0: 5378 + resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} 5379 + engines: {node: '>=18'} 5380 + 5335 5381 strip-literal@3.1.0: 5336 5382 resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} 5337 5383 ··· 5876 5922 resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} 5877 5923 engines: {node: '>=10'} 5878 5924 5925 + yoctocolors@2.1.2: 5926 + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} 5927 + engines: {node: '>=18'} 5928 + 5879 5929 youch-core@0.3.3: 5880 5930 resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} 5881 5931 ··· 8129 8179 '@rollup/rollup-win32-x64-msvc@4.60.2': 8130 8180 optional: true 8131 8181 8182 + '@sec-ant/readable-stream@0.4.1': {} 8183 + 8132 8184 '@sidvind/better-ajv-errors@3.0.1(ajv@8.20.0)': 8133 8185 dependencies: 8134 8186 ajv: 8.20.0 ··· 9272 9324 signal-exit: 4.1.0 9273 9325 strip-final-newline: 3.0.0 9274 9326 9327 + execa@9.6.1: 9328 + dependencies: 9329 + '@sindresorhus/merge-streams': 4.0.0 9330 + cross-spawn: 7.0.6 9331 + figures: 6.1.0 9332 + get-stream: 9.0.1 9333 + human-signals: 8.0.1 9334 + is-plain-obj: 4.1.0 9335 + is-stream: 4.0.1 9336 + npm-run-path: 6.0.0 9337 + pretty-ms: 9.3.0 9338 + signal-exit: 4.1.0 9339 + strip-final-newline: 4.0.0 9340 + yoctocolors: 2.1.2 9341 + 9275 9342 exsolve@1.0.8: {} 9276 9343 9277 9344 fake-indexeddb@6.2.5: {} ··· 9325 9392 fdir@6.5.0(picomatch@4.0.4): 9326 9393 optionalDependencies: 9327 9394 picomatch: 4.0.4 9395 + 9396 + figures@6.1.0: 9397 + dependencies: 9398 + is-unicode-supported: 2.1.0 9328 9399 9329 9400 file-entry-cache@8.0.0: 9330 9401 dependencies: ··· 9431 9502 9432 9503 get-stream@8.0.1: {} 9433 9504 9505 + get-stream@9.0.1: 9506 + dependencies: 9507 + '@sec-ant/readable-stream': 0.4.1 9508 + is-stream: 4.0.1 9509 + 9434 9510 get-tsconfig@4.14.0: 9435 9511 dependencies: 9436 9512 resolve-pkg-maps: 1.0.0 ··· 9556 9632 9557 9633 human-signals@5.0.0: {} 9558 9634 9635 + human-signals@8.0.1: {} 9636 + 9559 9637 ieee754@1.2.1: {} 9560 9638 9561 9639 ignore@5.3.2: {} ··· 9672 9750 9673 9751 is-path-inside@4.0.0: {} 9674 9752 9753 + is-plain-obj@4.1.0: {} 9754 + 9675 9755 is-reference@1.2.1: 9676 9756 dependencies: 9677 9757 '@types/estree': 1.0.8 ··· 9680 9760 9681 9761 is-stream@3.0.0: {} 9682 9762 9763 + is-stream@4.0.1: {} 9764 + 9765 + is-unicode-supported@2.1.0: {} 9766 + 9683 9767 is-wsl@2.2.0: 9684 9768 dependencies: 9685 9769 is-docker: 2.2.1 ··· 10558 10642 10559 10643 package-json-from-dist@1.0.1: {} 10560 10644 10645 + parse-ms@4.0.0: {} 10646 + 10561 10647 parseurl@1.3.3: {} 10562 10648 10563 10649 path-browserify@1.0.1: {} ··· 10790 10876 10791 10877 pretty-bytes@7.1.0: {} 10792 10878 10879 + pretty-ms@9.3.0: 10880 + dependencies: 10881 + parse-ms: 4.0.0 10882 + 10793 10883 process-nextick-args@2.0.1: {} 10794 10884 10795 10885 process@0.11.10: {} ··· 11128 11218 ansi-regex: 6.2.2 11129 11219 11130 11220 strip-final-newline@3.0.0: {} 11221 + 11222 + strip-final-newline@4.0.0: {} 11131 11223 11132 11224 strip-literal@3.1.0: 11133 11225 dependencies: ··· 11687 11779 yargs-parser: 22.0.0 11688 11780 11689 11781 yocto-queue@0.1.0: {} 11782 + 11783 + yoctocolors@2.1.2: {} 11690 11784 11691 11785 youch-core@0.3.3: 11692 11786 dependencies:
+55
server/utils/git.ts
··· 1 + import { execa, type Options } from 'execa' 2 + 3 + /** 4 + * Thin wrapper over `execa` for invoking the system `git` binary with 5 + * predictable defaults. 6 + * 7 + * - Forces non-interactive mode so a misconfigured ssh setup never hangs 8 + * waiting for a passphrase or `yes/no` prompt. 9 + * - Captures stderr so callers can produce useful error messages. 10 + * - Adds a default 60s timeout; callers can override via `options.timeout`. 11 + */ 12 + export async function git(args: string[], options: Options = {}): Promise<{ stdout: string, stderr: string }> { 13 + const result = await execa('git', args, { 14 + timeout: 60_000, 15 + ...options, 16 + env: { 17 + // Belt and braces against interactive prompts. `GIT_TERMINAL_PROMPT=0` 18 + // makes git fail rather than hang if it would otherwise ask for input 19 + // (e.g. credentials). 20 + GIT_TERMINAL_PROMPT: '0', 21 + // Don't pick up the running user's ssh config / known_hosts. The caller 22 + // supplies a complete GIT_SSH_COMMAND for ssh transports. 23 + GIT_CONFIG_NOSYSTEM: '1', 24 + ...options.env, 25 + }, 26 + // Buffer (default) is fine for small operations; for very large fetches 27 + // we'd want to stream stderr instead. 28 + reject: true, 29 + all: true, 30 + }) 31 + return { stdout: String(result.stdout), stderr: String(result.stderr) } 32 + } 33 + 34 + /** 35 + * Recognised remote rejection patterns from the knot when a repo no longer 36 + * exists or our key has been revoked. Surfaces as a typed error so the 37 + * worker can mark the mapping as terminally failed rather than retry forever. 38 + */ 39 + export class RemoteRejectedPushError extends Error { 40 + constructor(message: string, public readonly reason: 'repo-gone' | 'auth-rejected' | 'other') { 41 + super(message) 42 + this.name = 'RemoteRejectedPushError' 43 + } 44 + } 45 + 46 + export function classifyPushFailure(stderr: string): RemoteRejectedPushError | null { 47 + const lc = stderr.toLowerCase() 48 + if (lc.includes('repository not found') || lc.includes('does not exist') || lc.includes('does not appear to be a git repository')) { 49 + return new RemoteRejectedPushError(stderr.trim(), 'repo-gone') 50 + } 51 + if (lc.includes('permission denied') || lc.includes('publickey') && lc.includes('denied')) { 52 + return new RemoteRejectedPushError(stderr.trim(), 'auth-rejected') 53 + } 54 + return null 55 + }
+10 -4
server/utils/job-handlers.ts
··· 5 5 import { installationOctokit } from './github-app' 6 6 import type { JobEnvelope } from './queue' 7 7 import { enqueue } from './queue' 8 + import { syncPush, type PushPayload } from './sync-push' 8 9 import { generateAndPublishKey } from './tangled-pubkey' 9 10 import { enrollRepo } from './tangled-repo' 10 11 11 12 /** 12 13 * Map of job kind → handler. Each commit fills in its slice: 13 - * - 'github.push' → commit 12 (sync push events) 14 + * - 'github.push' → this commit (sync push events) 14 15 * - 'github.create' / 'github.delete' → commit 13 (branch/tag ref ops) 15 16 * - 'github.repository' → commit 14/15 (description, lifecycle) 16 - * - 'github.installation_repositories' → this commit (fan-out enrolment) 17 - * - 'tangled.backfill-installation' → this commit (paginate + fan-out) 18 - * - 'tangled.create-repo' → this commit (per-repo enrolment) 17 + * - 'github.installation_repositories' → commit 10 (fan-out enrolment) 18 + * - 'tangled.backfill-installation' → commit 10 (paginate + fan-out) 19 + * - 'tangled.create-repo' → commit 10 (per-repo enrolment) 19 20 * - 'atproto.publish-pubkey' → commit 9 20 21 * 21 22 * Unknown kinds throw so they surface as job failures rather than silent ··· 108 109 export async function dispatch(envelope: JobEnvelope): Promise<void> { 109 110 if (!KNOWN_KINDS.has(envelope.kind)) { 110 111 throw new Error(`unknown job kind: ${envelope.kind}`) 112 + } 113 + 114 + if (envelope.kind === 'github.push') { 115 + await syncPush(envelope.payload as PushPayload) 116 + return 111 117 } 112 118 113 119 if (envelope.kind === 'atproto.publish-pubkey') {
+87
server/utils/ssh-cmd.ts
··· 1 + import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' 2 + import os from 'node:os' 3 + import path from 'node:path' 4 + import { sql } from 'drizzle-orm' 5 + import { sshKey } from '../db/schema' 6 + import { useDb } from './db' 7 + import { decrypt } from './encryption' 8 + import { 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 + */ 27 + export 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. */ 81 + function 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 + }
+104 -2
server/utils/ssh-keypair.ts
··· 6 6 * and the PKCS#8-PEM-encoded private key (suitable for storage). 7 7 * 8 8 * We store PKCS#8 because Node loads it natively via `crypto.createPrivateKey`. 9 - * Conversion to OpenSSH private key format (what `git`/`ssh-agent` consumes) is 10 - * deferred until commit 12, where it lives next to the SSH push code. 9 + * The OpenSSH-private-key format used by `git`/`ssh` for authentication is 10 + * produced on demand by `pkcs8ToOpenSshPrivate` below. 11 11 */ 12 12 export interface GeneratedKeypair { 13 13 publicKeyOpenSsh: string ··· 55 55 len.writeUInt32BE(buf.length, 0) 56 56 return Buffer.concat([len, buf]) 57 57 } 58 + 59 + /** 60 + * Convert an ed25519 PKCS#8 PEM private key (what we store) to the OpenSSH 61 + * private key format (what `ssh`/`git` consume). Format spec: OpenSSH's 62 + * PROTOCOL.key. No passphrase — the file we hand to ssh is plaintext and 63 + * lives only for the duration of one push, in a 0600 temp file. 64 + * 65 + * Structure for an unencrypted ed25519 key: 66 + * "openssh-key-v1\0" 67 + * string ciphername = "none" 68 + * string kdfname = "none" 69 + * string kdfoptions = "" 70 + * uint32 nkeys = 1 71 + * string public-key-blob (ssh-ed25519 wire format: algo + raw32) 72 + * string private-section (padded to a multiple of 8): 73 + * uint32 checkint 74 + * uint32 checkint (same value, sanity check for decryption) 75 + * string "ssh-ed25519" 76 + * string public (raw 32) 77 + * string private (64 bytes: seed(32) || public(32)) 78 + * string comment 79 + * padding bytes 1,2,3,...,n 80 + * 81 + * The whole binary blob is then base64-wrapped in 82 + * `-----BEGIN OPENSSH PRIVATE KEY-----` / `-----END OPENSSH PRIVATE KEY-----` 83 + * with 70-char line breaks. 84 + */ 85 + export function pkcs8ToOpenSshPrivate(privateKeyPem: string, comment: string): string { 86 + const keyObj = crypto.createPrivateKey(privateKeyPem) 87 + if (keyObj.asymmetricKeyType !== 'ed25519') { 88 + throw new Error(`expected ed25519 private key, got ${String(keyObj.asymmetricKeyType)}`) 89 + } 90 + 91 + // PKCS#8 DER for ed25519 is a fixed 48-byte ASN.1 structure with the 92 + // 32-byte seed as the trailing bytes. (RFC 8410 §7.) 93 + const pkcs8Der = keyObj.export({ type: 'pkcs8', format: 'der' }) 94 + const seed = pkcs8Der.subarray(-32) 95 + 96 + // Derive the matching public key by re-extracting from the same key object. 97 + const publicKeyDer = crypto.createPublicKey(keyObj).export({ type: 'spki', format: 'der' }) 98 + const rawPublic = publicKeyDer.subarray(-32) 99 + 100 + const algo = Buffer.from('ssh-ed25519', 'utf8') 101 + const publicKeyBlob = Buffer.concat([sshString(algo), sshString(rawPublic)]) 102 + 103 + // checkint: a random uint32 written twice. ssh verifies the two are equal 104 + // after decryption — cheap integrity check. For an unencrypted key it's 105 + // still required but doesn't really verify anything; use random bytes. 106 + const checkint = crypto.randomBytes(4) 107 + 108 + // OpenSSH's private key format stores the seed concatenated with the public 109 + // key as one 64-byte "private" string. Looks redundant but is what ssh 110 + // parses. 111 + const privateMaterial = Buffer.concat([seed, rawPublic]) 112 + 113 + let privateSection = Buffer.concat([ 114 + checkint, 115 + checkint, 116 + sshString(algo), 117 + sshString(rawPublic), 118 + sshString(privateMaterial), 119 + sshString(Buffer.from(comment, 'utf8')), 120 + ]) 121 + 122 + // Pad to a multiple of 8 (the "none" cipher's block size). Padding bytes 123 + // are 1, 2, 3, … not zeros. 124 + const padLen = (8 - (privateSection.length % 8)) % 8 125 + if (padLen > 0) { 126 + const pad = Buffer.alloc(padLen) 127 + for (let i = 0; i < padLen; i++) pad[i] = i + 1 128 + privateSection = Buffer.concat([privateSection, pad]) 129 + } 130 + 131 + const blob = Buffer.concat([ 132 + Buffer.from('openssh-key-v1\0', 'utf8'), 133 + sshString(Buffer.from('none', 'utf8')), 134 + sshString(Buffer.from('none', 'utf8')), 135 + sshString(Buffer.alloc(0)), 136 + uint32BE(1), 137 + sshString(publicKeyBlob), 138 + sshString(privateSection), 139 + ]) 140 + 141 + const base64 = blob.toString('base64') 142 + const lines: string[] = [] 143 + for (let i = 0; i < base64.length; i += 70) { 144 + lines.push(base64.slice(i, i + 70)) 145 + } 146 + 147 + return [ 148 + '-----BEGIN OPENSSH PRIVATE KEY-----', 149 + ...lines, 150 + '-----END OPENSSH PRIVATE KEY-----', 151 + '', 152 + ].join('\n') 153 + } 154 + 155 + function uint32BE(n: number): Buffer { 156 + const buf = Buffer.alloc(4) 157 + buf.writeUInt32BE(n, 0) 158 + return buf 159 + }
+155
server/utils/sync-push.ts
··· 1 + import { mkdtempSync, rmSync } from 'node:fs' 2 + import os from 'node:os' 3 + import path from 'node:path' 4 + import { and, eq, sql } from 'drizzle-orm' 5 + import { repoMapping } from '../db/schema' 6 + import { useDb } from './db' 7 + import { classifyPushFailure, git, RemoteRejectedPushError } from './git' 8 + import { installationOctokit } from './github-app' 9 + import { loadSshCommandForInstall } from './ssh-cmd' 10 + 11 + const ZERO_SHA = '0000000000000000000000000000000000000000' 12 + 13 + export interface PushPayload { 14 + installationId: number 15 + githubRepoId: number 16 + ref: string 17 + before: string 18 + after: string 19 + } 20 + 21 + export interface PushResult { 22 + status: 'synced' | 'skipped' 23 + reason?: 'no-mapping' | 'disabled' | 'already-synced' | 'deletion' | 'repo-gone' 24 + } 25 + 26 + /** 27 + * Mirror a single push from GitHub to the configured knot. 28 + * 29 + * 1. Look up the repo_mapping (installationId, githubRepoId). Skip if absent 30 + * or disabled. 31 + * 2. Ref-tip dedupe: if lastSyncedRefs[ref] === after, no-op. Guards against 32 + * GitHub redeliveries and v1.1's tangled-primary loop (PLAN.md). 33 + * 3. Skip ref deletions (after = 0000…). Handled by github.delete in commit 13. 34 + * 4. Bare-init /tmp scratch; fetch `after` from GitHub via smart-HTTP using 35 + * the install token; push that ref to the knot over SSH with the 36 + * install's key, force-with-lease against our last known tip. 37 + * 5. Update lastSyncedRefs[ref] = after. 38 + * 39 + * On terminal failures (repo gone from knot, auth rejected) we mark the 40 + * mapping as `status='error'` so the worker stops retrying. Transient 41 + * failures (network blips, missing objects) re-throw and the queue retries 42 + * with backoff. 43 + */ 44 + export async function syncPush(payload: PushPayload): Promise<PushResult> { 45 + const db = useDb() 46 + 47 + const mapping = await db.select().from(repoMapping).where( 48 + and( 49 + eq(repoMapping.installationId, payload.installationId), 50 + eq(repoMapping.githubRepoId, payload.githubRepoId), 51 + ), 52 + ).limit(1) 53 + if (mapping.length === 0) return { status: 'skipped', reason: 'no-mapping' } 54 + const row = mapping[0]! 55 + 56 + if (row.disabledAt) return { status: 'skipped', reason: 'disabled' } 57 + if (!row.tangledRepoDid || !row.knot) return { status: 'skipped', reason: 'no-mapping' } 58 + 59 + if (payload.after === ZERO_SHA) return { status: 'skipped', reason: 'deletion' } 60 + 61 + const lastSynced = (row.lastSyncedRefs as Record<string, string>)[payload.ref] 62 + if (lastSynced === payload.after) return { status: 'skipped', reason: 'already-synced' } 63 + 64 + const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'synchub-push-')) 65 + let sshCleanup: (() => void) | undefined 66 + 67 + try { 68 + // 1. Bare init. No working tree, no objects until we fetch. 69 + await git(['init', '--bare', '-q'], { cwd: tmpDir }) 70 + 71 + // 2. Install-token-authed clone URL. The `x-access-token` username is 72 + // GitHub's convention for installation tokens. 73 + const octokit = await installationOctokit(payload.installationId) 74 + const { token } = (await octokit.auth({ type: 'installation' })) as { token: string } 75 + const githubUrl = `https://x-access-token:${token}@github.com/${row.githubFullName}.git` 76 + 77 + // 3. Fetch exactly the new ref. The `<sha>:<ref>` refspec asks git to 78 + // fetch the object reachable from `after` and store it under our 79 + // local refs/heads/... or refs/tags/... at the same name. 80 + await git( 81 + ['fetch', '--no-tags', '-q', githubUrl, `+${payload.after}:${payload.ref}`], 82 + { cwd: tmpDir, timeout: 120_000 }, 83 + ) 84 + 85 + // 4. Push to the knot. `force-with-lease` means "only update the ref if 86 + // its current tip on the knot still matches what we last saw". Without 87 + // a lease value we fall back to plain `--force` because we have no 88 + // way to know the knot's current tip otherwise (we don't `ls-remote`). 89 + // The lease is `<our last synced sha>` when we have one; on first 90 + // sync we use plain force. 91 + const { gitSshCommand, cleanup } = await loadSshCommandForInstall(payload.installationId) 92 + sshCleanup = cleanup 93 + 94 + const knotUrl = `ssh://git@${row.knot}/${row.tangledRepoDid}` 95 + const pushRefspec = lastSynced 96 + ? `--force-with-lease=${payload.ref}:${lastSynced} ${payload.after}:${payload.ref}` 97 + : `+${payload.after}:${payload.ref}` 98 + 99 + try { 100 + await git( 101 + ['push', '-q', knotUrl, ...pushRefspec.split(' ')], 102 + { 103 + cwd: tmpDir, 104 + env: { GIT_SSH_COMMAND: gitSshCommand }, 105 + timeout: 120_000, 106 + }, 107 + ) 108 + } 109 + catch (err) { 110 + const stderr = err instanceof Error && 'stderr' in err ? String((err as { stderr: unknown }).stderr) : '' 111 + const classified = classifyPushFailure(stderr) 112 + if (classified?.reason === 'repo-gone') { 113 + await markMappingError(row.id, 'knot reports repo no longer exists; stopping sync') 114 + return { status: 'skipped', reason: 'repo-gone' } 115 + } 116 + throw classified ?? err 117 + } 118 + 119 + // 5. Update last-synced tip for this ref. Use jsonb_set to leave other 120 + // refs untouched. 121 + await db.update(repoMapping) 122 + .set({ 123 + lastSyncedRefs: sql`jsonb_set(${repoMapping.lastSyncedRefs}, ${`{${jsonbPath(payload.ref)}}`}::text[], ${`"${payload.after}"`}::jsonb, true)`, 124 + updatedAt: new Date(), 125 + }) 126 + .where(eq(repoMapping.id, row.id)) 127 + 128 + return { status: 'synced' } 129 + } 130 + finally { 131 + sshCleanup?.() 132 + try { 133 + rmSync(tmpDir, { recursive: true, force: true }) 134 + } 135 + catch { 136 + // best-effort 137 + } 138 + } 139 + } 140 + 141 + /** jsonb_set path argument: `refs/heads/main` becomes a single text array element. */ 142 + function jsonbPath(ref: string): string { 143 + // Escape any double-quotes inside the ref. We only support standard git ref 144 + // names which never contain quotes, but be defensive. 145 + return `"${ref.replaceAll('"', '\\"')}"` 146 + } 147 + 148 + async function markMappingError(mappingId: number, message: string): Promise<void> { 149 + const db = useDb() 150 + await db.update(repoMapping) 151 + .set({ status: 'error', lastError: message, updatedAt: new Date() }) 152 + .where(eq(repoMapping.id, mappingId)) 153 + } 154 + 155 + export { RemoteRejectedPushError }
+51 -1
test/unit/ssh-keypair.spec.ts
··· 1 + import { execFileSync } from 'node:child_process' 1 2 import crypto from 'node:crypto' 3 + import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' 4 + import os from 'node:os' 5 + import path from 'node:path' 2 6 import { describe, expect, it } from 'vitest' 3 - import { generateKeypair } from '../../server/utils/ssh-keypair' 7 + import { generateKeypair, pkcs8ToOpenSshPrivate } from '../../server/utils/ssh-keypair' 4 8 5 9 describe('ssh-keypair', () => { 6 10 it('produces an OpenSSH-formatted ed25519 public key', () => { ··· 45 49 // (Tested indirectly via the keypair generator.) 46 50 const { publicKeyOpenSsh } = generateKeypair('comment with spaces ok') 47 51 expect(publicKeyOpenSsh).toContain('comment with spaces ok') 52 + }) 53 + 54 + describe('pkcs8ToOpenSshPrivate', () => { 55 + it('produces a PEM-wrapped OpenSSH private key block', () => { 56 + const { privateKeyPem } = generateKeypair('test') 57 + const openssh = pkcs8ToOpenSshPrivate(privateKeyPem, 'test-key') 58 + expect(openssh).toMatch(/^-----BEGIN OPENSSH PRIVATE KEY-----\n/) 59 + expect(openssh).toMatch(/-----END OPENSSH PRIVATE KEY-----\n$/) 60 + // Lines between markers should be base64 and <=70 chars. 61 + const innerLines = openssh.split('\n').slice(1, -2) 62 + for (const line of innerLines) { 63 + expect(line.length).toBeLessThanOrEqual(70) 64 + expect(line).toMatch(/^[A-Za-z0-9+/=]+$/) 65 + } 66 + }) 67 + 68 + it('rejects non-ed25519 keys', () => { 69 + const { privateKey } = crypto.generateKeyPairSync('rsa', { 70 + modulusLength: 2048, 71 + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, 72 + }) 73 + expect(() => pkcs8ToOpenSshPrivate(privateKey, 'x')) 74 + .toThrow(/expected ed25519/) 75 + }) 76 + 77 + it('is parseable by the real ssh-keygen, with derived public matching ours', () => { 78 + const { publicKeyOpenSsh, privateKeyPem } = generateKeypair('synchub-test') 79 + const openssh = pkcs8ToOpenSshPrivate(privateKeyPem, 'synchub-test') 80 + 81 + const dir = mkdtempSync(path.join(os.tmpdir(), 'synchub-ssh-test-')) 82 + try { 83 + const keyPath = path.join(dir, 'id_ed25519') 84 + writeFileSync(keyPath, openssh, { mode: 0o600 }) 85 + chmodSync(keyPath, 0o600) 86 + 87 + const derived = execFileSync('ssh-keygen', ['-y', '-f', keyPath], { encoding: 'utf8' }).trim() 88 + // ssh-keygen -y emits `ssh-ed25519 <base64>` (no comment). Compare 89 + // ignoring the comment we put on `publicKeyOpenSsh`. 90 + const ourBase64 = publicKeyOpenSsh.split(' ')[1] 91 + const derivedBase64 = derived.split(' ')[1] 92 + expect(derivedBase64).toBe(ourBase64) 93 + } 94 + finally { 95 + rmSync(dir, { recursive: true, force: true }) 96 + } 97 + }) 48 98 }) 49 99 })