Skip to content

Commit

Permalink
fix: Move environment validation to common (#13)
Browse files Browse the repository at this point in the history
Uses common functions to validate environment variables in the client and
the worker, reducing code duplication. The validation result is renamed to
"config" instead of "env", since "env" is just the source of the data, but did
not properly describe its usage.
  • Loading branch information
meyfa authored Dec 1, 2024
1 parent 46be1da commit eda6419
Show file tree
Hide file tree
Showing 4 changed files with 71 additions and 68 deletions.
41 changes: 7 additions & 34 deletions client/src/config.ts
Original file line number Diff line number Diff line change
@@ -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
}
10 changes: 5 additions & 5 deletions client/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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) => {
Expand All @@ -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
}
Expand Down
31 changes: 31 additions & 0 deletions common/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,34 @@ export interface UpdateResponse {
ip: string
modified: boolean
}

type EnvDict = Record<string, string | undefined>

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
}
57 changes: 28 additions & 29 deletions worker/src/main.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined>): 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/')
Expand All @@ -13,7 +22,9 @@ const RATE_LIMIT_MS = 30_000
let lastRequest = 0

export default {
async fetch(req: Request, env: Env, ctx: unknown): Promise<Response> {
async fetch(req: Request, env: Record<string, string | undefined>, ctx: unknown): Promise<Response> {
const config = validateEnvironment(env)

const url = new URL(req.url)
const remoteAddress = req.headers.get('CF-Connecting-IP')
if (remoteAddress == null) {
Expand All @@ -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 })
}

Expand All @@ -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 })
Expand Down Expand Up @@ -87,42 +98,30 @@ function timingSafeEqual(a: string, b: string) {
return crypto.subtle.timingSafeEqual(aBytes, bBytes)
}

async function updateRecords(env: Env, address: string): Promise<boolean> {
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<boolean> {
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
content: string
proxied: boolean
}

async function getRecord(env: Env, zoneId: string, recordName: string): Promise<Record> {
async function getRecord(config: Config, zoneId: string, recordName: string): Promise<DnsRecord> {
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}`
}
})

Expand All @@ -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<void> {
async function updateRecord(config: Config, zoneId: string, recordId: string, address: string): Promise<void> {
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({
Expand Down

0 comments on commit eda6419

Please sign in to comment.