tunnelfetch 1.0.0

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.
Files changed (96) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +617 -0
  3. package/README.zh-CN.md +470 -0
  4. package/package.json +74 -0
  5. package/src/client/cookies.js +429 -0
  6. package/src/client/decode.js +346 -0
  7. package/src/client/redirect.js +249 -0
  8. package/src/client.js +704 -0
  9. package/src/errors.js +181 -0
  10. package/src/http1/chunked.js +289 -0
  11. package/src/http1/index.js +10 -0
  12. package/src/http1/request.js +143 -0
  13. package/src/http1/response.js +493 -0
  14. package/src/http2/connection.js +1170 -0
  15. package/src/http2/constants.js +129 -0
  16. package/src/http2/frames.js +291 -0
  17. package/src/http2/hpack.js +420 -0
  18. package/src/http2/huffman.js +203 -0
  19. package/src/http2/index.js +21 -0
  20. package/src/index.js +46 -0
  21. package/src/pool.js +256 -0
  22. package/src/proxy/direct.js +62 -0
  23. package/src/proxy/http-connect.js +206 -0
  24. package/src/proxy/index.js +197 -0
  25. package/src/proxy/socks5.js +344 -0
  26. package/src/tls/aead.js +263 -0
  27. package/src/tls/connect.js +407 -0
  28. package/src/tls/constants.js +334 -0
  29. package/src/tls/extensions.js +376 -0
  30. package/src/tls/handshake-messages.js +901 -0
  31. package/src/tls/handshake.js +568 -0
  32. package/src/tls/handshake12.js +507 -0
  33. package/src/tls/index.js +44 -0
  34. package/src/tls/keyschedule.js +473 -0
  35. package/src/tls/record.js +872 -0
  36. package/src/tls/tickets.js +145 -0
  37. package/src/tls/transcript.js +101 -0
  38. package/src/tls/wire.js +224 -0
  39. package/src/transport.js +296 -0
  40. package/src/trust/der.js +551 -0
  41. package/src/trust/index.js +375 -0
  42. package/src/trust/name.js +235 -0
  43. package/src/trust/ocsp.js +759 -0
  44. package/src/trust/path.js +595 -0
  45. package/src/trust/roots.js +454 -0
  46. package/src/trust/x509.js +902 -0
  47. package/src/util/bytes.js +470 -0
  48. package/src/util/deadline.js +266 -0
  49. package/src/warmup-fixture.js +85 -0
  50. package/src/warmup.js +243 -0
  51. package/types/client/cookies.d.ts +159 -0
  52. package/types/client/decode.d.ts +54 -0
  53. package/types/client/redirect.d.ts +96 -0
  54. package/types/client.d.ts +323 -0
  55. package/types/errors.d.ts +141 -0
  56. package/types/http1/chunked.d.ts +48 -0
  57. package/types/http1/index.d.ts +3 -0
  58. package/types/http1/request.d.ts +44 -0
  59. package/types/http1/response.d.ts +183 -0
  60. package/types/http2/connection.d.ts +282 -0
  61. package/types/http2/constants.d.ts +95 -0
  62. package/types/http2/frames.d.ts +116 -0
  63. package/types/http2/hpack.d.ts +99 -0
  64. package/types/http2/huffman.d.ts +21 -0
  65. package/types/http2/index.d.ts +5 -0
  66. package/types/index.d.ts +17 -0
  67. package/types/pool.d.ts +135 -0
  68. package/types/proxy/direct.d.ts +26 -0
  69. package/types/proxy/http-connect.d.ts +37 -0
  70. package/types/proxy/index.d.ts +62 -0
  71. package/types/proxy/socks5.d.ts +47 -0
  72. package/types/tls/aead.d.ts +67 -0
  73. package/types/tls/connect.d.ts +280 -0
  74. package/types/tls/constants.d.ts +275 -0
  75. package/types/tls/extensions.d.ts +195 -0
  76. package/types/tls/handshake-messages.d.ts +430 -0
  77. package/types/tls/handshake.d.ts +90 -0
  78. package/types/tls/handshake12.d.ts +35 -0
  79. package/types/tls/index.d.ts +9 -0
  80. package/types/tls/keyschedule.d.ts +272 -0
  81. package/types/tls/record.d.ts +361 -0
  82. package/types/tls/tickets.d.ts +66 -0
  83. package/types/tls/transcript.d.ts +52 -0
  84. package/types/tls/wire.d.ts +106 -0
  85. package/types/transport.d.ts +222 -0
  86. package/types/trust/der.d.ts +239 -0
  87. package/types/trust/index.d.ts +194 -0
  88. package/types/trust/name.d.ts +33 -0
  89. package/types/trust/ocsp.d.ts +138 -0
  90. package/types/trust/path.d.ts +139 -0
  91. package/types/trust/roots.d.ts +36 -0
  92. package/types/trust/x509.d.ts +401 -0
  93. package/types/util/bytes.d.ts +183 -0
  94. package/types/util/deadline.d.ts +133 -0
  95. package/types/warmup-fixture.d.ts +11 -0
  96. package/types/warmup.d.ts +45 -0
