This commit is contained in:
2022-10-20 15:18:57 +02:00
committed by GitHub
parent fb7416dbec
commit a1af82501c
15 changed files with 429 additions and 768 deletions

1
src/cache.ts Normal file
View File

@@ -0,0 +1 @@
export const Cache = new Map<'ip' | 'zone', string>()

126
src/cloudflare.ts Normal file
View 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
View 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'),
},
}

View File

@@ -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
View 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
View 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
View 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')
}
}
}