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:
1 parent
2a7ac824f7
commit
32f940b763
18 files changed
+727
-36
No files matched your search
@@ -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 />} />
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user