@underpostnet/underpost
Version:
Underpost Platform — end-to-end CI/CD and application-delivery toolchain CLI. Covers bare metal, Kubernetes, K3s, kubeadm, LXD, container/image orchestration, secrets, databases, cron jobs, monitoring, SSH, runners, PWA + Workbox delivery, and release orc
554 lines (502 loc) • 18.4 kB
JavaScript
/**
* Provides a comprehensive set of DNS and IP management utilities,
* primarily focused on dynamic DNS (DDNS) updates and network checks.
* @module src/server/dns.js
* @namespace UnderpostDns
*/
import axios from 'axios';
import fs from 'fs';
import validator from 'validator';
import { loggerFactory } from './logger.js';
import dns from 'node:dns';
import os from 'node:os';
import { shellExec, pbcopy } from './process.js';
import Underpost from '../index.js';
import { writeEnv, readConfJson, loadCronDeployEnv } from './conf.js';
import { resolveDeployId } from './cron.js';
const logger = loggerFactory(import.meta);
/**
* Main class for handling DNS and IP related operations.
* All utility methods are implemented as static to serve as a namespace container.
* @class Dns
* @augments Dns
* @memberof UnderpostDns
*/
class Dns {
/**
* Retrieves the current public IP address (IPv4 or IPv6).
* @async
* @static
* @memberof UnderpostDns
* @returns {Promise<string>} The public IP address.
*/
static async getPublicIp() {
return await new Promise(async (resolve) => {
try {
return axios
.get(process.env.HTTP_PLAIN_IP_URL ? process.env.HTTP_PLAIN_IP_URL : 'https://api.ipify.org')
.then((response) => resolve(response.data));
} catch (error) {
logger.error('Error fetching public IP:', { error: error.message, stack: error.stack });
return resolve(null);
}
});
}
/**
* Checks for active internet connection by performing a DNS lookup on a specified domain.
* @static
* @memberof UnderpostDns
* @param {string} [domain='google.com'] The domain to check the connection against.
* @returns {Promise<boolean>} True if connected, false otherwise.
*/
static isInternetConnection(domain = 'google.com') {
return new Promise((resolve) => dns.lookup(domain, {}, (err) => resolve(err ? false : true)));
}
/**
* Determines the default network interface name using shell command.
* This method is primarily intended for Linux environments.
* @static
* @memberof UnderpostDns
* @returns {string} The default network interface name.
* @memberof UnderpostDns
*/
static getDefaultNetworkInterface() {
return shellExec(`ip route | grep default | cut -d ' ' -f 5`, {
stdout: true,
silent: true,
disableLog: true,
}).trim();
}
/**
* Gets the local device's IPv4 address by determining the active network interface.
* This relies on shell execution (`ip route`) and is primarily intended for Linux environments.
* @static
* @memberof UnderpostDns
* @returns {string} The local IPv4 address.
*/
static getLocalIPv4Address() {
// Determine the default network interface name using shell command
const interfaceName = Dns.getDefaultNetworkInterface();
// Find the IPv4 address associated with the determined interface
const networkInfo = os.networkInterfaces()[interfaceName];
if (!networkInfo) {
logger.error(`Could not find network interface: ${interfaceName}`);
return null;
}
const ipv4 = networkInfo.find((i) => i.family === 'IPv4');
if (!ipv4) {
logger.error(`Could not find IPv4 address for interface: ${interfaceName}`);
return null;
}
return ipv4.address;
}
/**
* Gets the MAC address of the main (default route) network interface.
* @static
* @memberof UnderpostDns
* @returns {string|null} The MAC address, or null if not found.
*/
static getMainInterfaceMac() {
const interfaceName = Dns.getDefaultNetworkInterface();
const networkInfo = os.networkInterfaces()[interfaceName];
if (!networkInfo || networkInfo.length === 0) {
logger.error(`Could not find network interface: ${interfaceName}`);
return null;
}
return networkInfo[0].mac;
}
/**
* Setup nftables tables and chains if they don't exist.
* @static
* @memberof UnderpostDns
*/
static setupNftables() {
shellExec(`sudo nft add table inet filter 2>/dev/null`, { silent: true });
shellExec(
`sudo nft add chain inet filter input '{ type filter hook input priority 0; policy accept; }' 2>/dev/null`,
{ silent: true },
);
shellExec(
`sudo nft add chain inet filter output '{ type filter hook output priority 0; policy accept; }' 2>/dev/null`,
{ silent: true },
);
shellExec(
`sudo nft add chain inet filter forward '{ type filter hook forward priority 0; policy accept; }' 2>/dev/null`,
{ silent: true },
);
}
/**
* Bans an IP address from ingress traffic.
* @static
* @memberof UnderpostDns
* @param {string} ip - The IP address to ban.
*/
static banIngress(ip) {
Dns.setupNftables();
if (!validator.isIP(ip)) {
logger.error(`Invalid IP address: ${ip}`);
return;
}
shellExec(`sudo nft add rule inet filter input ip saddr ${ip} counter drop`, { silent: true });
logger.info(`Banned ingress for IP: ${ip}`);
}
/**
* Bans an IP address from egress traffic.
* @static
* @memberof UnderpostDns
* @param {string} ip - The IP address to ban.
*/
static banEgress(ip) {
Dns.setupNftables();
if (!validator.isIP(ip)) {
logger.error(`Invalid IP address: ${ip}`);
return;
}
shellExec(`sudo nft add rule inet filter output ip daddr ${ip} counter drop`, { silent: true });
shellExec(`sudo nft add rule inet filter forward ip daddr ${ip} counter drop`, { silent: true });
logger.info(`Banned egress for IP: ${ip}`);
}
/**
* Helper to get nftables rule handles for a specific IP and chain.
* @static
* @memberof UnderpostDns
* @param {string} chain - The chain name (input, output, forward).
* @param {string} ip - The IP address.
* @param {string} type - The type (saddr or daddr).
* @returns {string[]} Array of handles.
*/
static getNftHandles(chain, ip, type) {
const output = shellExec(`sudo nft -a list chain inet filter ${chain}`, { stdout: true, silent: true });
const lines = output.split('\n');
const handles = [];
// Regex to match IP and handle. Note: output format depends on nft version but usually contains "handle <id>" at end.
// Example: ip saddr 1.2.3.4 counter packets 0 bytes 0 drop # handle 5
const regex = new RegExp(`ip ${type} ${ip} .* handle (\\d+)`);
for (const line of lines) {
const match = line.match(regex);
if (match) {
handles.push(match[1]);
}
}
return handles;
}
/**
* Unbans an IP address from ingress traffic.
* @static
* @memberof UnderpostDns
* @param {string} ip - The IP address to unban.
*/
static unbanIngress(ip) {
const handles = Dns.getNftHandles('input', ip, 'saddr');
for (const handle of handles) {
shellExec(`sudo nft delete rule inet filter input handle ${handle}`, { silent: true });
}
logger.info(`Unbanned ingress for IP: ${ip}`);
}
/**
* Unbans an IP address from egress traffic.
* @static
* @memberof UnderpostDns
* @param {string} ip - The IP address to unban.
*/
static unbanEgress(ip) {
const outputHandles = Dns.getNftHandles('output', ip, 'daddr');
for (const handle of outputHandles) {
shellExec(`sudo nft delete rule inet filter output handle ${handle}`, { silent: true });
}
const forwardHandles = Dns.getNftHandles('forward', ip, 'daddr');
for (const handle of forwardHandles) {
shellExec(`sudo nft delete rule inet filter forward handle ${handle}`, { silent: true });
}
logger.info(`Unbanned egress for IP: ${ip}`);
}
/**
* Lists all banned ingress IPs.
* @static
* @memberof UnderpostDns
*/
static listBannedIngress() {
const output = shellExec(`sudo nft list chain inet filter input`, { stdout: true, silent: true });
console.log(output);
}
/**
* Lists all banned egress IPs.
* @static
* @memberof UnderpostDns
*/
static listBannedEgress() {
console.log('--- Output Chain ---');
console.log(shellExec(`sudo nft list chain inet filter output`, { stdout: true, silent: true }));
console.log('--- Forward Chain ---');
console.log(shellExec(`sudo nft list chain inet filter forward`, { stdout: true, silent: true }));
}
/**
* Clears all banned ingress IPs.
* @static
* @memberof UnderpostDns
*/
static clearBannedIngress() {
shellExec(`sudo nft flush chain inet filter input`, { silent: true });
logger.info('Cleared all ingress bans.');
}
/**
* Clears all banned egress IPs.
* @static
* @memberof UnderpostDns
*/
static clearBannedEgress() {
shellExec(`sudo nft flush chain inet filter output`, { silent: true });
shellExec(`sudo nft flush chain inet filter forward`, { silent: true });
logger.info('Cleared all egress bans.');
}
/**
* Performs the dynamic DNS update logic.
* It checks if the public IP has changed and, if so, updates the configured DNS records.
* @async
* @static
* @memberof UnderpostDns
* @param {string} deployList Comma-separated string of deployment IDs to process.
* @returns {Promise<void>}
*/
static async callback(deployList) {
// loadCronDeployEnv();
const isOnline = await Dns.isInternetConnection();
if (!isOnline) return;
let testIp;
try {
testIp = await Dns.getPublicIp();
} catch (error) {
logger.error(error, { testIp, stack: error.stack });
}
const currentIp = Underpost.env.get('ip');
if (validator.isIP(testIp) && currentIp !== testIp) {
logger.info(`New IP detected`, testIp);
Underpost.env.set('monitor-input', 'pause');
for (const _deployId of deployList.split(',')) {
const deployId = _deployId.trim();
let confCronData;
try {
confCronData = readConfJson(deployId, 'cron', { resolve: true });
} catch (error) {
logger.warn(`Cron config file not found for deployId: ${deployId}`, { message: error.message });
continue;
}
if (!confCronData.records) {
logger.warn(`'records' field missing in cron config for deployId: ${deployId}`);
continue;
}
// Iterate through DNS record types (A, AAAA, etc.)
for (const recordType of Object.keys(confCronData.records)) {
switch (recordType) {
case 'A':
// Process A records for IPv4 update
for (const dnsProvider of confCronData.records[recordType]) {
if (typeof Dns.services.updateIp[dnsProvider.dns] === 'function')
await Dns.services.updateIp[dnsProvider.dns]({ ...dnsProvider, ip: testIp });
}
break;
// Add other record types (e.g., AAAA) here if needed
default:
break;
}
}
// Verify the IP update externally
try {
const ipUrlTest = `https://${process.env.DEFAULT_DEPLOY_HOST}`;
const response = await axios.get(ipUrlTest);
const verifyIp = response.request.socket.remoteAddress;
logger.info(ipUrlTest + ' verify ip', verifyIp);
if (verifyIp === testIp) {
logger.info('IP updated successfully and verified', testIp);
Underpost.env.set('ip', testIp);
Underpost.env.delete('monitor-input');
} else {
logger.error('IP not updated or verification failed', { expected: testIp, received: verifyIp });
}
} catch (error) {
logger.error('Error during IP update verification step', {
error: error.message,
stack: error.stack,
testIp,
});
}
}
}
}
/**
* Internal collection of external DNS service update functions.
* @static
* @memberof UnderpostDns
* @property {object} updateIp - Functions keyed by DNS provider name to update A/AAAA records.
*/
static services = {
updateIp: {
/**
* Updates the IP address for a dondominio.com DNS record.
* @memberof UnderpostDns
* @param {object} options
* @param {string} options.user - The dondominio DDNS username.
* @param {string} options.api_key - The dondominio DDNS password/API key.
* @param {string} options.host - The hostname to update.
* @param {string} options.dns - The name of the DNS provider ('dondominio').
* @param {string} options.ip - The new IPv4 address to set.
* @returns {Promise<boolean>} True on success, false on failure.
*/
dondominio: (options) => {
const { user, api_key, host, dns, ip } = options;
// Validate that required credentials are present before making any request
if (!user || !api_key) {
logger.error(
`${dns} update aborted: missing credentials. ` +
`Ensure DDNS_USER and DDNS_API_KEY environment variables are set ` +
`or provide 'user' and 'api_key' in cron records configuration.`,
{ host, hasUser: !!user, hasApiKey: !!api_key },
);
return Promise.resolve(false);
}
if (!host) {
logger.error(`${dns} update aborted: missing host. Set DDNS_HOST or provide 'host' in cron records.`);
return Promise.resolve(false);
}
const url = `https://dondns.dondominio.com/json/?user=${user}&password=${api_key}&host=${host}&ip=${ip}`;
// Log the update attempt without exposing the full URL containing credentials
logger.info(`${dns} update ip request`, { host, ip });
// Prevent live IP update in non-production environments
if (process.env.NODE_ENV !== 'production') {
logger.warn('Skipping dondominio update in non-production environment.');
return Promise.resolve(false);
}
return new Promise((resolve) => {
axios
.get(url)
.then((response) => {
logger.info(`${dns} update ip success`, response.data);
return resolve(true);
})
.catch((error) => {
// Only log the error message — the full error object contains the request URL with credentials
logger.error(`${dns} update ip error`, { message: error.message, host, ip });
return resolve(false);
});
});
},
// Add other DNS provider update functions here
},
};
/**
* Dispatcher for IP ban/unban/list/clear operations based on CLI options.
* @static
* @memberof UnderpostDns
* @param {string} [ips=''] Comma-separated string of IPs to process.
* @param {object} options - Options indicating which action to perform.
* @property {boolean} [options.banIngressAdd=false] - Ban IPs from ingress.
* @property {boolean} [options.banIngressRemove=false] - Unban IPs from ingress.
* @property {boolean} [options.banIngressList=false] - List banned ingress IPs.
* @property {boolean} [options.banIngressClear=false] - Clear all banned ingress IPs.
* @property {boolean} [options.banEgressAdd=false] - Ban IPs from egress.
* @property {boolean} [options.banEgressRemove=false] - Unban IPs from egress.
* @property {boolean} [options.banEgressList=false] - List banned egress IPs.
* @property {boolean} [options.banEgressClear=false] - Clear all banned egress IPs.
* @property {boolean} [options.banBothAdd=false] - Ban IPs from both ingress and egress.
* @property {boolean} [options.banBothRemove=false] - Unban IPs from both ingress and egress.
* @property {boolean} [options.dhcp=false] - Get local DHCP IP instead of public IP.
* @property {boolean} [options.copy=false] - Copy the public IP to clipboard.
* @return {Promise<string|void>} The public IP if no ban/unban action is taken.
*/
static async ipDispatcher(
ips = '',
options = {
banIngressAdd: false,
banIngressRemove: false,
banIngressList: false,
banIngressClear: false,
banEgressAdd: false,
banEgressRemove: false,
banEgressList: false,
banEgressClear: false,
banBothAdd: false,
banBothRemove: false,
copy: false,
dhcp: false,
},
) {
const ipList = ips
? ips
.split(',')
.map((i) => i.trim())
.filter(Boolean)
: [];
if (options.banIngressAdd) {
return ipList.forEach((ip) => Dns.banIngress(ip));
}
if (options.banIngressRemove) {
return ipList.forEach((ip) => Dns.unbanIngress(ip));
}
if (options.banIngressList) {
return Dns.listBannedIngress();
}
if (options.banIngressClear) {
return Dns.clearBannedIngress();
}
if (options.banEgressAdd) {
return ipList.forEach((ip) => Dns.banEgress(ip));
}
if (options.banEgressRemove) {
return ipList.forEach((ip) => Dns.unbanEgress(ip));
}
if (options.banEgressList) {
return Dns.listBannedEgress();
}
if (options.banEgressClear) {
return Dns.clearBannedEgress();
}
if (options.banBothAdd) {
return ipList.forEach((ip) => {
Dns.banIngress(ip);
Dns.banEgress(ip);
});
}
if (options.banBothRemove) {
return ipList.forEach((ip) => {
Dns.unbanIngress(ip);
Dns.unbanEgress(ip);
});
}
if (options.mac) {
const mac = Dns.getMainInterfaceMac();
console.log(mac);
return mac;
}
let ip;
if (options.dhcp) ip = Dns.getLocalIPv4Address();
else ip = await Dns.getPublicIp();
if (options.copy) return pbcopy(ip);
console.log(ip);
return ip;
}
}
/**
* @function isInternetConnection
* @memberof UnderpostDns
* @description Exported function for backward compatibility.
* @param {string} [domain='google.com']
* @returns {Promise<boolean>}
*/
const isInternetConnection = Dns.isInternetConnection;
/**
* @function getLocalIPv4Address
* @memberof UnderpostDns
* @description Exported function for backward compatibility.
* @returns {string}
*/
const getLocalIPv4Address = Dns.getLocalIPv4Address;
/**
* Main UnderpostDns class exposing the Dns API.
* @class UnderpostDns
* @memberof UnderpostDns
*/
class UnderpostDns {
static API = Dns;
}
export default UnderpostDns;
export { Dns, isInternetConnection, getLocalIPv4Address, UnderpostDns };