@namzu/sandbox 14.0.0 → 16.0.0

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.
Files changed (100) hide show
  1. package/CHANGELOG.md +924 -0
  2. package/README.md +369 -14
  3. package/dist/backends/aci-standby-pool/index.d.ts.map +1 -1
  4. package/dist/backends/aci-standby-pool/index.js +13 -1
  5. package/dist/backends/aci-standby-pool/index.js.map +1 -1
  6. package/dist/backends/docker/index.d.ts +169 -6
  7. package/dist/backends/docker/index.d.ts.map +1 -1
  8. package/dist/backends/docker/index.js +499 -85
  9. package/dist/backends/docker/index.js.map +1 -1
  10. package/dist/backends/firecracker/index.d.ts.map +1 -1
  11. package/dist/backends/firecracker/index.js +12 -2
  12. package/dist/backends/firecracker/index.js.map +1 -1
  13. package/dist/backends/firecracker/protocol.d.ts +459 -8
  14. package/dist/backends/firecracker/protocol.d.ts.map +1 -1
  15. package/dist/backends/firecracker/protocol.js +136 -0
  16. package/dist/backends/firecracker/protocol.js.map +1 -1
  17. package/dist/backends/firecracker/transport.d.ts +539 -6
  18. package/dist/backends/firecracker/transport.d.ts.map +1 -1
  19. package/dist/backends/firecracker/transport.js +1171 -24
  20. package/dist/backends/firecracker/transport.js.map +1 -1
  21. package/dist/backends/kubernetes/egress-policy.d.ts +1181 -13
  22. package/dist/backends/kubernetes/egress-policy.d.ts.map +1 -1
  23. package/dist/backends/kubernetes/egress-policy.js +2350 -31
  24. package/dist/backends/kubernetes/egress-policy.js.map +1 -1
  25. package/dist/backends/kubernetes/identity.d.ts +193 -0
  26. package/dist/backends/kubernetes/identity.d.ts.map +1 -0
  27. package/dist/backends/kubernetes/identity.js +147 -0
  28. package/dist/backends/kubernetes/identity.js.map +1 -0
  29. package/dist/backends/kubernetes/index.d.ts +678 -33
  30. package/dist/backends/kubernetes/index.d.ts.map +1 -1
  31. package/dist/backends/kubernetes/index.js +1180 -95
  32. package/dist/backends/kubernetes/index.js.map +1 -1
  33. package/dist/backends/kubernetes/ingress-policy.d.ts +375 -0
  34. package/dist/backends/kubernetes/ingress-policy.d.ts.map +1 -0
  35. package/dist/backends/kubernetes/ingress-policy.js +1050 -0
  36. package/dist/backends/kubernetes/ingress-policy.js.map +1 -0
  37. package/dist/backends/kubernetes/k8s-client.d.ts +213 -4
  38. package/dist/backends/kubernetes/k8s-client.d.ts.map +1 -1
  39. package/dist/backends/kubernetes/k8s-client.js +359 -52
  40. package/dist/backends/kubernetes/k8s-client.js.map +1 -1
  41. package/dist/backends/kubernetes/lease.d.ts +40 -14
  42. package/dist/backends/kubernetes/lease.d.ts.map +1 -1
  43. package/dist/backends/kubernetes/lease.js +68 -18
  44. package/dist/backends/kubernetes/lease.js.map +1 -1
  45. package/dist/backends/kubernetes/objects.d.ts +423 -3
  46. package/dist/backends/kubernetes/objects.d.ts.map +1 -1
  47. package/dist/backends/kubernetes/objects.js +364 -2
  48. package/dist/backends/kubernetes/objects.js.map +1 -1
  49. package/dist/backends/kubernetes/per-sandbox-policy.d.ts +219 -0
  50. package/dist/backends/kubernetes/per-sandbox-policy.d.ts.map +1 -0
  51. package/dist/backends/kubernetes/per-sandbox-policy.js +375 -0
  52. package/dist/backends/kubernetes/per-sandbox-policy.js.map +1 -0
  53. package/dist/backends/kubernetes/rbac.d.ts +153 -0
  54. package/dist/backends/kubernetes/rbac.d.ts.map +1 -0
  55. package/dist/backends/kubernetes/rbac.js +177 -0
  56. package/dist/backends/kubernetes/rbac.js.map +1 -0
  57. package/dist/backends/kubernetes/sandbox.d.ts +81 -14
  58. package/dist/backends/kubernetes/sandbox.d.ts.map +1 -1
  59. package/dist/backends/kubernetes/sandbox.js +149 -15
  60. package/dist/backends/kubernetes/sandbox.js.map +1 -1
  61. package/dist/backends/kubernetes/transport.d.ts +935 -9
  62. package/dist/backends/kubernetes/transport.d.ts.map +1 -1
  63. package/dist/backends/kubernetes/transport.js +1958 -62
  64. package/dist/backends/kubernetes/transport.js.map +1 -1
  65. package/dist/backends/kubernetes/workspace.d.ts +1149 -18
  66. package/dist/backends/kubernetes/workspace.d.ts.map +1 -1
  67. package/dist/backends/kubernetes/workspace.js +2825 -186
  68. package/dist/backends/kubernetes/workspace.js.map +1 -1
  69. package/dist/backends/remote-execution-controller.d.ts +14 -0
  70. package/dist/backends/remote-execution-controller.d.ts.map +1 -1
  71. package/dist/backends/remote-execution-controller.js.map +1 -1
  72. package/dist/index.d.ts +294 -18
  73. package/dist/index.d.ts.map +1 -1
  74. package/dist/index.js +280 -10
  75. package/dist/index.js.map +1 -1
  76. package/dist/testing/sandbox-conformance.d.ts +39 -5
  77. package/dist/testing/sandbox-conformance.d.ts.map +1 -1
  78. package/dist/testing/sandbox-conformance.js +436 -5
  79. package/dist/testing/sandbox-conformance.js.map +1 -1
  80. package/package.json +3 -3
  81. package/src/backends/aci-standby-pool/index.ts +16 -1
  82. package/src/backends/docker/index.ts +617 -100
  83. package/src/backends/firecracker/index.ts +14 -2
  84. package/src/backends/firecracker/protocol.ts +514 -6
  85. package/src/backends/firecracker/transport.ts +1492 -40
  86. package/src/backends/kubernetes/egress-policy.ts +3334 -55
  87. package/src/backends/kubernetes/identity.ts +261 -0
  88. package/src/backends/kubernetes/index.ts +1785 -127
  89. package/src/backends/kubernetes/ingress-policy.ts +1344 -0
  90. package/src/backends/kubernetes/k8s-client.ts +444 -54
  91. package/src/backends/kubernetes/lease.ts +75 -19
  92. package/src/backends/kubernetes/objects.ts +626 -6
  93. package/src/backends/kubernetes/per-sandbox-policy.ts +497 -0
  94. package/src/backends/kubernetes/rbac.ts +192 -0
  95. package/src/backends/kubernetes/sandbox.ts +218 -20
  96. package/src/backends/kubernetes/transport.ts +2733 -124
  97. package/src/backends/kubernetes/workspace.ts +4476 -222
  98. package/src/backends/remote-execution-controller.ts +14 -0
  99. package/src/index.ts +668 -19
  100. package/src/testing/sandbox-conformance.ts +540 -5
@@ -76,11 +76,470 @@
76
76
  * the kernel, not by the workload's cooperation.
77
77
  */
78
78
  import { isDeepStrictEqual } from 'node:util';
79
+ // The policy-enumeration and shape-reading primitives, shared with the
80
+ // ingress direction. There is one enumeration of the policies selecting a pod
81
+ // in this package and one reading of what a peer is; see that module's
82
+ // "Shared with the egress direction".
83
+ import { UnreadPolicyCollection, ciliumIdentityLabels, ciliumSelectorKey, corePeerIsWideOpen, formatLabels, isRecord, listPolicies, matchesLabelSelector, policyName, readList, selectorIsReadable, } from './ingress-policy.js';
79
84
  import { KubernetesAlreadyGoneError } from './k8s-client.js';
