/** * 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 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 = { 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(null) const [form, setForm] = useState(null) const [formError, setFormError] = useState(null) const [cutOpen, setCutOpen] = useState(false) const [cutZone, setCutZone] = useState('') const [newZone, setNewZone] = useState('') const [cutName, setCutName] = useState('') const [cutTargets, setCutTargets] = useState('') const [pendingRecord, setPendingRecord] = useState(null) const [pendingCut, setPendingCut] = useState(null) const [pendingZone, setPendingZone] = useState(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 => { 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 => { 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 => { 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 => { 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 => { 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 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: ( {record.name} {record.wildcard ? (wildcard) : null} ), type: {record.type}, value: ( {record.value} ), ttl: {formatNumber(record.ttl)}, source: ( {record.source || '—'} ), actions: (
{record.managed ? ( <> openEdit(record)} /> setPendingRecord(record)} /> ) : ( read-only )}
), })) const cutRows: Row[] = delegations.map((cut, i) => ({ __key: `${cut.name}-${i}`, name: ( {cut.name} ), targets: ( {cut.targets.join(', ')} ), managed: cut.managed ? '—' : 'read-only', actions: ( setPendingCut(cut)} /> ), })) return (
} /> {data !== null && !data.writable ? ( This is the resolver's own view. Zones from master files named in{' '} blocklist.local_zones are edited on disk; zones kept as DNSZone objects are edited from the console. ) : null} {data !== null && data.error !== '' ? ( {data.error}. The last good version is still being served. ) : null} {zones.length === 0 ? ( ) : ( <>
setSearch(e.target.value)} autoComplete="off" spellCheck={false} />
) : (

A delegated name, and everything under it, is answered by the servers named here — not by this resolver.

)}
{tab === 'records' ? ( String(row.__key)} loading={loading} empty={ } /> ) : (
String(row.__key)} empty={ } /> )} )} { 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={
} > {form !== null ? (
{formError !== null ? {formError} : null} setForm({ ...form, zone: value })} onNewZone={setNewZone} fixed={editing !== null} />
setForm({ ...form, name: e.target.value })} autoComplete="off" spellCheck={false} disabled={editing !== null} /> setForm({ ...form, value: e.target.value })} autoComplete="off" spellCheck={false} /> setForm({ ...form, ttl: e.target.value.replace(/[^0-9]/g, '') })} inputMode="numeric" />
) : null}
setCutOpen(false)} title="Delegate a zone" description="The names below the delegated name stop being answered here, except for the delegation itself." footer={
} >
{formError !== null ? {formError} : null} setCutName(e.target.value)} autoComplete="off" spellCheck={false} />