//! Bounded live access ring plus optional asynchronous structured-log sinks. //! Request handling only performs a try-lock/try-send; file/network I/O lives on //! the background worker and is bounded by a fixed queue. use anyhow::{ensure, Context, Result}; use arc_swap::ArcSwap; use axum::body::Body; use http::{Request, Uri}; use http_body_util::{BodyExt, Limited}; use hyper_rustls::HttpsConnectorBuilder; use hyper_util::{ client::legacy::{connect::HttpConnector, Client}, rt::TokioExecutor, }; use rustls::{ClientConfig, RootCertStore}; use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; use std::{ collections::{BTreeMap, VecDeque}, io::{self, Write as IoWrite}, path::PathBuf, sync::{ atomic::{AtomicU64, Ordering}, Arc, Mutex, OnceLock, }, time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; use tokio::{ fs::{File, OpenOptions}, io::AsyncWriteExt, net::TcpStream, sync::{broadcast, mpsc, watch}, }; use tokio_util::sync::CancellationToken; use tracing::{Event, Level, Subscriber}; use tracing_subscriber::{layer::Context as LayerContext, Layer}; pub const LIVE_WINDOW: usize = 256; pub const HISTORY_CAPACITY: usize = 4096; pub const LIVE_STREAM_CAPACITY: usize = 4096; const SINK_QUEUE: usize = 512; const SINK_TIMEOUT: Duration = Duration::from_secs(5); #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct FileSink { pub enabled: bool, pub path: String, /// Write one JSONL file per resolved vhost instead of one combined file. #[serde(default)] pub per_vhost: bool, /// Directory used by per-vhost mode. Files are named `.jsonl`; /// unmatched requests are written to `_unmatched.jsonl`. #[serde(default = "default_vhost_log_directory")] pub directory: String, } fn default_vhost_log_directory() -> String { "/var/log/rustproxy/vhosts".into() } impl Default for FileSink { fn default() -> Self { Self { enabled: false, path: "/var/log/rustproxy/access.jsonl".into(), per_vhost: false, directory: default_vhost_log_directory(), } } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct LogstashSink { pub enabled: bool, pub address: String, } impl Default for LogstashSink { fn default() -> Self { Self { enabled: false, address: "127.0.0.1:5044".into(), } } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct LokiSink { pub enabled: bool, pub url: String, pub bearer_token_env: Option, pub labels: BTreeMap, } impl Default for LokiSink { fn default() -> Self { Self { enabled: false, url: "http://127.0.0.1:3100/loki/api/v1/push".into(), bearer_token_env: None, labels: BTreeMap::from([("job".into(), "rustproxy".into())]), } } } #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct JsonFormat { /// output field -> LogEntry field. Empty means the complete standard entry. pub fields: BTreeMap, pub static_fields: BTreeMap, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Settings { /// Capture a bounded User-Agent value in access logs and traffic analytics. pub capture_user_agent: bool, /// Resolve the effective client IP through the loaded GeoIP database. /// Disabled by default because this adds per-request lookup work. pub capture_country: bool, pub file: FileSink, pub logstash: LogstashSink, pub loki: LokiSink, pub format: JsonFormat, } impl Default for Settings { fn default() -> Self { Self { capture_user_agent: true, capture_country: false, file: FileSink::default(), logstash: LogstashSink::default(), loki: LokiSink::default(), format: JsonFormat::default(), } } } impl Settings { pub fn enabled(&self) -> bool { self.file.enabled || self.logstash.enabled || self.loki.enabled } pub fn validate(&self) -> Result<()> { if self.file.enabled { if self.file.per_vhost { ensure!( !self.file.directory.trim().is_empty() && self.file.directory.len() <= 1024, "logging.file.directory must be 1..1024 bytes in per-vhost mode" ); } else { ensure!( !self.file.path.trim().is_empty() && self.file.path.len() <= 1024, "logging.file.path must be 1..1024 bytes" ); } } if self.logstash.enabled { let authority = self .logstash .address .parse::() .context("logging.logstash.address must be host:port")?; ensure!( !authority.host().is_empty() && authority.port_u16().is_some() && !authority.as_str().contains('@'), "logging.logstash.address must be host:port" ); } if self.loki.enabled { ensure!(self.loki.url.len() <= 2048, "logging.loki.url is too long"); let url = url::Url::parse(&self.loki.url).context("invalid logging.loki.url")?; ensure!( matches!(url.scheme(), "http" | "https") && url.host().is_some(), "logging.loki.url must be HTTP(S)" ); ensure!( url.username().is_empty() && url.password().is_none(), "credentials in logging.loki.url are forbidden; use bearer_token_env" ); } if let Some(name) = &self.loki.bearer_token_env { ensure!( !name.is_empty() && name.len() <= 128 && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_'), "logging.loki.bearer_token_env must be a simple environment variable name" ); } ensure!( self.loki.labels.len() <= 32, "at most 32 Loki labels are allowed" ); for (key, value) in &self.loki.labels { ensure!( !key.is_empty() && key.len() <= 64 && key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_'), "Loki label names must be 1..64 letters, numbers or underscores" ); ensure!( value.len() <= 256, "Loki label values must be at most 256 bytes" ); } ensure!( self.format.fields.len() <= 32 && self.format.static_fields.len() <= 32, "structured log format allows at most 32 mapped and 32 static fields" ); for (output, source) in &self.format.fields { validate_json_key(output)?; ensure!( source_value_name(source), "unknown structured log source field: {source}" ); } for (key, value) in &self.format.static_fields { validate_json_key(key)?; ensure!( value.len() <= 1024, "structured log static value is too long" ); } Ok(()) } } fn validate_json_key(key: &str) -> Result<()> { ensure!( !key.is_empty() && key.len() <= 128 && !key.chars().any(char::is_control), "structured log output field names must be 1..128 bytes without controls" ); Ok(()) } fn source_value_name(name: &str) -> bool { matches!( name, "vhost" | "route" | "cache_effective_enabled" | "transform_effective_enabled" | "backend" | "id" | "time_ms" | "method" | "host" | "path" | "http_version" | "transport" | "websocket_bootstrap" | "websocket_phase" | "websocket_error" | "h3_reset_code" | "upstream_status" | "upstream_error" | "client_ip" | "user_agent" | "country" | "status" | "duration_ms" | "request_bytes" | "response_bytes" | "outcome" | "cache" | "cache_reason" ) } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] pub enum ErrorLogFormat { #[serde(rename = "JSON")] Json, #[serde(rename = "RAW")] Raw, } impl Default for ErrorLogFormat { fn default() -> Self { Self::Json } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct ErrorLogSettings { pub enabled: bool, pub path: String, pub format: ErrorLogFormat, } impl Default for ErrorLogSettings { fn default() -> Self { Self { enabled: false, path: "/var/log/rustproxy/error.log".into(), format: ErrorLogFormat::Json, } } } impl ErrorLogSettings { pub fn validate(&self) -> Result<()> { if self.enabled { ensure!( !self.path.trim().is_empty() && self.path.len() <= 1024 && !self.path.chars().any(char::is_control), "error_log.path must be 1..1024 bytes without controls" ); } Ok(()) } } const ERROR_LOG_QUEUE: usize = 512; const ERROR_LOG_RETRY: Duration = Duration::from_secs(30); #[derive(Debug, Clone, Serialize)] pub struct ErrorLogStats { pub queue_capacity: usize, pub queue_dropped: u64, pub written: u64, pub errors: u64, } #[derive(Debug)] struct ErrorEvent { timestamp: String, target: String, message: String, fields: BTreeMap, } pub struct ErrorLogController { settings: Arc>, settings_changed: watch::Sender<()>, sender: mpsc::Sender, dropped: Arc, written: Arc, errors: Arc, } impl ErrorLogController { pub fn start() -> Arc { let (sender, receiver) = mpsc::channel(ERROR_LOG_QUEUE); let (settings_changed, settings_watch) = watch::channel(()); let settings = Arc::new(ArcSwap::from_pointee(ErrorLogSettings::default())); let dropped = Arc::new(AtomicU64::new(0)); let written = Arc::new(AtomicU64::new(0)); let errors = Arc::new(AtomicU64::new(0)); tokio::spawn(error_log_loop( receiver, settings.clone(), settings_watch, written.clone(), errors.clone(), )); Arc::new(Self { settings, settings_changed, sender, dropped, written, errors, }) } pub fn configure(&self, settings: ErrorLogSettings) { self.settings.store(Arc::new(settings)); self.settings_changed.send_replace(()); } fn push(&self, event: ErrorEvent) { if self.settings.load().enabled && self.sender.try_send(event).is_err() { self.dropped.fetch_add(1, Ordering::Relaxed); } } pub fn stats(&self) -> ErrorLogStats { ErrorLogStats { queue_capacity: ERROR_LOG_QUEUE, queue_dropped: self.dropped.load(Ordering::Relaxed), written: self.written.load(Ordering::Relaxed), errors: self.errors.load(Ordering::Relaxed), } } } static ERROR_LOG: OnceLock> = OnceLock::new(); pub fn install_error_log_controller(controller: Arc) -> Result<()> { ERROR_LOG .set(controller) .map_err(|_| anyhow::anyhow!("error logger already initialized")) } pub fn configure_error_log(settings: ErrorLogSettings) { if let Some(controller) = ERROR_LOG.get() { controller.configure(settings); } } pub fn error_log_stats() -> Option { ERROR_LOG.get().map(|controller| controller.stats()) } #[derive(Clone)] pub struct ErrorLogLayer { controller: Arc, } impl ErrorLogLayer { pub fn new(controller: Arc) -> Self { Self { controller } } } #[derive(Default)] struct EventVisitor { message: Option, fields: BTreeMap, } impl tracing::field::Visit for EventVisitor { fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { let value = format!("{value:?}"); if field.name() == "message" { self.message = Some(value); } else { self.fields.insert(field.name().to_owned(), value); } } fn record_str(&mut self, field: &tracing::field::Field, value: &str) { if field.name() == "message" { self.message = Some(value.to_owned()); } else { self.fields .insert(field.name().to_owned(), value.to_owned()); } } fn record_i64(&mut self, field: &tracing::field::Field, value: i64) { self.fields .insert(field.name().to_owned(), value.to_string()); } fn record_u64(&mut self, field: &tracing::field::Field, value: u64) { self.fields .insert(field.name().to_owned(), value.to_string()); } fn record_bool(&mut self, field: &tracing::field::Field, value: bool) { self.fields .insert(field.name().to_owned(), value.to_string()); } fn record_f64(&mut self, field: &tracing::field::Field, value: f64) { self.fields .insert(field.name().to_owned(), value.to_string()); } } impl Layer for ErrorLogLayer { fn on_event(&self, event: &Event<'_>, _ctx: LayerContext<'_, S>) { if *event.metadata().level() != Level::ERROR { return; } let mut visitor = EventVisitor::default(); event.record(&mut visitor); let mut fields = visitor.fields; for (key, value) in &mut fields { *value = if is_secret_field(key) { "[REDACTED]".into() } else { redact_text(value) }; } self.controller.push(ErrorEvent { timestamp: rfc3339_now(), target: event.metadata().target().to_owned(), message: redact_text(visitor.message.as_deref().unwrap_or("error")), fields, }); } } #[derive(Clone, Copy, Default)] pub struct RedactingStderr; pub struct RedactingWriter { buffer: Vec, } impl IoWrite for RedactingWriter { fn write(&mut self, bytes: &[u8]) -> io::Result { self.buffer.extend_from_slice(bytes); Ok(bytes.len()) } fn flush(&mut self) -> io::Result<()> { if self.buffer.is_empty() { return Ok(()); } let text = String::from_utf8_lossy(&self.buffer); let redacted = redact_text(&text); let mut stderr = io::stderr().lock(); stderr.write_all(redacted.as_bytes())?; stderr.flush()?; self.buffer.clear(); Ok(()) } } impl Drop for RedactingWriter { fn drop(&mut self) { let _ = self.flush(); } } impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for RedactingStderr { type Writer = RedactingWriter; fn make_writer(&'a self) -> Self::Writer { RedactingWriter { buffer: Vec::with_capacity(512), } } } fn redact_regex(pattern: &'static str) -> &'static regex::Regex { static AUTH: OnceLock = OnceLock::new(); static QUERY: OnceLock = OnceLock::new(); static JSON: OnceLock = OnceLock::new(); static KV: OnceLock = OnceLock::new(); static PEM: OnceLock = OnceLock::new(); match pattern { "auth" => AUTH.get_or_init(|| regex::Regex::new(r"(?i)(authorization\s*[:=]\s*)(?:bearer\s+)?[^\s,;]+" ).expect("redaction regex")), "query" => QUERY.get_or_init(|| regex::Regex::new(r"(?i)([?&](?:token|access_token|api_token|password|backup_password|secret|credential|credentials|key_secret|application_secret|consumer_key)=)[^&\s]+" ).expect("redaction regex")), "json" => JSON.get_or_init(|| regex::Regex::new(r#"(?i)(\"(?:authorization|proxy_authorization|api_token|access_token|application_secret|consumer_key|key_secret|backup_password|password|credential|credentials|secret|token|private_key|master_key)\"\s*:\s*\")[^\"]*(\")"#).expect("redaction regex")), "kv" => KV.get_or_init(|| regex::Regex::new(r#"(?i)((?:authorization|proxy[_-]?authorization|api[_-]?token|access[_-]?token|application[_-]?secret|consumer[_-]?key|key[_-]?secret|backup[_-]?password|password|credential|credentials|secret|token|private[_-]?key|master[_-]?key)\s*[=:]\s*)[^\s,;"']+"#).expect("redaction regex")), "pem" => PEM.get_or_init(|| regex::Regex::new(r"(?s)-----BEGIN [^-\r\n]*PRIVATE KEY-----.*?-----END [^-\r\n]*PRIVATE KEY-----" ).expect("redaction regex")), _ => unreachable!(), } } fn is_secret_field(name: &str) -> bool { matches!( name.to_ascii_lowercase().as_str(), "authorization" | "proxy_authorization" | "api_token" | "access_token" | "application_secret" | "consumer_key" | "key_secret" | "backup_password" | "password" | "credential" | "credentials" | "secret" | "token" | "private_key" | "master_key" ) } pub fn redact_text(input: &str) -> String { let mut value = redact_regex("pem") .replace_all(input, "[REDACTED PRIVATE KEY]") .into_owned(); value = redact_regex("auth") .replace_all(&value, "$1[REDACTED]") .into_owned(); value = redact_regex("query") .replace_all(&value, "$1[REDACTED]") .into_owned(); value = redact_regex("json") .replace_all(&value, "$1[REDACTED]$2") .into_owned(); redact_regex("kv") .replace_all(&value, "$1[REDACTED]") .into_owned() } fn redact_value(value: &mut Value) { match value { Value::String(text) => *text = redact_text(text), Value::Array(items) => { for item in items { redact_value(item); } } Value::Object(items) => { for (key, item) in items { if is_secret_field(key) { *item = Value::String("[REDACTED]".into()); } else { redact_value(item); } } } _ => {} } } fn rfc3339_now() -> String { let ms = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_millis() as i64; let seconds = ms / 1000; let millis = ms % 1000; let days = seconds.div_euclid(86_400); let day_seconds = seconds.rem_euclid(86_400); let z = days + 719_468; let era = if z >= 0 { z } else { z - 146_096 }.div_euclid(146_097); let doe = z - era * 146_097; let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; let mut year = yoe + era * 400; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let day = doy - (153 * mp + 2) / 5 + 1; let month = mp + if mp < 10 { 3 } else { -9 }; if month <= 2 { year += 1; } let hour = day_seconds / 3600; let minute = (day_seconds % 3600) / 60; let second = day_seconds % 60; format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{millis:03}Z") } async fn open_error_file(path: PathBuf) -> Result { let file = tokio::task::spawn_blocking(move || -> Result { let mut options = std::fs::OpenOptions::new(); options.create(true).append(true).write(true); #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; options.mode(0o600).custom_flags(libc::O_NOFOLLOW); } let file = options.open(&path)?; let metadata = file.metadata()?; ensure!(metadata.is_file(), "error log path is not a regular file"); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; file.set_permissions(std::fs::Permissions::from_mode(0o600))?; } Ok(file) }) .await .map_err(|_| anyhow::anyhow!("error log open worker failed"))??; Ok(File::from_std(file)) } fn format_error_event(event: &ErrorEvent, format: ErrorLogFormat) -> Vec { match format { ErrorLogFormat::Json => { let mut object = Map::new(); object.insert("timestamp".into(), Value::String(event.timestamp.clone())); object.insert("level".into(), Value::String("ERROR".into())); object.insert("target".into(), Value::String(event.target.clone())); object.insert("message".into(), Value::String(event.message.clone())); for (key, value) in &event.fields { if !matches!(key.as_str(), "timestamp" | "level" | "target" | "message") { object.insert(key.clone(), Value::String(value.clone())); } } let mut line = serde_json::to_vec(&Value::Object(object)).unwrap_or_default(); line.push(b'\n'); line } ErrorLogFormat::Raw => { let one_line = |value: &str| value.replace('\r', "\\r").replace('\n', "\\n"); let mut line = format!( "{} ERROR {} {}", event.timestamp, event.target, one_line(&event.message) ); for (key, value) in &event.fields { line.push(' '); line.push_str(key); line.push('='); line.push_str(&one_line(value)); } line.push('\n'); line.into_bytes() } } } async fn error_log_loop( mut receiver: mpsc::Receiver, settings: Arc>, mut settings_changed: watch::Receiver<()>, written: Arc, errors: Arc, ) { let mut open_path: Option = None; let mut file: Option = None; let mut retry_after: Option = None; let mut last_warning: Option = None; loop { let event = tokio::select! { event = receiver.recv() => match event { Some(event) => event, None => break, }, changed = settings_changed.changed() => { if changed.is_err() && receiver.is_closed() { break; } let config = settings.load_full(); let path = PathBuf::from(&config.path); if !config.enabled || open_path.as_ref() != Some(&path) { // Dropping Tokio's File closes the descriptor without blocking // the request path. A changed path is reopened on the next event. file = None; open_path = None; retry_after = None; } continue; } }; let config = settings.load_full(); if !config.enabled { file = None; open_path = None; retry_after = None; continue; } let path = PathBuf::from(&config.path); if open_path.as_ref() != Some(&path) { file = None; open_path = Some(path.clone()); retry_after = None; } if file.is_none() && retry_after.is_none_or(|until| Instant::now() >= until) { match open_error_file(path.clone()).await { Ok(opened) => { file = Some(opened); retry_after = None; } Err(error) => { errors.fetch_add(1, Ordering::Relaxed); retry_after = Some(Instant::now() + ERROR_LOG_RETRY); if last_warning.is_none_or(|time| time.elapsed() >= ERROR_LOG_RETRY) { tracing::warn!(path=%path.display(), %error, "optional error log file is unavailable"); last_warning = Some(Instant::now()); } } } } let Some(opened) = file.as_mut() else { continue; }; let line = format_error_event(&event, config.format); if let Err(error) = opened.write_all(&line).await { errors.fetch_add(1, Ordering::Relaxed); file = None; retry_after = Some(Instant::now() + ERROR_LOG_RETRY); if last_warning.is_none_or(|time| time.elapsed() >= ERROR_LOG_RETRY) { tracing::warn!(path=%path.display(), %error, "optional error log write failed"); last_warning = Some(Instant::now()); } } else { written.fetch_add(1, Ordering::Relaxed); } } } #[derive(Debug, Clone, Serialize)] pub struct LogEntry { pub vhost: Option, pub route: Option, pub cache_effective_enabled: Option, pub transform_effective_enabled: Option, pub backend: Option, pub id: u64, pub time_ms: u64, pub method: String, pub host: String, pub path: String, pub http_version: &'static str, pub transport: &'static str, pub websocket_bootstrap: Option<&'static str>, pub websocket_phase: Option<&'static str>, pub websocket_error: Option<&'static str>, pub h3_reset_code: Option<&'static str>, pub upstream_status: Option, pub upstream_error: Option, pub client_ip: Option, pub user_agent: Option, pub country: Option, pub status: u16, pub duration_ms: u64, pub request_bytes: u64, pub response_bytes: u64, pub outcome: &'static str, pub cache: &'static str, pub cache_reason: &'static str, } #[derive(Debug, Clone, Serialize)] pub struct SinkStats { pub queue_capacity: usize, pub queue_dropped: u64, pub sent: u64, pub errors: u64, } pub struct Logs { entries: Mutex>, pub dropped: AtomicU64, settings: Arc>, live: broadcast::Sender, sink: mpsc::Sender, sink_dropped: Arc, sink_sent: Arc, sink_errors: Arc, } impl Default for Logs { fn default() -> Self { let (sink, receiver) = mpsc::channel(SINK_QUEUE); drop(receiver); let (live, _) = broadcast::channel(LIVE_STREAM_CAPACITY); Self { entries: Mutex::new(VecDeque::new()), dropped: AtomicU64::new(0), settings: Arc::new(ArcSwap::from_pointee(Settings::default())), live, sink, sink_dropped: Arc::new(AtomicU64::new(0)), sink_sent: Arc::new(AtomicU64::new(0)), sink_errors: Arc::new(AtomicU64::new(0)), } } } impl Logs { pub fn start(settings: Settings, shutdown: CancellationToken) -> Arc { let (tx, rx) = mpsc::channel(SINK_QUEUE); let (live, _) = broadcast::channel(LIVE_STREAM_CAPACITY); let settings = Arc::new(ArcSwap::from_pointee(settings)); let sink_dropped = Arc::new(AtomicU64::new(0)); let sink_sent = Arc::new(AtomicU64::new(0)); let sink_errors = Arc::new(AtomicU64::new(0)); let logs = Arc::new(Self { entries: Mutex::new(VecDeque::new()), dropped: AtomicU64::new(0), settings: settings.clone(), live, sink: tx, sink_dropped: sink_dropped.clone(), sink_sent: sink_sent.clone(), sink_errors: sink_errors.clone(), }); tokio::spawn(sink_loop(rx, settings, sink_sent, sink_errors, shutdown)); logs } pub fn configure(&self, settings: Settings) { self.settings.store(Arc::new(settings)); } pub fn capture_user_agent(&self) -> bool { self.settings.load().capture_user_agent } pub fn capture_country(&self) -> bool { self.settings.load().capture_country } pub fn push(&self, entry: LogEntry) { if let Ok(mut entries) = self.entries.try_lock() { if entries.len() == HISTORY_CAPACITY { entries.pop_front(); } entries.push_back(entry.clone()); } else { self.dropped.fetch_add(1, Ordering::Relaxed); } let _ = self.live.send(entry.clone()); if self.settings.load().enabled() && self.sink.try_send(entry).is_err() { self.sink_dropped.fetch_add(1, Ordering::Relaxed); } } pub fn subscribe(&self) -> broadcast::Receiver { self.live.subscribe() } pub fn recent(&self) -> Vec { self.recent_limit(HISTORY_CAPACITY) } pub fn recent_limit(&self, limit: usize) -> Vec { self.entries .lock() .map(|entries| { entries .iter() .rev() .take(limit.min(HISTORY_CAPACITY)) .cloned() .collect() }) .unwrap_or_default() } pub fn stats(&self) -> SinkStats { SinkStats { queue_capacity: SINK_QUEUE, queue_dropped: self.sink_dropped.load(Ordering::Relaxed), sent: self.sink_sent.load(Ordering::Relaxed), errors: self.sink_errors.load(Ordering::Relaxed), } } } fn value_for(entry: &LogEntry, name: &str) -> Value { match name { "vhost" => entry .vhost .clone() .map(Value::String) .unwrap_or(Value::Null), "route" => entry .route .clone() .map(Value::String) .unwrap_or(Value::Null), "cache_effective_enabled" => entry .cache_effective_enabled .map(Value::Bool) .unwrap_or(Value::Null), "transform_effective_enabled" => entry .transform_effective_enabled .map(Value::Bool) .unwrap_or(Value::Null), "backend" => entry .backend .clone() .map(Value::String) .unwrap_or(Value::Null), "id" => json!(entry.id), "time_ms" => json!(entry.time_ms), "method" => json!(&entry.method), "host" => json!(&entry.host), "path" => json!(&entry.path), "http_version" => json!(entry.http_version), "transport" => json!(entry.transport), "websocket_bootstrap" => entry .websocket_bootstrap .map(|v| Value::String(v.into())) .unwrap_or(Value::Null), "websocket_phase" => entry .websocket_phase .map(|v| Value::String(v.into())) .unwrap_or(Value::Null), "websocket_error" => entry .websocket_error .map(|v| Value::String(v.into())) .unwrap_or(Value::Null), "h3_reset_code" => entry .h3_reset_code .map(|v| Value::String(v.into())) .unwrap_or(Value::Null), "upstream_status" => entry .upstream_status .map(|v| json!(v)) .unwrap_or(Value::Null), "upstream_error" => entry .upstream_error .clone() .map(Value::String) .unwrap_or(Value::Null), "client_ip" => entry .client_ip .clone() .map(Value::String) .unwrap_or(Value::Null), "user_agent" => entry .user_agent .clone() .map(Value::String) .unwrap_or(Value::Null), "country" => entry .country .clone() .map(Value::String) .unwrap_or(Value::Null), "status" => json!(entry.status), "duration_ms" => json!(entry.duration_ms), "request_bytes" => json!(entry.request_bytes), "response_bytes" => json!(entry.response_bytes), "outcome" => json!(entry.outcome), "cache" => json!(entry.cache), "cache_reason" => json!(entry.cache_reason), _ => Value::Null, } } fn format_entry(entry: &LogEntry, format: &JsonFormat) -> Value { if format.fields.is_empty() && format.static_fields.is_empty() { return serde_json::to_value(entry).unwrap_or(Value::Null); } let mut object = Map::new(); if format.fields.is_empty() { if let Value::Object(defaults) = serde_json::to_value(entry).unwrap_or(Value::Null) { object.extend(defaults); } } else { for (output, source) in &format.fields { object.insert(output.clone(), value_for(entry, source)); } } for (key, value) in &format.static_fields { object.insert(key.clone(), Value::String(value.clone())); } Value::Object(object) } type LokiClient = Client, Body>; fn loki_client() -> Result { let mut roots = RootCertStore::empty(); roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); let config = ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider())) .with_safe_default_protocol_versions()? .with_root_certificates(roots) .with_no_client_auth(); let mut http = HttpConnector::new(); http.enforce_http(false); http.set_connect_timeout(Some(SINK_TIMEOUT)); http.set_nodelay(true); let connector = HttpsConnectorBuilder::new() .with_tls_config(config) .https_or_http() .enable_http1() .wrap_connector(http); Ok(Client::builder(TokioExecutor::new()).build(connector)) } struct FileState { path: PathBuf, file: File, } const PER_VHOST_OPEN_FILES: usize = 64; fn vhost_log_name(vhost: Option<&str>) -> String { let raw = vhost .filter(|value| !value.is_empty()) .unwrap_or("_unmatched"); let mut safe = String::with_capacity(raw.len().min(96)); for ch in raw.chars().take(96) { safe.push( if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') { ch } else { '_' }, ); } if safe.is_empty() { "_unmatched".into() } else { safe } } struct LogstashState { address: String, stream: TcpStream, } async fn sink_loop( mut rx: mpsc::Receiver, settings: Arc>, sent: Arc, errors: Arc, shutdown: CancellationToken, ) { let client = loki_client().ok(); let mut file: Option = None; let mut vhost_files: BTreeMap = BTreeMap::new(); let mut vhost_directory: Option = None; let mut logstash: Option = None; loop { let entry = tokio::select! { _ = shutdown.cancelled() => break, entry = rx.recv() => match entry { Some(entry) => entry, None => break }, }; let config = settings.load_full(); let mut payload = format_entry(&entry, &config.format); redact_value(&mut payload); let mut attempted = false; let mut failed = false; if config.file.enabled { attempted = true; let mut line = serde_json::to_vec(&payload).unwrap_or_default(); line.push(b'\n'); if config.file.per_vhost { file = None; let directory = PathBuf::from(&config.file.directory); if vhost_directory.as_ref() != Some(&directory) { vhost_files.clear(); vhost_directory = Some(directory.clone()); } if tokio::fs::create_dir_all(&directory).await.is_err() { failed = true; } else { let path = directory.join(format!("{}.jsonl", vhost_log_name(entry.vhost.as_deref()))); if !vhost_files.contains_key(&path) { if vhost_files.len() >= PER_VHOST_OPEN_FILES { if let Some(path) = vhost_files.keys().next().cloned() { vhost_files.remove(&path); } } match OpenOptions::new() .create(true) .append(true) .open(&path) .await { Ok(opened) => { vhost_files.insert(path.clone(), opened); } Err(error) => { tracing::warn!(%error, path=%path.display(), "per-vhost access log file unavailable"); failed = true; } } } let write_failed = match vhost_files.get_mut(&path) { Some(opened) => opened.write_all(&line).await.is_err(), None => true, }; if write_failed { failed = true; vhost_files.remove(&path); } } } else { vhost_files.clear(); vhost_directory = None; let path = PathBuf::from(&config.file.path); if file.as_ref().is_none_or(|state| state.path != path) { file = match OpenOptions::new() .create(true) .append(true) .open(&path) .await { Ok(opened) => Some(FileState { path: path.clone(), file: opened, }), Err(error) => { tracing::warn!(%error, path=%path.display(), "access log file unavailable"); None } }; } if let Some(state) = file.as_mut() { if state.file.write_all(&line).await.is_err() { failed = true; file = None; } } else { failed = true; } } } else { file = None; vhost_files.clear(); vhost_directory = None; } if config.logstash.enabled { attempted = true; let address = config.logstash.address.clone(); if logstash .as_ref() .is_none_or(|state| state.address != address) { logstash = match tokio::time::timeout(SINK_TIMEOUT, TcpStream::connect(&address)).await { Ok(Ok(stream)) => Some(LogstashState { address: address.clone(), stream, }), _ => None, }; } let mut line = serde_json::to_vec(&payload).unwrap_or_default(); line.push(b'\n'); if let Some(state) = logstash.as_mut() { if !matches!( tokio::time::timeout(SINK_TIMEOUT, state.stream.write_all(&line)).await, Ok(Ok(())) ) { failed = true; logstash = None; } } else { failed = true; } } else { logstash = None; } if config.loki.enabled { attempted = true; let labels = if config.loki.labels.is_empty() { BTreeMap::from([("job".to_owned(), "rustproxy".to_owned())]) } else { config.loki.labels.clone() }; let body = json!({"streams":[{"stream":labels,"values":[[(entry.time_ms as u128 * 1_000_000).to_string(), payload.to_string()]]}]}); let result = async { let uri: Uri = config.loki.url.parse()?; let mut request = Request::post(uri) .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(&body)?))?; if let Some(env) = &config.loki.bearer_token_env { if let Ok(token) = std::env::var(env) { if !token.is_empty() { request .headers_mut() .insert("authorization", format!("Bearer {token}").parse()?); } } } let client = client.as_ref().context("Loki HTTP client unavailable")?; let response = client.request(request).await?; let status = response.status(); let _ = Limited::new(response.into_body(), 64 * 1024) .collect() .await; ensure!(status.is_success(), "Loki returned {status}"); Ok::<(), anyhow::Error>(()) }; if !matches!(tokio::time::timeout(SINK_TIMEOUT, result).await, Ok(Ok(()))) { failed = true; } } if attempted { if failed { errors.fetch_add(1, Ordering::Relaxed); } else { sent.fetch_add(1, Ordering::Relaxed); } } } } pub fn now_ms() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_millis() as u64 } #[cfg(test)] mod tests { use super::*; fn entry() -> LogEntry { LogEntry { vhost: Some("app".into()), route: Some("root".into()), cache_effective_enabled: Some(true), transform_effective_enabled: Some(false), backend: Some("origin".into()), id: 7, time_ms: 1_700_000_000_000, method: "GET".into(), host: "app.example".into(), path: "/health".into(), http_version: "3", transport: "h3", websocket_bootstrap: Some("h3_extended_connect"), websocket_phase: Some("tunnel_closed"), websocket_error: None, h3_reset_code: None, upstream_status: Some(101), upstream_error: None, client_ip: Some("192.0.2.10".into()), user_agent: Some("RustProxy-Test/1.0".into()), country: Some("PL".into()), status: 200, duration_ms: 12, request_bytes: 10, response_bytes: 42, outcome: "ok", cache: "HIT", cache_reason: "cache_hit", } } #[test] fn json_format_can_map_and_add_static_fields() { let mut format = JsonFormat::default(); format.fields.insert("http_status".into(), "status".into()); format.fields.insert("site".into(), "vhost".into()); format.fields.insert("route".into(), "route".into()); format.fields.insert("ua".into(), "user_agent".into()); format.fields.insert("geo_country".into(), "country".into()); format .fields .insert("cache_enabled".into(), "cache_effective_enabled".into()); format.fields.insert( "transform_enabled".into(), "transform_effective_enabled".into(), ); format.static_fields.insert("service".into(), "edge".into()); let mut settings = Settings::default(); settings.format = format.clone(); settings.validate().unwrap(); let value = format_entry(&entry(), &format); assert_eq!(value["http_status"], 200); assert_eq!(value["site"], "app"); assert_eq!(value["route"], "root"); assert_eq!(value["ua"], "RustProxy-Test/1.0"); assert_eq!(value["geo_country"], "PL"); assert_eq!(value["cache_enabled"], true); assert_eq!(value["transform_enabled"], false); assert_eq!(value["service"], "edge"); assert!(value.get("method").is_none()); } #[test] fn logging_validation_rejects_credentials_unknown_fields_and_bad_endpoints() { let mut settings = Settings::default(); settings.loki.enabled = true; settings.loki.url = "https://user:secret@example.invalid/loki/api/v1/push".into(); assert!(settings.validate().is_err()); let mut settings = Settings::default(); settings.logstash.enabled = true; settings.logstash.address = "missing-port.example".into(); assert!(settings.validate().is_err()); let mut settings = Settings::default(); settings .format .fields .insert("status".into(), "does_not_exist".into()); assert!(settings.validate().is_err()); } #[test] fn per_vhost_file_mode_has_safe_names_and_requires_directory() { assert_eq!(vhost_log_name(Some("app-1")), "app-1"); assert_eq!(vhost_log_name(None), "_unmatched"); assert_eq!(vhost_log_name(Some("bad/name")), "bad_name"); let mut settings = Settings::default(); settings.file.enabled = true; settings.file.per_vhost = true; settings.file.directory.clear(); assert!(settings.validate().is_err()); } #[test] fn capture_defaults_keep_geoip_off_request_path() { let settings = Settings::default(); assert!(settings.capture_user_agent); assert!(!settings.capture_country); } #[test] fn default_json_format_keeps_standard_entry() { let value = format_entry(&entry(), &JsonFormat::default()); assert_eq!(value["method"], "GET"); assert_eq!(value["cache"], "HIT"); assert_eq!(value["response_bytes"], 42); } #[test] fn error_log_defaults_off_and_redacts_common_secret_shapes() { let settings = ErrorLogSettings::default(); assert!(!settings.enabled); assert_eq!(settings.format, ErrorLogFormat::Json); let text = redact_text( r#"Authorization: Bearer topsecret url=https://x.invalid/?api_token=abc password=hunter2 {\"private_key\":\"pem-data\"}"#, ); assert!(!text.contains("topsecret")); assert!(!text.contains("api_token=abc")); assert!(!text.contains("hunter2")); assert!(!text.contains("pem-data")); } #[test] fn error_json_is_one_record_and_reserved_fields_cannot_be_overwritten() { let event = ErrorEvent { timestamp: "2026-10-03T12:34:56.123Z".into(), target: "rust_proxy::http".into(), message: "upstream failed\nsecond line".into(), fields: BTreeMap::from([ ("message".into(), "attacker override".into()), ("request_id".into(), "abc".into()), ]), }; let line = format_error_event(&event, ErrorLogFormat::Json); assert_eq!(line.iter().filter(|byte| **byte == b'\n').count(), 1); let value: Value = serde_json::from_slice(&line).unwrap(); assert_eq!(value["message"], "upstream failed\nsecond line"); assert_eq!(value["request_id"], "abc"); } #[test] fn retained_history_is_larger_than_the_live_snapshot_but_still_bounded() { let logs = Logs::default(); for id in 0..(LIVE_WINDOW + 80) { let mut item = entry(); item.id = id as u64; logs.push(item); } assert_eq!(logs.recent_limit(LIVE_WINDOW).len(), LIVE_WINDOW); assert_eq!(logs.recent().len(), LIVE_WINDOW + 80); assert_eq!( logs.recent_limit(HISTORY_CAPACITY + 1).len(), LIVE_WINDOW + 80 ); } }