diff --git a/src/ble/index.ts b/src/ble/index.ts new file mode 100644 index 0000000..5b6b5fc --- /dev/null +++ b/src/ble/index.ts @@ -0,0 +1,254 @@ +'use strict' + +/** @module ble */ +/** Bluetooth Low Energy module */ + +const util = require('util') +const os = require('os') + +const log = require('../log')('ble') +const wifi = require('../wifi') + +const DEVICE_NAME = process.env.DEVICE_NAME || 'intval3' +const SERVICE_ID = process.env.SERVICE_ID || 'intval3_ble' +const CHAR_ID = process.env.CHAR_ID || 'intval3char' +const WIFI_ID = process.env.WIFI_ID || 'wifichar' +const NETWORK = os.networkInterfaces() +const MAC = getMac() || spoofMac() + +//Give the device a unique device name, needs to be in env +process.env.BLENO_DEVICE_NAME += '_' + MAC +const bleno = require('bleno') + + +let currentWifi = 'disconnected' +let currentAddr = null +let getState + +const chars = [] + +function createChar(name, uuid, prop, write, read) { + function characteristic () { + bleno.Characteristic.call(this, { + uuid : uuid, + properties: prop + }) + } + util.inherits(characteristic, bleno.Characteristic) + if (prop.indexOf('read') !== -1) { + //data, offset, withoutResponse, callback + characteristic.prototype.onReadRequest = read + } + if (prop.indexOf('write') !== -1) { + characteristic.prototype.onWriteRequest = write + } + chars.push(new characteristic()) +} + +function createChars (onWrite, onRead) { + createChar('intval3', CHAR_ID, ['read', 'write'], onWrite, onRead) + createChar('wifi', WIFI_ID, ['read', 'write'], onWifiWrite, onWifiRead) +} + +function onWifiWrite (data, offset, withoutResponse, callback) { + let result + let utf8 + let obj + let ssid + let pwd + if (offset) { + log.warn(`Offset scenario`) + result = bleno.Characteristic.RESULT_ATTR_NOT_LONG + return callback(result) + } + utf8 = data.toString('utf8') + obj = JSON.parse(utf8) + ssid = obj.ssid + pwd = obj.pwd + log.info(`connecting to AP`, { ssid : ssid }) + return wifi.createPSK(ssid, pwd, (err, hash, plaintext) => { + if (err) { + log.error('Error hashing wifi password', err) + result = bleno.Characteristic.RESULT_UNLIKELY_ERROR + return callback(result) + } + return wifi.setNetwork(ssid, plaintext, hash, (err, data) => { + if (err) { + log.error('Error configuring wifi', err) + result = bleno.Characteristic.RESULT_UNLIKELY_ERROR + return callback(result) + } + currentWifi = ssid + currentAddr = getIp() + log.info(`Connected to AP`, { ssid : ssid, ip : currentAddr }) + result = bleno.Characteristic.RESULT_SUCCESS + return callback(result) + }) + }) +} + +function onWifiRead (offset, callback) { + let result = bleno.Characteristic.RESULT_SUCCESS + let wifiRes = {} + let data + wifi.list((err, list) => { + if (err) { + result = bleno.Characteristic.RESULT_UNLIKELY_ERROR + return callback(result) + } + wifiRes.available = list + wifiRes.current = currentWifi + wifiRes.ip = currentAddr + log.info('Discovered available APs', { found : list.length }) + data = new Buffer(JSON.stringify(wifiRes)) + callback(result, data.slice(offset, data.length)) + }) +} + +function getMac () { + const colonRe = new RegExp(':', 'g') + if (NETWORK && NETWORK.wlan0 && NETWORK.wlan0[0] && NETWORK.wlan0[0].mac) { + return NETWORK.wlan0[0].mac.replace(colonRe, '') + } + return undefined +} + +function spoofMac () { + const fs = require('fs') + const FSPATH = require.resolve('uuid') + const IDFILE = os.homedir() + '/.intval3id' + let uuid + let UUIDPATH + let TMP + let MACTMP + let dashRe + delete require.cache[FSPATH] + if (fs.existsSync(IDFILE)) { + return fs.readFileSync(IDFILE, 'utf8') + } + uuid = require('uuid').v4 + UUIDPATH = require.resolve('uuid') + delete require.cache[UUIDPATH] + TMP = uuid() + MACTMP = TMP.replace(dashRe, '').substring(0, 12) + dashRe = new RegExp('-', 'g') + fs.writeFileSync(IDFILE, MACTMP, 'utf8') + return MACTMP +} + +function getIp () { + let addr = null + let ipv4 + const ifaces = os.networkInterfaces() + if (ifaces && ifaces.wlan0) { + ipv4 = ifaces.wlan0.filter(iface => { + if (iface.family === 'IPv4') { + return iface + } + }) + if (ipv4.length === 1) { + addr = ipv4[0].address + } + } + return addr +} + + +function capitalize (s) { + return s[0].toUpperCase() + s.slice(1) +} + +/** Class representing the bluetooth interface */ +class BLE { + /** + * Establishes Bluetooth Low Energy services, accessible to process through this class + * + * @constructor + */ + constructor (bleGetState) { + log.info('Starting bluetooth service') + + getState = bleGetState + + bleno.on('stateChange', state => { + log.info('stateChange', { state : state }) + if (state === 'poweredOn') { + log.info('Starting advertising', { DEVICE_NAME, DEVICE_ID : process.env.BLENO_DEVICE_NAME }) + bleno.startAdvertising(DEVICE_NAME, [CHAR_ID]) + } else { + bleno.stopAdvertising() + } + }) + + bleno.on('advertisingStart', err => { + log.info('advertisingStart', { res : (err ? 'error ' + err : 'success') }) + createChars(this._onWrite.bind(this), this._onRead.bind(this)) + if (!err) { + bleno.setServices([ + new bleno.PrimaryService({ + uuid : SERVICE_ID, //hardcoded across panels + characteristics : chars + }) + ]) + } + }) + + bleno.on('accept', clientAddress => { + log.info('accept', { clientAddress : clientAddress }) + }) + + bleno.on('disconnect', clientAddress => { + log.info('disconnect', { clientAddress : clientAddress }) + }) + + wifi.getNetwork((err, ssid) => { + if (err) { + return log.error('wifi.getNetwork', err) + } + currentWifi = ssid + currentAddr = getIp() + log.info('wifi.getNetwork', {ssid : ssid, ip : currentAddr }) + }) + } + _onWrite (data, offset, withoutResponse, callback) { + let result = {} + let utf8 + let obj + let fn + if (offset) { + log.warn(`Offset scenario`) + result = bleno.Characteristic.RESULT_ATTR_NOT_LONG + return callback(result) + } + utf8 = data.toString('utf8') + obj = JSON.parse(utf8) + result = bleno.Characteristic.RESULT_SUCCESS + fn = `_on${capitalize(obj.type)}` + if (obj.type && this[fn]) { + return this[fn](obj, () => { + callback(result) + }) + } else { + return callback(result) + } + + } + _onRead (offset, callback) { + const result = bleno.Characteristic.RESULT_SUCCESS + const state = getState() + const data = new Buffer(JSON.stringify( state )) + callback(result, data.slice(offset, data.length)) + } + /** + * Binds functions to events that are triggered by BLE messages + * + * @param {string} eventName Name of the event to to bind + * @param {function} callback Invoked when the event is triggered + */ + on (eventName, callback) { + this[`_on${capitalize(eventName)}`] = callback + } + +} + +module.exports = BLE \ No newline at end of file diff --git a/src/wifi/index.ts b/src/wifi/index.ts new file mode 100644 index 0000000..59a2766 --- /dev/null +++ b/src/wifi/index.ts @@ -0,0 +1,209 @@ +'use strict' + +const networkPattern = /network[\s\S]*?=[\s\S]*?{([\s\S]*?)}/gi +const quoteRe = new RegExp('"', 'g') + +const filePath = '/etc/wpa_supplicant/wpa_supplicant.conf' +const reconfigure = '/sbin/wpa_cli reconfigure' +const refresh = 'ip link set wlan0 down && ip link set wlan0 up' +const iwlist = '/sbin/iwlist wlan0 scanning | grep "ESSID:"' +const iwgetid = '/sbin/iwgetid' + +const log = require('../log')('wifi') +const exec = require('child_process').exec +const fs = require('fs') + +let _entry = null +let _ssid = null +let _cb = null + +/** Class representing the wifi features */ +class Wifi { + constructor () { + + } + /** + * List available wifi access points + * + * @param {function} callback Function which gets invoked after list is returned + */ + list (callback) { + exec(iwlist, (err, stdout, stderr) => { + if (err) { + console.error(err) + return callback(err) + } + const limit = 20; + const lines = stdout.split('\n') + let output = [] + let line + let i = 0 + for (let l of lines) { + line = l.replace('ESSID:', '').trim() + if (line !== '""' && i < limit) { + line = line.replace(quoteRe, '') + output.push(line) + } + i++ + } + output = output.filter(ap => { + if (ap !== '') return ap + }) + return callback(null, output) + }) + } + /** + * (internal function) Invoked after config file is read, + * then invokes file write on the config file + * + * @param {object} err (optional) Error object only present if problem reading config file + * @param {string} data Contents of the config file + */ + _readConfigCb (err, data) { + let parsed + let current + if (err) { + console.error(err) + return _cb(err) + } + parsed = this._parseConfig(data) + current = parsed.find(network => { + return network.ssid === _ssid + }) + if (typeof current !== 'undefined') { + data = data.replace(current.raw, _entry) + } else { + data += '\n\n' + _entry + } + _entry = null + fs.writeFile(filePath, data, 'utf8', this._writeConfigCb.bind(this)) + } + /** + * (internal function) Invoked after config file is written, + * then executes reconfiguration command + * + * @param {object} err (optional) Error object only present if problem writing config file + */ + _writeConfigCb (err) { + if (err) { + console.error(err) + return _cb(err) + } + exec(reconfigure, this._reconfigureCb.bind(this)) + } + /** + * (internal function) Invoked after reconfiguration command is complete + * + * @param {object} err (optional) Error object only present if configuration command fails + * @param {string} stdout Standard output from reconfiguration command + * @param {string} stderr Error output from command if fails + */ + _reconfigureCb (err, stdout, stderr) { + if (err) { + console.error(err) + return _cb(err) + } + console.log('Wifi reconfigured') + exec(refresh, this._refreshCb.bind(this)) + } + /** + * (internal function) Invoked after wifi refresh command is complete + * + * @param {object} err (optional) Error object only present if refresh command fails + * @param {string} stdout Standard output from refresh command + * @param {string} stderr Error output from command if fails + */ + _refreshCb (err, stdout, stderr) { + if (err) { + console.error(err) + return _cb(err) + } + console.log('Wifi refreshed') + _cb(null, { ssid : _ssid }) + _cb = () => {} + } + _parseConfig (str) { + const networks = [] + const lines = str.split('\n') + let network = {} + for (let line of lines) { + if (line.substring(0, 9) === 'network={') { + network = {} + network.raw = line + } else if (network.raw && line.indexOf('ssid=') !== -1) { + network.ssid = line.replace('ssid=', '').trim().replace(quoteRe, '') + if (network.raw) { + network.raw += '\n' + line + } + } else if (network.raw && line.substring(0, 1) === '}') { + network.raw += '\n' + line + networks.push(network) + network = {} + } else if (network.raw) { + network.raw += '\n' + line + } + } + return networks + } + /** + * Create sanitized wpa_supplicant.conf stanza for + * configuring wifi without storing plaintext passwords + * @example + * network={ + * ssid="YOUR_SSID" + * #psk="YOUR_PASSWORD" + * psk=6a24edf1592aec4465271b7dcd204601b6e78df3186ce1a62a31f40ae9630702 + * } + * + * @param {string} ssid SSID of wifi network + * @param {string} pwd Plaintext passphrase of wifi network + * @param {function} callback Function called after psk hash is generated + */ + createPSK (ssid, pwd, callback) { + const cmd = `wpa_passphrase '${ssid.replace(/'/g, `'\\''`)}' '${pwd.replace(/'/g, `'\\''`)}' | grep "psk="` + let lines + let hash + let plaintext + exec(cmd, (err, stdout, stderr) => { + if (err) { + return callback(err) + } + lines = stdout.replace('#psk=', '').split('psk=') + hash = lines[1] + plaintext = lines[0] + callback(null, hash.trim(), plaintext.trim()) + }) + } + /** + * Function which initializes the processes for adding a wifi access point authentication + * + * @param {string} ssid SSID of network to configure + * @param {string} pwd Password of access point, plaintext to be masked + * @param {string} hash Password/SSID of access point, securely hashed + * @param {function} callback Function invoked after process is complete, or fails + */ + setNetwork (ssid, pwd, hash, callback) { + let masked = pwd.split('').map(char => { return char !== '"' ? '*' : '"' }).join('') + _entry = `network={\n\tssid="${ssid}"\n\t#psk=${masked}\n\tpsk=${hash}\n}\n` + _cb = callback + _ssid = ssid + fs.readFile(filePath, 'utf8', this._readConfigCb.bind(this)) + } + /** + * Executes command which gets the currently connected network + * + * @param {function} callback Function which is invoked after command is completed + */ + getNetwork (callback) { + let output + exec(iwgetid, (err, stdout, stderr) => { + if (err) { + return callback(err) + } + output = stdout.split('ESSID:')[1].replace(quoteRe, '').trim() + callback(null, output) + }) + } +} + +module.exports = new Wifi() \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 9b5a94d..85ea9ba 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,8 +12,11 @@ "outDir": "./lib/", "rootDir" : "./src/", "paths" : { - "log" : ["./lib/log"], - "delay" : [ "./lib/delay"] + "log" : [ "./lib/log" ], + "delay" : [ "./lib/delay" ], + "intval" : [ "./lib/intval" ], + "ble" : [ "./lib/ble" ], + "wifi" : [ "./lib/wifi" ] } }, "exclude" : [