From b9b17a036cec59e792573538c65e4cba63860458 Mon Sep 17 00:00:00 2001 From: mxmlndml <42516330+MXMLNDML@users.noreply.github.com> Date: Sun, 28 May 2023 15:27:03 +0200 Subject: [PATCH] add dyndns functionality --- src/cloudflare.ts | 80 +++++++++++++++++++++++++++++++++++++ src/getPublicIp.ts | 98 ++++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 58 +++++++++++++++++++++++++++ src/log.ts | 50 +++++++++++++++++++++++ tsconfig.json | 2 +- 5 files changed, 287 insertions(+), 1 deletion(-) create mode 100644 src/cloudflare.ts create mode 100644 src/getPublicIp.ts create mode 100644 src/log.ts diff --git a/src/cloudflare.ts b/src/cloudflare.ts new file mode 100644 index 0000000..bbd5f5f --- /dev/null +++ b/src/cloudflare.ts @@ -0,0 +1,80 @@ +type CFResponse = { + result: { + content: string; + name: string; + type: string; + id: string; + }[]; + errors: { + code: number; + message: string; + }[]; + success: boolean; +}; + +const getDnsRecord = async ( + zoneIdentifier: string, + name: string, + type: string, + apiKey: string, +): Promise<{ content: string; id: string }> => { + const url = + `https://api.cloudflare.com/client/v4/zones/${zoneIdentifier}/dns_records?name=${name}&type=${type}`; + const headers = { + Authorization: `bearer ${apiKey}`, + }; + + const response = await fetch(url, { headers }); + const json: CFResponse = await response.json(); + + if (json.success) { + return (({ content, id }) => ({ content, id }))(json.result[0]); + } + + const error = json.errors.reduce( + (message, error) => `${message}${error.message}. `, + "", + ); + throw new Error( + `failed to get dns ${type.toLowerCase()} record '${name}'. ${error}`, + ); +}; + +const patchDnsRecord = async ( + zoneIdentifier: string, + identifier: string, + apiKey: string, + content: string, + name: string, + type: string, +) => { + const url = + `https://api.cloudflare.com/client/v4/zones/${zoneIdentifier}/dns_records/${identifier}`; + const method = "PATCH"; + const headers = { + Authorization: `bearer ${apiKey}`, + "Content-Type": "application/json", + }; + const body = JSON.stringify({ + content, + name, + type, + }); + + const response = await fetch(url, { method, headers, body }); + const json: CFResponse = await response.json(); + + if (json.success) { + return; + } + + const error = json.errors.reduce( + (message, error) => `${message}${error.message}. `, + "", + ); + throw new Error( + `failed to patch dns ${type.toLowerCase} record '${name}'. ${error}`, + ); +}; + +export { getDnsRecord, patchDnsRecord }; diff --git a/src/getPublicIp.ts b/src/getPublicIp.ts new file mode 100644 index 0000000..e62cf06 --- /dev/null +++ b/src/getPublicIp.ts @@ -0,0 +1,98 @@ +import { Resolver } from "dns/promises"; +import * as log from "./log"; + +const OPEN_DNS = { + RESOLVER: "resolver1.opendns.com", + MYIP: "myip.opendns.com", +}; +const HTTPS_URLS = [ + "https://ipv4.icanhazip.com", + "https://ifconfig.me/ip", + "https://myexternalip.com/raw", + "https://ipecho.net/plain", +]; + +let dnsServers: string[] = []; + +// get public ipv4 address via dns +const dns = async (): Promise => { + const resolver = new Resolver(); + + // set resolver to opendns + if (dnsServers.length === 0) { + // cache dns server ip + dnsServers = await resolver.resolve4(OPEN_DNS.RESOLVER); + log.debug(`cached resolver ip address '${dnsServers[0]}'`); + } + resolver.setServers(dnsServers); + + // get public ip via opendns dns lookup + const [publicIp] = await resolver.resolve4(OPEN_DNS.MYIP); + log.debug(`determined public ip address '${publicIp}' via dns`); + + return publicIp; +}; + +const https = async (): Promise => { + const messages: string[] = []; + + const requests = HTTPS_URLS.map(async (url: string): Promise => { + try { + const response = await fetch(url); + + if (response.ok) { + return response; + } + throw new Error(response.statusText); + } catch (error) { + const message = + `failed to fetch public ip address via https from '${url}'`; + messages.push(message); + throw new Error(message); + } + }); + + try { + const response = await Promise.any(requests); + const publicIp = (await response.text()).replace("\n", ""); + log.debug( + `determined public ip address '${publicIp}' via https using '${response.url}'`, + ); + return publicIp; + } catch (error) { + messages.forEach((message) => log.warn(message)); + throw new Error((error as Error).message); + } +}; + +const getPublicIp = async () => { + let ip = ""; + try { + log.debug("determine public ip address via dns"); + ip = await dns(); + } catch (error) { + if (dnsServers.length === 0) { + log.warn(`dns resolution of '${OPEN_DNS.RESOLVER}' timed out`); + } else { + log.warn( + `dns resolution of '${OPEN_DNS.MYIP}' via '${dnsServers[0]}' timed out`, + ); + dnsServers = []; + log.debug("reset cached dns servers"); + } + log.debug("fall back to https"); + + try { + log.debug("determine public ip address via https"); + ip = await https(); + } catch (error) { + throw new Error( + "failed to determine public ip address via dns and https", + ); + } + } + + return ip; +}; + +export default getPublicIp; diff --git a/src/index.ts b/src/index.ts index e69de29..657f56a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -0,0 +1,58 @@ +import { getDnsRecord, patchDnsRecord } from "./cloudflare"; +import getPublicIp from "./getPublicIp"; +import * as log from "./log"; + +const { ZONE_ID, DOMAIN_NAME, API_KEY, INTERVAL } = process.env; + +if (ZONE_ID === undefined) { + log.error("could not access environment variable 'ZONE_ID'"); +} +if (DOMAIN_NAME === undefined) { + log.error("could not access environment variable 'DOMAIN_NAME'"); +} +if (API_KEY === undefined) { + log.error("could not access environment variable 'API_KEY'"); +} +if ( + ZONE_ID === undefined || DOMAIN_NAME === undefined || API_KEY === undefined +) { + process.exit(1); +} + +const dynamicDns = async () => { + try { + const [publicIp, dnsRecord] = await Promise.all([ + getPublicIp(), + getDnsRecord(ZONE_ID, DOMAIN_NAME, "A", API_KEY), + ]); + + if (publicIp === dnsRecord.content) { + log.info(`public ip address remained at '${publicIp}', no patch needed`); + log.info(`checking again in ${INTERVAL} minutes\n`); + return; + } + + log.info( + `public ip address changed from '${dnsRecord.content}' to '${publicIp}'`, + ); + await patchDnsRecord( + ZONE_ID, + dnsRecord.id, + API_KEY, + publicIp, + DOMAIN_NAME, + "A", + ); + log.info("patched dns entry"); + log.info(`checking again in ${INTERVAL} minutes\n`); + } catch (error) { + log.error((error as Error).message); + log.info(`retrying in ${INTERVAL} minutes\n`); + } +}; + +dynamicDns(); +setInterval( + dynamicDns, + Number.parseInt(INTERVAL === undefined ? "5" : INTERVAL) * 60 * 1000, +); diff --git a/src/log.ts b/src/log.ts new file mode 100644 index 0000000..b1dde29 --- /dev/null +++ b/src/log.ts @@ -0,0 +1,50 @@ +const STYLES = { + RESET: "\x1b[0m", + DEBUG: "\x1b[37m", + INFO: "\x1b[34m", + WARN: "\x1b[33m", + ERROR: "\x1b[31m", +}; + +let LOG_LEVEL = process.env.LOG_LEVEL || "INFO"; + +if (!["DEBUG", "INFO", "WARN", "ERROR"].includes(LOG_LEVEL)) { + console.warn( + `${STYLES.WARN}[WARN]\tunknown log level '${LOG_LEVEL}', proceeding with log level 'INFO'${STYLES.RESET}`, + ); + LOG_LEVEL = "INFO"; +} + +const debug = (...data: string[]) => { + if (!["DEBUG"].includes(LOG_LEVEL)) { + return; + } + const message = `${STYLES.DEBUG}[DEBUG]\t${data.join(" ")}${STYLES.RESET}`; + console.debug(message); +}; + +const info = (...data: string[]) => { + if (!["DEBUG", "INFO"].includes(LOG_LEVEL)) { + return; + } + const message = `${STYLES.INFO}[INFO]\t${data.join(" ")}${STYLES.RESET}`; + console.info(message); +}; + +const warn = (...data: string[]) => { + if (!["DEBUG", "INFO", "WARN"].includes(LOG_LEVEL)) { + return; + } + const message = `${STYLES.WARN}[WARN]\t${data.join(" ")}${STYLES.RESET}`; + console.warn(message); +}; + +const error = (...data: string[]) => { + if (!["DEBUG", "INFO", "WARN", "ERROR"].includes(LOG_LEVEL)) { + return; + } + const message = `${STYLES.ERROR}[ERROR]\t${data.join(" ")}${STYLES.RESET}`; + console.error(message); +}; + +export { debug, error, info, warn }; diff --git a/tsconfig.json b/tsconfig.json index a645c5b..389a60c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,7 +11,7 @@ // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ /* Language and Environment */ - "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + "target": "ES2021", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ // "jsx": "preserve", /* Specify what JSX code is generated. */ // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */