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
26 files changed
+1331 -117

No files matched your search

+19
View File
@@ -0,0 +1,19 @@
# Copy to .env.local and adjust for your setup. Every value is optional — the app falls back
# to local paths under ./data so it runs with no configuration at all.
# SQLite database — the single source of truth for records. Docker: /data/dnsmasq-admin.db
DNSMASQ_ADMIN_DB_PATH=./data/dnsmasq-admin.db
# Managed dnsmasq config file this app regenerates on every apply.
# Docker: /etc/dnsmasq.d/dnsmasq-admin.conf
DNSMASQ_ADMIN_CONF_PATH=./data/dnsmasq-admin.conf
# Container restarted over the Docker socket after an apply.
DNSMASQ_CONTAINER_NAME=dnsmasq
# Docker socket. If it is missing (e.g. developing on Windows), the restart is skipped and
# reported in the UI rather than failing.
DNSMASQ_ADMIN_DOCKER_SOCKET=/var/run/docker.sock
# DNS server used by the /resolve page. Point this at your dnsmasq container or LAN IP.
DNSMASQ_SERVER_IP=127.0.0.1
+4
View File
@@ -32,6 +32,10 @@ yarn-error.log*
# env files (can opt-in for committing if needed) # env files (can opt-in for committing if needed)
.env* .env*
!.env.example
# local sqlite database and generated dnsmasq config
/data
# vercel # vercel
.vercel .vercel
+15
View File
@@ -0,0 +1,15 @@
{
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"semi": false,
"singleQuote": false,
"quoteProps": "as-needed",
"jsxSingleQuote": false,
"trailingComma": "es5",
"bracketSpacing": true,
"bracketSameLine": true,
"arrowParens": "always",
"htmlWhitespaceSensitivity": "css",
"endOfLine": "lf"
}
+18
View File
@@ -0,0 +1,18 @@
# node:sqlite needs Node >= 22.5, so this must not drop below 22.
FROM node:24-alpine
RUN corepack enable
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
ENV NODE_ENV=production
ENV HOSTNAME=0.0.0.0
EXPOSE 3000
CMD ["pnpm", "start"]
+68 -22
View File
@@ -1,36 +1,82 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). # dnsmasq-admin
## Getting Started 一个极简的 Web UI,用来管理运行在 Docker 里的 dnsmasq 的 DNS 记录:域名记录的增删改查 + 一个解析测试页。
First, run the development server: - **记录列表**(`/records`):新增、编辑、启用/禁用、删除
- **解析测试**(`/resolve`):向 dnsmasq 发起 DNS 查询,验证记录是否已生效
- 支持 **A** 与 **CNAME** 两种记录类型
```bash ## 工作原理
npm run dev
# or ```
yarn dev ┌──────────┐ 写 /etc/dnsmasq.d/dnsmasq-admin.conf ┌──────────┐
# or │ 浏览器 │ ──────────> dnsmasq-admin (Next.js) ─────>│ dnsmasq │
pnpm dev └──────────┘ Docker socket: restart 容器 └──────────┘
# or │
bun dev └─> SQLite(唯一数据源)
``` ```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 1. 所有记录存在 **SQLite** 里(用 Node 内置的 `node:sqlite`,**没有任何原生依赖**,`node_modules` 里不会多出编译产物)。
2. 增删改都只改数据库,**不会立刻影响 dnsmasq**。
3. 点「应用并重启」后:按启用的记录重新生成托管配置文件 → 通过 Docker socket 重启 dnsmasq 容器使其生效。
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 之所以做成手动触发,是为了让你能先攒一批改动,再一次性重启 —— 每次改一条就重启一次容器没有必要,而且会让 DNS 短暂中断。
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. `address=/example.com/192.168.1.10` 这种写法会同时命中 `example.com` 及其**所有子域名**,所以不需要额外的通配符语法。
## Learn More ## 本地开发
To learn more about Next.js, take a look at the following resources: 需要 **Node >= 22.5**(`node:sqlite` 的最低要求)。
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. ```bash
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. pnpm install
pnpm dev
```
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! 打开 <http://localhost:3000>,会被重定向到 `/records`。
## Deploy on Vercel 本地运行不需要配任何环境变量 —— 数据库默认落在 `./data/dnsmasq-admin.db`,生成的配置默认落在 `./data/dnsmasq-admin.conf`。需要调整时复制 `.env.example` 为 `.env.local`。
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 在 **Windows / macOS 桌面上开发**时没有 `/var/run/docker.sock`,点「应用并重启」会提示「重启已跳过」,但配置文件**照常生成**,可以用来自查内容是否正确。完整的重启链路需要在装了 Docker 的 Linux 上验证。
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. ## Docker 部署
```bash
docker compose up -d
```
`docker-compose.yml` 里同时给了 dnsmasq 和 web 两个服务用于开箱联调。如果你已经有自己的 dnsmasq 容器,删掉 `dnsmasq` 服务,把 `dnsmasq-conf` 这个 volume 挂到你容器里的 `/etc/dnsmasq.d` 即可:
```yaml
# 在你现有的 dnsmasq 容器上加:
volumes:
- dnsmasq-conf:/etc/dnsmasq.d
```
注意两个 volume 都要保留:`app-data` 存着 SQLite 数据库,删了等于记录全丢。
## 环境变量
见 [`.env.example`](.env.example)。
| 变量 | 默认值 | 说明 |
| --- | --- | --- |
| `DNSMASQ_ADMIN_DB_PATH` | `./data/dnsmasq-admin.db` | SQLite 数据库位置 |
| `DNSMASQ_ADMIN_CONF_PATH` | `./data/dnsmasq-admin.conf` | 生成的托管配置文件 |
| `DNSMASQ_CONTAINER_NAME` | `dnsmasq` | 应用后要重启的容器名 |
| `DNSMASQ_ADMIN_DOCKER_SOCKET` | `/var/run/docker.sock` | Docker socket 路径;不存在则跳过重启 |
| `DNSMASQ_SERVER_IP` | `127.0.0.1` | `/resolve` 页查询用的 DNS 服务器 |
## ⚠️ 安全提示
**这个应用当前没有任何鉴权** —— 任何能访问到它的人都能增删 DNS 记录并重启你的 dnsmasq 容器。请务必只监听内网或本机,**不要暴露到公网**。
在 compose 里 `web` 挂载了 `/var/run/docker.sock`,这意味着该容器具备控制宿主机 Docker 的能力。这是设计使然(需要在应用后重启 dnsmasq),但应当把它当作高权限服务看待:不要用它跑不可信代码,端口只映射到内网网卡(如 `127.0.0.1:3000:3000`)而非 `0.0.0.0`。
## 技术备注(Next.js 16)
本项目启用了 `cacheComponents: true`,即 Next 16 的 Cache Components / PPR 模式。
这里踩到一个关键点:`node:sqlite` 是**同步**驱动,如果不显式处理,它的查询结果会在**构建期**被烤进预渲染的静态 shell,上线后页面永远显示构建时的旧数据。官方 issue 文档明确提到了这一点(`node_modules/next/dist/docs/01-app/03-api-reference/04-functions/io.md`),解法是在读库前 `await io()`,并把数据组件包在 `<Suspense>` 里 —— 参见 `lib/records.ts`。
同理,时间戳一律用 SQL 的 `datetime('now')` 而非 JS 的 `new Date()`,后者在预渲染阶段会直接导致构建失败。
+33 -14
View File
@@ -1,29 +1,48 @@
import type { Metadata } from "next"; import type { Metadata } from "next"
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google"
import "./globals.css"; import Link from "next/link"
import "./globals.css"
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
subsets: ["latin"], subsets: ["latin"],
}); })
const geistMono = Geist_Mono({ const geistMono = Geist_Mono({
variable: "--font-geist-mono", variable: "--font-geist-mono",
subsets: ["latin"], subsets: ["latin"],
}); })
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: "dnsmasq 管理",
description: "Generated by create next app", description: "管理 Docker 中 dnsmasq 的 DNS 记录",
}; }
const NAV_ITEMS = [
{ href: "/records", label: "域名记录" },
{ href: "/resolve", label: "解析测试" },
]
export default function RootLayout({ children }: LayoutProps<"/">) { export default function RootLayout({ children }: LayoutProps<"/">) {
return ( return (
<html <html lang="zh-CN" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}>
lang="en" <body className="min-h-full flex flex-col bg-zinc-50 text-zinc-900 dark:bg-black dark:text-zinc-100">
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} <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">
<body className="min-h-full flex flex-col">{children}</body> <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> </html>
); )
} }
+2 -66
View File
@@ -1,69 +1,5 @@
import Image from "next/image"; import { redirect } from "next/navigation"
export default function Home() { export default function Home() {
return ( redirect("/records")
<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>
);
} }
+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>
)
}
+38
View File
@@ -0,0 +1,38 @@
services:
# If you already run dnsmasq elsewhere, you can delete this service and simply mount the
# dnsmasq-conf volume at /etc/dnsmasq.d inside your existing container.
dnsmasq:
image: jpillora/dnsmasq
container_name: dnsmasq
cap_add:
- NET_ADMIN
ports:
- "53:53/udp"
volumes:
- dnsmasq-conf:/etc/dnsmasq.d
restart: unless-stopped
web:
build: .
ports:
- "3000:3000"
environment:
DNSMASQ_ADMIN_DB_PATH: /data/dnsmasq-admin.db
# Same mounted config directory dnsmasq reads from.
DNSMASQ_ADMIN_CONF_PATH: /etc/dnsmasq.d/dnsmasq-admin.conf
DNSMASQ_CONTAINER_NAME: dnsmasq
DNSMASQ_ADMIN_DOCKER_SOCKET: /var/run/docker.sock
# Composed DNS name of the dnsmasq service; use its LAN IP if it lives elsewhere.
DNSMASQ_SERVER_IP: dnsmasq
volumes:
# Shared with the dnsmasq container so the generated config is visible to it.
- dnsmasq-conf:/etc/dnsmasq.d
# Holds the SQLite database — keep this volume or you lose all records.
- app-data:/data
# Used only to restart the dnsmasq container after an apply.
- /var/run/docker.sock:/var/run/docker.sock
restart: unless-stopped
volumes:
dnsmasq-conf:
app-data:
+12
View File
@@ -0,0 +1,12 @@
import type { FieldErrors } from "./validate"
/**
* Shared shape returned by every Server Action that a form consumes via `useActionState`.
* Kept in its own module because a `'use server'` file may only export async functions.
*/
export interface ActionState {
ok: boolean
message?: string
detail?: string
fieldErrors?: FieldErrors
}
+84
View File
@@ -0,0 +1,84 @@
import { mkdirSync } from "node:fs"
import path from "node:path"
import { DatabaseSync } from "node:sqlite"
import { env } from "./env"
export type RecordType = "A" | "CNAME"
export interface DnsRecord {
id: number
domain: string
type: RecordType
value: string
enabled: boolean
comment: string
createdAt: string
updatedAt: string
}
const CREATE_TABLE = `
CREATE TABLE IF NOT EXISTS records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
domain TEXT NOT NULL COLLATE NOCASE UNIQUE,
type TEXT NOT NULL CHECK (type IN ('A','CNAME')),
value TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
comment TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`
function openDatabase(): DatabaseSync {
mkdirSync(path.dirname(env.dbPath), { recursive: true })
const db = new DatabaseSync(env.dbPath)
// WAL keeps reads from blocking writes; busy_timeout lets concurrent connections wait
// instead of failing immediately with SQLITE_BUSY.
db.exec("PRAGMA journal_mode = WAL")
db.exec("PRAGMA busy_timeout = 5000")
db.exec(CREATE_TABLE)
return db
}
// Cached on globalThis so Next.js dev HMR does not leak a new handle per reload.
const globalForDb = globalThis as typeof globalThis & {
__dnsmasqAdminDb?: DatabaseSync
}
export function getDb(): DatabaseSync {
globalForDb.__dnsmasqAdminDb ??= openDatabase()
return globalForDb.__dnsmasqAdminDb
}
type Row = Record<string, unknown>
/** Normalise whatever the driver hands back into a well-typed record. */
function rowsOf(result: unknown): Row[] {
return Array.isArray(result) ? (result as Row[]) : []
}
export function rowToRecord(row: Row): DnsRecord {
return {
id: Number(row.id),
domain: String(row.domain),
type: row.type === "CNAME" ? "CNAME" : "A",
value: String(row.value),
enabled: Number(row.enabled) === 1,
comment: String(row.comment ?? ""),
createdAt: String(row.created_at),
updatedAt: String(row.updated_at),
}
}
export function queryRows(sql: string, ...params: unknown[]): Row[] {
return rowsOf(getDb().prepare(sql).all(...params))
}
export function queryRow(sql: string, ...params: unknown[]): Row | null {
const row = getDb().prepare(sql).get(...params)
return row && typeof row === "object" ? (row as Row) : null
}
export function execute(sql: string, ...params: unknown[]): void {
getDb().prepare(sql).run(...params)
}
+88
View File
@@ -0,0 +1,88 @@
import { promises as dnsPromises } from "node:dns"
import { io } from "next/cache"
import { env } from "./env"
import { isValidDomain } from "./validate"
const LOOKUP_TIMEOUT_MS = 5000
export type LookupOutcome =
| { status: "a"; addresses: { address: string; ttl: number }[] }
| { status: "cname"; targets: string[] }
| { status: "empty"; detail: string }
| { status: "error"; detail: string }
const FAILURE_REASONS: Record<string, string> = {
ENOTFOUND: "域名不存在(NXDOMAIN)",
ENODATA: "该域名没有对应类型的解析记录",
ETIMEOUT: `查询超时,请确认 DNSMASQ_SERVER_IP 指向正确的 dnsmasq(当前 ${env.dnsServer})`,
ECONNREFUSED: "无法连接 DNS 服务器,请确认地址与端口",
EREFUSED: "DNS 服务器拒绝查询",
BADRESP: "DNS 服务器返回了无法解析的响应",
SERVFAIL: "DNS 服务器返回 SERVFAIL,请检查 dnsmasq 配置",
}
function describeFailure(error: unknown): string {
const { code, message } = (error ?? {}) as { code?: string; message?: string }
if (code && FAILURE_REASONS[code]) return FAILURE_REASONS[code]
if (code) return `查询失败(${code})${message ? `:${message}` : ""}`
return failureMessage(error)
}
function failureMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
function isNoRecords(error: unknown): boolean {
const { code } = (error ?? {}) as { code?: string }
return code === "ENOTFOUND" || code === "ENODATA"
}
function withTimeout<T>(promise: Promise<T>): Promise<T> {
let timer: NodeJS.Timeout | undefined
const guard = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
reject(Object.assign(new Error("ETIMEOUT"), { code: "ETIMEOUT" }))
}, LOOKUP_TIMEOUT_MS)
})
return Promise.race([promise, guard]).finally(() => {
if (timer) clearTimeout(timer)
})
}
/**
* Resolve a domain against the configured dnsmasq instance.
*
* A records are tried first because that is what most managed entries are; falling back to
* CNAME keeps aliases visible too. `await io()` keeps the lookup out of the static shell
* (it is a network read, and the result changes as records change).
*/
export async function lookupDomain(domain: string): Promise<LookupOutcome> {
await io()
if (!isValidDomain(domain)) {
return { status: "error", detail: "域名格式不正确,例如 nas.home" }
}
const resolver = new dnsPromises.Resolver()
resolver.setServers([env.dnsServer])
try {
const answers = await withTimeout(resolver.resolve4(domain, { ttl: true }))
return {
status: "a",
addresses: answers.map((answer) => ({ address: answer.address, ttl: answer.ttl })),
}
} catch (error) {
if (!isNoRecords(error)) return { status: "error", detail: describeFailure(error) }
}
try {
const targets = await withTimeout(resolver.resolveCname(domain))
if (targets.length === 0) return { status: "empty", detail: "没有 A 记录,也没有 CNAME 记录" }
return { status: "cname", targets }
} catch (error) {
if (isNoRecords(error)) return { status: "empty", detail: "没有 A 记录,也没有 CNAME 记录" }
return { status: "error", detail: describeFailure(error) }
}
}
+52
View File
@@ -0,0 +1,52 @@
import { mkdirSync, renameSync, writeFileSync } from "node:fs"
import path from "node:path"
import type { DnsRecord } from "./db"
import { env } from "./env"
const HEADER = "# Generated by dnsmasq-admin — 请勿手动编辑,每次「应用并重启」都会覆盖此文件"
/**
* dnsmasq's `address=/domain/ip` already answers for the domain *and* every subdomain, so
* there is no need for a separate wildcard syntax.
*/
export function renderRecord(record: DnsRecord): string {
return record.type === "A"
? `address=/${record.domain}/${record.value}`
: `cname=${record.domain},${record.value}`
}
export function renderConfig(records: DnsRecord[]): string {
const lines = records
.filter((record) => record.enabled)
.map(renderRecord)
if (lines.length === 0) {
return `${HEADER}\n\n# 当前没有启用的记录\n`
}
return `${HEADER}\n\n${lines.join("\n")}\n`
}
/**
* Regenerate the managed config file for the given records.
*
* Written to a sibling temp file first and then renamed, so dnsmasq can never read a
* half-written file.
*/
export async function writeManagedConfig(records: DnsRecord[]): Promise<{
path: string
recordCount: number
}> {
const dir = path.dirname(env.confPath)
mkdirSync(dir, { recursive: true })
const content = renderConfig(records)
const tempPath = `${env.confPath}.${process.pid}.tmp`
writeFileSync(tempPath, content, "utf8")
renameSync(tempPath, env.confPath)
return {
path: env.confPath,
recordCount: records.filter((record) => record.enabled).length,
}
}
+92
View File
@@ -0,0 +1,92 @@
import { existsSync } from "node:fs"
import http from "node:http"
import { env } from "./env"
export type ReloadOutcome =
| { status: "restarted"; detail: string }
| { status: "skipped"; detail: string }
| { status: "failed"; detail: string }
interface DockerResponse {
statusCode: number
body: string
}
const REQUEST_TIMEOUT_MS = 5000
function dockerRequest(socketPath: string, requestPath: string): Promise<DockerResponse> {
return new Promise((resolve, reject) => {
const request = http.request(
{
socketPath,
path: requestPath,
method: "POST",
headers: { Host: "localhost", Accept: "application/json" },
},
(response) => {
let body = ""
response.setEncoding("utf8")
response.on("data", (chunk) => {
body += chunk
})
response.on("end", () => {
resolve({ statusCode: response.statusCode ?? 0, body })
})
}
)
request.on("error", reject)
request.setTimeout(REQUEST_TIMEOUT_MS, () => {
request.destroy(new Error(`Docker API 请求超时(${REQUEST_TIMEOUT_MS}ms)`))
})
request.end()
})
}
function failureReason(error: unknown): string {
if (error instanceof Error) return error.message
return String(error)
}
/**
* Restart the dnsmasq container over the Docker socket.
*
* Never throws: hosts without a Docker socket (e.g. developing on Windows) get a "skipped"
* outcome so the UI can report it instead of blowing up.
*/
export async function restartDnsmasqContainer(): Promise<ReloadOutcome> {
if (!env.containerName) {
return { status: "skipped", detail: "未配置 DNSMASQ_CONTAINER_NAME,跳过重启" }
}
if (!existsSync(env.dockerSocket)) {
return { status: "skipped", detail: `Docker socket 不存在:${env.dockerSocket},跳过重启` }
}
try {
const response = await dockerRequest(
env.dockerSocket,
`/containers/${encodeURIComponent(env.containerName)}/restart`
)
if (response.statusCode >= 200 && response.statusCode < 300) {
return { status: "restarted", detail: `容器 ${env.containerName} 已重启` }
}
if (response.statusCode === 404) {
return { status: "failed", detail: `找不到容器 ${env.containerName}` }
}
if (response.statusCode === 403) {
return { status: "failed", detail: "Docker API 拒绝访问,请检查 socket 权限" }
}
const remote = response.body.trim().slice(0, 200)
return {
status: "failed",
detail: `Docker API 返回 ${response.statusCode}${remote ? `:${remote}` : ""}`,
}
} catch (error) {
return { status: "failed", detail: failureReason(error) }
}
}
+26
View File
@@ -0,0 +1,26 @@
import path from "node:path"
function withDefault(name: string, fallback: string): string {
const raw = process.env[name]
return raw && raw.trim() ? raw.trim() : fallback
}
function toAbsolute(target: string): string {
return path.isAbsolute(target) ? target : path.resolve(process.cwd(), target)
}
/**
* Runtime configuration. Everything is optional with sensible defaults so the app can
* boot locally without any env file; see `.env.example` for the deploy-time values.
*/
export const env = {
/** SQLite database file — the single source of truth for records. */
dbPath: toAbsolute(withDefault("DNSMASQ_ADMIN_DB_PATH", "./data/dnsmasq-admin.db")),
/** Managed dnsmasq config file that this app regenerates on every apply. */
confPath: toAbsolute(withDefault("DNSMASQ_ADMIN_CONF_PATH", "./data/dnsmasq-admin.conf")),
/** Container that gets restarted through the Docker socket after an apply. */
containerName: withDefault("DNSMASQ_CONTAINER_NAME", "dnsmasq"),
dockerSocket: withDefault("DNSMASQ_ADMIN_DOCKER_SOCKET", "/var/run/docker.sock"),
/** DNS server that the /resolve page queries. */
dnsServer: withDefault("DNSMASQ_SERVER_IP", "127.0.0.1"),
}
+91
View File
@@ -0,0 +1,91 @@
import { io } from "next/cache"
import { execute, queryRow, queryRows, rowToRecord, type DnsRecord } from "./db"
import type { RecordInput } from "./validate"
const SELECT_ALL = "SELECT * FROM records"
export class DuplicateDomainError extends Error {
constructor(readonly domain: string) {
super(`domain already exists: ${domain}`)
this.name = "DuplicateDomainError"
}
}
function isUniqueViolation(error: unknown): boolean {
const { code, message } = (error ?? {}) as { code?: string; message?: string }
return (
code === "SQLITE_CONSTRAINT_UNIQUE" ||
code === "SQLITE_CONSTRAINT_PRIMARYKEY" ||
String(message ?? "").includes("UNIQUE constraint failed")
)
}
/**
* Reads go through `await io()`.
*
* `node:sqlite` is a synchronous driver, so under `cacheComponents` its results would
* otherwise be captured into the prerendered static shell at build time and the page would
* show stale rows forever. `io()` suspends during prerender so the read happens at request
* time. See node_modules/next/dist/docs/01-app/03-api-reference/04-functions/io.md.
*/
export async function listRecords(): Promise<DnsRecord[]> {
await io()
return queryRows(`${SELECT_ALL} ORDER BY domain COLLATE NOCASE`).map(rowToRecord)
}
export async function getRecord(id: number): Promise<DnsRecord | null> {
await io()
const row = queryRow(`${SELECT_ALL} WHERE id = ?`, id)
return row ? rowToRecord(row) : null
}
export async function listEnabledRecords(): Promise<DnsRecord[]> {
await io()
return queryRows(`${SELECT_ALL} WHERE enabled = 1 ORDER BY domain COLLATE NOCASE`).map(rowToRecord)
}
// Writes below only ever run inside Server Actions, which always execute at request time,
// so they do not need an `io()` guard.
export function createRecord(input: RecordInput): void {
try {
execute(
"INSERT INTO records (domain, type, value, enabled, comment) VALUES (?, ?, ?, ?, ?)",
input.domain,
input.type,
input.value,
input.enabled ? 1 : 0,
input.comment
)
} catch (error) {
if (isUniqueViolation(error)) throw new DuplicateDomainError(input.domain)
throw error
}
}
export function updateRecord(id: number, input: RecordInput): void {
try {
execute(
`UPDATE records
SET domain = ?, type = ?, value = ?, enabled = ?, comment = ?, updated_at = datetime('now')
WHERE id = ?`,
input.domain,
input.type,
input.value,
input.enabled ? 1 : 0,
input.comment,
id
)
} catch (error) {
if (isUniqueViolation(error)) throw new DuplicateDomainError(input.domain)
throw error
}
}
export function removeRecord(id: number): void {
execute("DELETE FROM records WHERE id = ?", id)
}
export function setRecordEnabled(id: number, enabled: boolean): void {
execute("UPDATE records SET enabled = ?, updated_at = datetime('now') WHERE id = ?", enabled ? 1 : 0, id)
}
+88
View File
@@ -0,0 +1,88 @@
import type { RecordType } from "./db"
export type FieldErrors = Record<string, string>
export interface RecordInput {
domain: string
type: RecordType
value: string
enabled: boolean
comment: string
}
export type ParseResult =
| { ok: true; input: RecordInput }
| { ok: false; fieldErrors: FieldErrors }
const LABEL = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/
/** Lowercase, trim surrounding space and drop a trailing dot (`nas.home.` -> `nas.home`). */
export function normalizeDomain(raw: string): string {
return raw
.trim()
.toLowerCase()
.replace(/\.+$/, "")
}
export function isValidDomain(domain: string): boolean {
if (!domain || domain.length > 253) return false
const labels = domain.split(".")
if (labels.length === 0) return false
return labels.every((label) => label.length > 0 && label.length <= 63 && LABEL.test(label))
}
export function isValidIPv4(value: string): boolean {
const parts = value.trim().split(".")
if (parts.length !== 4) return false
return parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)
}
function readText(formData: FormData, key: string): string {
const value = formData.get(key)
return typeof value === "string" ? value.trim() : ""
}
/**
* Validate a submitted record form. Used by both the create/update Server Actions and the
* resolve page, so every user-supplied domain goes through the same rules.
*/
export function parseRecordForm(formData: FormData): ParseResult {
const fieldErrors: FieldErrors = {}
const domain = normalizeDomain(readText(formData, "domain"))
if (!domain) {
fieldErrors.domain = "请填写域名"
} else if (!isValidDomain(domain)) {
fieldErrors.domain = "域名格式不正确,例如 nas.home"
}
const rawType = readText(formData, "type")
const type: RecordType = rawType === "CNAME" ? "CNAME" : "A"
const value = normalizeDomain(readText(formData, "value"))
if (!value) {
fieldErrors.value = "请填写目标值"
} else if (type === "A") {
if (!isValidIPv4(value)) fieldErrors.value = "A 记录的目标必须是 IPv4 地址"
} else if (!isValidDomain(value)) {
fieldErrors.value = "CNAME 的目标必须是域名"
} else if (value === domain) {
fieldErrors.value = "CNAME 的目标不能指向自己"
}
const comment = readText(formData, "comment")
if (comment.length > 200) fieldErrors.comment = "备注请控制在 200 字以内"
if (Object.keys(fieldErrors).length > 0) return { ok: false, fieldErrors }
return {
ok: true,
input: {
domain,
type,
value,
enabled: formData.get("enabled") !== null,
comment,
},
}
}
+2 -1
View File
@@ -15,11 +15,12 @@
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/turbopack": "^4", "@tailwindcss/turbopack": "^4",
"@types/node": "^20", "@types/node": "24",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.4.0", "eslint-config-next": "16.4.0",
"prettier": "^3.9.9",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5" "typescript": "^5"
}, },
+24 -14
View File
@@ -168,7 +168,7 @@ importers:
dependencies: dependencies:
next: next:
specifier: 16.4.0 specifier: 16.4.0
version: 16.4.0(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@20.19.43)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) version: 16.4.0(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@24.19.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
react: react:
specifier: 19.3.0 specifier: 19.3.0
version: 19.3.0 version: 19.3.0
@@ -180,8 +180,8 @@ importers:
specifier: ^4 specifier: ^4
version: 4.3.3 version: 4.3.3
'@types/node': '@types/node':
specifier: ^20 specifier: '24'
version: 20.19.43 version: 24.19.1
'@types/react': '@types/react':
specifier: ^19 specifier: ^19
version: 19.3.0 version: 19.3.0
@@ -194,6 +194,9 @@ importers:
eslint-config-next: eslint-config-next:
specifier: 16.4.0 specifier: 16.4.0
version: 16.4.0(@typescript-eslint/parser@8.71.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) version: 16.4.0(@typescript-eslint/parser@8.71.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
prettier:
specifier: ^3.9.9
version: 3.9.9
tailwindcss: tailwindcss:
specifier: ^4 specifier: ^4
version: 4.3.3 version: 4.3.3
@@ -719,8 +722,8 @@ packages:
'@types/json5@0.0.29': '@types/json5@0.0.29':
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
'@types/node@20.19.43': '@types/node@24.19.1':
resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} resolution: {integrity: sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ==}
'@types/react-dom@19.3.0': '@types/react-dom@19.3.0':
resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==}
@@ -1862,6 +1865,11 @@ packages:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
prettier@3.9.9:
resolution: {integrity: sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==}
engines: {node: '>=14'}
hasBin: true
prop-types@15.8.1: prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
@@ -2106,8 +2114,8 @@ packages:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
undici-types@6.21.0: undici-types@7.24.6:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
unrs-resolver@1.12.2: unrs-resolver@1.12.2:
resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==} resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==}
@@ -2619,9 +2627,9 @@ snapshots:
'@types/json5@0.0.29': {} '@types/json5@0.0.29': {}
'@types/node@20.19.43': '@types/node@24.19.1':
dependencies: dependencies:
undici-types: 6.21.0 undici-types: 7.24.6
'@types/react-dom@19.3.0(@types/react@19.3.0)': '@types/react-dom@19.3.0(@types/react@19.3.0)':
dependencies: dependencies:
@@ -3779,7 +3787,7 @@ snapshots:
natural-compare@1.4.0: {} natural-compare@1.4.0: {}
next@16.4.0(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@20.19.43)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): next@16.4.0(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@24.19.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0):
dependencies: dependencies:
'@next/env': 16.4.0 '@next/env': 16.4.0
'@swc/helpers': 0.5.23 '@swc/helpers': 0.5.23
@@ -3798,7 +3806,7 @@ snapshots:
'@next/swc-linux-x64-musl': 16.4.0 '@next/swc-linux-x64-musl': 16.4.0
'@next/swc-win32-arm64-msvc': 16.4.0 '@next/swc-win32-arm64-msvc': 16.4.0
'@next/swc-win32-x64-msvc': 16.4.0 '@next/swc-win32-x64-msvc': 16.4.0
sharp: 0.35.5(@types/node@20.19.43) sharp: 0.35.5(@types/node@24.19.1)
transitivePeerDependencies: transitivePeerDependencies:
- '@babel/core' - '@babel/core'
- '@types/node' - '@types/node'
@@ -3905,6 +3913,8 @@ snapshots:
prelude-ls@1.2.1: {} prelude-ls@1.2.1: {}
prettier@3.9.9: {}
prop-types@15.8.1: prop-types@15.8.1:
dependencies: dependencies:
loose-envify: 1.4.0 loose-envify: 1.4.0
@@ -4010,7 +4020,7 @@ snapshots:
es-errors: 1.3.0 es-errors: 1.3.0
es-object-atoms: 1.1.2 es-object-atoms: 1.1.2
sharp@0.35.5(@types/node@20.19.43): sharp@0.35.5(@types/node@24.19.1):
dependencies: dependencies:
'@img/colour': 1.1.0 '@img/colour': 1.1.0
detect-libc: 2.1.2 detect-libc: 2.1.2
@@ -4041,7 +4051,7 @@ snapshots:
'@img/sharp-win32-arm64': 0.35.5 '@img/sharp-win32-arm64': 0.35.5
'@img/sharp-win32-ia32': 0.35.5 '@img/sharp-win32-ia32': 0.35.5
'@img/sharp-win32-x64': 0.35.5 '@img/sharp-win32-x64': 0.35.5
'@types/node': 20.19.43 '@types/node': 24.19.1
optional: true optional: true
shebang-command@2.0.0: shebang-command@2.0.0:
@@ -4237,7 +4247,7 @@ snapshots:
has-symbols: 1.1.0 has-symbols: 1.1.0
which-boxed-primitive: 1.1.1 which-boxed-primitive: 1.1.1
undici-types@6.21.0: {} undici-types@7.24.6: {}
unrs-resolver@1.12.2: unrs-resolver@1.12.2:
dependencies: dependencies: