Skip to main content

fakecloud_ec2/runtime/
netpolicy.rs

1//! Kubernetes NetworkPolicy enforcement for EC2 security groups (#1745 ph4).
2//!
3//! The Docker backend filters traffic with host nftables (phase 3). k8s Pods
4//! share a flat L3 network with no bridge to hook, so isolation there is
5//! expressed as **NetworkPolicy** objects (L3/L4 pod/IP selectors) and enforced
6//! by the cluster CNI — *if* the CNI enforces NetworkPolicy at all. Several
7//! common CNIs (notably kind's default `kindnet`) ignore NetworkPolicy, so this
8//! module always creates the (correct) policies and **degrades gracefully**:
9//! it detects the CNI, warns once when enforcement isn't guaranteed, and never
10//! blocks Pod creation on it.
11//!
12//! ## Pluggable CNI
13//!
14//! [`CniDriver`] abstracts "does this cluster enforce NetworkPolicy". Calico is
15//! the first known-enforcing driver; the enum is the extension point for
16//! Cilium/others. Detection is best-effort (presence of the CNI's API group /
17//! components); unknown CNIs degrade to "create but don't assume enforcement".
18//!
19//! The policy *translation* ([`build_policies`]) is pure and unit-tested; the
20//! apply path lives in the k8s client.
21
22use std::collections::BTreeMap;
23
24use k8s_openapi::api::networking::v1::{
25    IPBlock, NetworkPolicy, NetworkPolicyEgressRule, NetworkPolicyIngressRule, NetworkPolicyPeer,
26    NetworkPolicyPort, NetworkPolicySpec,
27};
28use k8s_openapi::apimachinery::pkg::apis::meta::v1::{LabelSelector, ObjectMeta};
29use k8s_openapi::apimachinery::pkg::util::intstr::IntOrString;
30
31use fakecloud_k8s::{labels, names};
32
33use super::firewall::{FirewallRule, InstanceRules};
34
35/// The `fakecloud-ec2` pod label whose value is the (DNS-safe) instance id —
36/// the same label the instance Pod carries, so a policy's `podSelector` targets
37/// exactly that instance.
38const INSTANCE_LABEL: &str = "fakecloud-ec2";
39
40/// Name of the NetworkPolicy backing one instance.
41pub fn policy_name(instance_id: &str) -> String {
42    format!("fakecloud-ec2-{}", names::label_safe(instance_id))
43}
44
45/// Build one NetworkPolicy per instance from the shared flattened rules. Each
46/// policy selects its instance Pod and restricts ingress/egress to the CIDRs +
47/// ports its security groups allow. `instance_label` is this fakecloud
48/// process's ownership label, stamped on every policy so the reaper can prune
49/// policies orphaned by a previous process.
50pub fn build_policies(
51    rules: &[InstanceRules],
52    namespace: &str,
53    instance_label: &str,
54) -> Vec<NetworkPolicy> {
55    rules
56        .iter()
57        .map(|r| build_one(r, namespace, instance_label))
58        .collect()
59}
60
61fn build_one(r: &InstanceRules, namespace: &str, instance_label: &str) -> NetworkPolicy {
62    let slug = names::label_safe(&r.instance_id);
63
64    let mut policy_labels = BTreeMap::new();
65    policy_labels.insert(
66        labels::MANAGED_BY.to_string(),
67        labels::MANAGED_BY_VALUE.to_string(),
68    );
69    policy_labels.insert(labels::INSTANCE.to_string(), instance_label.to_string());
70    policy_labels.insert(labels::SERVICE.to_string(), "ec2".to_string());
71
72    let mut selector_labels = BTreeMap::new();
73    selector_labels.insert(INSTANCE_LABEL.to_string(), slug);
74
75    NetworkPolicy {
76        metadata: ObjectMeta {
77            name: Some(policy_name(&r.instance_id)),
78            namespace: Some(namespace.to_string()),
79            labels: Some(policy_labels),
80            ..ObjectMeta::default()
81        },
82        spec: Some(NetworkPolicySpec {
83            pod_selector: LabelSelector {
84                match_labels: Some(selector_labels),
85                ..LabelSelector::default()
86            },
87            policy_types: Some(vec!["Ingress".to_string(), "Egress".to_string()]),
88            ingress: Some(r.ingress.iter().map(ingress_rule).collect()),
89            egress: Some(r.egress.iter().map(egress_rule).collect()),
90        }),
91    }
92}
93
94fn ingress_rule(r: &FirewallRule) -> NetworkPolicyIngressRule {
95    NetworkPolicyIngressRule {
96        from: peers(&r.cidr),
97        ports: ports(r),
98    }
99}
100
101fn egress_rule(r: &FirewallRule) -> NetworkPolicyEgressRule {
102    NetworkPolicyEgressRule {
103        to: peers(&r.cidr),
104        ports: ports(r),
105    }
106}
107
108/// `None` (any source/destination) for `0.0.0.0/0`/absent; otherwise a single
109/// `ipBlock` peer. Pod-to-pod allows (referenced security groups) arrive as the
110/// members' `/32`s, which match as ipBlocks.
111fn peers(cidr: &Option<String>) -> Option<Vec<NetworkPolicyPeer>> {
112    let c = cidr.as_deref()?;
113    if c == "0.0.0.0/0" || c.is_empty() {
114        return None;
115    }
116    Some(vec![NetworkPolicyPeer {
117        ip_block: Some(IPBlock {
118            cidr: c.to_string(),
119            except: None,
120        }),
121        ..NetworkPolicyPeer::default()
122    }])
123}
124
125/// Translate a rule's protocol/ports into NetworkPolicy ports. `None` (all
126/// ports) for all-protocols / portless / ICMP rules — standard NetworkPolicy
127/// only matches TCP/UDP/SCTP, so ICMP can't be narrowed and rides as "all".
128fn ports(r: &FirewallRule) -> Option<Vec<NetworkPolicyPort>> {
129    let proto = match r.protocol.as_str() {
130        "tcp" | "6" => "TCP",
131        "udp" | "17" => "UDP",
132        // all-protocols / icmp / unknown: cannot express as a NetworkPolicy
133        // port -> leave ports unset (all ports of all protocols).
134        _ => return None,
135    };
136    // Ports are parsed permissively as i64; a value outside the valid
137    // 0..=65535 TCP/UDP port range can't be a real port. Reject the whole rule
138    // rather than silently truncating via `as i32` into a wrong/negative port
139    // (bug-hunt 2026-06-18 finding 2.1).
140    let valid = |p: i64| (0..=65535).contains(&p);
141    if !valid(r.from_port) || !valid(r.to_port) {
142        return None;
143    }
144    let mut port = NetworkPolicyPort {
145        protocol: Some(proto.to_string()),
146        port: Some(IntOrString::Int(r.from_port as i32)),
147        end_port: None,
148    };
149    if r.to_port > r.from_port {
150        port.end_port = Some(r.to_port as i32);
151    }
152    Some(vec![port])
153}
154
155/// Container Network Interface plugins fakecloud knows how to reason about for
156/// NetworkPolicy enforcement. The pluggable seam: add a variant + detection to
157/// support a new enforcing CNI.
158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
159pub enum CniDriver {
160    /// Calico — enforces NetworkPolicy.
161    Calico,
162    /// Cilium — enforces NetworkPolicy.
163    Cilium,
164    /// A CNI we couldn't identify, or one known not to enforce NetworkPolicy
165    /// (e.g. kindnet). Policies are still created; enforcement isn't assumed.
166    Unknown,
167}
168
169impl CniDriver {
170    /// Whether this CNI is known to enforce NetworkPolicy.
171    pub fn enforces(self) -> bool {
172        matches!(self, CniDriver::Calico | CniDriver::Cilium)
173    }
174
175    /// Identify the CNI from the set of well-known component/daemonset names
176    /// present in the cluster (typically read from `kube-system`). Pure so the
177    /// detection logic is unit-testable without a cluster.
178    pub fn from_components<I, S>(component_names: I) -> Self
179    where
180        I: IntoIterator<Item = S>,
181        S: AsRef<str>,
182    {
183        let mut calico = false;
184        let mut cilium = false;
185        for name in component_names {
186            let n = name.as_ref();
187            if n.contains("calico") {
188                calico = true;
189            }
190            if n.contains("cilium") {
191                cilium = true;
192            }
193        }
194        if calico {
195            CniDriver::Calico
196        } else if cilium {
197            CniDriver::Cilium
198        } else {
199            CniDriver::Unknown
200        }
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    fn rules(
209        instance: &str,
210        ingress: Vec<FirewallRule>,
211        egress: Vec<FirewallRule>,
212    ) -> InstanceRules {
213        InstanceRules {
214            instance_id: instance.to_string(),
215            subnet_id: "subnet-1".to_string(),
216            private_ip: "172.30.0.2".to_string(),
217            ingress,
218            egress,
219        }
220    }
221
222    fn tcp(port: i64, cidr: Option<&str>) -> FirewallRule {
223        FirewallRule {
224            protocol: "tcp".into(),
225            from_port: port,
226            to_port: port,
227            cidr: cidr.map(str::to_string),
228        }
229    }
230
231    #[test]
232    fn policy_selects_instance_and_sets_both_directions() {
233        let r = rules("i-0ABC", vec![tcp(22, Some("10.0.0.0/8"))], vec![]);
234        let p = &build_policies(&[r], "fakecloud", "fakecloud-123")[0];
235        assert_eq!(p.metadata.name.as_deref(), Some("fakecloud-ec2-i-0abc"));
236        let spec = p.spec.as_ref().unwrap();
237        // selects the instance pod by its label-safe id
238        assert_eq!(
239            spec.pod_selector
240                .match_labels
241                .as_ref()
242                .unwrap()
243                .get("fakecloud-ec2")
244                .map(String::as_str),
245            Some("i-0abc")
246        );
247        assert_eq!(
248            spec.policy_types.as_ref().unwrap(),
249            &vec!["Ingress".to_string(), "Egress".to_string()]
250        );
251        // one ingress rule: from 10.0.0.0/8 tcp/22
252        let ing = &spec.ingress.as_ref().unwrap()[0];
253        let peer = &ing.from.as_ref().unwrap()[0];
254        assert_eq!(peer.ip_block.as_ref().unwrap().cidr, "10.0.0.0/8");
255        let port = &ing.ports.as_ref().unwrap()[0];
256        assert_eq!(port.protocol.as_deref(), Some("TCP"));
257        assert_eq!(port.port, Some(IntOrString::Int(22)));
258    }
259
260    #[test]
261    fn anywhere_all_protocols_rule_has_no_peer_or_ports() {
262        let all = FirewallRule {
263            protocol: "-1".into(),
264            from_port: -1,
265            to_port: -1,
266            cidr: Some("0.0.0.0/0".into()),
267        };
268        let r = rules("i-1", vec![all], vec![]);
269        let p = &build_policies(&[r], "fakecloud", "x")[0];
270        let ing = &p.spec.as_ref().unwrap().ingress.as_ref().unwrap()[0];
271        assert!(ing.from.is_none(), "0.0.0.0/0 -> any source");
272        assert!(ing.ports.is_none(), "all protocols -> any port");
273    }
274
275    #[test]
276    fn port_range_uses_end_port() {
277        let range = FirewallRule {
278            protocol: "tcp".into(),
279            from_port: 8000,
280            to_port: 8100,
281            cidr: None,
282        };
283        let r = rules("i-1", vec![range], vec![]);
284        let p = &build_policies(&[r], "fakecloud", "x")[0];
285        let port = &p.spec.as_ref().unwrap().ingress.as_ref().unwrap()[0]
286            .ports
287            .as_ref()
288            .unwrap()[0];
289        assert_eq!(port.port, Some(IntOrString::Int(8000)));
290        assert_eq!(port.end_port, Some(8100));
291    }
292
293    #[test]
294    fn out_of_range_port_is_rejected_not_truncated() {
295        // A port > 65535 (parsed permissively as i64) must not silently
296        // truncate via `as i32` into a wrong/negative port (finding 2.1).
297        let huge = FirewallRule {
298            protocol: "tcp".into(),
299            from_port: 70000,
300            to_port: 70000,
301            cidr: None,
302        };
303        let r = rules("i-1", vec![huge], vec![]);
304        let p = &build_policies(&[r], "fakecloud", "x")[0];
305        // No ports clause -> the rule isn't narrowed to a bogus port.
306        assert!(p.spec.as_ref().unwrap().ingress.as_ref().unwrap()[0]
307            .ports
308            .is_none());
309    }
310
311    #[test]
312    fn referenced_member_ip_becomes_ipblock() {
313        let r = rules("i-1", vec![tcp(80, Some("172.30.0.3/32"))], vec![]);
314        let p = &build_policies(&[r], "fakecloud", "x")[0];
315        let peer = &p.spec.as_ref().unwrap().ingress.as_ref().unwrap()[0]
316            .from
317            .as_ref()
318            .unwrap()[0];
319        assert_eq!(peer.ip_block.as_ref().unwrap().cidr, "172.30.0.3/32");
320    }
321
322    #[test]
323    fn cni_detection_and_enforcement() {
324        assert_eq!(
325            CniDriver::from_components(["calico-node", "coredns"]),
326            CniDriver::Calico
327        );
328        assert_eq!(
329            CniDriver::from_components(["cilium-abc"]),
330            CniDriver::Cilium
331        );
332        assert_eq!(
333            CniDriver::from_components(["kindnet-xyz", "kube-proxy"]),
334            CniDriver::Unknown
335        );
336        assert!(CniDriver::Calico.enforces());
337        assert!(CniDriver::Cilium.enforces());
338        assert!(!CniDriver::Unknown.enforces());
339    }
340}