From 14ed6799d72c7807467456808aa08a6f376ebe14 Mon Sep 17 00:00:00 2001 From: David Baker Date: Tue, 23 Sep 2014 17:16:13 +0100 Subject: [PATCH 01/10] Add support for TURN servers as per the TURN REST API (http://tools.ietf.org/html/draft-uberti-behave-turn-rest-00) --- synapse/config/homeserver.py | 3 +- synapse/config/voip.py | 41 +++++++++++++++++++++++++ synapse/rest/__init__.py | 3 +- synapse/rest/voip.py | 59 ++++++++++++++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 synapse/config/voip.py create mode 100644 synapse/rest/voip.py diff --git a/synapse/config/homeserver.py b/synapse/config/homeserver.py index 4b810a230..5a11fd6c7 100644 --- a/synapse/config/homeserver.py +++ b/synapse/config/homeserver.py @@ -21,11 +21,12 @@ from .ratelimiting import RatelimitConfig from .repository import ContentRepositoryConfig from .captcha import CaptchaConfig from .email import EmailConfig +from .voip import VoipConfig class HomeServerConfig(TlsConfig, ServerConfig, DatabaseConfig, LoggingConfig, RatelimitConfig, ContentRepositoryConfig, CaptchaConfig, - EmailConfig): + EmailConfig, VoipConfig): pass diff --git a/synapse/config/voip.py b/synapse/config/voip.py new file mode 100644 index 000000000..a47e81037 --- /dev/null +++ b/synapse/config/voip.py @@ -0,0 +1,41 @@ +# Copyright 2014 OpenMarket Ltd +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from ._base import Config + + +class VoipConfig(Config): + + def __init__(self, args): + super(VoipConfig, self).__init__(args) + self.turn_uri = args.turn_uri + self.turn_shared_secret = args.turn_shared_secret + self.turn_user_lifetime = args.turn_user_lifetime + + @classmethod + def add_arguments(cls, parser): + super(VoipConfig, cls).add_arguments(parser) + group = parser.add_argument_group("voip") + group.add_argument( + "--turn-uri", type=str, default=None, + help="The public URI of the TURN server to give to clients" + ) + group.add_argument( + "--turn-shared-secret", type=str, default=None, + help="The shared secret used to compute passwords for the TURN server" + ) + group.add_argument( + "--turn-user-lifetime", type=int, default=(1000 * 60 * 60), + help="How long generated TURN credentials last, in ms" + ) diff --git a/synapse/rest/__init__.py b/synapse/rest/__init__.py index ed785cfbd..3b9aa5973 100644 --- a/synapse/rest/__init__.py +++ b/synapse/rest/__init__.py @@ -15,7 +15,7 @@ from . import ( - room, events, register, login, profile, presence, initial_sync, directory + room, events, register, login, profile, presence, initial_sync, directory, voip ) @@ -42,3 +42,4 @@ class RestServletFactory(object): presence.register_servlets(hs, client_resource) initial_sync.register_servlets(hs, client_resource) directory.register_servlets(hs, client_resource) + voip.register_servlets(hs, client_resource) diff --git a/synapse/rest/voip.py b/synapse/rest/voip.py new file mode 100644 index 000000000..cba9b27e3 --- /dev/null +++ b/synapse/rest/voip.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 OpenMarket Ltd +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from twisted.internet import defer + +from base import RestServlet, client_path_pattern + +from syutil.base64util import encode_base64 + +import hmac +import hashlib + + +class VoipRestServlet(RestServlet): + PATTERN = client_path_pattern("/voip/turnuris$") + + @defer.inlineCallbacks + def on_GET(self, request): + auth_user = yield self.auth.get_user_by_req(request) + + turnUri = self.hs.config.voip.turn_uri + turnSecret = self.hs.config.voip.turn_shared_secret + userLifetime = self.hs.config.voip.turn_user_lifetime + if not turnUri or not turnSecret or not userLifetime: + defer.returnValue( (200, {"uris": []}) ) + + expiry = self.hs.get_clock().time_msec() + userLifetime + username = "%d:%s" % (expiry, auth_user.to_string()) + + mac = hmac.new(turnSecret, msg=username, digestmod=hashlib.sha1) + password = encode_base64(mac.digest()) + + defer.returnValue( (200, { + 'username': username, + 'password': password, + 'ttl': userLifetime / 1000, + 'uris': [ + turnUri, + ] + }) ) + + def on_OPTIONS(self, request): + return (200, {}) + + +def register_servlets(hs, http_server): + VoipRestServlet(hs).register(http_server) From c96ab4fcbb8b7cb61fffe46ef010ea2766d1dc63 Mon Sep 17 00:00:00 2001 From: David Baker Date: Tue, 23 Sep 2014 19:17:24 +0200 Subject: [PATCH 02/10] The config is not hierarchical --- synapse/rest/voip.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/synapse/rest/voip.py b/synapse/rest/voip.py index cba9b27e3..1989a322c 100644 --- a/synapse/rest/voip.py +++ b/synapse/rest/voip.py @@ -30,9 +30,9 @@ class VoipRestServlet(RestServlet): def on_GET(self, request): auth_user = yield self.auth.get_user_by_req(request) - turnUri = self.hs.config.voip.turn_uri - turnSecret = self.hs.config.voip.turn_shared_secret - userLifetime = self.hs.config.voip.turn_user_lifetime + turnUri = self.hs.config.turn_uri + turnSecret = self.hs.config.turn_shared_secret + userLifetime = self.hs.config.turn_user_lifetime if not turnUri or not turnSecret or not userLifetime: defer.returnValue( (200, {"uris": []}) ) From b42b0d3fe527313f7885ec3ac5e582a59c2c07fb Mon Sep 17 00:00:00 2001 From: David Baker Date: Wed, 24 Sep 2014 15:29:24 +0200 Subject: [PATCH 03/10] Use standard base64 encoding with padding to get the same result as coturn. --- synapse/rest/voip.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/synapse/rest/voip.py b/synapse/rest/voip.py index 1989a322c..bb0108cbd 100644 --- a/synapse/rest/voip.py +++ b/synapse/rest/voip.py @@ -17,10 +17,10 @@ from twisted.internet import defer from base import RestServlet, client_path_pattern -from syutil.base64util import encode_base64 import hmac import hashlib +import base64 class VoipRestServlet(RestServlet): @@ -40,7 +40,10 @@ class VoipRestServlet(RestServlet): username = "%d:%s" % (expiry, auth_user.to_string()) mac = hmac.new(turnSecret, msg=username, digestmod=hashlib.sha1) - password = encode_base64(mac.digest()) + # We need to use standard base64 encoding here, *not* syutil's encode_base64 + # because we need to add the standard padding to get the same result as the + # TURN server. + password = base64.b64encode(mac.digest()) defer.returnValue( (200, { 'username': username, From 5383ba55870079076277ee6e83458f6cd7ceee85 Mon Sep 17 00:00:00 2001 From: David Baker Date: Wed, 24 Sep 2014 16:01:36 +0100 Subject: [PATCH 04/10] rename endpoint to better reflect what it is and allow specifying multiple uris --- synapse/config/voip.py | 6 +++--- synapse/rest/voip.py | 8 +++----- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/synapse/config/voip.py b/synapse/config/voip.py index a47e81037..3a211ae6b 100644 --- a/synapse/config/voip.py +++ b/synapse/config/voip.py @@ -19,7 +19,7 @@ class VoipConfig(Config): def __init__(self, args): super(VoipConfig, self).__init__(args) - self.turn_uri = args.turn_uri + self.turn_uris = args.turn_uris.split(",") self.turn_shared_secret = args.turn_shared_secret self.turn_user_lifetime = args.turn_user_lifetime @@ -28,8 +28,8 @@ class VoipConfig(Config): super(VoipConfig, cls).add_arguments(parser) group = parser.add_argument_group("voip") group.add_argument( - "--turn-uri", type=str, default=None, - help="The public URI of the TURN server to give to clients" + "--turn-uris", type=str, default=None, + help="The public URIs of the TURN server to give to clients" ) group.add_argument( "--turn-shared-secret", type=str, default=None, diff --git a/synapse/rest/voip.py b/synapse/rest/voip.py index bb0108cbd..31f3fd100 100644 --- a/synapse/rest/voip.py +++ b/synapse/rest/voip.py @@ -24,13 +24,13 @@ import base64 class VoipRestServlet(RestServlet): - PATTERN = client_path_pattern("/voip/turnuris$") + PATTERN = client_path_pattern("/voip/turnServers$") @defer.inlineCallbacks def on_GET(self, request): auth_user = yield self.auth.get_user_by_req(request) - turnUri = self.hs.config.turn_uri + turnUris = self.hs.config.turn_uris turnSecret = self.hs.config.turn_shared_secret userLifetime = self.hs.config.turn_user_lifetime if not turnUri or not turnSecret or not userLifetime: @@ -49,9 +49,7 @@ class VoipRestServlet(RestServlet): 'username': username, 'password': password, 'ttl': userLifetime / 1000, - 'uris': [ - turnUri, - ] + 'uris': turnUris, }) ) def on_OPTIONS(self, request): From 455365113878632774d12039cb6ab362d6d16416 Mon Sep 17 00:00:00 2001 From: David Baker Date: Wed, 24 Sep 2014 17:04:33 +0200 Subject: [PATCH 05/10] Oops --- synapse/rest/voip.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/synapse/rest/voip.py b/synapse/rest/voip.py index 31f3fd100..a3a8842cb 100644 --- a/synapse/rest/voip.py +++ b/synapse/rest/voip.py @@ -33,7 +33,7 @@ class VoipRestServlet(RestServlet): turnUris = self.hs.config.turn_uris turnSecret = self.hs.config.turn_shared_secret userLifetime = self.hs.config.turn_user_lifetime - if not turnUri or not turnSecret or not userLifetime: + if not turnUris or not turnSecret or not userLifetime: defer.returnValue( (200, {"uris": []}) ) expiry = self.hs.get_clock().time_msec() + userLifetime From 7679ee7321ff588a84edab31507185d2cee80fc7 Mon Sep 17 00:00:00 2001 From: David Baker Date: Wed, 24 Sep 2014 16:07:33 +0100 Subject: [PATCH 06/10] Hopefully implement turn in the web client (probably wrong for Firefox because Firefox is a special snowflake) --- webclient/components/matrix/matrix-call.js | 33 +++++++++++++++++-- webclient/components/matrix/matrix-service.js | 4 +++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/webclient/components/matrix/matrix-call.js b/webclient/components/matrix/matrix-call.js index 7b5d9cffe..888f85347 100644 --- a/webclient/components/matrix/matrix-call.js +++ b/webclient/components/matrix/matrix-call.js @@ -49,6 +49,15 @@ angular.module('MatrixCall', []) .factory('MatrixCall', ['matrixService', 'matrixPhoneService', '$rootScope', '$timeout', function MatrixCallFactory(matrixService, matrixPhoneService, $rootScope, $timeout) { $rootScope.isWebRTCSupported = isWebRTCSupported(); + // FIXME: we should prevent any class from being placed or accepted before this has finished + matrixService.getTurnServer().then(function(response) { + console.log("Got TURN URIs: "+response.data.uris); + MatrixCall.turnServer = response.data; + }, function(error) { + console.log("Failed to get TURN URIs"); + MatrixCall.turnServer = {}; + }); + var MatrixCall = function(room_id) { this.room_id = room_id; this.call_id = "c" + new Date().getTime(); @@ -69,12 +78,30 @@ angular.module('MatrixCall', []) MatrixCall.CALL_TIMEOUT = 60000; MatrixCall.prototype.createPeerConnection = function() { - var stunServer = 'stun:stun.l.google.com:19302'; var pc; if (window.mozRTCPeerConnection) { - pc = new window.mozRTCPeerConnection({'url': stunServer}); + var iceServers = []; + if (MatrixCall.turnServer) { + iceServers.push({ + 'urls': MatrixCall.turnServer.uris, + 'username': MatrixCall.turnServer.username, + 'credential': MatrixCall.turnServer.password, + }); + } + + pc = new window.mozRTCPeerConnection({"iceServers":iceServers}); + //pc = new window.mozRTCPeerConnection({'url': stunServer}); } else { - pc = new window.RTCPeerConnection({"iceServers":[{"urls":"stun:stun.l.google.com:19302"}]}); + var iceServers = []; + if (MatrixCall.turnServer) { + iceServers.push({ + 'urls': MatrixCall.turnServer.uris, + 'username': MatrixCall.turnServer.username, + 'credential': MatrixCall.turnServer.password, + }); + } + + pc = new window.RTCPeerConnection({"iceServers":iceServers}); } var self = this; pc.oniceconnectionstatechange = function() { self.onIceConnectionStateChanged(); }; diff --git a/webclient/components/matrix/matrix-service.js b/webclient/components/matrix/matrix-service.js index 069e02e93..69e6caccd 100644 --- a/webclient/components/matrix/matrix-service.js +++ b/webclient/components/matrix/matrix-service.js @@ -762,6 +762,10 @@ angular.module('matrixService', []) var deferred = $q.defer(); deferred.reject({data:{error: "Invalid room: " + room_id}}); return deferred.promise; + }, + + getTurnServer: function() { + return doRequest("GET", "/voip/turnServers"); } }; From 7dc7c53029fccbccf291ca4c299fccfdeb8e19fb Mon Sep 17 00:00:00 2001 From: David Baker Date: Wed, 24 Sep 2014 17:28:47 +0200 Subject: [PATCH 07/10] The REST API spec only alows for returning a single server so name the endpoint appropriately. --- synapse/rest/voip.py | 2 +- webclient/components/matrix/matrix-service.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/synapse/rest/voip.py b/synapse/rest/voip.py index a3a8842cb..7260ff0e8 100644 --- a/synapse/rest/voip.py +++ b/synapse/rest/voip.py @@ -24,7 +24,7 @@ import base64 class VoipRestServlet(RestServlet): - PATTERN = client_path_pattern("/voip/turnServers$") + PATTERN = client_path_pattern("/voip/turnServer$") @defer.inlineCallbacks def on_GET(self, request): diff --git a/webclient/components/matrix/matrix-service.js b/webclient/components/matrix/matrix-service.js index 69e6caccd..cb827a0b4 100644 --- a/webclient/components/matrix/matrix-service.js +++ b/webclient/components/matrix/matrix-service.js @@ -765,7 +765,7 @@ angular.module('matrixService', []) }, getTurnServer: function() { - return doRequest("GET", "/voip/turnServers"); + return doRequest("GET", "/voip/turnServer"); } }; From 6806caffc7d10ed643a6bc80d53a0fe76becd6f5 Mon Sep 17 00:00:00 2001 From: David Baker Date: Wed, 24 Sep 2014 17:57:34 +0100 Subject: [PATCH 08/10] Refresh turn server before the ttl runs out. Support firefox. --- webclient/components/matrix/matrix-call.js | 37 ++++++++++++++-------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/webclient/components/matrix/matrix-call.js b/webclient/components/matrix/matrix-call.js index 888f85347..5c7902f87 100644 --- a/webclient/components/matrix/matrix-call.js +++ b/webclient/components/matrix/matrix-call.js @@ -49,15 +49,6 @@ angular.module('MatrixCall', []) .factory('MatrixCall', ['matrixService', 'matrixPhoneService', '$rootScope', '$timeout', function MatrixCallFactory(matrixService, matrixPhoneService, $rootScope, $timeout) { $rootScope.isWebRTCSupported = isWebRTCSupported(); - // FIXME: we should prevent any class from being placed or accepted before this has finished - matrixService.getTurnServer().then(function(response) { - console.log("Got TURN URIs: "+response.data.uris); - MatrixCall.turnServer = response.data; - }, function(error) { - console.log("Failed to get TURN URIs"); - MatrixCall.turnServer = {}; - }); - var MatrixCall = function(room_id) { this.room_id = room_id; this.call_id = "c" + new Date().getTime(); @@ -75,6 +66,22 @@ angular.module('MatrixCall', []) } + MatrixCall.getTurnServer = function() { + matrixService.getTurnServer().then(function(response) { + console.log("Got TURN URIs: "+response.data.uris); + MatrixCall.turnServer = response.data; + // re-fetch when we're about to reach the TTL + $timeout(MatrixCall.getTurnServer, MatrixCall.turnServer.ttl * 1000 * 0.9); + }, function(error) { + console.log("Failed to get TURN URIs"); + MatrixCall.turnServer = {}; + $timeout(MatrixCall.getTurnServer, 60000); + }); + } + + // FIXME: we should prevent any class from being placed or accepted before this has finished + MatrixCall.getTurnServer(); + MatrixCall.CALL_TIMEOUT = 60000; MatrixCall.prototype.createPeerConnection = function() { @@ -82,11 +89,13 @@ angular.module('MatrixCall', []) if (window.mozRTCPeerConnection) { var iceServers = []; if (MatrixCall.turnServer) { - iceServers.push({ - 'urls': MatrixCall.turnServer.uris, - 'username': MatrixCall.turnServer.username, - 'credential': MatrixCall.turnServer.password, - }); + for (var i = 0; i < MatrixCall.turnServer.uris.length; i++) { + iceServers.push({ + 'url': MatrixCall.turnServer.uris[i], + 'username': MatrixCall.turnServer.username, + 'credential': MatrixCall.turnServer.password, + }); + } } pc = new window.mozRTCPeerConnection({"iceServers":iceServers}); From a31bf7777694d794d8e861c2bfede4a8ebb8849e Mon Sep 17 00:00:00 2001 From: David Baker Date: Thu, 25 Sep 2014 11:24:49 +0200 Subject: [PATCH 09/10] Make turn server endpoint return an empty object if no turn servers to match the normal response. Don't break if the turn_uris option isn't present. --- synapse/config/voip.py | 2 +- synapse/rest/voip.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/synapse/config/voip.py b/synapse/config/voip.py index 3a211ae6b..c5131d9bc 100644 --- a/synapse/config/voip.py +++ b/synapse/config/voip.py @@ -19,7 +19,7 @@ class VoipConfig(Config): def __init__(self, args): super(VoipConfig, self).__init__(args) - self.turn_uris = args.turn_uris.split(",") + self.turn_uris = args.turn_uris.split(",") if args.turn_uris else None self.turn_shared_secret = args.turn_shared_secret self.turn_user_lifetime = args.turn_user_lifetime diff --git a/synapse/rest/voip.py b/synapse/rest/voip.py index 7260ff0e8..2e4627606 100644 --- a/synapse/rest/voip.py +++ b/synapse/rest/voip.py @@ -34,7 +34,7 @@ class VoipRestServlet(RestServlet): turnSecret = self.hs.config.turn_shared_secret userLifetime = self.hs.config.turn_user_lifetime if not turnUris or not turnSecret or not userLifetime: - defer.returnValue( (200, {"uris": []}) ) + defer.returnValue( (200, {}) ) expiry = self.hs.get_clock().time_msec() + userLifetime username = "%d:%s" % (expiry, auth_user.to_string()) From c0936b103c215aef12d64fbb490d8daef07903c4 Mon Sep 17 00:00:00 2001 From: David Baker Date: Thu, 25 Sep 2014 11:13:32 +0100 Subject: [PATCH 10/10] Add stun server fallback and I-told-you-so message if we get no TURN server and the connection fails. --- webclient/components/matrix/matrix-call.js | 50 ++++++++++++++-------- webclient/index.html | 2 +- 2 files changed, 34 insertions(+), 18 deletions(-) diff --git a/webclient/components/matrix/matrix-call.js b/webclient/components/matrix/matrix-call.js index 5c7902f87..3e8811e5f 100644 --- a/webclient/components/matrix/matrix-call.js +++ b/webclient/components/matrix/matrix-call.js @@ -68,10 +68,16 @@ angular.module('MatrixCall', []) MatrixCall.getTurnServer = function() { matrixService.getTurnServer().then(function(response) { - console.log("Got TURN URIs: "+response.data.uris); - MatrixCall.turnServer = response.data; - // re-fetch when we're about to reach the TTL - $timeout(MatrixCall.getTurnServer, MatrixCall.turnServer.ttl * 1000 * 0.9); + if (response.data.uris) { + console.log("Got TURN URIs: "+response.data.uris); + MatrixCall.turnServer = response.data; + $rootScope.haveTurn = true; + // re-fetch when we're about to reach the TTL + $timeout(MatrixCall.getTurnServer, MatrixCall.turnServer.ttl * 1000 * 0.9); + } else { + console.log("Got no TURN URIs from HS"); + $rootScope.haveTurn = false; + } }, function(error) { console.log("Failed to get TURN URIs"); MatrixCall.turnServer = {}; @@ -83,31 +89,41 @@ angular.module('MatrixCall', []) MatrixCall.getTurnServer(); MatrixCall.CALL_TIMEOUT = 60000; + MatrixCall.FALLBACK_STUN_SERVER = 'stun:stun.l.google.com:19302'; MatrixCall.prototype.createPeerConnection = function() { var pc; if (window.mozRTCPeerConnection) { var iceServers = []; if (MatrixCall.turnServer) { - for (var i = 0; i < MatrixCall.turnServer.uris.length; i++) { - iceServers.push({ - 'url': MatrixCall.turnServer.uris[i], - 'username': MatrixCall.turnServer.username, - 'credential': MatrixCall.turnServer.password, - }); - } + if (MatrixCall.turnServer.uris) { + for (var i = 0; i < MatrixCall.turnServer.uris.length; i++) { + iceServers.push({ + 'url': MatrixCall.turnServer.uris[i], + 'username': MatrixCall.turnServer.username, + 'credential': MatrixCall.turnServer.password, + }); + } + } else { + console.log("No TURN server: using fallback STUN server"); + iceServers.push({ 'url' : MatrixCall.FALLBACK_STUN_SERVER }); + } } pc = new window.mozRTCPeerConnection({"iceServers":iceServers}); - //pc = new window.mozRTCPeerConnection({'url': stunServer}); } else { var iceServers = []; if (MatrixCall.turnServer) { - iceServers.push({ - 'urls': MatrixCall.turnServer.uris, - 'username': MatrixCall.turnServer.username, - 'credential': MatrixCall.turnServer.password, - }); + if (MatrixCall.turnServer.uris) { + iceServers.push({ + 'urls': MatrixCall.turnServer.uris, + 'username': MatrixCall.turnServer.username, + 'credential': MatrixCall.turnServer.password, + }); + } else { + console.log("No TURN server: using fallback STUN server"); + iceServers.push({ 'urls' : MatrixCall.FALLBACK_STUN_SERVER }); + } } pc = new window.RTCPeerConnection({"iceServers":iceServers}); diff --git a/webclient/index.html b/webclient/index.html index 411c2762d..f233919e3 100644 --- a/webclient/index.html +++ b/webclient/index.html @@ -69,7 +69,7 @@ Incoming Voice Call Call Connecting... Call Connected - Media Connection Failed + Media Connection Failed{{ haveTurn ? "" : " (VoIP relaying unsupported by Home Server)" }} Call Rejected Call Canceled User Not Responding