feat: Unbound DNS-Resolver — vollständig (Renderer + Handler + UI)
Stub raus, vollständig implementiert:
* Migration 0014: dns_settings (single-row) + dns_zones.forward_to.
Default-Settings sind sinnvoll für die typische LAN-Resolver-Rolle
(1.1.1.1 + 9.9.9.9 upstream, localnet allow, DNSSEC + qname-min on).
* internal/services/dns: CRUD-Repo für zones, records, settings.
* internal/handlers/dns.go: REST /api/v1/dns/zones, /records, /settings
mit Auto-Reload nach jeder Mutation.
* internal/unbound/unbound.cfg.tpl + unbound.go: Renderer schreibt
/etc/unbound/unbound.conf.d/edgeguard.conf direkt (kein Symlink-
Dance, weil AppArmor unbound nur /etc/unbound erlaubt). Local-zones
authoritativ aus dns_records; forward-zones per stub-zone; default-
forwarders catchen alles sonst.
* main.go: dnsRepo + unbound-Reloader injiziert.
* render.go: unbound.New() bekommt Pool.
* postinst:
- Conf-Datei /etc/unbound/unbound.conf.d/edgeguard.conf wird als
edgeguard:edgeguard 0644 angelegt damit Renderer schreiben kann.
- /etc/edgeguard + Service-Subdirs auf 0755 (Squid + Unbound laufen
NICHT als edgeguard, brauchen Read-Traversal).
- Sudoers: systemctl reload unbound.service whitelisted.
* Template: chroot:"" (Conf liegt außerhalb /var/lib/unbound default-
chroot), DNSSEC-Trust-Anchor NICHT setzen (Distro hat schon
root-auto-trust-anchor-file.conf — sonst doppelter Anchor → start
failure).
* Frontend /dns: PageHeader + zwei Tabs (Zones + Resolver-Settings).
Zones-Tab mit Drawer für Records (CRUD pro Zone, A/AAAA/CNAME/TXT/
MX/SRV/NS/PTR/CAA). Sidebar-Eintrag unter Network.
* i18n DE/EN für dns.* Block.
Verified end-to-end: render → unbound restart → dig @127.0.0.1
example.com → 104.20.23.154 / 172.66.147.243.
Version 1.0.34 (mehrere Iterationen wegen AppArmor + chroot + perms).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,7 @@ const SSLPage = lazy(() => import('./pages/SSL'))
|
||||
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 ClusterPage = lazy(() => import('./pages/Cluster'))
|
||||
const SettingsPage = lazy(() => import('./pages/Settings'))
|
||||
|
||||
@@ -103,6 +104,7 @@ export default function App() {
|
||||
<Route path="/firewall" element={<FirewallPage />} />
|
||||
<Route path="/vpn/wireguard" element={<WireguardPage />} />
|
||||
<Route path="/forward-proxy" element={<ForwardProxyPage />} />
|
||||
<Route path="/dns" element={<DNSPage />} />
|
||||
<Route path="/cluster" element={<ClusterPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
|
||||
@@ -53,6 +53,7 @@ const NAV: NavSection[] = [
|
||||
{ path: '/networks', labelKey: 'nav.networks', icon: <ClusterOutlined /> },
|
||||
{ path: '/ip-addresses', labelKey: 'nav.ipAddresses', icon: <NodeIndexOutlined /> },
|
||||
{ path: '/ssl', labelKey: 'nav.ssl', icon: <SafetyCertificateOutlined /> },
|
||||
{ path: '/dns', labelKey: 'nav.dns', icon: <GlobalOutlined /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -72,7 +73,7 @@ const NAV: NavSection[] = [
|
||||
},
|
||||
]
|
||||
|
||||
const VERSION = '1.0.26'
|
||||
const VERSION = '1.0.34'
|
||||
|
||||
export default function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"vpn": "VPN",
|
||||
"wireguard": "WireGuard",
|
||||
"forwardProxy": "Forward-Proxy",
|
||||
"dns": "DNS",
|
||||
"firewall": "Firewall",
|
||||
"cluster": "Cluster",
|
||||
"settings": "Einstellungen",
|
||||
@@ -399,6 +400,51 @@
|
||||
"wg": "WireGuard"
|
||||
}
|
||||
},
|
||||
"dns": {
|
||||
"title": "DNS (Unbound)",
|
||||
"intro": "Unbound-Resolver auf :53. Lokale Zonen (authoritativ aus DNS-Records) und Forward-Zonen (per stub-zone weiter zu fremden Resolvern). Default-Forwarder für alles andere.",
|
||||
"tabs": { "zones": "Zonen", "settings": "Resolver-Settings" },
|
||||
"zone": {
|
||||
"name": "Zone-Name",
|
||||
"nameExtra": "FQDN ohne führenden/abschließenden Punkt — z.B. internal.netcell-it.de",
|
||||
"type": "Typ",
|
||||
"typeLocal": "local — authoritativ (records hier)",
|
||||
"typeForward": "forward — stub-zone zu fremdem Resolver",
|
||||
"forwardTo": "Upstream-Resolver",
|
||||
"forwardToExtra": "Komma-separierte IP-Liste — z.B. '10.0.0.53, 8.8.8.8'",
|
||||
"description": "Beschreibung",
|
||||
"records": "Records …",
|
||||
"add": "Zone hinzufügen",
|
||||
"edit": "Zone bearbeiten",
|
||||
"deleteConfirm": "Zone {{name}} mit allen Records wirklich löschen?"
|
||||
},
|
||||
"record": {
|
||||
"name": "Name",
|
||||
"nameExtra": "Relativ zur Zone (z.B. 'mailcow') oder FQDN mit abschließendem Punkt.",
|
||||
"type": "Typ",
|
||||
"value": "Wert",
|
||||
"valueExtra": "RDATA in Textform: A → IP, CNAME → FQDN, MX → 'priority host', TXT → 'string'.",
|
||||
"ttl": "TTL (sec)",
|
||||
"drawerTitle": "DNS-Records",
|
||||
"add": "Record hinzufügen",
|
||||
"edit": "Record bearbeiten",
|
||||
"deleteConfirm": "Record {{name}} wirklich löschen?"
|
||||
},
|
||||
"settings": {
|
||||
"intro": "Globale Resolver-Settings. Änderungen hier reloaden Unbound automatisch.",
|
||||
"listenAddresses": "Listen-Adressen",
|
||||
"listenAddressesExtra": "Komma-separiert. Standard 127.0.0.1+::1 — wenn LAN-Clients fragen sollen, z.B. die LAN-Iface-IP zusätzlich (10.10.20.3).",
|
||||
"listenPort": "Port",
|
||||
"upstreamForwards": "Default-Forwarders",
|
||||
"upstreamForwardsExtra": "Wo geht alles hin was nicht lokal ist. Default 1.1.1.1 + 9.9.9.9.",
|
||||
"accessACL": "Access-ACL (CIDRs)",
|
||||
"accessACLExtra": "Wer darf diesen Resolver benutzen.",
|
||||
"dnssec": "DNSSEC validieren",
|
||||
"qnameMin": "QName-Minimisation (privacy)",
|
||||
"cacheMin": "Cache min-TTL",
|
||||
"cacheMax": "Cache max-TTL"
|
||||
}
|
||||
},
|
||||
"fwd": {
|
||||
"title": "Forward-Proxy (Squid)",
|
||||
"intro": "Squid-basierter Forward-Proxy auf :3128. ACLs werden top-down nach Priority ausgewertet — first-match wins. Wenn keine Regel passt, gewinnt der Default: nur localnet (10/8, 172.16/12, 192.168/16) darf raus.",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"vpn": "VPN",
|
||||
"wireguard": "WireGuard",
|
||||
"forwardProxy": "Forward proxy",
|
||||
"dns": "DNS",
|
||||
"firewall": "Firewall",
|
||||
"cluster": "Cluster",
|
||||
"settings": "Settings",
|
||||
@@ -399,6 +400,51 @@
|
||||
"wg": "WireGuard"
|
||||
}
|
||||
},
|
||||
"dns": {
|
||||
"title": "DNS (Unbound)",
|
||||
"intro": "Unbound resolver on :53. Local zones (authoritative from DNS records) and forward zones (stub-zone to remote resolvers). Default forwarders catch everything else.",
|
||||
"tabs": { "zones": "Zones", "settings": "Resolver settings" },
|
||||
"zone": {
|
||||
"name": "Zone name",
|
||||
"nameExtra": "FQDN without leading/trailing dot — e.g. internal.netcell-it.de",
|
||||
"type": "Type",
|
||||
"typeLocal": "local — authoritative (records here)",
|
||||
"typeForward": "forward — stub-zone to remote resolver",
|
||||
"forwardTo": "Upstream resolvers",
|
||||
"forwardToExtra": "Comma-separated IP list — e.g. '10.0.0.53, 8.8.8.8'",
|
||||
"description": "Description",
|
||||
"records": "Records …",
|
||||
"add": "Add zone",
|
||||
"edit": "Edit zone",
|
||||
"deleteConfirm": "Really delete zone {{name}} and all its records?"
|
||||
},
|
||||
"record": {
|
||||
"name": "Name",
|
||||
"nameExtra": "Relative to zone (e.g. 'mailcow') or FQDN with trailing dot.",
|
||||
"type": "Type",
|
||||
"value": "Value",
|
||||
"valueExtra": "RDATA in text form: A → IP, CNAME → FQDN, MX → 'priority host', TXT → 'string'.",
|
||||
"ttl": "TTL (sec)",
|
||||
"drawerTitle": "DNS records",
|
||||
"add": "Add record",
|
||||
"edit": "Edit record",
|
||||
"deleteConfirm": "Really delete record {{name}}?"
|
||||
},
|
||||
"settings": {
|
||||
"intro": "Global resolver settings. Saves reload Unbound automatically.",
|
||||
"listenAddresses": "Listen addresses",
|
||||
"listenAddressesExtra": "Comma-separated. Default 127.0.0.1+::1 — to let LAN clients query, add the LAN iface IP (e.g. 10.10.20.3).",
|
||||
"listenPort": "Port",
|
||||
"upstreamForwards": "Default forwarders",
|
||||
"upstreamForwardsExtra": "Where everything not local goes. Default 1.1.1.1 + 9.9.9.9.",
|
||||
"accessACL": "Access ACL (CIDRs)",
|
||||
"accessACLExtra": "Who is allowed to use this resolver.",
|
||||
"dnssec": "DNSSEC validation",
|
||||
"qnameMin": "QName minimisation (privacy)",
|
||||
"cacheMin": "Cache min-TTL",
|
||||
"cacheMax": "Cache max-TTL"
|
||||
}
|
||||
},
|
||||
"fwd": {
|
||||
"title": "Forward proxy (Squid)",
|
||||
"intro": "Squid-based forward proxy on :3128. ACLs are evaluated top-down by priority — first match wins. If no rule matches, the default permits only localnet (10/8, 172.16/12, 192.168/16).",
|
||||
|
||||
390
management-ui/src/pages/DNS/index.tsx
Normal file
390
management-ui/src/pages/DNS/index.tsx
Normal file
@@ -0,0 +1,390 @@
|
||||
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
|
||||
}
|
||||
|
||||
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 ──────────────────────────────────────────────
|
||||
|
||||
function SettingsTab() {
|
||||
const { t } = useTranslation()
|
||||
const qc = useQueryClient()
|
||||
const { data, isLoading } = useQuery({ queryKey: ['dns', 'settings'], queryFn: getSettings })
|
||||
const [form] = Form.useForm<Settings>()
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async (v: Settings) => (await apiClient.put('/dns/settings', v)).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={data ?? undefined}
|
||||
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 }]}
|
||||
extra={t('dns.settings.listenAddressesExtra')}>
|
||||
<Input placeholder="127.0.0.1, ::1, 10.10.20.3" />
|
||||
</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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user