Compare commits

..

19 Commits

Author SHA1 Message Date
MartinOscar
f3de185508 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>
2024-10-26 20:43:19 -04:00
Charles
291b514e24 Webhook updates (#666) 2024-10-26 20:40:19 -04:00
Colin DeCarlo
86c369d7ce Implement Webhooks (#548)
* feat: First Webhook PoC draft

* feat: Dispatch Webhooks PoC

* fix: typo in webhook configuration scope

* Update 2024_04_21_162552_create_webhooks_table.php

Co-authored-by: Lance Pioch <lancepioch@gmail.com>

* Update 2024_04_21_162552_create_webhooks_table.php

Co-authored-by: Lance Pioch <lancepioch@gmail.com>

* Update 2024_04_21_162544_create_webhook_configurations_table.php

Co-authored-by: Lance Pioch <lancepioch@gmail.com>

* Update 2024_04_21_162544_create_webhook_configurations_table.php

Co-authored-by: Lance Pioch <lancepioch@gmail.com>

* Update DispatchWebhooks.php

Co-authored-by: Lance Pioch <lancepioch@gmail.com>

* Update DispatchWebhooksJob.php

Co-authored-by: Lance Pioch <lancepioch@gmail.com>

* Update DispatchWebhookForConfiguration.php

Co-authored-by: Lance Pioch <lancepioch@gmail.com>

* Update DispatchWebhookForConfiguration.php

Co-authored-by: Lance Pioch <lancepioch@gmail.com>

* Update DispatchWebhookForConfiguration.php

Co-authored-by: Lance Pioch <lancepioch@gmail.com>

* Update DispatchWebhooksJob.php

Co-authored-by: Lance Pioch <lancepioch@gmail.com>

* Update DispatchWebhooksJob.php

Co-authored-by: Lance Pioch <lancepioch@gmail.com>

* Update DispatchWebhooksJob.php

Co-authored-by: Lance Pioch <lancepioch@gmail.com>

* chore: Implement Webhook Event Discovery

* we got a test working for webhooks

* WIP

* Something is working!

* More tests

* clean up the tests now that they are passing

* WIP

* Don't use model specific events

* WIP

* WIP

* WIP

* WIP

* WIP

* Do it sync

* Reset these

* Don't need restored event type

* Deleted some unused jobs

* Find custom Events

* Remove observers

* Add custom event test

* Run Pint

* Add caching

* Don't cache every single event

* Fix tests

* Run Pint

* Phpstan fixes

* Pint fix

* Test fixes

* Middleware unit test fix

* Pint fixes

* Remove index not working for older dbs

* Use facade instead

---------

Co-authored-by: Pascale Beier <mail@pascalebeier.de>
Co-authored-by: Lance Pioch <lancepioch@gmail.com>
Co-authored-by: Vehikl <go@vehikl.com>
2024-10-26 20:35:25 -04:00
Quinten
5f77deb1fd Panel: Fix wings stoplogic (#407)
* Panel: FIx wings stoplogic

* do not make an expetion for `^C` let wings handle this

* remove withspaces
2024-10-26 19:21:14 -04:00
Charles
5f4429e2c3 Remove Bulk Delete from Nodes (#665)
* Remove Bulk Delete from Nodes

Removes bulk delete option from nodes.

* pint
2024-10-26 18:59:06 -04:00
Lance Pioch
1df3e8d5b0 Don't allow NodeStatisticsJob to be in the queue multiple times (#664)
* Make job unique

* Pint fix
2024-10-26 18:53:32 -04:00
Michael (Parker) Parker
ecb195b2c4 Merge pull request #662 from BlockyBlockling/docker-workflow-rework
Adding fix for forks to use a variable for Docker image reference instead of hard String
2024-10-26 18:45:59 -04:00
BlockyBlockling
86e8a6371e Update docker-publish.yml
Adding fix for forks to use a variable for Docker image reference

Source of information: https://docs.github.com/en/actions/use-cases-and-examples/publishing-packages/publishing-docker-images
2024-10-24 22:05:46 +02:00
Michael (Parker) Parker
d653edb22e Merge pull request #660 from BlockyBlockling/main
Fixing Critical error on Webserver on Pelican Panel Docker Image
2024-10-24 15:21:48 -04:00
BlockyBlockling
741252e395 Update supervisord.conf
Adding username and password dummy to get rid of critical error message
2024-10-24 21:15:03 +02:00
Michael (Parker) Parker
308601e6fe Merge pull request #659 from pelican-dev/issue/629
Make sure the .env can be accessed by the webserver when running Docker
2024-10-24 08:59:34 -04:00
Lance Pioch
3933222d98 Make sure the .env can be accessed 2024-10-23 21:36:48 -04:00
Boy132
c53ef78d89 Make sure schedules run with UTC (#657)
* make sure schedules use UTC for `next_run_at`

* use function from Utilities
2024-10-23 21:59:13 +02:00
Boy132
60792c05c2 Fix required for pinned threads input (#656) 2024-10-23 12:50:09 +02:00
Boy132
94420d06be Add UI for cpu pinning (#652)
* add ui for cpu pinning

* create "advanced" section
2024-10-22 23:34:46 +02:00
Fredrik Falk
6655ccca6e Speed up docker start (#647)
Starting the docker container is hampered due to setting `chown -R www-data:www-data /var/www/html/` on every start, causing it to traverse the entire directory which in our use case is very slow. This PR instead changes it to set permissions as part of the build process.

Sidenote: Is `LE_EMAIL` supposed to be used in addition to `ADMIN_EMAIL`?
2024-10-21 12:46:42 -04:00
Boy132
a193b4f5ab Installer: fix argument types for testConnection & return type for submit (#650)
* fix argument types for `testConnection`

* fix return type of submit
2024-10-21 18:43:16 +02:00
Boy132
3d5c8d14bd Add back trustedproxy config (#651) 2024-10-21 18:43:05 +02:00
Lance Pioch
de002324d7 Deselect all table records when switching primary allocation (#645) 2024-10-21 12:27:23 -04:00
184 changed files with 5259 additions and 1526 deletions

View File

@@ -28,6 +28,7 @@ fi
mkdir /pelican-data/database
ln -s /pelican-data/.env /var/www/html/
chown -h www-data:www-data /var/www/html/.env
ln -s /pelican-data/database/database.sqlite /var/www/html/database/
if ! grep -q "APP_KEY=" .env || grep -q "APP_KEY=$" .env; then
@@ -58,7 +59,7 @@ else
echo "Starting PHP-FPM only"
fi
chown -R www-data:www-data . /pelican-data/.env /pelican-data/database
chown -R www-data:www-data /pelican-data/.env /pelican-data/database
echo "Starting Supervisord"
exec "$@"

View File

@@ -1,5 +1,7 @@
[unix_http_server]
file=/tmp/supervisor.sock ; path to your socket file
username=dummy
password=dummy
[supervisord]
logfile=/var/log/supervisord/supervisord.log ; supervisord log file
@@ -18,6 +20,8 @@ supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
[supervisorctl]
serverurl=unix:///tmp/supervisor.sock ; use a unix:// URL for a unix socket
username=dummy
password=dummy
[program:php-fpm]
command=/usr/local/sbin/php-fpm -F

View File

@@ -9,6 +9,10 @@ on:
types:
- published
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
name: Build and Push
@@ -26,7 +30,7 @@ jobs:
id: docker_meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/pelican-dev/panel
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
flavor: |
latest=false
tags: |

View File

@@ -38,7 +38,8 @@ RUN touch .env
RUN composer install --no-dev --optimize-autoloader
# Set file permissions
RUN chmod -R 755 storage bootstrap/cache
RUN chmod -R 755 storage bootstrap/cache \
&& chown -R www-data:www-data ./
# Add scheduler to cron
RUN echo "* * * * * php /var/www/html/artisan schedule:run >> /dev/null 2>&1" | crontab -u www-data -

View File

@@ -1,45 +0,0 @@
<?php
namespace App\Casts;
use App\Models\Objects\Endpoint;
use Illuminate\Contracts\Database\Eloquent\Castable;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Support\Collection;
class EndpointCollection implements Castable
{
public static function castUsing(array $arguments)
{
return new class() implements CastsAttributes
{
public function get($model, $key, $value, $attributes)
{
if (!isset($attributes[$key])) {
return new Collection();
}
$data = json_decode($attributes[$key], true);
return (new Collection($data))->map(function ($value) {
return new Endpoint($value);
});
}
public function set($model, $key, $value, $attributes)
{
if (!is_array($value) && !$value instanceof Collection) {
return new Collection();
}
if (!$value instanceof Collection) {
$value = new Collection($value);
}
return [
'ports' => $value->toJson(),
];
}
};
}
}

View File

@@ -6,7 +6,6 @@ use Illuminate\Console\Command;
use App\Models\Schedule;
use Illuminate\Database\Eloquent\Builder;
use App\Services\Schedules\ProcessScheduleService;
use Carbon\Carbon;
class ProcessRunnableCommand extends Command
{
@@ -24,7 +23,7 @@ class ProcessRunnableCommand extends Command
->whereRelation('server', fn (Builder $builder) => $builder->whereNull('status'))
->where('is_active', true)
->where('is_processing', false)
->where('next_run_at', '<=', Carbon::now()->toDateTimeString())
->where('next_run_at', '<=', now('UTC')->toDateTimeString())
->get();
if ($schedules->count() < 1) {

View File

@@ -1,19 +0,0 @@
<?php
namespace App\Events\Server;
use App\Events\Event;
use App\Models\Server;
use Illuminate\Queue\SerializesModels;
class Created extends Event
{
use SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public Server $server)
{
}
}

View File

@@ -1,19 +0,0 @@
<?php
namespace App\Events\Server;
use App\Events\Event;
use App\Models\Server;
use Illuminate\Queue\SerializesModels;
class Creating extends Event
{
use SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public Server $server)
{
}
}

View File

@@ -1,19 +0,0 @@
<?php
namespace App\Events\Server;
use App\Events\Event;
use App\Models\Server;
use Illuminate\Queue\SerializesModels;
class Deleted extends Event
{
use SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public Server $server)
{
}
}

View File

@@ -1,19 +0,0 @@
<?php
namespace App\Events\Server;
use App\Events\Event;
use App\Models\Server;
use Illuminate\Queue\SerializesModels;
class Deleting extends Event
{
use SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public Server $server)
{
}
}

View File

@@ -1,19 +0,0 @@
<?php
namespace App\Events\Server;
use App\Events\Event;
use App\Models\Server;
use Illuminate\Queue\SerializesModels;
class Saved extends Event
{
use SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public Server $server)
{
}
}

View File

@@ -1,19 +0,0 @@
<?php
namespace App\Events\Server;
use App\Events\Event;
use App\Models\Server;
use Illuminate\Queue\SerializesModels;
class Saving extends Event
{
use SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public Server $server)
{
}
}

View File

@@ -1,19 +0,0 @@
<?php
namespace App\Events\Server;
use App\Events\Event;
use App\Models\Server;
use Illuminate\Queue\SerializesModels;
class Updated extends Event
{
use SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public Server $server)
{
}
}

View File

@@ -1,19 +0,0 @@
<?php
namespace App\Events\Server;
use App\Events\Event;
use App\Models\Server;
use Illuminate\Queue\SerializesModels;
class Updating extends Event
{
use SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public Server $server)
{
}
}

View File

@@ -1,19 +0,0 @@
<?php
namespace App\Events\Subuser;
use App\Events\Event;
use App\Models\Subuser;
use Illuminate\Queue\SerializesModels;
class Created extends Event
{
use SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public Subuser $subuser)
{
}
}

View File

@@ -1,19 +0,0 @@
<?php
namespace App\Events\Subuser;
use App\Events\Event;
use App\Models\Subuser;
use Illuminate\Queue\SerializesModels;
class Creating extends Event
{
use SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public Subuser $subuser)
{
}
}

View File

@@ -1,19 +0,0 @@
<?php
namespace App\Events\Subuser;
use App\Events\Event;
use App\Models\Subuser;
use Illuminate\Queue\SerializesModels;
class Deleted extends Event
{
use SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public Subuser $subuser)
{
}
}

View File

@@ -1,19 +0,0 @@
<?php
namespace App\Events\Subuser;
use App\Events\Event;
use App\Models\Subuser;
use Illuminate\Queue\SerializesModels;
class Deleting extends Event
{
use SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public Subuser $subuser)
{
}
}

View File

@@ -1,19 +0,0 @@
<?php
namespace App\Events\User;
use App\Models\User;
use App\Events\Event;
use Illuminate\Queue\SerializesModels;
class Created extends Event
{
use SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public User $user)
{
}
}

View File

