This commit is contained in:
Mateusz Gruszczyński committed 2026-10-09 17:09:41 +02:00
1 parent 3e33982cd3
commit ad95bfb050
27 files changed
+309 -80

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.29.5",
"version": "0.29.6",
"description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust."
},
"servers": [
+1
View File
@@ -2554,6 +2554,7 @@
<h3 class="subheading" data-i18n="errorLogTitle">Error file</h3>
<label class="check"><input id="error-log-enabled" type="checkbox" /><span
data-i18n="enabled">Enabled</span></label>
<p class="fine" data-i18n="errorLogRoutingHelp">Warnings, errors and worker panics go to this file when enabled, or to the console/systemd journal when disabled. File failures fall back to the console.</p>
<div class="field-grid">
<label><span data-i18n="errorLogPath">Error log path</span><input
id="error-log-path" maxlength="1024"
+33 -5
View File
@@ -161,7 +161,7 @@ fn format_error_event(event: &ErrorEvent, format: ErrorLogFormat) -> Vec<u8> {
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("level".into(), Value::String(event.level.clone()));
object.insert("target".into(), Value::String(event.target.clone()));
object.insert("message".into(), Value::String(event.message.clone()));
for (key, value) in &event.fields {
@@ -176,8 +176,9 @@ fn format_error_event(event: &ErrorEvent, format: ErrorLogFormat) -> Vec<u8> {
ErrorLogFormat::Raw => {
let one_line = |value: &str| value.replace('\r', "\\r").replace('\n', "\\n");
let mut line = format!(
"{} ERROR {} {}",
"{} {} {} {}",
event.timestamp,
event.level,
event.target,
one_line(&event.message)
);
@@ -193,6 +194,21 @@ fn format_error_event(event: &ErrorEvent, format: ErrorLogFormat) -> Vec<u8> {
}
}
fn write_error_fallback(event: &ErrorEvent, format: ErrorLogFormat) {
// File unavailable or the bounded queue is full: preserve the redacted
// diagnostic on stderr rather than dropping it into a black hole.
let line = format_error_event(event, format);
let mut stderr = io::stderr().lock();
let _ = stderr.write_all(&line);
}
fn warn_error_sink_failure(path: &std::path::Path, error: &dyn std::fmt::Display, action: &str) {
let path = redact_text(&path.display().to_string());
let error = redact_text(&error.to_string());
let mut stderr = io::stderr().lock();
let _ = writeln!(stderr, "RustProxy error log {action} ({path}): {error}; using stderr fallback");
}
async fn error_log_loop(
mut receiver: mpsc::Receiver<ErrorEvent>,
settings: Arc<ArcSwap<ErrorLogSettings>>,
@@ -232,6 +248,9 @@ async fn error_log_loop(
file = None;
open_path = None;
retry_after = None;
// A queued event may outlive a live settings change. It was
// suppressed on the console when queued; do not lose it now.
write_error_fallback(&event, config.format);
continue;
}
let path = PathBuf::from(&config.path);
@@ -250,24 +269,33 @@ async fn error_log_loop(
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");
warn_error_sink_failure(&path, &error, "open failed");
last_warning = Some(Instant::now());
}
}
}
}
let Some(opened) = file.as_mut() else {
write_error_fallback(&event, config.format);
continue;
};
let line = format_error_event(&event, config.format);
if let Err(error) = opened.write_all(&line).await {
// Tokio's File buffers writes; flush so diagnostics are visible to
// log readers promptly and a later shutdown cannot strand a record.
let write = async {
opened.write_all(&line).await?;
opened.flush().await
}
.await;
if let Err(error) = write {
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");
warn_error_sink_failure(&path, &error, "write failed");
last_warning = Some(Instant::now());
}
write_error_fallback(&event, config.format);
} else {
written.fetch_add(1, Ordering::Relaxed);
}
+18 -2
View File
@@ -328,6 +328,7 @@ pub struct ErrorLogStats {
#[derive(Debug)]
struct ErrorEvent {
timestamp: String,
level: String,
target: String,
message: String,
fields: BTreeMap<String, String>,
@@ -369,9 +370,23 @@ impl ErrorLogController {
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) {
if self.settings.load().enabled && self.sender.try_send(event).is_err() {
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 {
@@ -451,7 +466,7 @@ impl tracing::field::Visit for EventVisitor {
}
impl<S: Subscriber> Layer<S> for ErrorLogLayer {
fn on_event(&self, event: &Event<'_>, _ctx: LayerContext<'_, S>) {
if *event.metadata().level() != Level::ERROR {
if !matches!(*event.metadata().level(), Level::ERROR | Level::WARN) {
return;
}
let mut visitor = EventVisitor::default();
@@ -466,6 +481,7 @@ impl<S: Subscriber> Layer<S> for ErrorLogLayer {
}
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,
+50
View File
@@ -1,5 +1,6 @@
mod tests {
use super::*;
use tracing_subscriber::prelude::*;
fn entry() -> LogEntry {
LogEntry {
@@ -212,6 +213,7 @@ mod tests {
fn error_json_is_one_record_and_reserved_fields_cannot_be_overwritten() {
let event = ErrorEvent {
timestamp: "2026-10-03T12:34:56.123Z".into(),
level: "ERROR".into(),
target: "rust_proxy::http".into(),
message: "upstream failed\nsecond line".into(),
fields: BTreeMap::from([
@@ -224,6 +226,54 @@ mod tests {
let value: Value = serde_json::from_slice(&line).unwrap();
assert_eq!(value["message"], "upstream failed\nsecond line");
assert_eq!(value["request_id"], "abc");
assert_eq!(value["level"], "ERROR");
}
#[tokio::test]
async fn diagnostics_switch_between_console_and_file_without_restart() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("error.log");
let controller = ErrorLogController::start();
assert!(controller.console_enabled_for(&Level::WARN));
assert!(controller.console_enabled_for(&Level::ERROR));
controller.configure(ErrorLogSettings {
enabled: true,
path: path.to_string_lossy().into_owned(),
format: ErrorLogFormat::Json,
});
assert!(!controller.console_enabled_for(&Level::WARN));
assert!(!controller.console_enabled_for(&Level::ERROR));
assert!(controller.console_enabled_for(&Level::INFO));
let subscriber = tracing_subscriber::registry().with(ErrorLogLayer::new(controller.clone()));
tracing::subscriber::with_default(subscriber, || {
tracing::warn!(target: "rust_proxy::tests", "PHP-FPM FastCGI stderr");
tracing::error!(target: "rust_proxy::tests", "RustProxy worker panicked");
});
let content = tokio::time::timeout(Duration::from_secs(5), async {
loop {
if let Ok(content) = tokio::fs::read_to_string(&path).await {
if content.lines().count() == 2 {
break content;
}
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await
.expect("diagnostics were not written to the configured error file");
let entries: Vec<Value> = content
.lines()
.map(|line| serde_json::from_str(line).unwrap())
.collect();
assert_eq!(entries[0]["level"], "WARN");
assert_eq!(entries[1]["level"], "ERROR");
controller.configure(ErrorLogSettings::default());
assert!(controller.console_enabled_for(&Level::WARN));
assert!(controller.console_enabled_for(&Level::ERROR));
}
#[test]
+29 -1
View File
@@ -273,6 +273,7 @@ fn resolve_installed_data_dir(configured: PathBuf, explicitly_configured: bool)
async fn application() -> Result<()> {
let error_log = rust_proxy::logging::ErrorLogController::start();
rust_proxy::logging::install_error_log_controller(error_log.clone())?;
let console_router = error_log.clone();
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "rust_proxy=info,warn".into());
tracing_subscriber::registry()
@@ -280,10 +281,37 @@ async fn application() -> Result<()> {
.with(
tracing_subscriber::fmt::layer()
.with_target(false)
.with_writer(rust_proxy::logging::RedactingStderr),
.with_writer(rust_proxy::logging::RedactingStderr)
.with_filter(tracing_subscriber::filter::filter_fn(move |metadata| {
console_router.console_enabled_for(metadata.level())
})),
)
.with(rust_proxy::logging::ErrorLogLayer::new(error_log))
.init();
// Rust's default panic hook writes directly to stderr, bypassing tracing
// and the configured error file. Route worker panics through the same
// WARN/ERROR sink as other diagnostics without duplicate journald output.
std::panic::set_hook(Box::new(|info| {
let payload = if let Some(value) = info.payload().downcast_ref::<&str>() {
(*value).to_owned()
} else if let Some(value) = info.payload().downcast_ref::<String>() {
value.clone()
} else {
"non-string panic payload".to_owned()
};
let location = info.location().map_or_else(
|| "unknown".to_owned(),
|place| format!("{}:{}:{}", place.file(), place.line(), place.column()),
);
let thread = std::thread::current();
tracing::error!(
target: "rust_proxy::panic",
thread = thread.name().unwrap_or("unnamed"),
location = %location,
panic = %payload,
"RustProxy worker panicked"
);
}));
let matches = Cli::command().get_matches();
let explicit = |matches: &clap::ArgMatches, id: &str| {
matches.value_source(id).is_some_and(|source| {
+1 -1
View File
@@ -9,7 +9,7 @@ use super::{
use anyhow::{ensure, Context, Result};
use axum::{body::Body, response::Response};
use bytes::Bytes;
use futures_util::stream;
use futures_util::{stream, StreamExt};
use http::{
header::{ALLOW, CONTENT_LENGTH, CONTENT_TYPE},
HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri,
+13 -3
View File
@@ -178,9 +178,7 @@ async fn php_response_inner(
permit,
));
let mut response = Response::new(if send_body {
Body::from_stream(stream::unfold(rx, |mut rx| async move {
rx.recv().await.map(|item| (item, rx))
}))
Body::from_stream(fastcgi_body_stream(rx))
} else {
Body::empty()
});
@@ -189,6 +187,18 @@ async fn php_response_inner(
response
}
// tower-http's compression encoder may poll a body again after observing EOF.
// An unfused `stream::unfold` panics on that second poll, aborting the response
// before the encoder can emit its final gzip/br/zstd bytes.
pub(super) fn fastcgi_body_stream(
rx: mpsc::Receiver<io::Result<Bytes>>,
) -> impl futures_util::Stream<Item = io::Result<Bytes>> {
stream::unfold(rx, |mut rx| async move {
rx.recv().await.map(|item| (item, rx))
})
.fuse()
}
pub(super) fn method_not_allowed_response() -> Response {
let mut response = status_response(
StatusCode::METHOD_NOT_ALLOWED,
+5 -3
View File
@@ -1,9 +1,11 @@
use super::*;
use super::fastcgi::{
fastcgi_params, parse_cgi_headers, read_fcgi_record, write_fcgi_record, FCGI_END_REQUEST,
FCGI_PARAMS, FCGI_STDIN, FCGI_STDOUT,
fastcgi_body_stream, fastcgi_params, parse_cgi_headers, read_fcgi_record, write_fcgi_record,
FCGI_END_REQUEST, FCGI_PARAMS, FCGI_STDIN, FCGI_STDOUT,
};
use axum::{body::Body, response::Response};
use bytes::Bytes;
use futures_util::StreamExt;
use http::{
header::{
ACCEPT_RANGES, ALLOW, CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_ENCODING, CONTENT_LENGTH,
@@ -15,7 +17,7 @@ use http::{
use http_body_util::BodyExt;
use std::{collections::BTreeMap, path::Path, time::Duration};
use tempfile::TempDir;
use tokio::{io::AsyncWriteExt, time::Instant};
use tokio::{io::AsyncWriteExt, sync::mpsc, time::Instant};
fn service(root: &Path, listing: bool) -> CompiledService {
Service {
+60
View File
@@ -20,6 +20,66 @@ async fn fastcgi_fixture(response_body: &'static [u8]) -> std::net::SocketAddr {
address
}
#[tokio::test]
async fn fastcgi_stream_can_be_polled_repeatedly_after_eof() {
let (tx, rx) = mpsc::channel(1);
tx.send(Ok(Bytes::from_static(b"data"))).await.unwrap();
drop(tx);
let mut body = Box::pin(fastcgi_body_stream(rx));
assert_eq!(body.next().await.unwrap().unwrap(), Bytes::from_static(b"data"));
assert!(body.next().await.is_none());
// Without `fuse` this panics inside futures-util::stream::unfold.
assert!(body.next().await.is_none());
}
#[tokio::test]
async fn php_fpm_gzip_response_finishes_after_fastcgi_eof() {
use std::io::Read;
let expected = b"FastCGI compressed response regression: 0123456789 0123456789 0123456789";
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("index.php"), b"<?php echo 'placeholder';").unwrap();
let address = fastcgi_fixture(expected).await;
let configured = Service {
id: "php-compression-regression".into(),
name: "PHP compression regression".into(),
document_root: tmp.path().to_path_buf(),
index: vec!["index.php".into()],
php: Php {
enabled: true,
endpoint: format!("tcp:{address}"),
connect_timeout_ms: 500,
request_timeout_ms: 5_000,
max_concurrent_requests: 1,
..Default::default()
},
..Default::default()
};
configured.validate().unwrap();
let service = configured.compile().unwrap();
let response = serve_request(&service, &request(Method::GET, "/"), "/").await;
assert_eq!(response.status(), StatusCode::OK);
let mut accept = HeaderMap::new();
accept.insert("accept-encoding", "gzip".parse().unwrap());
let settings = crate::compression::Settings {
gzip: true,
br: false,
zstd: false,
deflate: false,
min_bytes: 32,
};
let response = crate::compression::encode(response, &accept, &Method::GET, &settings).await;
assert_eq!(response.headers()[CONTENT_ENCODING], "gzip");
let compressed = response.into_body().collect().await.unwrap().to_bytes();
let mut decoded = Vec::new();
flate2::read::GzDecoder::new(compressed.as_ref())
.read_to_end(&mut decoded)
.unwrap();
assert_eq!(decoded.as_slice(), expected);
}
#[tokio::test]
async fn php_fpm_executes_index_and_try_files_front_controller_without_source_disclosure() {
let tmp = TempDir::new().unwrap();