diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..aaf1d9e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,23 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + +permissions: + contents: read + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci && npm --prefix=client ci && npm --prefix=worker ci + - run: npm run lint diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3f7b92b --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +node_modules +dist +.wrangler diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..c267eed --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +**/node_modules +**/dist +**/*.json +**/*.yml +**/*.yaml diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..05397e7 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/prettierrc", + "tabWidth": 2, + "semi": false, + "singleQuote": true, + "trailingComma": "none", + "printWidth": 120 +} diff --git a/README.md b/README.md index ef56019..0c3b96c 100644 --- a/README.md +++ b/README.md @@ -1 +1 @@ -# dyndns \ No newline at end of file +# Dynamic DNS Using Cloudflare Workers diff --git a/client/.dockerignore b/client/.dockerignore new file mode 100644 index 0000000..d7cf18a --- /dev/null +++ b/client/.dockerignore @@ -0,0 +1,14 @@ +**/.DS_Store + +**/.gitignore +**/.gitattributes + +**/node_modules +**/dist +**/.wrangler + +**/Dockerfile +**/.dockerignore + +**/.prettierignore +**/prettierrc.json diff --git a/client/Dockerfile b/client/Dockerfile new file mode 100644 index 0000000..7526374 --- /dev/null +++ b/client/Dockerfile @@ -0,0 +1,30 @@ +# check=skip=SecretsUsedInArgOrEnv + +FROM node:20.18.1-alpine AS build +WORKDIR /app + +COPY package*.json ./ +RUN npm ci + +COPY . . +RUN npm run build + +FROM node:20.18.1-alpine AS deploy +WORKDIR /app + +COPY package*.json ./ +RUN npm ci --omit=dev && apk add --no-cache tini + +COPY --from=build /app/dist ./dist + +USER node + +ENV NODE_ENV=production + +ENV DDNS_URL='' +ENV DDNS_SECRET='' +ENV DDNS_UPDATE_INTERVAL='300' +ENV DDNS_REQUEST_TIMEOUT='30' + +ENTRYPOINT ["/sbin/tini", "--"] +CMD ["node", "--enable-source-maps", "dist/main.js"] diff --git a/client/package-lock.json b/client/package-lock.json new file mode 100644 index 0000000..804aa49 --- /dev/null +++ b/client/package-lock.json @@ -0,0 +1,63 @@ +{ + "name": "@meyfa/ddns-client", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@meyfa/ddns-client", + "dependencies": { + "@meyfa/ddns": "file:.." + }, + "devDependencies": { + "@types/node": "20.17.9", + "typescript": "5.7.2" + }, + "engines": { + "node": ">=20", + "npm": ">=9" + } + }, + "..": { + "name": "@meyfa/ddns", + "engines": { + "node": ">=20", + "npm": ">=9" + } + }, + "node_modules/@meyfa/ddns": { + "resolved": "..", + "link": true + }, + "node_modules/@types/node": { + "version": "20.17.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.9.tgz", + "integrity": "sha512-0JOXkRyLanfGPE2QRCwgxhzlBAvaRdCNMcvbd7jFfpmD4eEXll7LRwy5ymJmyeZqk7Nh7eD2LeUyQ68BbndmXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/typescript": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", + "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..b3d0f48 --- /dev/null +++ b/client/package.json @@ -0,0 +1,21 @@ +{ + "private": true, + "name": "@meyfa/ddns-client", + "type": "module", + "main": "dist/main.js", + "scripts": { + "build": "tsc", + "lint": "tsc --noEmit" + }, + "engines": { + "node": ">=20", + "npm": ">=9" + }, + "devDependencies": { + "@types/node": "20.17.9", + "typescript": "5.7.2" + }, + "dependencies": { + "@meyfa/ddns": "file:.." + } +} diff --git a/client/src/client.ts b/client/src/client.ts new file mode 100644 index 0000000..1e4380a --- /dev/null +++ b/client/src/client.ts @@ -0,0 +1,26 @@ +export interface UpdateOptions { + url: URL + secret: string + signal?: AbortSignal +} + +export interface UpdateResponse { + ip: string + modified: boolean +} + +export async function update(options: UpdateOptions): Promise { + const res = await fetch(options.url, { + method: 'PUT', + headers: { + Authorization: `Bearer ${options.secret}` + }, + signal: options.signal + }) + + if (!res.ok) { + throw new Error(`Request failed: ${res.status} ${res.statusText}`) + } + + return (await res.json()) as UpdateResponse +} diff --git a/client/src/config.ts b/client/src/config.ts new file mode 100644 index 0000000..65d8087 --- /dev/null +++ b/client/src/config.ts @@ -0,0 +1,50 @@ +export interface Env { + DDNS_URL: URL + DDNS_SECRET: string + DDNS_UPDATE_INTERVAL: number + DDNS_REQUEST_TIMEOUT: number +} + +export function validateEnvironment(env: NodeJS.ProcessEnv): Env { + const result = { + DDNS_URL: validateUrl(env, 'DDNS_URL'), + DDNS_SECRET: validateString(env, 'DDNS_SECRET'), + DDNS_UPDATE_INTERVAL: validatePositiveInteger(env, 'DDNS_UPDATE_INTERVAL'), + DDNS_REQUEST_TIMEOUT: validatePositiveInteger(env, 'DDNS_REQUEST_TIMEOUT') + } + + if (result.DDNS_UPDATE_INTERVAL < result.DDNS_REQUEST_TIMEOUT) { + throw new Error('DDNS_UPDATE_INTERVAL must be greater than or equal to DDNS_REQUEST_TIMEOUT') + } + + return result +} + +function validateUrl(env: NodeJS.ProcessEnv, key: string): URL { + const input = env[key] + if (input == null || input === '' || !URL.canParse(input)) { + throw new Error(`${key} must be a valid URL`) + } + return new URL(input) +} + +function validateString(env: NodeJS.ProcessEnv, key: string): string { + const input = env[key] + if (input == null || input === '') { + throw new Error(`${key} is required`) + } + return input +} + +function validatePositiveInteger(env: NodeJS.ProcessEnv, key: string): number { + const input = env[key] + const error = `${key} must be a positive integer` + if (input == null || input === '' || !/^\d+$/.test(input)) { + throw new Error(error) + } + const value = Number.parseInt(input, 10) + if (value <= 0) { + throw new Error(error) + } + return value +} diff --git a/client/src/log.ts b/client/src/log.ts new file mode 100644 index 0000000..b6d8d42 --- /dev/null +++ b/client/src/log.ts @@ -0,0 +1,10 @@ +type LogLevel = 'info' | 'error' + +export function log(level: LogLevel, message: string) { + const str = `${new Date().toISOString()} [${level}] ${message}` + if (level === 'error') { + process.stderr.write(str + '\n') + return + } + process.stdout.write(str + '\n') +} diff --git a/client/src/main.ts b/client/src/main.ts new file mode 100644 index 0000000..fa06af1 --- /dev/null +++ b/client/src/main.ts @@ -0,0 +1,42 @@ +import { update } from './client.js' +import { validateEnvironment } from './config.js' +import { log } from './log.js' +import { setTimeout as delay } from 'node:timers/promises' + +const env = validateEnvironment(process.env) + +const abortController = new AbortController() +for (const signal of ['SIGTERM', 'SIGINT'] as const) { + process.once(signal, (signal) => { + log('info', `Received ${signal}, exiting...`) + abortController.abort() + }) +} + +function run() { + log('info', 'Updating DDNS') + + const signal = AbortSignal.any([abortController.signal, AbortSignal.timeout(env.DDNS_REQUEST_TIMEOUT * 1000)]) + + update({ + url: env.DDNS_URL, + secret: env.DDNS_SECRET, + signal + }) + .then((result) => { + log('info', result.modified ? `Updated IP address: ${result.ip}` : `IP address unchanged: ${result.ip}`) + }) + .catch((err: unknown) => { + log('error', err instanceof Error ? `${err.name}: ${err.message}` : String(err)) + }) +} + +while (!abortController.signal.aborted) { + run() + + try { + await delay(env.DDNS_UPDATE_INTERVAL * 1000, undefined, { signal: abortController.signal }) + } catch (ignored: unknown) { + // aborted + } +} diff --git a/client/tsconfig.json b/client/tsconfig.json new file mode 100644 index 0000000..4a191ee --- /dev/null +++ b/client/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "lib": ["ES2022"], + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "nodenext", + "outDir": "./dist", + "verbatimModuleSyntax": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "allowJs": true, + "declaration": false, + "sourceMap": true + }, + "include": [ + "src" + ] +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..a216dc6 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,14 @@ +{ + "name": "@meyfa/ddns", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@meyfa/ddns", + "engines": { + "node": ">=20", + "npm": ">=9" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7e82c22 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "private": true, + "name": "@meyfa/ddns", + "scripts": { + "lint": "npm --prefix=./client run lint && npm --prefix=./worker run lint", + "deploy": "npm --prefix=./worker run deploy" + }, + "engines": { + "node": ">=20", + "npm": ">=9" + } +} diff --git a/worker/package-lock.json b/worker/package-lock.json new file mode 100644 index 0000000..5fd3414 --- /dev/null +++ b/worker/package-lock.json @@ -0,0 +1,759 @@ +{ + "name": "@meyfa/ddns-cloudflare-worker", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@meyfa/ddns-cloudflare-worker", + "dependencies": { + "@meyfa/ddns": "file:.." + }, + "devDependencies": { + "@cloudflare/workers-types": "4.20241127.0", + "typescript": "5.7.2", + "wrangler": "3.91.0" + } + }, + "..": { + "name": "@meyfa/ddns", + "engines": { + "node": ">=20", + "npm": ">=9" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.3.4", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "mime": "^3.0.0" + }, + "engines": { + "node": ">=16.13" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20241106.1", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workers-shared": { + "version": "0.9.0", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "mime": "^3.0.0", + "zod": "^3.22.3" + }, + "engines": { + "node": ">=16.7.0" + } + }, + "node_modules/@cloudflare/workers-types": { + "version": "4.20241127.0", + "dev": true, + "license": "MIT OR Apache-2.0" + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-plugins/node-globals-polyfill": { + "version": "0.2.3", + "dev": true, + "license": "ISC", + "peerDependencies": { + "esbuild": "*" + } + }, + "node_modules/@esbuild-plugins/node-modules-polyfill": { + "version": "0.2.2", + "dev": true, + "license": "ISC", + "dependencies": { + "escape-string-regexp": "^4.0.0", + "rollup-plugin-node-polyfills": "^0.2.1" + }, + "peerDependencies": { + "esbuild": "*" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.17.19", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@fastify/busboy": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@meyfa/ddns": { + "resolved": "..", + "link": true + }, + "node_modules/@types/node": { + "version": "22.10.1", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.20.0" + } + }, + "node_modules/@types/node-forge": { + "version": "1.3.11", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/acorn": { + "version": "8.14.0", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/as-table": { + "version": "1.0.55", + "dev": true, + "license": "MIT", + "dependencies": { + "printable-characters": "^1.0.42" + } + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "dev": true, + "license": "MIT" + }, + "node_modules/capnp-ts": { + "version": "0.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.1", + "tslib": "^2.2.0" + } + }, + "node_modules/chokidar": { + "version": "4.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "2.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/date-fns": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/debug": { + "version": "4.3.7", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/defu": { + "version": "6.1.4", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.17.19", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.17.19", + "@esbuild/android-arm64": "0.17.19", + "@esbuild/android-x64": "0.17.19", + "@esbuild/darwin-arm64": "0.17.19", + "@esbuild/darwin-x64": "0.17.19", + "@esbuild/freebsd-arm64": "0.17.19", + "@esbuild/freebsd-x64": "0.17.19", + "@esbuild/linux-arm": "0.17.19", + "@esbuild/linux-arm64": "0.17.19", + "@esbuild/linux-ia32": "0.17.19", + "@esbuild/linux-loong64": "0.17.19", + "@esbuild/linux-mips64el": "0.17.19", + "@esbuild/linux-ppc64": "0.17.19", + "@esbuild/linux-riscv64": "0.17.19", + "@esbuild/linux-s390x": "0.17.19", + "@esbuild/linux-x64": "0.17.19", + "@esbuild/netbsd-x64": "0.17.19", + "@esbuild/openbsd-x64": "0.17.19", + "@esbuild/sunos-x64": "0.17.19", + "@esbuild/win32-arm64": "0.17.19", + "@esbuild/win32-ia32": "0.17.19", + "@esbuild/win32-x64": "0.17.19" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "0.6.1", + "dev": true, + "license": "MIT" + }, + "node_modules/exit-hook": { + "version": "2.2.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-source": { + "version": "2.0.12", + "dev": true, + "license": "Unlicense", + "dependencies": { + "data-uri-to-buffer": "^2.0.0", + "source-map": "^0.6.1" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/hasown": { + "version": "2.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-core-module": { + "version": "2.15.1", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/itty-time": { + "version": "1.0.6", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "sourcemap-codec": "^1.4.8" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/miniflare": { + "version": "3.20241106.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "acorn": "^8.8.0", + "acorn-walk": "^8.2.0", + "capnp-ts": "^0.7.0", + "exit-hook": "^2.2.1", + "glob-to-regexp": "^0.4.1", + "stoppable": "^1.1.0", + "undici": "^5.28.4", + "workerd": "1.20241106.1", + "ws": "^8.18.0", + "youch": "^3.2.2", + "zod": "^3.22.3" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=16.13" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/mustache": { + "version": "4.2.0", + "dev": true, + "license": "MIT", + "bin": { + "mustache": "bin/mustache" + } + }, + "node_modules/nanoid": { + "version": "3.3.8", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-forge": { + "version": "1.3.1", + "dev": true, + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/ohash": { + "version": "1.1.4", + "dev": true, + "license": "MIT" + }, + "node_modules/path-parse": { + "version": "1.0.7", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "1.1.2", + "dev": true, + "license": "MIT" + }, + "node_modules/printable-characters": { + "version": "1.0.42", + "dev": true, + "license": "Unlicense" + }, + "node_modules/readdirp": { + "version": "4.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve": { + "version": "1.22.8", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve.exports": { + "version": "2.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/rollup-plugin-inject": { + "version": "3.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^0.6.1", + "magic-string": "^0.25.3", + "rollup-pluginutils": "^2.8.1" + } + }, + "node_modules/rollup-plugin-node-polyfills": { + "version": "0.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "rollup-plugin-inject": "^3.0.0" + } + }, + "node_modules/rollup-pluginutils": { + "version": "2.8.2", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^0.6.1" + } + }, + "node_modules/selfsigned": { + "version": "2.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node-forge": "^1.3.0", + "node-forge": "^1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sourcemap-codec": { + "version": "1.4.8", + "dev": true, + "license": "MIT" + }, + "node_modules/stacktracey": { + "version": "2.1.8", + "dev": true, + "license": "Unlicense", + "dependencies": { + "as-table": "^1.0.36", + "get-source": "^2.0.12" + } + }, + "node_modules/stoppable": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4", + "npm": ">=6" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.7.2", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.5.4", + "dev": true, + "license": "MIT" + }, + "node_modules/undici": { + "version": "5.28.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, + "node_modules/undici-types": { + "version": "6.20.0", + "dev": true, + "license": "MIT" + }, + "node_modules/unenv": { + "name": "unenv-nightly", + "version": "2.0.0-20241121-161142-806b5c0", + "dev": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "ohash": "^1.1.4", + "pathe": "^1.1.2", + "ufo": "^1.5.4" + } + }, + "node_modules/workerd": { + "version": "1.20241106.1", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20241106.1", + "@cloudflare/workerd-darwin-arm64": "1.20241106.1", + "@cloudflare/workerd-linux-64": "1.20241106.1", + "@cloudflare/workerd-linux-arm64": "1.20241106.1", + "@cloudflare/workerd-windows-64": "1.20241106.1" + } + }, + "node_modules/wrangler": { + "version": "3.91.0", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.3.4", + "@cloudflare/workers-shared": "0.9.0", + "@esbuild-plugins/node-globals-polyfill": "^0.2.3", + "@esbuild-plugins/node-modules-polyfill": "^0.2.2", + "blake3-wasm": "^2.1.5", + "chokidar": "^4.0.1", + "date-fns": "^4.1.0", + "esbuild": "0.17.19", + "itty-time": "^1.0.6", + "miniflare": "3.20241106.1", + "nanoid": "^3.3.3", + "path-to-regexp": "^6.3.0", + "resolve": "^1.22.8", + "resolve.exports": "^2.0.2", + "selfsigned": "^2.0.1", + "source-map": "^0.6.1", + "unenv": "npm:unenv-nightly@2.0.0-20241121-161142-806b5c0", + "workerd": "1.20241106.1", + "xxhash-wasm": "^1.0.1" + }, + "bin": { + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=16.17.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^4.20241106.0" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.18.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xxhash-wasm": { + "version": "1.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/youch": { + "version": "3.3.4", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie": "^0.7.1", + "mustache": "^4.2.0", + "stacktracey": "^2.1.8" + } + }, + "node_modules/zod": { + "version": "3.23.8", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/worker/package.json b/worker/package.json new file mode 100644 index 0000000..dff079a --- /dev/null +++ b/worker/package.json @@ -0,0 +1,17 @@ +{ + "private": true, + "name": "@meyfa/ddns-cloudflare-worker", + "type": "module", + "scripts": { + "lint": "tsc --noEmit", + "deploy": "wrangler deploy --config wrangler.toml" + }, + "devDependencies": { + "@cloudflare/workers-types": "4.20241127.0", + "typescript": "5.7.2", + "wrangler": "3.91.0" + }, + "dependencies": { + "@meyfa/ddns": "file:.." + } +} diff --git a/worker/src/main.ts b/worker/src/main.ts new file mode 100644 index 0000000..7f1a01b --- /dev/null +++ b/worker/src/main.ts @@ -0,0 +1,165 @@ +interface Env { + DDNS_SECRET?: string + CLOUDFLARE_API_TOKEN?: string + CLOUDFLARE_ZONE_ID?: string + CLOUDFLARE_RECORD_NAME?: string +} + +interface UpdateResponse { + ip: string + modified: boolean +} + +const API = new URL('https://api.cloudflare.com/client/v4/') +const RATE_LIMIT_MS = 30_000 + +let lastRequest = 0 + +export default { + async fetch(req: Request, env: Env, ctx: unknown): Promise { + const url = new URL(req.url) + const remoteAddress = req.headers.get('CF-Connecting-IP') + if (remoteAddress == null) { + return new Response('Internal Server Error', { status: 500 }) + } + + // Validate request + if (req.method !== 'PUT' || url.pathname !== '/') { + return new Response('Not Found', { status: 404 }) + } + + // Authorize request + const auth = req.headers.get('Authorization') ?? '' + if (!timingSafeEqual(auth, `Bearer ${env.DDNS_SECRET}`)) { + return new Response('Unauthorized', { status: 401 }) + } + + // Throttle updates to not overload the Cloudflare API + const now = Date.now() + if (now - lastRequest < RATE_LIMIT_MS) { + return new Response('Too Many Requests', { + status: 429, + headers: { + 'Retry-After': Math.ceil((lastRequest + RATE_LIMIT_MS - now) / 1000).toString() + } + }) + } + lastRequest = now + + // TOOD support IPv6 + if (!/(\d+\.){3}(\d+)/.test(remoteAddress)) { + return new Response('Bad Request', { status: 400 }) + } + + // Update records + let modified = false + try { + modified = await updateRecords(env, remoteAddress) + } catch (err: unknown) { + console.error(err) + return new Response('Internal Server Error', { status: 500 }) + } + + if (modified) { + console.log(`Updated IP address to ${remoteAddress}`) + } + + // Let the client know the IP address + const response: UpdateResponse = { + ip: remoteAddress, + modified + } + + return new Response(JSON.stringify(response), { + status: 200, + headers: { + 'Content-Type': 'application/json' + } + }) + } +} + +function timingSafeEqual(a: string, b: string) { + const encoder = new TextEncoder() + const aBytes = encoder.encode(a) + const bBytes = encoder.encode(b) + if (aBytes.byteLength !== bBytes.byteLength) { + return false + } + // @ts-ignore - timingSafeEqual is Cloudflare-specific + return crypto.subtle.timingSafeEqual(aBytes, bBytes) +} + +async function updateRecords(env: Env, address: string): Promise { + if (env.CLOUDFLARE_API_TOKEN == null || env.CLOUDFLARE_API_TOKEN === '') { + throw new Error('CLOUDFLARE_API_TOKEN is required') + } + if (env.CLOUDFLARE_ZONE_ID == null || env.CLOUDFLARE_ZONE_ID === '') { + throw new Error('CLOUDFLARE_ZONE_ID is required') + } + if (env.CLOUDFLARE_RECORD_NAME == null || env.CLOUDFLARE_RECORD_NAME === '') { + throw new Error('CLOUDFLARE_RECORD_NAME is required') + } + + const record = await getRecord(env, env.CLOUDFLARE_ZONE_ID, env.CLOUDFLARE_RECORD_NAME) + if (record.content === address) { + return false + } + + await updateRecord(env, env.CLOUDFLARE_ZONE_ID, record.id, address) + + return true +} + +interface Record { + id: string + type: string + name: string + content: string + proxied: boolean +} + +async function getRecord(env: Env, zoneId: string, recordName: string): Promise { + const url = new URL(`zones/${encodeURIComponent(zoneId)}/dns_records`, API) + url.searchParams.set('name', recordName) + + const res = await fetch(url, { + headers: { + Authorization: `Bearer ${env.CLOUDFLARE_API_TOKEN}` + } + }) + + if (!res.ok) { + throw new Error(`Failed to fetch records: ${res.status} ${res.statusText}`) + } + + const data = await res.json() + if (typeof data !== 'object' || data == null || !('result' in data) || !Array.isArray(data.result)) { + throw new Error('Invalid response') + } + + if (data.result.length === 0) { + throw new Error('Record not found') + } + + return data.result[0] +} + +async function updateRecord(env: Env, zoneId: string, recordId: string, address: string): Promise { + const url = new URL(`zones/${encodeURIComponent(zoneId)}/dns_records/${encodeURIComponent(recordId)}`, API) + + const res = await fetch(url, { + method: 'PATCH', + headers: { + Authorization: `Bearer ${env.CLOUDFLARE_API_TOKEN}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + content: address + }) + }) + + if (!res.ok) { + throw new Error(`Failed to update record: ${res.status} ${res.statusText}`) + } +} diff --git a/worker/tsconfig.json b/worker/tsconfig.json new file mode 100644 index 0000000..fe6c93f --- /dev/null +++ b/worker/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "lib": ["ES2022", "DOM"], + "target": "ES2022", + "module": "ES2022", + "verbatimModuleSyntax": true, + "strict": true, + "skipLibCheck": true, + "allowJs": true, + "noEmit": true + }, + "include": [ + "src" + ] +} diff --git a/worker/wrangler.toml b/worker/wrangler.toml new file mode 100644 index 0000000..cd8b8a1 --- /dev/null +++ b/worker/wrangler.toml @@ -0,0 +1,10 @@ +compatibility_date = "2024-11-30" + +send_metrics = false + +name = "ddns" +main = "src/main.ts" +keep_vars = true + +[observability] +enabled = true