From 8c7c3d21926b4f6b18c44b95fbffb86edfad2d07 Mon Sep 17 00:00:00 2001 From: Richard Anderson Date: Sun, 16 Feb 2025 19:31:58 +0000 Subject: [PATCH] Refactor firewall and add edit rule (#488) --- app/Actions/FirewallRule/CreateRule.php | 67 ---------- app/Actions/FirewallRule/DeleteRule.php | 28 ----- app/Actions/FirewallRule/ManageRule.php | 117 ++++++++++++++++++ app/Actions/Server/CreateServer.php | 21 ++-- app/Enums/FirewallRuleStatus.php | 4 + .../API/FirewallRuleController.php | 35 +++++- app/Http/Resources/FirewallRuleResource.php | 1 + app/Models/FirewallRule.php | 19 ++- app/SSH/Services/Firewall/Firewall.php | 4 +- app/SSH/Services/Firewall/Ufw.php | 33 ++--- app/Web/Pages/Servers/Firewall/Index.php | 93 ++++++++++---- .../Servers/Firewall/Widgets/RulesList.php | 66 ++++++++-- config/core.php | 8 -- database/factories/FirewallRuleFactory.php | 1 + ..._15_082027_update_firewall_rules_table.php | 39 ++++++ .../services/firewall/ufw/add-rule.blade.php | 11 -- .../firewall/ufw/apply-rules.blade.php | 37 ++++++ .../firewall/ufw/backup-rules.blade.php | 6 + .../firewall/ufw/clear-backups.blade.php | 6 + .../firewall/ufw/remove-rule.blade.php | 11 -- .../firewall/ufw/restore-rules.blade.php | 10 ++ tests/Feature/API/FirewallTest.php | 35 +++++- tests/Feature/FirewallTest.php | 1 + 23 files changed, 443 insertions(+), 210 deletions(-) delete mode 100755 app/Actions/FirewallRule/CreateRule.php delete mode 100755 app/Actions/FirewallRule/DeleteRule.php create mode 100755 app/Actions/FirewallRule/ManageRule.php create mode 100644 database/migrations/2025_02_15_082027_update_firewall_rules_table.php delete mode 100755 resources/views/ssh/services/firewall/ufw/add-rule.blade.php create mode 100644 resources/views/ssh/services/firewall/ufw/apply-rules.blade.php create mode 100644 resources/views/ssh/services/firewall/ufw/backup-rules.blade.php create mode 100644 resources/views/ssh/services/firewall/ufw/clear-backups.blade.php delete mode 100755 resources/views/ssh/services/firewall/ufw/remove-rule.blade.php create mode 100644 resources/views/ssh/services/firewall/ufw/restore-rules.blade.php diff --git a/app/Actions/FirewallRule/CreateRule.php b/app/Actions/FirewallRule/CreateRule.php deleted file mode 100755 index cc4f03f..0000000 --- a/app/Actions/FirewallRule/CreateRule.php +++ /dev/null @@ -1,67 +0,0 @@ - $server->id, - 'type' => $input['type'], - 'protocol' => $input['protocol'], - 'port' => $input['port'], - 'source' => $input['source'], - 'mask' => $input['mask'] ?? null, - ]); - - /** @var Firewall $firewallHandler */ - $firewallHandler = $server->firewall()->handler(); - $firewallHandler->addRule( - $rule->type, - $rule->getRealProtocol(), - $rule->port, - $rule->source, - $rule->mask - ); - - $rule->status = FirewallRuleStatus::READY; - $rule->save(); - - return $rule; - } - - public static function rules(): array - { - return [ - 'type' => [ - 'required', - 'in:allow,deny', - ], - 'protocol' => [ - 'required', - Rule::in(array_keys(config('core.firewall_protocols_port'))), - ], - 'port' => [ - 'required', - 'numeric', - 'min:1', - 'max:65535', - ], - 'source' => [ - 'required', - 'ip', - ], - 'mask' => [ - 'nullable', - 'numeric', - ], - ]; - } -} diff --git a/app/Actions/FirewallRule/DeleteRule.php b/app/Actions/FirewallRule/DeleteRule.php deleted file mode 100755 index e4ce83e..0000000 --- a/app/Actions/FirewallRule/DeleteRule.php +++ /dev/null @@ -1,28 +0,0 @@ -status = FirewallRuleStatus::DELETING; - $rule->save(); - - $server->firewall() - ->handler() - ->removeRule( - $rule->type, - $rule->getRealProtocol(), - $rule->port, - $rule->source, - $rule->mask - ); - - $rule->delete(); - } -} diff --git a/app/Actions/FirewallRule/ManageRule.php b/app/Actions/FirewallRule/ManageRule.php new file mode 100755 index 0000000..9fad76f --- /dev/null +++ b/app/Actions/FirewallRule/ManageRule.php @@ -0,0 +1,117 @@ + $input['name'], + 'server_id' => $server->id, + 'type' => $input['type'], + 'protocol' => $input['protocol'], + 'port' => $input['port'], + 'source' => $sourceAny ? null : $input['source'], + 'mask' => $sourceAny ? null : ($input['mask'] ?? null), + 'status' => FirewallRuleStatus::CREATING, + ]); + + $rule->save(); + + dispatch(fn () => $this->applyRule($rule)); + + return $rule; + } + + public function update(FirewallRule $rule, array $input): FirewallRule + { + $sourceAny = $input['source_any'] ?? empty($input['source'] ?? null); + $rule->update([ + 'name' => $input['name'], + 'type' => $input['type'], + 'protocol' => $input['protocol'], + 'port' => $input['port'], + 'source' => $sourceAny ? null : $input['source'], + 'mask' => $sourceAny ? null : ($input['mask'] ?? null), + 'status' => FirewallRuleStatus::UPDATING, + ]); + + dispatch(fn () => $this->applyRule($rule)); + + return $rule; + } + + public function delete(FirewallRule $rule): void + { + $rule->status = FirewallRuleStatus::DELETING; + $rule->save(); + + dispatch(fn () => $this->applyRule($rule)); + } + + protected function applyRule($rule): void + { + try { + /** @var Firewall $handler */ + $handler = $rule->server->firewall()->handler(); + $handler->applyRules(); + } catch (\Exception $e) { + $rule->server->firewallRules() + ->where('status', '!=', FirewallRuleStatus::READY) + ->update(['status' => FirewallRuleStatus::FAILED]); + + throw $e; + } + + if ($rule->status === FirewallRuleStatus::DELETING) { + $rule->delete(); + + return; + } + + $rule->status = FirewallRuleStatus::READY; + $rule->save(); + } + + public static function rules(): array + { + return [ + 'name' => [ + 'required', + 'string', + 'max:18', + ], + 'type' => [ + 'required', + 'in:allow,deny', + ], + 'protocol' => [ + 'required', + 'in:tcp,udp', + ], + 'port' => [ + 'required', + 'numeric', + 'min:1', + 'max:65535', + ], + 'source' => [ + 'nullable', + 'ip', + ], + 'mask' => [ + 'nullable', + 'numeric', + 'min:1', + 'max:32', + ], + ]; + } +} diff --git a/app/Actions/Server/CreateServer.php b/app/Actions/Server/CreateServer.php index f3da2ba..553c3f9 100755 --- a/app/Actions/Server/CreateServer.php +++ b/app/Actions/Server/CreateServer.php @@ -197,26 +197,29 @@ public function createFirewallRules(Server $server): void $server->firewallRules()->createMany([ [ 'type' => 'allow', - 'protocol' => 'ssh', + 'name' => 'SSH', + 'protocol' => 'tcp', 'port' => 22, - 'source' => '0.0.0.0', - 'mask' => 0, + 'source' => null, + 'mask' => null, 'status' => FirewallRuleStatus::READY, ], [ 'type' => 'allow', - 'protocol' => 'http', + 'name' => 'HTTP', + 'protocol' => 'tcp', 'port' => 80, - 'source' => '0.0.0.0', - 'mask' => 0, + 'source' => null, + 'mask' => null, 'status' => FirewallRuleStatus::READY, ], [ 'type' => 'allow', - 'protocol' => 'https', + 'name' => 'HTTPS', + 'protocol' => 'tcp', 'port' => 443, - 'source' => '0.0.0.0', - 'mask' => 0, + 'source' => null, + 'mask' => null, 'status' => FirewallRuleStatus::READY, ], ]); diff --git a/app/Enums/FirewallRuleStatus.php b/app/Enums/FirewallRuleStatus.php index c8e6e77..36f9d86 100644 --- a/app/Enums/FirewallRuleStatus.php +++ b/app/Enums/FirewallRuleStatus.php @@ -6,7 +6,11 @@ final class FirewallRuleStatus { const CREATING = 'creating'; + const UPDATING = 'updating'; + const READY = 'ready'; const DELETING = 'deleting'; + + const FAILED = 'failed'; } diff --git a/app/Http/Controllers/API/FirewallRuleController.php b/app/Http/Controllers/API/FirewallRuleController.php index e4cfd58..3220ea9 100644 --- a/app/Http/Controllers/API/FirewallRuleController.php +++ b/app/Http/Controllers/API/FirewallRuleController.php @@ -2,8 +2,7 @@ namespace App\Http\Controllers\API; -use App\Actions\FirewallRule\CreateRule; -use App\Actions\FirewallRule\DeleteRule; +use App\Actions\FirewallRule\ManageRule; use App\Http\Controllers\Controller; use App\Http\Resources\FirewallRuleResource; use App\Models\FirewallRule; @@ -21,6 +20,7 @@ use Spatie\RouteAttributes\Attributes\Middleware; use Spatie\RouteAttributes\Attributes\Post; use Spatie\RouteAttributes\Attributes\Prefix; +use Spatie\RouteAttributes\Attributes\Put; #[Prefix('api/projects/{project}/servers/{server}/firewall-rules')] #[Middleware(['auth:sanctum', 'can-see-project'])] @@ -41,10 +41,11 @@ public function index(Project $project, Server $server): ResourceCollection #[Post('/', name: 'api.projects.servers.firewall-rules.create', middleware: 'ability:write')] #[Endpoint(title: 'create', description: 'Create a new firewall rule.')] + #[BodyParam(name: 'name', required: true)] #[BodyParam(name: 'type', required: true, enum: ['allow', 'deny'])] #[BodyParam(name: 'protocol', required: true, enum: ['tcp', 'udp'])] #[BodyParam(name: 'port', required: true)] - #[BodyParam(name: 'source', required: true)] + #[BodyParam(name: 'source', required: false)] #[BodyParam(name: 'mask', description: 'Mask for source IP.', example: '0')] #[ResponseFromApiResource(FirewallRuleResource::class, FirewallRule::class)] public function create(Request $request, Project $project, Server $server): FirewallRuleResource @@ -53,9 +54,31 @@ public function create(Request $request, Project $project, Server $server): Fire $this->validateRoute($project, $server); - $this->validate($request, CreateRule::rules()); + $this->validate($request, ManageRule::rules()); - $firewallRule = app(CreateRule::class)->create($server, $request->all()); + $firewallRule = app(ManageRule::class)->create($server, $request->all()); + + return new FirewallRuleResource($firewallRule); + } + + #[Put('{firewallRule}', name: 'api.projects.servers.firewall-rules.edit', middleware: 'ability:write')] + #[Endpoint(title: 'edit', description: 'Update an existing firewall rule.')] + #[BodyParam(name: 'name', required: true)] + #[BodyParam(name: 'type', required: true, enum: ['allow', 'deny'])] + #[BodyParam(name: 'protocol', required: true, enum: ['tcp', 'udp'])] + #[BodyParam(name: 'port', required: true)] + #[BodyParam(name: 'source', required: false)] + #[BodyParam(name: 'mask', description: 'Mask for source IP.', example: '0')] + #[ResponseFromApiResource(FirewallRuleResource::class, FirewallRule::class)] + public function edit(Request $request, Project $project, Server $server, FirewallRule $firewallRule): FirewallRuleResource + { + $this->authorize('update', [FirewallRule::class, $firewallRule]); + + $this->validateRoute($project, $server); + + $this->validate($request, ManageRule::rules()); + + $firewallRule = app(ManageRule::class)->update($firewallRule, $request->all()); return new FirewallRuleResource($firewallRule); } @@ -81,7 +104,7 @@ public function delete(Project $project, Server $server, FirewallRule $firewallR $this->validateRoute($project, $server, $firewallRule); - app(DeleteRule::class)->delete($server, $firewallRule); + app(ManageRule::class)->delete($firewallRule); return response()->noContent(); } diff --git a/app/Http/Resources/FirewallRuleResource.php b/app/Http/Resources/FirewallRuleResource.php index 0e0db2e..15bc321 100644 --- a/app/Http/Resources/FirewallRuleResource.php +++ b/app/Http/Resources/FirewallRuleResource.php @@ -13,6 +13,7 @@ public function toArray(Request $request): array { return [ 'id' => $this->id, + 'name' => $this->name, 'server_id' => $this->server_id, 'type' => $this->type, 'protocol' => $this->protocol, diff --git a/app/Models/FirewallRule.php b/app/Models/FirewallRule.php index aec1fe8..b5a5669 100755 --- a/app/Models/FirewallRule.php +++ b/app/Models/FirewallRule.php @@ -2,11 +2,13 @@ namespace App\Models; +use App\Enums\FirewallRuleStatus; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; /** * @property int $server_id + * @property string $name * @property string $type * @property string $protocol * @property int $port @@ -21,6 +23,7 @@ class FirewallRule extends AbstractModel use HasFactory; protected $fillable = [ + 'name', 'server_id', 'type', 'protocol', @@ -36,13 +39,19 @@ class FirewallRule extends AbstractModel 'port' => 'integer', ]; + public function getStatusColor(): string + { + return match ($this->status) { + FirewallRuleStatus::CREATING, + FirewallRuleStatus::UPDATING, + FirewallRuleStatus::DELETING => 'warning', + FirewallRuleStatus::READY => 'success', + FirewallRuleStatus::FAILED => 'danger', + }; + } + public function server(): BelongsTo { return $this->belongsTo(Server::class); } - - public function getRealProtocol(): string - { - return $this->protocol === 'udp' ? 'udp' : 'tcp'; - } } diff --git a/app/SSH/Services/Firewall/Firewall.php b/app/SSH/Services/Firewall/Firewall.php index e71cdfe..1ef3531 100755 --- a/app/SSH/Services/Firewall/Firewall.php +++ b/app/SSH/Services/Firewall/Firewall.php @@ -4,7 +4,5 @@ interface Firewall { - public function addRule(string $type, string $protocol, int $port, string $source, ?string $mask): void; - - public function removeRule(string $type, string $protocol, int $port, string $source, ?string $mask): void; + public function applyRules(): void; } diff --git a/app/SSH/Services/Firewall/Ufw.php b/app/SSH/Services/Firewall/Ufw.php index 46a8a1e..431cbbb 100755 --- a/app/SSH/Services/Firewall/Ufw.php +++ b/app/SSH/Services/Firewall/Ufw.php @@ -2,6 +2,7 @@ namespace App\SSH\Services\Firewall; +use App\Enums\FirewallRuleStatus; use App\Exceptions\SSHError; class Ufw extends AbstractFirewall @@ -26,34 +27,16 @@ public function uninstall(): void /** * @throws SSHError */ - public function addRule(string $type, string $protocol, int $port, string $source, ?string $mask): void + public function applyRules(): void { - $this->service->server->ssh()->exec( - view('ssh.services.firewall.ufw.add-rule', [ - 'type' => $type, - 'protocol' => $protocol, - 'port' => $port, - 'source' => $source, - 'mask' => $mask || $mask === 0 ? '/'.$mask : '', - ]), - 'add-firewall-rule' - ); - } + $rules = $this->service->server + ->firewallRules() + ->where('status', '!=', FirewallRuleStatus::DELETING) + ->get(); - /** - * @throws SSHError - */ - public function removeRule(string $type, string $protocol, int $port, string $source, ?string $mask): void - { $this->service->server->ssh()->exec( - view('ssh.services.firewall.ufw.remove-rule', [ - 'type' => $type, - 'protocol' => $protocol, - 'port' => $port, - 'source' => $source, - 'mask' => $mask || $mask === 0 ? '/'.$mask : '', - ]), - 'remove-firewall-rule' + view('ssh.services.firewall.ufw.apply-rules', compact('rules')), + 'apply-rules' ); } } diff --git a/app/Web/Pages/Servers/Firewall/Index.php b/app/Web/Pages/Servers/Firewall/Index.php index b04a2e7..801eade 100644 --- a/app/Web/Pages/Servers/Firewall/Index.php +++ b/app/Web/Pages/Servers/Firewall/Index.php @@ -2,14 +2,17 @@ namespace App\Web\Pages\Servers\Firewall; -use App\Actions\FirewallRule\CreateRule; +use App\Actions\FirewallRule\ManageRule; use App\Models\FirewallRule; use App\Web\Pages\Servers\Page; use Filament\Actions\Action; +use Filament\Forms\Components\Checkbox; use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; +use Filament\Forms\Get; use Filament\Notifications\Notification; use Filament\Support\Enums\MaxWidth; +use Illuminate\Support\Facades\Request; class Index extends Page { @@ -31,6 +34,64 @@ public function getWidgets(): array ]; } + public static function getFirewallForm(?FirewallRule $record = null): array + { + return [ + TextInput::make('name') + ->label('Purpose') + ->default($record->name ?? null) + ->rules(ManageRule::rules()['name']), + Select::make('type') + ->label('Type') + ->default($record->type ?? 'allow') + ->options([ + 'allow' => 'Allow', + 'deny' => 'Deny', + ]) + ->rules(ManageRule::rules()['type']), + Select::make('protocol') + ->label('Protocol') + ->default($record->protocol ?? 'tcp') + ->options([ + 'tcp' => 'TCP', + 'udp' => 'UDP', + ]) + ->rules(ManageRule::rules()['protocol']), + TextInput::make('port') + ->label('Port') + ->default($record->port ?? null) + ->rules(['required', 'integer']), + Checkbox::make('source_any') + ->label('Any Source') + ->default(($record->source ?? null) == null) + ->rules(['boolean']) + ->helperText('Allow connections from any source, regardless of their IP address or subnet mask.') + ->live(), + TextInput::make('source') + ->hidden(fn (Get $get) => $get('source_any') == true) + ->label('Source') + ->helperText('The IP address of the source of the connection.') + ->rules(ManageRule::rules()['source']) + ->default($record->source ?? null) + ->suffixAction( + \Filament\Forms\Components\Actions\Action::make('get_ip') + ->icon('heroicon-o-globe-alt') + ->color('primary') + ->tooltip('Use My IP') + ->action(function ($set) { + $ip = Request::ip(); + $set('source', $ip); + }) + ), + TextInput::make('mask') + ->hidden(fn (Get $get) => $get('source_any') == true) + ->label('Mask') + ->default($record->mask ?? null) + ->helperText('The subnet mask of the source of the connection. Leave blank for a single IP address.') + ->rules(ManageRule::rules()['mask']), + ]; + } + protected function getHeaderActions(): array { return [ @@ -45,37 +106,19 @@ protected function getHeaderActions(): array ->label('Create a Rule') ->icon('heroicon-o-plus') ->modalWidth(MaxWidth::Large) - ->form([ - Select::make('type') - ->native(false) - ->options([ - 'allow' => 'Allow', - 'deny' => 'Deny', - ]) - ->rules(CreateRule::rules()['type']), - Select::make('protocol') - ->native(false) - ->options([ - 'tcp' => 'TCP', - 'udp' => 'UDP', - ]) - ->rules(CreateRule::rules()['protocol']), - TextInput::make('port') - ->rules(CreateRule::rules()['port']), - TextInput::make('source') - ->rules(CreateRule::rules()['source']), - TextInput::make('mask') - ->rules(CreateRule::rules()['mask']), - ]) + ->modalHeading('Create Firewall Rule') + ->modalDescription('Add a new rule to the firewall') + ->modalSubmitActionLabel('Create') + ->form(self::getFirewallForm()) ->action(function (array $data) { run_action($this, function () use ($data) { - app(CreateRule::class)->create($this->server, $data); + app(ManageRule::class)->create($this->server, $data); $this->dispatch('$refresh'); Notification::make() ->success() - ->title('Firewall rule created!') + ->title('Applying Firewall Rule') ->send(); }); }), diff --git a/app/Web/Pages/Servers/Firewall/Widgets/RulesList.php b/app/Web/Pages/Servers/Firewall/Widgets/RulesList.php index 42a9a3e..56a88cb 100644 --- a/app/Web/Pages/Servers/Firewall/Widgets/RulesList.php +++ b/app/Web/Pages/Servers/Firewall/Widgets/RulesList.php @@ -2,15 +2,18 @@ namespace App\Web\Pages\Servers\Firewall\Widgets; -use App\Actions\FirewallRule\DeleteRule; +use App\Actions\FirewallRule\ManageRule; use App\Models\FirewallRule; use App\Models\Server; +use App\Web\Pages\Servers\Firewall\Index; use Filament\Notifications\Notification; +use Filament\Support\Enums\MaxWidth; use Filament\Tables\Actions\Action; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; use Filament\Widgets\TableWidget as Widget; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Str; class RulesList extends Widget { @@ -26,19 +29,40 @@ protected function getTableQuery(): Builder protected function getTableColumns(): array { return [ + TextColumn::make('name') + ->searchable() + ->sortable() + ->label('Purpose'), TextColumn::make('type') ->sortable() - ->extraAttributes(['class' => 'uppercase']) - ->color(fn (FirewallRule $record) => $record->type === 'allow' ? 'green' : 'red'), + ->badge() + ->color(fn ($state) => $state === 'allow' ? 'success' : 'warning') + ->label('Type') + ->formatStateUsing(fn ($state) => Str::upper($state)), + TextColumn::make('id') + ->sortable() + ->label('Source') + ->formatStateUsing(function (FirewallRule $record) { + $source = $record->source == null ? 'any' : $record->source; + if ($source !== 'any' && $record->mask !== null) { + $source .= '/'.$record->mask; + } + + return $source; + }), TextColumn::make('protocol') ->sortable() - ->extraAttributes(['class' => 'uppercase']), + ->badge() + ->color('primary') + ->label('Protocol') + ->formatStateUsing(fn ($state) => Str::upper($state)), TextColumn::make('port') - ->sortable(), - TextColumn::make('source') - ->sortable(), - TextColumn::make('mask') - ->sortable(), + ->sortable() + ->label('Port'), + TextColumn::make('status') + ->label('Status') + ->badge() + ->color(fn (FirewallRule $record) => $record->getStatusColor()), ]; } @@ -49,6 +73,28 @@ public function table(Table $table): Table ->query($this->getTableQuery()) ->columns($this->getTableColumns()) ->actions([ + Action::make('edit') + ->icon('heroicon-o-pencil') + ->tooltip('Edit') + ->hiddenLabel() + ->modalWidth(MaxWidth::Large) + ->modalHeading('Edit Firewall Rule') + ->modalDescription('Edit the associated servers firewall rule.') + ->modalSubmitActionLabel('Update') + ->authorize(fn (FirewallRule $record) => auth()->user()->can('update', $record)) + ->form(fn ($record) => Index::getFirewallForm($record)) + ->action(function (FirewallRule $record, array $data) { + run_action($this, function () use ($record, $data) { + app(ManageRule::class)->update($record, $data); + + $this->dispatch('$refresh'); + + Notification::make() + ->success() + ->title('Applying Firewall Rule') + ->send(); + }); + }), Action::make('delete') ->icon('heroicon-o-trash') ->tooltip('Delete') @@ -58,7 +104,7 @@ public function table(Table $table): Table ->authorize(fn (FirewallRule $record) => auth()->user()->can('delete', $record)) ->action(function (FirewallRule $record) { try { - app(DeleteRule::class)->delete($this->server, $record); + app(ManageRule::class)->delete($record); } catch (\Exception $e) { Notification::make() ->danger() diff --git a/config/core.php b/config/core.php index ad7a747..ffdb636 100755 --- a/config/core.php +++ b/config/core.php @@ -485,14 +485,6 @@ 'post_max_size' => 'M', ], - /* - * firewall - */ - 'firewall_protocols_port' => [ - 'tcp' => '', - 'udp' => '', - ], - /* * Disable these IPs for servers */ diff --git a/database/factories/FirewallRuleFactory.php b/database/factories/FirewallRuleFactory.php index 13e8aa0..3075084 100644 --- a/database/factories/FirewallRuleFactory.php +++ b/database/factories/FirewallRuleFactory.php @@ -12,6 +12,7 @@ class FirewallRuleFactory extends Factory public function definition(): array { return [ + 'name' => $this->faker->word, 'type' => 'allow', 'protocol' => 'tcp', 'port' => $this->faker->numberBetween(1, 65535), diff --git a/database/migrations/2025_02_15_082027_update_firewall_rules_table.php b/database/migrations/2025_02_15_082027_update_firewall_rules_table.php new file mode 100644 index 0000000..49f10d8 --- /dev/null +++ b/database/migrations/2025_02_15_082027_update_firewall_rules_table.php @@ -0,0 +1,39 @@ +string('name')->default('Undefined')->after('id'); + $table->ipAddress('source')->default(null)->nullable()->change(); + }); + + DB::statement("UPDATE firewall_rules SET name = UPPER(protocol) WHERE protocol IN ('ssh', 'http', 'https')"); + DB::statement("UPDATE firewall_rules SET protocol = 'tcp' WHERE protocol IN ('ssh', 'http', 'https')"); + DB::statement("UPDATE firewall_rules SET source = null WHERE source = '0.0.0.0'"); + DB::statement("UPDATE firewall_rules SET mask = null WHERE mask = '0'"); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + DB::statement("UPDATE firewall_rules SET protocol = LOWER(name) WHERE protocol = 'tcp' AND LOWER(name) IN ('ssh', 'http', 'https')"); + DB::statement("UPDATE firewall_rules SET source = '0.0.0.0' WHERE source is null"); + DB::statement("UPDATE firewall_rules SET mask = '0' WHERE mask is null"); + + Schema::table('firewall_rules', function (Blueprint $table) { + $table->dropColumn('name'); + $table->ipAddress('source')->default('0.0.0.0')->change(); + }); + } +}; diff --git a/resources/views/ssh/services/firewall/ufw/add-rule.blade.php b/resources/views/ssh/services/firewall/ufw/add-rule.blade.php deleted file mode 100755 index 88c904f..0000000 --- a/resources/views/ssh/services/firewall/ufw/add-rule.blade.php +++ /dev/null @@ -1,11 +0,0 @@ -if ! sudo ufw {{ $type }} from {{ $source }}{{ $mask }} to any proto {{ $protocol }} port {{ $port }}; then - echo 'VITO_SSH_ERROR' && exit 1 -fi - -if ! sudo ufw reload; then - echo 'VITO_SSH_ERROR' && exit 1 -fi - -if ! sudo service ufw restart; then - echo 'VITO_SSH_ERROR' && exit 1 -fi diff --git a/resources/views/ssh/services/firewall/ufw/apply-rules.blade.php b/resources/views/ssh/services/firewall/ufw/apply-rules.blade.php new file mode 100644 index 0000000..397ff04 --- /dev/null +++ b/resources/views/ssh/services/firewall/ufw/apply-rules.blade.php @@ -0,0 +1,37 @@ +@include('ssh.services.firewall.ufw.backup-rules') + +if ! sudo ufw --force reset; then + echo 'VITO_SSH_ERROR' && exit 1 +fi + + +if ! sudo ufw default deny incoming; then + echo 'VITO_SSH_ERROR' && exit 1 +fi + +if ! sudo ufw default allow outgoing; then + echo 'VITO_SSH_ERROR' && exit 1 +fi + +@foreach($rules as $rule) + @php + $source = isset($rule->source) && $rule->source !== null + ? $rule->source . (isset($rule->mask) && $rule->mask !== null ? '/' . $rule->mask : '') + : 'any'; + @endphp + + if ! sudo ufw {{ $rule->type }} from {{ $source }} to any proto {{ $rule->protocol }} port {{ $rule->port }}; then + @include('ssh.services.firewall.ufw.restore-rules') + echo 'VITO_SSH_ERROR' && exit 1 + fi +@endforeach + +if ! sudo ufw --force enable; then + echo 'VITO_SSH_ERROR' && exit 1 +fi + +if ! sudo ufw reload; then + echo 'VITO_SSH_ERROR' && exit 1 +fi + +@include('ssh.services.firewall.ufw.clear-backups') diff --git a/resources/views/ssh/services/firewall/ufw/backup-rules.blade.php b/resources/views/ssh/services/firewall/ufw/backup-rules.blade.php new file mode 100644 index 0000000..2ecee34 --- /dev/null +++ b/resources/views/ssh/services/firewall/ufw/backup-rules.blade.php @@ -0,0 +1,6 @@ +sudo cp /etc/ufw/before.rules /tmp/ufw.before.backup +sudo cp /etc/ufw/after.rules /tmp/ufw.after.backup +sudo cp /etc/ufw/user.rules /tmp/ufw.user.backup +sudo cp /etc/ufw/before6.rules /tmp/ufw.before6.backup +sudo cp /etc/ufw/after6.rules /tmp/ufw.after6.backup +sudo cp /etc/ufw/user6.rules /tmp/ufw.user6.backup diff --git a/resources/views/ssh/services/firewall/ufw/clear-backups.blade.php b/resources/views/ssh/services/firewall/ufw/clear-backups.blade.php new file mode 100644 index 0000000..6deaecc --- /dev/null +++ b/resources/views/ssh/services/firewall/ufw/clear-backups.blade.php @@ -0,0 +1,6 @@ +sudo rm -f /tmp/ufw.before.backup +sudo rm -f /tmp/ufw.after.backup +sudo rm -f /tmp/ufw.user.backup +sudo rm -f /tmp/ufw.before6.backup +sudo rm -f /tmp/ufw.after6.backup +sudo rm -f /tmp/ufw.user6.backup diff --git a/resources/views/ssh/services/firewall/ufw/remove-rule.blade.php b/resources/views/ssh/services/firewall/ufw/remove-rule.blade.php deleted file mode 100755 index 8bd0e90..0000000 --- a/resources/views/ssh/services/firewall/ufw/remove-rule.blade.php +++ /dev/null @@ -1,11 +0,0 @@ -if ! sudo ufw delete {{ $type }} from {{ $source }}{{ $mask }} to any proto {{ $protocol }} port {{ $port }}; then - echo 'VITO_SSH_ERROR' && exit 1 -fi - -if ! sudo ufw reload; then - echo 'VITO_SSH_ERROR' && exit 1 -fi - -if ! sudo service ufw restart; then - echo 'VITO_SSH_ERROR' && exit 1 -fi diff --git a/resources/views/ssh/services/firewall/ufw/restore-rules.blade.php b/resources/views/ssh/services/firewall/ufw/restore-rules.blade.php new file mode 100644 index 0000000..e174ab7 --- /dev/null +++ b/resources/views/ssh/services/firewall/ufw/restore-rules.blade.php @@ -0,0 +1,10 @@ +sudo ufw --force disable + +sudo cp /tmp/ufw.before.backup /etc/ufw/before.rules +sudo cp /tmp/ufw.after.backup /etc/ufw/after.rules +sudo cp /tmp/ufw.user.backup /etc/ufw/user.rules +sudo cp /tmp/ufw.before6.backup /etc/ufw/before6.rules +sudo cp /tmp/ufw.after6.backup /etc/ufw/after6.rules +sudo cp /tmp/ufw.user6.backup /etc/ufw/user6.rules + +sudo ufw --force enable diff --git a/tests/Feature/API/FirewallTest.php b/tests/Feature/API/FirewallTest.php index 7d3eb89..bc240df 100644 --- a/tests/Feature/API/FirewallTest.php +++ b/tests/Feature/API/FirewallTest.php @@ -23,16 +23,47 @@ public function test_create_firewall_rule(): void 'project' => $this->server->project, 'server' => $this->server, ]), [ + 'name' => 'Test', 'type' => 'allow', 'protocol' => 'tcp', 'port' => '1234', 'source' => '0.0.0.0', - 'mask' => '0', + 'mask' => '1', ]) ->assertSuccessful() ->assertJsonFragment([ 'port' => 1234, - 'status' => FirewallRuleStatus::READY, + 'status' => FirewallRuleStatus::CREATING, + ]); + } + + public function test_edit_firewall_rule(): void + { + SSH::fake(); + + Sanctum::actingAs($this->user, ['read', 'write']); + + $rule = FirewallRule::factory()->create([ + 'server_id' => $this->server->id, + 'port' => 1234, + ]); + + $this->json('PUT', route('api.projects.servers.firewall-rules.edit', [ + 'project' => $this->server->project, + 'server' => $this->server, + 'firewallRule' => $rule, + ]), [ + 'name' => 'Test', + 'type' => 'allow', + 'protocol' => 'tcp', + 'port' => '55', + 'source' => null, + 'mask' => null, + ]) + ->assertSuccessful() + ->assertJsonFragment([ + 'port' => 55, + 'status' => FirewallRuleStatus::UPDATING, ]); } diff --git a/tests/Feature/FirewallTest.php b/tests/Feature/FirewallTest.php index c5c96a0..829f8f6 100644 --- a/tests/Feature/FirewallTest.php +++ b/tests/Feature/FirewallTest.php @@ -25,6 +25,7 @@ public function test_create_firewall_rule(): void 'server' => $this->server, ]) ->callAction('create', [ + 'name' => 'Test', 'type' => 'allow', 'protocol' => 'tcp', 'port' => '1234',