diff --git a/app/Exceptions/Http/Server/ServerTransferringException.php b/app/Exceptions/Http/Server/ServerTransferringException.php new file mode 100644 index 000000000..d36fb8de4 --- /dev/null +++ b/app/Exceptions/Http/Server/ServerTransferringException.php @@ -0,0 +1,17 @@ +repository = $repository; $this->locationRepository = $locationRepository; $this->nodeRepository = $nodeRepository; - $this->suspensionService = $suspensionService; $this->transferService = $transferService; $this->daemonConfigurationRepository = $daemonConfigurationRepository; } @@ -98,8 +89,7 @@ class ServerTransferController extends Controller * * @throws \Throwable */ - public function transfer(Request $request, Server $server) - { + public function transfer(Request $request, Server $server) { $validatedData = $request->validate([ 'node_id' => 'required|exists:nodes,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. $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. $transfer = new ServerTransfer; @@ -127,8 +114,8 @@ class ServerTransferController extends Controller $transfer->new_node = $node_id; $transfer->old_allocation = $server->allocation_id; $transfer->new_allocation = $allocation_id; - $transfer->old_additional_allocations = json_encode($server->allocations->where('id', '!=', $server->allocation_id)->pluck('id')); - $transfer->new_additional_allocations = json_encode($additional_allocations); + $transfer->old_additional_allocations = $server->allocations->where('id', '!=', $server->allocation_id)->pluck('id'); + $transfer->new_additional_allocations = $additional_allocations; $transfer->save(); diff --git a/app/Http/Controllers/Api/Client/Servers/WebsocketController.php b/app/Http/Controllers/Api/Client/Servers/WebsocketController.php index f18b47634..1de41b08e 100644 --- a/app/Http/Controllers/Api/Client/Servers/WebsocketController.php +++ b/app/Http/Controllers/Api/Client/Servers/WebsocketController.php @@ -3,12 +3,11 @@ namespace Pterodactyl\Http\Controllers\Api\Client\Servers; use Carbon\CarbonImmutable; -use Illuminate\Http\Response; use Pterodactyl\Models\Server; use Illuminate\Http\JsonResponse; use Pterodactyl\Models\Permission; 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\Services\Servers\GetUserPermissionsService; use Pterodactyl\Http\Controllers\Api\Client\ClientApiController; @@ -55,7 +54,22 @@ class WebsocketController extends ClientApiController { $user = $request->user(); 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 @@ -63,11 +77,11 @@ class WebsocketController extends ClientApiController ->setClaims([ 'user_id' => $request->user()->id, '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([ 'data' => [ diff --git a/app/Http/Controllers/Api/Remote/Servers/ServerTransferController.php b/app/Http/Controllers/Api/Remote/Servers/ServerTransferController.php index bd8827a6e..cc097a637 100644 --- a/app/Http/Controllers/Api/Remote/Servers/ServerTransferController.php +++ b/app/Http/Controllers/Api/Remote/Servers/ServerTransferController.php @@ -3,21 +3,19 @@ namespace Pterodactyl\Http\Controllers\Api\Remote\Servers; use Cake\Chronos\Chronos; -use Lcobucci\JWT\Builder; +use Illuminate\Support\Arr; use Illuminate\Http\Request; -use Lcobucci\JWT\Signer\Key; -use Psr\Log\LoggerInterface; use Illuminate\Http\Response; 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 Pterodactyl\Http\Controllers\Controller; -use Pterodactyl\Services\Servers\SuspensionService; -use Pterodactyl\Repositories\Eloquent\NodeRepository; +use Pterodactyl\Services\Nodes\NodeJWTService; use Pterodactyl\Repositories\Eloquent\ServerRepository; use Pterodactyl\Repositories\Wings\DaemonServerRepository; use Pterodactyl\Repositories\Wings\DaemonTransferRepository; -use Pterodactyl\Contracts\Repository\AllocationRepositoryInterface; use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException; use Pterodactyl\Services\Servers\ServerConfigurationStructureService; @@ -33,16 +31,6 @@ class ServerTransferController extends Controller */ private $repository; - /** - * @var \Pterodactyl\Contracts\Repository\AllocationRepositoryInterface - */ - private $allocationRepository; - - /** - * @var \Pterodactyl\Repositories\Eloquent\NodeRepository - */ - private $nodeRepository; - /** * @var \Pterodactyl\Repositories\Wings\DaemonServerRepository */ @@ -59,48 +47,34 @@ class ServerTransferController extends Controller private $configurationStructureService; /** - * @var \Pterodactyl\Services\Servers\SuspensionService + * @var \Pterodactyl\Services\Nodes\NodeJWTService */ - private $suspensionService; - - /** - * @var \Psr\Log\LoggerInterface - */ - private $writer; + private $jwtService; /** * ServerTransferController constructor. * * @param \Illuminate\Database\ConnectionInterface $connection * @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\DaemonTransferRepository $daemonTransferRepository * @param \Pterodactyl\Services\Servers\ServerConfigurationStructureService $configurationStructureService - * @param \Pterodactyl\Services\Servers\SuspensionService $suspensionService - * @param \Psr\Log\LoggerInterface $writer + * @param \Pterodactyl\Services\Nodes\NodeJWTService $jwtService */ public function __construct( ConnectionInterface $connection, ServerRepository $repository, - AllocationRepositoryInterface $allocationRepository, - NodeRepository $nodeRepository, DaemonServerRepository $daemonServerRepository, DaemonTransferRepository $daemonTransferRepository, ServerConfigurationStructureService $configurationStructureService, - SuspensionService $suspensionService, - LoggerInterface $writer + NodeJWTService $jwtService ) { $this->connection = $connection; $this->repository = $repository; - $this->allocationRepository = $allocationRepository; - $this->nodeRepository = $nodeRepository; $this->daemonServerRepository = $daemonServerRepository; $this->daemonTransferRepository = $daemonTransferRepository; $this->configurationStructureService = $configurationStructureService; - $this->suspensionService = $suspensionService; - $this->writer = $writer; + $this->jwtService = $jwtService; } /** @@ -110,7 +84,6 @@ class ServerTransferController extends Controller * @param string $uuid * @return \Illuminate\Http\JsonResponse * - * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException * @throws \Throwable */ @@ -120,52 +93,50 @@ class ServerTransferController extends Controller // Unsuspend the server and don't continue the transfer. if (! $request->input('successful')) { - $this->suspensionService->toggle($server, 'unsuspend'); - - return JsonResponse::create([], Response::HTTP_NO_CONTENT); + return $this->processFailedTransfer($server->transfer); } - $server->node_id = $server->transfer->new_node; - - $data = $this->configurationStructureService->handle($server); - $data['suspended'] = false; - $data['service']['skip_scripts'] = true; + // 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, [ + 'node_id' => $server->transfer->new_node, + ]); $allocations = $server->getAllocationMappings(); - $data['allocations']['default']['ip'] = array_key_first($allocations); - $data['allocations']['default']['port'] = $allocations[$data['allocations']['default']['ip']][0]; + $primary = array_key_first($allocations); + 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(); - $signer = new Sha256; + $this->connection->transaction(function () use ($data, $server) { + // 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')) - ->permittedFor($server->node->getConnectionAddress()) - ->identifiedBy(hash('sha256', $server->uuid), true) - ->issuedAt($now->getTimestamp()) - ->canOnlyBeUsedAfter($now->getTimestamp()) - ->expiresAt($now->addMinutes(15)->getTimestamp()) - ->relatedTo($server->uuid, true) - ->getToken($signer, new Key($server->node->getDecryptedKey())); + // Update the archived field on the transfer to make clients connect to the websocket + // on the new node to be able to receive transfer logs. + $server->transfer->forceFill(['archived' => true])->saveOrFail(); - // 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 - // we want to specify. - try { + // 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 + // we want to specify. $this->daemonTransferRepository ->setServer($server) - ->setNode($this->nodeRepository->find($server->transfer->new_node)) + ->setNode($server->transfer->newNode) ->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. * - * @param \Illuminate\Http\Request $request * @param string $uuid * @return \Illuminate\Http\JsonResponse * @@ -174,18 +145,8 @@ class ServerTransferController extends Controller public function failure(string $uuid) { $server = $this->repository->getByUuid($uuid); - $transfer = $server->transfer; - $allocationIds = json_decode($transfer->new_additional_allocations); - 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); + return $this->processFailedTransfer($server->transfer); } /** @@ -201,38 +162,63 @@ class ServerTransferController extends Controller $server = $this->repository->getByUuid($uuid); $transfer = $server->transfer; - $allocationIds = json_decode($transfer->old_additional_allocations); - array_push($allocationIds, $transfer->old_allocation); + /** @var \Pterodactyl\Models\Server $server */ + $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. - $this->connection->beginTransaction(); + // Remove the old allocations for the server and re-assign the server to the new + // 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. - $this->allocationRepository->updateWhereIn('id', $allocationIds, ['server_id' => null]); + $server = $server->fresh(); + $server->transfer->update(['successful' => true]); - // Update the server's allocation_id and node_id. - $server->allocation_id = $transfer->new_allocation; - $server->node_id = $transfer->new_node; - $server->save(); + return $server; + }); - // Mark the transfer as successful. - $transfer->successful = true; - $transfer->save(); - - // Commit the transaction. - $this->connection->commit(); - - // Delete the server from the old node + // Delete the server from the old node making sure to point it to the old node so + // that we do not delete it from the new node the server was transfered to. try { - $this->daemonServerRepository->setServer($server)->delete(); + $this->daemonServerRepository + ->setServer($server) + ->setNode($transfer->oldNode) + ->delete(); } catch (DaemonConnectionException $exception) { - $this->writer->warning($exception); + Log::warning($exception, ['transfer_id' => $server->transfer->id]); } - // Unsuspend the server - $server->load('node'); - $this->suspensionService->toggle($server, $this->suspensionService::ACTION_UNSUSPEND); + return new JsonResponse([], Response::HTTP_NO_CONTENT); + } - 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); } } diff --git a/app/Http/Controllers/Api/Remote/SftpAuthenticationController.php b/app/Http/Controllers/Api/Remote/SftpAuthenticationController.php index b62d6e5c1..cab532e81 100644 --- a/app/Http/Controllers/Api/Remote/SftpAuthenticationController.php +++ b/app/Http/Controllers/Api/Remote/SftpAuthenticationController.php @@ -12,6 +12,7 @@ use Pterodactyl\Exceptions\Http\HttpForbiddenException; use Pterodactyl\Repositories\Eloquent\ServerRepository; use Pterodactyl\Services\Servers\GetUserPermissionsService; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Pterodactyl\Exceptions\Http\Server\ServerTransferringException; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Pterodactyl\Http\Requests\Api\Remote\SftpAuthenticationFormRequest; 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. if ($server->installed !== 1 || $server->suspended) { throw new BadRequestHttpException( @@ -118,7 +124,7 @@ class SftpAuthenticationController extends Controller ); } - return JsonResponse::create([ + return new JsonResponse([ 'server' => $server->uuid, // Deprecated, but still needed at the moment for Wings. 'token' => '', @@ -132,7 +138,7 @@ class SftpAuthenticationController extends Controller * @param \Illuminate\Http\Request $request * @return string */ - protected function throttleKey(Request $request) + protected function throttleKey(Request $request): string { $username = explode('.', strrev($request->input('username', ''))); diff --git a/app/Http/Middleware/Api/Client/Server/AuthenticateServerAccess.php b/app/Http/Middleware/Api/Client/Server/AuthenticateServerAccess.php index 505f1a305..e9eaa143e 100644 --- a/app/Http/Middleware/Api/Client/Server/AuthenticateServerAccess.php +++ b/app/Http/Middleware/Api/Client/Server/AuthenticateServerAccess.php @@ -9,7 +9,7 @@ use Pterodactyl\Contracts\Repository\ServerRepositoryInterface; use Symfony\Component\HttpKernel\Exception\ConflictHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; -use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; +use Pterodactyl\Exceptions\Http\Server\ServerTransferringException; class AuthenticateServerAccess { @@ -24,7 +24,6 @@ class AuthenticateServerAccess * @var string[] */ protected $except = [ - 'api:client:server.view', 'api:client:server.ws', ]; @@ -65,17 +64,26 @@ class AuthenticateServerAccess } } - if ($server->suspended && !$request->routeIs('api:client:server.resources')) { + if ($server->suspended && ! $request->routeIs('api:client:server.resources')) { throw new BadRequestHttpException( 'This server is currently suspended and the functionality requested is unavailable.' ); } - if (! $server->isInstalled()) { - // 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. - if (! $user->root_admin || ($user->root_admin && ! $request->routeIs($this->except))) { - throw new ConflictHttpException('Server has not completed the installation process.'); + // 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()) { + // 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. + if (! $user->root_admin || ($user->root_admin && ! $request->routeIs($this->except))) { + throw new ConflictHttpException('Server has not completed the installation process.'); + } + } + + if (! is_null($server->transfer)) { + if (! $user->root_admin || ($user->root_admin && ! $request->routeIs($this->except))) { + throw new ServerTransferringException; + } } } diff --git a/app/Http/Middleware/Server/AccessingValidServer.php b/app/Http/Middleware/Server/AccessingValidServer.php index 1f464f7df..2491414c7 100644 --- a/app/Http/Middleware/Server/AccessingValidServer.php +++ b/app/Http/Middleware/Server/AccessingValidServer.php @@ -9,6 +9,7 @@ use Illuminate\Contracts\Routing\ResponseFactory; use Illuminate\Contracts\Config\Repository as ConfigRepository; use Pterodactyl\Contracts\Repository\ServerRepositoryInterface; use Symfony\Component\HttpKernel\Exception\ConflictHttpException; +use Pterodactyl\Exceptions\Http\Server\ServerTransferringException; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; class AccessingValidServer @@ -80,6 +81,14 @@ class AccessingValidServer 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 // as files are updated. $request->attributes->set('server', $server); diff --git a/app/Models/Server.php b/app/Models/Server.php index c8b8615fe..aace86d0b 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -306,7 +306,7 @@ class Server extends Model */ public function transfer() { - return $this->hasOne(ServerTransfer::class)->orderByDesc('id'); + return $this->hasOne(ServerTransfer::class)->whereNull('successful')->orderByDesc('id'); } /** diff --git a/app/Models/ServerTransfer.php b/app/Models/ServerTransfer.php index 8d7120645..1af9ab7e4 100644 --- a/app/Models/ServerTransfer.php +++ b/app/Models/ServerTransfer.php @@ -9,13 +9,16 @@ namespace Pterodactyl\Models; * @property int $new_node * @property int $old_allocation * @property int $new_allocation - * @property string $old_additional_allocations - * @property string $new_additional_allocations - * @property bool $successful + * @property array|null $old_additional_allocations + * @property array|null $new_additional_allocations + * @property bool|null $successful + * @property bool $archived * @property \Carbon\Carbon $created_at * @property \Carbon\Carbon $updated_at * * @property \Pterodactyl\Models\Server $server + * @property \Pterodactyl\Models\Node $oldNode + * @property \Pterodactyl\Models\Node $newNode */ class ServerTransfer extends Model { @@ -50,9 +53,10 @@ class ServerTransfer extends Model 'new_node' => 'int', 'old_allocation' => 'int', 'new_allocation' => 'int', - 'old_additional_allocations' => 'string', - 'new_additional_allocations' => 'string', + 'old_additional_allocations' => 'array', + 'new_additional_allocations' => 'array', 'successful' => 'bool', + 'archived' => 'bool', ]; /** @@ -64,9 +68,11 @@ class ServerTransfer extends Model 'new_node' => 'required|numeric', 'old_allocation' => 'required|numeric', 'new_allocation' => 'required|numeric', - 'old_additional_allocations' => 'nullable', - 'new_additional_allocations' => 'nullable', - 'successful' => 'sometimes|boolean', + 'old_additional_allocations' => 'nullable|array', + 'old_additional_allocations.*' => 'numeric', + '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); } + + /** + * 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'); + } } diff --git a/app/Services/Eggs/EggConfigurationService.php b/app/Services/Eggs/EggConfigurationService.php index 6dbb469ba..4d1db4e2e 100644 --- a/app/Services/Eggs/EggConfigurationService.php +++ b/app/Services/Eggs/EggConfigurationService.php @@ -102,7 +102,7 @@ class EggConfigurationService { // Get the legacy configuration structure for the server so that we // can property map the egg placeholders to values. - $structure = $this->configurationStructureService->handle($server, true); + $structure = $this->configurationStructureService->handle($server, [], true); $response = []; // Normalize the output of the configuration for the new Wings Daemon to more diff --git a/app/Services/Nodes/NodeJWTService.php b/app/Services/Nodes/NodeJWTService.php index 85332a6bc..7c359efe8 100644 --- a/app/Services/Nodes/NodeJWTService.php +++ b/app/Services/Nodes/NodeJWTService.php @@ -22,6 +22,11 @@ class NodeJWTService */ private $expiresAt; + /** + * @var string|null + */ + private $subject; + /** * Set the claims to include in this JWT. * @@ -35,6 +40,10 @@ class NodeJWTService return $this; } + /** + * @param \DateTimeInterface $date + * @return $this + */ public function setExpiresAt(DateTimeInterface $date) { $this->expiresAt = $date->getTimestamp(); @@ -42,20 +51,32 @@ class NodeJWTService 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. * * @param \Pterodactyl\Models\Node $node * @param string|null $identifiedBy + * @param string $algo * @return \Lcobucci\JWT\Token */ - public function handle(Node $node, string $identifiedBy) + public function handle(Node $node, string $identifiedBy, string $algo = 'md5') { $signer = new Sha256; $builder = (new Builder)->issuedBy(config('app.url')) ->permittedFor($node->getConnectionAddress()) - ->identifiedBy(md5($identifiedBy), true) + ->identifiedBy(hash($algo, $identifiedBy), true) ->issuedAt(CarbonImmutable::now()->getTimestamp()) ->canOnlyBeUsedAfter(CarbonImmutable::now()->subMinutes(5)->getTimestamp()); @@ -63,6 +84,10 @@ class NodeJWTService $builder = $builder->expiresAt($this->expiresAt); } + if (!empty($this->subject)) { + $builder = $builder->relatedTo($this->subject, true); + } + foreach ($this->claims as $key => $value) { $builder = $builder->withClaim($key, $value); } diff --git a/app/Services/Servers/GetUserPermissionsService.php b/app/Services/Servers/GetUserPermissionsService.php index e0ea20373..80ce35b52 100644 --- a/app/Services/Servers/GetUserPermissionsService.php +++ b/app/Services/Servers/GetUserPermissionsService.php @@ -24,6 +24,7 @@ class GetUserPermissionsService if ($user->root_admin) { $permissions[] = 'admin.websocket.errors'; $permissions[] = 'admin.websocket.install'; + $permissions[] = 'admin.websocket.transfer'; } return $permissions; diff --git a/app/Services/Servers/ServerConfigurationStructureService.php b/app/Services/Servers/ServerConfigurationStructureService.php index dc2a4bfb2..790e9ecc1 100644 --- a/app/Services/Servers/ServerConfigurationStructureService.php +++ b/app/Services/Servers/ServerConfigurationStructureService.php @@ -29,14 +29,25 @@ class ServerConfigurationStructureService * daemon, if you modify the structure eggs will break unexpectedly. * * @param \Pterodactyl\Models\Server $server + * @param array $override * @param bool $legacy deprecated * @return array */ - public function handle(Server $server, bool $legacy = false): array + public function handle(Server $server, array $override = [], bool $legacy = false): array { - return $legacy ? - $this->returnLegacyFormat($server) - : $this->returnCurrentFormat($server); + $clone = $server; + // If any overrides have been set on this call make sure to update them on the + // 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); } /** @@ -105,12 +116,12 @@ class ServerConfigurationStructureService })->toArray(), 'env' => $this->environment->handle($server), 'oom_disabled' => $server->oom_disabled, - 'memory' => (int) $server->memory, - 'swap' => (int) $server->swap, - 'io' => (int) $server->io, - 'cpu' => (int) $server->cpu, + 'memory' => (int)$server->memory, + 'swap' => (int)$server->swap, + 'io' => (int)$server->io, + 'cpu' => (int)$server->cpu, 'threads' => $server->threads, - 'disk' => (int) $server->disk, + 'disk' => (int)$server->disk, 'image' => $server->image, ], 'service' => [ @@ -118,7 +129,7 @@ class ServerConfigurationStructureService 'skip_scripts' => $server->skip_scripts, ], 'rebuild' => false, - 'suspended' => (int) $server->suspended, + 'suspended' => (int)$server->suspended, ]; } } diff --git a/app/Services/Servers/SuspensionService.php b/app/Services/Servers/SuspensionService.php index 6497d73ed..87fd0a334 100644 --- a/app/Services/Servers/SuspensionService.php +++ b/app/Services/Servers/SuspensionService.php @@ -6,6 +6,8 @@ use Webmozart\Assert\Assert; use Pterodactyl\Models\Server; use Illuminate\Database\ConnectionInterface; use Pterodactyl\Repositories\Wings\DaemonServerRepository; +use Symfony\Component\HttpKernel\Exception\ConflictHttpException; +use Pterodactyl\Exceptions\Http\Server\ServerTransferringException; class SuspensionService { @@ -56,12 +58,20 @@ class SuspensionService return; } + // Check if the server is currently being transferred. + if (! is_null($server->transfer)) { + throw new ServerTransferringException; + } + $this->connection->transaction(function () use ($action, $server) { $server->update([ 'suspended' => $action === self::ACTION_SUSPEND, ]); - $this->daemonServerRepository->setServer($server)->suspend($action === self::ACTION_UNSUSPEND); + // 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); + } }); } } diff --git a/app/Transformers/Api/Application/ServerTransformer.php b/app/Transformers/Api/Application/ServerTransformer.php index e2c32eb76..10c343d8c 100644 --- a/app/Transformers/Api/Application/ServerTransformer.php +++ b/app/Transformers/Api/Application/ServerTransformer.php @@ -28,6 +28,7 @@ class ServerTransformer extends BaseTransformer 'location', 'node', 'databases', + 'transfer', ]; /** @@ -55,8 +56,6 @@ class ServerTransformer extends BaseTransformer * * @param \Pterodactyl\Models\Server $server * @return array - * - * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException */ public function transform(Server $server): array { diff --git a/app/Transformers/Api/Client/ServerTransformer.php b/app/Transformers/Api/Client/ServerTransformer.php index 9897f8517..0673f9b57 100644 --- a/app/Transformers/Api/Client/ServerTransformer.php +++ b/app/Transformers/Api/Client/ServerTransformer.php @@ -72,6 +72,7 @@ class ServerTransformer extends BaseClientTransformer ], 'is_suspended' => $server->suspended, 'is_installing' => $server->installed !== 1, + 'is_transferring' => ! is_null($server->transfer), ]; } diff --git a/database/migrations/2020_12_14_013707_make_successful_nullable_in_server_transfers.php b/database/migrations/2020_12_14_013707_make_successful_nullable_in_server_transfers.php new file mode 100644 index 000000000..0a2885284 --- /dev/null +++ b/database/migrations/2020_12_14_013707_make_successful_nullable_in_server_transfers.php @@ -0,0 +1,32 @@ +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(); + }); + } +} diff --git a/database/migrations/2020_12_17_014330_add_archived_field_to_server_transfers_table.php b/database/migrations/2020_12_17_014330_add_archived_field_to_server_transfers_table.php new file mode 100644 index 000000000..1162d8a4f --- /dev/null +++ b/database/migrations/2020_12_17_014330_add_archived_field_to_server_transfers_table.php @@ -0,0 +1,38 @@ +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'); + }); + } +} diff --git a/database/migrations/2020_12_24_092449_make_allocation_fields_json.php b/database/migrations/2020_12_24_092449_make_allocation_fields_json.php new file mode 100644 index 000000000..0f1ff554f --- /dev/null +++ b/database/migrations/2020_12_24_092449_make_allocation_fields_json.php @@ -0,0 +1,34 @@ +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(); + }); + } +} diff --git a/resources/scripts/api/server/getServer.ts b/resources/scripts/api/server/getServer.ts index d9b76b400..959149f9d 100644 --- a/resources/scripts/api/server/getServer.ts +++ b/resources/scripts/api/server/getServer.ts @@ -40,6 +40,7 @@ export interface Server { }; isSuspended: boolean; isInstalling: boolean; + isTransferring: boolean; variables: ServerEggVariable[]; allocations: Allocation[]; } @@ -62,6 +63,7 @@ export const rawDataToServerObject = ({ attributes: data }: FractalResponseData) featureLimits: { ...data.feature_limits }, isSuspended: data.is_suspended, isInstalling: data.is_installing, + isTransferring: data.is_transferring, variables: ((data.relationships?.variables as FractalResponseList | undefined)?.data || []).map(rawDataToServerEggVariable), allocations: ((data.relationships?.allocations as FractalResponseList | undefined)?.data || []).map(rawDataToServerAllocation), }); diff --git a/resources/scripts/components/dashboard/ServerRow.tsx b/resources/scripts/components/dashboard/ServerRow.tsx index d42b3e434..7db977311 100644 --- a/resources/scripts/components/dashboard/ServerRow.tsx +++ b/resources/scripts/components/dashboard/ServerRow.tsx @@ -26,14 +26,14 @@ const IconDescription = styled.p<{ $alarm: boolean }>` const StatusIndicatorBox = styled(GreyRowBox)<{ $status: ServerPowerState | undefined }>` ${tw`grid grid-cols-12 gap-4 relative`}; - + & .status-bar { ${tw`w-2 bg-red-500 absolute right-0 z-20 rounded-full m-1 opacity-50 transition-all duration-150`}; height: calc(100% - 0.5rem); - + ${({ $status }) => (!$status || $status === 'offline') ? tw`bg-red-500` : ($status === 'running' ? tw`bg-green-500` : tw`bg-yellow-500`)}; } - + &:hover .status-bar { ${tw`opacity-75`}; } @@ -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); } - const disklimit = server.limits.disk !== 0 ? megabytesToHuman(server.limits.disk) : 'Unlimited'; - const memorylimit = server.limits.memory !== 0 ? megabytesToHuman(server.limits.memory) : 'Unlimited'; + const diskLimit = server.limits.disk !== 0 ? megabytesToHuman(server.limits.disk) : 'Unlimited'; + const memoryLimit = server.limits.memory !== 0 ? megabytesToHuman(server.limits.memory) : 'Unlimited'; return ( @@ -118,7 +118,14 @@ export default ({ server, className }: { server: Server; className?: string }) = : - + server.isTransferring ? +
+ + Transferring + +
+ : + : -

of {memorylimit}

+

of {memoryLimit}

@@ -143,7 +150,7 @@ export default ({ server, className }: { server: Server; className?: string }) = {bytesToHuman(stats.diskUsageInBytes)}
-

of {disklimit}

+

of {diskLimit}

} diff --git a/resources/scripts/components/elements/ProgressBar.tsx b/resources/scripts/components/elements/ProgressBar.tsx index 64ee732b7..fe98579bd 100644 --- a/resources/scripts/components/elements/ProgressBar.tsx +++ b/resources/scripts/components/elements/ProgressBar.tsx @@ -59,7 +59,7 @@ export default () => { }, [ progress, continuous ]); return ( -
+
{ const { connected, instance } = ServerContext.useStoreState(state => state.socket); const [ canSendCommands ] = usePermissions([ 'control.console' ]); const serverId = ServerContext.useStoreState(state => state.server.data!.id); + const isTransferring = ServerContext.useStoreState(state => state.server.data!.isTransferring); const [ history, setHistory ] = usePersistedState(`${serverId}:command_history`, []); const [ historyIndex, setHistoryIndex ] = useState(-1); @@ -74,6 +75,19 @@ export default () => { (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( TERMINAL_PRELUDE + '\u001b[1m\u001b[41m' + line.replace(/(?:\r\n|\r|\n)$/im, '') + '\u001b[0m', ); @@ -122,20 +136,23 @@ export default () => { // Add support for capturing keys terminal.attachCustomKeyEventHandler((e: KeyboardEvent) => { - // Ctrl + C ( Copy ) + // Ctrl + C (Copy) if (e.ctrlKey && e.key === 'c') { document.execCommand('copy'); return false; } + // Ctrl + F (Find) if (e.ctrlKey && e.key === 'f') { searchBar.show(); return false; } + // Escape if (e.key === 'Escape') { searchBar.hidden(); } + return true; }); } @@ -149,22 +166,29 @@ export default () => { useEffect(() => { if (connected && instance) { - terminal.clear(); + // Do not clear the console if the server is being transferred. + if (!isTransferring) { + terminal.clear(); + } instance.addListener('status', handlePowerChangeEvent); instance.addListener('console 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 error', handleDaemonErrorOutput); instance.send('send logs'); } return () => { - instance && instance.removeListener('console output', handleConsoleOutput) + instance && instance.removeListener('status', handlePowerChangeEvent) + .removeListener('console output', handleConsoleOutput) .removeListener('install output', handleConsoleOutput) + .removeListener('transfer logs', handleConsoleOutput) + .removeListener('transfer status', handleTransferStatus) .removeListener('daemon message', line => handleConsoleOutput(line, true)) - .removeListener('daemon error', handleDaemonErrorOutput) - .removeListener('status', handlePowerChangeEvent); + .removeListener('daemon error', handleDaemonErrorOutput); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [ connected, instance ]); diff --git a/resources/scripts/components/server/ServerConsole.tsx b/resources/scripts/components/server/ServerConsole.tsx index 4735488fc..d090e7a95 100644 --- a/resources/scripts/components/server/ServerConsole.tsx +++ b/resources/scripts/components/server/ServerConsole.tsx @@ -17,6 +17,7 @@ const ChunkedStatGraphs = lazy(() => import(/* webpackChunkName: "graphs" */'@/c const ServerConsole = () => { const isInstalling = ServerContext.useStoreState(state => state.server.data!.isInstalling); + const isTransferring = ServerContext.useStoreState(state => state.server.data!.isTransferring); // @ts-ignore const eggFeatures: string[] = ServerContext.useStoreState(state => state.server.data!.eggFeatures, isEqual); @@ -24,11 +25,7 @@ const ServerConsole = () => {
- {!isInstalling ? - - - - : + {isInstalling ?

@@ -37,6 +34,20 @@ const ServerConsole = () => {

+ : + isTransferring ? +
+ +

+ This server is currently being transferred to another node and all actions + are unavailable. +

+
+
+ : + + + }
diff --git a/resources/scripts/components/server/ServerDetailsBlock.tsx b/resources/scripts/components/server/ServerDetailsBlock.tsx index 0d87089c1..11f41f690 100644 --- a/resources/scripts/components/server/ServerDetailsBlock.tsx +++ b/resources/scripts/components/server/ServerDetailsBlock.tsx @@ -65,13 +65,14 @@ const ServerDetailsBlock = () => { const name = ServerContext.useStoreState(state => state.server.data!.name); 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 primaryAllocation = ServerContext.useStoreState(state => state.server.data!.allocations.filter(alloc => alloc.isDefault).map( allocation => (allocation.alias || allocation.ip) + ':' + allocation.port )).toString(); - const disklimit = limits.disk ? megabytesToHuman(limits.disk) : 'Unlimited'; - const memorylimit = limits.memory ? megabytesToHuman(limits.memory) : 'Unlimited'; + const diskLimit = limits.disk ? megabytesToHuman(limits.disk) : 'Unlimited'; + const memoryLimit = limits.memory ? megabytesToHuman(limits.memory) : 'Unlimited'; return ( @@ -81,10 +82,10 @@ const ServerDetailsBlock = () => { fixedWidth css={[ tw`mr-1`, - statusToColor(status, isInstalling), + statusToColor(status, isInstalling || isTransferring), ]} /> -  {!status ? 'Connecting...' : (isInstalling ? 'Installing' : status)} +  {!status ? 'Connecting...' : (isInstalling ? 'Installing' : (isTransferring) ? 'Transferring' : status)}

@@ -97,11 +98,11 @@ const ServerDetailsBlock = () => {

{bytesToHuman(stats.memory)} - / {memorylimit} + / {memoryLimit}

 {bytesToHuman(stats.disk)} - / {disklimit} + / {diskLimit}

); diff --git a/resources/scripts/components/server/TransferListener.tsx b/resources/scripts/components/server/TransferListener.tsx new file mode 100644 index 000000000..b46e655bc --- /dev/null +++ b/resources/scripts/components/server/TransferListener.tsx @@ -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; diff --git a/resources/scripts/components/server/WebsocketHandler.tsx b/resources/scripts/components/server/WebsocketHandler.tsx index 7a6cfd50a..10b91dd47 100644 --- a/resources/scripts/components/server/WebsocketHandler.tsx +++ b/resources/scripts/components/server/WebsocketHandler.tsx @@ -20,6 +20,59 @@ export default () => { const setServerStatus = ServerContext.useStoreActions(actions => actions.status.setServerStatus); 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) => { if (updatingToken) return; @@ -49,42 +102,7 @@ export default () => { return; } - 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.'); - } - }); - - 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)); + connect(uuid); }, [ uuid ]); return ( diff --git a/resources/scripts/components/server/databases/CreateDatabaseButton.tsx b/resources/scripts/components/server/databases/CreateDatabaseButton.tsx index d70faffba..c3b47250f 100644 --- a/resources/scripts/components/server/databases/CreateDatabaseButton.tsx +++ b/resources/scripts/components/server/databases/CreateDatabaseButton.tsx @@ -42,7 +42,6 @@ export default () => { setVisible(false); }) .catch(error => { - console.log(error); addError({ key: 'database:create', message: httpErrorToHuman(error) }); setSubmitting(false); }); diff --git a/resources/scripts/components/server/files/FileManagerContainer.tsx b/resources/scripts/components/server/files/FileManagerContainer.tsx index 613a1baaa..565716296 100644 --- a/resources/scripts/components/server/files/FileManagerContainer.tsx +++ b/resources/scripts/components/server/files/FileManagerContainer.tsx @@ -65,7 +65,7 @@ export default () => { } diff --git a/resources/scripts/routers/ServerRouter.tsx b/resources/scripts/routers/ServerRouter.tsx index 0a09774aa..533491db6 100644 --- a/resources/scripts/routers/ServerRouter.tsx +++ b/resources/scripts/routers/ServerRouter.tsx @@ -1,3 +1,4 @@ +import TransferListener from '@/components/server/TransferListener'; import React, { useEffect, useState } from 'react'; import { NavLink, Route, RouteComponentProps, Switch } from 'react-router-dom'; 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 [ error, setError ] = useState(''); const [ installing, setInstalling ] = useState(false); + const [ transferring, setTransferring ] = useState(false); const id = ServerContext.useStoreState(state => state.server.data?.id); const uuid = ServerContext.useStoreState(state => state.server.data?.uuid); 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 getServer = ServerContext.useStoreActions(actions => actions.server.getServer); const clearServerState = ServerContext.useStoreActions(actions => actions.clearServerState); @@ -51,13 +54,23 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>) setInstalling(!!isInstalling); }, [ isInstalling ]); + useEffect(() => { + setTransferring(!!isTransferring); + }, [ isTransferring ]); + useEffect(() => { setError(''); setInstalling(false); + setTransferring(false); + getServer(match.params.id) .catch(error => { if (error.response?.status === 409) { - setInstalling(true); + if (error.response.data?.errors[0]?.code === 'ServerTransferringException') { + setTransferring(true); + } else { + setInstalling(true); + } } else { console.error(error); setError(httpErrorToHuman(error)); @@ -116,10 +129,11 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>) + - {(installing && (!rootAdmin || (rootAdmin && !location.pathname.endsWith(`/server/${id}`)))) ? + {((installing || transferring) && (!rootAdmin || (rootAdmin && !location.pathname.endsWith(`/server/${id}`)))) ? diff --git a/resources/views/admin/servers/view/manage.blade.php b/resources/views/admin/servers/view/manage.blade.php index 93d7b6390..9962cfddf 100644 --- a/resources/views/admin/servers/view/manage.blade.php +++ b/resources/views/admin/servers/view/manage.blade.php @@ -58,6 +58,7 @@
+ @if(! $server->suspended)
@@ -71,7 +72,7 @@
{!! csrf_field() !!} - +
@@ -96,28 +97,48 @@ @endif -
-
-
-

Transfer Server

-
-
-

- Transfer this server to another node connected to this panel. - Warning! This feature has not been fully tested and may have bugs. -

-
+ @if(is_null($server->transfer)) +
+
+
+

Transfer Server

+
+
+

+ Transfer this server to another node connected to this panel. + Warning! This feature has not been fully tested and may have bugs. +

+
-
-
+ @else +
+
+
+

Transfer Server

+
+
+

+ This server is currently being transferred to another node. + Transfer was initiated at {{ $server->transfer->created_at }} +

+
+ + +
+
+ @endif