This commit is contained in:
developer committed 2026-09-28 10:37:15 +00:00
1 parent d94e4cf6a8
commit b2cd7d4b91
13 files changed
+542 -26

No files matched your search

+2
View File
@@ -20,6 +20,7 @@ import Domains from './pages/Domains.tsx'
import Login from './pages/Login.tsx'
import Logs from './pages/Logs.tsx'
import LocalZones from './pages/LocalZones.tsx'
import Lookup from './pages/Lookup.tsx'
import NotFound from './pages/NotFound.tsx'
import Settings from './pages/Settings.tsx'
import Upstreams from './pages/Upstreams.tsx'
@@ -52,6 +53,7 @@ function AppRoutes() {
<Route path="/" element={<Dashboard />} />
<Route path="/blocklists" element={<Blocklists />} />
<Route path="/domains" element={<Domains />} />
<Route path="/lookup" element={<Lookup />} />
<Route path="/local-zones" element={<LocalZones />} />
<Route path="/zones" element={<Zones />} />
<Route path="/clients" element={<Clients />} />
+2
View File
@@ -15,6 +15,7 @@ import {
Network,
PanelLeftClose,
ScrollText,
Search,
Settings,
Users,
X,
@@ -39,6 +40,7 @@ const NAV: NavItem[] = [
{ to: '/', label: 'Dashboard', icon: LayoutDashboard, end: true },
{ to: '/blocklists', label: 'Blocklists', icon: ListChecks },
{ to: '/domains', label: 'Domains', icon: Globe },
{ to: '/lookup', label: 'Lookup', icon: Search },
{ to: '/local-zones', label: 'Local zones', icon: FileCode2 },
{ to: '/zones', label: 'Zones', icon: Layers },
{ to: '/clients', label: 'Clients', icon: Users },
+9
View File
@@ -18,6 +18,7 @@ import type {
DomainCheckResponse,
DomainFilters,
DomainsResponse,
DnsTestResponse,
FlushCacheResponse,
LoginInput,
LoginResponse,
@@ -278,6 +279,14 @@ export function checkDomain(name: string): Promise<DomainCheckResponse> {
return apiFetch<DomainCheckResponse>(apiUrl('/api/domains/check', { name }))
}
/** Runs one query through the live pipeline, the console's connectivity test. */
export function testDns(name: string, qtype: string): Promise<DnsTestResponse> {
return apiFetch<DnsTestResponse>('/api/dns/test', {
method: 'POST',
body: JSON.stringify({ name, type: qtype }),
})
}
export function getZones(): Promise<ZonesResponse> {
return apiFetch<ZonesResponse>('/api/zones')
}
+24
View File
@@ -315,6 +315,30 @@ export interface DomainCheckError {
export type DomainCheckResponse = DomainCheckMatch | DomainCheckMiss | DomainCheckError
// --- POST /api/dns/test -----------------------------------------------------
/**
* The result of one query through the live pipeline. A failed lookup is a
* 200 with an rcode and no answers, so `error` only appears when the query
* never ran (bad name, engine down).
*/
export interface DnsTestResponse {
name: string
type: string
rcode: string
action: LogAction | string
answers: string[]
duration_ms: number
upstream: string
group: string
cache: string
local: boolean
truncated: boolean
blocked: boolean
block_source: string
error?: string
}
// --- GET /api/clients ------------------------------------------------------
export interface ClientMatch {
+335
View File
@@ -0,0 +1,335 @@
/**
* Lookup — the console's dig, and the place a domain's config is copied from.
*
* A test goes through the live pipeline (blocklists, ACL, local zones, cache,
* upstream), so it answers the question an operator actually has: "would a
* client pointed at this server get an answer for this name". The entries list
* is the same query over the record types a domain is normally configured
* with, and every entry can be turned into a local-zone override from there.
*/
import { useState } from 'react'
import type { ReactNode } from 'react'
import { Plus, Search } from 'lucide-react'
import { addZoneRecord, testDns } from '../lib/api'
import { useWhoami } from '../lib/session.ts'
import type { DnsTestResponse } from '../lib/types'
import { useAsyncAction } from '../hooks/useApi'
import {
Badge,
Button,
Card,
Dialog,
EmptyState,
IconButton,
Input,
Select,
Table,
useToast,
} from '../components/ui'
import { PageHeader } from '../components/layout'
type Row = Record<string, ReactNode>
/** The record types a "list the entries of this domain" lookup asks for. */
const ENTRY_TYPES = ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SOA']
const TEST_TYPES = ['A', 'AAAA', 'TXT', 'MX', 'NS', 'SOA', 'CNAME', 'PTR', 'ANY']
interface Entry {
key: string
name: string
type: string
ttl: number
value: string
}
/**
* Splits one rendered answer into its parts. The server sends the record as it
* appears in a zone file — `name ttl class type rdata` — so the rdata is
* whatever follows the fourth field and keeps its spaces (a TXT value, a
* quoted MX preference and exchange).
*/
function parseEntry(answer: string, type: string, index: number): Entry | null {
const parts = answer.trim().split(/\s+/)
if (parts.length < 5) return null
const ttl = Number.parseInt(parts[1], 10)
return {
key: `${parts[0]}-${type}-${index}`,
name: parts[0],
type: parts[3].toUpperCase(),
ttl: Number.isFinite(ttl) ? ttl : 0,
value: parts.slice(4).join(' '),
}
}
/**
* The apex to offer as the override zone. There is no public-suffix list in the
* binary, so this takes the last two labels: for `www.example.com` that is
* `example.com`, and the dialog opens with the field editable because
* `example.co.uk` needs three.
*/
function defaultZone(name: string): string {
return name.replace(/\.$/, '').split('.').slice(-2).join('.')
}
function rcodeTone(rcode: string): 'default' | 'ok' | 'warn' | 'danger' {
if (rcode === 'NOERROR') return 'ok'
if (rcode === 'NXDOMAIN') return 'warn'
return 'danger'
}
export default function Lookup() {
const { toast } = useToast()
const { canWrite } = useWhoami()
const [name, setName] = useState('')
const [qtype, setQtype] = useState('A')
const [result, setResult] = useState<DnsTestResponse | null>(null)
const [entries, setEntries] = useState<Entry[]>([])
const [listed, setListed] = useState('')
const [override, setOverride] = useState<Entry | null>(null)
const [zone, setZone] = useState('')
const [relName, setRelName] = useState('')
const [ttl, setTtl] = useState('')
const test = useAsyncAction(async (target: string, type: string) => testDns(target, type))
const write = useAsyncAction(async (input: { zone: string; name: string; type: string; ttl: number; value: string }) =>
addZoneRecord({
zone: input.zone,
name: input.name,
type: input.type,
ttl: input.ttl,
value: input.value,
}),
)
const trimmed = name.trim()
const runTest = async (): Promise<void> => {
if (trimmed === '') return
const res = await test.run(trimmed, qtype)
if (res === null) {
toast({ title: test.error ?? 'the test query failed', tone: 'danger' })
return
}
setResult(res)
}
const listEntries = async (): Promise<void> => {
if (trimmed === '') return
const results = await Promise.all(ENTRY_TYPES.map((t) => testDns(trimmed, t)))
const found: Entry[] = []
for (const [i, res] of results.entries()) {
if (res.error !== undefined) continue
for (const [j, answer] of res.answers.entries()) {
const entry = parseEntry(answer, ENTRY_TYPES[i], j)
if (entry !== null) found.push(entry)
}
}
setEntries(found)
setListed(trimmed)
toast({
title: found.length === 0 ? `no records for ${trimmed}` : `${found.length} record${found.length === 1 ? '' : 's'} for ${trimmed}`,
tone: found.length === 0 ? 'warn' : 'ok',
})
}
const openOverride = (entry: Entry): void => {
const apex = defaultZone(entry.name)
setOverride(entry)
setZone(apex)
setRelName(entry.name === `${apex}.` || entry.name === apex ? '@' : entry.name.replace(/\.$/, '').slice(0, -(apex.length + 1)))
setTtl(entry.ttl > 0 ? String(entry.ttl) : '300')
}
const saveOverride = async (): Promise<void> => {
const target = override
if (target === null) return
const res = await write.run({
zone: zone.trim(),
name: relName.trim() === '' ? '@' : relName.trim(),
type: target.type,
ttl: Number.parseInt(ttl, 10) || target.ttl,
value: target.value,
})
if (res === null) {
toast({ title: write.error ?? 'the server rejected that record', tone: 'danger' })
return
}
setOverride(null)
toast({ title: `override created in ${res.zone}`, description: `${target.type} ${target.name}`, tone: 'ok' })
}
const columns = [
{ key: 'name', header: 'Name', width: '40%' },
{ key: 'type', header: 'Type', width: '10%' },
{ key: 'value', header: 'Value', width: '38%' },
{ key: 'ttl', header: 'TTL', width: '6%', align: 'right' as const },
{ key: 'override', header: '', width: '6%', align: 'right' as const },
]
const rows: Row[] = entries.map((entry) => ({
__key: entry.key,
name: <span className="block max-w-[280px] truncate font-mono text-xs" title={entry.name}>{entry.name}</span>,
type: <Badge tone="info">{entry.type}</Badge>,
value: <span className="block max-w-[320px] truncate font-mono text-xs" title={entry.value}>{entry.value}</span>,
ttl: <span className="font-mono text-xs">{entry.ttl || '—'}</span>,
override: (
<IconButton
icon={Plus}
label={`create an override for ${entry.name} ${entry.type}`}
variant="ghost"
disabled={!canWrite}
onClick={() => openOverride(entry)}
/>
),
}))
return (
<div className="space-y-4">
<PageHeader
title="Lookup"
description="Test the server, list a domain's records, and copy one into a local zone as an override."
/>
<Card title="Test a name" subtitle="Resolved through the live pipeline, exactly as a client would see it.">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[1fr_auto_auto]">
<Input
label="Name"
placeholder="example.com or www.example.com"
icon={Search}
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') void runTest()
}}
autoComplete="off"
spellCheck={false}
/>
<Select
label="Type"
value={qtype}
onChange={(e) => setQtype(e.target.value)}
options={TEST_TYPES.map((t) => ({ value: t, label: t }))}
/>
<div className="flex items-end">
<Button onClick={() => void runTest()} loading={test.pending} disabled={trimmed === ''}>
Test
</Button>
</div>
</div>
{result ? (
<div className="mt-4 space-y-2 border-t border-slate-200 pt-4 dark:border-slate-800">
<div className="flex flex-wrap items-center gap-2">
<Badge tone={rcodeTone(result.rcode)}>{result.rcode}</Badge>
<Badge tone="muted">{result.type}</Badge>
<Badge tone={result.blocked ? 'danger' : 'default'}>{result.action}</Badge>
{result.local ? <Badge tone="ok">local zone</Badge> : null}
{result.cache === 'hit' || result.cache === 'stale' ? <Badge tone="info">cache {result.cache}</Badge> : null}
{result.truncated ? <Badge tone="warn">truncated</Badge> : null}
<span className="font-mono text-xs text-slate-500 dark:text-slate-400">
{result.duration_ms} ms{result.upstream ? ` · ${result.upstream}` : ''}
</span>
</div>
{result.error ? (
<p className="text-sm text-red-700 dark:text-red-300">{result.error}</p>
) : null}
{result.block_source ? (
<p className="text-sm text-slate-600 dark:text-slate-400">Blocked by {result.block_source}</p>
) : null}
{result.answers.length > 0 ? (
<ul className="space-y-1">
{result.answers.map((answer) => (
<li key={answer} className="break-all font-mono text-xs">{answer}</li>
))}
</ul>
) : result.error === undefined ? (
<p className="text-sm text-slate-500 dark:text-slate-400">No answer records.</p>
) : null}
</div>
) : null}
</Card>
<Card
title="Domain entries"
subtitle={listed === '' ? undefined : `Records for ${listed}`}
padded={false}
actions={
<Button variant="secondary" onClick={() => void listEntries()} loading={test.pending} disabled={trimmed === ''}>
List entries
</Button>
}
>
<div className="overflow-x-auto">
<Table
columns={columns}
rows={rows}
rowKey={(row) => String(row.__key)}
empty={
<EmptyState
title={listed === '' ? 'No domain looked up yet' : 'No records found'}
description="Enter a name above and list its entries. Every entry can become a local-zone override."
/>
}
/>
</div>
</Card>
<Dialog
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."
footer={
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => setOverride(null)} disabled={write.pending}>
Cancel
</Button>
<Button onClick={() => void saveOverride()} loading={write.pending} disabled={!canWrite}>
Create override
</Button>
</div>
}
>
<div className="space-y-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Input
label="Zone"
hint="The apex to make authoritative."
value={zone}
onChange={(e) => setZone(e.target.value)}
autoComplete="off"
spellCheck={false}
/>
<Input
label="Name"
hint="Relative to the zone; @ is the apex."
value={relName}
onChange={(e) => setRelName(e.target.value)}
autoComplete="off"
spellCheck={false}
/>
<Input
label="Type"
value={override?.type ?? ''}
onChange={() => undefined}
disabled
/>
<Input
label="TTL"
value={ttl}
onChange={(e) => setTtl(e.target.value)}
inputMode="numeric"
/>
</div>
<div className="rounded-md bg-slate-100 px-3 py-2 font-mono text-xs break-all dark:bg-slate-800">
{override?.name} {override?.ttl} IN {override?.type} {override?.value}
</div>
</div>
</Dialog>
</div>
)
}