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,8 +485,49 @@ 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()
->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') ->label('Reset Daemon Token')
->color('danger') ->color('danger')
->requiresConfirmation() ->requiresConfirmation()
@ -424,6 +538,7 @@ class EditNode extends EditRecord
Notification::make()->success()->title('Daemon Key Reset')->send(); Notification::make()->success()->title('Daemon Key Reset')->send();
$this->fillForm(); $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,
]); ]);