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>
197 lines
8.4 KiB
TypeScript
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>
|
|
}
|