Parcourir la source

Refactor the troubleshoot tool to actually establish a P2P connection (#838)

We now check that the peer-to-peer connection can be established.
Furthermore, we ensure that data channels are working as expected.
Lennart Grahl il y a 6 ans
Parent
commit
84a921f816
2 fichiers modifiés avec 412 ajouts et 135 suppressions
  1. 2 9
      troubleshoot/index.html
  2. 410 126
      troubleshoot/troubleshoot.js

+ 2 - 9
troubleshoot/index.html

@@ -171,18 +171,11 @@
                     text-no="Threema Web must be able to open a working WebSocket connection to the SaltyRTC signaling server.">
             </div>
 
-            <div id="check-pc" ng-if="$ctrl.os === 'android'">
-                <h2>Is WebRTC available?</h2>
-                <check
-                    result="$ctrl.resultPc"
-                    text-no="RTCPeerConnection is a part of WebRTC.<br>Threema Web cannot work without it.">
-            </div>
-
             <div id="check-dc" ng-if="$ctrl.os === 'android'">
-                <h2>Are WebRTC DataChannels available?</h2>
+                <h2>Are WebRTC DataChannel connections possible?</h2>
                 <check
                     result="$ctrl.resultDc"
-                    text-no="RTCDataChannel is a part of WebRTC.<br>Threema Web cannot work without it.">
+                    text-no="RTCDataChannel is a part of WebRTC.<br>Threema Web for Android cannot work without it.">
             </div>
 
             <div id="check-turn" ng-if="$ctrl.os === 'android'">

+ 410 - 126
troubleshoot/troubleshoot.js

@@ -1,4 +1,158 @@
-var app = angular.module('troubleshoot', ['ngSanitize']);
+/**
+ * Create peer connection and bind events to be logged.
+ * @param role The role (offerer or answerer).
+ * @returns {RTCPeerConnection}
+ */
+function createPeerConnection(role) {
+    // Detect safari
+    const uagent = window.navigator.userAgent.toLowerCase();
+    const isSafari  = /safari/.test(uagent) && /applewebkit/.test(uagent) && !/chrome/.test(uagent);
+
+    // Determine ICE servers
+    let iceServers;
+    if (isSafari) {
+        iceServers = [
+            'turn:turn.threema.ch:443?transport=udp',
+            'turn:turn.threema.ch:443?transport=tcp',
+            'turns:turn.threema.ch:443',
+        ];
+    } else {
+        iceServers = [
+            'turn:ds-turn.threema.ch:443?transport=udp',
+            'turn:ds-turn.threema.ch:443?transport=tcp',
+            'turns:ds-turn.threema.ch:443',
+        ];
+    }
+    console.debug('Using ICE servers: ' + iceServers);
+    const configuration = {iceServers: [{
+        urls: iceServers,
+        username: 'threema-angular-test',
+        credential: 'VaoVnhxKGt2wD20F9bTOgiew6yHQmj4P7y7SE4lrahAjTQC0dpnG32FR4fnrlpKa',
+    }]};
+
+    // Create peer connection
+    const pc = new RTCPeerConnection(configuration);
+    pc.addEventListener('negotiationneeded', async () => {
+        console.info(role, 'Negotiation needed');
+    });
+    pc.addEventListener('signalingstatechange', () => {
+        console.debug(role, 'Signaling state:', pc.signalingState);
+    });
+    pc.addEventListener('iceconnectionstatechange', () => {
+        console.debug(role, 'ICE connection state:', pc.iceConnectionState);
+    });
+    pc.addEventListener('icegatheringstatechange', () => {
+        console.debug(role, 'ICE gathering state:', pc.iceGatheringState);
+    });
+    pc.addEventListener('connectionstatechange', () => {
+        console.debug(role, 'Connection state:', pc.connectionState);
+    });
+    pc.addEventListener('icecandidate', (event) => {
+        console.debug(role, 'ICE candidate:', event.candidate);
+    });
+    pc.addEventListener('icecandidateerror', (event) => {
+        console.error(role, 'ICE candidate error:', event);
+    });
+    pc.addEventListener('datachannel', (event) => {
+        console.info(role, 'Incoming data channel:', event.channel.label);
+    });
+    return pc;
+}
+
+/**
+ * Create a data channel and bind events to be logged.
+ * @param pc The peer connection instance.
+ * @param role The role (offerer or answerer).
+ * @param label The label of the data channel.
+ * @param options The options passed to the RTCDataChannel instance.
+ * @returns {RTCDataChannel}
+ */
+function createDataChannel(pc, role, label, options) {
+    // Create data channel and bind events
+    const dc = pc.createDataChannel(label, options);
+    dc.addEventListener('open', () => {
+        console.info(role, label, 'open');
+    });
+    dc.addEventListener('close', () => {
+        console.info(role, label, 'closed');
+    });
+    dc.addEventListener('error', () => {
+        console.error(role, label, 'error:', error);
+    });
+    dc.addEventListener('bufferedamountlow', () => {
+        console.debug(role, label, 'buffered amount low:', dc.bufferedAmount);
+    });
+    dc.addEventListener('message', (event) => {
+        console.debug(role, label, `incoming message:`, event.data);
+    });
+    return dc;
+}
+
+/**
+ * Connect the peer connection instances to each other.
+ * @param offerer The offerer's peer connection instance.
+ * @param answerer The answerer's peer connection instance.
+ * @returns {Promise<void>} resolves once connected.
+ */
+async function connectPeerConnections(offerer, answerer) {
+    const pcs = [offerer, answerer];
+
+    // Forward ICE candidates to each other
+    for (const [me, other] of [pcs, pcs.slice().reverse()]) {
+        me.addEventListener('icecandidate', (event) => {
+            if (event.candidate !== null) {
+                other.addIceCandidate(event.candidate);
+            }
+        });
+    }
+
+    // Promise for the peer connections being connected
+    const [offererConnected, answererConnected] = pcs.map((pc) => {
+        return new Promise((resolve, reject) => {
+            pc.addEventListener('iceconnectionstatechange', () => {
+                switch (pc.iceConnectionState) {
+                    case 'connected':
+                    case 'completed':
+                        resolve(pc.iceConnectionState);
+                        break;
+                    case 'closed':
+                    case 'failed':
+                        reject(pc.iceConnectionState);
+                        break;
+                }
+            });
+        });
+    });
+
+    // Start the offer/answer dance
+    const signalingDone = new Promise((resolve, reject) => {
+        offerer.addEventListener('negotiationneeded', async () => {
+            try {
+                console.debug('Start signaling');
+                const offer = await offerer.createOffer();
+                await offerer.setLocalDescription(offer);
+                await answerer.setRemoteDescription(offer);
+                const answer = await answerer.createAnswer();
+                await answerer.setLocalDescription(answer);
+                await offerer.setRemoteDescription(answer);
+                console.debug('Signaling complete');
+                resolve();
+            } catch (error) {
+                reject(error);
+            }
+        });
+    });
+
+    // Wait until all is done
+    await Promise.all([
+        offererConnected,
+        answererConnected,
+        signalingDone,
+    ]);
+}
+
+// Here beginneth the Angular stuff
+const app = angular.module('troubleshoot', ['ngSanitize']);
 
 app.filter('osName', function() {
     return function(id) {
@@ -41,6 +195,9 @@ app.component('check', {
     `,
 });
 
+const SIGNALING_DATA_CHANNEL_LABEL = 'saltyrtc';
+const APP_DATA_CHANNEL_LABEL = 'therme';
+
 app.controller('ChecksController', function($scope, $timeout) {
     // Initialize state
     this.state = 'init';  // Either 'init' or 'check'
@@ -65,13 +222,10 @@ app.controller('ChecksController', function($scope, $timeout) {
         showLogs: false,
         logs: [],
     };
-    this.resultPc = {
-        state: 'unknown',
-        showLogs: false,
-    };
     this.resultDc = {
         state: 'unknown',
         showLogs: false,
+        logs: [],
     };
     this.resultTurn = {
         state: 'unknown',
@@ -86,75 +240,31 @@ app.controller('ChecksController', function($scope, $timeout) {
         this.doChecks();
     };
 
-    // Helper: Local storage
-    function localStorageAvailable() {
-        var test = 'test';
+    // Local store can be used
+    const localStorageAvailable = () => {
+        const test = 'test';
         try {
             localStorage.setItem(test, test);
             localStorage.removeItem(test);
-            return true;
-        } catch(e) {
-            return false;
-        }
-    }
-
-    // Helper: Desktop notifications
-    function desktopNotificationsAvailable() {
-        return 'Notification' in window;
-    }
-
-    // Helper: Peer connection
-    function peerConnectionAvailable() {
-        return window.RTCPeerConnection;
-    }
-
-    // Helper: Data channel
-    function dataChannelAvailable() {
-        return window.RTCPeerConnection && (new RTCPeerConnection()).createDataChannel;
-    }
-
-    // Run all the checks and update results
-    this.doChecks = () => {
-        // Check for JS
-        this.resultJs.state = 'yes';
-
-        // Check for LocalStorage
-        if (localStorageAvailable()) {
             this.resultLs.state = 'yes';
-        } else {
+        } catch(e) {
             this.resultLs.state = 'no';
         }
+    };
 
-        // Check for desktop notifications
-        if (desktopNotificationsAvailable()) {
-            this.resultDn.state = 'yes';
-        } else {
-            this.resultDn.state = 'no';
-        }
-
-        // Check for RTCPeerConnection
-        if (peerConnectionAvailable()) {
-            this.resultPc.state = 'yes';
-        } else {
-            this.resultPc.state = 'no';
-        }
-
-        // Check for RTCDataChannel
-        if (dataChannelAvailable()) {
-            this.resultDc.state = 'yes';
-            this.resultTurn.state = 'loading';
-        } else {
-            this.resultDc.state = 'no';
-            this.resultTurn.state = 'no';
-        }
+    // The desktop notification API is available
+    const desktopNotificationsAvailable = () => {
+        this.resultDn.state = 'Notification' in window ? 'yes' : 'no';
+    };
 
-        // Check for WebSocket connectivity
+    // A WebSocket connection can be established to the SaltyRTC server
+    const canEstablishWebSocket = () => {
         const subprotocol = 'v1.saltyrtc.org';
         const path = 'ffffffffffffffff00000000000000000000000000000000ffffffffffffffff';
         this.resultWs.showLogs = true;
         const ws = new WebSocket('wss://saltyrtc-ff.threema.ch/' + path, subprotocol);
         ws.binaryType = 'arraybuffer';
-        ws.addEventListener('open', (event) => {
+        ws.addEventListener('open', () => {
             $scope.$apply(() => {
                 this.resultWs.logs.push('Connected');
             });
@@ -183,9 +293,7 @@ app.controller('ChecksController', function($scope, $timeout) {
             console.log('Message bytes:', bytes);
 
             // Validate length
-            let valid;
             if (bytes.length < 81) {
-                valid = false;
                 return fail(`Invalid length: ${bytes.length}`);
             }
 
@@ -225,84 +333,260 @@ app.controller('ChecksController', function($scope, $timeout) {
                 this.resultWs.logs.push('Error');
             });
         });
-        ws.addEventListener('close', (event) => {
+        ws.addEventListener('close', () => {
             $scope.$apply(() => {
                 this.resultWs.logs.push('Connection closed');
             });
         });
         this.resultWs.logs.push('Connecting');
+    };
 
-        // Check for TURN connectivity
-        let timeout = null;
-        const testTurn = () => {
-            timeout = $timeout(() => this.turnSuccess = 'no', 10000);
-            const noop = () => {};
-
-            // Detect safari
-            const uagent = window.navigator.userAgent.toLowerCase();
-            const isSafari  = /safari/.test(uagent) && /applewebkit/.test(uagent) && !/chrome/.test(uagent);
-
-            // Determine ICE servers
-            let iceServers;
-            if (isSafari) {
-                iceServers = [
-                    'turn:turn.threema.ch:443?transport=udp',
-                    'turn:turn.threema.ch:443?transport=tcp',
-                    'turns:turn.threema.ch:443',
-                ];
-            } else {
-                iceServers = [
-                    'turn:ds-turn.threema.ch:443?transport=udp',
-                    'turn:ds-turn.threema.ch:443?transport=tcp',
-                    'turns:ds-turn.threema.ch:443',
-                ];
-            }
-            console.debug('Using ICE servers: ' + iceServers);
+    // A peer-to-peer connection can be established and a data channel can be
+    // used to send data.
+    const canEstablishDataChannels = () => {
+        this.resultDc.showLogs = true;
 
-            const pc = new RTCPeerConnection({iceServers: [{
-                urls: iceServers,
-                username: 'threema-angular-test',
-                credential: 'VaoVnhxKGt2wD20F9bTOgiew6yHQmj4P7y7SE4lrahAjTQC0dpnG32FR4fnrlpKa',
-            }]});
+        // Check for the RTCPeerConnecton object
+        if (window.RTCPeerConnection) {
+            this.resultDc.logs.push('RTCPeerConnection available');
+        } else {
+            this.resultDc.state = 'no';
+            this.resultDc.logs.push('RTCPeerConnection unavailable');
+            return;
+        }
 
-            this.resultTurn.showLogs = true;
+        // Check for the RTCDataChannel object
+        if (window.RTCPeerConnection && (new RTCPeerConnection()).createDataChannel) {
+            this.resultDc.logs.push('RTCDataChannel available');
+        } else {
+            this.resultDc.state = 'no';
+            this.resultDc.logs.push('RTCDataChannel unavailable');
+            return;
+        }
 
-            pc.createDataChannel('test');
+        // Create two peer connection instances
+        let offerer, answerer;
+        try {
+            [offerer, answerer] = [
+                createPeerConnection('Offerer'),
+                createPeerConnection('Answerer'),
+            ];
+        } catch (error) {
+            this.resultDc.state = 'no';
+            this.resultDc.logs.push(`Peer connection could not be created (${error.toString()})`);
+            return;
+        }
+
+        // Async phase begins
+        this.resultDc.state = 'loading';
+        const done = (success, message) => {
+            if (this.resultDc.state === 'loading') {
+                this.resultDc.state = success ? 'yes' : 'no';
+            }
+            this.resultDc.logs.push(message);
+            offerer.close();
+            answerer.close();
+        };
 
-            this.resultTurn.logs.push('Creating offer...');
-            pc.createOffer(function(sdp) { pc.setLocalDescription(sdp, noop, noop) }, noop);
+        // Connect the peer connection instances to each other
+        let peerConnectionsEstablished;
+        try {
+            peerConnectionsEstablished = connectPeerConnections(offerer, answerer);
+        } catch (error) {
+            return done(false, `Peer connections could not be connected (${error.toString()})`);
+        }
+        peerConnectionsEstablished
+            .then(() => {
+                $scope.$apply(() => this.resultDc.logs.push('Connected'));
+            })
+            .catch((error) => {
+                $scope.$apply(() => done(false, `Cannot connect (error: ${error.toString()})`));
+            });
 
-            pc.onicecandidate = (ice) => {
+        // Create data channels for each peer connection instance. We mimic
+        // what SaltyRTC and the web client would do here:
+        //
+        // - create a negotiated data channel with id 0 and send once open, and
+        // - create a data channel for the ARP on the offerer's side.
+        const canUseDataChannel = (role, dc, resolve, reject) => {
+            dc.addEventListener('open', () => {
                 $scope.$apply(() => {
-					if (ice.candidate === null) {
-						this.resultTurn.logs.push('Done collecting candidates.');
-                        if (this.resultTurn.state === 'loading') {
-                            this.resultTurn.state = 'no';
-                            $timeout.cancel(timeout);
-                        }
-					} else if (ice.candidate.candidate) {
-						const candidate = SDPUtils.parseCandidate(ice.candidate.candidate);
-						console.debug(candidate);
-
-                        let info = `[${candidate.type}] ${candidate.ip}:${candidate.port}`;
-                        if (candidate.relatedAddress) {
-                            info += ` via ${candidate.relatedAddress}`;
-                        }
-                        info += ` (${candidate.protocol})`;
-                        this.resultTurn.logs.push(info);
-
-						if (candidate.type === 'relay') {
-                            this.resultTurn.state = 'yes';
-                            $timeout.cancel(timeout);
-						}
-					} else {
-						console.warn('Invalid candidate:', ice.candidate.candidate);
-						this.resultTurn.logs.push('Invalid candidate (see debug log)');
-					}
+                    this.resultDc.logs.push(`${role}: Channel '${dc.label}' open`);
+                    try {
+                        dc.send('hello!');
+                    } catch (error) {
+                        this.resultDc.logs.push(
+                            `${role}: Channel '${dc.label}' was unable to send (${error.toString()})`);
+                        reject();
+                    }
                 });
+            });
+            dc.addEventListener('close', () => {
+                $scope.$apply(() => {
+                    if (this.resultDc.state  === 'loading') {
+                        this.resultDc.logs.push(`${role}: Channel '${dc.label}' closed`);
+                        reject();
+                    }
+                });
+            });
+            dc.addEventListener('error', () => {
+                $scope.$apply(() => {
+                    this.resultDc.logs.push(`${role}: Channel '${dc.label}' error (${error.message})`);
+                    reject();
+                });
+            });
+            dc.addEventListener('message', (event) => {
+                $scope.$apply(() => {
+                    if (event.data === 'hello!') {
+                        this.resultDc.logs.push(`${role}: Channel '${dc.label}' working`);
+                        resolve();
+                    } else {
+                        this.resultDc.logs.push(
+                            `${role}: Channel '${dc.label}' received an unexpected message ('${event.data}')`);
+                        reject();
+                    }
+                });
+            });
+        };
+        try {
+            Promise.all([
+                new Promise((resolve, reject) => {
+                    const dc = createDataChannel(
+                        offerer, 'Offerer', SIGNALING_DATA_CHANNEL_LABEL, {id: 0, negotiated: true});
+                    canUseDataChannel('Offerer', dc, resolve, reject);
+                }),
+                new Promise((resolve, reject) => {
+                    const dc = createDataChannel(
+                        answerer, 'Answerer', SIGNALING_DATA_CHANNEL_LABEL, {id: 0, negotiated: true});
+                    canUseDataChannel('Answerer', dc, resolve, reject);
+                }),
+                // Mimic handover by waiting until the peer connection has
+                // been established (and an additional second).
+                peerConnectionsEstablished
+                    .then(() => new Promise((resolve) => setTimeout(resolve, 1000)))
+                    .then(() => new Promise((resolve, reject) => {
+                        const dc = createDataChannel(offerer, 'Offerer', APP_DATA_CHANNEL_LABEL);
+                        canUseDataChannel('Offerer', dc, resolve, reject);
+                    })),
+                new Promise((resolve, reject) => {
+                    answerer.addEventListener('datachannel', (event) => {
+                        $scope.$apply(() => {
+                            const dc = event.channel;
+                            if (dc.label !== APP_DATA_CHANNEL_LABEL) {
+                                return done(false, `Unexpected 'datachannel' event (channel: ${dc.label})`);
+                            } else {
+                                canUseDataChannel('Answerer', dc, resolve, reject);
+                            }
+                        });
+                    });
+                }),
+            ])
+                .then(() => {
+                    $scope.$apply(() => done(true, 'Data channels open and working'));
+                })
+                .catch((error) => {
+                    $scope.$apply(() => done(false, `Cannot connect (error: ${error.toString()})`));
+                });
+        } catch (error) {
+            return done(false, `Data channels could not be created (${error.toString()})`);
+        }
+    };
+
+    const haveTurnCandidates = () => {
+        this.resultTurn.showLogs = true;
+
+        // Create a peer connection instance
+        let pc;
+        try {
+            pc = createPeerConnection('TURN');
+        } catch (error) {
+            this.resultTurn.state = 'no';
+            this.resultTurn.logs.push(`Peer connection could not be created (${error.toString()})`);
+            return;
+        }
+
+        // Async phase begins
+        this.resultTurn.state = 'loading';
+        const done = (success, message) => {
+            if (this.resultTurn.state === 'loading') {
+                this.resultTurn.state = success ? 'yes' : 'no';
             }
+            this.resultTurn.logs.push(message);
+            pc.close();
+        };
+
+        // Just trigger negotiation...
+        try {
+            pc.createDataChannel('kick-the-peer-connection-to-life');
+        } catch (error) {
+            return done(false, `Data channel could not be created (${error.toString()})`);
         }
-        testTurn();
+
+        // Create timeout
+        const timer = $timeout(() => this.resultTurn.state = 'no', 10000);
+
+        // Create and apply local offer (async)
+        (async () => {
+            try {
+                const offer = await pc.createOffer();
+                await pc.setLocalDescription(offer);
+            } catch (error) {
+                $scope.$apply(() => done(false, `Offer could not be created (${error.toString()})`));
+            }
+        })();
+
+        // Check for TURN ICE candidates
+        pc.addEventListener('icecandidate', (event) => {
+            $scope.$apply(() => {
+                // Check for end-of-candidates indicator
+                if (event.candidate === null) {
+                    $timeout.cancel(timer);
+                    return done(false, 'Done');
+                }
+
+                // Handle ICE candidate
+                if (event.candidate.candidate) {
+                    const candidate = SDPUtils.parseCandidate(event.candidate.candidate);
+                    let info = `[${candidate.type}] ${candidate.ip}:${candidate.port}`;
+                    if (candidate.relatedAddress) {
+                        info += ` via ${candidate.relatedAddress}`;
+                    }
+                    info += ` (${candidate.protocol})`;
+
+                    // Relay candidate found: Cancel timer
+                    if (candidate.type === 'relay') {
+                        $timeout.cancel(timer);
+                        return done(true, info);
+                    }
+
+                    // Normal candidate: Log and continue
+                    this.resultTurn.logs.push(info);
+                } else {
+                    this.resultTurn.logs.push(`Invalid candidate (${event.candidate.candidate})`);
+                }
+            });
+        });
+    };
+
+    // Run all the checks and update results
+    this.doChecks = () => {
+        // Check for JS
+        this.resultJs.state = 'yes';
+
+        // Check for LocalStorage
+        localStorageAvailable();
+
+        // Check for desktop notifications
+        desktopNotificationsAvailable();
+
+        // Check for data channel connectivity
+        canEstablishDataChannels();
+
+        // Check for WebSocket connectivity
+        canEstablishWebSocket();
+
+        // Check for TURN connectivity
+        haveTurnCandidates();
     };
 
 });