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, 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, "event" | "protocol_error" | "reason" | "error_detail" | "error" | "local_addr" | "tls_sni" | "sni" | "authority" | "matched_by" | "vhost" | "route" | "cache_effective_enabled" | "transform_effective_enabled" | "backend" | "id" | "request_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, level: 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(()); } /// All diagnostics go to stderr when file logging is disabled. With a file /// configured, warnings/errors go only to that file (unless the sink fails). /// Informational startup messages remain on the console. pub fn console_enabled_for(&self, level: &Level) -> bool { !matches!(*level, Level::WARN | Level::ERROR) || !self.settings.load().enabled } fn push(&self, event: ErrorEvent) { let config = self.settings.load(); if !config.enabled { return; } if let Err(error) = self.sender.try_send(event) { self.dropped.fetch_add(1, Ordering::Relaxed); // An overloaded/closed sink must not silently swallow a diagnostic // suppressed by the console filter. This emergency path is bounded // to one event and only runs on a failed non-blocking enqueue. write_error_fallback(&error.into_inner(), config.format); } } 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 !matches!(*event.metadata().level(), Level::ERROR | Level::WARN) { 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(), level: event.metadata().level().as_str().to_owned(), 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, }