zbtk
Version:
ZigBee Toolkit for Node.js
471 lines (426 loc) • 16.9 kB
JavaScript
import capModule from 'cap';
const { Cap, decoders } = capModule;
const capProtocols = decoders.PROTOCOL;
import { EventEmitter } from 'node:events';
import { connectAsync as mqttConnect } from 'mqtt';
import { pk } from './crypto.js';
import { parse as parsePacket } from './parse.js';
import { eui as rawFormatEui } from './format.js';
function formatEui(data) {
// all EUIs that we format here are read LE from the packet, so reverse before display
return rawFormatEui(data, /* reverse = */true);
}
import getPacketType from './type.js';
import getCluster from './cluster.js';
import { toHex as rawToHex, fromHex, jsonStringify, reverseEndian } from './utils.js';
function toHex(data) {
// all hex that we format here is read LE from the packet, so reverse before display
return rawToHex(data, /* reverse = */true);
}
import whence from 'whence';
function id(address) {
return fromHex(address).toString('hex');
}
const addressTable = {};
function populateAddressTable(eui, addr) {
const addrId = id(addr);
if (addressTable[addrId] && !addressTable[addrId].equals(eui)) {
throw new Error(`Conflict in address table! Both ${formatEui(addressTable[addrId])} and ${formatEui(eui)} use use 16-bit network address ${toHex(addr)}`);
}
addressTable[addrId] = Buffer.from(eui);
}
export default { open };
/**
* Open a capture device for packet capture and emit events of 'data', 'packet' and 'attribute' (and 'error') via the returned EventEmitter.
*
* @param {string} device the device to open for capture
* @param {object} [options] the capture options
* @param {(string|string[])} [options.emit=['attribute']] the events to emit via the returned EventEmitter and MQTT in case MQTT options are supplied, either one of 'data', 'packet' and/or 'attribute', 'error' events always getting emitted from the returned EventEmitter regardless of the settings
* @param {string|object|function} [options.filter] the filter to apply to the packets, a eval-estree-expression expression (see https://github.com/jonschlinkert/eval-estree-expression?tab=readme-ov-file#examples), estree-compatible expression AST, or filter function
* @param {object} [options.out] the output options
* @param {(boolean|string|string[])} [options.out.log] the events to log, any 'data', 'packet' and / or 'attribute', additionally 'verbose', 'info', 'warn' or 'error' sets the log-level, default is 'info'. true to log all emitted events as well as enable 'info' logging, false to disable logging entirely
* @param {object} [options.out.mqtt] the MQTT output options
* @param {string} [options.out.mqtt.url] the MQTT broker URL
* @param {object} [options.out.mqtt.options] the MQTT connection options
* @param {object} [options.out.mqtt.client] the MQTT client instead of creating a new one. attention: calling close() will *not* close this client
* @param {string} [options.out.mqtt.topic='zbtk'] the MQTT topic to publish the packets to
* @param {number} [options.bufferSize=10485760] the buffer size to use for packet capture
* @param {function} [options.bufferFormat] a function to format buffers before emitting them to console / MQTT
* @returns {Promise<EventEmitter>} a promise to an event emitter (with an additional close method), emitting events of 'options.emit' and 'error' events
*/
export async function open(device, options) {
let mqttClient, mqttTopic;
const mqtt = options?.out?.mqtt;
if (mqtt) {
mqttClient = mqtt.client || await mqttConnect(
mqtt.url, mqtt.options);
mqttTopic = mqtt.topic || 'zbtk';
}
let emit = options?.emit;
if (emit === undefined) {
emit = ['attribute'];
} else if (typeof emit === 'string') {
emit = [emit];
}
if (!Array.isArray(emit) || (emit = emit.filter(name => ['data', 'packet', 'attribute'].includes(name))).length === 0) {
throw new TypeError('No valid events to emit, must be one or multiple of "data", "packet" or "attribute"');
}
let log = options?.out?.log;
if (typeof log === 'boolean') {
log = log ? [...emit, 'info'] : [];
} else if (!log) {
log = ['info'];
} else if (typeof log === 'string') {
log = [log];
} else if (!Array.isArray(log)) {
throw new TypeError('Invalid log option, must be a boolean, string or array of strings');
}
emit = new Set(emit);
log = new Set(log);
let logLevel = 0; // no logging
let logPksInfo = true;
if (log.size) {
log.delete('error') && (logLevel = 1);
log.delete('warn') && (logLevel = 2);
log.delete('info') && (logLevel = 3);
log.delete('verbose') && (logLevel = 4);
logLevel = logLevel || 3; // default to info if no other log level is set
}
const events = new Set([...emit, ...log]); // union
events.delete('error');
const bufferSize = options?.bufferSize || 10 * 1024 * 1024, buffer = Buffer.alloc(bufferSize);
let filter;
if (typeof options?.filter === 'function') {
filter = options.filter;
} else if (options?.filter) {
filter = whence.compile(options.filter);
}
const cap = new Cap();
cap.open(device, '', bufferSize, buffer);
cap.setMinBytes && cap.setMinBytes(0);
const eventEmitter = new EventEmitter();
eventEmitter.on('error', function() {
// nothing to do here, our EventEmitter should not crash in case no
// handler is present. errors are still logged to console instead
});
eventEmitter.close = async function() {
try {
cap.close();
} finally {
// close the MQTT client in any case
if (!mqtt.client && mqttClient) {
await mqttClient.end();
}
}
};
cap.on('packet', async function() {
const context = {};
const logger = {
verbose: logLevel >= 4 ? console.trace : () => {},
info: logLevel >= 3 ? console.log : () => {},
warn: logLevel >= 2 ? console.warn : () => {},
error: function(...params) {
if (!Array.isArray(params)) {
params = params ? [params] : [];
}
if (!params.length) {
params = ['Unknown error occurred'];
}
let err = params.pop();
if (!(err instanceof Error)) {
if (!params.length) {
err = new Error(err);
} else {
params.push(err);
err = new Error(params[0]);
}
}
eventEmitter.emit('error', err, context);
if (logLevel >= 1) {
console.error(...params, err);
}
}
};
const eth = decoders.Ethernet(buffer);
if (eth.info.type !== capProtocols.ETHERNET.IPV4) {
return;
}
const ip = decoders.IPV4(buffer, eth.offset);
if (ip.info.protocol !== capProtocols.IP.UDP) {
return;
}
const udp = decoders.UDP(buffer, ip.offset);
if (udp.info.dstport !== 17754) { // Encap. ZigBee Packets
logger.warn(`Received non-ZigBee traffic on port ${udp.info.dstport} of capture device`);
return;
}
const data = context.data = buffer.subarray(udp.offset, udp.offset + udp.info.length);
let packet, packetStr, packetType, packetErr;
// parse the packet only if a filter is defined or if we are going to emit / log the parsed packet or its attributes
if (filter || (events.has('packet') || events.has('attribute'))) {
try {
packet = Object.defineProperty(parsePacket(data), 'toString', {
value: function() {
return packetStr || (packetStr = jsonStringify(this));
}
});
} catch (err) {
logger.error('Malformed packet!', data.toString('hex'), packetErr = err);
}
if (packet && !packetErr) {
try {
packetType = getPacketType(packet);
} catch (err) {
logger.error('Failed to determine packet type!', data.toString('hex'), err);
// nothing to do here
} finally {
packetType ||= 'UNKNOWN';
}
} else {
packetType = 'MALFORMED';
}
} else {
packetType = 'NOT_PARSED';
}
if (packetType === 'APS_CMD_TRANSPORT_KEY') {
const key = packet?.wpan?.zbee_nwk?.zbee_aps?.cmd?.key;
if (Buffer.isBuffer(key)) {
const newPk = pk(key);
logger.info();
logger.info('-'.repeat(60));
logger.info();
logger.info(`Captured Transport Key ${key.toString('hex')}`);
logger.info();
logger.info(newPk ? 'Key was automatically added to pre-configured key list' : 'Key was already present in pre-configured key list');
logger.info();
logger.info('-'.repeat(60));
logger.info();
}
}
Object.assign(context, { packet, type: packetType });
if (filter && !(await filter(context))) {
return;
}
if (log.has('data')) {
console.log(data.toString('hex'), `(${packetType})`);
}
if (emit.has('data')) {
eventEmitter.emit('data', data, context);
if (mqtt) {
try {
await mqttClient.publishAsync(mqttTopic, data);
} catch (err) {
logger.error('MQTT publish raw packet failed', err);
}
}
}
if (!packet || packetErr) {
return;
}
if (log.has('packet')) {
console.log(packet.toString() /* internally calls jsonStringify */, `(${packetType})`);
}
if (emit.has('packet')) {
eventEmitter.emit('packet', packet, context);
if (mqtt) {
try {
await mqttClient.publishAsync(mqttTopic, packet.toString() /* internally calls jsonStringify */);
} catch (err) {
logger.error('MQTT publish packet failed', err);
}
}
}
if (!events.has('attribute')) {
return;
}
const wpan = packet.wpan, zbee_nwk = wpan.zbee_nwk;
if (!zbee_nwk) {
return;
}
// populate address table, in case EUIs are to be published
if (!process.env.ZBTK_CAP_PASS_NO_EUI) {
try {
if (zbee_nwk.fc.ext_src) {
populateAddressTable(zbee_nwk.src64, zbee_nwk.src);
}
if (zbee_nwk.fc.ext_dst) {
populateAddressTable(zbee_nwk.dst64, zbee_nwk.dst);
}
if (zbee_nwk.sec) {
populateAddressTable(zbee_nwk.sec.src64, wpan.src16);
if (zbee_nwk.fc.end_device_initiator) {
populateAddressTable(zbee_nwk.sec.src64, zbee_nwk.src);
}
}
} catch (err) {
logger.error(err);
}
}
if (zbee_nwk.sec && zbee_nwk.data) {
logger.warn('Packet encrypted / decryption failed or not attempted');
if (logPksInfo) {
logger.info('Set or check ZBTK_CRYPTO_(WELL_KNOWN_)PKS environment variable(s) or capture Transport Key');
logPksInfo = false; // only log the PKS info once
}
}
if (!packetType.startsWith('ZCL_') || packetType.endsWith('_ACK') || !zbee_nwk.sec || !zbee_nwk.zbee_aps?.zbee_zcl) {
return;
}
let addr, eui, write = false;
if (packetType === 'ZCL_CMD_READ_ATTR_RSP' || packetType === 'ZCL_CMD_REPORT_ATTR') {
addr = zbee_nwk.src;
eui = zbee_nwk.fc.ext_src ? zbee_nwk.src64 : (zbee_nwk.fc.end_device_initiator ? zbee_nwk.sec.src64 : addressTable[id(addr)]);
} else if (packetType === 'ZCL_CMD_WRITE_ATTR') {
addr = zbee_nwk.dst;
eui = zbee_nwk.fc.ext_dst ? zbee_nwk.dst64 : addressTable[id(addr)];
write = true;
} else {
return;
}
// assign further context attributes after extraction from the packet in big-endian format
Object.assign(context, eui && { eui: reverseEndian(eui) }, { addr: reverseEndian(addr), write });
if (!process.env.ZBTK_CAP_PASS_NO_EUI && !eui) {
logger.warn(`Devices ${toHex(addr)} 64-Bit Extended Unique Identifier (EUI-64) neither present in packet, nor in address table (yet)`);
return;
}
const zbee_aps = zbee_nwk.zbee_aps;
// we always expose big-endian to the consumer, little-endian is only for internal use / packet parsing
Object.assign(context, { cluster: reverseEndian(zbee_aps.cluster), profile: reverseEndian(zbee_aps.profile) });
for (const intAttr of zbee_aps.zbee_zcl.attrs) {
const attr = { // same as for the context variables, expose big-endian instead of little-endian
id: reverseEndian(intAttr.id),
value: Buffer.isBuffer(intAttr.value) ? reverseEndian(intAttr.value) : intAttr.value
}, outAttr = {
id: rawToHex(attr.id),
value: Buffer.isBuffer(intAttr.value) ? (typeof options?.bufferFormat === 'function' ?
options.bufferFormat(attr.value) : rawToHex(attr.value)) : attr.value
};
if (log.has('attribute')) {
const cluster = getCluster(context.cluster);
console.log(`${cluster?.name || 'Unknown Cluster'} (${toHex(zbee_aps.cluster)})/${cluster?.get?.(attr.id) || 'Unknown Attribute'} (${outAttr.id}): ${Buffer.isBuffer(outAttr.value) ? rawToHex(outAttr.value) : outAttr.value} (${write ? 'written to' : 'read from'} ${!process.env.ZBTK_CAP_PASS_NO_EUI ? formatEui(eui) : toHex(addr)})`);
}
eventEmitter.emit('attribute', attr, context);
if (mqtt) {
try {
await mqttClient.publishAsync(`${mqttTopic}/${!process.env.ZBTK_CAP_PASS_NO_EUI ? formatEui(eui) : toHex(addr)}/${toHex(zbee_aps.cluster)}/${outAttr.id}`,
Buffer.isBuffer(outAttr.value) ? outAttr.value : `${outAttr.value}`);
} catch (err) {
logger.error(err, 'MQTT publish attribute failed');
}
}
}
});
return eventEmitter;
}
export const command = {
command: 'cap [device]',
desc: 'Packet / Attribute (to MQTT) Capture',
builder: yargs => yargs
.positional('device', {
desc: 'Capture device to use',
type: 'string',
conflicts: 'list-devices'
})
.option('list-devices', {
alias: ['list'],
desc: 'List all available capture devices',
type: 'boolean',
conflicts: 'device'
})
.option('emit', {
alias: 'e',
desc: 'Events to emit to MQTT',
type: 'array',
choices: ['data', 'packet', 'attribute'],
default: ['attribute']
})
.option('log', {
alias: 'l',
desc: 'Log outputs, defaults "info", if no output MQTT also to "packet", --no-log to disable',
type: 'array',
choices: [false, 'data', 'packet', 'attribute', 'info', 'warn', 'error', 'verbose']
})
.option('filter', {
alias: 'f',
desc: 'Filter packets to emit / log (whence expression)',
type: 'string'
})
.option('mqtt-host', {
alias: 'h',
desc: 'MQTT broker host',
type: 'string'
})
.option('mqtt-port', {
alias: 'p',
desc: 'MQTT broker port',
type: 'number',
default: 1883
})
.option('mqtt-username', {
alias: ['u', 'user', 'mqtt-user'],
desc: 'MQTT broker username',
type: 'string'
})
.option('mqtt-password', {
alias: ['pw', 'pass', 'mqtt-pw', 'mqtt-pass'],
desc: 'MQTT broker password',
type: 'string'
})
.option('mqtt-topic', {
alias: 't',
desc: 'MQTT topic',
type: 'string',
default: 'zbtk'
})
.check((argv, options) => {
if (argv.help) {
return true;
} else if (!argv.listDevices && !argv.device) {
throw new TypeError('Either specify a <device> to capture or use --list-devices to list all available capture devices');
}
return true;
})
.middleware(async argv => {
if (argv.help) {
return;
} else if (!argv.log) {
argv.log = ['info'];
if (!argv.mqttHost) {
argv.log.push('packet');
}
} else if (argv.log.includes(false)) {
argv.log = false;
}
})
.example('$0 cap --list-devices', 'List all available capture devices')
.example(`$0 cap '\\Device\\NPF_{83B280A6-6C08-4F7A-A8F2-9C88E12998CD}' --filter 'type != \\"WPAN_ACK\\" && type != \\"WPAN_COMMAND\\"'`, 'Capture non-WPAN packets and print them to console')
.example('$0 cap /dev/en0 --emit attribute --mqtt-host localhost --mqtt-user user --mqtt-password password', 'Capture packets from /dev/en0 and emit captured attributes to an MQTT broker')
.version(false)
.help(),
handler: async argv => {
if (argv.listDevices) {
const devices = Cap.deviceList();
if (devices.length === 0) {
console.log('No capture devices found');
} else {
devices.forEach(device => console.log(`Cap. Device: ${device.name}\nDescription: ${device.description}\n`));
}
} else {
await open(argv.device, {
emit: argv.emit,
filter: argv.filter,
out: {
log: argv.log,
mqtt: argv.mqttHost && {
url: `mqtt://${argv.mqttHost}:${argv.mqttPort}`,
options: {
username: argv.mqttUsername,
password: argv.mqttPassword
},
topic: argv.mqttTopic
}
}
});
}
}
};