Skip to content

Commit 0860187

Browse files
committed
Add underlying code to handle authenticating websocket credentials
1 parent 1ae3740 commit 0860187

File tree

12 files changed

+240
-35
lines changed

12 files changed

+240
-35
lines changed

app/Console/Commands/Server/BulkPowerActionCommand.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55
use Illuminate\Console\Command;
66
use GuzzleHttp\Exception\RequestException;
77
use Illuminate\Validation\ValidationException;
8+
use Pterodactyl\Repositories\Daemon\PowerRepository;
89
use Illuminate\Validation\Factory as ValidatorFactory;
910
use Pterodactyl\Contracts\Repository\ServerRepositoryInterface;
10-
use Pterodactyl\Contracts\Repository\Daemon\PowerRepositoryInterface;
1111

1212
class BulkPowerActionCommand extends Command
1313
{
@@ -42,12 +42,12 @@ class BulkPowerActionCommand extends Command
4242
/**
4343
* BulkPowerActionCommand constructor.
4444
*
45-
* @param \Pterodactyl\Contracts\Repository\Daemon\PowerRepositoryInterface $powerRepository
45+
* @param \Pterodactyl\Repositories\Daemon\PowerRepository $powerRepository
4646
* @param \Pterodactyl\Contracts\Repository\ServerRepositoryInterface $repository
4747
* @param \Illuminate\Validation\Factory $validator
4848
*/
4949
public function __construct(
50-
PowerRepositoryInterface $powerRepository,
50+
PowerRepository $powerRepository,
5151
ServerRepositoryInterface $repository,
5252
ValidatorFactory $validator
5353
) {
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<?php
2+
3+
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
4+
5+
use Cake\Chronos\Chronos;
6+
use Illuminate\Support\Str;
7+
use Illuminate\Http\Request;
8+
use Illuminate\Http\Response;
9+
use Pterodactyl\Models\Server;
10+
use Illuminate\Http\JsonResponse;
11+
use Illuminate\Contracts\Cache\Repository;
12+
use Symfony\Component\HttpKernel\Exception\HttpException;
13+
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
14+
15+
class WebsocketController extends ClientApiController
16+
{
17+
/**
18+
* @var \Illuminate\Contracts\Cache\Repository
19+
*/
20+
private $cache;
21+
22+
/**
23+
* WebsocketController constructor.
24+
*
25+
* @param \Illuminate\Contracts\Cache\Repository $cache
26+
*/
27+
public function __construct(Repository $cache)
28+
{
29+
parent::__construct();
30+
31+
$this->cache = $cache;
32+
}
33+
34+
/**
35+
* Generates a one-time token that is sent along in the request to the Daemon. The
36+
* daemon then connects back to the Panel to verify that the token is valid when it
37+
* is used.
38+
*
39+
* This token is valid for 30 seconds from time of generation, it is not designed
40+
* to be stored and used over and over.
41+
*
42+
* @param \Illuminate\Http\Request $request
43+
* @param \Pterodactyl\Models\Server $server
44+
* @return \Illuminate\Http\JsonResponse
45+
*/
46+
public function __invoke(Request $request, Server $server)
47+
{
48+
if (! $request->user()->can('connect-to-ws', $server)) {
49+
throw new HttpException(
50+
Response::HTTP_FORBIDDEN, 'You do not have permission to connect to this server\'s websocket.'
51+
);
52+
}
53+
54+
$token = Str::random(32);
55+
56+
$this->cache->put('ws:' . $token, [
57+
'user_id' => $request->user()->id,
58+
'server_id' => $server->id,
59+
'request_ip' => $request->ip(),
60+
'timestamp' => Chronos::now()->toIso8601String(),
61+
], Chronos::now()->addSeconds(30));
62+
63+
$socket = str_replace(['https://', 'http://'], ['wss://', 'ws://'], $server->node->getConnectionAddress());
64+
65+
return JsonResponse::create([
66+
'data' => [
67+
'socket' => $socket . sprintf('/api/servers/%s/ws/%s', $server->uuid, $token),
68+
],
69+
]);
70+
}
71+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
<?php
2+
3+
namespace Pterodactyl\Http\Controllers\Api\Remote;
4+
5+
use Illuminate\Http\Response;
6+
use Illuminate\Contracts\Cache\Repository;
7+
use Pterodactyl\Http\Controllers\Controller;
8+
use Pterodactyl\Repositories\Eloquent\UserRepository;
9+
use Pterodactyl\Repositories\Eloquent\ServerRepository;
10+
use Symfony\Component\HttpKernel\Exception\HttpException;
11+
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
12+
use Pterodactyl\Http\Requests\Api\Remote\AuthenticateWebsocketDetailsRequest;
13+
14+
class ValidateWebsocketController extends Controller
15+
{
16+
/**
17+
* @var \Illuminate\Contracts\Cache\Repository
18+
*/
19+
private $cache;
20+
21+
/**
22+
* @var \Pterodactyl\Repositories\Eloquent\ServerRepository
23+
*/
24+
private $serverRepository;
25+
26+
/**
27+
* @var \Pterodactyl\Repositories\Eloquent\UserRepository
28+
*/
29+
private $userRepository;
30+
31+
/**
32+
* ValidateWebsocketController constructor.
33+
*
34+
* @param \Illuminate\Contracts\Cache\Repository $cache
35+
* @param \Pterodactyl\Repositories\Eloquent\ServerRepository $serverRepository
36+
* @param \Pterodactyl\Repositories\Eloquent\UserRepository $userRepository
37+
*/
38+
public function __construct(Repository $cache, ServerRepository $serverRepository, UserRepository $userRepository)
39+
{
40+
$this->cache = $cache;
41+
$this->serverRepository = $serverRepository;
42+
$this->userRepository = $userRepository;
43+
}
44+
45+
/**
46+
* Route allowing the Wings daemon to validate that a websocket route request is
47+
* valid and that the given user has permission to access the resource.
48+
*
49+
* @param \Pterodactyl\Http\Requests\Api\Remote\AuthenticateWebsocketDetailsRequest $request
50+
* @param string $token
51+
* @return \Illuminate\Http\Response
52+
*
53+
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
54+
*/
55+
public function __invoke(AuthenticateWebsocketDetailsRequest $request, string $token)
56+
{
57+
$server = $this->serverRepository->getByUuid($request->input('server_uuid'));
58+
if (! $data = $this->cache->pull('ws:' . $token)) {
59+
throw new NotFoundHttpException;
60+
}
61+
62+
/** @var \Pterodactyl\Models\User $user */
63+
$user = $this->userRepository->find($data['user_id']);
64+
if (! $user->can('connect-to-ws', $server)) {
65+
throw new HttpException(Response::HTTP_FORBIDDEN, 'You do not have permission to access this resource.');
66+
}
67+
68+
/** @var \Pterodactyl\Models\Node $node */
69+
$node = $request->attributes->get('node');
70+
71+
if (
72+
$data['server_id'] !== $server->id
73+
|| $node->id !== $server->node_id
74+
// @todo this doesn't work well in dev currently, need to look into this way more.
75+
// @todo stems from some issue with the way requests are being proxied.
76+
// || $data['request_ip'] !== $request->input('originating_request_ip')
77+
) {
78+
throw new HttpException(Response::HTTP_BAD_REQUEST, 'The token provided is not valid for the requested resource.');
79+
}
80+
81+
return Response::create('', Response::HTTP_NO_CONTENT);
82+
}
83+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
namespace Pterodactyl\Http\Requests\Api\Remote;
4+
5+
use Illuminate\Foundation\Http\FormRequest;
6+
7+
class AuthenticateWebsocketDetailsRequest extends FormRequest
8+
{
9+
/**
10+
* @return bool
11+
*/
12+
public function authorize()
13+
{
14+
return true;
15+
}
16+
17+
/**
18+
* @return array
19+
*/
20+
public function rules()
21+
{
22+
return [
23+
'server_uuid' => 'required|string',
24+
];
25+
}
26+
}

app/Repositories/Wings/DaemonPowerRepository.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ public function send(string $action): ResponseInterface
1919
Assert::isInstanceOf($this->server, Server::class);
2020

2121
return $this->getHttpClient()->post(
22-
sprintf('/api/servers/%s/power', $this->server->id),
22+
sprintf('/api/servers/%s/power', $this->server->uuid),
2323
['json' => ['action' => $action]]
2424
);
2525
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import http from '@/api/http';
2+
3+
export default (server: string): Promise<string> => {
4+
return new Promise((resolve, reject) => {
5+
http.get(`/api/client/servers/${server}/websocket`)
6+
.then(response => resolve(response.data.data.socket))
7+
.catch(reject);
8+
});
9+
};

resources/scripts/components/server/Console.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,15 +59,15 @@ export default () => {
5959
terminal.clear();
6060

6161
instance
62-
.addListener('stats', data => console.log(JSON.parse(data)))
62+
// .addListener('stats', data => console.log(JSON.parse(data)))
6363
.addListener('console output', handleConsoleOutput);
6464

6565
instance.send('send logs');
6666
}
6767

6868
return () => {
6969
instance && instance
70-
.removeListener('console output', handleConsoleOutput)
70+
.removeAllListeners('console output')
7171
.removeAllListeners('stats');
7272
};
7373
}, [ connected, instance ]);

resources/scripts/components/server/WebsocketHandler.tsx

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,19 +15,21 @@ export default () => {
1515
return;
1616
}
1717

18-
console.log('Connecting!');
19-
20-
const socket = new Websocket(
21-
`wss://wings.pterodactyl.test:8080/api/servers/${server.uuid}/ws`,
22-
'CC8kHCuMkXPosgzGO6d37wvhNcksWxG6kTrA',
23-
);
18+
const socket = new Websocket(server.uuid);
2419

2520
socket.on('SOCKET_OPEN', () => setConnectionState(true));
2621
socket.on('SOCKET_CLOSE', () => setConnectionState(false));
2722
socket.on('SOCKET_ERROR', () => setConnectionState(false));
2823
socket.on('status', (status) => setServerStatus(status));
2924

30-
setInstance(socket);
25+
socket.connect()
26+
.then(() => setInstance(socket))
27+
.catch(error => console.error(error));
28+
29+
return () => {
30+
socket && socket.close();
31+
instance && instance!.removeAllListeners();
32+
};
3133
}, [ server ]);
3234

