From 6032bd1098ddd8825e2ec884a80b630c2ac58ca4 Mon Sep 17 00:00:00 2001 From: Saeed Vaziry Date: Tue, 27 May 2025 00:30:29 +0200 Subject: [PATCH] #591 - firewall --- app/Actions/FirewallRule/ManageRule.php | 27 ++-- .../API/FirewallRuleController.php | 4 - app/Http/Controllers/FirewallController.php | 66 ++++++++ app/Http/Controllers/ServerController.php | 16 ++ app/Http/Middleware/HandleInertiaRequests.php | 2 + app/Http/Resources/FirewallRuleResource.php | 1 + app/Models/FirewallRule.php | 24 +-- resources/js/app.tsx | 2 +- resources/js/components/app-sidebar.tsx | 12 +- resources/js/layouts/server/layout.tsx | 17 +- .../js/pages/firewall/components/columns.tsx | 138 ++++++++++++++++ .../js/pages/firewall/components/form.tsx | 152 ++++++++++++++++++ resources/js/pages/firewall/index.tsx | 42 +++++ .../js/pages/servers/components/header.tsx | 23 ++- resources/js/types/firewall.d.ts | 15 ++ resources/js/types/index.d.ts | 1 + tests/Feature/FirewallTest.php | 37 ++--- 17 files changed, 514 insertions(+), 65 deletions(-) create mode 100644 app/Http/Controllers/FirewallController.php create mode 100644 resources/js/pages/firewall/components/columns.tsx create mode 100644 resources/js/pages/firewall/components/form.tsx create mode 100644 resources/js/pages/firewall/index.tsx create mode 100644 resources/js/types/firewall.d.ts diff --git a/app/Actions/FirewallRule/ManageRule.php b/app/Actions/FirewallRule/ManageRule.php index a855a6c6..c3ab1a6c 100755 --- a/app/Actions/FirewallRule/ManageRule.php +++ b/app/Actions/FirewallRule/ManageRule.php @@ -8,6 +8,7 @@ use App\Models\Service; use App\SSH\Services\Firewall\Firewall; use Exception; +use Illuminate\Support\Facades\Validator; class ManageRule { @@ -17,6 +18,8 @@ class ManageRule */ public function create(Server $server, array $input): FirewallRule { + Validator::make($input, self::rules($input))->validate(); + $sourceAny = $input['source_any'] ?? empty($input['source'] ?? null); $rule = new FirewallRule([ 'name' => $input['name'], @@ -42,6 +45,8 @@ public function create(Server $server, array $input): FirewallRule */ public function update(FirewallRule $rule, array $input): FirewallRule { + Validator::make($input, self::rules($input))->validate(); + $sourceAny = $input['source_any'] ?? empty($input['source'] ?? null); $rule->update([ 'name' => $input['name'], @@ -93,11 +98,12 @@ protected function applyRule(FirewallRule $rule): void } /** + * @param array $input * @return array> */ - public static function rules(): array + public static function rules(array $input): array { - return [ + $rules = [ 'name' => [ 'required', 'string', @@ -117,16 +123,13 @@ public static function rules(): array 'min:1', 'max:65535', ], - 'source' => [ - 'nullable', - 'ip', - ], - 'mask' => [ - 'nullable', - 'numeric', - 'min:1', - 'max:32', - ], ]; + + if (! ($input['source_any'] ?? false)) { + $rules['source'] = ['required', 'ip']; + $rules['mask'] = ['required', 'numeric', 'min:1', 'max:32']; + } + + return $rules; } } diff --git a/app/Http/Controllers/API/FirewallRuleController.php b/app/Http/Controllers/API/FirewallRuleController.php index 3d028f3a..3f52f8cf 100644 --- a/app/Http/Controllers/API/FirewallRuleController.php +++ b/app/Http/Controllers/API/FirewallRuleController.php @@ -54,8 +54,6 @@ public function create(Request $request, Project $project, Server $server): Fire $this->validateRoute($project, $server); - $this->validate($request, ManageRule::rules()); - $firewallRule = app(ManageRule::class)->create($server, $request->all()); return new FirewallRuleResource($firewallRule); @@ -76,8 +74,6 @@ public function edit(Request $request, Project $project, Server $server, Firewal $this->validateRoute($project, $server); - $this->validate($request, ManageRule::rules()); - $firewallRule = app(ManageRule::class)->update($firewallRule, $request->all()); return new FirewallRuleResource($firewallRule); diff --git a/app/Http/Controllers/FirewallController.php b/app/Http/Controllers/FirewallController.php new file mode 100644 index 00000000..552ce51e --- /dev/null +++ b/app/Http/Controllers/FirewallController.php @@ -0,0 +1,66 @@ +authorize('viewAny', [FirewallRule::class, $server]); + + return Inertia::render('firewall/index', [ + 'rules' => FirewallRuleResource::collection($server->firewallRules()->latest()->simplePaginate(config('web.pagination_size'))), + ]); + } + + #[Post('/', name: 'firewall.store')] + public function store(Request $request, Server $server): RedirectResponse + { + $this->authorize('create', [FirewallRule::class, $server]); + + app(ManageRule::class)->create($server, $request->all()); + + return back() + ->with('info', 'Firewall rule is being created.'); + } + + #[Put('/{firewallRule}', name: 'firewall.update')] + public function update(Request $request, Server $server, FirewallRule $firewallRule): RedirectResponse + { + $this->authorize('update', $firewallRule); + + app(ManageRule::class)->update($firewallRule, $request->all()); + + return back() + ->with('info', 'Firewall rule is being updated.'); + } + + #[Delete('/{firewallRule}', name: 'firewall.destroy')] + public function destroy(Server $server, FirewallRule $firewallRule): RedirectResponse + { + $this->authorize('delete', $firewallRule); + + app(ManageRule::class)->delete($firewallRule); + + return back() + ->with('info', 'Firewall rule is being deleted.'); + } +} diff --git a/app/Http/Controllers/ServerController.php b/app/Http/Controllers/ServerController.php index 703e11f2..52c87c5b 100644 --- a/app/Http/Controllers/ServerController.php +++ b/app/Http/Controllers/ServerController.php @@ -18,6 +18,7 @@ use Spatie\RouteAttributes\Attributes\Delete; use Spatie\RouteAttributes\Attributes\Get; use Spatie\RouteAttributes\Attributes\Middleware; +use Spatie\RouteAttributes\Attributes\Patch; use Spatie\RouteAttributes\Attributes\Post; use Spatie\RouteAttributes\Attributes\Prefix; @@ -104,6 +105,21 @@ public function switch(Server $server): RedirectResponse return redirect()->route('servers.show', ['server' => $server->id]); } + #[Patch('/{server}/status', name: 'servers.status')] + public function status(Server $server): RedirectResponse + { + $this->authorize('view', $server); + + $server->checkConnection(); + + $server->refresh(); + + return back() + ->with($server->getStatusColor(), __('Server status is :status', [ + 'status' => $server->status, + ])); + } + #[Delete('/{server}', name: 'servers.destroy')] public function destroy(Server $server, Request $request): RedirectResponse { diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 1305bf49..0c345185 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -93,8 +93,10 @@ public function share(Request $request): array 'flash' => [ 'success' => fn () => $request->session()->get('success'), 'error' => fn () => $request->session()->get('error'), + 'danger' => fn () => $request->session()->get('danger'), 'warning' => fn () => $request->session()->get('warning'), 'info' => fn () => $request->session()->get('info'), + 'gray' => fn () => $request->session()->get('gray'), 'data' => fn () => $request->session()->get('data'), ], ]; diff --git a/app/Http/Resources/FirewallRuleResource.php b/app/Http/Resources/FirewallRuleResource.php index ef3b88aa..dc3cf06a 100644 --- a/app/Http/Resources/FirewallRuleResource.php +++ b/app/Http/Resources/FirewallRuleResource.php @@ -25,6 +25,7 @@ public function toArray(Request $request): array 'mask' => $this->mask, 'note' => $this->note, 'status' => $this->status, + 'status_color' => FirewallRule::$statusColors[$this->status] ?? 'gray', 'created_at' => $this->created_at, 'updated_at' => $this->updated_at, ]; diff --git a/app/Models/FirewallRule.php b/app/Models/FirewallRule.php index c7e80e17..7a8800af 100755 --- a/app/Models/FirewallRule.php +++ b/app/Models/FirewallRule.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Enums\FirewallRuleStatus; +use Database\Factories\FirewallRuleFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -20,7 +21,7 @@ */ class FirewallRule extends AbstractModel { - /** @use HasFactory<\Database\Factories\FirewallRuleFactory> */ + /** @use HasFactory */ use HasFactory; protected $fillable = [ @@ -40,17 +41,16 @@ 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', - default => 'secondary', - }; - } + /** + * @var array + */ + public static array $statusColors = [ + FirewallRuleStatus::CREATING => 'info', + FirewallRuleStatus::UPDATING => 'warning', + FirewallRuleStatus::DELETING => 'danger', + FirewallRuleStatus::READY => 'success', + FirewallRuleStatus::FAILED => 'danger', + ]; /** * @return BelongsTo diff --git a/resources/js/app.tsx b/resources/js/app.tsx index 07ca7c13..2e05383d 100644 --- a/resources/js/app.tsx +++ b/resources/js/app.tsx @@ -16,7 +16,7 @@ createInertiaApp({ root.render(); }, progress: { - color: '#4B5563', + color: '#5a5bc5', }, }); diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index 1603472b..13029317 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -13,7 +13,7 @@ import { SidebarMenuSubItem, } from '@/components/ui/sidebar'; import { type NavItem } from '@/types'; -import { Link } from '@inertiajs/react'; +import { Link, router } from '@inertiajs/react'; import { BookOpen, ChevronRightIcon, CogIcon, Folder, MousePointerClickIcon, ServerIcon } from 'lucide-react'; import AppLogo from './app-logo'; import { Icon } from '@/components/icon'; @@ -129,7 +129,7 @@ export function AppSidebar({ secondNavItems, secondNavTitle }: { secondNavItems? - + {item.icon && } {item.title} @@ -163,11 +163,9 @@ export function AppSidebar({ secondNavItems, secondNavTitle }: { secondNavItems? return ( - - - {item.icon && } - {item.title} - + router.visit(item.href)} isActive={isActive} disabled={item.isDisabled || false}> + {item.icon && } + {item.title} ); diff --git a/resources/js/layouts/server/layout.tsx b/resources/js/layouts/server/layout.tsx index 22ac8f37..8deec31b 100644 --- a/resources/js/layouts/server/layout.tsx +++ b/resources/js/layouts/server/layout.tsx @@ -1,5 +1,5 @@ import { type NavItem } from '@/types'; -import { ArrowLeftIcon, CloudUploadIcon, DatabaseIcon, HomeIcon, MousePointerClickIcon, RocketIcon, UsersIcon } from 'lucide-react'; +import { ArrowLeftIcon, CloudUploadIcon, DatabaseIcon, FlameIcon, HomeIcon, MousePointerClickIcon, RocketIcon, UsersIcon } from 'lucide-react'; import { ReactNode } from 'react'; import { Server } from '@/types/server'; import ServerHeader from '@/pages/servers/components/header'; @@ -20,6 +20,8 @@ export default function ServerLayout({ children }: { children: ReactNode }) { return null; } + const isMenuDisabled = page.props.server.status !== 'ready'; + const sidebarNavItems: NavItem[] = [ { title: 'Overview', @@ -31,6 +33,7 @@ export default function ServerLayout({ children }: { children: ReactNode }) { title: 'Database', href: route('databases', { server: page.props.server.id }), icon: DatabaseIcon, + isDisabled: isMenuDisabled, children: [ { title: 'Databases', @@ -54,6 +57,7 @@ export default function ServerLayout({ children }: { children: ReactNode }) { title: 'Sites', href: route('sites', { server: page.props.server.id }), icon: MousePointerClickIcon, + isDisabled: isMenuDisabled, children: page.props.site ? [ { @@ -70,11 +74,12 @@ export default function ServerLayout({ children }: { children: ReactNode }) { ] : [], }, - // { - // title: 'Firewall', - // href: '#', - // icon: FlameIcon, - // }, + { + title: 'Firewall', + href: route('firewall', { server: page.props.server.id }), + icon: FlameIcon, + isDisabled: isMenuDisabled, + }, // { // title: 'CronJobs', // href: '#', diff --git a/resources/js/pages/firewall/components/columns.tsx b/resources/js/pages/firewall/components/columns.tsx new file mode 100644 index 00000000..452a0385 --- /dev/null +++ b/resources/js/pages/firewall/components/columns.tsx @@ -0,0 +1,138 @@ +import { ColumnDef } from '@tanstack/react-table'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; +import { Button } from '@/components/ui/button'; +import { useForm } from '@inertiajs/react'; +import { LoaderCircleIcon, MoreVerticalIcon } from 'lucide-react'; +import FormSuccessful from '@/components/form-successful'; +import { useState } from 'react'; +import { FirewallRule } from '@/types/firewall'; +import { Badge } from '@/components/ui/badge'; +import RuleForm from '@/pages/firewall/components/form'; + +function Delete({ firewallRule }: { firewallRule: FirewallRule }) { + const [open, setOpen] = useState(false); + const form = useForm(); + + const submit = () => { + form.delete(route('firewall.destroy', { server: firewallRule.server_id, firewallRule: firewallRule }), { + onSuccess: () => { + setOpen(false); + }, + }); + }; + return ( + + + e.preventDefault()}> + Delete + + + + + Delete firewallRule [{firewallRule.name}] + Delete firewallRule + +

