From 735f3fcb054fcf8e2501de93f110560fae688708 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Thu, 9 Jul 2026 04:10:43 +0000 Subject: [PATCH] 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) --- web/.gitignore | 27 + web/BUILDING_WILD_APP.md | 380 ++ web/CLAUDE.md | 2 + web/LICENSE | 661 ++ web/README.md | 67 + web/components.json | 21 + web/docs/specs/routing-contract.md | 470 ++ web/eslint.config.js | 28 + web/index.html | 20 + web/package.json | 75 + web/pnpm-lock.yaml | 6194 ++++++++++++++++++ web/public/favicon.svg | 7 + web/public/manifest.json | 15 + web/public/vite.svg | 1 + web/src/App.css | 42 + web/src/App.tsx | 14 + web/src/assets/react.svg | 1 + web/src/components/AppSidebar.tsx | 134 + web/src/components/CentralComponent.tsx | 219 + web/src/components/CloudflareComponent.tsx | 426 ++ web/src/components/CopyButton.tsx | 49 + web/src/components/CrowdSecComponent.tsx | 760 +++ web/src/components/DdnsComponent.tsx | 280 + web/src/components/DhcpComponent.tsx | 350 + web/src/components/DnsComponent.tsx | 484 ++ web/src/components/DnsmasqSection.tsx | 86 + web/src/components/DownloadButton.tsx | 41 + web/src/components/ErrorBoundary.tsx | 167 + web/src/components/FirewallComponent.tsx | 530 ++ web/src/components/HelpPanel.tsx | 41 + web/src/components/IngressProxyComponent.tsx | 342 + web/src/components/LanDnsComponent.tsx | 259 + web/src/components/Message.tsx | 43 + web/src/components/ThemeToggle.tsx | 52 + web/src/components/VpnComponent.tsx | 583 ++ web/src/components/index.ts | 11 + web/src/components/ui/alert-dialog.tsx | 194 + web/src/components/ui/alert.tsx | 76 + web/src/components/ui/badge.tsx | 50 + web/src/components/ui/button.tsx | 59 + web/src/components/ui/card.tsx | 92 + web/src/components/ui/checkbox.tsx | 30 + web/src/components/ui/collapsible.tsx | 31 + web/src/components/ui/dialog.tsx | 141 + web/src/components/ui/drawer.tsx | 95 + web/src/components/ui/entity-tile.tsx | 97 + web/src/components/ui/form.tsx | 167 + web/src/components/ui/index.ts | 28 + web/src/components/ui/input.tsx | 26 + web/src/components/ui/label.tsx | 22 + web/src/components/ui/select.tsx | 158 + web/src/components/ui/separator.tsx | 28 + web/src/components/ui/sheet.tsx | 137 + web/src/components/ui/sidebar.tsx | 727 ++ web/src/components/ui/skeleton.tsx | 13 + web/src/components/ui/switch.tsx | 29 + web/src/components/ui/tabs.tsx | 64 + web/src/components/ui/textarea.tsx | 18 + web/src/components/ui/tooltip.tsx | 59 + web/src/contexts/HelpContext.tsx | 38 + web/src/contexts/ThemeContext.tsx | 73 + web/src/hooks/__tests__/useMessages.test.ts | 127 + web/src/hooks/__tests__/useStatus.test.ts | 104 + web/src/hooks/index.ts | 7 + web/src/hooks/use-mobile.ts | 19 + web/src/hooks/useCentralStatus.ts | 53 + web/src/hooks/useCert.ts | 39 + web/src/hooks/useCloudflare.ts | 18 + web/src/hooks/useConfig.ts | 62 + web/src/hooks/useCrowdSec.ts | 128 + web/src/hooks/useDdns.ts | 33 + web/src/hooks/useDhcp.ts | 42 + web/src/hooks/useDnsmasq.ts | 63 + web/src/hooks/useHaproxy.ts | 52 + web/src/hooks/useHealth.ts | 13 + web/src/hooks/useMessages.ts | 33 + web/src/hooks/useNftables.ts | 39 + web/src/hooks/usePageHelp.tsx | 25 + web/src/hooks/useStatus.ts | 11 + web/src/hooks/useVpn.ts | 93 + web/src/index.css | 160 + web/src/lib/queryClient.ts | 15 + web/src/lib/utils.ts | 6 + web/src/main.tsx | 24 + web/src/router/CentralLayout.tsx | 61 + web/src/router/index.tsx | 12 + web/src/router/pages/CentralPage.tsx | 10 + web/src/router/pages/CloudflarePage.tsx | 10 + web/src/router/pages/CrowdSecPage.tsx | 10 + web/src/router/pages/DdnsPage.tsx | 10 + web/src/router/pages/DhcpPage.tsx | 10 + web/src/router/pages/DnsPage.tsx | 10 + web/src/router/pages/FirewallPage.tsx | 10 + web/src/router/pages/IngressProxyPage.tsx | 10 + web/src/router/pages/LanDnsPage.tsx | 10 + web/src/router/pages/NotFoundPage.tsx | 30 + web/src/router/pages/VpnPage.tsx | 10 + web/src/router/routes.tsx | 41 + web/src/schemas/__tests__/config.test.ts | 330 + web/src/schemas/config.ts | 187 + web/src/services/api-legacy.ts | 132 + web/src/services/api.ts | 3 + web/src/services/api/cert.ts | 37 + web/src/services/api/client.ts | 142 + web/src/services/api/cloudflare.ts | 20 + web/src/services/api/config.ts | 12 + web/src/services/api/dnsmasq.ts | 28 + web/src/services/api/index.ts | 11 + web/src/services/api/networking.ts | 270 + web/src/services/api/types/index.ts | 1 + web/src/services/api/vpn.ts | 71 + web/src/test/setup.ts | 23 + web/src/test/test-utils.tsx | 37 + web/src/types/index.ts | 176 + web/src/utils/formatters.ts | 3 + web/src/vite-env.d.ts | 1 + web/tsconfig.app.json | 37 + web/tsconfig.json | 19 + web/tsconfig.node.json | 25 + web/vite.config.ts | 18 + web/vitest.config.ts | 20 + 121 files changed, 18347 insertions(+) create mode 100644 web/.gitignore create mode 100644 web/BUILDING_WILD_APP.md create mode 100644 web/CLAUDE.md create mode 100644 web/LICENSE create mode 100644 web/README.md create mode 100644 web/components.json create mode 100644 web/docs/specs/routing-contract.md create mode 100644 web/eslint.config.js create mode 100644 web/index.html create mode 100644 web/package.json create mode 100644 web/pnpm-lock.yaml create mode 100644 web/public/favicon.svg create mode 100644 web/public/manifest.json create mode 100644 web/public/vite.svg create mode 100644 web/src/App.css create mode 100644 web/src/App.tsx create mode 100644 web/src/assets/react.svg create mode 100644 web/src/components/AppSidebar.tsx create mode 100644 web/src/components/CentralComponent.tsx create mode 100644 web/src/components/CloudflareComponent.tsx create mode 100644 web/src/components/CopyButton.tsx create mode 100644 web/src/components/CrowdSecComponent.tsx create mode 100644 web/src/components/DdnsComponent.tsx create mode 100644 web/src/components/DhcpComponent.tsx create mode 100644 web/src/components/DnsComponent.tsx create mode 100644 web/src/components/DnsmasqSection.tsx create mode 100644 web/src/components/DownloadButton.tsx create mode 100644 web/src/components/ErrorBoundary.tsx create mode 100644 web/src/components/FirewallComponent.tsx create mode 100644 web/src/components/HelpPanel.tsx create mode 100644 web/src/components/IngressProxyComponent.tsx create mode 100644 web/src/components/LanDnsComponent.tsx create mode 100644 web/src/components/Message.tsx create mode 100644 web/src/components/ThemeToggle.tsx create mode 100644 web/src/components/VpnComponent.tsx create mode 100644 web/src/components/index.ts create mode 100644 web/src/components/ui/alert-dialog.tsx create mode 100644 web/src/components/ui/alert.tsx create mode 100644 web/src/components/ui/badge.tsx create mode 100644 web/src/components/ui/button.tsx create mode 100644 web/src/components/ui/card.tsx create mode 100644 web/src/components/ui/checkbox.tsx create mode 100644 web/src/components/ui/collapsible.tsx create mode 100644 web/src/components/ui/dialog.tsx create mode 100644 web/src/components/ui/drawer.tsx create mode 100644 web/src/components/ui/entity-tile.tsx create mode 100644 web/src/components/ui/form.tsx create mode 100644 web/src/components/ui/index.ts create mode 100644 web/src/components/ui/input.tsx create mode 100644 web/src/components/ui/label.tsx create mode 100644 web/src/components/ui/select.tsx create mode 100644 web/src/components/ui/separator.tsx create mode 100644 web/src/components/ui/sheet.tsx create mode 100644 web/src/components/ui/sidebar.tsx create mode 100644 web/src/components/ui/skeleton.tsx create mode 100644 web/src/components/ui/switch.tsx create mode 100644 web/src/components/ui/tabs.tsx create mode 100644 web/src/components/ui/textarea.tsx create mode 100644 web/src/components/ui/tooltip.tsx create mode 100644 web/src/contexts/HelpContext.tsx create mode 100644 web/src/contexts/ThemeContext.tsx create mode 100644 web/src/hooks/__tests__/useMessages.test.ts create mode 100644 web/src/hooks/__tests__/useStatus.test.ts create mode 100644 web/src/hooks/index.ts create mode 100644 web/src/hooks/use-mobile.ts create mode 100644 web/src/hooks/useCentralStatus.ts create mode 100644 web/src/hooks/useCert.ts create mode 100644 web/src/hooks/useCloudflare.ts create mode 100644 web/src/hooks/useConfig.ts create mode 100644 web/src/hooks/useCrowdSec.ts create mode 100644 web/src/hooks/useDdns.ts create mode 100644 web/src/hooks/useDhcp.ts create mode 100644 web/src/hooks/useDnsmasq.ts create mode 100644 web/src/hooks/useHaproxy.ts create mode 100644 web/src/hooks/useHealth.ts create mode 100644 web/src/hooks/useMessages.ts create mode 100644 web/src/hooks/useNftables.ts create mode 100644 web/src/hooks/usePageHelp.tsx create mode 100644 web/src/hooks/useStatus.ts create mode 100644 web/src/hooks/useVpn.ts create mode 100644 web/src/index.css create mode 100644 web/src/lib/queryClient.ts create mode 100644 web/src/lib/utils.ts create mode 100644 web/src/main.tsx create mode 100644 web/src/router/CentralLayout.tsx create mode 100644 web/src/router/index.tsx create mode 100644 web/src/router/pages/CentralPage.tsx create mode 100644 web/src/router/pages/CloudflarePage.tsx create mode 100644 web/src/router/pages/CrowdSecPage.tsx create mode 100644 web/src/router/pages/DdnsPage.tsx create mode 100644 web/src/router/pages/DhcpPage.tsx create mode 100644 web/src/router/pages/DnsPage.tsx create mode 100644 web/src/router/pages/FirewallPage.tsx create mode 100644 web/src/router/pages/IngressProxyPage.tsx create mode 100644 web/src/router/pages/LanDnsPage.tsx create mode 100644 web/src/router/pages/NotFoundPage.tsx create mode 100644 web/src/router/pages/VpnPage.tsx create mode 100644 web/src/router/routes.tsx create mode 100644 web/src/schemas/__tests__/config.test.ts create mode 100644 web/src/schemas/config.ts create mode 100644 web/src/services/api-legacy.ts create mode 100644 web/src/services/api.ts create mode 100644 web/src/services/api/cert.ts create mode 100644 web/src/services/api/client.ts create mode 100644 web/src/services/api/cloudflare.ts create mode 100644 web/src/services/api/config.ts create mode 100644 web/src/services/api/dnsmasq.ts create mode 100644 web/src/services/api/index.ts create mode 100644 web/src/services/api/networking.ts create mode 100644 web/src/services/api/types/index.ts create mode 100644 web/src/services/api/vpn.ts create mode 100644 web/src/test/setup.ts create mode 100644 web/src/test/test-utils.tsx create mode 100644 web/src/types/index.ts create mode 100644 web/src/utils/formatters.ts create mode 100644 web/src/vite-env.d.ts create mode 100644 web/tsconfig.app.json create mode 100644 web/tsconfig.json create mode 100644 web/tsconfig.node.json create mode 100644 web/vite.config.ts create mode 100644 web/vitest.config.ts diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..1968fc8 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,27 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Environment variables +.env.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/web/BUILDING_WILD_APP.md b/web/BUILDING_WILD_APP.md new file mode 100644 index 0000000..df60f50 --- /dev/null +++ b/web/BUILDING_WILD_APP.md @@ -0,0 +1,380 @@ +# Building Wild App + +This document describes the architecture and tooling used to build the Wild App, the web-based interface for managing Wild Cloud instances, hosted on Wild Central. + +## Principles + +- Stick with well known standards. +- Keep it simple. +- Use popular, well-maintained libraries. +- Use components wherever possible to avoid reinventing the wheel. +- Use TypeScript for type safety. + +### Tooling +## Dev Environment Requirements + +- Node.js 20+ +- pnpm for package management +- vite for build tooling +- React + TypeScript +- Tailwind CSS for styling +- shadcn/ui for ready-made components +- radix-ui for accessible components +- eslint for linting +- tsc for type checking +- vitest for unit tests + +#### Makefile commands + +- Build application: `make app-build` +- Run locally: `make app-run` +- Format code: `make app-fmt` +- Lint and typecheck: `make app-check` +- Test installation: `make app-test` + +### Scaffolding apps + +It is important to start an app with a good base structure to avoid difficult to debug config issues. + +This is a recommended setup. + +#### Install pnpm if necessary: + +```bash +curl -fsSL https://get.pnpm.io/install.sh | sh - +``` + +#### Install a React + Speedy Web Compiler (SWC) + TypeScript + TailwindCSS app with vite: + +``` +pnpm create vite@latest my-app -- --template react-swc-ts +``` + +#### Reconfigure to use shadcn/ui (radix + tailwind components) (see https://ui.shadcn.com/docs/installation/vite) + +##### Add tailwind. + +```bash +pnpm add tailwindcss @tailwindcss/vite +``` + +##### Replace everything in src/index.css with a tailwind import: + +```bash +echo "@import \"tailwindcss\";" > src/index.css +``` + +##### Edit tsconfig files + +The current version of Vite splits TypeScript configuration into three files, two of which need to be edited. Add the baseUrl and paths properties to the compilerOptions section of the tsconfig.json and tsconfig.app.json files: + +tsconfig.json + +```json +{ + "files": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.node.json" + } + ], + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + } +} +``` + +tsconfig.app.json + +```json +{ + "compilerOptions": { + // ... + "baseUrl": ".", + "paths": { + "@/*": [ + "./src/*" + ] + } + // ... + } +} +``` + +##### Update vite.config.ts + +```bash +pnpm add -D @types/node +``` +Then edit vite.config.ts to include the node types: + +```ts +import path from "path" +import tailwindcss from "@tailwindcss/vite" +import react from "@vitejs/plugin-react" +import { defineConfig } from "vite" + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react(), tailwindcss()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, +}) +``` + +##### Run the cli + +```bash +pnpm dlx shadcn@latest init +``` + +##### Add components + +```bash +pnpm dlx shadcn@latest add button +pnpm dlx shadcn@latest add alert-dialog +``` + +You can then use components with `import { Button } from "@/components/ui/button"` + +### UX Principles + +These principles ensure consistent, intuitive interfaces that align with Wild Cloud's philosophy of simplicity and clarity. Use them as quality control when building new components. + +#### Navigation & Structure + +- **Use shadcn AppSideBar** as the main navigation: https://ui.shadcn.com/docs/components/sidebar +- **Card-Based Layout**: Group related content in Card components + - Primary cards: `p-6` padding + - Nested cards: `p-4` padding with subtle shadows + - Use cards to create visual hierarchy through nesting +- **Spacing Rhythm**: Maintain consistent vertical spacing + - Major sections: `space-y-6` + - Related items: `space-y-4` + - Form fields: `space-y-3` + - Inline elements: `gap-2`, `gap-3`, or `gap-4` + +#### Visual Design + +- **Dark Mode**: Support both light and dark modes using Tailwind's `dark:` prefix + - Test all components in both modes for contrast and readability + - Use semantic color tokens that adapt to theme +- **Status Color System**: Use semantic left border colors to categorize content + - Blue (`border-l-blue-500`): Configuration sections + - Green (`border-l-green-500`): Network/infrastructure + - Red (`border-l-red-500`): Errors and warnings + - Cyan: Educational content +- **Icon-Text Pairing**: Pair important text with Lucide icons + - Place icons in colored containers: `p-2 bg-primary/10 rounded-lg` + - Provides visual anchors and improves scannability +- **Technical Data Display**: Show technical information clearly + - Use `font-mono` class for IPs, domains, configuration values + - Display in `bg-muted rounded-md p-2` containers + +#### Component Patterns + +- **Edit/View Mode Toggle**: For configuration sections + - Read-only: Display in `bg-muted rounded-md font-mono` containers with Edit button + - Edit mode: Replace with form inputs in-place + - Provides lightweight editing without context switching +- **Drawers for Complex Forms**: Use side panels for detailed input + - Maintains context with main content + - Better than modals for forms that benefit from seeing related data +- **Educational Content**: Use gradient cards for helpful information + - Background: `from-cyan-50 to-blue-50 dark:from-cyan-900/20 dark:to-blue-900/20` + - Include book icon and clear, concise guidance + - Makes learning feel integrated, not intrusive +- **Empty States**: Center content with clear next actions + - Large icon: `h-12 w-12 text-muted-foreground` + - Descriptive title and explanation + - Suggest action to resolve empty state + +#### Section Headers + +Structure all major section headers consistently: + +```tsx +
+
+ +
+
+

