This commit is contained in:
gru committed 2026-10-03 00:27:09 +01:00
commit 5e23e9af02
240 files changed
+99957

No files matched your search

+731
View File
@@ -0,0 +1,731 @@
//! 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},
path::PathBuf,
sync::{
atomic::{AtomicU64, Ordering},
Arc, Mutex,
},
time::{Duration, SystemTime, UNIX_EPOCH},
};
use tokio::{
fs::{File, OpenOptions},
io::AsyncWriteExt,
net::TcpStream,
sync::{broadcast, mpsc},
};
use tokio_util::sync::CancellationToken;
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 `<vhost>.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<String>,
pub labels: BTreeMap<String, String>,
}
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<String, String>,
pub static_fields: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Settings {
pub file: FileSink,
pub logstash: LogstashSink,
pub loki: LokiSink,
pub format: JsonFormat,
}
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::<http::uri::Authority>()
.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"
| "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"
| "status"
| "duration_ms"
| "request_bytes"
| "response_bytes"
| "outcome"
| "cache"
| "cache_reason"
)
}
#[derive(Debug, Clone, Serialize)]
pub struct LogEntry {
pub vhost: Option<String>,
pub route: Option<String>,
pub cache_effective_enabled: Option<bool>,
pub transform_effective_enabled: Option<bool>,
pub backend: Option<String>,
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<u16>,
pub upstream_error: Option<String>,
pub client_ip: Option<String>,
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<VecDeque<LogEntry>>,
pub dropped: AtomicU64,
settings: Arc<ArcSwap<Settings>>,
live: broadcast::Sender<LogEntry>,
sink: mpsc::Sender<LogEntry>,
sink_dropped: Arc<AtomicU64>,
sink_sent: Arc<AtomicU64>,
sink_errors: Arc<AtomicU64>,
}
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<Self> {
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 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<LogEntry> {
self.live.subscribe()
}
pub fn recent(&self) -> Vec<LogEntry> {
self.recent_limit(HISTORY_CAPACITY)
}
pub fn recent_limit(&self, limit: usize) -> Vec<LogEntry> {
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),
"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),
"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<hyper_rustls::HttpsConnector<HttpConnector>, Body>;
fn loki_client() -> Result<LokiClient> {
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<LogEntry>,
settings: Arc<ArcSwap<Settings>>,
sent: Arc<AtomicU64>,
errors: Arc<AtomicU64>,
shutdown: CancellationToken,
) {
let client = loki_client().ok();
let mut file: Option<FileState> = None;
let mut vhost_files: BTreeMap<PathBuf, File> = BTreeMap::new();
let mut vhost_directory: Option<PathBuf> = None;
let mut logstash: Option<LogstashState> = 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 payload = format_entry(&entry, &config.format);
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()),
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.static_fields.insert("service".into(), "edge".into());
let value = format_entry(&entry(), &format);
assert_eq!(value["http_status"], 200);
assert_eq!(value["site"], "app");
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 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 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);
}
}