troubleshoot.js 21 KB

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