v0.25.0
This commit is contained in:
1 parent
2e4aa4df4d
commit
87e571c170
106 files changed
+17952
-6185
No files matched your search
+10
@@ -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
@@ -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 [
|
||||
|
||||
@@ -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(§ion, e))?
|
||||
}
|
||||
"web-services" => {
|
||||
config.web_services =
|
||||
serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?
|
||||
}
|
||||
"redirects" => {
|
||||
config.redirects =
|
||||
serde_json::from_value(value).map_err(|e| invalid_section(§ion, 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 $uri/ /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 ^/app$ => /index.html"></textarea><small class="fine" data-i18n="webServiceRewritesHelp">One regex rule per line. Use pattern => /target for an internal rewrite or pattern => 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 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 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 pdf 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 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 upload_max_filesize = 64M 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">
|
||||
|
||||
@@ -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 { }
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
})();
|
||||
@@ -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();
|
||||
})();
|
||||
Vendored
+84
-34
@@ -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) {
|
||||
|
||||
Vendored
+10
-28
@@ -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
|
||||
|
||||
Vendored
+21
-5
@@ -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 {
|
||||
|
||||
Vendored
+2
-2
@@ -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 {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+80
-18
@@ -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 {
|
||||
|
||||
Vendored
+23
-16
@@ -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);
|
||||
}
|
||||
|
||||
Vendored
+42
-16
@@ -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)
|
||||
}
|
||||
|
||||
Vendored
+315
-124
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user