diff --git a/client/src/config.ts b/client/src/config.ts index 65d8087..fae9b35 100644 --- a/client/src/config.ts +++ b/client/src/config.ts @@ -1,50 +1,23 @@ -export interface Env { +import { validatePositiveInteger, validateString, validateUrl } from '@meyfa/ddns-common' + +export interface Config { DDNS_URL: URL DDNS_SECRET: string DDNS_UPDATE_INTERVAL: number DDNS_REQUEST_TIMEOUT: number } -export function validateEnvironment(env: NodeJS.ProcessEnv): Env { - const result = { +export function validateEnvironment(env: NodeJS.ProcessEnv): Config { + const config = { 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) { + if (config.DDNS_UPDATE_INTERVAL < config.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 + return config } diff --git a/client/src/main.ts b/client/src/main.ts index fa06af1..dd1f493 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -3,7 +3,7 @@ import { validateEnvironment } from './config.js' import { log } from './log.js' import { setTimeout as delay } from 'node:timers/promises' -const env = validateEnvironment(process.env) +const config = validateEnvironment(process.env) const abortController = new AbortController() for (const signal of ['SIGTERM', 'SIGINT'] as const) { @@ -16,11 +16,11 @@ for (const signal of ['SIGTERM', 'SIGINT'] as const) { function run() { log('info', 'Updating DDNS') - const signal = AbortSignal.any([abortController.signal, AbortSignal.timeout(env.DDNS_REQUEST_TIMEOUT * 1000)]) + const signal = AbortSignal.any([abortController.signal, AbortSignal.timeout(config.DDNS_REQUEST_TIMEOUT * 1000)]) update({ - url: env.DDNS_URL, - secret: env.DDNS_SECRET, + url: config.DDNS_URL, + secret: config.DDNS_SECRET, signal }) .then((result) => { @@ -35,7 +35,7 @@ while (!abortController.signal.aborted) { run() try { - await delay(env.DDNS_UPDATE_INTERVAL * 1000, undefined, { signal: abortController.signal }) + await delay(config.DDNS_UPDATE_INTERVAL * 1000, undefined, { signal: abortController.signal }) } catch (ignored: unknown) { // aborted } diff --git a/common/src/main.ts b/common/src/main.ts index 49ecc2c..729d6ae 100644 --- a/common/src/main.ts +++ b/common/src/main.ts @@ -2,3 +2,34 @@ export interface UpdateResponse { ip: string modified: boolean } + +type EnvDict = Record + +export function validateUrl(env: EnvDict, 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) +} + +export function validateString(env: EnvDict, key: string): string { + const input = env[key] + if (input == null || input === '') { + throw new Error(`${key} must be a non-empty string`) + } + return input +} + +export function validatePositiveInteger(env: EnvDict, 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/worker/src/main.ts b/worker/src/main.ts index d6aa318..ccf4438 100644 --- a/worker/src/main.ts +++ b/worker/src/main.ts @@ -1,10 +1,19 @@ -import type { UpdateResponse } from '@meyfa/ddns-common' +import { validateString, type UpdateResponse } from '@meyfa/ddns-common' -interface Env { - DDNS_SECRET?: string - CLOUDFLARE_API_TOKEN?: string - CLOUDFLARE_ZONE_ID?: string - CLOUDFLARE_RECORD_NAME?: string +interface Config { + DDNS_SECRET: string + CLOUDFLARE_API_TOKEN: string + CLOUDFLARE_ZONE_ID: string + CLOUDFLARE_RECORD_NAME: string +} + +function validateEnvironment(env: Record): Config { + return { + DDNS_SECRET: validateString(env, 'DDNS_SECRET'), + CLOUDFLARE_API_TOKEN: validateString(env, 'CLOUDFLARE_API_TOKEN'), + CLOUDFLARE_ZONE_ID: validateString(env, 'CLOUDFLARE_ZONE_ID'), + CLOUDFLARE_RECORD_NAME: validateString(env, 'CLOUDFLARE_RECORD_NAME') + } } const API = new URL('https://api.cloudflare.com/client/v4/') @@ -13,7 +22,9 @@ const RATE_LIMIT_MS = 30_000 let lastRequest = 0 export default { - async fetch(req: Request, env: Env, ctx: unknown): Promise { + async fetch(req: Request, env: Record, ctx: unknown): Promise { + const config = validateEnvironment(env) + const url = new URL(req.url) const remoteAddress = req.headers.get('CF-Connecting-IP') if (remoteAddress == null) { @@ -27,7 +38,7 @@ export default { // Authorize request const auth = req.headers.get('Authorization') ?? '' - if (!timingSafeEqual(auth, `Bearer ${env.DDNS_SECRET}`)) { + if (!timingSafeEqual(auth, `Bearer ${config.DDNS_SECRET}`)) { return new Response('Unauthorized', { status: 401 }) } @@ -51,7 +62,7 @@ export default { // Update records let modified = false try { - modified = await updateRecords(env, remoteAddress) + modified = await updateRecords(config, remoteAddress) } catch (err: unknown) { console.error(err) return new Response('Internal Server Error', { status: 500 }) @@ -87,28 +98,16 @@ function timingSafeEqual(a: string, b: string) { 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) +async function updateRecords(config: Config, address: string): Promise { + const record = await getRecord(config, config.CLOUDFLARE_ZONE_ID, config.CLOUDFLARE_RECORD_NAME) if (record.content === address) { return false } - - await updateRecord(env, env.CLOUDFLARE_ZONE_ID, record.id, address) - + await updateRecord(config, config.CLOUDFLARE_ZONE_ID, record.id, address) return true } -interface Record { +interface DnsRecord { id: string type: string name: string @@ -116,13 +115,13 @@ interface Record { proxied: boolean } -async function getRecord(env: Env, zoneId: string, recordName: string): Promise { +async function getRecord(config: Config, 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}` + Authorization: `Bearer ${config.CLOUDFLARE_API_TOKEN}` } }) @@ -142,13 +141,13 @@ async function getRecord(env: Env, zoneId: string, recordName: string): Promise< return data.result[0] } -async function updateRecord(env: Env, zoneId: string, recordId: string, address: string): Promise { +async function updateRecord(config: Config, 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}`, + Authorization: `Bearer ${config.CLOUDFLARE_API_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify({