Add a Zones console: create and manage local zones, records and delegations
The console could not see local zones at all. `blocklist.local_zones` compiled a store that nothing read back: there was no API for a zone, its records, its delegations or the file a record came from, and no way to change one without editing YAML and reloading. The Domains page is the blocklist's inline rules, not local data, so nothing in the console covered the half of the server that answers authoritatively from disk. There is now a Zones page and the endpoints behind it: `GET /api/zones`, `POST`/`PUT`/`DELETE /api/zones/records`, `POST`/`DELETE /api/zones/delegations` and `DELETE /api/zones?zone=`. **The console owns one directory and nothing else.** `blocklist.managed_zones` holds one `<apex>.zone` per zone, with that zone's SOA, apex NS set and the records added there, and it is compiled by the same `localzone.Load` as the operator's files — so a record added from the console is served on the next query, with no reload and no restart. Files in `blocklist.local_zones` are listed and shown read-only and are never rewritten; the key defaults to empty, so a deployment that never sets it gets a zone browser and no zone writes. Which file a record came from is now part of the store (`zone.files`, keyed by the index the store already uses), so the snapshot can say "console" or "your file" per record; the query path does not read it. **The writer proves its own output.** A record is validated by the project's own master-file parser, so `www` means `www.<zone>` and a bare CNAME target is completed exactly as it would be in a zone file; the file is rendered, and the rendering is re-parsed and compared with the records it meant to write before the file is renamed into place. A writer bug that dropped a record would otherwise present as a zone that silently stopped answering. A zone that cannot answer is refused rather than created: no SOA means no NXDOMAIN or NODATA authority, and no apex NS means nothing is delegated and no server is named. The SOA's primary follows the apex NS set — a zone whose SOA names a server outside its own NS set hands out a referral no resolver can follow — and the serial moves on every write. **A delegation takes the names under it, so a record below a cut is refused.** The store answers those names as delegated without reading the zone file, which would make the record data that looks alive and never answers. `DELETE /api/zones?zone=` exists for the same reason from the other side: a zone the console created is a real zone, so it says NXDOMAIN for what it does not hold, and deleting a record is not what gives a name back to upstream. It is refused for a zone that also has records outside the managed directory, because removing that file would remove none of the zone. **Two bugs the work turned up.** `engine.applyConfig` published local-record metrics only when `blocklist.local_zones` was non-empty, so a zone the console created was served but never counted; the gate is now the store's own record count. And the record-value renderer fell back to `RR.String()` for every type it did not know, which is the whole line rather than the RDATA — it wrote a name and a TTL into the value column for DHCID, LOC, SSHFP and SVCB, and would have fed that back as a record value on the next edit. Every write recompiles the store and flushes the cache. Which names changed is not knowable from the API, and a cached upstream answer for a name the zone now answers is simply wrong, so the flush is blunt on purpose. The wire shapes are type aliases in `internal/api` rather than copies, so there is one set of json tags, and `TestZoneEndpointKeySets` pins the key set at every level with a record and a delegation actually present — an empty list only proves the envelope. `TestZoneWritesNeedAManagedDirectory` pins the read-only default, and `TestZoneConsoleRoundTrip` walks the whole thing: a record added through the API is answered authoritatively on the next query, a cut stops the parent answering below it, and deleting the zone hands the name back. Verified against a running instance built from this tree: records and wildcards answer with `aa`, relative names are qualified, a record below a cut is refused with a message that says why, and the file on disk is a zone file an operator can read. The console page is typechecked and built; the browser was not available in this round, so it has not been looked at.
This commit is contained in:
1 parent
32f940b763
commit
504512f247
25 files changed
+4064
-56
No files matched your search
@@ -23,6 +23,7 @@ import LocalZones from './pages/LocalZones.tsx'
|
||||
import NotFound from './pages/NotFound.tsx'
|
||||
import Settings from './pages/Settings.tsx'
|
||||
import Upstreams from './pages/Upstreams.tsx'
|
||||
import Zones from './pages/Zones.tsx'
|
||||
|
||||
const THEME_KEY = 'fancydns.theme'
|
||||
|
||||
@@ -52,6 +53,7 @@ function AppRoutes() {
|
||||
<Route path="/blocklists" element={<Blocklists />} />
|
||||
<Route path="/domains" element={<Domains />} />
|
||||
<Route path="/local-zones" element={<LocalZones />} />
|
||||
<Route path="/zones" element={<Zones />} />
|
||||
<Route path="/clients" element={<Clients />} />
|
||||
<Route path="/upstreams" element={<Upstreams />} />
|
||||
<Route path="/logs" element={<Logs />} />
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
FileCode2,
|
||||
Globe,
|
||||
LayoutDashboard,
|
||||
Layers,
|
||||
ListChecks,
|
||||
Lock,
|
||||
LogOut,
|
||||
@@ -39,6 +40,7 @@ const NAV: NavItem[] = [
|
||||
{ to: '/blocklists', label: 'Blocklists', icon: ListChecks },
|
||||
{ to: '/domains', label: 'Domains', icon: Globe },
|
||||
{ to: '/local-zones', label: 'Local zones', icon: FileCode2 },
|
||||
{ to: '/zones', label: 'Zones', icon: Layers },
|
||||
{ to: '/clients', label: 'Clients', icon: Users },
|
||||
{ to: '/upstreams', label: 'Upstreams', icon: Network },
|
||||
{ to: '/logs', label: 'Logs', icon: ScrollText },
|
||||
|
||||
@@ -38,6 +38,10 @@ import type {
|
||||
UpstreamsResponse,
|
||||
ValidateResponse,
|
||||
Whoami,
|
||||
ZoneDelegationInput,
|
||||
ZoneRecordInput,
|
||||
ZonesResponse,
|
||||
ZoneWriteResponse,
|
||||
} from './types.ts'
|
||||
|
||||
const TOKEN_KEY = 'fancydns.token'
|
||||
@@ -274,6 +278,53 @@ export function checkDomain(name: string): Promise<DomainCheckResponse> {
|
||||
return apiFetch<DomainCheckResponse>(apiUrl('/api/domains/check', { name }))
|
||||
}
|
||||
|
||||
export function getZones(): Promise<ZonesResponse> {
|
||||
return apiFetch<ZonesResponse>('/api/zones')
|
||||
}
|
||||
|
||||
export function addZoneRecord(input: ZoneRecordInput): Promise<ZoneWriteResponse> {
|
||||
return apiFetch<ZoneWriteResponse>('/api/zones/records', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
})
|
||||
}
|
||||
|
||||
export function putZoneRecord(input: ZoneRecordInput): Promise<ZoneWriteResponse> {
|
||||
return apiFetch<ZoneWriteResponse>('/api/zones/records', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(input),
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteZoneRecord(
|
||||
zone: string,
|
||||
name: string,
|
||||
type: string,
|
||||
value?: string,
|
||||
): Promise<OkResponse> {
|
||||
return apiFetch<OkResponse>(
|
||||
apiUrl('/api/zones/records', { zone, name, type, value }),
|
||||
{ method: 'DELETE' },
|
||||
)
|
||||
}
|
||||
|
||||
export function addZoneDelegation(input: ZoneDelegationInput): Promise<ZoneWriteResponse> {
|
||||
return apiFetch<ZoneWriteResponse>('/api/zones/delegations', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteZoneDelegation(zone: string, name: string): Promise<OkResponse> {
|
||||
return apiFetch<OkResponse>(apiUrl('/api/zones/delegations', { zone, name }), {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteZone(zone: string): Promise<OkResponse> {
|
||||
return apiFetch<OkResponse>(apiUrl('/api/zones', { zone }), { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function getClients(): Promise<ClientsResponse> {
|
||||
return apiFetch<ClientsResponse>('/api/clients')
|
||||
}
|
||||
|
||||
@@ -620,3 +620,80 @@ export interface AddDomainInput {
|
||||
source?: string
|
||||
content?: string
|
||||
}
|
||||
|
||||
// --- Local zones -----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* One authoritative record. `value` is presentation-format RDATA with every
|
||||
* domain name absolute, so it can be pasted back into the form unchanged.
|
||||
*/
|
||||
export interface ZoneRecord {
|
||||
name: string
|
||||
type: string
|
||||
ttl: number
|
||||
value: string
|
||||
/** True when the owner was written `*.name`. */
|
||||
wildcard: boolean
|
||||
/** True for an NS record below the apex: the cut. */
|
||||
delegation: boolean
|
||||
/** True when the console may rewrite this record. */
|
||||
managed: boolean
|
||||
/** The master file the record was parsed from. */
|
||||
source: string
|
||||
}
|
||||
|
||||
/** A cut in a zone: a name below the apex handed to other servers. */
|
||||
export interface ZoneDelegation {
|
||||
name: string
|
||||
targets: string[]
|
||||
managed: boolean
|
||||
}
|
||||
|
||||
export interface Zone {
|
||||
origin: string
|
||||
serial: number
|
||||
soa: string
|
||||
default_ttl: number
|
||||
records: ZoneRecord[]
|
||||
delegations: ZoneDelegation[]
|
||||
record_count: number
|
||||
/** True when every record in the zone is the console's to rewrite. */
|
||||
managed: boolean
|
||||
files: string[]
|
||||
}
|
||||
|
||||
export interface ZonesResponse {
|
||||
zones: Zone[]
|
||||
files: string[]
|
||||
/** The directory the API may rewrite, `''` when writes are disabled. */
|
||||
managed_dir: string
|
||||
writable: boolean
|
||||
/** The record types the write endpoints accept, from the server. */
|
||||
types: string[]
|
||||
records: number
|
||||
built_at: string
|
||||
/** Why the store is stale, `''` when it is current. */
|
||||
error: string
|
||||
}
|
||||
|
||||
export interface ZoneRecordInput {
|
||||
zone: string
|
||||
name: string
|
||||
type: string
|
||||
/** 0 means the zone's default TTL. */
|
||||
ttl: number
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface ZoneDelegationInput {
|
||||
zone: string
|
||||
name: string
|
||||
targets: string[]
|
||||
ttl: number
|
||||
}
|
||||
|
||||
export interface ZoneWriteResponse {
|
||||
ok: boolean
|
||||
zone: string
|
||||
serial: number
|
||||
}
|
||||
@@ -0,0 +1,834 @@
|
||||
/**
|
||||
* Zones — the local zones this resolver answers for itself, and the
|
||||
* delegations that hand a name to another server.
|
||||
*
|
||||
* The console only ever writes the managed zone directory, so a record from an
|
||||
* operator's master file 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, because every write recompiles the zone store and the
|
||||
* serial in the answer is the cheapest way to see that the file 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)
|
||||
setForm({ zone: origin, 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('name 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
|
||||
? 'No writable managed zone directory'
|
||||
: 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, not the managed directory">
|
||||
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="Local zones this resolver answers for itself, and the names it delegates elsewhere."
|
||||
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.managed_dir === '' ? (
|
||||
<Notice tone="info" title="Zones are read-only here">
|
||||
Set <code className="font-mono">blocklist.managed_zones</code> to a writable directory and
|
||||
this page can create zones, add records and delegate names. The zones below are
|
||||
compiled from <code className="font-mono">blocklist.local_zones</code> and are not
|
||||
rewritten by the console.
|
||||
</Notice>
|
||||
) : null}
|
||||
{data !== null && data.managed_dir !== '' && !data.writable ? (
|
||||
<Notice tone="warn" title="The managed zone directory is not writable">
|
||||
<code className="font-mono">{data.managed_dir}</code> exists but the daemon cannot
|
||||
write to it, so the console is read-only. The zone files themselves are fine.
|
||||
</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={
|
||||
data?.managed_dir === ''
|
||||
? 'Add a zone with blocklist.local_zones, or set blocklist.managed_zones and create one from here.'
|
||||
: 'Create one from here: adding a record to a zone that does not exist yet starts it.'
|
||||
}
|
||||
/>
|
||||
</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
|
||||
? 'Managed by this console'
|
||||
: `From ${zone?.files.length ?? 0} file${zone?.files.length === 1 ? '' : 's'}`}
|
||||
{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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user