Skip to main content

torrust_tracker_deployer_lib/domain/tracker/config/
udp.rs

1//! UDP tracker configuration
2//!
3//! This module implements the **DDD validated constructor pattern** for UDP tracker
4//! configuration. The pattern ensures that UDP tracker configs are always valid
5//! after construction.
6//!
7//! ## Pattern Overview
8//!
9//! 1. **Private fields**: All fields are private to prevent bypassing validation
10//! 2. **Validated constructor**: `new()` validates all invariants before creation
11//! 3. **Getter methods**: Provide read-only access to field values
12//! 4. **Domain error type**: Rich error enum for validation failures
13//! 5. **Serde with validation**: Deserialization goes through the constructor
14//!
15//! ## Example
16//!
17//! ```rust
18//! use torrust_tracker_deployer_lib::domain::tracker::UdpTrackerConfig;
19//! use torrust_tracker_deployer_lib::shared::DomainName;
20//!
21//! // Valid configuration - succeeds
22//! let config = UdpTrackerConfig::new(
23//!     "0.0.0.0:6969".parse().unwrap(),
24//!     None,
25//! ).expect("valid config");
26//!
27//! // Invalid: port 0 - fails at construction
28//! let result = UdpTrackerConfig::new(
29//!     "0.0.0.0:0".parse().unwrap(),
30//!     None,
31//! );
32//! assert!(result.is_err());
33//! ```
34//!
35//! ## Reference Implementation
36//!
37//! See `http_api.rs` for the original reference implementation of this pattern.
38
39use std::fmt;
40use std::net::SocketAddr;
41
42use serde::{Deserialize, Serialize};
43use thiserror::Error;
44
45use crate::shared::DomainName;
46
47/// Errors that can occur when creating a `UdpTrackerConfig`
48///
49/// These errors represent domain invariant violations. Each variant provides
50/// context about what went wrong and enables the application layer to convert
51/// to user-friendly error messages.
52#[derive(Debug, Clone, PartialEq, Error)]
53pub enum UdpTrackerConfigError {
54    /// Dynamic port assignment (port 0) is not supported
55    ///
56    /// Port 0 tells the OS to assign a random available port, which is not
57    /// suitable for deployment configuration where ports must be known.
58    #[error("dynamic port (0) is not supported for UDP tracker bind address '{0}'")]
59    DynamicPortNotSupported(SocketAddr),
60}
61
62impl UdpTrackerConfigError {
63    /// Provides detailed troubleshooting guidance for this error
64    ///
65    /// This method follows the project's tiered help system pattern,
66    /// providing actionable guidance for resolving configuration issues.
67    #[must_use]
68    pub fn help(&self) -> &'static str {
69        match self {
70            Self::DynamicPortNotSupported(_) => {
71                "Dynamic port assignment (port 0) is not supported.\n\
72                 \n\
73                 Why: Port 0 tells the operating system to assign a random available port.\n\
74                 This is not suitable for deployment where ports must be known in advance\n\
75                 for firewall rules, load balancers, and client configuration.\n\
76                 \n\
77                 Fix: Specify an explicit port number (e.g., 6969, 6868, 6881).\n\
78                 \n\
79                 Example: \"bind_address\": \"0.0.0.0:6969\""
80            }
81        }
82    }
83}
84
85/// Internal struct for serde deserialization that bypasses validation
86///
87/// This allows us to deserialize JSON into the raw fields, then validate
88/// through the constructor. This pattern ensures that even
89/// deserialized configs are validated.
90#[derive(Deserialize)]
91struct UdpTrackerConfigRaw {
92    #[serde(deserialize_with = "crate::domain::tracker::config::deserialize_socket_addr")]
93    bind_address: SocketAddr,
94    #[serde(default)]
95    domain: Option<DomainName>,
96}
97
98/// UDP tracker bind configuration with domain invariants enforced at construction
99///
100/// This type guarantees that any instance is valid according to domain rules:
101/// - Bind address has a non-zero port
102///
103/// Note: Unlike HTTP trackers, UDP does not support TLS, so there are no
104/// TLS-related validation rules.
105///
106/// # Construction
107///
108/// Use `UdpTrackerConfig::new()` to create instances with validation:
109///
110/// ```rust
111/// use torrust_tracker_deployer_lib::domain::tracker::UdpTrackerConfig;
112///
113/// let config = UdpTrackerConfig::new(
114///     "0.0.0.0:6969".parse().unwrap(),
115///     None,
116/// )?;
117/// # Ok::<(), Box<dyn std::error::Error>>(())
118/// ```
119///
120/// # Invariants
121///
122/// The following invariants are enforced at construction time:
123///
124/// 1. **No dynamic ports**: `bind_address.port() != 0`
125#[derive(Debug, Clone, Serialize, PartialEq)]
126pub struct UdpTrackerConfig {
127    /// Bind address (e.g., "0.0.0.0:6868")
128    #[serde(serialize_with = "crate::domain::tracker::config::serialize_socket_addr")]
129    bind_address: SocketAddr,
130
131    /// Domain name for announce URLs (optional)
132    ///
133    /// When present, this domain can be used when communicating the tracker's
134    /// announce URL to users, e.g., `udp://tracker.example.com:6969/announce`
135    ///
136    /// Note: Unlike HTTP trackers, UDP does not support TLS, so there is no
137    /// `use_tls_proxy` field for UDP trackers.
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    domain: Option<DomainName>,
140}
141
142impl UdpTrackerConfig {
143    /// Creates a new UDP tracker configuration with validation
144    ///
145    /// This is the primary way to construct a `UdpTrackerConfig`. All domain
146    /// invariants are validated before the instance is created.
147    ///
148    /// # Arguments
149    ///
150    /// * `bind_address` - Socket address to bind to (e.g., "0.0.0.0:6969")
151    /// * `domain` - Optional domain for announce URLs
152    ///
153    /// # Errors
154    ///
155    /// Returns `UdpTrackerConfigError` if any invariant is violated:
156    ///
157    /// - `DynamicPortNotSupported` - if port is 0
158    ///
159    /// # Examples
160    ///
161    /// ```rust
162    /// use torrust_tracker_deployer_lib::domain::tracker::UdpTrackerConfig;
163    /// use torrust_tracker_deployer_lib::shared::DomainName;
164    ///
165    /// // Basic configuration without domain
166    /// let config = UdpTrackerConfig::new(
167    ///     "0.0.0.0:6969".parse().unwrap(),
168    ///     None,
169    /// )?;
170    ///
171    /// // Configuration with domain
172    /// let config_with_domain = UdpTrackerConfig::new(
173    ///     "0.0.0.0:6969".parse().unwrap(),
174    ///     Some(DomainName::new("tracker.example.com")?),
175    /// )?;
176    /// # Ok::<(), Box<dyn std::error::Error>>(())
177    /// ```
178    pub fn new(
179        bind_address: SocketAddr,
180        domain: Option<DomainName>,
181    ) -> Result<Self, UdpTrackerConfigError> {
182        // Invariant 1: Port 0 (dynamic assignment) is not supported
183        if bind_address.port() == 0 {
184            return Err(UdpTrackerConfigError::DynamicPortNotSupported(bind_address));
185        }
186
187        Ok(Self {
188            bind_address,
189            domain,
190        })
191    }
192
193    // -------------------------------------------------------------------------
194    // Getter methods - provide read-only access to fields
195    // -------------------------------------------------------------------------
196
197    /// Returns the bind address
198    #[must_use]
199    pub fn bind_address(&self) -> SocketAddr {
200        self.bind_address
201    }
202
203    /// Returns a reference to the domain, if configured
204    #[must_use]
205    pub fn domain(&self) -> Option<&DomainName> {
206        self.domain.as_ref()
207    }
208}
209
210/// Enables deserialization with validation through the constructor
211///
212/// This ensures that JSON deserialization also validates the config,
213/// maintaining the "always valid" invariant even for loaded data.
214impl<'de> Deserialize<'de> for UdpTrackerConfig {
215    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
216    where
217        D: serde::Deserializer<'de>,
218    {
219        let raw = UdpTrackerConfigRaw::deserialize(deserializer)?;
220        Self::new(raw.bind_address, raw.domain).map_err(serde::de::Error::custom)
221    }
222}
223
224impl fmt::Display for UdpTrackerConfig {
225    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
226        write!(f, "UDP tracker at {}", self.bind_address)?;
227        if let Some(domain) = &self.domain {
228            write!(f, " ({})", domain.as_str())?;
229        }
230        Ok(())
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    // =========================================================================
239    // Valid construction tests
240    // =========================================================================
241
242    #[test]
243    fn it_should_create_udp_tracker_config_without_domain() {
244        let config = UdpTrackerConfig::new("0.0.0.0:6868".parse().unwrap(), None)
245            .expect("valid config should succeed");
246
247        assert_eq!(
248            config.bind_address(),
249            "0.0.0.0:6868".parse::<SocketAddr>().unwrap()
250        );
251        assert!(config.domain().is_none());
252    }
253
254    #[test]
255    fn it_should_create_udp_tracker_config_with_domain() {
256        let domain = DomainName::new("tracker.example.com").unwrap();
257        let config = UdpTrackerConfig::new("0.0.0.0:6969".parse().unwrap(), Some(domain))
258            .expect("valid config should succeed");
259
260        assert_eq!(
261            config.bind_address(),
262            "0.0.0.0:6969".parse::<SocketAddr>().unwrap()
263        );
264        assert_eq!(
265            config.domain().map(DomainName::as_str),
266            Some("tracker.example.com")
267        );
268    }
269
270    // =========================================================================
271    // Invariant violation tests
272    // =========================================================================
273
274    #[test]
275    fn it_should_reject_port_zero() {
276        let result = UdpTrackerConfig::new("0.0.0.0:0".parse().unwrap(), None);
277
278        assert!(result.is_err());
279        let err = result.unwrap_err();
280        assert!(matches!(
281            err,
282            UdpTrackerConfigError::DynamicPortNotSupported(_)
283        ));
284        assert!(err.to_string().contains("dynamic port"));
285    }
286
287    #[test]
288    fn it_should_provide_help_text_for_port_zero_error() {
289        let err = UdpTrackerConfigError::DynamicPortNotSupported("0.0.0.0:0".parse().unwrap());
290
291        let help = err.help();
292        assert!(help.contains("Dynamic port assignment"));
293        assert!(help.contains("Fix:"));
294        assert!(help.contains("6969"));
295    }
296
297    // =========================================================================
298    // Serialization tests
299    // =========================================================================
300
301    #[test]
302    fn it_should_serialize_udp_tracker_config_without_domain() {
303        let config = UdpTrackerConfig::new("0.0.0.0:6969".parse().unwrap(), None).unwrap();
304
305        let json = serde_json::to_value(&config).unwrap();
306        assert_eq!(json["bind_address"], "0.0.0.0:6969");
307        // domain should not be present when None (skip_serializing_if)
308        assert!(json.get("domain").is_none());
309    }
310
311    #[test]
312    fn it_should_serialize_udp_tracker_config_with_domain() {
313        let domain = DomainName::new("udp.tracker.local").unwrap();
314        let config = UdpTrackerConfig::new("0.0.0.0:6969".parse().unwrap(), Some(domain)).unwrap();
315
316        let json = serde_json::to_value(&config).unwrap();
317        assert_eq!(json["bind_address"], "0.0.0.0:6969");
318        assert_eq!(json["domain"], "udp.tracker.local");
319    }
320
321    // =========================================================================
322    // Deserialization tests
323    // =========================================================================
324
325    #[test]
326    fn it_should_deserialize_udp_tracker_config_without_domain() {
327        let json = r#"{"bind_address": "0.0.0.0:6969"}"#;
328        let config: UdpTrackerConfig = serde_json::from_str(json).unwrap();
329
330        assert_eq!(
331            config.bind_address(),
332            "0.0.0.0:6969".parse::<SocketAddr>().unwrap()
333        );
334        assert!(config.domain().is_none());
335    }
336
337    #[test]
338    fn it_should_deserialize_udp_tracker_config_with_domain() {
339        let json = r#"{"bind_address": "0.0.0.0:6969", "domain": "udp.tracker.local"}"#;
340        let config: UdpTrackerConfig = serde_json::from_str(json).unwrap();
341
342        assert_eq!(
343            config.bind_address(),
344            "0.0.0.0:6969".parse::<SocketAddr>().unwrap()
345        );
346        assert_eq!(
347            config.domain().map(DomainName::as_str),
348            Some("udp.tracker.local")
349        );
350    }
351
352    #[test]
353    fn it_should_reject_port_zero_during_deserialization() {
354        let json = r#"{"bind_address": "0.0.0.0:0"}"#;
355        let result: Result<UdpTrackerConfig, _> = serde_json::from_str(json);
356
357        assert!(result.is_err());
358        let err_msg = result.unwrap_err().to_string();
359        assert!(err_msg.contains("dynamic port"));
360    }
361
362    // =========================================================================
363    // Display tests
364    // =========================================================================
365
366    #[test]
367    fn it_should_display_without_domain() {
368        let config = UdpTrackerConfig::new("0.0.0.0:6969".parse().unwrap(), None).unwrap();
369
370        assert_eq!(config.to_string(), "UDP tracker at 0.0.0.0:6969");
371    }
372
373    #[test]
374    fn it_should_display_with_domain() {
375        let domain = DomainName::new("tracker.example.com").unwrap();
376        let config = UdpTrackerConfig::new("0.0.0.0:6969".parse().unwrap(), Some(domain)).unwrap();
377
378        assert_eq!(
379            config.to_string(),
380            "UDP tracker at 0.0.0.0:6969 (tracker.example.com)"
381        );
382    }
383
384    // =========================================================================
385    // Round-trip tests
386    // =========================================================================
387
388    #[test]
389    fn it_should_round_trip_through_json() {
390        let domain = DomainName::new("tracker.example.com").unwrap();
391        let original =
392            UdpTrackerConfig::new("0.0.0.0:6969".parse().unwrap(), Some(domain)).unwrap();
393
394        let json = serde_json::to_string(&original).unwrap();
395        let restored: UdpTrackerConfig = serde_json::from_str(&json).unwrap();
396
397        assert_eq!(original, restored);
398    }
399}