Merge pull request #2883 from pterodactyl/matthewpi/transfer-improvements

Add Transfer Logs
This commit is contained in:
Dane Everitt 2020-12-24 10:37:30 -08:00 committed by GitHub
commit 70afc51c9e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 579 additions and 246 deletions

View file

@ -0,0 +1,17 @@
<?php
namespace Pterodactyl\Exceptions\Http\Server;
use Illuminate\Http\Response;
use Symfony\Component\HttpKernel\Exception\HttpException;
class ServerTransferringException extends HttpException
{
/**
* ServerTransferringException constructor.
*/
public function __construct()
{
parent::__construct(Response::HTTP_CONFLICT, 'This server is currently being transferred to a new machine, please try again laster.');
}
}

View file

@ -8,7 +8,6 @@ use Prologue\Alerts\AlertsMessageBag;
use Pterodactyl\Models\ServerTransfer; use Pterodactyl\Models\ServerTransfer;
use Pterodactyl\Http\Controllers\Controller; use Pterodactyl\Http\Controllers\Controller;
use Pterodactyl\Services\Servers\TransferService; use Pterodactyl\Services\Servers\TransferService;
use Pterodactyl\Services\Servers\SuspensionService;
use Pterodactyl\Repositories\Eloquent\NodeRepository; use Pterodactyl\Repositories\Eloquent\NodeRepository;
use Pterodactyl\Repositories\Eloquent\ServerRepository; use Pterodactyl\Repositories\Eloquent\ServerRepository;
use Pterodactyl\Repositories\Eloquent\LocationRepository; use Pterodactyl\Repositories\Eloquent\LocationRepository;
@ -42,11 +41,6 @@ class ServerTransferController extends Controller
*/ */
private $nodeRepository; private $nodeRepository;
/**
* @var \Pterodactyl\Services\Servers\SuspensionService
*/
private $suspensionService;
/** /**
* @var \Pterodactyl\Services\Servers\TransferService * @var \Pterodactyl\Services\Servers\TransferService
*/ */
@ -65,7 +59,6 @@ class ServerTransferController extends Controller
* @param \Pterodactyl\Repositories\Eloquent\ServerRepository $repository * @param \Pterodactyl\Repositories\Eloquent\ServerRepository $repository
* @param \Pterodactyl\Repositories\Eloquent\LocationRepository $locationRepository * @param \Pterodactyl\Repositories\Eloquent\LocationRepository $locationRepository
* @param \Pterodactyl\Repositories\Eloquent\NodeRepository $nodeRepository * @param \Pterodactyl\Repositories\Eloquent\NodeRepository $nodeRepository
* @param \Pterodactyl\Services\Servers\SuspensionService $suspensionService
* @param \Pterodactyl\Services\Servers\TransferService $transferService * @param \Pterodactyl\Services\Servers\TransferService $transferService
* @param \Pterodactyl\Repositories\Wings\DaemonConfigurationRepository $daemonConfigurationRepository * @param \Pterodactyl\Repositories\Wings\DaemonConfigurationRepository $daemonConfigurationRepository
*/ */
@ -75,7 +68,6 @@ class ServerTransferController extends Controller
ServerRepository $repository, ServerRepository $repository,
LocationRepository $locationRepository, LocationRepository $locationRepository,
NodeRepository $nodeRepository, NodeRepository $nodeRepository,
SuspensionService $suspensionService,
TransferService $transferService, TransferService $transferService,
DaemonConfigurationRepository $daemonConfigurationRepository DaemonConfigurationRepository $daemonConfigurationRepository
) { ) {
@ -84,7 +76,6 @@ class ServerTransferController extends Controller
$this->repository = $repository; $this->repository = $repository;
$this->locationRepository = $locationRepository; $this->locationRepository = $locationRepository;
$this->nodeRepository = $nodeRepository; $this->nodeRepository = $nodeRepository;
$this->suspensionService = $suspensionService;
$this->transferService = $transferService; $this->transferService = $transferService;
$this->daemonConfigurationRepository = $daemonConfigurationRepository; $this->daemonConfigurationRepository = $daemonConfigurationRepository;
} }
@ -98,8 +89,7 @@ class ServerTransferController extends Controller
* *
* @throws \Throwable * @throws \Throwable
*/ */
public function transfer(Request $request, Server $server) public function transfer(Request $request, Server $server) {
{
$validatedData = $request->validate([ $validatedData = $request->validate([
'node_id' => 'required|exists:nodes,id', 'node_id' => 'required|exists:nodes,id',
'allocation_id' => 'required|bail|unique:servers|exists:allocations,id', 'allocation_id' => 'required|bail|unique:servers|exists:allocations,id',
@ -116,9 +106,6 @@ class ServerTransferController extends Controller
// Check if the selected daemon is online. // Check if the selected daemon is online.
$this->daemonConfigurationRepository->setNode($node)->getSystemInformation(); $this->daemonConfigurationRepository->setNode($node)->getSystemInformation();
// Suspend the server and request an archive to be created.
$this->suspensionService->toggle($server, 'suspend');
// Create a new ServerTransfer entry. // Create a new ServerTransfer entry.
$transfer = new ServerTransfer; $transfer = new ServerTransfer;
@ -127,8 +114,8 @@ class ServerTransferController extends Controller
$transfer->new_node = $node_id; $transfer->new_node = $node_id;
$transfer->old_allocation = $server->allocation_id; $transfer->old_allocation = $server->allocation_id;
$transfer->new_allocation = $allocation_id; $transfer->new_allocation = $allocation_id;
$transfer->old_additional_allocations = json_encode($server->allocations->where('id', '!=', $server->allocation_id)->pluck('id')); $transfer->old_additional_allocations = $server->allocations->where('id', '!=', $server->allocation_id)->pluck('id');
$transfer->new_additional_allocations = json_encode($additional_allocations); $transfer->new_additional_allocations = $additional_allocations;
$transfer->save(); $transfer->save();

View file

@ -3,12 +3,11 @@
namespace Pterodactyl\Http\Controllers\Api\Client\Servers; namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Http\Response;
use Pterodactyl\Models\Server; use Pterodactyl\Models\Server;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Pterodactyl\Models\Permission; use Pterodactyl\Models\Permission;
use Pterodactyl\Services\Nodes\NodeJWTService; use Pterodactyl\Services\Nodes\NodeJWTService;
use Symfony\Component\HttpKernel\Exception\HttpException; use Pterodactyl\Exceptions\Http\HttpForbiddenException;
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest; use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
use Pterodactyl\Services\Servers\GetUserPermissionsService; use Pterodactyl\Services\Servers\GetUserPermissionsService;
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController; use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
@ -55,7 +54,22 @@ class WebsocketController extends ClientApiController
{ {
$user = $request->user(); $user = $request->user();
if ($user->cannot(Permission::ACTION_WEBSOCKET_CONNECT, $server)) { if ($user->cannot(Permission::ACTION_WEBSOCKET_CONNECT, $server)) {
throw new HttpException(Response::HTTP_FORBIDDEN, 'You do not have permission to connect to this server\'s websocket.'); throw new HttpForbiddenException('You do not have permission to connect to this server\'s websocket.');
}
$permissions = $this->permissionsService->handle($server, $user);
$node = $server->node;
if (! is_null($server->transfer)) {
// Check if the user has permissions to receive transfer logs.
if (! in_array('admin.websocket.transfer', $permissions)) {
throw new HttpForbiddenException('You do not have permission to view server transfer logs.');
}
// Redirect the websocket request to the new node if the server has been archived.
if ($server->transfer->archived) {
$node = $server->transfer->newNode;
}
} }
$token = $this->jwtService $token = $this->jwtService
@ -63,11 +77,11 @@ class WebsocketController extends ClientApiController
->setClaims([ ->setClaims([
'user_id' => $request->user()->id, 'user_id' => $request->user()->id,
'server_uuid' => $server->uuid, 'server_uuid' => $server->uuid,
'permissions' => $this->permissionsService->handle($server, $user), 'permissions' => $permissions,
]) ])
->handle($server->node, $user->id . $server->uuid); ->handle($node, $user->id . $server->uuid);
$socket = str_replace(['https://', 'http://'], ['wss://', 'ws://'], $server->node->getConnectionAddress()); $socket = str_replace(['https://', 'http://'], ['wss://', 'ws://'], $node->getConnectionAddress());
return new JsonResponse([ return new JsonResponse([
'data' => [ 'data' => [

View file

@ -3,21 +3,19 @@
namespace Pterodactyl\Http\Controllers\Api\Remote\Servers; namespace Pterodactyl\Http\Controllers\Api\Remote\Servers;
use Cake\Chronos\Chronos; use Cake\Chronos\Chronos;
use Lcobucci\JWT\Builder; use Illuminate\Support\Arr;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Lcobucci\JWT\Signer\Key;
use Psr\Log\LoggerInterface;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Lcobucci\JWT\Signer\Hmac\Sha256; use Pterodactyl\Models\Allocation;
use Illuminate\Support\Facades\Log;
use Pterodactyl\Models\ServerTransfer;
use Illuminate\Database\ConnectionInterface; use Illuminate\Database\ConnectionInterface;
use Pterodactyl\Http\Controllers\Controller; use Pterodactyl\Http\Controllers\Controller;
use Pterodactyl\Services\Servers\SuspensionService; use Pterodactyl\Services\Nodes\NodeJWTService;
use Pterodactyl\Repositories\Eloquent\NodeRepository;
use Pterodactyl\Repositories\Eloquent\ServerRepository; use Pterodactyl\Repositories\Eloquent\ServerRepository;
use Pterodactyl\Repositories\Wings\DaemonServerRepository; use Pterodactyl\Repositories\Wings\DaemonServerRepository;
use Pterodactyl\Repositories\Wings\DaemonTransferRepository; use Pterodactyl\Repositories\Wings\DaemonTransferRepository;
use Pterodactyl\Contracts\Repository\AllocationRepositoryInterface;
use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException; use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException;
use Pterodactyl\Services\Servers\ServerConfigurationStructureService; use Pterodactyl\Services\Servers\ServerConfigurationStructureService;
@ -33,16 +31,6 @@ class ServerTransferController extends Controller
*/ */
private $repository; private $repository;
/**
* @var \Pterodactyl\Contracts\Repository\AllocationRepositoryInterface
*/
private $allocationRepository;
/**
* @var \Pterodactyl\Repositories\Eloquent\NodeRepository
*/
private $nodeRepository;
/** /**
* @var \Pterodactyl\Repositories\Wings\DaemonServerRepository * @var \Pterodactyl\Repositories\Wings\DaemonServerRepository
*/ */
@ -59,48 +47,34 @@ class ServerTransferController extends Controller
private $configurationStructureService; private $configurationStructureService;
/** /**
* @var \Pterodactyl\Services\Servers\SuspensionService * @var \Pterodactyl\Services\Nodes\NodeJWTService
*/ */
private $suspensionService; private $jwtService;
/**
* @var \Psr\Log\LoggerInterface
*/
private $writer;
/** /**
* ServerTransferController constructor. * ServerTransferController constructor.
* *
* @param \Illuminate\Database\ConnectionInterface $connection * @param \Illuminate\Database\ConnectionInterface $connection
* @param \Pterodactyl\Repositories\Eloquent\ServerRepository $repository * @param \Pterodactyl\Repositories\Eloquent\ServerRepository $repository
* @param \Pterodactyl\Contracts\Repository\AllocationRepositoryInterface $allocationRepository
* @param \Pterodactyl\Repositories\Eloquent\NodeRepository $nodeRepository
* @param \Pterodactyl\Repositories\Wings\DaemonServerRepository $daemonServerRepository * @param \Pterodactyl\Repositories\Wings\DaemonServerRepository $daemonServerRepository
* @param \Pterodactyl\Repositories\Wings\DaemonTransferRepository $daemonTransferRepository * @param \Pterodactyl\Repositories\Wings\DaemonTransferRepository $daemonTransferRepository
* @param \Pterodactyl\Services\Servers\ServerConfigurationStructureService $configurationStructureService * @param \Pterodactyl\Services\Servers\ServerConfigurationStructureService $configurationStructureService
* @param \Pterodactyl\Services\Servers\SuspensionService $suspensionService * @param \Pterodactyl\Services\Nodes\NodeJWTService $jwtService
* @param \Psr\Log\LoggerInterface $writer
*/ */
public function __construct( public function __construct(
ConnectionInterface $connection, ConnectionInterface $connection,
ServerRepository $repository, ServerRepository $repository,
AllocationRepositoryInterface $allocationRepository,
NodeRepository $nodeRepository,
DaemonServerRepository $daemonServerRepository, DaemonServerRepository $daemonServerRepository,
DaemonTransferRepository $daemonTransferRepository, DaemonTransferRepository $daemonTransferRepository,
ServerConfigurationStructureService $configurationStructureService, ServerConfigurationStructureService $configurationStructureService,
SuspensionService $suspensionService, NodeJWTService $jwtService
LoggerInterface $writer
) { ) {
$this->connection = $connection; $this->connection = $connection;
$this->repository = $repository; $this->repository = $repository;
$this->allocationRepository = $allocationRepository;
$this->nodeRepository = $nodeRepository;
$this->daemonServerRepository = $daemonServerRepository; $this->daemonServerRepository = $daemonServerRepository;
$this->daemonTransferRepository = $daemonTransferRepository; $this->daemonTransferRepository = $daemonTransferRepository;
$this->configurationStructureService = $configurationStructureService; $this->configurationStructureService = $configurationStructureService;
$this->suspensionService = $suspensionService; $this->jwtService = $jwtService;
$this->writer = $writer;
} }
/** /**
@ -110,7 +84,6 @@ class ServerTransferController extends Controller
* @param string $uuid * @param string $uuid
* @return \Illuminate\Http\JsonResponse * @return \Illuminate\Http\JsonResponse
* *
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
* @throws \Throwable * @throws \Throwable
*/ */
@ -120,52 +93,50 @@ class ServerTransferController extends Controller
// Unsuspend the server and don't continue the transfer. // Unsuspend the server and don't continue the transfer.
if (! $request->input('successful')) { if (! $request->input('successful')) {
$this->suspensionService->toggle($server, 'unsuspend'); return $this->processFailedTransfer($server->transfer);
return JsonResponse::create([], Response::HTTP_NO_CONTENT);
} }
$server->node_id = $server->transfer->new_node; // We want to generate a new configuration using the new node_id value from the
// transfer, and not the old node value.
$data = $this->configurationStructureService->handle($server); $data = $this->configurationStructureService->handle($server, [
$data['suspended'] = false; 'node_id' => $server->transfer->new_node,
$data['service']['skip_scripts'] = true; ]);
$allocations = $server->getAllocationMappings(); $allocations = $server->getAllocationMappings();
$data['allocations']['default']['ip'] = array_key_first($allocations); $primary = array_key_first($allocations);
$data['allocations']['default']['port'] = $allocations[$data['allocations']['default']['ip']][0]; Arr::set($data, 'allocations.default.ip', $primary);
Arr::set($data, 'allocations.default.port', $allocations[$primary][0]);
Arr::set($data, 'service.skip_scripts', true);
Arr::set($data, 'suspended', false);
$now = Chronos::now(); $this->connection->transaction(function () use ($data, $server) {
$signer = new Sha256; // This token is used by the new node the server is being transfered to. It allows
// that node to communicate with the old node during the process to initiate the
// actual file transfer.
$token = $this->jwtService
->setExpiresAt(Chronos::now()->addMinutes(15))
->setSubject($server->uuid)
->handle($server->node, $server->uuid, 'sha256');
$token = (new Builder)->issuedBy(config('app.url')) // Update the archived field on the transfer to make clients connect to the websocket
->permittedFor($server->node->getConnectionAddress()) // on the new node to be able to receive transfer logs.
->identifiedBy(hash('sha256', $server->uuid), true) $server->transfer->forceFill(['archived' => true])->saveOrFail();
->issuedAt($now->getTimestamp())
->canOnlyBeUsedAfter($now->getTimestamp())
->expiresAt($now->addMinutes(15)->getTimestamp())
->relatedTo($server->uuid, true)
->getToken($signer, new Key($server->node->getDecryptedKey()));
// On the daemon transfer repository, make sure to set the node after the server // On the daemon transfer repository, make sure to set the node after the server
// because setServer() tells the repository to use the server's node and not the one // because setServer() tells the repository to use the server's node and not the one
// we want to specify. // we want to specify.
try {
$this->daemonTransferRepository $this->daemonTransferRepository
->setServer($server) ->setServer($server)
->setNode($this->nodeRepository->find($server->transfer->new_node)) ->setNode($server->transfer->newNode)
->notify($server, $data, $server->node, $token->__toString()); ->notify($server, $data, $server->node, $token->__toString());
} catch (DaemonConnectionException $exception) { });
throw $exception;
}
return JsonResponse::create([], Response::HTTP_NO_CONTENT); return new JsonResponse([], Response::HTTP_NO_CONTENT);
} }
/** /**
* The daemon notifies us about a transfer failure. * The daemon notifies us about a transfer failure.
* *
* @param \Illuminate\Http\Request $request
* @param string $uuid * @param string $uuid
* @return \Illuminate\Http\JsonResponse * @return \Illuminate\Http\JsonResponse
* *
@ -174,18 +145,8 @@ class ServerTransferController extends Controller
public function failure(string $uuid) public function failure(string $uuid)
{ {
$server = $this->repository->getByUuid($uuid); $server = $this->repository->getByUuid($uuid);
$transfer = $server->transfer;
$allocationIds = json_decode($transfer->new_additional_allocations); return $this->processFailedTransfer($server->transfer);
array_push($allocationIds, $transfer->new_allocation);
// Remove the new allocations.
$this->allocationRepository->updateWhereIn('id', $allocationIds, ['server_id' => null]);
// Unsuspend the server.
$this->suspensionService->toggle($server, 'unsuspend');
return JsonResponse::create([], Response::HTTP_NO_CONTENT);
} }
/** /**
@ -201,38 +162,63 @@ class ServerTransferController extends Controller
$server = $this->repository->getByUuid($uuid); $server = $this->repository->getByUuid($uuid);
$transfer = $server->transfer; $transfer = $server->transfer;
$allocationIds = json_decode($transfer->old_additional_allocations); /** @var \Pterodactyl\Models\Server $server */
array_push($allocationIds, $transfer->old_allocation); $server = $this->connection->transaction(function () use ($server, $transfer) {
$allocations = [$transfer->old_allocation];
if (! empty($transfer->old_additional_allocations)) {
array_push($allocations, $transfer->old_additional_allocations);
}
// Begin a transaction. // Remove the old allocations for the server and re-assign the server to the new
$this->connection->beginTransaction(); // primary allocation and node.
Allocation::query()->whereIn('id', $allocations)->update(['server_id' => null]);
$server->update([
'allocation_id' => $transfer->new_allocation,
'node_id' => $transfer->new_node,
]);
// Remove the old allocations. $server = $server->fresh();
$this->allocationRepository->updateWhereIn('id', $allocationIds, ['server_id' => null]); $server->transfer->update(['successful' => true]);
// Update the server's allocation_id and node_id. return $server;
$server->allocation_id = $transfer->new_allocation; });
$server->node_id = $transfer->new_node;
$server->save();
// Mark the transfer as successful. // Delete the server from the old node making sure to point it to the old node so
$transfer->successful = true; // that we do not delete it from the new node the server was transfered to.
$transfer->save();
// Commit the transaction.
$this->connection->commit();
// Delete the server from the old node
try { try {
$this->daemonServerRepository->setServer($server)->delete(); $this->daemonServerRepository
->setServer($server)
->setNode($transfer->oldNode)
->delete();
} catch (DaemonConnectionException $exception) { } catch (DaemonConnectionException $exception) {
$this->writer->warning($exception); Log::warning($exception, ['transfer_id' => $server->transfer->id]);
} }
// Unsuspend the server return new JsonResponse([], Response::HTTP_NO_CONTENT);
$server->load('node'); }
$this->suspensionService->toggle($server, $this->suspensionService::ACTION_UNSUSPEND);
return JsonResponse::create([], Response::HTTP_NO_CONTENT); /**
* Release all of the reserved allocations for this transfer and mark it as failed in
* the database.
*
* @param \Pterodactyl\Models\ServerTransfer $transfer
* @return \Illuminate\Http\JsonResponse
*
* @throws \Throwable
*/
protected function processFailedTransfer(ServerTransfer $transfer)
{
$this->connection->transaction(function () use (&$transfer) {
$transfer->forceFill(['successful' => false])->saveOrFail();
$allocations = [$transfer->new_allocation];
if (! empty($transfer->new_additional_allocations)) {
array_push($allocations, $transfer->new_additional_allocations);
}
Allocation::query()->whereIn('id', $allocations)->update(['server_id' => null]);
});
return new JsonResponse([], Response::HTTP_NO_CONTENT);
} }
} }

View file

@ -12,6 +12,7 @@ use Pterodactyl\Exceptions\Http\HttpForbiddenException;
use Pterodactyl\Repositories\Eloquent\ServerRepository; use Pterodactyl\Repositories\Eloquent\ServerRepository;
use Pterodactyl\Services\Servers\GetUserPermissionsService; use Pterodactyl\Services\Servers\GetUserPermissionsService;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Pterodactyl\Exceptions\Http\Server\ServerTransferringException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Pterodactyl\Http\Requests\Api\Remote\SftpAuthenticationFormRequest; use Pterodactyl\Http\Requests\Api\Remote\SftpAuthenticationFormRequest;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException; use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
@ -110,7 +111,12 @@ class SftpAuthenticationController extends Controller
} }
} }
// Remeber, for security purposes, only reveal the existence of the server to people that // Prevent SFTP access to servers that are being transferred.
if (! is_null($server->transfer)) {
throw new ServerTransferringException;
}
// Remember, for security purposes, only reveal the existence of the server to people that
// have provided valid credentials, and have permissions to know about it. // have provided valid credentials, and have permissions to know about it.
if ($server->installed !== 1 || $server->suspended) { if ($server->installed !== 1 || $server->suspended) {
throw new BadRequestHttpException( throw new BadRequestHttpException(
@ -118,7 +124,7 @@ class SftpAuthenticationController extends Controller
); );
} }
return JsonResponse::create([ return new JsonResponse([
'server' => $server->uuid, 'server' => $server->uuid,
// Deprecated, but still needed at the moment for Wings. // Deprecated, but still needed at the moment for Wings.
'token' => '', 'token' => '',
@ -132,7 +138,7 @@ class SftpAuthenticationController extends Controller
* @param \Illuminate\Http\Request $request * @param \Illuminate\Http\Request $request
* @return string * @return string
*/ */
protected function throttleKey(Request $request) protected function throttleKey(Request $request): string
{ {
$username = explode('.', strrev($request->input('username', ''))); $username = explode('.', strrev($request->input('username', '')));

View file

@ -9,7 +9,7 @@ use Pterodactyl\Contracts\Repository\ServerRepositoryInterface;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException; use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Pterodactyl\Exceptions\Http\Server\ServerTransferringException;
class AuthenticateServerAccess class AuthenticateServerAccess
{ {
@ -24,7 +24,6 @@ class AuthenticateServerAccess
* @var string[] * @var string[]
*/ */
protected $except = [ protected $except = [
'api:client:server.view',
'api:client:server.ws', 'api:client:server.ws',
]; ];
@ -71,6 +70,8 @@ class AuthenticateServerAccess
); );
} }
// Still allow users to get information about their server if it is installing or being transferred.
if (! $request->routeIs('api:client:server.view')) {
if (! $server->isInstalled()) { if (! $server->isInstalled()) {
// Throw an exception for all server routes; however if the user is an admin and requesting the // Throw an exception for all server routes; however if the user is an admin and requesting the
// server details, don't throw the exception for them. // server details, don't throw the exception for them.
@ -79,6 +80,13 @@ class AuthenticateServerAccess
} }
} }
if (! is_null($server->transfer)) {
if (! $user->root_admin || ($user->root_admin && ! $request->routeIs($this->except))) {
throw new ServerTransferringException;
}
}
}
$request->attributes->set('server', $server); $request->attributes->set('server', $server);
return $next($request); return $next($request);

View file

@ -9,6 +9,7 @@ use Illuminate\Contracts\Routing\ResponseFactory;
use Illuminate\Contracts\Config\Repository as ConfigRepository; use Illuminate\Contracts\Config\Repository as ConfigRepository;
use Pterodactyl\Contracts\Repository\ServerRepositoryInterface; use Pterodactyl\Contracts\Repository\ServerRepositoryInterface;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException; use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Pterodactyl\Exceptions\Http\Server\ServerTransferringException;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
class AccessingValidServer class AccessingValidServer
@ -80,6 +81,14 @@ class AccessingValidServer
return $this->response->view('errors.installing', [], 409); return $this->response->view('errors.installing', [], 409);
} }
if (! is_null($server->transfer)) {
if ($isApiRequest) {
throw new ServerTransferringException;
}
return $this->response->view('errors.transferring', [], 409);
}
// Add server to the request attributes. This will replace sessions // Add server to the request attributes. This will replace sessions
// as files are updated. // as files are updated.
$request->attributes->set('server', $server); $request->attributes->set('server', $server);

View file

@ -306,7 +306,7 @@ class Server extends Model
*/ */
public function transfer() public function transfer()
{ {
return $this->hasOne(ServerTransfer::class)->orderByDesc('id'); return $this->hasOne(ServerTransfer::class)->whereNull('successful')->orderByDesc('id');
} }
/** /**

View file

@ -9,13 +9,16 @@ namespace Pterodactyl\Models;
* @property int $new_node * @property int $new_node
* @property int $old_allocation * @property int $old_allocation
* @property int $new_allocation * @property int $new_allocation
* @property string $old_additional_allocations * @property array|null $old_additional_allocations
* @property string $new_additional_allocations * @property array|null $new_additional_allocations
* @property bool $successful * @property bool|null $successful
* @property bool $archived
* @property \Carbon\Carbon $created_at * @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at * @property \Carbon\Carbon $updated_at
* *
* @property \Pterodactyl\Models\Server $server * @property \Pterodactyl\Models\Server $server
* @property \Pterodactyl\Models\Node $oldNode
* @property \Pterodactyl\Models\Node $newNode
*/ */
class ServerTransfer extends Model class ServerTransfer extends Model
{ {
@ -50,9 +53,10 @@ class ServerTransfer extends Model
'new_node' => 'int', 'new_node' => 'int',
'old_allocation' => 'int', 'old_allocation' => 'int',
'new_allocation' => 'int', 'new_allocation' => 'int',
'old_additional_allocations' => 'string', 'old_additional_allocations' => 'array',
'new_additional_allocations' => 'string', 'new_additional_allocations' => 'array',
'successful' => 'bool', 'successful' => 'bool',
'archived' => 'bool',
]; ];
/** /**
@ -64,9 +68,11 @@ class ServerTransfer extends Model
'new_node' => 'required|numeric', 'new_node' => 'required|numeric',
'old_allocation' => 'required|numeric', 'old_allocation' => 'required|numeric',
'new_allocation' => 'required|numeric', 'new_allocation' => 'required|numeric',
'old_additional_allocations' => 'nullable', 'old_additional_allocations' => 'nullable|array',
'new_additional_allocations' => 'nullable', 'old_additional_allocations.*' => 'numeric',
'successful' => 'sometimes|boolean', 'new_additional_allocations' => 'nullable|array',
'new_additional_allocations.*' => 'numeric',
'successful' => 'sometimes|nullable|boolean',
]; ];
/** /**
@ -78,4 +84,24 @@ class ServerTransfer extends Model
{ {
return $this->belongsTo(Server::class); return $this->belongsTo(Server::class);
} }
/**
* Gets the source node associated with a server transfer.
*
* @return \Illuminate\Database\Eloquent\Relations\HasOne
*/
public function oldNode()
{
return $this->hasOne(Node::class, 'id', 'old_node');
}
/**
* Gets the target node associated with a server transfer.
*
* @return \Illuminate\Database\Eloquent\Relations\HasOne
*/
public function newNode()
{
return $this->hasOne(Node::class, 'id', 'new_node');
}
} }

View file

@ -102,7 +102,7 @@ class EggConfigurationService
{ {
// Get the legacy configuration structure for the server so that we // Get the legacy configuration structure for the server so that we
// can property map the egg placeholders to values. // can property map the egg placeholders to values.
$structure = $this->configurationStructureService->handle($server, true); $structure = $this->configurationStructureService->handle($server, [], true);
$response = []; $response = [];
// Normalize the output of the configuration for the new Wings Daemon to more // Normalize the output of the configuration for the new Wings Daemon to more

View file

@ -22,6 +22,11 @@ class NodeJWTService
*/ */
private $expiresAt; private $expiresAt;
/**
* @var string|null
*/
private $subject;
/** /**
* Set the claims to include in this JWT. * Set the claims to include in this JWT.
* *
@ -35,6 +40,10 @@ class NodeJWTService
return $this; return $this;
} }
/**
* @param \DateTimeInterface $date
* @return $this
*/
public function setExpiresAt(DateTimeInterface $date) public function setExpiresAt(DateTimeInterface $date)
{ {
$this->expiresAt = $date->getTimestamp(); $this->expiresAt = $date->getTimestamp();
@ -42,20 +51,32 @@ class NodeJWTService
return $this; return $this;
} }
/**
* @param string $subject
* @return $this
*/
public function setSubject(string $subject)
{
$this->subject = $subject;
return $this;
}
/** /**
* Generate a new JWT for a given node. * Generate a new JWT for a given node.
* *
* @param \Pterodactyl\Models\Node $node * @param \Pterodactyl\Models\Node $node
* @param string|null $identifiedBy * @param string|null $identifiedBy
* @param string $algo
* @return \Lcobucci\JWT\Token * @return \Lcobucci\JWT\Token
*/ */
public function handle(Node $node, string $identifiedBy) public function handle(Node $node, string $identifiedBy, string $algo = 'md5')
{ {
$signer = new Sha256; $signer = new Sha256;
$builder = (new Builder)->issuedBy(config('app.url')) $builder = (new Builder)->issuedBy(config('app.url'))
->permittedFor($node->getConnectionAddress()) ->permittedFor($node->getConnectionAddress())
->identifiedBy(md5($identifiedBy), true) ->identifiedBy(hash($algo, $identifiedBy), true)
->issuedAt(CarbonImmutable::now()->getTimestamp()) ->issuedAt(CarbonImmutable::now()->getTimestamp())
->canOnlyBeUsedAfter(CarbonImmutable::now()->subMinutes(5)->getTimestamp()); ->canOnlyBeUsedAfter(CarbonImmutable::now()->subMinutes(5)->getTimestamp());
@ -63,6 +84,10 @@ class NodeJWTService
$builder = $builder->expiresAt($this->expiresAt); $builder = $builder->expiresAt($this->expiresAt);
} }
if (!empty($this->subject)) {
$builder = $builder->relatedTo($this->subject, true);
}
foreach ($this->claims as $key => $value) { foreach ($this->claims as $key => $value) {
$builder = $builder->withClaim($key, $value); $builder = $builder->withClaim($key, $value);
} }

View file

@ -24,6 +24,7 @@ class GetUserPermissionsService
if ($user->root_admin) { if ($user->root_admin) {
$permissions[] = 'admin.websocket.errors'; $permissions[] = 'admin.websocket.errors';
$permissions[] = 'admin.websocket.install'; $permissions[] = 'admin.websocket.install';
$permissions[] = 'admin.websocket.transfer';
} }
return $permissions; return $permissions;

View file

@ -29,14 +29,25 @@ class ServerConfigurationStructureService
* daemon, if you modify the structure eggs will break unexpectedly. * daemon, if you modify the structure eggs will break unexpectedly.
* *
* @param \Pterodactyl\Models\Server $server * @param \Pterodactyl\Models\Server $server
* @param array $override
* @param bool $legacy deprecated * @param bool $legacy deprecated
* @return array * @return array
*/ */
public function handle(Server $server, bool $legacy = false): array public function handle(Server $server, array $override = [], bool $legacy = false): array
{ {
return $legacy ? $clone = $server;
$this->returnLegacyFormat($server) // If any overrides have been set on this call make sure to update them on the
: $this->returnCurrentFormat($server); // cloned instance so that the configuration generated uses them.
if (!empty($override)) {
$clone = $server->fresh();
foreach ($override as $key => $value) {
$clone->setAttribute($key, $value);
}
}
return $legacy
? $this->returnLegacyFormat($clone)
: $this->returnCurrentFormat($clone);
} }
/** /**

View file

@ -6,6 +6,8 @@ use Webmozart\Assert\Assert;
use Pterodactyl\Models\Server; use Pterodactyl\Models\Server;
use Illuminate\Database\ConnectionInterface; use Illuminate\Database\ConnectionInterface;
use Pterodactyl\Repositories\Wings\DaemonServerRepository; use Pterodactyl\Repositories\Wings\DaemonServerRepository;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Pterodactyl\Exceptions\Http\Server\ServerTransferringException;
class SuspensionService class SuspensionService
{ {
@ -56,12 +58,20 @@ class SuspensionService
return; return;
} }
// Check if the server is currently being transferred.
if (! is_null($server->transfer)) {
throw new ServerTransferringException;
}
$this->connection->transaction(function () use ($action, $server) { $this->connection->transaction(function () use ($action, $server) {
$server->update([ $server->update([
'suspended' => $action === self::ACTION_SUSPEND, 'suspended' => $action === self::ACTION_SUSPEND,
]); ]);
// Only send the suspension request to wings if the server is not currently being transferred.
if (is_null($server->transfer)) {
$this->daemonServerRepository->setServer($server)->suspend($action === self::ACTION_UNSUSPEND); $this->daemonServerRepository->setServer($server)->suspend($action === self::ACTION_UNSUSPEND);
}
}); });
} }
} }

View file

@ -28,6 +28,7 @@ class ServerTransformer extends BaseTransformer
'location', 'location',
'node', 'node',
'databases', 'databases',
'transfer',
]; ];
/** /**
@ -55,8 +56,6 @@ class ServerTransformer extends BaseTransformer
* *
* @param \Pterodactyl\Models\Server $server * @param \Pterodactyl\Models\Server $server
* @return array * @return array
*
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
*/ */
public function transform(Server $server): array public function transform(Server $server): array
{ {

View file

@ -72,6 +72,7 @@ class ServerTransformer extends BaseClientTransformer
], ],
'is_suspended' => $server->suspended, 'is_suspended' => $server->suspended,
'is_installing' => $server->installed !== 1, 'is_installing' => $server->installed !== 1,
'is_transferring' => ! is_null($server->transfer),
]; ];
} }

