diff --git a/internal/api/v1/handlers.go b/internal/api/v1/handlers.go index 117379d..6c778bf 100644 --- a/internal/api/v1/handlers.go +++ b/internal/api/v1/handlers.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "syscall" "time" @@ -329,24 +330,87 @@ func (api *API) StatusHandler(w http.ResponseWriter, r *http.Request, startTime }) } -// getDaemonStatus checks whether each managed daemon is active. -func getDaemonStatus() map[string]map[string]bool { - daemons := map[string]string{ - "dnsmasq": "dnsmasq.service", - "haproxy": "haproxy.service", - "wireguard": "wg-quick@wg0.service", - "crowdsec": "crowdsec.service", +// getDaemonStatus checks whether each managed daemon is active and gets its version. +func getDaemonStatus() map[string]map[string]any { + type daemonInfo struct { + unit string + verCmd []string // command to get version + verParse func(string) string // extract version from output } - result := make(map[string]map[string]bool, len(daemons)+1) - for name, unit := range daemons { - err := exec.Command("systemctl", "is-active", "--quiet", unit).Run() - result[name] = map[string]bool{"active": err == nil} + firstWord := func(s string) string { + if i := strings.IndexByte(s, ' '); i > 0 { + return s[:i] + } + return strings.TrimSpace(s) + } + + daemons := map[string]daemonInfo{ + "dnsmasq": { + unit: "dnsmasq.service", + verCmd: []string{"dnsmasq", "--version"}, + verParse: func(out string) string { + // "Dnsmasq version 2.90 ..." + if i := strings.Index(out, "version "); i >= 0 { + return firstWord(out[i+8:]) + } + return "" + }, + }, + "haproxy": { + unit: "haproxy.service", + verCmd: []string{"haproxy", "-v"}, + verParse: func(out string) string { + // "HAProxy version 2.8.5-1 ..." + if i := strings.Index(out, "version "); i >= 0 { + return firstWord(out[i+8:]) + } + return "" + }, + }, + "wireguard": { + unit: "wg-quick@wg0.service", + verCmd: []string{"wg", "--version"}, + verParse: func(out string) string { + // "wireguard-tools v1.0.20210914 - ..." + if i := strings.Index(out, " v"); i >= 0 { + return firstWord(out[i+1:]) + } + return "" + }, + }, + "crowdsec": { + unit: "crowdsec.service", + verCmd: []string{"cscli", "version", "--raw"}, + verParse: func(out string) string { + return firstWord(out) + }, + }, + } + + result := make(map[string]map[string]any, len(daemons)+1) + for name, info := range daemons { + err := exec.Command("systemctl", "is-active", "--quiet", info.unit).Run() + entry := map[string]any{"active": err == nil} + if out, verErr := exec.Command(info.verCmd[0], info.verCmd[1:]...).Output(); verErr == nil { + if v := info.verParse(string(out)); v != "" { + entry["version"] = v + } + } + result[name] = entry } // nftables has no persistent service — check if the wild-cloud table exists - err := exec.Command("sudo", "nft", "list", "table", "inet", "wild-cloud").Run() - result["nftables"] = map[string]bool{"active": err == nil} + nftErr := exec.Command("sudo", "nft", "list", "table", "inet", "wild-cloud").Run() + nftEntry := map[string]any{"active": nftErr == nil} + if out, verErr := exec.Command("nft", "--version").Output(); verErr == nil { + // "nftables v1.0.9 (Old Doc Yak #3)" + s := string(out) + if i := strings.Index(s, " v"); i >= 0 { + nftEntry["version"] = firstWord(s[i+1:]) + } + } + result["nftables"] = nftEntry return result } diff --git a/internal/api/v1/handlers_cloudflare.go b/internal/api/v1/handlers_cloudflare.go index 920a0c1..e01b4ca 100644 --- a/internal/api/v1/handlers_cloudflare.go +++ b/internal/api/v1/handlers_cloudflare.go @@ -2,7 +2,9 @@ package v1 import ( "encoding/json" + "fmt" "net/http" + "strings" ) type cloudflareZone struct { @@ -10,12 +12,19 @@ type cloudflareZone struct { Name string `json:"name"` } +type cloudflarePermissions struct { + Zone bool `json:"zone"` // can list zones + DNS bool `json:"dns"` // can read/edit DNS records +} + type cloudflareVerifyResponse struct { - TokenConfigured bool `json:"tokenConfigured"` - TokenValid bool `json:"tokenValid"` - TokenStatus string `json:"tokenStatus"` - Zones []cloudflareZone `json:"zones"` - Error string `json:"error,omitempty"` + TokenConfigured bool `json:"tokenConfigured"` + TokenValid bool `json:"tokenValid"` + TokenStatus string `json:"tokenStatus"` + Zones []cloudflareZone `json:"zones"` + RequiredZones []string `json:"requiredZones"` + Permissions cloudflarePermissions `json:"permissions"` + Error string `json:"error,omitempty"` } // CloudflareVerify checks whether the stored Cloudflare API token is valid @@ -47,6 +56,15 @@ func (api *API) CloudflareVerify(w http.ResponseWriter, r *http.Request) { return } resp.Zones = zones + resp.Permissions.Zone = true + + // Test DNS access on the first available zone + if len(zones) > 0 { + resp.Permissions.DNS = testCloudflareDNSAccess(token, zones[0].ID) + } + + // Derive required zones from registered domains + resp.RequiredZones = api.getRequiredZones() respondJSON(w, http.StatusOK, resp) } @@ -78,6 +96,36 @@ func (api *API) CloudflareSetZoneID(w http.ResponseWriter, r *http.Request) { respondMessage(w, http.StatusOK, "Zone ID saved") } +// getRequiredZones extracts unique root zones from all registered domains. +// e.g., "app.cloud.payne.io" → "payne.io" +func (api *API) getRequiredZones() []string { + doms, err := api.domains.List() + if err != nil || len(doms) == 0 { + return nil + } + + seen := make(map[string]bool) + var zones []string + for _, dom := range doms { + zone := rootZone(dom.DomainName) + if zone != "" && !seen[zone] { + seen[zone] = true + zones = append(zones, zone) + } + } + return zones +} + +// rootZone extracts the registrable zone from a FQDN. +// "app.cloud.payne.io" → "payne.io", "foo.example.com" → "example.com" +func rootZone(domain string) string { + parts := strings.Split(domain, ".") + if len(parts) < 2 { + return "" + } + return strings.Join(parts[len(parts)-2:], ".") +} + func verifyCloudflareToken(apiToken string) (string, error) { req, err := http.NewRequest(http.MethodGet, "https://api.cloudflare.com/client/v4/user/tokens/verify", nil) @@ -103,6 +151,24 @@ func verifyCloudflareToken(apiToken string) (string, error) { return result.Result.Status, nil } +// testCloudflareDNSAccess checks if the token can list DNS records for a zone. +func testCloudflareDNSAccess(apiToken, zoneID string) bool { + req, err := http.NewRequest(http.MethodGet, + fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records?per_page=1", zoneID), nil) + if err != nil { + return false + } + req.Header.Set("Authorization", "Bearer "+apiToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return false + } + defer resp.Body.Close() + + return resp.StatusCode == http.StatusOK +} + func listCloudflareZones(apiToken string) ([]cloudflareZone, error) { req, err := http.NewRequest(http.MethodGet, "https://api.cloudflare.com/client/v4/zones", nil) diff --git a/web/src/components/AppSidebar.tsx b/web/src/components/AppSidebar.tsx index 0398dfe..15c4a87 100644 --- a/web/src/components/AppSidebar.tsx +++ b/web/src/components/AppSidebar.tsx @@ -1,5 +1,7 @@ +import { useState, useEffect } from 'react'; import { NavLink } from 'react-router'; -import { Sun, Moon, Monitor, Shield, Lock, ShieldAlert, Wifi, LayoutDashboard, Globe, CloudLightning, Network } from 'lucide-react'; +import { Sun, Moon, Monitor, Shield, Lock, ShieldAlert, Wifi, LayoutDashboard, Globe, CloudLightning, Network, ChevronRight } from 'lucide-react'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible'; import { Sidebar, SidebarContent, @@ -17,10 +19,17 @@ import { import { useTheme } from '../contexts/ThemeContext'; import { useCentralStatus } from '../hooks/useCentralStatus'; +const ADVANCED_OPEN_KEY = 'wild-central:advanced-open'; + export function AppSidebar() { const { theme, setTheme } = useTheme(); const { state } = useSidebar(); const { data: centralStatus } = useCentralStatus(); + const [advancedOpen, setAdvancedOpen] = useState(() => localStorage.getItem(ADVANCED_OPEN_KEY) === 'true'); + + useEffect(() => { + localStorage.setItem(ADVANCED_OPEN_KEY, String(advancedOpen)); + }, [advancedOpen]); const cycleTheme = () => { if (theme === 'light') { @@ -158,31 +167,40 @@ export function AppSidebar() { - - Advanced - - - {advancedItems.map(({ to, icon: Icon, label, daemon }) => { - const active = centralStatus?.daemons?.[daemon]?.active; - return ( - - - {({ isActive }) => ( - - - {label} - {active !== undefined && ( - + + + + + Advanced + + + + + + + {advancedItems.map(({ to, icon: Icon, label, daemon }) => { + const active = centralStatus?.daemons?.[daemon]?.active; + return ( + + + {({ isActive }) => ( + + + {label} + {active !== undefined && ( + + )} + )} - - )} - - - ); - })} - - - + + + ); + })} + + + + + )} diff --git a/web/src/components/CrowdSecComponent.tsx b/web/src/components/CrowdSecComponent.tsx index 0a17f7e..56d3bd5 100644 --- a/web/src/components/CrowdSecComponent.tsx +++ b/web/src/components/CrowdSecComponent.tsx @@ -82,8 +82,12 @@ function titleCaseScenario(s: string): string { ).join(' '); } -function friendlyReason(reason: string): string { - if (!reason) return 'Community blocklist'; +function friendlyScenario(scenario: string, origin?: string): string { + if (!scenario) { + if (origin === 'capi') return 'Community blocklist'; + if (origin === 'manual' || origin === 'cscli') return 'Manual'; + return origin || 'Unknown'; + } const map: Record = { 'crowdsecurity/http-scan-classb': 'HTTP Scanner', 'crowdsecurity/ssh-slow-bruteforce': 'SSH Brute Force (slow)', @@ -96,8 +100,8 @@ function friendlyReason(reason: string): string { '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; + if (map[scenario]) return map[scenario]; + const short = scenario.includes('/') ? scenario.split('/').slice(1).join(' ') : scenario; return titleCaseScenario(short); } @@ -294,10 +298,10 @@ export function CrowdSecComponent() { /> {scenarioHubUrl(reason) ? ( - {friendlyReason(reason)} + {friendlyScenario(reason)} ) : ( - {friendlyReason(reason)} + {friendlyScenario(reason)} )} {count.toLocaleString()} @@ -411,10 +415,10 @@ export function CrowdSecComponent() { {scenarioHubUrl(alert.scenario) ? ( - {friendlyReason(alert.scenario)} + {friendlyScenario(alert.scenario)} ) : ( - {friendlyReason(alert.scenario)} + {friendlyScenario(alert.scenario)} )} {alert.machineId?.replace(/^wc-/, '') ?? '—'} {formatRelativeTime(alert.createdAt)} @@ -528,7 +532,7 @@ export function CrowdSecComponent() { } {d.value} {d.type} - {friendlyReason(d.reason)} + {friendlyScenario(d.scenario, d.origin)}
{d.duration && ( diff --git a/web/src/components/DashboardComponent.tsx b/web/src/components/DashboardComponent.tsx index a015816..671369a 100644 --- a/web/src/components/DashboardComponent.tsx +++ b/web/src/components/DashboardComponent.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Card } from './ui/card'; import { Button } from './ui/button'; @@ -7,7 +7,8 @@ import { Alert, AlertDescription } from './ui/alert'; import { Badge } from './ui/badge'; import { LayoutDashboard, Cloud, CheckCircle, XCircle, AlertCircle, Loader2, - BookOpen, Globe, Router, Server, RotateCw, Key, + BookOpen, Globe, Router, RotateCw, Key, + Shield, Eye, ArrowLeftRight, Lock, } from 'lucide-react'; import { useCloudflare } from '../hooks/useCloudflare'; import { useCentralStatus } from '../hooks/useCentralStatus'; @@ -15,6 +16,45 @@ import { useVpn } from '../hooks/useVpn'; import { secretsApi, dnsSettingsApi, nftablesConfigApi } from '../services/api'; import { usePageHelp } from '../hooks/usePageHelp'; +const SERVICES = [ + { key: 'dnsmasq', label: 'DNS / DHCP', icon: Globe }, + { key: 'haproxy', label: 'HAProxy', icon: ArrowLeftRight }, + { key: 'nftables', label: 'Firewall', icon: Shield }, + { key: 'wireguard', label: 'VPN', icon: Lock }, + { key: 'crowdsec', label: 'CrowdSec', icon: Eye }, +]; + +function formatUptime(totalSeconds: number): string { + const days = Math.floor(totalSeconds / 86400); + const hours = Math.floor((totalSeconds % 86400) / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + if (days > 0) return `${days}d ${hours}h ${minutes}m`; + if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`; + if (minutes > 0) return `${minutes}m ${seconds}s`; + return `${seconds}s`; +} + +function useLiveUptime(serverSeconds: number | undefined): string { + const [elapsed, setElapsed] = useState(0); + const baseRef = useRef(serverSeconds ?? 0); + + useEffect(() => { + if (serverSeconds === undefined) return; + baseRef.current = serverSeconds; + setElapsed(0); + }, [serverSeconds]); + + useEffect(() => { + if (serverSeconds === undefined) return; + const id = setInterval(() => setElapsed(e => e + 1), 1000); + return () => clearInterval(id); + }, [serverSeconds]); + + return formatUptime(baseRef.current + elapsed); +} + export function DashboardComponent() { const { data: centralStatus, isLoading: statusLoading, restart: restartDaemon, isRestarting } = useCentralStatus(); const { verification, isLoading: cfLoading } = useCloudflare(); @@ -44,11 +84,22 @@ export function DashboardComponent() { }, }); - // Warning conditions const cfTokenMissing = !cfLoading && !verification?.tokenConfigured; const cfTokenInvalid = !cfLoading && verification?.tokenConfigured && !verification?.tokenValid; const hasWarnings = cfTokenMissing || cfTokenInvalid; + const daemons = (centralStatus?.daemons ?? {}) as Record; + + const uptime = useLiveUptime(centralStatus?.uptimeSeconds); + + const ports = [ + { port: 443, proto: 'TCP', label: 'HTTPS' }, + { port: 80, proto: 'TCP', label: 'HTTP' }, + ...(vpnConfig?.enabled ? [{ port: vpnConfig.listenPort || 51820, proto: 'UDP', label: 'VPN' }] : []), + ...((nftConfig?.extraPorts ?? []) as { port: number; protocol?: string; label?: string }[]) + .map(ep => ({ port: ep.port, proto: (ep.protocol || 'tcp').toUpperCase(), label: ep.label || '' })), + ]; + usePageHelp({ title: 'What is the Dashboard?', description: ( @@ -69,15 +120,36 @@ export function DashboardComponent() { return (
- {/* Page Header */} -
-
- -
-
-

Dashboard

-

Infrastructure health and service prerequisites

+ {/* Page Header with system metadata */} +
+
+
+ +
+
+

Dashboard

+

System health overview

+
+ {!statusLoading && centralStatus ? ( +
+ {centralStatus.version} + | + up {uptime} + +
+ ) : !statusLoading ? ( + Offline + ) : null}
{/* Warnings */} @@ -98,230 +170,273 @@ export function DashboardComponent() {
)} - {/* 1. Cloudflare */} - -
-
- -
Cloudflare
-
- {cfLoading ? ( - - ) : verification?.tokenValid ? ( - - - Connected - - ) : verification?.tokenConfigured ? ( - - - Invalid - - ) : ( - Not configured - )} + {/* Services */} +
+
Services
+
+ {SERVICES.map((svc) => ( + + ))}
+
- {cfLoading ? ( -
- - Checking token... -
- ) : verification?.tokenValid ? ( -
- {verification.zones && verification.zones.length > 0 && ( -
- -
- Zones: - - {verification.zones.map((zone) => ( - - {zone.name} - - ))} - + {/* Configuration */} +
+
Configuration
+
+ {/* Cloudflare */} + +
+
+
+
+
Cloudflare
- )} - {!editingToken ? ( - - ) : ( -
- setTokenValue(e.target.value)} + {cfLoading ? ( + + ) : verification?.tokenValid ? ( + + + Connected + + ) : verification?.tokenConfigured ? ( + + + Invalid + + ) : ( + Not configured + )} +
+ + {cfLoading ? ( +
+ + Verifying... +
+ ) : verification?.tokenValid ? ( +
+
+ + +
+ -
- - -
-
- )} -
- ) : ( -
- {!editingToken ? ( -
-

- {verification?.tokenConfigured - ? `Token invalid${verification?.tokenStatus ? ` (${verification.tokenStatus})` : ''}` - : 'An API token is required for DNS and TLS features'} -

- + ) : ( +
+ setTokenValue(e.target.value)} + /> +
+ + +
+
+ )}
) : ( -
- setTokenValue(e.target.value)} - /> -
- - -
- {updateSecretsMutation.isError && ( -

{String(updateSecretsMutation.error)}

+
+ {!editingToken ? ( +
+

+ {verification?.tokenConfigured + ? `Token invalid${verification?.tokenStatus ? ` (${verification.tokenStatus})` : ''}` + : 'Required for DNS and TLS'} +

+ +
+ ) : ( +
+ setTokenValue(e.target.value)} + /> +
+ + +
+ {updateSecretsMutation.isError && ( +

{String(updateSecretsMutation.error)}

+ )} +
)}
)} -
- )} - + - {/* 2. Daemon Status */} - -
-
- -
Daemon Status
-
- {statusLoading ? ( - - ) : centralStatus ? ( - - - Running - - ) : ( - Offline - )} -
- - {statusLoading ? ( -
- - Loading status... -
- ) : centralStatus ? ( -
- {/* Service badges */} -
- - - - - -
- - {/* Version and uptime */} -
-
- Version -
{centralStatus.version || 'Unknown'}
+ {/* Router */} + +
+
+
- Uptime -
{centralStatus.uptime || '--'}
+
Router
+
LAN setup checklist
- - -
- ) : ( -

Cannot reach the Wild Central daemon.

- )} - - - {/* 3. Gateway Router */} - -
- -
Gateway Router
+
+

Configure your LAN router so Wild Central can serve traffic:

+
    +
  1. + Set the router's DNS server to Wild Central + {dnsSettings?.ip && ( + {dnsSettings.ip} + )} +
  2. +
  3. + Forward these ports to Wild Central: +
    + {ports.map((p, i) => ( + + {p.port}/{p.proto} + {p.label && ({p.label})} + + ))} +
    +
  4. +
+
+
-
-

Your LAN router should be configured to work with Wild Central:

-
    -
  • - Set the router's DNS server to Wild Central - {dnsSettings?.ip && ( - - {dnsSettings.ip} - - )} -
  • -
  • - Port-forward to Wild Central:{' '} - {[ - { port: 443, proto: 'TCP', label: 'HTTPS' }, - { port: 80, proto: 'TCP', label: 'HTTP' }, - ...(vpnConfig?.enabled ? [{ port: vpnConfig.listenPort || 51820, proto: 'UDP', label: 'VPN' }] : []), - ...((nftConfig?.extraPorts ?? []) as { port: number; protocol?: string; label?: string }[]) - .map(ep => ({ port: ep.port, proto: (ep.protocol || 'tcp').toUpperCase(), label: ep.label || '' })), - ].map((p, i) => ( - - {i > 0 && ', '} - {p.port}/{p.proto} - {p.label && ({p.label})} - - ))} -
  • -
-
-
+
); } -/** Inline badge showing a service name with a colored dot reflecting actual daemon status. */ -function ServiceBadge({ name, active }: { name: string; active?: boolean }) { - const dotColor = active === undefined ? 'bg-gray-400' : active ? 'bg-green-500' : 'bg-red-500'; +function ZoneCoverage({ available, required }: { + available: { id: string; name: string }[] | null; + required: string[] | null; +}) { + const availableNames = new Set((available ?? []).map(z => z.name)); + const requiredNames = required ?? []; + + // Merge: all required zones (checked/unchecked) + any extra available zones not in required + const allZones: { name: string; covered: boolean; needed: boolean }[] = []; + for (const name of requiredNames) { + allZones.push({ name, covered: availableNames.has(name), needed: true }); + } + for (const z of available ?? []) { + if (!requiredNames.includes(z.name)) { + allZones.push({ name: z.name, covered: true, needed: false }); + } + } + + if (allZones.length === 0) return null; + return ( - - - {name} - +
+
Zones
+
+ {allZones.map((z) => ( +
+ {z.needed ? ( + z.covered ? ( + + ) : ( + + ) + ) : ( + + )} + {z.name} + {!z.covered && z.needed && ( + not covered + )} + {!z.needed && ( + unused + )} +
+ ))} +
+
+ ); +} + +function PermissionRow({ label, description, ok }: { label: string; description: string; ok?: boolean }) { + return ( +
+ {ok ? ( + + ) : ( + + )} + {label} + {description} +
+ ); +} + +function ServiceCard({ label, daemon, icon: Icon, active, version, loading }: { + label: string; + daemon: string; + icon: typeof Globe; + active?: boolean; + version?: string; + loading: boolean; +}) { + return ( + +
+
+ {loading ? ( + + ) : ( + + )} +
+
+
{label}
+
{daemon}
+ {version &&
{version}
} +
+
+
); } diff --git a/web/src/components/FirewallComponent.tsx b/web/src/components/FirewallComponent.tsx index 3137036..d70ba35 100644 --- a/web/src/components/FirewallComponent.tsx +++ b/web/src/components/FirewallComponent.tsx @@ -386,6 +386,16 @@ export function FirewallComponent() { permissive (safe default while you're getting started).

+ {currentWan && !currentExtraPorts.some((p) => p.port === 22) && ( + + + + Strict mode is active but SSH (port 22) is not in your allowed ports. + You may lose remote access if you don't have another way in. + + + )} + {!editingWan ? (
{currentWan ? ( diff --git a/web/src/hooks/useCentralStatus.ts b/web/src/hooks/useCentralStatus.ts index 006eed2..736f556 100644 --- a/web/src/hooks/useCentralStatus.ts +++ b/web/src/hooks/useCentralStatus.ts @@ -3,6 +3,7 @@ import { apiClient } from '../services/api/client'; interface DaemonStatus { active: boolean; + version?: string; } interface CentralStatus { diff --git a/web/src/services/api/cloudflare.ts b/web/src/services/api/cloudflare.ts index 0e3589d..c1e2350 100644 --- a/web/src/services/api/cloudflare.ts +++ b/web/src/services/api/cloudflare.ts @@ -5,11 +5,18 @@ export interface CloudflareZone { name: string; } +export interface CloudflarePermissions { + zone: boolean; + dns: boolean; +} + export interface CloudflareVerifyResponse { tokenConfigured: boolean; tokenValid: boolean; tokenStatus: string; zones: CloudflareZone[] | null; + requiredZones: string[] | null; + permissions: CloudflarePermissions; error?: string; } diff --git a/web/src/services/api/networking.ts b/web/src/services/api/networking.ts index 3f9284d..1ae80f2 100644 --- a/web/src/services/api/networking.ts +++ b/web/src/services/api/networking.ts @@ -203,7 +203,7 @@ export interface CrowdSecDecision { value: string; type: string; scope: string; - reason: string; + scenario: string; origin: string; duration?: string; }