Add UI editing for local DNS zones
build / build-push (push) Successful in 29s

Local zones were only editable as mounted files, so operators could not manage DNS entries from the console. Add a Local zones editor backed by a shared Kubernetes ConfigMap, validate master files before saving, scope the pod service account to that ConfigMap, and reload changed zones on every replica while flushing stale cache entries.
This commit is contained in:
mats committed 2026-09-28 08:50:50 +02:00
1 parent 2a7ac824f7
commit 32f940b763
18 files changed
+727 -36

No files matched your search

+2
View File
@@ -19,6 +19,7 @@ import Dashboard from './pages/Dashboard.tsx'
import Domains from './pages/Domains.tsx'
import Login from './pages/Login.tsx'
import Logs from './pages/Logs.tsx'
import LocalZones from './pages/LocalZones.tsx'
import NotFound from './pages/NotFound.tsx'
import Settings from './pages/Settings.tsx'
import Upstreams from './pages/Upstreams.tsx'
@@ -50,6 +51,7 @@ function AppRoutes() {
<Route path="/" element={<Dashboard />} />
<Route path="/blocklists" element={<Blocklists />} />
<Route path="/domains" element={<Domains />} />
<Route path="/local-zones" element={<LocalZones />} />
<Route path="/clients" element={<Clients />} />
<Route path="/upstreams" element={<Upstreams />} />
<Route path="/logs" element={<Logs />} />
+2
View File
@@ -4,6 +4,7 @@ import { NavLink, useLocation, useNavigate } from 'react-router-dom'
import type { LucideIcon } from 'lucide-react'
import {
Database,
FileCode2,
Globe,
LayoutDashboard,
ListChecks,
@@ -37,6 +38,7 @@ const NAV: NavItem[] = [
{ to: '/', label: 'Dashboard', icon: LayoutDashboard, end: true },
{ to: '/blocklists', label: 'Blocklists', icon: ListChecks },
{ to: '/domains', label: 'Domains', icon: Globe },
{ to: '/local-zones', label: 'Local zones', icon: FileCode2 },
{ to: '/clients', label: 'Clients', icon: Users },
{ to: '/upstreams', label: 'Upstreams', icon: Network },
{ to: '/logs', label: 'Logs', icon: ScrollText },
+16
View File
@@ -21,6 +21,7 @@ import type {
FlushCacheResponse,
LoginInput,
LoginResponse,
LocalZonesResponse,
LogFilters,
LogItem,
LogsResponse,
@@ -319,6 +320,21 @@ export function getSettings(): Promise<SettingsResponse> {
return apiFetch<SettingsResponse>('/api/settings')
}
export function getLocalZones(): Promise<LocalZonesResponse> {
return apiFetch<LocalZonesResponse>('/api/local-zones')
}
export function saveLocalZone(name: string, content: string): Promise<OkResponse> {
return apiFetch<OkResponse>(`/api/local-zones/${encodeURIComponent(name)}`, {
method: 'PUT',
body: JSON.stringify({ content }),
})
}
export function deleteLocalZone(name: string): Promise<OkResponse> {
return apiFetch<OkResponse>(`/api/local-zones/${encodeURIComponent(name)}`, { method: 'DELETE' })
}
export function putSettings(yaml: string): Promise<SaveSettingsResponse> {
return apiFetch<SaveSettingsResponse>('/api/settings', {
method: 'PUT',
+11
View File
@@ -512,6 +512,17 @@ export interface SettingsResponse {
writable: boolean
}
// --- GET /api/local-zones -------------------------------------------------
export interface ZoneFile {
name: string
content: string
}
export interface LocalZonesResponse {
zones: ZoneFile[]
}
export interface SaveSettingsResponse {
ok: boolean
version: number
+168
View File
@@ -0,0 +1,168 @@
/** Edit the authoritative zone files shared by the DNS replicas. */
import { useEffect, useMemo, useState } from 'react'
import { FileCode2, Plus, Save, Trash2 } from 'lucide-react'
import { deleteLocalZone, getLocalZones, saveLocalZone } from '../lib/api'
import { useWhoami } from '../lib/session.ts'
import { useApi, useAsyncAction } from '../hooks/useApi'
import { Button, Card, CardBody, Input, Textarea, useToast } from '../components/ui'
import { LoadingBlock, PageError, PageHeader } from '../components/layout'
const EXAMPLE = '$ORIGIN example.net.\n$TTL 300\n@ IN A 192.0.2.10\nwww IN CNAME @\n'
function validApex(value: string): boolean {
const apex = value.trim().replace(/\.$/, '')
if (apex === '' || apex.length > 242) return false
return apex.split('.').every((label) =>
label.length > 0 && label.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i.test(label),
)
}
export default function LocalZones() {
const { canWrite } = useWhoami()
const { toast } = useToast()
const { data, error, loading, refetch } = useApi(() => getLocalZones(), [])
const [selected, setSelected] = useState<string | null>(null)
const [draft, setDraft] = useState<{ name: string; content: string } | null>(null)
const [creating, setCreating] = useState(false)
const [newName, setNewName] = useState('')
const save = useAsyncAction(async (input: { name: string; content: string }) =>
saveLocalZone(input.name, input.content),
)
const remove = useAsyncAction(async (name: string) => deleteLocalZone(name))
const zones = data?.zones ?? []
const active = useMemo(() => zones.find((zone) => zone.name === selected) ?? null, [zones, selected])
useEffect(() => {
if (selected === null && zones.length > 0 && !creating && draft === null) setSelected(zones[0].name)
}, [creating, draft, selected, zones])
useEffect(() => {
if (active !== null && draft === null) setDraft({ name: active.name, content: active.content })
if (active === null && selected !== null && !creating) setDraft(null)
}, [active, creating, draft, selected])
const startNew = (): void => {
setSelected(null)
setDraft(null)
setNewName('')
setCreating(true)
}
const beginCreate = (): void => {
const name = newName.trim().replace(/\.$/, '').toLowerCase()
if (!validApex(name)) {
toast({ title: 'Enter a valid DNS zone name', tone: 'danger' })
return
}
if (zones.some((zone) => zone.name === name)) {
toast({ title: 'That zone already exists', tone: 'danger' })
return
}
setSelected(null)
setDraft({ name, content: `$ORIGIN ${name}.\n$TTL 300\n` })
setCreating(false)
}
const onSave = async (): Promise<void> => {
if (draft === null || !validApex(draft.name)) return
const result = await save.run(draft)
if (result === null) {
toast({ title: 'Could not save zone', description: save.error ?? undefined, tone: 'danger' })
return
}
setSelected(draft.name.trim().replace(/\.$/, '').toLowerCase())
setDraft(null)
await refetch()
toast({ title: 'Zone saved', description: 'The DNS pods will load the updated file.', tone: 'ok' })
}
const onDelete = async (): Promise<void> => {
if (selected === null) return
if (!window.confirm(`Delete the ${selected} zone and all of its records?`)) return
const result = await remove.run(selected)
if (result === null) {
toast({ title: 'Could not delete zone', description: remove.error ?? undefined, tone: 'danger' })
return
}
setSelected(null)
setDraft(null)
await refetch()
toast({ title: 'Zone deleted', tone: 'ok' })
}
if (loading) return <LoadingBlock />
if (error !== null) return <PageError error={error} onRetry={() => void refetch()} />
const changed = draft !== null && (active === null || draft.name !== active.name || draft.content !== active.content)
return (
<div className="space-y-4">
<PageHeader
title="Local zones"
description="Add authoritative DNS records shared across replicas. Missing names under a zone apex return NXDOMAIN; for a single-host override, use that hostname as the apex."
actions={<Button icon={Plus} onClick={startNew} disabled={!canWrite}>New zone</Button>}
/>
<div className="grid gap-4 xl:grid-cols-[minmax(220px,0.7fr)_minmax(0,2fr)]">
<Card title="Zones" subtitle={`${zones.length} master files`} padded={false}>
<div className="space-y-1 p-2">
{zones.length === 0 && <p className="px-3 py-5 text-sm text-fg-subtle">No local zones yet.</p>}
{zones.map((zone) => (
<button
key={zone.name}
type="button"
onClick={() => { setCreating(false); setDraft(null); setSelected(zone.name) }}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition-colors ${selected === zone.name ? 'bg-accent-soft text-accent' : 'text-fg-muted hover:bg-surface-2 hover:text-fg'}`}
>
<FileCode2 size={15} aria-hidden="true" />
<span className="truncate font-mono">{zone.name}.</span>
</button>
))}
</div>
</Card>
<Card
title={draft?.name ? `${draft.name}.zone` : 'Zone file'}
subtitle="RFC 1035 master-file syntax. The zone apex is used as the default origin."
padded={false}
actions={selected !== null && draft !== null && canWrite ? (
<Button variant="danger" icon={Trash2} onClick={() => void onDelete()} loading={remove.pending}>Delete zone</Button>
) : undefined}
>
{creating ? (
<CardBody className="space-y-3">
<Input label="Zone apex" placeholder="example.net" value={newName} onChange={(event) => setNewName(event.currentTarget.value)} autoFocus />
<Button variant="primary" icon={Plus} onClick={beginCreate}>Create zone</Button>
</CardBody>
) : draft === null ? (
<CardBody><p className="text-sm text-fg-subtle">Choose a zone or create one to add records.</p></CardBody>
) : (
<div className="space-y-3 p-4">
<div className="flex flex-wrap items-end justify-between gap-3">
<div className="min-w-56 flex-1">
<Input label="Zone apex" value={draft.name} disabled={active !== null} onChange={(event) => setDraft({ ...draft, name: event.currentTarget.value })} />
</div>
<Button variant="primary" icon={Save} onClick={() => void onSave()} loading={save.pending} disabled={!canWrite || !changed || !validApex(draft.name)}>Save records</Button>
</div>
<Textarea
monospace
rows={20}
spellCheck={false}
aria-label="DNS zone records"
value={draft.content}
onChange={(event) => setDraft({ ...draft, content: event.currentTarget.value })}
placeholder={EXAMPLE}
className="min-h-[420px] font-mono text-xs leading-6"
/>
<p className="text-xs text-fg-subtle">Example: <code>@ IN A 192.0.2.10</code> or <code>www IN CNAME @</code>. Invalid records are rejected before saving.</p>
{!canWrite && <p className="text-xs text-warn">Your account is read-only.</p>}
</div>
)}
</Card>
</div>
</div>
)
}