use super::LoggerManager;
#[cfg(feature = "http")]
use crate::InklogError;
#[cfg(feature = "http")]
use crate::LogRecord;
#[cfg(feature = "http")]
use crate::Metrics;
#[cfg(feature = "http")]
use crossbeam_channel::Sender;
#[cfg(feature = "http")]
use std::sync::atomic::AtomicUsize;
#[cfg(feature = "http")]
use std::sync::{Arc, Mutex};
impl LoggerManager {
#[cfg(feature = "http")]
pub(crate) async fn start_http_server(
metrics: Arc<Metrics>,
sender: Sender<Arc<LogRecord>>,
effective_capacity: Arc<AtomicUsize>,
http_server_handle: &Mutex<Option<tokio::task::JoinHandle<()>>>,
config: &crate::HttpServerConfig,
) -> Result<(), InklogError> {
use axum::{
Router,
extract::{ConnectInfo, State},
http::{Request, StatusCode, header},
middleware::{self, Next},
response::{IntoResponse, Response},
routing::get,
};
use std::net::SocketAddr;
use subtle::ConstantTimeEq;
use tracing::info;
let health_path = config.health_path.clone();
let metrics_path = config.metrics_path.clone();
let health_status_getter = {
let sender = sender.clone();
let effective_capacity = effective_capacity.clone();
let metrics_clone = metrics.clone();
move || {
let channel_len = sender.len();
let channel_cap = effective_capacity.load(std::sync::atomic::Ordering::Relaxed);
metrics_clone.get_status(channel_len, channel_cap)
}
};
#[derive(Clone)]
struct HttpAuthState {
auth_enabled: bool,
token_value: Option<String>,
ip_whitelist: Option<Vec<String>>,
}
let (auth_enabled, token_value) = match config.auth.as_ref() {
Some(a) if a.enabled => {
let token_env = if a.token_env.is_empty() {
"INKLOG_HTTP_AUTH_TOKEN"
} else {
a.token_env.as_str()
};
match std::env::var(token_env) {
Ok(t) if !t.is_empty() => (true, Some(t)),
Ok(_) => {
let mut args = fluent_bundle::FluentArgs::new();
args.set("env", token_env);
return Err(InklogError::ConfigError(crate::i18n::tr_args(
"config-http_auth_token_empty",
args,
)));
}
Err(_) => {
let mut args = fluent_bundle::FluentArgs::new();
args.set("env", token_env);
return Err(InklogError::ConfigError(crate::i18n::tr_args(
"config-http_auth_token_not_set",
args,
)));
}
}
}
Some(_) => (false, None),
None => (false, None),
};
let auth_state = HttpAuthState {
auth_enabled,
token_value,
ip_whitelist: config.ip_whitelist.clone(),
};
async fn auth_middleware(
State(state): State<HttpAuthState>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
request: Request<axum::body::Body>,
next: Next,
) -> Response {
if state.auth_enabled
&& let Some(ref expected_token) = state.token_value
{
let auth_header = request
.headers()
.get(header::AUTHORIZATION)
.and_then(|h: &axum::http::HeaderValue| h.to_str().ok());
match auth_header {
Some(h) if h.starts_with("Bearer ") => {
let token = &h[7..];
if !subtle_constant_time_compare(
token.as_bytes(),
expected_token.as_bytes(),
) {
return (StatusCode::UNAUTHORIZED, "Invalid token").into_response();
}
}
_ => {
return (
StatusCode::UNAUTHORIZED,
"Missing or invalid Authorization header",
)
.into_response();
}
}
}
if let Some(ref whitelist) = state.ip_whitelist {
let client_ip = addr.ip().to_string();
if !whitelist.iter().any(|allowed| {
whitelist_entry_matches(
allowed,
&client_ip,
addr.ip(),
&INVALID_WHITELIST_WARNED,
)
}) {
return (StatusCode::FORBIDDEN, "IP not in whitelist").into_response();
}
}
next.run(request).await
}
fn subtle_constant_time_compare(a: &[u8], b: &[u8]) -> bool {
a.ct_eq(b).unwrap_u8() == 1
}
static INVALID_WHITELIST_WARNED: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
let app = Router::new()
.route(
&health_path,
get(|| async move {
let status = health_status_getter();
match serde_json::to_value(&status) {
Ok(v) => axum::Json(v),
Err(e) => {
let mut args = fluent_bundle::FluentArgs::new();
args.set("err", e.to_string());
tracing::error!(
"{}",
crate::i18n::tr_args("config-http_serialize_failed", args)
);
axum::Json(serde_json::json!({"error": "serialization failed"}))
}
}
}),
)
.route(
&metrics_path,
get(move || async move { metrics.export_prometheus() }),
)
.layer(middleware::from_fn_with_state(
auth_state.clone(),
auth_middleware,
))
.with_state(auth_state);
let addr: std::net::SocketAddr = format!("{}:{}", config.host, config.port)
.parse()
.map_err(|e: std::net::AddrParseError| {
let mut args = fluent_bundle::FluentArgs::new();
args.set("addr", format!("{}:{}", config.host, config.port));
args.set("err", e.to_string());
InklogError::ConfigError(crate::i18n::tr_args("config-invalid_http_address", args))
})?;
let auth_enabled = config.auth.as_ref().map(|a| a.enabled).unwrap_or(false);
let ip_whitelist = config.ip_whitelist.clone();
let error_mode = config.error_mode.clone();
let tls_config = config.tls.clone();
let make_svc = app.into_make_service_with_connect_info::<SocketAddr>();
let handle = if let Some(ref tls) = tls_config {
use axum_server::tls_rustls::RustlsConfig;
let rustls_config =
match RustlsConfig::from_pem_file(&tls.cert_path, &tls.key_path).await {
Ok(c) => c,
Err(e) => return bind_failure_outcome(&error_mode, addr, &e, true),
};
let tcp_listener = match tokio::net::TcpListener::bind(addr).await {
Ok(l) => l,
Err(e) => return bind_failure_outcome(&error_mode, addr, &e, true),
};
let std_listener = match tcp_listener.into_std() {
Ok(l) => l,
Err(e) => return bind_failure_outcome(&error_mode, addr, &e, true),
};
if let Err(e) = std_listener.set_nonblocking(true) {
return bind_failure_outcome(&error_mode, addr, &e, true);
}
let server = match axum_server::tls_rustls::from_tcp_rustls(std_listener, rustls_config)
{
Ok(s) => s,
Err(e) => return bind_failure_outcome(&error_mode, addr, &e, true),
};
info!(
"HTTPS server started on {} (auth: {}, ip_whitelist: {:?})",
addr, auth_enabled, ip_whitelist
);
tokio::spawn(async move {
if let Err(e) = server.serve(make_svc).await {
let mut args = fluent_bundle::FluentArgs::new();
args.set("err", e.to_string());
tracing::error!(
"{}",
crate::i18n::tr_args("config-https_server_error", args)
);
}
})
} else {
let listener = match tokio::net::TcpListener::bind(addr).await {
Ok(l) => l,
Err(e) => return bind_failure_outcome(&error_mode, addr, &e, false),
};
info!(
"HTTP server started on {} (auth: {}, ip_whitelist: {:?})",
addr, auth_enabled, ip_whitelist
);
tokio::spawn(async move {
match axum::serve(listener, make_svc).await {
Ok(_) => info!("HTTP server stopped"),
Err(e) => {
let mut args = fluent_bundle::FluentArgs::new();
args.set("err", e.to_string());
tracing::error!(
"{}",
crate::i18n::tr_args("config-http_server_error", args)
);
}
}
})
};
let mut handle_guard = http_server_handle.lock().unwrap_or_else(|e| e.into_inner());
*handle_guard = Some(handle);
info!("HTTP monitoring server configured on {}", addr);
Ok(())
}
}
#[cfg(feature = "http")]
fn bind_failure_outcome(
error_mode: &crate::HttpErrorMode,
addr: std::net::SocketAddr,
err: &std::io::Error,
https: bool,
) -> Result<(), InklogError> {
let mut args = fluent_bundle::FluentArgs::new();
args.set("addr", addr.to_string());
args.set("err", err.to_string());
let message_key = if https {
"config-https_server_error"
} else {
"config-http_bind_failed"
};
match error_mode {
crate::HttpErrorMode::Strict => Err(InklogError::ConfigError(crate::i18n::tr_args(
message_key,
args,
))),
crate::HttpErrorMode::Warn => {
tracing::warn!("{}", crate::i18n::tr_args(message_key, args));
Ok(())
}
}
}
#[cfg(feature = "http")]
fn whitelist_entry_matches(
allowed: &str,
client_ip: &str,
ip: std::net::IpAddr,
invalid_warned: &std::sync::atomic::AtomicBool,
) -> bool {
use std::sync::atomic::Ordering;
if let Some(prefix_body) = allowed.strip_suffix(".*") {
let prefix = format!("{prefix_body}.");
client_ip.starts_with(&prefix)
} else if allowed.contains('/') {
match parse_cidr(allowed) {
Some(network) => network.contains(&ip),
None => {
if !invalid_warned.swap(true, Ordering::Relaxed) {
tracing::warn!(
entry = %allowed,
"ip_whitelist entry is not a valid IP or CIDR; it never matches (fail-closed)"
);
}
false
}
}
} else {
client_ip == allowed
}
}
#[cfg(feature = "http")]
fn parse_cidr(cidr: &str) -> Option<ipnet::IpNet> {
cidr.parse().ok()
}
#[cfg(all(test, feature = "http"))]
mod tests {
use super::bind_failure_outcome;
use super::whitelist_entry_matches;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
fn ip(s: &str) -> std::net::IpAddr {
s.parse().unwrap()
}
#[test]
fn test_invalid_cidr_entry_fails_closed_and_raises_warn_flag() {
let flag = AtomicBool::new(false);
assert!(!whitelist_entry_matches(
"10.0.0.0/33",
"10.0.0.1",
ip("10.0.0.1"),
&flag
));
assert!(flag.load(Ordering::Relaxed));
assert!(!whitelist_entry_matches(
"10.0.0.0/33",
"10.0.0.2",
ip("10.0.0.2"),
&flag
));
assert!(flag.load(Ordering::Relaxed));
}
#[test]
fn test_valid_entries_match_without_touching_warn_flag() {
let flag = AtomicBool::new(false);
assert!(whitelist_entry_matches(
"192.168.1.1",
"192.168.1.1",
ip("192.168.1.1"),
&flag
));
assert!(whitelist_entry_matches(
"10.*",
"10.1.2.3",
ip("10.1.2.3"),
&flag
));
assert!(!whitelist_entry_matches(
"10.*",
"110.1.2.3",
ip("110.1.2.3"),
&flag
));
assert!(whitelist_entry_matches(
"192.168.0.0/24",
"192.168.0.99",
ip("192.168.0.99"),
&flag
));
assert!(!whitelist_entry_matches(
"192.168.0.0/24",
"192.168.1.1",
ip("192.168.1.1"),
&flag
));
assert!(!flag.load(Ordering::Relaxed));
}
#[test]
fn test_bind_failure_outcome_strict_propagates_error() {
let addr: std::net::SocketAddr = "127.0.0.1:1".parse().unwrap();
let err = std::io::Error::new(std::io::ErrorKind::AddrInUse, "address in use");
let result = bind_failure_outcome(&crate::HttpErrorMode::Strict, addr, &err, false);
let msg = match result {
Err(crate::InklogError::ConfigError(msg)) => msg,
other => panic!("expected ConfigError, got {other:?}"),
};
assert!(msg.contains("127.0.0.1"), "addr should appear: {msg}");
assert!(msg.contains("address in use"), "err should appear: {msg}");
}
#[test]
fn test_bind_failure_outcome_warn_degrades_to_ok() {
let addr: std::net::SocketAddr = "127.0.0.1:1".parse().unwrap();
let err = std::io::Error::new(std::io::ErrorKind::AddrInUse, "address in use");
let result = bind_failure_outcome(&crate::HttpErrorMode::Warn, addr, &err, false);
assert!(result.is_ok(), "Warn mode must degrade bind failure to Ok");
}
#[tokio::test]
async fn test_bind_conflict_propagates_per_error_mode() {
let occupied = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = occupied.local_addr().unwrap();
let bind_err = tokio::net::TcpListener::bind(addr)
.await
.expect_err("binding an occupied port must fail");
assert!(
bind_failure_outcome(&crate::HttpErrorMode::Strict, addr, &bind_err, false).is_err(),
"Strict mode must surface bind conflict as Err"
);
assert!(
bind_failure_outcome(&crate::HttpErrorMode::Warn, addr, &bind_err, false).is_ok(),
"Warn mode must degrade bind conflict to Ok"
);
}
}