From 44520992a91e94db4dba3df9039222d2d976c94c Mon Sep 17 00:00:00 2001 From: HDegroote <75906619+HDegroote@users.noreply.github.com> Date: Wed, 3 Jan 2024 17:53:33 +0100 Subject: [PATCH 1/6] Support --private mode for server and client --- client.js | 39 ++++++++++++++++++++++++--------------- package.json | 1 + server.js | 37 ++++++++++++++++++++++++++++--------- 3 files changed, 53 insertions(+), 24 deletions(-) diff --git a/client.js b/client.js index abbca1b..93ea396 100644 --- a/client.js +++ b/client.js @@ -2,13 +2,14 @@ const HyperDHT = require('hyperdht') const net = require('net') const argv = require('minimist')(process.argv.slice(2)) +const b4a = require('b4a') const libNet = require('@hyper-cmd/lib-net') const libUtils = require('@hyper-cmd/lib-utils') const libKeys = require('@hyper-cmd/lib-keys') const goodbye = require('graceful-goodbye') const connPiper = libNet.connPiper -const helpMsg = 'Usage:\nhypertele -p port_listen -u unix_socket ?--address service_address ?-c conf.json ?-i identity.json ?-s peer_key' +const helpMsg = 'Usage:\nhypertele -p port_listen -u unix_socket ?--address service_address ?-c conf.json ?-i identity.json ?-s peer_key ?--private' if (argv.help) { console.log(helpMsg) @@ -28,10 +29,30 @@ const conf = {} const target = argv.u ? argv.u : +argv.p -if (argv.s) { - conf.peer = libUtils.resolveHostToKey([], argv.s) +let keyPair = null +if (argv.i) { + keyPair = libUtils.resolveIdentity([], argv.i) + + if (!keyPair) { + console.error('Error: identity file invalid') + process.exit(-1) + } + + keyPair = libKeys.parseKeyPair(keyPair) } +conf.private = argv.private != null +if (conf.private) { + if (keyPair != null) throw new Error('The --private flag is not compatible with the -i(dentity) flag, since the identity is derived from the peer key') + const seed = argv.s + keyPair = HyperDHT.keyPair(b4a.from(seed, 'hex')) +} + +if (argv.s) { + conf.peer = conf.private + ? keyPair.publicKey + : libUtils.resolveHostToKey([], argv.s)} + if (argv.c) { libUtils.readConf(conf, argv.c) } @@ -52,18 +73,6 @@ if (!peer) { const debug = argv.debug -let keyPair = null -if (argv.i) { - keyPair = libUtils.resolveIdentity([], argv.i) - - if (!keyPair) { - console.error('Error: identity file invalid') - process.exit(-1) - } - - keyPair = libKeys.parseKeyPair(keyPair) -} - const stats = {} const dht = new HyperDHT({ diff --git a/package.json b/package.json index 8f5cdaa..19d6f17 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "@hyper-cmd/lib-keys": "https://github.com/holepunchto/hyper-cmd-lib-keys#v0.0.2", "@hyper-cmd/lib-net": "https://github.com/holepunchto/hyper-cmd-lib-net#v0.0.8", "@hyper-cmd/lib-utils": "https://github.com/holepunchto/hyper-cmd-lib-utils#v0.0.2", + "b4a": "^1.6.4", "graceful-goodbye": "^1.3.0", "hyperdht": "^6.11.0", "minimist": "^1.2.5" diff --git a/server.js b/server.js index b28322f..66056c1 100644 --- a/server.js +++ b/server.js @@ -1,6 +1,7 @@ #!/usr/bin/env node const HyperDHT = require('hyperdht') const net = require('net') +const b4a = require('b4a') const argv = require('minimist')(process.argv.slice(2)) const libNet = require('@hyper-cmd/lib-net') const libUtils = require('@hyper-cmd/lib-utils') @@ -8,7 +9,7 @@ const libKeys = require('@hyper-cmd/lib-keys') const goodbye = require('graceful-goodbye') const connPiper = libNet.connPiper -const helpMsg = 'Usage:\nhypertele-server -l service_port -u unix_socket ?--address service_address ?-c conf.json ?--seed seed ?--cert-skip' +const helpMsg = 'Usage:\nhypertele-server -l service_port -u unix_socket ?--address service_address ?-c conf.json ?--seed seed ?--cert-skip ?--private' if (argv.help) { console.log(helpMsg) @@ -52,6 +53,12 @@ if (conf.allow) { conf.allow = libKeys.prepKeyList(conf.allow) } +conf.private = false +if (argv.private) { + if (conf.allow) throw new Error('--private flag is not compatible with an allow list, as the private key derived from the seed is the capability') + conf.private = true +} + const debug = argv.debug const seed = Buffer.from(conf.seed, 'hex') @@ -63,14 +70,22 @@ const stats = {} const destIp = argv.address || '127.0.0.1' -const server = dht.createServer({ - firewall: (remotePublicKey, remoteHandshakePayload) => { - if (conf.allow && !libKeys.checkAllowList(conf.allow, remotePublicKey)) { - return true - } +const privateFirewall = (remotePublicKey) => { + return !b4a.equals(remotePublicKey, keyPair.publicKey) +} + +const allowListFirewall = (remotePublicKey, remoteHandshakePayload) => { + if (conf.allow && !libKeys.checkAllowList(conf.allow, remotePublicKey)) { + return true + } - return false - }, + return false +} + +const firewall = conf.private ? privateFirewall : allowListFirewall + +const server = dht.createServer({ + firewall, reusableSocket: true }, c => { connPiper(c, () => { @@ -83,7 +98,11 @@ const server = dht.createServer({ }) server.listen(keyPair).then(() => { - console.log('hypertele:', keyPair.publicKey.toString('hex')) + if (conf.private) { + console.log(`hypertele (private): connect with seed ${b4a.toString(seed, 'hex')} (listening on ${b4a.toString(keyPair.publicKey, 'hex')})`) + } else { + console.log('hypertele:', keyPair.publicKey.toString('hex')) + } }) if (debug) { From 43281efbc27c606ca2a2bd1e60d270f47879ae36 Mon Sep 17 00:00:00 2001 From: HDegroote <75906619+HDegroote@users.noreply.github.com> Date: Thu, 4 Jan 2024 13:59:23 +0100 Subject: [PATCH 2/6] Add end-to-end tests for basic proxy + private mode --- client.js | 12 +++- end-to-end-tests.js | 155 ++++++++++++++++++++++++++++++++++++++++++++ package.json | 5 +- server.js | 8 ++- 4 files changed, 176 insertions(+), 4 deletions(-) create mode 100644 end-to-end-tests.js diff --git a/client.js b/client.js index 93ea396..89919e3 100644 --- a/client.js +++ b/client.js @@ -16,7 +16,7 @@ if (argv.help) { process.exit(-1) } -if (!argv.u && !+argv.p) { +if (!argv.u && argv.p == null) { console.error('Error: proxy port invalid') process.exit(-1) } @@ -48,6 +48,12 @@ if (conf.private) { keyPair = HyperDHT.keyPair(b4a.from(seed, 'hex')) } +// Unofficial opt, only used for tests +let bootstrap = null +if (argv.bootstrap) { + bootstrap = [{ host: '127.0.0.1', port: argv.bootstrap }] +} + if (argv.s) { conf.peer = conf.private ? keyPair.publicKey @@ -76,6 +82,7 @@ const debug = argv.debug const stats = {} const dht = new HyperDHT({ + bootstrap, keyPair }) @@ -101,7 +108,8 @@ if (argv.u) { } else { const targetHost = argv.address || '127.0.0.1' proxy.listen(target, targetHost, () => { - console.log(`Server ready @${targetHost}:${target}`) + const { address, port } = proxy.address() + console.log(`Server ready @${address}:${port}`) }) } diff --git a/end-to-end-tests.js b/end-to-end-tests.js new file mode 100644 index 0000000..9471d10 --- /dev/null +++ b/end-to-end-tests.js @@ -0,0 +1,155 @@ +const { spawn } = require('node:child_process') +const { once } = require('node:events') +const http = require('http') +const createTestnet = require('hyperdht/testnet') +const test = require('brittle') +const HyperDHT = require('hyperdht') +const b4a = require('b4a') + +test('Can proxy in private mode', async t => { + const { bootstrap } = await createTestnet(3, t.teardown) + const portToProxy = await setupDummyServer(t.teardown) + const seed = 'a'.repeat(64) + + await setupHyperteleServer(portToProxy, seed, bootstrap, t, { isPrivate: true }) + const clientPort = await setupHyperteleClient(seed, bootstrap, t, { isPrivate: true }) + + const res = await request(clientPort) + t.is(res.data, 'You got served', 'Proxy works') +}) + +test('Cannot access private-mode server with public key', async t => { + const { bootstrap } = await createTestnet(3, t.teardown) + const portToProxy = await setupDummyServer(t.teardown) + const seed = 'a'.repeat(64) + const keypair = HyperDHT.keyPair(b4a.from(seed, 'hex')) + const pubKey = b4a.toString(keypair.publicKey, 'hex') + + await setupHyperteleServer(portToProxy, seed, bootstrap, t, { isPrivate: true }) + const clientPort = await setupHyperteleClient(pubKey, bootstrap, t, { isPrivate: false }) + + // Could also be a socket hangup if more time is given + await t.exception(async () => await request(clientPort), /Request timeout/) +}) + +test('Can proxy in non-private mode', async t => { + const { bootstrap } = await createTestnet(3, t.teardown) + const portToProxy = await setupDummyServer(t.teardown) + const seed = 'a'.repeat(64) + const keypair = HyperDHT.keyPair(b4a.from(seed, 'hex')) + const pubKey = b4a.toString(keypair.publicKey, 'hex') + + await setupHyperteleServer(portToProxy, seed, bootstrap, t, { isPrivate: false }) + const clientPort = await setupHyperteleClient(pubKey, bootstrap, t, { isPrivate: false }) + + const res = await request(clientPort) + t.is(res.data, 'You got served', 'Proxy works') +}) + +async function setupDummyServer (teardown) { + const server = http.createServer(async (req, res) => { + res.setHeader('Content-Type', 'text/html; charset=utf-8') + res.end('You got served') + }) + teardown(() => server.close()) + + server.listen({ port: 0, host: '127.0.0.1' }) + await once(server, 'listening') + return server.address().port +} + +async function setupHyperteleServer (portToProxy, seed, bootstrap, t, { isPrivate = false } = {}) { + const args = [ + './server.js', + '-l', + portToProxy, + '--seed', + seed, + '--bootstrap', + bootstrap[0].port + ] + if (isPrivate) args.push('--private') + + const setupServer = spawn('node', args) + t.teardown(() => setupServer.kill('SIGKILL')) + + setupServer.stderr.on('data', (data) => { + console.error(data.toString()) + t.fail('Failed to setup hypertele server') + }) + + await new Promise(resolve => { + setupServer.stdout.on('data', (data) => { + if (data.includes('hypertele')) { + resolve() + } + }) + }) +} + +async function setupHyperteleClient (seed, bootstrap, t, { isPrivate = false } = {}) { + const args = [ + './client.js', + '-p', + 0, // random + '-s', + seed, + '--bootstrap', + bootstrap[0].port + ] + if (isPrivate) args.push('--private') + + const setupClient = spawn('node', args) + t.teardown(() => setupClient.kill('SIGKILL')) + + setupClient.stderr.on('data', (data) => { + console.error(data.toString()) + t.fail('Failed to setup hypertele client') + }) + + const clientPort = await new Promise(resolve => { + setupClient.stdout.on('data', (data) => { + const msg = data.toString() + if (msg.includes('Server ready')) { + const port = msg.slice(msg.search(':') + 1) + resolve(port) + } + }) + }) + + return clientPort +} + +async function request (port, { msTimeout = 500 } = {}) { + const link = `http://127.0.0.1:${port}` + + return new Promise((resolve, reject) => { + const req = http.get(link, { + headers: { + Connection: 'close' + } + }) + + req.setTimeout(msTimeout, + () => { + reject(new Error('Request timeout')) + req.destroy() + } + ) + + req.on('error', reject) + req.on('response', function (res) { + let buf = '' + + res.setEncoding('utf-8') + + res.on('data', function (data) { + buf += data + }) + + res.on('end', function () { + resolve({ status: res.statusCode, data: buf }) + }) + }) + }) +} diff --git a/package.json b/package.json index 19d6f17..bec0489 100644 --- a/package.json +++ b/package.json @@ -25,5 +25,8 @@ "bugs": { "url": "https://github.com/bitfinexcom/hypertele/issues" }, - "homepage": "https://github.com/bitfinexcom/hypertele" + "homepage": "https://github.com/bitfinexcom/hypertele", + "devDependencies": { + "brittle": "^3.3.2" + } } diff --git a/server.js b/server.js index 66056c1..fe6914b 100644 --- a/server.js +++ b/server.js @@ -59,11 +59,17 @@ if (argv.private) { conf.private = true } +// Unofficial opt, only used for tests +let bootstrap = null +if (argv.bootstrap) { + bootstrap = [{ host: '127.0.0.1', port: argv.bootstrap }] +} + const debug = argv.debug const seed = Buffer.from(conf.seed, 'hex') -const dht = new HyperDHT() +const dht = new HyperDHT({ bootstrap }) const keyPair = HyperDHT.keyPair(seed) const stats = {} From 55d2800b71c8b86e2e352d736f46117267e91b61 Mon Sep 17 00:00:00 2001 From: HDegroote <75906619+HDegroote@users.noreply.github.com> Date: Thu, 4 Jan 2024 15:39:58 +0100 Subject: [PATCH 3/6] Fix formatting --- client.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client.js b/client.js index 93ea396..72ce29b 100644 --- a/client.js +++ b/client.js @@ -51,7 +51,8 @@ if (conf.private) { if (argv.s) { conf.peer = conf.private ? keyPair.publicKey - : libUtils.resolveHostToKey([], argv.s)} + : libUtils.resolveHostToKey([], argv.s) +} if (argv.c) { libUtils.readConf(conf, argv.c) From 043e4459e1337c43429a31d30c1320e9bea3e7b1 Mon Sep 17 00:00:00 2001 From: HDegroote <75906619+HDegroote@users.noreply.github.com> Date: Thu, 4 Jan 2024 15:49:08 +0100 Subject: [PATCH 4/6] Document --private option --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 5399cbe..8e5d5ec 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ options: --cert-skip : skip certificate check when connecting to the service --seed SEED : seed (command-line) --compress : enable chunk compression +--private : make the proxy private (do not leak the access capability to the DHT) ``` ```sh @@ -104,6 +105,7 @@ options: -s SERVER_PEER_KEY : server peer key (command-line) -i keypair.json : keypair file --compress : enable chunk compression +--private : access a private hypertele server (expects -s to contain the server's seed instead of the public key) ``` Read more about using identities here: https://github.com/prdn/hyper-cmd-docs/blob/main/identity.md From 35c9f9bb4284630814ce53bc9eb3238e0ace50d5 Mon Sep 17 00:00:00 2001 From: HDegroote <75906619+HDegroote@users.noreply.github.com> Date: Thu, 4 Jan 2024 16:06:26 +0100 Subject: [PATCH 5/6] Add CI --- .github/workflows/ci.yml | 22 ++++++++++++++++++++++ package.json | 6 +++++- 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..377efde --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,22 @@ +name: Build Status +on: + push: + branches: + - main + pull_request: + branches: + - main +jobs: + build: + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 https://github.com/actions/checkout/releases/tag/v4.1.1 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2 https://github.com/actions/setup-node/releases/tag/v3.8.2 + with: + node-version: 18 + - run: npm install + - run: npm test diff --git a/package.json b/package.json index bec0489..e12697b 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,10 @@ }, "homepage": "https://github.com/bitfinexcom/hypertele", "devDependencies": { - "brittle": "^3.3.2" + "brittle": "^3.3.2", + "standard": "^17.1.0" + }, + "scripts": { + "test": "standard && brittle end-to-end-tests.js" } } From fc755887673a97c9a0cf4d1bef39ec55a6e8fc67 Mon Sep 17 00:00:00 2001 From: HDegroote <75906619+HDegroote@users.noreply.github.com> Date: Sat, 6 Jan 2024 15:07:17 +0100 Subject: [PATCH 6/6] Move tests to dir + make executables' path absolute --- package.json | 2 +- end-to-end-tests.js => test/end-to-end-tests.js | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) rename end-to-end-tests.js => test/end-to-end-tests.js (93%) diff --git a/package.json b/package.json index e12697b..65408a1 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,6 @@ "standard": "^17.1.0" }, "scripts": { - "test": "standard && brittle end-to-end-tests.js" + "test": "standard && brittle test/end-to-end-tests.js" } } diff --git a/end-to-end-tests.js b/test/end-to-end-tests.js similarity index 93% rename from end-to-end-tests.js rename to test/end-to-end-tests.js index 9471d10..9e79f99 100644 --- a/end-to-end-tests.js +++ b/test/end-to-end-tests.js @@ -1,11 +1,16 @@ -const { spawn } = require('node:child_process') -const { once } = require('node:events') +const { spawn } = require('child_process') +const { once } = require('events') +const path = require('path') const http = require('http') const createTestnet = require('hyperdht/testnet') const test = require('brittle') const HyperDHT = require('hyperdht') const b4a = require('b4a') +const MAIN_DIR = path.dirname(__dirname) +const SERVER_EXECUTABLE = path.join(MAIN_DIR, 'server.js') +const CLIENT_EXECUTABLE = path.join(MAIN_DIR, 'client.js') + test('Can proxy in private mode', async t => { const { bootstrap } = await createTestnet(3, t.teardown) const portToProxy = await setupDummyServer(t.teardown) @@ -60,7 +65,7 @@ async function setupDummyServer (teardown) { async function setupHyperteleServer (portToProxy, seed, bootstrap, t, { isPrivate = false } = {}) { const args = [ - './server.js', + SERVER_EXECUTABLE, '-l', portToProxy, '--seed', @@ -89,7 +94,7 @@ async function setupHyperteleServer (portToProxy, seed, bootstrap, t, { isPrivat async function setupHyperteleClient (seed, bootstrap, t, { isPrivate = false } = {}) { const args = [ - './client.js', + CLIENT_EXECUTABLE, '-p', 0, // random '-s',