Skip to main content

majordomus_cli/mesh/
broadcast.rs

1//! The UDP broadcast provider: the controlled fallback for segments where multicast is
2//! filtered. Same protocol, same port, no second serialization — only the destination
3//! differs: the limited broadcast address in `auto` mode, or the directed broadcast
4//! addresses the declaration lists in `explicit` mode. Never `auto` by default, and it
5//! does not spray interfaces: one socket, the destinations named above, nothing more.
6//!
7//! Transmit-first by design: when the multicast provider runs, its socket already hears
8//! everything aimed at the port and a second bound socket would fight it for the
9//! address. This provider binds a listener only when it is the sole UDP provider.
10//!
11//! ```
12//! use majordomus_cli::mesh::broadcast::BroadcastProvider;
13//! use majordomus_cli::mesh::config::{BroadcastConfig, BroadcastMode};
14//! use majordomus_cli::mesh::provider::{MeshProvider, MeshProviderState};
15//!
16//! // A disabled declaration yields a provider that starts nothing and says so.
17//! let provider = BroadcastProvider::new(BroadcastConfig::default(), false);
18//! let status = provider.status();
19//! assert_eq!(status.id, "udp_broadcast");
20//! assert_eq!(status.state, MeshProviderState::Stopped);
21//! assert_eq!(status.sent, 0);
22//! # let _ = BroadcastMode::Auto;
23//! ```
24
25use std::net::{Ipv4Addr, SocketAddrV4, UdpSocket};
26use std::sync::atomic::Ordering;
27use std::sync::{Arc, Mutex};
28use std::time::Duration;
29
30use super::config::{BroadcastConfig, BroadcastMode};
31use super::protocol::{encode, MAX_DATAGRAM};
32use super::provider::{
33    jitter_ms, Counters, MeshProvider, MeshProviderState, Observation, ProviderContext,
34    ProviderStatus,
35};
36use super::registry::MeshSource;
37use super::MeshError;
38
39const READ_TIMEOUT: Duration = Duration::from_millis(500);
40
41/// The broadcast provider: the declared destinations, the counters its threads share,
42/// and the state its status reports. Constructed by the manager from the declaration.
43pub struct BroadcastProvider {
44    config: BroadcastConfig,
45    /// Whether this provider should also listen (no multicast provider is running).
46    listen: bool,
47    counters: Arc<Counters>,
48    state: Arc<Mutex<(MeshProviderState, Option<String>)>>,
49}
50
51impl BroadcastProvider {
52    /// A provider over its declaration; `listen` when no other UDP socket hears the
53    /// port — with the multicast provider running, that socket already hears every
54    /// datagram aimed at it, and this provider only transmits.
55    pub fn new(config: BroadcastConfig, listen: bool) -> Self {
56        BroadcastProvider {
57            config,
58            listen,
59            counters: Arc::new(Counters::default()),
60            state: Arc::new(Mutex::new((MeshProviderState::Stopped, None))),
61        }
62    }
63
64    fn destinations(&self) -> Result<Vec<SocketAddrV4>, MeshError> {
65        match self.config.mode {
66            BroadcastMode::Disabled => Ok(Vec::new()),
67            BroadcastMode::Auto => Ok(vec![SocketAddrV4::new(
68                Ipv4Addr::BROADCAST,
69                self.config.port,
70            )]),
71            BroadcastMode::Explicit => self
72                .config
73                .networks
74                .iter()
75                .map(|n| {
76                    n.parse::<Ipv4Addr>()
77                        .map(|ip| SocketAddrV4::new(ip, self.config.port))
78                        .map_err(|_| {
79                            MeshError::Provider(format!("'{n}' is not an IPv4 broadcast address"))
80                        })
81                })
82                .collect(),
83        }
84    }
85}
86
87impl MeshProvider for BroadcastProvider {
88    fn id(&self) -> &'static str {
89        "udp_broadcast"
90    }
91
92    fn start(&mut self, ctx: &ProviderContext) -> Result<(), MeshError> {
93        let destinations = match self.destinations() {
94            Ok(d) => d,
95            Err(e) => {
96                *self.state.lock().expect("broadcast state") =
97                    (MeshProviderState::Failed, Some(e.to_string()));
98                return Err(e);
99            }
100        };
101        if destinations.is_empty() {
102            let reason = "mode is disabled".to_string();
103            *self.state.lock().expect("broadcast state") =
104                (MeshProviderState::Stopped, Some(reason.clone()));
105            return Err(MeshError::Provider(reason));
106        }
107        // The sender binds an ephemeral port (the listener, when this provider has one,
108        // owns the well-known port); directed and limited broadcast need the flag.
109        let bind_port = if self.listen { self.config.port } else { 0 };
110        let socket = match UdpSocket::bind((Ipv4Addr::UNSPECIFIED, bind_port)) {
111            Ok(s) => s,
112            Err(e) => {
113                let reason = format!("cannot bind udp port {bind_port}: {e}");
114                *self.state.lock().expect("broadcast state") =
115                    (MeshProviderState::Failed, Some(reason.clone()));
116                return Err(MeshError::Provider(reason));
117            }
118        };
119        if let Err(e) = socket.set_broadcast(true) {
120            let reason = format!("cannot enable broadcast: {e}");
121            *self.state.lock().expect("broadcast state") =
122                (MeshProviderState::Failed, Some(reason.clone()));
123            return Err(MeshError::Provider(reason));
124        }
125        let _ = socket.set_read_timeout(Some(READ_TIMEOUT));
126        *self.state.lock().expect("broadcast state") = (
127            MeshProviderState::Running,
128            Some(format!(
129                "{} destination(s), {}",
130                destinations.len(),
131                if self.listen {
132                    "listening"
133                } else {
134                    "transmit only"
135                }
136            )),
137        );
138
139        if self.listen {
140            let socket = socket.try_clone().map_err(|e| {
141                MeshError::Provider(format!("cannot clone the broadcast socket: {e}"))
142            })?;
143            let tx = ctx.tx.clone();
144            let stop = Arc::clone(&ctx.stop);
145            let counters = Arc::clone(&self.counters);
146            let state = Arc::clone(&self.state);
147            let _ = std::thread::Builder::new()
148                .name("majordomus-mesh-broadcast-listen".into())
149                .spawn(move || {
150                    let mut buffer = [0u8; MAX_DATAGRAM + 1];
151                    while !stop.load(Ordering::SeqCst) {
152                        match socket.recv_from(&mut buffer) {
153                            Ok((len, from)) => {
154                                counters.received.fetch_add(1, Ordering::Relaxed);
155                                let _ = tx.send(Observation {
156                                    source: MeshSource::UdpBroadcast,
157                                    path: from.to_string(),
158                                    bytes: buffer[..len].to_vec(),
159                                });
160                            }
161                            Err(e)
162                                if e.kind() == std::io::ErrorKind::WouldBlock
163                                    || e.kind() == std::io::ErrorKind::TimedOut => {}
164                            Err(e) => {
165                                *state.lock().expect("broadcast state") =
166                                    (MeshProviderState::Failed, Some(format!("recv: {e}")));
167                                return;
168                            }
169                        }
170                    }
171                    *state.lock().expect("broadcast state") = (MeshProviderState::Stopped, None);
172                });
173        }
174
175        {
176            let stop = Arc::clone(&ctx.stop);
177            let beacon = Arc::clone(&ctx.beacon);
178            let counters = Arc::clone(&self.counters);
179            let interval = Duration::from_secs(self.config.interval_seconds);
180            let _ = std::thread::Builder::new()
181                .name("majordomus-mesh-broadcast-announce".into())
182                .spawn(move || {
183                    while !stop.load(Ordering::SeqCst) {
184                        if let Ok(bytes) = encode(&beacon.next_envelope()) {
185                            for destination in &destinations {
186                                if socket.send_to(&bytes, destination).is_ok() {
187                                    counters.sent.fetch_add(1, Ordering::Relaxed);
188                                }
189                            }
190                        }
191                        let total = interval + Duration::from_millis(jitter_ms(2000));
192                        let mut slept = Duration::ZERO;
193                        while slept < total && !stop.load(Ordering::SeqCst) {
194                            let slice = READ_TIMEOUT.min(total - slept);
195                            std::thread::sleep(slice);
196                            slept += slice;
197                        }
198                    }
199                });
200        }
201        Ok(())
202    }
203
204    fn status(&self) -> ProviderStatus {
205        let (state, detail) = self.state.lock().expect("broadcast state").clone();
206        ProviderStatus {
207            id: self.id().into(),
208            state,
209            detail,
210            sent: self.counters.sent.load(Ordering::Relaxed),
211            received: self.counters.received.load(Ordering::Relaxed),
212        }
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[test]
221    fn destinations_follow_the_declared_mode() {
222        let auto = BroadcastProvider::new(
223            BroadcastConfig {
224                mode: BroadcastMode::Auto,
225                ..BroadcastConfig::default()
226            },
227            false,
228        );
229        assert_eq!(auto.destinations().unwrap().len(), 1);
230        let explicit = BroadcastProvider::new(
231            BroadcastConfig {
232                mode: BroadcastMode::Explicit,
233                networks: vec!["192.168.1.255".into(), "10.0.0.255".into()],
234                ..BroadcastConfig::default()
235            },
236            false,
237        );
238        assert_eq!(explicit.destinations().unwrap().len(), 2);
239        let disabled = BroadcastProvider::new(BroadcastConfig::default(), false);
240        assert!(disabled.destinations().unwrap().is_empty());
241    }
242
243    /// How many fresh ports a listening start may try before the test gives up.
244    const BIND_ATTEMPTS: usize = 8;
245
246    /// The text the host gives a bind that finds its address taken, read from a real
247    /// collision rather than written down: it differs between operating systems.
248    fn address_in_use() -> String {
249        let held = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).unwrap();
250        let refused = UdpSocket::bind(held.local_addr().unwrap()).unwrap_err();
251        assert_eq!(refused.kind(), std::io::ErrorKind::AddrInUse, "{refused}");
252        refused.to_string()
253    }
254
255    /// Start a listening provider on a port nobody holds, and hand back the provider with
256    /// the port it holds.
257    ///
258    /// A free port can only be found by binding port 0 and letting it go, and between the
259    /// release and the provider's own bind any process on the host may take it: under load
260    /// one did, and the test failed with "cannot bind udp port 55932: Address already in
261    /// use". The provider binding the port it was configured with is what is under test,
262    /// so the port stays a configured one; a start that lost that race, and only that
263    /// race, is retried with a fresh port a bounded number of times. Any other failure of
264    /// the start fails the test at once.
265    fn start_listening_on_a_free_port(
266        ctx: &crate::mesh::provider::ProviderContext,
267    ) -> (BroadcastProvider, u16) {
268        let in_use = address_in_use();
269        let mut lost = Vec::new();
270        for _ in 0..BIND_ATTEMPTS {
271            let port = {
272                let probe = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).unwrap();
273                probe.local_addr().unwrap().port()
274            };
275            let mut provider = BroadcastProvider::new(
276                BroadcastConfig {
277                    mode: BroadcastMode::Auto,
278                    port,
279                    interval_seconds: 1,
280                    ..BroadcastConfig::default()
281                },
282                true,
283            );
284            match provider.start(ctx) {
285                Ok(()) => {
286                    // Hard: the sole UDP provider binds its listening socket and reaches
287                    // Running.
288                    assert_eq!(provider.status().state, MeshProviderState::Running);
289                    return (provider, port);
290                }
291                Err(e)
292                    if e.to_string()
293                        .ends_with(&format!("cannot bind udp port {port}: {in_use}")) =>
294                {
295                    lost.push(port);
296                }
297                Err(e) => panic!("the listening provider did not start: {e}"),
298            }
299        }
300        panic!("every port probed was taken before the provider could bind it: {lost:?}");
301    }
302
303    #[test]
304    fn the_sole_udp_provider_listens_and_counts_what_it_hears() {
305        let (tx, rx) = crate::mesh::provider::ObservationSender::bounded(64);
306        let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
307        let ctx = crate::mesh::provider::ProviderContext {
308            tx,
309            stop: std::sync::Arc::clone(&stop),
310            beacon: std::sync::Arc::new(crate::mesh::provider::Beacon::new(
311                std::sync::Arc::new(crate::mesh::identity::NodeIdentity::ephemeral().unwrap()),
312                vec![],
313                vec![],
314                vec![],
315                "test",
316            )),
317        };
318        let (_provider, port) = start_listening_on_a_free_port(&ctx);
319        // Best-effort: a host that delivers the loopback datagram hands it up as a
320        // broadcast observation; one that does not route it leaves the channel silent.
321        // The bound socket was the invariant under test; delivery is the network's to
322        // grant, and the round-trip is proven host-independently in tests/mesh.rs.
323        let sender = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
324        let _ = sender.send_to(b"aimed at the port", (Ipv4Addr::LOCALHOST, port));
325        if let Ok(heard) = rx.recv_timeout(Duration::from_secs(2)) {
326            assert_eq!(heard.source, MeshSource::UdpBroadcast);
327        }
328        stop.store(true, std::sync::atomic::Ordering::SeqCst);
329    }
330
331    #[test]
332    fn a_malformed_network_is_an_error_naming_it() {
333        let provider = BroadcastProvider::new(
334            BroadcastConfig {
335                mode: BroadcastMode::Explicit,
336                networks: vec!["not-an-address".into()],
337                ..BroadcastConfig::default()
338            },
339            false,
340        );
341        let error = provider.destinations().unwrap_err().to_string();
342        assert!(error.contains("not-an-address"), "{error}");
343    }
344}