1use actix_web::{
2 dev::Server,
3 http::header,
4 web::{self, ServiceConfig},
5 App, HttpRequest, HttpResponse, HttpServer,
6};
7use rust_zero_core::{HealthRegistry, Metrics, Profiler};
8use serde::{Deserialize, Serialize};
9#[cfg(all(feature = "sampling-profiler", unix))]
10use std::time::Duration;
11use std::{
12 io,
13 net::IpAddr,
14 sync::Arc,
15 time::{Instant, SystemTime, UNIX_EPOCH},
16};
17
18#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(default)]
21pub struct DevServerConfig {
22 pub enabled: bool,
23 pub host: String,
24 pub port: u16,
25 pub health_path: String,
26 pub metrics_path: String,
27 pub profile_path: String,
28 pub flamegraph_path: String,
29 pub runtime_path: String,
30 pub tasks_path: String,
31 pub allocator_path: String,
32 pub health_response: String,
33 pub enable_metrics: bool,
34 pub enable_profiling: bool,
35 pub enable_sampling_profiler: bool,
36 pub sampling_seconds: u64,
37 pub sampling_frequency: i32,
38 #[serde(skip_serializing)]
40 pub auth_token: Option<String>,
41 pub private_only: bool,
43}
44
45impl std::fmt::Debug for DevServerConfig {
46 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47 formatter
48 .debug_struct("DevServerConfig")
49 .field("enabled", &self.enabled)
50 .field("host", &self.host)
51 .field("port", &self.port)
52 .field("health_path", &self.health_path)
53 .field("metrics_path", &self.metrics_path)
54 .field("profile_path", &self.profile_path)
55 .field("flamegraph_path", &self.flamegraph_path)
56 .field("runtime_path", &self.runtime_path)
57 .field("tasks_path", &self.tasks_path)
58 .field("allocator_path", &self.allocator_path)
59 .field("health_response", &self.health_response)
60 .field("enable_metrics", &self.enable_metrics)
61 .field("enable_profiling", &self.enable_profiling)
62 .field("enable_sampling_profiler", &self.enable_sampling_profiler)
63 .field("sampling_seconds", &self.sampling_seconds)
64 .field("sampling_frequency", &self.sampling_frequency)
65 .field(
66 "auth_token",
67 &self.auth_token.as_ref().map(|_| "[REDACTED]"),
68 )
69 .field("private_only", &self.private_only)
70 .finish()
71 }
72}
73
74impl Default for DevServerConfig {
75 fn default() -> Self {
76 Self {
77 enabled: true,
78 host: String::new(),
79 port: 6060,
80 health_path: "/healthz".to_owned(),
81 metrics_path: "/metrics".to_owned(),
82 profile_path: "/debug/profile".to_owned(),
83 flamegraph_path: "/debug/flamegraph".to_owned(),
84 runtime_path: "/debug/runtime".to_owned(),
85 tasks_path: "/debug/tasks".to_owned(),
86 allocator_path: "/debug/allocator".to_owned(),
87 health_response: "OK".to_owned(),
88 enable_metrics: true,
89 enable_profiling: true,
90 enable_sampling_profiler: false,
91 sampling_seconds: 10,
92 sampling_frequency: 99,
93 auth_token: None,
94 private_only: false,
95 }
96 }
97}
98
99impl DevServerConfig {
100 pub fn address(&self) -> String {
101 format!("{}:{}", self.host, self.port)
102 }
103
104 pub fn validate(&self) -> io::Result<()> {
105 if self.sampling_seconds == 0 || self.sampling_seconds > 300 {
106 return Err(io::Error::new(
107 io::ErrorKind::InvalidInput,
108 "sampling_seconds must be between 1 and 300",
109 ));
110 }
111 if !(1..=1_000).contains(&self.sampling_frequency) {
112 return Err(io::Error::new(
113 io::ErrorKind::InvalidInput,
114 "sampling_frequency must be between 1 and 1000",
115 ));
116 }
117 if self.auth_token.as_ref().is_some_and(String::is_empty) {
118 return Err(io::Error::new(
119 io::ErrorKind::InvalidInput,
120 "auth_token cannot be empty",
121 ));
122 }
123 if self.private_only {
124 let host = self.host.parse::<IpAddr>().map_err(|_| {
125 io::Error::new(
126 io::ErrorKind::InvalidInput,
127 "private_only requires a literal private IP address",
128 )
129 })?;
130 if !is_private_address(host) {
131 return Err(io::Error::new(
132 io::ErrorKind::InvalidInput,
133 "private_only rejects public or unspecified listener addresses",
134 ));
135 }
136 }
137 Ok(())
138 }
139}
140
141fn is_private_address(address: IpAddr) -> bool {
142 match address {
143 IpAddr::V4(address) => {
144 address.is_loopback() || address.is_private() || address.is_link_local()
145 }
146 IpAddr::V6(address) => {
147 address.is_loopback()
148 || address.is_unique_local()
149 || (address.segments()[0] & 0xffc0) == 0xfe80
150 }
151 }
152}
153
154#[derive(Clone)]
156pub struct DevServer {
157 config: DevServerConfig,
158 metrics: Arc<Metrics>,
159 profiler: Arc<Profiler>,
160 health: HealthRegistry,
161 started_at: Instant,
162}
163
164impl DevServer {
165 pub fn new(config: DevServerConfig, metrics: Arc<Metrics>, profiler: Arc<Profiler>) -> Self {
166 if config.enable_profiling {
167 profiler.enable();
168 }
169 Self {
170 config,
171 metrics,
172 profiler,
173 health: HealthRegistry::new(),
174 started_at: Instant::now(),
175 }
176 }
177
178 pub fn with_health_registry(mut self, health: HealthRegistry) -> Self {
179 self.health = health;
180 self
181 }
182
183 pub fn health_registry(&self) -> HealthRegistry {
184 self.health.clone()
185 }
186
187 pub fn config(&self) -> &DevServerConfig {
188 &self.config
189 }
190
191 pub fn routes(&self) -> Vec<String> {
192 let mut routes = vec!["/".to_owned(), self.config.health_path.clone()];
193 if self.config.enable_metrics {
194 routes.push(self.config.metrics_path.clone());
195 }
196 if self.config.enable_profiling {
197 routes.push(self.config.profile_path.clone());
198 }
199 routes.push(self.config.runtime_path.clone());
200 routes.push(self.config.tasks_path.clone());
201 routes.push(self.config.allocator_path.clone());
202 if self.config.enable_sampling_profiler {
203 routes.push(self.config.flamegraph_path.clone());
204 }
205 routes
206 }
207
208 pub fn configure(&self, services: &mut ServiceConfig) {
210 let routes = self.routes();
211 services
212 .app_data(web::Data::new(self.clone()))
213 .route(
214 "/",
215 web::get().to(move |request: HttpRequest, server: web::Data<Self>| {
216 let routes = routes.clone();
217 async move {
218 if let Err(response) = server.authorize(&request) {
219 return response;
220 }
221 HttpResponse::Ok().json(routes)
222 }
223 }),
224 )
225 .route(
226 &self.config.health_path,
227 web::get().to(|request: HttpRequest, server: web::Data<Self>| async move {
228 if let Err(response) = server.authorize(&request) {
229 return response;
230 }
231 let health = server.health.snapshot();
232 let mut response = if health.is_ready() {
233 HttpResponse::Ok()
234 } else {
235 HttpResponse::ServiceUnavailable()
236 };
237 let body = if health.is_ready() {
238 server.config.health_response.clone()
239 } else {
240 format!("NOT READY: {}", health.unhealthy().join(","))
241 };
242 response
243 .insert_header((header::CONTENT_TYPE, "text/plain; charset=utf-8"))
244 .body(body)
245 }),
246 )
247 .route(
248 &self.config.runtime_path,
249 web::get().to(|request: HttpRequest, server: web::Data<Self>| async move {
250 if let Err(response) = server.authorize(&request) {
251 return response;
252 }
253 HttpResponse::Ok().json(RuntimeStats::capture(server.started_at))
254 }),
255 )
256 .route(
257 &self.config.tasks_path,
258 web::get().to(|request: HttpRequest, server: web::Data<Self>| async move {
259 if let Err(response) = server.authorize(&request) {
260 return response;
261 }
262 HttpResponse::Ok().json(TaskStats::capture())
263 }),
264 )
265 .route(
266 &self.config.allocator_path,
267 web::get().to(|request: HttpRequest, server: web::Data<Self>| async move {
268 if let Err(response) = server.authorize(&request) {
269 return response;
270 }
271 HttpResponse::Ok().json(AllocatorStats::capture())
272 }),
273 );
274
275 if self.config.enable_metrics {
276 services.route(
277 &self.config.metrics_path,
278 web::get().to(|request: HttpRequest, server: web::Data<Self>| async move {
279 if let Err(response) = server.authorize(&request) {
280 return response;
281 }
282 HttpResponse::Ok()
283 .insert_header((
284 header::CONTENT_TYPE,
285 "text/plain; version=0.0.4; charset=utf-8",
286 ))
287 .body(server.metrics.render())
288 }),
289 );
290 }
291
292 if self.config.enable_profiling {
293 services.route(
294 &self.config.profile_path,
295 web::get().to(|request: HttpRequest, server: web::Data<Self>| async move {
296 if let Err(response) = server.authorize(&request) {
297 return response;
298 }
299 HttpResponse::Ok()
300 .insert_header((header::CONTENT_TYPE, "text/plain; charset=utf-8"))
301 .body(server.profiler.render_report())
302 }),
303 );
304 }
305
306 if self.config.enable_sampling_profiler {
307 services.route(
308 &self.config.flamegraph_path,
309 web::get().to(|request: HttpRequest, server: web::Data<Self>| async move {
310 if let Err(response) = server.authorize(&request) {
311 return response;
312 }
313 server.flamegraph().await
314 }),
315 );
316 }
317 }
318
319 pub fn run(self) -> io::Result<Server> {
321 self.config.validate()?;
322 let address = self.config.address();
323 HttpServer::new(move || {
324 let server = self.clone();
325 App::new().configure(move |services| server.configure(services))
326 })
327 .bind(address)
328 .map(HttpServer::run)
329 }
330
331 fn authorize(&self, request: &HttpRequest) -> Result<(), HttpResponse> {
332 let Some(expected) = &self.config.auth_token else {
333 return Ok(());
334 };
335 let provided = request
336 .headers()
337 .get(header::AUTHORIZATION)
338 .and_then(|value| value.to_str().ok())
339 .and_then(|value| value.strip_prefix("Bearer "));
340 if provided
341 .is_some_and(|provided| constant_time_eq(provided.as_bytes(), expected.as_bytes()))
342 {
343 Ok(())
344 } else {
345 Err(HttpResponse::Unauthorized()
346 .insert_header((header::WWW_AUTHENTICATE, "Bearer"))
347 .finish())
348 }
349 }
350
351 async fn flamegraph(&self) -> HttpResponse {
352 #[cfg(all(feature = "sampling-profiler", unix))]
353 {
354 let seconds = self.config.sampling_seconds;
355 let frequency = self.config.sampling_frequency;
356 match tokio::task::spawn_blocking(move || render_flamegraph(seconds, frequency)).await {
357 Ok(Ok(svg)) => HttpResponse::Ok()
358 .insert_header((header::CONTENT_TYPE, "image/svg+xml; charset=utf-8"))
359 .body(svg),
360 Ok(Err(error)) => HttpResponse::InternalServerError().body(error),
361 Err(error) => HttpResponse::InternalServerError()
362 .body(format!("sampling profiler task failed: {error}")),
363 }
364 }
365 #[cfg(not(all(feature = "sampling-profiler", unix)))]
366 HttpResponse::NotImplemented()
367 .body("sampling profiling requires the rest/sampling-profiler feature on a Unix target")
368 }
369}
370
371fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
372 let mut difference = left.len() ^ right.len();
373 for index in 0..left.len().max(right.len()) {
374 difference |= usize::from(
375 left.get(index).copied().unwrap_or_default()
376 ^ right.get(index).copied().unwrap_or_default(),
377 );
378 }
379 difference == 0
380}
381
382#[cfg(all(feature = "sampling-profiler", unix))]
383fn render_flamegraph(seconds: u64, frequency: i32) -> Result<Vec<u8>, String> {
384 let guard = pprof::ProfilerGuardBuilder::default()
385 .frequency(frequency)
386 .blocklist(&["libc", "libgcc", "pthread", "vdso"])
387 .build()
388 .map_err(|error| error.to_string())?;
389 std::thread::sleep(Duration::from_secs(seconds));
390 let report = guard.report().build().map_err(|error| error.to_string())?;
391 let mut svg = Vec::new();
392 report
393 .flamegraph(&mut svg)
394 .map_err(|error| error.to_string())?;
395 Ok(svg)
396}
397
398#[derive(Debug, Serialize)]
399struct RuntimeStats {
400 process_id: u32,
401 available_parallelism: usize,
402 uptime_seconds: f64,
403 unix_time_seconds: u64,
404}
405
406#[derive(Debug, Serialize)]
407struct TaskStats {
408 runtime_available: bool,
409 worker_threads: usize,
410 alive_tasks: usize,
411 global_queue_depth: usize,
412}
413
414impl TaskStats {
415 fn capture() -> Self {
416 match tokio::runtime::Handle::try_current() {
417 Ok(handle) => {
418 let metrics = handle.metrics();
419 Self {
420 runtime_available: true,
421 worker_threads: metrics.num_workers(),
422 alive_tasks: metrics.num_alive_tasks(),
423 global_queue_depth: metrics.global_queue_depth(),
424 }
425 }
426 Err(_) => Self {
427 runtime_available: false,
428 worker_threads: 0,
429 alive_tasks: 0,
430 global_queue_depth: 0,
431 },
432 }
433 }
434}
435
436#[derive(Debug, Serialize)]
437struct AllocatorStats {
438 allocator: &'static str,
439 resident_set_high_water_bytes: Option<u64>,
440}
441
442impl AllocatorStats {
443 fn capture() -> Self {
444 Self {
445 allocator: "system",
446 resident_set_high_water_bytes: resident_set_high_water_bytes(),
447 }
448 }
449}
450
451#[cfg(unix)]
452fn resident_set_high_water_bytes() -> Option<u64> {
453 let mut usage = std::mem::MaybeUninit::<libc::rusage>::zeroed();
454 if unsafe { libc::getrusage(libc::RUSAGE_SELF, usage.as_mut_ptr()) } != 0 {
456 return None;
457 }
458 let bytes = unsafe { usage.assume_init() }.ru_maxrss as u64;
460 #[cfg(target_os = "macos")]
461 return Some(bytes);
462 #[cfg(not(target_os = "macos"))]
463 Some(bytes.saturating_mul(1024))
464}
465
466#[cfg(not(unix))]
467fn resident_set_high_water_bytes() -> Option<u64> {
468 None
469}
470
471impl RuntimeStats {
472 fn capture(started_at: Instant) -> Self {
473 Self {
474 process_id: std::process::id(),
475 available_parallelism: std::thread::available_parallelism()
476 .map(usize::from)
477 .unwrap_or(1),
478 uptime_seconds: started_at.elapsed().as_secs_f64(),
479 unix_time_seconds: SystemTime::now()
480 .duration_since(UNIX_EPOCH)
481 .unwrap_or_default()
482 .as_secs(),
483 }
484 }
485}
486
487#[cfg(test)]
488mod tests {
489 use super::*;
490 use actix_web::{body::to_bytes, http::StatusCode, test};
491 use rust_zero_core::VectorOptions;
492 use std::time::Duration;
493
494 fn server(config: DevServerConfig) -> DevServer {
495 let metrics = Arc::new(Metrics::new());
496 metrics
497 .counter_vec(VectorOptions::new("requests_total", "requests"))
498 .unwrap()
499 .inc(&[])
500 .unwrap();
501 let profiler = Arc::new(Profiler::new());
502 let server = DevServer::new(config, metrics, profiler.clone());
503 profiler.record("database", Duration::from_millis(2));
504 server
505 }
506
507 #[actix_rt::test]
508 async fn configuration_deserialization_preserves_production_defaults() {
509 let config: DevServerConfig =
510 serde_json::from_str(r#"{"port":7070,"enable_profiling":false}"#).unwrap();
511
512 assert_eq!(config.port, 7070);
513 assert_eq!(config.health_path, "/healthz");
514 assert!(config.enable_metrics);
515 assert!(!config.enable_profiling);
516 }
517
518 #[actix_rt::test]
519 async fn serves_health_metrics_profile_and_runtime_diagnostics() {
520 let server = server(DevServerConfig::default());
521 let app =
522 test::init_service(App::new().configure(move |config| server.configure(config))).await;
523
524 for path in [
525 "/healthz",
526 "/metrics",
527 "/debug/profile",
528 "/debug/runtime",
529 "/debug/tasks",
530 "/debug/allocator",
531 ] {
532 let response =
533 test::call_service(&app, test::TestRequest::get().uri(path).to_request()).await;
534 assert_eq!(response.status(), StatusCode::OK, "{path}");
535 }
536
537 let response =
538 test::call_service(&app, test::TestRequest::get().uri("/metrics").to_request()).await;
539 let body = to_bytes(response.into_body()).await.unwrap();
540 assert!(std::str::from_utf8(&body)
541 .unwrap()
542 .contains("requests_total 1"));
543 }
544
545 #[actix_rt::test]
546 async fn disabled_optional_routes_are_not_registered() {
547 let server = server(DevServerConfig {
548 enable_metrics: false,
549 enable_profiling: false,
550 ..DevServerConfig::default()
551 });
552 let routes = server.routes();
553 let app =
554 test::init_service(App::new().configure(move |config| server.configure(config))).await;
555
556 assert!(!routes.contains(&"/metrics".to_owned()));
557 for path in ["/metrics", "/debug/profile"] {
558 let response =
559 test::call_service(&app, test::TestRequest::get().uri(path).to_request()).await;
560 assert_eq!(response.status(), StatusCode::NOT_FOUND, "{path}");
561 }
562 }
563
564 #[actix_rt::test]
565 async fn aggregates_dependency_health_without_handler_polling() {
566 let health = HealthRegistry::new();
567 health.set("users-rpc", false);
568 let server = server(DevServerConfig::default()).with_health_registry(health.clone());
569 let app =
570 test::init_service(App::new().configure(move |config| server.configure(config))).await;
571
572 let response =
573 test::call_service(&app, test::TestRequest::get().uri("/healthz").to_request()).await;
574 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
575 let body = to_bytes(response.into_body()).await.unwrap();
576 assert_eq!(&body[..], b"NOT READY: users-rpc");
577
578 health.set("users-rpc", true);
579 let response =
580 test::call_service(&app, test::TestRequest::get().uri("/healthz").to_request()).await;
581 assert_eq!(response.status(), StatusCode::OK);
582 }
583
584 #[actix_rt::test]
585 async fn bearer_authentication_protects_every_diagnostic_route() {
586 let server = server(DevServerConfig {
587 auth_token: Some("diagnostics-secret".to_owned()),
588 ..DevServerConfig::default()
589 });
590 let app =
591 test::init_service(App::new().configure(move |config| server.configure(config))).await;
592
593 for path in ["/", "/healthz", "/metrics", "/debug/tasks"] {
594 let response =
595 test::call_service(&app, test::TestRequest::get().uri(path).to_request()).await;
596 assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "{path}");
597
598 let response = test::call_service(
599 &app,
600 test::TestRequest::get()
601 .uri(path)
602 .insert_header((header::AUTHORIZATION, "Bearer diagnostics-secret"))
603 .to_request(),
604 )
605 .await;
606 assert_eq!(response.status(), StatusCode::OK, "{path}");
607 }
608 }
609
610 #[actix_rt::test]
611 async fn validates_private_binding_and_sampling_bounds() {
612 let private = DevServerConfig {
613 host: "10.2.3.4".to_owned(),
614 private_only: true,
615 ..DevServerConfig::default()
616 };
617 assert!(private.validate().is_ok());
618
619 let public = DevServerConfig {
620 host: "8.8.8.8".to_owned(),
621 private_only: true,
622 ..DevServerConfig::default()
623 };
624 assert!(public.validate().is_err());
625
626 let wildcard = DevServerConfig {
627 host: String::new(),
628 private_only: true,
629 ..DevServerConfig::default()
630 };
631 assert!(wildcard.validate().is_err());
632
633 let invalid_sampling = DevServerConfig {
634 sampling_seconds: 0,
635 ..DevServerConfig::default()
636 };
637 assert!(invalid_sampling.validate().is_err());
638 }
639
640 #[actix_rt::test]
641 async fn exposes_bounded_task_and_allocator_diagnostics() {
642 let server = server(DevServerConfig::default());
643 let app =
644 test::init_service(App::new().configure(move |config| server.configure(config))).await;
645
646 let tasks: serde_json::Value = test::call_and_read_body_json(
647 &app,
648 test::TestRequest::get().uri("/debug/tasks").to_request(),
649 )
650 .await;
651 assert_eq!(tasks["runtime_available"], true);
652 assert!(tasks["worker_threads"].as_u64().unwrap() >= 1);
653
654 let allocator: serde_json::Value = test::call_and_read_body_json(
655 &app,
656 test::TestRequest::get()
657 .uri("/debug/allocator")
658 .to_request(),
659 )
660 .await;
661 assert_eq!(allocator["allocator"], "system");
662 assert!(allocator["resident_set_high_water_bytes"].is_number());
663 }
664
665 #[cfg(all(feature = "sampling-profiler", unix))]
666 #[actix_rt::test]
667 async fn sampling_profiler_produces_an_svg_flamegraph() {
668 let load = tokio::task::spawn_blocking(|| {
669 let started = Instant::now();
670 while started.elapsed() < Duration::from_secs(1) {
671 std::hint::black_box(started.elapsed());
672 }
673 });
674 let profile = tokio::task::spawn_blocking(|| render_flamegraph(1, 99));
675 let (load, svg) = tokio::join!(load, profile);
676 load.unwrap();
677 let svg = svg.unwrap().unwrap();
678 let svg = std::str::from_utf8(&svg).unwrap();
679 assert!(
680 svg.contains("<svg"),
681 "unexpected flamegraph output: {svg:?}"
682 );
683 assert!(svg.contains("</svg>"));
684 }
685}