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

strophe.jingle.js 16KB

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