Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 52 additions & 1 deletion crates/opte-api/src/ip.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.

// Copyright 2025 Oxide Computer Company
// Copyright 2026 Oxide Computer Company

use super::mac::MacAddr;
use crate::DomainName;
Expand Down Expand Up @@ -527,6 +527,28 @@ impl Ipv4Addr {
self.inner[0] == 169 && self.inner[1] == 254
}

/// Returns true if this is in the "this host on this network" block
/// (0.0.0.0/8).
///
/// [RFC 1122 §3.2.1.3] allows a host to use these before it learns its
/// address, so this is broader than [`Ipv4Addr::is_unspecified`].
///
/// [RFC 1122 §3.2.1.3]: https://www.rfc-editor.org/rfc/rfc1122#section-3.2.1.3
pub const fn is_this_network(&self) -> bool {
self.inner[0] == 0
}

/// Returns true if this is in the reserved class E block (240.0.0.0/4).
///
/// The IANA special-purpose registry ([RFC 6890]) marks the block,
/// reserved by [RFC 1112 §4], as "Source: False".
///
/// [RFC 6890]: https://www.rfc-editor.org/rfc/rfc6890
/// [RFC 1112 §4]: https://www.rfc-editor.org/rfc/rfc1112#section-4
pub const fn is_reserved(&self) -> bool {
self.inner[0] >= 240
}

/// Return the multicast MAC address associated with this multicast IPv4
/// address. If the IPv4 address is not multicast, None will be returned.
///
Expand Down Expand Up @@ -793,6 +815,35 @@ impl Ipv6Addr {
self.inner[0] == 0xfe && (self.inner[1] & 0xc0) == 0x80
}

/// Returns a description of the embedded-IPv4 form this address takes, or
/// `None` if it embeds no IPv4 address.
///
/// The IPv4-mapped ([RFC 4291 §2.5.5.2]) and IPv4-compatible
/// ([RFC 4291 §2.5.5.1]) forms convert to an IPv4 address and so would
/// carry IPv4 semantics past any IPv4 checks, while the NAT64 well-known
/// prefix ([RFC 6052 §2.1]) does not convert. [RFC 6052 §3.1]
/// network-specific prefixes are drawn from the operator's own address
/// space and cannot be recognized without knowing the configured prefix.
///
/// [RFC 4291 §2.5.5.1]: https://www.rfc-editor.org/rfc/rfc4291#section-2.5.5.1
/// [RFC 4291 §2.5.5.2]: https://www.rfc-editor.org/rfc/rfc4291#section-2.5.5.2
/// [RFC 6052 §2.1]: https://www.rfc-editor.org/rfc/rfc6052#section-2.1
/// [RFC 6052 §3.1]: https://www.rfc-editor.org/rfc/rfc6052#section-3.1
pub const fn embedded_ipv4_form(&self) -> Option<&'static str> {
match self.inner {
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, ..] => {
Some("IPv4-mapped (::ffff:0:0/96, RFC 4291 §2.5.5.2)")
}
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ..] => {
Some("IPv4-compatible (::/96, RFC 4291 §2.5.5.1)")
}
[0x00, 0x64, 0xff, 0x9b, 0, 0, 0, 0, 0, 0, 0, 0, ..] => {
Some("NAT64 well-known (64:ff9b::/96, RFC 6052 §2.1)")
}
_ => None,
}
}

/// Return `true` if this is a multicast IPv6 address with the ff04::/16 prefix
/// (admin-local scope with flags=0) as used by Omicron for underlay multicast.
///
Expand Down
2 changes: 1 addition & 1 deletion crates/opte-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ pub use ulp::*;
///
/// We rely on CI and the check-api-version.sh script to verify that
/// this number is incremented anytime the oxide-api code changes.
pub const API_VERSION: u64 = 41;
pub const API_VERSION: u64 = 42;

/// Major version of the OPTE package.
pub const MAJOR_VERSION: u64 = 0;
Expand Down
130 changes: 130 additions & 0 deletions lib/oxide-vpc/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -979,6 +979,67 @@ impl SourceFilter {
SourceFilter::Include(s) | SourceFilter::Exclude(s) => s,
}
}

/// Validate that every `Include` source is fit to serve as an (S,G)
/// source.
///
/// Both the subscribe and forwarding paths accept operator-supplied
/// source lists, so both validate here rather than each carrying its own
/// rules.
///
/// `Exclude` sets are left unchecked. Their entries name traffic to drop,
/// and an address that could never be a legitimate source may still be
/// one an operator wants to block.
///
/// # Errors
///
/// Returns a message naming the rejected address and the reason.
pub fn validate_sources(&self) -> Result<(), String> {
let SourceFilter::Include(sources) = self else {
return Ok(());
};
for src in sources {
if let Some(reason) = invalid_multicast_source(*src) {
return Err(format!("source filter address {src} {reason}"));
}
}
Ok(())
}
}

/// Returns the reason `src` is unfit to serve as a multicast (S,G) source, or
/// `None` if it is acceptable.
///
/// The rule set matches the source validators in dpd, nexus, and mgd. Shared
/// address space (100.64.0.0/10, [RFC 6598]) is permitted on purpose: it can
/// source traffic inside an operator network.
///
/// [RFC 6598]: https://www.rfc-editor.org/rfc/rfc6598
fn invalid_multicast_source(src: IpAddr) -> Option<String> {
if src.is_multicast() {
return Some("is multicast".to_string());
}

if src.is_unspecified()
|| src.is_loopback()
|| src.is_broadcast()
|| src.is_link_local()
{
return Some("is a special-use address".to_string());
}

match src {
IpAddr::Ip4(v4) if v4.is_this_network() => {
Some("is in 0.0.0.0/8 (this host on this network)".to_string())
}
IpAddr::Ip4(v4) if v4.is_reserved() => {
Some("is in the reserved class E block (240.0.0.0/4)".to_string())
}
IpAddr::Ip4(_) => None,
IpAddr::Ip6(v6) => v6
.embedded_ipv4_form()
.map(|form| format!("embeds an IPv4 address, {form}")),
}
}