@@ -0,0 +1,197 @@
1
+ // Proxy tunnels: turn "reach host:port" into a raw byte duplex, whatever sits in between.
2
+ //
3
+ // The whole point of this layer is that nothing above it can tell the difference. Once
4
+ // openTunnel resolves, the caller writes a TLS ClientHello or a plaintext HTTP request into an
5
+ // opaque pipe and the proxy protocol never surfaces again.
6
+ //
7
+ // The socket factory is injected rather than imported from the runtime. That is what makes every
8
+ // protocol path here testable byte-for-byte with an in-memory fake proxy and no network at all —
9
+ // and it is also why this package can run anywhere `{readable, writable}` can be produced.
10
+
11
+ import { ProxyError, ConfigError, codes } from '../errors.js';
12
+ import { openDirect } from './direct.js';
13
+ import { openHttpConnect } from './http-connect.js';
14
+ import { openSocks5 } from './socks5.js';
15
+
16
+ /**
17
+ * @typedef {{ readable: ReadableStream<Uint8Array>, writable: WritableStream<Uint8Array>,
18
+ * opened?: Promise<unknown>, close?: () => Promise<void> }} Duplex
19
+ * @typedef {(addr: {hostname: string, port: number},
20
+ * opts?: {secureTransport?: 'off'|'on'|'starttls', allowHalfOpen?: boolean}) => Duplex} ConnectFn
21
+ * @typedef {{ protocol: 'http'|'https'|'socks5'|'socks5h', hostname: string, port: number,
22
+ * username?: string, password?: string }} ProxyConfig
23
+ */
24
+
25
+ const DEFAULT_PORTS = { http: 8080, https: 443, socks5: 1080, socks5h: 1080 };
26
+
27
+ /**
28
+ * Normalise a proxy spec. Accepts a URL string (`http://user:pass@host:8080`,
29
+ * `socks5://host:1080`) or an object.
30
+ *
31
+ * `socks5h` is accepted as an alias of `socks5` because that is the spelling curl popularised for
32
+ * "resolve names at the proxy" — which is the only mode this package implements, since the
33
+ * runtime gives us no resolver and remote resolution is also what avoids leaking the target to
34
+ * the local DNS path.
35
+ *
36
+ * @param {string | ProxyConfig | null | undefined} spec
37
+ * @returns {ProxyConfig | null}
38
+ */
39
+ export function parseProxy(spec) {
40
+ if (spec == null || spec === '') return null;
41
+ if (typeof spec === 'object') return normalise(spec);
42
+
43
+ let url;
44
+ try {
45
+ url = new URL(spec);
46
+ } catch {
47
+ throw new ConfigError(
48
+ codes.CONFIG_INVALID,
49
+ `proxy "${spec}" is not a URL; expected scheme://[user:pass@]host[:port] with a scheme of ` +
50
+ 'http, https, socks5 or socks5h',
51
+ );
52
+ }
53
+ const protocol = url.protocol.replace(/:$/, '').toLowerCase();
54
+ return normalise({
55
+ protocol,
56
+ hostname: url.hostname.replace(/^\[|\]$/g, ''),
57
+ // `url.port` is empty both when no port was written AND when the written port equals the
58
+ // WHATWG default for the scheme — so `http://proxy:80` and `http://proxy` are indistinguishable
59
+ // through the URL API, and falling back to our default would silently dial 8080 instead of 80.
60
+ port: url.port ? Number(url.port) : explicitPort(spec),
61
+ // Credentials in a URL are percent-encoded; a password containing `@` or `:` only survives
62
+ // the round trip if it is decoded here.
63
+ username: url.username ? decodePart(url.username, 'username', spec) : undefined,
64
+ password: url.password ? decodePart(url.password, 'password', spec) : undefined,
65
+ });
66
+ }
67
+
68
+ /** Read the port straight out of the authority, since the URL API hides scheme-default ports. */
69
+ function explicitPort(spec) {
70
+ const sep = spec.indexOf('://');
71
+ if (sep === -1) return undefined;
72
+ const authority = spec.slice(sep + 3).split(/[/?#]/, 1)[0];
73
+ const hostPart = authority.includes('@') ? authority.slice(authority.lastIndexOf('@') + 1) : authority;
74
+ const m = /^(?:\[[^\]]*\]|[^:]*)(?::(\d+))?$/.exec(hostPart);
75
+ return m && m[1] !== undefined ? Number(m[1]) : undefined;
76
+ }
77
+
78
+ function decodePart(value, what, spec) {
79
+ try {
80
+ return decodeURIComponent(value);
81
+ } catch {
82
+ // decodeURIComponent throws a bare URIError, which would escape the typed-error contract
83
+ // every other malformed spec in this function honours.
84
+ throw new ConfigError(
85
+ codes.CONFIG_INVALID,
86
+ `proxy ${what} in "${spec}" contains a malformed percent-escape`,
87
+ { what },
88
+ );
89
+ }
90
+ }
91
+
92
+ function normalise(cfg) {
93
+ const protocol = String(cfg.protocol ?? '').toLowerCase().replace(/:$/, '');
94
+ if (!(protocol in DEFAULT_PORTS)) {
95
+ throw new ConfigError(
96
+ codes.CONFIG_INVALID,
97
+ `proxy protocol "${protocol}" is not supported; use http, https, socks5 or socks5h`,
98
+ { protocol },
99
+ );
100
+ }
101
+ if (!cfg.hostname) {
102
+ throw new ConfigError(codes.CONFIG_INVALID, 'proxy configuration has no hostname');
103
+ }
104
+ const port = cfg.port ?? DEFAULT_PORTS[protocol];
105
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
106
+ throw new ConfigError(codes.CONFIG_INVALID, `proxy port ${port} is out of range`, { port });
107
+ }
108
+ if ((cfg.password != null && cfg.password !== '') && !cfg.username) {
109
+ throw new ConfigError(codes.CONFIG_INVALID, 'proxy password given without a username');
110
+ }
111
+ return Object.freeze({
112
+ protocol: protocol === 'socks5h' ? 'socks5' : protocol,
113
+ hostname: cfg.hostname,
114
+ port,
115
+ username: cfg.username || undefined,
116
+ password: cfg.password || undefined,
117
+ });
118
+ }
119
+
120
+ /**
121
+ * Open a byte tunnel to `target`, through `proxy` if given.
122
+ *
123
+ * @param {object} args
124
+ * @param {ProxyConfig | string | null} [args.proxy] null/absent means a direct connection
125
+ * @param {{hostname: string, port: number}} args.target
126
+ * @param {ConnectFn} args.connect socket factory, injected
127
+ * @param {AbortSignal} [args.signal]
128
+ * @param {object} [args.limits]
129
+ * @returns {Promise<Duplex & { proxied: boolean }>}
130
+ */
131
+ export async function openTunnel({ proxy, target, connect, signal, limits = {} }) {
132
+ if (typeof connect !== 'function') {
133
+ throw new ConfigError(codes.CONFIG_INVALID, 'openTunnel requires a connect function');
134
+ }
135
+ validateTarget(target);
136
+ const cfg = typeof proxy === 'string' || (proxy && !Object.isFrozen(proxy)) ? parseProxy(proxy) : proxy ?? null;
137
+
138
+ if (!cfg) {
139
+ // Read the duplex off the socket explicitly rather than spreading it. Object spread copies own
140
+ // enumerable properties only, and on the target runtime a socket's `readable` and `writable`
141
+ // are accessors on the prototype — so `{ ...socket }` yields an object with neither, and the
142
+ // first read fails with "Cannot read properties of undefined (reading 'getReader')" from deep
143
+ // inside the TLS layer. The proxy branches below are unaffected because they return plain
144
+ // objects they built themselves; only this one handed a host object to the spread. The offline
145
+ // suite cannot catch it either, since its fake sockets are plain objects with own properties.
146
+ const socket = await openDirect({ target, connect, signal });
147
+ return {
148
+ readable: socket.readable,
149
+ writable: socket.writable,
150
+ opened: socket.opened,
151
+ close: () => socket.close?.(),
152
+ socket,
153
+ proxied: false,
154
+ };
155
+ }
156
+ if (cfg.protocol === 'http' || cfg.protocol === 'https') {
157
+ return { ...(await openHttpConnect({ proxy: cfg, target, connect, signal, limits })), proxied: true };
158
+ }
159
+ if (cfg.protocol === 'socks5') {
160
+ return { ...(await openSocks5({ proxy: cfg, target, connect, signal })), proxied: true };
161
+ }
162
+ /* c8 ignore next */
163
+ throw new ConfigError(codes.CONFIG_INVALID, `unreachable proxy protocol ${cfg.protocol}`);
164
+ }
165
+
166
+ function validateTarget(target) {
167
+ if (!target || typeof target.hostname !== 'string' || !target.hostname) {
168
+ throw new ConfigError(codes.CONFIG_INVALID, 'tunnel target needs a hostname');
169
+ }
170
+ if (!Number.isInteger(target.port) || target.port < 1 || target.port > 65535) {
171
+ throw new ConfigError(
172
+ codes.CONFIG_INVALID,
173
+ `tunnel target port ${target.port} is out of range`,
174
+ { port: target.port },
175
+ );
176
+ }
177
+ // A hostname carrying CR, LF or NUL would let a caller inject a second request line into the
178
+ // CONNECT we are about to write. Reject at the boundary rather than escaping downstream.
179
+ if (/[\0\r\n\s]/.test(target.hostname)) {
180
+ throw new ConfigError(
181
+ codes.CONFIG_INVALID,
182
+ 'tunnel target hostname contains whitespace or a control character',
183
+ { hostname: target.hostname },
184
+ );
185
+ }
186
+ }
187
+
188
+ /** Close a duplex without caring whether it was already gone. */
189
+ export async function closeQuietly(duplex) {
190
+ try {
191
+ await duplex?.close?.();
192
+ } catch {
193
+ /* already closed or never opened */
194
+ }
195
+ }
196
+
197
+ export { openDirect, openHttpConnect, openSocks5 };
@@ -0,0 +1,344 @@
1
+ // SOCKS5 (RFC 1928) with username/password authentication (RFC 1929).
2
+ //
3
+ // Two things here are classic implementation bugs, and both are guarded deliberately:
4
+ //
5
+ // * The username/password sub-negotiation carries VER = 0x01, not 0x05. Reusing 0x05 produces a
6
+ // handshake that fails against every conforming server and is maddening to diagnose.
7
+ // * The reply's BND.ADDR is variable length. Consuming the wrong number of bytes leaves the
8
+ // stream misaligned by a few octets, which does not fail here — it fails later, as a garbled
9
+ // first TLS record. The length is therefore derived from ATYP and an unknown ATYP is fatal.
10
+ //
11
+ // Addresses are sent as ATYP=0x03 (domain name) whenever the target is not already an IP literal,
12
+ // so the proxy resolves. That is not a preference: this runtime exposes no resolver, and remote
13
+ // resolution also keeps the target name off the local DNS path.
14
+
15
+ import { ProxyError, ConfigError, codes, hex8 } from '../errors.js';
16
+ import { ByteReader, ByteWriter, concat, utf8 } from '../util/bytes.js';
17
+
18
+ const VERSION = 0x05;
19
+ const AUTH_VERSION = 0x01;
20
+ const METHOD_NONE = 0x00;
21
+ const METHOD_USERPASS = 0x02;
22
+ const METHOD_UNACCEPTABLE = 0xff;
23
+ const CMD_CONNECT = 0x01;
24
+
25
+ const ATYP = { ipv4: 0x01, domain: 0x03, ipv6: 0x04 };
26
+
27
+ const REPLY = {
28
+ 0x00: 'succeeded',
29
+ 0x01: 'general SOCKS server failure',
30
+ 0x02: 'connection not allowed by ruleset',
31
+ 0x03: 'network unreachable',
32
+ 0x04: 'host unreachable',
33
+ 0x05: 'connection refused',
34
+ 0x06: 'TTL expired',
35
+ 0x07: 'command not supported',
36
+ 0x08: 'address type not supported',
37
+ };
38
+
39
+ const isIpv4 = (h) => /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.test(h) &&
40
+ h.split('.').every((o) => Number(o) <= 255);
41
+
42
+ /**
43
+ * @typedef {object} Socks5Options
44
+ * @property {import('./index.js').ProxyConfig} proxy credentials trigger RFC 1929 user/pass auth
45
+ * @property {{ hostname: string, port: number }} target
46
+ * @property {import('./index.js').ConnectFn} connect injected socket factory
47
+ * @property {AbortSignal} [signal]
48
+ */
49
+
50
+ /**
51
+ * Establish a SOCKS5 tunnel. Resolves with the tunnel duplex; every refusal (no acceptable
52
+ * auth method, rejected credentials, non-zero reply code, unframeable reply) throws a
53
+ * ProxyError naming the exact wire value the proxy sent.
54
+ *
55
+ * @param {Socks5Options} args
56
+ * @returns {Promise<import('./http-connect.js').ProxyTunnel>}
57
+ */
58
+ export async function openSocks5({ proxy, target, connect, signal }) {
59
+ signal?.throwIfAborted?.();
60
+ const where = `${proxy.hostname}:${proxy.port}`;
61
+
62
+ let socket;
63
+ try {
64
+ socket = connect({ hostname: proxy.hostname, port: proxy.port }, {
65
+ secureTransport: 'starttls',
66
+ allowHalfOpen: false,
67
+ });
68
+ } catch (cause) {
69
+ throw new ProxyError(
70
+ codes.PROXY_UNREACHABLE,
71
+ `could not open a socket to SOCKS5 proxy ${where}: ${cause?.message ?? cause}`,
72
+ { proxy: where },
73
+ );
74
+ }
75
+
76
+ let reader;
77
+ let writer;
78
+ try {
79
+ if (socket.opened) await socket.opened;
80
+ reader = new ByteReader(socket.readable);
81
+ writer = new ByteWriter(socket.writable);
82
+
83
+ const method = await greet(reader, writer, proxy, where);
84
+ if (method === METHOD_USERPASS) await authenticate(reader, writer, proxy, where);
85
+
86
+ await request(reader, writer, target, where);
87
+ writer.releaseLock();
88
+ return tunnelFrom(socket, reader);
89
+ } catch (err) {
90
+ try {
91
+ writer?.releaseLock();
92
+ await reader?.cancel(err);
93
+ await socket.close?.();
94
+ } catch {
95
+ /* socket already unusable */
96
+ }
97
+ throw err;
98
+ }
99
+ }
100
+
101
+ async function greet(reader, writer, proxy, where) {
102
+ const methods = proxy.username ? [METHOD_NONE, METHOD_USERPASS] : [METHOD_NONE];
103
+ await writer.write(Uint8Array.from([VERSION, methods.length, ...methods]));
104
+
105
+ const reply = await readExactly(reader, 2, 'SOCKS5 method selection', where);
106
+ if (reply[0] !== VERSION) {
107
+ throw new ProxyError(
108
+ codes.PROXY_PROTOCOL,
109
+ `SOCKS5 proxy ${where} answered the greeting with version ${hex8(reply[0])}, expected 0x05`,
110
+ { proxy: where, version: reply[0] },
111
+ );
112
+ }
113
+ const method = reply[1];
114
+ if (method === METHOD_UNACCEPTABLE) {
115
+ throw new ProxyError(
116
+ codes.SOCKS5_NO_ACCEPTABLE_AUTH,
117
+ `SOCKS5 proxy ${where} accepted none of the methods offered ` +
118
+ `(${methods.map(hex8).join(', ')}); ` +
119
+ (proxy.username
120
+ ? 'it wants an authentication method this package does not implement (only ' +
121
+ 'username/password, RFC 1929, is supported)'
122
+ : 'it requires authentication but no credentials were configured'),
123
+ { proxy: where, offered: methods },
124
+ );
125
+ }
126
+ // Checked against what we actually OFFERED, not against what we could in principle implement.
127
+ // Testing the wider set let a server that answered 0x02 to a no-credentials greeting fall into
128
+ // the authentication step, where the client would put an empty-credential RFC 1929 message on
129
+ // the wire before failing — bytes that should never have been sent.
130
+ if (!methods.includes(method)) {
131
+ throw new ProxyError(
132
+ codes.SOCKS5_NO_ACCEPTABLE_AUTH,
133
+ `SOCKS5 proxy ${where} selected authentication method ${hex8(method)}, which was not ` +
134
+ `offered (offered: ${methods.map(hex8).join(', ')})`,
135
+ { proxy: where, method, offered: methods },
136
+ );
137
+ }
138
+ // A server may legitimately select no-auth even when we offered credentials; that is not an
139
+ // error, it simply means the credentials go unused.
140
+ return method;
141
+ }
142
+
143
+ async function authenticate(reader, writer, proxy, where) {
144
+ const user = utf8(proxy.username);
145
+ const pass = utf8(proxy.password ?? '');
146
+ if (user.byteLength > 255 || pass.byteLength > 255) {
147
+ throw new ConfigError(
148
+ codes.CONFIG_INVALID,
149
+ `SOCKS5 username (${user.byteLength} bytes) and password (${pass.byteLength} bytes) ` +
150
+ 'must each be at most 255 bytes',
151
+ );
152
+ }
153
+ await writer.write(
154
+ concat([
155
+ Uint8Array.from([AUTH_VERSION, user.byteLength]),
156
+ user,
157
+ Uint8Array.from([pass.byteLength]),
158
+ pass,
159
+ ]),
160
+ );
161
+
162
+ const reply = await readExactly(reader, 2, 'SOCKS5 authentication reply', where);
163
+ if (reply[0] !== AUTH_VERSION) {
164
+ throw new ProxyError(
165
+ codes.PROXY_PROTOCOL,
166
+ `SOCKS5 proxy ${where} answered authentication with version ${hex8(reply[0])}, expected 0x01`,
167
+ { proxy: where, version: reply[0] },
168
+ );
169
+ }
170
+ if (reply[1] !== 0x00) {
171
+ throw new ProxyError(
172
+ codes.PROXY_AUTH_FAILED,
173
+ `SOCKS5 proxy ${where} rejected the credentials for user "${proxy.username}" ` +
174
+ `(status ${hex8(reply[1])})`,
175
+ { proxy: where, status: reply[1] },
176
+ );
177
+ }
178
+ }
179
+
180
+ async function request(reader, writer, target, where) {
181
+ await writer.write(
182
+ concat([Uint8Array.from([VERSION, CMD_CONNECT, 0x00]), encodeAddress(target)]),
183
+ );
184
+
185
+ const head = await readExactly(reader, 4, 'SOCKS5 reply', where);
186
+ if (head[0] !== VERSION) {
187
+ throw new ProxyError(
188
+ codes.PROXY_PROTOCOL,
189
+ `SOCKS5 proxy ${where} answered CONNECT with version ${hex8(head[0])}, expected 0x05`,
190
+ { proxy: where, version: head[0] },
191
+ );
192
+ }
193
+ const rep = head[1];
194
+ const atyp = head[3];
195
+
196
+ // The bound address must be drained even on failure, or a caller that retries on the same
197
+ // socket would read it as the next reply. Unknown ATYP means we cannot know how much to drain,
198
+ // which is exactly why it is fatal rather than ignorable.
199
+ const addrLen = await boundAddressLength(reader, atyp, where);
200
+ await readExactly(reader, addrLen + 2, 'SOCKS5 BND.ADDR and BND.PORT', where);
201
+
202
+ if (rep !== 0x00) {
203
+ throw new ProxyError(
204
+ codes.SOCKS5_REPLY,
205
+ `SOCKS5 proxy ${where} refused CONNECT to ${target.hostname}:${target.port}: ` +
206
+ `${REPLY[rep] ?? 'unassigned reply code'} (${hex8(rep)})`,
207
+ { proxy: where, reply: rep, target: `${target.hostname}:${target.port}` },
208
+ );
209
+ }
210
+ }
211
+
212
+ async function boundAddressLength(reader, atyp, where) {
213
+ if (atyp === ATYP.ipv4) return 4;
214
+ if (atyp === ATYP.ipv6) return 16;
215
+ if (atyp === ATYP.domain) {
216
+ const len = await readExactly(reader, 1, 'SOCKS5 BND.ADDR length', where);
217
+ return len[0];
218
+ }
219
+ throw new ProxyError(
220
+ codes.SOCKS5_ADDR_TYPE,
221
+ `SOCKS5 proxy ${where} replied with address type ${hex8(atyp)}; the reply cannot be framed ` +
222
+ 'and the stream position is unknown, so the tunnel is unusable',
223
+ { proxy: where, atyp },
224
+ );
225
+ }
226
+
227
+ /**
228
+ * Encode DST.ADDR + DST.PORT. Prefers the domain form so the proxy resolves.
229
+ * @param {{ hostname: string, port: number }} target
230
+ * @returns {Uint8Array}
231
+ */
232
+ export function encodeAddress(target) {
233
+ const { hostname, port } = target;
234
+ const portBytes = Uint8Array.from([(port >> 8) & 0xff, port & 0xff]);
235
+
236
+ if (isIpv4(hostname)) {
237
+ const octets = hostname.split('.').map(Number);
238
+ return concat([Uint8Array.from([ATYP.ipv4, ...octets]), portBytes]);
239
+ }
240
+ if (hostname.includes(':')) {
241
+ return concat([Uint8Array.from([ATYP.ipv6]), parseIpv6(hostname), portBytes]);
242
+ }
243
+ const name = utf8(hostname);
244
+ if (name.byteLength === 0 || name.byteLength > 255) {
245
+ throw new ConfigError(
246
+ codes.CONFIG_INVALID,
247
+ `SOCKS5 domain name must be 1..255 bytes, got ${name.byteLength}`,
248
+ { hostname },
249
+ );
250
+ }
251
+ return concat([Uint8Array.from([ATYP.domain, name.byteLength]), name, portBytes]);
252
+ }
253
+
254
+ /**
255
+ * Minimal IPv6 text parser: `::` compression and a trailing embedded IPv4 are both real.
256
+ * Throws ConfigError on anything that does not expand to exactly 8 groups.
257
+ * @param {string} text
258
+ * @returns {Uint8Array} the 16 address bytes
259
+ */
260
+ export function parseIpv6(text) {
261
+ let s = text.replace(/^\[|\]$/g, '');
262
+ const zone = s.indexOf('%');
263
+ if (zone !== -1) s = s.slice(0, zone);
264
+
265
+ let tail = [];
266
+ const lastColon = s.lastIndexOf(':');
267
+ const maybeV4 = s.slice(lastColon + 1);
268
+ if (maybeV4.includes('.')) {
269
+ if (!isIpv4(maybeV4)) {
270
+ throw new ConfigError(codes.CONFIG_INVALID, `"${text}" is not a valid IPv6 address`);
271
+ }
272
+ const o = maybeV4.split('.').map(Number);
273
+ tail = [(o[0] << 8) | o[1], (o[2] << 8) | o[3]];
274
+ s = s.slice(0, lastColon + 1) + '0:0';
275
+ }
276
+
277
+ const halves = s.split('::');
278
+ if (halves.length > 2) {
279
+ throw new ConfigError(codes.CONFIG_INVALID, `"${text}" has more than one "::"`);
280
+ }
281
+ const parse = (part) =>
282
+ part === '' ? [] : part.split(':').map((g) => {
283
+ if (!/^[0-9a-fA-F]{1,4}$/.test(g)) {
284
+ throw new ConfigError(codes.CONFIG_INVALID, `"${text}" has an invalid group "${g}"`);
285
+ }
286
+ return parseInt(g, 16);
287
+ });
288
+
289
+ let groups;
290
+ if (halves.length === 2) {
291
+ const head = parse(halves[0]);
292
+ const rest = parse(halves[1]);
293
+ const fill = 8 - head.length - rest.length;
294
+ if (fill < 0) throw new ConfigError(codes.CONFIG_INVALID, `"${text}" has too many groups`);
295
+ groups = [...head, ...new Array(fill).fill(0), ...rest];
296
+ } else {
297
+ groups = parse(halves[0]);
298
+ }
299
+ if (tail.length) groups = [...groups.slice(0, 6), ...tail].slice(0, 8);
300
+ if (groups.length !== 8) {
301
+ throw new ConfigError(
302
+ codes.CONFIG_INVALID,
303
+ `"${text}" expands to ${groups.length} groups, not 8`,
304
+ );
305
+ }
306
+ const out = new Uint8Array(16);
307
+ groups.forEach((g, i) => {
308
+ out[i * 2] = (g >> 8) & 0xff;
309
+ out[i * 2 + 1] = g & 0xff;
310
+ });
311
+ return out;
312
+ }
313
+
314
+ async function readExactly(reader, n, what, where) {
315
+ try {
316
+ return await reader.readExactly(n, what);
317
+ } catch (cause) {
318
+ throw new ProxyError(
319
+ codes.PROXY_PROTOCOL,
320
+ `SOCKS5 proxy ${where} closed or stalled during ${what}: ${cause?.message ?? cause}`,
321
+ { proxy: where, what },
322
+ );
323
+ }
324
+ }
325
+
326
+ /** Bytes that arrived alongside the reply are tunnel payload; the buffered reader carries them. */
327
+ function tunnelFrom(socket, reader) {
328
+ return {
329
+ readable: new ReadableStream({
330
+ async pull(controller) {
331
+ const chunk = await reader.readSome();
332
+ if (chunk === null) controller.close();
333
+ else controller.enqueue(chunk);
334
+ },
335
+ cancel(reason) {
336
+ return reader.cancel(reason);
337
+ },
338
+ }),
339
+ writable: socket.writable,
340
+ opened: socket.opened,
341
+ close: () => socket.close?.(),
342
+ socket,
343
+ };
344
+ }