80
- import { CILIUM_NETWORK_POLICY_API_GROUP, CILIUM_NETWORK_POLICY_API_VERSION, CORE_NETWORK_POLICY_API_GROUP, CORE_NETWORK_POLICY_API_VERSION, ciliumNetworkPolicyPath, networkPolicyPath, sandboxTemplateLabel, } from './objects.js';
81
- /** `${sandboxTemplateName}-egress`, the name {@link KubernetesEgressConfig.networkPolicyName} defaults to. */
82
- export function defaultEgressPolicyName(sandboxTemplateName) {
83
- return `${sandboxTemplateName}-egress`;
85
+ import { CILIUM_NETWORK_POLICY_API_GROUP, CILIUM_NETWORK_POLICY_API_VERSION, CORE_NETWORK_POLICY_API_GROUP, CORE_NETWORK_POLICY_API_VERSION, SANDBOX_TEMPLATE_LABEL_KEY, ciliumNetworkPolicyCollectionPath, ciliumNetworkPolicyPath, networkPolicyCollectionPath, networkPolicyPath, sandboxTemplateLabel, } from './objects.js';
86
+ /**
87
+ * Default {@link KubernetesEgressConfig.profileLabelKey} — this backend's
88
+ * own label domain, matching `SANDBOX_TEMPLATE_LABEL_KEY`'s prefix so
89
+ * every label a namzu host puts on a sandbox pod reads as one family.
90
+ *
91
+ * It is deliberately NOT upstream's `sandbox.users.io`: a key in someone
92
+ * else's domain is a key someone else may define differently. The cost is
93
+ * the ConfigMap edit named on {@link KubernetesEgressConfig.profile}, and
94
+ * the controller's refusal spells that edit out itself.
95
+ */
96
+ export const DEFAULT_EGRESS_PROFILE_LABEL_KEY = 'sandbox.namzu.ai/egress-profile';
97
+ /**
98
+ * Kubernetes' own label-value grammar, narrowed to DNS-1123: lowercase
99
+ * alphanumerics and `-`, starting and ending alphanumeric, at most 63
100
+ * characters.
101
+ *
102
+ * Narrower than what a label value may legally hold (`_` and `.` are legal
103
+ * there, and uppercase is too) because the profile is also a NAME: it goes
104
+ * into `${template}-${profile}-egress`, which has to be a legal object name,
105
+ * and a value that is legal as a label but not as a name would produce a
106
+ * policy an operator cannot apply.
107
+ */
108
+ const DNS_1123_LABEL = /^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$/;
109
+ /**
110
+ * A label KEY: an optional DNS-subdomain prefix, a `/`, then a name segment
111
+ * of at most 63 characters. Exactly what the API server enforces, checked
112
+ * here so a typo is refused during host wiring rather than as a claim the
113
+ * controller rejects one round trip later.
114
+ */
115
+ const LABEL_KEY_NAME = /^[A-Za-z0-9]([-A-Za-z0-9_.]{0,61}[A-Za-z0-9])?$/;
116
+ const LABEL_KEY_PREFIX = /^[a-z0-9]([-a-z0-9.]{0,251}[a-z0-9])?$/;
117
+ /**
118
+ * Named refusal for an egress PROFILE this backend can read but not use.
119
+ * Sibling of {@link KubernetesEgressPolicyConfigError} rather than a reuse of
120
+ * it: that one names a field under `config.egress.policy` and this one names
121
+ * a field beside it, and an operator reading either should not have to work
122
+ * out which level of the config the path belongs to.
123
+ *
124
+ * Thrown SYNCHRONOUSLY from `buildKubernetesBackend` and
125
+ * `createKubernetesWorkspace`, so a misconfigured profile surfaces during
126
+ * host wiring rather than on the first `create()`.
127
+ */
128
+ export class KubernetesEgressProfileConfigError extends Error {
129
+ field;
130
+ value;
131
+ name = 'KubernetesEgressProfileConfigError';
132
+ constructor(field, value, reason) {
133
+ super(`kubernetes: config.egress.${field} is unusable: ${JSON.stringify(value)} ${reason}. The profile travels onto a SandboxClaim's additionalPodMetadata.labels, onto a directly created Sandbox's pod template and into the translated policy's own selector, so a value the API server would reject leaves either a claim nothing binds or a policy nobody can apply. Refusing here rather than emitting it.`);
134
+ this.field = field;
135
+ this.value = value;
136
+ }
137
+ }
138
+ /**
139
+ * The profile label this config asks for, or nothing at all — the ONE place
140
+ * the key/value pair is derived, so the claim body, the Sandbox pod
141
+ * template, the policy selector and the policy name cannot disagree about
142
+ * what the profile is.
143
+ *
144
+ * Validates as it resolves: the value has to be a DNS-1123 label and the key
145
+ * a legal label key, both refused with {@link KubernetesEgressProfileConfigError}.
146
+ */
147
+ export function egressProfileLabel(egress) {
148
+ // The KEY is validated whenever it is present, profile or no profile. A
149
+ // key set without a value is a half-finished configuration — the value is
150
+ // usually the next line someone writes — and reporting the typo only once
151
+ // the profile arrives is reporting it at the second edit rather than the
152
+ // first.
153
+ const configured = egress?.profileLabelKey;
154
+ if (configured !== undefined)
155
+ assertUsableProfileLabelKey(configured);
156
+ const value = egress?.profile;
157
+ if (value === undefined)
158
+ return undefined;
159
+ if (!DNS_1123_LABEL.test(value)) {
160
+ throw new KubernetesEgressProfileConfigError('profile', value, 'is not a DNS-1123 label (lowercase letters, digits and dashes, starting and ending alphanumeric, at most 63 characters)');
161
+ }
162
+ const key = configured ?? DEFAULT_EGRESS_PROFILE_LABEL_KEY;
163
+ return { key, value };
164
+ }
165
+ /**
166
+ * Why a label KEY is unusable, or `undefined` when it is fine — the grammar
167
+ * half of the two checks below, shared because the API server's rule is the
168
+ * same whichever of this backend's label keys is being configured and two
169
+ * spellings of it would be two rules.
170
+ */
171
+ function labelKeyProblem(key) {
172
+ const slash = key.indexOf('/');
173
+ const name = slash === -1 ? key : key.slice(slash + 1);
174
+ const prefix = slash === -1 ? undefined : key.slice(0, slash);
175
+ if (!LABEL_KEY_NAME.test(name) ||
176
+ (prefix !== undefined && !LABEL_KEY_PREFIX.test(prefix)) ||
177
+ key.indexOf('/', slash + 1) !== -1) {
178
+ return 'is not a Kubernetes label key (an optional DNS-subdomain prefix, a single slash, then a name of at most 63 characters)';
179
+ }
180
+ return undefined;
181
+ }
182
+ /**
183
+ * Refuse a `profileLabelKey` the API server would not take, or that this
184
+ * backend already uses for something else.
185
+ */
186
+ function assertUsableProfileLabelKey(key) {
187
+ const problem = labelKeyProblem(key);
188
+ if (problem !== undefined) {
189
+ throw new KubernetesEgressProfileConfigError('profileLabelKey', key, problem);
190
+ }
191
+ // The one legal key that must not be used: it is the key this backend
192
+ // stamps the SandboxTemplate name under, and the profile label is applied
193
+ // LAST (see `sandboxPodLabels`), so this key would overwrite the template
194
+ // label on every pod this backend creates — and the translated policy's
195
+ // selector, built from the same resolution, would agree with it. Both
196
+ // halves would be wrong together, which is exactly the shape nothing else
197
+ // would catch.
198
+ if (key === SANDBOX_TEMPLATE_LABEL_KEY) {
199
+ throw new KubernetesEgressProfileConfigError('profileLabelKey', key, 'is the key this backend writes the SandboxTemplate name under, and a profile label is applied last — a pod would carry the profile value where its template label belongs, and the policy selector built from the same resolution would match it anyway');
200
+ }
201
+ }
202
+ /**
203
+ * Validate the profile without needing its value — the wiring-time hook, so
204
+ * `buildKubernetesBackend` and `createKubernetesWorkspace` refuse a bad
205
+ * profile the same moment they refuse an unenforceable policy.
206
+ */
207
+ export function assertEgressProfileIsUsable(egress) {
208
+ egressProfileLabel(egress);
209
+ }
210
+ /**
211
+ * Default {@link KubernetesPerSandboxEgressConfig.labelKey} — the key the
212
+ * per-sandbox policy's `endpointSelector` matches on.
213
+ *
214
+ * In this backend's own label domain, for the same reason the profile key is
215
+ * (see {@link DEFAULT_EGRESS_PROFILE_LABEL_KEY}), and carrying the same
216
+ * operator prerequisite: the controller's `allowed-label-domains` allowlist
217
+ * has to admit `sandbox.namzu.ai`, or every claim carrying it is refused with
218
+ * {@link KubernetesPodLabelsRejectedError} as the cause. A deployment that
219
+ * would rather not edit that ConfigMap sets this to a key under
220
+ * `sandbox.users.io`, which is upstream's own default domain.
221
+ */
222
+ export const DEFAULT_PER_SANDBOX_EGRESS_LABEL_KEY = 'sandbox.namzu.ai/per-sandbox-egress';
223
+ /**
224
+ * Named refusal for a `config.egress.perSandbox` this backend can read but
225
+ * not honour. Thrown SYNCHRONOUSLY from `buildKubernetesBackend`, beside the
226
+ * policy and profile refusals, so a host that has mis-declared the capability
227
+ * learns it during wiring rather than from the first `setNetworkPolicy` call
228
+ * — which may be an hour into a run, after work that cannot be redone.
229
+ *
230
+ * Its own class rather than a reuse of {@link KubernetesEgressProfileConfigError}
231
+ * for the reason that one is not a reuse of
232
+ * {@link KubernetesEgressPolicyConfigError}: an operator reading a refusal
233
+ * should not have to work out which level of `config.egress` the named field
234
+ * belongs to.
235
+ */
236
+ export class KubernetesPerSandboxEgressConfigError extends Error {
237
+ field;
238
+ value;
239
+ name = 'KubernetesPerSandboxEgressConfigError';
240
+ constructor(field, value, reason) {
241
+ super(`kubernetes: config.egress.perSandbox.${field} is unusable: ${JSON.stringify(value)} ${reason}. Per-sandbox egress writes one CiliumNetworkPolicy per live sandbox, selected by a per-sandbox pod label and fenced by an operator-applied ValidatingAdmissionPolicy, so a value this backend cannot honour would leave either a policy that selects nothing or a write the fence refuses. Refusing during host wiring rather than at the first setNetworkPolicy call.`);
242
+ this.field = field;
243
+ this.value = value;
244
+ }
245
+ }
246
+ /**
247
+ * The per-sandbox selector label KEY this config asks for, or nothing at all
248
+ * when `perSandbox` is unset — the one place the default is applied, so the
249
+ * pod label, the policy selector and the admission policy's own expectation
250
+ * cannot disagree.
251
+ *
252
+ * Validates as it resolves, exactly as {@link egressProfileLabel} does.
253
+ */
254
+ export function perSandboxEgressLabelKey(egress) {
255
+ const perSandbox = egress?.perSandbox;
256
+ if (perSandbox === undefined)
257
+ return undefined;
258
+ const key = perSandbox.labelKey ?? DEFAULT_PER_SANDBOX_EGRESS_LABEL_KEY;
259
+ const problem = labelKeyProblem(key);
260
+ if (problem !== undefined) {
261
+ throw new KubernetesPerSandboxEgressConfigError('labelKey', key, problem);
262
+ }
263
+ if (key === SANDBOX_TEMPLATE_LABEL_KEY) {
264
+ throw new KubernetesPerSandboxEgressConfigError('labelKey', key, 'is the key this backend writes the SandboxTemplate name under, so a pod would carry a sandbox name where its template label belongs and every policy selecting the template would stop selecting it');
265
+ }
266
+ // The profile's key is refused from the other direction too — see
267
+ // `composeAdditionalPodLabels` — but naming it HERE names the field a
268
+ // reader has to change, which the generic collision message cannot.
269
+ const profile = egressProfileLabel(egress);
270
+ if (profile !== undefined && key === profile.key) {
271
+ throw new KubernetesPerSandboxEgressConfigError('labelKey', key, 'is also config.egress.profileLabelKey, and both are written into the SAME pod-label map — one value would silently replace the other, and whichever lost would be a label a policy selector still expects');
272
+ }
273
+ return key;
274
+ }
275
+ /**
276
+ * Validate `config.egress.perSandbox` in full — the wiring-time hook, so a
277
+ * mis-declared capability is refused the same moment an unenforceable policy
278
+ * or an unusable profile is.
279
+ *
280
+ * `engine: 'core'` is the refusal the SDK's contract cares about: core
281
+ * `NetworkPolicy` cannot express a hostname at all, so a host that configured
282
+ * it would be told "policy applied" about an object that could never carry
283
+ * the allowlist. It is refused here rather than from the method, which means
284
+ * a `'core'` deployment never gets a handle carrying the method in the first
285
+ * place.
286
+ */
287
+ export function assertPerSandboxEgressIsUsable(egress) {
288
+ const perSandbox = egress?.perSandbox;
289
+ if (perSandbox === undefined)
290
+ return;
291
+ if (perSandbox.engine !== 'cilium') {
292
+ throw new KubernetesPerSandboxEgressConfigError('engine', perSandbox.engine, "is not 'cilium'; a per-sandbox allowlist is a list of HOSTNAMES, and core NetworkPolicy has only ipBlock, podSelector and namespaceSelector — it has no hostname concept to translate one into");
293
+ }
294
+ // `admissionPolicyName` is REQUIRED by the type; the cast is for the
295
+ // caller reaching here from JavaScript, or through a cast of its own.
296
+ // There is no default and there must not be one: the fence is what makes
297
+ // the policy-write RBAC safe to grant, and a host that could skip naming
298
+ // it would hold create/patch/delete on every CiliumNetworkPolicy in the
299
+ // namespace with nothing bounding what it writes.
300
+ const names = [
301
+ ['admissionPolicyName', perSandbox.admissionPolicyName, true],
302
+ ['admissionPolicyBindingName', perSandbox.admissionPolicyBindingName, false],
303
+ ];
304
+ for (const [field, value, required] of names) {
305
+ if (value === undefined) {
306
+ if (!required)
307
+ continue;
308
+ throw new KubernetesPerSandboxEgressConfigError(field, '', 'is required, and has no default: an unnamed fence is an unchecked one');
309
+ }
310
+ if (value === '' || value.trim() !== value) {
311
+ throw new KubernetesPerSandboxEgressConfigError(field, value, 'is not an object name (an empty or space-padded name matches nothing, so the fence check would refuse every write)');
312
+ }
313
+ }
314
+ if (perSandbox.narrowing !== undefined) {
315
+ assertCiliumNarrowingIsUsable(perSandbox.narrowing, 'perSandbox.narrowing');
316
+ }
317
+ perSandboxEgressLabelKey(egress);
318
+ }
319
+ /**
320
+ * Named refusal for `config.egress.perSandbox` reaching an entry point that
321
+ * can never carry the capability it configures: `createKubernetesWorkspace`.
322
+ *
323
+ * `perSandbox` exists to make `Sandbox.setNetworkPolicy` PRESENT on a TASK
324
+ * handle, where the acquire creates the object each policy is named after and
325
+ * owned by and stamps the per-sandbox pod label its selector matches, and
326
+ * where the RBAC and the admission fence are the ones that bound the write.
327
+ * A workspace's create path does none of that — it composes no per-sandbox
328
+ * pod label and tracks no owner uid for one — so on that path the option
329
+ * would be accepted and mean nothing at all: exactly the "declared and
330
+ * silently unused" configuration this module refuses everywhere else. The
331
+ * alternative, omitting the method and saying nothing, is how a host comes to
332
+ * believe it narrowed a workspace's egress.
333
+ *
334
+ * NOT a variant of {@link KubernetesPerSandboxEgressConfigError}, which is
335
+ * about a `perSandbox` value this backend cannot honour ANYWHERE (an engine
336
+ * with no hostname concept, an unnamed fence). This one is about a `perSandbox`
337
+ * value it honours perfectly well on the other entry point, so a caller
338
+ * catching the sibling for a typo'd value does not also catch a correct
339
+ * configuration aimed at the wrong path.
340
+ */
341
+ export class KubernetesWorkspacePerSandboxEgressConfigError extends Error {
342
+ name = 'KubernetesWorkspacePerSandboxEgressConfigError';
343
+ constructor() {
344
+ super(`kubernetes: config.egress.perSandbox is set, but a KubernetesWorkspace cannot carry the capability it configures: Sandbox.setNetworkPolicy is implemented on a TASK handle, whose acquire creates the object each per-sandbox policy is named after and owned by and stamps the pod label its selector matches, while a workspace's create path composes no per-sandbox pod label and tracks no owner uid for one. Accepting the option here would declare a capability this path never serves — the silent downgrade this backend refuses on every other entry point — so it is refused before anything is sent. Drop egress.perSandbox from the configuration workspaces are created from (a backend serving task sandboxes can keep it), or create a task sandbox with it, where the method is present.`);
345
+ }
346
+ }
347
+ /**
348
+ * Refuse `config.egress.perSandbox` on the entry point that cannot carry it —
349
+ * `createKubernetesWorkspace`. See
350
+ * {@link KubernetesWorkspacePerSandboxEgressConfigError}.
351
+ *
352
+ * Deliberately not {@link assertPerSandboxEgressIsUsable}, which validates the
353
+ * same config for the path that DOES honour it: a workspace has nothing to
354
+ * validate the option for, whatever its `engine` or `admissionPolicyName` say,
355
+ * because it serves no `setNetworkPolicy` at all. Calling that validator here
356
+ * instead would be worse than saying nothing — it would report a
357
+ * `perSandbox` this deployment can use as if it were in use.
358
+ */
359
+ export function assertWorkspaceCarriesNoPerSandboxEgress(egress) {
360
+ if (egress?.perSandbox === undefined)
361
+ return;
362
+ throw new KubernetesWorkspacePerSandboxEgressConfigError();
363
+ }
364
+ /**
365
+ * The ONE composer for a sandbox pod's `additionalPodMetadata.labels`.
366
+ *
367
+ * Every label this backend asks the controller to put on a POD is built
368
+ * here: the egress profile today, and whatever a later capability
369
+ * contributes through `extra` (a per-sandbox policy selector, for one). Two
370
+ * independent constructions would be two answers to "what labels is this pod
371
+ * selected by", and the policy selector is built from the same resolution —
372
+ * so a second builder would be a pod bound under a policy nobody checked.
373
+ *
374
+ * Deliberately NOT where `KubernetesBackendInternalConfig.claimLabels`
375
+ * goes. Those are a host's own bookkeeping on the CLAIM object's
376
+ * `metadata.labels`; putting them on the pod would change what selectors
377
+ * match a running sandbox, which is a different question on a different
378
+ * object.
379
+ *
380
+ * Returns an empty object when nothing applies, which every caller reads as
381
+ * "emit nothing at all" — that is what keeps an unprofiled body byte-identical.
382
+ *
383
+ * A key in `extra` that is ALSO the profile's is refused rather than merged
384
+ * either way round. Whichever won, the loser would be a label the translated
385
+ * policy's selector still expects: the profile's selector is built from this
386
+ * same resolution, so a pod carrying the other value is selected by no
387
+ * per-profile policy while `create()` reported the boundary verified. It is
388
+ * the same failure {@link egressProfileLabel} refuses the template key for,
389
+ * reached from the other direction.
390
+ */
391
+ export function composeAdditionalPodLabels(egress, extra) {
392
+ const profile = egressProfileLabel(egress);
393
+ if (profile !== undefined && extra !== undefined && profile.key in extra) {
394
+ throw new KubernetesEgressProfileConfigError('profileLabelKey', profile.key, `is also the key another capability contributes to the same pod-label map (as ${JSON.stringify(extra[profile.key])}), and one of the two values would silently replace the other`);
395
+ }
396
+ return {
397
+ ...(profile !== undefined ? { [profile.key]: profile.value } : {}),
398
+ ...extra,
399
+ };
400
+ }
401
+ /**
402
+ * Why a claim the controller refused with `InvalidMetadata` was refused, in
403
+ * this backend's own terms — in practice a pod label whose domain is not in
404
+ * the controller's `allowed-label-domains` allowlist, which today means the
405
+ * egress profile's.
406
+ *
407
+ * NOT what an acquire throws. A refused claim comes out of `create()` as
408
+ * `KubernetesAcquireError { reason: 'claim-rejected' }` whether or not a
409
+ * profile is configured — one condition, one taxonomy, one `catch` — and this
410
+ * rides as that error's `cause`. Two classes for one controller condition
411
+ * would make a host's error handling correct or incorrect depending on
412
+ * whether `config.egress.profile` happened to be set.
413
+ *
414
+ * What it adds to the acquire error is what the controller cannot know: the
415
+ * map this backend actually sent, and the `config.egress.profileLabelKey`
416
+ * that moves the offending key to an allowed domain. It carries the whole map
417
+ * rather than the profile alone, and `profile` is optional, because the map is
418
+ * {@link composeAdditionalPodLabels}'s — the profile is the only thing in it
419
+ * today, and a later capability adding a second key would otherwise get an
420
+ * explanation that named a label it did not send.
421
+ *
422
+ * It carries the controller's OWN `reason` and `message` rather than a
423
+ * translation of them: the message agent-sandbox v1.0.2 writes names the
424
+ * offending key, the domain, the ConfigMap key to edit and its default, and
425
+ * no paraphrase of it would be as useful. Measured verbatim against a kind
426
+ * cluster running that controller:
427
+ *
428
+ * > invalid additionalPodMetadata: failed to validate label
429
+ * > "sandbox.namzu.ai/egress-profile": label domain "sandbox.namzu.ai" is
430
+ * > not in the allowlist (configure the allowed-label-domains key of the
431
+ * > agent-sandbox-config ConfigMap in the controller namespace; default:
432
+ * > sandbox.users.io)
433
+ *
434
+ * The refusal is raised as soon as that condition is read rather than after
435
+ * the readiness budget, because `InvalidMetadata` is one of
436
+ * `TERMINAL_CLAIM_REASONS` — nothing about it becomes true by waiting — and
437
+ * the claim is deleted on the way out, so a misconfigured profile costs one
438
+ * round trip rather than a minute of polling.
439
+ */
440
+ export class KubernetesPodLabelsRejectedError extends Error {
441
+ requestedPodLabels;
442
+ claimName;
443
+ namespace;
444
+ controllerReason;
445
+ controllerMessage;
446
+ profile;
447
+ name = 'KubernetesPodLabelsRejectedError';
448
+ constructor(
449
+ /** Every label this backend put on the claim's `additionalPodMetadata`. */
450
+ requestedPodLabels, claimName, namespace,
451
+ /** The controller's own condition `reason`, e.g. `InvalidMetadata`. */
452
+ controllerReason,
453
+ /** The controller's own condition `message`, verbatim. */
454
+ controllerMessage,
455
+ /** The egress profile among those labels, when one is configured. */
456
+ profile) {
457
+ super(`kubernetes: the controller refused SandboxClaim ${claimName} in namespace ${namespace} carrying the pod labels ${formatLabels(requestedPodLabels)} — ${controllerReason}: ${controllerMessage}. Those labels are written onto the claim's additionalPodMetadata.labels, so each key's domain has to appear in the controller's allowed-label-domains allowlist; add it there, or move the offending key to a domain that is already allowed${profile === undefined ? '' : ` (config.egress.profileLabelKey, for the egress profile ${profile.key}=${profile.value})`}. The claim has been deleted.`);
458
+ this.requestedPodLabels = requestedPodLabels;
459
+ this.claimName = claimName;
460
+ this.namespace = namespace;
461
+ this.controllerReason = controllerReason;
462
+ this.controllerMessage = controllerMessage;
463
+ this.profile = profile;
464
+ }
465
+ }
466
+ /**
467
+ * Named refusal for a bound pod that never carried a label this backend asked
468
+ * the controller to put on it — the egress profile's, today the only one
469
+ * {@link composeAdditionalPodLabels} produces, which is why the class is named
470
+ * for the LABEL rather than for the profile.
471
+ *
472
+ * This is the one failure this capability must not have quietly. An
473
+ * unlabelled pod handed back is a sandbox running under the DEFAULT policy
474
+ * while the host believes it is on a narrower profile — the translated
475
+ * policy's selector includes the profile label, so a pod without it is
476
+ * selected by neither this profile's policy nor, necessarily, anything else.
477
+ * Refusing is correct and waiting is correct; proceeding is not, so the
478
+ * acquire releases what it claimed and raises this instead.
479
+ *
480
+ * `missingLabel` is the pair that never arrived rather than "the profile", so
481
+ * the refusal stays true for whatever a later capability contributes to that
482
+ * same map: the wait in `readAddressedPod` already covers every entry of it,
483
+ * and this refusal covers exactly the same set.
484
+ */
485
+ export class KubernetesPodLabelNotObservedError extends Error {
486
+ missingLabel;
487
+ subject;
488
+ observedLabels;
489
+ name = 'KubernetesPodLabelNotObservedError';
490
+ constructor(
491
+ /** The label this backend requested and never saw on the bound pod. */
492
+ missingLabel, subject, observedLabels) {
493
+ super(`kubernetes: refusing ${subject} — its pod never carried the label ${missingLabel.key}=${missingLabel.value} this backend asked the controller to put on it, within the readiness budget; the labels it did carry are ${formatLabels(observedLabels)}. The translated policy's selector includes that label, so admitting this pod would run it under whatever policy DOES select it rather than under the configured profile. The controller patches a claim's additionalPodMetadata.labels onto the pod it binds — a pod that never got them means the claim's metadata was not applied. Nothing was handed back and the claim was released.`);
494
+ this.missingLabel = missingLabel;
495
+ this.subject = subject;
496
+ this.observedLabels = observedLabels;
497
+ }
498
+ }
499
+ /** `true` unless the deployment asked for the single-object check by name. */
500
+ export function egressUnionVerificationEnabled(egress) {
501
+ return egress !== undefined && egress.verify !== 'named-object-only';
502
+ }
503
+ /**
504
+ * What a translated policy's `podSelector`/`endpointSelector` matches: the
505
+ * template label, plus the profile label when one is configured. One
506
+ * function so the two manifest builders below and every reader of a
507
+ * translation agree on the selector down to the key order.
508
+ */
509
+ export function egressPolicySelectorLabels(target) {
510
+ return {
511
+ ...sandboxTemplateLabel(target.sandboxTemplateName),
512
+ ...(target.profile !== undefined ? { [target.profile.key]: target.profile.value } : {}),
513
+ };
514
+ }
515
+ /**
516
+ * The longest name a Kubernetes object may carry. A `NetworkPolicy` name is a
517
+ * DNS subdomain, so 253 characters, and the API server refuses anything
518
+ * longer.
519
+ */
520
+ const MAX_OBJECT_NAME_LENGTH = 253;
521
+ /**
522
+ * `${sandboxTemplateName}-egress`, the name
523
+ * {@link KubernetesEgressConfig.networkPolicyName} defaults to — or
524
+ * `${sandboxTemplateName}-${profile}-egress` when a profile is configured,
525
+ * because one template under two profiles needs two policy objects and a
526
+ * single default name would have the second silently verify against the
527
+ * first's manifest.
528
+ *
529
+ * The profile is bounded at 63 characters on its own, but the CONCATENATION
530
+ * is what has to be a legal object name, and only this function knows both
531
+ * halves. Refused here, during host wiring, rather than as an API-server
532
+ * rejection on the first policy GET of the first `create()`: `networkPolicyName`
533
+ * is the way out and it is a config field, so this is a config error.
534
+ */
535
+ export function defaultEgressPolicyName(sandboxTemplateName, profile) {
536
+ if (profile === undefined)
537
+ return `${sandboxTemplateName}-egress`;
538
+ const name = `${sandboxTemplateName}-${profile}-egress`;
539
+ if (name.length > MAX_OBJECT_NAME_LENGTH) {
540
+ throw new KubernetesEgressProfileConfigError('profile', profile, `makes the default egress policy name ${JSON.stringify(name)} ${name.length} characters long, past the ${MAX_OBJECT_NAME_LENGTH} an object name may carry — shorten the profile or the SandboxTemplate name, or set config.egress.networkPolicyName yourself`);
541
+ }
542
+ return name;
84
543
  }
85
544
  /**
86
545
  * Named refusal for a hostname allowlist with no FQDN-capable engine
@@ -97,18 +556,270 @@ export class KubernetesUnenforceableEgressPolicyError extends Error {
97
556
  this.policyKind = policyKind;
98
557
  }
99
558
  }
559
+ /**
560
+ * Named refusal for a config value this backend can read but not use — today
561
+ * only a `public-internet` `exceptCidrs` entry that is not a CIDR. Thrown
562
+ * SYNCHRONOUSLY from `buildKubernetesBackend`, beside
563
+ * {@link KubernetesUnenforceableEgressPolicyError}, so a typo surfaces
564
+ * during host wiring rather than as a policy the API server rejects when an
565
+ * operator applies it.
566
+ */
567
+ export class KubernetesEgressPolicyConfigError extends Error {
568
+ field;
569
+ name = 'KubernetesEgressPolicyConfigError';
570
+ constructor(field, reason) {
571
+ // `field` is relative to `config.egress`, not to `config.egress.policy`:
572
+ // the fields that land here live at BOTH levels — `policy.exceptCidrs`
573
+ // on the policy, `ciliumNarrowing` and `perSandbox.narrowing` beside
574
+ // it — and a prefix that assumed one of them sent a reader to a key
575
+ // that does not exist.
576
+ super(`kubernetes: config.egress.${field} is unusable: ${reason}`);
577
+ this.field = field;
578
+ }
579
+ }
580
+ /**
581
+ * Named refusal for `config.egress.ciliumNarrowing` set on a policy/engine
582
+ * combination it does not apply to. Thrown SYNCHRONOUSLY from
583
+ * {@link assertEgressPolicyIsEnforceable}, beside
584
+ * {@link KubernetesUnenforceableEgressPolicyError}, so a narrowing option
585
+ * that would silently do nothing is refused at host-wiring time rather than
586
+ * accepted and ignored.
587
+ */
588
+ export class KubernetesEgressNarrowingUnsupportedError extends Error {
589
+ policyKind;
590
+ engine;
591
+ name = 'KubernetesEgressNarrowingUnsupportedError';
592
+ constructor(policyKind, engine) {
593
+ super(`kubernetes: config.egress.ciliumNarrowing is set, but config.egress.policy is '${policyKind}' under engine ${JSON.stringify(engine)}. Port, DNS-name and TLS-server-name narrowing only apply to a 'static' or 'resolver' hostname allowlist under engine: 'cilium' — set engine: 'cilium' with one of those policy kinds, or remove ciliumNarrowing. Refusing rather than silently ignoring an option that would never be applied.`);
594
+ this.policyKind = policyKind;
595
+ this.engine = engine;
596
+ }
597
+ }
598
+ /**
599
+ * The grammar a host refusal closes with: what an `allowedHosts` entry is.
600
+ * Every translation in this module implements it, which is why nothing turns
601
+ * it off — see {@link KubernetesNetworkPolicyHostError}.
602
+ */
603
+ const HOSTNAME_GRAMMAR_SENTENCE = "Entries are hostnames: 'api.example.com' for one host, '.example.com' for that domain and its subdomains.";
604
+ /**
605
+ * Raised for an `allowedHosts` entry this backend will not translate.
606
+ *
607
+ * Named for the ENTRY rather than for a config field, because entries arrive
608
+ * from two directions: a host's `Sandbox.setNetworkPolicy` call, and the
609
+ * `allowedHosts` of a `static`/`resolver` `config.egress.policy`.
610
+ * `SandboxNetworkPolicy` names HOSTS: `api.example.com` for one host,
611
+ * `.example.com` for a domain and its subdomains. A glob, a URL, a port
612
+ * suffix or an address is refused rather than emitted, because a
613
+ * `CiliumNetworkPolicy` carrying one is an object the API server rejects on
614
+ * apply — or, worse, accepts as a name that resolves to nothing, which reads
615
+ * from the outside exactly like a policy that is working.
616
+ */
617
+ export class KubernetesNetworkPolicyHostError extends Error {
618
+ host;
619
+ name = 'KubernetesNetworkPolicyHostError';
620
+ constructor(host, reason) {
621
+ super(`kubernetes: the allowedHosts entry ${JSON.stringify(host)} is refused: it ${reason}. ${HOSTNAME_GRAMMAR_SENTENCE} Nothing was written.`);
622
+ this.host = host;
623
+ }
624
+ }
625
+ /** A DNS name, lowercase, no scheme, no port, no wildcard. */
626
+ const DNS_NAME = /^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$/;
627
+ /**
628
+ * The one grammar an `allowedHosts` entry has to satisfy, on EVERY path that
629
+ * emits one.
630
+ *
631
+ * It lives here, beside the class it throws, rather than in the per-sandbox
632
+ * module that first needed it: it is called from {@link
633
+ * buildCiliumEgressManifest} — the single builder both translations go
634
+ * through — so the config-level allowlist and a `setNetworkPolicy` list are
635
+ * refused the SAME entries, by name, with nothing emitted. That is the
636
+ * contract {@link KubernetesNetworkPolicyHostError} already states — it
637
+ * names entries rather than a config field, for exactly this reason — and a
638
+ * grammar only ONE of the two translations enforces is not one the other has:
639
+ * that is how the config-level translation came to pass `'.com'`, `'.'`,
640
+ * `'..example.com'`, `'*'` and an IP literal straight into the emitted
641
+ * `toFQDNs`, where the per-sandbox path refused every one of them.
642
+ *
643
+ * The per-sandbox writer still calls it too — {@link normalizeHost}, which
644
+ * lowercases and then calls this — because there it also CANONICALISES the
645
+ * bytes, before the fence is read and before anything is queued.
646
+ */
647
+ export function assertUsableHost(entry) {
648
+ // The two reasons the per-sandbox writer's own type check used to give
649
+ // first, kept apart here so an untyped caller meets the same sentence
650
+ // whichever path reached this — see the note on lowercasing below.
651
+ if (typeof entry !== 'string') {
652
+ throw new KubernetesNetworkPolicyHostError(String(entry), 'is not a string');
653
+ }
654
+ if (entry === '') {
655
+ throw new KubernetesNetworkPolicyHostError(entry, 'is empty');
656
+ }
657
+ const bare = entry.startsWith('.') ? entry.slice(1) : entry;
658
+ if (bare === '') {
659
+ throw new KubernetesNetworkPolicyHostError(entry, 'names no domain after its leading dot');
660
+ }
661
+ if (entry.includes('*')) {
662
+ throw new KubernetesNetworkPolicyHostError(entry, "contains a glob; a domain and its subdomains are written with a leading dot ('.example.com'), which becomes matchName plus matchPattern");
663
+ }
664
+ if (!DNS_NAME.test(bare)) {
665
+ throw new KubernetesNetworkPolicyHostError(entry, 'is not a DNS name (a scheme, a path, a port suffix and an IP address all land here; letter case is canonicalised before this check, so it is never the cause)');
666
+ }
667
+ if (bare.length > 253) {
668
+ throw new KubernetesNetworkPolicyHostError(entry, 'is longer than a DNS name may be');
669
+ }
670
+ // A leading-dot entry becomes `matchPattern: '*.<domain>'`, and a
671
+ // single-label domain there is a whole public suffix — `.com`, `.org`.
672
+ // That is not an allowlist entry: it admits every name under a registry
673
+ // the caller does not control. Cilium would match it if it were applied,
674
+ // which is why this is a refusal rather than a lenient pass, and the
675
+ // shipped admission fence refuses the same pattern for the per-sandbox
676
+ // writes it binds (its `matchConditions` scope it to the sandbox host's
677
+ // ServiceAccount, so an operator applying the config-level object is not
678
+ // covered by it — one more reason the refusal has to come from here).
679
+ if (entry.startsWith('.') && !bare.includes('.')) {
680
+ throw new KubernetesNetworkPolicyHostError(entry, "names a whole top-level domain ('.com' means every name under it); a domain entry needs at least two labels, as in '.example.com', and the '*.com' pattern this would emit is one the shipped admission policy refuses for the per-sandbox writes it binds");
681
+ }
682
+ // An IPv4 literal passes the grammar above — every label is digits, and
683
+ // digits are legal in a DNS label. It is still not a hostname: a DNS
684
+ // top-level label is never all-numeric, and Cilium's `matchName` is
685
+ // compared against names the DNS proxy SAW, which an address never is. A
686
+ // policy carrying one is admitted and matches nothing, which reads from
687
+ // outside exactly like a policy that is working.
688
+ const lastLabel = bare.slice(bare.lastIndexOf('.') + 1);
689
+ if (/^[0-9]+$/.test(lastLabel)) {
690
+ throw new KubernetesNetworkPolicyHostError(entry, 'ends in an all-numeric label, so it is an address rather than a hostname; toFQDNs matches names a DNS lookup returned, and an address is never one of them (use config.egress.policy for address-based egress)');
691
+ }
692
+ }
693
+ /**
694
+ * Every entry of one allowlist, judged by {@link assertUsableHost} — called
695
+ * from {@link buildCiliumEgressManifest} before anything is emitted, so BOTH
696
+ * translations refuse the same entries.
697
+ *
698
+ * The entry is lowercased for the DECISION and not for the bytes. The
699
+ * per-sandbox writer canonicalises before it validates (`normalizeHost`), so
700
+ * judging the lowercased form is what makes the two paths agree on WHICH
701
+ * entries are refused — while the emitted `matchName` stays the string the
702
+ * caller wrote, exactly as every release before this one emitted it. This
703
+ * call refuses what no translation can express, not what one of them would
704
+ * spell differently.
705
+ */
706
+ function assertHostsAreUsable(allowedHosts) {
707
+ for (const host of allowedHosts) {
708
+ assertUsableHost(typeof host === 'string' ? host.toLowerCase() : host);
709
+ }
710
+ }
711
+ /**
712
+ * Every entry in `narrowing.ports`, `narrowing.hostPorts` and
713
+ * `narrowing.tlsPorts` is a port the API server will actually accept, none of
714
+ * those three is an explicitly empty array, and every DNS suffix
715
+ * `narrowing.dnsNames` names is a non-empty string — checked here,
716
+ * synchronously, for the same reason {@link assertEgressPolicyIsEnforceable}
717
+ * checks `exceptCidrs`: a value the API server rejects on apply would leave a
718
+ * policy that never verifies.
719
+ */
720
+ /**
721
+ * Refuse a narrowing the API server would reject on apply, naming the field
722
+ * that has to change.
723
+ *
724
+ * `fieldPath` is how the refusal spells the option's location, because the
725
+ * same shape is configurable in two places now: `ciliumNarrowing` narrows the
726
+ * CONFIG-level translated policy, and `perSandbox.narrowing` narrows the
727
+ * per-sandbox policies a host writes through `setNetworkPolicy`. One
728
+ * validator, because they are one shape and two would disagree the first time
729
+ * either grew a field.
730
+ */
731
+ export function assertCiliumNarrowingIsUsable(narrowing, fieldPath = 'ciliumNarrowing') {
732
+ const assertValidPort = (port, field) => {
733
+ if (typeof port !== 'number' || !Number.isInteger(port) || port < 1 || port > 65535) {
734
+ throw new KubernetesEgressPolicyConfigError(field, `${JSON.stringify(port)} is not a TCP port a NetworkPolicy/CiliumNetworkPolicy can carry (an integer 1-65535)`);
735
+ }
736
+ };
737
+ // `undefined` means "no restriction on this list" and is fine; `[]` is
738
+ // neither that nor a usable restriction — `narrowedHostFqdnRule` would
739
+ // emit `toPorts: [{ ports: [] }]` for it, a shape the API server rejects
740
+ // on apply. Refuse it here rather than let a typo (`ports: []` where
741
+ // `ports` was meant to be omitted) reach the cluster as a policy that
742
+ // never verifies.
743
+ const assertNotEmptyPortList = (ports, field) => {
744
+ if (ports !== undefined && ports.length === 0) {
745
+ throw new KubernetesEgressPolicyConfigError(field, 'must not be an empty array — omit the field entirely for no port restriction, or list at least one port');
746
+ }
747
+ };
748
+ assertNotEmptyPortList(narrowing.ports, `${fieldPath}.ports`);
749
+ for (const port of narrowing.ports ?? [])
750
+ assertValidPort(port, `${fieldPath}.ports`);
751
+ for (const [host, ports] of Object.entries(narrowing.hostPorts ?? {})) {
752
+ assertNotEmptyPortList(ports, `${fieldPath}.hostPorts[${JSON.stringify(host)}]`);
753
+ for (const port of ports) {
754
+ assertValidPort(port, `${fieldPath}.hostPorts[${JSON.stringify(host)}]`);
755
+ }
756
+ }
757
+ assertNotEmptyPortList(narrowing.tlsPorts, `${fieldPath}.tlsPorts`);
758
+ for (const port of narrowing.tlsPorts ?? [])
759
+ assertValidPort(port, `${fieldPath}.tlsPorts`);
760
+ if (typeof narrowing.dnsNames === 'object') {
761
+ const { clusterDomain, searchSuffixes } = narrowing.dnsNames;
762
+ if (clusterDomain !== undefined && clusterDomain.trim() === '') {
763
+ throw new KubernetesEgressPolicyConfigError(`${fieldPath}.dnsNames.clusterDomain`, 'must not be an empty string');
764
+ }
765
+ for (const suffix of searchSuffixes ?? []) {
766
+ if (typeof suffix !== 'string' || suffix.trim() === '') {
767
+ throw new KubernetesEgressPolicyConfigError(`${fieldPath}.dnsNames.searchSuffixes`, `${JSON.stringify(suffix)} is not a usable DNS suffix`);
768
+ }
769
+ }
770
+ }
771
+ }
772
+ /** Does `narrowing` actually ask for anything, or is every field off/absent. */
773
+ function ciliumNarrowingIsActive(narrowing) {
774
+ if (narrowing === undefined)
775
+ return false;
776
+ if (narrowing.ports !== undefined)
777
+ return true;
778
+ if (narrowing.hostPorts !== undefined && Object.keys(narrowing.hostPorts).length > 0)
779
+ return true;
780
+ if (dnsNarrowingIsActive(narrowing.dnsNames))
781
+ return true;
782
+ if (narrowing.tlsServerNames === true)
783
+ return true;
784
+ return false;
785
+ }
786
+ function dnsNarrowingIsActive(dnsNames) {
787
+ return activeDnsNarrowing(dnsNames) !== undefined;
788
+ }
789
+ /** `dnsNames` reduced to the config it names, or `undefined` when it is off. `true` reduces to every default. */
790
+ function activeDnsNarrowing(dnsNames) {
791
+ if (dnsNames === true)
792
+ return {};
793
+ if (typeof dnsNames === 'object' && dnsNames !== null)
794
+ return dnsNames;
795
+ return undefined;
796
+ }
100
797
  /**
101
798
  * Synchronous, no-I/O precondition: can `policy.kind` be enforced under
102
- * `engine` at all. Deliberately decided from the KIND alone — a `resolver`
103
- * policy's `resolve()` is never invoked here, both because calling it just to
104
- * prove a refusal would be wasted work (and possibly a side effect the host
105
- * did not expect yet) and because this has to stay callable synchronously
106
- * from `buildKubernetesBackend`, which contacts nothing.
799
+ * `engine` at all, and — if `narrowing` is set — does it apply to this
800
+ * policy/engine combination. Deliberately decided from the KIND alone — a
801
+ * `resolver` policy's `resolve()` is never invoked here, both because calling
802
+ * it just to prove a refusal would be wasted work (and possibly a side effect
803
+ * the host did not expect yet) and because this has to stay callable
804
+ * synchronously from `buildKubernetesBackend`, which contacts nothing.
107
805
  */
108
- export function assertEgressPolicyIsEnforceable(policy, engine) {
806
+ export function assertEgressPolicyIsEnforceable(policy, engine, narrowing) {
109
807
  if ((policy.kind === 'static' || policy.kind === 'resolver') && engine !== 'cilium') {
110
808
  throw new KubernetesUnenforceableEgressPolicyError(policy.kind);
111
809
  }
810
+ if (policy.kind === 'public-internet') {
811
+ for (const cidr of policy.exceptCidrs ?? []) {
812
+ if (parseCidr(cidr) === undefined) {
813
+ throw new KubernetesEgressPolicyConfigError('policy.exceptCidrs', `${JSON.stringify(cidr)} is not an IPv4 or IPv6 CIDR this backend can read. A NetworkPolicy ipBlock.except entry has to be a CIDR inside the block it carves out of, and an entry the API server rejects on apply would leave a policy that never verifies.`);
814
+ }
815
+ }
816
+ }
817
+ if (ciliumNarrowingIsActive(narrowing)) {
818
+ if (engine !== 'cilium' || (policy.kind !== 'static' && policy.kind !== 'resolver')) {
819
+ throw new KubernetesEgressNarrowingUnsupportedError(policy.kind, engine);
820
+ }
821
+ assertCiliumNarrowingIsUsable(narrowing);
822
+ }
112
823
  }
113
824
  /**
114
825
  * The cluster DNS egress rule every translated `NetworkPolicy` carries,
@@ -116,12 +827,125 @@ export function assertEgressPolicyIsEnforceable(policy, engine) {
116
827
  * allows the cluster's own DNS" section.
117
828
  */
118
829
  const CLUSTER_DNS_EGRESS_RULE = {
119
- to: [{ namespaceSelector: { matchLabels: { 'kubernetes.io/metadata.name': 'kube-system' } } }],
830
+ to: [
831
+ {
832
+ namespaceSelector: {
833
+ matchLabels: { 'kubernetes.io/metadata.name': 'kube-system' },
834
+ },
835
+ },
836
+ ],
837
+ ports: [
838
+ { protocol: 'UDP', port: 53 },
839
+ { protocol: 'TCP', port: 53 },
840
+ ],
841
+ };
842
+ /**
843
+ * DNS to the cluster resolver's own pods, which is narrower than
844
+ * {@link CLUSTER_DNS_EGRESS_RULE}'s whole-namespace rule and is what
845
+ * `'public-internet'` emits: that kind exists to name a destination set
846
+ * precisely, so it names the resolver precisely too. `kube-system` +
847
+ * `k8s-app: kube-dns` is the pairing CoreDNS ships under on every cluster
848
+ * this was checked against, and the same pairing the repo's own
849
+ * `k8s/manifests/networkpolicy.yaml` baseline already uses.
850
+ *
851
+ * `'deny-all'` keeps {@link CLUSTER_DNS_EGRESS_RULE} instead. It is not
852
+ * changed to this one: its emitted manifest is pinned byte-for-byte, because
853
+ * verification of the named object is an exact match and any change to the
854
+ * translation fails every `create()` on every deployment that already
855
+ * applied a policy.
856
+ */
857
+ const KUBE_DNS_EGRESS_RULE = {
858
+ to: [
859
+ {
860
+ namespaceSelector: {
861
+ matchLabels: { 'kubernetes.io/metadata.name': 'kube-system' },
862
+ },
863
+ podSelector: { matchLabels: { 'k8s-app': 'kube-dns' } },
864
+ },
865
+ ],
120
866
  ports: [
121
867
  { protocol: 'UDP', port: 53 },
122
868
  { protocol: 'TCP', port: 53 },
123
869
  ],
124
870
  };
871
+ /**
872
+ * What `'public-internet'` carves out of `0.0.0.0/0`, and why each entry is
873
+ * here. Order is part of the emitted manifest and therefore part of what
874
+ * verification compares, so it is fixed rather than sorted at build time.
875
+ *
876
+ * - `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16` — RFC 1918. The cluster
877
+ * network, the node network and every other sandbox pod live in one of
878
+ * them on every deployment this was checked against.
879
+ * - `100.64.0.0/10` — RFC 6598 carrier-grade NAT, which several managed
880
+ * Kubernetes offerings hand to pods or nodes. Missing from the policy the
881
+ * agent-sandbox controller writes when a template declares no
882
+ * `networkPolicy`, which is exactly why this check compares except lists
883
+ * rather than trusting that a policy "looks restrictive".
884
+ * - `169.254.0.0/16` — link-local, and with it `169.254.169.254`, the
885
+ * instance-metadata address whose credentials are the reason a sandbox
886
+ * reaching "only the internet" still must not reach sideways.
887
+ * - `127.0.0.0/8` — loopback. Not routable off the pod, but a policy that
888
+ * says "the public internet" should not say it admits loopback either.
889
+ * - `168.63.129.16/32` — one cloud's platform endpoint, a single address
890
+ * outside every range above that answers DNS and instance services.
891
+ */
892
+ const PUBLIC_INTERNET_EXCLUDED_IPV4_CIDRS = [
893
+ '10.0.0.0/8',
894
+ '172.16.0.0/12',
895
+ '192.168.0.0/16',
896
+ '100.64.0.0/10',
897
+ '169.254.0.0/16',
898
+ '127.0.0.0/8',
899
+ '168.63.129.16/32',
900
+ ];
901
+ /**
902
+ * The IPv6 equivalents: unique-local (`fc00::/7`), link-local
903
+ * (`fe80::/10`, which carries the v6 spelling of instance metadata) and
904
+ * loopback (`::1/128`). A cluster with no IPv6 at all is unaffected by the
905
+ * rule's presence — it names destinations nothing routes to.
906
+ */
907
+ const PUBLIC_INTERNET_EXCLUDED_IPV6_CIDRS = ['fc00::/7', 'fe80::/10', '::1/128'];
908
+ /**
909
+ * The one rule `'public-internet'` adds beside DNS: everything, minus the
910
+ * lists above, minus whatever the deployment added. No `ports`, because the
911
+ * kind is a statement about DESTINATIONS — a deployment that also wants to
912
+ * bound ports states that in its own applied policy, which this check then
913
+ * accepts as narrower.
914
+ *
915
+ * A deployment's own `exceptCidrs` are routed to the block of their own
916
+ * address family: a `NetworkPolicy` requires every `except` entry to sit
917
+ * inside the `cidr` it carves out of, so an IPv6 entry under the IPv4 block
918
+ * is rejected on apply.
919
+ */
920
+ function publicInternetEgressRule(exceptCidrs) {
921
+ const extraV4 = [];
922
+ const extraV6 = [];
923
+ for (const cidr of exceptCidrs ?? []) {
924
+ const parsed = parseCidr(cidr);
925
+ // Unparseable entries are refused by `assertEgressPolicyIsEnforceable`
926
+ // at construction; reaching here with one would mean this function was
927
+ // called around it, so it is dropped rather than emitted.
928
+ if (parsed === undefined)
929
+ continue;
930
+ (parsed.version === 4 ? extraV4 : extraV6).push(cidr);
931
+ }
932
+ return {
933
+ to: [
934
+ {
935
+ ipBlock: {
936
+ cidr: '0.0.0.0/0',
937
+ except: [...PUBLIC_INTERNET_EXCLUDED_IPV4_CIDRS, ...extraV4],
938
+ },
939
+ },
940
+ {
941
+ ipBlock: {
942
+ cidr: '::/0',
943
+ except: [...PUBLIC_INTERNET_EXCLUDED_IPV6_CIDRS, ...extraV6],
944
+ },
945
+ },
946
+ ],
947
+ };
948
+ }
125
949
  /**
126
950
  * Cilium requires DNS lookups to be explicitly allowed AND made visible to
127
951
  * the agent before `toFQDNs` enforcement can match anything a name resolves
@@ -156,9 +980,10 @@ const CILIUM_DNS_VISIBILITY_RULE = {
156
980
  },
157
981
  ],
158
982
  };
159
- function buildCoreNetworkPolicy(target, egress) {
983
+ function buildCoreNetworkPolicy(target, policyKind, egress) {
160
984
  return {
161
985
  kind: 'NetworkPolicy',
986
+ policyKind,
162
987
  namespace: target.namespace,
163
988
  name: target.name,
164
989
  manifest: {
@@ -166,32 +991,305 @@ function buildCoreNetworkPolicy(target, egress) {
166
991
  kind: 'NetworkPolicy',
167
992
  metadata: { name: target.name, namespace: target.namespace },
168
993
  spec: {
169
- podSelector: { matchLabels: sandboxTemplateLabel(target.sandboxTemplateName) },
994
+ podSelector: {
995
+ matchLabels: egressPolicySelectorLabels(target),
996
+ },
170
997
  policyTypes: ['Egress'],
171
998
  egress,
172
999
  },
173
1000
  },
174
1001
  };
175
1002
  }
176
- function buildCiliumNetworkPolicy(target, allowedHosts) {
1003
+ /** `[443]` — the TLS-port default for both `tlsServerNames` and `tlsPorts`. */
1004
+ const DEFAULT_TLS_PORTS = [443];
1005
+ /** `{ port: '443', protocol: 'TCP' }` — Cilium's port shape, ports spelled as strings. */
1006
+ function ciliumPortEntry(port) {
1007
+ return { port: String(port), protocol: 'TCP' };
1008
+ }
1009
+ /**
1010
+ * The refusal context of the PER-SANDBOX translation — `egress.perSandbox`,
1011
+ * whose allowlist comes from a host's own `setNetworkPolicy` call.
1012
+ *
1013
+ * Shared deliberately: the per-sandbox writer refuses a host by this context
1014
+ * before it reads the fence, and {@link buildCiliumEgressManifest} refuses the
1015
+ * same host by the same context on the way through the translation, so the two
1016
+ * cannot name different fields for one entry however the earlier check is
1017
+ * reached or skipped.
1018
+ */
1019
+ export const PER_SANDBOX_NARROWING_REFUSAL = {
1020
+ fieldPath: 'config.egress.perSandbox.narrowing',
1021
+ };
1022
+ /**
1023
+ * The refusal context of the config-level translation — `config.egress.policy`
1024
+ * and its `config.egress.ciliumNarrowing`.
1025
+ */
1026
+ export const CONFIG_LEVEL_NARROWING_REFUSAL = {
1027
+ fieldPath: 'config.egress.ciliumNarrowing',
1028
+ };
1029
+ /**
1030
+ * Refuse the one allowlist entry a narrowing option cannot express.
1031
+ *
1032
+ * `tlsServerNames` puts the entry on the rule as a TLS server name — one
1033
+ * exact SNI value a handshake presents — and a `.domain` entry is a set of
1034
+ * names no single SNI value means. Either way the emitted object would be
1035
+ * applied without complaint — nothing refuses an operator's apply: the
1036
+ * shipped admission fence's `matchConditions` scope it to the sandbox host's
1037
+ * ServiceAccount, so the config-level object is not matched by it — read back
1038
+ * deep-equal to what was sent, and deny what the caller asked to allow — the
1039
+ * failure every other refusal in this module exists to prevent. Refused
1040
+ * rather than translated another way,
1041
+ * because both other ways are guesses: dropping the subdomains silently
1042
+ * narrows what the caller asked for, and a wildcard SNI value is not
1043
+ * something the Cilium versions these manifests are written against are
1044
+ * known here to match — an SNI that matches nothing denies just as
1045
+ * completely, and more quietly.
1046
+ *
1047
+ * One sentence, because one entry now means one thing: both translations turn
1048
+ * `.domain` into `matchName: domain` PLUS `matchPattern: '*.domain'` — see
1049
+ * {@link ciliumFqdnEntries} — and no single SNI value means that pair.
1050
+ * `domain` alone denies every subdomain the pattern admits, and the entry as
1051
+ * written is not a name any handshake presents at all.
1052
+ *
1053
+ * `context` is the field the refusal sends the reader to — see
1054
+ * {@link HostsFitNarrowingContext}. A reader has to be sent to the field they
1055
+ * actually set: passed as a loose string, an expanding caller was told to
1056
+ * repair `config.egress.ciliumNarrowing` with the remedy that belongs to
1057
+ * `config.egress.perSandbox.narrowing`, a message whose only repair is a field
1058
+ * the entry never came from. Called from BOTH — the translation itself, so
1059
+ * every caller is covered, and the per-sandbox writer, which refuses earlier
1060
+ * still, before it reads the fence.
1061
+ */
1062
+ export function assertHostsFitNarrowing(allowedHosts, narrowing, context) {
1063
+ if (narrowing?.tlsServerNames !== true)
1064
+ return;
1065
+ for (const host of allowedHosts) {
1066
+ if (!host.startsWith('.'))
1067
+ continue;
1068
+ throw new KubernetesNetworkPolicyHostError(host, `names a domain and its subdomains while ${context.fieldPath}.tlsServerNames is on; a TLS server name is one exact SNI value a handshake presents, and this entry becomes a name plus a '*.domain' pattern, which no single value means — list the exact hosts, or leave tlsServerNames off for a domain list`);
1069
+ }
1070
+ }
1071
+ /**
1072
+ * The port list a narrowed translation applies to `host`, before splitting
1073
+ * out the TLS subset — see {@link KubernetesCiliumEgressNarrowing.ports}.
1074
+ * `undefined` means no port restriction: the host's `toFQDNs` rule carries
1075
+ * no `toPorts` at all.
1076
+ */
1077
+ function narrowedHostPorts(host, narrowing) {
1078
+ const explicit = narrowing.hostPorts?.[host] ?? narrowing.ports;
1079
+ if (explicit !== undefined)
1080
+ return explicit;
1081
+ // `serverNames` needs a port to attach to — leaving this host with no
1082
+ // port at all would mean either no `serverNames` rule (silently dropping
1083
+ // the option) or one with no `toPorts`, which Cilium rejects on apply.
1084
+ if (narrowing.tlsServerNames === true)
1085
+ return narrowing.tlsPorts ?? DEFAULT_TLS_PORTS;
1086
+ return undefined;
1087
+ }
1088
+ /**
1089
+ * One host's `toFQDNs` rule under narrowing: the allowlist entry's `toFQDNs`
1090
+ * entries, plus `toPorts` split into a `serverNames`-bearing entry for the
1091
+ * host's TLS ports and a plain entry for whatever is left, when
1092
+ * `tlsServerNames` is on.
1093
+ *
1094
+ * `fqdns` is {@link ciliumFqdnEntries} of the host's allowlist entry, passed
1095
+ * in rather than derived here because the entry and the host are not always
1096
+ * the same string: `.example.com` becomes `example.com` plus
1097
+ * `*.example.com`. It is REQUIRED, with no `[{ matchName: host }]` default —
1098
+ * a caller that omitted it would emit the entry unexpanded, which is the
1099
+ * exact object this translation stopped producing.
1100
+ *
1101
+ * `serverNames` carries the allowlist ENTRY as written while the `toFQDNs`
1102
+ * half carries the expansion of it. A TLS server name is one exact SNI value
1103
+ * a handshake presents, and a `.example.com` entry — whose `toFQDNs` half is
1104
+ * `example.com` PLUS `*.example.com` — has no single SNI value meaning that
1105
+ * set: `example.com` would deny every subdomain the pattern admits, and
1106
+ * `.example.com` is not a name any handshake ever presents. So
1107
+ * {@link assertHostsFitNarrowing} refuses that one combination before this
1108
+ * rule is built, from `buildCiliumEgressManifest` for every caller and from
1109
+ * the per-sandbox writer before it reads the fence — which is why this
1110
+ * function can still take `host` and the entry as one string.
1111
+ */
1112
+ function narrowedHostFqdnRule(host, narrowing, fqdns) {
1113
+ const ports = narrowedHostPorts(host, narrowing);
1114
+ if (ports === undefined)
1115
+ return { toFQDNs: fqdns };
1116
+ if (narrowing.tlsServerNames !== true) {
1117
+ return { toFQDNs: fqdns, toPorts: [{ ports: ports.map(ciliumPortEntry) }] };
1118
+ }
1119
+ const tlsPorts = narrowing.tlsPorts ?? DEFAULT_TLS_PORTS;
1120
+ const tlsSubset = ports.filter((port) => tlsPorts.includes(port));
1121
+ const rest = ports.filter((port) => !tlsPorts.includes(port));
1122
+ const toPorts = [];
1123
+ if (tlsSubset.length > 0) {
1124
+ toPorts.push({ ports: tlsSubset.map(ciliumPortEntry), serverNames: [host] });
1125
+ }
1126
+ if (rest.length > 0)
1127
+ toPorts.push({ ports: rest.map(ciliumPortEntry) });
1128
+ return {
1129
+ toFQDNs: fqdns,
1130
+ ...(toPorts.length > 0 ? { toPorts } : {}),
1131
+ };
1132
+ }
1133
+ /**
1134
+ * One allowlist entry, expanded to the `toFQDNs` entries it means.
1135
+ *
1136
+ * `SandboxNetworkPolicy.allowedHosts`'s own grammar, which the SDK states —
1137
+ * `packages/sdk/src/types/sandbox/index.ts`'s `allowedHosts` — and which
1138
+ * `src/egress/allowlist.ts` implements for the docker backend: `api.example.com`
1139
+ * is that host, and `.example.com` is the domain AND its subdomains. Cilium's
1140
+ * `matchName` is an exact name and does not match across a `.`, so the domain
1141
+ * form needs the name plus a `matchPattern` — `*.example.com` alone would
1142
+ * admit `a.example.com` and not `example.com` itself.
1143
+ *
1144
+ * Used by BOTH translations. The config-level one used to emit a leading-dot
1145
+ * entry verbatim instead, which no DNS answer carries and so denied the
1146
+ * domain and every subdomain it was asked to allow, after a create that
1147
+ * reported success — but "the config-level bytes are pinned" was never a
1148
+ * contract the pinned bytes honoured, and one entry has to mean one thing on
1149
+ * every backend.
1150
+ */
1151
+ export function ciliumFqdnEntries(entry) {
1152
+ if (!entry.startsWith('.'))
1153
+ return [{ matchName: entry }];
1154
+ const domain = entry.slice(1);
1155
+ return [{ matchName: domain }, { matchPattern: `*.${domain}` }];
1156
+ }
1157
+ /**
1158
+ * The DNS-visibility rule narrowed to the names an allowlist entry admits:
1159
+ * an exact `matchName` per allowed host plus the host under every search
1160
+ * suffix, and — for an entry that admits subdomains — the `matchPattern` that
1161
+ * admits them, under the bare name and under every suffix as well. Replaces
1162
+ * {@link CILIUM_DNS_VISIBILITY_RULE}'s `matchPattern: '*'`. See
1163
+ * {@link KubernetesCiliumDnsNarrowing}.
1164
+ *
1165
+ * Both halves follow from {@link ciliumFqdnEntries}: the DNS proxy has to see
1166
+ * exactly what the `toFQDNs` rule admits, or the half that learns addresses
1167
+ * from a lookup never sees one the other half allows.
1168
+ */
1169
+ function narrowedDnsVisibilityRule(allowedHosts, fallbackNamespace, dnsNames) {
1170
+ const namespace = dnsNames.namespace ?? fallbackNamespace;
1171
+ const clusterDomain = dnsNames.clusterDomain ?? 'cluster.local';
1172
+ const suffixes = [
1173
+ `${namespace}.svc.${clusterDomain}`,
1174
+ `svc.${clusterDomain}`,
1175
+ clusterDomain,
1176
+ ...(dnsNames.searchSuffixes ?? []),
1177
+ ];
1178
+ const matchNames = [];
1179
+ for (const entry of allowedHosts) {
1180
+ // A `.domain` entry resolves through its subdomains as well, so the
1181
+ // DNS proxy has to be allowed to SEE those lookups or `toFQDNs` never
1182
+ // learns the addresses they resolve to.
1183
+ const wildcard = entry.startsWith('.');
1184
+ const host = wildcard ? entry.slice(1) : entry;
1185
+ matchNames.push({ matchName: host });
1186
+ if (wildcard)
1187
+ matchNames.push({ matchPattern: `*.${host}` });
1188
+ for (const suffix of suffixes) {
1189
+ matchNames.push({ matchName: `${host}.${suffix}` });
1190
+ // The suffixed names are here because a guest resolving a name
1191
+ // with fewer dots than the cluster's `ndots` tries the search
1192
+ // suffixes FIRST, and a lookup the DNS proxy refuses is not an
1193
+ // NXDOMAIN the resolver walks past — it can fail the whole
1194
+ // resolution. An entry that admits subdomains needs the pattern
1195
+ // under each suffix too, or `a.example.com` fails on its first
1196
+ // search-suffix attempt under a rule that allows it.
1197
+ if (wildcard)
1198
+ matchNames.push({ matchPattern: `*.${host}.${suffix}` });
1199
+ }
1200
+ }
1201
+ return {
1202
+ toEndpoints: CILIUM_DNS_VISIBILITY_RULE.toEndpoints,
1203
+ toPorts: [
1204
+ {
1205
+ ports: [{ port: '53', protocol: 'ANY' }],
1206
+ rules: { dns: matchNames },
1207
+ },
1208
+ ],
1209
+ };
1210
+ }
1211
+ export function buildCiliumEgressManifest(options) {
1212
+ const { narrowing, allowedHosts, refusalContext } = options;
1213
+ // Before anything is emitted, and for EVERY caller — which is the point:
1214
+ // an entry this backend will not translate is one whose object would be
1215
+ // either rejected on apply or, worse, accepted as a name that matches
1216
+ // nothing while the create reports success. The config-level allowlist
1217
+ // used to reach this builder unvalidated, so `['.com']`, `['.']`,
1218
+ // `['..example.com']`, `['*']` and an address were each emitted into
1219
+ // `toFQDNs` — the first three as a `matchPattern` no fence covers on that
1220
+ // path (the shipped one is scoped to the sandbox host's ServiceAccount),
1221
+ // which turned a fail-closed no-op into a silent grant of every name under
1222
+ // a public suffix. See {@link assertHostsAreUsable}.
1223
+ assertHostsAreUsable(allowedHosts);
1224
+ // A leading-dot entry under `tlsServerNames`
1225
+ // would become `serverNames: ['.domain']` — not a name any handshake
1226
+ // presents — and `['domain']` would deny every subdomain the `toFQDNs`
1227
+ // half of the same rule admits; the object goes through with nothing
1228
+ // objecting to it, and reads back deep-equal to what was sent, so nothing
1229
+ // downstream would ever report it. (On the config-level path nothing
1230
+ // objects to it at all: the shipped fence is scoped to the sandbox host's
1231
+ // ServiceAccount and an operator's apply is not matched by it.)
1232
+ //
1233
+ // What this call guarantees, per caller shape: the per-sandbox writer
1234
+ // refuses the same hosts earlier still, by name and by the same context
1235
+ // (`per-sandbox-policy.ts`), and this call covers them again if that check
1236
+ // is ever reached later or skipped; the config-level translation has no
1237
+ // earlier check of its own and relies on this one entirely; and a direct
1238
+ // call to this function is covered here too, whatever context it passes.
1239
+ //
1240
+ // The context decides ONE thing, because one thing is left to decide —
1241
+ // which field the refusal names. It is required rather than derived, since
1242
+ // nothing here can see the caller's config, and derived from the bytes is
1243
+ // exactly how an expanding caller came to be sent to the config-level
1244
+ // field for a repair that only exists under `perSandbox.narrowing`.
1245
+ assertHostsFitNarrowing(allowedHosts, narrowing, refusalContext);
1246
+ // Unnarrowed is the exact shape every release before #490 emitted — kept
1247
+ // as its own branch, untouched, rather than folded into the narrowed one
1248
+ // with every option defaulted off, so the byte-identical guarantee does
1249
+ // not depend on the narrowed code path happening to reduce to it.
1250
+ const narrowed = ciliumNarrowingIsActive(narrowing);
1251
+ const activeDns = narrowed ? activeDnsNarrowing(narrowing.dnsNames) : undefined;
1252
+ const dnsRule = activeDns !== undefined
1253
+ ? narrowedDnsVisibilityRule(allowedHosts, options.namespace, activeDns)
1254
+ : CILIUM_DNS_VISIBILITY_RULE;
1255
+ const hostRules = narrowed
1256
+ ? allowedHosts.map((host) => narrowedHostFqdnRule(host, narrowing, ciliumFqdnEntries(host)))
1257
+ : [{ toFQDNs: allowedHosts.flatMap(ciliumFqdnEntries) }];
177
1258
  return {
178
1259
  kind: 'CiliumNetworkPolicy',
179
- namespace: target.namespace,
180
- name: target.name,
1260
+ policyKind: options.policyKind,
1261
+ namespace: options.namespace,
1262
+ name: options.name,
181
1263
  manifest: {
182
1264
  apiVersion: `${CILIUM_NETWORK_POLICY_API_GROUP}/${CILIUM_NETWORK_POLICY_API_VERSION}`,
183
1265
  kind: 'CiliumNetworkPolicy',
184
- metadata: { name: target.name, namespace: target.namespace },
1266
+ metadata: {
1267
+ name: options.name,
1268
+ namespace: options.namespace,
1269
+ ...(options.ownerReferences !== undefined
1270
+ ? { ownerReferences: options.ownerReferences }
1271
+ : {}),
1272
+ },
185
1273
  spec: {
186
- endpointSelector: { matchLabels: sandboxTemplateLabel(target.sandboxTemplateName) },
187
- egress: [
188
- CILIUM_DNS_VISIBILITY_RULE,
189
- { toFQDNs: allowedHosts.map((host) => ({ matchName: host })) },
190
- ],
1274
+ endpointSelector: {
1275
+ matchLabels: options.selectorLabels,
1276
+ },
1277
+ egress: [dnsRule, ...hostRules],
191
1278
  },
192
1279
  },
193
1280
  };
194
1281
  }
1282
+ function buildCiliumNetworkPolicy(target, policyKind, allowedHosts, narrowing) {
1283
+ return buildCiliumEgressManifest({
1284
+ namespace: target.namespace,
1285
+ name: target.name,
1286
+ selectorLabels: egressPolicySelectorLabels(target),
1287
+ allowedHosts,
1288
+ policyKind,
1289
+ ...(narrowing !== undefined ? { narrowing } : {}),
1290
+ refusalContext: CONFIG_LEVEL_NARROWING_REFUSAL,
1291
+ });
1292
+ }
195
1293
  /**
196
1294
  * The pure translation: an {@link EgressPolicy} plus the declared
197
1295
  * {@link KubernetesEgressEngine} in, the concrete manifest this backend can
@@ -209,23 +1307,34 @@ function buildCiliumNetworkPolicy(target, allowedHosts) {
209
1307
  * hosts against, and re-resolving on every `create()` would only produce a
210
1308
  * value nothing downstream re-applies to the cluster.
211
1309
  */
212
- export async function translateEgressPolicy(policy, engine, target) {
213
- assertEgressPolicyIsEnforceable(policy, engine);
1310
+ export async function translateEgressPolicy(policy, engine, target, ciliumNarrowing) {
1311
+ assertEgressPolicyIsEnforceable(policy, engine, ciliumNarrowing);
214
1312
  switch (policy.kind) {
215
1313
  case 'deny-all':
216
- return buildCoreNetworkPolicy(target, [CLUSTER_DNS_EGRESS_RULE]);
1314
+ return buildCoreNetworkPolicy(target, 'deny-all', [CLUSTER_DNS_EGRESS_RULE]);
1315
+ case 'no-network':
1316
+ // `policyTypes: ['Egress']` with an EMPTY rule list is the API's own
1317
+ // spelling of "this pod sends nothing": the pod is in egress
1318
+ // default-deny and no rule lets anything back out. Not even DNS —
1319
+ // see the kind's own doc.
1320
+ return buildCoreNetworkPolicy(target, 'no-network', []);
1321
+ case 'public-internet':
1322
+ return buildCoreNetworkPolicy(target, 'public-internet', [
1323
+ KUBE_DNS_EGRESS_RULE,
1324
+ publicInternetEgressRule(policy.exceptCidrs),
1325
+ ]);
217
1326
  case 'allow-all':
218
1327
  // No `to`/`ports` on an egress rule matches every destination and
219
1328
  // every port. `CLUSTER_DNS_EGRESS_RULE` is a strict subset of this,
220
1329
  // so it is folded in rather than listed twice.
221
- return buildCoreNetworkPolicy(target, [{}]);
1330
+ return buildCoreNetworkPolicy(target, 'allow-all', [{}]);
222
1331
  case 'static':
223
1332
  // `assertEgressPolicyIsEnforceable` already threw above unless
224
1333
  // `engine === 'cilium'`, so reaching here means it did not.
225
- return buildCiliumNetworkPolicy(target, policy.allowedHosts);
1334
+ return buildCiliumNetworkPolicy(target, 'static', policy.allowedHosts, ciliumNarrowing);
226
1335
  case 'resolver': {
227
1336
  const allowedHosts = await policy.resolve();
228
- return buildCiliumNetworkPolicy(target, allowedHosts);
1337
+ return buildCiliumNetworkPolicy(target, 'resolver', allowedHosts, ciliumNarrowing);
229
1338
  }
230
1339
  default: {
231
1340
  const exhaustive = policy;
@@ -270,14 +1379,28 @@ export class KubernetesEgressPolicyMismatchError extends Error {
270
1379
  * `../docker/index.ts`'s `assertNetworkCarriesThePolicy`, which inspects the
271
1380
  * daemon's own `{{.Internal}}` flag instead of trusting a network's name.
272
1381
  *
273
- * Checks exactly three things, each named separately in a mismatch so an
274
- * operator sees which one to fix:
1382
+ * Checks four things, each named separately in a mismatch so an operator sees
1383
+ * which one to fix:
1384
+ * - the object carries NO `specs` list. A `CiliumNetworkPolicy` carries
1385
+ * EITHER one `spec` or a `specs` list and a rule in either one enforces,
1386
+ * while every check below reads `spec` alone — so an object carrying both
1387
+ * would be compared on half of what it enforces. This is refused and not
1388
+ * read, because a comparison that accepts rules it never looked at is the
1389
+ * one answer this function must not give; the shipped admission fence
1390
+ * refuses a `specs` list for the same reason. The translation never emits
1391
+ * one, so its presence is drift and not an alternative spelling;
275
1392
  * - the selector (`podSelector` for `NetworkPolicy`, `endpointSelector` for
276
1393
  * `CiliumNetworkPolicy`) carries the expected template label:
277
1394
  * - `NetworkPolicy` additionally declares `policyTypes` including
278
1395
  * `'Egress'` — a `NetworkPolicy` with an `egress` array but no `'Egress'`
279
1396
  * in `policyTypes` enforces nothing on egress at all;
280
- * - the `egress` rule array matches the translation exactly.
1397
+ * - the `egress` rule array matches the translation exactly;
1398
+ * - and, ONLY when the translation carries `metadata.ownerReferences` (the
1399
+ * per-sandbox policies of `per-sandbox-policy.ts`, never the operator's
1400
+ * own object), that the live object still carries each of them — an owner
1401
+ * reference dropped between the write and the read is a policy the
1402
+ * cluster will never collect with the sandbox it belongs to, which is the
1403
+ * leak the reference exists to prevent, and it is invisible in `spec`.
281
1404
  *
282
1405
  * Never mutates and never creates — a 404/410 is refused, not repaired.
283
1406
  */
@@ -298,6 +1421,24 @@ export async function verifyEgressPolicyApplied(client, translated, signal) {
298
1421
  const expectedSpec = translated.manifest.spec;
299
1422
  const actualSpec = resource?.spec ?? {};
300
1423
  const selectorKey = translated.kind === 'CiliumNetworkPolicy' ? 'endpointSelector' : 'podSelector';
1424
+ // First, and before anything is compared: an object carrying a `specs`
1425
+ // list is refused rather than read. Everything below reads `spec`, and a
1426
+ // `CiliumNetworkPolicy` rule in a `specs` entry enforces exactly as one in
1427
+ // `spec` does — so comparing `spec` and reporting a match would be
1428
+ // accepting rules this function never looked at, which is the one answer it
1429
+ // must not give. `readCiliumEgressPolicies` reads both spellings and
1430
+ // `decideEgressUnion` refuses a `specs` entry that widens; this is the
1431
+ // other half of the same rule, for the callers that run this check ALONE —
1432
+ // `verify: 'named-object-only'`, and the per-sandbox read-back — and it is
1433
+ // what makes the named-object exemption in `decideEgressUnion` sound rather
1434
+ // than merely narrower. The translation never emits a `specs` list, and the
1435
+ // shipped admission fence refuses one for the same reason.
1436
+ const actualSpecs = resource?.specs;
1437
+ if (actualSpecs !== undefined && actualSpecs !== null) {
1438
+ throw new KubernetesEgressPolicyMismatchError(translated.kind, path, `specs is ${JSON.stringify(actualSpecs)}, and the translation never emits one — ${translated.kind === 'CiliumNetworkPolicy'
1439
+ ? 'a CiliumNetworkPolicy carries EITHER one spec or a specs list, and a rule in either one enforces'
1440
+ : 'a NetworkPolicy has no specs field at all'}, while this check reads spec.${selectorKey}${translated.kind === 'NetworkPolicy' ? ', spec.policyTypes' : ''} and spec.egress and nothing else — so anything a specs list enforces is egress this comparison never read`);
1441
+ }
301
1442
  if (!isDeepStrictEqual(actualSpec[selectorKey], expectedSpec[selectorKey])) {
302
1443
  throw new KubernetesEgressPolicyMismatchError(translated.kind, path, `spec.${selectorKey} is ${JSON.stringify(actualSpec[selectorKey])}, expected ${JSON.stringify(expectedSpec[selectorKey])} — the label every Sandbox this backend creates carries`);
303
1444
  }
@@ -310,5 +1451,1183 @@ export async function verifyEgressPolicyApplied(client, translated, signal) {
310
1451
  if (!isDeepStrictEqual(actualSpec.egress, expectedSpec.egress)) {
311
1452
  throw new KubernetesEgressPolicyMismatchError(translated.kind, path, `spec.egress is ${JSON.stringify(actualSpec.egress)}, expected ${JSON.stringify(expectedSpec.egress)}`);
312
1453
  }
1454
+ // Last, and only for a translation that asked for one: an object written
1455
+ // with an owner reference and read back without it is a policy that
1456
+ // outlives its sandbox. The operator-applied objects this function's other
1457
+ // caller checks carry none, so they never reach this branch.
1458
+ const expectedOwners = translated.manifest.metadata?.ownerReferences;
1459
+ if (expectedOwners !== undefined) {
1460
+ const actualOwners = resource?.metadata
1461
+ ?.ownerReferences;
1462
+ const held = Array.isArray(actualOwners) ? actualOwners : [];
1463
+ for (const owner of expectedOwners) {
1464
+ if (held.some((entry) => isDeepStrictEqual(entry, owner)))
1465
+ continue;
1466
+ throw new KubernetesEgressPolicyMismatchError(translated.kind, path, `metadata.ownerReferences is ${JSON.stringify(actualOwners)}, expected it to carry ${JSON.stringify(owner)} — without that entry the cluster never collects this policy with the object it belongs to, so it would outlive the sandbox it was written for`);
1467
+ }
1468
+ }
1469
+ }
1470
+ function parseIpv4(text) {
1471
+ const parts = text.split('.');
1472
+ if (parts.length !== 4)
1473
+ return undefined;
1474
+ let value = 0n;
1475
+ for (const part of parts) {
1476
+ if (!/^\d{1,3}$/.test(part))
1477
+ return undefined;
1478
+ const octet = Number(part);
1479
+ if (octet > 255)
1480
+ return undefined;
1481
+ value = (value << 8n) | BigInt(octet);
1482
+ }
1483
+ return value;
1484
+ }
1485
+ function parseIpv6(text) {
1486
+ // An embedded IPv4 tail (`::ffff:10.0.0.1`) is legal and is how a
1487
+ // dual-stack cluster spells a v4 address in a v6 field.
1488
+ let head = text;
1489
+ let tail = 0n;
1490
+ let tailGroups = 0;
1491
+ const lastColon = text.lastIndexOf(':');
1492
+ if (lastColon >= 0 && text.slice(lastColon + 1).includes('.')) {
1493
+ const embedded = parseIpv4(text.slice(lastColon + 1));
1494
+ if (embedded === undefined)
1495
+ return undefined;
1496
+ head = text.slice(0, lastColon + 1);
1497
+ tail = embedded;
1498
+ tailGroups = 2;
1499
+ }
1500
+ const halves = head.split('::');
1501
+ if (halves.length > 2)
1502
+ return undefined;
1503
+ const readGroups = (part) => {
1504
+ if (part === '')
1505
+ return [];
1506
+ const groups = [];
1507
+ for (const group of part.split(':')) {
1508
+ if (group === '')
1509
+ continue;
1510
+ if (!/^[0-9a-fA-F]{1,4}$/.test(group))
1511
+ return undefined;
1512
+ groups.push(BigInt(Number.parseInt(group, 16)));
1513
+ }
1514
+ return groups;
1515
+ };
1516
+ const left = readGroups(halves[0] ?? '');
1517
+ const right = readGroups(halves[1] ?? '');
1518
+ if (left === undefined || right === undefined)
1519
+ return undefined;
1520
+ const present = left.length + right.length + tailGroups;
1521
+ if (present > 8)
1522
+ return undefined;
1523
+ if (halves.length === 1 && present !== 8)
1524
+ return undefined;
1525
+ const zeros = 8 - present;
1526
+ const groups = [...left, ...Array.from({ length: zeros }, () => 0n), ...right];
1527
+ let value = 0n;
1528
+ for (const group of groups)
1529
+ value = (value << 16n) | group;
1530
+ if (tailGroups === 2)
1531
+ value = (value << 32n) | tail;
1532
+ return value;
1533
+ }
1534
+ /**
1535
+ * A CIDR, or `undefined` when it is not one this check can read. Host bits
1536
+ * are masked off rather than rejected: `10.0.0.1/8` and `10.0.0.0/8` name the
1537
+ * same block, and an operator who wrote the first meant the second.
1538
+ */
1539
+ export function parseCidr(text) {
1540
+ if (typeof text !== 'string')
1541
+ return undefined;
1542
+ const slash = text.indexOf('/');
1543
+ if (slash < 0)
1544
+ return undefined;
1545
+ const address = text.slice(0, slash);
1546
+ const prefix = text.slice(slash + 1);
1547
+ if (!/^\d{1,3}$/.test(prefix))
1548
+ return undefined;
1549
+ const bits = Number(prefix);
1550
+ if (address.includes(':')) {
1551
+ const value = parseIpv6(address);
1552
+ if (value === undefined || bits > 128)
1553
+ return undefined;
1554
+ return { version: 6, base: maskAddress(value, bits, 128), bits };
1555
+ }
1556
+ const value = parseIpv4(address);
1557
+ if (value === undefined || bits > 32)
1558
+ return undefined;
1559
+ return { version: 4, base: maskAddress(value, bits, 32), bits };
1560
+ }
1561
+ function maskAddress(value, bits, width) {
1562
+ if (bits === 0)
1563
+ return 0n;
1564
+ const host = BigInt(width - bits);
1565
+ return (value >> host) << host;
1566
+ }
1567
+ /** Every address of `inner` is an address of `outer`. */
1568
+ function cidrContains(outer, inner) {
1569
+ if (outer.version !== inner.version)
1570
+ return false;
1571
+ if (outer.bits > inner.bits)
1572
+ return false;
1573
+ const width = outer.version === 4 ? 32 : 128;
1574
+ return maskAddress(inner.base, outer.bits, width) === outer.base;
1575
+ }
1576
+ /** They share at least one address — i.e. one contains the other. */
1577
+ function cidrsOverlap(a, b) {
1578
+ return cidrContains(a, b) || cidrContains(b, a);
1579
+ }
1580
+ /**
1581
+ * The cluster resolver's identity as a `PolicyPeer` — shared by
1582
+ * {@link egressAllowance} (reading a Cilium DNS-visibility rule into its
1583
+ * core-shaped equivalent) and {@link reachesResolverAtDnsPort} (deciding
1584
+ * whether some OTHER policy's rule reaches it), so there is one definition of
1585
+ * "this peer is the cluster resolver" rather than two that could drift apart.
1586
+ */
1587
+ const RESOLVER_PEER = {
1588
+ kind: 'selector',
1589
+ namespaceSelector: {
1590
+ matchLabels: { 'kubernetes.io/metadata.name': 'kube-system' },
1591
+ },
1592
+ podSelector: { matchLabels: { 'k8s-app': 'kube-dns' } },
1593
+ text: 'the cluster resolver',
1594
+ };
1595
+ function readPortRanges(ports, defaultProtocol) {
1596
+ const entries = readList(ports);
1597
+ if (entries === 'unreadable')
1598
+ return 'unreadable';
1599
+ // Absent or empty `ports` on an egress rule means EVERY port.
1600
+ if (entries === undefined || entries.length === 0)
1601
+ return 'all';
1602
+ const ranges = [];
1603
+ for (const entry of entries) {
1604
+ if (!isRecord(entry))
1605
+ return 'unreadable';
1606
+ const rawProtocol = entry.protocol;
1607
+ if (rawProtocol !== undefined && typeof rawProtocol !== 'string')
1608
+ return 'unreadable';
1609
+ const protocol = rawProtocol === undefined
1610
+ ? defaultProtocol
1611
+ : rawProtocol.toUpperCase() === 'ANY'
1612
+ ? undefined
1613
+ : rawProtocol.toUpperCase();
1614
+ const endPort = entry.endPort;
1615
+ if (endPort !== undefined && typeof endPort !== 'number')
1616
+ return 'unreadable';
1617
+ const raw = entry.port;
1618
+ if (raw === undefined) {
1619
+ ranges.push(protocol === undefined ? {} : { protocol });
1620
+ continue;
1621
+ }
1622
+ const port = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : Number.NaN;
1623
+ // A NAMED container port. Resolving it needs the destination pod's own
1624
+ // container spec, which this check never has.
1625
+ if (!Number.isInteger(port))
1626
+ return 'unreadable';
1627
+ ranges.push({
1628
+ ...(protocol !== undefined ? { protocol } : {}),
1629
+ start: port,
1630
+ ...(typeof endPort === 'number' ? { end: endPort } : {}),
1631
+ });
1632
+ }
1633
+ return ranges;
1634
+ }
1635
+ function readCorePeer(peer) {
1636
+ if (!isRecord(peer))
1637
+ return undefined;
1638
+ const { ipBlock, podSelector, namespaceSelector } = peer;
1639
+ if (ipBlock !== undefined) {
1640
+ if (!isRecord(ipBlock))
1641
+ return undefined;
1642
+ const cidr = parseCidr(ipBlock.cidr);
1643
+ if (cidr === undefined)
1644
+ return undefined;
1645
+ const rawExcept = readList(ipBlock.except);
1646
+ if (rawExcept === 'unreadable')
1647
+ return undefined;
1648
+ const except = [];
1649
+ for (const entry of rawExcept ?? []) {
1650
+ const parsed = parseCidr(entry);
1651
+ if (parsed === undefined)
1652
+ return undefined;
1653
+ except.push(parsed);
1654
+ }
1655
+ return { kind: 'cidr', cidr, except, text: JSON.stringify(ipBlock) };
1656
+ }
1657
+ if (podSelector === undefined && namespaceSelector === undefined) {
1658
+ // A peer naming none of the three constrains nothing. The API server
1659
+ // rejects it on admission, so an object carrying one did not come from
1660
+ // there and is not read as anything.
1661
+ return undefined;
1662
+ }
1663
+ if (!selectorIsReadable(podSelector) || !selectorIsReadable(namespaceSelector))
1664
+ return undefined;
1665
+ return {
1666
+ kind: 'selector',
1667
+ ...(namespaceSelector !== undefined ? { namespaceSelector } : {}),
1668
+ ...(podSelector !== undefined ? { podSelector } : {}),
1669
+ text: JSON.stringify(peer),
1670
+ };
1671
+ }
1672
+ /**
1673
+ * The allowance a translated policy expresses. See {@link EgressAllowance}.
1674
+ */
1675
+ export function egressAllowance(translated) {
1676
+ const spec = translated.manifest.spec;
1677
+ // This module built the manifest a line ago, so `spec.egress` is always a
1678
+ // list here; the narrowing exists so the allowance is derived from the
1679
+ // object itself rather than from a second statement of what each kind
1680
+ // permits, and an impossible shape yields an allowance that permits
1681
+ // nothing rather than one that permits anything.
1682
+ const emitted = readList(spec.egress);
1683
+ const rules = emitted === undefined || emitted === 'unreadable' ? [] : emitted;
1684
+ if (translated.kind === 'CiliumNetworkPolicy') {
1685
+ const fqdns = [];
1686
+ // The DNS-visibility rule is the one entry among `rules` whose
1687
+ // `toEndpoints` names the cluster resolver — narrowed or not, it always
1688
+ // reuses `CILIUM_DNS_VISIBILITY_RULE.toEndpoints` verbatim (see
1689
+ // `narrowedDnsVisibilityRule` and `buildCiliumNetworkPolicy`), so a deep
1690
+ // equality check finds it regardless of which branch built this rule.
1691
+ const dnsVisibilityRule = rules.find((rule) => isRecord(rule) &&
1692
+ isDeepStrictEqual(rule.toEndpoints, CILIUM_DNS_VISIBILITY_RULE.toEndpoints));
1693
+ for (const rule of rules) {
1694
+ if (!isRecord(rule))
1695
+ continue;
1696
+ const hostNames = readList(rule.toFQDNs);
1697
+ if (hostNames === undefined || hostNames === 'unreadable')
1698
+ continue;
1699
+ // This module built `rule` a few lines above (see the comment at the
1700
+ // top of this function), so its `toPorts` is always in the shape
1701
+ // `readCiliumRulePorts` reads — 'unreadable' cannot happen for a
1702
+ // manifest this file emitted, and 'all' is the safe fallback if it
1703
+ // somehow did, since that is what an absent `toPorts` also means.
1704
+ const portsRead = readCiliumRulePorts(rule);
1705
+ const ports = portsRead.ok ? portsRead.ports : 'all';
1706
+ for (const entry of hostNames) {
1707
+ if (isRecord(entry) && typeof entry.matchName === 'string') {
1708
+ fqdns.push({ host: entry.matchName, ports });
1709
+ }
1710
+ }
1711
+ }
1712
+ // This module built `dnsVisibilityRule` above (when it exists at all —
1713
+ // every Cilium translation this function reads carries one), so
1714
+ // `readCiliumRuleDnsNames` failing to read it, or reading a wildcard,
1715
+ // cannot mean anything other than "not narrowed": 'all' is the safe
1716
+ // fallback, the same reasoning `readCiliumRulePorts`'s own fallback above
1717
+ // already relies on.
1718
+ const dnsNamesRead = dnsVisibilityRule === undefined ? undefined : readCiliumRuleDnsNames(dnsVisibilityRule);
1719
+ const dnsNarrowedTo = dnsNamesRead?.ok === true && dnsNamesRead.names !== 'all' ? dnsNamesRead.names : 'all';
1720
+ return {
1721
+ // The core-shaped reading of CILIUM_DNS_VISIBILITY_RULE — the one
1722
+ // place in this module where one resource's rule is restated in the
1723
+ // other's vocabulary, so that a core policy allowing exactly cluster
1724
+ // DNS is not reported as widening a translation that already allows
1725
+ // it. Nothing else about a Cilium translation is restated: an
1726
+ // allowlist of names has no core spelling at all.
1727
+ destinations: [{ peer: RESOLVER_PEER, ports: [{ start: 53 }] }],
1728
+ fqdns,
1729
+ dnsNarrowedTo,
1730
+ permitsEverything: false,
1731
+ permitsNothing: false,
1732
+ policyKind: translated.policyKind,
1733
+ namedObject: { kind: translated.kind, name: translated.name },
1734
+ };
1735
+ }
1736
+ const destinations = [];
1737
+ let permitsEverything = false;
1738
+ for (const rule of rules) {
1739
+ if (!isRecord(rule))
1740
+ continue;
1741
+ const ports = readPortRanges(rule.ports, 'TCP');
1742
+ if (ports === 'unreadable')
1743
+ continue;
1744
+ const to = readList(rule.to);
1745
+ if (to === 'unreadable')
1746
+ continue;
1747
+ if (to === undefined || to.length === 0) {
1748
+ destinations.push({ peer: { kind: 'everything' }, ports });
1749
+ if (ports === 'all')
1750
+ permitsEverything = true;
1751
+ continue;
1752
+ }
1753
+ for (const peer of to) {
1754
+ const read = readCorePeer(peer);
1755
+ if (read !== undefined)
1756
+ destinations.push({ peer: read, ports });
1757
+ }
1758
+ }
1759
+ return {
1760
+ destinations,
1761
+ fqdns: [],
1762
+ // A core `NetworkPolicy` translation never narrows DNS — it has no L7
1763
+ // concept to narrow with — so the DNS-widening check in
1764
+ // `coreEgressRuleVerdict`/`ciliumEgressRuleVerdict` never fires against
1765
+ // this allowance.
1766
+ dnsNarrowedTo: 'all',
1767
+ permitsEverything,
1768
+ permitsNothing: rules.length === 0,
1769
+ policyKind: translated.policyKind,
1770
+ namedObject: { kind: translated.kind, name: translated.name },
1771
+ };
1772
+ }
1773
+ // ---------------------------------------------------------------------------
1774
+ // Is one policy's rule inside the translation
1775
+ // ---------------------------------------------------------------------------
1776
+ function protocolCovers(allowed, wanted) {
1777
+ // `undefined` on the allowed side is EVERY protocol; on the wanted side it
1778
+ // is also every protocol, which only an every-protocol allowance covers.
1779
+ return allowed === undefined || allowed === wanted;
1780
+ }
1781
+ function portRangeCovers(allowed, wanted) {
1782
+ if (!protocolCovers(allowed.protocol, wanted.protocol))
1783
+ return false;
1784
+ if (allowed.start === undefined)
1785
+ return true;
1786
+ if (wanted.start === undefined)
1787
+ return false;
1788
+ const allowedEnd = allowed.end ?? allowed.start;
1789
+ const wantedEnd = wanted.end ?? wanted.start;
1790
+ return wanted.start >= allowed.start && wantedEnd <= allowedEnd;
1791
+ }
1792
+ function portsCover(allowed, wanted) {
1793
+ if (allowed === 'all')
1794
+ return true;
1795
+ return allowed.some((range) => portRangeCovers(range, wanted));
1796
+ }
1797
+ /** Every constraint `outer` places, `inner` places too — so `inner` selects a subset. */
1798
+ function selectorIsNarrower(outer, inner) {
1799
+ if (outer === undefined)
1800
+ return true;
1801
+ if (!isRecord(outer))
1802
+ return false;
1803
+ const outerLabels = outer.matchLabels;
1804
+ const outerExpressions = readList(outer.matchExpressions);
1805
+ if (outerExpressions === 'unreadable')
1806
+ return false;
1807
+ if (outerLabels === undefined && (outerExpressions ?? []).length === 0) {
1808
+ // An EMPTY selector is "everything of this kind", which every selector
1809
+ // of that kind is inside.
1810
+ return true;
1811
+ }
1812
+ if (inner === undefined || !isRecord(inner))
1813
+ return false;
1814
+ if (outerLabels !== undefined) {
1815
+ if (!isRecord(outerLabels))
1816
+ return false;
1817
+ const innerLabels = inner.matchLabels;
1818
+ if (!isRecord(innerLabels))
1819
+ return false;
1820
+ for (const [key, value] of Object.entries(outerLabels)) {
1821
+ if (!Object.hasOwn(innerLabels, key) || innerLabels[key] !== value)
1822
+ return false;
1823
+ }
1824
+ }
1825
+ const innerExpressions = readList(inner.matchExpressions);
1826
+ if (innerExpressions === 'unreadable')
1827
+ return false;
1828
+ for (const expression of outerExpressions ?? []) {
1829
+ if (!(innerExpressions ?? []).some((candidate) => isDeepStrictEqual(candidate, expression))) {
1830
+ return false;
1831
+ }
1832
+ }
1833
+ return true;
1834
+ }
1835
+ /** Is every address `wanted` admits also admitted by `allowed`. */
1836
+ function peerIsWithin(allowed, wanted) {
1837
+ if (allowed.kind === 'everything')
1838
+ return true;
1839
+ if (wanted.kind === 'everything')
1840
+ return false;
1841
+ if (allowed.kind === 'cidr') {
1842
+ if (wanted.kind !== 'cidr')
1843
+ return false;
1844
+ if (!cidrContains(allowed.cidr, wanted.cidr))
1845
+ return false;
1846
+ // Every hole the allowance carves out of its block has to be a hole in
1847
+ // this peer too, or this peer reaches an address the translation does
1848
+ // not allow. Coverage by a UNION of the peer's own `except` entries is
1849
+ // not attempted: one entry has to contain it.
1850
+ for (const hole of allowed.except) {
1851
+ if (!cidrsOverlap(hole, wanted.cidr))
1852
+ continue;
1853
+ if (!wanted.except.some((own) => cidrContains(own, hole)))
1854
+ return false;
1855
+ }
1856
+ return true;
1857
+ }
1858
+ if (wanted.kind !== 'selector')
1859
+ return false;
1860
+ // A namespaceSelector the allowance omits means "this namespace"; a peer
1861
+ // that names namespaces reaches further than that.
1862
+ if (allowed.namespaceSelector === undefined && wanted.namespaceSelector !== undefined) {
1863
+ return false;
1864
+ }
1865
+ return (selectorIsNarrower(allowed.namespaceSelector, wanted.namespaceSelector) &&
1866
+ selectorIsNarrower(allowed.podSelector, wanted.podSelector));
1867
+ }
1868
+ function destinationIsAllowed(allowance, peer, ports) {
1869
+ const wanted = ports === 'all' ? [{}] : ports;
1870
+ return wanted.every((range) => allowance.destinations.some((destination) => peerIsWithin(destination.peer, peer) && portsCover(destination.ports, range)));
1871
+ }
1872
+ /** Is every port `wanted` reaches on `host` also allowed by some `fqdns` entry naming it. */
1873
+ function fqdnIsAllowed(allowance, host, ports) {
1874
+ const wanted = ports === 'all' ? [{}] : ports;
1875
+ return wanted.every((range) => allowance.fqdns.some((entry) => entry.host === host && portsCover(entry.ports, range)));
1876
+ }
1877
+ /** Does `ports` (as a CANDIDATE rule's own port list) reach TCP or UDP 53 at all. */
1878
+ function coversDnsPort(ports) {
1879
+ if (ports === 'all')
1880
+ return true;
1881
+ return ports.some((range) => {
1882
+ if (range.protocol !== undefined && range.protocol !== 'UDP' && range.protocol !== 'TCP') {
1883
+ return false;
1884
+ }
1885
+ if (range.start === undefined)
1886
+ return true;
1887
+ return range.start <= 53 && (range.end ?? range.start) >= 53;
1888
+ });
1889
+ }
1890
+ /**
1891
+ * Does a candidate rule's `peer`+`ports` reach the cluster resolver on the DNS
1892
+ * port at all — the precondition for the DNS-widening check both
1893
+ * {@link coreEgressRuleVerdict} and {@link ciliumEgressRuleVerdict} apply
1894
+ * before falling back to the ordinary peer/port `destinationIsAllowed` check.
1895
+ */
1896
+ function reachesResolverAtDnsPort(peer, ports) {
1897
+ return peerIsWithin(peer, RESOLVER_PEER) && coversDnsPort(ports);
1898
+ }
1899
+ function describePeer(peer) {
1900
+ if (peer.kind === 'everything')
1901
+ return 'every destination';
1902
+ return peer.text;
1903
+ }
1904
+ function describePorts(ports) {
1905
+ if (ports === 'all')
1906
+ return 'every port';
1907
+ return ports
1908
+ .map((range) => range.start === undefined
1909
+ ? `every ${range.protocol ?? ''} port`.trim()
1910
+ : `${range.protocol ?? 'any'} ${range.start}${range.end !== undefined ? `-${range.end}` : ''}`)
1911
+ .join(', ');
1912
+ }
1913
+ function coreEgressRuleVerdict(rule, allowance) {
1914
+ // Under a translation that permits nothing, the rule's own shape does not
1915
+ // matter and is not read: ANY egress rule on a policy selecting this pod
1916
+ // lets something out.
1917
+ if (allowance.permitsNothing) {
1918
+ return {
1919
+ beyond: true,
1920
+ detail: 'an egress rule, where the configured translation permits no egress at all',
1921
+ };
1922
+ }
1923
+ if (!isRecord(rule))
1924
+ return {
1925
+ beyond: 'unknown',
1926
+ detail: 'an egress rule that is not an object',
1927
+ };
1928
+ const ports = readPortRanges(rule.ports, 'TCP');
1929
+ if (ports === 'unreadable') {
1930
+ return {
1931
+ beyond: 'unknown',
1932
+ detail: 'a ports entry this check cannot read',
1933
+ };
1934
+ }
1935
+ const to = readList(rule.to);
1936
+ if (to === 'unreadable') {
1937
+ return { beyond: 'unknown', detail: "a 'to' that is not a list of peers" };
1938
+ }
1939
+ if (to === undefined || to.length === 0) {
1940
+ // No `to` on an egress rule means EVERY destination.
1941
+ return allowance.permitsEverything
1942
+ ? { beyond: false }
1943
+ : {
1944
+ beyond: true,
1945
+ detail: `no 'to' peers, so every destination is reachable on ${describePorts(ports)}`,
1946
+ };
1947
+ }
1948
+ for (const peer of to) {
1949
+ const read = readCorePeer(peer);
1950
+ if (read === undefined) {
1951
+ return {
1952
+ beyond: 'unknown',
1953
+ detail: `a 'to' peer this check cannot read (${JSON.stringify(peer)})`,
1954
+ };
1955
+ }
1956
+ // A plain `NetworkPolicy` has no L7 concept, so it cannot express the DNS
1957
+ // restriction `ciliumNarrowing.dnsNames` narrows to — a core rule
1958
+ // reaching the resolver on the DNS port always resolves every name, and
1959
+ // that is wider than a narrowed translation whatever its own peer/port
1960
+ // shape says. This has to be decided BEFORE `destinationIsAllowed`
1961
+ // below: that check only reasons about reachability, and our own
1962
+ // translation's `destinations` entry for the resolver is peer/port-only
1963
+ // too, so a plain kube-dns rule would otherwise read as `within` a
1964
+ // translation it actually resolves every name for.
1965
+ if (allowance.dnsNarrowedTo !== 'all' && reachesResolverAtDnsPort(read, ports)) {
1966
+ return {
1967
+ beyond: true,
1968
+ detail: `a 'to' peer ${describePeer(read)} on ${describePorts(ports)} reaching the cluster resolver's DNS port with no DNS-name restriction — a plain NetworkPolicy cannot narrow lookups the way the configured translation's ciliumNarrowing.dnsNames does, so this rule resolves every name the narrowed policy does not`,
1969
+ };
1970
+ }
1971
+ if (!destinationIsAllowed(allowance, read, ports)) {
1972
+ const wideOpen = corePeerIsWideOpen(peer) === true;
1973
+ return {
1974
+ beyond: true,
1975
+ detail: `${wideOpen ? 'a wide-open ' : 'a '}'to' peer ${describePeer(read)} on ${describePorts(ports)}, which a '${allowance.policyKind}' translation does not allow`,
1976
+ };
1977
+ }
1978
+ }
1979
+ return { beyond: false };
1980
+ }
1981
+ /** Every `to…` field the Cilium CRD declares. A rule naming none of them is port-only. */
1982
+ const CILIUM_DESTINATION_FIELDS = [
1983
+ 'toEndpoints',
1984
+ 'toEntities',
1985
+ 'toCIDR',
1986
+ 'toCIDRSet',
1987
+ 'toFQDNs',
1988
+ 'toServices',
1989
+ 'toGroups',
1990
+ 'toNodes',
1991
+ ];
1992
+ /**
1993
+ * The label set a Cilium endpoint selector names, read back as the core
1994
+ * `namespaceSelector`/`podSelector` pair it is equivalent to, so the one
1995
+ * `peerIsWithin` serves both resource kinds. `undefined` when the selector
1996
+ * uses anything this reading cannot map — a label source that is not a pod
1997
+ * label, or a match expression.
1998
+ */
1999
+ function ciliumEndpointPeer(selector) {
2000
+ if (!isRecord(selector))
2001
+ return undefined;
2002
+ if (selector.matchExpressions !== undefined)
2003
+ return undefined;
2004
+ const matchLabels = selector.matchLabels;
2005
+ if (!isRecord(matchLabels))
2006
+ return undefined;
2007
+ const podLabels = {};
2008
+ let namespace;
2009
+ for (const [rawKey, value] of Object.entries(matchLabels)) {
2010
+ if (typeof value !== 'string')
2011
+ return undefined;
2012
+ const key = ciliumSelectorKey(rawKey);
2013
+ if (key === undefined)
2014
+ return undefined;
2015
+ if (key === 'io.kubernetes.pod.namespace') {
2016
+ namespace = value;
2017
+ continue;
2018
+ }
2019
+ podLabels[key] = value;
2020
+ }
2021
+ return {
2022
+ kind: 'selector',
2023
+ ...(namespace !== undefined
2024
+ ? {
2025
+ namespaceSelector: {
2026
+ matchLabels: { 'kubernetes.io/metadata.name': namespace },
2027
+ },
2028
+ }
2029
+ : {}),
2030
+ podSelector: { matchLabels: podLabels },
2031
+ text: JSON.stringify(selector),
2032
+ };
2033
+ }
2034
+ function readCiliumRulePorts(rule) {
2035
+ // A Cilium rule carries its ports one level deeper, and a port entry with
2036
+ // no protocol means ANY rather than TCP.
2037
+ const toPorts = readList(rule.toPorts);
2038
+ if (toPorts === 'unreadable') {
2039
+ return { ok: false, detail: 'a toPorts that is not a list' };
2040
+ }
2041
+ const portEntries = [];
2042
+ for (const entry of toPorts ?? []) {
2043
+ if (!isRecord(entry)) {
2044
+ return { ok: false, detail: 'a toPorts entry that is not an object' };
2045
+ }
2046
+ const list = readList(entry.ports);
2047
+ if (list === 'unreadable') {
2048
+ return { ok: false, detail: 'a toPorts ports field that is not a list' };
2049
+ }
2050
+ // An entry with no `ports` at all bounds nothing, so the rule reaches
2051
+ // every port — exactly what an absent `toPorts` means.
2052
+ if (list === undefined || list.length === 0) {
2053
+ portEntries.length = 0;
2054
+ break;
2055
+ }
2056
+ portEntries.push(...list);
2057
+ }
2058
+ const ports = readPortRanges(portEntries, undefined);
2059
+ if (ports === 'unreadable') {
2060
+ return { ok: false, detail: 'a toPorts entry this check cannot read' };
2061
+ }
2062
+ return { ok: true, ports };
2063
+ }
2064
+ /**
2065
+ * A Cilium rule's `toPorts[].rules.dns`, read into the exact-name list it
2066
+ * restricts lookups to, or `'all'` when the rule does not restrict DNS at
2067
+ * all. Shared by {@link egressAllowance} (reading OUR OWN narrowed
2068
+ * DNS-visibility rule into {@link EgressAllowance.dnsNarrowedTo}) and
2069
+ * {@link ciliumEgressRuleVerdict} (deciding whether a CANDIDATE rule's own
2070
+ * restriction is narrow enough to not widen it) — one reading of "what names
2071
+ * does this Cilium rule let resolve", not two that could disagree.
2072
+ *
2073
+ * A `toPorts` entry with no `rules` at all, or a `rules.dns` entry carrying
2074
+ * `matchPattern` rather than `matchName`, both read as `'all'`: an absent L7
2075
+ * restriction resolves every name by definition, and this check does not
2076
+ * attempt to decide whether some wildcard pattern is a subset of an exact
2077
+ * name list — `'all'` is the conservative (never under-counts a widening)
2078
+ * answer for a shape it cannot reduce further.
2079
+ */
2080
+ function readCiliumRuleDnsNames(rule) {
2081
+ const toPorts = readList(rule.toPorts);
2082
+ if (toPorts === 'unreadable')
2083
+ return { ok: false };
2084
+ const names = [];
2085
+ for (const entry of toPorts ?? []) {
2086
+ if (!isRecord(entry))
2087
+ return { ok: false };
2088
+ if (entry.rules === undefined)
2089
+ return { ok: true, names: 'all' };
2090
+ if (!isRecord(entry.rules))
2091
+ return { ok: false };
2092
+ const dns = readList(entry.rules.dns);
2093
+ if (dns === 'unreadable')
2094
+ return { ok: false };
2095
+ if (dns === undefined)
2096
+ return { ok: true, names: 'all' };
2097
+ for (const item of dns) {
2098
+ if (!isRecord(item))
2099
+ return { ok: false };
2100
+ if (typeof item.matchName === 'string') {
2101
+ names.push(item.matchName);
2102
+ continue;
2103
+ }
2104
+ // `matchPattern` (Cilium's glob syntax) or anything else this reading
2105
+ // does not recognise — both are read as unrestricted rather than
2106
+ // guessed at, per the doc comment above.
2107
+ return { ok: true, names: 'all' };
2108
+ }
2109
+ }
2110
+ return { ok: true, names };
2111
+ }
2112
+ function ciliumEgressRuleVerdict(rule, allowance) {
2113
+ if (allowance.permitsNothing) {
2114
+ return {
2115
+ beyond: true,
2116
+ detail: 'an egress rule, where the configured translation permits no egress at all',
2117
+ };
2118
+ }
2119
+ if (!isRecord(rule))
2120
+ return {
2121
+ beyond: 'unknown',
2122
+ detail: 'an egress rule that is not an object',
2123
+ };
2124
+ const portsRead = readCiliumRulePorts(rule);
2125
+ if (!portsRead.ok) {
2126
+ return { beyond: 'unknown', detail: portsRead.detail };
2127
+ }
2128
+ const ports = portsRead.ports;
2129
+ const fields = new Map();
2130
+ for (const field of CILIUM_DESTINATION_FIELDS) {
2131
+ const list = readList(rule[field]);
2132
+ if (list === 'unreadable') {
2133
+ return {
2134
+ beyond: 'unknown',
2135
+ detail: `a ${field} that is not a list of peers`,
2136
+ };
2137
+ }
2138
+ if (list !== undefined && list.length > 0)
2139
+ fields.set(field, list);
2140
+ }
2141
+ if (fields.size === 0) {
2142
+ return allowance.permitsEverything
2143
+ ? { beyond: false }
2144
+ : {
2145
+ beyond: true,
2146
+ detail: `a port-only egress rule, so every destination is reachable on ${describePorts(ports)}`,
2147
+ };
2148
+ }
2149
+ for (const entity of fields.get('toEntities') ?? []) {
2150
+ if (typeof entity !== 'string') {
2151
+ return {
2152
+ beyond: 'unknown',
2153
+ detail: 'a toEntities entry that is not an entity name',
2154
+ };
2155
+ }
2156
+ if (!allowance.permitsEverything) {
2157
+ return {
2158
+ beyond: true,
2159
+ detail: `toEntities '${entity}', which a '${allowance.policyKind}' translation does not allow`,
2160
+ };
2161
+ }
2162
+ }
2163
+ for (const field of ['toCIDR', 'toCIDRSet']) {
2164
+ for (const entry of fields.get(field) ?? []) {
2165
+ const peer = field === 'toCIDR'
2166
+ ? readCorePeer({ ipBlock: { cidr: entry } })
2167
+ : isRecord(entry)
2168
+ ? readCorePeer({
2169
+ ipBlock: { cidr: entry.cidr, except: entry.except },
2170
+ })
2171
+ : undefined;
2172
+ if (peer === undefined) {
2173
+ return {
2174
+ beyond: 'unknown',
2175
+ detail: `a ${field} entry this check cannot read`,
2176
+ };
2177
+ }
2178
+ if (!destinationIsAllowed(allowance, peer, ports)) {
2179
+ return {
2180
+ beyond: true,
2181
+ detail: `${field} ${describePeer(peer)} on ${describePorts(ports)}, which a '${allowance.policyKind}' translation does not allow`,
2182
+ };
2183
+ }
2184
+ }
2185
+ }
2186
+ for (const entry of fields.get('toFQDNs') ?? []) {
2187
+ if (!isRecord(entry)) {
2188
+ return {
2189
+ beyond: 'unknown',
2190
+ detail: 'a toFQDNs entry that is not an object',
2191
+ };
2192
+ }
2193
+ const matchName = entry.matchName;
2194
+ if (typeof matchName !== 'string' || !fqdnIsAllowed(allowance, matchName, ports)) {
2195
+ return {
2196
+ beyond: true,
2197
+ detail: `toFQDNs ${JSON.stringify(entry)} on ${describePorts(ports)}, which a '${allowance.policyKind}' translation does not allow`,
2198
+ };
2199
+ }
2200
+ }
2201
+ for (const selector of fields.get('toEndpoints') ?? []) {
2202
+ const peer = ciliumEndpointPeer(selector);
2203
+ if (peer === undefined) {
2204
+ return {
2205
+ beyond: 'unknown',
2206
+ detail: 'a toEndpoints entry this check cannot read as a selector',
2207
+ };
2208
+ }
2209
+ // See the matching comment in `coreEgressRuleVerdict`: reachability alone
2210
+ // cannot tell a plain kube-dns rule from a narrowed one, so this has to
2211
+ // run before `destinationIsAllowed` below. Unlike a core rule, a Cilium
2212
+ // one CAN narrow DNS on its own `toPorts.rules.dns` — read it and accept
2213
+ // the rule only when what it names is a subset of what our own
2214
+ // translation narrows to.
2215
+ if (allowance.dnsNarrowedTo !== 'all' && reachesResolverAtDnsPort(peer, ports)) {
2216
+ const narrowedTo = allowance.dnsNarrowedTo;
2217
+ const candidate = readCiliumRuleDnsNames(rule);
2218
+ const isSubset = candidate.ok &&
2219
+ candidate.names !== 'all' &&
2220
+ candidate.names.every((n) => narrowedTo.includes(n));
2221
+ if (!isSubset) {
2222
+ return {
2223
+ beyond: true,
2224
+ detail: `toEndpoints ${describePeer(peer)} on ${describePorts(ports)} reaching the cluster resolver's DNS port with ${candidate.ok && candidate.names !== 'all' ? 'a rules.dns list this check cannot confirm is a subset of' : 'no rules.dns restriction at least as narrow as'} the configured translation's ciliumNarrowing.dnsNames`,
2225
+ };
2226
+ }
2227
+ continue;
2228
+ }
2229
+ if (!destinationIsAllowed(allowance, peer, ports)) {
2230
+ return {
2231
+ beyond: true,
2232
+ detail: `toEndpoints ${describePeer(peer)} on ${describePorts(ports)}, which a '${allowance.policyKind}' translation does not allow`,
2233
+ };
2234
+ }
2235
+ }
2236
+ for (const field of ['toServices', 'toGroups', 'toNodes']) {
2237
+ if (fields.has(field)) {
2238
+ return {
2239
+ beyond: 'unknown',
2240
+ detail: `a ${field} rule, whose destinations this check cannot enumerate`,
2241
+ };
2242
+ }
2243
+ }
2244
+ return { beyond: false };
2245
+ }
2246
+ function unreadableEgressPolicy(kind, name, detail) {
2247
+ return {
2248
+ kind,
2249
+ name,
2250
+ selects: 'unknown',
2251
+ enforcesEgress: false,
2252
+ rules: [],
2253
+ unreadable: detail,
2254
+ };
2255
+ }
2256
+ /**
2257
+ * Is this the object `config.egress` named — see
2258
+ * {@link EgressPolicyDocument.namedObject}.
2259
+ *
2260
+ * Read off the allowance rather than passed in, so every caller of
2261
+ * {@link readCoreEgressPolicies}/{@link readCiliumEgressPolicies} that built
2262
+ * its allowance with {@link egressAllowance} gets the marking without having
2263
+ * to remember a second argument, and the readers stay a function of "(items,
2264
+ * this pod, what the translation allows)".
2265
+ *
2266
+ * For a core `NetworkPolicy` the name is the whole answer. For a
2267
+ * `CiliumNetworkPolicy` it is not: the CRD carries EITHER one `spec` or a
2268
+ * `specs` list, `verifyEgressPolicyApplied` reads the former and refuses an
2269
+ * object carrying the latter, so the reader marks only the document built
2270
+ * from `item.spec` and lets a `specs` entry be judged by the union rule like
2271
+ * any other object's rules — see {@link EgressPolicyDocument.namedObject}.
2272
+ *
2273
+ * Deliberately NOT applied to an unreadable document: an object at the named
2274
+ * name that could not be read keeps its `not-evaluable` verdict, which refuses
2275
+ * — a fail-closed answer for a shape whose exact comparison already refused it
2276
+ * earlier on the create path.
2277
+ */
2278
+ function isNamedObject(allowance, kind, name) {
2279
+ return allowance.namedObject.kind === kind && allowance.namedObject.name === name;
2280
+ }
2281
+ /** Every core `NetworkPolicy` in the list, reduced to {@link EgressPolicyDocument}. */
2282
+ export function readCoreEgressPolicies(items, target, allowance) {
2283
+ return items.map((item, index) => {
2284
+ const name = policyName(item, index);
2285
+ if (!isRecord(item)) {
2286
+ return unreadableEgressPolicy('NetworkPolicy', name, 'a list entry that is not a policy object');
2287
+ }
2288
+ const spec = item.spec;
2289
+ if (!isRecord(spec)) {
2290
+ return unreadableEgressPolicy('NetworkPolicy', name, 'a spec that is not an object');
2291
+ }
2292
+ const policyTypes = readList(spec.policyTypes);
2293
+ if (policyTypes === 'unreadable') {
2294
+ return unreadableEgressPolicy('NetworkPolicy', name, 'a spec.policyTypes that is not a list');
2295
+ }
2296
+ const rules = readList(spec.egress);
2297
+ if (rules === 'unreadable') {
2298
+ return unreadableEgressPolicy('NetworkPolicy', name, 'a spec.egress that is not a list of rules');
2299
+ }
2300
+ // An ABSENT `policyTypes` is defaulted by the API server from the blocks
2301
+ // the object carries: Egress appears exactly when `spec.egress` does.
2302
+ // This is the opposite of the ingress default, where Egress's absence is
2303
+ // the thing that has to be spelled out.
2304
+ const enforcesEgress = policyTypes === undefined ? rules !== undefined : policyTypes.includes('Egress');
2305
+ return {
2306
+ kind: 'NetworkPolicy',
2307
+ name,
2308
+ ...(isNamedObject(allowance, 'NetworkPolicy', name) ? { namedObject: true } : {}),
2309
+ selects: matchesLabelSelector(spec.podSelector, target.podLabels),
2310
+ enforcesEgress,
2311
+ // An `egress` block under a `policyTypes` that leaves Egress out is
2312
+ // ignored by the API server itself: it neither bounds nor widens.
2313
+ rules: enforcesEgress
2314
+ ? (rules ?? []).map((rule) => coreEgressRuleVerdict(rule, allowance))
2315
+ : [],
2316
+ };
2317
+ });
2318
+ }
2319
+ /** Every `CiliumNetworkPolicy` in the list, reduced the same way. */
2320
+ export function readCiliumEgressPolicies(items, target, allowance) {
2321
+ const identity = ciliumIdentityLabels(target.podLabels, target.namespace);
2322
+ const documents = [];
2323
+ for (const [index, item] of items.entries()) {
2324
+ const name = policyName(item, index);
2325
+ if (!isRecord(item)) {
2326
+ documents.push(unreadableEgressPolicy('CiliumNetworkPolicy', name, 'a list entry that is not a policy object'));
2327
+ continue;
2328
+ }
2329
+ // The CRD carries EITHER one `spec` or a `specs` list, and a rule in
2330
+ // either enforces. Reading only `spec` would miss a whole policy.
2331
+ //
2332
+ // The two spellings are NOT equal where the named-object exemption is
2333
+ // concerned — see {@link EgressPolicyDocument.namedObject}. The exact
2334
+ // comparison the exemption leans on reads `spec` and refuses an object
2335
+ // carrying a `specs` list, so `spec` is the one document that earns the
2336
+ // marking; a `specs` entry is read as an ordinary policy's rules and is
2337
+ // judged by the union rule, which is what refuses one that widens.
2338
+ const named = isNamedObject(allowance, 'CiliumNetworkPolicy', name);
2339
+ const specDocuments = [];
2340
+ if (item.spec !== undefined && item.spec !== null) {
2341
+ specDocuments.push({ spec: item.spec, isTheSpec: true });
2342
+ }
2343
+ const more = readList(item.specs);
2344
+ if (more === 'unreadable') {
2345
+ documents.push(unreadableEgressPolicy('CiliumNetworkPolicy', name, 'a specs that is not a list of rule specs'));
2346
+ continue;
2347
+ }
2348
+ for (const spec of more ?? [])
2349
+ specDocuments.push({ spec, isTheSpec: false });
2350
+ if (specDocuments.length === 0) {
2351
+ documents.push(unreadableEgressPolicy('CiliumNetworkPolicy', name, 'neither a spec nor a specs list'));
2352
+ continue;
2353
+ }
2354
+ for (const { spec, isTheSpec } of specDocuments) {
2355
+ const document = readCiliumEgressRuleSpec(spec, name, identity, allowance, named && isTheSpec);
2356
+ if (document !== undefined)
2357
+ documents.push(document);
2358
+ }
2359
+ }
2360
+ return documents;
2361
+ }
2362
+ /**
2363
+ * One `spec`/`specs` entry. `undefined` when it is node-scoped — see below.
2364
+ *
2365
+ * `isTheNamedSpec` is true only for the document built from the named
2366
+ * object's own `spec` — the one {@link verifyEgressPolicyApplied} compared to
2367
+ * the translation. A `specs` entry never is; see
2368
+ * {@link EgressPolicyDocument.namedObject} for why that distinction is
2369
+ * load-bearing rather than bookkeeping.
2370
+ */
2371
+ function readCiliumEgressRuleSpec(spec, name, identity, allowance, isTheNamedSpec) {
2372
+ const unreadable = (detail) => unreadableEgressPolicy('CiliumNetworkPolicy', name, detail);
2373
+ if (!isRecord(spec))
2374
+ return unreadable('a rule spec that is not an object');
2375
+ // A node-scoped rule selects nodes, never pods.
2376
+ if (spec.nodeSelector !== undefined)
2377
+ return undefined;
2378
+ const rules = readList(spec.egress);
2379
+ if (rules === 'unreadable')
2380
+ return unreadable('a spec.egress that is not a list of rules');
2381
+ const egressDeny = readList(spec.egressDeny);
2382
+ if (egressDeny === 'unreadable')
2383
+ return unreadable('a spec.egressDeny that is not a list of rules');
2384
+ let enforcesEgress = rules !== undefined || egressDeny !== undefined;
2385
+ const enableDefaultDeny = spec.enableDefaultDeny;
2386
+ if (enableDefaultDeny !== undefined) {
2387
+ if (!isRecord(enableDefaultDeny))
2388
+ return unreadable('an enableDefaultDeny that is not an object');
2389
+ const forEgress = enableDefaultDeny.egress;
2390
+ if (forEgress !== undefined && typeof forEgress !== 'boolean') {
2391
+ return unreadable('an enableDefaultDeny.egress that is not a boolean');
2392
+ }
2393
+ if (forEgress === false)
2394
+ enforcesEgress = false;
2395
+ }
2396
+ return {
2397
+ kind: 'CiliumNetworkPolicy',
2398
+ name,
2399
+ ...(isTheNamedSpec ? { namedObject: true } : {}),
2400
+ selects: matchesLabelSelector(spec.endpointSelector, identity, ciliumSelectorKey),
2401
+ enforcesEgress,
2402
+ // `egressDeny` rules are not read: a deny rule can only narrow what
2403
+ // leaves the pod, and this check refuses widening. Allow rules are read
2404
+ // whatever `enableDefaultDeny` says, because what a rule admits it
2405
+ // admits — it simply may not be the policy that default-denies.
2406
+ rules: (rules ?? []).map((rule) => ciliumEgressRuleVerdict(rule, allowance)),
2407
+ };
2408
+ }
2409
+ /**
2410
+ * The union rule, applied. Pure — no I/O, no client, no clock — so every
2411
+ * shape that has to be refused can be asserted one per test.
2412
+ *
2413
+ * Kubernetes UNIONS every policy selecting a pod: traffic leaves if ANY
2414
+ * selecting policy allows it. So one policy allowing more than the
2415
+ * translation is the finding however many narrower ones sit beside it, and a
2416
+ * pod no policy default-denies has no egress boundary at all whatever the
2417
+ * named object says.
2418
+ *
2419
+ * The named object's `spec` is the one document whose rules are not judged
2420
+ * here — see {@link EgressPolicyDocument.namedObject} — and it was compared to
2421
+ * the translation by {@link verifyEgressPolicyApplied} on the same create path
2422
+ * just before. Its SELECTOR and its egress-scoping still decide, which is what
2423
+ * keeps "nothing bounds this pod" answerable for a deployment whose only
2424
+ * policy is that one; and a document built from a `specs` entry is judged here
2425
+ * like any other object's, because the exempting comparison refuses an object
2426
+ * carrying a `specs` list rather than reading one.
2427
+ */
2428
+ export function decideEgressUnion(documents, allowance) {
2429
+ const examined = [];
2430
+ let enforcing = 0;
2431
+ let widening;
2432
+ let undecided;
2433
+ for (const document of documents) {
2434
+ const base = { kind: document.kind, name: document.name };
2435
+ if (document.unreadable !== undefined) {
2436
+ const entry = {
2437
+ ...base,
2438
+ verdict: 'not-evaluable',
2439
+ detail: document.unreadable,
2440
+ };
2441
+ examined.push(entry);
2442
+ undecided ??= entry;
2443
+ continue;
2444
+ }
2445
+ if (document.selects === 'no') {
2446
+ examined.push({ ...base, verdict: 'does-not-select' });
2447
+ continue;
2448
+ }
2449
+ if (document.selects === 'unknown') {
2450
+ const entry = {
2451
+ ...base,
2452
+ verdict: 'not-evaluable',
2453
+ detail: 'its selector uses something this check cannot evaluate against pod labels',
2454
+ };
2455
+ examined.push(entry);
2456
+ undecided ??= entry;
2457
+ continue;
2458
+ }
2459
+ // The named object's `spec` rules were just compared to the translation
2460
+ // by `verifyEgressPolicyApplied`, field by field, which is the one
2461
+ // judgement about them that a `toFQDNs`-by-`matchName` allowance cannot
2462
+ // reproduce — see {@link EgressPolicyDocument.namedObject}. So the union
2463
+ // rule does not judge them; judging them here is what made the check
2464
+ // refuse the object it had just told an operator to apply. Everything
2465
+ // below still applies, which is how a pod whose only policy is the named
2466
+ // one is still known to be in egress default-deny. Only that `spec`
2467
+ // document carries the marking: a `specs` entry is judged here like any
2468
+ // other rules.
2469
+ if (document.namedObject !== true) {
2470
+ const beyondRule = document.rules.find((rule) => rule.beyond === true);
2471
+ if (beyondRule !== undefined) {
2472
+ const entry = {
2473
+ ...base,
2474
+ verdict: 'widens-egress',
2475
+ ...(beyondRule.detail !== undefined ? { detail: beyondRule.detail } : {}),
2476
+ };
2477
+ examined.push(entry);
2478
+ widening ??= entry;
2479
+ continue;
2480
+ }
2481
+ const unknownRule = document.rules.find((rule) => rule.beyond === 'unknown');
2482
+ if (unknownRule !== undefined) {
2483
+ const entry = {
2484
+ ...base,
2485
+ verdict: 'not-evaluable',
2486
+ ...(unknownRule.detail !== undefined ? { detail: unknownRule.detail } : {}),
2487
+ };
2488
+ examined.push(entry);
2489
+ undecided ??= entry;
2490
+ continue;
2491
+ }
2492
+ }
2493
+ if (!document.enforcesEgress) {
2494
+ examined.push({
2495
+ ...base,
2496
+ verdict: 'not-egress-scoped',
2497
+ detail: 'it selects the pod but default-denies nothing on egress',
2498
+ });
2499
+ continue;
2500
+ }
2501
+ examined.push({ ...base, verdict: 'within' });
2502
+ enforcing += 1;
2503
+ }
2504
+ if (widening !== undefined) {
2505
+ return {
2506
+ examined,
2507
+ refusal: {
2508
+ kind: 'policy-widens-egress',
2509
+ summary: `${widening.kind}/${widening.name} selects this pod and allows ${widening.detail ?? 'egress the configured translation does not'}.`,
2510
+ },
2511
+ };
2512
+ }
2513
+ if (undecided !== undefined) {
2514
+ return {
2515
+ examined,
2516
+ refusal: {
2517
+ kind: 'not-evaluable',
2518
+ summary: `${undecided.kind}/${undecided.name} contains ${undecided.detail ?? 'something this check cannot evaluate'}, so what this pod may reach cannot be decided from the cluster's own objects.`,
2519
+ },
2520
+ };
2521
+ }
2522
+ // `allow-all` asks for no boundary, so a pod nothing default-denies is
2523
+ // exactly what it configured; every other kind needs a policy that
2524
+ // actually puts this pod in egress default-deny, or the translation is a
2525
+ // manifest nobody enforces.
2526
+ if (enforcing === 0 && !allowance.permitsEverything) {
2527
+ return {
2528
+ examined,
2529
+ refusal: {
2530
+ kind: 'no-enforcing-policy',
2531
+ summary: `no applied policy puts this pod in egress default-deny, so a '${allowance.policyKind}' translation bounds nothing it sends.`,
2532
+ },
2533
+ };
2534
+ }
2535
+ return { examined };
2536
+ }
2537
+ /**
2538
+ * The named refusal. Distinct from {@link KubernetesEgressPolicyMismatchError}
2539
+ * — which is about the ONE named object drifting from the translation — and
2540
+ * from `KubernetesIngressPolicyError`, which is about the agent port being
2541
+ * reachable. An operator debugging a release that ships all three tells them
2542
+ * apart by class and by the first clause of the message.
2543
+ *
2544
+ * It carries the pod's labels and EVERY policy examined, with a verdict each,
2545
+ * because that list is the operator's whole debugging session: "why does my
2546
+ * policy not count?" is answered by the line saying it did not select these
2547
+ * labels.
2548
+ */
2549
+ export class KubernetesEgressPolicyUnionError extends Error {
2550
+ refusal;
2551
+ subject;
2552
+ podLabels;
2553
+ policyKind;
2554
+ examined;
2555
+ unread;
2556
+ name = 'KubernetesEgressPolicyUnionError';
2557
+ constructor(refusal, subject, podLabels, policyKind, examined, summary,
2558
+ /** Empty on every decision made from policies that WERE read. */
2559
+ unread = []) {
2560
+ super(`kubernetes: refusing ${subject} — ${summary} config.egress.policy is '${policyKind}' and the pod's labels are ${formatLabels(podLabels)}. ${formatExaminedEgress(examined, unread)} Kubernetes UNIONS every policy selecting a pod, so what leaves this pod is everything ANY of them allows — a second policy widens egress however exactly the named object matches. ${formatEgressRemedy(refusal, unread)}`);
2561
+ this.refusal = refusal;
2562
+ this.subject = subject;
2563
+ this.podLabels = podLabels;
2564
+ this.policyKind = policyKind;
2565
+ this.examined = examined;
2566
+ this.unread = unread;
2567
+ }
2568
+ }
2569
+ function formatExaminedEgress(examined, unread) {
2570
+ const lines = examined.map((entry) => {
2571
+ const detail = entry.detail !== undefined ? ` (${entry.detail})` : '';
2572
+ return `${entry.kind}/${entry.name}: ${entry.verdict}${detail}`;
2573
+ });
2574
+ const read = lines.length === 0
2575
+ ? 'No policy was read from the collections this check could enumerate.'
2576
+ : `Policies examined: ${lines.join('; ')}.`;
2577
+ if (unread.length === 0)
2578
+ return read;
2579
+ const missing = unread
2580
+ .map((source) => `${source.resource} at ${source.path} (${source.why}: ${source.reason})`)
2581
+ .join('; ');
2582
+ return `${read} NOT read, so nothing below is a statement about it: ${missing}.`;
2583
+ }
2584
+ function formatEgressRemedy(refusal, unread) {
2585
+ if (refusal === 'not-evaluable') {
2586
+ const forbidden = unread.some((source) => source.why === 'forbidden');
2587
+ return `${forbidden ? "Grant this backend's ServiceAccount 'list' on that resource, " : 'Fix or remove the policy named above, '}or set egress.verify: 'named-object-only' to check only the one named object, as every release before this one did.`;
2588
+ }
2589
+ if (refusal === 'no-enforcing-policy') {
2590
+ return "Apply a NetworkPolicy whose podSelector matches the labels above and whose policyTypes includes 'Egress' (packages/sandbox/k8s/manifests/networkpolicy.yaml is this repo's baseline), or set egress.verify: 'named-object-only' if the boundary lives somewhere a namespaced Role cannot read.";
2591
+ }
2592
+ return "Narrow or delete the policy named above so that nothing selecting these pods allows more than config.egress does, or set egress.verify: 'named-object-only' to go back to checking only the named object.";
2593
+ }
2594
+ // ---------------------------------------------------------------------------
2595
+ // The I/O half
2596
+ // ---------------------------------------------------------------------------
2597
+ /**
2598
+ * Verify-not-trust, widened from one object to the union: list the
2599
+ * namespace's policies, evaluate every one that selects this pod against the
2600
+ * configured translation, and refuse unless nothing lets out more than
2601
+ * `config.egress` says. The named object's `spec` document is the one
2602
+ * exception, and it was compared to the translation by
2603
+ * {@link verifyEgressPolicyApplied} on this same create path just before this
2604
+ * one — see {@link EgressPolicyDocument.namedObject}: a Cilium object that
2605
+ * carries a `specs` list is refused there outright, so a `specs` entry is
2606
+ * judged HERE, like any other object's rules.
2607
+ *
2608
+ * Runs beside the ingress check on every create path — before the POST for a
2609
+ * directly created Sandbox, where the labels are known and a refusal leaves
2610
+ * nothing behind, and after the bind for a claimed one, where the pool's own
2611
+ * template decides the labels and a refusal releases the claim through the
2612
+ * acquire path's cleanup.
2613
+ */
2614
+ export async function verifyEgressPolicyUnion(client, translated, target, signal) {
2615
+ const allowance = egressAllowance(translated);
2616
+ const documents = [];
2617
+ try {
2618
+ documents.push(...readCoreEgressPolicies(await listPolicies(client, networkPolicyCollectionPath(target.namespace), 'networkpolicies', signal), target, allowance));
2619
+ if (target.engine === 'cilium') {
2620
+ documents.push(...readCiliumEgressPolicies(await listPolicies(client, ciliumNetworkPolicyCollectionPath(target.namespace), 'ciliumnetworkpolicies', signal), target, allowance));
2621
+ }
2622
+ }
2623
+ catch (err) {
2624
+ if (!(err instanceof UnreadPolicyCollection))
2625
+ throw err;
2626
+ throw new KubernetesEgressPolicyUnionError('not-evaluable', target.subject, target.podLabels, allowance.policyKind, decideEgressUnion(documents, allowance).examined, err.summary, [err.source]);
2627
+ }
2628
+ const decision = decideEgressUnion(documents, allowance);
2629
+ if (decision.refusal === undefined)
2630
+ return;
2631
+ throw new KubernetesEgressPolicyUnionError(decision.refusal.kind, target.subject, target.podLabels, allowance.policyKind, decision.examined, decision.refusal.summary);
313
2632
  }
314
2633
  //# sourceMappingURL=egress-policy.js.map