Add back auto deploy (#627)

* Add Docker, Refactor, Fix Notification

Co-authored-by: notCharles <charles@pelican.dev>

* Pint

* Required adjustments

* Remove deprecated

* Third time's the charm

---------

Co-authored-by: notCharles <charles@pelican.dev>
This commit is contained in:
MartinOscar 2024-10-27 02:43:19 +02:00 committed by GitHub
parent 291b514e24
commit f3de185508
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 245 additions and 98 deletions

View File

@ -19,7 +19,7 @@ class ApiKeyResource extends Resource
public static function getNavigationBadge(): ?string 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 public static function canEdit(Model $record): bool

View File

@ -4,9 +4,11 @@ namespace App\Filament\Resources\NodeResource\Pages;
use App\Filament\Resources\NodeResource; use App\Filament\Resources\NodeResource;
use App\Models\Node; use App\Models\Node;
use App\Services\Nodes\NodeAutoDeployService;
use App\Services\Nodes\NodeUpdateService; use App\Services\Nodes\NodeUpdateService;
use Filament\Actions; use Filament\Actions;
use Filament\Forms; use Filament\Forms;
use Filament\Forms\Components\Actions as FormActions;
use Filament\Forms\Components\Fieldset; use Filament\Forms\Components\Fieldset;
use Filament\Forms\Components\Grid; use Filament\Forms\Components\Grid;
use Filament\Forms\Components\Placeholder; use Filament\Forms\Components\Placeholder;
@ -21,6 +23,7 @@ use Filament\Forms\Get;
use Filament\Forms\Set; use Filament\Forms\Set;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord; use Filament\Resources\Pages\EditRecord;
use Filament\Support\Enums\Alignment;
use Illuminate\Support\HtmlString; use Illuminate\Support\HtmlString;
use Webbingbrasil\FilamentCopyActions\Forms\Actions\CopyAction; use Webbingbrasil\FilamentCopyActions\Forms\Actions\CopyAction;
@ -149,19 +152,9 @@ class EditNode extends EditRecord
true => 'success', true => 'success',
false => 'danger', false => 'danger',
]) ])
->columnSpan([ ->columnSpan(1),
'default' => 1,
'sm' => 1,
'md' => 1,
'lg' => 1,
]),
TextInput::make('daemon_listen') TextInput::make('daemon_listen')
->columnSpan([ ->columnSpan(1)
'default' => 1,
'sm' => 1,
'md' => 1,
'lg' => 1,
])
->label(trans('strings.port')) ->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.') ->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) ->minValue(1)
@ -182,12 +175,7 @@ class EditNode extends EditRecord
->maxLength(100), ->maxLength(100),
ToggleButtons::make('scheme') ToggleButtons::make('scheme')
->label('Communicate over SSL') ->label('Communicate over SSL')
->columnSpan([ ->columnSpan(1)
'default' => 1,
'sm' => 1,
'md' => 1,
'lg' => 1,
])
->inline() ->inline()
->helperText(function (Get $get) { ->helperText(function (Get $get) {
if (request()->isSecure()) { if (request()->isSecure()) {
@ -215,23 +203,48 @@ class EditNode extends EditRecord
]) ])
->default(fn () => request()->isSecure() ? 'https' : 'http'), ]), ->default(fn () => request()->isSecure() ? 'https' : 'http'), ]),
Tab::make('Advanced Settings') 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') ->icon('tabler-server-cog')
->schema([ ->schema([
TextInput::make('id') TextInput::make('id')
->label('Node ID') ->label('Node ID')
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 1]) ->columnSpan([
'default' => 1,
'sm' => 1,
'md' => 2,
'lg' => 1,
])
->disabled(), ->disabled(),
TextInput::make('uuid') TextInput::make('uuid')
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2]) ->columnSpan([
'default' => 1,
'sm' => 1,
'md' => 2,
'lg' => 2,
])
->label('Node UUID') ->label('Node UUID')
->hintAction(CopyAction::make()) ->hintAction(CopyAction::make())
->disabled(), ->disabled(),
TagsInput::make('tags') TagsInput::make('tags')
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2]) ->columnSpan([
'default' => 1,
'sm' => 1,
'md' => 2,
'lg' => 2,
])
->placeholder('Add Tags'), ->placeholder('Add Tags'),
TextInput::make('upload_size') 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') ->label('Upload Limit')
->hintIcon('tabler-question-mark') ->hintIcon('tabler-question-mark')
->hintIconTooltip('Enter the maximum size of files that can be uploaded through the web-based file manager.') ->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) ->maxValue(1024)
->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB'), ->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB'),
TextInput::make('daemon_sftp') 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') ->label('SFTP Port')
->minValue(1) ->minValue(1)
->maxValue(65535) ->maxValue(65535)
@ -248,11 +266,21 @@ class EditNode extends EditRecord
->required() ->required()
->integer(), ->integer(),
TextInput::make('daemon_sftp_alias') 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') ->label('SFTP Alias')
->helperText('Display alias for the SFTP address. Leave empty to use the Node FQDN.'), ->helperText('Display alias for the SFTP address. Leave empty to use the Node FQDN.'),
ToggleButtons::make('public') 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() ->label('Use Node for deployment?')->inline()
->options([ ->options([
true => 'Yes', true => 'Yes',
@ -263,7 +291,12 @@ class EditNode extends EditRecord
false => 'danger', false => 'danger',
]), ]),
ToggleButtons::make('maintenance_mode') 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() ->label('Maintenance Mode')->inline()
->hinticon('tabler-question-mark') ->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.") ->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', true => 'danger',
]), ]),
Grid::make() Grid::make()
->columns(['default' => 1, 'sm' => 1, 'md' => 3, 'lg' => 6]) ->columns([
'default' => 1,
'sm' => 1,
'md' => 3,
'lg' => 6,
])
->columnSpanFull() ->columnSpanFull()
->schema([ ->schema([
ToggleButtons::make('unlimited_mem') ToggleButtons::make('unlimited_mem')
@ -293,14 +331,24 @@ class EditNode extends EditRecord
true => 'primary', true => 'primary',
false => 'warning', false => 'warning',
]) ])
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 2]), ->columnSpan([
'default' => 1,
'sm' => 1,
'md' => 1,
'lg' => 2,
]),
TextInput::make('memory') TextInput::make('memory')
->dehydratedWhenHidden() ->dehydratedWhenHidden()
->hidden(fn (Get $get) => $get('unlimited_mem')) ->hidden(fn (Get $get) => $get('unlimited_mem'))
->label('Memory Limit')->inlineLabel() ->label('Memory Limit')->inlineLabel()
->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB') ->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB')
->required() ->required()
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 2]) ->columnSpan([
'default' => 1,
'sm' => 1,
'md' => 1,
'lg' => 2,
])
->numeric() ->numeric()
->minValue(0), ->minValue(0),
TextInput::make('memory_overallocate') TextInput::make('memory_overallocate')
@ -310,14 +358,24 @@ class EditNode extends EditRecord
->hidden(fn (Get $get) => $get('unlimited_mem')) ->hidden(fn (Get $get) => $get('unlimited_mem'))
->hintIcon('tabler-question-mark') ->hintIcon('tabler-question-mark')
->hintIconTooltip('The % allowable to go over the set limit.') ->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() ->numeric()
->minValue(-1) ->minValue(-1)
->maxValue(100) ->maxValue(100)
->suffix('%'), ->suffix('%'),
]), ]),
Grid::make() Grid::make()
->columns(['default' => 1, 'sm' => 1, 'md' => 3, 'lg' => 6]) ->columns([
'default' => 1,
'sm' => 1,
'md' => 3,
'lg' => 6,
])
->schema([ ->schema([
ToggleButtons::make('unlimited_disk') ToggleButtons::make('unlimited_disk')
->label('Disk')->inlineLabel()->inline() ->label('Disk')->inlineLabel()->inline()
@ -333,14 +391,24 @@ class EditNode extends EditRecord
true => 'primary', true => 'primary',
false => 'warning', false => 'warning',
]) ])
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 2]), ->columnSpan([
'default' => 1,
'sm' => 1,
'md' => 1,
'lg' => 2,
]),
TextInput::make('disk') TextInput::make('disk')
->dehydratedWhenHidden() ->dehydratedWhenHidden()
->hidden(fn (Get $get) => $get('unlimited_disk')) ->hidden(fn (Get $get) => $get('unlimited_disk'))
->label('Disk Limit')->inlineLabel() ->label('Disk Limit')->inlineLabel()
->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB') ->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB')
->required() ->required()
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 2]) ->columnSpan([
'default' => 1,
'sm' => 1,
'md' => 1,
'lg' => 2,
])
->numeric() ->numeric()
->minValue(0), ->minValue(0),
TextInput::make('disk_overallocate') TextInput::make('disk_overallocate')
@ -349,7 +417,12 @@ class EditNode extends EditRecord
->label('Overallocate')->inlineLabel() ->label('Overallocate')->inlineLabel()
->hintIcon('tabler-question-mark') ->hintIcon('tabler-question-mark')
->hintIconTooltip('The % allowable to go over the set limit.') ->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() ->required()
->numeric() ->numeric()
->minValue(-1) ->minValue(-1)
@ -412,19 +485,61 @@ class EditNode extends EditRecord
->rows(19) ->rows(19)
->hintAction(CopyAction::make()) ->hintAction(CopyAction::make())
->columnSpanFull(), ->columnSpanFull(),
Forms\Components\Actions::make([ Grid::make()
Forms\Components\Actions\Action::make('resetKey') ->columns()
->label('Reset Daemon Token') ->schema([
->color('danger') FormActions::make([
->requiresConfirmation() FormActions\Action::make('autoDeploy')
->modalHeading('Reset Daemon Token?') ->label('Auto Deploy Command')
->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.') ->color('primary')
->action(function (NodeUpdateService $nodeUpdateService, Node $node) { ->modalHeading('Auto Deploy Command')
$nodeUpdateService->handle($node, [], true); ->icon('tabler-rocket')
Notification::make()->success()->title('Daemon Key Reset')->send(); ->modalSubmitAction(false)
$this->fillForm(); ->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(),
]),
]), ]),
]), ]),
]); ]);

