diff --git a/app/Http/Controllers/Admin/Servers/ServerTransferController.php b/app/Http/Controllers/Admin/Servers/ServerTransferController.php new file mode 100644 index 000000000..323ffb7bc --- /dev/null +++ b/app/Http/Controllers/Admin/Servers/ServerTransferController.php @@ -0,0 +1,177 @@ +alert = $alert; + $this->allocationRepository = $allocationRepository; + $this->repository = $repository; + $this->locationRepository = $locationRepository; + $this->nodeRepository = $nodeRepository; + $this->suspensionService = $suspensionService; + $this->transferService = $transferService; + $this->daemonConfigurationRepository = $daemonConfigurationRepository; + } + + /** + * Starts a transfer of a server to a new node. + * + * @param \Illuminate\Http\Request $request + * @param \Pterodactyl\Models\Server $server + * @return \Illuminate\Http\RedirectResponse + * + * @throws \Throwable + */ + 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', + 'allocation_additional' => 'nullable', + ]); + + $node_id = $validatedData['node_id']; + $allocation_id = intval($validatedData['allocation_id']); + $additional_allocations = array_map('intval', $validatedData['allocation_additional'] ?? []); + + // Check if the node is viable for the transfer. + $node = $this->nodeRepository->getNodeWithResourceUsage($node_id); + if ($node->isViable($server->memory, $server->disk)) { + // 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; + + $transfer->server_id = $server->id; + $transfer->old_node = $server->node_id; + $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->save(); + + // Add the allocations to the server so they cannot be automatically assigned while the transfer is in progress. + $this->assignAllocationsToServer($server, $node_id, $allocation_id, $additional_allocations); + + // Request an archive from the server's current daemon. (this also checks if the daemon is online) + $this->transferService->requestArchive($server); + + $this->alert->success(trans('admin/server.alerts.transfer_started'))->flash(); + } else { + $this->alert->danger(trans('admin/server.alerts.transfer_not_viable'))->flash(); + } + + return redirect()->route('admin.servers.view.manage', $server->id); + } + + /** + * Assigns the specified allocations to the specified server. + * + * @param Server $server + * @param int $node_id + * @param int $allocation_id + * @param array $additional_allocations + */ + private function assignAllocationsToServer(Server $server, int $node_id, int $allocation_id, array $additional_allocations) + { + $allocations = $additional_allocations; + array_push($allocations, $allocation_id); + + $unassigned = $this->allocationRepository->getUnassignedAllocationIds($node_id); + + $updateIds = []; + foreach ($allocations as $allocation) { + if (! in_array($allocation, $unassigned)) { + continue; + } + + $updateIds[] = $allocation; + } + + if (! empty($updateIds)) { + $this->allocationRepository->updateWhereIn('id', $updateIds, ['server_id' => $server->id]); + } + } +} diff --git a/app/Http/Controllers/Admin/Servers/ServerViewController.php b/app/Http/Controllers/Admin/Servers/ServerViewController.php index de78ca17f..25e198592 100644 --- a/app/Http/Controllers/Admin/Servers/ServerViewController.php +++ b/app/Http/Controllers/Admin/Servers/ServerViewController.php @@ -2,6 +2,7 @@ namespace Pterodactyl\Http\Controllers\Admin\Servers; +use JavaScript; use Illuminate\Http\Request; use Pterodactyl\Models\Nest; use Pterodactyl\Models\Server; @@ -9,8 +10,10 @@ use Illuminate\Contracts\View\Factory; use Pterodactyl\Exceptions\DisplayException; use Pterodactyl\Http\Controllers\Controller; use Pterodactyl\Repositories\Eloquent\NestRepository; +use Pterodactyl\Repositories\Eloquent\NodeRepository; use Pterodactyl\Repositories\Eloquent\ServerRepository; use Pterodactyl\Traits\Controllers\JavascriptInjection; +use Pterodactyl\Repositories\Eloquent\LocationRepository; use Pterodactyl\Repositories\Eloquent\DatabaseHostRepository; class ServerViewController extends Controller @@ -37,17 +40,31 @@ class ServerViewController extends Controller */ private $nestRepository; + /** + * @var \Pterodactyl\Repositories\Eloquent\LocationRepository + */ + private $locationRepository; + + /** + * @var \Pterodactyl\Repositories\Eloquent\NodeRepository + */ + private $nodeRepository; + /** * ServerViewController constructor. * * @param \Pterodactyl\Repositories\Eloquent\DatabaseHostRepository $databaseHostRepository * @param \Pterodactyl\Repositories\Eloquent\NestRepository $nestRepository + * @param \Pterodactyl\Repositories\Eloquent\LocationRepository $locationRepository + * @param \Pterodactyl\Repositories\Eloquent\NodeRepository $nodeRepository * @param \Pterodactyl\Repositories\Eloquent\ServerRepository $repository * @param \Illuminate\Contracts\View\Factory $view */ public function __construct( DatabaseHostRepository $databaseHostRepository, NestRepository $nestRepository, + LocationRepository $locationRepository, + NodeRepository $nodeRepository, ServerRepository $repository, Factory $view ) { @@ -55,6 +72,8 @@ class ServerViewController extends Controller $this->databaseHostRepository = $databaseHostRepository; $this->repository = $repository; $this->nestRepository = $nestRepository; + $this->nodeRepository = $nodeRepository; + $this->locationRepository = $locationRepository; } /** @@ -150,6 +169,7 @@ class ServerViewController extends Controller * @return \Illuminate\Contracts\View\View * * @throws \Pterodactyl\Exceptions\DisplayException + * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException */ public function manage(Request $request, Server $server) { @@ -159,7 +179,22 @@ class ServerViewController extends Controller ); } - return $this->view->make('admin.servers.view.manage', compact('server')); + // Check if the panel doesn't have at least 2 nodes configured. + $nodes = $this->nodeRepository->all(); + $canTransfer = false; + if (count($nodes) >= 2) { + $canTransfer = true; + } + + Javascript::put([ + 'nodeData' => $this->nodeRepository->getNodesForServerCreation(), + ]); + + return $this->view->make('admin.servers.view.manage', [ + 'server' => $server, + 'locations' => $this->locationRepository->all(), + 'canTransfer' => $canTransfer, + ]); } /** diff --git a/app/Http/Controllers/Api/Remote/Servers/ServerTransferController.php b/app/Http/Controllers/Api/Remote/Servers/ServerTransferController.php new file mode 100644 index 000000000..1d2e723ee --- /dev/null +++ b/app/Http/Controllers/Api/Remote/Servers/ServerTransferController.php @@ -0,0 +1,238 @@ +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; + } + + /** + * The daemon notifies us about the archive status. + * + * @param \Illuminate\Http\Request $request + * @param string $uuid + * @return \Illuminate\Http\JsonResponse + * + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException + * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException + * @throws \Throwable + */ + public function archive(Request $request, string $uuid) + { + $server = $this->repository->getByUuid($uuid); + + // 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); + } + + $server->node_id = $server->transfer->new_node; + + $data = $this->configurationStructureService->handle($server); + $data['suspended'] = false; + $data['service']['skip_scripts'] = true; + + $allocations = $server->getAllocationMappings(); + $data['allocations']['default']['ip'] = array_key_first($allocations); + $data['allocations']['default']['port'] = $allocations[$data['allocations']['default']['ip']][0]; + + $now = Chronos::now(); + $signer = new 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->daemonSecret)); + + // 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 { + $this->daemonTransferRepository + ->setServer($server) + ->setNode($this->nodeRepository->find($server->transfer->new_node)) + ->notify($server, $data, $server->node, $token->__toString()); + } catch (DaemonConnectionException $exception) { + throw $exception; + } + + return JsonResponse::create([], Response::HTTP_NO_CONTENT); + } + + /** + * The daemon notifies us about a transfer failure. + * + * @param \Illuminate\Http\Request $request + * @param string $uuid + * @return \Illuminate\Http\JsonResponse + * + * @throws \Throwable + */ + 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); + } + + /** + * The daemon notifies us about a transfer success. + * + * @param string $uuid + * @return \Illuminate\Http\JsonResponse + * + * @throws \Throwable + */ + public function success(string $uuid) + { + $server = $this->repository->getByUuid($uuid); + $transfer = $server->transfer; + + $allocationIds = json_decode($transfer->old_additional_allocations); + array_push($allocationIds, $transfer->old_allocation); + + // Begin a transaction. + $this->connection->beginTransaction(); + + // Remove the old allocations. + $this->allocationRepository->updateWhereIn('id', $allocationIds, ['server_id' => null]); + + // Update the server's allocation_id and node_id. + $server->allocation_id = $transfer->new_allocation; + $server->node_id = $transfer->new_node; + $server->save(); + + // Mark the transfer as successful. + $transfer->successful = true; + $transfer->save(); + + // Commit the transaction. + $this->connection->commit(); + + // Delete the server from the old node + try { + $this->daemonServerRepository->setServer($server)->delete(); + } catch (DaemonConnectionException $exception) { + $this->writer->warning($exception); + } + + // Unsuspend the server + $server->load('node'); + $this->suspensionService->toggle($server, $this->suspensionService::ACTION_UNSUSPEND); + + return JsonResponse::create([], Response::HTTP_NO_CONTENT); + } +} diff --git a/app/Models/Node.php b/app/Models/Node.php index ca87e78e0..9518084c5 100644 --- a/app/Models/Node.php +++ b/app/Models/Node.php @@ -171,6 +171,7 @@ class Node extends Model ], 'system' => [ 'data' => $this->daemonBase, + 'archive_directory' => $this->daemonBase . '/.archives', 'username' => 'pterodactyl', 'timezone_path' => '/etc/timezone', 'set_permissions_on_boot' => true, @@ -236,4 +237,19 @@ class Node extends Model { return $this->hasMany(Allocation::class); } + + /** + * Returns a boolean if the node is viable for an additional server to be placed on it. + * + * @param int $memory + * @param int $disk + * @return bool + */ + public function isViable(int $memory, int $disk): bool + { + $memoryLimit = $this->memory * (1 + ($this->memory_overallocate / 100)); + $diskLimit = $this->disk * (1 + ($this->disk_overallocate / 100)); + + return ($this->sum_memory + $memory) <= $memoryLimit && ($this->sum_disk + $disk) <= $diskLimit; + } } diff --git a/app/Models/Server.php b/app/Models/Server.php index 9f5495028..97fea38a8 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -51,6 +51,7 @@ use Znck\Eloquent\Traits\BelongsToThrough; * @property \Pterodactyl\Models\Location $location * @property \Pterodactyl\Models\DaemonKey $key * @property \Pterodactyl\Models\DaemonKey[]|\Illuminate\Database\Eloquent\Collection $keys + * @property \Pterodactyl\Models\ServerTransfer $transfer * @property \Pterodactyl\Models\Backup[]|\Illuminate\Database\Eloquent\Collection $backups */ class Server extends Model @@ -186,7 +187,7 @@ class Server extends Model */ public function getAllocationMappings(): array { - return $this->allocations->groupBy('ip')->map(function ($item) { + return $this->allocations->where('node_id', $this->node_id)->groupBy('ip')->map(function ($item) { return $item->pluck('port'); })->toArray(); } @@ -341,6 +342,16 @@ class Server extends Model return $this->hasMany(DaemonKey::class); } + /** + * Returns the associated server transfer. + * + * @return \Illuminate\Database\Eloquent\Relations\HasOne + */ + public function transfer() + { + return $this->hasOne(ServerTransfer::class)->orderByDesc('id'); + } + /** * @return \Illuminate\Database\Eloquent\Relations\HasMany */ diff --git a/app/Models/ServerTransfer.php b/app/Models/ServerTransfer.php new file mode 100644 index 000000000..94072bf0c --- /dev/null +++ b/app/Models/ServerTransfer.php @@ -0,0 +1,81 @@ + 'int', + 'old_node' => 'int', + 'new_node' => 'int', + 'old_allocation' => 'int', + 'new_allocation' => 'int', + 'old_additional_allocations' => 'string', + 'new_additional_allocations' => 'string', + 'successful' => 'bool', + ]; + + /** + * @var array + */ + public static $validationRules = [ + 'server_id' => 'required|numeric|exists:servers,id', + 'old_node' => 'required|numeric', + 'new_node' => 'required|numeric', + 'old_allocation' => 'required|numeric', + 'new_allocation' => 'required|numeric', + 'old_additional_allocations' => 'nullable', + 'new_additional_allocations' => 'nullable', + 'successful' => 'sometimes|boolean', + ]; + + /** + * Gets the server associated with a server transfer. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function server() + { + return $this->belongsTo(Server::class); + } +} diff --git a/app/Repositories/Eloquent/NodeRepository.php b/app/Repositories/Eloquent/NodeRepository.php index 89abbeeb3..6f317bc81 100644 --- a/app/Repositories/Eloquent/NodeRepository.php +++ b/app/Repositories/Eloquent/NodeRepository.php @@ -174,6 +174,23 @@ class NodeRepository extends EloquentRepository implements NodeRepositoryInterfa })->values(); } + /** + * Returns a node with the given id with the Node's resource usage. + * + * @param int $node_id + * @return Node + */ + public function getNodeWithResourceUsage(int $node_id): Node + { + $instance = $this->getBuilder() + ->select(['nodes.id', 'nodes.fqdn', 'nodes.scheme', 'nodes.daemonSecret', 'nodes.daemonListen', 'nodes.memory', 'nodes.disk', 'nodes.memory_overallocate', 'nodes.disk_overallocate']) + ->selectRaw('IFNULL(SUM(servers.memory), 0) as sum_memory, IFNULL(SUM(servers.disk), 0) as sum_disk') + ->leftJoin('servers', 'servers.node_id', '=', 'nodes.id') + ->where('nodes.id', $node_id); + + return $instance->first(); + } + /** * Return the IDs of all nodes that exist in the provided locations and have the space * available to support the additional disk and memory provided. diff --git a/app/Repositories/Eloquent/UserRepository.php b/app/Repositories/Eloquent/UserRepository.php index b69df198c..1ed6c5b74 100644 --- a/app/Repositories/Eloquent/UserRepository.php +++ b/app/Repositories/Eloquent/UserRepository.php @@ -29,7 +29,7 @@ class UserRepository extends EloquentRepository implements UserRepositoryInterfa */ public function getAllUsersWithCounts(): LengthAwarePaginator { - return $this->getBuilder()->withCount('servers', 'subuserOf') + return $this->getBuilder()->withCount('servers') ->search($this->getSearchTerm()) ->paginate(50, $this->getColumns()); } diff --git a/app/Repositories/Wings/DaemonServerRepository.php b/app/Repositories/Wings/DaemonServerRepository.php index 3461e4cc9..b41c75483 100644 --- a/app/Repositories/Wings/DaemonServerRepository.php +++ b/app/Repositories/Wings/DaemonServerRepository.php @@ -124,4 +124,24 @@ class DaemonServerRepository extends DaemonRepository throw new DaemonConnectionException($exception); } } + + /** + * Requests the daemon to create a full archive of the server. + * Once the daemon is finished they will send a POST request to + * "/api/remote/servers/{uuid}/archive" with a boolean. + * + * @throws DaemonConnectionException + */ + public function requestArchive(): void + { + Assert::isInstanceOf($this->server, Server::class); + + try { + $this->getHttpClient()->post(sprintf( + '/api/servers/%s/archive', $this->server->uuid + )); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + } } diff --git a/app/Repositories/Wings/DaemonTransferRepository.php b/app/Repositories/Wings/DaemonTransferRepository.php new file mode 100644 index 000000000..e8a514196 --- /dev/null +++ b/app/Repositories/Wings/DaemonTransferRepository.php @@ -0,0 +1,35 @@ +getHttpClient()->post('/api/transfer', [ + 'json' => [ + 'server_id' => $server->uuid, + 'url' => $node->getConnectionAddress() . sprintf('/api/servers/%s/archive', $server->uuid), + 'token' => 'Bearer ' . $token, + 'server' => $data, + ], + ]); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + } +} diff --git a/app/Services/Servers/ServerCreationService.php b/app/Services/Servers/ServerCreationService.php index b7b6fdb22..cf0900282 100644 --- a/app/Services/Servers/ServerCreationService.php +++ b/app/Services/Servers/ServerCreationService.php @@ -4,7 +4,6 @@ namespace Pterodactyl\Services\Servers; use Ramsey\Uuid\Uuid; use Illuminate\Support\Arr; -use Pterodactyl\Models\Node; use Pterodactyl\Models\User; use Pterodactyl\Models\Server; use Illuminate\Support\Collection; diff --git a/app/Services/Servers/TransferService.php b/app/Services/Servers/TransferService.php new file mode 100644 index 000000000..b45a78dc8 --- /dev/null +++ b/app/Services/Servers/TransferService.php @@ -0,0 +1,46 @@ +repository = $repository; + $this->daemonServerRepository = $daemonServerRepository; + } + + /** + * Requests an archive from the daemon. + * + * @param int|\Pterodactyl\Models\Server $server + * + * @throws \Throwable + */ + public function requestArchive(Server $server) + { + $this->daemonServerRepository->setServer($server)->requestArchive(); + } +} diff --git a/database/migrations/2020_04_04_131016_add_table_server_transfers.php b/database/migrations/2020_04_04_131016_add_table_server_transfers.php new file mode 100644 index 000000000..1ce37c26d --- /dev/null +++ b/database/migrations/2020_04_04_131016_add_table_server_transfers.php @@ -0,0 +1,44 @@ +increments('id'); + $table->integer('server_id')->unsigned(); + $table->integer('old_node')->unsigned(); + $table->integer('new_node')->unsigned(); + $table->integer('old_allocation')->unsigned(); + $table->integer('new_allocation')->unsigned(); + $table->string('old_additional_allocations')->nullable(); + $table->string('new_additional_allocations')->nullable(); + $table->timestamps(); + }); + + Schema::table('server_transfers', function (Blueprint $table) { + $table->foreign('server_id')->references('id')->on('servers'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('server_transfers'); + } +} diff --git a/database/migrations/2020_04_04_172331_add_successful_column_to_server_transfers.php b/database/migrations/2020_04_04_172331_add_successful_column_to_server_transfers.php new file mode 100644 index 000000000..83f183abf --- /dev/null +++ b/database/migrations/2020_04_04_172331_add_successful_column_to_server_transfers.php @@ -0,0 +1,32 @@ +tinyInteger('successful')->unsigned()->default(0); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('server_transfers', function (Blueprint $table) { + $table->dropColumn('successful'); + }); + } +} diff --git a/public/themes/pterodactyl/js/admin/server/transfer.js b/public/themes/pterodactyl/js/admin/server/transfer.js new file mode 100644 index 000000000..5c2664a86 --- /dev/null +++ b/public/themes/pterodactyl/js/admin/server/transfer.js @@ -0,0 +1,56 @@ +$(document).ready(function () { + $('#pNodeId').select2({ + placeholder: 'Select a Node', + }).change(); + + $('#pAllocation').select2({ + placeholder: 'Select a Default Allocation', + }); + + $('#pAllocationAdditional').select2({ + placeholder: 'Select Additional Allocations', + }); +}); + +$('#pNodeId').on('change', function () { + let currentNode = $(this).val(); + + $.each(Pterodactyl.nodeData, function (i, v) { + if (v.id == currentNode) { + $('#pAllocation').html('').select2({ + data: v.allocations, + placeholder: 'Select a Default Allocation', + }); + + updateAdditionalAllocations(); + } + }); +}); + +$('#pAllocation').on('change', function () { + updateAdditionalAllocations(); +}); + +function updateAdditionalAllocations() { + let currentAllocation = $('#pAllocation').val(); + let currentNode = $('#pNodeId').val(); + + $.each(Pterodactyl.nodeData, function (i, v) { + if (v.id == currentNode) { + let allocations = []; + + for (let i = 0; i < v.allocations.length; i++) { + const allocation = v.allocations[i]; + + if (allocation.id != currentAllocation) { + allocations.push(allocation); + } + } + + $('#pAllocationAdditional').html('').select2({ + data: allocations, + placeholder: 'Select Additional Allocations', + }); + } + }); +} diff --git a/resources/lang/en/admin/server.php b/resources/lang/en/admin/server.php index fa254c8d9..a697d4e9e 100644 --- a/resources/lang/en/admin/server.php +++ b/resources/lang/en/admin/server.php @@ -27,5 +27,8 @@ return [ 'details_updated' => 'Server details have been successfully updated.', 'docker_image_updated' => 'Successfully changed the default Docker image to use for this server. A reboot is required to apply this change.', 'node_required' => 'You must have at least one node configured before you can add a server to this panel.', + 'transfer_nodes_required' => 'You must have at least two nodes configured before you can transfer servers.', + 'transfer_started' => 'Server transfer has been started.', + 'transfer_not_viable' => 'The node you selected is not viable for this transfer.', ], ]; diff --git a/resources/views/admin/servers/view/manage.blade.php b/resources/views/admin/servers/view/manage.blade.php index d84555acb..8c41fc503 100644 --- a/resources/views/admin/servers/view/manage.blade.php +++ b/resources/views/admin/servers/view/manage.blade.php @@ -20,80 +20,164 @@ @endsection @section('content') -@include('admin.servers.partials.navigation') -
This will reinstall the server with the assigned pack and service scripts. Danger! This could overwrite server data.
-