3335
return null;

resources/scripts/plugins/Websocket.ts

Lines changed: 33 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import Sockette from 'sockette';
2+
import getWebsocketToken from '@/api/server/getWebsocketToken';
23
import { EventEmitter } from 'events';
34

45
export const SOCKET_EVENTS = [
@@ -9,42 +10,53 @@ export const SOCKET_EVENTS = [
910
];
1011

1112
export class Websocket extends EventEmitter {
12-
socket: Sockette;
13+
private socket: Sockette | null;
14+
private readonly uuid: string;
1315

14-
constructor (url: string, protocol: string) {
16+
constructor (uuid: string) {
1517
super();
1618

17-
this.socket = new Sockette(url, {
18-
protocols: protocol,
19-
onmessage: e => {
20-
try {
21-
let { event, args } = JSON.parse(e.data);
22-
this.emit(event, ...args);
23-
} catch (ex) {
24-
console.warn('Failed to parse incoming websocket message.', ex);
25-
}
26-
},
27-
onopen: () => this.emit('SOCKET_OPEN'),
28-
onreconnect: () => this.emit('SOCKET_RECONNECT'),
29-
onclose: () => this.emit('SOCKET_CLOSE'),
30-
onerror: () => this.emit('SOCKET_ERROR'),
31-
});
19+
this.socket = null;
20+
this.uuid = uuid;
21+
}
22+
23+
async connect (): Promise<void> {
24+
getWebsocketToken(this.uuid)
25+
.then(url => {
26+
this.socket = new Sockette(url, {
27+
onmessage: e => {
28+
try {
29+
let { event, args } = JSON.parse(e.data);
30+
this.emit(event, ...args);
31+
} catch (ex) {
32+
console.warn('Failed to parse incoming websocket message.', ex);
33+
}
34+
},
35+
onopen: () => this.emit('SOCKET_OPEN'),
36+
onreconnect: () => this.emit('SOCKET_RECONNECT'),
37+
onclose: () => this.emit('SOCKET_CLOSE'),
38+
onerror: () => this.emit('SOCKET_ERROR'),
39+
});
40+
41+
return Promise.resolve();
42+
})
43+
.catch(error => Promise.reject(error));
3244
}
3345

3446
close (code?: number, reason?: string) {
35-
this.socket.close(code, reason);
47+
this.socket && this.socket.close(code, reason);
3648
}
3749

3850
open () {
39-
this.socket.open();
51+
this.socket && this.socket.open();
4052
}
4153

4254
reconnect () {
43-
this.socket.reconnect();
55+
this.socket && this.socket.reconnect();
4456
}
4557

4658
send (event: string, payload?: string | string[]) {
47-
this.socket.send(JSON.stringify({
59+
this.socket && this.socket.send(JSON.stringify({
4860
event, args: Array.isArray(payload) ? payload : [ payload ],
4961
}));
5062
}

resources/scripts/routers/ServerRouter.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>)
3939
</div>
4040
</CSSTransition>
4141
<Provider store={ServerContext.useStore()}>
42+
<WebsocketHandler/>
4243
<TransitionRouter>
4344
<div className={'w-full mx-auto'} style={{ maxWidth: '1200px' }}>
4445
{!server ?
@@ -47,7 +48,6 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>)
4748
</div>
4849
:
4950
<React.Fragment>
50-
<WebsocketHandler/>
5151
<Switch location={location}>
5252
<Route path={`${match.path}`} component={ServerConsole} exact/>
5353
<Route path={`${match.path}/files`} component={FileManagerContainer} exact/>

0 commit comments

Comments
 (0)