feat: Add Wild Central web app (Central-only UI)
Extract Central pages from the wild-cloud web app into a standalone React app for Wild Central. Includes: - Central overview, DNS, DHCP, Firewall, VPN, Ingress, CrowdSec pages - Simplified sidebar with Central-only navigation - Branding updated to "Wild Central" - All Cloud-specific pages, components, hooks, and API services removed - TypeScript type-check and production build pass Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
530
web/src/components/FirewallComponent.tsx
Normal file
530
web/src/components/FirewallComponent.tsx
Normal file
@@ -0,0 +1,530 @@
|
||||
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, ChevronDown, ChevronUp, Lock } from 'lucide-react';
|
||||
import { Badge } from './ui/badge';
|
||||
import { useConfig } from '../hooks';
|
||||
import { useNftables } from '../hooks/useNftables';
|
||||
import { useVpn } from '../hooks/useVpn';
|
||||
|
||||
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 { config: globalConfig, updateConfig } = useConfig();
|
||||
const { status: nftablesStatus, isLoadingStatus: isNftablesLoading, interfaces, refetchStatus } = useNftables();
|
||||
const { config: vpnConfig } = useVpn();
|
||||
|
||||
const nftCfg = globalConfig?.cloud?.nftables;
|
||||
const isEnabled = nftCfg?.enabled !== false;
|
||||
const currentWan = nftCfg?.wanInterface ?? '';
|
||||
const currentExtraPorts: ExtraPort[] = nftCfg?.extraPorts ?? [];
|
||||
const customRoutes = globalConfig?.cloud?.haproxy?.customRoutes ?? [];
|
||||
|
||||
const [editingWan, setEditingWan] = useState(false);
|
||||
const [wanValue, setWanValue] = useState('');
|
||||
const [savingWan, setSavingWan] = useState(false);
|
||||
const [togglingFirewall, setTogglingFirewall] = useState(false);
|
||||
const [showAdvanced, setShowAdvanced] = 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: NonNullable<NonNullable<typeof globalConfig>['cloud']>['nftables']) {
|
||||
if (!globalConfig) return;
|
||||
await updateConfig({
|
||||
...globalConfig,
|
||||
cloud: {
|
||||
...globalConfig.cloud,
|
||||
nftables: { ...nftCfg, ...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>
|
||||
|
||||
{!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>
|
||||
|
||||
{/* Advanced: raw rules */}
|
||||
<Card className={!isEnabled ? 'opacity-50' : ''}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Active Ruleset</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{isNftablesLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<Badge variant={nftablesStatus?.rules ? 'success' : isEnabled ? 'warning' : 'secondary'} className="gap-1">
|
||||
{nftablesStatus?.rules && <CheckCircle className="h-3 w-3" />}
|
||||
{nftablesStatus?.rules ? 'Active' : 'Not loaded'}
|
||||
</Badge>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs gap-1"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
>
|
||||
{showAdvanced ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
||||
Advanced
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{showAdvanced && (
|
||||
<CardContent>
|
||||
{nftablesStatus?.rules ? (
|
||||
<pre className="bg-muted p-3 rounded-lg overflow-x-auto text-xs font-mono">
|
||||
{nftablesStatus.rules}
|
||||
</pre>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No rules loaded yet. Rules are generated when you apply an HAProxy configuration.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user