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; }