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

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