499 lines
19 KiB
TypeScript
499 lines
19 KiB
TypeScript
import { useState } from 'react';
|
||
import { Card, CardHeader, CardTitle, CardContent } from './ui/card';
|
||
import { Button } from './ui/button';
|
||
import { Input } from './ui/input';
|
||
import { Label } from './ui/label';
|
||
import { Alert, AlertDescription } from './ui/alert';
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||
import { Shield, Loader2, AlertCircle, CheckCircle, Check, X, Plus, Trash2, BookOpen, Lock } from 'lucide-react';
|
||
import { Badge } from './ui/badge';
|
||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||
import { useNftables } from '../hooks/useNftables';
|
||
import { useVpn } from '../hooks/useVpn';
|
||
import { nftablesConfigApi, haproxyRoutesApi } from '../services/api/settings';
|
||
|
||
type ExtraPort = { port: number; protocol?: string; label?: string };
|
||
|
||
const WELL_KNOWN_PORTS: Record<number, string> = {
|
||
22: 'SSH',
|
||
53: 'DNS',
|
||
67: 'DHCP Server',
|
||
68: 'DHCP Client',
|
||
80: 'HTTP',
|
||
443: 'HTTPS',
|
||
3389: 'RDP',
|
||
5353: 'mDNS',
|
||
5900: 'VNC',
|
||
8404: 'HAProxy Stats',
|
||
};
|
||
|
||
// Ports that are always included automatically
|
||
const AUTO_TCP_PORTS = [80, 443, 8404];
|
||
const AUTO_UDP_PORTS = [53, 67, 68];
|
||
|
||
interface PortEntry {
|
||
port: number;
|
||
protocol: string;
|
||
label: string;
|
||
source: 'auto' | 'haproxy-custom' | 'user';
|
||
}
|
||
|
||
export function FirewallComponent() {
|
||
const queryClient = useQueryClient();
|
||
const { data: nftCfg } = useQuery({ queryKey: ['nftables', 'config'], queryFn: () => nftablesConfigApi.get() });
|
||
const { data: haproxyRoutes } = useQuery({ queryKey: ['haproxy', 'routes'], queryFn: () => haproxyRoutesApi.get() });
|
||
const nftMutation = useMutation({
|
||
mutationFn: (data: Parameters<typeof nftablesConfigApi.update>[0]) => nftablesConfigApi.update(data),
|
||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['nftables', 'config'] }),
|
||
});
|
||
const { interfaces, refetchStatus } = useNftables();
|
||
const { config: vpnConfig } = useVpn();
|
||
|
||
const isEnabled = nftCfg?.enabled !== false;
|
||
const currentWan = nftCfg?.wanInterface ?? '';
|
||
const currentExtraPorts: ExtraPort[] = nftCfg?.extraPorts ?? [];
|
||
const customRoutes = haproxyRoutes?.customRoutes ?? [];
|
||
|
||
const [editingWan, setEditingWan] = useState(false);
|
||
const [wanValue, setWanValue] = useState('');
|
||
const [savingWan, setSavingWan] = useState(false);
|
||
const [togglingFirewall, setTogglingFirewall] = useState(false);
|
||
|
||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||
|
||
const [newPort, setNewPort] = useState('');
|
||
const [newProtocol, setNewProtocol] = useState('tcp');
|
||
const [newLabel, setNewLabel] = useState('');
|
||
const [portError, setPortError] = useState('');
|
||
const [savingPorts, setSavingPorts] = useState(false);
|
||
|
||
const showSuccess = (msg: string) => {
|
||
setSuccessMessage(msg);
|
||
setErrorMessage(null);
|
||
setTimeout(() => setSuccessMessage(null), 5000);
|
||
};
|
||
|
||
const showError = (msg: string) => {
|
||
setErrorMessage(msg);
|
||
setSuccessMessage(null);
|
||
setTimeout(() => setErrorMessage(null), 8000);
|
||
};
|
||
|
||
// Build unified port list from all sources
|
||
function buildPortList(): PortEntry[] {
|
||
const entries: PortEntry[] = [];
|
||
const seen = new Set<string>();
|
||
|
||
const addEntry = (port: number, protocol: string, label: string, source: PortEntry['source']) => {
|
||
const key = `${port}/${protocol}`;
|
||
if (!seen.has(key)) {
|
||
seen.add(key);
|
||
entries.push({ port, protocol, label, source });
|
||
}
|
||
};
|
||
|
||
// Auto TCP ports (HAProxy base)
|
||
for (const port of AUTO_TCP_PORTS) {
|
||
addEntry(port, 'tcp', WELL_KNOWN_PORTS[port] || '', 'auto');
|
||
}
|
||
|
||
// HAProxy custom route ports
|
||
for (const route of customRoutes) {
|
||
addEntry(route.port, 'tcp', `HAProxy: ${route.name}`, 'haproxy-custom');
|
||
}
|
||
|
||
// Auto UDP ports (DNS/DHCP)
|
||
for (const port of AUTO_UDP_PORTS) {
|
||
addEntry(port, 'udp', WELL_KNOWN_PORTS[port] || '', 'auto');
|
||
}
|
||
|
||
// VPN listen port (when enabled)
|
||
if (vpnConfig?.enabled && vpnConfig.listenPort) {
|
||
addEntry(vpnConfig.listenPort, 'udp', 'WireGuard VPN', 'auto');
|
||
}
|
||
|
||
// User extra ports
|
||
for (const ep of currentExtraPorts) {
|
||
const proto = ep.protocol || 'tcp';
|
||
addEntry(ep.port, proto, ep.label || WELL_KNOWN_PORTS[ep.port] || '', 'user');
|
||
}
|
||
|
||
return entries;
|
||
}
|
||
|
||
const allPorts = buildPortList();
|
||
const tcpPorts = allPorts.filter(p => p.protocol === 'tcp');
|
||
const udpPorts = allPorts.filter(p => p.protocol === 'udp');
|
||
|
||
async function saveNftConfig(patch: Partial<NonNullable<typeof nftCfg>>) {
|
||
await nftMutation.mutateAsync(patch);
|
||
refetchStatus();
|
||
}
|
||
|
||
async function toggleFirewall() {
|
||
setTogglingFirewall(true);
|
||
try {
|
||
await saveNftConfig({ enabled: !isEnabled });
|
||
showSuccess(`Firewall ${isEnabled ? 'disabled' : 'enabled'}`);
|
||
} catch (e) {
|
||
showError(`Failed to toggle firewall: ${e instanceof Error ? e.message : String(e)}`);
|
||
}
|
||
setTogglingFirewall(false);
|
||
}
|
||
|
||
async function saveWan(value: string) {
|
||
setSavingWan(true);
|
||
try {
|
||
await saveNftConfig({ wanInterface: value || undefined });
|
||
showSuccess(value ? `WAN interface set to ${value}` : 'Strict mode disabled');
|
||
} catch (e) {
|
||
showError(`Failed to save WAN interface: ${e instanceof Error ? e.message : String(e)}`);
|
||
}
|
||
setSavingWan(false);
|
||
setEditingWan(false);
|
||
}
|
||
|
||
async function addPort() {
|
||
const port = parseInt(newPort, 10);
|
||
if (isNaN(port) || port < 1 || port > 65535) {
|
||
setPortError('Enter a valid port (1–65535)');
|
||
return;
|
||
}
|
||
if (currentExtraPorts.some((p) => p.port === port && (p.protocol || 'tcp') === newProtocol)) {
|
||
setPortError('Port/protocol already in list');
|
||
return;
|
||
}
|
||
setPortError('');
|
||
setSavingPorts(true);
|
||
try {
|
||
const entry: ExtraPort = { port, protocol: newProtocol };
|
||
if (newLabel.trim()) entry.label = newLabel.trim();
|
||
await saveNftConfig({ extraPorts: [...currentExtraPorts, entry] });
|
||
showSuccess(`Port ${port}/${newProtocol} added`);
|
||
setNewPort('');
|
||
setNewLabel('');
|
||
} catch (e) {
|
||
showError(`Failed to add port: ${e instanceof Error ? e.message : String(e)}`);
|
||
}
|
||
setSavingPorts(false);
|
||
}
|
||
|
||
async function removePort(port: number, protocol: string) {
|
||
try {
|
||
await saveNftConfig({
|
||
extraPorts: currentExtraPorts.filter(
|
||
(ep) => !(ep.port === port && (ep.protocol || 'tcp') === protocol)
|
||
),
|
||
});
|
||
showSuccess(`Port ${port}/${protocol} removed`);
|
||
} catch (e) {
|
||
showError(`Failed to remove port: ${e instanceof Error ? e.message : String(e)}`);
|
||
}
|
||
}
|
||
|
||
function renderPortRow(entry: PortEntry) {
|
||
const isAuto = entry.source !== 'user';
|
||
return (
|
||
<div
|
||
key={`${entry.port}/${entry.protocol}`}
|
||
className={`flex items-center gap-3 px-3 py-2 rounded-md ${isAuto ? 'bg-muted/50' : 'bg-muted'}`}
|
||
>
|
||
<span className="font-mono text-sm w-16">{entry.port}</span>
|
||
<span className="text-xs text-muted-foreground uppercase w-8">{entry.protocol}</span>
|
||
<span className="text-sm flex-1 truncate">
|
||
{entry.label}
|
||
</span>
|
||
{isAuto ? (
|
||
<span title="Managed automatically"><Lock className="h-3 w-3 text-muted-foreground shrink-0" /></span>
|
||
) : (
|
||
<button
|
||
onClick={() => removePort(entry.port, entry.protocol)}
|
||
className="text-muted-foreground hover:text-red-500 shrink-0"
|
||
title="Remove port"
|
||
>
|
||
<X className="h-3.5 w-3.5" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* Header with enable/disable toggle */}
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-4">
|
||
<div className="p-2 bg-primary/10 rounded-lg">
|
||
<Shield className="h-6 w-6 text-primary" />
|
||
</div>
|
||
<div>
|
||
<h2 className="text-2xl font-semibold">Firewall</h2>
|
||
<p className="text-muted-foreground">nftables rules protecting Wild Central itself</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<Badge variant={isEnabled ? 'success' : 'secondary'} className="gap-1">
|
||
{isEnabled && <CheckCircle className="h-3 w-3" />}
|
||
{isEnabled ? 'Enabled' : 'Disabled'}
|
||
</Badge>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-7 text-xs gap-1"
|
||
onClick={toggleFirewall}
|
||
disabled={togglingFirewall}
|
||
>
|
||
{togglingFirewall ? <Loader2 className="h-3 w-3 animate-spin" /> : null}
|
||
{isEnabled ? 'Disable' : 'Enable'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Educational card */}
|
||
<Card className="bg-gradient-to-br from-cyan-50 to-blue-50 dark:from-cyan-900/20 dark:to-blue-900/20 border-cyan-200 dark:border-cyan-800">
|
||
<CardContent className="pt-4">
|
||
<div className="flex gap-3">
|
||
<BookOpen className="h-5 w-5 text-cyan-600 dark:text-cyan-400 shrink-0 mt-0.5" />
|
||
<div className="space-y-1 text-sm text-cyan-900 dark:text-cyan-100">
|
||
<p>
|
||
Wild Central uses <strong>nftables</strong> to protect itself. Rules are generated
|
||
automatically from your HAProxy ingress configuration — the ports HAProxy listens
|
||
on are always allowed. The firewall is permissive by default; set a{' '}
|
||
<strong>WAN interface</strong> to enable strict filtering that drops anything not
|
||
explicitly listed. Make sure to add <strong>extra ports</strong> for any services
|
||
you need to reach directly (like SSH) before enabling strict mode.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{successMessage && (
|
||
<Alert className="border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/20">
|
||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||
<AlertDescription className="text-green-700 dark:text-green-300">{successMessage}</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
{errorMessage && (
|
||
<Alert variant="error">
|
||
<AlertCircle className="h-4 w-4" />
|
||
<AlertDescription>{errorMessage}</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
|
||
{/* Allowed Ports */}
|
||
<Card className={!isEnabled ? 'opacity-50 pointer-events-none' : ''}>
|
||
<CardHeader>
|
||
<CardTitle>Allowed Ports</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-5">
|
||
<p className="text-sm text-muted-foreground">
|
||
All ports the firewall allows through. Ports managed by HAProxy and system services are
|
||
locked — add your own with the form below.
|
||
</p>
|
||
|
||
{/* TCP ports */}
|
||
<div className="space-y-2">
|
||
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">TCP</h4>
|
||
<div className="space-y-1">
|
||
{tcpPorts.map(renderPortRow)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* UDP ports */}
|
||
<div className="space-y-2">
|
||
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">UDP</h4>
|
||
<div className="space-y-1">
|
||
{udpPorts.map(renderPortRow)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Add port form */}
|
||
<div className="border-t pt-4 space-y-3">
|
||
<h4 className="text-sm font-medium">Add Port</h4>
|
||
<div className="flex flex-wrap gap-2 items-end">
|
||
<div>
|
||
<Label htmlFor="new-port" className="text-xs">Port</Label>
|
||
<Input
|
||
id="new-port"
|
||
value={newPort}
|
||
onChange={(e) => { setNewPort(e.target.value); setPortError(''); }}
|
||
onKeyDown={(e) => e.key === 'Enter' && addPort()}
|
||
placeholder="e.g. 22"
|
||
className="font-mono h-8 text-sm w-24 mt-1"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label className="text-xs">Protocol</Label>
|
||
<Select value={newProtocol} onValueChange={setNewProtocol}>
|
||
<SelectTrigger className="h-8 text-sm w-20 mt-1 font-mono">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="tcp">TCP</SelectItem>
|
||
<SelectItem value="udp">UDP</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div>
|
||
<Label htmlFor="new-label" className="text-xs">Label (optional)</Label>
|
||
<Input
|
||
id="new-label"
|
||
value={newLabel}
|
||
onChange={(e) => setNewLabel(e.target.value)}
|
||
onKeyDown={(e) => e.key === 'Enter' && addPort()}
|
||
placeholder="e.g. SSH"
|
||
className="h-8 text-sm w-32 mt-1"
|
||
/>
|
||
</div>
|
||
<Button
|
||
size="sm"
|
||
className="h-8 gap-1"
|
||
onClick={addPort}
|
||
disabled={savingPorts || !newPort}
|
||
>
|
||
{savingPorts ? <Loader2 className="h-3 w-3 animate-spin" /> : <Plus className="h-3 w-3" />}
|
||
Add
|
||
</Button>
|
||
{!currentExtraPorts.some((p) => p.port === 22) && (
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
className="h-8 gap-1 text-xs"
|
||
onClick={() => { setNewPort('22'); setNewProtocol('tcp'); setNewLabel('SSH'); }}
|
||
>
|
||
<Plus className="h-3 w-3" />
|
||
Add SSH (22)
|
||
</Button>
|
||
)}
|
||
</div>
|
||
{portError && <p className="text-xs text-red-600">{portError}</p>}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* WAN Interface */}
|
||
<Card className={!isEnabled ? 'opacity-50 pointer-events-none' : ''}>
|
||
<CardHeader>
|
||
<CardTitle>WAN Interface</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<p className="text-sm text-muted-foreground">
|
||
Your <strong>WAN interface</strong> is the network port that faces the internet — the
|
||
one your router sends traffic to. When set, the firewall drops all inbound connections
|
||
on that interface except for the ports listed above. Leave unset to keep the firewall
|
||
permissive (safe default while you're getting started).
|
||
</p>
|
||
|
||
{currentWan && !currentExtraPorts.some((p) => p.port === 22) && (
|
||
<Alert variant="warning">
|
||
<AlertCircle className="h-4 w-4" />
|
||
<AlertDescription className="text-xs">
|
||
Strict mode is active but SSH (port 22) is not in your allowed ports.
|
||
You may lose remote access if you don't have another way in.
|
||
</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
|
||
{!editingWan ? (
|
||
<div className="flex items-center gap-3">
|
||
{currentWan ? (
|
||
<span className="font-mono bg-muted px-2 py-0.5 rounded text-sm">{currentWan}</span>
|
||
) : (
|
||
<span className="text-sm text-muted-foreground italic">
|
||
Not set — permissive mode (all inbound allowed)
|
||
</span>
|
||
)}
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-7 text-xs gap-1"
|
||
onClick={() => { setWanValue(currentWan); setEditingWan(true); }}
|
||
>
|
||
{currentWan ? 'Change' : 'Enable strict mode'}
|
||
</Button>
|
||
{currentWan && (
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-7 text-xs gap-1 text-muted-foreground hover:text-red-500"
|
||
onClick={() => saveWan('')}
|
||
>
|
||
<Trash2 className="h-3 w-3" />
|
||
Disable
|
||
</Button>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="space-y-3">
|
||
{currentWan === '' && !currentExtraPorts.some((p) => p.port === 22) && (
|
||
<Alert variant="warning">
|
||
<AlertCircle className="h-4 w-4" />
|
||
<AlertDescription className="text-xs">
|
||
SSH (port 22) is not in your extra ports list. Enabling strict mode will
|
||
block SSH access to Wild Central.
|
||
</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
<div className="space-y-1.5">
|
||
<Label className="text-xs">Select interface</Label>
|
||
{interfaces.length > 0 ? (
|
||
<Select value={wanValue} onValueChange={setWanValue}>
|
||
<SelectTrigger className="w-64 h-8 text-sm font-mono">
|
||
<SelectValue placeholder="Choose an interface…" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{interfaces.map((iface) => (
|
||
<SelectItem key={iface.name} value={iface.name}>
|
||
<span className="font-mono">{iface.name}</span>
|
||
{iface.addresses.length > 0 && (
|
||
<span className="text-muted-foreground ml-2 text-xs">
|
||
{iface.addresses[0].split('/')[0]}
|
||
</span>
|
||
)}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
) : (
|
||
<Input
|
||
value={wanValue}
|
||
onChange={(e) => setWanValue(e.target.value)}
|
||
placeholder="e.g. eth0"
|
||
className="font-mono h-8 text-sm w-32"
|
||
/>
|
||
)}
|
||
<p className="text-xs text-muted-foreground">
|
||
Pick the interface that faces your router/internet. Typically the one with your
|
||
external IP address.
|
||
</p>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<Button
|
||
size="sm"
|
||
className="h-8 gap-1"
|
||
onClick={() => saveWan(wanValue)}
|
||
disabled={savingWan || !wanValue}
|
||
>
|
||
{savingWan ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
|
||
Save
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-8 gap-1"
|
||
onClick={() => setEditingWan(false)}
|
||
>
|
||
<X className="h-3 w-3" />Cancel
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
</div>
|
||
);
|
||
}
|