Skip to main content

torrust_tracker_deployer_lib/domain/tracker/config/
http_api.rs

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