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>
This commit is contained in:
Debian
2026-05-11 06:28:41 +02:00
parent e537d70e04
commit 979b3cfa66
9 changed files with 75 additions and 14 deletions

View File

@@ -73,7 +73,7 @@ const NAV: NavSection[] = [
},
]
const VERSION = '1.0.34'
const VERSION = '1.0.35'
export default function Sidebar({ isOpen, onClose }: SidebarProps) {
const { t } = useTranslation()

View File

@@ -433,7 +433,9 @@
"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).",
"listenAddressesPlaceholder": "IPs wählen (oder eintippen)",
"listenAddressesRequired": "Mindestens eine Adresse erforderlich.",
"listenAddressesExtra": "Mehrfachauswahl aus den IPs die der Kernel kennt. 127.0.0.1 + ::1 = nur lokal; weitere LAN-Iface-IPs (z.B. 10.10.20.3) öffnen den Resolver für LAN-Clients. Eigene IPs lassen sich auch eintippen (Enter).",
"listenPort": "Port",
"upstreamForwards": "Default-Forwarders",
"upstreamForwardsExtra": "Wo geht alles hin was nicht lokal ist. Default 1.1.1.1 + 9.9.9.9.",

View File

@@ -433,7 +433,9 @@
"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).",
"listenAddressesPlaceholder": "Pick IPs (or type)",
"listenAddressesRequired": "At least one address required.",
"listenAddressesExtra": "Multi-select from kernel-discovered IPs. 127.0.0.1 + ::1 = local only; LAN iface IPs (e.g. 10.10.20.3) open the resolver to LAN clients. You can also type custom IPs (Enter).",
"listenPort": "Port",
"upstreamForwards": "Default forwarders",
"upstreamForwardsExtra": "Where everything not local goes. Default 1.1.1.1 + 9.9.9.9.",

View File

@@ -61,6 +61,16 @@ async function getSettings(): Promise<Settings | null> {
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 (
@@ -326,14 +336,51 @@ function RecordsDrawer({ zone, onClose }: RecordsDrawerProps) {
// ── 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 [form] = Form.useForm<Settings>()
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: Settings) => (await apiClient.put('/dns/settings', v)).data,
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'] })
@@ -346,14 +393,24 @@ function SettingsTab() {
<Form
form={form}
layout="vertical"
initialValues={data ?? undefined}
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 }]}
extra={t('dns.settings.listenAddressesExtra')}>
<Input placeholder="127.0.0.1, ::1, 10.10.20.3" />
<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%' }} />