View File

@ -2,12 +2,11 @@
namespace App\Http\Controllers\Admin; namespace App\Http\Controllers\Admin;
use Illuminate\Http\Request;
use App\Models\Node; use App\Models\Node;
use App\Models\ApiKey;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller; 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 class NodeAutoDeployController extends Controller
{ {
@ -15,48 +14,19 @@ class NodeAutoDeployController extends Controller
* NodeAutoDeployController constructor. * NodeAutoDeployController constructor.
*/ */
public function __construct( 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 * Handles the API request and returns the deployment command.
* nodes, and returns that as the deployment key for a node.
* *
* @throws \App\Exceptions\Model\DataValidationException * @throws \App\Exceptions\Model\DataValidationException
*/ */
public function __invoke(Request $request, Node $node): JsonResponse public function __invoke(Request $request, Node $node): JsonResponse
{ {
$keys = $request->user()->apiKeys() $command = $this->nodeAutoDeployService->handle($request, $node);
->where('key_type', ApiKey::TYPE_APPLICATION)
->get();
/** @var ApiKey|null $key */ return new JsonResponse(['command' => $command]);
$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,
]);
} }
} }

View File

@ -29,7 +29,7 @@ class ApiKeyController extends ClientApiController
*/ */
public function store(StoreApiKeyRequest $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.'); throw new DisplayException('You have reached the account limit for number of API keys.');
} }

