44
55use axum:: {
66 extract:: { ConnectInfo , Path , Query , State } ,
7- http:: StatusCode ,
7+ http:: { HeaderMap , StatusCode } ,
88 middleware:: { self , Next } ,
99 response:: { IntoResponse , Redirect , Response } ,
1010 routing:: get,
@@ -14,13 +14,14 @@ use serde::{Deserialize, Serialize};
1414use socket2:: { Domain , Socket , Type } ;
1515use std:: {
1616 collections:: HashSet ,
17+ env,
1718 io:: Cursor ,
1819 net:: { IpAddr , Ipv4Addr } ,
1920 num:: ParseIntError ,
2021} ;
2122use std:: {
2223 net:: AddrParseError ,
23- time:: { Duration , Instant } ,
24+ time:: { Duration , Instant , SystemTime , UNIX_EPOCH } ,
2425} ;
2526use std:: { net:: SocketAddrV6 , path:: Path as StdPath } ;
2627use std:: {
@@ -38,6 +39,10 @@ use crate::{
3839 image:: { self , ImageFormat } ,
3940 ratelimit:: { RateLimitConfig , RateLimitResult , RateLimiter } ,
4041} ;
42+ use once_cell:: sync:: Lazy ;
43+
44+ /// Lazy-loaded healthcheck token from environment variable
45+ static HEALTHCHECK_TOKEN : Lazy < Option < String > > = Lazy :: new ( || env:: var ( "HEALTHCHECK_TOKEN" ) . ok ( ) ) ;
4146
4247/// Error response structure for JSON error responses
4348#[ derive( Debug , Serialize ) ]
@@ -47,6 +52,43 @@ struct ErrorResponse {
4752 status : u16 ,
4853}
4954
55+ /// Health check response structure
56+ #[ derive( Debug , Serialize ) ]
57+ struct HealthResponse {
58+ status : String ,
59+ timestamp : u64 ,
60+ uptime_seconds : u64 ,
61+ version : String ,
62+ components : ComponentStatus ,
63+ }
64+
65+ /// Component status for health checks
66+ #[ derive( Debug , Serialize ) ]
67+ struct ComponentStatus {
68+ rate_limiter : RateLimiterHealth ,
69+ github_api : GitHubApiHealth ,
70+ }
71+
72+ /// Rate limiter health status
73+ #[ derive( Debug , Serialize ) ]
74+ struct RateLimiterHealth {
75+ status : String ,
76+ global_tokens_remaining : u32 ,
77+ global_tokens_max : u32 ,
78+ active_ip_count : u32 ,
79+ utilization_percent : f32 ,
80+ }
81+
82+ /// GitHub API health status
83+ #[ derive( Debug , Serialize ) ]
84+ struct GitHubApiHealth {
85+ status : String ,
86+ token_configured : bool ,
87+ circuit_breaker_open : bool ,
88+ #[ serde( skip_serializing_if = "Option::is_none" ) ]
89+ last_error : Option < String > ,
90+ }
91+
5092/// SVG input data for repository cards
5193#[ derive( Debug , Clone ) ]
5294struct SvgInputData {
@@ -84,10 +126,11 @@ pub struct ImageQuery {
84126 pub s : Option < String > ,
85127}
86128
87- /// Application state containing the rate limiter
129+ /// Application state containing the rate limiter and startup time
88130#[ derive( Clone , Debug ) ]
89131struct AppState {
90132 rate_limiter : RateLimiter ,
133+ startup_time : Instant ,
91134}
92135
93136/// Middleware to add Server header to all responses
@@ -169,7 +212,10 @@ pub async fn start_server(mut addresses: Vec<SocketAddr>) -> Option<Result<(), a
169212 }
170213
171214 let rate_limiter = RateLimiter :: new ( RateLimitConfig :: default ( ) ) ;
172- let app_state = AppState { rate_limiter } ;
215+ let app_state = AppState {
216+ rate_limiter,
217+ startup_time : Instant :: now ( ) ,
218+ } ;
173219
174220 let app = Router :: new ( )
175221 . route ( "/" , get ( index_handler) )
@@ -316,12 +362,164 @@ async fn status_handler(State(state): State<AppState>) -> Response {
316362 . into_response ( )
317363}
318364
319- /// Handles health check route - returns simple OK response.
365+ /// Check if the request is authorized for health check access.
366+ ///
367+ /// Authorization logic:
368+ /// - In debug mode: allow access if no token configured, validate if configured
369+ /// - In release mode: require valid token if HEALTHCHECK_TOKEN is configured
370+ /// - Token can be provided via Authorization Bearer header or 'token' query parameter
371+ fn is_health_check_authorized ( headers : & HeaderMap , query : & HealthQuery ) -> bool {
372+ let expected_token = match HEALTHCHECK_TOKEN . as_ref ( ) {
373+ Some ( token) => token,
374+ None => {
375+ // No token configured
376+ if cfg ! ( debug_assertions) {
377+ return true ; // Allow access in debug mode
378+ } else {
379+ return false ; // Deny access in release mode
380+ }
381+ }
382+ } ;
383+
384+ // Token is configured, validate it
385+ // Check Authorization Bearer header first
386+ if let Some ( auth_header) = headers. get ( "authorization" ) {
387+ if let Ok ( auth_str) = auth_header. to_str ( ) {
388+ if let Some ( token) = auth_str. strip_prefix ( "Bearer " ) {
389+ return token == expected_token;
390+ }
391+ }
392+ }
393+
394+ // Fallback to query parameter
395+ if let Some ( query_token) = & query. token {
396+ return query_token == expected_token;
397+ }
398+
399+ false
400+ }
401+
402+ /// Query parameters for health check endpoint
403+ #[ derive( Debug , Deserialize ) ]
404+ struct HealthQuery {
405+ token : Option < String > ,
406+ }
407+
408+ /// Handles health check route - returns comprehensive health status.
320409///
321410/// Endpoint: GET /health
322- /// Returns: 200 OK with "OK" text
323- async fn health_handler ( ) -> Response {
324- ( [ ( axum:: http:: header:: CONTENT_TYPE , "text/plain" ) ] , "OK" ) . into_response ( )
411+ /// Returns: JSON with detailed system health information including:
412+ /// - Service status and uptime
413+ /// - Rate limiter status
414+ /// - GitHub API connectivity
415+ /// - Component health checks
416+ ///
417+ /// Authentication:
418+ /// - Debug mode: always accessible
419+ /// - Release mode: requires HEALTHCHECK_TOKEN via Authorization Bearer or token query param
420+ #[ instrument]
421+ async fn health_handler (
422+ State ( state) : State < AppState > ,
423+ headers : HeaderMap ,
424+ Query ( query) : Query < HealthQuery > ,
425+ ) -> Response {
426+ // Check authorization
427+ if !is_health_check_authorized ( & headers, & query) {
428+ return (
429+ StatusCode :: UNAUTHORIZED ,
430+ Json ( ErrorResponse {
431+ error : "Unauthorized" . to_string ( ) ,
432+ message : "Valid authentication token required" . to_string ( ) ,
433+ status : 401 ,
434+ } ) ,
435+ )
436+ . into_response ( ) ;
437+ }
438+ let now = SystemTime :: now ( )
439+ . duration_since ( UNIX_EPOCH )
440+ . unwrap_or_default ( )
441+ . as_secs ( ) ;
442+
443+ let uptime = state. startup_time . elapsed ( ) . as_secs ( ) ;
444+
445+ // Check rate limiter health
446+ let rate_limit_status = state. rate_limiter . status ( ) . await ;
447+ let utilization = if rate_limit_status. global_tokens_max > 0 {
448+ 100.0
449+ - ( rate_limit_status. global_tokens_remaining as f32
450+ / rate_limit_status. global_tokens_max as f32
451+ * 100.0 )
452+ } else {
453+ 0.0
454+ } ;
455+
456+ let rate_limiter_health = RateLimiterHealth {
457+ status : if rate_limit_status. global_tokens_remaining > 0 {
458+ "healthy"
459+ } else {
460+ "degraded"
461+ }
462+ . to_string ( ) ,
463+ global_tokens_remaining : rate_limit_status. global_tokens_remaining ,
464+ global_tokens_max : rate_limit_status. global_tokens_max ,
465+ active_ip_count : rate_limit_status. active_ip_count ,
466+ utilization_percent : utilization,
467+ } ;
468+
469+ // Check GitHub API health
470+ let github_client = & github:: GITHUB_CLIENT ;
471+ let token_configured = github_client. has_token ( ) ;
472+ let circuit_breaker_open = github_client. disabled ( ) ;
473+
474+ // Perform a lightweight GitHub API check if token is available and circuit breaker is closed
475+ let ( github_status, last_error) = if !token_configured {
476+ ( "warning" , Some ( "No GitHub token configured" . to_string ( ) ) )
477+ } else if circuit_breaker_open {
478+ ( "degraded" , Some ( "Circuit breaker is open" . to_string ( ) ) )
479+ } else {
480+ // Try a quick validation call
481+ match tokio:: time:: timeout ( Duration :: from_secs ( 2 ) , github_client. validate_token ( ) ) . await {
482+ Ok ( Ok ( _) ) => ( "healthy" , None ) ,
483+ Ok ( Err ( e) ) => ( "degraded" , Some ( e. to_string ( ) ) ) ,
484+ Err ( _) => ( "degraded" , Some ( "Token validation timeout" . to_string ( ) ) ) ,
485+ }
486+ } ;
487+
488+ let github_health = GitHubApiHealth {
489+ status : github_status. to_string ( ) ,
490+ token_configured,
491+ circuit_breaker_open,
492+ last_error,
493+ } ;
494+
495+ // Determine overall status
496+ let overall_status = if github_status == "healthy" && rate_limiter_health. status == "healthy" {
497+ "healthy"
498+ } else if github_status == "degraded" || rate_limiter_health. status == "degraded" {
499+ "degraded"
500+ } else {
501+ "warning"
502+ } ;
503+
504+ let health_response = HealthResponse {
505+ status : overall_status. to_string ( ) ,
506+ timestamp : now,
507+ uptime_seconds : uptime,
508+ version : env ! ( "CARGO_PKG_VERSION" ) . to_string ( ) ,
509+ components : ComponentStatus {
510+ rate_limiter : rate_limiter_health,
511+ github_api : github_health,
512+ } ,
513+ } ;
514+
515+ let status_code = match overall_status {
516+ "healthy" => StatusCode :: OK ,
517+ "warning" => StatusCode :: OK ,
518+ "degraded" => StatusCode :: SERVICE_UNAVAILABLE ,
519+ _ => StatusCode :: INTERNAL_SERVER_ERROR ,
520+ } ;
521+
522+ ( status_code, Json ( health_response) ) . into_response ( )
325523}
326524
327525/// Handles HTTP requests for repository cards with rate limiting.
0 commit comments