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