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>
386 lines
14 KiB
TypeScript
386 lines
14 KiB
TypeScript
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>
|
||
)
|
||
}
|