Nelze vybrat více než 25 témat Téma musí začínat písmenem nebo číslem, může obsahovat pomlčky („-“) a může být dlouhé až 35 znaků.

strophe.jingle.js 17KB

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