You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

strophe.jingle.js 17KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492
  1. /* global $, __filename */
  2. import { getLogger } from '@jitsi/logger';
  3. import { $iq, Strophe } from 'strophe.js';
  4. import * as MediaType from '../../service/RTC/MediaType';
  5. import {
  6. ACTION_JINGLE_TR_RECEIVED,
  7. ACTION_JINGLE_TR_SUCCESS,
  8. createJingleEvent
  9. } from '../../service/statistics/AnalyticsEvents';
  10. import XMPPEvents from '../../service/xmpp/XMPPEvents';
  11. import Statistics from '../statistics/statistics';
  12. import GlobalOnErrorHandler from '../util/GlobalOnErrorHandler';
  13. import RandomUtil from '../util/RandomUtil';
  14. import ConnectionPlugin from './ConnectionPlugin';
  15. import { expandSourcesFromJson } from './JingleHelperFunctions';
  16. import JingleSessionPC from './JingleSessionPC';
  17. const logger = getLogger(__filename);
  18. // XXX Strophe is build around the idea of chaining function calls so allow long
  19. // function call chains.
  20. /* eslint-disable newline-per-chained-call */
  21. /**
  22. * Parses the transport XML element and returns the list of ICE candidates formatted as text.
  23. *
  24. * @param {*} transport Transport XML element extracted from the IQ.
  25. * @returns {Array<string>}
  26. */
  27. function _parseIceCandidates(transport) {
  28. const candidates = $(transport).find('>candidate');
  29. const parseCandidates = [];
  30. // Extract the candidate information from the IQ.
  31. candidates.each((_, candidate) => {
  32. const attributes = candidate.attributes;
  33. const candidateAttrs = [];
  34. for (let i = 0; i < attributes.length; i++) {
  35. const attr = attributes[i];
  36. candidateAttrs.push(`${attr.name}: ${attr.value}`);
  37. }
  38. parseCandidates.push(candidateAttrs.join(' '));
  39. });
  40. return parseCandidates;
  41. }
  42. /**
  43. *
  44. */
  45. export default class JingleConnectionPlugin extends ConnectionPlugin {
  46. /**
  47. * Creates new <tt>JingleConnectionPlugin</tt>
  48. * @param {XMPP} xmpp
  49. * @param {EventEmitter} eventEmitter
  50. * @param {Object} iceConfig an object that holds the iceConfig to be passed
  51. * to the p2p and the jvb <tt>PeerConnection</tt>.
  52. */
  53. constructor(xmpp, eventEmitter, iceConfig) {
  54. super();
  55. this.xmpp = xmpp;
  56. this.eventEmitter = eventEmitter;
  57. this.sessions = {};
  58. this.jvbIceConfig = iceConfig.jvb;
  59. this.p2pIceConfig = iceConfig.p2p;
  60. this.mediaConstraints = {
  61. offerToReceiveAudio: true,
  62. offerToReceiveVideo: true
  63. };
  64. }
  65. /**
  66. *
  67. * @param connection
  68. */
  69. init(connection) {
  70. super.init(connection);
  71. this.connection.addHandler(this.onJingle.bind(this),
  72. 'urn:xmpp:jingle:1', 'iq', 'set', null, null);
  73. }
  74. /**
  75. *
  76. * @param iq
  77. */
  78. onJingle(iq) {
  79. const sid = $(iq).find('jingle').attr('sid');
  80. const action = $(iq).find('jingle').attr('action');
  81. const fromJid = iq.getAttribute('from');
  82. // send ack first
  83. const ack = $iq({ type: 'result',
  84. to: fromJid,
  85. id: iq.getAttribute('id')
  86. });
  87. let sess = this.sessions[sid];
  88. if (action !== 'session-initiate') {
  89. if (!sess) {
  90. ack.attrs({ type: 'error' });
  91. ack.c('error', { type: 'cancel' })
  92. .c('item-not-found', {
  93. xmlns: 'urn:ietf:params:xml:ns:xmpp-stanzas'
  94. })
  95. .up()
  96. .c('unknown-session', {
  97. xmlns: 'urn:xmpp:jingle:errors:1'
  98. });
  99. logger.warn(`invalid session id: ${sid}`);
  100. logger.debug(iq);
  101. this.connection.send(ack);
  102. return true;
  103. }
  104. // local jid is not checked
  105. if (fromJid !== sess.remoteJid) {
  106. logger.warn(
  107. 'jid mismatch for session id', sid, sess.remoteJid, iq);
  108. ack.attrs({ type: 'error' });
  109. ack.c('error', { type: 'cancel' })
  110. .c('item-not-found', {
  111. xmlns: 'urn:ietf:params:xml:ns:xmpp-stanzas'
  112. })
  113. .up()
  114. .c('unknown-session', {
  115. xmlns: 'urn:xmpp:jingle:errors:1'
  116. });
  117. this.connection.send(ack);
  118. return true;
  119. }
  120. } else if (sess !== undefined) {
  121. // Existing session with same session id. This might be out-of-order
  122. // if the sess.remoteJid is the same as from.
  123. ack.attrs({ type: 'error' });
  124. ack.c('error', { type: 'cancel' })
  125. .c('service-unavailable', {
  126. xmlns: 'urn:ietf:params:xml:ns:xmpp-stanzas'
  127. })
  128. .up();
  129. logger.warn('duplicate session id', sid, iq);
  130. this.connection.send(ack);
  131. return true;
  132. }
  133. const now = window.performance.now();
  134. // FIXME that should work most of the time, but we'd have to
  135. // think how secure it is to assume that user with "focus"
  136. // nickname is Jicofo.
  137. const isP2P = Strophe.getResourceFromJid(fromJid) !== 'focus';
  138. // see http://xmpp.org/extensions/xep-0166.html#concepts-session
  139. const jsonMessages = $(iq).find('jingle>json-message');
  140. if (jsonMessages?.length) {
  141. let audioVideoSsrcs;
  142. logger.info(`Found a JSON-encoded element in ${action}, translating to standard Jingle.`);
  143. for (let i = 0; i < jsonMessages.length; i++) {
  144. // Currently there is always a single json-message in the IQ with the source information.
  145. audioVideoSsrcs = expandSourcesFromJson(iq, jsonMessages[i]);
  146. }
  147. if (audioVideoSsrcs?.size) {
  148. const logMessage = [];
  149. for (const endpoint of audioVideoSsrcs.keys()) {
  150. logMessage.push(`${endpoint}:[${audioVideoSsrcs.get(endpoint)}]`);
  151. }
  152. logger.debug(`Received ${action} from ${fromJid} with sources=${logMessage.join(', ')}`);
  153. }
  154. // TODO: is there a way to remove the json-message elements once we've extracted the information?
  155. // removeChild doesn't seem to work.
  156. }
  157. switch (action) {
  158. case 'session-initiate': {
  159. logger.log('(TIME) received session-initiate:\t', now);
  160. const startMuted = $(iq).find('jingle>startmuted');
  161. isP2P && logger.debug(`Received ${action} from ${fromJid}`);
  162. if (startMuted?.length) {
  163. const audioMuted = startMuted.attr(MediaType.AUDIO);
  164. const videoMuted = startMuted.attr(MediaType.VIDEO);
  165. this.eventEmitter.emit(
  166. XMPPEvents.START_MUTED_FROM_FOCUS,
  167. audioMuted === 'true',
  168. videoMuted === 'true');
  169. }
  170. const pcConfig = isP2P ? this.p2pIceConfig : this.jvbIceConfig;
  171. sess
  172. = new JingleSessionPC(
  173. $(iq).find('jingle').attr('sid'),
  174. $(iq).attr('to'),
  175. fromJid,
  176. this.connection,
  177. this.mediaConstraints,
  178. // Makes a copy in order to prevent exception thrown on RN when either this.p2pIceConfig or
  179. // this.jvbIceConfig is modified and there's a PeerConnection instance holding a reference
  180. JSON.parse(JSON.stringify(pcConfig)),
  181. isP2P,
  182. /* initiator */ false);
  183. this.sessions[sess.sid] = sess;
  184. this.eventEmitter.emit(XMPPEvents.CALL_INCOMING, sess, $(iq).find('>jingle'), now);
  185. break;
  186. }
  187. case 'session-accept': {
  188. const ssrcs = [];
  189. const contents = $(iq).find('jingle>content');
  190. // Extract the SSRCs from the session-accept received from a p2p peer.
  191. for (const content of contents) {
  192. const ssrc = $(content).find('description').attr('ssrc');
  193. ssrc && ssrcs.push(ssrc);
  194. }
  195. logger.debug(`Received ${action} from ${fromJid} with ssrcs=${ssrcs}`);
  196. this.eventEmitter.emit(XMPPEvents.CALL_ACCEPTED, sess, $(iq).find('>jingle'));
  197. break;
  198. }
  199. case 'content-modify': {
  200. const height = $(iq).find('jingle>content[name="video"]>max-frame-height');
  201. logger.debug(`Received ${action} from ${fromJid} with a max-frame-height=${height?.text()}`);
  202. sess.modifyContents($(iq).find('>jingle'));
  203. break;
  204. }
  205. case 'transport-info': {
  206. const candidates = _parseIceCandidates($(iq).find('jingle>content>transport'));
  207. logger.debug(`Received ${action} from ${fromJid} for candidates=${candidates.join(', ')}`);
  208. this.eventEmitter.emit(XMPPEvents.TRANSPORT_INFO, sess, $(iq).find('>jingle'));
  209. break;
  210. }
  211. case 'session-terminate': {
  212. logger.log('terminating...', sess.sid);
  213. let reasonCondition = null;
  214. let reasonText = null;
  215. if ($(iq).find('>jingle>reason').length) {
  216. reasonCondition
  217. = $(iq).find('>jingle>reason>:first')[0].tagName;
  218. reasonText = $(iq).find('>jingle>reason>text').text();
  219. }
  220. logger.debug(`Received ${action} from ${fromJid} disconnect reason=${reasonText}`);
  221. this.terminate(sess.sid, reasonCondition, reasonText);
  222. this.eventEmitter.emit(XMPPEvents.CALL_ENDED, sess, reasonCondition, reasonText);
  223. break;
  224. }
  225. case 'transport-replace': {
  226. logger.info('(TIME) Start transport replace:\t', now);
  227. const transport = $(iq).find('jingle>content>transport');
  228. const candidates = _parseIceCandidates(transport);
  229. const iceUfrag = $(transport).attr('ufrag');
  230. const icePwd = $(transport).attr('pwd');
  231. const dtlsFingerprint = $(transport).find('>fingerprint')?.text();
  232. logger.debug(`Received ${action} from ${fromJid} with iceUfrag=${iceUfrag},`
  233. + ` icePwd=${icePwd}, DTLS fingerprint=${dtlsFingerprint}, candidates=${candidates.join(', ')}`);
  234. Statistics.sendAnalytics(createJingleEvent(
  235. ACTION_JINGLE_TR_RECEIVED,
  236. {
  237. p2p: isP2P,
  238. value: now
  239. }));
  240. sess.replaceTransport($(iq).find('>jingle'), () => {
  241. const successTime = window.performance.now();
  242. logger.info('(TIME) Transport replace success:\t', successTime);
  243. Statistics.sendAnalytics(createJingleEvent(
  244. ACTION_JINGLE_TR_SUCCESS,
  245. {
  246. p2p: isP2P,
  247. value: successTime
  248. }));
  249. }, error => {
  250. GlobalOnErrorHandler.callErrorHandler(error);
  251. logger.error('Transport replace failed', error);
  252. sess.sendTransportReject();
  253. });
  254. break;
  255. }
  256. case 'source-add':
  257. sess.addRemoteStream($(iq).find('>jingle>content'));
  258. break;
  259. case 'source-remove':
  260. sess.removeRemoteStream($(iq).find('>jingle>content'));
  261. break;
  262. default:
  263. logger.warn('jingle action not implemented', action);
  264. ack.attrs({ type: 'error' });
  265. ack.c('error', { type: 'cancel' })
  266. .c('bad-request',
  267. { xmlns: 'urn:ietf:params:xml:ns:xmpp-stanzas' })
  268. .up();
  269. break;
  270. }
  271. this.connection.send(ack);
  272. return true;
  273. }
  274. /**
  275. * Creates new <tt>JingleSessionPC</tt> meant to be used in a direct P2P
  276. * connection, configured as 'initiator'.
  277. * @param {string} me our JID
  278. * @param {string} peer remote participant's JID
  279. * @return {JingleSessionPC}
  280. */
  281. newP2PJingleSession(me, peer) {
  282. const sess
  283. = new JingleSessionPC(
  284. RandomUtil.randomHexString(12),
  285. me,
  286. peer,
  287. this.connection,
  288. this.mediaConstraints,
  289. this.p2pIceConfig,
  290. /* P2P */ true,
  291. /* initiator */ true);
  292. this.sessions[sess.sid] = sess;
  293. return sess;
  294. }
  295. /**
  296. *
  297. * @param sid
  298. * @param reasonCondition
  299. * @param reasonText
  300. */
  301. terminate(sid, reasonCondition, reasonText) {
  302. if (this.sessions.hasOwnProperty(sid)) {
  303. if (this.sessions[sid].state !== 'ended') {
  304. this.sessions[sid].onTerminated(reasonCondition, reasonText);
  305. }
  306. delete this.sessions[sid];
  307. }
  308. }
  309. /**
  310. *
  311. */
  312. getStunAndTurnCredentials() {
  313. // get stun and turn configuration from server via xep-0215
  314. // uses time-limited credentials as described in
  315. // http://tools.ietf.org/html/draft-uberti-behave-turn-rest-00
  316. //
  317. // See https://modules.prosody.im/mod_turncredentials.html
  318. // for a prosody module which implements this.
  319. // Or the new implementation https://modules.prosody.im/mod_external_services which will be in prosody 0.12
  320. //
  321. // Currently, this doesn't work with updateIce and therefore credentials
  322. // with a long validity have to be fetched before creating the
  323. // peerconnection.
  324. // TODO: implement refresh via updateIce as described in
  325. // https://code.google.com/p/webrtc/issues/detail?id=1650
  326. this.connection.sendIQ(
  327. $iq({ type: 'get',
  328. to: this.xmpp.options.hosts.domain })
  329. .c('services', { xmlns: 'urn:xmpp:extdisco:2' }),
  330. v2Res => this.onReceiveStunAndTurnCredentials(v2Res),
  331. v2Err => {
  332. logger.warn('getting turn credentials with extdisco:2 failed, trying extdisco:1', v2Err);
  333. this.connection.sendIQ(
  334. $iq({ type: 'get',
  335. to: this.xmpp.options.hosts.domain })
  336. .c('services', { xmlns: 'urn:xmpp:extdisco:1' }),
  337. v1Res => this.onReceiveStunAndTurnCredentials(v1Res),
  338. v1Err => {
  339. logger.warn('getting turn credentials failed', v1Err);
  340. logger.warn('is mod_turncredentials or similar installed and configured?');
  341. }
  342. );
  343. });
  344. }
  345. /**
  346. * Parses response when querying for services using urn:xmpp:extdisco:1 or urn:xmpp:extdisco:2.
  347. * Stores results in jvbIceConfig and p2pIceConfig.
  348. * @param res The response iq.
  349. * @return {boolean} Whether something was processed from the supplied message.
  350. */
  351. onReceiveStunAndTurnCredentials(res) {
  352. const iceservers = [];
  353. $(res).find('>services>service').each((idx, el) => {
  354. // eslint-disable-next-line no-param-reassign
  355. el = $(el);
  356. const dict = {};
  357. const type = el.attr('type');
  358. switch (type) {
  359. case 'stun':
  360. dict.urls = `stun:${el.attr('host')}`;
  361. if (el.attr('port')) {
  362. dict.urls += `:${el.attr('port')}`;
  363. }
  364. iceservers.push(dict);
  365. break;
  366. case 'turn':
  367. case 'turns': {
  368. dict.urls = `${type}:`;
  369. dict.username = el.attr('username');
  370. dict.urls += el.attr('host');
  371. const port = el.attr('port');
  372. if (port) {
  373. dict.urls += `:${el.attr('port')}`;
  374. }
  375. const transport = el.attr('transport');
  376. if (transport && transport !== 'udp') {
  377. dict.urls += `?transport=${transport}`;
  378. }
  379. dict.credential = el.attr('password')
  380. || dict.credential;
  381. iceservers.push(dict);
  382. break;
  383. }
  384. }
  385. });
  386. const options = this.xmpp.options;
  387. // Shuffle ICEServers for loadbalancing
  388. for (let i = iceservers.length - 1; i > 0; i--) {
  389. const j = Math.floor(Math.random() * (i + 1));
  390. const temp = iceservers[i];
  391. iceservers[i] = iceservers[j];
  392. iceservers[j] = temp;
  393. }
  394. let filter;
  395. if (options.useTurnUdp) {
  396. filter = s => s.urls.startsWith('turn');
  397. } else {
  398. // By default we filter out STUN and TURN/UDP and leave only TURN/TCP.
  399. filter = s => s.urls.startsWith('turn') && (s.urls.indexOf('transport=tcp') >= 0);
  400. }
  401. this.jvbIceConfig.iceServers = iceservers.filter(filter);
  402. this.p2pIceConfig.iceServers = iceservers;
  403. return iceservers.length > 0;
  404. }
  405. /**
  406. * Returns the data saved in 'updateLog' in a format to be logged.
  407. */
  408. getLog() {
  409. const data = {};
  410. Object.keys(this.sessions).forEach(sid => {
  411. const session = this.sessions[sid];
  412. const pc = session.peerconnection;
  413. if (pc && pc.updateLog) {
  414. // FIXME: should probably be a .dump call
  415. data[`jingle_${sid}`] = {
  416. updateLog: pc.updateLog,
  417. stats: pc.stats,
  418. url: window.location.href
  419. };
  420. }
  421. });
  422. return data;
  423. }
  424. }
  425. /* eslint-enable newline-per-chained-call */