ipv6-bridge 1.0.0 → 2.1.1

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/src/dns64.js CHANGED
@@ -1,13 +1,50 @@
1
1
  /**
2
2
  * IPv6 Bridge - DNS64 Resolver
3
3
  *
4
- * Implements DNS64 (RFC 6052) for synthesizing IPv6 addresses from IPv4.
4
+ * Implements DNS64 (RFC 6147) using the IPv4-embedded IPv6 address format
5
+ * defined by RFC 6052.
6
+ *
7
+ * Name resolution goes through dns.lookup (the operating system resolver)
8
+ * rather than dns.resolve*. dns.resolve* speaks directly to DNS servers over
9
+ * port 53 and ignores the hosts file, mDNS, DNS-over-HTTPS and any other
10
+ * system resolver configuration — on a DoH-only or split-DNS host it fails
11
+ * outright even though normal name resolution works fine.
5
12
  *
6
13
  * @module dns64
7
14
  */
8
15
 
9
- const dns = require('dns').promises;
10
- const { NAT64_PREFIX } = require('./config');
16
+ const dns = require('dns');
17
+ const net = require('net');
18
+ const { embedIPv4, extractIPv4 } = require('./ipv6');
19
+ const { TtlCache } = require('./cache');
20
+ const config = require('./config');
21
+
22
+ /**
23
+ * IPv4 ranges that are not globally reachable (RFC 6890).
24
+ * Stored as [network, prefixLength] pairs.
25
+ */
26
+ const NON_GLOBAL_IPV4_RANGES = [
27
+ ['0.0.0.0', 8],
28
+ ['10.0.0.0', 8],
29
+ ['100.64.0.0', 10],
30
+ ['127.0.0.0', 8],
31
+ ['169.254.0.0', 16],
32
+ ['172.16.0.0', 12],
33
+ ['192.0.0.0', 24],
34
+ ['192.0.2.0', 24],
35
+ ['192.88.99.0', 24],
36
+ ['192.168.0.0', 16],
37
+ ['198.18.0.0', 15],
38
+ ['198.51.100.0', 24],
39
+ ['203.0.113.0', 24],
40
+ ['224.0.0.0', 4],
41
+ ['240.0.0.0', 4],
42
+ ];
43
+
44
+ const dnsCache = new TtlCache({
45
+ max: config.DNS_CACHE_MAX,
46
+ ttl: config.DNS_CACHE_TTL,
47
+ });
11
48
 
12
49
  /**
13
50
  * Detect the IP version of an address string.
@@ -16,75 +53,220 @@ const { NAT64_PREFIX } = require('./config');
16
53
  * @returns {'ipv4'|'ipv6'|'hostname'|null} Address type
17
54
  */
18
55
  function detectIPVersion(addr) {
19
- if (!addr) return null;
56
+ if (!addr || typeof addr !== 'string') return null;
20
57
 
21
- // IPv4: dotted decimal (0-255 per octet)
22
- if (/^(\d{1,3}\.){3}\d{1,3}$/.test(addr)) {
23
- const parts = addr.split('.').map(Number);
24
- if (parts.every((p) => p >= 0 && p <= 255)) {
25
- return 'ipv4';
26
- }
27
- }
28
-
29
- // IPv6: colon-delimited hex (includes :: shorthand)
30
- if (/^[a-f0-9:]+$/i.test(addr) && addr.includes(':')) {
31
- return 'ipv6';
32
- }
58
+ const result = net.isIP(addr);
59
+ if (result === 4) return 'ipv4';
60
+ if (result === 6) return 'ipv6';
33
61
 
34
- // Everything else is a hostname
62
+ // net.isIP returns 0 for non-IP strings (i.e. hostnames)
35
63
  return 'hostname';
36
64
  }
37
65
 
