From bca1a2aba190d2055f1c66897b3ee177913855f7 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 6 Apr 2026 17:06:39 +0100 Subject: [PATCH] fix: [#412] include UDP tracker domains in provision output and DNS reminder --- .../command_handlers/show/info/tracker.rs | 97 +++++++++++++++++++ .../provision/view_data/dns_reminder.rs | 31 +++++- .../provision/view_data/provision_details.rs | 2 +- 3 files changed, 126 insertions(+), 4 deletions(-) diff --git a/src/application/command_handlers/show/info/tracker.rs b/src/application/command_handlers/show/info/tracker.rs index e400190b..2597995a 100644 --- a/src/application/command_handlers/show/info/tracker.rs +++ b/src/application/command_handlers/show/info/tracker.rs @@ -329,6 +329,47 @@ impl ServiceInfo { self.tls_domains.iter().map(|d| d.domain.as_str()).collect() } + /// Returns all domain names that require DNS records: TLS service domains + /// plus UDP tracker domains (when a domain is explicitly configured). + /// + /// This is the correct method to use for the `provision` output `domains` array + /// and the DNS setup reminder, because both need to list every hostname the + /// operator must point at the server IP — including UDP trackers that have a + /// domain name set. + /// + /// `tls_domain_names()` is kept for the HTTPS-specific hint that only lists + /// TLS-enabled services. + #[must_use] + pub fn all_domain_names(&self) -> Vec<&str> { + let mut domains: Vec<&str> = self.tls_domain_names(); + for url in &self.udp_trackers { + // UDP tracker URLs are formatted as "udp://:/announce". + // Extract the host part and include it only when it is a domain name + // (i.e., it was originally set via UdpTrackerConfig::domain()). + if let Some(host) = Self::extract_host_from_udp_url(url) { + // Only include if it looks like a domain name (not a raw IP address) + if host.parse::().is_err() { + domains.push(host); + } + } + } + domains + } + + /// Extracts the host portion from a UDP tracker announce URL. + /// + /// Expected format: `udp://:/announce` + fn extract_host_from_udp_url(url: &str) -> Option<&str> { + // Strip scheme + let without_scheme = url.strip_prefix("udp://")?; + // Strip path suffix + let host_and_port = without_scheme + .strip_suffix("/announce") + .unwrap_or(without_scheme); + // Strip port + host_and_port.rsplit_once(':').map(|(host, _port)| host) + } + /// Returns all internal ports that are not exposed due to TLS #[must_use] pub fn unexposed_ports(&self) -> Vec { @@ -519,4 +560,60 @@ mod tests { assert_eq!(services.localhost_http_trackers.len(), 1); assert_eq!(services.localhost_http_trackers[0].port, 7070); } + + #[test] + fn it_should_return_all_domain_names_including_udp() { + let services = ServiceInfo::new( + vec![ + "udp://udp1.tracker.local:6868/announce".to_string(), + "udp://udp2.tracker.local:6969/announce".to_string(), + ], + vec!["https://http1.tracker.local/announce".to_string()], + vec![], + vec![], + "https://api.tracker.local/api".to_string(), + true, + false, + "http://10.0.0.1:1313/health_check".to_string(), // DevSkim: ignore DS137138 + false, + false, + vec![TlsDomainInfo { + domain: "http1.tracker.local".to_string(), + internal_port: 7070, + }], + ); + + let domains = services.all_domain_names(); + assert_eq!(domains.len(), 3); + assert!(domains.contains(&"http1.tracker.local")); + assert!(domains.contains(&"udp1.tracker.local")); + assert!(domains.contains(&"udp2.tracker.local")); + } + + #[test] + fn it_should_exclude_udp_trackers_without_domain_from_all_domain_names() { + let services = ServiceInfo::new( + // IP-only UDP trackers — no domain name + vec![ + "udp://10.0.0.1:6868/announce".to_string(), + "udp://10.0.0.2:6969/announce".to_string(), + ], + vec![], + vec![], + vec![], + "http://10.0.0.1:1212/api".to_string(), // DevSkim: ignore DS137138 + false, + false, + "http://10.0.0.1:1313/health_check".to_string(), // DevSkim: ignore DS137138 + false, + false, + vec![], + ); + + let domains = services.all_domain_names(); + assert!( + domains.is_empty(), + "IP-only UDP trackers must not appear in all_domain_names()" + ); + } } diff --git a/src/presentation/cli/views/commands/provision/view_data/dns_reminder.rs b/src/presentation/cli/views/commands/provision/view_data/dns_reminder.rs index 232151fd..73b26c13 100644 --- a/src/presentation/cli/views/commands/provision/view_data/dns_reminder.rs +++ b/src/presentation/cli/views/commands/provision/view_data/dns_reminder.rs @@ -158,10 +158,8 @@ impl DnsReminderView { /// ``` #[must_use] pub fn extract_all_domains(services: &ServiceInfo) -> Vec { - // Currently, ServiceInfo only tracks TLS domains - // This returns all domain names from tls_domains services - .tls_domain_names() + .all_domain_names() .iter() .map(|s| (*s).to_string()) .collect() @@ -238,6 +236,33 @@ mod tests { assert!(domains.contains(&"health.tracker.local".to_string())); } + #[test] + fn it_should_include_udp_tracker_domains_in_extract_all_domains() { + let services = ServiceInfo::new( + vec![ + "udp://udp1.tracker.local:6868/announce".to_string(), + "udp://udp2.tracker.local:6969/announce".to_string(), + ], + vec!["https://http.tracker.local/announce".to_string()], + vec![], + vec![], + "https://api.tracker.local/api".to_string(), + true, + false, + "http://10.0.0.1:1313/health_check".to_string(), // DevSkim: ignore DS137138 + false, + false, + vec![TlsDomainInfo::new("http.tracker.local".to_string(), 7070)], + ); + + let domains = DnsReminderView::extract_all_domains(&services); + + assert_eq!(domains.len(), 3); + assert!(domains.contains(&"http.tracker.local".to_string())); + assert!(domains.contains(&"udp1.tracker.local".to_string())); + assert!(domains.contains(&"udp2.tracker.local".to_string())); + } + #[test] fn it_should_return_empty_vec_when_no_domains_configured() { let services = ServiceInfo::new( diff --git a/src/presentation/cli/views/commands/provision/view_data/provision_details.rs b/src/presentation/cli/views/commands/provision/view_data/provision_details.rs index 73d1fd91..59fecaf0 100644 --- a/src/presentation/cli/views/commands/provision/view_data/provision_details.rs +++ b/src/presentation/cli/views/commands/provision/view_data/provision_details.rs @@ -80,7 +80,7 @@ impl From<&Environment> for ProvisionDetailsData { let grafana_config = environment.grafana_config(); let services = ServiceInfo::from_tracker_config(tracker_config, ip, grafana_config); services - .tls_domain_names() + .all_domain_names() .iter() .map(|s| (*s).to_string()) .collect()