From 1b91ef3405d1d2013cbdd6381ed69256c6432d18 Mon Sep 17 00:00:00 2001 From: Rich Bayliss Date: Fri, 5 Jun 2020 14:16:43 +0100 Subject: [PATCH] state: Report device MAC address to the API When reporting device information, send the MAC address of any interfaces on the system. Also expose in the Supervisor API at the route GET /v1/device. Change-type: patch Signed-off-by: Rich Bayliss --- docs/API.md | 3 +- src/config/functions.ts | 4 ++ src/config/schema-type.ts | 4 ++ src/device-state.ts | 5 +++ src/lib/constants.ts | 3 ++ src/lib/mac-address.ts | 57 +++++++++++++++++++++++++ src/network.ts | 9 +++- src/types/state.ts | 1 + test/18-startup.spec.ts | 22 ++++++++++ test/21-supervisor-api.spec.ts | 11 +++++ test/data/sys/class/net/balena0/address | 1 + test/data/sys/class/net/balena0/type | 1 + test/data/sys/class/net/enp0s3/address | 1 + test/data/sys/class/net/enp0s3/type | 1 + test/data/sys/class/net/enp0s4/address | 1 + test/data/sys/class/net/enp0s4/type | 1 + test/data/sys/class/net/master0/address | 1 + test/data/sys/class/net/master0/master | 0 test/data/sys/class/net/master0/type | 1 + test/data/sys/class/net/sit0/address | 1 + test/data/sys/class/net/sit0/type | 1 + test/lib/mocked-device-api.ts | 8 +++- 22 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 src/lib/mac-address.ts create mode 100644 test/data/sys/class/net/balena0/address create mode 100644 test/data/sys/class/net/balena0/type create mode 100644 test/data/sys/class/net/enp0s3/address create mode 100644 test/data/sys/class/net/enp0s3/type create mode 100644 test/data/sys/class/net/enp0s4/address create mode 100644 test/data/sys/class/net/enp0s4/type create mode 100644 test/data/sys/class/net/master0/address create mode 100644 test/data/sys/class/net/master0/master create mode 100644 test/data/sys/class/net/master0/type create mode 100644 test/data/sys/class/net/sit0/address create mode 100644 test/data/sys/class/net/sit0/type diff --git a/docs/API.md b/docs/API.md index 0ce5097a..bed3d369 100644 --- a/docs/API.md +++ b/docs/API.md @@ -327,6 +327,7 @@ The state is a JSON object that contains some or all of the following: * `api_port`: Port on which the supervisor is listening. * `commit`: Hash of the current commit of the application that is running. * `ip_address`: Space-separated list of IP addresses of the device. +* `mac_address`: Space-seperated list of MAC addresses of the device. * `status`: Status of the device regarding the app, as a string, i.e. "Stopping", "Starting", "Downloading", "Installing", "Idle". * `download_progress`: Amount of the application image that has been downloaded, expressed as a percentage. If the update has already been downloaded, this will be `null`. * `os_version`: Version of the host OS running on the device. @@ -1243,4 +1244,4 @@ $ curl -X POST -H "Content-Type: application/json" --data '{"follow":true,"all": ``` An example project using this endpoint can be found -[in this repository](https://github.com/balena-io-playground/device-cloud-logging). \ No newline at end of file +[in this repository](https://github.com/balena-io-playground/device-cloud-logging). diff --git a/src/config/functions.ts b/src/config/functions.ts index 68b397d5..88713145 100644 --- a/src/config/functions.ts +++ b/src/config/functions.ts @@ -8,6 +8,7 @@ import supervisorVersion = require('../lib/supervisor-version'); import * as config from '.'; import * as constants from '../lib/constants'; import * as osRelease from '../lib/os-release'; +import * as macAddress from '../lib/mac-address'; import log from '../lib/supervisor-console'; export const fnSchema = { @@ -34,6 +35,9 @@ export const fnSchema = { osVariant: () => { return osRelease.getOSVariant(constants.hostOSVersionPath); }, + macAddress: () => { + return macAddress.getAll(constants.macAddressPath); + }, deviceArch: async () => { try { // FIXME: We should be mounting the following file into the supervisor from the diff --git a/src/config/schema-type.ts b/src/config/schema-type.ts index d1ef7f0f..07418dbe 100644 --- a/src/config/schema-type.ts +++ b/src/config/schema-type.ts @@ -192,6 +192,10 @@ export const schemaTypes = { type: t.union([t.string, NullOrUndefined]), default: t.never, }, + macAddress: { + type: t.union([t.string, NullOrUndefined]), + default: t.never, + }, provisioningOptions: { type: t.interface({ // These types are taken from the types of the individual diff --git a/src/device-state.ts b/src/device-state.ts index 8d9b8de8..b70081d3 100644 --- a/src/device-state.ts +++ b/src/device-state.ts @@ -143,6 +143,7 @@ function createDeviceStateRouter(deviceState: DeviceState) { 'api_port', 'ip_address', 'os_version', + 'mac_address', 'supervisor_version', 'update_pending', 'update_failed', @@ -315,6 +316,7 @@ export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmit 'apiSecret', 'osVersion', 'osVariant', + 'macAddress', 'version', 'provisioned', 'apiEndpoint', @@ -339,6 +341,7 @@ export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmit api_secret: conf.apiSecret, os_version: conf.osVersion, os_variant: conf.osVariant, + mac_address: conf.macAddress, supervisor_version: conf.version, provisioning_progress: null, provisioning_state: '', @@ -393,8 +396,10 @@ export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmit log.debug('Starting periodic check for IP addresses'); await network.startIPAddressUpdate()(async (addresses) => { + const macAddress = await config.get('macAddress'); await this.reportCurrentState({ ip_address: addresses.join(' '), + mac_address: macAddress, }); }, constants.ipAddressUpdateInterval); } diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 9f5ac28e..543c8e47 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -21,6 +21,9 @@ const constants = { hostOSVersionPath: checkString(process.env.HOST_OS_VERSION_PATH) || `${rootMountPoint}/etc/os-release`, + macAddressPath: + checkString(process.env.MAC_ADDRESS_PATH) || + `${rootMountPoint}/sys/class/net`, privateAppEnvVars: [ 'RESIN_SUPERVISOR_API_KEY', 'RESIN_API_KEY', diff --git a/src/lib/mac-address.ts b/src/lib/mac-address.ts new file mode 100644 index 00000000..99421578 --- /dev/null +++ b/src/lib/mac-address.ts @@ -0,0 +1,57 @@ +import * as _ from 'lodash'; +import { promises as fs, exists } from 'mz/fs'; +import * as path from 'path'; + +import log from './supervisor-console'; +import { shouldReportInterface } from '../network'; + +import TypedError = require('typed-error'); +export class MacAddressError extends TypedError {} + +export async function getAll(sysClassNet: string): Promise { + try { + // read the directories in the sysfs... + const interfaces = await fs.readdir(sysClassNet); + + return _( + await Promise.all( + interfaces.map(async (intf) => { + try { + const [addressFile, typeFile, masterFile] = [ + 'address', + 'type', + 'master', + ].map((f) => path.join(sysClassNet, intf, f)); + + const [intfType, intfHasMaster] = await Promise.all([ + fs.readFile(typeFile, 'utf8'), + exists(masterFile), + ]); + + // check if we should report this interface at all, or if it is physical interface, or if the interface has a master interface (i.e. it's not the root interface) + if ( + !shouldReportInterface(intf) || + intfType.trim() !== '1' || + intfHasMaster + ) { + // we shouldn't report this MAC address + return ''; + } + + const addr = await fs.readFile(addressFile, 'utf8'); + return addr.split('\n')[0]?.trim()?.toUpperCase() ?? ''; + } catch (err) { + log.warn('Error reading MAC address for interface', intf, err); + return ''; + } + }), + ), + ) + .filter((addr) => addr !== '') + .uniq() + .join(' '); + } catch (err) { + log.error(new MacAddressError(`Unable to acquire MAC address: ${err}`)); + return; + } +} diff --git a/src/network.ts b/src/network.ts index 24d6f983..159694c2 100644 --- a/src/network.ts +++ b/src/network.ts @@ -115,6 +115,9 @@ export const connectivityCheckEnabled = Bluebird.method( ); const IP_REGEX = /^(?:balena|docker|rce|tun)[0-9]+|tun[0-9]+|resin-vpn|lo|resin-dns|supervisor0|balena-redsocks|resin-redsocks|br-[0-9a-f]{12}$/; + +export const shouldReportInterface = (intf: string) => !IP_REGEX.test(intf); + export function getIPAddresses(): string[] { // We get IP addresses but ignore: // - docker and balena bridges (docker0, docker1, balena0, etc) @@ -126,10 +129,12 @@ export function getIPAddresses(): string[] { // - the docker network for the supervisor API (supervisor0) // - custom docker network bridges (br- + 12 hex characters) return _(os.networkInterfaces()) - .omitBy((_interfaceFields, interfaceName) => IP_REGEX.test(interfaceName)) + .filter((_interfaceFields, interfaceName) => + shouldReportInterface(interfaceName), + ) .flatMap((validInterfaces) => { return _(validInterfaces) - .pickBy({ family: 'IPv4' }) + .pickBy({ family: 'IPv4', internal: false }) .map('address') .value(); }) diff --git a/src/types/state.ts b/src/types/state.ts index dd405cb5..90aa0c0f 100644 --- a/src/types/state.ts +++ b/src/types/state.ts @@ -21,6 +21,7 @@ export type DeviceReportFields = Partial<{ update_downloaded: boolean; is_on__commit: string; logs_channel: null; + mac_address: string | null; }>; export interface DeviceStatus { diff --git a/test/18-startup.spec.ts b/test/18-startup.spec.ts index 7a77783b..257e3854 100644 --- a/test/18-startup.spec.ts +++ b/test/18-startup.spec.ts @@ -7,9 +7,11 @@ import * as constants from '../src/lib/constants'; import { docker } from '../src/lib/docker-utils'; import { Supervisor } from '../src/supervisor'; import { expect } from './lib/chai-config'; +import _ = require('lodash'); describe('Startup', () => { let initClientStub: SinonStub; + let reportCurrentStateStub: SinonStub; let startStub: SinonStub; let vpnStatusPathStub: SinonStub; let appManagerStub: SinonStub; @@ -20,6 +22,10 @@ describe('Startup', () => { initClientStub = stub(APIBinder.prototype as any, 'initClient').returns( Promise.resolve(), ); + reportCurrentStateStub = stub( + DeviceState.prototype as any, + 'reportCurrentState', + ).resolves(); startStub = stub(APIBinder.prototype as any, 'start').returns( Promise.resolve(), ); @@ -40,6 +46,7 @@ describe('Startup', () => { vpnStatusPathStub.restore(); deviceStateStub.restore(); dockerStub.restore(); + reportCurrentStateStub.restore(); }); it('should startup correctly', async () => { @@ -52,5 +59,20 @@ describe('Startup', () => { expect(anySupervisor.logger).to.not.be.null; expect(anySupervisor.deviceState).to.not.be.null; expect(anySupervisor.apiBinder).to.not.be.null; + + let macAddresses: string[] = []; + reportCurrentStateStub.getCalls().forEach((call) => { + const m: string = call.args[0]['mac_address']; + if (!m) { + return; + } + + macAddresses = _.union(macAddresses, m.split(' ')); + }); + + const allMacAddresses = macAddresses.join(' '); + + expect(allMacAddresses).to.have.length.greaterThan(0); + expect(allMacAddresses).to.not.contain('NO:'); }); }); diff --git a/test/21-supervisor-api.spec.ts b/test/21-supervisor-api.spec.ts index 2745f304..3f31b7d6 100644 --- a/test/21-supervisor-api.spec.ts +++ b/test/21-supervisor-api.spec.ts @@ -113,6 +113,17 @@ describe('SupervisorAPI', () => { }); }); // TODO: add tests for V1 endpoints + describe('GET /v1/device', () => { + it('returns MAC address', async () => { + const response = await request + .get('/v1/device') + .set('Accept', 'application/json') + .set('Authorization', `Bearer ${VALID_SECRET}`) + .expect(200); + + expect(response.body).to.have.property('mac_address').that.is.not.empty; + }); + }); }); describe('V2 endpoints', () => { diff --git a/test/data/sys/class/net/balena0/address b/test/data/sys/class/net/balena0/address new file mode 100644 index 00000000..8e265f82 --- /dev/null +++ b/test/data/sys/class/net/balena0/address @@ -0,0 +1 @@ +NO:NO:NO:NO:NO:NO diff --git a/test/data/sys/class/net/balena0/type b/test/data/sys/class/net/balena0/type new file mode 100644 index 00000000..d00491fd --- /dev/null +++ b/test/data/sys/class/net/balena0/type @@ -0,0 +1 @@ +1 diff --git a/test/data/sys/class/net/enp0s3/address b/test/data/sys/class/net/enp0s3/address new file mode 100644 index 00000000..8c970db2 --- /dev/null +++ b/test/data/sys/class/net/enp0s3/address @@ -0,0 +1 @@ +00:11:22:33:44:55 diff --git a/test/data/sys/class/net/enp0s3/type b/test/data/sys/class/net/enp0s3/type new file mode 100644 index 00000000..d00491fd --- /dev/null +++ b/test/data/sys/class/net/enp0s3/type @@ -0,0 +1 @@ +1 diff --git a/test/data/sys/class/net/enp0s4/address b/test/data/sys/class/net/enp0s4/address new file mode 100644 index 00000000..a3cebff4 --- /dev/null +++ b/test/data/sys/class/net/enp0s4/address @@ -0,0 +1 @@ +66:77:88:99:AA:BB diff --git a/test/data/sys/class/net/enp0s4/type b/test/data/sys/class/net/enp0s4/type new file mode 100644 index 00000000..d00491fd --- /dev/null +++ b/test/data/sys/class/net/enp0s4/type @@ -0,0 +1 @@ +1 diff --git a/test/data/sys/class/net/master0/address b/test/data/sys/class/net/master0/address new file mode 100644 index 00000000..8e265f82 --- /dev/null +++ b/test/data/sys/class/net/master0/address @@ -0,0 +1 @@ +NO:NO:NO:NO:NO:NO diff --git a/test/data/sys/class/net/master0/master b/test/data/sys/class/net/master0/master new file mode 100644 index 00000000..e69de29b diff --git a/test/data/sys/class/net/master0/type b/test/data/sys/class/net/master0/type new file mode 100644 index 00000000..d00491fd --- /dev/null +++ b/test/data/sys/class/net/master0/type @@ -0,0 +1 @@ +1 diff --git a/test/data/sys/class/net/sit0/address b/test/data/sys/class/net/sit0/address new file mode 100644 index 00000000..8e265f82 --- /dev/null +++ b/test/data/sys/class/net/sit0/address @@ -0,0 +1 @@ +NO:NO:NO:NO:NO:NO diff --git a/test/data/sys/class/net/sit0/type b/test/data/sys/class/net/sit0/type new file mode 100644 index 00000000..0cfbf088 --- /dev/null +++ b/test/data/sys/class/net/sit0/type @@ -0,0 +1 @@ +2 diff --git a/test/lib/mocked-device-api.ts b/test/lib/mocked-device-api.ts index 8380e082..8f80d95e 100644 --- a/test/lib/mocked-device-api.ts +++ b/test/lib/mocked-device-api.ts @@ -75,9 +75,15 @@ async function create(): Promise { }); // Create SupervisorAPI const api = new SupervisorAPI({ - routers: [buildRoutes(appManager)], + routers: [deviceState.router, buildRoutes(appManager)], healthchecks: [deviceState.healthcheck, apiBinder.healthcheck], }); + + const macAddress = await config.get('macAddress'); + deviceState.reportCurrentState({ + mac_address: macAddress, + }); + // Return SupervisorAPI that is not listening yet return api; }