Add changes from upstream (#2076)

Co-authored-by: DaneEveritt <dane@daneeveritt.com>
This commit is contained in:
Boy132
2026-01-13 08:39:50 +01:00
committed by GitHub
parent 6ae4f007c8
commit 53aa49b11a
25 changed files with 192 additions and 56 deletions

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Enums;
use App\Models\Server;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Routing\Middleware\ThrottleRequests;
use Illuminate\Support\Facades\RateLimiter;
use Webmozart\Assert\Assert;
/**
* A basic resource throttler for individual servers. This is applied in addition
* to existing rate limits and allows the code to slow down speedy users that might
* be creating resources a little too quickly for comfort. This throttle generally
* only applies to creation flows, and not general view/edit/delete flows.
*/
enum ResourceLimit: string
{
case Websocket = 'websocket';
case AllocationCreate = 'allocation-create';
case BackupRestore = 'backup-restore';
case DatabaseCreate = 'database-create';
case ScheduleCreate = 'schedule-create';
case SubuserCreate = 'subuser-create';
case FilePull = 'file-pull';
public function throttleKey(): string
{
return "api.client:server-resource:{$this->name}";
}
/**
* Returns a middleware that will throttle the specific resource by server. This
* throttle applies to any user making changes to that resource on the specific
* server, it is NOT per-user.
*/
public function middleware(): string
{
return ThrottleRequests::using($this->throttleKey());
}
public function limit(): Limit
{
return match ($this) {
self::Websocket => Limit::perMinute(5),
self::BackupRestore => Limit::perMinutes(15, 3),
self::DatabaseCreate => Limit::perMinute(2),
self::SubuserCreate => Limit::perMinutes(15, 10),
self::FilePull => Limit::perMinutes(10, 5),
default => Limit::perMinute(2),
};
}
public static function boot(): void
{
foreach (self::cases() as $case) {
RateLimiter::for($case->throttleKey(), function (Request $request) use ($case) {
Assert::isInstanceOf($server = $request->route()->parameter('server'), Server::class);
return $case->limit()->by($server->uuid);
});
}
}
}

View File

@@ -18,11 +18,11 @@ final class GithubSchema extends OAuthSchema
public function getSetupSteps(): array
{
return array_merge([
Step::make('Register new Github OAuth App')
Step::make('Register new GitHub OAuth App')
->schema([
TextEntry::make('create_application')
->hiddenLabel()
->state(new HtmlString(Blade::render('<p>Visit the <x-filament::link href="https://github.com/settings/developers" target="_blank">Github Developer Dashboard</x-filament::link>, go to <b>OAuth Apps</b> and click on <b>New OAuth App</b>.</p><p>Enter an <b>Application name</b> (e.g. your panel name), set <b>Homepage URL</b> to your panel url and enter the below url as <b>Authorization callback URL</b>.</p>'))),
->state(new HtmlString(Blade::render('<p>Visit the <x-filament::link href="https://github.com/settings/developers" target="_blank">GitHub Developer Dashboard</x-filament::link>, go to <b>OAuth Apps</b> and click on <b>New OAuth App</b>.</p><p>Enter an <b>Application name</b> (e.g. your panel name), set <b>Homepage URL</b> to your panel url and enter the below url as <b>Authorization callback URL</b>.</p>'))),
TextInput::make('_noenv_callback')
->label('Authorization callback URL')
->dehydrated()

View File

@@ -253,13 +253,11 @@ class CreateNode extends CreateRecord
->columnSpan(2),
TextInput::make('upload_size')
->label(trans('admin/node.upload_limit'))
->helperText(trans('admin/node.upload_limit_help.0'))
->hintIcon('tabler-question-mark', trans('admin/node.upload_limit_help.1'))
->hintIcon('tabler-question-mark', trans('admin/node.upload_limit_help'))
->columnSpan(1)
->numeric()->required()
->default(256)
->minValue(1)
->maxValue(1024)
->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB'),
TextInput::make('daemon_sftp')
->columnSpan(1)

View File

@@ -319,10 +319,10 @@ class EditNode extends EditRecord
'lg' => 1,
])
->label(trans('admin/node.upload_limit'))
->hintIcon('tabler-question-mark', trans('admin/node.upload_limit_help.0') . trans('admin/node.upload_limit_help.1'))
->numeric()->required()
->hintIcon('tabler-question-mark', trans('admin/node.upload_limit_help'))
->numeric()
->required()
->minValue(1)
->maxValue(1024)
->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB'),
TextInput::make('daemon_sftp')
->columnSpan([

View File

@@ -83,15 +83,21 @@ class BackupController extends ClientApiController
// how best to allow a user to create a backup that is locked without also preventing
// them from just filling up a server with backups that can never be deleted?
if ($request->user()->can(SubuserPermission::BackupDelete, $server)) {
$action->setIsLocked((bool) $request->input('is_locked'));
$action->setIsLocked($request->boolean('is_locked'));
}
$backup = $action->handle($server, $request->input('name'));
$backup = Activity::event('server:backup.start')->transaction(function ($log) use ($action, $server, $request) {
$server->backups()->lockForUpdate();
Activity::event('server:backup.start')
->subject($backup)
->property(['name' => $backup->name, 'locked' => (bool) $request->input('is_locked')])
->log();
$backup = $action->handle($server, $request->input('name'));
$log->subject($backup)->property([
'name' => $backup->name,
'locked' => $request->boolean('is_locked'),
]);
return $backup;
});
return $this->fractal->item($backup)
->transformWith($this->getTransformer(BackupTransformer::class))

View File

@@ -59,12 +59,15 @@ class DatabaseController extends ClientApiController
*/
public function store(StoreDatabaseRequest $request, Server $server): array
{
$database = $this->deployDatabaseService->handle($server, $request->validated());
$database = Activity::event('server:database.create')->transaction(function ($log) use ($request, $server) {
$server->databases()->lockForUpdate();
Activity::event('server:database.create')
->subject($database)
->property('name', $database->database)
->log();
$database = $this->deployDatabaseService->handle($server, $request->validated());
$log->subject($database)->property('name', $database->database);
return $database;
});
return $this->fractal->item($database)
->parseIncludes(['password'])

View File

@@ -107,16 +107,19 @@ class NetworkAllocationController extends ClientApiController
*/
public function store(NewAllocationRequest $request, Server $server): array
{
if ($server->allocations()->count() >= $server->allocation_limit) {
throw new DisplayException('Cannot assign additional allocations to this server: limit has been reached.');
}
$allocation = Activity::event('server:allocation.create')->transaction(function ($log) use ($server) {
$server->allocations()->lockForUpdate();
$allocation = $this->assignableAllocationService->handle($server);
if ($server->allocations->count() >= $server->allocation_limit) {
throw new DisplayException('Cannot assign additional allocations to this server: limit has been reached.');
}
Activity::event('server:allocation.create')
->subject($allocation)
->property('allocation', $allocation->address)
->log();
$allocation = $this->assignableAllocationService->handle($server);
$log->subject($allocation)->property('allocation', $allocation->address);
return $allocation;
});
return $this->fractal->item($allocation)
->transformWith($this->getTransformer(AllocationTransformer::class))

View File

@@ -131,7 +131,7 @@ class BackupRemoteUploadController extends Controller
*/
private function getConfiguredMaxPartSize(): int
{
$maxPartSize = (int) config('backups.max_part_size', self::DEFAULT_MAX_PART_SIZE);
$maxPartSize = config('backups.max_part_size', self::DEFAULT_MAX_PART_SIZE);
if ($maxPartSize <= 0) {
$maxPartSize = self::DEFAULT_MAX_PART_SIZE;
}

View File

@@ -110,7 +110,7 @@ class Node extends Model implements Validatable
'daemon_listen' => ['required', 'numeric', 'between:1,65535'],
'daemon_connect' => ['required', 'numeric', 'between:1,65535'],
'maintenance_mode' => ['boolean'],
'upload_size' => ['int', 'between:1,1024'],
'upload_size' => ['int', 'min:1'],
'tags' => ['array'],
];

View File

@@ -2,6 +2,7 @@
namespace App\Providers;
use App\Enums\ResourceLimit;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Foundation\Http\Middleware\TrimStrings;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
@@ -98,5 +99,7 @@ class RouteServiceProvider extends ServiceProvider
config('http.rate_limit.application')
)->by($key);
});
ResourceLimit::boot();
}
}

View File

@@ -133,6 +133,9 @@ class DaemonServerRepository extends DaemonRepository
* make it easier to revoke tokens on the fly. This ensures that the JTI key is formatted
* correctly and avoids any costly mistakes in the codebase.
*
* @deprecated
* @see self::deauthorize()
*
* @throws ConnectionException
*/
public function revokeUserJTI(int $id): void
@@ -143,6 +146,21 @@ class DaemonServerRepository extends DaemonRepository
]);
}
/**
* Deauthorizes a user (disconnects websockets and SFTP) on the Wings instance for the server.
*
* @throws ConnectionException
*/
public function deauthorize(string $user): void
{
$this->getHttpClient()->post('/api/deauthorize-user', [
'json' => [
'user' => $user,
'servers' => [$this->server->uuid],
],
]);
}
public function getInstallLogs(): string
{
return $this->getHttpClient()

View File

@@ -48,7 +48,7 @@ class DetailsModificationService
// websockets.
if ($server->owner_id !== $owner) {
try {
$this->serverRepository->setServer($server)->revokeUserJTI($owner);
$this->serverRepository->setServer($server)->deauthorize($server->user->uuid);
} catch (ConnectionException) {
// Do nothing. A failure here is not ideal, but it is likely to be caused by daemon
// being offline, or in an entirely broken state. Remember, these tokens reset every

View File

@@ -78,7 +78,10 @@ class ServerDeletionService
}
}
$server->allocations()->update(['server_id' => null]);
$server->allocations()->update([
'server_id' => null,
'notes' => null,
]);
$server->delete();
});

