Files
dnsmasq-admin/lib/dns.ts
T
siujamo 97ad09ad8a 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
2026-10-09 16:47:31 +08:00

89 lines
3.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) }
}
}