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>
This commit is contained in:
Debian
2026-06-05 18:56:30 +02:00
parent 3a707e2e3f
commit bac7c7e349
24 changed files with 1666 additions and 6 deletions

View File

@@ -27,6 +27,7 @@ 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'))
@@ -161,6 +162,7 @@ export default function App() {
<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 />} />

View File

@@ -19,6 +19,7 @@ const PAGE_TITLES: Record<string, string> = {
'/ip-addresses': 'nav.ipAddresses',
'/ssl': 'nav.ssl',
'/dns': 'nav.dns',
'/dhcp': 'nav.dhcp',
'/ntp': 'nav.ntp',
'/vpn/wireguard': 'nav.wireguard',
'/forward-proxy': 'nav.forwardProxy',

View File

@@ -67,6 +67,7 @@ const NAV: NavSection[] = [
{ path: '/ip-addresses', labelKey: 'nav.ipAddresses', icon: <NodeIndexOutlined /> },
{ path: '/ssl', labelKey: 'nav.ssl', icon: <SafetyCertificateOutlined /> },
{ path: '/dns', labelKey: 'nav.dns', icon: <GlobalOutlined /> },
{ path: '/dhcp', labelKey: 'nav.dhcp', icon: <CloudServerOutlined /> },
{ path: '/ntp', labelKey: 'nav.ntp', icon: <ClockCircleOutlined /> },
],
},

View File

