Files
wild-central/web/src/components/FirewallComponent.tsx
2026-07-12 00:40:19 +00:00

499 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (165535)');
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>
);
}