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,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,
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user