diff --git a/p4/sidecar-lite.p4 b/p4/sidecar-lite.p4 index 0da4a93..868250c 100644 --- a/p4/sidecar-lite.p4 +++ b/p4/sidecar-lite.p4 @@ -27,168 +27,186 @@ control ingress( ipv4_ttl_decrement() ttl_dec; apply { - // - // Check if this is a packet coming from the scrimlet. If so just - // forward it out the source port indicated by the sidecar header. - // - if (hdr.sidecar.isValid()) { fwd_from_scrimlet(); return; } - - /// - /// Check if we need to proxy arp this packet. - /// - if (hdr.arp.isValid()) { - bool proxied = false; - pxarp.apply(hdr, ingress, egress, proxied); - if (proxied) { return; } - } - - bool mcast_dst = false; + bool is_mcast = false; + // TODO: Combine these checks when x4c supports && and ||. if (hdr.ipv6.isValid()) { - if (hdr.ipv6.dst[127:120] == 8w0xff) { mcast_dst = true; } + if (hdr.ipv6.dst[127:120] == 8w0xff) { is_mcast = true; } } if (hdr.ipv4.isValid()) { - if (hdr.ipv4.dst[31:28] == 4w0xe) { mcast_dst = true; } - } - - if (mcast_dst) { - if (hdr.ipv4.isValid()) { - // 01:00:5e + zero bit, then the low 23 bits of the - // group (RFC 1112 section 6.4). - if (hdr.ethernet.dst[47:23] != 25w0x0200bc) { - egress.drop = true; - return; - } - if (hdr.ethernet.dst[22:0] != hdr.ipv4.dst[22:0]) { - egress.drop = true; - return; - } - } - - if (hdr.ipv6.isValid()) { - if (hdr.ethernet.dst[47:32] != 16w0x3333) { - egress.drop = true; - return; - } - if (hdr.ethernet.dst[31:0] != hdr.ipv6.dst[31:0]) { - egress.drop = true; - return; - } - } + if (hdr.ipv4.dst[31:28] == 4w0xe) { is_mcast = true; } } // - // If the packet has a local destination, create the sidecar header and - // send it to the scrimlet. + // Check if this is a packet coming from the scrimlet. If so, just + // forward it out the source port indicated by the sidecar header. // - bool local_dst = false; - local.apply(ingress, hdr, local_dst); - if (local_dst) { - // If this is boundary services packet, it should have a geneve - // header. Decap and attempt to route. - if (hdr.geneve.isValid()) { - ingress.nat_egress_hit = true; - decap_geneve(); - } - // If there is no geneve header this is a packet from outside the - // rack and should be sent to the scrimlet. - else { fwd_to_scrimlet(); return; } + if (hdr.sidecar.isValid()) { + fwd_from_scrimlet(); } else { - // Multicast with TTL/hop_limit <= 1 must not be forwarded - // (RFC 1112, RFC 8200 section 3). The check lives here, not in - // the parser, because scrimlet transmissions return earlier - // through fwd_from_scrimlet and must not be dropped. - if (mcast_dst) { + /// + /// Check if we need to proxy arp this packet. + /// + if (hdr.arp.isValid()) { + bool proxied = false; + pxarp.apply(hdr, ingress, egress, proxied); + if (proxied) { return; } + } + + if (is_mcast) { if (hdr.ipv4.isValid()) { - if (hdr.ipv4.ttl <= 8w1) { egress.drop = true; return; } + // 01:00:5e + zero bit, then the low 23 bits of the + // group (RFC 1112 section 6.4). + if (hdr.ethernet.dst[47:23] != 25w0x0200bc) { + egress.drop = true; + return; + } + if (hdr.ethernet.dst[22:0] != hdr.ipv4.dst[22:0]) { + egress.drop = true; + return; + } } + if (hdr.ipv6.isValid()) { - if (hdr.ipv6.hop_limit <= 8w1) { + if (hdr.ethernet.dst[47:32] != 16w0x3333) { + egress.drop = true; + return; + } + if (hdr.ethernet.dst[31:0] != hdr.ipv6.dst[31:0]) { egress.drop = true; return; } } - } else { - attached.apply(ingress, hdr); } - if (hdr.geneve.isValid() == false) { - nat.apply(hdr, ingress, egress); // check for ingress nat - } - } - // - // After local and NAT processing, basic packet forwarding happens. - // - // Unicast and multicast are bifurcated, mirroring dendrite's - // sidecar.p4 (the meta.is_mcast metadata check). Multicast traffic - // is replicated and bypasses the unicast nexthop resolver, - // whose default_action is drop. Each replica's destination and - // source MACs are derived in the egress control instead. - // - // IPv6 link-local multicast (ff02::/16) never reaches this point; it is - // marked local and handled by the earlier local path. The parser - // rejects interface-local scope multicast, while expired multicast - // (TTL/hop_limit <= 1) gets dropped on the non-local path above. - // - // IPv4 local network control (224.0.0.0/24, RFC 5771 section 4) is - // not dropped by address. The TTL check covers IGMP, OSPF and - // PIM, but senders such as mDNS and VRRP can use TTL 255. - bool is_mcast = false; - if (hdr.ipv6.isValid()) { - if (hdr.ipv6.dst[127:120] == 8w0xff) { is_mcast = true; } - } - if (hdr.ipv4.isValid()) { - if (hdr.ipv4.dst[31:28] == 4w0xe) { is_mcast = true; } - } + // + // If the packet has a local destination, create the sidecar header and + // send it to the scrimlet. + // + bool local_dst = false; + local.apply(ingress, hdr, local_dst); + if (local_dst) { + // If this is boundary services packet, it should have a geneve + // header. Decap and attempt to route. + if (hdr.geneve.isValid()) { + ingress.nat_egress_hit = true; + decap_geneve(); + } + // If there is no geneve header this is a packet from outside the + // rack and should be sent to the scrimlet. + else { fwd_to_scrimlet(); return; } + } else { + // Multicast with TTL/hop_limit <= 1 must not be forwarded + // (RFC 1112, RFC 8200 section 3). The check lives here, not in + // the parser, because scrimlet transmissions take the + // fwd_from_scrimlet branch and must not be dropped. + if (is_mcast) { + if (hdr.ipv4.isValid()) { + if (hdr.ipv4.ttl <= 8w1) { egress.drop = true; return; } + } + if (hdr.ipv6.isValid()) { + if (hdr.ipv6.hop_limit <= 8w1) { + egress.drop = true; + return; + } + } + } else { + attached.apply(ingress, hdr); + } + if (hdr.geneve.isValid() == false) { + nat.apply(hdr, ingress, egress); // check for ingress nat + } + } - if (is_mcast) { - if (hdr.vlan.isValid()) { - hdr.ethernet.ether_type = hdr.vlan.ether_type; - hdr.vlan.setInvalid(); + // + // After local and NAT processing, basic packet forwarding happens. + // + // Unicast and multicast are bifurcated, mirroring dendrite's + // sidecar.p4 (the meta.is_mcast metadata check). Multicast traffic + // is replicated and bypasses the unicast nexthop resolver, + // whose default_action punts to the scrimlet. Each replica's + // destination and source MACs are derived in the egress control + // instead. + // + // IPv6 link-local multicast (ff02::/16) never reaches this point; it is + // marked local and handled by the earlier local path. The parser + // rejects interface-local scope multicast, while expired multicast + // (TTL/hop_limit <= 1) gets dropped on the non-local path above. + // + // IPv4 local network control (224.0.0.0/24, RFC 5771 section 4) is + // not dropped by address. The TTL check covers IGMP, OSPF and + // PIM, but senders such as mDNS and VRRP can use TTL 255. + is_mcast = false; + if (hdr.ipv6.isValid()) { + if (hdr.ipv6.dst[127:120] == 8w0xff) { is_mcast = true; } } - mcast.apply(hdr, ingress, egress); - // Packet copies that keep their encapsulation are forwarded on the - // outer IPv6 header, losing a hop like dendrite's - // MulticastRouter6 (RFC 8200 section 3). Decapsulated copies - // have the inner header decremented before decap_geneve executes. - if (ingress.mcast_decap) { - validate_mcast_external(); - if (egress.drop == false) { - if (hdr.inner_ipv4.isValid()) { - ttl_dec.apply(hdr.inner_ipv4); + if (hdr.ipv4.isValid()) { + if (hdr.ipv4.dst[31:28] == 4w0xe) { is_mcast = true; } + } + + if (is_mcast) { + if (hdr.vlan.isValid()) { + hdr.ethernet.ether_type = hdr.vlan.ether_type; + hdr.vlan.setInvalid(); + } + mcast.apply(hdr, ingress, egress); + // Packet copies that keep their encapsulation are forwarded on the + // outer IPv6 header, losing a hop like dendrite's + // MulticastRouter6 (RFC 8200 section 3). Decapsulated copies + // have the inner header decremented before decap_geneve executes. + if (ingress.mcast_decap) { + validate_mcast_external(); + if (egress.drop == false) { + if (hdr.inner_ipv4.isValid()) { + ttl_dec.apply(hdr.inner_ipv4); + } + if (hdr.inner_ipv6.isValid()) { + hdr.inner_ipv6.hop_limit = + hdr.inner_ipv6.hop_limit - 8w1; + } + decap_geneve(); } - if (hdr.inner_ipv6.isValid()) { - hdr.inner_ipv6.hop_limit = - hdr.inner_ipv6.hop_limit - 8w1; + } else { + if (hdr.ipv6.isValid()) { + if (hdr.ipv6.hop_limit <= 8w1) { egress.drop = true; } + if (egress.drop == false) { + hdr.ipv6.hop_limit = hdr.ipv6.hop_limit - 8w1; + } } - decap_geneve(); + } + // An empty bitmap is equivalent to having no multicast group + // configured, and the packet is dropped. + // + // This matches the default action of dendrite's + // mcast_replication_ipv6 (drop_mcastv6_admin_scoped_no_group). + // Neither pipeline sends an ICMP error for multicast (RFC 1812 + // section 4.3.2.7). + if (egress.port_bitmap == 128w0) { + egress.drop = true; } } else { - if (hdr.ipv6.isValid()) { - if (hdr.ipv6.hop_limit <= 8w1) { egress.drop = true; } - if (egress.drop == false) { - hdr.ipv6.hop_limit = hdr.ipv6.hop_limit - 8w1; - } + router.apply(hdr, ingress, egress); + // Prevent reflection. Multicast ingress-port suppression is + // handled in the replication bitmap, so this applies to + // unicast only. + if (ingress.port == egress.port) { egress.drop = true; } else { + resolver.apply(hdr, ingress, egress); } } - // An empty bitmap is equivalent to having no multicast group - // configured, and the packet is dropped. - // - // This matches the default action of dendrite's - // mcast_replication_ipv6 (drop_mcastv6_admin_scoped_no_group). - // Neither pipeline sends an ICMP error for multicast (RFC 1812 - // section 4.3.2.7). - if (egress.port_bitmap == 128w0) { - egress.drop = true; + } + + // + // Unicast has its source MAC rewritten for that port, but + // multicast is disregarded, as replicas get their MACs in the + // egress control, and multicast packets from the scrimlet + // continue on as userspace had sent it. + if (egress.drop == false) { + if (is_mcast == false) { + // Packets sent to scrimlet don't need their SMAC rewritten. + // If a resolver miss punts the packet and it gets retransmitted + // by tfportd (upon successful ARP/ND resolution), the SMAC is + // rewritten after fwd_from_scrimlet(). + if (egress.port != 16w0) { mac.apply(hdr, egress); } } - } else { - router.apply(hdr, ingress, egress); - resolver.apply(hdr, egress); - mac.apply(hdr, egress); - - // Prevent reflection. Multicast ingress-port suppression is - // handled in the replication bitmap, so this applies to - // unicast only. - if (ingress.port == egress.port) { egress.drop = true; } } } @@ -539,18 +557,19 @@ control attached( control resolver( inout headers_t hdr, + in ingress_metadata_t ingress, inout egress_metadata_t egress, ) { table resolver_v4 { key = { egress.nexthop_v4: exact; } - actions = { rewrite_dst; drop; } - default_action = drop; + actions = { request_arp; rewrite_dst; drop; } + default_action = request_arp; } table resolver_v6 { key = { egress.nexthop_v6: exact; } - actions = { rewrite_dst; drop; } - default_action = drop; + actions = { request_ndp; rewrite_dst; drop; } + default_action = request_ndp; } apply { @@ -558,7 +577,31 @@ control resolver( if (egress.nexthop_v6 != 128w0) { resolver_v6.apply(); } } + action request_arp() { + hdr.sidecar.setValid(); + hdr.sidecar.sc_ether_type = hdr.ethernet.ether_type; + hdr.ethernet.ether_type = 16w0x0901; // ETHERTYPE_SIDECAR + hdr.sidecar.sc_code = 8w0x03; // SC_ARP_NEEDED + hdr.sidecar.sc_ingress = ingress.port; + hdr.sidecar.sc_egress = egress.port; + hdr.sidecar.sc_payload = 128w0; + hdr.sidecar.sc_payload[31:0] = egress.nexthop_v4; + egress.port = 16w0; // USER_SPACE_SERVICE_PORT + } + + action request_ndp() { + hdr.sidecar.setValid(); + hdr.sidecar.sc_ether_type = hdr.ethernet.ether_type; + hdr.ethernet.ether_type = 16w0x0901; // ETHERTYPE_SIDECAR + hdr.sidecar.sc_code = 8w0x04; // SC_NEIGHBOR_NEEDED + hdr.sidecar.sc_ingress = ingress.port; + hdr.sidecar.sc_egress = egress.port; + hdr.sidecar.sc_payload = egress.nexthop_v6; + egress.port = 16w0; // USER_SPACE_SERVICE_PORT + } + action rewrite_dst(bit<48> dst) { hdr.ethernet.dst = dst; } + action drop() { egress.drop = true; } } diff --git a/softnpu/src/test.rs b/softnpu/src/test.rs index 41abb6b..047f687 100644 --- a/softnpu/src/test.rs +++ b/softnpu/src/test.rs @@ -27,6 +27,11 @@ const ETHERTYPE_SIDECAR: u16 = 0x0901; const GENEVE_UDP_PORT: u16 = 6081; const GENEVE_PROTO_ETH: u16 = 0x6558; const OXG_OPTION_CLASS: u16 = 0x0129; +const SC_FWD_FROM_USERSPACE: u8 = 0x00; +const SC_ARP_NEEDED: u8 = 0x0003; +const SC_NEIGHBOR_NEEDED: u8 = 0x0004; +// this is defined as bit<9> in sidecar.p4, so round up to u16 +const USER_SPACE_SERVICE_PORT: u16 = 0x0000; // Multicast tag values matching Dendrite's MULTICAST_TAG_* constants. const MCAST_TAG_EXTERNAL: u8 = 0; @@ -236,30 +241,55 @@ pub struct OxgMcastOpt { payload: Vec, } -fn pipeline_init(pipeline: &mut main_pipeline) { +// Sidecar frame format (following headers.p4) +#[allow(dead_code)] +#[derive(PktDerive)] +pub struct Sidecar { + sc_code: u8, + sc_pad: u8, + sc_ingress: u16be, + sc_egress: u16be, + sc_ether_type: u16be, + #[length = "16"] + sc_payload: Vec, + #[payload] + payload: Vec, // the packet inside the sidecar header +} + +fn router_pipeline_init( + pipeline: &mut main_pipeline, + v4_nh: &str, + v4_nh_port: u16, + v6_nh: &str, + v6_nh_port: u16, +) { // router entry upstream // Add a single path for 0.0.0.0/0 pointing at data in slot 2. let (key_buf, param_buf) = router_idx_entry("0.0.0.0", 0, 2, 1); pipeline .add_ingress_router_v4_idx_rtr_entry("index", &key_buf, ¶m_buf, 0); - // At slot 2, add a forwarding entry gw=1.2.3.1, port=1 - let (key_buf, param_buf) = router_forward_entry(2, "1.2.3.1", 1); + // At slot 2, add a forwarding entry (gw=v4_nh, port=v4_nh_port) + let (key_buf, param_buf) = router_forward_entry(2, v4_nh, v4_nh_port); pipeline.add_ingress_router_v4_route_rtr_entry( "forward", &key_buf, ¶m_buf, 0, ); // router entry downstream - // Add a single path for fd00::1 pointing at data in slot 2. + // Add a single path for fd00::1/64 pointing at data in slot 2. let (key_buf, param_buf) = router_idx_entry("fd00:1::", 64, 2, 1); pipeline .add_ingress_router_v6_idx_rtr_entry("index", &key_buf, ¶m_buf, 0); - // At slot 2, add a forwarding entry gw=fe80::1 port=0 - let (key_buf, param_buf) = router_forward_entry(2, "fe80::1", 0); + // At slot 2, add a forwarding entry (gw=v6_nh port=v6_nh_port) + let (key_buf, param_buf) = router_forward_entry(2, v6_nh, v6_nh_port); pipeline.add_ingress_router_v6_route_rtr_entry( "forward", &key_buf, ¶m_buf, 0, ); +} + +fn pipeline_init(pipeline: &mut main_pipeline) { + router_pipeline_init(pipeline, "1.2.3.1", 1, "fe80::1", 0); // nat entry let (key_buf, param_buf) = nat4_entry( @@ -1252,12 +1282,16 @@ fn mcast_ttl_pipeline() -> main_pipeline { } fn mcast_v4_probe(dst: &str, ttl: u8) -> Vec { + v4_probe("10.0.0.1", dst, ttl) +} + +fn v4_probe(src: &str, dst: &str, ttl: u8) -> Vec { let payload = [0u8; 8]; let mut ip_data: Vec = vec![0; 20 + payload.len()]; let mut ip = MutableIpv4Packet::new(&mut ip_data).unwrap(); ip.set_version(4); ip.set_header_length(5); - ip.set_source("10.0.0.1".parse().unwrap()); + ip.set_source(src.parse().unwrap()); ip.set_destination(dst.parse().unwrap()); ip.set_next_level_protocol(IpNextHeaderProtocol::new(17)); ip.set_total_length(20 + payload.len() as u16); @@ -1383,6 +1417,12 @@ fn ipv6_mcast_ff02_hop_limit_zero_to_scrimlet() -> Result<(), anyhow::Error> { assert_v6_to_scrimlet(&probe, "ff02:: packet with hop_limit=0") } +#[test] +fn ipv6_mcast_ff02_hop_limit_255_to_scrimlet() -> Result<(), anyhow::Error> { + let probe = mcast_v6_probe("ff02::fb", 255); + assert_v6_to_scrimlet(&probe, "ff02::fb packet with hop_limit=255") +} + fn assert_v6_to_scrimlet( probe: &[u8], what: &str, @@ -1460,8 +1500,8 @@ fn sidecar_wrap(egress: u16, ethertype: u16, l3: &[u8]) -> Vec { // Send `probe` from the scrimlet side (port 0) toward port 1 and collect what // exits port 1. // -// No replication group or NAT entry is installed: ingress returns early through -// fwd_from_scrimlet, which forwards out sc_egress without looking at the +// No replication group or NAT entry is installed: ingress takes the +// fwd_from_scrimlet path, which forwards out sc_egress without looking at the // multicast tables. fn scrimlet_mcast_copies( ethertype: u16, @@ -1485,47 +1525,212 @@ fn scrimlet_mcast_copies( npu.run(); + let dst = l2_dst(phy1.mac, ethertype, probe); let frame = sidecar_wrap(1, ethertype, probe); - phy0.send(&[TxFrame::new(phy1.mac, ETHERTYPE_SIDECAR, &frame)])?; + let sentinel = sidecar_wrap(1, ETHERTYPE_IPV4, &sentinel_v4()); + phy0.send(&[ + TxFrame::new(dst, ETHERTYPE_SIDECAR, &frame), + TxFrame::new(phy1.mac, ETHERTYPE_SIDECAR, &sentinel), + ])?; - wait_for( - || phy1.recv_buffer_len() > 0, - "scrimlet multicast on port 1", - ); - let frames = phy1.recv(); + let frames = recv_until_sentinel(&phy1, "scrimlet sentinel on port 1"); for f in &frames { - assert_eq!(f.src, phy0.mac, "scrimlet src MAC not rewritten"); - assert_eq!(f.dst, phy1.mac, "scrimlet dst MAC not rewritten"); + assert_eq!(f.src, phy0.mac, "scrimlet src MAC kept"); + assert_eq!(f.dst, dst, "scrimlet dst MAC kept"); } Ok(frames) } -// The scrimlet's own TTL-1 multicast (e.g., IGMP reports) is a -// transmission vs being a forwarding hop. The packet leaves unchanged, while -// the same packet from a front port is dropped. +// The scrimlet's own multicast (e.g., TTL-1 IGMP reports) acts as a +// transmission versus as a forwarding hop. The packet leaves unchanged at +// any TTL, while the same packet from a front port is dropped at TTL <= 1 +// (except ff02, which is punted to the scrimlet). #[test] -fn scrimlet_ipv4_mcast_ttl1_forwarded() -> Result<(), anyhow::Error> { - let probe = mcast_v4_probe("238.1.1.1", 1); - let fs = scrimlet_mcast_copies(ETHERTYPE_IPV4, &probe)?; - assert_eq!(fs.len(), 1, "one copy out the sidecar egress port"); - assert_eq!(fs[0].ethertype, ETHERTYPE_IPV4, "sidecar header stripped"); - let ip = Ipv4Packet::new(&fs[0].payload).unwrap(); - assert_eq!(ip.get_ttl(), 1, "scrimlet transmission keeps its TTL"); +fn scrimlet_mcast_forwarded() -> Result<(), anyhow::Error> { + for ttl in [0, 1, 64, 255] { + let probe = mcast_v4_probe("238.1.1.1", ttl); + let fs = scrimlet_mcast_copies(ETHERTYPE_IPV4, &probe)?; + assert_eq!(fs.len(), 1, "238.1.1.1 at TTL {ttl} out sc_egress"); + assert_eq!(fs[0].ethertype, ETHERTYPE_IPV4, "sidecar header stripped"); + let ip = Ipv4Packet::new(&fs[0].payload).unwrap(); + assert_eq!(ip.get_ttl(), ttl, "scrimlet transmission keeps its TTL"); + + for group in ["ff02::1", "ff0e::1"] { + let probe = mcast_v6_probe(group, ttl); + let fs = scrimlet_mcast_copies(ETHERTYPE_IPV6, &probe)?; + assert_eq!(fs.len(), 1, "{group} at hop limit {ttl} out sc_egress"); + assert_eq!( + fs[0].ethertype, ETHERTYPE_IPV6, + "sidecar header stripped" + ); + let ip = Ipv6Packet::new(&fs[0].payload).unwrap(); + assert_eq!( + ip.get_hop_limit(), + ttl, + "scrimlet transmission keeps its hop limit" + ); + } + } Ok(()) } -// IPv6 counterpart of the above. -// -// The group is ff0e because the parser already admits link-local multicast -// at hop_limit 1 and should not exercise the exemption. #[test] -fn scrimlet_ipv6_mcast_hop_limit1_forwarded() -> Result<(), anyhow::Error> { - let probe = mcast_v6_probe("ff0e::1", 1); - let fs = scrimlet_mcast_copies(ETHERTYPE_IPV6, &probe)?; - assert_eq!(fs.len(), 1, "one copy out the sidecar egress port"); - assert_eq!(fs[0].ethertype, ETHERTYPE_IPV6, "sidecar header stripped"); +fn scrimlet_non_ip_smac_rewritten() -> Result<(), anyhow::Error> { + let arp: [u8; 28] = [ + 0x00, 0x01, 0x08, 0x00, 0x06, 0x04, 0x00, 0x01, // request + 0x02, 0x00, 0x00, 0x00, 0x00, 0x09, 10, 0, 0, 1, // sha, spa + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 10, 0, 0, 2, // tha, tpa + ]; + let lldp: [u8; 8] = [0x02, 0x02, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00]; + let cases: [(u16, [u8; 6], &[u8]); 2] = [ + (0x0806, [0xff; 6], &arp), + (0x88cc, [0x01, 0x80, 0xc2, 0x00, 0x00, 0x0e], &lldp), + ]; + + for (ethertype, dst, payload) in cases { + let mut pipeline = main_pipeline::new(2); + pipeline_init(&mut pipeline); + let mut npu = SoftNpu::new(2, pipeline, false); + let phy0 = npu.phy(0); + let phy1 = npu.phy(1); + + npu.run(); + + let frame = sidecar_wrap(1, ethertype, payload); + let sentinel = sidecar_wrap(1, ETHERTYPE_IPV4, &sentinel_v4()); + phy0.send(&[ + TxFrame::new(dst, ETHERTYPE_SIDECAR, &frame), + TxFrame::new(phy1.mac, ETHERTYPE_SIDECAR, &sentinel), + ])?; + + let fs = recv_until_sentinel(&phy1, "scrimlet sentinel on port 1"); + assert_eq!(fs.len(), 1, "{ethertype:#06x} out sc_egress"); + assert_eq!(fs[0].ethertype, ethertype, "sidecar header stripped"); + assert_eq!(fs[0].src, [1, 2, 3, 4, 5, 6], "port 1's SMAC"); + assert_eq!(fs[0].dst, dst, "DMAC kept"); + } + Ok(()) +} + +fn scrimlet_mcast_group_copies( + dst: Option<[u8; 6]>, + ethertype: u16, + probe: &[u8], +) -> Result, anyhow::Error> { + let mut npu = SoftNpu::new(2, mcast_ttl_pipeline(), false); + let phy0 = npu.phy(0); + let phy1 = npu.phy(1); + + npu.run(); + + let dst = dst.unwrap_or_else(|| l2_dst(phy1.mac, ethertype, probe)); + let frame = sidecar_wrap(1, ethertype, probe); + phy0.send(&[TxFrame::new(dst, ETHERTYPE_SIDECAR, &frame)])?; + phy0.send(&[TxFrame::new(phy1.mac, ETHERTYPE_IPV4, &sentinel_v4())])?; + + let port1 = recv_until_sentinel(&phy1, "port 1 sentinel"); + assert_eq!(phy0.recv_buffer_len(), 0, "no copy back to the scrimlet"); + Ok(port1) +} + +#[test] +fn scrimlet_mcast_no_nat() -> Result<(), anyhow::Error> { + let probe = mcast_v4_probe("238.1.1.1", 64); + let fs = scrimlet_mcast_group_copies(None, ETHERTYPE_IPV4, &probe)?; + assert_eq!(fs.len(), 1, "one copy out sc_egress"); + assert_eq!(fs[0].ethertype, ETHERTYPE_IPV4, "not NAT-encapsulated"); + let ip = Ipv4Packet::new(&fs[0].payload).unwrap(); + assert_eq!( + ip.get_destination(), + "238.1.1.1".parse::().unwrap(), + "original group" + ); + assert_eq!(ip.get_ttl(), 64, "TTL not decremented"); + + let probe = mcast_v6_probe("ff0e::1", 64); + let fs = scrimlet_mcast_group_copies(None, ETHERTYPE_IPV6, &probe)?; + assert_eq!(fs.len(), 1, "one copy out sc_egress"); let ip = Ipv6Packet::new(&fs[0].payload).unwrap(); - assert_eq!(ip.get_hop_limit(), 1, "scrimlet transmission keeps its hop"); + assert_eq!( + ip.get_destination(), + "ff0e::1".parse::().unwrap(), + "not NAT-encapsulated to the underlay group" + ); + assert_eq!(ip.get_hop_limit(), 64, "hop limit not decremented"); + Ok(()) +} + +#[test] +fn scrimlet_geneve_mcast_no_repl() -> Result<(), anyhow::Error> { + let mut pipeline = mcast_ff0e_pipeline(&[], &[2]); + let mut rewrite_mac = [0x02, 0xaa, 0xbb, 0xcc, 0xdd, 0xee]; + rewrite_mac.reverse(); + for port in [1u16, 2] { + pipeline.add_egress_mcast_src_mac_entry( + "rewrite_src_mac", + &port.to_le_bytes(), + &rewrite_mac, + 0, + ); + } + + let mut npu = SoftNpu::new(3, pipeline, false); + let phy0 = npu.phy(0); + let phy1 = npu.phy(1); + let phy2 = npu.phy(2); + + npu.run(); + + let ip_data = geneve_mcast_v4_pkt_repl( + "fd00:1::1", + "ff0e::1", + "10.0.0.1", + "238.1.1.1", + Some(MCAST_TAG_UNDERLAY_EXTERNAL), + ); + let dst = l2_dst(phy1.mac, ETHERTYPE_IPV6, &ip_data); + let frame = sidecar_wrap(1, ETHERTYPE_IPV6, &ip_data); + let port1_sentinel = sidecar_wrap(1, ETHERTYPE_IPV4, &sentinel_v4()); + let port2_sentinel = sidecar_wrap(2, ETHERTYPE_IPV4, &sentinel_v4()); + phy0.send(&[ + TxFrame::new(dst, ETHERTYPE_SIDECAR, &frame), + TxFrame::new(phy1.mac, ETHERTYPE_SIDECAR, &port1_sentinel), + TxFrame::new(phy2.mac, ETHERTYPE_SIDECAR, &port2_sentinel), + ])?; + + let port1 = recv_until_sentinel(&phy1, "scrimlet sentinel on port 1"); + assert_eq!(port1.len(), 1, "one copy out sc_egress"); + assert_eq!( + port1[0].ethertype, ETHERTYPE_IPV6, + "sidecar header stripped" + ); + assert_eq!(port1[0].src, phy0.mac, "scrimlet src MAC kept"); + assert_eq!(port1[0].dst, dst, "scrimlet dst MAC kept"); + assert_eq!(port1[0].vid, None, "no VLAN added"); + assert_eq!( + port1[0].payload, ip_data, + "Geneve encapsulation and TTLs kept" + ); + + let port2 = recv_until_sentinel(&phy2, "scrimlet sentinel on port 2"); + assert!(port2.is_empty(), "no copy on the replication group port"); + assert_eq!(phy0.recv_buffer_len(), 0, "no copy back to the scrimlet"); + Ok(()) +} + +#[test] +fn scrimlet_mcast_dst_mac_unchecked() -> Result<(), anyhow::Error> { + let unicast = [0x02, 0, 0, 0, 0, 0x01]; + + let v6 = mcast_v6_probe("ff02::1", 64); + let fs = scrimlet_mcast_group_copies(Some(unicast), ETHERTYPE_IPV6, &v6)?; + assert_eq!(fs.len(), 1, "ff02::1 w/ a unicast DMAC out sc_egress"); + assert_eq!(fs[0].dst, unicast, "DMAC kept"); + + let v4 = mcast_v4_probe("238.1.1.1", 64); + let fs = scrimlet_mcast_group_copies(Some(unicast), ETHERTYPE_IPV4, &v4)?; + assert_eq!(fs.len(), 1, "238.1.1.1 w/ a unicast DMAC out sc_egress"); + assert_eq!(fs[0].dst, unicast, "DMAC kept"); Ok(()) } @@ -3444,6 +3649,190 @@ fn mcast_suppression_drops_when_only_group_zeroed() -> Result<(), anyhow::Error> Ok(()) } +#[test] +fn resolver_miss() -> Result<(), anyhow::Error> { + const NEXTHOP4: &str = "1.2.3.1"; + const NEXTHOP6: &str = "fe80::1"; + const DST_PORT: u16 = 0x0001; // Routes installed via DST_PORT + const SRC_PORT: u16 = 0x0002; // Packets injected from SRC_PORT + + let mut pipeline = main_pipeline::new(1); + router_pipeline_init(&mut pipeline, NEXTHOP4, DST_PORT, NEXTHOP6, DST_PORT); + + let mut npu = SoftNpu::new(3, pipeline, false); + let phy0 = npu.phy(USER_SPACE_SERVICE_PORT.into()); // scrimlet port + let phy1 = npu.phy(DST_PORT.into()); + let phy2 = npu.phy(SRC_PORT.into()); + + npu.run(); + + // IPv4 setup + const DST4: &str = "100.64.0.1"; + const SRC4: &str = "10.0.0.1"; + let probe4 = v4_probe(SRC4, DST4, 255); + let probe4_desc = format!("IPv4 packet (dst={DST4}, src={SRC4}, ttl=255)"); + + // Send IPv4 probe from port 2 so scrimlet (port 0) can receive it. + phy2.send(&[TxFrame::new(phy1.mac, ETHERTYPE_IPV4, &probe4)])?; + + wait_for( + || phy0.recv_buffer_len() > 0, + &format!("scrimlet should receive {probe4_desc}"), + ); + let maybe_sidecar = phy0.recv().remove(0); + assert_eq!( + maybe_sidecar.ethertype, ETHERTYPE_SIDECAR, + "{probe4_desc} should arrive with sidecar EtherType" + ); + let maybe_arp_needed = + SidecarPacket::new(&maybe_sidecar.payload).expect("sidecar header"); + + assert_eq!( + maybe_arp_needed.get_sc_ether_type(), + ETHERTYPE_IPV4, + "{probe4_desc} should retain its IPv4 EtherType" + ); + assert_eq!( + maybe_arp_needed.get_sc_code(), + SC_ARP_NEEDED, + "{probe4_desc} should arrive with sidecar code SC_ARP_NEEDED" + ); + assert_eq!( + maybe_arp_needed.get_sc_egress(), + DST_PORT, + "{probe4_desc} should arrive with sidecar egress {DST_PORT}" + ); + assert_eq!( + Ipv4Addr::from_octets( + // for sc_code=SC_ARP_NEEDED, the lower 4 bytes are the IPv4 nexthop + maybe_arp_needed.get_sc_payload_raw()[12..16] + .try_into() + .unwrap() + ), + NEXTHOP4.parse::().unwrap(), + "{probe4_desc} should arrive with sidecar payload {NEXTHOP4}" + ); + + // IPv6 setup + const DST6: &str = "fd00:1::1"; + const SRC6: &str = "fd00:1::99"; + let probe6 = v6_probe(SRC6, DST6, 255); + let probe6_desc = + format!("IPv6 packet (dst={DST6}, src={SRC6}, hop-limit=255)"); + + // Send IPv6 probe from port 2 so scrimlet (port 0) can receive it. + phy2.send(&[TxFrame::new(phy1.mac, ETHERTYPE_IPV6, &probe6)])?; + + wait_for( + || phy0.recv_buffer_len() > 0, + &format!("scrimlet should receive {probe6_desc}"), + ); + let maybe_sidecar = phy0.recv().remove(0); + assert_eq!( + maybe_sidecar.ethertype, ETHERTYPE_SIDECAR, + "{probe6_desc} should arrive with sidecar EtherType" + ); + let maybe_ndp_needed = + SidecarPacket::new(&maybe_sidecar.payload).expect("sidecar header"); + + assert_eq!( + maybe_ndp_needed.get_sc_ether_type(), + ETHERTYPE_IPV6, + "{probe6_desc} should retain its IPv6 EtherType" + ); + assert_eq!( + maybe_ndp_needed.get_sc_code(), + SC_NEIGHBOR_NEEDED, + "{probe6_desc} should arrive with sidecar code SC_NEIGHBOR_NEEDED" + ); + assert_eq!( + maybe_ndp_needed.get_sc_egress(), + DST_PORT, + "{probe6_desc} should arrive with sidecar egress {DST_PORT}" + ); + assert_eq!( + Ipv6Addr::from_octets( + // for sc_code=SC_NEIGHBOR_NEEDED, the payload is the IPv6 nexthop + maybe_ndp_needed.get_sc_payload_raw().try_into().unwrap() + ), + NEXTHOP6.parse::().unwrap(), + "{probe6_desc} should arrive with sidecar payload {NEXTHOP6}" + ); + + Ok(()) +} + +// Route everything out port 1 via nexthops with no resolver entries, +// so every resolver lookup misses. +fn resolver_miss_pipeline() -> main_pipeline { + let mut pipeline = main_pipeline::new(3); + router_pipeline_init(&mut pipeline, "1.2.3.1", 1, "fe80::1", 1); + pipeline +} + +// A packet routed back out of its ingress port is dropped before the control's +// resolver runs (it is never punted to userspace). +#[test] +fn resolver_miss_reflection_dropped() -> Result<(), anyhow::Error> { + let mut pipeline = resolver_miss_pipeline(); + let sentinel_dst = sentinel_route_v4(&mut pipeline, 2); + + let mut npu = SoftNpu::new(3, pipeline, false); + let (phy0, phy1, phy2) = (npu.phy(0), npu.phy(1), npu.phy(2)); + npu.run(); + + let probe = v4_probe("10.0.0.1", "100.64.0.1", 255); + let sentinel = sentinel_v4_to(&sentinel_dst); + phy1.send(&[ + TxFrame::new(phy1.mac, ETHERTYPE_IPV4, &probe), + TxFrame::new(phy2.mac, ETHERTYPE_IPV4, &sentinel), + ])?; + + // sentinel is sent last, so once it's present the probe should be too + recv_until_sentinel(&phy2, "sentinel on port 2"); + assert_eq!(phy0.recv_buffer_len(), 0, "probe punted to the scrimlet"); + assert_eq!(phy1.recv_buffer_len(), 0, "probe reflected out port 1"); + Ok(()) +} + +// A packet punted upon resolver miss keeps the sender's source MAC. When +// userspace sends it back, it gets the egress port's source MAC. +#[test] +fn resolver_miss_smac_rewrite() -> Result<(), anyhow::Error> { + let mut pipeline = resolver_miss_pipeline(); + // Give ports 0 and 1 source MACs 02:00:00:00:00:00 and 02:00:00:00:00:01 + // (mac_rewrite_entry takes the bytes in reverse order). Without an entry + // for port 0, rewriting a punted packet would be a no-op and go unnoticed. + for port in [0u16, 1] { + let (key, param) = mac_rewrite_entry(port, [port as u8, 0, 0, 0, 0, 2]); + pipeline.add_ingress_mac_mac_rewrite_entry("rewrite", &key, ¶m, 0); + } + + let mut npu = SoftNpu::new(3, pipeline, false); + let (phy0, phy1, phy2) = (npu.phy(0), npu.phy(1), npu.phy(2)); + npu.run(); + + let probe = v4_probe("10.0.0.1", "100.64.0.1", 255); + phy2.send(&[TxFrame::new(phy1.mac, ETHERTYPE_IPV4, &probe)])?; + wait_for(|| phy0.recv_buffer_len() > 0, "punted probe on port 0"); + let punted = phy0.recv().remove(0); + assert_eq!(punted.src, phy2.mac, "punted probe keeps the sender's SMAC"); + + // Send it back the way tfportd does once the nexthop resolves. + let mut frame = punted.payload; + MutableSidecarPacket::new(&mut frame) + .unwrap() + .set_sc_code(SC_FWD_FROM_USERSPACE); + phy0.send(&[TxFrame::new(phy1.mac, ETHERTYPE_SIDECAR, &frame)])?; + wait_for( + || phy1.recv_buffer_len() > 0, + "retransmitted probe on port 1", + ); + let forwarded = phy1.recv().remove(0); + assert_eq!(forwarded.src, [2, 0, 0, 0, 0, 1], "port 1's SMAC"); + Ok(()) +} + // Create an entry for the multipath cidr -> index table fn router_idx_entry( dst: &str,