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.
@@ -31,6 +31,6 @@ jobs:
31
31
  - uses: actions/setup-node@v4
32
32
  with:
33
33
  node-version: 24
34
- registry-url: "https://registry.npmjs.org"
34
+ registry-url: 'https://registry.npmjs.org'
35
35
  - run: npm ci
36
36
  - run: npm publish --provenance --access public
@@ -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
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "1.0.18"
2
+ ".": "1.1.0"
3
3
  }
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 await dns.resolvePtr(ptrAddress);
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() + 10 * 60 * 1000)
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() + 10 * 60 * 1000)
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() + 10 * 60 * 1000)
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() + 10 * 60 * 1000)
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
- let family = typeof entry.family === 'number' ? `IPv${entry.family}` : entry.family;
108
- if (entry.family !== family) {
109
- entry.family = family;
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
- async function timedFunction(prom, timeout, localAddress) {
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: new URL(RESOLV_URL).hostname,
138
- hostname: new URL(RESOLV_URL).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', err => {
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 (err) {
175
+ } catch (_err) {
176
176
  // can ignore this
177
177
  }
178
178
 
179
- return Object.assign({ localAddress }, data);
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.forEach(iface => {
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.forEach(iface => {
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 => Object.assign(entry.value, { family: net.isIPv6(entry.value.ip || entry.value.localAddress) ? 'IPv6' : 'IPv4' }))
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
- results.forEach(entry => {
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
- results.push(Object.assign(defaults.IPv4, { defaultInterface: true }));
226
+ defaults.IPv4.defaultInterface = true;
227
+ results.push(defaults.IPv4);
225
228
  }
226
229
 
227
230
  if (defaults.IPv6) {
228
- results.push(Object.assign(defaults.IPv6, { defaultInterface: true }));
231
+ defaults.IPv6.defaultInterface = true;
232
+ results.push(defaults.IPv6);
229
233
  }
230
234
 
231
- results = results.sort((a, b) => {
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.18",
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": "echo \"Error: no test specified\" && exit 1",
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": "7.0.13"
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
- "license-report": "6.8.1"
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
+ });