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>
761 lines
36 KiB
TypeScript
761 lines
36 KiB
TypeScript
import { useState } from 'react';
|
|
import { useForm } from 'react-hook-form';
|
|
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts';
|
|
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 {
|
|
ShieldAlert, Loader2, AlertCircle, CheckCircle, Ban, X, Trash2,
|
|
BookOpen, Shield, Activity, Zap, RefreshCw, Globe,
|
|
} from 'lucide-react';
|
|
import { Badge } from './ui/badge';
|
|
// Instance provisioning removed — Central-only mode
|
|
import { useCrowdSec } from '../hooks/useCrowdSec';
|
|
import type { AddDecisionRequest, CrowdSecAlert } from '../services/api/networking';
|
|
|
|
type Timescale = '1h' | '6h' | '24h' | '7d';
|
|
|
|
const TIMESCALES: { key: Timescale; label: string; ms: number; bucketMs: number }[] = [
|
|
{ key: '1h', label: '1h', ms: 60 * 60 * 1000, bucketMs: 5 * 60 * 1000 },
|
|
{ key: '6h', label: '6h', ms: 6 * 60 * 60 * 1000, bucketMs: 30 * 60 * 1000 },
|
|
{ key: '24h', label: '24h', ms: 24 * 60 * 60 * 1000, bucketMs: 2 * 60 * 60 * 1000 },
|
|
{ key: '7d', label: '7d', ms: 7 * 24 * 60 * 60 * 1000, bucketMs: 24 * 60 * 60 * 1000 },
|
|
];
|
|
|
|
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
|
|
function formatBucketLabel(ts: number, scale: Timescale): string {
|
|
const d = new Date(ts);
|
|
if (scale === '7d') return d.toLocaleDateString(undefined, { timeZone: tz, weekday: 'short', month: 'short', day: 'numeric' });
|
|
return d.toLocaleTimeString(undefined, { timeZone: tz, hour: '2-digit', minute: '2-digit', hour12: false });
|
|
}
|
|
|
|
function binAlerts(alerts: CrowdSecAlert[], scale: Timescale) {
|
|
const cfg = TIMESCALES.find(t => t.key === scale)!;
|
|
const now = Date.now();
|
|
// Align start to a clean bucket boundary so labels fall on round times
|
|
const rawStart = now - cfg.ms;
|
|
const alignedStart = Math.floor(rawStart / cfg.bucketMs) * cfg.bucketMs;
|
|
const numBuckets = Math.ceil((now - alignedStart) / cfg.bucketMs);
|
|
const buckets = Array.from({ length: numBuckets }, (_, i) => ({
|
|
label: formatBucketLabel(alignedStart + i * cfg.bucketMs, scale),
|
|
detections: 0,
|
|
}));
|
|
for (const a of alerts) {
|
|
const t = new Date(a.createdAt).getTime();
|
|
if (t < alignedStart) continue;
|
|
const idx = Math.floor((t - alignedStart) / cfg.bucketMs);
|
|
if (idx >= 0 && idx < numBuckets) buckets[idx].detections++;
|
|
}
|
|
return buckets;
|
|
}
|
|
|
|
function formatRelativeTime(isoString?: string): string {
|
|
if (!isoString) return 'unknown';
|
|
const diff = Date.now() - new Date(isoString).getTime();
|
|
const minutes = Math.floor(diff / 60000);
|
|
if (minutes < 1) return 'just now';
|
|
if (minutes < 60) return `${minutes}m ago`;
|
|
const hours = Math.floor(minutes / 60);
|
|
if (hours < 24) return `${hours}h ago`;
|
|
return `${Math.floor(hours / 24)}d ago`;
|
|
}
|
|
|
|
function scenarioHubUrl(scenario: string): string | null {
|
|
if (!scenario) return null;
|
|
const parts = scenario.split('/');
|
|
// Only link hub scenarios in author/name format (e.g. crowdsecurity/http-probing-classb)
|
|
// CAPI category labels (http:scan, ssh:bruteforce) have no individual hub pages
|
|
if (parts.length === 2 && parts[0] !== 'custom') {
|
|
return `https://app.crowdsec.net/hub/author/${parts[0]}/configurations/name/${parts[1]}`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const KNOWN_ABBREV = new Set(['ssh', 'http', 'https', 'tcp', 'udp', 'ftp', 'ip', 'dns', 'tls', 'dos', 'ddos', 'bf']);
|
|
|
|
function titleCaseScenario(s: string): string {
|
|
return s.replace(/[:\-_]/g, ' ').split(' ').filter(Boolean).map(w =>
|
|
KNOWN_ABBREV.has(w.toLowerCase()) ? w.toUpperCase() : w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()
|
|
).join(' ');
|
|
}
|
|
|
|
function friendlyReason(reason: string): string {
|
|
if (!reason) return 'Community blocklist';
|
|
const map: Record<string, string> = {
|
|
'crowdsecurity/http-scan-classb': 'HTTP Scanner',
|
|
'crowdsecurity/ssh-slow-bruteforce': 'SSH Brute Force (slow)',
|
|
'crowdsecurity/ssh-bf': 'SSH Brute Force',
|
|
'crowdsecurity/ssh-bruteforce': 'SSH Brute Force',
|
|
'crowdsecurity/http-dos': 'HTTP DoS',
|
|
'crowdsecurity/http-bruteforce': 'HTTP Brute Force',
|
|
'crowdsecurity/http-exploit': 'HTTP Exploit',
|
|
'crowdsecurity/http-crawl': 'HTTP Crawler',
|
|
'crowdsecurity/http-bad-user-agent': 'Bad User Agent',
|
|
'crowdsecurity/iptables-scan-multi_ports': 'Port Scanner',
|
|
};
|
|
if (map[reason]) return map[reason];
|
|
const short = reason.includes('/') ? reason.split('/').slice(1).join(' ') : reason;
|
|
return titleCaseScenario(short);
|
|
}
|
|
|
|
function parseDuration(dur?: string): string {
|
|
if (!dur) return '';
|
|
// Go duration: "164h1m31s" → "6d 20h"
|
|
const h = dur.match(/(\d+)h/);
|
|
const m = dur.match(/(\d+)m/);
|
|
const hours = h ? parseInt(h[1]) : 0;
|
|
const mins = m ? parseInt(m[1]) : 0;
|
|
if (hours >= 24) {
|
|
const days = Math.floor(hours / 24);
|
|
const rem = hours % 24;
|
|
return rem > 0 ? `${days}d ${rem}h` : `${days}d`;
|
|
}
|
|
if (hours > 0) return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
|
|
return `${mins}m`;
|
|
}
|
|
|
|
interface BlockFormValues {
|
|
ip: string;
|
|
type: 'ban' | 'allow';
|
|
duration: string;
|
|
reason: string;
|
|
}
|
|
|
|
export function CrowdSecComponent() {
|
|
const instances: string[] = [];
|
|
const {
|
|
status,
|
|
isLoadingStatus,
|
|
summary,
|
|
isLoadingSummary,
|
|
alerts,
|
|
isLoadingAlerts,
|
|
refetchAlerts,
|
|
graphAlerts,
|
|
isLoadingGraphAlerts,
|
|
decisions,
|
|
addDecision,
|
|
isAddingDecision,
|
|
addDecisionError,
|
|
deleteDecision,
|
|
deleteBouncer,
|
|
deleteMachine,
|
|
provision,
|
|
isProvisioning,
|
|
} = useCrowdSec();
|
|
|
|
const [provisionResults, setProvisionResults] = useState<Record<string, { ok: boolean; message: string }>>({});
|
|
const [provisioningInstance, setProvisioningInstance] = useState<string | null>(null);
|
|
const [blockSuccess, setBlockSuccess] = useState<string | null>(null);
|
|
const [timescale, setTimescale] = useState<Timescale>('24h');
|
|
|
|
const { register, handleSubmit, reset, setValue, watch, formState: { errors } } = useForm<BlockFormValues>({
|
|
defaultValues: { ip: '', type: 'ban', duration: '24h', reason: 'manual' },
|
|
});
|
|
const selectedType = watch('type');
|
|
|
|
function handleProvision(instanceName: string) {
|
|
setProvisioningInstance(instanceName);
|
|
provision(instanceName, {
|
|
onSuccess: (data) => {
|
|
setProvisionResults((prev) => ({ ...prev, [instanceName]: { ok: true, message: data.message } }));
|
|
setProvisioningInstance(null);
|
|
},
|
|
onError: (err) => {
|
|
setProvisionResults((prev) => ({ ...prev, [instanceName]: { ok: false, message: (err as Error).message } }));
|
|
setProvisioningInstance(null);
|
|
},
|
|
});
|
|
}
|
|
|
|
function onBlockSubmit(values: BlockFormValues) {
|
|
setBlockSuccess(null);
|
|
const req: AddDecisionRequest = { ip: values.ip, type: values.type, reason: values.reason, duration: values.duration };
|
|
addDecision(req, {
|
|
onSuccess: (data) => {
|
|
setBlockSuccess(data.message);
|
|
reset();
|
|
},
|
|
});
|
|
}
|
|
|
|
const sortedReasons = summary
|
|
? Object.entries(summary.byReason).sort(([, a], [, b]) => b - a)
|
|
: [];
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center gap-4 mb-6">
|
|
<div className="p-2 bg-primary/10 rounded-lg">
|
|
<ShieldAlert className="h-6 w-6 text-primary" />
|
|
</div>
|
|
<div>
|
|
<h2 className="text-2xl font-semibold">CrowdSec</h2>
|
|
<p className="text-muted-foreground">Collaborative intrusion detection — protecting your infrastructure with community threat intelligence</p>
|
|
</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>
|
|
<strong>CrowdSec</strong> analyzes logs from your k8s clusters, detects attacks, and shares threat
|
|
intelligence with the community. Wild Central runs the <strong>LAPI</strong> — the hub that agents
|
|
report to and that bouncers query for the live blocklist. Your cluster contributes to and benefits
|
|
from a shared blocklist of millions of known malicious IPs.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* LAPI Status */}
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Shield className="h-4 w-4 text-muted-foreground" />
|
|
<CardTitle>LAPI Status</CardTitle>
|
|
</div>
|
|
{isLoadingStatus ? (
|
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
|
) : (
|
|
<Badge variant={status?.active ? 'success' : 'warning'} className="gap-1">
|
|
{status?.active && <CheckCircle className="h-3 w-3" />}
|
|
{status?.active ? 'Running' : 'Stopped'}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
{!status?.active && !isLoadingStatus && (
|
|
<p className="text-sm text-muted-foreground">
|
|
CrowdSec is not running on Wild Central. Install it with{' '}
|
|
<code className="font-mono text-xs bg-muted px-1 rounded">apt install crowdsec crowdsec-firewall-bouncer</code>
|
|
{' '}or reinstall wild-cloud-central.
|
|
</p>
|
|
)}
|
|
{status?.active && (
|
|
<>
|
|
<div className="flex items-center justify-between py-2 px-3 rounded bg-muted">
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<Shield className="h-4 w-4 text-muted-foreground" />
|
|
<span>Perimeter firewall enforcement</span>
|
|
<span className="text-xs text-muted-foreground">(nftables on Wild Central)</span>
|
|
</div>
|
|
<Badge variant={status.firewallBouncer ? 'success' : 'warning'} className="gap-1">
|
|
{status.firewallBouncer && <CheckCircle className="h-3 w-3" />}
|
|
{status.firewallBouncer ? 'Active' : 'Stopped'}
|
|
</Badge>
|
|
</div>
|
|
{!status.firewallBouncer && (
|
|
<p className="text-xs text-muted-foreground">
|
|
Install the firewall bouncer to block threats at the perimeter:{' '}
|
|
<code className="font-mono bg-muted px-1 rounded">apt install crowdsec-firewall-bouncer</code>
|
|
</p>
|
|
)}
|
|
<p className="text-xs text-muted-foreground">
|
|
{status.machines.length > 0 && `${status.machines.length} k8s agent${status.machines.length !== 1 ? 's' : ''} reporting. `}
|
|
{status.bouncers.length > 0 && `${status.bouncers.length} enforcement point${status.bouncers.length !== 1 ? 's' : ''} active.`}
|
|
</p>
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{status?.active && (
|
|
<>
|
|
{/* Active Protection Summary */}
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center gap-2">
|
|
<Activity className="h-4 w-4 text-muted-foreground" />
|
|
<CardTitle>Active Protection</CardTitle>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
<p className="text-sm text-muted-foreground">
|
|
IPs currently blocked by the community blocklist (CAPI) and local decisions, enforced by your bouncers.
|
|
</p>
|
|
{isLoadingSummary ? (
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
<Loader2 className="h-4 w-4 animate-spin" />Loading ban statistics…
|
|
</div>
|
|
) : summary ? (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex items-center gap-2">
|
|
<Ban className="h-5 w-5 text-red-500" />
|
|
<span className="text-2xl font-bold">{summary.total.toLocaleString()}</span>
|
|
</div>
|
|
<span className="text-sm text-muted-foreground">IPs blocked right now</span>
|
|
</div>
|
|
{sortedReasons.length > 0 && (
|
|
<div className="space-y-1.5">
|
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">By threat type</p>
|
|
{sortedReasons.slice(0, 8).map(([reason, count]) => {
|
|
const pct = Math.round((count / summary.total) * 100);
|
|
return (
|
|
<div key={reason} className="flex items-center gap-2 text-sm">
|
|
<div className="flex-1 flex items-center gap-2">
|
|
<div
|
|
className="h-1.5 bg-red-400 rounded"
|
|
style={{ width: `${Math.max(pct, 2)}%`, maxWidth: '60%' }}
|
|
/>
|
|
{scenarioHubUrl(reason) ? (
|
|
<a href={scenarioHubUrl(reason)!} target="_blank" rel="noopener noreferrer" className="text-muted-foreground hover:text-foreground hover:underline">
|
|
{friendlyReason(reason)}
|
|
</a>
|
|
) : (
|
|
<span className="text-muted-foreground">{friendlyReason(reason)}</span>
|
|
)}
|
|
</div>
|
|
<span className="font-mono text-xs tabular-nums">{count.toLocaleString()}</span>
|
|
<span className="text-xs text-muted-foreground w-8 text-right">{pct}%</span>
|
|
</div>
|
|
);
|
|
})}
|
|
{sortedReasons.length > 8 && (
|
|
<p className="text-xs text-muted-foreground">+{sortedReasons.length - 8} more categories</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Detections Over Time */}
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Activity className="h-4 w-4 text-muted-foreground" />
|
|
<CardTitle>Detections Over Time</CardTitle>
|
|
</div>
|
|
<div className="flex gap-1">
|
|
{TIMESCALES.map(t => (
|
|
<Button
|
|
key={t.key}
|
|
variant={timescale === t.key ? 'default' : 'ghost'}
|
|
size="sm"
|
|
className="h-7 px-2 text-xs"
|
|
onClick={() => setTimescale(t.key)}
|
|
>
|
|
{t.label}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{isLoadingGraphAlerts ? (
|
|
<div className="flex items-center justify-center h-40 gap-2 text-sm text-muted-foreground">
|
|
<Loader2 className="h-4 w-4 animate-spin" />Loading…
|
|
</div>
|
|
) : (
|
|
<ResponsiveContainer width="100%" height={160}>
|
|
<BarChart data={binAlerts(graphAlerts, timescale)} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
|
|
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
|
<XAxis dataKey="label" tick={{ fontSize: 10 }} interval="preserveStartEnd" className="fill-muted-foreground" />
|
|
<YAxis allowDecimals={false} tick={{ fontSize: 10 }} className="fill-muted-foreground" />
|
|
<Tooltip
|
|
contentStyle={{ fontSize: 12 }}
|
|
formatter={(v) => [v, 'detections']}
|
|
/>
|
|
<Bar dataKey="detections" fill="#ef4444" radius={[2, 2, 0, 0]} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Recent Alert Feed */}
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Activity className="h-4 w-4 text-muted-foreground" />
|
|
<CardTitle>Recent Detections</CardTitle>
|
|
</div>
|
|
<Button variant="ghost" size="sm" onClick={() => refetchAlerts()} className="h-7 gap-1.5 text-xs">
|
|
<RefreshCw className="h-3 w-3" />Refresh
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<p className="text-sm text-muted-foreground mb-3">
|
|
Attack events detected by your k8s agents and reported to this LAPI. Each event results in a ban
|
|
that is shared with the CrowdSec community.
|
|
</p>
|
|
{isLoadingAlerts ? (
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
<Loader2 className="h-4 w-4 animate-spin" />Loading…
|
|
</div>
|
|
) : alerts.length === 0 ? (
|
|
<div className="text-center py-6">
|
|
<Shield className="h-10 w-10 text-muted-foreground mx-auto mb-2" />
|
|
<p className="text-sm text-muted-foreground">No detections yet.</p>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
Events appear here when k8s agents detect and report attacks. The community blocklist
|
|
(CAPI) is already protecting you even before local detections.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-1 max-h-72 overflow-y-auto">
|
|
<div className="grid grid-cols-[1fr_auto_auto_auto] gap-x-3 text-xs text-muted-foreground font-medium px-2 pb-1">
|
|
<span>Source IP</span>
|
|
<span>Threat</span>
|
|
<span>Agent</span>
|
|
<span>When</span>
|
|
</div>
|
|
{alerts.map((alert) => (
|
|
<div key={alert.id} className="grid grid-cols-[1fr_auto_auto_auto] gap-x-3 items-center py-1.5 px-2 bg-muted rounded text-sm">
|
|
<div className="flex items-center gap-1.5 min-w-0">
|
|
<span className="font-mono text-xs truncate">{alert.sourceIP || '—'}</span>
|
|
{alert.country && (
|
|
<span className="text-xs text-muted-foreground flex items-center gap-0.5 shrink-0">
|
|
<Globe className="h-3 w-3" />{alert.country}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{scenarioHubUrl(alert.scenario) ? (
|
|
<a href={scenarioHubUrl(alert.scenario)!} target="_blank" rel="noopener noreferrer" className="text-xs text-muted-foreground whitespace-nowrap hover:text-foreground hover:underline">
|
|
{friendlyReason(alert.scenario)}
|
|
</a>
|
|
) : (
|
|
<span className="text-xs text-muted-foreground whitespace-nowrap">{friendlyReason(alert.scenario)}</span>
|
|
)}
|
|
<span className="text-xs font-mono text-muted-foreground whitespace-nowrap">{alert.machineId?.replace(/^wc-/, '') ?? '—'}</span>
|
|
<span className="text-xs text-muted-foreground whitespace-nowrap">{formatRelativeTime(alert.createdAt)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Manual Decisions: Block / Allow */}
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center gap-2">
|
|
<Ban className="h-4 w-4 text-muted-foreground" />
|
|
<CardTitle>Block or Allow an IP</CardTitle>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<p className="text-sm text-muted-foreground">
|
|
Manually ban an IP (e.g. an attacker you've identified) or add a local allowlist entry
|
|
to prevent a legitimate IP from being blocked by the community list.
|
|
</p>
|
|
|
|
<form onSubmit={handleSubmit(onBlockSubmit)} className="space-y-3">
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<Label htmlFor="ip">IP Address</Label>
|
|
<Input
|
|
id="ip"
|
|
placeholder="1.2.3.4"
|
|
className="mt-1 font-mono"
|
|
{...register('ip', {
|
|
required: 'Required',
|
|
pattern: { value: /^[\d.:/a-fA-F]+$/, message: 'Invalid IP' },
|
|
})}
|
|
/>
|
|
{errors.ip && <p className="text-xs text-red-600 mt-1">{errors.ip.message}</p>}
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="reason">Reason</Label>
|
|
<Input
|
|
id="reason"
|
|
placeholder="manual"
|
|
className="mt-1"
|
|
{...register('reason')}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<Label>Action</Label>
|
|
<Select value={selectedType} onValueChange={(v) => setValue('type', v as 'ban' | 'allow')}>
|
|
<SelectTrigger className="mt-1">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="ban">Ban (block)</SelectItem>
|
|
<SelectItem value="allow">Allow (whitelist)</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<Label>Duration</Label>
|
|
<Select defaultValue="24h" onValueChange={(v) => setValue('duration', v)}>
|
|
<SelectTrigger className="mt-1">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="1h">1 hour</SelectItem>
|
|
<SelectItem value="24h">24 hours</SelectItem>
|
|
<SelectItem value="168h">7 days</SelectItem>
|
|
<SelectItem value="720h">30 days</SelectItem>
|
|
<SelectItem value="8760h">1 year</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
{blockSuccess && (
|
|
<Alert>
|
|
<CheckCircle className="h-4 w-4" />
|
|
<AlertDescription className="text-xs">{blockSuccess}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
{addDecisionError && (
|
|
<Alert variant="error">
|
|
<AlertCircle className="h-4 w-4" />
|
|
<AlertDescription className="text-xs">{(addDecisionError as Error).message}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
<Button type="submit" size="sm" disabled={isAddingDecision} className="gap-2">
|
|
{isAddingDecision ? <Loader2 className="h-4 w-4 animate-spin" /> : <Ban className="h-4 w-4" />}
|
|
{selectedType === 'allow' ? 'Add to Allowlist' : 'Block IP'}
|
|
</Button>
|
|
</form>
|
|
|
|
{/* Local decisions list */}
|
|
{decisions.length > 0 && (
|
|
<div className="border-t pt-3 space-y-2">
|
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Active local decisions</p>
|
|
<div className="space-y-1 max-h-48 overflow-y-auto">
|
|
{decisions.map((d) => (
|
|
<div key={d.id} className="flex items-center justify-between py-1.5 px-2 bg-muted rounded text-xs">
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
{d.type === 'allow'
|
|
? <CheckCircle className="h-3 w-3 text-green-500 shrink-0" />
|
|
: <Ban className="h-3 w-3 text-red-500 shrink-0" />
|
|
}
|
|
<span className="font-mono truncate">{d.value}</span>
|
|
<Badge variant="outline" className="text-xs h-4 shrink-0">{d.type}</Badge>
|
|
<span className="text-muted-foreground truncate">{friendlyReason(d.reason)}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
{d.duration && (
|
|
<span className="text-muted-foreground">{parseDuration(d.duration)}</span>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-5 w-5 p-0 text-muted-foreground hover:text-red-500"
|
|
onClick={() => deleteDecision(d.id)}
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Registered Agents */}
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center gap-2">
|
|
<Zap className="h-4 w-4 text-muted-foreground" />
|
|
<CardTitle>Registered Agents</CardTitle>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
<p className="text-sm text-muted-foreground">
|
|
<strong>Agents</strong> are CrowdSec engines running inside your k8s clusters. They analyze
|
|
logs, detect threats, and report to this LAPI. Each provisioned Wild Cloud instance registers
|
|
one agent here.
|
|
</p>
|
|
{status.machines.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground text-center py-3">No agents registered. Provision an instance below.</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{status.machines.map((m) => (
|
|
<div key={m.machineId} className="flex items-center justify-between py-2 px-3 bg-muted rounded">
|
|
<div className="space-y-0.5">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-mono text-sm">{m.machineId}</span>
|
|
<Badge variant={m.isValidated ? 'default' : 'secondary'} className="text-xs h-4">
|
|
{m.isValidated ? 'validated' : 'pending'}
|
|
</Badge>
|
|
</div>
|
|
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
|
{m.last_heartbeat && <span>heartbeat {formatRelativeTime(m.last_heartbeat)}</span>}
|
|
{m.version && <span>{m.version}</span>}
|
|
{m.ipAddress && <span className="font-mono">{m.ipAddress}</span>}
|
|
</div>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 w-7 p-0 text-muted-foreground hover:text-red-500"
|
|
onClick={() => deleteMachine(m.machineId)}
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Registered Bouncers */}
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center gap-2">
|
|
<Shield className="h-4 w-4 text-muted-foreground" />
|
|
<CardTitle>Registered Bouncers</CardTitle>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
<p className="text-sm text-muted-foreground">
|
|
<strong>Bouncers</strong> enforce the blocklist. Wild Central's nftables bouncer blocks at
|
|
the network perimeter. Each k8s cluster also runs a Traefik bouncer as a second layer.
|
|
</p>
|
|
{status.bouncers.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground text-center py-3">No bouncers registered. Provision an instance to add them.</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{status.bouncers.map((b) => {
|
|
const isFirewallBouncer = b.type === 'crowdsec-firewall-bouncer';
|
|
return (
|
|
<div key={b.name} className="flex items-center justify-between py-2 px-3 bg-muted rounded">
|
|
<div className="space-y-0.5">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-mono text-sm truncate max-w-64">{b.name}</span>
|
|
<Badge variant={b.revoked ? 'secondary' : 'default'} className="text-xs h-4">
|
|
{b.revoked ? 'revoked' : 'active'}
|
|
</Badge>
|
|
{isFirewallBouncer && (
|
|
<Badge variant="outline" className="text-xs h-4 border-orange-400 text-orange-600 dark:text-orange-400">
|
|
perimeter
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
{isFirewallBouncer ? 'Crowdsec Firewall Bouncer' : b.type?.replace(/-/g, ' ') || ''}
|
|
</p>
|
|
</div>
|
|
{!isFirewallBouncer && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 w-7 p-0 text-muted-foreground hover:text-red-500"
|
|
onClick={() => deleteBouncer(b.name)}
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Provision Instances */}
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center gap-2">
|
|
<Zap className="h-4 w-4 text-muted-foreground" />
|
|
<CardTitle>Provision Instances</CardTitle>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
<p className="text-sm text-muted-foreground">
|
|
<strong>Provisioning</strong> connects a Wild Cloud instance's CrowdSec agent to this LAPI —
|
|
generating credentials, registering agent and bouncer, and writing the LAPI URL into the
|
|
instance's app config. After provisioning, redeploy the CrowdSec app on the instance.
|
|
Safe to re-run.
|
|
</p>
|
|
<div className="space-y-2">
|
|
{instances.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">No instances found.</p>
|
|
) : (
|
|
instances.map((instanceName) => {
|
|
const agentName = `wc-${instanceName}`;
|
|
const bouncerName = `wc-bouncer-${instanceName}`;
|
|
const agent = status.machines.find((m) => m.machineId === agentName);
|
|
const bouncer = status.bouncers.find((b) => b.name === bouncerName);
|
|
const isActive = provisioningInstance === instanceName;
|
|
const result = provisionResults[instanceName];
|
|
|
|
return (
|
|
<div key={instanceName} className="rounded border p-3 space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<span className="font-mono text-sm font-medium">{instanceName}</span>
|
|
<Button
|
|
size="sm"
|
|
variant={agent ? 'outline' : 'default'}
|
|
onClick={() => handleProvision(instanceName)}
|
|
disabled={isProvisioning || isActive}
|
|
className="gap-2 h-7"
|
|
>
|
|
{isActive
|
|
? <><Loader2 className="h-3 w-3 animate-spin" />Provisioning…</>
|
|
: agent
|
|
? <><Zap className="h-3 w-3" />Re-provision</>
|
|
: <><Zap className="h-3 w-3" />Provision</>
|
|
}
|
|
</Button>
|
|
</div>
|
|
<div className="flex items-center gap-4 text-xs">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-muted-foreground">Agent:</span>
|
|
{agent ? (
|
|
<Badge variant={agent.isValidated ? 'default' : 'secondary'} className="text-xs h-4">
|
|
{agent.isValidated ? 'validated' : 'pending'}
|
|
{agent.last_heartbeat && ` · ${formatRelativeTime(agent.last_heartbeat)}`}
|
|
</Badge>
|
|
) : (
|
|
<span className="text-muted-foreground italic">not provisioned</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-muted-foreground">Bouncer:</span>
|
|
{bouncer ? (
|
|
<Badge variant={bouncer.revoked ? 'secondary' : 'default'} className="text-xs h-4">
|
|
{bouncer.revoked ? 'revoked' : 'active'}
|
|
</Badge>
|
|
) : (
|
|
<span className="text-muted-foreground italic">not provisioned</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{result && (
|
|
<Alert variant={result.ok ? 'default' : 'error'} className="py-2">
|
|
{result.ok ? <CheckCircle className="h-3 w-3" /> : <AlertCircle className="h-3 w-3" />}
|
|
<AlertDescription className="text-xs">{result.message}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|