View file

@ -0,0 +1,32 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class MakeSuccessfulNullableInServerTransfers extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('server_transfers', function (Blueprint $table) {
$table->boolean('successful')->nullable()->default(null)->change();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('server_transfers', function (Blueprint $table) {
$table->boolean('successful')->default(0)->change();
});
}
}

View file

@ -0,0 +1,38 @@
<?php
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class AddArchivedFieldToServerTransfersTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('server_transfers', function (Blueprint $table) {
$table->boolean('archived')->default(0)->after('new_additional_allocations');
});
// Update archived to all be true on existing transfers.
Schema::table('server_transfers', function (Blueprint $table) {
DB::statement('UPDATE `server_transfers` SET `archived` = 1 WHERE `successful` = 1');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('server_transfers', function (Blueprint $table) {
$table->dropColumn('archived');
});
}
}

View file

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class MakeAllocationFieldsJson extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('server_transfers', function (Blueprint $table) {
$table->json('old_additional_allocations')->nullable()->change();
$table->json('new_additional_allocations')->nullable()->change();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('server_transfers', function (Blueprint $table) {
$table->string('old_additional_allocations')->nullable()->change();
$table->string('new_additional_allocations')->nullable()->change();
});
}
}

View file

@ -40,6 +40,7 @@ export interface Server {
}; };
isSuspended: boolean; isSuspended: boolean;
isInstalling: boolean; isInstalling: boolean;
isTransferring: boolean;
variables: ServerEggVariable[]; variables: ServerEggVariable[];
allocations: Allocation[]; allocations: Allocation[];
} }
@ -62,6 +63,7 @@ export const rawDataToServerObject = ({ attributes: data }: FractalResponseData)
featureLimits: { ...data.feature_limits }, featureLimits: { ...data.feature_limits },
isSuspended: data.is_suspended, isSuspended: data.is_suspended,
isInstalling: data.is_installing, isInstalling: data.is_installing,
isTransferring: data.is_transferring,
variables: ((data.relationships?.variables as FractalResponseList | undefined)?.data || []).map(rawDataToServerEggVariable), variables: ((data.relationships?.variables as FractalResponseList | undefined)?.data || []).map(rawDataToServerEggVariable),
allocations: ((data.relationships?.allocations as FractalResponseList | undefined)?.data || []).map(rawDataToServerAllocation), allocations: ((data.relationships?.allocations as FractalResponseList | undefined)?.data || []).map(rawDataToServerAllocation),
}); });

