v0.31.5
This commit is contained in:
1 parent
d942c1a35d
commit
75104f87ba
103 files changed
+1344
-370
No files matched your search
@@ -14,8 +14,19 @@ use std::sync::OnceLock;
|
||||
mod documents;
|
||||
use documents::READER_DOCUMENTS;
|
||||
|
||||
pub(super) async fn index() -> Html<&'static str> {
|
||||
Html(include_str!("web/docs.html"))
|
||||
pub(super) async fn index() -> impl IntoResponse {
|
||||
Html(include_str!("web/docs.html").replace("__RUSTPROXY_ASSET_ID__", super::asset_build_id()))
|
||||
}
|
||||
|
||||
pub(super) async fn fingerprinted(axum::extract::Path(name): axum::extract::Path<String>) -> Response {
|
||||
let full = format!("/docs/{name}");
|
||||
match super::fingerprinted_path(&full, "/docs/").as_deref() {
|
||||
Some("/docs/init.js") => script().await.into_response(),
|
||||
Some("/docs/style.css") => style().await.into_response(),
|
||||
Some("/docs/swagger-ui-bundle.js") => asset("swagger-ui-bundle.js"),
|
||||
Some("/docs/swagger-ui.css") => asset("swagger-ui.css"),
|
||||
_ => StatusCode::NOT_FOUND.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn script() -> impl IntoResponse {
|
||||
|
||||
@@ -244,6 +244,7 @@ pub(super) static READER_DOCUMENTS: &[(&str, &str, &str)] = &[
|
||||
"guides",
|
||||
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/STREAMS.md")),
|
||||
),
|
||||
reader_doc!("docs/IMPLEMENTATION_0.31.5.md", "technical"),
|
||||
reader_doc!("docs/IMPLEMENTATION_0.31.4.md", "technical"),
|
||||
reader_doc!("docs/IMPLEMENTATION_0.31.3.md", "technical"),
|
||||
reader_doc!("docs/IMPLEMENTATION_0.31.1.md", "technical"),
|
||||
|
||||
@@ -351,6 +351,36 @@ fn parse_raw_error_record(line: &str) -> Value {
|
||||
Value::Object(object)
|
||||
}
|
||||
|
||||
fn file_log_aggregate_values(value: &Value, output: &mut Vec<String>) {
|
||||
if output.len() >= 16 {
|
||||
return;
|
||||
}
|
||||
match value {
|
||||
Value::Null => {}
|
||||
Value::Bool(value) => output.push(value.to_string()),
|
||||
Value::Number(value) => output.push(value.to_string()),
|
||||
Value::String(value) => {
|
||||
let rendered = if value.chars().count() > 512 {
|
||||
value.chars().take(512).collect::<String>()
|
||||
} else {
|
||||
value.clone()
|
||||
};
|
||||
if !rendered.is_empty() {
|
||||
output.push(rendered);
|
||||
}
|
||||
}
|
||||
Value::Array(values) => {
|
||||
for value in values.iter().take(16) {
|
||||
file_log_aggregate_values(value, output);
|
||||
if output.len() >= 16 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::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 {
|
||||
|
||||
+16
-188
@@ -4,12 +4,15 @@ 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;
|
||||
const FILE_LOG_MAX_AGGREGATE_RESULTS: usize = 200;
|
||||
const FILE_LOG_MAX_AGGREGATE_VALUES: usize = 4096;
|
||||
const FILE_LOG_GZIP_INPUT_MAX: u64 = 8 * 1024 * 1024;
|
||||
const FILE_LOG_GZIP_OUTPUT_MAX: u64 = 64 * 1024 * 1024;
|
||||
#[derive(Clone)]
|
||||
struct FileLogSource {
|
||||
id: String,
|
||||
label: String,
|
||||
group_id: String,
|
||||
kind: &'static str,
|
||||
path: std::path::PathBuf,
|
||||
enabled: bool,
|
||||
@@ -28,6 +31,10 @@ struct FileLogQuery {
|
||||
status_class: Option<String>,
|
||||
client_ip: Option<String>,
|
||||
vhost: Option<String>,
|
||||
consolidate_vhosts: Option<bool>,
|
||||
aggregate_field: Option<String>,
|
||||
aggregate_limit: Option<usize>,
|
||||
aggregate_min_count: Option<usize>,
|
||||
offset: Option<usize>,
|
||||
limit: Option<usize>,
|
||||
}
|
||||
@@ -42,6 +49,7 @@ fn file_modified_ms(metadata: &std::fs::Metadata) -> u64 {
|
||||
fn source_from_path(
|
||||
id: String,
|
||||
label: String,
|
||||
group_id: String,
|
||||
kind: &'static str,
|
||||
path: std::path::PathBuf,
|
||||
enabled: bool,
|
||||
@@ -58,6 +66,7 @@ fn source_from_path(
|
||||
FileLogSource {
|
||||
id,
|
||||
label,
|
||||
group_id,
|
||||
kind,
|
||||
path,
|
||||
enabled,
|
||||
@@ -109,9 +118,12 @@ fn configured_file_log_sources(config: &crate::config::Config) -> Vec<FileLogSou
|
||||
} else {
|
||||
format!("vhost-gz:{name}")
|
||||
};
|
||||
let vhost_name = name.split_once(".jsonl").map_or(name.as_str(), |(prefix, _)| prefix);
|
||||
let group_id = format!("vhost:{vhost_name}");
|
||||
sources.push(source_from_path(
|
||||
id,
|
||||
name,
|
||||
group_id,
|
||||
"access",
|
||||
path,
|
||||
config.logging.file.enabled,
|
||||
@@ -124,6 +136,7 @@ fn configured_file_log_sources(config: &crate::config::Config) -> Vec<FileLogSou
|
||||
sources.push(source_from_path(
|
||||
"access".into(),
|
||||
"Access log".into(),
|
||||
"access".into(),
|
||||
"access",
|
||||
access_path.clone(),
|
||||
config.logging.file.enabled,
|
||||
@@ -139,6 +152,7 @@ fn configured_file_log_sources(config: &crate::config::Config) -> Vec<FileLogSou
|
||||
sources.push(source_from_path(
|
||||
"error".into(),
|
||||
"Error log".into(),
|
||||
"error".into(),
|
||||
"error",
|
||||
error_path.clone(),
|
||||
config.error_log.enabled,
|
||||
@@ -155,6 +169,7 @@ fn file_log_sources_json(sources: &[FileLogSource]) -> Value {
|
||||
json!({
|
||||
"id": source.id,
|
||||
"label": source.label,
|
||||
"group_id": source.group_id,
|
||||
"kind": source.kind,
|
||||
"path": source.path.to_string_lossy(),
|
||||
"enabled": source.enabled,
|
||||
@@ -281,191 +296,4 @@ fn normalized_record(
|
||||
value
|
||||
}
|
||||
|
||||
fn contains_ci(value: Option<&Value>, needle: &str) -> bool {
|
||||
value
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|text| text.to_ascii_lowercase().contains(needle))
|
||||
}
|
||||
|
||||
fn record_matches(value: &Value, query: &FileLogQuery, search: &[FileLogSearchClause]) -> bool {
|
||||
let Some(object) = value.as_object() else {
|
||||
return false;
|
||||
};
|
||||
if !record_matches_search(value, search) {
|
||||
return false;
|
||||
}
|
||||
if let Some(event) = query
|
||||
.event
|
||||
.as_deref()
|
||||
.filter(|v| !v.is_empty() && *v != "all")
|
||||
{
|
||||
if object.get("event").and_then(Value::as_str) != Some(event) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(class) = query
|
||||
.status_class
|
||||
.as_deref()
|
||||
.filter(|v| !v.is_empty() && *v != "all")
|
||||
{
|
||||
let Some(status) = object.get("status").and_then(Value::as_u64) else {
|
||||
return false;
|
||||
};
|
||||
let expected = class
|
||||
.as_bytes()
|
||||
.first()
|
||||
.and_then(|v| v.checked_sub(b'0'))
|
||||
.map(u64::from);
|
||||
if expected != Some(status / 100) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(client) = query.client_ip.as_deref().filter(|v| !v.is_empty()) {
|
||||
if !contains_ci(object.get("client_ip"), &client.to_ascii_lowercase()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(vhost) = query.vhost.as_deref().filter(|v| !v.is_empty()) {
|
||||
if !contains_ci(object.get("vhost"), &vhost.to_ascii_lowercase()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn validate_file_log_query(query: &FileLogQuery) -> Result<(), ApiError> {
|
||||
let text_ok = |value: &Option<String>, max: usize| {
|
||||
value
|
||||
.as_ref()
|
||||
.is_none_or(|v| v.len() <= max && !v.chars().any(char::is_control))
|
||||
};
|
||||
if !text_ok(&query.source, 192)
|
||||
|| !text_ok(&query.q, 512)
|
||||
|| !text_ok(&query.client_ip, 128)
|
||||
|| !text_ok(&query.vhost, 256)
|
||||
|| query.offset.unwrap_or(0) > FILE_LOG_MAX_OFFSET
|
||||
|| query
|
||||
.limit
|
||||
.is_some_and(|v| v == 0 || v > FILE_LOG_MAX_LIMIT)
|
||||
|| query.event.as_ref().is_some_and(|v| {
|
||||
!matches!(
|
||||
v.as_str(),
|
||||
"" | "all" | "request" | "protocol_error" | "error"
|
||||
)
|
||||
})
|
||||
|| query.status_class.as_ref().is_some_and(|v| {
|
||||
!matches!(
|
||||
v.as_str(),
|
||||
"" | "all" | "1xx" | "2xx" | "3xx" | "4xx" | "5xx"
|
||||
)
|
||||
})
|
||||
{
|
||||
return Err(ApiError(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid file log filter".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn file_logs_query(
|
||||
State(state): State<Arc<App>>,
|
||||
Query(query): Query<FileLogQuery>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
validate_file_log_query(&query)?;
|
||||
let 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);
|
||||
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() {
|
||||
return Err(ApiError(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"unknown file log source".into(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
sources.sort_by(|a, b| b.modified_ms.cmp(&a.modified_ms));
|
||||
if sources.len() > FILE_LOG_MAX_SOURCES {
|
||||
truncated = true;
|
||||
sources.truncate(FILE_LOG_MAX_SOURCES);
|
||||
}
|
||||
}
|
||||
let offset = query.offset.unwrap_or(0);
|
||||
let limit = query.limit.unwrap_or(100).clamp(1, FILE_LOG_MAX_LIMIT);
|
||||
let mut remaining_bytes = FILE_LOG_SCAN_BYTES_TOTAL;
|
||||
let mut scanned_bytes = 0u64;
|
||||
let mut remaining_lines = FILE_LOG_SCAN_LINES;
|
||||
let mut scanned_lines = 0usize;
|
||||
let mut 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;
|
||||
break;
|
||||
}
|
||||
let sources_left = sources.len().saturating_sub(index).max(1);
|
||||
let line_budget = (remaining_lines / sources_left).max(1);
|
||||
match read_tail(source, remaining_bytes, line_budget) {
|
||||
Ok((lines, bytes, source_truncated)) => {
|
||||
remaining_bytes = remaining_bytes.saturating_sub(bytes);
|
||||
remaining_lines = remaining_lines.saturating_sub(lines.len());
|
||||
scanned_bytes += bytes;
|
||||
scanned_lines += lines.len();
|
||||
truncated |= source_truncated;
|
||||
for line in lines {
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let value = normalized_record(source, &line, &log_format);
|
||||
if record_matches(&value, &query, &search) {
|
||||
collect_file_log_fields(&value, &mut fields);
|
||||
records.push(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(_) => truncated = true,
|
||||
}
|
||||
}
|
||||
records.sort_by(|a, b| {
|
||||
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
|
||||
.into_iter()
|
||||
.skip(offset)
|
||||
.take(limit)
|
||||
.collect::<Vec<_>>();
|
||||
Ok(Json(json!({
|
||||
"entries": page,
|
||||
"total": total,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"truncated": truncated,
|
||||
"scanned_bytes": scanned_bytes,
|
||||
"scanned_lines": scanned_lines,
|
||||
"fields": fields,
|
||||
"sources": file_log_sources_json(&sources),
|
||||
})))
|
||||
})
|
||||
.await
|
||||
.map_err(|_| {
|
||||
ApiError(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"file log query worker failed".into(),
|
||||
)
|
||||
})?
|
||||
}
|
||||
include!("file_logs_query.rs");
|
||||
@@ -49,6 +49,7 @@ fn rotated_gzip_sources(
|
||||
entries.into_iter().map(|(name,path,_)| source_from_path(
|
||||
format!("{id_prefix}:gz:{name}"),
|
||||
format!("{label_prefix} · {name}"),
|
||||
id_prefix.to_owned(),
|
||||
kind, path, enabled, format
|
||||
)).collect()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
fn contains_ci(value: Option<&Value>, needle: &str) -> bool {
|
||||
value
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|text| text.to_ascii_lowercase().contains(needle))
|
||||
}
|
||||
|
||||
fn record_matches(value: &Value, query: &FileLogQuery, search: &[FileLogSearchClause]) -> bool {
|
||||
let Some(object) = value.as_object() else {
|
||||
return false;
|
||||
};
|
||||
if !record_matches_search(value, search) {
|
||||
return false;
|
||||
}
|
||||
if let Some(event) = query
|
||||
.event
|
||||
.as_deref()
|
||||
.filter(|v| !v.is_empty() && *v != "all")
|
||||
{
|
||||
if object.get("event").and_then(Value::as_str) != Some(event) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(class) = query
|
||||
.status_class
|
||||
.as_deref()
|
||||
.filter(|v| !v.is_empty() && *v != "all")
|
||||
{
|
||||
let Some(status) = object.get("status").and_then(Value::as_u64) else {
|
||||
return false;
|
||||
};
|
||||
let expected = class
|
||||
.as_bytes()
|
||||
.first()
|
||||
.and_then(|v| v.checked_sub(b'0'))
|
||||
.map(u64::from);
|
||||
if expected != Some(status / 100) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(client) = query.client_ip.as_deref().filter(|v| !v.is_empty()) {
|
||||
if !contains_ci(object.get("client_ip"), &client.to_ascii_lowercase()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(vhost) = query.vhost.as_deref().filter(|v| !v.is_empty()) {
|
||||
if !contains_ci(object.get("vhost"), &vhost.to_ascii_lowercase()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn validate_file_log_query(query: &FileLogQuery) -> Result<(), ApiError> {
|
||||
let text_ok = |value: &Option<String>, max: usize| {
|
||||
value
|
||||
.as_ref()
|
||||
.is_none_or(|v| v.len() <= max && !v.chars().any(char::is_control))
|
||||
};
|
||||
if !text_ok(&query.source, 192)
|
||||
|| !text_ok(&query.q, 512)
|
||||
|| !text_ok(&query.client_ip, 128)
|
||||
|| !text_ok(&query.vhost, 256)
|
||||
|| query.aggregate_field.as_ref().is_some_and(|field| !valid_raw_field_key(field))
|
||||
|| query.aggregate_limit.is_some_and(|value| value == 0 || value > FILE_LOG_MAX_AGGREGATE_RESULTS)
|
||||
|| query.aggregate_min_count.is_some_and(|value| value < 2 || value > FILE_LOG_SCAN_LINES)
|
||||
|| query.offset.unwrap_or(0) > FILE_LOG_MAX_OFFSET
|
||||
|| query
|
||||
.limit
|
||||
.is_some_and(|v| v == 0 || v > FILE_LOG_MAX_LIMIT)
|
||||
|| query.event.as_ref().is_some_and(|v| {
|
||||
!matches!(
|
||||
v.as_str(),
|
||||
"" | "all" | "request" | "protocol_error" | "error"
|
||||
)
|
||||
})
|
||||
|| query.status_class.as_ref().is_some_and(|v| {
|
||||
!matches!(
|
||||
v.as_str(),
|
||||
"" | "all" | "1xx" | "2xx" | "3xx" | "4xx" | "5xx"
|
||||
)
|
||||
})
|
||||
{
|
||||
return Err(ApiError(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid file log filter".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn file_logs_query(
|
||||
State(state): State<Arc<App>>,
|
||||
Query(query): Query<FileLogQuery>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
validate_file_log_query(&query)?;
|
||||
let 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);
|
||||
let log_format = config.logging.format.clone();
|
||||
let requested = query.source.as_deref().unwrap_or("all");
|
||||
let mut truncated = false;
|
||||
if requested != "all" {
|
||||
let consolidate_vhosts = query.consolidate_vhosts.unwrap_or(false) && requested.starts_with("vhost:");
|
||||
sources.retain(|source| source.id == requested || (consolidate_vhosts && source.group_id == requested));
|
||||
if sources.is_empty() {
|
||||
return Err(ApiError(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"unknown file log source".into(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
sources.sort_by(|a, b| b.modified_ms.cmp(&a.modified_ms));
|
||||
if sources.len() > FILE_LOG_MAX_SOURCES {
|
||||
truncated = true;
|
||||
sources.truncate(FILE_LOG_MAX_SOURCES);
|
||||
}
|
||||
}
|
||||
let offset = query.offset.unwrap_or(0);
|
||||
let limit = query.limit.unwrap_or(100).clamp(1, FILE_LOG_MAX_LIMIT);
|
||||
let mut remaining_bytes = FILE_LOG_SCAN_BYTES_TOTAL;
|
||||
let mut scanned_bytes = 0u64;
|
||||
let mut remaining_lines = FILE_LOG_SCAN_LINES;
|
||||
let mut scanned_lines = 0usize;
|
||||
let mut records = Vec::new();
|
||||
let mut fields = std::collections::BTreeSet::new();
|
||||
let aggregate_field = query.aggregate_field.as_deref().map(str::to_ascii_lowercase);
|
||||
let aggregate_min_count = query.aggregate_min_count.unwrap_or(2);
|
||||
let aggregate_limit = query.aggregate_limit.unwrap_or(50).clamp(1, FILE_LOG_MAX_AGGREGATE_RESULTS);
|
||||
let mut aggregate_counts = std::collections::HashMap::<String, usize>::new();
|
||||
let mut aggregate_truncated = false;
|
||||
for (index, source) in sources.iter().enumerate() {
|
||||
if remaining_bytes == 0 || remaining_lines == 0 {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
let sources_left = sources.len().saturating_sub(index).max(1);
|
||||
let line_budget = (remaining_lines / sources_left).max(1);
|
||||
match read_tail(source, remaining_bytes, line_budget) {
|
||||
Ok((lines, bytes, source_truncated)) => {
|
||||
remaining_bytes = remaining_bytes.saturating_sub(bytes);
|
||||
remaining_lines = remaining_lines.saturating_sub(lines.len());
|
||||
scanned_bytes += bytes;
|
||||
scanned_lines += lines.len();
|
||||
truncated |= source_truncated;
|
||||
for line in lines {
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let value = normalized_record(source, &line, &log_format);
|
||||
if record_matches(&value, &query, &search) {
|
||||
collect_file_log_fields(&value, &mut fields);
|
||||
if let Some(field) = aggregate_field.as_deref() {
|
||||
if let Some(field_value) = file_log_field(&value, field) {
|
||||
let mut values = Vec::new();
|
||||
file_log_aggregate_values(field_value, &mut values);
|
||||
for aggregate_value in values {
|
||||
if aggregate_counts.contains_key(&aggregate_value) || aggregate_counts.len() < FILE_LOG_MAX_AGGREGATE_VALUES {
|
||||
*aggregate_counts.entry(aggregate_value).or_insert(0) += 1;
|
||||
} else {
|
||||
aggregate_truncated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
records.push(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(_) => truncated = true,
|
||||
}
|
||||
}
|
||||
records.sort_by(|a, b| {
|
||||
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 mut aggregation = aggregate_counts.into_iter()
|
||||
.filter(|(_, count)| *count >= aggregate_min_count)
|
||||
.map(|(value, count)| json!({"value": value, "count": count}))
|
||||
.collect::<Vec<_>>();
|
||||
aggregation.sort_by(|left, right| {
|
||||
let left_count = left.get("count").and_then(Value::as_u64).unwrap_or(0);
|
||||
let right_count = right.get("count").and_then(Value::as_u64).unwrap_or(0);
|
||||
right_count.cmp(&left_count).then_with(|| {
|
||||
left.get("value").and_then(Value::as_str).unwrap_or("")
|
||||
.cmp(right.get("value").and_then(Value::as_str).unwrap_or(""))
|
||||
})
|
||||
});
|
||||
if aggregation.len() > aggregate_limit {
|
||||
aggregation.truncate(aggregate_limit);
|
||||
aggregate_truncated = true;
|
||||
}
|
||||
let total = records.len();
|
||||
let page = records
|
||||
.into_iter()
|
||||
.skip(offset)
|
||||
.take(limit)
|
||||
.collect::<Vec<_>>();
|
||||
Ok(Json(json!({
|
||||
"entries": page,
|
||||
"total": total,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"truncated": truncated,
|
||||
"scanned_bytes": scanned_bytes,
|
||||
"scanned_lines": scanned_lines,
|
||||
"fields": fields,
|
||||
"sources": file_log_sources_json(&sources),
|
||||
"aggregation": aggregate_field.map(|field| json!({
|
||||
"field": field,
|
||||
"min_count": aggregate_min_count,
|
||||
"entries": aggregation,
|
||||
"truncated": aggregate_truncated,
|
||||
})),
|
||||
})))
|
||||
})
|
||||
.await
|
||||
.map_err(|_| {
|
||||
ApiError(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"file log query worker failed".into(),
|
||||
)
|
||||
})?
|
||||
}
|
||||
@@ -113,6 +113,10 @@ async fn update_section(
|
||||
config.host_policies =
|
||||
serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?
|
||||
}
|
||||
"user-agent-blocking" => {
|
||||
config.user_agent_blocking =
|
||||
serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?
|
||||
}
|
||||
_ => {
|
||||
return Err(ApiError(
|
||||
StatusCode::NOT_FOUND,
|
||||
|
||||
+127
-3
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "RustProxy Administration API",
|
||||
"version": "0.31.4",
|
||||
"version": "0.31.5",
|
||||
"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": [
|
||||
@@ -659,6 +659,9 @@
|
||||
],
|
||||
"nullable": true,
|
||||
"description": "Error fallback for the main vhost source."
|
||||
},
|
||||
"user_agent_blocking": {
|
||||
"$ref": "#/components/schemas/UserAgentBlocking"
|
||||
}
|
||||
},
|
||||
"additionalProperties": true,
|
||||
@@ -917,6 +920,9 @@
|
||||
"$ref": "#/components/schemas/PhpFpmProfile"
|
||||
},
|
||||
"description": "Named PHP-FPM configurations reusable by Web Services."
|
||||
},
|
||||
"user_agent_blocking": {
|
||||
"$ref": "#/components/schemas/UserAgentBlocking"
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
@@ -5607,6 +5613,14 @@
|
||||
"modified_ms": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"group_id": {
|
||||
"type": "string",
|
||||
"description": "Stable logical source group. Per-vhost active JSONL and rotated .gz members share the same vhost:<id> group."
|
||||
},
|
||||
"compressed": {
|
||||
"type": "boolean",
|
||||
"description": "True for a rotated gzip source member."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -5675,6 +5689,52 @@
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/FileLogSource"
|
||||
}
|
||||
},
|
||||
"aggregation": {
|
||||
"type": [
|
||||
"object",
|
||||
"null"
|
||||
],
|
||||
"description": "Optional repeated-value aggregation computed across the bounded matching scan before pagination.",
|
||||
"properties": {
|
||||
"field": {
|
||||
"type": "string"
|
||||
},
|
||||
"min_count": {
|
||||
"type": "integer",
|
||||
"minimum": 2
|
||||
},
|
||||
"entries": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"value": {
|
||||
"type": "string"
|
||||
},
|
||||
"count": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"value",
|
||||
"count"
|
||||
]
|
||||
}
|
||||
},
|
||||
"truncated": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"field",
|
||||
"min_count",
|
||||
"entries",
|
||||
"truncated"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -5887,6 +5947,25 @@
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"UserAgentBlocking": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"patterns": {
|
||||
"type": "array",
|
||||
"maxItems": 128,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"maxLength": 256
|
||||
},
|
||||
"description": "Case-insensitive User-Agent block patterns. Plain text matches a substring; * is a wildcard."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -8686,7 +8765,8 @@
|
||||
"not_found_hosts",
|
||||
"streams",
|
||||
"acl-profiles",
|
||||
"host-policies"
|
||||
"host-policies",
|
||||
"user-agent-blocking"
|
||||
]
|
||||
},
|
||||
"description": "Configuration section to replace."
|
||||
@@ -10720,6 +10800,16 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "consolidate_vhosts",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "When true and source is vhost:<id>, scan the active JSONL plus all discovered rotated .gz members that share that vhost group.",
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "q",
|
||||
"in": "query",
|
||||
@@ -10782,6 +10872,40 @@
|
||||
"maxLength": 256
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "aggregate_field",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Optional parsed field to count across matching records before pagination. Supports nested fields accepted by Stored Logs search.",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"maxLength": 192
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "aggregate_min_count",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Minimum occurrences required for an aggregate value.",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": 2,
|
||||
"maximum": 20000,
|
||||
"default": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "aggregate_limit",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Maximum aggregate values returned.",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 200,
|
||||
"default": 50
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "offset",
|
||||
"in": "query",
|
||||
@@ -10899,4 +11023,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,13 @@ static ASSETS: LazyLock<HashMap<&'static str, Asset>> = LazyLock::new(|| {
|
||||
});
|
||||
|
||||
pub(super) async fn serve(State(state): State<Arc<App>>, request: Request) -> Response {
|
||||
let Some(asset) = ASSETS.get(request.uri().path()) else {
|
||||
let resolved = super::fingerprinted_path(request.uri().path(), "/assets/");
|
||||
let path = match resolved.as_deref() {
|
||||
Some(path) => path,
|
||||
None if matches!(request.uri().path(), "/assets/login-langs.json" | "/assets/langs.json" | "/favicon.svg") => request.uri().path(),
|
||||
None => return StatusCode::NOT_FOUND.into_response(),
|
||||
};
|
||||
let Some(asset) = ASSETS.get(path) else {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
};
|
||||
let snapshot = state.config.load();
|
||||
|
||||
+4
-1
@@ -11,6 +11,7 @@ static ADMIN_INDEX: OnceLock<PanelContent> = OnceLock::new();
|
||||
fn panel_response(headers: &HeaderMap, compression: &PanelCompression) -> Response {
|
||||
let content = ADMIN_INDEX.get_or_init(|| {
|
||||
let html = include_str!("web/index.html")
|
||||
.replace("__RUSTPROXY_ASSET_ID__", super::asset_build_id())
|
||||
.replace("__RUSTPROXY_VERSION__", env!("CARGO_PKG_VERSION"));
|
||||
// Exactly one static panel document shared between all administrator accounts.
|
||||
PanelContent::new(Box::leak(html.into_boxed_str()))
|
||||
@@ -24,7 +25,9 @@ pub(super) async fn index(
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
if session.is_none() {
|
||||
return Html(include_str!("web/login.html")).into_response();
|
||||
let html = include_str!("web/login.html")
|
||||
.replace("__RUSTPROXY_ASSET_ID__", super::asset_build_id());
|
||||
return Html(html).into_response();
|
||||
}
|
||||
let snapshot = state.config.load();
|
||||
panel_response(&headers, &snapshot.envelope.config.administration.panel_compression)
|
||||
|
||||
@@ -16,7 +16,9 @@ async fn panel_chooses_zstd_brotli_gzip_by_quality_and_support() {
|
||||
assert!(!plain.headers().contains_key(header::CONTENT_ENCODING));
|
||||
let source = plain.into_body().collect().await.unwrap().to_bytes();
|
||||
assert!(std::str::from_utf8(&source).unwrap().contains("id=\"app-startup\""));
|
||||
assert!(std::str::from_utf8(&source).unwrap().contains(env!("CARGO_PKG_VERSION")));
|
||||
assert!(std::str::from_utf8(&source).unwrap().contains(super::super::asset_build_id()));
|
||||
assert!(std::str::from_utf8(&source).unwrap().contains(&format!("RustProxy <b>{}</b>", env!("CARGO_PKG_VERSION"))));
|
||||
assert!(!std::str::from_utf8(&source).unwrap().contains("__RUSTPROXY_VERSION__"));
|
||||
for (accept, selected) in [
|
||||
("gzip, br, zstd", "zstd"),
|
||||
("gzip, br", "br"),
|
||||
|
||||
+20
-32
@@ -2,6 +2,21 @@ mod documentation;
|
||||
mod public;
|
||||
mod panel_content;
|
||||
mod panel_assets;
|
||||
|
||||
// Embedded asset URLs change with every build, independent of Cargo package version.
|
||||
// Only an exact identifier from this binary is accepted; arbitrary suffixes are 404.
|
||||
fn asset_build_id() -> &'static str {
|
||||
include_str!(concat!(env!("OUT_DIR"), "/asset-build-id.txt"))
|
||||
}
|
||||
fn fingerprinted_path(path: &str, prefix: &str) -> Option<String> {
|
||||
let name = path.strip_prefix(prefix)?;
|
||||
let (stem, extension) = name.rsplit_once('.')?;
|
||||
if extension != "js" && extension != "css" { return None; }
|
||||
let (base, token) = stem.rsplit_once('_')?;
|
||||
if token != asset_build_id() { return None; }
|
||||
Some(format!("{prefix}{base}.{extension}"))
|
||||
}
|
||||
|
||||
use crate::{
|
||||
auth::{self, Session},
|
||||
config::Update,
|
||||
@@ -107,45 +122,18 @@ pub fn router(state: Arc<App>) -> Router {
|
||||
.route("/logging", get(public::index))
|
||||
.route("/logs", get(public::index))
|
||||
.route("/settings", get(public::index))
|
||||
.route("/assets/login.js", get(panel_assets::serve))
|
||||
.route("/assets/{fingerprinted}", get(panel_assets::serve))
|
||||
.route("/assets/login-langs.json", get(panel_assets::serve))
|
||||
.route("/docs", get(documentation::index))
|
||||
.route("/docs/", get(documentation::index))
|
||||
.route("/docs/openapi.json", get(documentation::schema))
|
||||
.route("/docs/content.json", get(documentation::content))
|
||||
.route("/docs/init.js", get(documentation::script))
|
||||
.route("/docs/style.css", get(documentation::style))
|
||||
.route(
|
||||
"/docs/swagger-ui-bundle.js",
|
||||
get(|| async { documentation::asset("swagger-ui-bundle.js") }),
|
||||
)
|
||||
.route(
|
||||
"/docs/swagger-ui.css",
|
||||
get(|| async { documentation::asset("swagger-ui.css") }),
|
||||
)
|
||||
.route("/docs/{fingerprinted}", get(documentation::fingerprinted))
|
||||
.route(
|
||||
"/docs/licenses.txt",
|
||||
get(|| async { documentation::asset("licenses.txt") }),
|
||||
)
|
||||
.route("/assets/langs.json", get(panel_assets::serve))
|
||||
.route("/assets/app.js", get(panel_assets::serve))
|
||||
.route("/assets/management.js", get(panel_assets::serve))
|
||||
.route("/assets/drafts.js", get(panel_assets::serve))
|
||||
.route("/assets/integrations.js", get(panel_assets::serve))
|
||||
.route("/assets/traffic-panel.js", get(panel_assets::serve))
|
||||
.route("/assets/cache-panel.js", get(panel_assets::serve))
|
||||
.route("/assets/transform-panel.js", get(panel_assets::serve))
|
||||
.route("/assets/file-logs.js", get(panel_assets::serve))
|
||||
.route("/assets/settings.js", get(panel_assets::serve))
|
||||
.route("/assets/acme.js", get(panel_assets::serve))
|
||||
.route("/assets/vhosts.js", get(panel_assets::serve))
|
||||
.route("/assets/web-server-profiles.js", get(panel_assets::serve))
|
||||
.route("/assets/web-server.js", get(panel_assets::serve))
|
||||
.route("/assets/docker.js", get(panel_assets::serve))
|
||||
.route("/assets/streams.js", get(panel_assets::serve))
|
||||
.route("/assets/redirects.js", get(panel_assets::serve))
|
||||
.route("/assets/host404.js", get(panel_assets::serve))
|
||||
.route("/assets/style.css", get(panel_assets::serve))
|
||||
.route("/favicon.svg", get(panel_assets::serve))
|
||||
.route("/healthz", get(|| async { Json(json!({"status":"ok"})) }))
|
||||
.route("/api/status", get(status))
|
||||
@@ -309,11 +297,11 @@ fn panel_document_path(path: &str) -> bool {
|
||||
fn anonymous_allowed(method: &Method, path: &str) -> bool {
|
||||
if method == Method::GET || method == Method::HEAD {
|
||||
panel_document_path(path)
|
||||
|| fingerprinted_path(path, "/assets/")
|
||||
.is_some_and(|base| matches!(base.as_str(), "/assets/login.js" | "/assets/style.css"))
|
||||
|| matches!(
|
||||
path,
|
||||
"/assets/login.js"
|
||||
| "/assets/login-langs.json"
|
||||
| "/assets/style.css"
|
||||
"/assets/login-langs.json"
|
||||
| "/favicon.svg"
|
||||
| "/healthz"
|
||||
| "/api/auth/status"
|
||||
|
||||
@@ -397,7 +397,7 @@
|
||||
.unwrap();
|
||||
assert!(std::str::from_utf8(&public_body)
|
||||
.unwrap()
|
||||
.contains("/assets/login.js"));
|
||||
.contains(&format!("/assets/login_{}.js", super::asset_build_id())));
|
||||
|
||||
let private = router
|
||||
.clone()
|
||||
@@ -415,7 +415,7 @@
|
||||
.unwrap();
|
||||
assert!(std::str::from_utf8(&private_body)
|
||||
.unwrap()
|
||||
.contains("/assets/app.js"));
|
||||
.contains(&format!("/assets/app_{}.js", super::asset_build_id())));
|
||||
}
|
||||
store.close().await;
|
||||
}
|
||||
@@ -15,13 +15,9 @@
|
||||
"/",
|
||||
"/docs",
|
||||
"/docs/",
|
||||
"/docs/init.js",
|
||||
"/docs/style.css",
|
||||
"/docs/swagger-ui-bundle.js",
|
||||
"/docs/swagger-ui.css",
|
||||
"/docs/licenses.txt",
|
||||
"/docs/content.json",
|
||||
"/assets/app.js",
|
||||
&format!("/assets/app_{}.js", super::asset_build_id()),
|
||||
] {
|
||||
let response = router
|
||||
.clone()
|
||||
|
||||
@@ -34,6 +34,7 @@ fn stored_log_tail_enforces_line_budget() {
|
||||
let source = source_from_path(
|
||||
"access".into(),
|
||||
"Access log".into(),
|
||||
"access".into(),
|
||||
"access",
|
||||
path,
|
||||
false,
|
||||
@@ -61,6 +62,7 @@ fn stored_log_normalizes_mapped_client_ip_and_searches_diagnostics() {
|
||||
let source = source_from_path(
|
||||
"vhost:app".into(),
|
||||
"app.jsonl".into(),
|
||||
"vhost:app".into(),
|
||||
"access",
|
||||
dir.path().join("app.jsonl"),
|
||||
true,
|
||||
@@ -168,6 +170,7 @@ fn stored_log_tail_reads_rotated_gzip_and_inventory_discovers_it() {
|
||||
let source = source_from_path(
|
||||
"access:gz:access.jsonl.1.gz".into(),
|
||||
"Access log · access.jsonl.1.gz".into(),
|
||||
"access".into(),
|
||||
"access",
|
||||
rotated.clone(),
|
||||
true,
|
||||
@@ -186,3 +189,48 @@ fn stored_log_tail_reads_rotated_gzip_and_inventory_discovers_it() {
|
||||
let sources = configured_file_log_sources(&config);
|
||||
assert!(sources.iter().any(|item| item.path == rotated && item.compressed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_log_per_vhost_inventory_groups_active_and_rotated_files() {
|
||||
use std::io::Write;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let active = dir.path().join("app.jsonl");
|
||||
std::fs::write(&active, b"{\"event\":\"request\"}\n").unwrap();
|
||||
let rotated = dir.path().join("app.jsonl.1.gz");
|
||||
let file = std::fs::File::create(&rotated).unwrap();
|
||||
let mut encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default());
|
||||
writeln!(encoder, r#"{{"event":"request"}}"#).unwrap();
|
||||
encoder.finish().unwrap();
|
||||
|
||||
let mut config = Config::default();
|
||||
config.logging.file.per_vhost = true;
|
||||
config.logging.file.directory = dir.path().to_string_lossy().into_owned();
|
||||
let sources = configured_file_log_sources(&config);
|
||||
let app = sources.iter().filter(|source| source.group_id == "vhost:app").collect::<Vec<_>>();
|
||||
assert_eq!(app.len(), 2);
|
||||
assert!(app.iter().any(|source| source.id == "vhost:app"));
|
||||
assert!(app.iter().any(|source| source.compressed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_log_aggregate_values_support_scalars_and_bounded_arrays() {
|
||||
let value = json!(["BadBot/1", "BadBot/1", 503, true, {"ignored":"object"}]);
|
||||
let mut values = Vec::new();
|
||||
file_log_aggregate_values(&value, &mut values);
|
||||
assert_eq!(values, vec!["BadBot/1".to_string(), "BadBot/1".to_string(), "503".to_string(), "true".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_log_query_validates_aggregation_controls() {
|
||||
assert!(validate_file_log_query(&FileLogQuery {
|
||||
aggregate_field: Some("user_agent".into()),
|
||||
aggregate_min_count: Some(2),
|
||||
aggregate_limit: Some(50),
|
||||
..FileLogQuery::default()
|
||||
}).is_ok());
|
||||
assert!(validate_file_log_query(&FileLogQuery {
|
||||
aggregate_field: Some("bad field".into()),
|
||||
..FileLogQuery::default()
|
||||
}).is_err());
|
||||
}
|
||||
@@ -1,4 +1,35 @@
|
||||
#[tokio::test]
|
||||
async fn only_current_build_fingerprinted_javascript_and_css_are_served() {
|
||||
let (router, store, _dir, secret, _) = fixture().await;
|
||||
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||
let id = super::asset_build_id();
|
||||
for (name, suffix) in [
|
||||
("/assets/app", "js"),
|
||||
("/assets/host404", "js"),
|
||||
("/assets/style", "css"),
|
||||
("/assets/login", "js"),
|
||||
("/docs/init", "js"),
|
||||
("/docs/style", "css"),
|
||||
("/docs/swagger-ui-bundle", "js"),
|
||||
("/docs/swagger-ui", "css"),
|
||||
] {
|
||||
let legacy = format!("{name}.{suffix}");
|
||||
let wrong = format!("{name}_0000000000000000.{suffix}");
|
||||
let current = format!("{name}_{id}.{suffix}");
|
||||
for path in [legacy, wrong] {
|
||||
let response = router.clone().oneshot(request(&path)
|
||||
.header("cookie", &cookie).body(Body::empty()).unwrap())
|
||||
.await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND, "{path}");
|
||||
}
|
||||
let response = router.clone().oneshot(request(¤t)
|
||||
.header("cookie", &cookie).body(Body::empty()).unwrap())
|
||||
.await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK, "{current}");
|
||||
}
|
||||
store.close().await;
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn public_document_and_discovery_contain_only_login_information() {
|
||||
let (router, store, _dir, _, _) = fixture().await;
|
||||
for (path, expected) in [
|
||||
@@ -22,7 +53,7 @@
|
||||
.await
|
||||
.unwrap();
|
||||
let text = std::str::from_utf8(&bytes).unwrap();
|
||||
assert!(text.contains("/assets/login.js"));
|
||||
assert!(text.contains(&format!("/assets/login_{}.js", super::asset_build_id())));
|
||||
for absent in [
|
||||
"/assets/app.js",
|
||||
"id=\"workspace\"",
|
||||
|
||||
@@ -116,7 +116,7 @@ function appConfirm(message, options = {}) {
|
||||
}
|
||||
function apiArea(path) {
|
||||
const section = path.match(/^\/api\/config\/([^/?]+)/)?.[1];
|
||||
if (section) return ({ "listeners": "settings", "http2": "settings", "docker-agents": "dockerVhosts", "response": "settings", "pages": "settings", "cache": "cache", "transform": "settings", "logging": "settings", "geoip": "settings", "forwarding": "settings", "headers": "settings", "prometheus": "settings", "administration": "administration", "tls": "tls", "vhosts": "vhosts", "redirects": "redirects", "not_found_hosts": "notfound", "streams": "streams", "acl-profiles": "aclProfiles", "host-policies": "security" })[section] || "configuration";
|
||||
if (section) return ({ "listeners": "settings", "http2": "settings", "docker-agents": "dockerVhosts", "response": "settings", "pages": "settings", "cache": "cache", "transform": "settings", "logging": "settings", "geoip": "settings", "forwarding": "settings", "headers": "settings", "prometheus": "settings", "administration": "administration", "tls": "tls", "vhosts": "vhosts", "redirects": "redirects", "not_found_hosts": "notfound", "streams": "streams", "acl-profiles": "aclProfiles", "host-policies": "security", "user-agent-blocking": "security" })[section] || "configuration";
|
||||
if (path === "/api/config") return "configuration";
|
||||
if (path.startsWith("/api/traffic")) return "traffic";
|
||||
if (path.startsWith("/api/cache")) return "cache";
|
||||
@@ -345,6 +345,9 @@ function renderConfig() {
|
||||
$("host-policy-list").replaceChildren(...(config.host_policies || []).map((policy, index) => { const box = el("div", undefined, "entity"); box.append(el("span", policy.hosts.join(", "), "entity-title"), el("p", `${t("aclDefault")}: ${t(policy.acl.default || "deny")}\n${rulesText(policy.acl)}`, "entity-details")); const buttons = el("div", undefined, "entity-actions"); buttons.append(action(t("edit"), () => { $("policy-hosts").value = policy.hosts.join(", "); $("policy-default").value = policy.acl.default || "deny"; $("policy-rules").value = rulesText(policy.acl); const editor = $("security-policy-editor"); if (editor) { editor.open = true; editor.scrollIntoView({ block: "nearest" }); } }), action(t("remove"), async () => { if (await appConfirm(t("confirmDelete"), { danger: true })) { const next = structuredClone(config); next.host_policies.splice(index, 1); await saveSection("host-policies", next.host_policies); } })); box.append(buttons); return box; }));
|
||||
if (!config.host_policies?.length) $("host-policy-list").append(el("p", t("noPolicies"), "empty"));
|
||||
if ($("security-policy-count")) $("security-policy-count").textContent = String((config.host_policies || []).length);
|
||||
const uaPolicy = config.user_agent_blocking || {};
|
||||
if ($("global-ua-block-enabled")) $("global-ua-block-enabled").checked = uaPolicy.enabled === true;
|
||||
if ($("global-ua-block-patterns")) $("global-ua-block-patterns").value = (uaPolicy.patterns || []).join("\n");
|
||||
}
|
||||
async function saveRequest(path, payload) { if (busy) { toast(t("busy"), true); return false; } if (!authenticated || !envelope) return false; busy = true; const epoch = authEpoch; try { const value = await api(path, { method: "PUT", body: JSON.stringify(payload) }); if (!authenticated || epoch !== authEpoch) return false; envelope = value; if (path === "/api/config" || path === "/api/config/tls") await refreshCertificateInventory(); if (!authenticated || epoch !== authEpoch || !envelope) return false; renderConfig(); toast(t("saved")); await refresh(true); return true; } catch (e) { toast(e.message, true); if (e.status === 401) lock(); return false; } finally { busy = false; } }
|
||||
async function save(config) { return saveRequest("/api/config", { expected_revision: envelope?.revision, config }); }
|
||||
@@ -464,6 +467,7 @@ document.addEventListener("click", event => { if (dashboardSidebar?.classList.co
|
||||
document.addEventListener("keydown", event => { if (event.key === "Escape" && dashboardSidebar?.classList.contains("mobile-nav-open")) { setMobileMenu(false); mobileMenuToggle?.focus(); } });
|
||||
window.addEventListener("popstate", () => { setPanel(panelFromLocation(), false); setMobileMenu(false); });
|
||||
$("host-policy-form").addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; if (!envelope || busy) return; try { await withFormSubmitState(form, event.submitter, async () => { const hosts = [...new Set($("policy-hosts").value.split(",").map(h => h.trim().toLowerCase()).filter(Boolean))]; const acl = { default: $("policy-default").value, rules: parseRules($("policy-rules").value, $("policy-rules")) }; const next = structuredClone(envelope.config); next.host_policies = (next.host_policies || []).map(p => ({ ...p, hosts: p.hosts.filter(h => !hosts.includes(h)) })).filter(p => p.hosts.length); next.host_policies.push({ hosts, acl }); if (await saveSection("host-policies", next.host_policies)) { form.reset(); if ($("security-policy-editor")) $("security-policy-editor").open = false; } }); } catch (e) { toast(e.message, true); } });
|
||||
$("global-ua-block-form")?.addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; if (!envelope || busy) return; try { await withFormSubmitState(form, event.submitter, async () => { const patterns = [...new Set($("global-ua-block-patterns").value.split(/\r?\n/).map(value => value.trim()).filter(Boolean))]; await saveSection("user-agent-blocking", { enabled: $("global-ua-block-enabled").checked, patterns }); }); } catch (e) { toast(e.message, true); } });
|
||||
$("password-form").addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; try { await withFormSubmitState(form, event.submitter, async () => { if ($("new-password").value !== $("new-password-confirm").value) throw fieldError("new-password-confirm", t("passwordMismatch")); await api("/api/auth/password", { method: "POST", body: JSON.stringify({ current_password: $("current-password").value, new_password: $("new-password").value }) }); lock(); toast(t("passwordChanged")); }); } catch (e) { toast(e.message, true); } });
|
||||
$("revoke-sessions").addEventListener("click", async () => { if (!(await appConfirm(t("confirmRevoke"), { danger: true }))) return; try { await api("/api/auth/logout-all", { method: "POST" }); lock(); toast(t("locked")); } catch (e) { toast(e.message, true); } });
|
||||
$("security-audit-open").addEventListener("click", () => { const dialog = $("audit-dialog"); if (!dialog.open) dialog.showModal(); void loadAudit(); });
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>RustProxy / Docs</title>
|
||||
<link rel="icon" href="/favicon.svg">
|
||||
<link rel="stylesheet" href="/docs/swagger-ui.css">
|
||||
<link rel="stylesheet" href="/docs/style.css">
|
||||
<script defer src="/docs/swagger-ui-bundle.js"></script>
|
||||
<script defer src="/docs/init.js"></script>
|
||||
<link rel="stylesheet" href="/docs/swagger-ui___RUSTPROXY_ASSET_ID__.css">
|
||||
<link rel="stylesheet" href="/docs/style___RUSTPROXY_ASSET_ID__.css">
|
||||
<script defer src="/docs/swagger-ui-bundle___RUSTPROXY_ASSET_ID__.js"></script>
|
||||
<script defer src="/docs/init___RUSTPROXY_ASSET_ID__.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
let inventory = null, offset = 0, lastTotal = 0, busy = false;
|
||||
let currentEntries = [], selectedIndex = null;
|
||||
let preferencesLoaded = false, preferenceSaveRunning = false, preferenceSaveQueued = false;
|
||||
let searchTimer = null;
|
||||
let searchTimer = null, aggregateField = "", activeView = "entries";
|
||||
const defaultColumnIds = ["time", "event", "status", "client_ip", "vhost", "record", "source"];
|
||||
const specialLabels = new Map([
|
||||
["time", "time"], ["event", "fileLogsEvent"], ["status", "status"],
|
||||
@@ -13,17 +13,57 @@
|
||||
let visibleColumns = new Set(defaultColumnIds);
|
||||
|
||||
|
||||
const CONSOLIDATION_STORAGE_KEY = "rp-file-logs-consolidate-vhosts";
|
||||
const consolidationToggle = $("file-log-consolidate-vhosts");
|
||||
try {
|
||||
const saved = localStorage.getItem(CONSOLIDATION_STORAGE_KEY);
|
||||
if (consolidationToggle && (saved === "1" || saved === "0")) consolidationToggle.checked = saved === "1";
|
||||
} catch { /* Browser storage can be unavailable; use the HTML default. */ }
|
||||
|
||||
const sourceSelect = () => $("file-log-source");
|
||||
function setView(view) {
|
||||
activeView = view === "counts" ? "counts" : "entries";
|
||||
for (const name of ["entries", "counts"]) {
|
||||
const selected = name === activeView;
|
||||
$("file-log-tab-" + name).classList.toggle("active", selected);
|
||||
$("file-log-tab-" + name).setAttribute("aria-selected", String(selected));
|
||||
$("file-log-tab-" + name).tabIndex = selected ? 0 : -1;
|
||||
$("file-log-view-" + name).hidden = !selected;
|
||||
}
|
||||
if (activeView === "counts" && aggregateField) void refresh(true);
|
||||
}
|
||||
|
||||
function sourceOption(source) {
|
||||
const label = `${source.label} · ${source.format} · ${bytes(Number(source.size) || 0)}`;
|
||||
const grouped = Number(source.member_count) > 1 ? ` · ${fmt(source.member_count)} ${t("fileLogsFiles")}` : "";
|
||||
const label = `${source.label}${grouped} · ${source.format} · ${bytes(Number(source.size) || 0)}`;
|
||||
const option = el("option", label); option.value = source.id; return option;
|
||||
}
|
||||
function displaySources(sources) {
|
||||
if (!$("file-log-consolidate-vhosts")?.checked) return sources;
|
||||
const output = [], groups = new Map();
|
||||
for (const source of sources) {
|
||||
if (!String(source.group_id || "").startsWith("vhost:")) { output.push(source); continue; }
|
||||
const id = source.group_id;
|
||||
let group = groups.get(id);
|
||||
if (!group) {
|
||||
group = { ...source, id, label: id.slice("vhost:".length), size: 0, member_count: 0, compressed: false };
|
||||
groups.set(id, group); output.push(group);
|
||||
}
|
||||
group.size += Number(source.size) || 0; group.member_count += 1;
|
||||
group.modified_ms = Math.max(Number(group.modified_ms) || 0, Number(source.modified_ms) || 0);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
function renderInventory(value) {
|
||||
inventory = value;
|
||||
const select = sourceSelect();
|
||||
const selected = select.value || "all";
|
||||
const sources = Array.isArray(value?.sources) ? value.sources : [];
|
||||
let selected = select.value || "all";
|
||||
const rawSources = Array.isArray(value?.sources) ? value.sources : [];
|
||||
if ($("file-log-consolidate-vhosts")?.checked && selected !== "all") {
|
||||
const member = rawSources.find(source => source.id === selected);
|
||||
if (member?.group_id?.startsWith("vhost:")) selected = member.group_id;
|
||||
}
|
||||
const sources = displaySources(rawSources);
|
||||
const options = [Object.assign(el("option", t("fileLogsAllSources")), { value: "all" }), ...sources.map(sourceOption)];
|
||||
select.replaceChildren(...options);
|
||||
select.value = sources.some(source => source.id === selected) ? selected : "all";
|
||||
@@ -35,6 +75,13 @@
|
||||
}
|
||||
|
||||
const currentLimit = () => Math.max(1, Math.min(500, Number($("file-log-limit").value) || 100));
|
||||
async function copyText(value) {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try { await navigator.clipboard.writeText(value); toast(t("copied")); return; } catch { }
|
||||
}
|
||||
const area = document.createElement("textarea"); area.value = value; area.setAttribute("readonly", "");
|
||||
area.style.position = "fixed"; area.style.opacity = "0"; document.body.append(area); area.select(); document.execCommand("copy"); area.remove(); toast(t("copied"));
|
||||
}
|
||||
|
||||
function entryTime(entry) {
|
||||
if (entry.time_ms) return formatDateTime(Number(entry.time_ms));
|
||||
@@ -89,6 +136,11 @@
|
||||
if (normalized) availableColumns.add(normalized);
|
||||
}
|
||||
for (const id of visibleColumns) availableColumns.add(id);
|
||||
const suggestions = $("file-log-field-suggestions");
|
||||
if (suggestions) {
|
||||
const names = new Set(["user_agent", "client_ip", "vhost", "host", "path", "method", "status", "country", "source", ...fields]);
|
||||
suggestions.replaceChildren(...[...names].filter(Boolean).sort().map(name => Object.assign(el("option"), { value: normalizedField(name) || name })));
|
||||
}
|
||||
syncColumnPicker(); syncHeaders();
|
||||
}
|
||||
function syncHeaders() {
|
||||
@@ -166,6 +218,19 @@
|
||||
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 renderAggregation(aggregation) {
|
||||
const rows = $("file-log-aggregate-rows"), empty = $("file-log-aggregate-empty"), state = $("file-log-aggregate-state");
|
||||
if (!rows || !empty || !state) return;
|
||||
const entries = Array.isArray(aggregation?.entries) ? aggregation.entries : [];
|
||||
rows.replaceChildren(...entries.map(item => {
|
||||
const row = el("tr"), value = String(item.value ?? ""), valueCell = el("td"), button = el("button", value, "quiet file-log-aggregate-copy");
|
||||
button.type = "button"; button.title = t("copyValue"); button.addEventListener("click", () => void copyText(value)); valueCell.append(button);
|
||||
row.append(valueCell, el("td", fmt(Number(item.count) || 0))); return row;
|
||||
}));
|
||||
empty.hidden = !aggregation || entries.length !== 0;
|
||||
state.textContent = aggregation ? `${aggregation.field} · ${entries.length}${aggregation.truncated ? ` · ${t("fileLogsAggregateTruncated")}` : ""}` : "";
|
||||
}
|
||||
|
||||
function renderRows(entries = currentEntries) {
|
||||
currentEntries = entries;
|
||||
const ids = orderedColumns().filter(id => visibleColumns.has(id));
|
||||
@@ -189,6 +254,12 @@
|
||||
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);
|
||||
if ($("file-log-consolidate-vhosts")?.checked) params.set("consolidate_vhosts", "true");
|
||||
if (activeView === "counts" && aggregateField) {
|
||||
params.set("aggregate_field", aggregateField);
|
||||
params.set("aggregate_min_count", String(Math.max(2, Number($("file-log-aggregate-min")?.value) || 2)));
|
||||
params.set("aggregate_limit", String(Math.max(1, Number($("file-log-aggregate-limit")?.value) || 50)));
|
||||
}
|
||||
params.set("offset", String(offset)); params.set("limit", String(currentLimit()));
|
||||
return params.toString();
|
||||
}
|
||||
@@ -202,6 +273,7 @@
|
||||
lastTotal = Number(value.total) || 0; selectedIndex = null;
|
||||
applyFields(Array.isArray(value.fields) ? value.fields : []);
|
||||
renderRows(Array.isArray(value.entries) ? value.entries : []);
|
||||
renderAggregation(value.aggregation || null);
|
||||
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))
|
||||
@@ -213,12 +285,14 @@
|
||||
finally { busy = false; $("file-logs-refresh").disabled = false; }
|
||||
}
|
||||
function reset() {
|
||||
inventory = null; offset = 0; lastTotal = 0; currentEntries = []; selectedIndex = null;
|
||||
inventory = null; offset = 0; lastTotal = 0; currentEntries = []; selectedIndex = null; aggregateField = "";
|
||||
preferencesLoaded = false; preferenceSaveRunning = false; preferenceSaveQueued = false;
|
||||
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-log-rows").replaceChildren(); $("file-log-aggregate-rows")?.replaceChildren(); renderAggregation(null); syncColumnPicker(); syncHeaders();
|
||||
}
|
||||
$("file-log-tab-entries").addEventListener("click", () => setView("entries"));
|
||||
$("file-log-tab-counts").addEventListener("click", () => setView("counts"));
|
||||
$("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));
|
||||
@@ -226,6 +300,12 @@
|
||||
$("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));
|
||||
consolidationToggle?.addEventListener("change", () => {
|
||||
try { localStorage.setItem(CONSOLIDATION_STORAGE_KEY, consolidationToggle.checked ? "1" : "0"); } catch { }
|
||||
if (inventory) renderInventory(inventory);
|
||||
void refresh(true);
|
||||
});
|
||||
$("file-log-aggregate-form")?.addEventListener("submit", event => { event.preventDefault(); aggregateField = $("file-log-aggregate-field").value.trim().toLowerCase(); if (!aggregateField) { renderAggregation(null); return; } void refresh(true); });
|
||||
$("file-logs-prev").addEventListener("click", () => { offset = Math.max(0, offset - currentLimit()); void refresh(); });
|
||||
$("file-logs-next").addEventListener("click", () => { if (offset + currentLimit() < lastTotal) { offset += currentLimit(); void refresh(); } });
|
||||
syncColumnPicker(); syncHeaders();
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
<meta content="width=device-width, initial-scale=1" name="viewport" />
|
||||
<title>RustProxy / Control room</title>
|
||||
<link href="/favicon.svg" rel="icon" />
|
||||
<link href="/assets/style.css?v=__RUSTPROXY_VERSION__" rel="stylesheet" />
|
||||
<link href="/assets/style___RUSTPROXY_ASSET_ID__.css" rel="stylesheet" />
|
||||
<!-- One ordered JS payload: no race between module fetches and session/config API. -->
|
||||
<script defer="" src="/assets/app.js?v=__RUSTPROXY_VERSION__"></script>
|
||||
<script defer="" src="/assets/app___RUSTPROXY_ASSET_ID__.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@@ -1675,6 +1675,7 @@
|
||||
<code>error:"connection reset"</code>
|
||||
</div>
|
||||
</details>
|
||||
<label class="file-log-consolidate" title="Consolidate vhost log rotations"><input checked id="file-log-consolidate-vhosts" type="checkbox" /><span data-i18n="fileLogsConsolidateShort">Merge rotations</span></label>
|
||||
<button class="outline" data-i18n="refresh" id="file-logs-refresh"
|
||||
type="button">Refresh</button>
|
||||
</div>
|
||||
@@ -1697,6 +1698,25 @@
|
||||
<option>500</option>
|
||||
</select></label>
|
||||
</form>
|
||||
<div class="file-log-tabs" role="tablist" aria-label="Stored log views">
|
||||
<button class="file-log-tab active" id="file-log-tab-entries" type="button" role="tab" aria-controls="file-log-view-entries" aria-selected="true" data-i18n="fileLogsEntriesTab">Log entries</button>
|
||||
<button class="file-log-tab" id="file-log-tab-counts" type="button" role="tab" aria-controls="file-log-view-counts" aria-selected="false" data-i18n="fileLogsCountsTab">Repeated values</button>
|
||||
</div>
|
||||
<section class="file-log-advanced-parser" id="file-log-view-counts" role="tabpanel" aria-labelledby="file-log-tab-counts" hidden>
|
||||
<h3 data-i18n="fileLogsAdvancedParser">Advanced parser · repeated values</h3>
|
||||
<form class="file-log-aggregate-form" id="file-log-aggregate-form">
|
||||
<label><span data-i18n="fileLogsAggregateField">Field to count</span><input id="file-log-aggregate-field" list="file-log-field-suggestions" maxlength="128" placeholder="user_agent" /></label>
|
||||
<datalist id="file-log-field-suggestions"></datalist>
|
||||
<label><span data-i18n="fileLogsAggregateMin">Minimum repetitions</span><input id="file-log-aggregate-min" type="number" min="2" max="20000" value="2" /></label>
|
||||
<label><span data-i18n="fileLogsAggregateLimit">Top values</span><select id="file-log-aggregate-limit"><option>20</option><option selected>50</option><option>100</option><option>200</option></select></label>
|
||||
<button class="outline" data-i18n="fileLogsCountRepeats" type="submit">Count repeats</button>
|
||||
</form>
|
||||
<p class="fine" data-i18n="fileLogsAggregateHelp">Counts matching values across the bounded scanned log set, not only the current page. Use fields such as user_agent, client_ip, host, path or any parsed nested field.</p>
|
||||
<div class="table-scroll file-log-aggregate-table"><table><thead><tr><th data-i18n="fileLogsAggregateValue">Value</th><th data-i18n="fileLogsAggregateCount">Count</th></tr></thead><tbody id="file-log-aggregate-rows"></tbody></table></div>
|
||||
<p class="empty" data-i18n="fileLogsAggregateEmpty" id="file-log-aggregate-empty">No repeated values for the selected field.</p>
|
||||
<p class="fine" id="file-log-aggregate-state" role="status"></p>
|
||||
</section>
|
||||
<div id="file-log-view-entries" role="tabpanel" aria-labelledby="file-log-tab-entries">
|
||||
<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">
|
||||
@@ -1717,6 +1737,7 @@
|
||||
<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>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
<section class="panel" hidden="" id="panel-settings">
|
||||
@@ -2325,7 +2346,7 @@
|
||||
<summary aria-label="curl examples for HTTP BAN and PURGE" data-i18n-aria="cacheHttpInvalidationCurlLabel">i</summary>
|
||||
<div class="metric-info-popover" role="note">
|
||||
<p class="fine" data-i18n="cacheHttpInvalidationCurlIntro">Run curl from an allowlisted IP against a public vhost:</p>
|
||||
<code>curl -i -X PURGE 'https://site.example/assets/app.js?v=1'</code>
|
||||
<code>curl -i -X PURGE 'https://site.example/assets/app_abcdef0123456789.js'</code>
|
||||
<code>curl -i -X BAN 'https://site.example/assets/'</code>
|
||||
<code>curl -i -X BAN 'https://site.example/' -H 'X-RustProxy-Confirm-Wide: 1'</code>
|
||||
<p class="fine" data-i18n="cacheHttpInvalidationCurlNote">PURGE removes an exact URL; BAN invalidates a path prefix for 1 hour. No admin cookie is needed, but your IP must be allowed. A reverse proxy must forward these methods and trusted client IP information.</p>
|
||||
@@ -2944,6 +2965,20 @@
|
||||
</form>
|
||||
</details>
|
||||
</article>
|
||||
<article class="card security-policy-card">
|
||||
<div class="card-heading">
|
||||
<div><span class="small-label" data-i18n="userAgentBlocking">USER-AGENT BLOCKING</span>
|
||||
<h2 data-i18n="globalUaBlockingTitle">Global User-Agent blocklist</h2>
|
||||
<p class="fine" data-i18n="globalUaBlockingHelp">Blocks matching User-Agent values on public proxy vhosts before an upstream is opened. Per-vhost lists are configured in Vhosts → Security.</p>
|
||||
</div>
|
||||
</div>
|
||||
<form class="edit-form security-inline-form" id="global-ua-block-form">
|
||||
<label class="check"><input id="global-ua-block-enabled" type="checkbox" /><span data-i18n="enabled">Enabled</span></label>
|
||||
<label><span data-i18n="uaBlockPatterns">Blocked patterns, one per line</span><textarea id="global-ua-block-patterns" rows="6" maxlength="32768" spellcheck="false" placeholder="BadBot curl/* *scanner*"></textarea></label>
|
||||
<p class="fine" data-i18n="uaBlockPatternHelp">Matching is case-insensitive. Plain text matches a substring; * is a wildcard. Requests without User-Agent are not blocked by this rule.</p>
|
||||
<div class="form-actions"><button class="primary" data-i18n="apply" type="submit">Validate, save and apply</button></div>
|
||||
</form>
|
||||
</article>
|
||||
<article class="card security-policy-card">
|
||||
<div class="card-heading">
|
||||
<div><span class="small-label" data-i18n="securityAccessControl">ACCESS
|
||||
@@ -3012,7 +3047,7 @@
|
||||
<option value="cache_key">Cache key</option>
|
||||
</select></div>
|
||||
<div><label data-i18n="cacheInvalidationValue" for="cache-invalidation-value">Value</label><input
|
||||
id="cache-invalidation-value" maxlength="8192" placeholder="/assets/app.js" required="" />
|
||||
id="cache-invalidation-value" maxlength="8192" placeholder="/assets/app___RUSTPROXY_ASSET_ID__.js" required="" />
|
||||
</div>
|
||||
<div><label data-i18n="cacheInvalidationHost" for="cache-invalidation-host">Host
|
||||
(optional)</label><input id="cache-invalidation-host" maxlength="253"
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>RustProxy / Sign in</title>
|
||||
<link rel="icon" href="/favicon.svg">
|
||||
<link rel="stylesheet" href="/assets/style.css">
|
||||
<script defer src="/assets/login.js"></script>
|
||||
<link rel="stylesheet" href="/assets/style___RUSTPROXY_ASSET_ID__.css">
|
||||
<script defer src="/assets/login___RUSTPROXY_ASSET_ID__.js"></script>
|
||||
</head>
|
||||
|
||||
<body class="auth-page auth-loading">
|
||||
|
||||
+74
-1
@@ -10191,4 +10191,77 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
||||
|
||||
.cache-maintenance-info .metric-info-popover code+.fine {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
/* Stored Logs: compact header option and distinct analysis workspace. */
|
||||
.file-logs-heading-actions .file-log-consolidate {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: .45rem;
|
||||
padding: .35rem .55rem;
|
||||
min-height: 0;
|
||||
color: var(--muted);
|
||||
font-size: .8rem;
|
||||
font-weight: normal;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.file-log-consolidate input { width: 14px; height: 14px; margin: 0; accent-color: var(--accent); }
|
||||
.file-log-consolidate:hover { color: var(--text); }
|
||||
.file-log-tabs { display: flex; gap: .25rem; border-bottom: 1px solid var(--line); margin: .9rem 0 1rem; }
|
||||
.file-log-tab {
|
||||
appearance: none; background: transparent; border: 0; border-bottom: 2px solid transparent;
|
||||
border-radius: 0; padding: .65rem 1rem; color: var(--muted); cursor: pointer;
|
||||
font: inherit; font-weight: 500;
|
||||
}
|
||||
.file-log-tab:hover { color: var(--text); }
|
||||
.file-log-tab.active { color: var(--text); border-bottom-color: var(--accent); }
|
||||
.file-log-tab:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||
#file-log-view-counts[hidden], #file-log-view-entries[hidden] { display: none !important; }
|
||||
.file-log-advanced-parser h3 { margin: 0 0 .7rem; font-size: 1rem; }
|
||||
@media (max-width: 700px) {
|
||||
.file-logs-heading-actions .file-log-consolidate { margin-inline-end: auto; }
|
||||
.file-log-tab { padding: .6rem .7rem; }
|
||||
}
|
||||
/* 0.31.5 traffic/log analysis actions */
|
||||
.traffic-copy-value,
|
||||
.file-log-aggregate-copy {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
cursor: copy;
|
||||
}
|
||||
.traffic-copy-value:hover,
|
||||
.traffic-copy-value:focus-visible,
|
||||
.file-log-aggregate-copy:hover,
|
||||
.file-log-aggregate-copy:focus-visible {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.file-log-advanced-parser {
|
||||
margin: 0 0 1rem;
|
||||
padding: .75rem 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius, 12px);
|
||||
}
|
||||
.file-log-aggregate-form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 2fr) minmax(140px, 1fr) minmax(120px, 1fr) auto;
|
||||
gap: .75rem;
|
||||
align-items: end;
|
||||
margin-top: .75rem;
|
||||
}
|
||||
.file-log-aggregate-table {
|
||||
margin-top: .75rem;
|
||||
}
|
||||
.file-log-aggregate-table th:last-child,
|
||||
.file-log-aggregate-table td:last-child {
|
||||
width: 8rem;
|
||||
text-align: right;
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.file-log-aggregate-form { grid-template-columns: 1fr; }
|
||||
}
|
||||
@@ -4,6 +4,17 @@
|
||||
const chartPalette = ["var(--chart-accent)", "var(--chart-secondary)", "var(--chart-tertiary)", "var(--chart-quaternary)", "var(--chart-quinary)", "var(--chart-senary)", "var(--chart-septenary)", "var(--chart-octonary)"];
|
||||
const scope = () => $("traffic-scope")?.value || "*";
|
||||
const number = value => fmt(Number(value) || 0);
|
||||
async function copyText(value) {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try { await navigator.clipboard.writeText(value); toast(t("copied")); return; } catch { }
|
||||
}
|
||||
const area = document.createElement("textarea"); area.value = value; area.setAttribute("readonly", "");
|
||||
area.style.position = "fixed"; area.style.opacity = "0"; document.body.append(area); area.select(); document.execCommand("copy"); area.remove(); toast(t("copied"));
|
||||
}
|
||||
function copyButton(value, className) {
|
||||
const button = el("button", value || "-", `${className} traffic-copy-value`); button.type = "button"; button.title = t("copyValue");
|
||||
button.addEventListener("click", () => void copyText(value || "-")); return button;
|
||||
}
|
||||
|
||||
function selectTab(name) {
|
||||
activeTab = name;
|
||||
@@ -53,7 +64,7 @@
|
||||
legend.replaceChildren(...shown.map((item, index) => {
|
||||
const row = el("li"), label = el("span"), swatch = el("i", undefined, "traffic-chart-swatch");
|
||||
swatch.style.background = chartPalette[index % chartPalette.length];
|
||||
const text = el("span", item.value || "-", "traffic-chart-label"); text.title = item.value || "-";
|
||||
const text = copyButton(item.value || "-", "traffic-chart-label"); text.title = `${t("copyValue")}: ${item.value || "-"}`;
|
||||
label.append(swatch, text); row.append(label, el("b", `${(item.count / total * 100).toFixed(1)}%`)); return row;
|
||||
}));
|
||||
}
|
||||
@@ -65,7 +76,7 @@
|
||||
if (empty) empty.hidden = values.length > 0;
|
||||
chart.replaceChildren(...values.map(item => {
|
||||
const count = Number(item.count) || 0, row = el("div", undefined, "traffic-bar-row");
|
||||
const label = el("span", item.value || "-", "traffic-bar-label"); label.title = item.value || "-";
|
||||
const label = copyButton(item.value || "-", "traffic-bar-label"); label.title = `${t("copyValue")}: ${item.value || "-"}`;
|
||||
const track = el("span", undefined, "traffic-bar-track"), fill = el("i", undefined, "traffic-bar-fill"); fill.style.width = `${Math.max(1.5, count / peak * 100)}%`; track.append(fill);
|
||||
const value = el("span", formatter(count), "traffic-bar-value"); value.title = total ? `${(count / total * 100).toFixed(1)}%` : "";
|
||||
row.append(label, track, value); return row;
|
||||
|
||||
@@ -256,6 +256,10 @@
|
||||
field(g, "referrer", "vhReferrer", security.referrer_policy || "", "text", { maxLength: 8192 });
|
||||
field(g, "csp", "vhCsp", security.content_security_policy || "", "textarea", { rows: 2, maxLength: 8192 });
|
||||
field(g, "permissions", "vhPermissions", security.permissions_policy || "", "text", { maxLength: 8192 });
|
||||
const uaBlock = value.user_agent_blocking || {};
|
||||
field(g, "ua-block-enabled", "vhUaBlockEnabled", uaBlock.enabled === true, "checkbox");
|
||||
field(g, "ua-block-patterns", "vhUaBlockPatterns", (uaBlock.patterns || []).join("\n"), "textarea", { rows: 4, maxLength: 32768 });
|
||||
note(g, "vhUaBlockHelp");
|
||||
g = group("vhAdvanced", true); field(g, "paths", "vhPaths", JSON.stringify(value.routes || [], null, 2), "textarea", { rows: 5, maxLength: 131072 });
|
||||
field(g, "acl", "vhAcl", JSON.stringify(value.acl ?? null, null, 2), "textarea", { rows: 5, maxLength: 32768 });
|
||||
field(g, "headers", "vhHeaders", JSON.stringify(value.headers || {}, null, 2), "textarea", { rows: 5, maxLength: 65536 });
|
||||
@@ -463,6 +467,7 @@
|
||||
},
|
||||
sticky: { mode: str("sticky-mode"), cookie_name: nullable("cookie-name"), ttl_seconds: num("cookie-ttl") }
|
||||
},
|
||||
security_headers: { hsts: { enabled: yes("hsts"), max_age_seconds: num("hsts-age"), include_subdomains: yes("hsts-sub"), preload: yes("hsts-preload") }, nosniff: yes("nosniff"), frame_options: str("frame"), referrer_policy: nullable("referrer"), content_security_policy: nullable("csp"), permissions_policy: nullable("permissions") }
|
||||
security_headers: { hsts: { enabled: yes("hsts"), max_age_seconds: num("hsts-age"), include_subdomains: yes("hsts-sub"), preload: yes("hsts-preload") }, nosniff: yes("nosniff"), frame_options: str("frame"), referrer_policy: nullable("referrer"), content_security_policy: nullable("csp"), permissions_policy: nullable("permissions") },
|
||||
user_agent_blocking: { enabled: yes("ua-block-enabled"), patterns: lines(str("ua-block-patterns")) }
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user