@apifuse/provider-sdk 2.2.0-beta.16 → 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 +40 -0
- package/CHANGELOG.md +5 -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-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 +1 -1
- 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 +3 -1
- package/dist/types.d.ts +6 -1
- package/package.json +1 -1
- package/src/config/loader.ts +110 -18
- package/src/index.ts +5 -0
- package/src/native-egress-policy.ts +39 -33
- package/src/native-ipv4.ts +118 -0
- package/src/provider.ts +5 -0
- package/src/runtime/native-network.ts +734 -131
- package/src/runtime/proxy-nodemaven.ts +13 -5
- package/src/server/serve.ts +8 -1
- package/src/types.ts +6 -1
|
@@ -4,18 +4,32 @@ import { connect as connectTlsSocket, type TLSSocket } from "node:tls";
|
|
|
4
4
|
|
|
5
5
|
import { SocksClient } from "socks";
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
assertTunnelingScheme,
|
|
9
|
+
ProxyResolutionError,
|
|
10
|
+
SMARTPROXY_APP_KEY_ENV,
|
|
11
|
+
type ProxyProtocol,
|
|
12
|
+
VENDOR_DEFAULT_PROTOCOL,
|
|
13
|
+
resolveWithVendor,
|
|
14
|
+
} from "../config/loader.js";
|
|
8
15
|
import { TransportError } from "../errors.js";
|
|
9
16
|
import {
|
|
17
|
+
type DynamicEgressRuleSnapshot,
|
|
10
18
|
NativeEgressPolicyValidationError,
|
|
11
19
|
parseNativeEgressPolicy,
|
|
12
|
-
type DynamicEgressRuleSnapshot,
|
|
13
20
|
type StaticEgressRuleSnapshot,
|
|
14
21
|
} from "../native-egress-policy.js";
|
|
22
|
+
import {
|
|
23
|
+
canonicalizeEgressHost,
|
|
24
|
+
classifyEgressTargetHost,
|
|
25
|
+
type EgressHostCanonicalizationFailure,
|
|
26
|
+
ipv4InCidr,
|
|
27
|
+
parseStrictIpv4,
|
|
28
|
+
} from "../native-ipv4.js";
|
|
15
29
|
import type {
|
|
16
30
|
NativeNetworkClient,
|
|
17
|
-
NativeNetworkConnection,
|
|
18
31
|
NativeNetworkConnectInput,
|
|
32
|
+
NativeNetworkConnection,
|
|
19
33
|
NativeNetworkDynamicGrantOptions,
|
|
20
34
|
NativeNetworkEgressGrant,
|
|
21
35
|
NativeProviderConfig,
|
|
@@ -26,12 +40,17 @@ import type {
|
|
|
26
40
|
NativeTcpTlsMode,
|
|
27
41
|
ProviderProxyPolicy,
|
|
28
42
|
ProviderProxyProvider,
|
|
43
|
+
EnvContext,
|
|
29
44
|
} from "../types.js";
|
|
45
|
+
import { createEnvContext } from "./env.js";
|
|
30
46
|
import {
|
|
31
|
-
|
|
47
|
+
NODEMAVEN_FILTER_ENV,
|
|
48
|
+
NODEMAVEN_PASSWORD_ENV,
|
|
49
|
+
NODEMAVEN_USERNAME_ENV,
|
|
32
50
|
nodemavenSessionWindow,
|
|
33
51
|
synthesizeNodemavenProxy,
|
|
34
52
|
} from "./proxy-nodemaven.js";
|
|
53
|
+
import { redactSensitiveError } from "./request-options.js";
|
|
35
54
|
|
|
36
55
|
export type NativeNetworkErrorCode =
|
|
37
56
|
| "native_connection_aborted"
|
|
@@ -51,12 +70,13 @@ export type NativeNetworkErrorCode =
|
|
|
51
70
|
| "native_proxy_invalid";
|
|
52
71
|
|
|
53
72
|
export class NativeNetworkError extends TransportError {
|
|
54
|
-
constructor(message: string, code: NativeNetworkErrorCode) {
|
|
73
|
+
constructor(message: string, code: NativeNetworkErrorCode, cause?: Error) {
|
|
55
74
|
const isEgressPolicyFailure =
|
|
56
75
|
code.startsWith("native_egress_") || code === "native_dynamic_egress_unsupported";
|
|
57
76
|
super(message, {
|
|
58
77
|
code,
|
|
59
78
|
status: 0,
|
|
79
|
+
...(cause ? { cause } : {}),
|
|
60
80
|
...(isEgressPolicyFailure ? { category: "provider_error" as const, retryable: false } : {}),
|
|
61
81
|
});
|
|
62
82
|
this.name = "NativeNetworkError";
|
|
@@ -67,6 +87,11 @@ export class NativeNetworkError extends TransportError {
|
|
|
67
87
|
}
|
|
68
88
|
}
|
|
69
89
|
|
|
90
|
+
function safeDiagnosticEgressHost(value: unknown): string {
|
|
91
|
+
const canonical = canonicalizeEgressHost(value);
|
|
92
|
+
return canonical.ok ? canonical.host : `<invalid-host:${canonical.reason}>`;
|
|
93
|
+
}
|
|
94
|
+
|
|
70
95
|
export class NativeProxyExpiredError extends NativeNetworkError {
|
|
71
96
|
constructor(readonly expiresAt: string) {
|
|
72
97
|
super("Native connection closed at sticky proxy expiry", "native_proxy_expired");
|
|
@@ -76,15 +101,19 @@ export class NativeProxyExpiredError extends NativeNetworkError {
|
|
|
76
101
|
|
|
77
102
|
/** Raised before transport setup when a native destination is not authorized. */
|
|
78
103
|
export class NativeEgressNotDeclaredError extends NativeNetworkError {
|
|
104
|
+
readonly host: string;
|
|
105
|
+
|
|
79
106
|
constructor(
|
|
80
|
-
|
|
107
|
+
host: string,
|
|
81
108
|
readonly port: number,
|
|
82
109
|
readonly tls: "required" | "disabled",
|
|
83
110
|
) {
|
|
111
|
+
const diagnosticHost = safeDiagnosticEgressHost(host);
|
|
84
112
|
super(
|
|
85
|
-
`Native ${tls === "required" ? "TLS" : "TCP"} egress is not declared for ${
|
|
113
|
+
`Native ${tls === "required" ? "TLS" : "TCP"} egress is not declared for ${diagnosticHost}:${port}`,
|
|
86
114
|
"native_egress_not_declared",
|
|
87
115
|
);
|
|
116
|
+
this.host = diagnosticHost;
|
|
88
117
|
this.name = "NativeEgressNotDeclaredError";
|
|
89
118
|
}
|
|
90
119
|
}
|
|
@@ -94,16 +123,20 @@ export class NativeEgressNotDeclaredError extends NativeNetworkError {
|
|
|
94
123
|
* its expiry remains in the client's bounded recent-expiry evidence window.
|
|
95
124
|
*/
|
|
96
125
|
export class NativeEgressGrantExpiredError extends NativeNetworkError {
|
|
126
|
+
readonly host: string;
|
|
127
|
+
|
|
97
128
|
constructor(
|
|
98
|
-
|
|
129
|
+
host: string,
|
|
99
130
|
readonly port: number,
|
|
100
131
|
readonly tls: "required" | "disabled",
|
|
101
132
|
readonly expiresAt: string,
|
|
102
133
|
) {
|
|
134
|
+
const diagnosticHost = safeDiagnosticEgressHost(host);
|
|
103
135
|
super(
|
|
104
|
-
`Native ${tls === "required" ? "TLS" : "TCP"} egress grant expired for ${
|
|
136
|
+
`Native ${tls === "required" ? "TLS" : "TCP"} egress grant expired for ${diagnosticHost}:${port}`,
|
|
105
137
|
"native_egress_grant_expired",
|
|
106
138
|
);
|
|
139
|
+
this.host = diagnosticHost;
|
|
107
140
|
this.name = "NativeEgressGrantExpiredError";
|
|
108
141
|
}
|
|
109
142
|
}
|
|
@@ -111,10 +144,7 @@ export class NativeEgressGrantExpiredError extends NativeNetworkError {
|
|
|
111
144
|
/** Raised when an established connection exceeds its opt-in read-idle window. */
|
|
112
145
|
export class NativeIdleTimeoutError extends NativeNetworkError {
|
|
113
146
|
constructor() {
|
|
114
|
-
super(
|
|
115
|
-
"Native network socket timed out while reading.",
|
|
116
|
-
"native_connection_idle_timeout",
|
|
117
|
-
);
|
|
147
|
+
super("Native network socket timed out while reading.", "native_connection_idle_timeout");
|
|
118
148
|
this.name = "NativeIdleTimeoutError";
|
|
119
149
|
}
|
|
120
150
|
}
|
|
@@ -128,17 +158,38 @@ export type NativeGatewayProxySynthesisInput = {
|
|
|
128
158
|
readonly policy: ProviderProxyPolicy;
|
|
129
159
|
readonly affinityKey?: string;
|
|
130
160
|
readonly now: number;
|
|
161
|
+
readonly protocol: ProxyProtocol;
|
|
162
|
+
readonly credentials: VendorCredentialResolver;
|
|
131
163
|
};
|
|
132
164
|
|
|
165
|
+
export type VendorCredentialLookup =
|
|
166
|
+
| { readonly kind: "present"; readonly values: Readonly<Record<string, string>> }
|
|
167
|
+
| { readonly kind: "absent"; readonly missing: readonly string[] };
|
|
168
|
+
|
|
169
|
+
export type VendorCredentialResolver = (vendor: ProviderProxyProvider) => VendorCredentialLookup;
|
|
170
|
+
|
|
171
|
+
export type NativeGatewayProxySkipReason =
|
|
172
|
+
| { readonly kind: "credentials_absent"; readonly missing: readonly string[] }
|
|
173
|
+
| { readonly kind: "protocol_unsupported"; readonly protocol: string }
|
|
174
|
+
| { readonly kind: "allocation_failed"; readonly cause: Error }
|
|
175
|
+
| { readonly kind: "credential_lookup_failed"; readonly cause: Error };
|
|
176
|
+
|
|
177
|
+
export type NativeGatewayProxySynthesisResult =
|
|
178
|
+
| NativeGatewayProxy
|
|
179
|
+
| { readonly kind: "skipped"; readonly reason: NativeGatewayProxySkipReason }
|
|
180
|
+
| undefined;
|
|
181
|
+
|
|
133
182
|
/** A vendor adapter in the ordered native gateway resolution chain. */
|
|
134
183
|
export type NativeGatewayProxySynthesizer = (
|
|
135
184
|
input: NativeGatewayProxySynthesisInput,
|
|
136
|
-
) =>
|
|
185
|
+
) => NativeGatewayProxySynthesisResult | Promise<NativeGatewayProxySynthesisResult>;
|
|
137
186
|
|
|
138
187
|
export type NativeGatewayProxyResolutionInput = {
|
|
139
188
|
readonly policy: ProviderProxyPolicy;
|
|
140
189
|
readonly affinityKey?: string;
|
|
141
190
|
readonly now?: number;
|
|
191
|
+
readonly protocol?: ProxyProtocol;
|
|
192
|
+
readonly credentials?: VendorCredentialResolver;
|
|
142
193
|
readonly gatewaySynthesizers?: readonly NativeGatewayProxySynthesizer[];
|
|
143
194
|
};
|
|
144
195
|
|
|
@@ -147,6 +198,10 @@ export type NativeNetworkClientOptions = {
|
|
|
147
198
|
readonly affinityKey?: string;
|
|
148
199
|
/** Stable credential/account identity; hashed before vendor synthesis. */
|
|
149
200
|
readonly credentialIdentity?: string;
|
|
201
|
+
/** Vendor credential lookup; defaults to the process EnvContext. */
|
|
202
|
+
readonly credentials?: VendorCredentialResolver;
|
|
203
|
+
/** Explicit CONNECT/SOCKS5 override; vendors otherwise choose their default. */
|
|
204
|
+
readonly proxyProtocol?: ProxyProtocol;
|
|
150
205
|
/** Vendor adapters in priority order within each policy vendor slot. */
|
|
151
206
|
readonly gatewaySynthesizers?: readonly NativeGatewayProxySynthesizer[];
|
|
152
207
|
/** Warning-level lifecycle diagnostic sink. */
|
|
@@ -160,19 +215,74 @@ export type NativeNetworkClientOptions = {
|
|
|
160
215
|
readonly grantTcpEgress?: (input: NativeNetworkDynamicGrantOptions) => NativeNetworkEgressGrant;
|
|
161
216
|
};
|
|
162
217
|
|
|
163
|
-
|
|
218
|
+
const VENDOR_CREDENTIAL_NAMES: Readonly<Partial<Record<ProviderProxyProvider, readonly string[]>>> =
|
|
219
|
+
{
|
|
220
|
+
smartproxy: [SMARTPROXY_APP_KEY_ENV],
|
|
221
|
+
nodemaven: [NODEMAVEN_USERNAME_ENV, NODEMAVEN_PASSWORD_ENV],
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
/** Build a resolver over the SDK's existing injectable environment context. */
|
|
225
|
+
export function createEnvVendorCredentialResolver(
|
|
226
|
+
env: EnvContext = createEnvContext(),
|
|
227
|
+
): VendorCredentialResolver {
|
|
228
|
+
return (vendor) => {
|
|
229
|
+
const names = VENDOR_CREDENTIAL_NAMES[vendor] ?? [];
|
|
230
|
+
const values: Record<string, string> = {};
|
|
231
|
+
const missing: string[] = [];
|
|
232
|
+
for (const name of names) {
|
|
233
|
+
const value = env.get(name)?.trim();
|
|
234
|
+
if (value) values[name] = value;
|
|
235
|
+
else missing.push(name);
|
|
236
|
+
}
|
|
237
|
+
if (missing.length > 0 || names.length === 0) return { kind: "absent", missing };
|
|
238
|
+
if (vendor === "nodemaven") {
|
|
239
|
+
const filter = env.get(NODEMAVEN_FILTER_ENV)?.trim();
|
|
240
|
+
if (filter) values[NODEMAVEN_FILTER_ENV] = filter;
|
|
241
|
+
}
|
|
242
|
+
return { kind: "present", values };
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function skipped(reason: NativeGatewayProxySkipReason): NativeGatewayProxySynthesisResult {
|
|
247
|
+
return { kind: "skipped", reason };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function lookupCredentials(
|
|
164
251
|
input: NativeGatewayProxySynthesisInput,
|
|
165
|
-
):
|
|
166
|
-
|
|
252
|
+
): VendorCredentialLookup | { readonly kind: "error"; readonly cause: Error } {
|
|
253
|
+
try {
|
|
254
|
+
return input.credentials(input.vendor);
|
|
255
|
+
} catch (error) {
|
|
256
|
+
return {
|
|
257
|
+
kind: "error",
|
|
258
|
+
cause: error instanceof Error ? error : new Error(String(error)),
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
}
|
|
167
262
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
263
|
+
function synthesizeNodemavenGateway(
|
|
264
|
+
input: NativeGatewayProxySynthesisInput,
|
|
265
|
+
): NativeGatewayProxySynthesisResult {
|
|
266
|
+
if (input.vendor !== "nodemaven") return undefined;
|
|
267
|
+
const lookup = lookupCredentials(input);
|
|
268
|
+
if (lookup.kind === "error") {
|
|
269
|
+
return skipped({ kind: "credential_lookup_failed", cause: lookup.cause });
|
|
270
|
+
}
|
|
271
|
+
if (lookup.kind === "absent") {
|
|
272
|
+
return skipped({ kind: "credentials_absent", missing: lookup.missing });
|
|
273
|
+
}
|
|
171
274
|
const sessionWindow = nodemavenSessionWindow(input.policy, input.now);
|
|
172
275
|
const synthesized = synthesizeNodemavenProxy({
|
|
173
276
|
policy: input.policy,
|
|
277
|
+
credentials: {
|
|
278
|
+
username: lookup.values[NODEMAVEN_USERNAME_ENV] ?? "",
|
|
279
|
+
password: lookup.values[NODEMAVEN_PASSWORD_ENV] ?? "",
|
|
280
|
+
...(lookup.values[NODEMAVEN_FILTER_ENV]
|
|
281
|
+
? { filter: lookup.values[NODEMAVEN_FILTER_ENV] }
|
|
282
|
+
: {}),
|
|
283
|
+
},
|
|
174
284
|
affinityKey: input.affinityKey,
|
|
175
|
-
protocol:
|
|
285
|
+
protocol: input.protocol,
|
|
176
286
|
poolIndex: 0,
|
|
177
287
|
refreshEpoch: sessionWindow.refreshEpoch,
|
|
178
288
|
now: input.now,
|
|
@@ -187,7 +297,54 @@ function synthesizeNodemavenGateway(
|
|
|
187
297
|
};
|
|
188
298
|
}
|
|
189
299
|
|
|
300
|
+
async function synthesizeSmartproxyGateway(
|
|
301
|
+
input: NativeGatewayProxySynthesisInput,
|
|
302
|
+
): Promise<NativeGatewayProxySynthesisResult> {
|
|
303
|
+
if (input.vendor !== "smartproxy") return undefined;
|
|
304
|
+
const lookup = lookupCredentials(input);
|
|
305
|
+
if (lookup.kind === "error") {
|
|
306
|
+
return skipped({ kind: "credential_lookup_failed", cause: lookup.cause });
|
|
307
|
+
}
|
|
308
|
+
if (lookup.kind === "absent") {
|
|
309
|
+
return skipped({ kind: "credentials_absent", missing: lookup.missing });
|
|
310
|
+
}
|
|
311
|
+
try {
|
|
312
|
+
const resolved = await resolveWithVendor(
|
|
313
|
+
"smartproxy",
|
|
314
|
+
input.policy,
|
|
315
|
+
{
|
|
316
|
+
proxyPolicy: input.policy,
|
|
317
|
+
affinityKey: input.affinityKey,
|
|
318
|
+
protocol: input.protocol,
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
protocol: input.protocol,
|
|
322
|
+
poolIndex: 0,
|
|
323
|
+
refreshEpoch: 0,
|
|
324
|
+
credentials: lookup.values,
|
|
325
|
+
ambientDefaults: false,
|
|
326
|
+
sharedCache: false,
|
|
327
|
+
},
|
|
328
|
+
);
|
|
329
|
+
if (!resolved.url) {
|
|
330
|
+
return skipped({ kind: "allocation_failed", cause: new Error("No endpoint returned") });
|
|
331
|
+
}
|
|
332
|
+
return {
|
|
333
|
+
url: resolved.url,
|
|
334
|
+
vendor: "smartproxy",
|
|
335
|
+
sticky: isStickyPolicy(input.policy),
|
|
336
|
+
};
|
|
337
|
+
} catch (error) {
|
|
338
|
+
const cause = error instanceof Error ? error : new Error(String(error));
|
|
339
|
+
return skipped({
|
|
340
|
+
kind: "allocation_failed",
|
|
341
|
+
cause: redactSensitiveError(cause, Object.values(lookup.values)),
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
190
346
|
const DEFAULT_GATEWAY_SYNTHESIZERS: readonly NativeGatewayProxySynthesizer[] = [
|
|
347
|
+
synthesizeSmartproxyGateway,
|
|
191
348
|
synthesizeNodemavenGateway,
|
|
192
349
|
];
|
|
193
350
|
|
|
@@ -216,35 +373,135 @@ function resolveNativeVendorChain(policy: ProviderProxyPolicy): ProviderProxyPro
|
|
|
216
373
|
return chain;
|
|
217
374
|
}
|
|
218
375
|
|
|
219
|
-
|
|
220
|
-
|
|
376
|
+
type NativeGatewayVendorSkip = {
|
|
377
|
+
readonly vendor: ProviderProxyProvider;
|
|
378
|
+
readonly reason: NativeGatewayProxySkipReason | { readonly kind: "adapter_unavailable" };
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
function isProxyProtocol(value: unknown): value is ProxyProtocol {
|
|
382
|
+
return value === "http" || value === "socks5";
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function isSkippedSynthesis(
|
|
386
|
+
result: Exclude<NativeGatewayProxySynthesisResult, undefined>,
|
|
387
|
+
): result is { readonly kind: "skipped"; readonly reason: NativeGatewayProxySkipReason } {
|
|
388
|
+
return "kind" in result && result.kind === "skipped";
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function sanitizeVendorResolutionCause(
|
|
392
|
+
error: unknown,
|
|
393
|
+
vendor: ProviderProxyProvider,
|
|
394
|
+
credentials: VendorCredentialResolver,
|
|
395
|
+
): Error {
|
|
396
|
+
const cause = error instanceof Error ? error : new Error(String(error));
|
|
397
|
+
try {
|
|
398
|
+
const lookup = credentials(vendor);
|
|
399
|
+
return lookup.kind === "present"
|
|
400
|
+
? redactSensitiveError(cause, Object.values(lookup.values))
|
|
401
|
+
: cause;
|
|
402
|
+
} catch {
|
|
403
|
+
return cause;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function sanitizeVendorSkipReason(
|
|
408
|
+
reason: NativeGatewayProxySkipReason,
|
|
409
|
+
vendor: ProviderProxyProvider,
|
|
410
|
+
credentials: VendorCredentialResolver,
|
|
411
|
+
): NativeGatewayProxySkipReason {
|
|
412
|
+
return reason.kind === "allocation_failed" || reason.kind === "credential_lookup_failed"
|
|
413
|
+
? {
|
|
414
|
+
...reason,
|
|
415
|
+
cause: sanitizeVendorResolutionCause(reason.cause, vendor, credentials),
|
|
416
|
+
}
|
|
417
|
+
: reason;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function defaultVendorProtocol(vendor: ProviderProxyProvider): ProxyProtocol {
|
|
421
|
+
return vendor === "smartproxy" || vendor === "nodemaven"
|
|
422
|
+
? VENDOR_DEFAULT_PROTOCOL[vendor]
|
|
423
|
+
: "http";
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async function resolveNativeGatewayProxyDetailed(
|
|
221
427
|
input: NativeGatewayProxyResolutionInput,
|
|
222
|
-
): NativeGatewayProxy
|
|
223
|
-
if (input.policy.mode === "disabled") return
|
|
428
|
+
): Promise<{ proxy?: NativeGatewayProxy; skips: readonly NativeGatewayVendorSkip[] }> {
|
|
429
|
+
if (input.policy.mode === "disabled") return { skips: [] };
|
|
224
430
|
const synthesizers = input.gatewaySynthesizers ?? DEFAULT_GATEWAY_SYNTHESIZERS;
|
|
431
|
+
const credentials = input.credentials ?? createEnvVendorCredentialResolver();
|
|
225
432
|
const now = input.now ?? Date.now();
|
|
433
|
+
const skips: NativeGatewayVendorSkip[] = [];
|
|
226
434
|
for (const vendor of resolveNativeVendorChain(input.policy)) {
|
|
435
|
+
const protocol = input.protocol ?? defaultVendorProtocol(vendor);
|
|
436
|
+
if (!isProxyProtocol(protocol)) {
|
|
437
|
+
skips.push({ vendor, reason: { kind: "protocol_unsupported", protocol: String(protocol) } });
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
let vendorSkip: NativeGatewayVendorSkip["reason"] | undefined;
|
|
227
441
|
for (const synthesize of synthesizers) {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
442
|
+
try {
|
|
443
|
+
const resolved = await synthesize({
|
|
444
|
+
vendor,
|
|
445
|
+
policy: input.policy,
|
|
446
|
+
affinityKey: input.affinityKey,
|
|
447
|
+
now,
|
|
448
|
+
protocol,
|
|
449
|
+
credentials,
|
|
450
|
+
});
|
|
451
|
+
if (!resolved) continue;
|
|
452
|
+
if (isSkippedSynthesis(resolved)) {
|
|
453
|
+
vendorSkip = sanitizeVendorSkipReason(resolved.reason, vendor, credentials);
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
if (resolved.vendor === vendor) {
|
|
457
|
+
assertTunnelingScheme(resolved.url);
|
|
458
|
+
return { proxy: resolved, skips };
|
|
459
|
+
}
|
|
460
|
+
} catch (error) {
|
|
461
|
+
vendorSkip = {
|
|
462
|
+
kind: "allocation_failed",
|
|
463
|
+
cause: sanitizeVendorResolutionCause(error, vendor, credentials),
|
|
464
|
+
};
|
|
465
|
+
}
|
|
235
466
|
}
|
|
467
|
+
skips.push({ vendor, reason: vendorSkip ?? { kind: "adapter_unavailable" } });
|
|
468
|
+
}
|
|
469
|
+
return { skips };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Resolve the first configured native gateway, including allocation vendors. */
|
|
473
|
+
export async function resolveNativeGatewayProxy(
|
|
474
|
+
input: NativeGatewayProxyResolutionInput,
|
|
475
|
+
): Promise<NativeGatewayProxy | undefined> {
|
|
476
|
+
return (await resolveNativeGatewayProxyDetailed(input)).proxy;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function formatVendorSkip(skip: NativeGatewayVendorSkip): string {
|
|
480
|
+
switch (skip.reason.kind) {
|
|
481
|
+
case "credentials_absent":
|
|
482
|
+
return `${skip.vendor}: credentials absent (missing ${skip.reason.missing.join(", ") || "unspecified variables"})`;
|
|
483
|
+
case "protocol_unsupported":
|
|
484
|
+
return `${skip.vendor}: protocol ${skip.reason.protocol} is unsupported`;
|
|
485
|
+
case "allocation_failed":
|
|
486
|
+
return `${skip.vendor}: allocation failed (${skip.reason.cause.message})`;
|
|
487
|
+
case "credential_lookup_failed":
|
|
488
|
+
return `${skip.vendor}: credential lookup failed (${skip.reason.cause.message})`;
|
|
489
|
+
case "adapter_unavailable":
|
|
490
|
+
return `${skip.vendor}: no native adapter is registered`;
|
|
236
491
|
}
|
|
237
|
-
return undefined;
|
|
238
492
|
}
|
|
239
493
|
|
|
240
|
-
function proxyRequiredError(
|
|
494
|
+
function proxyRequiredError(
|
|
495
|
+
policy: ProviderProxyPolicy,
|
|
496
|
+
skips: readonly NativeGatewayVendorSkip[],
|
|
497
|
+
): ProxyResolutionError {
|
|
241
498
|
const chain = resolveNativeVendorChain(policy).filter(
|
|
242
499
|
(vendor): vendor is "smartproxy" | "nodemaven" =>
|
|
243
500
|
vendor === "smartproxy" || vendor === "nodemaven",
|
|
244
501
|
);
|
|
245
502
|
return new ProxyResolutionError(
|
|
246
503
|
"PROXY_REQUIRED",
|
|
247
|
-
`Native proxy egress is required but
|
|
504
|
+
`Native proxy egress is required but the vendor chain was exhausted: ${skips.map(formatVendorSkip).join("; ") || "no vendors declared"}.`,
|
|
248
505
|
{ vendorChain: chain },
|
|
249
506
|
);
|
|
250
507
|
}
|
|
@@ -268,8 +525,8 @@ function timeoutError(): NativeNetworkError {
|
|
|
268
525
|
return new NativeNetworkError("Native connection timed out", "native_connection_timeout");
|
|
269
526
|
}
|
|
270
527
|
|
|
271
|
-
function failedError(): NativeNetworkError {
|
|
272
|
-
return new NativeNetworkError("Native connection failed", "native_connection_failed");
|
|
528
|
+
function failedError(cause?: Error): NativeNetworkError {
|
|
529
|
+
return new NativeNetworkError("Native connection failed", "native_connection_failed", cause);
|
|
273
530
|
}
|
|
274
531
|
|
|
275
532
|
function assertCanStart(signal: AbortSignal | undefined, deadline: Deadline): void {
|
|
@@ -302,7 +559,7 @@ async function waitForSocketEvent(
|
|
|
302
559
|
} else resolve();
|
|
303
560
|
};
|
|
304
561
|
const onReady = () => finish();
|
|
305
|
-
const onError = () => finish(failedError());
|
|
562
|
+
const onError = (cause: Error) => finish(failedError(cause));
|
|
306
563
|
const onClose = () => finish(failedError());
|
|
307
564
|
const onAbort = () => finish(abortError());
|
|
308
565
|
|
|
@@ -357,11 +614,129 @@ function parseSocks5Proxy(proxyUrl: string): {
|
|
|
357
614
|
}
|
|
358
615
|
}
|
|
359
616
|
|
|
617
|
+
function parseHttpConnectProxy(proxyUrl: string): {
|
|
618
|
+
host: string;
|
|
619
|
+
port: number;
|
|
620
|
+
userId?: string;
|
|
621
|
+
password?: string;
|
|
622
|
+
} {
|
|
623
|
+
let parsed: URL;
|
|
624
|
+
try {
|
|
625
|
+
parsed = new URL(proxyUrl);
|
|
626
|
+
} catch {
|
|
627
|
+
throw new NativeNetworkError("Native proxy URL is invalid", "native_proxy_invalid");
|
|
628
|
+
}
|
|
629
|
+
const port = Number(parsed.port || "80");
|
|
630
|
+
if (
|
|
631
|
+
parsed.protocol !== "http:" ||
|
|
632
|
+
!parsed.hostname ||
|
|
633
|
+
!Number.isInteger(port) ||
|
|
634
|
+
port <= 0 ||
|
|
635
|
+
parsed.pathname !== "/" ||
|
|
636
|
+
parsed.search ||
|
|
637
|
+
parsed.hash
|
|
638
|
+
) {
|
|
639
|
+
throw new NativeNetworkError("Native proxy URL is invalid", "native_proxy_invalid");
|
|
640
|
+
}
|
|
641
|
+
try {
|
|
642
|
+
return {
|
|
643
|
+
host: parsed.hostname,
|
|
644
|
+
port,
|
|
645
|
+
...(parsed.username ? { userId: decodeURIComponent(parsed.username) } : {}),
|
|
646
|
+
...(parsed.password ? { password: decodeURIComponent(parsed.password) } : {}),
|
|
647
|
+
};
|
|
648
|
+
} catch {
|
|
649
|
+
throw new NativeNetworkError("Native proxy URL is invalid", "native_proxy_invalid");
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const SOCKS5_REPLY_CODES = {
|
|
654
|
+
Failure: 0x01,
|
|
655
|
+
NotAllowed: 0x02,
|
|
656
|
+
NetworkUnreachable: 0x03,
|
|
657
|
+
HostUnreachable: 0x04,
|
|
658
|
+
ConnectionRefused: 0x05,
|
|
659
|
+
TTLExpired: 0x06,
|
|
660
|
+
CommandNotSupported: 0x07,
|
|
661
|
+
AddressNotSupported: 0x08,
|
|
662
|
+
} as const;
|
|
663
|
+
|
|
664
|
+
function socks5ReplyCode(error: Error): number | undefined {
|
|
665
|
+
const match = /Socks5 proxy rejected connection - ([A-Za-z]+)/i.exec(error.message);
|
|
666
|
+
if (!match?.[1]) return undefined;
|
|
667
|
+
const reply = Object.entries(SOCKS5_REPLY_CODES).find(
|
|
668
|
+
([name]) => name.toLowerCase() === match[1]?.toLowerCase(),
|
|
669
|
+
);
|
|
670
|
+
return reply?.[1];
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function sanitizeProxyFailureCause(
|
|
674
|
+
error: Error,
|
|
675
|
+
proxyUrl: string,
|
|
676
|
+
credentials: { readonly userId?: string; readonly password?: string },
|
|
677
|
+
): Error {
|
|
678
|
+
// socks' SocksClientError retains the live socket in options. It is neither
|
|
679
|
+
// useful diagnostic payload nor serializable, so preserve the original error
|
|
680
|
+
// while replacing only that options object with a socket-free snapshot.
|
|
681
|
+
const options = Reflect.get(error, "options");
|
|
682
|
+
if (options && typeof options === "object" && !Array.isArray(options)) {
|
|
683
|
+
const snapshot = { ...(options as Record<string, unknown>) };
|
|
684
|
+
delete snapshot.existing_socket;
|
|
685
|
+
try {
|
|
686
|
+
Reflect.set(error, "options", snapshot);
|
|
687
|
+
} catch {
|
|
688
|
+
// The recursive redactor below clones readonly diagnostics safely.
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const replyCode = socks5ReplyCode(error);
|
|
693
|
+
if (replyCode !== undefined) {
|
|
694
|
+
try {
|
|
695
|
+
Object.defineProperty(error, "socks5ReplyCode", {
|
|
696
|
+
value: replyCode,
|
|
697
|
+
configurable: true,
|
|
698
|
+
enumerable: true,
|
|
699
|
+
writable: false,
|
|
700
|
+
});
|
|
701
|
+
} catch {
|
|
702
|
+
// The reply label remains in message if an exotic error is immutable.
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
let redactedProxyUrl = proxyUrl;
|
|
707
|
+
try {
|
|
708
|
+
const parsed = new URL(proxyUrl);
|
|
709
|
+
parsed.username = "[REDACTED]";
|
|
710
|
+
parsed.password = "[REDACTED]";
|
|
711
|
+
redactedProxyUrl = parsed.toString();
|
|
712
|
+
} catch {
|
|
713
|
+
// parseSocks5Proxy already validated this URL; keep a defensive fallback.
|
|
714
|
+
}
|
|
715
|
+
return redactSensitiveError(
|
|
716
|
+
error,
|
|
717
|
+
[
|
|
718
|
+
credentials.userId,
|
|
719
|
+
credentials.password,
|
|
720
|
+
credentials.userId !== undefined || credentials.password !== undefined
|
|
721
|
+
? `${credentials.userId ?? ""}:${credentials.password ?? ""}`
|
|
722
|
+
: undefined,
|
|
723
|
+
credentials.userId !== undefined || credentials.password !== undefined
|
|
724
|
+
? Buffer.from(`${credentials.userId ?? ""}:${credentials.password ?? ""}`).toString(
|
|
725
|
+
"base64",
|
|
726
|
+
)
|
|
727
|
+
: undefined,
|
|
728
|
+
].filter((value): value is string => typeof value === "string" && value.length > 0),
|
|
729
|
+
proxyUrl,
|
|
730
|
+
redactedProxyUrl,
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
|
|
360
734
|
async function waitForSocksHandshake(
|
|
361
735
|
proxySocket: Socket,
|
|
362
736
|
promise: ReturnType<typeof SocksClient.createConnection>,
|
|
363
737
|
signal: AbortSignal | undefined,
|
|
364
738
|
deadline: Deadline,
|
|
739
|
+
sanitizeFailure: (error: Error) => Error,
|
|
365
740
|
): Promise<Socket> {
|
|
366
741
|
assertCanStart(signal, deadline);
|
|
367
742
|
return await new Promise<Socket>((resolve, reject) => {
|
|
@@ -391,7 +766,9 @@ async function waitForSocksHandshake(
|
|
|
391
766
|
finish(
|
|
392
767
|
error instanceof Error && /\b(?:timed out|timeout)\b/i.test(error.message)
|
|
393
768
|
? timeoutError()
|
|
394
|
-
: failedError(
|
|
769
|
+
: failedError(
|
|
770
|
+
sanitizeFailure(error instanceof Error ? error : new Error(String(error))),
|
|
771
|
+
),
|
|
395
772
|
),
|
|
396
773
|
);
|
|
397
774
|
});
|
|
@@ -423,7 +800,138 @@ async function connectSocksTunnel(
|
|
|
423
800
|
existing_socket: proxySocket,
|
|
424
801
|
...(remaining === undefined ? {} : { timeout: Math.max(1, remaining) }),
|
|
425
802
|
});
|
|
426
|
-
return await waitForSocksHandshake(proxySocket, handshake, input.signal, deadline)
|
|
803
|
+
return await waitForSocksHandshake(proxySocket, handshake, input.signal, deadline, (error) =>
|
|
804
|
+
sanitizeProxyFailureCause(error, proxy.url, parsed),
|
|
805
|
+
);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function connectAuthority(host: string, port: number): string {
|
|
809
|
+
return `${host.includes(":") && !host.startsWith("[") ? `[${host}]` : host}:${port}`;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function connectStatusError(statusLine: string, statusCode?: number): Error {
|
|
813
|
+
const error = new Error(
|
|
814
|
+
`HTTP CONNECT proxy rejected tunnel: ${statusLine || "invalid response"}`,
|
|
815
|
+
);
|
|
816
|
+
Object.defineProperties(error, {
|
|
817
|
+
connectStatusLine: {
|
|
818
|
+
value: statusLine || "invalid response",
|
|
819
|
+
configurable: true,
|
|
820
|
+
enumerable: true,
|
|
821
|
+
},
|
|
822
|
+
...(statusCode === undefined
|
|
823
|
+
? {}
|
|
824
|
+
: {
|
|
825
|
+
connectStatusCode: {
|
|
826
|
+
value: statusCode,
|
|
827
|
+
configurable: true,
|
|
828
|
+
enumerable: true,
|
|
829
|
+
},
|
|
830
|
+
}),
|
|
831
|
+
});
|
|
832
|
+
return error;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
async function waitForConnectResponse(
|
|
836
|
+
proxySocket: Socket,
|
|
837
|
+
signal: AbortSignal | undefined,
|
|
838
|
+
deadline: Deadline,
|
|
839
|
+
sanitizeFailure: (error: Error) => Error,
|
|
840
|
+
): Promise<Socket> {
|
|
841
|
+
assertCanStart(signal, deadline);
|
|
842
|
+
return await new Promise<Socket>((resolve, reject) => {
|
|
843
|
+
let settled = false;
|
|
844
|
+
let buffered = Buffer.alloc(0);
|
|
845
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
846
|
+
const cleanup = () => {
|
|
847
|
+
if (timer) clearTimeout(timer);
|
|
848
|
+
proxySocket.off("data", onData);
|
|
849
|
+
proxySocket.off("error", onError);
|
|
850
|
+
proxySocket.off("close", onClose);
|
|
851
|
+
signal?.removeEventListener("abort", onAbort);
|
|
852
|
+
};
|
|
853
|
+
const finish = (error?: Error) => {
|
|
854
|
+
if (settled) return;
|
|
855
|
+
settled = true;
|
|
856
|
+
cleanup();
|
|
857
|
+
if (error) {
|
|
858
|
+
proxySocket.on("error", () => undefined);
|
|
859
|
+
proxySocket.destroy();
|
|
860
|
+
reject(error);
|
|
861
|
+
} else resolve(proxySocket);
|
|
862
|
+
};
|
|
863
|
+
const onError = (cause: Error) => finish(failedError(sanitizeFailure(cause)));
|
|
864
|
+
const onClose = () =>
|
|
865
|
+
finish(failedError(sanitizeFailure(new Error("HTTP CONNECT proxy closed before response"))));
|
|
866
|
+
const onAbort = () => finish(abortError());
|
|
867
|
+
const onData = (chunk: Buffer) => {
|
|
868
|
+
buffered = Buffer.concat([buffered, chunk]);
|
|
869
|
+
if (buffered.length > 64 * 1024) {
|
|
870
|
+
finish(failedError(sanitizeFailure(connectStatusError("response headers too large"))));
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
const headerEnd = buffered.indexOf("\r\n\r\n");
|
|
874
|
+
if (headerEnd < 0) return;
|
|
875
|
+
const header = buffered.subarray(0, headerEnd).toString("latin1");
|
|
876
|
+
const statusLine = header.split("\r\n", 1)[0] ?? "";
|
|
877
|
+
const match = /^HTTP\/1\.[01] ([0-9]{3})(?: |$)/.exec(statusLine);
|
|
878
|
+
const statusCode = match?.[1] ? Number(match[1]) : undefined;
|
|
879
|
+
if (statusCode === undefined || statusCode < 200 || statusCode >= 300) {
|
|
880
|
+
finish(failedError(sanitizeFailure(connectStatusError(statusLine, statusCode))));
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
const remaining = buffered.subarray(headerEnd + 4);
|
|
884
|
+
cleanup();
|
|
885
|
+
if (remaining.length > 0) proxySocket.unshift(remaining);
|
|
886
|
+
finish();
|
|
887
|
+
};
|
|
888
|
+
|
|
889
|
+
proxySocket.on("data", onData);
|
|
890
|
+
proxySocket.once("error", onError);
|
|
891
|
+
proxySocket.once("close", onClose);
|
|
892
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
893
|
+
const remaining = remainingMs(deadline);
|
|
894
|
+
if (remaining !== undefined) timer = setTimeout(() => finish(timeoutError()), remaining);
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
async function connectHttpTunnel(
|
|
899
|
+
proxy: NativeGatewayProxy,
|
|
900
|
+
input: NativeNetworkConnectInput,
|
|
901
|
+
deadline: Deadline,
|
|
902
|
+
beforeDestinationConnect: () => void,
|
|
903
|
+
): Promise<Socket> {
|
|
904
|
+
const parsed = parseHttpConnectProxy(proxy.url);
|
|
905
|
+
const proxySocket = await connectPlainSocket(parsed.host, parsed.port, input.signal, deadline);
|
|
906
|
+
const sanitizeFailure = (error: Error) => sanitizeProxyFailureCause(error, proxy.url, parsed);
|
|
907
|
+
try {
|
|
908
|
+
beforeDestinationConnect();
|
|
909
|
+
} catch (error) {
|
|
910
|
+
proxySocket.destroy();
|
|
911
|
+
throw error;
|
|
912
|
+
}
|
|
913
|
+
const authority = connectAuthority(input.host, input.port);
|
|
914
|
+
const authorization =
|
|
915
|
+
parsed.userId !== undefined || parsed.password !== undefined
|
|
916
|
+
? `Proxy-Authorization: Basic ${Buffer.from(`${parsed.userId ?? ""}:${parsed.password ?? ""}`).toString("base64")}\r\n`
|
|
917
|
+
: "";
|
|
918
|
+
const response = waitForConnectResponse(proxySocket, input.signal, deadline, sanitizeFailure);
|
|
919
|
+
proxySocket.write(
|
|
920
|
+
`CONNECT ${authority} HTTP/1.1\r\nHost: ${authority}\r\n${authorization}Proxy-Connection: Keep-Alive\r\n\r\n`,
|
|
921
|
+
);
|
|
922
|
+
return await response;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
async function connectProxyTunnel(
|
|
926
|
+
proxy: NativeGatewayProxy,
|
|
927
|
+
input: NativeNetworkConnectInput,
|
|
928
|
+
deadline: Deadline,
|
|
929
|
+
beforeDestinationConnect: () => void,
|
|
930
|
+
): Promise<Socket> {
|
|
931
|
+
assertTunnelingScheme(proxy.url);
|
|
932
|
+
return new URL(proxy.url).protocol === "http:"
|
|
933
|
+
? await connectHttpTunnel(proxy, input, deadline, beforeDestinationConnect)
|
|
934
|
+
: await connectSocksTunnel(proxy, input, deadline, beforeDestinationConnect);
|
|
427
935
|
}
|
|
428
936
|
|
|
429
937
|
async function upgradeTls(
|
|
@@ -473,12 +981,15 @@ export function createNativeNetworkConnection(
|
|
|
473
981
|
const resetIdleTimer = () => {
|
|
474
982
|
clearIdleTimer();
|
|
475
983
|
if (idleTimeoutMs === undefined || socket.readableEnded || socket.destroyed) return;
|
|
476
|
-
idleTimer = setTimeout(
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
984
|
+
idleTimer = setTimeout(
|
|
985
|
+
() => {
|
|
986
|
+
idleTimer = undefined;
|
|
987
|
+
if (socket.readableEnded || socket.destroyed) return;
|
|
988
|
+
closeReason = new NativeIdleTimeoutError();
|
|
989
|
+
socket.destroy(closeReason);
|
|
990
|
+
},
|
|
991
|
+
Math.max(0, idleTimeoutMs),
|
|
992
|
+
);
|
|
482
993
|
idleTimer.unref?.();
|
|
483
994
|
};
|
|
484
995
|
const clearLifecycle = () => {
|
|
@@ -541,7 +1052,7 @@ export function createNativeNetworkConnection(
|
|
|
541
1052
|
|
|
542
1053
|
const read = async (): Promise<Uint8Array | null> => {
|
|
543
1054
|
if (closeReason) throw closeReason;
|
|
544
|
-
if (terminalError) throw failedError();
|
|
1055
|
+
if (terminalError) throw failedError(terminalError);
|
|
545
1056
|
const chunk = socket.read() as Buffer | null;
|
|
546
1057
|
if (chunk) {
|
|
547
1058
|
resetIdleTimer();
|
|
@@ -596,7 +1107,7 @@ export function createNativeNetworkConnection(
|
|
|
596
1107
|
}
|
|
597
1108
|
await new Promise<void>((resolve, reject) => {
|
|
598
1109
|
socket.write(data, (error) => {
|
|
599
|
-
if (error) reject(failedError());
|
|
1110
|
+
if (error) reject(failedError(error));
|
|
600
1111
|
else resolve();
|
|
601
1112
|
});
|
|
602
1113
|
});
|
|
@@ -617,6 +1128,7 @@ export function createNativeNetworkConnection(
|
|
|
617
1128
|
async function resolveConnectionProxy(
|
|
618
1129
|
options: NativeNetworkClientOptions,
|
|
619
1130
|
input: NativeNetworkConnectInput,
|
|
1131
|
+
deadline: Deadline,
|
|
620
1132
|
): Promise<NativeGatewayProxy | undefined> {
|
|
621
1133
|
const policy = options.proxyPolicy;
|
|
622
1134
|
if (!policy || policy.mode === "disabled") return undefined;
|
|
@@ -626,13 +1138,54 @@ async function resolveConnectionProxy(
|
|
|
626
1138
|
(isStickyPolicy(policy) && options.credentialIdentity !== undefined
|
|
627
1139
|
? deriveNativeCredentialAffinityKey(options.credentialIdentity)
|
|
628
1140
|
: undefined);
|
|
629
|
-
const
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
1141
|
+
const resolution = await waitForProxyResolution(
|
|
1142
|
+
resolveNativeGatewayProxyDetailed({
|
|
1143
|
+
policy,
|
|
1144
|
+
affinityKey,
|
|
1145
|
+
protocol: options.proxyProtocol,
|
|
1146
|
+
credentials: options.credentials,
|
|
1147
|
+
gatewaySynthesizers: options.gatewaySynthesizers,
|
|
1148
|
+
}),
|
|
1149
|
+
input.signal,
|
|
1150
|
+
deadline,
|
|
1151
|
+
);
|
|
1152
|
+
if (!resolution.proxy && policy.mode === "required") {
|
|
1153
|
+
throw proxyRequiredError(policy, resolution.skips);
|
|
1154
|
+
}
|
|
1155
|
+
return resolution.proxy;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
async function waitForProxyResolution<T>(
|
|
1159
|
+
promise: Promise<T>,
|
|
1160
|
+
signal: AbortSignal | undefined,
|
|
1161
|
+
deadline: Deadline,
|
|
1162
|
+
): Promise<T> {
|
|
1163
|
+
assertCanStart(signal, deadline);
|
|
1164
|
+
return await new Promise<T>((resolve, reject) => {
|
|
1165
|
+
let settled = false;
|
|
1166
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
1167
|
+
const cleanup = () => {
|
|
1168
|
+
if (timer) clearTimeout(timer);
|
|
1169
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1170
|
+
};
|
|
1171
|
+
const finish = (value?: T, error?: unknown) => {
|
|
1172
|
+
if (settled) return;
|
|
1173
|
+
settled = true;
|
|
1174
|
+
cleanup();
|
|
1175
|
+
if (error !== undefined) reject(error);
|
|
1176
|
+
else resolve(value as T);
|
|
1177
|
+
};
|
|
1178
|
+
const onAbort = () => finish(undefined, abortError());
|
|
1179
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1180
|
+
const remaining = remainingMs(deadline);
|
|
1181
|
+
if (remaining !== undefined) {
|
|
1182
|
+
timer = setTimeout(() => finish(undefined, timeoutError()), remaining);
|
|
1183
|
+
}
|
|
1184
|
+
void promise.then(
|
|
1185
|
+
(value) => finish(value),
|
|
1186
|
+
(error) => finish(undefined, error),
|
|
1187
|
+
);
|
|
633
1188
|
});
|
|
634
|
-
if (!resolved && policy.mode === "required") throw proxyRequiredError(policy);
|
|
635
|
-
return resolved;
|
|
636
1189
|
}
|
|
637
1190
|
|
|
638
1191
|
type NativeConnectTls = "required" | "disabled";
|
|
@@ -648,10 +1201,6 @@ type StoredEgressGrant = {
|
|
|
648
1201
|
|
|
649
1202
|
export const NATIVE_EGRESS_EXPIRED_EVIDENCE_LIMIT = 256;
|
|
650
1203
|
|
|
651
|
-
function normalizeEgressHost(host: string): string {
|
|
652
|
-
return host.trim().toLowerCase().replace(/\.$/, "");
|
|
653
|
-
}
|
|
654
|
-
|
|
655
1204
|
function invalidPolicy(message: string): NativeNetworkError {
|
|
656
1205
|
return new NativeNetworkError(message, "native_egress_policy_invalid");
|
|
657
1206
|
}
|
|
@@ -694,43 +1243,45 @@ function matchesDynamicRuleSelectors(
|
|
|
694
1243
|
rule: DynamicEgressRuleSnapshot,
|
|
695
1244
|
input: NativeNetworkDynamicGrantOptions,
|
|
696
1245
|
): boolean {
|
|
697
|
-
const sourceHost = normalizeEgressHost(input.sourceHost);
|
|
698
|
-
const targetHost = normalizeEgressHost(input.host);
|
|
699
1246
|
return (
|
|
700
|
-
matchesSourceHost(rule, sourceHost) &&
|
|
1247
|
+
matchesSourceHost(rule, input.sourceHost) &&
|
|
701
1248
|
matchesPortSelectors(input.sourcePort, rule.sourcePorts, rule.sourcePortRanges) &&
|
|
702
|
-
rule
|
|
1249
|
+
matchesDynamicTargetHost(rule, input.host) &&
|
|
703
1250
|
matchesPortSelectors(input.port, rule.targetPorts, rule.targetPortRanges) &&
|
|
704
1251
|
grantTlsFitsRule(input.tls, rule.tls)
|
|
705
1252
|
);
|
|
706
1253
|
}
|
|
707
1254
|
|
|
1255
|
+
function matchesDynamicTargetHost(rule: DynamicEgressRuleSnapshot, targetHost: string): boolean {
|
|
1256
|
+
const targetKind = classifyEgressTargetHost(targetHost);
|
|
1257
|
+
if (targetKind === "ipv4") {
|
|
1258
|
+
const targetIp = parseStrictIpv4(targetHost);
|
|
1259
|
+
return (
|
|
1260
|
+
targetIp !== undefined && rule.targetIpv4Cidrs.some((cidr) => ipv4InCidr(targetIp, cidr))
|
|
1261
|
+
);
|
|
1262
|
+
}
|
|
1263
|
+
return (
|
|
1264
|
+
targetKind === "dns" &&
|
|
1265
|
+
rule.targetHostSuffixes.some((suffix) => matchesDnsSuffix(targetHost, suffix))
|
|
1266
|
+
);
|
|
1267
|
+
}
|
|
1268
|
+
|
|
708
1269
|
function invalidGrant(message: string): NativeNetworkError {
|
|
709
1270
|
return new NativeNetworkError(message, "native_egress_grant_invalid");
|
|
710
1271
|
}
|
|
711
1272
|
|
|
712
|
-
function
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
1273
|
+
function canonicalGrantHost(value: unknown): string {
|
|
1274
|
+
if (typeof value === "string" && value.includes("*"))
|
|
1275
|
+
throw invalidGrant("Native TCP egress grant hosts must be exact non-empty hostnames");
|
|
1276
|
+
const canonical = canonicalizeEgressHost(value);
|
|
1277
|
+
if (!canonical.ok)
|
|
1278
|
+
throw invalidGrant("Native TCP egress grant hosts must be exact non-empty hostnames");
|
|
1279
|
+
return canonical.host;
|
|
718
1280
|
}
|
|
719
1281
|
|
|
720
1282
|
function assertValidGrantInput(input: NativeNetworkDynamicGrantOptions): void {
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
!normalizeEgressHost(input.host) ||
|
|
724
|
-
hasControlCharacter(input.sourceHost) ||
|
|
725
|
-
hasControlCharacter(input.host) ||
|
|
726
|
-
/\s/.test(input.sourceHost) ||
|
|
727
|
-
/\s/.test(input.host) ||
|
|
728
|
-
input.sourceHost.includes("://") ||
|
|
729
|
-
input.host.includes("://") ||
|
|
730
|
-
input.sourceHost.includes("*") ||
|
|
731
|
-
input.host.includes("*")
|
|
732
|
-
)
|
|
733
|
-
throw invalidGrant("Native TCP egress grant hosts must be exact non-empty hostnames");
|
|
1283
|
+
canonicalGrantHost(input.sourceHost);
|
|
1284
|
+
canonicalGrantHost(input.host);
|
|
734
1285
|
if (
|
|
735
1286
|
!Number.isSafeInteger(input.sourcePort) ||
|
|
736
1287
|
input.sourcePort < 1 ||
|
|
@@ -746,51 +1297,65 @@ function assertValidGrantInput(input: NativeNetworkDynamicGrantOptions): void {
|
|
|
746
1297
|
throw invalidGrant("Native TCP egress grant ttlMs must be a positive integer");
|
|
747
1298
|
}
|
|
748
1299
|
|
|
749
|
-
|
|
750
|
-
|
|
1300
|
+
type NativeConnectInputRejectionReason =
|
|
1301
|
+
| EgressHostCanonicalizationFailure
|
|
1302
|
+
| "port-range"
|
|
1303
|
+
| "inspection-failure";
|
|
1304
|
+
|
|
1305
|
+
function invalidNativeConnectInput(
|
|
1306
|
+
field: keyof NativeNetworkConnectInput,
|
|
1307
|
+
reason: NativeConnectInputRejectionReason,
|
|
1308
|
+
): NativeNetworkError {
|
|
1309
|
+
return new NativeNetworkError(
|
|
1310
|
+
`Native connection input rejected: field=${field}; reason=${reason}`,
|
|
1311
|
+
"native_egress_input_invalid",
|
|
1312
|
+
);
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
function inspectNativeConnectInputField<K extends keyof NativeNetworkConnectInput>(
|
|
751
1316
|
input: NativeNetworkConnectInput,
|
|
752
|
-
|
|
1317
|
+
field: K,
|
|
1318
|
+
): NativeNetworkConnectInput[K] {
|
|
753
1319
|
try {
|
|
754
|
-
|
|
755
|
-
const port = input.port;
|
|
756
|
-
const serverName = input.serverName;
|
|
757
|
-
const rejectUnauthorized = input.rejectUnauthorized;
|
|
758
|
-
const idleTimeoutMs = input.idleTimeoutMs;
|
|
759
|
-
const timeoutMs = input.timeoutMs;
|
|
760
|
-
const signal = input.signal;
|
|
761
|
-
const affinityKey = input.affinityKey;
|
|
762
|
-
const snapshot: NativeNetworkConnectInput = {
|
|
763
|
-
host,
|
|
764
|
-
port,
|
|
765
|
-
...(serverName === undefined ? {} : { serverName }),
|
|
766
|
-
...(rejectUnauthorized === undefined ? {} : { rejectUnauthorized }),
|
|
767
|
-
...(idleTimeoutMs === undefined ? {} : { idleTimeoutMs }),
|
|
768
|
-
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
|
769
|
-
...(signal === undefined ? {} : { signal }),
|
|
770
|
-
...(affinityKey === undefined ? {} : { affinityKey }),
|
|
771
|
-
};
|
|
772
|
-
if (
|
|
773
|
-
typeof snapshot.host !== "string" ||
|
|
774
|
-
!snapshot.host.trim() ||
|
|
775
|
-
hasControlCharacter(snapshot.host) ||
|
|
776
|
-
!Number.isInteger(snapshot.port) ||
|
|
777
|
-
snapshot.port < 1 ||
|
|
778
|
-
snapshot.port > 65_535
|
|
779
|
-
)
|
|
780
|
-
throw new TypeError("invalid native connection target");
|
|
781
|
-
return snapshot;
|
|
1320
|
+
return input[field];
|
|
782
1321
|
} catch {
|
|
783
|
-
throw
|
|
784
|
-
"Native connection input could not be inspected safely",
|
|
785
|
-
"native_egress_input_invalid",
|
|
786
|
-
);
|
|
1322
|
+
throw invalidNativeConnectInput(field, "inspection-failure");
|
|
787
1323
|
}
|
|
788
1324
|
}
|
|
789
1325
|
|
|
1326
|
+
/** Internal canonical snapshot shared by production and SDK transport test doubles. */
|
|
1327
|
+
export function snapshotNativeConnectInput(
|
|
1328
|
+
input: NativeNetworkConnectInput,
|
|
1329
|
+
): NativeNetworkConnectInput {
|
|
1330
|
+
const host = inspectNativeConnectInputField(input, "host");
|
|
1331
|
+
const canonicalHost = canonicalizeEgressHost(host);
|
|
1332
|
+
if (!canonicalHost.ok) throw invalidNativeConnectInput("host", canonicalHost.reason);
|
|
1333
|
+
const port = inspectNativeConnectInputField(input, "port");
|
|
1334
|
+
if (!Number.isInteger(port) || port < 1 || port > 65_535)
|
|
1335
|
+
throw invalidNativeConnectInput("port", "port-range");
|
|
1336
|
+
const serverName = inspectNativeConnectInputField(input, "serverName");
|
|
1337
|
+
const rejectUnauthorized = inspectNativeConnectInputField(input, "rejectUnauthorized");
|
|
1338
|
+
const idleTimeoutMs = inspectNativeConnectInputField(input, "idleTimeoutMs");
|
|
1339
|
+
const timeoutMs = inspectNativeConnectInputField(input, "timeoutMs");
|
|
1340
|
+
const signal = inspectNativeConnectInputField(input, "signal");
|
|
1341
|
+
const affinityKey = inspectNativeConnectInputField(input, "affinityKey");
|
|
1342
|
+
return {
|
|
1343
|
+
host: canonicalHost.host,
|
|
1344
|
+
port,
|
|
1345
|
+
...(serverName === undefined ? {} : { serverName }),
|
|
1346
|
+
...(rejectUnauthorized === undefined ? {} : { rejectUnauthorized }),
|
|
1347
|
+
...(idleTimeoutMs === undefined ? {} : { idleTimeoutMs }),
|
|
1348
|
+
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
|
1349
|
+
...(signal === undefined ? {} : { signal }),
|
|
1350
|
+
...(affinityKey === undefined ? {} : { affinityKey }),
|
|
1351
|
+
};
|
|
1352
|
+
}
|
|
1353
|
+
|
|
790
1354
|
/** Internal canonical snapshot shared by production and SDK transport test doubles. */
|
|
791
1355
|
export function snapshotNativeGrantInput(
|
|
792
1356
|
input: NativeNetworkDynamicGrantOptions,
|
|
793
1357
|
): NativeNetworkDynamicGrantOptions {
|
|
1358
|
+
let snapshot: NativeNetworkDynamicGrantOptions;
|
|
794
1359
|
try {
|
|
795
1360
|
const sourceHost = input.sourceHost;
|
|
796
1361
|
const sourcePort = input.sourcePort;
|
|
@@ -798,7 +1363,7 @@ export function snapshotNativeGrantInput(
|
|
|
798
1363
|
const port = input.port;
|
|
799
1364
|
const tls = input.tls;
|
|
800
1365
|
const ttlMs = input.ttlMs;
|
|
801
|
-
|
|
1366
|
+
snapshot = {
|
|
802
1367
|
sourceHost,
|
|
803
1368
|
sourcePort,
|
|
804
1369
|
host,
|
|
@@ -812,6 +1377,12 @@ export function snapshotNativeGrantInput(
|
|
|
812
1377
|
"native_egress_input_invalid",
|
|
813
1378
|
);
|
|
814
1379
|
}
|
|
1380
|
+
assertValidGrantInput(snapshot);
|
|
1381
|
+
return {
|
|
1382
|
+
...snapshot,
|
|
1383
|
+
sourceHost: canonicalGrantHost(snapshot.sourceHost),
|
|
1384
|
+
host: canonicalGrantHost(snapshot.host),
|
|
1385
|
+
};
|
|
815
1386
|
}
|
|
816
1387
|
|
|
817
1388
|
/** Internal authorization seam shared by production and SDK transport test doubles. */
|
|
@@ -869,20 +1440,21 @@ export function createNativeEgressAuthorization(options: NativeNetworkClientOpti
|
|
|
869
1440
|
|
|
870
1441
|
const assertConnect = (input: NativeNetworkConnectInput, tls: NativeConnectTls): void => {
|
|
871
1442
|
if (!declared) return;
|
|
872
|
-
const host = normalizeEgressHost(input.host);
|
|
873
1443
|
const now = Date.now();
|
|
874
1444
|
purgeInactive(now);
|
|
875
1445
|
if (
|
|
876
1446
|
staticRules.some(
|
|
877
1447
|
(rule) =>
|
|
878
|
-
rule.host ===
|
|
1448
|
+
rule.host === input.host &&
|
|
1449
|
+
rule.ports.includes(input.port) &&
|
|
1450
|
+
tlsModeAllows(rule.tls, tls),
|
|
879
1451
|
)
|
|
880
1452
|
)
|
|
881
1453
|
return;
|
|
882
1454
|
const matching = grants.filter(
|
|
883
1455
|
(grant) =>
|
|
884
1456
|
!grant.revoked &&
|
|
885
|
-
grant.host === host &&
|
|
1457
|
+
grant.host === input.host &&
|
|
886
1458
|
grant.port === input.port &&
|
|
887
1459
|
tlsModeAllows(grant.tls, tls),
|
|
888
1460
|
);
|
|
@@ -890,9 +1462,7 @@ export function createNativeEgressAuthorization(options: NativeNetworkClientOpti
|
|
|
890
1462
|
const expired = [...expiredEvidence.values()]
|
|
891
1463
|
.filter(
|
|
892
1464
|
(grant) =>
|
|
893
|
-
grant.host === host &&
|
|
894
|
-
grant.port === input.port &&
|
|
895
|
-
tlsModeAllows(grant.tls, tls),
|
|
1465
|
+
grant.host === input.host && grant.port === input.port && tlsModeAllows(grant.tls, tls),
|
|
896
1466
|
)
|
|
897
1467
|
.sort((left, right) => (right.expiresAtMs ?? 0) - (left.expiresAtMs ?? 0))[0];
|
|
898
1468
|
if (expired?.expiresAtMs !== undefined)
|
|
@@ -908,11 +1478,45 @@ export function createNativeEgressAuthorization(options: NativeNetworkClientOpti
|
|
|
908
1478
|
const grantLocal = (input: NativeNetworkDynamicGrantOptions): NativeNetworkEgressGrant => {
|
|
909
1479
|
assertValidGrantInput(input);
|
|
910
1480
|
const ruleIndex = dynamicRules.findIndex((rule) => matchesDynamicRuleSelectors(rule, input));
|
|
911
|
-
if (ruleIndex < 0)
|
|
1481
|
+
if (ruleIndex < 0) {
|
|
1482
|
+
const diagnosticSourceHost = safeDiagnosticEgressHost(input.sourceHost);
|
|
1483
|
+
const diagnosticTargetHost = safeDiagnosticEgressHost(input.host);
|
|
1484
|
+
const sourceMatchingRuleIndices = dynamicRules.flatMap((rule, index) =>
|
|
1485
|
+
matchesSourceHost(rule, input.sourceHost) &&
|
|
1486
|
+
matchesPortSelectors(input.sourcePort, rule.sourcePorts, rule.sourcePortRanges)
|
|
1487
|
+
? [index]
|
|
1488
|
+
: [],
|
|
1489
|
+
);
|
|
1490
|
+
const targetKind = classifyEgressTargetHost(diagnosticTargetHost);
|
|
1491
|
+
let selectorDetails: string;
|
|
1492
|
+
if (sourceMatchingRuleIndices.length > 0) {
|
|
1493
|
+
const failedByRule: string[] = [];
|
|
1494
|
+
for (const index of sourceMatchingRuleIndices) {
|
|
1495
|
+
const rule = dynamicRules[index];
|
|
1496
|
+
if (!rule) continue;
|
|
1497
|
+
const failedDimensions: string[] = [];
|
|
1498
|
+
if (!matchesDynamicTargetHost(rule, input.host)) failedDimensions.push("target-host");
|
|
1499
|
+
if (!matchesPortSelectors(input.port, rule.targetPorts, rule.targetPortRanges))
|
|
1500
|
+
failedDimensions.push("target-port");
|
|
1501
|
+
if (!grantTlsFitsRule(input.tls, rule.tls)) failedDimensions.push("tls");
|
|
1502
|
+
failedByRule.push(`rule ${index}: ${failedDimensions.join(", ")}`);
|
|
1503
|
+
}
|
|
1504
|
+
selectorDetails = `source-matching rule indices: [${sourceMatchingRuleIndices.join(", ")}]; failed selector dimensions by rule: ${failedByRule.join("; ")}`;
|
|
1505
|
+
} else {
|
|
1506
|
+
const failedByRule = dynamicRules.map((rule, index) => {
|
|
1507
|
+
const failedDimensions: string[] = [];
|
|
1508
|
+
if (!matchesSourceHost(rule, input.sourceHost)) failedDimensions.push("source-host");
|
|
1509
|
+
if (!matchesPortSelectors(input.sourcePort, rule.sourcePorts, rule.sourcePortRanges))
|
|
1510
|
+
failedDimensions.push("source-port");
|
|
1511
|
+
return `rule ${index}: ${failedDimensions.join(", ")}`;
|
|
1512
|
+
});
|
|
1513
|
+
selectorDetails = `source-matching rule indices: []; failed selector dimensions by rule: ${failedByRule.join("; ")}`;
|
|
1514
|
+
}
|
|
912
1515
|
throw new NativeNetworkError(
|
|
913
|
-
`Native TCP egress grant is not declared for ${input.
|
|
1516
|
+
`Native TCP egress grant is not declared for source ${diagnosticSourceHost}:${input.sourcePort} to target ${diagnosticTargetHost}:${input.port} (${input.tls}); target kind: ${targetKind}; ${selectorDetails}`,
|
|
914
1517
|
"native_egress_not_declared",
|
|
915
1518
|
);
|
|
1519
|
+
}
|
|
916
1520
|
const rule = dynamicRules[ruleIndex];
|
|
917
1521
|
if (!rule) throw invalidGrant("Native TCP egress declaration is missing its matched rule");
|
|
918
1522
|
if (input.ttlMs !== undefined && rule.ttlMs !== undefined && input.ttlMs > rule.ttlMs)
|
|
@@ -920,7 +1524,6 @@ export function createNativeEgressAuthorization(options: NativeNetworkClientOpti
|
|
|
920
1524
|
`Native TCP egress grant ttlMs ${input.ttlMs} exceeds declared maximum ${rule.ttlMs}`,
|
|
921
1525
|
);
|
|
922
1526
|
const now = Date.now();
|
|
923
|
-
const targetHost = normalizeEgressHost(input.host);
|
|
924
1527
|
purgeInactive(now);
|
|
925
1528
|
const activeForRule = grants.filter(
|
|
926
1529
|
(grant) =>
|
|
@@ -939,7 +1542,7 @@ export function createNativeEgressAuthorization(options: NativeNetworkClientOpti
|
|
|
939
1542
|
throw invalidGrant("Native TCP egress grant expiry exceeds the supported date range");
|
|
940
1543
|
const stored: StoredEgressGrant = {
|
|
941
1544
|
ruleIndex,
|
|
942
|
-
host:
|
|
1545
|
+
host: input.host,
|
|
943
1546
|
port: input.port,
|
|
944
1547
|
tls: input.tls,
|
|
945
1548
|
...(expiresAtMs === undefined ? {} : { expiresAtMs }),
|
|
@@ -1023,10 +1626,10 @@ export function createNativeNetworkClient(
|
|
|
1023
1626
|
egress.assertConnect(request, "disabled");
|
|
1024
1627
|
const deadline = deadlineFrom(request.timeoutMs);
|
|
1025
1628
|
assertCanStart(request.signal, deadline);
|
|
1026
|
-
const proxy = await resolveConnectionProxy(options, request);
|
|
1629
|
+
const proxy = await resolveConnectionProxy(options, request, deadline);
|
|
1027
1630
|
egress.assertConnect(request, "disabled");
|
|
1028
1631
|
const socket = proxy
|
|
1029
|
-
? await
|
|
1632
|
+
? await connectProxyTunnel(proxy, request, deadline, () =>
|
|
1030
1633
|
egress.assertConnect(request, "disabled"),
|
|
1031
1634
|
)
|
|
1032
1635
|
: await connectPlainSocket(request.host, request.port, request.signal, deadline);
|
|
@@ -1037,10 +1640,10 @@ export function createNativeNetworkClient(
|
|
|
1037
1640
|
egress.assertConnect(request, "required");
|
|
1038
1641
|
const deadline = deadlineFrom(request.timeoutMs);
|
|
1039
1642
|
assertCanStart(request.signal, deadline);
|
|
1040
|
-
const proxy = await resolveConnectionProxy(options, request);
|
|
1643
|
+
const proxy = await resolveConnectionProxy(options, request, deadline);
|
|
1041
1644
|
egress.assertConnect(request, "required");
|
|
1042
1645
|
const tunnel = proxy
|
|
1043
|
-
? await
|
|
1646
|
+
? await connectProxyTunnel(proxy, request, deadline, () =>
|
|
1044
1647
|
egress.assertConnect(request, "required"),
|
|
1045
1648
|
)
|
|
1046
1649
|
: undefined;
|