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>
);
}