@apifuse/provider-sdk 2.2.0-beta.13 → 2.2.0-beta.15
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 +70 -6
- package/CHANGELOG.md +12 -0
- package/dist/define.js +9 -0
- package/dist/errors.d.ts +13 -0
- package/dist/errors.js +25 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/native-egress-policy.d.ts +27 -0
- package/dist/native-egress-policy.js +225 -0
- package/dist/provider.d.ts +3 -3
- package/dist/provider.js +2 -2
- package/dist/runtime/executor.js +17 -2
- package/dist/runtime/http.js +189 -9
- package/dist/runtime/native-network.d.ts +39 -4
- package/dist/runtime/native-network.js +365 -20
- package/dist/runtime/redirects.d.ts +29 -0
- package/dist/runtime/redirects.js +36 -0
- package/dist/runtime/stealth.js +16 -44
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/serve.d.ts +9 -0
- package/dist/server/serve.js +190 -51
- package/dist/server/types.d.ts +3 -0
- package/dist/server/types.js +1 -0
- package/dist/stateful/stateful-provider-owner-forwarder.js +9 -1
- package/dist/testing/run.js +32 -13
- package/dist/types.d.ts +23 -2
- package/package.json +1 -1
- package/src/define.ts +11 -0
- package/src/errors.ts +37 -0
- package/src/index.ts +12 -1
- package/src/native-egress-policy.ts +285 -0
- package/src/provider.ts +7 -0
- package/src/runtime/executor.ts +22 -2
- package/src/runtime/http.ts +217 -9
- package/src/runtime/native-network.ts +474 -22
- package/src/runtime/redirects.ts +66 -0
- package/src/runtime/stealth.ts +20 -47
- package/src/server/index.ts +2 -0
- package/src/server/serve.ts +226 -68
- package/src/server/types.ts +1 -0
- package/src/stateful/stateful-provider-owner-forwarder.ts +9 -1
- package/src/testing/run.ts +39 -14
- package/src/types.ts +32 -2
|
@@ -6,15 +6,24 @@ import { SocksClient } from "socks";
|
|
|
6
6
|
|
|
7
7
|
import { ProxyResolutionError } from "../config/loader.js";
|
|
8
8
|
import { TransportError } from "../errors.js";
|
|
9
|
+
import {
|
|
10
|
+
NativeEgressPolicyValidationError,
|
|
11
|
+
parseNativeEgressPolicy,
|
|
12
|
+
type DynamicEgressRuleSnapshot,
|
|
13
|
+
type StaticEgressRuleSnapshot,
|
|
14
|
+
} from "../native-egress-policy.js";
|
|
9
15
|
import type {
|
|
10
16
|
NativeNetworkClient,
|
|
11
17
|
NativeNetworkConnection,
|
|
12
18
|
NativeNetworkConnectInput,
|
|
13
19
|
NativeNetworkDynamicGrantOptions,
|
|
14
20
|
NativeNetworkEgressGrant,
|
|
21
|
+
NativeProviderConfig,
|
|
15
22
|
NativeProxyDrainHandler,
|
|
16
23
|
NativeProxyEgressInfo,
|
|
17
24
|
NativeProxyExpiringEvent,
|
|
25
|
+
NativeTcpPortRange,
|
|
26
|
+
NativeTcpTlsMode,
|
|
18
27
|
ProviderProxyPolicy,
|
|
19
28
|
ProviderProxyProvider,
|
|
20
29
|
} from "../types.js";
|
|
@@ -30,13 +39,26 @@ export type NativeNetworkErrorCode =
|
|
|
30
39
|
| "native_connection_failed"
|
|
31
40
|
| "native_connection_idle_timeout"
|
|
32
41
|
| "native_connection_timeout"
|
|
42
|
+
| "native_egress_authorization_failed"
|
|
43
|
+
| "native_egress_grant_expired"
|
|
44
|
+
| "native_egress_grant_invalid"
|
|
45
|
+
| "native_egress_grant_limit_exceeded"
|
|
46
|
+
| "native_egress_input_invalid"
|
|
47
|
+
| "native_egress_not_declared"
|
|
48
|
+
| "native_egress_policy_invalid"
|
|
33
49
|
| "native_dynamic_egress_unsupported"
|
|
34
50
|
| "native_proxy_expired"
|
|
35
51
|
| "native_proxy_invalid";
|
|
36
52
|
|
|
37
53
|
export class NativeNetworkError extends TransportError {
|
|
38
54
|
constructor(message: string, code: NativeNetworkErrorCode) {
|
|
39
|
-
|
|
55
|
+
const isEgressPolicyFailure =
|
|
56
|
+
code.startsWith("native_egress_") || code === "native_dynamic_egress_unsupported";
|
|
57
|
+
super(message, {
|
|
58
|
+
code,
|
|
59
|
+
status: 0,
|
|
60
|
+
...(isEgressPolicyFailure ? { category: "provider_error" as const, retryable: false } : {}),
|
|
61
|
+
});
|
|
40
62
|
this.name = "NativeNetworkError";
|
|
41
63
|
}
|
|
42
64
|
|
|
@@ -52,6 +74,40 @@ export class NativeProxyExpiredError extends NativeNetworkError {
|
|
|
52
74
|
}
|
|
53
75
|
}
|
|
54
76
|
|
|
77
|
+
/** Raised before transport setup when a native destination is not authorized. */
|
|
78
|
+
export class NativeEgressNotDeclaredError extends NativeNetworkError {
|
|
79
|
+
constructor(
|
|
80
|
+
readonly host: string,
|
|
81
|
+
readonly port: number,
|
|
82
|
+
readonly tls: "required" | "disabled",
|
|
83
|
+
) {
|
|
84
|
+
super(
|
|
85
|
+
`Native ${tls === "required" ? "TLS" : "TCP"} egress is not declared for ${host}:${port}`,
|
|
86
|
+
"native_egress_not_declared",
|
|
87
|
+
);
|
|
88
|
+
this.name = "NativeEgressNotDeclaredError";
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Raised when the destination was authorized by a grant whose TTL elapsed and
|
|
94
|
+
* its expiry remains in the client's bounded recent-expiry evidence window.
|
|
95
|
+
*/
|
|
96
|
+
export class NativeEgressGrantExpiredError extends NativeNetworkError {
|
|
97
|
+
constructor(
|
|
98
|
+
readonly host: string,
|
|
99
|
+
readonly port: number,
|
|
100
|
+
readonly tls: "required" | "disabled",
|
|
101
|
+
readonly expiresAt: string,
|
|
102
|
+
) {
|
|
103
|
+
super(
|
|
104
|
+
`Native ${tls === "required" ? "TLS" : "TCP"} egress grant expired for ${host}:${port}`,
|
|
105
|
+
"native_egress_grant_expired",
|
|
106
|
+
);
|
|
107
|
+
this.name = "NativeEgressGrantExpiredError";
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
55
111
|
/** Raised when an established connection exceeds its opt-in read-idle window. */
|
|
56
112
|
export class NativeIdleTimeoutError extends NativeNetworkError {
|
|
57
113
|
constructor() {
|
|
@@ -95,7 +151,12 @@ export type NativeNetworkClientOptions = {
|
|
|
95
151
|
readonly gatewaySynthesizers?: readonly NativeGatewayProxySynthesizer[];
|
|
96
152
|
/** Warning-level lifecycle diagnostic sink. */
|
|
97
153
|
readonly warn?: (message: string) => void;
|
|
98
|
-
/**
|
|
154
|
+
/**
|
|
155
|
+
* Provider-declared native egress. Undefined preserves legacy unrestricted
|
|
156
|
+
* behavior; any provided declaration, including an empty object, is enforced.
|
|
157
|
+
*/
|
|
158
|
+
readonly egress?: NonNullable<NativeProviderConfig["network"]>;
|
|
159
|
+
/** Additional deployment authorization layered on top of SDK enforcement. */
|
|
99
160
|
readonly grantTcpEgress?: (input: NativeNetworkDynamicGrantOptions) => NativeNetworkEgressGrant;
|
|
100
161
|
};
|
|
101
162
|
|
|
@@ -340,6 +401,7 @@ async function connectSocksTunnel(
|
|
|
340
401
|
proxy: NativeGatewayProxy,
|
|
341
402
|
input: NativeNetworkConnectInput,
|
|
342
403
|
deadline: Deadline,
|
|
404
|
+
beforeDestinationConnect: () => void,
|
|
343
405
|
): Promise<Socket> {
|
|
344
406
|
const parsed = parseSocks5Proxy(proxy.url);
|
|
345
407
|
const proxySocket = await connectPlainSocket(parsed.host, parsed.port, input.signal, deadline);
|
|
@@ -348,6 +410,12 @@ async function connectSocksTunnel(
|
|
|
348
410
|
// an unhandled late network error between library cleanup and our wrapper.
|
|
349
411
|
proxySocket.on("error", () => undefined);
|
|
350
412
|
const remaining = remainingMs(deadline);
|
|
413
|
+
try {
|
|
414
|
+
beforeDestinationConnect();
|
|
415
|
+
} catch (error) {
|
|
416
|
+
proxySocket.destroy();
|
|
417
|
+
throw error;
|
|
418
|
+
}
|
|
351
419
|
const handshake = SocksClient.createConnection({
|
|
352
420
|
command: "connect",
|
|
353
421
|
destination: { host: input.host, port: input.port },
|
|
@@ -567,34 +635,418 @@ async function resolveConnectionProxy(
|
|
|
567
635
|
return resolved;
|
|
568
636
|
}
|
|
569
637
|
|
|
570
|
-
|
|
638
|
+
type NativeConnectTls = "required" | "disabled";
|
|
639
|
+
|
|
640
|
+
type StoredEgressGrant = {
|
|
641
|
+
readonly ruleIndex: number;
|
|
642
|
+
readonly host: string;
|
|
643
|
+
readonly port: number;
|
|
644
|
+
readonly tls: NativeTcpTlsMode;
|
|
645
|
+
readonly expiresAtMs?: number;
|
|
646
|
+
revoked: boolean;
|
|
647
|
+
};
|
|
648
|
+
|
|
649
|
+
export const NATIVE_EGRESS_EXPIRED_EVIDENCE_LIMIT = 256;
|
|
650
|
+
|
|
651
|
+
function normalizeEgressHost(host: string): string {
|
|
652
|
+
return host.trim().toLowerCase().replace(/\.$/, "");
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function invalidPolicy(message: string): NativeNetworkError {
|
|
656
|
+
return new NativeNetworkError(message, "native_egress_policy_invalid");
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function matchesDnsSuffix(host: string, suffix: string): boolean {
|
|
660
|
+
return host === suffix || host.endsWith(`.${suffix}`);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function matchesSourceHost(rule: DynamicEgressRuleSnapshot, host: string): boolean {
|
|
664
|
+
const hasSelector = rule.sourceHost !== undefined || rule.sourceHostSuffixes.length > 0;
|
|
665
|
+
if (!hasSelector) return false;
|
|
666
|
+
return (
|
|
667
|
+
host === rule.sourceHost ||
|
|
668
|
+
rule.sourceHostSuffixes.some((suffix) => matchesDnsSuffix(host, suffix))
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function matchesPortSelectors(
|
|
673
|
+
port: number,
|
|
674
|
+
ports: readonly number[],
|
|
675
|
+
ranges: readonly NativeTcpPortRange[],
|
|
676
|
+
): boolean {
|
|
677
|
+
if (ports.length === 0 && ranges.length === 0) return false;
|
|
678
|
+
return ports.includes(port) || ranges.some(({ start, end }) => port >= start && port <= end);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function tlsModeAllows(mode: NativeTcpTlsMode, requested: NativeConnectTls): boolean {
|
|
682
|
+
return mode === "allowed" || mode === requested;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function grantTlsFitsRule(grant: NativeTcpTlsMode, rule: NativeTcpTlsMode): boolean {
|
|
686
|
+
return (
|
|
687
|
+
rule === "allowed" ||
|
|
688
|
+
(grant === "required" && rule === "required") ||
|
|
689
|
+
(grant === "disabled" && rule === "disabled")
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function matchesDynamicRuleSelectors(
|
|
694
|
+
rule: DynamicEgressRuleSnapshot,
|
|
695
|
+
input: NativeNetworkDynamicGrantOptions,
|
|
696
|
+
): boolean {
|
|
697
|
+
const sourceHost = normalizeEgressHost(input.sourceHost);
|
|
698
|
+
const targetHost = normalizeEgressHost(input.host);
|
|
699
|
+
return (
|
|
700
|
+
matchesSourceHost(rule, sourceHost) &&
|
|
701
|
+
matchesPortSelectors(input.sourcePort, rule.sourcePorts, rule.sourcePortRanges) &&
|
|
702
|
+
rule.targetHostSuffixes.some((suffix) => matchesDnsSuffix(targetHost, suffix)) &&
|
|
703
|
+
matchesPortSelectors(input.port, rule.targetPorts, rule.targetPortRanges) &&
|
|
704
|
+
grantTlsFitsRule(input.tls, rule.tls)
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function invalidGrant(message: string): NativeNetworkError {
|
|
709
|
+
return new NativeNetworkError(message, "native_egress_grant_invalid");
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function hasControlCharacter(value: string): boolean {
|
|
713
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
714
|
+
const code = value.charCodeAt(index);
|
|
715
|
+
if (code <= 31 || code === 127) return true;
|
|
716
|
+
}
|
|
717
|
+
return false;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function assertValidGrantInput(input: NativeNetworkDynamicGrantOptions): void {
|
|
721
|
+
if (
|
|
722
|
+
!normalizeEgressHost(input.sourceHost) ||
|
|
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");
|
|
734
|
+
if (
|
|
735
|
+
!Number.isSafeInteger(input.sourcePort) ||
|
|
736
|
+
input.sourcePort < 1 ||
|
|
737
|
+
input.sourcePort > 65_535 ||
|
|
738
|
+
!Number.isSafeInteger(input.port) ||
|
|
739
|
+
input.port < 1 ||
|
|
740
|
+
input.port > 65_535
|
|
741
|
+
)
|
|
742
|
+
throw invalidGrant("Native TCP egress grant ports must be integers from 1 to 65535");
|
|
743
|
+
if (input.tls !== "required" && input.tls !== "allowed" && input.tls !== "disabled")
|
|
744
|
+
throw invalidGrant("Native TCP egress grant tls must be required, allowed, or disabled");
|
|
745
|
+
if (input.ttlMs !== undefined && (!Number.isSafeInteger(input.ttlMs) || input.ttlMs <= 0))
|
|
746
|
+
throw invalidGrant("Native TCP egress grant ttlMs must be a positive integer");
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/** Internal canonical snapshot shared by production and SDK transport test doubles. */
|
|
750
|
+
export function snapshotNativeConnectInput(
|
|
751
|
+
input: NativeNetworkConnectInput,
|
|
752
|
+
): NativeNetworkConnectInput {
|
|
753
|
+
try {
|
|
754
|
+
const host = input.host;
|
|
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;
|
|
782
|
+
} catch {
|
|
783
|
+
throw new NativeNetworkError(
|
|
784
|
+
"Native connection input could not be inspected safely",
|
|
785
|
+
"native_egress_input_invalid",
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/** Internal canonical snapshot shared by production and SDK transport test doubles. */
|
|
791
|
+
export function snapshotNativeGrantInput(
|
|
792
|
+
input: NativeNetworkDynamicGrantOptions,
|
|
793
|
+
): NativeNetworkDynamicGrantOptions {
|
|
794
|
+
try {
|
|
795
|
+
const sourceHost = input.sourceHost;
|
|
796
|
+
const sourcePort = input.sourcePort;
|
|
797
|
+
const host = input.host;
|
|
798
|
+
const port = input.port;
|
|
799
|
+
const tls = input.tls;
|
|
800
|
+
const ttlMs = input.ttlMs;
|
|
801
|
+
return {
|
|
802
|
+
sourceHost,
|
|
803
|
+
sourcePort,
|
|
804
|
+
host,
|
|
805
|
+
port,
|
|
806
|
+
tls,
|
|
807
|
+
...(ttlMs === undefined ? {} : { ttlMs }),
|
|
808
|
+
};
|
|
809
|
+
} catch {
|
|
810
|
+
throw new NativeNetworkError(
|
|
811
|
+
"Native TCP egress grant input could not be inspected safely",
|
|
812
|
+
"native_egress_input_invalid",
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/** Internal authorization seam shared by production and SDK transport test doubles. */
|
|
818
|
+
export function createNativeEgressAuthorization(options: NativeNetworkClientOptions): {
|
|
819
|
+
assertConnect(input: NativeNetworkConnectInput, tls: NativeConnectTls): void;
|
|
820
|
+
grant(input: NativeNetworkDynamicGrantOptions): NativeNetworkEgressGrant;
|
|
821
|
+
} {
|
|
822
|
+
let declared: boolean;
|
|
823
|
+
let staticRules: readonly StaticEgressRuleSnapshot[];
|
|
824
|
+
let dynamicRules: readonly DynamicEgressRuleSnapshot[];
|
|
825
|
+
let delegate: NativeNetworkClientOptions["grantTcpEgress"];
|
|
826
|
+
try {
|
|
827
|
+
const policy = options.egress;
|
|
828
|
+
delegate = options.grantTcpEgress;
|
|
829
|
+
if (delegate !== undefined && typeof delegate !== "function")
|
|
830
|
+
throw invalidPolicy("Native egress delegate must be a function");
|
|
831
|
+
declared = policy !== undefined;
|
|
832
|
+
const snapshot = declared
|
|
833
|
+
? parseNativeEgressPolicy(policy)
|
|
834
|
+
: { staticRules: [], dynamicRules: [] };
|
|
835
|
+
staticRules = snapshot.staticRules;
|
|
836
|
+
dynamicRules = snapshot.dynamicRules;
|
|
837
|
+
} catch (error) {
|
|
838
|
+
if (error instanceof NativeNetworkError) throw error;
|
|
839
|
+
if (error instanceof NativeEgressPolicyValidationError) throw invalidPolicy(error.message);
|
|
840
|
+
throw invalidPolicy("Native egress policy could not be inspected safely");
|
|
841
|
+
}
|
|
842
|
+
const grants: StoredEgressGrant[] = [];
|
|
843
|
+
const expiredEvidence = new Map<string, StoredEgressGrant>();
|
|
844
|
+
const grantKey = (grant: Pick<StoredEgressGrant, "host" | "port" | "tls">): string =>
|
|
845
|
+
`${grant.host}\0${grant.port}\0${grant.tls}`;
|
|
846
|
+
const recordExpired = (grant: StoredEgressGrant): void => {
|
|
847
|
+
const key = grantKey(grant);
|
|
848
|
+
expiredEvidence.delete(key);
|
|
849
|
+
expiredEvidence.set(key, grant);
|
|
850
|
+
while (expiredEvidence.size > NATIVE_EGRESS_EXPIRED_EVIDENCE_LIMIT) {
|
|
851
|
+
const oldest = expiredEvidence.keys().next().value;
|
|
852
|
+
if (typeof oldest !== "string") break;
|
|
853
|
+
expiredEvidence.delete(oldest);
|
|
854
|
+
}
|
|
855
|
+
};
|
|
856
|
+
const purgeInactive = (now: number): void => {
|
|
857
|
+
const live: StoredEgressGrant[] = [];
|
|
858
|
+
for (const grant of grants) {
|
|
859
|
+
if (grant.revoked) continue;
|
|
860
|
+
if (grant.expiresAtMs !== undefined && now >= grant.expiresAtMs) {
|
|
861
|
+
recordExpired(grant);
|
|
862
|
+
continue;
|
|
863
|
+
}
|
|
864
|
+
live.push(grant);
|
|
865
|
+
}
|
|
866
|
+
grants.length = 0;
|
|
867
|
+
grants.push(...live);
|
|
868
|
+
};
|
|
869
|
+
|
|
870
|
+
const assertConnect = (input: NativeNetworkConnectInput, tls: NativeConnectTls): void => {
|
|
871
|
+
if (!declared) return;
|
|
872
|
+
const host = normalizeEgressHost(input.host);
|
|
873
|
+
const now = Date.now();
|
|
874
|
+
purgeInactive(now);
|
|
875
|
+
if (
|
|
876
|
+
staticRules.some(
|
|
877
|
+
(rule) =>
|
|
878
|
+
rule.host === host && rule.ports.includes(input.port) && tlsModeAllows(rule.tls, tls),
|
|
879
|
+
)
|
|
880
|
+
)
|
|
881
|
+
return;
|
|
882
|
+
const matching = grants.filter(
|
|
883
|
+
(grant) =>
|
|
884
|
+
!grant.revoked &&
|
|
885
|
+
grant.host === host &&
|
|
886
|
+
grant.port === input.port &&
|
|
887
|
+
tlsModeAllows(grant.tls, tls),
|
|
888
|
+
);
|
|
889
|
+
if (matching.length > 0) return;
|
|
890
|
+
const expired = [...expiredEvidence.values()]
|
|
891
|
+
.filter(
|
|
892
|
+
(grant) =>
|
|
893
|
+
grant.host === host &&
|
|
894
|
+
grant.port === input.port &&
|
|
895
|
+
tlsModeAllows(grant.tls, tls),
|
|
896
|
+
)
|
|
897
|
+
.sort((left, right) => (right.expiresAtMs ?? 0) - (left.expiresAtMs ?? 0))[0];
|
|
898
|
+
if (expired?.expiresAtMs !== undefined)
|
|
899
|
+
throw new NativeEgressGrantExpiredError(
|
|
900
|
+
input.host,
|
|
901
|
+
input.port,
|
|
902
|
+
tls,
|
|
903
|
+
new Date(expired.expiresAtMs).toISOString(),
|
|
904
|
+
);
|
|
905
|
+
throw new NativeEgressNotDeclaredError(input.host, input.port, tls);
|
|
906
|
+
};
|
|
907
|
+
|
|
908
|
+
const grantLocal = (input: NativeNetworkDynamicGrantOptions): NativeNetworkEgressGrant => {
|
|
909
|
+
assertValidGrantInput(input);
|
|
910
|
+
const ruleIndex = dynamicRules.findIndex((rule) => matchesDynamicRuleSelectors(rule, input));
|
|
911
|
+
if (ruleIndex < 0)
|
|
912
|
+
throw new NativeNetworkError(
|
|
913
|
+
`Native TCP egress grant is not declared for ${input.host}:${input.port} (${input.tls})`,
|
|
914
|
+
"native_egress_not_declared",
|
|
915
|
+
);
|
|
916
|
+
const rule = dynamicRules[ruleIndex];
|
|
917
|
+
if (!rule) throw invalidGrant("Native TCP egress declaration is missing its matched rule");
|
|
918
|
+
if (input.ttlMs !== undefined && rule.ttlMs !== undefined && input.ttlMs > rule.ttlMs)
|
|
919
|
+
throw invalidGrant(
|
|
920
|
+
`Native TCP egress grant ttlMs ${input.ttlMs} exceeds declared maximum ${rule.ttlMs}`,
|
|
921
|
+
);
|
|
922
|
+
const now = Date.now();
|
|
923
|
+
const targetHost = normalizeEgressHost(input.host);
|
|
924
|
+
purgeInactive(now);
|
|
925
|
+
const activeForRule = grants.filter(
|
|
926
|
+
(grant) =>
|
|
927
|
+
grant.ruleIndex === ruleIndex &&
|
|
928
|
+
!grant.revoked &&
|
|
929
|
+
(grant.expiresAtMs === undefined || now < grant.expiresAtMs),
|
|
930
|
+
).length;
|
|
931
|
+
if (rule.maxGrants !== undefined && activeForRule >= rule.maxGrants)
|
|
932
|
+
throw new NativeNetworkError(
|
|
933
|
+
`Native TCP egress grant limit exceeded for declaration ${ruleIndex}`,
|
|
934
|
+
"native_egress_grant_limit_exceeded",
|
|
935
|
+
);
|
|
936
|
+
const ttlMs = input.ttlMs ?? rule.ttlMs;
|
|
937
|
+
const expiresAtMs = ttlMs === undefined ? undefined : now + ttlMs;
|
|
938
|
+
if (expiresAtMs !== undefined && (!Number.isFinite(expiresAtMs) || expiresAtMs > 8.64e15))
|
|
939
|
+
throw invalidGrant("Native TCP egress grant expiry exceeds the supported date range");
|
|
940
|
+
const stored: StoredEgressGrant = {
|
|
941
|
+
ruleIndex,
|
|
942
|
+
host: targetHost,
|
|
943
|
+
port: input.port,
|
|
944
|
+
tls: input.tls,
|
|
945
|
+
...(expiresAtMs === undefined ? {} : { expiresAtMs }),
|
|
946
|
+
revoked: false,
|
|
947
|
+
};
|
|
948
|
+
expiredEvidence.delete(grantKey(stored));
|
|
949
|
+
grants.push(stored);
|
|
950
|
+
return {
|
|
951
|
+
revoke() {
|
|
952
|
+
stored.revoked = true;
|
|
953
|
+
},
|
|
954
|
+
};
|
|
955
|
+
};
|
|
956
|
+
|
|
957
|
+
return {
|
|
958
|
+
assertConnect,
|
|
959
|
+
grant(input) {
|
|
960
|
+
if (!declared) {
|
|
961
|
+
if (delegate) {
|
|
962
|
+
try {
|
|
963
|
+
const delegated = delegate(input);
|
|
964
|
+
if (!delegated || typeof delegated.revoke !== "function")
|
|
965
|
+
throw new TypeError("Native egress delegate returned an invalid grant");
|
|
966
|
+
return delegated;
|
|
967
|
+
} catch (error) {
|
|
968
|
+
if (error instanceof NativeNetworkError) throw error;
|
|
969
|
+
throw new NativeNetworkError(
|
|
970
|
+
"Deployment native egress authorization failed",
|
|
971
|
+
"native_egress_authorization_failed",
|
|
972
|
+
);
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
throw new NativeNetworkError(
|
|
976
|
+
"Dynamic native egress authorization is not configured",
|
|
977
|
+
"native_dynamic_egress_unsupported",
|
|
978
|
+
);
|
|
979
|
+
}
|
|
980
|
+
const local = grantLocal(input);
|
|
981
|
+
let delegated: NativeNetworkEgressGrant | undefined;
|
|
982
|
+
try {
|
|
983
|
+
delegated = delegate?.(Object.freeze({ ...input }));
|
|
984
|
+
if (delegated !== undefined && typeof delegated.revoke !== "function")
|
|
985
|
+
throw new TypeError("Native egress delegate returned an invalid grant");
|
|
986
|
+
} catch (error) {
|
|
987
|
+
local.revoke();
|
|
988
|
+
if (error instanceof NativeNetworkError) throw error;
|
|
989
|
+
throw new NativeNetworkError(
|
|
990
|
+
"Deployment native egress authorization failed",
|
|
991
|
+
"native_egress_authorization_failed",
|
|
992
|
+
);
|
|
993
|
+
}
|
|
994
|
+
let revoked = false;
|
|
995
|
+
return {
|
|
996
|
+
revoke() {
|
|
997
|
+
if (revoked) return;
|
|
998
|
+
revoked = true;
|
|
999
|
+
local.revoke();
|
|
1000
|
+
try {
|
|
1001
|
+
delegated?.revoke();
|
|
1002
|
+
} catch (error) {
|
|
1003
|
+
if (error instanceof NativeNetworkError) throw error;
|
|
1004
|
+
throw new NativeNetworkError(
|
|
1005
|
+
"Deployment native egress grant revocation failed",
|
|
1006
|
+
"native_egress_authorization_failed",
|
|
1007
|
+
);
|
|
1008
|
+
}
|
|
1009
|
+
},
|
|
1010
|
+
};
|
|
1011
|
+
},
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/** Create the SDK byte-stream runtime with provider-declared egress enforcement. */
|
|
571
1016
|
export function createNativeNetworkClient(
|
|
572
1017
|
options: NativeNetworkClientOptions = {},
|
|
573
1018
|
): NativeNetworkClient {
|
|
1019
|
+
const egress = createNativeEgressAuthorization(options);
|
|
574
1020
|
return {
|
|
575
1021
|
connectTcp: async (input) => {
|
|
576
|
-
const
|
|
577
|
-
|
|
578
|
-
const
|
|
1022
|
+
const request = snapshotNativeConnectInput(input);
|
|
1023
|
+
egress.assertConnect(request, "disabled");
|
|
1024
|
+
const deadline = deadlineFrom(request.timeoutMs);
|
|
1025
|
+
assertCanStart(request.signal, deadline);
|
|
1026
|
+
const proxy = await resolveConnectionProxy(options, request);
|
|
1027
|
+
egress.assertConnect(request, "disabled");
|
|
579
1028
|
const socket = proxy
|
|
580
|
-
? await connectSocksTunnel(proxy,
|
|
581
|
-
|
|
582
|
-
|
|
1029
|
+
? await connectSocksTunnel(proxy, request, deadline, () =>
|
|
1030
|
+
egress.assertConnect(request, "disabled"),
|
|
1031
|
+
)
|
|
1032
|
+
: await connectPlainSocket(request.host, request.port, request.signal, deadline);
|
|
1033
|
+
return createNativeNetworkConnection(socket, proxy, options, request.idleTimeoutMs);
|
|
583
1034
|
},
|
|
584
1035
|
connectTls: async (input) => {
|
|
585
|
-
const
|
|
586
|
-
|
|
587
|
-
const
|
|
588
|
-
|
|
589
|
-
const
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
);
|
|
1036
|
+
const request = snapshotNativeConnectInput(input);
|
|
1037
|
+
egress.assertConnect(request, "required");
|
|
1038
|
+
const deadline = deadlineFrom(request.timeoutMs);
|
|
1039
|
+
assertCanStart(request.signal, deadline);
|
|
1040
|
+
const proxy = await resolveConnectionProxy(options, request);
|
|
1041
|
+
egress.assertConnect(request, "required");
|
|
1042
|
+
const tunnel = proxy
|
|
1043
|
+
? await connectSocksTunnel(proxy, request, deadline, () =>
|
|
1044
|
+
egress.assertConnect(request, "required"),
|
|
1045
|
+
)
|
|
1046
|
+
: undefined;
|
|
1047
|
+
const socket = await upgradeTls(tunnel, request, deadline);
|
|
1048
|
+
return createNativeNetworkConnection(socket, proxy, options, request.idleTimeoutMs);
|
|
598
1049
|
},
|
|
1050
|
+
grantTcpEgress: (input) => egress.grant(snapshotNativeGrantInput(input)),
|
|
599
1051
|
};
|
|
600
1052
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { HttpRedirectFailureReason } from "../types.js";
|
|
2
|
+
|
|
3
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
4
|
+
|
|
5
|
+
export type RedirectHopDecision<TMethod extends string> =
|
|
6
|
+
| {
|
|
7
|
+
kind: "follow";
|
|
8
|
+
nextMethod: TMethod | "GET";
|
|
9
|
+
nextUrl: string;
|
|
10
|
+
}
|
|
11
|
+
| {
|
|
12
|
+
kind: "stop";
|
|
13
|
+
reason: HttpRedirectFailureReason;
|
|
14
|
+
nextUrl?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function isRedirectStatus(status: number): boolean {
|
|
18
|
+
return REDIRECT_STATUSES.has(status);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Shared fetch-compatible redirect method rewriting for stealth and ctx.http. */
|
|
22
|
+
export function nextRedirectMethod<TMethod extends string>(
|
|
23
|
+
status: number,
|
|
24
|
+
method: TMethod,
|
|
25
|
+
): TMethod | "GET" {
|
|
26
|
+
if (status === 303 && method !== "HEAD") return "GET";
|
|
27
|
+
if ((status === 301 || status === 302) && method === "POST") return "GET";
|
|
28
|
+
return method;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Resolves a Location value against the response URL without issuing a request. */
|
|
32
|
+
export function resolveRedirectUrl(
|
|
33
|
+
location: string | undefined,
|
|
34
|
+
responseUrl: string,
|
|
35
|
+
): string | undefined {
|
|
36
|
+
return location ? new URL(location, responseUrl).toString() : undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Shared post-response decision ordering for both redirect walkers. The
|
|
41
|
+
* caller-owned stop hook is checked first, matching stealth's pre-follow
|
|
42
|
+
* contract, then structural termination and loop checks run before follow.
|
|
43
|
+
*/
|
|
44
|
+
export function evaluateRedirectHop<TMethod extends string>(input: {
|
|
45
|
+
status: number;
|
|
46
|
+
method: TMethod;
|
|
47
|
+
nextUrl: string | undefined;
|
|
48
|
+
shouldStop: boolean;
|
|
49
|
+
redirectCount: number;
|
|
50
|
+
maxHops: number;
|
|
51
|
+
visitedRequests: ReadonlySet<string>;
|
|
52
|
+
}): RedirectHopDecision<TMethod> {
|
|
53
|
+
if (input.shouldStop) {
|
|
54
|
+
return { kind: "stop", reason: "stopped", ...(input.nextUrl ? { nextUrl: input.nextUrl } : {}) };
|
|
55
|
+
}
|
|
56
|
+
if (!input.nextUrl) return { kind: "stop", reason: "missing_location" };
|
|
57
|
+
if (input.redirectCount > input.maxHops) {
|
|
58
|
+
return { kind: "stop", reason: "max_hops", nextUrl: input.nextUrl };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const nextMethod = nextRedirectMethod(input.status, input.method);
|
|
62
|
+
if (input.visitedRequests.has(`${nextMethod} ${input.nextUrl}`)) {
|
|
63
|
+
return { kind: "stop", reason: "loop", nextUrl: input.nextUrl };
|
|
64
|
+
}
|
|
65
|
+
return { kind: "follow", nextMethod, nextUrl: input.nextUrl };
|
|
66
|
+
}
|