Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added support for ddns lookups for addresses in access lists (resolved merge conflicts from #3364) #4386

Open
wants to merge 24 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
5586709
Initial pass at DDNS support for client addresses
vari Dec 2, 2023
ec9eb0d
Refactor and integrate ddns resolution with nginx module
vari Dec 3, 2023
972d158
fix linter warnings
vari Dec 3, 2023
33f41f7
Fix utils.js linter error
vari Dec 3, 2023
743cdd8
Eliminate circular dependency
vari Dec 3, 2023
7b09fef
Update configs for active hosts only on ddns update
vari Dec 3, 2023
3b0ff57
doc string update
vari Dec 12, 2023
e317900
Add support for '-' in ddns domain names
vari Apr 29, 2024
6033b30
Merge remote-tracking branch 'origin-ddns/access-list-client-ddns-sup…
sylphrena0 Feb 22, 2025
cae8ba9
add ddns field to new json files
sylphrena0 Feb 23, 2025
00ee20c
updated ddns resolver to accept any domain/subdomain instead of domai…
sylphrena0 Mar 2, 2025
ad037fe
remove quotes from regex
sylphrena0 Mar 2, 2025
79bdc80
update comments and documentation, validate output from getent with r…
sylphrena0 Mar 2, 2025
72e2e09
update access list regex to use \d instead of [0-9]
sylphrena0 Mar 2, 2025
1f6fa75
Fix getent params in backend/lib/ddns_resolver/ddns_resolver.js
sylphrena0 Mar 2, 2025
f736815
update command parsing to pull first ipv4 address from result of gete…
sylphrena0 Mar 2, 2025
98b112a
aligned assessments in ddns_resolver.js
sylphrena0 Mar 2, 2025
5e079a4
return loopback address in case of failure to resolve ip address
sylphrena0 Mar 2, 2025
6d93f82
avoid adding domains to nginx config instead of resolving to loopback…
sylphrena0 Mar 2, 2025
6b5dcad
remove resolvedAddress if it matches the original address in nginx an…
sylphrena0 Mar 2, 2025
d70661c
resolve ddns after list is created or updated
sylphrena0 Mar 2, 2025
450d8d2
add note that ddns is supported
sylphrena0 Mar 2, 2025
893d133
fix alignment whitespace issue
sylphrena0 Mar 2, 2025
f4a4d23
fix invalid function call on creation/update
sylphrena0 Mar 2, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 ...');
Expand Down
9 changes: 9 additions & 0 deletions backend/internal/access-list.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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);
});
},

Expand Down Expand Up @@ -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);
});
},

Expand Down
58 changes: 49 additions & 9 deletions backend/internal/nginx.js
Original file line number Diff line number Diff line change
@@ -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 = {

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;

Expand All @@ -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
Expand All @@ -229,7 +269,7 @@ const internalNginx = {
});

} else {
locationsPromise = Promise.resolve();
locationsPromise = resolverPromise;
}

// Set the IPv6 setting for the host
Expand Down
70 changes: 70 additions & 0 deletions backend/lib/ddns_resolver/ddns_resolver.js
Original file line number Diff line number Diff line change
@@ -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}(?<!-)\.)+[A-Za-z]{2,6}$/,

/**
* Resolves the given address to its IP
* @param {String} domainName domain name of the dynamic DNS record
* @param {boolean} forceUpdate option to force resolution instead of using the cached value
*/
resolveAddress: (domainName, forceUpdate=false) => {
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;
170 changes: 170 additions & 0 deletions backend/lib/ddns_resolver/ddns_updater.js
Original file line number Diff line number Diff line change
@@ -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;
Loading