From aacd2d388a4a162aba8f901ecbd27ba658cf8c4d 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 feature allows the MDNS publisher to act as a DNS proxy on bridged networks for these services. Set the 'PROXY_DNS' envvar to 'true' to proxy DNS instead of publishing MDNS records, and ensure the networking mode is the bridge network and not 'host'. Change-type: minor Signed-off-by: Heds Simons --- Dockerfile | 3 +- 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/app.ts | 286 +------------------- src/dns-proxy.ts | 83 ++++++ src/mdns-publisher.ts | 216 +++++++++++++++ src/utils.ts | 131 +++++++++ 9 files changed, 488 insertions(+), 280 deletions(-) create mode 100644 src/dns-proxy.ts create mode 100644 src/mdns-publisher.ts create mode 100644 src/utils.ts diff --git a/Dockerfile b/Dockerfile index 5db35652..396d4b69 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 @@ -33,4 +35,3 @@ FROM base COPY --from=build /usr/src/app/build /usr/src/app/build COPY --from=build /usr/src/app/bin /usr/src/app/bin COPY --from=build /usr/src/app/config /usr/src/app/config -COPY --from=base /usr/src/app/node_modules /usr/src/app/node_modules 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/app.ts b/src/app.ts index bb0ed3d2..71606e67 100644 --- a/src/app.ts +++ b/src/app.ts @@ -12,284 +12,18 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -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'; +import { startDnsProxy } from './dns-proxy'; +import { startMdnsPublisher } from './mdns-publisher'; -/** - * 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; -} - -/** - * Hosts published via Avahi. - */ -interface PublishedHosts { - /** The Avahi group used to publish the host */ - group: string; - /** The full hostname of the published host */ - hostname: string; - /** The IP address of the published host */ - address: string; -} - -/** List of published hosts */ -const publishedHosts: PublishedHosts[] = []; -/** List of devices with accessible public URLs */ -let accessibleDevices: BalenaSdk.Device[] = []; - -/** DBus controller */ -const dbus = systemBus(); -/** - * DBus invoker. - * - * @param message DBus message to send - */ -const dbusInvoker = (message: Message): PromiseLike => { - return Bluebird.fromCallback(cb => { - 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. - */ -const getGroup = async (): Promise => { - return await dbusInvoker({ - destination: 'org.freedesktop.Avahi', - path: '/', - interface: 'org.freedesktop.Avahi.Server', - member: 'EntryGroupNew', - }); -}; - -/** - * Add a host address to the local domain. - * - * @param hostname Full hostname to publish - * @param address IP address for the hostname - */ -const addHostAddress = async ( - hostname: string, - address: string, -): Promise => { - // If the hostname is already published with the same address, return - if (_.find(publishedHosts, { hostname, address })) { - return; - } - - console.log(`Adding ${hostname} at address ${address} to local MDNS pool`); - - // We require a new group for each address. - // We don't catch errors, as our restart policy is to not restart. - const group = await getGroup(); - - await dbusInvoker({ - destination: 'org.freedesktop.Avahi', - path: group, - interface: 'org.freedesktop.Avahi.EntryGroup', - member: 'AddAddress', - body: [-1, -1, 0x10, hostname, address], - signature: 'iiuss', - }); - - await dbusInvoker({ - destination: 'org.freedesktop.Avahi', - path: group, - interface: 'org.freedesktop.Avahi.EntryGroup', - member: 'Commit', - }); - - // Add to the published hosts list - publishedHosts.push({ - group, - hostname, - address, - }); -}; - -/** - * Remove hostname from published list - * - * @param hostname Hostname to remove from list - */ -const removeHostAddress = async (hostname: string): Promise => { - // If the hostname doesn't exist, we don't use it - const hostDetails = _.find(publishedHosts, { hostname }); - if (!hostDetails) { - return; - } - - console.log(`Removing ${hostname} at address from local MDNS pool`); - - // Free the group, removing the published address - await dbusInvoker({ - destination: 'org.freedesktop.Avahi', - path: hostDetails.group, - interface: 'org.freedesktop.Avahi.EntryGroup', - member: 'Free', - }); - - // Remove from the published hosts list - _.remove(publishedHosts, { hostname }); -}; - -/** - * Scan balena devices with accessible public URLs - * - * @param tld TLD to use for URL publishing - * @param address IP address to use for publishing - */ -const reapDevices = async (deviceTld: string, address: string) => { - // Query the SDK using the Proxy service key for *all* current devices - try { - const devices = await balena.models.device.getAll(); - - // Get list of all accessible devices - const newAccessible = _.filter(devices, device => 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 filter = _.find(newAccessible, { uuid: device.uuid }) - ? true - : false; - if (!filter) { - toUnpublish.push(device); - } - return filter; - }); - - // Publish everything required - for (const device of toPublish) { - await addHostAddress(`${device.uuid}.devices.${deviceTld}`, address); - } - - // Unpublish the rest - for (const device of toUnpublish) { - await removeHostAddress(`${device.uuid}.devices.${deviceTld}`); - } - - accessibleDevices = newAccessible; - } catch (err) { - console.log(`Couldn't reap devices list: ${err}`); - } -}; - -// 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); -const balena = BalenaSdk({ - apiUrl: `https://api.${process.env.MDNS_TLD}/`, -}); +const dnsProxy = process.env.PROXY_DNS; (async () => { - 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(); - } - - // For each address, publish the interface IP address. - await Bluebird.map(MDNSHosts, host => { - const fullHostname = `${host}.${tld}`; - - return addHostAddress(fullHostname, 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); - } - } catch (err) { - console.log(`balena MDNS publisher error:\n${err}`); - // This is not ideal. However, dbus-native does not correctly free connections - // on event loop exit - process.exit(1); + // If proxying DNS, start dnsmasq else start the MDNS publisher. + // This will be killed on parent (this) exit if required + if (dnsProxy && dnsProxy.toLowerCase() === 'true') { + // Configure and run the DNS proxy + await startDnsProxy(); + } else { + await startMdnsPublisher(); } })(); diff --git a/src/dns-proxy.ts b/src/dns-proxy.ts new file mode 100644 index 00000000..0e9ed0ae --- /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}\n`; + }); + + 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/mdns-publisher.ts b/src/mdns-publisher.ts new file mode 100644 index 00000000..db1baaa2 --- /dev/null +++ b/src/mdns-publisher.ts @@ -0,0 +1,216 @@ +/** + * @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 BalenaSdk from 'balena-sdk'; +import * as Bluebird from 'bluebird'; +import { Message, systemBus } from 'dbus-native'; +import * as _ from 'lodash'; + +import { getFullHostnames, getHostAddress } from './utils'; + +/** + * Hosts published via Avahi. + */ +interface PublishedHosts { + /** The Avahi group used to publish the host */ + group: string; + /** The full hostname of the published host */ + hostname: string; + /** The IP address of the published host */ + address: string; +} + +/** List of published hosts */ +const publishedHosts: PublishedHosts[] = []; +/** List of devices with accessible public URLs */ +let accessibleDevices: BalenaSdk.Device[] = []; + +type Callback = (err: Error, ...params: any[]) => void; + +// Get SDK instance +const balena = BalenaSdk({ + apiUrl: `https://api.${process.env.MDNS_TLD}/`, +}); + +/** DBus controller */ +const dbus = systemBus(); +/** + * DBus invoker. + * + * @param message DBus message to send + */ +const dbusInvoker = (message: Message): PromiseLike => { + return Bluebird.fromCallback((cb: Callback) => { + return dbus.invoke(message, cb); + }); +}; + +/** + * Retrieve a new Avahi group for address publishing. + */ +const getGroup = async (): Promise => { + return await dbusInvoker({ + destination: 'org.freedesktop.Avahi', + path: '/', + interface: 'org.freedesktop.Avahi.Server', + member: 'EntryGroupNew', + }); +}; + +/** + * Add a host address to the local domain. + * + * @param hostname Full hostname to publish + * @param address IP address for the hostname + */ +const addHostAddress = async ( + hostname: string, + address: string, +): Promise => { + // If the hostname is already published with the same address, return + if (_.find(publishedHosts, { hostname, address })) { + return; + } + + console.log(`Adding ${hostname} at address ${address} to local MDNS pool`); + + // We require a new group for each address. + // We don't catch errors, as our restart policy is to not restart. + const group = await getGroup(); + + await dbusInvoker({ + destination: 'org.freedesktop.Avahi', + path: group, + interface: 'org.freedesktop.Avahi.EntryGroup', + member: 'AddAddress', + body: [-1, 0, 0x10, hostname, address], + signature: 'iiuss', + }); + + await dbusInvoker({ + destination: 'org.freedesktop.Avahi', + path: group, + interface: 'org.freedesktop.Avahi.EntryGroup', + member: 'Commit', + }); + + // Add to the published hosts list + publishedHosts.push({ + group, + hostname, + address, + }); +}; + +/** + * Remove hostname from published list + * + * @param hostname Hostname to remove from list + */ +const removeHostAddress = async (hostname: string): Promise => { + // If the hostname doesn't exist, we don't use it + const hostDetails = _.find(publishedHosts, { hostname }); + if (!hostDetails) { + return; + } + + console.log(`Removing ${hostname} at address from local MDNS pool`); + + // Free the group, removing the published address + await dbusInvoker({ + destination: 'org.freedesktop.Avahi', + path: hostDetails.group, + interface: 'org.freedesktop.Avahi.EntryGroup', + member: 'Free', + }); + + // Remove from the published hosts list + _.remove(publishedHosts, { hostname }); +}; + +/** + * Scan balena devices with accessible public URLs + * + * @param tld TLD to use for URL publishing + * @param address IP address to use for publishing + */ +const reapDevices = async (deviceTld: string, address: string) => { + // Query the SDK using the Proxy service key for *all* current devices + try { + const devices = await balena.models.device.getAll(); + + // Get list of all accessible devices + 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: any) => { + const filter = _.find(newAccessible, { uuid: device.uuid }) + ? true + : false; + if (!filter) { + toUnpublish.push(device); + } + return filter; + }); + + // Publish everything required + for (const device of toPublish) { + await addHostAddress(`${device.uuid}.devices.${deviceTld}`, address); + } + + // Unpublish the rest + for (const device of toUnpublish) { + await removeHostAddress(`${device.uuid}.devices.${deviceTld}`); + } + + accessibleDevices = newAccessible; + } catch (err) { + console.log(`Couldn't reap devices list: ${err}`); + } +}; + +export async function startMdnsPublisher(): Promise { + const tld = process.env.MDNS_TLD; + if (!tld) { + throw new Error('MDNS_TLD must be set!'); + } + + // Get the list of hostnames to advertise + const hosts = getFullHostnames(); + + try { + const ipAddr = await getHostAddress(process.env.INTERFACE); + + // For each address, publish the interface IP address. + 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); + } + } catch (err) { + console.log(`balena MDNS publisher error:\n${err}`); + // This is not ideal. However, dbus-native does not correctly free connections + // on event loop exit + process.exit(1); + } +} 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}`); +};