feat: add dnsmasq DNS record admin UI

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

No files matched your search

+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,
},
}
}