diff --git a/.github/workflows/bpf-focused-mcast-test.yaml b/.github/workflows/bpf-focused-mcast-test.yaml new file mode 100644 index 0000000000000..c830800e75fec --- /dev/null +++ b/.github/workflows/bpf-focused-mcast-test.yaml @@ -0,0 +1,46 @@ +name: BPF Focused Multicast EGW Test + +on: + workflow_dispatch: + inputs: + bpf_test: + description: 'BPF test name (without .c extension)' + required: false + default: 'tc_egressgw_redirect_multicast' + +permissions: read-all + +jobs: + bpf_focused_test: + name: BPF Focused Multicast EGW Test + runs-on: ubuntu-24.04 + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: feat/multicast-egw-cegp-downstream + persist-credentials: false + fetch-depth: 0 + + - name: Git status and log + run: | + echo "=== git status ===" + git status --short --branch + echo "" + echo "=== git log ===" + git log --oneline -5 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + + - name: Install Python deps for Scapy packet generation + run: | + pip3 install --quiet scapy Jinja2 + + - name: Run focused BPF test + run: | + make run_bpf_tests \ + BPF_TEST="${{ github.event.inputs.bpf_test || 'tc_egressgw_redirect_multicast' }}" \ + BPF_TEST_VERBOSE=1 diff --git a/bpf/bpf_lxc.c b/bpf/bpf_lxc.c index 9617156f5f06d..01969e7a8be8f 100644 --- a/bpf/bpf_lxc.c +++ b/bpf/bpf_lxc.c @@ -34,6 +34,7 @@ #include "lib/l3.h" #include "lib/local_delivery.h" #include "lib/lxc.h" +#include "lib/l2_responder.h" #include "lib/lrp.h" #include "lib/identity.h" #include "lib/policy.h" @@ -59,6 +60,70 @@ #include "lib/vtep.h" #include "lib/subnet.h" +/* is_valid_l2_announced_ip{,v4} โ€” L2-announced VIP source-IP fallthrough. + * + * Pairs with is_valid_lxc_src_ip{,v4} (lib/lxc.h, ENABLE_SIP_VERIFICATION): + * the strict (saddr == endpoint_ip) check runs first; on miss, callers also + * probe the L2 responder map. Pods running the kernel AMT relay + * (drivers/net/amt.c) source packets with a Service ExternalIP/VIP per + * RFC 7450 ยง5.1.2. Mirror b5b3b4b908 (sock4_skip_xlate): trust addresses + * present in the L2 responder map for direct_routing_dev_ifindex on this + * node. + * + * Kept here (not in lib/lxc.h) so lib/l2_responder.h stays out of lxc.h's + * include chain โ€” l2_responder.h carries handle_l2_announcement, which + * references arp/icmp6/runtime-config symbols only available where arp.h + * + icmp6.h + the per-endpoint config are already pulled. bpf_lxc.c + * already pulls those; tests and other lxc.h consumers don't. + * + * Operational caveat: lookup only succeeds on the node currently holding + * the L2 announce lease for the VIP. Pods on non-holder nodes still see + * DROP_INVALID_SIP โ€” colocate with the lease holder via nodeSelector or + * use hostNetwork. + */ +#ifdef ENABLE_SIP_VERIFICATION +static __always_inline bool +is_valid_l2_announced_ipv4(const struct iphdr *ip4 __maybe_unused) +{ +#ifdef ENABLE_IPV4 + struct l2_responder_v4_key l2key = { + .ip4 = ip4->saddr, + .ifindex = CONFIG(direct_routing_dev_ifindex), + }; + + return map_lookup_elem(&cilium_l2_responder_v4, &l2key) != NULL; +#else + return false; +#endif +} + +static __always_inline bool +is_valid_l2_announced_ipv6(struct ipv6hdr *ip6 __maybe_unused) +{ +#ifdef ENABLE_IPV6 + struct l2_responder_v6_key l2key = {}; + + l2key.ifindex = CONFIG(direct_routing_dev_ifindex); + ipv6_addr_copy(&l2key.ip6, (union v6addr *)&ip6->saddr); + return map_lookup_elem(&cilium_l2_responder_v6, &l2key) != NULL; +#else + return false; +#endif +} +#else /* !ENABLE_SIP_VERIFICATION */ +static __always_inline bool +is_valid_l2_announced_ipv4(const struct iphdr *ip4 __maybe_unused) +{ + return true; +} + +static __always_inline bool +is_valid_l2_announced_ipv6(struct ipv6hdr *ip6 __maybe_unused) +{ + return true; +} +#endif /* ENABLE_SIP_VERIFICATION */ + #if defined(ENABLE_HOST_FIREWALL) && !defined(ENABLE_ROUTING) static __always_inline int lxc_deliver_to_host(struct __ctx_buff *ctx, __u32 src_sec_identity) @@ -1005,7 +1070,9 @@ static __always_inline int __tail_handle_ipv6(struct __ctx_buff *ctx, #ifdef ENABLE_L7_LB from_l7lb = ctx_load_meta(ctx, CB_FROM_HOST) == FROM_HOST_L7_LB; #endif - if (!from_l7lb && unlikely(!is_valid_lxc_src_ip(ip6))) + if (!from_l7lb && + unlikely(!is_valid_lxc_src_ip(ip6) && + !is_valid_l2_announced_ipv6(ip6))) return DROP_INVALID_SIP; #ifdef ENABLE_PER_PACKET_LB @@ -1512,6 +1579,16 @@ static __always_inline int handle_ipv4_from_lxc(struct __ctx_buff *ctx, __u32 *d return DROP_UNKNOWN_CT; } +#ifdef ENABLE_EGRESS_GATEWAY_COMMON + if (egw_ipv4_is_mcast(ip4->daddr)) { + ret = egress_gw_handle_request(ctx, bpf_htons(ETH_P_IP), + SECLABEL_IPV4, *dst_sec_identity, + &trace); + if (ret != CTX_ACT_OK) + return ret; + } +#endif + return ipv4_forward_to_destination(ctx, ip4, tuple, *dst_sec_identity, ct_state, ct_status, info, skip_tunnel, hairpin_flow, from_l7lb, proxy_port, @@ -1563,7 +1640,9 @@ static __always_inline int __tail_handle_ipv4(struct __ctx_buff *ctx, #ifdef ENABLE_L7_LB from_l7lb = ctx_load_meta(ctx, CB_FROM_HOST) == FROM_HOST_L7_LB; #endif - if (!from_l7lb && unlikely(!is_valid_lxc_src_ipv4(ip4))) + if (!from_l7lb && + unlikely(!is_valid_lxc_src_ipv4(ip4) && + !is_valid_l2_announced_ipv4(ip4))) return DROP_INVALID_SIP; #ifdef ENABLE_MULTICAST @@ -1576,7 +1655,25 @@ static __always_inline int __tail_handle_ipv4(struct __ctx_buff *ctx, } if (IN_MULTICAST(bpf_ntohl(ip4->daddr))) { - if (mcast_lookup_subscriber_map(&ip4->daddr)) + /* Origin-node multicast EGW classification (BLO-8007). + * + * A pod-originated multicast destination that matches a multicast + * EgressGatewayPolicy must leave via the egress gateway, so it must + * NOT be short-circuited into the cluster-internal subscriber-map + * fast path here - that is the host/local emission that the egress + * classification has to win against. We therefore consult the EGW + * policy map first and only fall through to local delivery when the + * destination is *not* a multicast CEGP hit. On a hit we let the + * packet continue down the normal egress path (per-packet LB -> CT + * egress -> handle_ipv4_from_lxc), where the existing, already + * multicast-aware EGW redirect/SNAT machinery takes over. + * + * Non-matching multicast (no policy, excluded CIDR, or no gateway) + * keeps the pre-existing local multicast behavior, and unicast is + * untouched (egw_mcast_request_is_egress() is multicast-only). + */ + if (!egw_mcast_request_is_egress(ip4->saddr, ip4->daddr) && + mcast_lookup_subscriber_map(&ip4->daddr)) return tail_call_internal(ctx, CILIUM_CALL_MULTICAST_EP_DELIVERY, ext_err); diff --git a/bpf/bpf_overlay.c b/bpf/bpf_overlay.c index ab0a4a6fe8576..b1de2b5431c39 100644 --- a/bpf/bpf_overlay.c +++ b/bpf/bpf_overlay.c @@ -385,17 +385,38 @@ static __always_inline int handle_ipv4(struct __ctx_buff *ctx, { __u32 egress_ifindex = 0; __be32 snat_addr, daddr; + __be32 saddr; + __u8 nexthdr; + int l4_off; + fraginfo_t mcast_fraginfo; + saddr = ip4->saddr; daddr = ip4->daddr; + nexthdr = ip4->protocol; + l4_off = ETH_HLEN + ipv4_hdrlen(ip4); + mcast_fraginfo = ipfrag_encode_ipv4(ip4); + if (egress_gw_snat_needed_hook(ip4->saddr, daddr, &snat_addr, &egress_ifindex)) { if (snat_addr == EGRESS_GATEWAY_NO_EGRESS_IP) return DROP_NO_EGRESS_IP; + if (egw_ipv4_is_mcast(daddr) && !egress_ifindex) + return DROP_NO_FIB; ret = ipv4_l3(ctx, ETH_HLEN, NULL, NULL, ip4); if (unlikely(ret != CTX_ACT_OK)) return ret; + if (egw_ipv4_is_mcast(daddr)) { + ret = snat_v4_rewrite_headers(ctx, nexthdr, ETH_HLEN, + ipfrag_has_l4_header(mcast_fraginfo), + l4_off, saddr, snat_addr, + offsetof(struct iphdr, saddr), 0, + 0, 0, 0); + if (unlikely(ret < 0)) + return ret; + } + set_identity_mark(ctx, *identity, MARK_MAGIC_EGW_DONE); /* to-netdev@bpf_host handles SNAT, so no need to do it here. */ diff --git a/bpf/lib/egress_gateway.h b/bpf/lib/egress_gateway.h index cabffc58d0c81..724ce4e1949e4 100644 --- a/bpf/lib/egress_gateway.h +++ b/bpf/lib/egress_gateway.h @@ -27,6 +27,8 @@ struct egress_gw_policy_key { struct egress_gw_policy_entry { __be32 egress_ip; __be32 gateway_ip; + __u32 egress_ifindex; + __u32 reserved; }; struct egress_gw_policy_key6 { @@ -165,6 +167,51 @@ egress_gw_request_needs_redirect(struct ipv4_ct_tuple *rtuple __maybe_unused, #endif /* ENABLE_EGRESS_GATEWAY */ } +/* egw_mcast_request_is_egress - origin-node classification for pod-originated + * IPv4 multicast (BLO-8007, downstream-only). + * + * Returns true when @daddr is a multicast destination that matches a multicast + * EgressGatewayPolicy with a real gateway, i.e. the packet must leave the node + * via the egress gateway instead of the cluster-internal multicast fast path. + * + * Returns false for non-multicast destinations, for multicast with no matching + * policy (or an excluded-CIDR / no-gateway policy), and when EGW is compiled + * out. In every false case the caller keeps the pre-existing behavior, so this + * helper can only ever *divert* a multicast destination that an operator has + * explicitly placed under a policy - it never changes unicast handling. + * + * Downstream divergence from upstream Cilium: upstream never consults the EGW + * policy map for multicast destinations because the from-container path short- + * circuits IN_MULTICAST traffic to local delivery before any EGW lookup can + * run. We deliberately add this multicast-only lookup on the origin node so + * multicast CEGP hits are classified before local emission. The unicast + * gateway_ip sentinels (NO_GATEWAY / EXCLUDED_CIDR) are honored unchanged. + */ +static __always_inline bool +egw_mcast_request_is_egress(__be32 saddr __maybe_unused, __be32 daddr __maybe_unused) +{ +#if defined(ENABLE_EGRESS_GATEWAY) + const struct egress_gw_policy_entry *egress_gw_policy; + + if (!egw_ipv4_is_mcast(daddr)) + return false; + + egress_gw_policy = lookup_ip4_egress_gw_policy(saddr, daddr); + if (!egress_gw_policy) + return false; + + switch (egress_gw_policy->gateway_ip) { + case EGRESS_GATEWAY_NO_GATEWAY: + case EGRESS_GATEWAY_EXCLUDED_CIDR: + return false; + } + + return true; +#else + return false; +#endif /* ENABLE_EGRESS_GATEWAY */ +} + static __always_inline bool egress_gw_snat_needed(__be32 saddr __maybe_unused, __be32 daddr __maybe_unused, @@ -183,9 +230,7 @@ bool egress_gw_snat_needed(__be32 saddr __maybe_unused, return false; *snat_addr = egress_gw_policy->egress_ip; -#ifdef EGRESS_IFINDEX - *egress_ifindex = EGRESS_IFINDEX; -#endif + *egress_ifindex = egress_gw_policy->egress_ifindex; return true; #else diff --git a/bpf/tests/lib/egressgw_policy.h b/bpf/tests/lib/egressgw_policy.h index 1b9931c645b35..3be0f4dbbf150 100644 --- a/bpf/tests/lib/egressgw_policy.h +++ b/bpf/tests/lib/egressgw_policy.h @@ -14,6 +14,9 @@ static __always_inline void add_egressgw_policy_entry(__be32 saddr, __be32 daddr struct egress_gw_policy_entry in_val = { .egress_ip = egress_ip, .gateway_ip = gateway_ip, +#ifdef EGRESS_IFINDEX + .egress_ifindex = EGRESS_IFINDEX, +#endif }; map_update_elem(&cilium_egress_gw_policy_v4, &in_key, &in_val, 0); diff --git a/bpf/tests/tc_egressgw_origin_node_multicast.c b/bpf/tests/tc_egressgw_origin_node_multicast.c new file mode 100644 index 0000000000000..a5a6e352343a1 --- /dev/null +++ b/bpf/tests/tc_egressgw_origin_node_multicast.c @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: (GPL-2.0-only OR BSD-2-Clause) +/* Copyright Authors of Cilium */ + +/* Origin-node multicast EGW classification (BLO-8007). + * + * The from-container datapath (bpf_lxc.c::__tail_handle_ipv4) short-circuits + * IN_MULTICAST destinations into the cluster-internal subscriber-map fast path + * BEFORE any egress-gateway lookup. For pod-originated multicast that an + * operator has placed under a multicast CiliumEgressGatewayPolicy, that local + * emission must NOT win - the packet has to leave via the egress gateway. + * + * The ordering fix consults egw_mcast_request_is_egress() before the subscriber + * map. This unit test exercises that classifier directly against the real + * cilium_egress_gw_policy_v4 map so the contract is pinned independently of the + * full tail-call datapath: + * + * 1. multicast daddr + matching policy w/ real gateway -> egress (CEGP wins) + * 2. multicast daddr + no policy -> local (fanout kept) + * 3. multicast daddr + excluded-CIDR / no-gateway -> local (pre-existing) + * 4. unicast daddr + matching policy -> local (unicast CEGP + * semantics untouched) + * + * Cases 2-4 returning "local" mean the caller keeps the pre-existing behavior, + * which is the regression guard for AC "non-matching multicast and all unicast + * CEGP traffic follow the pre-existing paths". + * + * The from-overlay (gateway-node) redirect is covered separately by + * tc_egressgw_redirect_multicast.c. + */ + +#include +#include "common.h" +#include "pktgen.h" + +#define ENABLE_IPV4 +#define ENABLE_IPV6 +#define ENABLE_NODEPORT +#define ENABLE_EGRESS_GATEWAY +#define ENABLE_MASQUERADE_IPV4 1 +#define ENABLE_MASQUERADE_IPV6 1 +#define ENCAP_IFINDEX 42 +#define IFACE_IFINDEX 44 + +/* IPv4 datapath reads the egress ifindex from this #define rather than the + * policy struct (the IPv4 policy entry has no egress_ifindex field). + */ +#define EGRESS_IFINDEX IFACE_IFINDEX + +#define fib_lookup mock_fib_lookup +static __always_inline __maybe_unused long +mock_fib_lookup(void *ctx __maybe_unused, struct bpf_fib_lookup *params __maybe_unused, + int plen __maybe_unused, __u32 flags __maybe_unused); + +#define skb_get_tunnel_key mock_skb_get_tunnel_key +static int mock_skb_get_tunnel_key(__maybe_unused struct __sk_buff *skb, + struct bpf_tunnel_key *to, + __maybe_unused __u32 size, + __maybe_unused __u32 flags) +{ + to->remote_ipv4 = v4_node_one; + /* 0xfffff is the default SECLABEL */ + to->tunnel_id = 0xfffff; + return 0; +} + +#include "lib/bpf_overlay.h" + +#include "lib/egressgw.h" +#include "lib/ipcache.h" + +static __always_inline __maybe_unused long +mock_fib_lookup(void *ctx __maybe_unused, struct bpf_fib_lookup *params __maybe_unused, + int plen __maybe_unused, __u32 flags __maybe_unused) +{ + params->ifindex = IFACE_IFINDEX; + return 0; +} + +#define MCAST_GROUP IPV4(232, 1, 1, 50) +#define MCAST_GROUP_PFX IPV4(232, 0, 0, 0) +#define MCAST_CIDR 4 +/* A multicast group that is deliberately NOT placed under any policy. */ +#define MCAST_GROUP_UNPOLICIED IPV4(233, 7, 7, 7) + +CHECK("tc", "tc_egressgw_origin_node_mcast_classify") +int egressgw_origin_node_mcast_classify(const struct __ctx_buff *ctx __maybe_unused) +{ + test_init(); + + /* 1. multicast destination under a real-gateway policy must be + * classified as egress so the from-container path skips local fanout. + */ + TEST("mcast_policy_hit_is_egress", { + add_egressgw_policy_entry(CLIENT_IP, MCAST_GROUP_PFX, MCAST_CIDR, + GATEWAY_NODE_IP, EGRESS_IP); + + assert(egw_mcast_request_is_egress(CLIENT_IP, MCAST_GROUP)); + + del_egressgw_policy_entry(CLIENT_IP, MCAST_GROUP_PFX, MCAST_CIDR); + }); + + /* 2. multicast destination with no matching policy keeps local + * (cluster-internal) delivery - classifier returns false. + */ + TEST("mcast_no_policy_is_local", { + assert(!egw_mcast_request_is_egress(CLIENT_IP, MCAST_GROUP_UNPOLICIED)); + }); + + /* 3a. an excluded-CIDR policy is not an egress hit. */ + TEST("mcast_excluded_cidr_is_local", { + add_egressgw_policy_entry(CLIENT_IP, MCAST_GROUP_PFX, MCAST_CIDR, + EGRESS_GATEWAY_EXCLUDED_CIDR, 0); + + assert(!egw_mcast_request_is_egress(CLIENT_IP, MCAST_GROUP)); + + del_egressgw_policy_entry(CLIENT_IP, MCAST_GROUP_PFX, MCAST_CIDR); + }); + + /* 3b. a no-gateway policy is not an egress hit either. */ + TEST("mcast_no_gateway_is_local", { + add_egressgw_policy_entry(CLIENT_IP, MCAST_GROUP_PFX, MCAST_CIDR, + EGRESS_GATEWAY_NO_GATEWAY, 0); + + assert(!egw_mcast_request_is_egress(CLIENT_IP, MCAST_GROUP)); + + del_egressgw_policy_entry(CLIENT_IP, MCAST_GROUP_PFX, MCAST_CIDR); + }); + + /* 4. a unicast destination, even with a matching real-gateway policy, + * is never reclassified by this helper: the multicast-only guard short- + * circuits before the lookup, so unicast CEGP semantics are untouched. + */ + TEST("unicast_policy_is_not_mcast_egress", { + add_egressgw_policy_entry(CLIENT_IP, EXTERNAL_SVC_IP & 0xffffff, 24, + GATEWAY_NODE_IP, EGRESS_IP); + + assert(!egw_mcast_request_is_egress(CLIENT_IP, EXTERNAL_SVC_IP)); + + del_egressgw_policy_entry(CLIENT_IP, EXTERNAL_SVC_IP & 0xffffff, 24); + }); + + test_finish(); +} + +BPF_LICENSE("Dual BSD/GPL"); diff --git a/bpf/tests/tc_egressgw_redirect_multicast.c b/bpf/tests/tc_egressgw_redirect_multicast.c index ccb8ee0ffb786..8438132b15321 100644 --- a/bpf/tests/tc_egressgw_redirect_multicast.c +++ b/bpf/tests/tc_egressgw_redirect_multicast.c @@ -22,11 +22,16 @@ #define ENCAP_IFINDEX 42 #define IFACE_IFINDEX 44 -/* IPv4 datapath reads the egress ifindex from this #define rather than the - * policy struct (the IPv4 policy entry has no egress_ifindex field). +/* The test helper writes this into the IPv4 policy entry so multicast redirect + * can bypass FIB lookup and redirect directly to the egress device. */ #define EGRESS_IFINDEX IFACE_IFINDEX +#define ctx_redirect mock_ctx_redirect +static __always_inline __maybe_unused int +mock_ctx_redirect(const struct __sk_buff *ctx __maybe_unused, + int ifindex __maybe_unused, __u32 flags __maybe_unused); + #define fib_lookup mock_fib_lookup static __always_inline __maybe_unused long mock_fib_lookup(void *ctx __maybe_unused, struct bpf_fib_lookup *params __maybe_unused, @@ -49,6 +54,16 @@ static int mock_skb_get_tunnel_key(__maybe_unused struct __sk_buff *skb, #include "lib/egressgw.h" #include "lib/ipcache.h" +static __always_inline __maybe_unused int +mock_ctx_redirect(const struct __sk_buff *ctx __maybe_unused, + int ifindex __maybe_unused, __u32 flags __maybe_unused) +{ + if (ifindex == IFACE_IFINDEX && flags == 0) + return TC_ACT_REDIRECT; + + return CTX_ACT_OK; +} + static __always_inline __maybe_unused long mock_fib_lookup(void *ctx __maybe_unused, struct bpf_fib_lookup *params __maybe_unused, int plen __maybe_unused, __u32 flags __maybe_unused) @@ -64,6 +79,53 @@ mock_fib_lookup(void *ctx __maybe_unused, struct bpf_fib_lookup *params __maybe_ #define MCAST_GROUP_V6 \ { .addr = { 0xff, 0x0e, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x12, 0x34 } } +static __always_inline int multicast_redirect_v4_check(const struct __ctx_buff *ctx, + __u32 status_code) +{ + struct ipv4_ct_tuple tuple = {}; + void *data, *data_end; + struct udphdr *l4; + struct iphdr *l3; + + test_init(); + + data = (void *)(long)ctx_data(ctx); + data_end = (void *)(long)ctx->data_end; + + if (data + sizeof(__u32) > data_end) + test_fatal("status code out of bounds"); + + assert(*(__u32 *)data == status_code); + + l3 = data + sizeof(__u32) + sizeof(struct ethhdr); + if ((void *)l3 + sizeof(*l3) > data_end) + test_fatal("l3 out of bounds"); + + l4 = (void *)l3 + sizeof(*l3); + if ((void *)l4 + sizeof(*l4) > data_end) + test_fatal("l4 out of bounds"); + + if (l3->saddr != EGRESS_IP) + test_fatal("multicast source was not rewritten to egress IP"); + if (l3->daddr != MCAST_GROUP) + test_fatal("multicast destination changed"); + if (l4->source != MCAST_PUB_PORT || l4->dest != MCAST_PORT) + test_fatal("multicast UDP ports changed"); + if (csum_fold(csum_diff(NULL, 0, l3, sizeof(*l3), 0)) != 0) + test_fatal("IPv4 checksum invalid after multicast source rewrite"); + + tuple.nexthdr = IPPROTO_UDP; + tuple.saddr = CLIENT_IP; + tuple.daddr = MCAST_GROUP; + tuple.sport = MCAST_PUB_PORT; + tuple.dport = MCAST_PORT; + __ipv4_ct_tuple_reverse(&tuple); + if (map_lookup_elem(get_ct_map4(&tuple), &tuple)) + test_fatal("multicast CEGP packet allocated CT state"); + + test_finish(); +} + /* IPv4: a multicast packet matching a CEGP policy installed with * destinationCIDRs containing 232.0.0.0/4 produces TC_ACT_REDIRECT. */ @@ -103,9 +165,7 @@ int multicast_redirect_setup(struct __ctx_buff *ctx) CHECK("tc", "tc_egressgw_redirect_multicast") int multicast_redirect_check(const struct __ctx_buff *ctx) { - int ret = egressgw_status_check(ctx, (struct egressgw_test_ctx) { - .status_code = TC_ACT_REDIRECT, - }); + int ret = multicast_redirect_v4_check(ctx, TC_ACT_REDIRECT); del_egressgw_policy_entry(CLIENT_IP, IPV4(232, 0, 0, 0), 4); diff --git a/cilium-dbg/cmd/bpf_egress_list.go b/cilium-dbg/cmd/bpf_egress_list.go index 29ab402c880d1..5b146bac182e1 100644 --- a/cilium-dbg/cmd/bpf_egress_list.go +++ b/cilium-dbg/cmd/bpf_egress_list.go @@ -24,10 +24,13 @@ const ( ) type egressPolicy struct { - SourceIP string - DestCIDR string - EgressIP string - GatewayIP string + SourceIP string + DestCIDR string + EgressIP string + GatewayIP string + Traffic string + CTSemantics string + EgressIfindex uint32 } var bpfEgressListCmd = &cobra.Command{ @@ -45,11 +48,15 @@ var bpfEgressListCmd = &cobra.Command{ if err == nil { ipv4MapExists = true parse4 := func(key *egressmap.EgressPolicyKey4, val *egressmap.EgressPolicyVal4) { + traffic, ctSemantics := egressPolicySemantics(key.GetDestCIDR()) bpfEgressList = append(bpfEgressList, egressPolicy{ - SourceIP: key.GetSourceIP().String(), - DestCIDR: key.GetDestCIDR().String(), - EgressIP: val.GetEgressAddr().String(), - GatewayIP: mapGatewayIP(val.GetGatewayAddr()), + SourceIP: key.GetSourceIP().String(), + DestCIDR: key.GetDestCIDR().String(), + EgressIP: val.GetEgressAddr().String(), + GatewayIP: mapGatewayIP(val.GetGatewayAddr()), + Traffic: traffic, + CTSemantics: ctSemantics, + EgressIfindex: val.EgressIfindex, }) } @@ -64,11 +71,15 @@ var bpfEgressListCmd = &cobra.Command{ if err == nil { ipv6MapExists = true parse6 := func(key *egressmap.EgressPolicyKey6, val *egressmap.EgressPolicyVal6) { + traffic, ctSemantics := egressPolicySemantics(key.GetDestCIDR()) bpfEgressList = append(bpfEgressList, egressPolicy{ - SourceIP: key.GetSourceIP().String(), - DestCIDR: key.GetDestCIDR().String(), - EgressIP: val.GetEgressAddr().String(), - GatewayIP: mapGatewayIP(val.GetGatewayAddr()), + SourceIP: key.GetSourceIP().String(), + DestCIDR: key.GetDestCIDR().String(), + EgressIP: val.GetEgressAddr().String(), + GatewayIP: mapGatewayIP(val.GetGatewayAddr()), + Traffic: traffic, + CTSemantics: ctSemantics, + EgressIfindex: val.EgressIfindex, }) } @@ -111,12 +122,19 @@ func mapGatewayIP(ip netip.Addr) string { return ip.String() } +func egressPolicySemantics(destCIDR netip.Prefix) (traffic, ctSemantics string) { + if destCIDR.Addr().IsMulticast() { + return "multicast", "ct-bypass" + } + return "unicast", "ct-tracked" +} + func printEgressList(egressList []egressPolicy) { w := tabwriter.NewWriter(os.Stdout, 5, 0, 3, ' ', 0) - fmt.Fprintln(w, "Source IP\tDestination CIDR\tEgress IP\tGateway IP") + fmt.Fprintln(w, "Source IP\tDestination CIDR\tEgress IP\tGateway IP\tTraffic\tCT Semantics\tEgress Ifindex") for _, ep := range egressList { - fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", ep.SourceIP, ep.DestCIDR, ep.EgressIP, ep.GatewayIP) + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%d\n", ep.SourceIP, ep.DestCIDR, ep.EgressIP, ep.GatewayIP, ep.Traffic, ep.CTSemantics, ep.EgressIfindex) } w.Flush() diff --git a/cilium-dbg/cmd/bpf_egress_list_test.go b/cilium-dbg/cmd/bpf_egress_list_test.go new file mode 100644 index 0000000000000..0e8bed9b82231 --- /dev/null +++ b/cilium-dbg/cmd/bpf_egress_list_test.go @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Authors of Cilium + +package cmd + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestEgressPolicySemantics(t *testing.T) { + tests := []struct { + name string + destCIDR string + wantTraffic string + wantCTSemantics string + }{ + { + name: "unicast IPv4", + destCIDR: "10.0.0.0/24", + wantTraffic: "unicast", + wantCTSemantics: "ct-tracked", + }, + { + name: "multicast IPv4", + destCIDR: "232.0.0.0/4", + wantTraffic: "multicast", + wantCTSemantics: "ct-bypass", + }, + { + name: "unicast IPv6", + destCIDR: "2001:db8::/64", + wantTraffic: "unicast", + wantCTSemantics: "ct-tracked", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + traffic, ctSemantics := egressPolicySemantics(netip.MustParsePrefix(tt.destCIDR)) + require.Equal(t, tt.wantTraffic, traffic) + require.Equal(t, tt.wantCTSemantics, ctSemantics) + }) + } +} diff --git a/pkg/egressgateway/manager.go b/pkg/egressgateway/manager.go index 7991fe1cdd4b7..58a3c73e8a284 100644 --- a/pkg/egressgateway/manager.go +++ b/pkg/egressgateway/manager.go @@ -657,18 +657,23 @@ func (manager *Manager) updateEgressRules4() { if excludedCIDR { gatewayIP = ExcludedCIDRIPv4 } + egressIfindex := uint32(0) + if dstCIDR.Addr().IsMulticast() { + egressIfindex = gwc.egressIfindex + } - if policyPresent && policyVal.Match(gwc.egressIP4, gatewayIP) { + if policyPresent && policyVal.Match(gwc.egressIP4, gatewayIP, egressIfindex) { return } - if err := manager.policyMap4.Update(endpointIP, dstCIDR, gwc.egressIP4, gatewayIP); err != nil { + if err := manager.policyMap4.Update(endpointIP, dstCIDR, gwc.egressIP4, gatewayIP, egressIfindex); err != nil { manager.logger.Error( "Error applying IPv4 egress gateway policy", logfields.Error, err, logfields.SourceIP, endpointIP, logfields.DestinationCIDR, dstCIDR, logfields.EgressIP, gwc.egressIP4, + logfields.LinkIndex, egressIfindex, logfields.GatewayIP, gatewayIP, ) } else { @@ -676,6 +681,7 @@ func (manager *Manager) updateEgressRules4() { logfields.SourceIP, endpointIP, logfields.DestinationCIDR, dstCIDR, logfields.EgressIP, gwc.egressIP4, + logfields.LinkIndex, egressIfindex, logfields.GatewayIP, gatewayIP, ) } diff --git a/pkg/egressgateway/manager_privileged_test.go b/pkg/egressgateway/manager_privileged_test.go index 63e62ea585bf6..68325dc75d642 100644 --- a/pkg/egressgateway/manager_privileged_test.go +++ b/pkg/egressgateway/manager_privileged_test.go @@ -55,6 +55,7 @@ const ( destCIDR = "1.1.1.0/24" destCIDR3 = "1.1.3.0/24" + mcastDestCIDR = "232.0.0.0/4" allZeroDestCIDR = "0.0.0.0/0" excludedCIDR1 = "1.1.1.22/32" excludedCIDR2 = "1.1.1.240/30" @@ -745,6 +746,42 @@ func TestPrivilegedEndpointDataStore(t *testing.T) { }) } +func TestPrivilegedMulticastEgressGatewayManager(t *testing.T) { + k := setupEgressGatewayTestSuite(t) + createTestInterface(t, k.sysctl, testInterface1, []string{egressCIDR1}) + + link, err := safenetlink.LinkByName(testInterface1) + require.NoError(t, err) + ifIndex1 := uint32(link.Attrs().Index) + + policyMap4 := k.manager.policyMap4 + egressGatewayManager := k.manager + + k.policies.sync(t) + k.nodes.sync(t) + k.endpoints.sync(t) + + node1 := newCiliumNode(node1, node1IP, nodeGroup1Labels) + addNodeAndReconcile(t, k, egressGatewayManager, &node1) + + addPolicyAndReconcile(t, egressGatewayManager, k.policies, &policyParams{ + name: "policy-mcast", + endpointLabels: ep1Labels, + destinationCIDRs: []string{mcastDestCIDR}, + policyGwParams: []policyGatewayParams{{ + nodeLabels: nodeGroup1Labels, + iface: testInterface1, + }}, + }) + + ep1, _ := newEndpointAndIdentity("ep-mcast", ep1IP, "", ep1Labels) + addEndpointAndReconcile(t, egressGatewayManager, k.endpoints, &ep1) + + assertEgressRules4(t, policyMap4, []egressRule{ + {ep1IP, mcastDestCIDR, egressIP1, node1IP, ifIndex1}, + }) +} + func TestPrivilegedMultigatewayPolicy(t *testing.T) { k := setupEgressGatewayTestSuite(t) createTestInterface(t, k.sysctl, testInterface1, []string{egressCIDR1, egressCIDR1v6}) @@ -1084,7 +1121,7 @@ func assertEgressRules4(t *testing.T, policyMap *egressmap.PolicyMap4, rules []e func tryAssertEgressRules4(policyMap *egressmap.PolicyMap4, rules []egressRule) error { parsedRules := []parsedEgressRule{} for _, r := range rules { - parsedRules = append(parsedRules, parseEgressRule(r.sourceIP, r.destCIDR, r.egressIP, r.gatewayIP, 0)) + parsedRules = append(parsedRules, parseEgressRule(r.sourceIP, r.destCIDR, r.egressIP, r.gatewayIP, r.egressIfindex)) } for _, r := range parsedRules { @@ -1100,13 +1137,17 @@ func tryAssertEgressRules4(policyMap *egressmap.PolicyMap4, rules []egressRule) if policyVal.GetGatewayAddr() != r.gatewayIP { return fmt.Errorf("mismatched gateway IP. Expected: %s, Got: %s", r.String(), policyVal.String()) } + + if policyVal.EgressIfindex != r.egressIfindex { + return fmt.Errorf("mismatched egress ifindex") + } } untrackedRule := false policyMap.IterateWithCallback( func(key *egressmap.EgressPolicyKey4, val *egressmap.EgressPolicyVal4) { for _, r := range parsedRules { - if key.Match(r.sourceIP, r.destCIDR) && val.Match(r.egressIP, r.gatewayIP) { + if key.Match(r.sourceIP, r.destCIDR) && val.Match(r.egressIP, r.gatewayIP, r.egressIfindex) { return } } diff --git a/pkg/egressgateway/policy.go b/pkg/egressgateway/policy.go index 15b26bb80242a..9647926033b66 100644 --- a/pkg/egressgateway/policy.go +++ b/pkg/egressgateway/policy.go @@ -72,6 +72,11 @@ type PolicyConfig struct { gatewayConfigs []gatewayConfig matchedEndpoints map[endpointID]*endpointMetadata v6Needed bool + multicast bool +} + +func isIPv4MulticastPrefix(cidr netip.Prefix) bool { + return cidr.Addr().Is4() && cidr.Addr().IsMulticast() } // PolicyID includes policy name and namespace @@ -330,6 +335,7 @@ func ParseCEGP(cegp *v2.CiliumEgressGatewayPolicy) (*PolicyConfig, error) { var excludedCIDRs []netip.Prefix var policyGwConfigs []policyGatewayConfig var v6Needed bool + var multicast bool allowAllNamespacesRequirement := slim_metav1.LabelSelectorRequirement{ Key: k8sConst.PodNamespaceLabel, @@ -369,6 +375,15 @@ func ParseCEGP(cegp *v2.CiliumEgressGatewayPolicy) (*PolicyConfig, error) { if err != nil { return nil, fmt.Errorf("failed to parse destination CIDR %s: %w", cidrString, err) } + if cidr.Addr().Is6() && cidr.Addr().IsMulticast() { + return nil, fmt.Errorf("multicast destination CIDR %s is unsupported: only IPv4 multicast egress gateway policies are supported", cidrString) + } + if isIPv4MulticastPrefix(cidr) { + if cidr.Bits() < 4 { + return nil, fmt.Errorf("multicast destination CIDR %s is unsupported: IPv4 multicast prefixes must stay within 224.0.0.0/4", cidrString) + } + multicast = true + } dstCidrList = append(dstCidrList, cidr) if cidr.Addr().Is6() { v6Needed = true @@ -380,6 +395,9 @@ func ParseCEGP(cegp *v2.CiliumEgressGatewayPolicy) (*PolicyConfig, error) { if err != nil { return nil, fmt.Errorf("failed to parse excluded CIDR %s: %w", cidr, err) } + if cidr.Addr().IsMulticast() { + return nil, fmt.Errorf("excluded CIDR %s is unsupported: multicast egress gateway policies bypass conntrack and cannot use excludedCIDRs", cidrString) + } excludedCIDRs = append(excludedCIDRs, cidr) } @@ -433,6 +451,7 @@ func ParseCEGP(cegp *v2.CiliumEgressGatewayPolicy) (*PolicyConfig, error) { matchedEndpoints: make(map[endpointID]*endpointMetadata), policyGwConfigs: policyGwConfigs, v6Needed: v6Needed, + multicast: multicast, id: types.NamespacedName{ Name: name, }, diff --git a/pkg/egressgateway/policy_test.go b/pkg/egressgateway/policy_test.go index 0cda2210812ff..df8312d866216 100644 --- a/pkg/egressgateway/policy_test.go +++ b/pkg/egressgateway/policy_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/types" slimv1 "github.com/cilium/cilium/pkg/k8s/slim/k8s/apis/meta/v1" @@ -23,6 +24,64 @@ func getAsPolicyLabelSelectors(k8sLss []*slimv1.LabelSelector) (lss []*policyTyp return lss } +func TestParseCEGPMulticastDestinationCIDRs(t *testing.T) { + tests := []struct { + name string + destinationCIDRs []string + excludedCIDRs []string + wantErrContains string + }{ + { + name: "accept IPv4 SSM multicast prefix", + destinationCIDRs: []string{"232.0.0.0/4"}, + }, + { + name: "reject IPv4 multicast prefix wider than multicast range", + destinationCIDRs: []string{"224.0.0.0/3"}, + wantErrContains: "IPv4 multicast prefixes must stay within 224.0.0.0/4", + }, + { + name: "reject IPv6 multicast prefix", + destinationCIDRs: []string{"ff00::/8"}, + wantErrContains: "only IPv4 multicast egress gateway policies are supported", + }, + { + name: "reject multicast excluded CIDR", + destinationCIDRs: []string{"232.0.0.0/4"}, + excludedCIDRs: []string{"232.1.1.1/32"}, + wantErrContains: "multicast egress gateway policies bypass conntrack and cannot use excludedCIDRs", + }, + { + name: "unicast regression", + destinationCIDRs: []string{"1.1.1.0/24"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cegp, _ := newCEGP(&policyParams{ + name: "policy-1", + endpointLabels: map[string]string{"app": "test"}, + destinationCIDRs: tt.destinationCIDRs, + excludedCIDRs: tt.excludedCIDRs, + policyGwParams: []policyGatewayParams{{ + iface: "eth0", + }}, + }) + + config, err := ParseCEGP(cegp) + if tt.wantErrContains != "" { + require.Error(t, err) + require.ErrorContains(t, err, tt.wantErrContains) + return + } + + require.NoError(t, err) + require.Len(t, config.dstCIDRs, len(tt.destinationCIDRs)) + }) + } +} + func TestPolicyConfig_updateMatchedEndpointIDs(t *testing.T) { type fields struct { id types.NamespacedName diff --git a/pkg/maps/egressmap/policy.go b/pkg/maps/egressmap/policy.go index 39868f49d073f..1a58cf2f6ce43 100644 --- a/pkg/maps/egressmap/policy.go +++ b/pkg/maps/egressmap/policy.go @@ -41,8 +41,10 @@ type EgressPolicyKey4 struct { // EgressPolicyVal4 is the value of an egress policy map. type EgressPolicyVal4 struct { - EgressIP types.IPv4 `align:"egress_ip"` - GatewayIP types.IPv4 `align:"gateway_ip"` + EgressIP types.IPv4 `align:"egress_ip"` + GatewayIP types.IPv4 `align:"gateway_ip"` + EgressIfindex uint32 `align:"egress_ifindex"` + Reserved uint32 `align:"reserved"` } // EgressPolicyKey6 is the key of an egress policy map. @@ -226,11 +228,12 @@ func NewEgressPolicyKey4(sourceIP netip.Addr, destPrefix netip.Prefix) EgressPol // NewEgressPolicyVal4 returns a new EgressPolicyVal4 object representing for // the given egress IP and gateway IPs -func NewEgressPolicyVal4(egressIP, gatewayIP netip.Addr) EgressPolicyVal4 { +func NewEgressPolicyVal4(egressIP, gatewayIP netip.Addr, egressIfindex uint32) EgressPolicyVal4 { val := EgressPolicyVal4{} val.EgressIP.FromAddr(egressIP) val.GatewayIP.FromAddr(gatewayIP) + val.EgressIfindex = egressIfindex return val } @@ -267,9 +270,10 @@ func (v *EgressPolicyVal4) New() bpf.MapValue { return &EgressPolicyVal4{} } // Match returns true if the egressIP and gatewayIP parameters match the egress // policy value. -func (v *EgressPolicyVal4) Match(egressIP, gatewayIP netip.Addr) bool { +func (v *EgressPolicyVal4) Match(egressIP, gatewayIP netip.Addr, egressIfindex uint32) bool { return v.GetEgressAddr() == egressIP && - v.GetGatewayAddr() == gatewayIP + v.GetGatewayAddr() == gatewayIP && + v.EgressIfindex == egressIfindex } // GetEgressIP returns the egress policy value's egress IP. @@ -284,7 +288,7 @@ func (v *EgressPolicyVal4) GetGatewayAddr() netip.Addr { // String returns the string representation of an egress policy value. func (v *EgressPolicyVal4) String() string { - return fmt.Sprintf("%s %s", v.GetGatewayAddr(), v.GetEgressAddr()) + return fmt.Sprintf("%s %s %d", v.GetGatewayAddr(), v.GetEgressAddr(), v.EgressIfindex) } // Lookup returns the egress policy object associated with the provided (source @@ -301,9 +305,9 @@ func (m *PolicyMap4) Lookup(sourceIP netip.Addr, destCIDR netip.Prefix) (*Egress // Update updates the (sourceIP, destCIDR) egress policy entry with the provided // egress and gateway IPs. -func (m *PolicyMap4) Update(sourceIP netip.Addr, destCIDR netip.Prefix, egressIP, gatewayIP netip.Addr) error { +func (m *PolicyMap4) Update(sourceIP netip.Addr, destCIDR netip.Prefix, egressIP, gatewayIP netip.Addr, egressIfindex uint32) error { key := NewEgressPolicyKey4(sourceIP, destCIDR) - val := NewEgressPolicyVal4(egressIP, gatewayIP) + val := NewEgressPolicyVal4(egressIP, gatewayIP, egressIfindex) return m.m.Update(&key, &val) } diff --git a/pkg/maps/egressmap/policy_test.go b/pkg/maps/egressmap/policy_test.go index 33e395fe693ca..96715d018e290 100644 --- a/pkg/maps/egressmap/policy_test.go +++ b/pkg/maps/egressmap/policy_test.go @@ -35,10 +35,10 @@ func TestPrivilegedPolicyMap(t *testing.T) { egressIP1 := netip.MustParseAddr("3.3.3.1") egressIP2 := netip.MustParseAddr("3.3.3.2") - err := egressPolicyMap.Update(sourceIP1, destCIDR1, egressIP1, egressIP1) + err := egressPolicyMap.Update(sourceIP1, destCIDR1, egressIP1, egressIP1, 0) assert.NoError(t, err) - err = egressPolicyMap.Update(sourceIP2, destCIDR2, egressIP2, egressIP2) + err = egressPolicyMap.Update(sourceIP2, destCIDR2, egressIP2, egressIP2, 0) assert.NoError(t, err) val, err := egressPolicyMap.Lookup(sourceIP1, destCIDR1)