@apifuse/provider-sdk 2.2.0-beta.15 → 2.2.0-beta.17
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 +92 -13
- package/CHANGELOG.md +10 -0
- package/dist/config/loader.d.ts +19 -0
- package/dist/config/loader.js +59 -28
- package/dist/define.js +20 -1
- package/dist/error-resolution.d.ts +2 -0
- package/dist/error-resolution.js +90 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/native-egress-policy.d.ts +1 -0
- package/dist/native-egress-policy.js +34 -21
- package/dist/native-ipv4.d.ts +22 -0
- package/dist/native-ipv4.js +98 -0
- package/dist/provider.d.ts +2 -2
- package/dist/provider.js +1 -1
- package/dist/runtime/native-network.d.ts +43 -7
- package/dist/runtime/native-network.js +567 -110
- package/dist/runtime/proxy-nodemaven.d.ts +7 -0
- package/dist/runtime/proxy-nodemaven.js +5 -5
- package/dist/server/serve.js +76 -100
- package/dist/types.d.ts +9 -2
- package/dist/types.js +1 -0
- package/package.json +1 -1
- package/src/config/loader.ts +110 -18
- package/src/define.ts +33 -0
- package/src/error-resolution.ts +91 -0
- package/src/index.ts +6 -0
- package/src/native-egress-policy.ts +39 -33
- package/src/native-ipv4.ts +118 -0
- package/src/provider.ts +6 -0
- package/src/runtime/native-network.ts +734 -131
- package/src/runtime/proxy-nodemaven.ts +13 -5
- package/src/server/serve.ts +118 -98
- package/src/types.ts +11 -2
|
@@ -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, classifyEgressTargetHost, ipv4InCidr, parseStrictIpv4, } from "../native-ipv4.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 };
|
|
138
298
|
}
|
|
139
|
-
|
|
299
|
+
/** Resolve the first configured native gateway, including allocation vendors. */
|
|
300
|
+
export async function resolveNativeGatewayProxy(input) {
|
|
301
|
+
return (await resolveNativeGatewayProxyDetailed(input)).proxy;
|
|
302
|
+
}
|
|
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,19 +868,48 @@ 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
|
}
|
|
@@ -523,37 +937,35 @@ function grantTlsFitsRule(grant, rule) {
|
|
|
523
937
|
(grant === "disabled" && rule === "disabled"));
|
|
524
938
|
}
|
|
525
939
|
function matchesDynamicRuleSelectors(rule, input) {
|
|
526
|
-
|
|
527
|
-
const targetHost = normalizeEgressHost(input.host);
|
|
528
|
-
return (matchesSourceHost(rule, sourceHost) &&
|
|
940
|
+
return (matchesSourceHost(rule, input.sourceHost) &&
|
|
529
941
|
matchesPortSelectors(input.sourcePort, rule.sourcePorts, rule.sourcePortRanges) &&
|
|
530
|
-
rule
|
|
942
|
+
matchesDynamicTargetHost(rule, input.host) &&
|
|
531
943
|
matchesPortSelectors(input.port, rule.targetPorts, rule.targetPortRanges) &&
|
|
532
944
|
grantTlsFitsRule(input.tls, rule.tls));
|
|
533
945
|
}
|
|
946
|
+
function matchesDynamicTargetHost(rule, targetHost) {
|
|
947
|
+
const targetKind = classifyEgressTargetHost(targetHost);
|
|
948
|
+
if (targetKind === "ipv4") {
|
|
949
|
+
const targetIp = parseStrictIpv4(targetHost);
|
|
950
|
+
return (targetIp !== undefined && rule.targetIpv4Cidrs.some((cidr) => ipv4InCidr(targetIp, cidr)));
|
|
951
|
+
}
|
|
952
|
+
return (targetKind === "dns" &&
|
|
953
|
+
rule.targetHostSuffixes.some((suffix) => matchesDnsSuffix(targetHost, suffix)));
|
|
954
|
+
}
|
|
534
955
|
function invalidGrant(message) {
|
|
535
956
|
return new NativeNetworkError(message, "native_egress_grant_invalid");
|
|
536
957
|
}
|
|
537
|
-
function
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
return
|
|
958
|
+
function canonicalGrantHost(value) {
|
|
959
|
+
if (typeof value === "string" && value.includes("*"))
|
|
960
|
+
throw invalidGrant("Native TCP egress grant hosts must be exact non-empty hostnames");
|
|
961
|
+
const canonical = canonicalizeEgressHost(value);
|
|
962
|
+
if (!canonical.ok)
|
|
963
|
+
throw invalidGrant("Native TCP egress grant hosts must be exact non-empty hostnames");
|
|
964
|
+
return canonical.host;
|
|
544
965
|
}
|
|
545
966
|
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");
|
|
967
|
+
canonicalGrantHost(input.sourceHost);
|
|
968
|
+
canonicalGrantHost(input.host);
|
|
557
969
|
if (!Number.isSafeInteger(input.sourcePort) ||
|
|
558
970
|
input.sourcePort < 1 ||
|
|
559
971
|
input.sourcePort > 65_535 ||
|
|
@@ -566,42 +978,46 @@ function assertValidGrantInput(input) {
|
|
|
566
978
|
if (input.ttlMs !== undefined && (!Number.isSafeInteger(input.ttlMs) || input.ttlMs <= 0))
|
|
567
979
|
throw invalidGrant("Native TCP egress grant ttlMs must be a positive integer");
|
|
568
980
|
}
|
|
569
|
-
|
|
570
|
-
|
|
981
|
+
function invalidNativeConnectInput(field, reason) {
|
|
982
|
+
return new NativeNetworkError(`Native connection input rejected: field=${field}; reason=${reason}`, "native_egress_input_invalid");
|
|
983
|
+
}
|
|
984
|
+
function inspectNativeConnectInputField(input, field) {
|
|
571
985
|
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;
|
|
986
|
+
return input[field];
|
|
598
987
|
}
|
|
599
988
|
catch {
|
|
600
|
-
throw
|
|
989
|
+
throw invalidNativeConnectInput(field, "inspection-failure");
|
|
601
990
|
}
|
|
602
991
|
}
|
|
603
992
|
/** Internal canonical snapshot shared by production and SDK transport test doubles. */
|
|
993
|
+
export function snapshotNativeConnectInput(input) {
|
|
994
|
+
const host = inspectNativeConnectInputField(input, "host");
|
|
995
|
+
const canonicalHost = canonicalizeEgressHost(host);
|
|
996
|
+
if (!canonicalHost.ok)
|
|
997
|
+
throw invalidNativeConnectInput("host", canonicalHost.reason);
|
|
998
|
+
const port = inspectNativeConnectInputField(input, "port");
|
|
999
|
+
if (!Number.isInteger(port) || port < 1 || port > 65_535)
|
|
1000
|
+
throw invalidNativeConnectInput("port", "port-range");
|
|
1001
|
+
const serverName = inspectNativeConnectInputField(input, "serverName");
|
|
1002
|
+
const rejectUnauthorized = inspectNativeConnectInputField(input, "rejectUnauthorized");
|
|
1003
|
+
const idleTimeoutMs = inspectNativeConnectInputField(input, "idleTimeoutMs");
|
|
1004
|
+
const timeoutMs = inspectNativeConnectInputField(input, "timeoutMs");
|
|
1005
|
+
const signal = inspectNativeConnectInputField(input, "signal");
|
|
1006
|
+
const affinityKey = inspectNativeConnectInputField(input, "affinityKey");
|
|
1007
|
+
return {
|
|
1008
|
+
host: canonicalHost.host,
|
|
1009
|
+
port,
|
|
1010
|
+
...(serverName === undefined ? {} : { serverName }),
|
|
1011
|
+
...(rejectUnauthorized === undefined ? {} : { rejectUnauthorized }),
|
|
1012
|
+
...(idleTimeoutMs === undefined ? {} : { idleTimeoutMs }),
|
|
1013
|
+
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
|
1014
|
+
...(signal === undefined ? {} : { signal }),
|
|
1015
|
+
...(affinityKey === undefined ? {} : { affinityKey }),
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
/** Internal canonical snapshot shared by production and SDK transport test doubles. */
|
|
604
1019
|
export function snapshotNativeGrantInput(input) {
|
|
1020
|
+
let snapshot;
|
|
605
1021
|
try {
|
|
606
1022
|
const sourceHost = input.sourceHost;
|
|
607
1023
|
const sourcePort = input.sourcePort;
|
|
@@ -609,7 +1025,7 @@ export function snapshotNativeGrantInput(input) {
|
|
|
609
1025
|
const port = input.port;
|
|
610
1026
|
const tls = input.tls;
|
|
611
1027
|
const ttlMs = input.ttlMs;
|
|
612
|
-
|
|
1028
|
+
snapshot = {
|
|
613
1029
|
sourceHost,
|
|
614
1030
|
sourcePort,
|
|
615
1031
|
host,
|
|
@@ -621,6 +1037,12 @@ export function snapshotNativeGrantInput(input) {
|
|
|
621
1037
|
catch {
|
|
622
1038
|
throw new NativeNetworkError("Native TCP egress grant input could not be inspected safely", "native_egress_input_invalid");
|
|
623
1039
|
}
|
|
1040
|
+
assertValidGrantInput(snapshot);
|
|
1041
|
+
return {
|
|
1042
|
+
...snapshot,
|
|
1043
|
+
sourceHost: canonicalGrantHost(snapshot.sourceHost),
|
|
1044
|
+
host: canonicalGrantHost(snapshot.host),
|
|
1045
|
+
};
|
|
624
1046
|
}
|
|
625
1047
|
/** Internal authorization seam shared by production and SDK transport test doubles. */
|
|
626
1048
|
export function createNativeEgressAuthorization(options) {
|
|
@@ -678,21 +1100,20 @@ export function createNativeEgressAuthorization(options) {
|
|
|
678
1100
|
const assertConnect = (input, tls) => {
|
|
679
1101
|
if (!declared)
|
|
680
1102
|
return;
|
|
681
|
-
const host = normalizeEgressHost(input.host);
|
|
682
1103
|
const now = Date.now();
|
|
683
1104
|
purgeInactive(now);
|
|
684
|
-
if (staticRules.some((rule) => rule.host ===
|
|
1105
|
+
if (staticRules.some((rule) => rule.host === input.host &&
|
|
1106
|
+
rule.ports.includes(input.port) &&
|
|
1107
|
+
tlsModeAllows(rule.tls, tls)))
|
|
685
1108
|
return;
|
|
686
1109
|
const matching = grants.filter((grant) => !grant.revoked &&
|
|
687
|
-
grant.host === host &&
|
|
1110
|
+
grant.host === input.host &&
|
|
688
1111
|
grant.port === input.port &&
|
|
689
1112
|
tlsModeAllows(grant.tls, tls));
|
|
690
1113
|
if (matching.length > 0)
|
|
691
1114
|
return;
|
|
692
1115
|
const expired = [...expiredEvidence.values()]
|
|
693
|
-
.filter((grant) => grant.host === host &&
|
|
694
|
-
grant.port === input.port &&
|
|
695
|
-
tlsModeAllows(grant.tls, tls))
|
|
1116
|
+
.filter((grant) => grant.host === input.host && grant.port === input.port && tlsModeAllows(grant.tls, tls))
|
|
696
1117
|
.sort((left, right) => (right.expiresAtMs ?? 0) - (left.expiresAtMs ?? 0))[0];
|
|
697
1118
|
if (expired?.expiresAtMs !== undefined)
|
|
698
1119
|
throw new NativeEgressGrantExpiredError(input.host, input.port, tls, new Date(expired.expiresAtMs).toISOString());
|
|
@@ -701,15 +1122,51 @@ export function createNativeEgressAuthorization(options) {
|
|
|
701
1122
|
const grantLocal = (input) => {
|
|
702
1123
|
assertValidGrantInput(input);
|
|
703
1124
|
const ruleIndex = dynamicRules.findIndex((rule) => matchesDynamicRuleSelectors(rule, input));
|
|
704
|
-
if (ruleIndex < 0)
|
|
705
|
-
|
|
1125
|
+
if (ruleIndex < 0) {
|
|
1126
|
+
const diagnosticSourceHost = safeDiagnosticEgressHost(input.sourceHost);
|
|
1127
|
+
const diagnosticTargetHost = safeDiagnosticEgressHost(input.host);
|
|
1128
|
+
const sourceMatchingRuleIndices = dynamicRules.flatMap((rule, index) => matchesSourceHost(rule, input.sourceHost) &&
|
|
1129
|
+
matchesPortSelectors(input.sourcePort, rule.sourcePorts, rule.sourcePortRanges)
|
|
1130
|
+
? [index]
|
|
1131
|
+
: []);
|
|
1132
|
+
const targetKind = classifyEgressTargetHost(diagnosticTargetHost);
|
|
1133
|
+
let selectorDetails;
|
|
1134
|
+
if (sourceMatchingRuleIndices.length > 0) {
|
|
1135
|
+
const failedByRule = [];
|
|
1136
|
+
for (const index of sourceMatchingRuleIndices) {
|
|
1137
|
+
const rule = dynamicRules[index];
|
|
1138
|
+
if (!rule)
|
|
1139
|
+
continue;
|
|
1140
|
+
const failedDimensions = [];
|
|
1141
|
+
if (!matchesDynamicTargetHost(rule, input.host))
|
|
1142
|
+
failedDimensions.push("target-host");
|
|
1143
|
+
if (!matchesPortSelectors(input.port, rule.targetPorts, rule.targetPortRanges))
|
|
1144
|
+
failedDimensions.push("target-port");
|
|
1145
|
+
if (!grantTlsFitsRule(input.tls, rule.tls))
|
|
1146
|
+
failedDimensions.push("tls");
|
|
1147
|
+
failedByRule.push(`rule ${index}: ${failedDimensions.join(", ")}`);
|
|
1148
|
+
}
|
|
1149
|
+
selectorDetails = `source-matching rule indices: [${sourceMatchingRuleIndices.join(", ")}]; failed selector dimensions by rule: ${failedByRule.join("; ")}`;
|
|
1150
|
+
}
|
|
1151
|
+
else {
|
|
1152
|
+
const failedByRule = dynamicRules.map((rule, index) => {
|
|
1153
|
+
const failedDimensions = [];
|
|
1154
|
+
if (!matchesSourceHost(rule, input.sourceHost))
|
|
1155
|
+
failedDimensions.push("source-host");
|
|
1156
|
+
if (!matchesPortSelectors(input.sourcePort, rule.sourcePorts, rule.sourcePortRanges))
|
|
1157
|
+
failedDimensions.push("source-port");
|
|
1158
|
+
return `rule ${index}: ${failedDimensions.join(", ")}`;
|
|
1159
|
+
});
|
|
1160
|
+
selectorDetails = `source-matching rule indices: []; failed selector dimensions by rule: ${failedByRule.join("; ")}`;
|
|
1161
|
+
}
|
|
1162
|
+
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");
|
|
1163
|
+
}
|
|
706
1164
|
const rule = dynamicRules[ruleIndex];
|
|
707
1165
|
if (!rule)
|
|
708
1166
|
throw invalidGrant("Native TCP egress declaration is missing its matched rule");
|
|
709
1167
|
if (input.ttlMs !== undefined && rule.ttlMs !== undefined && input.ttlMs > rule.ttlMs)
|
|
710
1168
|
throw invalidGrant(`Native TCP egress grant ttlMs ${input.ttlMs} exceeds declared maximum ${rule.ttlMs}`);
|
|
711
1169
|
const now = Date.now();
|
|
712
|
-
const targetHost = normalizeEgressHost(input.host);
|
|
713
1170
|
purgeInactive(now);
|
|
714
1171
|
const activeForRule = grants.filter((grant) => grant.ruleIndex === ruleIndex &&
|
|
715
1172
|
!grant.revoked &&
|
|
@@ -722,7 +1179,7 @@ export function createNativeEgressAuthorization(options) {
|
|
|
722
1179
|
throw invalidGrant("Native TCP egress grant expiry exceeds the supported date range");
|
|
723
1180
|
const stored = {
|
|
724
1181
|
ruleIndex,
|
|
725
|
-
host:
|
|
1182
|
+
host: input.host,
|
|
726
1183
|
port: input.port,
|
|
727
1184
|
tls: input.tls,
|
|
728
1185
|
...(expiresAtMs === undefined ? {} : { expiresAtMs }),
|
|
@@ -797,10 +1254,10 @@ export function createNativeNetworkClient(options = {}) {
|
|
|
797
1254
|
egress.assertConnect(request, "disabled");
|
|
798
1255
|
const deadline = deadlineFrom(request.timeoutMs);
|
|
799
1256
|
assertCanStart(request.signal, deadline);
|
|
800
|
-
const proxy = await resolveConnectionProxy(options, request);
|
|
1257
|
+
const proxy = await resolveConnectionProxy(options, request, deadline);
|
|
801
1258
|
egress.assertConnect(request, "disabled");
|
|
802
1259
|
const socket = proxy
|
|
803
|
-
? await
|
|
1260
|
+
? await connectProxyTunnel(proxy, request, deadline, () => egress.assertConnect(request, "disabled"))
|
|
804
1261
|
: await connectPlainSocket(request.host, request.port, request.signal, deadline);
|
|
805
1262
|
return createNativeNetworkConnection(socket, proxy, options, request.idleTimeoutMs);
|
|
806
1263
|
},
|
|
@@ -809,10 +1266,10 @@ export function createNativeNetworkClient(options = {}) {
|
|
|
809
1266
|
egress.assertConnect(request, "required");
|
|
810
1267
|
const deadline = deadlineFrom(request.timeoutMs);
|
|
811
1268
|
assertCanStart(request.signal, deadline);
|
|
812
|
-
const proxy = await resolveConnectionProxy(options, request);
|
|
1269
|
+
const proxy = await resolveConnectionProxy(options, request, deadline);
|
|
813
1270
|
egress.assertConnect(request, "required");
|
|
814
1271
|
const tunnel = proxy
|
|
815
|
-
? await
|
|
1272
|
+
? await connectProxyTunnel(proxy, request, deadline, () => egress.assertConnect(request, "required"))
|
|
816
1273
|
: undefined;
|
|
817
1274
|
const socket = await upgradeTls(tunnel, request, deadline);
|
|
818
1275
|
return createNativeNetworkConnection(socket, proxy, options, request.idleTimeoutMs);
|