Skip to main content

torrust_tracker_deployer_lib/domain/tracker/config/
http.rs

1//! HTTP tracker configuration
2//!
3//! This module implements the **DDD validated constructor pattern** for HTTP tracker
4//! configuration. The pattern ensures that HTTP 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::HttpTrackerConfig;
19//! use torrust_tracker_deployer_lib::shared::DomainName;
20//!
21//! // Valid configuration without TLS - succeeds
22//! let config = HttpTrackerConfig::new(
23//!     "0.0.0.0:7070".parse().unwrap(),
24//!     None,
25//!     false,
26//! ).expect("valid config");
27//!
28//! // Invalid: port 0 - fails at construction
29//! let result = HttpTrackerConfig::new(
30//!     "0.0.0.0:0".parse().unwrap(),
31//!     None,
32//!     false,
33//! );
34//! assert!(result.is_err());
35//! ```
36//!
37//! ## Reference Implementation
38//!
39//! See `http_api.rs` for the original reference implementation of this pattern.
40
41use std::fmt;
42use std::net::SocketAddr;
43
44use serde::{Deserialize, Serialize};
45use thiserror::Error;
46
47use super::is_localhost;
48use crate::shared::DomainName;
49
50/// Errors that can occur when creating an `HttpTrackerConfig`
51///
52/// These errors represent domain invariant violations. Each variant provides
53/// context about what went wrong and enables the application layer to convert
54/// to user-friendly error messages.
55#[derive(Debug, Clone, PartialEq, Error)]
56pub enum HttpTrackerConfigError {
57    /// Dynamic port assignment (port 0) is not supported
58    ///
59    /// Port 0 tells the OS to assign a random available port, which is not
60    /// suitable for deployment configuration where ports must be known.
61    #[error("dynamic port (0) is not supported for HTTP tracker bind address '{0}'")]
62    DynamicPortNotSupported(SocketAddr),
63
64    /// TLS proxy is enabled but no domain is configured
65    ///
66    /// When `use_tls_proxy` is true, a domain is required because Caddy needs
67    /// the domain name to obtain Let's Encrypt certificates.
68    #[error("TLS proxy requires a domain to be configured for HTTP tracker bind address '{0}'")]
69    TlsProxyRequiresDomain(SocketAddr),
70
71    /// Localhost address cannot be used with TLS proxy
72    ///
73    /// Caddy runs in a separate container and cannot reach localhost addresses
74    /// in the tracker container. Use 0.0.0.0 or a specific IP instead.
75    #[error("localhost '{0}' cannot be used with TLS proxy for HTTP tracker (Caddy runs in separate container)")]
76    LocalhostWithTls(SocketAddr),
77}
78
79impl HttpTrackerConfigError {
80    /// Provides detailed troubleshooting guidance for this error
81    ///
82    /// This method follows the project's tiered help system pattern,
83    /// providing actionable guidance for resolving configuration issues.
84    #[must_use]
85    pub fn help(&self) -> &'static str {
86        match self {
87            Self::DynamicPortNotSupported(_) => {
88                "Dynamic port assignment (port 0) is not supported.\n\
89                 \n\
90                 Why: Port 0 tells the operating system to assign a random available port.\n\
91                 This is not suitable for deployment where ports must be known in advance\n\
92                 for firewall rules, load balancers, and client configuration.\n\
93                 \n\
94                 Fix: Specify an explicit port number (e.g., 7070, 8080, 80).\n\
95                 \n\
96                 Example: \"bind_address\": \"0.0.0.0:7070\""
97            }
98            Self::TlsProxyRequiresDomain(_) => {
99                "TLS proxy requires a domain name.\n\
100                 \n\
101                 Why: When use_tls_proxy is enabled, Caddy obtains TLS certificates from\n\
102                 Let's Encrypt using the ACME protocol. This requires a valid domain name.\n\
103                 \n\
104                 Fix (choose one):\n\
105                 1. Add a domain: \"domain\": \"tracker.example.com\"\n\
106                 2. Disable TLS: \"use_tls_proxy\": false\n\
107                 \n\
108                 Note: The domain must point to your server's IP address for certificate\n\
109                 acquisition to succeed."
110            }
111            Self::LocalhostWithTls(_) => {
112                "Localhost addresses cannot be used with TLS proxy.\n\
113                 \n\
114                 Why: Caddy runs in a separate Docker container and cannot reach localhost\n\
115                 addresses (127.0.0.1 or ::1) in the tracker container. Each container has\n\
116                 its own network namespace.\n\
117                 \n\
118                 Fix (choose one):\n\
119                 1. Use a routable address: \"bind_address\": \"0.0.0.0:7070\"\n\
120                 2. Disable TLS: \"use_tls_proxy\": false\n\
121                 \n\
122                 Note: If you need localhost-only access without TLS, you can use SSH\n\
123                 tunneling: ssh -L 7070:localhost:7070 user@server"
124            }
125        }
126    }
127}
128
129/// Internal struct for serde deserialization that bypasses validation
130///
131/// This allows us to deserialize JSON into the raw fields, then validate
132/// through the constructor. This pattern ensures that even
133/// deserialized configs are validated.
134#[derive(Deserialize)]
135struct HttpTrackerConfigRaw {
136    #[serde(deserialize_with = "crate::domain::tracker::config::deserialize_socket_addr")]
137    bind_address: SocketAddr,
138    #[serde(default)]
139    domain: Option<DomainName>,
140    use_tls_proxy: bool,
141}
142
143/// HTTP tracker bind configuration with domain invariants enforced at construction
144///
145/// This type guarantees that any instance is valid according to domain rules:
146/// - Bind address has a non-zero port
147/// - If TLS proxy is enabled, a domain is configured
148/// - If TLS proxy is enabled, bind address is not localhost
149///
150/// # Construction
151///
152/// Use `HttpTrackerConfig::new()` to create instances with validation:
153///
154/// ```rust
155/// use torrust_tracker_deployer_lib::domain::tracker::HttpTrackerConfig;
156/// use torrust_tracker_deployer_lib::shared::DomainName;
157///
158/// // Without TLS
159/// let config = HttpTrackerConfig::new(
160///     "0.0.0.0:7070".parse().unwrap(),
161///     None,
162///     false,
163/// )?;
164///
165/// // With TLS (requires domain)
166/// let tls_config = HttpTrackerConfig::new(
167///     "0.0.0.0:7070".parse().unwrap(),
168///     Some(DomainName::new("tracker.example.com")?),
169///     true,
170/// )?;
171/// # Ok::<(), Box<dyn std::error::Error>>(())
172/// ```
173///
174/// # Invariants
175///
176/// The following invariants are enforced at construction time:
177///
178/// 1. **No dynamic ports**: `bind_address.port() != 0`
179/// 2. **TLS requires domain**: `use_tls_proxy == true` implies `domain.is_some()`
180/// 3. **No localhost with TLS**: `use_tls_proxy == true` implies `!is_localhost(bind_address)`
181#[derive(Debug, Clone, Serialize, PartialEq)]
182pub struct HttpTrackerConfig {
183    /// Bind address (e.g., "0.0.0.0:7070")
184    #[serde(serialize_with = "crate::domain::tracker::config::serialize_socket_addr")]
185    bind_address: SocketAddr,
186
187    /// Domain name for HTTPS certificate acquisition (optional)
188    ///
189    /// When present along with `use_tls_proxy: true`, this HTTP tracker will be
190    /// accessible via HTTPS through the Caddy reverse proxy using this domain.
191    #[serde(skip_serializing_if = "Option::is_none")]
192    domain: Option<DomainName>,
193
194    /// Whether to proxy this service through Caddy with TLS termination
195    ///
196    /// When `true`:
197    /// - The service is proxied through Caddy with HTTPS enabled
198    /// - `domain` field is required
199    /// - Cannot be used with localhost bind addresses (`127.0.0.1`, `::1`)
200    /// - Implies the tracker's `on_reverse_proxy` should be `true`
201    use_tls_proxy: bool,
202}
203
204impl HttpTrackerConfig {
205    /// Creates a new HTTP tracker configuration with validation
206    ///
207    /// This is the primary way to construct an `HttpTrackerConfig`. All domain
208    /// invariants are validated before the instance is created.
209    ///
210    /// # Arguments
211    ///
212    /// * `bind_address` - Socket address to bind to (e.g., "0.0.0.0:7070")
213    /// * `domain` - Optional domain for TLS certificate (required if `use_tls_proxy` is true)
214    /// * `use_tls_proxy` - Whether to enable TLS via Caddy reverse proxy
215    ///
216    /// # Errors
217    ///
218    /// Returns `HttpTrackerConfigError` if any invariant is violated:
219    ///
220    /// - `DynamicPortNotSupported` - if port is 0
221    /// - `TlsProxyRequiresDomain` - if `use_tls_proxy` is true but `domain` is None
222    /// - `LocalhostWithTls` - if `use_tls_proxy` is true and `bind_address` is localhost
223    ///
224    /// # Examples
225    ///
226    /// ```rust
227    /// use torrust_tracker_deployer_lib::domain::tracker::HttpTrackerConfig;
228    /// use torrust_tracker_deployer_lib::shared::DomainName;
229    ///
230    /// // Basic configuration without TLS
231    /// let config = HttpTrackerConfig::new(
232    ///     "0.0.0.0:7070".parse().unwrap(),
233    ///     None,
234    ///     false,
235    /// )?;
236    ///
237    /// // Configuration with TLS (requires domain)
238    /// let tls_config = HttpTrackerConfig::new(
239    ///     "0.0.0.0:7070".parse().unwrap(),
240    ///     Some(DomainName::new("tracker.example.com")?),
241    ///     true,
242    /// )?;
243    /// # Ok::<(), Box<dyn std::error::Error>>(())
244    /// ```
245    pub fn new(
246        bind_address: SocketAddr,
247        domain: Option<DomainName>,
248        use_tls_proxy: bool,
249    ) -> Result<Self, HttpTrackerConfigError> {
250        // Invariant 1: Port 0 (dynamic assignment) is not supported
251        if bind_address.port() == 0 {
252            return Err(HttpTrackerConfigError::DynamicPortNotSupported(
253                bind_address,
254            ));
255        }
256
257        // Invariant 2: TLS proxy requires a domain
258        if use_tls_proxy && domain.is_none() {
259            return Err(HttpTrackerConfigError::TlsProxyRequiresDomain(bind_address));
260        }
261
262        // Invariant 3: Localhost cannot use TLS (Caddy in separate container)
263        if use_tls_proxy && is_localhost(&bind_address) {
264            return Err(HttpTrackerConfigError::LocalhostWithTls(bind_address));
265        }
266
267        Ok(Self {
268            bind_address,
269            domain,
270            use_tls_proxy,
271        })
272    }
273
274    // -------------------------------------------------------------------------
275    // Getter methods - provide read-only access to fields
276    // -------------------------------------------------------------------------
277
278    /// Returns the bind address
279    #[must_use]
280    pub fn bind_address(&self) -> SocketAddr {
281        self.bind_address
282    }
283
284    /// Returns a reference to the domain, if configured
285    #[must_use]
286    pub fn domain(&self) -> Option<&DomainName> {
287        self.domain.as_ref()
288    }
289
290    /// Returns whether TLS proxy is enabled
291    #[must_use]
292    pub fn use_tls_proxy(&self) -> bool {
293        self.use_tls_proxy
294    }
295
296    // -------------------------------------------------------------------------
297    // Convenience methods
298    // -------------------------------------------------------------------------
299
300    /// Returns true if this tracker uses the TLS proxy
301    ///
302    /// Alias for `use_tls_proxy()` for semantic clarity.
303    #[must_use]
304    pub fn uses_tls_proxy(&self) -> bool {
305        self.use_tls_proxy
306    }
307
308    /// Returns the domain name if TLS proxy is enabled
309    ///
310    /// Returns `None` if TLS is disabled, even if a domain is configured.
311    /// This is useful for determining the effective TLS domain.
312    #[must_use]
313    pub fn tls_domain(&self) -> Option<&DomainName> {
314        if self.use_tls_proxy {
315            self.domain.as_ref()
316        } else {
317            None
318        }
319    }
320}
321
322/// Enables deserialization with validation through the constructor
323///
324/// This ensures that JSON deserialization also validates the config,
325/// maintaining the "always valid" invariant even for loaded data.
326impl<'de> Deserialize<'de> for HttpTrackerConfig {
327    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
328    where
329        D: serde::Deserializer<'de>,
330    {
331        let raw = HttpTrackerConfigRaw::deserialize(deserializer)?;
332        Self::new(raw.bind_address, raw.domain, raw.use_tls_proxy).map_err(serde::de::Error::custom)
333    }
334}
335
336impl fmt::Display for HttpTrackerConfig {
337    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
338        write!(f, "HTTP tracker at {}", self.bind_address)?;
339        if let Some(domain) = &self.domain {
340            write!(f, " ({})", domain.as_str())?;
341        }
342        if self.use_tls_proxy {
343            write!(f, " [TLS]")?;
344        }
345        Ok(())
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    // =========================================================================
354    // Valid construction tests
355    // =========================================================================
356
357    #[test]
358    fn it_should_create_http_tracker_config_without_tls() {
359        let config = HttpTrackerConfig::new("0.0.0.0:7070".parse().unwrap(), None, false)
360            .expect("valid config should succeed");
361
362        assert_eq!(
363            config.bind_address(),
364            "0.0.0.0:7070".parse::<SocketAddr>().unwrap()
365        );
366        assert!(!config.uses_tls_proxy());
367        assert!(config.tls_domain().is_none());
368    }
369
370    #[test]
371    fn it_should_create_http_tracker_config_with_tls() {
372        let domain = DomainName::new("tracker.example.com").unwrap();
373        let config = HttpTrackerConfig::new("0.0.0.0:7070".parse().unwrap(), Some(domain), true)
374            .expect("valid config should succeed");
375
376        assert!(config.uses_tls_proxy());
377        assert_eq!(
378            config.tls_domain().map(DomainName::as_str),
379            Some("tracker.example.com")
380        );
381    }
382
383    #[test]
384    fn it_should_create_http_tracker_config_with_domain_but_no_tls() {
385        let domain = DomainName::new("tracker.example.com").unwrap();
386        let config = HttpTrackerConfig::new("0.0.0.0:7070".parse().unwrap(), Some(domain), false)
387            .expect("valid config should succeed");
388
389        assert!(!config.uses_tls_proxy());
390        // tls_domain returns None when TLS is disabled
391        assert!(config.tls_domain().is_none());
392        // but domain() still returns the domain
393        assert!(config.domain().is_some());
394    }
395
396    // =========================================================================
397    // Invariant violation tests
398    // =========================================================================
399
400    #[test]
401    fn it_should_reject_port_zero() {
402        let result = HttpTrackerConfig::new("0.0.0.0:0".parse().unwrap(), None, false);
403
404        assert!(result.is_err());
405        let err = result.unwrap_err();
406        assert!(matches!(
407            err,
408            HttpTrackerConfigError::DynamicPortNotSupported(_)
409        ));
410        assert!(err.to_string().contains("dynamic port"));
411    }
412
413    #[test]
414    fn it_should_reject_tls_without_domain() {
415        let result = HttpTrackerConfig::new("0.0.0.0:7070".parse().unwrap(), None, true);
416
417        assert!(result.is_err());
418        let err = result.unwrap_err();
419        assert!(matches!(
420            err,
421            HttpTrackerConfigError::TlsProxyRequiresDomain(_)
422        ));
423        assert!(err.to_string().contains("TLS proxy requires a domain"));
424    }
425
426    #[test]
427    fn it_should_reject_localhost_with_tls_ipv4() {
428        let domain = DomainName::new("tracker.example.com").unwrap();
429        let result = HttpTrackerConfig::new("127.0.0.1:7070".parse().unwrap(), Some(domain), true);
430
431        assert!(result.is_err());
432        let err = result.unwrap_err();
433        assert!(matches!(err, HttpTrackerConfigError::LocalhostWithTls(_)));
434        assert!(err.to_string().contains("localhost"));
435    }
436
437    #[test]
438    fn it_should_reject_localhost_with_tls_ipv6() {
439        let domain = DomainName::new("tracker.example.com").unwrap();
440        let result = HttpTrackerConfig::new("[::1]:7070".parse().unwrap(), Some(domain), true);
441
442        assert!(result.is_err());
443        let err = result.unwrap_err();
444        assert!(matches!(err, HttpTrackerConfigError::LocalhostWithTls(_)));
445    }
446
447    #[test]
448    fn it_should_allow_localhost_without_tls() {
449        let result = HttpTrackerConfig::new("127.0.0.1:7070".parse().unwrap(), None, false);
450
451        assert!(result.is_ok());
452    }
453
454    // =========================================================================
455    // Help text tests
456    // =========================================================================
457
458    #[test]
459    fn it_should_provide_help_text_for_port_zero_error() {
460        let err = HttpTrackerConfigError::DynamicPortNotSupported("0.0.0.0:0".parse().unwrap());
461
462        let help = err.help();
463        assert!(help.contains("Dynamic port assignment"));
464        assert!(help.contains("Fix:"));
465        assert!(help.contains("7070"));
466    }
467
468    #[test]
469    fn it_should_provide_help_text_for_tls_without_domain() {
470        let err = HttpTrackerConfigError::TlsProxyRequiresDomain("0.0.0.0:7070".parse().unwrap());
471
472        let help = err.help();
473        assert!(help.contains("TLS proxy requires a domain"));
474        assert!(help.contains("Fix"));
475    }
476
477    #[test]
478    fn it_should_provide_help_text_for_localhost_with_tls() {
479        let err = HttpTrackerConfigError::LocalhostWithTls("127.0.0.1:7070".parse().unwrap());
480
481        let help = err.help();
482        assert!(help.contains("Localhost addresses cannot be used"));
483        assert!(help.contains("Docker container"));
484    }
485
486    // =========================================================================
487    // Serialization tests
488    // =========================================================================
489
490    #[test]
491    fn it_should_serialize_http_tracker_config() {
492        let config = HttpTrackerConfig::new("0.0.0.0:7070".parse().unwrap(), None, false).unwrap();
493
494        let json = serde_json::to_value(&config).unwrap();
495        assert_eq!(json["bind_address"], "0.0.0.0:7070");
496        assert_eq!(json["use_tls_proxy"], false);
497    }
498
499    #[test]
500    fn it_should_serialize_http_tracker_config_with_tls() {
501        let domain = DomainName::new("tracker.example.com").unwrap();
502        let config =
503            HttpTrackerConfig::new("0.0.0.0:7070".parse().unwrap(), Some(domain), true).unwrap();
504
505        let json = serde_json::to_value(&config).unwrap();
506        assert_eq!(json["bind_address"], "0.0.0.0:7070");
507        assert_eq!(json["domain"], "tracker.example.com");
508        assert_eq!(json["use_tls_proxy"], true);
509    }
510
511    // =========================================================================
512    // Deserialization tests
513    // =========================================================================
514
515    #[test]
516    fn it_should_deserialize_http_tracker_config() {
517        let json = r#"{"bind_address": "0.0.0.0:7070", "use_tls_proxy": false}"#;
518        let config: HttpTrackerConfig = serde_json::from_str(json).unwrap();
519
520        assert_eq!(
521            config.bind_address(),
522            "0.0.0.0:7070".parse::<SocketAddr>().unwrap()
523        );
524        assert!(!config.use_tls_proxy());
525    }
526
527    #[test]
528    fn it_should_deserialize_http_tracker_config_with_tls() {
529        let json = r#"{"bind_address": "0.0.0.0:7070", "domain": "tracker.example.com", "use_tls_proxy": true}"#;
530        let config: HttpTrackerConfig = serde_json::from_str(json).unwrap();
531
532        assert_eq!(
533            config.bind_address(),
534            "0.0.0.0:7070".parse::<SocketAddr>().unwrap()
535        );
536        assert!(config.use_tls_proxy());
537        assert_eq!(
538            config.domain().map(DomainName::as_str),
539            Some("tracker.example.com")
540        );
541    }
542
543    #[test]
544    fn it_should_reject_port_zero_during_deserialization() {
545        let json = r#"{"bind_address": "0.0.0.0:0", "use_tls_proxy": false}"#;
546        let result: Result<HttpTrackerConfig, _> = serde_json::from_str(json);
547
548        assert!(result.is_err());
549        let err_msg = result.unwrap_err().to_string();
550        assert!(err_msg.contains("dynamic port"));
551    }
552
553    #[test]
554    fn it_should_reject_tls_without_domain_during_deserialization() {
555        let json = r#"{"bind_address": "0.0.0.0:7070", "use_tls_proxy": true}"#;
556        let result: Result<HttpTrackerConfig, _> = serde_json::from_str(json);
557
558        assert!(result.is_err());
559        let err_msg = result.unwrap_err().to_string();
560        assert!(err_msg.contains("TLS proxy requires a domain"));
561    }
562
563    // =========================================================================
564    // Display tests
565    // =========================================================================
566
567    #[test]
568    fn it_should_display_without_tls() {
569        let config = HttpTrackerConfig::new("0.0.0.0:7070".parse().unwrap(), None, false).unwrap();
570
571        assert_eq!(config.to_string(), "HTTP tracker at 0.0.0.0:7070");
572    }
573
574    #[test]
575    fn it_should_display_with_tls() {
576        let domain = DomainName::new("tracker.example.com").unwrap();
577        let config =
578            HttpTrackerConfig::new("0.0.0.0:7070".parse().unwrap(), Some(domain), true).unwrap();
579
580        assert_eq!(
581            config.to_string(),
582            "HTTP tracker at 0.0.0.0:7070 (tracker.example.com) [TLS]"
583        );
584    }
585
586    // =========================================================================
587    // Round-trip tests
588    // =========================================================================
589
590    #[test]
591    fn it_should_round_trip_through_json() {
592        let domain = DomainName::new("tracker.example.com").unwrap();
593        let original =
594            HttpTrackerConfig::new("0.0.0.0:7070".parse().unwrap(), Some(domain), true).unwrap();
595
596        let json = serde_json::to_string(&original).unwrap();
597        let restored: HttpTrackerConfig = serde_json::from_str(&json).unwrap();
598
599        assert_eq!(original, restored);
600    }
601}