add dyndns functionality

This commit is contained in:
mxmlndml
2023-05-28 15:27:03 +02:00
parent 42622bf56d
commit b9b17a036c
5 changed files with 287 additions and 1 deletions

80
src/cloudflare.ts Normal file
View File

@@ -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 };

98
src/getPublicIp.ts Normal file
View File

@@ -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<string> => {
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<string> => {
const messages: string[] = [];
const requests = HTTPS_URLS.map(async (url: string): Promise<Response> => {
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;

View File

@@ -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,
);

50
src/log.ts Normal file
View File

@@ -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 };

View File

@@ -11,7 +11,7 @@
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */ /* 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. */ // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */ // "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */