torrust_tracker_deployer_lib/domain/tracker/config/
http.rs1use 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#[derive(Debug, Clone, PartialEq, Error)]
56pub enum HttpTrackerConfigError {
57 #[error("dynamic port (0) is not supported for HTTP tracker bind address '{0}'")]
62 DynamicPortNotSupported(SocketAddr),
63
64 #[error("TLS proxy requires a domain to be configured for HTTP tracker bind address '{0}'")]
69 TlsProxyRequiresDomain(SocketAddr),
70
71 #[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 #[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#[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#[derive(Debug, Clone, Serialize, PartialEq)]
182pub struct HttpTrackerConfig {
183 #[serde(serialize_with = "crate::domain::tracker::config::serialize_socket_addr")]
185 bind_address: SocketAddr,
186
187 #[serde(skip_serializing_if = "Option::is_none")]
192 domain: Option<DomainName>,
193
194 use_tls_proxy: bool,
202}
203
204impl HttpTrackerConfig {
205 pub fn new(
246 bind_address: SocketAddr,
247 domain: Option<DomainName>,
248 use_tls_proxy: bool,
249 ) -> Result<Self, HttpTrackerConfigError> {
250 if bind_address.port() == 0 {
252 return Err(HttpTrackerConfigError::DynamicPortNotSupported(
253 bind_address,
254 ));
255 }
256
257 if use_tls_proxy && domain.is_none() {
259 return Err(HttpTrackerConfigError::TlsProxyRequiresDomain(bind_address));
260 }
261
262 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 #[must_use]
280 pub fn bind_address(&self) -> SocketAddr {
281 self.bind_address
282 }
283
284 #[must_use]
286 pub fn domain(&self) -> Option<&DomainName> {
287 self.domain.as_ref()
288 }
289
290 #[must_use]
292 pub fn use_tls_proxy(&self) -> bool {
293 self.use_tls_proxy
294 }
295
296 #[must_use]
304 pub fn uses_tls_proxy(&self) -> bool {
305 self.use_tls_proxy
306 }
307
308 #[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
322impl<'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 #[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 assert!(config.tls_domain().is_none());
392 assert!(config.domain().is_some());
394 }
395
396 #[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 #[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 #[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 #[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 #[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 #[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}