diff --git a/backend/index.js b/backend/index.js index 551378251..0e4c5ce2b 100644 --- a/backend/index.js +++ b/backend/index.js @@ -9,6 +9,7 @@ async function appStart () { const app = require('./app'); const internalCertificate = require('./internal/certificate'); const internalIpRanges = require('./internal/ip_ranges'); + const ddnsUpdater = require('./lib/ddns_resolver/ddns_updater'); return migrate.latest() .then(setup) @@ -17,6 +18,7 @@ async function appStart () { .then(() => { internalCertificate.initTimer(); internalIpRanges.initTimer(); + ddnsUpdater.initTimer(); const server = app.listen(3000, () => { logger.info('Backend PID ' + process.pid + ' listening on port 3000 ...'); diff --git a/backend/internal/access-list.js b/backend/internal/access-list.js index f6043e18b..97d0087b4 100644 --- a/backend/internal/access-list.js +++ b/backend/internal/access-list.js @@ -2,6 +2,7 @@ const _ = require('lodash'); const fs = require('fs'); const batchflow = require('batchflow'); const logger = require('../logger').access; +const ddnsUpdater = require('../lib/ddns_resolver/ddns_updater'); const error = require('../lib/error'); const utils = require('../lib/utils'); const accessListModel = require('../models/access_list'); @@ -97,6 +98,10 @@ const internalAccessList = { .then(() => { return internalAccessList.maskItems(row); }); + }) + .then((result) => { + // Call the DDNS updater after the access list update process is complete + return ddnsUpdater.updateDynamicDnsRecords().then(() => result); }); }, @@ -230,6 +235,10 @@ const internalAccessList = { .then(() => { return internalAccessList.maskItems(row); }); + }) + .then((result) => { + // Call the DDNS updater after the access list update process is complete + return ddnsUpdater.updateDynamicDnsRecords().then(() => result); }); }, diff --git a/backend/internal/nginx.js b/backend/internal/nginx.js index 5f802c004..b65cde486 100644 --- a/backend/internal/nginx.js +++ b/backend/internal/nginx.js @@ -1,9 +1,10 @@ -const _ = require('lodash'); -const fs = require('fs'); -const logger = require('../logger').nginx; -const config = require('../lib/config'); -const utils = require('../lib/utils'); -const error = require('../lib/error'); +const _ = require('lodash'); +const fs = require('fs'); +const logger = require('../logger').nginx; +const config = require('../lib/config'); +const utils = require('../lib/utils'); +const error = require('../lib/error'); +const ddnsResolver = require('../lib/ddns_resolver/ddns_resolver'); const internalNginx = { @@ -131,6 +132,37 @@ const internalNginx = { return '/data/nginx/' + internalNginx.getFileFriendlyHostType(host_type) + '/' + host_id + '.conf'; }, + /** + * Resolves any ddns addresses that need to be resolved for clients in the host's access list. + * Defines a new property 'resolvedAddress' on each client in `host.access_list.clients` that uses a ddns address. + * @param {Object} host + * @returns {Promise} + */ + resolveDDNSAddresses: (host) => { + const promises = []; + if (typeof host.access_list !== 'undefined' && host.access_list && typeof host.access_list.clients !== 'undefined' && host.access_list.clients) { + for (const client of host.access_list.clients) { + const address = client.address; + if (ddnsResolver.ddnsRegex.test(address)) { + const p = ddnsResolver.resolveAddress(address) + .then((resolvedIP) => { + if (resolvedIP !== address) { + Object.defineProperty(client, 'resolvedAddress', {value: resolvedIP}); + } else { + delete client.resolvedAddress; + } + return Promise.resolve(); + }); + promises.push(p); + } + } + } + if (promises.length) { + return Promise.all(promises); + } + return Promise.resolve(); + }, + /** * Generates custom locations * @param {Object} host @@ -203,6 +235,12 @@ const internalNginx = { return; } + // Resolve ddns addresses if needed + let resolverPromise = Promise.resolve(); + if (host_type === 'proxy_host') { + resolverPromise = internalNginx.resolveDDNSAddresses(host); + } + let locationsPromise; let origLocations; @@ -217,8 +255,10 @@ const internalNginx = { if (host.locations) { //logger.info ('host.locations = ' + JSON.stringify(host.locations, null, 2)); origLocations = [].concat(host.locations); - locationsPromise = internalNginx.renderLocations(host).then((renderedLocations) => { - host.locations = renderedLocations; + locationsPromise = resolverPromise.then(() => { + return internalNginx.renderLocations(host).then((renderedLocations) => { + host.locations = renderedLocations; + }); }); // Allow someone who is using / custom location path to use it, and skip the default / location @@ -229,7 +269,7 @@ const internalNginx = { }); } else { - locationsPromise = Promise.resolve(); + locationsPromise = resolverPromise; } // Set the IPv6 setting for the host diff --git a/backend/lib/ddns_resolver/ddns_resolver.js b/backend/lib/ddns_resolver/ddns_resolver.js new file mode 100644 index 000000000..26d367a31 --- /dev/null +++ b/backend/lib/ddns_resolver/ddns_resolver.js @@ -0,0 +1,70 @@ +const error = require('../error'); +const logger = require('../../logger').ddns; +const utils = require('../utils'); + +const ddnsResolver = { + /** Pattern to match any valid domain/subdomain */ + ddnsRegex: /^((?!-)[A-Za-z\d-]{1,63}(? { + if (!forceUpdate && ddnsResolver._cache.has(domainName)) { + // Check if it is still valid + const value = ddnsResolver._cache.get(domainName); + const ip = value[0]; + const lastUpdated = value[1]; + const nowSeconds = Date.now(); + const delta = nowSeconds - lastUpdated; + if (delta < ddnsResolver._updateIntervalMs) { + return Promise.resolve(ip); + } + } + ddnsResolver._cache.delete(domainName); + // Reach here only if cache value doesn't exist or needs to be updated + let host = domainName.toLowerCase(); + return ddnsResolver._queryHost(host) + .then((resolvedIP) => { + ddnsResolver._cache.set(domainName, [resolvedIP, Date.now()]); + return resolvedIP; + }) + .catch((/*error*/) => { + // return input address in case of failure + logger.error(`Failed to resolve IP for ${host}`); + return domainName; + }); + }, + + + /** Cache mapping host to (ip address, last updated time) */ + _cache: new Map(), + + /** + * Uses execSafe to query the IP address of the given host + * @param {String} host host to query + * @returns {Promise} resolves to the IPV4 address of the host + */ + _queryHost: (host) => { + return utils.execSafe('getent', ['ahostsv4', host]) + .then((result) => { + const ipv4Regex = /(\d{1,3}\.){3}\d{1,3}/; + const match = result.match(ipv4Regex); + + if (!match) { + logger.error(`IPV4 lookup for ${host} returned invalid output: ${result}`); + throw error.ValidationError('Invalid output from getent hosts'); + } + + return match[0]; + }, + (error) => { + logger.error('Error looking up IP for ' + host + ': ', error); + throw error; + }); + }, +}; + +module.exports = ddnsResolver; diff --git a/backend/lib/ddns_resolver/ddns_updater.js b/backend/lib/ddns_resolver/ddns_updater.js new file mode 100644 index 000000000..c63fc2f57 --- /dev/null +++ b/backend/lib/ddns_resolver/ddns_updater.js @@ -0,0 +1,170 @@ +const internalNginx = require('../../internal/nginx'); +const logger = require('../../logger').ddns; +const internalAccessList = require('../../internal/access-list'); +const ddnsResolver = require('./ddns_resolver'); + +const ddnsUpdater = { + /** + * Starts a timer to periodically check for ddns updates + */ + initTimer: () => { + ddnsUpdater._initialize(); + ddnsUpdater._interval = setInterval(ddnsUpdater.updateDynamicDnsRecords, ddnsUpdater._updateIntervalMs); + logger.info(`DDNS Update Timer initialized (interval: ${Math.floor(ddnsUpdater._updateIntervalMs / 1000)}s)`); + // Trigger a run so that initial cache is populated and hosts can be updated - delay by 10s to give server time to boot up + setTimeout(ddnsUpdater.updateDynamicDnsRecords, 10 * 1000); + }, + + /** Private **/ + // Properties + _initialized: false, + _updateIntervalMs: 60 * 60 * 1000, // 1 hr default (overridden with $DDNS_UPDATE_INTERVAL env var) + _interval: null, // reference to created interval id + _processingDDNSUpdate: false, + + // Methods + + _initialize: () => { + if (ddnsUpdater._initialized) { + return; + } + // Init the resolver + // Read and set custom update interval from env if needed + if (typeof process.env.DDNS_UPDATE_INTERVAL !== 'undefined') { + const interval = Number(process.env.DDNS_UPDATE_INTERVAL.toLowerCase()); + if (!isNaN(interval)) { + // Interval value from env is in seconds. Set min to 60s. + ddnsUpdater._updateIntervalMs = Math.max(interval * 1000, 60 * 1000); + } else { + logger.warn(`[DDNS] invalid value for update interval: '${process.env.DDNS_UPDATE_INTERVAL}'`); + } + } + ddnsUpdater._initialized = true; + }, + + /** + * Triggered by a timer, will check for and update ddns hosts in access list clients + */ + updateDynamicDnsRecords: () => { + logger.info('Checking for DDNS updates...'); + if (!ddnsUpdater._processingDDNSUpdate) { + ddnsUpdater._processingDDNSUpdate = true; + + const updatedAddresses = new Map(); + + // Get all ddns hostnames in use + return ddnsUpdater._getAccessLists() + .then((rows) => { + // Build map of used addresses that require resolution + const usedAddresses = new Map(); + for (const row of rows) { + if (!row.proxy_host_count) { + // Ignore rows (access lists) that are not associated to any hosts + continue; + } + for (const client of row.clients) { + if (!ddnsResolver.ddnsRegex.test(client.address)) { + continue; + } + if (!usedAddresses.has(client.address)) { + usedAddresses.set(client.address, [row]); + } else { + usedAddresses.get(client.address).push(row); + } + } + } + logger.info(`Found ${usedAddresses.size} address(es) in use.`); + // Remove unused addresses + const addressesToRemove = []; + for (const address of ddnsResolver._cache.keys()) { + if (!usedAddresses.has(address)) { + addressesToRemove.push(address); + } + } + addressesToRemove.forEach((address) => { ddnsResolver._cache.delete(address); }); + + const promises = []; + + for (const [address, rows] of usedAddresses) { + let oldIP = ''; + if (ddnsResolver._cache.has(address)) { + oldIP = ddnsResolver._cache.get(address)[0]; + } + const p = ddnsResolver.resolveAddress(address, true) + .then((resolvedIP) => { + if (resolvedIP === address) { + updatedAddresses.delete(address); + } + if (resolvedIP !== address && resolvedIP !== oldIP) { + // Mark this as an updated address + updatedAddresses.set(address, rows); + } + }); + promises.push(p); + } + + if (promises.length) { + return Promise.all(promises); + } + return Promise.resolve(); + }) + .then(() => { + logger.info(`${updatedAddresses.size} DDNS IP(s) updated.`); + const updatedRows = new Map(); + const proxy_hosts = []; + for (const rows of updatedAddresses.values()) { + for (const row of rows) { + if (!updatedRows.has(row.id)) { + updatedRows.set(row.id, 1); + for (const host of row.proxy_hosts) { + if (host.enabled) { + proxy_hosts.push(host); + } + } + } + } + } + if (proxy_hosts.length) { + logger.info(`Updating ${proxy_hosts.length} proxy host(s) affected by DDNS changes`); + return internalNginx.bulkGenerateConfigs('proxy_host', proxy_hosts) + .then(internalNginx.reload); + } + return Promise.resolve(); + }) + .then(() => { + logger.info('Finished checking for DDNS updates'); + ddnsUpdater._processingDDNSUpdate = false; + }); + } else { + logger.info('Skipping since previous DDNS update check is in progress'); + } + }, + + _getAccessLists: () => { + const fakeAccess = { + can: (/*role*/) => { + return Promise.resolve({ + permission_visibility: 'all' + }); + } + }; + + return internalAccessList.getAll(fakeAccess) + .then((rows) => { + const promises = []; + for (const row of rows) { + const p = internalAccessList.get(fakeAccess, { + id: row.id, + expand: ['owner', 'items', 'clients', 'proxy_hosts.[certificate,access_list.[clients,items]]'] + }, true /* <- skip masking */); + promises.push(p); + } + if (promises.length) { + return Promise.all(promises); + } + return Promise.resolve([]); + }); + } +}; + +module.exports = ddnsUpdater; \ No newline at end of file diff --git a/backend/lib/utils.js b/backend/lib/utils.js index bcdb3341c..26a0ffdc1 100644 --- a/backend/lib/utils.js +++ b/backend/lib/utils.js @@ -4,6 +4,7 @@ const execFile = require('child_process').execFile; const { Liquid } = require('liquidjs'); const logger = require('../logger').global; const error = require('./error'); +const spawn = require('child_process').spawn; module.exports = { @@ -26,6 +27,37 @@ module.exports = { return stdout; }, + /** + * Run the given command. Safer than using exec since args are passed as a list instead of in shell mode as a single string. + * @param {string} cmd The command to run + * @param {string} args The args to pass to the command + * @returns Promise that resolves to stdout or an object with error code and stderr if there's an error + */ + execSafe: (cmd, args) => { + return new Promise((resolve, reject) => { + let stdout = ''; + let stderr = ''; + const proc = spawn(cmd, args); + proc.stdout.on('data', (data) => { + stdout += data; + }); + proc.stderr.on('data', (data) => { + stderr += data; + }); + + proc.on('close', (exitCode) => { + if (!exitCode) { + resolve(stdout.trim()); + } else { + reject({ + exitCode: exitCode, + stderr: stderr + }); + } + }); + }); + }, + /** * @param {String} cmd * @param {Array} args @@ -96,6 +128,9 @@ module.exports = { */ renderEngine.registerFilter('nginxAccessRule', (v) => { if (typeof v.directive !== 'undefined' && typeof v.address !== 'undefined' && v.directive && v.address) { + if (typeof v.resolvedAddress !== 'undefined' && v.resolvedAddress) { + return `${v.directive} ${v.resolvedAddress}; # ${v.address}`; + } return `${v.directive} ${v.address};`; } return ''; diff --git a/backend/logger.js b/backend/logger.js index 0ebb07c58..78553f515 100644 --- a/backend/logger.js +++ b/backend/logger.js @@ -10,5 +10,6 @@ module.exports = { certbot: new Signale({scope: 'Certbot '}), import: new Signale({scope: 'Importer '}), setup: new Signale({scope: 'Setup '}), - ip_ranges: new Signale({scope: 'IP Ranges'}) + ip_ranges: new Signale({scope: 'IP Ranges'}), + ddns: new Signale({scope: 'DDNS '}) }; diff --git a/backend/schema/components/access-list-object.json b/backend/schema/components/access-list-object.json index cd0218d72..91c220e63 100644 --- a/backend/schema/components/access-list-object.json +++ b/backend/schema/components/access-list-object.json @@ -28,15 +28,19 @@ "oneOf": [ { "type": "string", - "pattern": "^([0-9]{1,3}\\.){3}[0-9]{1,3}(/([0-9]|[1-2][0-9]|3[0-2]))?$" + "pattern": "^([\\d]{1,3}\\.){3}[\\d]{1,3}(/([\\d]|[1-2][\\d]|3[0-2]))?$" }, { "type": "string", - "pattern": "^s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))?$" + "pattern": "^s*((([\\dA-Fa-f]{1,4}:){7}([\\dA-Fa-f]{1,4}|:))|(([\\dA-Fa-f]{1,4}:){6}(:[\\dA-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([\\dA-Fa-f]{1,4}:){5}(((:[\\dA-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([\\dA-Fa-f]{1,4}:){4}(((:[\\dA-Fa-f]{1,4}){1,3})|((:[\\dA-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([\\dA-Fa-f]{1,4}:){3}(((:[\\dA-Fa-f]{1,4}){1,4})|((:[\\dA-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([\\dA-Fa-f]{1,4}:){2}(((:[\\dA-Fa-f]{1,4}){1,5})|((:[\\dA-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([\\dA-Fa-f]{1,4}:){1}(((:[\\dA-Fa-f]{1,4}){1,6})|((:[\\dA-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[\\dA-Fa-f]{1,4}){1,7})|((:[\\dA-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(/([\\d]|[1-9][\\d]|1[0-1][\\d]|12[0-8]))?$" }, { "type": "string", "pattern": "^all$" + }, + { + "type": "string", + "pattern": "^((?!-)[A-Za-z\\d-]{1,63}(? Nginx HTTP Access + or + + DDNS +