Skip to content

Commit f143303

Browse files
committed
feat: [#272] add HTTPS/TLS support for health check API
Task 7.3: Health Check API TLS Support - Add TLS configuration to HealthCheckApiConfig domain model - Add HealthCheckApiSection DTO with TLS support - Update ServiceInfo to include health_check_uses_https flag - Add health_check_api service to CaddyContext - Update Caddyfile.tera template for health check reverse proxy - Add TrackerContext health_check_api_bind_address field - Update tracker.toml.tera with [health_check_api] section - Update show command views to display HTTPS indicator for health check - Add tests for health check TLS configuration
1 parent 1855058 commit f143303

File tree

16 files changed

+289
-15
lines changed

16 files changed

+289
-15
lines changed

docs/issues/272-add-https-support-with-caddy.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1048,7 +1048,7 @@ add the following to your /etc/hosts file:
10481048
Internal ports (1212, 7070, 7071, 3000) are not directly accessible when TLS is enabled.
10491049
```
10501050

1051-
#### 7.3: Add TLS Support for Health Check API
1051+
#### 7.3: Add TLS Support for Health Check API ✅ COMPLETE
10521052

10531053
**Current State**: The health check API (`health_check_api`) doesn't support TLS configuration like other HTTP services (HTTP trackers, Tracker API, Grafana).
10541054

@@ -1073,11 +1073,11 @@ Internal ports (1212, 7070, 7071, 3000) are not directly accessible when TLS is
10731073

10741074
**Implementation Scope**:
10751075

1076-
- [ ] Add `tls: Option<TlsConfig>` to health check API domain model
1077-
- [ ] Add `tls: Option<TlsConfig>` to health check API DTOs
1078-
- [ ] Update Caddyfile template to include health check when TLS is configured
1079-
- [ ] Update show command to display HTTPS URL when health check has TLS
1080-
- [ ] Update test command to use HTTPS for health check when TLS is configured
1076+
- [x] Add `tls: Option<TlsConfig>` to health check API domain model
1077+
- [x] Add `tls: Option<TlsConfig>` to health check API DTOs
1078+
- [x] Update Caddyfile template to include health check when TLS is configured
1079+
- [x] Update show command to display HTTPS URL when health check has TLS
1080+
- [ ] Update test command to use HTTPS for health check when TLS is configured (deferred to 7.1)
10811081

10821082
> **Note**: JSON schema regeneration deferred to Phase 8.
10831083

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

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,22 @@ use schemars::JsonSchema;
44
use serde::{Deserialize, Serialize};
55

66
use crate::application::command_handlers::create::config::errors::CreateConfigError;
7+
use crate::application::command_handlers::create::config::https::TlsSection;
8+
use crate::domain::tls::TlsConfig;
79
use crate::domain::tracker::HealthCheckApiConfig;
10+
use crate::shared::DomainName;
811

912
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
1013
pub struct HealthCheckApiSection {
1114
pub bind_address: String,
15+
16+
/// Optional TLS configuration for HTTPS
17+
///
18+
/// When present, this service will be proxied through Caddy with HTTPS enabled.
19+
/// The domain specified will be used for Let's Encrypt certificate acquisition.
20+
/// This is useful for exposing health checks to external monitoring systems.
21+
#[serde(default, skip_serializing_if = "Option::is_none")]
22+
pub tls: Option<TlsSection>,
1223
}
1324

1425
impl HealthCheckApiSection {
@@ -18,6 +29,7 @@ impl HealthCheckApiSection {
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 TLS domain is invalid.
2133
pub fn to_health_check_api_config(&self) -> Result<HealthCheckApiConfig, 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,14 +46,30 @@ impl HealthCheckApiSection {
3446
});
3547
}
3648

37-
Ok(HealthCheckApiConfig { bind_address })
49+
// Convert TLS section to domain type with validation
50+
let tls = match &self.tls {
51+
Some(tls_section) => {
52+
tls_section.validate()?;
53+
let domain = DomainName::new(&tls_section.domain).map_err(|e| {
54+
CreateConfigError::InvalidDomain {
55+
domain: tls_section.domain.clone(),
56+
reason: e.to_string(),
57+
}
58+
})?;
59+
Some(TlsConfig::new(domain))
60+
}
61+
None => None,
62+
};
63+
64+
Ok(HealthCheckApiConfig { bind_address, tls })
3865
}
3966
}
4067

4168
impl Default for HealthCheckApiSection {
4269
fn default() -> Self {
4370
Self {
4471
bind_address: "127.0.0.1:1313".to_string(),
72+
tls: None,
4573
}
4674
}
4775
}
@@ -54,6 +82,7 @@ mod tests {
5482
fn it_should_convert_to_domain_config_when_bind_address_is_valid() {
5583
let section = HealthCheckApiSection {
5684
bind_address: "127.0.0.1:1313".to_string(),
85+
tls: None,
5786
};
5887

5988
let config = section.to_health_check_api_config().unwrap();
@@ -62,12 +91,33 @@ mod tests {
6291
config.bind_address,
6392
"127.0.0.1:1313".parse::<SocketAddr>().unwrap()
6493
);
94+
assert!(config.tls.is_none());
95+
}
96+
97+
#[test]
98+
fn it_should_convert_to_domain_config_with_tls() {
99+
let section = HealthCheckApiSection {
100+
bind_address: "0.0.0.0:1313".to_string(),
101+
tls: Some(TlsSection {
102+
domain: "health.tracker.local".to_string(),
103+
}),
104+
};
105+
106+
let config = section.to_health_check_api_config().unwrap();
107+
108+
assert_eq!(
109+
config.bind_address,
110+
"0.0.0.0:1313".parse::<SocketAddr>().unwrap()
111+
);
112+
assert!(config.tls.is_some());
113+
assert_eq!(config.tls_domain(), Some("health.tracker.local"));
65114
}
66115

67116
#[test]
68117
fn it_should_fail_when_bind_address_is_invalid() {
69118
let section = HealthCheckApiSection {
70119
bind_address: "invalid".to_string(),
120+
tls: None,
71121
};
72122

73123
let result = section.to_health_check_api_config();
@@ -83,6 +133,7 @@ mod tests {
83133
fn it_should_reject_dynamic_port_assignment() {
84134
let section = HealthCheckApiSection {
85135
bind_address: "0.0.0.0:0".to_string(),
136+
tls: None,
86137
};
87138

88139
let result = section.to_health_check_api_config();
@@ -98,6 +149,7 @@ mod tests {
98149
fn it_should_allow_ipv6_addresses() {
99150
let section = HealthCheckApiSection {
100151
bind_address: "[::1]:1313".to_string(),
152+
tls: None,
101153
};
102154

103155
let result = section.to_health_check_api_config();
@@ -109,6 +161,7 @@ mod tests {
109161
fn it_should_allow_any_port_except_zero() {
110162
let section = HealthCheckApiSection {
111163
bind_address: "127.0.0.1:8080".to_string(),
164+
tls: None,
112165
};
113166

114167
let result = section.to_health_check_api_config();
@@ -121,5 +174,24 @@ mod tests {
121174
let section = HealthCheckApiSection::default();
122175

123176
assert_eq!(section.bind_address, "127.0.0.1:1313");
177+
assert!(section.tls.is_none());
178+
}
179+
180+
#[test]
181+
fn it_should_fail_when_tls_domain_is_invalid() {
182+
let section = HealthCheckApiSection {
183+
bind_address: "0.0.0.0:1313".to_string(),
184+
tls: Some(TlsSection {
185+
domain: "invalid domain with spaces".to_string(),
186+
}),
187+
};
188+
189+
let result = section.to_health_check_api_config();
190+
191+
assert!(result.is_err());
192+
assert!(matches!(
193+
result.unwrap_err(),
194+
CreateConfigError::InvalidDomain { .. }
195+
));
124196
}
125197
}

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

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,12 @@ pub struct ServiceInfo {
2929
/// Whether the API endpoint uses HTTPS via Caddy
3030
pub api_uses_https: bool,
3131

32-
/// Health check API URL (e.g., `http://10.0.0.1:1313/health_check`)
32+
/// Health check API URL (e.g., `http://10.0.0.1:1313/health_check` or `https://health.tracker.local/health_check`)
3333
pub health_check_url: String,
3434

35+
/// Whether the health check endpoint uses HTTPS via Caddy
36+
pub health_check_uses_https: bool,
37+
3538
/// Domains configured for TLS services (for /etc/hosts hint)
3639
pub tls_domains: Vec<TlsDomainInfo>,
3740
}
@@ -59,13 +62,15 @@ impl TlsDomainInfo {
5962
impl ServiceInfo {
6063
/// Create a new `ServiceInfo`
6164
#[must_use]
65+
#[allow(clippy::too_many_arguments)]
6266
pub fn new(
6367
udp_trackers: Vec<String>,
6468
https_http_trackers: Vec<String>,
6569
direct_http_trackers: Vec<String>,
6670
api_endpoint: String,
6771
api_uses_https: bool,
6872
health_check_url: String,
73+
health_check_uses_https: bool,
6974
tls_domains: Vec<TlsDomainInfo>,
7075
) -> Self {
7176
Self {
@@ -75,6 +80,7 @@ impl ServiceInfo {
7580
api_endpoint,
7681
api_uses_https,
7782
health_check_url,
83+
health_check_uses_https,
7884
tls_domains,
7985
}
8086
}
@@ -153,11 +159,24 @@ impl ServiceInfo {
153159
}
154160
}
155161

156-
let health_check_url = format!(
157-
"http://{}:{}/health_check", // DevSkim: ignore DS137138
158-
instance_ip,
159-
tracker_config.health_check_api.bind_address.port()
160-
);
162+
// Build health check URL based on TLS configuration
163+
let (health_check_url, health_check_uses_https) =
164+
if let Some(tls) = &tracker_config.health_check_api.tls {
165+
tls_domains.push(TlsDomainInfo {
166+
domain: tls.domain().to_string(),
167+
internal_port: tracker_config.health_check_api.bind_address.port(),
168+
});
169+
(format!("https://{}/health_check", tls.domain()), true)
170+
} else {
171+
(
172+
format!(
173+
"http://{}:{}/health_check", // DevSkim: ignore DS137138
174+
instance_ip,
175+
tracker_config.health_check_api.bind_address.port()
176+
),
177+
false,
178+
)
179+
};
161180

162181
Self::new(
163182
udp_trackers,
@@ -166,6 +185,7 @@ impl ServiceInfo {
166185
api_endpoint,
167186
api_uses_https,
168187
health_check_url,
188+
health_check_uses_https,
169189
tls_domains,
170190
)
171191
}
@@ -210,6 +230,7 @@ impl ServiceInfo {
210230
api_endpoint,
211231
false, // Legacy endpoints don't have TLS info
212232
health_check_url,
233+
false, // Legacy endpoints don't have health check TLS info
213234
Vec::new(), // No TLS domains from legacy endpoints
214235
)
215236
}
@@ -246,6 +267,7 @@ mod tests {
246267
"http://10.0.0.1:1212/api".to_string(), // DevSkim: ignore DS137138
247268
false,
248269
"http://10.0.0.1:1313/health_check".to_string(), // DevSkim: ignore DS137138
270+
false, // Health check doesn't use HTTPS
249271
vec![TlsDomainInfo {
250272
domain: "http1.tracker.local".to_string(),
251273
internal_port: 7070,
@@ -270,6 +292,7 @@ mod tests {
270292
"https://api.tracker.local/api".to_string(),
271293
true,
272294
"http://10.0.0.1:1313/health_check".to_string(), // DevSkim: ignore DS137138
295+
false, // Health check doesn't use HTTPS
273296
vec![
274297
TlsDomainInfo {
275298
domain: "api.tracker.local".to_string(),
@@ -297,6 +320,7 @@ mod tests {
297320
"https://api.tracker.local/api".to_string(),
298321
true,
299322
String::new(),
323+
false, // Health check doesn't use HTTPS
300324
vec![
301325
TlsDomainInfo {
302326
domain: "api.tracker.local".to_string(),
@@ -324,6 +348,7 @@ mod tests {
324348
"http://10.0.0.1:1212/api".to_string(), // DevSkim: ignore DS137138
325349
false,
326350
"http://10.0.0.1:1313/health_check".to_string(), // DevSkim: ignore DS137138
351+
false, // Health check doesn't use HTTPS
327352
vec![],
328353
);
329354

src/application/steps/rendering/caddy_templates.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,12 @@ impl<S> RenderCaddyTemplatesStep<S> {
172172
context = context.with_http_tracker(CaddyService::new(domain, port));
173173
}
174174

175+
// Add Health Check API if TLS configured
176+
if let Some(tls_domain) = tracker.health_check_api_tls_domain() {
177+
let port = tracker.health_check_api_port();
178+
context = context.with_health_check_api(CaddyService::new(tls_domain, port));
179+
}
180+
175181
// Add Grafana if TLS configured
176182
if let Some(ref grafana) = user_inputs.grafana {
177183
if let Some(tls_domain) = grafana.tls_domain() {

0 commit comments

Comments
 (0)