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/README.md +206 -31
- package/docs/API.md +365 -0
- package/docs/ARCHITECTURE.md +218 -0
- package/docs/CHANGELOG.md +159 -0
- package/docs/CONTRIBUTING.md +88 -0
- package/docs/GUIDE.md +675 -0
- package/docs/ROADMAP.md +69 -0
- package/examples/basic-usage.js +31 -23
- package/examples/embedded-usage.js +100 -0
- package/examples/production-usage.js +106 -0
- package/package.json +6 -6
- package/src/agent.js +271 -0
- package/src/cache.js +108 -0
- package/src/cli.js +265 -48
- package/src/config.js +159 -24
- package/src/connect.js +142 -0
- package/src/detect.js +82 -43
- package/src/discovery.js +122 -0
- package/src/dns64.js +226 -44
- package/src/doctor.js +222 -0
- package/src/index.js +157 -62
- package/src/ipv6.js +232 -0
- package/src/logger.js +47 -0
- package/src/netmatch.js +122 -0
- package/src/proxy.js +419 -101
- package/src/socks5.js +283 -0
- package/src/stats.js +140 -0
package/docs/API.md
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
# API Reference
|
|
2
|
+
|
|
3
|
+
Complete API documentation for IPv6 Bridge.
|
|
4
|
+
|
|
5
|
+
## Embedded API
|
|
6
|
+
|
|
7
|
+
These let an application use DNS64/NAT64 translation directly, with no proxy
|
|
8
|
+
and no system configuration. Every outbound connection tries native IPv6 first,
|
|
9
|
+
then a NAT64-synthesized address, then direct IPv4.
|
|
10
|
+
|
|
11
|
+
None of this can create connectivity the host does not have.
|
|
12
|
+
|
|
13
|
+
### `createAgent(options?)`
|
|
14
|
+
|
|
15
|
+
An `http.Agent` that resolves through DNS64, fails over across candidate
|
|
16
|
+
addresses, and pools sockets keyed by the original hostname.
|
|
17
|
+
|
|
18
|
+
```javascript
|
|
19
|
+
const { createAgent } = require('ipv6-bridge');
|
|
20
|
+
http.get('http://example.com', { agent: createAgent() }, handler);
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
**Parameters:** any `http.Agent` option. Defaults: `keepAlive: true`,
|
|
24
|
+
`keepAliveMsecs` from `IPV6_KEEP_ALIVE_MS`, `maxSockets` from
|
|
25
|
+
`IPV6_MAX_SOCKETS_PER_HOST`.
|
|
26
|
+
|
|
27
|
+
**Returns:** `http.Agent`
|
|
28
|
+
|
|
29
|
+
### `createHttpsAgent(options?)`
|
|
30
|
+
|
|
31
|
+
The same, performing the TLS handshake over the bridged socket.
|
|
32
|
+
|
|
33
|
+
```javascript
|
|
34
|
+
const { createHttpsAgent } = require('ipv6-bridge');
|
|
35
|
+
https.get('https://example.com', { agent: createHttpsAgent() }, handler);
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The certificate is validated against the **requested hostname**, never the
|
|
39
|
+
synthesized address the connection travelled over, so certificate verification
|
|
40
|
+
works normally and must not be disabled.
|
|
41
|
+
|
|
42
|
+
**Returns:** `https.Agent`
|
|
43
|
+
|
|
44
|
+
### `createAgents(options?)`
|
|
45
|
+
|
|
46
|
+
Both at once, for clients that take a pair.
|
|
47
|
+
|
|
48
|
+
**Returns:** `{ http: http.Agent, https: https.Agent }`
|
|
49
|
+
|
|
50
|
+
```javascript
|
|
51
|
+
const agents = createAgents();
|
|
52
|
+
axios.create({ httpAgent: agents.http, httpsAgent: agents.https });
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### `createLookup()`
|
|
56
|
+
|
|
57
|
+
A `dns.lookup`-compatible function applying DNS64 synthesis. Usable anywhere a
|
|
58
|
+
`lookup` option is accepted.
|
|
59
|
+
|
|
60
|
+
```javascript
|
|
61
|
+
net.connect({ host: 'db.example', port: 5432, lookup: createLookup() });
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Supports the `all` and `family` options. Lighter-touch than an agent, but it
|
|
65
|
+
only changes resolution — no failover or pooling.
|
|
66
|
+
|
|
67
|
+
**Returns:** `(hostname, options, callback) => void`
|
|
68
|
+
|
|
69
|
+
### `createConnector()`
|
|
70
|
+
|
|
71
|
+
A connector for undici, and therefore Node's global `fetch`. undici is not a
|
|
72
|
+
dependency; this is for projects that already use it.
|
|
73
|
+
|
|
74
|
+
```javascript
|
|
75
|
+
const { Agent, setGlobalDispatcher } = require('undici');
|
|
76
|
+
setGlobalDispatcher(new Agent({ connect: createConnector() }));
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
**Returns:** `(options, callback) => void`
|
|
80
|
+
|
|
81
|
+
### `resolve(hostname)`
|
|
82
|
+
|
|
83
|
+
Resolve the way the bridge would, without connecting. Useful for logging and
|
|
84
|
+
assertions.
|
|
85
|
+
|
|
86
|
+
```javascript
|
|
87
|
+
await resolve('8.8.8.8');
|
|
88
|
+
// → [ { host: '64:ff9b::808:808', family: 6, mode: 'nat64' },
|
|
89
|
+
// { host: '8.8.8.8', family: 4, mode: 'direct-ipv4' } ]
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**Returns:** `Promise<Array<{host: string, family: number, mode: string}>>`
|
|
93
|
+
|
|
94
|
+
### `getStats()`
|
|
95
|
+
|
|
96
|
+
A snapshot of counters, routing modes, DNS cache statistics and the active
|
|
97
|
+
prefix — the same data the proxy serves at `/status`, available to embedded
|
|
98
|
+
users.
|
|
99
|
+
|
|
100
|
+
```javascript
|
|
101
|
+
const stats = getStats();
|
|
102
|
+
stats.translationRate; // 0 means nothing is being translated
|
|
103
|
+
stats.routes.directIpv4Fallback; // connections that could not be translated
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
**Returns:** `object`
|
|
107
|
+
|
|
108
|
+
### `discoverPrefix()`
|
|
109
|
+
|
|
110
|
+
Run RFC 7050 discovery and report the network's NAT64 prefix, without changing
|
|
111
|
+
configuration.
|
|
112
|
+
|
|
113
|
+
**Returns:** `Promise<{prefix, length, bytes, source}|null>`
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## Proxy API
|
|
118
|
+
|
|
119
|
+
### `start(port?, options?)`
|
|
120
|
+
|
|
121
|
+
Starts the IPv6 Bridge proxy server.
|
|
122
|
+
|
|
123
|
+
```javascript
|
|
124
|
+
const { start, stop } = require('ipv6-bridge');
|
|
125
|
+
|
|
126
|
+
const server = await start(8080);
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
**Parameters:**
|
|
130
|
+
- `port` (number, default: `8080`) — Port to listen on.
|
|
131
|
+
- `options.host` (string, default: `127.0.0.1`) — Interface to bind to.
|
|
132
|
+
- `options.force` (boolean) — Start even if detection says the bridge isn't needed.
|
|
133
|
+
- `options.discoverPrefix` (boolean, default: `true`) — Run RFC 7050 prefix discovery.
|
|
134
|
+
- `options.socksPort` (number|null) — Also start a SOCKS5 listener on this port.
|
|
135
|
+
|
|
136
|
+
**Returns:** `Promise<http.Server | null>`
|
|
137
|
+
- Returns the HTTP server instance if the bridge started.
|
|
138
|
+
- Returns `null` if the bridge was not needed (IPv4 works, or NAT64 already works).
|
|
139
|
+
|
|
140
|
+
**Throws:** `Error` if the bridge is already running or startup fails.
|
|
141
|
+
|
|
142
|
+
**Behavior:**
|
|
143
|
+
1. Runs network detection (`needsBridge()`).
|
|
144
|
+
2. If the bridge is not needed and neither `options.force` nor `FORCE_BRIDGE` is set, returns `null`.
|
|
145
|
+
3. Optionally discovers the network's NAT64 prefix (RFC 7050).
|
|
146
|
+
4. Creates and starts the proxy, plus a SOCKS5 listener if configured.
|
|
147
|
+
|
|
148
|
+
Concurrent calls are safe: a second `start()` while one is in flight rejects rather than leaving an untracked server running.
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
### `stop()`
|
|
153
|
+
|
|
154
|
+
Stops the running bridge and any SOCKS5 listener, tearing down live connections.
|
|
155
|
+
|
|
156
|
+
```javascript
|
|
157
|
+
await stop();
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Open CONNECT tunnels are destroyed rather than waited on, so `stop()` always resolves — important when the bridge is started and stopped inside a test suite.
|
|
161
|
+
|
|
162
|
+
Safe to call when nothing is running.
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## CLI
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
# Start on the default port (8080)
|
|
170
|
+
npx ipv6-bridge start
|
|
171
|
+
|
|
172
|
+
# Custom port
|
|
173
|
+
IPV6_BRIDGE_PORT=9090 npx ipv6-bridge start
|
|
174
|
+
|
|
175
|
+
# Force start even if the bridge is not needed
|
|
176
|
+
FORCE_BRIDGE=1 npx ipv6-bridge start
|
|
177
|
+
|
|
178
|
+
# Diagnose this network
|
|
179
|
+
npx ipv6-bridge doctor
|
|
180
|
+
|
|
181
|
+
# Show help
|
|
182
|
+
npx ipv6-bridge --help
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Stop the bridge with `Ctrl+C` or by sending `SIGTERM`.
|
|
186
|
+
|
|
187
|
+
### `doctor`
|
|
188
|
+
|
|
189
|
+
Runs diagnostics and explains what it found:
|
|
190
|
+
|
|
191
|
+
- whether the system resolver and direct DNS queries agree
|
|
192
|
+
- whether IPv4, IPv6 and an upstream NAT64 gateway are reachable
|
|
193
|
+
- which NAT64 prefix is configured, and which one the network advertises
|
|
194
|
+
- whether the listener is exposed without access control
|
|
195
|
+
|
|
196
|
+
Exits non-zero if any check fails, so it can be used in provisioning scripts.
|
|
197
|
+
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
## Configuration
|
|
201
|
+
|
|
202
|
+
### Environment Variables
|
|
203
|
+
|
|
204
|
+
| Variable | Default | Description |
|
|
205
|
+
|----------|---------|-------------|
|
|
206
|
+
| `IPV6_BRIDGE_PORT` | `8080` | Port for the proxy server |
|
|
207
|
+
| `IPV6_BRIDGE_HOST` | `127.0.0.1` | Interface to bind to |
|
|
208
|
+
| `IPV6_BRIDGE_SOCKS_PORT` | _(off)_ | Serve SOCKS5 on this port |
|
|
209
|
+
| `IPV6_BRIDGE_AUTH` | _(none)_ | Require `user:password` from clients |
|
|
210
|
+
| `IPV6_BRIDGE_ALLOW` | _(any)_ | Client allowlist, e.g. `192.168.1.0/24` |
|
|
211
|
+
| `IPV6_BRIDGE_BYPASS` | _(none)_ | Hosts to reach directly, e.g. `*.internal.com` |
|
|
212
|
+
| `IPV6_BRIDGE_CONTROL` | `on` | Serve `/healthz`, `/status`, `/metrics`, `/proxy.pac` |
|
|
213
|
+
| `IPV6_BRIDGE_DISCOVER_PREFIX` | `on` | Discover the NAT64 prefix via RFC 7050 |
|
|
214
|
+
| `FORCE_BRIDGE` | _(unset)_ | Start the bridge even when detection says it isn't needed |
|
|
215
|
+
| `NAT64_PREFIX` | `64:ff9b::/96` | NAT64 prefix, with an optional `/length` |
|
|
216
|
+
| `IPV6_DNS_TIMEOUT` | `5000` | DNS resolution timeout (ms) |
|
|
217
|
+
| `IPV6_DNS_CACHE_TTL` | `30000` | DNS cache entry lifetime (ms) |
|
|
218
|
+
| `IPV6_DNS_CACHE_MAX` | `1000` | Maximum cached DNS entries |
|
|
219
|
+
| `IPV6_CONN_TIMEOUT` | `10000` | Proxy connection timeout (ms) |
|
|
220
|
+
| `IPV6_CONNECT_ATTEMPT_TIMEOUT` | `3000` | Timeout per candidate address before trying the next |
|
|
221
|
+
| `IPV6_KEEP_ALIVE_MS` | `15000` | Idle lifetime of pooled upstream sockets |
|
|
222
|
+
| `IPV6_MAX_SOCKETS_PER_HOST` | `64` | Maximum pooled sockets per upstream host |
|
|
223
|
+
| `IPV4_TEST_URL` | `http://ipv4.google.com` | Endpoint used to detect working IPv4 |
|
|
224
|
+
| `IPV6_TEST_URL` | `http://ipv6.google.com` | Endpoint used to detect working IPv6 |
|
|
225
|
+
| `NAT64_TEST_HOST` | `ipv4.google.com` | IPv4-only host used to probe for NAT64 |
|
|
226
|
+
| `LOG_LEVEL` | `info` | `silent`, `error`, `warn`, `info` or `debug` |
|
|
227
|
+
|
|
228
|
+
Invalid values are rejected at startup with an explanatory message rather than causing confusing failures later.
|
|
229
|
+
|
|
230
|
+
### NAT64 prefix formats
|
|
231
|
+
|
|
232
|
+
`NAT64_PREFIX` accepts any prefix length RFC 6052 defines — `/32`, `/40`, `/48`, `/56`, `/64` or `/96`. Without a length, `/96` is assumed.
|
|
233
|
+
|
|
234
|
+
```bash
|
|
235
|
+
NAT64_PREFIX=64:ff9b::/96 # well-known prefix (default)
|
|
236
|
+
NAT64_PREFIX=2001:db8:122:344::/64 # operator-assigned prefix
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
The well-known prefix `64:ff9b::` is only valid at `/96`, and per RFC 6052 section 3.1 it is never used to carry non-global IPv4 addresses.
|
|
240
|
+
|
|
241
|
+
---
|
|
242
|
+
|
|
243
|
+
## Operational endpoints
|
|
244
|
+
|
|
245
|
+
When `IPV6_BRIDGE_CONTROL` is on, the proxy answers these paths directly (as ordinary origin-form requests, not proxied ones):
|
|
246
|
+
|
|
247
|
+
| Path | Description |
|
|
248
|
+
|------|-------------|
|
|
249
|
+
| `/healthz` | Liveness check. Always reachable, even when authentication is enabled. |
|
|
250
|
+
| `/status` | JSON snapshot of counters, routing modes, DNS cache and active prefix. |
|
|
251
|
+
| `/metrics` | The same data in Prometheus text exposition format. |
|
|
252
|
+
| `/proxy.pac` | A proxy auto-configuration file pointing clients at the bridge. |
|
|
253
|
+
|
|
254
|
+
The most useful field in `/status` is `translationRate`: the share of connections that actually went through NAT64. A rate of `0` with a rising `routes.directIpv4Fallback` means the bridge is not translating anything.
|
|
255
|
+
|
|
256
|
+
```bash
|
|
257
|
+
curl http://127.0.0.1:8080/status
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
---
|
|
261
|
+
|
|
262
|
+
## Internal Modules
|
|
263
|
+
|
|
264
|
+
### `dns64.js`
|
|
265
|
+
|
|
266
|
+
#### `resolveCandidates(hostname)`
|
|
267
|
+
|
|
268
|
+
Resolves a hostname into an ordered list of connection candidates, most preferred first: native IPv6, then NAT64-synthesized, then direct IPv4.
|
|
269
|
+
|
|
270
|
+
**Returns:** `Promise<Array<{host: string, family: number, mode: string}>>`
|
|
271
|
+
|
|
272
|
+
#### `resolveIPv6(hostname)`
|
|
273
|
+
|
|
274
|
+
Resolves a hostname to IPv6 addresses using DNS64.
|
|
275
|
+
|
|
276
|
+
**Returns:** `Promise<string[]>`
|
|
277
|
+
|
|
278
|
+
#### `ipv4ToIPv6(ipv4)` / `ipv6ToIPv4(address)`
|
|
279
|
+
|
|
280
|
+
Converts between an IPv4 address and its IPv4-embedded IPv6 form using the active prefix.
|
|
281
|
+
|
|
282
|
+
```javascript
|
|
283
|
+
ipv4ToIPv6('192.0.2.1'); // → '64:ff9b::c000:201'
|
|
284
|
+
ipv6ToIPv4('64:ff9b::c000:201'); // → '192.0.2.1'
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Output is RFC 5952 canonical form, so leading zeros are suppressed.
|
|
288
|
+
|
|
289
|
+
#### `isGlobalIPv4(address)` / `canSynthesize(address)`
|
|
290
|
+
|
|
291
|
+
Whether an address is globally routable, and whether it may be synthesized with the active prefix.
|
|
292
|
+
|
|
293
|
+
#### `detectIPVersion(addr)`
|
|
294
|
+
|
|
295
|
+
**Returns:** `'ipv4'` | `'ipv6'` | `'hostname'` | `null`
|
|
296
|
+
|
|
297
|
+
---
|
|
298
|
+
|
|
299
|
+
### `ipv6.js`
|
|
300
|
+
|
|
301
|
+
Address primitives: `parseIPv6`, `formatIPv6`, `parsePrefix`, `embedIPv4`, `extractIPv4`. Implements the RFC 6052 section 2.2 embedding rules for every prefix length, including the reserved `u` octet at bits 64–71.
|
|
302
|
+
|
|
303
|
+
---
|
|
304
|
+
|
|
305
|
+
### `discovery.js`
|
|
306
|
+
|
|
307
|
+
#### `discoverPrefix()`
|
|
308
|
+
|
|
309
|
+
Discovers the network's NAT64 prefix per RFC 7050 by resolving `ipv4only.arpa` and looking for its known IPv4 addresses inside the synthesized AAAA records.
|
|
310
|
+
|
|
311
|
+
**Returns:** `Promise<{prefix, length, bytes, source}|null>`
|
|
312
|
+
|
|
313
|
+
---
|
|
314
|
+
|
|
315
|
+
### `detect.js`
|
|
316
|
+
|
|
317
|
+
#### `hasIPv4()` / `hasIPv6()` / `hasWorkingNAT64()`
|
|
318
|
+
|
|
319
|
+
Individual reachability probes. Any 2xx or 3xx response counts as reachable.
|
|
320
|
+
|
|
321
|
+
#### `needsBridge()`
|
|
322
|
+
|
|
323
|
+
Returns `true` only when IPv4 is unreachable, IPv6 works, and no upstream NAT64 gateway responds.
|
|
324
|
+
|
|
325
|
+
---
|
|
326
|
+
|
|
327
|
+
### `socks5.js`
|
|
328
|
+
|
|
329
|
+
#### `createSocksServer(port, host?)`
|
|
330
|
+
|
|
331
|
+
Starts a SOCKS5 listener (RFC 1928) supporting the CONNECT command, with optional username/password authentication (RFC 1929). Lets non-HTTP protocols — ssh, git, database clients — use the same DNS64/NAT64 translation.
|
|
332
|
+
|
|
333
|
+
---
|
|
334
|
+
|
|
335
|
+
## Troubleshooting
|
|
336
|
+
|
|
337
|
+
Run `npx ipv6-bridge doctor` first; it checks everything below automatically.
|
|
338
|
+
|
|
339
|
+
### Bridge says "not needed" but I want to test it
|
|
340
|
+
|
|
341
|
+
```bash
|
|
342
|
+
FORCE_BRIDGE=1 npx ipv6-bridge start
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
### Everything returns 502, or nothing seems translated
|
|
346
|
+
|
|
347
|
+
Check `/status`. If `translationRate` is `0` and `routes.directIpv4Fallback` is climbing, DNS64 is failing and the bridge is passing traffic through untranslated. Common causes:
|
|
348
|
+
|
|
349
|
+
- The network has no NAT64 gateway. Confirm with `doctor`.
|
|
350
|
+
- The NAT64 prefix is wrong. `doctor` reports the prefix the network advertises.
|
|
351
|
+
|
|
352
|
+
### DNS resolution fails
|
|
353
|
+
|
|
354
|
+
- The bridge uses the system resolver, so if `ping example.com` fails, so will the bridge.
|
|
355
|
+
- `dns.resolve` failing while `dns.lookup` works is normal on DoH-only hosts and does not affect the bridge.
|
|
356
|
+
|
|
357
|
+
### Connection timeouts
|
|
358
|
+
|
|
359
|
+
- Each candidate address gets `IPV6_CONNECT_ATTEMPT_TIMEOUT` (3s) before the next is tried.
|
|
360
|
+
- On a dual-stack network with `FORCE_BRIDGE`, expect a delay while the unreachable NAT64 route times out before falling back.
|
|
361
|
+
|
|
362
|
+
### Port already in use
|
|
363
|
+
|
|
364
|
+
- Change the port with `IPV6_BRIDGE_PORT=9090 npx ipv6-bridge start`.
|
|
365
|
+
- Check what's using it: `lsof -i :8080` (macOS/Linux) or `netstat -ano | findstr 8080` (Windows).
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
This document explains the technical architecture of IPv6 Bridge, focusing on why DNS64 and application-level NAT64 are used and how they work together.
|
|
4
|
+
|
|
5
|
+
## DNS64 vs NAT64
|
|
6
|
+
|
|
7
|
+
### DNS64 (RFC 6147)
|
|
8
|
+
|
|
9
|
+
DNS64 translates domain names to IPv6 addresses by synthesizing them from IPv4 records. The address format itself — how the 32 bits of IPv4 are embedded into an IPv6 address — is defined by RFC 6052.
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
Query: example.com
|
|
13
|
+
→ DNS returns IPv4: 192.0.2.1
|
|
14
|
+
→ DNS64 synthesizes IPv6: 64:ff9b::c000:0201
|
|
15
|
+
→ Application gets a routable IPv6 address
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
**Implementation:** `src/dns64.js`
|
|
19
|
+
|
|
20
|
+
1. Look the hostname up through the system resolver
|
|
21
|
+
2. Use native AAAA records if any exist
|
|
22
|
+
3. Otherwise synthesize IPv6 from the A records using the NAT64 prefix
|
|
23
|
+
|
|
24
|
+
### NAT64 (RFC 6146)
|
|
25
|
+
|
|
26
|
+
NAT64 translates packets between IPv6 and IPv4 at the network level. There are two complementary approaches:
|
|
27
|
+
|
|
28
|
+
- **ISP-level NAT64**: The ISP operates a gateway that recognizes the `64:ff9b::` prefix and translates packets automatically.
|
|
29
|
+
- **Application-level NAT64** (this project): An HTTP/HTTPS proxy intercepts requests, applies DNS64, and routes traffic through IPv6 so the ISP gateway can translate it.
|
|
30
|
+
|
|
31
|
+
Both approaches work together. If the ISP provides NAT64, the bridge is optional. If not, the bridge provides application-level translation.
|
|
32
|
+
|
|
33
|
+
## Why the system resolver, not `dns.resolve*`
|
|
34
|
+
|
|
35
|
+
`src/dns64.js` resolves names with `dns.lookup`, which calls the operating system's resolver (`getaddrinfo`).
|
|
36
|
+
|
|
37
|
+
The obvious alternative, `dns.resolve4` / `dns.resolve6`, talks directly to DNS servers over port 53 and ignores the hosts file, mDNS, DNS-over-HTTPS, and split-DNS configuration. On a host configured for DoH-only resolution — increasingly common, and exactly the kind of modern network this project targets — `dns.resolve*` fails outright with `ECONNREFUSED` even though ordinary name resolution works perfectly.
|
|
38
|
+
|
|
39
|
+
Using the system resolver means the bridge resolves names the same way every other program on the machine does.
|
|
40
|
+
|
|
41
|
+
## Address selection rules
|
|
42
|
+
|
|
43
|
+
RFC 6052 section 3.1 forbids representing **non-global** IPv4 addresses with the well-known prefix `64:ff9b::/96`. A NAT64 gateway will not route `64:ff9b::7f00:1` anywhere useful, because `127.0.0.1` is meaningless outside the local host.
|
|
44
|
+
|
|
45
|
+
The bridge therefore classifies every IPv4 address before synthesizing:
|
|
46
|
+
|
|
47
|
+
| Address | Action |
|
|
48
|
+
|---------|--------|
|
|
49
|
+
| Global (e.g. `8.8.8.8`) | Synthesize `64:ff9b::0808:0808` and route via NAT64 |
|
|
50
|
+
| Non-global (e.g. `127.0.0.1`, `192.168.1.5`, `10.0.0.2`) | Connect directly over IPv4 |
|
|
51
|
+
|
|
52
|
+
When an operator-assigned network-specific prefix (NSP) is configured instead of the well-known prefix, the section 3.1 restriction does not apply and all addresses are synthesized.
|
|
53
|
+
|
|
54
|
+
The non-global ranges are those listed in RFC 6890: `0.0.0.0/8`, `10/8`, `100.64/10`, `127/8`, `169.254/16`, `172.16/12`, `192.0.0/24`, `192.0.2/24`, `192.88.99/24`, `192.168/16`, `198.18/15`, `198.51.100/24`, `203.0.113/24`, `224/4` and `240/4`.
|
|
55
|
+
|
|
56
|
+
## End-to-End Flow
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
Step 1: Browser sends GET http://google.com/ via proxy (localhost:8080)
|
|
60
|
+
|
|
61
|
+
Step 2: proxy.js parses the absolute-form request target (RFC 7230 5.3.2)
|
|
62
|
+
→ "GET http://google.com/ HTTP/1.1" yields hostname "google.com"
|
|
63
|
+
|
|
64
|
+
Step 3: dns64.js resolves the hostname through the system resolver
|
|
65
|
+
├─ No AAAA records (IPv4-only site)
|
|
66
|
+
├─ A record: 142.251.32.14
|
|
67
|
+
├─ Address is global, so synthesis is permitted
|
|
68
|
+
└─ Synthesize: 64:ff9b::8efb:200e
|
|
69
|
+
|
|
70
|
+
Step 4: proxy.js strips hop-by-hop headers and connects outbound to
|
|
71
|
+
64:ff9b::8efb:200e (family: 6)
|
|
72
|
+
|
|
73
|
+
Step 5: ISP NAT64 gateway recognizes 64:ff9b:: prefix
|
|
74
|
+
→ Extracts 142.251.32.14
|
|
75
|
+
→ Forwards request over IPv4
|
|
76
|
+
|
|
77
|
+
Step 6: Google responds → NAT64 gateway translates back to IPv6
|
|
78
|
+
|
|
79
|
+
Step 7: proxy.js pipes response to the client
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Request handling
|
|
83
|
+
|
|
84
|
+
The proxy serves two distinct paths:
|
|
85
|
+
|
|
86
|
+
**Plain HTTP** — clients configured to use a forward proxy send an *absolute-form* request target (`GET http://example.com/path HTTP/1.1`) per RFC 7230 section 5.3.2. The proxy parses that directly, and accepts origin-form (`GET /path` plus a `Host` header) as a fallback for gateway-style use.
|
|
87
|
+
|
|
88
|
+
**HTTPS via CONNECT** — the client sends `CONNECT example.com:443`, the proxy opens a TCP tunnel to the resolved address and relays bytes in both directions without inspecting them. Authority parsing handles bracketed IPv6 literals (`[2001:db8::1]:443`), bare IPv6 literals, and `host:port`.
|
|
89
|
+
|
|
90
|
+
### Header handling
|
|
91
|
+
|
|
92
|
+
Hop-by-hop headers are removed in both directions per RFC 7230 section 6.1: `Connection`, `Proxy-Connection`, `Keep-Alive`, `Proxy-Authenticate`, `Proxy-Authorization`, `TE`, `Trailer`, `Transfer-Encoding` and `Upgrade`, plus any header named inside the `Connection` header.
|
|
93
|
+
|
|
94
|
+
`Proxy-Authorization` matters most: it carries credentials meant for the proxy itself, and forwarding it would leak them to every origin server the client visits.
|
|
95
|
+
|
|
96
|
+
### Failure reporting
|
|
97
|
+
|
|
98
|
+
When DNS64 resolution fails, the proxy falls back to a direct IPv4 connection — but logs a warning saying the request is **not** being translated. Silent fallback would make the bridge indistinguishable from a plain proxy, leaving users unable to tell whether translation ever happened.
|
|
99
|
+
|
|
100
|
+
Connection failures return a real status code (`502 Bad Gateway`, `504 Gateway Timeout`) on both the HTTP and CONNECT paths, rather than closing the socket and leaving the client to time out.
|
|
101
|
+
|
|
102
|
+
## Connection handling
|
|
103
|
+
|
|
104
|
+
Resolution produces an ordered list of candidates rather than a single address:
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
resolveCandidates("example.com")
|
|
108
|
+
→ [ { native IPv6 }, { NAT64-synthesized }, { direct IPv4 } ]
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`connect.js` tries them in order, giving each `IPV6_CONNECT_ATTEMPT_TIMEOUT`
|
|
112
|
+
before moving on. A single unreachable address is the normal case on a partially
|
|
113
|
+
broken network, so failing on the first attempt would make the bridge less
|
|
114
|
+
reliable than the stack it replaces.
|
|
115
|
+
|
|
116
|
+
Reaching the direct-IPv4 candidate when a NAT64 route existed means translation
|
|
117
|
+
failed, so that case is logged as a warning and counted separately from a
|
|
118
|
+
deliberate direct connection.
|
|
119
|
+
|
|
120
|
+
Successful HTTP connections are pooled by a keep-alive agent keyed on the
|
|
121
|
+
original hostname, so pooling survives the fact that the dialled address is
|
|
122
|
+
synthesized rather than literal.
|
|
123
|
+
|
|
124
|
+
## Component Diagram
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
cli.js ──→ index.js ──→ detect.js (is the bridge needed?)
|
|
128
|
+
│
|
|
129
|
+
├──→ discovery.js (RFC 7050: what prefix does this network use?)
|
|
130
|
+
│
|
|
131
|
+
├──→ proxy.js ──┐
|
|
132
|
+
│ ├──→ connect.js ──→ dns64.js ──→ ipv6.js
|
|
133
|
+
└──→ socks5.js ─┘ │
|
|
134
|
+
cache.js
|
|
135
|
+
|
|
136
|
+
config.js ──→ validated settings, consumed by everything
|
|
137
|
+
stats.js ──→ counters, surfaced at /status and /metrics
|
|
138
|
+
doctor.js ──→ standalone diagnostics
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### Component Roles
|
|
142
|
+
|
|
143
|
+
| Component | Role | Necessity |
|
|
144
|
+
|-----------|------|-----------|
|
|
145
|
+
| `dns64.js` | DNS64 resolution and candidate ordering | Critical |
|
|
146
|
+
| `ipv6.js` | Address parsing and RFC 6052 embedding | Critical |
|
|
147
|
+
| `connect.js` | Outbound connections, failover, pooling | Critical |
|
|
148
|
+
| `proxy.js` | HTTP/HTTPS proxy with IPv6 routing | Critical |
|
|
149
|
+
| `config.js` | Centralized configuration and validation | Required |
|
|
150
|
+
| `index.js` | Public API (`start`/`stop`) | Required |
|
|
151
|
+
| `cli.js` | CLI entry point | Required |
|
|
152
|
+
| `detect.js` | Auto-detects networks needing the bridge | Important |
|
|
153
|
+
| `discovery.js` | RFC 7050 NAT64 prefix discovery | Important |
|
|
154
|
+
| `socks5.js` | SOCKS5 listener for non-HTTP protocols | Optional |
|
|
155
|
+
| `doctor.js` | Diagnostics | Optional |
|
|
156
|
+
| `stats.js` | Runtime counters for `/status` and `/metrics` | Optional |
|
|
157
|
+
| `cache.js` | Bounded TTL cache for DNS results | Optional |
|
|
158
|
+
| `netmatch.js` | CIDR and hostname matching for allow/bypass lists | Optional |
|
|
159
|
+
| `logger.js` | Level-filtered structured logging | Required |
|
|
160
|
+
|
|
161
|
+
## Prefix discovery (RFC 7050)
|
|
162
|
+
|
|
163
|
+
Most networks that provide NAT64 assign their own prefix rather than using the
|
|
164
|
+
well-known one, so assuming `64:ff9b::/96` is wrong more often than it is right.
|
|
165
|
+
|
|
166
|
+
RFC 7050 defines the discovery mechanism: the name `ipv4only.arpa` has exactly
|
|
167
|
+
two A records, `192.0.0.170` and `192.0.0.171`, and no AAAA records of its own.
|
|
168
|
+
A DNS64 resolver therefore synthesizes AAAA records for it, and whatever wraps
|
|
169
|
+
those known IPv4 addresses reveals the prefix and its length.
|
|
170
|
+
|
|
171
|
+
At startup the bridge resolves that name and, for each returned address, tries
|
|
172
|
+
every RFC 6052 prefix length until the embedded bytes match a known address. An
|
|
173
|
+
explicitly configured `NAT64_PREFIX` always wins; discovery only fills in a
|
|
174
|
+
default, and logs when the network disagrees with the configuration.
|
|
175
|
+
|
|
176
|
+
## Detection logic
|
|
177
|
+
|
|
178
|
+
`needsBridge()` answers one question: *would this machine be unable to reach IPv4-only servers without help?*
|
|
179
|
+
|
|
180
|
+
```
|
|
181
|
+
1. Does plain IPv4 work? → yes: bridge NOT needed (dual-stack)
|
|
182
|
+
2. Does IPv6 work? → no: bridge cannot help
|
|
183
|
+
3. Does the ISP already do NAT64? → yes: bridge NOT needed
|
|
184
|
+
4. Otherwise → bridge IS needed
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Step 1 is what keeps the bridge from activating on an ordinary dual-stack network, where routing traffic through a nonexistent NAT64 gateway would break connections that already work.
|
|
188
|
+
|
|
189
|
+
Probes accept any 2xx or 3xx response as "reachable"; requiring exactly `200` would misreport a network as broken the moment a test endpoint starts redirecting. All three endpoints are configurable so detection still works where the defaults are blocked.
|
|
190
|
+
|
|
191
|
+
## Design Decisions
|
|
192
|
+
|
|
193
|
+
### Why Application-Level NAT64?
|
|
194
|
+
|
|
195
|
+
- **No kernel changes**: Works without modifying the system network stack.
|
|
196
|
+
- **Cross-platform**: Same code on Windows, macOS, Linux, and containers.
|
|
197
|
+
- **No admin rights**: Runs as a normal user process.
|
|
198
|
+
- **Covers primary use cases**: HTTP/HTTPS is 95%+ of internet traffic.
|
|
199
|
+
|
|
200
|
+
### Why loopback-only by default
|
|
201
|
+
|
|
202
|
+
The proxy has no authentication. Bound to a routable interface it is an open relay: anyone on the same network can push traffic through it under the host's IP address. Loopback is the only safe default, and exposing it is an explicit, warned-about opt-in.
|
|
203
|
+
|
|
204
|
+
### What We Don't Do
|
|
205
|
+
|
|
206
|
+
- **Packet-level translation**: The ISP gateway handles this.
|
|
207
|
+
- **Kernel-level NAT64**: Would require kernel modules and admin rights.
|
|
208
|
+
- **DNS server**: We use the system DNS resolver and enhance results with DNS64 synthesis.
|
|
209
|
+
|
|
210
|
+
## Deployment Scenarios
|
|
211
|
+
|
|
212
|
+
| Scenario | Behavior |
|
|
213
|
+
|----------|----------|
|
|
214
|
+
| Dual-stack (IPv4 + IPv6) | Detection sees working IPv4; `start()` returns `null` |
|
|
215
|
+
| ISP has NAT64 | Detection reaches an IPv4-only host over the synthesized address; `start()` returns `null` |
|
|
216
|
+
| IPv6-only, no ISP NAT64 | Bridge starts proxy; DNS64 synthesizes addresses; ISP gateway translates |
|
|
217
|
+
| No connectivity at all | `start()` returns `null` — the bridge cannot help |
|
|
218
|
+
| Forced start (`FORCE_BRIDGE=1`) | Bridge starts regardless of detection |
|