v0.26.1
This commit is contained in:
1 parent
ddc5c6593f
commit
b9d137eee1
63 files changed
+1232
-445
No files matched your search
@@ -399,6 +399,14 @@ pub(super) static READER_DOCUMENTS: &[(&str, &str, &str)] = &[
|
||||
"/docs/V0.25.1_AUDIT.md"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"docs/V0.26.1_IMPLEMENTATION.md",
|
||||
"technical",
|
||||
include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/docs/V0.26.1_IMPLEMENTATION.md"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"docs/V0.25.5_AUDIT.md",
|
||||
"audits",
|
||||
|
||||
+75
-6
@@ -229,12 +229,62 @@ fn read_tail(
|
||||
Ok((lines, bytes, truncated || iter.next().is_some()))
|
||||
}
|
||||
|
||||
fn normalized_record(source: &FileLogSource, line: &str) -> Value {
|
||||
fn copy_mapped_field(
|
||||
object: &mut serde_json::Map<String, Value>,
|
||||
format: &crate::logging::JsonFormat,
|
||||
canonical: &str,
|
||||
) {
|
||||
if object.contains_key(canonical) {
|
||||
return;
|
||||
}
|
||||
let mapped = format
|
||||
.fields
|
||||
.iter()
|
||||
.find_map(|(output, source)| (source == canonical).then_some(output));
|
||||
if let Some(value) = mapped.and_then(|output| object.get(output)).cloned() {
|
||||
object.insert(canonical.to_owned(), value);
|
||||
}
|
||||
}
|
||||
|
||||
fn normalized_record(
|
||||
source: &FileLogSource,
|
||||
line: &str,
|
||||
format: &crate::logging::JsonFormat,
|
||||
) -> 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");
|
||||
if source.kind == "access" {
|
||||
for field in [
|
||||
"event", "time_ms", "status", "client_ip", "vhost", "method", "host", "path",
|
||||
"transport", "http_version", "protocol_error", "error_detail", "tls_sni", "reason",
|
||||
"error", "sni", "authority", "matched_by",
|
||||
] {
|
||||
copy_mapped_field(object, format, field);
|
||||
}
|
||||
if !object.contains_key("client_ip") {
|
||||
if let Some(value) = object.get("source_ip").cloned() {
|
||||
object.insert("client_ip".into(), value);
|
||||
}
|
||||
}
|
||||
if !object.contains_key("reason") {
|
||||
if let Some(value) = object.get("protocol_error").cloned() {
|
||||
object.insert("reason".into(), value);
|
||||
}
|
||||
}
|
||||
if !object.contains_key("error") {
|
||||
if let Some(value) = object.get("error_detail").cloned() {
|
||||
object.insert("error".into(), value);
|
||||
}
|
||||
}
|
||||
if !object.contains_key("sni") {
|
||||
if let Some(value) = object.get("tls_sni").cloned() {
|
||||
object.insert("sni".into(), value);
|
||||
}
|
||||
}
|
||||
}
|
||||
object.entry("event").or_insert_with(|| {
|
||||
Value::String(
|
||||
if source.kind == "error" {
|
||||
@@ -256,12 +306,27 @@ fn contains_ci(value: Option<&Value>, needle: &str) -> bool {
|
||||
.is_some_and(|text| text.to_ascii_lowercase().contains(needle))
|
||||
}
|
||||
|
||||
fn append_search_values(value: &Value, output: &mut String) {
|
||||
match value {
|
||||
Value::Null => {}
|
||||
Value::Bool(value) => { output.push_str(if *value { " true" } else { " false" }); }
|
||||
Value::Number(value) => { output.push(' '); output.push_str(&value.to_string()); }
|
||||
Value::String(value) => { output.push(' '); output.push_str(&value.to_ascii_lowercase()); }
|
||||
Value::Array(values) => values.iter().for_each(|value| append_search_values(value, output)),
|
||||
Value::Object(values) => values.values().for_each(|value| append_search_values(value, output)),
|
||||
}
|
||||
}
|
||||
|
||||
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 !q.is_empty() {
|
||||
let mut searchable = String::new();
|
||||
append_search_values(value, &mut searchable);
|
||||
if !q.split_whitespace().all(|term| searchable.contains(term)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(event) = query
|
||||
.event
|
||||
@@ -345,7 +410,9 @@ async fn file_logs_query(
|
||||
let config = state.config.load().envelope.config.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut sources = configured_file_log_sources(&config);
|
||||
let log_format = config.logging.format.clone();
|
||||
let requested = query.source.as_deref().unwrap_or("all");
|
||||
let mut truncated = false;
|
||||
if requested != "all" {
|
||||
sources.retain(|source| source.id == requested);
|
||||
if sources.is_empty() {
|
||||
@@ -356,7 +423,10 @@ async fn file_logs_query(
|
||||
}
|
||||
} else {
|
||||
sources.sort_by(|a, b| b.modified_ms.cmp(&a.modified_ms));
|
||||
sources.truncate(FILE_LOG_MAX_SOURCES);
|
||||
if sources.len() > FILE_LOG_MAX_SOURCES {
|
||||
truncated = true;
|
||||
sources.truncate(FILE_LOG_MAX_SOURCES);
|
||||
}
|
||||
}
|
||||
let q = query.q.clone().unwrap_or_default().to_ascii_lowercase();
|
||||
let offset = query.offset.unwrap_or(0);
|
||||
@@ -365,7 +435,6 @@ async fn file_logs_query(
|
||||
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 {
|
||||
@@ -385,7 +454,7 @@ async fn file_logs_query(
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let value = normalized_record(source, &line);
|
||||
let value = normalized_record(source, &line, &log_format);
|
||||
if record_matches(&value, &query, &q) {
|
||||
records.push(value);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
include!("router.rs");
|
||||
include!("access.rs");
|
||||
include!("session.rs");
|
||||
include!("preferences.rs");
|
||||
include!("status.rs");
|
||||
include!("logging_api.rs");
|
||||
include!("file_logs.rs");
|
||||
|
||||
+33
-1
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "RustProxy Administration API",
|
||||
"version": "0.26.0",
|
||||
"version": "0.26.1",
|
||||
"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": [
|
||||
@@ -2909,6 +2909,38 @@
|
||||
},
|
||||
"description": "Account-scoped Live Logs columns shown in the dashboard. JSON log export still contains every field present in each entry."
|
||||
},
|
||||
"file_log_columns": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 11,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"time",
|
||||
"event",
|
||||
"status",
|
||||
"client_ip",
|
||||
"vhost",
|
||||
"transport",
|
||||
"matched_by",
|
||||
"sni",
|
||||
"authority",
|
||||
"record",
|
||||
"source"
|
||||
]
|
||||
},
|
||||
"default": [
|
||||
"time",
|
||||
"event",
|
||||
"status",
|
||||
"client_ip",
|
||||
"vhost",
|
||||
"record",
|
||||
"source"
|
||||
],
|
||||
"description": "Account-scoped Stored Logs explorer columns shown in the dashboard."
|
||||
},
|
||||
"theme": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
const LIVE_LOG_COLUMNS: &[&str] = &[
|
||||
"time",
|
||||
"request",
|
||||
"host",
|
||||
"source_ip",
|
||||
"vhost",
|
||||
"backend",
|
||||
"status",
|
||||
"duration",
|
||||
"cache",
|
||||
"outcome",
|
||||
"transport",
|
||||
"websocket",
|
||||
"upstream_error",
|
||||
];
|
||||
const FILE_LOG_COLUMNS: &[&str] = &[
|
||||
"time", "event", "status", "client_ip", "vhost", "transport", "matched_by", "sni",
|
||||
"authority", "record", "source",
|
||||
];
|
||||
const BACKEND_TABLE_COLUMNS: &[&str] = &[
|
||||
"vhost",
|
||||
"backend",
|
||||
"status",
|
||||
"active",
|
||||
"requests",
|
||||
"errors",
|
||||
"latency",
|
||||
"traffic",
|
||||
"generation",
|
||||
"generationRequests",
|
||||
"generationAge",
|
||||
"rotations",
|
||||
"lastFailure",
|
||||
"lastRecovery",
|
||||
];
|
||||
|
||||
fn default_admin_theme() -> String {
|
||||
"dark".to_owned()
|
||||
}
|
||||
fn default_file_log_columns() -> Vec<String> {
|
||||
["time", "event", "status", "client_ip", "vhost", "record", "source"]
|
||||
.into_iter()
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
}
|
||||
fn default_backend_table_columns() -> Vec<String> {
|
||||
BACKEND_TABLE_COLUMNS
|
||||
.iter()
|
||||
.map(|value| (*value).to_owned())
|
||||
.collect()
|
||||
}
|
||||
fn default_backend_table_sort() -> String {
|
||||
"vhost".to_owned()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct AdminPreferences {
|
||||
live_log_columns: Vec<String>,
|
||||
#[serde(default = "default_admin_theme")]
|
||||
theme: String,
|
||||
#[serde(default = "default_file_log_columns")]
|
||||
file_log_columns: Vec<String>,
|
||||
#[serde(default = "default_backend_table_columns")]
|
||||
backend_table_columns: Vec<String>,
|
||||
#[serde(default = "default_backend_table_sort")]
|
||||
backend_table_sort: String,
|
||||
#[serde(default)]
|
||||
backend_table_descending: bool,
|
||||
}
|
||||
|
||||
impl Default for AdminPreferences {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
live_log_columns: LIVE_LOG_COLUMNS
|
||||
.iter()
|
||||
.map(|value| (*value).to_owned())
|
||||
.collect(),
|
||||
theme: default_admin_theme(),
|
||||
file_log_columns: default_file_log_columns(),
|
||||
backend_table_columns: default_backend_table_columns(),
|
||||
backend_table_sort: default_backend_table_sort(),
|
||||
backend_table_descending: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_admin_preferences(preferences: &AdminPreferences) -> Result<(), ApiError> {
|
||||
if preferences.live_log_columns.is_empty()
|
||||
|| preferences.live_log_columns.len() > LIVE_LOG_COLUMNS.len()
|
||||
{
|
||||
return Err(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"at least one live-log column must be visible".into(),
|
||||
));
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for column in &preferences.live_log_columns {
|
||||
if !LIVE_LOG_COLUMNS.contains(&column.as_str()) || !seen.insert(column.as_str()) {
|
||||
return Err(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"invalid live-log column preference".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if preferences.file_log_columns.is_empty()
|
||||
|| preferences.file_log_columns.len() > FILE_LOG_COLUMNS.len()
|
||||
{
|
||||
return Err(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"at least one stored-log column must be visible".into(),
|
||||
));
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for column in &preferences.file_log_columns {
|
||||
if !FILE_LOG_COLUMNS.contains(&column.as_str()) || !seen.insert(column.as_str()) {
|
||||
return Err(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"invalid stored-log column preference".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if !matches!(preferences.theme.as_str(), "dark" | "light") {
|
||||
return Err(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"invalid administration theme preference".into(),
|
||||
));
|
||||
}
|
||||
if preferences.backend_table_columns.is_empty()
|
||||
|| preferences.backend_table_columns.len() > BACKEND_TABLE_COLUMNS.len()
|
||||
{
|
||||
return Err(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"at least one backend table column must be visible".into(),
|
||||
));
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for column in &preferences.backend_table_columns {
|
||||
if !BACKEND_TABLE_COLUMNS.contains(&column.as_str()) || !seen.insert(column.as_str()) {
|
||||
return Err(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"invalid backend table column preference".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if !BACKEND_TABLE_COLUMNS.contains(&preferences.backend_table_sort.as_str()) {
|
||||
return Err(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"invalid backend table sort preference".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn preferences(
|
||||
State(state): State<Arc<App>>,
|
||||
Extension(session): Extension<Session>,
|
||||
) -> Result<Json<AdminPreferences>, ApiError> {
|
||||
let stored = state
|
||||
.store
|
||||
.admin_preferences(&session.username)
|
||||
.await
|
||||
.map_err(auth::internal)?;
|
||||
let preferences = match stored {
|
||||
Some(document) => match serde_json::from_str::<AdminPreferences>(&document) {
|
||||
Ok(preferences) if validate_admin_preferences(&preferences).is_ok() => preferences,
|
||||
Ok(_) | Err(_) => {
|
||||
tracing::warn!(user = %session.username, "invalid admin view preferences ignored");
|
||||
AdminPreferences::default()
|
||||
}
|
||||
},
|
||||
None => AdminPreferences::default(),
|
||||
};
|
||||
Ok(Json(preferences))
|
||||
}
|
||||
|
||||
async fn update_preferences(
|
||||
State(state): State<Arc<App>>,
|
||||
Extension(session): Extension<Session>,
|
||||
input: Result<Json<AdminPreferences>, axum::extract::rejection::JsonRejection>,
|
||||
) -> Result<Json<AdminPreferences>, ApiError> {
|
||||
let Json(preferences) = input.map_err(public::invalid_json)?;
|
||||
validate_admin_preferences(&preferences)?;
|
||||
let document = serde_json::to_string(&preferences).map_err(auth::internal)?;
|
||||
if !state
|
||||
.store
|
||||
.save_admin_preferences(&session.username, &document)
|
||||
.await
|
||||
.map_err(auth::internal)?
|
||||
{
|
||||
return Err(ApiError(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"auth.invalid_credentials".into(),
|
||||
));
|
||||
}
|
||||
Ok(Json(preferences))
|
||||
}
|
||||
@@ -252,173 +252,6 @@ async fn session(
|
||||
Json(session_payload(&session, session_seconds))
|
||||
}
|
||||
|
||||
const LIVE_LOG_COLUMNS: &[&str] = &[
|
||||
"time",
|
||||
"request",
|
||||
"host",
|
||||
"source_ip",
|
||||
"vhost",
|
||||
"backend",
|
||||
"status",
|
||||
"duration",
|
||||
"cache",
|
||||
"outcome",
|
||||
"transport",
|
||||
"websocket",
|
||||
"upstream_error",
|
||||
];
|
||||
const BACKEND_TABLE_COLUMNS: &[&str] = &[
|
||||
"vhost",
|
||||
"backend",
|
||||
"status",
|
||||
"active",
|
||||
"requests",
|
||||
"errors",
|
||||
"latency",
|
||||
"traffic",
|
||||
"generation",
|
||||
"generationRequests",
|
||||
"generationAge",
|
||||
"rotations",
|
||||
"lastFailure",
|
||||
"lastRecovery",
|
||||
];
|
||||
|
||||
fn default_admin_theme() -> String {
|
||||
"dark".to_owned()
|
||||
}
|
||||
fn default_backend_table_columns() -> Vec<String> {
|
||||
BACKEND_TABLE_COLUMNS
|
||||
.iter()
|
||||
.map(|value| (*value).to_owned())
|
||||
.collect()
|
||||
}
|
||||
fn default_backend_table_sort() -> String {
|
||||
"vhost".to_owned()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct AdminPreferences {
|
||||
live_log_columns: Vec<String>,
|
||||
#[serde(default = "default_admin_theme")]
|
||||
theme: String,
|
||||
#[serde(default = "default_backend_table_columns")]
|
||||
backend_table_columns: Vec<String>,
|
||||
#[serde(default = "default_backend_table_sort")]
|
||||
backend_table_sort: String,
|
||||
#[serde(default)]
|
||||
backend_table_descending: bool,
|
||||
}
|
||||
|
||||
impl Default for AdminPreferences {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
live_log_columns: LIVE_LOG_COLUMNS
|
||||
.iter()
|
||||
.map(|value| (*value).to_owned())
|
||||
.collect(),
|
||||
theme: default_admin_theme(),
|
||||
backend_table_columns: default_backend_table_columns(),
|
||||
backend_table_sort: default_backend_table_sort(),
|
||||
backend_table_descending: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_admin_preferences(preferences: &AdminPreferences) -> Result<(), ApiError> {
|
||||
if preferences.live_log_columns.is_empty()
|
||||
|| preferences.live_log_columns.len() > LIVE_LOG_COLUMNS.len()
|
||||
{
|
||||
return Err(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"at least one live-log column must be visible".into(),
|
||||
));
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for column in &preferences.live_log_columns {
|
||||
if !LIVE_LOG_COLUMNS.contains(&column.as_str()) || !seen.insert(column.as_str()) {
|
||||
return Err(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"invalid live-log column preference".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if !matches!(preferences.theme.as_str(), "dark" | "light") {
|
||||
return Err(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"invalid administration theme preference".into(),
|
||||
));
|
||||
}
|
||||
if preferences.backend_table_columns.is_empty()
|
||||
|| preferences.backend_table_columns.len() > BACKEND_TABLE_COLUMNS.len()
|
||||
{
|
||||
return Err(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"at least one backend table column must be visible".into(),
|
||||
));
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for column in &preferences.backend_table_columns {
|
||||
if !BACKEND_TABLE_COLUMNS.contains(&column.as_str()) || !seen.insert(column.as_str()) {
|
||||
return Err(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"invalid backend table column preference".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if !BACKEND_TABLE_COLUMNS.contains(&preferences.backend_table_sort.as_str()) {
|
||||
return Err(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"invalid backend table sort preference".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn preferences(
|
||||
State(state): State<Arc<App>>,
|
||||
Extension(session): Extension<Session>,
|
||||
) -> Result<Json<AdminPreferences>, ApiError> {
|
||||
let stored = state
|
||||
.store
|
||||
.admin_preferences(&session.username)
|
||||
.await
|
||||
.map_err(auth::internal)?;
|
||||
let preferences = match stored {
|
||||
Some(document) => match serde_json::from_str::<AdminPreferences>(&document) {
|
||||
Ok(preferences) if validate_admin_preferences(&preferences).is_ok() => preferences,
|
||||
Ok(_) | Err(_) => {
|
||||
tracing::warn!(user = %session.username, "invalid admin view preferences ignored");
|
||||
AdminPreferences::default()
|
||||
}
|
||||
},
|
||||
None => AdminPreferences::default(),
|
||||
};
|
||||
Ok(Json(preferences))
|
||||
}
|
||||
|
||||
async fn update_preferences(
|
||||
State(state): State<Arc<App>>,
|
||||
Extension(session): Extension<Session>,
|
||||
input: Result<Json<AdminPreferences>, axum::extract::rejection::JsonRejection>,
|
||||
) -> Result<Json<AdminPreferences>, ApiError> {
|
||||
let Json(preferences) = input.map_err(public::invalid_json)?;
|
||||
validate_admin_preferences(&preferences)?;
|
||||
let document = serde_json::to_string(&preferences).map_err(auth::internal)?;
|
||||
if !state
|
||||
.store
|
||||
.save_admin_preferences(&session.username, &document)
|
||||
.await
|
||||
.map_err(auth::internal)?
|
||||
{
|
||||
return Err(ApiError(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"auth.invalid_credentials".into(),
|
||||
));
|
||||
}
|
||||
Ok(Json(preferences))
|
||||
}
|
||||
fn logged_out(secure_cookie: bool) -> Response {
|
||||
let mut response = Json(json!({"logged_out":true})).into_response();
|
||||
response.headers_mut().insert(
|
||||
|
||||
@@ -20,6 +20,10 @@
|
||||
LIVE_LOG_COLUMNS.len()
|
||||
);
|
||||
assert_eq!(initial["theme"], "dark");
|
||||
assert_eq!(
|
||||
initial["file_log_columns"].as_array().unwrap().len(),
|
||||
default_file_log_columns().len()
|
||||
);
|
||||
assert_eq!(
|
||||
initial["backend_table_columns"].as_array().unwrap().len(),
|
||||
BACKEND_TABLE_COLUMNS.len()
|
||||
@@ -45,6 +49,7 @@
|
||||
let legacy = json_body(response).await;
|
||||
assert_eq!(legacy["live_log_columns"], json!(["time", "status"]));
|
||||
assert_eq!(legacy["theme"], "dark");
|
||||
assert_eq!(legacy["file_log_columns"], json!(default_file_log_columns()));
|
||||
assert_eq!(
|
||||
legacy["backend_table_columns"].as_array().unwrap().len(),
|
||||
BACKEND_TABLE_COLUMNS.len()
|
||||
@@ -55,6 +60,7 @@
|
||||
let selected = json!({
|
||||
"live_log_columns":["time","request","status","upstream_error"],
|
||||
"theme":"light",
|
||||
"file_log_columns":["time","event","client_ip","record"],
|
||||
"backend_table_columns":["vhost","backend","status","requests","latency"],
|
||||
"backend_table_sort":"requests",
|
||||
"backend_table_descending":true
|
||||
|
||||
@@ -55,6 +55,37 @@
|
||||
assert!(validate_file_log_query(&query).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_log_normalizes_mapped_client_ip_and_searches_diagnostics() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let source = source_from_path(
|
||||
"vhost:app".into(),
|
||||
"app.jsonl".into(),
|
||||
"access",
|
||||
dir.path().join("app.jsonl"),
|
||||
true,
|
||||
"JSONL",
|
||||
);
|
||||
let mut format = crate::logging::JsonFormat::default();
|
||||
format.fields.remove("client_ip");
|
||||
format
|
||||
.fields
|
||||
.insert("remote_addr".into(), "client_ip".into());
|
||||
let record = normalized_record(
|
||||
&source,
|
||||
r#"{"event":"protocol_error","remote_addr":"192.0.2.10","reason":"tls_handshake_error","error":"HTTP parser error","sni":"app.example.test","matched_by":"sni","vhost":"app"}"#,
|
||||
&format,
|
||||
);
|
||||
assert_eq!(record["client_ip"], "192.0.2.10");
|
||||
assert_eq!(record["sni"], "app.example.test");
|
||||
|
||||
let query = FileLogQuery::default();
|
||||
assert!(record_matches(&record, &query, "error"));
|
||||
assert!(record_matches(&record, &query, "http parser"));
|
||||
assert!(record_matches(&record, &query, "app.example.test"));
|
||||
assert!(!record_matches(&record, &query, "missing"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn stored_log_reader_refuses_symlink_targets() {
|
||||
|
||||
@@ -355,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; $("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 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.rustProxyFileLogs?.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.26.0";
|
||||
$("server-mode").value = server.mode || "preserve"; $("server-value").value = server.value || "RustProxy/v0.26.1";
|
||||
for (const name of ["gzip", "br", "zstd", "deflate"]) $(`compress-${name}`).checked = !!compression[name];
|
||||
$("compress-min").value = compression.min_bytes ?? 1024; syncServerMode();
|
||||
}
|
||||
|
||||
+95
-15
@@ -1,7 +1,28 @@
|
||||
(() => {
|
||||
let inventory = null, offset = 0, lastTotal = 0, busy = false;
|
||||
let currentEntries = [], selectedIndex = null;
|
||||
let preferencesLoaded = false, preferenceSaveRunning = false, preferenceSaveQueued = false;
|
||||
let searchTimer = null;
|
||||
|
||||
const columns = [
|
||||
{ id: "time", label: "time", value: entry => entryTime(entry) },
|
||||
{ id: "event", label: "fileLogsEvent", value: entry => entry.event || "—" },
|
||||
{ id: "status", label: "status", value: entry => entryStatus(entry) },
|
||||
{ id: "client_ip", label: "clientIp", value: entry => entry.client_ip || entry.source_ip || "—" },
|
||||
{ id: "vhost", label: "vhost", value: entry => entry.vhost || "—" },
|
||||
{ id: "transport", label: "logTransport", value: entry => entry.transport ? `${entry.transport}${entry.http_version ? ` / HTTP ${entry.http_version}` : ""}` : "—" },
|
||||
{ id: "matched_by", label: "fileLogsMatchedBy", value: entry => entry.matched_by || "—" },
|
||||
{ id: "sni", label: "fileLogsSni", value: entry => entry.sni || entry.tls_sni || "—" },
|
||||
{ id: "authority", label: "fileLogsAuthority", value: entry => entry.authority || entry.host || "—" },
|
||||
{ id: "record", label: "fileLogsRecord", value: entry => entrySummary(entry) },
|
||||
{ id: "source", label: "fileLogsSource", value: entry => entry._source || "—" },
|
||||
];
|
||||
const defaultColumnIds = ["time", "event", "status", "client_ip", "vhost", "record", "source"];
|
||||
let visibleColumns = new Set(defaultColumnIds);
|
||||
|
||||
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;
|
||||
@@ -26,8 +47,9 @@
|
||||
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.reason || entry.protocol_error) return entry.reason || entry.protocol_error;
|
||||
if (entry.method || entry.path) return `${entry.method || ""} ${entry.host || entry.authority || ""}${entry.path || ""}`.trim();
|
||||
if (entry.error || entry.error_detail) return entry.error || entry.error_detail;
|
||||
if (entry.message) return entry.message;
|
||||
if (entry.raw) return entry.raw;
|
||||
return entry.outcome || "—";
|
||||
@@ -36,19 +58,72 @@
|
||||
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 || "—"]) {
|
||||
function syncHeaders() {
|
||||
document.querySelectorAll("#file-logs-table-head [data-file-log-column]").forEach(header => {
|
||||
header.hidden = !visibleColumns.has(header.dataset.fileLogColumn);
|
||||
});
|
||||
}
|
||||
function syncColumnPicker() {
|
||||
const picker = $("file-log-column-options");
|
||||
picker.replaceChildren(...columns.map(column => {
|
||||
const label = el("label", undefined, "check log-column-option");
|
||||
const input = document.createElement("input"); input.type = "checkbox"; input.checked = visibleColumns.has(column.id); input.dataset.column = column.id;
|
||||
const text = el("span", t(column.label)); text.dataset.i18n = column.label;
|
||||
input.addEventListener("change", () => {
|
||||
if (!input.checked && visibleColumns.size === 1) { input.checked = true; toast(t("fileLogsColumnsRequired"), true); return; }
|
||||
if (input.checked) visibleColumns.add(column.id); else visibleColumns.delete(column.id);
|
||||
syncHeaders(); renderRows(); void savePreferences();
|
||||
});
|
||||
label.append(input, text); return label;
|
||||
}));
|
||||
}
|
||||
function applyPreferences(preferences) {
|
||||
const selected = Array.isArray(preferences?.file_log_columns)
|
||||
? preferences.file_log_columns.filter(id => columns.some(column => column.id === id))
|
||||
: [];
|
||||
visibleColumns = new Set(selected.length ? selected : defaultColumnIds);
|
||||
preferencesLoaded = true;
|
||||
syncColumnPicker(); syncHeaders(); renderRows();
|
||||
}
|
||||
async function savePreferences() {
|
||||
if (!authenticated || !preferencesLoaded) return;
|
||||
if (preferenceSaveRunning) { preferenceSaveQueued = true; return; }
|
||||
preferenceSaveRunning = true;
|
||||
try {
|
||||
do {
|
||||
preferenceSaveQueued = false;
|
||||
const selected = columns.filter(column => visibleColumns.has(column.id)).map(column => column.id);
|
||||
const preferences = await window.rustProxyPreferences.update({ file_log_columns: selected });
|
||||
if (!preferenceSaveQueued) applyPreferences(preferences);
|
||||
} while (preferenceSaveQueued);
|
||||
} catch (error) { toast(error.message, true); }
|
||||
finally { preferenceSaveRunning = false; }
|
||||
}
|
||||
function renderRows(entries = currentEntries) {
|
||||
currentEntries = entries;
|
||||
const nodes = [];
|
||||
entries.forEach((entry, index) => {
|
||||
const row = el("tr"); row.tabIndex = 0; row.classList.toggle("selected", selectedIndex === index);
|
||||
row.setAttribute("aria-expanded", selectedIndex === index ? "true" : "false");
|
||||
for (const column of columns) {
|
||||
if (!visibleColumns.has(column.id)) continue;
|
||||
const value = column.value(entry);
|
||||
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;
|
||||
const select = () => { selectedIndex = selectedIndex === index ? null : index; renderRows(); };
|
||||
row.addEventListener("click", select);
|
||||
row.addEventListener("keydown", event => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); select(); } });
|
||||
nodes.push(row);
|
||||
if (selectedIndex === index) {
|
||||
const detailRow = el("tr", undefined, "file-log-detail-row");
|
||||
const cell = el("td"); cell.colSpan = Math.max(1, visibleColumns.size);
|
||||
const heading = el("strong", t("fileLogsDetails")); heading.dataset.i18n = "fileLogsDetails";
|
||||
const pre = el("pre", JSON.stringify(entry, null, 2), "metrics-example");
|
||||
const detail = el("div", undefined, "file-log-inline-detail"); detail.append(heading, pre); cell.append(detail); detailRow.append(cell); nodes.push(detailRow);
|
||||
}
|
||||
});
|
||||
$("file-log-rows").replaceChildren(...rows);
|
||||
$("file-logs-empty").hidden = rows.length !== 0;
|
||||
if (!rows.length) $("file-log-detail").textContent = t("fileLogsSelectRecord");
|
||||
$("file-log-rows").replaceChildren(...nodes);
|
||||
$("file-logs-empty").hidden = entries.length !== 0;
|
||||
}
|
||||
function queryString() {
|
||||
const params = new URLSearchParams();
|
||||
@@ -66,7 +141,8 @@
|
||||
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 : []);
|
||||
lastTotal = Number(value.total) || 0; selectedIndex = null;
|
||||
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))
|
||||
@@ -78,14 +154,18 @@
|
||||
finally { busy = false; $("file-logs-refresh").disabled = false; }
|
||||
}
|
||||
function reset() {
|
||||
inventory = null; offset = 0; lastTotal = 0; $("file-log-rows").replaceChildren(); $("file-log-detail").textContent = "";
|
||||
inventory = null; offset = 0; lastTotal = 0; currentEntries = []; selectedIndex = null;
|
||||
preferencesLoaded = false; preferenceSaveRunning = false; preferenceSaveQueued = false;
|
||||
visibleColumns = new Set(defaultColumnIds); $("file-log-rows").replaceChildren(); syncColumnPicker(); syncHeaders();
|
||||
}
|
||||
$("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-log-q").addEventListener("input", () => { clearTimeout(searchTimer); searchTimer = setTimeout(() => void refresh(true), 250); });
|
||||
$("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 };
|
||||
syncColumnPicker(); syncHeaders();
|
||||
window.rustProxyFileLogs = { refresh: () => refresh(false), reset, inventory: loadInventory, applyPreferences };
|
||||
})();
|
||||
+23
-16
@@ -1539,20 +1539,31 @@
|
||||
<option>500</option>
|
||||
</select></label>
|
||||
<button class="primary" data-i18n="applyFilters" type="submit">Apply filters</button>
|
||||
<details class="log-column-picker file-log-column-picker">
|
||||
<summary data-i18n="logColumns">Columns</summary>
|
||||
<div class="log-column-options" id="file-log-column-options"></div>
|
||||
</details>
|
||||
</form>
|
||||
<p class="fine file-log-inventory" id="file-log-inventory"></p>
|
||||
<details class="file-log-inventory-details">
|
||||
<summary data-i18n="fileLogsSourcesRead">Configured files</summary>
|
||||
<p class="fine file-log-inventory" id="file-log-inventory"></p>
|
||||
</details>
|
||||
<p class="fine" id="file-log-summary" role="status"></p>
|
||||
<div class="table-scroll file-logs-table">
|
||||
<table>
|
||||
<thead>
|
||||
<thead id="file-logs-table-head">
|
||||
<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>
|
||||
<th data-file-log-column="time" data-i18n="time">Time</th>
|
||||
<th data-file-log-column="event" data-i18n="fileLogsEvent">Event</th>
|
||||
<th data-file-log-column="status" data-i18n="status">Status</th>
|
||||
<th data-file-log-column="client_ip" data-i18n="clientIp">Client IP</th>
|
||||
<th data-file-log-column="vhost" data-i18n="vhost">Vhost</th>
|
||||
<th data-file-log-column="transport" data-i18n="logTransport" hidden>Transport</th>
|
||||
<th data-file-log-column="matched_by" data-i18n="fileLogsMatchedBy" hidden>Matched by</th>
|
||||
<th data-file-log-column="sni" data-i18n="fileLogsSni" hidden>SNI</th>
|
||||
<th data-file-log-column="authority" data-i18n="fileLogsAuthority" hidden>Authority</th>
|
||||
<th data-file-log-column="record" data-i18n="fileLogsRecord">Request / reason</th>
|
||||
<th data-file-log-column="source" data-i18n="fileLogsSource">Source</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="file-log-rows"></tbody>
|
||||
@@ -1563,10 +1574,6 @@
|
||||
<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">
|
||||
@@ -1742,7 +1749,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.26.0"
|
||||
id="server-value" maxlength="256" placeholder="RustProxy/v0.26.1"
|
||||
spellcheck="false" />
|
||||
<h3 class="subheading" data-i18n="diagnosticHeaders">Diagnostic headers</h3>
|
||||
<div class="switch-grid">
|
||||
@@ -2541,7 +2548,7 @@
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
<footer class="page-footer"><span>RustProxy <b>0.26.0</b></span><span id="footer-status">API - /
|
||||
<footer class="page-footer"><span>RustProxy <b>0.26.1</b></span><span id="footer-status">API - /
|
||||
dropped: 0</span></footer>
|
||||
</div>
|
||||
</main>
|
||||
@@ -3252,7 +3259,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.26.0</strong></div>
|
||||
<div><span class="small-label">RustProxy</span><strong>0.26.1</strong></div>
|
||||
</div>
|
||||
<dl class="about-meta">
|
||||
<div>
|
||||
|
||||
+49
-11
@@ -8207,7 +8207,7 @@ button.config-history-day.is-selected {
|
||||
|
||||
.file-logs-filter {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(120px, 1fr));
|
||||
grid-template-columns: repeat(8, minmax(120px, 1fr));
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
}
|
||||
@@ -8220,14 +8220,31 @@ button.config-history-day.is-selected {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.file-logs-table {
|
||||
max-height: 52vh;
|
||||
.file-log-column-picker {
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
.file-logs-table tbody tr {
|
||||
.file-logs-table {
|
||||
max-height: 62vh;
|
||||
}
|
||||
|
||||
.file-logs-table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 4;
|
||||
background: var(--panel);
|
||||
box-shadow: 0 1px 0 var(--line);
|
||||
}
|
||||
|
||||
.file-logs-table tbody tr:not(.file-log-detail-row) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-logs-table tbody tr.selected > td {
|
||||
background: var(--panel2);
|
||||
box-shadow: inset 3px 0 0 var(--accent);
|
||||
}
|
||||
|
||||
.file-logs-table td {
|
||||
max-width: 340px;
|
||||
overflow: hidden;
|
||||
@@ -8235,19 +8252,33 @@ button.config-history-day.is-selected {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-log-detail-row td {
|
||||
max-width: none;
|
||||
padding: 0;
|
||||
background: var(--panel2);
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.file-log-inline-detail {
|
||||
padding: 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.file-log-inline-detail pre {
|
||||
max-height: 32vh;
|
||||
margin: 8px 0 0;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.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;
|
||||
@@ -8255,7 +8286,14 @@ button.config-history-day.is-selected {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.file-log-inventory-details > summary {
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.file-log-inventory {
|
||||
margin: 8px 0 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
|
||||
@@ -238,6 +238,12 @@ pub struct CompiledFrontend {
|
||||
pub pages: crate::pages::VhostPages,
|
||||
pub proxy_timeouts: crate::vhost::ProxyTimeouts,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ResolvedLogVhost {
|
||||
pub vhost: String,
|
||||
pub matched_by: &'static str,
|
||||
}
|
||||
|
||||
pub struct Snapshot {
|
||||
pub vhost_metrics: std::collections::BTreeMap<String, Arc<crate::metrics::VhostMetrics>>,
|
||||
pub forwarding: crate::forwarding::Compiled,
|
||||
|
||||
@@ -93,6 +93,54 @@ impl Snapshot {
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Resolve a persistent protocol-error record to a configured vhost using
|
||||
/// the same exact/wildcard precedence and listener binding rules as normal routing.
|
||||
/// Client-provided Host/SNI values are never used as file names directly.
|
||||
pub fn resolve_logging_vhost(
|
||||
&self,
|
||||
local: SocketAddr,
|
||||
tls: bool,
|
||||
routed_vhost: Option<&str>,
|
||||
authority: Option<&str>,
|
||||
host: Option<&str>,
|
||||
sni: Option<&str>,
|
||||
) -> Option<ResolvedLogVhost> {
|
||||
if let Some(routed) = routed_vhost.filter(|value| !value.is_empty()) {
|
||||
if self.frontends.values().flatten().any(|frontend| {
|
||||
frontend.id == routed
|
||||
&& frontend
|
||||
.bindings
|
||||
.iter()
|
||||
.any(|binding| binding.matches(local, tls))
|
||||
}) {
|
||||
return Some(ResolvedLogVhost {
|
||||
vhost: routed.to_owned(),
|
||||
matched_by: "router",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (candidate, matched_by) in [
|
||||
(authority, "authority"),
|
||||
(host, "host"),
|
||||
(sni, "sni"),
|
||||
] {
|
||||
let Some(candidate) = candidate else {
|
||||
continue;
|
||||
};
|
||||
let Ok(normalized) = request_host(candidate) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(frontend) = self.frontend(&normalized, local, tls) {
|
||||
return Some(ResolvedLogVhost {
|
||||
vhost: frontend.id.clone(),
|
||||
matched_by,
|
||||
});
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
/// Select a redirect host/rule using the accepted socket, TLS state and normalized path.
|
||||
/// Exact hostnames win over the longest matching wildcard, matching vhost behavior.
|
||||
pub fn redirect_on(
|
||||
|
||||
@@ -187,6 +187,57 @@ fn wildcard_host_patterns_are_normalized_and_exact_hosts_win() {
|
||||
"wildcard/main"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn logging_vhost_resolution_reuses_router_host_precedence() {
|
||||
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
config.vhosts[0].id = "wildcard".into();
|
||||
config.vhosts[0].frontend.hosts = vec!["*.example.test".into()];
|
||||
let mut exact = config.vhosts[0].clone();
|
||||
exact.id = "exact".into();
|
||||
exact.frontend.hosts = vec!["api.example.test".into()];
|
||||
config.vhosts.push(exact);
|
||||
let snapshot = compile(config, 1, &policy()).unwrap();
|
||||
let local: SocketAddr = "127.0.0.1:8080".parse().unwrap();
|
||||
|
||||
let routed = snapshot
|
||||
.resolve_logging_vhost(
|
||||
local,
|
||||
false,
|
||||
Some("wildcard"),
|
||||
Some("api.example.test"),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(routed.vhost, "wildcard");
|
||||
assert_eq!(routed.matched_by, "router");
|
||||
|
||||
let exact = snapshot
|
||||
.resolve_logging_vhost(local, false, None, Some("API.EXAMPLE.TEST:8080"), None, None)
|
||||
.unwrap();
|
||||
assert_eq!(exact.vhost, "exact");
|
||||
assert_eq!(exact.matched_by, "authority");
|
||||
|
||||
let wildcard = snapshot
|
||||
.resolve_logging_vhost(local, false, None, None, Some("www.example.test:8080"), None)
|
||||
.unwrap();
|
||||
assert_eq!(wildcard.vhost, "wildcard");
|
||||
assert_eq!(wildcard.matched_by, "host");
|
||||
|
||||
let sni = snapshot
|
||||
.resolve_logging_vhost(local, false, None, None, None, Some("api.example.test"))
|
||||
.unwrap();
|
||||
assert_eq!(sni.vhost, "exact");
|
||||
assert_eq!(sni.matched_by, "sni");
|
||||
|
||||
assert!(snapshot
|
||||
.resolve_logging_vhost(local, false, None, None, None, Some("unknown.test"))
|
||||
.is_none());
|
||||
assert!(snapshot
|
||||
.resolve_logging_vhost(local, false, None, None, None, None)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reusable_acl_profile_reference_is_compiled_and_unknown_reference_fails() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
|
||||
+75
-16
@@ -28,7 +28,6 @@ use tokio::{
|
||||
task::JoinSet,
|
||||
};
|
||||
use tower::ServiceExt;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Settings {
|
||||
@@ -191,6 +190,63 @@ impl Drop for Lease {
|
||||
.fetch_sub(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn log_protocol_error(
|
||||
state: &App,
|
||||
kind: &'static str,
|
||||
detail: impl std::fmt::Display,
|
||||
peer: SocketAddr,
|
||||
local: SocketAddr,
|
||||
status: u16,
|
||||
authority: Option<String>,
|
||||
host: Option<String>,
|
||||
sni: Option<String>,
|
||||
) {
|
||||
let resolved = state.config.load().resolve_logging_vhost(
|
||||
local,
|
||||
true,
|
||||
None,
|
||||
authority.as_deref(),
|
||||
host.as_deref(),
|
||||
sni.as_deref(),
|
||||
);
|
||||
let (vhost, matched_by) = resolved
|
||||
.map(|resolved| (Some(resolved.vhost), resolved.matched_by))
|
||||
.unwrap_or((None, "none"));
|
||||
state.logs.push_protocol_error_attributed(
|
||||
kind,
|
||||
detail,
|
||||
peer,
|
||||
local,
|
||||
"quic",
|
||||
"3",
|
||||
status,
|
||||
sni,
|
||||
authority.or(host),
|
||||
vhost,
|
||||
matched_by,
|
||||
);
|
||||
}
|
||||
fn request_identity<B>(request: &http::Request<B>) -> (Option<String>, Option<String>) {
|
||||
let authority = request
|
||||
.uri()
|
||||
.authority()
|
||||
.map(|value| value.as_str().to_owned());
|
||||
let host = request
|
||||
.headers()
|
||||
.get(http::header::HOST)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_owned);
|
||||
(authority, host)
|
||||
}
|
||||
fn connection_sni(connection: &quinn::Connection) -> Option<String> {
|
||||
connection
|
||||
.handshake_data()?
|
||||
.downcast::<quinn::crypto::rustls::HandshakeData>()
|
||||
.ok()?
|
||||
.server_name
|
||||
.and_then(|name| crate::config::request_host(&name).ok())
|
||||
}
|
||||
pub async fn serve(bound: Bound, router: Router, state: Arc<App>, grace: Duration) -> Result<()> {
|
||||
let slots = Arc::new(Semaphore::new(
|
||||
bound.settings.max_concurrent_connections as usize,
|
||||
@@ -258,15 +314,16 @@ pub async fn serve(bound: Bound, router: Router, state: Arc<App>, grace: Duratio
|
||||
None if !address.ip().is_unspecified()=>address,
|
||||
None=>{connection.close(0x102u32.into(),b"local destination unavailable");return;},
|
||||
};
|
||||
let tls_sni=connection_sni(&connection);
|
||||
state.metrics.connections.fetch_add(1,Ordering::Relaxed);
|
||||
state.metrics.active_connections.fetch_add(1,Ordering::Relaxed);
|
||||
state.metrics.http3_connections.fetch_add(1,Ordering::Relaxed);
|
||||
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.clone(),limit,grace).await {
|
||||
state.logs.push_protocol_error(
|
||||
"http3_connection_error", &error, peer, local, "quic", "3", 0, None,
|
||||
if let Err(error)=connection_loop(connection,local,router,state.clone(),limit,grace,tls_sni.clone()).await {
|
||||
log_protocol_error(
|
||||
&state, "http3_connection_error", &error, peer, local, 0, None, None, tls_sni,
|
||||
);
|
||||
tracing::debug!(%error,"HTTP/3 connection ended");
|
||||
}
|
||||
@@ -290,6 +347,7 @@ async fn connection_loop(
|
||||
state: Arc<App>,
|
||||
limit: usize,
|
||||
grace: Duration,
|
||||
tls_sni: Option<String>,
|
||||
) -> Result<()> {
|
||||
let mut builder = h3::server::builder();
|
||||
builder.max_field_section_size(32 * 1024);
|
||||
@@ -316,27 +374,27 @@ async fn connection_loop(
|
||||
Ok(Some(resolver))=>{
|
||||
// Completed JoinSet entries are drained before accepting more.
|
||||
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,
|
||||
log_protocol_error(
|
||||
&state, "http3_stream_limit_rejected", "HTTP/3 concurrent request stream limit reached",
|
||||
connection.remote_address(), local, 0, None, None, tls_sni.clone(),
|
||||
);
|
||||
drop(resolver);continue;
|
||||
}
|
||||
let router=router.clone();let state=state.clone();let peer=connection.remote_address();
|
||||
let router=router.clone();let state=state.clone();let peer=connection.remote_address();let tls_sni=tls_sni.clone();
|
||||
requests.spawn(async move {
|
||||
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,
|
||||
log_protocol_error(
|
||||
&state, "http3_request_error", error, peer, local, 400, None, None, tls_sni.clone(),
|
||||
);
|
||||
return;
|
||||
},
|
||||
Err(_)=>{
|
||||
state.logs.push_protocol_error(
|
||||
"http3_header_timeout", "HTTP/3 request header timeout",
|
||||
peer, local, "quic", "3", 408, None,
|
||||
log_protocol_error(
|
||||
&state, "http3_header_timeout", "HTTP/3 request header timeout",
|
||||
peer, local, 408, None, None, tls_sni.clone(),
|
||||
);
|
||||
return;
|
||||
},
|
||||
@@ -347,9 +405,10 @@ async fn connection_loop(
|
||||
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,
|
||||
let (authority, host) = request_identity(&request);
|
||||
log_protocol_error(
|
||||
&state, "http3_invalid_headers", "HTTP/3 request contains forbidden connection-specific headers",
|
||||
peer, local, 400, authority, host, tls_sni.clone(),
|
||||
);
|
||||
send.stop_stream(Code::H3_MESSAGE_ERROR);return;
|
||||
}
|
||||
|
||||
+74
-2
@@ -2,9 +2,14 @@
|
||||
pub struct LogEntry {
|
||||
pub event: &'static str,
|
||||
pub protocol_error: Option<&'static str>,
|
||||
pub reason: Option<&'static str>,
|
||||
pub error_detail: Option<String>,
|
||||
pub error: Option<String>,
|
||||
pub local_addr: Option<String>,
|
||||
pub tls_sni: Option<String>,
|
||||
pub sni: Option<String>,
|
||||
pub authority: Option<String>,
|
||||
pub matched_by: Option<&'static str>,
|
||||
pub vhost: Option<String>,
|
||||
pub route: Option<String>,
|
||||
pub cache_effective_enabled: Option<bool>,
|
||||
@@ -85,6 +90,21 @@ fn protocol_error_detail(detail: impl std::fmt::Display) -> String {
|
||||
detail
|
||||
}
|
||||
|
||||
fn protocol_identity_field(value: Option<String>) -> Option<String> {
|
||||
value.map(|value| {
|
||||
let mut value: String = value.chars().filter(|ch| !ch.is_control()).collect();
|
||||
if value.len() > 512 {
|
||||
let mut end = 512;
|
||||
while !value.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
value.truncate(end);
|
||||
value.push_str("...");
|
||||
}
|
||||
value
|
||||
})
|
||||
}
|
||||
|
||||
impl Logs {
|
||||
pub fn start(settings: Settings, shutdown: CancellationToken) -> Arc<Self> {
|
||||
let (tx, rx) = mpsc::channel(SINK_QUEUE);
|
||||
@@ -143,18 +163,47 @@ impl Logs {
|
||||
http_version: &'static str,
|
||||
status: u16,
|
||||
tls_sni: Option<String>,
|
||||
) {
|
||||
self.push_protocol_error_attributed(
|
||||
kind, detail, client, local, transport, http_version, status, tls_sni, None, None,
|
||||
"none",
|
||||
);
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn push_protocol_error_attributed(
|
||||
&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>,
|
||||
authority: Option<String>,
|
||||
vhost: Option<String>,
|
||||
matched_by: &'static str,
|
||||
) {
|
||||
if !self.settings.load().enabled() {
|
||||
return;
|
||||
}
|
||||
let detail = protocol_error_detail(detail);
|
||||
let tls_sni = protocol_identity_field(tls_sni);
|
||||
let authority = protocol_identity_field(authority);
|
||||
let sni = tls_sni.clone();
|
||||
self.push_sink(LogEntry {
|
||||
event: "protocol_error",
|
||||
protocol_error: Some(kind),
|
||||
error_detail: Some(detail),
|
||||
reason: Some(kind),
|
||||
error_detail: Some(detail.clone()),
|
||||
error: Some(detail),
|
||||
local_addr: Some(local.to_string()),
|
||||
tls_sni,
|
||||
vhost: None,
|
||||
sni,
|
||||
authority,
|
||||
matched_by: Some(matched_by),
|
||||
vhost,
|
||||
route: None,
|
||||
cache_effective_enabled: None,
|
||||
transform_effective_enabled: None,
|
||||
@@ -221,11 +270,20 @@ fn value_for(entry: &LogEntry, name: &str) -> Value {
|
||||
.protocol_error
|
||||
.map(|v| Value::String(v.into()))
|
||||
.unwrap_or(Value::Null),
|
||||
"reason" => entry
|
||||
.reason
|
||||
.map(|v| Value::String(v.into()))
|
||||
.unwrap_or(Value::Null),
|
||||
"error_detail" => entry
|
||||
.error_detail
|
||||
.clone()
|
||||
.map(Value::String)
|
||||
.unwrap_or(Value::Null),
|
||||
"error" => entry
|
||||
.error
|
||||
.clone()
|
||||
.map(Value::String)
|
||||
.unwrap_or(Value::Null),
|
||||
"local_addr" => entry
|
||||
.local_addr
|
||||
.clone()
|
||||
@@ -236,6 +294,20 @@ fn value_for(entry: &LogEntry, name: &str) -> Value {
|
||||
.clone()
|
||||
.map(Value::String)
|
||||
.unwrap_or(Value::Null),
|
||||
"sni" => entry
|
||||
.sni
|
||||
.clone()
|
||||
.map(Value::String)
|
||||
.unwrap_or(Value::Null),
|
||||
"authority" => entry
|
||||
.authority
|
||||
.clone()
|
||||
.map(Value::String)
|
||||
.unwrap_or(Value::Null),
|
||||
"matched_by" => entry
|
||||
.matched_by
|
||||
.map(|v| Value::String(v.into()))
|
||||
.unwrap_or(Value::Null),
|
||||
"vhost" => entry
|
||||
.vhost
|
||||
.clone()
|
||||
|
||||
@@ -231,9 +231,14 @@ fn source_value_name(name: &str) -> bool {
|
||||
name,
|
||||
"event"
|
||||
| "protocol_error"
|
||||
| "reason"
|
||||
| "error_detail"
|
||||
| "error"
|
||||
| "local_addr"
|
||||
| "tls_sni"
|
||||
| "sni"
|
||||
| "authority"
|
||||
| "matched_by"
|
||||
| "vhost"
|
||||
| "route"
|
||||
| "cache_effective_enabled"
|
||||
|
||||
+10
-18
@@ -19,11 +19,17 @@ fn format_entry(entry: &LogEntry, format: &JsonFormat) -> Value {
|
||||
for name in [
|
||||
"event",
|
||||
"protocol_error",
|
||||
"reason",
|
||||
"error_detail",
|
||||
"error",
|
||||
"time_ms",
|
||||
"client_ip",
|
||||
"local_addr",
|
||||
"tls_sni",
|
||||
"sni",
|
||||
"authority",
|
||||
"matched_by",
|
||||
"vhost",
|
||||
"transport",
|
||||
"http_version",
|
||||
"status",
|
||||
@@ -63,24 +69,10 @@ struct FileState {
|
||||
const PER_VHOST_OPEN_FILES: usize = 64;
|
||||
|
||||
fn vhost_log_name(vhost: Option<&str>) -> String {
|
||||
let raw = vhost
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("_unmatched");
|
||||
let mut safe = String::with_capacity(raw.len().min(96));
|
||||
for ch in raw.chars().take(96) {
|
||||
safe.push(
|
||||
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
|
||||
ch
|
||||
} else {
|
||||
'_'
|
||||
},
|
||||
);
|
||||
}
|
||||
if safe.is_empty() {
|
||||
"_unmatched".into()
|
||||
} else {
|
||||
safe
|
||||
}
|
||||
vhost
|
||||
.filter(|value| crate::config::valid_id(value))
|
||||
.unwrap_or("_unmatched")
|
||||
.to_owned()
|
||||
}
|
||||
struct LogstashState {
|
||||
address: String,
|
||||
|
||||
+25
-1
@@ -5,9 +5,14 @@ mod tests {
|
||||
LogEntry {
|
||||
event: "request",
|
||||
protocol_error: None,
|
||||
reason: None,
|
||||
error_detail: None,
|
||||
error: None,
|
||||
local_addr: Some("192.0.2.1:443".into()),
|
||||
tls_sni: Some("app.example".into()),
|
||||
sni: Some("app.example".into()),
|
||||
authority: None,
|
||||
matched_by: None,
|
||||
vhost: Some("app".into()),
|
||||
route: Some("root".into()),
|
||||
cache_effective_enabled: Some(true),
|
||||
@@ -95,7 +100,7 @@ mod tests {
|
||||
fn per_vhost_file_mode_has_safe_names_and_requires_directory() {
|
||||
assert_eq!(vhost_log_name(Some("app-1")), "app-1");
|
||||
assert_eq!(vhost_log_name(None), "_unmatched");
|
||||
assert_eq!(vhost_log_name(Some("bad/name")), "bad_name");
|
||||
assert_eq!(vhost_log_name(Some("bad/name")), "_unmatched");
|
||||
let mut settings = Settings::default();
|
||||
settings.file.enabled = true;
|
||||
settings.file.per_vhost = true;
|
||||
@@ -124,7 +129,12 @@ mod tests {
|
||||
let mut item = entry();
|
||||
item.event = "protocol_error";
|
||||
item.protocol_error = Some("tls_handshake_error");
|
||||
item.reason = Some("tls_handshake_error");
|
||||
item.error_detail = Some("invalid handshake".into());
|
||||
item.error = Some("invalid handshake".into());
|
||||
item.sni = Some("app.example".into());
|
||||
item.authority = Some("app.example:443".into());
|
||||
item.matched_by = Some("sni");
|
||||
item.outcome = "protocol_error";
|
||||
item.status = 0;
|
||||
item.transport = "tls";
|
||||
@@ -135,7 +145,12 @@ mod tests {
|
||||
assert_eq!(value["site"], "app");
|
||||
assert_eq!(value["event"], "protocol_error");
|
||||
assert_eq!(value["protocol_error"], "tls_handshake_error");
|
||||
assert_eq!(value["reason"], "tls_handshake_error");
|
||||
assert_eq!(value["error_detail"], "invalid handshake");
|
||||
assert_eq!(value["error"], "invalid handshake");
|
||||
assert_eq!(value["sni"], "app.example");
|
||||
assert_eq!(value["authority"], "app.example:443");
|
||||
assert_eq!(value["matched_by"], "sni");
|
||||
assert_eq!(value["client_ip"], "192.0.2.10");
|
||||
assert_eq!(value["transport"], "tls");
|
||||
assert_eq!(value["outcome"], "protocol_error");
|
||||
@@ -170,6 +185,15 @@ mod tests {
|
||||
assert!(detail.ends_with("..."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_identity_fields_are_bounded_and_strip_controls() {
|
||||
let value = protocol_identity_field(Some(format!("host\n{}", "€".repeat(300)))).unwrap();
|
||||
assert!(!value.contains('\n'));
|
||||
assert!(value.is_char_boundary(value.len()));
|
||||
assert!(value.len() <= 515);
|
||||
assert!(value.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.26.0"
|
||||
about = "RustProxy - independent Rust reverse proxy and static Web Server 0.26.1"
|
||||
)]
|
||||
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.26.0 - all vhosts. Type 'help' for interactive filters; Ctrl-C or 'quit' exits.");
|
||||
println!("RustProxy live logs 0.26.1 - 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();
|
||||
|
||||
@@ -204,9 +204,14 @@ impl Drop for Exchange {
|
||||
self.logs.push(LogEntry {
|
||||
event: "request",
|
||||
protocol_error: None,
|
||||
reason: None,
|
||||
error_detail: None,
|
||||
error: None,
|
||||
local_addr: None,
|
||||
tls_sni: None,
|
||||
sni: None,
|
||||
authority: None,
|
||||
matched_by: None,
|
||||
cache_reason: *self.cache_reason.lock().unwrap_or_else(|e| e.into_inner()),
|
||||
vhost: self.vhost.clone(),
|
||||
route,
|
||||
|
||||
+59
-12
@@ -88,6 +88,61 @@ pub struct Transport {
|
||||
pub tls: bool,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn log_protocol_error_with_identity(
|
||||
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,
|
||||
authority: Option<String>,
|
||||
host: Option<String>,
|
||||
tls_sni: Option<String>,
|
||||
) {
|
||||
if let Some(state) = state {
|
||||
let resolved = state.config.load().resolve_logging_vhost(
|
||||
local,
|
||||
matches!(transport, "tls" | "quic"),
|
||||
None,
|
||||
authority.as_deref(),
|
||||
host.as_deref(),
|
||||
tls_sni.as_deref(),
|
||||
);
|
||||
let (vhost, matched_by) = resolved
|
||||
.map(|resolved| (Some(resolved.vhost), resolved.matched_by))
|
||||
.unwrap_or((None, "none"));
|
||||
state.logs.push_protocol_error_attributed(
|
||||
kind,
|
||||
detail,
|
||||
peer,
|
||||
local,
|
||||
transport,
|
||||
http_version,
|
||||
status,
|
||||
tls_sni,
|
||||
authority.or(host),
|
||||
vhost,
|
||||
matched_by,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn request_log_identity<B>(request: &Request<B>) -> (Option<String>, Option<String>) {
|
||||
let authority = request
|
||||
.uri()
|
||||
.authority()
|
||||
.map(|value| value.as_str().to_owned());
|
||||
let host = request
|
||||
.headers()
|
||||
.get(http::header::HOST)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_owned);
|
||||
(authority, host)
|
||||
}
|
||||
|
||||
fn log_protocol_error(
|
||||
state: Option<&Arc<App>>,
|
||||
kind: &'static str,
|
||||
@@ -99,18 +154,10 @@ fn log_protocol_error(
|
||||
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,
|
||||
);
|
||||
}
|
||||
log_protocol_error_with_identity(
|
||||
state, kind, detail, peer, local, transport, http_version, status, None, None,
|
||||
tls_sni,
|
||||
);
|
||||
}
|
||||
#[derive(Clone)]
|
||||
pub struct ServerShutdown(pub CancellationToken);
|
||||
|
||||
+3
-2
@@ -5,6 +5,7 @@ async fn http2_connection<I>(
|
||||
peer: SocketAddr,
|
||||
local: SocketAddr,
|
||||
transport: Transport,
|
||||
tls_sni: Option<String>,
|
||||
extended_connect: bool,
|
||||
frontend_state: Option<Arc<App>>,
|
||||
lease: Arc<ConnectionLease>,
|
||||
@@ -69,7 +70,7 @@ async fn http2_connection<I>(
|
||||
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,
|
||||
if transport.tls { "tls" } else { "tcp" }, "2", 0, tls_sni.clone(),
|
||||
);
|
||||
tracing::debug!(%error, "HTTP/2 connection closed");
|
||||
}
|
||||
@@ -108,7 +109,7 @@ async fn http2_connection<I>(
|
||||
if transport.tls { "tls" } else { "tcp" },
|
||||
"2",
|
||||
0,
|
||||
None,
|
||||
tls_sni.clone(),
|
||||
);
|
||||
tracing::debug!(?cause, %error, "HTTP/2 graceful shutdown ended with protocol error")
|
||||
}
|
||||
|
||||
@@ -100,7 +100,9 @@ mod h2c_tests {
|
||||
peer,
|
||||
local,
|
||||
Transport { tls: false },
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
lease,
|
||||
server_stop,
|
||||
Duration::from_secs(5),
|
||||
|
||||
@@ -125,6 +125,7 @@ async fn serve_inner(
|
||||
peer,
|
||||
local,
|
||||
Transport { tls: true },
|
||||
host.clone(),
|
||||
h2,
|
||||
extended_connect,
|
||||
false,
|
||||
@@ -168,6 +169,7 @@ async fn serve_inner(
|
||||
peer,
|
||||
local,
|
||||
Transport { tls: false },
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
@@ -197,6 +199,7 @@ async fn serve_inner(
|
||||
peer,
|
||||
local,
|
||||
Transport { tls: true },
|
||||
None,
|
||||
h2,
|
||||
false,
|
||||
false,
|
||||
@@ -245,6 +248,7 @@ async fn serve_inner(
|
||||
peer,
|
||||
local,
|
||||
Transport { tls: false },
|
||||
None,
|
||||
prior_h2,
|
||||
extended_connect,
|
||||
h2c && !prior_h2,
|
||||
@@ -276,6 +280,7 @@ async fn connection<I>(
|
||||
peer: SocketAddr,
|
||||
local: SocketAddr,
|
||||
transport: Transport,
|
||||
tls_sni: Option<String>,
|
||||
h2: bool,
|
||||
extended_connect: bool,
|
||||
h2c_upgrade: bool,
|
||||
@@ -294,6 +299,7 @@ async fn connection<I>(
|
||||
peer,
|
||||
local,
|
||||
transport,
|
||||
tls_sni.clone(),
|
||||
extended_connect,
|
||||
frontend_state.clone(),
|
||||
lease,
|
||||
@@ -322,7 +328,8 @@ async fn connection<I>(
|
||||
|
||||
if h2c_attempt(&request) {
|
||||
if !h2c_upgrade {
|
||||
log_protocol_error(
|
||||
let (authority, host) = request_log_identity(&request);
|
||||
log_protocol_error_with_identity(
|
||||
upgrade_state.as_ref(),
|
||||
"h2c_upgrade_rejected",
|
||||
"h2c upgrade is disabled on this listener",
|
||||
@@ -331,6 +338,8 @@ async fn connection<I>(
|
||||
"tcp",
|
||||
"1.1",
|
||||
400,
|
||||
authority,
|
||||
host,
|
||||
None,
|
||||
);
|
||||
return Ok::<_, Infallible>(
|
||||
@@ -344,7 +353,8 @@ async fn connection<I>(
|
||||
let injected_headers = match h2_headers_frame(&request) {
|
||||
Ok(frame) => frame,
|
||||
Err(message) => {
|
||||
log_protocol_error(
|
||||
let (authority, host) = request_log_identity(&request);
|
||||
log_protocol_error_with_identity(
|
||||
upgrade_state.as_ref(),
|
||||
"h2c_upgrade_invalid",
|
||||
message,
|
||||
@@ -353,6 +363,8 @@ async fn connection<I>(
|
||||
"tcp",
|
||||
"1.1",
|
||||
400,
|
||||
authority,
|
||||
host,
|
||||
None,
|
||||
);
|
||||
return Ok::<_, Infallible>(
|
||||
@@ -389,6 +401,7 @@ async fn connection<I>(
|
||||
peer,
|
||||
local,
|
||||
Transport { tls: false },
|
||||
None,
|
||||
extended_connect,
|
||||
upgrade_state_for_task.clone(),
|
||||
upgrade_lease,
|
||||
@@ -448,7 +461,7 @@ async fn connection<I>(
|
||||
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,
|
||||
if transport.tls { "tls" } else { "tcp" }, "1.1", 400, tls_sni.clone(),
|
||||
);
|
||||
tracing::debug!(%error, "HTTP/1 connection closed");
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ pub(super) fn fastcgi_params(
|
||||
if context.tls { "on" } else { "off" }.into(),
|
||||
);
|
||||
params.insert("GATEWAY_INTERFACE".into(), "CGI/1.1".into());
|
||||
params.insert("SERVER_SOFTWARE".into(), "RustProxy/0.26.0".into());
|
||||
params.insert("SERVER_SOFTWARE".into(), "RustProxy/0.26.1".into());
|
||||
params.insert("REDIRECT_STATUS".into(), "200".into());
|
||||
if let Some(value) = headers
|
||||
.get(CONTENT_TYPE)
|
||||
|
||||
@@ -185,7 +185,7 @@ pub(super) fn directory_listing(path: &str, entries: &[DirectoryEntry]) -> Vec<u
|
||||
"<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.26.0</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.1</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.26.0</b>"));
|
||||
assert!(body.contains("RustProxy <b>0.26.1</b>"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
Reference in new issue
Block a user