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:
2026-07-09 04:10:43 +00:00
parent 5defc27f63
commit 735f3fcb05
121 changed files with 18347 additions and 0 deletions

View 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 (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>
{!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>
);
}