Skip to content

Commit a8bd087

Browse files
committed
feat: [#279] add domain support for UDP trackers
Add optional domain field to UDP tracker configuration to allow domain names in announce URLs instead of IP addresses. Changes: - Add domain field to UdpTrackerConfig (domain layer) - Add domain field to UdpTrackerSection (application DTO) - Update show command to use domain for UDP URLs when available - Regenerate JSON schema with new field - Update manual test env with UDP domain example - Update all test fixtures with domain: None
1 parent 18b1d25 commit a8bd087

11 files changed

Lines changed: 212 additions & 13 deletions

File tree

schemas/environment-config.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.10s
2+
Running `target/debug/torrust-tracker-deployer create schema`
13
{
24
"$schema": "https://json-schema.org/draft/2020-12/schema",
35
"title": "EnvironmentCreationConfig",
@@ -460,6 +462,13 @@
460462
"properties": {
461463
"bind_address": {
462464
"type": "string"
465+
},
466+
"domain": {
467+
"description": "Domain name for the UDP tracker (optional)\n\nWhen present, this domain can be used in announce URLs instead of the IP.\nExample: `udp://tracker.example.com:6969/announce`\n\nNote: Unlike HTTP trackers, UDP does not support TLS, so there is no\n`use_tls_proxy` field for UDP trackers.",
468+
"type": [
469+
"string",
470+
"null"
471+
]
463472
}
464473
},
465474
"required": [

src/application/command_handlers/create/config/environment_config.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,7 @@ impl EnvironmentCreationConfig {
504504
},
505505
udp_trackers: vec![super::tracker::UdpTrackerSection {
506506
bind_address: "0.0.0.0:6969".to_string(),
507+
domain: None,
507508
}],
508509
http_trackers: vec![super::tracker::HttpTrackerSection {
509510
bind_address: "0.0.0.0:7070".to_string(),
@@ -1407,6 +1408,7 @@ mod tests {
14071408
},
14081409
udp_trackers: vec![UdpTrackerSection {
14091410
bind_address: "0.0.0.0:6969".to_string(),
1411+
domain: None,
14101412
}],
14111413
http_trackers: vec![HttpTrackerSection {
14121414
bind_address: "0.0.0.0:7070".to_string(),
@@ -1482,6 +1484,7 @@ mod tests {
14821484
},
14831485
udp_trackers: vec![UdpTrackerSection {
14841486
bind_address: "0.0.0.0:6969".to_string(),
1487+
domain: None,
14851488
}],
14861489
http_trackers: vec![HttpTrackerSection {
14871490
bind_address: "0.0.0.0:7070".to_string(),
@@ -1573,6 +1576,7 @@ mod tests {
15731576
},
15741577
udp_trackers: vec![UdpTrackerSection {
15751578
bind_address: "0.0.0.0:6969".to_string(),
1579+
domain: None,
15761580
}],
15771581
http_trackers: vec![HttpTrackerSection {
15781582
bind_address: "0.0.0.0:7070".to_string(),

src/application/command_handlers/create/config/tracker/tracker_section.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ impl Default for TrackerSection {
125125
},
126126
udp_trackers: vec![UdpTrackerSection {
127127
bind_address: "0.0.0.0:6969".to_string(),
128+
domain: None,
128129
}],
129130
http_trackers: vec![HttpTrackerSection {
130131
bind_address: "0.0.0.0:7070".to_string(),
@@ -161,6 +162,7 @@ mod tests {
161162
},
162163
udp_trackers: vec![UdpTrackerSection {
163164
bind_address: "0.0.0.0:6969".to_string(),
165+
domain: None,
164166
}],
165167
http_trackers: vec![HttpTrackerSection {
166168
bind_address: "0.0.0.0:7070".to_string(),
@@ -205,9 +207,11 @@ mod tests {
205207
udp_trackers: vec![
206208
UdpTrackerSection {
207209
bind_address: "0.0.0.0:6969".to_string(),
210+
domain: None,
208211
},
209212
UdpTrackerSection {
210213
bind_address: "0.0.0.0:6970".to_string(),
214+
domain: None,
211215
},
212216
],
213217
http_trackers: vec![
@@ -248,6 +252,7 @@ mod tests {
248252
},
249253
udp_trackers: vec![UdpTrackerSection {
250254
bind_address: "invalid".to_string(),
255+
domain: None,
251256
}],
252257
http_trackers: vec![],
253258
http_api: HttpApiSection {
@@ -279,6 +284,7 @@ mod tests {
279284
},
280285
udp_trackers: vec![UdpTrackerSection {
281286
bind_address: "0.0.0.0:6969".to_string(),
287+
domain: None,
282288
}],
283289
http_trackers: vec![HttpTrackerSection {
284290
bind_address: "0.0.0.0:7070".to_string(),
@@ -378,6 +384,7 @@ mod tests {
378384
},
379385
udp_trackers: vec![UdpTrackerSection {
380386
bind_address: "0.0.0.0:7070".to_string(),
387+
domain: None,
381388
}],
382389
http_trackers: vec![HttpTrackerSection {
383390
bind_address: "0.0.0.0:7070".to_string(),

src/application/command_handlers/create/config/tracker/udp_tracker_section.rs

Lines changed: 99 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,21 @@ use serde::{Deserialize, Serialize};
55

66
use crate::application::command_handlers::create::config::errors::CreateConfigError;
77
use crate::domain::tracker::UdpTrackerConfig;
8+
use crate::shared::DomainName;
89

910
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
1011
pub struct UdpTrackerSection {
1112
pub bind_address: String,
13+
14+
/// Domain name for the UDP tracker (optional)
15+
///
16+
/// When present, this domain can be used in announce URLs instead of the IP.
17+
/// Example: `udp://tracker.example.com:6969/announce`
18+
///
19+
/// Note: Unlike HTTP trackers, UDP does not support TLS, so there is no
20+
/// `use_tls_proxy` field for UDP trackers.
21+
#[serde(default, skip_serializing_if = "Option::is_none")]
22+
pub domain: Option<String>,
1223
}
1324

1425
impl UdpTrackerSection {
@@ -18,6 +29,7 @@ impl UdpTrackerSection {
1829
///
1930
/// Returns `CreateConfigError::InvalidBindAddress` if the bind address cannot be parsed as a valid IP:PORT combination.
2031
/// Returns `CreateConfigError::DynamicPortNotSupported` if port 0 (dynamic port assignment) is specified.
32+
/// Returns `CreateConfigError::InvalidDomain` if the domain is invalid.
2133
pub fn to_udp_tracker_config(&self) -> Result<UdpTrackerConfig, CreateConfigError> {
2234
// Validate that the bind address can be parsed as SocketAddr
2335
let bind_address = self.bind_address.parse::<SocketAddr>().map_err(|e| {
@@ -34,8 +46,23 @@ impl UdpTrackerSection {
3446
});
3547
}
3648

37-
// Domain type now uses SocketAddr (Step 0.7 completed)
38-
Ok(UdpTrackerConfig { bind_address })
49+
// Convert domain to domain type with validation (if present)
50+
let domain = match &self.domain {
51+
Some(domain_str) => {
52+
let domain =
53+
DomainName::new(domain_str).map_err(|e| CreateConfigError::InvalidDomain {
54+
domain: domain_str.clone(),
55+
reason: e.to_string(),
56+
})?;
57+
Some(domain)
58+
}
59+
None => None,
60+
};
61+
62+
Ok(UdpTrackerConfig {
63+
bind_address,
64+
domain,
65+
})
3966
}
4067
}
4168

@@ -47,6 +74,7 @@ mod tests {
4774
fn it_should_convert_valid_bind_address_to_udp_tracker_config() {
4875
let section = UdpTrackerSection {
4976
bind_address: "0.0.0.0:6969".to_string(),
77+
domain: None,
5078
};
5179

5280
let result = section.to_udp_tracker_config();
@@ -57,12 +85,52 @@ mod tests {
5785
config.bind_address,
5886
"0.0.0.0:6969".parse::<SocketAddr>().unwrap()
5987
);
88+
assert!(config.domain.is_none());
89+
}
90+
91+
#[test]
92+
fn it_should_convert_with_valid_domain() {
93+
let section = UdpTrackerSection {
94+
bind_address: "0.0.0.0:6969".to_string(),
95+
domain: Some("udp.tracker.local".to_string()),
96+
};
97+
98+
let result = section.to_udp_tracker_config();
99+
assert!(result.is_ok());
100+
101+
let config = result.unwrap();
102+
assert_eq!(
103+
config.bind_address,
104+
"0.0.0.0:6969".parse::<SocketAddr>().unwrap()
105+
);
106+
assert_eq!(
107+
config.domain.as_ref().map(DomainName::as_str),
108+
Some("udp.tracker.local")
109+
);
110+
}
111+
112+
#[test]
113+
fn it_should_fail_for_invalid_domain() {
114+
let section = UdpTrackerSection {
115+
bind_address: "0.0.0.0:6969".to_string(),
116+
domain: Some(String::new()), // Empty domain is invalid
117+
};
118+
119+
let result = section.to_udp_tracker_config();
120+
assert!(result.is_err());
121+
122+
if let Err(CreateConfigError::InvalidDomain { domain, .. }) = result {
123+
assert_eq!(domain, "");
124+
} else {
125+
panic!("Expected InvalidDomain error");
126+
}
60127
}
61128

62129
#[test]
63130
fn it_should_fail_for_invalid_bind_address() {
64131
let section = UdpTrackerSection {
65132
bind_address: "invalid".to_string(),
133+
domain: None,
66134
};
67135

68136
let result = section.to_udp_tracker_config();
@@ -79,6 +147,7 @@ mod tests {
79147
fn it_should_reject_port_zero() {
80148
let section = UdpTrackerSection {
81149
bind_address: "0.0.0.0:0".to_string(),
150+
domain: None,
82151
};
83152

84153
let result = section.to_udp_tracker_config();
@@ -92,20 +161,46 @@ mod tests {
92161
}
93162

94163
#[test]
95-
fn it_should_be_serializable() {
164+
fn it_should_be_serializable_without_domain() {
96165
let section = UdpTrackerSection {
97166
bind_address: "0.0.0.0:6969".to_string(),
167+
domain: None,
98168
};
99169

100170
let json = serde_json::to_string(&section).unwrap();
101171
assert!(json.contains("bind_address"));
102172
assert!(json.contains("0.0.0.0:6969"));
173+
// domain should not be present when None (skip_serializing_if)
174+
assert!(!json.contains("domain"));
103175
}
104176

105177
#[test]
106-
fn it_should_be_deserializable() {
178+
fn it_should_be_serializable_with_domain() {
179+
let section = UdpTrackerSection {
180+
bind_address: "0.0.0.0:6969".to_string(),
181+
domain: Some("udp.tracker.local".to_string()),
182+
};
183+
184+
let json = serde_json::to_string(&section).unwrap();
185+
assert!(json.contains("bind_address"));
186+
assert!(json.contains("0.0.0.0:6969"));
187+
assert!(json.contains("domain"));
188+
assert!(json.contains("udp.tracker.local"));
189+
}
190+
191+
#[test]
192+
fn it_should_be_deserializable_without_domain() {
107193
let json = r#"{"bind_address":"0.0.0.0:6969"}"#;
108194
let section: UdpTrackerSection = serde_json::from_str(json).unwrap();
109195
assert_eq!(section.bind_address, "0.0.0.0:6969");
196+
assert!(section.domain.is_none());
197+
}
198+
199+
#[test]
200+
fn it_should_be_deserializable_with_domain() {
201+
let json = r#"{"bind_address":"0.0.0.0:6969","domain":"udp.tracker.local"}"#;
202+
let section: UdpTrackerSection = serde_json::from_str(json).unwrap();
203+
assert_eq!(section.bind_address, "0.0.0.0:6969");
204+
assert_eq!(section.domain, Some("udp.tracker.local".to_string()));
110205
}
111206
}

src/application/command_handlers/show/info/tracker.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ impl ServiceInfo {
124124
/// * `instance_ip` - The IP address of the deployed instance
125125
/// * `grafana_config` - Optional Grafana configuration (for TLS domain info)
126126
#[must_use]
127+
#[allow(clippy::too_many_lines)]
127128
pub fn from_tracker_config(
128129
tracker_config: &TrackerConfig,
129130
instance_ip: IpAddr,
@@ -132,7 +133,13 @@ impl ServiceInfo {
132133
let udp_trackers = tracker_config
133134
.udp_trackers
134135
.iter()
135-
.map(|udp| format!("udp://{}:{}/announce", instance_ip, udp.bind_address.port()))
136+
.map(|udp| {
137+
let host = udp
138+
.domain
139+
.as_ref()
140+
.map_or_else(|| instance_ip.to_string(), |d| d.as_str().to_string());
141+
format!("udp://{}:{}/announce", host, udp.bind_address.port())
142+
})
136143
.collect();
137144

138145
// Separate HTTP trackers by TLS configuration and localhost status

src/domain/tracker/config/mod.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ pub fn is_localhost(addr: &SocketAddr) -> bool {
6767
/// private: false,
6868
/// },
6969
/// udp_trackers: vec![
70-
/// UdpTrackerConfig { bind_address: "0.0.0.0:6969".parse().unwrap() },
70+
/// UdpTrackerConfig { bind_address: "0.0.0.0:6969".parse().unwrap(), domain: None },
7171
/// ],
7272
/// http_trackers: vec![
7373
/// HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), domain: None, use_tls_proxy: false },
@@ -274,7 +274,7 @@ impl TrackerConfig {
274274
/// private: false,
275275
/// },
276276
/// udp_trackers: vec![
277-
/// UdpTrackerConfig { bind_address: "0.0.0.0:6969".parse().unwrap() },
277+
/// UdpTrackerConfig { bind_address: "0.0.0.0:6969".parse().unwrap(), domain: None },
278278
/// ],
279279
/// http_trackers: vec![
280280
/// HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), domain: None, use_tls_proxy: false },
@@ -537,6 +537,7 @@ impl Default for TrackerConfig {
537537
},
538538
udp_trackers: vec![UdpTrackerConfig {
539539
bind_address: "0.0.0.0:6969".parse().expect("valid address"),
540+
domain: None,
540541
}],
541542
http_trackers: vec![HttpTrackerConfig {
542543
bind_address: "0.0.0.0:7070".parse().expect("valid address"),
@@ -629,6 +630,7 @@ mod tests {
629630
},
630631
udp_trackers: vec![UdpTrackerConfig {
631632
bind_address: "0.0.0.0:6868".parse().unwrap(),
633+
domain: None,
632634
}],
633635
http_trackers: vec![HttpTrackerConfig {
634636
bind_address: "0.0.0.0:7070".parse().unwrap(),
@@ -730,6 +732,7 @@ mod tests {
730732
},
731733
udp_trackers: vec![UdpTrackerConfig {
732734
bind_address: "0.0.0.0:6969".parse().unwrap(),
735+
domain: None,
733736
}],
734737
http_trackers: vec![HttpTrackerConfig {
735738
bind_address: "0.0.0.0:7070".parse().unwrap(),
@@ -764,9 +767,11 @@ mod tests {
764767
udp_trackers: vec![
765768
UdpTrackerConfig {
766769
bind_address: "0.0.0.0:7070".parse().unwrap(),
770+
domain: None,
767771
},
768772
UdpTrackerConfig {
769773
bind_address: "0.0.0.0:7070".parse().unwrap(),
774+
domain: None,
770775
},
771776
],
772777
http_trackers: vec![],
@@ -960,6 +965,7 @@ mod tests {
960965
},
961966
udp_trackers: vec![UdpTrackerConfig {
962967
bind_address: "0.0.0.0:7070".parse().unwrap(),
968+
domain: None,
963969
}],
964970
http_trackers: vec![HttpTrackerConfig {
965971
bind_address: "0.0.0.0:7070".parse().unwrap(),

0 commit comments

Comments
 (0)