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}