Skip to main content

mctx_core/
config.rs

1use crate::error::MctxError;
2use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
3
4/// The address family used by one publication.
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
6pub enum PublicationAddressFamily {
7    Ipv4,
8    Ipv6,
9}
10
11/// Explicit outgoing multicast interface selection.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13pub enum OutgoingInterface {
14    /// Select the IPv4 multicast egress interface by local IPv4 address.
15    Ipv4Addr(Ipv4Addr),
16    /// Select the IPv6 multicast egress interface by local IPv6 address.
17    ///
18    /// On the IPv6 send path this also provides the exact local address to bind
19    /// when no explicit source address was configured.
20    Ipv6Addr(Ipv6Addr),
21    /// Select the IPv6 multicast egress interface by interface index.
22    Ipv6Index(u32),
23}
24
25impl From<Ipv4Addr> for OutgoingInterface {
26    fn from(value: Ipv4Addr) -> Self {
27        Self::Ipv4Addr(value)
28    }
29}
30
31impl From<Ipv6Addr> for OutgoingInterface {
32    fn from(value: Ipv6Addr) -> Self {
33        Self::Ipv6Addr(value)
34    }
35}
36
37/// The multicast scope encoded in an IPv6 group address.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum Ipv6MulticastScope {
40    InterfaceLocal,
41    LinkLocal,
42    RealmLocal,
43    AdminLocal,
44    SiteLocal,
45    OrganizationLocal,
46    Global,
47    Other(u8),
48}
49
50/// Configuration for one multicast publication socket.
51#[derive(Debug, Clone, PartialEq, Eq, Hash)]
52pub struct PublicationConfig {
53    /// The destination multicast group.
54    pub group: IpAddr,
55    /// The destination UDP port.
56    pub dst_port: u16,
57    /// The explicit multicast egress interface, if set.
58    pub outgoing_interface: Option<OutgoingInterface>,
59    /// The source UDP port to bind before sending, if explicitly set.
60    pub source_port: Option<u16>,
61    /// The source IP address to bind before sending, if explicitly set.
62    pub source_addr: Option<IpAddr>,
63    /// The multicast TTL (IPv4) or hop limit (IPv6) for transmitted packets.
64    pub ttl: u32,
65    /// Whether outbound multicast packets should be looped back to the local host.
66    pub loopback: bool,
67}
68
69impl PublicationConfig {
70    /// Creates a basic multicast publication configuration.
71    pub fn new(group: impl Into<IpAddr>, port: u16) -> Self {
72        Self {
73            group: group.into(),
74            dst_port: port,
75            outgoing_interface: None,
76            source_port: None,
77            source_addr: None,
78            ttl: 1,
79            loopback: true,
80        }
81    }
82
83    /// Returns the address family for this publication.
84    pub fn family(&self) -> PublicationAddressFamily {
85        match self.group {
86            IpAddr::V4(_) => PublicationAddressFamily::Ipv4,
87            IpAddr::V6(_) => PublicationAddressFamily::Ipv6,
88        }
89    }
90
91    /// Returns `true` when the publication targets an IPv4 group.
92    pub fn is_ipv4(&self) -> bool {
93        matches!(self.family(), PublicationAddressFamily::Ipv4)
94    }
95
96    /// Returns `true` when the publication targets an IPv6 group.
97    pub fn is_ipv6(&self) -> bool {
98        matches!(self.family(), PublicationAddressFamily::Ipv6)
99    }
100
101    /// Validates the configuration and returns an error if it is not usable.
102    pub fn validate(&self) -> Result<(), MctxError> {
103        if self.dst_port == 0 {
104            return Err(MctxError::InvalidDestinationPort);
105        }
106
107        if !self.group.is_multicast() {
108            return Err(MctxError::InvalidMulticastGroup);
109        }
110
111        if matches!(self.source_port, Some(0)) {
112            return Err(MctxError::InvalidSourcePort);
113        }
114
115        if let Some(source_addr) = self.source_addr {
116            if source_addr.is_multicast() || source_addr.is_unspecified() {
117                return Err(MctxError::InvalidSourceAddress);
118            }
119
120            if !same_family_ip(self.group, source_addr) {
121                return Err(MctxError::SourceAddressFamilyMismatch);
122            }
123        }
124
125        if let Some(interface) = self.outgoing_interface {
126            match (self.family(), interface) {
127                (PublicationAddressFamily::Ipv4, OutgoingInterface::Ipv4Addr(interface)) => {
128                    if interface.is_multicast() || interface.is_unspecified() {
129                        return Err(MctxError::InvalidInterfaceAddress);
130                    }
131                }
132                (PublicationAddressFamily::Ipv4, OutgoingInterface::Ipv6Addr(_))
133                | (PublicationAddressFamily::Ipv4, OutgoingInterface::Ipv6Index(_)) => {
134                    return Err(MctxError::OutgoingInterfaceFamilyMismatch);
135                }
136                (PublicationAddressFamily::Ipv6, OutgoingInterface::Ipv4Addr(_)) => {
137                    return Err(MctxError::OutgoingInterfaceFamilyMismatch);
138                }
139                (PublicationAddressFamily::Ipv6, OutgoingInterface::Ipv6Addr(interface)) => {
140                    if interface.is_multicast() || interface.is_unspecified() {
141                        return Err(MctxError::InvalidInterfaceAddress);
142                    }
143                }
144                (PublicationAddressFamily::Ipv6, OutgoingInterface::Ipv6Index(index)) => {
145                    if index == 0 {
146                        return Err(MctxError::InvalidIpv6InterfaceIndex);
147                    }
148                }
149            }
150        }
151
152        Ok(())
153    }
154
155    /// Sets the multicast egress interface.
156    pub fn with_outgoing_interface(
157        mut self,
158        outgoing_interface: impl Into<OutgoingInterface>,
159    ) -> Self {
160        self.outgoing_interface = Some(outgoing_interface.into());
161        self
162    }
163
164    /// Sets the multicast egress interface using the existing IPv4-oriented
165    /// convenience builder.
166    pub fn with_interface(self, interface: Ipv4Addr) -> Self {
167        self.with_outgoing_interface(interface)
168    }
169
170    /// Sets the IPv6 multicast egress interface by interface index.
171    pub fn with_ipv6_interface_index(mut self, interface_index: u32) -> Self {
172        self.outgoing_interface = Some(OutgoingInterface::Ipv6Index(interface_index));
173        self
174    }
175
176    /// Sets the source UDP port.
177    pub fn with_source_port(mut self, source_port: u16) -> Self {
178        self.source_port = Some(source_port);
179        self
180    }
181
182    /// Sets the exact local source address to bind before sending.
183    pub fn with_source_addr(mut self, source_addr: impl Into<IpAddr>) -> Self {
184        self.source_addr = Some(source_addr.into());
185        self
186    }
187
188    /// Sets the exact local address and UDP port to bind before sending.
189    pub fn with_bind_addr(mut self, bind_addr: impl Into<SocketAddr>) -> Self {
190        let bind_addr = bind_addr.into();
191        self.source_addr = Some(bind_addr.ip());
192        self.source_port = Some(bind_addr.port());
193
194        // Preserve an explicit IPv6 scope ID from scoped bind addresses so
195        // link-local senders keep their interface identity through socket setup.
196        if let SocketAddr::V6(bind_addr_v6) = bind_addr
197            && bind_addr_v6.scope_id() != 0
198        {
199            self.outgoing_interface = Some(OutgoingInterface::Ipv6Index(bind_addr_v6.scope_id()));
200        }
201
202        self
203    }
204
205    /// Sets the multicast TTL (IPv4) or hop limit (IPv6).
206    pub fn with_ttl(mut self, ttl: u32) -> Self {
207        self.ttl = ttl;
208        self
209    }
210
211    /// Enables or disables multicast loopback.
212    pub fn with_loopback(mut self, loopback: bool) -> Self {
213        self.loopback = loopback;
214        self
215    }
216
217    /// Returns the multicast scope for the configured IPv6 group, if applicable.
218    pub fn ipv6_scope(&self) -> Option<Ipv6MulticastScope> {
219        match self.group {
220            IpAddr::V6(group) => ipv6_multicast_scope(group),
221            IpAddr::V4(_) => None,
222        }
223    }
224}
225
226fn same_family_ip(left: IpAddr, right: IpAddr) -> bool {
227    matches!(
228        (left, right),
229        (IpAddr::V4(_), IpAddr::V4(_)) | (IpAddr::V6(_), IpAddr::V6(_))
230    )
231}
232
233/// Returns `true` if the group is in `ff3x::/32`.
234pub fn is_ipv6_ssm_group(group: Ipv6Addr) -> bool {
235    let octets = group.octets();
236    group.is_multicast() && (octets[1] & 0xf0) == 0x30 && octets[2] == 0 && octets[3] == 0
237}
238
239pub(crate) fn ipv6_multicast_scope(group: Ipv6Addr) -> Option<Ipv6MulticastScope> {
240    if !group.is_multicast() {
241        return None;
242    }
243
244    let scope = group.octets()[1] & 0x0f;
245    Some(match scope {
246        0x1 => Ipv6MulticastScope::InterfaceLocal,
247        0x2 => Ipv6MulticastScope::LinkLocal,
248        0x3 => Ipv6MulticastScope::RealmLocal,
249        0x4 => Ipv6MulticastScope::AdminLocal,
250        0x5 => Ipv6MulticastScope::SiteLocal,
251        0x8 => Ipv6MulticastScope::OrganizationLocal,
252        0xe => Ipv6MulticastScope::Global,
253        other => Ipv6MulticastScope::Other(other),
254    })
255}
256
257pub(crate) fn ipv6_destination_scope_id(group: Ipv6Addr, interface_index: u32) -> u32 {
258    match ipv6_multicast_scope(group) {
259        Some(Ipv6MulticastScope::InterfaceLocal | Ipv6MulticastScope::LinkLocal) => interface_index,
260        _ => 0,
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267    use std::net::{SocketAddrV4, SocketAddrV6};
268
269    #[test]
270    fn valid_ipv4_multicast_config_passes_validation() {
271        let cfg = PublicationConfig::new(Ipv4Addr::new(239, 1, 2, 3), 5000)
272            .with_source_port(5001)
273            .with_source_addr(Ipv4Addr::new(192, 168, 10, 5))
274            .with_ttl(8)
275            .with_loopback(false);
276
277        assert!(cfg.validate().is_ok());
278    }
279
280    #[test]
281    fn valid_ipv6_multicast_config_passes_validation() {
282        let cfg = PublicationConfig::new("ff31::8000:1234".parse::<Ipv6Addr>().unwrap(), 5000)
283            .with_source_addr("::1".parse::<Ipv6Addr>().unwrap())
284            .with_outgoing_interface("::1".parse::<Ipv6Addr>().unwrap())
285            .with_ttl(4);
286
287        assert!(cfg.validate().is_ok());
288        assert!(cfg.is_ipv6());
289    }
290
291    #[test]
292    fn port_zero_fails_validation() {
293        let cfg = PublicationConfig::new(Ipv4Addr::new(239, 1, 2, 3), 0);
294
295        let result = cfg.validate();
296
297        assert!(matches!(result, Err(MctxError::InvalidDestinationPort)));
298    }
299
300    #[test]
301    fn non_multicast_group_fails_validation() {
302        let cfg = PublicationConfig::new(Ipv4Addr::new(192, 168, 1, 10), 5000);
303
304        let result = cfg.validate();
305
306        assert!(matches!(result, Err(MctxError::InvalidMulticastGroup)));
307    }
308
309    #[test]
310    fn family_mismatched_source_fails_validation() {
311        let cfg = PublicationConfig::new(Ipv4Addr::new(239, 1, 2, 3), 5000)
312            .with_source_addr("::1".parse::<Ipv6Addr>().unwrap());
313
314        let result = cfg.validate();
315
316        assert!(matches!(
317            result,
318            Err(MctxError::SourceAddressFamilyMismatch)
319        ));
320    }
321
322    #[test]
323    fn family_mismatched_interface_fails_validation() {
324        let cfg = PublicationConfig::new("ff31::8000:1234".parse::<Ipv6Addr>().unwrap(), 5000)
325            .with_interface(Ipv4Addr::new(192, 168, 1, 10));
326
327        let result = cfg.validate();
328
329        assert!(matches!(
330            result,
331            Err(MctxError::OutgoingInterfaceFamilyMismatch)
332        ));
333    }
334
335    #[test]
336    fn unspecified_source_addr_fails_validation() {
337        let cfg = PublicationConfig::new(Ipv4Addr::new(239, 1, 2, 3), 5000)
338            .with_source_addr(Ipv4Addr::UNSPECIFIED);
339
340        let result = cfg.validate();
341
342        assert!(matches!(result, Err(MctxError::InvalidSourceAddress)));
343    }
344
345    #[test]
346    fn zero_ipv6_interface_index_fails_validation() {
347        let cfg = PublicationConfig::new("ff31::8000:1234".parse::<Ipv6Addr>().unwrap(), 5000)
348            .with_ipv6_interface_index(0);
349
350        let result = cfg.validate();
351
352        assert!(matches!(result, Err(MctxError::InvalidIpv6InterfaceIndex)));
353    }
354
355    #[test]
356    fn bind_addr_builder_sets_source_fields_for_ipv4() {
357        let bind_addr = SocketAddrV4::new(Ipv4Addr::new(10, 1, 2, 3), 5001);
358        let cfg =
359            PublicationConfig::new(Ipv4Addr::new(239, 1, 2, 3), 5000).with_bind_addr(bind_addr);
360
361        assert_eq!(
362            cfg.source_addr,
363            Some(IpAddr::V4(Ipv4Addr::new(10, 1, 2, 3)))
364        );
365        assert_eq!(cfg.source_port, Some(5001));
366    }
367
368    #[test]
369    fn bind_addr_builder_sets_source_fields_for_ipv6() {
370        let bind_addr = SocketAddrV6::new("fd00::10".parse().unwrap(), 5001, 0, 0);
371        let cfg = PublicationConfig::new("ff3e::8000:1234".parse::<Ipv6Addr>().unwrap(), 5000)
372            .with_bind_addr(bind_addr);
373
374        assert_eq!(
375            cfg.source_addr,
376            Some(IpAddr::V6("fd00::10".parse::<Ipv6Addr>().unwrap()))
377        );
378        assert_eq!(cfg.source_port, Some(5001));
379    }
380
381    #[test]
382    fn bind_addr_builder_preserves_ipv6_scope_as_interface_index() {
383        let bind_addr = SocketAddrV6::new("fe80::1234".parse().unwrap(), 5001, 0, 7);
384        let cfg = PublicationConfig::new("ff32::8000:1234".parse::<Ipv6Addr>().unwrap(), 5000)
385            .with_bind_addr(bind_addr);
386
387        assert_eq!(
388            cfg.outgoing_interface,
389            Some(OutgoingInterface::Ipv6Index(7))
390        );
391    }
392
393    #[test]
394    fn ipv6_ssm_detection_only_matches_ff3x_groups() {
395        assert!(is_ipv6_ssm_group("ff31::8000:1234".parse().unwrap()));
396        assert!(is_ipv6_ssm_group("ff3e::8000:1234".parse().unwrap()));
397        assert!(!is_ipv6_ssm_group("ff12::1234".parse().unwrap()));
398        assert!(!is_ipv6_ssm_group("ff31:1234::1".parse().unwrap()));
399    }
400
401    #[test]
402    fn link_local_ipv6_group_keeps_interface_index_in_destination_scope() {
403        let group = "ff32::8000:1234".parse::<Ipv6Addr>().unwrap();
404
405        assert_eq!(ipv6_destination_scope_id(group, 7), 7);
406    }
407
408    #[test]
409    fn wider_scope_ipv6_group_clears_destination_scope() {
410        let group = "ff3e::8000:1234".parse::<Ipv6Addr>().unwrap();
411
412        assert_eq!(ipv6_destination_scope_id(group, 7), 0);
413    }
414}