View file

@ -74,8 +74,8 @@ export default ({ server, className }: { server: Server; className?: string }) =
alarms.disk = server.limits.disk === 0 ? false : isAlarmState(stats.diskUsageInBytes, server.limits.disk); alarms.disk = server.limits.disk === 0 ? false : isAlarmState(stats.diskUsageInBytes, server.limits.disk);
} }
const disklimit = server.limits.disk !== 0 ? megabytesToHuman(server.limits.disk) : 'Unlimited'; const diskLimit = server.limits.disk !== 0 ? megabytesToHuman(server.limits.disk) : 'Unlimited';
const memorylimit = server.limits.memory !== 0 ? megabytesToHuman(server.limits.memory) : 'Unlimited'; const memoryLimit = server.limits.memory !== 0 ? megabytesToHuman(server.limits.memory) : 'Unlimited';
return ( return (
<StatusIndicatorBox as={Link} to={`/server/${server.id}`} className={className} $status={stats?.status}> <StatusIndicatorBox as={Link} to={`/server/${server.id}`} className={className} $status={stats?.status}>
@ -118,6 +118,13 @@ export default ({ server, className }: { server: Server; className?: string }) =
</span> </span>
</div> </div>
: :
server.isTransferring ?
<div css={tw`flex-1 text-center`}>
<span css={tw`bg-neutral-500 rounded px-2 py-1 text-neutral-100 text-xs`}>
Transferring
</span>
</div>
:
<Spinner size={'small'}/> <Spinner size={'small'}/>
: :
<React.Fragment> <React.Fragment>
@ -134,7 +141,7 @@ export default ({ server, className }: { server: Server; className?: string }) =
{bytesToHuman(stats.memoryUsageInBytes)} {bytesToHuman(stats.memoryUsageInBytes)}
</IconDescription> </IconDescription>
</div> </div>
<p css={tw`text-xs text-neutral-600 text-center mt-1`}>of {memorylimit}</p> <p css={tw`text-xs text-neutral-600 text-center mt-1`}>of {memoryLimit}</p>
</div> </div>
<div css={tw`flex-1 ml-4 sm:block hidden`}> <div css={tw`flex-1 ml-4 sm:block hidden`}>
<div css={tw`flex justify-center`}> <div css={tw`flex justify-center`}>
@ -143,7 +150,7 @@ export default ({ server, className }: { server: Server; className?: string }) =
{bytesToHuman(stats.diskUsageInBytes)} {bytesToHuman(stats.diskUsageInBytes)}
</IconDescription> </IconDescription>
</div> </div>
<p css={tw`text-xs text-neutral-600 text-center mt-1`}>of {disklimit}</p> <p css={tw`text-xs text-neutral-600 text-center mt-1`}>of {diskLimit}</p>
</div> </div>
</React.Fragment> </React.Fragment>
} }

