milestone 17: real deadlines, validator holes, upstream editor, trusted proxies, contract samples, badvers
CI / test (push) Successful in 1m22s
CI / test-aarch64 (push) Successful in 4m55s
CI / frontend (push) Successful in 39s
CI / cross (push) Successful in 7m57s
CI / docker (push) Failing after 1h10m42s

This commit is contained in:
2026-08-07 17:55:59 +02:00
parent 9b12dbaaa0
commit c50c6d285a
57 changed files with 2926 additions and 126 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ export default function RestartBanner() {
role="status"
className="flex items-center gap-3 border-b border-amber-300 bg-amber-50 px-4 py-2 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-100"
>
<span className="flex-1">Settings saved. Restart nxdns to apply.</span>
<span className="flex-1">Changes saved. Restart nxdns to apply.</span>
<button
type="button"
onClick={dismissRestartBanner}
@@ -10,7 +10,7 @@ import type { Settings, SettingsPatch } from "@/lib/types";
function baseSettings(): Settings {
return {
upstream: { read_timeout_ms: 3000, total_timeout_ms: 5000 },
upstream: { attempt_timeout_ms: 2500, read_timeout_ms: 3000, total_timeout_ms: 5000 },
dns: { bind_ipv4: "0.0.0.0", bind_ipv6: "::", port: 53, rate_limit: 100, rate_window_seconds: 60 },
blocking: { response: "zero", ttl: 300 },
cache: { size: 10000, negative_ttl_max: 300 },
@@ -22,6 +22,7 @@ function baseSettings(): Settings {
api_rate_limit_per_min: 60,
api_localhost_exempt: true,
sse_max_connections_per_ip: 2,
trusted_proxies: "",
auth_enabled: true,
},
doh_server: { enabled: false, bind: "0.0.0.0", port: 443, cert_path: "", key_path: "" },
@@ -37,6 +37,7 @@ const SECTIONS: readonly SectionDef[] = [
section: "upstream",
title: "Upstream",
fields: [
{ key: "attempt_timeout_ms", kind: "number" },
{ key: "read_timeout_ms", kind: "number" },
{ key: "total_timeout_ms", kind: "number" },
],
@@ -79,6 +80,7 @@ const SECTIONS: readonly SectionDef[] = [
{ key: "api_rate_limit_per_min", kind: "number" },
{ key: "api_localhost_exempt", kind: "boolean" },
{ key: "sse_max_connections_per_ip", kind: "number" },
{ key: "trusted_proxies", kind: "text" },
],
},
{ section: "doh_server", title: "DoH Server", fields: TLS_FIELDS },
+117
View File
@@ -0,0 +1,117 @@
import { useState, type FormEvent } from "react";
import InlineError from "@/lib/InlineError";
import type { Upstream, UpstreamInput } from "@/lib/types";
const INPUT_CLASS =
"mt-1 w-full rounded border border-zinc-300 bg-white px-3 py-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700 dark:bg-zinc-900";
const DEFAULT_PRIORITY = "100";
interface UpstreamFormProps {
initial?: Upstream;
busy: boolean;
error: Error | null;
onSubmit: (input: UpstreamInput) => Promise<void>;
onCancel?: () => void;
}
export default function UpstreamForm({ initial, busy, error, onSubmit, onCancel }: UpstreamFormProps) {
const [url, setUrl] = useState(initial?.url ?? "");
const [priority, setPriority] = useState(initial === undefined ? DEFAULT_PRIORITY : String(initial.priority));
const [enabled, setEnabled] = useState(initial?.enabled ?? true);
const [tlsName, setTlsName] = useState(initial?.tls_name ?? "");
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const parsed = Number(priority);
try {
// PUT replaces the row, so every field goes on every submit.
await onSubmit({
url: url.trim(),
priority: Number.isFinite(parsed) ? parsed : 0,
enabled,
tls_name: tlsName.trim(),
});
if (initial === undefined) {
setUrl("");
setPriority(DEFAULT_PRIORITY);
setEnabled(true);
setTlsName("");
}
} catch {
// The page renders the mutation error inline below the form.
}
}
return (
<form onSubmit={handleSubmit} className="mt-4 max-w-xl space-y-3">
<h2 className="text-lg font-medium">{initial === undefined ? "Add upstream" : `Edit ${initial.url}`}</h2>
<div>
<label htmlFor="upstream-url" className="block text-sm font-medium">
URL
</label>
<input
id="upstream-url"
type="text"
required
value={url}
onChange={(event) => setUrl(event.target.value)}
placeholder="udp://1.1.1.1:53"
className={INPUT_CLASS}
/>
</div>
<div>
<label htmlFor="upstream-priority" className="block text-sm font-medium">
Priority
</label>
<input
id="upstream-priority"
type="number"
min={0}
value={priority}
onChange={(event) => setPriority(event.target.value)}
className={INPUT_CLASS}
/>
</div>
<div>
<label htmlFor="upstream-tls-name" className="block text-sm font-medium">
TLS name
</label>
<input
id="upstream-tls-name"
type="text"
value={tlsName}
onChange={(event) => setTlsName(event.target.value)}
placeholder="one.one.one.one"
className={INPUT_CLASS}
/>
<p className="mt-1 text-xs text-zinc-500">
The SNI and certificate name for a <code>tls://</code> upstream. Leave empty for every other scheme.
</p>
</div>
<label className="flex items-center gap-2 text-sm font-medium">
<input type="checkbox" checked={enabled} onChange={(event) => setEnabled(event.target.checked)} />
Enabled
</label>
<div className="flex items-center gap-2">
<button
type="submit"
disabled={busy}
className="rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
{initial === undefined ? "Add upstream" : "Save changes"}
</button>
{onCancel !== undefined && (
<button
type="button"
onClick={onCancel}
className="rounded border border-zinc-300 px-3 py-1.5 text-sm font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700"
>
Cancel
</button>
)}
</div>
<InlineError error={error} />
</form>
);
}
@@ -0,0 +1,222 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { AuthProvider } from "@/auth/store";
import { dismissRestartBanner } from "@/features/settings/restartBanner";
import { createQueryClient } from "@/lib/queryClient";
import { createAppRouter } from "@/routes";
const UPSTREAMS = {
upstreams: [
{ id: 1, url: "udp://1.1.1.1:53", priority: 100, enabled: true, tls_name: "" },
{ id: 2, url: "tls://9.9.9.9:853", priority: 200, enabled: false, tls_name: "dns.quad9.net" },
],
};
const RESPONSES: Record<string, unknown> = {
"/api/upstreams": UPSTREAMS,
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
};
interface Call {
url: string;
method: string;
body: unknown;
}
let calls: Call[];
let writeResponse: (() => Response) | null;
beforeEach(() => {
calls = [];
writeResponse = null;
dismissRestartBanner();
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
const method = init?.method ?? "GET";
if (method !== "GET") {
calls.push({
url,
method,
body: typeof init?.body === "string" ? JSON.parse(init.body) : undefined,
});
if (writeResponse !== null) return writeResponse();
if (method === "DELETE") return new Response(null, { status: 204 });
return new Response(
JSON.stringify({
id: 3,
url: "udp://8.8.8.8:53",
priority: 100,
enabled: true,
tls_name: "",
restart_required: true,
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
const payload = RESPONSES[url];
if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "content-type": "application/json" },
});
}),
);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
async function renderUpstreamsRoute() {
const queryClient = createQueryClient();
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/upstreams"] }), queryClient);
render(
<AuthProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AuthProvider>,
);
await screen.findByRole("heading", { name: "Upstreams" });
}
test("renders the upstream table and the add form", async () => {
await renderUpstreamsRoute();
expect(screen.getByText("udp://1.1.1.1:53")).toBeTruthy();
expect(screen.getByText("tls://9.9.9.9:853")).toBeTruthy();
expect(screen.getByText("100")).toBeTruthy();
expect(screen.getByText("200")).toBeTruthy();
expect(screen.getByText("dns.quad9.net")).toBeTruthy();
const enabledToggle = screen.getByLabelText("udp://1.1.1.1:53 enabled") as HTMLInputElement;
expect(enabledToggle.checked).toBe(true);
const disabledToggle = screen.getByLabelText("tls://9.9.9.9:853 enabled") as HTMLInputElement;
expect(disabledToggle.checked).toBe(false);
expect(screen.getByRole("heading", { name: "Add upstream" })).toBeTruthy();
expect(screen.getByText(/reflects the running pool/)).toBeTruthy();
});
test("adding an upstream posts every field and raises the restart banner", async () => {
await renderUpstreamsRoute();
fireEvent.change(screen.getByLabelText("URL"), { target: { value: "udp://8.8.8.8:53" } });
fireEvent.change(screen.getByLabelText("Priority"), { target: { value: "150" } });
fireEvent.click(screen.getByRole("button", { name: "Add upstream" }));
await waitFor(() => expect(calls).toHaveLength(1));
expect(calls[0]).toEqual({
url: "/api/upstreams",
method: "POST",
body: { url: "udp://8.8.8.8:53", priority: 150, enabled: true, tls_name: "" },
});
const banner = await screen.findByRole("status");
expect(banner.textContent).toContain("Changes saved. Restart nxdns to apply.");
});
test("toggling enabled resends the whole row", async () => {
await renderUpstreamsRoute();
fireEvent.click(screen.getByLabelText("tls://9.9.9.9:853 enabled"));
await waitFor(() => expect(calls).toHaveLength(1));
expect(calls[0]).toEqual({
url: "/api/upstreams/2",
method: "PUT",
body: { url: "tls://9.9.9.9:853", priority: 200, enabled: true, tls_name: "dns.quad9.net" },
});
await screen.findByRole("status");
});
test("delete asks for confirmation and skips the request when refused", async () => {
await renderUpstreamsRoute();
vi.spyOn(window, "confirm").mockReturnValue(false);
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]!);
expect(calls).toHaveLength(0);
vi.spyOn(window, "confirm").mockReturnValue(true);
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]!);
await waitFor(() => expect(calls).toHaveLength(1));
expect(calls[0]!.method).toBe("DELETE");
expect(calls[0]!.url).toBe("/api/upstreams/1");
await screen.findByRole("status");
});
test("a 409 on create renders the conflict text inline", async () => {
await renderUpstreamsRoute();
writeResponse = () =>
new Response(JSON.stringify({ error: "an upstream with that url already exists" }), {
status: 409,
headers: { "content-type": "application/json" },
});
fireEvent.change(screen.getByLabelText("URL"), { target: { value: "udp://1.1.1.1:53" } });
fireEvent.click(screen.getByRole("button", { name: "Add upstream" }));
const alert = await screen.findByRole("alert");
expect(alert.textContent).toBe("an upstream with that url already exists");
expect(screen.queryByRole("status")).toBeNull();
});
test("a 409 on toggle renders the last-enabled conflict above the form", async () => {
await renderUpstreamsRoute();
writeResponse = () =>
new Response(JSON.stringify({ error: "the last enabled upstream cannot be disabled" }), {
status: 409,
headers: { "content-type": "application/json" },
});
fireEvent.click(screen.getByLabelText("udp://1.1.1.1:53 enabled"));
const alert = await screen.findByRole("alert");
expect(alert.textContent).toBe("the last enabled upstream cannot be disabled");
expect(screen.queryByRole("status")).toBeNull();
});
test("a 409 on delete renders the last-enabled conflict", async () => {
await renderUpstreamsRoute();
vi.spyOn(window, "confirm").mockReturnValue(true);
writeResponse = () =>
new Response(JSON.stringify({ error: "the last enabled upstream cannot be removed" }), {
status: 409,
headers: { "content-type": "application/json" },
});
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]!);
const alert = await screen.findByRole("alert");
expect(alert.textContent).toBe("the last enabled upstream cannot be removed");
expect(screen.queryByRole("status")).toBeNull();
});
test("editing a row seeds the form and PUTs the replaced row", async () => {
await renderUpstreamsRoute();
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[1]!);
await screen.findByRole("heading", { name: "Edit tls://9.9.9.9:853" });
expect((screen.getByLabelText("URL") as HTMLInputElement).value).toBe("tls://9.9.9.9:853");
expect((screen.getByLabelText("Priority") as HTMLInputElement).value).toBe("200");
expect((screen.getByLabelText("TLS name") as HTMLInputElement).value).toBe("dns.quad9.net");
fireEvent.change(screen.getByLabelText("Priority"), { target: { value: "10" } });
fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
await waitFor(() => expect(calls).toHaveLength(1));
expect(calls[0]).toEqual({
url: "/api/upstreams/2",
method: "PUT",
body: { url: "tls://9.9.9.9:853", priority: 10, enabled: false, tls_name: "dns.quad9.net" },
});
await screen.findByRole("heading", { name: "Add upstream" });
});
@@ -0,0 +1,131 @@
import { useState } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import InlineError from "@/lib/InlineError";
import { upstreamCreateMutation, upstreamDeleteMutation, upstreamUpdateMutation, upstreamsQuery } from "@/lib/queries";
import type { Upstream, UpstreamInput } from "@/lib/types";
import { raiseRestartBanner } from "../settings/restartBanner";
import UpstreamForm from "./UpstreamForm";
const TH_CLASS = "border-b border-zinc-300 px-3 py-2 text-left font-medium dark:border-zinc-700";
const TD_CLASS = "border-b border-zinc-200 px-3 py-2 dark:border-zinc-800";
export default function UpstreamsPage() {
const queryClient = useQueryClient();
const { data: upstreams } = useSuspenseQuery(upstreamsQuery());
const [editing, setEditing] = useState<Upstream | null>(null);
const create = useMutation(upstreamCreateMutation(queryClient));
const save = useMutation(upstreamUpdateMutation(queryClient));
const toggle = useMutation(upstreamUpdateMutation(queryClient));
const remove = useMutation(upstreamDeleteMutation(queryClient));
async function submitForm(input: UpstreamInput) {
if (editing === null) {
await create.mutateAsync(input);
} else {
await save.mutateAsync({ id: editing.id, input });
setEditing(null);
}
raiseRestartBanner();
}
function toggleEnabled(u: Upstream) {
toggle.mutate(
{
id: u.id,
input: { url: u.url, priority: u.priority, enabled: !u.enabled, tls_name: u.tls_name },
},
{ onSuccess: () => raiseRestartBanner() },
);
}
function deleteUpstream(u: Upstream) {
if (window.confirm(`Delete upstream "${u.url}"? Queries stop being forwarded to it.`)) {
remove.mutate(u.id, { onSuccess: () => raiseRestartBanner() });
}
}
const formError = editing === null ? create.error : save.error;
const tableError = remove.error ?? toggle.error;
return (
<section>
<h1 className="text-2xl font-semibold">Upstreams</h1>
<p className="mt-2 max-w-2xl text-sm text-zinc-500">
The pool builds its clients at startup, so an edit here takes effect at the next restart. The upstream
health table on the Dashboard reflects the running pool, not this list.
</p>
{upstreams.length === 0 ? (
<p className="mt-4 text-zinc-500">No upstreams yet. Add one below.</p>
) : (
<div className="mt-4 overflow-x-auto">
<table className="w-full min-w-max border-collapse text-sm">
<thead>
<tr>
<th className={TH_CLASS}>URL</th>
<th className={TH_CLASS}>Priority</th>
<th className={TH_CLASS}>Enabled</th>
<th className={TH_CLASS}>TLS name</th>
<th className={TH_CLASS}>
<span className="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody>
{upstreams.map((u) => (
<tr key={u.id}>
<td className={TD_CLASS}>
<span className="block max-w-72 truncate font-medium" title={u.url}>
{u.url}
</span>
</td>
<td className={`${TD_CLASS} tabular-nums`}>{u.priority}</td>
<td className={TD_CLASS}>
<input
type="checkbox"
aria-label={`${u.url} enabled`}
checked={u.enabled}
disabled={toggle.isPending}
onChange={() => toggleEnabled(u)}
/>
</td>
<td className={TD_CLASS}>{u.tls_name === "" ? "—" : u.tls_name}</td>
<td className={TD_CLASS}>
<div className="flex gap-3">
<button
type="button"
onClick={() => setEditing(u)}
className="text-sm font-medium text-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:text-blue-400"
>
Edit
</button>
<button
type="button"
onClick={() => deleteUpstream(u)}
disabled={remove.isPending}
className="text-sm font-medium text-red-600 disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:text-red-400"
>
Delete
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<InlineError error={tableError} />
<UpstreamForm
key={editing?.id ?? "add"}
initial={editing ?? undefined}
busy={editing === null ? create.isPending : save.isPending}
error={formError}
onSubmit={submitForm}
onCancel={editing === null ? undefined : () => setEditing(null)}
/>
</section>
);
}
-15
View File
@@ -81,12 +81,6 @@ async function request<T>(path: string, init?: { method?: string; body?: unknown
return (await res.json()) as T;
}
async function requestText(path: string): Promise<string> {
const res = await fetch(path, { credentials: "same-origin" });
if (!res.ok) throw await toApiError(res);
return res.text();
}
type QueryValue = string | number | boolean | undefined;
function qs(params: Record<string, QueryValue>): string {
@@ -100,10 +94,8 @@ function qs(params: Record<string, QueryValue>): string {
// Monitoring + meta
export const getMetrics = (): Promise<string> => requestText("/metrics");
export const getHealth = (): Promise<Health> => request("/api/health");
export const getVersion = (): Promise<Version> => request("/api/version");
export const getOpenapiYaml = (): Promise<string> => requestText("/api/openapi.yaml");
// Auth
@@ -133,7 +125,6 @@ export const getUpstreamHealth = (): Promise<UpstreamHealth> => request("/api/up
export const listGroups = async (): Promise<Group[]> => (await request<{ groups: Group[] }>("/api/groups")).groups;
export const createGroup = (input: GroupInput): Promise<Group> =>
request("/api/groups", { method: "POST", body: input });
export const getGroup = (id: number): Promise<Group> => request(`/api/groups/${id}`);
export const updateGroup = (id: number, input: GroupInput): Promise<Group> =>
request(`/api/groups/${id}`, { method: "PUT", body: input });
export const deleteGroup = (id: number): Promise<void> => request(`/api/groups/${id}`, { method: "DELETE" });
@@ -156,7 +147,6 @@ export const createBlocklist = (input: BlocklistInput): Promise<BlocklistEcho> =
request("/api/blocklists", { method: "POST", body: input });
export const updateBlocklistsNow = async (): Promise<SourceStatus[]> =>
(await request<{ sources: SourceStatus[] }>("/api/blocklists/update", { method: "POST", body: {} })).sources;
export const getBlocklist = (id: number): Promise<Blocklist> => request(`/api/blocklists/${id}`);
export const updateBlocklist = (id: number, input: BlocklistInput): Promise<BlocklistEcho> =>
request(`/api/blocklists/${id}`, { method: "PUT", body: input });
export const deleteBlocklist = (id: number): Promise<void> => request(`/api/blocklists/${id}`, { method: "DELETE" });
@@ -166,7 +156,6 @@ export const deleteBlocklist = (id: number): Promise<void> => request(`/api/bloc
export const listRules = async (): Promise<Rule[]> => (await request<{ rules: Rule[] }>("/api/rules")).rules;
export const createRule = (input: RuleInput): Promise<RuleEcho> =>
request("/api/rules", { method: "POST", body: input });
export const getRule = (id: number): Promise<Rule> => request(`/api/rules/${id}`);
export const updateRule = (id: number, input: RuleInput): Promise<RuleEcho> =>
request(`/api/rules/${id}`, { method: "PUT", body: input });
export const deleteRule = (id: number): Promise<void> => request(`/api/rules/${id}`, { method: "DELETE" });
@@ -177,7 +166,6 @@ export const listLocalRecords = async (): Promise<LocalRecord[]> =>
(await request<{ local_records: LocalRecord[] }>("/api/local-records")).local_records;
export const createLocalRecord = (input: LocalRecordInput): Promise<LocalRecord> =>
request("/api/local-records", { method: "POST", body: input });
export const getLocalRecord = (id: number): Promise<LocalRecord> => request(`/api/local-records/${id}`);
export const updateLocalRecord = (id: number, input: LocalRecordInput): Promise<LocalRecord> =>
request(`/api/local-records/${id}`, { method: "PUT", body: input });
export const deleteLocalRecord = (id: number): Promise<void> =>
@@ -189,7 +177,6 @@ export const listForwardZones = async (): Promise<ForwardZone[]> =>
(await request<{ forward_zones: ForwardZone[] }>("/api/forward-zones")).forward_zones;
export const createForwardZone = (input: ForwardZoneInput): Promise<ForwardZone> =>
request("/api/forward-zones", { method: "POST", body: input });
export const getForwardZone = (id: number): Promise<ForwardZone> => request(`/api/forward-zones/${id}`);
export const updateForwardZone = (id: number, input: ForwardZoneInput): Promise<ForwardZone> =>
request(`/api/forward-zones/${id}`, { method: "PUT", body: input });
export const deleteForwardZone = (id: number): Promise<void> =>
@@ -199,7 +186,6 @@ export const deleteForwardZone = (id: number): Promise<void> =>
export const listClients = async (): Promise<Client[]> =>
(await request<{ clients: Client[] }>("/api/clients")).clients;
export const getClient = (id: number): Promise<Client> => request(`/api/clients/${id}`);
export const updateClient = (id: number, edit: ClientEdit): Promise<Client> =>
request(`/api/clients/${id}`, { method: "PUT", body: edit });
export const deleteClient = (id: number): Promise<void> => request(`/api/clients/${id}`, { method: "DELETE" });
@@ -220,7 +206,6 @@ export const listUpstreams = async (): Promise<Upstream[]> =>
(await request<{ upstreams: Upstream[] }>("/api/upstreams")).upstreams;
export const createUpstream = (input: UpstreamInput): Promise<UpstreamEcho> =>
request("/api/upstreams", { method: "POST", body: input });
export const getUpstream = (id: number): Promise<Upstream> => request(`/api/upstreams/${id}`);
export const updateUpstream = (id: number, input: UpstreamInput): Promise<UpstreamEcho> =>
request(`/api/upstreams/${id}`, { method: "PUT", body: input });
export const deleteUpstream = (id: number): Promise<void> => request(`/api/upstreams/${id}`, { method: "DELETE" });
+701
View File
@@ -0,0 +1,701 @@
// Generated file — do not edit by hand.
//
// Every value below is a real response from the web server, captured by the
// contract-sample test in src/web/web_integration_test.zig and canonicalized:
// object keys sorted, every number 0, strings and booleans as the deterministic
// seed produced them, repeated array elements collapsed to the first. The type
// annotations are the ones api.ts hands to its own `request<T>`, so `tsc`
// refuses a field the wire does not send, a wire field types.ts does not
// declare, and a string outside a literal union.
//
// Regenerate with:
// zig build test -Dintegration -Dcontract-samples-out="$PWD/web/src/lib/contractSamples.gen.ts"
import type {
Blocklist,
BlocklistEcho,
Client,
ClientPrefix,
ErrorEnvelope,
ForwardZone,
Group,
Health,
LocalRecord,
LoginResponse,
LogoutResponse,
LookupResult,
PauseState,
QueriesPage,
Rule,
RuleEcho,
SettingsEnvelope,
SourceStatus,
StatsTimeseries,
StatsTotals,
Upstream,
UpstreamEcho,
UpstreamHealth,
Version,
} from "@/lib/types";
export const sample_get_health: Health = {
disk: {
db_bytes: 0,
free_bytes: 0,
log_bytes: 0,
sample_failures: 0,
state: "ok",
},
queries_dropped: 0,
refreshes_gated: 0,
snapshot_generation: 0,
status: "ok",
upstreams: {
available: 0,
total: 0,
},
writer_failed: false,
};
export const sample_get_version: Version = {
git_commit: "<build>",
uptime_seconds: 0,
version: "w10-test",
zig_version: "<build>",
};
export const sample_login: LoginResponse = {
auth_required: false,
authenticated: true,
};
export const sample_logout: LogoutResponse = {
authenticated: false,
};
export const sample_create_blocklist: BlocklistEcho = {
enabled: false,
id: 0,
is_suggested: false,
name: "ads",
url: "https://lists.example/ads.txt",
};
export const sample_list_blocklists: { blocklists: Blocklist[] } = {
blocklists: [
{
checksum: null,
domain_count: 0,
enabled: false,
id: 0,
is_suggested: false,
last_updated: null,
name: "ads",
skipped_regex_count: 0,
url: "https://lists.example/ads.txt",
wildcard_count: 0,
},
],
};
export const sample_update_blocklist: BlocklistEcho = {
enabled: false,
id: 0,
is_suggested: false,
name: "ads2",
url: "https://lists.example/ads.txt",
};
export const sample_update_blocklists_now: { sources: SourceStatus[] } = {
sources: [
{
domains: 0,
id: 0,
last_attempt: 0,
last_error: "",
last_success: 0,
loaded: false,
skipped_regex: 0,
state: "never_fetched",
url: "https://lists.example/ads.txt",
wildcards: 0,
},
],
};
export const sample_list_groups: { groups: Group[] } = {
groups: [
{
id: 0,
name: "default",
safe_search: false,
},
],
};
export const sample_create_group: Group = {
id: 0,
name: "kids",
safe_search: false,
};
export const sample_update_group: Group = {
id: 0,
name: "teens",
safe_search: true,
};
export const sample_put_group_sources: { source_ids: number[] } = {
source_ids: [0],
};
export const sample_get_group_sources: { source_ids: number[] } = {
source_ids: [0],
};
export const sample_create_rule: RuleEcho = {
action: "block",
group_id: 0,
id: 0,
kind: "exact",
pattern: "ads.example",
};
export const sample_list_rules: { rules: Rule[] } = {
rules: [
{
action: "block",
created_at: 0,
group: "default",
group_id: 0,
id: 0,
kind: "exact",
pattern: "ads.example",
},
],
};
export const sample_update_rule: RuleEcho = {
action: "block",
group_id: 0,
id: 0,
kind: "wildcard",
pattern: "*.ads.example",
};
export const sample_get_lookup: LookupResult = {
blocked: true,
domain: "sub.ads.example",
forward_zone: null,
group_id: 0,
local_records: false,
matched: "*.ads.example",
reason: "rule_block_wildcard",
safe_search_rewrite: null,
source_url: null,
};
export const sample_create_local_record: LocalRecord = {
id: 0,
name: "nas.lan",
rtype: "A",
ttl: 0,
value: "192.168.1.10",
};
export const sample_list_local_records: { local_records: LocalRecord[] } = {
local_records: [
{
id: 0,
name: "nas.lan",
rtype: "A",
ttl: 0,
value: "192.168.1.10",
},
],
};
export const sample_update_local_record: LocalRecord = {
id: 0,
name: "nas.lan",
rtype: "A",
ttl: 0,
value: "192.168.1.11",
};
export const sample_create_forward_zone: ForwardZone = {
id: 0,
resolver: "udp://10.0.0.1:53",
zone: "lan",
};
export const sample_list_forward_zones: { forward_zones: ForwardZone[] } = {
forward_zones: [
{
id: 0,
resolver: "udp://10.0.0.1:53",
zone: "lan",
},
],
};
export const sample_update_forward_zone: ForwardZone = {
id: 0,
resolver: "udp://10.0.0.2:53",
zone: "lan",
};
export const sample_list_clients: { clients: Client[] } = {
clients: [
{
first_seen: 0,
group: "default",
group_id: 0,
hand_edited: false,
id: 0,
ip: "192.168.1.50",
last_seen: 0,
name: "laptop",
},
],
};
export const sample_update_client: Client = {
first_seen: 0,
group: "default",
group_id: 0,
hand_edited: true,
id: 0,
ip: "192.168.1.50",
last_seen: 0,
name: "laptop-renamed",
};
export const sample_put_client_prefixes: { client_prefixes: ClientPrefix[] } = {
client_prefixes: [
{
group: "default",
group_id: 0,
id: 0,
prefix: "192.168.1.0/24",
priority: 0,
},
],
};
export const sample_list_client_prefixes: { client_prefixes: ClientPrefix[] } = {
client_prefixes: [
{
group: "default",
group_id: 0,
id: 0,
prefix: "192.168.1.0/24",
priority: 0,
},
],
};
export const sample_list_upstreams: { upstreams: Upstream[] } = {
upstreams: [
{
enabled: true,
id: 0,
priority: 0,
tls_name: "",
url: "https://dns.example/dns-query",
},
],
};
export const sample_create_upstream: UpstreamEcho = {
enabled: true,
id: 0,
priority: 0,
restart_required: true,
tls_name: "",
url: "https://dns2.example/dns-query",
};
export const sample_update_upstream: UpstreamEcho = {
enabled: true,
id: 0,
priority: 0,
restart_required: true,
tls_name: "",
url: "https://dns.example/dns-query",
};
export const sample_get_upstream_health: UpstreamHealth = {
available: 0,
total: 0,
upstreams: [
{
available: true,
consecutive_failures: 0,
enabled: true,
last_error: "",
success_rate: 0,
total_failures: 0,
total_successes: 0,
url: "https://dns.example/dns-query",
},
],
};
export const sample_get_queries: QueriesPage = {
next_before: 0,
queries: [
{
block_reason: "",
blocked: false,
cache_hit: true,
client_ip: "192.0.2.10",
domain: "d24.example",
id: 0,
qtype: 0,
response_time_us: 0,
ts: 0,
upstream: "https://dns.example/dns-query",
},
{
block_reason: "",
blocked: false,
cache_hit: false,
client_ip: "192.0.2.10",
domain: "d23.example",
id: 0,
qtype: 0,
response_time_us: 0,
ts: 0,
upstream: "https://dns.example/dns-query",
},
{
block_reason: "",
blocked: false,
cache_hit: true,
client_ip: "192.0.2.10",
domain: "d22.example",
id: 0,
qtype: 0,
response_time_us: 0,
ts: 0,
upstream: "https://dns.example/dns-query",
},
{
block_reason: "",
blocked: false,
cache_hit: false,
client_ip: "192.0.2.10",
domain: "d21.example",
id: 0,
qtype: 0,
response_time_us: 0,
ts: 0,
upstream: "https://dns.example/dns-query",
},
{
block_reason: "blocklist_domain",
blocked: true,
cache_hit: null,
client_ip: "192.0.2.10",
domain: "d20.example",
id: 0,
qtype: 0,
response_time_us: 0,
ts: 0,
upstream: "",
},
],
};
export const sample_get_stats: StatsTotals = {
avg_response_time_us: null,
blocked: 0,
cached: 0,
clients: 0,
period: "1h",
queries: 0,
since: 0,
until: 0,
};
export const sample_get_stats_timeseries: StatsTimeseries = {
bucket_seconds: 0,
buckets: [
{
blocked: 0,
cached: 0,
queries: 0,
ts: 0,
},
],
period: "1h",
since: 0,
until: 0,
};
export const sample_get_pause: PauseState = {
paused: false,
until: null,
};
export const sample_post_pause: PauseState = {
paused: true,
until: 0,
};
export const sample_get_settings: SettingsEnvelope = {
restart_required: [
"upstream.attempt_timeout_ms",
"upstream.read_timeout_ms",
"upstream.total_timeout_ms",
"dns.bind_ipv4",
"dns.bind_ipv6",
"dns.port",
"dns.rate_limit",
"dns.rate_window_seconds",
"blocking.response",
"blocking.ttl",
"cache.size",
"cache.negative_ttl_max",
"web.enabled",
"web.bind",
"web.port",
"web.session_ttl_hours",
"web.api_rate_limit_per_min",
"web.api_localhost_exempt",
"web.sse_max_connections_per_ip",
"web.trusted_proxies",
"doh_server.enabled",
"doh_server.bind",
"doh_server.port",
"doh_server.cert_path",
"doh_server.key_path",
"dot_server.enabled",
"dot_server.bind",
"dot_server.port",
"dot_server.cert_path",
"dot_server.key_path",
"edns.ecs_mode",
"logging.level",
"logging.retention_days",
"logging.query_log_buffer_max",
"logging.hide_domains",
"logging.hide_client_ips",
"logging.output",
"logging.file_path",
"logging.max_size_mb",
"logging.max_files",
"disk.min_free_mb",
"disk.warn_free_mb",
"blocklist_update.enabled",
"blocklist_update.interval_hours",
],
settings: {
blocking: {
response: "zero",
ttl: 0,
},
blocklist_update: {
enabled: true,
interval_hours: 0,
},
cache: {
negative_ttl_max: 0,
size: 0,
},
disk: {
min_free_mb: 0,
warn_free_mb: 0,
},
dns: {
bind_ipv4: "0.0.0.0",
bind_ipv6: "::",
port: 0,
rate_limit: 0,
rate_window_seconds: 0,
},
doh_server: {
bind: "0.0.0.0",
cert_path: "/etc/nxdns/cert.pem",
enabled: false,
key_path: "/etc/nxdns/key.pem",
port: 0,
},
dot_server: {
bind: "0.0.0.0",
cert_path: "/etc/nxdns/cert.pem",
enabled: false,
key_path: "/etc/nxdns/key.pem",
port: 0,
},
edns: {
ecs_mode: "strip",
},
logging: {
file_path: "/var/log/nxdns/nxdns.log",
hide_client_ips: false,
hide_domains: false,
level: "info",
max_files: 0,
max_size_mb: 0,
output: "stderr",
query_log_buffer_max: 0,
retention_days: 0,
},
upstream: {
attempt_timeout_ms: 0,
read_timeout_ms: 0,
total_timeout_ms: 0,
},
web: {
api_localhost_exempt: true,
api_rate_limit_per_min: 0,
auth_enabled: false,
bind: "0.0.0.0",
enabled: true,
port: 0,
session_ttl_hours: 0,
sse_max_connections_per_ip: 0,
trusted_proxies: "",
},
},
};
export const sample_put_settings: SettingsEnvelope = {
restart_required: [
"upstream.attempt_timeout_ms",
"upstream.read_timeout_ms",
"upstream.total_timeout_ms",
"dns.bind_ipv4",
"dns.bind_ipv6",
"dns.port",
"dns.rate_limit",
"dns.rate_window_seconds",
"blocking.response",
"blocking.ttl",
"cache.size",
"cache.negative_ttl_max",
"web.enabled",
"web.bind",
"web.port",
"web.session_ttl_hours",
"web.api_rate_limit_per_min",
"web.api_localhost_exempt",
"web.sse_max_connections_per_ip",
"web.trusted_proxies",
"doh_server.enabled",
"doh_server.bind",
"doh_server.port",
"doh_server.cert_path",
"doh_server.key_path",
"dot_server.enabled",
"dot_server.bind",
"dot_server.port",
"dot_server.cert_path",
"dot_server.key_path",
"edns.ecs_mode",
"logging.level",
"logging.retention_days",
"logging.query_log_buffer_max",
"logging.hide_domains",
"logging.hide_client_ips",
"logging.output",
"logging.file_path",
"logging.max_size_mb",
"logging.max_files",
"disk.min_free_mb",
"disk.warn_free_mb",
"blocklist_update.enabled",
"blocklist_update.interval_hours",
],
settings: {
blocking: {
response: "zero",
ttl: 0,
},
blocklist_update: {
enabled: true,
interval_hours: 0,
},
cache: {
negative_ttl_max: 0,
size: 0,
},
disk: {
min_free_mb: 0,
warn_free_mb: 0,
},
dns: {
bind_ipv4: "0.0.0.0",
bind_ipv6: "::",
port: 0,
rate_limit: 0,
rate_window_seconds: 0,
},
doh_server: {
bind: "0.0.0.0",
cert_path: "/etc/nxdns/cert.pem",
enabled: false,
key_path: "/etc/nxdns/key.pem",
port: 0,
},
dot_server: {
bind: "0.0.0.0",
cert_path: "/etc/nxdns/cert.pem",
enabled: false,
key_path: "/etc/nxdns/key.pem",
port: 0,
},
edns: {
ecs_mode: "strip",
},
logging: {
file_path: "/var/log/nxdns/nxdns.log",
hide_client_ips: false,
hide_domains: false,
level: "info",
max_files: 0,
max_size_mb: 0,
output: "stderr",
query_log_buffer_max: 0,
retention_days: 0,
},
upstream: {
attempt_timeout_ms: 0,
read_timeout_ms: 0,
total_timeout_ms: 0,
},
web: {
api_localhost_exempt: true,
api_rate_limit_per_min: 0,
auth_enabled: false,
bind: "0.0.0.0",
enabled: true,
port: 0,
session_ttl_hours: 0,
sse_max_connections_per_ip: 0,
trusted_proxies: "",
},
},
};
export const sample_error_bad_request: ErrorEnvelope = {
error: "logging.level: not one of the values this setting accepts",
};
export const sample_error_conflict: ErrorEnvelope = {
error: "an upstream with that url already exists",
};
export const sample_error_not_found: ErrorEnvelope = {
error: "not found",
};
export const sample_error_unauthorized: ErrorEnvelope = {
error: "authentication required",
};
export const sample_error_rate_limited: ErrorEnvelope = {
error: "rate limited",
};
+9
View File
@@ -0,0 +1,9 @@
//! Embeds the committed contract-sample golden (milestone-17 ruling 5). Module
//! root for the `contract_samples` anonymous import (test builds only) — a
//! `.ts` file cannot root one, and @embedFile paths resolve relative to this
//! file. The `docs/docs.zig` pattern.
pub const bytes = @embedFile("contractSamples.gen.ts");
/// Repo-relative path, so a failing assertion names the file to regenerate.
pub const path = "web/src/lib/contractSamples.gen.ts";
-5
View File
@@ -184,11 +184,6 @@ export const ruleCreateMutation = (qc: QueryClient) => ({
onSuccess: () => invalidateRules(qc),
});
export const ruleUpdateMutation = (qc: QueryClient) => ({
mutationFn: ({ id, input }: { id: number; input: RuleInput }) => api.updateRule(id, input),
onSuccess: () => invalidateRules(qc),
});
export const ruleDeleteMutation = (qc: QueryClient) => ({
mutationFn: (id: number) => api.deleteRule(id),
onSuccess: () => invalidateRules(qc),
+2 -1
View File
@@ -3,7 +3,7 @@ import type { Settings } from "@/lib/types";
function baseSettings(): Settings {
return {
upstream: { read_timeout_ms: 3000, total_timeout_ms: 5000 },
upstream: { attempt_timeout_ms: 2500, read_timeout_ms: 3000, total_timeout_ms: 5000 },
dns: { bind_ipv4: "0.0.0.0", bind_ipv6: "::", port: 53, rate_limit: 100, rate_window_seconds: 60 },
blocking: { response: "zero", ttl: 300 },
cache: { size: 10000, negative_ttl_max: 300 },
@@ -15,6 +15,7 @@ function baseSettings(): Settings {
api_rate_limit_per_min: 60,
api_localhost_exempt: true,
sse_max_connections_per_ip: 2,
trusted_proxies: "",
auth_enabled: true,
},
doh_server: { enabled: false, bind: "0.0.0.0", port: 443, cert_path: "", key_path: "" },
+11
View File
@@ -3,6 +3,14 @@
export type Period = "1h" | "24h" | "7d" | "30d";
/**
* Every non-2xx JSON response: `http_util.respondError` writes this one field
* and nothing else. `ApiError.message` in api.ts reads `error` out of it.
*/
export interface ErrorEnvelope {
error: string;
}
export interface Health {
status: "ok" | "degraded";
disk: {
@@ -314,6 +322,7 @@ export interface TlsListenerSettings {
export interface Settings {
upstream: {
attempt_timeout_ms: number;
read_timeout_ms: number;
total_timeout_ms: number;
};
@@ -340,6 +349,8 @@ export interface Settings {
api_rate_limit_per_min: number;
api_localhost_exempt: boolean;
sse_max_connections_per_ip: number;
/** Comma-separated IP literals; empty trusts no proxy's X-Forwarded-For. */
trusted_proxies: string;
/** Derived, read-only; true iff a password hash is stored. Never sent back. */
auth_enabled: boolean;
};
+9
View File
@@ -25,6 +25,7 @@ import {
statsQuery,
timeseriesQuery,
upstreamHealthQuery,
upstreamsQuery,
} from "@/lib/queries";
export interface RouterContext {
@@ -169,6 +170,13 @@ const localDnsRoute = createRoute({
component: lazyRouteComponent(() => import("@/features/local/LocalDnsPage")),
});
const upstreamsRoute = createRoute({
getParentRoute: () => shellRoute,
path: "/upstreams",
loader: ({ context }) => context.queryClient.ensureQueryData(upstreamsQuery()),
component: lazyRouteComponent(() => import("@/features/upstreams/UpstreamsPage")),
});
const lookupRoute = createRoute({
getParentRoute: () => shellRoute,
path: "/lookup",
@@ -194,6 +202,7 @@ const routeTree = rootRoute.addChildren([
blocklistsRoute,
rulesRoute,
localDnsRoute,
upstreamsRoute,
lookupRoute,
settingsRoute,
]),
+1
View File
@@ -14,6 +14,7 @@ const NAV_LABELS = [
"Blocklists",
"Rules",
"Local DNS",
"Upstreams",
"Lookup",
"Settings",
];
+1
View File
@@ -16,6 +16,7 @@ const NAV_ITEMS = [
{ to: "/blocklists", label: "Blocklists" },
{ to: "/rules", label: "Rules" },
{ to: "/local-dns", label: "Local DNS" },
{ to: "/upstreams", label: "Upstreams" },
{ to: "/lookup", label: "Lookup" },
{ to: "/settings", label: "Settings" },
] as const;