dstar_gateway_server/reflector/
config.rs

1//! `ReflectorConfig` — multi-client reflector configuration.
2//!
3//! The configuration uses a typestate builder (mirroring
4//! `SessionBuilder` in `dstar-gateway-core`) so required fields
5//! (`callsign`, `modules`, `bind`) are enforced at compile time. All
6//! other fields have sensible defaults drawn from the rewrite
7//! design spec Section 8.
8
9use std::collections::HashSet;
10use std::marker::PhantomData;
11use std::net::SocketAddr;
12use std::time::Duration;
13
14use dstar_gateway_core::types::{Callsign, Module, ProtocolKind};
15
16/// Marker indicating a required `ReflectorConfigBuilder` field has NOT been set.
17#[derive(Debug)]
18pub struct Missing;
19
20/// Marker indicating a required `ReflectorConfigBuilder` field HAS been set.
21#[derive(Debug)]
22pub struct Provided;
23
24/// Errors produced by [`ReflectorConfigBuilder::build`].
25#[non_exhaustive]
26#[derive(Debug, thiserror::Error)]
27pub enum ConfigError {
28    /// `modules` was set but contained zero entries.
29    #[error("reflector module set must contain at least one module")]
30    EmptyModules,
31}
32
33/// Complete configuration for a multi-client reflector.
34///
35/// Construct via [`Self::builder`] — the typestate builder enforces
36/// that `callsign`, `modules`, and `bind` are set before `build` is
37/// callable. All other fields carry defaults documented on
38/// [`ReflectorConfigBuilder`].
39#[non_exhaustive]
40#[derive(Debug, Clone)]
41pub struct ReflectorConfig {
42    /// Reflector's own callsign (e.g. `REF030`).
43    pub callsign: Callsign,
44    /// Modules this reflector exposes.
45    pub modules: HashSet<Module>,
46    /// UDP bind address.
47    pub bind: SocketAddr,
48    /// Maximum clients allowed per module.
49    pub max_clients_per_module: usize,
50    /// Maximum clients allowed across all modules.
51    pub max_total_clients: usize,
52    /// Which protocol endpoints are enabled.
53    ///
54    /// Default: all three (`DPlus`, `DExtra`, `DCS`). Use
55    /// [`ReflectorConfigBuilder::disable`] to remove one from the
56    /// set before building.
57    pub enabled_protocols: HashSet<ProtocolKind>,
58    /// Interval between keepalive polls sent to each client.
59    pub keepalive_interval: Duration,
60    /// Inactivity window after which a silent client is evicted.
61    pub keepalive_inactivity_timeout: Duration,
62    /// Inactivity window after which a stalled voice stream is closed.
63    pub voice_inactivity_timeout: Duration,
64    /// Whether voice from one protocol should be forwarded to clients on another protocol.
65    pub cross_protocol_forwarding: bool,
66    /// Per-client TX rate limit, in voice frames per second.
67    ///
68    /// Defaults to `60.0` (3× the nominal 20 fps D-STAR voice rate)
69    /// so a legitimate voice stream never hits the limit, but a
70    /// client trying to saturate the reflector with a burst of
71    /// 200+ fps gets rate-limited. Set higher if you expect large
72    /// bursts of legitimate traffic.
73    pub tx_rate_limit_frames_per_sec: f64,
74}
75
76impl ReflectorConfig {
77    /// Check whether a specific protocol endpoint is enabled.
78    #[must_use]
79    pub fn is_enabled(&self, protocol: ProtocolKind) -> bool {
80        self.enabled_protocols.contains(&protocol)
81    }
82
83    /// Start building a [`ReflectorConfig`].
84    ///
85    /// The returned builder is a typestate: [`ReflectorConfigBuilder::build`]
86    /// only compiles when `.callsign()`, `.module_set()`, and
87    /// `.bind()` have all been called. Skipping any of the three is
88    /// a compile error — the type parameters flip from [`Missing`]
89    /// to [`Provided`] as each setter is invoked.
90    ///
91    /// # Example
92    ///
93    /// ```
94    /// use std::collections::HashSet;
95    /// use dstar_gateway_core::types::{Callsign, Module};
96    /// use dstar_gateway_server::ReflectorConfig;
97    ///
98    /// let mut modules = HashSet::new();
99    /// let _ = modules.insert(Module::try_from_char('C')?);
100    ///
101    /// let config = ReflectorConfig::builder()
102    ///     .callsign(Callsign::try_from_str("REF999")?)
103    ///     .module_set(modules)
104    ///     .bind("0.0.0.0:30001".parse()?)
105    ///     .disable(dstar_gateway_core::types::ProtocolKind::DPlus)
106    ///     .disable(dstar_gateway_core::types::ProtocolKind::Dcs)
107    ///     .build()?;
108    /// assert!(config.is_enabled(dstar_gateway_core::types::ProtocolKind::DExtra));
109    /// # Ok::<(), Box<dyn std::error::Error>>(())
110    /// ```
111    #[must_use]
112    pub fn builder() -> ReflectorConfigBuilder<Missing, Missing, Missing> {
113        ReflectorConfigBuilder::empty()
114    }
115}
116
117/// Typestate builder for [`ReflectorConfig`].
118///
119/// Parameters:
120///
121/// - `Cs` — `Missing` or `Provided`, tracks whether the callsign has been set.
122/// - `Ms` — tracks whether the module set has been provided.
123/// - `Bn` — tracks whether the bind address has been set.
124///
125/// [`Self::build`] is only implemented when all three markers are
126/// [`Provided`] — forgetting any required field turns `.build()` into
127/// a compile error.
128#[derive(Debug)]
129pub struct ReflectorConfigBuilder<Cs, Ms, Bn> {
130    callsign: Option<Callsign>,
131    modules: Option<HashSet<Module>>,
132    bind: Option<SocketAddr>,
133    max_clients_per_module: usize,
134    max_total_clients: usize,
135    enabled_protocols: HashSet<ProtocolKind>,
136    keepalive_interval: Duration,
137    keepalive_inactivity_timeout: Duration,
138    voice_inactivity_timeout: Duration,
139    cross_protocol_forwarding: bool,
140    tx_rate_limit_frames_per_sec: f64,
141    _cs: PhantomData<Cs>,
142    _ms: PhantomData<Ms>,
143    _bn: PhantomData<Bn>,
144}
145
146impl ReflectorConfigBuilder<Missing, Missing, Missing> {
147    /// Create an empty builder with default optional values.
148    ///
149    /// All three protocols are enabled by default; call
150    /// [`ReflectorConfigBuilder::disable`] to remove one.
151    fn empty() -> Self {
152        let mut enabled = HashSet::new();
153        let _a = enabled.insert(ProtocolKind::DPlus);
154        let _b = enabled.insert(ProtocolKind::DExtra);
155        let _c = enabled.insert(ProtocolKind::Dcs);
156        Self {
157            callsign: None,
158            modules: None,
159            bind: None,
160            max_clients_per_module: 50,
161            max_total_clients: 250,
162            enabled_protocols: enabled,
163            keepalive_interval: Duration::from_secs(1),
164            keepalive_inactivity_timeout: Duration::from_secs(30),
165            voice_inactivity_timeout: Duration::from_secs(2),
166            cross_protocol_forwarding: false,
167            tx_rate_limit_frames_per_sec: 60.0,
168            _cs: PhantomData,
169            _ms: PhantomData,
170            _bn: PhantomData,
171        }
172    }
173}
174
175impl<Cs, Ms, Bn> ReflectorConfigBuilder<Cs, Ms, Bn> {
176    /// Set the reflector callsign.
177    #[must_use]
178    pub fn callsign(self, callsign: Callsign) -> ReflectorConfigBuilder<Provided, Ms, Bn> {
179        ReflectorConfigBuilder {
180            callsign: Some(callsign),
181            modules: self.modules,
182            bind: self.bind,
183            max_clients_per_module: self.max_clients_per_module,
184            max_total_clients: self.max_total_clients,
185            enabled_protocols: self.enabled_protocols,
186            keepalive_interval: self.keepalive_interval,
187            keepalive_inactivity_timeout: self.keepalive_inactivity_timeout,
188            voice_inactivity_timeout: self.voice_inactivity_timeout,
189            cross_protocol_forwarding: self.cross_protocol_forwarding,
190            tx_rate_limit_frames_per_sec: self.tx_rate_limit_frames_per_sec,
191            _cs: PhantomData,
192            _ms: PhantomData,
193            _bn: PhantomData,
194        }
195    }
196
197    /// Set the module set (`HashSet<Module>` — pass one or more module letters).
198    #[must_use]
199    pub fn module_set(self, modules: HashSet<Module>) -> ReflectorConfigBuilder<Cs, Provided, Bn> {
200        ReflectorConfigBuilder {
201            callsign: self.callsign,
202            modules: Some(modules),
203            bind: self.bind,
204            max_clients_per_module: self.max_clients_per_module,
205            max_total_clients: self.max_total_clients,
206            enabled_protocols: self.enabled_protocols,
207            keepalive_interval: self.keepalive_interval,
208            keepalive_inactivity_timeout: self.keepalive_inactivity_timeout,
209            voice_inactivity_timeout: self.voice_inactivity_timeout,
210            cross_protocol_forwarding: self.cross_protocol_forwarding,
211            tx_rate_limit_frames_per_sec: self.tx_rate_limit_frames_per_sec,
212            _cs: PhantomData,
213            _ms: PhantomData,
214            _bn: PhantomData,
215        }
216    }
217
218    /// Set the UDP bind address.
219    #[must_use]
220    pub fn bind(self, bind: SocketAddr) -> ReflectorConfigBuilder<Cs, Ms, Provided> {
221        ReflectorConfigBuilder {
222            callsign: self.callsign,
223            modules: self.modules,
224            bind: Some(bind),
225            max_clients_per_module: self.max_clients_per_module,
226            max_total_clients: self.max_total_clients,
227            enabled_protocols: self.enabled_protocols,
228            keepalive_interval: self.keepalive_interval,
229            keepalive_inactivity_timeout: self.keepalive_inactivity_timeout,
230            voice_inactivity_timeout: self.voice_inactivity_timeout,
231            cross_protocol_forwarding: self.cross_protocol_forwarding,
232            tx_rate_limit_frames_per_sec: self.tx_rate_limit_frames_per_sec,
233            _cs: PhantomData,
234            _ms: PhantomData,
235            _bn: PhantomData,
236        }
237    }
238
239    /// Override the maximum clients per module (default `50`).
240    #[must_use]
241    pub const fn max_clients_per_module(mut self, value: usize) -> Self {
242        self.max_clients_per_module = value;
243        self
244    }
245
246    /// Override the maximum total clients (default `250`).
247    #[must_use]
248    pub const fn max_total_clients(mut self, value: usize) -> Self {
249        self.max_total_clients = value;
250        self
251    }
252
253    /// Add a protocol to the enabled set.
254    #[must_use]
255    pub fn enable(mut self, protocol: ProtocolKind) -> Self {
256        let _inserted = self.enabled_protocols.insert(protocol);
257        self
258    }
259
260    /// Remove a protocol from the enabled set.
261    #[must_use]
262    pub fn disable(mut self, protocol: ProtocolKind) -> Self {
263        let _removed = self.enabled_protocols.remove(&protocol);
264        self
265    }
266
267    /// Override the keepalive poll interval (default `1s`).
268    #[must_use]
269    pub const fn keepalive_interval(mut self, value: Duration) -> Self {
270        self.keepalive_interval = value;
271        self
272    }
273
274    /// Override the keepalive inactivity timeout (default `30s`).
275    #[must_use]
276    pub const fn keepalive_inactivity_timeout(mut self, value: Duration) -> Self {
277        self.keepalive_inactivity_timeout = value;
278        self
279    }
280
281    /// Override the voice inactivity timeout (default `2s`).
282    #[must_use]
283    pub const fn voice_inactivity_timeout(mut self, value: Duration) -> Self {
284        self.voice_inactivity_timeout = value;
285        self
286    }
287
288    /// Enable or disable cross-protocol forwarding (default `false`).
289    #[must_use]
290    pub const fn cross_protocol_forwarding(mut self, value: bool) -> Self {
291        self.cross_protocol_forwarding = value;
292        self
293    }
294
295    /// Override the per-client TX rate limit in frames per second
296    /// (default `60.0`).
297    ///
298    /// The default is 3× the nominal 20 fps D-STAR voice rate, so
299    /// legitimate voice streams never hit the limit. Lower it to
300    /// tighten the `DoS` envelope; raise it if you expect large bursts
301    /// of legitimate traffic.
302    #[must_use]
303    pub const fn tx_rate_limit_frames_per_sec(mut self, value: f64) -> Self {
304        self.tx_rate_limit_frames_per_sec = value;
305        self
306    }
307}
308
309impl ReflectorConfigBuilder<Provided, Provided, Provided> {
310    /// Finalize the configuration.
311    ///
312    /// # Errors
313    ///
314    /// Returns [`ConfigError::EmptyModules`] if the supplied module
315    /// set was empty. All other required fields are guaranteed
316    /// non-`None` by the typestate markers.
317    pub fn build(self) -> Result<ReflectorConfig, ConfigError> {
318        let Some(callsign) = self.callsign else {
319            unreachable!("Provided marker guarantees callsign is Some");
320        };
321        let Some(modules) = self.modules else {
322            unreachable!("Provided marker guarantees modules is Some");
323        };
324        let Some(bind) = self.bind else {
325            unreachable!("Provided marker guarantees bind is Some");
326        };
327        if modules.is_empty() {
328            return Err(ConfigError::EmptyModules);
329        }
330        Ok(ReflectorConfig {
331            callsign,
332            modules,
333            bind,
334            max_clients_per_module: self.max_clients_per_module,
335            max_total_clients: self.max_total_clients,
336            enabled_protocols: self.enabled_protocols,
337            keepalive_interval: self.keepalive_interval,
338            keepalive_inactivity_timeout: self.keepalive_inactivity_timeout,
339            voice_inactivity_timeout: self.voice_inactivity_timeout,
340            cross_protocol_forwarding: self.cross_protocol_forwarding,
341            tx_rate_limit_frames_per_sec: self.tx_rate_limit_frames_per_sec,
342        })
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use super::{
349        Callsign, ConfigError, Duration, HashSet, Module, ProtocolKind, ReflectorConfig, SocketAddr,
350    };
351    use std::net::{IpAddr, Ipv4Addr};
352
353    type TestResult = Result<(), Box<dyn std::error::Error>>;
354
355    fn module_set(modules: &[Module]) -> HashSet<Module> {
356        let mut set = HashSet::new();
357        for &m in modules {
358            let _ = set.insert(m);
359        }
360        set
361    }
362
363    const BIND: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 30001);
364
365    #[test]
366    fn happy_path_builds_with_defaults() -> TestResult {
367        let config = ReflectorConfig::builder()
368            .callsign(Callsign::from_wire_bytes(*b"REF030  "))
369            .module_set(module_set(&[Module::A, Module::B, Module::C]))
370            .bind(BIND)
371            .build()?;
372        assert_eq!(config.callsign, Callsign::from_wire_bytes(*b"REF030  "));
373        assert_eq!(config.modules.len(), 3);
374        assert_eq!(config.bind, BIND);
375        // Defaults
376        assert_eq!(config.max_clients_per_module, 50);
377        assert_eq!(config.max_total_clients, 250);
378        assert!(config.is_enabled(ProtocolKind::DPlus));
379        assert!(config.is_enabled(ProtocolKind::DExtra));
380        assert!(config.is_enabled(ProtocolKind::Dcs));
381        assert_eq!(config.keepalive_interval, Duration::from_secs(1));
382        assert_eq!(config.keepalive_inactivity_timeout, Duration::from_secs(30));
383        assert_eq!(config.voice_inactivity_timeout, Duration::from_secs(2));
384        assert!(!config.cross_protocol_forwarding);
385        Ok(())
386    }
387
388    #[test]
389    fn empty_module_set_is_rejected() {
390        let result = ReflectorConfig::builder()
391            .callsign(Callsign::from_wire_bytes(*b"REF030  "))
392            .module_set(HashSet::new())
393            .bind(BIND)
394            .build();
395        assert!(matches!(result, Err(ConfigError::EmptyModules)));
396    }
397
398    #[test]
399    fn overrides_replace_defaults() -> TestResult {
400        let config = ReflectorConfig::builder()
401            .callsign(Callsign::from_wire_bytes(*b"REF030  "))
402            .module_set(module_set(&[Module::C]))
403            .bind(BIND)
404            .max_clients_per_module(10)
405            .max_total_clients(40)
406            .disable(ProtocolKind::DPlus)
407            .disable(ProtocolKind::Dcs)
408            .keepalive_interval(Duration::from_millis(500))
409            .keepalive_inactivity_timeout(Duration::from_secs(10))
410            .voice_inactivity_timeout(Duration::from_millis(1500))
411            .cross_protocol_forwarding(true)
412            .build()?;
413        assert_eq!(config.max_clients_per_module, 10);
414        assert_eq!(config.max_total_clients, 40);
415        assert!(!config.is_enabled(ProtocolKind::DPlus));
416        assert!(config.is_enabled(ProtocolKind::DExtra));
417        assert!(!config.is_enabled(ProtocolKind::Dcs));
418        assert_eq!(config.keepalive_interval, Duration::from_millis(500));
419        assert_eq!(config.keepalive_inactivity_timeout, Duration::from_secs(10));
420        assert_eq!(config.voice_inactivity_timeout, Duration::from_millis(1500));
421        assert!(config.cross_protocol_forwarding);
422        Ok(())
423    }
424
425    #[test]
426    fn cross_protocol_forwarding_defaults_false() -> TestResult {
427        let config = ReflectorConfig::builder()
428            .callsign(Callsign::from_wire_bytes(*b"REF030  "))
429            .module_set(module_set(&[Module::C]))
430            .bind(BIND)
431            .build()?;
432        assert!(!config.cross_protocol_forwarding);
433        Ok(())
434    }
435}