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:
385
management-ui/src/pages/DHCP/index.tsx
Normal file
385
management-ui/src/pages/DHCP/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user