diff --git a/package.json b/package.json index d45fc43b..d49609a7 100644 --- a/package.json +++ b/package.json @@ -16,12 +16,14 @@ "@types/ws": "^7.4.0", "async": "^3.2.1", "buffer": "^6.0.3", + "net": "^1.0.2", "node-fetch": "^2.6.1", "process": "^0.11.10", "punycode": "^2.1.1", "react-native-randombytes": "^3.6.0", "readable-stream": "^2.3.6", "sdp": "^3.0.2", + "tls": "^0.0.1", "stanza-shims": "^1.1.2", "tslib": "^2.2.0", "ws": "^7.4.4" diff --git a/rollup.config.js b/rollup.config.js index c73b99c7..0a991356 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -5,6 +5,8 @@ export default { 'async', 'crypto', 'events', + 'net', + 'tls', 'node-fetch', 'punycode', 'sdp', @@ -18,5 +20,5 @@ export default { file: 'dist/es/index.module.js', format: 'es' }, - plugins: [resolve({ browser: true })] + plugins: [resolve({ browser: true, preferBuiltins: true })] }; diff --git a/scripts/build.ts b/scripts/build.ts index eab69848..49680b16 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -1,6 +1,11 @@ -import { execSync as Child } from 'child_process'; +import { execSync } from 'child_process'; import FS from 'fs'; +const Child = (command: string) => { + const ret = execSync(command, { stdio: 'inherit' }); + return ret; +} + const Pkg = JSON.parse(FS.readFileSync('package.json').toString()); function fileReplace(fileName: string, placeholder: string, value: string) { @@ -19,7 +24,12 @@ fileReplace('dist/es/Constants.js', '__STANZAJS_VERSION__', Pkg.version); Child('npm run compile:rollup'); -Child('mkdir dist/npm'); +if (!FS.existsSync('dist')) { + FS.mkdirSync('dist'); +} +if (!FS.existsSync('dist/npm')) { + FS.mkdirSync('dist/npm'); +} Child('cp -r dist/cjs/* dist/npm/'); Child('cp dist/es/index.module.js dist/npm/module.js'); Child(`cp ${__dirname}/../*.md dist/npm`); diff --git a/src/Client.ts b/src/Client.ts index 9c20b35a..e8090f15 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -11,6 +11,7 @@ import { core as corePlugins } from './plugins'; import Protocol, { IQ, Message, Presence, StreamError, Stream } from './protocol'; import BOSH from './transports/bosh'; import WebSocket from './transports/websocket'; +import TCP from './transports/tcp'; import { timeoutPromise, uuid } from './Utils'; interface StreamData { @@ -102,8 +103,11 @@ export default class Client extends EventEmitter { this.transports = { bosh: BOSH, - websocket: WebSocket + websocket: WebSocket, }; + if (typeof window === 'undefined') { + this.transports.tcp = TCP; + } this.incomingDataQueue = priorityQueue(async (task, done) => { const { kind, stanza } = task; @@ -186,7 +190,7 @@ export default class Client extends EventEmitter { ); }); - this.on('--transport-disconnected', async () => { + const dcHandler = async () => { const drains: Array> = []; if (!this.incomingDataQueue.idle()) { drains.push(this.incomingDataQueue.drain()); @@ -213,7 +217,9 @@ export default class Client extends EventEmitter { } this.emit('disconnected'); - }); + }; + this.on('--transport-disconnected', dcHandler); + this.on('--transport-error', dcHandler); this.on('iq', (iq: IQ) => { const iqType = iq.type; @@ -284,10 +290,12 @@ export default class Client extends EventEmitter { jid: '', transports: { bosh: true, - websocket: true + websocket: true, + tcp: true, }, useStreamManagement: true, - transportPreferenceOrder: ['websocket', 'bosh'], + transportPreferenceOrder: ['tcp', 'websocket', 'bosh'], + requireSecureTransport: true, ...currConfig, ...opts }; @@ -349,65 +357,87 @@ export default class Client extends EventEmitter { } const transportPref = this.config.transportPreferenceOrder ?? []; - let endpoints: { [key: string]: string[] } | undefined; + const transportEndpoints: Array<[Transport, TransportConfig]> = []; + let endpoints: { [key: string]: string[] } = {}; + try { + endpoints = await (this as unknown as Agent).discoverBindings( + this.config.server! + ); + } catch (e) { + console.error(e); + } for (const name of transportPref) { const settings = this.config.transports![name]; - if (!settings) { + if (!settings || !this.transports![name]) { continue; } + const transport = new this.transports![name]( + this as unknown as Agent, + this.sm, + this.stanzas + ); let config: TransportConfig = { acceptLanguages: this.config.acceptLanguages || [this.config.lang ?? 'en'], jid: this.config.jid!, lang: this.config.lang ?? 'en', - server: this.config.server! + server: this.config.server!, }; - const transport = new this.transports[name]( - this as unknown as Agent, - this.sm, - this.stanzas - ); if (typeof settings === 'string') { - config.url = settings; - } else if (settings == true) { + transportEndpoints.push([transport, { ...config, url: settings }]); + } else if (settings === true) { if (transport.discoverBindings) { - const discovered = await transport.discoverBindings(this.config.server!); - if (!discovered) { - continue; + for (const ep of (await transport.discoverBindings(this.config.server!) ?? [])) { + transportEndpoints.push([transport, { ...config, ...ep }]); } - config = { - ...config, - ...discovered - }; } else { - if (!endpoints) { - try { - endpoints = await (this as unknown as Agent).discoverBindings( - this.config.server! - ); - } catch (err) { - console.error(err); - continue; - } - } - endpoints[name] = (endpoints[name] || []).filter( - url => url.startsWith('wss:') || url.startsWith('https:') - ); - if (!endpoints[name] || !endpoints[name].length) { - continue; + for (const ep of (endpoints[name] ?? [])) { + transportEndpoints.push([transport, { ...config, url: ep }]); } - config.url = endpoints[name][0]; } + } else if (typeof settings === 'object') { + transportEndpoints.push([transport, { ...config, ...settings }]); } + } + const secureOptions: Array<[Transport, TransportConfig]> = []; + const insecureOptions: Array<[Transport, TransportConfig]> = []; + + // secureOptions + insecureOptions will be sorted as transportEndpoints + // is created in priority order. + for (const [transport, endpoint] of transportEndpoints) { + if ( + endpoint.url?.startsWith('https://') || + endpoint.url?.startsWith('wss://') || + transport instanceof TCP + ) { + secureOptions.push([transport, endpoint]); + } else { + insecureOptions.push([transport, endpoint]); + } + } + + const options = + this.config.requireSecureTransport + ? secureOptions + : secureOptions.concat(insecureOptions); + + for (const [transport, endpoint] of options) { this.transport = transport; - this.transport.connect(config); - return; + const dcPromise = new Promise(resolve => this.once('disconnected', resolve)); + try { + await this.transport.connect(endpoint); + return; + } catch (_) { + await this.disconnect(); + this.transport = undefined; + } + await dcPromise; } - console.error('No endpoints found for the requested transports.'); this.emit('--transport-disconnected'); + throw 'No adequate endpoints found for the requested transports.'; } public async disconnect(): Promise { diff --git a/src/index.ts b/src/index.ts index 54b20f11..f5de6e21 100644 --- a/src/index.ts +++ b/src/index.ts @@ -99,6 +99,8 @@ export interface AgentEvents { // Any "--" prefixed events are for internal use only '--reset-stream-features': void; '--transport-disconnected': void; + '--transport-connected': void; + '--transport-error': Error; '*': (...args: any[]) => void; } @@ -200,7 +202,10 @@ export interface AgentConfig { * * If a transport is set to a string, that will be used as the connection URL. * - * @default { websocket: true, bosh: true } + * If a transport is set to an object, it MUST include a url value for + * the connection URL. + * + * @default { websocket: true, bosh: true, tcp: true } */ transports?: { [key: string]: boolean | string | Partial }; @@ -211,10 +216,24 @@ export interface AgentConfig { * * If a configured transport type is not listed, it will be skipped. * - * @default ['websocket', 'bosh'] + * @default ['tcp', 'websocket', 'bosh'] */ transportPreferenceOrder?: string[]; + /** + * Require Secure Transport + * + * Guarantees that any transport chosen will use TLS or equivalent. If + * no secure transport is available, connection will fail. + * + * If a transport is specified which is not secure, this option will + * prevent it from being used. If ws:// and https:// is available, but + * websocket is higher priority than BOSH, BOSH will still be used. + * + * @default true + */ + requireSecureTransport?: boolean; + /** * Account Password * @@ -242,9 +261,9 @@ export interface Transport { stream?: Stream; authenticated?: boolean; - discoverBindings?(host: string): Promise | null>; + discoverBindings?(host: string): Promise> | null>; - connect(opts: TransportConfig): void; + connect(opts: TransportConfig): Promise; disconnect(cleanly?: boolean): void; restart(): void; send(name: string, data?: JXT.JSONData): Promise; @@ -265,6 +284,11 @@ export interface TransportConfig { maxRetries?: number; wait?: number; maxHoldOpen?: number; + + // TCP/TLS settings + directTLS?: boolean, + port?: number, + pubkey?: Buffer, } import * as RSM from './helpers/RSM'; @@ -288,6 +312,7 @@ export { export const VERSION = Constants.VERSION; import Plugins from './plugins'; +import { TlsOptions } from 'tls'; export * from './plugins'; export function createClient(opts: AgentConfig): Agent { diff --git a/src/transports/bosh.ts b/src/transports/bosh.ts index 1ff4229e..25dec829 100644 --- a/src/transports/bosh.ts +++ b/src/transports/bosh.ts @@ -147,6 +147,7 @@ export default class BOSH extends Duplex implements Transport { condition: StreamErrorCondition.InvalidXML }; this.client.emit('stream:error', streamError, err); + this.client.emit('--transport-error', err); this.send('error', streamError); return this.disconnect(); }); @@ -170,6 +171,7 @@ export default class BOSH extends Duplex implements Transport { this.sid = e.stanza.sid || this.sid; this.maxWaitTime = e.stanza.maxWaitTime || this.maxWaitTime; + this.client.emit('--transport-connected'); this.client.emit('stream:start', e.stanza); } return; @@ -186,41 +188,47 @@ export default class BOSH extends Duplex implements Transport { this.scheduleRequests(); } - public connect(opts: TransportConfig): void { - this.config = opts; - - this.url = opts.url!; - if (opts.rid) { - this.rid = opts.rid; - } - if (opts.sid) { - this.sid = opts.sid; - } - if (opts.wait) { - this.maxWaitTime = opts.wait; - } - if (opts.maxHoldOpen) { - this.maxHoldOpen = opts.maxHoldOpen; - } - - if (this.sid) { - this.hasStream = true; - this.stream = {}; - this.client.emit('connected'); - this.client.emit('session:prebind', this.config.jid); - this.client.emit('session:started'); - return; - } - - this._send({ - lang: opts.lang, - maxHoldOpen: this.maxHoldOpen, - maxWaitTime: this.maxWaitTime, - to: opts.server, - version: '1.6', - xmppVersion: '1.0' + public async connect(opts: TransportConfig): Promise { + return await new Promise(async (resolve, reject) => { + this.config = opts; + + this.url = opts.url!; + if (opts.rid) { + this.rid = opts.rid; + } + if (opts.sid) { + this.sid = opts.sid; + } + if (opts.wait) { + this.maxWaitTime = opts.wait; + } + if (opts.maxHoldOpen) { + this.maxHoldOpen = opts.maxHoldOpen; + } + + if (this.sid) { + this.hasStream = true; + this.stream = {}; + this.client.emit('connected'); + this.client.emit('session:prebind', this.config.jid); + this.client.emit('session:started'); + return; + } + + await this._send({ + lang: opts.lang, + maxHoldOpen: this.maxHoldOpen, + maxWaitTime: this.maxWaitTime, + to: opts.server, + version: '1.6', + xmppVersion: '1.0' + }); + + this.client.once('--transport-connected', () => resolve()); + this.client.once('--transport-error', err => reject(err)); }); } + public restart(): void { this.hasStream = false; @@ -252,7 +260,7 @@ export default class BOSH extends Duplex implements Transport { return; } - return new Promise((resolve, reject) => { + return await new Promise((resolve, reject) => { this.write(output, 'utf8', err => (err ? reject(err) : resolve())); }); } @@ -295,6 +303,7 @@ export default class BOSH extends Duplex implements Transport { this.process(result); }) .catch(err => { + this.client.emit('--transport-error', err); this.end(err); }); this.toggleChannel(); diff --git a/src/transports/tcp.ts b/src/transports/tcp.ts new file mode 100644 index 00000000..292e96c9 --- /dev/null +++ b/src/transports/tcp.ts @@ -0,0 +1,237 @@ +import { Duplex } from 'readable-stream'; +import net from 'net'; +import tls, { TLSSocket } from 'tls'; + +import { Agent, Transport, TransportConfig } from '../'; +import { StreamErrorCondition } from '../Constants'; +import StreamManagement from '../helpers/StreamManagement'; +import { Stream, StreamFeatures } from '../protocol'; + +import { JSONData, ParsedData, Registry, StreamParser } from '../jxt'; +import features from '../plugins/features'; + +export default class TCP extends Duplex implements Transport { + public hasStream?: boolean; + public stream?: Stream; + public authenticated?: boolean; + + private client: Agent; + private config!: TransportConfig; + private sm: StreamManagement; + private stanzas: Registry; + private parser!: StreamParser; + + private socket?: net.Socket; + private tlssocket?: TLSSocket; + private isSecure: boolean; + + constructor(client: Agent, sm: StreamManagement, stanzas: Registry) { + super({ objectMode: true }); + this.client = client; + this.sm = sm; + this.stanzas = stanzas; + this.isSecure = false; + + this.on('data', e => { + this.client.emit('stream:data', e.stanza, e.kind); + }); + + this.on('end', () => { + if (this.client.transport === this) { + this.client.emit('--transport-disconnected'); + } + }); + } + + public initParser(): void { + this.parser = new StreamParser({ + acceptLanguages: this.config.acceptLanguages, + allowComments: false, + lang: this.config.lang, + registry: this.stanzas, + wrappedStream: true + }); + + this.parser.on('data', async (e: ParsedData) => { + const name = e.kind; + const stanzaObj = e.stanza; + if (name === 'stream') { + if (stanzaObj.action === 'open') { + this.hasStream = true; + this.stream = stanzaObj; + return this.client.emit('session:prebind', this.config.jid); + } + if (stanzaObj.action === 'close') { + this.client.emit('stream:end'); + return this.disconnect(); + } + } + if (name === 'features') { + if (stanzaObj.tls) { + this.write(``); + } + } + if (name === 'tls') { + if (stanzaObj.type === 'proceed') { + try { + await this.negotiateTls(); + } catch (e) { + this.client.emit('--transport-error', e as Error); + } + } else if (stanzaObj.type === 'failure') { + this.client.emit('--transport-error', new Error('TLS negotiation failed')); + this.socket?.destroy(); + this.emit('end'); + } + } + + this.push({ kind: e.kind, stanza: e.stanza }); + }); + + this.parser.on('error', (err: any) => { + const streamError = { condition: StreamErrorCondition.InvalidXML }; + this.client.emit('stream:error', streamError, err); + this.write(this.stanzas.export('error', streamError)!.toString()); + return this.disconnect(); + }); + } + + async discoverBindings(server: string): Promise<[TransportConfig]> { + return [ + { url: server + ':5222' } as TransportConfig + ]; + } + + public async connect(opts: TransportConfig): Promise { + return await new Promise((resolve, reject) => { + this.config = opts; + this.hasStream = false; + + this.initParser(); + + let host: string = this.config.url!.split(':')[0]; + let port: number = this.config.port || parseInt(this.config.url!.split(':')![1] || '-1'); + + if (port < 0 || port > 65535) { + console.error('Invalid or nonexistent port'); + return; + } + + if (port === 5223 || this.config.directTLS) { + port ||= 5223; + + // direct TLS connection + this.socket = net.connect({ host, port }, async () => { + this.emit('connect'); + this.client.emit('connected'); + try { + await this.negotiateTls(); + } catch (e) { + this.client.emit('--transport-error', e as Error); + } + }); + } else { + // STARTTLS or unsecure connection + this.socket = net.connect({ host, port }, async () => { + this.emit('connect'); + this.client.emit('connected'); + this.openStream(); + }); + + const handleFeatures = (features: StreamFeatures) => { + if (!('tls' in features)) { + if (this.client.config.requireSecureTransport && !this.isSecure) { + this.client.emit('--transport-error', new Error('failed to connect - STARTLS not offered')); + this.client.off('features', handleFeatures); + } else { + this.client.emit('stream:start', {}); + this.client.emit('--transport-connected'); + this.client.off('features', handleFeatures); + } + } + }; + + this.client.on('features', handleFeatures); + this.client.once('--transport-connected', () => resolve()); + this.client.once('--transport-error', e => reject(e)); + this.socket.on('data', packet => this.parser!.write(packet.toString('utf8'))); + } + }); + } + + public _write(chunk: string, encoding: string, done: (err?: Error) => void): void { + const data = Buffer.from(chunk, 'utf8').toString(); + this.client.emit('raw', 'outgoing', data); + (this.tlssocket || this.socket)?.write(data); + done(); + } + + public _read(): void { return; } + + public disconnect(cleanly = true): void { + if (cleanly) this.write(``); + setTimeout(() => { + this.hasStream = false; + (this.tlssocket || this.socket)?.destroy(); + this.emit('end'); + }, cleanly ? 500 : 0); + } + + public restart(): void { + this.hasStream = false; + this.initParser(); + this.openStream(); + } + + public async send(name: string, data?: JSONData): Promise { + let output: string | undefined; + if (data) { + output = this.stanzas.export(name, data)?.toString(); + } + if (!output) { + return; + } + + return new Promise((resolve, reject) => { + this.write(output, 'utf8', err => (err ? reject(err) : resolve())); + }); + } + + private openStream(): void { + this.write(``); + } + + private async negotiateTls(): Promise { + return await new Promise((resolve, reject) => { + this.tlssocket = tls.connect({ + socket: this.socket!, + checkServerIdentity: (host: string, cert: tls.PeerCertificate): Error | undefined => { + if (!this.config.pubkey) return undefined; + if (Buffer.compare((cert as any).pubkey, this.config.pubkey!) !== 0) { + return new Error('failed to connect - invalid certificate'); + } + return undefined; + } + }); + this.initParser(); + this.tlssocket.on('secureConnect', () => { + this.openStream(); + this.isSecure = true; + }); + this.tlssocket.on('data', chunk => { + const data = chunk.toString('utf8'); + this.client.emit('raw', 'incoming', data); + this.parser!.write(data); + }); + this.tlssocket.once('data', () => { + this.client.emit('stream:start', {}); + this.client.emit('--transport-connected'); + resolve(); + }); + this.tlssocket.on('error', e => { + this.tlssocket!.destroy(); + reject(e); + }); + }); + } +} \ No newline at end of file diff --git a/src/transports/websocket.ts b/src/transports/websocket.ts index 28f573d7..f0603ce9 100644 --- a/src/transports/websocket.ts +++ b/src/transports/websocket.ts @@ -32,11 +32,11 @@ export default class WSConnection extends Duplex implements Transport { this.client.emit('stream:data', e.stanza, e.kind); }); - this.on('error', () => { - this.end(); + this.on('error', err => { + this.end(err); }); - - this.on('end', () => { + + this.on('end', (err) => { if (this.client.transport === this) { this.client.emit('--transport-disconnected'); } @@ -49,7 +49,9 @@ export default class WSConnection extends Duplex implements Transport { public _write(chunk: string, encoding: string, done: (err?: Error) => void): void { if (!this.socket || this.socket.readyState !== WS_OPEN) { - return done(new Error('Socket closed')); + const err = new Error('Socket closed'); + this.client.emit('--transport-error', err); + return done(err); } const data = Buffer.from(chunk, 'utf8').toString(); @@ -58,70 +60,76 @@ export default class WSConnection extends Duplex implements Transport { done(); } - public connect(opts: TransportConfig): void { - this.config = opts; - this.hasStream = false; - this.closing = false; - - this.parser = new StreamParser({ - acceptLanguages: this.config.acceptLanguages, - allowComments: false, - lang: this.config.lang, - registry: this.stanzas, - wrappedStream: false - }); - - this.parser.on('data', (e: ParsedData) => { - const name = e.kind; - const stanzaObj = e.stanza; - - if (name === 'stream') { - if (stanzaObj.action === 'open') { - this.hasStream = true; - this.stream = stanzaObj; - return this.client.emit('stream:start', stanzaObj); + public async connect(opts: TransportConfig): Promise { + return await new Promise((resolve, reject) => { + this.config = opts; + this.hasStream = false; + this.closing = false; + + this.parser = new StreamParser({ + acceptLanguages: this.config.acceptLanguages, + allowComments: false, + lang: this.config.lang, + registry: this.stanzas, + wrappedStream: false + }); + + this.parser.on('data', (e: ParsedData) => { + const name = e.kind; + const stanzaObj = e.stanza; + + if (name === 'stream') { + if (stanzaObj.action === 'open') { + this.hasStream = true; + this.stream = stanzaObj; + this.client.emit('--transport-connected'); + return this.client.emit('stream:start', stanzaObj); + } + if (stanzaObj.action === 'close') { + this.client.emit('stream:end'); + return this.disconnect(); + } } - if (stanzaObj.action === 'close') { - this.client.emit('stream:end'); - return this.disconnect(); + + this.push({ kind: e.kind, stanza: e.stanza }); + }); + + this.parser.on('error', (err: any) => { + const streamError = { + condition: StreamErrorCondition.InvalidXML + }; + this.client.emit('stream:error', streamError, err); + this.write(this.stanzas.export('error', streamError)!.toString()); + this.client.emit('--transport-error', err); + return this.disconnect(); + }); + + this.socket = new WebSocket(opts.url!, 'xmpp'); + this.socket.onopen = () => { + this.emit('connect'); + this.sm.started = false; + this.client.emit('connected'); + this.write(this.startHeader()); + }; + this.socket.onmessage = wsMsg => { + const data = Buffer.from(wsMsg.data as string, 'utf8').toString(); + this.client.emit('raw', 'incoming', data); + if (this.parser) { + this.parser.write(data); } - } - - this.push({ kind: e.kind, stanza: e.stanza }); - }); - - this.parser.on('error', (err: any) => { - const streamError = { - condition: StreamErrorCondition.InvalidXML }; - this.client.emit('stream:error', streamError, err); - this.write(this.stanzas.export('error', streamError)!.toString()); - return this.disconnect(); + this.socket.onclose = ({ reason }) => { + this.client.emit('--transport-error', new Error(reason)); + this.push(null); + }; + this.socket.onerror = (err) => { + this.push(null); + }; + this.client.once('--transport-connected', () => resolve()); + this.client.once('--transport-error', e => reject(e)); }); - - this.socket = new WebSocket(opts.url!, 'xmpp'); - this.socket.onopen = () => { - this.emit('connect'); - this.sm.started = false; - this.client.emit('connected'); - this.write(this.startHeader()); - }; - this.socket.onmessage = wsMsg => { - const data = Buffer.from(wsMsg.data as string, 'utf8').toString(); - this.client.emit('raw', 'incoming', data); - if (this.parser) { - this.parser.write(data); - } - }; - this.socket.onclose = () => { - this.push(null); - }; - this.socket.onerror = (err) => { - console.error(err); - this.push(null); - }; } - + public disconnect(clean = true): void { if (this.socket && !this.closing && this.hasStream && clean) { this.closing = true; diff --git a/test/jxt/parser.ts b/test/jxt/parser.ts index c686ce92..d61c746a 100644 --- a/test/jxt/parser.ts +++ b/test/jxt/parser.ts @@ -105,7 +105,7 @@ test('Wait for comment', () => { parser.write('-- -->'); parser.end(); } catch (err) { - console.log(err); + console.error(err); } }); diff --git a/test/live-connection.ts b/test/live-connection.ts index 849c49c1..b8dd2162 100644 --- a/test/live-connection.ts +++ b/test/live-connection.ts @@ -1,4 +1,5 @@ import expect from 'expect'; +import crypto from 'crypto'; import * as stanza from '../src'; test('Connect using WebSocket', done => { @@ -20,6 +21,19 @@ test('Connect using WebSocket', done => { client.connect(); }); +test('Fail to connect using WebSocket', async () => { + expect.assertions(1); + + const client = stanza.createClient({ + jid: 'anon@anon.stanzajs.org', + transports: { + websocket: 'https://anon.stanzajs.org/http-bind' + } + }); + + expect(client.connect).rejects.toThrow(); +}); + test('Connect using BOSH', done => { expect.assertions(1); @@ -39,14 +53,207 @@ test('Connect using BOSH', done => { client.connect(); }); +test('Fail to connect using BOSH', async () => { + expect.assertions(1); + + const client = stanza.createClient({ + jid: 'anon@anon.stanzajs.org', + transports: { + bosh: 'wss://anon.stanzajs.org/xmpp-websocket' + } + }); + + expect(client.connect).rejects.toThrow(); +}); + +test('Connect using TCP (STARTLS)', done => { + expect.assertions(1); + + const client = stanza.createClient({ + jid: 'anon@anon.stanzajs.org', + transports: { + tcp: true + } + }); + + client.on('session:started', () => { + client.disconnect(); + expect(true).toBe(true); + done(); + }); + + client.connect(); +}); + +const validcert = +`-----BEGIN CERTIFICATE----- +MIIFJjCCBA6gAwIBAgISAy/NagHMtd0QlUUAu4XFm1I0MA0GCSqGSIb3DQEBCwUA +MDIxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQswCQYDVQQD +EwJSMzAeFw0yMTA4MjcwNjIzNTBaFw0yMTExMjUwNjIzNDlaMBwxGjAYBgNVBAMT +EWFub24uc3RhbnphanMub3JnMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC +AQEAxsNE9//sy0JB0+LjZQinuk060uL8X7YpTt6D/nJJrwTsIkUe72o8cI2P1gbC +VHGYmx6vIkbs30jcV718atTEjQ7F04L63znHVulYxC3amam1TaZl52hSmatnSLVS +OKCA06bDE3KFZSqf2biBuaFHZ8IxZmGXtwwM/hlPnsMiVwWDGsY3DQBJ5wZDW8s3 +HkQP4H6vxAbBgsS8CUIt62kk4STOE9MsszzFOwWiOZr85X36YXo39AUJPKuczxdS +ybu3ffHdMPkyI9GideHwZ1o429T0aNwf4/HZah7vmw1s43Hf3IM6uYYJEMVS6vAt +7O7z9Vath1rQoEYm5XfmdbvKnwIDAQABo4ICSjCCAkYwDgYDVR0PAQH/BAQDAgWg +MB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjAMBgNVHRMBAf8EAjAAMB0G +A1UdDgQWBBSeipzQEoY2dYXEdckDk0R8gGe+OTAfBgNVHSMEGDAWgBQULrMXt1hW +y65QCUDmH6+dixTCxjBVBggrBgEFBQcBAQRJMEcwIQYIKwYBBQUHMAGGFWh0dHA6 +Ly9yMy5vLmxlbmNyLm9yZzAiBggrBgEFBQcwAoYWaHR0cDovL3IzLmkubGVuY3Iu +b3JnLzAcBgNVHREEFTATghFhbm9uLnN0YW56YWpzLm9yZzBMBgNVHSAERTBDMAgG +BmeBDAECATA3BgsrBgEEAYLfEwEBATAoMCYGCCsGAQUFBwIBFhpodHRwOi8vY3Bz +LmxldHNlbmNyeXB0Lm9yZzCCAQIGCisGAQQB1nkCBAIEgfMEgfAA7gB1AFzcQ5L+ +5qtFRLFemtRW5hA3+9X6R9yhc5SyXub2xw7KAAABe4Z+ULoAAAQDAEYwRAIgAqx1 +vnDicFjeZu63Yec2lxUZ1S1AN7lrfQD26T4kf1wCIFzihMg0t1XHXVF3GJ6XmSkV +h9iI2oSR8KvNjT44djmBAHUA9lyUL9F3MCIUVBgIMJRWjuNNExkzv98MLyALzE7x +ZOMAAAF7hn5QtwAABAMARjBEAiA9hkYqUczSXNoDujgVCygw4aomD+tlszZQil+C +YAo9dwIgMmfGNtAiAn41g7fqyMKk5ZPxdETXa6GL1ACp81gxHAswDQYJKoZIhvcN +AQELBQADggEBAELaNEgQeEiNp89JkyQw0xITyID6lpneyGitRCDYTZvOanzhaqX/ +Uhlb021QksD0uuE6eJ5YbyjB8G6TmlwAyp+Urq6lrghLfx/4nJ74nqV4YX3gzfta +AMW/WN5A+OjSHOnXd00hWJo9tybMzMLcLBdRZaDdW1zGOzA1ZI2lxXSh8i5Yoavi +1IPo28JsDFIx+JLdFvxN5MZBnOnYxyRddh1KsEnRNV0MpLbIpt7I2diINlCFif76 +PBhwcAxmAK1RMIO1ubvr83vx1Aaw45i5pusKe8JOWPRsgBdaaVMBr7XUFTtp7OE0 +KJcPvKcw7lee3i9IjSkr7xpLFz5XIKGrD8c= +-----END CERTIFICATE-----`; + +const invalidcert = +`-----BEGIN CERTIFICATE----- +MIIEhzCCA2+gAwIBAgIQBzqkk7k/YrYKAAAAAPuB6DANBgkqhkiG9w0BAQsFADBG +MQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExM +QzETMBEGA1UEAxMKR1RTIENBIDFDMzAeFw0yMTA4MjMwNDAzNDRaFw0yMTExMTUw +NDAzNDNaMBkxFzAVBgNVBAMTDnd3dy5nb29nbGUuY29tMFkwEwYHKoZIzj0CAQYI +KoZIzj0DAQcDQgAEtAzrBmnqksqM0fypfchLIYZCi1ZLifdynZglgoP0mlMEZVDs +MLFVPucGmBTIORvWhfKzIyUNGHIn9r5+dnaiM6OCAmcwggJjMA4GA1UdDwEB/wQE +AwIHgDATBgNVHSUEDDAKBggrBgEFBQcDATAMBgNVHRMBAf8EAjAAMB0GA1UdDgQW +BBQZDN5lrOyr62P9JMXMbT/M8BdMCzAfBgNVHSMEGDAWgBSKdH+vhc3ulc09nNDi +RhTzcTUdJzBqBggrBgEFBQcBAQReMFwwJwYIKwYBBQUHMAGGG2h0dHA6Ly9vY3Nw +LnBraS5nb29nL2d0czFjMzAxBggrBgEFBQcwAoYlaHR0cDovL3BraS5nb29nL3Jl +cG8vY2VydHMvZ3RzMWMzLmRlcjAZBgNVHREEEjAQgg53d3cuZ29vZ2xlLmNvbTAh +BgNVHSAEGjAYMAgGBmeBDAECATAMBgorBgEEAdZ5AgUDMDwGA1UdHwQ1MDMwMaAv +oC2GK2h0dHA6Ly9jcmxzLnBraS5nb29nL2d0czFjMy9RT3ZKME4xc1QyQS5jcmww +ggEEBgorBgEEAdZ5AgQCBIH1BIHyAPAAdwB9PvL4j/+IVWgkwsDKnlKJeSvFDngJ +fy5ql2iZfiLw1wAAAXtxZKTzAAAEAwBIMEYCIQCAct1r7Lt0HrHLsxtDwveb3Ny+ +MNX0PcF6RzPQ0aijeAIhAKca0H/O2Kgf80/KNTdldTd0PyppJ7ouFy8imDdL19uJ +AHUAXNxDkv7mq0VEsV6a1FbmEDf71fpH3KFzlLJe5vbHDsoAAAF7cWSlqAAABAMA +RjBEAiBR0gYJZg2FwaK3FHCALReafzSlj7T5UCh3nHZbDxG8vAIgLTD31R9xCyrG +UlK1Thw76H0di2ziYXCh/AEiLpLn90gwDQYJKoZIhvcNAQELBQADggEBANMroXvs +YknyxdElXC2xbNWo6OSAEjof9EQmIBYDqWiToqO17Omois1qA6bF3bdqBZRaXIwl +Ut5jqmEBIEmt27e1nVDkOrY7/xhglz0BBn65pBlLGQmwl6/xSicGG0i1+SDJzB+7 +b8po3s8G7BQ9tZq6uBhPXuiupfxr1co7FFo4v0GWtjTHC15/2upSfvlUu7OU2n2q +su+jEUMo1fJqaF6rioEKhWJHv1ZqPQf59CFxM8uq1reusoqY0bM7VMymJlrgnIMJ +AJC06U3ArWErYVyjuqkfbm6TDbqjy3TSGUwvmkQT6sODJMz8gEXAn9R4lNtg62Ci +rMOU4YMvqw/caKo= +-----END CERTIFICATE----- +`; +test('TCP/TLS Invalid Certificate', async () => { + expect.assertions(1); + + const pubkey = crypto.createPublicKey(invalidcert).export({ type: 'spki', format: 'der' }); + const client = stanza.createClient({ + jid: 'anon@anon.stanzajs.org', + transports: { + tcp: { url: 'anon.stanzajs.org', port: 5222, pubkey } + }, + }); + + const fn = jest.fn(); + await (client.connect as () => Promise)().catch(fn); + expect(fn).toHaveBeenCalledTimes(1); +}); + +test('TCP/TLS Valid Certificate', async done => { + expect.assertions(1); + + const pubkey = crypto.createPublicKey(validcert).export({ type: 'spki', format: 'der' }); + const client = stanza.createClient({ + jid: 'anon@anon.stanzajs.org', + transports: { + tcp: { url: 'anon.stanzajs.org', port: 5222, pubkey } + }, + }); + + client.on('session:started', () => { + client.disconnect(); + expect(true).toBe(true); + done(); + }); + await client.connect(); +}); + +test('requireSecureTransport fail', async () => { + expect.assertions(1); + + const client = stanza.createClient({ + jid: 'anon@anon.stanzajs.org', + transports: { + tcp: false, + websocket: 'ws://anon.stanzajs.org/xmpp-websocket', + bosh: 'http://anon.stanzajs.org/http-bind', + }, + requireSecureTransport: true + }); + + expect(client.connect).rejects.toThrow(); +}); + +test('requireSecureTransport succeed', async done => { + expect.assertions(1); + + const client = stanza.createClient({ + jid: 'anon@anon.stanzajs.org', + transports: { + tcp: false, + websocket: 'ws://anon.stanzajs.org/xmpp-websocket', + bosh: 'https://anon.stanzajs.org/http-bind', + }, + requireSecureTransport: true, + transportPreferenceOrder: ['websocket', 'bosh'], + }); + + client.on('session:started', () => { + client.disconnect(); + expect(true).toBe(true); + done(); + }); + await client.connect(); +}); + +test('Pick alternative', async done => { + expect.assertions(1); + + const client = stanza.createClient({ + jid: 'anon@anon.stanzajs.org', + transports: { + tcp: false, + websocket: 'wss://anon.stanzajs.org/invalid-path', + bosh: 'https://anon.stanzajs.org/http-bind', + }, + transportPreferenceOrder: ['tcp', 'websocket', 'bosh'] + }); + + client.on('session:started', () => { + client.disconnect(); + expect(true).toBe(true); + done(); + }); + await client.connect(); +}); + test('End to end', done => { expect.assertions(2); const client1 = stanza.createClient({ - jid: 'anon@anon.stanzajs.org' + jid: 'anon@anon.stanzajs.org', + transports: { + websocket: 'wss://anon.stanzajs.org/xmpp-websocket' + } }); const client2 = stanza.createClient({ - jid: 'anon@anon.stanzajs.org' + jid: 'anon@anon.stanzajs.org', + transports: { + websocket: 'wss://anon.stanzajs.org/xmpp-websocket' + } }); client1.on('session:started', async () => { diff --git a/webpack.config.js b/webpack.config.js index 3571ca5b..089a0cb4 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -23,4 +23,4 @@ module.exports = { reportFilename: 'webpack-stats.html' }) ] -}; +}; \ No newline at end of file