pubface 1.0.18 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/release.yaml +1 -1
- package/.github/workflows/test.yaml +22 -0
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +19 -0
- package/eslint.config.mjs +31 -0
- package/index.js +41 -34
- package/package.json +8 -4
- package/test/index.test.js +508 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
on:
|
|
2
|
+
push:
|
|
3
|
+
branches:
|
|
4
|
+
- master
|
|
5
|
+
pull_request:
|
|
6
|
+
branches:
|
|
7
|
+
- master
|
|
8
|
+
|
|
9
|
+
name: test
|
|
10
|
+
jobs:
|
|
11
|
+
test:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
strategy:
|
|
14
|
+
matrix:
|
|
15
|
+
node-version: [22, 24]
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
- uses: actions/setup-node@v4
|
|
19
|
+
with:
|
|
20
|
+
node-version: ${{ matrix.node-version }}
|
|
21
|
+
- run: npm ci
|
|
22
|
+
- run: npm test
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.1.0](https://github.com/postalsys/pubface/compare/v1.0.19...v1.1.0) (2026-03-25)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* add test suite, eslint, and CI workflow ([c0baf91](https://github.com/postalsys/pubface/commit/c0baf91b6b50339026c8808e5f7da65ff89d6678))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* require Node 22+ for test CI workflow ([0df3095](https://github.com/postalsys/pubface/commit/0df3095808bb56dcdb932b69d86f48b43428ae4a))
|
|
14
|
+
|
|
15
|
+
## [1.0.19](https://github.com/postalsys/pubface/compare/v1.0.18...v1.0.19) (2026-03-23)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Bug Fixes
|
|
19
|
+
|
|
20
|
+
* bumped deps ([d7708a3](https://github.com/postalsys/pubface/commit/d7708a3bca8bc7f63570a9be0a5b4bc65cf2980d))
|
|
21
|
+
|
|
3
22
|
## [1.0.18](https://github.com/postalsys/pubface/compare/v1.0.17...v1.0.18) (2026-01-28)
|
|
4
23
|
|
|
5
24
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import js from '@eslint/js';
|
|
2
|
+
|
|
3
|
+
export default [
|
|
4
|
+
{
|
|
5
|
+
ignores: ['node_modules/', 'ee-dist/']
|
|
6
|
+
},
|
|
7
|
+
js.configs.recommended,
|
|
8
|
+
{
|
|
9
|
+
files: ['**/*.js'],
|
|
10
|
+
languageOptions: {
|
|
11
|
+
ecmaVersion: 2022,
|
|
12
|
+
sourceType: 'commonjs',
|
|
13
|
+
globals: {
|
|
14
|
+
require: 'readonly',
|
|
15
|
+
module: 'readonly',
|
|
16
|
+
exports: 'readonly',
|
|
17
|
+
process: 'readonly',
|
|
18
|
+
__dirname: 'readonly',
|
|
19
|
+
__filename: 'readonly',
|
|
20
|
+
console: 'readonly',
|
|
21
|
+
Buffer: 'readonly',
|
|
22
|
+
setTimeout: 'readonly',
|
|
23
|
+
clearTimeout: 'readonly',
|
|
24
|
+
URL: 'readonly'
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
rules: {
|
|
28
|
+
'no-unused-vars': ['error', { argsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }]
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
];
|
package/index.js
CHANGED
|
@@ -11,6 +11,7 @@ const RESOLV_URL = process.env.RESOLV_URL || 'https://api.nodemailer.com/';
|
|
|
11
11
|
const RESOLV_TIMEOUT = Number(process.env.RESOLV_TIMEOUT) || 5;
|
|
12
12
|
|
|
13
13
|
const RESOLV_TIMEOUT_SEC = RESOLV_TIMEOUT * 1000;
|
|
14
|
+
const DNS_TTL = 10 * 60 * 1000;
|
|
14
15
|
const DNS_CACHE = {};
|
|
15
16
|
|
|
16
17
|
function getPtrAddr(address) {
|
|
@@ -33,7 +34,7 @@ function getPtrAddr(address) {
|
|
|
33
34
|
|
|
34
35
|
async function resolvePtr(address) {
|
|
35
36
|
let ptrAddress = getPtrAddr(address);
|
|
36
|
-
return
|
|
37
|
+
return dns.resolvePtr(ptrAddress);
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
async function updateDns() {
|
|
@@ -44,7 +45,7 @@ async function updateDns() {
|
|
|
44
45
|
DNS_CACHE.AAAA = false;
|
|
45
46
|
DNS_CACHE.A = {
|
|
46
47
|
host: url.hostname,
|
|
47
|
-
expires: new Date(Date.now() +
|
|
48
|
+
expires: new Date(Date.now() + DNS_TTL)
|
|
48
49
|
};
|
|
49
50
|
return;
|
|
50
51
|
}
|
|
@@ -53,7 +54,7 @@ async function updateDns() {
|
|
|
53
54
|
DNS_CACHE.A = false;
|
|
54
55
|
DNS_CACHE.AAAA = {
|
|
55
56
|
host: url.hostname,
|
|
56
|
-
expires: new Date(Date.now() +
|
|
57
|
+
expires: new Date(Date.now() + DNS_TTL)
|
|
57
58
|
};
|
|
58
59
|
return;
|
|
59
60
|
}
|
|
@@ -67,7 +68,7 @@ async function updateDns() {
|
|
|
67
68
|
if (results && results.length) {
|
|
68
69
|
DNS_CACHE.A = {
|
|
69
70
|
host: results[0],
|
|
70
|
-
expires: new Date(Date.now() +
|
|
71
|
+
expires: new Date(Date.now() + DNS_TTL)
|
|
71
72
|
};
|
|
72
73
|
}
|
|
73
74
|
} catch (err) {
|
|
@@ -84,7 +85,7 @@ async function updateDns() {
|
|
|
84
85
|
if (results && results.length) {
|
|
85
86
|
DNS_CACHE.AAAA = {
|
|
86
87
|
host: results[0],
|
|
87
|
-
expires: new Date(Date.now() +
|
|
88
|
+
expires: new Date(Date.now() + DNS_TTL)
|
|
88
89
|
};
|
|
89
90
|
}
|
|
90
91
|
} catch (err) {
|
|
@@ -99,23 +100,23 @@ async function updateDns() {
|
|
|
99
100
|
function getPublicInterfaces() {
|
|
100
101
|
let interfaces = os.networkInterfaces();
|
|
101
102
|
let publicInterfaces = { IPv4: [], IPv6: [] };
|
|
102
|
-
Object.keys(interfaces)
|
|
103
|
-
.flatMap(iface => interfaces[iface].filter(entry => !entry.internal).map(entry => Object.assign({ iface }, entry)))
|
|
104
|
-
.forEach(originalEntry => {
|
|
105
|
-
let entry = Object.assign({}, originalEntry);
|
|
106
103
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
104
|
+
for (let [name, entries] of Object.entries(interfaces)) {
|
|
105
|
+
for (let entry of entries) {
|
|
106
|
+
if (entry.internal) {
|
|
107
|
+
continue;
|
|
110
108
|
}
|
|
109
|
+
let family = typeof entry.family === 'number' ? `IPv${entry.family}` : entry.family;
|
|
111
110
|
if (Array.isArray(publicInterfaces[family])) {
|
|
112
|
-
publicInterfaces[family].push(entry);
|
|
111
|
+
publicInterfaces[family].push({ ...entry, iface: name, family });
|
|
113
112
|
}
|
|
114
|
-
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
115
116
|
return publicInterfaces;
|
|
116
117
|
}
|
|
117
118
|
|
|
118
|
-
|
|
119
|
+
function timedFunction(prom, timeout, localAddress) {
|
|
119
120
|
return new Promise((resolve, reject) => {
|
|
120
121
|
setTimeout(() => {
|
|
121
122
|
let err = new Error('Resolving requested resource timed out');
|
|
@@ -129,13 +130,14 @@ async function timedFunction(prom, timeout, localAddress) {
|
|
|
129
130
|
}
|
|
130
131
|
|
|
131
132
|
async function resolveIP(localAddress, family) {
|
|
133
|
+
let resolvHostname = new URL(RESOLV_URL).hostname;
|
|
132
134
|
let data = await new Promise((resolve, reject) => {
|
|
133
135
|
let req = fetchUrl(RESOLV_URL, {
|
|
134
136
|
userAgent: `${packageData.name}/${packageData.version}`,
|
|
135
137
|
tls: {
|
|
136
138
|
host: DNS_CACHE[family] && DNS_CACHE[family].host,
|
|
137
|
-
servername:
|
|
138
|
-
hostname:
|
|
139
|
+
servername: resolvHostname,
|
|
140
|
+
hostname: resolvHostname,
|
|
139
141
|
rejectUnauthorized: false,
|
|
140
142
|
localAddress
|
|
141
143
|
}
|
|
@@ -149,9 +151,7 @@ async function resolveIP(localAddress, family) {
|
|
|
149
151
|
}
|
|
150
152
|
});
|
|
151
153
|
|
|
152
|
-
req.on('error',
|
|
153
|
-
reject(err);
|
|
154
|
-
});
|
|
154
|
+
req.on('error', reject);
|
|
155
155
|
|
|
156
156
|
req.on('end', () => {
|
|
157
157
|
try {
|
|
@@ -172,39 +172,41 @@ async function resolveIP(localAddress, family) {
|
|
|
172
172
|
if (name && name.length) {
|
|
173
173
|
data.name = name[0];
|
|
174
174
|
}
|
|
175
|
-
} catch (
|
|
175
|
+
} catch (_err) {
|
|
176
176
|
// can ignore this
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
-
return
|
|
179
|
+
return { localAddress, ...data };
|
|
180
180
|
}
|
|
181
181
|
|
|
182
182
|
async function resolvePublicInterfaces() {
|
|
183
183
|
let interfaces = getPublicInterfaces();
|
|
184
184
|
let promises = [];
|
|
185
185
|
|
|
186
|
-
// update resolver IP4/6 addresses
|
|
187
186
|
await updateDns();
|
|
188
187
|
|
|
189
188
|
if (DNS_CACHE.A && DNS_CACHE.A.host) {
|
|
190
|
-
// default
|
|
191
189
|
promises.push(timedFunction(resolveIP(false, 'A'), RESOLV_TIMEOUT_SEC, false));
|
|
192
|
-
interfaces.IPv4
|
|
190
|
+
for (let iface of interfaces.IPv4) {
|
|
193
191
|
promises.push(timedFunction(resolveIP(iface.address, 'A'), RESOLV_TIMEOUT_SEC, iface.address));
|
|
194
|
-
}
|
|
192
|
+
}
|
|
195
193
|
}
|
|
196
194
|
|
|
197
195
|
if (DNS_CACHE.AAAA && DNS_CACHE.AAAA.host) {
|
|
198
196
|
promises.push(timedFunction(resolveIP(false, 'AAAA'), RESOLV_TIMEOUT_SEC, false));
|
|
199
|
-
interfaces.IPv6
|
|
197
|
+
for (let iface of interfaces.IPv6) {
|
|
200
198
|
promises.push(timedFunction(resolveIP(iface.address, 'AAAA'), RESOLV_TIMEOUT_SEC, iface.address));
|
|
201
|
-
}
|
|
199
|
+
}
|
|
202
200
|
}
|
|
203
201
|
|
|
204
202
|
let defaults = {};
|
|
205
203
|
let results = (await Promise.allSettled(promises))
|
|
206
204
|
.filter(entry => entry.status === 'fulfilled')
|
|
207
|
-
.map(entry =>
|
|
205
|
+
.map(entry => {
|
|
206
|
+
let value = entry.value;
|
|
207
|
+
value.family = net.isIPv6(value.ip || value.localAddress) ? 'IPv6' : 'IPv4';
|
|
208
|
+
return value;
|
|
209
|
+
})
|
|
208
210
|
.filter(entry => {
|
|
209
211
|
if (!entry.localAddress) {
|
|
210
212
|
defaults[entry.family] = entry;
|
|
@@ -213,22 +215,24 @@ async function resolvePublicInterfaces() {
|
|
|
213
215
|
return true;
|
|
214
216
|
});
|
|
215
217
|
|
|
216
|
-
|
|
218
|
+
for (let entry of results) {
|
|
217
219
|
if (defaults[entry.family] && defaults[entry.family].ip === entry.ip) {
|
|
218
220
|
entry.defaultInterface = true;
|
|
219
221
|
defaults[entry.family] = false;
|
|
220
222
|
}
|
|
221
|
-
}
|
|
223
|
+
}
|
|
222
224
|
|
|
223
225
|
if (defaults.IPv4) {
|
|
224
|
-
|
|
226
|
+
defaults.IPv4.defaultInterface = true;
|
|
227
|
+
results.push(defaults.IPv4);
|
|
225
228
|
}
|
|
226
229
|
|
|
227
230
|
if (defaults.IPv6) {
|
|
228
|
-
|
|
231
|
+
defaults.IPv6.defaultInterface = true;
|
|
232
|
+
results.push(defaults.IPv6);
|
|
229
233
|
}
|
|
230
234
|
|
|
231
|
-
results
|
|
235
|
+
results.sort((a, b) => {
|
|
232
236
|
if (a.family !== b.family) {
|
|
233
237
|
return a.family.localeCompare(b.family);
|
|
234
238
|
}
|
|
@@ -245,3 +249,6 @@ async function resolvePublicInterfaces() {
|
|
|
245
249
|
}
|
|
246
250
|
|
|
247
251
|
module.exports = { resolvePublicInterfaces };
|
|
252
|
+
|
|
253
|
+
// exported for testing
|
|
254
|
+
module.exports._internal = { getPtrAddr, getPublicInterfaces, timedFunction, resolvePtr, updateDns, resolveIP, DNS_CACHE, RESOLV_TIMEOUT_SEC };
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pubface",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Resolve public network interfaces for current machine",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"test": "
|
|
7
|
+
"test": "node --test 'test/**/*.test.js'",
|
|
8
8
|
"format": "prettier --write .",
|
|
9
|
+
"lint": "eslint .",
|
|
9
10
|
"build-source": "rm -rf node_modules package-lock.json && npm install && npm run licenses && rm -rf node_modules package-lock.json && npm install --production && rm -rf package-lock.json",
|
|
10
11
|
"build-dist": "npx pkg --compress Brotli package.json && rm -rf package-lock.json && npm install",
|
|
11
12
|
"build-dist-fast": "npx pkg --debug package.json && rm -rf package-lock.json && npm install",
|
|
@@ -28,11 +29,14 @@
|
|
|
28
29
|
},
|
|
29
30
|
"dependencies": {
|
|
30
31
|
"ipaddr.js": "2.3.0",
|
|
31
|
-
"nodemailer": "
|
|
32
|
+
"nodemailer": "8.0.4"
|
|
32
33
|
},
|
|
33
34
|
"devDependencies": {
|
|
35
|
+
"@eslint/js": "^10.0.1",
|
|
36
|
+
"eslint": "^10.1.0",
|
|
37
|
+
"license-report": "6.8.2",
|
|
34
38
|
"pkg": "5.8.1",
|
|
35
|
-
"
|
|
39
|
+
"prettier": "^3.8.1"
|
|
36
40
|
},
|
|
37
41
|
"pkg": {
|
|
38
42
|
"assets": [
|
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { describe, it, mock, afterEach } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
const dns = require('node:dns');
|
|
7
|
+
|
|
8
|
+
const { resolvePublicInterfaces, _internal } = require('../index.js');
|
|
9
|
+
const { getPtrAddr, getPublicInterfaces, timedFunction, resolvePtr, updateDns, DNS_CACHE, RESOLV_TIMEOUT_SEC } = _internal;
|
|
10
|
+
|
|
11
|
+
function resetDnsCache() {
|
|
12
|
+
delete DNS_CACHE.A;
|
|
13
|
+
delete DNS_CACHE.AAAA;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// -- getPtrAddr ---------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
describe('getPtrAddr', () => {
|
|
19
|
+
it('should convert IPv4 to reverse PTR format', () => {
|
|
20
|
+
assert.equal(getPtrAddr('1.2.3.4'), '4.3.2.1.in-addr.arpa.');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('should reverse octets for 192.168.1.1', () => {
|
|
24
|
+
assert.equal(getPtrAddr('192.168.1.1'), '1.1.168.192.in-addr.arpa.');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should handle 0.0.0.0', () => {
|
|
28
|
+
assert.equal(getPtrAddr('0.0.0.0'), '0.0.0.0.in-addr.arpa.');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should handle 255.255.255.255', () => {
|
|
32
|
+
assert.equal(getPtrAddr('255.255.255.255'), '255.255.255.255.in-addr.arpa.');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should handle 10.0.0.1', () => {
|
|
36
|
+
assert.equal(getPtrAddr('10.0.0.1'), '1.0.0.10.in-addr.arpa.');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should convert IPv6 loopback to PTR format', () => {
|
|
40
|
+
// ::1 bytes are all 0x00 except the last which is 0x01
|
|
41
|
+
// All bytes < 0x0a, so the padding bug does not affect this case
|
|
42
|
+
const result = getPtrAddr('::1');
|
|
43
|
+
assert.equal(result, '1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.ip6.arpa.');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should convert IPv6 address with only sub-0x0a bytes', () => {
|
|
47
|
+
// 2001:0002:0003:0004:0005:0006:0007:0008
|
|
48
|
+
// All bytes are < 0x0a, so no padding bug
|
|
49
|
+
const result = getPtrAddr('2001:2:3:4:5:6:7:8');
|
|
50
|
+
assert.equal(result, '8.0.0.0.7.0.0.0.6.0.0.0.5.0.0.0.4.0.0.0.3.0.0.0.2.0.0.0.1.0.0.2.ip6.arpa.');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should produce result for 2001:db8::1 (known hex-padding bug)', () => {
|
|
54
|
+
// Bug: byte 0x0d (13) only produces 'd' instead of '0d' because the
|
|
55
|
+
// padding condition uses 0x0a instead of 0x10. This results in 31
|
|
56
|
+
// nibbles instead of 32, making the PTR address invalid.
|
|
57
|
+
const result = getPtrAddr('2001:db8::1');
|
|
58
|
+
// Actual (buggy) output -- the '0' before 'd' is missing:
|
|
59
|
+
assert.equal(result, '1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.1.0.0.2.ip6.arpa.');
|
|
60
|
+
// Correct output would be:
|
|
61
|
+
// '1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa.'
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should produce result for full IPv6 (known hex-padding bug)', () => {
|
|
65
|
+
// 2001:0db8:85a3::8a2e:0370:7334
|
|
66
|
+
// Byte 0x0d produces 'd' instead of '0d'
|
|
67
|
+
const result = getPtrAddr('2001:0db8:85a3:0000:0000:8a2e:0370:7334');
|
|
68
|
+
assert.equal(result, '4.3.3.7.0.7.3.0.e.2.a.8.0.0.0.0.0.0.0.0.3.a.5.8.8.b.d.1.0.0.2.ip6.arpa.');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should throw for invalid address', () => {
|
|
72
|
+
assert.throws(() => getPtrAddr('not-an-ip'));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should throw for empty string', () => {
|
|
76
|
+
assert.throws(() => getPtrAddr(''));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('should return undefined for address that parses but is neither v4 nor v6', () => {
|
|
80
|
+
// This cannot actually happen with valid IP addresses, but tests the fallthrough
|
|
81
|
+
// ipaddr.parse only accepts v4/v6, so it throws first
|
|
82
|
+
assert.throws(() => getPtrAddr('abc'));
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// -- getPublicInterfaces ------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
describe('getPublicInterfaces', () => {
|
|
89
|
+
afterEach(() => {
|
|
90
|
+
mock.restoreAll();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should filter out internal interfaces', () => {
|
|
94
|
+
mock.method(os, 'networkInterfaces', () => ({
|
|
95
|
+
lo0: [{ address: '127.0.0.1', family: 'IPv4', internal: true, mac: '00:00:00:00:00:00' }],
|
|
96
|
+
en0: [{ address: '192.168.1.100', family: 'IPv4', internal: false, mac: 'aa:bb:cc:dd:ee:ff' }]
|
|
97
|
+
}));
|
|
98
|
+
|
|
99
|
+
const result = getPublicInterfaces();
|
|
100
|
+
assert.equal(result.IPv4.length, 1);
|
|
101
|
+
assert.equal(result.IPv4[0].address, '192.168.1.100');
|
|
102
|
+
assert.equal(result.IPv4[0].iface, 'en0');
|
|
103
|
+
assert.equal(result.IPv6.length, 0);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should group interfaces by IPv4 and IPv6', () => {
|
|
107
|
+
mock.method(os, 'networkInterfaces', () => ({
|
|
108
|
+
en0: [
|
|
109
|
+
{ address: '192.168.1.100', family: 'IPv4', internal: false },
|
|
110
|
+
{ address: 'fe80::1', family: 'IPv6', internal: false }
|
|
111
|
+
]
|
|
112
|
+
}));
|
|
113
|
+
|
|
114
|
+
const result = getPublicInterfaces();
|
|
115
|
+
assert.equal(result.IPv4.length, 1);
|
|
116
|
+
assert.equal(result.IPv6.length, 1);
|
|
117
|
+
assert.equal(result.IPv4[0].address, '192.168.1.100');
|
|
118
|
+
assert.equal(result.IPv6[0].address, 'fe80::1');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('should normalize numeric family 4 to IPv4', () => {
|
|
122
|
+
mock.method(os, 'networkInterfaces', () => ({
|
|
123
|
+
en0: [{ address: '192.168.1.100', family: 4, internal: false }]
|
|
124
|
+
}));
|
|
125
|
+
|
|
126
|
+
const result = getPublicInterfaces();
|
|
127
|
+
assert.equal(result.IPv4[0].family, 'IPv4');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('should normalize numeric family 6 to IPv6', () => {
|
|
131
|
+
mock.method(os, 'networkInterfaces', () => ({
|
|
132
|
+
en0: [{ address: 'fe80::1', family: 6, internal: false }]
|
|
133
|
+
}));
|
|
134
|
+
|
|
135
|
+
const result = getPublicInterfaces();
|
|
136
|
+
assert.equal(result.IPv6[0].family, 'IPv6');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('should return empty arrays when no public interfaces', () => {
|
|
140
|
+
mock.method(os, 'networkInterfaces', () => ({
|
|
141
|
+
lo0: [{ address: '127.0.0.1', family: 'IPv4', internal: true }]
|
|
142
|
+
}));
|
|
143
|
+
|
|
144
|
+
const result = getPublicInterfaces();
|
|
145
|
+
assert.equal(result.IPv4.length, 0);
|
|
146
|
+
assert.equal(result.IPv6.length, 0);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('should return empty arrays for empty interface map', () => {
|
|
150
|
+
mock.method(os, 'networkInterfaces', () => ({}));
|
|
151
|
+
|
|
152
|
+
const result = getPublicInterfaces();
|
|
153
|
+
assert.equal(result.IPv4.length, 0);
|
|
154
|
+
assert.equal(result.IPv6.length, 0);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('should collect interfaces from multiple adapters', () => {
|
|
158
|
+
mock.method(os, 'networkInterfaces', () => ({
|
|
159
|
+
en0: [{ address: '192.168.1.100', family: 'IPv4', internal: false }],
|
|
160
|
+
en1: [{ address: '10.0.0.1', family: 'IPv4', internal: false }],
|
|
161
|
+
lo0: [{ address: '127.0.0.1', family: 'IPv4', internal: true }]
|
|
162
|
+
}));
|
|
163
|
+
|
|
164
|
+
const result = getPublicInterfaces();
|
|
165
|
+
assert.equal(result.IPv4.length, 2);
|
|
166
|
+
const addresses = result.IPv4.map(i => i.address).sort();
|
|
167
|
+
assert.deepEqual(addresses, ['10.0.0.1', '192.168.1.100']);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('should attach iface name to each entry', () => {
|
|
171
|
+
mock.method(os, 'networkInterfaces', () => ({
|
|
172
|
+
eth0: [{ address: '10.0.0.5', family: 'IPv4', internal: false }]
|
|
173
|
+
}));
|
|
174
|
+
|
|
175
|
+
const result = getPublicInterfaces();
|
|
176
|
+
assert.equal(result.IPv4[0].iface, 'eth0');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('should not mutate original interface entries', () => {
|
|
180
|
+
const original = { address: '192.168.1.100', family: 4, internal: false };
|
|
181
|
+
mock.method(os, 'networkInterfaces', () => ({ en0: [original] }));
|
|
182
|
+
|
|
183
|
+
getPublicInterfaces();
|
|
184
|
+
assert.equal(original.family, 4);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('should ignore unknown family types', () => {
|
|
188
|
+
mock.method(os, 'networkInterfaces', () => ({
|
|
189
|
+
en0: [{ address: '192.168.1.100', family: 'IPX', internal: false }]
|
|
190
|
+
}));
|
|
191
|
+
|
|
192
|
+
const result = getPublicInterfaces();
|
|
193
|
+
assert.equal(result.IPv4.length, 0);
|
|
194
|
+
assert.equal(result.IPv6.length, 0);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('should handle adapter with mixed internal and external', () => {
|
|
198
|
+
mock.method(os, 'networkInterfaces', () => ({
|
|
199
|
+
en0: [
|
|
200
|
+
{ address: '127.0.0.1', family: 'IPv4', internal: true },
|
|
201
|
+
{ address: '192.168.1.100', family: 'IPv4', internal: false },
|
|
202
|
+
{ address: '::1', family: 'IPv6', internal: true },
|
|
203
|
+
{ address: 'fe80::1', family: 'IPv6', internal: false }
|
|
204
|
+
]
|
|
205
|
+
}));
|
|
206
|
+
|
|
207
|
+
const result = getPublicInterfaces();
|
|
208
|
+
assert.equal(result.IPv4.length, 1);
|
|
209
|
+
assert.equal(result.IPv6.length, 1);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// -- timedFunction ------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
describe('timedFunction', () => {
|
|
216
|
+
it('should resolve when promise resolves before timeout', async () => {
|
|
217
|
+
const result = await timedFunction(Promise.resolve('ok'), 1000);
|
|
218
|
+
assert.equal(result, 'ok');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('should resolve with complex data', async () => {
|
|
222
|
+
const data = { ip: '1.2.3.4', name: 'test.example.com' };
|
|
223
|
+
const result = await timedFunction(Promise.resolve(data), 1000);
|
|
224
|
+
assert.deepEqual(result, data);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('should reject when promise rejects before timeout', async () => {
|
|
228
|
+
await assert.rejects(() => timedFunction(Promise.reject(new Error('network error')), 1000), { message: 'network error' });
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('should reject with timeout error when promise does not settle', async () => {
|
|
232
|
+
let cleanup;
|
|
233
|
+
const slow = new Promise(resolve => {
|
|
234
|
+
cleanup = resolve;
|
|
235
|
+
});
|
|
236
|
+
// ref'd timer keeps event loop alive while the .unref()'d timer in timedFunction fires
|
|
237
|
+
const keepAlive = setTimeout(() => {}, 5000);
|
|
238
|
+
try {
|
|
239
|
+
await assert.rejects(() => timedFunction(slow, 50), { message: 'Resolving requested resource timed out' });
|
|
240
|
+
} finally {
|
|
241
|
+
clearTimeout(keepAlive);
|
|
242
|
+
cleanup();
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('should include _source on timeout error when localAddress given', async () => {
|
|
247
|
+
let cleanup;
|
|
248
|
+
const slow = new Promise(resolve => {
|
|
249
|
+
cleanup = resolve;
|
|
250
|
+
});
|
|
251
|
+
const keepAlive = setTimeout(() => {}, 5000);
|
|
252
|
+
try {
|
|
253
|
+
await timedFunction(slow, 50, '192.168.1.100');
|
|
254
|
+
assert.fail('Should have thrown');
|
|
255
|
+
} catch (err) {
|
|
256
|
+
assert.equal(err.message, 'Resolving requested resource timed out');
|
|
257
|
+
assert.equal(err._source, '192.168.1.100');
|
|
258
|
+
} finally {
|
|
259
|
+
clearTimeout(keepAlive);
|
|
260
|
+
cleanup();
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('should not set _source when localAddress is false', async () => {
|
|
265
|
+
let cleanup;
|
|
266
|
+
const slow = new Promise(resolve => {
|
|
267
|
+
cleanup = resolve;
|
|
268
|
+
});
|
|
269
|
+
const keepAlive = setTimeout(() => {}, 5000);
|
|
270
|
+
try {
|
|
271
|
+
await timedFunction(slow, 50, false);
|
|
272
|
+
assert.fail('Should have thrown');
|
|
273
|
+
} catch (err) {
|
|
274
|
+
assert.equal(err._source, undefined);
|
|
275
|
+
} finally {
|
|
276
|
+
clearTimeout(keepAlive);
|
|
277
|
+
cleanup();
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('should not set _source when localAddress is omitted', async () => {
|
|
282
|
+
let cleanup;
|
|
283
|
+
const slow = new Promise(resolve => {
|
|
284
|
+
cleanup = resolve;
|
|
285
|
+
});
|
|
286
|
+
const keepAlive = setTimeout(() => {}, 5000);
|
|
287
|
+
try {
|
|
288
|
+
await timedFunction(slow, 50);
|
|
289
|
+
assert.fail('Should have thrown');
|
|
290
|
+
} catch (err) {
|
|
291
|
+
assert.equal(err._source, undefined);
|
|
292
|
+
} finally {
|
|
293
|
+
clearTimeout(keepAlive);
|
|
294
|
+
cleanup();
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// -- resolvePtr ---------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
describe('resolvePtr', () => {
|
|
302
|
+
afterEach(() => {
|
|
303
|
+
mock.restoreAll();
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('should call dns.resolvePtr with correct PTR address for IPv4', async () => {
|
|
307
|
+
mock.method(dns.promises, 'resolvePtr', async addr => {
|
|
308
|
+
assert.equal(addr, '4.3.2.1.in-addr.arpa.');
|
|
309
|
+
return ['host.example.com'];
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
const result = await resolvePtr('1.2.3.4');
|
|
313
|
+
assert.deepEqual(result, ['host.example.com']);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it('should return multiple PTR records', async () => {
|
|
317
|
+
mock.method(dns.promises, 'resolvePtr', async () => {
|
|
318
|
+
return ['a.example.com', 'b.example.com'];
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
const result = await resolvePtr('1.2.3.4');
|
|
322
|
+
assert.equal(result.length, 2);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('should propagate DNS errors', async () => {
|
|
326
|
+
mock.method(dns.promises, 'resolvePtr', async () => {
|
|
327
|
+
throw new Error('ENOTFOUND');
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
await assert.rejects(() => resolvePtr('1.2.3.4'), { message: 'ENOTFOUND' });
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// -- updateDns ----------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
describe('updateDns', () => {
|
|
337
|
+
afterEach(() => {
|
|
338
|
+
mock.restoreAll();
|
|
339
|
+
resetDnsCache();
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('should populate A cache on successful IPv4 resolution', async () => {
|
|
343
|
+
mock.method(dns.promises, 'resolve4', async () => ['93.184.216.34']);
|
|
344
|
+
mock.method(dns.promises, 'resolve6', async () => {
|
|
345
|
+
throw new Error('ENODATA');
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
await updateDns();
|
|
349
|
+
|
|
350
|
+
assert.ok(DNS_CACHE.A);
|
|
351
|
+
assert.equal(DNS_CACHE.A.host, '93.184.216.34');
|
|
352
|
+
assert.ok(DNS_CACHE.A.expires instanceof Date);
|
|
353
|
+
assert.ok(DNS_CACHE.A.expires > new Date());
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('should populate AAAA cache on successful IPv6 resolution', async () => {
|
|
357
|
+
mock.method(dns.promises, 'resolve4', async () => {
|
|
358
|
+
throw new Error('ENODATA');
|
|
359
|
+
});
|
|
360
|
+
mock.method(dns.promises, 'resolve6', async () => ['2606:2800:220:1:248:1893:25c8:1946']);
|
|
361
|
+
|
|
362
|
+
await updateDns();
|
|
363
|
+
|
|
364
|
+
assert.ok(DNS_CACHE.AAAA);
|
|
365
|
+
assert.equal(DNS_CACHE.AAAA.host, '2606:2800:220:1:248:1893:25c8:1946');
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it('should populate both A and AAAA when both resolve', async () => {
|
|
369
|
+
mock.method(dns.promises, 'resolve4', async () => ['93.184.216.34']);
|
|
370
|
+
mock.method(dns.promises, 'resolve6', async () => ['2606:2800:220:1:248:1893:25c8:1946']);
|
|
371
|
+
|
|
372
|
+
await updateDns();
|
|
373
|
+
|
|
374
|
+
assert.equal(DNS_CACHE.A.host, '93.184.216.34');
|
|
375
|
+
assert.equal(DNS_CACHE.AAAA.host, '2606:2800:220:1:248:1893:25c8:1946');
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it('should store error when IPv4 resolution fails', async () => {
|
|
379
|
+
const err = new Error('ENOTFOUND');
|
|
380
|
+
mock.method(dns.promises, 'resolve4', async () => {
|
|
381
|
+
throw err;
|
|
382
|
+
});
|
|
383
|
+
mock.method(dns.promises, 'resolve6', async () => {
|
|
384
|
+
throw new Error('ENODATA');
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
await updateDns();
|
|
388
|
+
|
|
389
|
+
assert.ok(DNS_CACHE.A);
|
|
390
|
+
assert.equal(DNS_CACHE.A.error, err);
|
|
391
|
+
assert.equal(DNS_CACHE.A.host, undefined);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it('should store error when IPv6 resolution fails', async () => {
|
|
395
|
+
const err = new Error('ENOTFOUND');
|
|
396
|
+
mock.method(dns.promises, 'resolve4', async () => {
|
|
397
|
+
throw new Error('ENODATA');
|
|
398
|
+
});
|
|
399
|
+
mock.method(dns.promises, 'resolve6', async () => {
|
|
400
|
+
throw err;
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
await updateDns();
|
|
404
|
+
|
|
405
|
+
assert.ok(DNS_CACHE.AAAA);
|
|
406
|
+
assert.equal(DNS_CACHE.AAAA.error, err);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it('should use first result when DNS returns multiple addresses', async () => {
|
|
410
|
+
mock.method(dns.promises, 'resolve4', async () => ['1.1.1.1', '2.2.2.2']);
|
|
411
|
+
mock.method(dns.promises, 'resolve6', async () => {
|
|
412
|
+
throw new Error('ENODATA');
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
await updateDns();
|
|
416
|
+
|
|
417
|
+
assert.equal(DNS_CACHE.A.host, '1.1.1.1');
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it('should set expiry 10 minutes in the future', async () => {
|
|
421
|
+
mock.method(dns.promises, 'resolve4', async () => ['1.1.1.1']);
|
|
422
|
+
mock.method(dns.promises, 'resolve6', async () => {
|
|
423
|
+
throw new Error('ENODATA');
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
const before = Date.now();
|
|
427
|
+
await updateDns();
|
|
428
|
+
const after = Date.now();
|
|
429
|
+
|
|
430
|
+
const expiryMs = DNS_CACHE.A.expires.getTime();
|
|
431
|
+
// Should be ~10 minutes (600000ms) from now
|
|
432
|
+
assert.ok(expiryMs >= before + 9 * 60 * 1000);
|
|
433
|
+
assert.ok(expiryMs <= after + 11 * 60 * 1000);
|
|
434
|
+
});
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
// -- RESOLV_TIMEOUT_SEC -------------------------------------------------------
|
|
438
|
+
|
|
439
|
+
describe('RESOLV_TIMEOUT_SEC', () => {
|
|
440
|
+
it('should default to 5000ms (RESOLV_TIMEOUT=5)', () => {
|
|
441
|
+
assert.equal(RESOLV_TIMEOUT_SEC, 5000);
|
|
442
|
+
});
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
// -- resolvePublicInterfaces (no-network) -------------------------------------
|
|
446
|
+
|
|
447
|
+
describe('resolvePublicInterfaces', () => {
|
|
448
|
+
afterEach(() => {
|
|
449
|
+
mock.restoreAll();
|
|
450
|
+
resetDnsCache();
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it('should be a function', () => {
|
|
454
|
+
assert.equal(typeof resolvePublicInterfaces, 'function');
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it('should return empty array when all DNS resolution fails', async () => {
|
|
458
|
+
mock.method(dns.promises, 'resolve4', async () => {
|
|
459
|
+
throw new Error('ENODATA');
|
|
460
|
+
});
|
|
461
|
+
mock.method(dns.promises, 'resolve6', async () => {
|
|
462
|
+
throw new Error('ENODATA');
|
|
463
|
+
});
|
|
464
|
+
mock.method(os, 'networkInterfaces', () => ({}));
|
|
465
|
+
|
|
466
|
+
const result = await resolvePublicInterfaces();
|
|
467
|
+
assert.ok(Array.isArray(result));
|
|
468
|
+
assert.equal(result.length, 0);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it('should return empty array when DNS succeeds but no interfaces and API unreachable', async () => {
|
|
472
|
+
mock.method(dns.promises, 'resolve4', async () => ['127.0.0.1']);
|
|
473
|
+
mock.method(dns.promises, 'resolve6', async () => {
|
|
474
|
+
throw new Error('ENODATA');
|
|
475
|
+
});
|
|
476
|
+
mock.method(os, 'networkInterfaces', () => ({}));
|
|
477
|
+
|
|
478
|
+
// resolveIP will be called for the default interface (localAddress=false)
|
|
479
|
+
// It will fail since 127.0.0.1 is not a real resolver, but allSettled handles it
|
|
480
|
+
const result = await resolvePublicInterfaces();
|
|
481
|
+
assert.ok(Array.isArray(result));
|
|
482
|
+
});
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
// -- module exports -----------------------------------------------------------
|
|
486
|
+
|
|
487
|
+
describe('module exports', () => {
|
|
488
|
+
it('should export resolvePublicInterfaces', () => {
|
|
489
|
+
const mod = require('../index.js');
|
|
490
|
+
assert.equal(typeof mod.resolvePublicInterfaces, 'function');
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it('should export _internal with all helper functions', () => {
|
|
494
|
+
const mod = require('../index.js');
|
|
495
|
+
assert.ok(mod._internal);
|
|
496
|
+
assert.equal(typeof mod._internal.getPtrAddr, 'function');
|
|
497
|
+
assert.equal(typeof mod._internal.getPublicInterfaces, 'function');
|
|
498
|
+
assert.equal(typeof mod._internal.timedFunction, 'function');
|
|
499
|
+
assert.equal(typeof mod._internal.resolvePtr, 'function');
|
|
500
|
+
assert.equal(typeof mod._internal.updateDns, 'function');
|
|
501
|
+
assert.equal(typeof mod._internal.resolveIP, 'function');
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it('should export DNS_CACHE as an object', () => {
|
|
505
|
+
const mod = require('../index.js');
|
|
506
|
+
assert.equal(typeof mod._internal.DNS_CACHE, 'object');
|
|
507
|
+
});
|
|
508
|
+
});
|