import { getLogger } from '@jitsi/logger'; import $ from 'jquery'; import { $iq, Strophe } from 'strophe.js'; import { MediaType } from '../../service/RTC/MediaType'; import { ACTION_JINGLE_TR_RECEIVED, ACTION_JINGLE_TR_SUCCESS, createJingleEvent } from '../../service/statistics/AnalyticsEvents'; import { XMPPEvents } from '../../service/xmpp/XMPPEvents'; import Statistics from '../statistics/statistics'; import GlobalOnErrorHandler from '../util/GlobalOnErrorHandler'; import RandomUtil from '../util/RandomUtil'; import ConnectionPlugin from './ConnectionPlugin'; import { expandSourcesFromJson } from './JingleHelperFunctions'; import JingleSessionPC from './JingleSessionPC'; const logger = getLogger(__filename); // XXX Strophe is build around the idea of chaining function calls so allow long // function call chains. /* eslint-disable newline-per-chained-call */ /** * Parses the transport XML element and returns the list of ICE candidates formatted as text. * * @param {*} transport Transport XML element extracted from the IQ. * @returns {Array} */ function _parseIceCandidates(transport) { const candidates = $(transport).find('>candidate'); const parseCandidates = []; // Extract the candidate information from the IQ. candidates.each((_, candidate) => { const attributes = candidate.attributes; const candidateAttrs = []; for (let i = 0; i < attributes.length; i++) { const attr = attributes[i]; candidateAttrs.push(`${attr.name}: ${attr.value}`); } parseCandidates.push(candidateAttrs.join(' ')); }); return parseCandidates; } /** * */ export default class JingleConnectionPlugin extends ConnectionPlugin { /** * Creates new JingleConnectionPlugin * @param {XMPP} xmpp * @param {EventEmitter} eventEmitter * @param {Object} iceConfig an object that holds the iceConfig to be passed * to the p2p and the jvb PeerConnection. */ constructor(xmpp, eventEmitter, iceConfig) { super(); this.xmpp = xmpp; this.eventEmitter = eventEmitter; this.sessions = {}; this.jvbIceConfig = iceConfig.jvb; this.p2pIceConfig = iceConfig.p2p; this.mediaConstraints = { offerToReceiveAudio: true, offerToReceiveVideo: true }; } /** * * @param connection */ init(connection) { super.init(connection); this.connection.addHandler(this.onJingle.bind(this), 'urn:xmpp:jingle:1', 'iq', 'set', null, null); } /** * * @param iq */ onJingle(iq) { const sid = $(iq).find('jingle').attr('sid'); const action = $(iq).find('jingle').attr('action'); const fromJid = iq.getAttribute('from'); // send ack first const ack = $iq({ type: 'result', to: fromJid, id: iq.getAttribute('id') }); let sess = this.sessions[sid]; if (action !== 'session-initiate') { if (!sess) { ack.attrs({ type: 'error' }); ack.c('error', { type: 'cancel' }) .c('item-not-found', { xmlns: 'urn:ietf:params:xml:ns:xmpp-stanzas' }) .up() .c('unknown-session', { xmlns: 'urn:xmpp:jingle:errors:1' }); logger.warn(`invalid session id: ${sid}`); logger.debug(iq); this.connection.send(ack); return true; } // local jid is not checked if (fromJid !== sess.remoteJid) { logger.warn( 'jid mismatch for session id', sid, sess.remoteJid, iq); ack.attrs({ type: 'error' }); ack.c('error', { type: 'cancel' }) .c('item-not-found', { xmlns: 'urn:ietf:params:xml:ns:xmpp-stanzas' }) .up() .c('unknown-session', { xmlns: 'urn:xmpp:jingle:errors:1' }); this.connection.send(ack); return true; } } else if (sess !== undefined) { // Existing session with same session id. This might be out-of-order // if the sess.remoteJid is the same as from. ack.attrs({ type: 'error' }); ack.c('error', { type: 'cancel' }) .c('service-unavailable', { xmlns: 'urn:ietf:params:xml:ns:xmpp-stanzas' }) .up(); logger.warn('duplicate session id', sid, iq); this.connection.send(ack); return true; } const now = window.performance.now(); // FIXME that should work most of the time, but we'd have to // think how secure it is to assume that user with "focus" // nickname is Jicofo. const isP2P = Strophe.getResourceFromJid(fromJid) !== 'focus'; // see http://xmpp.org/extensions/xep-0166.html#concepts-session const jsonMessages = $(iq).find('jingle>json-message'); if (jsonMessages?.length) { let audioVideoSsrcs; logger.info(`Found a JSON-encoded element in ${action}, translating to standard Jingle.`); for (let i = 0; i < jsonMessages.length; i++) { // Currently there is always a single json-message in the IQ with the source information. audioVideoSsrcs = expandSourcesFromJson(iq, jsonMessages[i]); } if (audioVideoSsrcs?.size) { const logMessage = []; for (const endpoint of audioVideoSsrcs.keys()) { logMessage.push(`${endpoint}:[${audioVideoSsrcs.get(endpoint)}]`); } logger.debug(`Received ${action} from ${fromJid} with sources=${logMessage.join(', ')}`); } // TODO: is there a way to remove the json-message elements once we've extracted the information? // removeChild doesn't seem to work. } switch (action) { case 'session-initiate': { logger.log('(TIME) received session-initiate:\t', now); const startMuted = $(iq).find('jingle>startmuted'); isP2P && logger.debug(`Received ${action} from ${fromJid}`); if (startMuted?.length) { const audioMuted = startMuted.attr(MediaType.AUDIO); const videoMuted = startMuted.attr(MediaType.VIDEO); this.eventEmitter.emit( XMPPEvents.START_MUTED_FROM_FOCUS, audioMuted === 'true', videoMuted === 'true'); } const pcConfig = isP2P ? this.p2pIceConfig : this.jvbIceConfig; sess = new JingleSessionPC( $(iq).find('jingle').attr('sid'), $(iq).attr('to'), fromJid, this.connection, this.mediaConstraints, // Makes a copy in order to prevent exception thrown on RN when either this.p2pIceConfig or // this.jvbIceConfig is modified and there's a PeerConnection instance holding a reference JSON.parse(JSON.stringify(pcConfig)), isP2P, /* initiator */ false); this.sessions[sess.sid] = sess; this.eventEmitter.emit(XMPPEvents.CALL_INCOMING, sess, $(iq).find('>jingle'), now); break; } case 'session-accept': { const ssrcs = []; const contents = $(iq).find('jingle>content'); // Extract the SSRCs from the session-accept received from a p2p peer. for (const content of contents) { const ssrc = $(content).find('description').attr('ssrc'); ssrc && ssrcs.push(ssrc); } logger.debug(`Received ${action} from ${fromJid} with ssrcs=${ssrcs}`); this.eventEmitter.emit(XMPPEvents.CALL_ACCEPTED, sess, $(iq).find('>jingle')); break; } case 'content-modify': { logger.debug(`Received ${action} from ${fromJid}`); sess.modifyContents($(iq).find('>jingle')); break; } case 'transport-info': { const candidates = _parseIceCandidates($(iq).find('jingle>content>transport')); logger.debug(`Received ${action} from ${fromJid} for candidates=${candidates.join(', ')}`); this.eventEmitter.emit(XMPPEvents.TRANSPORT_INFO, sess, $(iq).find('>jingle')); break; } case 'session-terminate': { logger.log('terminating...', sess.sid); let reasonCondition = null; let reasonText = null; if ($(iq).find('>jingle>reason').length) { reasonCondition = $(iq).find('>jingle>reason>:first')[0].tagName; reasonText = $(iq).find('>jingle>reason>text').text(); } logger.debug(`Received ${action} from ${fromJid} disconnect reason=${reasonText}`); this.terminate(sess.sid, reasonCondition, reasonText); this.eventEmitter.emit(XMPPEvents.CALL_ENDED, sess, reasonCondition, reasonText); break; } case 'transport-replace': { logger.info('(TIME) Start transport replace:\t', now); const transport = $(iq).find('jingle>content>transport'); const candidates = _parseIceCandidates(transport); const iceUfrag = $(transport).attr('ufrag'); const icePwd = $(transport).attr('pwd'); const dtlsFingerprint = $(transport).find('>fingerprint')?.text(); logger.debug(`Received ${action} from ${fromJid} with iceUfrag=${iceUfrag},` + ` icePwd=${icePwd}, DTLS fingerprint=${dtlsFingerprint}, candidates=${candidates.join(', ')}`); Statistics.sendAnalytics(createJingleEvent( ACTION_JINGLE_TR_RECEIVED, { p2p: isP2P, value: now })); sess.replaceTransport($(iq).find('>jingle'), () => { const successTime = window.performance.now(); logger.info('(TIME) Transport replace success:\t', successTime); Statistics.sendAnalytics(createJingleEvent( ACTION_JINGLE_TR_SUCCESS, { p2p: isP2P, value: successTime })); }, error => { GlobalOnErrorHandler.callErrorHandler(error); logger.error('Transport replace failed', error); sess.sendTransportReject(); }); break; } case 'source-add': sess.addRemoteStream($(iq).find('>jingle>content')); break; case 'source-remove': sess.removeRemoteStream($(iq).find('>jingle>content')); break; default: logger.warn('jingle action not implemented', action); ack.attrs({ type: 'error' }); ack.c('error', { type: 'cancel' }) .c('bad-request', { xmlns: 'urn:ietf:params:xml:ns:xmpp-stanzas' }) .up(); break; } this.connection.send(ack); return true; } /** * Creates new JingleSessionPC meant to be used in a direct P2P * connection, configured as 'initiator'. * @param {string} me our JID * @param {string} peer remote participant's JID * @return {JingleSessionPC} */ newP2PJingleSession(me, peer) { const sess = new JingleSessionPC( RandomUtil.randomHexString(12), me, peer, this.connection, this.mediaConstraints, this.p2pIceConfig, /* P2P */ true, /* initiator */ true); this.sessions[sess.sid] = sess; return sess; } /** * * @param sid * @param reasonCondition * @param reasonText */ terminate(sid, reasonCondition, reasonText) { if (this.sessions.hasOwnProperty(sid)) { if (this.sessions[sid].state !== 'ended') { this.sessions[sid].onTerminated(reasonCondition, reasonText); } delete this.sessions[sid]; } } /** * */ getStunAndTurnCredentials() { // get stun and turn configuration from server via xep-0215 // uses time-limited credentials as described in // http://tools.ietf.org/html/draft-uberti-behave-turn-rest-00 // // See https://modules.prosody.im/mod_turncredentials.html // for a prosody module which implements this. // Or the new implementation https://modules.prosody.im/mod_external_services which will be in prosody 0.12 // // Currently, this doesn't work with updateIce and therefore credentials // with a long validity have to be fetched before creating the // peerconnection. // TODO: implement refresh via updateIce as described in // https://code.google.com/p/webrtc/issues/detail?id=1650 this.connection.sendIQ( $iq({ type: 'get', to: this.xmpp.options.hosts.domain }) .c('services', { xmlns: 'urn:xmpp:extdisco:2' }), v2Res => this.onReceiveStunAndTurnCredentials(v2Res), () => { logger.warn('getting turn credentials with extdisco:2 failed, trying extdisco:1'); this.connection.sendIQ( $iq({ type: 'get', to: this.xmpp.options.hosts.domain }) .c('services', { xmlns: 'urn:xmpp:extdisco:1' }), v1Res => this.onReceiveStunAndTurnCredentials(v1Res), () => { logger.warn('getting turn credentials failed'); logger.warn('is mod_turncredentials or similar installed and configured?'); } ); }); } /** * Parses response when querying for services using urn:xmpp:extdisco:1 or urn:xmpp:extdisco:2. * Stores results in jvbIceConfig and p2pIceConfig. * @param res The response iq. * @return {boolean} Whether something was processed from the supplied message. */ onReceiveStunAndTurnCredentials(res) { const iceservers = []; $(res).find('>services>service').each((idx, el) => { // eslint-disable-next-line no-param-reassign el = $(el); const dict = {}; const type = el.attr('type'); switch (type) { case 'stun': dict.urls = `stun:${el.attr('host')}`; if (el.attr('port')) { dict.urls += `:${el.attr('port')}`; } iceservers.push(dict); break; case 'turn': case 'turns': { dict.urls = `${type}:`; dict.username = el.attr('username'); dict.urls += el.attr('host'); const port = el.attr('port'); if (port) { dict.urls += `:${el.attr('port')}`; } const transport = el.attr('transport'); if (transport && transport !== 'udp') { dict.urls += `?transport=${transport}`; } dict.credential = el.attr('password') || dict.credential; iceservers.push(dict); break; } } }); const options = this.xmpp.options; // Shuffle ICEServers for loadbalancing for (let i = iceservers.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); const temp = iceservers[i]; iceservers[i] = iceservers[j]; iceservers[j] = temp; } let filter; if (options.useTurnUdp) { filter = s => s.urls.startsWith('turn'); } else { // By default we filter out STUN and TURN/UDP and leave only TURN/TCP. filter = s => s.urls.startsWith('turn') && (s.urls.indexOf('transport=tcp') >= 0); } this.jvbIceConfig.iceServers = iceservers.filter(filter); this.p2pIceConfig.iceServers = iceservers; return iceservers.length > 0; } /** * Returns the data saved in 'updateLog' in a format to be logged. */ getLog() { const data = {}; Object.keys(this.sessions).forEach(sid => { const session = this.sessions[sid]; const pc = session.peerconnection; if (pc && pc.updateLog) { // FIXME: should probably be a .dump call data[`jingle_${sid}`] = { updateLog: pc.updateLog, stats: pc.stats, url: window.location.href }; } }); return data; } } /* eslint-enable newline-per-chained-call */