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/src/index.js
CHANGED
|
@@ -1,62 +1,157 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* IPv6 Bridge - Main Entry Point
|
|
3
|
-
*
|
|
4
|
-
* Coordinates bridge startup and shutdown
|
|
5
|
-
*
|
|
6
|
-
* 1. Detection (detect.js): Checks if the bridge is needed.
|
|
7
|
-
* 2.
|
|
8
|
-
* 3.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
1
|
+
/**
|
|
2
|
+
* IPv6 Bridge - Main Entry Point
|
|
3
|
+
*
|
|
4
|
+
* Coordinates bridge startup and shutdown:
|
|
5
|
+
*
|
|
6
|
+
* 1. Detection (detect.js): Checks if the bridge is needed.
|
|
7
|
+
* 2. Discovery (discovery.js): Finds the network's NAT64 prefix (RFC 7050).
|
|
8
|
+
* 3. DNS64 Resolver (dns64.js): Synthesizes IPv6 addresses from IPv4.
|
|
9
|
+
* 4. Proxy (proxy.js): HTTP/HTTPS proxy with NAT64 routing.
|
|
10
|
+
* 5. SOCKS5 (socks5.js): Optional listener for non-HTTP protocols.
|
|
11
|
+
*
|
|
12
|
+
* @module ipv6-bridge
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const { createProxy } = require('./proxy');
|
|
16
|
+
const { createSocksServer } = require('./socks5');
|
|
17
|
+
const { needsBridge } = require('./detect');
|
|
18
|
+
const { discoverAndApply, discoverPrefix } = require('./discovery');
|
|
19
|
+
const agent = require('./agent');
|
|
20
|
+
const { dnsCache } = require('./dns64');
|
|
21
|
+
const stats = require('./stats');
|
|
22
|
+
const config = require('./config');
|
|
23
|
+
const log = require('./logger');
|
|
24
|
+
|
|
25
|
+
let activeServer = null;
|
|
26
|
+
let activeSocksServer = null;
|
|
27
|
+
let pendingStart = null;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Start the IPv6 bridge.
|
|
31
|
+
*
|
|
32
|
+
* @param {number} [port] - Port to listen on
|
|
33
|
+
* @param {object} [options] - Startup options
|
|
34
|
+
* @param {string} [options.host] - Interface to bind to (defaults to loopback)
|
|
35
|
+
* @param {boolean} [options.force] - Start even if detection says it isn't needed
|
|
36
|
+
* @param {boolean} [options.discoverPrefix] - Run RFC 7050 prefix discovery
|
|
37
|
+
* @param {number|null} [options.socksPort] - Also start a SOCKS5 listener
|
|
38
|
+
* @returns {Promise<http.Server|null>} Server instance if started, null if not needed
|
|
39
|
+
* @throws {Error} If already running or startup fails
|
|
40
|
+
*/
|
|
41
|
+
function start(port = config.DEFAULT_PORT, options = {}) {
|
|
42
|
+
// Assigned synchronously so concurrent callers cannot both pass the guard
|
|
43
|
+
// and leave a second, untracked server running.
|
|
44
|
+
if (activeServer || pendingStart) {
|
|
45
|
+
return Promise.reject(new Error('IPv6 Bridge is already running'));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const {
|
|
49
|
+
host = config.BIND_HOST,
|
|
50
|
+
force = Boolean(process.env.FORCE_BRIDGE),
|
|
51
|
+
discoverPrefix = config.PREFIX_DISCOVERY,
|
|
52
|
+
socksPort = config.SOCKS_PORT,
|
|
53
|
+
} = options;
|
|
54
|
+
|
|
55
|
+
pendingStart = (async () => {
|
|
56
|
+
const needed = await needsBridge();
|
|
57
|
+
|
|
58
|
+
if (!needed && !force) {
|
|
59
|
+
log.info('IPv6 bridge not needed — IPv4 is reachable or NAT64 already works.');
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
if (!needed) {
|
|
63
|
+
log.info('IPv6 bridge not needed, but a forced start was requested.');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (discoverPrefix) {
|
|
67
|
+
// Best effort: a network without DNS64 simply keeps the configured prefix.
|
|
68
|
+
await discoverAndApply().catch((err) => {
|
|
69
|
+
log.debug(`NAT64 prefix discovery failed: ${err.message}`);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const server = await createProxy(port, host);
|
|
74
|
+
|
|
75
|
+
if (socksPort) {
|
|
76
|
+
try {
|
|
77
|
+
activeSocksServer = await createSocksServer(socksPort, host);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
await new Promise((resolve) => server.close(resolve));
|
|
80
|
+
throw err;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return server;
|
|
85
|
+
})();
|
|
86
|
+
|
|
87
|
+
return pendingStart
|
|
88
|
+
.then((server) => {
|
|
89
|
+
activeServer = server;
|
|
90
|
+
return server;
|
|
91
|
+
})
|
|
92
|
+
.finally(() => {
|
|
93
|
+
pendingStart = null;
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Stop the IPv6 bridge.
|
|
99
|
+
*
|
|
100
|
+
* Live connections are torn down; CONNECT tunnels would otherwise keep the
|
|
101
|
+
* server open indefinitely.
|
|
102
|
+
*
|
|
103
|
+
* @returns {Promise<void>} Resolves once everything has closed
|
|
104
|
+
*/
|
|
105
|
+
async function stop() {
|
|
106
|
+
const server = activeServer;
|
|
107
|
+
const socks = activeSocksServer;
|
|
108
|
+
activeServer = null;
|
|
109
|
+
activeSocksServer = null;
|
|
110
|
+
|
|
111
|
+
const closers = [];
|
|
112
|
+
if (server) {
|
|
113
|
+
closers.push(typeof server.closeGracefully === 'function'
|
|
114
|
+
? server.closeGracefully()
|
|
115
|
+
: new Promise((resolve) => server.close(resolve)));
|
|
116
|
+
}
|
|
117
|
+
if (socks) {
|
|
118
|
+
closers.push(socks.closeGracefully());
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
await Promise.all(closers);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Snapshot what the bridge has done so far.
|
|
126
|
+
*
|
|
127
|
+
* Applications embedding the agents can use this to confirm translation is
|
|
128
|
+
* actually happening rather than silently falling back.
|
|
129
|
+
*
|
|
130
|
+
* @returns {object} Counters, routing modes, DNS cache stats and active prefix
|
|
131
|
+
*/
|
|
132
|
+
function getStats() {
|
|
133
|
+
const prefix = config.getPrefix();
|
|
134
|
+
return stats.snapshot({
|
|
135
|
+
dnsCache: dnsCache.stats(),
|
|
136
|
+
nat64Prefix: `${prefix.prefix}/${prefix.length}`,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
module.exports = {
|
|
141
|
+
// Proxy lifecycle
|
|
142
|
+
start,
|
|
143
|
+
stop,
|
|
144
|
+
|
|
145
|
+
// Embeddable primitives — use the bridge from inside an application,
|
|
146
|
+
// with no proxy and no system configuration.
|
|
147
|
+
createAgent: agent.createAgent,
|
|
148
|
+
createHttpsAgent: agent.createHttpsAgent,
|
|
149
|
+
createAgents: agent.createAgents,
|
|
150
|
+
createLookup: agent.createLookup,
|
|
151
|
+
createConnector: agent.createConnector,
|
|
152
|
+
|
|
153
|
+
// Introspection
|
|
154
|
+
resolve: agent.resolve,
|
|
155
|
+
getStats,
|
|
156
|
+
discoverPrefix,
|
|
157
|
+
};
|
package/src/ipv6.js
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IPv6 Bridge - IPv6 address primitives
|
|
3
|
+
*
|
|
4
|
+
* Parsing, formatting, and the IPv4-embedded address format of RFC 6052.
|
|
5
|
+
* Node exposes no inet_pton/inet_ntop equivalent, so these are implemented
|
|
6
|
+
* here to keep the package dependency-free.
|
|
7
|
+
*
|
|
8
|
+
* @module ipv6
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const net = require('net');
|
|
12
|
+
|
|
13
|
+
/** Prefix lengths permitted by RFC 6052 section 2.2. */
|
|
14
|
+
const VALID_PREFIX_LENGTHS = [32, 40, 48, 56, 64, 96];
|
|
15
|
+
|
|
16
|
+
/** The RFC 6052 section 3.1 well-known prefix, in canonical form. */
|
|
17
|
+
const WELL_KNOWN_PREFIX_ADDRESS = '64:ff9b::';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Parse an IPv6 address into its 16 bytes.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} address - IPv6 address, optionally with an embedded IPv4 tail
|
|
23
|
+
* @returns {Buffer|null} 16-byte buffer, or null if the address is invalid
|
|
24
|
+
*/
|
|
25
|
+
function parseIPv6(address) {
|
|
26
|
+
if (typeof address !== 'string' || net.isIP(address) !== 6) return null;
|
|
27
|
+
|
|
28
|
+
let text = address;
|
|
29
|
+
|
|
30
|
+
// A trailing dotted-quad ("::ffff:192.0.2.1") is converted to hex groups.
|
|
31
|
+
const embedded = /(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(text);
|
|
32
|
+
if (embedded) {
|
|
33
|
+
const octets = embedded[1].split('.').map(Number);
|
|
34
|
+
const high = ((octets[0] << 8) | octets[1]).toString(16);
|
|
35
|
+
const low = ((octets[2] << 8) | octets[3]).toString(16);
|
|
36
|
+
text = text.slice(0, embedded.index) + `${high}:${low}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const [head, tail, ...extra] = text.split('::');
|
|
40
|
+
if (extra.length > 0) return null;
|
|
41
|
+
|
|
42
|
+
const parseGroups = (part) => (part ? part.split(':').filter((g) => g !== '') : []);
|
|
43
|
+
const headGroups = parseGroups(head);
|
|
44
|
+
const tailGroups = tail === undefined ? [] : parseGroups(tail);
|
|
45
|
+
|
|
46
|
+
const total = headGroups.length + tailGroups.length;
|
|
47
|
+
if (total > 8) return null;
|
|
48
|
+
if (tail === undefined && total !== 8) return null;
|
|
49
|
+
|
|
50
|
+
const groups = [
|
|
51
|
+
...headGroups,
|
|
52
|
+
...Array(8 - total).fill('0'),
|
|
53
|
+
...tailGroups,
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
const bytes = Buffer.alloc(16);
|
|
57
|
+
for (let i = 0; i < 8; i++) {
|
|
58
|
+
const value = parseInt(groups[i], 16);
|
|
59
|
+
if (Number.isNaN(value) || value < 0 || value > 0xffff) return null;
|
|
60
|
+
bytes.writeUInt16BE(value, i * 2);
|
|
61
|
+
}
|
|
62
|
+
return bytes;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Format 16 bytes as a canonical IPv6 address (RFC 5952): lowercase, with the
|
|
67
|
+
* longest run of zero groups compressed to "::".
|
|
68
|
+
*
|
|
69
|
+
* @param {Buffer} bytes - 16-byte buffer
|
|
70
|
+
* @returns {string} Canonical IPv6 address
|
|
71
|
+
*/
|
|
72
|
+
function formatIPv6(bytes) {
|
|
73
|
+
if (!Buffer.isBuffer(bytes) || bytes.length !== 16) {
|
|
74
|
+
throw new Error('formatIPv6: expected a 16-byte buffer');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const groups = [];
|
|
78
|
+
for (let i = 0; i < 8; i++) groups.push(bytes.readUInt16BE(i * 2));
|
|
79
|
+
|
|
80
|
+
// Find the longest run of two or more zero groups.
|
|
81
|
+
let bestStart = -1;
|
|
82
|
+
let bestLength = 0;
|
|
83
|
+
let runStart = -1;
|
|
84
|
+
for (let i = 0; i <= 8; i++) {
|
|
85
|
+
if (i < 8 && groups[i] === 0) {
|
|
86
|
+
if (runStart === -1) runStart = i;
|
|
87
|
+
} else if (runStart !== -1) {
|
|
88
|
+
const length = i - runStart;
|
|
89
|
+
if (length > bestLength) {
|
|
90
|
+
bestStart = runStart;
|
|
91
|
+
bestLength = length;
|
|
92
|
+
}
|
|
93
|
+
runStart = -1;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const hex = groups.map((g) => g.toString(16));
|
|
98
|
+
if (bestLength < 2) return hex.join(':');
|
|
99
|
+
|
|
100
|
+
const head = hex.slice(0, bestStart).join(':');
|
|
101
|
+
const tail = hex.slice(bestStart + bestLength).join(':');
|
|
102
|
+
return `${head}::${tail}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Parse an IPv4 address into its 4 bytes.
|
|
107
|
+
*
|
|
108
|
+
* @param {string} address - IPv4 address
|
|
109
|
+
* @returns {Buffer|null} 4-byte buffer, or null if invalid
|
|
110
|
+
*/
|
|
111
|
+
function parseIPv4(address) {
|
|
112
|
+
if (typeof address !== 'string' || net.isIP(address) !== 4) return null;
|
|
113
|
+
return Buffer.from(address.split('.').map(Number));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Parse a NAT64 prefix specification such as "64:ff9b::/96" or "64:ff9b::".
|
|
118
|
+
*
|
|
119
|
+
* @param {string} spec - Prefix, with an optional "/length" suffix
|
|
120
|
+
* @returns {{prefix: string, length: number, bytes: Buffer}} Parsed prefix
|
|
121
|
+
* @throws {Error} If the prefix or length is invalid
|
|
122
|
+
*/
|
|
123
|
+
function parsePrefix(spec) {
|
|
124
|
+
if (typeof spec !== 'string' || spec.trim() === '') {
|
|
125
|
+
throw new Error('NAT64 prefix must be a non-empty string');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const [address, lengthText] = spec.trim().split('/');
|
|
129
|
+
const length = lengthText === undefined ? 96 : Number(lengthText);
|
|
130
|
+
|
|
131
|
+
if (!VALID_PREFIX_LENGTHS.includes(length)) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`Invalid NAT64 prefix length /${lengthText}: RFC 6052 permits only ` +
|
|
134
|
+
`${VALID_PREFIX_LENGTHS.map((l) => `/${l}`).join(', ')}`
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const bytes = parseIPv6(address);
|
|
139
|
+
if (!bytes) {
|
|
140
|
+
throw new Error(`Invalid NAT64 prefix "${address}": not a valid IPv6 address`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Everything past the prefix length must be zero.
|
|
144
|
+
for (let bit = length; bit < 128; bit++) {
|
|
145
|
+
const byte = bytes[Math.floor(bit / 8)];
|
|
146
|
+
if ((byte >> (7 - (bit % 8))) & 1) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
`Invalid NAT64 prefix "${spec}": bits after /${length} must be zero`
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const formatted = formatIPv6(bytes);
|
|
154
|
+
|
|
155
|
+
// RFC 6052 section 3.1 defines the well-known prefix only at /96.
|
|
156
|
+
if (formatted === WELL_KNOWN_PREFIX_ADDRESS && length !== 96) {
|
|
157
|
+
throw new Error(
|
|
158
|
+
`Invalid NAT64 prefix "${spec}": the well-known prefix ${WELL_KNOWN_PREFIX_ADDRESS} ` +
|
|
159
|
+
`is only defined as /96 (RFC 6052 section 3.1)`
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return { prefix: formatted, length, bytes };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Byte positions an embedded IPv4 address occupies for a given prefix length.
|
|
168
|
+
*
|
|
169
|
+
* RFC 6052 section 2.2 reserves bits 64-71 (byte 8) as the "u" octet, which
|
|
170
|
+
* must be zero, so the address skips over it.
|
|
171
|
+
*
|
|
172
|
+
* @param {number} prefixLength - Prefix length in bits
|
|
173
|
+
* @returns {number[]} Four byte indices
|
|
174
|
+
*/
|
|
175
|
+
function embeddedPositions(prefixLength) {
|
|
176
|
+
const positions = [];
|
|
177
|
+
let index = prefixLength / 8;
|
|
178
|
+
while (positions.length < 4) {
|
|
179
|
+
if (index === 8) index = 9; // skip the reserved u octet
|
|
180
|
+
positions.push(index);
|
|
181
|
+
index += 1;
|
|
182
|
+
}
|
|
183
|
+
return positions;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Embed an IPv4 address into a NAT64 prefix (RFC 6052 section 2.2).
|
|
188
|
+
*
|
|
189
|
+
* @param {string} ipv4 - IPv4 address
|
|
190
|
+
* @param {{bytes: Buffer, length: number}} prefix - Parsed prefix
|
|
191
|
+
* @returns {string} IPv4-embedded IPv6 address
|
|
192
|
+
* @throws {Error} If the IPv4 address is invalid
|
|
193
|
+
*/
|
|
194
|
+
function embedIPv4(ipv4, prefix) {
|
|
195
|
+
const octets = parseIPv4(ipv4);
|
|
196
|
+
if (!octets) throw new Error(`embedIPv4: invalid IPv4 address "${ipv4}"`);
|
|
197
|
+
|
|
198
|
+
const bytes = Buffer.alloc(16);
|
|
199
|
+
prefix.bytes.copy(bytes, 0, 0, prefix.length / 8);
|
|
200
|
+
|
|
201
|
+
embeddedPositions(prefix.length).forEach((position, i) => {
|
|
202
|
+
bytes[position] = octets[i];
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
return formatIPv6(bytes);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Extract an IPv4 address embedded in an IPv6 address (RFC 6052 section 2.2).
|
|
210
|
+
*
|
|
211
|
+
* @param {string} address - IPv4-embedded IPv6 address
|
|
212
|
+
* @param {number} prefixLength - Prefix length in bits
|
|
213
|
+
* @returns {string|null} Extracted IPv4 address, or null if the input is invalid
|
|
214
|
+
*/
|
|
215
|
+
function extractIPv4(address, prefixLength) {
|
|
216
|
+
const bytes = parseIPv6(address);
|
|
217
|
+
if (!bytes || !VALID_PREFIX_LENGTHS.includes(prefixLength)) return null;
|
|
218
|
+
|
|
219
|
+
return embeddedPositions(prefixLength).map((position) => bytes[position]).join('.');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
module.exports = {
|
|
223
|
+
VALID_PREFIX_LENGTHS,
|
|
224
|
+
WELL_KNOWN_PREFIX_ADDRESS,
|
|
225
|
+
parseIPv6,
|
|
226
|
+
formatIPv6,
|
|
227
|
+
parseIPv4,
|
|
228
|
+
parsePrefix,
|
|
229
|
+
embedIPv4,
|
|
230
|
+
extractIPv4,
|
|
231
|
+
embeddedPositions,
|
|
232
|
+
};
|
package/src/logger.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IPv6 Bridge - Logger
|
|
3
|
+
*
|
|
4
|
+
* Minimal structured logger with level filtering.
|
|
5
|
+
* Respects the LOG_LEVEL environment variable (default: 'info').
|
|
6
|
+
*
|
|
7
|
+
* Levels: silent < error < warn < info < debug
|
|
8
|
+
*
|
|
9
|
+
* @module logger
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const LEVELS = { silent: -1, error: 0, warn: 1, info: 2, debug: 3 };
|
|
13
|
+
|
|
14
|
+
const currentLevel = LEVELS[
|
|
15
|
+
(process.env.LOG_LEVEL || 'info').toLowerCase()
|
|
16
|
+
] ?? LEVELS.info;
|
|
17
|
+
|
|
18
|
+
function timestamp() {
|
|
19
|
+
return new Date().toISOString();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function format(level, msg) {
|
|
23
|
+
return `[${timestamp()}] [${level.toUpperCase()}] ${msg}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
module.exports = {
|
|
27
|
+
error(msg) {
|
|
28
|
+
if (currentLevel >= LEVELS.error) {
|
|
29
|
+
console.error(format('error', msg));
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
warn(msg) {
|
|
33
|
+
if (currentLevel >= LEVELS.warn) {
|
|
34
|
+
console.warn(format('warn', msg));
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
info(msg) {
|
|
38
|
+
if (currentLevel >= LEVELS.info) {
|
|
39
|
+
console.log(format('info', msg));
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
debug(msg) {
|
|
43
|
+
if (currentLevel >= LEVELS.debug) {
|
|
44
|
+
console.log(format('debug', msg));
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
};
|
package/src/netmatch.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IPv6 Bridge - Address and hostname matching
|
|
3
|
+
*
|
|
4
|
+
* Shared matching logic for the client allowlist and the bypass list.
|
|
5
|
+
* Supports IPv4/IPv6 CIDR ranges, bare addresses, and hostname wildcards.
|
|
6
|
+
*
|
|
7
|
+
* @module netmatch
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const net = require('net');
|
|
11
|
+
const { parseIPv6, parseIPv4 } = require('./ipv6');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Normalize an address into bytes. IPv4-mapped IPv6 addresses ("::ffff:1.2.3.4",
|
|
15
|
+
* which is how a dual-stack listener reports IPv4 peers) are reduced to IPv4.
|
|
16
|
+
*
|
|
17
|
+
* @param {string} address - IP address
|
|
18
|
+
* @returns {{bytes: Buffer, family: number}|null} Normalized address
|
|
19
|
+
*/
|
|
20
|
+
function toBytes(address) {
|
|
21
|
+
if (typeof address !== 'string') return null;
|
|
22
|
+
|
|
23
|
+
const mapped = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i.exec(address);
|
|
24
|
+
const candidate = mapped ? mapped[1] : address;
|
|
25
|
+
|
|
26
|
+
const version = net.isIP(candidate);
|
|
27
|
+
if (version === 4) return { bytes: parseIPv4(candidate), family: 4 };
|
|
28
|
+
if (version === 6) {
|
|
29
|
+
const bytes = parseIPv6(candidate);
|
|
30
|
+
return bytes ? { bytes, family: 6 } : null;
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Parse a CIDR rule such as "192.168.0.0/16", "10.0.0.1" or "2001:db8::/32".
|
|
37
|
+
*
|
|
38
|
+
* @param {string} rule - CIDR or bare address
|
|
39
|
+
* @returns {{bytes: Buffer, family: number, bits: number}|null} Parsed rule
|
|
40
|
+
*/
|
|
41
|
+
function parseCidr(rule) {
|
|
42
|
+
const [address, lengthText] = String(rule).trim().split('/');
|
|
43
|
+
const parsed = toBytes(address);
|
|
44
|
+
if (!parsed) return null;
|
|
45
|
+
|
|
46
|
+
const maxBits = parsed.family === 4 ? 32 : 128;
|
|
47
|
+
const bits = lengthText === undefined ? maxBits : Number(lengthText);
|
|
48
|
+
if (!Number.isInteger(bits) || bits < 0 || bits > maxBits) return null;
|
|
49
|
+
|
|
50
|
+
return { ...parsed, bits };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Test whether an address falls inside a parsed CIDR rule.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} address - IP address to test
|
|
57
|
+
* @param {{bytes: Buffer, family: number, bits: number}} rule - Parsed rule
|
|
58
|
+
* @returns {boolean} true if the address matches
|
|
59
|
+
*/
|
|
60
|
+
function matchesCidr(address, rule) {
|
|
61
|
+
const parsed = toBytes(address);
|
|
62
|
+
if (!parsed || !rule || parsed.family !== rule.family) return false;
|
|
63
|
+
|
|
64
|
+
const wholeBytes = Math.floor(rule.bits / 8);
|
|
65
|
+
const remainingBits = rule.bits % 8;
|
|
66
|
+
|
|
67
|
+
for (let i = 0; i < wholeBytes; i++) {
|
|
68
|
+
if (parsed.bytes[i] !== rule.bytes[i]) return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (remainingBits === 0) return true;
|
|
72
|
+
|
|
73
|
+
const mask = (0xff << (8 - remainingBits)) & 0xff;
|
|
74
|
+
return (parsed.bytes[wholeBytes] & mask) === (rule.bytes[wholeBytes] & mask);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Compile a comma-separated rule list into a matcher.
|
|
79
|
+
*
|
|
80
|
+
* Each rule is either a CIDR/address or a hostname pattern, where a leading
|
|
81
|
+
* "*." matches any subdomain and the bare domain itself.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} spec - Comma-separated rules
|
|
84
|
+
* @returns {{isEmpty: boolean, rules: string[], matches: (value: string) => boolean}} Matcher
|
|
85
|
+
*/
|
|
86
|
+
function compile(spec) {
|
|
87
|
+
const rules = String(spec || '')
|
|
88
|
+
.split(',')
|
|
89
|
+
.map((r) => r.trim().toLowerCase())
|
|
90
|
+
.filter(Boolean);
|
|
91
|
+
|
|
92
|
+
const cidrs = [];
|
|
93
|
+
const hostPatterns = [];
|
|
94
|
+
|
|
95
|
+
for (const rule of rules) {
|
|
96
|
+
const cidr = parseCidr(rule);
|
|
97
|
+
if (cidr) cidrs.push(cidr);
|
|
98
|
+
else hostPatterns.push(rule);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function matches(value) {
|
|
102
|
+
if (!value) return false;
|
|
103
|
+
const target = String(value).toLowerCase();
|
|
104
|
+
|
|
105
|
+
if (net.isIP(target) !== 0 || /^::ffff:/i.test(target)) {
|
|
106
|
+
if (cidrs.some((rule) => matchesCidr(target, rule))) return true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return hostPatterns.some((pattern) => {
|
|
110
|
+
if (pattern === '*') return true;
|
|
111
|
+
if (pattern.startsWith('*.')) {
|
|
112
|
+
const domain = pattern.slice(2);
|
|
113
|
+
return target === domain || target.endsWith('.' + domain);
|
|
114
|
+
}
|
|
115
|
+
return target === pattern;
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { isEmpty: rules.length === 0, rules, matches };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
module.exports = { compile, parseCidr, matchesCidr, toBytes };
|