firewall: add/manage rules

This commit is contained in:
Crivion
2025-10-22 09:36:38 +03:00
parent 408e07a0b1
commit d1fbefc2a3
11 changed files with 405 additions and 1 deletions

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Actions\Firewall;
use Illuminate\Support\Facades\Process;
use RuntimeException;
class AddUfwDenyRuleAction
{
public function execute(string $ruleSpec): void
{
$ruleSpec = trim($ruleSpec);
if ($ruleSpec === '') {
throw new RuntimeException('Empty rule spec');
}
$bin = config('laranode.laranode_bin_path') . '/laranode-ufw.sh';
$proc = Process::run(['sudo', $bin, 'deny', $ruleSpec]);
if ($proc->failed()) {
throw new RuntimeException('UFW deny failed: ' . $proc->errorOutput());
}
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Actions\Firewall;
use Illuminate\Support\Facades\Process;
use RuntimeException;
class AddUfwRuleAction
{
public function execute(string $ruleSpec): void
{
$ruleSpec = trim($ruleSpec);
if ($ruleSpec === '') {
throw new RuntimeException('Empty rule spec');
}
$bin = config('laranode.laranode_bin_path') . '/laranode-ufw.sh';
$proc = Process::run(['sudo', $bin, 'allow', $ruleSpec]);
if ($proc->failed()) {
throw new RuntimeException('UFW allow failed: ' . $proc->errorOutput());
}
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Actions\Firewall;
use Illuminate\Support\Facades\Process;
use RuntimeException;
class DeleteUfwRuleAction
{
public function execute(string $idOrSpec): void
{
$idOrSpec = trim($idOrSpec);
if ($idOrSpec === '') {
throw new RuntimeException('Empty rule id/spec');
}
$bin = config('laranode.laranode_bin_path') . '/laranode-ufw.sh';
$proc = Process::run(['sudo', $bin, 'delete', $idOrSpec]);
if ($proc->failed()) {
throw new RuntimeException('UFW delete failed: ' . $proc->errorOutput());
}
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Actions\Firewall;
use Illuminate\Support\Facades\Process;
class GetUfwRulesAction
{
public function execute(): array
{
$bin = config('laranode.laranode_bin_path') . '/laranode-ufw.sh';
$proc = Process::run(['sudo', $bin, 'list']);
if ($proc->failed()) {
return [];
}
$lines = preg_split("/\r?\n/", trim($proc->output()));
$rules = [];
foreach ($lines as $line) {
if (!preg_match('/^\s*\[(\s*\d+)\]\s+(.+?)\s+(ALLOW|DENY)\s+(IN|OUT)\s+(.+)$/i', $line, $m)) {
continue;
}
$number = (int) trim($m[1]);
$service = trim($m[2]);
$action = strtoupper(trim($m[3]));
$direction = strtoupper(trim($m[4]));
$from = trim($m[5]);
$rules[] = [
'number' => $number,
'service' => $service,
'action' => $action,
'direction' => $direction,
'from' => $from,
];
}
return $rules;
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Actions\Firewall;
use Illuminate\Support\Facades\Process;
class GetUfwStatusAction
{
public function execute(): string
{
$bin = config('laranode.laranode_bin_path') . '/laranode-ufw.sh';
$proc = Process::run(['sudo', $bin, 'status']);
if ($proc->failed()) {
return 'unknown';
}
return trim($proc->output());
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Actions\Firewall;
use Illuminate\Support\Facades\Process;
use RuntimeException;
class ToggleUfwAction
{
public function execute(bool $enable): string
{
$bin = config('laranode.laranode_bin_path') . '/laranode-ufw.sh';
$cmd = $enable ? 'enable' : 'disable';
$proc = Process::run(['sudo', $bin, $cmd]);
if ($proc->failed()) {
throw new RuntimeException('UFW toggle failed: ' . $proc->errorOutput());
}
return trim($proc->output());
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Http\Controllers;
use App\Actions\Firewall\AddUfwRuleAction;
use App\Actions\Firewall\DeleteUfwRuleAction;
use App\Actions\Firewall\GetUfwRulesAction;
use App\Actions\Firewall\GetUfwStatusAction;
use App\Actions\Firewall\ToggleUfwAction;
use App\Http\Middleware\AdminMiddleware;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
class FirewallController extends Controller
{
public function __construct()
{
$this->middleware(['auth', AdminMiddleware::class]);
}
public function index(): \Inertia\Response
{
$status = (new GetUfwStatusAction())->execute();
$rules = (new GetUfwRulesAction())->execute();
return Inertia::render('Firewall/Index', compact('status', 'rules'));
}
public function toggle(Request $request): RedirectResponse
{
$validated = $request->validate([
'enabled' => 'required|boolean',
]);
$enable = (bool) $validated['enabled'];
(new ToggleUfwAction())->execute($enable);
session()->flash('success', 'Firewall ' . ($enable ? 'enabled' : 'disabled') . ' successfully.');
return redirect()->route('firewall.index');
}
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'rule' => 'required|string|min:2',
'type' => 'required|string|in:allow,deny',
]);
if ($validated['type'] === 'allow') {
(new AddUfwRuleAction())->execute($validated['rule']);
} else {
(new \App\Actions\Firewall\AddUfwDenyRuleAction())->execute($validated['rule']);
}
session()->flash('success', 'Rule ' . $validated['type'] . 'ed successfully.');
return redirect()->route('firewall.index');
}
public function destroy(string $id): RedirectResponse
{
(new DeleteUfwRuleAction())->execute($id);
session()->flash('success', 'Rule deleted successfully.');
return redirect()->route('firewall.index');
}
}

View File

@@ -0,0 +1,64 @@
#!/usr/bin/env bash
set -euo pipefail
cmd=${1:-}
arg=${2:-}
run() {
if command -v ufw >/dev/null 2>&1; then
ufw "$@"
else
echo "ufw command not found" >&2
exit 127
fi
}
case "$cmd" in
status)
run status | head -n1
;;
enable)
yes | run enable >/dev/null
echo "enabled"
;;
disable)
yes | run disable >/dev/null
echo "disabled"
;;
list)
run status numbered
;;
allow)
if [ -z "${arg:-}" ]; then
echo "rule spec required" >&2
exit 2
fi
run allow $arg >/dev/null
echo "allowed: $arg"
;;
deny)
if [ -z "${arg:-}" ]; then
echo "rule spec required" >&2
exit 2
fi
run deny $arg >/dev/null
echo "denied: $arg"
;;
delete)
if [ -z "${arg:-}" ]; then
echo "rule id/spec required" >&2
exit 2
fi
if [[ "$arg" =~ ^[0-9]+$ ]]; then
yes | run delete "$arg" >/dev/null
else
yes | run delete $arg >/dev/null
fi
echo "deleted: $arg"
;;
*)
echo "unknown command" >&2
exit 2
;;
}

