├── .all-contributorsrc ├── .coveralls.yml.example ├── .gitignore ├── .nvmrc ├── .travis.yml ├── CHANGES.md ├── LICENCE ├── README.md ├── __tests__ └── index.js ├── jest-setup.js ├── package-lock.json ├── package.json └── src ├── index.d.ts ├── index.js └── parser ├── index.js ├── linux.js └── win32.js /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "local-devices", 3 | "projectOwner": "DylanPiercey", 4 | "repoType": "github", 5 | "repoHost": "https://github.com", 6 | "files": [ 7 | "README.md" 8 | ], 9 | "imageSize": 100, 10 | "commit": false, 11 | "commitConvention": "eslint", 12 | "contributors": [ 13 | { 14 | "login": "DylanPiercey", 15 | "name": "Dylan Piercey", 16 | "avatar_url": "https://avatars2.githubusercontent.com/u/4985201?v=4", 17 | "profile": "https://twitter.com/dylan_piercey", 18 | "contributions": [ 19 | "code", 20 | "example", 21 | "review", 22 | "doc", 23 | "ideas", 24 | "question" 25 | ] 26 | }, 27 | { 28 | "login": "natterstefan", 29 | "name": "Stefan Natter", 30 | "avatar_url": "https://avatars2.githubusercontent.com/u/1043668?v=4", 31 | "profile": "http://twitter.com/natterstefan", 32 | "contributions": [ 33 | "code", 34 | "test", 35 | "bug", 36 | "doc", 37 | "ideas" 38 | ] 39 | }, 40 | { 41 | "login": "kounelios13", 42 | "name": "kounelios13", 43 | "avatar_url": "https://avatars3.githubusercontent.com/u/11466138?v=4", 44 | "profile": "https://github.com/kounelios13", 45 | "contributions": [ 46 | "bug", 47 | "doc" 48 | ] 49 | }, 50 | { 51 | "login": "MarkusSuomi", 52 | "name": "MarkusSuomi", 53 | "avatar_url": "https://avatars3.githubusercontent.com/u/5594334?v=4", 54 | "profile": "https://github.com/MarkusSuomi", 55 | "contributions": [ 56 | "code" 57 | ] 58 | }, 59 | { 60 | "login": "nolazybits", 61 | "name": "Xavier Martin", 62 | "avatar_url": "https://avatars1.githubusercontent.com/u/214998?v=4", 63 | "profile": "http://nolazybits.com", 64 | "contributions": [ 65 | "code" 66 | ] 67 | }, 68 | { 69 | "login": "howel52", 70 | "name": "howel52", 71 | "avatar_url": "https://avatars0.githubusercontent.com/u/9854818?v=4", 72 | "profile": "https://me.howel52.com/", 73 | "contributions": [ 74 | "code", 75 | "bug" 76 | ] 77 | }, 78 | { 79 | "login": "LucaSoldi", 80 | "name": "LucaSoldi", 81 | "avatar_url": "https://avatars0.githubusercontent.com/u/5584781?v=4", 82 | "profile": "https://github.com/LucaSoldi", 83 | "contributions": [ 84 | "code", 85 | "bug" 86 | ] 87 | }, 88 | { 89 | "login": "Miosame", 90 | "name": "Miosame", 91 | "avatar_url": "https://avatars1.githubusercontent.com/u/8201077?v=4", 92 | "profile": "https://github.com/Miosame", 93 | "contributions": [ 94 | "code", 95 | "doc", 96 | "example" 97 | ] 98 | }, 99 | { 100 | "login": "timrogers", 101 | "name": "Tim Rogers", 102 | "avatar_url": "https://avatars.githubusercontent.com/u/116134?v=4", 103 | "profile": "https://timrogers.co.uk", 104 | "contributions": [ 105 | "code", 106 | "doc", 107 | "test" 108 | ] 109 | } 110 | ], 111 | "contributorsPerLine": 7 112 | } 113 | -------------------------------------------------------------------------------- /.coveralls.yml.example: -------------------------------------------------------------------------------- 1 | # Inspired by https://github.com/Flexberry/javascript-project-template/blob/master/.coveralls.yml.example 2 | # When built on Travis, Coveralls can detect the associated repo automatically. 3 | # If you're running locally, you must have a .coveralls.yml file with your 4 | # repo_token in it; or, you must provide a COVERALLS_REPO_TOKEN 5 | # environment-variable on the command-line. 6 | 7 | # The secret repo token for your repository, found at the bottom of your 8 | # repository's page on Coveralls. DON'T ADD THIS TO PUBLIC REPO. Should be kept 9 | # SECRET - anyone could use it to submit coverage data on your repo's behalf. 10 | repo_token: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # VS Code 2 | .vscode 3 | 4 | # Sublime 5 | *.sublime* 6 | 7 | # OSX 8 | *.DS_Store 9 | 10 | # NPM 11 | node_modules 12 | npm-debug.log 13 | 14 | # Testing 15 | test/run.js 16 | coverage 17 | .coveralls.yml 18 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v10.17 -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - '8' 5 | - '10' 6 | 7 | script: 8 | - npm run test 9 | 10 | notifications: 11 | email: 12 | on_success: change 13 | on_failure: always 14 | 15 | after_success: 'npm run coveralls' 16 | 17 | cache: 18 | directories: 19 | - ~/.npm # cache npm's cache 20 | - ~/npm # cache latest npm 21 | - node_modules # npm install, unlike npm ci, doesn't wipe node_modules -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | # Local-Devices 2 | 3 | All notable changes to this project will be documented here. The format is based 4 | on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project 5 | adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 6 | 7 | ## [4.0.0] - 2022-08-15 8 | 9 | ### Changed 10 | 11 | ### Added 12 | 13 | - support passing in an `arpPath` option to override the arp binary used ([#59](https://github.com/DylanPiercey/local-devices/pull/59)) 14 | 15 | ⚠ BREAKING CHANGES 16 | 17 | - switch to using an option object for the `find` api ([#59](https://github.com/DylanPiercey/local-devices/pull/59)) 18 | 19 | ## [3.2.0] - 2021-09-21 20 | 21 | ### Added 22 | 23 | - flag to skip name resolution ([#24](https://github.com/DylanPiercey/local-devices/pull/36)) 24 | 25 | ## [3.1.0] - 2020-09-25 26 | 27 | ### Added 28 | 29 | - support passing ip ranges to the `find` api ([#24](https://github.com/DylanPiercey/local-devices/pull/24)) 30 | 31 | ## [3.0.0] - 2019-10-29 32 | 33 | ### Changed 34 | 35 | ⚠ BREAKING CHANGES 36 | 37 | - dropping Node v8 support because [end-of-life](https://github.com/nodejs/Release#release-schedule) 38 | [[#18](https://github.com/DylanPiercey/local-devices/pull/18)] 39 | 40 | ### Fixes 41 | 42 | - increase `maxBuffer` of `cp.exec` to 10MB (1024*1024*10), fixes [#10](https://github.com/DylanPiercey/local-devices/issues/10) 43 | - fix: add timeout options when exec arp ([#13](https://github.com/DylanPiercey/local-devices/pull/13)) 44 | - Fixed win32 parser for better windows support ([#9](https://github.com/DylanPiercey/local-devices/pull/9)) 45 | - validate ip address before executing command for 'find' ([#16](https://github.com/DylanPiercey/local-devices/pull/16)) 46 | 47 | ## [2.0.0] - 2019-02-10 48 | 49 | ### Added 50 | 51 | - Support for Raspberry Pi (Linux) 52 | - Partial support for windows 53 | - Jest test suite and tests for Linux and other platforms 54 | - with Travis CI integration 55 | 56 | ### Changed 57 | 58 | - fixed npm module versions in package.json 59 | - fixed node version to v8.14.1 60 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018, Dylan Piercey and Contributors 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 13 | all 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 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Local Devices 2 | 3 | [![version][version-badge]][package] 4 | [![MIT License][license-badge]][licence] 5 | [![js-standard-style](https://img.shields.io/badge/code%20style-standard-brightgreen.svg)](http://standardjs.com/) 6 | [![All Contributors](https://img.shields.io/badge/all_contributors-7-orange.svg?style=flat-square)](#contributors) 7 | [![PRs Welcome][prs-badge]][prs] 8 | 9 | [![Build Status][build-badge]][build] 10 | [![Coverage Status][coverage-badge]][coverage] 11 | [![Watch on GitHub][github-watch-badge]][github-watch] 12 | [![Star on GitHub][github-star-badge]][github-star] 13 | 14 | Find all devices connected to the local network using `arp -a`. 15 | This module also pings all possible ip's in the local network to build the arp table. 16 | 17 | ## Installation 18 | 19 | ### Npm 20 | 21 | ```console 22 | npm install local-devices 23 | ``` 24 | 25 | ### Example 26 | 27 | ```javascript 28 | // Using a transpiler 29 | import find from 'local-devices' 30 | // Without using a transpiler 31 | const find = require('local-devices'); 32 | 33 | // Find all local network devices. 34 | find().then(devices => { 35 | devices /* 36 | [ 37 | { name: '?', ip: '192.168.0.10', mac: '...' }, 38 | { name: '...', ip: '192.168.0.17', mac: '...' }, 39 | { name: '...', ip: '192.168.0.21', mac: '...' }, 40 | { name: '...', ip: '192.168.0.22', mac: '...' } 41 | ] 42 | */ 43 | }) 44 | 45 | // Find a single device by ip address. 46 | find({ address: '192.168.0.10' }).then(device => { 47 | device /* 48 | { 49 | name: '?', 50 | ip: '192.168.0.10', 51 | mac: '...' 52 | } 53 | */ 54 | }) 55 | 56 | // Find all devices within 192.168.0.1 to 192.168.0.25 range 57 | find({ address: '192.168.0.1-192.168.0.25' }).then(devices => { 58 | devices /* 59 | [ 60 | { name: '?', ip: '192.168.0.10', mac: '...' }, 61 | { name: '...', ip: '192.168.0.17', mac: '...' }, 62 | { name: '...', ip: '192.168.0.21', mac: '...' }, 63 | { name: '...', ip: '192.168.0.22', mac: '...' } 64 | ] 65 | */ 66 | }) 67 | 68 | // Find all devices within /24 subnet range of 192.168.0.x 69 | find({ address: '192.168.0.0/24' }).then(devices => { 70 | devices /* 71 | [ 72 | { name: '?', ip: '192.168.0.10', mac: '...' }, 73 | { name: '...', ip: '192.168.0.50', mac: '...' }, 74 | { name: '...', ip: '192.168.0.155', mac: '...' }, 75 | { name: '...', ip: '192.168.0.211', mac: '...' } 76 | ] 77 | */ 78 | }) 79 | 80 | // Find all devices without resolving host names (Uses 'arp -an') - this is more performant if hostnames are not needed 81 | // (This flag is ignored on Windows machines as 'arp -an' is not supported) 82 | find({ skipNameResolution: true }).then(devices => { 83 | devices /* 84 | [ 85 | { name: '?', ip: '192.168.0.10', mac: '...' }, 86 | { name: '?', ip: '192.168.0.50', mac: '...' }, 87 | { name: '?', ip: '192.168.0.155', mac: '...' }, 88 | { name: '?', ip: '192.168.0.211', mac: '...' } 89 | ] 90 | */ 91 | }) 92 | 93 | // Find all devices, specifying your own path for the `arp` binary 94 | find({ arpPath: '/usr/sbin/arp' }).then(devices => { 95 | devices /* 96 | [ 97 | { name: '?', ip: '192.168.0.10', mac: '...' }, 98 | { name: '?', ip: '192.168.0.50', mac: '...' }, 99 | { name: '?', ip: '192.168.0.155', mac: '...' }, 100 | { name: '?', ip: '192.168.0.211', mac: '...' } 101 | ] 102 | */ 103 | }) 104 | ``` 105 | 106 | ## Contributions 107 | 108 | * Use `npm test` to run tests. 109 | 110 | Please feel free to create a PR! 111 | 112 | ## Contributors 113 | 114 | Thanks goes to these wonderful people ([emoji key][emojis]): 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 |
Dylan Piercey
Dylan Piercey

