Files
edgeguard-native/management-ui/src/pages/DNS/index.tsx
Debian 979b3cfa66 feat(dns): Listen-Adressen als Multi-Select aus Kernel-IPs
Vorher: Free-Text-Input ('127.0.0.1, ::1, 10.10.20.3') — Operator
musste Werte tippen + auf Format aufpassen.

Jetzt: Multi-Select (mode='tags') das die IPs aus /system/interfaces
+ vier Spezial-Werte (0.0.0.0, ::, 127.0.0.1, ::1) anbietet. Optionen
zeigen IP + Iface-Name + Family ('10.0.20.26 — ens19 (IPv4)'). Tag-
Mode lässt zusätzlich freie Eingabe zu, falls eine geplante VIP noch
nicht im Kernel ist.

Convertierung Form↔Wire: UI Array ↔ DB Comma-CSV.

Version 1.0.35.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 06:28:41 +02:00

448 lines
17 KiB
TypeScript

import { useState } from 'react'
import { Alert, Button, Drawer, Form, Input, InputNumber, Modal, Select, Space, Switch, Tabs, Tag, Typography, message } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { GlobalOutlined, NodeIndexOutlined, PlusOutlined, SettingOutlined } from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import apiClient, { isEnvelope } from '../../api/client'
import DataTable from '../../components/DataTable'
import PageHeader from '../../components/PageHeader'
import ActionButtons from '../../components/ActionButtons'
import StatusDot from '../../components/StatusDot'
const { Text } = Typography
interface Zone {
id: number
name: string
zone_type: 'local' | 'forward'
description?: string | null
managed_by: string
forward_to?: string | null
active: boolean
}
interface DNSRecord {
id: number
zone_id: number
name: string
record_type: string
value: string
ttl: number
active: boolean
}
interface Settings {
listen_addresses: string
listen_port: number
upstream_forwards: string
access_acl: string
dnssec: boolean
qname_minimisation: boolean
cache_min_ttl: number
cache_max_ttl: number
}
const RECORD_TYPES = ['A', 'AAAA', 'CNAME', 'TXT', 'MX', 'SRV', 'NS', 'PTR', 'CAA']
async function listZones(): Promise<Zone[]> {
const r = await apiClient.get('/dns/zones')
if (!isEnvelope(r.data)) return []
return (r.data.data as { zones?: Zone[] }).zones ?? []
}
async function listRecords(zoneID: number): Promise<DNSRecord[]> {
const r = await apiClient.get(`/dns/zones/${zoneID}/records`)
if (!isEnvelope(r.data)) return []
return (r.data.data as { records?: DNSRecord[] }).records ?? []
}
async function getSettings(): Promise<Settings | null> {
const r = await apiClient.get('/dns/settings')
return isEnvelope(r.data) ? (r.data.data as Settings) : null
}
interface SystemIface {
ifname: string
addr_info?: Array<{ family: 'inet' | 'inet6'; local: string; prefixlen: number }>
}
async function listSystemInterfaces(): Promise<SystemIface[]> {
const r = await apiClient.get('/system/interfaces')
if (!isEnvelope(r.data)) return []
return (r.data.data as { interfaces?: SystemIface[] }).interfaces ?? []
}
export default function DNSPage() {
const { t } = useTranslation()
return (
<div>
<PageHeader
icon={<GlobalOutlined />}
title={t('dns.title')}
subtitle={t('dns.intro')}
/>
<Tabs
defaultActiveKey="zones"
items={[
{ key: 'zones', label: <span><NodeIndexOutlined /> {t('dns.tabs.zones')}</span>, children: <ZonesTab /> },
{ key: 'settings', label: <span><SettingOutlined /> {t('dns.tabs.settings')}</span>, children: <SettingsTab /> },
]}
/>
</div>
)
}
// ── Zones tab ──────────────────────────────────────────────────
function ZonesTab() {
const { t } = useTranslation()
const qc = useQueryClient()
const { data, isLoading } = useQuery({ queryKey: ['dns', 'zones'], queryFn: listZones })
const [editing, setEditing] = useState<Zone | null>(null)
const [creating, setCreating] = useState(false)
const [form] = Form.useForm<Zone>()
const [recordsZone, setRecordsZone] = useState<Zone | null>(null)
const upsert = useMutation({
mutationFn: async (v: Zone) => {
if (editing) return (await apiClient.put(`/dns/zones/${editing.id}`, v)).data
return (await apiClient.post('/dns/zones', v)).data
},
onSuccess: () => {
message.success(t('common.save'))
setEditing(null); setCreating(false); form.resetFields()
void qc.invalidateQueries({ queryKey: ['dns', 'zones'] })
},
onError: (e: Error) => message.error(e.message),
})
const del = useMutation({
mutationFn: async (id: number) => { await apiClient.delete(`/dns/zones/${id}`) },
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['dns', 'zones'] }) },
onError: (e: Error) => message.error(e.message),
})
const cols: ColumnsType<Zone> = [
{ title: t('dns.zone.name'), dataIndex: 'name', key: 'name', render: (s: string) => <code>{s}</code> },
{ title: t('dns.zone.type'), dataIndex: 'zone_type', key: 'zone_type',
render: (s: string) => <Tag color={s === 'local' ? 'blue' : 'purple'}>{s}</Tag> },
{ title: t('dns.zone.forwardTo'), dataIndex: 'forward_to', key: 'forward_to',
render: (v?: string | null) => v ? <Text code style={{ fontSize: 11 }}>{v}</Text> : '—' },
{ title: t('dns.zone.description'), dataIndex: 'description', key: 'description', render: (v?: string | null) => v ?? '—' },
{ title: t('common.active'), dataIndex: 'active', key: 'active', render: (v: boolean) => <StatusDot active={v} /> },
{
title: t('common.actions'), key: 'actions',
render: (_, row) => (
<Space size={4}>
{row.zone_type === 'local' && (
<Button size="small" type="text" onClick={() => setRecordsZone(row)}>
{t('dns.zone.records')}
</Button>
)}
<ActionButtons
onEdit={() => {
setEditing(row)
form.setFieldsValue(row)
}}
onDelete={() => del.mutate(row.id)}
deleteConfirm={t('dns.zone.deleteConfirm', { name: row.name })}
/>
</Space>
),
},
]
return (
<>
<DataTable
rowKey="id"
loading={isLoading}
dataSource={data ?? []}
columns={cols}
extraActions={
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
setCreating(true); form.resetFields()
form.setFieldsValue({ zone_type: 'local', active: true } as Zone)
}}>
{t('dns.zone.add')}
</Button>
}
/>
<Modal
title={editing ? t('dns.zone.edit') : t('dns.zone.add')}
open={editing !== null || creating}
onCancel={() => { setEditing(null); setCreating(false); form.resetFields() }}
onOk={() => { void form.submit() }}
confirmLoading={upsert.isPending}
width={580}
destroyOnClose
>
<Form form={form} layout="vertical" onFinish={(v) => upsert.mutate(v)}>
<Form.Item label={t('dns.zone.name')} name="name" rules={[{ required: true }]}
extra={t('dns.zone.nameExtra')}>
<Input placeholder="internal.netcell-it.de" />
</Form.Item>
<Form.Item label={t('dns.zone.type')} name="zone_type" rules={[{ required: true }]}>
<Select options={[
{ value: 'local', label: t('dns.zone.typeLocal') },
{ value: 'forward', label: t('dns.zone.typeForward') },
]} />
</Form.Item>
<Form.Item noStyle shouldUpdate={(p, c) => p.zone_type !== c.zone_type}>
{({ getFieldValue }) => getFieldValue('zone_type') === 'forward' && (
<Form.Item label={t('dns.zone.forwardTo')} name="forward_to" rules={[{ required: true }]}
extra={t('dns.zone.forwardToExtra')}>
<Input placeholder="10.0.0.53, 8.8.8.8" />
</Form.Item>
)}
</Form.Item>
<Form.Item label={t('dns.zone.description')} name="description">
<Input.TextArea rows={2} />
</Form.Item>
<Form.Item label={t('common.active')} name="active" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Modal>
<RecordsDrawer zone={recordsZone} onClose={() => setRecordsZone(null)} />
</>
)
}
// ── Records drawer ────────────────────────────────────────────
interface RecordsDrawerProps {
zone: Zone | null
onClose: () => void
}
function RecordsDrawer({ zone, onClose }: RecordsDrawerProps) {
const { t } = useTranslation()
const qc = useQueryClient()
const open = zone !== null
const zoneID = zone?.id ?? 0
const { data, isLoading } = useQuery({
queryKey: ['dns', 'records', zoneID],
queryFn: () => listRecords(zoneID),
enabled: open,
})
const [editing, setEditing] = useState<DNSRecord | null>(null)
const [creating, setCreating] = useState(false)
const [form] = Form.useForm<DNSRecord>()
const upsert = useMutation({
mutationFn: async (v: DNSRecord) => {
if (editing) return (await apiClient.put(`/dns/records/${editing.id}`, v)).data
return (await apiClient.post(`/dns/zones/${zoneID}/records`, v)).data
},
onSuccess: () => {
message.success(t('common.save'))
setEditing(null); setCreating(false); form.resetFields()
void qc.invalidateQueries({ queryKey: ['dns', 'records', zoneID] })
},
onError: (e: Error) => message.error(e.message),
})
const del = useMutation({
mutationFn: async (id: number) => { await apiClient.delete(`/dns/records/${id}`) },
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['dns', 'records', zoneID] }) },
onError: (e: Error) => message.error(e.message),
})
const cols: ColumnsType<DNSRecord> = [
{ title: t('dns.record.name'), dataIndex: 'name', key: 'name', render: (s: string) => <code>{s}</code> },
{ title: t('dns.record.type'), dataIndex: 'record_type', key: 'record_type', render: (s: string) => <Tag>{s}</Tag> },
{ title: t('dns.record.value'), dataIndex: 'value', key: 'value', render: (s: string) => <Text code style={{ fontSize: 12 }}>{s}</Text> },
{ title: t('dns.record.ttl'), dataIndex: 'ttl', key: 'ttl', width: 80 },
{ title: t('common.active'), dataIndex: 'active', key: 'active', render: (v: boolean) => <StatusDot active={v} /> },
{
title: t('common.actions'), key: 'actions',
render: (_, row) => (
<ActionButtons
onEdit={() => {
setEditing(row)
form.setFieldsValue(row)
}}
onDelete={() => del.mutate(row.id)}
deleteConfirm={t('dns.record.deleteConfirm', { name: row.name })}
/>
),
},
]
return (
<Drawer
open={open}
onClose={onClose}
width={920}
title={zone && (
<Space>
<span>{t('dns.record.drawerTitle')}</span>
<Tag color="blue"><code>{zone.name}</code></Tag>
</Space>
)}
destroyOnClose
>
<DataTable
rowKey="id"
loading={isLoading}
dataSource={data ?? []}
columns={cols}
extraActions={
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
setCreating(true); form.resetFields()
form.setFieldsValue({ record_type: 'A', ttl: 300, active: true } as DNSRecord)
}}>
{t('dns.record.add')}
</Button>
}
/>
<Modal
title={editing ? t('dns.record.edit') : t('dns.record.add')}
open={editing !== null || creating}
onCancel={() => { setEditing(null); setCreating(false); form.resetFields() }}
onOk={() => { void form.submit() }}
confirmLoading={upsert.isPending}
width={580}
destroyOnClose
>
<Form form={form} layout="vertical" onFinish={(v) => upsert.mutate(v)}>
<Form.Item label={t('dns.record.name')} name="name" rules={[{ required: true }]}
extra={t('dns.record.nameExtra')}>
<Input placeholder="mailcow oder mailcow.internal.netcell-it.de." />
</Form.Item>
<Form.Item label={t('dns.record.type')} name="record_type" rules={[{ required: true }]}>
<Select options={RECORD_TYPES.map(r => ({ value: r, label: r }))} />
</Form.Item>
<Form.Item label={t('dns.record.value')} name="value" rules={[{ required: true }]}
extra={t('dns.record.valueExtra')}>
<Input placeholder="10.10.20.5" />
</Form.Item>
<Form.Item label={t('dns.record.ttl')} name="ttl">
<InputNumber min={30} max={86400} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label={t('common.active')} name="active" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Modal>
</Drawer>
)
}
// ── Settings tab ──────────────────────────────────────────────
// Settings-Form-Shape unterscheidet sich vom Wire-Shape: listen_addresses
// ist im UI ein Array (Multi-Select), wird beim Save zur Komma-CSV.
interface SettingsForm extends Omit<Settings, 'listen_addresses'> {
listen_addresses: string[]
}
function SettingsTab() {
const { t } = useTranslation()
const qc = useQueryClient()
const { data, isLoading } = useQuery({ queryKey: ['dns', 'settings'], queryFn: getSettings })
const { data: sys } = useQuery({ queryKey: ['system', 'interfaces'], queryFn: listSystemInterfaces })
const [form] = Form.useForm<SettingsForm>()
// Multi-Select-Optionen: alle IPv4/IPv6 die der Kernel kennt + Spezial-
// Werte (0.0.0.0 = alle IPv4, :: = alle IPv6, 127.0.0.1 / ::1 = lo).
// Mode "tags" damit der Operator notfalls auch eine IP eintippen kann
// die der Kernel noch nicht meldet (z.B. eine geplante VIP).
const ipOptions: { value: string; label: string }[] = []
ipOptions.push({ value: '0.0.0.0', label: '0.0.0.0 — alle IPv4-Interfaces' })
ipOptions.push({ value: '::', label: ':: — alle IPv6-Interfaces' })
ipOptions.push({ value: '127.0.0.1', label: '127.0.0.1 — Loopback IPv4' })
ipOptions.push({ value: '::1', label: '::1 — Loopback IPv6' })
for (const i of sys ?? []) {
if (i.ifname === 'lo') continue
for (const a of i.addr_info ?? []) {
ipOptions.push({
value: a.local,
label: `${a.local}${i.ifname} (${a.family === 'inet' ? 'IPv4' : 'IPv6'})`,
})
}
}
const initial: SettingsForm | undefined = data ? {
...data,
listen_addresses: data.listen_addresses
.split(',')
.map(s => s.trim())
.filter(Boolean),
} : undefined
const save = useMutation({
mutationFn: async (v: SettingsForm) => {
const body: Settings = { ...v, listen_addresses: v.listen_addresses.join(', ') }
return (await apiClient.put('/dns/settings', body)).data
},
onSuccess: () => {
message.success(t('common.save'))
void qc.invalidateQueries({ queryKey: ['dns', 'settings'] })
},
onError: (e: Error) => message.error(e.message),
})
if (isLoading) return null
return (
<Form
form={form}
layout="vertical"
initialValues={initial}
onFinish={(v) => save.mutate(v)}
style={{ maxWidth: 720 }}
>
<Alert type="info" showIcon className="mb-12" message={t('dns.settings.intro')} />
<Form.Item
label={t('dns.settings.listenAddresses')}
name="listen_addresses"
rules={[{ required: true, type: 'array', min: 1, message: t('dns.settings.listenAddressesRequired') }]}
extra={t('dns.settings.listenAddressesExtra')}
>
<Select
mode="tags"
placeholder={t('dns.settings.listenAddressesPlaceholder')}
options={ipOptions}
showSearch
optionFilterProp="value"
/>
</Form.Item>
<Form.Item label={t('dns.settings.listenPort')} name="listen_port" rules={[{ required: true }]}>
<InputNumber min={1} max={65535} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label={t('dns.settings.upstreamForwards')} name="upstream_forwards" rules={[{ required: true }]}
extra={t('dns.settings.upstreamForwardsExtra')}>
<Input placeholder="1.1.1.1, 9.9.9.9" />
</Form.Item>
<Form.Item label={t('dns.settings.accessACL')} name="access_acl" rules={[{ required: true }]}
extra={t('dns.settings.accessACLExtra')}>
<Input placeholder="127.0.0.0/8, 10.0.0.0/8" />
</Form.Item>
<Form.Item label={t('dns.settings.dnssec')} name="dnssec" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item label={t('dns.settings.qnameMin')} name="qname_minimisation" valuePropName="checked">
<Switch />
</Form.Item>
<Space>
<Form.Item label={t('dns.settings.cacheMin')} name="cache_min_ttl">
<InputNumber min={0} style={{ width: 120 }} />
</Form.Item>
<Form.Item label={t('dns.settings.cacheMax')} name="cache_max_ttl">
<InputNumber min={60} style={{ width: 120 }} />
</Form.Item>
</Space>
<Form.Item>
<Button type="primary" htmlType="submit" loading={save.isPending}>
{t('common.save')}
</Button>
</Form.Item>
</Form>
)
}