diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index adc36412a..d47663334 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -51,7 +51,7 @@ jobs: - name: install dependencies run: composer install --prefer-dist --no-interaction --no-progress - name: run cs-fixer - run: vendor/bin/php-cs-fixer fix --dry-run --diff --diff-format=udiff --rules=psr_autoloading + run: vendor/bin/php-cs-fixer fix --dry-run --diff --diff-format=udiff --config .php_cs.dist continue-on-error: true - name: execute unit tests run: vendor/bin/phpunit --bootstrap bootstrap/app.php tests/Unit diff --git a/app/Console/Commands/Server/BulkPowerActionCommand.php b/app/Console/Commands/Server/BulkPowerActionCommand.php index 460df7194..01607bece 100644 --- a/app/Console/Commands/Server/BulkPowerActionCommand.php +++ b/app/Console/Commands/Server/BulkPowerActionCommand.php @@ -89,9 +89,7 @@ class BulkPowerActionCommand extends Command */ protected function getQueryBuilder(array $servers, array $nodes) { - $instance = Server::query() - ->where('suspended', false) - ->where('installed', Server::STATUS_INSTALLED); + $instance = Server::query()->whereNull('status'); if (!empty($nodes) && !empty($servers)) { $instance->whereIn('id', $servers)->orWhereIn('node_id', $nodes); diff --git a/app/Contracts/Repository/ServerRepositoryInterface.php b/app/Contracts/Repository/ServerRepositoryInterface.php index 323e1cd36..fc3e6ff1a 100644 --- a/app/Contracts/Repository/ServerRepositoryInterface.php +++ b/app/Contracts/Repository/ServerRepositoryInterface.php @@ -66,11 +66,6 @@ interface ServerRepositoryInterface extends RepositoryInterface */ public function isUniqueUuidCombo(string $uuid, string $short): bool; - /** - * Get the amount of servers that are suspended. - */ - public function getSuspendedServersCount(): int; - /** * Returns all of the servers that exist for a given node in a paginated response. */ diff --git a/app/Exceptions/Http/Connection/DaemonConnectionException.php b/app/Exceptions/Http/Connection/DaemonConnectionException.php index 94778c033..eba5f8a7f 100644 --- a/app/Exceptions/Http/Connection/DaemonConnectionException.php +++ b/app/Exceptions/Http/Connection/DaemonConnectionException.php @@ -2,8 +2,8 @@ namespace Pterodactyl\Exceptions\Http\Connection; -use Illuminate\Support\Arr; use Illuminate\Http\Response; +use Illuminate\Support\Facades\Log; use GuzzleHttp\Exception\GuzzleException; use Pterodactyl\Exceptions\DisplayException; @@ -17,6 +17,16 @@ class DaemonConnectionException extends DisplayException */ private $statusCode = Response::HTTP_GATEWAY_TIMEOUT; + /** + * Every request to the Wings instance will return a unique X-Request-Id header + * which allows for all errors to be efficiently tied to a specific request that + * triggered them, and gives users a more direct method of informing hosts when + * something goes wrong. + * + * @var string|null + */ + private $requestId; + /** * Throw a displayable exception caused by a daemon connection error. */ @@ -24,23 +34,23 @@ class DaemonConnectionException extends DisplayException { /** @var \GuzzleHttp\Psr7\Response|null $response */ $response = method_exists($previous, 'getResponse') ? $previous->getResponse() : null; + $this->requestId = $response ? $response->getHeaderLine('X-Request-Id') : null; if ($useStatusCode) { $this->statusCode = is_null($response) ? $this->statusCode : $response->getStatusCode(); } - $message = trans('admin/server.exceptions.daemon_exception', [ - 'code' => is_null($response) ? 'E_CONN_REFUSED' : $response->getStatusCode(), - ]); + if (is_null($response)) { + $message = 'Could not establish a connection to the machine running this server. Please try again.'; + } else { + $message = sprintf('There was an error while communicating with the machine running this server. This error has been logged, please try again. (code: %s) (request_id: %s)', $response->getStatusCode(), $this->requestId ?? ''); + } // Attempt to pull the actual error message off the response and return that if it is not // a 500 level error. if ($this->statusCode < 500 && !is_null($response)) { - $body = $response->getBody(); - if (is_string($body) || (is_object($body) && method_exists($body, '__toString'))) { - $body = json_decode(is_string($body) ? $body : $body->__toString(), true); - $message = '[Wings Error]: ' . Arr::get($body, 'error', $message); - } + $body = json_decode($response->getBody()->__toString(), true); + $message = sprintf('An error occurred on the remote host: %s. (request id: %s)', $body['error'] ?? $message, $this->requestId ?? ''); } $level = $this->statusCode >= 500 && $this->statusCode !== 504 @@ -50,6 +60,19 @@ class DaemonConnectionException extends DisplayException parent::__construct($message, $previous, $level); } + /** + * Override the default reporting method for DisplayException by just logging immediately + * here and including the specific X-Request-Id header that was returned by the call. + * + * @return void + */ + public function report() + { + Log::{$this->getErrorLevel()}($this->getPrevious(), [ + 'request_id' => $this->requestId, + ]); + } + /** * Return the HTTP status code for this exception. * @@ -59,4 +82,12 @@ class DaemonConnectionException extends DisplayException { return $this->statusCode; } + + /** + * @return string|null + */ + public function getRequestId() + { + return $this->requestId; + } } diff --git a/app/Exceptions/Http/Server/ServerStateConflictException.php b/app/Exceptions/Http/Server/ServerStateConflictException.php new file mode 100644 index 000000000..f0eb096b1 --- /dev/null +++ b/app/Exceptions/Http/Server/ServerStateConflictException.php @@ -0,0 +1,30 @@ +isSuspended()) { + $message = 'This server is currently suspended and the functionality requested is unavailable.'; + } elseif (!$server->isInstalled()) { + $message = 'This server has not yet completed its installation process, please try again later.'; + } elseif ($server->status === Server::STATUS_RESTORING_BACKUP) { + $message = 'This server is currently restoring from a backup, please try again later.'; + } elseif (!is_null($server->transfer)) { + $message = 'This server is currently being transferred to a new machine, please try again later.'; + } + + parent::__construct($message, $previous); + } +} diff --git a/app/Exceptions/Http/Server/ServerTransferringException.php b/app/Exceptions/Http/Server/ServerTransferringException.php deleted file mode 100644 index d36fb8de4..000000000 --- a/app/Exceptions/Http/Server/ServerTransferringException.php +++ /dev/null @@ -1,17 +0,0 @@ -repository = $repository; $this->initiateBackupService = $initiateBackupService; $this->deleteBackupService = $deleteBackupService; - $this->repository = $repository; + $this->downloadLinkService = $downloadLinkService; } /** * Returns all of the backups for a given server instance in a paginated * result set. * - * @return array + * @throws \Spatie\Fractalistic\Exceptions\InvalidTransformation + * @throws \Spatie\Fractalistic\Exceptions\NoTransformerSpecified + * @throws \Illuminate\Auth\Access\AuthorizationException */ - public function index(GetBackupsRequest $request, Server $server) + public function index(Request $request, Server $server): array { + if (!$request->user()->can(Permission::ACTION_BACKUP_READ, $server)) { + throw new AuthorizationException(); + } + $limit = min($request->query('per_page') ?? 20, 50); return $this->fractal->collection($server->backups()->paginate($limit)) @@ -64,17 +81,24 @@ class BackupController extends ClientApiController /** * Starts the backup process for a server. * - * @return array - * - * @throws \Exception|\Throwable + * @throws \Spatie\Fractalistic\Exceptions\InvalidTransformation + * @throws \Spatie\Fractalistic\Exceptions\NoTransformerSpecified + * @throws \Throwable */ - public function store(StoreBackupRequest $request, Server $server) + public function store(StoreBackupRequest $request, Server $server): array { - $backup = $this->initiateBackupService - ->setIgnoredFiles( - explode(PHP_EOL, $request->input('ignored') ?? '') - ) - ->handle($server, $request->input('name')); + /** @var \Pterodactyl\Models\Backup $backup */ + $backup = $server->audit(AuditLog::SERVER__BACKUP_STARTED, function (AuditLog $model, Server $server) use ($request) { + $backup = $this->initiateBackupService + ->setIgnoredFiles( + explode(PHP_EOL, $request->input('ignored') ?? '') + ) + ->handle($server, $request->input('name')); + + $model->metadata = ['backup_uuid' => $backup->uuid]; + + return $backup; + }); return $this->fractal->item($backup) ->transformWith($this->getTransformer(BackupTransformer::class)) @@ -84,10 +108,16 @@ class BackupController extends ClientApiController /** * Returns information about a single backup. * - * @return array + * @throws \Spatie\Fractalistic\Exceptions\InvalidTransformation + * @throws \Spatie\Fractalistic\Exceptions\NoTransformerSpecified + * @throws \Illuminate\Auth\Access\AuthorizationException */ - public function view(GetBackupsRequest $request, Server $server, Backup $backup) + public function view(Request $request, Server $server, Backup $backup): array { + if (!$request->user()->can(Permission::ACTION_BACKUP_READ, $server)) { + throw new AuthorizationException(); + } + return $this->fractal->item($backup) ->transformWith($this->getTransformer(BackupTransformer::class)) ->toArray(); @@ -97,14 +127,91 @@ class BackupController extends ClientApiController * Deletes a backup from the panel as well as the remote source where it is currently * being stored. * - * @return \Illuminate\Http\JsonResponse + * @throws \Throwable + */ + public function delete(Request $request, Server $server, Backup $backup): JsonResponse + { + if (!$request->user()->can(Permission::ACTION_BACKUP_DELETE, $server)) { + throw new AuthorizationException(); + } + + $server->audit(AuditLog::SERVER__BACKUP_DELETED, function (AuditLog $audit) use ($backup) { + $audit->metadata = ['backup_uuid' => $backup->uuid]; + + $this->deleteBackupService->handle($backup); + }); + + return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT); + } + + /** + * Download the backup for a given server instance. For daemon local files, the file + * will be streamed back through the Panel. For AWS S3 files, a signed URL will be generated + * which the user is redirected to. + * + * @throws \Illuminate\Auth\Access\AuthorizationException + */ + public function download(Request $request, Server $server, Backup $backup): JsonResponse + { + if (!$request->user()->can(Permission::ACTION_BACKUP_DOWNLOAD, $server)) { + throw new AuthorizationException(); + } + + switch ($backup->disk) { + case Backup::ADAPTER_WINGS: + case Backup::ADAPTER_AWS_S3: + return new JsonResponse([ + 'object' => 'signed_url', + 'attributes' => ['url' => ''], + ]); + default: + throw new BadRequestHttpException(); + } + } + + /** + * Handles restoring a backup by making a request to the Wings instance telling it + * to begin the process of finding (or downloading) the backup and unpacking it + * over the server files. + * + * If the "truncate" flag is passed through in this request then all of the + * files that currently exist on the server will be deleted before restoring. + * Otherwise the archive will simply be unpacked over the existing files. * * @throws \Throwable */ - public function delete(DeleteBackupRequest $request, Server $server, Backup $backup) + public function restore(Request $request, Server $server, Backup $backup): JsonResponse { - $this->deleteBackupService->handle($backup); + if (!$request->user()->can(Permission::ACTION_BACKUP_RESTORE, $server)) { + throw new AuthorizationException(); + } - return JsonResponse::create([], JsonResponse::HTTP_NO_CONTENT); + // Cannot restore a backup unless a server is fully installed and not currently + // processing a different backup restoration request. + if (!is_null($server->status)) { + throw new BadRequestHttpException('This server is not currently in a state that allows for a backup to be restored.'); + } + + if (!$backup->is_successful && !$backup->completed_at) { + throw new BadRequestHttpException('This backup cannot be restored at this time: not completed or failed.'); + } + + $server->audit(AuditLog::SERVER__BACKUP_RESTORE_STARTED, function (AuditLog $audit, Server $server) use ($backup, $request) { + $audit->metadata = ['backup_uuid' => $backup->uuid]; + + // If the backup is for an S3 file we need to generate a unique Download link for + // it that will allow Wings to actually access the file. + if ($backup->disk === Backup::ADAPTER_AWS_S3) { + $url = $this->downloadLinkService->handle($backup, $request->user()); + } + + // Update the status right away for the server so that we know not to allow certain + // actions against it via the Panel API. + $server->update(['status' => Server::STATUS_RESTORING_BACKUP]); + + $this->repository->setServer($server)->restore($backup, $url ?? null, $request->input('truncate') === 'true'); + }); + + return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT); } } diff --git a/app/Http/Controllers/Api/Client/Servers/DownloadBackupController.php b/app/Http/Controllers/Api/Client/Servers/DownloadBackupController.php deleted file mode 100644 index fb7bc4717..000000000 --- a/app/Http/Controllers/Api/Client/Servers/DownloadBackupController.php +++ /dev/null @@ -1,131 +0,0 @@ -daemonBackupRepository = $daemonBackupRepository; - $this->responseFactory = $responseFactory; - $this->jwtService = $jwtService; - $this->backupManager = $backupManager; - } - - /** - * Download the backup for a given server instance. For daemon local files, the file - * will be streamed back through the Panel. For AWS S3 files, a signed URL will be generated - * which the user is redirected to. - * - * @return \Illuminate\Http\JsonResponse - */ - public function __invoke(DownloadBackupRequest $request, Server $server, Backup $backup) - { - switch ($backup->disk) { - case Backup::ADAPTER_WINGS: - $url = $this->getLocalBackupUrl($backup, $server, $request->user()); - break; - case Backup::ADAPTER_AWS_S3: - $url = $this->getS3BackupUrl($backup, $server); - break; - default: - throw new BadRequestHttpException(); - } - - return new JsonResponse([ - 'object' => 'signed_url', - 'attributes' => [ - 'url' => $url, - ], - ]); - } - - /** - * Returns a signed URL that allows us to download a file directly out of a non-public - * S3 bucket by using a signed URL. - * - * @return string - */ - protected function getS3BackupUrl(Backup $backup, Server $server) - { - /** @var \League\Flysystem\AwsS3v3\AwsS3Adapter $adapter */ - $adapter = $this->backupManager->adapter(Backup::ADAPTER_AWS_S3); - - $client = $adapter->getClient(); - - $request = $client->createPresignedRequest( - $client->getCommand('GetObject', [ - 'Bucket' => $adapter->getBucket(), - 'Key' => sprintf('%s/%s.tar.gz', $server->uuid, $backup->uuid), - 'ContentType' => 'application/x-gzip', - ]), - CarbonImmutable::now()->addMinutes(5) - ); - - return $request->getUri()->__toString(); - } - - /** - * Returns a download link a backup stored on a wings instance. - * - * @return string - */ - protected function getLocalBackupUrl(Backup $backup, Server $server, User $user) - { - $token = $this->jwtService - ->setExpiresAt(CarbonImmutable::now()->addMinutes(15)->toDateTimeImmutable()) - ->setClaims([ - 'backup_uuid' => $backup->uuid, - 'server_uuid' => $server->uuid, - ]) - ->handle($server->node, $user->id . $server->uuid); - - return sprintf( - '%s/download/backup?token=%s', - $server->node->getConnectionAddress(), - $token->toString() - ); - } -} diff --git a/app/Http/Controllers/Api/Client/Servers/FileController.php b/app/Http/Controllers/Api/Client/Servers/FileController.php index b3d697266..9ff503fd7 100644 --- a/app/Http/Controllers/Api/Client/Servers/FileController.php +++ b/app/Http/Controllers/Api/Client/Servers/FileController.php @@ -5,6 +5,7 @@ namespace Pterodactyl\Http\Controllers\Api\Client\Servers; use Carbon\CarbonImmutable; use Illuminate\Http\Response; use Pterodactyl\Models\Server; +use Pterodactyl\Models\AuditLog; use Illuminate\Http\JsonResponse; use Pterodactyl\Services\Nodes\NodeJWTService; use Illuminate\Contracts\Routing\ResponseFactory; @@ -74,19 +75,16 @@ class FileController extends ClientApiController /** * Return the contents of a specified file for the user. * - * @throws \Pterodactyl\Exceptions\Http\Server\FileSizeTooLargeException - * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException + * @throws \Throwable */ public function contents(GetFileContentsRequest $request, Server $server): Response { - return new Response( - $this->fileRepository->setServer($server)->getContent( - $request->get('file'), - config('pterodactyl.files.max_edit_size') - ), - Response::HTTP_OK, - ['Content-Type' => 'text/plain'] + $response = $this->fileRepository->setServer($server)->getContent( + $request->get('file'), + config('pterodactyl.files.max_edit_size') ); + + return new Response($response, Response::HTTP_OK, ['Content-Type' => 'text/plain']); } /** @@ -95,17 +93,21 @@ class FileController extends ClientApiController * * @return array * - * @throws \Exception + * @throws \Throwable */ public function download(GetFileContentsRequest $request, Server $server) { - $token = $this->jwtService - ->setExpiresAt(CarbonImmutable::now()->addMinutes(15)->toDateTimeImmutable()) - ->setClaims([ - 'file_path' => rawurldecode($request->get('file')), - 'server_uuid' => $server->uuid, - ]) - ->handle($server->node, $request->user()->id . $server->uuid); + $token = $server->audit(AuditLog::SERVER__FILESYSTEM_DOWNLOAD, function (AuditLog $audit, Server $server) use ($request) { + $audit->metadata = ['file' => $request->get('file')]; + + return $this->jwtService + ->setExpiresAt(CarbonImmutable::now()->addMinutes(15)) + ->setClaims([ + 'file_path' => rawurldecode($request->get('file')), + 'server_uuid' => $server->uuid, + ]) + ->handle($server->node, $request->user()->id . $server->uuid); + }); return [ 'object' => 'signed_url', @@ -126,7 +128,14 @@ class FileController extends ClientApiController */ public function write(WriteFileContentRequest $request, Server $server): JsonResponse { - $this->fileRepository->setServer($server)->putContent($request->get('file'), $request->getContent()); + $server->audit(AuditLog::SERVER__FILESYSTEM_WRITE, function (AuditLog $audit, Server $server) use ($request) { + $audit->subaction = 'write_content'; + $audit->metadata = ['file' => $request->get('file')]; + + $this->fileRepository + ->setServer($server) + ->putContent($request->get('file'), $request->getContent()); + }); return new JsonResponse([], Response::HTTP_NO_CONTENT); } @@ -134,13 +143,18 @@ class FileController extends ClientApiController /** * Creates a new folder on the server. * - * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException + * @throws \Throwable */ public function create(CreateFolderRequest $request, Server $server): JsonResponse { - $this->fileRepository - ->setServer($server) - ->createDirectory($request->input('name'), $request->input('root', '/')); + $server->audit(AuditLog::SERVER__FILESYSTEM_WRITE, function (AuditLog $audit, Server $server) use ($request) { + $audit->subaction = 'create_folder'; + $audit->metadata = ['file' => $request->input('root', '/') . $request->input('name')]; + + $this->fileRepository + ->setServer($server) + ->createDirectory($request->input('name'), $request->input('root', '/')); + }); return new JsonResponse([], Response::HTTP_NO_CONTENT); } @@ -148,13 +162,17 @@ class FileController extends ClientApiController /** * Renames a file on the remote machine. * - * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException + * @throws \Throwable */ public function rename(RenameFileRequest $request, Server $server): JsonResponse { - $this->fileRepository - ->setServer($server) - ->renameFiles($request->input('root'), $request->input('files')); + $server->audit(AuditLog::SERVER__FILESYSTEM_RENAME, function (AuditLog $audit, Server $server) use ($request) { + $audit->metadata = ['root' => $request->input('root'), 'files' => $request->input('files')]; + + $this->fileRepository + ->setServer($server) + ->renameFiles($request->input('root'), $request->input('files')); + }); return new JsonResponse([], Response::HTTP_NO_CONTENT); } @@ -166,9 +184,14 @@ class FileController extends ClientApiController */ public function copy(CopyFileRequest $request, Server $server): JsonResponse { - $this->fileRepository - ->setServer($server) - ->copyFile($request->input('location')); + $server->audit(AuditLog::SERVER__FILESYSTEM_WRITE, function (AuditLog $audit, Server $server) use ($request) { + $audit->subaction = 'copy_file'; + $audit->metadata = ['file' => $request->input('location')]; + + $this->fileRepository + ->setServer($server) + ->copyFile($request->input('location')); + }); return new JsonResponse([], Response::HTTP_NO_CONTENT); } @@ -178,14 +201,18 @@ class FileController extends ClientApiController */ public function compress(CompressFilesRequest $request, Server $server): array { - // Allow up to five minutes for this request to process before timing out. - set_time_limit(300); + $file = $server->audit(AuditLog::SERVER__FILESYSTEM_COMPRESS, function (AuditLog $audit, Server $server) use ($request) { + // Allow up to five minutes for this request to process before timing out. + set_time_limit(300); - $file = $this->fileRepository->setServer($server) - ->compressFiles( - $request->input('root'), - $request->input('files') - ); + $audit->metadata = ['root' => $request->input('root'), 'files' => $request->input('files')]; + + return $this->fileRepository->setServer($server) + ->compressFiles( + $request->input('root'), + $request->input('files') + ); + }); return $this->fractal->item($file) ->transformWith($this->getTransformer(FileObjectTransformer::class)) @@ -197,11 +224,15 @@ class FileController extends ClientApiController */ public function decompress(DecompressFilesRequest $request, Server $server): JsonResponse { - // Allow up to five minutes for this request to process before timing out. - set_time_limit(300); + $file = $server->audit(AuditLog::SERVER__FILESYSTEM_DECOMPRESS, function (AuditLog $audit, Server $server) use ($request) { + // Allow up to five minutes for this request to process before timing out. + set_time_limit(300); - $this->fileRepository->setServer($server) - ->decompressFile($request->input('root'), $request->input('file')); + $audit->metadata = ['root' => $request->input('root'), 'files' => $request->input('file')]; + + $this->fileRepository->setServer($server) + ->decompressFile($request->input('root'), $request->input('file')); + }); return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT); } @@ -213,11 +244,15 @@ class FileController extends ClientApiController */ public function delete(DeleteFileRequest $request, Server $server): JsonResponse { - $this->fileRepository->setServer($server) - ->deleteFiles( - $request->input('root'), - $request->input('files') - ); + $server->audit(AuditLog::SERVER__FILESYSTEM_DELETE, function (AuditLog $audit, Server $server) use ($request) { + $audit->metadata = ['root' => $request->input('root'), 'files' => $request->input('files')]; + + $this->fileRepository->setServer($server) + ->deleteFiles( + $request->input('root'), + $request->input('files') + ); + }); return new JsonResponse([], Response::HTTP_NO_CONTENT); } @@ -243,11 +278,15 @@ class FileController extends ClientApiController * * @param $request * - * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException + * @throws \Throwable */ public function pull(PullFileRequest $request, Server $server): JsonResponse { - $this->fileRepository->setServer($server)->pull($request->input('url'), $request->input('directory')); + $server->audit(AuditLog::SERVER__FILESYSTEM_PULL, function (AuditLog $audit, Server $server) use ($request) { + $audit->metadata = ['directory' => $request->input('directory'), 'url' => $request->input('url')]; + + $this->fileRepository->setServer($server)->pull($request->input('url'), $request->input('directory')); + }); return new JsonResponse([], Response::HTTP_NO_CONTENT); } diff --git a/app/Http/Controllers/Api/Remote/Backups/BackupStatusController.php b/app/Http/Controllers/Api/Remote/Backups/BackupStatusController.php index 6f1959e95..3ec271186 100644 --- a/app/Http/Controllers/Api/Remote/Backups/BackupStatusController.php +++ b/app/Http/Controllers/Api/Remote/Backups/BackupStatusController.php @@ -3,7 +3,10 @@ namespace Pterodactyl\Http\Controllers\Api\Remote\Backups; use Carbon\CarbonImmutable; +use Illuminate\Http\Request; use Pterodactyl\Models\Backup; +use Pterodactyl\Models\Server; +use Pterodactyl\Models\AuditLog; use Illuminate\Http\JsonResponse; use League\Flysystem\AwsS3v3\AwsS3Adapter; use Pterodactyl\Exceptions\DisplayException; @@ -39,9 +42,9 @@ class BackupStatusController extends Controller * * @return \Illuminate\Http\JsonResponse * - * @throws \Exception + * @throws \Throwable */ - public function __invoke(ReportBackupCompleteRequest $request, string $backup) + public function index(ReportBackupCompleteRequest $request, string $backup) { /** @var \Pterodactyl\Models\Backup $model */ $model = Backup::query()->where('uuid', $backup)->firstOrFail(); @@ -50,21 +53,60 @@ class BackupStatusController extends Controller throw new BadRequestHttpException('Cannot update the status of a backup that is already marked as completed.'); } - $successful = $request->input('successful') ? true : false; + $action = $request->input('successful') + ? AuditLog::SERVER__BACKUP_COMPELTED + : AuditLog::SERVER__BACKUP_FAILED; - $model->fill([ - 'is_successful' => $successful, - 'checksum' => $successful ? ($request->input('checksum_type') . ':' . $request->input('checksum')) : null, - 'bytes' => $successful ? $request->input('size') : 0, - 'completed_at' => CarbonImmutable::now(), - ])->save(); + $model->server->audit($action, function (AuditLog $audit) use ($model, $request) { + $audit->is_system = true; + $audit->metadata = ['backup_uuid' => $model->uuid]; - // Check if we are using the s3 backup adapter. If so, make sure we mark the backup as - // being completed in S3 correctly. - $adapter = $this->backupManager->adapter(); - if ($adapter instanceof AwsS3Adapter) { - $this->completeMultipartUpload($model, $adapter, $successful); - } + $successful = $request->input('successful') ? true : false; + $model->fill([ + 'is_successful' => $successful, + 'checksum' => $successful ? ($request->input('checksum_type') . ':' . $request->input('checksum')) : null, + 'bytes' => $successful ? $request->input('size') : 0, + 'completed_at' => CarbonImmutable::now(), + ])->save(); + + // Check if we are using the s3 backup adapter. If so, make sure we mark the backup as + // being completed in S3 correctly. + $adapter = $this->backupManager->adapter(); + if ($adapter instanceof AwsS3Adapter) { + $this->completeMultipartUpload($model, $adapter, $successful); + } + }); + + return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT); + } + + /** + * Handles toggling the restoration status of a server. The server status field should be + * set back to null, even if the restoration failed. This is not an unsolvable state for + * the server, and the user can keep trying to restore, or just use the reinstall button. + * + * The only thing the successful field does is update the entry value for the audit logs + * table tracking for this restoration. + * + * @return \Illuminate\Http\JsonResponse + * + * @throws \Throwable + */ + public function restore(Request $request, string $backup) + { + /** @var \Pterodactyl\Models\Backup $model */ + $model = Backup::query()->where('uuid', $backup)->firstOrFail(); + $action = $request->get('successful') + ? AuditLog::SERVER__BACKUP_RESTORE_COMPLETED + : AuditLog::SERVER__BACKUP_RESTORE_FAILED; + + // Just create a new audit entry for this event and update the server state + // so that power actions, file management, and backups can resume as normal. + $model->server->audit($action, function (AuditLog $audit, Server $server) use ($backup) { + $audit->is_system = true; + $audit->metadata = ['backup_uuid' => $backup]; + $server->update(['status' => null]); + }); return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT); } diff --git a/app/Http/Controllers/Api/Remote/Servers/ServerInstallController.php b/app/Http/Controllers/Api/Remote/Servers/ServerInstallController.php index 8009742c5..e2217adff 100644 --- a/app/Http/Controllers/Api/Remote/Servers/ServerInstallController.php +++ b/app/Http/Controllers/Api/Remote/Servers/ServerInstallController.php @@ -4,6 +4,7 @@ namespace Pterodactyl\Http\Controllers\Api\Remote\Servers; use Illuminate\Http\Request; use Illuminate\Http\Response; +use Pterodactyl\Models\Server; use Illuminate\Http\JsonResponse; use Pterodactyl\Http\Controllers\Controller; use Pterodactyl\Repositories\Eloquent\ServerRepository; @@ -55,10 +56,13 @@ class ServerInstallController extends Controller { $server = $this->repository->getByUuid($uuid); - $this->repository->update($server->id, [ - 'installed' => (string) $request->input('successful') === '1' ? 1 : 2, - ], true, true); + $status = $request->input('successful') === '1' ? null : Server::STATUS_INSTALL_FAILED; + if ($server->status === Server::STATUS_SUSPENDED) { + $status = Server::STATUS_SUSPENDED; + } - return JsonResponse::create([], Response::HTTP_NO_CONTENT); + $this->repository->update($server->id, ['status' => $status], true, true); + + 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 461dade4f..4a04c3089 100644 --- a/app/Http/Controllers/Api/Remote/SftpAuthenticationController.php +++ b/app/Http/Controllers/Api/Remote/SftpAuthenticationController.php @@ -12,8 +12,6 @@ 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; @@ -98,16 +96,7 @@ class SftpAuthenticationController extends Controller } } - // 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('Server is not installed or is currently suspended.'); - } + $server->validateCurrentState(); return new JsonResponse([ 'server' => $server->uuid, diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 88df47e1f..d2e0f2cc7 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -28,7 +28,6 @@ use Pterodactyl\Http\Middleware\Api\AuthenticateIPAccess; use Pterodactyl\Http\Middleware\Api\ApiSubstituteBindings; use Illuminate\Foundation\Http\Middleware\ValidatePostSize; use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse; -use Pterodactyl\Http\Middleware\Server\AccessingValidServer; use Pterodactyl\Http\Middleware\Api\Daemon\DaemonAuthenticate; use Pterodactyl\Http\Middleware\RequireTwoFactorAuthentication; use Illuminate\Foundation\Http\Middleware\CheckForMaintenanceMode; @@ -106,7 +105,6 @@ class Kernel extends HttpKernel 'auth' => Authenticate::class, 'auth.basic' => AuthenticateWithBasicAuth::class, 'guest' => RedirectIfAuthenticated::class, - 'server' => AccessingValidServer::class, 'admin' => AdminAuthenticate::class, 'csrf' => VerifyCsrfToken::class, 'throttle' => ThrottleRequests::class, diff --git a/app/Http/Middleware/Admin/Servers/ServerInstalled.php b/app/Http/Middleware/Admin/Servers/ServerInstalled.php index 919d27ad6..baf88af2d 100644 --- a/app/Http/Middleware/Admin/Servers/ServerInstalled.php +++ b/app/Http/Middleware/Admin/Servers/ServerInstalled.php @@ -25,7 +25,7 @@ class ServerInstalled throw new NotFoundHttpException('No server resource was located in the request parameters.'); } - if ($server->installed !== 1) { + if (!$server->isInstalled()) { throw new HttpException(Response::HTTP_FORBIDDEN, 'Access to this resource is not allowed due to the current installation state.'); } diff --git a/app/Http/Middleware/Api/Client/Server/AuthenticateServerAccess.php b/app/Http/Middleware/Api/Client/Server/AuthenticateServerAccess.php index 4ab87ce44..0634e2585 100644 --- a/app/Http/Middleware/Api/Client/Server/AuthenticateServerAccess.php +++ b/app/Http/Middleware/Api/Client/Server/AuthenticateServerAccess.php @@ -6,10 +6,8 @@ use Closure; use Illuminate\Http\Request; use Pterodactyl\Models\Server; use Pterodactyl\Contracts\Repository\ServerRepositoryInterface; -use Symfony\Component\HttpKernel\Exception\ConflictHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; -use Pterodactyl\Exceptions\Http\Server\ServerTransferringException; -use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Pterodactyl\Exceptions\Http\Server\ServerStateConflictException; class AuthenticateServerAccess { @@ -60,23 +58,17 @@ class AuthenticateServerAccess } } - if ($server->suspended && !$request->routeIs('api:client:server.resources')) { - throw new BadRequestHttpException('This server is currently suspended and the functionality requested is unavailable.'); - } - - // 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.'); + try { + $server->validateCurrentState(); + } catch (ServerStateConflictException $exception) { + // 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->isSuspended() && !$request->routeIs('api:client:server.resources')) { + throw $exception; } - } - - if (!is_null($server->transfer)) { - if (!$user->root_admin || ($user->root_admin && !$request->routeIs($this->except))) { - throw new ServerTransferringException(); + if (!$user->root_admin || !$request->routeIs($this->except)) { + throw $exception; } } } diff --git a/app/Http/Middleware/Server/AccessingValidServer.php b/app/Http/Middleware/Server/AccessingValidServer.php deleted file mode 100644 index 8f66bbec8..000000000 --- a/app/Http/Middleware/Server/AccessingValidServer.php +++ /dev/null @@ -1,92 +0,0 @@ -config = $config; - $this->repository = $repository; - $this->response = $response; - } - - /** - * Determine if a given user has permission to access a server. - * - * @return \Illuminate\Http\Response|mixed - * - * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException - * @throws \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException - * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException - */ - public function handle(Request $request, Closure $next) - { - $attributes = $request->route()->parameter('server'); - $isApiRequest = $request->expectsJson() || $request->is(...$this->config->get('pterodactyl.json_routes', [])); - $server = $this->repository->getByUuid($attributes instanceof Server ? $attributes->uuid : $attributes); - - if ($server->suspended) { - if ($isApiRequest) { - throw new AccessDeniedHttpException('Server is suspended and cannot be accessed.'); - } - - return $this->response->view('errors.suspended', [], 403); - } - - // Servers can have install statuses other than 1 or 0, so don't check - // for a bool-type operator here. - if ($server->installed !== 1) { - if ($isApiRequest) { - throw new ConflictHttpException('Server is still completing the installation process.'); - } - - 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); - - return $next($request); - } -} diff --git a/app/Http/Requests/Api/Client/Servers/Backups/DeleteBackupRequest.php b/app/Http/Requests/Api/Client/Servers/Backups/DeleteBackupRequest.php deleted file mode 100644 index 33b68aabd..000000000 --- a/app/Http/Requests/Api/Client/Servers/Backups/DeleteBackupRequest.php +++ /dev/null @@ -1,17 +0,0 @@ -route()->parameter('server'); - /** @var \Pterodactyl\Models\Backup|mixed $backup */ - $backup = $this->route()->parameter('backup'); - - if ($server instanceof Server && $backup instanceof Backup) { - if ($server->exists && $backup->exists && $server->id === $backup->server_id) { - return true; - } - } - - return false; - } -} diff --git a/app/Http/Requests/Api/Client/Servers/Backups/GetBackupsRequest.php b/app/Http/Requests/Api/Client/Servers/Backups/GetBackupsRequest.php deleted file mode 100644 index f938906d1..000000000 --- a/app/Http/Requests/Api/Client/Servers/Backups/GetBackupsRequest.php +++ /dev/null @@ -1,17 +0,0 @@ - 'required|uuid', + 'action' => 'required|string|max:191', + 'subaction' => 'nullable|string|max:191', + 'device' => 'array', + 'device.ip_address' => 'ip', + 'device.user_agent' => 'string', + 'metadata' => 'array', + ]; + + /** + * @var string + */ + protected $table = 'audit_logs'; + + /** + * @var bool + */ + protected $immutableDates = true; + + /** + * @var string[] + */ + protected $casts = [ + 'device' => 'array', + 'metadata' => 'array', + ]; + + /** + * @var string[] + */ + protected $guarded = [ + 'id', + 'created_at', + ]; + + /** + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function user() + { + return $this->belongsTo(User::class); + } + + /** + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function server() + { + return $this->belongsTo(Server::class); + } + + /** + * Creates a new AuditLog model and returns it, attaching device information and the + * currently authenticated user if available. This model is not saved at this point, so + * you can always make modifications to it as needed before saving. + * + * @return $this + */ + public static function instance(string $action, array $metadata, bool $isSystem = false) + { + /** @var \Illuminate\Http\Request $request */ + $request = Container::getInstance()->make('request'); + if ($isSystem || !$request instanceof Request) { + $request = null; + } + + return (new self())->fill([ + 'uuid' => Uuid::uuid4()->toString(), + 'is_system' => $isSystem, + 'user_id' => ($request && $request->user()) ? $request->user()->id : null, + 'server_id' => null, + 'action' => $action, + 'device' => $request ? [ + 'ip_address' => $request->getClientIp(), + 'user_agent' => $request->userAgent(), + ] : [], + 'metadata' => $metadata, + ]); + } +} diff --git a/app/Models/Permission.php b/app/Models/Permission.php index 3fc2efdff..5a0fc95f8 100644 --- a/app/Models/Permission.php +++ b/app/Models/Permission.php @@ -39,9 +39,9 @@ class Permission extends Model public const ACTION_BACKUP_READ = 'backup.read'; public const ACTION_BACKUP_CREATE = 'backup.create'; - public const ACTION_BACKUP_UPDATE = 'backup.update'; public const ACTION_BACKUP_DELETE = 'backup.delete'; public const ACTION_BACKUP_DOWNLOAD = 'backup.download'; + public const ACTION_BACKUP_RESTORE = 'backup.restore'; public const ACTION_ALLOCATION_READ = 'allocation.read'; public const ACTION_ALLOCATION_CREATE = 'allocation.create'; @@ -155,9 +155,9 @@ class Permission extends Model 'keys' => [ 'create' => 'Allows a user to create new backups for this server.', 'read' => 'Allows a user to view all backups that exist for this server.', - 'update' => '', 'delete' => 'Allows a user to remove backups from the system.', - 'download' => 'Allows a user to download backups.', + 'download' => 'Allows a user to download a backup for the server. Danger: this allows a user to access all files for the server in the backup.', + 'restore' => 'Allows a user to restore a backup for the server. Danger: this allows the user to delete all of the server files in the process.', ], ], diff --git a/app/Models/Server.php b/app/Models/Server.php index 864e24078..bd539282a 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -2,9 +2,11 @@ namespace Pterodactyl\Models; +use Closure; use Illuminate\Notifications\Notifiable; use Illuminate\Database\Query\JoinClause; use Znck\Eloquent\Traits\BelongsToThrough; +use Pterodactyl\Exceptions\Http\Server\ServerStateConflictException; /** * @property int $id @@ -14,8 +16,8 @@ use Znck\Eloquent\Traits\BelongsToThrough; * @property int $node_id * @property string $name * @property string $description + * @property string|null $status * @property bool $skip_scripts - * @property bool $suspended * @property int $owner_id * @property int $memory * @property int $swap @@ -29,7 +31,6 @@ use Znck\Eloquent\Traits\BelongsToThrough; * @property int $egg_id * @property string $startup * @property string $image - * @property int $installed * @property int $allocation_limit * @property int $database_limit * @property int $backup_limit @@ -49,6 +50,7 @@ use Znck\Eloquent\Traits\BelongsToThrough; * @property \Pterodactyl\Models\ServerTransfer $transfer * @property \Pterodactyl\Models\Backup[]|\Illuminate\Database\Eloquent\Collection $backups * @property \Pterodactyl\Models\Mount[]|\Illuminate\Database\Eloquent\Collection $mounts + * @property \Pterodactyl\Models\AuditLog[] $audits */ class Server extends Model { @@ -61,9 +63,10 @@ class Server extends Model */ public const RESOURCE_NAME = 'server'; - public const STATUS_INSTALLING = 0; - public const STATUS_INSTALLED = 1; - public const STATUS_INSTALL_FAILED = 2; + public const STATUS_INSTALLING = 'installing'; + public const STATUS_INSTALL_FAILED = 'install_failed'; + public const STATUS_SUSPENDED = 'suspended'; + public const STATUS_RESTORING_BACKUP = 'restoring_backup'; /** * The table associated with the model. @@ -79,6 +82,7 @@ class Server extends Model * @var array */ protected $attributes = [ + 'status' => self::STATUS_INSTALLING, 'oom_disabled' => true, ]; @@ -101,7 +105,7 @@ class Server extends Model * * @var array */ - protected $guarded = ['id', 'installed', self::CREATED_AT, self::UPDATED_AT, 'deleted_at']; + protected $guarded = ['id', self::CREATED_AT, self::UPDATED_AT, 'deleted_at']; /** * @var array @@ -112,6 +116,7 @@ class Server extends Model 'name' => 'required|string|min:1|max:191', 'node_id' => 'required|exists:nodes,id', 'description' => 'string', + 'status' => 'nullable|string', 'memory' => 'required|numeric|min:0', 'swap' => 'required|numeric|min:-1', 'io' => 'required|numeric|between:10,1000', @@ -125,7 +130,6 @@ class Server extends Model 'startup' => 'required|string', 'skip_scripts' => 'sometimes|boolean', 'image' => 'required|string|max:191', - 'installed' => 'in:0,1,2', 'database_limit' => 'present|nullable|integer|min:0', 'allocation_limit' => 'sometimes|nullable|integer|min:0', 'backup_limit' => 'present|nullable|integer|min:0', @@ -139,7 +143,6 @@ class Server extends Model protected $casts = [ 'node_id' => 'integer', 'skip_scripts' => 'boolean', - 'suspended' => 'boolean', 'owner_id' => 'integer', 'memory' => 'integer', 'swap' => 'integer', @@ -150,7 +153,6 @@ class Server extends Model 'allocation_id' => 'integer', 'nest_id' => 'integer', 'egg_id' => 'integer', - 'installed' => 'integer', 'database_limit' => 'integer', 'allocation_limit' => 'integer', 'backup_limit' => 'integer', @@ -168,7 +170,12 @@ class Server extends Model public function isInstalled(): bool { - return $this->installed === 1; + return $this->status !== self::STATUS_INSTALLING && $this->status !== self::STATUS_INSTALL_FAILED; + } + + public function isSuspended(): bool + { + return $this->status === self::STATUS_SUSPENDED; } /** @@ -320,4 +327,68 @@ class Server extends Model { return $this->hasManyThrough(Mount::class, MountServer::class, 'server_id', 'id', 'id', 'mount_id'); } + + /** + * Returns a fresh AuditLog model for the server. This model is not saved to the + * database when created, so it is up to the caller to correctly store it as needed. + * + * @return \Pterodactyl\Models\AuditLog + */ + public function newAuditEvent(string $action, array $metadata = []): AuditLog + { + return AuditLog::instance($action, $metadata)->fill([ + 'server_id' => $this->id, + ]); + } + + /** + * Stores a new audit event for a server by using a transaction. If the transaction + * fails for any reason everything executed within will be rolled back. The callback + * passed in will receive the AuditLog model before it is saved and the second argument + * will be the current server instance. The callback should modify the audit entry as + * needed before finishing, any changes will be persisted. + * + * The response from the callback is returned to the caller. + * + * @return mixed + * + * @throws \Throwable + */ + public function audit(string $action, Closure $callback) + { + return $this->getConnection()->transaction(function () use ($action, $callback) { + $model = $this->newAuditEvent($action); + $response = $callback($model, $this); + $model->save(); + + return $response; + }); + } + + /** + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function audits() + { + return $this->hasMany(AuditLog::class); + } + + /** + * Checks if the server is currently in a user-accessible state. If not, an + * exception is raised. This should be called whenever something needs to make + * sure the server is not in a weird state that should block user access. + * + * @throws \Pterodactyl\Exceptions\Http\Server\ServerStateConflictException + */ + public function validateCurrentState() + { + if ( + $this->isSuspended() || + !$this->isInstalled() || + $this->status === self::STATUS_RESTORING_BACKUP || + !is_null($this->transfer) + ) { + throw new ServerStateConflictException($this); + } + } } diff --git a/app/Repositories/Eloquent/ServerRepository.php b/app/Repositories/Eloquent/ServerRepository.php index 2e308ce17..8bb79c10f 100644 --- a/app/Repositories/Eloquent/ServerRepository.php +++ b/app/Repositories/Eloquent/ServerRepository.php @@ -168,14 +168,6 @@ class ServerRepository extends EloquentRepository implements ServerRepositoryInt return !$this->getBuilder()->where('uuid', '=', $uuid)->orWhere('uuidShort', '=', $short)->exists(); } - /** - * Get the amount of servers that are suspended. - */ - public function getSuspendedServersCount(): int - { - return $this->getBuilder()->where('suspended', true)->count(); - } - /** * Returns all of the servers that exist for a given node in a paginated response. */ diff --git a/app/Repositories/Wings/DaemonBackupRepository.php b/app/Repositories/Wings/DaemonBackupRepository.php index 9b320e1b2..571775fa5 100644 --- a/app/Repositories/Wings/DaemonBackupRepository.php +++ b/app/Repositories/Wings/DaemonBackupRepository.php @@ -53,6 +53,31 @@ class DaemonBackupRepository extends DaemonRepository } } + /** + * Sends a request to Wings to begin restoring a backup for a server. + * + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException + */ + public function restore(Backup $backup, string $url = null, bool $truncate = false): ResponseInterface + { + Assert::isInstanceOf($this->server, Server::class); + + try { + return $this->getHttpClient()->post( + sprintf('/api/servers/%s/backup/%s/restore', $this->server->uuid, $backup->uuid), + [ + 'json' => [ + 'adapter' => $backup->disk, + 'truncate_directory' => $truncate, + 'download_url' => $url ?? '', + ], + ] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + } + /** * Deletes a backup from the daemon. * diff --git a/app/Services/Backups/DownloadLinkService.php b/app/Services/Backups/DownloadLinkService.php new file mode 100644 index 000000000..509c79ca2 --- /dev/null +++ b/app/Services/Backups/DownloadLinkService.php @@ -0,0 +1,75 @@ +backupManager = $backupManager; + $this->jwtService = $jwtService; + } + + /** + * Returns the URL that allows for a backup to be downloaded by an individual + * user, or by the Wings control software. + */ + public function handle(Backup $backup, User $user): string + { + if ($backup->disk === Backup::ADAPTER_AWS_S3) { + return $this->getS3BackupUrl($backup); + } + + $token = $this->jwtService + ->setExpiresAt(CarbonImmutable::now()->addMinutes(15)) + ->setClaims([ + 'backup_uuid' => $backup->uuid, + 'server_uuid' => $backup->server->uuid, + ]) + ->handle($backup->server->node, $user->id . $backup->server->uuid); + + return sprintf('%s/download/backup?token=%s', $backup->server->node->getConnectionAddress(), $token->__toString()); + } + + /** + * Returns a signed URL that allows us to download a file directly out of a non-public + * S3 bucket by using a signed URL. + * + * @return string + */ + protected function getS3BackupUrl(Backup $backup) + { + /** @var \League\Flysystem\AwsS3v3\AwsS3Adapter $adapter */ + $adapter = $this->backupManager->adapter(Backup::ADAPTER_AWS_S3); + + $request = $adapter->getClient()->createPresignedRequest( + $adapter->getClient()->getCommand('GetObject', [ + 'Bucket' => $adapter->getBucket(), + 'Key' => sprintf('%s/%s.tar.gz', $backup->server->uuid, $backup->uuid), + 'ContentType' => 'application/x-gzip', + ]), + CarbonImmutable::now()->addMinutes(5) + ); + + return $request->getUri()->__toString(); + } +} diff --git a/app/Services/Servers/ReinstallServerService.php b/app/Services/Servers/ReinstallServerService.php index cfb880455..561512b37 100644 --- a/app/Services/Servers/ReinstallServerService.php +++ b/app/Services/Servers/ReinstallServerService.php @@ -39,7 +39,7 @@ class ReinstallServerService public function handle(Server $server) { return $this->connection->transaction(function () use ($server) { - $server->forceFill(['installed' => Server::STATUS_INSTALLING])->save(); + $server->fill(['status' => Server::STATUS_INSTALLING])->save(); $this->daemonServerRepository->setServer($server)->reinstall(); diff --git a/app/Services/Servers/ServerConfigurationStructureService.php b/app/Services/Servers/ServerConfigurationStructureService.php index cfef71c6f..c94155bf0 100644 --- a/app/Services/Servers/ServerConfigurationStructureService.php +++ b/app/Services/Servers/ServerConfigurationStructureService.php @@ -47,7 +47,7 @@ class ServerConfigurationStructureService { return [ 'uuid' => $server->uuid, - 'suspended' => $server->suspended, + 'suspended' => $server->isSuspended(), 'environment' => $this->environment->handle($server), 'invocation' => $server->startup, 'skip_egg_scripts' => $server->skip_scripts, @@ -118,7 +118,7 @@ class ServerConfigurationStructureService 'skip_scripts' => $server->skip_scripts, ], 'rebuild' => false, - 'suspended' => (int) $server->suspended, + 'suspended' => $server->isSuspended() ? 1 : 0, ]; } } diff --git a/app/Services/Servers/ServerCreationService.php b/app/Services/Servers/ServerCreationService.php index 9d93558fe..f0ef58d1b 100644 --- a/app/Services/Servers/ServerCreationService.php +++ b/app/Services/Servers/ServerCreationService.php @@ -211,8 +211,8 @@ class ServerCreationService 'node_id' => Arr::get($data, 'node_id'), 'name' => Arr::get($data, 'name'), 'description' => Arr::get($data, 'description') ?? '', + 'status' => Server::STATUS_INSTALLING, 'skip_scripts' => Arr::get($data, 'skip_scripts') ?? isset($data['skip_scripts']), - 'suspended' => false, 'owner_id' => Arr::get($data, 'owner_id'), 'memory' => Arr::get($data, 'memory'), 'swap' => Arr::get($data, 'swap'), diff --git a/app/Services/Servers/SuspensionService.php b/app/Services/Servers/SuspensionService.php index f58fa098a..3c61b1935 100644 --- a/app/Services/Servers/SuspensionService.php +++ b/app/Services/Servers/SuspensionService.php @@ -6,7 +6,7 @@ use Webmozart\Assert\Assert; use Pterodactyl\Models\Server; use Illuminate\Database\ConnectionInterface; use Pterodactyl\Repositories\Wings\DaemonServerRepository; -use Pterodactyl\Exceptions\Http\Server\ServerTransferringException; +use Symfony\Component\HttpKernel\Exception\ConflictHttpException; class SuspensionService { @@ -49,18 +49,18 @@ class SuspensionService // Nothing needs to happen if we're suspending the server and it is already // suspended in the database. Additionally, nothing needs to happen if the server // is not suspended and we try to un-suspend the instance. - if ($isSuspending === $server->suspended) { + if ($isSuspending === $server->isSuspended()) { return; } // Check if the server is currently being transferred. if (!is_null($server->transfer)) { - throw new ServerTransferringException(); + throw new ConflictHttpException('Cannot toggle suspension status on a server that is currently being transferred.'); } - $this->connection->transaction(function () use ($action, $server) { + $this->connection->transaction(function () use ($action, $server, $isSuspending) { $server->update([ - 'suspended' => $action === self::ACTION_SUSPEND, + 'status' => $isSuspending ? Server::STATUS_SUSPENDED : null, ]); // Only send the suspension request to wings if the server is not currently being transferred. diff --git a/app/Transformers/Api/Application/ServerTransformer.php b/app/Transformers/Api/Application/ServerTransformer.php index ff776504c..622a78bb0 100644 --- a/app/Transformers/Api/Application/ServerTransformer.php +++ b/app/Transformers/Api/Application/ServerTransformer.php @@ -59,17 +59,9 @@ class ServerTransformer extends BaseTransformer 'identifier' => $model->uuidShort, 'name' => $model->name, 'description' => $model->description, - - 'is_suspended' => $model->suspended, - 'is_installing' => $model->installed !== 1, - 'is_transferring' => ! is_null($model->transfer), - - 'user' => $model->owner_id, - 'node' => $model->node_id, - 'allocation' => $model->allocation_id, - 'nest' => $model->nest_id, - 'egg' => $model->egg_id, - + 'status' => $model->status, + // This field is deprecated, please use "status". + 'suspended' => $model->isSuspended(), 'limits' => [ 'memory' => $model->memory, 'swap' => $model->swap, @@ -78,20 +70,23 @@ class ServerTransformer extends BaseTransformer 'cpu' => $model->cpu, 'threads' => $model->threads, ], - 'feature_limits' => [ 'databases' => $model->database_limit, 'allocations' => $model->allocation_limit, 'backups' => $model->backup_limit, ], - + 'user' => $model->owner_id, + 'node' => $model->node_id, + 'allocation' => $model->allocation_id, + 'nest' => $model->nest_id, + 'egg' => $model->egg_id, 'container' => [ 'startup_command' => $model->startup, 'image' => $model->image, - 'installed' => (int) $model->installed === 1, + // This field is deprecated, please use "status". + 'installed' => $model->isInstalled() ? 1 : 0, 'environment' => $this->environmentService->handle($model), ], - $model->getUpdatedAtColumn() => $this->formatTimestamp($model->updated_at), $model->getCreatedAtColumn() => $this->formatTimestamp($model->created_at), ]; diff --git a/app/Transformers/Api/Client/ServerTransformer.php b/app/Transformers/Api/Client/ServerTransformer.php index 03f853052..99771e676 100644 --- a/app/Transformers/Api/Client/ServerTransformer.php +++ b/app/Transformers/Api/Client/ServerTransformer.php @@ -64,8 +64,11 @@ class ServerTransformer extends BaseClientTransformer 'allocations' => $server->allocation_limit, 'backups' => $server->backup_limit, ], - 'is_suspended' => $server->suspended, - 'is_installing' => $server->installed !== 1, + 'status' => $server->status, + // This field is deprecated, please use "status". + 'is_suspended' => $server->isSuspended(), + // This field is deprecated, please use "status". + 'is_installing' => !$server->isInstalled(), 'is_transferring' => !is_null($server->transfer), ]; } diff --git a/database/Factories/ServerFactory.php b/database/Factories/ServerFactory.php index c56b66863..d39d614b2 100644 --- a/database/Factories/ServerFactory.php +++ b/database/Factories/ServerFactory.php @@ -30,7 +30,7 @@ class ServerFactory extends Factory 'name' => $this->faker->firstName, 'description' => implode(' ', $this->faker->sentences()), 'skip_scripts' => 0, - 'suspended' => 0, + 'status' => null, 'memory' => 512, 'swap' => 0, 'disk' => 512, @@ -38,7 +38,6 @@ class ServerFactory extends Factory 'cpu' => 0, 'threads' => null, 'oom_disabled' => 0, - 'installed' => 1, 'allocation_limit' => null, 'database_limit' => null, 'created_at' => Carbon::now(), diff --git a/database/migrations/2021_01_17_102401_create_audit_logs_table.php b/database/migrations/2021_01_17_102401_create_audit_logs_table.php new file mode 100644 index 000000000..f67e7d647 --- /dev/null +++ b/database/migrations/2021_01_17_102401_create_audit_logs_table.php @@ -0,0 +1,42 @@ +id(); + $table->char('uuid', 36); + $table->boolean('is_system')->default(false); + $table->unsignedInteger('user_id')->nullable(); + $table->unsignedInteger('server_id')->nullable(); + $table->string('action'); + $table->string('subaction')->nullable(); + $table->json('device'); + $table->json('metadata'); + $table->timestamp('created_at', 0); + + $table->foreign('user_id')->references('id')->on('users')->onDelete('set null'); + $table->foreign('server_id')->references('id')->on('servers')->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('audit_logs'); + } +} diff --git a/database/migrations/2021_01_17_152623_add_generic_server_status_column.php b/database/migrations/2021_01_17_152623_add_generic_server_status_column.php new file mode 100644 index 000000000..12e6abb95 --- /dev/null +++ b/database/migrations/2021_01_17_152623_add_generic_server_status_column.php @@ -0,0 +1,55 @@ +string('status')->nullable()->after('description'); + }); + + DB::transaction(function () { + DB::update('UPDATE servers SET `status` = \'suspended\' WHERE `suspended` = 1'); + DB::update('UPDATE servers SET `status` = \'installing\' WHERE `installed` = 0'); + DB::update('UPDATE servers SET `status` = \'install_failed\' WHERE `installed` = 2'); + }); + + Schema::table('servers', function (Blueprint $table) { + $table->dropColumn('suspended'); + $table->dropColumn('installed'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('servers', function (Blueprint $table) { + $table->unsignedTinyInteger('suspended')->default(0); + $table->unsignedTinyInteger('installed')->default(0); + }); + + DB::transaction(function () { + DB::update('UPDATE servers SET `suspended` = 1 WHERE `status` = \'suspended\''); + DB::update('UPDATE servers SET `installed` = 1 WHERE `status` IS NULL'); + DB::update('UPDATE servers SET `installed` = 2 WHERE `status` = \'install_failed\''); + }); + + Schema::table('servers', function (Blueprint $table) { + $table->dropColumn('status'); + }); + } +} diff --git a/resources/lang/en/admin/server.php b/resources/lang/en/admin/server.php index 026230099..6e3ce9f94 100644 --- a/resources/lang/en/admin/server.php +++ b/resources/lang/en/admin/server.php @@ -12,7 +12,7 @@ return [ 'no_new_default_allocation' => 'You are attempting to delete the default allocation for this server but there is no fallback allocation to use.', 'marked_as_failed' => 'This server was marked as having failed a previous installation. Current status cannot be toggled in this state.', 'bad_variable' => 'There was a validation error with the :name variable.', - 'daemon_exception' => 'There was an exception while attempting to communicate with the daemon resulting in a HTTP/:code response code. This exception has been logged.', + 'daemon_exception' => 'There was an exception while attempting to communicate with the daemon resulting in a HTTP/:code response code. This exception has been logged. (request id: :request_id)', 'default_allocation_not_found' => 'The requested default allocation was not found in this server\'s allocations.', ], 'alerts' => [ diff --git a/resources/scripts/api/server/backups/index.ts b/resources/scripts/api/server/backups/index.ts new file mode 100644 index 000000000..016836364 --- /dev/null +++ b/resources/scripts/api/server/backups/index.ts @@ -0,0 +1,5 @@ +import http from '@/api/http'; + +export const restoreServerBackup = async (uuid: string, backup: string): Promise => { + await http.post(`/api/client/servers/${uuid}/backups/${backup}/restore`); +}; diff --git a/resources/scripts/api/server/getServer.ts b/resources/scripts/api/server/getServer.ts index 959149f9d..3d74ee2ab 100644 --- a/resources/scripts/api/server/getServer.ts +++ b/resources/scripts/api/server/getServer.ts @@ -1,6 +1,6 @@ import http, { FractalResponseData, FractalResponseList } from '@/api/http'; import { rawDataToServerAllocation, rawDataToServerEggVariable } from '@/api/transformers'; -import { ServerEggVariable } from '@/api/server/types'; +import { ServerEggVariable, ServerStatus } from '@/api/server/types'; export interface Allocation { id: number; @@ -17,6 +17,7 @@ export interface Server { uuid: string; name: string; node: string; + status: ServerStatus; sftpDetails: { ip: string; port: number; @@ -38,7 +39,6 @@ export interface Server { allocations: number; backups: number; }; - isSuspended: boolean; isInstalling: boolean; isTransferring: boolean; variables: ServerEggVariable[]; @@ -51,6 +51,7 @@ export const rawDataToServerObject = ({ attributes: data }: FractalResponseData) uuid: data.uuid, name: data.name, node: data.node, + status: data.status, invocation: data.invocation, dockerImage: data.docker_image, sftpDetails: { @@ -61,8 +62,7 @@ export const rawDataToServerObject = ({ attributes: data }: FractalResponseData) limits: { ...data.limits }, eggFeatures: data.egg_features || [], featureLimits: { ...data.feature_limits }, - isSuspended: data.is_suspended, - isInstalling: data.is_installing, + isInstalling: data.status === 'installing' || data.status === 'install_failed', 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/api/server/types.d.ts b/resources/scripts/api/server/types.d.ts index b37fae402..dd871162c 100644 --- a/resources/scripts/api/server/types.d.ts +++ b/resources/scripts/api/server/types.d.ts @@ -1,3 +1,5 @@ +export type ServerStatus = 'installing' | 'install_failed' | 'suspended' | 'restoring_backup' | null; + export interface ServerBackup { uuid: string; isSuccessful: boolean; diff --git a/resources/scripts/api/transformers.ts b/resources/scripts/api/transformers.ts index 73c6c217f..25deb7691 100644 --- a/resources/scripts/api/transformers.ts +++ b/resources/scripts/api/transformers.ts @@ -32,6 +32,7 @@ export const rawDataToFileObject = (data: FractalResponseData): FileObject => ({ 'application/x-br', // .tar.br 'application/x-bzip2', // .tar.bz2, .bz2 'application/gzip', // .tar.gz, .gz + 'application/x-gzip', 'application/x-lzip', // .tar.lz4, .lz4 (not sure if this mime type is correct) 'application/x-sz', // .tar.sz, .sz (not sure if this mime type is correct) 'application/x-xz', // .tar.xz, .xz diff --git a/resources/scripts/assets/images/not_found.svg b/resources/scripts/assets/images/not_found.svg new file mode 100644 index 000000000..222a4152e --- /dev/null +++ b/resources/scripts/assets/images/not_found.svg @@ -0,0 +1 @@ +not found \ No newline at end of file diff --git a/resources/scripts/assets/images/pterodactyl.svg b/resources/scripts/assets/images/pterodactyl.svg new file mode 100755 index 000000000..f3582adf2 --- /dev/null +++ b/resources/scripts/assets/images/pterodactyl.svg @@ -0,0 +1 @@ +Artboard 1 \ No newline at end of file diff --git a/resources/scripts/assets/images/server_error.svg b/resources/scripts/assets/images/server_error.svg new file mode 100644 index 000000000..726fa106d --- /dev/null +++ b/resources/scripts/assets/images/server_error.svg @@ -0,0 +1 @@ +server down \ No newline at end of file diff --git a/resources/scripts/assets/images/server_installing.svg b/resources/scripts/assets/images/server_installing.svg new file mode 100644 index 000000000..d2a0ae48b --- /dev/null +++ b/resources/scripts/assets/images/server_installing.svg @@ -0,0 +1 @@ +uploading \ No newline at end of file diff --git a/resources/scripts/assets/images/server_restore.svg b/resources/scripts/assets/images/server_restore.svg new file mode 100644 index 000000000..ce36a8d44 --- /dev/null +++ b/resources/scripts/assets/images/server_restore.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/scripts/components/App.tsx b/resources/scripts/components/App.tsx index 13764bbf8..3a76ceb2e 100644 --- a/resources/scripts/components/App.tsx +++ b/resources/scripts/components/App.tsx @@ -9,7 +9,7 @@ import ServerRouter from '@/routers/ServerRouter'; import AuthenticationRouter from '@/routers/AuthenticationRouter'; import { SiteSettings } from '@/state/settings'; import ProgressBar from '@/components/elements/ProgressBar'; -import NotFound from '@/components/screens/NotFound'; +import { NotFound } from '@/components/elements/ScreenBlock'; import tw from 'twin.macro'; import GlobalStylesheet from '@/assets/css/GlobalStylesheet'; import { history } from '@/components/history'; diff --git a/resources/scripts/components/dashboard/ServerRow.tsx b/resources/scripts/components/dashboard/ServerRow.tsx index 7db977311..40eebefac 100644 --- a/resources/scripts/components/dashboard/ServerRow.tsx +++ b/resources/scripts/components/dashboard/ServerRow.tsx @@ -41,7 +41,7 @@ const StatusIndicatorBox = styled(GreyRowBox)<{ $status: ServerPowerState | unde export default ({ server, className }: { server: Server; className?: string }) => { const interval = useRef(null); - const [ isSuspended, setIsSuspended ] = useState(server.isSuspended); + const [ isSuspended, setIsSuspended ] = useState(server.status === 'suspended'); const [ stats, setStats ] = useState(null); const getStats = () => getServerResourceUsage(server.uuid) @@ -49,8 +49,8 @@ export default ({ server, className }: { server: Server; className?: string }) = .catch(error => console.error(error)); useEffect(() => { - setIsSuspended(stats?.isSuspended || server.isSuspended); - }, [ stats?.isSuspended, server.isSuspended ]); + setIsSuspended(stats?.isSuspended || server.status === 'suspended'); + }, [ stats?.isSuspended, server.status ]); useEffect(() => { // Don't waste a HTTP request if there is nothing important to show to the user because @@ -107,25 +107,27 @@ export default ({ server, className }: { server: Server; className?: string }) = isSuspended ?
- {server.isSuspended ? 'Suspended' : 'Connection Error'} + {server.status === 'suspended' ? 'Suspended' : 'Connection Error'}
: - server.isInstalling ? + (server.isTransferring || server.status) ?
- Installing + {server.isTransferring ? + 'Transferring' + : + server.status === 'installing' ? 'Installing' : ( + server.status === 'restoring_backup' ? + 'Restoring Backup' + : + 'Unavailable' + ) + }
: - server.isTransferring ? -
- - Transferring - -
- : - + :