From 88db097f6f7a3613c47b8da3c0525ec6bbe255e6 Mon Sep 17 00:00:00 2001 From: Heds Simons Date: Fri, 16 Aug 2019 11:19:35 +0100 Subject: [PATCH] dnsproxy: Add DNS proxying functionality. There are services that don't use the libc resolver in their service containers (for example some Go-based services). This addition installs dnsmasq into the MDNS services which can be enabled by an envvar. Change-type: minor Signed-off-by: Heds Simons --- Dockerfile | 2 + bin/balena-mdns-publisher | 2 +- config/confd_env_backend/conf.d/env.toml | 3 +- config/confd_env_backend/templates/env.tmpl | 1 + package-lock.json | 43 ++++++- package.json | 2 + src/dns-proxy.ts | 83 +++++++++++++ src/{app.ts => mdns-publisher.ts} | 129 +++++-------------- src/utils.ts | 131 ++++++++++++++++++++ 9 files changed, 292 insertions(+), 104 deletions(-) create mode 100644 src/dns-proxy.ts rename src/{app.ts => mdns-publisher.ts} (66%) create mode 100644 src/utils.ts diff --git a/Dockerfile b/Dockerfile index 5db35652..0f50d941 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,6 +3,7 @@ FROM balena/open-balena-base:v8.0.3 as base RUN apt-get update && \ apt-get install -yq --no-install-recommends \ libdbus-glib-1-dev \ + dnsmasq \ && apt-get clean && rm -rf /var/lib/apt/lists/* WORKDIR /usr/src/app @@ -15,6 +16,7 @@ RUN JOBS=MAX npm ci --unsafe-perm --production && npm cache clean --force && rm # Copy and enable the service COPY config/services /etc/systemd/system +RUN systemctl disable dnsmasq.service RUN systemctl enable balena-mdns-publisher.service # Build service diff --git a/bin/balena-mdns-publisher b/bin/balena-mdns-publisher index 299240c0..5961f53b 100755 --- a/bin/balena-mdns-publisher +++ b/bin/balena-mdns-publisher @@ -1,2 +1,2 @@ #!/usr/bin/env node -require('../build/app'); +require('../build/mdns-publisher'); diff --git a/config/confd_env_backend/conf.d/env.toml b/config/confd_env_backend/conf.d/env.toml index c6ec8698..883a4d86 100644 --- a/config/confd_env_backend/conf.d/env.toml +++ b/config/confd_env_backend/conf.d/env.toml @@ -8,5 +8,6 @@ keys = [ "DBUS_SESSION_BUS_ADDRESS", "BALENA_SUPERVISOR_ADDRESS", "BALENA_SUPERVISOR_API_KEY", - "MDNS_API_TOKEN" + "MDNS_API_TOKEN", + "PROXY_DNS", ] diff --git a/config/confd_env_backend/templates/env.tmpl b/config/confd_env_backend/templates/env.tmpl index 48588c6e..bbc3592f 100644 --- a/config/confd_env_backend/templates/env.tmpl +++ b/config/confd_env_backend/templates/env.tmpl @@ -5,4 +5,5 @@ DBUS_SESSION_BUS_ADDRESS={{getenv "DBUS_SESSION_BUS_ADDRESS"}} BALENA_SUPERVISOR_ADDRESS={{getenv "BALENA_SUPERVISOR_ADDRESS"}} BALENA_SUPERVISOR_API_KEY={{getenv "BALENA_SUPERVISOR_API_KEY"}} MDNS_API_TOKEN={{getenv "MDNS_API_TOKEN"}} +PROXY_DNS={{getenv "PROXY_DNS"}} NODE_EXTRA_CA_CERTS={{if getenv "BALENA_ROOT_CA"}}/etc/ssl/certs/balenaRootCA.pem{{end}} diff --git a/package-lock.json b/package-lock.json index 48b36208..24a11d2d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -114,6 +114,15 @@ "integrity": "sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA==", "dev": true }, + "@types/mz": { + "version": "0.0.32", + "resolved": "https://registry.npmjs.org/@types/mz/-/mz-0.0.32.tgz", + "integrity": "sha512-cy3yebKhrHuOcrJGkfwNHhpTXQLgmXSv1BX+4p32j+VUQ6aP2eJ5cL7OvGcAQx75fCTFaAIIAKewvqL+iwSd4g==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/node": { "version": "10.14.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-10.14.4.tgz", @@ -215,6 +224,11 @@ "integrity": "sha512-/FQM1EDkTsf63Ub2C6O7GuYFDsSXUwsaZDurV0np41ocwq0jthUAYCmhBX9f+KwlaCgIuWyr/4WlUQUBfKfZog==", "dev": true }, + "any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha1-q8av7tzqUugJzcA3au0845Y10X8=" + }, "argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -2806,6 +2820,16 @@ "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", "dev": true }, + "mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "requires": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, "nan": { "version": "2.13.2", "resolved": "https://registry.npmjs.org/nan/-/nan-2.13.2.tgz", @@ -2951,8 +2975,7 @@ "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", - "dev": true + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" }, "object-copy": { "version": "0.1.0", @@ -4062,6 +4085,22 @@ "integrity": "sha512-16GbgwTmFMYFyQMLvtQjvNWh30dsFe1cAW5Fg1wm5+dg84L9Pe36mftsIRU95/W2YsISxsz/xq4VB23sqpgb/A==", "dev": true }, + "thenify": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.0.tgz", + "integrity": "sha1-5p44obq+lpsBCCB5eLn2K4hgSDk=", + "requires": { + "any-promise": "^1.0.0" + } + }, + "thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha1-GhkY1ALY/D+Y+/I02wvMjMEOlyY=", + "requires": { + "thenify": ">= 3.1.0 < 4" + } + }, "through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", diff --git a/package.json b/package.json index d32c24d7..a23aa1b6 100644 --- a/package.json +++ b/package.json @@ -25,11 +25,13 @@ "bluebird": "^3.5.1", "dbus-native": "^0.4.0", "lodash": "^4.17.15", + "mz": "^2.7.0", "request": "^2.88.0", "request-promise": "^4.2.4" }, "devDependencies": { "@types/lodash": "^4.14.134", + "@types/mz": "0.0.32", "@types/node": "^10.14.4", "@types/request-promise": "^4.1.42", "husky": "^1.3.1", diff --git a/src/dns-proxy.ts b/src/dns-proxy.ts new file mode 100644 index 00000000..46ec8ba3 --- /dev/null +++ b/src/dns-proxy.ts @@ -0,0 +1,83 @@ +/** + * @license + * Copyright (C) 2018-2019 Balena Ltd. + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import { spawn } from 'child_process'; +import * as _ from 'lodash'; +import * as fs from 'mz/fs'; + +import { getFullHostnames, getHostAddress } from './utils'; + +/** + * Creates the dnsmasq config from the subdomains. + * + * @param hosts The subdomains to provide DNS for. + * @param ipAddr The IP address to point DNS records to. + * @returns void promise. + */ +const configureDnsmasq = async ( + hosts: string[], + ipAddr: string, +): Promise => { + // Write all the host entries to a new dnsmasq configuration + let config = 'log-queries\n'; + + _.map(hosts, host => { + config += `address=/${host}/${ipAddr}`; + }); + + await fs.writeFile('/etc/dnsmasq.conf', config); +}; + +/** + * Start the dnsmasq process in debug mode for DNS proxying. + * + * @returns Void promise. + */ +export async function startDnsProxy(): Promise { + // Configure the dnsmasq config + // Get the list of hostnames to DNS proxy for + const hosts = getFullHostnames(); + + try { + const ipAddr = await getHostAddress(process.env.INTERFACE); + + // For each address, publish the interface IP address. + await configureDnsmasq(hosts, ipAddr); + } catch (err) { + console.log(`balena DNS proxier configuration error:\n${err}`); + } + + // Start dnsmasq, log output to console + try { + const dnsmasq = spawn('/usr/sbin/dnsmasq', [ + '-d', + '-x', + '/run/dnsmasq.pid', + ]); + + dnsmasq.stdout.on('data', data => { + console.log(`dnsmasq: ${data.toString()}`); + }); + dnsmasq.stderr.on('data', data => { + console.error(`dnsmasq: Error - ${data}`); + }); + dnsmasq.on('close', code => { + if (code !== 0) { + console.log(`dnsmasq: process exited with code ${code}`); + } + }); + } catch (err) { + console.log(`dnsmasq: Could not launch daemon - ${err}`); + } +} diff --git a/src/app.ts b/src/mdns-publisher.ts similarity index 66% rename from src/app.ts rename to src/mdns-publisher.ts index bb0ed3d2..45cbae96 100644 --- a/src/app.ts +++ b/src/mdns-publisher.ts @@ -16,24 +16,9 @@ import * as BalenaSdk from 'balena-sdk'; import * as Bluebird from 'bluebird'; import { Message, systemBus } from 'dbus-native'; import * as _ from 'lodash'; -import * as os from 'os'; -import * as request from 'request-promise'; -/** - * Supervisor returned device details interface. - */ -interface HostDeviceDetails { - api_port: string; - ip_address: string; - os_version: string; - supervisor_version: string; - update_pending: boolean; - update_failed: boolean; - update_downloaded: boolean; - commit: string; - status: string; - download_progress: string | null; -} +import { startDnsProxy } from './dns-proxy'; +import { getFullHostnames, getHostAddress } from './utils'; /** * Hosts published via Avahi. @@ -52,6 +37,8 @@ const publishedHosts: PublishedHosts[] = []; /** List of devices with accessible public URLs */ let accessibleDevices: BalenaSdk.Device[] = []; +type Callback = (err: Error, ...params: any[]) => void; + /** DBus controller */ const dbus = systemBus(); /** @@ -60,71 +47,11 @@ const dbus = systemBus(); * @param message DBus message to send */ const dbusInvoker = (message: Message): PromiseLike => { - return Bluebird.fromCallback(cb => { + return Bluebird.fromCallback((cb: Callback) => { return dbus.invoke(message, cb); }); }; -/** - * Retrieves the IPv4 address for the named interface. - * - * @param intf Name of interface to query - */ -const getNamedInterfaceAddr = (intf: string): string => { - const nics = os.networkInterfaces()[intf]; - - if (!nics) { - throw new Error('The configured interface is not present, exiting'); - } - - // We need to look for the IPv4 address - let ipv4Intf; - for (const nic of nics) { - if (nic.family === 'IPv4') { - ipv4Intf = nic; - break; - } - } - - if (!ipv4Intf) { - throw new Error( - 'IPv4 version of configured interface is not present, exiting', - ); - } - - return ipv4Intf.address; -}; - -/** - * Retrieve the IPv4 address for the default balena internet-connected interface. - */ -const getDefaultInterfaceAddr = async (): Promise => { - let deviceDetails: HostDeviceDetails | null = null; - - // We continue to attempt to get the default IP address every 10 seconds, - // inifinitely, as without our service the rest won't work. - while (!deviceDetails) { - try { - deviceDetails = await request({ - uri: `${process.env.BALENA_SUPERVISOR_ADDRESS}/v1/device?apikey=${ - process.env.BALENA_SUPERVISOR_API_KEY - }`, - json: true, - method: 'GET', - }).promise(); - } catch (_err) { - console.log( - 'Could not acquire IP address from Supervisor, retrying in 10 seconds', - ); - await Bluebird.delay(10000); - } - } - - // Ensure that we only use the first returned IP address route. We don't want to broadcast - // on multiple subnets. - return deviceDetails.ip_address.split(' ')[0]; -}; - /** * Retrieve a new Avahi group for address publishing. */ @@ -163,7 +90,7 @@ const addHostAddress = async ( path: group, interface: 'org.freedesktop.Avahi.EntryGroup', member: 'AddAddress', - body: [-1, -1, 0x10, hostname, address], + body: [-1, 0, 0x10, hostname, address], signature: 'iiuss', }); @@ -220,14 +147,17 @@ const reapDevices = async (deviceTld: string, address: string) => { const devices = await balena.models.device.getAll(); // Get list of all accessible devices - const newAccessible = _.filter(devices, device => device.is_web_accessible); + const newAccessible = _.filter( + devices, + (device: any) => device.is_web_accessible, + ); // Get all devices that are not in both lists const xorList = _.xorBy(accessibleDevices, newAccessible, 'uuid'); // Get all new devices to be published and old to be unpublished const toUnpublish: BalenaSdk.Device[] = []; - const toPublish = _.filter(xorList, device => { + const toPublish = _.filter(xorList, (device: any) => { const filter = _.find(newAccessible, { uuid: device.uuid }) ? true : false; @@ -253,39 +183,38 @@ const reapDevices = async (deviceTld: string, address: string) => { } }; -// Use the 'MDNS_SUBDOMAINS' envvar to collect the list of hosts to -// advertise -if (!process.env.MDNS_TLD || !process.env.MDNS_SUBDOMAINS) { - throw new Error('MDNS_TLD and MDNS_SUBDOMAINS must be set.'); -} -const tld = process.env.MDNS_TLD; -const MDNSHosts = JSON.parse(process.env.MDNS_SUBDOMAINS); +// Get SDK instance const balena = BalenaSdk({ apiUrl: `https://api.${process.env.MDNS_TLD}/`, }); +const tld = process.env.MDNS_TLD; +const dnsProxy = process.env.PROXY_DNS; +if (!tld) { + throw new Error('MDNS_TLD must be set!'); +} + (async () => { + // Get the list of hostnames to advertise + const hosts = getFullHostnames(); + try { - let ipAddr: string; - // Get IP address for the specified interface, and the TLD to use. - if (process.env.INTERFACE) { - ipAddr = getNamedInterfaceAddr(process.env.INTERFACE); - } else { - ipAddr = await getDefaultInterfaceAddr(); - } + const ipAddr = await getHostAddress(process.env.INTERFACE); // For each address, publish the interface IP address. - await Bluebird.map(MDNSHosts, host => { - const fullHostname = `${host}.${tld}`; - - return addHostAddress(fullHostname, ipAddr); - }); + await Bluebird.map(hosts, (host: string) => addHostAddress(host, ipAddr)); // Finally, login to the SDK and set a timerInterval every 20 seconds to update public URL addresses if (process.env.MDNS_API_TOKEN) { await balena.auth.loginWithToken(process.env.MDNS_API_TOKEN); setInterval(() => reapDevices(tld, ipAddr), 20 * 1000); } + + // If proxying DNS, start dnsmasq. This will be killed on parent (this) exit if required + if (dnsProxy) { + // Configure and run the DNS proxy + await startDnsProxy(); + } } catch (err) { console.log(`balena MDNS publisher error:\n${err}`); // This is not ideal. However, dbus-native does not correctly free connections diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 00000000..56f5468b --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,131 @@ +/** + * @license + * Copyright (C) 2018-2019 Balena Ltd. + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import * as Bluebird from 'bluebird'; +import * as _ from 'lodash'; +import * as os from 'os'; +import * as request from 'request-promise'; + +/** + * Supervisor returned device details interface. + */ +interface HostDeviceDetails { + api_port: string; + ip_address: string; + os_version: string; + supervisor_version: string; + update_pending: boolean; + update_failed: boolean; + update_downloaded: boolean; + commit: string; + status: string; + download_progress: string | null; +} + +/** + * Retrieve the IPv4 address for the default balena internet-connected interface. + * + * @returns IP adress for the first default balena interface. + */ +const getDefaultInterfaceAddr = async (): Promise => { + let deviceDetails: HostDeviceDetails | null = null; + + // We continue to attempt to get the default IP address every 10 seconds, + // inifinitely, as without our service the rest won't work. + while (!deviceDetails) { + try { + deviceDetails = await request({ + uri: `${process.env.BALENA_SUPERVISOR_ADDRESS}/v1/device?apikey=${ + process.env.BALENA_SUPERVISOR_API_KEY + }`, + json: true, + method: 'GET', + }).promise(); + } catch (_err) { + console.log( + 'Could not acquire IP address from Supervisor, retrying in 10 seconds', + ); + await Bluebird.delay(10000); + } + } + + // Ensure that we only use the first returned IP address route. We don't want to broadcast + // on multiple subnets. + return deviceDetails.ip_address.split(' ')[0]; +}; + +/** + * Retrieves the IPv4 address for the named interface. + * + * @param intf Name of interface to query + * @returns Full IP address of interface. + */ +const getNamedInterfaceAddr = (intf: string): string => { + const nics = os.networkInterfaces()[intf]; + + if (!nics) { + throw new Error('The configured interface is not present, exiting'); + } + + // We need to look for the IPv4 address + let ipv4Intf; + for (const nic of nics) { + if (nic.family === 'IPv4') { + ipv4Intf = nic; + break; + } + } + + if (!ipv4Intf) { + throw new Error( + 'IPv4 version of configured interface is not present, exiting', + ); + } + + return ipv4Intf.address; +}; + +/** + * Retrieves the host IP address. + * + * @param namedInterface The name of the interface to query, if any. + * @returns Address to be used for the host. + */ +export const getHostAddress = async ( + namedInterface: string | void, +): Promise => { + // Get IP address for the specified interface, and the TLD to use. + if (namedInterface) { + return getNamedInterfaceAddr(namedInterface); + } + + return await getDefaultInterfaceAddr(); +}; + +/** + * Retrieves the full hostnames of all addresses to publish/proxy. + * + * @returns Array of full hostnames. + */ +export const getFullHostnames = (): string[] => { + // Use the 'MDNS_SUBDOMAINS' envvar to collect the list of hosts to + // proxy DNS for + if (!process.env.MDNS_TLD || !process.env.MDNS_SUBDOMAINS) { + throw new Error('MDNS_TLD and MDNS_SUBDOMAINS must be set.'); + } + const tld = process.env.MDNS_TLD; + const MDNSHosts = JSON.parse(process.env.MDNS_SUBDOMAINS); + + return _.map(MDNSHosts, host => `${host}.${tld}`); +};