@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.
@@ -3,6 +3,11 @@ export declare const NODEMAVEN_USERNAME_ENV = "APIFUSE__PROXY__NODEMAVEN_USERNAM
3
3
  export declare const NODEMAVEN_PASSWORD_ENV = "APIFUSE__PROXY__NODEMAVEN_PASSWORD";
4
4
  export declare const NODEMAVEN_FILTER_ENV = "APIFUSE__PROXY__NODEMAVEN_FILTER";
5
5
  export declare const NODEMAVEN_GATEWAY_HOST = "gate.nodemaven.com";
6
+ export type NodemavenCredentials = {
7
+ readonly username: string;
8
+ readonly password: string;
9
+ readonly filter?: string;
10
+ };
6
11
  /** Both schemes tunnel bytes end-to-end, preserving the client TLS handshake. */
7
12
  export type ProxyProtocol = "http" | "socks5";
8
13
  /**
@@ -23,6 +28,8 @@ export type NodemavenSessionWindow = {
23
28
  export declare function nodemavenSessionWindow(policy: ProviderProxyPolicy, now?: number): NodemavenSessionWindow;
24
29
  export type NodemavenSynthesisInput = {
25
30
  policy: ProviderProxyPolicy;
31
+ /** Explicit credentials; synthesis never reads process-global state. */
32
+ credentials: NodemavenCredentials;
26
33
  affinityKey: string | undefined;
27
34
  protocol: ProxyProtocol;
28
35
  poolIndex: number;
@@ -30,8 +30,8 @@ function readNodemavenUsername() {
30
30
  function readNodemavenPassword() {
31
31
  return process.env[NODEMAVEN_PASSWORD_ENV]?.trim() || undefined;
32
32
  }
33
- function resolveNodemavenFilter() {
34
- const raw = process.env[NODEMAVEN_FILTER_ENV]?.trim().toLowerCase();
33
+ function resolveNodemavenFilter(value) {
34
+ const raw = value?.trim().toLowerCase();
35
35
  if (!raw)
36
36
  return DEFAULT_NODEMAVEN_FILTER;
37
37
  if (!NODEMAVEN_FILTERS.has(raw)) {
@@ -97,12 +97,12 @@ function selectPort(protocol, sid, poolIndex) {
97
97
  * There is no allocation API — geo/session are encoded in the username.
98
98
  */
99
99
  export function synthesizeNodemavenProxy(input) {
100
- const username = readNodemavenUsername();
101
- const password = readNodemavenPassword();
100
+ const username = input.credentials.username.trim();
101
+ const password = input.credentials.password.trim();
102
102
  if (!username || !password) {
103
103
  throw new Error(`NodeMaven credentials missing: set ${NODEMAVEN_USERNAME_ENV} and ${NODEMAVEN_PASSWORD_ENV}.`);
104
104
  }
105
- const filter = resolveNodemavenFilter();
105
+ const filter = resolveNodemavenFilter(input.credentials.filter);
106
106
  const sid = deriveSid(input.policy, input.affinityKey, input.poolIndex, input.refreshEpoch);
107
107
  const port = selectPort(input.protocol, sid, input.poolIndex);
108
108
  const lifetimeMinutes = nodemavenLifetimeMinutes(input.policy);
@@ -16,7 +16,7 @@ import { createEnvContext } from "../runtime/env.js";
16
16
  import { executeOperation } from "../runtime/executor.js";
17
17
  import { createHttpClient } from "../runtime/http.js";
18
18
  import { wrapWithInstrumentation } from "../runtime/instrumentation.js";
19
- import { createNativeNetworkClient } from "../runtime/native-network.js";
19
+ import { createEnvVendorCredentialResolver, createNativeNetworkClient, } from "../runtime/native-network.js";
20
20
  import { getProviderBaseUrl } from "../runtime/provider.js";
21
21
  import { PROXY_AUTH_IP_DENIED_CODE, PROXY_EDGE_AUTH_REJECTED_CODE, PROXY_POOL_EXHAUSTED_CODE, } from "../runtime/proxy-errors.js";
22
22
  import { PROVIDER_TELEMETRY_HEADER, ProxyTelemetryCollector } from "../runtime/proxy-telemetry.js";
@@ -225,6 +225,7 @@ function createProviderContext(provider, request, operationId, options = {}, sta
225
225
  egress: provider.native.network,
226
226
  proxyPolicy: resolveNativeProxyPolicy(provider),
227
227
  affinityKey: proxyClientOptions.affinityKey,
228
+ credentials: createEnvVendorCredentialResolver(env),
228
229
  }),
229
230
  },
230
231
  }
@@ -312,6 +313,7 @@ function createAuthFlowContext(provider, request, options = {}, signal) {
312
313
  egress: provider.native.network,
313
314
  proxyPolicy: resolveNativeProxyPolicy(provider),
314
315
  affinityKey: proxyClientOptions.affinityKey,
316
+ credentials: createEnvVendorCredentialResolver(createEnvContext(provider.secrets?.map((secret) => secret.name))),
315
317
  }),
316
318
  },
317
319
  }
