diff options
author | Joas Schilling <213943+nickvergessen@users.noreply.github.com> | 2023-02-21 12:57:27 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-02-21 12:57:27 +0100 |
commit | 6d98b9c7b6ceb1da61016f36e2e8a02ea9c1a281 (patch) | |
tree | dc467c2ee6d92771cb68d38771bc28e9d7bca2e8 /recording | |
parent | daa51fe2f8cee2bf7d0fb1b179d2153488ca4d53 (diff) | |
parent | ecf5c485e224c085b53127721dff6b4212622282 (diff) |
Merge pull request #8756 from nextcloud/use-internal-client-of-signaling-server-for-recording
Use internal client of signaling server for recording
Diffstat (limited to 'recording')
-rw-r--r-- | recording/server.conf.in | 29 | ||||
-rw-r--r-- | recording/src/nextcloud/talk/recording/Config.py | 47 | ||||
-rw-r--r-- | recording/src/nextcloud/talk/recording/Participant.py | 110 | ||||
-rw-r--r-- | recording/src/nextcloud/talk/recording/Service.py | 6 |
4 files changed, 169 insertions, 23 deletions
diff --git a/recording/server.conf.in b/recording/server.conf.in index 111916235..0e27413fe 100644 --- a/recording/server.conf.in +++ b/recording/server.conf.in @@ -65,3 +65,32 @@ # Shared secret for requests from and to the backend servers. This must be the # same value as configured in the Nextcloud admin ui. #secret = the-shared-secret + +[signaling] +# Common shared secret for authenticating as an internal client of signaling +# servers if a specific secret is not set for a signaling server. This must be +# the same value as configured in the signaling server configuration file. +#internalsecret = the-shared-secret-for-internal-clients + +# Comma-separated list of signaling servers with specific internal secrets. +#signalings = signaling-id, another-signaling + +# Signaling server configurations as defined in the "[signaling]" section above. +# The section names must match the ids used in "signalings" above. +#[signaling-id] +# URL of the signaling server +#url = https://signaling.domain.invalid + +# Shared secret for authenticating as an internal client of signaling servers. +# This must be the same value as configured in the signaling server +# configuration file. +#internalsecret = the-shared-secret-for-internal-clients + +#[another-signaling] +# URL of the signaling server +#url = https://signaling.otherdomain.invalid + +# Shared secret for authenticating as an internal client of signaling servers. +# This must be the same value as configured in the signaling server +# configuration file. +#internalsecret = the-shared-secret-for-internal-clients diff --git a/recording/src/nextcloud/talk/recording/Config.py b/recording/src/nextcloud/talk/recording/Config.py index 9e177dedb..6ce01bd50 100644 --- a/recording/src/nextcloud/talk/recording/Config.py +++ b/recording/src/nextcloud/talk/recording/Config.py @@ -36,6 +36,7 @@ class Config: self._configParser = ConfigParser() self._backendIdsByBackendUrl = {} + self._signalingIdsBySignalingUrl = {} def load(self, fileName): fileName = os.path.abspath(fileName) @@ -48,6 +49,7 @@ class Config: self._configParser.read(fileName) self._loadBackends() + self._loadSignalings() def _loadBackends(self): self._backendIdsByBackendUrl = {} @@ -72,6 +74,35 @@ class Config: backendUrl = self._configParser[backendId]['url'].rstrip('/') self._backendIdsByBackendUrl[backendUrl] = backendId + def _loadSignalings(self): + self._signalingIdsBySignalingUrl = {} + + if 'signaling' not in self._configParser: + self._logger.warning(f"No configured signalings") + + return + + if 'signalings' not in self._configParser['signaling']: + if 'internalsecret' not in self._configParser['signaling']: + self._logger.warning(f"No configured signalings") + + return + + signalingIds = self._configParser.get('signaling', 'signalings') + signalingIds = [signalingId.strip() for signalingId in signalingIds.split(',')] + + for signalingId in signalingIds: + if 'url' not in self._configParser[signalingId]: + self._logger.error(f"Missing 'url' property for signaling {signalingId}") + continue + + if 'internalsecret' not in self._configParser[signalingId]: + self._logger.error(f"Missing 'internalsecret' property for signaling {signalingId}") + continue + + signalingUrl = self._configParser[signalingId]['url'].rstrip('/') + self._signalingIdsBySignalingUrl[signalingUrl] = signalingId + def getLogLevel(self): """ Returns the log level. @@ -157,4 +188,20 @@ class Config: return self._configParser.get('backend', key, fallback=default) + def getSignalingSecret(self, signalingUrl): + """ + Returns the shared secret for authenticating as an internal client of + signaling servers. + + Defaults to None. + """ + signalingUrl = signalingUrl.rstrip('/') + if signalingUrl in self._signalingIdsBySignalingUrl: + signalingId = self._signalingIdsBySignalingUrl[signalingUrl] + + if self._configParser.get(signalingId, 'internalsecret', fallback=None): + return self._configParser.get(signalingId, 'internalsecret') + + return self._configParser.get('signaling', 'internalsecret', fallback=None) + config = Config() diff --git a/recording/src/nextcloud/talk/recording/Participant.py b/recording/src/nextcloud/talk/recording/Participant.py index d6c585fcc..2bf0a5f4f 100644 --- a/recording/src/nextcloud/talk/recording/Participant.py +++ b/recording/src/nextcloud/talk/recording/Participant.py @@ -21,12 +21,16 @@ Module to join a call with a browser. """ +import hashlib +import hmac import json import logging +import re import threading import websocket from datetime import datetime +from secrets import token_urlsafe from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.firefox.service import Service as FirefoxService @@ -34,6 +38,8 @@ from selenium.webdriver.support.wait import WebDriverWait from shutil import disk_usage from time import sleep +from .Config import config + class BiDiLogsHelper: """ Helper class to get browser logs using the BiDi protocol. @@ -284,6 +290,14 @@ class SeleniumHelper: If realtime logs are available logs are printed as soon as they are received. Otherwise they will be printed once the script has finished. + + The value returned by the script will be in turn returned by this + function; the type will be respected and adjusted as needed (so a + JavaScript string is returned as a Python string, but a JavaScript + object is returned as a Python dict). If nothing is returned by the + script None will be returned. + + :return: the value returned by the script, or None """ # Real time logs are enabled while the command is being executed. @@ -291,7 +305,7 @@ class SeleniumHelper: self.printLogs() self.bidiLogsHelper.setRealtimeLogsEnabled(True) - self.driver.execute_script(script) + result = self.driver.execute_script(script) if self.bidiLogsHelper: # Give it some time to receive the last real time logs before @@ -302,6 +316,8 @@ class SeleniumHelper: self.printLogs() + return result + def executeAsync(self, script): """ Executes the given script asynchronously. @@ -311,16 +327,35 @@ class SeleniumHelper: calls. The script needs to explicitly signal that the execution has finished by - including the special text "{RETURN}" (without quotes). If "{RETURN}" is - not included the function will automatically return once all the root + calling "returnResolve()" (with or without a parameter). If + "returnResolve()" is not called (no matter if with or without a + parameter) the function will automatically return once all the root statements of the script were executed (which works as expected if using "await" calls, but not if the script includes something like "someFunctionReturningAPromise().then(() => { more code })"; in that case the script should be written as - "someFunctionReturningAPromise().then(() => { more code {RETURN} })"). + "someFunctionReturningAPromise().then(() => { more code; returnResolve() })"). + + Similarly, exceptions thrown by a root statement (including "await" + calls) will be propagated to the Python function. However, this does not + work if the script includes something like + "someFunctionReturningAPromise().catch(exception => { more code; throw exception })"; + in that case the script should be written as + "someFunctionReturningAPromise().catch(exception => { more code; returnReject(exception) })". If realtime logs are available logs are printed as soon as they are received. Otherwise they will be printed once the script has finished. + + The value returned by the script will be in turn returned by this + function; the type will be respected and adjusted as needed (so a + JavaScript string is returned as a Python string, but a JavaScript + object is returned as a Python dict). If nothing is returned by the + script None will be returned. + + Note that the value returned by the script must be explicitly specified + by calling "returnResolve(XXX)"; it is not possible to use "return XXX". + + :return: the value returned by the script, or None """ # Real time logs are enabled while the command is being executed. @@ -330,19 +365,18 @@ class SeleniumHelper: # Add an explicit return point at the end of the script if none is # given. - if script.find('{RETURN}') == -1: - script += '{RETURN}' + if re.search('returnResolve\(.*\)', script) == None: + script += '; returnResolve()' # await is not valid in the root context in Firefox, so the script to be # executed needs to be wrapped in an async function. - script = '(async() => { ' + script + ' })().catch(error => { console.error(error) {RETURN} })' - # Asynchronous scripts need to explicitly signal that they are finished - # by invoking the callback injected as the last argument. - # https://www.selenium.dev/documentation/legacy/json_wire_protocol/#sessionsessionidexecute_async - script = script.replace('{RETURN}', '; arguments[arguments.length - 1]()') + # by invoking the callback injected as the last argument with a promise + # and resolving or rejecting the promise. + # https://w3c.github.io/webdriver/#dfn-execute-async-script + script = 'promise = new Promise(async(returnResolve, returnReject) => { try { ' + script + ' } catch (exception) { returnReject(exception) } }); arguments[arguments.length - 1](promise)' - self.driver.execute_async_script(script) + result = self.driver.execute_async_script(script) if self.bidiLogsHelper: # Give it some time to receive the last real time logs before @@ -353,6 +387,8 @@ class SeleniumHelper: self.printLogs() + return result + class Participant(): """ @@ -391,22 +427,56 @@ class Participant(): def joinCall(self, token): """ - Joins (or starts) the call in the room with the given token. + Joins the call in the room with the given token. - The participant will join as a guest. + The participant will join as an internal client of the signaling server. :param token: the token of the room to join. """ self.seleniumHelper.driver.get(self.nextcloudUrl + '/index.php/call/' + token + '/recording') - def leaveCall(self): - """ - Leaves the current call. + secret = config.getBackendSecret(self.nextcloudUrl) + if secret == None: + raise Exception(f"No configured backend secret for {self.nextcloudUrl}") + + random = token_urlsafe(64) + hmacValue = hmac.new(secret.encode(), random.encode(), hashlib.sha256) + + # If there are several signaling servers configured in Nextcloud the + # signaling settings can change between different calls, so they need to + # be got just once. The scripts are executed in their own scope, so + # values have to be stored in the window object to be able to use them + # later in another script. + settings = self.seleniumHelper.executeAsync(f''' + window.signalingSettings = await OCA.Talk.signalingGetSettingsForRecording('{token}', '{random}', '{hmacValue.hexdigest()}') + returnResolve(window.signalingSettings) + ''') - The call must have been joined first. + secret = config.getSignalingSecret(settings['server']) + if secret == None: + raise Exception(f"No configured signaling secret for {settings['server']}") + + random = token_urlsafe(64) + hmacValue = hmac.new(secret.encode(), random.encode(), hashlib.sha256) + + self.seleniumHelper.executeAsync(f''' + await OCA.Talk.signalingJoinCallForRecording( + '{token}', + window.signalingSettings, + {{ + random: '{random}', + token: '{hmacValue.hexdigest()}', + backend: '{self.nextcloudUrl}', + }} + ) + ''') + + def disconnect(self): + """ + Disconnects from the signaling server. """ - self.seleniumHelper.executeAsync(''' - await OCA.Talk.SimpleWebRTC.connection.leaveCurrentCall() + self.seleniumHelper.execute(''' + OCA.Talk.signalingKill() ''') diff --git a/recording/src/nextcloud/talk/recording/Service.py b/recording/src/nextcloud/talk/recording/Service.py index 258ff1cb9..0bb1519f5 100644 --- a/recording/src/nextcloud/talk/recording/Service.py +++ b/recording/src/nextcloud/talk/recording/Service.py @@ -284,11 +284,11 @@ class Service: self._process = None if self._participant: - self._logger.debug("Leaving call") + self._logger.debug("Disconnecting from signaling server") try: - self._participant.leaveCall() + self._participant.disconnect() except: - self._logger.exception("Error when leaving call") + self._logger.exception("Error when disconnecting from signaling server") finally: self._participant = None |