diff --git a/app/Enums/ResourceLimit.php b/app/Enums/ResourceLimit.php new file mode 100644 index 000000000..59707601a --- /dev/null +++ b/app/Enums/ResourceLimit.php @@ -0,0 +1,65 @@ +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); + }); + } + } +} diff --git a/app/Extensions/OAuth/Schemas/GithubSchema.php b/app/Extensions/OAuth/Schemas/GithubSchema.php index c3dfc4894..b7258ab4b 100644 --- a/app/Extensions/OAuth/Schemas/GithubSchema.php +++ b/app/Extensions/OAuth/Schemas/GithubSchema.php @@ -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('
Visit the
Enter an Application name (e.g. your panel name), set Homepage URL to your panel url and enter the below url as Authorization callback URL.
'))), + ->state(new HtmlString(Blade::render('Visit the
Enter an Application name (e.g. your panel name), set Homepage URL to your panel url and enter the below url as Authorization callback URL.
'))), TextInput::make('_noenv_callback') ->label('Authorization callback URL') ->dehydrated() diff --git a/app/Filament/Admin/Resources/Nodes/Pages/CreateNode.php b/app/Filament/Admin/Resources/Nodes/Pages/CreateNode.php index 5850bc8b8..b5de18d5d 100644 --- a/app/Filament/Admin/Resources/Nodes/Pages/CreateNode.php +++ b/app/Filament/Admin/Resources/Nodes/Pages/CreateNode.php @@ -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) diff --git a/app/Filament/Admin/Resources/Nodes/Pages/EditNode.php b/app/Filament/Admin/Resources/Nodes/Pages/EditNode.php index 20cd7036c..0497beae2 100644 --- a/app/Filament/Admin/Resources/Nodes/Pages/EditNode.php +++ b/app/Filament/Admin/Resources/Nodes/Pages/EditNode.php @@ -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([ diff --git a/app/Http/Controllers/Api/Client/Servers/BackupController.php b/app/Http/Controllers/Api/Client/Servers/BackupController.php index fd6217ad6..d5492cbcd 100644 --- a/app/Http/Controllers/Api/Client/Servers/BackupController.php +++ b/app/Http/Controllers/Api/Client/Servers/BackupController.php @@ -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)) diff --git a/app/Http/Controllers/Api/Client/Servers/DatabaseController.php b/app/Http/Controllers/Api/Client/Servers/DatabaseController.php index 2ce65f017..445c8b2bc 100644 --- a/app/Http/Controllers/Api/Client/Servers/DatabaseController.php +++ b/app/Http/Controllers/Api/Client/Servers/DatabaseController.php @@ -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']) diff --git a/app/Http/Controllers/Api/Client/Servers/NetworkAllocationController.php b/app/Http/Controllers/Api/Client/Servers/NetworkAllocationController.php index 08bfb54f2..fc8ae338d 100644 --- a/app/Http/Controllers/Api/Client/Servers/NetworkAllocationController.php +++ b/app/Http/Controllers/Api/Client/Servers/NetworkAllocationController.php @@ -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)) diff --git a/app/Http/Controllers/Api/Remote/Backups/BackupRemoteUploadController.php b/app/Http/Controllers/Api/Remote/Backups/BackupRemoteUploadController.php index 77d869949..fa149c31e 100644 --- a/app/Http/Controllers/Api/Remote/Backups/BackupRemoteUploadController.php +++ b/app/Http/Controllers/Api/Remote/Backups/BackupRemoteUploadController.php @@ -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; } diff --git a/app/Models/Node.php b/app/Models/Node.php index 69b1072d5..4c5479dc3 100644 --- a/app/Models/Node.php +++ b/app/Models/Node.php @@ -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'], ]; diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index 311e3d0fc..36c42d07c 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -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(); } } diff --git a/app/Repositories/Daemon/DaemonServerRepository.php b/app/Repositories/Daemon/DaemonServerRepository.php index e4abe7408..6088acf13 100644 --- a/app/Repositories/Daemon/DaemonServerRepository.php +++ b/app/Repositories/Daemon/DaemonServerRepository.php @@ -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() diff --git a/app/Services/Servers/DetailsModificationService.php b/app/Services/Servers/DetailsModificationService.php index b3b789d60..e8c40e3c5 100644 --- a/app/Services/Servers/DetailsModificationService.php +++ b/app/Services/Servers/DetailsModificationService.php @@ -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 diff --git a/app/Services/Servers/ServerDeletionService.php b/app/Services/Servers/ServerDeletionService.php index 925694f27..7f59d017c 100644 --- a/app/Services/Servers/ServerDeletionService.php +++ b/app/Services/Servers/ServerDeletionService.php @@ -78,7 +78,10 @@ class ServerDeletionService } } - $server->allocations()->update(['server_id' => null]); + $server->allocations()->update([ + 'server_id' => null, + 'notes' => null, + ]); $server->delete(); }); diff --git a/app/Services/Subusers/SubuserDeletionService.php b/app/Services/Subusers/SubuserDeletionService.php index b68b948f8..bbf232995 100644 --- a/app/Services/Subusers/SubuserDeletionService.php +++ b/app/Services/Subusers/SubuserDeletionService.php @@ -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]); diff --git a/app/Services/Subusers/SubuserUpdateService.php b/app/Services/Subusers/SubuserUpdateService.php index 14fd9decd..1ce9c0ac5 100644 --- a/app/Services/Subusers/SubuserUpdateService.php +++ b/app/Services/Subusers/SubuserUpdateService.php @@ -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. diff --git a/bootstrap/app.php b/bootstrap/app.php index d40ab8cc3..cd5ca7005 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -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, diff --git a/config/backups.php b/config/backups.php index 0074bf8d7..c3bf16cf7 100644 --- a/config/backups.php +++ b/config/backups.php @@ -1,5 +1,6 @@ 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' => [ diff --git a/config/http.php b/config/http.php index ed54e475b..69bf4b530 100644 --- a/config/http.php +++ b/config/http.php @@ -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), diff --git a/contributing.md b/contributing.md index 70cc654c7..627c04e4c 100644 --- a/contributing.md +++ b/contributing.md @@ -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 diff --git a/database/migrations/2026_01_09_134116_clear_unused_allocation_notes.php b/database/migrations/2026_01_09_134116_clear_unused_allocation_notes.php new file mode 100644 index 000000000..6eb6e5c2d --- /dev/null +++ b/database/migrations/2026_01_09_134116_clear_unused_allocation_notes.php @@ -0,0 +1,25 @@ +whereNull('server_id') + ->update(['notes' => null]); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // Not needed + } +}; diff --git a/lang/en/admin/node.php b/lang/en/admin/node.php index 40b514673..32b3fdd32 100644 --- a/lang/en/admin/node.php +++ b/lang/en/admin/node.php @@ -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.', diff --git a/routes/api-client.php b/routes/api-client.php index 12ab28af7..4365cfeb2 100644 --- a/routes/api-client.php +++ b/routes/api-client.php @@ -1,5 +1,6 @@ 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']); }); diff --git a/security.md b/security.md index 093456c58..520e6e2d6 100644 --- a/security.md +++ b/security.md @@ -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