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. */
|
||||
|
||||
/* 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. */
|
||||
|
||||
Reference in New Issue
Block a user