From 086018751d24dcf698e587d925ada9010352e532 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Sun, 8 Sep 2019 17:48:37 -0700 Subject: [PATCH] Add underlying code to handle authenticating websocket credentials --- .../Server/BulkPowerActionCommand.php | 6 +- .../Client/Servers/WebsocketController.php | 71 ++++++++++++++++ .../Remote/ValidateWebsocketController.php | 83 +++++++++++++++++++ .../AuthenticateWebsocketDetailsRequest.php | 26 ++++++ .../Wings/DaemonPowerRepository.php | 2 +- .../scripts/api/server/getWebsocketToken.ts | 9 ++ .../scripts/components/server/Console.tsx | 4 +- .../components/server/WebsocketHandler.tsx | 16 ++-- resources/scripts/plugins/Websocket.ts | 54 +++++++----- resources/scripts/routers/ServerRouter.tsx | 2 +- routes/api-client.php | 1 + routes/api-remote.php | 1 + 12 files changed, 240 insertions(+), 35 deletions(-) create mode 100644 app/Http/Controllers/Api/Client/Servers/WebsocketController.php create mode 100644 app/Http/Controllers/Api/Remote/ValidateWebsocketController.php create mode 100644 app/Http/Requests/Api/Remote/AuthenticateWebsocketDetailsRequest.php create mode 100644 resources/scripts/api/server/getWebsocketToken.ts diff --git a/app/Console/Commands/Server/BulkPowerActionCommand.php b/app/Console/Commands/Server/BulkPowerActionCommand.php index 58188c6d4..7c34a56e7 100644 --- a/app/Console/Commands/Server/BulkPowerActionCommand.php +++ b/app/Console/Commands/Server/BulkPowerActionCommand.php @@ -5,9 +5,9 @@ namespace Pterodactyl\Console\Commands\Server; use Illuminate\Console\Command; use GuzzleHttp\Exception\RequestException; use Illuminate\Validation\ValidationException; +use Pterodactyl\Repositories\Daemon\PowerRepository; use Illuminate\Validation\Factory as ValidatorFactory; use Pterodactyl\Contracts\Repository\ServerRepositoryInterface; -use Pterodactyl\Contracts\Repository\Daemon\PowerRepositoryInterface; class BulkPowerActionCommand extends Command { @@ -42,12 +42,12 @@ class BulkPowerActionCommand extends Command /** * BulkPowerActionCommand constructor. * - * @param \Pterodactyl\Contracts\Repository\Daemon\PowerRepositoryInterface $powerRepository + * @param \Pterodactyl\Repositories\Daemon\PowerRepository $powerRepository * @param \Pterodactyl\Contracts\Repository\ServerRepositoryInterface $repository * @param \Illuminate\Validation\Factory $validator */ public function __construct( - PowerRepositoryInterface $powerRepository, + PowerRepository $powerRepository, ServerRepositoryInterface $repository, ValidatorFactory $validator ) { diff --git a/app/Http/Controllers/Api/Client/Servers/WebsocketController.php b/app/Http/Controllers/Api/Client/Servers/WebsocketController.php new file mode 100644 index 000000000..e8dccc47f --- /dev/null +++ b/app/Http/Controllers/Api/Client/Servers/WebsocketController.php @@ -0,0 +1,71 @@ +cache = $cache; + } + + /** + * Generates a one-time token that is sent along in the request to the Daemon. The + * daemon then connects back to the Panel to verify that the token is valid when it + * is used. + * + * This token is valid for 30 seconds from time of generation, it is not designed + * to be stored and used over and over. + * + * @param \Illuminate\Http\Request $request + * @param \Pterodactyl\Models\Server $server + * @return \Illuminate\Http\JsonResponse + */ + public function __invoke(Request $request, Server $server) + { + if (! $request->user()->can('connect-to-ws', $server)) { + throw new HttpException( + Response::HTTP_FORBIDDEN, 'You do not have permission to connect to this server\'s websocket.' + ); + } + + $token = Str::random(32); + + $this->cache->put('ws:' . $token, [ + 'user_id' => $request->user()->id, + 'server_id' => $server->id, + 'request_ip' => $request->ip(), + 'timestamp' => Chronos::now()->toIso8601String(), + ], Chronos::now()->addSeconds(30)); + + $socket = str_replace(['https://', 'http://'], ['wss://', 'ws://'], $server->node->getConnectionAddress()); + + return JsonResponse::create([ + 'data' => [ + 'socket' => $socket . sprintf('/api/servers/%s/ws/%s', $server->uuid, $token), + ], + ]); + } +} diff --git a/app/Http/Controllers/Api/Remote/ValidateWebsocketController.php b/app/Http/Controllers/Api/Remote/ValidateWebsocketController.php new file mode 100644 index 000000000..d3b8ab5c0 --- /dev/null +++ b/app/Http/Controllers/Api/Remote/ValidateWebsocketController.php @@ -0,0 +1,83 @@ +cache = $cache; + $this->serverRepository = $serverRepository; + $this->userRepository = $userRepository; + } + + /** + * Route allowing the Wings daemon to validate that a websocket route request is + * valid and that the given user has permission to access the resource. + * + * @param \Pterodactyl\Http\Requests\Api\Remote\AuthenticateWebsocketDetailsRequest $request + * @param string $token + * @return \Illuminate\Http\Response + * + * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException + */ + public function __invoke(AuthenticateWebsocketDetailsRequest $request, string $token) + { + $server = $this->serverRepository->getByUuid($request->input('server_uuid')); + if (! $data = $this->cache->pull('ws:' . $token)) { + throw new NotFoundHttpException; + } + + /** @var \Pterodactyl\Models\User $user */ + $user = $this->userRepository->find($data['user_id']); + if (! $user->can('connect-to-ws', $server)) { + throw new HttpException(Response::HTTP_FORBIDDEN, 'You do not have permission to access this resource.'); + } + + /** @var \Pterodactyl\Models\Node $node */ + $node = $request->attributes->get('node'); + + if ( + $data['server_id'] !== $server->id + || $node->id !== $server->node_id + // @todo this doesn't work well in dev currently, need to look into this way more. + // @todo stems from some issue with the way requests are being proxied. + // || $data['request_ip'] !== $request->input('originating_request_ip') + ) { + throw new HttpException(Response::HTTP_BAD_REQUEST, 'The token provided is not valid for the requested resource.'); + } + + return Response::create('', Response::HTTP_NO_CONTENT); + } +} diff --git a/app/Http/Requests/Api/Remote/AuthenticateWebsocketDetailsRequest.php b/app/Http/Requests/Api/Remote/AuthenticateWebsocketDetailsRequest.php new file mode 100644 index 000000000..885e19239 --- /dev/null +++ b/app/Http/Requests/Api/Remote/AuthenticateWebsocketDetailsRequest.php @@ -0,0 +1,26 @@ + 'required|string', + ]; + } +} diff --git a/app/Repositories/Wings/DaemonPowerRepository.php b/app/Repositories/Wings/DaemonPowerRepository.php index 1338cd31e..d7ef42c4f 100644 --- a/app/Repositories/Wings/DaemonPowerRepository.php +++ b/app/Repositories/Wings/DaemonPowerRepository.php @@ -19,7 +19,7 @@ class DaemonPowerRepository extends DaemonRepository Assert::isInstanceOf($this->server, Server::class); return $this->getHttpClient()->post( - sprintf('/api/servers/%s/power', $this->server->id), + sprintf('/api/servers/%s/power', $this->server->uuid), ['json' => ['action' => $action]] ); } diff --git a/resources/scripts/api/server/getWebsocketToken.ts b/resources/scripts/api/server/getWebsocketToken.ts new file mode 100644 index 000000000..cb7801e53 --- /dev/null +++ b/resources/scripts/api/server/getWebsocketToken.ts @@ -0,0 +1,9 @@ +import http from '@/api/http'; + +export default (server: string): Promise => { + return new Promise((resolve, reject) => { + http.get(`/api/client/servers/${server}/websocket`) + .then(response => resolve(response.data.data.socket)) + .catch(reject); + }); +}; diff --git a/resources/scripts/components/server/Console.tsx b/resources/scripts/components/server/Console.tsx index ce2514bf9..49f49e5ea 100644 --- a/resources/scripts/components/server/Console.tsx +++ b/resources/scripts/components/server/Console.tsx @@ -59,7 +59,7 @@ export default () => { terminal.clear(); instance - .addListener('stats', data => console.log(JSON.parse(data))) + // .addListener('stats', data => console.log(JSON.parse(data))) .addListener('console output', handleConsoleOutput); instance.send('send logs'); @@ -67,7 +67,7 @@ export default () => { return () => { instance && instance - .removeListener('console output', handleConsoleOutput) + .removeAllListeners('console output') .removeAllListeners('stats'); }; }, [ connected, instance ]); diff --git a/resources/scripts/components/server/WebsocketHandler.tsx b/resources/scripts/components/server/WebsocketHandler.tsx index a8d2f3003..565e96bbf 100644 --- a/resources/scripts/components/server/WebsocketHandler.tsx +++ b/resources/scripts/components/server/WebsocketHandler.tsx @@ -15,19 +15,21 @@ export default () => { return; } - console.log('Connecting!'); - - const socket = new Websocket( - `wss://wings.pterodactyl.test:8080/api/servers/${server.uuid}/ws`, - 'CC8kHCuMkXPosgzGO6d37wvhNcksWxG6kTrA', - ); + const socket = new Websocket(server.uuid); socket.on('SOCKET_OPEN', () => setConnectionState(true)); socket.on('SOCKET_CLOSE', () => setConnectionState(false)); socket.on('SOCKET_ERROR', () => setConnectionState(false)); socket.on('status', (status) => setServerStatus(status)); - setInstance(socket); + socket.connect() + .then(() => setInstance(socket)) + .catch(error => console.error(error)); + + return () => { + socket && socket.close(); + instance && instance!.removeAllListeners(); + }; }, [ server ]); return null; diff --git a/resources/scripts/plugins/Websocket.ts b/resources/scripts/plugins/Websocket.ts index f48bddec6..2122ff264 100644 --- a/resources/scripts/plugins/Websocket.ts +++ b/resources/scripts/plugins/Websocket.ts @@ -1,4 +1,5 @@ import Sockette from 'sockette'; +import getWebsocketToken from '@/api/server/getWebsocketToken'; import { EventEmitter } from 'events'; export const SOCKET_EVENTS = [ @@ -9,42 +10,53 @@ export const SOCKET_EVENTS = [ ]; export class Websocket extends EventEmitter { - socket: Sockette; + private socket: Sockette | null; + private readonly uuid: string; - constructor (url: string, protocol: string) { + constructor (uuid: string) { super(); - this.socket = new Sockette(url, { - protocols: protocol, - onmessage: e => { - try { - let { event, args } = JSON.parse(e.data); - this.emit(event, ...args); - } catch (ex) { - console.warn('Failed to parse incoming websocket message.', ex); - } - }, - onopen: () => this.emit('SOCKET_OPEN'), - onreconnect: () => this.emit('SOCKET_RECONNECT'), - onclose: () => this.emit('SOCKET_CLOSE'), - onerror: () => this.emit('SOCKET_ERROR'), - }); + this.socket = null; + this.uuid = uuid; + } + + async connect (): Promise { + getWebsocketToken(this.uuid) + .then(url => { + this.socket = new Sockette(url, { + onmessage: e => { + try { + let { event, args } = JSON.parse(e.data); + this.emit(event, ...args); + } catch (ex) { + console.warn('Failed to parse incoming websocket message.', ex); + } + }, + onopen: () => this.emit('SOCKET_OPEN'), + onreconnect: () => this.emit('SOCKET_RECONNECT'), + onclose: () => this.emit('SOCKET_CLOSE'), + onerror: () => this.emit('SOCKET_ERROR'), + }); + + return Promise.resolve(); + }) + .catch(error => Promise.reject(error)); } close (code?: number, reason?: string) { - this.socket.close(code, reason); + this.socket && this.socket.close(code, reason); } open () { - this.socket.open(); + this.socket && this.socket.open(); } reconnect () { - this.socket.reconnect(); + this.socket && this.socket.reconnect(); } send (event: string, payload?: string | string[]) { - this.socket.send(JSON.stringify({ + this.socket && this.socket.send(JSON.stringify({ event, args: Array.isArray(payload) ? payload : [ payload ], })); } diff --git a/resources/scripts/routers/ServerRouter.tsx b/resources/scripts/routers/ServerRouter.tsx index bd5e691a4..010e5198c 100644 --- a/resources/scripts/routers/ServerRouter.tsx +++ b/resources/scripts/routers/ServerRouter.tsx @@ -39,6 +39,7 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>) +
{!server ? @@ -47,7 +48,6 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>)
: - diff --git a/routes/api-client.php b/routes/api-client.php index 05366c44c..560645e6d 100644 --- a/routes/api-client.php +++ b/routes/api-client.php @@ -29,6 +29,7 @@ Route::group(['prefix' => '/account'], function () { */ Route::group(['prefix' => '/servers/{server}', 'middleware' => [AuthenticateServerAccess::class]], function () { Route::get('/', 'Servers\ServerController@index')->name('api.client.servers.view'); + Route::get('/websocket', 'Servers\WebsocketController')->name('api.client.servers.websocket'); Route::get('/resources', 'Servers\ResourceUtilizationController') ->name('api.client.servers.resources'); diff --git a/routes/api-remote.php b/routes/api-remote.php index 5566651d4..24cc78576 100644 --- a/routes/api-remote.php +++ b/routes/api-remote.php @@ -1,6 +1,7 @@ name('api.remote.authenticate'); +Route::post('/websocket/{token}', 'ValidateWebsocketController')->name('api.remote.authenticate_websocket'); Route::post('/download-file', 'FileDownloadController@index')->name('api.remote.download_file'); Route::group(['prefix' => '/eggs'], function () {