Skip to content

Commit 37b65ef

Browse files
tegefaulkesCMCDragonkai
authored andcommitted
feat: added QUICServer.initHolePunch for server side hole punching
* Related #4 [ci skip]
1 parent c1352a5 commit 37b65ef

File tree

2 files changed

+204
-93
lines changed

2 files changed

+204
-93
lines changed

src/QUICServer.ts

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
1-
import type { Crypto, Host, Hostname, Port, RemoteInfo } from './types';
1+
import type {
2+
Crypto,
3+
Host,
4+
Hostname,
5+
Port,
6+
PromiseDeconstructed,
7+
RemoteInfo,
8+
} from './types';
29
import type { Header } from './native/types';
310
import type QUICConnectionMap from './QUICConnectionMap';
411
import type { QUICConfig, TlsConfig } from './config';
512
import type { StreamCodeToReason, StreamReasonToCode } from './types';
13+
import type { QUICServerConnectionEvent } from './events';
614
import Logger from '@matrixai/logger';
715
import { running } from '@matrixai/async-init';
816
import { StartStop, ready } from '@matrixai/async-init/dist/StartStop';
@@ -14,6 +22,7 @@ import * as events from './events';
1422
import * as utils from './utils';
1523
import * as errors from './errors';
1624
import QUICSocket from './QUICSocket';
25+
import { promise } from './utils';
1726

