microvms_core/control/connector.rs
1// SPDX-License-Identifier: Apache-2.0
2//! Managed connector intents and customer-managed VPC egress connector validation.
3//!
4//! # Why an enum rather than a string
5//!
6//! Two reasons, and both are measurements.
7//!
8//! The API takes a **fully-qualified ARN** and rejects the bare name with "Malformed
9//! network connector ARN" (measured 2026-08-05). `NetworkConnector` in the service model
10//! is just `{type: string, max: 2048, min: 1}` — no pattern, no enum — so a free-form
11//! parameter passes every check the model states and fails on the wire, and the value
12//! that reads most natural to write (`"ALL_INGRESS"`) is exactly the one that fails.
13//! Deriving the ARN from an intent means the caller states what they want and the
14//! spelling is not theirs to get wrong.
15//!
16//! # TRAP-11, revised: the shell connector is a variant, on measured ground
17//!
18//! `SHELL_INGRESS` **is** a variant here, and it was not always. This module used to
19//! omit it on the claim that it gated a console-only debugging flow — "not a
20//! programmatic exec path despite the name" — and that omission made requesting it
21//! unwriteable. `docs/PLATFORM.md` (measured 2026-08-15) refutes the claim: the shell
22//! endpoint is a real PTY over a WebSocket, and it is programmatically drivable. The
23//! ground that actually holds is narrower — **one interactive session is not
24//! programmatic exec**: no exec ids, no idempotency, no separated stdout/stderr, no exit
25//! codes. So the variant exists for callers that want the PTY, and the exec path never
26//! requests it; the lifecycle test in [`crate::control::microvm`] asserts a launch
27//! carries exactly the connectors its caller asked for.
28//!
29//! Two measured constraints travel with the variant (both from `docs/PLATFORM.md`):
30//! `ALL_INGRESS` cannot combine with any other ingress connector, and the platform says
31//! so only at token-mint time — `RunMicrovm` accepts the invalid set, the VM reaches
32//! RUNNING, and it bills until something asks for a shell token.
33//! [`crate::control::ControlPlane::run_microvm`] refuses the combination locally
34//! instead. The pair that works is `[HTTP_INGRESS, SHELL_INGRESS]`.
35//!
36//! The sibling half of TRAP-11 — the shell-auth operation — is still closed by the
37//! absence of a method on [`crate::control::ControlPlane`]; see that module's docs.
38
39use crate::region::Region;
40
41/// Validates a customer-managed connector ARN before launching a billable VM.
42pub(super) fn require_egress_connector_arn(arn: &str, region: &Region) -> Result<(), crate::Error> {
43 let parts: Vec<&str> = arn.splitn(6, ':').collect();
44 let valid = parts.len() == 6
45 && (1..=crate::constants::MAX_NETWORK_CONNECTOR_LEN).contains(&arn.len())
46 && parts[0] == "arn"
47 && parts[1] == "aws"
48 && parts[2] == "lambda"
49 && parts[3] == region.as_str()
50 && parts[4].len() == 12
51 && parts[4].bytes().all(|byte| byte.is_ascii_digit())
52 && parts[5]
53 .strip_prefix("network-connector:")
54 .is_some_and(|resource| {
55 let mut parts = resource.split(':');
56 let name = parts.next().unwrap_or_default();
57 let version = parts.next();
58 !name.is_empty()
59 && name
60 .bytes()
61 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
62 && version.is_none_or(|version| {
63 !version.is_empty()
64 && !version.starts_with('0')
65 && version.bytes().all(|byte| byte.is_ascii_digit())
66 })
67 && parts.next().is_none()
68 });
69 if !valid {
70 return Err(crate::Error::invalid_arg(format!(
71 "egressNetworkConnectors requires a customer-managed Lambda network connector ARN \
72 in {} (arn:aws:lambda:{}:<12-digit-account>:network-connector:<id>[:<version>]); \
73 create the VPC egress connector through lambda-core first",
74 region.as_str(),
75 region.as_str(),
76 )));
77 }
78 Ok(())
79}
80
81/// Compatibility constant: omitting an egress connector does not block internet access.
82/// Isolation requires a VPC egress connector and a VPC without an internet gateway
83/// or NAT gateway. A launch flag cannot establish the VPC's routing configuration.
84pub const PLATFORM_HONOURS_OMITTED_EGRESS: bool = false;
85
86/// Conservative network posture inferred from launch options.
87///
88/// Customer-managed connector ARNs do not prove internet isolation: their VPC routing
89/// must be checked separately. No combination of flags is automatically `Sealed`.
90#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
91pub enum EgressPosture {
92 /// `INTERNET_EGRESS` is on the request: the VM has outbound network by design.
93 Open,
94 /// Internet isolation has not been verified. This is the default launch posture.
95 Unsealed,
96 /// Advisory in-guest proxy denial ([`crate::sandbox::RunRequest::deny_egress`]).
97 /// A workload can bypass it by ignoring its environment.
98 BestEffort,
99 /// Internet isolation verified separately through VPC routing configuration.
100 /// Retained for stored labels; never inferred by [`EgressPosture::for_launch`].
101 Sealed,
102}
103
104impl EgressPosture {
105 /// The posture of a launch that requested `egress` and asked for `deny_egress`.
106 ///
107 /// These flags cannot verify VPC routing, so this never returns `Sealed`.
108 pub fn for_launch(egress: bool, deny_egress: bool) -> Self {
109 match (egress, deny_egress) {
110 (true, _) => EgressPosture::Open,
111 (false, true) => EgressPosture::BestEffort,
112 (false, false) => EgressPosture::Unsealed,
113 }
114 }
115
116 /// The wire spelling, which is what an envelope and a `--json` consumer branch on.
117 ///
118 /// Four values and no `Option`: a consumer never has to guard against a missing
119 /// posture, and none of the four is the empty string a `bool` degrades into.
120 pub fn as_str(self) -> &'static str {
121 match self {
122 EgressPosture::Open => "open",
123 EgressPosture::Unsealed => "unsealed",
124 EgressPosture::BestEffort => "best-effort",
125 EgressPosture::Sealed => "sealed",
126 }
127 }
128
129 /// Whether outbound traffic is refused by something other than the workload's goodwill.
130 ///
131 /// Only [`EgressPosture::Sealed`] answers `true`. [`EgressPosture::BestEffort`] does
132 /// not, and that is the whole reason this predicate exists rather than a
133 /// `posture != Open` test at each call site.
134 pub fn is_sealed(self) -> bool {
135 matches!(self, EgressPosture::Sealed)
136 }
137
138 /// The posture a stored label names, or `None` for a string this version does not know.
139 ///
140 /// The inverse of [`EgressPosture::as_str`], and it exists for one caller: the CLI's
141 /// name registry persists the label of the launch a name was registered for, and
142 /// `agent-up`'s refresh path reads it back in a later process rather than assuming the
143 /// posture of the launch it did not perform. `None` rather than a default, so the caller
144 /// decides what an unreadable record means instead of inheriting a claim.
145 pub fn from_label(label: &str) -> Option<Self> {
146 [
147 EgressPosture::Open,
148 EgressPosture::Unsealed,
149 EgressPosture::BestEffort,
150 EgressPosture::Sealed,
151 ]
152 .into_iter()
153 .find(|posture| posture.as_str() == label)
154 }
155
156 /// One sentence a human can act on, printed beside the label.
157 ///
158 /// Each names the mechanism rather than a verdict, because "no egress" read as a
159 /// verdict is the defect.
160 pub fn describe(self) -> &'static str {
161 match self {
162 EgressPosture::Open => {
163 "the INTERNET_EGRESS connector is on the request; the VM reaches the \
164 internet by design"
165 }
166 EgressPosture::Unsealed => {
167 "internet isolation is unverified. Use a VPC egress connector with a VPC \
168 without an internet gateway or NAT gateway; connector omission does not \
169 block internet access. Not a seal"
170 }
171 EgressPosture::BestEffort => {
172 "advisory proxy-deny environment variables affect clients that honor them; \
173 workloads can bypass them. Not a seal"
174 }
175 EgressPosture::Sealed => {
176 "internet isolation requires separately verified VPC routing without an \
177 internet gateway or NAT gateway; launch flags alone do not establish it"
178 }
179 }
180 }
181}
182
183/// Defaults to the posture of a launch with no network flags.
184impl Default for EgressPosture {
185 fn default() -> Self {
186 EgressPosture::for_launch(false, false)
187 }
188}
189
190impl std::fmt::Display for EgressPosture {
191 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
192 f.write_str(self.as_str())
193 }
194}
195
196/// Lambda-managed connector intents. Customer-managed VPC connectors use explicit ARNs.
197///
198/// Named for the *intent* rather than for the wire value, because the intent is what a
199/// caller has: "let the proxy reach the VM" and "let the VM reach the internet". The
200/// wire spellings ([`ConnectorIntent::wire_name`]) are an implementation detail of the
201/// ARN.
202#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
203pub enum ConnectorIntent {
204 /// Lets the endpoint proxy reach the VM. Required for any session to work.
205 ///
206 /// The union that cannot be intersected: the platform refuses to combine it with
207 /// any other ingress connector, and only at token-mint time — see the module docs.
208 /// A VM that needs a shell requests [`ConnectorIntent::HttpIngress`] plus
209 /// [`ConnectorIntent::ShellIngress`] instead of this.
210 AllIngress,
211 /// Lets the endpoint proxy reach the VM's HTTP surface, without the shell.
212 ///
213 /// The finer-grained sibling of [`ConnectorIntent::AllIngress`], and the half of the
214 /// measured pair `[HTTP_INGRESS, SHELL_INGRESS]` that keeps the daemon reachable
215 /// (`docs/PLATFORM.md`, measured 2026-08-15).
216 HttpIngress,
217 /// Lets the VM mint shell tokens and serve its PTY WebSocket.
218 ///
219 /// One interactive session, not programmatic exec — the module docs carry the
220 /// revision of TRAP-11 that admitted this variant. Never combined with
221 /// [`ConnectorIntent::AllIngress`]; the pair that launches and mints is with
222 /// [`ConnectorIntent::HttpIngress`].
223 ShellIngress,
224 /// Lets the VM reach the internet.
225 ///
226 /// Omitted by default — the right default for a daemon that needs none. Omitting it
227 /// omits the connector from the request; measured 2026-09-11 and 2026-09-12 the
228 /// platform still gave such a VM outbound network (`docs/PLATFORM.md`, "A VM launched
229 /// without the egress connector still has outbound network").
230 Egress,
231}
232
233impl ConnectorIntent {
234 /// Every intent, so a test can enumerate the complete set.
235 ///
236 /// Maintained by hand; the tests below assert its length, so an edit here is a
237 /// deliberate one. The set grew from two to four when TRAP-11 was revised — see the
238 /// module docs.
239 pub const ALL: [ConnectorIntent; 4] = [
240 ConnectorIntent::AllIngress,
241 ConnectorIntent::HttpIngress,
242 ConnectorIntent::ShellIngress,
243 ConnectorIntent::Egress,
244 ];
245
246 /// The connector's name as the ARN spells it.
247 ///
248 /// `INTERNET_EGRESS` rather than `EGRESS`: the wire name is not the intent's name,
249 /// which is the second reason this is a table rather than a `Display` derive.
250 pub fn wire_name(self) -> &'static str {
251 match self {
252 ConnectorIntent::AllIngress => "ALL_INGRESS",
253 ConnectorIntent::HttpIngress => "HTTP_INGRESS",
254 ConnectorIntent::ShellIngress => "SHELL_INGRESS",
255 ConnectorIntent::Egress => "INTERNET_EGRESS",
256 }
257 }
258
259 /// The fully-qualified ARN for this connector in `region`.
260 ///
261 /// One interpolation for both directions. The Python client's earlier shape derived
262 /// the egress ARN by string-replacing `ALL_INGRESS` inside the ingress one, which
263 /// produced a valid ARN only as long as the two names never became substrings of
264 /// each other (`sandbox.py:391-398`).
265 ///
266 /// The doubled `aws-network-connector` segment is not a typo: the resource type and
267 /// the resource name are both present, and the format is copied from
268 /// `sandbox.py:398` rather than reconstructed from the ARN grammar.
269 pub fn arn(self, region: &Region) -> String {
270 format!(
271 "arn:aws:lambda:{}:aws:network-connector:aws-network-connector:{}",
272 region.as_str(),
273 self.wire_name()
274 )
275 }
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281
282 #[test]
283 fn customer_connector_arn_length_uses_the_run_microvm_limit() {
284 let prefix = "arn:aws:lambda:us-east-1:123456789012:network-connector:";
285 let maximum = crate::constants::MAX_NETWORK_CONNECTOR_LEN;
286 let arn = format!("{prefix}{}", "x".repeat(maximum - prefix.len()));
287 require_egress_connector_arn(&arn, &Region::UsEast1).expect("at the request limit");
288 require_egress_connector_arn(&format!("{arn}x"), &Region::UsEast1)
289 .expect_err("one over the request limit");
290 }
291
292 /// An omitted connector cannot establish internet isolation.
293 #[test]
294 fn a_launch_with_no_connector_is_unsealed_and_not_sealed() {
295 let posture = EgressPosture::for_launch(false, false);
296 assert_eq!(posture, EgressPosture::Unsealed);
297 assert_eq!(posture.as_str(), "unsealed");
298 assert!(
299 !posture.is_sealed(),
300 "omitting the connector does not seal the VM; it is measured not to"
301 );
302 }
303
304 /// The other three postures, and the two rules that order them: `--egress` is `open`
305 /// whatever else was asked for, and proxy denial is only advisory.
306 #[test]
307 fn the_posture_table_is_the_whole_domain() {
308 assert_eq!(EgressPosture::for_launch(true, false), EgressPosture::Open);
309 assert_eq!(
310 EgressPosture::for_launch(true, true),
311 EgressPosture::Open,
312 "asking for egress is asking for egress; the advisory deny cannot dress it as \
313 anything narrower"
314 );
315 assert_eq!(
316 EgressPosture::for_launch(false, true),
317 EgressPosture::BestEffort
318 );
319 assert_eq!(EgressPosture::BestEffort.as_str(), "best-effort");
320 assert!(
321 !EgressPosture::BestEffort.is_sealed(),
322 "best effort is not a seal, and this predicate is the only place that decides it"
323 );
324 assert!(EgressPosture::Sealed.is_sealed());
325 }
326
327 /// Every posture renders a distinct non-empty label and a sentence that says what the
328 /// mechanism is, because a consumer branches on the label and a human reads the line.
329 #[test]
330 fn every_posture_has_a_distinct_label_and_a_description() {
331 let postures = [
332 EgressPosture::Open,
333 EgressPosture::Unsealed,
334 EgressPosture::BestEffort,
335 EgressPosture::Sealed,
336 ];
337 let labels: Vec<&str> = postures.iter().map(|posture| posture.as_str()).collect();
338 for (i, a) in labels.iter().enumerate() {
339 assert!(!a.is_empty());
340 for b in &labels[i + 1..] {
341 assert_ne!(a, b);
342 }
343 }
344 for posture in postures {
345 assert!(posture.describe().len() > 40, "{posture}");
346 }
347 // The two postures that are not seals must say so in words, not only in the
348 // predicate: the human line is what a reviewer reads.
349 for posture in [EgressPosture::Unsealed, EgressPosture::BestEffort] {
350 assert!(
351 posture.describe().contains("Not a seal"),
352 "{posture} must state that it is not a seal: {}",
353 posture.describe()
354 );
355 }
356 }
357
358 /// Defaults and explicitly empty launch options report the same posture.
359 #[test]
360 fn the_default_posture_is_the_derived_one() {
361 assert_eq!(
362 EgressPosture::default(),
363 EgressPosture::for_launch(false, false),
364 "a defaulted posture must be the posture of a launch that asks for nothing, \
365 derived from the same launch options"
366 );
367 }
368
369 /// Every label round-trips, and an unknown one is `None` rather than a guess.
370 ///
371 /// The registry stores the label, so a record written by a later version — or corrupted
372 /// — must not be read as a posture this version would then report as measured.
373 #[test]
374 fn every_label_round_trips_and_an_unknown_one_is_none() {
375 for posture in [
376 EgressPosture::Open,
377 EgressPosture::Unsealed,
378 EgressPosture::BestEffort,
379 EgressPosture::Sealed,
380 ] {
381 assert_eq!(EgressPosture::from_label(posture.as_str()), Some(posture));
382 }
383 assert_eq!(EgressPosture::from_label("sealed-ish"), None);
384 assert_eq!(EgressPosture::from_label(""), None);
385 assert_eq!(
386 EgressPosture::from_label("Open"),
387 None,
388 "the labels are the wire spelling, and case is part of it"
389 );
390 }
391
392 /// The exact ARN, as a literal, for the region the measurements were taken in. The
393 /// format came from a measurement rather than from the ARN grammar, so a
394 /// reconstruction of it in the test would agree with a reconstruction in the code.
395 #[test]
396 fn the_ingress_arn_is_the_measured_literal() {
397 assert_eq!(
398 ConnectorIntent::AllIngress.arn(&Region::UsEast1),
399 "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:ALL_INGRESS"
400 );
401 }
402
403 /// The egress ARN, likewise — and note the wire name is `INTERNET_EGRESS` while the
404 /// variant is `Egress`, which is the thing a `Display` derive would have got wrong.
405 #[test]
406 fn the_egress_arn_uses_the_internet_egress_wire_name() {
407 assert_eq!(
408 ConnectorIntent::Egress.arn(&Region::UsEast1),
409 "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:INTERNET_EGRESS"
410 );
411 }
412
413 /// The region is interpolated rather than fixed, for every region including the
414 /// escape hatch. TRAP-4 is "for the request region", and a hardcoded `us-east-1`
415 /// would pass both tests above.
416 #[test]
417 fn the_arn_carries_the_request_region_for_every_region() {
418 for region in crate::region::MICROVM_REGIONS {
419 let arn = ConnectorIntent::AllIngress.arn(®ion);
420 assert!(
421 arn.contains(&format!(":lambda:{}:aws:", region.as_str())),
422 "{arn}"
423 );
424 }
425 let unlisted = Region::unlisted("me-south-1");
426 assert!(
427 ConnectorIntent::Egress
428 .arn(&unlisted)
429 .contains(":lambda:me-south-1:aws:"),
430 "the escape hatch still derives an ARN for its own region"
431 );
432 }
433
434 /// TRAP-11, rewritten on the ground that holds. This test used to assert no intent
435 /// names `SHELL_INGRESS`, standing on the claim that the shell was a console-only
436 /// debugging path — a claim `docs/PLATFORM.md` (measured 2026-08-15) refutes: the
437 /// shell endpoint is a real PTY and programmatically drivable. What holds instead is
438 /// that **one interactive session is not programmatic exec**, so the variant exists,
439 /// exactly one intent renders it, and the check on the rendered ARNs stays — a
440 /// variant named something else must not render `SHELL_INGRESS` either.
441 ///
442 /// The other half of the revised guard — a launch carries exactly the connectors its
443 /// caller asked for — lives with the lifecycle test in `microvm.rs`.
444 ///
445 /// **Falsification** — add a second variant whose wire name contains `SHELL`, or
446 /// rename [`ConnectorIntent::ShellIngress`]'s wire name, and this fails.
447 #[test]
448 fn shell_ingress_is_one_deliberate_intent() {
449 assert_eq!(
450 ConnectorIntent::ALL.len(),
451 4,
452 "four intents, and shell is deliberately one of them"
453 );
454 let shells: Vec<ConnectorIntent> = ConnectorIntent::ALL
455 .into_iter()
456 .filter(|intent| intent.wire_name().contains("SHELL"))
457 .collect();
458 assert_eq!(
459 shells,
460 vec![ConnectorIntent::ShellIngress],
461 "exactly one intent names the shell, and it is the one that says so"
462 );
463 assert_eq!(
464 ConnectorIntent::ShellIngress.arn(&Region::UsEast1),
465 "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:SHELL_INGRESS"
466 );
467 }
468
469 /// `HTTP_INGRESS`, the measured literal — the finer-grained ingress that pairs with
470 /// the shell connector, since `ALL_INGRESS` cannot combine with either
471 /// (`docs/PLATFORM.md`, measured 2026-08-15).
472 #[test]
473 fn the_http_ingress_arn_is_the_measured_literal() {
474 assert_eq!(
475 ConnectorIntent::HttpIngress.arn(&Region::UsEast1),
476 "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:HTTP_INGRESS"
477 );
478 }
479
480 /// No two intents render the same ARN. The string-replace shape this replaced
481 /// could produce two identical ARNs if the names ever became substrings of one
482 /// another, and that is the failure this asserts against — now across the whole
483 /// set rather than the original pair.
484 #[test]
485 fn no_two_intents_render_the_same_arn() {
486 let arns: Vec<String> = ConnectorIntent::ALL
487 .iter()
488 .map(|intent| intent.arn(&Region::EuWest1))
489 .collect();
490 for (i, a) in arns.iter().enumerate() {
491 for b in &arns[i + 1..] {
492 assert_ne!(a, b);
493 }
494 }
495 assert!(!arns[0].contains("INTERNET"), "{}", arns[0]);
496 }
497
498 /// Every derived ARN clears the model's `NetworkConnector` bounds (min 1, max 2048),
499 /// including for the longest region name. The model states no pattern, so length is
500 /// the only constraint there is to check — and it is checked here rather than
501 /// trusted because a 2048-character ARN would be rejected on the wire with the same
502 /// "malformed" message a bare name gets.
503 #[test]
504 fn every_derived_arn_fits_the_models_connector_bounds() {
505 for region in crate::region::MICROVM_REGIONS {
506 for intent in ConnectorIntent::ALL {
507 let arn = intent.arn(®ion);
508 assert!((1..=2048).contains(&arn.len()), "{arn}");
509 assert!(arn.starts_with("arn:aws:lambda:"), "{arn}");
510 }
511 }
512 }
513}