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/socks5.js
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IPv6 Bridge - SOCKS5 server (RFC 1928, RFC 1929)
|
|
3
|
+
*
|
|
4
|
+
* An HTTP proxy can only carry HTTP. SOCKS5 carries any TCP protocol, so this
|
|
5
|
+
* listener lets ssh, git, database clients and anything else reach IPv4-only
|
|
6
|
+
* servers through the same DNS64/NAT64 translation.
|
|
7
|
+
*
|
|
8
|
+
* Only the CONNECT command is implemented; BIND and UDP ASSOCIATE require
|
|
9
|
+
* inbound reachability that a user-space bridge cannot provide.
|
|
10
|
+
*
|
|
11
|
+
* @module socks5
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const net = require('net');
|
|
15
|
+
const { connectWithFallback } = require('./connect');
|
|
16
|
+
const config = require('./config');
|
|
17
|
+
const stats = require('./stats');
|
|
18
|
+
const log = require('./logger');
|
|
19
|
+
|
|
20
|
+
const VERSION = 0x05;
|
|
21
|
+
|
|
22
|
+
const AUTH_NONE = 0x00;
|
|
23
|
+
const AUTH_USERPASS = 0x02;
|
|
24
|
+
const AUTH_UNACCEPTABLE = 0xff;
|
|
25
|
+
|
|
26
|
+
const CMD_CONNECT = 0x01;
|
|
27
|
+
|
|
28
|
+
const ATYP_IPV4 = 0x01;
|
|
29
|
+
const ATYP_DOMAIN = 0x03;
|
|
30
|
+
const ATYP_IPV6 = 0x04;
|
|
31
|
+
|
|
32
|
+
const REPLY = {
|
|
33
|
+
SUCCESS: 0x00,
|
|
34
|
+
GENERAL_FAILURE: 0x01,
|
|
35
|
+
NOT_ALLOWED: 0x02,
|
|
36
|
+
HOST_UNREACHABLE: 0x04,
|
|
37
|
+
TTL_EXPIRED: 0x06,
|
|
38
|
+
COMMAND_NOT_SUPPORTED: 0x07,
|
|
39
|
+
ADDRESS_TYPE_NOT_SUPPORTED: 0x08,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Read an exact number of bytes from a socket.
|
|
44
|
+
*
|
|
45
|
+
* @param {net.Socket} socket - Socket to read from
|
|
46
|
+
* @param {number} length - Byte count
|
|
47
|
+
* @param {number} timeout - Milliseconds to wait
|
|
48
|
+
* @returns {Promise<Buffer>} The bytes read
|
|
49
|
+
*/
|
|
50
|
+
function readBytes(socket, length, timeout) {
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
if (length === 0) return resolve(Buffer.alloc(0));
|
|
53
|
+
|
|
54
|
+
let buffer = socket.read(length);
|
|
55
|
+
if (buffer) return resolve(buffer);
|
|
56
|
+
|
|
57
|
+
const timer = setTimeout(() => {
|
|
58
|
+
cleanup();
|
|
59
|
+
reject(new Error('SOCKS5 handshake timed out'));
|
|
60
|
+
}, timeout);
|
|
61
|
+
|
|
62
|
+
function onReadable() {
|
|
63
|
+
buffer = socket.read(length);
|
|
64
|
+
if (buffer) {
|
|
65
|
+
cleanup();
|
|
66
|
+
resolve(buffer);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function onEnd() {
|
|
70
|
+
cleanup();
|
|
71
|
+
reject(new Error('SOCKS5 client closed the connection during the handshake'));
|
|
72
|
+
}
|
|
73
|
+
function cleanup() {
|
|
74
|
+
clearTimeout(timer);
|
|
75
|
+
socket.removeListener('readable', onReadable);
|
|
76
|
+
socket.removeListener('end', onEnd);
|
|
77
|
+
socket.removeListener('error', onEnd);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
socket.on('readable', onReadable);
|
|
81
|
+
socket.once('end', onEnd);
|
|
82
|
+
socket.once('error', onEnd);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Build a SOCKS5 reply message.
|
|
88
|
+
*
|
|
89
|
+
* @param {number} code - Reply code
|
|
90
|
+
* @param {object} [bound] - Bound address to report
|
|
91
|
+
* @returns {Buffer} Encoded reply
|
|
92
|
+
*/
|
|
93
|
+
function reply(code, bound = null) {
|
|
94
|
+
// Clients ignore the bound address for CONNECT, so report the unspecified
|
|
95
|
+
// IPv4 address unless a real one is available.
|
|
96
|
+
if (!bound || net.isIP(bound.address) !== 4) {
|
|
97
|
+
return Buffer.from([VERSION, code, 0x00, ATYP_IPV4, 0, 0, 0, 0, 0, 0]);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const octets = bound.address.split('.').map(Number);
|
|
101
|
+
const message = Buffer.alloc(10);
|
|
102
|
+
message[0] = VERSION;
|
|
103
|
+
message[1] = code;
|
|
104
|
+
message[2] = 0x00;
|
|
105
|
+
message[3] = ATYP_IPV4;
|
|
106
|
+
Buffer.from(octets).copy(message, 4);
|
|
107
|
+
message.writeUInt16BE(bound.port || 0, 8);
|
|
108
|
+
return message;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Negotiate the authentication method (RFC 1928 section 3, RFC 1929).
|
|
113
|
+
*
|
|
114
|
+
* @param {net.Socket} socket - Client socket
|
|
115
|
+
* @param {number} timeout - Handshake timeout
|
|
116
|
+
* @returns {Promise<boolean>} true if the client is authenticated
|
|
117
|
+
*/
|
|
118
|
+
async function negotiateAuth(socket, timeout) {
|
|
119
|
+
const greeting = await readBytes(socket, 2, timeout);
|
|
120
|
+
if (greeting[0] !== VERSION) throw new Error(`Unsupported SOCKS version ${greeting[0]}`);
|
|
121
|
+
|
|
122
|
+
const methods = await readBytes(socket, greeting[1], timeout);
|
|
123
|
+
const required = config.AUTH ? AUTH_USERPASS : AUTH_NONE;
|
|
124
|
+
|
|
125
|
+
if (!methods.includes(required)) {
|
|
126
|
+
socket.end(Buffer.from([VERSION, AUTH_UNACCEPTABLE]));
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
socket.write(Buffer.from([VERSION, required]));
|
|
131
|
+
if (!config.AUTH) return true;
|
|
132
|
+
|
|
133
|
+
// RFC 1929 username/password sub-negotiation
|
|
134
|
+
const header = await readBytes(socket, 2, timeout);
|
|
135
|
+
if (header[0] !== 0x01) throw new Error('Unsupported SOCKS5 auth sub-negotiation version');
|
|
136
|
+
|
|
137
|
+
const username = (await readBytes(socket, header[1], timeout)).toString();
|
|
138
|
+
const passwordLength = (await readBytes(socket, 1, timeout))[0];
|
|
139
|
+
const password = (await readBytes(socket, passwordLength, timeout)).toString();
|
|
140
|
+
|
|
141
|
+
const ok = username === config.AUTH.username && password === config.AUTH.password;
|
|
142
|
+
socket.write(Buffer.from([0x01, ok ? 0x00 : 0x01]));
|
|
143
|
+
|
|
144
|
+
if (!ok) {
|
|
145
|
+
stats.counters.authFailures += 1;
|
|
146
|
+
socket.end();
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Read the CONNECT request and resolve its target.
|
|
154
|
+
*
|
|
155
|
+
* @param {net.Socket} socket - Client socket
|
|
156
|
+
* @param {number} timeout - Handshake timeout
|
|
157
|
+
* @returns {Promise<{hostname: string, port: number}>} Requested destination
|
|
158
|
+
*/
|
|
159
|
+
async function readRequest(socket, timeout) {
|
|
160
|
+
const header = await readBytes(socket, 4, timeout);
|
|
161
|
+
if (header[0] !== VERSION) throw new Error(`Unsupported SOCKS version ${header[0]}`);
|
|
162
|
+
|
|
163
|
+
const command = header[1];
|
|
164
|
+
const addressType = header[3];
|
|
165
|
+
|
|
166
|
+
if (command !== CMD_CONNECT) {
|
|
167
|
+
const err = new Error(`Unsupported SOCKS5 command ${command}`);
|
|
168
|
+
err.replyCode = REPLY.COMMAND_NOT_SUPPORTED;
|
|
169
|
+
throw err;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
let hostname;
|
|
173
|
+
if (addressType === ATYP_IPV4) {
|
|
174
|
+
hostname = Array.from(await readBytes(socket, 4, timeout)).join('.');
|
|
175
|
+
} else if (addressType === ATYP_IPV6) {
|
|
176
|
+
const bytes = await readBytes(socket, 16, timeout);
|
|
177
|
+
const groups = [];
|
|
178
|
+
for (let i = 0; i < 16; i += 2) groups.push(bytes.readUInt16BE(i).toString(16));
|
|
179
|
+
hostname = groups.join(':');
|
|
180
|
+
} else if (addressType === ATYP_DOMAIN) {
|
|
181
|
+
const length = (await readBytes(socket, 1, timeout))[0];
|
|
182
|
+
hostname = (await readBytes(socket, length, timeout)).toString();
|
|
183
|
+
} else {
|
|
184
|
+
const err = new Error(`Unsupported SOCKS5 address type ${addressType}`);
|
|
185
|
+
err.replyCode = REPLY.ADDRESS_TYPE_NOT_SUPPORTED;
|
|
186
|
+
throw err;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const port = (await readBytes(socket, 2, timeout)).readUInt16BE(0);
|
|
190
|
+
return { hostname, port };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function handleConnection(socket) {
|
|
194
|
+
const timeout = config.CONNECTION_TIMEOUT;
|
|
195
|
+
socket.pause();
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
if (!config.ALLOW_FROM.isEmpty && !config.ALLOW_FROM.matches(socket.remoteAddress)) {
|
|
199
|
+
stats.counters.authFailures += 1;
|
|
200
|
+
log.warn(`Rejected SOCKS5 connection from ${socket.remoteAddress}: not in the allowlist`);
|
|
201
|
+
socket.end();
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (!await negotiateAuth(socket, timeout)) return;
|
|
206
|
+
|
|
207
|
+
const target = await readRequest(socket, timeout);
|
|
208
|
+
stats.counters.socksRequests += 1;
|
|
209
|
+
|
|
210
|
+
const bypass = !config.BYPASS.isEmpty && config.BYPASS.matches(target.hostname);
|
|
211
|
+
|
|
212
|
+
let upstream;
|
|
213
|
+
try {
|
|
214
|
+
({ socket: upstream } = await connectWithFallback(target.hostname, target.port, { bypass }));
|
|
215
|
+
} catch (err) {
|
|
216
|
+
stats.counters.proxyErrors += 1;
|
|
217
|
+
log.warn(`SOCKS5 connect to ${target.hostname}:${target.port} failed: ${err.message}`);
|
|
218
|
+
socket.end(reply(err.code === 'ETIMEDOUT' ? REPLY.TTL_EXPIRED : REPLY.HOST_UNREACHABLE));
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (socket.destroyed) {
|
|
223
|
+
upstream.destroy();
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
log.debug(`SOCKS5 ${target.hostname}:${target.port} connected`);
|
|
228
|
+
socket.write(reply(REPLY.SUCCESS, upstream.address()));
|
|
229
|
+
|
|
230
|
+
upstream.on('data', (chunk) => { stats.counters.bytesToClient += chunk.length; });
|
|
231
|
+
socket.on('data', (chunk) => { stats.counters.bytesToUpstream += chunk.length; });
|
|
232
|
+
|
|
233
|
+
socket.resume();
|
|
234
|
+
upstream.pipe(socket);
|
|
235
|
+
socket.pipe(upstream);
|
|
236
|
+
|
|
237
|
+
upstream.on('error', () => socket.destroy());
|
|
238
|
+
socket.on('error', () => upstream.destroy());
|
|
239
|
+
socket.on('close', () => upstream.destroy());
|
|
240
|
+
} catch (err) {
|
|
241
|
+
log.debug(`SOCKS5 handshake failed: ${err.message}`);
|
|
242
|
+
if (!socket.destroyed) {
|
|
243
|
+
socket.end(reply(err.replyCode || REPLY.GENERAL_FAILURE));
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Create and start a SOCKS5 server.
|
|
250
|
+
*
|
|
251
|
+
* @param {number} port - Port to listen on
|
|
252
|
+
* @param {string} [host] - Interface to bind to
|
|
253
|
+
* @returns {Promise<net.Server>} Resolves with the server once it's listening
|
|
254
|
+
*/
|
|
255
|
+
function createSocksServer(port, host = config.BIND_HOST) {
|
|
256
|
+
return new Promise((resolve, reject) => {
|
|
257
|
+
const server = net.createServer(handleConnection);
|
|
258
|
+
const sockets = new Set();
|
|
259
|
+
|
|
260
|
+
server.on('connection', (socket) => {
|
|
261
|
+
sockets.add(socket);
|
|
262
|
+
socket.on('close', () => sockets.delete(socket));
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
server.closeGracefully = () => new Promise((done) => {
|
|
266
|
+
server.close(() => done());
|
|
267
|
+
for (const socket of sockets) socket.destroy();
|
|
268
|
+
sockets.clear();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
server.once('error', (err) => {
|
|
272
|
+
reject(new Error(`Failed to start SOCKS5 server: ${err.message}`));
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
server.listen(port, host, () => {
|
|
276
|
+
const bound = server.address();
|
|
277
|
+
log.info(`SOCKS5 listening on ${bound.address}:${bound.port}`);
|
|
278
|
+
resolve(server);
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
module.exports = { createSocksServer, REPLY };
|
package/src/stats.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IPv6 Bridge - Runtime counters
|
|
3
|
+
*
|
|
4
|
+
* Tracks what the bridge actually did, so operators can confirm that traffic is
|
|
5
|
+
* being translated rather than quietly falling back to direct connections.
|
|
6
|
+
*
|
|
7
|
+
* @module stats
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const startedAt = Date.now();
|
|
11
|
+
|
|
12
|
+
const counters = {
|
|
13
|
+
httpRequests: 0,
|
|
14
|
+
connectRequests: 0,
|
|
15
|
+
socksRequests: 0,
|
|
16
|
+
responses2xx: 0,
|
|
17
|
+
responses4xx: 0,
|
|
18
|
+
responses5xx: 0,
|
|
19
|
+
proxyErrors: 0,
|
|
20
|
+
timeouts: 0,
|
|
21
|
+
authFailures: 0,
|
|
22
|
+
bytesToClient: 0,
|
|
23
|
+
bytesToUpstream: 0,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* How each request was routed. "nat64" means the bridge did its job; a rising
|
|
28
|
+
* "directIpv4Fallback" means DNS64 is failing and nothing is being translated.
|
|
29
|
+
*/
|
|
30
|
+
const routes = {
|
|
31
|
+
nat64: 0,
|
|
32
|
+
nativeIpv6: 0,
|
|
33
|
+
ipv6Literal: 0,
|
|
34
|
+
directIpv4: 0,
|
|
35
|
+
directIpv4Fallback: 0,
|
|
36
|
+
bypassed: 0,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function increment(group, key, amount = 1) {
|
|
40
|
+
if (Object.prototype.hasOwnProperty.call(group, key)) {
|
|
41
|
+
group[key] += amount;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Record a routing decision by its mode name. */
|
|
46
|
+
function recordRoute(mode) {
|
|
47
|
+
const key = {
|
|
48
|
+
'nat64': 'nat64',
|
|
49
|
+
'native-ipv6': 'nativeIpv6',
|
|
50
|
+
'ipv6-literal': 'ipv6Literal',
|
|
51
|
+
'direct-ipv4': 'directIpv4',
|
|
52
|
+
'direct-ipv4-fallback': 'directIpv4Fallback',
|
|
53
|
+
'bypassed': 'bypassed',
|
|
54
|
+
}[mode];
|
|
55
|
+
if (key) routes[key] += 1;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Record an upstream response status code. */
|
|
59
|
+
function recordStatus(statusCode) {
|
|
60
|
+
if (statusCode >= 200 && statusCode < 400) counters.responses2xx += 1;
|
|
61
|
+
else if (statusCode >= 400 && statusCode < 500) counters.responses4xx += 1;
|
|
62
|
+
else if (statusCode >= 500) counters.responses5xx += 1;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Snapshot every counter.
|
|
67
|
+
*
|
|
68
|
+
* @param {object} [extra] - Additional sections to include (cache stats, config)
|
|
69
|
+
* @returns {object} Snapshot
|
|
70
|
+
*/
|
|
71
|
+
function snapshot(extra = {}) {
|
|
72
|
+
const uptimeMs = Date.now() - startedAt;
|
|
73
|
+
const translated = routes.nat64;
|
|
74
|
+
const untranslated = routes.directIpv4 + routes.directIpv4Fallback;
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
uptimeSeconds: Math.floor(uptimeMs / 1000),
|
|
78
|
+
startedAt: new Date(startedAt).toISOString(),
|
|
79
|
+
counters: { ...counters },
|
|
80
|
+
routes: { ...routes },
|
|
81
|
+
// The headline number: is the bridge actually bridging?
|
|
82
|
+
translationRate: translated + untranslated === 0
|
|
83
|
+
? null
|
|
84
|
+
: Number((translated / (translated + untranslated)).toFixed(4)),
|
|
85
|
+
...extra,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Render the snapshot in Prometheus text exposition format. */
|
|
90
|
+
function toPrometheus(extra = {}) {
|
|
91
|
+
const data = snapshot(extra);
|
|
92
|
+
const lines = [];
|
|
93
|
+
|
|
94
|
+
const emit = (name, value, help, type = 'counter') => {
|
|
95
|
+
if (value === null || value === undefined) return;
|
|
96
|
+
lines.push(`# HELP ${name} ${help}`);
|
|
97
|
+
lines.push(`# TYPE ${name} ${type}`);
|
|
98
|
+
lines.push(`${name} ${value}`);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
emit('ipv6_bridge_uptime_seconds', data.uptimeSeconds, 'Seconds since the bridge started', 'gauge');
|
|
102
|
+
|
|
103
|
+
for (const [key, value] of Object.entries(data.counters)) {
|
|
104
|
+
const name = `ipv6_bridge_${key.replace(/[A-Z]/g, (c) => '_' + c.toLowerCase())}`;
|
|
105
|
+
emit(name, value, `Total ${key}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
for (const [key, value] of Object.entries(data.routes)) {
|
|
109
|
+
const mode = key.replace(/[A-Z]/g, (c) => '_' + c.toLowerCase());
|
|
110
|
+
lines.push(`ipv6_bridge_route_total{mode="${mode}"} ${value}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
emit('ipv6_bridge_translation_rate', data.translationRate,
|
|
114
|
+
'Share of resolved requests routed through NAT64', 'gauge');
|
|
115
|
+
|
|
116
|
+
if (data.dnsCache) {
|
|
117
|
+
emit('ipv6_bridge_dns_cache_size', data.dnsCache.size, 'DNS cache entries', 'gauge');
|
|
118
|
+
emit('ipv6_bridge_dns_cache_hits', data.dnsCache.hits, 'DNS cache hits');
|
|
119
|
+
emit('ipv6_bridge_dns_cache_misses', data.dnsCache.misses, 'DNS cache misses');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return lines.join('\n') + '\n';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Reset every counter. Intended for tests. */
|
|
126
|
+
function reset() {
|
|
127
|
+
for (const key of Object.keys(counters)) counters[key] = 0;
|
|
128
|
+
for (const key of Object.keys(routes)) routes[key] = 0;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
module.exports = {
|
|
132
|
+
counters,
|
|
133
|
+
routes,
|
|
134
|
+
increment,
|
|
135
|
+
recordRoute,
|
|
136
|
+
recordStatus,
|
|
137
|
+
snapshot,
|
|
138
|
+
toPrometheus,
|
|
139
|
+
reset,
|
|
140
|
+
};
|