View file

@ -59,7 +59,7 @@ export default () => {
}, [ progress, continuous ]); }, [ progress, continuous ]);
return ( return (
<div className={'w-full fixed'} style={{ height: '2px' }}> <div css={tw`w-full fixed`} style={{ height: '2px' }}>
<CSSTransition <CSSTransition
timeout={150} timeout={150}
appear appear

View file

@ -67,6 +67,7 @@ export default () => {
const { connected, instance } = ServerContext.useStoreState(state => state.socket); const { connected, instance } = ServerContext.useStoreState(state => state.socket);
const [ canSendCommands ] = usePermissions([ 'control.console' ]); const [ canSendCommands ] = usePermissions([ 'control.console' ]);
const serverId = ServerContext.useStoreState(state => state.server.data!.id); const serverId = ServerContext.useStoreState(state => state.server.data!.id);
const isTransferring = ServerContext.useStoreState(state => state.server.data!.isTransferring);
const [ history, setHistory ] = usePersistedState<string[]>(`${serverId}:command_history`, []); const [ history, setHistory ] = usePersistedState<string[]>(`${serverId}:command_history`, []);
const [ historyIndex, setHistoryIndex ] = useState(-1); const [ historyIndex, setHistoryIndex ] = useState(-1);
@ -74,6 +75,19 @@ export default () => {
(prelude ? TERMINAL_PRELUDE : '') + line.replace(/(?:\r\n|\r|\n)$/im, '') + '\u001b[0m', (prelude ? TERMINAL_PRELUDE : '') + line.replace(/(?:\r\n|\r|\n)$/im, '') + '\u001b[0m',
); );
const handleTransferStatus = (status: string) => {
switch (status) {
// Sent by either the source or target node if a failure occurs.
case 'failure':
terminal.writeln(TERMINAL_PRELUDE + 'Transfer has failed.\u001b[0m');
return;
// Sent by the source node whenever the server was archived successfully.
case 'archive':
terminal.writeln(TERMINAL_PRELUDE + 'Server has been archived successfully, attempting connection to target node..\u001b[0m');
}
};
const handleDaemonErrorOutput = (line: string) => terminal.writeln( const handleDaemonErrorOutput = (line: string) => terminal.writeln(
TERMINAL_PRELUDE + '\u001b[1m\u001b[41m' + line.replace(/(?:\r\n|\r|\n)$/im, '') + '\u001b[0m', TERMINAL_PRELUDE + '\u001b[1m\u001b[41m' + line.replace(/(?:\r\n|\r|\n)$/im, '') + '\u001b[0m',
); );
@ -128,14 +142,17 @@ export default () => {
return false; return false;
} }
// Ctrl + F (Find)
if (e.ctrlKey && e.key === 'f') { if (e.ctrlKey && e.key === 'f') {
searchBar.show(); searchBar.show();
return false; return false;
} }
// Escape
if (e.key === 'Escape') { if (e.key === 'Escape') {
searchBar.hidden(); searchBar.hidden();
} }
return true; return true;
}); });
} }
@ -149,22 +166,29 @@ export default () => {
useEffect(() => { useEffect(() => {
if (connected && instance) { if (connected && instance) {
// Do not clear the console if the server is being transferred.
if (!isTransferring) {
terminal.clear(); terminal.clear();
}
instance.addListener('status', handlePowerChangeEvent); instance.addListener('status', handlePowerChangeEvent);
instance.addListener('console output', handleConsoleOutput); instance.addListener('console output', handleConsoleOutput);
instance.addListener('install output', handleConsoleOutput); instance.addListener('install output', handleConsoleOutput);
instance.addListener('transfer logs', handleConsoleOutput);
instance.addListener('transfer status', handleTransferStatus);
instance.addListener('daemon message', line => handleConsoleOutput(line, true)); instance.addListener('daemon message', line => handleConsoleOutput(line, true));
instance.addListener('daemon error', handleDaemonErrorOutput); instance.addListener('daemon error', handleDaemonErrorOutput);
instance.send('send logs'); instance.send('send logs');
} }
return () => { return () => {
instance && instance.removeListener('console output', handleConsoleOutput) instance && instance.removeListener('status', handlePowerChangeEvent)
.removeListener('console output', handleConsoleOutput)
.removeListener('install output', handleConsoleOutput) .removeListener('install output', handleConsoleOutput)
.removeListener('transfer logs', handleConsoleOutput)
.removeListener('transfer status', handleTransferStatus)
.removeListener('daemon message', line => handleConsoleOutput(line, true)) .removeListener('daemon message', line => handleConsoleOutput(line, true))
.removeListener('daemon error', handleDaemonErrorOutput) .removeListener('daemon error', handleDaemonErrorOutput);
.removeListener('status', handlePowerChangeEvent);
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [ connected, instance ]); }, [ connected, instance ]);

View file

@ -17,6 +17,7 @@ const ChunkedStatGraphs = lazy(() => import(/* webpackChunkName: "graphs" */'@/c
const ServerConsole = () => { const ServerConsole = () => {
const isInstalling = ServerContext.useStoreState(state => state.server.data!.isInstalling); const isInstalling = ServerContext.useStoreState(state => state.server.data!.isInstalling);
const isTransferring = ServerContext.useStoreState(state => state.server.data!.isTransferring);
// @ts-ignore // @ts-ignore
const eggFeatures: string[] = ServerContext.useStoreState(state => state.server.data!.eggFeatures, isEqual); const eggFeatures: string[] = ServerContext.useStoreState(state => state.server.data!.eggFeatures, isEqual);
@ -24,11 +25,7 @@ const ServerConsole = () => {
<ServerContentBlock title={'Console'} css={tw`flex flex-wrap`}> <ServerContentBlock title={'Console'} css={tw`flex flex-wrap`}>
<div css={tw`w-full lg:w-1/4`}> <div css={tw`w-full lg:w-1/4`}>
<ServerDetailsBlock/> <ServerDetailsBlock/>
{!isInstalling ? {isInstalling ?
<Can action={[ 'control.start', 'control.stop', 'control.restart' ]} matchAny>
<PowerControls/>
</Can>
:
<div css={tw`mt-4 rounded bg-yellow-500 p-3`}> <div css={tw`mt-4 rounded bg-yellow-500 p-3`}>
<ContentContainer> <ContentContainer>
<p css={tw`text-sm text-yellow-900`}> <p css={tw`text-sm text-yellow-900`}>
@ -37,6 +34,20 @@ const ServerConsole = () => {
</p> </p>
</ContentContainer> </ContentContainer>
</div> </div>
:
isTransferring ?
<div css={tw`mt-4 rounded bg-yellow-500 p-3`}>
<ContentContainer>
<p css={tw`text-sm text-yellow-900`}>
This server is currently being transferred to another node and all actions
are unavailable.
</p>
</ContentContainer>
</div>
:
<Can action={[ 'control.start', 'control.stop', 'control.restart' ]} matchAny>
<PowerControls/>
</Can>
} }
</div> </div>
<div css={tw`w-full lg:w-3/4 mt-4 lg:mt-0 lg:pl-4`}> <div css={tw`w-full lg:w-3/4 mt-4 lg:mt-0 lg:pl-4`}>

View file

@ -65,13 +65,14 @@ const ServerDetailsBlock = () => {
const name = ServerContext.useStoreState(state => state.server.data!.name); const name = ServerContext.useStoreState(state => state.server.data!.name);
const isInstalling = ServerContext.useStoreState(state => state.server.data!.isInstalling); const isInstalling = ServerContext.useStoreState(state => state.server.data!.isInstalling);
const isTransferring = ServerContext.useStoreState(state => state.server.data!.isTransferring);
const limits = ServerContext.useStoreState(state => state.server.data!.limits); const limits = ServerContext.useStoreState(state => state.server.data!.limits);
const primaryAllocation = ServerContext.useStoreState(state => state.server.data!.allocations.filter(alloc => alloc.isDefault).map( const primaryAllocation = ServerContext.useStoreState(state => state.server.data!.allocations.filter(alloc => alloc.isDefault).map(
allocation => (allocation.alias || allocation.ip) + ':' + allocation.port allocation => (allocation.alias || allocation.ip) + ':' + allocation.port
)).toString(); )).toString();
const disklimit = limits.disk ? megabytesToHuman(limits.disk) : 'Unlimited'; const diskLimit = limits.disk ? megabytesToHuman(limits.disk) : 'Unlimited';
const memorylimit = limits.memory ? megabytesToHuman(limits.memory) : 'Unlimited'; const memoryLimit = limits.memory ? megabytesToHuman(limits.memory) : 'Unlimited';
return ( return (
<TitledGreyBox css={tw`break-words`} title={name} icon={faServer}> <TitledGreyBox css={tw`break-words`} title={name} icon={faServer}>
@ -81,10 +82,10 @@ const ServerDetailsBlock = () => {
fixedWidth fixedWidth
css={[ css={[
tw`mr-1`, tw`mr-1`,
statusToColor(status, isInstalling), statusToColor(status, isInstalling || isTransferring),
]} ]}
/> />
&nbsp;{!status ? 'Connecting...' : (isInstalling ? 'Installing' : status)} &nbsp;{!status ? 'Connecting...' : (isInstalling ? 'Installing' : (isTransferring) ? 'Transferring' : status)}
</p> </p>
<CopyOnClick text={primaryAllocation}> <CopyOnClick text={primaryAllocation}>
<p css={tw`text-xs mt-2`}> <p css={tw`text-xs mt-2`}>
@ -97,11 +98,11 @@ const ServerDetailsBlock = () => {
</p> </p>
<p css={tw`text-xs mt-2`}> <p css={tw`text-xs mt-2`}>
<FontAwesomeIcon icon={faMemory} fixedWidth css={tw`mr-1`}/> {bytesToHuman(stats.memory)} <FontAwesomeIcon icon={faMemory} fixedWidth css={tw`mr-1`}/> {bytesToHuman(stats.memory)}
<span css={tw`text-neutral-500`}> / {memorylimit}</span> <span css={tw`text-neutral-500`}> / {memoryLimit}</span>
</p> </p>
<p css={tw`text-xs mt-2`}> <p css={tw`text-xs mt-2`}>
<FontAwesomeIcon icon={faHdd} fixedWidth css={tw`mr-1`}/>&nbsp;{bytesToHuman(stats.disk)} <FontAwesomeIcon icon={faHdd} fixedWidth css={tw`mr-1`}/>&nbsp;{bytesToHuman(stats.disk)}
<span css={tw`text-neutral-500`}> / {disklimit}</span> <span css={tw`text-neutral-500`}> / {diskLimit}</span>
</p> </p>
</TitledGreyBox> </TitledGreyBox>
); );

View file

@ -0,0 +1,32 @@
import useWebsocketEvent from '@/plugins/useWebsocketEvent';
import { ServerContext } from '@/state/server';
const TransferListener = () => {
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const getServer = ServerContext.useStoreActions(actions => actions.server.getServer);
const setServerFromState = ServerContext.useStoreActions(actions => actions.server.setServerFromState);
// Listen for the transfer status event so we can update the state of the server.
useWebsocketEvent('transfer status', (status: string) => {
if (status === 'starting') {
setServerFromState(s => ({ ...s, isTransferring: true }));
return;
}
if (status === 'failure') {
setServerFromState(s => ({ ...s, isTransferring: false }));
return;
}
if (status !== 'success') {
return;
}
// Refresh the server's information as it's node and allocations were just updated.
getServer(uuid).catch(error => console.error(error));
});
return null;
};
export default TransferListener;

View file

@ -20,6 +20,59 @@ export default () => {
const setServerStatus = ServerContext.useStoreActions(actions => actions.status.setServerStatus); const setServerStatus = ServerContext.useStoreActions(actions => actions.status.setServerStatus);
const { setInstance, setConnectionState } = ServerContext.useStoreActions(actions => actions.socket); const { setInstance, setConnectionState } = ServerContext.useStoreActions(actions => actions.socket);
const connect = (uuid: string) => {
const socket = new Websocket();
socket.on('auth success', () => setConnectionState(true));
socket.on('SOCKET_CLOSE', () => setConnectionState(false));
socket.on('SOCKET_ERROR', () => {
setError('connecting');
setConnectionState(false);
});
socket.on('status', (status) => setServerStatus(status));
socket.on('daemon error', message => {
console.warn('Got error message from daemon socket:', message);
});
socket.on('token expiring', () => updateToken(uuid, socket));
socket.on('token expired', () => updateToken(uuid, socket));
socket.on('jwt error', (error: string) => {
setConnectionState(false);
console.warn('JWT validation error from wings:', error);
if (reconnectErrors.find(v => error.toLowerCase().indexOf(v) >= 0)) {
updateToken(uuid, socket);
} else {
setError('There was an error validating the credentials provided for the websocket. Please refresh the page.');
}
});
socket.on('transfer status', (status: string) => {
if (status === 'starting' || status === 'success') {
return;
}
// This code forces a reconnection to the websocket which will connect us to the target node instead of the source node
// in order to be able to receive transfer logs from the target node.
socket.close();
setError('connecting');
setConnectionState(false);
setInstance(null);
connect(uuid);
});
getWebsocketToken(uuid)
.then(data => {
// Connect and then set the authentication token.
socket.setToken(data.token).connect(data.socket);
// Once that is done, set the instance.
setInstance(socket);
})
.catch(error => console.error(error));
};
const updateToken = (uuid: string, socket: Websocket) => { const updateToken = (uuid: string, socket: Websocket) => {
if (updatingToken) return; if (updatingToken) return;
@ -49,42 +102,7 @@ export default () => {
return; return;
} }
const socket = new Websocket(); connect(uuid);
socket.on('auth success', () => setConnectionState(true));
socket.on('SOCKET_CLOSE', () => setConnectionState(false));
socket.on('SOCKET_ERROR', () => {
setError('connecting');
setConnectionState(false);
});
socket.on('status', (status) => setServerStatus(status));
socket.on('daemon error', message => {
console.warn('Got error message from daemon socket:', message);
});
socket.on('token expiring', () => updateToken(uuid, socket));
socket.on('token expired', () => updateToken(uuid, socket));
socket.on('jwt error', (error: string) => {
setConnectionState(false);
console.warn('JWT validation error from wings:', error);
if (reconnectErrors.find(v => error.toLowerCase().indexOf(v) >= 0)) {
updateToken(uuid, socket);
} else {
setError('There was an error validating the credentials provided for the websocket. Please refresh the page.');
}
});
getWebsocketToken(uuid)
.then(data => {
// Connect and then set the authentication token.
socket.setToken(data.token).connect(data.socket);
// Once that is done, set the instance.
setInstance(socket);
})
.catch(error => console.error(error));
}, [ uuid ]); }, [ uuid ]);
return ( return (

View file

@ -42,7 +42,6 @@ export default () => {
setVisible(false); setVisible(false);
}) })
.catch(error => { .catch(error => {
console.log(error);
addError({ key: 'database:create', message: httpErrorToHuman(error) }); addError({ key: 'database:create', message: httpErrorToHuman(error) });
setSubmitting(false); setSubmitting(false);
}); });

View file

@ -65,7 +65,7 @@ export default () => {
<FileActionCheckbox <FileActionCheckbox
type={'checkbox'} type={'checkbox'}
css={tw`mx-4`} css={tw`mx-4`}
checked={selectedFilesLength === (files ? files.length : -1)} checked={selectedFilesLength === (files?.length === 0 ? -1 : files?.length)}
onChange={onSelectAllClick} onChange={onSelectAllClick}
/> />
} }

View file

@ -1,3 +1,4 @@
import TransferListener from '@/components/server/TransferListener';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { NavLink, Route, RouteComponentProps, Switch } from 'react-router-dom'; import { NavLink, Route, RouteComponentProps, Switch } from 'react-router-dom';
import NavigationBar from '@/components/NavigationBar'; import NavigationBar from '@/components/NavigationBar';
@ -35,10 +36,12 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>)
const rootAdmin = useStoreState(state => state.user.data!.rootAdmin); const rootAdmin = useStoreState(state => state.user.data!.rootAdmin);
const [ error, setError ] = useState(''); const [ error, setError ] = useState('');
const [ installing, setInstalling ] = useState(false); const [ installing, setInstalling ] = useState(false);
const [ transferring, setTransferring ] = useState(false);
const id = ServerContext.useStoreState(state => state.server.data?.id); const id = ServerContext.useStoreState(state => state.server.data?.id);
const uuid = ServerContext.useStoreState(state => state.server.data?.uuid); const uuid = ServerContext.useStoreState(state => state.server.data?.uuid);
const isInstalling = ServerContext.useStoreState(state => state.server.data?.isInstalling); const isInstalling = ServerContext.useStoreState(state => state.server.data?.isInstalling);
const isTransferring = ServerContext.useStoreState(state => state.server.data?.isTransferring);
const serverId = ServerContext.useStoreState(state => state.server.data?.internalId); const serverId = ServerContext.useStoreState(state => state.server.data?.internalId);
const getServer = ServerContext.useStoreActions(actions => actions.server.getServer); const getServer = ServerContext.useStoreActions(actions => actions.server.getServer);
const clearServerState = ServerContext.useStoreActions(actions => actions.clearServerState); const clearServerState = ServerContext.useStoreActions(actions => actions.clearServerState);
@ -51,13 +54,23 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>)
setInstalling(!!isInstalling); setInstalling(!!isInstalling);
}, [ isInstalling ]); }, [ isInstalling ]);
useEffect(() => {
setTransferring(!!isTransferring);
}, [ isTransferring ]);
useEffect(() => { useEffect(() => {
setError(''); setError('');
setInstalling(false); setInstalling(false);
setTransferring(false);
getServer(match.params.id) getServer(match.params.id)
.catch(error => { .catch(error => {
if (error.response?.status === 409) { if (error.response?.status === 409) {
if (error.response.data?.errors[0]?.code === 'ServerTransferringException') {
setTransferring(true);
} else {
setInstalling(true); setInstalling(true);
}
} else { } else {
console.error(error); console.error(error);
setError(httpErrorToHuman(error)); setError(httpErrorToHuman(error));
@ -116,10 +129,11 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>)
</SubNavigation> </SubNavigation>
</CSSTransition> </CSSTransition>
<InstallListener/> <InstallListener/>
<TransferListener/>
<WebsocketHandler/> <WebsocketHandler/>
{(installing && (!rootAdmin || (rootAdmin && !location.pathname.endsWith(`/server/${id}`)))) ? {((installing || transferring) && (!rootAdmin || (rootAdmin && !location.pathname.endsWith(`/server/${id}`)))) ?
<ScreenBlock <ScreenBlock
title={'Your server is installing.'} title={installing ? 'Your server is installing.' : 'Your server is currently being transferred.'}
image={'/assets/svgs/server_installing.svg'} image={'/assets/svgs/server_installing.svg'}
message={'Please check back in a few minutes.'} message={'Please check back in a few minutes.'}
/> />

View file

@ -58,6 +58,7 @@
</div> </div>
</div> </div>
</div> </div>
@if(! $server->suspended) @if(! $server->suspended)
<div class="col-sm-4"> <div class="col-sm-4">
<div class="box box-warning"> <div class="box box-warning">
@ -71,7 +72,7 @@
<form action="{{ route('admin.servers.view.manage.suspension', $server->id) }}" method="POST"> <form action="{{ route('admin.servers.view.manage.suspension', $server->id) }}" method="POST">
{!! csrf_field() !!} {!! csrf_field() !!}
<input type="hidden" name="action" value="suspend" /> <input type="hidden" name="action" value="suspend" />
<button type="submit" class="btn btn-warning">Suspend Server</button> <button type="submit" class="btn btn-warning @if(! is_null($server->transfer)) disabled @endif">Suspend Server</button>
</form> </form>
</div> </div>
</div> </div>
@ -96,6 +97,7 @@
</div> </div>
@endif @endif
@if(is_null($server->transfer))
<div class="col-sm-4"> <div class="col-sm-4">
<div class="box box-success"> <div class="box box-success">
<div class="box-header with-border"> <div class="box-header with-border">
@ -118,6 +120,25 @@
</div> </div>
</div> </div>
</div> </div>
@else
<div class="col-sm-4">
<div class="box box-success">
<div class="box-header with-border">
<h3 class="box-title">Transfer Server</h3>
</div>
<div class="box-body">
<p>
This server is currently being transferred to another node.
Transfer was initiated at <strong>{{ $server->transfer->created_at }}</strong>
</p>
</div>
<div class="box-footer">
<button class="btn btn-success disabled">Transfer Server</button>
</div>
</div>
</div>
@endif
</div> </div>
<div class="modal fade" id="transferServerModal" tabindex="-1" role="dialog"> <div class="modal fade" id="transferServerModal" tabindex="-1" role="dialog">