add dyndns functionality
This commit is contained in:
80
src/cloudflare.ts
Normal file
80
src/cloudflare.ts
Normal 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
98
src/getPublicIp.ts
Normal 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;
|
||||||
58
src/index.ts
58
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,
|
||||||
|
);
|
||||||
|
|||||||
50
src/log.ts
Normal file
50
src/log.ts
Normal 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 };
|
||||||
@@ -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. */
|
||||||
|
|||||||
Reference in New Issue
Block a user