@apifuse/provider-sdk 2.2.0-beta.16 → 2.2.0-beta.18
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/AUTHORING.md +40 -0
- package/CHANGELOG.md +9 -0
- package/dist/config/loader.d.ts +19 -0
- package/dist/config/loader.js +59 -28
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/native-address.d.ts +43 -0
- package/dist/native-address.js +281 -0
- package/dist/native-egress-policy.d.ts +4 -0
- package/dist/native-egress-policy.js +86 -23
- package/dist/provider.d.ts +1 -1
- package/dist/provider.js +1 -1
- package/dist/runtime/native-network.d.ts +46 -7
- package/dist/runtime/native-network.js +590 -114
- package/dist/runtime/proxy-nodemaven.d.ts +7 -0
- package/dist/runtime/proxy-nodemaven.js +5 -5
- package/dist/server/serve.js +3 -1
- package/dist/types.d.ts +10 -1
- package/package.json +1 -1
- package/src/config/loader.ts +110 -18
- package/src/index.ts +5 -0
- package/src/native-address.ts +340 -0
- package/src/native-egress-policy.ts +105 -32
- package/src/provider.ts +5 -0
- package/src/runtime/native-network.ts +770 -136
- package/src/runtime/proxy-nodemaven.ts +13 -5
- package/src/server/serve.ts +8 -1
- package/src/types.ts +10 -1
|
@@ -2,16 +2,20 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { Socket } from "node:net";
|
|
3
3
|
import { connect as connectTlsSocket } from "node:tls";
|
|
4
4
|
import { SocksClient } from "socks";
|
|
5
|
-
import { ProxyResolutionError } from "../config/loader.js";
|
|
5
|
+
import { assertTunnelingScheme, ProxyResolutionError, SMARTPROXY_APP_KEY_ENV, VENDOR_DEFAULT_PROTOCOL, resolveWithVendor, } from "../config/loader.js";
|
|
6
6
|
import { TransportError } from "../errors.js";
|
|
7
7
|
import { NativeEgressPolicyValidationError, parseNativeEgressPolicy, } from "../native-egress-policy.js";
|
|
8
|
-
import {
|
|
8
|
+
import { canonicalizeEgressHost, classifyEgressHost, embeddedIpv4FromIpv6, ipv4InCidr, ipv6InCidr, parseIpv6, parseStrictIpv4, } from "../native-address.js";
|
|
9
|
+
import { createEnvContext } from "./env.js";
|
|
10
|
+
import { NODEMAVEN_FILTER_ENV, NODEMAVEN_PASSWORD_ENV, NODEMAVEN_USERNAME_ENV, nodemavenSessionWindow, synthesizeNodemavenProxy, } from "./proxy-nodemaven.js";
|
|
11
|
+
import { redactSensitiveError } from "./request-options.js";
|
|
9
12
|
export class NativeNetworkError extends TransportError {
|
|
10
|
-
constructor(message, code) {
|
|
13
|
+
constructor(message, code, cause) {
|
|
11
14
|
const isEgressPolicyFailure = code.startsWith("native_egress_") || code === "native_dynamic_egress_unsupported";
|
|
12
15
|
super(message, {
|
|
13
16
|
code,
|
|
14
17
|
status: 0,
|
|
18
|
+
...(cause ? { cause } : {}),
|
|
15
19
|
...(isEgressPolicyFailure ? { category: "provider_error", retryable: false } : {}),
|
|
16
20
|
});
|
|
17
21
|
this.name = "NativeNetworkError";
|
|
@@ -20,6 +24,10 @@ export class NativeNetworkError extends TransportError {
|
|
|
20
24
|
return super.code;
|
|
21
25
|
}
|
|
22
26
|
}
|
|
27
|
+
function safeDiagnosticEgressHost(value) {
|
|
28
|
+
const canonical = canonicalizeEgressHost(value);
|
|
29
|
+
return canonical.ok ? canonical.host : `<invalid-host:${canonical.reason}>`;
|
|
30
|
+
}
|
|
23
31
|
export class NativeProxyExpiredError extends NativeNetworkError {
|
|
24
32
|
expiresAt;
|
|
25
33
|
constructor(expiresAt) {
|
|
@@ -30,14 +38,15 @@ export class NativeProxyExpiredError extends NativeNetworkError {
|
|
|
30
38
|
}
|
|
31
39
|
/** Raised before transport setup when a native destination is not authorized. */
|
|
32
40
|
export class NativeEgressNotDeclaredError extends NativeNetworkError {
|
|
33
|
-
host;
|
|
34
41
|
port;
|
|
35
42
|
tls;
|
|
43
|
+
host;
|
|
36
44
|
constructor(host, port, tls) {
|
|
37
|
-
|
|
38
|
-
|
|
45
|
+
const diagnosticHost = safeDiagnosticEgressHost(host);
|
|
46
|
+
super(`Native ${tls === "required" ? "TLS" : "TCP"} egress is not declared for ${diagnosticHost}:${port}`, "native_egress_not_declared");
|
|
39
47
|
this.port = port;
|
|
40
48
|
this.tls = tls;
|
|
49
|
+
this.host = diagnosticHost;
|
|
41
50
|
this.name = "NativeEgressNotDeclaredError";
|
|
42
51
|
}
|
|
43
52
|
}
|
|
@@ -46,16 +55,17 @@ export class NativeEgressNotDeclaredError extends NativeNetworkError {
|
|
|
46
55
|
* its expiry remains in the client's bounded recent-expiry evidence window.
|
|
47
56
|
*/
|
|
48
57
|
export class NativeEgressGrantExpiredError extends NativeNetworkError {
|
|
49
|
-
host;
|
|
50
58
|
port;
|
|
51
59
|
tls;
|
|
52
60
|
expiresAt;
|
|
61
|
+
host;
|
|
53
62
|
constructor(host, port, tls, expiresAt) {
|
|
54
|
-
|
|
55
|
-
|
|
63
|
+
const diagnosticHost = safeDiagnosticEgressHost(host);
|
|
64
|
+
super(`Native ${tls === "required" ? "TLS" : "TCP"} egress grant expired for ${diagnosticHost}:${port}`, "native_egress_grant_expired");
|
|
56
65
|
this.port = port;
|
|
57
66
|
this.tls = tls;
|
|
58
67
|
this.expiresAt = expiresAt;
|
|
68
|
+
this.host = diagnosticHost;
|
|
59
69
|
this.name = "NativeEgressGrantExpiredError";
|
|
60
70
|
}
|
|
61
71
|
}
|
|
@@ -66,17 +76,69 @@ export class NativeIdleTimeoutError extends NativeNetworkError {
|
|
|
66
76
|
this.name = "NativeIdleTimeoutError";
|
|
67
77
|
}
|
|
68
78
|
}
|
|
79
|
+
const VENDOR_CREDENTIAL_NAMES = {
|
|
80
|
+
smartproxy: [SMARTPROXY_APP_KEY_ENV],
|
|
81
|
+
nodemaven: [NODEMAVEN_USERNAME_ENV, NODEMAVEN_PASSWORD_ENV],
|
|
82
|
+
};
|
|
83
|
+
/** Build a resolver over the SDK's existing injectable environment context. */
|
|
84
|
+
export function createEnvVendorCredentialResolver(env = createEnvContext()) {
|
|
85
|
+
return (vendor) => {
|
|
86
|
+
const names = VENDOR_CREDENTIAL_NAMES[vendor] ?? [];
|
|
87
|
+
const values = {};
|
|
88
|
+
const missing = [];
|
|
89
|
+
for (const name of names) {
|
|
90
|
+
const value = env.get(name)?.trim();
|
|
91
|
+
if (value)
|
|
92
|
+
values[name] = value;
|
|
93
|
+
else
|
|
94
|
+
missing.push(name);
|
|
95
|
+
}
|
|
96
|
+
if (missing.length > 0 || names.length === 0)
|
|
97
|
+
return { kind: "absent", missing };
|
|
98
|
+
if (vendor === "nodemaven") {
|
|
99
|
+
const filter = env.get(NODEMAVEN_FILTER_ENV)?.trim();
|
|
100
|
+
if (filter)
|
|
101
|
+
values[NODEMAVEN_FILTER_ENV] = filter;
|
|
102
|
+
}
|
|
103
|
+
return { kind: "present", values };
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
function skipped(reason) {
|
|
107
|
+
return { kind: "skipped", reason };
|
|
108
|
+
}
|
|
109
|
+
function lookupCredentials(input) {
|
|
110
|
+
try {
|
|
111
|
+
return input.credentials(input.vendor);
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
return {
|
|
115
|
+
kind: "error",
|
|
116
|
+
cause: error instanceof Error ? error : new Error(String(error)),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
}
|
|
69
120
|
function synthesizeNodemavenGateway(input) {
|
|
70
|
-
if (input.vendor !== "nodemaven"
|
|
121
|
+
if (input.vendor !== "nodemaven")
|
|
71
122
|
return undefined;
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
123
|
+
const lookup = lookupCredentials(input);
|
|
124
|
+
if (lookup.kind === "error") {
|
|
125
|
+
return skipped({ kind: "credential_lookup_failed", cause: lookup.cause });
|
|
126
|
+
}
|
|
127
|
+
if (lookup.kind === "absent") {
|
|
128
|
+
return skipped({ kind: "credentials_absent", missing: lookup.missing });
|
|
129
|
+
}
|
|
75
130
|
const sessionWindow = nodemavenSessionWindow(input.policy, input.now);
|
|
76
131
|
const synthesized = synthesizeNodemavenProxy({
|
|
77
132
|
policy: input.policy,
|
|
133
|
+
credentials: {
|
|
134
|
+
username: lookup.values[NODEMAVEN_USERNAME_ENV] ?? "",
|
|
135
|
+
password: lookup.values[NODEMAVEN_PASSWORD_ENV] ?? "",
|
|
136
|
+
...(lookup.values[NODEMAVEN_FILTER_ENV]
|
|
137
|
+
? { filter: lookup.values[NODEMAVEN_FILTER_ENV] }
|
|
138
|
+
: {}),
|
|
139
|
+
},
|
|
78
140
|
affinityKey: input.affinityKey,
|
|
79
|
-
protocol:
|
|
141
|
+
protocol: input.protocol,
|
|
80
142
|
poolIndex: 0,
|
|
81
143
|
refreshEpoch: sessionWindow.refreshEpoch,
|
|
82
144
|
now: input.now,
|
|
@@ -90,7 +152,48 @@ function synthesizeNodemavenGateway(input) {
|
|
|
90
152
|
expiresAt: synthesized.expiresAt,
|
|
91
153
|
};
|
|
92
154
|
}
|
|
155
|
+
async function synthesizeSmartproxyGateway(input) {
|
|
156
|
+
if (input.vendor !== "smartproxy")
|
|
157
|
+
return undefined;
|
|
158
|
+
const lookup = lookupCredentials(input);
|
|
159
|
+
if (lookup.kind === "error") {
|
|
160
|
+
return skipped({ kind: "credential_lookup_failed", cause: lookup.cause });
|
|
161
|
+
}
|
|
162
|
+
if (lookup.kind === "absent") {
|
|
163
|
+
return skipped({ kind: "credentials_absent", missing: lookup.missing });
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
const resolved = await resolveWithVendor("smartproxy", input.policy, {
|
|
167
|
+
proxyPolicy: input.policy,
|
|
168
|
+
affinityKey: input.affinityKey,
|
|
169
|
+
protocol: input.protocol,
|
|
170
|
+
}, {
|
|
171
|
+
protocol: input.protocol,
|
|
172
|
+
poolIndex: 0,
|
|
173
|
+
refreshEpoch: 0,
|
|
174
|
+
credentials: lookup.values,
|
|
175
|
+
ambientDefaults: false,
|
|
176
|
+
sharedCache: false,
|
|
177
|
+
});
|
|
178
|
+
if (!resolved.url) {
|
|
179
|
+
return skipped({ kind: "allocation_failed", cause: new Error("No endpoint returned") });
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
url: resolved.url,
|
|
183
|
+
vendor: "smartproxy",
|
|
184
|
+
sticky: isStickyPolicy(input.policy),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
const cause = error instanceof Error ? error : new Error(String(error));
|
|
189
|
+
return skipped({
|
|
190
|
+
kind: "allocation_failed",
|
|
191
|
+
cause: redactSensitiveError(cause, Object.values(lookup.values)),
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
93
195
|
const DEFAULT_GATEWAY_SYNTHESIZERS = [
|
|
196
|
+
synthesizeSmartproxyGateway,
|
|
94
197
|
synthesizeNodemavenGateway,
|
|
95
198
|
];
|
|
96
199
|
/** Domain-separated, process-independent affinity derived from credential identity. */
|
|
@@ -116,29 +219,104 @@ function resolveNativeVendorChain(policy) {
|
|
|
116
219
|
}
|
|
117
220
|
return chain;
|
|
118
221
|
}
|
|
119
|
-
|
|
120
|
-
|
|
222
|
+
function isProxyProtocol(value) {
|
|
223
|
+
return value === "http" || value === "socks5";
|
|
224
|
+
}
|
|
225
|
+
function isSkippedSynthesis(result) {
|
|
226
|
+
return "kind" in result && result.kind === "skipped";
|
|
227
|
+
}
|
|
228
|
+
function sanitizeVendorResolutionCause(error, vendor, credentials) {
|
|
229
|
+
const cause = error instanceof Error ? error : new Error(String(error));
|
|
230
|
+
try {
|
|
231
|
+
const lookup = credentials(vendor);
|
|
232
|
+
return lookup.kind === "present"
|
|
233
|
+
? redactSensitiveError(cause, Object.values(lookup.values))
|
|
234
|
+
: cause;
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
return cause;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
function sanitizeVendorSkipReason(reason, vendor, credentials) {
|
|
241
|
+
return reason.kind === "allocation_failed" || reason.kind === "credential_lookup_failed"
|
|
242
|
+
? {
|
|
243
|
+
...reason,
|
|
244
|
+
cause: sanitizeVendorResolutionCause(reason.cause, vendor, credentials),
|
|
245
|
+
}
|
|
246
|
+
: reason;
|
|
247
|
+
}
|
|
248
|
+
function defaultVendorProtocol(vendor) {
|
|
249
|
+
return vendor === "smartproxy" || vendor === "nodemaven"
|
|
250
|
+
? VENDOR_DEFAULT_PROTOCOL[vendor]
|
|
251
|
+
: "http";
|
|
252
|
+
}
|
|
253
|
+
async function resolveNativeGatewayProxyDetailed(input) {
|
|
121
254
|
if (input.policy.mode === "disabled")
|
|
122
|
-
return
|
|
255
|
+
return { skips: [] };
|
|
123
256
|
const synthesizers = input.gatewaySynthesizers ?? DEFAULT_GATEWAY_SYNTHESIZERS;
|
|
257
|
+
const credentials = input.credentials ?? createEnvVendorCredentialResolver();
|
|
124
258
|
const now = input.now ?? Date.now();
|
|
259
|
+
const skips = [];
|
|
125
260
|
for (const vendor of resolveNativeVendorChain(input.policy)) {
|
|
261
|
+
const protocol = input.protocol ?? defaultVendorProtocol(vendor);
|
|
262
|
+
if (!isProxyProtocol(protocol)) {
|
|
263
|
+
skips.push({ vendor, reason: { kind: "protocol_unsupported", protocol: String(protocol) } });
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
let vendorSkip;
|
|
126
267
|
for (const synthesize of synthesizers) {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
268
|
+
try {
|
|
269
|
+
const resolved = await synthesize({
|
|
270
|
+
vendor,
|
|
271
|
+
policy: input.policy,
|
|
272
|
+
affinityKey: input.affinityKey,
|
|
273
|
+
now,
|
|
274
|
+
protocol,
|
|
275
|
+
credentials,
|
|
276
|
+
});
|
|
277
|
+
if (!resolved)
|
|
278
|
+
continue;
|
|
279
|
+
if (isSkippedSynthesis(resolved)) {
|
|
280
|
+
vendorSkip = sanitizeVendorSkipReason(resolved.reason, vendor, credentials);
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (resolved.vendor === vendor) {
|
|
284
|
+
assertTunnelingScheme(resolved.url);
|
|
285
|
+
return { proxy: resolved, skips };
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
vendorSkip = {
|
|
290
|
+
kind: "allocation_failed",
|
|
291
|
+
cause: sanitizeVendorResolutionCause(error, vendor, credentials),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
135
294
|
}
|
|
295
|
+
skips.push({ vendor, reason: vendorSkip ?? { kind: "adapter_unavailable" } });
|
|
136
296
|
}
|
|
137
|
-
return
|
|
297
|
+
return { skips };
|
|
298
|
+
}
|
|
299
|
+
/** Resolve the first configured native gateway, including allocation vendors. */
|
|
300
|
+
export async function resolveNativeGatewayProxy(input) {
|
|
301
|
+
return (await resolveNativeGatewayProxyDetailed(input)).proxy;
|
|
138
302
|
}
|
|
139
|
-
function
|
|
303
|
+
function formatVendorSkip(skip) {
|
|
304
|
+
switch (skip.reason.kind) {
|
|
305
|
+
case "credentials_absent":
|
|
306
|
+
return `${skip.vendor}: credentials absent (missing ${skip.reason.missing.join(", ") || "unspecified variables"})`;
|
|
307
|
+
case "protocol_unsupported":
|
|
308
|
+
return `${skip.vendor}: protocol ${skip.reason.protocol} is unsupported`;
|
|
309
|
+
case "allocation_failed":
|
|
310
|
+
return `${skip.vendor}: allocation failed (${skip.reason.cause.message})`;
|
|
311
|
+
case "credential_lookup_failed":
|
|
312
|
+
return `${skip.vendor}: credential lookup failed (${skip.reason.cause.message})`;
|
|
313
|
+
case "adapter_unavailable":
|
|
314
|
+
return `${skip.vendor}: no native adapter is registered`;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
function proxyRequiredError(policy, skips) {
|
|
140
318
|
const chain = resolveNativeVendorChain(policy).filter((vendor) => vendor === "smartproxy" || vendor === "nodemaven");
|
|
141
|
-
return new ProxyResolutionError("PROXY_REQUIRED", `Native proxy egress is required but
|
|
319
|
+
return new ProxyResolutionError("PROXY_REQUIRED", `Native proxy egress is required but the vendor chain was exhausted: ${skips.map(formatVendorSkip).join("; ") || "no vendors declared"}.`, { vendorChain: chain });
|
|
142
320
|
}
|
|
143
321
|
function deadlineFrom(timeoutMs) {
|
|
144
322
|
if (timeoutMs === undefined)
|
|
@@ -154,8 +332,8 @@ function abortError() {
|
|
|
154
332
|
function timeoutError() {
|
|
155
333
|
return new NativeNetworkError("Native connection timed out", "native_connection_timeout");
|
|
156
334
|
}
|
|
157
|
-
function failedError() {
|
|
158
|
-
return new NativeNetworkError("Native connection failed", "native_connection_failed");
|
|
335
|
+
function failedError(cause) {
|
|
336
|
+
return new NativeNetworkError("Native connection failed", "native_connection_failed", cause);
|
|
159
337
|
}
|
|
160
338
|
function assertCanStart(signal, deadline) {
|
|
161
339
|
if (signal?.aborted)
|
|
@@ -186,7 +364,7 @@ async function waitForSocketEvent(socket, event, signal, deadline) {
|
|
|
186
364
|
resolve();
|
|
187
365
|
};
|
|
188
366
|
const onReady = () => finish();
|
|
189
|
-
const onError = () => finish(failedError());
|
|
367
|
+
const onError = (cause) => finish(failedError(cause));
|
|
190
368
|
const onClose = () => finish(failedError());
|
|
191
369
|
const onAbort = () => finish(abortError());
|
|
192
370
|
socket.once(event, onReady);
|
|
@@ -230,7 +408,104 @@ function parseSocks5Proxy(proxyUrl) {
|
|
|
230
408
|
throw new NativeNetworkError("Native proxy URL is invalid", "native_proxy_invalid");
|
|
231
409
|
}
|
|
232
410
|
}
|
|
233
|
-
|
|
411
|
+
function parseHttpConnectProxy(proxyUrl) {
|
|
412
|
+
let parsed;
|
|
413
|
+
try {
|
|
414
|
+
parsed = new URL(proxyUrl);
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
throw new NativeNetworkError("Native proxy URL is invalid", "native_proxy_invalid");
|
|
418
|
+
}
|
|
419
|
+
const port = Number(parsed.port || "80");
|
|
420
|
+
if (parsed.protocol !== "http:" ||
|
|
421
|
+
!parsed.hostname ||
|
|
422
|
+
!Number.isInteger(port) ||
|
|
423
|
+
port <= 0 ||
|
|
424
|
+
parsed.pathname !== "/" ||
|
|
425
|
+
parsed.search ||
|
|
426
|
+
parsed.hash) {
|
|
427
|
+
throw new NativeNetworkError("Native proxy URL is invalid", "native_proxy_invalid");
|
|
428
|
+
}
|
|
429
|
+
try {
|
|
430
|
+
return {
|
|
431
|
+
host: parsed.hostname,
|
|
432
|
+
port,
|
|
433
|
+
...(parsed.username ? { userId: decodeURIComponent(parsed.username) } : {}),
|
|
434
|
+
...(parsed.password ? { password: decodeURIComponent(parsed.password) } : {}),
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
catch {
|
|
438
|
+
throw new NativeNetworkError("Native proxy URL is invalid", "native_proxy_invalid");
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
const SOCKS5_REPLY_CODES = {
|
|
442
|
+
Failure: 0x01,
|
|
443
|
+
NotAllowed: 0x02,
|
|
444
|
+
NetworkUnreachable: 0x03,
|
|
445
|
+
HostUnreachable: 0x04,
|
|
446
|
+
ConnectionRefused: 0x05,
|
|
447
|
+
TTLExpired: 0x06,
|
|
448
|
+
CommandNotSupported: 0x07,
|
|
449
|
+
AddressNotSupported: 0x08,
|
|
450
|
+
};
|
|
451
|
+
function socks5ReplyCode(error) {
|
|
452
|
+
const match = /Socks5 proxy rejected connection - ([A-Za-z]+)/i.exec(error.message);
|
|
453
|
+
if (!match?.[1])
|
|
454
|
+
return undefined;
|
|
455
|
+
const reply = Object.entries(SOCKS5_REPLY_CODES).find(([name]) => name.toLowerCase() === match[1]?.toLowerCase());
|
|
456
|
+
return reply?.[1];
|
|
457
|
+
}
|
|
458
|
+
function sanitizeProxyFailureCause(error, proxyUrl, credentials) {
|
|
459
|
+
// socks' SocksClientError retains the live socket in options. It is neither
|
|
460
|
+
// useful diagnostic payload nor serializable, so preserve the original error
|
|
461
|
+
// while replacing only that options object with a socket-free snapshot.
|
|
462
|
+
const options = Reflect.get(error, "options");
|
|
463
|
+
if (options && typeof options === "object" && !Array.isArray(options)) {
|
|
464
|
+
const snapshot = { ...options };
|
|
465
|
+
delete snapshot.existing_socket;
|
|
466
|
+
try {
|
|
467
|
+
Reflect.set(error, "options", snapshot);
|
|
468
|
+
}
|
|
469
|
+
catch {
|
|
470
|
+
// The recursive redactor below clones readonly diagnostics safely.
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
const replyCode = socks5ReplyCode(error);
|
|
474
|
+
if (replyCode !== undefined) {
|
|
475
|
+
try {
|
|
476
|
+
Object.defineProperty(error, "socks5ReplyCode", {
|
|
477
|
+
value: replyCode,
|
|
478
|
+
configurable: true,
|
|
479
|
+
enumerable: true,
|
|
480
|
+
writable: false,
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
catch {
|
|
484
|
+
// The reply label remains in message if an exotic error is immutable.
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
let redactedProxyUrl = proxyUrl;
|
|
488
|
+
try {
|
|
489
|
+
const parsed = new URL(proxyUrl);
|
|
490
|
+
parsed.username = "[REDACTED]";
|
|
491
|
+
parsed.password = "[REDACTED]";
|
|
492
|
+
redactedProxyUrl = parsed.toString();
|
|
493
|
+
}
|
|
494
|
+
catch {
|
|
495
|
+
// parseSocks5Proxy already validated this URL; keep a defensive fallback.
|
|
496
|
+
}
|
|
497
|
+
return redactSensitiveError(error, [
|
|
498
|
+
credentials.userId,
|
|
499
|
+
credentials.password,
|
|
500
|
+
credentials.userId !== undefined || credentials.password !== undefined
|
|
501
|
+
? `${credentials.userId ?? ""}:${credentials.password ?? ""}`
|
|
502
|
+
: undefined,
|
|
503
|
+
credentials.userId !== undefined || credentials.password !== undefined
|
|
504
|
+
? Buffer.from(`${credentials.userId ?? ""}:${credentials.password ?? ""}`).toString("base64")
|
|
505
|
+
: undefined,
|
|
506
|
+
].filter((value) => typeof value === "string" && value.length > 0), proxyUrl, redactedProxyUrl);
|
|
507
|
+
}
|
|
508
|
+
async function waitForSocksHandshake(proxySocket, promise, signal, deadline, sanitizeFailure) {
|
|
234
509
|
assertCanStart(signal, deadline);
|
|
235
510
|
return await new Promise((resolve, reject) => {
|
|
236
511
|
let settled = false;
|
|
@@ -261,7 +536,7 @@ async function waitForSocksHandshake(proxySocket, promise, signal, deadline) {
|
|
|
261
536
|
timer = setTimeout(() => finish(timeoutError()), remaining);
|
|
262
537
|
void promise.then((result) => finish(undefined, result.socket), (error) => finish(error instanceof Error && /\b(?:timed out|timeout)\b/i.test(error.message)
|
|
263
538
|
? timeoutError()
|
|
264
|
-
: failedError()));
|
|
539
|
+
: failedError(sanitizeFailure(error instanceof Error ? error : new Error(String(error))))));
|
|
265
540
|
});
|
|
266
541
|
}
|
|
267
542
|
async function connectSocksTunnel(proxy, input, deadline, beforeDestinationConnect) {
|
|
@@ -286,7 +561,117 @@ async function connectSocksTunnel(proxy, input, deadline, beforeDestinationConne
|
|
|
286
561
|
existing_socket: proxySocket,
|
|
287
562
|
...(remaining === undefined ? {} : { timeout: Math.max(1, remaining) }),
|
|
288
563
|
});
|
|
289
|
-
return await waitForSocksHandshake(proxySocket, handshake, input.signal, deadline);
|
|
564
|
+
return await waitForSocksHandshake(proxySocket, handshake, input.signal, deadline, (error) => sanitizeProxyFailureCause(error, proxy.url, parsed));
|
|
565
|
+
}
|
|
566
|
+
function connectAuthority(host, port) {
|
|
567
|
+
return `${host.includes(":") && !host.startsWith("[") ? `[${host}]` : host}:${port}`;
|
|
568
|
+
}
|
|
569
|
+
function connectStatusError(statusLine, statusCode) {
|
|
570
|
+
const error = new Error(`HTTP CONNECT proxy rejected tunnel: ${statusLine || "invalid response"}`);
|
|
571
|
+
Object.defineProperties(error, {
|
|
572
|
+
connectStatusLine: {
|
|
573
|
+
value: statusLine || "invalid response",
|
|
574
|
+
configurable: true,
|
|
575
|
+
enumerable: true,
|
|
576
|
+
},
|
|
577
|
+
...(statusCode === undefined
|
|
578
|
+
? {}
|
|
579
|
+
: {
|
|
580
|
+
connectStatusCode: {
|
|
581
|
+
value: statusCode,
|
|
582
|
+
configurable: true,
|
|
583
|
+
enumerable: true,
|
|
584
|
+
},
|
|
585
|
+
}),
|
|
586
|
+
});
|
|
587
|
+
return error;
|
|
588
|
+
}
|
|
589
|
+
async function waitForConnectResponse(proxySocket, signal, deadline, sanitizeFailure) {
|
|
590
|
+
assertCanStart(signal, deadline);
|
|
591
|
+
return await new Promise((resolve, reject) => {
|
|
592
|
+
let settled = false;
|
|
593
|
+
let buffered = Buffer.alloc(0);
|
|
594
|
+
let timer;
|
|
595
|
+
const cleanup = () => {
|
|
596
|
+
if (timer)
|
|
597
|
+
clearTimeout(timer);
|
|
598
|
+
proxySocket.off("data", onData);
|
|
599
|
+
proxySocket.off("error", onError);
|
|
600
|
+
proxySocket.off("close", onClose);
|
|
601
|
+
signal?.removeEventListener("abort", onAbort);
|
|
602
|
+
};
|
|
603
|
+
const finish = (error) => {
|
|
604
|
+
if (settled)
|
|
605
|
+
return;
|
|
606
|
+
settled = true;
|
|
607
|
+
cleanup();
|
|
608
|
+
if (error) {
|
|
609
|
+
proxySocket.on("error", () => undefined);
|
|
610
|
+
proxySocket.destroy();
|
|
611
|
+
reject(error);
|
|
612
|
+
}
|
|
613
|
+
else
|
|
614
|
+
resolve(proxySocket);
|
|
615
|
+
};
|
|
616
|
+
const onError = (cause) => finish(failedError(sanitizeFailure(cause)));
|
|
617
|
+
const onClose = () => finish(failedError(sanitizeFailure(new Error("HTTP CONNECT proxy closed before response"))));
|
|
618
|
+
const onAbort = () => finish(abortError());
|
|
619
|
+
const onData = (chunk) => {
|
|
620
|
+
buffered = Buffer.concat([buffered, chunk]);
|
|
621
|
+
if (buffered.length > 64 * 1024) {
|
|
622
|
+
finish(failedError(sanitizeFailure(connectStatusError("response headers too large"))));
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
const headerEnd = buffered.indexOf("\r\n\r\n");
|
|
626
|
+
if (headerEnd < 0)
|
|
627
|
+
return;
|
|
628
|
+
const header = buffered.subarray(0, headerEnd).toString("latin1");
|
|
629
|
+
const statusLine = header.split("\r\n", 1)[0] ?? "";
|
|
630
|
+
const match = /^HTTP\/1\.[01] ([0-9]{3})(?: |$)/.exec(statusLine);
|
|
631
|
+
const statusCode = match?.[1] ? Number(match[1]) : undefined;
|
|
632
|
+
if (statusCode === undefined || statusCode < 200 || statusCode >= 300) {
|
|
633
|
+
finish(failedError(sanitizeFailure(connectStatusError(statusLine, statusCode))));
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
const remaining = buffered.subarray(headerEnd + 4);
|
|
637
|
+
cleanup();
|
|
638
|
+
if (remaining.length > 0)
|
|
639
|
+
proxySocket.unshift(remaining);
|
|
640
|
+
finish();
|
|
641
|
+
};
|
|
642
|
+
proxySocket.on("data", onData);
|
|
643
|
+
proxySocket.once("error", onError);
|
|
644
|
+
proxySocket.once("close", onClose);
|
|
645
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
646
|
+
const remaining = remainingMs(deadline);
|
|
647
|
+
if (remaining !== undefined)
|
|
648
|
+
timer = setTimeout(() => finish(timeoutError()), remaining);
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
async function connectHttpTunnel(proxy, input, deadline, beforeDestinationConnect) {
|
|
652
|
+
const parsed = parseHttpConnectProxy(proxy.url);
|
|
653
|
+
const proxySocket = await connectPlainSocket(parsed.host, parsed.port, input.signal, deadline);
|
|
654
|
+
const sanitizeFailure = (error) => sanitizeProxyFailureCause(error, proxy.url, parsed);
|
|
655
|
+
try {
|
|
656
|
+
beforeDestinationConnect();
|
|
657
|
+
}
|
|
658
|
+
catch (error) {
|
|
659
|
+
proxySocket.destroy();
|
|
660
|
+
throw error;
|
|
661
|
+
}
|
|
662
|
+
const authority = connectAuthority(input.host, input.port);
|
|
663
|
+
const authorization = parsed.userId !== undefined || parsed.password !== undefined
|
|
664
|
+
? `Proxy-Authorization: Basic ${Buffer.from(`${parsed.userId ?? ""}:${parsed.password ?? ""}`).toString("base64")}\r\n`
|
|
665
|
+
: "";
|
|
666
|
+
const response = waitForConnectResponse(proxySocket, input.signal, deadline, sanitizeFailure);
|
|
667
|
+
proxySocket.write(`CONNECT ${authority} HTTP/1.1\r\nHost: ${authority}\r\n${authorization}Proxy-Connection: Keep-Alive\r\n\r\n`);
|
|
668
|
+
return await response;
|
|
669
|
+
}
|
|
670
|
+
async function connectProxyTunnel(proxy, input, deadline, beforeDestinationConnect) {
|
|
671
|
+
assertTunnelingScheme(proxy.url);
|
|
672
|
+
return new URL(proxy.url).protocol === "http:"
|
|
673
|
+
? await connectHttpTunnel(proxy, input, deadline, beforeDestinationConnect)
|
|
674
|
+
: await connectSocksTunnel(proxy, input, deadline, beforeDestinationConnect);
|
|
290
675
|
}
|
|
291
676
|
async function upgradeTls(socket, input, deadline) {
|
|
292
677
|
assertCanStart(input.signal, deadline);
|
|
@@ -399,7 +784,7 @@ export function createNativeNetworkConnection(socket, proxy, options, idleTimeou
|
|
|
399
784
|
if (closeReason)
|
|
400
785
|
throw closeReason;
|
|
401
786
|
if (terminalError)
|
|
402
|
-
throw failedError();
|
|
787
|
+
throw failedError(terminalError);
|
|
403
788
|
const chunk = socket.read();
|
|
404
789
|
if (chunk) {
|
|
405
790
|
resetIdleTimer();
|
|
@@ -456,7 +841,7 @@ export function createNativeNetworkConnection(socket, proxy, options, idleTimeou
|
|
|
456
841
|
await new Promise((resolve, reject) => {
|
|
457
842
|
socket.write(data, (error) => {
|
|
458
843
|
if (error)
|
|
459
|
-
reject(failedError());
|
|
844
|
+
reject(failedError(error));
|
|
460
845
|
else
|
|
461
846
|
resolve();
|
|
462
847
|
});
|
|
@@ -474,7 +859,7 @@ export function createNativeNetworkConnection(socket, proxy, options, idleTimeou
|
|
|
474
859
|
},
|
|
475
860
|
};
|
|
476
861
|
}
|
|
477
|
-
async function resolveConnectionProxy(options, input) {
|
|
862
|
+
async function resolveConnectionProxy(options, input, deadline) {
|
|
478
863
|
const policy = options.proxyPolicy;
|
|
479
864
|
if (!policy || policy.mode === "disabled")
|
|
480
865
|
return undefined;
|
|
@@ -483,31 +868,85 @@ async function resolveConnectionProxy(options, input) {
|
|
|
483
868
|
(isStickyPolicy(policy) && options.credentialIdentity !== undefined
|
|
484
869
|
? deriveNativeCredentialAffinityKey(options.credentialIdentity)
|
|
485
870
|
: undefined);
|
|
486
|
-
const
|
|
871
|
+
const resolution = await waitForProxyResolution(resolveNativeGatewayProxyDetailed({
|
|
487
872
|
policy,
|
|
488
873
|
affinityKey,
|
|
874
|
+
protocol: options.proxyProtocol,
|
|
875
|
+
credentials: options.credentials,
|
|
489
876
|
gatewaySynthesizers: options.gatewaySynthesizers,
|
|
877
|
+
}), input.signal, deadline);
|
|
878
|
+
if (!resolution.proxy && policy.mode === "required") {
|
|
879
|
+
throw proxyRequiredError(policy, resolution.skips);
|
|
880
|
+
}
|
|
881
|
+
return resolution.proxy;
|
|
882
|
+
}
|
|
883
|
+
async function waitForProxyResolution(promise, signal, deadline) {
|
|
884
|
+
assertCanStart(signal, deadline);
|
|
885
|
+
return await new Promise((resolve, reject) => {
|
|
886
|
+
let settled = false;
|
|
887
|
+
let timer;
|
|
888
|
+
const cleanup = () => {
|
|
889
|
+
if (timer)
|
|
890
|
+
clearTimeout(timer);
|
|
891
|
+
signal?.removeEventListener("abort", onAbort);
|
|
892
|
+
};
|
|
893
|
+
const finish = (value, error) => {
|
|
894
|
+
if (settled)
|
|
895
|
+
return;
|
|
896
|
+
settled = true;
|
|
897
|
+
cleanup();
|
|
898
|
+
if (error !== undefined)
|
|
899
|
+
reject(error);
|
|
900
|
+
else
|
|
901
|
+
resolve(value);
|
|
902
|
+
};
|
|
903
|
+
const onAbort = () => finish(undefined, abortError());
|
|
904
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
905
|
+
const remaining = remainingMs(deadline);
|
|
906
|
+
if (remaining !== undefined) {
|
|
907
|
+
timer = setTimeout(() => finish(undefined, timeoutError()), remaining);
|
|
908
|
+
}
|
|
909
|
+
void promise.then((value) => finish(value), (error) => finish(undefined, error));
|
|
490
910
|
});
|
|
491
|
-
if (!resolved && policy.mode === "required")
|
|
492
|
-
throw proxyRequiredError(policy);
|
|
493
|
-
return resolved;
|
|
494
911
|
}
|
|
495
912
|
export const NATIVE_EGRESS_EXPIRED_EVIDENCE_LIMIT = 256;
|
|
496
|
-
function normalizeEgressHost(host) {
|
|
497
|
-
return host.trim().toLowerCase().replace(/\.$/, "");
|
|
498
|
-
}
|
|
499
913
|
function invalidPolicy(message) {
|
|
500
914
|
return new NativeNetworkError(message, "native_egress_policy_invalid");
|
|
501
915
|
}
|
|
502
916
|
function matchesDnsSuffix(host, suffix) {
|
|
503
917
|
return host === suffix || host.endsWith(`.${suffix}`);
|
|
504
918
|
}
|
|
505
|
-
|
|
506
|
-
|
|
919
|
+
/** Internal validator-independent source selector matcher. */
|
|
920
|
+
export function matchesSourceHost(rule, host) {
|
|
921
|
+
const kind = classifyEgressHost(host);
|
|
922
|
+
if (kind === "numeric-ambiguous")
|
|
923
|
+
return false;
|
|
924
|
+
const hasSelector = rule.sourceHost !== undefined ||
|
|
925
|
+
rule.sourceHostSuffixes.length > 0 ||
|
|
926
|
+
rule.sourceIpv4Cidrs.length > 0 ||
|
|
927
|
+
rule.sourceIpv6Cidrs.length > 0;
|
|
507
928
|
if (!hasSelector)
|
|
508
929
|
return false;
|
|
509
|
-
|
|
510
|
-
|
|
930
|
+
if (host === rule.sourceHost)
|
|
931
|
+
return true;
|
|
932
|
+
return matchesHostFamilySelectors(host, rule.sourceHostSuffixes, rule.sourceIpv4Cidrs, rule.sourceIpv6Cidrs);
|
|
933
|
+
}
|
|
934
|
+
function matchesHostFamilySelectors(host, hostSuffixes, ipv4Cidrs, ipv6Cidrs) {
|
|
935
|
+
const kind = classifyEgressHost(host);
|
|
936
|
+
if (kind === "ipv4") {
|
|
937
|
+
const address = parseStrictIpv4(host);
|
|
938
|
+
return address !== undefined && ipv4Cidrs.some((cidr) => ipv4InCidr(address, cidr));
|
|
939
|
+
}
|
|
940
|
+
if (kind === "ipv4-mapped-ipv6") {
|
|
941
|
+
const address = parseIpv6(host);
|
|
942
|
+
const embedded = address && embeddedIpv4FromIpv6(address);
|
|
943
|
+
return embedded !== undefined && ipv4Cidrs.some((cidr) => ipv4InCidr(embedded, cidr));
|
|
944
|
+
}
|
|
945
|
+
if (kind === "ipv6") {
|
|
946
|
+
const address = parseIpv6(host);
|
|
947
|
+
return address !== undefined && ipv6Cidrs.some((cidr) => ipv6InCidr(address, cidr));
|
|
948
|
+
}
|
|
949
|
+
return kind === "dns" && hostSuffixes.some((suffix) => matchesDnsSuffix(host, suffix));
|
|
511
950
|
}
|
|
512
951
|
function matchesPortSelectors(port, ports, ranges) {
|
|
513
952
|
if (ports.length === 0 && ranges.length === 0)
|
|
@@ -523,37 +962,29 @@ function grantTlsFitsRule(grant, rule) {
|
|
|
523
962
|
(grant === "disabled" && rule === "disabled"));
|
|
524
963
|
}
|
|
525
964
|
function matchesDynamicRuleSelectors(rule, input) {
|
|
526
|
-
|
|
527
|
-
const targetHost = normalizeEgressHost(input.host);
|
|
528
|
-
return (matchesSourceHost(rule, sourceHost) &&
|
|
965
|
+
return (matchesSourceHost(rule, input.sourceHost) &&
|
|
529
966
|
matchesPortSelectors(input.sourcePort, rule.sourcePorts, rule.sourcePortRanges) &&
|
|
530
|
-
rule
|
|
967
|
+
matchesDynamicTargetHost(rule, input.host) &&
|
|
531
968
|
matchesPortSelectors(input.port, rule.targetPorts, rule.targetPortRanges) &&
|
|
532
969
|
grantTlsFitsRule(input.tls, rule.tls));
|
|
533
970
|
}
|
|
971
|
+
function matchesDynamicTargetHost(rule, targetHost) {
|
|
972
|
+
return matchesHostFamilySelectors(targetHost, rule.targetHostSuffixes, rule.targetIpv4Cidrs, rule.targetIpv6Cidrs);
|
|
973
|
+
}
|
|
534
974
|
function invalidGrant(message) {
|
|
535
975
|
return new NativeNetworkError(message, "native_egress_grant_invalid");
|
|
536
976
|
}
|
|
537
|
-
function
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
return
|
|
977
|
+
function canonicalGrantHost(value) {
|
|
978
|
+
if (typeof value === "string" && value.includes("*"))
|
|
979
|
+
throw invalidGrant("Native TCP egress grant hosts must be exact non-empty hostnames");
|
|
980
|
+
const canonical = canonicalizeEgressHost(value);
|
|
981
|
+
if (!canonical.ok)
|
|
982
|
+
throw invalidGrant("Native TCP egress grant hosts must be exact non-empty hostnames");
|
|
983
|
+
return canonical.host;
|
|
544
984
|
}
|
|
545
985
|
function assertValidGrantInput(input) {
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
hasControlCharacter(input.sourceHost) ||
|
|
549
|
-
hasControlCharacter(input.host) ||
|
|
550
|
-
/\s/.test(input.sourceHost) ||
|
|
551
|
-
/\s/.test(input.host) ||
|
|
552
|
-
input.sourceHost.includes("://") ||
|
|
553
|
-
input.host.includes("://") ||
|
|
554
|
-
input.sourceHost.includes("*") ||
|
|
555
|
-
input.host.includes("*"))
|
|
556
|
-
throw invalidGrant("Native TCP egress grant hosts must be exact non-empty hostnames");
|
|
986
|
+
canonicalGrantHost(input.sourceHost);
|
|
987
|
+
canonicalGrantHost(input.host);
|
|
557
988
|
if (!Number.isSafeInteger(input.sourcePort) ||
|
|
558
989
|
input.sourcePort < 1 ||
|
|
559
990
|
input.sourcePort > 65_535 ||
|
|
@@ -566,42 +997,46 @@ function assertValidGrantInput(input) {
|
|
|
566
997
|
if (input.ttlMs !== undefined && (!Number.isSafeInteger(input.ttlMs) || input.ttlMs <= 0))
|
|
567
998
|
throw invalidGrant("Native TCP egress grant ttlMs must be a positive integer");
|
|
568
999
|
}
|
|
569
|
-
|
|
570
|
-
|
|
1000
|
+
function invalidNativeConnectInput(field, reason) {
|
|
1001
|
+
return new NativeNetworkError(`Native connection input rejected: field=${field}; reason=${reason}`, "native_egress_input_invalid");
|
|
1002
|
+
}
|
|
1003
|
+
function inspectNativeConnectInputField(input, field) {
|
|
571
1004
|
try {
|
|
572
|
-
|
|
573
|
-
const port = input.port;
|
|
574
|
-
const serverName = input.serverName;
|
|
575
|
-
const rejectUnauthorized = input.rejectUnauthorized;
|
|
576
|
-
const idleTimeoutMs = input.idleTimeoutMs;
|
|
577
|
-
const timeoutMs = input.timeoutMs;
|
|
578
|
-
const signal = input.signal;
|
|
579
|
-
const affinityKey = input.affinityKey;
|
|
580
|
-
const snapshot = {
|
|
581
|
-
host,
|
|
582
|
-
port,
|
|
583
|
-
...(serverName === undefined ? {} : { serverName }),
|
|
584
|
-
...(rejectUnauthorized === undefined ? {} : { rejectUnauthorized }),
|
|
585
|
-
...(idleTimeoutMs === undefined ? {} : { idleTimeoutMs }),
|
|
586
|
-
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
|
587
|
-
...(signal === undefined ? {} : { signal }),
|
|
588
|
-
...(affinityKey === undefined ? {} : { affinityKey }),
|
|
589
|
-
};
|
|
590
|
-
if (typeof snapshot.host !== "string" ||
|
|
591
|
-
!snapshot.host.trim() ||
|
|
592
|
-
hasControlCharacter(snapshot.host) ||
|
|
593
|
-
!Number.isInteger(snapshot.port) ||
|
|
594
|
-
snapshot.port < 1 ||
|
|
595
|
-
snapshot.port > 65_535)
|
|
596
|
-
throw new TypeError("invalid native connection target");
|
|
597
|
-
return snapshot;
|
|
1005
|
+
return input[field];
|
|
598
1006
|
}
|
|
599
1007
|
catch {
|
|
600
|
-
throw
|
|
1008
|
+
throw invalidNativeConnectInput(field, "inspection-failure");
|
|
601
1009
|
}
|
|
602
1010
|
}
|
|
603
1011
|
/** Internal canonical snapshot shared by production and SDK transport test doubles. */
|
|
1012
|
+
export function snapshotNativeConnectInput(input) {
|
|
1013
|
+
const host = inspectNativeConnectInputField(input, "host");
|
|
1014
|
+
const canonicalHost = canonicalizeEgressHost(host);
|
|
1015
|
+
if (!canonicalHost.ok)
|
|
1016
|
+
throw invalidNativeConnectInput("host", canonicalHost.reason);
|
|
1017
|
+
const port = inspectNativeConnectInputField(input, "port");
|
|
1018
|
+
if (!Number.isInteger(port) || port < 1 || port > 65_535)
|
|
1019
|
+
throw invalidNativeConnectInput("port", "port-range");
|
|
1020
|
+
const serverName = inspectNativeConnectInputField(input, "serverName");
|
|
1021
|
+
const rejectUnauthorized = inspectNativeConnectInputField(input, "rejectUnauthorized");
|
|
1022
|
+
const idleTimeoutMs = inspectNativeConnectInputField(input, "idleTimeoutMs");
|
|
1023
|
+
const timeoutMs = inspectNativeConnectInputField(input, "timeoutMs");
|
|
1024
|
+
const signal = inspectNativeConnectInputField(input, "signal");
|
|
1025
|
+
const affinityKey = inspectNativeConnectInputField(input, "affinityKey");
|
|
1026
|
+
return {
|
|
1027
|
+
host: canonicalHost.host,
|
|
1028
|
+
port,
|
|
1029
|
+
...(serverName === undefined ? {} : { serverName }),
|
|
1030
|
+
...(rejectUnauthorized === undefined ? {} : { rejectUnauthorized }),
|
|
1031
|
+
...(idleTimeoutMs === undefined ? {} : { idleTimeoutMs }),
|
|
1032
|
+
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
|
1033
|
+
...(signal === undefined ? {} : { signal }),
|
|
1034
|
+
...(affinityKey === undefined ? {} : { affinityKey }),
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
1037
|
+
/** Internal canonical snapshot shared by production and SDK transport test doubles. */
|
|
604
1038
|
export function snapshotNativeGrantInput(input) {
|
|
1039
|
+
let snapshot;
|
|
605
1040
|
try {
|
|
606
1041
|
const sourceHost = input.sourceHost;
|
|
607
1042
|
const sourcePort = input.sourcePort;
|
|
@@ -609,7 +1044,7 @@ export function snapshotNativeGrantInput(input) {
|
|
|
609
1044
|
const port = input.port;
|
|
610
1045
|
const tls = input.tls;
|
|
611
1046
|
const ttlMs = input.ttlMs;
|
|
612
|
-
|
|
1047
|
+
snapshot = {
|
|
613
1048
|
sourceHost,
|
|
614
1049
|
sourcePort,
|
|
615
1050
|
host,
|
|
@@ -621,6 +1056,12 @@ export function snapshotNativeGrantInput(input) {
|
|
|
621
1056
|
catch {
|
|
622
1057
|
throw new NativeNetworkError("Native TCP egress grant input could not be inspected safely", "native_egress_input_invalid");
|
|
623
1058
|
}
|
|
1059
|
+
assertValidGrantInput(snapshot);
|
|
1060
|
+
return {
|
|
1061
|
+
...snapshot,
|
|
1062
|
+
sourceHost: canonicalGrantHost(snapshot.sourceHost),
|
|
1063
|
+
host: canonicalGrantHost(snapshot.host),
|
|
1064
|
+
};
|
|
624
1065
|
}
|
|
625
1066
|
/** Internal authorization seam shared by production and SDK transport test doubles. */
|
|
626
1067
|
export function createNativeEgressAuthorization(options) {
|
|
@@ -678,21 +1119,20 @@ export function createNativeEgressAuthorization(options) {
|
|
|
678
1119
|
const assertConnect = (input, tls) => {
|
|
679
1120
|
if (!declared)
|
|
680
1121
|
return;
|
|
681
|
-
const host = normalizeEgressHost(input.host);
|
|
682
1122
|
const now = Date.now();
|
|
683
1123
|
purgeInactive(now);
|
|
684
|
-
if (staticRules.some((rule) => rule.host ===
|
|
1124
|
+
if (staticRules.some((rule) => rule.host === input.host &&
|
|
1125
|
+
rule.ports.includes(input.port) &&
|
|
1126
|
+
tlsModeAllows(rule.tls, tls)))
|
|
685
1127
|
return;
|
|
686
1128
|
const matching = grants.filter((grant) => !grant.revoked &&
|
|
687
|
-
grant.host === host &&
|
|
1129
|
+
grant.host === input.host &&
|
|
688
1130
|
grant.port === input.port &&
|
|
689
1131
|
tlsModeAllows(grant.tls, tls));
|
|
690
1132
|
if (matching.length > 0)
|
|
691
1133
|
return;
|
|
692
1134
|
const expired = [...expiredEvidence.values()]
|
|
693
|
-
.filter((grant) => grant.host === host &&
|
|
694
|
-
grant.port === input.port &&
|
|
695
|
-
tlsModeAllows(grant.tls, tls))
|
|
1135
|
+
.filter((grant) => grant.host === input.host && grant.port === input.port && tlsModeAllows(grant.tls, tls))
|
|
696
1136
|
.sort((left, right) => (right.expiresAtMs ?? 0) - (left.expiresAtMs ?? 0))[0];
|
|
697
1137
|
if (expired?.expiresAtMs !== undefined)
|
|
698
1138
|
throw new NativeEgressGrantExpiredError(input.host, input.port, tls, new Date(expired.expiresAtMs).toISOString());
|
|
@@ -701,15 +1141,51 @@ export function createNativeEgressAuthorization(options) {
|
|
|
701
1141
|
const grantLocal = (input) => {
|
|
702
1142
|
assertValidGrantInput(input);
|
|
703
1143
|
const ruleIndex = dynamicRules.findIndex((rule) => matchesDynamicRuleSelectors(rule, input));
|
|
704
|
-
if (ruleIndex < 0)
|
|
705
|
-
|
|
1144
|
+
if (ruleIndex < 0) {
|
|
1145
|
+
const diagnosticSourceHost = safeDiagnosticEgressHost(input.sourceHost);
|
|
1146
|
+
const diagnosticTargetHost = safeDiagnosticEgressHost(input.host);
|
|
1147
|
+
const sourceMatchingRuleIndices = dynamicRules.flatMap((rule, index) => matchesSourceHost(rule, input.sourceHost) &&
|
|
1148
|
+
matchesPortSelectors(input.sourcePort, rule.sourcePorts, rule.sourcePortRanges)
|
|
1149
|
+
? [index]
|
|
1150
|
+
: []);
|
|
1151
|
+
const targetKind = classifyEgressHost(diagnosticTargetHost);
|
|
1152
|
+
let selectorDetails;
|
|
1153
|
+
if (sourceMatchingRuleIndices.length > 0) {
|
|
1154
|
+
const failedByRule = [];
|
|
1155
|
+
for (const index of sourceMatchingRuleIndices) {
|
|
1156
|
+
const rule = dynamicRules[index];
|
|
1157
|
+
if (!rule)
|
|
1158
|
+
continue;
|
|
1159
|
+
const failedDimensions = [];
|
|
1160
|
+
if (!matchesDynamicTargetHost(rule, input.host))
|
|
1161
|
+
failedDimensions.push("target-host");
|
|
1162
|
+
if (!matchesPortSelectors(input.port, rule.targetPorts, rule.targetPortRanges))
|
|
1163
|
+
failedDimensions.push("target-port");
|
|
1164
|
+
if (!grantTlsFitsRule(input.tls, rule.tls))
|
|
1165
|
+
failedDimensions.push("tls");
|
|
1166
|
+
failedByRule.push(`rule ${index}: ${failedDimensions.join(", ")}`);
|
|
1167
|
+
}
|
|
1168
|
+
selectorDetails = `source-matching rule indices: [${sourceMatchingRuleIndices.join(", ")}]; failed selector dimensions by rule: ${failedByRule.join("; ")}`;
|
|
1169
|
+
}
|
|
1170
|
+
else {
|
|
1171
|
+
const failedByRule = dynamicRules.map((rule, index) => {
|
|
1172
|
+
const failedDimensions = [];
|
|
1173
|
+
if (!matchesSourceHost(rule, input.sourceHost))
|
|
1174
|
+
failedDimensions.push("source-host");
|
|
1175
|
+
if (!matchesPortSelectors(input.sourcePort, rule.sourcePorts, rule.sourcePortRanges))
|
|
1176
|
+
failedDimensions.push("source-port");
|
|
1177
|
+
return `rule ${index}: ${failedDimensions.join(", ")}`;
|
|
1178
|
+
});
|
|
1179
|
+
selectorDetails = `source-matching rule indices: []; failed selector dimensions by rule: ${failedByRule.join("; ")}`;
|
|
1180
|
+
}
|
|
1181
|
+
throw new NativeNetworkError(`Native TCP egress grant is not declared for source ${diagnosticSourceHost}:${input.sourcePort} to target ${diagnosticTargetHost}:${input.port} (${input.tls}); target kind: ${targetKind}; ${selectorDetails}`, "native_egress_not_declared");
|
|
1182
|
+
}
|
|
706
1183
|
const rule = dynamicRules[ruleIndex];
|
|
707
1184
|
if (!rule)
|
|
708
1185
|
throw invalidGrant("Native TCP egress declaration is missing its matched rule");
|
|
709
1186
|
if (input.ttlMs !== undefined && rule.ttlMs !== undefined && input.ttlMs > rule.ttlMs)
|
|
710
1187
|
throw invalidGrant(`Native TCP egress grant ttlMs ${input.ttlMs} exceeds declared maximum ${rule.ttlMs}`);
|
|
711
1188
|
const now = Date.now();
|
|
712
|
-
const targetHost = normalizeEgressHost(input.host);
|
|
713
1189
|
purgeInactive(now);
|
|
714
1190
|
const activeForRule = grants.filter((grant) => grant.ruleIndex === ruleIndex &&
|
|
715
1191
|
!grant.revoked &&
|
|
@@ -722,7 +1198,7 @@ export function createNativeEgressAuthorization(options) {
|
|
|
722
1198
|
throw invalidGrant("Native TCP egress grant expiry exceeds the supported date range");
|
|
723
1199
|
const stored = {
|
|
724
1200
|
ruleIndex,
|
|
725
|
-
host:
|
|
1201
|
+
host: input.host,
|
|
726
1202
|
port: input.port,
|
|
727
1203
|
tls: input.tls,
|
|
728
1204
|
...(expiresAtMs === undefined ? {} : { expiresAtMs }),
|
|
@@ -797,10 +1273,10 @@ export function createNativeNetworkClient(options = {}) {
|
|
|
797
1273
|
egress.assertConnect(request, "disabled");
|
|
798
1274
|
const deadline = deadlineFrom(request.timeoutMs);
|
|
799
1275
|
assertCanStart(request.signal, deadline);
|
|
800
|
-
const proxy = await resolveConnectionProxy(options, request);
|
|
1276
|
+
const proxy = await resolveConnectionProxy(options, request, deadline);
|
|
801
1277
|
egress.assertConnect(request, "disabled");
|
|
802
1278
|
const socket = proxy
|
|
803
|
-
? await
|
|
1279
|
+
? await connectProxyTunnel(proxy, request, deadline, () => egress.assertConnect(request, "disabled"))
|
|
804
1280
|
: await connectPlainSocket(request.host, request.port, request.signal, deadline);
|
|
805
1281
|
return createNativeNetworkConnection(socket, proxy, options, request.idleTimeoutMs);
|
|
806
1282
|
},
|
|
@@ -809,10 +1285,10 @@ export function createNativeNetworkClient(options = {}) {
|
|
|
809
1285
|
egress.assertConnect(request, "required");
|
|
810
1286
|
const deadline = deadlineFrom(request.timeoutMs);
|
|
811
1287
|
assertCanStart(request.signal, deadline);
|
|
812
|
-
const proxy = await resolveConnectionProxy(options, request);
|
|
1288
|
+
const proxy = await resolveConnectionProxy(options, request, deadline);
|
|
813
1289
|
egress.assertConnect(request, "required");
|
|
814
1290
|
const tunnel = proxy
|
|
815
|
-
? await
|
|
1291
|
+
? await connectProxyTunnel(proxy, request, deadline, () => egress.assertConnect(request, "required"))
|
|
816
1292
|
: undefined;
|
|
817
1293
|
const socket = await upgradeTls(tunnel, request, deadline);
|
|
818
1294
|
return createNativeNetworkConnection(socket, proxy, options, request.idleTimeoutMs);
|