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

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