feat: add dnsmasq DNS record admin UI

- store records in SQLite via built-in node:sqlite, no native deps
- wrap DB reads in await io() so they stay out of the static shell
- add CRUD pages backed by Server Actions with per-request data
- generate managed config and restart container via Docker socket
- add /resolve page to query dnsmasq for A and CNAME records
- add Dockerfile and compose setup with shared config volume
This commit is contained in:
siujamo committed 2026-10-09 16:47:31 +08:00
1 parent c5585f2b13
commit 97ad09ad8a
27 files changed
+1344 -130

No files matched your search

+13 -13
View File
@@ -1,26 +1,26 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
+33 -14
View File
@@ -1,29 +1,48 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import type { Metadata } from "next"
import { Geist, Geist_Mono } from "next/font/google"
import Link from "next/link"
import "./globals.css"
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
})
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
})
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
title: "dnsmasq 管理",
description: "管理 Docker 中 dnsmasq 的 DNS 记录",
}
const NAV_ITEMS = [
{ href: "/records", label: "域名记录" },
{ href: "/resolve", label: "解析测试" },
]
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
<html lang="zh-CN" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}>
<body className="min-h-full flex flex-col bg-zinc-50 text-zinc-900 dark:bg-black dark:text-zinc-100">
<header className="border-b border-black/10 bg-white dark:border-white/15 dark:bg-zinc-950">
<nav className="mx-auto flex max-w-4xl items-center gap-6 px-6 py-3">
<span className="text-sm font-semibold">dnsmasq 管理</span>
{NAV_ITEMS.map((item) => (
<Link
key={item.href}
href={item.href}
className="text-sm text-zinc-600 transition-opacity hover:opacity-70 dark:text-zinc-400">
{item.label}
</Link>
))}
</nav>
</header>
<main className="mx-auto w-full max-w-4xl flex-1 px-6 py-8">{children}</main>
</body>
</html>
);
)
}
+2 -66
View File
@@ -1,69 +1,5 @@
import Image from "next/image";
import { redirect } from "next/navigation"
export default function Home() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert h-5 w-[100px]"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the{" "}
<code className="rounded bg-black/[.06] px-1.5 py-0.5 font-mono text-[0.9em] dark:bg-white/[.08]">
page.tsx
</code>{" "}
file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert h-[14px] w-4"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={14}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
redirect("/records")
}
+41
View File
@@ -0,0 +1,41 @@
import { notFound } from "next/navigation"
import { Suspense } from "react"
import { getRecord } from "@/lib/records"
import { RecordForm } from "../../_components/RecordForm"
import { updateRecordAction } from "../../actions"
export const metadata = { title: "编辑记录" }
export default function EditRecordPage({ params }: PageProps<"/records/[id]">) {
return (
<section className="space-y-6">
<h1 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">编辑记录</h1>
{/* params are awaited inside Suspense so the shell can still be prerendered */}
<Suspense fallback={<p className="text-sm text-zinc-500 dark:text-zinc-400">加载中…</p>}>
<EditRecordForm params={params} />
</Suspense>
</section>
)
}
async function EditRecordForm({ params }: Pick<PageProps<"/records/[id]">, "params">) {
const { id } = await params
const numericId = Number(id)
// Guard against /records/abc: Number() would hand NaN to SQLite.
if (!Number.isInteger(numericId) || numericId <= 0) notFound()
const record = await getRecord(numericId)
if (!record) notFound()
return (
<RecordForm
action={updateRecordAction}
record={record}
submitLabel="保存修改"
cancelHref="/records"
/>
)
}
+37
View File
@@ -0,0 +1,37 @@
"use client"
import { useActionState } from "react"
import type { ActionState } from "@/lib/action-state"
export interface ApplyButtonProps {
action: (prev: ActionState, formData: FormData) => Promise<ActionState>
}
export function ApplyButton({ action }: ApplyButtonProps) {
const [state, formAction, pending] = useActionState<ActionState, FormData>(action, { ok: false })
return (
<div className="space-y-2">
<form action={formAction}>
<button
type="submit"
disabled={pending}
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-zinc-700 disabled:opacity-50 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-white">
{pending ? "应用中…" : "应用并重启 dnsmasq"}
</button>
</form>
{state.message ? (
<div className="space-y-1 text-sm">
<p
className={state.ok ? "text-emerald-700 dark:text-emerald-400" : "text-red-600 dark:text-red-400"}>
{state.message}
</p>
{state.detail ? (
<p className="text-xs text-zinc-500 dark:text-zinc-400">{state.detail}</p>
) : null}
</div>
) : null}
</div>
)
}
+14
View File
@@ -0,0 +1,14 @@
"use client"
export function DeleteButton() {
return (
<button
type="submit"
onClick={(event) => {
if (!window.confirm("确定要删除这条记录吗?")) event.preventDefault()
}}
className="text-sm text-red-600 transition-opacity hover:opacity-70 dark:text-red-400">
删除
</button>
)
}
+127
View File
@@ -0,0 +1,127 @@
"use client"
import Link from "next/link"
import { useActionState } from "react"
import type { ActionState } from "@/lib/action-state"
import type { DnsRecord } from "@/lib/db"
const fieldClass =
"w-full rounded-md border border-black/10 bg-white px-3 py-2 text-sm text-zinc-900 outline-none focus:border-zinc-500 dark:border-white/15 dark:bg-white/5 dark:text-zinc-100"
const labelClass = "block text-sm font-medium text-zinc-700 dark:text-zinc-300"
function FieldError({ message }: { message?: string }) {
if (!message) return null
return <p className="mt-1 text-xs text-red-600 dark:text-red-400">{message}</p>
}
export interface RecordFormProps {
/** Server Action to run on submit: either create or update, both bound to this form. */
action: (prev: ActionState, formData: FormData) => Promise<ActionState>
record?: DnsRecord
submitLabel: string
cancelHref: string
}
export function RecordForm({ action, record, submitLabel, cancelHref }: RecordFormProps) {
const [state, formAction, pending] = useActionState<ActionState, FormData>(action, { ok: false })
const errors = state.fieldErrors ?? {}
return (
<form action={formAction} className="max-w-xl space-y-4">
{record ? <input type="hidden" name="id" value={record.id} /> : null}
{state.message ? (
<p className="rounded-md bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-500/10 dark:text-red-300">
{state.message}
</p>
) : null}
<div>
<label className={labelClass} htmlFor="domain">
域名
</label>
<input
id="domain"
name="domain"
className={fieldClass}
defaultValue={record?.domain}
placeholder="nas.home"
autoComplete="off"
spellCheck={false}
/>
<p className="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
无需通配符:dnsmasq 的 <code>address=/域名/</code> 已自动覆盖其所有子域名。
</p>
<FieldError message={errors.domain} />
</div>
<div>
<label className={labelClass} htmlFor="type">
记录类型
</label>
<select id="type" name="type" className={fieldClass} defaultValue={record?.type ?? "A"}>
<option value="A">A(域名 → IPv4)</option>
<option value="CNAME">CNAME(别名 → 域名)</option>
</select>
</div>
<div>
<label className={labelClass} htmlFor="value">
目标值
</label>
<input
id="value"
name="value"
className={fieldClass}
defaultValue={record?.value}
placeholder="192.168.1.10"
autoComplete="off"
spellCheck={false}
/>
<p className="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
A 记录填 IPv4 地址,CNAME 记录填目标域名。
</p>
<FieldError message={errors.value} />
</div>
<div>
<label className={labelClass} htmlFor="comment">
备注
</label>
<input
id="comment"
name="comment"
className={fieldClass}
defaultValue={record?.comment}
placeholder="选填"
/>
<FieldError message={errors.comment} />
</div>
<label className="flex items-center gap-2 text-sm text-zinc-700 dark:text-zinc-300">
<input
type="checkbox"
name="enabled"
defaultChecked={record ? record.enabled : true}
className="size-4 accent-zinc-900 dark:accent-zinc-100"
/>
启用(取消勾选则不会写入配置文件)
</label>
<div className="flex items-center gap-3 pt-2">
<button
type="submit"
disabled={pending}
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-zinc-700 disabled:opacity-50 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-white">
{pending ? "保存中…" : submitLabel}
</button>
<Link
href={cancelHref}
className="rounded-md border border-black/10 px-4 py-2 text-sm text-zinc-700 transition-colors hover:bg-black/5 dark:border-white/15 dark:text-zinc-300 dark:hover:bg-white/5">
取消
</Link>
</div>
</form>
)
}
+115
View File
@@ -0,0 +1,115 @@
"use server"
import { revalidatePath } from "next/cache"
import { redirect } from "next/navigation"
import type { ActionState } from "@/lib/action-state"
import { writeManagedConfig } from "@/lib/dnsmasq-config"
import { restartDnsmasqContainer } from "@/lib/docker"
import {
DuplicateDomainError,
createRecord,
listEnabledRecords,
removeRecord,
setRecordEnabled,
updateRecord,
} from "@/lib/records"
import { parseRecordForm } from "@/lib/validate"
const RECORDS_PATH = "/records"
function readId(formData: FormData): number | null {
const raw = formData.get("id")
const id = typeof raw === "string" ? Number(raw) : NaN
return Number.isInteger(id) && id > 0 ? id : null
}
function asDuplicateState(error: unknown): ActionState | null {
if (error instanceof DuplicateDomainError) {
return { ok: false, fieldErrors: { domain: `域名 ${error.domain} 已存在` } }
}
return null
}
export async function createRecordAction(
_prev: ActionState,
formData: FormData
): Promise<ActionState> {
const parsed = parseRecordForm(formData)
if (!parsed.ok) return { ok: false, fieldErrors: parsed.fieldErrors }
try {
createRecord(parsed.input)
} catch (error) {
const duplicate = asDuplicateState(error)
if (duplicate) return duplicate
throw error
}
revalidatePath(RECORDS_PATH)
redirect(RECORDS_PATH)
}
export async function updateRecordAction(
_prev: ActionState,
formData: FormData
): Promise<ActionState> {
const id = readId(formData)
if (id === null) return { ok: false, message: "记录不存在,可能已被删除" }
const parsed = parseRecordForm(formData)
if (!parsed.ok) return { ok: false, fieldErrors: parsed.fieldErrors }
try {
updateRecord(id, parsed.input)
} catch (error) {
const duplicate = asDuplicateState(error)
if (duplicate) return duplicate
throw error
}
revalidatePath(RECORDS_PATH)
redirect(RECORDS_PATH)
}
export async function deleteRecordAction(formData: FormData): Promise<void> {
const id = readId(formData)
if (id === null) return
removeRecord(id)
revalidatePath(RECORDS_PATH)
}
export async function toggleRecordAction(formData: FormData): Promise<void> {
const id = readId(formData)
if (id === null) return
// The hidden field carries the state this record should move *to*.
setRecordEnabled(id, formData.get("enabled") === "1")
revalidatePath(RECORDS_PATH)
}
/**
* Regenerate the managed dnsmasq config from SQLite and restart the container.
*
* Deliberately manual rather than automatic, so several edits can be batched into one
* restart (see the plan: 手动应用并重启).
*/
export async function applyConfigAction(
_prev: ActionState,
_formData: FormData
): Promise<ActionState> {
const written = await writeManagedConfig(await listEnabledRecords())
const reload = await restartDnsmasqContainer()
revalidatePath(RECORDS_PATH)
const writtenMessage = `已写入 ${written.recordCount} 条记录到 ${written.path}`
if (reload.status === "restarted") {
return { ok: true, message: `${writtenMessage},${reload.detail}` }
}
if (reload.status === "skipped") {
return { ok: true, message: writtenMessage, detail: reload.detail }
}
return { ok: false, message: `${writtenMessage},但重启失败`, detail: reload.detail }
}
+22
View File
@@ -0,0 +1,22 @@
import Link from "next/link"
import { RecordForm } from "../_components/RecordForm"
import { createRecordAction } from "../actions"
export const metadata = { title: "新增记录" }
export default function NewRecordPage() {
return (
<section className="space-y-6">
<header className="space-y-1">
<Link
href="/records"
className="text-sm text-zinc-500 transition-opacity hover:opacity-70 dark:text-zinc-400">
← 返回列表
</Link>
<h1 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">新增记录</h1>
</header>
<RecordForm action={createRecordAction} submitLabel="创建记录" cancelHref="/records" />
</section>
)
}
+118
View File
@@ -0,0 +1,118 @@
import Link from "next/link"
import { Suspense } from "react"
import { env } from "@/lib/env"
import { listRecords } from "@/lib/records"
import { applyConfigAction, deleteRecordAction, toggleRecordAction } from "./actions"
import { ApplyButton } from "./_components/ApplyButton"
import { DeleteButton } from "./_components/DeleteButton"
export const metadata = { title: "域名记录" }
const cardClass = "rounded-lg border border-black/10 p-4 dark:border-white/15"
export default function RecordsPage() {
return (
<section className="space-y-6">
<header className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">域名记录</h1>
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
记录保存在 SQLite 中,修改后需要「应用并重启」才会写入 dnsmasq 配置。
</p>
</div>
<Link
href="/records/new"
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-zinc-700 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-white">
新增记录
</Link>
</header>
<div className={cardClass}>
<h2 className="text-sm font-medium text-zinc-900 dark:text-zinc-100">生效</h2>
<p className="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
配置文件:<code>{env.confPath}</code> · 目标容器:<code>{env.containerName}</code>
</p>
<div className="mt-3">
<ApplyButton action={applyConfigAction} />
</div>
</div>
<Suspense fallback={<p className="text-sm text-zinc-500 dark:text-zinc-400">加载中…</p>}>
<RecordTable />
</Suspense>
</section>
)
}
async function RecordTable() {
const records = await listRecords()
if (records.length === 0) {
return (
<div className={cardClass}>
<p className="text-sm text-zinc-500 dark:text-zinc-400">
还没有任何记录,点右上角「新增记录」开始。
</p>
</div>
)
}
return (
<div className="overflow-x-auto rounded-lg border border-black/10 dark:border-white/15">
<table className="w-full text-left text-sm">
<thead className="border-b border-black/10 bg-zinc-50 text-xs uppercase text-zinc-500 dark:border-white/15 dark:bg-white/5 dark:text-zinc-400">
<tr>
<th className="px-4 py-2 font-medium">域名</th>
<th className="px-4 py-2 font-medium">类型</th>
<th className="px-4 py-2 font-medium">目标值</th>
<th className="px-4 py-2 font-medium">状态</th>
<th className="px-4 py-2 font-medium">备注</th>
<th className="px-4 py-2 font-medium">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-black/5 dark:divide-white/10">
{records.map((record) => (
<tr key={record.id}>
<td className="px-4 py-2 font-mono text-zinc-900 dark:text-zinc-100">{record.domain}</td>
<td className="px-4 py-2 text-zinc-600 dark:text-zinc-400">{record.type}</td>
<td className="px-4 py-2 font-mono text-zinc-600 dark:text-zinc-400">{record.value}</td>
<td className="px-4 py-2">
<span
className={
record.enabled
? "text-emerald-700 dark:text-emerald-400"
: "text-zinc-400 dark:text-zinc-500"
}>
{record.enabled ? "已启用" : "已禁用"}
</span>
</td>
<td className="px-4 py-2 text-zinc-600 dark:text-zinc-400">{record.comment || "—"}</td>
<td className="px-4 py-2">
<div className="flex items-center gap-3">
<Link
href={`/records/${record.id}`}
className="text-sm text-zinc-700 transition-opacity hover:opacity-70 dark:text-zinc-300">
编辑
</Link>
<form action={toggleRecordAction} className="inline">
<input type="hidden" name="id" value={record.id} />
<input type="hidden" name="enabled" value={record.enabled ? "0" : "1"} />
<button
type="submit"
className="text-sm text-zinc-700 transition-opacity hover:opacity-70 dark:text-zinc-300">
{record.enabled ? "禁用" : "启用"}
</button>
</form>
<form action={deleteRecordAction} className="inline">
<input type="hidden" name="id" value={record.id} />
<DeleteButton />
</form>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
+101
View File
@@ -0,0 +1,101 @@
import { Suspense } from "react"
import { env } from "@/lib/env"
import { lookupDomain } from "@/lib/dns"
import { normalizeDomain } from "@/lib/validate"
export const metadata = { title: "解析测试" }
const cardClass = "rounded-lg border border-black/10 p-4 dark:border-white/15"
export default function ResolvePage({ searchParams }: PageProps<"/resolve">) {
return (
<section className="space-y-6">
<header className="space-y-1">
<h1 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">解析测试</h1>
<p className="text-sm text-zinc-500 dark:text-zinc-400">
向 <code>{env.dnsServer}</code> 发起查询,验证记录是否已经生效。
</p>
</header>
<form action="/resolve" className="flex max-w-xl items-center gap-2">
<input
type="text"
name="domain"
defaultValue=""
placeholder="nas.home"
autoComplete="off"
spellCheck={false}
aria-label="域名"
className="w-full rounded-md border border-black/10 bg-white px-3 py-2 text-sm text-zinc-900 outline-none focus:border-zinc-500 dark:border-white/15 dark:bg-white/5 dark:text-zinc-100"
/>
<button
type="submit"
className="shrink-0 rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-zinc-700 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-white">
解析
</button>
</form>
{/* searchParams are request-time data, so they must be awaited behind Suspense */}
<Suspense fallback={<p className="text-sm text-zinc-500 dark:text-zinc-400">查询中…</p>}>
<LookupResult searchParams={searchParams} />
</Suspense>
</section>
)
}
async function LookupResult({ searchParams }: Pick<PageProps<"/resolve">, "searchParams">) {
const { domain } = await searchParams
const raw = Array.isArray(domain) ? domain[0] : domain
if (!raw) {
return (
<p className="text-sm text-zinc-500 dark:text-zinc-400">输入域名后点「解析」开始查询。</p>
)
}
const target = normalizeDomain(raw)
const outcome = await lookupDomain(target)
return (
<div className={cardClass}>
<p className="mb-3 font-mono text-sm text-zinc-900 dark:text-zinc-100">{target}</p>
{outcome.status === "a" ? (
<table className="w-full text-left text-sm">
<thead className="text-xs uppercase text-zinc-500 dark:text-zinc-400">
<tr>
<th className="py-1 font-medium">IPv4 地址</th>
<th className="py-1 font-medium">TTL</th>
</tr>
</thead>
<tbody>
{outcome.addresses.map((entry) => (
<tr key={entry.address}>
<td className="py-1 font-mono text-zinc-900 dark:text-zinc-100">{entry.address}</td>
<td className="py-1 text-zinc-600 dark:text-zinc-400">{entry.ttl}</td>
</tr>
))}
</tbody>
</table>
) : null}
{outcome.status === "cname" ? (
<ul className="space-y-1 text-sm">
{outcome.targets.map((item) => (
<li key={item} className="font-mono text-zinc-900 dark:text-zinc-100">
{target} → {item}
</li>
))}
</ul>
) : null}
{outcome.status === "empty" ? (
<p className="text-sm text-zinc-500 dark:text-zinc-400">{outcome.detail}</p>
) : null}
{outcome.status === "error" ? (
<p className="text-sm text-red-600 dark:text-red-400">{outcome.detail}</p>
) : null}
</div>
)
}