| … | … | @@ -86,15 +86,42 @@ export class Client { |
| 86 | 86 | } |
| 87 | 87 | |
| 88 | 88 | /** |
| 89 | * Connect to a remote daemon via Hyperswarm. Joins the topic derived | |
| 90 | * from the server's public key, waits for the first connection, then | |
| 91 | * attaches a Protomux RPC channel. | |
| 89 | * Connect to a remote daemon via Hyperswarm. Joins the topic derived from | |
| 90 | * `serverKey`, waits for a connection, attaches a Protomux RPC channel, and | |
| 91 | * (when `opts.verify` is given) confirms the peer that answered is the one | |
| 92 | * that was asked for. | |
| 93 | * | |
| 94 | * ⚠ `serverKey` IS NOT AN IDENTITY — IT IS ONLY A RENDEZVOUS ADDRESS. | |
| 95 | * | |
| 96 | * The topic is `hash(serverKey + salt)`, and SwarmTransport.listen() creates | |
| 97 | * its Hyperswarm with NO keyPair, so a server's Noise key is random per | |
| 98 | * process and never equals the `serverKey` clients use to find it. Anything | |
| 99 | * joined to that topic as a server can therefore answer, and there is no | |
| 100 | * cryptographic binding to check `conn.remotePublicKey` against. | |
| 101 | * | |
| 102 | * That is not hypothetical. In a pear-git-v3 fleet, a mirror joins its | |
| 103 | * ORIGIN's topic (the topic travels in the deployment bundle), so it is a | |
| 104 | * server on the exact topic clients use to reach the origin. Measured | |
| 105 | * 2026-08-08: `connectSwarm({serverKey: <tabertron>})` issued ON stortron was | |
| 106 | * answered by STORTRON ITSELF 6 times out of 6. It asked itself for the | |
| 107 | * origin's release index, got its own, and every artifact fetch then failed | |
| 108 | * with "no holder" — a wrong ANSWER, not a transport error, so nothing looked | |
| 109 | * broken. resync-from-origin on the same path hung twice for 300 s. | |
| 110 | * | |
| 111 | * `opts.verify` closes that hole at the application layer, where identity | |
| 112 | * actually lives. It receives a connected Client and returns truthy to accept. | |
| 113 | * Rejected peers are destroyed and the wait continues until `timeout`, so a | |
| 114 | * caller reaches the intended daemon or fails loudly, never silently talks to | |
| 115 | * the wrong one. It is layered as a callback rather than a built-in `status` | |
| 116 | * check because hyper-svc is generic — which handler proves identity is the | |
| 117 | * consumer's business, and this stays compatible with servers of any version. | |
| 92 | 118 | * |
| 93 | 119 | * @param {object} opts |
| 94 | * @param {string|Buffer} opts.serverKey - server's Noise public key (hex or Buffer) | |
| 120 | * @param {string|Buffer} opts.serverKey - rendezvous key; derives the topic | |
| 95 | 121 | * @param {string} [opts.topicSalt='pear-git-v3'] - salt for topic derivation |
| 96 | 122 | * @param {object} [opts.swarm] - Hyperswarm constructor options (e.g. testnet bootstrap) |
| 97 | * @param {number} [opts.timeout=30000] - connection timeout ms | |
| 123 | * @param {number} [opts.timeout=30000] - total ms to find an ACCEPTED peer | |
| 124 | * @param {(client: Client) => Promise<boolean>} [opts.verify] - identity gate | |
| 98 | 125 | * @returns {Promise<Client>} |
| 99 | 126 | */ |
| 100 | 127 | static async connectSwarm (opts = {}) { |
| … | … | @@ -105,25 +132,81 @@ export class Client { |
| 105 | 132 | const salt = opts.topicSalt || 'pear-git-v3' |
| 106 | 133 | const topic = crypto.hash(b4a.concat([serverKey, b4a.from(salt)])) |
| 107 | 134 | const timeout = opts.timeout || 30_000 |
| 135 | const verify = typeof opts.verify === 'function' ? opts.verify : null | |
| 108 | 136 | |
| 109 | 137 | const swarm = new Hyperswarm(opts.swarm || {}) |
| 110 | 138 | const discovery = swarm.join(topic, { server: false, client: true }) |
| 111 | 139 | |
| 112 | const conn = await new Promise((resolve, reject) => { | |
| 113 | const timer = setTimeout(() => { | |
| 114 | reject(new Error('swarm-rpc: connection timeout')) | |
| 115 | }, timeout) | |
| 116 | swarm.on('connection', (c) => { | |
| 117 | clearTimeout(timer) | |
| 118 | resolve(c) | |
| 119 | }) | |
| 120 | discovery.flushed().catch(reject) | |
| 121 | }) | |
| 140 | let rejected = 0 | |
| 141 | let client = null | |
| 142 | try { | |
| 143 | client = await new Promise((resolve, reject) => { | |
| 144 | let settled = false | |
| 145 | // NOTE: onConnection stays attached for the swarm's whole life — it is | |
| 146 | // deliberately NOT removed here. Connections can still arrive after a | |
| 147 | // peer is accepted, and a NoiseSecretStream with no 'error' listener | |
| 148 | // throws on reset, so detaching left later arrivals unguarded and | |
| 149 | // crashed the process at teardown with ECONNRESET. The listener dies | |
| 150 | // with the swarm when the returned Client is closed. | |
| 151 | const finish = (fn, arg) => { | |
| 152 | if (settled) return | |
| 153 | settled = true | |
| 154 | clearTimeout(timer) | |
| 155 | fn(arg) | |
| 156 | } | |
| 157 | const timer = setTimeout(() => { | |
| 158 | finish(reject, new Error('swarm-rpc: connection timeout' + | |
| 159 | (rejected ? ' (' + rejected + ' peer(s) answered but failed verification — ' + | |
| 160 | 'the requested serverKey is a topic, not an identity, so other peers on ' + | |
| 161 | 'that topic can answer)' : ''))) | |
| 162 | }, timeout) | |
| 163 | ||
| 164 | const onConnection = (c) => { | |
| 165 | // Routine, and load-bearing: a NoiseSecretStream with no 'error' | |
| 166 | // listener throws on ECONNRESET, and this path DESTROYS connections | |
| 167 | // by design (every peer that fails verification, plus any that arrive | |
| 168 | // after one is accepted). Without this the first rejected peer takes | |
| 169 | // the whole process down. SwarmTransport._onConnection takes the same | |
| 170 | // posture server-side. | |
| 171 | c.on('error', () => {}) | |
| 172 | if (settled) { try { c.destroy() } catch {} ; return } | |
| 173 | const candidate = new Client({ | |
| 174 | kind: 'swarm', | |
| 175 | swarm, | |
| 176 | conn: c, | |
| 177 | rpc: attachRpcClient(Protomux.from(c)) | |
| 178 | }) | |
| 179 | if (!verify) return finish(resolve, candidate) | |
| 180 | ||
| 181 | // Verified off the critical path: keep listening while this runs, so a | |
| 182 | // slow or lying peer cannot stall a good one behind it. | |
| 183 | Promise.resolve() | |
| 184 | .then(() => verify(candidate)) | |
| 185 | .then((ok) => { | |
| 186 | if (ok) return finish(resolve, candidate) | |
| 187 | rejected++ | |
| 188 | try { c.destroy() } catch {} | |
| 189 | }) | |
| 190 | .catch(() => { | |
| 191 | rejected++ | |
| 192 | try { c.destroy() } catch {} | |
| 193 | }) | |
| 194 | } | |
| 122 | 195 | |
| 123 | const mux = Protomux.from(conn) | |
| 124 | const rpc = attachRpcClient(mux) | |
| 196 | swarm.on('connection', onConnection) | |
| 197 | discovery.flushed().catch((err) => finish(reject, err)) | |
| 198 | }) | |
| 199 | } catch (err) { | |
| 200 | // Destroy the swarm on EVERY failure path. It used to be left running: a | |
| 201 | // Hyperswarm holds the event loop open, so each failed connect leaked one | |
| 202 | // and the process never exited. That is what hung pear-git-v3's | |
| 203 | // basic.test.js when a mirror's origin was unreachable — 123/123 tests | |
| 204 | // passed, then the run never terminated. | |
| 205 | try { await swarm.destroy() } catch {} | |
| 206 | throw err | |
| 207 | } | |
| 125 | 208 | |
| 126 | return new Client({ kind: 'swarm', swarm, conn, rpc }) | |
| 209 | return client | |
| 127 | 210 | } |
| 128 | 211 | |
| 129 | 212 | constructor (opts) { |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "hyper-svc", |
| 3 | "version": "0.1.8", | |
| 3 | "version": "0.1.9", | |
| 4 | 4 | "description": "Reusable per-app daemon library \u2014 UDS + HTTP for local RPC, Hyperswarm for peer mirroring", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| … | … | @@ -0,0 +1,151 @@ |
| 1 | // connectSwarm's identity gate + the failed-connect swarm leak. | |
| 2 | // | |
| 3 | // Context (measured 2026-08-08, not hypothetical): `serverKey` only derives the | |
| 4 | // topic — SwarmTransport.listen() builds its Hyperswarm with NO keyPair, so a | |
| 5 | // server's Noise key is random per process and never equals the serverKey. | |
| 6 | // In a pear-git-v3 fleet a mirror joins its ORIGIN's topic, making it a server | |
| 7 | // on the very topic clients use to reach the origin. On stortron, | |
| 8 | // connectSwarm({serverKey: <tabertron>}) was answered by STORTRON ITSELF 6/6: | |
| 9 | // it asked itself for the origin's release index, got its own, and every | |
| 10 | // artifact fetch failed "no holder". A wrong ANSWER, not a transport error. | |
| 11 | ||
| 12 | import test from 'brittle' | |
| 13 | import b4a from 'b4a' | |
| 14 | import crypto from 'hypercore-crypto' | |
| 15 | import createTestnet from '@hyperswarm/testnet' | |
| 16 | import Corestore from 'corestore' | |
| 17 | import { mkdtempSync } from '#node:fs' | |
| 18 | import { join } from '#node:path' | |
| 19 | import { tmpdir } from '#node:os' | |
| 20 | ||
| 21 | import { Service } from '../lib/service.js' | |
| 22 | import { Client } from '../lib/client.js' | |
| 23 | ||
| 24 | const tmpStore = () => new Corestore(mkdtempSync(join(tmpdir(), 'hsvc-verify-'))) | |
| 25 | ||
| 26 | // Two services sharing ONE topic — the prod shape (origin + mirror). | |
| 27 | async function twoOnOneTopic (t, testnet, topic) { | |
| 28 | const made = [] | |
| 29 | for (const name of ['origin', 'impostor']) { | |
| 30 | const store = tmpStore() | |
| 31 | const svc = new Service({ | |
| 32 | name, | |
| 33 | corestore: store, | |
| 34 | swarm: { topic, swarm: { bootstrap: testnet.bootstrap } } | |
| 35 | }) | |
| 36 | svc.handle('status', async () => ({ identity: name })) | |
| 37 | svc.handle('who', async () => name) | |
| 38 | await svc.listen() | |
| 39 | t.teardown(async () => { | |
| 40 | try { await svc.close() } catch {} | |
| 41 | try { await store.close() } catch {} | |
| 42 | }) | |
| 43 | made.push(svc) | |
| 44 | } | |
| 45 | return made | |
| 46 | } | |
| 47 | ||
| 48 | test('without verify, ANY peer on the topic can answer (the bug)', async (t) => { | |
| 49 | const testnet = await createTestnet(2, t.teardown) | |
| 50 | const serverKey = b4a.alloc(32, 'unverified-topic') | |
| 51 | const topic = crypto.hash(b4a.concat([serverKey, b4a.from('pear-git-v3')])) | |
| 52 | await twoOnOneTopic(t, testnet, topic) | |
| 53 | ||
| 54 | const client = await Client.connectSwarm({ | |
| 55 | serverKey, topicSalt: 'pear-git-v3', swarm: { bootstrap: testnet.bootstrap } | |
| 56 | }) | |
| 57 | t.teardown(async () => { try { await client.close() } catch {} }) | |
| 58 | ||
| 59 | const who = await client.call('who') | |
| 60 | // Deliberately NOT asserting which one — that is the point. Whoever gets | |
| 61 | // there first answers, and the caller cannot tell. Documented, not desired. | |
| 62 | t.ok(who === 'origin' || who === 'impostor', 'some peer answered: ' + who) | |
| 63 | }) | |
| 64 | ||
| 65 | test('verify rejects the wrong peer and keeps waiting for the right one', async (t) => { | |
| 66 | const testnet = await createTestnet(2, t.teardown) | |
| 67 | const serverKey = b4a.alloc(32, 'verified-topic') | |
| 68 | const topic = crypto.hash(b4a.concat([serverKey, b4a.from('pear-git-v3')])) | |
| 69 | await twoOnOneTopic(t, testnet, topic) | |
| 70 | ||
| 71 | const seen = [] | |
| 72 | const client = await Client.connectSwarm({ | |
| 73 | serverKey, | |
| 74 | topicSalt: 'pear-git-v3', | |
| 75 | timeout: 20000, | |
| 76 | swarm: { bootstrap: testnet.bootstrap }, | |
| 77 | verify: async (c) => { | |
| 78 | const st = await c.call('status', {}) | |
| 79 | seen.push(st.identity) | |
| 80 | return st.identity === 'origin' | |
| 81 | } | |
| 82 | }) | |
| 83 | t.teardown(async () => { try { await client.close() } catch {} }) | |
| 84 | ||
| 85 | t.is(await client.call('who'), 'origin', 'reached the daemon that was ASKED for') | |
| 86 | t.ok(seen.includes('origin'), 'verify ran against the accepted peer') | |
| 87 | }) | |
| 88 | ||
| 89 | test('all peers failing verification is a loud timeout, not a wrong answer', async (t) => { | |
| 90 | const testnet = await createTestnet(2, t.teardown) | |
| 91 | const serverKey = b4a.alloc(32, 'nobody-matches') | |
| 92 | const topic = crypto.hash(b4a.concat([serverKey, b4a.from('pear-git-v3')])) | |
| 93 | await twoOnOneTopic(t, testnet, topic) | |
| 94 | ||
| 95 | await t.exception( | |
| 96 | () => Client.connectSwarm({ | |
| 97 | serverKey, | |
| 98 | topicSalt: 'pear-git-v3', | |
| 99 | timeout: 3000, | |
| 100 | swarm: { bootstrap: testnet.bootstrap }, | |
| 101 | verify: async () => false | |
| 102 | }), | |
| 103 | /timeout/, | |
| 104 | 'fails rather than silently returning the wrong daemon' | |
| 105 | ) | |
| 106 | }) | |
| 107 | ||
| 108 | test('a verify that throws is treated as rejection, not a crash', async (t) => { | |
| 109 | const testnet = await createTestnet(2, t.teardown) | |
| 110 | const serverKey = b4a.alloc(32, 'verify-throws') | |
| 111 | const topic = crypto.hash(b4a.concat([serverKey, b4a.from('pear-git-v3')])) | |
| 112 | await twoOnOneTopic(t, testnet, topic) | |
| 113 | ||
| 114 | await t.exception( | |
| 115 | () => Client.connectSwarm({ | |
| 116 | serverKey, | |
| 117 | topicSalt: 'pear-git-v3', | |
| 118 | timeout: 3000, | |
| 119 | swarm: { bootstrap: testnet.bootstrap }, | |
| 120 | verify: async () => { throw new Error('handler missing on that peer') } | |
| 121 | }), | |
| 122 | /timeout/, | |
| 123 | 'a throwing verify cannot escape as an unhandled rejection' | |
| 124 | ) | |
| 125 | }) | |
| 126 | ||
| 127 | test('a failed connect does not leak the Hyperswarm', async (t) => { | |
| 128 | // The leak that hung pear-git-v3's basic.test.js: 123/123 passed, then the | |
| 129 | // process never exited because each failed connect left a swarm holding the | |
| 130 | // event loop open. | |
| 131 | const testnet = await createTestnet(2, t.teardown) | |
| 132 | const serverKey = b4a.alloc(32, 'nobody-home') // nothing serves this topic | |
| 133 | ||
| 134 | const before = process._getActiveHandles ? process._getActiveHandles().length : null | |
| 135 | await t.exception( | |
| 136 | () => Client.connectSwarm({ | |
| 137 | serverKey, topicSalt: 'pear-git-v3', timeout: 1500, | |
| 138 | swarm: { bootstrap: testnet.bootstrap } | |
| 139 | }), | |
| 140 | /timeout/, | |
| 141 | 'times out with nobody serving' | |
| 142 | ) | |
| 143 | // Destruction is awaited inside connectSwarm's catch, so by here the swarm's | |
| 144 | // sockets are already gone rather than merely scheduled for teardown. | |
| 145 | if (before !== null) { | |
| 146 | const after = process._getActiveHandles().length | |
| 147 | t.ok(after <= before + 2, 'no swarm left running (handles ' + before + ' → ' + after + ')') | |
| 148 | } else { | |
| 149 | t.pass('handle introspection unavailable in this runtime') | |
| 150 | } | |
| 151 | }) |