├── .babelrc.js ├── .circleci └── config.yml ├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── package.json ├── src ├── DashButton.ts ├── MacAddresses.ts ├── NetworkInterfaces.ts ├── Packets.ts ├── __mocks__ │ ├── console.ts │ └── pcap.ts ├── __tests__ │ ├── DashButton-test.ts │ ├── MacAddresses-test.ts │ ├── NetworkInterfaces-test.ts │ └── Packets-test.ts └── cli.ts ├── ts-declarations └── pcap │ └── index.d.ts ├── tsconfig.json └── yarn.lock /.babelrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: ['@babel/plugin-proposal-class-properties'], 3 | presets: [['@babel/preset-env', { targets: { node: 'current' } }], '@babel/preset-typescript'], 4 | }; 5 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | working_directory: ~/dash-button 5 | docker: 6 | - image: circleci/node:10 7 | steps: 8 | - checkout 9 | - run: 10 | name: Install apt Packages 11 | command: sudo apt-get --yes install libpcap-dev 12 | - restore_cache: 13 | key: v2-yarn-cache 14 | - run: 15 | name: Install npm Packages 16 | command: yarn --pure-lockfile 17 | - save_cache: 18 | key: v2-yarn-cache 19 | paths: 20 | - ~/.cache/yarn 21 | - run: 22 | name: Lint 23 | command: yarn lint 24 | - run: 25 | name: Run Tests 26 | command: yarn test -- --coverage 27 | - run: 28 | name: Upload results to Codecov 29 | command: bash <(curl -s https://codecov.io/bash) 30 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: 'universe/node', 3 | settings: { 4 | react: { version: 'latest' }, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 27 | node_modules 28 | 29 | # Compiled files 30 | /build/ 31 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "tabWidth": 2, 4 | "singleQuote": true, 5 | "trailingComma": "all" 6 | } 7 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | This is how the Dash Button code is organized, so you can understand where to make changes and how to test your code. 4 | 5 | ## Source Code 6 | 7 | The source files are under `src` and are written using TypeScript. 8 | 9 | ## Building 10 | 11 | The source files are compiled with `tsc` to a version of JavaScript that Node.js understands. These compiled files go in a directory called `build`, which is not committed to Git but is published to npm. 12 | 13 | The easiest way to run `tsc` is to run `npm run build` or `npm run watch`. Both of these compile the TypeScript in `src` and output it in `build`, but the `watch` command will keep watching your filesystem for any changes and compile files when you save them. It's recommended when you are developing. 14 | 15 | ## Testing 16 | 17 | The unit tests run with [Jest](https://facebook.github.io/jest/) since it focuses on automocking, which is great for Dash Button since we want to mock the pcap library. Look under `src/__tests__` for the test files and run them with `npm test`. You can pass options to Jest after `--` in the npm command; to have Jest re-run the tests when a file changes, run `npm test -- --watch`. 18 | 19 | Manually test the CLI by running `sudo node build/cli.js`. 20 | 21 | ## Publishing 22 | 23 | Most contributors don't have to think about publishing since that's the responsibility of the package owners. These instructions are for owners: 24 | 25 | Before publishing, npm will automatically run the prepublish script, which cleans the build directory and recompiles all of the source files. Then just the build files are uploaded to npm and the new version is made available. 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-present James Ide 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dash Button for Node [![Circle CI](https://circleci.com/gh/ide/dash-button.svg?style=svg)](https://circleci.com/gh/ide/dash-button) [![codecov](https://codecov.io/gh/ide/dash-button/branch/master/graph/badge.svg)](https://codecov.io/gh/ide/dash-button) [![npm version](https://badge.fury.io/js/dash-button.svg)](http://badge.fury.io/js/dash-button) 2 | 3 | This project is archived. As of 2020, Amazon has disabled Dash Buttons. Various guides on the internet (e.g. [Rescue Your Amazon Dash Buttons](https://blog.christophermullins.com/2019/12/20/rescue-your-amazon-dash-buttons)) explain how to keep some Dash Buttons working but it is no longer as simple to customize Dash Buttons as before. 4 | 5 | Additionally, some of this library's dependencies don't compile with Node 12 and newer. You can still compile the dependencies with Node 10. 6 | 7 | For these reasons, this project is archived. 8 | 9 | --- 10 | 11 | Dash Button is a small Node server that reacts to Amazon Dash buttons on your WiFi network. You can write event handlers that Dash Button will run when it detects someone has pressed your Dash button. 12 | 13 | Dash Button is designed to run on a Raspberry Pi. Specifically, it runs on [Raspbian](https://www.raspbian.org/) (Jessie or newer) and supports modern Node.js. 14 | 15 | - [Installation and Setup](#installation-and-setup) 16 | 1. [Setting Up Your Dash Button](#setting-up-your-dash-button) 17 | 2. [Finding the MAC Address of Your Dash Button](#finding-the-mac-address-of-your-dash-button) 18 | 3. [Telling Dash Button about Your Dash Button](#telling-dash-button-about-your-dash-button) 19 | 4. [Running Code When You Press Your Dash Button](#running-code-when-you-press-your-dash-button) 20 | - [API](#api) 21 | - [DashButton](#dashbutton) 22 | - [Subscription](#subscription) 23 | - [Help Wanted](#help-wanted) 24 | - [Acknowledgements](#acknowledgements) 25 | - [License](#license) 26 | 27 | ## Installation and Setup 28 | 29 | Dash Button runs on Node 8 and up on macOS and Linux. It depends on [libpcap](http://www.tcpdump.org/): 30 | 31 | ```sh 32 | # Ubuntu and Debian 33 | sudo apt-get install libpcap-dev 34 | # Fedora and CentOS 35 | sudo yum install libpcap-devel 36 | ``` 37 | 38 | Install Dash Button in your project using npm: 39 | 40 | ```sh 41 | npm install --save dash-button 42 | ``` 43 | 44 | You will need to configure Dash Button with the MAC address of each of your Dash buttons, plus code to run when you press them. The examples here use ES2017. 45 | 46 | ### Setting Up Your Dash Button 47 | 48 | Follow Amazon's instructions to add your WiFi credentials to your Dash button, but skip the last step of choosing which product to order when you press the button. Your button is correctly configured if its LED flashes white for a few seconds before turning red when you press it. Note that the Dash button throttles presses, so you may have to wait a minute if you've pressed it recently. 49 | 50 | ### Finding the MAC Address of Your Dash Button 51 | 52 | The dash-button package includes a script that prints the MAC addresses of devices sending DHCP requests or ARP probes, which the Dash button emits when pressed. Use this to learn the MAC address of your Dash button by pressing it. 53 | 54 | Add a new script to the `scripts` section of your package.json file: 55 | 56 | ```json 57 | { 58 | "scripts": { 59 | "scan": "dash-button scan" 60 | } 61 | } 62 | ``` 63 | 64 | Run it with `sudo npm run scan`: 65 | ``` 66 | $ sudo npm run scan 67 | ``` 68 | 69 | By default it will listen on the first external network interface, which is commonly `en0` or `wlan0`, for example. You can listen on another interface with the `--interface` option: 70 | ``` 71 | sudo npm run scan -- --interface en1 72 | ``` 73 | 74 | ### Telling Dash Button about Your Dash Button 75 | 76 | Once you know your Dash button's MAC address you need to tell Dash Button about it: 77 | 78 | ```js 79 | const DashButton = require('dash-button'); 80 | 81 | const DASH_BUTTON_MAC_ADDRESS = 'xx:xx:xx:xx:xx:xx'; 82 | 83 | let button = new DashButton(DASH_BUTTON_MAC_ADDRESS); 84 | ``` 85 | 86 | ### Running Code When You Press Your Dash Button 87 | 88 | Add a listener to your button. The listener will run when you press the button. 89 | 90 | ```js 91 | let subscription = button.addListener(async () => { 92 | let nest = require('unofficial-nest-api'); 93 | await nest.login(username, password); 94 | nest.setFanModeOn(); 95 | }); 96 | 97 | // Later, if you want to remove the listener do so with the subscription: 98 | subscription.remove(); 99 | ``` 100 | 101 | You can add both normal and async functions. If you add an async function, Dash Button waits for the promise to settle before listening to new button presses. 102 | 103 | ## API 104 | 105 | ### DashButton 106 | A `DashButton` listens to presses from a single Dash button with a specified MAC address. See the setup instructions for how to learn your Dash button's MAC address by scanning for DHCP requests and ARP probes. 107 | 108 | #### Constructor 109 | `constructor(macAddress: string, options?: Options = {})` 110 | 111 | Creates a new `DashButton` object that listens to presses from the Dash button with the given MAC address. The supported options are: 112 | 113 | - `networkInterface`: name of the network interface on which to listen, like "en0" or "wlan0". See `ifconfig` for the list of interfaces on your computer. Defaults to the first external interface. 114 | 115 | #### addListener 116 | `addListener(listener): Subscription` 117 | 118 | Adds a listener function that is invoked when this `DashButton` detects a press from your Dash button. Use the returned subscription to remove the listener. 119 | 120 | **The listener may be an async function.** If you add an async listener, this `DashButton` will ignore subsequent presses from your Dash button until the async function completes. When you have multiple async listeners, the `DashButton` will wait for all of them to complete, even if some throw errors, before listening to any new presses. This lets you conveniently implement your own policy for throttling presses. 121 | 122 | ### Subscription 123 | Subscriptions are returned from `DashButton.addListener` and give you a convenient way to remove listeners. 124 | 125 | #### remove 126 | `remove()` 127 | 128 | Removes the listener that is subscribed to the `DashButton`. It will release its reference to the listener's closure to mitigate memory leaks. Calling `remove()` more than once on the same subscription is OK. 129 | 130 | ## Help Wanted 131 | 132 | ### Green Light 133 | 134 | The coolest feature would be to control the light on the Dash button so it turns green. Currently it turns white when broadcasting a DHCP request or ARP packet and then red when it doesn't receive a response from Amazon. But when you use a Dash button in the normal way, the light turns green after Amazon has placed your order. It would be great and make custom Dash apps feel more responsive if Dash Button could send back some kind of packet to trick the Dash button's light into turning green. 135 | 136 | You probably can figure out what's going on with a packet capturing library or a tool like Wireshark. Once we know what Amazon's response looks like, then we need to spoof it. This might be impossible because of TLS but it's worth a shot. 137 | 138 | ## Acknowledgements 139 | 140 | These posts and projects were helpful for making Dash Button: 141 | - ["How I Hacked Amazon’s $5 WiFi Button to track Baby Data"](https://medium.com/@edwardbenson/how-i-hacked-amazon-s-5-wifi-button-to-track-baby-data-794214b0bdd8) by @eob 142 | - [uber-dash](https://github.com/geoffrey/uber-dash) by @geoffrey 143 | - [node_pcap](https://github.com/mranney/node_pcap) by @mranney 144 | 145 | ## License 146 | 147 | This source code is released under [the MIT license](./LICENSE). It is not affiliated with Amazon. 148 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dash-button", 3 | "version": "3.2.0", 4 | "description": "A small server that reacts to Amazon Dash buttons on your WiFi network", 5 | "main": "build/DashButton.js", 6 | "files": [ 7 | "build" 8 | ], 9 | "bin": { 10 | "dash-button": "build/cli.js" 11 | }, 12 | "enginesStrict": { 13 | "node": ">=8.3" 14 | }, 15 | "scripts": { 16 | "build": "tsc", 17 | "clean": "rm -rf build", 18 | "lint": "eslint --ext .ts,.d.ts src", 19 | "prepare": "rm -rf build && tsc", 20 | "start": "node build/cli.js", 21 | "test": "jest", 22 | "watch": "tsc --watch" 23 | }, 24 | "jest": { 25 | "roots": [ 26 | "/src" 27 | ] 28 | }, 29 | "repository": { 30 | "type": "git", 31 | "url": "git+https://github.com/ide/dash-button.git" 32 | }, 33 | "keywords": [ 34 | "amazon", 35 | "dash", 36 | "button" 37 | ], 38 | "author": "James Ide", 39 | "license": "MIT", 40 | "bugs": { 41 | "url": "https://github.com/ide/dash-button/issues" 42 | }, 43 | "homepage": "https://github.com/ide/dash-button#readme", 44 | "dependencies": { 45 | "nullthrows": "^1.1.1", 46 | "pcap": "^2.1.0", 47 | "yargs": "^14.0.0" 48 | }, 49 | "devDependencies": { 50 | "@babel/core": "^7.6.0", 51 | "@babel/plugin-proposal-class-properties": "^7.5.5", 52 | "@babel/preset-env": "^7.6.0", 53 | "@babel/preset-typescript": "^7.6.0", 54 | "@types/jest": "^24.0.18", 55 | "@types/node": "^12.7.5", 56 | "@types/yargs": "^13.0.2", 57 | "@typescript-eslint/eslint-plugin": "^2.3.0", 58 | "@typescript-eslint/parser": "^2.3.0", 59 | "babel-jest": "^25.0.0", 60 | "eslint": "^6.4.0", 61 | "eslint-config-universe": "^2.0.0", 62 | "jest": "^25.0.0", 63 | "prettier": "^1.18.2", 64 | "typescript": "^3.6.3" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/DashButton.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import nullthrows from 'nullthrows'; 3 | import pcap from 'pcap'; 4 | 5 | import * as MacAddresses from './MacAddresses'; 6 | import * as NetworkInterfaces from './NetworkInterfaces'; 7 | import * as Packets from './Packets'; 8 | 9 | export type DashButtonOptions = { 10 | networkInterface?: string; 11 | }; 12 | 13 | export type DashButtonListener = (packet: Object) => void | Promise; 14 | 15 | type GuardedListener = (packet: Object) => Promise; 16 | 17 | let pcapSession: any; 18 | 19 | function getPcapSession(interfaceName: string) { 20 | if (!pcapSession) { 21 | pcapSession = Packets.createCaptureSession(interfaceName); 22 | } else { 23 | assert.equal( 24 | interfaceName, 25 | pcapSession.device_name, 26 | 'The existing pcap session must be listening on the specified interface', 27 | ); 28 | } 29 | return pcapSession; 30 | } 31 | 32 | export class DashButton { 33 | _macAddress: string; 34 | _networkInterface: string; 35 | _packetListener: (rawPacket: unknown) => void; 36 | _dashListeners: Set; 37 | _isResponding: boolean; 38 | 39 | constructor(macAddress: string, options: DashButtonOptions = {}) { 40 | this._macAddress = macAddress.toLowerCase(); 41 | this._networkInterface = options.networkInterface || nullthrows(NetworkInterfaces.getDefault()); 42 | this._packetListener = this._handlePacket.bind(this); 43 | this._dashListeners = new Set(); 44 | this._isResponding = false; 45 | } 46 | 47 | addListener(listener: DashButtonListener): Subscription { 48 | if (!this._dashListeners.size) { 49 | let session = getPcapSession(this._networkInterface); 50 | session.addListener('packet', this._packetListener); 51 | } 52 | 53 | // We run the listeners with Promise.all, which rejects early as soon as any of its promises are 54 | // rejected. Since we want to wait for all of the listeners to finish we need to catch any 55 | // errors they may throw. 56 | let guardedListener = this._createGuardedListener(listener); 57 | this._dashListeners.add(guardedListener); 58 | 59 | return new Subscription(() => { 60 | this._dashListeners.delete(guardedListener); 61 | if (!this._dashListeners.size) { 62 | let session = getPcapSession(this._networkInterface); 63 | session.removeListener('packet', this._packetListener); 64 | if (!session.listenerCount('packet')) { 65 | session.close(); 66 | } 67 | } 68 | }); 69 | } 70 | 71 | _createGuardedListener(listener: (...args: any[]) => void | Promise): GuardedListener { 72 | return async (...args: any[]): Promise => { 73 | try { 74 | await listener(...args); 75 | return undefined; 76 | } catch (error) { 77 | return error; 78 | } 79 | }; 80 | } 81 | 82 | async _handlePacket(rawPacket: unknown): Promise { 83 | if (this._isResponding) { 84 | return; 85 | } 86 | 87 | let packet = pcap.decode(rawPacket); 88 | let macAddress = MacAddresses.getEthernetSource(packet); 89 | if (macAddress !== this._macAddress) { 90 | return; 91 | } 92 | 93 | this._isResponding = true; 94 | try { 95 | // The listeners are guarded so this should never throw, but wrap it in try-catch to be 96 | // defensive 97 | let listeners = Array.from(this._dashListeners); 98 | let errors = await Promise.all(listeners.map(listener => listener(packet))); 99 | for (let error of errors) { 100 | if (error) { 101 | console.error(`Listener threw an uncaught error:\n${error.stack}`); 102 | } 103 | } 104 | } finally { 105 | this._isResponding = false; 106 | } 107 | } 108 | } 109 | 110 | class Subscription { 111 | _remove: () => void; 112 | 113 | constructor(onRemove: () => void) { 114 | this._remove = onRemove; 115 | } 116 | 117 | remove(): void { 118 | if (!this._remove) { 119 | return; 120 | } 121 | this._remove(); 122 | delete this._remove; 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/MacAddresses.ts: -------------------------------------------------------------------------------- 1 | export function getEthernetSource(packet: any): string { 2 | return decimalToHex(packet.payload.shost.addr); 3 | } 4 | 5 | export function decimalToHex(numbers: number[]): string { 6 | let hexStrings = numbers.map(decimal => decimal.toString(16).padStart(2, '0')); 7 | return hexStrings.join(':'); 8 | } 9 | -------------------------------------------------------------------------------- /src/NetworkInterfaces.ts: -------------------------------------------------------------------------------- 1 | import os from 'os'; 2 | 3 | export function getDefault(): string | null { 4 | let interfaces = os.networkInterfaces(); 5 | let names = Object.keys(interfaces); 6 | for (let name of names) { 7 | if (interfaces[name].every(iface => !iface.internal)) { 8 | return name; 9 | } 10 | } 11 | return null; 12 | } 13 | -------------------------------------------------------------------------------- /src/Packets.ts: -------------------------------------------------------------------------------- 1 | import pcap from 'pcap'; 2 | 3 | // Dash buttons send DHCPREQUEST messages (new) and ARP probes (old) 4 | const PACKET_FILTER = 5 | '(arp or (udp and src port 68 and dst port 67 and udp[247:4] == 0x63350103)) and src host 0.0.0.0'; 6 | 7 | export function createCaptureSession(interfaceName: string) { 8 | return pcap.createSession(interfaceName, PACKET_FILTER); 9 | } 10 | -------------------------------------------------------------------------------- /src/__mocks__/console.ts: -------------------------------------------------------------------------------- 1 | import { Writable } from 'stream'; 2 | 3 | const ActualConsole = require.requireActual('console').Console; 4 | 5 | class Console { 6 | static Console = Console; 7 | 8 | _silentConsole = new ActualConsole(new NullWritableStream()); 9 | 10 | log = jest.fn((...args) => this._silentConsole.log(...args)); 11 | info = jest.fn((...args) => this._silentConsole.info(...args)); 12 | warn = jest.fn((...args) => this._silentConsole.warn(...args)); 13 | error = jest.fn((...args) => this._silentConsole.error(...args)); 14 | assert = jest.fn((...args) => this._silentConsole.assert(...args)); 15 | dir = jest.fn((...args) => this._silentConsole.dir(...args)); 16 | trace = jest.fn((...args) => this._silentConsole.trace(...args)); 17 | time = jest.fn((...args) => this._silentConsole.time(...args)); 18 | timeEnd = jest.fn((...args) => this._silentConsole.timeEnd(...args)); 19 | } 20 | 21 | class NullWritableStream extends Writable { 22 | _write(_chunk, _encoding, callback) { 23 | callback(); 24 | } 25 | } 26 | 27 | module.exports = new Console(); 28 | -------------------------------------------------------------------------------- /src/__mocks__/pcap.ts: -------------------------------------------------------------------------------- 1 | import events from 'events'; 2 | 3 | let pcap = require.requireActual('pcap') as any; 4 | 5 | let pcapMock = jest.genMockFromModule('pcap') as any; 6 | pcapMock.decode = pcap.decode; 7 | 8 | pcapMock.createSession.mockImplementation((interfaceName, filter = '') => { 9 | let session = new events.EventEmitter() as any; 10 | session.device_name = interfaceName; 11 | session.filter = filter; 12 | session.close = jest.fn(); 13 | return session; 14 | }); 15 | 16 | export default pcapMock; 17 | -------------------------------------------------------------------------------- /src/__tests__/DashButton-test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | 3 | jest.mock('pcap'); 4 | jest.mock('../NetworkInterfaces'); 5 | 6 | describe('DashButton', () => { 7 | const MAC_ADDRESS = '00:11:22:33:44:55'; 8 | const NETWORK_INTERFACE = 'en0'; 9 | 10 | let pcap; 11 | let DashButton; 12 | let NetworkInterfaces; 13 | 14 | beforeEach(() => { 15 | pcap = require('pcap').default; 16 | DashButton = require('../DashButton').DashButton; 17 | NetworkInterfaces = require('../NetworkInterfaces'); 18 | 19 | NetworkInterfaces.getDefault.mockReturnValue(NETWORK_INTERFACE); 20 | }); 21 | 22 | afterEach(() => { 23 | jest.resetModules(); 24 | }); 25 | 26 | test(`should normalize (lowercase) the dash buttons MAC address`, () => { 27 | let button = new DashButton('00:11:AA:33:44:BB'); 28 | 29 | expect(button._macAddress).toEqual('00:11:aa:33:44:bb'); 30 | }); 31 | 32 | test(`creates a pcap session the first time a listener is added`, () => { 33 | let button = new DashButton(MAC_ADDRESS); 34 | button.addListener(() => {}); 35 | 36 | expect(pcap.createSession).toHaveBeenCalledTimes(1); 37 | }); 38 | 39 | test(`shares pcap sessions amongst buttons`, () => { 40 | let button1 = new DashButton(MAC_ADDRESS); 41 | button1.addListener(() => {}); 42 | 43 | let button2 = new DashButton('66:77:88:99:aa:bb'); 44 | button2.addListener(() => {}); 45 | 46 | expect(pcap.createSession).toHaveBeenCalledTimes(1); 47 | }); 48 | 49 | test(`creates a pcap session on the default interface`, () => { 50 | let button = new DashButton(MAC_ADDRESS); 51 | button.addListener(() => {}); 52 | 53 | expect(pcap.createSession).toHaveBeenCalledTimes(1); 54 | expect(pcap.createSession.mock.calls[0][0]).toBe(NETWORK_INTERFACE); 55 | }); 56 | 57 | test(`creates a pcap session on the specified interface`, () => { 58 | let button = new DashButton(MAC_ADDRESS, { networkInterface: 'wlan0' }); 59 | button.addListener(() => {}); 60 | 61 | expect(pcap.createSession).toHaveBeenCalledTimes(1); 62 | expect(pcap.createSession.mock.calls[0][0]).toBe('wlan0'); 63 | }); 64 | 65 | test(`notifies the appropriate listeners for each packet`, () => { 66 | let mockSession = pcap.createSession(NetworkInterfaces.getDefault()); 67 | pcap.createSession.mockReturnValueOnce(mockSession); 68 | 69 | let button1Listener = jest.fn(); 70 | let button2Listener = jest.fn(); 71 | 72 | let button1 = new DashButton(MAC_ADDRESS); 73 | button1.addListener(button1Listener); 74 | let button2 = new DashButton('66:77:88:99:aa:bb'); 75 | button2.addListener(button2Listener); 76 | 77 | let packet1 = createMockArpProbe(MAC_ADDRESS); 78 | mockSession.emit('packet', packet1); 79 | expect(button1Listener).toHaveBeenCalledTimes(1); 80 | expect(button2Listener).not.toHaveBeenCalled(); 81 | 82 | let packet2 = createMockArpProbe('66:77:88:99:aa:bb'); 83 | mockSession.emit('packet', packet2); 84 | expect(button1Listener).toHaveBeenCalledTimes(1); 85 | expect(button2Listener).toHaveBeenCalledTimes(1); 86 | }); 87 | 88 | test(`waits for listeners for a prior packet to asynchronously complete before handling any new packets`, async () => { 89 | let mockSession = pcap.createSession(NetworkInterfaces.getDefault()); 90 | let listenerCompletion = null; 91 | let originalAddListener = mockSession.addListener; 92 | mockSession.addListener = function addListener(eventName, listener) { 93 | originalAddListener.call(this, eventName, function(...args) { 94 | listenerCompletion = listener.apply(this, args); 95 | }); 96 | }; 97 | pcap.createSession.mockReturnValueOnce(mockSession); 98 | 99 | let button = new DashButton(MAC_ADDRESS); 100 | let calls = 0; 101 | button.addListener(() => { 102 | calls++; 103 | }); 104 | 105 | let packet = createMockArpProbe(MAC_ADDRESS); 106 | mockSession.emit('packet', packet); 107 | expect(calls).toBe(1); 108 | let firstListenerCompletion = listenerCompletion; 109 | mockSession.emit('packet', packet); 110 | expect(calls).toBe(1); 111 | await firstListenerCompletion; 112 | mockSession.emit('packet', packet); 113 | expect(calls).toBe(2); 114 | }); 115 | 116 | test(`waits for all listeners even if some threw an error`, async () => { 117 | let mockSession = pcap.createSession(NetworkInterfaces.getDefault()); 118 | pcap.createSession.mockReturnValueOnce(mockSession); 119 | 120 | let originalConsole = global.console; 121 | global.console = require.requireMock('console'); 122 | 123 | let button = new DashButton(MAC_ADDRESS); 124 | let errorCount = 0; 125 | button.addListener(() => { 126 | errorCount++; 127 | throw new Error('Intentional sync error'); 128 | }); 129 | button.addListener(() => { 130 | errorCount++; 131 | return Promise.reject(new Error('Intentional async error')); 132 | }); 133 | 134 | let listenerPromise; 135 | button.addListener(() => { 136 | listenerPromise = (async () => { 137 | // Wait for the other listeners to throw 138 | await Promise.resolve(); 139 | expect(errorCount).toBe(2); 140 | await Promise.resolve(); 141 | return 'success'; 142 | })(); 143 | return listenerPromise; 144 | }); 145 | 146 | let packet = createMockArpProbe(MAC_ADDRESS); 147 | expect(listenerPromise).not.toBeDefined(); 148 | expect(console.error).not.toHaveBeenCalled(); 149 | 150 | mockSession.emit('packet', packet); 151 | expect(listenerPromise).toBeDefined(); 152 | let result = await listenerPromise; 153 | expect(result).toBe('success'); 154 | 155 | // TODO: Define a public interface to learn when the DashButton is done 156 | // handling a packet 157 | while (button._isResponding) { 158 | await Promise.resolve(); 159 | } 160 | 161 | expect(console.error).toHaveBeenCalledTimes(2); 162 | expect((console.error as jest.Mock).mock.calls[0][0]).toEqual( 163 | expect.stringContaining('Intentional sync error'), 164 | ); 165 | expect((console.error as jest.Mock).mock.calls[1][0]).toEqual( 166 | expect.stringContaining('Intentional async error'), 167 | ); 168 | 169 | global.console = originalConsole; 170 | }); 171 | 172 | test(`runs its async listeners concurrently`, () => { 173 | let mockSession = pcap.createSession(NetworkInterfaces.getDefault()); 174 | pcap.createSession.mockReturnValueOnce(mockSession); 175 | 176 | let button = new DashButton(MAC_ADDRESS); 177 | let calls = 0; 178 | button.addListener(async () => { 179 | calls++; 180 | await Promise.resolve(); 181 | }); 182 | button.addListener(async () => { 183 | calls++; 184 | await Promise.resolve(); 185 | }); 186 | 187 | let packet = createMockArpProbe(MAC_ADDRESS); 188 | expect(calls).toBe(0); 189 | mockSession.emit('packet', packet); 190 | expect(calls).toBe(2); 191 | }); 192 | 193 | test(`removes packet listeners when a button has no more listeners`, () => { 194 | let mockSession = pcap.createSession(NetworkInterfaces.getDefault()); 195 | pcap.createSession.mockReturnValueOnce(mockSession); 196 | 197 | let button = new DashButton(MAC_ADDRESS); 198 | let subscription1 = button.addListener(() => {}); 199 | let subscription2 = button.addListener(() => {}); 200 | expect(mockSession.listenerCount('packet')).toBe(1); 201 | 202 | subscription1.remove(); 203 | expect(mockSession.listenerCount('packet')).toBe(1); 204 | subscription2.remove(); 205 | expect(mockSession.listenerCount('packet')).toBe(0); 206 | }); 207 | 208 | test(`doesn't throw if you remove a subscription twice`, () => { 209 | let mockSession = pcap.createSession(NetworkInterfaces.getDefault()); 210 | pcap.createSession.mockReturnValueOnce(mockSession); 211 | 212 | let button = new DashButton(MAC_ADDRESS); 213 | let subscription = button.addListener(() => {}); 214 | 215 | subscription.remove(); 216 | expect(mockSession.listenerCount('packet')).toBe(0); 217 | expect(() => subscription.remove()).not.toThrow(); 218 | }); 219 | 220 | test(`closes the pcap session when no more buttons are listening`, () => { 221 | let mockSession = pcap.createSession(NetworkInterfaces.getDefault()); 222 | pcap.createSession.mockReturnValueOnce(mockSession); 223 | 224 | let button1Listener = jest.fn(); 225 | let button2Listener = jest.fn(); 226 | 227 | let button1 = new DashButton(MAC_ADDRESS); 228 | let subscription1 = button1.addListener(button1Listener); 229 | let button2 = new DashButton('66:77:88:99:aa:bb'); 230 | let subscription2 = button2.addListener(button2Listener); 231 | 232 | subscription1.remove(); 233 | expect(mockSession.close).not.toHaveBeenCalled(); 234 | subscription2.remove(); 235 | expect(mockSession.close).toHaveBeenCalledTimes(1); 236 | }); 237 | }); 238 | 239 | function createMockArpProbe(sourceMacAddress) { 240 | let decimals = sourceMacAddress.split(':').map(hex => parseInt(hex, 16)); 241 | assert(decimals.length === 6, 'MAC addresses must be six bytes'); 242 | 243 | return { 244 | link_type: 'LINKTYPE_ETHERNET', 245 | // prettier-ignore 246 | header: Buffer.from([ 247 | 249, 133, 27, 86, // Seconds 248 | 137, 239, 1, 0, // Microseconds 249 | 42, 0, 0, 0, // Captured length 250 | 42, 0, 0, 0, // Total length 251 | ]), 252 | // prettier-ignore 253 | buf: Buffer.from([ 254 | 255, 255, 255, 255, 255, 255, // Destination MAC address 255 | ...decimals, // Source MAC address 256 | 8, 6, // EtherType (0x0806 = ARP) 257 | 0, 1, // HTYPE 258 | 8, 0, // PTYPE 259 | 6, // HLEN 260 | 4, // PLEN 261 | 0, 1, // Operation 262 | ...decimals, // SHA 263 | 0, 0, 0, 0, // SPA 264 | 0, 0, 0, 0, 0, 0, // THA 265 | 10, 0, 10, 20, // TPA 266 | ]) 267 | }; 268 | } 269 | -------------------------------------------------------------------------------- /src/__tests__/MacAddresses-test.ts: -------------------------------------------------------------------------------- 1 | import * as MacAddresses from '../MacAddresses'; 2 | 3 | describe('MacAddresses', () => { 4 | test(`converts arrays of decimal numbers to hex strings`, () => { 5 | let decimals = [115, 107, 32, 146, 92, 19]; 6 | let hex = MacAddresses.decimalToHex(decimals); 7 | expect(hex).toBe('73:6b:20:92:5c:13'); 8 | }); 9 | 10 | test(`left-pads hex digits with zeros`, () => { 11 | let decimals = [0, 1, 2, 3, 4, 5]; 12 | let hex = MacAddresses.decimalToHex(decimals); 13 | expect(hex).toBe('00:01:02:03:04:05'); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/__tests__/NetworkInterfaces-test.ts: -------------------------------------------------------------------------------- 1 | import os from 'os'; 2 | 3 | import * as NetworkInterfaces from '../NetworkInterfaces'; 4 | 5 | jest.mock('os'); 6 | 7 | describe('NetworkInterfaces', () => { 8 | test(`returns null if it can't find an external interface`, () => { 9 | (os.networkInterfaces as jest.Mock).mockImplementation(() => { 10 | return { lo0: loopbackInterfaces }; 11 | }); 12 | 13 | let interfaceName = NetworkInterfaces.getDefault(); 14 | expect(interfaceName).toBe(null); 15 | }); 16 | 17 | test(`returns the first external interface it finds`, () => { 18 | (os.networkInterfaces as jest.Mock).mockImplementation(() => { 19 | return { lo0: loopbackInterfaces, en0: wifiInterfaces }; 20 | }); 21 | 22 | let interfaceName = NetworkInterfaces.getDefault(); 23 | expect(interfaceName).toBe('en0'); 24 | }); 25 | }); 26 | 27 | let loopbackInterfaces = [ 28 | { 29 | address: '::1', 30 | netmask: 'ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff', 31 | family: 'IPv6', 32 | mac: '00:00:00:00:00:00', 33 | scopeid: 0, 34 | internal: true, 35 | }, 36 | { 37 | address: '127.0.0.1', 38 | netmask: '255.0.0.0', 39 | family: 'IPv4', 40 | mac: '00:00:00:00:00:00', 41 | internal: true, 42 | }, 43 | { 44 | address: 'fe80::1', 45 | netmask: 'ffff:ffff:ffff:ffff::', 46 | family: 'IPv6', 47 | mac: '00:00:00:00:00:00', 48 | scopeid: 1, 49 | internal: true, 50 | }, 51 | ]; 52 | 53 | let wifiInterfaces = [ 54 | { 55 | address: 'fe80::bae8:56ff:fe37:84c0', 56 | netmask: 'ffff:ffff:ffff:ffff::', 57 | family: 'IPv6', 58 | mac: 'b8:e8:56:37:84:c0', 59 | scopeid: 4, 60 | internal: false, 61 | }, 62 | { 63 | address: '192.168.1.206', 64 | netmask: '255.255.255.0', 65 | family: 'IPv4', 66 | mac: 'b8:e8:56:37:84:c0', 67 | internal: false, 68 | }, 69 | ]; 70 | -------------------------------------------------------------------------------- /src/__tests__/Packets-test.ts: -------------------------------------------------------------------------------- 1 | import pcap from 'pcap'; 2 | 3 | import * as Packets from '../Packets'; 4 | 5 | jest.mock('pcap'); 6 | 7 | describe('Packets', () => { 8 | test(`creates a capture session for DHCP requests and ARP probes`, () => { 9 | Packets.createCaptureSession('en0'); 10 | expect(pcap.createSession.mock.calls.length).toBe(1); 11 | expect(pcap.createSession.mock.calls[0][0]).toBe('en0'); 12 | expect(pcap.createSession.mock.calls[0][1]).toBe( 13 | '(arp or (udp and src port 68 and dst port 67 and udp[247:4] == 0x63350103)) and src host 0.0.0.0', 14 | ); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import pcap from 'pcap'; 3 | import yargs from 'yargs'; 4 | 5 | import * as MacAddresses from './MacAddresses'; 6 | import * as NetworkInterfaces from './NetworkInterfaces'; 7 | import * as Packets from './Packets'; 8 | 9 | if (require.main === module) { 10 | let parser = yargs 11 | .usage('Usage: $0 [options]') 12 | .command('scan', 'Scan for DHCP requests and ARP probes') 13 | .example( 14 | '$0 scan -i wlan0', 15 | 'Scan for DHCP requests and ARP probes on the given network interface', 16 | ) 17 | .help() 18 | .alias('h', 'help') 19 | .version() 20 | .option('i', { 21 | alias: 'interface', 22 | nargs: 1, 23 | default: NetworkInterfaces.getDefault(), 24 | describe: 'The network interface on which to listen', 25 | global: true, 26 | }); 27 | let { argv } = parser; 28 | let commands = new Set(argv._); 29 | if (!commands.size) { 30 | parser.showHelp(); 31 | } else if (commands.has('scan')) { 32 | let interfaceName = argv.interface as string; 33 | let pcapSession = Packets.createCaptureSession(interfaceName); 34 | pcapSession.addListener('packet', rawPacket => { 35 | let packet = pcap.decode(rawPacket); 36 | // console.log('Buffer:', packet.payload.payload.payload.data.toString('hex')); 37 | let sourceMacAddress = MacAddresses.getEthernetSource(packet); 38 | console.log('Detected a DHCP request or ARP probe from %s', sourceMacAddress); 39 | }); 40 | console.log('Scanning for DHCP requests and ARP probes on %s...', interfaceName); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /ts-declarations/pcap/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'pcap' { 2 | const pcap: any; 3 | export default pcap; 4 | 5 | export type PcapSession = any; 6 | } 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2019", 4 | "lib": ["es2019", "esnext.asynciterable"], 5 | "module": "commonjs", 6 | "sourceMap": true, 7 | "outDir": "./build", 8 | "rootDir": "./src", 9 | "typeRoots": ["node_modules/@types", "./ts-declarations"], 10 | "noUnusedLocals": true, 11 | "noUnusedParameters": true, 12 | "noImplicitAny": false, 13 | "noImplicitReturns": true, 14 | "noImplicitThis": false, 15 | "noFallthroughCasesInSwitch": true, 16 | "esModuleInterop": true 17 | } 18 | } 19 | --------------------------------------------------------------------------------