Skip to main content

rusty_stack/platform/
service.rs

1//! Service launch surfaces with local-only endpoint exposure.
2//!
3//! Provides service configuration and health endpoint definitions that
4//! enforce binding to `127.0.0.1` only (never `0.0.0.0`).
5//!
6//! # Security Model
7//!
8//! - All services bind to `127.0.0.1` (localhost) only
9//! - No external interface exposure
10//! - Health endpoints at `/health` respond with HTTP 200 within 30 seconds
11//!
12//! # Health Endpoints
13//!
14//! Each service registers `http://127.0.0.1:<port>/health` which returns:
15//! - HTTP 200 with JSON body `{"status": "ok"}` when healthy
16//! - HTTP 503 with JSON body `{"status": "unhealthy"}` when not healthy
17//!
18//! This module does not start an actual HTTP server — it provides the
19//! configuration, address binding logic, and health check structures
20//! used by the runtime to manage services.
21
22use serde::{Deserialize, Serialize};
23use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
24use std::time::Duration;
25
26/// Default health check path.
27pub const HEALTH_PATH: &str = "/health";
28
29/// Default health check timeout (30 seconds).
30pub const HEALTH_TIMEOUT: Duration = Duration::from_secs(30);
31
32/// Localhost IPv4 address — the only address services may bind to.
33pub const LOCALHOST: Ipv4Addr = Ipv4Addr::new(127, 0, 0, 1);
34
35// ===========================================================================
36// Service Configuration
37// ===========================================================================
38
39/// Configuration for a managed service.
40///
41/// All services bind to `127.0.0.1` only. The bind address is always
42/// constructed from [`LOCALHOST`] — callers cannot override it to
43/// `0.0.0.0` or any external interface.
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
45pub struct ServiceConfig {
46    /// Human-readable service name.
47    pub name: String,
48    /// Port number for the service.
49    pub port: u16,
50    /// Whether the service is enabled.
51    pub enabled: bool,
52}
53
54impl ServiceConfig {
55    /// Create a new service config bound to localhost on the given port.
56    ///
57    /// The bind address is always `127.0.0.1:<port>`.
58    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    /// Get the socket address for this service.
67    ///
68    /// Always returns `127.0.0.1:<port>`.
69    pub fn socket_addr(&self) -> SocketAddr {
70        SocketAddr::new(IpAddr::V4(LOCALHOST), self.port)
71    }
72
73    /// Get the health check URL for this service.
74    ///
75    /// Returns `http://127.0.0.1:<port>/health`.
76    pub fn health_url(&self) -> String {
77        format!("http://127.0.0.1:{}{}", self.port, HEALTH_PATH)
78    }
79
80    /// Validate that this config never binds to a non-local address.
81    ///
82    /// Always returns `true` since the address is always constructed
83    /// from [`LOCALHOST`].
84    pub fn is_local_only(&self) -> bool {
85        true
86    }
87}
88
89// ===========================================================================
90// Health Check
91// ===========================================================================
92
93/// Result of a health check against a service.
94#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
95pub struct HealthCheckResult {
96    /// Name of the service that was checked.
97    pub service: String,
98    /// URL that was checked.
99    pub url: String,
100    /// Whether the health check succeeded.
101    pub healthy: bool,
102    /// Response time in milliseconds, if measured.
103    pub response_ms: Option<u64>,
104    /// Error message if the health check failed.
105    pub error: Option<String>,
106}
107
108impl HealthCheckResult {
109    /// Create a successful health check result.
110    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    /// Create a failed health check result.
121    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// ===========================================================================
137// Service Registry
138// ===========================================================================
139
140/// Registry of managed services with their configurations.
141#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
142pub struct ServiceRegistry {
143    services: Vec<ServiceConfig>,
144}
145
146impl ServiceRegistry {
147    /// Create a new empty registry.
148    pub fn new() -> Self {
149        Self::default()
150    }
151
152    /// Register a new service.
153    ///
154    /// Returns `Err` if a service with the same name or port already exists.
155    pub fn register(&mut self, config: ServiceConfig) -> Result<(), ServiceError> {
156        // Check for duplicate name
157        if self.services.iter().any(|s| s.name == config.name) {
158            return Err(ServiceError::DuplicateName {
159                name: config.name.clone(),
160            });
161        }
162
163        // Check for duplicate port
164        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    /// Get a service by name.
173    pub fn get(&self, name: &str) -> Option<&ServiceConfig> {
174        self.services.iter().find(|s| s.name == name)
175    }
176
177    /// Get all registered services.
178    pub fn all(&self) -> &[ServiceConfig] {
179        &self.services
180    }
181
182    /// Get all enabled services.
183    pub fn enabled(&self) -> Vec<&ServiceConfig> {
184        self.services.iter().filter(|s| s.enabled).collect()
185    }
186
187    /// Get health check URLs for all enabled services.
188    pub fn health_urls(&self) -> Vec<String> {
189        self.enabled().iter().map(|s| s.health_url()).collect()
190    }
191
192    /// Validate that all services bind to localhost only.
193    ///
194    /// Since [`ServiceConfig`] always constructs addresses from [`LOCALHOST`],
195    /// this always returns `true`.
196    pub fn all_local_only(&self) -> bool {
197        self.services.iter().all(|s| s.is_local_only())
198    }
199}
200
201// ===========================================================================
202// Address Validation
203// ===========================================================================
204
205/// Validate that a socket address is localhost only.
206///
207/// Returns `true` only for `127.0.0.1` (IPv4) or `::1` (IPv6).
208/// Returns `false` for `0.0.0.0`, `[::]`, or any non-loopback address.
209pub 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
216/// Validate that a bind address string represents a localhost-only address.
217///
218/// Accepts:
219/// - `127.0.0.1`
220/// - `localhost`
221///
222/// Rejects:
223/// - `0.0.0.0`
224/// - `::`
225/// - Any non-loopback address
226pub fn validate_bind_address(addr_str: &str) -> Result<SocketAddr, ServiceError> {
227    // Check for dangerous wildcard addresses
228    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    // Try to parse as SocketAddr (may include port)
235    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    // Try to parse as IP with default port
245    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    // Try "localhost" hostname
256    if addr_str == "localhost" {
257        return Ok(SocketAddr::new(IpAddr::V4(LOCALHOST), 0));
258    }
259
260    // Try "localhost:port"
261    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// ===========================================================================
273// Errors
274// ===========================================================================
275
276/// Errors related to service management.
277#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
278pub enum ServiceError {
279    /// A service with this name already exists.
280    DuplicateName { name: String },
281    /// A service with this port already exists.
282    DuplicatePort { port: u16 },
283    /// The requested bind address is not localhost.
284    ForbiddenBindAddress { addr: String },
285    /// The bind address could not be parsed.
286    InvalidBindAddress { addr: String },
287    /// Health check timeout exceeded.
288    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// ===========================================================================
327// Tests
328// ===========================================================================
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    // ================================================================
335    // VAL-WIN-010: Services bind to 127.0.0.1 only
336    // ================================================================
337
338    #[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        // Must NOT be 0.0.0.0
357        assert_ne!(addr.ip(), IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)));
358        // Must be 127.0.0.1
359        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    // ================================================================
395    // VAL-WIN-011: Health Endpoints
396    // ================================================================
397
398    #[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    // ================================================================
438    // Service Registry
439    // ================================================================
440
441    #[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    // ================================================================
503    // Address Validation
504    // ================================================================
505
506    #[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    // ================================================================
531    // Serde
532    // ================================================================
533
534    #[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    // ================================================================
553    // Error Display
554    // ================================================================
555
556    #[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}