From cb4cd0d5ee5a0169de3a34a3352a95f01d5547eb Mon Sep 17 00:00:00 2001 From: Zeeshan Lakhani Date: Wed, 27 May 2026 02:40:28 +0000 Subject: [PATCH 1/4] [multicast, smf] validate SSM joins and rework the joiner service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We reject a source-less join on an SSM-range group (232.0.0.0/8, ff30::/12, RFC 4607 §1) at CLI validation. SSM defines no shared (*, G) tree, so such a join installs no usable listener state and could look like a network regression. Classification lives in ssm.rs and mirrors Nexus's is_ssm_address. The SMF manifest's start method pointed at mgd_method_script.sh, a copy-paste path nothing installs, so the service flapped between incomplete and running. It now points at method_script.sh (matching package-manifest.toml), and the script is reworked to spawn one joiner per configured multicast group (SSM encoded as group@src1,src2), pinning joins via multicast_iface (IPv4) and a new ipv6_scope property. The service idles when no groups are configured and restarts through the ctrun contract when any joiner exits, re-establishing every membership. --- .gitignore | 1 + app/src/main.rs | 23 ++++++ app/src/ssm.rs | 50 ++++++++++++- smf/method_script.sh | 131 ++++++++++++++++++++++++++++++--- smf/thundermuffin/manifest.xml | 5 +- 5 files changed, 198 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index cd63e4f..67a9b82 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /target *.sw* out +pkg/ diff --git a/app/src/main.rs b/app/src/main.rs index a69e3fb..c5d600f 100644 --- a/app/src/main.rs +++ b/app/src/main.rs @@ -128,6 +128,29 @@ impl Server { cmd: &mut clap::Command, ) -> Result<(), clap::Error> { if self.multicast_source.is_empty() { + // An any-source (*, G) join on an SSM-range group receives no + // traffic. SSM defines no shared (*, G) tree: the host rules + // (RFC 4607 §4.1) reject a source-less join to an SSM destination, + // and Nexus only ever programs an SSM-range group as an (S, G) + // channel. The fabric forwards via a control-plane subscription + // rather than by snooping the guest's join, so a source-less join + // installs no usable reception state and yields a silent + // zero-delivery receive indistinguishable from a network + // regression. + // + // We reject it so that the mismatch surfaces as a CLI error. + if ssm::is_ssm_multicast(self.listen) { + return Err(cmd.error( + ErrorKind::MissingRequiredArgument, + format!( + "`listen` {} is in the source-specific multicast (SSM) \ + range (232.0.0.0/8, ff30::/12) but no sources were \ + supplied; an any-source join is undeliverable. Pass \ + `--multicast-source` for an INCLUDE-mode (S, G) join", + self.listen + ), + )); + } return Ok(()); } if !self.listen.is_multicast() { diff --git a/app/src/ssm.rs b/app/src/ssm.rs index 3272311..a65a33f 100644 --- a/app/src/ssm.rs +++ b/app/src/ssm.rs @@ -1,4 +1,5 @@ -//! Source-specific multicast (SSM) functionality that isn't covered by `socket2`. +//! Source-specific multicast (SSM) support: group classification and joins +//! not covered by `socket2`. //! //! `socket2` exposes `Socket::join_ssm_v4` for IPv4 SSM joins via //! `IP_ADD_SOURCE_MEMBERSHIP`, but has no IPv6 equivalent in any current @@ -8,9 +9,30 @@ //! [`setsourcefilter(3SOCKET)`]: https://illumos.org/man/3SOCKET/setsourcefilter use socket2::Socket; -use std::net::Ipv6Addr; +use std::net::{IpAddr, Ipv6Addr}; use std::os::fd::AsRawFd; +/// Whether `addr` is in the source-specific multicast (SSM) range: +/// `232.0.0.0/8` for IPv4 and `ff30::/12` for IPv6 (flags nibble `3`, +/// referring to any scope), per [RFC 4607][rfc4607] §1. An SSM group builds no +/// shared `(*, G)` tree, so it is reachable only through an INCLUDE-mode +/// `(S, G)` join. +/// +/// The ranges mirror Nexus's canonical `is_ssm_address`, so a probe's in-zone +/// join classifies a group the same way the control plane does when it +/// programs the group's forwarding tables. +/// +/// [rfc4607]: https://datatracker.ietf.org/doc/html/rfc4607 +pub fn is_ssm_multicast(addr: IpAddr) -> bool { + match addr { + IpAddr::V4(v4) => v4.octets()[0] == 232, + IpAddr::V6(v6) => { + let octets = v6.octets(); + octets[0] == 0xff && octets[1] >> 4 == 0x3 + } + } +} + // RFC 3678 / POSIX include-mode source filter. Standardized as `1` on every // platform that exposes `setsourcefilter`. const MCAST_INCLUDE: u32 = 1; @@ -97,3 +119,27 @@ pub fn join_ssm_v6( } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ssm_classification_matches_rfc4607_ranges() { + // IPv4: only 232.0.0.0/8 is SSM. The adjacent ASM and link-local + // ranges are not. + assert!(is_ssm_multicast("232.0.0.1".parse().unwrap())); + assert!(is_ssm_multicast("232.255.255.255".parse().unwrap())); + assert!(!is_ssm_multicast("231.255.255.255".parse().unwrap())); + assert!(!is_ssm_multicast("233.0.0.1".parse().unwrap())); + assert!(!is_ssm_multicast("239.1.2.3".parse().unwrap())); + assert!(!is_ssm_multicast("224.0.0.1".parse().unwrap())); + + // IPv6: ff30::/12 (flags nibble 3) is SSM at every scope. Other + // multicast flag/scope combinations are not. + assert!(is_ssm_multicast("ff3e::1".parse().unwrap())); + assert!(is_ssm_multicast("ff35::1234".parse().unwrap())); + assert!(!is_ssm_multicast("ff0e::1".parse().unwrap())); + assert!(!is_ssm_multicast("ff02::1".parse().unwrap())); + } +} diff --git a/smf/method_script.sh b/smf/method_script.sh index cf2a834..8595453 100755 --- a/smf/method_script.sh +++ b/smf/method_script.sh @@ -1,18 +1,131 @@ #!/bin/bash +# +# SMF start method for the thundermuffin membership service. +# +# The service holds multicast group memberships open on behalf of a zone that +# has no listening application of its own. For each configured group it spawns +# a "joiner": a `thundermuffin server` process whose only purpose is to keep +# the kernel's IGMP/MLD membership for that group established on the pinned +# interface. The illumos IP stack accepts (and answers ICMP echo for) only +# locally joined groups, so the joiners supply the membership state the +# zone's stack needs to accept group traffic. Forwarding to the port is +# programmed separately by the control plane. +# +# This must run inside the zone: IGMP/MLD membership is per-netstack state, +# and a zone with an exclusive IP stack cannot receive it from outside. The +# sled-agent, as a global-zone process, can only join groups in the GZ +# netstack. The joiners stand in for the guest workload that performs the +# in-guest join on actual instances. +# +# The script blocks on the joiners to keep the start method's process +# contract populated. If any joiner dies the whole instance restarts and +# every membership is re-established. set -o errexit set -o pipefail +set -o nounset export RUST_LOG=info -args=( - --port "$(svcprop -c -p config/port "${SMF_FMRI}")" -) - -if [[ -e /opt/oxide/thundermuffin/bin/thundermuffin ]]; -then - # mgd.tar.gz gets the binaries at /opt/oxide/mgd/bin/ - exec /opt/oxide/thundermuffin/bin/thundermuffin run "${args[@]}" -else +BIN=/opt/oxide/thundermuffin/bin/thundermuffin +if [[ ! -x "$BIN" ]]; then + echo "thundermuffin binary not found at $BIN" >&2 exit 1 fi + +port=$(svcprop -c -p config/port "${SMF_FMRI}") + +# IPv4 address bound to the interface the joins should be pinned to (the +# probe's overlay port). For multicast this becomes IP_MULTICAST_IF and, for +# SSM, pins the source-specific membership to that interface. +# +# Note: this may be unset. +iface=$(svcprop -c -p config/multicast_iface "${SMF_FMRI}" 2>/dev/null || true) +if [[ "$iface" == '""' ]]; then + iface="" +fi + +# Multi-valued list of multicast groups to join and hold open. The illumos IP +# stack only accepts (and only answers ICMP echo for) a group the interface +# has locally joined, so a probe with no listening application needs this +# long-lived membership to respond to reachability probes. Forwarding to the +# port is programmed statically by the control plane, as this is solely the +# host-local membership gate. +groups=$(svcprop -c -p config/multicast_group "${SMF_FMRI}" 2>/dev/null || true) +if [[ "$groups" == '""' ]]; then + groups="" +fi + +# Interface index (ifindex) the IPv6 joins should be pinned to. IPv6 has no +# address-based IP_MULTICAST_IF equivalent, so the binary takes a numeric +# `--scope` instead (IPV6_MULTICAST_IF, and the SSM join interface). A value +# of 0 means kernel-selected and is the binary's default, so it is elided. +ipv6_scope=$(svcprop -c -p config/ipv6_scope "${SMF_FMRI}" 2>/dev/null || echo 0) + +iface_args=() +if [[ -n "$iface" ]]; then + iface_args=(--multicast-iface "$iface") +fi +if [[ "$ipv6_scope" != 0 ]]; then + iface_args+=(--scope "$ipv6_scope") +fi + +if [[ -z "$groups" ]]; then + # No groups configured. Stay online but idle so the service does not + # flap. The sled-agent rewrites config and restarts the service when + # membership changes. + echo "no multicast groups configured; idling" >&2 + exec sleep infinity +fi + +# Spawn one joiner per group. +pids=() +for entry in $groups; do + # svcprop -c quotes each astring value, so strip the surrounding quotes. + entry=${entry#\"} + entry=${entry%\"} + [[ -z "$entry" ]] && continue + # sled-agent encodes a source-specific (SSM) membership as + # `group@src1,src2` and an any-source (ASM) membership as a bare `group`. + # The `@` separator is not a shell metacharacter, so svcprop emits it + # verbatim. + # + # We split off the optional source list and pass one `--multicast-source` + # per source so an SSM group gets the INCLUDE-mode (S, G) join it requires. + group=${entry%%@*} + src_args=() + if [[ "$entry" == *"@"* ]]; then + sources=${entry#*@} + IFS=',' read -ra src_list <<< "$sources" + for src in "${src_list[@]}"; do + [[ -z "$src" ]] && continue + src_args+=(--multicast-source "$src") + done + fi + "$BIN" --transport udp --port "$port" "${iface_args[@]}" \ + server "$group" "${src_args[@]}" & + pids+=("$!") +done + +# If every entry stripped to an empty string (e.g., a multi-valued property +# holding only empty values), no joiners would be started. We remain idle +# instead of calling `wait -n` with no pids, which returns 127 and would +# restart-loop the service. +if ((${#pids[@]} == 0)); then + echo "no non-empty multicast groups configured; idling" >&2 + exec sleep infinity +fi + +# Block on the joiners to keep the start method's process contract (created +# by ctrun in the manifest) populated. If any joiner exits, this script exits +# too. ctrun's noorphan option then kills the surviving joiners, the contract +# empties, and svc.startd restarts the instance, re-establishing every +# membership. +# +# The explicit exit covers the clean-exit path. On a nonzero status, errexit +# already terminates the script at the wait itself. Either way, the script +# exits with the first-reaped joiner's status, which only surfaces in the +# service log. The restart is driven by the emptied contract (startd/duration), +# not by the status. +wait -n "${pids[@]}" +exit $? diff --git a/smf/thundermuffin/manifest.xml b/smf/thundermuffin/manifest.xml index 86d4ff4..c5f4644 100644 --- a/smf/thundermuffin/manifest.xml +++ b/smf/thundermuffin/manifest.xml @@ -11,12 +11,15 @@ + + + From a134244b4a9745b182480ec01808c18208ed1f45 Mon Sep 17 00:00:00 2001 From: Zeeshan Lakhani Date: Fri, 24 Jul 2026 08:06:25 +0000 Subject: [PATCH 2/4] [review] address feedback + select multicast interfaces by name or address First set of work is replacing the IPv4-only `--multicast-iface` address with an `InterfaceSelector` that accepts an interface name or an IP bound to one, resolved per group family. IPv4 groups use the interface's address for `IP_MULTICAST_IF` and joins. IPv6 groups use its `ifindex` for `IPV6_MULTICAST_IF` and joins, and as the bind scope for interface- and link-local groups. Ambiguous address selectors resolve to the first matching interface. `--scope` is now unicast-only, and the `ipv6_scope` SMF property has been removed. SSM validation collapses to a single `--multicast-iface` requirement. The method script reads one interface property and returns early when no groups are configured. Finally, this also addresses the review feedback around method-script checks, control flow, property handling, and header documentation. --- app/src/main.rs | 45 ++++---- app/src/ssm.rs | 15 ++- app/src/udp.rs | 59 ++++++++-- app/src/util.rs | 204 +++++++++++++++++++++++++++++++++ smf/method_script.sh | 66 ++++++----- smf/thundermuffin/manifest.xml | 1 - 6 files changed, 321 insertions(+), 69 deletions(-) diff --git a/app/src/main.rs b/app/src/main.rs index c5d600f..1ea68c6 100644 --- a/app/src/main.rs +++ b/app/src/main.rs @@ -8,6 +8,8 @@ mod tcp; mod udp; mod util; +use util::InterfaceSelector; + /// A program to send muffins from one computer to another. #[derive(Parser, Debug)] #[command(version, about)] @@ -20,9 +22,8 @@ struct Cli { #[arg(short, long, default_value_t = 4747)] port: u16, - /// Scope (zone index) to use for IPv6 targets. Also used as the outgoing - /// `IPV6_MULTICAST_IF` interface index when the destination/listen address - /// is an IPv6 multicast address. + /// Scope (zone index) to use for IPv6 unicast targets. For multicast + /// use `--multicast-iface` instead. #[arg(short, long, default_value_t = 0)] scope: u32, @@ -47,13 +48,18 @@ struct Cli { #[arg(long)] multicast_loop: bool, - /// Outgoing `IP_MULTICAST_IF` for IPv4 multicast, expressed as the - /// IPv4 address bound to the desired interface. For IPv6 use - /// `--scope` (numeric ifindex) instead. When omitted the kernel selects - /// the interface from its routing table, which may surprise on - /// multi-homed hosts. + /// Interface to pin multicast traffic to. + /// + /// This accepts either an interface name (e.g. `net0`) or an IP address + /// bound to the interface. The group's address family selects what the + /// selector resolves to: the interface's IPv4 address for `IP_MULTICAST_IF` + /// and the IPv4 joins, or its interface index for `IPV6_MULTICAST_IF`, the + /// IPv6 joins, and, for interface- and link-local groups, the bind + /// scope. When omitted the kernel selects the + /// interface from its routing table, which may not choose the intended + /// interface on hosts with multiple network interfaces. #[arg(long)] - multicast_iface: Option, + multicast_iface: Option, #[command(subcommand)] kind: Participant, @@ -86,7 +92,7 @@ struct Server { /// IP address to listen on. When this is a multicast address the /// receiver binds directly to the group on `--port`, sets `SO_REUSEADDR` /// (so multiple receivers can co-bind), and joins the group on the - /// interface selected by `--multicast-iface` (IPv4) or `--scope` (IPv6). + /// interface selected by `--multicast-iface`. listen: IpAddr, /// Wallclock duration (seconds) for UDP receivers. When unset the @@ -175,24 +181,19 @@ impl Server { )); } } - // SSM joins must be pinned to a specific interface. With + // SSM joins must be pinned to a specific interface. With a // kernel-picked interface (`INADDR_ANY` for v4, `ifindex = 0` for // v6) the join can silently land on an interface where the source // isn't reachable and deliver no traffic, which looks identical // to a real network regression in CI. - match self.listen { - IpAddr::V4(_) if cli.multicast_iface.is_none() => Err(cmd.error( - ErrorKind::MissingRequiredArgument, - "`--multicast-source` (IPv4) requires `--multicast-iface` \ - to pin the SSM join to a specific interface", - )), - IpAddr::V6(_) if cli.scope == 0 => Err(cmd.error( + if cli.multicast_iface.is_none() { + return Err(cmd.error( ErrorKind::MissingRequiredArgument, - "`--multicast-source` (IPv6) requires non-zero `--scope` \ - (interface index) to pin the SSM join to a specific interface", - )), - _ => Ok(()), + "`--multicast-source` requires `--multicast-iface` to pin \ + the SSM join to a specific interface", + )); } + Ok(()) } /// IPv4 SSM sources. `validate` guarantees that, when `listen` is v4 diff --git a/app/src/ssm.rs b/app/src/ssm.rs index a65a33f..f587b56 100644 --- a/app/src/ssm.rs +++ b/app/src/ssm.rs @@ -13,8 +13,9 @@ use std::net::{IpAddr, Ipv6Addr}; use std::os::fd::AsRawFd; /// Whether `addr` is in the source-specific multicast (SSM) range: -/// `232.0.0.0/8` for IPv4 and `ff30::/12` for IPv6 (flags nibble `3`, -/// referring to any scope), per [RFC 4607][rfc4607] §1. An SSM group builds no +/// `232.0.0.0/8` for IPv4, per [RFC 4607][rfc4607] §1, and `ff30::/12` +/// for IPv6 (any address with flags nibble `3`). The IPv6 range is a +/// superset of the RFC's `ff3x::/32` blocks. An SSM group builds no /// shared `(*, G)` tree, so it is reachable only through an INCLUDE-mode /// `(S, G)` join. /// @@ -23,6 +24,9 @@ use std::os::fd::AsRawFd; /// programs the group's forwarding tables. /// /// [rfc4607]: https://datatracker.ietf.org/doc/html/rfc4607 +// TODO: move SSM classification into oxnet alongside a multicast address +// type, so this crate, Nexus's `is_ssm_address`, and other consumers share +// one implementation. pub fn is_ssm_multicast(addr: IpAddr) -> bool { match addr { IpAddr::V4(v4) => v4.octets()[0] == 232, @@ -125,7 +129,7 @@ mod tests { use super::*; #[test] - fn ssm_classification_matches_rfc4607_ranges() { + fn ssm_classification_matches_nexus_ranges() { // IPv4: only 232.0.0.0/8 is SSM. The adjacent ASM and link-local // ranges are not. assert!(is_ssm_multicast("232.0.0.1".parse().unwrap())); @@ -135,8 +139,9 @@ mod tests { assert!(!is_ssm_multicast("239.1.2.3".parse().unwrap())); assert!(!is_ssm_multicast("224.0.0.1".parse().unwrap())); - // IPv6: ff30::/12 (flags nibble 3) is SSM at every scope. Other - // multicast flag/scope combinations are not. + // IPv6: ff30::/12 (flags nibble 3) classifies as SSM at every + // scope, matching Nexus's superset of the RFC 4607 ff3x::/32 + // blocks. Other multicast flag/scope combinations are not. assert!(is_ssm_multicast("ff3e::1".parse().unwrap())); assert!(is_ssm_multicast("ff35::1234".parse().unwrap())); assert!(!is_ssm_multicast("ff0e::1".parse().unwrap())); diff --git a/app/src/udp.rs b/app/src/udp.rs index d6b1ddf..e62a5d4 100644 --- a/app/src/udp.rs +++ b/app/src/udp.rs @@ -52,6 +52,21 @@ fn set_ip_multicast_loop_v4(socket: &Socket, enable: bool) -> io::Result<()> { ) } +/// The `sin6_scope_id` to use for a multicast `group` pinned to `ifindex`. +/// +/// Scope identifiers disambiguate link-scoped addresses (RFC 4007), so +/// only interface-local (`ffx1::`) and link-local (`ffx2::`) groups carry +/// the ifindex in the socket address. Wider-scope groups are already +/// pinned by `IPV6_MULTICAST_IF` or the join, and the kernel may reject a +/// nonzero scope on them, so the scope stays 0. +fn v6_multicast_scope_id(group: Ipv6Addr, ifindex: u32) -> u32 { + if matches!(group.segments()[0] & 0x000f, 1 | 2) { + ifindex + } else { + 0 + } +} + pub(crate) fn run(cli: &Cli) -> Result<()> { match cli.kind { Participant::Client(ref client) => run_client(cli, client), @@ -72,8 +87,8 @@ fn run_client(cli: &Cli, client: &Client) -> Result<()> { .context("set IP_MULTICAST_TTL")?; set_ip_multicast_loop_v4(&s, cli.multicast_loop) .context("set IP_MULTICAST_LOOP")?; - if let Some(iface) = cli.multicast_iface { - s.set_multicast_if_v4(&iface) + if let Some(iface) = &cli.multicast_iface { + s.set_multicast_if_v4(&iface.v4_addr()?) .context("set IP_MULTICAST_IF")?; } } else { @@ -82,24 +97,30 @@ fn run_client(cli: &Cli, client: &Client) -> Result<()> { (s, SocketAddr::V4(sa)) } IpAddr::V6(addr) => { - let sa = SocketAddrV6::new(addr, cli.port, 0, cli.scope); let s = Socket::new(Domain::IPV6, Type::DGRAM, Some(Protocol::UDP))?; - if is_mcast { + let scope_id = if is_mcast { s.set_multicast_hops_v6(u32::from(cli.ttl)) .context("set IPV6_MULTICAST_HOPS")?; s.set_multicast_loop_v6(cli.multicast_loop) .context("set IPV6_MULTICAST_LOOP")?; + let ifindex = match &cli.multicast_iface { + Some(iface) => iface.index()?, + None => 0, + }; // ifindex 0 means "kernel picks". Cross-platform behavior of // explicit 0 is inconsistent, so skip the call entirely. - if let Some(idx) = NonZeroU32::new(cli.scope) { + if let Some(idx) = NonZeroU32::new(ifindex) { s.set_multicast_if_v6(idx.get()) .context("set IPV6_MULTICAST_IF")?; } + v6_multicast_scope_id(addr, ifindex) } else { s.set_unicast_hops_v6(u32::from(cli.ttl)) .context("set IPV6_UNICAST_HOPS")?; - } + cli.scope + }; + let sa = SocketAddrV6::new(addr, cli.port, 0, scope_id); (s, SocketAddr::V6(sa)) } }; @@ -203,8 +224,10 @@ fn run_server(cli: &Cli, server: &Server) -> Result<()> { } s.bind(&sa.into())?; if is_mcast { - let iface = - cli.multicast_iface.unwrap_or(Ipv4Addr::UNSPECIFIED); + let iface = match &cli.multicast_iface { + Some(iface) => iface.v4_addr()?, + None => Ipv4Addr::UNSPECIFIED, + }; join_v4(&s, addr, iface, &server.multicast_source_v4())?; } s @@ -212,13 +235,29 @@ fn run_server(cli: &Cli, server: &Server) -> Result<()> { IpAddr::V6(addr) => { let s = Socket::new(Domain::IPV6, Type::DGRAM, Some(Protocol::UDP))?; - let sa = SocketAddrV6::new(addr, cli.port, 0, cli.scope); + + let ifindex = if is_mcast { + match &cli.multicast_iface { + Some(iface) => iface.index()?, + None => 0, + } + } else { + 0 + }; + + let scope_id = if is_mcast { + v6_multicast_scope_id(addr, ifindex) + } else { + cli.scope + }; + + let sa = SocketAddrV6::new(addr, cli.port, 0, scope_id); if is_mcast { s.set_reuse_address(true).context("SO_REUSEADDR")?; } s.bind(&sa.into())?; if is_mcast { - join_v6(&s, addr, cli.scope, &server.multicast_source_v6())?; + join_v6(&s, addr, ifindex, &server.multicast_source_v6())?; } s } diff --git a/app/src/util.rs b/app/src/util.rs index cd18fb6..d67d2a8 100644 --- a/app/src/util.rs +++ b/app/src/util.rs @@ -1,3 +1,10 @@ +use anyhow::{Context, Result, anyhow, bail}; +use std::convert::Infallible; +use std::ffi::{CStr, CString}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; +use std::str::FromStr; +use std::{io, iter, ptr}; + const MUFFIN: &[u8] = b"muffin "; /// Length of the big-endian `u64` sequence-number prefix that precedes the @@ -51,3 +58,200 @@ pub fn get_seq(buf: &[u8]) -> Option { bytes.copy_from_slice(&buf[..SEQ_LEN]); Some(u64::from_be_bytes(bytes)) } + +/// The interface selector for pinning multicast traffic. +/// +/// This is either an interface name (e.g. `net0`) or an IP address bound to the +/// interface. +/// +/// The group's address family decides what the selector resolves to. An +/// IPv4 group needs the interface's IPv4 address (`IP_MULTICAST_IF` and +/// the joins take an address). An IPv6 group needs the interface index +/// (`IPV6_MULTICAST_IF` and the joins take an ifindex). +/// +/// An address selector that appears on multiple interfaces (an IPv6 +/// link-local address, notably) resolves to the first matching entry. +/// Prefer the name form when the address is ambiguous. +#[derive(Clone, Debug)] +pub enum InterfaceSelector { + Name(String), + Addr(IpAddr), +} + +impl FromStr for InterfaceSelector { + type Err = Infallible; + + fn from_str(input: &str) -> Result { + Ok(input + .parse::() + .map_or_else(|_| Self::Name(input.to_string()), Self::Addr)) + } +} + +impl InterfaceSelector { + /// The interface's IPv4 address, for `IP_MULTICAST_IF` and the IPv4 + /// joins. + /// + /// A selector that already is an IPv4 address is returned as-is, + /// preserving the semantics of passing an arbitrary `IP_MULTICAST_IF` + /// value. + /// + /// # Errors + /// + /// Fails when the interface list cannot be read, or no interface + /// matches the selector, or the matched interface has no IPv4 + /// address. + pub fn v4_addr(&self) -> Result { + match self { + Self::Addr(IpAddr::V4(addr)) => Ok(*addr), + Self::Addr(addr @ IpAddr::V6(_)) => { + let ifaddrs = IfAddrs::load()?; + let name = owner_name(&ifaddrs, *addr)?; + v4_addr_of(&ifaddrs, &name).with_context(|| { + format!("selector `{addr}` resolved to interface `{name}`") + }) + } + Self::Name(name) => v4_addr_of(&IfAddrs::load()?, name), + } + } + + /// The interface's index, for `IPV6_MULTICAST_IF`, the IPv6 joins, + /// and the bind scope of interface- and link-local groups. + /// + /// # Errors + /// + /// Fails when no interface matches the selector. + pub fn index(&self) -> Result { + match self { + Self::Name(name) => ifname_to_index(name), + Self::Addr(addr) => { + ifname_to_index(&owner_name(&IfAddrs::load()?, *addr)?) + } + } + } +} + +/// Resolve an interface name to its index via [`if_nametoindex(3SOCKET)`]. +/// +/// [`if_nametoindex(3SOCKET)`]: https://illumos.org/man/3SOCKET/if_nametoindex +fn ifname_to_index(name: &str) -> Result { + if name.is_empty() { + bail!("interface name is empty"); + } + let cname = CString::new(name).context("interface name contains NUL")?; + // SAFETY: `cname` is a valid NUL-terminated string for the duration of + // the call. + let index = unsafe { libc::if_nametoindex(cname.as_ptr()) }; + if index == 0 { + let error = io::Error::last_os_error(); + bail!("no interface named `{name}`: {error}"); + } + Ok(index) +} + +/// Owned [`getifaddrs(3SOCKET)`] list, freed on drop. +/// +/// [`getifaddrs(3SOCKET)`]: https://illumos.org/man/3SOCKET/getifaddrs +struct IfAddrs { + head: *mut libc::ifaddrs, +} + +impl IfAddrs { + fn load() -> Result { + let mut head = ptr::null_mut(); + // SAFETY: `getifaddrs` allocates a list into `head` on success. + // The list is freed exactly once (on drop). + if unsafe { libc::getifaddrs(&mut head) } != 0 { + return Err(io::Error::last_os_error()).context("getifaddrs"); + } + Ok(Self { head }) + } + + /// Iterate the entries. Borrowed entries stay live until `self` + /// drops. + fn iter(&self) -> impl Iterator { + // SAFETY: the entries form a null-terminated linked list owned by + // `self`, and `as_ref` turns exactly the null tail into `None`. + iter::successors(unsafe { self.head.as_ref() }, |entry| unsafe { + entry.ifa_next.as_ref() + }) + } +} + +impl Drop for IfAddrs { + fn drop(&mut self) { + // SAFETY: `head` came from a successful `getifaddrs` in `load` + // and is freed only here. + unsafe { libc::freeifaddrs(self.head) }; + } +} + +fn entry_name(entry: &libc::ifaddrs) -> Option<&str> { + // SAFETY: `ifa_name` is a NUL-terminated string owned by the list. + unsafe { CStr::from_ptr(entry.ifa_name) }.to_str().ok() +} + +/// Whether the entry belongs to interface `name` or not. On illumos, addresses +/// live on logical interfaces (`name:1`, `name:2`), so those count too. +fn entry_matches(entry: &libc::ifaddrs, name: &str) -> bool { + if name.is_empty() { + return false; + } + entry_name(entry).is_some_and(|entry_name| { + entry_name == name + || entry_name + .strip_prefix(name) + .is_some_and(|rest| rest.starts_with(':')) + }) +} + +fn entry_addr(entry: &libc::ifaddrs) -> Option { + if entry.ifa_addr.is_null() { + return None; + } + + // SAFETY: `ifa_addr` is non-null and only reinterpreted as the + // sockaddr type its family indicates. `getifaddrs` allocates each + // sockaddr with the alignment that concrete type requires. + unsafe { + match i32::from((*entry.ifa_addr).sa_family) { + libc::AF_INET => { + let sin = &*(entry.ifa_addr as *const libc::sockaddr_in); + Some(IpAddr::V4(Ipv4Addr::from(u32::from_be( + sin.sin_addr.s_addr, + )))) + } + libc::AF_INET6 => { + let sin6 = &*(entry.ifa_addr as *const libc::sockaddr_in6); + Some(IpAddr::V6(Ipv6Addr::from(sin6.sin6_addr.s6_addr))) + } + _ => None, + } + } +} + +/// The base name of the interface `addr` is bound to, with any illumos +/// logical-interface suffix (`:1`) stripped so it resolves with +/// `if_nametoindex`. +fn owner_name(ifaddrs: &IfAddrs, addr: IpAddr) -> Result { + ifaddrs + .iter() + .find(|entry| entry_addr(entry) == Some(addr)) + .and_then(entry_name) + .and_then(|name| name.split(':').next()) + .filter(|base| !base.is_empty()) + .map(str::to_owned) + .ok_or_else(|| anyhow!("no interface has address `{addr}`")) +} + +/// The first IPv4 address bound to interface `name`. +fn v4_addr_of(ifaddrs: &IfAddrs, name: &str) -> Result { + ifaddrs + .iter() + .filter(|entry| entry_matches(entry, name)) + .find_map(|entry| match entry_addr(entry) { + Some(IpAddr::V4(addr)) => Some(addr), + _ => None, + }) + .ok_or_else(|| anyhow!("interface `{name}` has no IPv4 address")) +} diff --git a/smf/method_script.sh b/smf/method_script.sh index 8595453..599cfe9 100755 --- a/smf/method_script.sh +++ b/smf/method_script.sh @@ -8,8 +8,11 @@ # the kernel's IGMP/MLD membership for that group established on the pinned # interface. The illumos IP stack accepts (and answers ICMP echo for) only # locally joined groups, so the joiners supply the membership state the -# zone's stack needs to accept group traffic. Forwarding to the port is -# programmed separately by the control plane. +# zone's stack needs to accept group traffic. Switch-side replication of +# group traffic to the zone's underlay port is programmed separately. Nexus +# creates the groups in DPD (Dendrite's data-plane daemon), and ddmd programs +# the per-port replication members from DDM peer subscriptions (RFD 488). The +# joiners only manage the host-local membership. # # This must run inside the zone: IGMP/MLD membership is per-netstack state, # and a zone with an exclusive IP stack cannot receive it from outside. The @@ -33,17 +36,18 @@ if [[ ! -x "$BIN" ]]; then exit 1 fi -port=$(svcprop -c -p config/port "${SMF_FMRI}") - -# IPv4 address bound to the interface the joins should be pinned to (the -# probe's overlay port). For multicast this becomes IP_MULTICAST_IF and, for -# SSM, pins the source-specific membership to that interface. -# -# Note: this may be unset. -iface=$(svcprop -c -p config/multicast_iface "${SMF_FMRI}" 2>/dev/null || true) -if [[ "$iface" == '""' ]]; then - iface="" -fi +# Read an optional property, normalizing svcprop's representation of +# "nothing there" to an empty string. svcprop -c quotes astring values, so +# an empty value is emitted as a literal pair of double quotes. An unset +# property makes svcprop fail entirely. Both cases collapse to "". +svcprop_optional() { + local value + value=$(svcprop -c -p "$1" "${SMF_FMRI}" 2>/dev/null) || value="" + if [[ "$value" == '""' ]]; then + value="" + fi + printf '%s' "$value" +} # Multi-valued list of multicast groups to join and hold open. The illumos IP # stack only accepts (and only answers ICMP echo for) a group the interface @@ -51,24 +55,7 @@ fi # long-lived membership to respond to reachability probes. Forwarding to the # port is programmed statically by the control plane, as this is solely the # host-local membership gate. -groups=$(svcprop -c -p config/multicast_group "${SMF_FMRI}" 2>/dev/null || true) -if [[ "$groups" == '""' ]]; then - groups="" -fi - -# Interface index (ifindex) the IPv6 joins should be pinned to. IPv6 has no -# address-based IP_MULTICAST_IF equivalent, so the binary takes a numeric -# `--scope` instead (IPV6_MULTICAST_IF, and the SSM join interface). A value -# of 0 means kernel-selected and is the binary's default, so it is elided. -ipv6_scope=$(svcprop -c -p config/ipv6_scope "${SMF_FMRI}" 2>/dev/null || echo 0) - -iface_args=() -if [[ -n "$iface" ]]; then - iface_args=(--multicast-iface "$iface") -fi -if [[ "$ipv6_scope" != 0 ]]; then - iface_args+=(--scope "$ipv6_scope") -fi +groups=$(svcprop_optional config/multicast_group) if [[ -z "$groups" ]]; then # No groups configured. Stay online but idle so the service does not @@ -78,6 +65,23 @@ if [[ -z "$groups" ]]; then exec sleep infinity fi +# svc.startd sets SMF_FMRI in every method's environment, see smf_method(7) +# and svc.startd(8). +port=$(svcprop -c -p config/port "${SMF_FMRI}") + +# Interface the joins should be pinned to (the probe's overlay port), as an +# interface name or an IP address bound to it. The binary resolves it per +# group family: the interface's IPv4 address for IP_MULTICAST_IF and the +# IPv4 joins, its ifindex for IPV6_MULTICAST_IF and the IPv6 joins. +# +# Note: this may be unset. +iface=$(svcprop_optional config/multicast_iface) + +iface_args=() +if [[ -n "$iface" ]]; then + iface_args=(--multicast-iface "$iface") +fi + # Spawn one joiner per group. pids=() for entry in $groups; do diff --git a/smf/thundermuffin/manifest.xml b/smf/thundermuffin/manifest.xml index c5f4644..5b306c0 100644 --- a/smf/thundermuffin/manifest.xml +++ b/smf/thundermuffin/manifest.xml @@ -18,7 +18,6 @@ - From 486559bc81f0264360033d7e974de9b353046fb0 Mon Sep 17 00:00:00 2001 From: Zeeshan Lakhani Date: Tue, 4 Aug 2026 08:10:45 +0000 Subject: [PATCH 3/4] [review, ssm] tighten SSM classification and validate destinations Restrict the IPv6 SSM classifier to the RFC 4607 ff3x::/32 blocks (matching Nexus's `is_ssm_address`) and validate SSM destinations against the control plane's admission rules: the reserved 232.0.0.0/24, IPv6 group IDs below 0x80000000, and unusable multicast scopes. Reject broadcast and link-local multicast sources at parse time, matching Dendrite's source checks. We'll eventually oxnet all this (in a TODO), as that affects many repos. --- app/src/main.rs | 29 +++++++++-- app/src/ssm.rs | 135 ++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 144 insertions(+), 20 deletions(-) diff --git a/app/src/main.rs b/app/src/main.rs index 1ea68c6..9a179b5 100644 --- a/app/src/main.rs +++ b/app/src/main.rs @@ -115,8 +115,12 @@ struct Server { } /// Reject obviously-non-unicast addresses (multicast, unspecified, loopback) -/// at clap parse time so SSM misconfiguration surfaces as a CLI error rather -/// than an opaque kernel `EADDRNOTAVAIL` after socket setup. +/// and non-routable sources (broadcast, link-local) at clap parse time so SSM +/// misconfiguration surfaces as a CLI error rather than an opaque kernel +/// `EADDRNOTAVAIL` after socket setup. +/// +/// This matches the source checks Dendrite applies at group creation +/// (i.e., `dpd/src/mcast/validate.rs`). fn parse_unicast_ipaddr(s: &str) -> Result { let addr: IpAddr = s .parse() @@ -124,6 +128,16 @@ fn parse_unicast_ipaddr(s: &str) -> Result { if addr.is_multicast() || addr.is_unspecified() || addr.is_loopback() { return Err(format!("must be a unicast address (got `{addr}`)")); } + let non_routable = match addr { + IpAddr::V4(v4) => v4.is_broadcast() || v4.is_link_local(), + IpAddr::V6(v6) => v6.is_unicast_link_local(), + }; + if non_routable { + return Err(format!( + "must be a routable source address, not broadcast or link-local \ + (got `{addr}`)" + )); + } Ok(addr) } @@ -133,6 +147,15 @@ impl Server { cli: &Cli, cmd: &mut clap::Command, ) -> Result<(), clap::Error> { + // Match the control plane's admission rules for SSM destinations + // (reserved 232.0.0.0/24, non-allocatable IPv6 group IDs, unusable + // scopes). The rack never programs such a group, so a join would + // yield a silent zero-delivery reception. + if ssm::is_ssm_multicast(self.listen) + && let Err(msg) = ssm::validate_ssm_destination(self.listen) + { + return Err(cmd.error(ErrorKind::ValueValidation, msg)); + } if self.multicast_source.is_empty() { // An any-source (*, G) join on an SSM-range group receives no // traffic. SSM defines no shared (*, G) tree: the host rules @@ -150,7 +173,7 @@ impl Server { ErrorKind::MissingRequiredArgument, format!( "`listen` {} is in the source-specific multicast (SSM) \ - range (232.0.0.0/8, ff30::/12) but no sources were \ + range (232.0.0.0/8, ff3x::/32) but no sources were \ supplied; an any-source join is undeliverable. Pass \ `--multicast-source` for an INCLUDE-mode (S, G) join", self.listen diff --git a/app/src/ssm.rs b/app/src/ssm.rs index f587b56..be94157 100644 --- a/app/src/ssm.rs +++ b/app/src/ssm.rs @@ -13,30 +13,85 @@ use std::net::{IpAddr, Ipv6Addr}; use std::os::fd::AsRawFd; /// Whether `addr` is in the source-specific multicast (SSM) range: -/// `232.0.0.0/8` for IPv4, per [RFC 4607][rfc4607] §1, and `ff30::/12` -/// for IPv6 (any address with flags nibble `3`). The IPv6 range is a -/// superset of the RFC's `ff3x::/32` blocks. An SSM group builds no -/// shared `(*, G)` tree, so it is reachable only through an INCLUDE-mode -/// `(S, G)` join. +/// `232.0.0.0/8` for IPv4 and `ff3x::/32` for IPv6, per +/// [RFC 4607][rfc4607] §1. The IPv6 space is sixteen disjoint /32 blocks, +/// not the broader `ff30::/12` prefix. An SSM group builds no shared `(*, G)` +/// tree, so it is reachable only through an INCLUDE-mode `(S, G)` join. /// -/// The ranges mirror Nexus's canonical `is_ssm_address`, so a probe's in-zone -/// join classifies a group the same way the control plane does when it -/// programs the group's forwarding tables. +/// This mirrors Nexus's canonical `is_ssm_address`, so a probe's in-zone join +/// classifies a group the same way the control plane does when it programs the +/// group's forwarding tables. /// /// [rfc4607]: https://datatracker.ietf.org/doc/html/rfc4607 -// TODO: move SSM classification into oxnet alongside a multicast address -// type, so this crate, Nexus's `is_ssm_address`, and other consumers share -// one implementation. +// TODO: move SSM classification and the RFC 4607 admission checks (see +// `validate_ssm_destination`) into oxnet alongside a multicast address +// type, so this crate, Nexus's `is_ssm_address`, Dendrite's validation, +// and other consumers share one implementation. pub fn is_ssm_multicast(addr: IpAddr) -> bool { match addr { IpAddr::V4(v4) => v4.octets()[0] == 232, IpAddr::V6(v6) => { - let octets = v6.octets(); - octets[0] == 0xff && octets[1] >> 4 == 0x3 + let segments = v6.segments(); + segments[0] & 0xfff0 == 0xff30 && segments[1] == 0 } } } +/// Validate an SSM destination against the allocation rules the Oxide +/// control plane enforces at pool admission and group creation (Nexus's +/// `validate_multicast_range` and Dendrite's `dpd/src/mcast/validate.rs`). +/// The caller classifies `addr` as SSM via [`is_ssm_multicast`] first. +/// +/// The control plane never programs these groups, so a join would install +/// kernel state that can receive nothing and surface as a silent +/// zero-delivery run. For IPv4, [RFC 4607 §4.3][rfc4607-43] reserves +/// 232.0.0.0 and holds 232.0.0.1 through 232.0.0.255 for IANA, excluding +/// the whole first /24. For IPv6, only `ff3x::/96` group IDs of +/// 0x80000000 and above are dynamically allocatable ([RFC 4607 §1][rfc4607-1] +/// and [§4.3][rfc4607-43]), and the scope nibble must be usable for +/// inter-sled delivery ([RFC 7346 §2][rfc7346-2]). +/// +/// [rfc4607-1]: https://www.rfc-editor.org/rfc/rfc4607#section-1 +/// [rfc4607-43]: https://www.rfc-editor.org/rfc/rfc4607#section-4.3 +/// [rfc7346-2]: https://www.rfc-editor.org/rfc/rfc7346#section-2 +pub fn validate_ssm_destination(addr: IpAddr) -> Result<(), String> { + match addr { + IpAddr::V4(v4) => { + let octets = v4.octets(); + if octets[1] == 0 && octets[2] == 0 { + return Err(format!( + "{v4} is in the reserved IPv4 SSM subnet \ + (232.0.0.0/24, RFC 4607)" + )); + } + } + IpAddr::V6(v6) => { + let segments = v6.segments(); + let scope = segments[0] & 0x000f; + if !matches!(scope, 0x4 | 0x5 | 0x8 | 0xe) { + return Err(format!( + "{v6} has an unusable multicast scope nibble \ + ({scope:x}); usable scopes are admin-local (4), \ + site-local (5), organization-local (8), and global (e)" + )); + } + let within_prefix = segments[2] == 0 + && segments[3] == 0 + && segments[4] == 0 + && segments[5] == 0; + let group_id = + (u32::from(segments[6]) << 16) | u32::from(segments[7]); + if !within_prefix || group_id < 0x8000_0000 { + return Err(format!( + "{v6} is not a dynamically allocatable IPv6 SSM address \ + (ff3x::8000:0 through ff3x::ffff:ffff per RFC 4607)" + )); + } + } + } + Ok(()) +} + // RFC 3678 / POSIX include-mode source filter. Standardized as `1` on every // platform that exposes `setsourcefilter`. const MCAST_INCLUDE: u32 = 1; @@ -129,7 +184,7 @@ mod tests { use super::*; #[test] - fn ssm_classification_matches_nexus_ranges() { + fn ssm_classification_matches_rfc4607_ranges() { // IPv4: only 232.0.0.0/8 is SSM. The adjacent ASM and link-local // ranges are not. assert!(is_ssm_multicast("232.0.0.1".parse().unwrap())); @@ -139,12 +194,58 @@ mod tests { assert!(!is_ssm_multicast("239.1.2.3".parse().unwrap())); assert!(!is_ssm_multicast("224.0.0.1".parse().unwrap())); - // IPv6: ff30::/12 (flags nibble 3) classifies as SSM at every - // scope, matching Nexus's superset of the RFC 4607 ff3x::/32 - // blocks. Other multicast flag/scope combinations are not. + // IPv6: ff3x::/32 is SSM at every scope. Other multicast flag/scope + // combinations and the ASM gaps inside ff30::/12 are not. assert!(is_ssm_multicast("ff3e::1".parse().unwrap())); assert!(is_ssm_multicast("ff35::1234".parse().unwrap())); + // RFC 4607 reserves the full /32 for possible future use of the + // network-prefix field. + assert!(is_ssm_multicast("ff3e:0:1234::1".parse().unwrap())); + assert!(!is_ssm_multicast("ff3e:20:1234::1".parse().unwrap())); assert!(!is_ssm_multicast("ff0e::1".parse().unwrap())); assert!(!is_ssm_multicast("ff02::1".parse().unwrap())); } + + #[test] + fn ssm_destination_validation_matches_control_plane_rules() { + // IPv4: the reserved first /24 (RFC 4607 §4.3) is unusable, the + // rest of 232/8 is fine. + assert!( + validate_ssm_destination("232.0.0.1".parse().unwrap()).is_err() + ); + assert!(validate_ssm_destination("232.0.1.1".parse().unwrap()).is_ok()); + + // IPv6: only ff3x::/96 group IDs of 0x80000000 and above are + // dynamically allocatable. + assert!( + validate_ssm_destination("ff3e::8000:1".parse().unwrap()).is_ok() + ); + assert!(validate_ssm_destination("ff3e::1".parse().unwrap()).is_err()); + assert!( + validate_ssm_destination("ff3e::4000:1".parse().unwrap()).is_err() + ); + assert!( + validate_ssm_destination("ff3e:0:1234::8000:1".parse().unwrap()) + .is_err() + ); + + // IPv6: unusable and unassigned scope nibbles are rejected even + // with allocatable group IDs, usable scopes pass. + for scoped in [ + "ff31::8000:1", + "ff32::8000:1", + "ff33::8000:1", + "ff36::8000:1", + "ff3d::8000:1", + "ff3f::8000:1", + ] { + assert!( + validate_ssm_destination(scoped.parse().unwrap()).is_err(), + "{scoped} should be rejected for its scope" + ); + } + assert!( + validate_ssm_destination("ff35::8000:1".parse().unwrap()).is_ok() + ); + } } From f8413dc7127fe691303b0b1ade40d0f195fda136 Mon Sep 17 00:00:00 2001 From: Zeeshan Lakhani Date: Thu, 8 Oct 2026 07:33:02 +0000 Subject: [PATCH 4/4] [post-review, ssm] follow dendrite's validation + IfAddr and comment cleanup Our SSM validation now matches oxidecomputer/dendrite#396, which is the updated read on the RFCs for both group and source checking. We move the interface lookups onto `IfAddrs`/`IfAddr`, and add tests for source parsing and the interface-name handling. Also, we do a full run over the documentation to make this match up with the current stack of PRs going into prod. --- app/src/main.rs | 130 ++++++++++++++++++++++------- app/src/ssm.rs | 194 +++++++++++++++++++++++++------------------ app/src/udp.rs | 10 +-- app/src/util.rs | 179 ++++++++++++++++++++++++--------------- smf/method_script.sh | 90 ++++++++++---------- 5 files changed, 375 insertions(+), 228 deletions(-) diff --git a/app/src/main.rs b/app/src/main.rs index 9a179b5..c3e4c06 100644 --- a/app/src/main.rs +++ b/app/src/main.rs @@ -55,9 +55,10 @@ struct Cli { /// selector resolves to: the interface's IPv4 address for `IP_MULTICAST_IF` /// and the IPv4 joins, or its interface index for `IPV6_MULTICAST_IF`, the /// IPv6 joins, and, for interface- and link-local groups, the bind - /// scope. When omitted the kernel selects the - /// interface from its routing table, which may not choose the intended - /// interface on hosts with multiple network interfaces. + /// scope. + /// + /// If omitted, the kernel picks an interface from its routing table. On a + /// host with more than one, it may not pick the intended one. #[arg(long)] multicast_iface: Option, @@ -114,13 +115,16 @@ struct Server { multicast_source: Vec, } -/// Reject obviously-non-unicast addresses (multicast, unspecified, loopback) -/// and non-routable sources (broadcast, link-local) at clap parse time so SSM -/// misconfiguration surfaces as a CLI error rather than an opaque kernel -/// `EADDRNOTAVAIL` after socket setup. +/// Parse an SSM source, rejecting addresses that can't send forwarded +/// traffic: multicast, unspecified, loopback, broadcast, link-local, +/// `0.0.0.0/8` ([RFC 1122] §3.2.1.3), `240.0.0.0/4` ([RFC 1112] §4), and +/// IPv4-mapped/compatible IPv6 ([RFC 4291] §2.5.5). +/// +/// Checking at parse time lets clap reject bad sources up front. /// -/// This matches the source checks Dendrite applies at group creation -/// (i.e., `dpd/src/mcast/validate.rs`). +/// [RFC 1112]: https://www.rfc-editor.org/rfc/rfc1112.html +/// [RFC 1122]: https://www.rfc-editor.org/rfc/rfc1122.html +/// [RFC 4291]: https://www.rfc-editor.org/rfc/rfc4291.html fn parse_unicast_ipaddr(s: &str) -> Result { let addr: IpAddr = s .parse() @@ -128,17 +132,32 @@ fn parse_unicast_ipaddr(s: &str) -> Result { if addr.is_multicast() || addr.is_unspecified() || addr.is_loopback() { return Err(format!("must be a unicast address (got `{addr}`)")); } - let non_routable = match addr { - IpAddr::V4(v4) => v4.is_broadcast() || v4.is_link_local(), - IpAddr::V6(v6) => v6.is_unicast_link_local(), + + let reason = match addr { + IpAddr::V4(v4) => match v4.octets() { + [255, 255, 255, 255] => Some("broadcast"), + [169, 254, ..] => Some("link-local"), + [0, ..] => Some("in 0.0.0.0/8 (RFC 1122 §3.2.1.3)"), + [240..=255, ..] => Some("in reserved 240.0.0.0/4 (RFC 1112 §4)"), + _ => None, + }, + IpAddr::V6(v6) => match v6.segments() { + [0xfe80..=0xfebf, ..] => Some("link-local"), + [0, 0, 0, 0, 0, 0xffff, ..] => { + Some("IPv4-mapped (RFC 4291 §2.5.5.2)") + } + [0, 0, 0, 0, 0, 0, ..] => { + Some("IPv4-compatible (RFC 4291 §2.5.5.1)") + } + _ => None, + }, }; - if non_routable { - return Err(format!( - "must be a routable source address, not broadcast or link-local \ - (got `{addr}`)" - )); + match reason { + Some(reason) => Err(format!( + "must be a routable source address, not {reason} (got `{addr}`)" + )), + None => Ok(addr), } - Ok(addr) } impl Server { @@ -147,13 +166,10 @@ impl Server { cli: &Cli, cmd: &mut clap::Command, ) -> Result<(), clap::Error> { - // Match the control plane's admission rules for SSM destinations - // (reserved 232.0.0.0/24, non-allocatable IPv6 group IDs, unusable - // scopes). The rack never programs such a group, so a join would - // yield a silent zero-delivery reception. - if ssm::is_ssm_multicast(self.listen) - && let Err(msg) = ssm::validate_ssm_destination(self.listen) - { + // A join to an SSM null address or a non-forwardable scope receives + // no packets. + let is_ssm = ssm::is_ssm_multicast(self.listen); + if is_ssm && let Err(msg) = ssm::validate_ssm_destination(self.listen) { return Err(cmd.error(ErrorKind::ValueValidation, msg)); } if self.multicast_source.is_empty() { @@ -161,19 +177,21 @@ impl Server { // traffic. SSM defines no shared (*, G) tree: the host rules // (RFC 4607 §4.1) reject a source-less join to an SSM destination, // and Nexus only ever programs an SSM-range group as an (S, G) - // channel. The fabric forwards via a control-plane subscription - // rather than by snooping the guest's join, so a source-less join - // installs no usable reception state and yields a silent - // zero-delivery receive indistinguishable from a network - // regression. + // channel. + // + // The switch and OPTE forward from state the control plane + // programs, and neither snoops the guest's join. A source-less + // join installs nothing usable, and the run delivers zero + // packets, which could look like a network regression. // // We reject it so that the mismatch surfaces as a CLI error. - if ssm::is_ssm_multicast(self.listen) { + if is_ssm { return Err(cmd.error( ErrorKind::MissingRequiredArgument, format!( "`listen` {} is in the source-specific multicast (SSM) \ - range (232.0.0.0/8, ff3x::/32) but no sources were \ + range (232.0.0.0/8, ff3x::/32, ffbx::/32) but no \ + sources were \ supplied; an any-source join is undeliverable. Pass \ `--multicast-source` for an INCLUDE-mode (S, G) join", self.listen @@ -290,3 +308,51 @@ fn main() -> Result<()> { Transport::Udp => udp::run(&cli), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_unicast_ipaddr_accepts_routable_unicast() { + for accepted in [ + "10.0.0.1", + "100.64.0.1", + "192.0.2.1", + "2001:db8::1", + "fd00::1", + ] { + assert_eq!( + parse_unicast_ipaddr(accepted), + Ok(accepted.parse().unwrap()), + "{accepted} should be accepted" + ); + } + } + + #[test] + fn parse_unicast_ipaddr_rejects_unusable_sources() { + for (rejected, reason) in [ + ("239.1.1.1", "must be a unicast address"), + ("ff3e::8000:1", "must be a unicast address"), + ("0.0.0.0", "must be a unicast address"), + ("::", "must be a unicast address"), + ("127.0.0.1", "must be a unicast address"), + ("::1", "must be a unicast address"), + ("255.255.255.255", "broadcast"), + ("169.254.1.1", "link-local"), + ("fe80::1", "link-local"), + ("0.1.2.3", "0.0.0.0/8"), + ("240.0.0.1", "240.0.0.0/4"), + ("::ffff:192.0.2.1", "IPv4-mapped"), + ("::192.0.2.1", "IPv4-compatible"), + ] { + let error = parse_unicast_ipaddr(rejected) + .expect_err(&format!("{rejected} should be rejected")); + assert!( + error.contains(reason), + "{rejected} rejected for the wrong reason: {error}" + ); + } + } +} diff --git a/app/src/ssm.rs b/app/src/ssm.rs index be94157..7fb5aa4 100644 --- a/app/src/ssm.rs +++ b/app/src/ssm.rs @@ -1,5 +1,5 @@ -//! Source-specific multicast (SSM) support: group classification and joins -//! not covered by `socket2`. +//! Source-specific multicast (SSM) support, including group classification and +//! joins not covered by `socket2`. //! //! `socket2` exposes `Socket::join_ssm_v4` for IPv4 SSM joins via //! `IP_ADD_SOURCE_MEMBERSHIP`, but has no IPv6 equivalent in any current @@ -9,86 +9,99 @@ //! [`setsourcefilter(3SOCKET)`]: https://illumos.org/man/3SOCKET/setsourcefilter use socket2::Socket; -use std::net::{IpAddr, Ipv6Addr}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use std::os::fd::AsRawFd; -/// Whether `addr` is in the source-specific multicast (SSM) range: -/// `232.0.0.0/8` for IPv4 and `ff3x::/32` for IPv6, per -/// [RFC 4607][rfc4607] §1. The IPv6 space is sixteen disjoint /32 blocks, -/// not the broader `ff30::/12` prefix. An SSM group builds no shared `(*, G)` -/// tree, so it is reachable only through an INCLUDE-mode `(S, G)` join. +const IPV4_SSM_FIRST_OCTET: u8 = 232; +const IPV4_SSM_RESERVED_NULL: Ipv4Addr = Ipv4Addr::new(232, 0, 0, 0); +const IPV6_SSM_NULL_GROUP_ID: u32 = 0x4000_0000; +const IPV6_SCOPE_MASK: u16 = 0x000f; +const IPV6_SCOPE_RESERVED_ZERO: u16 = 0x0; +const IPV6_SCOPE_INTERFACE_LOCAL: u16 = 0x1; +const IPV6_SCOPE_LINK_LOCAL: u16 = 0x2; +const IPV6_PREFIX_BASED_PLEN_MASK: u16 = 0x00ff; + +// Checks R, P, and T in the flags nibble (SSM wants 0, 1, 1 per RFC 7371 +// §4.1.2). X and scope are masked off. +const IPV6_SSM_PREFIX: u16 = 0xff30; +const IPV6_SSM_PREFIX_MASK: u16 = 0xff70; + +const fn ipv6_segments_are_ssm(segments: [u16; 8]) -> bool { + segments[0] & IPV6_SSM_PREFIX_MASK == IPV6_SSM_PREFIX + && segments[1] & IPV6_PREFIX_BASED_PLEN_MASK == 0 +} + +/// Whether `addr` is a source-specific multicast (SSM) group. /// -/// This mirrors Nexus's canonical `is_ssm_address`, so a probe's in-zone join -/// classifies a group the same way the control plane does when it programs the -/// group's forwarding tables. +/// IPv4: anywhere in `232.0.0.0/8` ([RFC 4607] §1). IPv6: P and T set, R +/// clear (so `ff3x` or `ffbx`), with a zero prefix length. Reserved bits are +/// ignored ([RFC 7371] §4.1.1, §4.1.2, [RFC 3956] §3). /// -/// [rfc4607]: https://datatracker.ietf.org/doc/html/rfc4607 +/// [RFC 3956]: https://www.rfc-editor.org/rfc/rfc3956.html +/// [RFC 4607]: https://www.rfc-editor.org/rfc/rfc4607.html +/// [RFC 7371]: https://www.rfc-editor.org/rfc/rfc7371.html // TODO: move SSM classification and the RFC 4607 admission checks (see -// `validate_ssm_destination`) into oxnet alongside a multicast address -// type, so this crate, Nexus's `is_ssm_address`, Dendrite's validation, -// and other consumers share one implementation. +// `validate_ssm_destination`) into oxnet alongside multicast address types. +// This crate, Nexus's `is_ssm_address`, and Dendrite's validation could then +// share one implementation. pub fn is_ssm_multicast(addr: IpAddr) -> bool { match addr { - IpAddr::V4(v4) => v4.octets()[0] == 232, - IpAddr::V6(v6) => { - let segments = v6.segments(); - segments[0] & 0xfff0 == 0xff30 && segments[1] == 0 - } + IpAddr::V4(v4) => v4.octets()[0] == IPV4_SSM_FIRST_OCTET, + IpAddr::V6(v6) => ipv6_segments_are_ssm(v6.segments()), } } -/// Validate an SSM destination against the allocation rules the Oxide -/// control plane enforces at pool admission and group creation (Nexus's -/// `validate_multicast_range` and Dendrite's `dpd/src/mcast/validate.rs`). -/// The caller classifies `addr` as SSM via [`is_ssm_multicast`] first. +/// Reject the SSM null addresses `232.0.0.0` and `ff3x::4000:0` +/// ([RFC 4607] §4.3) and IPv6 scopes 0 through 2 ([RFC 4291] §2.7). /// -/// The control plane never programs these groups, so a join would install -/// kernel state that can receive nothing and surface as a silent -/// zero-delivery run. For IPv4, [RFC 4607 §4.3][rfc4607-43] reserves -/// 232.0.0.0 and holds 232.0.0.1 through 232.0.0.255 for IANA, excluding -/// the whole first /24. For IPv6, only `ff3x::/96` group IDs of -/// 0x80000000 and above are dynamically allocatable ([RFC 4607 §1][rfc4607-1] -/// and [§4.3][rfc4607-43]), and the scope nibble must be usable for -/// inter-sled delivery ([RFC 7346 §2][rfc7346-2]). +/// Callers must check [`is_ssm_multicast`] first. /// -/// [rfc4607-1]: https://www.rfc-editor.org/rfc/rfc4607#section-1 -/// [rfc4607-43]: https://www.rfc-editor.org/rfc/rfc4607#section-4.3 -/// [rfc7346-2]: https://www.rfc-editor.org/rfc/rfc7346#section-2 +/// [RFC 4291]: https://www.rfc-editor.org/rfc/rfc4291.html +/// [RFC 4607]: https://www.rfc-editor.org/rfc/rfc4607.html pub fn validate_ssm_destination(addr: IpAddr) -> Result<(), String> { match addr { IpAddr::V4(v4) => { - let octets = v4.octets(); - if octets[1] == 0 && octets[2] == 0 { + if v4 == IPV4_SSM_RESERVED_NULL { return Err(format!( - "{v4} is in the reserved IPv4 SSM subnet \ - (232.0.0.0/24, RFC 4607)" + "{v4} is the reserved IPv4 SSM null address \ + (RFC 4607 §4.3)" )); } } IpAddr::V6(v6) => { let segments = v6.segments(); - let scope = segments[0] & 0x000f; - if !matches!(scope, 0x4 | 0x5 | 0x8 | 0xe) { - return Err(format!( - "{v6} has an unusable multicast scope nibble \ - ({scope:x}); usable scopes are admin-local (4), \ - site-local (5), organization-local (8), and global (e)" - )); + let scope = segments[0] & IPV6_SCOPE_MASK; + match scope { + IPV6_SCOPE_RESERVED_ZERO => { + return Err(format!( + "{v6} has the reserved multicast scope 0 \ + (RFC 4291 §2.7)" + )); + } + IPV6_SCOPE_INTERFACE_LOCAL | IPV6_SCOPE_LINK_LOCAL => { + return Err(format!( + "{v6} has an interface- or link-local multicast \ + scope ({scope:x}) that cannot be forwarded \ + (RFC 4291 §2.7)" + )); + } + _ => {} } - let within_prefix = segments[2] == 0 - && segments[3] == 0 - && segments[4] == 0 - && segments[5] == 0; + + let within_prefix = + segments[2..6].iter().all(|segment| *segment == 0); let group_id = (u32::from(segments[6]) << 16) | u32::from(segments[7]); - if !within_prefix || group_id < 0x8000_0000 { + + if within_prefix && group_id == IPV6_SSM_NULL_GROUP_ID { return Err(format!( - "{v6} is not a dynamically allocatable IPv6 SSM address \ - (ff3x::8000:0 through ff3x::ffff:ffff per RFC 4607)" + "{v6} is the reserved IPv6 SSM null address \ + (ff3x::4000:0, RFC 4607 §4.3)" )); } } } + Ok(()) } @@ -194,58 +207,75 @@ mod tests { assert!(!is_ssm_multicast("239.1.2.3".parse().unwrap())); assert!(!is_ssm_multicast("224.0.0.1".parse().unwrap())); - // IPv6: ff3x::/32 is SSM at every scope. Other multicast flag/scope - // combinations and the ASM gaps inside ff30::/12 are not. + // IPv6: ff3x::/32 and ffbx::/32 are SSM at every scope, reserved bits + // aside. Other flag combinations and nonzero prefix lengths + // (RFC 3306 ASM) are not. assert!(is_ssm_multicast("ff3e::1".parse().unwrap())); assert!(is_ssm_multicast("ff35::1234".parse().unwrap())); + // RFC 4607 reserves the full /32 for possible future use of the // network-prefix field. assert!(is_ssm_multicast("ff3e:0:1234::1".parse().unwrap())); assert!(!is_ssm_multicast("ff3e:20:1234::1".parse().unwrap())); assert!(!is_ssm_multicast("ff0e::1".parse().unwrap())); assert!(!is_ssm_multicast("ff02::1".parse().unwrap())); + assert!(is_ssm_multicast("ffbe::1".parse().unwrap())); + assert!(is_ssm_multicast("ff3e:1200::1".parse().unwrap())); + assert!(!is_ssm_multicast("ff7e:120:1234::1".parse().unwrap())); + assert!(!is_ssm_multicast("ff1e::1".parse().unwrap())); } #[test] - fn ssm_destination_validation_matches_control_plane_rules() { - // IPv4: the reserved first /24 (RFC 4607 §4.3) is unusable, the - // rest of 232/8 is fine. + fn ssm_destination_validation() { assert!( - validate_ssm_destination("232.0.0.1".parse().unwrap()).is_err() + validate_ssm_destination("232.0.0.0".parse().unwrap()).is_err() ); - assert!(validate_ssm_destination("232.0.1.1".parse().unwrap()).is_ok()); + for accepted in ["232.0.0.1", "232.0.0.255", "232.0.1.1"] { + assert!( + validate_ssm_destination(accepted.parse().unwrap()).is_ok(), + "{accepted} should be accepted" + ); + } - // IPv6: only ff3x::/96 group IDs of 0x80000000 and above are - // dynamically allocatable. - assert!( - validate_ssm_destination("ff3e::8000:1".parse().unwrap()).is_ok() - ); - assert!(validate_ssm_destination("ff3e::1".parse().unwrap()).is_err()); - assert!( - validate_ssm_destination("ff3e::4000:1".parse().unwrap()).is_err() - ); - assert!( - validate_ssm_destination("ff3e:0:1234::8000:1".parse().unwrap()) - .is_err() - ); + for rejected in ["ff3e::4000:0", "ffbe::4000:0"] { + assert!( + validate_ssm_destination(rejected.parse().unwrap()).is_err(), + "{rejected} should be rejected" + ); + } + for accepted in [ + "ff3e::1", + "ff3e::4000:1", + "ff3e::8000:1", + "ff3e::f000:1", + "ff3e:0:1234::4000:0", + ] { + assert!( + validate_ssm_destination(accepted.parse().unwrap()).is_ok(), + "{accepted} should be accepted" + ); + } - // IPv6: unusable and unassigned scope nibbles are rejected even - // with allocatable group IDs, usable scopes pass. + for scoped in ["ff30::8000:1", "ff31::8000:1", "ff32::8000:1"] { + assert!( + validate_ssm_destination(scoped.parse().unwrap()).is_err(), + "{scoped} should be rejected for its scope" + ); + } for scoped in [ - "ff31::8000:1", - "ff32::8000:1", "ff33::8000:1", + "ff34::8000:1", + "ff35::8000:1", "ff36::8000:1", + "ff38::8000:1", "ff3d::8000:1", + "ff3e::8000:1", "ff3f::8000:1", ] { assert!( - validate_ssm_destination(scoped.parse().unwrap()).is_err(), - "{scoped} should be rejected for its scope" + validate_ssm_destination(scoped.parse().unwrap()).is_ok(), + "{scoped} should be accepted for its scope" ); } - assert!( - validate_ssm_destination("ff35::8000:1".parse().unwrap()).is_ok() - ); } } diff --git a/app/src/udp.rs b/app/src/udp.rs index e62a5d4..d54bd04 100644 --- a/app/src/udp.rs +++ b/app/src/udp.rs @@ -54,11 +54,11 @@ fn set_ip_multicast_loop_v4(socket: &Socket, enable: bool) -> io::Result<()> { /// The `sin6_scope_id` to use for a multicast `group` pinned to `ifindex`. /// -/// Scope identifiers disambiguate link-scoped addresses (RFC 4007), so -/// only interface-local (`ffx1::`) and link-local (`ffx2::`) groups carry -/// the ifindex in the socket address. Wider-scope groups are already -/// pinned by `IPV6_MULTICAST_IF` or the join, and the kernel may reject a -/// nonzero scope on them, so the scope stays 0. +/// Kernels only consult the scope ID for interface- and link-local addresses +/// (RFC 4007 zones). Only `ffx1::` and `ffx2::` groups therefore carry the +/// `ifindex` in the socket address. Wider-scope groups are already pinned by +/// `IPV6_MULTICAST_IF` or the join, and the kernel may reject a nonzero +/// scope on them. Their scope stays 0. fn v6_multicast_scope_id(group: Ipv6Addr, ifindex: u32) -> u32 { if matches!(group.segments()[0] & 0x000f, 1 | 2) { ifindex diff --git a/app/src/util.rs b/app/src/util.rs index d67d2a8..cbb6375 100644 --- a/app/src/util.rs +++ b/app/src/util.rs @@ -1,4 +1,4 @@ -use anyhow::{Context, Result, anyhow, bail}; +use anyhow::{Context, Result, bail}; use std::convert::Infallible; use std::ffi::{CStr, CString}; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; @@ -106,12 +106,12 @@ impl InterfaceSelector { Self::Addr(IpAddr::V4(addr)) => Ok(*addr), Self::Addr(addr @ IpAddr::V6(_)) => { let ifaddrs = IfAddrs::load()?; - let name = owner_name(&ifaddrs, *addr)?; - v4_addr_of(&ifaddrs, &name).with_context(|| { + let name = ifaddrs.owner_name(*addr)?; + ifaddrs.v4_addr(&name).with_context(|| { format!("selector `{addr}` resolved to interface `{name}`") }) } - Self::Name(name) => v4_addr_of(&IfAddrs::load()?, name), + Self::Name(name) => IfAddrs::load()?.v4_addr(name), } } @@ -125,7 +125,7 @@ impl InterfaceSelector { match self { Self::Name(name) => ifname_to_index(name), Self::Addr(addr) => { - ifname_to_index(&owner_name(&IfAddrs::load()?, *addr)?) + ifname_to_index(&IfAddrs::load()?.owner_name(*addr)?) } } } @@ -141,12 +141,12 @@ fn ifname_to_index(name: &str) -> Result { let cname = CString::new(name).context("interface name contains NUL")?; // SAFETY: `cname` is a valid NUL-terminated string for the duration of // the call. - let index = unsafe { libc::if_nametoindex(cname.as_ptr()) }; - if index == 0 { + let idx = unsafe { libc::if_nametoindex(cname.as_ptr()) }; + if idx == 0 { let error = io::Error::last_os_error(); bail!("no interface named `{name}`: {error}"); } - Ok(index) + Ok(idx) } /// Owned [`getifaddrs(3SOCKET)`] list, freed on drop. @@ -169,12 +169,37 @@ impl IfAddrs { /// Iterate the entries. Borrowed entries stay live until `self` /// drops. - fn iter(&self) -> impl Iterator { + fn iter(&self) -> impl Iterator> { // SAFETY: the entries form a null-terminated linked list owned by // `self`, and `as_ref` turns exactly the null tail into `None`. iter::successors(unsafe { self.head.as_ref() }, |entry| unsafe { entry.ifa_next.as_ref() }) + .map(IfAddr) + } + + /// The base name of the interface `addr` is bound to, with any illumos + /// logical-interface suffix (`:1`) stripped so `if_nametoindex` can + /// resolve it. + fn owner_name(&self, addr: IpAddr) -> Result { + self.iter() + .find(|entry| entry.addr() == Some(addr)) + .and_then(IfAddr::name) + .map(base_ifname) + .filter(|base| !base.is_empty()) + .map(str::to_owned) + .with_context(|| format!("no interface has address `{addr}`")) + } + + /// The first IPv4 address bound to interface `name`. + fn v4_addr(&self, name: &str) -> Result { + self.iter() + .filter(|entry| entry.matches(name)) + .find_map(|entry| match entry.addr() { + Some(IpAddr::V4(addr)) => Some(addr), + _ => None, + }) + .with_context(|| format!("interface `{name}` has no IPv4 address")) } } @@ -186,72 +211,96 @@ impl Drop for IfAddrs { } } -fn entry_name(entry: &libc::ifaddrs) -> Option<&str> { - // SAFETY: `ifa_name` is a NUL-terminated string owned by the list. - unsafe { CStr::from_ptr(entry.ifa_name) }.to_str().ok() -} +/// Borrowed entry of an [`IfAddrs`] list, valid while the list lives. +#[derive(Clone, Copy)] +struct IfAddr<'a>(&'a libc::ifaddrs); -/// Whether the entry belongs to interface `name` or not. On illumos, addresses -/// live on logical interfaces (`name:1`, `name:2`), so those count too. -fn entry_matches(entry: &libc::ifaddrs, name: &str) -> bool { - if name.is_empty() { - return false; +impl<'a> IfAddr<'a> { + fn name(self) -> Option<&'a str> { + // SAFETY: `ifa_name` is a NUL-terminated string owned by the list. + unsafe { CStr::from_ptr(self.0.ifa_name) }.to_str().ok() } - entry_name(entry).is_some_and(|entry_name| { - entry_name == name - || entry_name - .strip_prefix(name) - .is_some_and(|rest| rest.starts_with(':')) - }) -} -fn entry_addr(entry: &libc::ifaddrs) -> Option { - if entry.ifa_addr.is_null() { - return None; + fn matches(self, name: &str) -> bool { + self.name() + .is_some_and(|entry_name| ifname_matches(entry_name, name)) } - // SAFETY: `ifa_addr` is non-null and only reinterpreted as the - // sockaddr type its family indicates. `getifaddrs` allocates each - // sockaddr with the alignment that concrete type requires. - unsafe { - match i32::from((*entry.ifa_addr).sa_family) { - libc::AF_INET => { - let sin = &*(entry.ifa_addr as *const libc::sockaddr_in); - Some(IpAddr::V4(Ipv4Addr::from(u32::from_be( - sin.sin_addr.s_addr, - )))) - } - libc::AF_INET6 => { - let sin6 = &*(entry.ifa_addr as *const libc::sockaddr_in6); - Some(IpAddr::V6(Ipv6Addr::from(sin6.sin6_addr.s6_addr))) + fn addr(self) -> Option { + let sockaddr = self.0.ifa_addr; + if sockaddr.is_null() { + return None; + } + + // SAFETY: `ifa_addr` is non-null and only reinterpreted as the + // sockaddr type its family indicates. `getifaddrs` allocates each + // sockaddr with the alignment that concrete type requires. + unsafe { + match i32::from((*sockaddr).sa_family) { + libc::AF_INET => { + let sin = &*(sockaddr as *const libc::sockaddr_in); + Some(IpAddr::V4(Ipv4Addr::from(u32::from_be( + sin.sin_addr.s_addr, + )))) + } + libc::AF_INET6 => { + let sin6 = &*(sockaddr as *const libc::sockaddr_in6); + Some(IpAddr::V6(Ipv6Addr::from(sin6.sin6_addr.s6_addr))) + } + _ => None, } - _ => None, } } } -/// The base name of the interface `addr` is bound to, with any illumos -/// logical-interface suffix (`:1`) stripped so it resolves with -/// `if_nametoindex`. -fn owner_name(ifaddrs: &IfAddrs, addr: IpAddr) -> Result { - ifaddrs - .iter() - .find(|entry| entry_addr(entry) == Some(addr)) - .and_then(entry_name) - .and_then(|name| name.split(':').next()) - .filter(|base| !base.is_empty()) - .map(str::to_owned) - .ok_or_else(|| anyhow!("no interface has address `{addr}`")) +/// The interface an illumos logical interface name belongs to, e.g. `net0` +/// for `net0:1`. +fn base_ifname(name: &str) -> &str { + name.split_once(':').map_or(name, |(base, _)| base) } -/// The first IPv4 address bound to interface `name`. -fn v4_addr_of(ifaddrs: &IfAddrs, name: &str) -> Result { - ifaddrs - .iter() - .filter(|entry| entry_matches(entry, name)) - .find_map(|entry| match entry_addr(entry) { - Some(IpAddr::V4(addr)) => Some(addr), - _ => None, - }) - .ok_or_else(|| anyhow!("interface `{name}` has no IPv4 address")) +/// Whether `entry_name` belongs to interface `name`. On illumos, addresses +/// live on logical interfaces (`name:1`, `name:2`), which count as matches +/// too. +fn ifname_matches(entry_name: &str, name: &str) -> bool { + !name.is_empty() + && (entry_name == name + || entry_name + .strip_prefix(name) + .is_some_and(|rest| rest.starts_with(':'))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn base_ifname_strips_logical_suffix() { + assert_eq!(base_ifname("net0"), "net0"); + assert_eq!(base_ifname("net0:1"), "net0"); + assert_eq!(base_ifname(":1"), ""); + } + + #[test] + fn ifname_matches_base_and_logical_interfaces() { + assert!(ifname_matches("net0", "net0")); + assert!(ifname_matches("net0:1", "net0")); + assert!(ifname_matches("net0:1", "net0:1")); + assert!(!ifname_matches("net01", "net0")); + assert!(!ifname_matches("net0", "net0:1")); + assert!(!ifname_matches("net0", "")); + } + + #[test] + fn ifaddrs_resolves_loopback() { + let ifaddrs = IfAddrs::load().unwrap(); + let name = ifaddrs.owner_name(IpAddr::V4(Ipv4Addr::LOCALHOST)).unwrap(); + + assert!(!name.contains(':'), "`{name}` kept a logical suffix"); + assert!(ifname_to_index(&name).is_ok()); + assert!(ifaddrs.v4_addr(&name).unwrap().is_loopback()); + + assert!(ifaddrs.owner_name("192.0.2.255".parse().unwrap()).is_err()); + assert!(ifaddrs.v4_addr("thundermuffin0").is_err()); + } } diff --git a/smf/method_script.sh b/smf/method_script.sh index 599cfe9..a3bf04c 100755 --- a/smf/method_script.sh +++ b/smf/method_script.sh @@ -32,21 +32,23 @@ export RUST_LOG=info BIN=/opt/oxide/thundermuffin/bin/thundermuffin if [[ ! -x "$BIN" ]]; then - echo "thundermuffin binary not found at $BIN" >&2 - exit 1 + echo "thundermuffin binary not found at $BIN" >&2 + exit 1 fi # Read an optional property, normalizing svcprop's representation of -# "nothing there" to an empty string. svcprop -c quotes astring values, so -# an empty value is emitted as a literal pair of double quotes. An unset -# property makes svcprop fail entirely. Both cases collapse to "". +# "nothing there" to an empty string. +# +# svcprop -c quotes astring values, and an empty value comes out as a literal +# pair of double quotes. An unset property makes svcprop fail entirely. +# Both cases collapse to "". svcprop_optional() { - local value - value=$(svcprop -c -p "$1" "${SMF_FMRI}" 2>/dev/null) || value="" - if [[ "$value" == '""' ]]; then - value="" - fi - printf '%s' "$value" + local value + value=$(svcprop -c -p "$1" "${SMF_FMRI}" 2>/dev/null) || value="" + if [[ "$value" == '""' ]]; then + value="" + fi + printf '%s' "$value" } # Multi-valued list of multicast groups to join and hold open. The illumos IP @@ -58,11 +60,11 @@ svcprop_optional() { groups=$(svcprop_optional config/multicast_group) if [[ -z "$groups" ]]; then - # No groups configured. Stay online but idle so the service does not - # flap. The sled-agent rewrites config and restarts the service when - # membership changes. - echo "no multicast groups configured; idling" >&2 - exec sleep infinity + # No groups configured. Stay online but idle so the service does not + # flap. The sled-agent rewrites config and restarts the service when + # membership changes. + echo "no multicast groups configured; idling" >&2 + exec sleep infinity fi # svc.startd sets SMF_FMRI in every method's environment, see smf_method(7) @@ -72,43 +74,43 @@ port=$(svcprop -c -p config/port "${SMF_FMRI}") # Interface the joins should be pinned to (the probe's overlay port), as an # interface name or an IP address bound to it. The binary resolves it per # group family: the interface's IPv4 address for IP_MULTICAST_IF and the -# IPv4 joins, its ifindex for IPV6_MULTICAST_IF and the IPv6 joins. +# IPv4 joins; its ifindex for IPV6_MULTICAST_IF and the IPv6 joins. # # Note: this may be unset. iface=$(svcprop_optional config/multicast_iface) iface_args=() if [[ -n "$iface" ]]; then - iface_args=(--multicast-iface "$iface") + iface_args=(--multicast-iface "$iface") fi # Spawn one joiner per group. pids=() for entry in $groups; do - # svcprop -c quotes each astring value, so strip the surrounding quotes. - entry=${entry#\"} - entry=${entry%\"} - [[ -z "$entry" ]] && continue - # sled-agent encodes a source-specific (SSM) membership as - # `group@src1,src2` and an any-source (ASM) membership as a bare `group`. - # The `@` separator is not a shell metacharacter, so svcprop emits it - # verbatim. - # - # We split off the optional source list and pass one `--multicast-source` - # per source so an SSM group gets the INCLUDE-mode (S, G) join it requires. - group=${entry%%@*} - src_args=() - if [[ "$entry" == *"@"* ]]; then - sources=${entry#*@} - IFS=',' read -ra src_list <<< "$sources" - for src in "${src_list[@]}"; do - [[ -z "$src" ]] && continue - src_args+=(--multicast-source "$src") - done - fi - "$BIN" --transport udp --port "$port" "${iface_args[@]}" \ - server "$group" "${src_args[@]}" & - pids+=("$!") + # svcprop -c quotes each astring value, so strip the surrounding quotes. + entry=${entry#\"} + entry=${entry%\"} + [[ -z "$entry" ]] && continue + # sled-agent encodes a source-specific (SSM) membership as + # `group@src1,src2` and an any-source (ASM) membership as a bare `group`. + # The `@` separator is not a shell metacharacter, so svcprop emits it + # verbatim. + # + # We split off the optional source list and pass one `--multicast-source` + # per source so an SSM group gets the INCLUDE-mode (S, G) join it requires. + group=${entry%%@*} + src_args=() + if [[ "$entry" == *"@"* ]]; then + sources=${entry#*@} + IFS=',' read -ra src_list <<<"$sources" + for src in "${src_list[@]}"; do + [[ -z "$src" ]] && continue + src_args+=(--multicast-source "$src") + done + fi + "$BIN" --transport udp --port "$port" "${iface_args[@]}" \ + server "$group" "${src_args[@]}" & + pids+=("$!") done # If every entry stripped to an empty string (e.g., a multi-valued property @@ -116,8 +118,8 @@ done # instead of calling `wait -n` with no pids, which returns 127 and would # restart-loop the service. if ((${#pids[@]} == 0)); then - echo "no non-empty multicast groups configured; idling" >&2 - exec sleep infinity + echo "no non-empty multicast groups configured; idling" >&2 + exec sleep infinity fi # Block on the joiners to keep the start method's process contract (created