View File

@@ -72,7 +72,7 @@ const SidebarNavi = () => {
{auth.user.role == 'admin' && (
<li>
<Link
href="/admin/firewall"
href={route('firewall.index')}
className="relative flex flex-row items-center h-11 focus:outline-none hover:bg-gray-900 text-gray-300 border-l-4 border-transparent hover:border-indigo-900 pr-6"
>
<div>

View File

@@ -0,0 +1,122 @@
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, usePage } from '@inertiajs/react';
import { router } from '@inertiajs/react';
import { useState } from 'react';
import { toast } from 'react-toastify';
import ConfirmationButton from '@/Components/ConfirmationButton';
import { MdSecurity } from 'react-icons/md';
import { FaToggleOn, FaToggleOff } from 'react-icons/fa';
import { TiDelete } from 'react-icons/ti';
export default function FirewallIndex({ status, rules }) {
const { auth } = usePage().props;
const [newRule, setNewRule] = useState('');
const [ruleType, setRuleType] = useState('allow');
const isEnabled = (status || '').toLowerCase().includes('active') || (status || '').toLowerCase().includes('enabled');
const toggleFirewall = () => {
router.post(route('firewall.toggle'), { enabled: !isEnabled }, {
onBefore: () => toast(`${!isEnabled ? 'Enabling' : 'Disabling'} firewall...`),
onSuccess: () => router.reload({ only: ['status'] }),
onError: () => toast('Failed to toggle firewall')
});
};
const addRule = (e) => {
e.preventDefault();
if (!newRule.trim()) return;
router.post(route('firewall.store'), { rule: newRule.trim(), type: ruleType }, {
onBefore: () => toast('Adding rule...'),
onSuccess: () => { setNewRule(''); setRuleType('allow'); router.reload({ only: ['rules'] }); },
onError: () => toast('Failed to add rule')
});
};
const deleteRule = (idOrSpec) => {
router.delete(route('firewall.destroy', { id: idOrSpec }), {
onBefore: () => toast('Deleting rule...'),
onSuccess: () => router.reload({ only: ['rules'] }),
onError: () => toast('Failed to delete rule')
});
};
return (
<AuthenticatedLayout
header={
<div className="flex flex-col xl:flex-row xl:justify-between max-w-7xl pr-5">
<h2 className="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight flex items-center">
<MdSecurity className='mr-2' />
Firewall
</h2>
<div className="flex items-center space-x-3">
<div className={`inline-flex items-center px-3 py-1 rounded-md text-sm font-medium ${isEnabled ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' : 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200'}`}>
{isEnabled ? 'Active' : 'Disabled'}
</div>
<ConfirmationButton doAction={toggleFirewall}>
<button className={`p-2 rounded-lg transition-colors ${isEnabled ? 'bg-green-100 hover:bg-green-200 text-green-600 dark:bg-green-900 dark:hover:bg-green-800 dark:text-green-300' : 'bg-gray-100 hover:bg-gray-200 text-gray-600 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-gray-400'}`}>
{isEnabled ? <FaToggleOn className='w-5 h-5' /> : <FaToggleOff className='w-5 h-5' />}
</button>
</ConfirmationButton>
</div>
</div>
}
>
<Head title="Firewall" />
<div className="max-w-7xl px-4 my-8">
<form onSubmit={addRule} className="bg-white dark:bg-gray-850 p-4 rounded-md flex items-center space-x-3">
<input
type="text"
className="w-full bg-gray-100 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2 dark:bg-gray-800 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white"
placeholder="e.g. 22/tcp or proto tcp from 1.2.3.4 to any port 22"
value={newRule}
onChange={(e) => setNewRule(e.target.value)}
/>
<select
className="bg-gray-100 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2 dark:bg-gray-800 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white"
value={ruleType}
onChange={(e) => setRuleType(e.target.value)}
>
<option value="allow">Allow</option>
<option value="deny">Deny</option>
</select>
<button type="submit" className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm">Add Rule</button>
</form>
<div className="relative overflow-x-auto bg-white dark:bg-gray-850 mt-3">
<table className="w-full text-left rtl:text-right text-gray-500 dark:text-gray-400">
<thead className="text-gray-700 uppercase bg-gray-200 dark:bg-gray-700 dark:text-gray-300 text-sm">
<tr>
<th className="px-6 py-3">#</th>
<th className="px-6 py-3">Service/Port</th>
<th className="px-6 py-3">Action</th>
<th className="px-6 py-3">Direction</th>
<th className="px-6 py-3">From</th>
<th className="px-6 py-3">Actions</th>
</tr>
</thead>
<tbody className="text-sm">
{rules?.map((r, idx) => (
<tr key={`rule-${idx}`} className="bg-white border-b text-gray-700 dark:text-gray-200 dark:bg-gray-850 dark:border-gray-700 border-gray-200">
<td className="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">{r.number}</td>
<td className="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">{r.service}</td>
<td className="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">{r.action}</td>
<td className="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">{r.direction}</td>
<td className="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">{r.from}</td>
<td className="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
<div className='flex items-center space-x-2'>
<ConfirmationButton doAction={() => deleteRule(r.number)}>
<TiDelete className='w-6 h-6 text-red-500' />
</ConfirmationButton>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -3,6 +3,7 @@
use App\Http\Controllers\AccountsController;
use App\Http\Controllers\DashboardController;
use App\Http\Controllers\FilemanagerController;
use App\Http\Controllers\FirewallController;
use App\Http\Controllers\PHPManagerController;
use App\Http\Controllers\ProfileController;
use App\Http\Controllers\StatsHistoryController;
@@ -43,6 +44,14 @@ Route::post('/mysql', [MysqlController::class, 'store'])->middleware(['auth'])->
Route::patch('/mysql', [MysqlController::class, 'update'])->middleware(['auth'])->name('mysql.update');
Route::delete('/mysql', [MysqlController::class, 'destroy'])->middleware(['auth'])->name('mysql.destroy');
// Firewall [Admin]
Route::middleware(['auth', AdminMiddleware::class])->group(function () {
Route::get('/admin/firewall', [FirewallController::class, 'index'])->name('firewall.index');
Route::post('/admin/firewall/toggle', [FirewallController::class, 'toggle'])->name('firewall.toggle');
Route::post('/admin/firewall/rules', [FirewallController::class, 'store'])->name('firewall.store');
Route::delete('/admin/firewall/rules/{id}', [FirewallController::class, 'destroy'])->name('firewall.destroy');
});
// Filemanager [Admin | User]
Route::get('/filemanager', [FilemanagerController::class, 'index'])->middleware(['auth'])->name('filemanager');
Route::get('/filemanager/get-directory-contents', [FilemanagerController::class, 'getDirectoryContents'])->middleware(['auth'])->name('filemanager.getDirectorContents');