This commit is contained in:
Mateusz Gruszczyński committed 2026-10-05 22:29:54 +02:00
1 parent b9d137eee1
commit 32e6875980
51 files changed
+1255 -452

No files matched your search

+8
View File
@@ -399,6 +399,14 @@ pub(super) static READER_DOCUMENTS: &[(&str, &str, &str)] = &[
"/docs/V0.25.1_AUDIT.md"
)),
),
(
"docs/V0.26.2_IMPLEMENTATION.md",
"technical",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/docs/V0.26.2_IMPLEMENTATION.md"
)),
),
(
"docs/V0.26.1_IMPLEMENTATION.md",
"technical",
+384
View File
@@ -0,0 +1,384 @@
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)),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum FileLogSearchClause {
Any {
value: String,
negated: bool,
},
Field {
field: String,
value: String,
negated: bool,
},
}
fn tokenize_file_log_search(input: &str) -> Result<Vec<String>, ApiError> {
let mut tokens = Vec::new();
let mut current = String::new();
let mut quoted = false;
let mut escaped = false;
for ch in input.chars() {
if escaped {
current.push(ch);
escaped = false;
continue;
}
if quoted && ch == '\\' {
escaped = true;
continue;
}
if ch == '"' {
quoted = !quoted;
continue;
}
if ch.is_whitespace() && !quoted {
if !current.is_empty() {
tokens.push(std::mem::take(&mut current));
}
continue;
}
current.push(ch);
}
if quoted || escaped {
return Err(ApiError(
StatusCode::BAD_REQUEST,
"invalid file log search syntax".into(),
));
}
if !current.is_empty() {
tokens.push(current);
}
Ok(tokens)
}
fn parse_file_log_search(input: &str) -> Result<Vec<FileLogSearchClause>, ApiError> {
let tokens = tokenize_file_log_search(input)?;
let mut clauses = Vec::new();
let mut index = 0usize;
while index < tokens.len() {
let token = &tokens[index];
if token.eq_ignore_ascii_case("AND") {
index += 1;
continue;
}
if token.eq_ignore_ascii_case("OR") {
return Err(ApiError(
StatusCode::BAD_REQUEST,
"file log search supports AND and negation, not OR".into(),
));
}
let (negated, term) = token
.strip_prefix('!')
.map_or((false, token.as_str()), |value| (true, value));
if term.is_empty() {
return Err(ApiError(
StatusCode::BAD_REQUEST,
"invalid file log search syntax".into(),
));
}
if let Some((field, inline_value)) = term.split_once(':') {
if field.is_empty()
|| field.len() > 128
|| !field
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.'))
{
return Err(ApiError(
StatusCode::BAD_REQUEST,
"invalid file log search field".into(),
));
}
let value = if inline_value.is_empty() {
index += 1;
let Some(next) = tokens.get(index) else {
return Err(ApiError(
StatusCode::BAD_REQUEST,
"missing file log search value".into(),
));
};
if next.eq_ignore_ascii_case("AND") || next.eq_ignore_ascii_case("OR") {
return Err(ApiError(
StatusCode::BAD_REQUEST,
"missing file log search value".into(),
));
}
next.clone()
} else {
inline_value.to_owned()
};
if value.is_empty() || value.len() > 256 {
return Err(ApiError(
StatusCode::BAD_REQUEST,
"invalid file log search value".into(),
));
}
clauses.push(FileLogSearchClause::Field {
field: field.to_ascii_lowercase(),
value: value.to_ascii_lowercase(),
negated,
});
} else {
clauses.push(FileLogSearchClause::Any {
value: term.to_ascii_lowercase(),
negated,
});
}
if clauses.len() > 32 {
return Err(ApiError(
StatusCode::BAD_REQUEST,
"too many file log search clauses".into(),
));
}
index += 1;
}
Ok(clauses)
}
fn file_log_field_alias(field: &str) -> &str {
match field {
"req" | "request" => "method",
"ip" | "client" => "client_ip",
"source" => "_source",
"time" => "timestamp",
other => other,
}
}
fn file_log_field<'a>(value: &'a Value, field: &str) -> Option<&'a Value> {
let mut current = value;
for part in file_log_field_alias(field).split('.') {
let object = current.as_object()?;
current = object.get(part).or_else(|| {
object
.iter()
.find(|(key, _)| key.eq_ignore_ascii_case(part))
.map(|(_, value)| value)
})?;
}
Some(current)
}
fn wildcard_match_ci(candidate: &str, pattern: &str) -> bool {
if !pattern.contains('*') {
return candidate.contains(pattern);
}
if pattern == "*" {
return true;
}
let starts_wild = pattern.starts_with('*');
let ends_wild = pattern.ends_with('*');
let parts = pattern
.split('*')
.filter(|part| !part.is_empty())
.collect::<Vec<_>>();
if parts.is_empty() {
return true;
}
let mut cursor = 0usize;
for (index, part) in parts.iter().enumerate() {
let Some(found) = candidate[cursor..].find(part) else {
return false;
};
if index == 0 && !starts_wild && found != 0 {
return false;
}
cursor += found + part.len();
}
ends_wild || cursor == candidate.len()
}
fn numeric_match(candidate: f64, pattern: &str) -> Option<bool> {
if let Some(raw) = pattern.strip_prefix(">=") {
return raw
.parse::<f64>()
.ok()
.map(|expected| candidate >= expected);
}
if let Some(raw) = pattern.strip_prefix("<=") {
return raw
.parse::<f64>()
.ok()
.map(|expected| candidate <= expected);
}
if let Some(raw) = pattern.strip_prefix('>') {
return raw.parse::<f64>().ok().map(|expected| candidate > expected);
}
if let Some(raw) = pattern.strip_prefix('<') {
return raw.parse::<f64>().ok().map(|expected| candidate < expected);
}
None
}
fn file_log_value_matches(value: &Value, pattern: &str) -> bool {
match value {
Value::Null => pattern == "null",
Value::Bool(value) => wildcard_match_ci(if *value { "true" } else { "false" }, pattern),
Value::Number(value) => {
let rendered = value.to_string();
if pattern.len() == 3 && pattern.ends_with("xx") {
return rendered.as_bytes().first() == pattern.as_bytes().first();
}
numeric_match(value.as_f64().unwrap_or_default(), pattern)
.unwrap_or_else(|| wildcard_match_ci(&rendered, pattern))
}
Value::String(value) => {
let candidate = value.to_ascii_lowercase();
if pattern.ends_with("xx") && pattern.len() == 3 && candidate.len() == 3 {
return candidate.as_bytes().first() == pattern.as_bytes().first();
}
if let Ok(candidate_number) = candidate.parse::<f64>() {
if let Some(result) = numeric_match(candidate_number, pattern) {
return result;
}
}
wildcard_match_ci(&candidate, pattern)
}
Value::Array(values) => values
.iter()
.any(|value| file_log_value_matches(value, pattern)),
Value::Object(_) => {
let mut rendered = String::new();
append_search_values(value, &mut rendered);
wildcard_match_ci(rendered.trim(), pattern)
}
}
}
fn record_matches_search(value: &Value, clauses: &[FileLogSearchClause]) -> bool {
clauses.iter().all(|clause| {
let (matched, negated) = match clause {
FileLogSearchClause::Any {
value: needle,
negated,
} => {
let mut searchable = String::new();
append_search_values(value, &mut searchable);
(wildcard_match_ci(searchable.trim(), needle), *negated)
}
FileLogSearchClause::Field {
field,
value: needle,
negated,
} => (
file_log_field(value, field)
.is_some_and(|value| file_log_value_matches(value, needle)),
*negated,
),
};
matched != negated
})
}
fn valid_raw_field_key(value: &str) -> bool {
!value.is_empty()
&& value.len() <= 128
&& value
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.'))
}
fn parse_raw_error_record(line: &str) -> Value {
let mut parts = line.split_whitespace();
let Some(timestamp) = parts.next() else {
return json!({"raw": line});
};
let Some(level) = parts.next() else {
return json!({"raw": line});
};
let Some(target) = parts.next() else {
return json!({"raw": line});
};
let rest = parts.collect::<Vec<_>>();
let first_field = rest.iter().position(|token| {
token
.split_once('=')
.is_some_and(|(key, _)| valid_raw_field_key(key))
});
let message_end = first_field.unwrap_or(rest.len());
let mut object = serde_json::Map::new();
object.insert("timestamp".into(), Value::String(timestamp.into()));
object.insert("level".into(), Value::String(level.into()));
object.insert("target".into(), Value::String(target.into()));
if message_end > 0 {
object.insert(
"message".into(),
Value::String(rest[..message_end].join(" ")),
);
}
let mut index = message_end;
while index < rest.len() {
let Some((key, start)) = rest[index].split_once('=') else {
index += 1;
continue;
};
if !valid_raw_field_key(key) {
index += 1;
continue;
}
let mut pieces = vec![start];
index += 1;
while index < rest.len()
&& !rest[index]
.split_once('=')
.is_some_and(|(next_key, _)| valid_raw_field_key(next_key))
{
pieces.push(rest[index]);
index += 1;
}
object.insert(key.into(), Value::String(pieces.join(" ")));
}
object.insert("raw".into(), Value::String(line.into()));
Value::Object(object)
}
fn collect_file_log_fields(value: &Value, output: &mut std::collections::BTreeSet<String>) {
fn walk(prefix: &str, value: &Value, output: &mut std::collections::BTreeSet<String>) {
if output.len() >= 64 {
return;
}
match value {
Value::Object(object) => {
for (key, child) in object {
if key == "_source_kind" || output.len() >= 64 {
continue;
}
let path = if prefix.is_empty() {
key.clone()
} else {
format!("{prefix}.{key}")
};
if child.is_object() {
walk(&path, child, output);
} else {
output.insert(path);
}
}
}
_ if !prefix.is_empty() => {
output.insert(prefix.into());
}
_ => {}
}
}
walk("", value, output);
}
+42 -37
View File
@@ -4,7 +4,6 @@ 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,
@@ -17,7 +16,6 @@ struct FileLogSource {
size: u64,
modified_ms: u64,
}
#[derive(Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct FileLogQuery {
@@ -30,7 +28,6 @@ struct FileLogQuery {
offset: Option<usize>,
limit: Option<usize>,
}
fn file_modified_ms(metadata: &std::fs::Metadata) -> u64 {
metadata
.modified()
@@ -39,7 +36,6 @@ fn file_modified_ms(metadata: &std::fs::Metadata) -> u64 {
.map(|duration| duration.as_millis() as u64)
.unwrap_or(0)
}
fn source_from_path(
id: String,
label: String,
@@ -70,7 +66,6 @@ fn source_from_path(
.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 {
@@ -134,7 +129,6 @@ fn configured_file_log_sources(config: &crate::config::Config) -> Vec<FileLogSou
));
sources
}
fn file_log_sources_json(sources: &[FileLogSource]) -> Value {
Value::Array(
sources
@@ -155,7 +149,6 @@ fn file_log_sources_json(sources: &[FileLogSource]) -> Value {
.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))
@@ -178,7 +171,6 @@ async fn file_logs(State(state): State<Arc<App>>) -> Result<Json<Value>, ApiErro
}
})))
}
fn open_file_log(path: &std::path::Path) -> std::io::Result<std::fs::File> {
let mut options = std::fs::OpenOptions::new();
options.read(true);
@@ -197,7 +189,6 @@ fn open_file_log(path: &std::path::Path) -> std::io::Result<std::fs::File> {
}
Ok(file)
}
fn read_tail(
source: &FileLogSource,
budget: u64,
@@ -228,7 +219,6 @@ fn read_tail(
.collect::<Vec<_>>();
Ok((lines, bytes, truncated || iter.next().is_some()))
}
fn copy_mapped_field(
object: &mut serde_json::Map<String, Value>,
format: &crate::logging::JsonFormat,
@@ -251,16 +241,35 @@ fn normalized_record(
line: &str,
format: &crate::logging::JsonFormat,
) -> Value {
let mut value = serde_json::from_str::<Value>(line).unwrap_or_else(|_| json!({"raw": line}));
let mut value = if source.format == "RAW" {
parse_raw_error_record(line)
} else {
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",
"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);
}
@@ -297,6 +306,7 @@ fn normalized_record(
});
object.insert("_source".into(), Value::String(source.id.clone()));
object.insert("_source_kind".into(), Value::String(source.kind.into()));
object.insert("_source_format".into(), Value::String(source.format.into()));
value
}
@@ -306,27 +316,12 @@ 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 {
fn record_matches(value: &Value, query: &FileLogQuery, search: &[FileLogSearchClause]) -> bool {
let Some(object) = value.as_object() else {
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 !record_matches_search(value, search) {
return false;
}
if let Some(event) = query
.event
@@ -374,7 +369,7 @@ fn validate_file_log_query(query: &FileLogQuery) -> Result<(), ApiError> {
.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.q, 512)
|| !text_ok(&query.client_ip, 128)
|| !text_ok(&query.vhost, 256)
|| query.offset.unwrap_or(0) > FILE_LOG_MAX_OFFSET
@@ -407,6 +402,7 @@ async fn file_logs_query(
Query(query): Query<FileLogQuery>,
) -> Result<Json<Value>, ApiError> {
validate_file_log_query(&query)?;
let search = parse_file_log_search(query.q.as_deref().unwrap_or(""))?;
let config = state.config.load().envelope.config.clone();
tokio::task::spawn_blocking(move || {
let mut sources = configured_file_log_sources(&config);
@@ -428,7 +424,6 @@ async fn file_logs_query(
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;
@@ -436,6 +431,7 @@ async fn file_logs_query(
let mut remaining_lines = FILE_LOG_SCAN_LINES;
let mut scanned_lines = 0usize;
let mut records = Vec::new();
let mut fields = std::collections::BTreeSet::new();
for (index, source) in sources.iter().enumerate() {
if remaining_bytes == 0 || remaining_lines == 0 {
truncated = true;
@@ -455,7 +451,8 @@ async fn file_logs_query(
continue;
}
let value = normalized_record(source, &line, &log_format);
if record_matches(&value, &query, &q) {
if record_matches(&value, &query, &search) {
collect_file_log_fields(&value, &mut fields);
records.push(value);
}
}
@@ -465,8 +462,15 @@ async fn file_logs_query(
}
}
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 numeric = |value: &Value| value.get("time_ms").and_then(Value::as_u64);
match (numeric(a), numeric(b)) {
(Some(left), Some(right)) => right.cmp(&left),
_ => b
.get("timestamp")
.and_then(Value::as_str)
.unwrap_or("")
.cmp(a.get("timestamp").and_then(Value::as_str).unwrap_or("")),
}
});
let total = records.len();
let page = records
@@ -482,6 +486,7 @@ async fn file_logs_query(
"truncated": truncated,
"scanned_bytes": scanned_bytes,
"scanned_lines": scanned_lines,
"fields": fields,
"sources": file_log_sources_json(&sources),
})))
})
+1
View File
@@ -7,6 +7,7 @@ include!("preferences.rs");
include!("status.rs");
include!("logging_api.rs");
include!("file_logs.rs");
include!("file_log_search.rs");
include!("operations.rs");
include!("certificates.rs");
include!("tail.rs");
+10 -3
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.26.1",
"version": "0.26.2",
"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": [
@@ -4928,6 +4928,13 @@
"type": "integer",
"format": "int64"
},
"fields": {
"type": "array",
"description": "Bounded discovered leaf field names available for dynamic Stored Logs columns.",
"items": {
"type": "string"
}
},
"sources": {
"type": "array",
"items": {
@@ -9685,10 +9692,10 @@
"name": "q",
"in": "query",
"required": false,
"description": "Case-insensitive text search in the bounded scanned records.",
"description": "Case-insensitive field query. Supports field:value, AND, ! negation, quoted values, wildcards and numeric comparisons; bare terms search all values.",
"schema": {
"type": "string",
"maxLength": 256
"maxLength": 512
}
},
{
+19 -10
View File
@@ -13,10 +13,7 @@ const LIVE_LOG_COLUMNS: &[&str] = &[
"websocket",
"upstream_error",
];
const FILE_LOG_COLUMNS: &[&str] = &[
"time", "event", "status", "client_ip", "vhost", "transport", "matched_by", "sni",
"authority", "record", "source",
];
const FILE_LOG_COLUMN_MAX: usize = 64;
const BACKEND_TABLE_COLUMNS: &[&str] = &[
"vhost",
"backend",
@@ -38,10 +35,18 @@ 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()
[
"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
@@ -104,7 +109,7 @@ fn validate_admin_preferences(preferences: &AdminPreferences) -> Result<(), ApiE
}
}
if preferences.file_log_columns.is_empty()
|| preferences.file_log_columns.len() > FILE_LOG_COLUMNS.len()
|| preferences.file_log_columns.len() > FILE_LOG_COLUMN_MAX
{
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
@@ -113,7 +118,11 @@ fn validate_admin_preferences(preferences: &AdminPreferences) -> Result<(), ApiE
}
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()) {
if column.is_empty()
|| column.len() > 128
|| column.chars().any(char::is_control)
|| !seen.insert(column.as_str())
{
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid stored-log column preference".into(),
+143 -91
View File
@@ -1,100 +1,152 @@
#[test]
fn stored_log_inventory_keeps_existing_files_visible_when_writer_is_disabled() {
use std::io::Write;
#[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 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);
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);
#[test]
fn stored_log_tail_enforces_line_budget() {
use std::io::Write;
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);
}
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);
#[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());
}
let source = source_from_path(
"access".into(),
"Access log".into(),
"access",
path,
false,
"JSONL",
#[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,
&parse_file_log_search("error").unwrap()
));
assert!(record_matches(
&record,
&query,
&parse_file_log_search(r#"error:"HTTP parser""#).unwrap()
));
assert!(record_matches(
&record,
&query,
&parse_file_log_search("sni:app.example.test AND !country:Poland").unwrap()
));
assert!(!record_matches(
&record,
&query,
&parse_file_log_search("sni:missing").unwrap()
));
}
#[test]
fn stored_log_search_supports_field_aliases_negation_and_numeric_filters() {
let record = json!({
"method":"GET",
"country":"Germany",
"status":503,
"path":"/api/users"
});
assert!(record_matches_search(
&record,
&parse_file_log_search("req: GET AND !country: Poland AND status:>=500").unwrap()
));
assert!(record_matches_search(
&record,
&parse_file_log_search("status:5xx path:/api/*").unwrap()
));
assert!(!record_matches_search(
&record,
&parse_file_log_search("country:Poland").unwrap()
));
}
#[test]
fn stored_log_raw_error_parser_extracts_basic_fields() {
let record = parse_raw_error_record(
"2026-10-05T19:00:00.000Z ERROR rust_proxy::http upstream failed client_ip=192.0.2.10 status=502 error=connection reset",
);
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);
}
assert_eq!(record["timestamp"], "2026-10-05T19:00:00.000Z");
assert_eq!(record["level"], "ERROR");
assert_eq!(record["target"], "rust_proxy::http");
assert_eq!(record["message"], "upstream failed");
assert_eq!(record["client_ip"], "192.0.2.10");
assert_eq!(record["status"], "502");
assert_eq!(record["error"], "connection reset");
}
#[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;
#[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() {
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());
}
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());
}
+2 -2
View File
@@ -344,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(); 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("");
pendingAuthDestination = destination; if (notify) adminAuthChannel?.postMessage("locked"); $("api-docs").hidden = true; $("logs-menu").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 = ""; });
@@ -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.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 activate(info) { pendingAuthDestination = null; authMessage(""); $("api-docs").hidden = false; $("logs-menu").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); }
+1 -1
View File
@@ -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.1";
$("server-mode").value = server.mode || "preserve"; $("server-value").value = server.value || "RustProxy/v0.26.2";
for (const name of ["gzip", "br", "zstd", "deflate"]) $(`compress-${name}`).checked = !!compression[name];
$("compress-min").value = compression.min_bytes ?? 1024; syncServerMode();
}
+115 -53
View File
@@ -3,44 +3,39 @@
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"];
const specialLabels = new Map([
["time", "time"], ["event", "fileLogsEvent"], ["status", "status"],
["client_ip", "clientIp"], ["vhost", "vhost"], ["record", "fileLogsRecord"],
["source", "fileLogsSource"],
]);
let availableColumns = new Set(defaultColumnIds);
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 label = `${source.label} · ${source.format} · ${bytes(Number(source.size) || 0)}`;
const option = el("option", label); option.value = source.id; return option;
}
function renderInventory(value) {
inventory = value;
const selected = sourceSelect().value || "all";
const select = sourceSelect();
const selected = select.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");
select.replaceChildren(...options);
select.value = sources.some(source => source.id === selected) ? selected : "all";
}
async function loadInventory() {
const value = await api("/api/file-logs"); renderInventory(value); return value;
const value = await api("/api/file-logs");
renderInventory(value);
return value;
}
const currentLimit = () => Math.max(1, Math.min(500, Number($("file-log-limit").value) || 100));
function entryTime(entry) {
if (entry.time_ms) return new Date(Number(entry.time_ms)).toLocaleString(locale());
if (entry.timestamp) return String(entry.timestamp);
@@ -54,24 +49,67 @@
if (entry.raw) return entry.raw;
return entry.outcome || "—";
}
function entryStatus(entry) {
const status = Number(entry.status) || 0;
return status ? String(status) : "—";
function nestedValue(entry, path) {
let value = entry;
for (const part of path.split(".")) {
if (!value || typeof value !== "object" || !Object.hasOwn(value, part)) return undefined;
value = value[part];
}
return value;
}
function displayValue(value) {
if (value === undefined || value === null || value === "") return "—";
if (typeof value === "object") return JSON.stringify(value);
return String(value);
}
function columnValue(entry, id) {
if (id === "time") return entryTime(entry);
if (id === "record") return entrySummary(entry);
if (id === "source") return entry._source || "—";
return displayValue(nestedValue(entry, id));
}
function columnLabel(id) {
const key = specialLabels.get(id);
return key ? t(key) : id;
}
function normalizedField(field) {
if (field === "_source") return "source";
if (field === "_source_kind" || field === "_source_format") return null;
return field;
}
function orderedColumns() {
const preferred = defaultColumnIds.filter(id => availableColumns.has(id));
const rest = [...availableColumns].filter(id => !defaultColumnIds.includes(id)).sort((a, b) => a.localeCompare(b, locale(), { sensitivity: "base" }));
return [...preferred, ...rest];
}
function applyFields(fields = []) {
for (const field of fields) {
if (typeof field !== "string") continue;
const normalized = normalizedField(field);
if (normalized) availableColumns.add(normalized);
}
for (const id of visibleColumns) availableColumns.add(id);
syncColumnPicker(); syncHeaders();
}
function syncHeaders() {
document.querySelectorAll("#file-logs-table-head [data-file-log-column]").forEach(header => {
header.hidden = !visibleColumns.has(header.dataset.fileLogColumn);
const row = $("file-logs-table-head").querySelector("tr");
const headers = orderedColumns().filter(id => visibleColumns.has(id)).map(id => {
const header = el("th", columnLabel(id)); header.dataset.fileLogColumn = id;
const key = specialLabels.get(id); if (key) header.dataset.i18n = key;
return header;
});
row.replaceChildren(...headers);
}
function syncColumnPicker() {
const picker = $("file-log-column-options");
picker.replaceChildren(...columns.map(column => {
picker.replaceChildren(...orderedColumns().map(id => {
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;
const input = document.createElement("input"); input.type = "checkbox"; input.checked = visibleColumns.has(id); input.dataset.column = id;
const text = el("span", columnLabel(id));
const key = specialLabels.get(id); if (key) text.dataset.i18n = key;
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);
if (input.checked) visibleColumns.add(id); else visibleColumns.delete(id);
syncHeaders(); renderRows(); void savePreferences();
});
label.append(input, text); return label;
@@ -79,9 +117,10 @@
}
function applyPreferences(preferences) {
const selected = Array.isArray(preferences?.file_log_columns)
? preferences.file_log_columns.filter(id => columns.some(column => column.id === id))
? preferences.file_log_columns.filter(id => typeof id === "string" && id.length <= 128)
: [];
visibleColumns = new Set(selected.length ? selected : defaultColumnIds);
for (const id of visibleColumns) availableColumns.add(id);
preferencesLoaded = true;
syncColumnPicker(); syncHeaders(); renderRows();
}
@@ -92,45 +131,64 @@
try {
do {
preferenceSaveQueued = false;
const selected = columns.filter(column => visibleColumns.has(column.id)).map(column => column.id);
const selected = orderedColumns().filter(id => visibleColumns.has(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 flattenFields(value, prefix = "", output = []) {
if (output.length >= 128) return output;
if (value && typeof value === "object" && !Array.isArray(value)) {
for (const [key, child] of Object.entries(value)) {
if (key === "_source_kind") continue;
const path = prefix ? `${prefix}.${key}` : key;
if (child && typeof child === "object" && !Array.isArray(child)) flattenFields(child, path, output);
else output.push([path, displayValue(child)]);
if (output.length >= 128) break;
}
} else if (prefix) output.push([prefix, displayValue(value)]);
return output;
}
function detailRow(entry) {
const row = el("tr", undefined, "file-log-detail-row");
const cell = el("td"); cell.colSpan = Math.max(1, visibleColumns.size);
const detail = el("div", undefined, "file-log-inline-detail");
const heading = el("strong", t("fileLogsParsedFields")); heading.dataset.i18n = "fileLogsParsedFields";
const fields = el("div", undefined, "file-log-field-grid");
for (const [key, value] of flattenFields(entry)) {
const item = el("div", undefined, "file-log-field");
item.append(el("code", key), el("span", value)); fields.append(item);
}
const raw = el("details", undefined, "file-log-raw-json");
const summary = el("summary", t("fileLogsRawJson")); summary.dataset.i18n = "fileLogsRawJson";
raw.append(summary, el("pre", JSON.stringify(entry, null, 2), "metrics-example"));
detail.append(heading, fields, raw); cell.append(detail); row.append(cell); return row;
}
function renderRows(entries = currentEntries) {
currentEntries = entries;
const ids = orderedColumns().filter(id => visibleColumns.has(id));
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);
for (const id of ids) {
const value = columnValue(entry, id), cell = el("td", value); cell.title = value; row.append(cell);
}
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);
}
nodes.push(row); if (selectedIndex === index) nodes.push(detailRow(entry));
});
$("file-log-rows").replaceChildren(...nodes);
$("file-logs-empty").hidden = entries.length !== 0;
}
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);
}
const source = sourceSelect().value || "all";
if (source !== "all") params.set("source", source);
const q = $("file-log-q").value.trim(); if (q) params.set("q", q);
params.set("offset", String(offset)); params.set("limit", String(currentLimit()));
return params.toString();
}
@@ -142,6 +200,7 @@
if (!inventory) await loadInventory();
const value = await api(`/api/file-logs/query?${queryString()}`);
lastTotal = Number(value.total) || 0; selectedIndex = null;
applyFields(Array.isArray(value.fields) ? value.fields : []);
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")
@@ -156,16 +215,19 @@
function reset() {
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();
availableColumns = new Set(defaultColumnIds); visibleColumns = new Set(defaultColumnIds);
sourceSelect().replaceChildren(Object.assign(el("option", t("fileLogsAllSources")), { value: "all" }));
$("file-log-rows").replaceChildren(); syncColumnPicker(); syncHeaders();
}
$("file-logs-open").addEventListener("click", () => setPanel("filelogs"));
$("file-logs-open").addEventListener("click", () => { $("logs-menu").open = false; 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-log-limit").addEventListener("change", () => refresh(true));
sourceSelect().addEventListener("change", () => 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));
syncColumnPicker(); syncHeaders();
window.rustProxyFileLogs = { refresh: () => refresh(false), reset, inventory: loadInventory, applyPreferences };
})();
+48 -62
View File
@@ -64,13 +64,18 @@
<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="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"
id="about-open" type="button">About</button><button class="quiet" data-i18n="logout" hidden=""
id="logout">Lock</button></div>
hidden="">Documentation</a>
<details class="top-log-menu" hidden="" id="logs-menu">
<summary class="quiet" data-i18n="logs">Logs</summary>
<div class="top-log-menu-items"><button class="quiet" data-i18n="storedLogs" hidden=""
id="file-logs-open" type="button">Stored logs</button><button class="quiet"
data-i18n="liveLogs" hidden="" id="logs-open" type="button">Live access logs</button>
</div>
</details><select aria-label="Language" class="language-select" data-i18n-aria="language"
id="language"></select><button class="quiet" data-i18n="about" id="about-open"
type="button">About</button><button class="quiet" data-i18n="logout" hidden=""
id="logout">Lock</button>
</div>
</header>
<section class="login-section" hidden="" id="login">
<div class="eyebrow" data-i18n="firstSlice">THE FIRST WORKING SLICE</div>
@@ -551,7 +556,7 @@
<option data-i18n="vhLastRecovery" value="lastRecovery">Last recovery</option>
</select></label>
<button class="quiet backend-sort-direction" data-i18n-aria="backendSortAscending"
id="pool-sort-direction" type="button">A-Z</button>
id="pool-sort-direction" type="button">↑</button>
<details class="backend-column-picker">
<summary class="quiet" data-i18n="backendColumns">Columns</summary>
<div class="backend-column-options">
@@ -595,8 +600,6 @@
vhosts</button>
</div>
</div>
<p class="fine" data-i18n="vhHealthHint">Live state: leases cover the entire exchange; traffic
is an EWMA of payload bytes.</p>
<div class="table-scroll">
<table class="pool-table">
<thead>
@@ -1502,75 +1505,58 @@
<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 class="file-logs-heading-actions">
<details class="file-log-search-help">
<summary class="quiet" data-i18n="fileLogsSearchHelp">Search syntax</summary>
<div class="file-log-search-help-popover">
<strong data-i18n="fileLogsSearchHelpTitle">Kibana-style field search</strong>
<p class="fine" data-i18n="fileLogsSearchHelpText">Use field:value clauses.
Clauses are joined with AND; prefix a clause with ! to exclude it. Quotes
keep spaces together.</p>
<code>req: GET AND !country: PL</code>
<code>status:&gt;=500 AND path:/api/*</code>
<code>error:"connection reset"</code>
</div>
</details>
<button class="outline" data-i18n="refresh" id="file-logs-refresh"
type="button">Refresh</button>
</div>
</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>
<p class="fine" data-i18n="fileLogsHelp">Searches bounded RustProxy access/error logs and parses
structured fields without exposing filesystem paths.</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>
<label class="file-log-source"><span data-i18n="fileLogsSource">Source</span><select
id="file-log-source">
<option data-i18n="fileLogsAllSources" 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">
maxlength="512" data-i18n-placeholder="fileLogsSearchPlaceholder"
placeholder="req: GET AND !country: Poland" type="search" /></label>
<label class="file-log-limit"><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>
<div class="file-logs-table-toolbar">
<p class="fine" id="file-log-summary" role="status"></p>
<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>
<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>
<div class="table-scroll file-logs-table">
<table>
<thead id="file-logs-table-head">
<tr>
<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>
<tr></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>
search.</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>
@@ -1749,7 +1735,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.1"
id="server-value" maxlength="256" placeholder="RustProxy/v0.26.2"
spellcheck="false" />
<h3 class="subheading" data-i18n="diagnosticHeaders">Diagnostic headers</h3>
<div class="switch-grid">
@@ -2548,7 +2534,7 @@
</section>
</div>
</section>
<footer class="page-footer"><span>RustProxy <b>0.26.1</b></span><span id="footer-status">API - /
<footer class="page-footer"><span>RustProxy <b>0.26.2</b></span><span id="footer-status">API - /
dropped: 0</span></footer>
</div>
</main>
@@ -2658,7 +2644,7 @@
</div>
<article class="card audit-dialog-card">
<div class="field-grid tunnel-controls">
<div><label data-i18n="tunnelKillReason" for="tunnel-kill-reason">Close reason</label><input
<div><label data-i18n="tunnelKillReason" for="tunnel-kill-reason">Kill reason</label><input
id="tunnel-kill-reason" maxlength="256" data-i18n-placeholder="tunnelKillReasonPlaceholder"
placeholder="Administrative close" /></div>
</div>
@@ -3259,7 +3245,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.1</strong></div>
<div><span class="small-label">RustProxy</span><strong>0.26.2</strong></div>
</div>
<dl class="about-meta">
<div>
+1 -1
View File
@@ -171,7 +171,7 @@
render();
}
syncColumnPicker(); syncHeaders();
$("logs-open").addEventListener("click", () => open()); $("logs-close").addEventListener("click", () => dialog.close());
$("logs-open").addEventListener("click", () => { $("logs-menu").open = false; open(); }); $("logs-close").addEventListener("click", () => dialog.close());
$("logs-load-history").addEventListener("click", async () => {
const button = $("logs-load-history"); button.disabled = true;
try {
+186 -2
View File
@@ -8240,7 +8240,7 @@ button.config-history-day.is-selected {
cursor: pointer;
}
.file-logs-table tbody tr.selected > td {
.file-logs-table tbody tr.selected>td {
background: var(--panel2);
box-shadow: inset 3px 0 0 var(--accent);
}
@@ -8286,7 +8286,7 @@ button.config-history-day.is-selected {
gap: 16px;
}
.file-log-inventory-details > summary {
.file-log-inventory-details>summary {
cursor: pointer;
color: var(--muted);
font-size: 0.86rem;
@@ -8316,4 +8316,188 @@ button.config-history-day.is-selected {
align-items: stretch;
flex-direction: column;
}
}
.top-log-menu {
position: relative;
}
.top-log-menu>summary,
.file-log-search-help>summary {
list-style: none;
cursor: pointer;
user-select: none;
}
.top-log-menu>summary::-webkit-details-marker,
.file-log-search-help>summary::-webkit-details-marker {
display: none;
}
.top-log-menu[open]>summary,
.file-log-search-help[open]>summary {
color: var(--text);
border-color: var(--border-hover);
}
.top-log-menu-items {
position: absolute;
z-index: 120;
top: calc(100% + 6px);
inset-inline-end: 0;
display: grid;
gap: 4px;
min-width: 170px;
padding: 6px;
border: 1px solid var(--line);
background: var(--panel);
box-shadow: 0 12px 28px var(--shadow-strong);
}
.top-log-menu-items .quiet {
width: 100%;
text-align: start;
white-space: nowrap;
}
.file-logs-heading-actions,
.file-logs-table-toolbar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.file-log-search-help {
position: relative;
}
.file-log-search-help-popover {
position: absolute;
z-index: 90;
top: calc(100% + 6px);
inset-inline-end: 0;
display: grid;
gap: 7px;
width: min(420px, calc(100vw - 36px));
padding: 12px;
border: 1px solid var(--line);
background: var(--panel2);
box-shadow: 0 12px 28px var(--shadow-strong);
}
.file-log-search-help-popover p {
margin: 0;
}
.file-log-search-help-popover code {
display: block;
overflow-wrap: anywhere;
}
.file-logs-filter {
display: grid;
grid-template-columns: minmax(180px, 260px) minmax(260px, 1fr) minmax(110px, 150px);
gap: 10px;
align-items: end;
}
.file-logs-filter .file-log-search {
grid-column: auto;
}
.file-logs-filter input,
.file-logs-filter select {
width: 100%;
}
.file-logs-table-toolbar {
justify-content: space-between;
min-height: 32px;
}
.file-logs-table-toolbar .fine {
margin: 0;
}
.file-log-column-picker {
margin-inline-start: auto;
}
.file-log-column-picker .log-column-options {
width: min(360px, calc(100vw - 24px));
max-height: 420px;
overflow: auto;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.file-log-column-picker .log-column-option span {
overflow: hidden;
text-overflow: ellipsis;
}
.file-log-field-grid {
display: grid;
gap: 1px;
margin-top: 8px;
border: 1px solid var(--line);
}
.file-log-field {
display: grid;
grid-template-columns: minmax(140px, .5fr) minmax(0, 1.5fr);
gap: 12px;
padding: 6px 8px;
background: var(--panel);
}
.file-log-field code {
overflow-wrap: anywhere;
color: var(--muted);
}
.file-log-field span {
min-width: 0;
overflow-wrap: anywhere;
}
.file-log-raw-json {
margin-top: 10px;
}
.file-log-raw-json>summary {
cursor: pointer;
color: var(--muted);
}
.backend-sort-direction {
min-width: 36px;
font-size: 16px;
line-height: 1;
}
@media (max-width: 700px) {
.file-logs-heading-actions,
.file-logs-table-toolbar {
align-items: stretch;
flex-wrap: wrap;
}
.file-logs-filter {
grid-template-columns: 1fr;
}
.file-log-limit {
max-width: 160px;
}
.file-log-field {
grid-template-columns: 1fr;
gap: 3px;
}
.file-log-column-picker .log-column-options {
grid-template-columns: 1fr;
}
}
+1 -1
View File
@@ -41,7 +41,7 @@
}
function syncPoolSortDirection() {
const control = $("pool-sort-direction"); if (!control) return;
control.textContent = poolTablePrefs.descending ? "Z-A" : "A-Z";
control.textContent = poolTablePrefs.descending ? "↓" : "↑";
control.setAttribute("aria-label", t(poolTablePrefs.descending ? "backendSortDescending" : "backendSortAscending"));
}
function applyPoolTableView() {
+1 -1
View File
@@ -41,7 +41,7 @@ use zeroize::Zeroizing;
#[command(
name = "rust-proxy",
version,
about = "RustProxy - independent Rust reverse proxy and static Web Server 0.26.1"
about = "RustProxy - independent Rust reverse proxy and static Web Server 0.26.2"
)]
struct Cli {
#[arg(
+1 -1
View File
@@ -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.1 - all vhosts. Type 'help' for interactive filters; Ctrl-C or 'quit' exits.");
println!("RustProxy live logs 0.26.2 - 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();
+1 -1
View File
@@ -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.1".into());
params.insert("SERVER_SOFTWARE".into(), "RustProxy/0.26.2".into());
params.insert("REDIRECT_STATUS".into(), "200".into());
if let Some(value) = headers
.get(CONTENT_TYPE)
+1 -1
View File
@@ -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.1</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.2</b></span><span>Web Server · Directory listing</span></footer></main></body></html>"#);
html.into_bytes()
}
+1 -1
View File
@@ -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.1</b>"));
assert!(body.contains("RustProxy <b>0.26.2</b>"));
}
#[tokio::test]