@@ -15,6 +15,7 @@
"wireguard": "WireGuard",
"forwardProxy": "Forward-Proxy",
"dns": "DNS",
"dhcp": "DHCP",
"ntp": "Zeit (NTP)",
"firewall": "Firewall",
"firewallLive": "Firewall-Log",
@@ -1075,6 +1076,52 @@
"failed": "Import fehlgeschlagen"
}
},
"dhcp": {
"title": "DHCP (Kea)",
"intro": "DHCPv4-Server: globale Optionen, pro-Subnet-Pools und statische Reservierungen.",
"csvHint": "Komma-getrennt.",
"saved": "Gespeichert",
"saveFailed": "Speichern fehlgeschlagen",
"deleted": "Gelöscht",
"deleteFailed": "Löschen fehlgeschlagen",
"reservations": "Reservierungen",
"haWarnTitle": "Nur auf EINER Node aktivieren",
"haWarnDesc": "Zwei DHCP-Server im selben LAN führen zu Adresskonflikten. Diese Einstellung ist node-lokal — aktiviere DHCP nur auf einer Cluster-Node.",
"tabs": {
"settings": "Einstellungen",
"subnets": "Subnets"
},
"settings": {
"enabled": "DHCP auf dieser Node aktiv",
"defaultLease": "Standard-Lease-Zeit (Sekunden)",
"maxLease": "Max-Lease-Zeit (Sekunden)",
"domainName": "Domain-Name",
"dnsServers": "DNS-Server (global)"
},
"subnet": {
"name": "Name",
"interface": "Interface",
"cidr": "Subnetz (CIDR)",
"pool": "Pool",
"poolStart": "Pool-Start",
"poolEnd": "Pool-Ende",
"gateway": "Gateway",
"dnsServers": "DNS-Server",
"active": "Aktiv",
"add": "Subnet hinzufügen",
"edit": "Subnet bearbeiten",
"deleteConfirm": "Subnet \"{{name}}\" wirklich löschen?"
},
"reservation": {
"name": "Name",
"mac": "MAC-Adresse",
"ip": "IP-Adresse",
"hostname": "Hostname",
"add": "Reservierung hinzufügen",
"edit": "Reservierung bearbeiten",
"deleteConfirm": "Reservierung wirklich löschen?"
}
},
"ntp": {
"title": "Zeitserver (Chrony)",
"intro": "Chrony als Time-Sync-Daemon (NTP). Quellen oben, Listen-/Serve-Konfig im Settings-Tab. Wenn 'serve_clients' aktiv und LAN-IPs gebound sind, wird die Box selbst zum NTP-Server für das LAN.",

View File

@@ -15,6 +15,7 @@
"wireguard": "WireGuard",
"forwardProxy": "Forward proxy",
"dns": "DNS",
"dhcp": "DHCP",
"ntp": "Time (NTP)",
"firewall": "Firewall",
"firewallLive": "Firewall log",
@@ -1075,6 +1076,52 @@
"failed": "Import failed"
}
},
"dhcp": {
"title": "DHCP (Kea)",
"intro": "DHCPv4 server: global options, per-subnet pools and static reservations.",
"csvHint": "Comma-separated.",
"saved": "Saved",
"saveFailed": "Save failed",
"deleted": "Deleted",
"deleteFailed": "Delete failed",
"reservations": "Reservations",
"haWarnTitle": "Enable on ONE node only",
"haWarnDesc": "Two DHCP servers on the same LAN cause address conflicts. This setting is node-local — enable DHCP on a single cluster node only.",
"tabs": {
"settings": "Settings",
"subnets": "Subnets"
},
"settings": {
"enabled": "DHCP active on this node",
"defaultLease": "Default lease time (seconds)",
"maxLease": "Max lease time (seconds)",
"domainName": "Domain name",
"dnsServers": "DNS servers (global)"
},
"subnet": {
"name": "Name",
"interface": "Interface",
"cidr": "Subnet (CIDR)",
"pool": "Pool",
"poolStart": "Pool start",
"poolEnd": "Pool end",
"gateway": "Gateway",
"dnsServers": "DNS servers",
"active": "Active",
"add": "Add subnet",
"edit": "Edit subnet",
"deleteConfirm": "Really delete subnet \"{{name}}\"?"
},
"reservation": {
"name": "Name",
"mac": "MAC address",
"ip": "IP address",
"hostname": "Hostname",
"add": "Add reservation",
"edit": "Edit reservation",
"deleteConfirm": "Really delete this reservation?"
}
},
"ntp": {
"title": "Time server (Chrony)",
"intro": "Chrony as time-sync daemon (NTP). Sources on top, listen/serve config on the settings tab. With 'serve_clients' on and LAN-IPs bound, the box itself becomes an NTP server for the LAN.",

View File

@@ -0,0 +1,385 @@
import { useEffect, useState } from 'react'
import {
Alert, Button, Card, Form, Input, InputNumber, Modal, Popconfirm, Space,
Switch, Table, Tabs, Tag, Tooltip, Typography, message, Select,
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { CloudServerOutlined, PlusOutlined } from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import apiClient, { isEnvelope } from '../../api/client'
import { useAuthStore } from '../../stores/auth'
import PageHeader from '../../components/PageHeader'
const { Text } = Typography
interface DHCPSettings {
id: number
enabled: boolean
default_lease: number
max_lease: number
domain_name: string
dns_servers: string
}
interface DHCPSubnet {
id: number
name: string
interface_name: string
subnet_cidr: string
pool_start: string
pool_end: string
gateway: string
dns_servers: string
lease_time?: number | null
active: boolean
description: string
}
interface DHCPReservation {
id: number
subnet_id: number
name: string
mac_address: string
ip_address: string
hostname: string
active: boolean
}
interface NetIface { name: string; role: string }
export default function DHCPPage() {
const { t } = useTranslation()
return (
<div>
<PageHeader icon={<CloudServerOutlined />} title={t('dhcp.title')} subtitle={t('dhcp.intro')} />
<Tabs
defaultActiveKey="settings"
items={[
{ key: 'settings', label: t('dhcp.tabs.settings'), children: <SettingsTab /> },
{ key: 'subnets', label: t('dhcp.tabs.subnets'), children: <SubnetsTab /> },
]}
/>
</div>
)
}
// ── Settings ─────────────────────────────────────────────────────────
function SettingsTab() {
const { t } = useTranslation()
const qc = useQueryClient()
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
const [msg, msgCtx] = message.useMessage()
const [form] = Form.useForm<DHCPSettings>()
const { data } = useQuery({
queryKey: ['dhcp', 'settings'],
queryFn: async () => {
const r = await apiClient.get('/dhcp/settings')
return isEnvelope(r.data) ? (r.data.data as DHCPSettings) : null
},
})
useEffect(() => { if (data) form.setFieldsValue(data) }, [data, form])
const save = useMutation({
mutationFn: async (v: DHCPSettings) => apiClient.put('/dhcp/settings', v),
onSuccess: () => {
msg.success(t('dhcp.saved'))
void qc.invalidateQueries({ queryKey: ['dhcp', 'settings'] })
},
onError: (e: Error) => msg.error(t('dhcp.saveFailed') + ': ' + e.message),
})
return (
<Card size="small">
{msgCtx}
<Alert
type="warning"
showIcon
className="mb-16"
message={t('dhcp.haWarnTitle')}
description={t('dhcp.haWarnDesc')}
/>
<Form<DHCPSettings> form={form} layout="vertical" onFinish={(v) => save.mutate(v)}>
<Form.Item label={t('dhcp.settings.enabled')} name="enabled" valuePropName="checked">
<Switch disabled={isViewer} />
</Form.Item>
<Form.Item label={t('dhcp.settings.defaultLease')} name="default_lease">
<InputNumber min={60} style={{ width: 240 }} disabled={isViewer} />
</Form.Item>
<Form.Item label={t('dhcp.settings.maxLease')} name="max_lease">
<InputNumber min={60} style={{ width: 240 }} disabled={isViewer} />
</Form.Item>
<Form.Item label={t('dhcp.settings.domainName')} name="domain_name">
<Input placeholder="lan" disabled={isViewer} />
</Form.Item>
<Form.Item label={t('dhcp.settings.dnsServers')} name="dns_servers" extra={t('dhcp.csvHint')}>
<Input placeholder="1.1.1.1, 8.8.8.8" disabled={isViewer} />
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button type="primary" htmlType="submit" loading={save.isPending} disabled={isViewer}>
{t('common.save')}
</Button>
</Tooltip>
</Form.Item>
</Form>
</Card>
)
}
// ── Subnets ──────────────────────────────────────────────────────────
function SubnetsTab() {
const { t } = useTranslation()
const qc = useQueryClient()
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
const [msg, msgCtx] = message.useMessage()
const [form] = Form.useForm<DHCPSubnet>()
const [editing, setEditing] = useState<DHCPSubnet | null>(null)
const [open, setOpen] = useState(false)
const [resvFor, setResvFor] = useState<DHCPSubnet | null>(null)
const { data: subnets } = useQuery({
queryKey: ['dhcp', 'subnets'],
queryFn: async () => {
const r = await apiClient.get('/dhcp/subnets')
return isEnvelope(r.data) ? ((r.data.data as { subnets?: DHCPSubnet[] }).subnets ?? []) : []
},
})
const { data: ifaces } = useQuery({
queryKey: ['network-interfaces'],
queryFn: async () => {
const r = await apiClient.get('/network-interfaces')
return isEnvelope(r.data) ? ((r.data.data as { interfaces?: NetIface[] }).interfaces ?? []) : []
},
})
const save = useMutation({
mutationFn: async (v: DHCPSubnet) => {
if (editing) return apiClient.put(`/dhcp/subnets/${editing.id}`, v)
return apiClient.post('/dhcp/subnets', v)
},
onSuccess: () => {
msg.success(t('dhcp.saved'))
setOpen(false); setEditing(null)
void qc.invalidateQueries({ queryKey: ['dhcp', 'subnets'] })
},
onError: (e: Error) => msg.error(t('dhcp.saveFailed') + ': ' + e.message),
})
const del = useMutation({
mutationFn: async (id: number) => apiClient.delete(`/dhcp/subnets/${id}`),
onSuccess: () => {
msg.success(t('dhcp.deleted'))
void qc.invalidateQueries({ queryKey: ['dhcp', 'subnets'] })
},
onError: (e: Error) => msg.error(t('dhcp.deleteFailed') + ': ' + e.message),
})
const openNew = () => {
setEditing(null)
form.resetFields()
form.setFieldsValue({ active: true } as Partial<DHCPSubnet>)
setOpen(true)
}
const openEdit = (s: DHCPSubnet) => {
setEditing(s)
form.setFieldsValue(s)
setOpen(true)
}
const cols: ColumnsType<DHCPSubnet> = [
{ title: t('dhcp.subnet.name'), dataIndex: 'name' },
{ title: t('dhcp.subnet.interface'), dataIndex: 'interface_name' },
{ title: t('dhcp.subnet.cidr'), dataIndex: 'subnet_cidr' },
{
title: t('dhcp.subnet.pool'), key: 'pool',
render: (_, r) => (r.pool_start && r.pool_end ? `${r.pool_start} ${r.pool_end}` : '—'),
},
{ title: t('dhcp.subnet.gateway'), dataIndex: 'gateway', render: (v) => v || '—' },
{
title: t('common.status'), dataIndex: 'active',
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? t('common.active') : t('common.inactive')}</Tag>,
},
{
title: t('common.actions'), key: 'actions', width: 280,
render: (_, r) => (
<Space>
<Button size="small" onClick={() => setResvFor(r)}>{t('dhcp.reservations')}</Button>
{!isViewer && <Button size="small" onClick={() => openEdit(r)}>{t('common.edit')}</Button>}
{!isViewer && (
<Popconfirm title={t('dhcp.subnet.deleteConfirm', { name: r.name })} onConfirm={() => del.mutate(r.id)}>
<Button size="small" danger>{t('common.delete')}</Button>
</Popconfirm>
)}
</Space>
),
},
]
return (
<Card size="small">
{msgCtx}
<Space style={{ marginBottom: 12 }}>
<Button type="primary" icon={<PlusOutlined />} onClick={openNew} disabled={isViewer}>
{t('dhcp.subnet.add')}
</Button>
</Space>
<Table<DHCPSubnet> rowKey="id" size="small" columns={cols} dataSource={subnets ?? []} pagination={false} />
<Modal
title={editing ? t('dhcp.subnet.edit') : t('dhcp.subnet.add')}
open={open}
onCancel={() => setOpen(false)}
onOk={() => form.submit()}
confirmLoading={save.isPending}
destroyOnClose
>
<Form<DHCPSubnet> form={form} layout="vertical" onFinish={(v) => save.mutate(v)}>
<Form.Item label={t('dhcp.subnet.name')} name="name" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item label={t('dhcp.subnet.interface')} name="interface_name" rules={[{ required: true }]}>
<Select
options={(ifaces ?? []).map((i) => ({ value: i.name, label: `${i.name} (${i.role})` }))}
showSearch
/>
</Form.Item>
<Form.Item label={t('dhcp.subnet.cidr')} name="subnet_cidr" rules={[{ required: true }]}>
<Input placeholder="10.0.0.0/24" />
</Form.Item>
<Space>
<Form.Item label={t('dhcp.subnet.poolStart')} name="pool_start">
<Input placeholder="10.0.0.100" />
</Form.Item>
<Form.Item label={t('dhcp.subnet.poolEnd')} name="pool_end">
<Input placeholder="10.0.0.200" />
</Form.Item>
</Space>
<Form.Item label={t('dhcp.subnet.gateway')} name="gateway">
<Input placeholder="10.0.0.1" />
</Form.Item>
<Form.Item label={t('dhcp.subnet.dnsServers')} name="dns_servers" extra={t('dhcp.csvHint')}>
<Input placeholder="1.1.1.1, 8.8.8.8" />
</Form.Item>
<Form.Item label={t('dhcp.subnet.active')} name="active" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Modal>
{resvFor && (
<ReservationsModal subnet={resvFor} onClose={() => setResvFor(null)} />
)}
</Card>
)
}
// ── Reservations (per subnet) ────────────────────────────────────────
function ReservationsModal({ subnet, onClose }: { subnet: DHCPSubnet; onClose: () => void }) {
const { t } = useTranslation()
const qc = useQueryClient()
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
const [msg, msgCtx] = message.useMessage()
const [form] = Form.useForm<DHCPReservation>()
const [editing, setEditing] = useState<DHCPReservation | null>(null)
const [formOpen, setFormOpen] = useState(false)
const key = ['dhcp', 'reservations', subnet.id]
const { data } = useQuery({
queryKey: key,
queryFn: async () => {
const r = await apiClient.get(`/dhcp/subnets/${subnet.id}/reservations`)
return isEnvelope(r.data) ? ((r.data.data as { reservations?: DHCPReservation[] }).reservations ?? []) : []
},
})
const save = useMutation({
mutationFn: async (v: DHCPReservation) => {
if (editing) return apiClient.put(`/dhcp/reservations/${editing.id}`, v)
return apiClient.post(`/dhcp/subnets/${subnet.id}/reservations`, v)
},
onSuccess: () => {
msg.success(t('dhcp.saved'))
setFormOpen(false); setEditing(null)
void qc.invalidateQueries({ queryKey: key })
},
onError: (e: Error) => msg.error(t('dhcp.saveFailed') + ': ' + e.message),
})
const del = useMutation({
mutationFn: async (id: number) => apiClient.delete(`/dhcp/reservations/${id}`),
onSuccess: () => {
msg.success(t('dhcp.deleted'))
void qc.invalidateQueries({ queryKey: key })
},
onError: (e: Error) => msg.error(t('dhcp.deleteFailed') + ': ' + e.message),
})
const cols: ColumnsType<DHCPReservation> = [
{ title: t('dhcp.reservation.mac'), dataIndex: 'mac_address' },
{ title: t('dhcp.reservation.ip'), dataIndex: 'ip_address' },
{ title: t('dhcp.reservation.hostname'), dataIndex: 'hostname', render: (v) => v || '—' },
{
title: t('common.actions'), key: 'actions', width: 180,
render: (_, r) => (
<Space>
{!isViewer && <Button size="small" onClick={() => { setEditing(r); form.setFieldsValue(r); setFormOpen(true) }}>{t('common.edit')}</Button>}
{!isViewer && (
<Popconfirm title={t('dhcp.reservation.deleteConfirm')} onConfirm={() => del.mutate(r.id)}>
<Button size="small" danger>{t('common.delete')}</Button>
</Popconfirm>
)}
</Space>
),
},
]
return (
<Modal
title={`${t('dhcp.reservations')}${subnet.name}`}
open
onCancel={onClose}
footer={null}
width={640}
>
{msgCtx}
<Space style={{ marginBottom: 12 }}>
<Button
type="primary" icon={<PlusOutlined />} disabled={isViewer}
onClick={() => { setEditing(null); form.resetFields(); form.setFieldsValue({ active: true } as Partial<DHCPReservation>); setFormOpen(true) }}
>
{t('dhcp.reservation.add')}
</Button>
</Space>
<Table<DHCPReservation> rowKey="id" size="small" columns={cols} dataSource={data ?? []} pagination={false} />
<Modal
title={editing ? t('dhcp.reservation.edit') : t('dhcp.reservation.add')}
open={formOpen}
onCancel={() => setFormOpen(false)}
onOk={() => form.submit()}
confirmLoading={save.isPending}
destroyOnClose
>
<Form<DHCPReservation> form={form} layout="vertical" onFinish={(v) => save.mutate(v)}>
<Form.Item label={t('dhcp.reservation.name')} name="name">
<Input />
</Form.Item>
<Form.Item label={t('dhcp.reservation.mac')} name="mac_address" rules={[{ required: true }]}>
<Input placeholder="aa:bb:cc:dd:ee:ff" />
</Form.Item>
<Form.Item label={t('dhcp.reservation.ip')} name="ip_address" rules={[{ required: true }]}>
<Input placeholder="10.0.0.50" />
</Form.Item>
<Form.Item label={t('dhcp.reservation.hostname')} name="hostname">
<Input />
</Form.Item>
<Form.Item label={t('dhcp.subnet.active')} name="active" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Modal>
<Text type="secondary" style={{ fontSize: 12 }}>{subnet.subnet_cidr}</Text>
</Modal>
)
}