├── .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 | [](http://standardjs.com/)
6 | [](#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 |
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 |
--------------------------------------------------------------------------------