diff --git a/README.md b/README.md index 311a403..26ad6cb 100644 --- a/README.md +++ b/README.md @@ -10,14 +10,14 @@ When your NAS or a cron script calls this worker, it reads the caller's IP from ![Synology DSM DDNS settings showing a custom Cloudflare provider using a Cloudflare Worker /nic/update DynDNS2 URL with hostname, IP, username, and password fields.](assets/synology-ddns-dashboard.png) > [!NOTE] -> Use Deploy to Cloudflare if you want Cloudflare to create your own copy of the repo, provision supported resources such as D1, and build the Worker for you. Keep your zone-specific values ready before you start: the setup flow still needs your Cloudflare API token, zone ID, shared secret, and allowed hostnames. The helper links below are the fastest way to gather them. +> Use Deploy to Cloudflare if you want Cloudflare to create your own copy of the repo, provision supported resources such as D1, and build the Worker for you. This avoids a manual local clone, but Cloudflare's documented button flow still creates a new GitHub or GitLab repository copy for the user. Keep your zone-specific values ready before you start: the setup flow still needs your Cloudflare API token, zone ID, shared secret, and allowed hostnames. The helper links below are the fastest way to gather them. ## Features - Synology DSM custom DDNS provider compatibility (`GET /nic/update`) - JSON API for scripts and automation (`POST /update` with OpenAPI docs) - IPv4 (A) and IPv6 (AAAA) support -- D1-backed audit log with automatic cleanup +- Optional D1-backed audit log with scheduled cleanup - Hostname allowlist to limit what records can be changed - Configurable proxied/DNS-only mode and TTL @@ -26,8 +26,7 @@ When your NAS or a cron script calls this worker, it reads the caller's IP from Choose the path that matches how you want to manage the project: - Deploy to Cloudflare for a dashboard-led setup and a repo copy in your own GitHub account -- Guided local setup with `pnpm setup` if you want the repo on disk and a scripted Wrangler flow -- Manual Wrangler setup if you want to enter each step yourself +- Local setup with a few explicit Wrangler helper commands if you want the repo on disk Use the local path for a more hands on deploy: @@ -35,18 +34,11 @@ Use the local path for a more hands on deploy: git clone https://github.com/okikio/cloudflare-ddns.git cd cloudflare-ddns pnpm install -pnpm setup +pnpm setup:secrets +pnpm run deploy ``` -The setup wizard will: - -- create or attach a D1 database -- prompt for the required secrets -- write the hostname allowlist into `wrangler.jsonc` -- validate the worker configuration -- offer to apply migrations and deploy immediately - -If you want the manual Wrangler flow instead, keep reading. +If you want to attach a specific D1 database and run explicit remote migrations from a local clone, keep reading. ## Before you deploy @@ -71,22 +63,7 @@ Deploy to Cloudflare can prefill descriptions and defaults, but it cannot curren ## Setup -### Guided setup - -```sh -pnpm setup -``` - -If you prefer to run the steps separately: - -```sh -pnpm setup:db -pnpm setup:secrets -pnpm verify-setup -pnpm run deploy -``` - -### Manual Wrangler setup +### Local Wrangler setup ### 1. Clone and install @@ -104,9 +81,9 @@ npx wrangler d1 create cloudflare-ddns-db If you want the database provisioned before the first deploy, copy the generated D1 binding back into your local `wrangler.jsonc`, or use `pnpm setup:db` to create the database and write that binding for you. -If you skip this step, keep the committed `d1_databases` entry as-is. Wrangler can automatically provision the D1 database from that binding during `pnpm run deploy`, Workers Builds, or Deploy to Cloudflare. +If you skip this step, keep the committed `d1_databases` entry as-is. Wrangler can automatically provision the D1 database from that binding during `pnpm run deploy`, Workers Builds, or Deploy to Cloudflare. This is the default template path. -If you use Cloudflare Workers Builds with Git integration, set the project Deploy command to `pnpm run deploy` instead of `npx wrangler deploy`. This repository uses a pnpm workspace, so `pnpm deploy` runs pnpm's built-in workspace deploy command rather than the package script. `pnpm run deploy` is the form that runs this repository's `predeploy` step before `wrangler deploy`. +If you use Cloudflare Workers Builds with Git integration, set the project Deploy command to `pnpm run deploy` instead of `npx wrangler deploy`. This repository uses a pnpm workspace, so `pnpm deploy` runs pnpm's built-in workspace deploy command rather than the package script. `pnpm run deploy` is the form that runs this repository's plain `wrangler deploy` script. ### 3. Set secrets @@ -146,12 +123,23 @@ You can also use `pnpm setup:secrets`, which uploads the actual secrets and writ pnpm run deploy ``` -This runs D1 migrations automatically before deploying. +This runs a standard `wrangler deploy`. -On a first hosted deploy, Wrangler can auto-create the D1 database from the committed binding before applying remote migrations. If you already created the database up front with `pnpm setup:db` or `wrangler d1 create`, the same deploy command reuses that configured binding. +For this repository's default template path, the Worker deploys without requiring a pre-existing D1 `database_id`. DDNS updates still work before the audit-log table exists, but D1-backed audit logging only becomes active after you apply the SQL migrations from a local/operator workflow. Workers Builds does not need a dedicated D1 build secret for this repository. Use the committed D1 binding and set the Deploy command to `pnpm run deploy`. +If you want to manage the D1 database explicitly after cloning locally, run: + +```sh +pnpm setup:db +pnpm setup:secrets +pnpm run migrate:remote +pnpm run deploy +``` + +That path writes a real `database_id` into your local `wrangler.jsonc`, uploads the required secrets, applies the SQL migrations remotely, and then deploys the Worker. It is the recommended way to enable the D1-backed audit log. + ## Environment variables These non-secret variables live in `wrangler.jsonc` and can be overridden per-environment: @@ -249,7 +237,7 @@ src/ ddns.ts Shared update logic (findRecord, compare, create/patch) cloudflare-api.ts Typed wrapper around Cloudflare DNS REST API validation.ts IP validation, config parsing (no dependencies) - logging.ts D1-backed audit logging and cleanup + logging.ts Best-effort D1 audit logging and cleanup endpoints/ synology.ts GET /nic/update (DynDNS2 text responses) update.ts POST /update (JSON, OpenAPI via Chanfana) diff --git a/package.json b/package.json index 44c625b..605af5e 100644 --- a/package.json +++ b/package.json @@ -23,13 +23,11 @@ }, "scripts": { "cf-typegen": "wrangler types", - "setup": "node --experimental-strip-types ./scripts/setup.ts", "setup:db": "node --experimental-strip-types ./scripts/setup-db.ts", "setup:secrets": "node --experimental-strip-types ./scripts/setup-secrets.ts", - "verify-setup": "node --experimental-strip-types ./scripts/verify-setup.ts", "typecheck:scripts": "tsc -p tsconfig.scripts.json", "deploy": "wrangler deploy", - "predeploy": "node --experimental-strip-types ./scripts/verify-setup.ts && wrangler d1 migrations apply DB --remote", + "migrate:remote": "wrangler d1 migrations apply DB --remote", "dev": "wrangler d1 migrations apply DB --local && wrangler dev", "test": "wrangler deploy --dry-run && npx vitest run --config tests/vitest.config.mts" }, diff --git a/scripts/common.ts b/scripts/common.ts index 70eb608..27c6b46 100644 --- a/scripts/common.ts +++ b/scripts/common.ts @@ -342,16 +342,6 @@ export async function info(message: string): Promise { console.log(message); } -export function parseSecretList(output: string): RequiredSecret[] { - const schema = z.array(z.object({ name: z.enum(REQUIRED_SECRETS) }).catchall(z.unknown())); - const result = schema.safeParse(JSON.parse(output)); - if (!result.success) { - return []; - } - - return result.data.map((entry) => entry.name); -} - export function getRequiredVar(config: WranglerConfig, name: RequiredVar): string { return config.vars?.[name]?.trim() ?? ""; } diff --git a/scripts/setup-db.ts b/scripts/setup-db.ts index b8a0dc5..e3867eb 100644 --- a/scripts/setup-db.ts +++ b/scripts/setup-db.ts @@ -12,11 +12,6 @@ import { writeWranglerConfig, } from "./common.ts"; -function extractDatabaseId(output: string): string | null { - const match = output.match(/"database_id"\s*:\s*"([0-9a-f-]{36})"/i); - return match?.[1] ?? null; -} - export async function setupDatabase(): Promise { const config = await readWranglerConfig(); const existingBinding = getPrimaryD1Binding(config); @@ -32,31 +27,44 @@ export async function setupDatabase(): Promise { const defaultName = existingBinding?.database_name || `${config.name}-db`; const databaseName = await prompt("D1 database name", { defaultValue: defaultName }); - const output = runWrangler(["d1", "create", databaseName]); - const databaseId = extractDatabaseId(output); + const bindingName = existingBinding?.binding || "DB"; + runWrangler([ + "d1", + "create", + databaseName, + "--update-config", + "--binding", + bindingName, + ], { + stdio: "inherit", + }); + + const updatedConfig = await readWranglerConfig(); + const updatedBinding = getPrimaryD1Binding(updatedConfig); + const databaseId = updatedBinding?.database_id; - if (!databaseId) { + if (!databaseId || !isUuid(databaseId)) { throw new Error( - `Wrangler created a database but the script could not find the database_id in the output.\n${output}`, + "Wrangler created a database, but the updated wrangler.jsonc does not contain a valid database_id. Check the D1 binding that Wrangler wrote to the config and try again.", ); } - config.d1_databases = [ + updatedConfig.d1_databases = [ { - binding: existingBinding?.binding || "DB", + binding: bindingName, database_name: databaseName, database_id: databaseId, }, ]; - await writeWranglerConfig(config); + await writeWranglerConfig(updatedConfig); console.log(`Updated wrangler.jsonc with D1 database ${databaseName} (${databaseId}).`); return databaseId; } async function main(): Promise { await setupDatabase(); - await outro("Next: run `pnpm setup:secrets` or `pnpm setup`."); + await outro("Next: run `pnpm setup:secrets`, `pnpm run migrate:remote`, or `pnpm run deploy`."); } if (isMainModule(import.meta.url)) { diff --git a/scripts/setup-secrets.ts b/scripts/setup-secrets.ts index 4602b4e..33f1ba8 100644 --- a/scripts/setup-secrets.ts +++ b/scripts/setup-secrets.ts @@ -91,7 +91,7 @@ export async function setupSecrets(): Promise<{ async function main(): Promise { await setupSecrets(); - await outro("Next: run `pnpm verify-setup` or `pnpm setup`."); + await outro("Next: run `pnpm run deploy` or `pnpm run migrate:remote` if you attached a specific D1 database locally."); } if (isMainModule(import.meta.url)) { diff --git a/scripts/setup.ts b/scripts/setup.ts deleted file mode 100644 index e9810ef..0000000 --- a/scripts/setup.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { ensureWranglerAuth, intro, isMainModule, outro, promptYesNo, runWrangler, step } from "./common.ts"; -import { setupDatabase } from "./setup-db.ts"; -import { setupSecrets } from "./setup-secrets.ts"; -import { verifySetup } from "./verify-setup.ts"; - -interface SetupProjectOptions { - deployNow?: boolean; -} - -export async function setupProject(options: SetupProjectOptions = {}): Promise { - await intro("Cloudflare DDNS setup"); - ensureWranglerAuth(); - - await setupDatabase(); - await setupSecrets(); - await verifySetup(); - - const shouldDeploy = options.deployNow ?? (await promptYesNo("Run remote migrations and deploy now?", true)); - if (!shouldDeploy) { - await outro("Setup complete. Run `pnpm run deploy` when you are ready."); - return; - } - - await step("Applying migrations"); - runWrangler(["d1", "migrations", "apply", "DB", "--remote"], { stdio: "inherit" }); - - await step("Deploying Worker"); - runWrangler(["deploy"], { stdio: "inherit" }); - await outro("Deployment complete."); -} - -async function main(): Promise { - await setupProject({ deployNow: process.argv.includes("--deploy") ? true : undefined }); -} - -if (isMainModule(import.meta.url)) { - main().catch((error: unknown) => { - const message = error instanceof Error ? error.message : String(error); - console.error(message); - process.exitCode = 1; - }); -} \ No newline at end of file diff --git a/scripts/verify-setup.ts b/scripts/verify-setup.ts deleted file mode 100644 index aedf461..0000000 --- a/scripts/verify-setup.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { - REQUIRED_SECRETS, - getRequiredVar, - getPrimaryD1Binding, - isMainModule, - parseSecretList, - readWranglerConfig, - runWrangler, - success, - validateAllowedHostnamesCsv, -} from "./common.ts"; - -function missingRequiredSecrets(config: Awaited>): string[] { - const configured = new Set(config.secrets?.required || []); - return REQUIRED_SECRETS.filter((name) => !configured.has(name)); -} - -export async function verifySetup(): Promise { - const config = await readWranglerConfig(); - const errors: string[] = []; - - const binding = getPrimaryD1Binding(config); - if (!binding) { - errors.push("Missing D1 binding `DB` in wrangler.jsonc."); - } else if (!binding.database_name?.trim()) { - errors.push("Missing D1 database_name for binding `DB` in wrangler.jsonc."); - } - - const missingSecretsConfig = missingRequiredSecrets(config); - if (missingSecretsConfig.length > 0) { - errors.push( - `wrangler.jsonc is missing required secret declarations for: ${missingSecretsConfig.join(", ")}`, - ); - } - - const allowedHostnames = getRequiredVar(config, "DDNS_ALLOWED_HOSTNAMES"); - const allowedHostnameValidation = validateAllowedHostnamesCsv(allowedHostnames); - if (!allowedHostnameValidation.ok) { - errors.push( - `wrangler.jsonc vars.DDNS_ALLOWED_HOSTNAMES is missing or invalid.\n${allowedHostnameValidation.errors.join("\n")}`, - ); - } - - let remoteSecretNames: string[] = []; - try { - const output = runWrangler(["secret", "list", "--format", "json"]); - remoteSecretNames = parseSecretList(output); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - errors.push( - `Could not inspect deployed Worker secrets. Make sure you are logged into Wrangler and have run \`pnpm setup:secrets\`.\n${message}`, - ); - } - - if (remoteSecretNames.length > 0) { - const configuredSecrets = new Set(remoteSecretNames); - const missingRemote = REQUIRED_SECRETS.filter((name) => !configuredSecrets.has(name)); - if (missingRemote.length > 0) { - errors.push(`Missing deployed Worker secrets: ${missingRemote.join(", ")}. Run \`pnpm setup:secrets\`.`); - } - } - - if (errors.length > 0) { - throw new Error(errors.join("\n\n")); - } - - await success(`Setup looks good for Worker ${config.name}.`); - return true; -} - -async function main(): Promise { - await verifySetup(); -} - -if (isMainModule(import.meta.url)) { - main().catch((error: unknown) => { - const message = error instanceof Error ? error.message : String(error); - console.error(message); - process.exitCode = 1; - }); -} \ No newline at end of file diff --git a/src/logging.ts b/src/logging.ts index 5fe7e51..eb18d25 100644 --- a/src/logging.ts +++ b/src/logging.ts @@ -1,10 +1,10 @@ /** - * D1-backed update logging. + * Best-effort D1-backed update logging. * * Each DDNS update attempt (success or failure) is recorded so operators - * can audit what happened. A scheduled cron job calls `cleanupLogs` to - * prune rows older than the configured retention period, keeping the - * D1 database within free-tier row limits. + * can audit what happened when the `ddns_logs` table has already been + * created via D1 migrations. A scheduled cron job calls `cleanupLogs` to + * prune rows older than the configured retention period. */ import type { UpdateAction } from "./types"; @@ -23,8 +23,8 @@ export interface DdnsLogEntry { * Insert a single update log row into D1. * * Failures are intentionally swallowed (logged to console) so that a - * D1 hiccup does not prevent the DNS update response from reaching - * the caller. + * missing table, failed migration, or transient D1 hiccup does not + * prevent the DNS update response from reaching the caller. */ export async function logUpdate(db: D1Database, entry: DdnsLogEntry): Promise { try { @@ -49,7 +49,7 @@ export async function logUpdate(db: D1Database, entry: DdnsLogEntry): Promise { try { diff --git a/tests/helpers.ts b/tests/helpers.ts index f923670..b70c108 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -122,6 +122,11 @@ export function createMockDnsApi() { }; } +export async function dropLogSchema(db: D1Database): Promise { + await db.prepare("DROP INDEX IF EXISTS idx_ddns_logs_created_at").run(); + await db.prepare("DROP TABLE IF EXISTS ddns_logs").run(); +} + /** Build a Synology-style update URL with sensible defaults. */ export function makeSynologyUrl( overrides: { diff --git a/tests/integration/scheduled.test.ts b/tests/integration/scheduled.test.ts new file mode 100644 index 0000000..c45a44b --- /dev/null +++ b/tests/integration/scheduled.test.ts @@ -0,0 +1,43 @@ +import { env } from "cloudflare:workers"; +import { describe, expect, it } from "vitest"; + +import worker from "../../src/index"; +import type { Env } from "../bindings"; +import { dropLogSchema } from "../helpers"; + +function createExecutionContext(): { + ctx: ExecutionContext; + waitForTasks: () => Promise; +} { + const tasks: Promise[] = []; + + return { + ctx: { + waitUntil(task: Promise) { + tasks.push(task); + }, + passThroughOnException() { + return; + }, + } as ExecutionContext, + waitForTasks: async () => { + await Promise.all(tasks); + }, + }; +} + +describe("scheduled cleanup", () => { + it("does nothing when cleanup runs before migrations", async () => { + await dropLogSchema(env.DB); + + const { ctx, waitForTasks } = createExecutionContext(); + await worker.scheduled({} as ScheduledEvent, env as Env, ctx); + await waitForTasks(); + + const { results } = await env.DB.prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'ddns_logs'", + ).all(); + + expect(results).toHaveLength(0); + }); +}); \ No newline at end of file diff --git a/tests/integration/synology.test.ts b/tests/integration/synology.test.ts index 22c4cc7..c76fd59 100644 --- a/tests/integration/synology.test.ts +++ b/tests/integration/synology.test.ts @@ -2,7 +2,7 @@ import { SELF } from "cloudflare:test"; import { env } from "cloudflare:workers"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { createMockDnsApi, makeSynologyUrl } from "../helpers"; +import { createMockDnsApi, dropLogSchema, makeSynologyUrl } from "../helpers"; const dns = createMockDnsApi(); const DEFAULT_ALLOWED_HOSTNAMES = "nas.example.com,home.example.com"; @@ -169,6 +169,22 @@ describe("GET /nic/update — D1 logging", () => { expect(results[0].action).toBe("created"); expect(results[0].source).toBe("synology"); }); + + it("still updates DNS when the log table is missing", async () => { + await dropLogSchema(env.DB); + + const response = await SELF.fetch(makeSynologyUrl({ myip: "198.51.100.25" })); + expect(response.status).toBe(200); + expect(await response.text()).toBe("good 198.51.100.25"); + + await new Promise((r) => setTimeout(r, 50)); + + const { results } = await env.DB.prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'ddns_logs'", + ).all(); + + expect(results).toHaveLength(0); + }); }); // --------------------------------------------------------------------------- diff --git a/tests/integration/update.test.ts b/tests/integration/update.test.ts index ed7db84..5fe1710 100644 --- a/tests/integration/update.test.ts +++ b/tests/integration/update.test.ts @@ -2,7 +2,7 @@ import { SELF } from "cloudflare:test"; import { env } from "cloudflare:workers"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { createMockDnsApi } from "../helpers"; +import { createMockDnsApi, dropLogSchema } from "../helpers"; const dns = createMockDnsApi(); const DEFAULT_ALLOWED_HOSTNAMES = "nas.example.com,home.example.com"; @@ -248,4 +248,20 @@ describe("POST /update — D1 logging", () => { "nas.example.com", ]); }); + + it("recreates the log schema on first write when the table is missing", async () => { + it("still updates DNS when the log table is missing", async () => { + await dropLogSchema(env.DB); + + const response = await SELF.fetch(makeUpdateRequest({ ip: "198.51.100.21" })); + expect(response.status).toBe(200); + + await new Promise((r) => setTimeout(r, 50)); + + const { results } = await env.DB.prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'ddns_logs'", + ).all(); + + expect(results).toHaveLength(0); + }); });