From ef515cffbe2810493512b3de86f2219d2d0442d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20Wa=C5=9Bniowski?= Date: Wed, 22 May 2024 17:07:15 +0200 Subject: [PATCH] fix: add reachability checks for TLS (#3606) Co-authored-by: kwasniow --- docs/samples/browser-plugin-meetings/app.js | 2 + .../browser-plugin-meetings/index.html | 3 + packages/@webex/plugin-meetings/src/config.ts | 1 + .../plugin-meetings/src/meetings/index.ts | 18 ++ .../src/reachability/clusterReachability.ts | 25 +- .../plugin-meetings/src/reachability/index.ts | 25 +- .../plugin-meetings/src/reachability/util.ts | 21 ++ .../test/unit/spec/meetings/index.js | 13 + .../spec/reachability/clusterReachability.ts | 108 ++++++-- .../test/unit/spec/reachability/index.ts | 257 ++++++++++++++---- .../test/unit/spec/reachability/util.ts | 34 ++- 11 files changed, 417 insertions(+), 90 deletions(-) diff --git a/docs/samples/browser-plugin-meetings/app.js b/docs/samples/browser-plugin-meetings/app.js index c176a05ac86..23e34945567 100644 --- a/docs/samples/browser-plugin-meetings/app.js +++ b/docs/samples/browser-plugin-meetings/app.js @@ -39,6 +39,7 @@ const breakoutTable = document.getElementById('breakout-table'); const breakoutHostOperation = document.getElementById('breakout-host-operation'); const getStatsButton = document.getElementById('get-stats'); const tcpReachabilityConfigElm = document.getElementById('enable-tcp-reachability'); +const tlsReachabilityConfigElm = document.getElementById('enable-tls-reachability'); const guestName = document.querySelector('#guest-name'); const getGuestToken = document.querySelector('#get-guest-token'); @@ -116,6 +117,7 @@ function generateWebexConfig({credentials}) { enableUnifiedMeetings: true, enableAdhocMeetings: true, enableTcpReachability: tcpReachabilityConfigElm.checked, + enableTlsReachability: tlsReachabilityConfigElm.checked, }, enableAutomaticLLM: enableLLM.checked, }, diff --git a/docs/samples/browser-plugin-meetings/index.html b/docs/samples/browser-plugin-meetings/index.html index 797e9b555f7..4dac82095c1 100644 --- a/docs/samples/browser-plugin-meetings/index.html +++ b/docs/samples/browser-plugin-meetings/index.html @@ -89,6 +89,9 @@


+ + +
diff --git a/packages/@webex/plugin-meetings/src/config.ts b/packages/@webex/plugin-meetings/src/config.ts index 43cdf850cb5..759372cd615 100644 --- a/packages/@webex/plugin-meetings/src/config.ts +++ b/packages/@webex/plugin-meetings/src/config.ts @@ -87,6 +87,7 @@ export default { enableUnifiedMeetings: true, enableAdhocMeetings: true, enableTcpReachability: false, + enableTlsReachability: false, }, degradationPreferences: { maxMacroblocksLimit: 8192, diff --git a/packages/@webex/plugin-meetings/src/meetings/index.ts b/packages/@webex/plugin-meetings/src/meetings/index.ts index 5a1d5982e59..840bc396fd3 100644 --- a/packages/@webex/plugin-meetings/src/meetings/index.ts +++ b/packages/@webex/plugin-meetings/src/meetings/index.ts @@ -717,6 +717,24 @@ export default class Meetings extends WebexPlugin { } } + /** + * API to toggle TLS reachability, needs to be called before webex.meetings.register() + * @param {Boolean} newValue + * @private + * @memberof Meetings + * @returns {undefined} + */ + private _toggleTlsReachability(newValue: boolean) { + if (typeof newValue !== 'boolean') { + return; + } + // @ts-ignore + if (this.config.experimental.enableTlsReachability !== newValue) { + // @ts-ignore + this.config.experimental.enableTlsReachability = newValue; + } + } + /** * Explicitly sets up the meetings plugin by registering * the device, connecting to mercury, and listening for locus events. diff --git a/packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts b/packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts index 7a3884dae1f..d8560fcc5ad 100644 --- a/packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts +++ b/packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts @@ -2,7 +2,7 @@ import {Defer} from '@webex/common'; import LoggerProxy from '../common/logs/logger-proxy'; import {ClusterNode} from './request'; -import {convertStunUrlToTurn} from './util'; +import {convertStunUrlToTurn, convertStunUrlToTurnTls} from './util'; import {ICE_GATHERING_STATE, CONNECTION_STATE} from '../constants'; @@ -29,6 +29,7 @@ export type ClusterReachabilityResult = { export class ClusterReachability { private numUdpUrls: number; private numTcpUrls: number; + private numXTlsUrls: number; private result: ClusterReachabilityResult; private pc?: RTCPeerConnection; private defer: Defer; // this defer is resolved once reachability checks for this cluster are completed @@ -46,6 +47,7 @@ export class ClusterReachability { this.isVideoMesh = clusterInfo.isVideoMesh; this.numUdpUrls = clusterInfo.udp.length; this.numTcpUrls = clusterInfo.tcp.length; + this.numXTlsUrls = clusterInfo.xtls.length; this.pc = this.createPeerConnection(clusterInfo); @@ -94,8 +96,16 @@ export class ClusterReachability { }; }); + const turnTlsIceServers = cluster.xtls.map((urlString: string) => { + return { + username: 'webexturnreachuser', + credential: 'webexturnreachpwd', + urls: [convertStunUrlToTurnTls(urlString)], + }; + }); + return { - iceServers: [...udpIceServers, ...tcpIceServers], + iceServers: [...udpIceServers, ...tcpIceServers, ...turnTlsIceServers], iceCandidatePoolSize: 0, iceTransportPolicy: 'all', }; @@ -194,7 +204,7 @@ export class ClusterReachability { * @returns {boolean} true if we have all results, false otherwise */ private haveWeGotAllResults(): boolean { - return ['udp', 'tcp'].every( + return ['udp', 'tcp', 'xtls'].every( (protocol) => this.result[protocol].result === 'reachable' || this.result[protocol].result === 'untested' ); @@ -207,7 +217,7 @@ export class ClusterReachability { * @param {number} latency * @returns {void} */ - private storeLatencyResult(protocol: 'udp' | 'tcp', latency: number) { + private storeLatencyResult(protocol: 'udp' | 'tcp' | 'xtls', latency: number) { const result = this.result[protocol]; if (result.latencyInMilliseconds === undefined) { @@ -227,6 +237,7 @@ export class ClusterReachability { */ private registerIceCandidateListener() { this.pc.onicecandidate = (e) => { + const TURN_TLS_PORT = 443; const CANDIDATE_TYPES = { SERVER_REFLEXIVE: 'srflx', RELAY: 'relay', @@ -239,7 +250,8 @@ export class ClusterReachability { } if (e.candidate.type === CANDIDATE_TYPES.RELAY) { - this.storeLatencyResult('tcp', this.getElapsedTime()); + const protocol = e.candidate.port === TURN_TLS_PORT ? 'xtls' : 'tcp'; + this.storeLatencyResult(protocol, this.getElapsedTime()); // we don't add public IP for TCP, because in the case of relay candidates // e.candidate.address is the TURN server address, not the client's public IP } @@ -275,6 +287,9 @@ export class ClusterReachability { this.result.tcp = { result: this.numTcpUrls > 0 ? 'unreachable' : 'untested', }; + this.result.xtls = { + result: this.numXTlsUrls > 0 ? 'unreachable' : 'untested', + }; try { const offer = await this.pc.createOffer({offerToReceiveAudio: true}); diff --git a/packages/@webex/plugin-meetings/src/reachability/index.ts b/packages/@webex/plugin-meetings/src/reachability/index.ts index d9a7937bb28..05aa0705ee9 100644 --- a/packages/@webex/plugin-meetings/src/reachability/index.ts +++ b/packages/@webex/plugin-meetings/src/reachability/index.ts @@ -22,10 +22,14 @@ export type ReachabilityMetrics = { reachability_public_udp_failed: number; reachability_public_tcp_success: number; reachability_public_tcp_failed: number; + reachability_public_xtls_success: number; + reachability_public_xtls_failed: number; reachability_vmn_udp_success: number; reachability_vmn_udp_failed: number; reachability_vmn_tcp_success: number; reachability_vmn_tcp_failed: number; + reachability_vmn_xtls_success: number; + reachability_vmn_xtls_failed: number; }; /** @@ -141,10 +145,14 @@ export default class Reachability { reachability_public_udp_failed: 0, reachability_public_tcp_success: 0, reachability_public_tcp_failed: 0, + reachability_public_xtls_success: 0, + reachability_public_xtls_failed: 0, reachability_vmn_udp_success: 0, reachability_vmn_udp_failed: 0, reachability_vmn_tcp_success: 0, reachability_vmn_tcp_failed: 0, + reachability_vmn_xtls_success: 0, + reachability_vmn_xtls_failed: 0, }; const updateStats = (clusterType: 'public' | 'vmn', result: ClusterReachabilityResult) => { @@ -156,6 +164,10 @@ export default class Reachability { const outcome = result.tcp.result === 'reachable' ? 'success' : 'failed'; stats[`reachability_${clusterType}_tcp_${outcome}`] += 1; } + if (result.xtls && result.xtls.result !== 'untested') { + const outcome = result.xtls.result === 'reachable' ? 'success' : 'failed'; + stats[`reachability_${clusterType}_xtls_${outcome}`] += 1; + } }; try { @@ -338,7 +350,10 @@ export default class Reachability { LoggerProxy.logger.log( `Reachability:index#performReachabilityChecks --> doing UDP${ // @ts-ignore - this.webex.config.meetings.experimental.enableTcpReachability ? ' and TCP' : '' + this.webex.config.meetings.experimental.enableTcpReachability ? ',TCP' : '' + }${ + // @ts-ignore + this.webex.config.meetings.experimental.enableTlsReachability ? ',TLS' : '' } reachability checks` ); @@ -354,6 +369,14 @@ export default class Reachability { cluster.tcp = []; } + const includeTlsReachability = + // @ts-ignore + this.webex.config.meetings.experimental.enableTlsReachability && !cluster.isVideoMesh; + + if (!includeTlsReachability) { + cluster.xtls = []; + } + this.clusterReachability[key] = new ClusterReachability(key, cluster); return this.clusterReachability[key].start().then((result) => { diff --git a/packages/@webex/plugin-meetings/src/reachability/util.ts b/packages/@webex/plugin-meetings/src/reachability/util.ts index 7ecfa108351..ff781d88262 100644 --- a/packages/@webex/plugin-meetings/src/reachability/util.ts +++ b/packages/@webex/plugin-meetings/src/reachability/util.ts @@ -22,3 +22,24 @@ export function convertStunUrlToTurn(stunUrl: string, protocol: 'udp' | 'tcp') { return url.toString(); } + +/** + * Converts a stun url to a turns url + * + * @param {string} stunUrl url of a stun server + * @returns {string} url of a turns server + */ +export function convertStunUrlToTurnTls(stunUrl: string) { + // stunUrl looks like this: "stun:external-media1.public.wjfkm-a-15.prod.infra.webex.com:443" + // and we need it to be like this: "turns:external-media1.public.wjfkm-a-15.prod.infra.webex.com:443?transport=tcp" + const url = new URL(stunUrl); + + if (url.protocol !== 'stun:') { + throw new Error(`Not a STUN URL: ${stunUrl}`); + } + + url.protocol = 'turns:'; + url.searchParams.append('transport', 'tcp'); + + return url.toString(); +} diff --git a/packages/@webex/plugin-meetings/test/unit/spec/meetings/index.js b/packages/@webex/plugin-meetings/test/unit/spec/meetings/index.js index acf846d6340..b5bf9d6ac12 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meetings/index.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/meetings/index.js @@ -253,6 +253,19 @@ describe('plugin-meetings', () => { }); }); + describe('#_toggleTlsReachability', () => { + it('should have _toggleTlsReachability', () => { + assert.equal(typeof webex.meetings._toggleTlsReachability, 'function'); + }); + + describe('success', () => { + it('should update meetings to do TLS reachability', () => { + webex.meetings._toggleTlsReachability(true); + assert.equal(webex.meetings.config.experimental.enableTlsReachability, true); + }); + }); + }); + describe('Public API Contracts', () => { describe('#register', () => { it('emits an event and resolves when register succeeds', async () => { diff --git a/packages/@webex/plugin-meetings/test/unit/spec/reachability/clusterReachability.ts b/packages/@webex/plugin-meetings/test/unit/spec/reachability/clusterReachability.ts index bf27a95f379..8f6ac75e29e 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/reachability/clusterReachability.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/reachability/clusterReachability.ts @@ -4,7 +4,7 @@ import sinon from 'sinon'; import testUtils from '../../../utils/testUtils'; // packages/@webex/plugin-meetings/test/unit/spec/reachability/clusterReachability.ts -import { ClusterReachability } from '@webex/plugin-meetings/src/reachability/clusterReachability'; // replace with actual path +import {ClusterReachability} from '@webex/plugin-meetings/src/reachability/clusterReachability'; // replace with actual path describe('ClusterReachability', () => { let previousRTCPeerConnection; @@ -28,9 +28,8 @@ describe('ClusterReachability', () => { isVideoMesh: false, udp: ['stun:udp1', 'stun:udp2'], tcp: ['stun:tcp1.webex.com', 'stun:tcp2.webex.com:5004'], - xtls: ['xtls1', 'xtls2'], + xtls: ['stun:xtls1.webex.com', 'stun:xtls2.webex.com:443'], }); - }); afterEach(() => { @@ -50,8 +49,26 @@ describe('ClusterReachability', () => { iceServers: [ {username: '', credential: '', urls: ['stun:udp1']}, {username: '', credential: '', urls: ['stun:udp2']}, - {username: 'webexturnreachuser', credential: 'webexturnreachpwd', urls: ['turn:tcp1.webex.com?transport=tcp']}, - {username: 'webexturnreachuser', credential: 'webexturnreachpwd', urls: ['turn:tcp2.webex.com:5004?transport=tcp']} + { + username: 'webexturnreachuser', + credential: 'webexturnreachpwd', + urls: ['turn:tcp1.webex.com?transport=tcp'], + }, + { + username: 'webexturnreachuser', + credential: 'webexturnreachpwd', + urls: ['turn:tcp2.webex.com:5004?transport=tcp'], + }, + { + username: 'webexturnreachuser', + credential: 'webexturnreachpwd', + urls: ['turns:xtls1.webex.com?transport=tcp'], + }, + { + username: 'webexturnreachuser', + credential: 'webexturnreachpwd', + urls: ['turns:xtls2.webex.com:443?transport=tcp'], + }, ], iceCandidatePoolSize: 0, iceTransportPolicy: 'all', @@ -79,7 +96,7 @@ describe('ClusterReachability', () => { assert.deepEqual(clusterReachability.getResult(), { udp: {result: 'untested'}, tcp: {result: 'untested'}, - xtls: {result: 'untested'} + xtls: {result: 'untested'}, }); }); @@ -92,7 +109,7 @@ describe('ClusterReachability', () => { afterEach(() => { clock.restore(); - }) + }); it('should initiate the ICE gathering process', async () => { const promise = clusterReachability.start(); @@ -107,11 +124,11 @@ describe('ClusterReachability', () => { assert.calledOnceWithExactly(fakePeerConnection.createOffer, {offerToReceiveAudio: true}); assert.calledOnce(fakePeerConnection.setLocalDescription); - await clock.tickAsync(3000);// move the clock so that reachability times out + await clock.tickAsync(3000); // move the clock so that reachability times out await promise; }); - it('resolves and has correct result as soon as it finds that both udp and tcp is reachable', async () => { + it('resolves and has correct result as soon as it finds that all udp, tcp and tls are reachable', async () => { const promise = clusterReachability.start(); await clock.tickAsync(100); @@ -120,12 +137,17 @@ describe('ClusterReachability', () => { await clock.tickAsync(100); fakePeerConnection.onicecandidate({candidate: {type: 'relay', address: 'someTurnRelayIp'}}); + await clock.tickAsync(100); + fakePeerConnection.onicecandidate({ + candidate: {type: 'relay', address: 'someTurnRelayIp', port: 443}, + }); + await promise; assert.deepEqual(clusterReachability.getResult(), { udp: {result: 'reachable', latencyInMilliseconds: 100, clientMediaIPs: ['somePublicIp']}, tcp: {result: 'reachable', latencyInMilliseconds: 200}, - xtls: {result: 'untested'} + xtls: {result: 'reachable', latencyInMilliseconds: 300}, }); }); @@ -139,7 +161,7 @@ describe('ClusterReachability', () => { assert.deepEqual(clusterReachability.getResult(), { udp: {result: 'unreachable'}, tcp: {result: 'unreachable'}, - xtls: {result: 'untested'} + xtls: {result: 'unreachable'}, }); }); @@ -148,7 +170,7 @@ describe('ClusterReachability', () => { isVideoMesh: true, udp: ['stun:udp1', 'stun:udp2'], tcp: ['stun:tcp1.webex.com', 'stun:tcp2.webex.com:5004'], - xtls: ['xtls1', 'xtls2'], + xtls: ['stun:xtls1.webex.com', 'stun:xtls1.webex.com:443'], }); const promise = clusterReachability.start(); @@ -160,7 +182,7 @@ describe('ClusterReachability', () => { assert.deepEqual(clusterReachability.getResult(), { udp: {result: 'unreachable'}, tcp: {result: 'unreachable'}, - xtls: {result: 'untested'} + xtls: {result: 'unreachable'}, }); }); @@ -176,7 +198,7 @@ describe('ClusterReachability', () => { assert.deepEqual(clusterReachability.getResult(), { udp: {result: 'unreachable'}, tcp: {result: 'unreachable'}, - xtls: {result: 'untested'} + xtls: {result: 'unreachable'}, }); }); @@ -194,7 +216,7 @@ describe('ClusterReachability', () => { assert.deepEqual(clusterReachability.getResult(), { udp: {result: 'reachable', latencyInMilliseconds: 30, clientMediaIPs: ['somePublicIp1']}, tcp: {result: 'unreachable'}, - xtls: {result: 'untested'} + xtls: {result: 'unreachable'}, }); }); @@ -211,15 +233,19 @@ describe('ClusterReachability', () => { await clock.tickAsync(10); fakePeerConnection.onicecandidate({candidate: {type: 'srflx', address: 'somePublicIp3'}}); - await clock.tickAsync(3000);// move the clock so that reachability times out + await clock.tickAsync(3000); // move the clock so that reachability times out await promise; // latency should be from only the first candidates, but the clientMediaIps should be from all UDP candidates (not TCP) assert.deepEqual(clusterReachability.getResult(), { - udp: {result: 'reachable', latencyInMilliseconds: 10, clientMediaIPs: ['somePublicIp1', 'somePublicIp2', 'somePublicIp3']}, + udp: { + result: 'reachable', + latencyInMilliseconds: 10, + clientMediaIPs: ['somePublicIp1', 'somePublicIp2', 'somePublicIp3'], + }, tcp: {result: 'unreachable'}, - xtls: {result: 'untested'} + xtls: {result: 'unreachable'}, }); }); @@ -236,7 +262,7 @@ describe('ClusterReachability', () => { await clock.tickAsync(10); fakePeerConnection.onicecandidate({candidate: {type: 'relay', address: 'someTurnRelayIp3'}}); - await clock.tickAsync(3000);// move the clock so that reachability times out + await clock.tickAsync(3000); // move the clock so that reachability times out await promise; @@ -244,7 +270,38 @@ describe('ClusterReachability', () => { assert.deepEqual(clusterReachability.getResult(), { udp: {result: 'unreachable'}, tcp: {result: 'reachable', latencyInMilliseconds: 10}, - xtls: {result: 'untested'} + xtls: {result: 'unreachable'}, + }); + }); + + it('should store latency only for the first tls relay candidate', async () => { + const promise = clusterReachability.start(); + + await clock.tickAsync(10); + fakePeerConnection.onicecandidate({ + candidate: {type: 'relay', address: 'someTurnRelayIp1', port: 443}, + }); + + // generate more candidates + await clock.tickAsync(10); + fakePeerConnection.onicecandidate({ + candidate: {type: 'relay', address: 'someTurnRelayIp2', port: 443}, + }); + + await clock.tickAsync(10); + fakePeerConnection.onicecandidate({ + candidate: {type: 'relay', address: 'someTurnRelayIp3', port: 443}, + }); + + await clock.tickAsync(3000); // move the clock so that reachability times out + + await promise; + + // latency should be from only the first candidates, but the clientMediaIps should be from only from UDP candidates + assert.deepEqual(clusterReachability.getResult(), { + udp: {result: 'unreachable'}, + tcp: {result: 'unreachable'}, + xtls: {result: 'reachable', latencyInMilliseconds: 10}, }); }); @@ -266,13 +323,20 @@ describe('ClusterReachability', () => { // send also a relay candidate so that the reachability check finishes fakePeerConnection.onicecandidate({candidate: {type: 'relay', address: 'someTurnRelayIp'}}); + fakePeerConnection.onicecandidate({ + candidate: {type: 'relay', address: 'someTurnRelayIp', port: 443}, + }); await promise; assert.deepEqual(clusterReachability.getResult(), { - udp: {result: 'reachable', latencyInMilliseconds: 10, clientMediaIPs: ['somePublicIp1', 'somePublicIp2']}, + udp: { + result: 'reachable', + latencyInMilliseconds: 10, + clientMediaIPs: ['somePublicIp1', 'somePublicIp2'], + }, tcp: {result: 'reachable', latencyInMilliseconds: 40}, - xtls: {result: 'untested'} + xtls: {result: 'reachable', latencyInMilliseconds: 40}, }); }); }); diff --git a/packages/@webex/plugin-meetings/test/unit/spec/reachability/index.ts b/packages/@webex/plugin-meetings/test/unit/spec/reachability/index.ts index 17ffa942a9f..df73d2e4fde 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/reachability/index.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/reachability/index.ts @@ -1,11 +1,14 @@ import {assert} from '@webex/test-helper-chai'; import MockWebex from '@webex/test-helper-mock-webex'; import sinon from 'sinon'; -import Reachability, {ReachabilityResults, ReachabilityResultsForBackend} from '@webex/plugin-meetings/src/reachability/'; +import Reachability, { + ReachabilityResults, + ReachabilityResultsForBackend, +} from '@webex/plugin-meetings/src/reachability/'; import MeetingUtil from '@webex/plugin-meetings/src/meeting/util'; import * as ClusterReachabilityModule from '@webex/plugin-meetings/src/reachability/clusterReachability'; -import { IP_VERSION } from '@webex/plugin-meetings/src/constants'; +import {IP_VERSION} from '@webex/plugin-meetings/src/constants'; describe('isAnyPublicClusterReachable', () => { let webex; @@ -36,19 +39,31 @@ describe('isAnyPublicClusterReachable', () => { }; it('returns true when udp is reachable', async () => { - await checkIsClusterReachable({x: {udp: {result: 'reachable'}, tcp: {result: 'unreachable'}}}, true); + await checkIsClusterReachable( + {x: {udp: {result: 'reachable'}, tcp: {result: 'unreachable'}}}, + true + ); }); it('returns true when tcp is reachable', async () => { - await checkIsClusterReachable({x: {udp: {result: 'unreachable'}, tcp: {result: 'reachable'}}}, true); + await checkIsClusterReachable( + {x: {udp: {result: 'unreachable'}, tcp: {result: 'reachable'}}}, + true + ); }); it('returns true when both tcp and udp are reachable', async () => { - await checkIsClusterReachable({x: {udp: {result: 'reachable'}, tcp: {result: 'reachable'}}}, true); + await checkIsClusterReachable( + {x: {udp: {result: 'reachable'}, tcp: {result: 'reachable'}}}, + true + ); }); it('returns false when both tcp and udp are unreachable', async () => { - await checkIsClusterReachable({x: {udp: {result: 'unreachable'}, tcp: {result: 'unreachable'}}}, false); + await checkIsClusterReachable( + {x: {udp: {result: 'unreachable'}, tcp: {result: 'unreachable'}}}, + false + ); }); it('returns false when reachability result is empty', async () => { @@ -61,60 +76,69 @@ describe('isAnyPublicClusterReachable', () => { describe('ignores video mesh reachability', () => { it('returns false if there are no public cluster results, only video mesh', async () => { - await checkIsClusterReachable({ - x: { - udp: {result: 'reachable'}, - tcp: {result: 'reachable'}, - isVideoMesh: true, - }, - y: { - udp: {result: 'unreachable'}, - tcp: {result: 'reachable'}, - isVideoMesh: true, - } - }, false); + await checkIsClusterReachable( + { + x: { + udp: {result: 'reachable'}, + tcp: {result: 'reachable'}, + isVideoMesh: true, + }, + y: { + udp: {result: 'unreachable'}, + tcp: {result: 'reachable'}, + isVideoMesh: true, + }, + }, + false + ); }); it('returns false if there public cluster reachability failed, only video mesh succeeded', async () => { - await checkIsClusterReachable({ - x: { - udp: {result: 'unreachable'}, - tcp: {result: 'reachable'}, - isVideoMesh: true, - }, - y: { - udp: {result: 'reachable'}, - tcp: {result: 'unreachable'}, - isVideoMesh: true, - }, - publicOne: { - udp: {result: 'unreachable'}, - tcp: {result: 'unreachable'}, - isVideoMesh: false, - } - }, false); + await checkIsClusterReachable( + { + x: { + udp: {result: 'unreachable'}, + tcp: {result: 'reachable'}, + isVideoMesh: true, + }, + y: { + udp: {result: 'reachable'}, + tcp: {result: 'unreachable'}, + isVideoMesh: true, + }, + publicOne: { + udp: {result: 'unreachable'}, + tcp: {result: 'unreachable'}, + isVideoMesh: false, + }, + }, + false + ); }); it('returns true if there is at least 1 public cluster result, while video mesh is not reachable', async () => { - await checkIsClusterReachable({ - x: { - udp: {result: 'reachable'}, - tcp: {result: 'reachable'}, - isVideoMesh: true, - }, - y: { - udp: {result: 'unreachable'}, - tcp: {result: 'reachable'}, - isVideoMesh: true, - }, - publicOne: { - udp: {result: 'unreachable'}, - tcp: {result: 'reachable'}, - isVideoMesh: false, - } - }, true); + await checkIsClusterReachable( + { + x: { + udp: {result: 'reachable'}, + tcp: {result: 'reachable'}, + isVideoMesh: true, + }, + y: { + udp: {result: 'unreachable'}, + tcp: {result: 'reachable'}, + isVideoMesh: true, + }, + publicOne: { + udp: {result: 'unreachable'}, + tcp: {result: 'reachable'}, + isVideoMesh: false, + }, + }, + true + ); }); - }) + }); }); describe('gatherReachability', () => { @@ -244,7 +268,10 @@ describe('gatherReachability', () => { }); it('starts ClusterReachability on each media cluster', async () => { - webex.config.meetings.experimental = {enableTcpReachability: true}; + webex.config.meetings.experimental = { + enableTcpReachability: true, + enableTlsReachability: true, + }; const getClustersResult = { clusters: { @@ -284,11 +311,11 @@ describe('gatherReachability', () => { xtls: ['xtls1.1', 'xtls1.2'], isVideoMesh: false, }); - // cluster 2 is video mesh, so we should not do TCP reachability on it + // cluster 2 is video mesh, so we should not do TCP or TLS reachability on it assert.calledWith(clusterReachabilityCtorStub, 'cluster 2', { udp: ['udp2.1', 'udp2.2'], tcp: [], - xtls: ['xtls2.1', 'xtls2.2'], + xtls: [], isVideoMesh: true, }); @@ -296,7 +323,10 @@ describe('gatherReachability', () => { }); it('does not do TCP reachability if it is disabled in config', async () => { - webex.config.meetings.experimental = {enableTcpReachability: false}; + webex.config.meetings.experimental = { + enableTcpReachability: false, + enableTlsReachability: true, + }; const getClustersResult = { clusters: { @@ -329,6 +359,82 @@ describe('gatherReachability', () => { xtls: ['testXTLS1', 'testXTLS2'], }); }); + + it('does not do TLS reachability if it is disabled in config', async () => { + webex.config.meetings.experimental = { + enableTcpReachability: true, + enableTlsReachability: false, + }; + + const getClustersResult = { + clusters: { + 'cluster name': { + udp: ['testUDP1', 'testUDP2'], + tcp: ['testTCP1', 'testTCP2'], + xtls: ['testXTLS1', 'testXTLS2'], + isVideoMesh: false, + }, + }, + joinCookie: {id: 'id'}, + }; + + const reachability = new Reachability(webex); + + reachability.reachabilityRequest.getClusters = sinon.stub().returns(getClustersResult); + + const clusterReachabilityCtorStub = sinon + .stub(ClusterReachabilityModule, 'ClusterReachability') + .callsFake(() => ({ + start: sinon.stub().resolves({}), + })); + + await reachability.gatherReachability(); + + assert.calledOnceWithExactly(clusterReachabilityCtorStub, 'cluster name', { + isVideoMesh: false, + udp: ['testUDP1', 'testUDP2'], + tcp: ['testTCP1', 'testTCP2'], + xtls: [], // empty list because TLS is disabled in config + }); + }); + + it('does not do TCP or TLS reachability if it is disabled in config', async () => { + webex.config.meetings.experimental = { + enableTcpReachability: false, + enableTlsReachability: false, + }; + + const getClustersResult = { + clusters: { + 'cluster name': { + udp: ['testUDP1', 'testUDP2'], + tcp: ['testTCP1', 'testTCP2'], + xtls: ['testXTLS1', 'testXTLS2'], + isVideoMesh: false, + }, + }, + joinCookie: {id: 'id'}, + }; + + const reachability = new Reachability(webex); + + reachability.reachabilityRequest.getClusters = sinon.stub().returns(getClustersResult); + + const clusterReachabilityCtorStub = sinon + .stub(ClusterReachabilityModule, 'ClusterReachability') + .callsFake(() => ({ + start: sinon.stub().resolves({}), + })); + + await reachability.gatherReachability(); + + assert.calledOnceWithExactly(clusterReachabilityCtorStub, 'cluster name', { + isVideoMesh: false, + udp: ['testUDP1', 'testUDP2'], + tcp: [], // empty list because TCP is disabled in config + xtls: [], // empty list because TLS is disabled in config + }); + }); }); describe('getReachabilityResults', () => { @@ -460,10 +566,14 @@ describe('getReachabilityMetrics', () => { reachability_public_udp_failed: 0, reachability_public_tcp_success: 0, reachability_public_tcp_failed: 0, + reachability_public_xtls_success: 0, + reachability_public_xtls_failed: 0, reachability_vmn_udp_success: 0, reachability_vmn_udp_failed: 0, reachability_vmn_tcp_success: 0, reachability_vmn_tcp_failed: 0, + reachability_vmn_xtls_success: 0, + reachability_vmn_xtls_failed: 0, }); }); @@ -523,10 +633,14 @@ describe('getReachabilityMetrics', () => { reachability_public_udp_failed: 2, reachability_public_tcp_success: 2, reachability_public_tcp_failed: 1, + reachability_public_xtls_success: 0, + reachability_public_xtls_failed: 0, reachability_vmn_udp_success: 1, reachability_vmn_udp_failed: 0, reachability_vmn_tcp_success: 1, reachability_vmn_tcp_failed: 1, + reachability_vmn_xtls_success: 0, + reachability_vmn_xtls_failed: 0, } ); }); @@ -562,7 +676,16 @@ describe('getReachabilityMetrics', () => { public5: { udp: {result: 'reachable', latencyInMilliseconds: '400', clientMediaIPs: ['10.10.10.10']}, tcp: {result: 'untested'}, - xtls: {result: 'untested'}, + xtls: {result: 'unreachable'}, + }, + public6: { + udp: {result: 'untested'}, + tcp: {result: 'untested'}, + xtls: { + result: 'reachable', + latencyInMilliseconds: '200', + clientMediaIPs: ['10.10.10.10'], + }, }, }, // expected result: @@ -571,10 +694,14 @@ describe('getReachabilityMetrics', () => { reachability_public_udp_failed: 1, reachability_public_tcp_success: 1, reachability_public_tcp_failed: 2, + reachability_public_xtls_success: 1, + reachability_public_xtls_failed: 1, reachability_vmn_udp_success: 0, reachability_vmn_udp_failed: 0, reachability_vmn_tcp_success: 0, reachability_vmn_tcp_failed: 0, + reachability_vmn_xtls_success: 0, + reachability_vmn_xtls_failed: 0, } ); }); @@ -611,10 +738,16 @@ describe('getReachabilityMetrics', () => { vmn5: { udp: {result: 'reachable', latencyInMilliseconds: 200, clientMediaIPs: ['10.10.10.10']}, tcp: {result: 'unreachable'}, - xtls: {result: 'untested'}, + xtls: {result: 'unreachable'}, isVideoMesh: true, someOtherField: 'any value', }, + vmn6: { + udp: {result: 'untested'}, + tcp: {result: 'untested'}, + xtls: {result: 'reachable', latencyInMilliseconds: 100, clientMediaIPs: ['10.10.10.10']}, + isVideoMesh: true, + }, }, // expected result: { @@ -622,11 +755,15 @@ describe('getReachabilityMetrics', () => { reachability_public_udp_failed: 0, reachability_public_tcp_success: 0, reachability_public_tcp_failed: 0, + reachability_public_xtls_success: 0, + reachability_public_xtls_failed: 0, reachability_vmn_udp_success: 3, reachability_vmn_udp_failed: 1, reachability_vmn_tcp_success: 1, reachability_vmn_tcp_failed: 3, + reachability_vmn_xtls_success: 1, + reachability_vmn_xtls_failed: 1, } ); }); -}); \ No newline at end of file +}); diff --git a/packages/@webex/plugin-meetings/test/unit/spec/reachability/util.ts b/packages/@webex/plugin-meetings/test/unit/spec/reachability/util.ts index 4b7e575b425..bfe85e6efde 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/reachability/util.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/reachability/util.ts @@ -1,6 +1,9 @@ import {assert} from '@webex/test-helper-chai'; -import {convertStunUrlToTurn} from '@webex/plugin-meetings/src/reachability/util'; +import { + convertStunUrlToTurn, + convertStunUrlToTurnTls, +} from '@webex/plugin-meetings/src/reachability/util'; describe('plugin-meetings/src/reachability/util', () => { describe('#convertStunUrlToTurn()', () => { @@ -34,7 +37,34 @@ describe('plugin-meetings/src/reachability/util', () => { }); it('show fail if stunUrl is not a STUN url', () => { - assert.throws(() => convertStunUrlToTurn('http://webex.com', 'tcp'), 'Not a STUN URL: http://webex.com'); + assert.throws( + () => convertStunUrlToTurn('http://webex.com', 'tcp'), + 'Not a STUN URL: http://webex.com' + ); + }); + }); + + describe('#convertStunUrlToTurnTls()', () => { + it(`should convert to a turns url`, () => { + const turnsUrl = convertStunUrlToTurnTls( + 'stun:external-media91.public.wjfkm-a-10.prod.infra.webex.com:443' + ); + + assert.equal( + turnsUrl, + 'turns:external-media91.public.wjfkm-a-10.prod.infra.webex.com:443?transport=tcp' + ); + }); + + it('show fail if stunUrl is not a valid url', () => { + assert.throws(() => convertStunUrlToTurn('not a url', 'tcp'), 'Invalid URL: not a url'); + }); + + it('show fail if stunUrl is not a STUN url', () => { + assert.throws( + () => convertStunUrlToTurn('http://webex.com', 'tcp'), + 'Not a STUN URL: http://webex.com' + ); }); }); });