From 4ef25fd3403211a7e739a0b3bf75672daa3e6f44 Mon Sep 17 00:00:00 2001 From: Joas Schilling Date: Mon, 13 Jan 2025 11:56:38 +0100 Subject: [PATCH] feat(setupcheck): Show an admin check if the feature is not supported by the HPB Signed-off-by: Joas Schilling --- lib/Controller/SignalingController.php | 88 +----------- lib/SetupCheck/HighPerformanceBackend.php | 61 +++++++- lib/Signaling/Manager.php | 134 ++++++++++++++++++ tests/php/CapabilitiesTest.php | 2 + .../Controller/SignalingControllerTest.php | 4 - 5 files changed, 195 insertions(+), 94 deletions(-) diff --git a/lib/Controller/SignalingController.php b/lib/Controller/SignalingController.php index 64892a9a6797..52cbb7fd5f14 100644 --- a/lib/Controller/SignalingController.php +++ b/lib/Controller/SignalingController.php @@ -8,7 +8,6 @@ namespace OCA\Talk\Controller; -use GuzzleHttp\Exception\ConnectException; use OCA\Talk\Config; use OCA\Talk\Events\BeforeSignalingResponseSentEvent; use OCA\Talk\Exceptions\ForbiddenException; @@ -22,7 +21,6 @@ use OCA\Talk\ResponseDefinitions; use OCA\Talk\Room; use OCA\Talk\Service\BanService; -use OCA\Talk\Service\CertificateService; use OCA\Talk\Service\ParticipantService; use OCA\Talk\Service\SessionService; use OCA\Talk\Signaling\Messages; @@ -59,7 +57,6 @@ public function __construct( private \OCA\Talk\Signaling\Manager $signalingManager, private TalkSession $session, private Manager $manager, - private CertificateService $certificateService, private ParticipantService $participantService, private SessionService $sessionService, private IDBConnection $dbConnection, @@ -272,89 +269,12 @@ private function getFederationSettings(?Room $room): ?array { */ #[OpenAPI(scope: OpenAPI::SCOPE_ADMINISTRATION, tags: ['settings'])] public function getWelcomeMessage(int $serverId): DataResponse { - $signalingServers = $this->talkConfig->getSignalingServers(); - if (empty($signalingServers) || !isset($signalingServers[$serverId])) { - return new DataResponse(null, Http::STATUS_NOT_FOUND); - } - - $url = rtrim($signalingServers[$serverId]['server'], '/'); - $url = strtolower($url); - - if (str_starts_with($url, 'wss://')) { - $url = 'https://' . substr($url, 6); - } - - if (str_starts_with($url, 'ws://')) { - $url = 'http://' . substr($url, 5); - } - - $verifyServer = (bool)$signalingServers[$serverId]['verify']; - - if ($verifyServer && str_contains($url, 'https://')) { - $expiration = $this->certificateService->getCertificateExpirationInDays($url); - - if ($expiration < 0) { - return new DataResponse(['error' => 'CERTIFICATE_EXPIRED'], Http::STATUS_INTERNAL_SERVER_ERROR); - } - } - - $client = $this->clientService->newClient(); try { - $timeBefore = $this->timeFactory->getTime(); - $response = $client->get($url . '/api/v1/welcome', [ - 'verify' => $verifyServer, - 'nextcloud' => [ - 'allow_local_address' => true, - ], - ]); - $timeAfter = $this->timeFactory->getTime(); - - $body = $response->getBody(); - $data = json_decode($body, true); - - if (!is_array($data)) { - return new DataResponse([ - 'error' => 'JSON_INVALID', - ], Http::STATUS_INTERNAL_SERVER_ERROR); - } - - if (!isset($data['version'])) { - return new DataResponse([ - 'error' => 'UPDATE_REQUIRED', - 'version' => '', - ], Http::STATUS_INTERNAL_SERVER_ERROR); - } - - if (!$this->signalingManager->isCompatibleSignalingServer($response)) { - return new DataResponse([ - 'error' => 'UPDATE_REQUIRED', - 'version' => $data['version'] ?? '', - ], Http::STATUS_INTERNAL_SERVER_ERROR); - } - - $responseTime = $this->timeFactory->getDateTime($response->getHeader('date'))->getTimestamp(); - if (($timeBefore - Config::ALLOWED_BACKEND_TIMEOFFSET) > $responseTime - || ($timeAfter + Config::ALLOWED_BACKEND_TIMEOFFSET) < $responseTime) { - return new DataResponse([ - 'error' => 'TIME_OUT_OF_SYNC', - ], Http::STATUS_INTERNAL_SERVER_ERROR); - } - - $missingFeatures = $this->signalingManager->getSignalingServerMissingFeatures($response); - if (!empty($missingFeatures)) { - return new DataResponse([ - 'warning' => 'UPDATE_OPTIONAL', - 'features' => $missingFeatures, - 'version' => $data['version'] ?? '', - ]); - } - - return new DataResponse($data); - } catch (ConnectException $e) { - return new DataResponse(['error' => 'CAN_NOT_CONNECT'], Http::STATUS_INTERNAL_SERVER_ERROR); - } catch (\Exception $e) { - return new DataResponse(['error' => (string)$e->getCode()], Http::STATUS_INTERNAL_SERVER_ERROR); + $testResult = $this->signalingManager->checkServerCompatibility($serverId); + } catch (\OutOfBoundsException) { + return new DataResponse(null, Http::STATUS_NOT_FOUND); } + return new DataResponse($testResult['data'], $testResult['status']); } /** diff --git a/lib/SetupCheck/HighPerformanceBackend.php b/lib/SetupCheck/HighPerformanceBackend.php index 030b0d6f2579..9470f0a5d25d 100644 --- a/lib/SetupCheck/HighPerformanceBackend.php +++ b/lib/SetupCheck/HighPerformanceBackend.php @@ -9,6 +9,8 @@ namespace OCA\Talk\SetupCheck; use OCA\Talk\Config; +use OCA\Talk\Signaling\Manager; +use OCP\AppFramework\Http; use OCP\ICacheFactory; use OCP\IL10N; use OCP\IURLGenerator; @@ -21,6 +23,7 @@ public function __construct( readonly protected ICacheFactory $cacheFactory, readonly protected IURLGenerator $urlGenerator, readonly protected IL10N $l, + readonly protected Manager $signalManager, ) { } @@ -54,12 +57,58 @@ public function run(): SetupResult { ); } - if ($this->cacheFactory->isAvailable()) { - return SetupResult::success(); + try { + $testResult = $this->signalManager->checkServerCompatibility(0); + } catch (\OutOfBoundsException) { + return SetupResult::error($this->l->t('High-performance backend not configured correctly')); } - return SetupResult::warning( - $this->l->t('It is highly recommended to configure a memory cache when running Nextcloud Talk with a High-performance backend.'), - $this->urlGenerator->linkToDocs('admin-cache'), - ); + if ($testResult['status'] === Http::STATUS_INTERNAL_SERVER_ERROR) { + $error = $testResult['data']['error']; + if ($error === 'CAN_NOT_CONNECT') { + return SetupResult::error($this->l->t('Error: Cannot connect to server')); + } + if ($error === 'JSON_INVALID') { + return SetupResult::error($this->l->t('Error: Server did not respond with proper JSON')); + } + if ($error === 'CERTIFICATE_EXPIRED') { + return SetupResult::error($this->l->t('Error: Certificate expired')); + } + if ($error === 'TIME_OUT_OF_SYNC') { + return SetupResult::error($this->l->t('Error: System times of Nextcloud server and High-performance backend server are out of sync. Please make sure that both servers are connected to a time-server or manually synchronize their time.')); + } + if ($error === 'UPDATE_REQUIRED') { + $version = $testResult['data']['version'] ?? $this->l->t('Could not get version'); + return SetupResult::error(str_replace( + '{version}', + $version, + $this->l->t('Error: Running version: {version}; Server needs to be updated to be compatible with this version of Talk'), + )); + } + if ($error) { + return SetupResult::error(str_replace('{error}', $error, $this->l->t('Error: Server responded with: {error}'))); + } + return SetupResult::error($this->l->t('Error: Unknown error occurred')); + } + if ($testResult['status'] === Http::STATUS_OK + && isset($testResult['data']['warning']) + && $testResult['data']['warning'] === 'UPDATE_OPTIONAL' + ) { + $version = $testResult['data']['version'] ?? $this->l->t('Could not get version'); + $features = implode(', ', $testResult['data']['features'] ?? []); + return SetupResult::warning(str_replace( + ['{version}', '{features}'], + [$version, $features], + $this->l->t('Warning: Running version: {version}; Server does not support all features of this Talk version, missing features: {features}') + )); + } + + if (!$this->cacheFactory->isAvailable()) { + return SetupResult::warning( + $this->l->t('It is highly recommended to configure a memory cache when running Nextcloud Talk with a High-performance backend.'), + $this->urlGenerator->linkToDocs('admin-cache'), + ); + } + + return SetupResult::success(); } } diff --git a/lib/Signaling/Manager.php b/lib/Signaling/Manager.php index c795f7540c73..61e36cce6563 100644 --- a/lib/Signaling/Manager.php +++ b/lib/Signaling/Manager.php @@ -8,10 +8,15 @@ namespace OCA\Talk\Signaling; +use GuzzleHttp\Exception\ConnectException; use OCA\Talk\CachePrefix; use OCA\Talk\Config; use OCA\Talk\Room; +use OCA\Talk\Service\CertificateService; use OCA\Talk\Service\RoomService; +use OCP\AppFramework\Http; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\Http\Client\IClientService; use OCP\Http\Client\IResponse; use OCP\ICache; use OCP\ICacheFactory; @@ -26,11 +31,139 @@ public function __construct( protected IConfig $serverConfig, protected Config $talkConfig, protected RoomService $roomService, + protected ITimeFactory $timeFactory, + protected IClientService $clientService, + protected CertificateService $certificateService, ICacheFactory $cacheFactory, ) { $this->cache = $cacheFactory->createDistributed(CachePrefix::SIGNALING_ASSIGNED_SERVER); } + /** + * @param int $serverId + * @return array{status: Http::STATUS_OK, data: array}|array{status: Http::STATUS_INTERNAL_SERVER_ERROR, data: array{error: string, version?: string}} + * @throws \OutOfBoundsException When the serverId is not found + */ + public function checkServerCompatibility(int $serverId): array { + $signalingServers = $this->talkConfig->getSignalingServers(); + if (empty($signalingServers) || !isset($signalingServers[$serverId])) { + throw new \OutOfBoundsException(); + } + + $url = rtrim($signalingServers[$serverId]['server'], '/'); + $url = strtolower($url); + + if (str_starts_with($url, 'wss://')) { + $url = 'https://' . substr($url, 6); + } + + if (str_starts_with($url, 'ws://')) { + $url = 'http://' . substr($url, 5); + } + + $verifyServer = (bool)$signalingServers[$serverId]['verify']; + + if ($verifyServer && str_contains($url, 'https://')) { + $expiration = $this->certificateService->getCertificateExpirationInDays($url); + + if ($expiration < 0) { + return [ + 'status' => Http::STATUS_INTERNAL_SERVER_ERROR, + 'data' => [ + 'error' => 'CERTIFICATE_EXPIRED', + ], + ]; + } + } + + $client = $this->clientService->newClient(); + try { + $timeBefore = $this->timeFactory->getTime(); + $response = $client->get($url . '/api/v1/welcome', [ + 'verify' => $verifyServer, + 'nextcloud' => [ + 'allow_local_address' => true, + ], + ]); + $timeAfter = $this->timeFactory->getTime(); + + $body = $response->getBody(); + $data = json_decode($body, true); + + if (!is_array($data)) { + return [ + 'status' => Http::STATUS_INTERNAL_SERVER_ERROR, + 'data' => [ + 'error' => 'JSON_INVALID', + ], + ]; + } + + if (!isset($data['version'])) { + return [ + 'status' => Http::STATUS_INTERNAL_SERVER_ERROR, + 'data' => [ + 'error' => 'UPDATE_REQUIRED', + 'version' => '', + ], + ]; + } + + if (!$this->isCompatibleSignalingServer($response)) { + return [ + 'status' => Http::STATUS_INTERNAL_SERVER_ERROR, + 'data' => [ + 'error' => 'UPDATE_REQUIRED', + 'version' => $data['version'] ?? '', + ], + ]; + } + + $responseTime = $this->timeFactory->getDateTime($response->getHeader('date'))->getTimestamp(); + if (($timeBefore - Config::ALLOWED_BACKEND_TIMEOFFSET) > $responseTime + || ($timeAfter + Config::ALLOWED_BACKEND_TIMEOFFSET) < $responseTime) { + return [ + 'status' => Http::STATUS_INTERNAL_SERVER_ERROR, + 'data' => [ + 'error' => 'TIME_OUT_OF_SYNC', + ], + ]; + } + + $missingFeatures = $this->getSignalingServerMissingFeatures($response); + if (!empty($missingFeatures)) { + return [ + 'status' => Http::STATUS_OK, + 'data' => [ + 'warning' => 'UPDATE_OPTIONAL', + 'features' => $missingFeatures, + 'version' => $data['version'], + ], + ]; + } + + return [ + 'status' => Http::STATUS_OK, + 'data' => $data, + ]; + } catch (ConnectException) { + return [ + 'status' => Http::STATUS_INTERNAL_SERVER_ERROR, + 'data' => [ + 'error' => 'CAN_NOT_CONNECT', + ], + ]; + } catch (\Exception $e) { + return [ + 'status' => Http::STATUS_INTERNAL_SERVER_ERROR, + 'data' => [ + 'error' => (string)$e->getCode(), + ], + ]; + } + + } + public function isCompatibleSignalingServer(IResponse $response): bool { $featureHeader = $response->getHeader(self::FEATURE_HEADER); $features = explode(',', $featureHeader); @@ -49,6 +182,7 @@ public function getSignalingServerMissingFeatures(IResponse $response): array { return array_values(array_diff([ 'dialout', + 'join-features', ], $features)); } diff --git a/tests/php/CapabilitiesTest.php b/tests/php/CapabilitiesTest.php index ba1d0d57d21f..4664b4299bcd 100644 --- a/tests/php/CapabilitiesTest.php +++ b/tests/php/CapabilitiesTest.php @@ -133,6 +133,7 @@ public function testGetCapabilitiesGuest(): void { 'start-without-media' => false, 'max-duration' => 0, 'blur-virtual-background' => false, + 'end-to-end-encryption' => false, 'predefined-backgrounds' => [ '1_office.jpg', '2_home.jpg', @@ -267,6 +268,7 @@ public function testGetCapabilitiesUserAllowed(bool $isNotAllowed, bool $canCrea 'start-without-media' => false, 'max-duration' => 0, 'blur-virtual-background' => false, + 'end-to-end-encryption' => false, 'predefined-backgrounds' => [ '1_office.jpg', '2_home.jpg', diff --git a/tests/php/Controller/SignalingControllerTest.php b/tests/php/Controller/SignalingControllerTest.php index 1523388e0ba6..e63791c1116d 100644 --- a/tests/php/Controller/SignalingControllerTest.php +++ b/tests/php/Controller/SignalingControllerTest.php @@ -20,7 +20,6 @@ use OCA\Talk\Participant; use OCA\Talk\Room; use OCA\Talk\Service\BanService; -use OCA\Talk\Service\CertificateService; use OCA\Talk\Service\ParticipantService; use OCA\Talk\Service\RoomService; use OCA\Talk\Service\SessionService; @@ -65,7 +64,6 @@ class SignalingControllerTest extends TestCase { protected TalkSession&MockObject $session; protected \OCA\Talk\Signaling\Manager&MockObject $signalingManager; protected Manager|MockObject $manager; - protected CertificateService&MockObject $certificateService; protected ParticipantService&MockObject $participantService; protected SessionService&MockObject $sessionService; protected Messages&MockObject $messages; @@ -108,7 +106,6 @@ public function setUp(): void { $this->dbConnection = \OCP\Server::get(IDBConnection::class); $this->signalingManager = $this->createMock(\OCA\Talk\Signaling\Manager::class); $this->manager = $this->createMock(Manager::class); - $this->certificateService = $this->createMock(CertificateService::class); $this->participantService = $this->createMock(ParticipantService::class); $this->sessionService = $this->createMock(SessionService::class); $this->messages = $this->createMock(Messages::class); @@ -128,7 +125,6 @@ private function recreateSignalingController() { $this->signalingManager, $this->session, $this->manager, - $this->certificateService, $this->participantService, $this->sessionService, $this->dbConnection,