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/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
+ };