Skip to main content

fakecloud_ec2/
defaults.rs

1//! Default-VPC bootstrap.
2//!
3//! Every real AWS account has, per region, a *default VPC* (`172.31.0.0/16`)
4//! with an attached internet gateway, a main route table that sends
5//! `0.0.0.0/0` at the gateway, one default subnet per Availability Zone, a
6//! `default` security group, and a default network ACL. Callers that never
7//! touch the VPC APIs (the common case — `RunInstances` with no `SubnetId`)
8//! still expect their instances to land in that default VPC and come back from
9//! `DescribeInstances` with a real `vpc-…` / `subnet-…`.
10//!
11//! fakecloud builds the same fixtures the first time an account's EC2 state is
12//! constructed ([`Ec2State::new`](crate::state::Ec2State::new)). The resource
13//! ids are **deterministic** functions of the account id and a role string
14//! (region-independent — see [`deterministic_id`]), so the throwaway empty
15//! states that the read paths synthesize as a "not found" fallback report the
16//! *same* ids as the persisted account state regardless of the caller's region.
17//!
18//! Per-VPC packet isolation (issue #1745 phase 2+) keys off this topology: a
19//! subnet whose route table has a `0.0.0.0/0 -> igw-…` route is public and gets
20//! a routable backing network; a subnet without one is private (`internal`).
21
22use crate::state::{
23    Ec2State, InternetGateway, NetworkAcl, NetworkAclAssoc, NetworkAclEntry, Route, RouteTable,
24    RouteTableAssociation, SecurityGroup, SecurityGroupRule, Subnet, Vpc,
25};
26
27/// CIDR of the default VPC, matching AWS.
28const DEFAULT_VPC_CIDR: &str = "172.31.0.0/16";
29
30/// The Availability Zone suffixes that receive a default subnet. AWS creates a
31/// default subnet in every AZ; three covers the cardinality every realistic
32/// test exercises (and keeps the deterministic CIDR layout simple).
33const DEFAULT_AZ_SUFFIXES: [&str; 3] = ["a", "b", "c"];
34
35/// Deterministic EC2 resource id: `<prefix>-<17 hex>` derived from the account
36/// and a per-resource `role`. Deliberately **region-independent**: a
37/// `MultiAccountState` partitions by account and pins a single region per
38/// server, but read handlers build a throwaway `Ec2State::new(account,
39/// req.region)` for accounts that don't exist yet — where `req.region` is the
40/// caller's SigV4 scope, not the server's region. Seeding the id on the region
41/// made those throwaway-derived ids disagree with the persisted account's ids
42/// whenever the client region differed from the server's, so a no-subnet launch
43/// stamped the instance with a subnet/VPC id that didn't exist in its own
44/// account (bug-hunt 2026-06-18 finding 1.1). Dropping region from the seed
45/// makes both paths agree; the AZ/CIDR cosmetics below still use the region.
46pub(crate) fn deterministic_id(prefix: &str, account: &str, role: &str) -> String {
47    let seed = format!("{account}/{role}");
48    let h1 = fnv1a64(seed.as_bytes());
49    let h2 = fnv1a64(format!("{seed}/salt").as_bytes());
50    // 16 hex from the first hash + 1 nibble from the second = the 17 hex chars
51    // a modern EC2 long-id carries.
52    format!("{prefix}-{:016x}{:01x}", h1, h2 & 0xf)
53}
54
55/// FNV-1a 64-bit. A tiny, dependency-free, stable hash — we only need
56/// determinism, not cryptographic strength.
57fn fnv1a64(bytes: &[u8]) -> u64 {
58    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
59    for b in bytes {
60        h ^= u64::from(*b);
61        h = h.wrapping_mul(0x0000_0100_0000_01b3);
62    }
63    h
64}
65
66/// AWS-style AZ-id prefix for a region: `us-east-1 -> use1`. Falls back to the
67/// region with dashes stripped for non-`a-b-N` shapes.
68fn az_id_prefix(region: &str) -> String {
69    let parts: Vec<&str> = region.split('-').collect();
70    if parts.len() == 3 && !parts[1].is_empty() {
71        format!(
72            "{}{}{}",
73            parts[0],
74            parts[1].chars().next().unwrap_or('x'),
75            parts[2]
76        )
77    } else {
78        region.replace('-', "")
79    }
80}
81
82/// The default VPC id for an account (also exposed so request handlers can
83/// resolve the implicit default without re-deriving the seed by hand).
84pub(crate) fn default_vpc_id(account: &str) -> String {
85    deterministic_id("vpc", account, "default-vpc")
86}
87
88/// The default security-group id for an account.
89pub(crate) fn default_security_group_id(account: &str) -> String {
90    deterministic_id("sg", account, "default-sg")
91}
92
93/// Populate `state` with the default VPC topology. Called once at state
94/// construction; idempotent in practice because the deterministic ids collide
95/// on re-entry.
96pub(crate) fn bootstrap_default_network(state: &mut Ec2State) {
97    let account = state.account_id.clone();
98    let region = if state.region.is_empty() {
99        "us-east-1".to_string()
100    } else {
101        state.region.clone()
102    };
103
104    let vpc_id = default_vpc_id(&account);
105    let igw_id = deterministic_id("igw", &account, "default-igw");
106    let rtb_id = deterministic_id("rtb", &account, "default-rtb");
107    let acl_id = deterministic_id("acl", &account, "default-acl");
108    let sg_id = default_security_group_id(&account);
109
110    // --- default VPC ---
111    state.vpcs.insert(
112        vpc_id.clone(),
113        Vpc {
114            vpc_id: vpc_id.clone(),
115            cidr_block: DEFAULT_VPC_CIDR.to_string(),
116            state: "available".to_string(),
117            dhcp_options_id: "default".to_string(),
118            instance_tenancy: "default".to_string(),
119            is_default: true,
120            enable_dns_support: true,
121            enable_dns_hostnames: true,
122            cidr_associations: Vec::new(),
123        },
124    );
125
126    // --- internet gateway, attached to the default VPC ---
127    state.internet_gateways.insert(
128        igw_id.clone(),
129        InternetGateway {
130            internet_gateway_id: igw_id.clone(),
131            attachments: vec![(vpc_id.clone(), "available".to_string())],
132        },
133    );
134
135    // --- default subnets, one per AZ ---
136    let az_prefix = az_id_prefix(&region);
137    let mut subnet_ids = Vec::new();
138    for (idx, suffix) in DEFAULT_AZ_SUFFIXES.iter().enumerate() {
139        let subnet_id = deterministic_id("subnet", &account, &format!("default-subnet-{suffix}"));
140        let az = format!("{region}{suffix}");
141        state.subnets.insert(
142            subnet_id.clone(),
143            Subnet {
144                subnet_id: subnet_id.clone(),
145                vpc_id: vpc_id.clone(),
146                // /20 blocks carved from 172.31.0.0/16: .0, .16, .32 …
147                cidr_block: format!("172.31.{}.0/20", idx * 16),
148                availability_zone: az,
149                availability_zone_id: format!("{az_prefix}-az{}", idx + 1),
150                state: "available".to_string(),
151                available_ip_address_count: 4091,
152                default_for_az: true,
153                // Default subnets auto-assign public IPs, matching AWS.
154                map_public_ip_on_launch: true,
155                assign_ipv6_address_on_creation: false,
156                map_customer_owned_ip_on_launch: false,
157                enable_dns64: false,
158                private_dns_hostname_type: "ip-name".to_string(),
159            },
160        );
161        subnet_ids.push(subnet_id);
162    }
163
164    // --- main route table: local + default route at the IGW (public) ---
165    let mut associations = vec![RouteTableAssociation {
166        association_id: deterministic_id("rtbassoc", &account, "default-rtb-main"),
167        route_table_id: rtb_id.clone(),
168        subnet_id: None,
169        gateway_id: None,
170        main: true,
171    }];
172    for sid in &subnet_ids {
173        associations.push(RouteTableAssociation {
174            association_id: deterministic_id("rtbassoc", &account, &format!("default-rtb-{sid}")),
175            route_table_id: rtb_id.clone(),
176            subnet_id: Some(sid.clone()),
177            gateway_id: None,
178            main: false,
179        });
180    }
181    state.route_tables.insert(
182        rtb_id.clone(),
183        RouteTable {
184            route_table_id: rtb_id.clone(),
185            vpc_id: vpc_id.clone(),
186            routes: vec![
187                Route {
188                    destination_cidr_block: Some(DEFAULT_VPC_CIDR.to_string()),
189                    gateway_id: Some("local".to_string()),
190                    ..Default::default()
191                },
192                Route {
193                    destination_cidr_block: Some("0.0.0.0/0".to_string()),
194                    gateway_id: Some(igw_id.clone()),
195                    ..Default::default()
196                },
197            ],
198            associations,
199        },
200    );
201
202    // --- default security group: allow all from self, allow all egress ---
203    state.security_groups.insert(
204        sg_id.clone(),
205        SecurityGroup {
206            group_id: sg_id.clone(),
207            group_name: "default".to_string(),
208            description: "default VPC security group".to_string(),
209            vpc_id: vpc_id.clone(),
210            rules: vec![
211                SecurityGroupRule {
212                    rule_id: deterministic_id("sgr", &account, "default-sg-ingress"),
213                    group_id: sg_id.clone(),
214                    is_egress: false,
215                    ip_protocol: "-1".to_string(),
216                    from_port: -1,
217                    to_port: -1,
218                    cidr_ipv4: None,
219                    cidr_ipv6: None,
220                    prefix_list_id: None,
221                    referenced_group_id: Some(sg_id.clone()),
222                    description: String::new(),
223                },
224                SecurityGroupRule {
225                    rule_id: deterministic_id("sgr", &account, "default-sg-egress"),
226                    group_id: sg_id.clone(),
227                    is_egress: true,
228                    ip_protocol: "-1".to_string(),
229                    from_port: -1,
230                    to_port: -1,
231                    cidr_ipv4: Some("0.0.0.0/0".to_string()),
232                    cidr_ipv6: None,
233                    prefix_list_id: None,
234                    referenced_group_id: None,
235                    description: String::new(),
236                },
237            ],
238        },
239    );
240
241    // --- default network ACL: allow-all, associated with every default subnet ---
242    let nacl_associations = subnet_ids
243        .iter()
244        .map(|sid| NetworkAclAssoc {
245            association_id: deterministic_id("aclassoc", &account, &format!("default-acl-{sid}")),
246            subnet_id: sid.clone(),
247        })
248        .collect();
249    state.network_acls.insert(
250        acl_id.clone(),
251        NetworkAcl {
252            network_acl_id: acl_id.clone(),
253            vpc_id: vpc_id.clone(),
254            is_default: true,
255            entries: vec![
256                allow_all_entry(false),
257                deny_all_entry(false),
258                allow_all_entry(true),
259                deny_all_entry(true),
260            ],
261            associations: nacl_associations,
262        },
263    );
264}
265
266fn allow_all_entry(egress: bool) -> NetworkAclEntry {
267    NetworkAclEntry {
268        rule_number: 100,
269        protocol: "-1".to_string(),
270        rule_action: "allow".to_string(),
271        egress,
272        cidr_block: Some("0.0.0.0/0".to_string()),
273        ipv6_cidr_block: None,
274        port_range: None,
275        icmp_type_code: None,
276    }
277}
278
279fn deny_all_entry(egress: bool) -> NetworkAclEntry {
280    NetworkAclEntry {
281        rule_number: 32767,
282        protocol: "-1".to_string(),
283        rule_action: "deny".to_string(),
284        egress,
285        cidr_block: Some("0.0.0.0/0".to_string()),
286        ipv6_cidr_block: None,
287        port_range: None,
288        icmp_type_code: None,
289    }
290}
291
292/// True when `subnet_id` resolves to a subnet whose route table carries a
293/// `0.0.0.0/0` route at an internet gateway — i.e. a *public* subnet. A subnet
294/// without such a route is private and (phase 2) backs onto an `internal`
295/// network. Subnets default to their VPC's main route table when not
296/// explicitly associated.
297// Drives per-subnet networking (chooses `internal` vs routable backing
298// networks) from `service/mod.rs` and `instance.rs`.
299pub(crate) fn subnet_is_public(state: &Ec2State, subnet_id: &str) -> bool {
300    let Some(subnet) = state.subnets.get(subnet_id) else {
301        return false;
302    };
303    // An explicit association wins; otherwise fall back to the VPC's main table.
304    let explicit = state.route_tables.values().find(|rt| {
305        rt.associations
306            .iter()
307            .any(|a| a.subnet_id.as_deref() == Some(subnet_id))
308    });
309    let main = state
310        .route_tables
311        .values()
312        .find(|rt| rt.vpc_id == subnet.vpc_id && rt.associations.iter().any(|a| a.main));
313    let rt = explicit.or(main);
314    rt.map(route_table_has_igw_default).unwrap_or(false)
315}
316
317fn route_table_has_igw_default(rt: &RouteTable) -> bool {
318    rt.routes.iter().any(|r| {
319        r.destination_cidr_block.as_deref() == Some("0.0.0.0/0")
320            && r.gateway_id
321                .as_deref()
322                .map(|g| g.starts_with("igw-"))
323                .unwrap_or(false)
324    })
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330    use crate::state::Ec2State;
331
332    #[test]
333    fn deterministic_id_is_stable_and_shaped() {
334        let a = deterministic_id("vpc", "123456789012", "default-vpc");
335        let b = deterministic_id("vpc", "123456789012", "default-vpc");
336        assert_eq!(a, b);
337        assert!(a.starts_with("vpc-"));
338        // 17 hex chars after the prefix, matching EC2 long-ids.
339        let hex = a.strip_prefix("vpc-").unwrap();
340        assert_eq!(hex.len(), 17);
341        assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
342    }
343
344    #[test]
345    fn deterministic_id_varies_by_account_and_role() {
346        let base = deterministic_id("vpc", "111111111111", "default-vpc");
347        assert_ne!(base, deterministic_id("vpc", "222222222222", "default-vpc"));
348        assert_ne!(base, deterministic_id("vpc", "111111111111", "default-igw"));
349    }
350
351    #[test]
352    fn deterministic_id_is_region_independent() {
353        // The id seed deliberately excludes region so read-path (req.region)
354        // and persisted (server region) states agree (finding 1.1). Region is
355        // not a parameter anymore — this documents the contract by asserting
356        // default_vpc_id depends only on the account.
357        assert_eq!(
358            default_vpc_id("111111111111"),
359            deterministic_id("vpc", "111111111111", "default-vpc")
360        );
361    }
362
363    #[test]
364    fn az_id_prefix_matches_aws_shape() {
365        assert_eq!(az_id_prefix("us-east-1"), "use1");
366        assert_eq!(az_id_prefix("eu-west-2"), "euw2");
367        assert_eq!(az_id_prefix("ap-southeast-1"), "aps1");
368    }
369
370    #[test]
371    fn bootstrap_creates_full_default_topology() {
372        let state = Ec2State::new("123456789012", "us-east-1");
373        // exactly one VPC, marked default
374        assert_eq!(state.vpcs.len(), 1);
375        let vpc = state.vpcs.values().next().unwrap();
376        assert!(vpc.is_default);
377        assert_eq!(vpc.cidr_block, "172.31.0.0/16");
378        // one subnet per AZ suffix, all default_for_az + public
379        assert_eq!(state.subnets.len(), DEFAULT_AZ_SUFFIXES.len());
380        assert!(state.subnets.values().all(|s| s.default_for_az));
381        assert!(state.subnets.values().all(|s| s.map_public_ip_on_launch));
382        // IGW attached to the default VPC
383        assert_eq!(state.internet_gateways.len(), 1);
384        let igw = state.internet_gateways.values().next().unwrap();
385        assert_eq!(igw.attachments[0].0, vpc.vpc_id);
386        // default SG + default NACL
387        let sg = state.security_groups.values().next().unwrap();
388        assert_eq!(sg.group_name, "default");
389        assert!(state.network_acls.values().next().unwrap().is_default);
390    }
391
392    #[test]
393    fn default_subnets_are_public() {
394        let state = Ec2State::new("123456789012", "us-east-1");
395        for sid in state.subnets.keys() {
396            assert!(
397                subnet_is_public(&state, sid),
398                "subnet {sid} should be public"
399            );
400        }
401    }
402
403    #[test]
404    fn ids_match_across_fresh_states() {
405        // The throwaway "empty" states read paths build must agree with the
406        // persisted account state on the default VPC id.
407        let a = Ec2State::new("123456789012", "us-east-1");
408        let b = Ec2State::new("123456789012", "us-east-1");
409        let a_vpc: Vec<_> = a.vpcs.keys().collect();
410        let b_vpc: Vec<_> = b.vpcs.keys().collect();
411        assert_eq!(a_vpc, b_vpc);
412        assert_eq!(a_vpc[0], &default_vpc_id("123456789012"));
413    }
414
415    #[test]
416    fn default_vpc_id_agrees_across_regions() {
417        // The crux of finding 1.1: a read-path empty built with the caller's
418        // region must derive the SAME default VPC id as the persisted account
419        // state built with the server's region.
420        let read_path = Ec2State::new("123456789012", "eu-west-1");
421        let persisted = Ec2State::new("123456789012", "us-east-1");
422        let read_vpc = read_path.vpcs.keys().next().unwrap();
423        let persisted_vpc = persisted.vpcs.keys().next().unwrap();
424        assert_eq!(read_vpc, persisted_vpc);
425        // subnets too (so a no-subnet launch resolves a subnet that exists in
426        // the persisted account regardless of the caller's region).
427        let read_subnets: std::collections::BTreeSet<_> = read_path.subnets.keys().collect();
428        let persisted_subnets: std::collections::BTreeSet<_> = persisted.subnets.keys().collect();
429        assert_eq!(read_subnets, persisted_subnets);
430    }
431}