Skip to content

Commit

Permalink
Add sniff command.
Browse files Browse the repository at this point in the history
  • Loading branch information
Nerivec committed Jul 7, 2024
1 parent e69b2b8 commit 9b37a83
Show file tree
Hide file tree
Showing 6 changed files with 342 additions and 5 deletions.
26 changes: 24 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ Interact with EmberZNet-based adapters using zigbee-herdsman 'ember' driver
- Reload custom event handlers
- Run custom script

#### Sniff

- Start sniffing

#### Utils

- Parse NVM3 tokens backup file
Expand Down Expand Up @@ -82,6 +86,7 @@ USAGE
* [`ember-zli bootloader`](#ember-zli-bootloader)
* [`ember-zli help [COMMAND]`](#ember-zli-help-command)
* [`ember-zli router`](#ember-zli-router)
* [`ember-zli sniff`](#ember-zli-sniff)
* [`ember-zli stack`](#ember-zli-stack)
* [`ember-zli utils`](#ember-zli-utils)
* [`ember-zli version`](#ember-zli-version)
Expand Down Expand Up @@ -126,7 +131,7 @@ DESCRIPTION
Display help for ember-zli.
```

_See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/v6.0.22/src/commands/help.ts)_
_See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/v6.2.5/src/commands/help.ts)_

## `ember-zli router`

Expand All @@ -145,6 +150,23 @@ EXAMPLES

_See code: [src/commands/router/index.ts](https://github.com/Nerivec/ember-zli/blob/v2.1.0/src/commands/router/index.ts)_

## `ember-zli sniff`

Sniff Zigbee traffic (to Wireshark, to custom handler or just log in file)

```
USAGE
$ ember-zli sniff
DESCRIPTION
Sniff Zigbee traffic (to Wireshark, to custom handler or just log in file)
EXAMPLES
$ ember-zli sniff
```

_See code: [src/commands/sniff/index.ts](https://github.com/Nerivec/ember-zli/blob/v2.1.0/src/commands/sniff/index.ts)_

## `ember-zli stack`

Interact with the EmberZNet stack in the adapter.
Expand Down Expand Up @@ -197,5 +219,5 @@ FLAG DESCRIPTIONS
Additionally shows the architecture, node version, operating system, and versions of plugins that the CLI is using.
```

_See code: [@oclif/plugin-version](https://github.com/oclif/plugin-version/blob/v2.1.2/src/commands/version.ts)_
_See code: [@oclif/plugin-version](https://github.com/oclif/plugin-version/blob/v2.2.6/src/commands/version.ts)_
<!-- commandsstop -->
2 changes: 1 addition & 1 deletion src/commands/router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ export default class Router extends Command {
exit = await this.navigateMenu()

if (exit && this.routerState === RouterState.RUNNING) {
exit = await confirm({ default: false, message: 'Router is currently running. Confirm exit?' })
exit = await confirm({ message: 'Router is currently running. Confirm exit?', default: false })
}
}

Expand Down
200 changes: 200 additions & 0 deletions src/commands/sniff/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import { confirm, input, select } from '@inquirer/prompts'
import { Command } from '@oclif/core'
import { Socket, createSocket } from 'node:dgram'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { Logger } from 'winston'
import { ZSpec } from 'zigbee-herdsman'
import { SLStatus } from 'zigbee-herdsman/dist/adapter/ember/enums.js'
import { Ezsp } from 'zigbee-herdsman/dist/adapter/ember/ezsp/ezsp.js'

import { DATA_FOLDER, logger } from '../../index.js'
import { emberStart, emberStop } from '../../utils/ember.js'
import { getPortConf } from '../../utils/port.js'
import { createWiresharkZEPFrame } from '../../utils/wireshark.js'

enum SniffMenu {
START_SNIFFING = 0,
}

const DEFAULT_WIRESHARK_IP_ADDRESS = '127.0.0.1'
const DEFAULT_ZEP_UDP_PORT = 17754

export default class Sniff extends Command {
static override args = {}
static override description = 'Sniff Zigbee traffic (to Wireshark, to custom handler or just log in file)'
static override examples = ['<%= config.bin %> <%= command.id %>']
static override flags = {}

public ezsp: Ezsp | undefined
public sequence: number = 0
public sniffing: boolean = false
public udpSocket: Socket | undefined
public wiresharkIPAddress: string = DEFAULT_WIRESHARK_IP_ADDRESS
public zepUDPPort: number = DEFAULT_ZEP_UDP_PORT

private customHandler: ((cmd: Command, logger: Logger, linkQuality: number, rssi: number, packetContents: Buffer) => void) | undefined

public async run(): Promise<void> {
// const { args, flags } = await this.parse(Sniff)
const portConf = await getPortConf()
logger.debug(`Using port conf: ${JSON.stringify(portConf)}`)

this.ezsp = await emberStart(portConf)
let exit: boolean = false

while (!exit) {
exit = await this.navigateMenu()

if (exit && this.sniffing) {
exit = await confirm({ message: 'Sniffing is currently running. Confirm exit?', default: false })
}
}

this.udpSocket?.close()
await emberStop(this.ezsp)

return this.exit(0)
}

private async menuStartSniffing(): Promise<boolean> {
if (!this.ezsp) {
logger.error(`Invalid state, no EZSP layer available.`)
return this.exit(1)
}

const sendToWireshark = await confirm({ message: 'Send to Wireshark?', default: true })

if (sendToWireshark) {
this.wiresharkIPAddress = await input({ message: 'Wireshark IP address', default: DEFAULT_WIRESHARK_IP_ADDRESS })
this.zepUDPPort = Number.parseInt(await input({ message: 'Wireshark ZEP UDP port', default: `${DEFAULT_ZEP_UDP_PORT}` }), 10)
this.udpSocket = createSocket('udp4')

this.udpSocket.bind(this.zepUDPPort)
}

// set desired tx power before scan
const radioTxPower = Number.parseInt(
await input({
default: '5',
message: 'Radio transmit power [-128-127]',
validate(value: string) {
if (/\./.test(value)) {
return false
}

const v = Number.parseInt(value, 10)

return v >= -128 && v <= 127
},
}),
10,
)

let status = await this.ezsp.ezspSetRadioPower(radioTxPower)

if (status !== SLStatus.OK) {
logger.error(`Failed to set transmit power to ${radioTxPower} status=${SLStatus[status]}.`)
return true
}

const channelChoices: { name: string; value: number }[] = []

for (const channel of ZSpec.ALL_802_15_4_CHANNELS) {
channelChoices.push({ name: channel.toString(), value: channel })
}

const channel = await select<number>({
choices: channelChoices,
message: 'Channel to sniff',
})
const eui64 = await this.ezsp.ezspGetEui64()
const deviceId = Number.parseInt(eui64.slice(-4), 16)

status = await this.ezsp.mfglibInternalStart(true)

if (status !== SLStatus.OK) {
logger.error(`Failed to start listening for packets with status=${SLStatus[status]}.`)
return true
}

status = await this.ezsp.mfglibInternalSetChannel(channel)

if (status !== SLStatus.OK) {
logger.error(`Failed to set channel with status=${SLStatus[status]}.`)
return true
}

this.sniffing = true

const handlerFile = join(DATA_FOLDER, `ezspMfglibRxHandler.mjs`)

if (existsSync(handlerFile)) {
try {
const importedScript = await import(pathToFileURL(handlerFile).toString())

if (typeof importedScript.default !== 'function') {
throw new TypeError(`Not a function.`)
}

this.customHandler = importedScript.default

logger.info(`Loaded custom handler.`)
} catch (error) {
logger.error(`Failed to load custom handler. ${error}`)
}
}

// XXX: this is currently not restored, but not a problem since only possible menu is exit
const ezspMfglibRxHandlerOriginal = this.ezsp.ezspMfglibRxHandler

this.ezsp.ezspMfglibRxHandler = (linkQuality: number, rssi: number, packetContents: Buffer): void => {

Check failure on line 152 in src/commands/sniff/index.ts

View workflow job for this annotation

GitHub Actions / unit-tests (ubuntu-latest, lts/-1)

Type '(linkQuality: number, rssi: number, packetContents: Buffer) => void' is not assignable to type '(linkQuality: number, rssi: number, packetLength: number, packetContents: number[]) => void'.

Check failure on line 152 in src/commands/sniff/index.ts

View workflow job for this annotation

GitHub Actions / unit-tests (ubuntu-latest, lts/*)

Type '(linkQuality: number, rssi: number, packetContents: Buffer) => void' is not assignable to type '(linkQuality: number, rssi: number, packetLength: number, packetContents: number[]) => void'.

Check failure on line 152 in src/commands/sniff/index.ts

View workflow job for this annotation

GitHub Actions / unit-tests (ubuntu-latest, latest)

Type '(linkQuality: number, rssi: number, packetContents: Buffer) => void' is not assignable to type '(linkQuality: number, rssi: number, packetLength: number, packetContents: number[]) => void'.

Check failure on line 152 in src/commands/sniff/index.ts

View workflow job for this annotation

GitHub Actions / unit-tests (windows-latest, lts/-1)

Type '(linkQuality: number, rssi: number, packetContents: Buffer) => void' is not assignable to type '(linkQuality: number, rssi: number, packetLength: number, packetContents: number[]) => void'.

Check failure on line 152 in src/commands/sniff/index.ts

View workflow job for this annotation

GitHub Actions / unit-tests (windows-latest, lts/*)

Type '(linkQuality: number, rssi: number, packetContents: Buffer) => void' is not assignable to type '(linkQuality: number, rssi: number, packetLength: number, packetContents: number[]) => void'.

Check failure on line 152 in src/commands/sniff/index.ts

View workflow job for this annotation

GitHub Actions / unit-tests (windows-latest, latest)

Type '(linkQuality: number, rssi: number, packetContents: Buffer) => void' is not assignable to type '(linkQuality: number, rssi: number, packetLength: number, packetContents: number[]) => void'.
if (this.customHandler) {
this.customHandler(this, logger, linkQuality, rssi, packetContents)
}

if (sendToWireshark) {
try {
const wsZEPFrame = createWiresharkZEPFrame(channel, deviceId, linkQuality, rssi, this.sequence, packetContents)
this.sequence += 1

if (this.sequence > 4294967295) {
// wrap if necessary...
this.sequence = 0
}

// expected valid if `sendToWireshark`
this.udpSocket!.send(wsZEPFrame, this.zepUDPPort, this.wiresharkIPAddress)
} catch (error) {
logger.debug(error)
}
} else if (!this.customHandler) {
// log as debug if nothing enabled
ezspMfglibRxHandlerOriginal(linkQuality, rssi, packetContents)

Check failure on line 174 in src/commands/sniff/index.ts

View workflow job for this annotation

GitHub Actions / unit-tests (ubuntu-latest, lts/-1)

Expected 4 arguments, but got 3.

Check failure on line 174 in src/commands/sniff/index.ts

View workflow job for this annotation

GitHub Actions / unit-tests (ubuntu-latest, lts/*)

Expected 4 arguments, but got 3.

Check failure on line 174 in src/commands/sniff/index.ts

View workflow job for this annotation

GitHub Actions / unit-tests (ubuntu-latest, latest)

Expected 4 arguments, but got 3.

Check failure on line 174 in src/commands/sniff/index.ts

View workflow job for this annotation

GitHub Actions / unit-tests (windows-latest, lts/-1)

Expected 4 arguments, but got 3.

Check failure on line 174 in src/commands/sniff/index.ts

View workflow job for this annotation

GitHub Actions / unit-tests (windows-latest, lts/*)

Expected 4 arguments, but got 3.

Check failure on line 174 in src/commands/sniff/index.ts

View workflow job for this annotation

GitHub Actions / unit-tests (windows-latest, latest)

Expected 4 arguments, but got 3.
}
}

logger.info(`Sniffing started.`)

return false
}

private async navigateMenu(): Promise<boolean> {
const answer = await select<-1 | SniffMenu>({
choices: [
{ name: 'Start sniffing', value: SniffMenu.START_SNIFFING, disabled: this.sniffing },
{ name: 'Exit', value: -1 },
],
message: 'Menu',
})

switch (answer) {
case SniffMenu.START_SNIFFING: {
return this.menuStartSniffing()
}
}

return true
}
}
11 changes: 9 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,15 @@ if (!existsSync(LOGS_FOLDER)) {
export const logger = createLogger({
format: format.combine(
format.errors({ stack: true }),
format.timestamp(), // new Date().toISOString()
format.printf((info) => `[${info.timestamp}] ${info.level}:\t${info.namespace ?? 'cli'}: ${info.message}`),
format.timestamp({
format:
new Date().toLocaleString(
'sv' /* uses ISO */,
// eslint-disable-next-line new-cap
{ timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone },
) + '.SSS',
}),
format.printf((info) => `[${info.timestamp}] ${info.level}: \t${info.namespace ?? 'cli'}: ${info.message}`),
),
levels: config.syslog.levels,
transports: [
Expand Down
107 changes: 107 additions & 0 deletions src/utils/wireshark.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/**
* @see https://github.com/wireshark/wireshark/blob/master/epan/dissectors/packet-zep.c
* @see https://github.com/wireshark/wireshark/blob/master/epan/dissectors/packet-ieee802154.c
* @see https://github.com/wireshark/wireshark/blob/master/epan/dissectors/packet-zbee-nwk.c
*------------------------------------------------------------
*
* ZEP Packets must be received in the following format:
* |UDP Header| ZEP Header |IEEE 802.15.4 Packet|
* | 8 bytes | 16/32 bytes | <= 127 bytes |
*------------------------------------------------------------
*
* ZEP v1 Header will have the following format:
* |Preamble|Version|Channel ID|Device ID|CRC/LQI Mode|LQI Val|Reserved|Length|
* |2 bytes |1 byte | 1 byte | 2 bytes | 1 byte |1 byte |7 bytes |1 byte|
*
* ZEP v2 Header will have the following format (if type=1/Data):
* |Preamble|Version| Type |Channel ID|Device ID|CRC/LQI Mode|LQI Val|NTP Timestamp|Sequence#|Reserved|Length|
* |2 bytes |1 byte |1 byte| 1 byte | 2 bytes | 1 byte |1 byte | 8 bytes | 4 bytes |10 bytes|1 byte|
*
* ZEP v2 Header will have the following format (if type=2/Ack):
* |Preamble|Version| Type |Sequence#|
* |2 bytes |1 byte |1 byte| 4 bytes |
*------------------------------------------------------------
*/
const PREAMBLE = 'EX'
const PROTOCOL_VERSION = 2
const PROTOCOL_TYPE = 1
/** Baseline NTP time if bit-0=0 -> 7-Feb-2036 @ 06:28:16 UTC */
const MSB_0_BASE_TIME = 2085978496000n
/** Baseline NTP time if bit-0=1 -> 1-Jan-1900 @ 01:00:00 UTC */
const MSB_1_BASE_TIME = -2208988800000n

const getZepTimestamp = (): bigint => {
const now = BigInt(Date.now())
const useBase1 = now < MSB_0_BASE_TIME // time < Feb-2036
// MSB_1_BASE_TIME: dates <= Feb-2036, MSB_0_BASE_TIME: if base0 needed for dates >= Feb-2036
const baseTime = now - (useBase1 ? MSB_1_BASE_TIME : MSB_0_BASE_TIME)
let seconds = baseTime / 1000n
const fraction = ((baseTime % 1000n) * 0x100000000n) / 1000n

if (useBase1) {
seconds |= 0x80000000n // set high-order bit if MSB_1_BASE_TIME 1900 used
}

return BigInt.asIntN(64, (seconds << 32n) | fraction)
}

export const createWiresharkZEPFrame = (
channelId: number,
deviceId: number,
lqi: number,
rssi: number,
sequence: number,
data: Buffer,
lqiMode: boolean = false,
): Buffer => {
const buffer = Buffer.alloc(167)
let offset = 0

// The IEEE 802.15.4 packet encapsulated in the ZEP frame must have the "TI CC24xx" format
// See figure 21 on page 24 of the CC2420 datasheet: https://www.ti.com/lit/ds/symlink/cc2420.pdf
// So, two bytes must be added at the end:
// * First byte: RSSI value as a signed 8 bits integer (range -128 to 127)
// * Second byte:
// - the most significant bit is set to 1 of the CRC of the frame is correct
// - the 7 least significant bits contain the LQI value as a unsigned 7 bits integer (range 0 to 127)
data[data.length - 2] = rssi
data[data.length - 1] = 0x80 | ((lqi >> 1) & 0x7f)

// Protocol ID String | Character string | 2.0.3 to 4.2.5
buffer.write(PREAMBLE, offset)
offset += 2

// Protocol Version | Unsigned integer (8 bits) | 1.2.0 to 4.2.5
buffer.writeUInt8(PROTOCOL_VERSION, offset++)
// Type | Unsigned integer (8 bits) | 1.2.0 to 1.8.15, 1.12.0 to 4.2.5
buffer.writeUInt8(PROTOCOL_TYPE, offset++)
// Channel ID | Unsigned integer (8 bits) | 1.2.0 to 4.2.5
buffer.writeUInt8(channelId, offset++)
// Device ID | Unsigned integer (16 bits) | 1.2.0 to 4.2.5
buffer.writeUint16BE(deviceId, offset)
offset += 2

// LQI/CRC Mode | Boolean | 1.2.0 to 4.2.5
buffer.writeUInt8(lqiMode ? 1 : 0, offset++)
// Link Quality Indication | Unsigned integer (8 bits) | 1.2.0 to 4.2.5
buffer.writeUInt8(lqi, offset++)

// Timestamp | Date and time | 1.2.0 to 4.2.5
buffer.writeBigInt64BE(getZepTimestamp(), offset)
offset += 8

// Sequence Number | Unsigned integer (32 bits) | 1.2.0 to 4.2.5
buffer.writeUint32BE(sequence, offset)
offset += 4

// Reserved Fields | Byte sequence | 2.0.0 to 4.2.5
offset += 10

// Length | Unsigned integer (8 bits) | 1.2.0 to 4.2.5
buffer.writeUInt8(data.length, offset++)

buffer.set(data, offset)
offset += data.length

return buffer.subarray(0, offset) // increased to "beyond last" above
}
1 change: 1 addition & 0 deletions test/commands/sniff/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
describe('sniff', () => {})

0 comments on commit 9b37a83

Please sign in to comment.