Browse Source

Implement backend APIs to be used by standalone signaling server.

A standalone signaling server can be configured in the admin UI and is
notified through the BackendController on changes that should be sent to
connected clients.

See #339 for a description of the backend API.

Signed-off-by: Joachim Bauch <bauch@struktur.de>
pull/366/head
Joachim Bauch 8 years ago
parent
commit
be33ec8d9c
Failed to extract signature
  1. 1
      appinfo/info.xml
  2. 8
      appinfo/routes.php
  3. 43
      js/admin/signaling-server.js
  4. 82
      lib/Config.php
  5. 183
      lib/Controller/BackendController.php
  6. 12
      lib/Controller/PageController.php
  7. 23
      lib/Controller/RoomController.php
  8. 154
      lib/Controller/SignalingController.php
  9. 68
      lib/Settings/Admin/SignalingServer.php
  10. 24
      templates/settings/admin/signaling-server.php

1
appinfo/info.xml

@ -59,6 +59,7 @@ And in the works for the [coming versions](https://github.com/nextcloud/spreed/m
<settings>
<admin>OCA\Spreed\Settings\Admin\TurnServer</admin>
<admin>OCA\Spreed\Settings\Admin\StunServer</admin>
<admin>OCA\Spreed\Settings\Admin\SignalingServer</admin>
<admin-section>OCA\Spreed\Settings\Admin\Section</admin-section>
</settings>

8
appinfo/routes.php

@ -46,6 +46,14 @@ return [
'apiVersion' => 'v1',
],
],
[
'name' => 'Signaling#backend',
'url' => '/api/{apiVersion}/signaling/backend',
'verb' => 'POST',
'requirements' => [
'apiVersion' => 'v1',
],
],
[
'name' => 'Call#getPeersForCall',
'url' => '/api/{apiVersion}/call/{token}',

43
js/admin/signaling-server.js

@ -0,0 +1,43 @@
/* global OC, OCP, OCA, $, _, Handlebars */
(function(OC, OCP, OCA, $) {
'use strict';
OCA.VideoCalls = OCA.VideoCalls || {};
OCA.VideoCalls.Admin = OCA.VideoCalls.Admin || {};
OCA.VideoCalls.Admin.SignalingServer = {
$signaling: undefined,
init: function() {
this.$signaling = $('div.signaling-server');
this.$signaling.find('input').on('change', this.saveServer);
},
saveServer: function() {
// this.$signaling.find('input').removeClass('error');
// this.$signaling.find('.icon-checkmark-color').addClass('hidden');
// OCP.AppConfig.setValue('spreed', $(this).attr('name'), $(this).value, {
// success: function() {
// self.temporaryShowSuccess($server);
// }
// });
},
temporaryShowSuccess: function($server) {
var $icon = $server.find('.icon-checkmark-color');
$icon.removeClass('hidden');
setTimeout(function() {
$icon.addClass('hidden');
}, 2000);
}
};
})(OC, OCP, OCA, $);
$(document).ready(function(){
OCA.VideoCalls.Admin.SignalingServer.init();
});

82
lib/Config.php

@ -23,6 +23,8 @@ namespace OCA\Spreed;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\IConfig;
use OCP\IUser;
use OCP\Security\ISecureRandom;
class Config {
@ -32,14 +34,21 @@ class Config {
/** @var ITimeFactory */
protected $timeFactory;
/** @var ISecureRandom */
private $secureRandom;
/**
* Config constructor.
*
* @param IConfig $config
* @param ISecureRandom $secureRandom
* @param ITimeFactory $timeFactory
*/
public function __construct(IConfig $config, ITimeFactory $timeFactory) {
public function __construct(IConfig $config,
ISecureRandom $secureRandom,
ITimeFactory $timeFactory) {
$this->config = $config;
$this->secureRandom = $secureRandom;
$this->timeFactory = $timeFactory;
}
@ -96,4 +105,75 @@ class Config {
);
}
/**
* @return string
*/
public function getSignalingServer() {
return $this->config->getAppValue('spreed', 'signaling_server', '');
}
/**
* @return string
*/
public function getSignalingSecret() {
return $this->config->getAppValue('spreed', 'signaling_secret', '');
}
/**
* @param string $userId
* @return string
*/
public function getSignalingTicket($userId) {
if (empty($userId)) {
$secret = $this->config->getAppValue('spreed', 'signaling_ticket_secret', '');
} else {
$secret = $this->config->getUserValue($userId, 'spreed', 'signaling_ticket_secret', '');
}
if (empty($secret)) {
// Create secret lazily on first access.
// TODO(fancycode): Is there a possibility for a race condition?
$secret = $this->secureRandom->generate(255);
if (empty($userId)) {
$this->config->setAppValue('spreed', 'signaling_ticket_secret', $secret);
} else {
$this->config->setUserValue($userId, 'spreed', 'signaling_ticket_secret', $secret);
}
}
// Format is "random:timestamp:userid:checksum" and "checksum" is the
// SHA256-HMAC of "random:timestamp:userid" with the per-user secret.
$random = $this->secureRandom->generate(16);
$timestamp = $this->timeFactory->getTime();
$data = $random . ':' . $timestamp . ':' . $userId;
$hash = hash_hmac('sha256', $data, $secret);
return $data . ':' . $hash;
}
/**
* @param string $userId
* @param string $ticket
* @return bool
*/
public function validateSignalingTicket($userId, $ticket) {
if (empty($userId)) {
$secret = $this->config->getAppValue('spreed', 'signaling_ticket_secret', '');
} else {
$secret = $this->config->getUserValue($userId, 'spreed', 'signaling_ticket_secret', '');
}
if (empty($secret)) {
return false;
}
$lastcolon = strrpos($ticket, ':');
if ($lastcolon === false) {
// Immediately reject invalid formats.
return false;
}
// TODO(fancycode): Should we reject tickets that are too old?
$data = substr($ticket, 0, $lastcolon);
$hash = hash_hmac('sha256', $data, $secret);
return hash_equals($hash, substr($ticket, $lastcolon + 1));
}
}

183
lib/Controller/BackendController.php

@ -0,0 +1,183 @@
<?php
/**
* @copyright Copyright (c) 2017 Joachim Bauch <bauch@struktur.de>
*
* @author Joachim Bauch <bauch@struktur.de>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\Spreed\Controller;
use OCA\Spreed\Config;
use OCA\Spreed\Room;
use OCP\AppFramework\Controller;
use OCP\Http\Client\IClientService;
use OCP\ILogger;
use OCP\IRequest;
use OCP\Security\ISecureRandom;
class BackendController extends Controller {
/** @var Config */
private $config;
/** @var ILogger */
private $logger;
/** @var IClientService */
private $clientService;
/** @var ISecureRandom */
private $secureRandom;
/**
* @param string $appName
* @param IRequest $request
* @param IConfig $config
* @param ILogger $logger
* @param IClientService $clientService
*/
public function __construct($appName,
IRequest $request,
Config $config,
ILogger $logger,
IClientService $clientService,
ISecureRandom $secureRandom) {
parent::__construct($appName, $request);
$this->config = $config;
$this->logger = $logger;
$this->clientService = $clientService;
$this->secureRandom = $secureRandom;
}
/**
* Perform a request to the signaling backend.
*
* @param string $url
* @param array $data
*/
private function backendRequest($url, $data) {
$signaling = $this->config->getSignalingServer();
if (empty($signaling)) {
return;
}
if (substr($signaling, -1) === '/') {
$signaling = substr($signaling, 0, strlen($signaling) - 1);
}
$url = $signaling . $url;
if (substr($url, 0, 6) === 'wss://') {
$url = 'https://' . substr($url, 6);
} else if (substr($url, 0, 5) === 'ws://') {
$url = 'http://' . substr($url, 5);
}
$client = $this->clientService->newClient();
$body = json_encode($data);
$headers = [
'Content-Type' => 'application/json',
];
$random = $this->secureRandom->generate(64);
$hash = hash_hmac('sha256', $random . $body, $this->config->getSignalingSecret());
$headers['Spreed-Signaling-Random'] = $random;
$headers['Spreed-Signaling-Checksum'] = $hash;
$response = $client->post($url, [
'headers' => $headers,
'body' => $body,
'verify' => false,
]);
}
/**
* The given users are now invited to a room.
*
* @param Room $room
* @param array $userIds
*/
public function roomInvited($room, $userIds) {
$this->logger->info("Now invited to " . $room->getToken() . ": " + print_r($userIds, true));
$this->backendRequest('/api/v1/room/' . $room->getToken(), [
'type' => 'invite',
'invite' => [
'userids' => $userIds,
'properties' => [
'name' => $room->getName(),
'type' => $room->getType(),
],
],
]);
}
/**
* The given users are no longer invited to a room.
*
* @param Room $room
* @param array $userIds
*/
public function roomsDisinvited($room, $userIds) {
$this->logger->info("No longer invited to " . $room->getToken() . ": " + print_r($userIds, true));
$this->backendRequest('/api/v1/room/' . $room->getToken(), [
'type' => 'disinvite',
'disinvite' => [
'userids' => $userIds,
],
]);
}
/**
* The given room has been modified.
*
* @param Room $room
*/
public function roomModified($room) {
$this->logger->info("Room modified: " . $room->getToken());
$participants = $room->getParticipants();
$userIds = [];
foreach ($participants['users'] as $participant => $data) {
array_push($userIds, $participant);
}
$this->backendRequest('/api/v1/room/' . $room->getToken(), [
'type' => 'update',
'update' => [
'userids' => $userIds,
'properties' => [
'name' => $room->getName(),
'type' => $room->getType(),
],
],
]);
}
/**
* The given room has been deleted.
*
* @param Room $room
*/
public function roomDeleted($room) {
$this->logger->info("Room deleted: " . $room->getToken());
$participants = $room->getParticipants();
$userIds = [];
foreach ($participants['users'] as $participant => $data) {
array_push($userIds, $participant);
}
$this->backendRequest('/api/v1/room/' . $room->getToken(), [
'type' => 'delete',
'delete' => [
'userids' => $userIds,
],
]);
}
}

12
lib/Controller/PageController.php

@ -26,6 +26,7 @@ namespace OCA\Spreed\Controller;
use OC\HintException;
use OCA\Spreed\Exceptions\ParticipantNotFoundException;
use OCA\Spreed\Exceptions\RoomNotFoundException;
use OCA\Spreed\Config;
use OCA\Spreed\Manager;
use OCA\Spreed\Participant;
use OCA\Spreed\Room;
@ -55,6 +56,8 @@ class PageController extends Controller {
private $url;
/** @var IManager */
private $notificationManager;
/** @var Config */
private $config;
/**
* @param string $appName
@ -66,6 +69,7 @@ class PageController extends Controller {
* @param Manager $manager
* @param IURLGenerator $url
* @param IManager $notificationManager
* @param Config $config
*/
public function __construct($appName,
IRequest $request,
@ -75,7 +79,8 @@ class PageController extends Controller {
ILogger $logger,
Manager $manager,
IURLGenerator $url,
IManager $notificationManager) {
IManager $notificationManager,
Config $config) {
parent::__construct($appName, $request);
$this->api = $api;
$this->session = $session;
@ -84,6 +89,7 @@ class PageController extends Controller {
$this->manager = $manager;
$this->url = $url;
$this->notificationManager = $notificationManager;
$this->config = $config;
}
/**
@ -156,6 +162,8 @@ class PageController extends Controller {
$params = [
'token' => $token,
'signaling-server' => $this->config->getSignalingServer(),
'signaling-ticket' => $this->config->getSignalingTicket($this->userId),
];
$response = new TemplateResponse($this->appName, 'index', $params);
$csp = new ContentSecurityPolicy();
@ -194,6 +202,8 @@ class PageController extends Controller {
$params = [
'token' => $token,
'signaling-server' => $this->config->getSignalingServer(),
'signaling-ticket' => $this->config->getSignalingTicket($this->userId),
];
$response = new TemplateResponse($this->appName, 'index-public', $params, 'base');
$csp = new ContentSecurityPolicy();

23
lib/Controller/RoomController.php

@ -63,6 +63,8 @@ class RoomController extends OCSController {
private $activityManager;
/** @var IL10N */
private $l10n;
/** @var BackendController */
private $backend;
/**
* @param string $appName
@ -76,6 +78,7 @@ class RoomController extends OCSController {
* @param INotificationManager $notificationManager
* @param IActivityManager $activityManager
* @param IL10N $l10n
* @param BackendController $backend
*/
public function __construct($appName,
$UserId,
@ -87,7 +90,8 @@ class RoomController extends OCSController {
Manager $manager,
INotificationManager $notificationManager,
IActivityManager $activityManager,
IL10N $l10n) {
IL10N $l10n,
BackendController $backend) {
parent::__construct($appName, $request);
$this->session = $session;
$this->userId = $UserId;
@ -98,6 +102,7 @@ class RoomController extends OCSController {
$this->notificationManager = $notificationManager;
$this->activityManager = $activityManager;
$this->l10n = $l10n;
$this->backend = $backend;
}
/**
@ -350,6 +355,11 @@ class RoomController extends OCSController {
$this->createNotification($currentUser, $targetUser, $room);
$this->backend->roomInvited($room, [
$currentUser->getUID(),
$targetUser->getUID(),
]);
return new DataResponse(['token' => $room->getToken()], Http::STATUS_CREATED);
}
}
@ -419,6 +429,8 @@ class RoomController extends OCSController {
'participantType' => Participant::OWNER,
]);
$this->backend->roomInvited($room, [$this->userId]);
return new DataResponse(['token' => $room->getToken()], Http::STATUS_CREATED);
}
@ -450,6 +462,8 @@ class RoomController extends OCSController {
if (!$room->setName($roomName)) {
return new DataResponse([], Http::STATUS_METHOD_NOT_ALLOWED);
}
$this->backend->roomModified($room);
return new DataResponse([]);
}
@ -473,6 +487,7 @@ class RoomController extends OCSController {
return new DataResponse([], Http::STATUS_FORBIDDEN);
}
$this->backend->roomDeleted($room);
$room->deleteRoom();
return new DataResponse([]);
@ -561,6 +576,7 @@ class RoomController extends OCSController {
'userId' => $newUser->getUID(),
]);
$this->createNotification($currentUser, $newUser, $room);
$this->backend->roomInvited($room, [$newUser->getUID()]);
return new DataResponse(['type' => $room->getType()]);
}
@ -569,6 +585,7 @@ class RoomController extends OCSController {
'userId' => $newUser->getUID(),
]);
$this->createNotification($currentUser, $newUser, $room);
$this->backend->roomInvited($room, [$newUser->getUID()]);
return new DataResponse([]);
}
@ -596,6 +613,7 @@ class RoomController extends OCSController {
if ($room->getType() === Room::ONE_TO_ONE_CALL) {
$room->deleteRoom();
$this->backend->roomDeleted($room);
return new DataResponse([]);
}
@ -615,6 +633,7 @@ class RoomController extends OCSController {
}
$room->removeUser($targetUser);
$this->backend->roomsDisinvited($room, [$targetUser->getUID()]);
return new DataResponse([]);
}
@ -636,6 +655,7 @@ class RoomController extends OCSController {
if ($room->getType() === Room::ONE_TO_ONE_CALL || $room->getNumberOfParticipants() === 1) {
$room->deleteRoom();
$this->backend->roomDeleted($room);
} else {
$currentUser = $this->userManager->get($this->userId);
if (!$currentUser instanceof IUser) {
@ -643,6 +663,7 @@ class RoomController extends OCSController {
}
$room->removeUser($currentUser);
$this->backend->roomsDisinvited($room, [$currentUser->getUID()]);
}
return new DataResponse([]);

154
lib/Controller/SignalingController.php

@ -30,10 +30,13 @@ use OCA\Spreed\Room;
use OCA\Spreed\Signaling\Messages;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\JSONResponse;
use OCP\AppFramework\OCSController;
use OCP\IDBConnection;
use OCP\IRequest;
use OCP\ISession;
use OCP\IUser;
use OCP\IUserManager;
class SignalingController extends OCSController {
/** @var Config */
@ -48,6 +51,8 @@ class SignalingController extends OCSController {
private $messages;
/** @var string|null */
private $userId;
/** @var IUserManager */
private $userManager;
/**
* @param string $appName
@ -66,6 +71,7 @@ class SignalingController extends OCSController {
Manager $manager,
IDBConnection $connection,
Messages $messages,
IUserManager $userManager,
$UserId) {
parent::__construct($appName, $request);
$this->config = $config;
@ -73,6 +79,7 @@ class SignalingController extends OCSController {
$this->dbConnection = $connection;
$this->manager = $manager;
$this->messages = $messages;
$this->userManager = $userManager;
$this->userId = $UserId;
}
@ -83,6 +90,10 @@ class SignalingController extends OCSController {
* @return DataResponse
*/
public function signaling($messages) {
if ($this->config->getSignalingServer() !== '') {
throw new \Exception('Internal signaling disabled.');
}
$response = [];
$messages = json_decode($messages, true);
foreach($messages as $message) {
@ -137,6 +148,10 @@ class SignalingController extends OCSController {
* @return DataResponse
*/
public function pullMessages() {
if ($this->config->getSignalingServer() !== '') {
throw new \Exception('Internal signaling disabled.');
}
$data = [];
$seconds = 30;
$sessionId = '';
@ -221,4 +236,143 @@ class SignalingController extends OCSController {
return $usersInRoom;
}
/*
* Check if the current request is coming from an allowed backend.
*
* The backends are sending the custom header "Spreed-Signaling-Random"
* containing at least 32 bytes random data, and the header
* "Spreed-Signaling-Checksum", which is the SHA256-HMAC of the random data
* and the body of the request, calculated with the shared secret from the
* configuration.
*
* @return bool
*/
private function validateBackendRequest($data) {
$random = $_SERVER['HTTP_SPREED_SIGNALING_RANDOM'];
if (empty($random) || strlen($random) < 32) {
return false;
}
$checksum = $_SERVER['HTTP_SPREED_SIGNALING_CHECKSUM'];
if (empty($checksum)) {
return false;
}
$hash = hash_hmac('sha256', $random . $data, $this->config->getSignalingSecret());
return hash_equals($hash, strtolower($checksum));
}
/**
* Backend API to query information required for standalone signaling
* servers.
*
* See sections "Backend validation" in
* https://github.com/nextcloud/spreed/wiki/Spreed-Signaling-API
*
* @NoCSRFRequired
* @PublicPage
*
* @param string $message
* @return JSONResponse
*/
public function backend() {
$json = file_get_contents('php://input');
if (!$this->validateBackendRequest($json)) {
return new JSONResponse([
'type' => 'error',
'error' => [
'code' => 'invalid_request',
'message' => 'The request could not be authenticated.',
],
]);
}
$message = json_decode($json, true);
switch ($message['type']) {
case 'auth':
// Query authentication information about a user.
return $this->backendAuth($message['auth']);
case 'room':
// Query information about a room.
return $this->backendRoom($message['room']);
default:
return new JSONResponse([
'type' => 'error',
'error' => [
'code' => 'unknown_type',
'message' => 'The given type ' . print_r($message, true) . ' is not supported.',
],
]);
}
}
private function backendAuth($auth) {
$params = $auth['params'];
$userId = $params['userid'];
if (!$this->config->validateSignalingTicket($userId, $params['ticket'])) {
return new JSONResponse([
'type' => 'error',
'error' => [
'code' => 'invalid_ticket',
'message' => 'The given ticket is not valid for this user.',
],
]);
}
if (!empty($userId)) {
$user = $this->userManager->get($userId);
if (!$user instanceof IUser) {
return new JSONResponse([
'type' => 'error',
'error' => [
'code' => 'no_such_user',
'message' => 'The given user does not exist.',
],
]);
}
}
$response = [
'type' => 'auth',
'auth' => [
'version' => '1.0',
],
];
if (!empty($userId)) {
$response['auth']['userid'] = $user->getUID();
$response['auth']['user'] = [
'displayname' => $user->getDisplayName(),
];
}
return new JSONResponse($response);
}
private function backendRoom($room) {
$roomId = $room['roomid'];
$userId = $room['userid'];
try {
$room = $this->manager->getRoomForParticipantByToken($roomId, $userId);
} catch (RoomNotFoundException $e) {
return new JSONResponse([
'type' => 'error',
'error' => [
'code' => 'no_such_room',
'message' => 'The user is not invited to this room.',
],
]);
}
$response = [
'type' => 'room',
'room' => [
'version' => '1.0',
'roomid' => $room->getToken(),
'properties' => [
'name' => $room->getName(),
'type' => $room->getType(),
],
],
];
return new JSONResponse($response);
}
}

68
lib/Settings/Admin/SignalingServer.php

@ -0,0 +1,68 @@
<?php
/**
* @author Joachim Bauch <mail@joachim-bauch.de>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\Spreed\Settings\Admin;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\IConfig;
use OCP\Settings\ISettings;
class SignalingServer implements ISettings {
/** @var IConfig */
private $config;
public function __construct(IConfig $config) {
$this->config = $config;
}
/**
* @return TemplateResponse
*/
public function getForm() {
$parameters = [
'signalingServer' => $this->config->getAppValue('spreed', 'signaling_server'),
'signalingSecret' => $this->config->getAppValue('spreed', 'signaling_secret'),
];
return new TemplateResponse('spreed', 'settings/admin/signaling-server', $parameters, '');
}
/**
* @return string the section ID, e.g. 'sharing'
*/
public function getSection() {
return 'videocalls';
}
/**
* @return int whether the form should be rather on the top or bottom of
* the admin section. The forms are arranged in ascending order of the
* priority values. It is required to return a value between 0 and 100.
*
* E.g.: 70
*/
public function getPriority() {
return 75;
}
}

24
templates/settings/admin/signaling-server.php

@ -0,0 +1,24 @@
<?php
/** @var array $_ */
/** @var \OCP\IL10N $l */
script('spreed', ['admin/signaling-server']);
style('spreed', ['settings-admin']);
?>
<div class="videocalls section signaling-server">
<h3><?php p($l->t('Signaling server')) ?></h3>
<p class="settings-hint"><?php p($l->t('An external signaling server can optionally be used for larger installations. Leave empty to use the internal signaling server.')) ?></p>
<p>
<label for="signaling_server"><?php p($l->t('External signaling server')) ?></label>
<input type="text" id="signaling_server"
name="signaling_server" placeholder="wss://signaling.example.org"
value="<?php p($_['signalingServer']) ?>" />
</p>
<p>
<label for="signaling_secret"><?php p($l->t('Shared secret')) ?></label>
<input type="text" id="signaling_secret"
name="signaling_secret" placeholder="shared secret"
value="<?php p($_['signalingSecret']) ?>" />
</p>
</div>
Loading…
Cancel
Save