sentinel_proxy/
builtin_handlers.rs

1//! Built-in handlers for Sentinel proxy
2//!
3//! These handlers provide default responses for common endpoints like
4//! status pages, health checks, and metrics. They are used when routes
5//! are configured with `service-type: builtin`.
6
7use bytes::Bytes;
8use http::{Response, StatusCode};
9use http_body_util::Full;
10use serde::Serialize;
11use std::collections::HashMap;
12use std::sync::Arc;
13use std::time::{Duration, Instant};
14
15use sentinel_config::{BuiltinHandler, Config};
16
17/// Application state for builtin handlers
18pub struct BuiltinHandlerState {
19    /// Application start time
20    start_time: Instant,
21    /// Application version
22    version: String,
23    /// Instance ID
24    instance_id: String,
25}
26
27impl BuiltinHandlerState {
28    /// Create new handler state
29    pub fn new(version: String, instance_id: String) -> Self {
30        Self {
31            start_time: Instant::now(),
32            version,
33            instance_id,
34        }
35    }
36
37    /// Get uptime as a Duration
38    pub fn uptime(&self) -> Duration {
39        self.start_time.elapsed()
40    }
41
42    /// Format uptime as human-readable string
43    pub fn uptime_string(&self) -> String {
44        let uptime = self.uptime();
45        let secs = uptime.as_secs();
46        let days = secs / 86400;
47        let hours = (secs % 86400) / 3600;
48        let mins = (secs % 3600) / 60;
49        let secs = secs % 60;
50
51        if days > 0 {
52            format!("{}d {}h {}m {}s", days, hours, mins, secs)
53        } else if hours > 0 {
54            format!("{}h {}m {}s", hours, mins, secs)
55        } else if mins > 0 {
56            format!("{}m {}s", mins, secs)
57        } else {
58            format!("{}s", secs)
59        }
60    }
61}
62
63/// Status response payload
64#[derive(Debug, Serialize)]
65pub struct StatusResponse {
66    /// Service status
67    pub status: &'static str,
68    /// Service version
69    pub version: String,
70    /// Service uptime
71    pub uptime: String,
72    /// Uptime in seconds
73    pub uptime_secs: u64,
74    /// Instance identifier
75    pub instance_id: String,
76    /// Timestamp
77    pub timestamp: String,
78}
79
80/// Health check response
81#[derive(Debug, Serialize)]
82pub struct HealthResponse {
83    /// Health status
84    pub status: &'static str,
85    /// Timestamp
86    pub timestamp: String,
87}
88
89/// Upstream health snapshot for the upstreams handler
90#[derive(Debug, Clone, Default)]
91pub struct UpstreamHealthSnapshot {
92    /// Health status per upstream, keyed by upstream ID
93    pub upstreams: HashMap<String, UpstreamStatus>,
94}
95
96/// Status of a single upstream
97#[derive(Debug, Clone, Serialize)]
98pub struct UpstreamStatus {
99    /// Upstream ID
100    pub id: String,
101    /// Load balancing algorithm
102    pub load_balancing: String,
103    /// Target statuses
104    pub targets: Vec<TargetStatus>,
105}
106
107/// Status of a single target within an upstream
108#[derive(Debug, Clone, Serialize)]
109pub struct TargetStatus {
110    /// Target address
111    pub address: String,
112    /// Weight
113    pub weight: u32,
114    /// Health status
115    pub status: TargetHealthStatus,
116    /// Failure rate (0.0 - 1.0)
117    pub failure_rate: Option<f64>,
118    /// Last error message if unhealthy
119    pub last_error: Option<String>,
120}
121
122/// Health status of a target
123#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
124#[serde(rename_all = "lowercase")]
125pub enum TargetHealthStatus {
126    /// Target is healthy
127    Healthy,
128    /// Target is unhealthy
129    Unhealthy,
130    /// Health status unknown (no checks yet)
131    Unknown,
132}
133
134/// Execute a builtin handler
135pub fn execute_handler(
136    handler: BuiltinHandler,
137    state: &BuiltinHandlerState,
138    request_id: &str,
139    config: Option<Arc<Config>>,
140    upstreams: Option<UpstreamHealthSnapshot>,
141) -> Response<Full<Bytes>> {
142    match handler {
143        BuiltinHandler::Status => status_handler(state, request_id),
144        BuiltinHandler::Health => health_handler(request_id),
145        BuiltinHandler::Metrics => metrics_handler(request_id),
146        BuiltinHandler::NotFound => not_found_handler(request_id),
147        BuiltinHandler::Config => config_handler(config, request_id),
148        BuiltinHandler::Upstreams => upstreams_handler(upstreams, request_id),
149    }
150}
151
152/// JSON status page handler
153fn status_handler(state: &BuiltinHandlerState, request_id: &str) -> Response<Full<Bytes>> {
154    let response = StatusResponse {
155        status: "ok",
156        version: state.version.clone(),
157        uptime: state.uptime_string(),
158        uptime_secs: state.uptime().as_secs(),
159        instance_id: state.instance_id.clone(),
160        timestamp: chrono::Utc::now().to_rfc3339(),
161    };
162
163    let body = serde_json::to_vec_pretty(&response).unwrap_or_else(|_| {
164        b"{\"status\":\"ok\"}".to_vec()
165    });
166
167    Response::builder()
168        .status(StatusCode::OK)
169        .header("Content-Type", "application/json; charset=utf-8")
170        .header("X-Request-Id", request_id)
171        .header("Cache-Control", "no-cache, no-store, must-revalidate")
172        .body(Full::new(Bytes::from(body)))
173        .expect("static response builder with valid headers cannot fail")
174}
175
176/// Health check handler
177fn health_handler(request_id: &str) -> Response<Full<Bytes>> {
178    let response = HealthResponse {
179        status: "healthy",
180        timestamp: chrono::Utc::now().to_rfc3339(),
181    };
182
183    let body = serde_json::to_vec(&response).unwrap_or_else(|_| {
184        b"{\"status\":\"healthy\"}".to_vec()
185    });
186
187    Response::builder()
188        .status(StatusCode::OK)
189        .header("Content-Type", "application/json; charset=utf-8")
190        .header("X-Request-Id", request_id)
191        .header("Cache-Control", "no-cache, no-store, must-revalidate")
192        .body(Full::new(Bytes::from(body)))
193        .expect("static response builder with valid headers cannot fail")
194}
195
196/// Prometheus metrics handler
197fn metrics_handler(request_id: &str) -> Response<Full<Bytes>> {
198    // Get metrics from the global registry
199    // For now, return basic metrics format
200    let metrics = format!(
201        "# HELP sentinel_up Sentinel proxy is up and running\n\
202         # TYPE sentinel_up gauge\n\
203         sentinel_up 1\n\
204         # HELP sentinel_build_info Build information\n\
205         # TYPE sentinel_build_info gauge\n\
206         sentinel_build_info{{version=\"{}\"}} 1\n",
207        env!("CARGO_PKG_VERSION")
208    );
209
210    Response::builder()
211        .status(StatusCode::OK)
212        .header("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
213        .header("X-Request-Id", request_id)
214        .body(Full::new(Bytes::from(metrics)))
215        .expect("static response builder with valid headers cannot fail")
216}
217
218/// 404 Not Found handler
219fn not_found_handler(request_id: &str) -> Response<Full<Bytes>> {
220    let body = serde_json::json!({
221        "error": "Not Found",
222        "status": 404,
223        "message": "The requested resource could not be found.",
224        "request_id": request_id,
225        "timestamp": chrono::Utc::now().to_rfc3339(),
226    });
227
228    let body_bytes = serde_json::to_vec_pretty(&body).unwrap_or_else(|_| {
229        b"{\"error\":\"Not Found\",\"status\":404}".to_vec()
230    });
231
232    Response::builder()
233        .status(StatusCode::NOT_FOUND)
234        .header("Content-Type", "application/json; charset=utf-8")
235        .header("X-Request-Id", request_id)
236        .body(Full::new(Bytes::from(body_bytes)))
237        .expect("static response builder with valid headers cannot fail")
238}
239
240/// Configuration dump handler
241///
242/// Returns the current running configuration as JSON. Sensitive fields like
243/// TLS private keys are redacted for security.
244fn config_handler(config: Option<Arc<Config>>, request_id: &str) -> Response<Full<Bytes>> {
245    let body = match &config {
246        Some(cfg) => {
247            // Build a response with configuration details
248            // The Config struct derives Serialize, so we can serialize directly
249            // Note: sensitive fields should be redacted in production
250            let response = serde_json::json!({
251                "timestamp": chrono::Utc::now().to_rfc3339(),
252                "request_id": request_id,
253                "config": {
254                    "server": &cfg.server,
255                    "listeners": cfg.listeners.iter().map(|l| {
256                        serde_json::json!({
257                            "id": l.id,
258                            "address": l.address,
259                            "protocol": l.protocol,
260                            "default_route": l.default_route,
261                            "request_timeout_secs": l.request_timeout_secs,
262                            "keepalive_timeout_secs": l.keepalive_timeout_secs,
263                            // TLS config is redacted - only show if enabled
264                            "tls_enabled": l.tls.is_some(),
265                        })
266                    }).collect::<Vec<_>>(),
267                    "routes": cfg.routes.iter().map(|r| {
268                        serde_json::json!({
269                            "id": r.id,
270                            "priority": r.priority,
271                            "matches": r.matches,
272                            "upstream": r.upstream,
273                            "service_type": r.service_type,
274                            "builtin_handler": r.builtin_handler,
275                            "filters": r.filters,
276                            "waf_enabled": r.waf_enabled,
277                        })
278                    }).collect::<Vec<_>>(),
279                    "upstreams": cfg.upstreams.iter().map(|(id, u)| {
280                        serde_json::json!({
281                            "id": id,
282                            "targets": u.targets.iter().map(|t| {
283                                serde_json::json!({
284                                    "address": t.address,
285                                    "weight": t.weight,
286                                })
287                            }).collect::<Vec<_>>(),
288                            "load_balancing": u.load_balancing,
289                            "health_check": u.health_check.as_ref().map(|h| {
290                                serde_json::json!({
291                                    "interval_secs": h.interval_secs,
292                                    "timeout_secs": h.timeout_secs,
293                                    "healthy_threshold": h.healthy_threshold,
294                                    "unhealthy_threshold": h.unhealthy_threshold,
295                                })
296                            }),
297                            // TLS config redacted
298                            "tls_enabled": u.tls.is_some(),
299                        })
300                    }).collect::<Vec<_>>(),
301                    "agents": cfg.agents.iter().map(|a| {
302                        serde_json::json!({
303                            "id": a.id,
304                            "agent_type": a.agent_type,
305                            "timeout_ms": a.timeout_ms,
306                        })
307                    }).collect::<Vec<_>>(),
308                    "filters": cfg.filters.keys().collect::<Vec<_>>(),
309                    "waf": cfg.waf.as_ref().map(|w| {
310                        serde_json::json!({
311                            "mode": w.mode,
312                            "engine": w.engine,
313                            "audit_log": w.audit_log,
314                        })
315                    }),
316                    "limits": &cfg.limits,
317                }
318            });
319
320            serde_json::to_vec_pretty(&response).unwrap_or_else(|e| {
321                serde_json::to_vec(&serde_json::json!({
322                    "error": "Failed to serialize config",
323                    "message": e.to_string(),
324                })).unwrap_or_default()
325            })
326        }
327        None => {
328            serde_json::to_vec_pretty(&serde_json::json!({
329                "error": "Configuration unavailable",
330                "status": 503,
331                "message": "Config manager not available",
332                "request_id": request_id,
333                "timestamp": chrono::Utc::now().to_rfc3339(),
334            })).unwrap_or_default()
335        }
336    };
337
338    let status = if config.is_some() {
339        StatusCode::OK
340    } else {
341        StatusCode::SERVICE_UNAVAILABLE
342    };
343
344    Response::builder()
345        .status(status)
346        .header("Content-Type", "application/json; charset=utf-8")
347        .header("X-Request-Id", request_id)
348        .header("Cache-Control", "no-cache, no-store, must-revalidate")
349        .body(Full::new(Bytes::from(body)))
350        .expect("static response builder with valid headers cannot fail")
351}
352
353/// Upstream health status handler
354///
355/// Returns the health status of all configured upstreams and their targets.
356fn upstreams_handler(
357    snapshot: Option<UpstreamHealthSnapshot>,
358    request_id: &str,
359) -> Response<Full<Bytes>> {
360    let body = match snapshot {
361        Some(data) => {
362            // Count healthy/unhealthy/unknown targets
363            let mut total_healthy = 0;
364            let mut total_unhealthy = 0;
365            let mut total_unknown = 0;
366
367            for upstream in data.upstreams.values() {
368                for target in &upstream.targets {
369                    match target.status {
370                        TargetHealthStatus::Healthy => total_healthy += 1,
371                        TargetHealthStatus::Unhealthy => total_unhealthy += 1,
372                        TargetHealthStatus::Unknown => total_unknown += 1,
373                    }
374                }
375            }
376
377            let response = serde_json::json!({
378                "timestamp": chrono::Utc::now().to_rfc3339(),
379                "request_id": request_id,
380                "summary": {
381                    "total_upstreams": data.upstreams.len(),
382                    "total_targets": total_healthy + total_unhealthy + total_unknown,
383                    "healthy": total_healthy,
384                    "unhealthy": total_unhealthy,
385                    "unknown": total_unknown,
386                },
387                "upstreams": data.upstreams.values().collect::<Vec<_>>(),
388            });
389
390            serde_json::to_vec_pretty(&response).unwrap_or_else(|e| {
391                serde_json::to_vec(&serde_json::json!({
392                    "error": "Failed to serialize upstreams",
393                    "message": e.to_string(),
394                })).unwrap_or_default()
395            })
396        }
397        None => {
398            // No upstreams configured or data unavailable
399            serde_json::to_vec_pretty(&serde_json::json!({
400                "timestamp": chrono::Utc::now().to_rfc3339(),
401                "request_id": request_id,
402                "summary": {
403                    "total_upstreams": 0,
404                    "total_targets": 0,
405                    "healthy": 0,
406                    "unhealthy": 0,
407                    "unknown": 0,
408                },
409                "upstreams": [],
410                "message": "No upstreams configured",
411            })).unwrap_or_default()
412        }
413    };
414
415    Response::builder()
416        .status(StatusCode::OK)
417        .header("Content-Type", "application/json; charset=utf-8")
418        .header("X-Request-Id", request_id)
419        .header("Cache-Control", "no-cache, no-store, must-revalidate")
420        .body(Full::new(Bytes::from(body)))
421        .expect("static response builder with valid headers cannot fail")
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427
428    #[test]
429    fn test_status_handler() {
430        let state = BuiltinHandlerState::new(
431            "0.1.0".to_string(),
432            "test-instance".to_string(),
433        );
434
435        let response = status_handler(&state, "test-request-id");
436        assert_eq!(response.status(), StatusCode::OK);
437
438        let content_type = response.headers().get("Content-Type").unwrap();
439        assert_eq!(content_type, "application/json; charset=utf-8");
440    }
441
442    #[test]
443    fn test_health_handler() {
444        let response = health_handler("test-request-id");
445        assert_eq!(response.status(), StatusCode::OK);
446    }
447
448    #[test]
449    fn test_metrics_handler() {
450        let response = metrics_handler("test-request-id");
451        assert_eq!(response.status(), StatusCode::OK);
452
453        let content_type = response.headers().get("Content-Type").unwrap();
454        assert!(content_type.to_str().unwrap().contains("text/plain"));
455    }
456
457    #[test]
458    fn test_not_found_handler() {
459        let response = not_found_handler("test-request-id");
460        assert_eq!(response.status(), StatusCode::NOT_FOUND);
461    }
462
463    #[test]
464    fn test_config_handler_with_config() {
465        let config = Arc::new(Config::default_for_testing());
466        let response = config_handler(Some(config), "test-request-id");
467        assert_eq!(response.status(), StatusCode::OK);
468
469        let content_type = response.headers().get("Content-Type").unwrap();
470        assert_eq!(content_type, "application/json; charset=utf-8");
471    }
472
473    #[test]
474    fn test_config_handler_without_config() {
475        let response = config_handler(None, "test-request-id");
476        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
477    }
478
479    #[test]
480    fn test_upstreams_handler_with_data() {
481        let mut upstreams = HashMap::new();
482        upstreams.insert(
483            "backend".to_string(),
484            UpstreamStatus {
485                id: "backend".to_string(),
486                load_balancing: "round_robin".to_string(),
487                targets: vec![
488                    TargetStatus {
489                        address: "10.0.0.1:8080".to_string(),
490                        weight: 1,
491                        status: TargetHealthStatus::Healthy,
492                        failure_rate: Some(0.0),
493                        last_error: None,
494                    },
495                    TargetStatus {
496                        address: "10.0.0.2:8080".to_string(),
497                        weight: 1,
498                        status: TargetHealthStatus::Unhealthy,
499                        failure_rate: Some(0.8),
500                        last_error: Some("connection refused".to_string()),
501                    },
502                ],
503            },
504        );
505
506        let snapshot = UpstreamHealthSnapshot { upstreams };
507        let response = upstreams_handler(Some(snapshot), "test-request-id");
508        assert_eq!(response.status(), StatusCode::OK);
509
510        let content_type = response.headers().get("Content-Type").unwrap();
511        assert_eq!(content_type, "application/json; charset=utf-8");
512    }
513
514    #[test]
515    fn test_upstreams_handler_no_upstreams() {
516        let response = upstreams_handler(None, "test-request-id");
517        assert_eq!(response.status(), StatusCode::OK);
518    }
519
520    #[test]
521    fn test_uptime_formatting() {
522        let state = BuiltinHandlerState::new(
523            "0.1.0".to_string(),
524            "test".to_string(),
525        );
526
527        // Just verify it doesn't panic and returns a string
528        let uptime = state.uptime_string();
529        assert!(!uptime.is_empty());
530    }
531}