diff --git a/package.json b/package.json index 6c683f4..2deea0e 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "cloudflare-ddns", "description": "Cloudflare Worker that acts as a DDNS provider for Synology NAS and other clients.", "private": true, + "type": "module", "engines": { "node": ">=22", "pnpm": ">=9" @@ -21,12 +22,13 @@ }, "scripts": { "cf-typegen": "wrangler types", - "setup": "node ./scripts/setup.mjs", - "setup:db": "node ./scripts/setup-db.mjs", - "setup:secrets": "node ./scripts/setup-secrets.mjs", - "verify-setup": "node ./scripts/verify-setup.mjs", + "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 ./scripts/verify-setup.mjs && wrangler d1 migrations apply DB --remote", + "predeploy": "node --experimental-strip-types ./scripts/verify-setup.ts && 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 new file mode 100644 index 0000000..08b7e0d --- /dev/null +++ b/scripts/common.ts @@ -0,0 +1,264 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { createInterface } from "node:readline/promises"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { z } from "zod"; + +export const REQUIRED_SECRETS = [ + "CF_API_TOKEN", + "CF_ZONE_ID", + "DDNS_SHARED_SECRET", + "DDNS_ALLOWED_HOSTNAMES", +] as const; + +export const PLACEHOLDER_DATABASE_ID = "00000000-0000-0000-0000-000000000000"; + +const uuidSchema = z.string().regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i); +const zoneIdSchema = z.string().regex(/^[0-9a-f]{32}$/i, "CF_ZONE_ID should be a 32-character hexadecimal zone ID."); +const nonEmptyTextSchema = z.string().trim().min(1, "This value cannot be empty."); +const sharedSecretSchema = z + .string() + .min(12, "Use a longer DDNS_SHARED_SECRET. At least 12 characters is recommended."); + +type RequiredSecret = (typeof REQUIRED_SECRETS)[number]; + +export interface WranglerD1Binding { + binding: string; + database_name: string; + database_id: string; +} + +interface WranglerSecretsConfig { + required?: string[]; +} + +export interface WranglerConfig { + name: string; + d1_databases?: WranglerD1Binding[]; + secrets?: WranglerSecretsConfig; + vars?: Record; + [key: string]: unknown; +} + +interface WranglerResultOptions { + input?: string; + stdio?: "pipe" | "inherit"; +} + +interface PromptOptions { + defaultValue?: string; +} + +export interface HostnameValidationResult { + ok: boolean; + errors: string[]; + hostnames: string[]; +} + +const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const wranglerConfigPath = path.join(projectRoot, "wrangler.jsonc"); + +function stripJsonComments(input: string): string { + return input.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); +} + +export async function readWranglerConfig(): Promise { + const raw = await fs.readFile(wranglerConfigPath, "utf8"); + return JSON.parse(stripJsonComments(raw)) as WranglerConfig; +} + +export async function writeWranglerConfig(config: WranglerConfig): Promise { + await fs.writeFile(wranglerConfigPath, `${JSON.stringify(config, null, "\t")}\n`, "utf8"); +} + +export function getPrimaryD1Binding(config: WranglerConfig): WranglerD1Binding | null { + return config.d1_databases?.[0] ?? null; +} + +export function isUuid(value: string): boolean { + return uuidSchema.safeParse(value).success; +} + +export function isZoneId(value: string): boolean { + return zoneIdSchema.safeParse(value).success; +} + +export function isPlaceholderDatabaseId(value: string): boolean { + return value === PLACEHOLDER_DATABASE_ID; +} + +function isValidHostnameLabel(value: string): boolean { + return /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(value); +} + +export function validateAllowedHostnamesCsv(value: string): HostnameValidationResult { + const errors: string[] = []; + const normalized: string[] = []; + const seen = new Set(); + + if (!value || !value.trim()) { + return { + ok: false, + errors: ["DDNS_ALLOWED_HOSTNAMES cannot be empty."], + hostnames: [], + }; + } + + for (const [index, rawEntry] of value.split(",").entries()) { + const entry = rawEntry.trim().toLowerCase(); + if (!entry) { + errors.push(`Entry ${index + 1} is empty.`); + continue; + } + + const labels = entry.split("."); + if (labels.length < 2) { + errors.push(`Entry ${index + 1} must be a fully qualified hostname: ${entry}`); + continue; + } + + const wildcardLabels = labels.filter((label) => label === "*").length; + if (wildcardLabels > 1) { + errors.push(`Entry ${index + 1} can only contain one wildcard label: ${entry}`); + continue; + } + + if (wildcardLabels === 1 && labels[0] !== "*") { + errors.push(`Entry ${index + 1} may only use a wildcard in the first label: ${entry}`); + continue; + } + + const invalidLabel = labels.find((label, labelIndex) => { + if (label === "*" && labelIndex === 0) return false; + return !isValidHostnameLabel(label); + }); + + if (invalidLabel) { + errors.push(`Entry ${index + 1} contains an invalid label: ${entry}`); + continue; + } + + if (seen.has(entry)) { + errors.push(`Entry ${index + 1} is duplicated: ${entry}`); + continue; + } + + seen.add(entry); + normalized.push(entry); + } + + return { + ok: errors.length === 0, + errors, + hostnames: normalized, + }; +} + +export function validateRequiredText(value: string, fieldName: string): string { + const result = nonEmptyTextSchema.safeParse(value); + if (result.success) { + return ""; + } + + return `${fieldName} cannot be empty.`; +} + +export function validateSharedSecret(value: string): string { + const required = validateRequiredText(value, "DDNS_SHARED_SECRET"); + if (required) { + return required; + } + + const result = sharedSecretSchema.safeParse(value); + return result.success ? "" : result.error.issues[0]?.message ?? "DDNS_SHARED_SECRET is invalid."; +} + +export function validateZoneId(value: string): string { + const required = validateRequiredText(value, "CF_ZONE_ID"); + if (required) { + return required; + } + + const result = zoneIdSchema.safeParse(value); + return result.success ? "" : result.error.issues[0]?.message ?? "CF_ZONE_ID is invalid."; +} + +export function generatedSharedSecret(): string { + return randomBytes(24).toString("base64url"); +} + +export function wranglerBinaryPath(): string { + return path.join(projectRoot, "node_modules", ".bin", process.platform === "win32" ? "wrangler.cmd" : "wrangler"); +} + +export function runWrangler(args: string[], options: WranglerResultOptions = {}): string { + const result = spawnSync(wranglerBinaryPath(), args, { + cwd: projectRoot, + encoding: "utf8", + input: options.input, + stdio: options.stdio ?? "pipe", + }); + + if (result.error) { + throw result.error; + } + + if (result.status !== 0) { + const details = [result.stdout, result.stderr].filter(Boolean).join("\n").trim(); + throw new Error(details || `Wrangler command failed: ${args.join(" ")}`); + } + + return (result.stdout ?? "").trim(); +} + +export function ensureWranglerAuth(): void { + try { + runWrangler(["whoami"]); + } catch { + throw new Error("Wrangler is not logged in. Run `npx wrangler login` and try again."); + } +} + +export async function prompt(question: string, options: PromptOptions = {}): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + try { + const suffix = options.defaultValue ? ` [${options.defaultValue}]` : ""; + const answer = (await rl.question(`${question}${suffix}: `)).trim(); + if (!answer && options.defaultValue !== undefined) { + return options.defaultValue; + } + return answer; + } finally { + rl.close(); + } +} + +export async function promptYesNo(question: string, defaultValue = true): Promise { + const hint = defaultValue ? "Y/n" : "y/N"; + const answer = (await prompt(`${question} (${hint})`)).toLowerCase(); + if (!answer) return defaultValue; + return answer === "y" || answer === "yes"; +} + +export function isMainModule(metaUrl: string): boolean { + const target = process.argv[1]; + if (!target) return false; + return metaUrl === pathToFileURL(path.resolve(target)).href; +} + +export function printHeading(title: string): void { + console.log(`\n== ${title} ==`); +} + +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); +} \ No newline at end of file diff --git a/scripts/setup-db.ts b/scripts/setup-db.ts new file mode 100644 index 0000000..eb7753c --- /dev/null +++ b/scripts/setup-db.ts @@ -0,0 +1,67 @@ +import { + ensureWranglerAuth, + getPrimaryD1Binding, + isMainModule, + isPlaceholderDatabaseId, + isUuid, + printHeading, + prompt, + readWranglerConfig, + runWrangler, + 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); + const existingId = existingBinding?.database_id; + + if (existingId && isUuid(existingId) && !isPlaceholderDatabaseId(existingId)) { + console.log(`D1 database is already configured: ${existingBinding.database_name} (${existingId})`); + return existingId; + } + + ensureWranglerAuth(); + printHeading("D1 database setup"); + + 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); + + if (!databaseId) { + throw new Error( + `Wrangler created a database but the script could not find the database_id in the output.\n${output}`, + ); + } + + config.d1_databases = [ + { + binding: existingBinding?.binding || "DB", + database_name: databaseName, + database_id: databaseId, + }, + ]; + + await writeWranglerConfig(config); + console.log(`Updated wrangler.jsonc with D1 database ${databaseName} (${databaseId}).`); + return databaseId; +} + +async function main(): Promise { + await setupDatabase(); + console.log("Next: run `pnpm setup:secrets` or `pnpm setup`."); +} + +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/setup-secrets.ts b/scripts/setup-secrets.ts new file mode 100644 index 0000000..9785ea1 --- /dev/null +++ b/scripts/setup-secrets.ts @@ -0,0 +1,96 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { + REQUIRED_SECRETS, + ensureWranglerAuth, + generatedSharedSecret, + isMainModule, + printHeading, + prompt, + readWranglerConfig, + runWrangler, + validateAllowedHostnamesCsv, + validateRequiredText, + validateSharedSecret, + validateZoneId, +} from "./common.ts"; + +async function promptUntilValid( + question: string, + validate: (value: string) => string, + defaultValue?: string, +): Promise { + while (true) { + const answer = await prompt(question, { defaultValue }); + const error = validate(answer); + if (!error) return answer; + console.error(error); + } +} + +export async function setupSecrets(): Promise<{ + apiToken: string; + zoneId: string; + sharedSecret: string; + allowedHostnames: string; +}> { + const config = await readWranglerConfig(); + ensureWranglerAuth(); + printHeading(`Secret setup for ${config.name}`); + + const apiToken = await promptUntilValid( + "Cloudflare API token", + (value) => validateRequiredText(value, "CF_API_TOKEN"), + process.env.CF_API_TOKEN, + ); + + const zoneId = await promptUntilValid("Cloudflare zone ID", validateZoneId, process.env.CF_ZONE_ID); + + const sharedSecret = await promptUntilValid( + "DDNS shared secret", + validateSharedSecret, + process.env.DDNS_SHARED_SECRET || generatedSharedSecret(), + ); + + const allowedHostnames = await promptUntilValid( + "Allowed hostnames (comma-separated)", + (value) => { + const result = validateAllowedHostnamesCsv(value); + return result.ok ? "" : result.errors.join("\n"); + }, + process.env.DDNS_ALLOWED_HOSTNAMES || "nas.example.com,*.nas.example.com", + ); + + const tempFile = path.join(os.tmpdir(), `cloudflare-ddns-secrets-${Date.now()}.env`); + const envText = [ + `CF_API_TOKEN=${apiToken}`, + `CF_ZONE_ID=${zoneId}`, + `DDNS_SHARED_SECRET=${sharedSecret}`, + `DDNS_ALLOWED_HOSTNAMES=${allowedHostnames}`, + ].join("\n"); + + await fs.writeFile(tempFile, envText, "utf8"); + try { + runWrangler(["secret", "bulk", tempFile], { stdio: "inherit" }); + } finally { + await fs.rm(tempFile, { force: true }); + } + + console.log(`Uploaded ${REQUIRED_SECRETS.length} required secrets.`); + return { apiToken, zoneId, sharedSecret, allowedHostnames }; +} + +async function main(): Promise { + await setupSecrets(); + console.log("Next: run `pnpm verify-setup` or `pnpm setup`."); +} + +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/setup.ts b/scripts/setup.ts new file mode 100644 index 0000000..cdd0765 --- /dev/null +++ b/scripts/setup.ts @@ -0,0 +1,42 @@ +import { isMainModule, printHeading, promptYesNo, runWrangler, ensureWranglerAuth } 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 { + printHeading("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) { + console.log("Setup complete. Run `pnpm deploy` when you are ready."); + return; + } + + printHeading("Applying migrations"); + runWrangler(["d1", "migrations", "apply", "DB", "--remote"], { stdio: "inherit" }); + + printHeading("Deploying Worker"); + runWrangler(["deploy"], { stdio: "inherit" }); + console.log("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 new file mode 100644 index 0000000..917847c --- /dev/null +++ b/scripts/verify-setup.ts @@ -0,0 +1,72 @@ +import { + REQUIRED_SECRETS, + getPrimaryD1Binding, + isMainModule, + isPlaceholderDatabaseId, + isUuid, + parseSecretList, + readWranglerConfig, + runWrangler, +} 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_id || !isUuid(binding.database_id) || isPlaceholderDatabaseId(binding.database_id)) { + errors.push("D1 database_id is missing or still set to the placeholder. Run `pnpm setup:db`."); + } + + const missingSecretsConfig = missingRequiredSecrets(config); + if (missingSecretsConfig.length > 0) { + errors.push( + `wrangler.jsonc is missing required secret declarations for: ${missingSecretsConfig.join(", ")}`, + ); + } + + 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")); + } + + console.log(`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/tsconfig.scripts.json b/tsconfig.scripts.json new file mode 100644 index 0000000..344015d --- /dev/null +++ b/tsconfig.scripts.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "target": "es2023", + "lib": ["es2023"], + "module": "es2022", + "moduleResolution": "bundler", + "types": ["node"], + "allowImportingTsExtensions": true, + "noEmit": true + }, + "include": ["scripts/**/*.ts"] +} \ No newline at end of file