Skip to content

Commit

Permalink
feat: added QUICServer.initHolePunch for server side hole punching
Browse files Browse the repository at this point in the history
* Related #4

[ci skip]
  • Loading branch information
tegefaulkes committed May 4, 2023
1 parent cfab3b3 commit 9c901ff
Show file tree
Hide file tree
Showing 2 changed files with 204 additions and 93 deletions.
72 changes: 71 additions & 1 deletion src/QUICServer.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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')`.
Expand Down Expand Up @@ -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<boolean> {
// 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<void>();
const timeoutTimer = setTimeout(() => {
timedOut = true;
timedOutProm.resolveP();
}, timeout);
let delay = 250;
let delayTimer: NodeJS.Timer | undefined;
let sleepProm: PromiseDeconstructed<void> | undefined;
let established = false;
const establishedProm = promise<void>();
// 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<void>();
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.
Expand Down
225 changes: 133 additions & 92 deletions tests/QUICClient.test.ts
Original file line number Diff line number Diff line change
@@ -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, [
Expand Down Expand Up @@ -544,96 +547,134 @@ describe(QUICClient.name, () => {
{ numRuns: 3 },
);
});
// Test('dual stack to dual stack', async () => {
//
// const {
// p: clientErrorEventP,
// rejectP: rejectClientErrorEventP
// } = utils.promise<events.QUICClientErrorEvent>();
//
// const {
// p: serverErrorEventP,
// rejectP: rejectServerErrorEventP
// } = utils.promise<events.QUICServerErrorEvent>();
//
// const {
// p: serverStopEventP,
// resolveP: resolveServerStopEventP
// } = utils.promise<events.QUICServerStopEvent>();
//
// const {
// p: clientDestroyEventP,
// resolveP: resolveClientDestroyEventP
// } = utils.promise<events.QUICClientDestroyEvent>();
//
// const {
// p: connectionEventP,
// resolveP: resolveConnectionEventP
// } = utils.promise<events.QUICServerConnectionEvent>();
//
// const {
// p: streamEventP,
// resolveP: resolveStreamEventP
// } = utils.promise<events.QUICConnectionStreamEvent>();
//
// 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 },
);
});
});

0 comments on commit 9c901ff

Please sign in to comment.