66
+ function ipv4ToInt(ipv4) {
67
+ return ipv4.split('.').reduce((acc, octet) => (acc * 256) + Number(octet), 0);
68
+ }
69
+
38
70
  /**
39
- * Convert an IPv4 address to IPv6 using the NAT64 prefix (RFC 6052).
71
+ * Check whether an IPv4 address is globally reachable.
40
72
  *
41
- * The IPv4 address is embedded in the lower 32 bits of the IPv6 address:
42
- * 192.0.2.1 64:ff9b::c000:0201
73
+ * RFC 6052 section 3.1 forbids representing non-global IPv4 addresses with the
74
+ * well-known prefix, so these must not be handed to a NAT64 gateway.
75
+ *
76
+ * @param {string} ipv4 - IPv4 address
77
+ * @returns {boolean} true if the address is globally routable
78
+ */
79
+ function isGlobalIPv4(ipv4) {
80
+ if (net.isIP(ipv4) !== 4) return false;
81
+
82
+ const addr = ipv4ToInt(ipv4);
83
+ return !NON_GLOBAL_IPV4_RANGES.some(([network, bits]) => {
84
+ const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
85
+ return ((addr & mask) >>> 0) === ((ipv4ToInt(network) & mask) >>> 0);
86
+ });
87
+ }
88
+
89
+ /**
90
+ * Whether an IPv4 address may be synthesized with the configured prefix.
91
+ *
92
+ * An operator-assigned network-specific prefix may carry non-global addresses;
93
+ * the well-known prefix may not.
94
+ *
95
+ * @param {string} ipv4 - IPv4 address
96
+ * @returns {boolean} true if synthesis is permitted
97
+ */
98
+ function canSynthesize(ipv4) {
99
+ if (net.isIP(ipv4) !== 4) return false;
100
+ return !config.usingWellKnownPrefix() || isGlobalIPv4(ipv4);
101
+ }
102
+
103
+ /**
104
+ * Convert an IPv4 address to IPv6 using the configured NAT64 prefix (RFC 6052).
105
+ *
106
+ * This is a pure format conversion; it does not enforce RFC 6052 section 3.1.
107
+ * Use canSynthesize() to decide whether conversion is appropriate.
43
108
  *
44
109
  * @param {string} ipv4 - IPv4 address (e.g., '192.0.2.1')
45
- * @returns {string} IPv6 address with NAT64 prefix
110
+ * @returns {string} IPv4-embedded IPv6 address
46
111
  * @throws {Error} If the input is not a valid IPv4 address
47
112
  */
