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}