diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5c1e72d --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# Copy to .env.local and adjust for your setup. Every value is optional — the app falls back +# to local paths under ./data so it runs with no configuration at all. + +# SQLite database — the single source of truth for records. Docker: /data/dnsmasq-admin.db +DNSMASQ_ADMIN_DB_PATH=./data/dnsmasq-admin.db + +# Managed dnsmasq config file this app regenerates on every apply. +# Docker: /etc/dnsmasq.d/dnsmasq-admin.conf +DNSMASQ_ADMIN_CONF_PATH=./data/dnsmasq-admin.conf + +# Container restarted over the Docker socket after an apply. +DNSMASQ_CONTAINER_NAME=dnsmasq + +# Docker socket. If it is missing (e.g. developing on Windows), the restart is skipped and +# reported in the UI rather than failing. +DNSMASQ_ADMIN_DOCKER_SOCKET=/var/run/docker.sock + +# DNS server used by the /resolve page. Point this at your dnsmasq container or LAN IP. +DNSMASQ_SERVER_IP=127.0.0.1 diff --git a/.gitignore b/.gitignore index 5ef6a52..396dd47 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,10 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example + +# local sqlite database and generated dnsmasq config +/data # vercel .vercel diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..c2b94ba --- /dev/null +++ b/.prettierrc @@ -0,0 +1,15 @@ +{ + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "semi": false, + "singleQuote": false, + "quoteProps": "as-needed", + "jsxSingleQuote": false, + "trailingComma": "es5", + "bracketSpacing": true, + "bracketSameLine": true, + "arrowParens": "always", + "htmlWhitespaceSensitivity": "css", + "endOfLine": "lf" +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6182de7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +# node:sqlite needs Node >= 22.5, so this must not drop below 22. +FROM node:24-alpine + +RUN corepack enable + +WORKDIR /app + +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN pnpm install --frozen-lockfile + +COPY . . +RUN pnpm build + +ENV NODE_ENV=production +ENV HOSTNAME=0.0.0.0 +EXPOSE 3000 + +CMD ["pnpm", "start"] diff --git a/README.md b/README.md index e215bc4..eb5faf8 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,82 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# dnsmasq-admin -## Getting Started +一个极简的 Web UI,用来管理运行在 Docker 里的 dnsmasq 的 DNS 记录:域名记录的增删改查 + 一个解析测试页。 -First, run the development server: +- **记录列表**(`/records`):新增、编辑、启用/禁用、删除 +- **解析测试**(`/resolve`):向 dnsmasq 发起 DNS 查询,验证记录是否已生效 +- 支持 **A** 与 **CNAME** 两种记录类型 -```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev +## 工作原理 + +``` + ┌──────────┐ 写 /etc/dnsmasq.d/dnsmasq-admin.conf ┌──────────┐ + │ 浏览器 │ ──────────> dnsmasq-admin (Next.js) ─────>│ dnsmasq │ + └──────────┘ Docker socket: restart 容器 └──────────┘ + │ + └─> SQLite(唯一数据源) ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +1. 所有记录存在 **SQLite** 里(用 Node 内置的 `node:sqlite`,**没有任何原生依赖**,`node_modules` 里不会多出编译产物)。 +2. 增删改都只改数据库,**不会立刻影响 dnsmasq**。 +3. 点「应用并重启」后:按启用的记录重新生成托管配置文件 → 通过 Docker socket 重启 dnsmasq 容器使其生效。 -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +之所以做成手动触发,是为了让你能先攒一批改动,再一次性重启 —— 每次改一条就重启一次容器没有必要,而且会让 DNS 短暂中断。 -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +`address=/example.com/192.168.1.10` 这种写法会同时命中 `example.com` 及其**所有子域名**,所以不需要额外的通配符语法。 -## Learn More +## 本地开发 -To learn more about Next.js, take a look at the following resources: +需要 **Node >= 22.5**(`node:sqlite` 的最低要求)。 -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +```bash +pnpm install +pnpm dev +``` -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +打开 ,会被重定向到 `/records`。 -## Deploy on Vercel +本地运行不需要配任何环境变量 —— 数据库默认落在 `./data/dnsmasq-admin.db`,生成的配置默认落在 `./data/dnsmasq-admin.conf`。需要调整时复制 `.env.example` 为 `.env.local`。 -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +在 **Windows / macOS 桌面上开发**时没有 `/var/run/docker.sock`,点「应用并重启」会提示「重启已跳过」,但配置文件**照常生成**,可以用来自查内容是否正确。完整的重启链路需要在装了 Docker 的 Linux 上验证。 -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +## Docker 部署 + +```bash +docker compose up -d +``` + +`docker-compose.yml` 里同时给了 dnsmasq 和 web 两个服务用于开箱联调。如果你已经有自己的 dnsmasq 容器,删掉 `dnsmasq` 服务,把 `dnsmasq-conf` 这个 volume 挂到你容器里的 `/etc/dnsmasq.d` 即可: + +```yaml +# 在你现有的 dnsmasq 容器上加: +volumes: + - dnsmasq-conf:/etc/dnsmasq.d +``` + +注意两个 volume 都要保留:`app-data` 存着 SQLite 数据库,删了等于记录全丢。 + +## 环境变量 + +见 [`.env.example`](.env.example)。 + +| 变量 | 默认值 | 说明 | +| --- | --- | --- | +| `DNSMASQ_ADMIN_DB_PATH` | `./data/dnsmasq-admin.db` | SQLite 数据库位置 | +| `DNSMASQ_ADMIN_CONF_PATH` | `./data/dnsmasq-admin.conf` | 生成的托管配置文件 | +| `DNSMASQ_CONTAINER_NAME` | `dnsmasq` | 应用后要重启的容器名 | +| `DNSMASQ_ADMIN_DOCKER_SOCKET` | `/var/run/docker.sock` | Docker socket 路径;不存在则跳过重启 | +| `DNSMASQ_SERVER_IP` | `127.0.0.1` | `/resolve` 页查询用的 DNS 服务器 | + +## ⚠️ 安全提示 + +**这个应用当前没有任何鉴权** —— 任何能访问到它的人都能增删 DNS 记录并重启你的 dnsmasq 容器。请务必只监听内网或本机,**不要暴露到公网**。 + +在 compose 里 `web` 挂载了 `/var/run/docker.sock`,这意味着该容器具备控制宿主机 Docker 的能力。这是设计使然(需要在应用后重启 dnsmasq),但应当把它当作高权限服务看待:不要用它跑不可信代码,端口只映射到内网网卡(如 `127.0.0.1:3000:3000`)而非 `0.0.0.0`。 + +## 技术备注(Next.js 16) + +本项目启用了 `cacheComponents: true`,即 Next 16 的 Cache Components / PPR 模式。 + +这里踩到一个关键点:`node:sqlite` 是**同步**驱动,如果不显式处理,它的查询结果会在**构建期**被烤进预渲染的静态 shell,上线后页面永远显示构建时的旧数据。官方 issue 文档明确提到了这一点(`node_modules/next/dist/docs/01-app/03-api-reference/04-functions/io.md`),解法是在读库前 `await io()`,并把数据组件包在 `` 里 —— 参见 `lib/records.ts`。 + +同理,时间戳一律用 SQL 的 `datetime('now')` 而非 JS 的 `new Date()`,后者在预渲染阶段会直接导致构建失败。 diff --git a/app/globals.css b/app/globals.css index a2dc41e..2205352 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,26 +1,26 @@ @import "tailwindcss"; :root { - --background: #ffffff; - --foreground: #171717; + --background: #ffffff; + --foreground: #171717; } @theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); } @media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } + :root { + --background: #0a0a0a; + --foreground: #ededed; + } } body { - background: var(--background); - color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; + background: var(--background); + color: var(--foreground); + font-family: Arial, Helvetica, sans-serif; } diff --git a/app/layout.tsx b/app/layout.tsx index 9852c15..9330180 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,29 +1,48 @@ -import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; -import "./globals.css"; +import type { Metadata } from "next" +import { Geist, Geist_Mono } from "next/font/google" +import Link from "next/link" +import "./globals.css" const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"], -}); +}) const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"], -}); +}) export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", -}; + title: "dnsmasq 管理", + description: "管理 Docker 中 dnsmasq 的 DNS 记录", +} + +const NAV_ITEMS = [ + { href: "/records", label: "域名记录" }, + { href: "/resolve", label: "解析测试" }, +] export default function RootLayout({ children }: LayoutProps<"/">) { return ( - - {children} + + +
+ +
+ +
{children}
+ - ); + ) } diff --git a/app/page.tsx b/app/page.tsx index c887311..9205432 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,69 +1,5 @@ -import Image from "next/image"; +import { redirect } from "next/navigation" export default function Home() { - return ( -
-
- Next.js logo -
-

- To get started, edit the{" "} - - page.tsx - {" "} - file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

-
-
- - Vercel logomark - Deploy Now - - - Documentation - -
-
-
- ); + redirect("/records") } diff --git a/app/records/[id]/page.tsx b/app/records/[id]/page.tsx new file mode 100644 index 0000000..47fa119 --- /dev/null +++ b/app/records/[id]/page.tsx @@ -0,0 +1,41 @@ +import { notFound } from "next/navigation" +import { Suspense } from "react" +import { getRecord } from "@/lib/records" +import { RecordForm } from "../../_components/RecordForm" +import { updateRecordAction } from "../../actions" + +export const metadata = { title: "编辑记录" } + +export default function EditRecordPage({ params }: PageProps<"/records/[id]">) { + return ( +
+

编辑记录

+ + {/* params are awaited inside Suspense so the shell can still be prerendered */} + 加载中…

}> + +
+
+ ) +} + +async function EditRecordForm({ params }: Pick, "params">) { + const { id } = await params + const numericId = Number(id) + + // Guard against /records/abc: Number() would hand NaN to SQLite. + if (!Number.isInteger(numericId) || numericId <= 0) notFound() + + const record = await getRecord(numericId) + + if (!record) notFound() + + return ( + + ) +} diff --git a/app/records/_components/ApplyButton.tsx b/app/records/_components/ApplyButton.tsx new file mode 100644 index 0000000..3e68d9f --- /dev/null +++ b/app/records/_components/ApplyButton.tsx @@ -0,0 +1,37 @@ +"use client" + +import { useActionState } from "react" +import type { ActionState } from "@/lib/action-state" + +export interface ApplyButtonProps { + action: (prev: ActionState, formData: FormData) => Promise +} + +export function ApplyButton({ action }: ApplyButtonProps) { + const [state, formAction, pending] = useActionState(action, { ok: false }) + + return ( +
+
+ +
+ + {state.message ? ( +
+

+ {state.message} +

+ {state.detail ? ( +

{state.detail}

+ ) : null} +
+ ) : null} +
+ ) +} diff --git a/app/records/_components/DeleteButton.tsx b/app/records/_components/DeleteButton.tsx new file mode 100644 index 0000000..79844ea --- /dev/null +++ b/app/records/_components/DeleteButton.tsx @@ -0,0 +1,14 @@ +"use client" + +export function DeleteButton() { + return ( + + ) +} diff --git a/app/records/_components/RecordForm.tsx b/app/records/_components/RecordForm.tsx new file mode 100644 index 0000000..ad4a834 --- /dev/null +++ b/app/records/_components/RecordForm.tsx @@ -0,0 +1,127 @@ +"use client" + +import Link from "next/link" +import { useActionState } from "react" +import type { ActionState } from "@/lib/action-state" +import type { DnsRecord } from "@/lib/db" + +const fieldClass = + "w-full rounded-md border border-black/10 bg-white px-3 py-2 text-sm text-zinc-900 outline-none focus:border-zinc-500 dark:border-white/15 dark:bg-white/5 dark:text-zinc-100" + +const labelClass = "block text-sm font-medium text-zinc-700 dark:text-zinc-300" + +function FieldError({ message }: { message?: string }) { + if (!message) return null + return

{message}

+} + +export interface RecordFormProps { + /** Server Action to run on submit: either create or update, both bound to this form. */ + action: (prev: ActionState, formData: FormData) => Promise + record?: DnsRecord + submitLabel: string + cancelHref: string +} + +export function RecordForm({ action, record, submitLabel, cancelHref }: RecordFormProps) { + const [state, formAction, pending] = useActionState(action, { ok: false }) + const errors = state.fieldErrors ?? {} + + return ( +
+ {record ? : null} + + {state.message ? ( +

+ {state.message} +

+ ) : null} + +
+ + +

+ 无需通配符:dnsmasq 的 address=/域名/ 已自动覆盖其所有子域名。 +

+ +
+ +
+ + +
+ +
+ + +

+ A 记录填 IPv4 地址,CNAME 记录填目标域名。 +

+ +
+ +
+ + + +
+ + + +
+ + + 取消 + +
+
+ ) +} diff --git a/app/records/actions.ts b/app/records/actions.ts new file mode 100644 index 0000000..5b89157 --- /dev/null +++ b/app/records/actions.ts @@ -0,0 +1,115 @@ +"use server" + +import { revalidatePath } from "next/cache" +import { redirect } from "next/navigation" +import type { ActionState } from "@/lib/action-state" +import { writeManagedConfig } from "@/lib/dnsmasq-config" +import { restartDnsmasqContainer } from "@/lib/docker" +import { + DuplicateDomainError, + createRecord, + listEnabledRecords, + removeRecord, + setRecordEnabled, + updateRecord, +} from "@/lib/records" +import { parseRecordForm } from "@/lib/validate" + +const RECORDS_PATH = "/records" + +function readId(formData: FormData): number | null { + const raw = formData.get("id") + const id = typeof raw === "string" ? Number(raw) : NaN + return Number.isInteger(id) && id > 0 ? id : null +} + +function asDuplicateState(error: unknown): ActionState | null { + if (error instanceof DuplicateDomainError) { + return { ok: false, fieldErrors: { domain: `域名 ${error.domain} 已存在` } } + } + return null +} + +export async function createRecordAction( + _prev: ActionState, + formData: FormData +): Promise { + const parsed = parseRecordForm(formData) + if (!parsed.ok) return { ok: false, fieldErrors: parsed.fieldErrors } + + try { + createRecord(parsed.input) + } catch (error) { + const duplicate = asDuplicateState(error) + if (duplicate) return duplicate + throw error + } + + revalidatePath(RECORDS_PATH) + redirect(RECORDS_PATH) +} + +export async function updateRecordAction( + _prev: ActionState, + formData: FormData +): Promise { + const id = readId(formData) + if (id === null) return { ok: false, message: "记录不存在,可能已被删除" } + + const parsed = parseRecordForm(formData) + if (!parsed.ok) return { ok: false, fieldErrors: parsed.fieldErrors } + + try { + updateRecord(id, parsed.input) + } catch (error) { + const duplicate = asDuplicateState(error) + if (duplicate) return duplicate + throw error + } + + revalidatePath(RECORDS_PATH) + redirect(RECORDS_PATH) +} + +export async function deleteRecordAction(formData: FormData): Promise { + const id = readId(formData) + if (id === null) return + removeRecord(id) + revalidatePath(RECORDS_PATH) +} + +export async function toggleRecordAction(formData: FormData): Promise { + const id = readId(formData) + if (id === null) return + // The hidden field carries the state this record should move *to*. + setRecordEnabled(id, formData.get("enabled") === "1") + revalidatePath(RECORDS_PATH) +} + +/** + * Regenerate the managed dnsmasq config from SQLite and restart the container. + * + * Deliberately manual rather than automatic, so several edits can be batched into one + * restart (see the plan: 手动应用并重启). + */ +export async function applyConfigAction( + _prev: ActionState, + _formData: FormData +): Promise { + const written = await writeManagedConfig(await listEnabledRecords()) + const reload = await restartDnsmasqContainer() + + revalidatePath(RECORDS_PATH) + + const writtenMessage = `已写入 ${written.recordCount} 条记录到 ${written.path}` + + if (reload.status === "restarted") { + return { ok: true, message: `${writtenMessage},${reload.detail}` } + } + + if (reload.status === "skipped") { + return { ok: true, message: writtenMessage, detail: reload.detail } + } + + return { ok: false, message: `${writtenMessage},但重启失败`, detail: reload.detail } +} diff --git a/app/records/new/page.tsx b/app/records/new/page.tsx new file mode 100644 index 0000000..2dfafb4 --- /dev/null +++ b/app/records/new/page.tsx @@ -0,0 +1,22 @@ +import Link from "next/link" +import { RecordForm } from "../_components/RecordForm" +import { createRecordAction } from "../actions" + +export const metadata = { title: "新增记录" } + +export default function NewRecordPage() { + return ( +
+
+ + ← 返回列表 + +

新增记录

+
+ + +
+ ) +} diff --git a/app/records/page.tsx b/app/records/page.tsx new file mode 100644 index 0000000..6543a7e --- /dev/null +++ b/app/records/page.tsx @@ -0,0 +1,118 @@ +import Link from "next/link" +import { Suspense } from "react" +import { env } from "@/lib/env" +import { listRecords } from "@/lib/records" +import { applyConfigAction, deleteRecordAction, toggleRecordAction } from "./actions" +import { ApplyButton } from "./_components/ApplyButton" +import { DeleteButton } from "./_components/DeleteButton" + +export const metadata = { title: "域名记录" } + +const cardClass = "rounded-lg border border-black/10 p-4 dark:border-white/15" + +export default function RecordsPage() { + return ( +
+
+
+

域名记录

+

+ 记录保存在 SQLite 中,修改后需要「应用并重启」才会写入 dnsmasq 配置。 +

+
+ + 新增记录 + +
+ +
+

生效

+

+ 配置文件:{env.confPath} · 目标容器:{env.containerName} +

+
+ +
+
+ + 加载中…

}> + +
+
+ ) +} + +async function RecordTable() { + const records = await listRecords() + + if (records.length === 0) { + return ( +
+

+ 还没有任何记录,点右上角「新增记录」开始。 +

+
+ ) + } + + return ( +
+ + + + + + + + + + + + + {records.map((record) => ( + + + + + + + + + ))} + +
域名类型目标值状态备注操作
{record.domain}{record.type}{record.value} + + {record.enabled ? "已启用" : "已禁用"} + + {record.comment || "—"} +
+ + 编辑 + +
+ + + +
+
+ + + +
+
+
+ ) +} diff --git a/app/resolve/page.tsx b/app/resolve/page.tsx new file mode 100644 index 0000000..30a0936 --- /dev/null +++ b/app/resolve/page.tsx @@ -0,0 +1,101 @@ +import { Suspense } from "react" +import { env } from "@/lib/env" +import { lookupDomain } from "@/lib/dns" +import { normalizeDomain } from "@/lib/validate" + +export const metadata = { title: "解析测试" } + +const cardClass = "rounded-lg border border-black/10 p-4 dark:border-white/15" + +export default function ResolvePage({ searchParams }: PageProps<"/resolve">) { + return ( +
+
+

解析测试

+

+ 向 {env.dnsServer} 发起查询,验证记录是否已经生效。 +

+
+ +
+ + +
+ + {/* searchParams are request-time data, so they must be awaited behind Suspense */} + 查询中…

}> + +
+
+ ) +} + +async function LookupResult({ searchParams }: Pick, "searchParams">) { + const { domain } = await searchParams + const raw = Array.isArray(domain) ? domain[0] : domain + + if (!raw) { + return ( +

输入域名后点「解析」开始查询。

+ ) + } + + const target = normalizeDomain(raw) + const outcome = await lookupDomain(target) + + return ( +
+

{target}

+ + {outcome.status === "a" ? ( + + + + + + + + + {outcome.addresses.map((entry) => ( + + + + + ))} + +
IPv4 地址TTL
{entry.address}{entry.ttl}
+ ) : null} + + {outcome.status === "cname" ? ( +
    + {outcome.targets.map((item) => ( +
  • + {target} → {item} +
  • + ))} +
+ ) : null} + + {outcome.status === "empty" ? ( +

{outcome.detail}

+ ) : null} + + {outcome.status === "error" ? ( +

{outcome.detail}

+ ) : null} +
+ ) +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..aed8544 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,38 @@ +services: + # If you already run dnsmasq elsewhere, you can delete this service and simply mount the + # dnsmasq-conf volume at /etc/dnsmasq.d inside your existing container. + dnsmasq: + image: jpillora/dnsmasq + container_name: dnsmasq + cap_add: + - NET_ADMIN + ports: + - "53:53/udp" + volumes: + - dnsmasq-conf:/etc/dnsmasq.d + restart: unless-stopped + + web: + build: . + ports: + - "3000:3000" + environment: + DNSMASQ_ADMIN_DB_PATH: /data/dnsmasq-admin.db + # Same mounted config directory dnsmasq reads from. + DNSMASQ_ADMIN_CONF_PATH: /etc/dnsmasq.d/dnsmasq-admin.conf + DNSMASQ_CONTAINER_NAME: dnsmasq + DNSMASQ_ADMIN_DOCKER_SOCKET: /var/run/docker.sock + # Composed DNS name of the dnsmasq service; use its LAN IP if it lives elsewhere. + DNSMASQ_SERVER_IP: dnsmasq + volumes: + # Shared with the dnsmasq container so the generated config is visible to it. + - dnsmasq-conf:/etc/dnsmasq.d + # Holds the SQLite database — keep this volume or you lose all records. + - app-data:/data + # Used only to restart the dnsmasq container after an apply. + - /var/run/docker.sock:/var/run/docker.sock + restart: unless-stopped + +volumes: + dnsmasq-conf: + app-data: diff --git a/lib/action-state.ts b/lib/action-state.ts new file mode 100644 index 0000000..d9be4b3 --- /dev/null +++ b/lib/action-state.ts @@ -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 +} diff --git a/lib/db.ts b/lib/db.ts new file mode 100644 index 0000000..70f49ed --- /dev/null +++ b/lib/db.ts @@ -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 + +/** 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) +} diff --git a/lib/dns.ts b/lib/dns.ts new file mode 100644 index 0000000..36521b2 --- /dev/null +++ b/lib/dns.ts @@ -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 = { + 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(promise: Promise): Promise { + let timer: NodeJS.Timeout | undefined + const guard = new Promise((_, 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 { + 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) } + } +} diff --git a/lib/dnsmasq-config.ts b/lib/dnsmasq-config.ts new file mode 100644 index 0000000..955a5db --- /dev/null +++ b/lib/dnsmasq-config.ts @@ -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, + } +} diff --git a/lib/docker.ts b/lib/docker.ts new file mode 100644 index 0000000..4cad50c --- /dev/null +++ b/lib/docker.ts @@ -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 { + 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 { + 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) } + } +} diff --git a/lib/env.ts b/lib/env.ts new file mode 100644 index 0000000..14f11ba --- /dev/null +++ b/lib/env.ts @@ -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"), +} diff --git a/lib/records.ts b/lib/records.ts new file mode 100644 index 0000000..67e6121 --- /dev/null +++ b/lib/records.ts @@ -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 { + await io() + return queryRows(`${SELECT_ALL} ORDER BY domain COLLATE NOCASE`).map(rowToRecord) +} + +export async function getRecord(id: number): Promise { + await io() + const row = queryRow(`${SELECT_ALL} WHERE id = ?`, id) + return row ? rowToRecord(row) : null +} + +export async function listEnabledRecords(): Promise { + 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) +} diff --git a/lib/validate.ts b/lib/validate.ts new file mode 100644 index 0000000..3138008 --- /dev/null +++ b/lib/validate.ts @@ -0,0 +1,88 @@ +import type { RecordType } from "./db" + +export type FieldErrors = Record + +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, + }, + } +} diff --git a/package.json b/package.json index e0950ae..9b3f95a 100644 --- a/package.json +++ b/package.json @@ -15,11 +15,12 @@ }, "devDependencies": { "@tailwindcss/turbopack": "^4", - "@types/node": "^20", + "@types/node": "24", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "16.4.0", + "prettier": "^3.9.9", "tailwindcss": "^4", "typescript": "^5" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6113aa6..a24a034 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -168,7 +168,7 @@ importers: dependencies: next: specifier: 16.4.0 - version: 16.4.0(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@20.19.43)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + version: 16.4.0(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@24.19.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) react: specifier: 19.3.0 version: 19.3.0 @@ -180,8 +180,8 @@ importers: specifier: ^4 version: 4.3.3 '@types/node': - specifier: ^20 - version: 20.19.43 + specifier: '24' + version: 24.19.1 '@types/react': specifier: ^19 version: 19.3.0 @@ -194,6 +194,9 @@ importers: eslint-config-next: specifier: 16.4.0 version: 16.4.0(@typescript-eslint/parser@8.71.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) + prettier: + specifier: ^3.9.9 + version: 3.9.9 tailwindcss: specifier: ^4 version: 4.3.3 @@ -719,8 +722,8 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} - '@types/node@20.19.43': - resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} + '@types/node@24.19.1': + resolution: {integrity: sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ==} '@types/react-dom@19.3.0': resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} @@ -1862,6 +1865,11 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + prettier@3.9.9: + resolution: {integrity: sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==} + engines: {node: '>=14'} + hasBin: true + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -2106,8 +2114,8 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} - undici-types@6.21.0: - resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} unrs-resolver@1.12.2: resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==} @@ -2619,9 +2627,9 @@ snapshots: '@types/json5@0.0.29': {} - '@types/node@20.19.43': + '@types/node@24.19.1': dependencies: - undici-types: 6.21.0 + undici-types: 7.24.6 '@types/react-dom@19.3.0(@types/react@19.3.0)': dependencies: @@ -3779,7 +3787,7 @@ snapshots: natural-compare@1.4.0: {} - next@16.4.0(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@20.19.43)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): + next@16.4.0(@babel/core@7.29.7(supports-color@7.2.0))(@types/node@24.19.1)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): dependencies: '@next/env': 16.4.0 '@swc/helpers': 0.5.23 @@ -3798,7 +3806,7 @@ snapshots: '@next/swc-linux-x64-musl': 16.4.0 '@next/swc-win32-arm64-msvc': 16.4.0 '@next/swc-win32-x64-msvc': 16.4.0 - sharp: 0.35.5(@types/node@20.19.43) + sharp: 0.35.5(@types/node@24.19.1) transitivePeerDependencies: - '@babel/core' - '@types/node' @@ -3905,6 +3913,8 @@ snapshots: prelude-ls@1.2.1: {} + prettier@3.9.9: {} + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -4010,7 +4020,7 @@ snapshots: es-errors: 1.3.0 es-object-atoms: 1.1.2 - sharp@0.35.5(@types/node@20.19.43): + sharp@0.35.5(@types/node@24.19.1): dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 @@ -4041,7 +4051,7 @@ snapshots: '@img/sharp-win32-arm64': 0.35.5 '@img/sharp-win32-ia32': 0.35.5 '@img/sharp-win32-x64': 0.35.5 - '@types/node': 20.19.43 + '@types/node': 24.19.1 optional: true shebang-command@2.0.0: @@ -4237,7 +4247,7 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 - undici-types@6.21.0: {} + undici-types@7.24.6: {} unrs-resolver@1.12.2: dependencies: