Simplify logic when a server is in an unsupported state

This commit is contained in:
Dane Everitt 2021-01-30 13:28:31 -08:00
parent be26921fcc
commit e30a765071
No known key found for this signature in database
GPG key ID: EEA66103B3D71F53
10 changed files with 76 additions and 294 deletions

View file

@ -0,0 +1,30 @@
<?php
namespace Pterodactyl\Exceptions\Http\Server;
use Throwable;
use Pterodactyl\Models\Server;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
class ServerStateConflictException extends ConflictHttpException
{
/**
* Exception thrown when the server is in an unsupported state for API access or
* certain operations within the codebase.
*/
public function __construct(Server $server, Throwable $previous = null)
{
$message = 'This server is currently in an unsupported state, please try again later.';
if ($server->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);
}
}

View file

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

View file

@ -12,8 +12,6 @@ use Pterodactyl\Exceptions\Http\HttpForbiddenException;
use Pterodactyl\Repositories\Eloquent\ServerRepository; use Pterodactyl\Repositories\Eloquent\ServerRepository;
use Pterodactyl\Services\Servers\GetUserPermissionsService; use Pterodactyl\Services\Servers\GetUserPermissionsService;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Pterodactyl\Exceptions\Http\Server\ServerTransferringException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Pterodactyl\Http\Requests\Api\Remote\SftpAuthenticationFormRequest; use Pterodactyl\Http\Requests\Api\Remote\SftpAuthenticationFormRequest;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException; use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
@ -98,16 +96,7 @@ class SftpAuthenticationController extends Controller
} }
} }
// Prevent SFTP access to servers that are being transferred. $server->validateCurrentState();
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->isSuspended() || !$server->isInstalled()) {
throw new BadRequestHttpException('Server is not installed or is currently suspended.');
}
return new JsonResponse([ return new JsonResponse([
'server' => $server->uuid, 'server' => $server->uuid,

View file

@ -28,7 +28,6 @@ use Pterodactyl\Http\Middleware\Api\AuthenticateIPAccess;
use Pterodactyl\Http\Middleware\Api\ApiSubstituteBindings; use Pterodactyl\Http\Middleware\Api\ApiSubstituteBindings;
use Illuminate\Foundation\Http\Middleware\ValidatePostSize; use Illuminate\Foundation\Http\Middleware\ValidatePostSize;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse; use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Pterodactyl\Http\Middleware\Server\AccessingValidServer;
use Pterodactyl\Http\Middleware\Api\Daemon\DaemonAuthenticate; use Pterodactyl\Http\Middleware\Api\Daemon\DaemonAuthenticate;
use Pterodactyl\Http\Middleware\RequireTwoFactorAuthentication; use Pterodactyl\Http\Middleware\RequireTwoFactorAuthentication;
use Illuminate\Foundation\Http\Middleware\CheckForMaintenanceMode; use Illuminate\Foundation\Http\Middleware\CheckForMaintenanceMode;
@ -106,7 +105,6 @@ class Kernel extends HttpKernel
'auth' => Authenticate::class, 'auth' => Authenticate::class,
'auth.basic' => AuthenticateWithBasicAuth::class, 'auth.basic' => AuthenticateWithBasicAuth::class,
'guest' => RedirectIfAuthenticated::class, 'guest' => RedirectIfAuthenticated::class,
'server' => AccessingValidServer::class,
'admin' => AdminAuthenticate::class, 'admin' => AdminAuthenticate::class,
'csrf' => VerifyCsrfToken::class, 'csrf' => VerifyCsrfToken::class,
'throttle' => ThrottleRequests::class, 'throttle' => ThrottleRequests::class,

View file

@ -6,10 +6,8 @@ use Closure;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Pterodactyl\Models\Server; use Pterodactyl\Models\Server;
use Pterodactyl\Contracts\Repository\ServerRepositoryInterface; use Pterodactyl\Contracts\Repository\ServerRepositoryInterface;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Pterodactyl\Exceptions\Http\Server\ServerTransferringException; use Pterodactyl\Exceptions\Http\Server\ServerStateConflictException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
class AuthenticateServerAccess class AuthenticateServerAccess
{ {
@ -60,23 +58,17 @@ class AuthenticateServerAccess
} }
} }
if ($server->suspended && !$request->routeIs('api:client:server.resources')) { try {
throw new BadRequestHttpException('This server is currently suspended and the functionality requested is unavailable.'); $server->validateCurrentState();
} } catch (ServerStateConflictException $exception) {
// Still allow users to get information about their server if it is installing or
// Still allow users to get information about their server if it is installing or being transferred. // being transferred.
if (!$request->routeIs('api:client:server.view')) { if (!$request->routeIs('api:client:server.view')) {
if (!$server->isInstalled()) { if ($server->isSuspended() && !$request->routeIs('api:client:server.resources')) {
// Throw an exception for all server routes; however if the user is an admin and requesting the throw $exception;
// 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 (!$user->root_admin || !$request->routeIs($this->except)) {
throw $exception;
if (!is_null($server->transfer)) {
if (!$user->root_admin || ($user->root_admin && !$request->routeIs($this->except))) {
throw new ServerTransferringException();
} }
} }
} }

View file

@ -1,92 +0,0 @@
<?php
namespace Pterodactyl\Http\Middleware\Server;
use Closure;
use Illuminate\Http\Request;
use Pterodactyl\Models\Server;
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
{
/**
* @var \Illuminate\Contracts\Config\Repository
*/
private $config;
/**
* @var \Pterodactyl\Contracts\Repository\ServerRepositoryInterface
*/
private $repository;
/**
* @var \Illuminate\Contracts\Routing\ResponseFactory
*/
private $response;
/**
* AccessingValidServer constructor.
*/
public function __construct(
ConfigRepository $config,
ResponseFactory $response,
ServerRepositoryInterface $repository
) {
$this->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->isSuspended()) {
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->isInstalled()) {
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);
}
}

View file

@ -6,6 +6,7 @@ use Closure;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Illuminate\Database\Query\JoinClause; use Illuminate\Database\Query\JoinClause;
use Znck\Eloquent\Traits\BelongsToThrough; use Znck\Eloquent\Traits\BelongsToThrough;
use Pterodactyl\Exceptions\Http\Server\ServerStateConflictException;
/** /**
* @property int $id * @property int $id
@ -371,4 +372,23 @@ class Server extends Model
{ {
return $this->hasMany(AuditLog::class); 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);
}
}
} }

View file

@ -6,7 +6,7 @@ use Webmozart\Assert\Assert;
use Pterodactyl\Models\Server; use Pterodactyl\Models\Server;
use Illuminate\Database\ConnectionInterface; use Illuminate\Database\ConnectionInterface;
use Pterodactyl\Repositories\Wings\DaemonServerRepository; use Pterodactyl\Repositories\Wings\DaemonServerRepository;
use Pterodactyl\Exceptions\Http\Server\ServerTransferringException; use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
class SuspensionService class SuspensionService
{ {
@ -55,7 +55,7 @@ class SuspensionService
// Check if the server is currently being transferred. // Check if the server is currently being transferred.
if (!is_null($server->transfer)) { 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, $isSuspending) { $this->connection->transaction(function () use ($action, $server, $isSuspending) {

View file

@ -111,21 +111,23 @@ export default ({ server, className }: { server: Server; className?: string }) =
</span> </span>
</div> </div>
: :
server.isInstalling ? (server.isTransferring || server.status) ?
<div css={tw`flex-1 text-center`}> <div css={tw`flex-1 text-center`}>
<span css={tw`bg-neutral-500 rounded px-2 py-1 text-neutral-100 text-xs`}> <span css={tw`bg-neutral-500 rounded px-2 py-1 text-neutral-100 text-xs`}>
Installing {server.isTransferring ?
'Transferring'
:
server.status === 'installing' ? 'Installing' : (
server.status === 'restoring_backup' ?
'Restoring Backup'
:
'Unavailable'
)
}
</span> </span>
</div> </div>
: :
server.isTransferring ? <Spinner size={'small'}/>
<div css={tw`flex-1 text-center`}>
<span css={tw`bg-neutral-500 rounded px-2 py-1 text-neutral-100 text-xs`}>
Transferring
</span>
</div>
:
<Spinner size={'small'}/>
: :
<React.Fragment> <React.Fragment>
<div css={tw`flex-1 flex md:ml-4 sm:flex hidden justify-center`}> <div css={tw`flex-1 flex md:ml-4 sm:flex hidden justify-center`}>

View file

@ -1,140 +0,0 @@
<?php
namespace Pterodactyl\Tests\Unit\Http\Middleware\Server;
use Mockery as m;
use Pterodactyl\Models\Server;
use Illuminate\Contracts\Config\Repository;
use Illuminate\Contracts\Routing\ResponseFactory;
use Pterodactyl\Http\Middleware\Server\AccessingValidServer;
use Pterodactyl\Tests\Unit\Http\Middleware\MiddlewareTestCase;
use Pterodactyl\Contracts\Repository\ServerRepositoryInterface;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
class AccessingValidServerTest extends MiddlewareTestCase
{
/**
* @var \Illuminate\Contracts\Config\Repository|\Mockery\Mock
*/
private $config;
/**
* @var \Pterodactyl\Contracts\Repository\ServerRepositoryInterface|\Mockery\Mock
*/
private $repository;
/**
* @var \Illuminate\Contracts\Routing\ResponseFactory|\Mockery\Mock
*/
private $response;
/**
* Setup tests.
*/
public function setUp(): void
{
parent::setUp();
$this->config = m::mock(Repository::class);
$this->repository = m::mock(ServerRepositoryInterface::class);
$this->response = m::mock(ResponseFactory::class);
}
/**
* Test that an exception is thrown if the request is an API request and the server is suspended.
*/
public function testExceptionIsThrownIfServerIsSuspended()
{
$this->expectException(AccessDeniedHttpException::class);
$this->expectExceptionMessage('Server is suspended and cannot be accessed.');
$model = Server::factory()->make(['suspended' => 1]);
$this->request->shouldReceive('route->parameter')->with('server')->once()->andReturn('123456');
$this->request->shouldReceive('expectsJson')->withNoArgs()->once()->andReturn(true);
$this->repository->shouldReceive('getByUuid')->with('123456')->once()->andReturn($model);
$this->getMiddleware()->handle($this->request, $this->getClosureAssertions());
}
/**
* Test that an exception is thrown if the request is an API request and the server is not installed.
*/
public function testExceptionIsThrownIfServerIsNotInstalled()
{
$this->expectException(ConflictHttpException::class);
$this->expectExceptionMessage('Server is still completing the installation process.');
$model = Server::factory()->make(['installed' => 0]);
$this->request->shouldReceive('route->parameter')->with('server')->once()->andReturn('123456');
$this->request->shouldReceive('expectsJson')->withNoArgs()->once()->andReturn(true);
$this->repository->shouldReceive('getByUuid')->with('123456')->once()->andReturn($model);
$this->getMiddleware()->handle($this->request, $this->getClosureAssertions());
}
/**
* Test that the correct error pages are rendered depending on the status of the server.
*
* @dataProvider viewDataProvider
*/
public function testCorrectErrorPagesAreRendered(Server $model, string $page, int $httpCode)
{
$this->request->shouldReceive('route->parameter')->with('server')->once()->andReturn('123456');
$this->request->shouldReceive('expectsJson')->withNoArgs()->once()->andReturn(false);
$this->config->shouldReceive('get')->with('pterodactyl.json_routes', [])->once()->andReturn([]);
$this->request->shouldReceive('is')->with(...[])->once()->andReturn(false);
$this->repository->shouldReceive('getByUuid')->with('123456')->once()->andReturn($model);
$this->response->shouldReceive('view')->with($page, [], $httpCode)->once()->andReturn(true);
$response = $this->getMiddleware()->handle($this->request, $this->getClosureAssertions());
$this->assertTrue($response);
}
/**
* Test that the full middleware works correctly.
*/
public function testValidServerProcess()
{
$model = Server::factory()->make();
$this->request->shouldReceive('route->parameter')->with('server')->once()->andReturn('123456');
$this->request->shouldReceive('expectsJson')->withNoArgs()->once()->andReturn(false);
$this->config->shouldReceive('get')->with('pterodactyl.json_routes', [])->once()->andReturn([]);
$this->request->shouldReceive('is')->with(...[])->once()->andReturn(false);
$this->repository->shouldReceive('getByUuid')->with('123456')->once()->andReturn($model);
$this->getMiddleware()->handle($this->request, $this->getClosureAssertions());
$this->assertRequestHasAttribute('server');
$this->assertRequestAttributeEquals($model, 'server');
}
/**
* Provide test data that checks that the correct view is returned for each model type.
*/
public function viewDataProvider(): array
{
// Without this we are unable to instantiate the factory builders for some reason.
$this->refreshApplication();
return [
[Server::factory()->make(['suspended' => 1]), 'errors.suspended', 403],
[Server::factory()->make(['installed' => 0]), 'errors.installing', 409],
[Server::factory()->make(['installed' => 2]), 'errors.installing', 409],
];
}
/**
* Return an instance of the middleware using mocked dependencies.
*/
private function getMiddleware(): AccessingValidServer
{
return new AccessingValidServer($this->config, $this->response, $this->repository);
}
}