diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..30a9de6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +node_modules +npm-debug.log +.* \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ff467e9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM node:16 + +# Create app directory +WORKDIR /usr/src/app + +# Install app dependencies +COPY package*.json ./ +RUN npm install + +RUN apt-get update +RUN apt-get -y install \ + tcpdump + +# Bundle app source +COPY ./src/ . + +CMD ["npm", "run", "start"] \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..4e83226 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,266 @@ +{ + "name": "rfmon-to-influx", + "version": "1.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "rfmon-to-influx", + "version": "1.0.0", + "license": "AGPL-3.0", + "dependencies": { + "@influxdata/influxdb-client": "^1.20.0", + "@influxdata/influxdb-client-apis": "^1.20.0", + "log4js": "^6.3.0", + "luxon": "^2.1.1", + "string-argv": "^0.3.1" + } + }, + "node_modules/@influxdata/influxdb-client": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/@influxdata/influxdb-client/-/influxdb-client-1.20.0.tgz", + "integrity": "sha512-jaKSI63hmQ5VSkJrFJkYIXaKlhoF+mGd4HmOf7v/X7pmEi69ReHp922Wyx6/OeCrpndRMbsadk+XmGNdd43cFw==" + }, + "node_modules/@influxdata/influxdb-client-apis": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/@influxdata/influxdb-client-apis/-/influxdb-client-apis-1.20.0.tgz", + "integrity": "sha512-KMTmXH4rbpS+NWGpqDjxcKTyan2rbiT2IM5AdRElKhH2sHbH96xwLgziaxeC+OCJLeNAdehJgae3I8WiZjbwdg==", + "peerDependencies": { + "@influxdata/influxdb-client": "*" + } + }, + "node_modules/date-format": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/date-format/-/date-format-3.0.0.tgz", + "integrity": "sha512-eyTcpKOcamdhWJXj56DpQMo1ylSQpcGtGKXcU0Tb97+K56/CF5amAqqqNj0+KvA0iw2ynxtHWFsPDSClCxe48w==", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/debug": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz", + "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/flatted": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-2.0.2.tgz", + "integrity": "sha512-r5wGx7YeOwNWNlCA0wQ86zKyDLMQr+/RB8xy74M4hTphfmjlijTSSXGuH8rnvKZnfT9i+75zmd8jcKdMR4O6jA==" + }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.8.tgz", + "integrity": "sha512-qkIilPUYcNhJpd33n0GBXTB1MMPp14TxEsEs0pTrsSVucApsYzW5V+Q8Qxhik6KU3evy+qkAAowTByymK0avdg==" + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/log4js": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/log4js/-/log4js-6.3.0.tgz", + "integrity": "sha512-Mc8jNuSFImQUIateBFwdOQcmC6Q5maU0VVvdC2R6XMb66/VnT+7WS4D/0EeNMZu1YODmJe5NIn2XftCzEocUgw==", + "dependencies": { + "date-format": "^3.0.0", + "debug": "^4.1.1", + "flatted": "^2.0.1", + "rfdc": "^1.1.4", + "streamroller": "^2.2.4" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/luxon": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-2.1.1.tgz", + "integrity": "sha512-6VQVNw7+kQu3hL1ZH5GyOhnk8uZm21xS7XJ/6vDZaFNcb62dpFDKcH8TI5NkoZOdMRxr7af7aYGrJlE/Wv0i1w==", + "engines": { + "node": ">=12" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/rfdc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.3.0.tgz", + "integrity": "sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==" + }, + "node_modules/streamroller": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/streamroller/-/streamroller-2.2.4.tgz", + "integrity": "sha512-OG79qm3AujAM9ImoqgWEY1xG4HX+Lw+yY6qZj9R1K2mhF5bEmQ849wvrb+4vt4jLMLzwXttJlQbOdPOQVRv7DQ==", + "dependencies": { + "date-format": "^2.1.0", + "debug": "^4.1.1", + "fs-extra": "^8.1.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/streamroller/node_modules/date-format": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/date-format/-/date-format-2.1.0.tgz", + "integrity": "sha512-bYQuGLeFxhkxNOF3rcMtiZxvCBAquGzZm6oWA1oZ0g2THUzivaRhv8uOhdr19LmoobSOLoIAxeUK2RdbM8IFTA==", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/string-argv": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.1.tgz", + "integrity": "sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg==", + "engines": { + "node": ">=0.6.19" + } + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "engines": { + "node": ">= 4.0.0" + } + } + }, + "dependencies": { + "@influxdata/influxdb-client": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/@influxdata/influxdb-client/-/influxdb-client-1.20.0.tgz", + "integrity": "sha512-jaKSI63hmQ5VSkJrFJkYIXaKlhoF+mGd4HmOf7v/X7pmEi69ReHp922Wyx6/OeCrpndRMbsadk+XmGNdd43cFw==" + }, + "@influxdata/influxdb-client-apis": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/@influxdata/influxdb-client-apis/-/influxdb-client-apis-1.20.0.tgz", + "integrity": "sha512-KMTmXH4rbpS+NWGpqDjxcKTyan2rbiT2IM5AdRElKhH2sHbH96xwLgziaxeC+OCJLeNAdehJgae3I8WiZjbwdg==", + "requires": {} + }, + "date-format": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/date-format/-/date-format-3.0.0.tgz", + "integrity": "sha512-eyTcpKOcamdhWJXj56DpQMo1ylSQpcGtGKXcU0Tb97+K56/CF5amAqqqNj0+KvA0iw2ynxtHWFsPDSClCxe48w==" + }, + "debug": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz", + "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==", + "requires": { + "ms": "2.1.2" + } + }, + "flatted": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-2.0.2.tgz", + "integrity": "sha512-r5wGx7YeOwNWNlCA0wQ86zKyDLMQr+/RB8xy74M4hTphfmjlijTSSXGuH8rnvKZnfT9i+75zmd8jcKdMR4O6jA==" + }, + "fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "requires": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + } + }, + "graceful-fs": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.8.tgz", + "integrity": "sha512-qkIilPUYcNhJpd33n0GBXTB1MMPp14TxEsEs0pTrsSVucApsYzW5V+Q8Qxhik6KU3evy+qkAAowTByymK0avdg==" + }, + "jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=", + "requires": { + "graceful-fs": "^4.1.6" + } + }, + "log4js": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/log4js/-/log4js-6.3.0.tgz", + "integrity": "sha512-Mc8jNuSFImQUIateBFwdOQcmC6Q5maU0VVvdC2R6XMb66/VnT+7WS4D/0EeNMZu1YODmJe5NIn2XftCzEocUgw==", + "requires": { + "date-format": "^3.0.0", + "debug": "^4.1.1", + "flatted": "^2.0.1", + "rfdc": "^1.1.4", + "streamroller": "^2.2.4" + } + }, + "luxon": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-2.1.1.tgz", + "integrity": "sha512-6VQVNw7+kQu3hL1ZH5GyOhnk8uZm21xS7XJ/6vDZaFNcb62dpFDKcH8TI5NkoZOdMRxr7af7aYGrJlE/Wv0i1w==" + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "rfdc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.3.0.tgz", + "integrity": "sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==" + }, + "streamroller": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/streamroller/-/streamroller-2.2.4.tgz", + "integrity": "sha512-OG79qm3AujAM9ImoqgWEY1xG4HX+Lw+yY6qZj9R1K2mhF5bEmQ849wvrb+4vt4jLMLzwXttJlQbOdPOQVRv7DQ==", + "requires": { + "date-format": "^2.1.0", + "debug": "^4.1.1", + "fs-extra": "^8.1.0" + }, + "dependencies": { + "date-format": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/date-format/-/date-format-2.1.0.tgz", + "integrity": "sha512-bYQuGLeFxhkxNOF3rcMtiZxvCBAquGzZm6oWA1oZ0g2THUzivaRhv8uOhdr19LmoobSOLoIAxeUK2RdbM8IFTA==" + } + } + }, + "string-argv": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.1.tgz", + "integrity": "sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg==" + }, + "universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..309915b --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "rfmon-to-influx", + "version": "1.0.0", + "description": "Writing (mostly meta-) data received in Wireless-Monitor-Mode into an InfluxDB", + "main": "main.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "start": "node main.js" + }, + "repository": { + "type": "git", + "url": "https://gitea.ruekov.eu/Ruakij/rfmon-to-influx" + }, + "author": "Ruakij", + "license": "AGPL-3.0", + "dependencies": { + "@influxdata/influxdb-client": "^1.20.0", + "@influxdata/influxdb-client-apis": "^1.20.0", + "log4js": "^6.3.0", + "luxon": "^2.1.1", + "string-argv": "^0.3.1" + } +} diff --git a/src/dto/Packet.js b/src/dto/Packet.js new file mode 100644 index 0000000..712daea --- /dev/null +++ b/src/dto/Packet.js @@ -0,0 +1,105 @@ +const PacketType = { + Beacon: 'Beacon', + ProbeRequest: 'ProbeRequest', + ProbeResponse: 'ProbeResponse', + Data: 'Data', + RequestToSend: 'RequestToSend', + ClearToSend: 'ClearToSend', + Acknowledgment: 'Acknowledgment', + BlockAcknowledgment: 'BlockAcknowledgment', + NoData: 'NoData', + Authentication: 'Authentication', + AssociationRequest: 'AssociationRequest', + AssociationResponse: 'AssociationResponse', + Disassociation: 'Disassociation', + Handshake: 'Handshake', + Unknown: 'Unknown' +} + +const FlagType = { + MoreFragments: "MoreFragments", + Retry: "Retry", + PwrMgt: "PwrMgt", + MoreData: "MoreData", + Protected: "Protected", + Order: "Order" +} + +class Packet{ + timestampMicros; + + flags = {}; + + srcMac; + dstMac; + bssid; + + signal; + frequency; + dataRate; + + durationMicros; + + payloadData; + get payloadSize(){ + return this.payloadData.length; + } + + packetType; +} + +// Extensions of Packet +class PacketWithSSID extends Packet{ + ssid; +} + +class BeaconPacket extends PacketWithSSID{} +class ProbeRequestPacket extends PacketWithSSID{} +class ProbeResponsePacket extends PacketWithSSID{} + +const AuthenticationType = { + OpenSystem_1: 'OpenSystem_1', + OpenSystem_2: 'OpenSystem_2', + Unknown: 'Unknown', +} +class AuthenticationPacket extends Packet{ + authenticationType; +} + +class AssociationRequestPacket extends PacketWithSSID{} +class AssociationResponsePacket extends Packet{ + associationIsSuccessful; +} + +class DisassociationPacket extends Packet{ + disassociationReason; +} + + +const HandshakeStage = { + 1: '1', + 2: '2', + 3: '3', + 4: '4' +} +class HandshakePacket extends Packet{ + handshakeStage; +} + +// Specify exports +module.exports = { + PacketType, + FlagType, + Packet, + PacketWithSSID, + BeaconPacket, + ProbeRequestPacket, + ProbeResponsePacket, + AuthenticationType, + AuthenticationPacket, + AssociationRequestPacket, + AssociationResponsePacket, + DisassociationPacket, + HandshakeStage, + HandshakePacket, +}; diff --git a/src/helper/env.js b/src/helper/env.js new file mode 100644 index 0000000..08bff89 --- /dev/null +++ b/src/helper/env.js @@ -0,0 +1,13 @@ +function requireEnvVars(requiredEnv){ + // Ensure required ENV vars are set + let unsetEnv = requiredEnv.filter((env) => !(typeof process.env[env] !== 'undefined')); + + if (unsetEnv.length > 0) { + return "Required ENV variables are not set: [" + unsetEnv.join(', ') + "]"; + } +} + +// Specify exports +module.exports = { + requireEnvVars +}; \ No newline at end of file diff --git a/src/helper/exec.js b/src/helper/exec.js new file mode 100644 index 0000000..ed7f628 --- /dev/null +++ b/src/helper/exec.js @@ -0,0 +1,18 @@ +const logger = require("./logger.js")("exec"); + +const { spawn } = require("child_process"); +const { parseArgsStringToArgv } = require('string-argv'); + + +function exec(cmd, options){ + const [bin, ...args] = parseArgsStringToArgv(cmd); + + logger.addContext("binary", "bin"); + logger.debug(`Spawn process '${cmd}'`); + return spawn(bin, args, options); +} + +// Specify exports +module.exports = { + exec +}; \ No newline at end of file diff --git a/src/helper/hexConverter.js b/src/helper/hexConverter.js new file mode 100644 index 0000000..00c2e8c --- /dev/null +++ b/src/helper/hexConverter.js @@ -0,0 +1,24 @@ +// From https://stackoverflow.com/a/34356351 + +// Convert a hex string to a byte array +function hexToBytes(hex) { + for (var bytes = [], c = 0; c < hex.length; c += 2) + bytes.push(parseInt(hex.substr(c, 2), 16)); + return bytes; +} + +// Convert a byte array to a hex string +function bytesToHex(bytes) { + for (var hex = [], i = 0; i < bytes.length; i++) { + var current = bytes[i] < 0 ? bytes[i] + 256 : bytes[i]; + hex.push((current >>> 4).toString(16)); + hex.push((current & 0xF).toString(16)); + } + return hex.join(""); +} + +// Specify exports +module.exports = { + hexToBytes, + bytesToHex +} \ No newline at end of file diff --git a/src/helper/influx-checks.js b/src/helper/influx-checks.js new file mode 100644 index 0000000..2c77ede --- /dev/null +++ b/src/helper/influx-checks.js @@ -0,0 +1,61 @@ +const logger = require.main.require("./helper/logger.js")("influx-checks"); + +const Os = require("os"); +const { InfluxDB, Point } = require('@influxdata/influxdb-client') +const Influx = require('@influxdata/influxdb-client-apis'); + + +function checkHealth(influxDb){ + return new Promise((resolve, reject) => { + new Influx.HealthAPI(influxDb) // Check influx health + .getHealth() + .catch((err) => { + logger.error("Could not communicate with Influx:"); + logger.error(`Error [${err.code}]:`, err.message); + reject(); + }) + .then((res) => { + logger.debug("Server healthy.", "Version: ", res.version); + resolve(res); + }); + }); +} + +function checkBucket(influxDb, options){ + return new Promise((resolve, reject) => { + new Influx.BucketsAPI(influxDb).getBuckets(options) + .catch((err) => { // Weirdly the influx-Api returns 404 for searches of non-existing buckets + logger.error("Could not get bucket:"); + logger.error(`Error [${err.code}]:`, err.message); + reject(); + }).then((res) => { // But an empty list when the bucket exists, but token does not have permission to get details + logger.debug("Bucket found"); + resolve(res); + // Now we know the bucket exists and we have some kind of permission.. but we still dont know if we are able to write to it.. + }); + }); +} + +function checkWriteApi(influxDb, options){ + return new Promise((resolve, reject) => { + const writeApi = influxDb.getWriteApi(options.org, options.bucket); // Get WriteAPI + writeApi.writePoint(new Point("worker_connectionTest").tag("hostname", Os.hostname())) // Write point + writeApi.close() + .catch((err) => { + logger.error("Could not get writeApi:"); + logger.error(`Error [${err.code}]:`, err.message); + reject(); + }).then((res) => { + logger.debug("Writing ok"); + resolve(); + }); + }); +} + + +// Specify exports +module.exports = { + checkHealth, + checkBucket, + checkWriteApi, +}; \ No newline at end of file diff --git a/src/helper/logger.js b/src/helper/logger.js new file mode 100644 index 0000000..6e61ab0 --- /dev/null +++ b/src/helper/logger.js @@ -0,0 +1,11 @@ +const log4js = require("log4js"); + + +function setup(category = "unknown"){ + const logger = log4js.getLogger(category); + logger.level = process.env.LOGLEVEL ?? "INFO"; + return logger; +} + +// Specify exports +module.exports = setup; \ No newline at end of file diff --git a/src/helper/userHelper.js b/src/helper/userHelper.js new file mode 100644 index 0000000..50a1624 --- /dev/null +++ b/src/helper/userHelper.js @@ -0,0 +1,46 @@ +// This file specifies functions to help a user with e.g. configuration-errors + +function detectStreamData(stream, timeout = 5000){ + return new Promise((resolve, reject) => { + let timeoutHandler; + if(timeout){ + timeoutHandler = setTimeout(() => { + reject('timeout'); + remListeners(); + }, + timeout); + } + + function remListeners(){ + stream.removeListener('error', errorHandler); + stream.removeListener('data', dataHandler); + if(timeoutHandler) clearTimeout(timeoutHandler); + } + + function errorHandler(err) { + remListeners(); + } + function dataHandler(data) { + resolve(data); + remListeners(); + } + + stream.on('error', errorHandler); + stream.on('data', dataHandler); + }); +} + +function detectStreamsData(streams, timeout = 5000){ + let promises = []; + streams.forEach((stream) => { + promises.push(detectStreamData(stream, timeout)); + }) + return promises; +} + + +// Specify exports +module.exports = { + detectStreamData, + detectStreamsData, +}; \ No newline at end of file diff --git a/src/helper/wifiStateAnalyzer.js b/src/helper/wifiStateAnalyzer.js new file mode 100644 index 0000000..e2622de --- /dev/null +++ b/src/helper/wifiStateAnalyzer.js @@ -0,0 +1,43 @@ +const { HandshakeStage } = require.main.require('./dto/Packet.js'); + +function keyInfoFromRaw(keyInfoRaw) { + return { + "KeyDescriptorVersion": keyInfoRaw>>0 & 0b111, + "KeyType": keyInfoRaw>>3 & 0b1, + "KeyIndex": keyInfoRaw>>4 & 0b11, + "Install": keyInfoRaw>>6 & 0b1, + "KeyACK": keyInfoRaw>>7 & 0b1, + "KeyMIC": keyInfoRaw>>8 & 0b1, + "Secure": keyInfoRaw>>9 & 0b1, + "Error": keyInfoRaw>>10 & 0b1, + "Request": keyInfoRaw>>11 & 0b1, + "EncryptedKeyData": keyInfoRaw>>12 & 0b1, + "SMKMessage": keyInfoRaw>>13 & 0b1, + }; +} + +const HANDSHAKE_STAGE_KEYINFO = { + "keys": ["Install", "KeyACK", "KeyMIC", "Secure"], + "0100": HandshakeStage[1], + "0010": HandshakeStage[2], + "1111": HandshakeStage[3], + "0011": HandshakeStage[4], +}; +function handshakeStageFromKeyInfo(keyInfo){ + + // Extract compare-keys + let keyData = ""; + for (const key of HANDSHAKE_STAGE_KEYINFO['keys']) { + keyData += keyInfo[key].toString(); + } + + // Get and return stage + return HANDSHAKE_STAGE_KEYINFO[keyData]; +} + + +// Specify exports +module.exports = { + keyInfoFromRaw, + handshakeStageFromKeyInfo, +}; \ No newline at end of file diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..d471c8f --- /dev/null +++ b/src/main.js @@ -0,0 +1,131 @@ +"use strict"; +const logFactory = require("./helper/logger.js"); +const logger = logFactory("main"); + +const { requireEnvVars } = require("./helper/env.js"); +const { exit } = require("process"); +const { exec } = require("./helper/exec.js"); + +const { InfluxDB } = require('@influxdata/influxdb-client'); +const InfluxChecks = require('./helper/influx-checks.js'); + +const { RegexBlockStream } = require("./streamHandler/RegexBlockStream.js"); +const { PacketStreamFactory } = require("./streamHandler/PacketStreamFactory.js"); +const { PacketInfluxPointFactory } = require("./streamHandler/PacketInfluxPointFactory.js"); +const { InfluxPointWriter } = require("./streamHandler/InfluxPointWriter.js"); + +const userHelper = require("./helper/userHelper.js"); + + +/// Setup ENVs +const env = process.env; +// Defaults +{ + env.LOGLEVEL ??= "INFO"; + env.WIFI_INTERFACE ??= "wlan0"; +} +// Required vars +let errorMsg = requireEnvVars([ + "INFLUX_URL", "INFLUX_TOKEN", + "INFLUX_ORG", "INFLUX_BUCKET" +]); +if(errorMsg){ + logger.fatal(errorMsg); + exit(1); +} + +(async function() { + logger.info("Setup Influx.."); + const influxDb = new InfluxDB({url: env.INFLUX_URL, token: env.INFLUX_TOKEN}); + + await InfluxChecks.checkHealth(influxDb) + .then((res) => {return InfluxChecks.checkBucket(influxDb, { + org: env.INFLUX_ORG, + name: env.INFLUX_BUCKET + })}) + .then((res) => {return InfluxChecks.checkWriteApi(influxDb, { + org: env.INFLUX_ORG, + bucket: env.INFLUX_BUCKET + })}) + .catch((err) => { + if(err) { + logger.error("Error whilst checking influx:"); + logger.error(err); + } + logger.fatal("Setup influx failed!"); + exit(1); + }); + + logger.info("Influx ok"); + + logger.info("Starting tcpdump.."); + const TCPDUMP_BASECMD = "tcpdump -vvv -e -n -X -s0 -i" + let cmd = `${TCPDUMP_BASECMD} ${env.WIFI_INTERFACE}`; + + let proc = exec(cmd); + logger.debug("Creating & Attaching streams.."); + let regexBlockStream = new RegexBlockStream(/^\d{2}:\d{2}:\d{2}.\d{6}.*(\n( {4,8}|\t\t?).*)+\n/gm); + let packetStreamFactory = new PacketStreamFactory(); + let packetInfluxPointFactory = new PacketInfluxPointFactory(); + let influxPointWriter = new InfluxPointWriter(influxDb, env.INFLUX_ORG, env.INFLUX_BUCKET); + proc.stdout + .setEncoding("utf8") + .pipe(regexBlockStream) + .pipe(packetStreamFactory) + .pipe(packetInfluxPointFactory) + .pipe(influxPointWriter); + + logger.debug("Attaching error-logger.."); + const loggerTcpdump = logFactory("tcpdump"); + proc.stderr.setEncoding("utf8").on("data", (data) => { + if(!data.match(/^(tcpdump: )?listening on /i) || !data.match(/^\d+ packets captured/i)) { // Catch start-error + loggerTcpdump.debug(data); + } + else loggerTcpdump.error(data); + }); + + // FIXME: This is a hacky workaround to not let errors from subprocess bubble up and terminate our process + regexBlockStream.on('error', (err) => {}); + + proc.on("error", (err) => { + loggerTcpdump.error(err); + }); + + const loggerPacketStream = logFactory("PacketStreamFactory"); + userHelper.detectStreamData(proc.stdout, 10000) // Expect tcpdump-logs to have data after max. 10s + .then(() => { + loggerTcpdump.debug("Got first data"); + userHelper.detectStreamData(packetStreamFactory, 10000) // Expect then to have packets after further 10s + .then(() => { + loggerPacketStream.debug("Got first packet"); + }) + .catch((err) => { + if(err == 'timeout') loggerPacketStream.warn("No packets"); + }); + }) + .catch((err) => { + if(err == 'timeout') loggerTcpdump.warn("No data after 10s! Wrong configuration?"); + }); + + logger.debug("Attaching exit-handler.."); + proc.on("exit", (code) => { + loggerTcpdump.debug(`tcpdump exited code: ${code}`); + if (code) { + loggerTcpdump.fatal(`tcpdump exited with non-zero code: ${code}`); + exit(1); + } + logger.info("Shutdown"); + exit(0); + }); + + // Handle stop-signals for graceful shutdown + function shutdownReq() { + logger.info("Shutdown request received.."); + logger.debug("Stopping subprocess tcpdump, then exiting myself.."); + proc.kill(); // Kill process (send SIGTERM), then upper event-handler will stop self + } + process.on('SIGTERM', shutdownReq); + process.on('SIGINT', shutdownReq); + + logger.info("Startup complete"); +})(); diff --git a/src/streamHandler/InfluxPointWriter.js b/src/streamHandler/InfluxPointWriter.js new file mode 100644 index 0000000..9bd96ad --- /dev/null +++ b/src/streamHandler/InfluxPointWriter.js @@ -0,0 +1,38 @@ +const logger = require.main.require("./helper/logger.js")("InfluxPointWriter"); +const { Writable } = require('stream'); +const {InfluxDB, Point, HttpError} = require('@influxdata/influxdb-client') + +/** + * Get points and write them into influx + */ +class InfluxPointWriter extends Writable{ + /** + * + * @param {InfluxDB} influxDb InfluxDb + * @param {string} org Organization to use + * @param {string} bucket Bucket to use + * @param {Partial} options Options for WriteApi + */ + constructor(influxDb, org, bucket, options){ + super({ + objectMode: true + }); + this._api = influxDb.getWriteApi(org, bucket, 'us', options); + } + + _write(point, encoding, next){ + this._api.writePoint(point); + next(); + } + + _flush(next){ + this._api.flush(true) + .catch((err) => { next(new Error(`WriteApi rejected promise for flush: ${err}`)); }) + .then(next); + } +} + +// Specify exports +module.exports = { + InfluxPointWriter +}; \ No newline at end of file diff --git a/src/streamHandler/PacketInfluxPointFactory.js b/src/streamHandler/PacketInfluxPointFactory.js new file mode 100644 index 0000000..e21b7b0 --- /dev/null +++ b/src/streamHandler/PacketInfluxPointFactory.js @@ -0,0 +1,85 @@ +const logger = require.main.require("./helper/logger.js")("PacketStreamFactory"); +const { Transform } = require('stream'); +const {Point} = require('@influxdata/influxdb-client') + +/** Keys to always use as tags */ +const TAG_LIST = [ + "srcMac", + "dstMac", + "bssid", + "frequency", + "flags", + "packetType", +]; + +/** Measurement-name and corresponding field-key */ +const MEASUREMENT_MAP = new Map([ + ["Signal", "signal"], + ["PayloadSize", "payloadSize"], + ["DataRate", "dataRate"], + ["SSID", "ssid"], + ["AuthenticationType", "authenticationType"], + ["AssociationSuccess", "associationIsSuccessful"], + ["DisassociationReason", "disassociationReason"], +]); + + +/** + * Get packets and convert them into influx-points + */ +class PacketInfluxPointFactory extends Transform{ + constructor(){ + super({ + readableObjectMode: true, + writableObjectMode: true + }); + } + + _transform(packet, encoding, next){ + // Create measurements + MEASUREMENT_MAP.forEach((objKey, measurement) => { + if(packet[objKey] == null) return; + + let point = new Point(measurement); // Create point + + // Set tags + TAG_LIST.filter(tag => Object.keys(packet).includes(tag)) // Filter tags available on object + .filter(tag => packet[tag] != null) // Filter tags not falsy on object + .forEach(tag => { + tagObjectRecursively(point, tag, packet[tag]); + }); + + point.setField('value', packet[objKey]); // Set field + + this.push(point); // Push point into stream + }); + + next(); // Get next packet + } +} + +function tagObjectRecursively(point, tag, field, suffix = ""){ + if(typeof(field) == "object"){ + // TODO: Convert boolean-arrays like "packet.flags" to key: value + Object.entries(field).map(([key, value]) => { + tagObjectRecursively(point, tag, value, `_${key}${suffix}`); + }); + } + else point.tag(tag+suffix, field); +} + +/** Mapping for type -> field-method */ +const POINT_FIELD_TYPE = new Map([ + ['boolean', function(key, value){ return this.booleanField(key, value); }], + ['number', function(key, value){ return this.intField(key, value); }], + ['string', function(key, value){ return this.stringField(key, value); }], +]); +Point.prototype.setField = function(key, value){ + let setField = POINT_FIELD_TYPE.get(typeof value); + return setField.apply(this, [key, value]); +} + +// Specify exports +module.exports = { + PacketInfluxPointFactory +}; \ No newline at end of file diff --git a/src/streamHandler/PacketStreamFactory.js b/src/streamHandler/PacketStreamFactory.js new file mode 100644 index 0000000..9d7c866 --- /dev/null +++ b/src/streamHandler/PacketStreamFactory.js @@ -0,0 +1,152 @@ +const logger = require.main.require("./helper/logger.js")("PacketStreamFactory"); +const { Transform } = require('stream'); +const { DateTime } = require("luxon"); +const { PacketType, FlagType, Packet, PacketWithSSID, BeaconPacket, ProbeRequestPacket, ProbeResponsePacket, AuthenticationPacket, AuthenticationType, AssociationResponsePacket, DisassociationPacket, HandshakePacket, HandshakeStage } = require.main.require('./dto/Packet.js'); +const hexConv = require.main.require("./helper/hexConverter.js"); +const wifiStateAnalyser = require.main.require("./helper/wifiStateAnalyzer.js"); + +const PACKET_TYPE_MAP = { + "Beacon": PacketType.Beacon, + "Probe Request": PacketType.ProbeRequest, + "Probe Response": PacketType.ProbeResponse, + "Data": PacketType.Data, + "Request-To-Send": PacketType.RequestToSend, + "Clear-To-Send": PacketType.ClearToSend, + "Acknowledgment": PacketType.Acknowledgment, + "BA": PacketType.BlockAcknowledgment, + "Authentication": PacketType.Authentication, + "Assoc Request": PacketType.AssociationRequest, + "Assoc Response": PacketType.AssociationResponse, + "Disassociation:": PacketType.Disassociation, + "EAPOL": PacketType.Handshake, +}; +const PACKET_TYPES_REGEX = Object.keys(PACKET_TYPE_MAP).join('|'); + +const AUTHENTICATION_TYPE_MAP = { + "(Open System)-1": AuthenticationType.OpenSystem_1, + "(Open System)-2": AuthenticationType.OpenSystem_2, +} + +const FLAG_TYPE_MAP = { + "Retry": FlagType.Retry, + "Pwr Mgmt": FlagType.PwrMgt, + "More Data": FlagType.MoreData, + "Protected": FlagType.Protected, +} +const FLAG_TYPE_MAPS_REGEX = Object.keys(FLAG_TYPE_MAP).join('|'); + +/** + * Read data from text-blocks and convert them to Packet + */ +class PacketStreamFactory extends Transform{ + constructor(){ + super({ + readableObjectMode: true, + writableObjectMode: true + }); + } + + _transform(chunk, encoding, next){ + let packet = new Packet(); + + const lines = chunk.split('\n'); + const header = lines.splice(0, 1)[0]; // Grab first line, 'lines' is now the payload + packet = this._handleHeader(packet, header); + packet = this._handlePayload(packet, lines); + + next(null, packet); // Get next chunk + } + + _handleHeader(packet, data){ + // Convert time to epoch-micros Unfortunately luxon doesnt use micros, but millis as smallest time-unit requiring some "hacks" + packet.timestampMicros = DateTime.fromISO(data.slice(0, 12)).toSeconds() + data.slice(12, 15)/1000000; + + // Find flags + data.match(data.match(new RegExp('(?<=^|\\s)('+ FLAG_TYPE_MAPS_REGEX +')(?=$|\\s)', 'ig')) + ?.forEach(match => packet.flags[FLAG_TYPE_MAP[match]] = true) // Set them to true in flags + ); + + packet.dataRate = Number(data.match(/(?<=^|\s)\d+(\.\d+)?(?=\sMb\/?s($|\s))/i)?.[0]) || null; + packet.frequency = Number(data.match(/(?<=^|\s)\d{4}(?=\sMHz($|\s))/i)?.[0]) || null; + + packet.durationMicros = Number(data.match(/(?<=^|\s)\d{1,4}(?=us($|\s))/i)?.[0]) || null; + + packet.signal = Number(data.match(/(?<=^|\s)-\d{2,3}(?=dBm\sSignal($|\s))/i)?.[0]) || null; + + let packetTypeStr = data.match(new RegExp('(?<=^|\\s)('+ PACKET_TYPES_REGEX +')(?=$|\\s)', 'i'))?.[0]; + if(packetTypeStr) + packet.packetType = PACKET_TYPE_MAP[packetTypeStr]; + else if(data.match(/(SA|TA|DA|RA|BSSID):.{17}\s*$/i)){ + packet.packetType = PacketType.NoData + } + else { + packet.packetType = PacketType.Unknown; + } + + packet.srcMac = data.match(/(?<=(^|\s)(SA|TA):).{17}(?=$|\s)/i)?.[0] ?? null; + + packet.dstMac = data.match(/(?<=(^|\s)(DA|RA):).{17}(?=$|\s)/i)?.[0] ?? null; + + packet.bssid = data.match(/(?<=(^|\s)BSSID:).{17}(?=$|\s)/i)?.[0] ?? null; + + // Cover special cases with more data + let newPacket; + switch(packet.packetType){ + case PacketType.Beacon: + case PacketType.ProbeRequest: + case PacketType.ProbeResponse: + case PacketType.AssociationRequest: + newPacket = new PacketWithSSID(); + newPacket.ssid = data.match(new RegExp('(?<=(^|\\s)'+ packetTypeStr +'\\s\\().{0,32}(?=\\)($|\\s))', 'i'))?.[0] ?? null; + break; + + case PacketType.Authentication: + newPacket = new AuthenticationPacket(); + newPacket.authenticationType = AUTHENTICATION_TYPE_MAP[data.match(/(?<=(^|\s)Authentication\s).{3,}(?=\:(\s|$))/i)[0]] ?? AuthenticationType.Unknown; + break; + + case PacketType.AssociationResponse: + newPacket = new AssociationResponsePacket(); + newPacket.associationIsSuccessful = data.match(/(?<=(^|\s)Assoc\sResponse\s.{0,30})Successful(?=\s|$)/i) ? true : false; + break; + + case PacketType.Disassociation: + newPacket = new DisassociationPacket(); + newPacket.disassociationReason = data.match(/(?<=(^|\s)Disassociation:\s).*$/i)?.[0] ?? null; + break; + } + if(newPacket) packet = Object.assign(newPacket, packet); // Use new, more specific, packet and copy old data over + + return packet; + } + + _handlePayload(packet, data){ + data = data.join(''); + + // Get payload-Hex-Data. If there is no data: empty + packet.payloadData = hexConv.hexToBytes(data.match(/(?<=\s)([A-F0-9]{1,4}(?=\s))/igm)?.join('') ?? ''); + packet.payloadData.splice(packet.payloadData.length-4, 4); // Remove FrameCheck sequence + + // Cover special cases with more data + let newPacket; + switch(packet.packetType){ + case PacketType.Handshake: + newPacket = new HandshakePacket(); + + // Read key-information + const keyInfoRaw = (packet.payloadData[0x5]<<0x8) + packet.payloadData[0x6]; + const keyInfo = wifiStateAnalyser.keyInfoFromRaw(keyInfoRaw); // Convert + + newPacket.handshakeStage = wifiStateAnalyser.handshakeStageFromKeyInfo(keyInfo); // Get stage + break; + } + if(newPacket) packet = Object.assign(newPacket, packet); + + return packet; + } +} + +// Specify exports +module.exports = { + PacketStreamFactory +}; \ No newline at end of file diff --git a/src/streamHandler/RegexBlockStream.js b/src/streamHandler/RegexBlockStream.js new file mode 100644 index 0000000..30d7646 --- /dev/null +++ b/src/streamHandler/RegexBlockStream.js @@ -0,0 +1,67 @@ +const logger = require.main.require("./helper/logger.js")("RegexBlockStream"); +const { Transform } = require('stream') + +/** + * Matches whole blocks as regex and passes them on + */ +class RegexBlockStream extends Transform{ + matcher; + withholdLastBlock; + matchAllOnFlush; + + /** + * @param {RegExp} matcher Block-match + * @param {boolean} withholdLastBlock When true, the last matches block will not be submitted to prevent submitting incomplete blocks. + * @param {boolean} matchAllOnFlush (Only in combination with withholdLastBlock) When enabled, the buffer will be matched on last time on _flush (stream deconstruction) and write any, also incomplete, blocks + * @remarks WARNING: It should match a clean-block (including e.g. newline)! Otherwise buffer will get dirty and use more and more resources. + */ + constructor(matcher, withholdLastBlock = true, matchAllOnFlush = false){ + super({ + readableObjectMode: true, + writableObjectMode: true + }); + + this.matcher = matcher; + this.withholdLastBlock = withholdLastBlock; + this.matchAllOnFlush = matchAllOnFlush; + } + + _transform(chunk, encoding, next){ + chunk = this.readableBuffer.length? this.readableBuffer.join('') + chunk: chunk; // Add previous buffer to current chunk + this.readableBuffer.length && this.readableBuffer.clear(); // Clear buffer once we read it + + let matches = chunk.match(this.matcher); // Match + if(matches){ + if(this.withholdLastBlock) matches.pop(); // Remove last if we want to withhold it + chunk = this._writeMatches(matches, chunk); + } + + this.readableBuffer.push(chunk); // Store remaining data in buffer + next(); // Get next chunk + } + + _writeMatches(matches, chunk = null){ + if(matches){ + matches.forEach((match) => { + this.push(match); // Write match to stream + if(chunk) chunk = chunk.replace(match, ''); // Remove match from chunks + }); + } + if(chunk) return chunk; + } + + _flush(next){ + if(matchAllOnFlush){ // When requested, we'll match one last time over the remaining buffer + let chunk = this.readableBuffer.join(''); + let matches = chunk.match(this.matcher); // Match remaining buffer + _writeMatches(matches); // Write matches including last element + } + + next(); // Tell system we are done + } +} + +// Specify exports +module.exports = { + RegexBlockStream +}; \ No newline at end of file