+ Are you sure you want to delete rule {firewallRule.name}? This action cannot be undone. +

+ + + + + + +
+
+ ); +} + +export const columns: ColumnDef[] = [ + { + accessorKey: 'name', + header: 'Name', + enableColumnFilter: true, + enableSorting: true, + }, + { + accessorKey: 'type', + header: 'Type', + enableColumnFilter: true, + enableSorting: true, + cell: ({ row }) => { + return {row.original.type}; + }, + }, + { + accessorKey: 'source', + header: 'Source', + enableColumnFilter: true, + enableSorting: true, + cell: ({ row }) => { + return {row.original.source ?? 'any'}; + }, + }, + { + accessorKey: 'protocol', + header: 'Protocol', + enableColumnFilter: true, + enableSorting: true, + cell: ({ row }) => { + return {row.original.protocol}; + }, + }, + { + accessorKey: 'port', + header: 'Port', + enableColumnFilter: true, + enableSorting: true, + }, + { + accessorKey: 'status', + header: 'Status', + enableColumnFilter: true, + enableSorting: true, + cell: ({ row }) => { + return {row.original.status}; + }, + }, + { + id: 'actions', + enableColumnFilter: false, + enableSorting: false, + cell: ({ row }) => { + return ( +
+ + + + + + + e.preventDefault()}>Edit + + + + + +
+ ); + }, + }, +]; diff --git a/resources/js/pages/firewall/components/form.tsx b/resources/js/pages/firewall/components/form.tsx new file mode 100644 index 00000000..18709c8f --- /dev/null +++ b/resources/js/pages/firewall/components/form.tsx @@ -0,0 +1,152 @@ +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { FormEvent, ReactNode, useState } from 'react'; +import { Form, FormField, FormFields } from '@/components/ui/form'; +import { Button } from '@/components/ui/button'; +import { useForm } from '@inertiajs/react'; +import { LoaderCircleIcon } from 'lucide-react'; +import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; +import InputError from '@/components/ui/input-error'; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Checkbox } from '@/components/ui/checkbox'; +import { FirewallRule } from '@/types/firewall'; + +export default function RuleForm({ serverId, firewallRule, children }: { serverId: number; firewallRule?: FirewallRule; children: ReactNode }) { + const [open, setOpen] = useState(false); + const form = useForm<{ + name: string; + type: string; + protocol: string; + port: string; + source_any: boolean; + source: string; + mask: string; + }>({ + name: firewallRule?.name || '', + type: firewallRule?.type || '', + protocol: firewallRule?.protocol || '', + port: firewallRule?.port?.toString() || '', + source_any: !firewallRule?.source, + source: firewallRule?.source || '', + mask: firewallRule?.mask?.toString() || '', + }); + + const submit = (e: FormEvent) => { + e.preventDefault(); + if (firewallRule) { + form.put(route('firewall.update', { server: serverId, firewallRule: firewallRule.id }), { + onSuccess: () => { + setOpen(false); + form.reset(); + }, + }); + return; + } + + form.post(route('firewall.store', { server: serverId }), { + onSuccess: () => { + setOpen(false); + form.reset(); + }, + }); + }; + return ( + + {children} + + + {firewallRule ? 'Edit' : 'Create'} firewall rule + {firewallRule ? 'Edit' : 'Create'} new firewall rule + +
+ + + + form.setData('name', e.target.value)} /> + + + + + + + + + + + + + + + + + + form.setData('port', e.target.value)} /> + + + + +
+ form.setData('source_any', !form.data.source_any)} /> + +
+
+ + {!form.data.source_any && ( + <> + + + form.setData('source', e.target.value)} /> + + + + + + form.setData('mask', e.target.value)} /> + + + + )} +
+
+ + + + + + +
+
+ ); +} diff --git a/resources/js/pages/firewall/index.tsx b/resources/js/pages/firewall/index.tsx new file mode 100644 index 00000000..3d282f9c --- /dev/null +++ b/resources/js/pages/firewall/index.tsx @@ -0,0 +1,42 @@ +import { Head, usePage } from '@inertiajs/react'; +import { Server } from '@/types/server'; +import { PaginatedData } from '@/types'; +import { FirewallRule } from '@/types/firewall'; +import ServerLayout from '@/layouts/server/layout'; +import HeaderContainer from '@/components/header-container'; +import Heading from '@/components/heading'; +import { Button } from '@/components/ui/button'; +import { PlusIcon } from 'lucide-react'; +import Container from '@/components/container'; +import { DataTable } from '@/components/data-table'; +import { columns } from '@/pages/firewall/components/columns'; +import RuleForm from '@/pages/firewall/components/form'; + +export default function Firewall() { + const page = usePage<{ + server: Server; + rules: PaginatedData; + }>(); + + return ( + + + + + + +
+ + + +
+
+ + +
+
+ ); +} diff --git a/resources/js/pages/servers/components/header.tsx b/resources/js/pages/servers/components/header.tsx index 2aeb5fbf..47de66f6 100644 --- a/resources/js/pages/servers/components/header.tsx +++ b/resources/js/pages/servers/components/header.tsx @@ -6,16 +6,37 @@ import { cn } from '@/lib/utils'; import { Site } from '@/types/site'; import { StatusRipple } from '@/components/status-ripple'; import { Badge } from '@/components/ui/badge'; +import { useForm } from '@inertiajs/react'; export default function ServerHeader({ server, site }: { server: Server; site?: Site }) { + const statusForm = useForm(); + + const checkStatus = () => { + if (['installing', 'installation_failed'].includes(server.status)) { + return; + } + + statusForm.patch(route('servers.status', { server: server.id })); + }; + return (
+ + +
+ {statusForm.processing && } + {!statusForm.processing && } +
+
+ + {server.status} + +
-
{server.name}
diff --git a/resources/js/types/firewall.d.ts b/resources/js/types/firewall.d.ts new file mode 100644 index 00000000..6511f9d3 --- /dev/null +++ b/resources/js/types/firewall.d.ts @@ -0,0 +1,15 @@ +export interface FirewallRule { + id: number; + name: string; + server_id: number; + type: string; + protocol: string; + port: number; + source: string; + mask: number; + note: string; + status: string; + status_color: 'gray' | 'success' | 'info' | 'warning' | 'danger'; + created_at: string; + updated_at: string; +} diff --git a/resources/js/types/index.d.ts b/resources/js/types/index.d.ts index aef401b6..2826a4e1 100644 --- a/resources/js/types/index.d.ts +++ b/resources/js/types/index.d.ts @@ -28,6 +28,7 @@ export interface NavItem { onlyActivePath?: string; icon?: LucideIcon | null; isActive?: boolean; + isDisabled?: boolean; children?: NavItem[]; } diff --git a/tests/Feature/FirewallTest.php b/tests/Feature/FirewallTest.php index 829f8f6f..d54737e6 100644 --- a/tests/Feature/FirewallTest.php +++ b/tests/Feature/FirewallTest.php @@ -5,10 +5,8 @@ use App\Enums\FirewallRuleStatus; use App\Facades\SSH; use App\Models\FirewallRule; -use App\Web\Pages\Servers\Firewall\Index; -use App\Web\Pages\Servers\Firewall\Widgets\RulesList; use Illuminate\Foundation\Testing\RefreshDatabase; -use Livewire\Livewire; +use Inertia\Testing\AssertableInertia; use Tests\TestCase; class FirewallTest extends TestCase @@ -21,18 +19,15 @@ public function test_create_firewall_rule(): void $this->actingAs($this->user); - Livewire::test(Index::class, [ - 'server' => $this->server, + $this->post(route('firewall.store', ['server' => $this->server]), [ + 'name' => 'Test', + 'type' => 'allow', + 'protocol' => 'tcp', + 'port' => '1234', + 'source' => '0.0.0.0', + 'mask' => '1', ]) - ->callAction('create', [ - 'name' => 'Test', - 'type' => 'allow', - 'protocol' => 'tcp', - 'port' => '1234', - 'source' => '0.0.0.0', - 'mask' => '0', - ]) - ->assertSuccessful(); + ->assertSessionDoesntHaveErrors(); $this->assertDatabaseHas('firewall_rules', [ 'port' => '1234', @@ -44,14 +39,13 @@ public function test_see_firewall_rules(): void { $this->actingAs($this->user); - $rule = FirewallRule::factory()->create([ + FirewallRule::factory()->create([ 'server_id' => $this->server->id, ]); - $this->get(Index::getUrl(['server' => $this->server])) + $this->get(route('firewall', $this->server)) ->assertSuccessful() - ->assertSee($rule->source) - ->assertSee($rule->port); + ->assertInertia(fn (AssertableInertia $page) => $page->component('firewall/index')); } public function test_delete_firewall_rule(): void @@ -64,11 +58,10 @@ public function test_delete_firewall_rule(): void 'server_id' => $this->server->id, ]); - Livewire::test(RulesList::class, [ + $this->delete(route('firewall.destroy', [ 'server' => $this->server, - ]) - ->callTableAction('delete', $rule->id) - ->assertSuccessful(); + 'firewallRule' => $rule, + ]))->assertSessionDoesntHaveErrors(); $this->assertDatabaseMissing('firewall_rules', [ 'id' => $rule->id,