This commit is contained in:
Mateusz Gruszczyński committed 2026-10-04 09:29:10 +02:00
1 parent 2e4aa4df4d
commit 87e571c170
106 files changed
+17952 -6185

No files matched your search

+10
View File
@@ -199,6 +199,16 @@ mod tests {
routing_path("/static/js//events.js").unwrap(),
"/static/js/events.js"
);
for path in [
"/%2e%2e/secret",
"/a/%2E%2E/b",
"/a%2fb",
"/a%5cb",
"/bad%",
"/bad%2",
] {
assert!(routing_path(path).is_err(), "{path}");
}
assert_eq!(routing_path("//admin///users").unwrap(), "/admin/users");
}
#[test]
+126 -3
View File
@@ -1,28 +1,135 @@
//! Authenticated OpenAPI/Swagger UI, exclusively on the admin listener.
//! Only these allowlisted vendor assets can be read; no filesystem or wildcard route.
//! Authenticated documentation Reader and OpenAPI/Swagger UI on the admin listener.
//! Documentation and vendor assets are embedded at compile time; no runtime filesystem or CDN access.
use axum::{
response::{Html, IntoResponse, Response},
Json,
};
use http::StatusCode;
use serde_json::Value;
use serde_json::{json, Value};
use std::sync::OnceLock;
// Keep Reader input independent from build-script output. `include_str!` makes every
// document a direct compiler input, so a clean or partially removed target directory
// cannot leave the crate pointing at a missing OUT_DIR artifact.
static READER_DOCUMENTS: &[(&str, &str, &str)] = &[
("README.md", "project", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))),
("ROADMAP.md", "project", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/ROADMAP.md"))),
("CHANGELOG.md", "project", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/CHANGELOG.md"))),
("docs/ACME_PROVIDERS.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/ACME_PROVIDERS.md"))),
("docs/ACME_RFC2136.md", "historical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/ACME_RFC2136.md"))),
("docs/API.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/API.md"))),
("docs/API_ACCESS.md", "technical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/API_ACCESS.md"))),
("docs/ARCHITECTURE.md", "technical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/ARCHITECTURE.md"))),
("docs/CACHE.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/CACHE.md"))),
("docs/CACHE_DIAGNOSTICS.md", "technical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/CACHE_DIAGNOSTICS.md"))),
("docs/CHANGELOG_TECHNICAL_ARCHIVE.md", "historical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/CHANGELOG_TECHNICAL_ARCHIVE.md"))),
("docs/CONTAINER.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/CONTAINER.md"))),
("docs/CUSTOM_LOCATIONS.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/CUSTOM_LOCATIONS.md"))),
("docs/DOCKER_DISCOVERY.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/DOCKER_DISCOVERY.md"))),
("docs/ERROR_LOGGING.md", "technical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/ERROR_LOGGING.md"))),
("docs/HTTP3.md", "technical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/HTTP3.md"))),
("docs/INSTALL.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/INSTALL.md"))),
("docs/INTEGRATIONS_040.md", "historical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/INTEGRATIONS_040.md"))),
("docs/LIVE_LOGS.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/LIVE_LOGS.md"))),
("docs/MANAGEMENT.md", "historical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/MANAGEMENT.md"))),
("docs/MIGRATION.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/MIGRATION.md"))),
("docs/MOCK_AUTH.md", "technical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/MOCK_AUTH.md"))),
("docs/MOCK_SERVER.md", "technical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/MOCK_SERVER.md"))),
("docs/NOT_FOUND_HOSTS.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/NOT_FOUND_HOSTS.md"))),
("docs/NPM_MIGRATION.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/NPM_MIGRATION.md"))),
("docs/PHP_FPM.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/PHP_FPM.md"))),
("docs/PROMETHEUS.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/PROMETHEUS.md"))),
("docs/README.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/README.md"))),
("docs/REDIRECTS.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/REDIRECTS.md"))),
("docs/REFERENCES.md", "technical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/REFERENCES.md"))),
("docs/RENEWAL.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/RENEWAL.md"))),
("docs/ROADMAP_HISTORY.md", "historical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/ROADMAP_HISTORY.md"))),
("docs/SECRET_STORE_BACKUP.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/SECRET_STORE_BACKUP.md"))),
("docs/SECURITY.md", "technical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/SECURITY.md"))),
("docs/SECURITY_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/SECURITY_AUDIT.md"))),
("docs/SETTINGS.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/SETTINGS.md"))),
("docs/STREAMS.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/STREAMS.md"))),
("docs/TEST_REPORT.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/TEST_REPORT.md"))),
("docs/TODO.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/TODO.md"))),
("docs/TRANSFORM.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/TRANSFORM.md"))),
("docs/TRANSPORT.md", "technical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/TRANSPORT.md"))),
("docs/V0.21.0_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.21.0_AUDIT.md"))),
("docs/V0.22.0_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.22.0_AUDIT.md"))),
("docs/V0.22.1_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.22.1_AUDIT.md"))),
("docs/V0.22.2_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.22.2_AUDIT.md"))),
("docs/V0.22.3_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.22.3_AUDIT.md"))),
("docs/V0.23.0_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.23.0_AUDIT.md"))),
("docs/V0.23.1_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.23.1_AUDIT.md"))),
("docs/V0.23.2_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.23.2_AUDIT.md"))),
("docs/V0.23.3_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.23.3_AUDIT.md"))),
("docs/V0.24.0_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.24.0_AUDIT.md"))),
("docs/V0.24.1_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.24.1_AUDIT.md"))),
("docs/V0.24.3_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.24.3_AUDIT.md"))),
("docs/V0.24.4_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.24.4_AUDIT.md"))),
("docs/V0.24.5_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.24.5_AUDIT.md"))),
("docs/V0.24.6_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.24.6_AUDIT.md"))),
("docs/V0.24.7_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.24.7_AUDIT.md"))),
("docs/V0.24.8_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.24.8_AUDIT.md"))),
("docs/V0.25.0_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.25.0_AUDIT.md"))),
("docs/V0.25.0_SECURITY_PREPROD_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.25.0_SECURITY_PREPROD_AUDIT.md"))),
("docs/VHOSTS.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/VHOSTS.md"))),
("docs/VHOST_ACCESS.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/VHOST_ACCESS.md"))),
("docs/WEB_SERVER.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/WEB_SERVER.md"))),
];
pub(super) async fn index() -> Html<&'static str> {
Html(include_str!("web/docs.html"))
}
pub(super) async fn script() -> impl IntoResponse {
(
[("content-type", "text/javascript; charset=utf-8")],
include_str!("web/docs.js"),
)
}
pub(super) async fn style() -> impl IntoResponse {
(
[("content-type", "text/css; charset=utf-8")],
include_str!("web/docs.css"),
)
}
fn reader_bundle() -> &'static [u8] {
static BUNDLE: OnceLock<Vec<u8>> = OnceLock::new();
BUNDLE
.get_or_init(|| {
let documents: Vec<_> = READER_DOCUMENTS
.iter()
.copied()
.map(|(path, category, content)| {
let file_name = path.rsplit('/').next().unwrap_or(path);
let title = content
.lines()
.find_map(|line| line.strip_prefix("# "))
.filter(|title| !title.trim().is_empty())
.unwrap_or_else(|| file_name.trim_end_matches(".md"));
json!({
"path": path,
"title": title,
"category": category,
"content": content,
})
})
.collect();
serde_json::to_vec(&json!({ "documents": documents }))
.expect("embedded documentation must serialize")
})
.as_slice()
}
pub(super) async fn content() -> impl IntoResponse {
(
[("content-type", "application/json; charset=utf-8")],
reader_bundle(),
)
}
pub(super) async fn schema() -> Json<Value> {
static DOCUMENT: OnceLock<Value> = OnceLock::new();
Json(
@@ -36,6 +143,7 @@ pub(super) async fn schema() -> Json<Value> {
.clone(),
)
}
pub(super) fn asset(name: &'static str) -> Response {
// Bytes are embedded for debug AND release, and always served through the
// authenticated router. Never read a file or fetch a CDN on an HTTP request.
@@ -61,6 +169,21 @@ pub(super) fn asset(name: &'static str) -> Response {
mod tests {
use super::*;
#[test]
fn reader_bundle_contains_embedded_project_and_web_server_docs() {
let value: Value = serde_json::from_slice(reader_bundle()).unwrap();
let documents = value["documents"].as_array().unwrap();
assert!(documents.iter().any(|doc| doc["path"] == "README.md"));
assert!(documents.iter().any(|doc| doc["path"] == "docs/WEB_SERVER.md"));
assert!(documents.iter().any(|doc| doc["path"] == "docs/PHP_FPM.md"));
assert!(documents.iter().all(|doc| {
doc["path"].as_str().is_some_and(|value| !value.is_empty())
&& doc["title"].as_str().is_some_and(|value| !value.is_empty())
&& doc["category"].as_str().is_some_and(|value| !value.is_empty())
&& doc["content"].as_str().is_some_and(|value| !value.is_empty())
}));
}
#[tokio::test]
async fn fixed_vendor_names_return_embedded_assets_with_expected_types() {
for (name, expected_type) in [
+102
View File
@@ -45,6 +45,7 @@ pub fn router(state: Arc<App>) -> Router {
.route("/", get(public::index))
.route("/overview", get(public::index))
.route("/vhosts", get(public::index))
.route("/web-server", get(public::index))
.route("/docker-vhosts", get(public::index))
.route("/redirects", get(public::index))
.route("/404-hosts", get(public::index))
@@ -52,6 +53,7 @@ pub fn router(state: Arc<App>) -> Router {
.route("/acl-profiles", get(public::index))
.route("/configuration", get(public::index))
.route("/tls", get(public::index))
.route("/traffic", get(public::index))
.route("/cache", get(public::index))
.route("/transform", get(public::index))
.route("/logging", get(public::index))
@@ -77,6 +79,7 @@ pub fn router(state: Arc<App>) -> Router {
.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(
@@ -127,6 +130,15 @@ pub fn router(state: Arc<App>) -> Router {
)
}),
)
.route(
"/assets/traffic-panel.js",
get(|| async {
(
[("content-type", "text/javascript; charset=utf-8")],
include_str!("web/traffic-panel.js"),
)
}),
)
.route(
"/assets/cache-panel.js",
get(|| async {
@@ -172,6 +184,15 @@ pub fn router(state: Arc<App>) -> Router {
)
}),
)
.route(
"/assets/web-server.js",
get(|| async {
(
[("content-type", "text/javascript; charset=utf-8")],
include_str!("web/web-server.js"),
)
}),
)
.route(
"/assets/docker.js",
get(|| async {
@@ -272,6 +293,7 @@ pub fn router(state: Arc<App>) -> Router {
"/api/certificate-api/{key_id}",
delete(certificate_api_revoke).layer(DefaultBodyLimit::max(4096)),
)
.route("/api/traffic", get(traffic_status))
.route("/api/cache", get(cache_status))
.route("/api/transform", get(transform_status))
.route("/api/cache/purge", post(cache_purge))
@@ -360,6 +382,7 @@ fn panel_document_path(path: &str) -> bool {
path,
"/" | "/overview"
| "/vhosts"
| "/web-server"
| "/docker-vhosts"
| "/redirects"
| "/404-hosts"
@@ -367,6 +390,7 @@ fn panel_document_path(path: &str) -> bool {
| "/acl-profiles"
| "/configuration"
| "/tls"
| "/traffic"
| "/cache"
| "/transform"
| "/logging"
@@ -1280,6 +1304,11 @@ fn status_payload(state: &App) -> Value {
});
payload["system"] = json!(system);
payload["backend_pools"] = json!(snapshot.pools.iter().map(|p| p.view()).collect::<Vec<_>>());
payload["vhost_metrics"] = json!(snapshot
.vhost_metrics
.iter()
.map(|(id, metrics)| (id.clone(), metrics.view()))
.collect::<std::collections::BTreeMap<_, _>>());
let mut backend_pool_domains = std::collections::BTreeMap::new();
for agent in &snapshot.envelope.config.docker_agents.agents {
for assignment in agent
@@ -1544,6 +1573,10 @@ async fn update_section(
config.vhosts =
serde_json::from_value(value).map_err(|e| invalid_section(&section, e))?
}
"web-services" => {
config.web_services =
serde_json::from_value(value).map_err(|e| invalid_section(&section, e))?
}
"redirects" => {
config.redirects =
serde_json::from_value(value).map_err(|e| invalid_section(&section, e))?
@@ -2174,6 +2207,11 @@ mod tests {
router.clone().oneshot(anonymous).await.unwrap().status(),
StatusCode::UNAUTHORIZED
);
let anonymous = request("/api/traffic").body(Body::empty()).unwrap();
assert_eq!(
router.clone().oneshot(anonymous).await.unwrap().status(),
StatusCode::UNAUTHORIZED
);
let anonymous = request("/api/transform").body(Body::empty()).unwrap();
assert_eq!(
router.clone().oneshot(anonymous).await.unwrap().status(),
@@ -2526,6 +2564,7 @@ mod tests {
for path in [
"/overview",
"/vhosts",
"/web-server",
"/docker-vhosts",
"/redirects",
"/404-hosts",
@@ -2533,6 +2572,7 @@ mod tests {
"/acl-profiles",
"/configuration",
"/tls",
"/traffic",
"/cache",
"/transform",
"/logging",
@@ -2651,6 +2691,7 @@ mod tests {
for (path, expected) in [
("/api/logs?vhost=demo&limit=8", 200),
("/api/logs?cache=BAD", 400),
("/api/traffic", 200),
("/api/cache", 200),
("/api/transform", 200),
] {
@@ -3199,6 +3240,7 @@ mod tests {
"/api/config",
"/api/backends",
"/api/docker",
"/api/traffic",
"/api/cache?limit=invalid",
"/api/logs?limit=invalid",
"/api/live-logs",
@@ -3212,6 +3254,7 @@ mod tests {
"/api/metrics",
"/metrics",
"/docs/openapi.json",
"/docs/content.json",
"/docs/init.js",
"/docs/style.css",
"/docs/swagger-ui-bundle.js",
@@ -3221,6 +3264,7 @@ mod tests {
"/assets/app.js",
"/assets/management.js",
"/assets/integrations.js",
"/assets/traffic-panel.js",
"/assets/cache-panel.js",
"/assets/transform-panel.js",
"/assets/vhosts.js",
@@ -3281,6 +3325,7 @@ mod tests {
("/api/auth/setup", "GET"),
("/healthz", "POST"),
("/docs/openapi.json", "HEAD"),
("/docs/content.json", "HEAD"),
] {
let response = router
.clone()
@@ -3381,6 +3426,7 @@ mod tests {
"/docs/swagger-ui-bundle.js",
"/docs/swagger-ui.css",
"/docs/licenses.txt",
"/docs/content.json",
"/assets/app.js",
] {
let response = router
@@ -3415,6 +3461,23 @@ mod tests {
"cookie"
);
assert!(spec["paths"].get("/api/config").is_some());
let response = router
.clone()
.oneshot(
request("/docs/content.json")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let docs = json_body(response).await;
let documents = docs["documents"].as_array().expect("documentation bundle");
assert!(documents.iter().any(|document| document["path"] == "docs/README.md"));
assert!(documents.iter().any(|document| document["path"] == "docs/WEB_SERVER.md"));
assert!(documents.iter().any(|document| document["path"] == "docs/PHP_FPM.md"));
assert!(documents.iter().any(|document| document["path"] == "docs/V0.25.0_AUDIT.md"));
assert!(documents.iter().any(|document| document["path"] == "docs/V0.25.0_SECURITY_PREPROD_AUDIT.md"));
store.close().await;
}
#[tokio::test]
@@ -3515,6 +3578,45 @@ mod tests {
}
}
#[derive(Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct TrafficQuery {
vhost: Option<String>,
}
async fn traffic_status(
State(state): State<Arc<App>>,
Query(query): Query<TrafficQuery>,
) -> Result<Json<Value>, ApiError> {
let snapshot = state.config.load_full();
if query.vhost.as_ref().is_some_and(|id| {
id != "*" && !snapshot.envelope.config.vhosts.iter().any(|vhost| &vhost.id == id)
}) {
return Err(ApiError(
StatusCode::NOT_FOUND,
"unknown vhost traffic scope".into(),
));
}
let scope = query.vhost.as_deref().unwrap_or("*");
let vhosts = snapshot
.envelope
.config
.vhosts
.iter()
.map(|vhost| json!({"id": vhost.id, "hosts": vhost.frontend.hosts}))
.collect::<Vec<_>>();
Ok(Json(json!({
"scope": scope,
"since": "process_start",
"settings": {
"capture_user_agent": snapshot.envelope.config.logging.capture_user_agent,
"capture_country": snapshot.envelope.config.logging.capture_country
},
"geoip": state.geoip.view(),
"analytics": state.traffic.view(Some(scope)),
"vhosts": vhosts
})))
}
#[derive(Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct CacheQuery {
+651 -3
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.24.10",
"version": "0.25.0",
"description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust."
},
"servers": [
@@ -361,6 +361,9 @@
}
},
"description": "Local response returned by RustProxy without opening a backend connection."
},
"source": {
"$ref": "#/components/schemas/Source"
}
},
"additionalProperties": false,
@@ -509,13 +512,15 @@
"type": "boolean",
"default": false,
"description": "RFC 8441 WebSocket Extended CONNECT over HTTP/2. Independent opt-in alongside classic HTTP/1.1 Upgrade; requires WebSocket support and an H2-capable selected listener."
},
"source": {
"$ref": "#/components/schemas/Source"
}
},
"additionalProperties": true,
"required": [
"id",
"frontend",
"backends"
"frontend"
]
},
"Config": {
@@ -665,6 +670,16 @@
"format": {
"type": "object",
"additionalProperties": true
},
"capture_user_agent": {
"type": "boolean",
"default": true,
"description": "Capture a bounded User-Agent string in access logs and traffic analytics."
},
"capture_country": {
"type": "boolean",
"default": false,
"description": "Resolve country from GeoIP for access logs and traffic analytics. Disabled by default to avoid per-request GeoIP work unless country policy/rate-limit already requires it."
}
}
},
@@ -717,6 +732,13 @@
}
},
"description": "Optional asynchronous ERROR-only file sink. Rotation and retention are handled externally by logrotate."
},
"web_services": {
"type": "array",
"maxItems": 128,
"items": {
"$ref": "#/components/schemas/WebService"
}
}
},
"additionalProperties": true
@@ -1605,6 +1627,25 @@
"minimum": 1,
"maximum": 64,
"default": 4
},
"hot_auto_trim_enabled": {
"type": "boolean",
"default": true,
"description": "When HOT exceeds the configured target, periodically remove only sufficiently idle objects in bounded passes."
},
"hot_auto_trim_after_seconds": {
"type": "integer",
"minimum": 5,
"maximum": 86400,
"default": 300,
"description": "Minimum idle age before an object is eligible for automatic HOT trimming."
},
"hot_auto_trim_target_percent": {
"type": "integer",
"minimum": 10,
"maximum": 95,
"default": 80,
"description": "HOT usage waterline targeted by automatic idle trimming."
}
},
"additionalProperties": false
@@ -4057,6 +4098,530 @@
"http3_new_handshakes",
"administration_restart_required"
]
},
"Source": {
"oneOf": [
{
"type": "object",
"additionalProperties": false,
"properties": {
"type": {
"type": "string",
"enum": [
"reverse_proxy"
]
}
},
"required": [
"type"
]
},
{
"type": "object",
"additionalProperties": false,
"properties": {
"type": {
"type": "string",
"enum": [
"web_server"
]
},
"web_service_id": {
"type": "string",
"description": "ID of a reusable Web Service from config.web_services."
}
},
"required": [
"type",
"web_service_id"
]
}
],
"description": "Request source. Missing in older configuration documents is treated as Reverse Proxy."
},
"WebService": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"maxLength": 64
},
"name": {
"type": "string",
"maxLength": 96
},
"document_root": {
"type": "string",
"description": "Absolute local filesystem directory forming the static-file access boundary."
},
"index": {
"type": "array",
"maxItems": 16,
"items": {
"type": "string",
"maxLength": 128
},
"description": "Optional ordered index file names. An empty list skips index lookup; directory listing/error behavior applies directly."
},
"directory_listing": {
"type": "boolean",
"default": false
},
"mime_types": {
"type": "object",
"maxProperties": 64,
"additionalProperties": {
"type": "string",
"maxLength": 256
},
"description": "Optional file-extension to Content-Type overrides. Extensions are stored without a leading dot."
},
"default_mime_type": {
"type": "string",
"maxLength": 256,
"default": "application/octet-stream",
"description": "Fallback Content-Type for extensions not covered by an override or RustProxy built-ins."
},
"cache_control": {
"type": "string",
"nullable": true,
"maxLength": 1024,
"description": "Optional default Cache-Control header for files served by this Web Service. Vhost/location response-header rules remain authoritative and may override it."
},
"cache_rules": {
"type": "array",
"maxItems": 64,
"default": [],
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"matcher",
"cache_control"
],
"properties": {
"matcher": {
"type": "string",
"description": "ext:<extension> or mime:<prefix>"
},
"cache_control": {
"type": "string",
"maxLength": 1024
}
}
},
"description": "Ordered per-extension/per-MIME Cache-Control rules evaluated before cache_control."
},
"response_headers": {
"type": "object",
"maxProperties": 32,
"default": {},
"additionalProperties": {
"type": "string",
"maxLength": 8192
},
"description": "Fixed Web Service response headers. Framing/transport headers are rejected; vhost/location response rules run later."
},
"try_files": {
"type": "array",
"maxItems": 16,
"default": [],
"items": {
"type": "string",
"maxLength": 512
},
"description": "Ordered nginx-style file candidates. Supports $uri, $query_string and a final =status."
},
"rewrites": {
"type": "array",
"maxItems": 32,
"default": [],
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"pattern",
"replacement"
],
"properties": {
"pattern": {
"type": "string",
"maxLength": 512
},
"replacement": {
"type": "string",
"maxLength": 1024
},
"status": {
"type": "integer",
"nullable": true,
"enum": [
301,
302,
307,
308
]
}
}
},
"description": "Regex internal rewrites or explicit redirects evaluated before try_files."
},
"precompressed": {
"type": "object",
"additionalProperties": false,
"default": {
"enabled": false,
"brotli": true,
"gzip": true
},
"properties": {
"enabled": {
"type": "boolean",
"default": false
},
"brotli": {
"type": "boolean",
"default": true
},
"gzip": {
"type": "boolean",
"default": true
}
}
},
"download_extensions": {
"type": "array",
"maxItems": 64,
"default": [],
"items": {
"type": "string",
"maxLength": 32
},
"description": "Extensions served with Content-Disposition: attachment."
},
"symlink_policy": {
"type": "string",
"enum": [
"deny",
"inside_root"
],
"default": "deny",
"description": "deny is portable/default. inside_root is a Linux-only opt-in and cannot currently be combined with PHP-FPM."
},
"php": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean",
"default": false
},
"extensions": {
"type": "array",
"maxItems": 16,
"default": [
"php"
],
"items": {
"type": "string",
"maxLength": 32
}
},
"endpoint": {
"type": "string",
"description": "unix:/path or loopback tcp:literal-IP:port, for example tcp:127.0.0.1:9000 or tcp:[::1]:9000"
},
"connect_timeout_ms": {
"type": "integer",
"format": "uint64",
"minimum": 50,
"maximum": 60000,
"default": 1000
},
"request_timeout_ms": {
"type": "integer",
"format": "uint64",
"minimum": 100,
"maximum": 3600000,
"default": 30000
},
"max_concurrent_requests": {
"type": "integer",
"minimum": 1,
"maximum": 4096,
"default": 64
},
"pass_authorization": {
"type": "boolean",
"default": true,
"description": "Forward inbound Authorization as HTTP_AUTHORIZATION to PHP-FPM."
},
"max_request_body_bytes": {
"type": "integer",
"format": "uint64",
"minimum": 0,
"maximum": 8589934592,
"default": 0,
"description": "RustProxy-side streaming PHP request body limit. 0 disables this additional limit."
},
"max_response_header_bytes": {
"type": "integer",
"minimum": 4096,
"maximum": 1048576,
"default": 65536
},
"stderr_log_bytes": {
"type": "integer",
"minimum": 0,
"maximum": 65536,
"default": 4096,
"description": "Maximum PHP-FPM stderr bytes retained per request for diagnostics."
},
"custom_params": {
"type": "object",
"maxProperties": 64,
"default": {},
"additionalProperties": {
"type": "string",
"maxLength": 8192
},
"description": "Trusted static FastCGI NAME=value parameters. Core CGI, HTTP_* and PHP control names are reserved."
},
"php_values": {
"type": "object",
"maxProperties": 64,
"default": {},
"additionalProperties": {
"type": "string",
"maxLength": 8192
},
"description": "Trusted php.ini directive/value overrides combined into the FastCGI PHP_VALUE parameter. PHP_ADMIN_VALUE is not exposed."
}
},
"description": "Optional bounded direct FastCGI connection to a PHP-FPM pool."
},
"htaccess": {
"type": "object",
"additionalProperties": false,
"default": {
"enabled": false,
"filename": ".htaccess",
"scan_subdirectories": true,
"strict": false
},
"properties": {
"enabled": {
"type": "boolean",
"default": false,
"description": "Compile the bounded RustProxy .htaccess compatibility subset for this Web Service."
},
"filename": {
"type": "string",
"maxLength": 64,
"default": ".htaccess",
"description": "Plain configuration file name searched from the document root."
},
"scan_subdirectories": {
"type": "boolean",
"default": true,
"description": "Compile matching files in subdirectories, bounded to depth 16 and 128 files."
},
"strict": {
"type": "boolean",
"default": false,
"description": "Reject Apply when an unsupported directive is found instead of logging and ignoring it."
}
},
"description": "Migration-oriented Apache .htaccess subset compiled at Web Service apply time; never parsed per request."
},
"hardening": {
"type": "object",
"additionalProperties": false,
"default": {
"block_common_attacks": false,
"strip_unnecessary_headers": false
},
"properties": {
"block_common_attacks": {
"type": "boolean",
"default": false,
"description": "Reject high-confidence exploit probes, sensitive dot/control/backup paths and unsafe TRACE/CONNECT/TRACK methods for this Web Service."
},
"strip_unnecessary_headers": {
"type": "boolean",
"default": false,
"description": "Remove common implementation/fingerprint response headers such as Server and X-Powered-By after Web Service/.htaccess processing."
}
},
"description": "Optional Web Service hardening. This is a bounded deterministic compatibility/security layer, not a generic WAF."
}
},
"required": [
"id",
"name",
"document_root"
]
},
"TrafficTopEntry": {
"type": "object",
"additionalProperties": false,
"required": [
"value",
"count"
],
"properties": {
"value": {
"type": "string"
},
"count": {
"type": "integer",
"format": "int64",
"minimum": 0
}
}
},
"TrafficCounters": {
"type": "object",
"additionalProperties": false,
"properties": {
"requests": {
"type": "integer",
"format": "int64"
},
"errors": {
"type": "integer",
"format": "int64"
},
"duration_ms": {
"type": "integer",
"format": "int64"
},
"request_bytes": {
"type": "integer",
"format": "int64"
},
"response_bytes": {
"type": "integer",
"format": "int64"
},
"mean_duration_ms": {
"type": "number",
"format": "double",
"nullable": true
},
"countries": {
"type": "array",
"items": {
"$ref": "#/components/schemas/TrafficTopEntry"
}
},
"user_agents": {
"type": "array",
"items": {
"$ref": "#/components/schemas/TrafficTopEntry"
}
},
"client_ips": {
"type": "array",
"items": {
"$ref": "#/components/schemas/TrafficTopEntry"
}
},
"methods": {
"type": "array",
"items": {
"$ref": "#/components/schemas/TrafficTopEntry"
}
},
"statuses": {
"type": "array",
"items": {
"$ref": "#/components/schemas/TrafficTopEntry"
}
},
"routes": {
"type": "array",
"items": {
"$ref": "#/components/schemas/TrafficTopEntry"
}
},
"paths": {
"type": "array",
"items": {
"$ref": "#/components/schemas/TrafficTopEntry"
}
},
"cache": {
"type": "array",
"items": {
"$ref": "#/components/schemas/TrafficTopEntry"
}
}
}
},
"TrafficAnalytics": {
"type": "object",
"additionalProperties": false,
"properties": {
"scope": {
"type": "string"
},
"approximate_top_lists": {
"type": "boolean"
},
"queue_capacity": {
"type": "integer"
},
"queue_dropped": {
"type": "integer",
"format": "int64"
},
"data": {
"$ref": "#/components/schemas/TrafficCounters"
}
}
},
"TrafficStatus": {
"type": "object",
"additionalProperties": false,
"properties": {
"scope": {
"type": "string"
},
"since": {
"type": "string",
"enum": [
"process_start"
]
},
"settings": {
"type": "object",
"additionalProperties": false,
"properties": {
"capture_user_agent": {
"type": "boolean"
},
"capture_country": {
"type": "boolean"
}
}
},
"geoip": {
"type": "object",
"additionalProperties": true
},
"analytics": {
"$ref": "#/components/schemas/TrafficAnalytics"
},
"vhosts": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": true
}
}
}
}
}
},
@@ -8371,6 +8936,89 @@
}
]
}
},
"/api/traffic": {
"get": {
"summary": "Bounded per-vhost traffic analytics",
"tags": [
"Traffic"
],
"operationId": "get_api_traffic",
"parameters": [
{
"name": "vhost",
"in": "query",
"required": false,
"description": "Configured vhost ID or * for all vhosts.",
"schema": {
"type": "string",
"default": "*"
}
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TrafficStatus"
}
}
}
},
"401": {
"description": "Authentication required or revoked",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Host, origin or CSRF validation failed",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "Unknown vhost scope",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"429": {
"description": "Bounded worker/session/rate limit",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"503": {
"description": "Control-plane storage/worker unavailable",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
}
}
}
+46 -11
View File
@@ -44,7 +44,7 @@ function updateAdminPreferences(patch) {
}
function resetAdminPreferences() { adminPreferences = null; adminPreferencesLoading = null; adminPreferencesWrite = Promise.resolve(); }
window.rustProxyPreferences = { load: loadAdminPreferences, update: updateAdminPreferences, current: () => adminPreferences, applyTheme: applyAdminTheme };
const PANEL_PATHS = { overview: "/overview", vhosts: "/vhosts", "docker-vhosts": "/docker-vhosts", redirects: "/redirects", notfound: "/404-hosts", streams: "/streams", "acl-profiles": "/acl-profiles", tls: "/tls", cache: "/cache", transform: "/transform", settings: "/settings" };
const PANEL_PATHS = { overview: "/overview", vhosts: "/vhosts", "web-server": "/web-server", "docker-vhosts": "/docker-vhosts", redirects: "/redirects", notfound: "/404-hosts", streams: "/streams", "acl-profiles": "/acl-profiles", tls: "/tls", traffic: "/traffic", cache: "/cache", transform: "/transform", settings: "/settings" };
function panelFromLocation() { if (typeof location === "undefined") return "overview"; if (location.pathname === "/configuration" || location.pathname === "/logging") return "settings"; const found = Object.entries(PANEL_PATHS).find(([, path]) => path === location.pathname); return found?.[0] || "overview"; }
let envelope = null, busy = false, connected = false, currentPanel = panelFromLocation(), previous = null;
let samples = [], protocolSamples = { h1: [], h2: [], h3: [] }, websocketProtocolSamples = { h1: [], h2: [], h3: [] }, transformSamples = [], polling = false, toastTimer, editingBackend = null, editingRoute = null;
@@ -138,6 +138,7 @@ 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", "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 (path === "/api/config") return "configuration";
if (path.startsWith("/api/traffic")) return "traffic";
if (path.startsWith("/api/cache")) return "cache";
if (path.startsWith("/api/tunnels")) return "overview";
if (path.startsWith("/api/transform")) return "transform";
@@ -176,7 +177,7 @@ function setPanel(name, navigate = true) {
if (navigate && typeof location !== "undefined" && ["http:", "https:"].includes(location.protocol) && location.pathname !== PANEL_PATHS[name]) history.pushState({ panel: name }, "", PANEL_PATHS[name]);
if (name === "settings") window.rustProxySettings?.syncLocation?.();
if (name === "tls") window.rustProxyAcme?.syncLocation?.();
if (name === "cache") void window.rustProxyCachePanel?.refresh(); if (name === "transform") void window.rustProxyTransformPanel?.refresh(); if (name === "docker-vhosts") void window.rustProxyDocker?.refresh(false); if (name === "tls") { void window.rustProxyAcme?.refresh(); void refreshCertificateInventory().then(() => { if (authenticated && currentPanel === "tls") renderCertificates(); }); }
if (name === "traffic") void window.rustProxyTrafficPanel?.refresh(); if (name === "cache") void window.rustProxyCachePanel?.refresh(); if (name === "transform") void window.rustProxyTransformPanel?.refresh(); if (name === "docker-vhosts") void window.rustProxyDocker?.refresh(false); if (name === "tls") { void window.rustProxyAcme?.refresh(); void refreshCertificateInventory().then(() => { if (authenticated && currentPanel === "tls") renderCertificates(); }); }
}
function bytes(value) { if (value < 1024) return `${value} B`; for (const unit of ["KiB", "MiB", "GiB", "TiB"]) { value /= 1024; if (value < 1024 || unit === "TiB") return `${value.toFixed(value < 10 ? 1 : 0)} ${unit}`; } }
function uptime(value) {
@@ -303,7 +304,7 @@ function parseRules(text, target = null) {
});
}
function renderConfig() {
if (!envelope) return; window.rustProxyCachePanel?.configure(); renderCertificates(); renderSettings(); window.rustProxyPages?.render(); window.rustProxyAclProfiles?.render(); window.rustProxyVhosts?.render(); window.rustProxyDocker?.render(); window.rustProxyRedirects?.render(); window.rustProxyNotFound?.render(); window.rustProxyStreams?.render(); const config = envelope.config; if (!settingsDirty.has("config-editor")) $("config-editor").value = JSON.stringify(config, null, 2);
if (!envelope) return; window.rustProxyCachePanel?.configure(); renderCertificates(); renderSettings(); window.rustProxyPages?.render(); window.rustProxyAclProfiles?.render(); window.rustProxyVhosts?.render(); window.rustProxyWebServer?.render(); window.rustProxyDocker?.render(); window.rustProxyRedirects?.render(); window.rustProxyNotFound?.render(); window.rustProxyStreams?.render(); const config = envelope.config; if (!settingsDirty.has("config-editor")) $("config-editor").value = JSON.stringify(config, null, 2);
$("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);
@@ -324,7 +325,7 @@ function authReturnDestination() {
}
function lock(notify = true) {
const destination = pendingAuthDestination || authReturnDestination(), navigating = typeof location !== "undefined" && (location.protocol === "http:" || location.protocol === "https:");
pendingAuthDestination = destination; if (notify) adminAuthChannel?.postMessage("locked"); $("api-docs").hidden = true; resetAdminPreferences(); window.rustProxyCertificates?.reset(); window.rustProxySettings?.reset(); window.rustProxyCachePanel?.reset(); window.rustProxyTransformPanel?.reset(); window.rustProxyAcme?.reset(); window.rustProxyIntegrations?.reset(); window.rustProxyLogs?.reset(); $("logs-open").hidden = true; window.rustProxyAclProfiles?.reset(); window.rustProxyVhosts?.reset(); window.rustProxyDocker?.reset(); window.rustProxyRedirects?.reset(); window.rustProxyNotFound?.reset(); window.rustProxyStreams?.reset(); authEpoch++; stopLive(); csrf = ""; authenticated = false; sessionInfo = null; envelope = null; certificateInventory = new Map(); previous = null; samples = []; protocolSamples = { h1: [], h2: [], h3: [] }; transformSamples = []; systemSamples = []; connectionOpenSamples = []; connectionExchangeSamples = []; networkInterfaceSamples.clear(); lastLogs = null; latestStatus = null; clearSettingsDirty(); connected = false; busy = false; $("workspace").hidden = true; $("login").hidden = navigating; $("logout").hidden = true; $("password").value = ""; $("password-confirm").value = ""; $("setup-token").value = ""; $("password-form").reset(); $("config-editor").value = ""; $("logs").replaceChildren(); $("audit-rows").replaceChildren(); authMessage("");
pendingAuthDestination = destination; if (notify) adminAuthChannel?.postMessage("locked"); $("api-docs").hidden = true; resetAdminPreferences(); window.rustProxyCertificates?.reset(); window.rustProxySettings?.reset(); window.rustProxyTrafficPanel?.reset(); window.rustProxyCachePanel?.reset(); window.rustProxyTransformPanel?.reset(); window.rustProxyAcme?.reset(); window.rustProxyIntegrations?.reset(); window.rustProxyLogs?.reset(); $("logs-open").hidden = true; window.rustProxyAclProfiles?.reset(); window.rustProxyVhosts?.reset(); window.rustProxyWebServer?.reset(); window.rustProxyDocker?.reset(); window.rustProxyRedirects?.reset(); window.rustProxyNotFound?.reset(); window.rustProxyStreams?.reset(); authEpoch++; stopLive(); csrf = ""; authenticated = false; sessionInfo = null; envelope = null; certificateInventory = new Map(); previous = null; samples = []; protocolSamples = { h1: [], h2: [], h3: [] }; transformSamples = []; systemSamples = []; connectionOpenSamples = []; connectionExchangeSamples = []; networkInterfaceSamples.clear(); lastLogs = null; latestStatus = null; clearSettingsDirty(); connected = false; busy = false; $("workspace").hidden = true; $("login").hidden = navigating; $("logout").hidden = true; $("password").value = ""; $("password-confirm").value = ""; $("setup-token").value = ""; $("password-form").reset(); $("config-editor").value = ""; $("logs").replaceChildren(); $("audit-rows").replaceChildren(); authMessage("");
for (const id of ["certificate-list", "host-policy-list"]) { $(id)?.replaceChildren(); }
$("workspace").querySelectorAll("form").forEach(form => form.reset());
$("workspace").querySelectorAll("input:not([type=checkbox]):not([type=radio]),textarea").forEach(input => { input.value = ""; });
@@ -407,7 +408,34 @@ async function revalidateSession() {
window.addEventListener("pageshow", event => { if (event.persisted) void revalidateSession(); });
document.addEventListener("visibilitychange", () => { if (document.hidden) stopLive(); else if (authenticated) { void revalidateSession(); } });
setInterval(() => { if (!document.hidden) void refresh(); }, 5000);
(async () => { try { await loadLanguages(); translate(); const status = await api("/api/auth/status"); setupRequired = status.setup_required; syncAuthForm(); if (!setupRequired) { try { await activate(await api("/api/auth/session")); } catch (e) { if (e.status === 401) { location.replace(`${location.pathname}${location.search}`); return; } throw e; } } } catch (e) { $("login").hidden = false; document.documentElement.classList.remove("app-loading"); toast(e.message, true); } })();
function showStartupFailure(error) {
// index.html is served only after the server has accepted the browser session.
// A module/configuration error must therefore never masquerade as a logout.
$("login").hidden = true;
$("workspace").hidden = false;
$("logout").hidden = !authenticated;
document.documentElement.classList.remove("app-loading");
toast(error?.message || String(error), true);
}
(async () => {
try {
// Revalidate the session first. The anonymous document has its own login.js;
// app.js belongs to the authenticated shell and must not fall back to its
// legacy embedded sign-in form when another startup step fails.
const info = await api("/api/auth/session");
await loadLanguages();
setupRequired = false;
translate();
syncAuthForm();
await activate(info);
} catch (e) {
if (e.status === 401) {
location.replace(`${location.pathname}${location.search}`);
return;
}
showStartupFailure(e);
}
})();
function renderCertificates() {
if (!envelope) return;
@@ -778,7 +806,7 @@ function renderSettings() {
if (!settingsDirty.has("response-form")) {
const server = config.server_header || {}, compression = config.compression || {}, diagnostics = config.diagnostic_headers || {};
$("expose-request-id").checked = !!diagnostics.request_id;
$("server-mode").value = server.mode || "preserve"; $("server-value").value = server.value || "RustProxy/v0.24.10";
$("server-mode").value = server.mode || "preserve"; $("server-value").value = server.value || "RustProxy/v0.25.0";
for (const name of ["gzip", "br", "zstd", "deflate"]) $(`compress-${name}`).checked = !!compression[name];
$("compress-min").value = compression.min_bytes ?? 1024; syncServerMode();
}
@@ -787,6 +815,9 @@ function renderSettings() {
$("cache-transient-memory").value = (c.transient_max_bytes ?? 67108864) / 1048576;
$("cache-object").value = (c.max_object_bytes ?? 16777216) / 1024; $("cache-entries").value = c.max_entries ?? 4096;
$("cache-eviction-limit").value = c.hot_eviction_max_objects ?? 64;
$("cache-auto-trim-enabled").checked = c.hot_auto_trim_enabled !== false;
$("cache-auto-trim-after").value = c.hot_auto_trim_after_seconds ?? 300;
$("cache-auto-trim-target").value = c.hot_auto_trim_target_percent ?? 80;
$("cache-cold-enabled").checked = !!c.cold_enabled; $("cache-cold-path").value = c.cold_path || "cache/cold";
$("cache-cold-bytes").value = (c.cold_max_bytes ?? 4294967296) / 1048576; $("cache-cold-entries").value = c.cold_max_entries ?? 50000; $("cache-cold-after").value = c.cold_after_seconds ?? 300;
$("cache-cold-queue-bytes").value = (c.cold_queue_max_bytes ?? 33554432) / 1048576;
@@ -822,6 +853,8 @@ function renderSettings() {
}
if (!settingsDirty.has("logging-form")) {
const l = config.logging || {}, file = l.file || {}, logstash = l.logstash || {}, loki = l.loki || {}, format = l.format || {};
$("log-capture-user-agent").checked = l.capture_user_agent !== false;
$("log-capture-country").checked = l.capture_country === true;
$("log-file-enabled").checked = !!file.enabled; $("log-file-per-vhost").checked = !!file.per_vhost; $("log-file-path").value = file.path || "/var/log/rustproxy/access.jsonl"; $("log-file-directory").value = file.directory || "/var/log/rustproxy/vhosts"; syncLoggingFileMode();
$("log-logstash-enabled").checked = !!logstash.enabled; $("log-logstash-address").value = logstash.address || "127.0.0.1:5044";
$("log-loki-enabled").checked = !!loki.enabled; $("log-loki-url").value = loki.url || "http://127.0.0.1:3100/loki/api/v1/push"; $("log-loki-token").value = loki.bearer_token_env || "";
@@ -1046,7 +1079,7 @@ bindSettingsForm("response-form", next => {
next.compression = { min_bytes: Number($("compress-min").value) }; for (const name of ["gzip", "br", "zstd", "deflate"]) next.compression[name] = $(`compress-${name}`).checked;
}, "response", next => ({ server_header: next.server_header, diagnostic_headers: next.diagnostic_headers, compression: next.compression }));
bindSettingsForm("cache-form", next => {
next.cache = { ...(next.cache || {}), enabled: $("cache-enabled").checked, memory_preset: selectedCacheMemoryPreset, max_bytes: Math.round(Number($("cache-memory").value) * 1048576), transient_max_bytes: Math.round(Number($("cache-transient-memory").value) * 1048576), max_object_bytes: Number($("cache-object").value) * 1024, max_entries: Number($("cache-entries").value), hot_eviction_max_objects: Number($("cache-eviction-limit").value), cold_enabled: $("cache-cold-enabled").checked, cold_preset: selectedCacheColdPreset, cold_path: $("cache-cold-path").value.trim(), cold_max_bytes: Math.round(Number($("cache-cold-bytes").value) * 1048576), cold_max_entries: Number($("cache-cold-entries").value), cold_queue_max_bytes: Math.round(Number($("cache-cold-queue-bytes").value) * 1048576), cold_gc_interval_seconds: Number($("cache-cold-gc-interval").value), cold_gc_max_delete: Number($("cache-cold-gc-max-delete").value), cold_after_seconds: Number($("cache-cold-after").value), cold_max_hot_seconds: Number($("cache-cold-max-hot").value), cold_on_idle: $("cache-cold-on-idle").checked, cold_on_age: $("cache-cold-on-age").checked, cold_on_lru: $("cache-cold-on-lru").checked, cold_on_shutdown: $("cache-cold-on-shutdown").checked, max_ttl_seconds: Number($("cache-ttl").value), default_ttl_seconds: Number($("cache-default-ttl").value), collapse_enabled: $("cache-collapse-enabled").checked, collapse_wait_timeout_ms: Number($("cache-collapse-timeout").value), collapse_timeout_fallback: $("cache-collapse-fallback").value, stale_if_error_timeout: $("cache-sie-timeout").checked, stale_if_error_connection: $("cache-sie-connection").checked, stale_if_error_dns: $("cache-sie-dns").checked, stale_if_error_tls: $("cache-sie-tls").checked, stale_if_error_statuses: $("cache-sie-statuses").value.split(/[\s,]+/).filter(Boolean).map(Number), proactive_refresh_enabled: $("cache-proactive-enabled").checked, proactive_refresh_before_expiry_seconds: Number($("cache-proactive-before").value), proactive_refresh_percent: Number($("cache-proactive-percent").value), proactive_refresh_max_inflight: Number($("cache-proactive-inflight").value), bypass_query: $("cache-query").checked, bypass_prefixes: $("cache-prefixes").value.split(/\r?\n/).map(v => v.trim()).filter(Boolean), static_ignore_cookies: $("cache-static-cookie").checked, static_extensions: $("cache-static-extensions").value.split(/[\n,]+/).map(v => v.trim().replace(/^\./, "").toLowerCase()).filter(Boolean), expose_status: $("cache-status-header").checked, expose_hits: $("cache-hits-header").checked, expose_cache_status: $("cache-rfc-header").checked, expose_resident_age: $("cache-resident-age-header").checked };
next.cache = { ...(next.cache || {}), enabled: $("cache-enabled").checked, memory_preset: selectedCacheMemoryPreset, max_bytes: Math.round(Number($("cache-memory").value) * 1048576), transient_max_bytes: Math.round(Number($("cache-transient-memory").value) * 1048576), max_object_bytes: Number($("cache-object").value) * 1024, max_entries: Number($("cache-entries").value), hot_eviction_max_objects: Number($("cache-eviction-limit").value), hot_auto_trim_enabled: $("cache-auto-trim-enabled").checked, hot_auto_trim_after_seconds: Number($("cache-auto-trim-after").value), hot_auto_trim_target_percent: Number($("cache-auto-trim-target").value), cold_enabled: $("cache-cold-enabled").checked, cold_preset: selectedCacheColdPreset, cold_path: $("cache-cold-path").value.trim(), cold_max_bytes: Math.round(Number($("cache-cold-bytes").value) * 1048576), cold_max_entries: Number($("cache-cold-entries").value), cold_queue_max_bytes: Math.round(Number($("cache-cold-queue-bytes").value) * 1048576), cold_gc_interval_seconds: Number($("cache-cold-gc-interval").value), cold_gc_max_delete: Number($("cache-cold-gc-max-delete").value), cold_after_seconds: Number($("cache-cold-after").value), cold_max_hot_seconds: Number($("cache-cold-max-hot").value), cold_on_idle: $("cache-cold-on-idle").checked, cold_on_age: $("cache-cold-on-age").checked, cold_on_lru: $("cache-cold-on-lru").checked, cold_on_shutdown: $("cache-cold-on-shutdown").checked, max_ttl_seconds: Number($("cache-ttl").value), default_ttl_seconds: Number($("cache-default-ttl").value), collapse_enabled: $("cache-collapse-enabled").checked, collapse_wait_timeout_ms: Number($("cache-collapse-timeout").value), collapse_timeout_fallback: $("cache-collapse-fallback").value, stale_if_error_timeout: $("cache-sie-timeout").checked, stale_if_error_connection: $("cache-sie-connection").checked, stale_if_error_dns: $("cache-sie-dns").checked, stale_if_error_tls: $("cache-sie-tls").checked, stale_if_error_statuses: $("cache-sie-statuses").value.split(/[\s,]+/).filter(Boolean).map(Number), proactive_refresh_enabled: $("cache-proactive-enabled").checked, proactive_refresh_before_expiry_seconds: Number($("cache-proactive-before").value), proactive_refresh_percent: Number($("cache-proactive-percent").value), proactive_refresh_max_inflight: Number($("cache-proactive-inflight").value), bypass_query: $("cache-query").checked, bypass_prefixes: $("cache-prefixes").value.split(/\r?\n/).map(v => v.trim()).filter(Boolean), static_ignore_cookies: $("cache-static-cookie").checked, static_extensions: $("cache-static-extensions").value.split(/[\n,]+/).map(v => v.trim().replace(/^\./, "").toLowerCase()).filter(Boolean), expose_status: $("cache-status-header").checked, expose_hits: $("cache-hits-header").checked, expose_cache_status: $("cache-rfc-header").checked, expose_resident_age: $("cache-resident-age-header").checked };
}, "cache");
bindSettingsForm("transform-form", next => {
next.transform = {
@@ -1063,14 +1096,14 @@ bindSettingsForm("transform-form", next => {
max_inflight: Number($("transform-max-inflight").value)
};
}, "transform");
const LOGGING_SOURCE_FIELDS = ["vhost", "route", "cache_effective_enabled", "transform_effective_enabled", "backend", "id", "time_ms", "method", "host", "path", "http_version", "transport", "websocket_bootstrap", "websocket_phase", "websocket_error", "h3_reset_code", "upstream_status", "upstream_error", "client_ip", "status", "duration_ms", "request_bytes", "response_bytes", "outcome", "cache", "cache_reason"];
const LOGGING_SOURCE_FIELDS = ["vhost", "route", "cache_effective_enabled", "transform_effective_enabled", "backend", "id", "time_ms", "method", "host", "path", "http_version", "transport", "websocket_bootstrap", "websocket_phase", "websocket_error", "h3_reset_code", "upstream_status", "upstream_error", "client_ip", "user_agent", "country", "status", "duration_ms", "request_bytes", "response_bytes", "outcome", "cache", "cache_reason"];
const LOGGING_PRESETS = {
standard: {},
minimal: { time_ms: "time_ms", method: "method", host: "host", path: "path", status: "status", duration_ms: "duration_ms" },
nginx: { time_ms: "time_ms", remote_addr: "client_ip", request_method: "method", host: "host", request_uri: "path", status: "status", request_time_ms: "duration_ms", request_length: "request_bytes", bytes_sent: "response_bytes", upstream: "backend", cache: "cache", cache_reason: "cache_reason" },
ecs: { "event.created_ms": "time_ms", "client.address": "client_ip", "http.request.method": "method", "server.address": "host", "url.path": "path", "http.response.status_code": "status", "event.duration_ms": "duration_ms", "http.request.body.bytes": "request_bytes", "http.response.body.bytes": "response_bytes", "service.target.name": "backend", "event.outcome": "outcome", "cache.result": "cache", "cache.reason": "cache_reason" }
nginx: { time_ms: "time_ms", remote_addr: "client_ip", http_user_agent: "user_agent", country: "country", request_method: "method", host: "host", request_uri: "path", status: "status", request_time_ms: "duration_ms", request_length: "request_bytes", bytes_sent: "response_bytes", upstream: "backend", cache: "cache", cache_reason: "cache_reason" },
ecs: { "event.created_ms": "time_ms", "client.address": "client_ip", "user_agent.original": "user_agent", "client.geo.country_iso_code": "country", "http.request.method": "method", "server.address": "host", "url.path": "path", "http.response.status_code": "status", "event.duration_ms": "duration_ms", "http.request.body.bytes": "request_bytes", "http.response.body.bytes": "response_bytes", "service.target.name": "backend", "event.outcome": "outcome", "cache.result": "cache", "cache.reason": "cache_reason" }
};
const LOGGING_PREVIEW_VALUES = { vhost: "app", route: "root", cache_effective_enabled: true, transform_effective_enabled: false, backend: "origin-1", id: 1842, time_ms: 1769328123456, method: "CONNECT", host: "app.example", path: "/socket.io/", http_version: "3", transport: "h3", websocket_bootstrap: "h3_extended_connect", websocket_phase: "tunnel_closed", websocket_error: null, h3_reset_code: null, upstream_status: 101, client_ip: "203.0.113.24", status: 200, duration_ms: 12, request_bytes: 0, response_bytes: 4821, outcome: "websocket_closed", cache: "BYPASS", cache_reason: "request_upgrade" };
const LOGGING_PREVIEW_VALUES = { vhost: "app", route: "root", cache_effective_enabled: true, transform_effective_enabled: false, backend: "origin-1", id: 1842, time_ms: 1769328123456, method: "CONNECT", host: "app.example", path: "/socket.io/", http_version: "3", transport: "h3", websocket_bootstrap: "h3_extended_connect", websocket_phase: "tunnel_closed", websocket_error: null, h3_reset_code: null, upstream_status: 101, client_ip: "203.0.113.24", user_agent: "Mozilla/5.0 RustProxy-Test", country: "PL", status: 200, duration_ms: 12, request_bytes: 0, response_bytes: 4821, outcome: "websocket_closed", cache: "BYPASS", cache_reason: "request_upgrade" };
function parseJsonObjectValue(value, target = null) { let parsed; try { parsed = JSON.parse(value || "{}"); } catch { throw fieldError(target, t("loggingJsonInvalid")); } if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") throw fieldError(target, t("loggingJsonInvalid")); return parsed; }
function loggingMappingKey(mapping) { return JSON.stringify(Object.entries(mapping || {}).sort(([a], [b]) => a.localeCompare(b))); }
function detectLoggingPreset(mapping) { const key = loggingMappingKey(mapping); for (const [name, preset] of Object.entries(LOGGING_PRESETS)) if (loggingMappingKey(preset) === key) return name; return "custom"; }
@@ -1124,6 +1157,8 @@ const selectLoggingSection = next => ({ logging: next.logging, error_log: next.e
bindSettingsForm("logging-form", next => {
const parseObject = id => parseJsonObjectValue($(id).value, $(id));
next.logging = {
capture_user_agent: $("log-capture-user-agent").checked,
capture_country: $("log-capture-country").checked,
file: { enabled: $("log-file-enabled").checked, path: $("log-file-path").value.trim(), per_vhost: $("log-file-per-vhost").checked, directory: $("log-file-directory").value.trim() },
logstash: { enabled: $("log-logstash-enabled").checked, address: $("log-logstash-address").value.trim() },
loki: { enabled: $("log-loki-enabled").checked, url: $("log-loki-url").value.trim(), bearer_token_env: $("log-loki-token").value.trim() || null, labels: parseObject("log-loki-labels") },
+168 -1
View File
@@ -175,4 +175,171 @@ main {
.docs-confirm-actions .docs-confirm-primary {
background: #101923;
color: #fff
}
}
.docs-tabs {
display: flex;
gap: 8px;
margin: 16px 0;
border-bottom: 1px solid #c8d0d8
}
.docs-tabs button {
border: 1px solid #c8d0d8;
border-bottom: 0;
background: #e9edf1;
padding: 9px 15px;
cursor: pointer
}
.docs-tabs button.active {
background: #fff;
font-weight: 700
}
.docs-api-controls {
background: #fff;
border: 1px solid #d7dde3;
padding: 12px 16px;
margin-bottom: 16px
}
.docs-api-controls label {
display: inline-flex;
align-items: center;
gap: 7px
}
.reader-layout {
display: grid;
grid-template-columns: minmax(220px, 300px) minmax(0, 1fr);
gap: 18px;
align-items: start
}
.reader-sidebar,
.markdown-reader {
background: #fff;
border: 1px solid #d7dde3
}
.reader-sidebar {
position: sticky;
top: 12px;
max-height: calc(100vh - 140px);
overflow: auto;
padding: 12px
}
.reader-sidebar label {
display: block;
font-weight: 700;
margin-bottom: 6px
}
.reader-sidebar input {
width: 100%;
padding: 8px 9px;
border: 1px solid #aeb8c2;
margin-bottom: 12px
}
.reader-group {
margin: 14px 0 6px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: .08em;
color: #5f6c79
}
.reader-list button {
width: 100%;
display: block;
text-align: left;
border: 0;
background: transparent;
padding: 7px 8px;
cursor: pointer;
color: #24313e
}
.reader-list button:hover,
.reader-list button.active {
background: #eef2f5
}
.reader-list button small {
display: block;
color: #6d7883;
overflow-wrap: anywhere
}
.markdown-reader {
min-height: 60vh;
padding: 24px 28px;
overflow-wrap: anywhere
}
.markdown-reader h1 {
margin-top: 0;
font-size: 28px
}
.markdown-reader h2 {
margin-top: 30px;
font-size: 21px;
border-bottom: 1px solid #e0e5ea;
padding-bottom: 6px
}
.markdown-reader h3 {
margin-top: 24px;
font-size: 17px
}
.markdown-reader p,
.markdown-reader li {
line-height: 1.6
}
.markdown-reader pre {
overflow: auto;
padding: 14px;
background: #101923;
color: #f3f6f8
}
.markdown-reader code {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace
}
.markdown-reader :not(pre) > code {
padding: 2px 4px;
background: #edf1f4
}
.markdown-reader blockquote {
margin-left: 0;
padding-left: 14px;
border-left: 3px solid #d97743;
color: #52606d
}
.reader-empty {
color: #687581;
padding: 10px 8px
}
@media(max-width:820px) {
.reader-layout {
grid-template-columns: 1fr
}
.reader-sidebar {
position: static;
max-height: 320px
}
.markdown-reader {
padding: 18px
}
}
+27 -15
View File
@@ -1,36 +1,48 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>RustProxy / API</title>
<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>
</head>
<body>
<header class="docs-bar"><a class="docs-name" href="/">RustProxy</a>
<h1 data-i18n="docsTitle">API documentation</h1>
<a href="/" data-i18n="docsPanel">Back to panel</a><button type="button" id="docs-logout"
data-i18n="docsLogout">Sign out</button>
<h1 data-i18n="docsTitle">Documentation</h1>
<a href="/" data-i18n="docsPanel">Back to panel</a><button type="button" id="docs-logout" data-i18n="docsLogout">Sign out</button>
</header>
<main>
<section class="docs-session">
<p id="docs-session" role="status"></p>
<p data-i18n="docsSession">Uses your administrator session. Writes require CSRF protection.</p>
<label><input type="checkbox" id="docs-writes"><span data-i18n="docsWrites">Enable write
operations</span></label>
<p data-i18n="docsReadOnly">Read-only execution by default. SSE streams and login/setup are not executed
here.</p>
<p data-i18n="docsSession">Uses your administrator session. API writes require CSRF protection.</p>
</section>
<p id="docs-error" role="alert"></p>
<div id="swagger-ui"></div>
<p><a href="/docs/licenses.txt" data-i18n="docsLicenses">Swagger UI licenses</a></p>
<nav class="docs-tabs" aria-label="Documentation sections">
<button type="button" id="docs-tab-reader" class="active" data-docs-tab="reader" data-i18n="docsReader">Reader</button>
<button type="button" id="docs-tab-api" data-docs-tab="api" data-i18n="docsApi">API</button>
</nav>
<section id="docs-reader-pane" class="docs-pane">
<div class="reader-layout">
<aside class="reader-sidebar">
<label for="docs-reader-search" data-i18n="docsReaderSearch">Search documentation</label>
<input id="docs-reader-search" type="search" autocomplete="off" data-i18n-placeholder="docsReaderSearchPlaceholder" placeholder="Search title or path">
<div id="docs-reader-list" class="reader-list"></div>
</aside>
<article id="docs-reader-content" class="markdown-reader" aria-live="polite"></article>
</div>
</section>
<section id="docs-api-pane" class="docs-pane" hidden>
<section class="docs-api-controls">
<label><input type="checkbox" id="docs-writes"><span data-i18n="docsWrites">Enable write operations</span></label>
<p data-i18n="docsReadOnly">Read-only execution by default. SSE streams and login/setup are not executed here.</p>
</section>
<div id="swagger-ui"></div>
<p><a href="/docs/licenses.txt" data-i18n="docsLicenses">Swagger UI licenses</a></p>
</section>
</main>
</body>
</html>
</html>
+121 -13
View File
@@ -1,9 +1,11 @@
"use strict";
// Fixed same-origin schema + browser session; never query-configurable/CDN-based.
(() => {
const element = id => document.getElementById(id); let session = null, spec = null, ui = null, translations = {}, fallback = {}, epoch = 0;
const element = id => document.getElementById(id);
let session = null, spec = null, ui = null, translations = {}, fallback = {}, epoch = 0;
let documents = [], docsByPath = new Map(), currentDocument = null, activeTab = "reader";
let channel = null; try { channel = new BroadcastChannel("rustproxy-admin-session"); } catch { }
const t = key => translations[key] ?? fallback[key] ?? key;
function docsConfirm(message) {
return new Promise(resolve => {
const dialog = document.createElement("dialog"); dialog.className = "docs-confirm";
@@ -18,11 +20,16 @@
dialog.showModal(); cancel.focus();
});
}
function clear() { epoch++; session = null; spec = null; ui = null; element("docs-writes").checked = false; element("swagger-ui").replaceChildren(); element("docs-session").textContent = ""; }
function clear() {
epoch++; session = null; spec = null; ui = null; documents = []; docsByPath = new Map(); currentDocument = null;
element("docs-writes").checked = false; element("swagger-ui").replaceChildren(); element("docs-reader-list").replaceChildren(); element("docs-reader-content").replaceChildren(); element("docs-session").textContent = "";
}
function expired() { clear(); element("docs-error").replaceChildren(); const link = document.createElement("a"); link.href = "/?next=docs"; link.textContent = t("docsExpired"); element("docs-error").append(link); }
channel?.addEventListener("message", event => { if (event.data === "locked") expired(); });
async function get(path) { const response = await fetch(path, { credentials: "same-origin", cache: "no-store", redirect: "error" }); if (!response.ok) { if (response.status === 401) expired(); throw new Error(t("docsError")); } return response.json(); }
async function checkSession() { const mine = epoch; const info = await get("/api/auth/session"); if (mine !== epoch || !session || info.csrf_token !== session.csrf_token || info.username !== session.username) { expired(); throw new Error(t("docsExpired")); } return info; }
async function requestInterceptor(request) {
if (!session || !spec) throw new Error(t("docsExpired"));
const url = new URL(request.url, location.origin), method = (request.method || "GET").toLowerCase();
@@ -36,12 +43,9 @@
}
return request;
}
function render() {
if (!session || !spec) return;
// Disable the online validator and URL/config overrides. No persisted auth or
// externally supplied schema can send cookies or CSRF tokens elsewhere.
// These mandatory wire headers are supplied by the browser/interceptor, not
// typed into Swagger fields. Keep the downloadable specification accurate.
function renderApi() {
if (!session || !spec || typeof SwaggerUIBundle !== "function") return;
const displaySpec = structuredClone(spec);
for (const path of Object.values(displaySpec.paths)) for (const [method, operation] of Object.entries(path)) {
if (["get", "post", "put", "delete", "patch", "head", "options"].includes(method) && operation.parameters)
@@ -55,17 +59,121 @@
requestInterceptor, responseInterceptor: response => { if (response.status === 401) expired(); return response; }
});
}
element("docs-writes").addEventListener("change", render);
function normalizeDocPath(base, target) {
const clean = target.split("#", 1)[0].split("?", 1)[0];
if (!clean) return base;
const stack = clean.startsWith("/") ? [] : base.split("/").slice(0, -1);
for (const part of clean.replace(/^\//, "").split("/")) {
if (!part || part === ".") continue;
if (part === "..") { if (stack.length) stack.pop(); continue; }
stack.push(part);
}
return stack.join("/");
}
function appendInline(parent, text, basePath) {
const pattern = /(`[^`]+`|\[[^\]]+\]\([^)]+\))/g; let offset = 0;
for (const match of text.matchAll(pattern)) {
if (match.index > offset) parent.append(document.createTextNode(text.slice(offset, match.index)));
const token = match[0];
if (token.startsWith("`")) {
const code = document.createElement("code"); code.textContent = token.slice(1, -1); parent.append(code);
} else {
const parsed = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(token); const anchor = document.createElement("a");
anchor.textContent = parsed[1]; const target = parsed[2].trim();
if (/^https?:\/\//i.test(target)) { anchor.href = target; anchor.target = "_blank"; anchor.rel = "noreferrer"; }
else if (target.startsWith("#")) { anchor.href = target; }
else {
const resolved = normalizeDocPath(basePath, target);
if (docsByPath.has(resolved)) {
anchor.href = `#doc=${encodeURIComponent(resolved)}`;
anchor.addEventListener("click", event => { event.preventDefault(); selectDocument(resolved, true); });
} else { anchor.href = target; }
}
parent.append(anchor);
}
offset = match.index + token.length;
}
if (offset < text.length) parent.append(document.createTextNode(text.slice(offset)));
}
function renderMarkdown(documentInfo) {
const target = element("docs-reader-content"); target.replaceChildren();
const lines = documentInfo.content.replaceAll("\r\n", "\n").split("\n");
let code = null, list = null, paragraph = [];
const flushParagraph = () => { if (!paragraph.length) return; const p = document.createElement("p"); appendInline(p, paragraph.join(" ").trim(), documentInfo.path); target.append(p); paragraph = []; };
const closeList = () => { list = null; };
for (const line of lines) {
if (line.startsWith("```")) {
flushParagraph(); closeList();
if (code) { target.append(code.pre); code = null; }
else { const pre = document.createElement("pre"), node = document.createElement("code"); pre.append(node); code = { pre, node, lines: [] }; }
continue;
}
if (code) { code.lines.push(line); code.node.textContent = code.lines.join("\n"); continue; }
const heading = /^(#{1,4})\s+(.+)$/.exec(line);
if (heading) { flushParagraph(); closeList(); const h = document.createElement(`h${heading[1].length}`); appendInline(h, heading[2], documentInfo.path); target.append(h); continue; }
const bullet = /^\s*[-*]\s+(.+)$/.exec(line), ordered = /^\s*\d+\.\s+(.+)$/.exec(line);
if (bullet || ordered) {
flushParagraph(); const tag = ordered ? "ol" : "ul";
if (!list || list.tagName.toLowerCase() !== tag) { list = document.createElement(tag); target.append(list); }
const item = document.createElement("li"); appendInline(item, (bullet || ordered)[1], documentInfo.path); list.append(item); continue;
}
if (/^\s*>\s?/.test(line)) { flushParagraph(); closeList(); const q = document.createElement("blockquote"); appendInline(q, line.replace(/^\s*>\s?/, ""), documentInfo.path); target.append(q); continue; }
if (/^\s*---+\s*$/.test(line)) { flushParagraph(); closeList(); target.append(document.createElement("hr")); continue; }
if (!line.trim()) { flushParagraph(); closeList(); continue; }
closeList(); paragraph.push(line.trim());
}
flushParagraph(); if (code) target.append(code.pre);
}
const categoryLabel = category => t({ project: "docsCategoryProject", guides: "docsCategoryGuides", technical: "docsCategoryTechnical", audits: "docsCategoryAudits", historical: "docsCategoryHistorical" }[category] || category);
function renderReaderList() {
const query = (element("docs-reader-search").value || "").trim().toLowerCase();
const root = element("docs-reader-list"); root.replaceChildren();
const matches = documents.filter(item => !query || `${item.title} ${item.path}`.toLowerCase().includes(query));
for (const category of ["project", "guides", "technical", "audits", "historical"]) {
const group = matches.filter(item => item.category === category); if (!group.length) continue;
const heading = document.createElement("div"); heading.className = "reader-group"; heading.textContent = categoryLabel(category); root.append(heading);
for (const item of group) {
const button = document.createElement("button"); button.type = "button"; button.classList.toggle("active", item.path === currentDocument?.path);
const title = document.createElement("span"); title.textContent = item.title; const path = document.createElement("small"); path.textContent = item.path;
button.append(title, path); button.addEventListener("click", () => selectDocument(item.path, true)); root.append(button);
}
}
if (!root.children.length) { const empty = document.createElement("div"); empty.className = "reader-empty"; empty.textContent = t("docsReaderEmpty"); root.append(empty); }
}
function selectDocument(path, updateHash = false) {
const item = docsByPath.get(path) || docsByPath.get("docs/README.md") || documents[0]; if (!item) return;
currentDocument = item; renderReaderList(); renderMarkdown(item);
if (updateHash) history.replaceState(null, "", `#doc=${encodeURIComponent(item.path)}`);
}
function selectTab(tab) {
activeTab = tab === "api" ? "api" : "reader";
element("docs-reader-pane").hidden = activeTab !== "reader"; element("docs-api-pane").hidden = activeTab !== "api";
element("docs-tab-reader").classList.toggle("active", activeTab === "reader"); element("docs-tab-api").classList.toggle("active", activeTab === "api");
if (activeTab === "api") renderApi();
}
element("docs-writes").addEventListener("change", renderApi);
element("docs-reader-search").addEventListener("input", renderReaderList);
element("docs-tab-reader").addEventListener("click", () => selectTab("reader"));
element("docs-tab-api").addEventListener("click", () => selectTab("api"));
element("docs-logout").addEventListener("click", async () => { if (!session) return; try { await checkSession(); const response = await fetch("/api/auth/logout", { method: "POST", credentials: "same-origin", cache: "no-store", redirect: "error", headers: { "X-RustProxy-Request": "1", "X-CSRF-Token": session.csrf_token } }); if (!response.ok && response.status !== 401) throw new Error(t("docsError")); channel?.postMessage("locked"); expired(); } catch (error) { element("docs-error").textContent = error.message; } });
async function boot() {
const mine = ++epoch; try {
const info = await get("/api/auth/session"); if (mine !== epoch) return;
const [document, bundle] = await Promise.all([get("/docs/openapi.json"), get("/assets/langs.json")]); if (mine !== epoch) return;
const [documentSpec, bundle, docBundle] = await Promise.all([get("/docs/openapi.json"), get("/assets/langs.json"), get("/docs/content.json")]); if (mine !== epoch) return;
const packs = bundle.languages || []; fallback = packs.find(p => p.id === bundle.default)?.translations || {}; let selected = null; try { selected = localStorage.getItem("rp-language"); } catch { }
const pack = packs.find(p => p.id === selected) || packs.find(p => p.id === bundle.default); translations = pack?.translations || {};
window.document.documentElement.lang = pack?.meta.locale || "und"; window.document.documentElement.dir = pack?.meta.direction || "ltr";
window.document.querySelectorAll("[data-i18n]").forEach(n => n.textContent = t(n.dataset.i18n));
session = info; spec = document; element("docs-session").textContent = `${t("docsAccount")}: ${session.username}`; element("docs-error").textContent = ""; render();
window.document.querySelectorAll("[data-i18n]").forEach(n => n.textContent = t(n.dataset.i18n)); window.document.querySelectorAll("[data-i18n-placeholder]").forEach(n => n.placeholder = t(n.dataset.i18nPlaceholder));
session = info; spec = documentSpec; documents = Array.isArray(docBundle.documents) ? docBundle.documents : []; docsByPath = new Map(documents.map(item => [item.path, item]));
element("docs-session").textContent = `${t("docsAccount")}: ${session.username}`; element("docs-error").textContent = "";
const hash = new URLSearchParams(location.hash.replace(/^#/, "")).get("doc"); selectDocument(hash && docsByPath.has(hash) ? hash : "docs/README.md"); selectTab("reader");
} catch (error) { if (mine === epoch) { clear(); element("docs-error").textContent = error.message; } }
}
document.addEventListener("visibilitychange", () => { if (!document.hidden && session) void checkSession().catch(() => expired()); });
+276 -46
View File
@@ -12,10 +12,12 @@
<script defer="" src="/assets/management.js"></script>
<script defer="" src="/assets/integrations.js"></script>
<script defer="" src="/assets/vhosts.js"></script>
<script defer="" src="/assets/web-server.js"></script>
<script defer="" src="/assets/docker.js"></script>
<script defer="" src="/assets/streams.js"></script>
<script defer="" src="/assets/redirects.js"></script>
<script defer="" src="/assets/host404.js"></script>
<script defer="" src="/assets/traffic-panel.js"></script>
<script defer="" src="/assets/cache-panel.js"></script>
<script defer="" src="/assets/transform-panel.js"></script>
<script defer="" src="/assets/acme.js"></script>
@@ -32,19 +34,21 @@
<button class="nav active" data-panel="overview"><span>01</span><b
data-i18n="overview">Overview</b></button>
<button class="nav" data-panel="vhosts"><span>02</span><b data-i18n="vhosts">Vhosts</b></button>
<button class="nav" data-panel="docker-vhosts"><span>03</span><b data-i18n="docker-vhosts">Docker
<button class="nav" data-panel="web-server"><span>03</span><b data-i18n="webServer">Web Server</b></button>
<button class="nav" data-panel="docker-vhosts"><span>04</span><b data-i18n="docker-vhosts">Docker
vhosts</b></button>
<button class="nav" data-panel="redirects"><span>04</span><b
<button class="nav" data-panel="redirects"><span>05</span><b
data-i18n="redirects">Redirects</b></button>
<button class="nav" data-panel="notfound"><span>05</span><b data-i18n="notfound">404 hosts</b></button>
<button class="nav" data-panel="streams"><span>06</span><b data-i18n="streams">Streams</b></button>
<button class="nav" data-panel="acl-profiles"><span>07</span><b data-i18n="aclProfiles">ACL
<button class="nav" data-panel="notfound"><span>06</span><b data-i18n="notfound">404 hosts</b></button>
<button class="nav" data-panel="streams"><span>07</span><b data-i18n="streams">Streams</b></button>
<button class="nav" data-panel="acl-profiles"><span>08</span><b data-i18n="aclProfiles">ACL
profiles</b></button>
<button class="nav" data-panel="tls"><span>08</span><b data-i18n="tls">TLS</b></button>
<button class="nav" data-panel="cache"><span>09</span><b data-i18n="cache">Cache</b></button>
<button class="nav" data-panel="transform"><span>10</span><b
<button class="nav" data-panel="tls"><span>09</span><b data-i18n="tls">TLS</b></button>
<button class="nav" data-panel="traffic"><span>10</span><b data-i18n="traffic">Traffic</b></button>
<button class="nav" data-panel="cache"><span>11</span><b data-i18n="cache">Cache</b></button>
<button class="nav" data-panel="transform"><span>12</span><b
data-i18n="transform">Transform</b></button>
<button class="nav" data-panel="settings"><span>11</span><b data-i18n="settings">Settings</b></button>
<button class="nav" data-panel="settings"><span>13</span><b data-i18n="settings">Settings</b></button>
</nav>
<div class="sidebar-bottom"><span class="small-label" data-i18n="runtime">RUNTIME</span>
<p>Rust / Tokio / Hyper<br />Axum / Tower / SQLite</p>
@@ -498,7 +502,7 @@
</div>
</article>
</div>
<article class="card pool-card">
<article class="card pool-card" id="backend-diagnostics-card">
<div class="card-heading">
<h2 data-i18n="vhHealthTitle">Backends</h2><button class="quiet" data-i18n="vhManage"
id="pool-open" type="button">Manage vhosts</button>
@@ -547,6 +551,23 @@
</div>
</section>
<section class="panel" hidden="" id="panel-web-server">
<p class="notice fine" data-i18n="webServerIntro">Create reusable local Web Services here, then attach them to a vhost or Custom location.</p>
<div class="vhost-layout">
<article class="card">
<div class="card-heading">
<h2 data-i18n="webServiceList">Web Services</h2>
<div class="vhost-list-tools">
<label class="sr-only" data-i18n="webServiceSearch" for="web-service-search">Search Web Services</label>
<input autocomplete="off" data-i18n-aria="webServiceSearch" data-i18n-placeholder="webServiceSearchPlaceholder" id="web-service-search" type="search" />
<button class="outline" data-i18n="webServiceNew" id="web-service-new" type="button">New Web Service</button>
</div>
</div>
<div class="entity-list" id="web-service-list"></div>
</article>
</div>
</section>
<section class="panel" hidden="" id="panel-docker-vhosts">
<p class="notice fine" data-i18n="dockerVhostsIntro">Published TCP ports discovered by configured
Docker agents. Map names and aliases, select the Docker host IP, and inspect/tune live backend
@@ -937,6 +958,45 @@
</div>
</div>
</section>
<section class="panel" hidden="" id="panel-traffic">
<div class="cache-toolbar card"><label><span data-i18n="logVhost">Vhost</span><select id="traffic-scope">
<option data-i18n="logAllVhosts" value="*">All vhosts</option>
</select></label><button class="quiet" data-i18n="cacheRefresh" id="traffic-refresh" type="button">Refresh</button>
<button class="quiet" data-i18n="settings" id="traffic-settings" type="button">Settings</button>
<div class="cache-toolbar-status"><span class="fine" id="traffic-panel-state" role="status"></span>
<label class="check cache-live-toggle"><input checked="" id="traffic-live" type="checkbox" />
<span data-i18n="cacheLive">Live (3 s)</span></label></div>
</div>
<div class="stats-grid cache-stats">
<article class="stat"><span data-i18n="requests">Requests</span><strong id="traffic-requests">-</strong><small data-i18n="trafficSinceStart">Since process start</small></article>
<article class="stat"><span data-i18n="errors">Errors</span><strong id="traffic-errors">-</strong><small id="traffic-error-ratio">-</small></article>
<article class="stat"><span data-i18n="trafficResponseBytes">Response bytes</span><strong id="traffic-response-bytes">-</strong><small id="traffic-request-bytes">-</small></article>
<article class="stat"><span data-i18n="trafficMeanLatency">Mean latency</span><strong id="traffic-latency">-</strong><small id="traffic-dropped">-</small></article>
</div>
<article class="card">
<div class="card-heading"><div><span class="small-label" data-i18n="trafficBoundedLabel">BOUNDED / ASYNC</span><h2 data-i18n="trafficDimensionsTitle">Traffic dimensions</h2></div></div>
<p class="fine" data-i18n="trafficApproxHelp">Top lists are approximate and bounded so high-cardinality traffic cannot grow proxy memory without limit.</p>
<p class="fine" id="traffic-capture-status"></p>
</article>
<div class="traffic-chart-grid">
<article class="card traffic-chart-card"><h3 class="subheading" data-i18n="trafficTopCountries">Top countries</h3><div class="traffic-donut-layout"><div class="traffic-donut" id="traffic-chart-countries" role="img"><span id="traffic-chart-countries-total">-</span></div><ol class="traffic-chart-legend" id="traffic-chart-countries-legend"></ol></div></article>
<article class="card traffic-chart-card"><h3 class="subheading" data-i18n="trafficTopUserAgents">Top User-Agents</h3><div class="traffic-donut-layout"><div class="traffic-donut" id="traffic-chart-user-agents" role="img"><span id="traffic-chart-user-agents-total">-</span></div><ol class="traffic-chart-legend" id="traffic-chart-user-agents-legend"></ol></div></article>
<article class="card traffic-chart-card"><h3 class="subheading" data-i18n="trafficTopClientIps">Top client IPs</h3><div class="traffic-donut-layout"><div class="traffic-donut" id="traffic-chart-client-ips" role="img"><span id="traffic-chart-client-ips-total">-</span></div><ol class="traffic-chart-legend" id="traffic-chart-client-ips-legend"></ol></div></article>
<article class="card traffic-chart-card"><h3 class="subheading" data-i18n="trafficMethods">Methods</h3><div class="traffic-donut-layout"><div class="traffic-donut" id="traffic-chart-methods" role="img"><span id="traffic-chart-methods-total">-</span></div><ol class="traffic-chart-legend" id="traffic-chart-methods-legend"></ol></div></article>
<article class="card traffic-chart-card"><h3 class="subheading" data-i18n="trafficStatusClasses">Status classes</h3><div class="traffic-donut-layout"><div class="traffic-donut" id="traffic-chart-statuses" role="img"><span id="traffic-chart-statuses-total">-</span></div><ol class="traffic-chart-legend" id="traffic-chart-statuses-legend"></ol></div></article>
<article class="card traffic-chart-card"><h3 class="subheading" data-i18n="trafficCacheOutcomes">Cache outcomes</h3><div class="traffic-donut-layout"><div class="traffic-donut" id="traffic-chart-cache" role="img"><span id="traffic-chart-cache-total">-</span></div><ol class="traffic-chart-legend" id="traffic-chart-cache-legend"></ol></div></article>
</div>
<div class="logging-grid">
<article class="card"><h3 class="subheading" data-i18n="trafficTopCountries">Top countries</h3><div class="table-scroll"><table class="cache-table"><thead><tr><th data-i18n="trafficValue">Value</th><th data-i18n="requests">Requests</th></tr></thead><tbody id="traffic-countries"></tbody></table></div><p class="empty" id="traffic-countries-empty" data-i18n="trafficNoCountryData">Country collection is disabled or no GeoIP data is available.</p></article>
<article class="card"><h3 class="subheading" data-i18n="trafficTopUserAgents">Top User-Agents</h3><div class="table-scroll"><table class="cache-table"><thead><tr><th>User-Agent</th><th data-i18n="requests">Requests</th></tr></thead><tbody id="traffic-user-agents"></tbody></table></div><p class="empty" id="traffic-user-agents-empty" data-i18n="trafficNoUserAgentData">No User-Agent data collected.</p></article>
<article class="card"><h3 class="subheading" data-i18n="trafficTopClientIps">Top client IPs</h3><div class="table-scroll"><table class="cache-table"><thead><tr><th data-i18n="trafficClientIp">Client IP</th><th data-i18n="requests">Requests</th></tr></thead><tbody id="traffic-client-ips"></tbody></table></div><p class="empty" id="traffic-client-ips-empty" data-i18n="trafficNoData">No traffic data yet.</p></article>
<article class="card"><h3 class="subheading" data-i18n="trafficTopPaths">Top paths</h3><div class="table-scroll"><table class="cache-table"><thead><tr><th data-i18n="trafficValue">Value</th><th data-i18n="requests">Requests</th></tr></thead><tbody id="traffic-paths"></tbody></table></div><p class="empty" id="traffic-paths-empty" data-i18n="trafficNoData">No traffic data yet.</p></article>
<article class="card"><h3 class="subheading" data-i18n="trafficTopRoutes">Top routes</h3><div class="table-scroll"><table class="cache-table"><thead><tr><th data-i18n="trafficValue">Value</th><th data-i18n="requests">Requests</th></tr></thead><tbody id="traffic-routes"></tbody></table></div><p class="empty" id="traffic-routes-empty" data-i18n="trafficNoData">No traffic data yet.</p></article>
<article class="card"><h3 class="subheading" data-i18n="trafficMethods">Methods</h3><div class="table-scroll"><table class="cache-table"><thead><tr><th data-i18n="trafficValue">Value</th><th data-i18n="requests">Requests</th></tr></thead><tbody id="traffic-methods"></tbody></table></div><p class="empty" id="traffic-methods-empty" data-i18n="trafficNoData">No traffic data yet.</p></article>
<article class="card"><h3 class="subheading" data-i18n="trafficStatusClasses">Status classes</h3><div class="table-scroll"><table class="cache-table"><thead><tr><th data-i18n="trafficValue">Value</th><th data-i18n="requests">Requests</th></tr></thead><tbody id="traffic-statuses"></tbody></table></div><p class="empty" id="traffic-statuses-empty" data-i18n="trafficNoData">No traffic data yet.</p></article>
<article class="card"><h3 class="subheading" data-i18n="trafficCacheOutcomes">Cache outcomes</h3><div class="table-scroll"><table class="cache-table"><thead><tr><th data-i18n="trafficValue">Value</th><th data-i18n="requests">Requests</th></tr></thead><tbody id="traffic-cache"></tbody></table></div><p class="empty" id="traffic-cache-empty" data-i18n="trafficNoData">No traffic data yet.</p></article>
</div>
</section>
<section class="panel" hidden="" id="panel-cache">
<div class="cache-toolbar card"><label><span data-i18n="logVhost">Vhost</span><select
id="cache-scope">
@@ -1396,7 +1456,7 @@
<option data-i18n="serverCustom" value="custom">Replace / add custom value</option>
</select>
<label data-i18n="serverValue" for="server-value">Custom Server value</label><input
id="server-value" maxlength="256" placeholder="RustProxy/v0.24.10"
id="server-value" maxlength="256" placeholder="RustProxy/v0.25.0"
spellcheck="false" />
<h3 class="subheading" data-i18n="diagnosticHeaders">Diagnostic headers</h3>
<div class="switch-grid">
@@ -1483,10 +1543,14 @@
<div><label data-i18n="cacheEvictionLimit" for="cache-eviction-limit">LRU
removals per admission</label><input id="cache-eviction-limit"
max="4096" min="1" required="" type="number" /></div>
<div><label data-i18n="cacheAutoTrimAfter" for="cache-auto-trim-after">Idle age before HOT trim (seconds)</label><input id="cache-auto-trim-after" max="86400" min="5" required="" type="number" /></div>
<div><label data-i18n="cacheAutoTrimTarget" for="cache-auto-trim-target">HOT trim target (%)</label><input id="cache-auto-trim-target" max="95" min="10" required="" type="number" /></div>
<div><label data-i18n="cacheMaxTtl" for="cache-ttl">Maximum TTL
(seconds)</label><input id="cache-ttl" max="604800" min="1" required=""
type="number" /></div>
</div>
<label class="check"><input id="cache-auto-trim-enabled" type="checkbox" /><span data-i18n="cacheAutoTrimEnabled">Automatically release idle HOT cache memory</span></label>
<p class="fine" data-i18n="cacheAutoTrimHelp">When HOT memory is above the target, only entries idle for the configured age are removed in bounded passes. Active objects are not purged.</p>
<label data-i18n="cacheDefaultTtl" for="cache-default-ttl">Fallback TTL for public
responses (0 = off)</label><input id="cache-default-ttl" max="604800" min="0"
required="" type="number" />
@@ -1756,16 +1820,32 @@
</div>
<div aria-labelledby="settings-tab-logging" class="settings-page" data-settings-page="logging"
hidden="" id="settings-page-logging" role="tabpanel">
<form class="card edit-form" id="logging-form">
<div class="card edit-form" id="logging-settings-card">
<div class="card-heading">
<div><span class="small-label" data-i18n="loggingMode">JSON / ASYNC / BOUNDED</span>
<h2 data-i18n="loggingTitle">Structured access logging</h2>
</div><span class="pill live" data-i18n="hotApply">LIVE APPLY</span>
</div>
<p class="fine" data-i18n="loggingHelp">Live log viewing stays in memory. External sinks
run on
a bounded background queue and never block proxy request I/O.</p>
<div class="logging-grid">
<nav aria-label="Logging settings" class="settings-tabs sub-tabs" id="logging-settings-tabs"
role="tablist">
<button aria-selected="true" data-i18n="loggingTabOutputs"
data-logging-settings-tab="outputs" type="button">Outputs</button>
<button aria-selected="false" data-i18n="loggingTabFormat"
data-logging-settings-tab="format" type="button">JSON format</button>
<button aria-selected="false" data-i18n="loggingTabErrors"
data-logging-settings-tab="errors" type="button">Error log</button>
</nav>
<form class="logging-settings-subform" id="logging-form">
<div data-logging-settings-page="outputs">
<p class="fine" data-i18n="loggingHelp">Live log viewing stays in memory. External sinks
run on a bounded background queue and never block proxy request I/O.</p>
<article class="card soft-card">
<h3 class="subheading" data-i18n="loggingCaptureTitle">Captured request dimensions</h3>
<label class="check"><input id="log-capture-user-agent" type="checkbox" /><span data-i18n="loggingCaptureUserAgent">Capture User-Agent</span></label>
<label class="check"><input id="log-capture-country" type="checkbox" /><span data-i18n="loggingCaptureCountry">Capture country from GeoIP</span></label>
<p class="fine" data-i18n="loggingCaptureCountryHelp">Country lookup is optional and disabled by default. Enable it only when country logs/statistics are needed; country ACL/rate-limit rules still resolve GeoIP when required.</p>
</article>
<div class="logging-grid">
<article class="card">
<h3 class="subheading" data-i18n="loggingFile">JSON file</h3>
<label class="check"><input id="log-file-enabled" type="checkbox" /><span
@@ -1803,13 +1883,15 @@
<label><span data-i18n="loggingLabels">Labels (JSON object)</span><textarea
id="log-loki-labels" rows="4" spellcheck="false"></textarea></label>
</article>
</div>
<p class="fine" id="logging-status" role="status"></p>
</div>
<h3 class="subheading" data-i18n="loggingFormat">Configurable JSON format</h3>
<p class="fine" data-i18n="loggingFormatHelp">Map output names to source fields. Leave
mapping
empty for the complete standard entry.</p>
<div class="logging-format-actions"><label><span data-i18n="loggingPreset">Format
preset</span><select id="log-format-preset">
<div data-logging-settings-page="format" hidden>
<h3 class="subheading" data-i18n="loggingFormat">Configurable JSON format</h3>
<p class="fine" data-i18n="loggingFormatHelp">Map output names to source fields. Leave
mapping empty for the complete standard entry.</p>
<div class="logging-format-actions"><label><span data-i18n="loggingPreset">Format
preset</span><select id="log-format-preset">
<option data-i18n="loggingPresetCustom" value="custom">Custom</option>
<option data-i18n="loggingPresetStandard" value="standard">RustProxy
standard
@@ -1818,32 +1900,29 @@
</option>
<option data-i18n="loggingPresetEcs" value="ecs">ECS-style</option>
<option data-i18n="loggingPresetMinimal" value="minimal">Minimal</option>
</select></label>
<div class="logging-builder-action"><span class="small-label"
data-i18n="loggingBuilder">Visual field builder</span><button
class="outline" data-i18n="loggingBuilderOpen" id="log-format-open"
type="button">Configure
fields</button></div>
</div>
<details>
<summary data-i18n="loggingAdvancedJson">Advanced JSON mapping</summary>
<div class="field-grid"><label><span data-i18n="loggingFields">Field mapping (JSON
object)</span><textarea id="log-format-fields" rows="7"
spellcheck="false"></textarea></label>
<label><span data-i18n="loggingStatic">Static fields (JSON
object)</span><textarea id="log-format-static" rows="7"
spellcheck="false"></textarea></label>
</select></label>
<div class="logging-builder-action"><span class="small-label"
data-i18n="loggingBuilder">Visual field builder</span><button
class="outline" data-i18n="loggingBuilderOpen" id="log-format-open"
type="button">Configure fields</button></div>
</div>
</details>
<p class="fine" id="logging-status" role="status"></p>
<details>
<summary data-i18n="loggingAdvancedJson">Advanced JSON mapping</summary>
<div class="field-grid"><label><span data-i18n="loggingFields">Field mapping (JSON
object)</span><textarea id="log-format-fields" rows="7"
spellcheck="false"></textarea></label>
<label><span data-i18n="loggingStatic">Static fields (JSON
object)</span><textarea id="log-format-static" rows="7"
spellcheck="false"></textarea></label>
</div>
</details>
</div>
<div class="form-actions"><button class="primary" data-i18n="apply"
type="submit">Validate, save
and apply</button></div>
</form>
<form class="card edit-form" id="error-log-form">
<div class="card-heading">
<h2 data-i18n="errorLogTitle">Error file</h2><span class="pill live" data-i18n="hotApply">LIVE APPLY</span>
</div>
</form>
<form class="logging-settings-subform" data-logging-settings-page="errors" hidden id="error-log-form">
<h3 class="subheading" data-i18n="errorLogTitle">Error file</h3>
<label class="check"><input id="error-log-enabled" type="checkbox" /><span
data-i18n="enabled">Enabled</span></label>
<div class="field-grid">
@@ -1859,7 +1938,8 @@
<p class="fine" id="error-log-status" role="status"></p>
<div class="form-actions"><button class="primary" data-i18n="apply"
type="submit">Validate, save and apply</button></div>
</form>
</form>
</div>
</div>
<div aria-labelledby="settings-tab-prometheus" class="settings-page"
data-settings-page="prometheus" hidden="" id="settings-page-prometheus" role="tabpanel">
@@ -2123,7 +2203,7 @@
</section>
</div>
</section>
<footer class="page-footer"><span>RustProxy <b>0.24.10</b></span><span id="footer-status">API - /
<footer class="page-footer"><span>RustProxy <b>0.25.0</b></span><span id="footer-status">API - /
dropped: 0</span></footer>
</div>
</main>
@@ -2365,6 +2445,156 @@
<p class="fine log-footnote" data-i18n="logBufferHint">Live view starts with the latest 256 completed exchanges.
Increase buffer to keep accumulating live entries up to 4096 until the log window is closed.</p>
</dialog>
<dialog aria-labelledby="web-service-editor-title" class="vhost-dialog" id="web-service-dialog">
<form class="card vh-form" id="web-service-form" novalidate="">
<div class="card-heading">
<h2 data-i18n="webServiceEditor" id="web-service-editor-title">Web Service configuration</h2>
<span class="pill" id="web-service-editing">NEW</span>
<button class="quiet" data-i18n="close" id="web-service-close" type="button">Close</button>
</div>
<nav aria-label="Web Service configuration" class="vh-tabs" id="web-service-tabs"></nav>
<div class="web-service-pages">
<div class="vh-grid web-service-page" data-web-service-page="general">
<fieldset class="vh-group vh-wide">
<legend data-i18n="webServicePresetTitle">Configuration preset</legend>
<div class="vh-grid">
<label><span data-i18n="webServicePreset">Preset</span><select id="web-service-preset">
<option data-i18n="webServicePresetCustom" value="custom">Custom</option>
<option data-i18n="webServicePresetStatic" value="static">Static site</option>
<option data-i18n="webServicePresetSpa" value="spa">SPA</option>
<option data-i18n="webServicePresetPhpGeneric" value="php_generic">PHP generic</option>
<option value="wordpress">WordPress</option>
<option value="drupal">Drupal</option>
<option value="laravel">Laravel</option>
<option value="symfony">Symfony</option>
<option value="joomla">Joomla</option>
<option value="prestashop">PrestaShop</option>
<option data-i18n="webServicePresetDownloads" value="downloads">Downloads / file repository</option>
</select></label>
<div class="vh-field"><span class="small-label" data-i18n="webServicePresetAction">Preset action</span><button class="outline" data-i18n="webServicePresetApply" id="web-service-preset-apply" type="button">Apply preset</button></div>
<label class="check"><input id="web-service-hardening-attacks" type="checkbox" /><span data-i18n="webServiceHardeningAttacks">Block common attack probes</span></label>
<label class="check"><input id="web-service-hardening-headers" type="checkbox" /><span data-i18n="webServiceHardeningHeaders">Strip unnecessary fingerprint headers</span></label>
<p class="fine vh-wide" data-i18n="webServicePresetHelp" id="web-service-preset-summary">Presets fill normal Web Service settings and remain fully editable. Document root, service ID/name and PHP-FPM endpoint are not replaced.</p>
</div>
</fieldset>
<label><span data-i18n="webServiceId">Service ID</span><input id="web-service-id" maxlength="64" pattern="[A-Za-z0-9_-]{1,64}" required="" /></label>
<label><span data-i18n="webServiceName">Name</span><input id="web-service-name" maxlength="96" required="" /></label>
<label class="vh-wide"><span data-i18n="webServiceRoot">Document root</span><input id="web-service-root" maxlength="4096" placeholder="/var/www/main" required="" /></label>
<label class="vh-wide"><span data-i18n="webServiceIndex">Index files</span><textarea id="web-service-index" maxlength="4096" rows="5" spellcheck="false"></textarea><small class="fine" data-i18n="webServiceIndexHelp">Optional ordered index list. index.php may be used when PHP-FPM is enabled.</small></label>
<label class="check"><input id="web-service-listing" type="checkbox" /><span data-i18n="webServiceDirectoryListing">Directory listing</span></label>
</div>
<div class="vh-grid web-service-page" data-web-service-page="routing" hidden>
<label class="vh-wide"><span data-i18n="webServiceTryFiles">try_files</span><textarea id="web-service-try-files" maxlength="8192" rows="7" spellcheck="false" placeholder="$uri&#10;$uri/&#10;/index.html"></textarea><small class="fine" data-i18n="webServiceTryFilesHelp">One candidate per line. Supports $uri, $query_string and a final =404/other error status.</small></label>
<label class="vh-wide"><span data-i18n="webServiceRewrites">Rewrite rules</span><textarea id="web-service-rewrites" maxlength="32768" rows="8" spellcheck="false" placeholder="^/old/(.*)$ => 301:/new/$1&#10;^/app$ => /index.html"></textarea><small class="fine" data-i18n="webServiceRewritesHelp">One regex rule per line. Use pattern =&gt; /target for an internal rewrite or pattern =&gt; 301:/target for a redirect.</small></label>
</div>
<div class="vh-grid web-service-page" data-web-service-page="htaccess" hidden>
<label class="check"><input id="web-service-htaccess-enabled" type="checkbox" /><span data-i18n="webServiceHtaccessEnabled">Enable .htaccess compatibility</span></label>
<label><span data-i18n="webServiceHtaccessFilename">Configuration file</span><input id="web-service-htaccess-filename" maxlength="64" value=".htaccess" /></label>
<label class="check"><input id="web-service-htaccess-recursive" type="checkbox" checked /><span data-i18n="webServiceHtaccessRecursive">Scan subdirectories</span></label>
<label class="check"><input id="web-service-htaccess-strict" type="checkbox" /><span data-i18n="webServiceHtaccessStrict">Strict unsupported-directive mode</span></label>
<div class="vh-wide"><button class="outline" id="web-service-htaccess-help" data-i18n="webServiceHtaccessDocs" type="button">Compatibility documentation</button></div>
<p class="fine vh-wide" data-i18n="webServiceHtaccessHelp">RustProxy compiles a bounded Apache-compatible subset when the Web Service is applied. Files are not parsed on every request; apply the Web Service again after editing .htaccess on disk.</p>
</div>
<div class="vh-grid web-service-page" data-web-service-page="mime" hidden>
<label class="vh-wide"><span data-i18n="webServiceMimeOverrides">MIME overrides</span><textarea id="web-service-mime-types" maxlength="16384" rows="8" spellcheck="false" placeholder="wasm = application/wasm&#10;webp = image/webp"></textarea><small class="fine" data-i18n="webServiceMimeHelp">Optional extension-to-Content-Type overrides, one per line. Built-in mappings are used for all other known extensions.</small></label>
<label><span data-i18n="webServiceDefaultMime">Default MIME type</span><input id="web-service-default-mime" maxlength="256" value="application/octet-stream" required="" /></label>
</div>
<div class="vh-grid web-service-page" data-web-service-page="cache" hidden>
<label><span data-i18n="webServiceCacheControl">Default Cache-Control</span><input id="web-service-cache-control" maxlength="1024" placeholder="public, max-age=3600" /></label>
<label class="vh-wide"><span data-i18n="webServiceCacheRules">Cache-Control rules</span><textarea id="web-service-cache-rules" maxlength="32768" rows="7" spellcheck="false" placeholder="ext:html = no-cache&#10;mime:image/ = public, max-age=604800"></textarea><small class="fine" data-i18n="webServiceCacheRulesHelp">Specific rules are evaluated in order before the default. Match ext:css or mime:image/.</small></label>
<label class="vh-wide"><span data-i18n="webServiceResponseHeaders">Response headers</span><textarea id="web-service-response-headers" maxlength="32768" rows="7" spellcheck="false" placeholder="X-Content-Type-Options = nosniff"></textarea><small class="fine" data-i18n="webServiceResponseHeadersHelp">Fixed headers for this service. Vhost/location response-header rules are applied later and can override them.</small></label>
<p class="fine vh-wide" data-i18n="webServiceCacheHelp">Local files use filesystem/OS page cache; this controls browser/proxy caching and does not create a second RustProxy object cache.</p>
</div>
<div class="vh-grid web-service-page" data-web-service-page="delivery" hidden>
<label class="check"><input id="web-service-precompressed" type="checkbox" /><span data-i18n="webServicePrecompressed">Serve precompressed sidecars</span></label>
<label class="check"><input id="web-service-precompressed-br" type="checkbox" /><span data-i18n="webServicePrecompressedBr">Brotli (.br)</span></label>
<label class="check"><input id="web-service-precompressed-gzip" type="checkbox" /><span data-i18n="webServicePrecompressedGzip">Gzip (.gz)</span></label>
<label class="vh-wide"><span data-i18n="webServiceDownloadExtensions">Download extensions</span><textarea id="web-service-download-extensions" maxlength="4096" rows="5" spellcheck="false" placeholder="zip&#10;pdf&#10;tar"></textarea><small class="fine" data-i18n="webServiceDownloadExtensionsHelp">Files with these extensions receive Content-Disposition: attachment.</small></label>
<p class="fine vh-wide" data-i18n="webServiceRangeHelp">Static and precompressed representations keep validators, single-range requests, 206/416 and streaming delivery.</p>
</div>
<div class="vh-grid web-service-page" data-web-service-page="php" hidden>
<label class="check"><input id="web-service-php-enabled" type="checkbox" /><span data-i18n="webServicePhpEnabled">Enable PHP-FPM / FastCGI</span></label>
<label class="vh-wide"><span data-i18n="webServicePhpEndpoint">PHP-FPM endpoint</span><input id="web-service-php-endpoint" maxlength="512" placeholder="unix:/run/php/php-fpm.sock" /><small class="fine" data-i18n="webServicePhpEndpointHelp">Unix: unix:/run/php/php-fpm.sock. TCP: tcp:127.0.0.1:9000 or tcp:[::1]:9000. TCP is intentionally limited to loopback addresses.</small></label>
<label><span data-i18n="webServicePhpExtensions">PHP extensions</span><input id="web-service-php-extensions" maxlength="512" placeholder="php" /></label>
<label class="check"><input id="web-service-php-pass-authorization" type="checkbox" /><span data-i18n="webServicePhpPassAuthorization">Forward Authorization header</span></label>
<label><span data-i18n="webServicePhpConnectTimeout">Connect timeout (ms)</span><input id="web-service-php-connect-timeout" min="50" max="60000" step="1" type="number" /></label>
<label><span data-i18n="webServicePhpRequestTimeout">Total FastCGI request timeout (ms)</span><input id="web-service-php-request-timeout" min="100" max="3600000" step="1" type="number" /></label>
<label><span data-i18n="webServicePhpConcurrency">Max concurrent PHP requests</span><input id="web-service-php-concurrency" min="1" max="4096" step="1" type="number" /></label>
<label><span data-i18n="webServicePhpBodyLimit">PHP request body limit (bytes, 0 = unlimited)</span><input id="web-service-php-body-limit" min="0" max="8589934592" step="1" type="number" /></label>
<label><span data-i18n="webServicePhpHeaderLimit">Max FastCGI response header bytes</span><input id="web-service-php-header-limit" min="4096" max="1048576" step="1" type="number" /></label>
<label><span data-i18n="webServicePhpStderrLimit">Max PHP-FPM stderr log bytes</span><input id="web-service-php-stderr-limit" min="0" max="65536" step="1" type="number" /></label>
<label class="vh-wide"><span data-i18n="webServicePhpCustomParams">Custom FastCGI parameters</span><textarea id="web-service-php-custom-params" maxlength="65536" rows="6" spellcheck="false" placeholder="APP_ENV = production&#10;APP_DEBUG = 0"></textarea><small class="fine" data-i18n="webServicePhpCustomParamsHelp">One NAME = value entry per line. Static trusted values only; core CGI, HTTP_* and PHP_* control names are reserved.</small></label>
<label class="vh-wide"><span data-i18n="webServicePhpValues">PHP INI overrides (PHP_VALUE)</span><textarea id="web-service-php-values" maxlength="65536" rows="6" spellcheck="false" placeholder="memory_limit = 256M&#10;upload_max_filesize = 64M&#10;post_max_size = 64M"></textarea><small class="fine" data-i18n="webServicePhpValuesHelp">One php.ini directive = value per line. RustProxy sends the validated set as PHP_VALUE; PHP_ADMIN_VALUE is never accepted here.</small></label>
<div class="vh-wide form-actions"><button class="outline" data-i18n="webServicePhpDocsButton" id="web-service-php-help" type="button">FastCGI syntax and safety</button></div>
<p class="fine vh-wide" data-i18n="webServicePhpHelp">RustProxy speaks FastCGI directly to a long-running PHP-FPM pool. PHP is not spawned per request and PHP source is never served when PHP handling is selected.</p>
</div>
<div class="vh-grid web-service-page" data-web-service-page="security" hidden>
<label><span data-i18n="webServiceSymlinkPolicy">Symlink policy</span><select id="web-service-symlink-policy"><option data-i18n="webServiceSymlinkDeny" value="deny">Deny</option><option data-i18n="webServiceSymlinkInsideRoot" value="inside_root">Allow only inside document root (Linux)</option></select></label>
<div class="vh-wide">
<span class="small-label" data-i18n="webServiceHardeningTitle">Request/response hardening</span>
<p class="fine" data-i18n="webServiceHardeningSecurityHelp">Hardening switches are shown beside the preset selector on General. They remain independent settings after a preset is applied.</p>
</div>
<p class="fine vh-wide" data-i18n="webServiceSafety">Document root is a strict filesystem boundary. The default denies symlinks. Linux can optionally follow only targets that resolve inside the root; PHP-FPM currently requires the deny policy.</p>
</div>
</div>
<div class="form-actions">
<button class="primary" data-i18n="apply" type="submit">Validate, save and apply</button>
<button class="quiet" data-i18n="vhReset" id="web-service-reset" type="button">Discard changes</button>
</div>
</form>
</dialog>
<dialog aria-labelledby="htaccess-help-title" class="vhost-dialog location-help-dialog" id="htaccess-help-dialog">
<div class="window-heading">
<h2 data-i18n="webServiceHtaccessDocsTitle" id="htaccess-help-title">.htaccess compatibility</h2>
<button class="outline" data-i18n="close" id="htaccess-help-close" type="button">Close</button>
</div>
<article class="card location-help-content">
<p data-i18n="webServiceHtaccessDocsIntro">This is a migration-oriented compatibility layer, not a complete Apache runtime. Supported directives are compiled into RustProxy Web Server rules.</p>
<h3 data-i18n="webServiceHtaccessDocsSupportedTitle">Supported subset</h3>
<p><code>RewriteEngine</code>, <code>RewriteBase</code>, <code>RewriteCond</code>, <code>RewriteRule</code>, <code>DirectoryIndex</code>, <code>Options +/-Indexes</code>, <code>AddType</code>, <code>AddHandler</code> (PHP extensions), <code>Header set/add/unset</code>, <code>ExpiresActive</code>, <code>ExpiresDefault</code>, <code>ExpiresByType</code>, <code>Require all granted/denied</code>.</p>
<h3 data-i18n="webServiceHtaccessDocsRewriteTitle">Rewrite compatibility</h3>
<p data-i18n="webServiceHtaccessDocsRewrite">Rewrite conditions support REQUEST_FILENAME file tests (-f, -d, -s), REQUEST_URI, REQUEST_METHOD, QUERY_STRING, HTTP_HOST, HTTPS, DOCUMENT_ROOT, THE_REQUEST and HTTP headers. Flags: L, END, R, R=301/302/307/308, QSA, NC, F, OR. PT/NE are accepted as no-ops. E= is accepted as a compatibility no-op because request HTTP headers already reach PHP-FPM.</p>
<pre><code>RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.php [L]</code></pre>
<h3 data-i18n="webServiceHtaccessDocsPhpTitle">PHP-FPM</h3>
<p data-i18n="webServiceHtaccessDocsPhp">.htaccess may add PHP extensions with AddHandler, but the PHP-FPM endpoint, timeouts and concurrency remain controlled by the Web Service PHP-FPM tab. Apache SetHandler proxy socket syntax is intentionally not accepted.</p>
<h3 data-i18n="webServiceHtaccessDocsReloadTitle">Loading and limits</h3>
<p data-i18n="webServiceHtaccessDocsReload">Files are read and compiled on Validate, save and apply. RustProxy does not parse .htaccess per request. Re-apply after changing a file. The scan is bounded to 128 files, depth 16, 128 KiB per file and 1 MiB total.</p>
<h3 data-i18n="webServiceHtaccessDocsUnsupportedTitle">Not Apache-complete</h3>
<p data-i18n="webServiceHtaccessDocsUnsupported">ErrorDocument, SetEnvIf, Redirect/RedirectMatch and FallbackResource are not implemented in this compatibility layer. General Files/FilesMatch, unsupported authentication/authorization directives, non-PHP handlers and SetHandler are rejected even when Strict mode is off because silently ignoring them could weaken access control or expose source. .htaccess/.htpasswd files are always blocked from HTTP delivery. Other unsupported non-security directives are logged and ignored unless Strict mode is enabled.</p>
<p class="fine" data-i18n="webServiceHtaccessDocsSecurity">Vhost/location ACL, authentication, error pages and response rules still remain authoritative. .htaccess cannot escape the document root, select an arbitrary FastCGI socket or override protected framing headers.</p>
</article>
</dialog>
<dialog aria-labelledby="php-help-title" class="vhost-dialog location-help-dialog" id="php-help-dialog">
<div class="window-heading">
<h2 data-i18n="webServicePhpDocsTitle" id="php-help-title">PHP-FPM / FastCGI configuration</h2>
<button class="outline" data-i18n="close" id="php-help-close" type="button">Close</button>
</div>
<article class="card location-help-content">
<p data-i18n="webServicePhpDocsIntro">RustProxy connects directly to an existing PHP-FPM pool. Prefer a Unix socket on the same host. TCP is accepted only on loopback and is not a public/network FastCGI transport.</p>
<h3 data-i18n="webServicePhpDocsEndpointTitle">Endpoint syntax</h3>
<pre><code>unix:/run/php/php-fpm.sock
tcp:127.0.0.1:9000
tcp:[::1]:9000</code></pre>
<p class="fine" data-i18n="webServicePhpDocsEndpoint">For TCP, include the tcp: prefix, a literal loopback IP and port. Hostnames and non-loopback addresses are rejected. For Unix sockets, RustProxy must have permission to connect to the socket.</p>
<h3 data-i18n="webServicePhpDocsCustomTitle">Custom FastCGI parameters</h3>
<pre><code>APP_ENV = production
APP_DEBUG = 0
TENANT = main</code></pre>
<p data-i18n="webServicePhpDocsCustom">Use one NAME = value entry per line. Names are normalized to uppercase and may contain A-Z, 0-9 and underscore. These are static trusted operator values. RustProxy rejects core CGI names, HTTP_* names, PHP_VALUE and PHP_ADMIN_VALUE so they cannot override request routing, script selection or HTTP-derived parameters.</p>
<h3 data-i18n="webServicePhpDocsValuesTitle">PHP INI overrides</h3>
<pre><code>memory_limit = 256M
upload_max_filesize = 64M
post_max_size = 64M
max_execution_time = 30</code></pre>
<p data-i18n="webServicePhpDocsValues">These lines are combined into the FastCGI PHP_VALUE parameter. PHP-FPM/PHP still decides which directives are changeable at this scope. Newlines/control characters are rejected and PHP_ADMIN_VALUE is intentionally unavailable.</p>
<h3 data-i18n="webServicePhpDocsLimitsTitle">Limits and headers</h3>
<p data-i18n="webServicePhpDocsLimits">Request timeout covers the FastCGI exchange. The body limit is enforced while streaming and returns 413 when exceeded; 0 disables the RustProxy-side body limit. Response-header and stderr limits bound memory/diagnostic data. Authorization forwarding can be disabled for applications that do not need HTTP_AUTHORIZATION.</p>
<p class="fine" data-i18n="webServicePhpDocsSecurity">Do not put secrets in custom parameters or PHP_VALUE unless you accept storing them in normal Web Service configuration. Use a dedicated non-root PHP-FPM pool identity and keep PHP-FPM off public networks.</p>
</article>
</dialog>
<dialog aria-labelledby="vhost-editor-title" class="vhost-dialog" id="vhost-dialog">
<form class="card vh-form" id="vhost-form" novalidate="">
<div class="card-heading">
+1 -1
View File
@@ -4,7 +4,7 @@
const node = id => document.getElementById(id);
const themes = new Set(["dark", "light"]);
try { const cached = localStorage.getItem("rp-theme"); document.documentElement.dataset.theme = themes.has(cached) ? cached : "dark"; } catch { document.documentElement.dataset.theme = "dark"; }
const panelPaths = new Set(["/overview", "/vhosts", "/docker-vhosts", "/redirects", "/404-hosts", "/streams", "/acl-profiles", "/configuration", "/tls", "/cache", "/transform", "/logging", "/settings"]);
const panelPaths = new Set(["/overview", "/vhosts", "/docker-vhosts", "/redirects", "/404-hosts", "/streams", "/acl-profiles", "/configuration", "/tls", "/traffic", "/cache", "/transform", "/logging", "/settings"]);
const settingsTabs = new Set(["listeners", "connections", "docker-agents", "response", "cache", "transform", "headers", "forwarding", "pages", "logging", "prometheus", "administration", "tls-policy", "manual-config", "security"]);
let packs = new Map(), fallback = null, language = null, setupRequired = false, ready = false;
try { language = localStorage.getItem("rp-language"); } catch { }
+15 -1
View File
@@ -39,6 +39,20 @@
button.addEventListener("keydown", event => { const index = cacheTabs.indexOf(button); let next; if (event.key === "ArrowRight" || event.key === "ArrowDown") next = (index + 1) % cacheTabs.length; else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = (index + cacheTabs.length - 1) % cacheTabs.length; else if (event.key === "Home") next = 0; else if (event.key === "End") next = cacheTabs.length - 1; else return; event.preventDefault(); selectCacheTab(cacheTabs[next].dataset.cacheSettingsTab, true); });
}
selectCacheTab("general");
const loggingTabs = [...document.querySelectorAll("[data-logging-settings-tab]")];
function selectLoggingTab(name, focus = false) {
if (!loggingTabs.some(button => button.dataset.loggingSettingsTab === name)) return;
for (const button of loggingTabs) { const chosen = button.dataset.loggingSettingsTab === name; button.setAttribute("aria-selected", String(chosen)); button.tabIndex = chosen ? 0 : -1; if (chosen && focus) button.focus(); }
document.querySelectorAll("[data-logging-settings-page]").forEach(page => page.hidden = page.dataset.loggingSettingsPage !== name);
const loggingForm = $("logging-form"), errorLogForm = $("error-log-form");
if (loggingForm) loggingForm.hidden = name === "errors";
if (errorLogForm) errorLogForm.hidden = name !== "errors";
}
for (const button of loggingTabs) {
button.addEventListener("click", () => selectLoggingTab(button.dataset.loggingSettingsTab));
button.addEventListener("keydown", event => { const index = loggingTabs.indexOf(button); let next; if (event.key === "ArrowRight" || event.key === "ArrowDown") next = (index + 1) % loggingTabs.length; else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = (index + loggingTabs.length - 1) % loggingTabs.length; else if (event.key === "Home") next = 0; else if (event.key === "End") next = loggingTabs.length - 1; else return; event.preventDefault(); selectLoggingTab(loggingTabs[next].dataset.loggingSettingsTab, true); });
}
selectLoggingTab("outputs");
const httpTabs = [...document.querySelectorAll("[data-http-settings-tab]")];
function selectHttpTab(name, focus = false) {
if (!httpTabs.some(button => button.dataset.httpSettingsTab === name)) return;
@@ -192,6 +206,6 @@ curl --fail --show-error --request POST \
const text = await response.text(); if (epoch !== authEpoch || !authenticated) return; $("prom-preview-output").textContent = text.slice(0, 262144); $("prom-preview-output").hidden = false;
} catch (error) { if (epoch === authEpoch) toast(error.message, true); } finally { button.disabled = false; }
});
window.rustProxySettings = { select, selectHttpTab, syncLocation, render, current: () => active, reset() { select("listeners", false, false); selectCacheTab("general"); selectHttpTab("http2"); certificateApiState = null; certificateApiLoading = false; $("certificate-api-key-id").value = ""; $("certificate-api-comment").value = ""; $("certificate-api-token").value = ""; $("certificate-api-secret").textContent = ""; $("certificate-api-credentials").replaceChildren(); $("certificate-api-secret-wrap").hidden = true; $("prom-preview-output").textContent = ""; $("prom-preview-output").hidden = true; } };
window.rustProxySettings = { select, selectHttpTab, syncLocation, render, current: () => active, reset() { select("listeners", false, false); selectCacheTab("general"); selectLoggingTab("outputs"); selectHttpTab("http2"); certificateApiState = null; certificateApiLoading = false; $("certificate-api-key-id").value = ""; $("certificate-api-comment").value = ""; $("certificate-api-token").value = ""; $("certificate-api-secret").textContent = ""; $("certificate-api-credentials").replaceChildren(); $("certificate-api-secret-wrap").hidden = true; $("prom-preview-output").textContent = ""; $("prom-preview-output").hidden = true; } };
select(active); render();
})();
+134 -3
View File
@@ -4548,6 +4548,95 @@ dialog .window-heading {
margin: 14px 0
}
.traffic-chart-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin: 14px 0
}
.traffic-chart-card {
min-width: 0
}
.traffic-donut-layout {
display: grid;
grid-template-columns: 148px minmax(0, 1fr);
gap: 14px;
align-items: center
}
.traffic-donut {
position: relative;
width: 148px;
aspect-ratio: 1;
border-radius: 50%;
background: var(--panel2);
box-shadow: inset 0 0 0 1px var(--line)
}
.traffic-donut::before {
content: "";
position: absolute;
inset: 23%;
border-radius: 50%;
background: var(--panel);
box-shadow: 0 0 0 1px var(--line)
}
.traffic-donut>span {
position: absolute;
inset: 0;
z-index: 1;
display: grid;
place-items: center;
font: 700 16px var(--mono)
}
.traffic-chart-legend {
display: grid;
gap: 6px;
padding: 0;
margin: 0;
list-style: none;
min-width: 0
}
.traffic-chart-legend li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
font-size: 11px
}
.traffic-chart-legend li>span {
display: flex;
align-items: center;
gap: 7px;
min-width: 0
}
.traffic-chart-swatch {
width: 9px;
height: 9px;
border-radius: 50%;
flex: 0 0 auto
}
.traffic-chart-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap
}
.traffic-chart-legend b {
font: 700 10px var(--mono);
flex: 0 0 auto
}
.logging-grid label {
min-width: 0
}
@@ -4557,12 +4646,40 @@ dialog .window-heading {
max-width: none
}
#vhost-list {
.logging-settings-subform {
margin: 0;
min-width: 0
}
@media(max-width:1100px) {
.traffic-chart-grid {
grid-template-columns: repeat(2, minmax(0, 1fr))
}
}
@media(max-width:720px) {
.traffic-chart-grid,
.logging-grid {
grid-template-columns: 1fr
}
.traffic-donut-layout {
grid-template-columns: 120px minmax(0, 1fr)
}
.traffic-donut {
width: 120px
}
}
#vhost-list,
#web-service-list {
display: grid;
gap: 10px
}
#panel-vhosts .card-heading {
#panel-vhosts .card-heading,
#panel-web-server .card-heading {
flex-wrap: wrap
}
@@ -4573,7 +4690,8 @@ dialog .window-heading {
margin-left: auto
}
#vhost-search {
#vhost-search,
#web-service-search {
width: min(260px, 32vw);
min-width: 180px
}
@@ -7426,3 +7544,16 @@ body[data-density="compact"] .page-footer {
.cache-io-key {
color: var(--transform-chart);
}
/* Web Service editor mirrors the vhost tabbed configuration layout. */
.web-service-pages {
min-width: 0;
}
.web-service-page[hidden] {
display: none !important;
}
#web-service-dialog .web-service-page {
padding-top: 2px;
}
+135
View File
@@ -0,0 +1,135 @@
"use strict";
(() => {
let loading = false, timer = null, lastData = null;
const chartPalette = ["var(--accent)", "var(--green)", "var(--blue)", "var(--transform-chart)", "var(--red)", "var(--notice-text)", "var(--border-hover)", "var(--separator)"];
const scope = () => $("traffic-scope")?.value || "*";
const number = value => fmt(Number(value) || 0);
function configure(data) {
const selected = scope(), options = [el("option", t("logAllVhosts"))];
options[0].value = "*";
for (const vhost of data.vhosts || []) {
const option = el("option", `${vhost.id} / ${(vhost.hosts || []).join(", ")}`);
option.value = vhost.id;
options.push(option);
}
$("traffic-scope").replaceChildren(...options);
$("traffic-scope").value = options.some(option => option.value === selected) ? selected : "*";
}
function renderTop(id, entries, enabled = true) {
const body = $(`traffic-${id}`), empty = $(`traffic-${id}-empty`);
const values = enabled ? (entries || []) : [];
body.replaceChildren(...values.map(item => {
const row = el("tr");
const value = el("td", item.value || "-"); value.title = item.value || "-";
row.append(value, el("td", number(item.count)));
return row;
}));
empty.hidden = values.length > 0;
}
function renderDonut(id, entries, enabled = true) {
const chart = $(`traffic-chart-${id}`), legend = $(`traffic-chart-${id}-legend`), totalNode = $(`traffic-chart-${id}-total`);
if (!chart || !legend || !totalNode) return;
const values = (enabled ? (entries || []) : []).filter(item => Number(item.count) > 0);
const total = values.reduce((sum, item) => sum + Number(item.count || 0), 0);
totalNode.textContent = number(total);
if (!total) {
chart.style.background = "var(--panel2)";
chart.setAttribute("aria-label", t("trafficNoData"));
legend.replaceChildren(el("li", t("trafficNoData"), "fine"));
return;
}
const shown = values.slice(0, 7).map(item => ({ value: item.value || "-", count: Number(item.count) || 0 }));
if (values.length > shown.length) {
shown.push({ value: t("trafficOther"), count: values.slice(shown.length).reduce((sum, item) => sum + Number(item.count || 0), 0) });
}
let cursor = 0;
const segments = shown.map((item, index) => {
const start = cursor / total * 100;
cursor += item.count;
const end = cursor / total * 100;
return `${chartPalette[index % chartPalette.length]} ${start.toFixed(4)}% ${end.toFixed(4)}%`;
});
chart.style.background = `conic-gradient(${segments.join(", ")})`;
const heading = chart.closest(".traffic-chart-card")?.querySelector("h3")?.textContent || t("trafficDimensionsTitle");
chart.setAttribute("aria-label", `${heading}: ${shown.map(item => `${item.value} ${Math.round(item.count / total * 100)}%`).join(", ")}`);
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 || "-";
label.append(swatch, text);
row.append(label, el("b", `${(item.count / total * 100).toFixed(1)}%`));
return row;
}));
}
function render(data) {
lastData = data;
configure(data);
const analytics = data.analytics || {}, stats = analytics.data || {}, settings = data.settings || {};
$("traffic-requests").textContent = number(stats.requests);
$("traffic-errors").textContent = number(stats.errors);
const requests = Number(stats.requests) || 0, errors = Number(stats.errors) || 0;
$("traffic-error-ratio").textContent = `${requests ? (errors / requests * 100).toFixed(2) : "0.00"}%`;
$("traffic-response-bytes").textContent = fmtBytes(Number(stats.response_bytes) || 0);
$("traffic-request-bytes").textContent = `${t("trafficRequestBytes")}: ${fmtBytes(Number(stats.request_bytes) || 0)}`;
$("traffic-latency").textContent = stats.mean_duration_ms == null ? "-" : `${fmt(stats.mean_duration_ms, 2)} ms`;
$("traffic-dropped").textContent = `${t("trafficDroppedSamples")}: ${number(analytics.queue_dropped)}`;
const capture = [];
capture.push(settings.capture_user_agent ? t("trafficUserAgentEnabled") : t("trafficUserAgentDisabled"));
capture.push(settings.capture_country ? (data.geoip?.loaded ? t("trafficCountryEnabled") : t("trafficCountryNoDb")) : t("trafficCountryDisabled"));
$("traffic-capture-status").textContent = capture.join(" · ");
renderTop("countries", stats.countries, !!settings.capture_country);
renderTop("user-agents", stats.user_agents, !!settings.capture_user_agent);
renderTop("client-ips", stats.client_ips);
renderTop("paths", stats.paths);
renderTop("routes", stats.routes);
renderTop("methods", stats.methods);
renderTop("statuses", stats.statuses);
renderTop("cache", stats.cache);
renderDonut("countries", stats.countries, !!settings.capture_country);
renderDonut("user-agents", stats.user_agents, !!settings.capture_user_agent);
renderDonut("client-ips", stats.client_ips);
renderDonut("methods", stats.methods);
renderDonut("statuses", stats.statuses);
renderDonut("cache", stats.cache);
$("traffic-panel-state").textContent = `${t("trafficUpdated")}: ${new Date().toLocaleTimeString(locale())}`;
}
async function refresh() {
if (!authenticated || loading) return;
loading = true;
try {
const selected = scope();
const data = await api(`/api/traffic?vhost=${encodeURIComponent(selected)}`);
render(data);
} catch (error) {
$("traffic-panel-state").textContent = error.message;
} finally { loading = false; }
}
function reset() {
lastData = null;
if ($("traffic-panel-state")) $("traffic-panel-state").textContent = "";
for (const id of ["countries", "user-agents", "client-ips", "paths", "routes", "methods", "statuses", "cache"]) {
$(`traffic-${id}`)?.replaceChildren();
}
for (const id of ["countries", "user-agents", "client-ips", "methods", "statuses", "cache"]) {
const chart = $(`traffic-chart-${id}`), legend = $(`traffic-chart-${id}-legend`), total = $(`traffic-chart-${id}-total`);
if (chart) chart.style.background = "var(--panel2)";
legend?.replaceChildren();
if (total) total.textContent = "-";
}
}
function startTimer() {
if (timer) return;
timer = setInterval(() => {
if (!authenticated || !$("traffic-live")?.checked || $("panel-traffic")?.hidden) return;
void refresh();
}, 3000);
}
$("traffic-refresh")?.addEventListener("click", () => void refresh());
$("traffic-scope")?.addEventListener("change", () => void refresh());
$("traffic-settings")?.addEventListener("click", () => {
setPanel("settings");
window.rustProxySettings?.select("logging");
});
startTimer();
window.rustProxyTrafficPanel = { refresh, reset, rerender: () => lastData && render(lastData) };
})();
+103 -21
View File
@@ -5,6 +5,7 @@
let basicEditor = null, locationsReader = () => [], aclControl = null, editing = null, revision = null, dirty = false, initialized = false, readBindings = () => [], readHosts = () => [], countryRatesReader = () => [], pagesEditor = null, stagedCertificates = [], uploading = false, draftEpoch = 0;
const chartHistory = new Map();
const backendTrafficHistory = new Map();
const vhostTrafficHistory = new Map();
const dialog = $("vhost-dialog");
const form = $("vhost-form"), container = $("vhost-fields");
const text = (tag, key, cls) => { const node = el(tag, t(key), cls); node.dataset.i18n = key; return node; };
@@ -35,10 +36,17 @@
const nullable = (name, scope) => str(name, scope) || null;
const defaultProxyTimeouts = () => ({ connect_timeout_seconds: 10, response_header_timeout_seconds: 120, read_timeout_seconds: 60, send_timeout_seconds: 60 });
const changed = () => { dirty = true; };
const fillWebServices = (select, chosen = "") => {
const first = el("option", t("webServiceSelect")); first.value = "";
select.replaceChildren(first, ...(envelope?.config.web_services || []).map(service => { const option = el("option", service.name || service.id); option.value = service.id; return option; }));
if (chosen && !Array.from(select.options).some(option => option.value === chosen)) { const missing = el("option", `${chosen} / ${t("locationMissing")}`); missing.value = chosen; select.append(missing); }
select.value = chosen;
};
const allowDiscard = async () => { if (!dirty) return true; if (!(await appConfirm(t("vhUnsaved"), { danger: true, confirmLabel: t("discardAction") }))) return false; dirty = false; return true; };
const defaults = () => ({
id: "", enabled: true, force_ssl: false, forward_client_ip: true, websockets: false, websocket_legacy_support: false, websocket_rfc8441: false, websocket_rfc9220: false, http2_enabled: true, http3_enabled: true, cloudflare_client_ip: false, timing_headers: false, backend_header: false, backend_timing_header: false,
frontend: { hosts: [], listener_ids: [] },
source: { type: "reverse_proxy" },
backends: [{ id: "origin-1", url: "", weight: 1, enabled: true, pool_eligible: true, allow_private: true, accept_local_listener_risk: false, preserve_host: true, protocol: "http1", tls_verify: false }],
cache: { enabled: false }, transform: { enabled: false }, rate_limit: {}, routes: [], headers: {}, acl_profile_id: null, acl: null, tls_policy: null, proxy_timeouts: defaultProxyTimeouts()
});
@@ -168,8 +176,8 @@
identity.append(title, endpoint); head.append(identity); const remove = button("remove", () => { row.remove(); changed(); }); head.append(remove); row.append(head);
const main = el("div", undefined, "vh-grid vh-backend-main"); row.append(main);
const id = field(main, "member-id", "vhMemberId", value.id, "text", { required: true, pattern: "[A-Za-z0-9_-]{1,64}", maxLength: 64 });
const url = field(main, "member-url", "vhUrl", value.url, "url", { required: true, maxLength: 512 });
const id = field(main, "member-id", "vhMemberId", value.id, "text", { pattern: "[A-Za-z0-9_-]{1,64}", maxLength: 64 });
const url = field(main, "member-url", "vhUrl", value.url, "url", { maxLength: 512 });
field(main, "member-weight", "vhWeight", value.weight ?? 1, "number", { required: true, min: 1, max: 256 });
const protocol = field(main, "member-protocol", "vhProtocol", value.protocol || "http1", "select", { values: [["http1", "vhProtocolHttp1"], ["auto", "vhProtocolAuto"], ["http2", "vhProtocolHttp2"]] });
@@ -283,12 +291,14 @@
function readRows() {
return [...rows.children].map(r => {
const action = str("location-action", r), rewriteMode = str("location-rewrite-mode", r);
const handled = action !== "return";
return {
id: str("location-id", r), path_prefix: str("location-path", r), match_type: str("location-match", r),
source: action === "web_server" ? { type: "web_server", web_service_id: str("location-web-service", r) } : { type: "reverse_proxy" },
backend_id: action === "proxy" ? (str("location-backend", r) || null) : null,
enabled: yes("location-enabled", r), cache: action === "proxy" ? (str("location-cache", r) === "" ? null : str("location-cache", r) === "on") : false,
acl: r.readAcl(), headers: r.readHeaders(),
rewrite: { mode: action === "proxy" ? rewriteMode : "preserve", value: action === "proxy" && rewriteMode !== "preserve" ? read("location-rewrite-value", r).value.trim() : "" },
rewrite: { mode: handled ? rewriteMode : "preserve", value: handled && rewriteMode !== "preserve" ? read("location-rewrite-value", r).value.trim() : "" },
response: action === "return" ? { status: Number(str("location-response-status", r)), content_type: str("location-response-type", r), body: read("location-response-body", r).value } : null
};
});
@@ -303,8 +313,10 @@
field(body, "location-id", "locationId", value.id || `location-${suffix}`, "text", { required: true, pattern: "[A-Za-z0-9_-]{1,64}", maxLength: 64 });
const match = field(body, "location-match", "locationMatchType", value.match_type || "prefix", "select", { values: [["prefix", "locationMatchPrefix"], ["prefix_priority", "locationMatchPriority"], ["exact", "locationMatchExact"], ["regex", "locationMatchRegex"], ["regex_insensitive", "locationMatchRegexI"]] });
const path = field(body, "location-path", "locationPathPattern", value.path_prefix || "/", "text", { required: true, maxLength: 1024 }); path.placeholder = "/api or ^/api/v[0-9]+/";
const action = field(body, "location-action", "locationAction", value.response ? "return" : "proxy", "select", { values: [["proxy", "locationProxyAction"], ["return", "locationReturnAction"]] });
const initialAction = value.response ? "return" : value.source?.type === "web_server" ? "web_server" : "proxy";
const action = field(body, "location-action", "locationAction", initialAction, "select", { values: [["proxy", "locationProxyAction"], ["web_server", "locationWebServerAction"], ["return", "locationReturnAction"]] });
const backend = field(body, "location-backend", "backend", value.backend_id || "", "select"); memberOptions(backend, value.backend_id || "");
const webService = field(body, "location-web-service", "webService", value.source?.web_service_id || "", "select"); fillWebServices(webService, value.source?.web_service_id || "");
field(body, "location-enabled", "enabled", value.enabled !== false, "checkbox");
const cache = field(body, "location-cache", "cacheEnabled", value.cache === true ? "on" : value.cache === false ? "off" : "", "select", { values: [["", "inherit"], ["on", "enabled"], ["off", "notEnabled"]] });
@@ -339,14 +351,15 @@
actions.append(dedicated, button("remove", () => { row.remove(); sync(); })); body.append(actions);
const syncUi = () => {
const isReturn = action.value === "return", isRegex = match.value === "regex" || match.value === "regex_insensitive";
backend.disabled = isReturn; cache.disabled = isReturn; dedicated.disabled = isReturn;
const isReturn = action.value === "return", isWeb = action.value === "web_server", isProxy = action.value === "proxy", isRegex = match.value === "regex" || match.value === "regex_insensitive";
backend.disabled = !isProxy; cache.disabled = !isProxy; dedicated.disabled = !isProxy;
webService.disabled = !isWeb; webService.required = isWeb;
returnBox.hidden = !isReturn; rewriteMode.disabled = isReturn; rewriteValue.disabled = isReturn || rewriteMode.value === "preserve" || rewriteMode.value === "strip_prefix";
for (const option of rewriteMode.options) if (option.value === "regex") option.disabled = !isRegex;
if (!isRegex && rewriteMode.value === "regex") rewriteMode.value = "preserve";
path.placeholder = isRegex ? "^/api/v[0-9]+/" : "/api/";
const id = str("location-id", row) || t("locationId"), pattern = str("location-path", row) || "/";
summary.textContent = `${id} · ${t(`locationMatch_${match.value}`)} · ${pattern} · ${t(isReturn ? "locationReturnShort" : "locationProxyShort")}`;
summary.textContent = `${id} · ${t(`locationMatch_${match.value}`)} · ${pattern} · ${t(isReturn ? "locationReturnShort" : isWeb ? "locationWebServerShort" : "locationProxyShort")}`;
previewStatus.hidden = yes("location-enabled", row);
previewValues.match.textContent = `${t(`locationMatch_${match.value}`)} · ${pattern}`;
@@ -356,12 +369,18 @@
previewValues.rewrite.textContent = "-";
previewValues.cache.textContent = t("notEnabled");
} else {
const selected = backend.selectedOptions?.[0]?.textContent || t("locationPool");
previewValues.handling.textContent = `${t("locationProxyAction")}: ${selected}`;
if (isWeb) {
const selected = webService.selectedOptions?.[0]?.textContent || t("webServiceSelect");
previewValues.handling.textContent = `${t("locationWebServerAction")}: ${selected}`;
previewValues.cache.textContent = t("notEnabled");
} else {
const selected = backend.selectedOptions?.[0]?.textContent || t("locationPool");
previewValues.handling.textContent = `${t("locationProxyAction")}: ${selected}`;
previewValues.cache.textContent = t(cache.value === "on" ? "enabled" : cache.value === "off" ? "notEnabled" : "inherit");
}
const rewriteLabels = { preserve: "locationRewritePreserve", strip_prefix: "locationRewriteStrip", replace_prefix: "locationRewriteReplace", regex: "locationRewriteRegex" };
const replacement = read("location-rewrite-value", row).value.trim();
previewValues.rewrite.textContent = `${t(rewriteLabels[rewriteMode.value] || "locationRewritePreserve")}${replacement && !["preserve", "strip_prefix"].includes(rewriteMode.value) ? `: ${replacement}` : ""} · ${t("locationPreviewQueryPreserved")}`;
previewValues.cache.textContent = t(cache.value === "on" ? "enabled" : cache.value === "off" ? "notEnabled" : "inherit");
}
previewValues.access.textContent = yes("acl-use", row) ? t("locationPreviewCustomAcl") : t("locationPreviewVhostAcl");
try {
@@ -423,6 +442,10 @@
container.replaceChildren(); $("vhost-editing").textContent = t(editing ? "vhEditLabel" : "vhNewLabel");
let g = group("vhEditor"); field(g, "id", "vhId", value.id, "text", { required: true, maxLength: 64, pattern: "[A-Za-z0-9_-]{1,64}" }); if (editing) note(g, "vhIdRenameHelp");
readHosts = hostTagEditor(g, value.frontend?.hosts || []); field(g, "enabled", "vhEnabled", value.enabled !== false, "checkbox");
const mainSource = field(g, "source-type", "sourceType", value.source?.type || "reverse_proxy", "select", { values: [["reverse_proxy", "sourceReverseProxy"], ["web_server", "sourceWebServer"]] });
const mainWebService = field(g, "source-web-service", "webService", value.source?.web_service_id || "", "select"); fillWebServices(mainWebService, value.source?.web_service_id || "");
const syncMainSource = () => { const web = mainSource.value === "web_server"; mainWebService.disabled = !web; mainWebService.required = web; }; mainSource.addEventListener("change", syncMainSource); syncMainSource();
note(g, "sourceHelp");
const websocketEnabled = field(g, "websockets", "vhWebsockets", value.websockets !== false, "checkbox");
const websocketModeValue = value.websocket_rfc8441 === true && value.websocket_rfc9220 === true ? "h2h3" : value.websocket_rfc8441 === true ? "h2" : value.websocket_rfc9220 === true ? "h3" : "h1";
const websocketMode = field(g, "websocket-mode", "vhWebsocketMode", websocketModeValue, "select", { values: [["h1", "vhWebsocketH1"], ["h2", "vhWebsocketH2Fallback"], ["h3", "vhWebsocketH3Fallback"], ["h2h3", "vhWebsocketH2H3Fallback"]] });
@@ -600,7 +623,7 @@
authMode.addEventListener("change", syncAuth); syncAuth();
g = group("vhResponsePolicy"); const server = value.server_header, diagnostics = value.diagnostic_headers;
const serverMode = field(g, "server-mode", "serverHeader", server?.mode || "inherit", "select", { values: [["inherit", "vhInherit"], ["preserve", "serverPreserve"], ["hide", "serverHide"], ["custom", "serverCustom"]] });
const serverValue = field(g, "server-value", "serverValue", server?.value || "RustProxy/v0.24.10", "text", { maxLength: 256 });
const serverValue = field(g, "server-value", "serverValue", server?.value || "RustProxy/v0.25.0", "text", { maxLength: 256 });
const syncServer = () => { serverValue.disabled = serverMode.value !== "custom"; serverValue.required = !serverValue.disabled; }; serverMode.addEventListener("change", syncServer); syncServer();
const inheritDiagnostics = field(g, "diag-inherit", "diagInherit", !diagnostics, "checkbox"), diagInputs = [];
for (const [name, key] of [["request_id", "diagRequestId"]]) diagInputs.push(field(g, `diag-${name}`, key, diagnostics?.[name] ?? envelope?.config.diagnostic_headers?.[name], "checkbox"));
@@ -651,9 +674,14 @@
paths = JSON.parse(str("paths")); acl = JSON.parse(str("acl")); headers = JSON.parse(str("headers"));
if (!Array.isArray(paths) || (acl !== null && (typeof acl !== "object" || Array.isArray(acl))) || !headers || typeof headers !== "object" || Array.isArray(headers)) throw Error();
} catch { throw new Error(t("vhJsonInvalid")); }
const members = [...form.querySelectorAll(".vh-member")].map(row => ({ id: str("member-id", row), url: str("member-url", row), weight: num("member-weight", row), enabled: yes("member-enabled", row), pool_eligible: yes("member-pool", row), backup: yes("member-backup", row), drain: yes("member-drain", row), allow_private: yes("member-private", row), accept_local_listener_risk: yes("member-local-risk", row), preserve_host: yes("member-host", row), protocol: str("member-protocol", row), address_allowlist: lines(str("member-allowlist", row)), tls_server_name: nullable("member-tls-name", row), tls_verify: yes("member-tls-verify", row), tls_ca_pem: nullable("member-ca", row), max_concurrent_requests: num("member-max-concurrent", row), queue_timeout_ms: num("member-queue-timeout", row) }));
const members = [...form.querySelectorAll(".vh-member")].map(row => ({ id: str("member-id", row), url: str("member-url", row), weight: num("member-weight", row), enabled: yes("member-enabled", row), pool_eligible: yes("member-pool", row), backup: yes("member-backup", row), drain: yes("member-drain", row), allow_private: yes("member-private", row), accept_local_listener_risk: yes("member-local-risk", row), preserve_host: yes("member-host", row), protocol: str("member-protocol", row), address_allowlist: lines(str("member-allowlist", row)), tls_server_name: nullable("member-tls-name", row), tls_verify: yes("member-tls-verify", row), tls_ca_pem: nullable("member-ca", row), max_concurrent_requests: num("member-max-concurrent", row), queue_timeout_ms: num("member-queue-timeout", row) })).filter(member => member.url);
const bindingSelection = readBindings();
if (!bindingSelection.length || !members.length) throw new Error(t("vhJsonInvalid"));
const mainSourceType = str("source-type");
const needsProxy = mainSourceType === "reverse_proxy" || paths.some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy");
if (!bindingSelection.length) throw new Error(t("vhListenerRequired"));
if (needsProxy && !members.length) throw new Error(t("vhBackendRequired"));
if (mainSourceType === "web_server" && !str("source-web-service")) throw new Error(t("webServiceRequired"));
if (paths.some(route => !route.response && route.source?.type === "web_server" && !route.source.web_service_id)) throw new Error(t("webServiceRequired"));
if (yes("force-ssl")) {
const tlsPorts = new Set(); let tlsSelected = false, httpSelected = false;
for (const id of bindingSelection) {
@@ -681,7 +709,7 @@
if (JSON.stringify(selected) !== JSON.stringify(globalPolicy)) throw new Error(t("vhTlsHttp3Conflict"));
}
return {
id: str("id"), enabled: yes("enabled"), force_ssl: yes("force-ssl"), forward_client_ip: yes("forward-client-ip"), websockets: yes("websockets"), websocket_legacy_support: yes("websockets") && yes("websocket-legacy-support"), websocket_rfc8441: yes("websockets") && ["h2", "h2h3"].includes(str("websocket-mode")), websocket_rfc9220: yes("websockets") && ["h3", "h2h3"].includes(str("websocket-mode")), http2_enabled: yes("http2-enabled"), http3_enabled: yes("http3-enabled"), cloudflare_client_ip: yes("cloudflare-client-ip"), timing_headers: yes("timing-headers"), backend_header: yes("backend-header"), backend_timing_header: yes("backend-timing-header"), basic_auth: basicEditor.read(), certificate_id: nullable("certificate-id"), tls_policy: yes("tls-policy-custom") ? { tls12: yes("tls-policy-12"), tls13: yes("tls-policy-13"), cipher_suites: lines(str("tls-policy-ciphers")) } : null, cache: collectCache(), transform: collectTransform(), pages: pagesEditor.read(), frontend: { hosts: readHosts(), listener_ids: bindingSelection }, acl_profile_id: nullable("acl-profile-id"),
id: str("id"), enabled: yes("enabled"), source: mainSourceType === "web_server" ? { type: "web_server", web_service_id: str("source-web-service") } : { type: "reverse_proxy" }, force_ssl: yes("force-ssl"), forward_client_ip: yes("forward-client-ip"), websockets: yes("websockets"), websocket_legacy_support: yes("websockets") && yes("websocket-legacy-support"), websocket_rfc8441: yes("websockets") && ["h2", "h2h3"].includes(str("websocket-mode")), websocket_rfc9220: yes("websockets") && ["h3", "h2h3"].includes(str("websocket-mode")), http2_enabled: yes("http2-enabled"), http3_enabled: yes("http3-enabled"), cloudflare_client_ip: yes("cloudflare-client-ip"), timing_headers: yes("timing-headers"), backend_header: yes("backend-header"), backend_timing_header: yes("backend-timing-header"), basic_auth: basicEditor.read(), certificate_id: nullable("certificate-id"), tls_policy: yes("tls-policy-custom") ? { tls12: yes("tls-policy-12"), tls13: yes("tls-policy-13"), cipher_suites: lines(str("tls-policy-ciphers")) } : null, cache: collectCache(), transform: collectTransform(), pages: pagesEditor.read(), frontend: { hosts: readHosts(), listener_ids: bindingSelection }, acl_profile_id: nullable("acl-profile-id"),
proxy_timeouts: { connect_timeout_seconds: num("proxy-connect-timeout"), response_header_timeout_seconds: num("proxy-header-timeout"), read_timeout_seconds: num("proxy-read-timeout"), send_timeout_seconds: num("proxy-send-timeout") },
rate_limit: { enabled: yes("rate-enabled"), requests: num("rate-requests"), window_seconds: num("rate-window"), burst: num("rate-burst"), country_access: { mode: str("country-mode"), countries: lines(str("country-list")).map(value => value.toUpperCase()) }, country_limits: countryRatesReader() },
server_header: str("server-mode") === "inherit" ? null : { mode: str("server-mode"), value: str("server-value") },
@@ -710,6 +738,11 @@
if (!envelope) return;
if (!initialized) load(defaults(), [], false);
const config = envelope.config;
const configuredHasProxySource = (config.vhosts || []).some(vhost =>
vhost.source?.type !== "web_server" || (vhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy")
);
const diagnosticsCard = $("backend-diagnostics-card");
if (diagnosticsCard) diagnosticsCard.hidden = !configuredHasProxySource;
const query = ($("vhost-search")?.value || "").trim().toLowerCase();
const visibleVhosts = (config.vhosts || []).filter(v => {
if (!query) return true;
@@ -717,6 +750,8 @@
v.id,
...(v.frontend?.hosts || []),
...(v.backends || []).flatMap(backend => [backend.id, backend.url]),
v.source?.web_service_id,
...(v.routes || []).map(route => route.source?.web_service_id),
].filter(Boolean).join(" ").toLowerCase();
return haystack.includes(query);
});
@@ -725,6 +760,7 @@
const head = el("div", undefined, "vhost-card-head"), identity = el("div", undefined, "vhost-card-identity");
const title = el("div", undefined, "vhost-title-line"); title.append(el("strong", v.id, "entity-title"));
const state = el("span", t(v.enabled === false ? "vhDisabled" : "vhEnabled"), `pill ${v.enabled === false ? "" : "live"}`); title.append(state);
if (v.source?.type === "web_server") title.append(el("span", `${t("webServer")}: ${v.source.web_service_id}`, "pill"));
identity.append(title, el("div", (v.frontend?.hosts || []).join(", ") || "-", "vhost-hosts")); head.append(identity);
const actions = el("div", undefined, "entity-actions");
const configuredHosts = (v.frontend?.hosts || []).filter(Boolean);
@@ -762,7 +798,11 @@
["vhCertificate", v.certificate_id || t("vhCertificateDefault")],
["vhAclProfile", v.acl_profile_id ? ((config.acl_profiles || []).find(profile => profile.id === v.acl_profile_id)?.name || v.acl_profile_id) : t("vhAclProfileNone")],
["cache", v.cache?.enabled === true ? t("enabled") : v.cache?.enabled === false ? t("notEnabled") : t("inherit")],
["vhBackendPool", `${(v.backends || []).length}`],
["vhBackendPool", v.source?.type === "web_server"
? ((v.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy") && (v.backends || []).length
? `${t("webServer")} + ${(v.backends || []).length}`
: t("webServer"))
: `${(v.backends || []).length}`],
];
for (const [key, value] of detailItems) { const item = el("div", undefined, "vhost-detail"); item.append(text("span", key, "small-label"), el("b", value)); details.append(item); }
box.append(details);
@@ -773,7 +813,10 @@
box.append(live);
const chart = el("div", undefined, "vhost-chart"); const chartHead = el("div", undefined, "vhost-chart-head"); chartHead.append(text("span", "vhLifetimeChart", "small-label")); const chartValue = el("span", "-", "fine"); chartValue.dataset.chartValueFor = v.id; chartHead.append(chartValue); chart.append(chartHead);
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.setAttribute("viewBox", "0 0 320 54"); svg.setAttribute("preserveAspectRatio", "none"); svg.dataset.chartFor = v.id; svg.setAttribute("aria-label", t("vhLifetimeChart")); const polyline = document.createElementNS("http://www.w3.org/2000/svg", "polyline"); polyline.setAttribute("class", "vhost-chart-line"); svg.append(polyline); chart.append(svg); box.append(chart);
const health = el("p", t("vhHealthEmpty"), "vh-health-summary"); health.dataset.healthFor = v.id; box.append(health);
const hasProxySource = v.source?.type !== "web_server" || (v.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy");
if (hasProxySource) {
const health = el("p", t("vhHealthEmpty"), "vh-health-summary"); health.dataset.healthFor = v.id; box.append(health);
}
const policy = el("div", undefined, "vhost-policy-pills");
if (v.basic_auth?.enabled) policy.append(el("small", t("authBasic"), "pill"));
if (v.tinyauth?.enabled) policy.append(el("small", "Tinyauth", "pill"));
@@ -809,10 +852,20 @@
backendTrafficHistory.set(key, { total, at: now, snapshot, rate });
return rate;
}
function vhostTrafficRate(id, metrics, snapshot, now, seen) {
seen.add(id);
const total = Math.max(0, Number(metrics?.bytes_in) || 0) + Math.max(0, Number(metrics?.bytes_out) || 0);
const previous = vhostTrafficHistory.get(id);
if (previous?.snapshot === snapshot) return previous.rate;
const elapsed = previous ? Math.max(.001, (now - previous.at) / 1000) : 0;
const rate = previous && total >= previous.total ? (total - previous.total) / elapsed : 0;
vhostTrafficHistory.set(id, { total, at: now, snapshot, rate });
return rate;
}
function renderStatus(status) {
form.querySelectorAll(".vh-member").forEach(row => row.syncLocalRisk?.());
const rows = [], summaries = new Map(), aggregate = new Map();
const trafficSnapshot = status, trafficNow = performance.now(), seenTraffic = new Set();
const trafficSnapshot = status, trafficNow = performance.now(), seenTraffic = new Set(), seenVhostTraffic = new Set();
const statusKeys = { up: "vhUp", down: "vhDown", checking: "vhChecking", draining: "vhDraining", disabled: "vhDisabled" };
const configuredVhosts = new Map((envelope?.config.vhosts || []).map(vhost => [vhost.id, vhost]));
for (const pool of status.backend_pools || []) {
@@ -846,10 +899,36 @@
});
}
for (const key of backendTrafficHistory.keys()) if (!seenTraffic.has(key)) backendTrafficHistory.delete(key);
$("pool-rows").replaceChildren(...rows); $("pool-empty").hidden = rows.length > 0;
for (const [id, vhost] of configuredVhosts) {
const existing = aggregate.get(id) || {};
const metrics = status.vhost_metrics?.[id];
const hasProxySource = vhost.source?.type !== "web_server" || (vhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy");
const disabled = vhost.enabled === false;
aggregate.set(id, {
...existing,
disabled,
staticOnly: !hasProxySource,
healthMode: existing.healthMode,
up: existing.up ?? 0,
total: existing.total ?? 0,
active: metrics ? Number(metrics.active) || 0 : existing.active ?? 0,
requests: metrics ? Number(metrics.requests) || 0 : existing.requests ?? 0,
errors: metrics ? Number(metrics.errors) || 0 : existing.errors ?? 0,
traffic: metrics ? vhostTrafficRate(id, metrics, trafficSnapshot, trafficNow, seenVhostTraffic) : existing.traffic ?? 0,
latency: metrics ? Number(metrics.ewma_latency_ms) || 0 : existing.latency ?? 0,
});
}
for (const key of vhostTrafficHistory.keys()) if (!seenVhostTraffic.has(key)) vhostTrafficHistory.delete(key);
const hasConfiguredProxySource = [...configuredVhosts.values()].some(vhost =>
vhost.source?.type !== "web_server" || (vhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy")
);
$("pool-rows").replaceChildren(...rows);
$("pool-empty").hidden = rows.length > 0 || !hasConfiguredProxySource;
const backendDiagnosticsCard = $("backend-diagnostics-card");
if (backendDiagnosticsCard) backendDiagnosticsCard.hidden = !hasConfiguredProxySource;
document.querySelectorAll("[data-health-for]").forEach(node => node.textContent = summaries.get(node.dataset.healthFor) || t("vhHealthEmpty"));
const fill = (attr, format) => document.querySelectorAll(`[data-${attr.replace(/[A-Z]/g, m => `-${m.toLowerCase()}`)}]`).forEach(node => { const data = aggregate.get(node.dataset[attr]); node.textContent = data ? format(data) : "-"; });
fill("vhostHealth", d => d.disabled ? t("vhDisabled") : d.healthMode === "off" ? t("vhHealthDisabled") : `${d.up}/${d.total} ${t("vhUp")}`);
fill("vhostHealth", d => d.disabled ? t("vhDisabled") : d.staticOnly ? t("vhUp") : d.healthMode === "off" ? t("vhHealthDisabled") : `${d.up}/${d.total} ${t("vhUp")}`);
fill("vhostRequests", d => fmt(d.requests)); fill("vhostActive", d => fmt(d.active)); fill("vhostErrors", d => fmt(d.errors));
fill("vhostLatency", d => d.latency > 0 ? `${fmt(d.latency, 1)} ms` : "-"); fill("vhostTraffic", d => `${rateBytes(d.traffic)}/s`);
const now = performance.now();
@@ -863,7 +942,10 @@
const displayPoints = history.pendingCount ? [...history.points, history.pendingSum / history.pendingCount] : history.points; const actualPeak = Math.max(0, ...displayPoints), scalePeak = Math.max(1, actualPeak); const points = displayPoints.map((value, index) => `${index * 320 / Math.max(1, displayPoints.length - 1)},${50 - value / scalePeak * 44}`).join(" ");
const svg = document.querySelector(`[data-chart-for="${CSS.escape(id)}"]`); svg?.querySelector("polyline")?.setAttribute("points", points); const label = document.querySelector(`[data-chart-value-for="${CSS.escape(id)}"]`); if (label) label.textContent = `${rate.toFixed(1)} ${t("vhReqPerSecond")} · ${t("vhPeak")} ${actualPeak.toFixed(1)}`;
}
$("vhost-health-live").textContent = editing ? (summaries.get(editing) || t("vhHealthEmpty")) : t("vhNewHealthHint");
const editingVhost = editing ? configuredVhosts.get(editing) : null;
const editingHasProxy = editingVhost && (editingVhost.source?.type !== "web_server" || (editingVhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy"));
$("vhost-health-live").hidden = !!editingVhost && !editingHasProxy;
$("vhost-health-live").textContent = editing ? (editingHasProxy ? (summaries.get(editing) || t("vhHealthEmpty")) : "") : t("vhNewHealthHint");
}
form.addEventListener("input", changed); form.addEventListener("change", changed);
form.addEventListener("submit", async event => {
@@ -894,6 +976,6 @@
});
$("pool-open").addEventListener("click", () => setPanel("vhosts"));
$("location-help-close")?.addEventListener("click", () => $("location-help-dialog")?.close());
window.rustProxyVhosts = { render, renderStatus, reset() { dialog.close(); draftEpoch++; stagedCertificates = []; uploading = false; editing = null; revision = null; dirty = false; initialized = false; chartHistory.clear(); container.replaceChildren(); $("vhost-search").value = ""; $("vhost-list").replaceChildren(); $("pool-rows").replaceChildren(); } };
window.rustProxyVhosts = { render, renderStatus, reset() { dialog.close(); draftEpoch++; stagedCertificates = []; uploading = false; editing = null; revision = null; dirty = false; initialized = false; chartHistory.clear(); backendTrafficHistory.clear(); vhostTrafficHistory.clear(); container.replaceChildren(); $("vhost-search").value = ""; $("vhost-list").replaceChildren(); $("pool-rows").replaceChildren(); } };
if (envelope) render(); if (latestStatus) renderStatus(latestStatus);
})();
+438
View File
@@ -0,0 +1,438 @@
"use strict";
(() => {
let editing = null;
const form = $("web-service-form"), dialog = $("web-service-dialog");
const lines = value => value.split(/[\n,]+/).map(item => item.trim()).filter(Boolean);
const strictLines = value => value.split(/\n+/).map(item => item.trim()).filter(Boolean);
const mimeLines = value => Object.entries(value || {}).map(([extension, mime]) => `${extension} = ${mime}`).join("\n");
const mapLines = value => Object.entries(value || {}).map(([name, item]) => `${name} = ${item}`).join("\n");
const cacheLines = value => (value || []).map(rule => `${rule.matcher} = ${rule.cache_control}`).join("\n");
const rewriteLines = value => (value || []).map(rule => `${rule.pattern} => ${rule.status ? `${rule.status}:` : ""}${rule.replacement}`).join("\n");
const staticCacheRules = () => [
{ matcher: "ext:html", cache_control: "no-cache" },
{ matcher: "ext:htm", cache_control: "no-cache" },
{ matcher: "ext:css", cache_control: "public, max-age=604800" },
{ matcher: "ext:js", cache_control: "public, max-age=604800" },
{ matcher: "ext:mjs", cache_control: "public, max-age=604800" },
{ matcher: "mime:image/", cache_control: "public, max-age=2592000" },
{ matcher: "mime:font/", cache_control: "public, max-age=2592000" },
];
const phpFrontController = () => ["$uri", "$uri/", "/index.php?$query_string"];
const presetHints = {
static: "webServicePresetHintStatic", spa: "webServicePresetHintSpa", php_generic: "webServicePresetHintPhpGeneric",
wordpress: "webServicePresetHintWordpress", drupal: "webServicePresetHintDrupal", laravel: "webServicePresetHintLaravel",
symfony: "webServicePresetHintSymfony", joomla: "webServicePresetHintJoomla", prestashop: "webServicePresetHintPrestashop",
downloads: "webServicePresetHintDownloads"
};
function parseMimeTypes(value) {
const result = {};
for (const line of strictLines(value)) {
const match = line.match(/^\.?([A-Za-z0-9_-]{1,32})\s*=\s*(.+)$/);
if (!match) throw new Error(t("webServiceMimeInvalid"));
const extension = match[1].toLowerCase(), mime = match[2].trim();
if (!mime || Object.hasOwn(result, extension)) throw new Error(t("webServiceMimeInvalid"));
result[extension] = mime;
}
return result;
}
function parseKeyValueLines(value, errorKey, normalizeKey = key => key) {
const result = {};
for (const line of strictLines(value)) {
const split = line.indexOf("=");
if (split <= 0) throw new Error(t(errorKey));
const key = normalizeKey(line.slice(0, split).trim()), item = line.slice(split + 1).trim();
if (!key || !item || Object.hasOwn(result, key)) throw new Error(t(errorKey));
result[key] = item;
}
return result;
}
const reservedFastCgiParams = new Set([
"REQUEST_METHOD", "SCRIPT_FILENAME", "SCRIPT_NAME", "QUERY_STRING", "REQUEST_URI", "DOCUMENT_ROOT",
"SERVER_PROTOCOL", "SERVER_NAME", "SERVER_PORT", "REMOTE_ADDR", "REMOTE_PORT", "HTTPS",
"GATEWAY_INTERFACE", "SERVER_SOFTWARE", "REDIRECT_STATUS", "CONTENT_TYPE", "CONTENT_LENGTH",
"PATH_INFO", "PATH_TRANSLATED", "PHP_VALUE", "PHP_ADMIN_VALUE"
]);
function parseFastCgiParams(value) {
const result = parseKeyValueLines(value, "webServicePhpCustomParamsInvalid", key => key.toUpperCase());
const entries = Object.entries(result);
if (entries.length > 64) throw new Error(t("webServicePhpCustomParamsInvalid"));
for (const [name, item] of entries) {
if (!/^[A-Z][A-Z0-9_]{0,63}$/.test(name) || name.startsWith("HTTP_") || reservedFastCgiParams.has(name) || item.length > 8192 || /[\x00-\x1f\x7f]/.test(item)) {
throw new Error(t("webServicePhpCustomParamsInvalid"));
}
}
return result;
}
function parsePhpValues(value) {
const result = parseKeyValueLines(value, "webServicePhpValuesInvalid", key => key.toLowerCase());
const entries = Object.entries(result);
if (entries.length > 64) throw new Error(t("webServicePhpValuesInvalid"));
for (const [name, item] of entries) {
if (!/^[a-z0-9_.-]{1,64}$/.test(name) || item.length > 8192 || /[\x00-\x1f\x7f]/.test(item)) {
throw new Error(t("webServicePhpValuesInvalid"));
}
}
return result;
}
function validatePhpCustomData(customParams, phpValues) {
const size = [...Object.entries(customParams), ...Object.entries(phpValues)]
.reduce((total, [name, value]) => total + name.length + value.length + 2, 0);
if (size > 128 * 1024) throw new Error(t("webServicePhpCustomDataTooLarge"));
}
function parseCacheRules(value) {
const result = [];
for (const line of strictLines(value)) {
const split = line.indexOf("=");
if (split <= 0) throw new Error(t("webServiceCacheRulesInvalid"));
const matcher = line.slice(0, split).trim().toLowerCase(), cache_control = line.slice(split + 1).trim();
if (!/^(?:ext:[A-Za-z0-9_-]{1,32}|mime:.+)$/.test(matcher) || !cache_control) throw new Error(t("webServiceCacheRulesInvalid"));
result.push({ matcher, cache_control });
}
return result;
}
function parseRewrites(value) {
const result = [];
for (const line of strictLines(value)) {
const split = line.indexOf("=>");
if (split <= 0) throw new Error(t("webServiceRewritesInvalid"));
const pattern = line.slice(0, split).trim();
let replacement = line.slice(split + 2).trim(), status = null;
const redirect = replacement.match(/^(301|302|307|308):(\/.*)$/);
if (redirect) { status = Number(redirect[1]); replacement = redirect[2]; }
if (!pattern || !replacement.startsWith("/")) throw new Error(t("webServiceRewritesInvalid"));
result.push({ pattern, replacement, status });
}
return result;
}
const tabDefinitions = [
["general", "webServiceTabGeneral"],
["routing", "webServiceTabRouting"],
["htaccess", "webServiceTabHtaccess"],
["mime", "webServiceTabMime"],
["cache", "webServiceTabCacheHeaders"],
["delivery", "webServiceTabDelivery"],
["php", "webServiceTabPhp"],
["security", "webServiceTabSecurity"],
];
function activateTab(name) {
document.querySelectorAll("#web-service-tabs button").forEach(button => {
const active = button.dataset.tab === name;
button.classList.toggle("active", active);
button.setAttribute("aria-pressed", String(active));
button.setAttribute("aria-selected", String(active));
button.tabIndex = active ? 0 : -1;
});
document.querySelectorAll("[data-web-service-page]").forEach(page => { page.hidden = page.dataset.webServicePage !== name; });
}
function initTabs() {
const tabs = $("web-service-tabs");
tabs.replaceChildren();
tabs.setAttribute("role", "tablist");
for (const [name, key] of tabDefinitions) {
const button = el("button", t(key), "quiet");
button.type = "button";
button.dataset.tab = name;
button.dataset.i18n = key;
button.setAttribute("role", "tab");
button.addEventListener("click", () => activateTab(name));
tabs.append(button);
}
activateTab("general");
}
function syncPhp() {
const enabled = $("web-service-php-enabled").checked;
for (const id of [
"web-service-php-endpoint", "web-service-php-extensions", "web-service-php-connect-timeout",
"web-service-php-request-timeout", "web-service-php-concurrency", "web-service-php-pass-authorization",
"web-service-php-body-limit", "web-service-php-header-limit", "web-service-php-stderr-limit",
"web-service-php-custom-params", "web-service-php-values"
]) $(id).disabled = !enabled;
}
function syncPrecompressed() {
const enabled = $("web-service-precompressed").checked;
$("web-service-precompressed-br").disabled = !enabled;
$("web-service-precompressed-gzip").disabled = !enabled;
}
function syncHtaccess() {
const enabled = $("web-service-htaccess-enabled").checked;
for (const id of ["web-service-htaccess-filename", "web-service-htaccess-recursive", "web-service-htaccess-strict"]) $(id).disabled = !enabled;
}
const sourceId = source => source?.type === "web_server" ? source.web_service_id : null;
function usage(id) {
const uses = [];
for (const vhost of envelope?.config.vhosts || []) {
if (sourceId(vhost.source) === id) uses.push(vhost.id);
for (const route of vhost.routes || []) if (!route.response && sourceId(route.source) === id) uses.push(`${vhost.id}/${route.id}`);
}
return uses;
}
function defaults() {
return {
id: "", name: "", document_root: "", index: ["index.html", "index.htm"], directory_listing: false,
mime_types: {}, default_mime_type: "application/octet-stream", cache_control: null, cache_rules: [], response_headers: {},
try_files: [], rewrites: [], precompressed: { enabled: false, brotli: true, gzip: true }, download_extensions: [],
symlink_policy: "deny", php: {
enabled: false, extensions: ["php"], endpoint: "unix:/run/php/php-fpm.sock", connect_timeout_ms: 1000,
request_timeout_ms: 30000, max_concurrent_requests: 64, pass_authorization: true, max_request_body_bytes: 0,
max_response_header_bytes: 65536, stderr_log_bytes: 4096, custom_params: {}, php_values: {}
},
htaccess: { enabled: false, filename: ".htaccess", scan_subdirectories: true, strict: false },
hardening: { block_common_attacks: false, strip_unnecessary_headers: false }
};
}
function presetConfig(name) {
const item = defaults();
item.precompressed.enabled = true;
item.cache_rules = staticCacheRules();
if (name === "static") return item;
if (name === "spa") { item.index = ["index.html"]; item.try_files = ["$uri", "/index.html"]; return item; }
if (name === "downloads") {
item.index = []; item.directory_listing = true; item.cache_control = "public, max-age=3600"; item.cache_rules = [];
item.precompressed.enabled = false; item.download_extensions = ["zip", "7z", "tar", "gz", "tgz", "bz2", "xz", "pdf", "iso"]; return item;
}
item.index = ["index.php", "index.html", "index.htm"];
item.php.enabled = true;
item.php.extensions = ["php"];
if (name === "php_generic") { item.try_files = ["$uri", "$uri/", "=404"]; return item; }
if (["wordpress", "drupal", "laravel", "symfony", "joomla", "prestashop"].includes(name)) {
item.try_files = phpFrontController();
return item;
}
return null;
}
function populateSettings(item) {
$("web-service-index").value = (item.index || []).join("\n");
$("web-service-listing").checked = item.directory_listing === true;
$("web-service-try-files").value = (item.try_files || []).join("\n");
$("web-service-rewrites").value = rewriteLines(item.rewrites);
$("web-service-mime-types").value = mimeLines(item.mime_types);
$("web-service-default-mime").value = item.default_mime_type || "application/octet-stream";
$("web-service-cache-control").value = item.cache_control || "";
$("web-service-cache-rules").value = cacheLines(item.cache_rules);
$("web-service-response-headers").value = mapLines(item.response_headers);
$("web-service-precompressed").checked = item.precompressed?.enabled === true;
$("web-service-precompressed-br").checked = item.precompressed?.brotli !== false;
$("web-service-precompressed-gzip").checked = item.precompressed?.gzip !== false;
$("web-service-download-extensions").value = (item.download_extensions || []).join("\n");
$("web-service-php-enabled").checked = item.php?.enabled === true;
$("web-service-php-endpoint").value = item.php?.endpoint || "unix:/run/php/php-fpm.sock";
$("web-service-php-extensions").value = (item.php?.extensions || ["php"]).join(", ");
$("web-service-php-connect-timeout").value = item.php?.connect_timeout_ms ?? 1000;
$("web-service-php-request-timeout").value = item.php?.request_timeout_ms ?? 30000;
$("web-service-php-concurrency").value = item.php?.max_concurrent_requests ?? 64;
$("web-service-php-pass-authorization").checked = item.php?.pass_authorization !== false;
$("web-service-php-body-limit").value = item.php?.max_request_body_bytes ?? 0;
$("web-service-php-header-limit").value = item.php?.max_response_header_bytes ?? 65536;
$("web-service-php-stderr-limit").value = item.php?.stderr_log_bytes ?? 4096;
$("web-service-php-custom-params").value = mapLines(item.php?.custom_params);
$("web-service-php-values").value = mapLines(item.php?.php_values);
$("web-service-symlink-policy").value = item.symlink_policy || "deny";
$("web-service-htaccess-enabled").checked = item.htaccess?.enabled === true;
$("web-service-htaccess-filename").value = item.htaccess?.filename || ".htaccess";
$("web-service-htaccess-recursive").checked = item.htaccess?.scan_subdirectories !== false;
$("web-service-htaccess-strict").checked = item.htaccess?.strict === true;
$("web-service-hardening-attacks").checked = item.hardening?.block_common_attacks === true;
$("web-service-hardening-headers").checked = item.hardening?.strip_unnecessary_headers === true;
syncPhp();
syncPrecompressed();
syncHtaccess();
}
function updatePresetSummary() {
const key = presetHints[$("web-service-preset").value];
$("web-service-preset-summary").textContent = key ? t(key) : t("webServicePresetHelp");
}
function applyPreset() {
try {
const preset = presetConfig($("web-service-preset").value);
if (!preset) { toast(t("webServicePresetChoose"), true); return; }
// Keep deployment-specific FastCGI endpoint/tuning while replacing application policy.
preset.php.endpoint = $("web-service-php-endpoint").value.trim() || preset.php.endpoint;
preset.php.connect_timeout_ms = Number($("web-service-php-connect-timeout").value || preset.php.connect_timeout_ms);
preset.php.request_timeout_ms = Number($("web-service-php-request-timeout").value || preset.php.request_timeout_ms);
preset.php.max_concurrent_requests = Number($("web-service-php-concurrency").value || preset.php.max_concurrent_requests);
preset.php.pass_authorization = $("web-service-php-pass-authorization").checked;
preset.php.max_request_body_bytes = Number($("web-service-php-body-limit").value || 0);
preset.php.max_response_header_bytes = Number($("web-service-php-header-limit").value || preset.php.max_response_header_bytes);
preset.php.stderr_log_bytes = Number($("web-service-php-stderr-limit").value || 0);
preset.php.custom_params = parseFastCgiParams($("web-service-php-custom-params").value);
preset.php.php_values = parsePhpValues($("web-service-php-values").value);
validatePhpCustomData(preset.php.custom_params, preset.php.php_values);
preset.hardening.block_common_attacks = $("web-service-hardening-attacks").checked;
preset.hardening.strip_unnecessary_headers = $("web-service-hardening-headers").checked;
populateSettings(preset);
updatePresetSummary();
toast(t("webServicePresetApplied"));
} catch (error) {
toast(error.message, true);
}
}
function presetChanged() {
updatePresetSummary();
// Presets are one-shot form generators. Selecting one must immediately
// materialize its normal Web Service fields; otherwise a user can select a
// CMS, save the form and unknowingly persist the previous/custom values.
if ($("web-service-preset").value !== "custom") applyPreset();
}
function open(value = null) {
const item = Object.assign(defaults(), structuredClone(value || {}));
item.precompressed = Object.assign(defaults().precompressed, item.precompressed || {});
item.php = Object.assign(defaults().php, item.php || {});
item.htaccess = Object.assign(defaults().htaccess, item.htaccess || {});
item.hardening = Object.assign(defaults().hardening, item.hardening || {});
editing = value?.id || null;
$("web-service-id").value = item.id || "";
$("web-service-id").readOnly = !!editing;
$("web-service-name").value = item.name || "";
$("web-service-root").value = item.document_root || "";
populateSettings(item);
$("web-service-preset").value = "custom";
updatePresetSummary();
$("web-service-editing").textContent = t(editing ? "vhEditLabel" : "vhNewLabel");
activateTab("general");
dialog.showModal();
}
function close() { editing = null; form.reset(); if (dialog.open) dialog.close(); }
function render() {
if (!envelope) return;
const query = ($("web-service-search")?.value || "").trim().toLowerCase();
const services = (envelope.config.web_services || []).filter(service => !query || [service.id, service.name, service.document_root].join(" ").toLowerCase().includes(query));
const cards = services.map(service => {
const box = el("article", undefined, "vhost-card");
const head = el("div", undefined, "vhost-card-head"), identity = el("div", undefined, "vhost-card-identity");
const title = el("div", undefined, "vhost-title-line");
title.append(el("strong", service.name || service.id, "entity-title"), el("span", t("webServer"), "pill live"));
identity.append(title, el("div", service.document_root, "vhost-hosts"));
const actions = el("div", undefined, "entity-actions");
const edit = el("button", t("edit"), "quiet"); edit.type = "button"; edit.addEventListener("click", () => open(structuredClone(service)));
const remove = el("button", t("remove"), "quiet"); remove.type = "button"; remove.addEventListener("click", async () => {
const used = usage(service.id);
if (used.length) { toast(`${t("webServiceInUse")}: ${used.join(", ")}`, true); return; }
if (!(await appConfirm(t("webServiceDelete"), { danger: true }))) return;
const next = (envelope.config.web_services || []).filter(item => item.id !== service.id);
if (await saveSection("web-services", next)) render();
});
actions.append(edit, remove); head.append(identity, actions); box.append(head);
const details = el("div", undefined, "vhost-detail-strip");
const detailItems = [
["webServiceId", service.id],
["webServiceIndex", (service.index || []).join(", ") || t("webServiceNoIndex")],
["webServiceDirectoryListing", t(service.directory_listing ? "enabled" : "notEnabled")],
["webServiceTryFiles", (service.try_files || []).length ? String(service.try_files.length) : "-"],
["webServiceHtaccessEnabled", t(service.htaccess?.enabled ? "enabled" : "notEnabled")],
["webServicePhpEnabled", t(service.php?.enabled ? "enabled" : "notEnabled")],
["webServicePrecompressed", t(service.precompressed?.enabled ? "enabled" : "notEnabled")],
["webServiceHardeningTitle", t((service.hardening?.block_common_attacks || service.hardening?.strip_unnecessary_headers) ? "enabled" : "notEnabled")],
["webServiceCacheControl", service.cache_control || t("inherit")],
["webServiceUsedBy", usage(service.id).length ? usage(service.id).join(", ") : t("webServiceUnused")],
];
for (const [key, value] of detailItems) {
const item = el("div", undefined, "vhost-detail");
item.append(el("span", t(key), "small-label"), el("b", value));
details.append(item);
}
box.append(details); return box;
});
$("web-service-list").replaceChildren(...(cards.length ? cards : [el("p", t(query ? "webServiceSearchEmpty" : "webServiceEmpty"), "fine")]));
}
form.addEventListener("submit", async event => {
event.preventDefault(); if (!envelope || busy) return;
const invalid = [...form.elements].find(input => input.willValidate && !input.checkValidity()); if (invalid) { activateTab(invalid.closest("[data-web-service-page]")?.dataset.webServicePage || "general"); invalid.reportValidity(); return; }
try {
const phpEnabled = $("web-service-php-enabled").checked;
const item = {
id: $("web-service-id").value.trim(),
name: $("web-service-name").value.trim(),
document_root: $("web-service-root").value.trim(),
index: lines($("web-service-index").value),
directory_listing: $("web-service-listing").checked,
mime_types: parseMimeTypes($("web-service-mime-types").value),
default_mime_type: $("web-service-default-mime").value.trim() || "application/octet-stream",
cache_control: $("web-service-cache-control").value.trim() || null,
cache_rules: parseCacheRules($("web-service-cache-rules").value),
response_headers: parseKeyValueLines($("web-service-response-headers").value, "webServiceResponseHeadersInvalid", key => key.toLowerCase()),
try_files: strictLines($("web-service-try-files").value),
rewrites: parseRewrites($("web-service-rewrites").value),
precompressed: { enabled: $("web-service-precompressed").checked, brotli: $("web-service-precompressed-br").checked, gzip: $("web-service-precompressed-gzip").checked },
download_extensions: lines($("web-service-download-extensions").value).map(value => value.replace(/^\./, "").toLowerCase()),
symlink_policy: $("web-service-symlink-policy").value,
htaccess: {
enabled: $("web-service-htaccess-enabled").checked,
filename: $("web-service-htaccess-filename").value.trim() || ".htaccess",
scan_subdirectories: $("web-service-htaccess-recursive").checked,
strict: $("web-service-htaccess-strict").checked
},
hardening: {
block_common_attacks: $("web-service-hardening-attacks").checked,
strip_unnecessary_headers: $("web-service-hardening-headers").checked
},
php: {
enabled: phpEnabled,
extensions: lines($("web-service-php-extensions").value).map(value => value.replace(/^\./, "").toLowerCase()),
endpoint: $("web-service-php-endpoint").value.trim() || "unix:/run/php/php-fpm.sock",
connect_timeout_ms: Number($("web-service-php-connect-timeout").value || 1000),
request_timeout_ms: Number($("web-service-php-request-timeout").value || 30000),
max_concurrent_requests: Number($("web-service-php-concurrency").value || 64),
pass_authorization: $("web-service-php-pass-authorization").checked,
max_request_body_bytes: Number($("web-service-php-body-limit").value || 0),
max_response_header_bytes: Number($("web-service-php-header-limit").value || 65536),
stderr_log_bytes: Number($("web-service-php-stderr-limit").value || 0),
custom_params: parseFastCgiParams($("web-service-php-custom-params").value),
php_values: parsePhpValues($("web-service-php-values").value)
}
};
validatePhpCustomData(item.php.custom_params, item.php.php_values);
if (phpEnabled && item.symlink_policy !== "deny") throw new Error(t("webServicePhpSymlinkConflict"));
const next = structuredClone(envelope.config.web_services || []);
const duplicate = next.find(service => service.id === item.id && service.id !== editing);
if (duplicate) { toast(t("webServiceDuplicate"), true); return; }
const position = next.findIndex(service => service.id === editing);
if (position >= 0) next[position] = item; else next.push(item);
if (await saveSection("web-services", next)) { close(); render(); }
} catch (error) {
toast(error.message, true);
}
});
initTabs();
$("web-service-php-enabled").addEventListener("change", syncPhp);
$("web-service-precompressed").addEventListener("change", syncPrecompressed);
$("web-service-htaccess-enabled").addEventListener("change", syncHtaccess);
$("web-service-preset").addEventListener("change", presetChanged);
$("web-service-preset-apply").addEventListener("click", applyPreset);
$("web-service-htaccess-help").addEventListener("click", () => { const help = $("htaccess-help-dialog"); if (!help.open) help.showModal(); });
$("htaccess-help-close").addEventListener("click", () => $("htaccess-help-dialog").close());
$("web-service-php-help").addEventListener("click", () => { const help = $("php-help-dialog"); if (!help.open) help.showModal(); });
$("php-help-close").addEventListener("click", () => $("php-help-dialog").close());
$("web-service-new").addEventListener("click", () => open());
$("web-service-close").addEventListener("click", close);
$("web-service-reset").addEventListener("click", close);
$("web-service-search").addEventListener("input", render);
dialog.addEventListener("cancel", event => { event.preventDefault(); close(); });
window.rustProxyWebServer = { render, reset: close };
if (envelope) render();
})();
+84 -34
View File
@@ -33,7 +33,10 @@ impl Cache {
for item in read_dir.flatten() {
let path = item.path();
if path.is_file() {
let extension = path.extension().and_then(|v| v.to_str()).unwrap_or_default();
let extension = path
.extension()
.and_then(|v| v.to_str())
.unwrap_or_default();
if matches!(extension, "json" | "body" | "tmp") {
let _ = std::fs::remove_file(path);
}
@@ -142,12 +145,24 @@ impl Cache {
revision,
scope: file.scope,
scheme: file.scheme,
authority: if file.authority.is_empty() { file.domain.clone() } else { file.authority },
authority: if file.authority.is_empty() {
file.domain.clone()
} else {
file.authority
},
domain: file.domain,
path: file.path.clone(),
target: if file.target.is_empty() { file.path } else { file.target },
target: if file.target.is_empty() {
file.path
} else {
file.target
},
tags: file.tags,
created_at: if file.created_at_ms == 0 { stored_at } else { system_time_ms(file.created_at_ms) },
created_at: if file.created_at_ms == 0 {
stored_at
} else {
system_time_ms(file.created_at_ms)
},
headers,
body_path,
meta_path: path,
@@ -310,7 +325,9 @@ impl Cache {
let key = key_hex(&draft.key);
let body_name = format!("{key}.body");
let (body_path, meta_path, body_tmp, meta_tmp) = cold_paths(&draft.root, &draft.key);
let Some(shard) = body_path.parent() else { return; };
let Some(shard) = body_path.parent() else {
return;
};
if tokio::fs::create_dir_all(shard).await.is_err() {
self.counters.cold_errors.fetch_add(1, Ordering::Relaxed);
return;
@@ -334,7 +351,13 @@ impl Cache {
path: draft.entry.path.clone(),
target: draft.entry.target.clone(),
tags: draft.entry.tags.clone(),
created_at_ms: draft.entry.created_at.duration_since(UNIX_EPOCH).unwrap_or_default().as_millis().min(u64::MAX as u128) as u64,
created_at_ms: draft
.entry
.created_at
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
.min(u64::MAX as u128) as u64,
headers,
body_file: body_name,
body_bytes: draft.entry.body.len(),
@@ -347,8 +370,18 @@ impl Cache {
stale_if_error: draft.entry.stale_if_error,
must_revalidate: draft.entry.must_revalidate,
proxy_revalidate: draft.entry.proxy_revalidate,
origin_etag: draft.entry.origin_etag.as_ref().and_then(|v| v.to_str().ok()).map(str::to_owned),
origin_last_modified: draft.entry.origin_last_modified.as_ref().and_then(|v| v.to_str().ok()).map(str::to_owned),
origin_etag: draft
.entry
.origin_etag
.as_ref()
.and_then(|v| v.to_str().ok())
.map(str::to_owned),
origin_last_modified: draft
.entry
.origin_last_modified
.as_ref()
.and_then(|v| v.to_str().ok())
.map(str::to_owned),
last_access_ms: now_ms(),
hits: draft.entry.hits,
transformed: draft.entry.transformed,
@@ -592,8 +625,19 @@ impl Cache {
.get(&entry.scope)
.copied()
.unwrap_or(0);
let key_generation = store.key_generations.get(&entry.base_key).copied().unwrap_or(0);
if !self.enqueue_demotion(key, entry, revision, generation, scope_generation, key_generation) {
let key_generation = store
.key_generations
.get(&entry.base_key)
.copied()
.unwrap_or(0);
if !self.enqueue_demotion(
key,
entry,
revision,
generation,
scope_generation,
key_generation,
) {
self.counters.evictions.fetch_add(1, Ordering::Relaxed);
}
}
@@ -633,18 +677,25 @@ impl Cache {
.get(&entry.scope)
.copied()
.unwrap_or(0);
let key_generation = store.key_generations.get(&entry.base_key).copied().unwrap_or(0);
let key_generation = store
.key_generations
.get(&entry.base_key)
.copied()
.unwrap_or(0);
let bytes = entry.cost;
let memory_lease = self.try_reserve_transient(bytes, &settings).unwrap_or_else(|| {
let bytes = bytes as u64;
self.transient_bytes_active.fetch_add(bytes, Ordering::Relaxed);
TransientLease {
_inner: Arc::new(TransientLeaseInner {
counter: self.transient_bytes_active.clone(),
bytes,
}),
}
});
let memory_lease =
self.try_reserve_transient(bytes, &settings)
.unwrap_or_else(|| {
let bytes = bytes as u64;
self.transient_bytes_active
.fetch_add(bytes, Ordering::Relaxed);
TransientLease {
_inner: Arc::new(TransientLeaseInner {
counter: self.transient_bytes_active.clone(),
bytes,
}),
}
});
ColdDraft {
key,
revision,
@@ -780,7 +831,10 @@ impl Cache {
let batch: Vec<_> = {
let cold = self.cold.lock().unwrap_or_else(|e| e.into_inner());
cold.lru
.range((std::ops::Bound::Excluded(after_sequence), std::ops::Bound::Unbounded))
.range((
std::ops::Bound::Excluded(after_sequence),
std::ops::Bound::Unbounded,
))
.take(SNAPSHOT_BATCH)
.filter_map(|(sequence, key)| {
cold.entries.get(key).map(|entry| {
@@ -860,11 +914,9 @@ impl Cache {
break;
}
}
if !cold
.entries
.get(&candidate.key)
.is_some_and(|entry| entry.sequence == candidate.sequence && entry.pinned_reads == 0)
{
if !cold.entries.get(&candidate.key).is_some_and(|entry| {
entry.sequence == candidate.sequence && entry.pinned_reads == 0
}) {
continue;
}
if let Some(entry) = cold.remove(&candidate.key) {
@@ -940,7 +992,10 @@ impl Cache {
.clone()
.try_acquire_owned()
.map_err(|_| anyhow::anyhow!("cold cache cleanup is already running"))?;
let id = self.cleanup_sequence.fetch_add(1, Ordering::Relaxed).saturating_add(1);
let id = self
.cleanup_sequence
.fetch_add(1, Ordering::Relaxed)
.saturating_add(1);
let job = ColdCleanupJob {
id,
status: "queued",
@@ -974,12 +1029,7 @@ impl Cache {
.unwrap_or_else(|e| e.into_inner())
.clone();
let result = cache
.remove_cold_candidates(
Some(&request),
None,
None,
settings.cold_gc_max_delete,
)
.remove_cold_candidates(Some(&request), None, None, settings.cold_gc_max_delete)
.await;
let mut jobs = cache.cleanup_jobs.lock().unwrap_or_else(|e| e.into_inner());
if let Some(job) = jobs.get_mut(&id) {
+10 -28
View File
@@ -36,10 +36,7 @@ impl Drop for CollapseWaiterGuard {
impl Drop for FillLeaderGuard {
fn drop(&mut self) {
if let Some(cache) = self.cache.upgrade() {
let mut flights = cache
.fill_flights
.lock()
.unwrap_or_else(|e| e.into_inner());
let mut flights = cache.fill_flights.lock().unwrap_or_else(|e| e.into_inner());
let current = flights
.get(&self.key)
.is_some_and(|flight| flight.id == self.id);
@@ -53,19 +50,12 @@ impl Drop for FillLeaderGuard {
}
impl Cache {
pub async fn claim_fill(
self: &Arc<Self>,
ticket: &Ticket,
settings: &Settings,
) -> FillClaim {
pub async fn claim_fill(self: &Arc<Self>, ticket: &Ticket, settings: &Settings) -> FillClaim {
if !settings.collapse_enabled {
return FillClaim::Disabled;
}
let existing = {
let mut flights = self
.fill_flights
.lock()
.unwrap_or_else(|e| e.into_inner());
let mut flights = self.fill_flights.lock().unwrap_or_else(|e| e.into_inner());
if let Some(flight) = flights.get(&ticket.key) {
Some(flight.clone())
} else {
@@ -131,11 +121,11 @@ impl Cache {
fn cancel_all_fill_flights(&self) {
let flights = {
let mut flights = self
.fill_flights
.lock()
.unwrap_or_else(|e| e.into_inner());
flights.drain().map(|(_, flight)| flight).collect::<Vec<_>>()
let mut flights = self.fill_flights.lock().unwrap_or_else(|e| e.into_inner());
flights
.drain()
.map(|(_, flight)| flight)
.collect::<Vec<_>>()
};
for flight in flights {
flight.done.send_replace(true);
@@ -144,10 +134,7 @@ impl Cache {
fn cancel_scope_fill_flights(&self, scope: &str) {
let removed = {
let mut flights = self
.fill_flights
.lock()
.unwrap_or_else(|e| e.into_inner());
let mut flights = self.fill_flights.lock().unwrap_or_else(|e| e.into_inner());
let keys = flights
.iter()
.filter(|(_, flight)| flight.scope == scope)
@@ -250,12 +237,7 @@ impl Cache {
}
if self
.proactive_inflight
.compare_exchange_weak(
current,
current + 1,
Ordering::AcqRel,
Ordering::Relaxed,
)
.compare_exchange_weak(current, current + 1, Ordering::AcqRel, Ordering::Relaxed)
.is_ok()
{
self.counters
+21 -5
View File
@@ -102,14 +102,22 @@ impl Cache {
("bans_removed_total", "counter", view.bans_removed),
("ban_matches_total", "counter", view.ban_matches),
("active_bans", "gauge", view.active_bans as u64),
("proactive_scheduled_total", "counter", view.proactive_scheduled),
(
"proactive_scheduled_total",
"counter",
view.proactive_scheduled,
),
("proactive_started_total", "counter", view.proactive_started),
("proactive_skipped_total", "counter", view.proactive_skipped),
("proactive_inflight", "gauge", view.proactive_inflight),
("entries", "gauge", view.entries as u64),
("resident_bytes", "gauge", view.resident_bytes as u64),
("hot_logical_bytes", "gauge", view.hot_logical_bytes as u64),
("hot_allocated_bytes", "gauge", view.hot_allocated_bytes as u64),
(
"hot_allocated_bytes",
"gauge",
view.hot_allocated_bytes as u64,
),
("hot_limit_bytes", "gauge", view.hot_limit_bytes as u64),
(
"transient_limit_bytes",
@@ -124,7 +132,11 @@ impl Cache {
("cold_entries", "gauge", view.cold_entries as u64),
("cold_bytes", "gauge", view.cold_bytes),
("cold_limit_bytes", "gauge", view.cold_limit_bytes),
("cold_limit_entries", "gauge", view.cold_limit_entries as u64),
(
"cold_limit_entries",
"gauge",
view.cold_limit_entries as u64,
),
("cold_hits_total", "counter", view.cold_hits),
("cold_promotions_total", "counter", view.cold_promotions),
("hot_demotions_total", "counter", view.hot_demotions),
@@ -201,7 +213,9 @@ impl Cache {
let _ = tx.try_send(ColdCommand::PurgeDirectory(root));
}
self.counters.purges.fetch_add(1, Ordering::Relaxed);
self.counters.purge_entries.fetch_add(count as u64, Ordering::Relaxed);
self.counters
.purge_entries
.fetch_add(count as u64, Ordering::Relaxed);
count
}
/// Purge one vhost without evicting other hosts. Generation bumps also stop
@@ -242,7 +256,9 @@ impl Cache {
let _ = tx.try_send(ColdCommand::Delete(delete));
}
self.counters.purges.fetch_add(1, Ordering::Relaxed);
self.counters.purge_entries.fetch_add(count as u64, Ordering::Relaxed);
self.counters
.purge_entries
.fetch_add(count as u64, Ordering::Relaxed);
count
}
pub fn scope_view(&self, scope: &str) -> ScopeView {
+2 -2
View File
@@ -233,7 +233,8 @@ impl HttpBody for FillBody {
this.candidate = None;
} else {
let after = candidate.data.capacity();
let unbudgeted = after.saturating_sub(before.saturating_add(required_growth));
let unbudgeted =
after.saturating_sub(before.saturating_add(required_growth));
if unbudgeted > 0
&& !candidate.fill_gauge.grow(unbudgeted, &candidate.settings)
{
@@ -340,4 +341,3 @@ impl Drop for FillBody {
}
}
}
+80 -18
View File
@@ -84,6 +84,7 @@ impl Cache {
_ = maintenance_shutdown.cancelled() => break,
_ = tick.tick() => {
let Some(cache) = weak.upgrade() else { break; };
cache.trim_hot_idle();
cache.demote_idle();
cache.sweep_cold().await;
cache.gc_banned(256);
@@ -94,7 +95,9 @@ impl Cache {
let weak = Arc::downgrade(&cache);
tokio::spawn(async move {
loop {
let Some(cache) = weak.upgrade() else { break; };
let Some(cache) = weak.upgrade() else {
break;
};
let interval = cache
.tier_settings
.lock()
@@ -156,8 +159,8 @@ impl Cache {
}
let lease = self.try_reserve_transient(bytes, settings)?;
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
let fill_now = usize::try_from(self.fill_bytes_active.load(Ordering::Relaxed))
.unwrap_or(usize::MAX);
let fill_now =
usize::try_from(self.fill_bytes_active.load(Ordering::Relaxed)).unwrap_or(usize::MAX);
let allowed = settings
.max_bytes
.saturating_add(Self::fill_headroom_bytes(settings.max_bytes));
@@ -165,20 +168,14 @@ impl Cache {
let generation = store.generation;
let mut removed = 0usize;
let mut released_hot = false;
while store
.bytes
.saturating_add(fill_now)
.saturating_add(bytes)
> allowed
{
while store.bytes.saturating_add(fill_now).saturating_add(bytes) > allowed {
if removed >= settings.hot_eviction_max_objects {
self.counters
.hot_eviction_limited
.fetch_add(1, Ordering::Relaxed);
break;
}
let Some((_, key)) = store.lru.first_key_value().map(|(seq, key)| (*seq, *key))
else {
let Some((_, key)) = store.lru.first_key_value().map(|(seq, key)| (*seq, *key)) else {
break;
};
let Some(old) = store.remove(&key) else {
@@ -210,11 +207,7 @@ impl Cache {
self.counters.evictions.fetch_add(1, Ordering::Relaxed);
}
}
let fits = store
.bytes
.saturating_add(fill_now)
.saturating_add(bytes)
<= allowed;
let fits = store.bytes.saturating_add(fill_now).saturating_add(bytes) <= allowed;
if fits {
self.fill_bytes_active
.fetch_add(bytes as u64, Ordering::Relaxed);
@@ -234,6 +227,76 @@ impl Cache {
}
}
/// Bound long-lived HOT RSS without flushing the working set. Only entries
/// idle for the configured age are considered, and only while HOT usage is
/// above the configured target waterline.
fn trim_hot_idle(&self) {
let settings = self
.tier_settings
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone();
if !settings.hot_auto_trim_enabled {
return;
}
let target = settings
.max_bytes
.saturating_mul(settings.hot_auto_trim_target_percent as usize)
/ 100;
let idle_cutoff = Duration::from_secs(settings.hot_auto_trim_after_seconds);
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
if store.bytes <= target {
return;
}
let revision = store.revision;
let generation = store.generation;
let mut removed = 0usize;
while store.bytes > target && removed < settings.hot_eviction_max_objects {
let Some((_, key)) = store.lru.first_key_value().map(|(seq, key)| (*seq, *key)) else {
break;
};
let idle = store
.entries
.get(&key)
.is_some_and(|entry| entry.last_access.elapsed() >= idle_cutoff);
if !idle {
// LRU order is also last-access order, so newer entries cannot qualify.
break;
}
let Some(entry) = store.remove(&key) else {
continue;
};
removed += 1;
let scope_generation = store
.scope_generations
.get(&entry.scope)
.copied()
.unwrap_or(0);
let key_generation = store
.key_generations
.get(&entry.base_key)
.copied()
.unwrap_or(0);
if !(settings.cold_enabled
&& settings.cold_on_lru
&& self.enqueue_demotion(
key,
entry,
revision,
generation,
scope_generation,
key_generation,
))
{
self.counters.evictions.fetch_add(1, Ordering::Relaxed);
}
}
drop(store);
if removed > 0 {
release_allocator_memory();
}
}
pub fn configure(&self, settings: Settings) {
*self.tier_settings.lock().unwrap_or_else(|e| e.into_inner()) = settings.clone();
// Hot-applied limit reductions take effect immediately for published HOT
@@ -244,8 +307,7 @@ impl Cache {
let generation = store.generation;
let mut released_hot = false;
while store.entries.len() > settings.max_entries || store.bytes > settings.max_bytes {
let Some((_, key)) = store.lru.first_key_value().map(|(seq, key)| (*seq, *key))
else {
let Some((_, key)) = store.lru.first_key_value().map(|(seq, key)| (*seq, *key)) else {
break;
};
let Some(old) = store.remove(&key) else {
+23 -16
View File
@@ -13,12 +13,7 @@ impl Cache {
.copied()
.unwrap_or(0)
!= ticket.scope_generation
|| store
.key_generations
.get(&ticket.key)
.copied()
.unwrap_or(0)
!= ticket.key_generation
|| store.key_generations.get(&ticket.key).copied().unwrap_or(0) != ticket.key_generation
{
return None;
}
@@ -490,7 +485,10 @@ impl Cache {
skip_compression: parts.extensions.get::<crate::compression::Skip>().is_some(),
origin_etag,
origin_last_modified,
transformed: parts.extensions.get::<crate::transform::Applied>().is_some(),
transformed: parts
.extensions
.get::<crate::transform::Applied>()
.is_some(),
_permit: permit,
_fill_leader: fill_leader,
};
@@ -522,7 +520,10 @@ impl Cache {
}
if entry.cost > settings.max_bytes
|| (!entry.requires_revalidation
&& entry.initial_age.saturating_add(entry.received.elapsed()).as_secs()
&& entry
.initial_age
.saturating_add(entry.received.elapsed())
.as_secs()
>= retention_deadline(
entry.ttl,
entry.stale_while_revalidate,
@@ -541,12 +542,7 @@ impl Cache {
.copied()
.unwrap_or(0)
!= ticket.scope_generation
|| store
.key_generations
.get(&ticket.key)
.copied()
.unwrap_or(0)
!= ticket.key_generation
|| store.key_generations.get(&ticket.key).copied().unwrap_or(0) != ticket.key_generation
{
return false;
}
@@ -584,10 +580,21 @@ impl Cache {
.get(&old.scope)
.copied()
.unwrap_or(0);
let key_generation = store.key_generations.get(&old.base_key).copied().unwrap_or(0);
let key_generation = store
.key_generations
.get(&old.base_key)
.copied()
.unwrap_or(0);
if !(settings.cold_enabled
&& settings.cold_on_lru
&& self.enqueue_demotion(key, old, revision, generation, scope_generation, key_generation))
&& self.enqueue_demotion(
key,
old,
revision,
generation,
scope_generation,
key_generation,
))
{
self.counters.evictions.fetch_add(1, Ordering::Relaxed);
}
+42 -16
View File
@@ -112,6 +112,12 @@ pub struct Settings {
/// Maximum number of LRU objects removed during one admission attempt.
/// Reaching the limit makes the new object pass through uncached.
pub hot_eviction_max_objects: usize,
/// Automatically release idle HOT objects once the tier is above the target waterline.
pub hot_auto_trim_enabled: bool,
/// Minimum idle age before automatic HOT trimming can evict an object.
pub hot_auto_trim_after_seconds: u64,
/// Target HOT usage percentage reached by each automatic trim pass.
pub hot_auto_trim_target_percent: u8,
/// Persistent cold tier. Disabled by default to avoid implicit disk writes.
pub cold_enabled: bool,
/// Last explicitly selected COLD policy profile, persisted with the cache
@@ -179,6 +185,9 @@ impl Default for Settings {
transient_max_bytes: 64 * 1024 * 1024,
cold_queue_max_bytes: 32 * 1024 * 1024,
hot_eviction_max_objects: 64,
hot_auto_trim_enabled: true,
hot_auto_trim_after_seconds: 300,
hot_auto_trim_target_percent: 80,
cold_enabled: false,
cold_preset: None,
cold_path: "cache/cold".into(),
@@ -261,6 +270,14 @@ impl Settings {
(1..=4096).contains(&self.hot_eviction_max_objects),
"cache.hot_eviction_max_objects must be 1..4096"
);
ensure!(
(5..=86_400).contains(&self.hot_auto_trim_after_seconds),
"cache.hot_auto_trim_after_seconds must be 5..86400"
);
ensure!(
(10..=95).contains(&self.hot_auto_trim_target_percent),
"cache.hot_auto_trim_target_percent must be 10..95"
);
ensure!(
(65_536..=1024_u64 * 1024 * 1024 * 1024).contains(&self.cold_max_bytes),
"cache.cold_max_bytes must be 64 KiB..1 TiB"
@@ -337,7 +354,10 @@ impl Settings {
(500..=599).contains(status),
"cache.stale_if_error_statuses must contain HTTP 5xx status codes"
);
ensure!(statuses.insert(*status), "duplicate stale-if-error HTTP status");
ensure!(
statuses.insert(*status),
"duplicate stale-if-error HTTP status"
);
}
ensure!(
(1..=604_800).contains(&self.proactive_refresh_before_expiry_seconds),
@@ -560,7 +580,6 @@ pub struct ScopeView {
pub cold_bytes: u64,
}
#[derive(Default)]
struct Counters {
hits: AtomicU64,
@@ -712,7 +731,11 @@ pub struct ColdCleanupRequest {
}
impl Default for ColdCleanupRequest {
fn default() -> Self {
Self { unused_for_seconds: None, oldest: Some(100), hits_zero: false }
Self {
unused_for_seconds: None,
oldest: Some(100),
hits_zero: false,
}
}
}
impl ColdCleanupRequest {
@@ -722,7 +745,10 @@ impl ColdCleanupRequest {
"at least one cold-cache cleanup criterion is required"
);
if let Some(value) = self.unused_for_seconds {
ensure!((1..=31_536_000).contains(&value), "unused_for_seconds must be 1..31536000");
ensure!(
(1..=31_536_000).contains(&value),
"unused_for_seconds must be 1..31536000"
);
}
if let Some(value) = self.oldest {
ensure!((1..=10_000).contains(&value), "oldest must be 1..10000");
@@ -1301,9 +1327,11 @@ impl Revalidation {
}
}
if let Some(Some(value)) = control.get("min-fresh") {
if value.parse::<u64>().ok().is_some_and(|required| {
self.ttl.saturating_sub(self.age) < required
}) {
if value
.parse::<u64>()
.ok()
.is_some_and(|required| self.ttl.saturating_sub(self.age) < required)
{
return Some("request_min_fresh");
}
}
@@ -1497,14 +1525,16 @@ fn default_cache_scheme() -> String {
fn cache_tags(headers: &HeaderMap) -> Vec<String> {
let mut tags = Vec::new();
for value in headers.get_all("cache-tag") {
let Ok(value) = value.to_str() else { continue; };
let Ok(value) = value.to_str() else {
continue;
};
for raw in value.split(|c: char| c == ',' || c.is_ascii_whitespace()) {
let tag = raw.trim();
if tag.is_empty()
|| tag.len() > 128
|| !tag
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.' | b':' | b'/'))
|| !tag.bytes().all(|b| {
b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.' | b':' | b'/')
})
{
continue;
}
@@ -1660,10 +1690,7 @@ fn cache_state(
requires_revalidation: bool,
request_headers: &HeaderMap,
) -> CacheState {
if age < ttl
&& !requires_revalidation
&& request_freshness_allows(age, ttl, request_headers)
{
if age < ttl && !requires_revalidation && request_freshness_allows(age, ttl, request_headers) {
return CacheState::Fresh;
}
match stale_mode(
@@ -1710,4 +1737,3 @@ fn pairs_to_headers(values: &[(String, String)]) -> Option<HeaderMap> {
}
Some(headers)
}
+315 -124
View File
@@ -172,7 +172,14 @@ mod tests {
let mut single_range = HeaderMap::new();
single_range.insert("range", HeaderValue::from_static("bytes=0-3"));
assert_eq!(
request_policy(&Method::GET, &single_range, true, "/asset.bin", false, &settings()),
request_policy(
&Method::GET,
&single_range,
true,
"/asset.bin",
false,
&settings()
),
RequestPolicy::Use
);
assert!(request_cacheable(
@@ -185,7 +192,14 @@ mod tests {
));
single_range.insert("range", HeaderValue::from_static("bytes=0-3,7-9"));
assert_eq!(
request_policy(&Method::GET, &single_range, true, "/asset.bin", false, &settings()),
request_policy(
&Method::GET,
&single_range,
true,
"/asset.bin",
false,
&settings()
),
RequestPolicy::Bypass("request_range_unsupported")
);
}
@@ -210,11 +224,23 @@ mod tests {
fn request_max_stale_is_bounded_and_respects_revalidation_flags() {
let unbounded = headers("max-stale");
let bounded = headers("max-stale=15");
assert_eq!(stale_mode(120, 60, 0, false, false, false, &unbounded), Some(false));
assert_eq!(stale_mode(70, 60, 0, false, false, false, &bounded), Some(false));
assert_eq!(
stale_mode(120, 60, 0, false, false, false, &unbounded),
Some(false)
);
assert_eq!(
stale_mode(70, 60, 0, false, false, false, &bounded),
Some(false)
);
assert_eq!(stale_mode(80, 60, 0, false, false, false, &bounded), None);
assert_eq!(stale_mode(70, 60, 30, false, false, false, &HeaderMap::new()), Some(true));
assert_eq!(stale_mode(100, 60, 30, false, false, false, &HeaderMap::new()), None);
assert_eq!(
stale_mode(70, 60, 30, false, false, false, &HeaderMap::new()),
Some(true)
);
assert_eq!(
stale_mode(100, 60, 30, false, false, false, &HeaderMap::new()),
None
);
assert_eq!(stale_mode(70, 60, 30, true, false, false, &unbounded), None);
assert_eq!(stale_mode(70, 60, 30, false, true, false, &unbounded), None);
}
@@ -260,9 +286,21 @@ mod tests {
origin_last_modified: None,
};
assert!(base.can_stale_if_error());
assert!(!Revalidation { age: 91, ..base.clone() }.can_stale_if_error());
assert!(!Revalidation { must_revalidate: true, ..base.clone() }.can_stale_if_error());
assert!(!Revalidation { proxy_revalidate: true, ..base }.can_stale_if_error());
assert!(!Revalidation {
age: 91,
..base.clone()
}
.can_stale_if_error());
assert!(!Revalidation {
must_revalidate: true,
..base.clone()
}
.can_stale_if_error());
assert!(!Revalidation {
proxy_revalidate: true,
..base
}
.can_stale_if_error());
}
#[test]
@@ -370,14 +408,10 @@ mod tests {
json: true,
..Default::default()
};
let transformed = crate::transform::apply(
response,
"/data.json",
&transform_settings,
false,
)
.await
.unwrap();
let transformed =
crate::transform::apply(response, "/data.json", &transform_settings, false)
.await
.unwrap();
let transformed_plan = plan(
StatusCode::OK,
transformed.headers(),
@@ -395,7 +429,9 @@ mod tests {
let first = wrapped.into_body().collect().await.unwrap().to_bytes();
assert_eq!(&first[..], br#"{"message":"hello"}"#);
let hit = cache.get(&ticket).expect("transformed representation should be cached");
let hit = cache
.get(&ticket)
.expect("transformed representation should be cached");
assert_eq!(hit.response.headers()["etag"], expected_etag);
assert_eq!(
hit.response.headers()["content-length"].to_str().unwrap(),
@@ -452,10 +488,9 @@ mod tests {
"cache-control",
HeaderValue::from_static("public, max-age=60"),
);
response.headers_mut().insert(
"content-type",
HeaderValue::from_str(content_type).unwrap(),
);
response
.headers_mut()
.insert("content-type", HeaderValue::from_str(content_type).unwrap());
response.headers_mut().insert(
"content-length",
HeaderValue::from_str(&origin_body.len().to_string()).unwrap(),
@@ -488,14 +523,9 @@ mod tests {
"json" => transform_settings.json = true,
_ => unreachable!(),
}
let transformed = crate::transform::apply(
response,
path,
&transform_settings,
false,
)
.await
.unwrap();
let transformed = crate::transform::apply(response, path, &transform_settings, false)
.await
.unwrap();
let transformed_plan = plan(
StatusCode::OK,
transformed.headers(),
@@ -513,9 +543,15 @@ mod tests {
);
let first = wrapped.into_body().collect().await.unwrap().to_bytes();
assert!(!first.is_empty(), "{kind}");
assert_ne!(first.as_ref(), origin_body, "{kind} should change the chosen fixture");
assert_ne!(
first.as_ref(),
origin_body,
"{kind} should change the chosen fixture"
);
let hit = cache.get(&ticket).expect("transformed representation should be cached");
let hit = cache
.get(&ticket)
.expect("transformed representation should be cached");
assert_eq!(hit.response.headers()["etag"], expected_etag, "{kind}");
assert_eq!(
hit.response.headers()["content-length"],
@@ -693,7 +729,9 @@ mod tests {
let mut s = Settings::default();
s.transient_max_bytes = 128 * 1024;
s.cold_queue_max_bytes = 64 * 1024;
let first = cache.try_reserve_transient(96 * 1024, &s).expect("first reservation");
let first = cache
.try_reserve_transient(96 * 1024, &s)
.expect("first reservation");
assert!(cache.try_reserve_transient(40 * 1024, &s).is_none());
assert_eq!(cache.view().transient_bytes, 96 * 1024);
drop(first);
@@ -748,9 +786,7 @@ mod tests {
must_revalidate: false,
proxy_revalidate: false,
origin_etag: Some(HeaderValue::from_static("W/\"origin\"")),
origin_last_modified: Some(HeaderValue::from_static(
"Mon, 26 Jan 2026 23:11:40 GMT",
)),
origin_last_modified: Some(HeaderValue::from_static("Mon, 26 Jan 2026 23:11:40 GMT")),
};
let mut matching = HeaderMap::new();
matching.insert("if-none-match", HeaderValue::from_static("\"origin\""));
@@ -773,13 +809,7 @@ mod tests {
let mut response_headers = headers("public, max-age=60");
response_headers.insert("etag", public_etag.clone());
response_headers.insert("last-modified", modified.clone());
let plan = plan(
StatusCode::OK,
&response_headers,
&settings,
Duration::ZERO,
)
.unwrap();
let plan = plan(StatusCode::OK, &response_headers, &settings, Duration::ZERO).unwrap();
let mut response = Response::new(Body::from("transformed"));
*response.headers_mut() = response_headers;
response
@@ -810,9 +840,15 @@ mod tests {
assert_eq!(client_conditional["if-modified-since"], modified);
let mut validation = HeaderMap::new();
validation.insert("cache-control", HeaderValue::from_static("public, max-age=60"));
validation.insert(
"cache-control",
HeaderValue::from_static("public, max-age=60"),
);
validation.insert("etag", HeaderValue::from_static("\"origin-new\""));
validation.insert("last-modified", HeaderValue::from_static("Mon, 26 Jan 2026 23:12:40 GMT"));
validation.insert(
"last-modified",
HeaderValue::from_static("Mon, 26 Jan 2026 23:12:40 GMT"),
);
let merged = cached.merge_validation_headers(&validation);
assert_eq!(merged["etag"], public_etag);
}
@@ -907,18 +943,13 @@ mod tests {
let mut origin_headers = headers("public, max-age=60");
origin_headers.insert("vary", HeaderValue::from_static("Accept-Encoding"));
origin_headers.insert("content-length", HeaderValue::from_static("7"));
let origin_plan = plan(StatusCode::OK, &origin_headers, &s, Duration::ZERO)
.expect("origin plan");
let origin_plan =
plan(StatusCode::OK, &origin_headers, &s, Duration::ZERO).expect("origin plan");
let mut transformed_headers = headers("public, max-age=60");
transformed_headers.insert("content-length", HeaderValue::from_static("7"));
let transformed_plan = plan(
StatusCode::OK,
&transformed_headers,
&s,
Duration::ZERO,
)
.expect("transformed plan");
let transformed_plan = plan(StatusCode::OK, &transformed_headers, &s, Duration::ZERO)
.expect("transformed plan");
let cache_plan = origin_plan
.without_vary("accept-encoding")
.restrict(transformed_plan);
@@ -954,21 +985,13 @@ mod tests {
dark.insert("x-theme", HeaderValue::from_static("dark"));
let mut origin_headers = headers("public, max-age=60");
origin_headers.insert(
"vary",
HeaderValue::from_static("Accept-Encoding, X-Theme"),
);
let origin_plan = plan(StatusCode::OK, &origin_headers, &s, Duration::ZERO)
.expect("origin plan");
origin_headers.insert("vary", HeaderValue::from_static("Accept-Encoding, X-Theme"));
let origin_plan =
plan(StatusCode::OK, &origin_headers, &s, Duration::ZERO).expect("origin plan");
let mut transformed_headers = headers("public, max-age=60");
transformed_headers.insert("vary", HeaderValue::from_static("X-Theme"));
let transformed_plan = plan(
StatusCode::OK,
&transformed_headers,
&s,
Duration::ZERO,
)
.expect("transformed plan");
let transformed_plan = plan(StatusCode::OK, &transformed_headers, &s, Duration::ZERO)
.expect("transformed plan");
let cache_plan = origin_plan
.without_vary("accept-encoding")
.restrict(transformed_plan);
@@ -1059,11 +1082,20 @@ mod tests {
let entry = store.entries.values_mut().next().expect("stored object");
entry.received = Instant::now() - Duration::from_secs(2);
}
assert!(cache.get(&initial).is_none(), "normal request must not receive stale data");
assert_eq!(cache.view().entries, 1, "freshness expiry must not physically evict the object");
assert!(
cache.get(&initial).is_none(),
"normal request must not receive stale data"
);
assert_eq!(
cache.view().entries,
1,
"freshness expiry must not physically evict the object"
);
let stale = ticket(&cache, &headers("max-stale"));
let hit = cache.get(&stale).expect("explicit max-stale may reuse retained stale data");
let hit = cache
.get(&stale)
.expect("explicit max-stale may reuse retained stale data");
assert!(hit.stale);
assert_eq!(
hit.response.into_body().collect().await.unwrap().to_bytes(),
@@ -1099,7 +1131,14 @@ mod tests {
validation.insert("etag", HeaderValue::from_static("\"origin\""));
let merged = candidate.merge_validation_headers(&validation);
let hit = cache
.publish_revalidated(t.clone(), &candidate, merged, &validation, &s, Duration::ZERO)
.publish_revalidated(
t.clone(),
&candidate,
merged,
&validation,
&s,
Duration::ZERO,
)
.expect("validated body can be delivered");
assert_eq!(
hit.response.into_body().collect().await.unwrap().to_bytes(),
@@ -1142,7 +1181,14 @@ mod tests {
validation.insert("etag", HeaderValue::from_static("\"origin\""));
let merged = candidate.merge_validation_headers(&validation);
let hit = cache
.publish_revalidated(t, &candidate, merged, &validation, &s, Duration::from_secs(2))
.publish_revalidated(
t,
&candidate,
merged,
&validation,
&s,
Duration::from_secs(2),
)
.expect("validated object");
assert!(hit.resident_age_seconds >= 32);
}
@@ -1284,7 +1330,10 @@ mod tests {
assert!(after.hot_allocated_bytes <= s.max_bytes);
assert_eq!(after.fill_bytes_active, 0);
assert_eq!(after.transient_bytes, 0);
assert!(cache.get(&first).is_none(), "LRU entry should make room before the next fill allocation");
assert!(
cache.get(&first).is_none(),
"LRU entry should make room before the next fill allocation"
);
assert!(cache.get(&second).is_some());
assert!(after.evictions >= 1);
}
@@ -1418,12 +1467,17 @@ mod tests {
assert_eq!(first, Bytes::from_static(b"first-"));
drop(body);
assert!(cache.get(&t).is_none(), "partial body must never become a HIT");
assert!(
cache.get(&t).is_none(),
"partial body must never become a HIT"
);
assert_eq!(cache.view().fills_active, 0);
assert_eq!(cache.view().rejected_fills, 1);
fill(&cache, t.clone(), "complete", &s).await;
let hit = cache.get(&t).expect("a later complete GET can fill the cache");
let hit = cache
.get(&t)
.expect("a later complete GET can fill the cache");
assert_eq!(
hit.response.into_body().collect().await.unwrap().to_bytes(),
Bytes::from_static(b"complete")
@@ -1528,18 +1582,30 @@ mod scope_tests {
assert!(effective.enabled && effective.expose_status);
let mut globally_disabled = global.clone();
globally_disabled.enabled = false;
assert!(!Overrides { enabled: Some(true), ..Overrides::default() }
assert!(
!Overrides {
enabled: Some(true),
..Overrides::default()
}
.resolve(&globally_disabled)
.unwrap()
.enabled);
assert!(!Overrides { enabled: Some(false), ..Overrides::default() }
.enabled
);
assert!(
!Overrides {
enabled: Some(false),
..Overrides::default()
}
.resolve(&global)
.unwrap()
.enabled);
assert!(!Overrides::default()
.resolve(&globally_disabled)
.unwrap()
.enabled);
.enabled
);
assert!(
!Overrides::default()
.resolve(&globally_disabled)
.unwrap()
.enabled
);
assert_eq!(effective.max_bytes, global.max_bytes);
assert_eq!(effective.max_ttl_seconds, 30);
assert!(Overrides {
@@ -1861,8 +1927,22 @@ mod v022_cache_tests {
)
.await;
let cases = [
(ByteRange { start: None, end: Some(3) }, "hij", "bytes 7-9/10"),
(ByteRange { start: Some(7), end: None }, "hij", "bytes 7-9/10"),
(
ByteRange {
start: None,
end: Some(3),
},
"hij",
"bytes 7-9/10",
),
(
ByteRange {
start: Some(7),
end: None,
},
"hij",
"bytes 7-9/10",
),
];
for (range, expected, content_range) in cases {
let hit = cache.get(&ticket).unwrap().apply_range(range, None);
@@ -1873,38 +1953,66 @@ mod v022_cache_tests {
}
let unsatisfied = cache.get(&ticket).unwrap().apply_range(
ByteRange { start: Some(50), end: None },
ByteRange {
start: Some(50),
end: None,
},
None,
);
assert_eq!(unsatisfied.response.status(), StatusCode::RANGE_NOT_SATISFIABLE);
assert_eq!(unsatisfied.response.headers()["content-range"], "bytes */10");
assert_eq!(
unsatisfied.response.into_body().collect().await.unwrap().to_bytes().len(),
unsatisfied.response.status(),
StatusCode::RANGE_NOT_SATISFIABLE
);
assert_eq!(
unsatisfied.response.headers()["content-range"],
"bytes */10"
);
assert_eq!(
unsatisfied
.response
.into_body()
.collect()
.await
.unwrap()
.to_bytes()
.len(),
0
);
let mismatch = HeaderValue::from_static("\"asset-v2\"");
let full = cache.get(&ticket).unwrap().apply_range(
ByteRange { start: Some(0), end: Some(2) },
ByteRange {
start: Some(0),
end: Some(2),
},
Some(&mismatch),
);
assert_eq!(full.response.status(), StatusCode::OK);
assert!(!full.response.headers().contains_key("content-range"));
assert_eq!(
full.response.into_body().collect().await.unwrap().to_bytes(),
full.response
.into_body()
.collect()
.await
.unwrap()
.to_bytes(),
Bytes::from_static(b"abcdefghij")
);
let matched = HeaderValue::from_static("\"asset-v1\"");
let partial = cache.get(&ticket).unwrap().apply_range(
ByteRange { start: Some(0), end: Some(2) },
ByteRange {
start: Some(0),
end: Some(2),
},
Some(&matched),
);
assert_eq!(partial.response.status(), StatusCode::PARTIAL_CONTENT);
}
#[test]
fn range_parser_rejects_multipart_and_duplicate_fields_and_if_range_date_allows_older_representation() {
fn range_parser_rejects_multipart_and_duplicate_fields_and_if_range_date_allows_older_representation(
) {
let mut multipart = HeaderMap::new();
multipart.insert("range", HeaderValue::from_static("bytes=0-1,4-5"));
assert_eq!(
@@ -1999,13 +2107,23 @@ mod v022_cache_tests {
let mut settings = active_settings();
settings.collapse_wait_timeout_ms = 10;
let ticket = cache
.ticket(22, "http", "collapse.example", "origin", "/timeout", &HeaderMap::new())
.ticket(
22,
"http",
"collapse.example",
"origin",
"/timeout",
&HeaderMap::new(),
)
.unwrap();
let leader = match cache.claim_fill(&ticket, &settings).await {
FillClaim::Leader(guard) => guard,
_ => panic!("missing leader"),
};
assert!(matches!(cache.claim_fill(&ticket, &settings).await, FillClaim::Timeout));
assert!(matches!(
cache.claim_fill(&ticket, &settings).await,
FillClaim::Timeout
));
assert_eq!(cache.view().collapse_timeouts, 1);
drop(leader);
assert!(matches!(
@@ -2020,7 +2138,14 @@ mod v022_cache_tests {
let mut settings = active_settings();
settings.collapse_wait_timeout_ms = 5_000;
let ticket = cache
.ticket(22, "http", "collapse.example", "origin", "/cancel", &HeaderMap::new())
.ticket(
22,
"http",
"collapse.example",
"origin",
"/cancel",
&HeaderMap::new(),
)
.unwrap();
let leader = match cache.claim_fill(&ticket, &settings).await {
FillClaim::Leader(guard) => guard,
@@ -2058,10 +2183,24 @@ mod v022_cache_tests {
let mut pl_headers = HeaderMap::new();
pl_headers.insert("accept-language", HeaderValue::from_static("pl"));
let en = cache
.ticket(22, "http", "vary-collapse.example", "origin", "/page", &en_headers)
.ticket(
22,
"http",
"vary-collapse.example",
"origin",
"/page",
&en_headers,
)
.unwrap();
let pl = cache
.ticket(22, "http", "vary-collapse.example", "origin", "/page", &pl_headers)
.ticket(
22,
"http",
"vary-collapse.example",
"origin",
"/page",
&pl_headers,
)
.unwrap();
assert_eq!(en.key, pl.key, "Vary is learned from the response");
let leader = match cache.claim_fill(&en, &settings).await {
@@ -2136,7 +2275,14 @@ mod v022_cache_tests {
fn proactive_refresh_guards_deduplicate_and_bound_parallel_work() {
let cache = Cache::new(22);
let ticket = cache
.ticket(22, "http", "refresh.example", "origin", "/asset", &HeaderMap::new())
.ticket(
22,
"http",
"refresh.example",
"origin",
"/asset",
&HeaderMap::new(),
)
.unwrap();
let mut settings = active_settings();
settings.proactive_refresh_enabled = true;
@@ -2192,7 +2338,9 @@ mod v022_cache_tests {
&HeaderMap::new(),
)
.unwrap();
assert!(cache.begin_proactive_schedule(&overflow, &settings).is_none());
assert!(cache
.begin_proactive_schedule(&overflow, &settings)
.is_none());
assert!(cache.view().proactive_skipped > 0);
drop(guards);
}
@@ -2502,7 +2650,10 @@ mod v022_cache_tests {
"fresh",
)
.await;
assert!(cache.get(&fresh).is_some(), "old BAN must not reject newer objects");
assert!(
cache.get(&fresh).is_some(),
"old BAN must not reject newer objects"
);
assert_eq!(cache.bans().len(), 1);
assert!(cache.remove_ban(ban_id));
assert!(cache.bans().is_empty());
@@ -2729,9 +2880,10 @@ mod cold_tier_tests {
"public, max-age=120, stale-while-revalidate=30, stale-if-error=90",
),
);
transformed
.headers_mut()
.insert("etag", HeaderValue::from_static("\"rustproxy-representation\""));
transformed.headers_mut().insert(
"etag",
HeaderValue::from_static("\"rustproxy-representation\""),
);
transformed
.headers_mut()
.insert("content-length", HeaderValue::from_static("7"));
@@ -2739,15 +2891,15 @@ mod cold_tier_tests {
.extensions_mut()
.insert(crate::transform::OriginValidators {
etag: Some(HeaderValue::from_static("\"origin-v1\"")),
last_modified: Some(HeaderValue::from_static(
"Mon, 26 Jan 2026 23:11:40 GMT",
)),
last_modified: Some(HeaderValue::from_static("Mon, 26 Jan 2026 23:11:40 GMT")),
});
transformed
.extensions_mut()
.insert(crate::transform::Applied {
kind: crate::transform::Kind::Json,
original_bytes: 9,
transformed_bytes: 7,
});
transformed.extensions_mut().insert(crate::transform::Applied {
kind: crate::transform::Kind::Json,
original_bytes: 9,
transformed_bytes: 7,
});
let transformed_plan = plan(
StatusCode::OK,
transformed.headers(),
@@ -2868,7 +3020,9 @@ mod cold_tier_tests {
.await
.expect("no-transform object after restart");
assert_eq!(protected_hit.response.headers()["etag"], "\"origin-css\"");
assert!(crate::transform::no_transform(protected_hit.response.headers()));
assert!(crate::transform::no_transform(
protected_hit.response.headers()
));
assert!(protected_hit
.response
.extensions()
@@ -2994,7 +3148,10 @@ mod cold_tier_tests {
assert_eq!(view.entries, 0);
assert_eq!(view.resident_bytes, 0);
assert_eq!(view.hot_logical_bytes, 0);
assert_eq!(view.transient_bytes, 0, "persisted ColdEntry must not retain the body allocation");
assert_eq!(
view.transient_bytes, 0,
"persisted ColdEntry must not retain the body allocation"
);
assert_eq!(view.cold_entries, 2);
shutdown.cancel();
@@ -3072,17 +3229,23 @@ mod cold_tier_tests {
for item in std::fs::read_dir(&root).unwrap() {
let item = item.unwrap();
let name = item.file_name().to_string_lossy().to_string();
assert!(item.path().is_dir(), "cold root must contain shard directories only");
assert!(
item.path().is_dir(),
"cold root must contain shard directories only"
);
assert_eq!(name.len(), 2);
assert!(name.bytes().all(|b| b.is_ascii_hexdigit()));
shards.push(item.path());
}
assert!(!shards.is_empty());
assert!(shards.iter().any(|shard| {
std::fs::read_dir(shard).unwrap().filter_map(|entry| entry.ok()).any(|entry| {
let name = entry.file_name().to_string_lossy().to_string();
name.ends_with(".json") || name.ends_with(".body")
})
std::fs::read_dir(shard)
.unwrap()
.filter_map(|entry| entry.ok())
.any(|entry| {
let name = entry.file_name().to_string_lossy().to_string();
name.ends_with(".json") || name.ends_with(".body")
})
}));
shutdown.cancel();
}
@@ -3096,7 +3259,12 @@ mod cold_tier_tests {
std::fs::write(root.join("legacy.body"), b"old").unwrap();
std::fs::write(root.join("legacy.json"), b"{}").unwrap();
let shutdown = CancellationToken::new();
let cache = Cache::start(11, Some(dir.path().to_path_buf()), settings, shutdown.clone());
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings,
shutdown.clone(),
);
assert_eq!(cache.view().cold_entries, 0);
assert!(!root.join("legacy.body").exists());
assert!(!root.join("legacy.json").exists());
@@ -3109,12 +3277,23 @@ mod cold_tier_tests {
let mut settings = cold_settings();
settings.max_entries = 1;
let shutdown = CancellationToken::new();
let cache = Cache::start(11, Some(dir.path().to_path_buf()), settings.clone(), shutdown.clone());
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/one", "first-payload").await;
fill(&cache, &settings, "/two", "second-payload").await;
fill(&cache, &settings, "/three", "third-payload").await;
wait_for_cold(&cache, 2).await;
let job = cache.start_cold_cleanup(ColdCleanupRequest { unused_for_seconds: None, oldest: Some(1), hits_zero: false }).unwrap();
let job = cache
.start_cold_cleanup(ColdCleanupRequest {
unused_for_seconds: None,
oldest: Some(1),
hits_zero: false,
})
.unwrap();
for _ in 0..100 {
let current = cache.cold_cleanup_job(job.id).unwrap();
if current.status == "done" {
@@ -3134,7 +3313,12 @@ mod cold_tier_tests {
let dir = tempfile::tempdir().unwrap();
let settings = cold_settings();
let shutdown = CancellationToken::new();
let cache = Cache::start(11, Some(dir.path().to_path_buf()), settings, shutdown.clone());
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings,
shutdown.clone(),
);
let _held = cache.cleanup_slots.clone().try_acquire_owned().unwrap();
let result = cache.start_cold_cleanup(ColdCleanupRequest {
unused_for_seconds: None,
@@ -3178,7 +3362,14 @@ mod cold_tier_tests {
assert_eq!(result.removed_entries, 0);
assert!(cache.cold.lock().unwrap().entries.contains_key(&key));
cache.cold.lock().unwrap().entries.get_mut(&key).unwrap().pinned_reads = 0;
cache
.cold
.lock()
.unwrap()
.entries
.get_mut(&key)
.unwrap()
.pinned_reads = 0;
let result = cache
.remove_cold_candidates(Some(&request), None, None, 1)
.await;
+247 -24
View File
@@ -60,6 +60,8 @@ pub struct Config {
#[serde(default, skip_serializing_if = "crate::tls::Settings::is_empty")]
pub tls: crate::tls::Settings,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub web_services: Vec<crate::web_server::Service>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub vhosts: Vec<crate::vhost::Vhost>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub host_policies: Vec<HostPolicy>,
@@ -169,6 +171,12 @@ pub struct CompiledBackend {
/// directly. Used by the guarded control-plane reverse-proxy path.
pub targets_admin_listener: bool,
}
#[derive(Debug, Clone)]
pub enum CompiledSource {
ReverseProxy,
WebServer(Arc<crate::web_server::CompiledService>),
}
#[derive(Debug, Clone)]
pub struct CompiledRoute {
pub backend_id: Option<String>,
@@ -182,7 +190,8 @@ pub struct CompiledRoute {
pub path_regex: Option<regex::Regex>,
pub rewrite: crate::vhost::PathRewrite,
pub response: Option<crate::vhost::LocationResponse>,
pub backend: Arc<CompiledBackend>,
pub source: CompiledSource,
pub backend: Option<Arc<CompiledBackend>>,
pub acl: Option<CompiledAcl>,
pub headers: CompiledHeaders,
}
@@ -280,8 +289,7 @@ impl Snapshot {
&& frontend.websocket_rfc8441
&& frontend.http2_enabled
&& frontend.bindings.iter().any(|binding| {
binding.matches(local, tls)
&& if tls { binding.http2 } else { binding.h2c }
binding.matches(local, tls) && if tls { binding.http2 } else { binding.h2c }
})
})
}
@@ -854,11 +862,47 @@ pub fn compile_with_active(
config.compression.validate()?;
config.server_header.validate()?;
let forwarding = config.forwarding.compile()?;
ensure!(
config.web_services.len() <= 128,
"too many Web Services (max 128)"
);
let mut web_services = HashMap::new();
for service in &mut config.web_services {
service
.validate()
.with_context(|| format!("Web Service {}", service.id))?;
let compiled = Arc::new(
service
.compile()
.with_context(|| format!("Web Service {}", service.id))?,
);
ensure!(
web_services.insert(service.id.clone(), compiled).is_none(),
"duplicate Web Service id"
);
}
ensure!(config.vhosts.len() <= 64, "too many vhosts (max 64)");
let mut vhost_ids = HashSet::new();
for host in &mut config.vhosts {
host.validate()
.with_context(|| format!("vhost {}", host.id))?;
let validate_source = |source: &crate::vhost::Source| -> Result<()> {
if let crate::vhost::Source::WebServer { web_service_id } = source {
ensure!(
web_services.contains_key(web_service_id),
"vhost {} references unknown Web Service {}",
host.id,
web_service_id
);
}
Ok(())
};
validate_source(&host.source)?;
for route in &host.routes {
if route.response.is_none() {
validate_source(&route.source)?;
}
}
ensure!(vhost_ids.insert(host.id.clone()), "duplicate vhost id");
}
ensure!(
@@ -1183,16 +1227,20 @@ pub fn compile_with_active(
backend.id = format!("{}/{}", host.id, definition.id);
compiled_members.push(Arc::new(backend));
}
let mut pool = crate::balance::Pool::new(
host.id.clone(),
host.load_balancing.clone(),
&host.backends,
compiled_members,
)?;
if let Some(old) = previous.and_then(|s| s.pools.iter().find(|p| p.id == host.id)) {
pool.reuse_observations(old);
}
let pool = Arc::new(pool);
let pool = if compiled_members.is_empty() {
None
} else {
let mut pool = crate::balance::Pool::new(
host.id.clone(),
host.load_balancing.clone(),
&host.backends,
compiled_members,
)?;
if let Some(old) = previous.and_then(|s| s.pools.iter().find(|p| p.id == host.id)) {
pool.reuse_observations(old);
}
Some(Arc::new(pool))
};
let metrics = previous
.and_then(|s| s.vhost_metrics.get(&host.id))
.cloned()
@@ -1270,11 +1318,31 @@ pub fn compile_with_active(
proxy_timeouts: host.proxy_timeouts.clone(),
});
if host.enabled {
let (fallback_source, fallback_pool, fallback_backend) = match &host.source {
crate::vhost::Source::ReverseProxy => {
let pool = pool.as_ref().ok_or_else(|| {
anyhow::anyhow!(
"vhost {} Reverse Proxy source has no backend pool",
host.id
)
})?;
(
CompiledSource::ReverseProxy,
Some(pool.clone()),
Some(pool.members[0].backend.clone()),
)
}
crate::vhost::Source::WebServer { web_service_id } => (
CompiledSource::WebServer(web_services[web_service_id].clone()),
None,
None,
),
};
let fallback = CompiledRoute {
backend_id: None,
bindings: frontend.bindings.clone(),
frontend: Some(frontend.clone()),
pool: Some(pool.clone()),
pool: fallback_pool,
cache: None,
id: format!("{}/main", host.id),
path_prefix: "/".into(),
@@ -1282,7 +1350,8 @@ pub fn compile_with_active(
path_regex: None,
rewrite: crate::vhost::PathRewrite::default(),
response: None,
backend: pool.members[0].backend.clone(),
source: fallback_source,
backend: fallback_backend,
acl: None,
headers: crate::headers::CompiledHeaders::default(),
};
@@ -1341,19 +1410,56 @@ pub fn compile_with_active(
validate_location_response(route.response.as_ref())?;
let acl = route.acl.as_ref().map(Acl::compile).transpose()?;
let headers = route.headers.compile()?;
if let Some(id) = &route.backend_id {
ensure!(
host.backends.iter().any(|b| &b.id == id),
"vhost path references unknown backend {}",
id
);
if route.source.is_reverse_proxy() {
if let Some(id) = &route.backend_id {
ensure!(
host.backends.iter().any(|b| &b.id == id),
"vhost path references unknown backend {}",
id
);
}
}
if host.enabled && route.enabled {
let (source, route_pool, backend) = if route.response.is_some() {
// Local return is handled before source dispatch and needs no origin.
(CompiledSource::ReverseProxy, None, None)
} else {
match &route.source {
crate::vhost::Source::ReverseProxy => {
let pool = pool.as_ref().ok_or_else(|| {
anyhow::anyhow!(
"vhost {} location {} Reverse Proxy source has no backend pool",
host.id,
route.id
)
})?;
let backend = if let Some(id) = route.backend_id.as_deref() {
pool.members
.iter()
.find(|member| member.definition.id == id)
.map(|member| member.backend.clone())
.ok_or_else(|| anyhow::anyhow!("unknown backend {id}"))?
} else {
pool.members[0].backend.clone()
};
(
CompiledSource::ReverseProxy,
Some(pool.clone()),
Some(backend),
)
}
crate::vhost::Source::WebServer { web_service_id } => (
CompiledSource::WebServer(web_services[web_service_id].clone()),
None,
None,
),
}
};
let compiled = CompiledRoute {
backend_id: route.backend_id.clone(),
bindings: frontend.bindings.clone(),
frontend: Some(frontend.clone()),
pool: Some(pool.clone()),
pool: route_pool,
cache: route.cache,
id: format!("{}/{}", host.id, route.id),
path_prefix: route.path_prefix.clone(),
@@ -1361,7 +1467,8 @@ pub fn compile_with_active(
path_regex,
rewrite: route.rewrite.clone(),
response: route.response.clone(),
backend: pool.members[0].backend.clone(),
source,
backend,
acl,
headers,
};
@@ -1380,7 +1487,9 @@ pub fn compile_with_active(
.or_default()
.push(frontend.clone());
}
pools.push(pool);
if let Some(pool) = pool {
pools.push(pool);
}
}
}
let quic_tls = tls
@@ -1537,6 +1646,120 @@ mod tests {
let second = compile(serde_json::from_str(&saved).unwrap(), 1, &policy()).unwrap();
assert!(second.select("localhost", "/caf\u{e9}/hello").is_some());
}
#[test]
fn web_server_main_source_needs_no_backend_pool() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(tmp.path().join("index.html"), b"hello").unwrap();
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
config.web_services.push(crate::web_server::Service {
id: "site".into(),
name: "Site".into(),
document_root: tmp.path().to_path_buf(),
index: vec!["index.html".into()],
directory_listing: false,
..Default::default()
});
config.vhosts[0].source = crate::vhost::Source::WebServer {
web_service_id: "site".into(),
};
config.vhosts[0].backends.clear();
config.vhosts[0].routes.clear();
let snapshot = compile(config, 1, &policy()).unwrap();
let route = snapshot.select("localhost", "/").unwrap();
assert!(matches!(&route.source, CompiledSource::WebServer(_)));
assert!(route.pool.is_none());
assert!(route.backend.is_none());
assert!(snapshot.pools.is_empty());
}
#[test]
fn web_server_and_reverse_proxy_can_share_one_vhost() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(tmp.path().join("index.html"), b"hello").unwrap();
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
config.web_services.push(crate::web_server::Service {
id: "site".into(),
name: "Site".into(),
document_root: tmp.path().to_path_buf(),
index: vec!["index.html".into()],
directory_listing: false,
..Default::default()
});
let host = &mut config.vhosts[0];
host.source = crate::vhost::Source::WebServer {
web_service_id: "site".into(),
};
host.routes = vec![crate::vhost::PathRoute {
id: "api".into(),
path_prefix: "/api".into(),
source: crate::vhost::Source::ReverseProxy,
..Default::default()
}];
let snapshot = compile(config, 1, &policy()).unwrap();
let root = snapshot.select("localhost", "/").unwrap();
let api = snapshot.select("localhost", "/api/test").unwrap();
assert!(matches!(&root.source, CompiledSource::WebServer(_)));
assert!(matches!(&api.source, CompiledSource::ReverseProxy));
assert!(api.pool.is_some());
assert!(api.backend.is_some());
}
#[test]
fn one_web_service_can_be_reused_by_multiple_vhosts() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(tmp.path().join("index.html"), b"shared").unwrap();
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
config.web_services.push(crate::web_server::Service {
id: "shared-site".into(),
name: "Shared site".into(),
document_root: tmp.path().to_path_buf(),
index: vec!["index.html".into()],
directory_listing: false,
..Default::default()
});
let first = &mut config.vhosts[0];
first.source = crate::vhost::Source::WebServer {
web_service_id: "shared-site".into(),
};
first.backends.clear();
first.routes.clear();
let mut second = first.clone();
second.id = "shared-second".into();
second.frontend.hosts = vec!["second.localhost".into()];
config.vhosts.push(second);
let snapshot = compile(config, 1, &policy()).unwrap();
let first_route = snapshot.select("localhost", "/").unwrap();
let second_route = snapshot.select("second.localhost", "/").unwrap();
assert!(
matches!(&first_route.source, CompiledSource::WebServer(service) if service.id == "shared-site")
);
assert!(
matches!(&second_route.source, CompiledSource::WebServer(service) if service.id == "shared-site")
);
}
#[test]
fn unknown_web_service_reference_is_rejected() {
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
config.vhosts[0].source = crate::vhost::Source::WebServer {
web_service_id: "missing".into(),
};
let error = compile(config, 1, &policy()).unwrap_err().to_string();
assert!(error.contains("unknown Web Service"), "{error}");
}
#[test]
fn missing_source_fields_remain_reverse_proxy_for_old_configs() {
let source: crate::vhost::Source =
serde_json::from_value(serde_json::json!({"type":"reverse_proxy"})).unwrap();
assert_eq!(source, crate::vhost::Source::ReverseProxy);
let route: crate::vhost::PathRoute =
serde_json::from_value(serde_json::json!({"id":"legacy"})).unwrap();
assert_eq!(route.source, crate::vhost::Source::ReverseProxy);
}
#[test]
fn host_normalization() {
assert_eq!(request_host("EXAMPLE.com.:8080").unwrap(), "example.com");
+16 -1
View File
@@ -22,7 +22,8 @@ impl Diagnostics {
tokio::spawn(async move {
let mut previous: Option<(View, Counters)> = None;
loop {
let sample = tokio::task::spawn_blocking(move || system_stats::sample(previous)).await;
let sample =
tokio::task::spawn_blocking(move || system_stats::sample(previous)).await;
if let Ok((view, counters)) = sample {
*sampler.latest.write().unwrap_or_else(|e| e.into_inner()) = view.clone();
previous = Some((view, counters));
@@ -147,3 +148,17 @@ impl Diagnostics {
text
}
}
/// Return free glibc allocator arenas/pages to the OS. This does not evict live
/// RustProxy objects; it only asks the allocator to release already-free pages.
/// On non-glibc platforms this is a no-op.
pub fn trim_allocator() -> bool {
#[cfg(all(target_os = "linux", target_env = "gnu"))]
unsafe {
return libc::malloc_trim(0) != 0;
}
#[cfg(not(all(target_os = "linux", target_env = "gnu")))]
{
false
}
}
+6 -4
View File
@@ -3,8 +3,8 @@ pub mod acme;
pub mod admin;
#[path = "auth/mod.rs"]
pub mod auth;
pub mod basic_auth;
pub mod backup;
pub mod basic_auth;
pub mod config;
pub mod db;
pub mod demo;
@@ -16,27 +16,29 @@ pub mod proxy;
pub mod runtime;
pub mod server;
pub mod tls;
pub mod upstream;
pub mod traffic;
pub mod update_check;
pub mod upstream;
pub mod cache;
pub mod compression;
pub mod transform;
pub mod diagnostics;
pub mod docker;
pub mod pages;
pub mod settings;
pub mod stream;
pub mod system_stats;
pub mod transform;
pub mod balance;
pub mod forwarding;
pub mod geoip;
pub mod http3;
pub mod security;
pub mod secret_store;
pub mod security;
pub mod tinyauth;
pub mod vhost;
pub mod web_server;
pub mod prometheus;
pub mod rate_limit;
+270 -50
View File
@@ -109,14 +109,31 @@ pub struct JsonFormat {
pub static_fields: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Settings {
/// Capture a bounded User-Agent value in access logs and traffic analytics.
pub capture_user_agent: bool,
/// Resolve the effective client IP through the loaded GeoIP database.
/// Disabled by default because this adds per-request lookup work.
pub capture_country: bool,
pub file: FileSink,
pub logstash: LogstashSink,
pub loki: LokiSink,
pub format: JsonFormat,
}
impl Default for Settings {
fn default() -> Self {
Self {
capture_user_agent: true,
capture_country: false,
file: FileSink::default(),
logstash: LogstashSink::default(),
loki: LokiSink::default(),
format: JsonFormat::default(),
}
}
}
impl Settings {
pub fn enabled(&self) -> bool {
self.file.enabled || self.logstash.enabled || self.loki.enabled
@@ -216,6 +233,9 @@ fn source_value_name(name: &str) -> bool {
matches!(
name,
"vhost"
| "route"
| "cache_effective_enabled"
| "transform_effective_enabled"
| "backend"
| "id"
| "time_ms"
@@ -231,6 +251,8 @@ fn source_value_name(name: &str) -> bool {
| "upstream_status"
| "upstream_error"
| "client_ip"
| "user_agent"
| "country"
| "status"
| "duration_ms"
| "request_bytes"
@@ -241,7 +263,6 @@ fn source_value_name(name: &str) -> bool {
)
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum ErrorLogFormat {
#[serde(rename = "JSON")]
@@ -250,7 +271,9 @@ pub enum ErrorLogFormat {
Raw,
}
impl Default for ErrorLogFormat {
fn default() -> Self { Self::Json }
fn default() -> Self {
Self::Json
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -262,14 +285,20 @@ pub struct ErrorLogSettings {
}
impl Default for ErrorLogSettings {
fn default() -> Self {
Self { enabled: false, path: "/var/log/rustproxy/error.log".into(), format: ErrorLogFormat::Json }
Self {
enabled: false,
path: "/var/log/rustproxy/error.log".into(),
format: ErrorLogFormat::Json,
}
}
}
impl ErrorLogSettings {
pub fn validate(&self) -> Result<()> {
if self.enabled {
ensure!(
!self.path.trim().is_empty() && self.path.len() <= 1024 && !self.path.chars().any(char::is_control),
!self.path.trim().is_empty()
&& self.path.len() <= 1024
&& !self.path.chars().any(char::is_control),
"error_log.path must be 1..1024 bytes without controls"
);
}
@@ -319,7 +348,14 @@ impl ErrorLogController {
written.clone(),
errors.clone(),
));
Arc::new(Self { settings, settings_changed, sender, dropped, written, errors })
Arc::new(Self {
settings,
settings_changed,
sender,
dropped,
written,
errors,
})
}
pub fn configure(&self, settings: ErrorLogSettings) {
self.settings.store(Arc::new(settings));
@@ -343,10 +379,14 @@ impl ErrorLogController {
static ERROR_LOG: OnceLock<Arc<ErrorLogController>> = OnceLock::new();
pub fn install_error_log_controller(controller: Arc<ErrorLogController>) -> Result<()> {
ERROR_LOG.set(controller).map_err(|_| anyhow::anyhow!("error logger already initialized"))
ERROR_LOG
.set(controller)
.map_err(|_| anyhow::anyhow!("error logger already initialized"))
}
pub fn configure_error_log(settings: ErrorLogSettings) {
if let Some(controller) = ERROR_LOG.get() { controller.configure(settings); }
if let Some(controller) = ERROR_LOG.get() {
controller.configure(settings);
}
}
pub fn error_log_stats() -> Option<ErrorLogStats> {
ERROR_LOG.get().map(|controller| controller.stats())
@@ -357,7 +397,9 @@ pub struct ErrorLogLayer {
controller: Arc<ErrorLogController>,
}
impl ErrorLogLayer {
pub fn new(controller: Arc<ErrorLogController>) -> Self { Self { controller } }
pub fn new(controller: Arc<ErrorLogController>) -> Self {
Self { controller }
}
}
#[derive(Default)]
@@ -368,21 +410,42 @@ struct EventVisitor {
impl tracing::field::Visit for EventVisitor {
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
let value = format!("{value:?}");
if field.name() == "message" { self.message = Some(value); }
else { self.fields.insert(field.name().to_owned(), value); }
if field.name() == "message" {
self.message = Some(value);
} else {
self.fields.insert(field.name().to_owned(), value);
}
}
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
if field.name() == "message" { self.message = Some(value.to_owned()); }
else { self.fields.insert(field.name().to_owned(), value.to_owned()); }
if field.name() == "message" {
self.message = Some(value.to_owned());
} else {
self.fields
.insert(field.name().to_owned(), value.to_owned());
}
}
fn record_i64(&mut self, field: &tracing::field::Field, value: i64) {
self.fields
.insert(field.name().to_owned(), value.to_string());
}
fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
self.fields
.insert(field.name().to_owned(), value.to_string());
}
fn record_bool(&mut self, field: &tracing::field::Field, value: bool) {
self.fields
.insert(field.name().to_owned(), value.to_string());
}
fn record_f64(&mut self, field: &tracing::field::Field, value: f64) {
self.fields
.insert(field.name().to_owned(), value.to_string());
}
fn record_i64(&mut self, field: &tracing::field::Field, value: i64) { self.fields.insert(field.name().to_owned(), value.to_string()); }
fn record_u64(&mut self, field: &tracing::field::Field, value: u64) { self.fields.insert(field.name().to_owned(), value.to_string()); }
fn record_bool(&mut self, field: &tracing::field::Field, value: bool) { self.fields.insert(field.name().to_owned(), value.to_string()); }
fn record_f64(&mut self, field: &tracing::field::Field, value: f64) { self.fields.insert(field.name().to_owned(), value.to_string()); }
}
impl<S: Subscriber> Layer<S> for ErrorLogLayer {
fn on_event(&self, event: &Event<'_>, _ctx: LayerContext<'_, S>) {
if *event.metadata().level() != Level::ERROR { return; }
if *event.metadata().level() != Level::ERROR {
return;
}
let mut visitor = EventVisitor::default();
event.record(&mut visitor);
let mut fields = visitor.fields;
@@ -404,11 +467,18 @@ impl<S: Subscriber> Layer<S> for ErrorLogLayer {
#[derive(Clone, Copy, Default)]
pub struct RedactingStderr;
pub struct RedactingWriter { buffer: Vec<u8> }
pub struct RedactingWriter {
buffer: Vec<u8>,
}
impl IoWrite for RedactingWriter {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> { self.buffer.extend_from_slice(bytes); Ok(bytes.len()) }
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
self.buffer.extend_from_slice(bytes);
Ok(bytes.len())
}
fn flush(&mut self) -> io::Result<()> {
if self.buffer.is_empty() { return Ok(()); }
if self.buffer.is_empty() {
return Ok(());
}
let text = String::from_utf8_lossy(&self.buffer);
let redacted = redact_text(&text);
let mut stderr = io::stderr().lock();
@@ -418,10 +488,18 @@ impl IoWrite for RedactingWriter {
Ok(())
}
}
impl Drop for RedactingWriter { fn drop(&mut self) { let _ = self.flush(); } }
impl Drop for RedactingWriter {
fn drop(&mut self) {
let _ = self.flush();
}
}
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for RedactingStderr {
type Writer = RedactingWriter;
fn make_writer(&'a self) -> Self::Writer { RedactingWriter { buffer: Vec::with_capacity(512) } }
fn make_writer(&'a self) -> Self::Writer {
RedactingWriter {
buffer: Vec::with_capacity(512),
}
}
}
fn redact_regex(pattern: &'static str) -> &'static regex::Regex {
@@ -462,17 +540,31 @@ fn is_secret_field(name: &str) -> bool {
}
pub fn redact_text(input: &str) -> String {
let mut value = redact_regex("pem").replace_all(input, "[REDACTED PRIVATE KEY]").into_owned();
value = redact_regex("auth").replace_all(&value, "$1[REDACTED]").into_owned();
value = redact_regex("query").replace_all(&value, "$1[REDACTED]").into_owned();
value = redact_regex("json").replace_all(&value, "$1[REDACTED]$2").into_owned();
redact_regex("kv").replace_all(&value, "$1[REDACTED]").into_owned()
let mut value = redact_regex("pem")
.replace_all(input, "[REDACTED PRIVATE KEY]")
.into_owned();
value = redact_regex("auth")
.replace_all(&value, "$1[REDACTED]")
.into_owned();
value = redact_regex("query")
.replace_all(&value, "$1[REDACTED]")
.into_owned();
value = redact_regex("json")
.replace_all(&value, "$1[REDACTED]$2")
.into_owned();
redact_regex("kv")
.replace_all(&value, "$1[REDACTED]")
.into_owned()
}
fn redact_value(value: &mut Value) {
match value {
Value::String(text) => *text = redact_text(text),
Value::Array(items) => for item in items { redact_value(item); },
Value::Array(items) => {
for item in items {
redact_value(item);
}
}
Value::Object(items) => {
for (key, item) in items {
if is_secret_field(key) {
@@ -487,7 +579,10 @@ fn redact_value(value: &mut Value) {
}
fn rfc3339_now() -> String {
let ms = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as i64;
let ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64;
let seconds = ms / 1000;
let millis = ms % 1000;
let days = seconds.div_euclid(86_400);
@@ -501,7 +596,9 @@ fn rfc3339_now() -> String {
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = mp + if mp < 10 { 3 } else { -9 };
if month <= 2 { year += 1; }
if month <= 2 {
year += 1;
}
let hour = day_seconds / 3600;
let minute = (day_seconds % 3600) / 60;
let second = day_seconds % 60;
@@ -526,7 +623,9 @@ async fn open_error_file(path: PathBuf) -> Result<File> {
file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
}
Ok(file)
}).await.map_err(|_| anyhow::anyhow!("error log open worker failed"))??;
})
.await
.map_err(|_| anyhow::anyhow!("error log open worker failed"))??;
Ok(File::from_std(file))
}
@@ -630,7 +729,9 @@ async fn error_log_loop(
}
}
}
let Some(opened) = file.as_mut() else { continue; };
let Some(opened) = file.as_mut() else {
continue;
};
let line = format_error_event(&event, config.format);
if let Err(error) = opened.write_all(&line).await {
errors.fetch_add(1, Ordering::Relaxed);
@@ -667,6 +768,8 @@ pub struct LogEntry {
pub upstream_status: Option<u16>,
pub upstream_error: Option<String>,
pub client_ip: Option<String>,
pub user_agent: Option<String>,
pub country: Option<String>,
pub status: u16,
pub duration_ms: u64,
pub request_bytes: u64,
@@ -735,6 +838,12 @@ impl Logs {
pub fn configure(&self, settings: Settings) {
self.settings.store(Arc::new(settings));
}
pub fn capture_user_agent(&self) -> bool {
self.settings.load().capture_user_agent
}
pub fn capture_country(&self) -> bool {
self.settings.load().capture_country
}
pub fn push(&self, entry: LogEntry) {
if let Ok(mut entries) = self.entries.try_lock() {
if entries.len() == HISTORY_CAPACITY {
@@ -758,7 +867,14 @@ impl Logs {
pub fn recent_limit(&self, limit: usize) -> Vec<LogEntry> {
self.entries
.lock()
.map(|entries| entries.iter().rev().take(limit.min(HISTORY_CAPACITY)).cloned().collect())
.map(|entries| {
entries
.iter()
.rev()
.take(limit.min(HISTORY_CAPACITY))
.cloned()
.collect()
})
.unwrap_or_default()
}
pub fn stats(&self) -> SinkStats {
@@ -778,6 +894,19 @@ fn value_for(entry: &LogEntry, name: &str) -> Value {
.clone()
.map(Value::String)
.unwrap_or(Value::Null),
"route" => entry
.route
.clone()
.map(Value::String)
.unwrap_or(Value::Null),
"cache_effective_enabled" => entry
.cache_effective_enabled
.map(Value::Bool)
.unwrap_or(Value::Null),
"transform_effective_enabled" => entry
.transform_effective_enabled
.map(Value::Bool)
.unwrap_or(Value::Null),
"backend" => entry
.backend
.clone()
@@ -790,13 +919,46 @@ fn value_for(entry: &LogEntry, name: &str) -> Value {
"path" => json!(&entry.path),
"http_version" => json!(entry.http_version),
"transport" => json!(entry.transport),
"websocket_bootstrap" => entry.websocket_bootstrap.map(|v| Value::String(v.into())).unwrap_or(Value::Null),
"websocket_phase" => entry.websocket_phase.map(|v| Value::String(v.into())).unwrap_or(Value::Null),
"websocket_error" => entry.websocket_error.map(|v| Value::String(v.into())).unwrap_or(Value::Null),
"h3_reset_code" => entry.h3_reset_code.map(|v| Value::String(v.into())).unwrap_or(Value::Null),
"upstream_status" => entry.upstream_status.map(|v| json!(v)).unwrap_or(Value::Null),
"upstream_error" => entry.upstream_error.clone().map(Value::String).unwrap_or(Value::Null),
"client_ip" => entry.client_ip.clone().map(Value::String).unwrap_or(Value::Null),
"websocket_bootstrap" => entry
.websocket_bootstrap
.map(|v| Value::String(v.into()))
.unwrap_or(Value::Null),
"websocket_phase" => entry
.websocket_phase
.map(|v| Value::String(v.into()))
.unwrap_or(Value::Null),
"websocket_error" => entry
.websocket_error
.map(|v| Value::String(v.into()))
.unwrap_or(Value::Null),
"h3_reset_code" => entry
.h3_reset_code
.map(|v| Value::String(v.into()))
.unwrap_or(Value::Null),
"upstream_status" => entry
.upstream_status
.map(|v| json!(v))
.unwrap_or(Value::Null),
"upstream_error" => entry
.upstream_error
.clone()
.map(Value::String)
.unwrap_or(Value::Null),
"client_ip" => entry
.client_ip
.clone()
.map(Value::String)
.unwrap_or(Value::Null),
"user_agent" => entry
.user_agent
.clone()
.map(Value::String)
.unwrap_or(Value::Null),
"country" => entry
.country
.clone()
.map(Value::String)
.unwrap_or(Value::Null),
"status" => json!(entry.status),
"duration_ms" => json!(entry.duration_ms),
"request_bytes" => json!(entry.request_bytes),
@@ -855,12 +1017,24 @@ struct FileState {
const PER_VHOST_OPEN_FILES: usize = 64;
fn vhost_log_name(vhost: Option<&str>) -> String {
let raw = vhost.filter(|value| !value.is_empty()).unwrap_or("_unmatched");
let raw = vhost
.filter(|value| !value.is_empty())
.unwrap_or("_unmatched");
let mut safe = String::with_capacity(raw.len().min(96));
for ch in raw.chars().take(96) {
safe.push(if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') { ch } else { '_' });
safe.push(
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
ch
} else {
'_'
},
);
}
if safe.is_empty() {
"_unmatched".into()
} else {
safe
}
if safe.is_empty() { "_unmatched".into() } else { safe }
}
struct LogstashState {
address: String,
@@ -902,15 +1076,23 @@ async fn sink_loop(
if tokio::fs::create_dir_all(&directory).await.is_err() {
failed = true;
} else {
let path = directory.join(format!("{}.jsonl", vhost_log_name(entry.vhost.as_deref())));
let path =
directory.join(format!("{}.jsonl", vhost_log_name(entry.vhost.as_deref())));
if !vhost_files.contains_key(&path) {
if vhost_files.len() >= PER_VHOST_OPEN_FILES {
if let Some(path) = vhost_files.keys().next().cloned() {
vhost_files.remove(&path);
}
}
match OpenOptions::new().create(true).append(true).open(&path).await {
Ok(opened) => { vhost_files.insert(path.clone(), opened); }
match OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.await
{
Ok(opened) => {
vhost_files.insert(path.clone(), opened);
}
Err(error) => {
tracing::warn!(%error, path=%path.display(), "per-vhost access log file unavailable");
failed = true;
@@ -931,8 +1113,16 @@ async fn sink_loop(
vhost_directory = None;
let path = PathBuf::from(&config.file.path);
if file.as_ref().is_none_or(|state| state.path != path) {
file = match OpenOptions::new().create(true).append(true).open(&path).await {
Ok(opened) => Some(FileState { path: path.clone(), file: opened }),
file = match OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.await
{
Ok(opened) => Some(FileState {
path: path.clone(),
file: opened,
}),
Err(error) => {
tracing::warn!(%error, path=%path.display(), "access log file unavailable");
None
@@ -1062,6 +1252,8 @@ mod tests {
upstream_status: Some(101),
upstream_error: None,
client_ip: Some("192.0.2.10".into()),
user_agent: Some("RustProxy-Test/1.0".into()),
country: Some("PL".into()),
status: 200,
duration_ms: 12,
request_bytes: 10,
@@ -1077,10 +1269,28 @@ mod tests {
let mut format = JsonFormat::default();
format.fields.insert("http_status".into(), "status".into());
format.fields.insert("site".into(), "vhost".into());
format.fields.insert("route".into(), "route".into());
format.fields.insert("ua".into(), "user_agent".into());
format.fields.insert("geo_country".into(), "country".into());
format
.fields
.insert("cache_enabled".into(), "cache_effective_enabled".into());
format.fields.insert(
"transform_enabled".into(),
"transform_effective_enabled".into(),
);
format.static_fields.insert("service".into(), "edge".into());
let mut settings = Settings::default();
settings.format = format.clone();
settings.validate().unwrap();
let value = format_entry(&entry(), &format);
assert_eq!(value["http_status"], 200);
assert_eq!(value["site"], "app");
assert_eq!(value["route"], "root");
assert_eq!(value["ua"], "RustProxy-Test/1.0");
assert_eq!(value["geo_country"], "PL");
assert_eq!(value["cache_enabled"], true);
assert_eq!(value["transform_enabled"], false);
assert_eq!(value["service"], "edge");
assert!(value.get("method").is_none());
}
@@ -1117,6 +1327,13 @@ mod tests {
assert!(settings.validate().is_err());
}
#[test]
fn capture_defaults_keep_geoip_off_request_path() {
let settings = Settings::default();
assert!(settings.capture_user_agent);
assert!(!settings.capture_country);
}
#[test]
fn default_json_format_keeps_standard_entry() {
let value = format_entry(&entry(), &JsonFormat::default());
@@ -1167,6 +1384,9 @@ mod tests {
}
assert_eq!(logs.recent_limit(LIVE_WINDOW).len(), LIVE_WINDOW);
assert_eq!(logs.recent().len(), LIVE_WINDOW + 80);
assert_eq!(logs.recent_limit(HISTORY_CAPACITY + 1).len(), LIVE_WINDOW + 80);
assert_eq!(
logs.recent_limit(HISTORY_CAPACITY + 1).len(),
LIVE_WINDOW + 80
);
}
}
+193 -61
View File
@@ -8,6 +8,12 @@ use hyper_util::{
client::legacy::{connect::HttpConnector, Client},
rt::TokioExecutor,
};
use rust_proxy::{
auth,
config::{self, Config, Envelope, ValidationPolicy},
db::Store,
runtime::App,
};
use rustls::{
client::{
danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier},
@@ -16,12 +22,6 @@ use rustls::{
pki_types::{CertificateDer, ServerName, UnixTime},
ClientConfig, DigitallySignedStruct, RootCertStore, SignatureScheme,
};
use rust_proxy::{
auth,
config::{self, Config, Envelope, ValidationPolicy},
db::Store,
runtime::App,
};
use sha2::{Digest, Sha256};
use std::io::{self, BufRead, IsTerminal, Read, Write};
use std::{
@@ -34,14 +34,14 @@ use tokio::{
net::{TcpListener, UdpSocket},
task::JoinSet,
};
use zeroize::Zeroizing;
use tracing_subscriber::prelude::*;
use zeroize::Zeroizing;
#[derive(Parser)]
#[command(
name = "rust-proxy",
version,
about = "RustProxy - independent Rust reverse proxy 0.24.10"
about = "RustProxy - independent Rust reverse proxy and Web Server 0.25.0"
)]
struct Cli {
#[arg(
@@ -142,9 +142,7 @@ enum BackupCommand {
output: Option<PathBuf>,
},
/// Restore an encrypted full backup atomically. Stop the RustProxy service first.
Restore {
archive: PathBuf,
},
Restore { archive: PathBuf },
}
#[derive(Subcommand)]
@@ -241,11 +239,21 @@ fn main() -> Result<()> {
}
fn installed_data_dir_candidates() -> &'static [&'static str] {
#[cfg(any(target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", target_os = "dragonfly"))]
#[cfg(any(
target_os = "freebsd",
target_os = "openbsd",
target_os = "netbsd",
target_os = "dragonfly"
))]
{
return &["/var/db/rustproxy", "/var/lib/rustproxy"];
}
#[cfg(not(any(target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", target_os = "dragonfly")))]
#[cfg(not(any(
target_os = "freebsd",
target_os = "openbsd",
target_os = "netbsd",
target_os = "dragonfly"
)))]
{
&["/var/lib/rustproxy", "/var/db/rustproxy"]
}
@@ -302,11 +310,16 @@ async fn application() -> Result<()> {
let cli = Cli::from_arg_matches(&matches)?;
let data_dir_explicit = explicit(&matches, "data_dir");
if cli.livelogs {
ensure!(cli.command.is_none(), "--livelogs cannot be combined with a subcommand");
ensure!(
cli.command.is_none(),
"--livelogs cannot be combined with a subcommand"
);
let data_dir = resolve_installed_data_dir(cli.data_dir, data_dir_explicit);
return live_logs_command(data_dir, cli.admin_listen).await;
}
let command = cli.command.context("a subcommand is required (or use --livelogs)")?;
let command = cli
.command
.context("a subcommand is required (or use --livelogs)")?;
match command {
Command::Token => {
admin_command(
@@ -316,7 +329,11 @@ async fn application() -> Result<()> {
.await
}
Command::Admin { action } => {
admin_command(resolve_installed_data_dir(cli.data_dir, data_dir_explicit), action).await
admin_command(
resolve_installed_data_dir(cli.data_dir, data_dir_explicit),
action,
)
.await
}
Command::Tls { action } => {
tls_command(
@@ -368,15 +385,19 @@ async fn backup_command(data_dir: PathBuf, action: BackupCommand) -> Result<()>
});
let mut generated = false;
let password = if io::stdin().is_terminal() {
let value = Zeroizing::new(
rpassword::prompt_password("Backup password (leave empty to generate): ")?
);
let value = Zeroizing::new(rpassword::prompt_password(
"Backup password (leave empty to generate): ",
)?);
if value.is_empty() {
generated = true;
Zeroizing::new(rust_proxy::backup::generated_password())
} else {
let confirmation = Zeroizing::new(rpassword::prompt_password("Confirm backup password: ")?);
ensure!(value.as_str() == confirmation.as_str(), "backup passwords do not match");
let confirmation =
Zeroizing::new(rpassword::prompt_password("Confirm backup password: ")?);
ensure!(
value.as_str() == confirmation.as_str(),
"backup passwords do not match"
);
value
}
} else {
@@ -386,10 +407,15 @@ async fn backup_command(data_dir: PathBuf, action: BackupCommand) -> Result<()>
let summary = rust_proxy::backup::create(&data_dir, &output, password.as_str()).await?;
println!(
"Encrypted backup created: {} ({} files, configuration revision {})",
summary.path.display(), summary.files, summary.configuration_revision
summary.path.display(),
summary.files,
summary.configuration_revision
);
if generated {
println!("Generated backup password (shown once): {}", password.as_str());
println!(
"Generated backup password (shown once): {}",
password.as_str()
);
println!("Store this password securely; restore is impossible without it.");
}
Ok(())
@@ -401,10 +427,13 @@ async fn backup_command(data_dir: PathBuf, action: BackupCommand) -> Result<()>
);
let password = Zeroizing::new(rpassword::prompt_password("Backup password: ")?);
ensure!(!password.is_empty(), "backup password cannot be empty");
let summary = rust_proxy::backup::restore(&archive, &data_dir, password.as_str()).await?;
let summary =
rust_proxy::backup::restore(&archive, &data_dir, password.as_str()).await?;
println!(
"Backup restored to {} ({} files, configuration revision {})",
data_dir.display(), summary.files, summary.configuration_revision
data_dir.display(),
summary.files,
summary.configuration_revision
);
Ok(())
}
@@ -529,9 +558,13 @@ async fn run(
let initialize = directory.clone();
tokio::task::spawn_blocking(move || rust_proxy::tls::initialize_default(&initialize)).await??;
rust_proxy::secret_store::initialize(&data_dir)?;
let migrated = rust_proxy::secret_store::migrate_acme_credentials(&store, &data_dir, &directory).await?;
let migrated =
rust_proxy::secret_store::migrate_acme_credentials(&store, &data_dir, &directory).await?;
if migrated > 0 {
tracing::info!(migrated, "legacy ACME credentials migrated to encrypted Secret Store");
tracing::info!(
migrated,
"legacy ACME credentials migrated to encrypted Secret Store"
);
}
let cert_dir = Some(directory.clone());
let policy = ValidationPolicy {
@@ -1040,7 +1073,10 @@ struct LiveLogFilters {
}
fn log_string<'a>(entry: &'a serde_json::Value, key: &str) -> &'a str {
entry.get(key).and_then(|value| value.as_str()).unwrap_or("-")
entry
.get(key)
.and_then(|value| value.as_str())
.unwrap_or("-")
}
fn log_optional_string<'a>(entry: &'a serde_json::Value, key: &str) -> Option<&'a str> {
@@ -1049,7 +1085,10 @@ fn log_optional_string<'a>(entry: &'a serde_json::Value, key: &str) -> Option<&'
fn live_status_matches(filter: &str, status: u64) -> bool {
if let Some(prefix) = filter.strip_suffix("xx") {
return prefix.parse::<u64>().ok().is_some_and(|class| status / 100 == class);
return prefix
.parse::<u64>()
.ok()
.is_some_and(|class| status / 100 == class);
}
filter.parse::<u64>().ok() == Some(status)
}
@@ -1076,7 +1115,10 @@ fn live_log_matches(entry: &serde_json::Value, filters: &LiveLogFilters) -> bool
return false;
}
}
let status = entry.get("status").and_then(|value| value.as_u64()).unwrap_or(0);
let status = entry
.get("status")
.and_then(|value| value.as_u64())
.unwrap_or(0);
if filters
.status
.as_ref()
@@ -1105,7 +1147,10 @@ fn live_log_matches(entry: &serde_json::Value, filters: &LiveLogFilters) -> bool
}
fn live_time(entry: &serde_json::Value) -> String {
let ms = entry.get("time_ms").and_then(|value| value.as_u64()).unwrap_or(0);
let ms = entry
.get("time_ms")
.and_then(|value| value.as_u64())
.unwrap_or(0);
let seconds = (ms / 1000) % 86_400;
format!(
"{:02}:{:02}:{:02}.{:03}Z",
@@ -1117,8 +1162,14 @@ fn live_time(entry: &serde_json::Value) -> String {
}
fn print_live_entry(entry: &serde_json::Value, detail: bool) {
let id = entry.get("id").and_then(|value| value.as_u64()).unwrap_or(0);
let status = entry.get("status").and_then(|value| value.as_u64()).unwrap_or(0);
let id = entry
.get("id")
.and_then(|value| value.as_u64())
.unwrap_or(0);
let status = entry
.get("status")
.and_then(|value| value.as_u64())
.unwrap_or(0);
let duration = entry
.get("duration_ms")
.and_then(|value| value.as_u64())
@@ -1175,7 +1226,9 @@ fn live_filters_summary(filters: &LiveLogFilters) -> String {
let mut values = Vec::new();
macro_rules! add {
($name:literal, $value:expr) => {
if let Some(value) = $value.as_ref() { values.push(format!("{}={}", $name, value)); }
if let Some(value) = $value.as_ref() {
values.push(format!("{}={}", $name, value));
}
};
}
add!("host", filters.host);
@@ -1188,11 +1241,23 @@ fn live_filters_summary(filters: &LiveLogFilters) -> String {
add!("cache", filters.cache);
add!("status", filters.status);
add!("path", filters.path_prefix);
if filters.errors_only { values.push("errors=on".into()); }
if let Some(ms) = filters.slow_ms { values.push(format!("slow>={ms}ms")); }
if filters.detail { values.push("detail=on".into()); }
if filters.paused { values.push("paused".into()); }
if values.is_empty() { "none".into() } else { values.join(" ") }
if filters.errors_only {
values.push("errors=on".into());
}
if let Some(ms) = filters.slow_ms {
values.push(format!("slow>={ms}ms"));
}
if filters.detail {
values.push("detail=on".into());
}
if filters.paused {
values.push("paused".into());
}
if values.is_empty() {
"none".into()
} else {
values.join(" ")
}
}
fn live_help() {
@@ -1227,20 +1292,44 @@ fn handle_live_command(
match command.as_str() {
"help" | "?" => live_help(),
"filters" => println!("filters: {}", live_filters_summary(filters)),
"reset" => { *filters = LiveLogFilters::default(); println!("filters cleared"); }
"pause" => { filters.paused = true; println!("paused"); }
"resume" => { filters.paused = false; println!("resumed"); }
"clear" => { print!("\x1b[2J\x1b[H"); io::stdout().flush()?; }
"reset" => {
*filters = LiveLogFilters::default();
println!("filters cleared");
}
"pause" => {
filters.paused = true;
println!("paused");
}
"resume" => {
filters.paused = false;
println!("resumed");
}
"clear" => {
print!("\x1b[2J\x1b[H");
io::stdout().flush()?;
}
"quit" | "exit" | "q" => return Ok(false),
"detail" => filters.detail = matches!(value, "on" | "1" | "true"),
"errors" => filters.errors_only = matches!(value, "on" | "1" | "true"),
"slow" => {
filters.slow_ms = if matches!(value, "" | "off" | "*") { None } else { Some(value.parse().context("slow expects milliseconds or off")?) };
filters.slow_ms = if matches!(value, "" | "off" | "*") {
None
} else {
Some(value.parse().context("slow expects milliseconds or off")?)
};
}
"status" => {
if matches!(value, "" | "*" | "off") { filters.status = None; }
else {
ensure!(matches!(value, "1xx" | "2xx" | "3xx" | "4xx" | "5xx") || value.parse::<u16>().ok().is_some_and(|v| (100..=599).contains(&v)), "status expects 100..599 or 1xx..5xx");
if matches!(value, "" | "*" | "off") {
filters.status = None;
} else {
ensure!(
matches!(value, "1xx" | "2xx" | "3xx" | "4xx" | "5xx")
|| value
.parse::<u16>()
.ok()
.is_some_and(|v| (100..=599).contains(&v)),
"status expects 100..599 or 1xx..5xx"
);
filters.status = Some(value.to_ascii_lowercase());
}
}
@@ -1249,9 +1338,19 @@ fn handle_live_command(
"backend" => set_live_filter(&mut filters.backend, value),
"route" => set_live_filter(&mut filters.route, value),
"client" => set_live_filter(&mut filters.client, value),
"method" => { set_live_filter(&mut filters.method, value); if let Some(v) = filters.method.as_mut() { *v = v.to_ascii_uppercase(); } },
"method" => {
set_live_filter(&mut filters.method, value);
if let Some(v) = filters.method.as_mut() {
*v = v.to_ascii_uppercase();
}
}
"transport" => set_live_filter(&mut filters.transport, value),
"cache" => { set_live_filter(&mut filters.cache, value); if let Some(v) = filters.cache.as_mut() { *v = v.to_ascii_uppercase(); } },
"cache" => {
set_live_filter(&mut filters.cache, value);
if let Some(v) = filters.cache.as_mut() {
*v = v.to_ascii_uppercase();
}
}
"path" => set_live_filter(&mut filters.path_prefix, value),
"show" => {
let id: u64 = value.parse().context("show expects a request id")?;
@@ -1262,7 +1361,10 @@ fn handle_live_command(
}
_ => bail!("unknown live-log command '{command}' (type help)"),
}
if !matches!(command.as_str(), "help" | "?" | "filters" | "show" | "clear") {
if !matches!(
command.as_str(),
"help" | "?" | "filters" | "show" | "clear"
) {
println!("filters: {}", live_filters_summary(filters));
}
Ok(true)
@@ -1277,7 +1379,13 @@ fn take_sse_event(buffer: &mut Vec<u8>) -> Option<Vec<u8>> {
let lf = find(b"\n\n").map(|index| (index, 2));
let crlf = find(b"\r\n\r\n").map(|index| (index, 4));
let (index, delimiter) = match (lf, crlf) {
(Some(a), Some(b)) => if a.0 <= b.0 { a } else { b },
(Some(a), Some(b)) => {
if a.0 <= b.0 {
a
} else {
b
}
}
(Some(a), None) => a,
(None, Some(b)) => b,
(None, None) => return None,
@@ -1361,9 +1469,12 @@ fn local_admin_public_roots() -> RootCertStore {
fn local_admin_certificate_pins(data_dir: &Path) -> Result<Vec<[u8; 32]>> {
let directory = data_dir.join("certs");
let mut pins = Vec::new();
for entry in std::fs::read_dir(&directory)
.with_context(|| format!("cannot read local certificate directory {}", directory.display()))?
{
for entry in std::fs::read_dir(&directory).with_context(|| {
format!(
"cannot read local certificate directory {}",
directory.display()
)
})? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|value| value.to_str()) != Some("pem") {
@@ -1394,7 +1505,10 @@ fn local_admin_certificate_pins(data_dir: &Path) -> Result<Vec<[u8; 32]>> {
Ok(pins)
}
fn local_admin_scheme_from_probe(status: StatusCode, location: Option<&str>) -> Result<&'static str> {
fn local_admin_scheme_from_probe(
status: StatusCode,
location: Option<&str>,
) -> Result<&'static str> {
if status == StatusCode::PERMANENT_REDIRECT {
ensure!(
location.is_some_and(|value| value.starts_with("https://")),
@@ -1487,10 +1601,17 @@ impl LocalAdminClient {
}
async fn live_logs_command(dir: PathBuf, addr: SocketAddr) -> Result<()> {
ensure!(addr.ip().is_loopback(), "--livelogs requires the local loopback administration listener");
ensure!(
addr.ip().is_loopback(),
"--livelogs requires the local loopback administration listener"
);
auth::prepare_directory(&dir)?;
let path = dir.join("config.sqlite3");
ensure!(path.is_file(), "database does not exist at {}; start rust-proxy serve first", path.display());
ensure!(
path.is_file(),
"database does not exist at {}; start rust-proxy serve first",
path.display()
);
auth::prepare_database(&path)?;
let store = Store::open(&path, &Config::default()).await?;
let admin = LocalAdminClient::connect(&dir, addr).await?;
@@ -1515,7 +1636,7 @@ async fn live_logs_command(dir: PathBuf, addr: SocketAddr) -> Result<()> {
}
});
let interactive = io::stdin().is_terminal() && io::stdout().is_terminal();
println!("RustProxy live logs 0.24.10 - all vhosts. Type 'help' for interactive filters; Ctrl-C or 'quit' exits.");
println!("RustProxy live logs 0.25.0 - all vhosts. Type 'help' for interactive filters; Ctrl-C or 'quit' exits.");
if interactive { print!("> "); io::stdout().flush()?; }
let mut filters = LiveLogFilters::default();
let mut history = std::collections::BTreeMap::<u64, serde_json::Value>::new();
@@ -1811,7 +1932,11 @@ async fn tls_command(dir: PathBuf, addr: SocketAddr, action: TlsCommand) -> Resu
if matches!(&action, TlsCommand::Reload) {
auth::prepare_directory(&dir)?;
let path = dir.join("config.sqlite3");
ensure!(path.is_file(), "database does not exist at {}; start rust-proxy serve first", path.display());
ensure!(
path.is_file(),
"database does not exist at {}; start rust-proxy serve first",
path.display()
);
auth::prepare_database(&path)?;
let store = Store::open(&path, &Config::default()).await?;
let admin = LocalAdminClient::connect(&dir, addr).await?;
@@ -1848,7 +1973,11 @@ async fn tls_command(dir: PathBuf, addr: SocketAddr, action: TlsCommand) -> Resu
auth::prepare_directory(&dir)?;
let database = dir.join("config.sqlite3");
ensure!(database.is_file(), "database does not exist at {}; start rust-proxy serve first", database.display());
ensure!(
database.is_file(),
"database does not exist at {}; start rust-proxy serve first",
database.display()
);
auth::prepare_database(&database)?;
let store = Store::open(&database, &Config::default()).await?;
let admin = LocalAdminClient::connect(&dir, addr).await?;
@@ -1982,7 +2111,10 @@ mod cli_regressions {
#[test]
fn sse_parser_accepts_lf_and_crlf_frames() {
let mut lf = b"event: log\ndata: {\"id\":1}\n\nrest".to_vec();
assert_eq!(take_sse_event(&mut lf).unwrap(), b"event: log\ndata: {\"id\":1}".to_vec());
assert_eq!(
take_sse_event(&mut lf).unwrap(),
b"event: log\ndata: {\"id\":1}".to_vec()
);
assert_eq!(lf, b"rest".to_vec());
let mut crlf = b"event: gap\r\ndata: {\"dropped\":2}\r\n\r\n".to_vec();
let event = take_sse_event(&mut crlf).unwrap();
+57
View File
@@ -426,9 +426,24 @@ pub struct VhostMetrics {
pub bytes_in: AtomicU64,
pub bytes_out: AtomicU64,
pub duration_us: AtomicU64,
pub ewma_duration_us: AtomicU64,
pub status: [AtomicU64; 5],
pub buckets: [AtomicU64; 11],
}
#[derive(Debug, Clone, Serialize)]
pub struct VhostMetricsView {
pub requests: u64,
pub completed: u64,
pub active: u64,
pub rejected: u64,
pub errors: u64,
pub bytes_in: u64,
pub bytes_out: u64,
pub average_latency_ms: f64,
pub ewma_latency_ms: f64,
}
impl VhostMetrics {
pub fn complete(
&self,
@@ -448,6 +463,23 @@ impl VhostMetrics {
self.status[(status as usize / 100).clamp(1, 5) - 1].fetch_add(1, Ordering::Relaxed);
let micros = duration.as_micros().min(u64::MAX as u128) as u64;
self.duration_us.fetch_add(micros, Ordering::Relaxed);
let mut current = self.ewma_duration_us.load(Ordering::Relaxed);
loop {
let next = if current == 0 {
micros
} else {
current.saturating_mul(7).saturating_add(micros) / 8
};
match self.ewma_duration_us.compare_exchange_weak(
current,
next,
Ordering::Relaxed,
Ordering::Relaxed,
) {
Ok(_) => break,
Err(observed) => current = observed,
}
}
for (index, threshold) in DURATION_BUCKETS_US.iter().enumerate() {
if micros <= *threshold {
self.buckets[index].fetch_add(1, Ordering::Relaxed);
@@ -455,6 +487,26 @@ impl VhostMetrics {
}
self.buckets[DURATION_BUCKETS_US.len()].fetch_add(1, Ordering::Relaxed);
}
pub fn view(&self) -> VhostMetricsView {
let completed = n(&self.completed);
let duration_us = n(&self.duration_us);
VhostMetricsView {
requests: n(&self.requests),
completed,
active: n(&self.active),
rejected: n(&self.rejected),
errors: n(&self.errors),
bytes_in: n(&self.bytes_in),
bytes_out: n(&self.bytes_out),
average_latency_ms: if completed == 0 {
0.0
} else {
duration_us as f64 / completed as f64 / 1000.0
},
ewma_latency_ms: n(&self.ewma_duration_us) as f64 / 1000.0,
}
}
}
#[cfg(test)]
mod vhost_tests {
@@ -470,6 +522,11 @@ mod vhost_tests {
assert_eq!(n(&m.buckets[1]), 0);
assert_eq!(n(&m.buckets[2]), 1);
assert_eq!(n(&m.buckets[10]), 1);
let view = m.view();
assert_eq!(view.requests, 0);
assert_eq!(view.completed, 1);
assert_eq!(view.errors, 1);
assert!(view.ewma_latency_ms >= 20.0);
}
}
+97 -30
View File
@@ -41,6 +41,8 @@ pub struct Exchange {
pub upstream_status: AtomicU16,
pub upstream_error: Mutex<Option<String>>,
pub client_ip: Mutex<Option<String>>,
pub user_agent: Option<String>,
pub country: Mutex<Option<String>>,
pub started: Instant,
pub status: AtomicU16,
pub cache_status: AtomicU16,
@@ -51,18 +53,28 @@ pub struct Exchange {
pub response_bytes: AtomicU64,
pub metrics: Arc<Metrics>,
pub logs: Arc<Logs>,
pub traffic: Arc<crate::traffic::Analytics>,
pub _permit: OwnedSemaphorePermit,
}
impl Exchange {
pub fn websocket_phase(&self, phase: &'static str) {
if self.websocket_bootstrap.is_some() {
*self.websocket_phase.lock().unwrap_or_else(|e| e.into_inner()) = Some(phase);
*self
.websocket_phase
.lock()
.unwrap_or_else(|e| e.into_inner()) = Some(phase);
}
}
pub fn websocket_error(&self, phase: &'static str, error: &'static str) {
if self.websocket_bootstrap.is_some() {
*self.websocket_phase.lock().unwrap_or_else(|e| e.into_inner()) = Some(phase);
*self.websocket_error.lock().unwrap_or_else(|e| e.into_inner()) = Some(error);
*self
.websocket_phase
.lock()
.unwrap_or_else(|e| e.into_inner()) = Some(phase);
*self
.websocket_error
.lock()
.unwrap_or_else(|e| e.into_inner()) = Some(error);
}
}
pub fn h3_reset(&self, code: &'static str) {
@@ -126,13 +138,71 @@ impl Drop for Exchange {
.unwrap_or_else(|e| e.into_inner())
.as_ref()
.map(|l| l.member.backend.id.clone());
let websocket_phase = *self.websocket_phase.lock().unwrap_or_else(|e| e.into_inner());
let websocket_phase = *self
.websocket_phase
.lock()
.unwrap_or_else(|e| e.into_inner());
let route = self.route.lock().unwrap_or_else(|e| e.into_inner()).clone();
let cache_effective_enabled = *self
.cache_effective_enabled
.lock()
.unwrap_or_else(|e| e.into_inner());
let transform_effective_enabled = *self
.transform_effective_enabled
.lock()
.unwrap_or_else(|e| e.into_inner());
let client_ip = self
.client_ip
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone();
let country = self
.country
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone();
let request_bytes = self.request_bytes.load(Ordering::Relaxed);
let response_bytes = self.response_bytes.load(Ordering::Relaxed);
let duration_ms = duration.as_millis() as u64;
let cache = match self.cache_status.load(Ordering::Relaxed) {
1 => "HIT",
2 => "MISS",
3 => "BYPASS",
_ => "-",
};
let outcome = if failed {
"body_error"
} else if aborted {
"cancelled"
} else if status >= 500 {
"proxy_or_upstream_error"
} else if status == 101 || websocket_phase == Some("tunnel_closed") {
"websocket_closed"
} else {
"complete"
};
if let Some(vhost) = self.vhost.clone() {
self.traffic.push(crate::traffic::Sample {
vhost,
route: route.clone(),
method: self.method.clone(),
path: self.path.clone(),
client_ip: client_ip.clone(),
status,
duration_ms,
request_bytes,
response_bytes,
cache,
user_agent: self.user_agent.clone(),
country: country.clone(),
});
}
self.logs.push(LogEntry {
cache_reason: *self.cache_reason.lock().unwrap_or_else(|e| e.into_inner()),
vhost: self.vhost.clone(),
route: self.route.lock().unwrap_or_else(|e| e.into_inner()).clone(),
cache_effective_enabled: *self.cache_effective_enabled.lock().unwrap_or_else(|e| e.into_inner()),
transform_effective_enabled: *self.transform_effective_enabled.lock().unwrap_or_else(|e| e.into_inner()),
route,
cache_effective_enabled,
transform_effective_enabled,
backend,
id: self.id,
time_ms: now_ms(),
@@ -143,35 +213,29 @@ impl Drop for Exchange {
transport: self.transport,
websocket_bootstrap: self.websocket_bootstrap,
websocket_phase,
websocket_error: *self.websocket_error.lock().unwrap_or_else(|e| e.into_inner()),
websocket_error: *self
.websocket_error
.lock()
.unwrap_or_else(|e| e.into_inner()),
h3_reset_code: *self.h3_reset_code.lock().unwrap_or_else(|e| e.into_inner()),
upstream_status: match self.upstream_status.load(Ordering::Relaxed) {
0 => None,
value => Some(value),
},
upstream_error: self.upstream_error.lock().unwrap_or_else(|e| e.into_inner()).clone(),
client_ip: self.client_ip.lock().unwrap_or_else(|e| e.into_inner()).clone(),
upstream_error: self
.upstream_error
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone(),
client_ip,
user_agent: self.user_agent.clone(),
country,
status,
duration_ms: duration.as_millis() as u64,
cache: match self.cache_status.load(Ordering::Relaxed) {
1 => "HIT",
2 => "MISS",
3 => "BYPASS",
_ => "-",
},
request_bytes: self.request_bytes.load(Ordering::Relaxed),
response_bytes: self.response_bytes.load(Ordering::Relaxed),
outcome: if failed {
"body_error"
} else if aborted {
"cancelled"
} else if status >= 500 {
"proxy_or_upstream_error"
} else if status == 101 || websocket_phase == Some("tunnel_closed") {
"websocket_closed"
} else {
"complete"
},
duration_ms,
cache,
request_bytes,
response_bytes,
outcome,
});
}
}
@@ -332,6 +396,8 @@ mod tests {
upstream_status: AtomicU16::new(0),
upstream_error: Mutex::new(None),
client_ip: Mutex::new(Some("127.0.0.1".into())),
user_agent: Some("RustProxy-Test/1.0".into()),
country: Mutex::new(Some("PL".into())),
started: Instant::now(),
status: AtomicU16::new(200),
cache_status: AtomicU16::new(0),
@@ -342,6 +408,7 @@ mod tests {
response_bytes: AtomicU64::new(0),
metrics,
logs: Arc::new(Logs::default()),
traffic: crate::traffic::Analytics::start(tokio_util::sync::CancellationToken::new()),
_permit: permit,
});
(exchange, slots)
+11 -3
View File
@@ -62,12 +62,18 @@ mod tests {
#[test]
fn admin_cookie_can_be_restored_only_from_captured_fragment() {
let mut h = HeaderMap::new();
h.insert("cookie", "a=1; rustproxy_session=secret; b=2".parse().unwrap());
h.insert(
"cookie",
"a=1; rustproxy_session=secret; b=2".parse().unwrap(),
);
let admin = strip_admin_cookie(&mut h);
assert_eq!(h.get("cookie").unwrap(), "a=1; b=2");
assert_eq!(admin, vec!["rustproxy_session=secret"]);
restore_admin_cookie(&mut h, &admin);
assert_eq!(h.get("cookie").unwrap(), "a=1; b=2; rustproxy_session=secret");
assert_eq!(
h.get("cookie").unwrap(),
"a=1; b=2; rustproxy_session=secret"
);
}
}
@@ -85,7 +91,9 @@ pub fn strip_admin_cookie(headers: &mut HeaderMap) -> Vec<String> {
.flat_map(|h| h.split(';'))
{
let part = part.trim();
let Some((name, _)) = part.split_once('=') else { continue; };
let Some((name, _)) = part.split_once('=') else {
continue;
};
if name == crate::auth::COOKIE_NAME {
admin.push(part.to_owned());
} else {
+388 -198
View File
@@ -15,7 +15,7 @@ use axum::{
};
use body::{Direction, Exchange, MeteredBody};
use http::{
header::{HeaderValue, HOST},
header::{HeaderValue, HOST, USER_AGENT},
HeaderMap, Method, StatusCode, Uri, Version,
};
use http_body::Body as HttpBody;
@@ -24,10 +24,16 @@ use http_body_util::BodyExt;
struct ResponseRulesApplied;
#[derive(Clone)]
struct LocalGeneratedResponse;
#[derive(Clone)]
struct StaticSourceResponse;
#[derive(Clone, Copy)]
struct RedirectResponse { http3_enabled: bool }
struct RedirectResponse {
http3_enabled: bool,
}
#[derive(Clone, Copy)]
struct LocalHostResponse { http3_enabled: bool }
struct LocalHostResponse {
http3_enabled: bool,
}
#[derive(Clone, Copy)]
struct OriginTiming(Duration);
#[derive(Clone)]
@@ -80,7 +86,9 @@ fn format_error_chain(error: &(dyn std::error::Error + 'static)) -> String {
parts.join(" -> ")
}
fn classify_stale_error(error: &(dyn std::error::Error + 'static)) -> crate::cache::StaleErrorClass {
fn classify_stale_error(
error: &(dyn std::error::Error + 'static),
) -> crate::cache::StaleErrorClass {
let mut current = Some(error);
let mut text = String::new();
for _ in 0..12 {
@@ -115,7 +123,10 @@ fn classify_stale_error(error: &(dyn std::error::Error + 'static)) -> crate::cac
}
}
fn upstream_error_class(error: &(dyn std::error::Error + 'static), connect_failure: bool) -> &'static str {
fn upstream_error_class(
error: &(dyn std::error::Error + 'static),
connect_failure: bool,
) -> &'static str {
match classify_stale_error(error) {
crate::cache::StaleErrorClass::Dns => "dns",
crate::cache::StaleErrorClass::Tls => "tls",
@@ -217,7 +228,6 @@ where
detail
}
#[derive(Clone)]
struct CacheRefreshContext {
state: Arc<App>,
@@ -271,21 +281,17 @@ async fn refresh_cache_once(
*refresh.version_mut() = Version::HTTP_11;
*refresh.headers_mut() = refresh_headers;
let started = Instant::now();
let Ok(Ok(origin)) = tokio::time::timeout(
context.timeout,
context.backend.client.request(refresh),
)
.await
let Ok(Ok(origin)) =
tokio::time::timeout(context.timeout, context.backend.client.request(refresh)).await
else {
return false;
};
let origin_validation_headers = origin.headers().clone();
let Some(route) = context.snapshot.select_on(
&context.host,
&context.path,
context.local,
context.tls,
) else {
let Some(route) =
context
.snapshot
.select_on(&context.host, &context.path, context.local, context.tls)
else {
return false;
};
if origin.status() == StatusCode::NOT_MODIFIED {
@@ -464,10 +470,8 @@ fn schedule_proactive_refresh(
delay = 5;
continue;
};
let Some(revalidation_guard) = context
.state
.cache
.begin_background_revalidation(&cached)
let Some(revalidation_guard) =
context.state.cache.begin_background_revalidation(&cached)
else {
drop(inflight_guard);
delay = 1;
@@ -547,7 +551,11 @@ fn local_location_response(spec: &crate::vhost::LocationResponse, method: &Metho
let body_allowed = !matches!(status.as_u16(), 100..=199 | 204 | 304) && *method != Method::HEAD;
let mut response = Response::builder()
.status(status)
.body(if body_allowed { Body::from(spec.body.clone()) } else { Body::empty() })
.body(if body_allowed {
Body::from(spec.body.clone())
} else {
Body::empty()
})
.unwrap_or_else(|_| (StatusCode::INTERNAL_SERVER_ERROR, Body::empty()).into_response());
if let Ok(value) = HeaderValue::from_str(&spec.content_type) {
response.headers_mut().insert("content-type", value);
@@ -596,8 +604,7 @@ fn admin_session_passthrough(
tls: bool,
backend: &crate::config::CompiledBackend,
) -> bool {
tls
&& backend.accept_local_listener_risk
tls && backend.accept_local_listener_risk
&& backend.targets_admin_listener
&& snapshot
.envelope
@@ -904,6 +911,17 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
.extensions()
.get::<PeerAddr>()
.map(|peer| peer.0.ip().to_string());
let user_agent = state
.logs
.capture_user_agent()
.then(|| {
request
.headers()
.get(USER_AGENT)
.and_then(|value| value.to_str().ok())
.map(|value| value.chars().take(512).collect::<String>())
})
.flatten();
let (http_version, transport) = match request.version() {
Version::HTTP_10 => ("1.0", "h1"),
Version::HTTP_11 => ("1.1", "h1"),
@@ -939,6 +957,8 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
// the verified effective client address after trusted-proxy/Cloudflare
// processing, so policy and live-log identity remain aligned.
client_ip: Mutex::new(initial_client_ip),
user_agent,
country: Mutex::new(None),
started: Instant::now(),
status: AtomicU16::new(500),
cache_status: AtomicU16::new(0),
@@ -949,6 +969,7 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
response_bytes: AtomicU64::new(0),
metrics: state.metrics.clone(),
logs: state.logs.clone(),
traffic: state.traffic.clone(),
_permit: permit,
});
if let Ok(value) = HeaderValue::from_str(&id.to_string()) {
@@ -968,7 +989,10 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
))
});
let mut response = next.run(request).await;
let minimal_welcome = response.extensions().get::<crate::pages::MinimalWelcome>().is_some();
let minimal_welcome = response
.extensions()
.get::<crate::pages::MinimalWelcome>()
.is_some();
let code = response.status().as_u16();
let custom_error = frontend_context
.as_ref()
@@ -989,17 +1013,32 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
// minimal. It is never cacheable and carries no optional RustProxy
// diagnostics/server/security/compression/cache-diagnostic headers.
for name in [
"server", "server-timing", "alt-svc", "x-rustproxy-request-id",
"x-rustproxy-revision", "x-rust-proxy-backend",
"x-rust-proxy-backend-time", "x-rustproxy-backend-time", "x-cache", "x-cache-hits",
"x-cache-status", "x-cache-resident-age", "content-encoding", "vary",
"strict-transport-security", "content-security-policy",
"permissions-policy", "referrer-policy", "x-frame-options",
"server",
"server-timing",
"alt-svc",
"x-rustproxy-request-id",
"x-rustproxy-revision",
"x-rust-proxy-backend",
"x-rust-proxy-backend-time",
"x-rustproxy-backend-time",
"x-cache",
"x-cache-hits",
"x-cache-status",
"x-cache-resident-age",
"content-encoding",
"vary",
"strict-transport-security",
"content-security-policy",
"permissions-policy",
"referrer-policy",
"x-frame-options",
"x-content-type-options",
] {
response.headers_mut().remove(name);
}
response.headers_mut().insert("cache-control", HeaderValue::from_static("no-store"));
response
.headers_mut()
.insert("cache-control", HeaderValue::from_static("no-store"));
} else {
if response
.extensions()
@@ -1029,10 +1068,18 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
id,
&state.active_listeners,
);
if response.extensions().get::<RedirectResponse>().is_some_and(|redirect| !redirect.http3_enabled) {
if response
.extensions()
.get::<RedirectResponse>()
.is_some_and(|redirect| !redirect.http3_enabled)
{
response.headers_mut().remove("alt-svc");
}
if response.extensions().get::<LocalHostResponse>().is_some_and(|item| !item.http3_enabled) {
if response
.extensions()
.get::<LocalHostResponse>()
.is_some_and(|item| !item.http3_enabled)
{
response.headers_mut().remove("alt-svc");
}
response = crate::compression::encode(
@@ -1055,8 +1102,18 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
.is_none()
&& response.extensions().get::<RedirectResponse>().is_none()
&& response.extensions().get::<LocalHostResponse>().is_none()
&& response.extensions().get::<crate::pages::PageServed>().is_none()
&& response.extensions().get::<LocalGeneratedResponse>().is_none()
&& response
.extensions()
.get::<crate::pages::PageServed>()
.is_none()
&& response
.extensions()
.get::<LocalGeneratedResponse>()
.is_none()
&& response
.extensions()
.get::<StaticSourceResponse>()
.is_none()
&& response
.extensions()
.get::<crate::tinyauth::AuthenticationFailure>()
@@ -1087,8 +1144,14 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
cache.annotate_transformed(response.headers_mut(), transformed);
}
}
if response.extensions().get::<crate::pages::PageServed>().is_some()
|| response.extensions().get::<LocalGeneratedResponse>().is_some()
if response
.extensions()
.get::<crate::pages::PageServed>()
.is_some()
|| response
.extensions()
.get::<LocalGeneratedResponse>()
.is_some()
{
// Static/placeholder/default pages are generated locally and must never
// look cacheable or carry RustProxy cache diagnostics/validators.
@@ -1145,7 +1208,10 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
}
/// Build a same-host HTTPS redirect while preserving the request path and query.
fn force_ssl_location(host: &str, uri: &Uri, port: u16) -> Option<HeaderValue> {
let path = uri.path_and_query().map(|value| value.as_str()).unwrap_or("/");
let path = uri
.path_and_query()
.map(|value| value.as_str())
.unwrap_or("/");
let location = if port == 443 {
format!("https://{host}{path}")
} else {
@@ -1164,7 +1230,9 @@ fn force_ssl_redirect(
}
let location = force_ssl_location(host, uri, frontend.https_redirect_port?)?;
let mut response = StatusCode::PERMANENT_REDIRECT.into_response();
response.headers_mut().insert(http::header::LOCATION, location);
response
.headers_mut()
.insert(http::header::LOCATION, location);
// Do not pin a redirect in a browser after an operator later disables Force SSL.
response
.headers_mut()
@@ -1295,8 +1363,8 @@ async fn forward(
);
};
let frontend = snapshot.frontend(&host, local.0, tls);
let websocket_legacy_support = handshake.is_some()
&& frontend.is_some_and(|frontend| frontend.websocket_legacy_support);
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);
let local_host = snapshot.not_found_host_on(&host, local.0, tls);
// TLS ALPN normally prevents HTTP/2 from reaching a frontend that disabled it.
@@ -1342,7 +1410,9 @@ async fn forward(
);
}
if !tls {
if let Some(response) = frontend.and_then(|frontend| force_ssl_redirect(frontend, &host, request.uri())) {
if let Some(response) =
frontend.and_then(|frontend| force_ssl_redirect(frontend, &host, request.uri()))
{
return response;
}
}
@@ -1373,10 +1443,24 @@ async fn forward(
}
}
if let Some(exchange) = request.extensions().get::<Arc<Exchange>>() {
*exchange.client_ip.lock().unwrap_or_else(|e| e.into_inner()) = Some(context.client_ip.to_string());
*exchange.client_ip.lock().unwrap_or_else(|e| e.into_inner()) =
Some(context.client_ip.to_string());
}
if let Some(frontend) = frontend {
let country = state.geoip.country(context.client_ip);
// GeoIP is deliberately off the default request path. Resolve only when
// a country policy/rate-limit requires it or the operator explicitly
// enables country capture for access logs + traffic analytics.
let capture_country = state.logs.capture_country();
let country = (capture_country
|| frontend.profile_country_access.needs_geoip()
|| frontend.rate_limit.needs_geoip())
.then(|| state.geoip.country(context.client_ip))
.flatten();
if capture_country {
if let Some(exchange) = request.extensions().get::<Arc<Exchange>>() {
*exchange.country.lock().unwrap_or_else(|e| e.into_inner()) = country.clone();
}
}
if !frontend.profile_country_access.allows(country.as_deref())
|| !frontend.rate_limit.country_allowed(country.as_deref())
{
@@ -1424,12 +1508,16 @@ async fn forward(
"RustProxy: redirect target could not be rendered\n",
);
};
response.extensions_mut().insert(RedirectResponse { http3_enabled: _redirect.http3_enabled });
response.extensions_mut().insert(RedirectResponse {
http3_enabled: _redirect.http3_enabled,
});
return response;
}
if let Some(item) = local_host {
let mut response = item.page.serve(request.method(), StatusCode::NOT_FOUND);
response.extensions_mut().insert(LocalHostResponse { http3_enabled: item.http3_enabled });
response.extensions_mut().insert(LocalHostResponse {
http3_enabled: item.http3_enabled,
});
return response;
}
let selected = snapshot.select_on(&host, &path, local.0, tls);
@@ -1537,12 +1625,69 @@ async fn forward(
if let Some(spec) = &route.response {
return local_location_response(spec, request.method());
}
if let crate::config::CompiledSource::WebServer(service) = &route.source {
if handshake.is_some() {
return error(
StatusCode::BAD_REQUEST,
"RustProxy: Web Server source does not support WebSocket upgrades\n",
);
}
let service_path = match route.rewritten_path(&path) {
Ok(Some(path)) => path,
Ok(None) => path.clone(),
Err(_) => {
return error(
StatusCode::BAD_REQUEST,
"RustProxy: invalid Web Server rewrite path\n",
)
}
};
if let Some(exchange) = request.extensions().get::<Arc<Exchange>>() {
*exchange.route.lock().unwrap_or_else(|e| e.into_inner()) = Some(route.id.clone());
*exchange
.cache_effective_enabled
.lock()
.unwrap_or_else(|e| e.into_inner()) = Some(false);
*exchange
.transform_effective_enabled
.lock()
.unwrap_or_else(|e| e.into_inner()) = Some(false);
}
// Do not hold a shared reference to Request<Body> across await. Axum handlers
// must return Send futures, while Body is not Sync. Clone only static-request
// metadata needed by the filesystem source before entering the async server.
let static_method = request.method().clone();
let static_headers = request.headers().clone();
let static_uri = request.uri().clone();
let static_version = request.version();
let static_body = std::mem::replace(request.body_mut(), Body::empty());
let mut response = crate::web_server::serve(
service,
static_method,
static_headers,
static_uri,
&service_path,
static_body,
crate::web_server::RequestContext {
client_ip: context.client_ip,
client_port: Some(peer.0.port()),
server_name: host.clone(),
server_port: local.0.port(),
tls,
version: static_version,
},
)
.await;
response.extensions_mut().insert(StaticSourceResponse);
return response;
}
let (backend, backend_lease, affinity_cookie) = if let Some(pool) = &route.pool {
let selection = match &route.backend_id {
Some(id) => pool.select_member_wait(id).await,
None => pool
.select_wait(context.client_ip, request.headers(), tls)
.await,
None => {
pool.select_wait(context.client_ip, request.headers(), tls)
.await
}
};
let selection = match selection {
Ok(selection) => selection,
@@ -1573,7 +1718,13 @@ async fn forward(
selection.cookie,
)
} else {
(route.backend.clone(), None, None)
let Some(backend) = route.backend.clone() else {
return error(
StatusCode::INTERNAL_SERVER_ERROR,
"RustProxy: Reverse Proxy route has no backend\n",
);
};
(backend, None, None)
};
let admin_control_plane = admin_session_passthrough(&snapshot, &host, tls, &backend);
if admin_control_plane {
@@ -1588,9 +1739,14 @@ async fn forward(
let scope = route.frontend.as_ref().map(|f| f.id.as_str()).unwrap_or("");
if let Some(exchange) = request.extensions().get::<Arc<Exchange>>() {
*exchange.route.lock().unwrap_or_else(|e| e.into_inner()) = Some(route.id.clone());
*exchange.cache_effective_enabled.lock().unwrap_or_else(|e| e.into_inner()) =
Some(cache_settings.active_for(route.cache));
*exchange.transform_effective_enabled.lock().unwrap_or_else(|e| e.into_inner()) =
*exchange
.cache_effective_enabled
.lock()
.unwrap_or_else(|e| e.into_inner()) = Some(cache_settings.active_for(route.cache));
*exchange
.transform_effective_enabled
.lock()
.unwrap_or_else(|e| e.into_inner()) =
Some(transform_settings.is_some_and(|settings| settings.enabled));
}
let policy_reason = if admin_control_plane {
@@ -1654,7 +1810,10 @@ async fn forward(
Ok(None) => public_suffix.clone(),
Ok(Some(rewritten)) => {
let Some(mut encoded) = encoded_origin_path(&rewritten) else {
return error(StatusCode::BAD_REQUEST, "RustProxy: invalid rewritten request target\n");
return error(
StatusCode::BAD_REQUEST,
"RustProxy: invalid rewritten request target\n",
);
};
if let Some(query) = request.uri().query() {
encoded.push('?');
@@ -1662,7 +1821,12 @@ async fn forward(
}
encoded
}
Err(_) => return error(StatusCode::BAD_REQUEST, "RustProxy: invalid rewritten request target\n"),
Err(_) => {
return error(
StatusCode::BAD_REQUEST,
"RustProxy: invalid rewritten request target\n",
)
}
};
let Some(uri) = upstream_uri(&backend, &origin_suffix) else {
return error(
@@ -1684,8 +1848,7 @@ async fn forward(
None
};
let mut h2_downstream = if handshake.as_ref().is_some_and(|handshake| handshake.h2())
&& (backend.protocol != crate::config::UpstreamProtocol::Http2
|| websocket_legacy_support)
&& (backend.protocol != crate::config::UpstreamProtocol::Http2 || websocket_legacy_support)
{
Some(std::mem::replace(request.body_mut(), Body::empty()))
} else {
@@ -1847,7 +2010,9 @@ async fn forward(
path: path.clone(),
scope: scope.to_owned(),
timeout: frontend
.and_then(|frontend| frontend.proxy_timeouts.response_header_timeout())
.and_then(|frontend| {
frontend.proxy_timeouts.response_header_timeout()
})
.unwrap_or(state.header_timeout),
local: local.0,
tls,
@@ -1867,10 +2032,7 @@ async fn forward(
});
}
}
if cache_settings.proactive_refresh_enabled
&& !hit.stale
&& hit.remaining_ttl > 0
{
if cache_settings.proactive_refresh_enabled && !hit.stale && hit.remaining_ttl > 0 {
schedule_proactive_refresh(
CacheRefreshContext {
state: state.clone(),
@@ -1884,7 +2046,9 @@ async fn forward(
path: path.clone(),
scope: scope.to_owned(),
timeout: frontend
.and_then(|frontend| frontend.proxy_timeouts.response_header_timeout())
.and_then(|frontend| {
frontend.proxy_timeouts.response_header_timeout()
})
.unwrap_or(state.header_timeout),
local: local.0,
tls,
@@ -1918,10 +2082,8 @@ async fn forward(
if hit.transformed {
state.transform_metrics.record_cache_hit(scope);
}
cache_settings.annotate_resident_age(
hit.response.headers_mut(),
hit.resident_age_seconds,
);
cache_settings
.annotate_resident_age(hit.response.headers_mut(), hit.resident_age_seconds);
hit.response.extensions_mut().insert(ResponseRulesApplied);
return hit.response;
}
@@ -1959,39 +2121,35 @@ async fn forward(
.unwrap_or_else(|e| e.into_inner()) = reason;
}
}
let serve_stale = |reason: &'static str, elapsed: Duration, origin_backend: &str| -> Option<Response> {
let cached = cached_revalidation.as_ref()?;
let mut hit = state.cache.stale_if_error_hit(cached)?;
if let crate::cache::RangeSelection::Single(range) = range_selection {
hit = hit.apply_range(range, if_range.as_ref());
}
cache_settings.annotate_reason(
hit.response.headers_mut(),
"HIT",
hit.hits,
0,
reason,
);
cache_settings.annotate_transformed(hit.response.headers_mut(), hit.transformed);
if hit.transformed {
state.transform_metrics.record_cache_hit(scope);
}
cache_settings.annotate_resident_age(
hit.response.headers_mut(),
hit.resident_age_seconds,
);
hit.response.extensions_mut().insert(ResponseRulesApplied);
hit.response.extensions_mut().insert(OriginTiming(elapsed));
hit.response
.extensions_mut()
.insert(OriginBackend(origin_backend.to_owned()));
state.cache.record(scope, &host, "HIT", reason);
if let Some(exchange) = &exchange {
exchange.cache_status.store(1, Ordering::Relaxed);
*exchange.cache_reason.lock().unwrap_or_else(|e| e.into_inner()) = reason;
}
Some(hit.response)
};
let serve_stale =
|reason: &'static str, elapsed: Duration, origin_backend: &str| -> Option<Response> {
let cached = cached_revalidation.as_ref()?;
let mut hit = state.cache.stale_if_error_hit(cached)?;
if let crate::cache::RangeSelection::Single(range) = range_selection {
hit = hit.apply_range(range, if_range.as_ref());
}
cache_settings.annotate_reason(hit.response.headers_mut(), "HIT", hit.hits, 0, reason);
cache_settings.annotate_transformed(hit.response.headers_mut(), hit.transformed);
if hit.transformed {
state.transform_metrics.record_cache_hit(scope);
}
cache_settings
.annotate_resident_age(hit.response.headers_mut(), hit.resident_age_seconds);
hit.response.extensions_mut().insert(ResponseRulesApplied);
hit.response.extensions_mut().insert(OriginTiming(elapsed));
hit.response
.extensions_mut()
.insert(OriginBackend(origin_backend.to_owned()));
state.cache.record(scope, &host, "HIT", reason);
if let Some(exchange) = &exchange {
exchange.cache_status.store(1, Ordering::Relaxed);
*exchange
.cache_reason
.lock()
.unwrap_or_else(|e| e.into_inner()) = reason;
}
Some(hit.response)
};
if only_cached {
let mut response = error(
StatusCode::GATEWAY_TIMEOUT,
@@ -2006,9 +2164,7 @@ async fn forward(
// A follower can wake after a different Vary representation was published; in
// that case it competes for the next flight instead of stampeding the origin.
let mut fill_leader = None;
if handshake.is_none()
&& matches!(range_selection, crate::cache::RangeSelection::None)
{
if handshake.is_none() && matches!(range_selection, crate::cache::RangeSelection::None) {
if let Some(ticket) = cache_ticket.as_ref() {
let mut collapse_rounds = 0u8;
loop {
@@ -2018,7 +2174,8 @@ async fn forward(
break;
}
crate::cache::FillClaim::Waited => {
if let Some(mut hit) = state.cache.get_tiered(ticket, cache_settings).await {
if let Some(mut hit) = state.cache.get_tiered(ticket, cache_settings).await
{
let conditional = crate::cache::conditional_not_modified(
&client_conditionals,
hit.response.headers(),
@@ -2045,7 +2202,9 @@ async fn forward(
path: path.clone(),
scope: scope.to_owned(),
timeout: frontend
.and_then(|frontend| frontend.proxy_timeouts.response_header_timeout())
.and_then(|frontend| {
frontend.proxy_timeouts.response_header_timeout()
})
.unwrap_or(state.header_timeout),
local: local.0,
tls,
@@ -2087,11 +2246,9 @@ async fn forward(
}
collapse_rounds = collapse_rounds.saturating_add(1);
if collapse_rounds >= 8 {
state.cache.record_reason(
scope,
&host,
"cache_collapse_variant_limit",
);
state
.cache
.record_reason(scope, &host, "cache_collapse_variant_limit");
break;
}
}
@@ -2100,13 +2257,17 @@ async fn forward(
cache_settings.collapse_timeout_fallback,
crate::cache::CollapseTimeoutFallback::StaleIfAvailable
) {
if let Some(stale) =
serve_stale("cache_collapse_timeout_stale", Duration::ZERO, &backend.id)
{
if let Some(stale) = serve_stale(
"cache_collapse_timeout_stale",
Duration::ZERO,
&backend.id,
) {
return stale;
}
}
state.cache.record_reason(scope, &host, "cache_collapse_timeout_origin");
state
.cache
.record_reason(scope, &host, "cache_collapse_timeout_origin");
break;
}
crate::cache::FillClaim::Disabled => break,
@@ -2250,7 +2411,9 @@ async fn forward(
&& handshake.is_none()
&& request_body_empty
&& retry_safe_method(&retry_method)
&& retry_policy.as_ref().is_some_and(|policy| policy.max_retries > 0);
&& retry_policy
.as_ref()
.is_some_and(|policy| policy.max_retries > 0);
let upstream_started = Instant::now();
let mut attempt_started = Instant::now();
let mut active_backend = backend.clone();
@@ -2269,7 +2432,11 @@ async fn forward(
};
let result = tokio::time::timeout(
response_header_timeout,
client.request(request_attempt.take().expect("upstream request attempt missing")),
client.request(
request_attempt
.take()
.expect("upstream request attempt missing"),
),
)
.await;
let attempt_elapsed = attempt_started.elapsed();
@@ -2292,12 +2459,7 @@ async fn forward(
break result;
};
let selection = match pool
.select_retry_wait(
context.client_ip,
&retry_headers,
tls,
&attempted_backends,
)
.select_retry_wait(context.client_ip, &retry_headers, tls, &attempted_backends)
.await
{
Ok(selection) => selection,
@@ -2472,8 +2634,7 @@ async fn forward(
};
if handshake.as_ref().is_some_and(|handshake| {
handshake.h3() || (handshake.h2() && !websocket_upstream_h2)
})
&& response.status().is_success()
}) && response.status().is_success()
&& response.status() != StatusCode::SWITCHING_PROTOCOLS
{
if let Some(exchange) = &exchange {
@@ -2535,8 +2696,8 @@ async fn forward(
if let Some(cookie) = active_affinity_cookie {
parts.headers.append("set-cookie", cookie);
}
let h3_tunnel = handshake.as_ref().is_some_and(|handshake| handshake.h3())
&& upgraded.is_some();
let h3_tunnel =
handshake.as_ref().is_some_and(|handshake| handshake.h3()) && upgraded.is_some();
let h2_bridge_tunnel = handshake.as_ref().is_some_and(|handshake| handshake.h2())
&& !websocket_upstream_h2
&& upgraded.is_some();
@@ -2589,15 +2750,13 @@ async fn forward(
parts.headers.remove("content-length");
parts.headers.remove("transfer-encoding");
parts.extensions.insert(crate::compression::Skip);
parts
.extensions
.insert(websocket::H3Tunnel::new(
upstream,
exchange,
active_backend.origin.clone(),
lease,
stop,
));
parts.extensions.insert(websocket::H3Tunnel::new(
upstream,
exchange,
active_backend.origin.clone(),
lease,
stop,
));
Response::from_parts(parts, Body::empty())
} else if h2_bridge_tunnel {
let Some(upstream) = upgraded.take() else {
@@ -2716,41 +2875,41 @@ async fn forward(
.insert(OriginBackend(active_backend.id.clone()));
if !range_requested && handshake.is_none() {
if let Some(settings) = transform_settings.filter(|settings| settings.enabled) {
let transform_started = Instant::now();
response = match crate::transform::apply(
response,
&public_suffix,
settings,
origin_no_transform,
)
.await
{
Ok(response) => {
state.transform_metrics.record_response(
scope,
&response,
transform_started.elapsed(),
);
response
}
Err(cause) => {
state
.transform_metrics
.record_error(scope, transform_started.elapsed());
state.cache.record_reason(scope, &host, "transform_error");
if let Some(exchange) = &exchange {
*exchange
.cache_reason
.lock()
.unwrap_or_else(|e| e.into_inner()) = "transform_error";
let transform_started = Instant::now();
response = match crate::transform::apply(
response,
&public_suffix,
settings,
origin_no_transform,
)
.await
{
Ok(response) => {
state.transform_metrics.record_response(
scope,
&response,
transform_started.elapsed(),
);
response
}
tracing::debug!(error = %cause, "static response transformation failed");
return error(
StatusCode::BAD_GATEWAY,
"RustProxy: upstream response body could not be transformed\n",
);
}
};
Err(cause) => {
state
.transform_metrics
.record_error(scope, transform_started.elapsed());
state.cache.record_reason(scope, &host, "transform_error");
if let Some(exchange) = &exchange {
*exchange
.cache_reason
.lock()
.unwrap_or_else(|e| e.into_inner()) = "transform_error";
}
tracing::debug!(error = %cause, "static response transformation failed");
return error(
StatusCode::BAD_GATEWAY,
"RustProxy: upstream response body could not be transformed\n",
);
}
};
}
}
@@ -2927,7 +3086,9 @@ async fn forward(
path: path.clone(),
scope: scope.to_owned(),
timeout: frontend
.and_then(|frontend| frontend.proxy_timeouts.response_header_timeout())
.and_then(|frontend| {
frontend.proxy_timeouts.response_header_timeout()
})
.unwrap_or(state.header_timeout),
local: local.0,
tls,
@@ -2959,7 +3120,10 @@ async fn forward(
let detail = upstream_failure_detail(&cause, &active_backend, elapsed, connect_failure);
if let Some(exchange) = &exchange {
exchange.websocket_error("upstream_request_failed", "upstream_http_error");
*exchange.upstream_error.lock().unwrap_or_else(|e| e.into_inner()) = Some(detail.clone());
*exchange
.upstream_error
.lock()
.unwrap_or_else(|e| e.into_inner()) = Some(detail.clone());
}
if let Some(lease) = &active_lease {
lease.error_detail(
@@ -2994,7 +3158,9 @@ async fn forward(
};
let stale_class = classify_stale_error(&cause);
if cache_settings.allows_stale_error_class(stale_class) {
if let Some(stale) = serve_stale("cache_stale_if_error", elapsed, &active_backend.id) {
if let Some(stale) =
serve_stale("cache_stale_if_error", elapsed, &active_backend.id)
{
return stale;
}
}
@@ -3022,7 +3188,10 @@ async fn forward(
}
if let Some(exchange) = &exchange {
exchange.websocket_error("upstream_timeout", "header_timeout");
*exchange.upstream_error.lock().unwrap_or_else(|e| e.into_inner()) = Some("response_header_timeout".into());
*exchange
.upstream_error
.lock()
.unwrap_or_else(|e| e.into_inner()) = Some("response_header_timeout".into());
}
if let Some(lease) = &active_lease {
lease.error_detail(
@@ -3031,12 +3200,12 @@ async fn forward(
final_attempt_elapsed,
);
}
if cache_settings
.allows_stale_error_class(crate::cache::StaleErrorClass::Timeout)
{
if let Some(stale) =
serve_stale("cache_stale_if_error", upstream_started.elapsed(), &active_backend.id)
{
if cache_settings.allows_stale_error_class(crate::cache::StaleErrorClass::Timeout) {
if let Some(stale) = serve_stale(
"cache_stale_if_error",
upstream_started.elapsed(),
&active_backend.id,
) {
return stale;
}
}
@@ -3078,7 +3247,7 @@ mod authority_tests {
let snapshot = crate::config::compile(config, 1, &validation_policy()).unwrap();
let route = snapshot.select("localhost", "/").unwrap();
assert_eq!(
upstream_request_version(&route.backend, false),
upstream_request_version(route.backend.as_ref().unwrap(), false),
Version::HTTP_2
);
}
@@ -3107,9 +3276,8 @@ mod authority_tests {
#[tokio::test]
async fn h3_delete_unknown_nonempty_body_is_replayed_not_buffered_away() {
use bytes::Bytes;
let stream = futures_util::stream::iter([Ok::<_, std::io::Error>(
Bytes::from_static(b"payload"),
)]);
let stream =
futures_util::stream::iter([Ok::<_, std::io::Error>(Bytes::from_static(b"payload"))]);
let mut request = Request::builder()
.method(Method::DELETE)
.version(Version::HTTP_3)
@@ -3221,7 +3389,10 @@ mod authority_tests {
for name in ["etag", "x-cache", "x-cache-reason", "cache-status"] {
assert!(!response.headers().contains_key(name), "{name}");
}
assert!(response.extensions().get::<LocalGeneratedResponse>().is_some());
assert!(response
.extensions()
.get::<LocalGeneratedResponse>()
.is_some());
}
#[test]
fn http2_uses_authority_and_rejects_conflicting_host() {
@@ -3238,11 +3409,9 @@ mod authority_tests {
}
#[test]
fn legacy_websocket_origin_matches_upstream_scheme_and_host() {
let origin = legacy_websocket_origin(
"https://ilo.internal:443",
&"10.0.0.5:443".parse().unwrap(),
)
.unwrap();
let origin =
legacy_websocket_origin("https://ilo.internal:443", &"10.0.0.5:443".parse().unwrap())
.unwrap();
assert_eq!(origin, "https://10.0.0.5:443");
assert!(legacy_websocket_origin("not-an-origin", &"10.0.0.5".parse().unwrap()).is_none());
}
@@ -3311,18 +3480,39 @@ mod authority_tests {
#[test]
fn backend_diagnostics_are_opt_in_and_strip_spoofed_values() {
let mut response = Response::new(Body::empty());
response.headers_mut().insert("x-rust-proxy-backend", HeaderValue::from_static("spoofed"));
response.headers_mut().insert("x-rust-proxy-backend-time", HeaderValue::from_static("legacy-spoof"));
response.headers_mut().insert("x-rustproxy-backend-time", HeaderValue::from_static("999ms"));
response
.headers_mut()
.insert("x-rust-proxy-backend", HeaderValue::from_static("spoofed"));
response.headers_mut().insert(
"x-rust-proxy-backend-time",
HeaderValue::from_static("legacy-spoof"),
);
response.headers_mut().insert(
"x-rustproxy-backend-time",
HeaderValue::from_static("999ms"),
);
apply_backend_diagnostic_values(&mut response, false, false);
assert!(response.headers().get("x-rust-proxy-backend").is_none());
assert!(response.headers().get("x-rust-proxy-backend-time").is_none());
assert!(response
.headers()
.get("x-rust-proxy-backend-time")
.is_none());
assert!(response.headers().get("x-rustproxy-backend-time").is_none());
response.extensions_mut().insert(OriginBackend("origin-a".into()));
response.extensions_mut().insert(OriginTiming(Duration::from_millis(12)));
response
.extensions_mut()
.insert(OriginBackend("origin-a".into()));
response
.extensions_mut()
.insert(OriginTiming(Duration::from_millis(12)));
apply_backend_diagnostic_values(&mut response, true, true);
assert_eq!(response.headers().get("x-rust-proxy-backend").unwrap(), "origin-a");
assert_eq!(response.headers().get("x-rustproxy-backend-time").unwrap(), "12.00ms");
assert_eq!(
response.headers().get("x-rust-proxy-backend").unwrap(),
"origin-a"
);
assert_eq!(
response.headers().get("x-rustproxy-backend-time").unwrap(),
"12.00ms"
);
}
}
+59 -21
View File
@@ -19,8 +19,8 @@ use http_body_util::BodyExt;
use hyper::upgrade::{OnUpgrade, Upgraded};
use hyper_util::rt::TokioIo;
use rand::{rngs::OsRng, RngCore};
use sha1::{Digest, Sha1};
use serde::Serialize;
use sha1::{Digest, Sha1};
use std::{
collections::BTreeMap,
future::Future,
@@ -288,8 +288,7 @@ pub fn validate_response(
"invalid upstream upgrade"
);
ensure!(
!headers.contains_key("transfer-encoding")
&& !headers.contains_key("content-length"),
!headers.contains_key("transfer-encoding") && !headers.contains_key("content-length"),
"101 response must not include body framing"
);
for value in headers.get_all("connection").iter() {
@@ -829,7 +828,11 @@ pub fn spawn_h2_idle_watch(state: Arc<App>, session: &Arc<TunnelSession>) {
return;
};
if session.inactive_for() >= idle {
session._active.exchange.failed.store(true, Ordering::Relaxed);
session
._active
.exchange
.failed
.store(true, Ordering::Relaxed);
session
._active
.exchange
@@ -858,7 +861,9 @@ pub fn bridge_h2_to_h1(
lease: Option<Arc<ConnectionLease>>,
stop: Option<ServerShutdown>,
) -> Body {
let stop = stop.map(|value| value.0).unwrap_or_else(|| state.shutdown.clone());
let stop = stop
.map(|value| value.0)
.unwrap_or_else(|| state.shutdown.clone());
let session = state.tunnel_registry.register(
exchange.clone(),
upstream_label,
@@ -919,7 +924,10 @@ pub fn bridge_h2_to_h1(
Ok(read) => {
buffer.truncate(read);
record_session_bytes(&exchange, &session, read as u64, false);
Some((Ok::<Bytes, io::Error>(Bytes::from(buffer)), (upstream_read, session, exchange)))
Some((
Ok::<Bytes, io::Error>(Bytes::from(buffer)),
(upstream_read, session, exchange),
))
}
Err(error) => Some((Err(error), (upstream_read, session, exchange))),
}
@@ -1061,21 +1069,32 @@ pub async fn run_h3(
let stop = stop.map(|v| v.0).unwrap_or_else(|| state.shutdown.clone());
let started = Instant::now();
let activity = Arc::new(AtomicU64::new(0));
let session = state.tunnel_registry.register(
exchange.clone(),
upstream_label,
"h3-rfc9220",
stop,
lease,
);
let session =
state
.tunnel_registry
.register(exchange.clone(), upstream_label, "h3-rfc9220", stop, lease);
let stop = session.cancellation();
let upstream = TokioIo::new(upstream);
let (mut upstream_read, mut upstream_write) = tokio::io::split(upstream);
let result = {
let copying = async {
tokio::try_join!(
h3_client_to_backend(receive, &mut upstream_write, &exchange, &activity, started, &session),
backend_to_h3(&mut upstream_read, send, &exchange, &activity, started, &session)
h3_client_to_backend(
receive,
&mut upstream_write,
&exchange,
&activity,
started,
&session
),
backend_to_h3(
&mut upstream_read,
send,
&exchange,
&activity,
started,
&session
)
)?;
Ok::<(), io::Error>(())
};
@@ -1312,22 +1331,41 @@ mod tests {
#[test]
fn rfc8441_h2_response_rejects_h1_framing_fields() {
let handshake = validate_h2(&h2_request()).unwrap();
for (name, value) in [("connection", "close"), ("upgrade", "websocket"), ("content-length", "0")] {
for (name, value) in [
("connection", "close"),
("upgrade", "websocket"),
("content-length", "0"),
] {
let mut headers = HeaderMap::new();
headers.insert(
http::header::HeaderName::from_bytes(name.as_bytes()).unwrap(),
value.parse().unwrap(),
);
assert!(validate_h2_response(&headers, &handshake).is_err(), "accepted forbidden response {name}");
assert!(
validate_h2_response(&headers, &handshake).is_err(),
"accepted forbidden response {name}"
);
}
}
#[test]
fn tunnel_transport_metrics_classify_frontend_protocol() {
assert!(matches!(TunnelTransport::from_protocol("h1-upgrade"), TunnelTransport::H1));
assert!(matches!(TunnelTransport::from_protocol("h2-rfc8441-h1-upstream"), TunnelTransport::H2));
assert!(matches!(TunnelTransport::from_protocol("h2-rfc8441-h2-upstream"), TunnelTransport::H2));
assert!(matches!(TunnelTransport::from_protocol("h3-rfc9220"), TunnelTransport::H3));
assert!(matches!(
TunnelTransport::from_protocol("h1-upgrade"),
TunnelTransport::H1
));
assert!(matches!(
TunnelTransport::from_protocol("h2-rfc8441-h1-upstream"),
TunnelTransport::H2
));
assert!(matches!(
TunnelTransport::from_protocol("h2-rfc8441-h2-upstream"),
TunnelTransport::H2
));
assert!(matches!(
TunnelTransport::from_protocol("h3-rfc9220"),
TunnelTransport::H3
));
}
#[test]
+3
View File
@@ -69,6 +69,9 @@ impl CountryAccess {
);
Ok(())
}
pub fn needs_geoip(&self) -> bool {
self.mode != CountryMode::Off
}
pub fn allows(&self, country: Option<&str>) -> bool {
match self.mode {
CountryMode::Off => true,
+33 -3
View File
@@ -40,6 +40,7 @@ pub struct App {
pub certificate_slots: Arc<Semaphore>,
pub metrics: Arc<Metrics>,
pub transform_metrics: Arc<crate::transform::Metrics>,
pub traffic: Arc<crate::traffic::Analytics>,
pub cache: Arc<crate::cache::Cache>,
pub geoip: Arc<crate::geoip::Database>,
pub rate_limiter: Arc<crate::rate_limit::Limiter>,
@@ -271,6 +272,7 @@ impl App {
}
}
let logs = Logs::start(log_settings, shutdown.clone());
let traffic = crate::traffic::Analytics::start(shutdown.clone());
let app = Arc::new(Self {
basic_workers: Arc::new(Semaphore::new(2)),
basic_limits: Default::default(),
@@ -298,6 +300,7 @@ impl App {
certificate_slots: Arc::new(Semaphore::new(2)),
metrics: Arc::new(Metrics::default()),
transform_metrics: Arc::new(crate::transform::Metrics::default()),
traffic,
logs,
software_update: Arc::new(crate::update_check::SharedStatus::default()),
shutdown,
@@ -323,6 +326,23 @@ impl App {
Arc::downgrade(&app),
app.shutdown.clone(),
));
// High request rates create short-lived allocations in Hyper/Tokio and
// cache fill paths. glibc may retain already-free arenas as RSS. Trim
// them periodically without evicting live cache objects or connections.
let trim_shutdown = app.shutdown.clone();
app.tunnels.spawn(async move {
let mut tick = tokio::time::interval(Duration::from_secs(60));
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
tick.tick().await;
loop {
tokio::select! {
_ = trim_shutdown.cancelled() => break,
_ = tick.tick() => {
let _ = tokio::task::spawn_blocking(crate::diagnostics::trim_allocator).await;
}
}
}
});
app
}
pub fn start_update_check(self: &Arc<Self>, config: crate::update_check::Config) {
@@ -465,9 +485,15 @@ impl App {
/// resolver is rebuilt as one validated snapshot, so the requested pair is
/// re-read atomically with the other configured pairs while desired
/// configuration and its revision remain unchanged.
pub async fn reload_certificate(self: &Arc<Self>, certificate_id: &str) -> Result<u64, ApiError> {
pub async fn reload_certificate(
self: &Arc<Self>,
certificate_id: &str,
) -> Result<u64, ApiError> {
if !crate::config::valid_id(certificate_id) {
return Err(ApiError(StatusCode::NOT_FOUND, "certificate not found".into()));
return Err(ApiError(
StatusCode::NOT_FOUND,
"certificate not found".into(),
));
}
self.reload_certificate_snapshot(Some(certificate_id)).await
}
@@ -526,7 +552,11 @@ impl App {
.map_err(|error| ApiError(StatusCode::UNPROCESSABLE_ENTITY, format!("{error:#}")))?;
self.config.store(Arc::new(prepared));
if let Some(certificate_id) = certificate_id {
tracing::info!(revision, certificate_id, "configured certificate reloaded from disk");
tracing::info!(
revision,
certificate_id,
"configured certificate reloaded from disk"
);
} else {
tracing::info!(revision, "configured certificates reloaded from disk");
}
+50 -11
View File
@@ -59,7 +59,10 @@ fn read_key(path: &Path) -> Result<Zeroizing<[u8; KEY_BYTES]>> {
metadata.is_file() && !metadata.file_type().is_symlink(),
"secret store master key must be a regular file"
);
ensure!(metadata.len() == KEY_BYTES as u64, "invalid secret store master key size");
ensure!(
metadata.len() == KEY_BYTES as u64,
"invalid secret store master key size"
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
@@ -79,7 +82,10 @@ fn read_key(path: &Path) -> Result<Zeroizing<[u8; KEY_BYTES]>> {
let mut file = options.open(path)?;
file.read_exact(bytes.as_mut())?;
let mut extra = [0u8; 1];
ensure!(file.read(&mut extra)? == 0, "invalid secret store master key size");
ensure!(
file.read(&mut extra)? == 0,
"invalid secret store master key size"
);
Ok(bytes)
}
@@ -158,7 +164,10 @@ pub async fn put(
metadata: serde_json::Value,
) -> Result<()> {
validate_identity(secret_id, provider)?;
ensure!(!plaintext.is_empty() && plaintext.len() <= MAX_SECRET_BYTES, "invalid secret size");
ensure!(
!plaintext.is_empty() && plaintext.len() <= MAX_SECRET_BYTES,
"invalid secret size"
);
let metadata = serde_json::to_string(&metadata)?;
ensure!(metadata.len() <= 4096, "secret metadata is too large");
initialize(data_dir)?;
@@ -212,12 +221,16 @@ pub async fn get(
}
validate_identity(secret_id, &provider)?;
let version: i64 = row.try_get("encryption_version")?;
ensure!(version == ENCRYPTION_VERSION, "unsupported secret encryption version");
ensure!(
version == ENCRYPTION_VERSION,
"unsupported secret encryption version"
);
let nonce: Vec<u8> = row.try_get("nonce")?;
ensure!(nonce.len() == NONCE_BYTES, "invalid secret nonce");
let mut ciphertext = Zeroizing::new(row.try_get::<Vec<u8>, _>("ciphertext")?);
ensure!(
ciphertext.len() >= AES_256_GCM.tag_len() && ciphertext.len() <= MAX_SECRET_BYTES + AES_256_GCM.tag_len(),
ciphertext.len() >= AES_256_GCM.tag_len()
&& ciphertext.len() <= MAX_SECRET_BYTES + AES_256_GCM.tag_len(),
"invalid encrypted secret size"
);
let mut nonce_bytes = [0u8; NONCE_BYTES];
@@ -287,7 +300,10 @@ pub async fn migrate_acme_credentials(
return Ok(0);
}
let root_meta = fs::symlink_metadata(&root)?;
ensure!(root_meta.is_dir() && !root_meta.file_type().is_symlink(), "invalid ACME directory");
ensure!(
root_meta.is_dir() && !root_meta.file_type().is_symlink(),
"invalid ACME directory"
);
let mut migrated = 0usize;
for entry in fs::read_dir(&root)? {
let entry = entry?;
@@ -301,7 +317,10 @@ pub async fn migrate_acme_credentials(
continue;
}
let profile: serde_json::Value = serde_json::from_slice(&fs::read(&profile_path)?)?;
let provider = profile.get("provider").and_then(serde_json::Value::as_str).unwrap_or("");
let provider = profile
.get("provider")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
let certificate_id = profile
.get("certificate_id")
.and_then(serde_json::Value::as_str)
@@ -341,7 +360,8 @@ pub async fn migrate_acme_credentials(
)
.await?;
plaintext.zeroize();
fs::remove_file(&credential_path).context("legacy ACME credential was encrypted but could not be removed")?;
fs::remove_file(&credential_path)
.context("legacy ACME credential was encrypted but could not be removed")?;
migrated += 1;
}
Ok(migrated)
@@ -413,7 +433,10 @@ mod tests {
.unwrap();
assert_eq!(value.as_slice(), secret);
drop(value);
assert_eq!(read_key(&master_key_path(dir.path())).unwrap().len(), KEY_BYTES);
assert_eq!(
read_key(&master_key_path(dir.path())).unwrap().len(),
KEY_BYTES
);
}
#[tokio::test]
@@ -425,11 +448,27 @@ mod tests {
let store = crate::db::Store::open(&database, &crate::config::Config::default())
.await
.unwrap();
put(&store, dir.path(), "acme/site/cloudflare/credential", "cloudflare", b"secret-value", serde_json::json!({})).await.unwrap();
put(
&store,
dir.path(),
"acme/site/cloudflare/credential",
"cloudflare",
b"secret-value",
serde_json::json!({}),
)
.await
.unwrap();
sqlx::query("UPDATE secret_store SET ciphertext = zeroblob(length(ciphertext))")
.execute(&store.pool)
.await
.unwrap();
assert!(get(&store, dir.path(), "acme/site/cloudflare/credential", Some("cloudflare")).await.is_err());
assert!(get(
&store,
dir.path(),
"acme/site/cloudflare/credential",
Some("cloudflare")
)
.await
.is_err());
}
}
+118 -39
View File
@@ -6,6 +6,7 @@ use anyhow::{Context, Result};
use axum::{body::Body, Router};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use bytes::{Buf, Bytes, BytesMut};
use http_body::Body as HttpBody;
use hyper::{
body::Incoming,
server::conn::{http1, http2},
@@ -13,7 +14,6 @@ use hyper::{
Request, Response,
};
use hyper_util::rt::{TokioExecutor, TokioIo, TokioTimer};
use http_body::Body as HttpBody;
use std::{
convert::Infallible,
io,
@@ -35,7 +35,6 @@ use tokio::{
use tokio_util::sync::CancellationToken;
use tower::ServiceExt;
const HTTP2_PRIOR_KNOWLEDGE_PREFACE: &[u8; 24] = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n";
const H2_FRAME_HEADER_LEN: usize = 9;
@@ -48,7 +47,10 @@ fn header_has_token(headers: &http::HeaderMap, name: &str, token: &str) -> bool
.any(|value| value.trim().eq_ignore_ascii_case(token))
}
fn single_header<'a>(headers: &'a http::HeaderMap, name: &str) -> std::result::Result<&'a str, &'static str> {
fn single_header<'a>(
headers: &'a http::HeaderMap,
name: &str,
) -> std::result::Result<&'a str, &'static str> {
let mut values = headers.get_all(name).iter();
let value = values.next().ok_or("missing required h2c header")?;
if values.next().is_some() {
@@ -100,9 +102,15 @@ fn h2_headers_frame<B>(request: &Request<B>) -> std::result::Result<Bytes, &'sta
if request.version() != http::Version::HTTP_11 {
return Err("h2c upgrade requires HTTP/1.1");
}
if !header_has_token(request.headers(), http::header::CONNECTION.as_str(), "upgrade")
|| !header_has_token(request.headers(), http::header::CONNECTION.as_str(), "http2-settings")
{
if !header_has_token(
request.headers(),
http::header::CONNECTION.as_str(),
"upgrade",
) || !header_has_token(
request.headers(),
http::header::CONNECTION.as_str(),
"http2-settings",
) {
return Err("Connection must nominate Upgrade and HTTP2-Settings");
}
if !single_header(request.headers(), http::header::UPGRADE.as_str())?
@@ -117,10 +125,19 @@ fn h2_headers_frame<B>(request: &Request<B>) -> std::result::Result<Bytes, &'sta
if settings.len() > 36 || settings.len() % 6 != 0 {
return Err("invalid HTTP2-Settings payload");
}
if request.headers().contains_key(http::header::TRANSFER_ENCODING) {
if request
.headers()
.contains_key(http::header::TRANSFER_ENCODING)
{
return Err("h2c upgrade request body is not supported");
}
if request.headers().get_all(http::header::CONTENT_LENGTH).iter().count() > 1 {
if request
.headers()
.get_all(http::header::CONTENT_LENGTH)
.iter()
.count()
> 1
{
return Err("duplicate Content-Length on h2c upgrade");
}
if request
@@ -135,7 +152,12 @@ fn h2_headers_frame<B>(request: &Request<B>) -> std::result::Result<Bytes, &'sta
.uri()
.authority()
.map(|value| value.as_str())
.or_else(|| request.headers().get(http::header::HOST).and_then(|value| value.to_str().ok()))
.or_else(|| {
request
.headers()
.get(http::header::HOST)
.and_then(|value| value.to_str().ok())
})
.ok_or("h2c upgrade requires Host/:authority")?;
let path = request
.uri()
@@ -170,7 +192,9 @@ fn h2_headers_frame<B>(request: &Request<B>) -> std::result::Result<Bytes, &'sta
if name == "te" && !value.as_bytes().eq_ignore_ascii_case(b"trailers") {
continue;
}
let value = value.to_str().map_err(|_| "non-text header in h2c bootstrap")?;
let value = value
.to_str()
.map_err(|_| "non-text header in h2c bootstrap")?;
hpack_literal(&mut block, name, value);
}
if block.len() > 32_768 {
@@ -218,15 +242,21 @@ impl<I> H2cUpgradeIo<I> {
}
fn try_finish_bootstrap(&mut self) -> io::Result<()> {
if self.bootstrap_done || self.bootstrap.len() < HTTP2_PRIOR_KNOWLEDGE_PREFACE.len() + H2_FRAME_HEADER_LEN {
if self.bootstrap_done
|| self.bootstrap.len() < HTTP2_PRIOR_KNOWLEDGE_PREFACE.len() + H2_FRAME_HEADER_LEN
{
return Ok(());
}
if &self.bootstrap[..HTTP2_PRIOR_KNOWLEDGE_PREFACE.len()] != HTTP2_PRIOR_KNOWLEDGE_PREFACE {
return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid HTTP/2 client preface after h2c upgrade"));
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"invalid HTTP/2 client preface after h2c upgrade",
));
}
let offset = HTTP2_PRIOR_KNOWLEDGE_PREFACE.len();
let header = &self.bootstrap[offset..offset + H2_FRAME_HEADER_LEN];
let length = ((header[0] as usize) << 16) | ((header[1] as usize) << 8) | header[2] as usize;
let length =
((header[0] as usize) << 16) | ((header[1] as usize) << 8) | header[2] as usize;
if length > 16_384
|| header[3] != 0x4
|| header[4] != 0
@@ -235,7 +265,10 @@ impl<I> H2cUpgradeIo<I> {
|| header[7] != 0
|| header[8] != 0
{
return Err(io::Error::new(io::ErrorKind::InvalidData, "h2c client preface must begin with a non-ACK SETTINGS frame"));
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"h2c client preface must begin with a non-ACK SETTINGS frame",
));
}
let end = offset + H2_FRAME_HEADER_LEN + length;
if self.bootstrap.len() < end {
@@ -299,7 +332,11 @@ impl<I: AsyncRead + Unpin> AsyncRead for H2cUpgradeIo<I> {
}
impl<I: AsyncWrite + Unpin> AsyncWrite for H2cUpgradeIo<I> {
fn poll_write(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>, buf: &[u8]) -> Poll<io::Result<usize>> {
fn poll_write(
mut self: Pin<&mut Self>,
cx: &mut TaskContext<'_>,
buf: &[u8],
) -> Poll<io::Result<usize>> {
Pin::new(&mut self.inner).poll_write(cx, buf)
}
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll<io::Result<()>> {
@@ -994,11 +1031,15 @@ async fn connection<I>(
match on_upgrade.await {
Ok(upgraded) => {
if let Some(state) = &upgrade_state_for_task {
state.metrics.http2_connections.fetch_add(1, Ordering::Relaxed);
state
.metrics
.http2_connections
.fetch_add(1, Ordering::Relaxed);
}
let extended_connect = upgrade_state_for_task.as_ref().is_some_and(|state| {
state.config.load().websocket_rfc8441_on(local, false)
});
let extended_connect =
upgrade_state_for_task.as_ref().is_some_and(|state| {
state.config.load().websocket_rfc8441_on(local, false)
});
let io = H2cUpgradeIo::new(TokioIo::new(upgraded), injected_headers);
http2_connection(
io,
@@ -1156,8 +1197,14 @@ async fn http2_connection<I>(
};
match tokio::time::timeout(drain, &mut conn).await {
Ok(Ok(())) => tracing::debug!(?cause, "HTTP/2 graceful shutdown drained"),
Ok(Err(error)) => tracing::debug!(?cause, %error, "HTTP/2 graceful shutdown ended with protocol error"),
Err(_) => tracing::debug!(?cause, drain_seconds = drain.as_secs_f64(), "HTTP/2 drain timeout reached; closing transport"),
Ok(Err(error)) => {
tracing::debug!(?cause, %error, "HTTP/2 graceful shutdown ended with protocol error")
}
Err(_) => tracing::debug!(
?cause,
drain_seconds = drain.as_secs_f64(),
"HTTP/2 drain timeout reached; closing transport"
),
}
}
@@ -1219,13 +1266,18 @@ mod h2c_tests {
) -> io::Result<(u8, u8, u32, Vec<u8>)> {
let mut header = [0u8; 9];
stream.read_exact(&mut header).await?;
let length = ((header[0] as usize) << 16) | ((header[1] as usize) << 8) | header[2] as usize;
let length =
((header[0] as usize) << 16) | ((header[1] as usize) << 8) | header[2] as usize;
if length > 64 * 1024 {
return Err(io::Error::new(io::ErrorKind::InvalidData, "unexpectedly large HTTP/2 test frame"));
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"unexpectedly large HTTP/2 test frame",
));
}
let mut payload = vec![0u8; length];
stream.read_exact(&mut payload).await?;
let stream_id = u32::from_be_bytes([header[5], header[6], header[7], header[8]]) & 0x7fff_ffff;
let stream_id =
u32::from_be_bytes([header[5], header[6], header[7], header[8]]) & 0x7fff_ffff;
Ok((header[3], header[4], stream_id, payload))
}
@@ -1236,7 +1288,11 @@ mod h2c_tests {
async fn start_direct_h2(
router: Router,
settings: Http2Connections,
) -> (tokio::net::TcpStream, CancellationToken, tokio::task::JoinHandle<()>) {
) -> (
tokio::net::TcpStream,
CancellationToken,
tokio::task::JoinHandle<()>,
) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let stop = CancellationToken::new();
@@ -1264,7 +1320,10 @@ mod h2c_tests {
.await;
});
let mut client = tokio::net::TcpStream::connect(address).await.unwrap();
client.write_all(HTTP2_PRIOR_KNOWLEDGE_PREFACE).await.unwrap();
client
.write_all(HTTP2_PRIOR_KNOWLEDGE_PREFACE)
.await
.unwrap();
client
.write_all(&[0, 0, 0, 0x4, 0, 0, 0, 0, 0])
.await
@@ -1307,7 +1366,10 @@ mod h2c_tests {
assert!(response.starts_with("HTTP/1.1 101"), "{response}");
assert!(response.to_ascii_lowercase().contains("upgrade: h2c"));
client.write_all(HTTP2_PRIOR_KNOWLEDGE_PREFACE).await.unwrap();
client
.write_all(HTTP2_PRIOR_KNOWLEDGE_PREFACE)
.await
.unwrap();
// Client connection preface must begin with a non-ACK SETTINGS frame.
client
.write_all(&[0, 0, 0, 0x4, 0, 0, 0, 0, 0])
@@ -1317,12 +1379,10 @@ mod h2c_tests {
let mut saw_stream_one_headers = false;
let mut body = Vec::new();
for _ in 0..16 {
let (kind, flags, stream_id, payload) = tokio::time::timeout(
Duration::from_secs(2),
read_h2_frame(&mut client),
)
.await
.expect("HTTP/2 response frame timeout");
let (kind, flags, stream_id, payload) =
tokio::time::timeout(Duration::from_secs(2), read_h2_frame(&mut client))
.await
.expect("HTTP/2 response frame timeout");
if kind == 0x1 && stream_id == 1 {
saw_stream_one_headers = true;
}
@@ -1414,7 +1474,10 @@ mod h2c_tests {
.header("http2-settings", "")
.body(())
.unwrap();
client.write_all(&h2_headers_frame(&upgrade).unwrap()).await.unwrap();
client
.write_all(&h2_headers_frame(&upgrade).unwrap())
.await
.unwrap();
let saw_goaway_and_close = tokio::time::timeout(Duration::from_secs(4), async {
let mut saw_goaway = false;
@@ -1422,7 +1485,12 @@ mod h2c_tests {
match try_read_h2_frame(&mut client).await {
Ok((0x7, _, 0, _)) => saw_goaway = true,
Ok(_) => {}
Err(error) if matches!(error.kind(), io::ErrorKind::UnexpectedEof | io::ErrorKind::ConnectionReset) => {
Err(error)
if matches!(
error.kind(),
io::ErrorKind::UnexpectedEof | io::ErrorKind::ConnectionReset
) =>
{
return saw_goaway;
}
Err(_) => return saw_goaway,
@@ -1431,7 +1499,10 @@ mod h2c_tests {
})
.await
.expect("lifetime/drain shutdown exceeded its bound");
assert!(saw_goaway_and_close, "lifetime shutdown must send GOAWAY before drain timeout closes TCP");
assert!(
saw_goaway_and_close,
"lifetime shutdown must send GOAWAY before drain timeout closes TCP"
);
server.await.unwrap();
drop(blocker);
}
@@ -1466,7 +1537,10 @@ mod h2c_tests {
.header("http2-settings", "")
.body(())
.unwrap();
client.write_all(&h2_headers_frame(&request).unwrap()).await.unwrap();
client
.write_all(&h2_headers_frame(&request).unwrap())
.await
.unwrap();
let release = blocker.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(1_250)).await;
@@ -1481,7 +1555,9 @@ mod h2c_tests {
Ok((0x7, _, 0, _)) => saw_goaway = true,
Ok((kind, flags, 1, _)) if (kind == 0x0 || kind == 0x1) && flags & 0x1 != 0 => {
saw_end_stream = true;
if saw_goaway { return (true, true); }
if saw_goaway {
return (true, true);
}
}
Ok(_) => {}
Err(_) => return (saw_goaway, saw_end_stream),
@@ -1491,7 +1567,10 @@ mod h2c_tests {
.await
.expect("active stream did not drain within the configured bound");
assert!(saw_goaway, "lifetime shutdown must send GOAWAY");
assert!(saw_end_stream, "an accepted stream must be allowed to finish during drain");
assert!(
saw_end_stream,
"an accepted stream must be allowed to finish during drain"
);
server.await.unwrap();
}
+22 -4
View File
@@ -442,11 +442,29 @@ mod tests {
};
configured.validate().unwrap();
assert_eq!(configured.idle_timeout(), Duration::from_secs(45));
assert_eq!(configured.max_connection_lifetime(), Some(Duration::from_secs(3_600)));
assert_eq!(
configured.max_connection_lifetime(),
Some(Duration::from_secs(3_600))
);
assert_eq!(configured.drain_timeout(), Duration::from_secs(20));
assert!(Http2Connections { idle_timeout_seconds: 0, ..defaults }.validate().is_err());
assert!(Http2Connections { max_connection_lifetime_seconds: 2_592_001, ..defaults }.validate().is_err());
assert!(Http2Connections { drain_timeout_seconds: 0, ..defaults }.validate().is_err());
assert!(Http2Connections {
idle_timeout_seconds: 0,
..defaults
}
.validate()
.is_err());
assert!(Http2Connections {
max_connection_lifetime_seconds: 2_592_001,
..defaults
}
.validate()
.is_err());
assert!(Http2Connections {
drain_timeout_seconds: 0,
..defaults
}
.validate()
.is_err());
}
#[test]
fn server_policy_and_injection() {
+5 -10
View File
@@ -600,11 +600,7 @@ fn x509_time(tag: u8, raw: &[u8]) -> Option<(String, i64)> {
cursor += 2;
let second = x509_decimal(&raw[cursor..cursor + 2])?;
cursor += 2;
if day == 0
|| day > x509_days_in_month(year, month)?
|| hour > 23
|| minute > 59
|| second > 60
if day == 0 || day > x509_days_in_month(year, month)? || hour > 23 || minute > 59 || second > 60
{
return None;
}
@@ -622,8 +618,8 @@ fn x509_time(tag: u8, raw: &[u8]) -> Option<(String, i64)> {
if zone_hour > 23 || zone_minute > 59 {
return None;
}
let seconds = i64::from(zone_hour * 3600 + zone_minute * 60)
* if sign == b'+' { 1 } else { -1 };
let seconds =
i64::from(zone_hour * 3600 + zone_minute * 60) * if sign == b'+' { 1 } else { -1 };
(
seconds,
format!(
@@ -640,9 +636,8 @@ fn x509_time(tag: u8, raw: &[u8]) -> Option<(String, i64)> {
.checked_mul(86_400)?
.checked_add(i64::from(hour * 3600 + minute * 60 + second.min(59)))?
.checked_sub(offset_seconds)?;
let display = format!(
"{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}{suffix}"
);
let display =
format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}{suffix}");
Some((display, unix))
}
+343
View File
@@ -0,0 +1,343 @@
//! Bounded, asynchronous per-vhost traffic analytics.
//!
//! The request path only uses `try_send`; aggregation runs on one background
//! worker. High-cardinality dimensions use bounded Space-Saving tables so a
//! hostile path/User-Agent stream cannot grow process memory without bound.
use serde::Serialize;
use std::{
collections::{BTreeMap, HashMap},
sync::{
atomic::{AtomicU64, Ordering},
Arc, Mutex,
},
};
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
const QUEUE_CAPACITY: usize = 2048;
const TOP_CAPACITY: usize = 32;
const MAX_VHOSTS: usize = 64;
const MAX_LABEL_BYTES: usize = 256;
#[derive(Debug, Clone)]
pub struct Sample {
pub vhost: String,
pub route: Option<String>,
pub method: String,
pub path: String,
pub client_ip: Option<String>,
pub status: u16,
pub duration_ms: u64,
pub request_bytes: u64,
pub response_bytes: u64,
pub cache: &'static str,
pub user_agent: Option<String>,
pub country: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct TopEntry {
pub value: String,
pub count: u64,
}
#[derive(Debug, Clone, Default)]
struct TopTable {
values: HashMap<String, u64>,
}
impl TopTable {
fn add(&mut self, raw: &str, weight: u64) {
if raw.is_empty() || weight == 0 {
return;
}
let value = truncate(raw, MAX_LABEL_BYTES);
if let Some(count) = self.values.get_mut(&value) {
*count = count.saturating_add(weight);
return;
}
if self.values.len() < TOP_CAPACITY {
self.values.insert(value, weight);
return;
}
let Some((smallest, minimum)) = self
.values
.iter()
.min_by_key(|(_, count)| **count)
.map(|(value, count)| (value.clone(), *count))
else {
return;
};
self.values.remove(&smallest);
self.values.insert(value, minimum.saturating_add(weight));
}
fn merge(&mut self, other: &Self) {
for (value, count) in &other.values {
self.add(value, *count);
}
}
fn view(&self) -> Vec<TopEntry> {
let mut entries = self
.values
.iter()
.map(|(value, count)| TopEntry {
value: value.clone(),
count: *count,
})
.collect::<Vec<_>>();
entries.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.value.cmp(&b.value)));
entries
}
}
#[derive(Debug, Clone, Default)]
struct Counters {
requests: u64,
errors: u64,
duration_ms: u64,
request_bytes: u64,
response_bytes: u64,
countries: TopTable,
user_agents: TopTable,
client_ips: TopTable,
methods: TopTable,
statuses: TopTable,
routes: TopTable,
paths: TopTable,
cache: TopTable,
}
impl Counters {
fn record(&mut self, sample: Sample) {
self.requests = self.requests.saturating_add(1);
self.errors = self.errors.saturating_add((sample.status >= 500) as u64);
self.duration_ms = self.duration_ms.saturating_add(sample.duration_ms);
self.request_bytes = self.request_bytes.saturating_add(sample.request_bytes);
self.response_bytes = self.response_bytes.saturating_add(sample.response_bytes);
self.methods.add(&sample.method, 1);
self.statuses.add(&format!("{}xx", sample.status / 100), 1);
self.routes
.add(sample.route.as_deref().unwrap_or("(main)"), 1);
self.paths.add(&sample.path, 1);
self.cache.add(sample.cache, 1);
if let Some(country) = sample.country.as_deref() {
self.countries.add(country, 1);
}
if let Some(user_agent) = sample.user_agent.as_deref() {
self.user_agents.add(user_agent, 1);
}
if let Some(client_ip) = sample.client_ip.as_deref() {
self.client_ips.add(client_ip, 1);
}
}
fn merge(&mut self, other: &Self) {
self.requests = self.requests.saturating_add(other.requests);
self.errors = self.errors.saturating_add(other.errors);
self.duration_ms = self.duration_ms.saturating_add(other.duration_ms);
self.request_bytes = self.request_bytes.saturating_add(other.request_bytes);
self.response_bytes = self.response_bytes.saturating_add(other.response_bytes);
self.countries.merge(&other.countries);
self.user_agents.merge(&other.user_agents);
self.client_ips.merge(&other.client_ips);
self.methods.merge(&other.methods);
self.statuses.merge(&other.statuses);
self.routes.merge(&other.routes);
self.paths.merge(&other.paths);
self.cache.merge(&other.cache);
}
fn view(&self) -> CountersView {
CountersView {
requests: self.requests,
errors: self.errors,
duration_ms: self.duration_ms,
request_bytes: self.request_bytes,
response_bytes: self.response_bytes,
mean_duration_ms: (self.requests > 0)
.then(|| self.duration_ms as f64 / self.requests as f64),
countries: self.countries.view(),
user_agents: self.user_agents.view(),
client_ips: self.client_ips.view(),
methods: self.methods.view(),
statuses: self.statuses.view(),
routes: self.routes.view(),
paths: self.paths.view(),
cache: self.cache.view(),
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct CountersView {
pub requests: u64,
pub errors: u64,
pub duration_ms: u64,
pub request_bytes: u64,
pub response_bytes: u64,
pub mean_duration_ms: Option<f64>,
pub countries: Vec<TopEntry>,
pub user_agents: Vec<TopEntry>,
pub client_ips: Vec<TopEntry>,
pub methods: Vec<TopEntry>,
pub statuses: Vec<TopEntry>,
pub routes: Vec<TopEntry>,
pub paths: Vec<TopEntry>,
pub cache: Vec<TopEntry>,
}
#[derive(Debug, Clone, Serialize)]
pub struct View {
pub scope: String,
pub approximate_top_lists: bool,
pub queue_capacity: usize,
pub queue_dropped: u64,
pub data: CountersView,
}
#[derive(Default)]
struct State {
vhosts: BTreeMap<String, Counters>,
}
pub struct Analytics {
state: Arc<Mutex<State>>,
tx: mpsc::Sender<Sample>,
dropped: Arc<AtomicU64>,
}
impl Analytics {
pub fn start(shutdown: CancellationToken) -> Arc<Self> {
let state = Arc::new(Mutex::new(State::default()));
let dropped = Arc::new(AtomicU64::new(0));
let (tx, mut rx) = mpsc::channel::<Sample>(QUEUE_CAPACITY);
let worker_state = state.clone();
tokio::spawn(async move {
loop {
tokio::select! {
_ = shutdown.cancelled() => break,
sample = rx.recv() => {
let Some(sample) = sample else { break; };
let mut state = worker_state.lock().unwrap_or_else(|e| e.into_inner());
if !state.vhosts.contains_key(&sample.vhost) && state.vhosts.len() >= MAX_VHOSTS {
continue;
}
state.vhosts.entry(sample.vhost.clone()).or_default().record(sample);
}
}
}
});
Arc::new(Self { state, tx, dropped })
}
pub fn push(&self, sample: Sample) {
if self.tx.try_send(sample).is_err() {
self.dropped.fetch_add(1, Ordering::Relaxed);
}
}
pub fn view(&self, scope: Option<&str>) -> View {
let state = self.state.lock().unwrap_or_else(|e| e.into_inner());
let selected = scope.filter(|value| !value.is_empty()).unwrap_or("*");
let data = if selected == "*" {
let mut total = Counters::default();
for counters in state.vhosts.values() {
total.merge(counters);
}
total.view()
} else {
state
.vhosts
.get(selected)
.cloned()
.unwrap_or_default()
.view()
};
View {
scope: selected.to_owned(),
approximate_top_lists: true,
queue_capacity: QUEUE_CAPACITY,
queue_dropped: self.dropped.load(Ordering::Relaxed),
data,
}
}
pub fn summaries(&self) -> BTreeMap<String, CountersView> {
self.state
.lock()
.unwrap_or_else(|e| e.into_inner())
.vhosts
.iter()
.map(|(id, counters)| (id.clone(), counters.view()))
.collect()
}
pub fn reset(&self, scope: Option<&str>) -> usize {
let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
match scope.filter(|value| !value.is_empty() && *value != "*") {
Some(scope) => {
if state.vhosts.remove(scope).is_some() {
1
} else {
0
}
}
None => {
let count = state.vhosts.len();
state.vhosts.clear();
count
}
}
}
}
fn truncate(value: &str, max_bytes: usize) -> String {
if value.len() <= max_bytes {
return value.to_owned();
}
let mut end = max_bytes;
while !value.is_char_boundary(end) {
end -= 1;
}
value[..end].to_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn top_table_is_bounded() {
let mut table = TopTable::default();
for i in 0..1000 {
table.add(&format!("value-{i}"), 1);
}
assert_eq!(table.values.len(), TOP_CAPACITY);
}
#[test]
fn labels_are_truncated_at_utf8_boundaries() {
let value = "ą".repeat(200);
let trimmed = truncate(&value, 255);
assert!(trimmed.len() <= 255);
assert!(trimmed.is_char_boundary(trimmed.len()));
}
#[test]
fn client_ip_is_exposed_in_the_bounded_top_view() {
let mut counters = Counters::default();
counters.record(Sample {
vhost: "site".into(),
route: None,
method: "GET".into(),
path: "/".into(),
client_ip: Some("203.0.113.7".into()),
status: 200,
duration_ms: 2,
request_bytes: 10,
response_bytes: 20,
cache: "MISS",
user_agent: Some("test-agent".into()),
country: Some("PL".into()),
});
let view = counters.view();
assert_eq!(view.client_ips.len(), 1);
assert_eq!(view.client_ips[0].value, "203.0.113.7");
assert_eq!(view.client_ips[0].count, 1);
}
}
+36 -27
View File
@@ -69,11 +69,10 @@ impl Config {
"repository_url" => repository_url = Some(parse_string(value, key)?),
"version_url" => version_url = Some(parse_string(value, key)?),
"interval_seconds" => {
interval_seconds = Some(
value
.parse::<u64>()
.with_context(|| "UPDATE_CHECK.toml interval_seconds must be an integer")?,
)
interval_seconds =
Some(value.parse::<u64>().with_context(|| {
"UPDATE_CHECK.toml interval_seconds must be an integer"
})?)
}
_ => bail!("UPDATE_CHECK.toml contains unsupported key {key}"),
}
@@ -100,14 +99,14 @@ impl Config {
if let Some(value) = interval_seconds {
self.interval_seconds = value;
}
Self::validated(
self.repository_url,
self.version_url,
self.interval_seconds,
)
Self::validated(self.repository_url, self.version_url, self.interval_seconds)
}
fn validated(repository_url: String, version_url: String, interval_seconds: u64) -> Result<Self> {
fn validated(
repository_url: String,
version_url: String,
interval_seconds: u64,
) -> Result<Self> {
let repository_url = validate_http_url("update repository URL", &repository_url)?;
let version_url = validate_http_url("update version URL", &version_url)?;
ensure!(
@@ -176,7 +175,10 @@ impl Default for SharedStatus {
impl SharedStatus {
pub fn configure(&self, config: &Config) {
let mut view = self.view.write().unwrap_or_else(|poisoned| poisoned.into_inner());
let mut view = self
.view
.write()
.unwrap_or_else(|poisoned| poisoned.into_inner());
view.repository_url = Some(config.repository_url.clone());
}
@@ -188,7 +190,10 @@ impl SharedStatus {
}
fn record_success(&self, latest_version: String) {
let mut view = self.view.write().unwrap_or_else(|poisoned| poisoned.into_inner());
let mut view = self
.view
.write()
.unwrap_or_else(|poisoned| poisoned.into_inner());
view.update_available = is_newer_version(&latest_version, &view.current_version);
view.latest_version = Some(latest_version);
}
@@ -226,12 +231,11 @@ async fn check_once(config: &Config) -> Result<String> {
async fn fetch(url: &str) -> Result<String> {
let roots = load_root_store();
let tls = ClientConfig::builder_with_provider(Arc::new(
rustls::crypto::ring::default_provider(),
))
.with_safe_default_protocol_versions()?
.with_root_certificates(roots)
.with_no_client_auth();
let tls =
ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
.with_safe_default_protocol_versions()?
.with_root_certificates(roots)
.with_no_client_auth();
let mut http = HttpConnector::new();
http.enforce_http(false);
http.set_connect_timeout(Some(REQUEST_TIMEOUT));
@@ -287,13 +291,10 @@ async fn fetch(url: &str) -> Result<String> {
.collect()
.await
.map_err(|error| {
anyhow::anyhow!(
"update version document is too large or incomplete: {error}"
)
anyhow::anyhow!("update version document is too large or incomplete: {error}")
})?
.to_bytes();
return String::from_utf8(body.to_vec())
.context("update version document is not UTF-8");
return String::from_utf8(body.to_vec()).context("update version document is not UTF-8");
}
unreachable!("redirect loop always returns or fails at the configured limit")
@@ -395,7 +396,10 @@ fn parse_version(value: &str) -> Result<([u64; 3], Option<&str>)> {
let major = parts.next().context("missing major version")?.parse()?;
let minor = parts.next().context("missing minor version")?.parse()?;
let patch = parts.next().context("missing patch version")?.parse()?;
ensure!(parts.next().is_none(), "version must contain major.minor.patch");
ensure!(
parts.next().is_none(),
"version must contain major.minor.patch"
);
if let Some(pre) = pre {
ensure!(!pre.is_empty(), "pre-release version must not be empty");
}
@@ -426,7 +430,10 @@ mod tests {
#[test]
fn embedded_defaults_are_valid() {
let config = Config::embedded().unwrap();
assert_eq!(config.repository_url, "https://git.linuxiarz.pl/gru/RustProxy");
assert_eq!(
config.repository_url,
"https://git.linuxiarz.pl/gru/RustProxy"
);
assert_eq!(
config.version_url,
"https://git.linuxiarz.pl/gru/RustProxy/raw/branch/master/Cargo.toml"
@@ -454,7 +461,9 @@ foo = { version = "100.0.0" }
let base =
Url::parse("https://updates.example.invalid/releases/current/Cargo.toml").unwrap();
assert_eq!(
redirect_target(&base, "../next/Cargo.toml").unwrap().as_str(),
redirect_target(&base, "../next/Cargo.toml")
.unwrap()
.as_str(),
"https://updates.example.invalid/releases/next/Cargo.toml"
);
assert!(redirect_target(&base, "ftp://updates.example.invalid/Cargo.toml").is_err());
+78 -15
View File
@@ -142,6 +142,33 @@ impl Default for LocationResponse {
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Source {
ReverseProxy,
WebServer { web_service_id: String },
}
impl Default for Source {
fn default() -> Self {
Self::ReverseProxy
}
}
impl Source {
pub fn is_reverse_proxy(&self) -> bool {
matches!(self, Self::ReverseProxy)
}
pub fn validate(&self) -> Result<()> {
if let Self::WebServer { web_service_id } = self {
ensure!(
crate::config::valid_id(web_service_id),
"invalid Web Service reference"
);
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Vhost {
@@ -208,6 +235,10 @@ pub struct Vhost {
pub transform: crate::transform::Overrides,
#[serde(default)]
pub pages: crate::pages::VhostPages,
/// Main source for the vhost fallback. Missing in older configs means Reverse Proxy.
#[serde(default)]
pub source: Source,
#[serde(default)]
pub backends: Vec<PoolMember>,
#[serde(default)]
pub load_balancing: crate::balance::Settings,
@@ -356,6 +387,9 @@ impl PoolMember {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PathRoute {
/// Source selected for this location. A local return ignores this field.
#[serde(default)]
pub source: Source,
/// None balances over the whole pool. Some pins this path to one healthy
/// member; never fail over a path to an unrelated application.
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -384,6 +418,7 @@ pub struct PathRoute {
impl Default for PathRoute {
fn default() -> Self {
Self {
source: Source::default(),
backend_id: None,
id: "root".into(),
path_prefix: root(),
@@ -407,6 +442,7 @@ impl PathRoute {
&& self.path_prefix == "/"
&& self.match_type == PathMatch::Prefix
&& self.enabled
&& self.source == Source::ReverseProxy
&& self.backend_id.is_none()
&& self.cache.is_none()
&& self.acl.is_none()
@@ -439,7 +475,10 @@ impl Vhost {
!self.websocket_rfc9220 || self.http3_enabled,
"RFC 9220 requires HTTP/3 to be enabled for the vhost"
);
ensure!(!self.frontend.hosts.is_empty(), "vhost requires at least one hostname");
ensure!(
!self.frontend.hosts.is_empty(),
"vhost requires at least one hostname"
);
let mut hosts = std::collections::HashSet::new();
for host in &mut self.frontend.hosts {
*host = crate::config::normalize_host_pattern(host)?;
@@ -456,9 +495,18 @@ impl Vhost {
self.frontend.listener_ids.len() <= 16,
"vhost allows at most 16 named listeners"
);
self.source.validate()?;
let needs_proxy = self.source.is_reverse_proxy()
|| self.routes.iter().any(|route| {
route.enabled && route.response.is_none() && route.source.is_reverse_proxy()
});
ensure!(
!self.backends.is_empty() && self.backends.len() <= 32,
"vhost requires 1..32 backend members"
self.backends.len() <= 32,
"vhost allows at most 32 backend members"
);
ensure!(
!needs_proxy || !self.backends.is_empty(),
"vhost requires at least one backend member for Reverse Proxy sources"
);
let mut members = std::collections::HashSet::new();
for member in &self.backends {
@@ -483,6 +531,13 @@ impl Vhost {
// Normalize the untouched pre-0.16 synthetic root route out of the custom
// location list so adding/removing Custom locations cannot delete fallback.
self.routes.retain(|route| !route.is_legacy_main_fallback());
for route in &self.routes {
route.source.validate()?;
ensure!(
route.source.is_reverse_proxy() || route.backend_id.is_none(),
"Web Server location cannot pin a reverse-proxy backend"
);
}
ensure!(
self.routes.len() <= 64,
"at most 64 custom locations per vhost"
@@ -506,7 +561,8 @@ impl Vhost {
"choose either Tinyauth or Basic authentication"
);
self.pages.validate()?;
self.transform.resolve(&crate::transform::Settings::default())?;
self.transform
.resolve(&crate::transform::Settings::default())?;
self.security_headers.compile()?;
self.headers.compile()?;
if let Some(acl) = &self.acl {
@@ -757,7 +813,10 @@ mod default_listener_tests {
}
]))
.unwrap();
assert!(config.listeners.endpoints[0].binding.http2_settings.is_none());
assert!(config.listeners.endpoints[0]
.binding
.http2_settings
.is_none());
let policy = ValidationPolicy {
proxy_addr: "127.0.0.1:18080".parse().unwrap(),
admin_addr: "127.0.0.1:19090".parse().unwrap(),
@@ -766,9 +825,15 @@ mod default_listener_tests {
data_dir: None,
};
let plan = listener_plan(&config, &policy).unwrap();
let tls = plan.iter().find(|item| item.address.port() == 8443).unwrap();
let tls = plan
.iter()
.find(|item| item.address.port() == 8443)
.unwrap();
assert_eq!(tls.http2_settings, Some(config.http2));
let h2c = plan.iter().find(|item| item.address.port() == 8081).unwrap();
let h2c = plan
.iter()
.find(|item| item.address.port() == 8081)
.unwrap();
assert_eq!(
h2c.http2_settings,
Some(crate::settings::Http2Connections {
@@ -784,15 +849,13 @@ mod default_listener_tests {
let plain: Listener =
serde_json::from_value(json!({"address":"127.0.0.1:8080","tls":false})).unwrap();
assert!(!plain.h2c);
let h2c: Listener = serde_json::from_value(
json!({"address":"127.0.0.1:8080","tls":false,"h2c":true}),
)
.unwrap();
let h2c: Listener =
serde_json::from_value(json!({"address":"127.0.0.1:8080","tls":false,"h2c":true}))
.unwrap();
assert!(validate_listener(h2c).is_ok());
let invalid: Listener = serde_json::from_value(
json!({"address":"127.0.0.1:8443","tls":true,"h2c":true}),
)
.unwrap();
let invalid: Listener =
serde_json::from_value(json!({"address":"127.0.0.1:8443","tls":true,"h2c":true}))
.unwrap();
assert!(validate_listener(invalid).is_err());
}
#[test]
+4031
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff