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

@@ -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>
)
}