您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符

strophe.jingle.js 18KB

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