Files
edgeguard-native/management-ui/src/App.tsx
Debian bac7c7e349 feat(dhcp): DHCPv4-Server via Kea (kea-dhcp4-server) — v1.2.92
Verwalteter DHCPv4-Server analog Unbound/Squid/Chrony.
- Migration 0041: dhcp_settings (singleton, node-lokal), dhcp_subnets, dhcp_reservations.
- internal/kea: Renderer baut Kea-JSON via Go-Struct→Marshal (garantiert valide), managed /etc/edgeguard/kea/kea-dhcp4.conf (Symlink von /etc/kea), Service-Lifecycle an enabled gekoppelt (default AUS, kein rogue DHCP). Interface per NAME (cluster-sicher, kein node-lokaler FK).
- internal/services/dhcp + internal/handlers/dhcp.go: Settings + Subnet/Reservation-CRUD, Validierung (CIDR/IP/MAC/interface exists).
- configgen: Stop/Enable/DisableService. Firewall: AutoFWRule.Iface → udp/67 pro LAN-Interface gescopt (kein WAN). Cluster: subnets/reservations repliziert (hashSpec), dhcp_settings node-lokal (localOnlyTables).
- main.go + render.go + WithAllReloaders Wiring. Packaging: kea-dhcp4-server Dependency, /etc/edgeguard/kea Dir, Symlink, disable-on-install, sudoers (restart/stop/enable/disable).
- UI: DHCP-Seite (Settings + Subnets + Reservierungen pro Subnet), Route/Nav/i18n de/en, HA-Warnung 'nur auf einer Node aktivieren'.
- Tests (guarded EG_FWTEST_DSN): Kea-Renderer gegen DB (valides JSON + Felder), FW-Auto-Rule-Iface inkl. nft -c. Scope v1: DHCPv4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 18:56:30 +02:00

197 lines
8.4 KiB
TypeScript

