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 <rich@balena.io>
This commit is contained in:
Rich Bayliss 2020-06-05 14:16:43 +01:00
parent 9d42e3518d
commit 1b91ef3405
No known key found for this signature in database
GPG Key ID: E53C4B4D18499E1A
22 changed files with 133 additions and 4 deletions

View File

@ -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. * `api_port`: Port on which the supervisor is listening.
* `commit`: Hash of the current commit of the application that is running. * `commit`: Hash of the current commit of the application that is running.
* `ip_address`: Space-separated list of IP addresses of the device. * `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". * `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`. * `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. * `os_version`: Version of the host OS running on the device.

View File

@ -8,6 +8,7 @@ import supervisorVersion = require('../lib/supervisor-version');
import * as config from '.'; import * as config from '.';
import * as constants from '../lib/constants'; import * as constants from '../lib/constants';
import * as osRelease from '../lib/os-release'; import * as osRelease from '../lib/os-release';
import * as macAddress from '../lib/mac-address';
import log from '../lib/supervisor-console'; import log from '../lib/supervisor-console';
export const fnSchema = { export const fnSchema = {
@ -34,6 +35,9 @@ export const fnSchema = {
osVariant: () => { osVariant: () => {
return osRelease.getOSVariant(constants.hostOSVersionPath); return osRelease.getOSVariant(constants.hostOSVersionPath);
}, },
macAddress: () => {
return macAddress.getAll(constants.macAddressPath);
},
deviceArch: async () => { deviceArch: async () => {
try { try {
// FIXME: We should be mounting the following file into the supervisor from the // FIXME: We should be mounting the following file into the supervisor from the

View File

@ -192,6 +192,10 @@ export const schemaTypes = {
type: t.union([t.string, NullOrUndefined]), type: t.union([t.string, NullOrUndefined]),
default: t.never, default: t.never,
}, },
macAddress: {
type: t.union([t.string, NullOrUndefined]),
default: t.never,
},
provisioningOptions: { provisioningOptions: {
type: t.interface({ type: t.interface({
// These types are taken from the types of the individual // These types are taken from the types of the individual

View File

@ -143,6 +143,7 @@ function createDeviceStateRouter(deviceState: DeviceState) {
'api_port', 'api_port',
'ip_address', 'ip_address',
'os_version', 'os_version',
'mac_address',
'supervisor_version', 'supervisor_version',
'update_pending', 'update_pending',
'update_failed', 'update_failed',
@ -315,6 +316,7 @@ export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmit
'apiSecret', 'apiSecret',
'osVersion', 'osVersion',
'osVariant', 'osVariant',
'macAddress',
'version', 'version',
'provisioned', 'provisioned',
'apiEndpoint', 'apiEndpoint',
@ -339,6 +341,7 @@ export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmit
api_secret: conf.apiSecret, api_secret: conf.apiSecret,
os_version: conf.osVersion, os_version: conf.osVersion,
os_variant: conf.osVariant, os_variant: conf.osVariant,
mac_address: conf.macAddress,
supervisor_version: conf.version, supervisor_version: conf.version,
provisioning_progress: null, provisioning_progress: null,
provisioning_state: '', provisioning_state: '',
@ -393,8 +396,10 @@ export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmit
log.debug('Starting periodic check for IP addresses'); log.debug('Starting periodic check for IP addresses');
await network.startIPAddressUpdate()(async (addresses) => { await network.startIPAddressUpdate()(async (addresses) => {
const macAddress = await config.get('macAddress');
await this.reportCurrentState({ await this.reportCurrentState({
ip_address: addresses.join(' '), ip_address: addresses.join(' '),
mac_address: macAddress,
}); });
}, constants.ipAddressUpdateInterval); }, constants.ipAddressUpdateInterval);
} }

View File

@ -21,6 +21,9 @@ const constants = {
hostOSVersionPath: hostOSVersionPath:
checkString(process.env.HOST_OS_VERSION_PATH) || checkString(process.env.HOST_OS_VERSION_PATH) ||
`${rootMountPoint}/etc/os-release`, `${rootMountPoint}/etc/os-release`,
macAddressPath:
checkString(process.env.MAC_ADDRESS_PATH) ||
`${rootMountPoint}/sys/class/net`,
privateAppEnvVars: [ privateAppEnvVars: [
'RESIN_SUPERVISOR_API_KEY', 'RESIN_SUPERVISOR_API_KEY',
'RESIN_API_KEY', 'RESIN_API_KEY',

57
src/lib/mac-address.ts Normal file
View File

@ -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<string | undefined> {
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;
}
}

View File

@ -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}$/; 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[] { export function getIPAddresses(): string[] {
// We get IP addresses but ignore: // We get IP addresses but ignore:
// - docker and balena bridges (docker0, docker1, balena0, etc) // - docker and balena bridges (docker0, docker1, balena0, etc)
@ -126,10 +129,12 @@ export function getIPAddresses(): string[] {
// - the docker network for the supervisor API (supervisor0) // - the docker network for the supervisor API (supervisor0)
// - custom docker network bridges (br- + 12 hex characters) // - custom docker network bridges (br- + 12 hex characters)
return _(os.networkInterfaces()) return _(os.networkInterfaces())
.omitBy((_interfaceFields, interfaceName) => IP_REGEX.test(interfaceName)) .filter((_interfaceFields, interfaceName) =>
shouldReportInterface(interfaceName),
)
.flatMap((validInterfaces) => { .flatMap((validInterfaces) => {
return _(validInterfaces) return _(validInterfaces)
.pickBy({ family: 'IPv4' }) .pickBy({ family: 'IPv4', internal: false })
.map('address') .map('address')
.value(); .value();
}) })

View File

@ -21,6 +21,7 @@ export type DeviceReportFields = Partial<{
update_downloaded: boolean; update_downloaded: boolean;
is_on__commit: string; is_on__commit: string;
logs_channel: null; logs_channel: null;
mac_address: string | null;
}>; }>;
export interface DeviceStatus { export interface DeviceStatus {

View File

@ -7,9 +7,11 @@ import * as constants from '../src/lib/constants';
import { docker } from '../src/lib/docker-utils'; import { docker } from '../src/lib/docker-utils';
import { Supervisor } from '../src/supervisor'; import { Supervisor } from '../src/supervisor';
import { expect } from './lib/chai-config'; import { expect } from './lib/chai-config';
import _ = require('lodash');
describe('Startup', () => { describe('Startup', () => {
let initClientStub: SinonStub; let initClientStub: SinonStub;
let reportCurrentStateStub: SinonStub;
let startStub: SinonStub; let startStub: SinonStub;
let vpnStatusPathStub: SinonStub; let vpnStatusPathStub: SinonStub;
let appManagerStub: SinonStub; let appManagerStub: SinonStub;
@ -20,6 +22,10 @@ describe('Startup', () => {
initClientStub = stub(APIBinder.prototype as any, 'initClient').returns( initClientStub = stub(APIBinder.prototype as any, 'initClient').returns(
Promise.resolve(), Promise.resolve(),
); );
reportCurrentStateStub = stub(
DeviceState.prototype as any,
'reportCurrentState',
).resolves();
startStub = stub(APIBinder.prototype as any, 'start').returns( startStub = stub(APIBinder.prototype as any, 'start').returns(
Promise.resolve(), Promise.resolve(),
); );
@ -40,6 +46,7 @@ describe('Startup', () => {
vpnStatusPathStub.restore(); vpnStatusPathStub.restore();
deviceStateStub.restore(); deviceStateStub.restore();
dockerStub.restore(); dockerStub.restore();
reportCurrentStateStub.restore();
}); });
it('should startup correctly', async () => { it('should startup correctly', async () => {
@ -52,5 +59,20 @@ describe('Startup', () => {
expect(anySupervisor.logger).to.not.be.null; expect(anySupervisor.logger).to.not.be.null;
expect(anySupervisor.deviceState).to.not.be.null; expect(anySupervisor.deviceState).to.not.be.null;
expect(anySupervisor.apiBinder).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:');
}); });
}); });

View File

@ -113,6 +113,17 @@ describe('SupervisorAPI', () => {
}); });
}); });
// TODO: add tests for V1 endpoints // 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', () => { describe('V2 endpoints', () => {

View File

@ -0,0 +1 @@
NO:NO:NO:NO:NO:NO

View File

@ -0,0 +1 @@
1

View File

@ -0,0 +1 @@
00:11:22:33:44:55

View File

@ -0,0 +1 @@
1

View File

@ -0,0 +1 @@
66:77:88:99:AA:BB

View File

@ -0,0 +1 @@
1

View File

@ -0,0 +1 @@
NO:NO:NO:NO:NO:NO

View File

View File

@ -0,0 +1 @@
1

View File

@ -0,0 +1 @@
NO:NO:NO:NO:NO:NO

View File

@ -0,0 +1 @@
2

View File

@ -75,9 +75,15 @@ async function create(): Promise<SupervisorAPI> {
}); });
// Create SupervisorAPI // Create SupervisorAPI
const api = new SupervisorAPI({ const api = new SupervisorAPI({
routers: [buildRoutes(appManager)], routers: [deviceState.router, buildRoutes(appManager)],
healthchecks: [deviceState.healthcheck, apiBinder.healthcheck], healthchecks: [deviceState.healthcheck, apiBinder.healthcheck],
}); });
const macAddress = await config.get('macAddress');
deviceState.reportCurrentState({
mac_address: macAddress,
});
// Return SupervisorAPI that is not listening yet // Return SupervisorAPI that is not listening yet
return api; return api;
} }