1827
/**
1928
* You must provide a error handler `addEventListener('error')`.
@@ -316,6 +325,67 @@ class QUICServer extends EventTarget {
316325
};
317326
}
318327

328+
/**
329+
* This initiates sending UDP packets to a target client to open up a port in the NAT for the client to connect
330+
* through. This will return early if the connection already exists or was established while polling.
331+
*/
332+
public async initHolePunch(
333+
remoteInfo: RemoteInfo,
334+
timeout: number = 5000,
335+
): Promise<boolean> {
336+
// Checking existing connections
337+
for (const [, connection] of this.connectionMap.serverConnections) {
338+
if (
339+
remoteInfo.host === connection.remoteHost &&
340+
remoteInfo.port === connection.remotePort
341+
) {
342+
// Connection exists, return early
343+
return true;
344+
}
345+
}
346+
// We need to send a random data packet to the target until the process times out or a connection is established
347+
let timedOut = false;
348+
const timedOutProm = promise<void>();
349+
const timeoutTimer = setTimeout(() => {
350+
timedOut = true;
351+
timedOutProm.resolveP();
352+
}, timeout);
353+
let delay = 250;
354+
let delayTimer: NodeJS.Timer | undefined;
355+
let sleepProm: PromiseDeconstructed<void> | undefined;
356+
let established = false;
357+
const establishedProm = promise<void>();
358+
// Setting up established event checking
359+
const handleEstablished = (event: QUICServerConnectionEvent) => {
360+
const connection = event.detail;
361+
if (
362+
remoteInfo.host === connection.remoteHost &&
363+
remoteInfo.port === connection.remotePort
364+
) {
365+
// Clean up and resolve
366+
this.removeEventListener('connection', handleEstablished);
367+
established = true;
368+
establishedProm.resolveP();
369+
}
370+
};
371+
this.addEventListener('connection', handleEstablished);
372+
try {
373+
while (!established && !timedOut) {
374+
await this.socket.send('hello!', remoteInfo.port, remoteInfo.host);
375+
sleepProm = promise<void>();
376+
delayTimer = setTimeout(() => sleepProm!.resolveP(), delay);
377+
delay *= 2;
378+
await Promise.race([sleepProm.p, establishedProm.p, timedOutProm.p]);
379+
}
380+
return established;
381+
} finally {
382+
clearTimeout(timeoutTimer);
383+
if (delayTimer != null) clearTimeout(delayTimer);
384+
sleepProm?.resolveP();
385+
this.removeEventListener('connection', handleEstablished);
386+
}
387+
}
388+
319389
/**
320390
* Creates a retry token.
321391
* This will embed peer host IP and DCID into the token.

tests/QUICClient.test.ts

Lines changed: 133 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import type { Crypto, Host, Port } from '@/types';
22
import type * as events from '@/events';
3+
import dgram from 'dgram';
34
import Logger, { LogLevel, StreamHandler, formatting } from '@matrixai/logger';
45
import { fc, testProp } from '@fast-check/jest';
56
import QUICClient from '@/QUICClient';
67
import QUICServer from '@/QUICServer';
78
import * as errors from '@/errors';
89
import { promise } from '@/utils';
10+
import QUICSocket from '@/QUICSocket';
911
import * as testsUtils from './utils';
1012
import { tlsConfigWithCaArb } from './tlsUtils';
13+
import { sleep } from './utils';
1114

1215
describe(QUICClient.name, () => {
1316
const logger = new Logger(`${QUICClient.name} Test`, LogLevel.WARN, [
@@ -544,96 +547,134 @@ describe(QUICClient.name, () => {
544547
{ numRuns: 3 },
545548
);
546549
});
547-
// Test('dual stack to dual stack', async () => {
548-
//
549-
// const {
550-
// p: clientErrorEventP,
551-
// rejectP: rejectClientErrorEventP
552-
// } = utils.promise<events.QUICClientErrorEvent>();
553-
//
554-
// const {
555-
// p: serverErrorEventP,
556-
// rejectP: rejectServerErrorEventP
557-
// } = utils.promise<events.QUICServerErrorEvent>();
558-
//
559-
// const {
560-
// p: serverStopEventP,
561-
// resolveP: resolveServerStopEventP
562-
// } = utils.promise<events.QUICServerStopEvent>();
563-
//
564-
// const {
565-
// p: clientDestroyEventP,
566-
// resolveP: resolveClientDestroyEventP
567-
// } = utils.promise<events.QUICClientDestroyEvent>();
568-
//
569-
// const {
570-
// p: connectionEventP,
571-
// resolveP: resolveConnectionEventP
572-
// } = utils.promise<events.QUICServerConnectionEvent>();
573-
//
574-
// const {
575-
// p: streamEventP,
576-
// resolveP: resolveStreamEventP
577-
// } = utils.promise<events.QUICConnectionStreamEvent>();
578-
//
579-
// const server = new QUICServer({
580-
// crypto,
581-
// logger: logger.getChild(QUICServer.name)
582-
// });
583-
// server.addEventListener('error', handleServerErrorEvent);
584-
// server.addEventListener('stop', handleServerStopEvent);
585-
//
586-
// // Every time I have a promise
587-
// // I can attempt to await 4 promises
588-
// // Then the idea is that this will resolve 4 times
589-
// // Once for each time?
590-
// // If you add once
591-
// // Do you also
592-
//
593-
// // Fundamentally there could be multiple of these
594-
// // This is not something I can put outside
595-
//
596-
// server.addEventListener(
597-
// 'connection',
598-
// (e: events.QUICServerConnectionEvent) => {
599-
// resolveConnectionEventP(e);
600-
//
601-
// // const conn = e.detail;
602-
// // conn.addEventListener('stream', (e: events.QUICConnectionStreamEvent) => {
603-
// // resolveStreamEventP(e);
604-
// // }, { once: true });
605-
// },
606-
// { once: true }
607-
// );
608-
//
609-
// // Dual stack server
610-
// await server.start({
611-
// host: '::' as Host,
612-
// port: 0 as Port
613-
// });
614-
// // Dual stack client
615-
// const client = await QUICClient.createQUICClient({
616-
// // host: server.host,
617-
// // host: '::ffff:127.0.0.1' as Host,
618-
// host: '::1' as Host,
619-
// port: server.port,
620-
// localHost: '::' as Host,
621-
// crypto,
622-
// logger: logger.getChild(QUICClient.name)
623-
// });
624-
// client.addEventListener('error', handleClientErrorEvent);
625-
// client.addEventListener('destroy', handleClientDestroyEvent);
626-
//
627-
// // await testsUtils.sleep(1000);
628-
//
629-
// await expect(connectionEventP).resolves.toBeInstanceOf(events.QUICServerConnectionEvent);
630-
// await client.destroy();
631-
// await expect(clientDestroyEventP).resolves.toBeInstanceOf(events.QUICClientDestroyEvent);
632-
// await server.stop();
633-
// await expect(serverStopEventP).resolves.toBeInstanceOf(events.QUICServerStopEvent);
634-
//
635-
// // No errors occurred
636-
// await expect(Promise.race([clientErrorEventP, Promise.resolve()])).resolves.toBe(undefined);
637-
// await expect(Promise.race([serverErrorEventP, Promise.resolve()])).resolves.toBe(undefined);
638-
// });
550+
describe('UDP nat punching', () => {
551+
testProp(
552+
'server can send init packets',
553+
[tlsConfigWithCaArb],
554+
async (tlsConfigProm) => {
555+
const tlsConfig = await tlsConfigProm;
556+
const server = new QUICServer({
557+
crypto,
558+
logger: logger.getChild(QUICServer.name),
559+
config: {
560+
tlsConfig: tlsConfig.tlsConfig,
561+
verifyPeer: false,
562+
},
563+
});
564+
await server.start({
565+
host: '127.0.0.1' as Host,
566+
});
567+
// @ts-ignore: kidnap protected property
568+
const socket = server.socket;
569+
const mockedSend = jest.spyOn(socket, 'send');
570+
// The server can send packets
571+
// Should send 4 packets in 2 seconds
572+
const result = await server.initHolePunch(
573+
{
574+
host: '127.0.0.1' as Host,
575+
port: 55555 as Port,
576+
},
577+
2000,
578+
);
579+
expect(mockedSend).toHaveBeenCalledTimes(4);
580+
expect(result).toBeFalse();
581+
await server.stop();
582+
},
583+
{ numRuns: 1 },
584+
);
585+
testProp(
586+
'init ends when connection establishes',
587+
[tlsConfigWithCaArb],
588+
async (tlsConfigProm) => {
589+
const tlsConfig = await tlsConfigProm;
590+
const server = new QUICServer({
591+
crypto,
592+
logger: logger.getChild(QUICServer.name),
593+
config: {
594+
tlsConfig: tlsConfig.tlsConfig,
595+
verifyPeer: false,
596+
},
597+
});
598+
await server.start({
599+
host: '127.0.0.1' as Host,
600+
});
601+
// @ts-ignore: kidnap protected property
602+
const socket = server.socket;
603+
// The server can send packets
604+
// Should send 4 packets in 2 seconds
605+
const clientProm = sleep(1000)
606+
.then(async () => {
607+
const client = await QUICClient.createQUICClient({
608+
host: '::ffff:127.0.0.1' as Host,
609+
port: server.port,
610+
localHost: '::' as Host,
611+
localPort: 55556 as Port,
612+
crypto,
613+
logger: logger.getChild(QUICClient.name),
614+
config: {
615+
verifyPeer: false,
616+
},
617+
});
618+
await client.destroy({ force: true });
619+
})
620+
.catch((e) => console.error(e));
621+
const result = await server.initHolePunch(
622+
{
623+
host: '127.0.0.1' as Host,
624+
port: 55556 as Port,
625+
},
626+
2000,
627+
);
628+
await clientProm;
629+
expect(result).toBeTrue();
630+
await server.stop();
631+
},
632+
{ numRuns: 1 },
633+
);
634+
testProp(
635+
'init returns with existing connections',
636+
[tlsConfigWithCaArb],
637+
async (tlsConfigProm) => {
638+
const tlsConfig = await tlsConfigProm;
639+
const server = new QUICServer({
640+
crypto,
641+
logger: logger.getChild(QUICServer.name),
642+
config: {
643+
tlsConfig: tlsConfig.tlsConfig,
644+
verifyPeer: false,
645+
},
646+
});
647+
await server.start({
648+
host: '127.0.0.1' as Host,
649+
});
650+
const client = await QUICClient.createQUICClient({
651+
host: '::ffff:127.0.0.1' as Host,
652+
port: server.port,
653+
localHost: '::' as Host,
654+
localPort: 55556 as Port,
655+
crypto,
656+
logger: logger.getChild(QUICClient.name),
657+
config: {
658+
verifyPeer: false,
659+
},
660+
});
661+
const result = await Promise.race([
662+
server.initHolePunch(
663+
{
664+
host: '127.0.0.1' as Host,
665+
port: 55556 as Port,
666+
},
667+
2000,
668+
),
669+
sleep(10).then(() => {
670+
throw Error('timed out');
671+
}),
672+
]);
673+
expect(result).toBeTrue();
674+
await client.destroy({ force: true });
675+
await server.stop();
676+
},
677+
{ numRuns: 1 },
678+
);
679+
});
639680
});

0 commit comments

Comments
 (0)