troubleshoot.js 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592
  1. /**
  2. * Create peer connection and bind events to be logged.
  3. * @param role The role (offerer or answerer).
  4. * @returns {RTCPeerConnection}
  5. */
  6. function createPeerConnection(role) {
  7. // Detect safari
  8. const uagent = window.navigator.userAgent.toLowerCase();
  9. const isSafari = /safari/.test(uagent) && /applewebkit/.test(uagent) && !/chrome/.test(uagent);
  10. // Determine ICE servers
  11. let iceServers;
  12. if (isSafari) {
  13. iceServers = [
  14. 'turn:turn.threema.ch:443?transport=udp',
  15. 'turn:turn.threema.ch:443?transport=tcp',
  16. 'turns:turn.threema.ch:443',
  17. ];
  18. } else {
  19. iceServers = [
  20. 'turn:ds-turn.threema.ch:443?transport=udp',
  21. 'turn:ds-turn.threema.ch:443?transport=tcp',
  22. 'turns:ds-turn.threema.ch:443',
  23. ];
  24. }
  25. console.debug('Using ICE servers: ' + iceServers);
  26. const configuration = {iceServers: [{
  27. urls: iceServers,
  28. username: 'threema-angular-test',
  29. credential: 'VaoVnhxKGt2wD20F9bTOgiew6yHQmj4P7y7SE4lrahAjTQC0dpnG32FR4fnrlpKa',
  30. }]};
  31. // Create peer connection
  32. const pc = new RTCPeerConnection(configuration);
  33. pc.addEventListener('negotiationneeded', async () => {
  34. console.info(role, 'Negotiation needed');
  35. });
  36. pc.addEventListener('signalingstatechange', () => {
  37. console.debug(role, 'Signaling state:', pc.signalingState);
  38. });
  39. pc.addEventListener('iceconnectionstatechange', () => {
  40. console.debug(role, 'ICE connection state:', pc.iceConnectionState);
  41. });
  42. pc.addEventListener('icegatheringstatechange', () => {
  43. console.debug(role, 'ICE gathering state:', pc.iceGatheringState);
  44. });
  45. pc.addEventListener('connectionstatechange', () => {
  46. console.debug(role, 'Connection state:', pc.connectionState);
  47. });
  48. pc.addEventListener('icecandidate', (event) => {
  49. console.debug(role, 'ICE candidate:', event.candidate);
  50. });
  51. pc.addEventListener('icecandidateerror', (event) => {
  52. console.error(role, 'ICE candidate error:', event);
  53. });
  54. pc.addEventListener('datachannel', (event) => {
  55. console.info(role, 'Incoming data channel:', event.channel.label);
  56. });
  57. return pc;
  58. }
  59. /**
  60. * Create a data channel and bind events to be logged.
  61. * @param pc The peer connection instance.
  62. * @param role The role (offerer or answerer).
  63. * @param label The label of the data channel.
  64. * @param options The options passed to the RTCDataChannel instance.
  65. * @returns {RTCDataChannel}
  66. */
  67. function createDataChannel(pc, role, label, options) {
  68. // Create data channel and bind events
  69. const dc = pc.createDataChannel(label, options);
  70. dc.addEventListener('open', () => {
  71. console.info(role, label, 'open');
  72. });
  73. dc.addEventListener('close', () => {
  74. console.info(role, label, 'closed');
  75. });
  76. dc.addEventListener('error', () => {
  77. console.error(role, label, 'error:', error);
  78. });
  79. dc.addEventListener('bufferedamountlow', () => {
  80. console.debug(role, label, 'buffered amount low:', dc.bufferedAmount);
  81. });
  82. dc.addEventListener('message', (event) => {
  83. console.debug(role, label, `incoming message:`, event.data);
  84. });
  85. return dc;
  86. }
  87. /**
  88. * Connect the peer connection instances to each other.
  89. * @param offerer The offerer's peer connection instance.
  90. * @param answerer The answerer's peer connection instance.
  91. * @returns {Promise<void>} resolves once connected.
  92. */
  93. async function connectPeerConnections(offerer, answerer) {
  94. const pcs = [offerer, answerer];
  95. // Forward ICE candidates to each other
  96. for (const [me, other] of [pcs, pcs.slice().reverse()]) {
  97. me.addEventListener('icecandidate', (event) => {
  98. if (event.candidate !== null) {
  99. other.addIceCandidate(event.candidate);
  100. }
  101. });
  102. }
  103. // Promise for the peer connections being connected
  104. const [offererConnected, answererConnected] = pcs.map((pc) => {
  105. return new Promise((resolve, reject) => {
  106. pc.addEventListener('iceconnectionstatechange', () => {
  107. switch (pc.iceConnectionState) {
  108. case 'connected':
  109. case 'completed':
  110. resolve(pc.iceConnectionState);
  111. break;
  112. case 'closed':
  113. case 'failed':
  114. reject(pc.iceConnectionState);
  115. break;
  116. }
  117. });
  118. });
  119. });
  120. // Start the offer/answer dance
  121. const signalingDone = new Promise((resolve, reject) => {
  122. offerer.addEventListener('negotiationneeded', async () => {
  123. try {
  124. console.debug('Start signaling');
  125. const offer = await offerer.createOffer();
  126. await offerer.setLocalDescription(offer);
  127. await answerer.setRemoteDescription(offer);
  128. const answer = await answerer.createAnswer();
  129. await answerer.setLocalDescription(answer);
  130. await offerer.setRemoteDescription(answer);
  131. console.debug('Signaling complete');
  132. resolve();
  133. } catch (error) {
  134. reject(error);
  135. }
  136. });
  137. });
  138. // Wait until all is done
  139. await Promise.all([
  140. offererConnected,
  141. answererConnected,
  142. signalingDone,
  143. ]);
  144. }
  145. // Here beginneth the Angular stuff
  146. const app = angular.module('troubleshoot', ['ngSanitize']);
  147. app.filter('osName', function() {
  148. return function(id) {
  149. switch (id) {
  150. case 'android':
  151. return 'Android';
  152. case 'ios':
  153. return 'iOS';
  154. default:
  155. return '?';
  156. }
  157. }
  158. });
  159. app.component('check', {
  160. bindings: {
  161. result: '<',
  162. textNo: '@',
  163. },
  164. template: `
  165. <div class="status status-no" ng-if="$ctrl.result.state === 'no'">
  166. <i class="material-icons md-36" aria-label="No">error</i> <span class="text">No</span>
  167. <p class="small" ng-if="$ctrl.textNo" ng-bind-html="$ctrl.textNo"></p>
  168. </div>
  169. <div class="status status-yes" ng-if="$ctrl.result.state === 'yes'">
  170. <i class="material-icons md-36" aria-label="Yes">check_circle</i> <span class="text">Yes</span>
  171. </div>
  172. <div class="status status-unknown" ng-if="$ctrl.result.state === 'unknown'">
  173. <i class="material-icons md-36" aria-label="Unknown">help</i> <span class="text">Unknown</span>
  174. </div>
  175. <div class="status status-test" ng-if="$ctrl.result.state === 'loading'">
  176. <img src="loading.gif" alt="Loading..." aria-label="Loading">
  177. </div>
  178. <div class="logs" ng-if="$ctrl.result.showLogs">
  179. <p>Results:</p>
  180. <div class="log-data">
  181. <p ng-repeat="log in $ctrl.result.logs">{{ log }}</p>
  182. </div>
  183. </div>
  184. `,
  185. });
  186. const SIGNALING_DATA_CHANNEL_LABEL = 'saltyrtc';
  187. const APP_DATA_CHANNEL_LABEL = 'therme';
  188. app.controller('ChecksController', function($scope, $timeout) {
  189. // Initialize state
  190. this.state = 'init'; // Either 'init' or 'check'
  191. this.os = null; // Either 'android' or 'ios'
  192. // Initialize results
  193. // Valid states: yes, no, unknown, loading
  194. this.resultJs = {
  195. state: 'unknown',
  196. showLogs: false,
  197. };
  198. this.resultLs = {
  199. state: 'unknown',
  200. showLogs: false,
  201. };
  202. this.resultDn = {
  203. state: 'unknown',
  204. showLogs: false,
  205. };
  206. this.resultWs = {
  207. state: 'unknown',
  208. showLogs: false,
  209. logs: [],
  210. };
  211. this.resultDc = {
  212. state: 'unknown',
  213. showLogs: false,
  214. logs: [],
  215. };
  216. this.resultTurn = {
  217. state: 'unknown',
  218. showLogs: false,
  219. logs: [],
  220. };
  221. // Start checks
  222. this.start = (os) => {
  223. this.os = os;
  224. this.state = 'check';
  225. this.doChecks();
  226. };
  227. // Local store can be used
  228. const localStorageAvailable = () => {
  229. const test = 'test';
  230. try {
  231. localStorage.setItem(test, test);
  232. localStorage.removeItem(test);
  233. this.resultLs.state = 'yes';
  234. } catch(e) {
  235. this.resultLs.state = 'no';
  236. }
  237. };
  238. // The desktop notification API is available
  239. const desktopNotificationsAvailable = () => {
  240. this.resultDn.state = 'Notification' in window ? 'yes' : 'no';
  241. };
  242. // A WebSocket connection can be established to the SaltyRTC server
  243. const canEstablishWebSocket = () => {
  244. const subprotocol = 'v1.saltyrtc.org';
  245. const path = 'ffffffffffffffff00000000000000000000000000000000ffffffffffffffff';
  246. this.resultWs.showLogs = true;
  247. const ws = new WebSocket('wss://saltyrtc-ff.threema.ch/' + path, subprotocol);
  248. ws.binaryType = 'arraybuffer';
  249. ws.addEventListener('open', () => {
  250. $scope.$apply(() => {
  251. this.resultWs.logs.push('Connected');
  252. });
  253. });
  254. ws.addEventListener('message', (event) => {
  255. console.log('Message from server ', event.data);
  256. const success = () => {
  257. $scope.$apply(() => {
  258. this.resultWs.state = 'yes';
  259. this.resultWs.logs.push('Received server-hello message');
  260. });
  261. ws.close(1000);
  262. };
  263. const fail = (msg) => {
  264. $scope.$apply(() => {
  265. this.resultWs.state = 'no';
  266. console.error(msg);
  267. this.resultWs.logs.push(`Invalid server-hello message (${msg})`);
  268. });
  269. ws.close(1000);
  270. };
  271. // This should be the SaltyRTC server-hello message.
  272. const bytes = new Uint8Array(event.data);
  273. console.log('Message bytes:', bytes);
  274. // Validate length
  275. if (bytes.length < 81) {
  276. return fail(`Invalid length: ${bytes.length}`);
  277. }
  278. // Split up message
  279. const nonce = bytes.slice(0, 24);
  280. const data = bytes.slice(24);
  281. // Validate nonce
  282. if (nonce[16] !== 0) {
  283. return fail('Invalid nonce (source != 0)');
  284. }
  285. if (nonce[17] !== 0) {
  286. return fail('Invalid nonce (destination != 0)');
  287. }
  288. if (nonce[18] !== 0 || nonce[19] !== 0) {
  289. return fail('Invalid nonce (overflow != 0)');
  290. }
  291. // Data should start with 0x82 (fixmap with 2 entries) followed by a string
  292. // with either the value "type" or "key".
  293. if (data[0] !== 0x82) {
  294. return fail('Invalid data (does not start with 0x82)');
  295. }
  296. if (data[1] === 0xa3 && data[2] === 'k'.charCodeAt(0) && data[3] === 'e'.charCodeAt(0) && data[4] === 'y'.charCodeAt(0)) {
  297. return success();
  298. }
  299. if (data[1] === 0xa4 && data[2] === 't'.charCodeAt(0) && data[3] === 'y'.charCodeAt(0) && data[4] === 'p'.charCodeAt(0) && data[5] === 'e'.charCodeAt(0)) {
  300. return success();
  301. }
  302. return fail('Invalid data (bad map key)');
  303. });
  304. ws.addEventListener('error', (event) => {
  305. console.error('WS error:', event);
  306. $scope.$apply(() => {
  307. this.resultWs.state = 'no';
  308. this.resultWs.logs.push('Error');
  309. });
  310. });
  311. ws.addEventListener('close', () => {
  312. $scope.$apply(() => {
  313. this.resultWs.logs.push('Connection closed');
  314. });
  315. });
  316. this.resultWs.logs.push('Connecting');
  317. };
  318. // A peer-to-peer connection can be established and a data channel can be
  319. // used to send data.
  320. const canEstablishDataChannels = () => {
  321. this.resultDc.showLogs = true;
  322. // Check for the RTCPeerConnecton object
  323. if (window.RTCPeerConnection) {
  324. this.resultDc.logs.push('RTCPeerConnection available');
  325. } else {
  326. this.resultDc.state = 'no';
  327. this.resultDc.logs.push('RTCPeerConnection unavailable');
  328. return;
  329. }
  330. // Check for the RTCDataChannel object
  331. if (window.RTCPeerConnection && (new RTCPeerConnection()).createDataChannel) {
  332. this.resultDc.logs.push('RTCDataChannel available');
  333. } else {
  334. this.resultDc.state = 'no';
  335. this.resultDc.logs.push('RTCDataChannel unavailable');
  336. return;
  337. }
  338. // Create two peer connection instances
  339. let offerer, answerer;
  340. try {
  341. [offerer, answerer] = [
  342. createPeerConnection('Offerer'),
  343. createPeerConnection('Answerer'),
  344. ];
  345. } catch (error) {
  346. this.resultDc.state = 'no';
  347. this.resultDc.logs.push(`Peer connection could not be created (${error.toString()})`);
  348. return;
  349. }
  350. // Async phase begins
  351. this.resultDc.state = 'loading';
  352. const done = (success, message) => {
  353. if (this.resultDc.state === 'loading') {
  354. this.resultDc.state = success ? 'yes' : 'no';
  355. }
  356. this.resultDc.logs.push(message);
  357. offerer.close();
  358. answerer.close();
  359. };
  360. // Connect the peer connection instances to each other
  361. let peerConnectionsEstablished;
  362. try {
  363. peerConnectionsEstablished = connectPeerConnections(offerer, answerer);
  364. } catch (error) {
  365. return done(false, `Peer connections could not be connected (${error.toString()})`);
  366. }
  367. peerConnectionsEstablished
  368. .then(() => {
  369. $scope.$apply(() => this.resultDc.logs.push('Connected'));
  370. })
  371. .catch((error) => {
  372. $scope.$apply(() => done(false, `Cannot connect (error: ${error.toString()})`));
  373. });
  374. // Create data channels for each peer connection instance. We mimic
  375. // what SaltyRTC and the web client would do here:
  376. //
  377. // - create a negotiated data channel with id 0 and send once open, and
  378. // - create a data channel for the ARP on the offerer's side.
  379. const canUseDataChannel = (role, dc, resolve, reject) => {
  380. dc.addEventListener('open', () => {
  381. $scope.$apply(() => {
  382. this.resultDc.logs.push(`${role}: Channel '${dc.label}' open`);
  383. try {
  384. dc.send('hello!');
  385. } catch (error) {
  386. this.resultDc.logs.push(
  387. `${role}: Channel '${dc.label}' was unable to send (${error.toString()})`);
  388. reject();
  389. }
  390. });
  391. });
  392. dc.addEventListener('close', () => {
  393. $scope.$apply(() => {
  394. if (this.resultDc.state === 'loading') {
  395. this.resultDc.logs.push(`${role}: Channel '${dc.label}' closed`);
  396. reject();
  397. }
  398. });
  399. });
  400. dc.addEventListener('error', () => {
  401. $scope.$apply(() => {
  402. this.resultDc.logs.push(`${role}: Channel '${dc.label}' error (${error.message})`);
  403. reject();
  404. });
  405. });
  406. dc.addEventListener('message', (event) => {
  407. $scope.$apply(() => {
  408. if (event.data === 'hello!') {
  409. this.resultDc.logs.push(`${role}: Channel '${dc.label}' working`);
  410. resolve();
  411. } else {
  412. this.resultDc.logs.push(
  413. `${role}: Channel '${dc.label}' received an unexpected message ('${event.data}')`);
  414. reject();
  415. }
  416. });
  417. });
  418. };
  419. try {
  420. Promise.all([
  421. new Promise((resolve, reject) => {
  422. const dc = createDataChannel(
  423. offerer, 'Offerer', SIGNALING_DATA_CHANNEL_LABEL, {id: 0, negotiated: true});
  424. canUseDataChannel('Offerer', dc, resolve, reject);
  425. }),
  426. new Promise((resolve, reject) => {
  427. const dc = createDataChannel(
  428. answerer, 'Answerer', SIGNALING_DATA_CHANNEL_LABEL, {id: 0, negotiated: true});
  429. canUseDataChannel('Answerer', dc, resolve, reject);
  430. }),
  431. // Mimic handover by waiting until the peer connection has
  432. // been established (and an additional second).
  433. peerConnectionsEstablished
  434. .then(() => new Promise((resolve) => setTimeout(resolve, 1000)))
  435. .then(() => new Promise((resolve, reject) => {
  436. const dc = createDataChannel(offerer, 'Offerer', APP_DATA_CHANNEL_LABEL);
  437. canUseDataChannel('Offerer', dc, resolve, reject);
  438. })),
  439. new Promise((resolve, reject) => {
  440. answerer.addEventListener('datachannel', (event) => {
  441. $scope.$apply(() => {
  442. const dc = event.channel;
  443. if (dc.label !== APP_DATA_CHANNEL_LABEL) {
  444. return done(false, `Unexpected 'datachannel' event (channel: ${dc.label})`);
  445. } else {
  446. canUseDataChannel('Answerer', dc, resolve, reject);
  447. }
  448. });
  449. });
  450. }),
  451. ])
  452. .then(() => {
  453. $scope.$apply(() => done(true, 'Data channels open and working'));
  454. })
  455. .catch((error) => {
  456. $scope.$apply(() => done(false, `Cannot connect (error: ${error.toString()})`));
  457. });
  458. } catch (error) {
  459. return done(false, `Data channels could not be created (${error.toString()})`);
  460. }
  461. };
  462. const haveTurnCandidates = () => {
  463. this.resultTurn.showLogs = true;
  464. // Create a peer connection instance
  465. let pc;
  466. try {
  467. pc = createPeerConnection('TURN');
  468. } catch (error) {
  469. this.resultTurn.state = 'no';
  470. this.resultTurn.logs.push(`Peer connection could not be created (${error.toString()})`);
  471. return;
  472. }
  473. // Async phase begins
  474. this.resultTurn.state = 'loading';
  475. const done = (success, message) => {
  476. if (this.resultTurn.state === 'loading') {
  477. this.resultTurn.state = success ? 'yes' : 'no';
  478. }
  479. this.resultTurn.logs.push(message);
  480. pc.close();
  481. };
  482. // Just trigger negotiation...
  483. try {
  484. pc.createDataChannel('kick-the-peer-connection-to-life');
  485. } catch (error) {
  486. return done(false, `Data channel could not be created (${error.toString()})`);
  487. }
  488. // Create timeout
  489. const timer = $timeout(() => this.resultTurn.state = 'no', 10000);
  490. // Create and apply local offer (async)
  491. (async () => {
  492. try {
  493. const offer = await pc.createOffer();
  494. await pc.setLocalDescription(offer);
  495. } catch (error) {
  496. $scope.$apply(() => done(false, `Offer could not be created (${error.toString()})`));
  497. }
  498. })();
  499. // Check for TURN ICE candidates
  500. pc.addEventListener('icecandidate', (event) => {
  501. $scope.$apply(() => {
  502. // Check for end-of-candidates indicator
  503. if (event.candidate === null) {
  504. $timeout.cancel(timer);
  505. return done(false, 'Done');
  506. }
  507. // Handle ICE candidate
  508. if (event.candidate.candidate) {
  509. const candidate = SDPUtils.parseCandidate(event.candidate.candidate);
  510. let info = `[${candidate.type}] ${candidate.ip}:${candidate.port}`;
  511. if (candidate.relatedAddress) {
  512. info += ` via ${candidate.relatedAddress}`;
  513. }
  514. info += ` (${candidate.protocol})`;
  515. // Relay candidate found: Cancel timer
  516. if (candidate.type === 'relay') {
  517. $timeout.cancel(timer);
  518. return done(true, info);
  519. }
  520. // Normal candidate: Log and continue
  521. this.resultTurn.logs.push(info);
  522. } else {
  523. this.resultTurn.logs.push(`Invalid candidate (${event.candidate.candidate})`);
  524. }
  525. });
  526. });
  527. };
  528. // Run all the checks and update results
  529. this.doChecks = () => {
  530. // Check for JS
  531. this.resultJs.state = 'yes';
  532. // Check for LocalStorage
  533. localStorageAvailable();
  534. // Check for desktop notifications
  535. desktopNotificationsAvailable();
  536. // Check for data channel connectivity
  537. canEstablishDataChannels();
  538. // Check for WebSocket connectivity
  539. canEstablishWebSocket();
  540. // Check for TURN connectivity
  541. haveTurnCandidates();
  542. };
  543. });