From f3de1855083601cf78b09f7b5f5afc86de698b9b Mon Sep 17 00:00:00 2001 From: MartinOscar <40749467+RMartinOscar@users.noreply.github.com> Date: Sun, 27 Oct 2024 02:43:19 +0200 Subject: [PATCH] Add back auto deploy (#627) * Add Docker, Refactor, Fix Notification Co-authored-by: notCharles * Pint * Required adjustments * Remove deprecated * Third time's the charm --------- Co-authored-by: notCharles --- app/Filament/Resources/ApiKeyResource.php | 2 +- .../Resources/NodeResource/Pages/EditNode.php | 211 ++++++++++++++---- .../Admin/NodeAutoDeployController.php | 44 +--- .../Api/Client/ApiKeyController.php | 2 +- app/Models/ApiKey.php | 9 +- app/Services/Nodes/NodeAutoDeployService.php | 58 +++++ config/panel.php | 5 + phpunit.xml | 8 +- .../Api/Client/ApiKeyControllerTest.php | 4 +- 9 files changed, 245 insertions(+), 98 deletions(-) create mode 100644 app/Services/Nodes/NodeAutoDeployService.php diff --git a/app/Filament/Resources/ApiKeyResource.php b/app/Filament/Resources/ApiKeyResource.php index 282ef6ecbb..46350714eb 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 10d5d10626..b5f3804e1e 100644 --- a/app/Filament/Resources/NodeResource/Pages/EditNode.php +++ b/app/Filament/Resources/NodeResource/Pages/EditNode.php @@ -4,9 +4,11 @@ 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\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 @@ public function form(Forms\Form $form): Forms\Form 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 @@ public function form(Forms\Form $form): Forms\Form ->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 @@ public function form(Forms\Form $form): Forms\Form ]) ->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 @@ public function form(Forms\Form $form): Forms\Form ->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 @@ public function form(Forms\Form $form): Forms\Form ->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 @@ public function form(Forms\Form $form): Forms\Form 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 @@ public function form(Forms\Form $form): Forms\Form 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 @@ public function form(Forms\Form $form): Forms\Form 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 @@ public function form(Forms\Form $form): Forms\Form ->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 @@ public function form(Forms\Form $form): Forms\Form 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 @@ public function form(Forms\Form $form): Forms\Form ->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 @@ public function form(Forms\Form $form): Forms\Form ->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 1029706c35..e2f89e4c85 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(); - - /** @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]); - } + $command = $this->nodeAutoDeployService->handle($request, $node); - 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 0a1026ad59..7889b3e000 100644 --- a/app/Http/Controllers/Api/Client/ApiKeyController.php +++ b/app/Http/Controllers/Api/Client/ApiKeyController.php @@ -29,7 +29,7 @@ public function index(ClientApiRequest $request): array */ 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 96c714f246..09914483e1 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 0000000000..5e9c55b223 --- /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 487ee354b5..5d0cabd0c1 100644 --- a/config/panel.php +++ b/config/panel.php @@ -167,4 +167,9 @@ '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 610eac74ed..191be938c9 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 462875c59b..aa87f62c6a 100644 --- a/tests/Integration/Api/Client/ApiKeyControllerTest.php +++ b/tests/Integration/Api/Client/ApiKeyControllerTest.php @@ -96,14 +96,14 @@ public function testApiKeyCannotSpecifyMoreThanFiftyIps(): void } /** - * 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, ]);