/// Subscribe a port to a multicast group.
Expand Down Expand Up @@ -1499,6 +1560,75 @@ impl opte::api::cmd::CmdOk for DetachSubnetResp {}
pub mod tests {
use super::*;

/// Build an `Include` filter holding a single source.
fn filter_with(src: IpAddr) -> SourceFilter {
SourceFilter::Include(BTreeSet::from([src]))
}

#[test]
fn validate_sources_accepts_ordinary_unicast() {
for src in [
IpAddr::Ip4("192.168.1.1".parse().unwrap()),
// Shared address space (100.64.0.0/10, RFC 6598) can source
// traffic inside an operator network.
IpAddr::Ip4("100.64.0.1".parse().unwrap()),
// The class E boundary is exact.
IpAddr::Ip4("223.255.255.255".parse().unwrap()),
IpAddr::Ip6("2001:db8::1".parse().unwrap()),
// A NAT64 network-specific prefix that starts with 64:ff9b but is
// not the well-known /96.
IpAddr::Ip6("64:ff9b:0:1::c000:201".parse().unwrap()),
] {
assert!(
filter_with(src).validate_sources().is_ok(),
"{src} should be accepted as a source"
);
}
}

#[test]
fn validate_sources_rejects_unfit_addresses() {
for src in [
IpAddr::Ip4("224.1.1.1".parse().unwrap()),
IpAddr::Ip4("127.0.0.1".parse().unwrap()),
IpAddr::Ip4("255.255.255.255".parse().unwrap()),
IpAddr::Ip4("169.254.1.1".parse().unwrap()),
// 0.0.0.0/8, this host on this network, RFC 1122 §3.2.1.3
IpAddr::Ip4("0.0.0.0".parse().unwrap()),
IpAddr::Ip4("0.1.2.3".parse().unwrap()),
// 240.0.0.0/4, class E, RFC 1112 §4
IpAddr::Ip4("240.0.0.1".parse().unwrap()),
IpAddr::Ip4("255.255.255.254".parse().unwrap()),
IpAddr::Ip6("ff0e::1".parse().unwrap()),
IpAddr::Ip6("::1".parse().unwrap()),
IpAddr::Ip6("fe80::1".parse().unwrap()),
// ::ffff:192.0.2.1, RFC 4291 §2.5.5.2
IpAddr::Ip6("::ffff:c000:201".parse().unwrap()),
// ::192.0.2.1, RFC 4291 §2.5.5.1
IpAddr::Ip6("::c000:201".parse().unwrap()),
// 64:ff9b::192.0.2.1, RFC 6052 §2.1
IpAddr::Ip6("64:ff9b::c000:201".parse().unwrap()),
] {
assert!(
filter_with(src).validate_sources().is_err(),
"{src} should be rejected as a source"
);
}
}

/// An `Exclude` set names traffic to drop, so its entries are not held to
/// the (S,G) source rules. An address that could never be a legitimate
/// source may still be one an operator wants to block.
#[test]
fn validate_sources_ignores_exclude_sets() {
let filter = SourceFilter::Exclude(BTreeSet::from([
IpAddr::Ip4("0.1.2.3".parse().unwrap()),
IpAddr::Ip4("240.0.0.1".parse().unwrap()),
IpAddr::Ip6("fe80::1".parse().unwrap()),
]));
assert!(filter.validate_sources().is_ok());
}

#[test]
fn ports_from_str_good() {
assert_eq!("AnY".parse::<Ports>(), Ok(Ports::Any));
Expand Down
23 changes: 7 additions & 16 deletions xde/src/xde.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4113,6 +4113,12 @@ fn set_mcast_forwarding_hdlr(
});
}

// The aggregated source filter is operator-supplied, so it is held to
// the same rules as a subscriber's filter.
if let Err(msg) = entry.source_filter.validate_sources() {
return Err(OpteError::System { errno: EINVAL, msg });
}

// Reject `Reserved`. It serves no replication target, so the Tx-side
// selection never picks such a hop and an accepted one would silently
// drop the group's traffic with no telemetry.
Expand Down Expand Up @@ -4293,23 +4299,8 @@ fn mcast_subscribe_hdlr(env: &mut IoctlEnvelope) -> Result<NoResp, OpteError> {
}

// Validate source filter: sources must contain valid unicast addresses
for src in req.filter.sources() {
if src.is_multicast() {
return Err(OpteError::BadState(format!(
"source filter address {src} is multicast"
)));
}
req.filter.validate_sources().map_err(OpteError::BadState)?;

if src.is_unspecified()
|| src.is_loopback()
|| src.is_broadcast()
|| src.is_link_local()
{
return Err(OpteError::BadState(format!(
"source filter address {src} is a special-use address"
)));
}
}
let group_key = match req.group {
oxide_vpc::api::IpAddr::Ip6(ip6) => {
// If an overlay->underlay mapping exists, use it; otherwise, if the
Expand Down