This commit is contained in:
Mateusz Gruszczyński committed 2026-10-11 17:10:04 +02:00
1 parent d942c1a35d
commit 75104f87ba
103 files changed
+1344 -370

No files matched your search

+13 -2
View File
@@ -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 {
+1
View File
@@ -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"),
+30
View File
@@ -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
View File
@@ -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");
+1
View File
@@ -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()
}
+232
View File
@@ -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(),
)
})?
}
+4
View File
@@ -113,6 +113,10 @@ async fn update_section(
config.host_policies =
serde_json::from_value(value).map_err(|e| invalid_section(&section, e))?
}
"user-agent-blocking" => {
config.user_agent_blocking =
serde_json::from_value(value).map_err(|e| invalid_section(&section, e))?
}
_ => {
return Err(ApiError(
StatusCode::NOT_FOUND,
+127 -3
View File
@@ -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 @@
}
}
}
}
}
+7 -1
View File
@@ -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
View File
@@ -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)
+3 -1
View File
@@ -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
View File
@@ -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"
+2 -2
View File
@@ -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;
}
+1 -5
View File
@@ -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()
+48
View File
@@ -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());
}
+32 -1
View File
@@ -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(&current)
.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\"",
+5 -1
View File
@@ -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(); });
+4 -4
View File
@@ -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>
+86 -6
View File
@@ -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();
+39 -4
View File
@@ -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&#10;curl/*&#10;*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"
+2 -2
View File
@@ -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
View File
@@ -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; }
}
+13 -2
View File
@@ -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;
+6 -1
View File
@@ -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")) }
};
}
+2
View File
@@ -223,6 +223,7 @@ pub fn compile_with_active(
guarded_bindings.sort();
guarded_bindings.dedup();
let global_headers = config.headers.compile()?;
let user_agent_blocking = config.user_agent_blocking.compile()?;
ensure!(
config.host_policies.len() <= 256,
"too many host ACL policies"
@@ -330,6 +331,7 @@ pub fn compile_with_active(
redirects,
not_found_hosts,
host_acls,
user_agent_blocking,
headers: global_headers,
})
}
+4
View File
@@ -72,6 +72,8 @@ pub struct Config {
pub vhosts: Vec<crate::vhost::Vhost>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub host_policies: Vec<HostPolicy>,
#[serde(default)]
pub user_agent_blocking: crate::user_agent::Policy,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub acl_profiles: Vec<AclProfile>,
#[serde(default, skip_serializing_if = "HeaderRules::is_empty")]
@@ -263,6 +265,7 @@ pub struct CompiledFrontend {
pub transform: crate::transform::Settings,
pub pages: crate::pages::VhostPages,
pub proxy_timeouts: crate::vhost::ProxyTimeouts,
pub user_agent_blocking: crate::user_agent::CompiledPolicy,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedLogVhost {
@@ -287,6 +290,7 @@ pub struct Snapshot {
redirects: HashMap<String, Vec<Arc<crate::redirect::CompiledHost>>>,
not_found_hosts: HashMap<String, Vec<Arc<crate::not_found::CompiledHost>>>,
host_acls: HashMap<String, Arc<CompiledAcl>>,
pub user_agent_blocking: crate::user_agent::CompiledPolicy,
pub headers: CompiledHeaders,
}
impl std::fmt::Debug for Snapshot {
+1
View File
@@ -224,6 +224,7 @@ fn compile_routing(
transform: host.transform.resolve(&config.transform)?,
pages: host.pages.clone(),
proxy_timeouts: host.proxy_timeouts.clone(),
user_agent_blocking: host.user_agent_blocking.compile()?,
});
if host.enabled {
let (fallback_source, fallback_pool, fallback_backend) = match &host.source {
+1
View File
@@ -19,6 +19,7 @@ pub mod tls;
pub mod traffic;
pub mod upstream;
pub mod update_check;
pub mod user_agent;
pub mod cache;
pub mod compression;
+8
View File
@@ -108,6 +108,14 @@ async fn forward_request(
);
};
let frontend = snapshot.frontend(&host, local.0, tls);
if snapshot.user_agent_blocking.blocks(request.headers())
|| frontend.is_some_and(|frontend| frontend.user_agent_blocking.blocks(request.headers()))
{
return error(
StatusCode::FORBIDDEN,
"RustProxy: denied by User-Agent policy\n",
);
}
let websocket_legacy_support =
handshake.is_some() && frontend.is_some_and(|frontend| frontend.websocket_legacy_support);
let redirect = snapshot.redirect_on(&host, &path, local.0, tls);
+104
View File
@@ -0,0 +1,104 @@
//! Bounded global/per-vhost User-Agent blocking.
//!
//! Plain patterns match case-insensitive substrings. `*` acts as a wildcard;
//! patterns without `*` remain intentionally simple for operator blocklists.
use anyhow::{ensure, Result};
use http::{header::USER_AGENT, HeaderMap};
use serde::{Deserialize, Serialize};
const MAX_PATTERNS: usize = 128;
const MAX_PATTERN_BYTES: usize = 256;
const MAX_TOTAL_BYTES: usize = 16 * 1024;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Policy {
pub enabled: bool,
pub patterns: Vec<String>,
}
#[derive(Debug, Clone, Default)]
pub struct CompiledPolicy {
enabled: bool,
patterns: Vec<String>,
}
impl Policy {
pub fn compile(&self) -> Result<CompiledPolicy> {
ensure!(
self.patterns.len() <= MAX_PATTERNS,
"User-Agent blocklist supports at most {MAX_PATTERNS} patterns"
);
let mut patterns = Vec::with_capacity(self.patterns.len());
let mut total = 0usize;
for raw in &self.patterns {
let pattern = raw.trim();
ensure!(
!pattern.is_empty()
&& pattern.len() <= MAX_PATTERN_BYTES
&& !pattern.chars().any(char::is_control),
"User-Agent block patterns must be 1..{MAX_PATTERN_BYTES} bytes without controls"
);
total = total.saturating_add(pattern.len());
ensure!(
total <= MAX_TOTAL_BYTES,
"User-Agent blocklist is too large"
);
let normalized = pattern.to_ascii_lowercase();
if !patterns.contains(&normalized) {
patterns.push(normalized);
}
}
Ok(CompiledPolicy {
enabled: self.enabled,
patterns,
})
}
}
fn wildcard_match(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()
}
impl CompiledPolicy {
pub fn blocks(&self, headers: &HeaderMap) -> bool {
if !self.enabled || self.patterns.is_empty() {
return false;
}
let Some(value) = headers.get(USER_AGENT) else {
return false;
};
let candidate = String::from_utf8_lossy(value.as_bytes()).to_ascii_lowercase();
self.patterns
.iter()
.any(|pattern| wildcard_match(&candidate, pattern))
}
}
#[cfg(test)]
mod tests;
+53
View File
@@ -0,0 +1,53 @@
use super::*;
use http::HeaderValue;
fn headers(value: Option<&str>) -> HeaderMap {
let mut headers = HeaderMap::new();
if let Some(value) = value {
headers.insert(USER_AGENT, HeaderValue::from_str(value).unwrap());
}
headers
}
#[test]
fn plain_patterns_match_case_insensitive_substrings() {
let policy = Policy {
enabled: true,
patterns: vec!["BadBot".into()],
}
.compile()
.unwrap();
assert!(policy.blocks(&headers(Some("Mozilla/5.0 badbot/2.1"))));
assert!(!policy.blocks(&headers(Some("Mozilla/5.0"))));
}
#[test]
fn wildcard_patterns_can_anchor_or_span() {
let policy = Policy {
enabled: true,
patterns: vec!["curl/*".into(), "*scanner*".into()],
}
.compile()
.unwrap();
assert!(policy.blocks(&headers(Some("curl/8.4.0"))));
assert!(policy.blocks(&headers(Some("AcmeScanner/1"))));
assert!(!policy.blocks(&headers(Some("prefix curl/8.4.0"))));
}
#[test]
fn disabled_or_missing_user_agent_is_allowed() {
let disabled = Policy {
enabled: false,
patterns: vec!["*".into()],
}
.compile()
.unwrap();
assert!(!disabled.blocks(&headers(Some("anything"))));
let enabled = Policy {
enabled: true,
patterns: vec!["*".into()],
}
.compile()
.unwrap();
assert!(!enabled.blocks(&headers(None)));
}
+1 -1
View File
@@ -149,7 +149,6 @@ pub enum RewriteMode {
ReplacePrefix,
Regex,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, deny_unknown_fields)]
pub struct PathRewrite {
@@ -309,6 +308,7 @@ pub struct Vhost {
pub headers: HeaderRules,
#[serde(default)]
pub security_headers: SecurityHeaders,
#[serde(default)] pub user_agent_blocking: crate::user_agent::Policy,
/// Nginx-style upstream timeout controls. Empty values inherit process defaults.
#[serde(default)]
pub proxy_timeouts: ProxyTimeouts,
+1
View File
@@ -244,6 +244,7 @@ impl Vhost {
self.pages.validate()?;
self.transform.resolve(&crate::transform::Settings::default())?;
self.security_headers.compile()?;
self.user_agent_blocking.compile()?;
self.headers.compile()?;
if let Some(acl) = &self.acl {
acl.compile()?;