package/dist/types.d.ts CHANGED
@@ -1089,6 +1089,10 @@ export interface NativeTcpEgressRule {
1089
1089
  /**
1090
1090
  * Bounded native TCP egress discovered through a declared bootstrap endpoint.
1091
1091
  * Host suffixes are exact DNS suffixes, not wildcard patterns.
1092
+ * Dynamic rules must declare at least one target host selector through
1093
+ * targetHostSuffixes and/or targetIpv4Cidrs. IPv4-literal grant targets match
1094
+ * only targetIpv4Cidrs, while DNS-name targets match only targetHostSuffixes.
1095
+ * CIDRs are exact IPv4 networks in a.b.c.d/nn form.
1092
1096
  *
1093
1097
  * Dynamic rules are ordered. The first rule whose source, target, port, and TLS
1094
1098
  * selectors match exclusively owns the grant; its ttlMs and maxGrants bounds
@@ -1101,7 +1105,8 @@ export interface NativeTcpDynamicEgressRule {
1101
1105
  readonly sourceHostSuffixes?: readonly string[];
1102
1106
  readonly sourcePorts?: readonly number[];
1103
1107
  readonly sourcePortRanges?: readonly NativeTcpPortRange[];
1104
- readonly targetHostSuffixes: readonly string[];
1108
+ readonly targetHostSuffixes?: readonly string[];
1109
+ readonly targetIpv4Cidrs?: readonly string[];
1105
1110
  readonly targetPorts?: readonly number[];
1106
1111
  readonly targetPortRanges?: readonly NativeTcpPortRange[];
1107
1112
  readonly tls: NativeTcpTlsMode;
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.2.0-beta.16",
2
+ "version": "2.2.0-beta.17",
3
3
  "name": "@apifuse/provider-sdk",
4
4
  "private": false,
5
5
  "type": "module",
@@ -7,7 +7,10 @@ import { Redis } from "ioredis";
7
7
  import type { ProviderProxyPolicy, ProviderProxyProvider, TraceConfig } from "../types.js";
8
8
  import {
9
9
  NODEMAVEN_DEFAULT_PROTOCOL,
10
+ NODEMAVEN_FILTER_ENV,
10
11
  NODEMAVEN_MAX_POOL_SIZE,
12
+ NODEMAVEN_PASSWORD_ENV,
13
+ NODEMAVEN_USERNAME_ENV,
11
14
  type ProxyProtocol,
12
15
  hasNodemavenCredentials,
13
16
  nodemavenPoolSize,
@@ -683,7 +686,7 @@ export async function resolveProxy(
683
686
  * for smartproxy, and is the only protocol ctx.http (Bun native fetch) supports.
684
687
  * Override per call via ProxyResolutionOptions.protocol (harness/tests).
685
688
  */
686
- const VENDOR_DEFAULT_PROTOCOL: Record<ProxyVendorName, ProxyProtocol> = {
689
+ export const VENDOR_DEFAULT_PROTOCOL: Readonly<Record<ProxyVendorName, ProxyProtocol>> = {
687
690
  smartproxy: "http",
688
691
  nodemaven: NODEMAVEN_DEFAULT_PROTOCOL,
689
692
  };
@@ -708,21 +711,59 @@ export function assertTunnelingScheme(url: string): void {
708
711
  }
709
712
  }
710
713
 
711
- async function resolveWithVendor(
714
+ export type ProxyVendorResolutionContext = {
715
+ readonly protocol: ProxyProtocol;
716
+ readonly poolIndex: number;
717
+ readonly refreshEpoch: number;
718
+ /** Explicit vendor credentials. Omit only on the legacy ambient-env path. */
719
+ readonly credentials?: Readonly<Record<string, string>>;
720
+ /** Disable non-policy env defaults for deterministic injected adapters. */
721
+ readonly ambientDefaults?: boolean;
722
+ /** Disable env-discovered Redis sharing for deterministic injected adapters. */
723
+ readonly sharedCache?: boolean;
724
+ };
725
+
726
+ export async function resolveWithVendor(
712
727
  vendor: ProxyVendorName,
713
728
  policy: ProviderProxyPolicy,
714
729
  options: ProxyResolutionOptions,
715
- context: { protocol: ProxyProtocol; poolIndex: number; refreshEpoch: number },
730
+ context: ProxyVendorResolutionContext,
716
731
  ): Promise<ResolvedProxyConfig> {
717
732
  if (vendor === "nodemaven") {
718
733
  const startedAt = Date.now();
734
+ const username = (
735
+ context.credentials === undefined
736
+ ? process.env[NODEMAVEN_USERNAME_ENV]
737
+ : context.credentials[NODEMAVEN_USERNAME_ENV]
738
+ )?.trim();
739
+ const password = (
740
+ context.credentials === undefined
741
+ ? process.env[NODEMAVEN_PASSWORD_ENV]
742
+ : context.credentials[NODEMAVEN_PASSWORD_ENV]
743
+ )?.trim();
744
+ const filter =
745
+ context.credentials === undefined
746
+ ? process.env[NODEMAVEN_FILTER_ENV]
747
+ : context.credentials[NODEMAVEN_FILTER_ENV];
748
+ if (!username || !password) {
749
+ throw new ProxyResolutionError(
750
+ "PROXY_ALLOCATION_FAILED",
751
+ `NodeMaven credentials missing: set ${NODEMAVEN_USERNAME_ENV} and ${NODEMAVEN_PASSWORD_ENV}.`,
752
+ { vendor: "nodemaven" },
753
+ );
754
+ }
719
755
  const synthesized = synthesizeNodemavenProxy({
720
756
  policy,
757
+ credentials: {
758
+ username,
759
+ password,
760
+ ...(filter ? { filter } : {}),
761
+ },
721
762
  affinityKey: options.affinityKey,
722
763
  protocol: context.protocol,
723
764
  poolIndex: context.poolIndex,
724
765
  refreshEpoch: context.refreshEpoch,
725
- country: resolveSmartproxyCountry(policy),
766
+ country: resolveSmartproxyCountry(policy, context.ambientDefaults !== false),
726
767
  });
727
768
  options.telemetry?.recordProxyResolution({
728
769
  provider: "nodemaven",
@@ -746,7 +787,11 @@ async function resolveWithVendor(
746
787
  }
747
788
 
748
789
  // smartproxy allocation-style vendor.
749
- const appKey = process.env[SMARTPROXY_APP_KEY_ENV]?.trim();
790
+ const appKey = (
791
+ context.credentials === undefined
792
+ ? process.env[SMARTPROXY_APP_KEY_ENV]
793
+ : context.credentials[SMARTPROXY_APP_KEY_ENV]
794
+ )?.trim();
750
795
  if (!appKey) {
751
796
  // Guarded by vendorHasCredentials; treated as a vendor-internal failure.
752
797
  throw new ProxyResolutionError(
@@ -755,13 +800,15 @@ async function resolveWithVendor(
755
800
  { vendor: "smartproxy" },
756
801
  );
757
802
  }
758
- const lifetimeMinutes = resolveSmartproxyLifetime(policy);
803
+ const lifetimeMinutes = resolveSmartproxyLifetime(policy, context.ambientDefaults !== false);
759
804
  const allocated = await allocateSmartproxy(
760
805
  policy,
761
806
  appKey,
762
807
  lifetimeMinutes,
763
808
  options.affinityKey,
764
809
  context.protocol,
810
+ context.ambientDefaults !== false,
811
+ context.sharedCache !== false,
765
812
  );
766
813
  options.telemetry?.recordProxyResolution({ ...allocated.telemetry, protocol: context.protocol });
767
814
  const poolIndex = selectProxyPoolIndex(allocated.pool.urls.length, context.poolIndex);
@@ -985,15 +1032,22 @@ export function mapFlatAttempt(
985
1032
  return { vendorIndex: 0, poolIndex: 0 };
986
1033
  }
987
1034
 
988
- function resolveSmartproxyCountry(policy: ProviderProxyPolicy): string | undefined {
1035
+ function resolveSmartproxyCountry(
1036
+ policy: ProviderProxyPolicy,
1037
+ ambientDefaults = true,
1038
+ ): string | undefined {
989
1039
  return (
990
- policy.geo?.country ?? process.env[DEFAULT_PROXY_COUNTRY_ENV]?.trim().toUpperCase() ?? undefined
1040
+ policy.geo?.country ??
1041
+ (ambientDefaults
1042
+ ? process.env[DEFAULT_PROXY_COUNTRY_ENV]?.trim().toUpperCase() || undefined
1043
+ : undefined)
991
1044
  );
992
1045
  }
993
1046
 
994
- function resolveSmartproxyLifetime(policy: ProviderProxyPolicy): number {
1047
+ function resolveSmartproxyLifetime(policy: ProviderProxyPolicy, ambientDefaults = true): number {
995
1048
  const configuredLifetime =
996
- policy.session?.lifetimeMinutes ?? readPositiveNumberEnv(DEFAULT_PROXY_LIFETIME_ENV, 30);
1049
+ policy.session?.lifetimeMinutes ??
1050
+ (ambientDefaults ? readPositiveNumberEnv(DEFAULT_PROXY_LIFETIME_ENV, 30) : 30);
997
1051
  return Math.min(SMARTPROXY_MAX_LIFETIME_MINUTES, Math.max(1, Math.floor(configuredLifetime)));
998
1052
  }
999
1053
 
@@ -1024,15 +1078,21 @@ function selectProxyPoolIndex(poolSize: number, attempt = 0): number {
1024
1078
 
1025
1079
  function buildSmartproxyCacheKey(
1026
1080
  policy: ProviderProxyPolicy,
1081
+ appKey: string,
1027
1082
  affinityKey: string | undefined,
1028
1083
  lifetimeMinutes: number,
1029
1084
  protocol: ProxyProtocol,
1085
+ ambientDefaults = true,
1030
1086
  ): string {
1031
1087
  const poolSize = resolveSmartproxyPoolSize(policy);
1032
1088
  return JSON.stringify({
1033
1089
  provider: "smartproxy",
1090
+ credentialHash: createHash("sha256")
1091
+ .update("apifuse-smartproxy-credential:v1\0")
1092
+ .update(appKey)
1093
+ .digest("hex"),
1034
1094
  protocol,
1035
- country: resolveSmartproxyCountry(policy),
1095
+ country: resolveSmartproxyCountry(policy, ambientDefaults),
1036
1096
  affinity: policy.session?.affinity ?? "request",
1037
1097
  affinityKey: (policy.session?.affinity ?? "request") === "request" ? undefined : affinityKey,
1038
1098
  lifetimeMinutes,
@@ -1046,8 +1106,17 @@ async function allocateSmartproxy(
1046
1106
  lifetimeMinutes: number,
1047
1107
  affinityKey: string | undefined,
1048
1108
  protocol: ProxyProtocol,
1109
+ ambientDefaults = true,
1110
+ sharedCache = true,
1049
1111
  ): Promise<SmartproxyAllocationResult> {
1050
- const cacheKey = buildSmartproxyCacheKey(policy, affinityKey, lifetimeMinutes, protocol);
1112
+ const cacheKey = buildSmartproxyCacheKey(
1113
+ policy,
1114
+ appKey,
1115
+ affinityKey,
1116
+ lifetimeMinutes,
1117
+ protocol,
1118
+ ambientDefaults,
1119
+ );
1051
1120
  const startedAt = Date.now();
1052
1121
  const now = startedAt;
1053
1122
  const invalidatedUntil = invalidatedProxyKeys.get(cacheKey) ?? 0;
@@ -1055,7 +1124,15 @@ async function allocateSmartproxy(
1055
1124
  const cached = proxyCache.get(cacheKey);
1056
1125
  if (!skipCached && cached && isFresh(cached, now)) {
1057
1126
  if (shouldSoftRefresh(cached, now)) {
1058
- void refreshSmartproxyPool(cacheKey, policy, appKey, lifetimeMinutes, protocol);
1127
+ void refreshSmartproxyPool(
1128
+ cacheKey,
1129
+ policy,
1130
+ appKey,
1131
+ lifetimeMinutes,
1132
+ protocol,
1133
+ ambientDefaults,
1134
+ sharedCache,
1135
+ );
1059
1136
  return {
1060
1137
  pool: cached,
1061
1138
  telemetry: telemetryForPool(cached, "soft_stale_refresh", startedAt, {
@@ -1069,7 +1146,7 @@ async function allocateSmartproxy(
1069
1146
  };
1070
1147
  }
1071
1148
 
1072
- if (!skipCached) {
1149
+ if (!skipCached && sharedCache) {
1073
1150
  const redisResult = await readSmartproxyRedisPool(cacheKey, startedAt);
1074
1151
  if (redisResult) return redisResult;
1075
1152
  }
@@ -1092,6 +1169,8 @@ async function allocateSmartproxy(
1092
1169
  lifetimeMinutes,
1093
1170
  startedAt,
1094
1171
  protocol,
1172
+ ambientDefaults,
1173
+ sharedCache,
1095
1174
  ).finally(() => {
1096
1175
  proxyInflight.delete(cacheKey);
1097
1176
  });
@@ -1131,6 +1210,8 @@ async function refreshSmartproxyPool(
1131
1210
  appKey: string,
1132
1211
  lifetimeMinutes: number,
1133
1212
  protocol: ProxyProtocol,
1213
+ ambientDefaults: boolean,
1214
+ sharedCache: boolean,
1134
1215
  ): Promise<void> {
1135
1216
  try {
1136
1217
  await allocateSmartproxyShared(
@@ -1140,6 +1221,8 @@ async function refreshSmartproxyPool(
1140
1221
  lifetimeMinutes,
1141
1222
  Date.now(),
1142
1223
  protocol,
1224
+ ambientDefaults,
1225
+ sharedCache,
1143
1226
  {
1144
1227
  background: true,
1145
1228
  },
@@ -1156,9 +1239,11 @@ async function allocateSmartproxyShared(
1156
1239
  lifetimeMinutes: number,
1157
1240
  startedAt: number,
1158
1241
  protocol: ProxyProtocol,
1242
+ ambientDefaults: boolean,
1243
+ sharedCache: boolean,
1159
1244
  options: { background?: boolean } = {},
1160
1245
  ): Promise<SmartproxyAllocationResult> {
1161
- const redis = getProxyRedis();
1246
+ const redis = sharedCache ? getProxyRedis() : undefined;
1162
1247
  if (!redis || !(await ensureRedisReady(redis))) {
1163
1248
  return await allocateAndStoreSmartproxyPool(
1164
1249
  cacheKey,
@@ -1166,7 +1251,7 @@ async function allocateSmartproxyShared(
1166
1251
  appKey,
1167
1252
  lifetimeMinutes,
1168
1253
  startedAt,
1169
- { cacheStatus: "allocator", protocol },
1254
+ { cacheStatus: "allocator", protocol, ambientDefaults },
1170
1255
  );
1171
1256
  }
1172
1257
 
@@ -1192,6 +1277,7 @@ async function allocateSmartproxyShared(
1192
1277
  redis,
1193
1278
  poolKey,
1194
1279
  protocol,
1280
+ ambientDefaults,
1195
1281
  },
1196
1282
  );
1197
1283
  } finally {
@@ -1348,6 +1434,7 @@ async function allocateAndStoreSmartproxyPool(
1348
1434
  redis?: ProxyRedisClient;
1349
1435
  poolKey?: string;
1350
1436
  protocol: ProxyProtocol;
1437
+ ambientDefaults: boolean;
1351
1438
  },
1352
1439
  ): Promise<SmartproxyAllocationResult> {
1353
1440
  const poolSize = resolveSmartproxyPoolSize(policy);
@@ -1357,6 +1444,7 @@ async function allocateAndStoreSmartproxyPool(
1357
1444
  lifetimeMinutes,
1358
1445
  poolSize,
1359
1446
  options.protocol,
1447
+ options.ambientDefaults,
1360
1448
  );
1361
1449
  const allocatorStartedAt = Date.now();
1362
1450
  const allocatorDeadlineAt = allocatorStartedAt + smartproxyAllocatorDeadlineMs();
@@ -1427,7 +1515,7 @@ async function allocateAndStoreSmartproxyPool(
1427
1515
  expiresAt: allocatedAt + ttlMs,
1428
1516
  diagnostics: {
1429
1517
  provider: "smartproxy",
1430
- country: resolveSmartproxyCountry(policy) ?? "default",
1518
+ country: resolveSmartproxyCountry(policy, options.ambientDefaults) ?? "default",
1431
1519
  lifetimeMinutes,
1432
1520
  affinity: policy.session?.affinity ?? "request",
1433
1521
  rawConnect: true,
@@ -1590,6 +1678,7 @@ function buildSmartproxyAllocatorUrl(
1590
1678
  lifetimeMinutes: number,
1591
1679
  poolSize: number,
1592
1680
  protocol: ProxyProtocol,
1681
+ ambientDefaults = true,
1593
1682
  ): string {
1594
1683
  const params = new URLSearchParams({
1595
1684
  app_key: appKey,
@@ -1600,7 +1689,7 @@ function buildSmartproxyAllocatorUrl(
1600
1689
  format: "txt",
1601
1690
  lb: "\\n",
1602
1691
  });
1603
- const country = resolveSmartproxyCountry(policy);
1692
+ const country = resolveSmartproxyCountry(policy, ambientDefaults);
1604
1693
  if (country) {
1605
1694
  params.set("cc", country);
1606
1695
  }
@@ -1681,8 +1770,11 @@ function markSmartproxyCacheInvalidated(options: ProxyResolutionOptions = {}): s
1681
1770
  }
1682
1771
 
1683
1772
  const lifetimeMinutes = resolveSmartproxyLifetime(policy);
1773
+ const appKey = process.env[SMARTPROXY_APP_KEY_ENV]?.trim();
1774
+ if (!appKey) return undefined;
1684
1775
  const cacheKey = buildSmartproxyCacheKey(
1685
1776
  policy,
1777
+ appKey,
1686
1778
  options.affinityKey,
1687
1779
  lifetimeMinutes,
1688
1780
  options.protocol ?? VENDOR_DEFAULT_PROTOCOL.smartproxy,
package/src/index.ts CHANGED
@@ -72,6 +72,7 @@ export { executeOperation } from "./runtime/executor.js";
72
72
  export { createHttpClient } from "./runtime/http.js";
73
73
  export {
74
74
  createNativeNetworkClient,
75
+ createEnvVendorCredentialResolver,
75
76
  deriveNativeCredentialAffinityKey,
76
77
  NativeEgressGrantExpiredError,
77
78
  NativeEgressNotDeclaredError,
@@ -81,10 +82,14 @@ export {
81
82
  resolveNativeGatewayProxy,
82
83
  type NativeGatewayProxy,
83
84
  type NativeGatewayProxyResolutionInput,
85
+ type NativeGatewayProxySkipReason,
84
86
  type NativeGatewayProxySynthesizer,
87
+ type NativeGatewayProxySynthesisResult,
85
88
  type NativeGatewayProxySynthesisInput,
86
89
  type NativeNetworkClientOptions,
87
90
  type NativeNetworkErrorCode,
91
+ type VendorCredentialLookup,
92
+ type VendorCredentialResolver,
88
93
  } from "./runtime/native-network.js";
89
94
  export type { Insight, InsightSeverity } from "./runtime/insights.js";
90
95
  export { generateInsights } from "./runtime/insights.js";
@@ -5,6 +5,7 @@ import type {
5
5
  NativeTcpPortRange,
6
6
  NativeTcpTlsMode,
7
7
  } from "./types.js";
8
+ import { canonicalizeEgressHost, parseIpv4Cidr } from "./native-ipv4.js";
8
9
 
9
10
  type NativeNetworkDeclaration = NonNullable<NativeProviderConfig["network"]>;
10
11
 
@@ -26,6 +27,7 @@ const NATIVE_DYNAMIC_TCP_RULE_FIELD_RECORD = {
26
27
  sourcePorts: true,
27
28
  sourcePortRanges: true,
28
29
  targetHostSuffixes: true,
30
+ targetIpv4Cidrs: true,
29
31
  targetPorts: true,
30
32
  targetPortRanges: true,
31
33
  tls: true,
@@ -55,6 +57,7 @@ export type DynamicEgressRuleSnapshot = {
55
57
  readonly sourcePorts: readonly number[];
56
58
  readonly sourcePortRanges: readonly NativeTcpPortRange[];
57
59
  readonly targetHostSuffixes: readonly string[];
60
+ readonly targetIpv4Cidrs: readonly string[];
58
61
  readonly targetPorts: readonly number[];
59
62
  readonly targetPortRanges: readonly NativeTcpPortRange[];
60
63
  readonly tls: NativeTcpTlsMode;
@@ -117,28 +120,12 @@ function dataArray(value: unknown, fieldPath: string): readonly unknown[] {
117
120
  return result;
118
121
  }
119
122
 
120
- function hasControlCharacter(value: string): boolean {
121
- for (let index = 0; index < value.length; index += 1) {
122
- const code = value.charCodeAt(index);
123
- if (code <= 31 || code === 127) return true;
124
- }
125
- return false;
126
- }
127
-
128
123
  function host(value: unknown, fieldPath: string, suffix = false): string {
129
- if (
130
- typeof value !== "string" ||
131
- !value.trim() ||
132
- hasControlCharacter(value) ||
133
- /\s/.test(value) ||
134
- value.includes("://")
135
- )
136
- fail(`${fieldPath} must be a non-empty hostname`);
137
- if (value.includes("*"))
124
+ if (typeof value === "string" && value.includes("*"))
138
125
  fail(`${fieldPath} must be an exact ${suffix ? "DNS suffix" : "hostname"}, not a wildcard`);
139
- const normalized = value.trim().toLowerCase().replace(/\.$/, "");
140
- if (!normalized) fail(`${fieldPath} must be a non-empty hostname`);
141
- return normalized;
126
+ const canonical = canonicalizeEgressHost(value);
127
+ if (!canonical.ok) fail(`${fieldPath} must be a non-empty hostname`);
128
+ return canonical.host;
142
129
  }
143
130
 
144
131
  function port(value: unknown, fieldPath: string): number {
@@ -157,6 +144,24 @@ function hostSuffixes(value: unknown, fieldPath: string): readonly string[] {
157
144
  );
158
145
  }
159
146
 
147
+ function ipv4Cidrs(value: unknown, fieldPath: string): readonly string[] {
148
+ const seen = new Set<string>();
149
+ return dataArray(value, fieldPath).map((value, index) => {
150
+ const cidrPath = `${fieldPath}[${index}]`;
151
+ if (typeof value !== "string") fail(`${cidrPath} must be an IPv4 CIDR in a.b.c.d/nn form`);
152
+ const parsed = parseIpv4Cidr(value);
153
+ if (!parsed.ok) {
154
+ if (parsed.reason === "non-canonical-network")
155
+ fail(`${cidrPath} must use the canonical network address with no host bits set`);
156
+ fail(`${cidrPath} must be an IPv4 CIDR in a.b.c.d/nn form`);
157
+ }
158
+ const duplicateKey = `${parsed.network}/${parsed.prefix}`;
159
+ if (seen.has(duplicateKey)) fail(`${fieldPath} must not contain duplicate CIDRs`);
160
+ seen.add(duplicateKey);
161
+ return value;
162
+ });
163
+ }
164
+
160
165
  function ranges(value: unknown, fieldPath: string): readonly NativeTcpPortRange[] {
161
166
  return dataArray(value, fieldPath).map((value, index) => {
162
167
  const rangePath = `${fieldPath}[${index}]`;
@@ -212,9 +217,7 @@ export function parseNativeEgressPolicy(value: unknown): NativeEgressPolicySnaps
212
217
  ? []
213
218
  : hostSuffixes(rule.sourceHostSuffixes, `${fieldPath}.sourceHostSuffixes`);
214
219
  if (sourceHost === undefined && sourceHostSuffixes.length === 0)
215
- fail(
216
- `${fieldPath} must declare sourceHost or a non-empty sourceHostSuffixes list`,
217
- );
220
+ fail(`${fieldPath} must declare sourceHost or a non-empty sourceHostSuffixes list`);
218
221
  const sourcePorts =
219
222
  rule.sourcePorts === undefined
220
223
  ? []
@@ -224,15 +227,19 @@ export function parseNativeEgressPolicy(value: unknown): NativeEgressPolicySnaps
224
227
  ? []
225
228
  : ranges(rule.sourcePortRanges, `${fieldPath}.sourcePortRanges`);
226
229
  if (sourcePorts.length === 0 && sourcePortRanges.length === 0)
230
+ fail(`${fieldPath} must declare a non-empty sourcePorts or sourcePortRanges list`);
231
+ const targetHostSuffixes =
232
+ rule.targetHostSuffixes === undefined
233
+ ? []
234
+ : hostSuffixes(rule.targetHostSuffixes, `${fieldPath}.targetHostSuffixes`);
235
+ const targetIpv4Cidrs =
236
+ rule.targetIpv4Cidrs === undefined
237
+ ? []
238
+ : ipv4Cidrs(rule.targetIpv4Cidrs, `${fieldPath}.targetIpv4Cidrs`);
239
+ if (targetHostSuffixes.length === 0 && targetIpv4Cidrs.length === 0)
227
240
  fail(
228
- `${fieldPath} must declare a non-empty sourcePorts or sourcePortRanges list`,
241
+ `${fieldPath} must declare a non-empty targetHostSuffixes or targetIpv4Cidrs list`,
229
242
  );
230
- const targetHostSuffixes = hostSuffixes(
231
- rule.targetHostSuffixes,
232
- `${fieldPath}.targetHostSuffixes`,
233
- );
234
- if (targetHostSuffixes.length === 0)
235
- fail(`${fieldPath}.targetHostSuffixes must not be empty`);
236
243
  const targetPorts =
237
244
  rule.targetPorts === undefined
238
245
  ? []
@@ -242,15 +249,14 @@ export function parseNativeEgressPolicy(value: unknown): NativeEgressPolicySnaps
242
249
  ? []
243
250
  : ranges(rule.targetPortRanges, `${fieldPath}.targetPortRanges`);
244
251
  if (targetPorts.length === 0 && targetPortRanges.length === 0)
245
- fail(
246
- `${fieldPath} must declare a non-empty targetPorts or targetPortRanges list`,
247
- );
252
+ fail(`${fieldPath} must declare a non-empty targetPorts or targetPortRanges list`);
248
253
  return {
249
254
  ...(sourceHost === undefined ? {} : { sourceHost }),
250
255
  sourceHostSuffixes,
251
256
  sourcePorts,
252
257
  sourcePortRanges,
253
258
  targetHostSuffixes,
259
+ targetIpv4Cidrs,
254
260
  targetPorts,
255
261
  targetPortRanges,
256
262
  tls: tls(rule.tls, `${fieldPath}.tls`),