import { Suspense, lazy, useEffect, useState, type ReactNode } from 'react'
import { BrowserRouter, Navigate, Route, Routes, useLocation } from 'react-router-dom'
import ErrorBoundary from './components/ErrorBoundary'
import { ConfigProvider, Spin } from 'antd'
import deDE from 'antd/locale/de_DE'
import enUS from 'antd/locale/en_US'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import AppLayout from './components/Layout/AppLayout'
import apiClient, { isEnvelope } from './api/client'
import { useAuthStore, type SessionUser } from './stores/auth'
const LoginPage = lazy(() => import('./pages/Login'))
const ResetPasswordPage = lazy(() => import('./pages/ResetPassword'))
const SetupPage = lazy(() => import('./pages/Setup'))
const DashboardPage = lazy(() => import('./pages/Dashboard'))
const DomainsPage = lazy(() => import('./pages/Domains'))
const DomainDetailPage = lazy(() => import('./pages/Domains/Detail'))
const BackendsPage = lazy(() => import('./pages/Backends'))
const BackendDetailPage = lazy(() => import('./pages/Backends/Detail'))
const RoutingRulesPage = lazy(() => import('./pages/RoutingRules'))
const NetworksPage = lazy(() => import('./pages/Networks'))
const IPAddressesPage = lazy(() => import('./pages/IPAddresses'))
const SSLPage = lazy(() => import('./pages/SSL'))
const FirewallPage = lazy(() => import('./pages/Firewall'))
const WireguardPage = lazy(() => import('./pages/Wireguard'))
const ForwardProxyPage = lazy(() => import('./pages/ForwardProxy'))
const DNSPage = lazy(() => import('./pages/DNS'))
const DHCPPage = lazy(() => import('./pages/DHCP'))
const NTPPage = lazy(() => import('./pages/NTP'))
const ClusterPage = lazy(() => import('./pages/Cluster'))
const FirewallLivePage = lazy(() => import('./pages/FirewallLive'))
const LogsPage = lazy(() => import('./pages/Logs'))
const AuditPage = lazy(() => import('./pages/Audit'))
const BackupsPage = lazy(() => import('./pages/Backups'))
const DiagnosticsPage = lazy(() => import('./pages/Diagnostics'))
const AlertsPage = lazy(() => import('./pages/Alerts'))
const LicensePage = lazy(() => import('./pages/License'))
const SettingsPage = lazy(() => import('./pages/Settings'))
const UsersPage = lazy(() => import('./pages/Users'))
const CrowdSecPage = lazy(() => import('./pages/CrowdSec'))
const WAFPage = lazy(() => import('./pages/WAF'))
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: 1, refetchOnWindowFocus: false },
},
})
// Theme tokens 1:1 wie mail-gateway/enconf — Body-Density bekommen
// wir über AntD `size="small"` auf den Tables (DataTable hat das per
// default), nicht über kleinere Theme-Tokens.
const antdTheme = {
token: {
colorPrimary: '#0EA5E9',
borderRadius: 6,
borderRadiusLG: 8,
fontSize: 13,
fontWeightStrong: 600,
fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
colorBgContainer: '#FFFFFF',
colorBgLayout: '#F8FAFC',
colorBorder: '#E2E8F0',
colorText: '#0F172A',
colorTextSecondary: '#64748B',
controlHeight: 34,
},
components: {
Tabs: {
itemColor: '#334155',
itemHoverColor: '#0F172A',
itemSelectedColor: '#0EA5E9',
inkBarColor: '#0EA5E9',
cardBg: '#F1F5F9',
titleFontSize: 13,
},
},
}
function RequireAuth({ children }: { children: ReactNode }) {
const user = useAuthStore((s) => s.user)
const setUser = useAuthStore((s) => s.set)
const location = useLocation()
// Wenn kein Store-User da ist (z.B. direkt nach SSO-Callback: Cookie
// gesetzt, sessionStorage leer — oder Hard-Refresh), einmal /auth/me
// probieren, bevor wir nach /login umleiten.
const [checking, setChecking] = useState(user === null)
useEffect(() => {
if (user !== null) {
setChecking(false)
return
}
let cancelled = false
apiClient.get('/auth/me')
.then((r) => {
if (!cancelled && isEnvelope(r.data)) setUser(r.data.data as SessionUser)
})
.catch(() => { /* 401 → Interceptor leitet auf /login */ })
.finally(() => { if (!cancelled) setChecking(false) })
return () => { cancelled = true }
}, [user, setUser])
if (user) return <>{children}</>
if (checking) {
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Spin size="large" />
</div>
)
}
return <Navigate to="/login" replace state={{ from: location }} />
}
function SetupGate({ children }: { children: ReactNode }) {
const location = useLocation()
useEffect(() => {
if (location.pathname.startsWith('/setup')) return
apiClient.get('/setup/status').then((r) => {
const env = r.data
if (isEnvelope(env)) {
const data = env.data as { completed?: boolean } | undefined
if (data && data.completed === false) {
window.location.replace('/setup')
}
}
}).catch(() => { /* setup-gate is best-effort */ })
}, [location.pathname])
return <>{children}</>
}
export default function App() {
const { i18n } = useTranslation()
const antdLocale = i18n.language?.startsWith('de') ? deDE : enUS
return (
<ConfigProvider theme={antdTheme} locale={antdLocale}>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<SetupGate>
<LocationKeyBoundary>
<Suspense fallback={<div className="loader-center"><Spin size="large" /></div>}>
<Routes>
<Route path="/setup" element={<SetupPage onComplete={(u: SessionUser) => useAuthStore.getState().set(u)} />} />
<Route path="/login" element={<LoginPage onLogin={(u: SessionUser) => useAuthStore.getState().set(u)} />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
<Route element={<RequireAuth><AppLayout /></RequireAuth>}>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/domains" element={<DomainsPage />} />
<Route path="/domains/:id" element={<DomainDetailPage />} />
<Route path="/backends" element={<BackendsPage />} />
<Route path="/backends/:id" element={<BackendDetailPage />} />
<Route path="/routing-rules" element={<RoutingRulesPage />} />
<Route path="/networks" element={<NetworksPage />} />
<Route path="/ip-addresses" element={<IPAddressesPage />} />
<Route path="/ssl" element={<SSLPage />} />
<Route path="/firewall" element={<FirewallPage />} />
<Route path="/firewall/live" element={<FirewallLivePage />} />
<Route path="/vpn/wireguard" element={<WireguardPage />} />
<Route path="/forward-proxy" element={<ForwardProxyPage />} />
<Route path="/dns" element={<DNSPage />} />
<Route path="/dhcp" element={<DHCPPage />} />
<Route path="/ntp" element={<NTPPage />} />
<Route path="/cluster" element={<ClusterPage />} />
<Route path="/logs" element={<LogsPage />} />
<Route path="/audit" element={<AuditPage />} />
<Route path="/backups" element={<BackupsPage />} />
<Route path="/diagnostics" element={<DiagnosticsPage />} />
<Route path="/alerts" element={<AlertsPage />} />
<Route path="/license" element={<LicensePage />} />
<Route path="/users" element={<UsersPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/crowdsec" element={<CrowdSecPage />} />
<Route path="/waf" element={<WAFPage />} />
</Route>
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</Suspense>
</LocationKeyBoundary>
</SetupGate>
</BrowserRouter>
</QueryClientProvider>
</ConfigProvider>
)
}
// Resets the ErrorBoundary on every route change so a render error on
// one page never permanently blocks navigation to another page.
function LocationKeyBoundary({ children }: { children: ReactNode }) {
const { pathname } = useLocation()
return <ErrorBoundary key={pathname}>{children}</ErrorBoundary>
}