Add underlying code to handle authenticating websocket credentials

This commit is contained in:
Dane Everitt 2019-09-08 17:48:37 -07:00
parent 1ae374069c
commit 086018751d
No known key found for this signature in database
GPG key ID: EEA66103B3D71F53
12 changed files with 240 additions and 35 deletions

View file

@ -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
) {

View file

@ -0,0 +1,71 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
use Cake\Chronos\Chronos;
use Illuminate\Support\Str;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Pterodactyl\Models\Server;
use Illuminate\Http\JsonResponse;
use Illuminate\Contracts\Cache\Repository;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
class WebsocketController extends ClientApiController
{
/**
* @var \Illuminate\Contracts\Cache\Repository
*/
private $cache;
/**
* WebsocketController constructor.
*
* @param \Illuminate\Contracts\Cache\Repository $cache
*/
public function __construct(Repository $cache)
{
parent::__construct();
$this->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),
],
]);
}
}

View file

@ -0,0 +1,83 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Remote;
use Illuminate\Http\Response;
use Illuminate\Contracts\Cache\Repository;
use Pterodactyl\Http\Controllers\Controller;
use Pterodactyl\Repositories\Eloquent\UserRepository;
use Pterodactyl\Repositories\Eloquent\ServerRepository;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Pterodactyl\Http\Requests\Api\Remote\AuthenticateWebsocketDetailsRequest;
class ValidateWebsocketController extends Controller
{
/**
* @var \Illuminate\Contracts\Cache\Repository
*/
private $cache;
/**
* @var \Pterodactyl\Repositories\Eloquent\ServerRepository
*/
private $serverRepository;
/**
* @var \Pterodactyl\Repositories\Eloquent\UserRepository
*/
private $userRepository;
/**
* ValidateWebsocketController constructor.
*
* @param \Illuminate\Contracts\Cache\Repository $cache
* @param \Pterodactyl\Repositories\Eloquent\ServerRepository $serverRepository
* @param \Pterodactyl\Repositories\Eloquent\UserRepository $userRepository
*/
public function __construct(Repository $cache, ServerRepository $serverRepository, UserRepository $userRepository)
{
$this->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);
}
}

View file

@ -0,0 +1,26 @@
<?php
namespace Pterodactyl\Http\Requests\Api\Remote;
use Illuminate\Foundation\Http\FormRequest;
class AuthenticateWebsocketDetailsRequest extends FormRequest
{
/**
* @return bool
*/
public function authorize()
{
return true;
}
/**
* @return array
*/
public function rules()
{
return [
'server_uuid' => 'required|string',
];
}
}

View file

@ -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]]
);
}

View file

@ -0,0 +1,9 @@
import http from '@/api/http';
export default (server: string): Promise<string> => {
return new Promise((resolve, reject) => {
http.get(`/api/client/servers/${server}/websocket`)
.then(response => resolve(response.data.data.socket))
.catch(reject);
});
};

View file

@ -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 ]);

View file

@ -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;

View file

@ -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<void> {
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 ],
}));
}

View file

@ -39,6 +39,7 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>)
</div>
</CSSTransition>
<Provider store={ServerContext.useStore()}>
<WebsocketHandler/>
<TransitionRouter>
<div className={'w-full mx-auto'} style={{ maxWidth: '1200px' }}>
{!server ?
@ -47,7 +48,6 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>)
</div>
:
<React.Fragment>
<WebsocketHandler/>
<Switch location={location}>
<Route path={`${match.path}`} component={ServerConsole} exact/>
<Route path={`${match.path}/files`} component={FileManagerContainer} exact/>

View file

@ -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');

View file

@ -1,6 +1,7 @@
<?php
Route::get('/authenticate/{token}', 'ValidateKeyController@index')->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 () {