Skip to main content

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(&region);
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(&region);
508                assert!((1..=2048).contains(&arn.len()), "{arn}");
509                assert!(arn.starts_with("arn:aws:lambda:"), "{arn}");
510            }
511        }
512    }
513}