220 lines
8.9 KiB
TypeScript
220 lines
8.9 KiB
TypeScript
|
|
import { useEffect, useState } from 'react'
|
||
|
|
import { apiRequest, applyFirewallRules } from '../api/client'
|
||
|
|
import { Plus, Trash2, Zap } from 'lucide-react'
|
||
|
|
|
||
|
|
interface FirewallRule {
|
||
|
|
id: number
|
||
|
|
port: string
|
||
|
|
protocol: string
|
||
|
|
action: string
|
||
|
|
ps: string
|
||
|
|
}
|
||
|
|
|
||
|
|
export function FirewallPage() {
|
||
|
|
const [rules, setRules] = useState<FirewallRule[]>([])
|
||
|
|
const [loading, setLoading] = useState(true)
|
||
|
|
const [error, setError] = useState('')
|
||
|
|
const [showCreate, setShowCreate] = useState(false)
|
||
|
|
const [creating, setCreating] = useState(false)
|
||
|
|
const [creatingError, setCreatingError] = useState('')
|
||
|
|
const [applying, setApplying] = useState(false)
|
||
|
|
|
||
|
|
const loadRules = () => {
|
||
|
|
setLoading(true)
|
||
|
|
apiRequest<FirewallRule[]>('/firewall/list')
|
||
|
|
.then(setRules)
|
||
|
|
.catch((err) => setError(err.message))
|
||
|
|
.finally(() => setLoading(false))
|
||
|
|
}
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
loadRules()
|
||
|
|
}, [])
|
||
|
|
|
||
|
|
const handleCreate = (e: React.FormEvent<HTMLFormElement>) => {
|
||
|
|
e.preventDefault()
|
||
|
|
const form = e.currentTarget
|
||
|
|
const port = (form.elements.namedItem('port') as HTMLInputElement).value.trim()
|
||
|
|
const protocol = (form.elements.namedItem('protocol') as HTMLSelectElement).value
|
||
|
|
const action = (form.elements.namedItem('action') as HTMLSelectElement).value
|
||
|
|
const ps = (form.elements.namedItem('ps') as HTMLInputElement).value.trim()
|
||
|
|
|
||
|
|
if (!port) {
|
||
|
|
setCreatingError('Port is required')
|
||
|
|
return
|
||
|
|
}
|
||
|
|
setCreating(true)
|
||
|
|
setCreatingError('')
|
||
|
|
apiRequest<{ status: boolean; msg: string }>('/firewall/create', {
|
||
|
|
method: 'POST',
|
||
|
|
body: JSON.stringify({ port, protocol, action, ps }),
|
||
|
|
})
|
||
|
|
.then(() => {
|
||
|
|
setShowCreate(false)
|
||
|
|
form.reset()
|
||
|
|
loadRules()
|
||
|
|
})
|
||
|
|
.catch((err) => setCreatingError(err.message))
|
||
|
|
.finally(() => setCreating(false))
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleDelete = (id: number, port: string) => {
|
||
|
|
if (!confirm(`Delete rule for port ${port}?`)) return
|
||
|
|
apiRequest<{ status: boolean }>(`/firewall/${id}`, { method: 'DELETE' })
|
||
|
|
.then(loadRules)
|
||
|
|
.catch((err) => setError(err.message))
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleApply = () => {
|
||
|
|
setApplying(true)
|
||
|
|
applyFirewallRules()
|
||
|
|
.then(() => loadRules())
|
||
|
|
.catch((err) => setError(err.message))
|
||
|
|
.finally(() => setApplying(false))
|
||
|
|
}
|
||
|
|
|
||
|
|
if (loading) return <div className="text-gray-500">Loading...</div>
|
||
|
|
if (error) return <div className="p-4 rounded bg-red-100 text-red-700">{error}</div>
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div>
|
||
|
|
<div className="flex justify-between items-center mb-4">
|
||
|
|
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">Security / Firewall</h1>
|
||
|
|
<div className="flex gap-2">
|
||
|
|
<button
|
||
|
|
onClick={handleApply}
|
||
|
|
disabled={applying || rules.length === 0}
|
||
|
|
className="flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white rounded-lg font-medium"
|
||
|
|
>
|
||
|
|
<Zap className="w-4 h-4" />
|
||
|
|
{applying ? 'Applying...' : 'Apply to UFW'}
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
onClick={() => setShowCreate(true)}
|
||
|
|
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium"
|
||
|
|
>
|
||
|
|
<Plus className="w-4 h-4" />
|
||
|
|
Add Rule
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="mb-4 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 text-amber-800 dark:text-amber-200 text-sm">
|
||
|
|
Rules are stored in the panel. Click "Apply to UFW" to run <code className="font-mono">ufw allow/deny</code> for each rule.
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{showCreate && (
|
||
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md">
|
||
|
|
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">Add Firewall Rule</h2>
|
||
|
|
<form onSubmit={handleCreate} className="space-y-4">
|
||
|
|
{creatingError && (
|
||
|
|
<div className="p-2 rounded bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 text-sm">
|
||
|
|
{creatingError}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
<div>
|
||
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Port</label>
|
||
|
|
<input
|
||
|
|
name="port"
|
||
|
|
type="text"
|
||
|
|
placeholder="80 or 80-90 or 80,443"
|
||
|
|
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
|
||
|
|
required
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Protocol</label>
|
||
|
|
<select
|
||
|
|
name="protocol"
|
||
|
|
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
|
||
|
|
>
|
||
|
|
<option value="tcp">TCP</option>
|
||
|
|
<option value="udp">UDP</option>
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Action</label>
|
||
|
|
<select
|
||
|
|
name="action"
|
||
|
|
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
|
||
|
|
>
|
||
|
|
<option value="accept">Accept</option>
|
||
|
|
<option value="drop">Drop</option>
|
||
|
|
<option value="reject">Reject</option>
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Note (optional)</label>
|
||
|
|
<input
|
||
|
|
name="ps"
|
||
|
|
type="text"
|
||
|
|
placeholder="HTTP"
|
||
|
|
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div className="flex gap-2 justify-end pt-2">
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={() => setShowCreate(false)}
|
||
|
|
className="px-4 py-2 text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
|
||
|
|
>
|
||
|
|
Cancel
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
type="submit"
|
||
|
|
disabled={creating}
|
||
|
|
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white rounded-lg font-medium"
|
||
|
|
>
|
||
|
|
{creating ? 'Adding...' : 'Add'}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</form>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||
|
|
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||
|
|
<thead className="bg-gray-50 dark:bg-gray-700">
|
||
|
|
<tr>
|
||
|
|
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Port</th>
|
||
|
|
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Protocol</th>
|
||
|
|
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Action</th>
|
||
|
|
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Note</th>
|
||
|
|
<th className="px-4 py-2 text-right text-sm font-medium text-gray-700 dark:text-gray-300">Actions</th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||
|
|
{rules.length === 0 ? (
|
||
|
|
<tr>
|
||
|
|
<td colSpan={5} className="px-4 py-8 text-center text-gray-500">
|
||
|
|
No rules. Click "Add Rule" to create one.
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
) : (
|
||
|
|
rules.map((r) => (
|
||
|
|
<tr key={r.id}>
|
||
|
|
<td className="px-4 py-2 text-gray-900 dark:text-white font-mono">{r.port}</td>
|
||
|
|
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">{r.protocol}</td>
|
||
|
|
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">{r.action}</td>
|
||
|
|
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">{r.ps || '-'}</td>
|
||
|
|
<td className="px-4 py-2 text-right">
|
||
|
|
<button
|
||
|
|
onClick={() => handleDelete(r.id, r.port)}
|
||
|
|
className="p-2 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
|
||
|
|
title="Delete"
|
||
|
|
>
|
||
|
|
<Trash2 className="w-4 h-4" />
|
||
|
|
</button>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
))
|
||
|
|
)}
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|