diff --git a/app/Filament/Resources/ApiKeyResource.php b/app/Filament/Resources/ApiKeyResource.php index 282ef6ecb..46350714e 100644 --- a/app/Filament/Resources/ApiKeyResource.php +++ b/app/Filament/Resources/ApiKeyResource.php @@ -19,7 +19,7 @@ class ApiKeyResource extends Resource public static function getNavigationBadge(): ?string { - return static::getModel()::where('key_type', '2')->count() ?: null; + return static::getModel()::where('key_type', ApiKey::TYPE_APPLICATION)->count() ?: null; } public static function canEdit(Model $record): bool diff --git a/app/Filament/Resources/NodeResource/Pages/EditNode.php b/app/Filament/Resources/NodeResource/Pages/EditNode.php index 10d5d1062..b5f3804e1 100644 --- a/app/Filament/Resources/NodeResource/Pages/EditNode.php +++ b/app/Filament/Resources/NodeResource/Pages/EditNode.php @@ -4,9 +4,11 @@ namespace App\Filament\Resources\NodeResource\Pages; use App\Filament\Resources\NodeResource; use App\Models\Node; +use App\Services\Nodes\NodeAutoDeployService; use App\Services\Nodes\NodeUpdateService; use Filament\Actions; use Filament\Forms; +use Filament\Forms\Components\Actions as FormActions; use Filament\Forms\Components\Fieldset; use Filament\Forms\Components\Grid; use Filament\Forms\Components\Placeholder; @@ -21,6 +23,7 @@ use Filament\Forms\Get; use Filament\Forms\Set; use Filament\Notifications\Notification; use Filament\Resources\Pages\EditRecord; +use Filament\Support\Enums\Alignment; use Illuminate\Support\HtmlString; use Webbingbrasil\FilamentCopyActions\Forms\Actions\CopyAction; @@ -149,19 +152,9 @@ class EditNode extends EditRecord true => 'success', false => 'danger', ]) - ->columnSpan([ - 'default' => 1, - 'sm' => 1, - 'md' => 1, - 'lg' => 1, - ]), + ->columnSpan(1), TextInput::make('daemon_listen') - ->columnSpan([ - 'default' => 1, - 'sm' => 1, - 'md' => 1, - 'lg' => 1, - ]) + ->columnSpan(1) ->label(trans('strings.port')) ->helperText('If you are running the daemon behind Cloudflare you should set the daemon port to 8443 to allow websocket proxying over SSL.') ->minValue(1) @@ -182,12 +175,7 @@ class EditNode extends EditRecord ->maxLength(100), ToggleButtons::make('scheme') ->label('Communicate over SSL') - ->columnSpan([ - 'default' => 1, - 'sm' => 1, - 'md' => 1, - 'lg' => 1, - ]) + ->columnSpan(1) ->inline() ->helperText(function (Get $get) { if (request()->isSecure()) { @@ -215,23 +203,48 @@ class EditNode extends EditRecord ]) ->default(fn () => request()->isSecure() ? 'https' : 'http'), ]), Tab::make('Advanced Settings') - ->columns(['default' => 1, 'sm' => 1, 'md' => 4, 'lg' => 6]) + ->columns([ + 'default' => 1, + 'sm' => 1, + 'md' => 4, + 'lg' => 6, + ]) ->icon('tabler-server-cog') ->schema([ TextInput::make('id') ->label('Node ID') - ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 1]) + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 2, + 'lg' => 1, + ]) ->disabled(), TextInput::make('uuid') - ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2]) + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 2, + 'lg' => 2, + ]) ->label('Node UUID') ->hintAction(CopyAction::make()) ->disabled(), TagsInput::make('tags') - ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2]) + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 2, + 'lg' => 2, + ]) ->placeholder('Add Tags'), TextInput::make('upload_size') - ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 1]) + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 2, + 'lg' => 1, + ]) ->label('Upload Limit') ->hintIcon('tabler-question-mark') ->hintIconTooltip('Enter the maximum size of files that can be uploaded through the web-based file manager.') @@ -240,7 +253,12 @@ class EditNode extends EditRecord ->maxValue(1024) ->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB'), TextInput::make('daemon_sftp') - ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 3]) + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 1, + 'lg' => 3, + ]) ->label('SFTP Port') ->minValue(1) ->maxValue(65535) @@ -248,11 +266,21 @@ class EditNode extends EditRecord ->required() ->integer(), TextInput::make('daemon_sftp_alias') - ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 3]) + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 1, + 'lg' => 3, + ]) ->label('SFTP Alias') ->helperText('Display alias for the SFTP address. Leave empty to use the Node FQDN.'), ToggleButtons::make('public') - ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 3]) + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 1, + 'lg' => 3, + ]) ->label('Use Node for deployment?')->inline() ->options([ true => 'Yes', @@ -263,7 +291,12 @@ class EditNode extends EditRecord false => 'danger', ]), ToggleButtons::make('maintenance_mode') - ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 3]) + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 1, + 'lg' => 3, + ]) ->label('Maintenance Mode')->inline() ->hinticon('tabler-question-mark') ->hintIconTooltip("If the node is marked 'Under Maintenance' users won't be able to access servers that are on this node.") @@ -276,7 +309,12 @@ class EditNode extends EditRecord true => 'danger', ]), Grid::make() - ->columns(['default' => 1, 'sm' => 1, 'md' => 3, 'lg' => 6]) + ->columns([ + 'default' => 1, + 'sm' => 1, + 'md' => 3, + 'lg' => 6, + ]) ->columnSpanFull() ->schema([ ToggleButtons::make('unlimited_mem') @@ -293,14 +331,24 @@ class EditNode extends EditRecord true => 'primary', false => 'warning', ]) - ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 2]), + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 1, + 'lg' => 2, + ]), TextInput::make('memory') ->dehydratedWhenHidden() ->hidden(fn (Get $get) => $get('unlimited_mem')) ->label('Memory Limit')->inlineLabel() ->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB') ->required() - ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 2]) + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 1, + 'lg' => 2, + ]) ->numeric() ->minValue(0), TextInput::make('memory_overallocate') @@ -310,14 +358,24 @@ class EditNode extends EditRecord ->hidden(fn (Get $get) => $get('unlimited_mem')) ->hintIcon('tabler-question-mark') ->hintIconTooltip('The % allowable to go over the set limit.') - ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 2]) + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 1, + 'lg' => 2, + ]) ->numeric() ->minValue(-1) ->maxValue(100) ->suffix('%'), ]), Grid::make() - ->columns(['default' => 1, 'sm' => 1, 'md' => 3, 'lg' => 6]) + ->columns([ + 'default' => 1, + 'sm' => 1, + 'md' => 3, + 'lg' => 6, + ]) ->schema([ ToggleButtons::make('unlimited_disk') ->label('Disk')->inlineLabel()->inline() @@ -333,14 +391,24 @@ class EditNode extends EditRecord true => 'primary', false => 'warning', ]) - ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 2]), + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 1, + 'lg' => 2, + ]), TextInput::make('disk') ->dehydratedWhenHidden() ->hidden(fn (Get $get) => $get('unlimited_disk')) ->label('Disk Limit')->inlineLabel() ->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB') ->required() - ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 2]) + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 1, + 'lg' => 2, + ]) ->numeric() ->minValue(0), TextInput::make('disk_overallocate') @@ -349,7 +417,12 @@ class EditNode extends EditRecord ->label('Overallocate')->inlineLabel() ->hintIcon('tabler-question-mark') ->hintIconTooltip('The % allowable to go over the set limit.') - ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 2]) + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 1, + 'lg' => 2, + ]) ->required() ->numeric() ->minValue(-1) @@ -412,19 +485,61 @@ class EditNode extends EditRecord ->rows(19) ->hintAction(CopyAction::make()) ->columnSpanFull(), - Forms\Components\Actions::make([ - Forms\Components\Actions\Action::make('resetKey') - ->label('Reset Daemon Token') - ->color('danger') - ->requiresConfirmation() - ->modalHeading('Reset Daemon Token?') - ->modalDescription('Resetting the daemon token will void any request coming from the old token. This token is used for all sensitive operations on the daemon including server creation and deletion. We suggest changing this token regularly for security.') - ->action(function (NodeUpdateService $nodeUpdateService, Node $node) { - $nodeUpdateService->handle($node, [], true); - Notification::make()->success()->title('Daemon Key Reset')->send(); - $this->fillForm(); - }), - ]), + Grid::make() + ->columns() + ->schema([ + FormActions::make([ + FormActions\Action::make('autoDeploy') + ->label('Auto Deploy Command') + ->color('primary') + ->modalHeading('Auto Deploy Command') + ->icon('tabler-rocket') + ->modalSubmitAction(false) + ->modalCancelAction(false) + ->modalFooterActionsAlignment(Alignment::Center) + ->form([ + ToggleButtons::make('docker') + ->label('Type') + ->live() + ->helperText('Choose between Standalone and Docker install.') + ->inline() + ->default(false) + ->afterStateUpdated(fn (bool $state, NodeAutoDeployService $service, Node $node, Set $set) => $set('generatedToken', $service->handle(request(), $node, $state))) + ->options([ + false => 'Standalone', + true => 'Docker', + ]) + ->colors([ + false => 'primary', + true => 'success', + ]) + ->columnSpan(1), + Textarea::make('generatedToken') + ->label('To auto-configure your node run the following command:') + ->readOnly() + ->autosize() + ->hintAction(fn (string $state) => CopyAction::make()->copyable($state)) + ->formatStateUsing(fn (NodeAutoDeployService $service, Node $node, Set $set, Get $get) => $set('generatedToken', $service->handle(request(), $node, $get('docker')))), + ]) + ->mountUsing(function (Forms\Form $form) { + Notification::make()->success()->title('Autodeploy Generated')->send(); + $form->fill(); + }), + ])->fullWidth(), + FormActions::make([ + FormActions\Action::make('resetKey') + ->label('Reset Daemon Token') + ->color('danger') + ->requiresConfirmation() + ->modalHeading('Reset Daemon Token?') + ->modalDescription('Resetting the daemon token will void any request coming from the old token. This token is used for all sensitive operations on the daemon including server creation and deletion. We suggest changing this token regularly for security.') + ->action(function (NodeUpdateService $nodeUpdateService, Node $node) { + $nodeUpdateService->handle($node, [], true); + Notification::make()->success()->title('Daemon Key Reset')->send(); + $this->fillForm(); + }), + ])->fullWidth(), + ]), ]), ]), ]); diff --git a/app/Http/Controllers/Admin/NodeAutoDeployController.php b/app/Http/Controllers/Admin/NodeAutoDeployController.php index 1029706c3..e2f89e4c8 100644 --- a/app/Http/Controllers/Admin/NodeAutoDeployController.php +++ b/app/Http/Controllers/Admin/NodeAutoDeployController.php @@ -2,12 +2,11 @@ namespace App\Http\Controllers\Admin; -use Illuminate\Http\Request; use App\Models\Node; -use App\Models\ApiKey; -use Illuminate\Http\JsonResponse; use App\Http\Controllers\Controller; -use App\Services\Api\KeyCreationService; +use App\Services\Nodes\NodeAutoDeployService; +use Illuminate\Http\Request; +use Illuminate\Http\JsonResponse; class NodeAutoDeployController extends Controller { @@ -15,48 +14,19 @@ class NodeAutoDeployController extends Controller * NodeAutoDeployController constructor. */ public function __construct( - private KeyCreationService $keyCreationService + private readonly NodeAutoDeployService $nodeAutoDeployService ) { } /** - * Generates a new API key for the logged-in user with only permission to read - * nodes, and returns that as the deployment key for a node. + * Handles the API request and returns the deployment command. * * @throws \App\Exceptions\Model\DataValidationException */ public function __invoke(Request $request, Node $node): JsonResponse { - $keys = $request->user()->apiKeys() - ->where('key_type', ApiKey::TYPE_APPLICATION) - ->get(); + $command = $this->nodeAutoDeployService->handle($request, $node); - /** @var ApiKey|null $key */ - $key = $keys - ->filter(function (ApiKey $key) { - foreach ($key->getAttributes() as $permission => $value) { - if ($permission === 'r_nodes' && $value === 1) { - return true; - } - } - - return false; - }) - ->first(); - - // We couldn't find a key that exists for this user with only permission for - // reading nodes. Go ahead and create it now. - if (!$key) { - $key = $this->keyCreationService->setKeyType(ApiKey::TYPE_APPLICATION)->handle([ - 'user_id' => $request->user()->id, - 'memo' => 'Automatically generated node deployment key.', - 'allowed_ips' => [], - ], ['r_nodes' => 1]); - } - - return new JsonResponse([ - 'node' => $node->id, - 'token' => $key->identifier . $key->token, - ]); + return new JsonResponse(['command' => $command]); } } diff --git a/app/Http/Controllers/Api/Client/ApiKeyController.php b/app/Http/Controllers/Api/Client/ApiKeyController.php index 0a1026ad5..7889b3e00 100644 --- a/app/Http/Controllers/Api/Client/ApiKeyController.php +++ b/app/Http/Controllers/Api/Client/ApiKeyController.php @@ -29,7 +29,7 @@ class ApiKeyController extends ClientApiController */ public function store(StoreApiKeyRequest $request): array { - if ($request->user()->apiKeys->count() >= 25) { + if ($request->user()->apiKeys->count() >= config('panel.api.key_limit')) { throw new DisplayException('You have reached the account limit for number of API keys.'); } diff --git a/app/Models/ApiKey.php b/app/Models/ApiKey.php index 96c714f24..09914483e 100644 --- a/app/Models/ApiKey.php +++ b/app/Models/ApiKey.php @@ -71,15 +71,8 @@ class ApiKey extends Model public const TYPE_ACCOUNT = 1; - /* @deprecated */ public const TYPE_APPLICATION = 2; - /* @deprecated */ - public const TYPE_DAEMON_USER = 3; - - /* @deprecated */ - public const TYPE_DAEMON_APPLICATION = 4; - /** * The length of API key identifiers. */ @@ -138,7 +131,7 @@ class ApiKey extends Model */ public static array $validationRules = [ 'user_id' => 'required|exists:users,id', - 'key_type' => 'present|integer|min:0|max:4', + 'key_type' => 'present|integer|min:0|max:2', 'identifier' => 'required|string|size:16|unique:api_keys,identifier', 'token' => 'required|string', 'memo' => 'required|nullable|string|max:500', diff --git a/app/Services/Nodes/NodeAutoDeployService.php b/app/Services/Nodes/NodeAutoDeployService.php new file mode 100644 index 000000000..5e9c55b22 --- /dev/null +++ b/app/Services/Nodes/NodeAutoDeployService.php @@ -0,0 +1,58 @@ +where('key_type', ApiKey::TYPE_APPLICATION) + ->where('r_nodes', true) + ->first(); + + // We couldn't find a key that exists for this user with only permission for + // reading nodes. Go ahead and create it now. + if (!$key) { + $key = $this->keyCreationService->setKeyType(ApiKey::TYPE_APPLICATION)->handle([ + 'memo' => 'Automatically generated node deployment key.', + 'user_id' => $request->user()->id, + ], ['r_nodes' => true]); + } + + $token = $key->identifier . $key->token; + + if (!$token) { + return null; + } + + return sprintf( + '%s wings configure --panel-url %s --token %s --node %d%s', + $docker ? 'docker compose exec -it' : 'sudo', + config('app.url'), + $token, + $node->id, + $request->isSecure() ? '' : ' --allow-insecure' + ); + } +} diff --git a/config/panel.php b/config/panel.php index 487ee354b..5d0cabd0c 100644 --- a/config/panel.php +++ b/config/panel.php @@ -167,4 +167,9 @@ return [ 'use_binary_prefix' => env('PANEL_USE_BINARY_PREFIX', true), 'editable_server_descriptions' => env('PANEL_EDITABLE_SERVER_DESCRIPTIONS', true), + + 'api' => [ + 'key_limit' => env('API_KEYS_LIMIT', 25), + 'key_expire_time' => env('API_KEYS_EXPIRE_TIME', 720), + ], ]; diff --git a/phpunit.xml b/phpunit.xml index 610eac74e..191be938c 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,5 +1,11 @@ - + ./tests/Integration diff --git a/tests/Integration/Api/Client/ApiKeyControllerTest.php b/tests/Integration/Api/Client/ApiKeyControllerTest.php index 462875c59..aa87f62c6 100644 --- a/tests/Integration/Api/Client/ApiKeyControllerTest.php +++ b/tests/Integration/Api/Client/ApiKeyControllerTest.php @@ -96,14 +96,14 @@ class ApiKeyControllerTest extends ClientApiIntegrationTestCase } /** - * Test that no more than 25 API keys can exist at any one time for an account. This prevents + * Test that no more than the Max number of API keys can exist at one time for an account. This prevents * a DoS attack vector against the panel. */ public function testApiKeyLimitIsApplied(): void { /** @var \App\Models\User $user */ $user = User::factory()->create(); - ApiKey::factory()->times(25)->for($user)->create([ + ApiKey::factory()->times(config('panel.api.key_limit', 25))->for($user)->create([ 'key_type' => ApiKey::TYPE_ACCOUNT, ]);