mirror of
https://github.com/cupcakearmy/docker-ddns-cloudflare.git
synced 2025-12-15 00:44:59 +00:00
1.3.0 (#6)
This commit is contained in:
1
src/cache.ts
Normal file
1
src/cache.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const Cache = new Map<'ip' | 'zone', string>()
|
||||
126
src/cloudflare.ts
Normal file
126
src/cloudflare.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import axios from 'axios'
|
||||
|
||||
import { Cache } from './cache.js'
|
||||
import { Config } from './config.js'
|
||||
import { logger } from './logger.js'
|
||||
|
||||
type DNSRecord = {
|
||||
id: string
|
||||
zone_id: string
|
||||
zone_name: string
|
||||
name: string
|
||||
type: string
|
||||
content: string
|
||||
proxiable: boolean
|
||||
proxied: boolean
|
||||
ttl: number // 1 means automatic
|
||||
locked: boolean
|
||||
}
|
||||
|
||||
type DNSRecordCreate = Pick<DNSRecord, 'name' | 'type' | 'ttl' | 'proxied' | 'content'>
|
||||
type DNSRecordPatch = Partial<DNSRecordCreate>
|
||||
|
||||
const Base = axios.create({
|
||||
baseURL: 'https://api.cloudflare.com/client/v4',
|
||||
headers: {
|
||||
Authorization: `Bearer ${Config.auth.token}`,
|
||||
},
|
||||
})
|
||||
|
||||
export const API = {
|
||||
zones: {
|
||||
async findByName(name: string): Promise<string | null> {
|
||||
try {
|
||||
const { data } = await Base({
|
||||
url: '/zones',
|
||||
params: {
|
||||
name,
|
||||
},
|
||||
})
|
||||
return data.result[0].id
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
},
|
||||
records: {
|
||||
async create(zoneId: string, zone: DNSRecordCreate): Promise<void> {
|
||||
await Base({
|
||||
url: `/zones/${zoneId}/dns_records`,
|
||||
method: 'POST',
|
||||
data: zone,
|
||||
})
|
||||
},
|
||||
async remove(zoneId: string, recordId: string): Promise<void> {
|
||||
await Base({
|
||||
url: `/zones/${zoneId}/dns_records/${recordId}`,
|
||||
method: 'DELETE',
|
||||
})
|
||||
},
|
||||
async patch(zoneId: string, recordId: string, data: DNSRecordPatch): Promise<void> {
|
||||
await Base({
|
||||
url: `/zones/${zoneId}/dns_records/${recordId}`,
|
||||
method: 'PATCH',
|
||||
data,
|
||||
})
|
||||
},
|
||||
async find(zoneId: string): Promise<DNSRecord[]> {
|
||||
const { data } = await Base({
|
||||
url: `/zones/${zoneId}/dns_records`,
|
||||
params: {
|
||||
type: 'A',
|
||||
name: Config.dns.record,
|
||||
},
|
||||
})
|
||||
return data.result as DNSRecord[]
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export async function update(ip: string) {
|
||||
// Find zone
|
||||
if (!Cache.has('zone')) {
|
||||
logger.debug('Fetching zone')
|
||||
const zone = await API.zones.findByName(Config.dns.zone)
|
||||
if (!zone) {
|
||||
logger.error(`Zone "${Config.dns.zone}"" not found`)
|
||||
process.exit(1)
|
||||
}
|
||||
Cache.set('zone', zone)
|
||||
}
|
||||
|
||||
const zoneId = Cache.get('zone')!
|
||||
logger.debug(`Zone ID: ${zoneId}`)
|
||||
|
||||
// Set record
|
||||
const records = await API.records.find(zoneId)
|
||||
|
||||
logger.debug('Updating record', ip)
|
||||
|
||||
switch (records.length) {
|
||||
case 0:
|
||||
// Create DNS Record
|
||||
logger.debug('Creating DNS record')
|
||||
await API.records.create(zoneId, {
|
||||
content: ip,
|
||||
name: Config.dns.record,
|
||||
proxied: Config.dns.proxied,
|
||||
ttl: 1,
|
||||
type: 'A',
|
||||
})
|
||||
return
|
||||
case 1:
|
||||
// Only one record, thats fine
|
||||
break
|
||||
default:
|
||||
// More than one record, delete all but the first
|
||||
logger.debug('Deleting other DNS records')
|
||||
for (const record of records.slice(1)) {
|
||||
await API.records.remove(zoneId, record.id)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// Update the remaining record
|
||||
await API.records.patch(zoneId, records[0]!.id, { content: ip, proxied: Config.dns.proxied })
|
||||
}
|
||||
50
src/config.ts
Normal file
50
src/config.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { config } from 'dotenv'
|
||||
import { validate } from 'node-cron'
|
||||
|
||||
config()
|
||||
|
||||
function getEnv(key: string, fallback: string, parse?: undefined, validator?: (s: string) => boolean): string
|
||||
function getEnv<T>(key: string, fallback: T, parse: (value: string) => T, validator?: (T: string) => boolean): T
|
||||
function getEnv<T>(
|
||||
key: string,
|
||||
fallback: T,
|
||||
parse?: (value: string) => T,
|
||||
validator?: (s: string | T) => boolean
|
||||
): T | string {
|
||||
const value = process.env[key]
|
||||
const parsed = value === undefined ? fallback : parse ? parse(value) : value
|
||||
if (validator && !validator(parsed)) {
|
||||
console.error(`Invalid or missing value for ${key}: ${value}`)
|
||||
process.exit(1)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function parseBoolean(value: string): boolean {
|
||||
value = value.toLowerCase()
|
||||
const truthy = ['true', 'yes', '1']
|
||||
return truthy.includes(value)
|
||||
}
|
||||
|
||||
function isPresent(s: string): boolean {
|
||||
return s.length > 0
|
||||
}
|
||||
|
||||
export const Config = {
|
||||
version: getEnv('npm_package_version', 'unknown'),
|
||||
logging: {
|
||||
level: getEnv('LOG_LEVEL', 'info'),
|
||||
},
|
||||
auth: {
|
||||
token: getEnv('TOKEN', '', undefined, isPresent),
|
||||
},
|
||||
dns: {
|
||||
zone: getEnv('ZONE', '', undefined, isPresent),
|
||||
record: getEnv('DNS_RECORD', '', undefined, isPresent),
|
||||
proxied: getEnv('PROXIED', false, parseBoolean),
|
||||
},
|
||||
runner: {
|
||||
cron: getEnv('CRON', '*/5 * * * *', undefined, (s) => validate(s)),
|
||||
resolver: getEnv('RESOLVER', 'https://api.ipify.org'),
|
||||
},
|
||||
}
|
||||
122
src/index.ts
122
src/index.ts
@@ -1,122 +1,14 @@
|
||||
import Cloudflare from 'cloudflare'
|
||||
import Axios from 'axios'
|
||||
import { CronJob } from 'cron'
|
||||
import { config } from 'dotenv'
|
||||
import winston from 'winston'
|
||||
import { schedule } from 'node-cron'
|
||||
import process from 'process'
|
||||
|
||||
const logger = winston.createLogger({
|
||||
level: 'info',
|
||||
transports: [
|
||||
new winston.transports.Console({
|
||||
format: winston.format.combine(winston.format.timestamp(), winston.format.colorize(), winston.format.simple()),
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
const Cache = new Map<string, string>()
|
||||
|
||||
async function getCurrentIp(resolver?: string): Promise<string> {
|
||||
const { data } = await Axios({
|
||||
url: resolver || 'https://api.ipify.org/',
|
||||
method: 'GET',
|
||||
})
|
||||
return data as string
|
||||
}
|
||||
|
||||
function checkIfUpdateIsRequired(newIP: string): boolean {
|
||||
// Check if IP has changed.
|
||||
const current = Cache.get('ip')
|
||||
if (newIP !== current) {
|
||||
Cache.set('ip', newIP)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type DNSRecord = {
|
||||
zone: string
|
||||
record: string
|
||||
ip: string
|
||||
proxied: boolean
|
||||
}
|
||||
|
||||
type DNSBrowseResult = { result: { id: string; type: string; name: string; proxied: boolean; ttl: number }[] }
|
||||
async function update(cf: Cloudflare, options: DNSRecord) {
|
||||
// Find zone
|
||||
if (!Cache.has('zone')) {
|
||||
logger.debug('Fetching zone')
|
||||
const zones: { result: { id: string; name: string }[] } = (await cf.zones.browse()) as any
|
||||
const zone = zones.result.find((z) => z.name === options.zone)
|
||||
if (!zone) {
|
||||
logger.error(`Zone "${options.zone}"" not found`)
|
||||
process.exit(1)
|
||||
}
|
||||
Cache.set('zone', zone.id)
|
||||
}
|
||||
|
||||
const zoneId = Cache.get('zone')!
|
||||
logger.debug(`Zone ID: ${zoneId}`)
|
||||
|
||||
// Set record
|
||||
|
||||
const records: DNSBrowseResult = (await cf.dnsRecords.browse(zoneId)) as any
|
||||
const relevant = records.result.filter((r) => r.name === options.record && r.type === 'A')
|
||||
if (relevant.length === 0) {
|
||||
// Create DNS Record
|
||||
logger.debug('Creating DNS record')
|
||||
await cf.dnsRecords.add(zoneId, {
|
||||
type: 'A',
|
||||
name: options.record,
|
||||
content: options.ip,
|
||||
proxied: options.proxied,
|
||||
ttl: 1,
|
||||
})
|
||||
} else {
|
||||
if (relevant.length > 1) {
|
||||
// Delete other records as they cannot all point to the same IP
|
||||
logger.debug('Deleting other DNS records')
|
||||
for (const record of relevant.slice(1)) {
|
||||
await cf.dnsRecords.del(zoneId, record.id)
|
||||
}
|
||||
}
|
||||
// Update DNS Record
|
||||
logger.debug('Updating DNS record')
|
||||
const record = relevant[0]!
|
||||
await cf.dnsRecords.edit(zoneId, record.id, {
|
||||
type: 'A',
|
||||
name: options.record,
|
||||
content: options.ip,
|
||||
proxied: options.proxied,
|
||||
ttl: record.ttl,
|
||||
})
|
||||
logger.info(`Updated DNS record ${record.name}`)
|
||||
}
|
||||
}
|
||||
import { Config } from './config.js'
|
||||
import { logger } from './logger.js'
|
||||
import { loop } from './runner.js'
|
||||
|
||||
async function main() {
|
||||
config()
|
||||
const { EMAIL, KEY, TOKEN, ZONE, DNS_RECORD, PROXIED, CRON, RESOLVER } = process.env
|
||||
if (!ZONE || !DNS_RECORD) {
|
||||
logger.error('Missing environment variables')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Initialize Cloudflare
|
||||
const cf = new Cloudflare(TOKEN ? { token: TOKEN } : { email: EMAIL, key: KEY })
|
||||
|
||||
async function fn() {
|
||||
const ip = await getCurrentIp(RESOLVER)
|
||||
const changed = checkIfUpdateIsRequired(ip)
|
||||
logger.info(`Running. Update required: ${!!changed}`)
|
||||
if (changed)
|
||||
await update(cf, { ip, record: DNS_RECORD!, zone: ZONE!, proxied: Boolean(PROXIED) }).catch((e) =>
|
||||
logger.error(e.message)
|
||||
)
|
||||
}
|
||||
|
||||
const cron = new CronJob(CRON || '*/5 * * * *', fn, null, true, undefined, null, true)
|
||||
logger.info('Started service.')
|
||||
const cron = schedule(Config.runner.cron, loop)
|
||||
logger.info('Started service.', { version: Config.version })
|
||||
logger.debug('Config', Config)
|
||||
|
||||
function terminate() {
|
||||
logger.info('Stopping service.')
|
||||
|
||||
22
src/ip.ts
Normal file
22
src/ip.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import axios from 'axios'
|
||||
|
||||
import { Cache } from './cache.js'
|
||||
import { Config } from './config.js'
|
||||
|
||||
export async function getCurrentIp(): Promise<string> {
|
||||
const { data } = await axios({
|
||||
url: Config.runner.resolver,
|
||||
method: 'GET',
|
||||
})
|
||||
return data as string
|
||||
}
|
||||
|
||||
export function checkIfUpdateIsRequired(newIP: string): boolean {
|
||||
// Check if IP has changed.
|
||||
const current = Cache.get('ip')
|
||||
if (newIP !== current) {
|
||||
Cache.set('ip', newIP)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
12
src/logger.ts
Normal file
12
src/logger.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import winston from 'winston'
|
||||
|
||||
import { Config } from './config.js'
|
||||
|
||||
export const logger = winston.createLogger({
|
||||
level: Config.logging.level,
|
||||
transports: [
|
||||
new winston.transports.Console({
|
||||
format: winston.format.combine(winston.format.timestamp(), winston.format.colorize(), winston.format.simple()),
|
||||
}),
|
||||
],
|
||||
})
|
||||
18
src/runner.ts
Normal file
18
src/runner.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { update } from './cloudflare.js'
|
||||
import { checkIfUpdateIsRequired, getCurrentIp } from './ip.js'
|
||||
import { logger } from './logger.js'
|
||||
|
||||
export async function loop() {
|
||||
const ip = await getCurrentIp()
|
||||
const changed = checkIfUpdateIsRequired(ip)
|
||||
logger.info(`Running. Update required: ${!!changed}`)
|
||||
if (changed) {
|
||||
try {
|
||||
await update(ip)
|
||||
logger.info('Successfully updated DNS record')
|
||||
} catch (e) {
|
||||
logger.error(e)
|
||||
logger.error('Failed to update DNS record')
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user