diff --git a/src/QUICServer.ts b/src/QUICServer.ts index f52e6124..880f6a45 100644 --- a/src/QUICServer.ts +++ b/src/QUICServer.ts @@ -1,8 +1,16 @@ -import type { Crypto, Host, Hostname, Port, RemoteInfo } from './types'; +import type { + Crypto, + Host, + Hostname, + Port, + PromiseDeconstructed, + RemoteInfo, +} from './types'; import type { Header } from './native/types'; import type QUICConnectionMap from './QUICConnectionMap'; import type { QUICConfig, TlsConfig } from './config'; import type { StreamCodeToReason, StreamReasonToCode } from './types'; +import type { QUICServerConnectionEvent } from './events'; import Logger from '@matrixai/logger'; import { running } from '@matrixai/async-init'; import { StartStop, ready } from '@matrixai/async-init/dist/StartStop'; @@ -14,6 +22,7 @@ import * as events from './events'; import * as utils from './utils'; import * as errors from './errors'; import QUICSocket from './QUICSocket'; +import { promise } from './utils'; /** * You must provide a error handler `addEventListener('error')`. @@ -316,6 +325,67 @@ class QUICServer extends EventTarget { }; } + /** + * This initiates sending UDP packets to a target client to open up a port in the NAT for the client to connect + * through. This will return early if the connection already exists or was established while polling. + */ + public async initHolePunch( + remoteInfo: RemoteInfo, + timeout: number = 5000, + ): Promise { + // Checking existing connections + for (const [, connection] of this.connectionMap.serverConnections) { + if ( + remoteInfo.host === connection.remoteHost && + remoteInfo.port === connection.remotePort + ) { + // Connection exists, return early + return true; + } + } + // We need to send a random data packet to the target until the process times out or a connection is established + let timedOut = false; + const timedOutProm = promise(); + const timeoutTimer = setTimeout(() => { + timedOut = true; + timedOutProm.resolveP(); + }, timeout); + let delay = 250; + let delayTimer: NodeJS.Timer | undefined; + let sleepProm: PromiseDeconstructed | undefined; + let established = false; + const establishedProm = promise(); + // Setting up established event checking + const handleEstablished = (event: QUICServerConnectionEvent) => { + const connection = event.detail; + if ( + remoteInfo.host === connection.remoteHost && + remoteInfo.port === connection.remotePort + ) { + // Clean up and resolve + this.removeEventListener('connection', handleEstablished); + established = true; + establishedProm.resolveP(); + } + }; + this.addEventListener('connection', handleEstablished); + try { + while (!established && !timedOut) { + await this.socket.send('hello!', remoteInfo.port, remoteInfo.host); + sleepProm = promise(); + delayTimer = setTimeout(() => sleepProm!.resolveP(), delay); + delay *= 2; + await Promise.race([sleepProm.p, establishedProm.p, timedOutProm.p]); + } + return established; + } finally { + clearTimeout(timeoutTimer); + if (delayTimer != null) clearTimeout(delayTimer); + sleepProm?.resolveP(); + this.removeEventListener('connection', handleEstablished); + } + } + /** * Creates a retry token. * This will embed peer host IP and DCID into the token. diff --git a/tests/QUICClient.test.ts b/tests/QUICClient.test.ts index 44ff03a1..2c10be72 100644 --- a/tests/QUICClient.test.ts +++ b/tests/QUICClient.test.ts @@ -1,13 +1,16 @@ import type { Crypto, Host, Port } from '@/types'; import type * as events from '@/events'; +import dgram from 'dgram'; import Logger, { LogLevel, StreamHandler, formatting } from '@matrixai/logger'; import { fc, testProp } from '@fast-check/jest'; import QUICClient from '@/QUICClient'; import QUICServer from '@/QUICServer'; import * as errors from '@/errors'; import { promise } from '@/utils'; +import QUICSocket from '@/QUICSocket'; import * as testsUtils from './utils'; import { tlsConfigWithCaArb } from './tlsUtils'; +import { sleep } from './utils'; describe(QUICClient.name, () => { const logger = new Logger(`${QUICClient.name} Test`, LogLevel.WARN, [ @@ -544,96 +547,134 @@ describe(QUICClient.name, () => { { numRuns: 3 }, ); }); - // Test('dual stack to dual stack', async () => { - // - // const { - // p: clientErrorEventP, - // rejectP: rejectClientErrorEventP - // } = utils.promise(); - // - // const { - // p: serverErrorEventP, - // rejectP: rejectServerErrorEventP - // } = utils.promise(); - // - // const { - // p: serverStopEventP, - // resolveP: resolveServerStopEventP - // } = utils.promise(); - // - // const { - // p: clientDestroyEventP, - // resolveP: resolveClientDestroyEventP - // } = utils.promise(); - // - // const { - // p: connectionEventP, - // resolveP: resolveConnectionEventP - // } = utils.promise(); - // - // const { - // p: streamEventP, - // resolveP: resolveStreamEventP - // } = utils.promise(); - // - // const server = new QUICServer({ - // crypto, - // logger: logger.getChild(QUICServer.name) - // }); - // server.addEventListener('error', handleServerErrorEvent); - // server.addEventListener('stop', handleServerStopEvent); - // - // // Every time I have a promise - // // I can attempt to await 4 promises - // // Then the idea is that this will resolve 4 times - // // Once for each time? - // // If you add once - // // Do you also - // - // // Fundamentally there could be multiple of these - // // This is not something I can put outside - // - // server.addEventListener( - // 'connection', - // (e: events.QUICServerConnectionEvent) => { - // resolveConnectionEventP(e); - // - // // const conn = e.detail; - // // conn.addEventListener('stream', (e: events.QUICConnectionStreamEvent) => { - // // resolveStreamEventP(e); - // // }, { once: true }); - // }, - // { once: true } - // ); - // - // // Dual stack server - // await server.start({ - // host: '::' as Host, - // port: 0 as Port - // }); - // // Dual stack client - // const client = await QUICClient.createQUICClient({ - // // host: server.host, - // // host: '::ffff:127.0.0.1' as Host, - // host: '::1' as Host, - // port: server.port, - // localHost: '::' as Host, - // crypto, - // logger: logger.getChild(QUICClient.name) - // }); - // client.addEventListener('error', handleClientErrorEvent); - // client.addEventListener('destroy', handleClientDestroyEvent); - // - // // await testsUtils.sleep(1000); - // - // await expect(connectionEventP).resolves.toBeInstanceOf(events.QUICServerConnectionEvent); - // await client.destroy(); - // await expect(clientDestroyEventP).resolves.toBeInstanceOf(events.QUICClientDestroyEvent); - // await server.stop(); - // await expect(serverStopEventP).resolves.toBeInstanceOf(events.QUICServerStopEvent); - // - // // No errors occurred - // await expect(Promise.race([clientErrorEventP, Promise.resolve()])).resolves.toBe(undefined); - // await expect(Promise.race([serverErrorEventP, Promise.resolve()])).resolves.toBe(undefined); - // }); + describe('UDP nat punching', () => { + testProp( + 'server can send init packets', + [tlsConfigWithCaArb], + async (tlsConfigProm) => { + const tlsConfig = await tlsConfigProm; + const server = new QUICServer({ + crypto, + logger: logger.getChild(QUICServer.name), + config: { + tlsConfig: tlsConfig.tlsConfig, + verifyPeer: false, + }, + }); + await server.start({ + host: '127.0.0.1' as Host, + }); + // @ts-ignore: kidnap protected property + const socket = server.socket; + const mockedSend = jest.spyOn(socket, 'send'); + // The server can send packets + // Should send 4 packets in 2 seconds + const result = await server.initHolePunch( + { + host: '127.0.0.1' as Host, + port: 55555 as Port, + }, + 2000, + ); + expect(mockedSend).toHaveBeenCalledTimes(4); + expect(result).toBeFalse(); + await server.stop(); + }, + { numRuns: 1 }, + ); + testProp( + 'init ends when connection establishes', + [tlsConfigWithCaArb], + async (tlsConfigProm) => { + const tlsConfig = await tlsConfigProm; + const server = new QUICServer({ + crypto, + logger: logger.getChild(QUICServer.name), + config: { + tlsConfig: tlsConfig.tlsConfig, + verifyPeer: false, + }, + }); + await server.start({ + host: '127.0.0.1' as Host, + }); + // @ts-ignore: kidnap protected property + const socket = server.socket; + // The server can send packets + // Should send 4 packets in 2 seconds + const clientProm = sleep(1000) + .then(async () => { + const client = await QUICClient.createQUICClient({ + host: '::ffff:127.0.0.1' as Host, + port: server.port, + localHost: '::' as Host, + localPort: 55556 as Port, + crypto, + logger: logger.getChild(QUICClient.name), + config: { + verifyPeer: false, + }, + }); + await client.destroy({ force: true }); + }) + .catch((e) => console.error(e)); + const result = await server.initHolePunch( + { + host: '127.0.0.1' as Host, + port: 55556 as Port, + }, + 2000, + ); + await clientProm; + expect(result).toBeTrue(); + await server.stop(); + }, + { numRuns: 1 }, + ); + testProp( + 'init returns with existing connections', + [tlsConfigWithCaArb], + async (tlsConfigProm) => { + const tlsConfig = await tlsConfigProm; + const server = new QUICServer({ + crypto, + logger: logger.getChild(QUICServer.name), + config: { + tlsConfig: tlsConfig.tlsConfig, + verifyPeer: false, + }, + }); + await server.start({ + host: '127.0.0.1' as Host, + }); + const client = await QUICClient.createQUICClient({ + host: '::ffff:127.0.0.1' as Host, + port: server.port, + localHost: '::' as Host, + localPort: 55556 as Port, + crypto, + logger: logger.getChild(QUICClient.name), + config: { + verifyPeer: false, + }, + }); + const result = await Promise.race([ + server.initHolePunch( + { + host: '127.0.0.1' as Host, + port: 55556 as Port, + }, + 2000, + ), + sleep(10).then(() => { + throw Error('timed out'); + }), + ]); + expect(result).toBeTrue(); + await client.destroy({ force: true }); + await server.stop(); + }, + { numRuns: 1 }, + ); + }); });