From e4e7ddc39c6e02797b0b8babcf3ae80a8c3119de Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sat, 13 Jun 2026 14:08:37 +0100 Subject: [PATCH] fix: push to knots via in-process `ssh2` instead of `ssh` binary --- package.json | 2 + pnpm-lock.yaml | 217 +++++++++++--------------- server/utils/git-wire/receive-pack.ts | 129 +++++++++++---- server/utils/splice.ts | 32 ++-- server/utils/ssh-cmd.ts | 60 +------ test/unit/ssh2-receive-pack.spec.ts | 130 +++++++++++++++ 6 files changed, 336 insertions(+), 234 deletions(-) create mode 100644 test/unit/ssh2-receive-pack.spec.ts diff --git a/package.json b/package.json index 926c639..efe5008 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "nuxt": "^4.4.8", "nuxt-og-image": "^6.4.11", "rolldown": "^1.0.0-rc.18", + "ssh2": "^1.17.0", "vite-plus": "0.1.20", "vue": "3.5.33", "vue-router": "^5.0.6" @@ -63,6 +64,7 @@ "@playwright/test": "1.59.1", "@stylistic/eslint-plugin": "^5.10.0", "@types/node": "^24.13.2", + "@types/ssh2": "^1.15.5", "@vitest/coverage-v8": "^4.1.5", "@vue/test-utils": "2.4.10", "drizzle-kit": "^0.31.10", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ab5e287..34d189e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,10 +59,13 @@ importers: version: 4.4.8(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0))(@electric-sql/pglite@0.4.5)(@parcel/watcher@2.5.6)(@types/node@24.13.2)(@vue/compiler-sfc@3.5.38)(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.7.0))(ioredis@5.10.1)(magicast@0.5.3)(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@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.9.0) nuxt-og-image: specifier: ^6.4.11 - version: 6.5.3(b947c0efbbd857aebb0c05595d628220) + version: 6.5.3(7c45f4c9f7a120d9de0460f4082612ea) rolldown: specifier: ^1.0.0-rc.18 version: 1.0.0-rc.18 + ssh2: + specifier: ^1.17.0 + version: 1.17.0 vite-plus: specifier: 0.1.20 version: 0.1.20(@types/node@24.13.2)(@vitest/coverage-v8@4.1.5)(esbuild@0.28.0)(happy-dom@20.9.0)(jiti@2.7.0)(terser@5.46.2)(tsx@4.21.0)(typescript@6.0.3)(vite@7.3.2(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))(yaml@2.9.0) @@ -91,6 +94,9 @@ importers: '@types/node': specifier: ^24.13.2 version: 24.13.2 + '@types/ssh2': + specifier: ^1.15.5 + version: 1.15.5 '@vitest/coverage-v8': specifier: ^4.1.5 version: 4.1.5(@vitest/browser@4.1.5)(@voidzero-dev/vite-plus-test@0.1.20) @@ -2928,12 +2934,18 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/node@18.19.130': + resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} + '@types/node@24.13.2': resolution: {integrity: sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==} '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + '@types/ssh2@1.15.5': + resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==} + '@types/web-bluetooth@0.0.21': resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} @@ -3358,6 +3370,9 @@ packages: resolution: {integrity: sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==} engines: {node: '>= 14'} + asn1@0.2.6: + resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -3453,6 +3468,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bcrypt-pbkdf@1.0.2: + resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + before-after-hook@4.0.0: resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} @@ -3494,6 +3512,10 @@ packages: buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + buildcheck@0.0.7: + resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==} + engines: {node: '>=10.0.0'} + bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} @@ -3605,6 +3627,10 @@ packages: core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cpu-features@0.0.10: + resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==} + engines: {node: '>=10.0.0'} + crc-32@1.2.2: resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} engines: {node: '>=0.8'} @@ -4738,6 +4764,9 @@ packages: multiformats@9.9.0: resolution: {integrity: sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==} + nan@2.27.0: + resolution: {integrity: sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==} + nano-staged@1.0.2: resolution: {integrity: sha512-Fytar3zHLY99nlMfqPPbraxZodqQAHPpdPRyYaplL+lB9DCR6pUrafxbG+Btz4+7fO5Rm/+DO4ZeDO/nLSUMhw==} engines: {node: ^22 || >= 24} @@ -5387,6 +5416,9 @@ packages: safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + sax@1.6.0: resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} engines: {node: '>=11.0.0'} @@ -5495,6 +5527,10 @@ packages: engines: {node: '>=20.16.0'} hasBin: true + ssh2@1.17.0: + resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==} + engines: {node: '>=10.16.0'} + standard-as-callback@2.1.0: resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} @@ -5652,6 +5688,9 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tweetnacl@0.14.5: + resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -5689,6 +5728,9 @@ packages: unctx@2.5.0: resolution: {integrity: sha512-p+Rz9x0R7X+CYDkT+Xg8/GhpcShTlU8n+cf9OtOEf7zEQsNcCZO1dPKNRDqvUTaq+P32PMMkxWHwfrxkqfqAYg==} + undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} @@ -8495,12 +8537,20 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/node@18.19.130': + dependencies: + undici-types: 5.26.5 + '@types/node@24.13.2': dependencies: undici-types: 7.18.2 '@types/resolve@1.20.2': {} + '@types/ssh2@1.15.5': + dependencies: + '@types/node': 18.19.130 + '@types/web-bluetooth@0.0.21': {} '@types/whatwg-mimetype@3.0.2': {} @@ -9010,6 +9060,10 @@ snapshots: - bare-buffer - react-native-b4a + asn1@0.2.6: + dependencies: + safer-buffer: 2.1.2 + assertion-error@2.0.1: {} ast-kit@2.2.0: @@ -9086,6 +9140,10 @@ snapshots: baseline-browser-mapping@2.10.27: {} + bcrypt-pbkdf@1.0.2: + dependencies: + tweetnacl: 0.14.5 + before-after-hook@4.0.0: {} bindings@1.5.0: @@ -9127,6 +9185,9 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + buildcheck@0.0.7: + optional: true + bundle-name@4.1.0: dependencies: run-applescript: 7.1.0 @@ -9235,6 +9296,12 @@ snapshots: core-util-is@1.0.3: {} + cpu-features@0.0.10: + dependencies: + buildcheck: 0.0.7 + nan: 2.27.0 + optional: true + crc-32@1.2.2: {} crc32-stream@6.0.0: @@ -10372,6 +10439,9 @@ snapshots: multiformats@9.9.0: {} + nan@2.27.0: + optional: true + nano-staged@1.0.2: {} nanoid@3.3.12: {} @@ -10485,112 +10555,6 @@ snapshots: - supports-color - uploadthing - nitropack@2.13.4(@electric-sql/pglite@0.4.5)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.5)(@neondatabase/serverless@1.1.0))(oxc-parser@0.134.0)(rolldown@1.0.0-rc.18)(srvx@0.11.15): - dependencies: - '@cloudflare/kv-asset-handler': 0.4.2 - '@rollup/plugin-alias': 6.0.0(rollup@4.60.2) - '@rollup/plugin-commonjs': 29.0.2(rollup@4.60.2) - '@rollup/plugin-inject': 5.0.5(rollup@4.60.2) - '@rollup/plugin-json': 6.1.0(rollup@4.60.2) - '@rollup/plugin-node-resolve': 16.0.3(rollup@4.60.2) - '@rollup/plugin-replace': 6.0.3(rollup@4.60.2) - '@rollup/plugin-terser': 1.0.0(rollup@4.60.2) - '@vercel/nft': 1.5.0(rollup@4.60.2) - archiver: 7.0.1 - c12: 3.3.4(magicast@0.5.3) - chokidar: 5.0.0 - citty: 0.2.2 - compatx: 0.2.0 - confbox: 0.2.4 - consola: 3.4.2 - cookie-es: 2.0.1 - croner: 10.0.1 - crossws: 0.3.5 - 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)) - defu: 6.1.7 - destr: 2.0.5 - dot-prop: 10.1.0 - esbuild: 0.28.0 - escape-string-regexp: 5.0.0 - etag: 1.8.1 - exsolve: 1.0.8 - globby: 16.2.0 - gzip-size: 7.0.0 - h3: 1.15.11 - hookable: 5.5.3 - httpxy: 0.5.1 - ioredis: 5.10.1 - jiti: 2.7.0 - klona: 2.0.6 - knitwork: 1.3.0 - listhen: 1.10.0(srvx@0.11.15) - magic-string: 0.30.21 - magicast: 0.5.3 - mime: 4.1.0 - mlly: 1.8.2 - node-fetch-native: 1.6.7 - node-mock-http: 1.0.4 - ofetch: 1.5.1 - ohash: 2.0.11 - pathe: 2.0.3 - perfect-debounce: 2.1.0 - pkg-types: 2.3.1 - pretty-bytes: 7.1.0 - radix3: 1.1.2 - rollup: 4.60.2 - rollup-plugin-visualizer: 7.0.1(rolldown@1.0.0-rc.18)(rollup@4.60.2) - scule: 1.3.0 - semver: 7.8.4 - serve-placeholder: 2.0.2 - serve-static: 2.2.1 - source-map: 0.7.6 - std-env: 4.1.0 - ufo: 1.6.4 - ultrahtml: 1.6.0 - uncrypto: 0.1.3 - unctx: 2.5.0 - unenv: 2.0.0-rc.24 - unimport: 6.3.0(oxc-parser@0.134.0)(rolldown@1.0.0-rc.18) - unplugin-utils: 0.3.1 - unstorage: 1.17.5(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)))(ioredis@5.10.1) - untyped: 2.0.0 - unwasm: 0.5.3 - youch: 4.1.1 - youch-core: 0.3.3 - transitivePeerDependencies: - - '@azure/app-configuration' - - '@azure/cosmos' - - '@azure/data-tables' - - '@azure/identity' - - '@azure/keyvault-secrets' - - '@azure/storage-blob' - - '@capacitor/preferences' - - '@deno/kv' - - '@electric-sql/pglite' - - '@libsql/client' - - '@netlify/blobs' - - '@planetscale/database' - - '@upstash/redis' - - '@vercel/blob' - - '@vercel/functions' - - '@vercel/kv' - - aws4fetch - - bare-abort-controller - - bare-buffer - - better-sqlite3 - - drizzle-orm - - encoding - - idb-keyval - - mysql2 - - oxc-parser - - react-native-b4a - - rolldown - - sqlite3 - - srvx - - supports-color - - uploadthing - optional: true - node-addon-api@7.1.1: {} node-fetch-native@1.6.7: {} @@ -10630,7 +10594,7 @@ snapshots: dependencies: boolbase: 1.0.0 - nuxt-og-image@6.5.3(b947c0efbbd857aebb0c05595d628220): + nuxt-og-image@6.5.3(7c45f4c9f7a120d9de0460f4082612ea): dependencies: '@clack/prompts': 1.5.1 '@nuxt/kit': 4.4.8(magicast@0.5.3) @@ -10670,7 +10634,7 @@ snapshots: '@resvg/resvg-js': 2.6.2 '@resvg/resvg-wasm': 2.6.2 fontless: 0.2.1(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)))(ioredis@5.10.1)(vite@7.3.2(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0)) - nitropack: 2.13.4(@electric-sql/pglite@0.4.5)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.5)(@neondatabase/serverless@1.1.0))(oxc-parser@0.134.0)(rolldown@1.0.0-rc.18)(srvx@0.11.15) + nitropack: 2.13.4(@electric-sql/pglite@0.4.5)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.5)(@neondatabase/serverless@1.1.0))(oxc-parser@0.133.0)(rolldown@1.0.0-rc.18)(srvx@0.11.15) playwright-core: 1.59.1 sharp: 0.34.5 unifont: 0.7.4 @@ -11488,6 +11452,8 @@ snapshots: safe-buffer@5.2.1: {} + safer-buffer@2.1.2: {} + sax@1.6.0: {} scule@1.3.0: {} @@ -11617,6 +11583,14 @@ snapshots: srvx@0.11.15: {} + ssh2@1.17.0: + dependencies: + asn1: 0.2.6 + bcrypt-pbkdf: 1.0.2 + optionalDependencies: + cpu-features: 0.0.10 + nan: 2.27.0 + standard-as-callback@2.1.0: {} statuses@2.0.2: {} @@ -11783,6 +11757,8 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tweetnacl@0.14.5: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -11825,6 +11801,8 @@ snapshots: magic-string: 0.30.21 unplugin: 2.3.11 + undici-types@5.26.5: {} + undici-types@7.18.2: {} undici@6.25.0: {} @@ -11869,27 +11847,6 @@ snapshots: oxc-parser: 0.133.0 rolldown: 1.0.0-rc.18 - unimport@6.3.0(oxc-parser@0.134.0)(rolldown@1.0.0-rc.18): - dependencies: - acorn: 8.16.0 - escape-string-regexp: 5.0.0 - estree-walker: 3.0.3 - local-pkg: 1.1.2 - magic-string: 0.30.21 - mlly: 1.8.2 - pathe: 2.0.3 - picomatch: 4.0.4 - pkg-types: 2.3.1 - scule: 1.3.0 - strip-literal: 3.1.0 - tinyglobby: 0.2.17 - unplugin: 3.0.0 - unplugin-utils: 0.3.1 - optionalDependencies: - oxc-parser: 0.134.0 - rolldown: 1.0.0-rc.18 - optional: true - universal-github-app-jwt@2.2.2: {} universal-user-agent@7.0.3: {} diff --git a/server/utils/git-wire/receive-pack.ts b/server/utils/git-wire/receive-pack.ts index c920c5a..fd4b081 100644 --- a/server/utils/git-wire/receive-pack.ts +++ b/server/utils/git-wire/receive-pack.ts @@ -1,5 +1,5 @@ -import { type ChildProcessWithoutNullStreams, spawn } from 'node:child_process' -import { Readable } from 'node:stream' +import { PassThrough, Readable } from 'node:stream' +import { Client } from 'ssh2' import { classifyNgReason, classifySshStderr, WireError } from './errors' import { encodePktLine, flushPkt, lineToString, PktLineReader } from './pkt-line' import { type Advertisement, parseAdvertisement } from './refs' @@ -24,9 +24,10 @@ export interface RefUpdate { } /** - * A spawned process exposing `git-receive-pack`'s stdio. The default factory - * runs ssh to the knot; tests inject a factory that spawns the binary against - * a local bare repo, so the stdio protocol is identical either way. + * A process exposing `git-receive-pack`'s stdio. The default factory runs an + * in-process `ssh2` channel to the knot; tests inject a factory that spawns the + * binary against a local bare repo, so the stdio protocol is identical either + * way. */ export interface ReceivePackProcess { stdin: NodeJS.WritableStream @@ -44,35 +45,107 @@ export interface SshTarget { host: string port?: number repoPath: string - sshArgs: string[] + /** Decrypted OpenSSH-format private key for this install. */ + privateKey: string } -/** Default transport: ssh to the knot and invoke its `git-receive-pack`. */ -export function sshReceivePackFactory(target: SshTarget): ReceivePackFactory { +/** + * Default transport: open an in-process `ssh2` connection to the knot and run + * its `git-receive-pack`. No `ssh` binary (the Vercel runtime has none); the + * key stays in memory. + * + * Host keys: tangled knots are addressed by hostname over TLS-fronted DNS, and + * v1 has no pinned host keys, so `hostVerifier` accepts any (TOFU-equivalent to + * the previous `StrictHostKeyChecking=accept-new`). Pin once the canonical knot + * keys are known. + */ +export function ssh2ReceivePackFactory(target: SshTarget): ReceivePackFactory { return () => { - const portArgs = target.port ? ['-p', String(target.port)] : [] - // ssh:// transports invoke the remote command with the path including its - // leading slash, single-quoted. The knot resolves repos by that path. + // The knot resolves repos by the leading-slash path, single-quoted. const remoteCmd = `git-receive-pack '${target.repoPath}'` - const child = spawn('ssh', [...target.sshArgs, ...portArgs, `git@${target.host}`, remoteCmd], { - stdio: ['pipe', 'pipe', 'pipe'], + const client = new Client() + + const stdin = new PassThrough() + const stdout = new PassThrough() + let stderrBuf = Buffer.alloc(0) + let connError: Error | null = null + let killed = false + + const appendStderr = (chunk: Buffer) => { + stderrBuf = Buffer.concat([stderrBuf, chunk]).subarray(-STDERR_CAP) + } + + const done = new Promise(resolve => { + let settled = false + const settle = (code: number | null) => { + if (settled) return + settled = true + resolve(code) + } + + client.on('ready', () => { + client.exec(remoteCmd, (err, channel) => { + if (err) { + connError = err + stdout.end() + client.end() + settle(null) + return + } + stdin.pipe(channel) + channel.pipe(stdout) + channel.stderr.on('data', appendStderr) + channel.on('exit', code => settle(typeof code === 'number' ? code : null)) + channel.on('close', () => { client.end(); stdout.end() }) + }) + }) + + // A connection / auth failure surfaces here. Capturing it (rather than + // leaving 'error' unhandled, which crashes the process) folds the message + // into the stderr band so open() reports a WireError the job handler + // catches. Ending stdout unblocks the advertisement read. + client.on('error', err => { + if (!killed) connError = err + stdout.end() + settle(null) + }) + + // Always-fires backstop: `client.end()` (from kill(), a channel close, or + // an exec error) emits 'close', so `done` resolves even if the channel + // already exited and won't emit another event. + client.on('close', () => { + stdout.end() + settle(null) + }) }) - return wrapChild(child) - } -} -function wrapChild(child: ChildProcessWithoutNullStreams): ReceivePackProcess { - let stderrBuf = Buffer.alloc(0) - child.stderr.on('data', (chunk: Buffer) => { - stderrBuf = Buffer.concat([stderrBuf, chunk]).subarray(-STDERR_CAP) - }) - const done = new Promise(resolve => child.on('close', resolve)) - return { - stdin: child.stdin, - stdout: child.stdout, - stderr: () => stderrBuf.toString('utf8'), - kill: () => child.kill('SIGKILL'), - done, + // stdin EPIPE-style errors once the channel goes away are expected. + stdin.on('error', () => {}) + + client.connect({ + host: target.host, + port: target.port ?? 22, + username: 'git', + privateKey: target.privateKey, + readyTimeout: 15_000, + hostVerifier: () => true, + }) + + return { + stdin, + stdout, + stderr: () => { + const captured = stderrBuf.toString('utf8') + if (connError) return `${captured}${captured ? '\n' : ''}ssh error: ${connError.message}`.trim() + return captured + }, + kill: () => { + killed = true + client.end() + stdout.end() + }, + done, + } } } diff --git a/server/utils/splice.ts b/server/utils/splice.ts index fd94f20..08835ea 100644 --- a/server/utils/splice.ts +++ b/server/utils/splice.ts @@ -2,11 +2,11 @@ import { type ReceivePackFactory, ReceivePackSession, type RefUpdate, - sshReceivePackFactory, + ssh2ReceivePackFactory, } from './git-wire/receive-pack' import { ZERO_SHA } from './git-wire/refs' import { fetchPack } from './git-wire/upload-pack' -import { loadSshArgsForInstall } from './ssh-cmd' +import { loadSshKeyForInstall } from './ssh-cmd' import { sshEndpointForKnot } from './sync-push-host' const DEFAULT_MAX_PACK_BYTES = 1024 * 1024 * 1024 @@ -20,15 +20,11 @@ function maxPackBytes(): number { return Number.isNaN(n) || n <= 0 ? DEFAULT_MAX_PACK_BYTES : n } -async function sshFactory(installationId: number, knot: string, repoDid: string): Promise<{ - factory: ReceivePackFactory - cleanup: () => void -}> { - const { args, cleanup } = await loadSshArgsForInstall(installationId) +async function sshFactory(installationId: number, knot: string, repoDid: string): Promise { + const privateKey = await loadSshKeyForInstall(installationId) const { host, port } = sshEndpointForKnot(knot) // ssh:// path form: leading slash, the knot resolves the repo by DID. - const factory = sshReceivePackFactory({ host, port, repoPath: `/${repoDid}`, sshArgs: args }) - return { factory, cleanup } + return ssh2ReceivePackFactory({ host, port, repoPath: `/${repoDid}`, privateKey }) } export interface SplicePushParams { @@ -64,13 +60,8 @@ export interface SplicePushResult { * keeps the knot's advertised tip as the authoritative compare-and-swap base. */ export async function splicePush(params: SplicePushParams): Promise { - const { factory, cleanup } = await sshFactory(params.installationId, params.knot, params.repoDid) - try { - return await runSplice(factory, params) - } - finally { - cleanup() - } + const factory = await sshFactory(params.installationId, params.knot, params.repoDid) + return runSplice(factory, params) } /** The fetch + push exchange over an open session. Split out for the wire test. */ @@ -127,13 +118,8 @@ export async function spliceDelete(params: { repoDid: string ref: string }): Promise { - const { factory, cleanup } = await sshFactory(params.installationId, params.knot, params.repoDid) - try { - return await runSpliceDelete(factory, params.ref) - } - finally { - cleanup() - } + const factory = await sshFactory(params.installationId, params.knot, params.repoDid) + return runSpliceDelete(factory, params.ref) } /** The delete exchange over an open session. Split out for the wire test. */ diff --git a/server/utils/ssh-cmd.ts b/server/utils/ssh-cmd.ts index 2c2840e..eb1197c 100644 --- a/server/utils/ssh-cmd.ts +++ b/server/utils/ssh-cmd.ts @@ -1,6 +1,3 @@ -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' @@ -8,27 +5,14 @@ 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: - * - `args`: the ssh option list (`-i -o ...`) ready to splice into a - * `spawn('ssh', [...args, target, command])` call - * - a `cleanup()` callback that synchronously removes the temp dir + * Decrypt the install's SSH private key and return it as an in-memory + * OpenSSH-format string, ready to hand to the `ssh2` client. * - * 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. + * The push transport runs in-process via `ssh2` (no `ssh` binary, which the + * Vercel runtime doesn't provide), so the key never touches disk: it's + * decrypted, used for one connection, and dropped when the function returns. */ -export async function loadSshArgsForInstall(installationId: number): Promise<{ - args: string[] - cleanup: () => void -}> { +export async function loadSshKeyForInstall(installationId: number): Promise { const db = useDb() const rows = await db.select({ privateKeyCiphertext: sshKey.privateKeyCiphertext, @@ -44,35 +28,5 @@ export async function loadSshArgsForInstall(installationId: number): Promise<{ 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 args = [ - '-i', keyPath, - '-o', `UserKnownHostsFile=${knownHostsPath}`, - '-o', 'StrictHostKeyChecking=accept-new', - '-o', 'IdentitiesOnly=yes', - '-o', 'BatchMode=yes', - '-o', 'ConnectTimeout=15', - ] - - return { - args, - cleanup: () => { - try { - rmSync(dir, { recursive: true, force: true }) - } - catch { - // best-effort; the temp dir will be cleaned up on process restart. - } - }, - } + return pkcs8ToOpenSshPrivate(pem, `synchub.to/${installationId}`) } diff --git a/test/unit/ssh2-receive-pack.spec.ts b/test/unit/ssh2-receive-pack.spec.ts new file mode 100644 index 0000000..c3945f6 --- /dev/null +++ b/test/unit/ssh2-receive-pack.spec.ts @@ -0,0 +1,130 @@ +import { spawn } from 'node:child_process' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { Server, utils as sshUtils } from 'ssh2' +import { ReceivePackSession } from '../../server/utils/git-wire/receive-pack' +import { ssh2ReceivePackFactory } from '../../server/utils/git-wire/receive-pack' +import { ZERO_SHA } from '../../server/utils/git-wire/refs' +import { generateKeypair, pkcs8ToOpenSshPrivate } from '../../server/utils/ssh-keypair' +import { fakeGithubFetch, GitFixture } from '../utils/git-wire' +import { fetchPack } from '../../server/utils/git-wire/upload-pack' + +async function* fromBuffer(b: Buffer): AsyncGenerator { + yield b +} + +async function drain(gen: AsyncGenerator): Promise { + const parts: Buffer[] = [] + for await (const c of gen) parts.push(c) + return Buffer.concat(parts) +} + +/** + * An in-process ssh2 server that authorises one public key and runs the real + * `git-receive-pack` against the given bare repo on exec. Mirrors the knot's + * `git@host: git-receive-pack ''` surface so the ssh2 factory is exercised + * end to end. + */ +function startKnotServer(authorizedPubKey: string, repoFor: (path: string) => string | null) { + const hostKey = sshUtils.generateKeyPairSync('ed25519').private + const parsed = sshUtils.parseKey(authorizedPubKey) + if (parsed instanceof Error) throw parsed + const allowed = Array.isArray(parsed) ? parsed[0]! : parsed + const allowedSSH = allowed.getPublicSSH() + + const server = new Server({ hostKeys: [hostKey] }, client => { + client.on('authentication', ctx => { + if (ctx.method === 'publickey' && ctx.key.algo === allowed.type && ctx.key.data.equals(allowedSSH)) { + ctx.accept() + return + } + ctx.reject() + }) + client.on('ready', () => { + client.on('session', accept => { + accept().once('exec', (acceptExec, _reject, info) => { + const match = info.command.match(/^git-receive-pack '(.+)'$/) + const repo = match ? repoFor(match[1]!) : null + const stream = acceptExec() + if (!repo) { + stream.stderr.write('repository not found\n') + stream.exit(128) + stream.end() + return + } + const child = spawn('git-receive-pack', [repo], { stdio: ['pipe', 'pipe', 'pipe'] }) + stream.pipe(child.stdin) + child.stdout.pipe(stream) + child.stderr.on('data', (d: Buffer) => stream.stderr.write(d)) + child.on('close', code => { stream.exit(code ?? 0); stream.end() }) + }) + }) + }) + }) + + return new Promise<{ port: number, close: () => void }>(resolve => { + server.listen(0, '127.0.0.1', () => { + resolve({ port: (server.address() as { port: number }).port, close: () => server.close() }) + }) + }) +} + +describe('ssh2ReceivePackFactory (against an in-process ssh2 knot)', () => { + let fx: GitFixture + let realFetch: typeof globalThis.fetch + let knotServer: { port: number, close: () => void } | null = null + + beforeEach(() => { + fx = new GitFixture() + realFetch = globalThis.fetch + }) + + afterEach(() => { + globalThis.fetch = realFetch + knotServer?.close() + knotServer = null + fx.cleanup() + }) + + async function packFor(ghBare: string, want: string, haves: string[]): Promise { + globalThis.fetch = fakeGithubFetch(new Map([['owner/repo', ghBare]])) as unknown as typeof globalThis.fetch + const { pack } = await fetchPack({ repoFullName: 'owner/repo', token: 't', want, haves, maxBytes: 1 << 30 }) + return drain(pack) + } + + it('pushes a ref to the knot over a real ssh2 connection', async () => { + const gh = fx.initBare('gh.git') + const work = fx.initWork('work') + const sha = fx.commit(work, 'a.txt', 'hello') + fx.pushTo(work, gh, 'HEAD:refs/heads/main') + const knot = fx.initBare('knot.git') + + const key = generateKeypair('synchub.to/1') + knotServer = await startKnotServer(key.publicKeyOpenSsh, p => (p === '/repo-did' ? knot : null)) + + const factory = ssh2ReceivePackFactory({ + host: '127.0.0.1', + port: knotServer.port, + repoPath: '/repo-did', + // Mirror production: the worker hands ssh2 the OpenSSH-format key that + // `loadSshKeyForInstall` derives from the stored PKCS#8 PEM. + privateKey: pkcs8ToOpenSshPrivate(key.privateKeyPem, 'synchub.to/1'), + }) + + const session = await ReceivePackSession.open(factory) + await session.push([{ ref: 'refs/heads/main', old: ZERO_SHA, next: sha }], fromBuffer(await packFor(gh, sha, []))) + + expect(fx.revParse(knot, 'refs/heads/main')).toBe(sha) + }) + + it('surfaces a connection failure as a caught WireError, not an uncaught throw', async () => { + const key = generateKeypair('synchub.to/1') + // Nothing listening on this port: connect() emits 'error' (ECONNREFUSED). + const factory = ssh2ReceivePackFactory({ + host: '127.0.0.1', + port: 1, + repoPath: '/repo-did', + privateKey: pkcs8ToOpenSshPrivate(key.privateKeyPem, 'synchub.to/1'), + }) + await expect(ReceivePackSession.open(factory)).rejects.toThrow(/ssh error|ECONNREFUSED|advertisement|end of stream/) + }) +}) -- 2.51.2