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}`);
+};