Merge pull request #1361 from balena-io/report-mac-address

state: Report device MAC address to the API
This commit is contained in:
bulldozer-balena[bot] 2020-06-22 11:03:22 +00:00 committed by GitHub
commit eccc353ea7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
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.
* `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).
[in this repository](https://github.com/balena-io-playground/device-cloud-logging).

View File

@ -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

View File

@ -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

View File

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

View File

@ -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',

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}$/;
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();
})

View File

@ -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 {

View File

@ -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:');
});
});

View File

@ -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', () => {

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