@@ -1,19 +0,0 @@
<?php
namespace App\Events\User;
use App\Models\User;
use App\Events\Event;
use Illuminate\Queue\SerializesModels;
class Creating extends Event
{
use SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public User $user)
{
}
}

View File

@@ -1,19 +0,0 @@
<?php
namespace App\Events\User;
use App\Models\User;
use App\Events\Event;
use Illuminate\Queue\SerializesModels;
class Deleted extends Event
{
use SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public User $user)
{
}
}

View File

@@ -1,19 +0,0 @@
<?php
namespace App\Events\User;
use App\Models\User;
use App\Events\Event;
use Illuminate\Queue\SerializesModels;
class Deleting extends Event
{
use SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public User $user)
{
}
}

View File

@@ -0,0 +1,9 @@
<?php
namespace App\Exceptions\Service\Allocation;
use App\Exceptions\PanelException;
class AllocationDoesNotBelongToServerException extends PanelException
{
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Exceptions\Service\Allocation;
use App\Exceptions\DisplayException;
class AutoAllocationNotEnabledException extends DisplayException
{
/**
* AutoAllocationNotEnabledException constructor.
*/
public function __construct()
{
parent::__construct(
'Server auto-allocation is not enabled for this instance.'
);
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Exceptions\Service\Allocation;
use App\Exceptions\DisplayException;
class CidrOutOfRangeException extends DisplayException
{
/**
* CidrOutOfRangeException constructor.
*/
public function __construct()
{
parent::__construct(trans('exceptions.allocations.cidr_out_of_range'));
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Exceptions\Service\Allocation;
use App\Exceptions\DisplayException;
class InvalidPortMappingException extends DisplayException
{
/**
* InvalidPortMappingException constructor.
*/
public function __construct(mixed $port)
{
parent::__construct(trans('exceptions.allocations.invalid_mapping', ['port' => $port]));
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Exceptions\Service\Allocation;
use App\Exceptions\DisplayException;
class NoAutoAllocationSpaceAvailableException extends DisplayException
{
/**
* NoAutoAllocationSpaceAvailableException constructor.
*/
public function __construct()
{
parent::__construct(
'Cannot assign additional allocation: no more space available on node.'
);
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Exceptions\Service\Allocation;
use App\Exceptions\DisplayException;
class PortOutOfRangeException extends DisplayException
{
/**
* PortOutOfRangeException constructor.
*/
public function __construct()
{
parent::__construct(trans('exceptions.allocations.port_out_of_range'));
}
}

View File

@@ -0,0 +1,9 @@
<?php
namespace App\Exceptions\Service\Allocation;
use App\Exceptions\DisplayException;
class ServerUsingAllocationException extends DisplayException
{
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Exceptions\Service\Allocation;
use App\Exceptions\DisplayException;
class TooManyPortsInRangeException extends DisplayException
{
/**
* TooManyPortsInRangeException constructor.
*/
public function __construct()
{
parent::__construct(trans('exceptions.allocations.too_many_ports'));
}
}

View File

@@ -0,0 +1,9 @@
<?php
namespace App\Exceptions\Service\Deployment;
use App\Exceptions\DisplayException;
class NoViableAllocationException extends DisplayException
{
}

View File

@@ -25,6 +25,7 @@ use Filament\Pages\SimplePage;
use Filament\Support\Enums\MaxWidth;
use Filament\Support\Exceptions\Halt;
use Illuminate\Http\RedirectResponse;
use Illuminate\Routing\Redirector;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\HtmlString;
@@ -94,7 +95,7 @@ class PanelInstaller extends SimplePage implements HasForms
return 'data';
}
public function submit(): RedirectResponse
public function submit(): Redirector|RedirectResponse
{
// Disable installer
$this->writeToEnvironment(['APP_INSTALLED' => 'true']);

View File

@@ -72,7 +72,7 @@ class DatabaseStep
});
}
private static function testConnection(string $driver, string $host, string $port, string $database, string $username, string $password): bool
private static function testConnection(string $driver, ?string $host, null|string|int $port, ?string $database, ?string $username, ?string $password): bool
{
if ($driver === 'sqlite') {
return true;

View File

@@ -56,7 +56,7 @@ class RedisStep
});
}
private static function testConnection(string $host, string $port, string $username, string $password): bool
private static function testConnection(string $host, null|string|int $port, ?string $username, ?string $password): bool
{
try {
config()->set('database.redis._panel_install_test', [

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

@@ -3,7 +3,6 @@
namespace App\Filament\Resources\DatabaseHostResource\Pages;
use App\Filament\Resources\DatabaseHostResource;
use App\Models\Objects\Endpoint;
use App\Services\Databases\Hosts\HostCreationService;
use Closure;
use Exception;
@@ -60,7 +59,7 @@ class CreateDatabaseHost extends CreateRecord
->numeric()
->default(3306)
->minValue(0)
->maxValue(Endpoint::PORT_CEIL),
->maxValue(65535),
TextInput::make('max_databases')
->label('Max databases')
->helpertext('Blank is unlimited.')

View File

@@ -5,7 +5,6 @@ namespace App\Filament\Resources\DatabaseHostResource\Pages;
use App\Filament\Resources\DatabaseHostResource;
use App\Filament\Resources\DatabaseHostResource\RelationManagers\DatabasesRelationManager;
use App\Models\DatabaseHost;
use App\Models\Objects\Endpoint;
use App\Services\Databases\Hosts\HostUpdateService;
use Closure;
use Exception;
@@ -56,7 +55,7 @@ class EditDatabaseHost extends EditRecord
->required()
->numeric()
->minValue(0)
->maxValue(Endpoint::PORT_CEIL),
->maxValue(65535),
TextInput::make('max_databases')
->label('Max databases')
->helpertext('Blank is unlimited.')

View File

@@ -65,7 +65,7 @@ class CreateEgg extends CreateRecord
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2]),
Toggle::make('force_outgoing_ip')
->hintIcon('tabler-question-mark')
->hintIconTooltip("Forces all outgoing network traffic to have its Source IP NATed to the IP of the server's primary endpoint.
->hintIconTooltip("Forces all outgoing network traffic to have its Source IP NATed to the IP of the server's primary allocation IP.
Required for certain games to work properly when the Node has multiple public IP addresses.
Enabling this option will disable internal networking for any servers using this egg, causing them to be unable to internally access other servers on the same node."),
Hidden::make('script_is_privileged')

View File

@@ -83,7 +83,7 @@ class EditEgg extends EditRecord
Toggle::make('force_outgoing_ip')
->inline(false)
->hintIcon('tabler-question-mark')
->hintIconTooltip("Forces all outgoing network traffic to have its Source IP NATed to the IP of the server's endpoint.
->hintIconTooltip("Forces all outgoing network traffic to have its Source IP NATed to the IP of the server's primary allocation IP.
Required for certain games to work properly when the Node has multiple public IP addresses.
Enabling this option will disable internal networking for any servers using this egg, causing them to be unable to internally access other servers on the same node."),
Hidden::make('script_is_privileged')

View File

@@ -4,6 +4,7 @@ namespace App\Filament\Resources\EggResource\RelationManagers;
use App\Models\Server;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables\Columns\SelectColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
@@ -32,6 +33,11 @@ class ServersRelationManager extends RelationManager
->url(fn (Server $server): string => route('filament.admin.resources.nodes.edit', ['record' => $server->node])),
TextColumn::make('image')
->label('Docker Image'),
SelectColumn::make('allocation.id')
->label('Primary Allocation')
->options(fn (Server $server) => [$server->allocation->id => $server->allocation->address])
->selectablePlaceholder(false)
->sortable(),
]);
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Filament\Resources;
use App\Filament\Resources\NodeResource\Pages;
use App\Filament\Resources\NodeResource\RelationManagers\AllocationsRelationManager;
use App\Filament\Resources\NodeResource\RelationManagers\NodesRelationManager;
use App\Models\Node;
use Filament\Resources\Resource;
@@ -23,6 +24,7 @@ class NodeResource extends Resource
public static function getRelations(): array
{
return [
AllocationsRelationManager::class,
NodesRelationManager::class,
];
}

View File

@@ -3,7 +3,6 @@
namespace App\Filament\Resources\NodeResource\Pages;
use App\Filament\Resources\NodeResource;
use App\Models\Objects\Endpoint;
use Filament\Forms;
use Filament\Forms\Components\Actions\Action;
use Filament\Forms\Components\Grid;
@@ -140,7 +139,7 @@ class CreateNode extends CreateRecord
->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)
->maxValue(Endpoint::PORT_CEIL)
->maxValue(65535)
->default(8080)
->required()
->integer(),
@@ -245,7 +244,7 @@ class CreateNode extends CreateRecord
->columnSpan(1)
->label('SFTP Port')
->minValue(1)
->maxValue(Endpoint::PORT_CEIL)
->maxValue(65535)
->default(2022)
->required()
->integer(),

View File

@@ -4,10 +4,11 @@ namespace App\Filament\Resources\NodeResource\Pages;
use App\Filament\Resources\NodeResource;
use App\Models\Node;
use App\Models\Objects\Endpoint;
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;
@@ -22,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;
@@ -150,23 +152,13 @@ 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)
->maxValue(Endpoint::PORT_CEIL)
->maxValue(65535)
->default(8080)
->required()
->integer(),
@@ -183,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()) {
@@ -216,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.')
@@ -241,19 +253,34 @@ 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(Endpoint::PORT_CEIL)
->maxValue(65535)
->default(2022)
->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',
@@ -264,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.")
@@ -277,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')
@@ -294,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')
@@ -311,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()
@@ -334,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')
@@ -350,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)
@@ -413,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(),
]),
]),
]),
]);

View File

@@ -6,9 +6,7 @@ use App\Filament\Resources\NodeResource;
use App\Models\Node;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\CreateAction;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
@@ -83,12 +81,6 @@ class ListNodes extends ListRecords
->actions([
EditAction::make(),
])
->bulkActions([
BulkActionGroup::make([
DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can('delete node')),
]),
])
->emptyStateIcon('tabler-server-2')
->emptyStateDescription('')
->emptyStateHeading('No Nodes')

View File

