Skip to content

Commit 16ee0d2

Browse files
[multicast] prevent VLAN translation via match key enforcement
Fixes #107. Stacked on #189. This adds VLAN-aware NAT ingress matching to prevent cross-VLAN translation. Previously, a packet arriving with VLAN 100 destined to a multicast group configured for VLAN 200 would be NAT encapsulated and forwarded, effectively translating the packet to the wrong customer's network. NAT ingress table matching (mcast_nat.rs, mod.rs): - Add Ipv4VlanMatchKey and Ipv6VlanMatchKey that match on destination address, VLAN header validity, and VLAN ID - For groups with VLAN, install two entries: untagged (for decapsulated Geneve from underlay) and correctly tagged (for customer packets) - Packets with the wrong VLAN miss both entries and are not NAT encapsulated Multicast router VLAN handling (sidecar.p4): - Strip incoming VLAN tag before routing lookup in MulticastRouter4/6 - forward_vlan action re-adds the group's configured VLAN on egress - Prevents unintended VLAN translation at the routing stage Rollback changes: - Remove dead NAT rollback branches for internal groups (no NAT entries) - Add rollback support for VLAN changes in NAT and route tables Counter fix: - The underlay multicast counter condition was unreachable for packets tagged MULTICAST_TAG_UNDERLAY_EXTERNAL that were not decapped. The check for == MULTICAST_TAG_UNDERLAY excluded these packets, causing them to fall through to the external counter. Pull Request: #194
1 parent 731a821 commit 16ee0d2

9 files changed

Lines changed: 1232 additions & 276 deletions

File tree

dpd-client/tests/integration_tests/mcast.rs

Lines changed: 394 additions & 21 deletions
Large diffs are not rendered by default.

dpd/p4/sidecar.p4

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -612,8 +612,16 @@ control NatIngress (
612612
}
613613

