firewall: move add rule to modal for ux consistency

This commit is contained in:
Crivion
2025-10-22 10:01:54 +03:00
parent 9eeec71c1f
commit 82dc052ef5
3 changed files with 198 additions and 30 deletions

View File

@@ -38,14 +38,43 @@ class FirewallController extends Controller
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'rule' => 'required|string|min:2',
'type' => 'required|string|in:allow,deny',
'protocol' => 'required|string|in:tcp,udp',
'port' => 'required|integer|min:1|max:65535',
'ip' => 'required|string', // validated below for any|ip|cidr
'to' => 'required|string',
'comment' => 'nullable|string|max:150',
]);
$from = trim($validated['ip']);
$to = trim($validated['to']);
$isAny = fn(string $v) => strtolower($v) === 'any';
$isIp = fn(string $v) => filter_var($v, FILTER_VALIDATE_IP) !== false;
$isCidr = fn(string $v) => (bool) preg_match('/^((25[0-5]|2[0-4]\\d|1?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|1?\\d?\\d)){3})\\/(3[0-2]|[12]?\\d)$/', $v);
if (!($isAny($from) || $isIp($from) || $isCidr($from))) {
return back()->withErrors(['ip' => 'IP must be "any", a valid IP address, or CIDR range.'])->withInput();
}
if (!($isAny($to) || $isIp($to))) {
return back()->withErrors(['to' => 'To must be "any" or a valid IP address.'])->withInput();
}
$proto = strtolower($validated['protocol']);
$port = (int) $validated['port'];
$comment = $validated['comment'] ?? '';
$comment = trim($comment);
$commentEscaped = str_replace("'", "\\'", $comment);
$spec = "proto {$proto} from {$from} to {$to} port {$port}";
if ($commentEscaped !== '') {
$spec .= " comment '" . $commentEscaped . "'";
}
if ($validated['type'] === 'allow') {
(new AddUfwRuleAction())->execute($validated['rule']);
(new AddUfwRuleAction())->execute($spec);
} else {
(new \App\Actions\Firewall\AddUfwDenyRuleAction())->execute($validated['rule']);
(new \App\Actions\Firewall\AddUfwDenyRuleAction())->execute($spec);
}
session()->flash('success', 'Rule ' . $validated['type'] . 'ed successfully.');

View File

@@ -4,6 +4,7 @@ import { router } from '@inertiajs/react';
import { useState } from 'react';
import { toast } from 'react-toastify';
import ConfirmationButton from '@/Components/ConfirmationButton';
import CreateFirewallRuleForm from './Partials/CreateFirewallRuleForm';
import { MdSecurity } from 'react-icons/md';
import { FaToggleOn, FaToggleOff } from 'react-icons/fa';
import { TiDelete } from 'react-icons/ti';
@@ -23,15 +24,7 @@ export default function FirewallIndex({ status, rules }) {
});
};
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')
});
};
// rule creation moved to modal form component
const deleteRule = (idOrSpec) => {
router.delete(route('firewall.destroy', { id: idOrSpec }), {
@@ -65,24 +58,9 @@ export default function FirewallIndex({ status, rules }) {
<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="bg-white dark:bg-gray-850 p-4 rounded-md flex items-center justify-between">
<CreateFirewallRuleForm />
</div>
<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">

View File

@@ -0,0 +1,161 @@
import InputError from '@/Components/InputError';
import InputLabel from '@/Components/InputLabel';
import Modal from '@/Components/Modal';
import PrimaryButton from '@/Components/PrimaryButton';
import SecondaryButton from '@/Components/SecondaryButton';
import TextInput from '@/Components/TextInput';
import { useForm } from '@inertiajs/react';
import { useState } from 'react';
import { MdSecurity } from 'react-icons/md';
export default function CreateFirewallRuleForm() {
const [showModal, setShowModal] = useState(false);
const {
data,
setData,
post,
processing,
reset,
errors,
clearErrors,
} = useForm({
type: 'allow',
protocol: 'tcp',
port: '',
ip: 'any',
to: 'any',
comment: '',
});
const openModal = () => setShowModal(true);
const closeModal = () => {
setShowModal(false);
clearErrors();
reset({ type: 'allow', protocol: 'tcp', port: '', ip: 'any', to: 'any', comment: '' });
};
const submit = (e) => {
e.preventDefault();
post(route('firewall.store'), {
preserveScroll: true,
onSuccess: () => closeModal(),
});
};
return (
<>
<button onClick={openModal} className='flex items-center text-gray-700 dark:text-gray-300'>
<MdSecurity className='mr-2' />
Add Firewall Rule
</button>
<Modal show={showModal} onClose={closeModal}>
<form onSubmit={submit} className="p-6">
<h2 className="text-lg font-medium text-gray-900 dark:text-gray-100 flex items-center">
<MdSecurity className='mr-2' />
Add Firewall Rule
</h2>
<div className="mt-6 flex flex-col space-y-4 max-h-[500px]">
<div>
<InputLabel htmlFor="type" value="Action" className='my-2' />
<select
id="type"
name="type"
value={data.type}
onChange={(e) => setData('type', e.target.value)}
className="mt-1 block w-full flex-1 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:text-white"
>
<option value="allow">Allow</option>
<option value="deny">Deny</option>
</select>
<InputError message={errors.type} className="mt-2" />
</div>
<div>
<InputLabel htmlFor="protocol" value="Protocol" className='my-2' />
<select
id="protocol"
name="protocol"
value={data.protocol}
onChange={(e) => setData('protocol', e.target.value)}
className="mt-1 block w-full flex-1 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:text-white"
>
<option value="tcp">TCP</option>
<option value="udp">UDP</option>
</select>
<InputError message={errors.protocol} className="mt-2" />
</div>
<div>
<InputLabel htmlFor="port" value="Port" className='my-2' />
<TextInput
id="port"
name="port"
type="number"
min="1"
max="65535"
value={data.port}
onChange={(e) => setData('port', e.target.value)}
className="mt-1 block w-full"
placeholder="22"
required
/>
<InputError message={errors.port} className="mt-2" />
</div>
<div>
<InputLabel htmlFor="ip" value="IP" className='my-2' />
<TextInput
id="ip"
name="ip"
value={data.ip}
onChange={(e) => setData('ip', e.target.value)}
className="mt-1 block w-full"
placeholder="any or 1.2.3.4 or 1.2.3.0/24"
/>
<InputError message={errors.ip} className="mt-2" />
</div>
<div>
<InputLabel htmlFor="to" value="To" className='my-2' />
<TextInput
id="to"
name="to"
value={data.to}
onChange={(e) => setData('to', e.target.value)}
className="mt-1 block w-full"
placeholder="any"
/>
<InputError message={errors.to} className="mt-2" />
</div>
<div>
<InputLabel htmlFor="comment" value="Comment" className='my-2' />
<TextInput
id="comment"
name="comment"
value={data.comment}
onChange={(e) => setData('comment', e.target.value)}
className="mt-1 block w-full"
placeholder="Optional"
/>
<InputError message={errors.comment} className="mt-2" />
</div>
<div className="flex justify-end">
<PrimaryButton className="mr-3" disabled={processing}>
Add Rule
</PrimaryButton>
<SecondaryButton onClick={closeModal}>
Cancel
</SecondaryButton>
</div>
</div>
</form>
</Modal>
</>
);
}