@@ -0,0 +1,160 @@
<?php
namespace App\Filament\Resources\NodeResource\RelationManagers;
use App\Models\Allocation;
use App\Models\Node;
use App\Services\Allocations\AssignmentService;
use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Forms\Set;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\TextInputColumn;
use Filament\Tables\Table;
use Illuminate\Support\HtmlString;
/**
* @method Node getOwnerRecord()
*/
class AllocationsRelationManager extends RelationManager
{
protected static string $relationship = 'allocations';
protected static ?string $icon = 'tabler-plug-connected';
public function form(Form $form): Form
{
return $form
->schema([
TextInput::make('ip')
->required()
->maxLength(255),
]);
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('ip')
// Non Primary Allocations
// ->checkIfRecordIsSelectableUsing(fn (Allocation $allocation) => $allocation->id !== $allocation->server?->allocation_id)
// All assigned allocations
->checkIfRecordIsSelectableUsing(fn (Allocation $allocation) => $allocation->server_id === null)
->searchable()
->columns([
TextColumn::make('id'),
TextColumn::make('port')
->searchable()
->label('Port'),
TextColumn::make('server.name')
->label('Server')
->icon('tabler-brand-docker')
->searchable()
->url(fn (Allocation $allocation): string => $allocation->server ? route('filament.admin.resources.servers.edit', ['record' => $allocation->server]) : ''),
TextInputColumn::make('ip_alias')
->searchable()
->label('Alias'),
TextInputColumn::make('ip')
->searchable()
->label('IP'),
])
->filters([
//
])
->actions([
//
])
->headerActions([
Tables\Actions\Action::make('create new allocation')->label('Create Allocations')
->form(fn () => [
TextInput::make('allocation_ip')
->datalist($this->getOwnerRecord()->ipAddresses())
->label('IP Address')
->inlineLabel()
->ipv4()
->helperText("Usually your machine's public IP unless you are port forwarding.")
->required(),
TextInput::make('allocation_alias')
->label('Alias')
->inlineLabel()
->default(null)
->helperText('Optional display name to help you remember what these are.')
->required(false),
TagsInput::make('allocation_ports')
->placeholder('Examples: 27015, 27017-27019')
->helperText(new HtmlString('
These are the ports that users can connect to this Server through.
<br />
You would have to port forward these on your home network.
'))
->label('Ports')
->inlineLabel()
->live()
->afterStateUpdated(function ($state, Set $set) {
$ports = collect();
$update = false;
foreach ($state as $portEntry) {
if (!str_contains($portEntry, '-')) {
if (is_numeric($portEntry)) {
$ports->push((int) $portEntry);
continue;
}
// Do not add non numerical ports
$update = true;
continue;
}
$update = true;
[$start, $end] = explode('-', $portEntry);
if (!is_numeric($start) || !is_numeric($end)) {
continue;
}
$start = max((int) $start, 0);
$end = min((int) $end, 2 ** 16 - 1);
foreach (range($start, $end) as $i) {
$ports->push($i);
}
}
$uniquePorts = $ports->unique()->values();
if ($ports->count() > $uniquePorts->count()) {
$update = true;
$ports = $uniquePorts;
}
$sortedPorts = $ports->sort()->values();
if ($sortedPorts->all() !== $ports->all()) {
$update = true;
$ports = $sortedPorts;
}
$ports = $ports->filter(fn ($port) => $port > 1024 && $port < 65535)->values();
if ($update) {
$set('allocation_ports', $ports->all());
}
})
->splitKeys(['Tab', ' ', ','])
->required(),
])
->action(fn (array $data, AssignmentService $service) => $service->handle($this->getOwnerRecord(), $data)),
])
->bulkActions([
BulkActionGroup::make([
DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can('delete allocation')),
]),
]);
}
}

View File

@@ -4,6 +4,7 @@ namespace App\Filament\Resources\NodeResource\RelationManagers;
use App\Models\Server;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables\Columns\SelectColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
@@ -32,6 +33,11 @@ class NodesRelationManager extends RelationManager
->icon('tabler-egg')
->url(fn (Server $server): string => route('filament.admin.resources.eggs.edit', ['record' => $server->user]))
->sortable(),
SelectColumn::make('allocation.id')
->label('Primary Allocation')
->options(fn (Server $server) => [$server->allocation->id => $server->allocation->address])
->selectablePlaceholder(false)
->sortable(),
TextColumn::make('memory')->icon('tabler-device-desktop-analytics'),
TextColumn::make('cpu')->icon('tabler-cpu'),
TextColumn::make('databases_count')

View File

@@ -3,10 +3,11 @@
namespace App\Filament\Resources\ServerResource\Pages;
use App\Filament\Resources\ServerResource;
use App\Models\Allocation;
use App\Models\Egg;
use App\Models\Node;
use App\Models\Objects\Endpoint;
use App\Models\User;
use App\Services\Allocations\AssignmentService;
use App\Services\Servers\RandomWordService;
use App\Services\Servers\ServerCreationService;
use App\Services\Users\UserCreationService;
@@ -24,6 +25,7 @@ use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\ToggleButtons;
@@ -33,6 +35,7 @@ use Filament\Forms\Form;
use Filament\Forms\Get;
use Filament\Forms\Set;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Validator;
@@ -47,12 +50,6 @@ class CreateServer extends CreateRecord
public ?Node $node = null;
public ?Egg $egg = null;
public array $ports = [];
public array $eggDefaultPorts = [];
private ServerCreationService $serverCreationService;
public function boot(ServerCreationService $serverCreationService): void
@@ -150,11 +147,175 @@ class CreateServer extends CreateRecord
->relationship('node', 'name')
->searchable()
->preload()
->afterStateUpdated(function (Forms\Set $set, $state) {
->afterStateUpdated(function (Set $set, $state) {
$set('allocation_id', null);
$this->node = Node::find($state);
})
->required(),
Select::make('allocation_id')
->preload()
->live()
->prefixIcon('tabler-network')
->label('Primary Allocation')
->columnSpan([
'default' => 2,
'sm' => 3,
'md' => 2,
'lg' => 3,
])
->disabled(fn (Get $get) => $get('node_id') === null)
->searchable(['ip', 'port', 'ip_alias'])
->afterStateUpdated(function (Set $set) {
$set('allocation_additional', null);
$set('allocation_additional.needstobeastringhere.extra_allocations', null);
})
->getOptionLabelFromRecordUsing(
fn (Allocation $allocation) => "$allocation->ip:$allocation->port" .
($allocation->ip_alias ? " ($allocation->ip_alias)" : '')
)
->placeholder(function (Get $get) {
$node = Node::find($get('node_id'));
if ($node?->allocations) {
return 'Select an Allocation';
}
return 'Create a New Allocation';
})
->relationship(
'allocation',
'ip',
fn (Builder $query, Get $get) => $query
->where('node_id', $get('node_id'))
->whereNull('server_id'),
)
->createOptionForm(fn (Get $get) => [
TextInput::make('allocation_ip')
->datalist(Node::find($get('node_id'))?->ipAddresses() ?? [])
->label('IP Address')
->inlineLabel()
->ipv4()
->helperText("Usually your machine's public IP unless you are port forwarding.")
// ->selectablePlaceholder(false)
->required(),
TextInput::make('allocation_alias')
->label('Alias')
->inlineLabel()
->default(null)
->datalist([
$get('name'),
Egg::find($get('egg_id'))?->name,
])
->helperText('Optional display name to help you remember what these are.')
->required(false),
TagsInput::make('allocation_ports')
->placeholder('Examples: 27015, 27017-27019')
->helperText(new HtmlString('
These are the ports that users can connect to this Server through.
<br />
You would have to port forward these on your home network.
'))
->label('Ports')
->inlineLabel()
->live()
->afterStateUpdated(function ($state, Set $set) {
$ports = collect();
$update = false;
foreach ($state as $portEntry) {
if (!str_contains($portEntry, '-')) {
if (is_numeric($portEntry)) {
$ports->push((int) $portEntry);
continue;
}
// Do not add non-numerical ports
$update = true;
continue;
}
$update = true;
[$start, $end] = explode('-', $portEntry);
if (!is_numeric($start) || !is_numeric($end)) {
continue;
}
$start = max((int) $start, 0);
$end = min((int) $end, 2 ** 16 - 1);
$range = $start <= $end ? range($start, $end) : range($end, $start);
foreach ($range as $i) {
if ($i > 1024 && $i <= 65535) {
$ports->push($i);
}
}
}
$uniquePorts = $ports->unique()->values();
if ($ports->count() > $uniquePorts->count()) {
$update = true;
$ports = $uniquePorts;
}
$sortedPorts = $ports->sort()->values();
if ($sortedPorts->all() !== $ports->all()) {
$update = true;
$ports = $sortedPorts;
}
if ($update) {
$set('allocation_ports', $ports->all());
}
})
->splitKeys(['Tab', ' ', ','])
->required(),
])
->createOptionUsing(function (array $data, Get $get, AssignmentService $assignmentService): int {
return collect(
$assignmentService->handle(Node::find($get('node_id')), $data)
)->first();
})
->required(),
Repeater::make('allocation_additional')
->label('Additional Allocations')
->columnSpan([
'default' => 2,
'sm' => 3,
'md' => 3,
'lg' => 3,
])
->addActionLabel('Add Allocation')
->disabled(fn (Get $get) => $get('allocation_id') === null)
// ->addable() TODO disable when all allocations are taken
// ->addable() TODO disable until first additional allocation is selected
->simple(
Select::make('extra_allocations')
->live()
->preload()
->disableOptionsWhenSelectedInSiblingRepeaterItems()
->prefixIcon('tabler-network')
->label('Additional Allocations')
->columnSpan(2)
->disabled(fn (Get $get) => $get('../../node_id') === null)
->searchable(['ip', 'port', 'ip_alias'])
->getOptionLabelFromRecordUsing(
fn (Allocation $allocation) => "$allocation->ip:$allocation->port" .
($allocation->ip_alias ? " ($allocation->ip_alias)" : '')
)
->placeholder('Select additional Allocations')
->disableOptionsWhenSelectedInSiblingRepeaterItems()
->relationship(
'allocations',
'ip',
fn (Builder $query, Get $get, Select $component, $state) => $query
->where('node_id', $get('../../node_id'))
->whereNot('id', $get('../../allocation_id'))
->whereNull('server_id'),
),
),
Textarea::make('description')
->placeholder('Description')
->rows(3)
@@ -180,26 +341,40 @@ class CreateServer extends CreateRecord
->schema([
Select::make('egg_id')
->prefixIcon('tabler-egg')
->relationship('egg', 'name')
->columnSpan([
'default' => 2,
'default' => 1,
'sm' => 2,
'md' => 2,
'lg' => 4,
])
->relationship('egg', 'name')
->searchable()
->preload()
->live()
->afterStateUpdated(function ($state, Set $set, Get $get, $old) {
$this->egg = Egg::query()->find($state);
$set('startup', $this->egg?->startup);
$egg = Egg::query()->find($state);
$set('startup', $egg->startup ?? '');
$set('image', '');
$this->resetEggVariables($set, $get);
$variables = $egg->variables ?? [];
$serverVariables = collect();
foreach ($variables as $variable) {
$serverVariables->add($variable->toArray());
}
$variables = [];
$set($path = 'server_variables', $serverVariables->sortBy(['sort'])->all());
for ($i = 0; $i < $serverVariables->count(); $i++) {
$set("$path.$i.variable_value", $serverVariables[$i]['default_value']);
$set("$path.$i.variable_id", $serverVariables[$i]['id']);
$variables[$serverVariables[$i]['env_variable']] = $serverVariables[$i]['default_value'];
}
$set('environment', $variables);
$previousEgg = Egg::query()->find($old);
if (!$get('name') || $previousEgg?->getKebabName() === $get('name')) {
$set('name', $this->egg->getKebabName());
$set('name', $egg->getKebabName());
}
})
->required(),
@@ -255,21 +430,13 @@ class CreateServer extends CreateRecord
Textarea::make('startup')
->hintIcon('tabler-code')
->label('Startup Command')
->hidden(fn () => !$this->egg)
->hidden(fn (Get $get) => $get('egg_id') === null)
->required()
->live()
->disabled(fn (Forms\Get $get) => $this->egg === null)
->afterStateUpdated($this->resetEggVariables(...))
->columnSpan([
'default' => 1,
'sm' => 2,
'md' => 2,
'lg' => 4,
])
->rows(function ($state) {
return str($state)->explode("\n")->reduce(
fn (int $carry, $line) => $carry + floor(strlen($line) / 125),
0
1
);
})
->columnSpan([
@@ -353,70 +520,6 @@ class CreateServer extends CreateRecord
->columnSpan(2),
]),
]),
Wizard\Step::make('Allocation')
->label('Allocation')
->icon('tabler-transfer-in')
->completedIcon('tabler-check')
->columns(4)
->schema([
Forms\Components\TagsInput::make('ports')
->columnSpan(2)
->hintIcon('tabler-question-mark')
->hintIconTooltip('Ports are limited from 1025 to 65535')
->placeholder('Example: 25565, 8080, 1337-1340')
->splitKeys(['Tab', ' ', ','])
->helperText(new HtmlString('
These are the ports that users can connect to this Server through.
You would typically port forward these on your home network.
'))
->label('Ports')
->afterStateUpdated(self::ports(...))
->live(),
Forms\Components\Repeater::make('assignments')
->columnSpan(2)
->defaultItems(fn () => count($this->eggDefaultPorts))
->label('Port Assignments')
->helperText(function (Forms\Get $get) {
if (empty($this->eggDefaultPorts)) {
return "This egg doesn't have any ports defined.";
}
if (empty($get('ports'))) {
return 'You must add ports to assign them!';
}
return '';
})
->live()
->addable(false)
->deletable(false)
->reorderable(false)
->simple(
Forms\Components\Select::make('port')
->live()
->placeholder('Select a Port')
->disabled(fn (Forms\Get $get) => empty($get('../../ports')) || empty($get('../../assignments')))
->prefix(function (Forms\Components\Component $component) {
$key = str($component->getStatePath())->beforeLast('.')->afterLast('.')->toString();
return $key;
})
->disableOptionsWhenSelectedInSiblingRepeaterItems()
->options(fn (Forms\Get $get) => $this->ports)
->required(),
),
Forms\Components\Select::make('ip')
->label('IP Address')
->options(fn () => collect($this->node?->ipAddresses())->mapWithKeys(fn ($ip) => [$ip => $ip]))
->placeholder('Any')
->columnSpan(1),
]),
Step::make('Environment Configuration')
->label('Environment Configuration')
->icon('tabler-brand-docker')
@@ -524,14 +627,24 @@ class CreateServer extends CreateRecord
->minValue(0)
->helperText('100% equals one CPU core.'),
]),
]),
Fieldset::make('Advanced Limits')
->columnSpan(6)
->columns([
'default' => 1,
'sm' => 2,
'md' => 3,
'lg' => 3,
])
->schema([
Grid::make()
->columns(4)
->columnSpanFull()
->schema([
ToggleButtons::make('swap_support')
->live()
->label('Enable Swap Memory')
->label('Swap Memory')
->inlineLabel()
->inline()
->columnSpan(2)
@@ -576,7 +689,37 @@ class CreateServer extends CreateRecord
Hidden::make('io')
->helperText('The IO performance relative to other running containers')
->label('Block IO Proportion')
->default(config('panel.default_io_weight')),
->default(500),
Grid::make()
->columns(4)
->columnSpanFull()
->schema([
ToggleButtons::make('cpu_pinning')
->label('CPU Pinning')->inlineLabel()->inline()
->default(false)
->afterStateUpdated(fn (Set $set) => $set('threads', []))
->live()
->options([
false => 'Disabled',
true => 'Enabled',
])
->colors([
false => 'success',
true => 'warning',
])
->columnSpan(2),
TagsInput::make('threads')
->dehydratedWhenHidden()
->hidden(fn (Get $get) => !$get('cpu_pinning'))
->label('Pinned Threads')->inlineLabel()
->required(fn (Get $get) => $get('cpu_pinning'))
->columnSpan(2)
->separator()
->splitKeys([','])
->placeholder('Add pinned thread, e.g. 0 or 2-4'),
]),
Grid::make()
->columns(4)
@@ -729,16 +872,9 @@ class CreateServer extends CreateRecord
protected function handleRecordCreation(array $data): Model
{
$ipAddress = $data['ip'] ?? Endpoint::INADDR_ANY;
foreach ($data['ports'] ?? [] as $i => $port) {
$data['ports'][$i] = (string) new Endpoint($port, $ipAddress);
}
$data['allocation_additional'] = collect($data['allocation_additional'])->filter()->all();
foreach (array_keys($this->eggDefaultPorts) as $i => $env) {
$data['environment'][$env] = $data['ports'][$data['assignments'][$i]];
}
return $this->serverCreationService->handle($data, validateVariables: false);
return $this->serverCreationService->handle($data);
}
private function shouldHideComponent(Get $get, Component $component): bool
@@ -771,79 +907,4 @@ class CreateServer extends CreateRecord
->mapWithKeys(fn ($value) => [$value => $value])
->all();
}
public function ports(array $state, Forms\Set $set): void
{
$ports = collect();
foreach ($state as $portEntry) {
if (str_contains($portEntry, '-')) {
[$start, $end] = explode('-', $portEntry);
if (!is_numeric($start) || !is_numeric($end)) {
continue;
}
$start = max((int) $start, Endpoint::PORT_FLOOR);
$end = min((int) $end, Endpoint::PORT_CEIL);
for ($i = $start; $i <= $end; $i++) {
$ports->push($i);
}
}
if (!is_numeric($portEntry)) {
continue;
}
$ports->push((int) $portEntry);
}
$uniquePorts = $ports->unique()->values();
if ($ports->count() > $uniquePorts->count()) {
$ports = $uniquePorts;
}
$ports = $ports->filter(fn ($port) => $port > Endpoint::PORT_FLOOR && $port < Endpoint::PORT_CEIL)->values();
$set('ports', $ports->all());
$this->ports = $ports->all();
}
public function resetEggVariables(Forms\Set $set, Forms\Get $get): void
{
$set('assignments', []);
$i = 0;
$this->eggDefaultPorts = [];
if (str_contains($get('startup'), '{{SERVER_PORT}}') || str_contains($this->egg->config_files, '{{server.allocations.default.port}}')) {
$this->eggDefaultPorts['SERVER_PORT'] = null;
$set('assignments.SERVER_PORT', ['port' => null]);
}
$variables = $this->egg->variables ?? [];
$serverVariables = collect();
$this->ports = [];
foreach ($variables as $variable) {
if (in_array('port', $variable->rules)) {
$this->eggDefaultPorts[$variable->env_variable] = $variable->default_value;
$this->ports[] = (int) $variable->default_value;
$set("assignments.$variable->env_variable", ['port' => $i++]);
continue;
}
$serverVariables->add($variable->toArray());
}
$set('ports', $this->ports);
$variables = [];
$set($path = 'server_variables', $serverVariables->sortBy(['sort'])->all());
for ($i = 0; $i < $serverVariables->count(); $i++) {
$set("$path.$i.variable_value", $serverVariables[$i]['default_value']);
$set("$path.$i.variable_id", $serverVariables[$i]['id']);
$variables[$serverVariables[$i]['env_variable']] = $serverVariables[$i]['default_value'];
}
$set('environment', $variables);
}
}

View File

@@ -2,58 +2,49 @@
namespace App\Filament\Resources\ServerResource\Pages;
use App\Models\Node;
use App\Models\Objects\Endpoint;
use Exception;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\HtmlString;
use App\Models\Database;
use App\Services\Databases\DatabaseManagementService;
use App\Services\Databases\DatabasePasswordService;
use Filament\Forms\Components\Actions\Action;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Get;
use Filament\Forms\Set;
use LogicException;
use App\Filament\Resources\ServerResource;
use App\Http\Controllers\Admin\ServersController;
use App\Services\Servers\RandomWordService;
use App\Services\Servers\SuspensionService;
use App\Services\Servers\TransferServerService;
use Filament\Actions;
use Filament\Forms;
use App\Enums\ContainerStatus;
use App\Enums\ServerState;
use App\Filament\Resources\ServerResource;
use App\Http\Controllers\Admin\ServersController;
use App\Models\Database;
use App\Models\Egg;
use App\Models\Server;
use App\Models\ServerVariable;
use App\Services\Databases\DatabaseManagementService;
use App\Services\Databases\DatabasePasswordService;
use App\Services\Servers\RandomWordService;
use App\Services\Servers\ServerDeletionService;
use App\Services\Servers\SuspensionService;
use App\Services\Servers\TransferServerService;
use Closure;
use Exception;
use Filament\Actions;
use Filament\Forms;
use Filament\Forms\Components\Actions\Action;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\Fieldset;
use Filament\Forms\Components\Grid;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Tabs;
use Filament\Forms\Components\Tabs\Tab;
use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\ToggleButtons;
use Filament\Forms\Form;
use Filament\Forms\Get;
use Filament\Forms\Set;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Validator;
use LogicException;
use Webbingbrasil\FilamentCopyActions\Forms\Actions\CopyAction;
class EditServer extends EditRecord
{
public ?Node $node = null;
public ?Egg $egg = null;
public array $ports = [];
public array $eggDefaultPorts = [];
protected static string $resource = ServerResource::class;
public function form(Form $form): Form
@@ -73,26 +64,6 @@ class EditServer extends EditRecord
Tab::make('Information')
->icon('tabler-info-circle')
->schema([
Forms\Components\ToggleButtons::make('condition')
->label('Status')
->formatStateUsing(fn (Server $server) => $server->condition)
->options(fn ($state) => collect(array_merge(ContainerStatus::cases(), ServerState::cases()))
->filter(fn ($condition) => $condition->value === $state)
->mapWithKeys(fn ($state) => [$state->value => str($state->value)->replace('_', ' ')->ucwords()])
)
->colors(collect(array_merge(ContainerStatus::cases(), ServerState::cases()))->mapWithKeys(
fn ($status) => [$status->value => $status->color()]
))
->icons(collect(array_merge(ContainerStatus::cases(), ServerState::cases()))->mapWithKeys(
fn ($status) => [$status->value => $status->icon()]
))
->columnSpan([
'default' => 2,
'sm' => 1,
'md' => 1,
'lg' => 1,
]),
TextInput::make('name')
->prefixIcon('tabler-server')
->label('Display Name')
@@ -150,7 +121,7 @@ class EditServer extends EditRecord
]),
Textarea::make('description')
->label('Notes')
->label('Description')
->columnSpanFull(),
TextInput::make('uuid')
@@ -194,7 +165,6 @@ class EditServer extends EditRecord
])
->disabled(),
]),
Tab::make('Environment')
->icon('tabler-brand-docker')
->schema([
@@ -295,14 +265,23 @@ class EditServer extends EditRecord
->numeric()
->minValue(0),
]),
]),
Fieldset::make('Advanced Limits')
->columns([
'default' => 1,
'sm' => 2,
'md' => 3,
'lg' => 3,
])
->schema([
Grid::make()
->columns(4)
->columnSpanFull()
->schema([
ToggleButtons::make('swap_support')
->live()
->label('Enable Swap Memory')->inlineLabel()->inline()
->label('Swap Memory')->inlineLabel()->inline()
->columnSpan(2)
->afterStateUpdated(function ($state, Set $set) {
$value = match ($state) {
@@ -347,10 +326,41 @@ class EditServer extends EditRecord
->integer(),
]),
Forms\Components\Hidden::make('io')
Hidden::make('io')
->helperText('The IO performance relative to other running containers')
->label('Block IO Proportion'),
Grid::make()
->columns(4)
->columnSpanFull()
->schema([
ToggleButtons::make('cpu_pinning')
->label('CPU Pinning')->inlineLabel()->inline()
->default(false)
->afterStateUpdated(fn (Set $set) => $set('threads', []))
->formatStateUsing(fn (Get $get) => !empty($get('threads')))
->live()
->options([
false => 'Disabled',
true => 'Enabled',
])
->colors([
false => 'success',
true => 'warning',
])
->columnSpan(2),
TagsInput::make('threads')
->dehydratedWhenHidden()
->hidden(fn (Get $get) => !$get('cpu_pinning'))
->label('Pinned Threads')->inlineLabel()
->required(fn (Get $get) => $get('cpu_pinning'))
->columnSpan(2)
->separator()
->splitKeys([','])
->placeholder('Add pinned thread, e.g. 0 or 2-4'),
]),
Grid::make()
->columns(4)
->columnSpanFull()
@@ -492,66 +502,6 @@ class EditServer extends EditRecord
])
->required(),
Forms\Components\TagsInput::make('ports')
->columnSpan(3)
->placeholder('Example: 25565, 8080, 1337-1340')
->splitKeys(['Tab', ' ', ','])
->helperText(new HtmlString('
These are the ports that users can connect to this Server through.
<br />
You would typically port forward these on your home network.
'))
->label('Ports')
->formatStateUsing(fn (Server $server) => $server->ports->map(fn ($port) => (string) $port)->all())
->afterStateUpdated(self::ports(...))
->live(),
Forms\Components\Repeater::make('portVariables')
->label('Port Assignments')
->columnSpan(3)
->addable(false)
->deletable(false)
->mutateRelationshipDataBeforeSaveUsing(function ($data) {
$portIndex = $data['port'];
unset($data['port']);
return [
'variable_value' => (string) $this->ports[$portIndex],
];
})
->relationship('serverVariables', function (Builder $query) {
$query->whereHas('variable', function (Builder $query) {
$query->where('rules', 'like', '%port%');
});
})
->simple(
Forms\Components\Select::make('port')
->live()
->disabled(fn (Forms\Get $get) => empty($get('../../ports')) || empty($get('../../assignments')))
->prefix(function (Forms\Components\Component $component, ServerVariable $serverVariable) {
return $serverVariable->variable->env_variable;
})
->formatStateUsing(function (ServerVariable $serverVariable, Forms\Get $get) {
return array_search($serverVariable->variable_value, array_values($get('../../ports')));
})
->disableOptionsWhenSelectedInSiblingRepeaterItems()
->options(fn (Forms\Get $get) => $this->ports)
->required(),
)
->afterStateHydrated(function (Forms\Set $set, Forms\Get $get, Server $server) {
$this->ports($ports = $get('ports'), $set);
foreach ($this->portOptions($server->egg) as $key => $port) {
$set("assignments.$key", ['port' => $portIndex = array_search($port, array_values($ports))]);
}
}),
Textarea::make('startup')
->label('Startup Command')
->required()
@@ -634,7 +584,7 @@ class EditServer extends EditRecord
->label(fn (ServerVariable $serverVariable) => $serverVariable->variable->name)
->hintIconTooltip(fn (ServerVariable $serverVariable) => implode('|', $serverVariable->variable->rules))
->prefix(fn (ServerVariable $serverVariable) => '{{' . $serverVariable->variable->env_variable . '}}')
->helperText(fn (ServerVariable $serverVariable) => empty($serverVariable->variable?->description) ? '—' : $serverVariable->variable->description);
->helperText(fn (ServerVariable $serverVariable) => empty($serverVariable->variable->description) ? '—' : $serverVariable->variable->description);
}
return $components;
@@ -873,14 +823,17 @@ class EditServer extends EditRecord
return $data;
}
public function getRelationManagers(): array
{
return [
ServerResource\RelationManagers\AllocationsRelationManager::class,
];
}
private function shouldHideComponent(ServerVariable $serverVariable, Forms\Components\Component $component): bool
{
$containsRuleIn = array_first($serverVariable->variable->rules, fn ($value) => str($value)->startsWith('in:'), false);
if (collect($serverVariable->variable->rules)->contains('port')) {
return true;
}
if ($component instanceof Select) {
return !$containsRuleIn;
}
@@ -904,76 +857,6 @@ class EditServer extends EditRecord
->all();
}
public function ports(array $state, Forms\Set $set): void
{
$ports = collect();
foreach ($state as $portEntry) {
if (str_contains($portEntry, '-')) {
[$start, $end] = explode('-', $portEntry);
try {
$startEndpoint = new Endpoint($start);
$endEndpoint = new Endpoint($end);
} catch (Exception) {
continue;
}
if ($startEndpoint->ip !== $endEndpoint->ip) {
continue;
}
foreach (range($startEndpoint->port, $endEndpoint->port) as $port) {
$ports->push(new Endpoint($port, $startEndpoint->ip));
}
for ($i = $start; $i <= $end; $i++) {
$ports->push($i);
}
continue;
}
try {
$ports->push(new Endpoint($portEntry));
} catch (Exception) {
continue;
}
}
$ports = $ports->map(fn ($endpoint) => (string) $endpoint);
$uniquePorts = $ports->unique()->values();
if ($ports->count() > $uniquePorts->count()) {
$ports = $uniquePorts;
}
$set('ports', $ports->all());
$this->ports = $ports->all();
}
public function portOptions(Egg $egg, ?string $startup = null): array
{
if (empty($startup)) {
$startup = $egg->startup;
}
$options = [];
if (str_contains($startup, '{{SERVER_PORT}}')) {
$options['SERVER_PORT'] = null;
}
foreach ($egg->variables as $variable) {
if (!in_array('port', $variable->rules)) {
continue;
}
$options[$variable->env_variable] = $variable->default_value;
}
return $options;
}
protected function rotatePassword(DatabasePasswordService $service, Database $record, Set $set, Get $get): void
{
$newPassword = $service->handle($record);

View File

@@ -9,6 +9,7 @@ use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\CreateAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Columns\SelectColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Grouping\Group;
use Filament\Tables\Table;
@@ -60,6 +61,16 @@ class ListServers extends ListRecords
->hidden(fn (Table $table) => $table->getGrouping()?->getId() === 'user.username')
->sortable()
->searchable(),
SelectColumn::make('allocation_id')
->label('Primary Allocation')
->hidden(!auth()->user()->can('update server'))
->options(fn (Server $server) => $server->allocations->mapWithKeys(fn ($allocation) => [$allocation->id => $allocation->address]))
->selectablePlaceholder(false)
->sortable(),
TextColumn::make('allocation_id_readonly')
->label('Primary Allocation')
->hidden(auth()->user()->can('update server'))
->state(fn (Server $server) => $server->allocation->address),
TextColumn::make('image')->hidden(),
TextColumn::make('backups_count')
->counts('backups')
@@ -67,9 +78,6 @@ class ListServers extends ListRecords
->icon('tabler-file-download')
->numeric()
->sortable(),
TextColumn::make('ports')
->badge()
->separator(),
])
->actions([
Action::make('View')

View File

@@ -0,0 +1,161 @@
<?php
namespace App\Filament\Resources\ServerResource\RelationManagers;
use App\Models\Allocation;
use App\Models\Server;
use App\Services\Allocations\AssignmentService;
use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Forms\Set;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\AssociateAction;
use Filament\Tables\Actions\CreateAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\TextInputColumn;
use Filament\Tables\Table;
use Illuminate\Support\HtmlString;
/**
* @method Server getOwnerRecord()
*/
class AllocationsRelationManager extends RelationManager
{
protected static string $relationship = 'allocations';
public function form(Form $form): Form
{
return $form
->schema([
TextInput::make('ip')
->required()
->maxLength(255),
]);
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('ip')
->recordTitle(fn (Allocation $allocation) => "$allocation->ip:$allocation->port")
->checkIfRecordIsSelectableUsing(fn (Allocation $record) => $record->id !== $this->getOwnerRecord()->allocation_id)
->inverseRelationship('server')
->columns([
TextColumn::make('ip')->label('IP'),
TextColumn::make('port')->label('Port'),
TextInputColumn::make('ip_alias')->label('Alias'),
IconColumn::make('primary')
->icon(fn ($state) => match ($state) {
true => 'tabler-star-filled',
default => 'tabler-star',
})
->color(fn ($state) => match ($state) {
true => 'warning',
default => 'gray',
})
->action(fn (Allocation $allocation) => $this->getOwnerRecord()->update(['allocation_id' => $allocation->id]) && $this->deselectAllTableRecords())
->default(fn (Allocation $allocation) => $allocation->id === $this->getOwnerRecord()->allocation_id)
->label('Primary'),
])
->actions([
Action::make('make-primary')
->action(fn (Allocation $allocation) => $this->getOwnerRecord()->update(['allocation_id' => $allocation->id]) && $this->deselectAllTableRecords())
->label(fn (Allocation $allocation) => $allocation->id === $this->getOwnerRecord()->allocation_id ? '' : 'Make Primary'),
])
->headerActions([
CreateAction::make()->label('Create Allocation')
->createAnother(false)
->form(fn () => [
TextInput::make('allocation_ip')
->datalist($this->getOwnerRecord()->node->ipAddresses())
->label('IP Address')
->inlineLabel()
->ipv4()
->helperText("Usually your machine's public IP unless you are port forwarding.")
->required(),
TextInput::make('allocation_alias')
->label('Alias')
->inlineLabel()
->default(null)
->helperText('Optional display name to help you remember what these are.')
->required(false),
TagsInput::make('allocation_ports')
->placeholder('Examples: 27015, 27017-27019')
->helperText(new HtmlString('
These are the ports that users can connect to this Server through.
<br />
You would have to port forward these on your home network.
'))
->label('Ports')
->inlineLabel()
->live()
->afterStateUpdated(function ($state, Set $set) {
$ports = collect();
$update = false;
foreach ($state as $portEntry) {
if (!str_contains($portEntry, '-')) {
if (is_numeric($portEntry)) {
$ports->push((int) $portEntry);
continue;
}
// Do not add non numerical ports
$update = true;
continue;
}
$update = true;
[$start, $end] = explode('-', $portEntry);
if (!is_numeric($start) || !is_numeric($end)) {
continue;
}
$start = max((int) $start, 0);
$end = min((int) $end, 2 ** 16 - 1);
foreach (range($start, $end) as $i) {
$ports->push($i);
}
}
$uniquePorts = $ports->unique()->values();
if ($ports->count() > $uniquePorts->count()) {
$update = true;
$ports = $uniquePorts;
}
$sortedPorts = $ports->sort()->values();
if ($sortedPorts->all() !== $ports->all()) {
$update = true;
$ports = $sortedPorts;
}
$ports = $ports->filter(fn ($port) => $port > 1024 && $port < 65535)->values();
if ($update) {
$set('allocation_ports', $ports->all());
}
})
->splitKeys(['Tab', ' ', ','])
->required(),
])
->action(fn (array $data, AssignmentService $service) => $service->handle($this->getOwnerRecord()->node, $data, $this->getOwnerRecord())),
AssociateAction::make()
->multiple()
->associateAnother(false)
->preloadRecordSelect()
->recordSelectOptionsQuery(fn ($query) => $query->whereBelongsTo($this->getOwnerRecord()->node))
->label('Add Allocation'),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DissociateBulkAction::make(),
]),
]);
}
}

View File

@@ -201,7 +201,7 @@ class EditProfile extends \Filament\Pages\Auth\EditProfile
Tab::make('API Keys')
->icon('tabler-key')
->schema([
Grid::make(5)->schema([
Grid::make('asdf')->columns(5)->schema([
Section::make('Create API Key')->columnSpan(3)->schema([
TextInput::make('description')
->live(),
@@ -289,7 +289,7 @@ class EditProfile extends \Filament\Pages\Auth\EditProfile
if ($token = $data['2facode'] ?? null) {
$tokens = $this->toggleTwoFactorService->handle($record, $token, true);
cache()->set("users.$record->id.2fa.tokens", implode("\n", $tokens), 15);
cache()->set("users.$record->id.2fa.tokens", implode("\n", $tokens), now()->addSeconds(15));
$this->redirectRoute('filament.admin.auth.profile', ['tab' => '-2fa-tab']);
}

View File

@@ -8,6 +8,7 @@ use App\Models\User;
use App\Services\Servers\SuspensionService;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables\Actions;
use Filament\Tables\Columns\SelectColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
@@ -65,6 +66,11 @@ class ServersRelationManager extends RelationManager
->icon('tabler-egg')
->url(fn (Server $server): string => route('filament.admin.resources.eggs.edit', ['record' => $server->egg]))
->sortable(),
SelectColumn::make('allocation.id')
->label('Primary Allocation')
->options(fn (Server $server) => [$server->allocation->id => $server->allocation->address])
->selectablePlaceholder(false)
->sortable(),
TextColumn::make('image')->hidden(),
TextColumn::make('databases_count')
->counts('databases')

View File

@@ -0,0 +1,70 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\WebhookResource\Pages;
use App\Models\WebhookConfiguration;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class WebhookResource extends Resource
{
protected static ?string $model = WebhookConfiguration::class;
protected static ?string $navigationIcon = 'tabler-webhook';
protected static ?string $navigationGroup = 'Advanced';
protected static ?string $label = 'Webhooks';
public static function form(Form $form): Form
{
return $form
->schema([
TextInput::make('endpoint')->activeUrl()->required(),
TextInput::make('description')->nullable(),
CheckboxList::make('events')->lazy()->options(
fn () => WebhookConfiguration::filamentCheckboxList()
)
->searchable()
->bulkToggleable()
->columns(3)
->columnSpanFull()
->gridDirection('row')
->required(),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('description'),
TextColumn::make('endpoint'),
])
->actions([
Tables\Actions\EditAction::make(),
]);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListWebhookConfigurations::route('/'),
'create' => Pages\CreateWebhookConfiguration::route('/create'),
'edit' => Pages\EditWebhookConfiguration::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\WebhookResource\Pages;
use App\Filament\Resources\WebhookResource;
use Filament\Resources\Pages\CreateRecord;
class CreateWebhookConfiguration extends CreateRecord
{
protected static string $resource = WebhookResource::class;
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\WebhookResource\Pages;
use App\Filament\Resources\WebhookResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditWebhookConfiguration extends EditRecord
{
protected static string $resource = WebhookResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\WebhookResource\Pages;
use App\Filament\Resources\WebhookResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListWebhookConfigurations extends ListRecords
{
protected static string $resource = WebhookResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@@ -40,7 +40,7 @@ class Utilities
{
return Carbon::instance((new CronExpression(
sprintf('%s %s %s %s %s', $minute, $hour, $dayOfMonth, $month, $dayOfWeek)
))->getNextRunDate());
))->getNextRunDate(now('UTC')));
}
public static function checked(string $name, mixed $default): string

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

@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Admin\Nodes;
use Illuminate\View\View;
use App\Models\Node;
use Illuminate\Support\Collection;
use App\Models\Allocation;
use App\Http\Controllers\Controller;
use App\Traits\Controllers\JavascriptInjection;
use App\Services\Helpers\SoftwareVersionService;
@@ -56,6 +57,32 @@ class NodeViewController extends Controller
return view('admin.nodes.view.configuration', compact('node'));
}
/**
* Return the node allocation management page.
*/
public function allocations(Node $node): View
{
$node->setRelation(
'allocations',
$node->allocations()
->orderByRaw('server_id IS NOT NULL DESC, server_id IS NULL')
->orderByRaw('INET_ATON(ip) ASC')
->orderBy('port')
->with('server:id,name')
->paginate(50)
);
$this->plainInject(['node' => Collection::wrap($node)->only(['id'])]);
return view('admin.nodes.view.allocation', [
'node' => $node,
'allocations' => Allocation::query()->where('node_id', $node->id)
->groupBy('ip')
->orderByRaw('INET_ATON(ip) ASC')
->get(['ip']),
]);
}
/**
* Return a listing of servers that exist for this specific node.
*/

View File

@@ -3,7 +3,10 @@
namespace App\Http\Controllers\Admin;
use Illuminate\View\View;
use Illuminate\Http\Request;
use App\Models\Node;
use Illuminate\Http\Response;
use App\Models\Allocation;
use Illuminate\Http\RedirectResponse;
use Prologue\Alerts\AlertsMessageBag;
use Illuminate\View\Factory as ViewFactory;
@@ -12,8 +15,11 @@ use App\Services\Nodes\NodeUpdateService;
use Illuminate\Cache\Repository as CacheRepository;
use App\Services\Nodes\NodeCreationService;
use App\Services\Nodes\NodeDeletionService;
use App\Services\Allocations\AssignmentService;
use App\Services\Helpers\SoftwareVersionService;
use App\Http\Requests\Admin\Node\NodeFormRequest;
use App\Http\Requests\Admin\Node\AllocationFormRequest;
use App\Http\Requests\Admin\Node\AllocationAliasFormRequest;
class NodesController extends Controller
{
@@ -22,6 +28,7 @@ class NodesController extends Controller
*/
public function __construct(
protected AlertsMessageBag $alert,
protected AssignmentService $assignmentService,
protected CacheRepository $cache,
protected NodeCreationService $creationService,
protected NodeDeletionService $deletionService,
@@ -39,6 +46,19 @@ class NodesController extends Controller
return view('admin.nodes.new');
}
/**
* Post controller to create a new node on the system.
*
* @throws \App\Exceptions\Model\DataValidationException
*/
public function store(NodeFormRequest $request): RedirectResponse
{
$node = $this->creationService->handle($request->normalize());
$this->alert->info(trans('admin/node.notices.node_created'))->flash();
return redirect()->route('admin.nodes.view.allocation', $node->id);
}
/**
* Updates settings for a node.
*
@@ -53,6 +73,83 @@ class NodesController extends Controller
return redirect()->route('admin.nodes.view.settings', $node->id)->withInput();
}
/**
* Removes a single allocation from a node.
*
* @throws \App\Exceptions\Service\Allocation\ServerUsingAllocationException
*/
public function allocationRemoveSingle(int $node, Allocation $allocation): Response
{
$allocation->delete();
return response('', 204);
}
/**
* Removes multiple individual allocations from a node.
*
* @throws \App\Exceptions\Service\Allocation\ServerUsingAllocationException
*/
public function allocationRemoveMultiple(Request $request, int $node): Response
{
$allocations = $request->input('allocations');
foreach ($allocations as $rawAllocation) {
$allocation = new Allocation();
$allocation->id = $rawAllocation['id'];
$this->allocationRemoveSingle($node, $allocation);
}
return response('', 204);
}
/**
* Remove all allocations for a specific IP at once on a node.
*/
public function allocationRemoveBlock(Request $request, int $node): RedirectResponse
{
/** @var Node $node */
$node = Node::query()->findOrFail($node);
$node->allocations()
->where('ip', $request->input('ip'))
->whereNull('server_id')
->delete();
$this->alert->success(trans('admin/node.notices.unallocated_deleted', ['ip' => $request->input('ip')]))
->flash();
return redirect()->route('admin.nodes.view.allocation', $node);
}
/**
* Sets an alias for a specific allocation on a node.
*
* @throws \App\Exceptions\Model\DataValidationException
*/
public function allocationSetAlias(AllocationAliasFormRequest $request): \Symfony\Component\HttpFoundation\Response
{
$allocation = Allocation::query()->findOrFail($request->input('allocation_id'));
$alias = (empty($request->input('alias'))) ? null : $request->input('alias');
$allocation->update(['ip_alias' => $alias]);
return response('', 204);
}
/**
* Creates new allocations on a node.
*
* @throws \App\Exceptions\Service\Allocation\CidrOutOfRangeException
* @throws \App\Exceptions\Service\Allocation\InvalidPortMappingException
* @throws \App\Exceptions\Service\Allocation\PortOutOfRangeException
* @throws \App\Exceptions\Service\Allocation\TooManyPortsInRangeException
*/
public function createAllocation(AllocationFormRequest $request, Node $node): RedirectResponse
{
$this->assignmentService->handle($node, $request->normalize());
$this->alert->success(trans('admin/node.notices.allocations_added'))->flash();
return redirect()->route('admin.nodes.view.allocation', $node->id);
}
/**
* Deletes a node from the system.
*

View File

@@ -36,6 +36,11 @@ class CreateServerController extends Controller
$eggs = Egg::with('variables')->get();
\JavaScript::put([
'nodeData' => Node::getForServerCreation(),
'eggs' => $eggs->keyBy('id'),
]);
return view('admin.servers.new', [
'eggs' => $eggs,
'nodes' => Node::all(),
@@ -47,6 +52,7 @@ class CreateServerController extends Controller
*
* @throws \Illuminate\Validation\ValidationException
* @throws \App\Exceptions\DisplayException
* @throws \App\Exceptions\Service\Deployment\NoViableAllocationException
* @throws \Throwable
*/
public function store(ServerFormRequest $request): RedirectResponse

View File

@@ -29,6 +29,8 @@ class ServerTransferController extends Controller
{
$validatedData = $request->validate([
'node_id' => 'required|exists:nodes,id',
'allocation_id' => 'required|bail|unique:servers|exists:allocations,id',
'allocation_additional' => 'nullable',
]);
if ($this->transferServerService->handle($server, $validatedData)) {

View File

@@ -47,8 +47,12 @@ class ServerViewController extends Controller
*/
public function build(Server $server): View
{
$allocations = $server->node->allocations->toBase();
return view('admin.servers.view.build', [
'server' => $server,
'assigned' => $allocations->where('server_id', $server->id)->sortBy('port')->sortBy('ip'),
'unassigned' => $allocations->where('server_id', null)->sortBy('port')->sortBy('ip'),
]);
}
@@ -117,6 +121,10 @@ class ServerViewController extends Controller
$canTransfer = true;
}
\JavaScript::put([
'nodeData' => Node::getForServerCreation(),
]);
return view('admin.servers.view.manage', [
'nodes' => Node::all(),
'server' => $server,

View File

@@ -0,0 +1,79 @@
<?php
namespace App\Http\Controllers\Api\Application\Nodes;
use App\Models\Node;
use Illuminate\Http\JsonResponse;
use App\Models\Allocation;
use Spatie\QueryBuilder\QueryBuilder;
use Spatie\QueryBuilder\AllowedFilter;
use Illuminate\Database\Eloquent\Builder;
use App\Services\Allocations\AssignmentService;
use App\Transformers\Api\Application\AllocationTransformer;
use App\Http\Controllers\Api\Application\ApplicationApiController;
use App\Http\Requests\Api\Application\Allocations\GetAllocationsRequest;
use App\Http\Requests\Api\Application\Allocations\StoreAllocationRequest;
use App\Http\Requests\Api\Application\Allocations\DeleteAllocationRequest;
class AllocationController extends ApplicationApiController
{
/**
* AllocationController constructor.
*/
public function __construct(
private AssignmentService $assignmentService,
) {
parent::__construct();
}
/**
* Return all the allocations that exist for a given node.
*/
public function index(GetAllocationsRequest $request, Node $node): array
{
$allocations = QueryBuilder::for($node->allocations())
->allowedFilters([
AllowedFilter::exact('ip'),
AllowedFilter::exact('port'),
'ip_alias',
AllowedFilter::callback('server_id', function (Builder $builder, $value) {
if (empty($value) || is_bool($value) || !ctype_digit((string) $value)) {
return $builder->whereNull('server_id');
}
return $builder->where('server_id', $value);
}),
])
->paginate($request->query('per_page') ?? 50);
return $this->fractal->collection($allocations)
->transformWith($this->getTransformer(AllocationTransformer::class))
->toArray();
}
/**
* Store new allocations for a given node.
*
* @throws \App\Exceptions\DisplayException
* @throws \App\Exceptions\Service\Allocation\CidrOutOfRangeException
* @throws \App\Exceptions\Service\Allocation\InvalidPortMappingException
* @throws \App\Exceptions\Service\Allocation\PortOutOfRangeException
* @throws \App\Exceptions\Service\Allocation\TooManyPortsInRangeException
*/
public function store(StoreAllocationRequest $request, Node $node): JsonResponse
{
$this->assignmentService->handle($node, $request->validated());
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
}
/**
* Delete a specific allocation from the Panel.
*/
public function delete(DeleteAllocationRequest $request, Node $node, Allocation $allocation): JsonResponse
{
$allocation->delete();
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
}
}

View File

@@ -49,6 +49,7 @@ class ServerController extends ApplicationApiController
* @throws \Illuminate\Validation\ValidationException
* @throws \App\Exceptions\DisplayException
* @throws \App\Exceptions\Model\DataValidationException
* @throws \App\Exceptions\Service\Deployment\NoViableAllocationException
*/
public function store(StoreServerRequest $request): JsonResponse
{

View File

@@ -69,6 +69,8 @@ class ServerManagementController extends ApplicationApiController
{
$validatedData = $request->validate([
'node_id' => 'required|exists:nodes,id',
'allocation_id' => 'required|bail|unique:servers|exists:allocations,id',
'allocation_additional' => 'nullable',
]);
if ($this->transferServerService->handle($server, $validatedData)) {

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

@@ -0,0 +1,137 @@
<?php
namespace App\Http\Controllers\Api\Client\Servers;
use App\Models\Server;
use Illuminate\Http\JsonResponse;
use App\Facades\Activity;
use App\Models\Allocation;
use App\Exceptions\DisplayException;
use App\Transformers\Api\Client\AllocationTransformer;
use App\Http\Controllers\Api\Client\ClientApiController;
use App\Services\Allocations\FindAssignableAllocationService;
use App\Http\Requests\Api\Client\Servers\Network\GetNetworkRequest;
use App\Http\Requests\Api\Client\Servers\Network\NewAllocationRequest;
use App\Http\Requests\Api\Client\Servers\Network\DeleteAllocationRequest;
use App\Http\Requests\Api\Client\Servers\Network\UpdateAllocationRequest;
use App\Http\Requests\Api\Client\Servers\Network\SetPrimaryAllocationRequest;
class NetworkAllocationController extends ClientApiController
{
/**
* NetworkAllocationController constructor.
*/
public function __construct(
private FindAssignableAllocationService $assignableAllocationService,
) {
parent::__construct();
}
/**
* Lists all the allocations available to a server and whether
* they are currently assigned as the primary for this server.
*/
public function index(GetNetworkRequest $request, Server $server): array
{
return $this->fractal->collection($server->allocations)
->transformWith($this->getTransformer(AllocationTransformer::class))
->toArray();
}
/**
* Set the primary allocation for a server.
*
* @throws \App\Exceptions\Model\DataValidationException
*/
public function update(UpdateAllocationRequest $request, Server $server, Allocation $allocation): array
{
$original = $allocation->notes;
$allocation->forceFill(['notes' => $request->input('notes')])->save();
if ($original !== $allocation->notes) {
Activity::event('server:allocation.notes')
->subject($allocation)
->property(['allocation' => $allocation->toString(), 'old' => $original, 'new' => $allocation->notes])
->log();
}
return $this->fractal->item($allocation)
->transformWith($this->getTransformer(AllocationTransformer::class))
->toArray();
}
/**
* Set the primary allocation for a server.
*
* @throws \App\Exceptions\Model\DataValidationException
*/
public function setPrimary(SetPrimaryAllocationRequest $request, Server $server, Allocation $allocation): array
{
$server->allocation()->associate($allocation);
$server->save();
Activity::event('server:allocation.primary')
->subject($allocation)
->property('allocation', $allocation->toString())
->log();
return $this->fractal->item($allocation)
->transformWith($this->getTransformer(AllocationTransformer::class))
->toArray();
}
/**
* Set the notes for the allocation for a server.
*s.
*
* @throws \App\Exceptions\DisplayException
*/
public function store(NewAllocationRequest $request, Server $server): array
{
if ($server->allocations()->count() >= $server->allocation_limit) {
throw new DisplayException('Cannot assign additional allocations to this server: limit has been reached.');
}
$allocation = $this->assignableAllocationService->handle($server);
Activity::event('server:allocation.create')
->subject($allocation)
->property('allocation', $allocation->toString())
->log();
return $this->fractal->item($allocation)
->transformWith($this->getTransformer(AllocationTransformer::class))
->toArray();
}
/**
* Delete an allocation from a server.
*
* @throws \App\Exceptions\DisplayException
*/
public function delete(DeleteAllocationRequest $request, Server $server, Allocation $allocation): JsonResponse
{
// Don't allow the deletion of allocations if the server does not have an
// allocation limit set.
if (empty($server->allocation_limit)) {
throw new DisplayException('You cannot delete allocations for this server: no allocation limit is set.');
}
if ($allocation->id === $server->allocation_id) {
throw new DisplayException('You cannot delete the primary allocation for this server.');
}
Allocation::query()->where('id', $allocation->id)->update([
'notes' => null,
'server_id' => null,
]);
Activity::event('server:allocation.delete')
->subject($allocation)
->property('allocation', $allocation->toString())
->log();
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Api\Client\Servers;
use App\Models\User;
use App\Notifications\RemovedFromServer;
use Illuminate\Http\Request;
use App\Models\Server;
use Illuminate\Http\JsonResponse;
@@ -144,6 +145,11 @@ class SubuserController extends ClientApiController
$log->transaction(function ($instance) use ($server, $subuser) {
$subuser->delete();
$subuser->user->notify(new RemovedFromServer([
'user' => $subuser->user->name_first,
'name' => $subuser->server->name,
]));
try {
$this->serverRepository->setServer($server)->revokeUserJTI($subuser->user_id);
} catch (DaemonConnectionException $exception) {

View File

@@ -16,7 +16,7 @@ class ServerContainersController extends Controller
{
$status = fluent($request->json()->all())->get('data.new_state');
cache()->set("servers.$server->uuid.container.status", $status, 3600);
cache()->set("servers.$server->uuid.container.status", $status, now()->addHour());
return new JsonResponse([]);
}

View File

@@ -48,7 +48,7 @@ class ServerDetailsController extends Controller
// Avoid run-away N+1 SQL queries by preloading the relationships that are used
// within each of the services called below.
$servers = Server::query()->with('egg', 'mounts', 'variables')
$servers = Server::query()->with('allocations', 'egg', 'mounts', 'variables')
->where('node_id', $node->id)
// If you don't cast this to a string you'll end up with a stringified per_page returned in
// the metadata, and then daemon will panic crash as a result.

View File

@@ -6,6 +6,7 @@ use App\Models\Server;
use App\Repositories\Daemon\DaemonServerRepository;
use Illuminate\Http\Response;
use Illuminate\Http\JsonResponse;
use App\Models\Allocation;
use App\Models\ServerTransfer;
use Illuminate\Database\ConnectionInterface;
use App\Http\Controllers\Controller;
@@ -52,7 +53,13 @@ class ServerTransferController extends Controller
/** @var \App\Models\Server $server */
$server = $this->connection->transaction(function () use ($server, $transfer) {
$allocations = array_merge([$transfer->old_allocation], $transfer->old_additional_allocations);
// Remove the old allocations for the server and re-assign the server to the new
// primary allocation and node.
Allocation::query()->whereIn('id', $allocations)->update(['server_id' => null]);
$server->update([
'allocation_id' => $transfer->new_allocation,
'node_id' => $transfer->new_node,
]);
@@ -86,6 +93,9 @@ class ServerTransferController extends Controller
{
$this->connection->transaction(function () use (&$transfer) {
$transfer->forceFill(['successful' => false])->saveOrFail();
$allocations = array_merge([$transfer->new_allocation], $transfer->new_additional_allocations);
Allocation::query()->whereIn('id', $allocations)->update(['server_id' => null]);
});
return new JsonResponse([], Response::HTTP_NO_CONTENT);

View File

@@ -10,6 +10,7 @@ use App\Models\Server;
use App\Models\Subuser;
use App\Models\Database;
use App\Models\Schedule;
use App\Models\Allocation;
use Illuminate\Database\Eloquent\Model;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@@ -47,6 +48,7 @@ class ResourceBelongsToServer
switch (get_class($model)) {
// All of these models use "server_id" as the field key for the server
// they are assigned to, so the logic is identical for them all.
case Allocation::class:
case Backup::class:
case Database::class:
case Schedule::class:

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Http\Requests\Admin\Node;
use App\Http\Requests\Admin\AdminFormRequest;
class AllocationAliasFormRequest extends AdminFormRequest
{
public function rules(): array
{
return [
'alias' => 'present|nullable|string',
'allocation_id' => 'required|numeric|exists:allocations,id',
];
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Http\Requests\Admin\Node;
use App\Http\Requests\Admin\AdminFormRequest;
class AllocationFormRequest extends AdminFormRequest
{
public function rules(): array
{
return [
'allocation_ip' => 'required|string',
'allocation_alias' => 'sometimes|nullable|string|max:255',
'allocation_ports' => 'required|array',
];
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Http\Requests\Admin;
use App\Models\Server;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
class ServerFormRequest extends AdminFormRequest
@@ -24,10 +25,34 @@ class ServerFormRequest extends AdminFormRequest
*/
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator) {
$validator->after(function ($validator) {
$validator->sometimes('node_id', 'required|numeric|bail|exists:nodes,id', function ($input) {
return !$input->auto_deploy;
});
$validator->sometimes('allocation_id', [
'required',
'numeric',
'bail',
Rule::exists('allocations', 'id')->where(function ($query) {
$query->where('node_id', $this->input('node_id'));
$query->whereNull('server_id');
}),
], function ($input) {
return !$input->auto_deploy;
});
$validator->sometimes('allocation_additional.*', [
'sometimes',
'required',
'numeric',
Rule::exists('allocations', 'id')->where(function ($query) {
$query->where('node_id', $this->input('node_id'));
$query->whereNull('server_id');
}),
], function ($input) {
return !$input->auto_deploy;
});
});
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Http\Requests\Api\Application\Allocations;
use App\Services\Acl\Api\AdminAcl;
use App\Http\Requests\Api\Application\ApplicationApiRequest;
class DeleteAllocationRequest extends ApplicationApiRequest
{
protected ?string $resource = AdminAcl::RESOURCE_ALLOCATIONS;
protected int $permission = AdminAcl::WRITE;
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Http\Requests\Api\Application\Allocations;
use App\Services\Acl\Api\AdminAcl;
use App\Http\Requests\Api\Application\ApplicationApiRequest;
class GetAllocationsRequest extends ApplicationApiRequest
{
protected ?string $resource = AdminAcl::RESOURCE_ALLOCATIONS;
protected int $permission = AdminAcl::READ;
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Http\Requests\Api\Application\Allocations;
use App\Services\Acl\Api\AdminAcl;
use App\Http\Requests\Api\Application\ApplicationApiRequest;
class StoreAllocationRequest extends ApplicationApiRequest
{
protected ?string $resource = AdminAcl::RESOURCE_ALLOCATIONS;
protected int $permission = AdminAcl::WRITE;
public function rules(): array
{
return [
'ip' => 'required|string',
'alias' => 'sometimes|nullable|string|max:255',
'ports' => 'required|array',
'ports.*' => 'string',
];
}
public function validated($key = null, $default = null): array
{
$data = parent::validated();
return [
'allocation_ip' => $data['ip'],
'allocation_ports' => $data['ports'],
'allocation_alias' => $data['alias'] ?? null,
];
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Http\Requests\Api\Application\Servers;
use App\Models\Server;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
use App\Services\Acl\Api\AdminAcl;
use App\Models\Objects\DeploymentObject;
@@ -48,6 +49,10 @@ class StoreServerRequest extends ApplicationApiRequest
'feature_limits.allocations' => $rules['allocation_limit'],
'feature_limits.backups' => $rules['backup_limit'],
// Placeholders for rules added in withValidator() function.
'allocation.default' => '',
'allocation.additional.*' => '',
// Automatic deployment rules
'deploy' => 'sometimes|required|array',
'deploy.locations' => 'array',
@@ -82,7 +87,8 @@ class StoreServerRequest extends ApplicationApiRequest
'cpu' => array_get($data, 'limits.cpu'),
'threads' => array_get($data, 'limits.threads'),
'skip_scripts' => array_get($data, 'skip_scripts', false),
'ports' => array_get($data, 'ports'),
'allocation_id' => array_get($data, 'allocation.default'),
'allocation_additional' => array_get($data, 'allocation.additional'),
'start_on_completion' => array_get($data, 'start_on_completion', false),
'database_limit' => array_get($data, 'feature_limits.databases'),
'allocation_limit' => array_get($data, 'feature_limits.allocations'),
@@ -98,6 +104,24 @@ class StoreServerRequest extends ApplicationApiRequest
*/
public function withValidator(Validator $validator): void
{
$validator->sometimes('allocation.default', [
'required', 'integer', 'bail',
Rule::exists('allocations', 'id')->where(function ($query) {
$query->whereNull('server_id');
}),
], function ($input) {
return !$input->deploy;
});
$validator->sometimes('allocation.additional.*', [
'integer',
Rule::exists('allocations', 'id')->where(function ($query) {
$query->whereNull('server_id');
}),
], function ($input) {
return !$input->deploy;
});
/** @deprecated use tags instead */
$validator->sometimes('deploy.locations', 'present', function ($input) {
return $input->deploy;
@@ -110,10 +134,6 @@ class StoreServerRequest extends ApplicationApiRequest
$validator->sometimes('deploy.port_range', 'present', function ($input) {
return $input->deploy;
});
$validator->sometimes('deploy.node_id', 'present', function ($input) {
return $input->deploy;
});
}
/**
@@ -129,7 +149,6 @@ class StoreServerRequest extends ApplicationApiRequest
$object->setDedicated($this->input('deploy.dedicated_ip', false));
$object->setTags($this->input('deploy.tags', $this->input('deploy.locations', [])));
$object->setPorts($this->input('deploy.port_range', []));
$object->setNode($this->input('deploy.node_id'));
return $object;
}

View File

@@ -15,6 +15,7 @@ class UpdateServerBuildConfigurationRequest extends ServerWriteRequest
$rules = Server::getRulesForUpdate($this->parameter('server', Server::class));
return [
'allocation' => $rules['allocation_id'],
'oom_killer' => $rules['oom_killer'],
'limits' => 'sometimes|array',
@@ -53,6 +54,7 @@ class UpdateServerBuildConfigurationRequest extends ServerWriteRequest
{
$data = parent::validated();
$data['allocation_id'] = $data['allocation'];
$data['database_limit'] = $data['feature_limits']['databases'] ?? null;
$data['allocation_limit'] = $data['feature_limits']['allocations'] ?? null;
$data['backup_limit'] = $data['feature_limits']['backups'] ?? null;

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Http\Requests\Api\Client\Servers\Network;
use App\Models\Permission;
use App\Http\Requests\Api\Client\ClientApiRequest;
class DeleteAllocationRequest extends ClientApiRequest
{
public function permission(): string
{
return Permission::ACTION_ALLOCATION_DELETE;
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Http\Requests\Api\Client\Servers\Network;
use App\Models\Permission;
use App\Http\Requests\Api\Client\ClientApiRequest;
class GetNetworkRequest extends ClientApiRequest
{
/**
* Check that the user has permission to view the allocations for
* this server.
*/
public function permission(): string
{
return Permission::ACTION_ALLOCATION_READ;
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Http\Requests\Api\Client\Servers\Network;
use App\Models\Permission;
use App\Http\Requests\Api\Client\ClientApiRequest;
class NewAllocationRequest extends ClientApiRequest
{
public function permission(): string
{
return Permission::ACTION_ALLOCATION_CREATE;
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Http\Requests\Api\Client\Servers\Network;
class SetPrimaryAllocationRequest extends UpdateAllocationRequest
{
public function rules(): array
{
return [];
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests\Api\Client\Servers\Network;
use App\Models\Allocation;
use App\Models\Permission;
use App\Http\Requests\Api\Client\ClientApiRequest;
class UpdateAllocationRequest extends ClientApiRequest
{
public function permission(): string
{
return Permission::ACTION_ALLOCATION_UPDATE;
}
public function rules(): array
{
$rules = Allocation::getRules();
return [
'notes' => array_merge($rules['notes'], ['present']),
];
}
}

View File

@@ -4,26 +4,16 @@ namespace App\Jobs;
use App\Models\Node;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class NodeStatistics implements ShouldQueue
class NodeStatistics implements ShouldBeUnique, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Create a new job instance.
*/
public function __construct()
{
//
}
/**
* Execute the job.
*/
public function handle(): void
{
foreach (Node::all() as $node) {

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Jobs;
use App\Models\WebhookConfiguration;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
class ProcessWebhook implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
private WebhookConfiguration $webhookConfiguration,
private string $eventName,
private array $data
) {
}
public function handle(): void
{
try {
Http::post($this->webhookConfiguration->endpoint, $this->data)->throw();
$successful = now();
} catch (\Exception) {
$successful = null;
}
$this->webhookConfiguration->webhooks()->create([
'payload' => $this->data,
'successful_at' => $successful,
'event' => $this->eventName,
'endpoint' => $this->webhookConfiguration->endpoint,
]);
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Listeners;
use App\Jobs\ProcessWebhook;
use App\Models\WebhookConfiguration;
class DispatchWebhooks
{
public function handle(string $eventName, array $data): void
{
if (!$this->eventIsWatched($eventName)) {
return;
}
$matchingHooks = cache()->rememberForever("webhooks.$eventName", function () use ($eventName) {
return WebhookConfiguration::query()->whereJsonContains('events', $eventName)->get();
});
foreach ($matchingHooks ?? [] as $webhookConfig) {
if (in_array($eventName, $webhookConfig->events)) {
ProcessWebhook::dispatch($webhookConfig, $eventName, $data);
}
}
}
protected function eventIsWatched(string $eventName): bool
{
$watchedEvents = cache()->rememberForever('watchedWebhooks', function () {
return WebhookConfiguration::pluck('events')
->flatten()
->unique()
->values()
->all();
});
return in_array($eventName, $watchedEvents);
}
}

View File

@@ -1,31 +0,0 @@
<?php
namespace App\Livewire;
use App\Models\Objects\Endpoint;
use Livewire\Mechanisms\HandleComponents\Synthesizers\Synth;
use Stringable;
class EndpointSynth extends Synth
{
public static string $key = 'endpoint';
public static function match(mixed $target): bool
{
return $target instanceof Endpoint;
}
public function dehydrate(Stringable $target): string
{
return (string) $target;
}
public function hydrate(mixed $value): ?Endpoint
{
if (!is_string($value) && !is_int($value)) {
return null;
}
return new Endpoint($value);
}
}

134
app/Models/Allocation.php Normal file
View File

@@ -0,0 +1,134 @@
<?php
namespace App\Models;
use App\Exceptions\Service\Allocation\ServerUsingAllocationException;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* App\Models\Allocation.
*
* @property int $id
* @property int $node_id
* @property string $ip
* @property string|null $ip_alias
* @property int $port
* @property int|null $server_id
* @property string|null $notes
* @property \Carbon\Carbon|null $created_at
* @property \Carbon\Carbon|null $updated_at
* @property string $alias
* @property bool $has_alias
* @property \App\Models\Server|null $server
* @property \App\Models\Node $node
*
* @method static \Database\Factories\AllocationFactory factory(...$parameters)
* @method static \Illuminate\Database\Eloquent\Builder|Allocation newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|Allocation newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|Allocation query()
* @method static \Illuminate\Database\Eloquent\Builder|Allocation whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|Allocation whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|Allocation whereIp($value)
* @method static \Illuminate\Database\Eloquent\Builder|Allocation whereIpAlias($value)
* @method static \Illuminate\Database\Eloquent\Builder|Allocation whereNodeId($value)
* @method static \Illuminate\Database\Eloquent\Builder|Allocation whereNotes($value)
* @method static \Illuminate\Database\Eloquent\Builder|Allocation wherePort($value)
* @method static \Illuminate\Database\Eloquent\Builder|Allocation whereServerId($value)
* @method static \Illuminate\Database\Eloquent\Builder|Allocation whereUpdatedAt($value)
*
* @mixin \Eloquent
*/
class Allocation extends Model
{
/**
* The resource name for this model when it is transformed into an
* API representation using fractal.
*/
public const RESOURCE_NAME = 'allocation';
/**
* The table associated with the model.
*/
protected $table = 'allocations';
/**
* Fields that are not mass assignable.
*/
protected $guarded = ['id', 'created_at', 'updated_at'];
public static array $validationRules = [
'node_id' => 'required|exists:nodes,id',
'ip' => 'required|ip',
'port' => 'required|numeric|between:1024,65535',
'ip_alias' => 'nullable|string',
'server_id' => 'nullable|exists:servers,id',
'notes' => 'nullable|string|max:256',
];
protected static function booted(): void
{
static::deleting(function (self $allocation) {
throw_if($allocation->server_id, new ServerUsingAllocationException(trans('exceptions.allocations.server_using')));
});
}
protected function casts(): array
{
return [
'node_id' => 'integer',
'port' => 'integer',
'server_id' => 'integer',
];
}
public function getRouteKeyName(): string
{
return $this->getKeyName();
}
/**
* Accessor to automatically provide the IP alias if defined.
*/
public function getAliasAttribute(?string $value): string
{
return (is_null($this->ip_alias)) ? $this->ip : $this->ip_alias;
}
/**
* Accessor to quickly determine if this allocation has an alias.
*/
public function getHasAliasAttribute(?string $value): bool
{
return !is_null($this->ip_alias);
}
/** @return Attribute<string, never> */
protected function address(): Attribute
{
return Attribute::make(
get: fn () => "$this->ip:$this->port",
);
}
public function toString(): string
{
return $this->address;
}
/**
* Gets information for the server associated with this allocation.
*/
public function server(): BelongsTo
{
return $this->belongsTo(Server::class);
}
/**
* Return the Node model associated with this allocation.
*/
public function node(): BelongsTo
{
return $this->belongsTo(Node::class);
}
}

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

82
app/Models/AuditLog.php Normal file
View File

@@ -0,0 +1,82 @@
<?php
namespace App\Models;
use Ramsey\Uuid\Uuid;
use Illuminate\Http\Request;
use Illuminate\Container\Container;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @deprecated — this class will be dropped in a future version, use the activity log
*/
class AuditLog extends Model
{
public const UPDATED_AT = null;
public static array $validationRules = [
'uuid' => 'required|uuid',
'action' => 'required|string|max:255',
'subaction' => 'nullable|string|max:255',
'device' => 'array',
'device.ip_address' => 'ip',
'device.user_agent' => 'string',
'metadata' => 'array',
];
protected $table = 'audit_logs';
protected $guarded = [
'id',
'created_at',
];
protected function casts(): array
{
return [
'is_system' => 'bool',
'device' => 'array',
'metadata' => 'array',
'created_at' => 'immutable_datetime',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function server(): BelongsTo
{
return $this->belongsTo(Server::class);
}
/**
* Creates a new AuditLog model and returns it, attaching device information and the
* currently authenticated user if available. This model is not saved at this point, so
* you can always make modifications to it as needed before saving.
*
* @deprecated
*/
public static function instance(string $action, array $metadata, bool $isSystem = false): self
{
/** @var \Illuminate\Http\Request $request */
$request = Container::getInstance()->make('request');
if ($isSystem || !$request instanceof Request) {
$request = null;
}
return (new self())->fill([
'uuid' => Uuid::uuid4()->toString(),
'is_system' => $isSystem,
'user_id' => ($request && $request->user()) ? $request->user()->id : null,
'server_id' => null,
'action' => $action,
'device' => $request ? [
'ip_address' => $request->getClientIp() ?? '127.0.0.1',
'user_agent' => $request->userAgent() ?? '',
] : [],
'metadata' => $metadata,
]);
}
}

View File

@@ -23,6 +23,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* @property \Carbon\CarbonImmutable $updated_at
* @property \Carbon\CarbonImmutable|null $deleted_at
* @property \App\Models\Server $server
* @property \App\Models\AuditLog[] $audits
*/
class Backup extends Model
{

View File

@@ -322,12 +322,6 @@ class Egg extends Model
public function getKebabName(): string
{
return str($this->name)
->kebab()
->replace('--', '-')
->lower()
->trim()
->split('/[^\w\-]/')
->join('');
return str($this->name)->kebab()->lower()->trim()->split('/[^\w\-]/')->join('');
}
}

View File

@@ -37,7 +37,7 @@ class EggVariable extends Model
/**
* Reserved environment variable names.
*/
public const RESERVED_ENV_NAMES = 'SERVER_MEMORY,SERVER_IP,ENV,HOME,USER,STARTUP,SERVER_UUID,UUID';
public const RESERVED_ENV_NAMES = 'SERVER_MEMORY,SERVER_IP,SERVER_PORT,ENV,HOME,USER,STARTUP,SERVER_UUID,UUID';
/**
* The table associated with the model.

View File

@@ -2,6 +2,7 @@
namespace App\Models\Filters;
use Illuminate\Support\Str;
use Spatie\QueryBuilder\Filters\Filter;
use Illuminate\Database\Eloquent\Builder;
@@ -31,6 +32,26 @@ class MultiFieldServerFilter implements Filter
// Only select the server values, otherwise you'll end up merging the allocation and
// server objects together, resulting in incorrect behavior and returned values.
->select('servers.*')
->join('allocations', 'allocations.server_id', '=', 'servers.id')
->where(function (Builder $builder) use ($value) {
$parts = explode(':', $value);
$builder->when(
!Str::startsWith($value, ':'),
// When the string does not start with a ":" it means we're looking for an IP or IP:Port
// combo, so use a query to handle that.
function (Builder $builder) use ($parts) {
$builder->orWhere('allocations.ip', $parts[0]);
if (!is_null($parts[1] ?? null)) {
$builder->where('allocations.port', 'LIKE', "{$parts[1]}%");
}
},
// Otherwise, just try to search for that specific port in the allocations.
function (Builder $builder) use ($value) {
$builder->orWhere('allocations.port', 'LIKE', substr($value, 1) . '%');
}
);
})
->groupBy('servers.id');
return;

View File

@@ -8,6 +8,7 @@ use Exception;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Symfony\Component\Yaml\Yaml;
@@ -40,6 +41,7 @@ use Symfony\Component\Yaml\Yaml;
* @property \Carbon\Carbon $updated_at
* @property \App\Models\Mount[]|\Illuminate\Database\Eloquent\Collection $mounts
* @property \App\Models\Server[]|\Illuminate\Database\Eloquent\Collection $servers
* @property \App\Models\Allocation[]|\Illuminate\Database\Eloquent\Collection $allocations
*/
class Node extends Model
{
@@ -233,6 +235,14 @@ class Node extends Model
return $this->hasMany(Server::class);
}
/**
* Gets the allocations associated with a node.
*/
public function allocations(): HasMany
{
return $this->hasMany(Allocation::class);
}
/**
* Returns a boolean if the node is viable for an additional server to be placed on it.
*/
@@ -262,6 +272,28 @@ class Node extends Model
return true;
}
public static function getForServerCreation(): Collection
{
return self::with('allocations')->get()->map(function (Node $item) {
$filtered = $item->getRelation('allocations')->where('server_id', null)->map(function ($map) {
return collect($map)->only(['id', 'ip', 'port']);
});
$ports = $filtered->map(function ($map) {
return [
'id' => $map['id'],
'text' => sprintf('%s:%s', $map['ip'], $map['port']),
];
})->values();
return [
'id' => $item->id,
'text' => $item->name,
'allocations' => $ports,
];
})->values();
}
public function systemInformation(): array
{
return once(function () {
@@ -288,10 +320,11 @@ class Node extends Model
public function serverStatuses(): array
{
$statuses = [];
try {
$statuses = Http::daemon($this)->connectTimeout(1)->timeout(1)->throw()->get('/api/servers')->json() ?? [];
} catch (Exception) {
$statuses = [];
$statuses = Http::daemon($this)->connectTimeout(1)->timeout(1)->get('/api/servers')->json() ?? [];
} catch (Exception $exception) {
report($exception);
}
foreach ($statuses as $status) {
@@ -345,10 +378,7 @@ class Node extends Model
// pass
}
return $ips
->filter(fn ($ip) => preg_match('/^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$/', $ip))
->unique()
->all();
return $ips->all();
});
}
}

View File

@@ -2,8 +2,6 @@
namespace App\Models\Objects;
use App\Models\Node;
class DeploymentObject
{
private bool $dedicated = false;
@@ -12,8 +10,6 @@ class DeploymentObject
private array $ports = [];
private Node $node;
public function isDedicated(): bool
{
return $this->dedicated;
@@ -49,16 +45,4 @@ class DeploymentObject
return $this;
}
public function getNode(): Node
{
return $this->node;
}
public function setNode(Node $node): self
{
$this->node = $node;
return $this;
}
}

View File

@@ -1,64 +0,0 @@
<?php
namespace App\Models\Objects;
use Illuminate\Contracts\Support\Jsonable;
use InvalidArgumentException;
class Endpoint implements Jsonable
{
public const CIDR_MAX_BITS = 27;
public const CIDR_MIN_BITS = 32;
public const PORT_FLOOR = 1024;
public const PORT_CEIL = 65535;
public const PORT_RANGE_LIMIT = 1000;
public const PORT_RANGE_REGEX = '/^(\d{4,5})-(\d{4,5})$/';
public const INADDR_ANY = '0.0.0.0';
public const INADDR_LOOPBACK = '127.0.0.1';
public int $port;
public string $ip;
public function __construct(string|int $port, ?string $ip = null)
{
$this->ip = $ip ?? self::INADDR_ANY;
$this->port = (int) $port;
if (str_contains($port, ':')) {
[$this->ip, $port] = explode(':', $port);
$this->port = (int) $port;
}
throw_unless(filter_var($this->ip, FILTER_VALIDATE_IP) !== false, new InvalidArgumentException("$this->ip is an invalid IP address"));
throw_unless($this->port > self::PORT_FLOOR, "Port $this->port must be greater than " . self::PORT_FLOOR);
throw_unless($this->port < self::PORT_CEIL, "Port $this->port must be less than " . self::PORT_CEIL);
}
public function __toString(): string
{
$ip = $this->ip;
if ($ip === self::INADDR_ANY) {
return (string) $this->port;
}
if ($ip === self::INADDR_LOOPBACK) {
$ip = 'localhost';
}
return "$ip:$this->port";
}
public function toJson($options = 0): string
{
return json_encode($this->__toString());
}
}

Some files were not shown because too many files have changed in this diff Show More