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:
27 files changed
+1344
-130
No files matched your search
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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"]
|
||||||
@@ -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()`,后者在预渲染阶段会直接导致构建失败。
|
||||||
+13
-13
@@ -1,26 +1,26 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: #ffffff;
|
--background: #ffffff;
|
||||||
--foreground: #171717;
|
--foreground: #171717;
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--font-sans: var(--font-geist-sans);
|
--font-sans: var(--font-geist-sans);
|
||||||
--font-mono: var(--font-geist-mono);
|
--font-mono: var(--font-geist-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root {
|
||||||
--background: #0a0a0a;
|
--background: #0a0a0a;
|
||||||
--foreground: #ededed;
|
--foreground: #ededed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
}
|
}
|
||||||
+33
-14
@@ -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
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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"),
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+24
-14
@@ -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:
|
||||||
|
|||||||
Reference in new issue
Block a user