💻 💡 👀 📖 🤔 💬
Stefan Natter
Stefan Natter

💻 ⚠️ 🐛 📖 🤔
kounelios13
kounelios13

🐛 📖
MarkusSuomi
MarkusSuomi

💻
Xavier Martin
Xavier Martin

💻
howel52
howel52

💻 🐛
LucaSoldi
LucaSoldi

💻 🐛
Miosame
Miosame

💻 📖 💡
Tim Rogers
Tim Rogers

💻 📖 ⚠️
134 | 135 | 136 | 137 | 138 | 139 | This project follows the [all-contributors][all-contributors] specification. 140 | Contributions of any kind are welcome! 141 | 142 | ### How to add Contributors 143 | 144 | Contributors can be added with the [all-contributors cli](https://allcontributors.org/docs/en/cli/installation). 145 | The cli is already installed and can be [used like this](https://allcontributors.org/docs/en/bot/usage): 146 | 147 | ```bash 148 | yarn all-contributors add 149 | ``` 150 | 151 | ## LICENCE 152 | 153 | [MIT](LICENCE) 154 | 155 | [package]: https://www.npmjs.com/package/local-devices 156 | [licence]: https://github.com/DylanPiercey/local-devices/blob/master/LICENCE 157 | [prs]: http://makeapullrequest.com 158 | [github-watch]: https://github.com/DylanPiercey/local-devices/watchers 159 | [github-star]: https://github.com/DylanPiercey/local-devices/stargazers 160 | [github-watch-badge]: https://img.shields.io/github/watchers/DylanPiercey/local-devices.svg?style=social 161 | [github-star-badge]: https://img.shields.io/github/stars/DylanPiercey/local-devices.svg?style=social 162 | [version-badge]: https://img.shields.io/npm/v/local-devices.svg?style=flat-square 163 | [license-badge]: https://img.shields.io/npm/l/local-devices.svg?style=flat-square 164 | [prs-badge]: https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square 165 | [emojis]: https://github.com/kentcdodds/all-contributors#emoji-key 166 | [all-contributors]: https://github.com/kentcdodds/all-contributors 167 | 168 | [build-badge]: https://travis-ci.org/DylanPiercey/local-devices.svg?branch=master 169 | [build]: https://travis-ci.org/DylanPiercey/local-devices 170 | [coverage-badge]: https://coveralls.io/repos/github/DylanPiercey/local-devices/badge.svg?branch=master 171 | [coverage]: https://coveralls.io/github/DylanPiercey/local-devices?branch=master 172 | -------------------------------------------------------------------------------- /__tests__/index.js: -------------------------------------------------------------------------------- 1 | const find = require('../src/index') 2 | var cp = require('mz/child_process') 3 | 4 | const TEN_MEGA_BYTE = 1024 * 1024 * 10 5 | const ONE_MINUTE = 60 * 1000 6 | 7 | describe('local-devices', () => { 8 | const platforms = [ 9 | 'linux', 10 | 'darwin', 11 | 'win32' 12 | ] 13 | 14 | beforeAll(() => { 15 | this.originalPlatform = process.platform 16 | }) 17 | 18 | afterAll(() => { 19 | Object.defineProperty(process, 'platform', { 20 | value: this.originalPlatform 21 | }) 22 | }) 23 | 24 | platforms.forEach(platform => { 25 | describe(`on ${platform}`, () => { 26 | beforeAll(() => { 27 | Object.defineProperty(process, 'platform', { 28 | value: platform 29 | }) 30 | }) 31 | 32 | afterEach(() => cp.exec.mockClear()) 33 | 34 | it('returns the result of all IPs', async () => { 35 | const result = await find() 36 | expect(result).toEqual([ 37 | { name: '?', ip: '192.168.0.202', mac: '00:12:34:56:78:90' }, 38 | { name: '?', ip: '192.168.0.212', mac: '00:12:34:56:78:91' }, 39 | { name: '?', ip: '192.168.0.222', mac: '00:12:34:56:78:92' }, 40 | { name: '?', ip: '192.168.0.232', mac: '00:12:34:56:78:93' }, 41 | { name: '?', ip: '192.168.1.234', mac: '00:12:34:56:78:94' } 42 | ]) 43 | }) 44 | 45 | it('returns empty list if empty response returned', async () => { 46 | cp.exec.mockImplementationOnce(_ => Promise.resolve()) 47 | const result = await find() 48 | expect(result).toEqual([]) 49 | }) 50 | 51 | it('returns all IPs within /24 range', async () => { 52 | const result = await find({ address: '192.168.1.0/24' }) 53 | expect(result).toEqual([ 54 | { name: '?', ip: '192.168.1.234', mac: '00:12:34:56:78:94' } 55 | ]) 56 | }) 57 | 58 | it('returns all IPs within 1-254 range', async () => { 59 | const result = await find({ address: '192.168.1.1-192.168.1.254' }) 60 | expect(result).toEqual([ 61 | { name: '?', ip: '192.168.1.234', mac: '00:12:34:56:78:94' } 62 | ]) 63 | }) 64 | 65 | it('returns the result of a single IP (Note: undefined on win32)', async () => { 66 | const result = await find({ address: '192.168.0.222' }) 67 | 68 | if (process.platform.includes('win32')) { 69 | // not supported yet 70 | expect(result).toBeUndefined() 71 | return 72 | } 73 | 74 | expect(result).toEqual( 75 | { name: '?', ip: '192.168.0.222', mac: '00:12:34:56:78:92' } 76 | ) 77 | }) 78 | 79 | it('returns undefined, when the host is not resolved', async () => { 80 | const result = await find({ address: '192.168.0.242' }) 81 | expect(result).toBeUndefined() 82 | }) 83 | 84 | it('returns undefined, when the host does not exist in arp table', async () => { 85 | const result = await find({ address: '192.168.0.243' }) 86 | expect(result).toBeUndefined() 87 | }) 88 | 89 | it('rejects when the host is not a valid ip address', async () => { 90 | await expect(find({ address: '127.0.0.1 | mkdir attacker' })).rejects.toThrow('Invalid IP') 91 | }) 92 | 93 | it('invokes cp.exec with maxBuffer of 10 MB and a timeout of 1 minute, when invoking find without an ip', async () => { 94 | await find() 95 | expect(cp.exec).toHaveBeenCalledWith('arp -a', { maxBuffer: TEN_MEGA_BYTE, timeout: ONE_MINUTE }) 96 | }) 97 | 98 | it('invokes cp.exec with maxBuffer of 10 MB and a timeout of 1 minute, when invoking find without an ip and skip name resolution', async () => { 99 | await find({ address: null, skipNameResolution: true }) 100 | if (process.platform.includes('win32')) { 101 | expect(cp.exec).toHaveBeenCalledWith('arp -a', { maxBuffer: TEN_MEGA_BYTE, timeout: ONE_MINUTE }) 102 | } else { 103 | expect(cp.exec).toHaveBeenCalledWith('arp -an', { maxBuffer: TEN_MEGA_BYTE, timeout: ONE_MINUTE }) 104 | } 105 | }) 106 | 107 | it('invokes cp.exec with maxBuffer of 10 MB and a timeout of 1 minute, when invoking find with a single ip', async () => { 108 | await find({ address: '192.168.0.242' }) 109 | expect(cp.exec).toHaveBeenCalledWith('arp -n 192.168.0.242', { maxBuffer: TEN_MEGA_BYTE, timeout: ONE_MINUTE }) 110 | }) 111 | 112 | it('invokes cp.exec with maxBuffer of 10 MB, a timeout of 1 minute and a custom arp binary, when invoking find with an arpPath', async () => { 113 | await (find({ arpPath: '/usr/sbin/arp' })) 114 | expect(cp.exec).toHaveBeenCalledWith('/usr/sbin/arp -a', { maxBuffer: TEN_MEGA_BYTE, timeout: ONE_MINUTE }) 115 | }) 116 | }) 117 | }) 118 | }) 119 | -------------------------------------------------------------------------------- /jest-setup.js: -------------------------------------------------------------------------------- 1 | /** 2 | * MOCKS 3 | */ 4 | const mockHosts = [ 5 | '? (192.168.0.202) at 00:12:34:56:78:90 on en0 ifscope [ethernet]', 6 | '? (192.168.0.212) at 00:12:34:56:78:91 on en0 ifscope [ethernet]', 7 | '? (192.168.0.222) at 00:12:34:56:78:92 on en0 ifscope [ethernet]', 8 | '? (192.168.0.232) at 00:12:34:56:78:93 on en0 ifscope [ethernet]', 9 | '? (192.168.1.234) at 00:12:34:56:78:94 on en0 ifscope [ethernet]', 10 | // "special" cases (eg. unresolved hosts) 11 | '? (192.168.0.242) at (incomplete) on en0 ifscope [ethernet]', // host is in the list but incomplete 12 | '192.168.0.243 (192.168.0.243) -- no entry' // host has no entry in the arp table 13 | ] 14 | 15 | const mockLinuxHosts = [ 16 | '? (192.168.0.202) at 00:12:34:56:78:90 [ether] on eth0', 17 | '? (192.168.0.212) at 00:12:34:56:78:91 [ether] on eth0', 18 | '? (192.168.0.222) at 00:12:34:56:78:92 [ether] on eth0', 19 | '? (192.168.0.232) at 00:12:34:56:78:93 [ether] on eth0', 20 | '? (192.168.1.234) at 00:12:34:56:78:94 [ether] on eth0', 21 | // "special" cases (eg. unresolved hosts) 22 | '? (192.168.0.242) at on eth0', 23 | '192.168.0.243 (192.168.0.243) -- no entry' // host has no entry in the arp table 24 | ] 25 | 26 | /* eslint-disable */ 27 | // NOTE: may not cover all test cases yet 28 | const mockWinHosts = [ 29 | '192.168.0.202 00-12-34-56-78-90 dynamic', 30 | '192.168.0.212 00-12-34-56-78-91 dynamic', 31 | '192.168.0.222 00-12-34-56-78-92 dynamic', 32 | '192.168.0.232 00-12-34-56-78-93 dynamic', 33 | '192.168.1.234 00-12-34-56-78-94 dynamic', 34 | // "special" cases (eg. unresolved hosts) 35 | '192.168.1.242 (incomplete) dynamic', 36 | ] 37 | /* eslint-enable */ 38 | 39 | function mockPrepareHosts (command) { 40 | // first filter all special case examples from the mockHost list (eg. no entry) 41 | const workingHosts = mockHosts.filter(i => i.indexOf('no entry') < 0) 42 | let r = workingHosts.join('\n') 43 | 44 | if (command.includes('-n')) { 45 | const ip = command.match(/arp -(.*){1} (.*)/)[2] 46 | r = mockHosts.find(i => i.indexOf(ip) > 0) 47 | } 48 | 49 | return r 50 | } 51 | 52 | function mockPrepareLinuxHosts (command) { 53 | // first filter all special case examples from the mockHost list (eg. no entry) 54 | const workingHosts = mockLinuxHosts.filter(i => i.indexOf('no entry') < 0) 55 | let r = workingHosts.join('\n') 56 | 57 | // then define the current use-case (arp all or arp one) 58 | if (command.includes('-n')) { 59 | // receive the ip address of the request and the mac address from the mocked hosts 60 | const ip = command.match(/arp -(.*){1} (.*)/)[2] 61 | const host = mockLinuxHosts.find(i => i.indexOf(ip) >= 0) 62 | 63 | // and finally prepare the arp output for the tests 64 | if (host.indexOf('no entry') >= 0) { 65 | r = host 66 | } else { 67 | const macAddress = host.split(' ')[3] 68 | r = `Address HWtype HWaddress Flags Mask Iface 69 | ${ip} ether ${macAddress} C eth0` 70 | } 71 | } 72 | 73 | return r 74 | } 75 | 76 | function mockPrepareWin (command) { 77 | // first filter all special case examples from the mockHost list (eg. no entry) 78 | const workingHosts = mockWinHosts.filter(i => i.indexOf('no entry') < 0) 79 | /* eslint-disable-next-line */ 80 | workingHosts.unshift('Internet Address Physical Address Type') 81 | let r = workingHosts.join('\n') 82 | 83 | if (command.includes('-n')) { 84 | // only for TESTS, win32 will return the manual text instead 85 | r = 'scanning a specific IP is not supported on Windows with local-devices' 86 | } 87 | return r 88 | } 89 | 90 | jest.mock('mz/child_process', () => ({ 91 | exec: jest.fn(command => { 92 | var r = mockPrepareHosts(command) 93 | if (process.platform === 'linux') { 94 | r = mockPrepareLinuxHosts(command) 95 | } else if (process.platform === 'win32') { 96 | r = mockPrepareWin(command) 97 | } 98 | return new Promise(resolve => resolve([r])) 99 | }) 100 | })) 101 | 102 | // Mock net.Socket (Alternative (not tested yet): https://www.npmjs.com/package/mitm) 103 | jest.mock('net', () => ({ 104 | Socket: jest.fn(() => ({ 105 | setTimeout: jest.fn((timeout, cb) => { 106 | cb() 107 | }), 108 | destroy: jest.fn(), 109 | connect: jest.fn((port, address, cb) => { 110 | cb() 111 | }), 112 | once: jest.fn((timeout, cb) => { 113 | cb() 114 | }) 115 | })) 116 | })) 117 | 118 | jest.mock('os', () => ({ 119 | // example mock for darwin (MacOSX) 120 | networkInterfaces: jest.fn().mockReturnValue({ 121 | lo0: 122 | [{ 123 | address: '127.0.0.1', 124 | netmask: '255.0.0.0', 125 | family: 'IPv4', 126 | mac: '00:00:00:00:00:00', 127 | internal: true, 128 | cidr: '127.0.0.1/8' 129 | }], 130 | utun0: 131 | [{ 132 | address: 'as12::d3f4:56j:k789:0l00', 133 | netmask: 'ffff:ffff:ffff:ffff::', 134 | family: 'IPv6', 135 | mac: '00:00:00:00:00:00', 136 | scopeid: 11, 137 | internal: false, 138 | cidr: 'as12::d3f4:56j:k789:0l00/64' 139 | }], 140 | en5: 141 | [{ 142 | address: '192.168.0.200', 143 | netmask: '255.255.254.0', 144 | family: 'IPv4', 145 | mac: '00:12:34:56:78:99', 146 | internal: false, 147 | cidr: '192.168.0.0/23' 148 | }] 149 | }) 150 | })) 151 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "local-devices", 3 | "description": "Find devices connected to the current local network.", 4 | "version": "4.0.0", 5 | "author": "Dylan Piercey ", 6 | "license": "MIT", 7 | "main": "src/index.js", 8 | "files": [ 9 | "src" 10 | ], 11 | "scripts": { 12 | "contributors:add": "all-contributors add", 13 | "contributors:generate": "all-contributors generate", 14 | "coveralls": "jest --coverage && cat ./coverage/lcov.info | coveralls", 15 | "lint": "standard --verbose | snazzy", 16 | "test": "jest", 17 | "pretest": "npm run lint", 18 | "watch-test": "jest --watch" 19 | }, 20 | "types": "./src/index.d.ts", 21 | "dependencies": { 22 | "get-ip-range": "^2.1.0", 23 | "ip": "^1.1.5", 24 | "mz": "^2.7.0" 25 | }, 26 | "devDependencies": { 27 | "@types/jest": "^24.0.19", 28 | "all-contributors-cli": "^6.9.3", 29 | "coveralls": "^3.0.7", 30 | "husky": "^3.0.9", 31 | "jest": "^24.9.0", 32 | "lint-staged": "^9.4.2", 33 | "snazzy": "^8.0.0", 34 | "standard": "^14.3.1" 35 | }, 36 | "engines": { 37 | "node": ">=10.17" 38 | }, 39 | "homepage": "https://github.com/DylanPiercey/local-devices", 40 | "bugs": "https://github.com/DylanPiercey/local-devices/issues", 41 | "keywords": [ 42 | "arp", 43 | "devices", 44 | "ip", 45 | "local", 46 | "mac", 47 | "mac-address", 48 | "scan" 49 | ], 50 | "repository": { 51 | "type": "git", 52 | "url": "https://github.com/DylanPiercey/local-devices" 53 | }, 54 | "husky": { 55 | "hooks": { 56 | "pre-commit": "lint-staged", 57 | "pre-push": "npm test" 58 | } 59 | }, 60 | "lint-staged": { 61 | "*.js": [ 62 | "npm run lint", 63 | "git update-index --again", 64 | "jest --findRelatedTests" 65 | ] 66 | }, 67 | "jest": { 68 | "setupFiles": [ 69 | "./jest-setup.js" 70 | ] 71 | }, 72 | "standard": { 73 | "globals": [ 74 | "jest", 75 | "describe", 76 | "beforeAll", 77 | "afterAll", 78 | "beforeEach", 79 | "afterEach", 80 | "it", 81 | "expect" 82 | ] 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module "local-devices" { 2 | 3 | function findLocalDevices(opts?: { address?: any, skipNameResolution?: boolean, arpPath?: string }): Promise; 4 | 5 | namespace findLocalDevices 6 | { 7 | interface IDevice 8 | { 9 | name: string; 10 | ip: string; 11 | mac: string; 12 | } 13 | } 14 | 15 | export = findLocalDevices; 16 | } -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | var ip = require('ip') 2 | var os = require('os') 3 | var net = require('net') 4 | var cp = require('mz/child_process') 5 | var getIPRange = require('get-ip-range') 6 | 7 | var parseLinux = require('./parser/linux') 8 | var parseWin32 = require('./parser/win32') 9 | var parseRow = require('./parser') 10 | 11 | var servers 12 | var lock = {} 13 | 14 | const TEN_MEGA_BYTE = 1024 * 1024 * 10 15 | const ONE_MINUTE = 60 * 1000 16 | const options = { 17 | maxBuffer: TEN_MEGA_BYTE, 18 | timeout: ONE_MINUTE 19 | } 20 | 21 | /** 22 | * Finds all local devices (ip and mac address) connected to the current network. 23 | */ 24 | module.exports = function findLocalDevices ({ address = '', skipNameResolution = false, arpPath = 'arp' } = {}) { 25 | var key = String(address) 26 | 27 | if (isRange(address)) { 28 | try { 29 | servers = getIPRange(key) 30 | } catch (error) { 31 | // Note: currently doesn't throw the intended error message from the package maintainer 32 | // It will still be caught, PR here though: https://github.com/JoeScho/get-ip-range/pull/6 33 | return error 34 | } 35 | } else { 36 | servers = getServers() 37 | } 38 | 39 | if (!lock[key]) { 40 | if (!address || isRange(key)) { 41 | lock[key] = pingServers().then(() => arpAll(skipNameResolution, arpPath)).then(unlock(key)) 42 | } else { 43 | lock[key] = pingServer(address).then(address => arpOne(address, arpPath)).then(unlock(key)) 44 | } 45 | } 46 | 47 | return lock[key] 48 | } 49 | 50 | function isRange (address) { 51 | return address && new RegExp('/|-').test(address) 52 | } 53 | 54 | /** 55 | * Gets the current list of possible servers in the local networks. 56 | */ 57 | function getServers () { 58 | var interfaces = os.networkInterfaces() 59 | var result = [] 60 | 61 | for (var key in interfaces) { 62 | var addresses = interfaces[key] 63 | for (var i = addresses.length; i--;) { 64 | var address = addresses[i] 65 | if (address.family === 'IPv4' && !address.internal) { 66 | var subnet = ip.subnet(address.address, address.netmask) 67 | var current = ip.toLong(subnet.firstAddress) 68 | var last = ip.toLong(subnet.lastAddress) - 1 69 | while (current++ < last) result.push(ip.fromLong(current)) 70 | } 71 | } 72 | } 73 | 74 | return result 75 | } 76 | 77 | /** 78 | * Sends a ping to all servers to update the arp table. 79 | */ 80 | function pingServers () { 81 | return Promise.all(servers.map(pingServer)) 82 | } 83 | 84 | /** 85 | * Pings an individual server to update the arp table. 86 | */ 87 | function pingServer (address) { 88 | return new Promise(function (resolve) { 89 | var socket = new net.Socket() 90 | socket.setTimeout(1000, close) 91 | socket.connect(80, address, close) 92 | socket.once('error', close) 93 | 94 | function close () { 95 | socket.destroy() 96 | resolve(address) 97 | } 98 | }) 99 | } 100 | 101 | /** 102 | * Reads the arp table. 103 | */ 104 | function arpAll (skipNameResolution = false, arpPath) { 105 | const isWindows = process.platform.includes('win32') 106 | const cmd = (skipNameResolution && !isWindows) ? `${arpPath} -an` : `${arpPath} -a` 107 | return cp.exec(cmd, options).then(parseAll) 108 | } 109 | 110 | /** 111 | * Parses arp scan data into a useable collection. 112 | */ 113 | function parseAll (data) { 114 | if (!data || !data[0]) { 115 | return [] 116 | } 117 | 118 | if (process.platform.includes('linux')) { 119 | var rows = data[0].split('\n') 120 | return rows.map(function (row) { 121 | return parseLinux(row, servers) 122 | }).filter(Boolean) 123 | } else if (process.platform.includes('win32')) { 124 | var winRows = data[0].split('\n').splice(1) 125 | return winRows.map(function (row) { 126 | return parseWin32(row, servers) 127 | }).filter(Boolean) 128 | } 129 | 130 | return data[0] 131 | .trim() 132 | .split('\n') 133 | .map(function (row) { 134 | return parseRow(row, servers) 135 | }) 136 | .filter(Boolean) 137 | } 138 | 139 | /** 140 | * Reads the arp table for a single address. 141 | */ 142 | function arpOne (address, arpPath) { 143 | if (!ip.isV4Format(address) && !ip.isV6Format(address)) { 144 | return Promise.reject(new Error('Invalid IP address provided.')) 145 | } 146 | 147 | return cp.exec(`${arpPath} -n ${address}`, options).then(parseOne) 148 | } 149 | 150 | /** 151 | * Parses a single row of arp data. 152 | */ 153 | function parseOne (data) { 154 | if (!data || !data[0]) { 155 | return 156 | } 157 | 158 | if (process.platform.includes('linux')) { 159 | // ignore unresolved hosts (can happen when parseOne returns only one unresolved host) 160 | if (data[0].indexOf('no entry') >= 0) { 161 | return 162 | } 163 | 164 | // remove first row (containing "headlines") 165 | var rows = data[0].split('\n').slice(1)[0] 166 | return parseLinux(rows, servers, true) 167 | } else if (process.platform.includes('win32')) { 168 | return // currently not supported 169 | } 170 | 171 | return parseRow(data[0], servers) 172 | } 173 | 174 | /** 175 | * Clears the current promise and unlocks (will ping servers again). 176 | */ 177 | function unlock (key) { 178 | return function (data) { 179 | lock[key] = null 180 | return data 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/parser/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Parses each row in the arp table into { name, ip, mac }. 3 | */ 4 | module.exports = function parseRow (row, servers) { 5 | // Parse name. 6 | var nameStart = 0 7 | var nameEnd = row.indexOf('(') - 1 8 | var name = row.slice(nameStart, nameEnd) 9 | 10 | // Parse ip. 11 | var ipStart = nameEnd + 2 12 | var ipEnd = row.indexOf(')', ipStart) 13 | var ipAddress = row.slice(ipStart, ipEnd) 14 | // Only resolve external ips. 15 | if (!~servers.indexOf(ipAddress)) { 16 | return 17 | } 18 | 19 | // Parse mac 20 | var macStart = row.indexOf(' at ', ipEnd) + 4 21 | var macEnd = row.indexOf(' on ', macStart) 22 | var macAddress = row.slice(macStart, macEnd) 23 | // Ignore unresolved hosts. 24 | if (macAddress === '(incomplete)') { 25 | return 26 | } 27 | // Format for always 2 digits 28 | macAddress = macAddress 29 | .replace(/^.:/, '0$&') 30 | .replace(/:.(?=:|$)/g, ':0X$&') 31 | .replace(/X:/g, '') 32 | 33 | return { 34 | name: name, 35 | ip: ipAddress, 36 | mac: macAddress 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/parser/linux.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Parses each row in the arp table into { name, ip, mac } on linux. 3 | * 4 | * partially inspired by https://github.com/goliatone/arpscan/blob/master/lib/arpscanner.js 5 | */ 6 | module.exports = function parseLinux (row, servers, parseOne) { 7 | var result = {} 8 | 9 | // Ignore unresolved hosts. 10 | if (row === '' || row.indexOf('incomplete') >= 0) { 11 | return 12 | } 13 | 14 | var chunks = row.split(' ').filter(Boolean) 15 | if (parseOne) { 16 | result = prepareOne(chunks) 17 | } else { 18 | result = prepareAll(chunks) 19 | } 20 | 21 | // Only resolve external ips. 22 | if (!~servers.indexOf(result.ip)) { 23 | return 24 | } 25 | 26 | return result 27 | } 28 | 29 | function prepareOne (chunks) { 30 | return { 31 | name: '?', // a hostname is not provided on the raspberry pi (linux) 32 | ip: chunks[0], 33 | mac: chunks[2] 34 | } 35 | } 36 | 37 | function prepareAll (chunks) { 38 | return { 39 | name: chunks[0], 40 | ip: chunks[1].match(/\((.*)\)/)[1], 41 | mac: chunks[3] 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/parser/win32.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Parses each row in the arp table into { name, ip, mac } on win32. 3 | */ 4 | module.exports = function parseRow (row, servers) { 5 | var chunks = row.split(/\s+/g).filter(function (el) { return el.length > 1 }) 6 | 7 | // Parse name. 8 | var ipAddress = chunks[0] 9 | // Only resolve external ips. 10 | if (!~servers.indexOf(ipAddress)) { 11 | return 12 | } 13 | 14 | // Parse mac 15 | var macAddress = chunks[1].replace(/-/g, ':') 16 | // Ignore unresolved hosts. 17 | if (macAddress === '(incomplete)') { 18 | return 19 | } 20 | 21 | return { 22 | name: '?', // unresolved 23 | ip: ipAddress, 24 | mac: macAddress 25 | } 26 | } 27 | --------------------------------------------------------------------------------