View File

@@ -28,7 +28,7 @@ class SubuserDeletionService
event(new SubUserRemoved($subuser->server, $subuser->user));
try {
$this->serverRepository->setServer($server)->revokeUserJTI($subuser->user_id);
$this->serverRepository->setServer($server)->deauthorize($subuser->user->uuid);
} catch (ConnectionException $exception) {
// Don't block this request if we can't connect to the daemon instance.
logger()->warning($exception, ['user_id' => $subuser->user_id, 'server_id' => $server->id]);

View File

@@ -46,7 +46,7 @@ class SubuserUpdateService
$subuser->update(['permissions' => $cleanedPermissions]);
try {
$this->serverRepository->setServer($server)->revokeUserJTI($subuser->user_id);
$this->serverRepository->setServer($server)->deauthorize($subuser->user->uuid);
} catch (ConnectionException $exception) {
// Don't block this request if we can't connect to the daemon instance. Chances are it is
// offline and the token will be invalid once daemon boots back.

View File

@@ -44,6 +44,10 @@ return Application::configure(basePath: dirname(__DIR__))
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
'node.maintenance' => \App\Http\Middleware\MaintenanceMiddleware::class,
]);
$middleware->priority([
\Illuminate\Routing\Middleware\SubstituteBindings::class,
]);
})
->withSingletons([
\Illuminate\Contracts\Console\Kernel::class => \App\Console\Kernel::class,

View File

@@ -1,5 +1,6 @@
<?php
use App\Http\Controllers\Api\Remote\Backups\BackupRemoteUploadController;
use App\Models\Backup;
return [
@@ -10,16 +11,16 @@ return [
// This value is used to determine the lifespan of UploadPart presigned urls that daemon
// uses to upload backups to S3 storage. Value is in minutes, so this would default to an hour.
'presigned_url_lifespan' => env('BACKUP_PRESIGNED_URL_LIFESPAN', 60),
'presigned_url_lifespan' => (int) env('BACKUP_PRESIGNED_URL_LIFESPAN', 60),
// This value defines the maximal size of a single part for the S3 multipart upload during backups
// The maximal part size must be given in bytes. The default value is 5GB.
// Note that 5GB is the maximum for a single part when using AWS S3.
'max_part_size' => env('BACKUP_MAX_PART_SIZE', 5 * 1024 * 1024 * 1024),
'max_part_size' => (int) env('BACKUP_MAX_PART_SIZE', BackupRemoteUploadController::DEFAULT_MAX_PART_SIZE),
// The time to wait before automatically failing a backup, time is in minutes and defaults
// to 6 hours. To disable this feature, set the value to `0`.
'prune_age' => env('BACKUP_PRUNE_AGE', 360),
'prune_age' => (int) env('BACKUP_PRUNE_AGE', 360),
// Defines the backup creation throttle limits for users. In this default example, we allow
// a user to create two (successful or pending) backups per 10 minutes. Even if they delete
@@ -27,8 +28,8 @@ return [
//
// Set the period to "0" to disable this throttle. The period is defined in seconds.
'throttles' => [
'limit' => env('BACKUP_THROTTLE_LIMIT', 2),
'period' => env('BACKUP_THROTTLE_PERIOD', 600),
'limit' => (int) env('BACKUP_THROTTLE_LIMIT', 2),
'period' => (int) env('BACKUP_THROTTLE_PERIOD', 600),
],
'disks' => [

View File

@@ -13,7 +13,7 @@ return [
*/
'rate_limit' => [
'client_period' => 1,
'client' => env('APP_API_CLIENT_RATELIMIT', 720),
'client' => env('APP_API_CLIENT_RATELIMIT', 120),
'application_period' => 1,
'application' => env('APP_API_APPLICATION_RATELIMIT', 240),

View File

@@ -8,7 +8,7 @@ To start contributing to Pelican Panel, you need to have a basic understanding o
* [PHP](https://php.net) & [Laravel](https://laravel.com)
* [Livewire](https://laravel-livewire.com) & [Filament](https://filamentphp.com)
* [Git](https://git-scm.com) & [Github](https://github.com)
* [Git](https://git-scm.com) & [GitHub](https://github.com)
## Dev Environment Setup

View File

@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
DB::table('allocations')
->whereNull('server_id')
->update(['notes' => null]);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// Not needed
}
};

View File

@@ -61,10 +61,7 @@ return [
'tags' => 'Tags',
'upload_limit' => 'Upload Limit',
'upload_limit_help' => [
'Enter the maximum size of files that can be uploaded through the web-based file manager.',
'Make sure your webserver supports file uploads of this size!',
],
'upload_limit_help' => 'Enter the maximum size of files that can be uploaded through the web-based file manager.',
'sftp_port' => 'SFTP Port',
'sftp_alias' => 'SFTP Alias',
'sftp_alias_help' => 'Display alias for the SFTP address. Leave empty to use the Node FQDN.',

View File

@@ -1,5 +1,6 @@
<?php
use App\Enums\ResourceLimit;
use App\Http\Controllers\Api\Client;
use App\Http\Middleware\Activity\AccountSubject;
use App\Http\Middleware\Activity\ServerSubject;
@@ -50,7 +51,9 @@ Route::prefix('/account')->middleware(AccountSubject::class)->group(function ()
*/
Route::prefix('/servers/{server:uuid}')->middleware([ServerSubject::class, AuthenticateServerAccess::class, ResourceBelongsToServer::class])->group(function () {
Route::get('/', [Client\Servers\ServerController::class, 'index'])->name('api:client:server.view');
Route::get('/websocket', Client\Servers\WebsocketController::class)->name('api:client:server.ws');
Route::middleware([ResourceLimit::Websocket->middleware()])
->get('/websocket', Client\Servers\WebsocketController::class)
->name('api:client:server.ws');
Route::get('/resources', Client\Servers\ResourceUtilizationController::class)->name('api:client:server.resources');
Route::get('/activity', Client\Servers\ActivityLogController::class)->name('api:client:server.activity');
@@ -59,7 +62,8 @@ Route::prefix('/servers/{server:uuid}')->middleware([ServerSubject::class, Authe
Route::prefix('/databases')->group(function () {
Route::get('/', [Client\Servers\DatabaseController::class, 'index']);
Route::post('/', [Client\Servers\DatabaseController::class, 'store']);
Route::middleware([ResourceLimit::DatabaseCreate->middleware()])
->post('/', [Client\Servers\DatabaseController::class, 'store']);
Route::post('/{database}/rotate-password', [Client\Servers\DatabaseController::class, 'rotatePassword']);
Route::delete('/{database}', [Client\Servers\DatabaseController::class, 'delete']);
});
@@ -76,13 +80,15 @@ Route::prefix('/servers/{server:uuid}')->middleware([ServerSubject::class, Authe
Route::post('/delete', [Client\Servers\FileController::class, 'delete']);
Route::post('/create-folder', [Client\Servers\FileController::class, 'create']);
Route::post('/chmod', [Client\Servers\FileController::class, 'chmod']);
Route::post('/pull', [Client\Servers\FileController::class, 'pull'])->middleware(['throttle:10,5']);
Route::middleware([ResourceLimit::FilePull->middleware()])
->post('/pull', [Client\Servers\FileController::class, 'pull']);
Route::get('/upload', Client\Servers\FileUploadController::class);
});
Route::prefix('/schedules')->group(function () {
Route::get('/', [Client\Servers\ScheduleController::class, 'index']);
Route::post('/', [Client\Servers\ScheduleController::class, 'store']);
Route::middleware([ResourceLimit::ScheduleCreate->middleware()])
->post('/', [Client\Servers\ScheduleController::class, 'store']);
Route::get('/{schedule}', [Client\Servers\ScheduleController::class, 'view']);
Route::post('/{schedule}', [Client\Servers\ScheduleController::class, 'update']);
Route::post('/{schedule}/execute', [Client\Servers\ScheduleController::class, 'execute']);
@@ -95,7 +101,8 @@ Route::prefix('/servers/{server:uuid}')->middleware([ServerSubject::class, Authe
Route::prefix('/network/allocations')->group(function () {
Route::get('/', [Client\Servers\NetworkAllocationController::class, 'index']);
Route::post('/', [Client\Servers\NetworkAllocationController::class, 'store']);
Route::middleware([ResourceLimit::AllocationCreate->middleware()])
->post('/', [Client\Servers\NetworkAllocationController::class, 'store']);
Route::post('/{allocation}', [Client\Servers\NetworkAllocationController::class, 'update']);
Route::post('/{allocation}/primary', [Client\Servers\NetworkAllocationController::class, 'setPrimary']);
Route::delete('/{allocation}', [Client\Servers\NetworkAllocationController::class, 'delete']);
@@ -103,7 +110,8 @@ Route::prefix('/servers/{server:uuid}')->middleware([ServerSubject::class, Authe
Route::prefix('/users')->group(function () {
Route::get('/', [Client\Servers\SubuserController::class, 'index']);
Route::post('/', [Client\Servers\SubuserController::class, 'store']);
Route::middleware([ResourceLimit::SubuserCreate->middleware()])
->post('/', [Client\Servers\SubuserController::class, 'store']);
Route::get('/{user:uuid}', [Client\Servers\SubuserController::class, 'view']);
Route::post('/{user:uuid}', [Client\Servers\SubuserController::class, 'update']);
Route::delete('/{user:uuid}', [Client\Servers\SubuserController::class, 'delete']);
@@ -116,7 +124,8 @@ Route::prefix('/servers/{server:uuid}')->middleware([ServerSubject::class, Authe
Route::get('/{backup:uuid}/download', [Client\Servers\BackupController::class, 'download']);
Route::put('/{backup:uuid}/rename', [Client\Servers\BackupController::class, 'rename']);
Route::post('/{backup:uuid}/lock', [Client\Servers\BackupController::class, 'toggleLock']);
Route::post('/{backup:uuid}/restore', [Client\Servers\BackupController::class, 'restore']);
Route::middleware([ResourceLimit::BackupRestore->middleware()])
->post('/{backup:uuid}/restore', [Client\Servers\BackupController::class, 'restore']);
Route::delete('/{backup:uuid}', [Client\Servers\BackupController::class, 'delete']);
});

View File

@@ -8,8 +8,9 @@ While Pelican is in beta, we only provide security fixes for the most recent bet
## Reporting a Vulnerability
Please report any vulnerabilities via _one_ of the following methods:
- [Create a security advisory on Github](https://github.com/pelican-dev/panel/security/advisories/new)
- Send an e-mail to team@pelican.dev
- [Create a security advisory on GitHub](https://github.com/pelican-dev/panel/security/advisories/new)
- Send an e-mail to <team@pelican.dev>
Include steps to reproduce, affected versions, impact, and a proof of concept if available.

View File

@@ -42,7 +42,7 @@ class DeleteSubuserTest extends ClientApiIntegrationTestCase
'permissions' => [SubuserPermission::WebsocketConnect],
]);
$mock->expects('setServer->revokeUserJTI')->with($subuser->id)->andReturnUndefined();
$mock->expects('setServer->deauthorize')->with($subuser->uuid)->andReturnUndefined();
$this->actingAs($user)->deleteJson($this->link($server) . "/users/$subuser->uuid")->assertNoContent();
@@ -58,7 +58,7 @@ class DeleteSubuserTest extends ClientApiIntegrationTestCase
'permissions' => [SubuserPermission::WebsocketConnect],
]);
$mock->expects('setServer->revokeUserJTI')->with($subuser->id)->andReturnUndefined();
$mock->expects('setServer->deauthorize')->with($subuser->uuid)->andReturnUndefined();
$this->actingAs($user)->deleteJson($this->link($server) . "/users/$subuser->uuid")->assertNoContent();
}

View File

@@ -37,7 +37,7 @@ class SubuserAuthorizationTest extends ClientApiIntegrationTestCase
$this->instance(DaemonServerRepository::class, $mock = \Mockery::mock(DaemonServerRepository::class));
if ($method === 'DELETE') {
$mock->expects('setServer->revokeUserJTI')->with($internal->id)->andReturnUndefined();
$mock->expects('setServer->deauthorize')->with($internal->uuid)->andReturnUndefined();
}
// This route is acceptable since they're accessing a subuser on their own server.