v0.26.0
This commit is contained in:
1 parent
d168ee2afa
commit
ddc5c6593f
70 files changed
+2293
-594
No files matched your search
@@ -94,6 +94,11 @@ pub(super) static READER_DOCUMENTS: &[(&str, &str, &str)] = &[
|
||||
"/docs/ERROR_LOGGING.md"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"docs/FILE_LOGS.md",
|
||||
"guides",
|
||||
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/FILE_LOGS.md")),
|
||||
),
|
||||
(
|
||||
"docs/HTTP3.md",
|
||||
"technical",
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
const FILE_LOG_SCAN_BYTES_PER_SOURCE: u64 = 2 * 1024 * 1024;
|
||||
const FILE_LOG_SCAN_BYTES_TOTAL: u64 = 8 * 1024 * 1024;
|
||||
const FILE_LOG_SCAN_LINES: usize = 20_000;
|
||||
const FILE_LOG_MAX_SOURCES: usize = 32;
|
||||
const FILE_LOG_MAX_LIMIT: usize = 500;
|
||||
const FILE_LOG_MAX_OFFSET: usize = 10_000;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FileLogSource {
|
||||
id: String,
|
||||
label: String,
|
||||
kind: &'static str,
|
||||
path: std::path::PathBuf,
|
||||
enabled: bool,
|
||||
format: &'static str,
|
||||
exists: bool,
|
||||
size: u64,
|
||||
modified_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
struct FileLogQuery {
|
||||
source: Option<String>,
|
||||
q: Option<String>,
|
||||
event: Option<String>,
|
||||
status_class: Option<String>,
|
||||
client_ip: Option<String>,
|
||||
vhost: Option<String>,
|
||||
offset: Option<usize>,
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
fn file_modified_ms(metadata: &std::fs::Metadata) -> u64 {
|
||||
metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|duration| duration.as_millis() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn source_from_path(
|
||||
id: String,
|
||||
label: String,
|
||||
kind: &'static str,
|
||||
path: std::path::PathBuf,
|
||||
enabled: bool,
|
||||
format: &'static str,
|
||||
) -> FileLogSource {
|
||||
let metadata = std::fs::symlink_metadata(&path).ok();
|
||||
let safe_regular = metadata
|
||||
.as_ref()
|
||||
.is_some_and(|meta| meta.file_type().is_file() && !meta.file_type().is_symlink());
|
||||
FileLogSource {
|
||||
id,
|
||||
label,
|
||||
kind,
|
||||
path,
|
||||
enabled,
|
||||
format,
|
||||
exists: safe_regular,
|
||||
size: metadata
|
||||
.as_ref()
|
||||
.filter(|_| safe_regular)
|
||||
.map_or(0, |m| m.len()),
|
||||
modified_ms: metadata
|
||||
.as_ref()
|
||||
.filter(|_| safe_regular)
|
||||
.map_or(0, file_modified_ms),
|
||||
}
|
||||
}
|
||||
|
||||
fn configured_file_log_sources(config: &crate::config::Config) -> Vec<FileLogSource> {
|
||||
let mut sources = Vec::new();
|
||||
if config.logging.file.per_vhost {
|
||||
let directory = std::path::PathBuf::from(&config.logging.file.directory);
|
||||
let directory_safe = std::fs::symlink_metadata(&directory)
|
||||
.ok()
|
||||
.is_some_and(|meta| meta.file_type().is_dir() && !meta.file_type().is_symlink());
|
||||
if directory_safe {
|
||||
let mut entries = std::fs::read_dir(&directory)
|
||||
.ok()
|
||||
.into_iter()
|
||||
.flat_map(|iter| iter.filter_map(|entry| entry.ok()))
|
||||
.filter_map(|entry| {
|
||||
let path = entry.path();
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
if !name.ends_with(".jsonl") || name.len() > 160 {
|
||||
return None;
|
||||
}
|
||||
let file_type = entry.file_type().ok()?;
|
||||
if !file_type.is_file() || file_type.is_symlink() {
|
||||
return None;
|
||||
}
|
||||
let meta = entry.metadata().ok()?;
|
||||
Some((name, path, file_modified_ms(&meta)))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
entries.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.0.cmp(&b.0)));
|
||||
entries.truncate(256);
|
||||
for (name, path, _) in entries {
|
||||
let id = format!("vhost:{}", name.trim_end_matches(".jsonl"));
|
||||
sources.push(source_from_path(
|
||||
id,
|
||||
name,
|
||||
"access",
|
||||
path,
|
||||
config.logging.file.enabled,
|
||||
"JSONL",
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sources.push(source_from_path(
|
||||
"access".into(),
|
||||
"Access log".into(),
|
||||
"access",
|
||||
std::path::PathBuf::from(&config.logging.file.path),
|
||||
config.logging.file.enabled,
|
||||
"JSONL",
|
||||
));
|
||||
}
|
||||
sources.push(source_from_path(
|
||||
"error".into(),
|
||||
"Error log".into(),
|
||||
"error",
|
||||
std::path::PathBuf::from(&config.error_log.path),
|
||||
config.error_log.enabled,
|
||||
match config.error_log.format {
|
||||
crate::logging::ErrorLogFormat::Json => "JSONL",
|
||||
crate::logging::ErrorLogFormat::Raw => "RAW",
|
||||
},
|
||||
));
|
||||
sources
|
||||
}
|
||||
|
||||
fn file_log_sources_json(sources: &[FileLogSource]) -> Value {
|
||||
Value::Array(
|
||||
sources
|
||||
.iter()
|
||||
.map(|source| {
|
||||
json!({
|
||||
"id": source.id,
|
||||
"label": source.label,
|
||||
"kind": source.kind,
|
||||
"path": source.path.to_string_lossy(),
|
||||
"enabled": source.enabled,
|
||||
"format": source.format,
|
||||
"exists": source.exists,
|
||||
"size": source.size,
|
||||
"modified_ms": source.modified_ms,
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
async fn file_logs(State(state): State<Arc<App>>) -> Result<Json<Value>, ApiError> {
|
||||
let config = state.config.load().envelope.config.clone();
|
||||
let sources = tokio::task::spawn_blocking(move || configured_file_log_sources(&config))
|
||||
.await
|
||||
.map_err(|_| {
|
||||
ApiError(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"file log inventory worker failed".into(),
|
||||
)
|
||||
})?;
|
||||
Ok(Json(json!({
|
||||
"sources": file_log_sources_json(&sources),
|
||||
"limits": {
|
||||
"max_limit": FILE_LOG_MAX_LIMIT,
|
||||
"max_offset": FILE_LOG_MAX_OFFSET,
|
||||
"scan_bytes_per_source": FILE_LOG_SCAN_BYTES_PER_SOURCE,
|
||||
"scan_bytes_total": FILE_LOG_SCAN_BYTES_TOTAL,
|
||||
"scan_lines": FILE_LOG_SCAN_LINES,
|
||||
"max_sources_per_query": FILE_LOG_MAX_SOURCES,
|
||||
}
|
||||
})))
|
||||
}
|
||||
|
||||
fn open_file_log(path: &std::path::Path) -> std::io::Result<std::fs::File> {
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.read(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.custom_flags(libc::O_NOFOLLOW);
|
||||
}
|
||||
let file = options.open(path)?;
|
||||
let metadata = file.metadata()?;
|
||||
if !metadata.file_type().is_file() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"configured log path is not a regular file",
|
||||
));
|
||||
}
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
fn read_tail(
|
||||
source: &FileLogSource,
|
||||
budget: u64,
|
||||
line_budget: usize,
|
||||
) -> std::io::Result<(Vec<String>, u64, bool)> {
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
let mut file = open_file_log(&source.path)?;
|
||||
let length = file.metadata()?.len();
|
||||
let bytes = length.min(budget).min(FILE_LOG_SCAN_BYTES_PER_SOURCE);
|
||||
let start = length.saturating_sub(bytes);
|
||||
file.seek(SeekFrom::Start(start))?;
|
||||
let mut content = Vec::with_capacity(bytes as usize);
|
||||
file.take(bytes).read_to_end(&mut content)?;
|
||||
let truncated = start > 0;
|
||||
if truncated {
|
||||
if let Some(index) = content.iter().position(|byte| *byte == b'\n') {
|
||||
content.drain(..=index);
|
||||
} else {
|
||||
content.clear();
|
||||
}
|
||||
}
|
||||
let text = String::from_utf8_lossy(&content);
|
||||
let mut iter = text.lines().rev();
|
||||
let lines = iter
|
||||
.by_ref()
|
||||
.take(line_budget)
|
||||
.map(str::to_owned)
|
||||
.collect::<Vec<_>>();
|
||||
Ok((lines, bytes, truncated || iter.next().is_some()))
|
||||
}
|
||||
|
||||
fn normalized_record(source: &FileLogSource, line: &str) -> Value {
|
||||
let mut value = serde_json::from_str::<Value>(line).unwrap_or_else(|_| json!({"raw": line}));
|
||||
if !value.is_object() {
|
||||
value = json!({"value": value});
|
||||
}
|
||||
let object = value.as_object_mut().expect("record normalized to object");
|
||||
object.entry("event").or_insert_with(|| {
|
||||
Value::String(
|
||||
if source.kind == "error" {
|
||||
"error"
|
||||
} else {
|
||||
"request"
|
||||
}
|
||||
.into(),
|
||||
)
|
||||
});
|
||||
object.insert("_source".into(), Value::String(source.id.clone()));
|
||||
object.insert("_source_kind".into(), Value::String(source.kind.into()));
|
||||
value
|
||||
}
|
||||
|
||||
fn contains_ci(value: Option<&Value>, needle: &str) -> bool {
|
||||
value
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|text| text.to_ascii_lowercase().contains(needle))
|
||||
}
|
||||
|
||||
fn record_matches(value: &Value, query: &FileLogQuery, q: &str) -> bool {
|
||||
let Some(object) = value.as_object() else {
|
||||
return false;
|
||||
};
|
||||
if !q.is_empty() && !value.to_string().to_ascii_lowercase().contains(q) {
|
||||
return false;
|
||||
}
|
||||
if let Some(event) = query
|
||||
.event
|
||||
.as_deref()
|
||||
.filter(|v| !v.is_empty() && *v != "all")
|
||||
{
|
||||
if object.get("event").and_then(Value::as_str) != Some(event) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(class) = query
|
||||
.status_class
|
||||
.as_deref()
|
||||
.filter(|v| !v.is_empty() && *v != "all")
|
||||
{
|
||||
let Some(status) = object.get("status").and_then(Value::as_u64) else {
|
||||
return false;
|
||||
};
|
||||
let expected = class
|
||||
.as_bytes()
|
||||
.first()
|
||||
.and_then(|v| v.checked_sub(b'0'))
|
||||
.map(u64::from);
|
||||
if expected != Some(status / 100) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(client) = query.client_ip.as_deref().filter(|v| !v.is_empty()) {
|
||||
if !contains_ci(object.get("client_ip"), &client.to_ascii_lowercase()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(vhost) = query.vhost.as_deref().filter(|v| !v.is_empty()) {
|
||||
if !contains_ci(object.get("vhost"), &vhost.to_ascii_lowercase()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn validate_file_log_query(query: &FileLogQuery) -> Result<(), ApiError> {
|
||||
let text_ok = |value: &Option<String>, max: usize| {
|
||||
value
|
||||
.as_ref()
|
||||
.is_none_or(|v| v.len() <= max && !v.chars().any(char::is_control))
|
||||
};
|
||||
if !text_ok(&query.source, 192)
|
||||
|| !text_ok(&query.q, 256)
|
||||
|| !text_ok(&query.client_ip, 128)
|
||||
|| !text_ok(&query.vhost, 256)
|
||||
|| query.offset.unwrap_or(0) > FILE_LOG_MAX_OFFSET
|
||||
|| query
|
||||
.limit
|
||||
.is_some_and(|v| v == 0 || v > FILE_LOG_MAX_LIMIT)
|
||||
|| query.event.as_ref().is_some_and(|v| {
|
||||
!matches!(
|
||||
v.as_str(),
|
||||
"" | "all" | "request" | "protocol_error" | "error"
|
||||
)
|
||||
})
|
||||
|| query.status_class.as_ref().is_some_and(|v| {
|
||||
!matches!(
|
||||
v.as_str(),
|
||||
"" | "all" | "1xx" | "2xx" | "3xx" | "4xx" | "5xx"
|
||||
)
|
||||
})
|
||||
{
|
||||
return Err(ApiError(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid file log filter".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn file_logs_query(
|
||||
State(state): State<Arc<App>>,
|
||||
Query(query): Query<FileLogQuery>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
validate_file_log_query(&query)?;
|
||||
let config = state.config.load().envelope.config.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut sources = configured_file_log_sources(&config);
|
||||
let requested = query.source.as_deref().unwrap_or("all");
|
||||
if requested != "all" {
|
||||
sources.retain(|source| source.id == requested);
|
||||
if sources.is_empty() {
|
||||
return Err(ApiError(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"unknown file log source".into(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
sources.sort_by(|a, b| b.modified_ms.cmp(&a.modified_ms));
|
||||
sources.truncate(FILE_LOG_MAX_SOURCES);
|
||||
}
|
||||
let q = query.q.clone().unwrap_or_default().to_ascii_lowercase();
|
||||
let offset = query.offset.unwrap_or(0);
|
||||
let limit = query.limit.unwrap_or(100).clamp(1, FILE_LOG_MAX_LIMIT);
|
||||
let mut remaining_bytes = FILE_LOG_SCAN_BYTES_TOTAL;
|
||||
let mut scanned_bytes = 0u64;
|
||||
let mut remaining_lines = FILE_LOG_SCAN_LINES;
|
||||
let mut scanned_lines = 0usize;
|
||||
let mut truncated = false;
|
||||
let mut records = Vec::new();
|
||||
for (index, source) in sources.iter().enumerate() {
|
||||
if remaining_bytes == 0 || remaining_lines == 0 {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
let sources_left = sources.len().saturating_sub(index).max(1);
|
||||
let line_budget = (remaining_lines / sources_left).max(1);
|
||||
match read_tail(source, remaining_bytes, line_budget) {
|
||||
Ok((lines, bytes, source_truncated)) => {
|
||||
remaining_bytes = remaining_bytes.saturating_sub(bytes);
|
||||
remaining_lines = remaining_lines.saturating_sub(lines.len());
|
||||
scanned_bytes += bytes;
|
||||
scanned_lines += lines.len();
|
||||
truncated |= source_truncated;
|
||||
for line in lines {
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let value = normalized_record(source, &line);
|
||||
if record_matches(&value, &query, &q) {
|
||||
records.push(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(_) => truncated = true,
|
||||
}
|
||||
}
|
||||
records.sort_by(|a, b| {
|
||||
let time = |value: &Value| value.get("time_ms").and_then(Value::as_u64).unwrap_or(0);
|
||||
time(b).cmp(&time(a))
|
||||
});
|
||||
let total = records.len();
|
||||
let page = records
|
||||
.into_iter()
|
||||
.skip(offset)
|
||||
.take(limit)
|
||||
.collect::<Vec<_>>();
|
||||
Ok(Json(json!({
|
||||
"entries": page,
|
||||
"total": total,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"truncated": truncated,
|
||||
"scanned_bytes": scanned_bytes,
|
||||
"scanned_lines": scanned_lines,
|
||||
"sources": file_log_sources_json(&sources),
|
||||
})))
|
||||
})
|
||||
.await
|
||||
.map_err(|_| {
|
||||
ApiError(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"file log query worker failed".into(),
|
||||
)
|
||||
})?
|
||||
}
|
||||
@@ -5,6 +5,7 @@ include!("access.rs");
|
||||
include!("session.rs");
|
||||
include!("status.rs");
|
||||
include!("logging_api.rs");
|
||||
include!("file_logs.rs");
|
||||
include!("operations.rs");
|
||||
include!("certificates.rs");
|
||||
include!("tail.rs");
|
||||
|
||||
+339
-3
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "RustProxy Administration API",
|
||||
"version": "0.25.7",
|
||||
"version": "0.26.0",
|
||||
"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": [
|
||||
@@ -4799,6 +4799,111 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"FileLogSource": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"access",
|
||||
"error"
|
||||
]
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Configured server-side path. Clients cannot supply filesystem paths."
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"JSONL",
|
||||
"RAW"
|
||||
]
|
||||
},
|
||||
"exists": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"size": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"modified_ms": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"FileLogsInventory": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sources": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/FileLogSource"
|
||||
}
|
||||
},
|
||||
"limits": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"FileLogsQuery": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"entries": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"total": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"offset": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"truncated": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"scanned_bytes": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"scanned_lines": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"sources": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/FileLogSource"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -5161,7 +5266,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Sets an HttpOnly SameSite=Strict session cookie. Browser sessions use the configured 7–30 day absolute lifetime. Use the panel sign-in page. No bearer token or URL token is accepted.",
|
||||
"description": "Sets an HttpOnly SameSite=Strict session cookie. Browser sessions use the configured 7\u201330 day absolute lifetime. Use the panel sign-in page. No bearer token or URL token is accepted.",
|
||||
"security": [],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -9463,6 +9568,237 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/file-logs": {
|
||||
"get": {
|
||||
"summary": "Configured on-disk log sources",
|
||||
"description": "Lists only RustProxy log paths from the active configuration. This endpoint is independent from the Live Logs memory buffer and does not accept a filesystem path.",
|
||||
"tags": [
|
||||
"Monitoring"
|
||||
],
|
||||
"operationId": "get_api_file_logs",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Configured file-log inventory",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/FileLogsInventory"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Authentication required or revoked",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Host, origin or CSRF validation failed",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "Bounded worker/session/rate limit",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "Control-plane storage/worker unavailable",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/file-logs/query": {
|
||||
"get": {
|
||||
"summary": "Query configured on-disk logs",
|
||||
"description": "Reads bounded tails of configured RustProxy access/error log files using server-side source IDs only. The query never accepts arbitrary paths, follows no symlink on Unix, and is separate from /api/logs and /api/live-logs.",
|
||||
"tags": [
|
||||
"Monitoring"
|
||||
],
|
||||
"operationId": "get_api_file_logs_query",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "source",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Source ID returned by /api/file-logs, or all. Never a filesystem path.",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "q",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Case-insensitive text search in the bounded scanned records.",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"maxLength": 256
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "event",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Event class.",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"all",
|
||||
"request",
|
||||
"protocol_error",
|
||||
"error"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "status_class",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "HTTP status class.",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"all",
|
||||
"1xx",
|
||||
"2xx",
|
||||
"3xx",
|
||||
"4xx",
|
||||
"5xx"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "client_ip",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Client IP substring filter.",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"maxLength": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "vhost",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Vhost substring filter.",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"maxLength": 256
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "offset",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Result offset within the bounded scan.",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 10000,
|
||||
"default": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Page size.",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 500,
|
||||
"default": 100
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Bounded filtered page",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/FileLogsQuery"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid filter or unknown source",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Authentication required or revoked",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Host, origin or CSRF validation failed",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "Bounded worker/session/rate limit",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "Control-plane storage/worker unavailable",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,7 @@ pub fn router(state: Arc<App>) -> Router {
|
||||
.route("/cache", get(public::index))
|
||||
.route("/transform", get(public::index))
|
||||
.route("/logging", get(public::index))
|
||||
.route("/logs", get(public::index))
|
||||
.route("/settings", get(public::index))
|
||||
.route(
|
||||
"/assets/login.js",
|
||||
@@ -183,6 +184,15 @@ pub fn router(state: Arc<App>) -> Router {
|
||||
)
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/assets/file-logs.js",
|
||||
get(|| async {
|
||||
(
|
||||
[("content-type", "text/javascript; charset=utf-8")],
|
||||
include_str!("web/file-logs.js"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/assets/settings.js",
|
||||
get(|| async {
|
||||
@@ -348,6 +358,8 @@ pub fn router(state: Arc<App>) -> Router {
|
||||
.route("/api/config/{section}", put(update_section))
|
||||
.route("/api/logs", get(logs))
|
||||
.route("/api/live-logs", get(live_logs_stream))
|
||||
.route("/api/file-logs", get(file_logs))
|
||||
.route("/api/file-logs/query", get(file_logs_query))
|
||||
.route("/api/preferences", get(preferences).put(update_preferences))
|
||||
.route("/api/audit", get(audit))
|
||||
.route("/api/events", get(events))
|
||||
|
||||
@@ -4,4 +4,5 @@ mod tests {
|
||||
include!("tests/part_03.rs");
|
||||
include!("tests/part_04.rs");
|
||||
include!("tests/part_05.rs");
|
||||
include!("tests/part_06.rs");
|
||||
}
|
||||
@@ -383,6 +383,7 @@
|
||||
"/cache",
|
||||
"/transform",
|
||||
"/logging",
|
||||
"/logs",
|
||||
"/settings",
|
||||
] {
|
||||
let public = router
|
||||
|
||||
@@ -78,6 +78,9 @@
|
||||
for (path, expected) in [
|
||||
("/api/logs?vhost=demo&limit=8", 200),
|
||||
("/api/logs?cache=BAD", 400),
|
||||
("/api/file-logs", 200),
|
||||
("/api/file-logs/query?source=all&limit=8", 200),
|
||||
("/api/file-logs/query?limit=0", 400),
|
||||
("/api/traffic", 200),
|
||||
("/api/cache", 200),
|
||||
("/api/transform", 200),
|
||||
|
||||
@@ -293,6 +293,8 @@
|
||||
"/api/cache?limit=invalid",
|
||||
"/api/logs?limit=invalid",
|
||||
"/api/live-logs",
|
||||
"/api/file-logs",
|
||||
"/api/file-logs/query?limit=invalid",
|
||||
"/api/certificate-api",
|
||||
"/api/preferences",
|
||||
"/api/audit",
|
||||
@@ -322,6 +324,7 @@
|
||||
"/assets/redirects.js",
|
||||
"/assets/host404.js",
|
||||
"/assets/settings.js",
|
||||
"/assets/file-logs.js",
|
||||
"/assets/acme.js",
|
||||
"/api/not-a-route",
|
||||
"/not-a-route",
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
#[test]
|
||||
fn stored_log_inventory_keeps_existing_files_visible_when_writer_is_disabled() {
|
||||
use std::io::Write;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("access.jsonl");
|
||||
let mut file = std::fs::File::create(&path).unwrap();
|
||||
writeln!(file, r#"{{"event":"request","status":200}}"#).unwrap();
|
||||
|
||||
let mut config = Config::default();
|
||||
config.logging.file.enabled = false;
|
||||
config.logging.file.per_vhost = false;
|
||||
config.logging.file.path = path.to_string_lossy().into_owned();
|
||||
let sources = configured_file_log_sources(&config);
|
||||
let access = sources.iter().find(|source| source.id == "access").unwrap();
|
||||
assert!(!access.enabled);
|
||||
assert!(access.exists);
|
||||
assert!(access.size > 0);
|
||||
assert_eq!(access.path, path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_log_tail_enforces_line_budget() {
|
||||
use std::io::Write;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("access.jsonl");
|
||||
let mut file = std::fs::File::create(&path).unwrap();
|
||||
for id in 0..10 {
|
||||
writeln!(file, r#"{{"time_ms":{id},"event":"request"}}"#).unwrap();
|
||||
}
|
||||
drop(file);
|
||||
|
||||
let source = source_from_path(
|
||||
"access".into(),
|
||||
"Access log".into(),
|
||||
"access",
|
||||
path,
|
||||
false,
|
||||
"JSONL",
|
||||
);
|
||||
let (lines, _bytes, truncated) = read_tail(&source, 1024 * 1024, 3).unwrap();
|
||||
assert_eq!(lines.len(), 3);
|
||||
assert!(lines[0].contains(r#""time_ms":9"#));
|
||||
assert!(lines[2].contains(r#""time_ms":7"#));
|
||||
assert!(truncated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_log_query_rejects_oversized_source_identifier() {
|
||||
let query = FileLogQuery {
|
||||
source: Some("x".repeat(193)),
|
||||
..FileLogQuery::default()
|
||||
};
|
||||
assert!(validate_file_log_query(&query).is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn stored_log_reader_refuses_symlink_targets() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let real = dir.path().join("real.jsonl");
|
||||
let link = dir.path().join("link.jsonl");
|
||||
std::fs::write(&real, b"{}\n").unwrap();
|
||||
symlink(&real, &link).unwrap();
|
||||
assert!(open_file_log(&link).is_err());
|
||||
}
|
||||
@@ -61,7 +61,7 @@ function updateAdminPreferences(patch) {
|
||||
}
|
||||
function resetAdminPreferences() { adminPreferences = null; adminPreferencesLoading = null; adminPreferencesWrite = Promise.resolve(); }
|
||||
window.rustProxyPreferences = { load: loadAdminPreferences, update: updateAdminPreferences, current: () => adminPreferences, applyTheme: applyAdminTheme };
|
||||
const PANEL_PATHS = { overview: "/overview", vhosts: "/vhosts", "web-server": "/web-server", "docker-vhosts": "/docker-vhosts", redirects: "/redirects", notfound: "/404-hosts", streams: "/streams", "acl-profiles": "/acl-profiles", tls: "/tls", traffic: "/traffic", cache: "/cache", transform: "/transform", settings: "/settings" };
|
||||
const PANEL_PATHS = { overview: "/overview", vhosts: "/vhosts", "web-server": "/web-server", "docker-vhosts": "/docker-vhosts", redirects: "/redirects", notfound: "/404-hosts", streams: "/streams", "acl-profiles": "/acl-profiles", tls: "/tls", traffic: "/traffic", cache: "/cache", transform: "/transform", filelogs: "/logs", settings: "/settings" };
|
||||
function panelFromLocation() { if (typeof location === "undefined") return "overview"; if (location.pathname === "/configuration" || location.pathname === "/logging") return "settings"; const found = Object.entries(PANEL_PATHS).find(([, path]) => path === location.pathname); return found?.[0] || "overview"; }
|
||||
let envelope = null, busy = false, connected = false, currentPanel = panelFromLocation(), previous = null;
|
||||
let samples = [], protocolSamples = { h1: [], h2: [], h3: [] }, websocketProtocolSamples = { h1: [], h2: [], h3: [] }, transformSamples = [], polling = false, toastTimer, editingBackend = null, editingRoute = null;
|
||||
@@ -162,6 +162,7 @@ function apiArea(path) {
|
||||
if (path.startsWith("/api/certificates") || path.startsWith("/api/acme")) return "tls";
|
||||
if (path.startsWith("/api/certificate-api")) return "settings";
|
||||
if (path.startsWith("/api/docker")) return "dockerVhosts";
|
||||
if (path.startsWith("/api/file-logs")) return "filelogs";
|
||||
if (path.startsWith("/api/logs")) return "logging";
|
||||
if (path.startsWith("/api/audit") || path.startsWith("/api/preferences") || path.startsWith("/api/auth/password") || path.startsWith("/api/auth/logout")) return "security";
|
||||
return null;
|
||||
@@ -194,6 +195,7 @@ function setPanel(name, navigate = true) {
|
||||
if (navigate && typeof location !== "undefined" && ["http:", "https:"].includes(location.protocol) && location.pathname !== PANEL_PATHS[name]) history.pushState({ panel: name }, "", PANEL_PATHS[name]);
|
||||
if (name === "settings") window.rustProxySettings?.syncLocation?.();
|
||||
if (name === "tls") window.rustProxyAcme?.syncLocation?.();
|
||||
if (name === "filelogs") void window.rustProxyFileLogs?.refresh();
|
||||
if (name === "traffic") void window.rustProxyTrafficPanel?.refresh(); if (name === "cache") void window.rustProxyCachePanel?.refresh(); if (name === "transform") void window.rustProxyTransformPanel?.refresh(); if (name === "docker-vhosts") void window.rustProxyDocker?.refresh(false); if (name === "tls") { void window.rustProxyAcme?.refresh(); void refreshCertificateInventory().then(() => { if (authenticated && currentPanel === "tls") renderCertificates(); }); }
|
||||
}
|
||||
function bytes(value) { if (value < 1024) return `${value} B`; for (const unit of ["KiB", "MiB", "GiB", "TiB"]) { value /= 1024; if (value < 1024 || unit === "TiB") return `${value.toFixed(value < 10 ? 1 : 0)} ${unit}`; } }
|
||||
@@ -342,7 +344,7 @@ function authReturnDestination() {
|
||||
}
|
||||
function lock(notify = true) {
|
||||
const destination = pendingAuthDestination || authReturnDestination(), navigating = typeof location !== "undefined" && (location.protocol === "http:" || location.protocol === "https:");
|
||||
pendingAuthDestination = destination; if (notify) adminAuthChannel?.postMessage("locked"); $("api-docs").hidden = true; resetAdminPreferences(); window.rustProxyCertificates?.reset(); window.rustProxySettings?.reset(); window.rustProxyTrafficPanel?.reset(); window.rustProxyCachePanel?.reset(); window.rustProxyTransformPanel?.reset(); window.rustProxyAcme?.reset(); window.rustProxyIntegrations?.reset(); window.rustProxyLogs?.reset(); $("logs-open").hidden = true; window.rustProxyAclProfiles?.reset(); window.rustProxyVhosts?.reset(); window.rustProxyWebServer?.reset(); window.rustProxyDocker?.reset(); window.rustProxyRedirects?.reset(); window.rustProxyNotFound?.reset(); window.rustProxyStreams?.reset(); authEpoch++; stopLive(); csrf = ""; authenticated = false; sessionInfo = null; envelope = null; certificateInventory = new Map(); previous = null; samples = []; protocolSamples = { h1: [], h2: [], h3: [] }; transformSamples = []; systemSamples = []; connectionOpenSamples = []; connectionExchangeSamples = []; networkInterfaceSamples.clear(); lastLogs = null; latestStatus = null; clearSettingsDirty(); connected = false; busy = false; $("workspace").hidden = true; $("login").hidden = navigating; $("logout").hidden = true; $("password").value = ""; $("password-confirm").value = ""; $("setup-token").value = ""; $("password-form").reset(); $("config-editor").value = ""; $("logs").replaceChildren(); $("audit-rows").replaceChildren(); authMessage("");
|
||||
pendingAuthDestination = destination; if (notify) adminAuthChannel?.postMessage("locked"); $("api-docs").hidden = true; resetAdminPreferences(); window.rustProxyCertificates?.reset(); window.rustProxySettings?.reset(); window.rustProxyTrafficPanel?.reset(); window.rustProxyCachePanel?.reset(); window.rustProxyTransformPanel?.reset(); window.rustProxyAcme?.reset(); window.rustProxyIntegrations?.reset(); window.rustProxyLogs?.reset(); window.rustProxyFileLogs?.reset(); $("logs-open").hidden = true; $("file-logs-open").hidden = true; window.rustProxyAclProfiles?.reset(); window.rustProxyVhosts?.reset(); window.rustProxyWebServer?.reset(); window.rustProxyDocker?.reset(); window.rustProxyRedirects?.reset(); window.rustProxyNotFound?.reset(); window.rustProxyStreams?.reset(); authEpoch++; stopLive(); csrf = ""; authenticated = false; sessionInfo = null; envelope = null; certificateInventory = new Map(); previous = null; samples = []; protocolSamples = { h1: [], h2: [], h3: [] }; transformSamples = []; systemSamples = []; connectionOpenSamples = []; connectionExchangeSamples = []; networkInterfaceSamples.clear(); lastLogs = null; latestStatus = null; clearSettingsDirty(); connected = false; busy = false; $("workspace").hidden = true; $("login").hidden = navigating; $("logout").hidden = true; $("password").value = ""; $("password-confirm").value = ""; $("setup-token").value = ""; $("password-form").reset(); $("config-editor").value = ""; $("logs").replaceChildren(); $("audit-rows").replaceChildren(); authMessage("");
|
||||
for (const id of ["certificate-list", "host-policy-list"]) { $(id)?.replaceChildren(); }
|
||||
$("workspace").querySelectorAll("form").forEach(form => form.reset());
|
||||
$("workspace").querySelectorAll("input:not([type=checkbox]):not([type=radio]),textarea").forEach(input => { input.value = ""; });
|
||||
@@ -353,7 +355,7 @@ function lock(notify = true) {
|
||||
if (navigating) { document.documentElement.classList.add("app-loading"); location.replace(destination); }
|
||||
}
|
||||
|
||||
async function activate(info) { pendingAuthDestination = null; authMessage(""); $("api-docs").hidden = false; $("logs-open").hidden = false; authEpoch++; sessionInfo = info; csrf = info.csrf_token; authenticated = true; const preferences = await loadAdminPreferences(); window.rustProxyLogs?.applyPreferences(preferences); window.rustProxyVhosts?.applyPreferences(preferences); await loadConfig(); $("password").value = ""; $("password-confirm").value = ""; $("setup-token").value = ""; $("login").hidden = true; $("workspace").hidden = false; $("logout").hidden = false; renderSession(); await refresh(true); startLive(); document.documentElement.classList.remove("app-loading"); }
|
||||
async function activate(info) { pendingAuthDestination = null; authMessage(""); $("api-docs").hidden = false; $("logs-open").hidden = false; $("file-logs-open").hidden = false; authEpoch++; sessionInfo = info; csrf = info.csrf_token; authenticated = true; const preferences = await loadAdminPreferences(); window.rustProxyLogs?.applyPreferences(preferences); window.rustProxyVhosts?.applyPreferences(preferences); await loadConfig(); $("password").value = ""; $("password-confirm").value = ""; $("setup-token").value = ""; $("login").hidden = true; $("workspace").hidden = false; $("logout").hidden = false; renderSession(); await refresh(true); startLive(); document.documentElement.classList.remove("app-loading"); }
|
||||
async function loadAudit() { if (!authenticated) return; const epoch = authEpoch; try { const data = await api("/api/audit"); if (!authenticated || epoch !== authEpoch) return; const rows = data.entries.map(item => { const tr = el("tr"); for (const value of [new Date(item.time * 1000).toLocaleString(locale()), item.actor, item.action, item.detail]) { const td = el("td", value); td.title = value; tr.append(td); } return tr; }); $("audit-rows").replaceChildren(...rows); $("empty-audit").hidden = rows.length > 0; } catch (e) { toast(e.message, true); } }
|
||||
let tunnelRefreshTimer = null;
|
||||
function tunnelMessage(value = "", error = false) { const node = $("tunnels-status"); if (!node) return; node.textContent = value; node.classList.toggle("error", !!error); }
|
||||
|
||||
@@ -6,7 +6,7 @@ function renderSettings() {
|
||||
if (!settingsDirty.has("response-form")) {
|
||||
const server = config.server_header || {}, compression = config.compression || {}, diagnostics = config.diagnostic_headers || {};
|
||||
$("expose-request-id").checked = !!diagnostics.request_id;
|
||||
$("server-mode").value = server.mode || "preserve"; $("server-value").value = server.value || "RustProxy/v0.25.7";
|
||||
$("server-mode").value = server.mode || "preserve"; $("server-value").value = server.value || "RustProxy/v0.26.0";
|
||||
for (const name of ["gzip", "br", "zstd", "deflate"]) $(`compress-${name}`).checked = !!compression[name];
|
||||
$("compress-min").value = compression.min_bytes ?? 1024; syncServerMode();
|
||||
}
|
||||
@@ -296,14 +296,14 @@ bindSettingsForm("transform-form", next => {
|
||||
max_inflight: Number($("transform-max-inflight").value)
|
||||
};
|
||||
}, "transform");
|
||||
const LOGGING_SOURCE_FIELDS = ["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"];
|
||||
const LOGGING_SOURCE_FIELDS = ["event", "protocol_error", "error_detail", "local_addr", "tls_sni", "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"];
|
||||
const LOGGING_PRESETS = {
|
||||
standard: {},
|
||||
minimal: { time_ms: "time_ms", method: "method", host: "host", path: "path", status: "status", duration_ms: "duration_ms" },
|
||||
nginx: { time_ms: "time_ms", remote_addr: "client_ip", http_user_agent: "user_agent", country: "country", request_method: "method", host: "host", request_uri: "path", status: "status", request_time_ms: "duration_ms", request_length: "request_bytes", bytes_sent: "response_bytes", upstream: "backend", cache: "cache", cache_reason: "cache_reason" },
|
||||
ecs: { "event.created_ms": "time_ms", "client.address": "client_ip", "user_agent.original": "user_agent", "client.geo.country_iso_code": "country", "http.request.method": "method", "server.address": "host", "url.path": "path", "http.response.status_code": "status", "event.duration_ms": "duration_ms", "http.request.body.bytes": "request_bytes", "http.response.body.bytes": "response_bytes", "service.target.name": "backend", "event.outcome": "outcome", "cache.result": "cache", "cache.reason": "cache_reason" }
|
||||
};
|
||||
const LOGGING_PREVIEW_VALUES = { vhost: "app", route: "root", cache_effective_enabled: true, transform_effective_enabled: false, backend: "origin-1", id: 1842, time_ms: 1769328123456, method: "CONNECT", host: "app.example", path: "/socket.io/", http_version: "3", transport: "h3", websocket_bootstrap: "h3_extended_connect", websocket_phase: "tunnel_closed", websocket_error: null, h3_reset_code: null, upstream_status: 101, client_ip: "203.0.113.24", user_agent: "Mozilla/5.0 RustProxy-Test", country: "PL", status: 200, duration_ms: 12, request_bytes: 0, response_bytes: 4821, outcome: "websocket_closed", cache: "BYPASS", cache_reason: "request_upgrade" };
|
||||
const LOGGING_PREVIEW_VALUES = { event: "request", protocol_error: null, error_detail: null, local_addr: "192.0.2.1:443", tls_sni: "app.example", vhost: "app", route: "root", cache_effective_enabled: true, transform_effective_enabled: false, backend: "origin-1", id: 1842, time_ms: 1769328123456, method: "CONNECT", host: "app.example", path: "/socket.io/", http_version: "3", transport: "h3", websocket_bootstrap: "h3_extended_connect", websocket_phase: "tunnel_closed", websocket_error: null, h3_reset_code: null, upstream_status: 101, client_ip: "203.0.113.24", user_agent: "Mozilla/5.0 RustProxy-Test", country: "PL", status: 200, duration_ms: 12, request_bytes: 0, response_bytes: 4821, outcome: "websocket_closed", cache: "BYPASS", cache_reason: "request_upgrade" };
|
||||
function parseJsonObjectValue(value, target = null) { let parsed; try { parsed = JSON.parse(value || "{}"); } catch { throw fieldError(target, t("loggingJsonInvalid")); } if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") throw fieldError(target, t("loggingJsonInvalid")); return parsed; }
|
||||
function loggingMappingKey(mapping) { return JSON.stringify(Object.entries(mapping || {}).sort(([a], [b]) => a.localeCompare(b))); }
|
||||
function detectLoggingPreset(mapping) { const key = loggingMappingKey(mapping); for (const [name, preset] of Object.entries(LOGGING_PRESETS)) if (loggingMappingKey(preset) === key) return name; return "custom"; }
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
(() => {
|
||||
let inventory = null, offset = 0, lastTotal = 0, busy = false;
|
||||
const sourceSelect = () => $("file-log-source");
|
||||
const currentLimit = () => Math.max(1, Math.min(500, Number($("file-log-limit").value) || 100));
|
||||
function sourceOption(source) {
|
||||
const label = `${source.label} · ${source.format} · ${bytes(source.size || 0)}${source.enabled ? ` · ${t("enabled")}` : ""}`;
|
||||
const option = el("option", label); option.value = source.id; return option;
|
||||
}
|
||||
function renderInventory(value) {
|
||||
inventory = value;
|
||||
const selected = sourceSelect().value || "all";
|
||||
const sources = Array.isArray(value?.sources) ? value.sources : [];
|
||||
const options = [Object.assign(el("option", t("fileLogsAllSources")), { value: "all" }), ...sources.map(sourceOption)];
|
||||
sourceSelect().replaceChildren(...options);
|
||||
sourceSelect().value = ["all", ...sources.map(source => source.id)].includes(selected) ? selected : "all";
|
||||
$("file-log-inventory").textContent = sources.length
|
||||
? sources.map(source => `${source.label}: ${source.path} (${bytes(source.size || 0)})`).join(" · ")
|
||||
: t("fileLogsNoSources");
|
||||
}
|
||||
async function loadInventory() {
|
||||
const value = await api("/api/file-logs"); renderInventory(value); return value;
|
||||
}
|
||||
function entryTime(entry) {
|
||||
if (entry.time_ms) return new Date(Number(entry.time_ms)).toLocaleString(locale());
|
||||
if (entry.timestamp) return String(entry.timestamp);
|
||||
return "—";
|
||||
}
|
||||
function entrySummary(entry) {
|
||||
if (entry.protocol_error) return entry.protocol_error;
|
||||
if (entry.method || entry.path) return `${entry.method || ""} ${entry.host || ""}${entry.path || ""}`.trim();
|
||||
if (entry.message) return entry.message;
|
||||
if (entry.raw) return entry.raw;
|
||||
return entry.outcome || "—";
|
||||
}
|
||||
function entryStatus(entry) {
|
||||
const status = Number(entry.status) || 0;
|
||||
return status ? String(status) : "—";
|
||||
}
|
||||
function renderRows(entries) {
|
||||
const rows = entries.map(entry => {
|
||||
const row = el("tr"); row.tabIndex = 0;
|
||||
for (const value of [entryTime(entry), entry.event || "—", entryStatus(entry), entry.client_ip || "—", entry.vhost || "—", entrySummary(entry), entry._source || "—"]) {
|
||||
const cell = el("td", String(value)); cell.title = String(value); row.append(cell);
|
||||
}
|
||||
const show = () => { $("file-log-detail").textContent = JSON.stringify(entry, null, 2); };
|
||||
row.addEventListener("click", show); row.addEventListener("keydown", event => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); show(); } });
|
||||
return row;
|
||||
});
|
||||
$("file-log-rows").replaceChildren(...rows);
|
||||
$("file-logs-empty").hidden = rows.length !== 0;
|
||||
if (!rows.length) $("file-log-detail").textContent = t("fileLogsSelectRecord");
|
||||
}
|
||||
function queryString() {
|
||||
const params = new URLSearchParams();
|
||||
params.set("source", sourceSelect().value || "all");
|
||||
for (const [id, name] of [["file-log-q", "q"], ["file-log-event", "event"], ["file-log-status", "status_class"], ["file-log-client", "client_ip"], ["file-log-vhost", "vhost"]]) {
|
||||
const value = $(id).value.trim(); if (value && value !== "all") params.set(name, value);
|
||||
}
|
||||
params.set("offset", String(offset)); params.set("limit", String(currentLimit()));
|
||||
return params.toString();
|
||||
}
|
||||
async function refresh(reset = false) {
|
||||
if (!authenticated || busy) return;
|
||||
if (reset) offset = 0;
|
||||
busy = true; $("file-logs-refresh").disabled = true;
|
||||
try {
|
||||
if (!inventory) await loadInventory();
|
||||
const value = await api(`/api/file-logs/query?${queryString()}`);
|
||||
lastTotal = Number(value.total) || 0; renderRows(Array.isArray(value.entries) ? value.entries : []);
|
||||
const from = lastTotal ? offset + 1 : 0, to = Math.min(offset + currentLimit(), lastTotal);
|
||||
$("file-log-summary").textContent = t("fileLogsSummary")
|
||||
.replace("{from}", fmt(from)).replace("{to}", fmt(to)).replace("{total}", fmt(lastTotal))
|
||||
.replace("{bytes}", bytes(Number(value.scanned_bytes) || 0)).replace("{lines}", fmt(Number(value.scanned_lines) || 0))
|
||||
+ (value.truncated ? ` · ${t("fileLogsTruncated")}` : "");
|
||||
$("file-logs-prev").disabled = offset === 0;
|
||||
$("file-logs-next").disabled = offset + currentLimit() >= lastTotal;
|
||||
} catch (error) { toast(error.message, true); }
|
||||
finally { busy = false; $("file-logs-refresh").disabled = false; }
|
||||
}
|
||||
function reset() {
|
||||
inventory = null; offset = 0; lastTotal = 0; $("file-log-rows").replaceChildren(); $("file-log-detail").textContent = "";
|
||||
}
|
||||
$("file-logs-open").addEventListener("click", () => setPanel("filelogs"));
|
||||
$("settings-file-logs-open").addEventListener("click", () => setPanel("filelogs"));
|
||||
$("file-logs-refresh").addEventListener("click", () => refresh(true));
|
||||
$("file-logs-filter").addEventListener("submit", event => { event.preventDefault(); void refresh(true); });
|
||||
$("file-logs-prev").addEventListener("click", () => { offset = Math.max(0, offset - currentLimit()); void refresh(); });
|
||||
$("file-logs-next").addEventListener("click", () => { if (offset + currentLimit() < lastTotal) { offset += currentLimit(); void refresh(); } });
|
||||
sourceSelect().addEventListener("change", () => refresh(true));
|
||||
window.rustProxyFileLogs = { refresh: () => refresh(false), reset, inventory: loadInventory };
|
||||
})();
|
||||
@@ -22,6 +22,7 @@
|
||||
<script defer="" src="/assets/transform-panel.js"></script>
|
||||
<script defer="" src="/assets/acme.js"></script>
|
||||
<script defer="" src="/assets/settings.js"></script>
|
||||
<script defer="" src="/assets/file-logs.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@@ -63,7 +64,8 @@
|
||||
<header class="topbar"><span class="breadcrumb">CONTROL ROOM <span>/</span> <b
|
||||
id="page-label">Overview</b></span>
|
||||
<div class="top-actions"><a class="quiet" href="/docs" data-i18n="documentation" id="api-docs"
|
||||
hidden="">Documentation</a><button class="quiet" data-i18n="liveLogs" hidden="" id="logs-open"
|
||||
hidden="">Documentation</a><button class="quiet" data-i18n="logs" hidden="" id="file-logs-open"
|
||||
type="button">Logs</button><button class="quiet" data-i18n="liveLogs" hidden="" id="logs-open"
|
||||
type="button">Live
|
||||
access logs</button><select aria-label="Language" class="language-select"
|
||||
data-i18n-aria="language" id="language"></select><button class="quiet" data-i18n="about"
|
||||
@@ -1494,6 +1496,79 @@
|
||||
yet.</p>
|
||||
</article>
|
||||
</section>
|
||||
<section class="panel" hidden="" id="panel-filelogs">
|
||||
<article class="card file-logs-workspace">
|
||||
<div class="card-heading">
|
||||
<div><span class="small-label" data-i18n="fileLogsMode">LOCAL / READ-ONLY / BOUNDED</span>
|
||||
<h2 data-i18n="fileLogsTitle">Stored log explorer</h2>
|
||||
</div>
|
||||
<button class="outline" data-i18n="refresh" id="file-logs-refresh"
|
||||
type="button">Refresh</button>
|
||||
</div>
|
||||
<p class="fine" data-i18n="fileLogsHelp">Reads only configured RustProxy log files. It is
|
||||
independent from Live Logs and never accepts an arbitrary filesystem path.</p>
|
||||
<form class="file-logs-filter" id="file-logs-filter">
|
||||
<label><span data-i18n="fileLogsSource">Source</span><select id="file-log-source">
|
||||
<option value="all">All configured files</option>
|
||||
</select></label>
|
||||
<label class="file-log-search"><span data-i18n="search">Search</span><input id="file-log-q"
|
||||
maxlength="256" data-i18n-placeholder="fileLogsSearchPlaceholder"
|
||||
placeholder="IP, path, error, request id..." /></label>
|
||||
<label><span data-i18n="fileLogsEvent">Event</span><select id="file-log-event">
|
||||
<option data-i18n="all" value="all">All</option>
|
||||
<option value="request">request</option>
|
||||
<option value="protocol_error">protocol_error</option>
|
||||
<option value="error">error</option>
|
||||
</select></label>
|
||||
<label><span data-i18n="status">Status</span><select id="file-log-status">
|
||||
<option data-i18n="all" value="all">All</option>
|
||||
<option value="1xx">1xx</option>
|
||||
<option value="2xx">2xx</option>
|
||||
<option value="3xx">3xx</option>
|
||||
<option value="4xx">4xx</option>
|
||||
<option value="5xx">5xx</option>
|
||||
</select></label>
|
||||
<label><span data-i18n="clientIp">Client IP</span><input id="file-log-client"
|
||||
maxlength="128" placeholder="192.0.2.10" /></label>
|
||||
<label><span data-i18n="vhost">Vhost</span><input id="file-log-vhost"
|
||||
maxlength="256" /></label>
|
||||
<label><span data-i18n="fileLogsPageSize">Page size</span><select id="file-log-limit">
|
||||
<option>50</option>
|
||||
<option selected>100</option>
|
||||
<option>250</option>
|
||||
<option>500</option>
|
||||
</select></label>
|
||||
<button class="primary" data-i18n="applyFilters" type="submit">Apply filters</button>
|
||||
</form>
|
||||
<p class="fine file-log-inventory" id="file-log-inventory"></p>
|
||||
<p class="fine" id="file-log-summary" role="status"></p>
|
||||
<div class="table-scroll file-logs-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="time">Time</th>
|
||||
<th data-i18n="fileLogsEvent">Event</th>
|
||||
<th data-i18n="status">Status</th>
|
||||
<th data-i18n="clientIp">Client IP</th>
|
||||
<th data-i18n="vhost">Vhost</th>
|
||||
<th data-i18n="fileLogsRecord">Request / reason</th>
|
||||
<th data-i18n="fileLogsSource">Source</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="file-log-rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="empty" data-i18n="fileLogsEmpty" id="file-logs-empty">No records match the current
|
||||
filters.</p>
|
||||
<div class="file-logs-pager"><button class="quiet" data-i18n="previous" disabled
|
||||
id="file-logs-prev" type="button">Previous</button><button class="quiet"
|
||||
data-i18n="next" disabled id="file-logs-next" type="button">Next</button></div>
|
||||
<details open class="file-log-detail-card">
|
||||
<summary data-i18n="fileLogsDetails">Record details</summary>
|
||||
<pre class="metrics-example" id="file-log-detail"></pre>
|
||||
</details>
|
||||
</article>
|
||||
</section>
|
||||
<section class="panel" hidden="" id="panel-settings">
|
||||
<div class="settings-shortcuts">
|
||||
<div><b data-i18n="security">Security</b>
|
||||
@@ -1667,7 +1742,7 @@
|
||||
<option data-i18n="serverCustom" value="custom">Replace / add custom value</option>
|
||||
</select>
|
||||
<label data-i18n="serverValue" for="server-value">Custom Server value</label><input
|
||||
id="server-value" maxlength="256" placeholder="RustProxy/v0.25.7"
|
||||
id="server-value" maxlength="256" placeholder="RustProxy/v0.26.0"
|
||||
spellcheck="false" />
|
||||
<h3 class="subheading" data-i18n="diagnosticHeaders">Diagnostic headers</h3>
|
||||
<div class="switch-grid">
|
||||
@@ -2065,6 +2140,17 @@
|
||||
<p class="fine" data-i18n="loggingHelp">Live log viewing stays in memory.
|
||||
External sinks
|
||||
run on a bounded background queue and never block proxy request I/O.</p>
|
||||
<article class="card soft-card file-logs-launcher">
|
||||
<div>
|
||||
<h3 class="subheading" data-i18n="fileLogsTitle">Stored log explorer
|
||||
</h3>
|
||||
<p class="fine" data-i18n="fileLogsSettingsHelp">Browse configured
|
||||
access/error files with server-side filters. File logs can be off
|
||||
while Live Logs continues to use its memory buffer.</p>
|
||||
</div>
|
||||
<button class="outline" data-i18n="openLogs" id="settings-file-logs-open"
|
||||
type="button">Open Logs</button>
|
||||
</article>
|
||||
<article class="card soft-card">
|
||||
<h3 class="subheading" data-i18n="loggingCaptureTitle">Captured request
|
||||
dimensions</h3>
|
||||
@@ -2455,7 +2541,7 @@
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
<footer class="page-footer"><span>RustProxy <b>0.25.7</b></span><span id="footer-status">API - /
|
||||
<footer class="page-footer"><span>RustProxy <b>0.26.0</b></span><span id="footer-status">API - /
|
||||
dropped: 0</span></footer>
|
||||
</div>
|
||||
</main>
|
||||
@@ -3166,7 +3252,7 @@ max_execution_time = 30</code></pre>
|
||||
<article class="card about-card">
|
||||
<div class="about-product">
|
||||
<span aria-hidden="true" class="brand-mark">r<span>/</span>p</span>
|
||||
<div><span class="small-label">RustProxy</span><strong>0.25.7</strong></div>
|
||||
<div><span class="small-label">RustProxy</span><strong>0.26.0</strong></div>
|
||||
</div>
|
||||
<dl class="about-meta">
|
||||
<div>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
const node = id => document.getElementById(id);
|
||||
const themes = new Set(["dark", "light"]);
|
||||
try { const cached = localStorage.getItem("rp-theme"); document.documentElement.dataset.theme = themes.has(cached) ? cached : "dark"; } catch { document.documentElement.dataset.theme = "dark"; }
|
||||
const panelPaths = new Set(["/overview", "/vhosts", "/docker-vhosts", "/redirects", "/404-hosts", "/streams", "/acl-profiles", "/configuration", "/tls", "/traffic", "/cache", "/transform", "/logging", "/settings"]);
|
||||
const panelPaths = new Set(["/overview", "/vhosts", "/docker-vhosts", "/redirects", "/404-hosts", "/streams", "/acl-profiles", "/configuration", "/tls", "/traffic", "/cache", "/transform", "/logging", "/logs", "/settings"]);
|
||||
const settingsTabs = new Set(["listeners", "connections", "docker-agents", "response", "cache", "transform", "headers", "forwarding", "pages", "logging", "prometheus", "administration", "tls-policy", "manual-config", "security"]);
|
||||
let packs = new Map(), fallback = null, language = null, setupRequired = false, ready = false;
|
||||
try { language = localStorage.getItem("rp-language"); } catch { }
|
||||
|
||||
@@ -8198,4 +8198,84 @@ button.config-history-day.is-selected {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.file-logs-workspace {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.file-logs-filter {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(120px, 1fr));
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.file-logs-filter label {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-logs-filter .file-log-search {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.file-logs-table {
|
||||
max-height: 52vh;
|
||||
}
|
||||
|
||||
.file-logs-table tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-logs-table td {
|
||||
max-width: 340px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-logs-pager {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.file-log-detail-card pre {
|
||||
max-height: 32vh;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.file-logs-launcher {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.file-log-inventory {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.file-logs-filter {
|
||||
grid-template-columns: repeat(3, minmax(140px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.file-logs-filter {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.file-logs-filter .file-log-search {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.file-logs-launcher {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
+63
-122
@@ -203,10 +203,15 @@ pub async fn serve(bound: Bound, router: Router, state: Arc<App>, grace: Duratio
|
||||
_=tasks.join_next(),if !tasks.is_empty()=>{},
|
||||
incoming=bound.endpoint.accept()=>{
|
||||
let Some(incoming)=incoming else {break;};
|
||||
let peer=incoming.remote_address();
|
||||
if !incoming.remote_address_validated() {let _=incoming.retry();continue;}
|
||||
let Ok(permit)=slots.clone().try_acquire_owned() else {
|
||||
state.metrics.http3_rejected_connections.fetch_add(1,Ordering::Relaxed);
|
||||
state.metrics.rejected_connections.fetch_add(1,Ordering::Relaxed);
|
||||
state.logs.push_protocol_error(
|
||||
"http3_connection_limit_rejected", "HTTP/3 connection concurrency limit reached",
|
||||
peer, bound.address, "quic", "3", 0, None,
|
||||
);
|
||||
incoming.refuse();continue;
|
||||
};
|
||||
let mut config=bound.server_config.clone();
|
||||
@@ -214,7 +219,15 @@ pub async fn serve(bound: Bound, router: Router, state: Arc<App>, grace: Duratio
|
||||
// Certificate changes apply to new handshakes without changing
|
||||
// active transport limits or regenerating the Retry token key.
|
||||
config.crypto=crypto;
|
||||
let connecting=match incoming.accept_with(Arc::new(config)) {Ok(c)=>c,Err(_)=>continue};
|
||||
let connecting=match incoming.accept_with(Arc::new(config)) {
|
||||
Ok(c)=>c,
|
||||
Err(error)=>{
|
||||
state.logs.push_protocol_error(
|
||||
"http3_accept_error", error, peer, bound.address, "quic", "3", 0, None,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let router=router.clone();let state=state.clone();let address=bound.address;
|
||||
let limit=bound.settings.max_concurrent_bidistream as usize;
|
||||
tasks.spawn(async move {
|
||||
@@ -222,7 +235,21 @@ pub async fn serve(bound: Bound, router: Router, state: Arc<App>, grace: Duratio
|
||||
_=state.shutdown.cancelled()=>return,
|
||||
result=tokio::time::timeout(Duration::from_secs(10),connecting)=>match result {
|
||||
Ok(Ok(connection))=>connection,
|
||||
_=>{state.metrics.tls_handshake_errors.fetch_add(1,Ordering::Relaxed);return;},
|
||||
Ok(Err(error))=>{
|
||||
state.metrics.tls_handshake_errors.fetch_add(1,Ordering::Relaxed);
|
||||
state.logs.push_protocol_error(
|
||||
"http3_handshake_error", error, peer, address, "quic", "3", 0, None,
|
||||
);
|
||||
return;
|
||||
},
|
||||
Err(_)=>{
|
||||
state.metrics.tls_handshake_errors.fetch_add(1,Ordering::Relaxed);
|
||||
state.logs.push_protocol_error(
|
||||
"http3_handshake_timeout", "HTTP/3 QUIC/TLS handshake timeout",
|
||||
peer, address, "quic", "3", 0, None,
|
||||
);
|
||||
return;
|
||||
},
|
||||
}
|
||||
};
|
||||
// A wildcard without destination metadata must fail closed.
|
||||
@@ -237,7 +264,10 @@ pub async fn serve(bound: Bound, router: Router, state: Arc<App>, grace: Duratio
|
||||
state.metrics.http3_active_connections.fetch_add(1,Ordering::Relaxed);
|
||||
state.metrics.tls_connections.fetch_add(1,Ordering::Relaxed);
|
||||
let _lease=Lease {_permit:permit,metrics:state.metrics.clone()};
|
||||
if let Err(error)=connection_loop(connection,local,router,state,limit,grace).await {
|
||||
if let Err(error)=connection_loop(connection,local,router,state.clone(),limit,grace).await {
|
||||
state.logs.push_protocol_error(
|
||||
"http3_connection_error", &error, peer, local, "quic", "3", 0, None,
|
||||
);
|
||||
tracing::debug!(%error,"HTTP/3 connection ended");
|
||||
}
|
||||
});
|
||||
@@ -285,16 +315,42 @@ async fn connection_loop(
|
||||
next=h3.accept()=>match next {
|
||||
Ok(Some(resolver))=>{
|
||||
// Completed JoinSet entries are drained before accepting more.
|
||||
if requests.len()>=limit {drop(resolver);continue;}
|
||||
if requests.len()>=limit {
|
||||
state.logs.push_protocol_error(
|
||||
"http3_stream_limit_rejected", "HTTP/3 concurrent request stream limit reached",
|
||||
connection.remote_address(), local, "quic", "3", 0, None,
|
||||
);
|
||||
drop(resolver);continue;
|
||||
}
|
||||
let router=router.clone();let state=state.clone();let peer=connection.remote_address();
|
||||
requests.spawn(async move {
|
||||
let Ok(Ok((mut request,stream)))=tokio::time::timeout(state.header_timeout,resolver.resolve_request()).await else {return;};
|
||||
let resolved=tokio::time::timeout(state.header_timeout,resolver.resolve_request()).await;
|
||||
let (mut request,stream)=match resolved {
|
||||
Ok(Ok(request))=>request,
|
||||
Ok(Err(error))=>{
|
||||
state.logs.push_protocol_error(
|
||||
"http3_request_error", error, peer, local, "quic", "3", 400, None,
|
||||
);
|
||||
return;
|
||||
},
|
||||
Err(_)=>{
|
||||
state.logs.push_protocol_error(
|
||||
"http3_header_timeout", "HTTP/3 request header timeout",
|
||||
peer, local, "quic", "3", 408, None,
|
||||
);
|
||||
return;
|
||||
},
|
||||
};
|
||||
*request.version_mut()=Version::HTTP_3;
|
||||
let protocol=request.extensions().get::<h3::ext::Protocol>().map(|protocol|protocol.as_str().to_owned());
|
||||
let extended_connect=request.method()==Method::CONNECT && protocol.is_some();
|
||||
let websocket=extended_connect && protocol.as_deref()==Some("websocket");
|
||||
let (mut send,receive)=stream.split();
|
||||
if invalid_headers(request.headers()) {
|
||||
state.logs.push_protocol_error(
|
||||
"http3_invalid_headers", "HTTP/3 request contains forbidden connection-specific headers",
|
||||
peer, local, "quic", "3", 400, None,
|
||||
);
|
||||
send.stop_stream(Code::H3_MESSAGE_ERROR);return;
|
||||
}
|
||||
let expected=request.headers().get("content-length").and_then(|v|v.to_str().ok()).and_then(|v|v.parse::<u64>().ok());
|
||||
@@ -377,122 +433,7 @@ async fn connection_loop(
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn invalid_headers(headers: &HeaderMap) -> bool {
|
||||
[
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-connection",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
]
|
||||
.iter()
|
||||
.any(|name| headers.contains_key(*name))
|
||||
|| headers
|
||||
.get_all("te")
|
||||
.iter()
|
||||
.any(|value| value.as_bytes() != b"trailers")
|
||||
|| headers.get_all("content-length").iter().count() > 1
|
||||
|| headers.get("content-length").is_some_and(|value| {
|
||||
value
|
||||
.to_str()
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.is_none()
|
||||
})
|
||||
}
|
||||
struct ReceiveBody {
|
||||
stream: h3::server::RequestStream<h3::quinn::RecvStream, Bytes>,
|
||||
expected: Option<u64>,
|
||||
received: u64,
|
||||
data_done: bool,
|
||||
done: bool,
|
||||
}
|
||||
impl HttpBody for ReceiveBody {
|
||||
type Data = Bytes;
|
||||
type Error = io::Error;
|
||||
fn poll_frame(
|
||||
self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
) -> Poll<Option<std::result::Result<Frame<Bytes>, io::Error>>> {
|
||||
let this = self.get_mut();
|
||||
if this.done {
|
||||
return Poll::Ready(None);
|
||||
}
|
||||
if !this.data_done {
|
||||
match this.stream.poll_recv_data(cx) {
|
||||
Poll::Pending => return Poll::Pending,
|
||||
Poll::Ready(Err(error)) => {
|
||||
this.done = true;
|
||||
return Poll::Ready(Some(Err(io::Error::other(error.to_string()))));
|
||||
}
|
||||
Poll::Ready(Ok(Some(mut data))) => {
|
||||
let length = data.remaining();
|
||||
this.received = this.received.saturating_add(length as u64);
|
||||
if this
|
||||
.expected
|
||||
.is_some_and(|expected| this.received > expected)
|
||||
{
|
||||
drop(data);
|
||||
this.done = true;
|
||||
this.stream.stop_sending(Code::H3_MESSAGE_ERROR);
|
||||
return Poll::Ready(Some(Err(io::Error::other(
|
||||
"HTTP/3 Content-Length exceeded",
|
||||
))));
|
||||
}
|
||||
return Poll::Ready(Some(Ok(Frame::data(data.copy_to_bytes(length)))));
|
||||
}
|
||||
Poll::Ready(Ok(None)) => this.data_done = true,
|
||||
}
|
||||
}
|
||||
match this.stream.poll_recv_trailers(cx) {
|
||||
Poll::Pending => Poll::Pending,
|
||||
Poll::Ready(Err(error)) => {
|
||||
this.done = true;
|
||||
Poll::Ready(Some(Err(io::Error::other(error.to_string()))))
|
||||
}
|
||||
Poll::Ready(Ok(trailers)) => {
|
||||
this.done = true;
|
||||
if this
|
||||
.expected
|
||||
.is_some_and(|expected| expected != this.received)
|
||||
{
|
||||
this.stream.stop_sending(Code::H3_MESSAGE_ERROR);
|
||||
return Poll::Ready(Some(Err(io::Error::other(
|
||||
"HTTP/3 Content-Length mismatch",
|
||||
))));
|
||||
}
|
||||
Poll::Ready(trailers.map(|mut headers| {
|
||||
crate::proxy::headers::strip_hop_by_hop(&mut headers);
|
||||
Ok(Frame::trailers(headers))
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
fn is_end_stream(&self) -> bool {
|
||||
self.done
|
||||
}
|
||||
fn size_hint(&self) -> SizeHint {
|
||||
// A declared zero is not proof that the peer ended its stream. Unknown
|
||||
// lengths are verified by the cache's bounded empty-body probe.
|
||||
let mut hint = SizeHint::new();
|
||||
if self.done {
|
||||
hint.set_exact(0);
|
||||
} else if let Some(remaining) = self
|
||||
.expected
|
||||
.map(|n| n.saturating_sub(self.received))
|
||||
.filter(|n| *n > 0)
|
||||
{
|
||||
hint.set_upper(remaining);
|
||||
}
|
||||
hint
|
||||
}
|
||||
}
|
||||
impl Drop for ReceiveBody {
|
||||
fn drop(&mut self) {
|
||||
// Dropping the request stream cancels the receive side at QUIC level.
|
||||
// Explicit STOP_SENDING is used only from poll_frame paths where the
|
||||
// stream handle is available and a protocol error was detected.
|
||||
}
|
||||
}
|
||||
include!("http3/body.rs");
|
||||
|
||||
#[cfg(test)]
|
||||
include!("http3/tests.rs");
|
||||
@@ -0,0 +1,117 @@
|
||||
fn invalid_headers(headers: &HeaderMap) -> bool {
|
||||
[
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-connection",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
]
|
||||
.iter()
|
||||
.any(|name| headers.contains_key(*name))
|
||||
|| headers
|
||||
.get_all("te")
|
||||
.iter()
|
||||
.any(|value| value.as_bytes() != b"trailers")
|
||||
|| headers.get_all("content-length").iter().count() > 1
|
||||
|| headers.get("content-length").is_some_and(|value| {
|
||||
value
|
||||
.to_str()
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.is_none()
|
||||
})
|
||||
}
|
||||
struct ReceiveBody {
|
||||
stream: h3::server::RequestStream<h3::quinn::RecvStream, Bytes>,
|
||||
expected: Option<u64>,
|
||||
received: u64,
|
||||
data_done: bool,
|
||||
done: bool,
|
||||
}
|
||||
impl HttpBody for ReceiveBody {
|
||||
type Data = Bytes;
|
||||
type Error = io::Error;
|
||||
fn poll_frame(
|
||||
self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
) -> Poll<Option<std::result::Result<Frame<Bytes>, io::Error>>> {
|
||||
let this = self.get_mut();
|
||||
if this.done {
|
||||
return Poll::Ready(None);
|
||||
}
|
||||
if !this.data_done {
|
||||
match this.stream.poll_recv_data(cx) {
|
||||
Poll::Pending => return Poll::Pending,
|
||||
Poll::Ready(Err(error)) => {
|
||||
this.done = true;
|
||||
return Poll::Ready(Some(Err(io::Error::other(error.to_string()))));
|
||||
}
|
||||
Poll::Ready(Ok(Some(mut data))) => {
|
||||
let length = data.remaining();
|
||||
this.received = this.received.saturating_add(length as u64);
|
||||
if this
|
||||
.expected
|
||||
.is_some_and(|expected| this.received > expected)
|
||||
{
|
||||
drop(data);
|
||||
this.done = true;
|
||||
this.stream.stop_sending(Code::H3_MESSAGE_ERROR);
|
||||
return Poll::Ready(Some(Err(io::Error::other(
|
||||
"HTTP/3 Content-Length exceeded",
|
||||
))));
|
||||
}
|
||||
return Poll::Ready(Some(Ok(Frame::data(data.copy_to_bytes(length)))));
|
||||
}
|
||||
Poll::Ready(Ok(None)) => this.data_done = true,
|
||||
}
|
||||
}
|
||||
match this.stream.poll_recv_trailers(cx) {
|
||||
Poll::Pending => Poll::Pending,
|
||||
Poll::Ready(Err(error)) => {
|
||||
this.done = true;
|
||||
Poll::Ready(Some(Err(io::Error::other(error.to_string()))))
|
||||
}
|
||||
Poll::Ready(Ok(trailers)) => {
|
||||
this.done = true;
|
||||
if this
|
||||
.expected
|
||||
.is_some_and(|expected| expected != this.received)
|
||||
{
|
||||
this.stream.stop_sending(Code::H3_MESSAGE_ERROR);
|
||||
return Poll::Ready(Some(Err(io::Error::other(
|
||||
"HTTP/3 Content-Length mismatch",
|
||||
))));
|
||||
}
|
||||
Poll::Ready(trailers.map(|mut headers| {
|
||||
crate::proxy::headers::strip_hop_by_hop(&mut headers);
|
||||
Ok(Frame::trailers(headers))
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
fn is_end_stream(&self) -> bool {
|
||||
self.done
|
||||
}
|
||||
fn size_hint(&self) -> SizeHint {
|
||||
// A declared zero is not proof that the peer ended its stream. Unknown
|
||||
// lengths are verified by the cache's bounded empty-body probe.
|
||||
let mut hint = SizeHint::new();
|
||||
if self.done {
|
||||
hint.set_exact(0);
|
||||
} else if let Some(remaining) = self
|
||||
.expected
|
||||
.map(|n| n.saturating_sub(self.received))
|
||||
.filter(|n| *n > 0)
|
||||
{
|
||||
hint.set_upper(remaining);
|
||||
}
|
||||
hint
|
||||
}
|
||||
}
|
||||
impl Drop for ReceiveBody {
|
||||
fn drop(&mut self) {
|
||||
// Dropping the request stream cancels the receive side at QUIC level.
|
||||
// Explicit STOP_SENDING is used only from poll_frame paths where the
|
||||
// stream handle is available and a protocol error was detected.
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
//! the background worker and is bounded by a fixed queue.
|
||||
|
||||
include!("logging/model.rs");
|
||||
include!("logging/error_runtime.rs");
|
||||
include!("logging/format.rs");
|
||||
include!("logging/runtime.rs");
|
||||
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
impl IoWrite for RedactingWriter {
|
||||
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||
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<regex::Regex> = OnceLock::new();
|
||||
static QUERY: OnceLock<regex::Regex> = OnceLock::new();
|
||||
static JSON: OnceLock<regex::Regex> = OnceLock::new();
|
||||
static KV: OnceLock<regex::Regex> = OnceLock::new();
|
||||
static PEM: OnceLock<regex::Regex> = 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<File> {
|
||||
let file = tokio::task::spawn_blocking(move || -> Result<std::fs::File> {
|
||||
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<u8> {
|
||||
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<ErrorEvent>,
|
||||
settings: Arc<ArcSwap<ErrorLogSettings>>,
|
||||
mut settings_changed: watch::Receiver<()>,
|
||||
written: Arc<AtomicU64>,
|
||||
errors: Arc<AtomicU64>,
|
||||
) {
|
||||
let mut open_path: Option<PathBuf> = None;
|
||||
let mut file: Option<File> = None;
|
||||
let mut retry_after: Option<Instant> = None;
|
||||
let mut last_warning: Option<Instant> = 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+95
-276
@@ -1,281 +1,10 @@
|
||||
impl IoWrite for RedactingWriter {
|
||||
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||
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<regex::Regex> = OnceLock::new();
|
||||
static QUERY: OnceLock<regex::Regex> = OnceLock::new();
|
||||
static JSON: OnceLock<regex::Regex> = OnceLock::new();
|
||||
static KV: OnceLock<regex::Regex> = OnceLock::new();
|
||||
static PEM: OnceLock<regex::Regex> = 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<File> {
|
||||
let file = tokio::task::spawn_blocking(move || -> Result<std::fs::File> {
|
||||
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<u8> {
|
||||
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<ErrorEvent>,
|
||||
settings: Arc<ArcSwap<ErrorLogSettings>>,
|
||||
mut settings_changed: watch::Receiver<()>,
|
||||
written: Arc<AtomicU64>,
|
||||
errors: Arc<AtomicU64>,
|
||||
) {
|
||||
let mut open_path: Option<PathBuf> = None;
|
||||
let mut file: Option<File> = None;
|
||||
let mut retry_after: Option<Instant> = None;
|
||||
let mut last_warning: Option<Instant> = 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 event: &'static str,
|
||||
pub protocol_error: Option<&'static str>,
|
||||
pub error_detail: Option<String>,
|
||||
pub local_addr: Option<String>,
|
||||
pub tls_sni: Option<String>,
|
||||
pub vhost: Option<String>,
|
||||
pub route: Option<String>,
|
||||
pub cache_effective_enabled: Option<bool>,
|
||||
@@ -342,6 +71,20 @@ impl Default for Logs {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn protocol_error_detail(detail: impl std::fmt::Display) -> String {
|
||||
let mut detail = redact_text(&detail.to_string());
|
||||
if detail.len() > 512 {
|
||||
let mut end = 512;
|
||||
while !detail.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
detail.truncate(end);
|
||||
detail.push_str("...");
|
||||
}
|
||||
detail
|
||||
}
|
||||
|
||||
impl Logs {
|
||||
pub fn start(settings: Settings, shutdown: CancellationToken) -> Arc<Self> {
|
||||
let (tx, rx) = mpsc::channel(SINK_QUEUE);
|
||||
@@ -382,10 +125,66 @@ impl Logs {
|
||||
self.dropped.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
let _ = self.live.send(entry.clone());
|
||||
self.push_sink(entry);
|
||||
}
|
||||
fn push_sink(&self, entry: LogEntry) {
|
||||
if self.settings.load().enabled() && self.sink.try_send(entry).is_err() {
|
||||
self.sink_dropped.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
/// Persist transport/protocol failures without polluting the bounded Live Logs buffer.
|
||||
pub fn push_protocol_error(
|
||||
&self,
|
||||
kind: &'static str,
|
||||
detail: impl std::fmt::Display,
|
||||
client: std::net::SocketAddr,
|
||||
local: std::net::SocketAddr,
|
||||
transport: &'static str,
|
||||
http_version: &'static str,
|
||||
status: u16,
|
||||
tls_sni: Option<String>,
|
||||
) {
|
||||
if !self.settings.load().enabled() {
|
||||
return;
|
||||
}
|
||||
let detail = protocol_error_detail(detail);
|
||||
self.push_sink(LogEntry {
|
||||
event: "protocol_error",
|
||||
protocol_error: Some(kind),
|
||||
error_detail: Some(detail),
|
||||
local_addr: Some(local.to_string()),
|
||||
tls_sni,
|
||||
vhost: None,
|
||||
route: None,
|
||||
cache_effective_enabled: None,
|
||||
transform_effective_enabled: None,
|
||||
backend: None,
|
||||
id: 0,
|
||||
request_id: String::new(),
|
||||
time_ms: now_ms(),
|
||||
method: String::new(),
|
||||
host: String::new(),
|
||||
path: String::new(),
|
||||
http_version,
|
||||
transport,
|
||||
websocket_bootstrap: None,
|
||||
websocket_phase: None,
|
||||
websocket_error: None,
|
||||
h3_reset_code: None,
|
||||
upstream_status: None,
|
||||
upstream_error: None,
|
||||
client_ip: Some(client.ip().to_string()),
|
||||
user_agent: None,
|
||||
country: None,
|
||||
status,
|
||||
duration_ms: 0,
|
||||
request_bytes: 0,
|
||||
response_bytes: 0,
|
||||
outcome: "protocol_error",
|
||||
cache: "-",
|
||||
cache_reason: "protocol_error",
|
||||
});
|
||||
}
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<LogEntry> {
|
||||
self.live.subscribe()
|
||||
}
|
||||
@@ -417,6 +216,26 @@ impl Logs {
|
||||
|
||||
fn value_for(entry: &LogEntry, name: &str) -> Value {
|
||||
match name {
|
||||
"event" => json!(entry.event),
|
||||
"protocol_error" => entry
|
||||
.protocol_error
|
||||
.map(|v| Value::String(v.into()))
|
||||
.unwrap_or(Value::Null),
|
||||
"error_detail" => entry
|
||||
.error_detail
|
||||
.clone()
|
||||
.map(Value::String)
|
||||
.unwrap_or(Value::Null),
|
||||
"local_addr" => entry
|
||||
.local_addr
|
||||
.clone()
|
||||
.map(Value::String)
|
||||
.unwrap_or(Value::Null),
|
||||
"tls_sni" => entry
|
||||
.tls_sni
|
||||
.clone()
|
||||
.map(Value::String)
|
||||
.unwrap_or(Value::Null),
|
||||
"vhost" => entry
|
||||
.vhost
|
||||
.clone()
|
||||
|
||||
@@ -22,7 +22,7 @@ use std::{
|
||||
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
use tokio::{
|
||||
fs::{File, OpenOptions},
|
||||
fs::File,
|
||||
io::AsyncWriteExt,
|
||||
net::TcpStream,
|
||||
sync::{broadcast, mpsc, watch},
|
||||
@@ -229,7 +229,12 @@ fn validate_json_key(key: &str) -> Result<()> {
|
||||
fn source_value_name(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"vhost"
|
||||
"event"
|
||||
| "protocol_error"
|
||||
| "error_detail"
|
||||
| "local_addr"
|
||||
| "tls_sni"
|
||||
| "vhost"
|
||||
| "route"
|
||||
| "cache_effective_enabled"
|
||||
| "transform_effective_enabled"
|
||||
|
||||
+43
-12
@@ -15,6 +15,23 @@ fn format_entry(entry: &LogEntry, format: &JsonFormat) -> Value {
|
||||
for (key, value) in &format.static_fields {
|
||||
object.insert(key.clone(), Value::String(value.clone()));
|
||||
}
|
||||
if entry.event == "protocol_error" {
|
||||
for name in [
|
||||
"event",
|
||||
"protocol_error",
|
||||
"error_detail",
|
||||
"time_ms",
|
||||
"client_ip",
|
||||
"local_addr",
|
||||
"tls_sni",
|
||||
"transport",
|
||||
"http_version",
|
||||
"status",
|
||||
"outcome",
|
||||
] {
|
||||
object.insert(name.into(), value_for(entry, name));
|
||||
}
|
||||
}
|
||||
Value::Object(object)
|
||||
}
|
||||
|
||||
@@ -69,6 +86,30 @@ struct LogstashState {
|
||||
address: String,
|
||||
stream: TcpStream,
|
||||
}
|
||||
|
||||
async fn open_access_file(path: PathBuf) -> Result<File> {
|
||||
let file = tokio::task::spawn_blocking(move || -> Result<std::fs::File> {
|
||||
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(), "access 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!("access log open worker failed"))??;
|
||||
Ok(File::from_std(file))
|
||||
}
|
||||
async fn sink_loop(
|
||||
mut rx: mpsc::Receiver<LogEntry>,
|
||||
settings: Arc<ArcSwap<Settings>>,
|
||||
@@ -113,12 +154,7 @@ async fn sink_loop(
|
||||
vhost_files.remove(&path);
|
||||
}
|
||||
}
|
||||
match OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.await
|
||||
{
|
||||
match open_access_file(path.clone()).await {
|
||||
Ok(opened) => {
|
||||
vhost_files.insert(path.clone(), opened);
|
||||
}
|
||||
@@ -142,12 +178,7 @@ async fn sink_loop(
|
||||
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
|
||||
{
|
||||
file = match open_access_file(path.clone()).await {
|
||||
Ok(opened) => Some(FileState {
|
||||
path: path.clone(),
|
||||
file: opened,
|
||||
|
||||
@@ -3,6 +3,11 @@ mod tests {
|
||||
|
||||
fn entry() -> LogEntry {
|
||||
LogEntry {
|
||||
event: "request",
|
||||
protocol_error: None,
|
||||
error_detail: None,
|
||||
local_addr: Some("192.0.2.1:443".into()),
|
||||
tls_sni: Some("app.example".into()),
|
||||
vhost: Some("app".into()),
|
||||
route: Some("root".into()),
|
||||
cache_effective_enabled: Some(true),
|
||||
@@ -108,11 +113,63 @@ mod tests {
|
||||
#[test]
|
||||
fn default_json_format_keeps_standard_entry() {
|
||||
let value = format_entry(&entry(), &JsonFormat::default());
|
||||
assert_eq!(value["event"], "request");
|
||||
assert_eq!(value["method"], "GET");
|
||||
assert_eq!(value["cache"], "HIT");
|
||||
assert_eq!(value["response_bytes"], 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_error_keeps_required_diagnostic_fields_with_custom_mapping() {
|
||||
let mut item = entry();
|
||||
item.event = "protocol_error";
|
||||
item.protocol_error = Some("tls_handshake_error");
|
||||
item.error_detail = Some("invalid handshake".into());
|
||||
item.outcome = "protocol_error";
|
||||
item.status = 0;
|
||||
item.transport = "tls";
|
||||
let mut format = JsonFormat::default();
|
||||
format.fields.insert("site".into(), "vhost".into());
|
||||
format.static_fields.insert("event".into(), "custom".into());
|
||||
let value = format_entry(&item, &format);
|
||||
assert_eq!(value["site"], "app");
|
||||
assert_eq!(value["event"], "protocol_error");
|
||||
assert_eq!(value["protocol_error"], "tls_handshake_error");
|
||||
assert_eq!(value["error_detail"], "invalid handshake");
|
||||
assert_eq!(value["client_ip"], "192.0.2.10");
|
||||
assert_eq!(value["transport"], "tls");
|
||||
assert_eq!(value["outcome"], "protocol_error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_errors_skip_live_history_and_use_persistent_sink_only() {
|
||||
let logs = Logs::default();
|
||||
let mut settings = Settings::default();
|
||||
settings.file.enabled = true;
|
||||
logs.configure(settings);
|
||||
logs.push_protocol_error(
|
||||
"tls_handshake_error",
|
||||
"invalid token=topsecret",
|
||||
"192.0.2.10:50000".parse().unwrap(),
|
||||
"192.0.2.20:443".parse().unwrap(),
|
||||
"tls",
|
||||
"",
|
||||
0,
|
||||
Some("app.example".into()),
|
||||
);
|
||||
assert!(logs.recent().is_empty());
|
||||
assert_eq!(logs.sink_dropped.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_error_detail_is_utf8_safe_bounded_and_redacted() {
|
||||
let detail = protocol_error_detail(format!("token=topsecret {}", "€".repeat(300)));
|
||||
assert!(!detail.contains("topsecret"));
|
||||
assert!(detail.is_char_boundary(detail.len()));
|
||||
assert!(detail.len() <= 515);
|
||||
assert!(detail.ends_with("..."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_log_defaults_off_and_redacts_common_secret_shapes() {
|
||||
let settings = ErrorLogSettings::default();
|
||||
|
||||
@@ -41,7 +41,7 @@ use zeroize::Zeroizing;
|
||||
#[command(
|
||||
name = "rust-proxy",
|
||||
version,
|
||||
about = "RustProxy - independent Rust reverse proxy and static Web Server 0.25.7"
|
||||
about = "RustProxy - independent Rust reverse proxy and static Web Server 0.26.0"
|
||||
)]
|
||||
struct Cli {
|
||||
#[arg(
|
||||
|
||||
+1
-1
@@ -239,7 +239,7 @@ async fn live_logs_command(dir: PathBuf, addr: SocketAddr) -> Result<()> {
|
||||
}
|
||||
});
|
||||
let interactive = io::stdin().is_terminal() && io::stdout().is_terminal();
|
||||
println!("RustProxy live logs 0.25.7 - all vhosts. Type 'help' for interactive filters; Ctrl-C or 'quit' exits.");
|
||||
println!("RustProxy live logs 0.26.0 - all vhosts. Type 'help' for interactive filters; Ctrl-C or 'quit' exits.");
|
||||
if interactive { print!("> "); io::stdout().flush()?; }
|
||||
let mut filters = LiveLogFilters::default();
|
||||
let mut history = std::collections::BTreeMap::<u64, serde_json::Value>::new();
|
||||
|
||||
@@ -202,6 +202,11 @@ impl Drop for Exchange {
|
||||
});
|
||||
}
|
||||
self.logs.push(LogEntry {
|
||||
event: "request",
|
||||
protocol_error: None,
|
||||
error_detail: None,
|
||||
local_addr: None,
|
||||
tls_sni: None,
|
||||
cache_reason: *self.cache_reason.lock().unwrap_or_else(|e| e.into_inner()),
|
||||
vhost: self.vhost.clone(),
|
||||
route,
|
||||
|
||||
@@ -87,6 +87,31 @@ pub struct LocalAddr(pub SocketAddr);
|
||||
pub struct Transport {
|
||||
pub tls: bool,
|
||||
}
|
||||
|
||||
fn log_protocol_error(
|
||||
state: Option<&Arc<App>>,
|
||||
kind: &'static str,
|
||||
detail: impl std::fmt::Display,
|
||||
peer: SocketAddr,
|
||||
local: SocketAddr,
|
||||
transport: &'static str,
|
||||
http_version: &'static str,
|
||||
status: u16,
|
||||
tls_sni: Option<String>,
|
||||
) {
|
||||
if let Some(state) = state {
|
||||
state.logs.push_protocol_error(
|
||||
kind,
|
||||
detail,
|
||||
peer,
|
||||
local,
|
||||
transport,
|
||||
http_version,
|
||||
status,
|
||||
tls_sni,
|
||||
);
|
||||
}
|
||||
}
|
||||
#[derive(Clone)]
|
||||
pub struct ServerShutdown(pub CancellationToken);
|
||||
#[derive(Clone)]
|
||||
|
||||
@@ -6,6 +6,7 @@ async fn http2_connection<I>(
|
||||
local: SocketAddr,
|
||||
transport: Transport,
|
||||
extended_connect: bool,
|
||||
frontend_state: Option<Arc<App>>,
|
||||
lease: Arc<ConnectionLease>,
|
||||
stop: CancellationToken,
|
||||
grace: Duration,
|
||||
@@ -66,6 +67,10 @@ async fn http2_connection<I>(
|
||||
tokio::select! {
|
||||
result = &mut conn => {
|
||||
if let Err(error) = result {
|
||||
log_protocol_error(
|
||||
frontend_state.as_ref(), "http2_connection_error", &error, peer, local,
|
||||
if transport.tls { "tls" } else { "tcp" }, "2", 0, None,
|
||||
);
|
||||
tracing::debug!(%error, "HTTP/2 connection closed");
|
||||
}
|
||||
return;
|
||||
@@ -94,6 +99,17 @@ async fn http2_connection<I>(
|
||||
match tokio::time::timeout(drain, &mut conn).await {
|
||||
Ok(Ok(())) => tracing::debug!(?cause, "HTTP/2 graceful shutdown drained"),
|
||||
Ok(Err(error)) => {
|
||||
log_protocol_error(
|
||||
frontend_state.as_ref(),
|
||||
"http2_drain_protocol_error",
|
||||
&error,
|
||||
peer,
|
||||
local,
|
||||
if transport.tls { "tls" } else { "tcp" },
|
||||
"2",
|
||||
0,
|
||||
None,
|
||||
);
|
||||
tracing::debug!(?cause, %error, "HTTP/2 graceful shutdown ended with protocol error")
|
||||
}
|
||||
Err(_) => tracing::debug!(
|
||||
|
||||
+108
-15
@@ -23,6 +23,18 @@ async fn serve_inner(
|
||||
let local = stream.local_addr().context("accepted local address unavailable")?;
|
||||
let Ok(permit) = permits.clone().try_acquire_owned() else {
|
||||
if let Some(m) = &metrics { m.rejected_connections.fetch_add(1, Ordering::Relaxed); }
|
||||
let log_state = match &tls {
|
||||
Some(TlsMode::Frontend(state, _)) => Some(state),
|
||||
_ => frontend_state.as_ref(),
|
||||
};
|
||||
log_protocol_error(
|
||||
log_state,
|
||||
"connection_limit_rejected",
|
||||
"connection concurrency limit reached",
|
||||
peer, local,
|
||||
if tls.is_some() { "tls" } else { "tcp" },
|
||||
"", 0, None,
|
||||
);
|
||||
drop(stream); continue;
|
||||
};
|
||||
let _ = stream.set_nodelay(true);
|
||||
@@ -46,16 +58,31 @@ async fn serve_inner(
|
||||
_ = stop.cancelled() => return,
|
||||
started = tokio::time::timeout_at(deadline, acceptor) => started,
|
||||
};
|
||||
let Ok(Ok(start)) = started else {
|
||||
state.metrics.tls_handshake_errors.fetch_add(1, Ordering::Relaxed);
|
||||
return;
|
||||
let start = match started {
|
||||
Ok(Ok(start)) => start,
|
||||
Ok(Err(error)) => {
|
||||
state.metrics.tls_handshake_errors.fetch_add(1, Ordering::Relaxed);
|
||||
log_protocol_error(
|
||||
Some(&state), "tls_client_hello_error", error, peer, local,
|
||||
"tls", "", 0, None,
|
||||
);
|
||||
return;
|
||||
}
|
||||
Err(_) => {
|
||||
state.metrics.tls_handshake_errors.fetch_add(1, Ordering::Relaxed);
|
||||
log_protocol_error(
|
||||
Some(&state), "tls_client_hello_timeout",
|
||||
"TLS ClientHello timeout", peer, local, "tls", "", 0, None,
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let host = start
|
||||
.client_hello()
|
||||
.server_name()
|
||||
.and_then(|name| crate::config::request_host(name).ok());
|
||||
let config = {
|
||||
let snapshot = state.config.load();
|
||||
let host = start
|
||||
.client_hello()
|
||||
.server_name()
|
||||
.and_then(|name| crate::config::request_host(name).ok());
|
||||
let frontend = host
|
||||
.as_deref()
|
||||
.and_then(|host| snapshot.frontend(host, local, true));
|
||||
@@ -74,7 +101,14 @@ async fn serve_inner(
|
||||
snapshot.tls_http1.clone()
|
||||
}
|
||||
};
|
||||
let Some(config) = config else { return; };
|
||||
let Some(config) = config else {
|
||||
log_protocol_error(
|
||||
Some(&state), "tls_configuration_unavailable",
|
||||
"no TLS configuration is available for this connection",
|
||||
peer, local, "tls", "", 0, host.clone(),
|
||||
);
|
||||
return;
|
||||
};
|
||||
let accepted = tokio::select! {
|
||||
_ = stop.cancelled() => return,
|
||||
accepted = tokio::time::timeout_at(deadline, start.into_stream(config)) => accepted,
|
||||
@@ -94,7 +128,7 @@ async fn serve_inner(
|
||||
h2,
|
||||
extended_connect,
|
||||
false,
|
||||
None,
|
||||
Some(state.clone()),
|
||||
lease,
|
||||
stop,
|
||||
grace,
|
||||
@@ -102,7 +136,20 @@ async fn serve_inner(
|
||||
)
|
||||
.await;
|
||||
},
|
||||
_ => { state.metrics.tls_handshake_errors.fetch_add(1, Ordering::Relaxed); },
|
||||
Ok(Err(error)) => {
|
||||
state.metrics.tls_handshake_errors.fetch_add(1, Ordering::Relaxed);
|
||||
log_protocol_error(
|
||||
Some(&state), "tls_handshake_error", error, peer, local,
|
||||
"tls", "", 0, host.clone(),
|
||||
);
|
||||
},
|
||||
Err(_) => {
|
||||
state.metrics.tls_handshake_errors.fetch_add(1, Ordering::Relaxed);
|
||||
log_protocol_error(
|
||||
Some(&state), "tls_handshake_timeout", "TLS handshake timeout",
|
||||
peer, local, "tls", "", 0, host.clone(),
|
||||
);
|
||||
},
|
||||
}
|
||||
}
|
||||
Some(TlsMode::Fixed(config, plaintext_router)) => {
|
||||
@@ -232,7 +279,7 @@ async fn connection<I>(
|
||||
h2: bool,
|
||||
extended_connect: bool,
|
||||
h2c_upgrade: bool,
|
||||
h2c_state: Option<Arc<App>>,
|
||||
frontend_state: Option<Arc<App>>,
|
||||
lease: Arc<ConnectionLease>,
|
||||
stop: CancellationToken,
|
||||
grace: Duration,
|
||||
@@ -248,6 +295,7 @@ async fn connection<I>(
|
||||
local,
|
||||
transport,
|
||||
extended_connect,
|
||||
frontend_state.clone(),
|
||||
lease,
|
||||
stop,
|
||||
grace,
|
||||
@@ -257,11 +305,12 @@ async fn connection<I>(
|
||||
return;
|
||||
}
|
||||
let request_stop = stop.clone();
|
||||
let service_frontend_state = frontend_state.clone();
|
||||
let service = service_fn(move |mut request: Request<Incoming>| {
|
||||
let router = router.clone();
|
||||
let request_stop = request_stop.clone();
|
||||
let request_lease = lease.clone();
|
||||
let upgrade_state = h2c_state.clone();
|
||||
let upgrade_state = service_frontend_state.clone();
|
||||
async move {
|
||||
request.extensions_mut().insert(PeerAddr(peer));
|
||||
request.extensions_mut().insert(LocalAddr(local));
|
||||
@@ -273,6 +322,17 @@ async fn connection<I>(
|
||||
|
||||
if h2c_attempt(&request) {
|
||||
if !h2c_upgrade {
|
||||
log_protocol_error(
|
||||
upgrade_state.as_ref(),
|
||||
"h2c_upgrade_rejected",
|
||||
"h2c upgrade is disabled on this listener",
|
||||
peer,
|
||||
local,
|
||||
"tcp",
|
||||
"1.1",
|
||||
400,
|
||||
None,
|
||||
);
|
||||
return Ok::<_, Infallible>(
|
||||
Response::builder()
|
||||
.status(http::StatusCode::BAD_REQUEST)
|
||||
@@ -284,13 +344,24 @@ async fn connection<I>(
|
||||
let injected_headers = match h2_headers_frame(&request) {
|
||||
Ok(frame) => frame,
|
||||
Err(message) => {
|
||||
log_protocol_error(
|
||||
upgrade_state.as_ref(),
|
||||
"h2c_upgrade_invalid",
|
||||
message,
|
||||
peer,
|
||||
local,
|
||||
"tcp",
|
||||
"1.1",
|
||||
400,
|
||||
None,
|
||||
);
|
||||
return Ok::<_, Infallible>(
|
||||
Response::builder()
|
||||
.status(http::StatusCode::BAD_REQUEST)
|
||||
.header(http::header::CACHE_CONTROL, "no-store")
|
||||
.body(Body::from(message))
|
||||
.expect("static h2c validation response"),
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
let on_upgrade = hyper::upgrade::on(&mut request);
|
||||
@@ -319,6 +390,7 @@ async fn connection<I>(
|
||||
local,
|
||||
Transport { tls: false },
|
||||
extended_connect,
|
||||
upgrade_state_for_task.clone(),
|
||||
upgrade_lease,
|
||||
upgrade_stop,
|
||||
grace,
|
||||
@@ -326,7 +398,20 @@ async fn connection<I>(
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(error) => tracing::debug!(%error, "h2c upgrade transport failed"),
|
||||
Err(error) => {
|
||||
log_protocol_error(
|
||||
upgrade_state_for_task.as_ref(),
|
||||
"h2c_transport_error",
|
||||
&error,
|
||||
peer,
|
||||
local,
|
||||
"tcp",
|
||||
"2",
|
||||
0,
|
||||
None,
|
||||
);
|
||||
tracing::debug!(%error, "h2c upgrade transport failed");
|
||||
}
|
||||
}
|
||||
};
|
||||
if let Some(state) = upgrade_state {
|
||||
@@ -359,7 +444,15 @@ async fn connection<I>(
|
||||
.with_upgrades();
|
||||
tokio::pin!(conn);
|
||||
tokio::select! {
|
||||
result = &mut conn => { if let Err(error) = result { tracing::debug!(%error, "HTTP/1 connection closed"); } },
|
||||
result = &mut conn => {
|
||||
if let Err(error) = result {
|
||||
log_protocol_error(
|
||||
frontend_state.as_ref(), "http1_connection_error", &error, peer, local,
|
||||
if transport.tls { "tls" } else { "tcp" }, "1.1", 400, None,
|
||||
);
|
||||
tracing::debug!(%error, "HTTP/1 connection closed");
|
||||
}
|
||||
},
|
||||
_ = stop.cancelled() => {
|
||||
conn.as_mut().graceful_shutdown(); let _ = tokio::time::timeout(grace, &mut conn).await;
|
||||
},
|
||||
|
||||
@@ -2,11 +2,11 @@ use super::{
|
||||
config::{CompiledPhp, FastCgiEndpoint, MAX_HEADER_VALUE_BYTES},
|
||||
filesystem::path_components,
|
||||
htaccess,
|
||||
response::{builtin_error_response, status_response},
|
||||
request::server_protocol,
|
||||
response::{builtin_error_response, status_response},
|
||||
CompiledService, RequestContext,
|
||||
};
|
||||
use anyhow::{Context, ensure, Result};
|
||||
use anyhow::{ensure, Context, Result};
|
||||
use axum::{body::Body, response::Response};
|
||||
use bytes::Bytes;
|
||||
use futures_util::stream;
|
||||
@@ -38,9 +38,7 @@ impl<T> FastCgiIo for T where T: AsyncRead + AsyncWrite + Unpin + Send {}
|
||||
async fn connect_fastcgi(endpoint: &FastCgiEndpoint) -> io::Result<Box<dyn FastCgiIo>> {
|
||||
match endpoint {
|
||||
#[cfg(unix)]
|
||||
FastCgiEndpoint::Unix(path) => {
|
||||
Ok(Box::new(tokio::net::UnixStream::connect(path).await?))
|
||||
}
|
||||
FastCgiEndpoint::Unix(path) => Ok(Box::new(tokio::net::UnixStream::connect(path).await?)),
|
||||
FastCgiEndpoint::Tcp(address) => {
|
||||
Ok(Box::new(tokio::net::TcpStream::connect(address).await?))
|
||||
}
|
||||
@@ -95,7 +93,6 @@ fn encode_fcgi_param(target: &mut Vec<u8>, name: &str, value: &str) -> io::Resul
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
fn fastcgi_header_param(name: &HeaderName) -> Option<String> {
|
||||
let lower = name.as_str();
|
||||
if matches!(
|
||||
@@ -147,32 +144,45 @@ pub(super) fn fastcgi_params(
|
||||
params.insert("QUERY_STRING".into(), query.unwrap_or("").into());
|
||||
params.insert("REQUEST_URI".into(), request_uri.into());
|
||||
params.insert("DOCUMENT_ROOT".into(), document_root);
|
||||
params.insert("SERVER_PROTOCOL".into(), server_protocol(context.version).into());
|
||||
params.insert(
|
||||
"SERVER_PROTOCOL".into(),
|
||||
server_protocol(context.version).into(),
|
||||
);
|
||||
params.insert("SERVER_NAME".into(), context.server_name.clone());
|
||||
params.insert("SERVER_PORT".into(), context.server_port.to_string());
|
||||
params.insert("REMOTE_ADDR".into(), context.client_ip.to_string());
|
||||
if let Some(port) = context.client_port {
|
||||
params.insert("REMOTE_PORT".into(), port.to_string());
|
||||
}
|
||||
params.insert("HTTPS".into(), if context.tls { "on" } else { "off" }.into());
|
||||
params.insert(
|
||||
"HTTPS".into(),
|
||||
if context.tls { "on" } else { "off" }.into(),
|
||||
);
|
||||
params.insert("GATEWAY_INTERFACE".into(), "CGI/1.1".into());
|
||||
params.insert("SERVER_SOFTWARE".into(), "RustProxy/0.25.7".into());
|
||||
params.insert("SERVER_SOFTWARE".into(), "RustProxy/0.26.0".into());
|
||||
params.insert("REDIRECT_STATUS".into(), "200".into());
|
||||
if let Some(value) = headers.get(CONTENT_TYPE).and_then(|value| value.to_str().ok()) {
|
||||
if let Some(value) = headers
|
||||
.get(CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
{
|
||||
params.insert("CONTENT_TYPE".into(), value.into());
|
||||
}
|
||||
if let Some(value) = headers.get(CONTENT_LENGTH).and_then(|value| value.to_str().ok()) {
|
||||
if let Some(value) = headers
|
||||
.get(CONTENT_LENGTH)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
{
|
||||
params.insert("CONTENT_LENGTH".into(), value.into());
|
||||
}
|
||||
for (name, value) in headers {
|
||||
// HTTP_PROXY is a well-known CGI/FastCGI injection vector (httpoxy).
|
||||
// This must never depend on the optional Web Service hardening switch.
|
||||
if name.as_str() == "proxy"
|
||||
|| (!php.pass_authorization && name.as_str() == "authorization")
|
||||
if name.as_str() == "proxy" || (!php.pass_authorization && name.as_str() == "authorization")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(param) = fastcgi_header_param(name) else { continue };
|
||||
let Some(param) = fastcgi_header_param(name) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(value) = value.to_str() else { continue };
|
||||
if value.len() > MAX_HEADER_VALUE_BYTES {
|
||||
continue;
|
||||
@@ -224,7 +234,8 @@ pub(super) async fn read_fcgi_record(
|
||||
tokio::time::timeout_at(deadline, io.read_exact(&mut header))
|
||||
.await
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "FastCGI response timeout"))??;
|
||||
if header[0] != FCGI_VERSION_1 || u16::from_be_bytes([header[2], header[3]]) != FCGI_REQUEST_ID {
|
||||
if header[0] != FCGI_VERSION_1 || u16::from_be_bytes([header[2], header[3]]) != FCGI_REQUEST_ID
|
||||
{
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"invalid FastCGI record header",
|
||||
@@ -325,7 +336,10 @@ async fn drain_fastcgi_body(
|
||||
loop {
|
||||
match read_fcgi_record(io.as_mut(), deadline).await {
|
||||
Ok((FCGI_STDOUT, content)) => {
|
||||
if send_body && !content.is_empty() && tx.send(Ok(Bytes::from(content))).await.is_err() {
|
||||
if send_body
|
||||
&& !content.is_empty()
|
||||
&& tx.send(Ok(Bytes::from(content))).await.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +85,8 @@ pub(super) async fn file_response(
|
||||
if let Some(range) = range {
|
||||
response.headers_mut().insert(
|
||||
CONTENT_RANGE,
|
||||
HeaderValue::from_str(&format!("bytes {}-{}/{length}", range.start, range.end)).unwrap(),
|
||||
HeaderValue::from_str(&format!("bytes {}-{}/{length}", range.start, range.end))
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
add_validators(&mut response, etag.as_deref(), modified);
|
||||
@@ -173,14 +174,18 @@ pub(super) fn directory_listing(path: &str, entries: &[DirectoryEntry]) -> Vec<u
|
||||
}
|
||||
let href = html_escape(&href);
|
||||
let slash = if entry.directory { "/" } else { "" };
|
||||
let class = if entry.directory { "entry directory" } else { "entry file" };
|
||||
let class = if entry.directory {
|
||||
"entry directory"
|
||||
} else {
|
||||
"entry file"
|
||||
};
|
||||
let kind = if entry.directory { "Directory" } else { "File" };
|
||||
let type_label = if entry.directory { "DIR" } else { "FILE" };
|
||||
html.push_str(&format!(
|
||||
"<a class=\"{class}\" href=\"{href}\"><span class=\"type\">{type_label}</span><span class=\"name\">{label}{slash}</span><span class=\"kind\">{kind}</span></a>"
|
||||
));
|
||||
}
|
||||
html.push_str(r#"</div></section><footer class="page-footer"><span>RustProxy <b>0.25.7</b></span><span>Web Server · Directory listing</span></footer></main></body></html>"#);
|
||||
html.push_str(r#"</div></section><footer class="page-footer"><span>RustProxy <b>0.26.0</b></span><span>Web Server · Directory listing</span></footer></main></body></html>"#);
|
||||
html.into_bytes()
|
||||
}
|
||||
|
||||
|
||||
@@ -202,7 +202,7 @@ async fn empty_index_can_go_directly_to_directory_listing() {
|
||||
let body = std::str::from_utf8(&body).unwrap();
|
||||
assert!(body.contains("file.txt"));
|
||||
assert!(body.contains("DIRECTORY LISTING"));
|
||||
assert!(body.contains("RustProxy <b>0.25.7</b>"));
|
||||
assert!(body.contains("RustProxy <b>0.26.0</b>"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
Reference in new issue
Block a user