614614
// Separate table for IPv4 multicast packets that need to be encapsulated.
615+
// Groups without VLAN match untagged only. Groups with VLAN match both
616+
// untagged (for decapsulated Geneve from underlay) and correctly tagged.
617+
// Packets with wrong VLAN miss and are not NAT encapsulated.
618+
// When hdr.vlan.isValid()==false, vlan_id matches as 0.
615619
table ingress_ipv4_mcast {
616-
key = { hdr.ipv4.dst_addr : exact; }
620+
key = {
621+
hdr.ipv4.dst_addr : exact;
622+
hdr.vlan.isValid() : exact;
623+
hdr.vlan.vlan_id : exact;
624+
}
617625
actions = { mcast_forward_ipv4_to; }
618626
const size = IPV4_MULTICAST_TABLE_SIZE;
619627
counters = mcast_ipv4_ingress_ctr;
@@ -630,8 +638,16 @@ control NatIngress (
630638
}
631639

632640
// Separate table for IPv6 multicast packets that need to be encapsulated.
641+
// Groups without VLAN match untagged only. Groups with VLAN match both
642+
// untagged (for decapsulated Geneve from underlay) and correctly tagged.
643+
// Packets with wrong VLAN miss and are not NAT encapsulated.
644+
// When hdr.vlan.isValid()==false, vlan_id matches as 0.
633645
table ingress_ipv6_mcast {
634-
key = { hdr.ipv6.dst_addr : exact; }
646+
key = {
647+
hdr.ipv6.dst_addr : exact;
648+
hdr.vlan.isValid() : exact;
649+
hdr.vlan.vlan_id : exact;
650+
}
635651
actions = { mcast_forward_ipv6_to; }
636652
const size = IPV6_MULTICAST_TABLE_SIZE;
637653
counters = mcast_ipv6_ingress_ctr;
@@ -1311,7 +1327,9 @@ control MulticastRouter4(
13111327
apply {
13121328
// If the packet came in with a VLAN tag, we need to invalidate
13131329
// the VLAN header before we do the lookup. The VLAN header
1314-
// will be re-attached if set in the forward_vlan action.
1330+
// will be re-attached if set in the forward_vlan action (or
1331+
// untagged for groups without VLAN). This prevents unintended
1332+
// VLAN translation.
13151333
if (hdr.vlan.isValid()) {
13161334
hdr.ethernet.ether_type = hdr.vlan.ether_type;
13171335
hdr.vlan.setInvalid();
@@ -1449,7 +1467,9 @@ control MulticastRouter6 (
14491467
apply {
14501468
// If the packet came in with a VLAN tag, we need to invalidate
14511469
// the VLAN header before we do the lookup. The VLAN header
1452-
// will be re-attached if set in the forward_vlan action.
1470+
// will be re-attached if set in the forward_vlan action (or
1471+
// untagged for groups without VLAN). This prevents unintended
1472+
// VLAN translation.
14531473
if (hdr.vlan.isValid()) {
14541474
hdr.ethernet.ether_type = hdr.vlan.ether_type;
14551475
hdr.vlan.setInvalid();

dpd/src/counters.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// License, v. 2.0. If a copy of the MPL was not distributed with this
33
// file, You can obtain one at https://mozilla.org/MPL/2.0/
44
//
5-
// Copyright 2025 Oxide Computer Company
5+
// Copyright 2026 Oxide Computer Company
66

77
/// This module contains the support for reading the indirect counters defined
88
/// by the p4 program. While direct counters are attached to an existing table,
@@ -284,6 +284,7 @@ enum DropReason {
284284
GeneveOptionsTooLong,
285285
GeneveOptionMalformed,
286286
GeneveOptionUnknown,
287+
VlanMismatch,
287288
}
288289

289290
impl TryFrom<u8> for DropReason {
@@ -317,6 +318,7 @@ impl TryFrom<u8> for DropReason {
317318
23 => Ok(DropReason::GeneveOptionsTooLong),
318319
24 => Ok(DropReason::GeneveOptionMalformed),
319320
25 => Ok(DropReason::GeneveOptionUnknown),
321+
26 => Ok(DropReason::VlanMismatch),
320322
x => Err(format!("Unrecognized drop reason: {x}")),
321323
}
322324
}
@@ -343,8 +345,8 @@ fn reason_label(ctr: u8) -> Result<Option<String>, String> {
343345
DropReason::Ipv4TtlExceeded => "ipv4_ttl_exceeded".to_string(),
344346
DropReason::Ipv6TtlInvalid => "ipv6_ttl_invalid".to_string(),
345347
DropReason::Ipv6TtlExceeded => "ipv6_ttl_exceeded".to_string(),
346-
DropReason::Ipv4Unrouteable => "ipv6_unrouteable".to_string(),
347-
DropReason::Ipv6Unrouteable => "ipv4_unrouteable".to_string(),
348+
DropReason::Ipv4Unrouteable => "ipv4_unrouteable".to_string(),
349+
DropReason::Ipv6Unrouteable => "ipv6_unrouteable".to_string(),
348350
DropReason::NatIngressMiss => "nat_ingress_miss".to_string(),
349351
DropReason::MulticastNoGroup => "multicast_no_group".to_string(),
350352
DropReason::MulticastInvalidMac => "multicast_invalid_mac".to_string(),
@@ -362,6 +364,7 @@ fn reason_label(ctr: u8) -> Result<Option<String>, String> {
362364
"geneve_option_malformed".to_string()
363365
}
364366
DropReason::GeneveOptionUnknown => "geneve_option_unknown".to_string(),
367+
DropReason::VlanMismatch => "vlan_mismatch".to_string(),
365368
};
366369
Ok(Some(label))
367370
}

dpd/src/mcast/mod.rs

Lines changed: 116 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,7 @@ pub(crate) fn add_group_external(
351351
scoped_underlay_id.id(),
352352
nat_target,
353353
group_info.sources.as_deref(),
354+
group_info.external_forwarding.vlan_id,
354355
);
355356

356357
// Configure external tables and handle VLAN propagation
@@ -656,8 +657,12 @@ pub(crate) fn modify_group_external(
656657

657658
// Create rollback context for external group update
658659
let group_entry_for_rollback = group_entry.clone();
659-
let rollback_ctx =
660-
GroupUpdateRollbackContext::new(s, group_ip, &group_entry_for_rollback);
660+
let rollback_ctx = GroupUpdateRollbackContext::new(
661+
s,
662+
group_ip,
663+
&group_entry_for_rollback,
664+
new_group_info.external_forwarding.vlan_id,
665+
);
661666

662667
// Pre-compute normalized sources for rollback purposes
663668
let normalized_sources = normalize_sources(new_group_info.sources.clone());
@@ -693,10 +698,9 @@ pub(crate) fn modify_group_external(
693698
updated_group.int_fwding.nat_target = Some(nat_target);
694699

695700
let old_vlan_id = updated_group.ext_fwding.vlan_id;
696-
updated_group.ext_fwding.vlan_id = new_group_info
697-
.external_forwarding
698-
.vlan_id
699-
.or(updated_group.ext_fwding.vlan_id);
701+
// VLAN is assigned directly -> Some(x) sets VLAN, None removes VLAN
702+
updated_group.ext_fwding.vlan_id =
703+
new_group_info.external_forwarding.vlan_id;
700704
updated_group.sources = normalize_sources(
701705
new_group_info.sources.clone().or(updated_group.sources),
702706
);
@@ -789,9 +793,11 @@ pub(crate) fn modify_group_internal(
789793
s,
790794
group_ip.into(),
791795
&group_entry_for_rollback,
796+
// Internal groups don't have VLANs, so `attempted_vlan_id` is None.
797+
None,
792798
);
793799

794-
// Internal groups don't update sources - always `None`
800+
// Internal groups don't update sources, so always `None`
795801
debug_assert!(
796802
group_entry.sources.is_none(),
797803
"Internal groups should not have sources"
@@ -1182,7 +1188,7 @@ pub(super) fn sources_contain_any(sources: &[IpSrc]) -> bool {
11821188
/// This ensures uniqueness across the group's lifecycle and prevents
11831189
/// tag collision when group IPs are reused after deletion.
11841190
fn generate_default_tag(group_ip: IpAddr) -> String {
1185-
format!("{}:{}", Uuid::new_v4(), group_ip)
1191+
format!("{}:{group_ip}", Uuid::new_v4())
11861192
}
11871193

11881194
/// Multiple representations map to "allow any source" in P4:
@@ -1357,12 +1363,17 @@ fn configure_external_tables(
13571363
add_source_filters(s, group_ip, group_info.sources.as_deref())?;
13581364

13591365
// Add NAT entry
1366+
let vlan_id = group_info.external_forwarding.vlan_id;
13601367
match group_ip {
13611368
IpAddr::V4(ipv4) => {
1362-
table::mcast::mcast_nat::add_ipv4_entry(s, ipv4, nat_target)?;
1369+
table::mcast::mcast_nat::add_ipv4_entry(
1370+
s, ipv4, nat_target, vlan_id,
1371+
)?;
13631372
}
13641373
IpAddr::V6(ipv6) => {
1365-
table::mcast::mcast_nat::add_ipv6_entry(s, ipv6, nat_target)?;
1374+
table::mcast::mcast_nat::add_ipv6_entry(
1375+
s, ipv6, nat_target, vlan_id,
1376+
)?;
13661377
}
13671378
}
13681379

@@ -1688,39 +1699,46 @@ fn update_external_tables(
16881699
add_source_filters(s, group_ip, new_sources.as_deref())?;
16891700
}
16901701

1702+
let old_vlan_id = group_entry.ext_fwding.vlan_id;
1703+
let new_vlan_id = new_group_info.external_forwarding.vlan_id;
1704+
16911705
// Update NAT target - external groups always have NAT targets
1692-
if Some(new_group_info.internal_forwarding.nat_target.ok_or_else(|| {
1693-
DpdError::Invalid("external groups must have NAT target".to_string())
1694-
})?) != group_entry.int_fwding.nat_target
1706+
// Also handles VLAN changes since VLAN is part of the NAT match key
1707+
let new_nat_target =
1708+
new_group_info.internal_forwarding.nat_target.ok_or_else(|| {
1709+
DpdError::Invalid(
1710+
"external groups must have NAT target".to_string(),
1711+
)
1712+
})?;
1713+
1714+
if Some(new_nat_target) != group_entry.int_fwding.nat_target
1715+
|| old_vlan_id != new_vlan_id
16951716
{
16961717
update_nat_tables(
16971718
s,
16981719
group_ip,
1699-
Some(new_group_info.internal_forwarding.nat_target.ok_or_else(
1700-
|| {
1701-
DpdError::Invalid(
1702-
"external groups must have NAT target".to_string(),
1703-
)
1704-
},
1705-
)?),
1720+
Some(new_nat_target),
17061721
group_entry.int_fwding.nat_target,
1722+
old_vlan_id,
1723+
new_vlan_id,
17071724
)?;
17081725
}
17091726

1710-
// Update VLAN if it changed
1711-
if new_group_info.external_forwarding.vlan_id
1712-
!= group_entry.ext_fwding.vlan_id
1713-
{
1727+
// Update route table VLAN if it changed
1728+
// Route tables use simple dst_addr matching but select forward vs forward_vlan action
1729+
if old_vlan_id != new_vlan_id {
17141730
match group_ip {
17151731
IpAddr::V4(ipv4) => table::mcast::mcast_route::update_ipv4_entry(
17161732
s,
17171733
ipv4,
1718-
new_group_info.external_forwarding.vlan_id,
1734+
old_vlan_id,
1735+
new_vlan_id,
17191736
),
17201737
IpAddr::V6(ipv6) => table::mcast::mcast_route::update_ipv6_entry(
17211738
s,
17221739
ipv6,
1723-
new_group_info.external_forwarding.vlan_id,
1740+
old_vlan_id,
1741+
new_vlan_id,
17241742
),
17251743
}?;
17261744
}
@@ -1811,7 +1829,11 @@ fn delete_group_tables(
18111829
// (which have NAT targets).
18121830
if group.int_fwding.nat_target.is_some() {
18131831
remove_ipv4_source_filters(s, ipv4, group.sources.as_deref())?;
1814-
table::mcast::mcast_nat::del_ipv4_entry(s, ipv4)?;
1832+
table::mcast::mcast_nat::del_ipv4_entry(
1833+
s,
1834+
ipv4,
1835+
group.ext_fwding.vlan_id,
1836+
)?;
18151837
}
18161838

18171839
delete_group_bitmap_entries(s, group)?;
@@ -1825,7 +1847,11 @@ fn delete_group_tables(
18251847
// NAT targets). Internal groups don't have source filtering.
18261848
if group.int_fwding.nat_target.is_some() {
18271849
remove_ipv6_source_filters(s, ipv6, group.sources.as_deref())?;
1828-
table::mcast::mcast_nat::del_ipv6_entry(s, ipv6)?;
1850+
table::mcast::mcast_nat::del_ipv6_entry(
1851+
s,
1852+
ipv6,
1853+
group.ext_fwding.vlan_id,
1854+
)?;
18291855
}
18301856

18311857
delete_group_bitmap_entries(s, group)?;
@@ -1863,31 +1889,47 @@ fn update_nat_tables(
18631889
group_ip: IpAddr,
18641890
new_nat_target: Option<NatTarget>,
18651891
old_nat_target: Option<NatTarget>,
1892+
old_vlan_id: Option<u16>,
1893+
new_vlan_id: Option<u16>,
18661894
) -> DpdResult<()> {
18671895
match (group_ip, new_nat_target, old_nat_target) {
1868-
(IpAddr::V4(ipv4), Some(nat), Some(_)) => {
1869-
// NAT to NAT - update existing entry
1870-
table::mcast::mcast_nat::update_ipv4_entry(s, ipv4, nat)
1896+
(IpAddr::V4(ipv4), Some(new_nat), Some(old_nat)) => {
1897+
// NAT to NAT - update existing entry (handles VLAN changes internally)
1898+
table::mcast::mcast_nat::update_ipv4_entry(
1899+
s,
1900+
ipv4,
1901+
new_nat,
1902+
old_nat,
1903+
old_vlan_id,
1904+
new_vlan_id,
1905+
)
18711906
}
1872-
(IpAddr::V6(ipv6), Some(nat), Some(_)) => {
1873-
// NAT to NAT - update existing entry
1874-
table::mcast::mcast_nat::update_ipv6_entry(s, ipv6, nat)
1907+
(IpAddr::V6(ipv6), Some(new_nat), Some(old_nat)) => {
1908+
// NAT to NAT - update existing entry (handles VLAN changes internally)
1909+
table::mcast::mcast_nat::update_ipv6_entry(
1910+
s,
1911+
ipv6,
1912+
new_nat,
1913+
old_nat,
1914+
old_vlan_id,
1915+
new_vlan_id,
1916+
)
18751917
}
18761918
(IpAddr::V4(ipv4), Some(nat), None) => {
18771919
// No NAT to NAT - add new entry
1878-
table::mcast::mcast_nat::add_ipv4_entry(s, ipv4, nat)
1920+
table::mcast::mcast_nat::add_ipv4_entry(s, ipv4, nat, new_vlan_id)
18791921
}
18801922
(IpAddr::V6(ipv6), Some(nat), None) => {
18811923
// No NAT to NAT - add new entry
1882-
table::mcast::mcast_nat::add_ipv6_entry(s, ipv6, nat)
1924+
table::mcast::mcast_nat::add_ipv6_entry(s, ipv6, nat, new_vlan_id)
18831925
}
18841926
(IpAddr::V4(ipv4), None, Some(_)) => {
18851927
// NAT to no NAT - delete entry
1886-
table::mcast::mcast_nat::del_ipv4_entry(s, ipv4)
1928+
table::mcast::mcast_nat::del_ipv4_entry(s, ipv4, old_vlan_id)
18871929
}
18881930
(IpAddr::V6(ipv6), None, Some(_)) => {
18891931
// NAT to no NAT - delete entry
1890-
table::mcast::mcast_nat::del_ipv6_entry(s, ipv6)
1932+
table::mcast::mcast_nat::del_ipv6_entry(s, ipv6, old_vlan_id)
18911933
}
18921934
_ => Ok(()), // No change (None → None)
18931935
}
@@ -1934,33 +1976,49 @@ fn update_internal_group_bitmap_tables(
19341976
/// Only updates the external bitmap entry since that's the only bitmap
19351977
/// entry created during group configuration. The underlay replication
19361978
/// is handled separately via the ASIC's multicast group primitives.
1979+
///
1980+
/// # Arguments
1981+
///
1982+
/// * `s` - Switch instance for table operations.
1983+
/// * `group_ip` - IP address of the multicast group.
1984+
/// * `external_group_id` - ID of the external multicast group for bitmap updates.
1985+
/// * `members` - Group members used to recreate port bitmap.
1986+
/// * `current_vlan_id` - VLAN currently in the table (may be the attempted new VLAN).
1987+
/// * `target_vlan_id` - VLAN to restore to (the original VLAN).
19371988
fn update_fwding_tables(
19381989
s: &Switch,
19391990
group_ip: IpAddr,
19401991
external_group_id: MulticastGroupId,
19411992
members: &[MulticastGroupMember],
1942-
vlan_id: Option<u16>,
1993+
current_vlan_id: Option<u16>,
1994+
target_vlan_id: Option<u16>,
19431995
) -> DpdResult<()> {
19441996
match group_ip {
1945-
IpAddr::V4(ipv4) => {
1946-
table::mcast::mcast_route::update_ipv4_entry(s, ipv4, vlan_id)
1947-
}
1948-
IpAddr::V6(ipv6) => {
1949-
table::mcast::mcast_route::update_ipv6_entry(s, ipv6, vlan_id)
1950-
.and_then(|_| {
1951-
// Update external bitmap for external members
1952-
// (only external bitmap entries exist; underlay replication
1953-
// uses ASIC multicast groups directly)
1954-
let external_port_bitmap =
1955-
create_port_bitmap(members, Direction::External);
1956-
table::mcast::mcast_egress::update_bitmap_entry(
1957-
s,
1958-
external_group_id,
1959-
&external_port_bitmap,
1960-
vlan_id,
1961-
)
1962-
})
1963-
}
1997+
IpAddr::V4(ipv4) => table::mcast::mcast_route::update_ipv4_entry(
1998+
s,
1999+
ipv4,
2000+
current_vlan_id,
2001+
target_vlan_id,
2002+
),
2003+
IpAddr::V6(ipv6) => table::mcast::mcast_route::update_ipv6_entry(
2004+
s,
2005+
ipv6,
2006+
current_vlan_id,
2007+
target_vlan_id,
2008+
)
2009+
.and_then(|_| {
2010+
// Update external bitmap for external members
2011+
// (only external bitmap entries exist, underlay replication
2012+
// uses ASIC multicast groups directly)
2013+
let external_port_bitmap =
2014+
create_port_bitmap(members, Direction::External);
2015+
table::mcast::mcast_egress::update_bitmap_entry(
2016+
s,
2017+
external_group_id,
2018+
&external_port_bitmap,
2019+
target_vlan_id,
2020+
)
2021+
}),
19642022
}
19652023
}
19662024

0 commit comments

Comments
 (0)