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>
This commit is contained in:
matsandClaude Sonnet 5.5 committed 2026-10-16 10:56:21 +02:00
1 parent 7d58b80583
commit 7fa48bcdf9
18 files changed
+726 -404

No files matched your search

-2
View File
@@ -17,7 +17,6 @@ import Clients from './pages/Clients.tsx'
import Dashboard from './pages/Dashboard.tsx'
import Domains from './pages/Domains.tsx'
import Login from './pages/Login.tsx'
import LocalZones from './pages/LocalZones.tsx'
import Lookup from './pages/Lookup.tsx'
import NotFound from './pages/NotFound.tsx'
import Overrides from './pages/Overrides.tsx'
@@ -54,7 +53,6 @@ function AppRoutes() {
<Route path="/domains" element={<Domains />} />
<Route path="/lookup" element={<Lookup />} />
<Route path="/overrides" element={<Overrides />} />
<Route path="/local-zones" element={<LocalZones />} />
<Route path="/zones" element={<Zones />} />
<Route path="/clients" element={<Clients />} />
<Route path="/upstreams" element={<Upstreams />} />
+1 -3
View File
@@ -3,7 +3,6 @@ import type { ReactNode } from 'react'
import { NavLink, useLocation, useNavigate } from 'react-router-dom'
import type { LucideIcon } from 'lucide-react'
import {
FileCode2,
Globe,
LayoutDashboard,
Layers,
@@ -41,8 +40,7 @@ const NAV: NavItem[] = [
{ to: '/domains', label: 'Domains', icon: Globe },
{ to: '/lookup', label: 'Lookup', icon: Search },
{ to: '/overrides', label: 'Address redirects', icon: MapPin },
{ to: '/local-zones', label: 'Zone files', icon: FileCode2 },
{ to: '/zones', label: 'Managed zones', icon: Layers },
{ to: '/zones', label: 'Zones', icon: Layers },
{ to: '/clients', label: 'Clients', icon: Users },
{ to: '/upstreams', label: 'Upstreams', icon: Network },
{ to: '/settings', label: 'Settings', icon: Settings },
+28 -16
View File
@@ -20,7 +20,6 @@ import type {
FlushCacheResponse,
LoginInput,
LoginResponse,
LocalZonesResponse,
OkResponse,
RefreshBlocklistsResponse,
ReloadSettingsResponse,
@@ -36,6 +35,8 @@ import type {
ZoneRecordInput,
ZonesResponse,
ZoneWriteResponse,
BlockListSpec,
BlocklistSource,
Links,
StatusResponse,
} from './types.ts'
@@ -224,6 +225,32 @@ export function getLinks(): Promise<Links> {
return apiFetch<Links>('/api/links')
}
/**
* The BlockList objects, or null when this server does not manage them: the
* console does, a resolver running on its own does not (it answers 404), and
* the page then shows the compiled lists without edit controls.
*/
export async function getBlocklistSources(): Promise<BlocklistSource[] | null> {
try {
const res = await apiFetch<{ sources: BlocklistSource[] | null }>('/api/blocklist-sources')
return res.sources ?? []
} catch (err) {
if (err instanceof ApiError && [404, 405, 501].includes(err.status)) return null
throw err
}
}
export function putBlocklistSource(name: string, spec: BlockListSpec): Promise<OkResponse> {
return apiFetch<OkResponse>(`/api/blocklist-sources/${encodeURIComponent(name)}`, {
method: 'PUT',
body: JSON.stringify(spec),
})
}
export function deleteBlocklistSource(name: string): Promise<OkResponse> {
return apiFetch<OkResponse>(`/api/blocklist-sources/${encodeURIComponent(name)}`, { method: 'DELETE' })
}
export function getBlocklists(): Promise<BlocklistsResponse> {
return apiFetch<BlocklistsResponse>('/api/blocklists')
}
@@ -366,21 +393,6 @@ 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',
+27 -11
View File
@@ -163,6 +163,26 @@ export interface BlocklistStats {
sources_detail: Record<string, BlocklistSourceMeta>
}
/** A BlockList object, as the console stores it (GET /api/blocklist-sources). */
export interface BlockListSpec {
url: string
format?: string
mirror?: string[]
disabled?: boolean
/** A duration such as "24h". */
refresh?: string
timeout?: string
max_bytes?: number
categories?: string[]
comment?: string
insecure_skip_tls_verify?: boolean
}
export interface BlocklistSource {
name: string
spec: BlockListSpec
}
export interface BlocklistsResponse {
items: BlocklistItem[]
stats: BlocklistStats
@@ -291,6 +311,13 @@ export interface ClientsResponse {
groups: ClientGroup[]
default_policy: DefaultPolicy
default_rate_limit: number
/**
* Set (to false) by the console, which edits ClientGroup objects and cannot
* edit what the resolver's config file defines. Absent when the resolver
* itself is the editor, which is when both are editable.
*/
default_policy_editable?: boolean
file_groups_editable?: boolean
}
/**
@@ -442,17 +469,6 @@ 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
+312 -25
View File
@@ -8,11 +8,19 @@
import { useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import { AlertTriangle, Clock, Layers, RefreshCw, ShieldCheck, Split, Trash2 } from 'lucide-react'
import { AlertTriangle, Clock, Layers, Pencil, Plus, RefreshCw, ShieldCheck, Split, Trash2 } from 'lucide-react'
import { checkDomain, getBlocklists, refreshBlocklists, toggleBlocklist } from '../lib/api'
import {
checkDomain,
deleteBlocklistSource,
getBlocklists,
getBlocklistSources,
putBlocklistSource,
refreshBlocklists,
toggleBlocklist,
} from '../lib/api'
import { useWhoami } from '../lib/session.ts'
import type { BlocklistItem, BlocklistStats } from '../lib/types'
import type { BlockListSpec, BlocklistItem, BlocklistSource, BlocklistStats } from '../lib/types'
import { useApi, useAsyncAction } from '../hooks/useApi'
import { useAutoRefresh } from '../hooks/useAutoRefresh'
import { useDebounced } from '../hooks/useDebounced'
@@ -22,9 +30,12 @@ import {
Button,
Card,
CardBody,
ConfirmDialog,
Dialog,
EmptyState,
IconButton,
Input,
Select,
StatCard,
Switch,
Table,
@@ -62,6 +73,67 @@ const EMPTY_STATS: BlocklistStats = {
sources_detail: {},
}
const FORMATS = [
{ value: 'auto', label: 'Detect from the content' },
{ value: 'hosts', label: 'hosts file' },
{ value: 'plain', label: 'One domain per line' },
{ value: 'adblock', label: 'Adblock Plus' },
{ value: 'rpz', label: 'RPZ zone' },
]
/** The form for one BlockList. Fields the form does not show ride along in `extra`. */
interface SourceDraft {
isNew: boolean
name: string
url: string
format: string
refresh: string
categories: string
comment: string
enabled: boolean
extra: BlockListSpec
}
const EMPTY_SOURCE: SourceDraft = {
isNew: true,
name: '',
url: '',
format: 'auto',
refresh: '24h',
categories: '',
comment: '',
enabled: true,
extra: { url: '' },
}
function draftFromSource(src: BlocklistSource): SourceDraft {
const { spec } = src
return {
isNew: false,
name: src.name,
url: spec.url,
format: spec.format || 'auto',
refresh: spec.refresh ?? '',
categories: (spec.categories ?? []).join(', '),
comment: spec.comment ?? '',
enabled: !spec.disabled,
extra: spec,
}
}
function specFromDraft(d: SourceDraft): BlockListSpec {
const categories = d.categories.split(',').map((c) => c.trim()).filter((c) => c !== '')
return {
...d.extra,
url: d.url.trim(),
format: d.format === 'auto' ? undefined : d.format,
refresh: d.refresh.trim() || undefined,
categories: categories.length > 0 ? categories : undefined,
comment: d.comment.trim() || undefined,
disabled: d.enabled ? undefined : true,
}
}
function truncate(value: string, max = 42): string {
if (value.length <= max) return value
return `${value.slice(0, max - 1)}…`
@@ -71,7 +143,22 @@ export default function Blocklists() {
const { toast } = useToast()
const { canWrite } = useWhoami()
const { data, error, loading, refetch } = useApi(() => getBlocklists(), [])
useAutoRefresh(refetch, 15000)
// The BlockList objects, when this server manages them (the console does).
const sourcesApi = useApi(() => getBlocklistSources(), [])
const managed = sourcesApi.data !== null && sourcesApi.data !== undefined
const sources = useMemo<BlocklistSource[]>(() => sourcesApi.data ?? [], [sourcesApi.data])
const canEditSources = managed && canWrite
const refetchAll = (): void => {
refetch()
sourcesApi.refetch()
}
useAutoRefresh(refetchAll, 15000)
const [sourceDraft, setSourceDraft] = useState<SourceDraft | null>(null)
const [sourceError, setSourceError] = useState<string | null>(null)
const [pendingRemove, setPendingRemove] = useState<string | null>(null)
const saveSource = useAsyncAction(async (d: SourceDraft) => putBlocklistSource(d.name.trim(), specFromDraft(d)))
const removeSource = useAsyncAction(async (name: string) => deleteBlocklistSource(name))
const [overrides, setOverrides] = useState<Record<string, boolean>>({})
const [pendingRows, setPendingRows] = useState<Record<string, boolean>>({})
@@ -140,6 +227,35 @@ export default function Blocklists() {
refetch()
}
const submitSource = async (): Promise<void> => {
if (!sourceDraft) return
setSourceError(null)
if (sourceDraft.name.trim() === '' || sourceDraft.url.trim() === '') {
setSourceError('A list needs a name and a URL.')
return
}
const res = await saveSource.run(sourceDraft)
if (res === null) {
setSourceError(saveSource.error ?? 'The server rejected that list.')
return
}
notify(toast, 'ok', `${sourceDraft.isNew ? 'added' : 'saved'} ${sourceDraft.name.trim()}`)
setSourceDraft(null)
refetchAll()
}
const confirmRemove = async (): Promise<void> => {
if (pendingRemove === null) return
const res = await removeSource.run(pendingRemove)
if (res === null) {
notify(toast, 'fail', removeSource.error ?? `could not remove ${pendingRemove}`)
return
}
notify(toast, 'ok', `removed ${pendingRemove}`)
setPendingRemove(null)
refetchAll()
}
if (error) return <PageError error={error} onRetry={refetch} />
const columns = [
@@ -151,9 +267,11 @@ export default function Blocklists() {
{ key: 'updated', header: 'Updated', width: '12%' },
{ key: 'duration', header: 'Duration', width: '10%', align: 'right' as const },
{ key: 'source', header: 'Source', width: '24%' },
{ key: 'refresh', header: '', width: '5%', align: 'right' as const },
{ key: 'refresh', header: '', width: managed ? '10%' : '5%', align: 'right' as const },
]
const objectFor = (name: string): BlocklistSource | undefined => sources.find((x) => x.name === name)
const rows: Row[] = items.map((item) => {
// Everything about a source except its name and parse stats lives under
// meta; before that nested shape these columns read fields that never
@@ -200,17 +318,77 @@ export default function Blocklists() {
</span>
),
refresh: (
<IconButton
icon={RefreshCw}
label={`refresh ${item.name}`}
variant="ghost"
disabled={busy || !canWrite}
onClick={() => void onRefresh([item.name])}
/>
<span className="flex justify-end gap-0.5">
<IconButton
icon={RefreshCw}
label={`refresh ${item.name}`}
variant="ghost"
disabled={busy || !canWrite}
onClick={() => void onRefresh([item.name])}
/>
{objectFor(item.name) ? (
<>
<IconButton
icon={Pencil}
label={`edit ${item.name}`}
variant="ghost"
disabled={!canEditSources}
onClick={() => {
const src = objectFor(item.name)
if (src) {
setSourceError(null)
setSourceDraft(draftFromSource(src))
}
}}
/>
<IconButton
icon={Trash2}
label={`remove ${item.name}`}
variant="ghost"
disabled={!canEditSources}
onClick={() => setPendingRemove(item.name)}
/>
</>
) : null}
</span>
),
}
})
// Lists that exist as objects but that the resolver has not compiled yet (just
// added, or failing to download): shown, so a save is never invisible.
for (const src of sources) {
if (items.some((i) => i.name === src.name)) continue
rows.push({
__key: `pending-${src.name}`,
name: (
<span className="flex min-w-0 flex-col">
<span className="truncate font-medium">{src.name}</span>
<span className="truncate text-xs text-slate-500">waiting for the resolver</span>
</span>
),
format: <Badge tone="muted">{src.spec.format || 'auto'}</Badge>,
enabled: <Badge tone={src.spec.disabled ? 'muted' : 'default'}>{src.spec.disabled ? 'off' : 'on'}</Badge>,
rules: <span className="font-mono text-xs">—</span>,
size: <span className="font-mono text-xs">—</span>,
updated: <span className="text-xs">—</span>,
duration: <span className="font-mono text-xs">—</span>,
source: (
<span className="block max-w-[240px] truncate font-mono text-xs text-slate-500" title={src.spec.url}>
{truncate(src.spec.url)}
</span>
),
refresh: (
<span className="flex justify-end gap-0.5">
<IconButton icon={Pencil} label={`edit ${src.name}`} variant="ghost" disabled={!canEditSources}
onClick={() => { setSourceError(null); setSourceDraft(draftFromSource(src)) }} />
<IconButton icon={Trash2} label={`remove ${src.name}`} variant="ghost" disabled={!canEditSources}
onClick={() => setPendingRemove(src.name)} />
</span>
),
})
}
// The lookup answers 200 with an error string instead of a 4xx, so its three
// shapes are told apart by their keys rather than by the status code.
const lookupError = result !== null && 'error' in result ? result.error : null
@@ -225,15 +403,31 @@ export default function Blocklists() {
title="Blocklists"
description="Compiled rule sources, their freshness and their health."
actions={
<Button
icon={RefreshCw}
onClick={() => void onRefresh()}
loading={refresh.pending}
disabled={!canWrite}
title={canWrite ? undefined : 'This API key is read-only'}
>
Refresh all
</Button>
<div className="flex items-center gap-2">
{managed ? (
<Button
variant="outline"
icon={Plus}
onClick={() => {
setSourceError(null)
setSourceDraft(EMPTY_SOURCE)
}}
disabled={!canEditSources}
title={canWrite ? undefined : 'This API key is read-only'}
>
Add list
</Button>
) : null}
<Button
icon={RefreshCw}
onClick={() => void onRefresh()}
loading={refresh.pending}
disabled={!canWrite}
title={canWrite ? undefined : 'This API key is read-only'}
>
Refresh all
</Button>
</div>
}
/>
@@ -338,17 +532,110 @@ export default function Blocklists() {
empty={
<EmptyState
title="No blocklists configured"
description="Add list sources in the configuration to compile rules."
description={
managed
? 'Add a list above: a URL to a hosts file, an Adblock list or an RPZ zone.'
: 'Add list sources in the configuration to compile rules.'
}
/>
}
/>
</div>
</Card>
<p className="flex items-center gap-2 text-xs text-slate-500 dark:text-slate-400">
<Trash2 size={12} aria-hidden="true" />
Removing a list is a configuration change: delete its source in Settings, then refresh.
<p className="text-xs text-slate-500 dark:text-slate-400">
{managed
? 'Lists you add here are BlockList objects in the cluster. Lists named in the resolver\'s config file are shown but edited there. Turning a list off keeps it, and the resolver drops its rules.'
: 'Lists come from the resolver\'s configuration; edit the config and reload to change them.'}
</p>
<Dialog
open={sourceDraft !== null}
onClose={() => setSourceDraft(null)}
title={sourceDraft?.isNew ? 'Add a blocklist' : `Edit ${sourceDraft?.name ?? ''}`}
description="The resolver downloads the list, keeps a copy on its volume and refreshes it on the interval."
footer={
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => setSourceDraft(null)} disabled={saveSource.pending}>
Cancel
</Button>
<Button onClick={() => void submitSource()} loading={saveSource.pending}>
{sourceDraft?.isNew ? 'Add list' : 'Save changes'}
</Button>
</div>
}
>
{sourceDraft ? (
<div className="space-y-3">
{sourceError ? (
<p className="rounded-md bg-red-500/10 px-3 py-2 text-sm text-red-700 dark:text-red-300">{sourceError}</p>
) : null}
<Input
label="Name"
hint="Lower-case letters, digits, '-' and '.'. It becomes the object's name and cannot be changed later."
value={sourceDraft.name}
disabled={!sourceDraft.isNew}
onChange={(e) => setSourceDraft({ ...sourceDraft, name: e.target.value })}
autoComplete="off"
spellCheck={false}
/>
<Input
label="URL"
placeholder="https://example.com/hosts"
hint="http or https."
value={sourceDraft.url}
onChange={(e) => setSourceDraft({ ...sourceDraft, url: e.target.value })}
autoComplete="off"
spellCheck={false}
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Select
label="Format"
value={sourceDraft.format}
onChange={(e) => setSourceDraft({ ...sourceDraft, format: e.target.value })}
options={FORMATS}
/>
<Input
label="Refresh every"
placeholder="24h"
hint="Empty means the default, 24h."
value={sourceDraft.refresh}
onChange={(e) => setSourceDraft({ ...sourceDraft, refresh: e.target.value })}
autoComplete="off"
/>
</div>
<Input
label="Categories"
placeholder="ads, tracking"
hint="Comma separated labels shown next to the rules."
value={sourceDraft.categories}
onChange={(e) => setSourceDraft({ ...sourceDraft, categories: e.target.value })}
autoComplete="off"
/>
<Input
label="Comment"
value={sourceDraft.comment}
onChange={(e) => setSourceDraft({ ...sourceDraft, comment: e.target.value })}
/>
<Switch
checked={sourceDraft.enabled}
onChange={(next) => setSourceDraft({ ...sourceDraft, enabled: next })}
label={sourceDraft.enabled ? 'enabled' : 'disabled'}
/>
</div>
) : null}
</Dialog>
<ConfirmDialog
open={pendingRemove !== null}
onClose={() => setPendingRemove(null)}
onConfirm={() => void confirmRemove()}
title="Remove this blocklist?"
message={pendingRemove ? `${pendingRemove} and its rules are dropped from the resolver.` : ''}
confirmLabel="Remove"
destructive
pending={removeSource.pending}
/>
</div>
)
}
+25 -5
View File
@@ -213,7 +213,21 @@ export default function Clients() {
const { canWrite } = useWhoami()
const { data, error, loading, refetch } = useApi(() => getClients(), [])
const groups = useMemo<ClientGroup[]>(() => data?.groups ?? [], [data])
// 'history' rows are traffic counters for a name that is not a group (the
// default bucket, or a group removed since); saving them back would try to
// create them.
const groups = useMemo<ClientGroup[]>(
() => (data?.groups ?? []).filter((g) => g.source !== 'history'),
[data],
)
const defaultEditable = data?.default_policy_editable !== false
// A group the resolver's config file defines cannot be changed through the
// console, which writes cluster objects; saying so beats a save that
// appears to work.
const isLocked = (g: ClientGroup): boolean =>
g.source !== 'kubernetes' && data?.file_groups_editable === false
const lockedReason = "Defined in the resolver's config file; edit it there"
const [defaultPolicy] = useState<string>('')
const [defaultDraft, setDefaultDraft] = useState<string | null>(null)
const [editorOpen, setEditorOpen] = useState(false)
@@ -357,13 +371,13 @@ export default function Clients() {
title="Default policy"
subtitle={`Applied to clients that match no group. Default rate limit: ${
formatNumber(data?.default_rate_limit ?? 0)
} qps`}
} qps${defaultEditable ? '' : ' · set in the resolver\'s config (acl.default_policy)'}`}
>
<div className="w-full sm:max-w-xs">
<Select
label="Policy"
value={effectiveDefault}
disabled={loading || save.pending || !canWrite}
disabled={loading || save.pending || !canWrite || !defaultEditable}
onChange={(e) => void saveDefault(e.target.value)}
options={DEFAULT_POLICIES}
/>
@@ -397,6 +411,11 @@ export default function Clients() {
<Badge tone={group.policy === 'deny' ? 'danger' : group.policy === 'block_only' ? 'warn' : 'ok'}>
{group.policy}
</Badge>
{group.source === 'kubernetes' ? (
<Badge tone="info">cluster</Badge>
) : isLocked(group) ? (
<Badge tone="muted">config file</Badge>
) : null}
</div>
{group.comment ? (
<p className="mt-0.5 text-xs text-slate-500 dark:text-slate-400">{group.comment}</p>
@@ -417,13 +436,14 @@ export default function Clients() {
</div>
</div>
<div className="flex shrink-0 gap-1">
<IconButton icon={Pencil} label={`edit ${group.name}`} variant="ghost" disabled={!canWrite} onClick={() => openEdit(i)} />
<IconButton icon={Pencil} label={`edit ${group.name}`} variant="ghost" disabled={!canWrite || isLocked(group)} title={isLocked(group) ? lockedReason : undefined} onClick={() => openEdit(i)} />
<IconButton icon={Copy} label={`duplicate ${group.name}`} variant="ghost" disabled={!canWrite} onClick={() => duplicate(i)} />
<IconButton
icon={Trash2}
label={`delete ${group.name}`}
variant="ghost"
disabled={!canWrite}
disabled={!canWrite || isLocked(group)}
title={isLocked(group) ? lockedReason : undefined}
onClick={() => setPendingDelete(i)}
/>
</div>
+1 -9
View File
@@ -343,14 +343,6 @@ export default function Domains() {
onChange={(e) => setForm((f) => ({ ...f, action: e.target.value }))}
options={ACTIONS.map((a) => ({ value: a.value, label: a.label }))}
/>
<Input
label="List"
placeholder="inline"
hint="Optional group name, defaults to the inline list."
value={form.list}
onChange={(e) => setForm((f) => ({ ...f, list: e.target.value }))}
autoComplete="off"
/>
</div>
<button
type="button"
@@ -380,7 +372,7 @@ export default function Domains() {
title="Delete this rule?"
message={
pendingDelete
? `${pendingDelete.name} (${pendingDelete.action}) will be removed and the change persisted to the configuration file.`
? `${pendingDelete.name} (${pendingDelete.action}) will be removed from the DomainRule it belongs to.`
: ''
}
confirmLabel="Delete"
-242
View File
@@ -1,242 +0,0 @@
/** Edit console-managed authoritative zone files shared by DNS replicas. */
import { useEffect, useMemo, useState } from 'react'
import { FileCode2, Plus, Save, Trash2 } from 'lucide-react'
import { deleteLocalZone, getLocalZones, getZones, saveLocalZone } from '../lib/api'
import { useWhoami } from '../lib/session.ts'
import type { ZoneRecord } from '../lib/types'
import { useApi, useAsyncAction } from '../hooks/useApi'
import {
Badge,
Button,
Card,
CardBody,
Input,
Table,
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(), [])
// What the daemon has actually compiled out of the selected file. A raw
// textarea says nothing about what a zone ends up serving, and guessing
// from $TTL and owner columns is a bad way to find a typo.
const { data: compiled } = useApi(getZones, [])
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])
// The compiled view is keyed by the zone's canonical apex, which carries a
// trailing dot the file listing does not.
const served = useMemo(() => {
if (active === null || compiled === null) return null
const want = active.name.endsWith('.') ? active.name : `${active.name}.`
return compiled.zones.find((zone) => zone.origin === want) ?? null
}, [active, compiled])
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="Zone files"
description="Edit master-file data for authoritative zones. These files are shared across replicas; names missing beneath a zone apex return NXDOMAIN. Use Managed zones for record-by-record editing."
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">
{served === null ? (
<p className="rounded-md border border-line bg-surface-2 px-3 py-2 text-xs text-fg-muted">
The daemon has not compiled a zone called{' '}
<span className="font-mono">{draft.name}.</span> — the file may be invalid, or
it may be a name the resolver does not serve. The errors are on the status page.
</p>
) : (
<div className="rounded-lg border border-line">
<p className="border-b border-line bg-surface-2 px-3 py-2 text-xs text-fg-muted">
<span className="font-semibold text-fg">
{served.record_count} record{served.record_count === 1 ? '' : 's'} served
</span>{' '}
for this apex
{served.serial > 0 ? ` · SOA serial ${served.serial}` : ''}
{served.delegations.length > 0
? ` · ${served.delegations.length} delegation${served.delegations.length === 1 ? '' : 's'}`
: ''}
. This is what the resolver answers with, compiled from the file below.
</p>
<div className="p-2">
<Table
columns={[
{ key: 'name', header: 'Name', width: '40%' },
{ key: 'type', header: 'Type', width: '10%' },
{ key: 'value', header: 'Value', width: '34%' },
{ key: 'ttl', header: 'TTL', width: '10%', align: 'right' },
{ key: 'managed', header: 'Source', width: '6%' },
]}
rows={served.records.map((record: ZoneRecord, i) => ({
__key: `${record.name}-${record.type}-${record.value}-${i}`,
name: (
<span className="block max-w-[280px] truncate font-mono text-xs">
{record.name}
{record.wildcard ? (
<span className="ml-1 text-fg-subtle">wildcard</span>
) : null}
</span>
),
type: <Badge tone={record.delegation ? 'warn' : 'default'}>{record.type}</Badge>,
value: (
<span className="block max-w-[320px] truncate font-mono text-xs">
{record.value}
</span>
),
ttl: <span className="font-mono text-xs">{record.ttl}</span>,
managed: record.managed ? 'console' : 'file',
}))}
rowKey={(row) => String(row.__key)}
/>
</div>
</div>
)}
<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>
)
}
+1 -1
View File
@@ -286,7 +286,7 @@ export default function Lookup() {
open={override !== null}
onClose={() => setOverride(null)}
title="Create an override"
description="The record is written to the console's managed zone, which makes that apex authoritative for the names it holds."
description="The record is added to the DNSZone for that apex, which must already exist: a zone is authoritative for every name under it, so a new one is created from the Zones page with its own name servers."
footer={
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => setOverride(null)} disabled={write.pending}>
+1 -1
View File
@@ -125,7 +125,7 @@ export default function Overrides() {
<Card
title="Current redirects"
subtitle={`${items.length} ${items.length === 1 ? 'domain' : 'domains'} · source: managed:address-overrides`}
subtitle={`${items.length} ${items.length === 1 ? 'domain' : 'domains'} · AddressOverride objects`}
>
{items.length === 0 ? (
<EmptyState
+21 -2
View File
@@ -20,7 +20,7 @@ import {
Trash2,
} from 'lucide-react'
import { getUpstreams, putUpstreams } from '../lib/api'
import { getStatus, getUpstreams, putUpstreams } from '../lib/api'
import { useWhoami } from '../lib/session.ts'
import type { UpstreamBootstrap, UpstreamPolicy, UpstreamProto, UpstreamStat } from '../lib/types'
import { useApi, useAsyncAction } from '../hooks/useApi'
@@ -173,8 +173,15 @@ function splitList(value: string): string[] {
export default function Upstreams() {
const { toast } = useToast()
const { canWrite } = useWhoami()
const { canWrite: keyCanWrite } = useWhoami()
const { data, error, loading, refetch } = useApi(() => getUpstreams(), [])
// Upstreams are deployment configuration: in the cluster they come from the
// resolver's config file, which is mounted read-only, so this page shows live
// health and cannot save. The status endpoint says whether the file can be
// written.
const { data: status } = useApi(() => getStatus(), [])
const configWritable = status?.config.writable !== false
const canWrite = keyCanWrite && configWritable
const [draft, setDraft] = useState<Draft | null>(null)
const [editorOpen, setEditorOpen] = useState(false)
@@ -360,6 +367,16 @@ export default function Upstreams() {
}
/>
{!configWritable ? (
<p className="rounded-md bg-sky-500/10 px-3 py-2 text-sm text-sky-800 dark:text-sky-200">
Upstreams are part of the resolver's configuration (in Kubernetes, its ConfigMap), so this
page shows their live health and cannot change them. Edit the config and reload.
</p>
) : null}
{/* A disabled fieldset disables every control inside it, so a read-only
page cannot build a draft it could never save. */}
<fieldset disabled={!canWrite} className="m-0 min-w-0 space-y-4 border-0 p-0">
<Card title="Pool behaviour">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
<Select
@@ -484,6 +501,8 @@ export default function Upstreams() {
)}
</Card>
</fieldset>
<p className="text-xs text-slate-500 dark:text-slate-400">
{working.servers.length} server{working.servers.length === 1 ? '' : 's'} ·{' '}
{formatNumber(working.servers.reduce((sum, s) => sum + s.weight, 0))} total weight ·
+20 -38
View File
@@ -2,17 +2,17 @@
* 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.
* 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 { useNavigate } from 'react-router-dom'
import { AlertTriangle, FileCode2, List, Pencil, Plus, Search, Trash2, Waypoints } from 'lucide-react'
import { AlertTriangle, List, Pencil, Plus, Search, Trash2, Waypoints } from 'lucide-react'
import {
addZoneDelegation,
@@ -122,7 +122,6 @@ const NEW_ZONE = '__new__'
export default function Zones() {
const { toast } = useToast()
const { canWrite } = useWhoami()
const navigate = useNavigate()
const { data, error, loading, refetch } = useApi(getZones, [])
const [origin, setOrigin] = useState('')
@@ -329,7 +328,7 @@ export default function Zones() {
const noWriteReason = !canWrite
? 'This API key is read-only'
: !writable
? 'No writable managed zone directory'
? 'Zones are edited from the console, not from the resolver itself'
: undefined
const recordColumns = [
@@ -383,7 +382,7 @@ export default function Zones() {
/>
</>
) : (
<span className="text-[10px] text-fg-subtle" title="This record is in a master file, not the managed directory">
<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>
)}
@@ -418,8 +417,8 @@ export default function Zones() {
return (
<div className="space-y-4">
<PageHeader
title="Managed zones"
description="Manage records and delegations for console-owned authoritative zones. Use Zone files to edit master-file data."
title="Zones"
description="Records and delegations for the zones this resolver answers for itself."
actions={
<div className="flex flex-wrap gap-2">
<Button
@@ -442,18 +441,11 @@ export default function Zones() {
}
/>
{data !== null && data.managed_dir === '' ? (
{data !== null && !data.writable ? (
<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.
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 !== '' ? (
@@ -467,9 +459,9 @@ export default function Zones() {
<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.'
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>
@@ -492,23 +484,13 @@ export default function Zones() {
</p>
<p className="text-fg-subtle">
{zone?.managed === true
? 'Managed by this console'
: `From ${zone?.files.length ?? 0} file${zone?.files.length === 1 ? '' : 's'}`}
? '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={FileCode2}
onClick={() => navigate('/local-zones')}
title="This zone comes from a master file, not the managed directory. Local zones edits that file."
>
Edit the file
</Button>
) : null}
{zone !== null && zone.managed ? (
<Button
variant="ghost"