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

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