Skip to content

Commit 204b5fc

Browse files
committed
feat: add health handler with authentication
1 parent 02c8f14 commit 204b5fc

File tree

1 file changed

+206
-8
lines changed

1 file changed

+206
-8
lines changed

src/server.rs

Lines changed: 206 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
55
use 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};
1414
use socket2::{Domain, Socket, Type};
1515
use std::{
1616
collections::HashSet,
17+
env,
1718
io::Cursor,
1819
net::{IpAddr, Ipv4Addr},
1920
num::ParseIntError,
2021
};
2122
use std::{
2223
net::AddrParseError,
23-
time::{Duration, Instant},
24+
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
2425
};
2526
use std::{net::SocketAddrV6, path::Path as StdPath};
2627
use 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)]
5294
struct 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)]
89131
struct 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

Comments
 (0)