48
113
  function ipv4ToIPv6(ipv4) {
49
114
  if (!ipv4 || typeof ipv4 !== 'string') {
50
115
  throw new Error('ipv4ToIPv6: address must be a non-empty string');
51
116
  }
52
-
53
- const parts = ipv4.split('.').map(Number);
54
- if (parts.length !== 4 || parts.some((p) => !Number.isInteger(p) || p < 0 || p > 255)) {
117
+ if (net.isIP(ipv4) !== 4) {
55
118
  throw new Error(`ipv4ToIPv6: invalid IPv4 address "${ipv4}"`);
56
119
  }
120
+ return embedIPv4(ipv4, config.getPrefix());
121
+ }
57
122
 
58
- const hex1 = ((parts[0] << 8) | parts[1]).toString(16).padStart(4, '0');
59
- const hex2 = ((parts[2] << 8) | parts[3]).toString(16).padStart(4, '0');
60
- return `${NAT64_PREFIX}${hex1}:${hex2}`;
123
+ /**
124
+ * Recover the IPv4 address embedded in a synthesized IPv6 address.
125
+ *
126
+ * @param {string} address - IPv4-embedded IPv6 address
127
+ * @returns {string|null} The IPv4 address, or null if not embedded
128
+ */
129
+ function ipv6ToIPv4(address) {
130
+ return extractIPv4(address, config.getPrefix().length);
61
131
  }
62
132
 
63
133
  /**
64
- * Resolve a hostname to IPv6 addresses using DNS64 (RFC 6052).
134
+ * Look up every address for a hostname via the system resolver, with a timeout.
65
135
  *
66
- * 1. Try native AAAA resolution first.
67
- * 2. Fall back to A resolution and synthesize IPv6 via NAT64 prefix.
136
+ * @param {string} hostname - Hostname to look up
137
+ * @param {number} [timeoutMs] - Timeout in milliseconds
138
+ * @returns {Promise<Array<{address: string, family: number}>>} Resolved records
139
+ */
140
+ function lookupAll(hostname, timeoutMs = config.DNS_TIMEOUT) {
141
+ return new Promise((resolve, reject) => {
142
+ let settled = false;
143
+
144
+ const timer = setTimeout(() => {
145
+ settled = true;
146
+ const err = new Error(`DNS lookup for "${hostname}" timed out after ${timeoutMs}ms`);
147
+ err.code = 'ETIMEDOUT';
148
+ reject(err);
149
+ }, timeoutMs);
150
+
151
+ dns.lookup(hostname, { all: true, verbatim: true }, (err, addresses) => {
152
+ if (settled) return;
153
+ settled = true;
154
+ clearTimeout(timer);
155
+ if (err) reject(err);
156
+ else resolve(addresses);
157
+ });
158
+ });
159
+ }
160
+
161
+ /** Rotate an array so repeated lookups spread across available records. */
162
+ function rotate(items) {
163
+ if (items.length < 2) return items;
164
+ const offset = Math.floor(Math.random() * items.length);
165
+ return [...items.slice(offset), ...items.slice(0, offset)];
166
+ }
167
+
168
+ /**
169
+ * Resolve a hostname into an ordered list of connection candidates.
170
+ *
171
+ * Candidates are ordered most- to least-preferred, so a caller can fail over
172
+ * rather than giving up on the first unreachable address:
173
+ *
174
+ * 1. native IPv6 (the host is already reachable without translation)
175
+ * 2. NAT64-synthesized IPv6 (what this bridge exists to provide)
176
+ * 3. direct IPv4 (last resort; not translated)
177
+ *
178
+ * @param {string} hostname - Hostname or IP literal
179
+ * @returns {Promise<Array<{host: string, family: number, mode: string}>>} Candidates
180
+ */
181
+ async function resolveCandidates(hostname) {
182
+ const ipVersion = detectIPVersion(hostname);
183
+
184
+ if (ipVersion === 'ipv6') {
185
+ return [{ host: hostname, family: 6, mode: 'ipv6-literal' }];
186
+ }
187
+
188
+ if (ipVersion === 'ipv4') {
189
+ if (canSynthesize(hostname)) {
190
+ return [
191
+ { host: ipv4ToIPv6(hostname), family: 6, mode: 'nat64' },
192
+ { host: hostname, family: 4, mode: 'direct-ipv4' },
193
+ ];
194
+ }
195
+ // RFC 6052 section 3.1: non-global IPv4 cannot use the well-known prefix.
196
+ return [{ host: hostname, family: 4, mode: 'direct-ipv4' }];
197
+ }
198
+
199
+ const cached = dnsCache.get(hostname);
200
+ if (cached) return cached;
201
+
202
+ const records = await lookupAll(hostname);
203
+
204
+ const ipv6 = rotate(records.filter((r) => r.family === 6).map((r) => r.address));
205
+ const ipv4 = rotate(records.filter((r) => r.family === 4).map((r) => r.address));
206
+
207
+ if (ipv6.length === 0 && ipv4.length === 0) {
208
+ throw new Error(`No A or AAAA records found for ${hostname}`);
209
+ }
210
+
211
+ const candidates = [
212
+ ...ipv6.map((host) => ({ host, family: 6, mode: 'native-ipv6' })),
213
+ ...ipv4.filter(canSynthesize).map((address) => ({
214
+ host: ipv4ToIPv6(address), family: 6, mode: 'nat64',
215
+ })),
216
+ ...ipv4.map((host) => ({ host, family: 4, mode: 'direct-ipv4' })),
217
+ ];
218
+
219
+ dnsCache.set(hostname, candidates);
220
+ return candidates;
221
+ }
222
+
223
+ /**
224
+ * Resolve a hostname, reporting how it would be reached.
225
+ *
226
+ * @param {string} hostname - Domain name to resolve
227
+ * @returns {Promise<{addresses: string[], mode: string}>} Addresses and routing mode
228
+ * @throws {Error} If the hostname cannot be resolved at all
229
+ */
230
+ async function resolveHost(hostname) {
231
+ const candidates = await resolveCandidates(hostname);
232
+ const primary = candidates[0].mode;
233
+ return {
234
+ addresses: candidates.filter((c) => c.mode === primary).map((c) => c.host),
235
+ mode: primary,
236
+ };
237
+ }
238
+
239
+ /**
240
+ * Resolve a hostname to IPv6 addresses using DNS64.
68
241
  *
69
242
  * @param {string} hostname - Domain name to resolve
70
243
  * @returns {Promise<string[]>} Array of IPv6 addresses
71
- * @throws {Error} If DNS resolution fails completely
244
+ * @throws {Error} If no IPv6 address can be produced
72
245
  */
73
246
  async function resolveIPv6(hostname) {
74
- try {
75
- const resolver = dns.resolve6(hostname);
76
- const timeout = new Promise((_, reject) =>
77
- setTimeout(() => reject(new Error('DNS timeout')), 5000)
247
+ const candidates = await resolveCandidates(hostname);
248
+ const ipv6 = candidates.filter((c) => c.family === 6).map((c) => c.host);
249
+
250
+ if (ipv6.length === 0) {
251
+ throw new Error(
252
+ `Cannot produce an IPv6 address for ${hostname}: it resolves only to ` +
253
+ `non-global IPv4 addresses, which RFC 6052 section 3.1 forbids ` +
254
+ `representing with the well-known prefix`
78
255
  );
79
- return await Promise.race([resolver, timeout]);
80
- } catch {
81
- try {
82
- const ipv4 = await dns.resolve4(hostname);
83
- return ipv4.map(ipv4ToIPv6);
84
- } catch (err) {
85
- throw new Error(`DNS resolution failed for ${hostname}: ${err.message}`);
86
- }
87
256
  }
257
+
258
+ return ipv6;
88
259
  }
89
260
 
90
- module.exports = { ipv4ToIPv6, resolveIPv6, detectIPVersion };
261
+ module.exports = {
262
+ ipv4ToIPv6,
263
+ ipv6ToIPv4,
264
+ resolveIPv6,
265
+ resolveHost,
266
+ resolveCandidates,
267
+ detectIPVersion,
268
+ isGlobalIPv4,
269
+ canSynthesize,
270
+ lookupAll,
271
+ dnsCache,
272
+ };
package/src/doctor.js ADDED
@@ -0,0 +1,222 @@
1
+ /**
2
+ * IPv6 Bridge - Diagnostics
3
+ *
4
+ * Answers the question a user actually has when their IPv6-only network is
5
+ * misbehaving: what is broken, and what should I do about it?
6
+ *
7
+ * Every check reports what it observed and, on failure, what that implies.
8
+ *
9
+ * @module doctor
10
+ */
11
+
12
+ const os = require('os');
13
+ const dns = require('dns');
14
+ const dnsPromises = require('dns').promises;
15
+ const { lookupAll } = require('./dns64');
16
+ const { discoverPrefix } = require('./discovery');
17
+ const { hasIPv4, hasIPv6, hasWorkingNAT64 } = require('./detect');
18
+ const config = require('./config');
19
+
20
+ const PASS = 'pass';
21
+ const FAIL = 'fail';
22
+ const WARN = 'warn';
23
+ const INFO = 'info';
24
+
25
+ function interfaces() {
26
+ const result = { ipv4: [], ipv6: [] };
27
+ for (const [name, addresses] of Object.entries(os.networkInterfaces())) {
28
+ for (const address of addresses || []) {
29
+ if (address.internal) continue;
30
+ const family = address.family === 'IPv4' || address.family === 4 ? 'ipv4' : 'ipv6';
31
+ result[family].push(`${name}: ${address.address}`);
32
+ }
33
+ }
34
+ return result;
35
+ }
36
+
37
+ /**
38
+ * Compare the system resolver against direct DNS queries.
39
+ *
40
+ * These disagree more often than people expect: dns.resolve* bypasses the
41
+ * hosts file and any DNS-over-HTTPS configuration, so it can fail completely
42
+ * on a host where normal name resolution works.
43
+ */
44
+ async function checkResolvers() {
45
+ const checks = [];
46
+ const probe = 'example.com';
47
+
48
+ let systemOk = false;
49
+ try {
50
+ const records = await lookupAll(probe);
51
+ systemOk = records.length > 0;
52
+ checks.push({
53
+ name: 'System resolver (dns.lookup)',
54
+ status: PASS,
55
+ detail: `${probe} -> ${records.map((r) => r.address).join(', ')}`,
56
+ });
57
+ } catch (err) {
58
+ checks.push({
59
+ name: 'System resolver (dns.lookup)',
60
+ status: FAIL,
61
+ detail: `${probe} did not resolve (${err.code || err.message})`,
62
+ advice: 'Name resolution is broken for every program on this machine, not just the bridge. Check your DNS settings or VPN.',
63
+ });
64
+ }
65
+
66
+ try {
67
+ await dnsPromises.resolve4(probe);
68
+ checks.push({
69
+ name: 'Direct DNS queries (dns.resolve)',
70
+ status: PASS,
71
+ detail: `servers: ${dns.getServers().join(', ')}`,
72
+ });
73
+ } catch (err) {
74
+ checks.push({
75
+ name: 'Direct DNS queries (dns.resolve)',
76
+ status: systemOk ? INFO : WARN,
77
+ detail: `failed (${err.code || err.message}); configured servers: ${dns.getServers().join(', ')}`,
78
+ advice: systemOk
79
+ ? 'Harmless: the bridge resolves through the system resolver, which works. Tools that query port 53 directly will fail on this host.'
80
+ : 'Both resolution paths are failing. Check DNS configuration.',
81
+ });
82
+ }
83
+
84
+ return checks;
85
+ }
86
+
87
+ async function checkConnectivity() {
88
+ const checks = [];
89
+
90
+ const ipv4 = await hasIPv4();
91
+ checks.push({
92
+ name: 'IPv4 connectivity',
93
+ status: ipv4 ? PASS : INFO,
94
+ detail: ipv4 ? `reached ${config.IPV4_TEST_URL}` : `could not reach ${config.IPV4_TEST_URL}`,
95
+ advice: ipv4 ? 'IPv4 works, so the bridge is not required on this network.' : undefined,
96
+ });
97
+
98
+ const ipv6 = await hasIPv6();
99
+ checks.push({
100
+ name: 'IPv6 connectivity',
101
+ status: ipv6 ? PASS : (ipv4 ? INFO : FAIL),
102
+ detail: ipv6 ? `reached ${config.IPV6_TEST_URL}` : `could not reach ${config.IPV6_TEST_URL}`,
103
+ advice: !ipv6 && !ipv4
104
+ ? 'Neither protocol works. The bridge cannot help until basic connectivity is restored.'
105
+ : undefined,
106
+ });
107
+
108
+ if (ipv6) {
109
+ const nat64 = await hasWorkingNAT64();
110
+ checks.push({
111
+ name: 'Upstream NAT64 gateway',
112
+ status: nat64 ? PASS : INFO,
113
+ detail: nat64
114
+ ? `reached ${config.NAT64_TEST_HOST} over a synthesized address`
115
+ : `could not reach ${config.NAT64_TEST_HOST} over a synthesized address`,
116
+ advice: nat64
117
+ ? 'Your network already translates IPv4 traffic; the bridge is optional here.'
118
+ : 'No NAT64 gateway responded. If this is an IPv6-only network, the bridge is needed — but it can only work if your ISP operates a NAT64 gateway.',
119
+ });
120
+ }
121
+
122
+ return checks;
123
+ }
124
+
125
+ async function checkPrefix() {
126
+ const current = config.getPrefix();
127
+ const checks = [{
128
+ name: 'Configured NAT64 prefix',
129
+ status: INFO,
130
+ detail: `${current.prefix}/${current.length}${process.env.NAT64_PREFIX ? ' (from NAT64_PREFIX)' : ' (default)'}`,
131
+ }];
132
+
133
+ const discovered = await discoverPrefix().catch(() => null);
134
+
135
+ if (!discovered) {
136
+ checks.push({
137
+ name: 'NAT64 prefix discovery (RFC 7050)',
138
+ status: INFO,
139
+ detail: 'ipv4only.arpa returned no synthesized AAAA records',
140
+ advice: 'This resolver does not provide DNS64. On an IPv6-only network that usually means you must point at a DNS64 resolver, or set NAT64_PREFIX manually.',
141
+ });
142
+ return checks;
143
+ }
144
+
145
+ const matches = discovered.prefix === current.prefix && discovered.length === current.length;
146
+ checks.push({
147
+ name: 'NAT64 prefix discovery (RFC 7050)',
148
+ status: matches ? PASS : WARN,
149
+ detail: `network advertises ${discovered.prefix}/${discovered.length}`,
150
+ advice: matches
151
+ ? undefined
152
+ : `This differs from the prefix in use. Set NAT64_PREFIX=${discovered.prefix}/${discovered.length} or let discovery apply it automatically.`,
153
+ });
154
+
155
+ return checks;
156
+ }
157
+
158
+ function checkSecurity() {
159
+ const checks = [];
160
+ const loopback = config.isLoopbackBind();
161
+ const guarded = Boolean(config.AUTH) || !config.ALLOW_FROM.isEmpty;
162
+
163
+ checks.push({
164
+ name: 'Listener exposure',
165
+ status: loopback || guarded ? PASS : WARN,
166
+ detail: loopback
167
+ ? `bound to ${config.BIND_HOST} (loopback only)`
168
+ : `bound to ${config.BIND_HOST}${guarded ? ' with access control' : ' with no access control'}`,
169
+ advice: loopback || guarded
170
+ ? undefined
171
+ : 'Anyone who can reach this host can relay traffic through it. Set IPV6_BRIDGE_AUTH or IPV6_BRIDGE_ALLOW, or bind to 127.0.0.1.',
172
+ });
173
+
174
+ return checks;
175
+ }
176
+
177
+ /**
178
+ * Run every diagnostic check.
179
+ *
180
+ * @returns {Promise<{checks: object[], summary: {pass: number, warn: number, fail: number}, interfaces: object}>}
181
+ */
182
+ async function diagnose() {
183
+ const checks = [
184
+ ...await checkResolvers(),
185
+ ...await checkConnectivity(),
186
+ ...await checkPrefix(),
187
+ ...checkSecurity(),
188
+ ];
189
+
190
+ const summary = { pass: 0, warn: 0, fail: 0, info: 0 };
191
+ for (const check of checks) summary[check.status] += 1;
192
+
193
+ return { checks, summary, interfaces: interfaces() };
194
+ }
195
+
196
+ /**
197
+ * Render diagnostics as human-readable text.
198
+ *
199
+ * @param {object} report - Result of diagnose()
200
+ * @returns {string} Formatted report
201
+ */
202
+ function format(report) {
203
+ const symbols = { pass: ' OK ', fail: ' FAIL ', warn: ' WARN ', info: ' INFO ' };
204
+ const lines = ['', 'IPv6 Bridge diagnostics', '======================='];
205
+
206
+ lines.push('', 'Network interfaces:');
207
+ lines.push(` IPv4: ${report.interfaces.ipv4.join(', ') || 'none'}`);
208
+ lines.push(` IPv6: ${report.interfaces.ipv6.join(', ') || 'none'}`);
209
+ lines.push('');
210
+
211
+ for (const check of report.checks) {
212
+ lines.push(`[${symbols[check.status]}] ${check.name}`);
213
+ lines.push(` ${check.detail}`);
214
+ if (check.advice) lines.push(` -> ${check.advice}`);
215
+ }
216
+
217
+ const { pass, warn, fail } = report.summary;
218
+ lines.push('', `${pass} passed, ${warn} warning(s), ${fail} failure(s)`, '');
219
+ return lines.join('\n');
220
+ }
221
+
222
+ module.exports = { diagnose, format };