Skip to content

Commit 48ea2a8

Browse files
committed
Initial pass at DDNS support for client addresses
This is a first pass attempt at adding support for using ddns (really any resolvable domain name) as the address in access list clients. This helps make it possible to restrict access to hosts using a dynamic public IP (e.g. allow access to a proxied host from your local network only via ddns address). Current approach is hacky since it was developed by manually replacing files in an existing npm docker container. Future commits will integrate this better and avoid needing to patch/intercept existing APIs. See associated PR for more details.
1 parent e08a4d4 commit 48ea2a8

File tree

3 files changed

+314
-0
lines changed

3 files changed

+314
-0
lines changed

backend/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ async function appStart () {
99
const apiValidator = require('./lib/validator/api');
1010
const internalCertificate = require('./internal/certificate');
1111
const internalIpRanges = require('./internal/ip_ranges');
12+
const ddnsResolver = require('./lib/ddns_resolver/ddns_resolver');
1213

1314
return migrate.latest()
1415
.then(setup)
@@ -20,6 +21,7 @@ async function appStart () {
2021

2122
internalCertificate.initTimer();
2223
internalIpRanges.initTimer();
24+
ddnsResolver.initTimer();
2325

2426
const server = app.listen(3000, () => {
2527
logger.info('Backend PID ' + process.pid + ' listening on port 3000 ...');
Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
const error = require('../error')
2+
const logger = require('../../logger').global;
3+
const internalAccessList = require('../../internal/access-list');
4+
const internalNginx = require('../../internal/nginx');
5+
const spawn = require('child_process').spawn;
6+
7+
const cmdHelper = {
8+
/**
9+
* Run the given command. Safer than using exec since args are passed as a list instead of in shell mode as a single string.
10+
* @param {string} cmd The command to run
11+
* @param {string} args The args to pass to the command
12+
* @returns Promise that resolves to stdout or an object with error code and stderr if there's an error
13+
*/
14+
run: (cmd, args) => {
15+
return new Promise((resolve, reject) => {
16+
let stdout = '';
17+
let stderr = '';
18+
const proc = spawn(cmd, args);
19+
proc.stdout.on('data', (data) => {
20+
stdout += data;
21+
});
22+
proc.stderr.on('data', (data) => {
23+
stderr += data;
24+
});
25+
26+
proc.on('close', (exitCode) => {
27+
if (!exitCode) {
28+
resolve(stdout.trim());
29+
} else {
30+
reject({
31+
exitCode: exitCode,
32+
stderr: stderr
33+
});
34+
}
35+
});
36+
});
37+
}
38+
};
39+
40+
const ddnsResolver = {
41+
/**
42+
* Starts a timer to periodically check for ddns updates
43+
*/
44+
initTimer: () => {
45+
ddnsResolver._initialize();
46+
ddnsResolver._interval = setInterval(ddnsResolver._checkForDDNSUpdates, ddnsResolver._updateIntervalMs);
47+
logger.info(`DDNS Update Timer initialized (interval: ${Math.floor(ddnsResolver._updateIntervalMs / 1000)}s)`);
48+
// Trigger a run so that initial cache is populated and hosts can be updated - delay by 10s to give server time to boot up
49+
setTimeout(ddnsResolver._checkForDDNSUpdates, 10 * 1000);
50+
},
51+
52+
/**
53+
* Checks whether the address requires resolution (i.e. starts with ddns:)
54+
* @param {String} address
55+
* @returns {boolean}
56+
*/
57+
requiresResolution: (address) => {
58+
if (typeof address !== 'undefined' && address && address.toLowerCase().startsWith('ddns:')) {
59+
return true;
60+
}
61+
return false;
62+
},
63+
64+
/**
65+
* Resolves the given address to its IP
66+
* @param {String} address
67+
* @param {boolean} forceUpdate: whether to force resolution instead of using the cached value
68+
*/
69+
resolveAddress: (address, forceUpdate=false) => {
70+
if (!forceUpdate && ddnsResolver._cache.has(address)) {
71+
// Check if it is still valid
72+
const value = ddnsResolver._cache.get(address);
73+
const ip = value[0];
74+
const lastUpdated = value[1];
75+
const nowSeconds = Date.now();
76+
const delta = nowSeconds - lastUpdated;
77+
if (delta < ddnsResolver._updateIntervalMs) {
78+
return Promise.resolve(ip);
79+
}
80+
}
81+
ddnsResolver._cache.delete(address);
82+
// Reach here only if cache value doesn't exist or needs to be updated
83+
let host = address.toLowerCase();
84+
if (host.startsWith('ddns:')) {
85+
host = host.substring(5);
86+
}
87+
return ddnsResolver._queryHost(host)
88+
.then((resolvedIP) => {
89+
ddnsResolver._cache.set(address, [resolvedIP, Date.now()]);
90+
return resolvedIP;
91+
})
92+
.catch((_error) => {
93+
// return input address in case of failure
94+
return address;
95+
});
96+
},
97+
98+
99+
/** Private **/
100+
// Properties
101+
_initialized: false,
102+
_updateIntervalMs: 1000 * 60 * 60, // 1 hr default (overriden with $DDNS_UPDATE_INTERVAL env var)
103+
/**
104+
* cache mapping host to (ip address, last updated time)
105+
*/
106+
_cache: new Map(),
107+
_interval: null, // reference to created interval id
108+
_processingDDNSUpdate: false,
109+
110+
_originalGenerateConfig: null, // Used for patching config generation to resolve hosts
111+
112+
// Methods
113+
114+
_initialize: () => {
115+
if (ddnsResolver._initialized) {
116+
return;
117+
}
118+
// Init the resolver
119+
// Read and set custom update interval from env if needed
120+
if (typeof process.env.DDNS_UPDATE_INTERVAL !== 'undefined') {
121+
const interval = Number(process.env.DDNS_UPDATE_INTERVAL.toLowerCase());
122+
if (!isNaN(interval)) {
123+
// Interval value from env is in seconds. Set min to 60s.
124+
ddnsResolver._updateIntervalMs = Math.max(interval * 1000, 60 * 1000);
125+
} else {
126+
logger.warn(`[DDNS] invalid value for update interval: '${process.env.DDNS_UPDATE_INTERVAL}'`);
127+
}
128+
}
129+
130+
// Patch nginx config generation if needed (check env var)
131+
if (typeof process.env.DDNS_UPDATE_PATCH !== 'undefined') {
132+
const enabled = Number(process.env.DDNS_UPDATE_PATCH.toLowerCase());
133+
if (!isNaN(enabled) && enabled) {
134+
logger.info('Patching nginx config generation');
135+
ddnsResolver._originalGenerateConfig = internalNginx.generateConfig;
136+
internalNginx.generateConfig = ddnsResolver._patchedGenerateConfig;
137+
}
138+
}
139+
ddnsResolver._initialized = true;
140+
},
141+
142+
/**
143+
*
144+
* @param {String} host
145+
* @returns {Promise}
146+
*/
147+
_queryHost: (host) => {
148+
logger.info('Looking up IP for ', host);
149+
return cmdHelper.run('getent', ['hosts', host])
150+
.then((result) => {
151+
if (result.length < 8) {
152+
logger.error('IP lookup returned invalid output: ', result);
153+
throw error.ValidationError('Invalid output from getent hosts');
154+
}
155+
const out = result.split(/\s+/);
156+
logger.info(`Resolved ${host} to ${out[0]}`);
157+
return out[0];
158+
},
159+
(error) => {
160+
logger.error('Error looking up IP for ' + host + ': ', error);
161+
throw error;
162+
});
163+
},
164+
165+
_patchedGenerateConfig: (host_type, host) => {
166+
const promises = [];
167+
if (host_type === 'proxy_host') {
168+
if (typeof host.access_list !== 'undefined' && typeof host.access_list.clients !== 'undefined') {
169+
for (const client of host.access_list.clients) {
170+
if (ddnsResolver.requiresResolution(client.address)) {
171+
const p = ddnsResolver.resolveAddress(client.address)
172+
.then((resolvedIP) => {
173+
client.address = `${resolvedIP}; # ${client.address}`;
174+
return Promise.resolve();
175+
});
176+
promises.push(p);
177+
}
178+
}
179+
}
180+
}
181+
if (promises.length) {
182+
return Promise.all(promises)
183+
.then(() => {
184+
return ddnsResolver._originalGenerateConfig(host_type, host);
185+
});
186+
}
187+
return ddnsResolver._originalGenerateConfig(host_type, host);
188+
},
189+
190+
/**
191+
* Triggered by a timer, will check for and update ddns hosts in access list clients
192+
*/
193+
_checkForDDNSUpdates: () => {
194+
logger.info('Checking for DDNS updates...');
195+
if (!ddnsResolver._processingDDNSUpdate) {
196+
ddnsResolver._processingDDNSUpdate = true;
197+
198+
const updatedAddresses = new Map();
199+
200+
// Get all ddns hostnames in use
201+
return ddnsResolver._getAccessLists()
202+
.then((rows) => {
203+
// Build map of used addresses that require resolution
204+
const usedAddresses = new Map();
205+
for (const row of rows) {
206+
if (!row.proxy_host_count) {
207+
// Ignore rows (access lists) that are not associated to any hosts
208+
continue;
209+
}
210+
for (const client of row.clients) {
211+
if (!ddnsResolver.requiresResolution(client.address)) {
212+
continue;
213+
}
214+
if (!usedAddresses.has(client.address)) {
215+
usedAddresses.set(client.address, [row]);
216+
} else {
217+
usedAddresses.get(client.address).push(row);
218+
}
219+
}
220+
}
221+
logger.info(`Found ${usedAddresses.size} address(es) in use.`);
222+
// Remove unused addresses
223+
const addressesToRemove = [];
224+
for (const address of ddnsResolver._cache.keys()) {
225+
if (!usedAddresses.has(address)) {
226+
addressesToRemove.push(address);
227+
}
228+
}
229+
addressesToRemove.forEach((address) => { ddnsResolver._cache.delete(address); });
230+
231+
const promises = [];
232+
233+
for (const [address, rows] of usedAddresses) {
234+
let oldIP = '';
235+
if (ddnsResolver._cache.has(address)) {
236+
oldIP = ddnsResolver._cache.get(address)[0];
237+
}
238+
const p = ddnsResolver.resolveAddress(address, true)
239+
.then((resolvedIP) => {
240+
if (resolvedIP !== address && resolvedIP !== oldIP) {
241+
// Mark this as an updated address
242+
updatedAddresses.set(address, rows);
243+
}
244+
});
245+
promises.push(p);
246+
}
247+
248+
if (promises.length) {
249+
return Promise.all(promises);
250+
}
251+
return Promise.resolve();
252+
})
253+
.then(() => {
254+
logger.info(`${updatedAddresses.size} DDNS IP(s) updated.`);
255+
const updatedRows = new Map();
256+
const proxy_hosts = [];
257+
for (const rows of updatedAddresses.values()) {
258+
for (const row of rows) {
259+
if (!updatedRows.has(row.id)) {
260+
updatedRows.set(row.id, 1);
261+
proxy_hosts.push(...row.proxy_hosts);
262+
}
263+
}
264+
}
265+
if (proxy_hosts.length) {
266+
logger.info(`Updating ${proxy_hosts.length} proxy host(s) affected by DDNS changes`);
267+
return internalNginx.bulkGenerateConfigs('proxy_host', proxy_hosts)
268+
.then(internalNginx.reload);
269+
}
270+
return Promise.resolve();
271+
})
272+
.then(() => {
273+
logger.info('Finished checking for DDNS updates');
274+
ddnsResolver._processingDDNSUpdate = false;
275+
});
276+
} else {
277+
logger.info('Skipping since previous DDNS update check is in progress');
278+
}
279+
},
280+
281+
_getAccessLists: () => {
282+
const fakeAccess = {
283+
can: (capabilityStr) => {
284+
return Promise.resolve({
285+
permission_visibility: 'all'
286+
})
287+
}
288+
};
289+
290+
return internalAccessList.getAll(fakeAccess)
291+
.then((rows) => {
292+
const promises = [];
293+
for (const row of rows) {
294+
const p = internalAccessList.get(fakeAccess, {
295+
id: row.id,
296+
expand: ['owner', 'items', 'clients', 'proxy_hosts.[certificate,access_list.[clients,items]]']
297+
}, true /* <- skip masking */);
298+
promises.push(p);
299+
}
300+
if (promises.length) {
301+
return Promise.all(promises);
302+
}
303+
return Promise.resolve([]);
304+
});
305+
}
306+
};
307+
308+
module.exports = ddnsResolver;

backend/schema/endpoints/access-lists.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@
3636
{
3737
"type": "string",
3838
"pattern": "^all$"
39+
},
40+
{
41+
"type": "string",
42+
"pattern": "^ddns:[\\w\\.]+$"
3943
}
4044
]
4145
},

0 commit comments

Comments
 (0)