1use serde::{Deserialize, Serialize};
23use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
24use std::time::Duration;
25
26pub const HEALTH_PATH: &str = "/health";
28
29pub const HEALTH_TIMEOUT: Duration = Duration::from_secs(30);
31
32pub const LOCALHOST: Ipv4Addr = Ipv4Addr::new(127, 0, 0, 1);
34
35#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
45pub struct ServiceConfig {
46 pub name: String,
48 pub port: u16,
50 pub enabled: bool,
52}
53
54impl ServiceConfig {
55 pub fn new(name: impl Into<String>, port: u16) -> Self {
59 Self {
60 name: name.into(),
61 port,
62 enabled: true,
63 }
64 }
65
66 pub fn socket_addr(&self) -> SocketAddr {
70 SocketAddr::new(IpAddr::V4(LOCALHOST), self.port)
71 }
72
73 pub fn health_url(&self) -> String {
77 format!("http://127.0.0.1:{}{}", self.port, HEALTH_PATH)
78 }
79
80 pub fn is_local_only(&self) -> bool {
85 true
86 }
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
95pub struct HealthCheckResult {
96 pub service: String,
98 pub url: String,
100 pub healthy: bool,
102 pub response_ms: Option<u64>,
104 pub error: Option<String>,
106}
107
108impl HealthCheckResult {
109 pub fn ok(service: impl Into<String>, url: impl Into<String>, response_ms: u64) -> Self {
111 Self {
112 service: service.into(),
113 url: url.into(),
114 healthy: true,
115 response_ms: Some(response_ms),
116 error: None,
117 }
118 }
119
120 pub fn failed(
122 service: impl Into<String>,
123 url: impl Into<String>,
124 error: impl Into<String>,
125 ) -> Self {
126 Self {
127 service: service.into(),
128 url: url.into(),
129 healthy: false,
130 response_ms: None,
131 error: Some(error.into()),
132 }
133 }
134}
135
136#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
142pub struct ServiceRegistry {
143 services: Vec<ServiceConfig>,
144}
145
146impl ServiceRegistry {
147 pub fn new() -> Self {
149 Self::default()
150 }
151
152 pub fn register(&mut self, config: ServiceConfig) -> Result<(), ServiceError> {
156 if self.services.iter().any(|s| s.name == config.name) {
158 return Err(ServiceError::DuplicateName {
159 name: config.name.clone(),
160 });
161 }
162
163 if self.services.iter().any(|s| s.port == config.port) {
165 return Err(ServiceError::DuplicatePort { port: config.port });
166 }
167
168 self.services.push(config);
169 Ok(())
170 }
171
172 pub fn get(&self, name: &str) -> Option<&ServiceConfig> {
174 self.services.iter().find(|s| s.name == name)
175 }
176
177 pub fn all(&self) -> &[ServiceConfig] {
179 &self.services
180 }
181
182 pub fn enabled(&self) -> Vec<&ServiceConfig> {
184 self.services.iter().filter(|s| s.enabled).collect()
185 }
186
187 pub fn health_urls(&self) -> Vec<String> {
189 self.enabled().iter().map(|s| s.health_url()).collect()
190 }
191
192 pub fn all_local_only(&self) -> bool {
197 self.services.iter().all(|s| s.is_local_only())
198 }
199}
200
201pub fn is_localhost_addr(addr: SocketAddr) -> bool {
210 match addr.ip() {
211 IpAddr::V4(ip) => ip == LOCALHOST,
212 IpAddr::V6(ip) => ip == Ipv6Addr::LOCALHOST,
213 }
214}
215
216pub fn validate_bind_address(addr_str: &str) -> Result<SocketAddr, ServiceError> {
227 if addr_str == "0.0.0.0" || addr_str == "::" || addr_str == "[::]" {
229 return Err(ServiceError::ForbiddenBindAddress {
230 addr: addr_str.to_string(),
231 });
232 }
233
234 if let Ok(addr) = addr_str.parse::<SocketAddr>() {
236 if is_localhost_addr(addr) {
237 return Ok(addr);
238 }
239 return Err(ServiceError::ForbiddenBindAddress {
240 addr: addr_str.to_string(),
241 });
242 }
243
244 if let Ok(ip) = addr_str.parse::<IpAddr>() {
246 let addr = SocketAddr::new(ip, 0);
247 if is_localhost_addr(addr) {
248 return Ok(addr);
249 }
250 return Err(ServiceError::ForbiddenBindAddress {
251 addr: addr_str.to_string(),
252 });
253 }
254
255 if addr_str == "localhost" {
257 return Ok(SocketAddr::new(IpAddr::V4(LOCALHOST), 0));
258 }
259
260 if let Some(rest) = addr_str.strip_prefix("localhost:") {
262 if let Ok(port) = rest.parse::<u16>() {
263 return Ok(SocketAddr::new(IpAddr::V4(LOCALHOST), port));
264 }
265 }
266
267 Err(ServiceError::InvalidBindAddress {
268 addr: addr_str.to_string(),
269 })
270}
271
272#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
278pub enum ServiceError {
279 DuplicateName { name: String },
281 DuplicatePort { port: u16 },
283 ForbiddenBindAddress { addr: String },
285 InvalidBindAddress { addr: String },
287 HealthCheckTimeout { service: String, timeout_secs: u64 },
289}
290
291impl std::fmt::Display for ServiceError {
292 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
293 match self {
294 ServiceError::DuplicateName { name } => {
295 write!(f, "duplicate service name: {}", name)
296 }
297 ServiceError::DuplicatePort { port } => {
298 write!(f, "duplicate service port: {}", port)
299 }
300 ServiceError::ForbiddenBindAddress { addr } => {
301 write!(
302 f,
303 "bind address '{}' is forbidden — services must bind to 127.0.0.1 only",
304 addr
305 )
306 }
307 ServiceError::InvalidBindAddress { addr } => {
308 write!(f, "invalid bind address: '{}'", addr)
309 }
310 ServiceError::HealthCheckTimeout {
311 service,
312 timeout_secs,
313 } => {
314 write!(
315 f,
316 "health check for '{}' timed out after {}s",
317 service, timeout_secs
318 )
319 }
320 }
321 }
322}
323
324impl std::error::Error for ServiceError {}
325
326#[cfg(test)]
331mod tests {
332 use super::*;
333
334 #[test]
339 fn test_service_config_binds_to_localhost() {
340 let config = ServiceConfig::new("test-service", 8080);
341 let addr = config.socket_addr();
342 assert_eq!(addr.ip(), IpAddr::V4(LOCALHOST));
343 assert_eq!(addr.port(), 8080);
344 }
345
346 #[test]
347 fn test_service_config_is_local_only() {
348 let config = ServiceConfig::new("test-service", 8080);
349 assert!(config.is_local_only());
350 }
351
352 #[test]
353 fn test_service_config_never_binds_zero() {
354 let config = ServiceConfig::new("test-service", 8080);
355 let addr = config.socket_addr();
356 assert_ne!(addr.ip(), IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)));
358 assert_eq!(addr.ip(), IpAddr::V4(LOCALHOST));
360 }
361
362 #[test]
363 fn test_validate_bind_address_localhost() {
364 assert!(validate_bind_address("127.0.0.1").is_ok());
365 assert!(validate_bind_address("localhost").is_ok());
366 }
367
368 #[test]
369 fn test_validate_bind_address_rejects_wildcard() {
370 assert!(validate_bind_address("0.0.0.0").is_err());
371 assert!(validate_bind_address("::").is_err());
372 assert!(validate_bind_address("[::]").is_err());
373 }
374
375 #[test]
376 fn test_validate_bind_address_rejects_external() {
377 assert!(validate_bind_address("192.168.1.1").is_err());
378 assert!(validate_bind_address("10.0.0.1").is_err());
379 }
380
381 #[test]
382 fn test_validate_bind_address_with_port() {
383 let addr = validate_bind_address("127.0.0.1:8080").unwrap();
384 assert_eq!(addr.port(), 8080);
385 }
386
387 #[test]
388 fn test_validate_bind_address_localhost_with_port() {
389 let addr = validate_bind_address("localhost:9090").unwrap();
390 assert_eq!(addr.ip(), IpAddr::V4(LOCALHOST));
391 assert_eq!(addr.port(), 9090);
392 }
393
394 #[test]
399 fn test_health_url_format() {
400 let config = ServiceConfig::new("api", 8080);
401 assert_eq!(config.health_url(), "http://127.0.0.1:8080/health");
402 }
403
404 #[test]
405 fn test_health_check_result_ok() {
406 let result = HealthCheckResult::ok("api", "http://127.0.0.1:8080/health", 42);
407 assert!(result.healthy);
408 assert_eq!(result.response_ms, Some(42));
409 assert!(result.error.is_none());
410 }
411
412 #[test]
413 fn test_health_check_result_failed() {
414 let result = HealthCheckResult::failed("api", "http://127.0.0.1:8080/health", "timeout");
415 assert!(!result.healthy);
416 assert_eq!(result.error, Some("timeout".to_string()));
417 }
418
419 #[test]
420 fn test_health_check_result_serde_roundtrip() {
421 let result = HealthCheckResult::ok("api", "http://127.0.0.1:8080/health", 42);
422 let json = serde_json::to_string(&result).unwrap();
423 let back: HealthCheckResult = serde_json::from_str(&json).unwrap();
424 assert_eq!(result, back);
425 }
426
427 #[test]
428 fn test_health_timeout_constant() {
429 assert_eq!(HEALTH_TIMEOUT, Duration::from_secs(30));
430 }
431
432 #[test]
433 fn test_health_path_constant() {
434 assert_eq!(HEALTH_PATH, "/health");
435 }
436
437 #[test]
442 fn test_registry_register_and_get() {
443 let mut registry = ServiceRegistry::new();
444 let config = ServiceConfig::new("api", 8080);
445 registry.register(config).unwrap();
446
447 let found = registry.get("api").unwrap();
448 assert_eq!(found.port, 8080);
449 }
450
451 #[test]
452 fn test_registry_rejects_duplicate_name() {
453 let mut registry = ServiceRegistry::new();
454 registry.register(ServiceConfig::new("api", 8080)).unwrap();
455 let result = registry.register(ServiceConfig::new("api", 9090));
456 assert!(matches!(result, Err(ServiceError::DuplicateName { .. })));
457 }
458
459 #[test]
460 fn test_registry_rejects_duplicate_port() {
461 let mut registry = ServiceRegistry::new();
462 registry.register(ServiceConfig::new("api", 8080)).unwrap();
463 let result = registry.register(ServiceConfig::new("web", 8080));
464 assert!(matches!(result, Err(ServiceError::DuplicatePort { .. })));
465 }
466
467 #[test]
468 fn test_registry_all_local_only() {
469 let mut registry = ServiceRegistry::new();
470 registry.register(ServiceConfig::new("api", 8080)).unwrap();
471 registry.register(ServiceConfig::new("web", 9090)).unwrap();
472 assert!(registry.all_local_only());
473 }
474
475 #[test]
476 fn test_registry_health_urls() {
477 let mut registry = ServiceRegistry::new();
478 registry.register(ServiceConfig::new("api", 8080)).unwrap();
479 registry.register(ServiceConfig::new("web", 9090)).unwrap();
480
481 let urls = registry.health_urls();
482 assert_eq!(urls.len(), 2);
483 assert!(urls.contains(&"http://127.0.0.1:8080/health".to_string()));
484 assert!(urls.contains(&"http://127.0.0.1:9090/health".to_string()));
485 }
486
487 #[test]
488 fn test_registry_enabled_filters_disabled() {
489 let mut registry = ServiceRegistry::new();
490 let mut disabled = ServiceConfig::new("disabled", 8080);
491 disabled.enabled = false;
492 registry.register(disabled).unwrap();
493 registry
494 .register(ServiceConfig::new("enabled", 9090))
495 .unwrap();
496
497 let enabled = registry.enabled();
498 assert_eq!(enabled.len(), 1);
499 assert_eq!(enabled[0].name, "enabled");
500 }
501
502 #[test]
507 fn test_is_localhost_addr_ipv4() {
508 let addr = SocketAddr::new(IpAddr::V4(LOCALHOST), 8080);
509 assert!(is_localhost_addr(addr));
510 }
511
512 #[test]
513 fn test_is_localhost_addr_ipv6() {
514 let addr = SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 8080);
515 assert!(is_localhost_addr(addr));
516 }
517
518 #[test]
519 fn test_is_not_localhost_addr() {
520 let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)), 8080);
521 assert!(!is_localhost_addr(addr));
522 }
523
524 #[test]
525 fn test_is_not_localhost_zero() {
526 let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), 8080);
527 assert!(!is_localhost_addr(addr));
528 }
529
530 #[test]
535 fn test_service_config_serde_roundtrip() {
536 let config = ServiceConfig::new("test-service", 8080);
537 let json = serde_json::to_string(&config).unwrap();
538 let back: ServiceConfig = serde_json::from_str(&json).unwrap();
539 assert_eq!(config, back);
540 }
541
542 #[test]
543 fn test_service_error_serde_roundtrip() {
544 let err = ServiceError::ForbiddenBindAddress {
545 addr: "0.0.0.0".to_string(),
546 };
547 let json = serde_json::to_string(&err).unwrap();
548 let back: ServiceError = serde_json::from_str(&json).unwrap();
549 assert_eq!(err, back);
550 }
551
552 #[test]
557 fn test_error_display_forbidden() {
558 let err = ServiceError::ForbiddenBindAddress {
559 addr: "0.0.0.0".to_string(),
560 };
561 let msg = format!("{}", err);
562 assert!(msg.contains("0.0.0.0"));
563 assert!(msg.contains("127.0.0.1"));
564 }
565
566 #[test]
567 fn test_error_display_timeout() {
568 let err = ServiceError::HealthCheckTimeout {
569 service: "api".to_string(),
570 timeout_secs: 30,
571 };
572 let msg = format!("{}", err);
573 assert!(msg.contains("api"));
574 assert!(msg.contains("30"));
575 }
576}