Skip to content

Commit

Permalink
feat: Add initial implementation (#1)
Browse files Browse the repository at this point in the history
  • Loading branch information
meyfa authored Nov 30, 2024
1 parent fb421b3 commit 0f0293c
Show file tree
Hide file tree
Showing 21 changed files with 1,308 additions and 1 deletion.
23 changes: 23 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.DS_Store
node_modules
dist
.wrangler
5 changes: 5 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
**/node_modules
**/dist
**/*.json
**/*.yml
**/*.yaml
8 changes: 8 additions & 0 deletions .prettierrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"tabWidth": 2,
"semi": false,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 120
}
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1 @@
# dyndns
# Dynamic DNS Using Cloudflare Workers
14 changes: 14 additions & 0 deletions client/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
**/.DS_Store

**/.gitignore
**/.gitattributes

**/node_modules
**/dist
**/.wrangler

**/Dockerfile
**/.dockerignore

**/.prettierignore
**/prettierrc.json
30 changes: 30 additions & 0 deletions client/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
63 changes: 63 additions & 0 deletions client/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 21 additions & 0 deletions client/package.json
Original file line number Diff line number Diff line change
@@ -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:.."
}
}
26 changes: 26 additions & 0 deletions client/src/client.ts
Original file line number Diff line number Diff line change
@@ -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<UpdateResponse> {
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
}
50 changes: 50 additions & 0 deletions client/src/config.ts
Original file line number Diff line number Diff line change
@@ -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
}
10 changes: 10 additions & 0 deletions client/src/log.ts
Original file line number Diff line number Diff line change
@@ -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')
}
42 changes: 42 additions & 0 deletions client/src/main.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
19 changes: 19 additions & 0 deletions client/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
14 changes: 14 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading

0 comments on commit 0f0293c

Please sign in to comment.