Section Title

+

+ Brief description of section purpose +

+
+
+``` + +#### Status & Feedback + +- **Status Badges**: Use colored badges with icons for state indication + - Keep compact but descriptive + - Include hover/expansion for additional detail +- **Alert Positioning**: Place alerts near related content + - Use semantic colors and icons (CheckCircle, AlertCircle, XCircle) + - Include dismissible X button for manual dismissal +- **Success Messages**: Auto-dismiss after 5 seconds + - Green color with CheckCircle icon + - Clear, affirmative message +- **Error Messages**: Structured and actionable + - Title in bold, detailed message below + - Red color with AlertCircle icon + - Suggest resolution when possible +- **Loading States**: Context-appropriate indicators + - Inline: Use `Loader2` spinner in buttons/actions + - Full section: Card with centered spinner and descriptive text + +#### Form Components + +Use react-hook-form for all forms. Never duplicate component styling. + +**Standard Form Pattern**: +```tsx +import { useForm, Controller } from 'react-hook-form'; +import { Input, Label, Button } from '@/components/ui'; +import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select'; + +const { register, handleSubmit, control, formState: { errors } } = useForm({ + defaultValues: { /* ... */ } +}); + +
+
+ + + {errors.text &&

{errors.text.message}

} +
+ +
+ + ( + + )} + /> + {errors.select &&

{errors.select.message}

} +
+
+``` + +**Rules**: +- **Text inputs**: Use `Input` with `register()` +- **Select dropdowns**: Use `Select` components with `Controller` (never native ` setOperatorEmail(e.target.value)} + placeholder="email@example.com" + className="mt-1" + /> + +
+ + +
+ + ) : ( +
+ {globalConfig?.operator?.email || ( + Not configured + )} +
+ )} + + + + ); +} diff --git a/web/src/components/CloudflareComponent.tsx b/web/src/components/CloudflareComponent.tsx new file mode 100644 index 0000000..c05638a --- /dev/null +++ b/web/src/components/CloudflareComponent.tsx @@ -0,0 +1,426 @@ +import { useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Card, CardContent } from './ui/card'; +import { Button } from './ui/button'; +import { Input, Label } from './ui'; +import { Alert, AlertDescription } from './ui/alert'; +import { + Cloud, CheckCircle, XCircle, AlertCircle, Loader2, Edit2, + RefreshCw, ExternalLink, BookOpen, Globe, Key, Shield, Check, X, KeyRound, +} from 'lucide-react'; +import { Badge } from './ui/badge'; +import { useCloudflare } from '../hooks/useCloudflare'; +import { useConfig } from '../hooks'; +import { useDdns } from '../hooks/useDdns'; +import { useCert } from '../hooks/useCert'; +import { secretsApi } from '../services/api'; +import { usePageHelp } from '../hooks/usePageHelp'; + +export function CloudflareComponent() { + const { verification, isLoading: isVerifying, refetch: reverify } = useCloudflare(); + const { config: globalConfig, updateConfig: updateGlobalConfig, isUpdating } = useConfig(); + const { status: ddnsStatus } = useDdns(); + const { status: certStatus, isLoading: certLoading, provision: provisionCert, isProvisioning, provisionError } = useCert(); + + const queryClient = useQueryClient(); + const { data: globalSecrets } = useQuery({ + queryKey: ['globalSecrets'], + queryFn: () => secretsApi.get(), + }); + const updateSecretsMutation = useMutation({ + mutationFn: (values: Record) => secretsApi.update(values), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['globalSecrets'] }); + queryClient.invalidateQueries({ queryKey: ['cloudflare', 'verify'] }); + setEditingToken(false); + setTokenValue(''); + showSuccess('Cloudflare API token saved'); + }, + onError: (err) => { + showError(String(err)); + }, + }); + + const cfTokenIsSet = !!(globalSecrets as Record | undefined) + && !!(globalSecrets as { cloudflare?: { apiToken?: string } })?.cloudflare?.apiToken; + + const [editingToken, setEditingToken] = useState(false); + const [tokenValue, setTokenValue] = useState(''); + const [editingDomain, setEditingDomain] = useState(false); + const [domainInput, setDomainInput] = useState(''); + const [successMessage, setSuccessMessage] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + + const showSuccess = (msg: string) => { + setSuccessMessage(msg); + setErrorMessage(null); + setTimeout(() => setSuccessMessage(null), 5000); + }; + + const showError = (msg: string) => { + setErrorMessage(msg); + setSuccessMessage(null); + setTimeout(() => setErrorMessage(null), 8000); + }; + + usePageHelp({ + title: 'Why Cloudflare?', + description: ( + <> +

+ Wild Cloud uses Cloudflare to manage DNS records and provision TLS certificates for your domains. + A Cloudflare API token enables three key features: Dynamic DNS keeps your domain + pointed at your changing public IP, TLS certificates are provisioned via + Let's Encrypt DNS-01 challenges, and ExternalDNS automatically creates DNS records + when you deploy apps. +

+

+ Create an API token in your Cloudflare dashboard with Zone:DNS:Edit and{' '} + Zone:Zone:Read permissions, scoped to your domain's zone. +

+ + ), + icon: , + color: 'bg-gradient-to-r from-orange-50 to-amber-50 dark:from-orange-950/20 dark:to-amber-950/20', + actions: ( + + + + ), + }); + + return ( +
+ {/* Page Header */} +
+
+

Cloudflare

+

Manage your Cloudflare integration for DNS and TLS

+
+ {verification?.tokenValid && ( + + + Connected + + )} +
+ + {/* Alerts */} + {successMessage && ( + + + {successMessage} + + )} + {errorMessage && ( + + + {errorMessage} + + )} + + {/* API Token Card */} + +
+
+ +
API Token
+
+ {!editingToken && ( + + )} +
+ {!editingToken && ( + cfTokenIsSet + ?

Configured

+ :

Not set — required for DNS and TLS features

+ )} + {editingToken && ( +
+ setTokenValue(e.target.value)} + /> +
+ + +
+
+ )} +
+ + {/* Central Domain & TLS Card */} + +
+
+ +
Central Domain
+
+
+ {certStatus?.cert?.exists && ( + + + TLS + + )} + {!editingDomain && ( + + )} +
+
+ + {editingDomain ? ( +
+
+ + setDomainInput(e.target.value)} + placeholder="central.example.com" + className="mt-1 font-mono" + /> +

+ Must resolve to this server (via internal DNS or VPN). +

+
+
+ + +
+
+ ) : ( +
+
+ {globalConfig?.cloud?.central?.domain ? ( + + {globalConfig.cloud.central.domain} + + ) : ( + Not configured + )} +
+ + {globalConfig?.cloud?.central?.domain && ( +
+ {certLoading ? ( + + ) : certStatus?.cert?.exists ? ( +
+
+ + Valid TLS certificate +
+
+ Expires in {certStatus.cert.daysLeft} days + {certStatus.cert.issuerCN && <> · {certStatus.cert.issuerCN}} +
+
+ ) : ( +
+
+ + No TLS certificate +
+ + {provisionError && ( + + + {(provisionError as Error).message} + + )} +
+ )} +
+ )} +
+ )} +
+ + {/* Verification Checklist Card */} + +
+
+ +
Token Verification
+
+ +
+ + {isVerifying && !verification ? ( +
+ + Verifying token... +
+ ) : ( +
+ {/* Token configured */} +
+ {verification?.tokenConfigured + ? + : } + Token configured +
+ + {/* Token valid */} + {verification?.tokenConfigured && ( +
+ {verification?.tokenValid + ? + : } + Token valid{verification?.tokenStatus ? ` (${verification.tokenStatus})` : ''} +
+ )} + + {/* Accessible zones */} + {verification?.tokenValid && verification.zones && verification.zones.length > 0 && ( +
+ +
+ Accessible zones: + + {verification.zones.map((zone) => ( + + {zone.name} + + ))} + +
+
+ )} + + {/* Error */} + {verification?.error && ( +
+ + {verification.error} +
+ )} +
+ )} +
+ + {/* Feature Status Card */} + +
+
+ +
Features Using This Token
+
+
+ +
+ {/* DDNS */} +
+
+ + Dynamic DNS +
+ {ddnsStatus?.enabled + ? Active + : Disabled} +
+ + {/* TLS Certificates */} +
+
+ + TLS Certificate +
+ {certStatus?.cert?.exists + ? + Valid ({certStatus.cert.daysLeft}d left) + + : certStatus?.configured + ? Not provisioned + : Not configured} +
+ + {/* ExternalDNS note */} +
+
+ + ExternalDNS +
+ Per-instance +
+
+
+
+
+ ); +} diff --git a/web/src/components/CopyButton.tsx b/web/src/components/CopyButton.tsx new file mode 100644 index 0000000..bb22d48 --- /dev/null +++ b/web/src/components/CopyButton.tsx @@ -0,0 +1,49 @@ +import { useState } from 'react'; +import { Copy, Check } from 'lucide-react'; +import { Button } from './ui/button'; + +interface CopyButtonProps { + content: string; + label?: string; + variant?: 'default' | 'outline' | 'secondary' | 'ghost'; + disabled?: boolean; +} + +export function CopyButton({ + content, + label = 'Copy', + variant = 'outline', + disabled = false, +}: CopyButtonProps) { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(content); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (error) { + console.error('Failed to copy:', error); + } + }; + + return ( + + ); +} diff --git a/web/src/components/CrowdSecComponent.tsx b/web/src/components/CrowdSecComponent.tsx new file mode 100644 index 0000000..9175d25 --- /dev/null +++ b/web/src/components/CrowdSecComponent.tsx @@ -0,0 +1,760 @@ +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 = { + '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>({}); + const [provisioningInstance, setProvisioningInstance] = useState(null); + const [blockSuccess, setBlockSuccess] = useState(null); + const [timescale, setTimescale] = useState('24h'); + + const { register, handleSubmit, reset, setValue, watch, formState: { errors } } = useForm({ + 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 ( +
+
+
+ +
+
+

CrowdSec

+

Collaborative intrusion detection — protecting your infrastructure with community threat intelligence

+
+
+ + {/* Educational card */} + + +
+ +
+

+ CrowdSec analyzes logs from your k8s clusters, detects attacks, and shares threat + intelligence with the community. Wild Central runs the LAPI — 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. +

+
+
+
+
+ + {/* LAPI Status */} + + +
+
+ + LAPI Status +
+ {isLoadingStatus ? ( + + ) : ( + + {status?.active && } + {status?.active ? 'Running' : 'Stopped'} + + )} +
+
+ + {!status?.active && !isLoadingStatus && ( +

+ CrowdSec is not running on Wild Central. Install it with{' '} + apt install crowdsec crowdsec-firewall-bouncer + {' '}or reinstall wild-cloud-central. +

+ )} + {status?.active && ( + <> +
+
+ + Perimeter firewall enforcement + (nftables on Wild Central) +
+ + {status.firewallBouncer && } + {status.firewallBouncer ? 'Active' : 'Stopped'} + +
+ {!status.firewallBouncer && ( +

+ Install the firewall bouncer to block threats at the perimeter:{' '} + apt install crowdsec-firewall-bouncer +

+ )} +

+ {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.`} +

+ + )} +
+
+ + {status?.active && ( + <> + {/* Active Protection Summary */} + + +
+ + Active Protection +
+
+ +

+ IPs currently blocked by the community blocklist (CAPI) and local decisions, enforced by your bouncers. +

+ {isLoadingSummary ? ( +
+ Loading ban statistics… +
+ ) : summary ? ( +
+
+
+ + {summary.total.toLocaleString()} +
+ IPs blocked right now +
+ {sortedReasons.length > 0 && ( +
+

By threat type

+ {sortedReasons.slice(0, 8).map(([reason, count]) => { + const pct = Math.round((count / summary.total) * 100); + return ( +
+
+
+ {scenarioHubUrl(reason) ? ( + + {friendlyReason(reason)} + + ) : ( + {friendlyReason(reason)} + )} +
+ {count.toLocaleString()} + {pct}% +
+ ); + })} + {sortedReasons.length > 8 && ( +

+{sortedReasons.length - 8} more categories

+ )} +
+ )} +
+ ) : null} + + + + {/* Detections Over Time */} + + +
+
+ + Detections Over Time +
+
+ {TIMESCALES.map(t => ( + + ))} +
+
+
+ + {isLoadingGraphAlerts ? ( +
+ Loading… +
+ ) : ( + + + + + + [v, 'detections']} + /> + + + + )} +
+
+ + {/* Recent Alert Feed */} + + +
+
+ + Recent Detections +
+ +
+
+ +

+ 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. +

+ {isLoadingAlerts ? ( +
+ Loading… +
+ ) : alerts.length === 0 ? ( +
+ +

No detections yet.

+

+ Events appear here when k8s agents detect and report attacks. The community blocklist + (CAPI) is already protecting you even before local detections. +

+
+ ) : ( +
+
+ Source IP + Threat + Agent + When +
+ {alerts.map((alert) => ( +
+
+ {alert.sourceIP || '—'} + {alert.country && ( + + {alert.country} + + )} +
+ {scenarioHubUrl(alert.scenario) ? ( + + {friendlyReason(alert.scenario)} + + ) : ( + {friendlyReason(alert.scenario)} + )} + {alert.machineId?.replace(/^wc-/, '') ?? '—'} + {formatRelativeTime(alert.createdAt)} +
+ ))} +
+ )} +
+
+ + {/* Manual Decisions: Block / Allow */} + + +
+ + Block or Allow an IP +
+
+ +

+ 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. +

+ +
+
+
+ + + {errors.ip &&

{errors.ip.message}

} +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + {blockSuccess && ( + + + {blockSuccess} + + )} + {addDecisionError && ( + + + {(addDecisionError as Error).message} + + )} + + +
+ + {/* Local decisions list */} + {decisions.length > 0 && ( +
+

Active local decisions

+
+ {decisions.map((d) => ( +
+
+ {d.type === 'allow' + ? + : + } + {d.value} + {d.type} + {friendlyReason(d.reason)} +
+
+ {d.duration && ( + {parseDuration(d.duration)} + )} + +
+
+ ))} +
+
+ )} +
+
+ + {/* Registered Agents */} + + +
+ + Registered Agents +
+
+ +

+ Agents 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. +

+ {status.machines.length === 0 ? ( +

No agents registered. Provision an instance below.

+ ) : ( +
+ {status.machines.map((m) => ( +
+
+
+ {m.machineId} + + {m.isValidated ? 'validated' : 'pending'} + +
+
+ {m.last_heartbeat && heartbeat {formatRelativeTime(m.last_heartbeat)}} + {m.version && {m.version}} + {m.ipAddress && {m.ipAddress}} +
+
+ +
+ ))} +
+ )} +
+
+ + {/* Registered Bouncers */} + + +
+ + Registered Bouncers +
+
+ +

+ Bouncers 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. +

+ {status.bouncers.length === 0 ? ( +

No bouncers registered. Provision an instance to add them.

+ ) : ( +
+ {status.bouncers.map((b) => { + const isFirewallBouncer = b.type === 'crowdsec-firewall-bouncer'; + return ( +
+
+
+ {b.name} + + {b.revoked ? 'revoked' : 'active'} + + {isFirewallBouncer && ( + + perimeter + + )} +
+

+ {isFirewallBouncer ? 'Crowdsec Firewall Bouncer' : b.type?.replace(/-/g, ' ') || ''} +

+
+ {!isFirewallBouncer && ( + + )} +
+ ); + })} +
+ )} +
+
+ + {/* Provision Instances */} + + +
+ + Provision Instances +
+
+ +

+ Provisioning 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. +

+
+ {instances.length === 0 ? ( +

No instances found.

+ ) : ( + 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 ( +
+
+ {instanceName} + +
+
+
+ Agent: + {agent ? ( + + {agent.isValidated ? 'validated' : 'pending'} + {agent.last_heartbeat && ` · ${formatRelativeTime(agent.last_heartbeat)}`} + + ) : ( + not provisioned + )} +
+
+ Bouncer: + {bouncer ? ( + + {bouncer.revoked ? 'revoked' : 'active'} + + ) : ( + not provisioned + )} +
+
+ {result && ( + + {result.ok ? : } + {result.message} + + )} +
+ ); + }) + )} +
+
+
+ + )} +
+ ); +} diff --git a/web/src/components/DdnsComponent.tsx b/web/src/components/DdnsComponent.tsx new file mode 100644 index 0000000..52e75e3 --- /dev/null +++ b/web/src/components/DdnsComponent.tsx @@ -0,0 +1,280 @@ +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Card, CardHeader, CardTitle, CardContent } from './ui/card'; +import { Button } from './ui/button'; +import { Input, Label } from './ui'; +import { Textarea } from './ui/textarea'; +import { Alert, AlertDescription } from './ui/alert'; +import { Globe, Loader2, AlertCircle, CheckCircle, XCircle, RefreshCw, Edit2 } from 'lucide-react'; +import { Badge } from './ui/badge'; +import { useConfig } from '../hooks'; +import { useDdns } from '../hooks/useDdns'; +import { secretsApi } from '../services/api'; +import type { DdnsRecordStatus } from '../services/api/networking'; + +export function DdnsComponent() { + const { config: globalConfig, updateConfig, isUpdating } = useConfig(); + const { status: ddnsStatus, isLoadingStatus: isDdnsLoading, trigger: triggerDdns, isTriggering: isDdnsTriggering } = useDdns(); + + const queryClient = useQueryClient(); + const { data: globalSecrets } = useQuery({ + queryKey: ['globalSecrets'], + queryFn: () => secretsApi.get(), + }); + const updateSecretsMutation = useMutation({ + mutationFn: (values: Record) => secretsApi.update(values), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['globalSecrets'] }); + setEditingToken(false); + setTokenValue(''); + }, + }); + const cfTokenIsSet = !!(globalSecrets as Record | undefined) + && !!(globalSecrets as { ddns?: { cloudflare?: { apiToken?: string } } })?.ddns?.cloudflare?.apiToken; + + const [editingDdnsConfig, setEditingDdnsConfig] = useState(false); + const [ddnsConfigForm, setDdnsConfigForm] = useState({ enabled: false, records: '', intervalMinutes: 5 }); + const [editingToken, setEditingToken] = useState(false); + const [tokenValue, setTokenValue] = useState(''); + + const handleDdnsConfigEdit = () => { + setDdnsConfigForm({ + enabled: globalConfig?.cloud?.ddns?.enabled ?? false, + records: (globalConfig?.cloud?.ddns?.records ?? []).join('\n'), + intervalMinutes: globalConfig?.cloud?.ddns?.intervalMinutes ?? 5, + }); + setEditingDdnsConfig(true); + }; + + const handleDdnsConfigSave = async () => { + if (!globalConfig) return; + const records = ddnsConfigForm.records.split('\n').map(r => r.trim()).filter(Boolean); + await updateConfig({ + ...globalConfig, + cloud: { + ...globalConfig.cloud, + ddns: { + enabled: ddnsConfigForm.enabled, + provider: 'cloudflare', + records, + intervalMinutes: ddnsConfigForm.intervalMinutes, + }, + }, + }); + setEditingDdnsConfig(false); + }; + + return ( +
+
+
+ +
+
+

Dynamic DNS

+

Keeps your Cloudflare A records pointed at your current public IP

+
+
+ + + +
+ Status + {isDdnsLoading ? ( + + ) : ddnsStatus?.enabled ? ( + + + Active + + ) : ( + Disabled + )} +
+
+ + {ddnsStatus?.enabled && ( +
+
+
+ Public IP +
{ddnsStatus.currentIP || '—'}
+
+
+ Last checked +
+ {ddnsStatus.lastChecked ? new Date(ddnsStatus.lastChecked).toLocaleTimeString() : '—'} +
+
+
+ {ddnsStatus.lastError && ( + + + {ddnsStatus.lastError} + + )} + +
+ )} +
+
+ + + +
+ Configuration + {!editingDdnsConfig && ( + + )} +
+
+ + {editingDdnsConfig ? ( +
+
+ setDdnsConfigForm(prev => ({ ...prev, enabled: e.target.checked }))} + /> + +
+
+ +