Files
fancy-dns/web/src/pages/Zones.tsx
T
matsandClaude Sonnet 5.5 7fa48bcdf9 Cluster UI: Blocklists management, locked config-file items, read-only upstreams; honour allow_config_write
The console now works as the cluster's editor, without the monitoring pages.

Web: Blocklists gets Add/Edit/Remove for BlockList objects (and shows ones the
resolver has not compiled yet); Clients marks cluster groups and locks config-file
groups and the default policy; Upstreams becomes a read-only health view when the
config cannot be written; Zones and Domains lose the managed-directory and list
copy; the Local zones page and its API calls are removed; nav is Status,
Blocklists, Domains, Lookup, Address redirects, Zones, Clients, Upstreams, Settings.

Console: a change to a group the resolver's config file defines is refused with the
reason instead of being skipped silently, and /api/clients says what the console
can and cannot edit.

Server: isFileWritable called a read-only existing file (a mounted ConfigMap)
writable, and api.allow_config_write was not reflected in the writable flag nor
checked by the upstream/ACL edit path. Both fixed; tests pin them.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 10:56:21 +02:00

840 lines
27 KiB
TypeScript

/**
* Zones — the local zones this resolver answers for itself, and the
* delegations that hand a name to another server.
*
* In the cluster a zone is a DNSZone object and the console edits it; a record
* from a master file in the resolver's own config is shown and left alone: it
* carries no edit controls, and the API would refuse it anyway. Everything the
* page does is a request-then-refetch: a write changes the object, the resolver
* re-renders the zone, and the serial in the answer is the cheapest way to see
* that it moved.
*/
import { useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import { AlertTriangle, List, Pencil, Plus, Search, Trash2, Waypoints } from 'lucide-react'
import {
addZoneDelegation,
addZoneRecord,
deleteZone,
deleteZoneDelegation,
deleteZoneRecord,
getZones,
putZoneRecord,
} from '../lib/api'
import { useWhoami } from '../lib/session.ts'
import type {
Zone,
ZoneDelegation,
ZoneDelegationInput,
ZoneRecord,
ZoneRecordInput,
} from '../lib/types'
import { useApi, useAsyncAction } from '../hooks/useApi'
import { useDebounced } from '../hooks/useDebounced'
import { formatNumber, formatRelative, toLines } from '../lib/format'
import {
Badge,
Button,
Card,
ConfirmDialog,
Dialog,
EmptyState,
IconButton,
Input,
Select,
Table,
Tabs,
Textarea,
useToast,
} from '../components/ui'
import type { ToastOptions } from '../components/ui'
import { PageError, PageHeader } from '../components/layout'
type Row = Record<string, ReactNode>
function notify(
toast: (options: ToastOptions) => void,
kind: 'ok' | 'fail',
message: string,
): void {
toast({ title: message, tone: kind === 'ok' ? 'ok' : 'danger' })
}
/** One hint per type, so the value field says what belongs in it. */
const VALUE_HINTS: Record<string, string> = {
A: 'An IPv4 address, e.g. 10.0.0.5',
AAAA: 'An IPv6 address, e.g. 2001:db8::5',
CNAME: 'The target name. A name without a trailing dot is completed with the zone.',
MX: 'Preference and exchange, e.g. 10 mail.example.com. — a bare exchange name is completed with the zone',
NS: 'A nameserver name. A bare name is completed with the zone',
PTR: 'The name this address maps back to',
SRV: 'Priority, weight, port and target, e.g. 0 1 443 target.example.com.',
TXT: 'One or more quoted strings, e.g. "v=spf1 -all"',
CAA: 'Flag, tag and value, e.g. 0 issue "letsencrypt.org"',
}
function valueHint(type: string): string {
return VALUE_HINTS[type] ?? 'The RDATA, exactly as a zone file spells it'
}
function typeTone(type: string): 'default' | 'ok' | 'warn' | 'danger' | 'info' | 'muted' {
switch (type) {
case 'A':
case 'AAAA':
return 'ok'
case 'CNAME':
return 'info'
case 'NS':
return 'warn'
case 'TXT':
case 'SPF':
return 'muted'
case 'SRV':
case 'MX':
return 'default'
default:
return 'default'
}
}
function matches(record: ZoneRecord, q: string): boolean {
if (q === '') return true
return (
record.name.toLowerCase().includes(q) ||
record.type.toLowerCase().includes(q) ||
record.value.toLowerCase().includes(q)
)
}
/** The record form's state, for both adding and editing. */
type RecordForm = {
zone: string
name: string
type: string
ttl: string
value: string
}
const NEW_ZONE = '__new__'
export default function Zones() {
const { toast } = useToast()
const { canWrite } = useWhoami()
const { data, error, loading, refetch } = useApi(getZones, [])
const [origin, setOrigin] = useState('')
const [tab, setTab] = useState('records')
const [search, setSearch] = useState('')
const q = useDebounced(search, 200)
const [recordOpen, setRecordOpen] = useState(false)
const [editing, setEditing] = useState<ZoneRecord | null>(null)
const [form, setForm] = useState<RecordForm | null>(null)
const [formError, setFormError] = useState<string | null>(null)
const [cutOpen, setCutOpen] = useState(false)
const [cutZone, setCutZone] = useState<string>('')
const [newZone, setNewZone] = useState<string>('')
const [cutName, setCutName] = useState<string>('')
const [cutTargets, setCutTargets] = useState<string>('')
const [pendingRecord, setPendingRecord] = useState<ZoneRecord | null>(null)
const [pendingCut, setPendingCut] = useState<ZoneDelegation | null>(null)
const [pendingZone, setPendingZone] = useState<Zone | null>(null)
const zones = useMemo(() => data?.zones ?? [], [data])
// A zone is selected as soon as there is one, and the selection follows a
// zone that has gone away rather than pointing at nothing.
useEffect(() => {
if (zones.length === 0) {
setOrigin('')
return
}
if (!zones.some((zone) => zone.origin === origin)) setOrigin(zones[0].origin)
}, [zones, origin])
const zone = useMemo(
() => zones.find((z) => z.origin === origin) ?? null,
[zones, origin],
)
const writable = data?.writable === true
const canEdit = canWrite && writable
const saveRecord = useAsyncAction(async (input: ZoneRecordInput) =>
editing ? putZoneRecord(input) : addZoneRecord(input),
)
const dropRecord = useAsyncAction(async (target: { zone: string; record: ZoneRecord }) =>
deleteZoneRecord(target.zone, target.record.name, target.record.type, target.record.value),
)
const makeCut = useAsyncAction(async (input: ZoneDelegationInput) => addZoneDelegation(input))
const dropCut = useAsyncAction(async (name: string) =>
deleteZoneDelegation(zone?.origin ?? '', name),
)
const dropZone = useAsyncAction(async (target: string) => deleteZone(target))
const recordLines = useMemo(() => toLines(cutTargets), [cutTargets])
const targets = useMemo(
() =>
cutTargets
.split(/[\n,]+/)
.map((t) => t.trim())
.filter((t) => t !== ''),
[cutTargets],
)
const openNew = (): void => {
setEditing(null)
setNewZone('')
setForm({
zone: origin || NEW_ZONE,
name: '',
type: data?.types[0] ?? 'A',
ttl: '',
value: '',
})
setFormError(null)
setRecordOpen(true)
}
const openEdit = (record: ZoneRecord): void => {
setEditing(record)
setForm({
zone: zone?.origin ?? '',
name: record.name,
type: record.type,
ttl: String(record.ttl),
value: record.value,
})
setFormError(null)
setRecordOpen(true)
}
const submitRecord = async (): Promise<void> => {
if (form === null) return
setFormError(null)
const zoneName = form.zone === NEW_ZONE ? newZone.trim() : form.zone
if (zoneName === '') {
setFormError(
form.zone === NEW_ZONE
? 'enter the new zone apex'
: 'choose the zone this record belongs to',
)
return
}
if (form.name.trim() === '') {
setFormError('a record needs an owner name')
return
}
if (form.value.trim() === '') {
setFormError(`a ${form.type} record needs a value`)
return
}
const res = await saveRecord.run({
zone: zoneName,
name: form.name.trim(),
type: form.type,
ttl: Number(form.ttl) || 0,
value: form.value.trim(),
})
if (res === null) {
const msg = saveRecord.error ?? 'the server rejected that record'
setFormError(msg)
notify(toast, 'fail', msg)
return
}
notify(toast, 'ok', `${editing ? 'replaced' : 'added'} ${form.name.trim()} ${form.type}`)
setOrigin(zoneName)
setRecordOpen(false)
setForm(null)
refetch()
}
const submitCut = async (): Promise<void> => {
setFormError(null)
const zoneName = cutZone === NEW_ZONE ? newZone.trim() : cutZone
if (zoneName === '') {
setFormError('name the zone to delegate from')
return
}
if (cutName.trim() === '') {
setFormError('name the zone being delegated')
return
}
if (targets.length === 0) {
setFormError('a delegation needs at least one nameserver')
return
}
const res = await makeCut.run({ zone: zoneName, name: cutName.trim(), targets, ttl: 0 })
if (res === null) {
const msg = makeCut.error ?? 'the server rejected that delegation'
setFormError(msg)
notify(toast, 'fail', msg)
return
}
notify(toast, 'ok', `delegated ${cutName.trim()}`)
setOrigin(zoneName)
setCutOpen(false)
setCutName('')
setCutTargets('')
refetch()
}
const confirmRecord = async (): Promise<void> => {
const target = pendingRecord
const zoneName = zone?.origin ?? ''
if (target === null) return
const res = await dropRecord.run({ zone: zoneName, record: target })
if (res === null) {
notify(toast, 'fail', dropRecord.error ?? `could not delete ${target.name}`)
return
}
setPendingRecord(null)
notify(toast, 'ok', `deleted ${target.name} ${target.type}`)
refetch()
}
const confirmCut = async (): Promise<void> => {
const target = pendingCut
if (target === null) return
const res = await dropCut.run(target.name)
if (res === null) {
notify(toast, 'fail', dropCut.error ?? `could not remove the delegation for ${target.name}`)
return
}
setPendingCut(null)
notify(toast, 'ok', `${target.name} answers authoritatively again`)
refetch()
}
const confirmZone = async (): Promise<void> => {
const target = pendingZone
if (target === null) return
const res = await dropZone.run(target.origin)
if (res === null) {
notify(toast, 'fail', dropZone.error ?? `could not delete ${target.origin}`)
return
}
setPendingZone(null)
notify(toast, 'ok', `deleted ${target.origin}`)
refetch()
}
if (error) return <PageError error={error} onRetry={refetch} />
const records = (zone?.records ?? []).filter((record) => matches(record, q.trim().toLowerCase()))
const delegations = zone?.delegations ?? []
const noWriteReason = !canWrite
? 'This API key is read-only'
: !writable
? 'Zones are edited from the console, not from the resolver itself'
: undefined
const recordColumns = [
{ key: 'name', header: 'Name', width: '32%' },
{ key: 'type', header: 'Type', width: '8%' },
{ key: 'value', header: 'Value', width: '34%' },
{ key: 'ttl', header: 'TTL', width: '8%', align: 'right' as const },
{ key: 'source', header: 'File', width: '14%' },
{ key: 'actions', header: '', width: '4%', align: 'right' as const },
]
const recordRows: Row[] = records.map((record, i) => ({
__key: `${record.name}-${record.type}-${record.value}-${i}`,
name: (
<span className="block max-w-[320px] truncate font-mono text-xs" title={record.name}>
{record.name}
{record.wildcard ? <span className="ml-1 text-fg-subtle">(wildcard)</span> : null}
</span>
),
type: <Badge tone={typeTone(record.type)}>{record.type}</Badge>,
value: (
<span className="block max-w-[360px] truncate font-mono text-xs" title={record.value}>
{record.value}
</span>
),
ttl: <span className="font-mono text-xs">{formatNumber(record.ttl)}</span>,
source: (
<span
className="block max-w-[200px] truncate text-xs text-fg-subtle"
title={record.source}
>
{record.source || '—'}
</span>
),
actions: (
<div className="flex items-center justify-end gap-0.5">
{record.managed ? (
<>
<IconButton
icon={Pencil}
label={`edit ${record.name} ${record.type}`}
variant="ghost"
disabled={!canEdit}
onClick={() => openEdit(record)}
/>
<IconButton
icon={Trash2}
label={`delete ${record.name} ${record.type}`}
variant="ghost"
disabled={!canEdit}
onClick={() => setPendingRecord(record)}
/>
</>
) : (
<span className="text-[10px] text-fg-subtle" title="This record is in a master file in the resolver's config, not in a DNSZone object">
read-only
</span>
)}
</div>
),
}))
const cutRows: Row[] = delegations.map((cut, i) => ({
__key: `${cut.name}-${i}`,
name: (
<span className="block max-w-[320px] truncate font-mono text-xs" title={cut.name}>
{cut.name}
</span>
),
targets: (
<span className="block max-w-[420px] truncate font-mono text-xs">
{cut.targets.join(', ')}
</span>
),
managed: cut.managed ? '—' : 'read-only',
actions: (
<IconButton
icon={Trash2}
label={`remove the delegation for ${cut.name}`}
variant="ghost"
disabled={!canEdit || !cut.managed}
onClick={() => setPendingCut(cut)}
/>
),
}))
return (
<div className="space-y-4">
<PageHeader
title="Zones"
description="Records and delegations for the zones this resolver answers for itself."
actions={
<div className="flex flex-wrap gap-2">
<Button
icon={Waypoints}
onClick={() => {
setCutZone(zone?.origin ?? NEW_ZONE)
setNewZone('')
setFormError(null)
setCutOpen(true)
}}
disabled={!canEdit}
title={noWriteReason}
>
Delegate
</Button>
<Button icon={Plus} onClick={openNew} disabled={!canEdit} title={noWriteReason}>
Add record
</Button>
</div>
}
/>
{data !== null && !data.writable ? (
<Notice tone="info" title="Zones are read-only here">
This is the resolver's own view. Zones from master files named in{' '}
<code className="font-mono">blocklist.local_zones</code> are edited on disk; zones kept as
DNSZone objects are edited from the console.
</Notice>
) : null}
{data !== null && data.error !== '' ? (
<Notice tone="danger" title="The zone store is stale">
{data.error}. The last good version is still being served.
</Notice>
) : null}
{zones.length === 0 ? (
<Card>
<EmptyState
title="No local zones"
description={
writable
? 'Create one from here: adding a record to a zone that does not exist yet starts it.'
: 'Add a master file to blocklist.local_zones, or create a zone from the console.'
}
/>
</Card>
) : (
<>
<Card>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<Select
label="Zone"
value={origin}
onChange={(e) => setOrigin(e.target.value)}
options={zones.map((z) => ({ value: z.origin, label: z.origin }))}
/>
<div className="flex items-end">
<div className="w-full text-xs text-fg-muted">
<p>
{formatNumber(zone?.record_count ?? 0)} record
{zone?.record_count === 1 ? '' : 's'}
{zone !== null && zone.serial > 0 ? ` · serial ${zone.serial}` : ''}
</p>
<p className="text-fg-subtle">
{zone?.managed === true
? 'A DNSZone object'
: `From ${zone?.files.length ?? 0} master file${zone?.files.length === 1 ? '' : 's'} (read-only)`}
{data !== null ? ` · loaded ${formatRelative(data.built_at)}` : ''}
</p>
</div>
</div>
<div className="flex items-end justify-end gap-2">
{zone !== null && zone.managed ? (
<Button
variant="ghost"
icon={Trash2}
onClick={() => setPendingZone(zone)}
disabled={!canEdit}
title={noWriteReason}
>
Delete zone
</Button>
) : null}
</div>
</div>
</Card>
<Card padded={false}>
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-line p-3">
<Tabs
tabs={[
{ id: 'records', label: 'Records', count: records.length, icon: List },
{
id: 'delegations',
label: 'Delegations',
count: delegations.length,
icon: Waypoints,
},
]}
value={tab}
onChange={setTab}
/>
{tab === 'records' ? (
<div className="w-full sm:w-72">
<Input
label="Search"
placeholder="www, 10.0.0., MX"
icon={Search}
value={search}
onChange={(e) => setSearch(e.target.value)}
autoComplete="off"
spellCheck={false}
/>
</div>
) : (
<p className="text-xs text-fg-muted">
A delegated name, and everything under it, is answered by the servers named
here — not by this resolver.
</p>
)}
</div>
<div className="p-3">
{tab === 'records' ? (
<Table
columns={recordColumns}
rows={recordRows}
rowKey={(row) => String(row.__key)}
loading={loading}
empty={
<EmptyState
title={q === '' ? 'This zone has no records' : 'No records match'}
description={
q === ''
? 'Add one with the button above.'
: 'Change the search, or clear it to see the whole zone.'
}
/>
}
/>
) : (
<Table
columns={[
{ key: 'name', header: 'Delegated name', width: '36%' },
{ key: 'targets', header: 'Nameservers', width: '52%' },
{ key: 'managed', header: 'Source', width: '8%' },
{ key: 'actions', header: '', width: '4%', align: 'right' },
]}
rows={cutRows}
rowKey={(row) => String(row.__key)}
empty={
<EmptyState
title="Nothing is delegated"
description="A delegation hands a name, and everything under it, to other servers."
/>
}
/>
)}
</div>
</Card>
</>
)}
<Dialog
open={recordOpen}
onClose={() => {
setRecordOpen(false)
setForm(null)
}}
title={editing ? `Edit ${editing.name} ${editing.type}` : 'Add a record'}
description={
editing
? 'Saving replaces every record of this name and type in this zone.'
: 'A record that does not exist yet in this zone is added; the same name and type is added to the RRset.'
}
footer={
<div className="flex justify-end gap-2">
<Button
variant="ghost"
onClick={() => setRecordOpen(false)}
disabled={saveRecord.pending}
>
Cancel
</Button>
<Button
onClick={() => void submitRecord()}
loading={saveRecord.pending}
disabled={form === null || !canEdit}
>
{editing ? 'Save record' : 'Add record'}
</Button>
</div>
}
>
{form !== null ? (
<div className="space-y-3">
{formError !== null ? <ErrorBox>{formError}</ErrorBox> : null}
<ZonePicker
zones={zones}
value={form.zone}
newZone={newZone}
onPick={(value) => setForm({ ...form, zone: value })}
onNewZone={setNewZone}
fixed={editing !== null}
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Input
label="Name"
placeholder="www or *.www"
hint="Relative to the zone: www means www.{zone}. A leading *. makes it a wildcard."
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
autoComplete="off"
spellCheck={false}
disabled={editing !== null}
/>
<Select
label="Type"
value={form.type}
onChange={(e) => setForm({ ...form, type: e.target.value })}
options={(data?.types ?? ['A']).map((t) => ({ value: t, label: t }))}
/>
</div>
<Input
label="Value"
placeholder={valueHint(form.type)}
hint={valueHint(form.type)}
value={form.value}
onChange={(e) => setForm({ ...form, value: e.target.value })}
autoComplete="off"
spellCheck={false}
/>
<Input
label="TTL"
placeholder={zone?.default_ttl ? String(zone.default_ttl) : '300'}
hint="Seconds. Empty means the zone's default."
value={form.ttl}
onChange={(e) => setForm({ ...form, ttl: e.target.value.replace(/[^0-9]/g, '') })}
inputMode="numeric"
/>
</div>
) : null}
</Dialog>
<Dialog
open={cutOpen}
onClose={() => setCutOpen(false)}
title="Delegate a zone"
description="The names below the delegated name stop being answered here, except for the delegation itself."
footer={
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => setCutOpen(false)} disabled={makeCut.pending}>
Cancel
</Button>
<Button onClick={() => void submitCut()} loading={makeCut.pending} disabled={!canEdit}>
Delegate
</Button>
</div>
}
>
<div className="space-y-3">
{formError !== null ? <ErrorBox>{formError}</ErrorBox> : null}
<ZonePicker
zones={zones}
value={cutZone}
newZone={newZone}
onPick={setCutZone}
onNewZone={setNewZone}
/>
<Input
label="Delegated name"
placeholder="sub.example.com."
hint="A name below the zone's apex. The apex is the zone's own server list, not a delegation."
value={cutName}
onChange={(e) => setCutName(e.target.value)}
autoComplete="off"
spellCheck={false}
/>
<Textarea
label="Nameservers"
hint={`One per line, or comma separated. ${recordLines.length} line${recordLines.length === 1 ? '' : 's'}, ${targets.length} name${targets.length === 1 ? '' : 's'}.`}
monospace
rows={4}
value={cutTargets}
onChange={(e) => setCutTargets(e.target.value)}
placeholder={'ns1.sub.example.com.\nns2.sub.example.com.'}
spellCheck={false}
/>
</div>
</Dialog>
<ConfirmDialog
open={pendingRecord !== null}
onClose={() => setPendingRecord(null)}
onConfirm={() => void confirmRecord()}
title="Delete this record?"
message={
pendingRecord
? `${pendingRecord.name} ${pendingRecord.type} ${pendingRecord.value} will be removed from the zone file.`
: ''
}
confirmLabel="Delete"
destructive
pending={dropRecord.pending}
/>
<ConfirmDialog
open={pendingCut !== null}
onClose={() => setPendingCut(null)}
onConfirm={() => void confirmCut()}
title="Remove this delegation?"
message={
pendingCut
? `${pendingCut.name} answers authoritatively from here again, and names under it get an NXDOMAIN from this zone.`
: ''
}
confirmLabel="Remove"
destructive
pending={dropCut.pending}
/>
<ConfirmDialog
open={pendingZone !== null}
onClose={() => setPendingZone(null)}
onConfirm={() => void confirmZone()}
title="Delete this zone?"
message={
pendingZone
? `${pendingZone.origin} stops being served here, and its names go back to whatever answered for them before. Its zone file is deleted.`
: ''
}
confirmLabel="Delete zone"
destructive
pending={dropZone.pending}
/>
</div>
)
}
function Notice({
tone,
title,
children,
}: {
tone: 'info' | 'warn' | 'danger'
title: string
children: ReactNode
}) {
const tones = {
info: 'border-sky-500/40 bg-sky-500/10 text-fg',
warn: 'border-warn/40 bg-warn-soft text-fg',
danger: 'border-red-500/40 bg-red-500/10 text-fg',
} as const
return (
<p className={`flex items-start gap-2 rounded-lg border px-3 py-2 text-xs ${tones[tone]}`}>
<AlertTriangle size={14} className="mt-0.5 shrink-0 text-fg-muted" aria-hidden="true" />
<span>
<span className="font-semibold">{title}. </span>
{children}
</span>
</p>
)
}
function ErrorBox({ children }: { children: ReactNode }) {
return (
<p className="rounded-md bg-red-500/10 px-3 py-2 text-sm text-red-700 dark:text-red-300">
{children}
</p>
)
}
/**
* Picks an existing zone or a new apex. A typo inside an existing zone name
* would silently create a second zone, so the two are separate choices: the
* select picks the apex, and a new one is typed out.
*/
function ZonePicker({
zones,
value,
newZone,
onPick,
onNewZone,
fixed = false,
}: {
zones: Zone[]
value: string
newZone: string
onPick: (value: string) => void
onNewZone: (value: string) => void
fixed?: boolean
}) {
return (
<div className="space-y-3">
<Select
label="Zone"
value={value}
onChange={(e) => onPick(e.target.value)}
disabled={fixed}
options={[
...zones.map((z) => ({ value: z.origin, label: z.origin })),
{ value: NEW_ZONE, label: 'New zone…' },
]}
/>
{value === NEW_ZONE ? (
<Input
label="New zone apex"
placeholder="home.example.com."
hint="The zone is created with this name as its apex. Its NS record at the apex is taken from the delegation's nameservers."
value={newZone}
onChange={(e) => onNewZone(e.target.value)}
autoComplete="off"
spellCheck={false}
/>
) : null}
</div>
)
}