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..c3e4c06 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,19 @@ 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. + /// + /// 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, + multicast_iface: Option, #[command(subcommand)] kind: Participant, @@ -86,7 +93,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 @@ -108,9 +115,16 @@ struct Server { multicast_source: Vec, } -/// 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. +/// 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. +/// +/// [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() @@ -118,7 +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}`)")); } - Ok(addr) + + 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, + }, + }; + match reason { + Some(reason) => Err(format!( + "must be a routable source address, not {reason} (got `{addr}`)" + )), + None => Ok(addr), + } } impl Server { @@ -127,7 +166,38 @@ impl Server { cli: &Cli, cmd: &mut clap::Command, ) -> Result<(), clap::Error> { + // 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() { + // 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 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 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, 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 + ), + )); + } return Ok(()); } if !self.listen.is_multicast() { @@ -152,24 +222,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 @@ -243,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 3272311..7fb5aa4 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, 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 @@ -8,9 +9,102 @@ //! [`setsourcefilter(3SOCKET)`]: https://illumos.org/man/3SOCKET/setsourcefilter use socket2::Socket; -use std::net::Ipv6Addr; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use std::os::fd::AsRawFd; +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. +/// +/// 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). +/// +/// [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 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] == IPV4_SSM_FIRST_OCTET, + IpAddr::V6(v6) => ipv6_segments_are_ssm(v6.segments()), + } +} + +/// 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). +/// +/// Callers must check [`is_ssm_multicast`] first. +/// +/// [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) => { + if v4 == IPV4_SSM_RESERVED_NULL { + return Err(format!( + "{v4} is the reserved IPv4 SSM null address \ + (RFC 4607 §4.3)" + )); + } + } + IpAddr::V6(v6) => { + let segments = v6.segments(); + 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..6].iter().all(|segment| *segment == 0); + let group_id = + (u32::from(segments[6]) << 16) | u32::from(segments[7]); + + if within_prefix && group_id == IPV6_SSM_NULL_GROUP_ID { + return Err(format!( + "{v6} is the reserved IPv6 SSM null address \ + (ff3x::4000:0, RFC 4607 §4.3)" + )); + } + } + } + + Ok(()) +} + // RFC 3678 / POSIX include-mode source filter. Standardized as `1` on every // platform that exposes `setsourcefilter`. const MCAST_INCLUDE: u32 = 1; @@ -97,3 +191,91 @@ 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: 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() { + assert!( + validate_ssm_destination("232.0.0.0".parse().unwrap()).is_err() + ); + 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" + ); + } + + 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" + ); + } + + 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 [ + "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_ok(), + "{scoped} should be accepted for its scope" + ); + } + } +} diff --git a/app/src/udp.rs b/app/src/udp.rs index d6b1ddf..d54bd04 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`. +/// +/// 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 + } 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..cbb6375 100644 --- a/app/src/util.rs +++ b/app/src/util.rs @@ -1,3 +1,10 @@ +use anyhow::{Context, Result, 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,249 @@ 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 = ifaddrs.owner_name(*addr)?; + ifaddrs.v4_addr(&name).with_context(|| { + format!("selector `{addr}` resolved to interface `{name}`") + }) + } + Self::Name(name) => IfAddrs::load()?.v4_addr(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(&IfAddrs::load()?.owner_name(*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 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(idx) +} + +/// 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() + }) + .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")) + } +} + +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) }; + } +} + +/// Borrowed entry of an [`IfAddrs`] list, valid while the list lives. +#[derive(Clone, Copy)] +struct IfAddr<'a>(&'a libc::ifaddrs); + +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() + } + + fn matches(self, name: &str) -> bool { + self.name() + .is_some_and(|entry_name| ifname_matches(entry_name, name)) + } + + 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, + } + } + } +} + +/// 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) +} + +/// 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 cf2a834..a3bf04c 100755 --- a/smf/method_script.sh +++ b/smf/method_script.sh @@ -1,18 +1,137 @@ #!/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. 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 +# 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}")" -) +BIN=/opt/oxide/thundermuffin/bin/thundermuffin +if [[ ! -x "$BIN" ]]; then + 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, 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" +} + +# 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_optional config/multicast_group) -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 - exit 1 +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 + +# 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 + # 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..5b306c0 100644 --- a/smf/thundermuffin/manifest.xml +++ b/smf/thundermuffin/manifest.xml @@ -11,12 +11,14 @@ + +