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
{
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

View File

@ -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,8 +485,49 @@ class EditNode extends EditRecord
->rows(19)
->hintAction(CopyAction::make())
->columnSpanFull(),
Forms\Components\Actions::make([
Forms\Components\Actions\Action::make('resetKey')
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()
@ -424,6 +538,7 @@ class EditNode extends EditRecord
Notification::make()->success()->title('Daemon Key Reset')->send();
$this->fillForm();
}),
])->fullWidth(),
]),
]),
]),

View File

@ -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]);
}
}

View File

@ -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.');
}

View File

@ -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',

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),
'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"?>
<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>
<testsuite name="Integration">
<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.
*/
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,
]);