View File

@ -71,15 +71,8 @@ class ApiKey extends Model
public const TYPE_ACCOUNT = 1; public const TYPE_ACCOUNT = 1;
/* @deprecated */
public const TYPE_APPLICATION = 2; 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. * The length of API key identifiers.
*/ */
@ -138,7 +131,7 @@ class ApiKey extends Model
*/ */
public static array $validationRules = [ public static array $validationRules = [
'user_id' => 'required|exists:users,id', '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', 'identifier' => 'required|string|size:16|unique:api_keys,identifier',
'token' => 'required|string', 'token' => 'required|string',
'memo' => 'required|nullable|string|max:500', 'memo' => 'required|nullable|string|max:500',

View File

@ -0,0 +1,58 @@
<?php
namespace App\Services\Nodes;
use App\Models\ApiKey;
use App\Models\Node;
use App\Services\Api\KeyCreationService;
use Illuminate\Http\Request;
class NodeAutoDeployService
{
/**
* NodeAutoDeployService constructor.
*/
public function __construct(
private readonly KeyCreationService $keyCreationService
) {
}
/**
* 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.
*
* @throws \App\Exceptions\Model\DataValidationException
*/
public function handle(Request $request, Node $node, ?bool $docker = false): ?string
{
/** @var ApiKey|null $key */
$key = ApiKey::query()
->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'
);
}
}

View File

@ -167,4 +167,9 @@ return [
'use_binary_prefix' => env('PANEL_USE_BINARY_PREFIX', true), 'use_binary_prefix' => env('PANEL_USE_BINARY_PREFIX', true),
'editable_server_descriptions' => env('PANEL_EDITABLE_SERVER_DESCRIPTIONS', 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),
],
]; ];

View File

@ -1,5 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd" bootstrap="bootstrap/tests.php" colors="true"> <phpunit
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"
bootstrap="bootstrap/tests.php"
colors="true"
displayDetailsOnSkippedTests="true"
>
<testsuites> <testsuites>
<testsuite name="Integration"> <testsuite name="Integration">
<directory>./tests/Integration</directory> <directory>./tests/Integration</directory>

View File

@ -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. * a DoS attack vector against the panel.
*/ */
public function testApiKeyLimitIsApplied(): void public function testApiKeyLimitIsApplied(): void
{ {
/** @var \App\Models\User $user */ /** @var \App\Models\User $user */
$user = User::factory()->create(); $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, 'key_type' => ApiKey::TYPE_ACCOUNT,
]); ]);