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

No files matched your search

+126 -3
View File
@@ -1,28 +1,135 @@
//! Authenticated OpenAPI/Swagger UI, exclusively on the admin listener.
//! Only these allowlisted vendor assets can be read; no filesystem or wildcard route.
//! Authenticated documentation Reader and OpenAPI/Swagger UI on the admin listener.
//! Documentation and vendor assets are embedded at compile time; no runtime filesystem or CDN access.
use axum::{
response::{Html, IntoResponse, Response},
Json,
};
use http::StatusCode;
use serde_json::Value;
use serde_json::{json, Value};
use std::sync::OnceLock;
// Keep Reader input independent from build-script output. `include_str!` makes every
// document a direct compiler input, so a clean or partially removed target directory
// cannot leave the crate pointing at a missing OUT_DIR artifact.
static READER_DOCUMENTS: &[(&str, &str, &str)] = &[
("README.md", "project", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))),
("ROADMAP.md", "project", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/ROADMAP.md"))),
("CHANGELOG.md", "project", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/CHANGELOG.md"))),
("docs/ACME_PROVIDERS.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/ACME_PROVIDERS.md"))),
("docs/ACME_RFC2136.md", "historical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/ACME_RFC2136.md"))),
("docs/API.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/API.md"))),
("docs/API_ACCESS.md", "technical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/API_ACCESS.md"))),
("docs/ARCHITECTURE.md", "technical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/ARCHITECTURE.md"))),
("docs/CACHE.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/CACHE.md"))),
("docs/CACHE_DIAGNOSTICS.md", "technical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/CACHE_DIAGNOSTICS.md"))),
("docs/CHANGELOG_TECHNICAL_ARCHIVE.md", "historical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/CHANGELOG_TECHNICAL_ARCHIVE.md"))),
("docs/CONTAINER.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/CONTAINER.md"))),
("docs/CUSTOM_LOCATIONS.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/CUSTOM_LOCATIONS.md"))),
("docs/DOCKER_DISCOVERY.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/DOCKER_DISCOVERY.md"))),
("docs/ERROR_LOGGING.md", "technical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/ERROR_LOGGING.md"))),
("docs/HTTP3.md", "technical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/HTTP3.md"))),
("docs/INSTALL.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/INSTALL.md"))),
("docs/INTEGRATIONS_040.md", "historical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/INTEGRATIONS_040.md"))),
("docs/LIVE_LOGS.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/LIVE_LOGS.md"))),
("docs/MANAGEMENT.md", "historical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/MANAGEMENT.md"))),
("docs/MIGRATION.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/MIGRATION.md"))),
("docs/MOCK_AUTH.md", "technical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/MOCK_AUTH.md"))),
("docs/MOCK_SERVER.md", "technical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/MOCK_SERVER.md"))),
("docs/NOT_FOUND_HOSTS.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/NOT_FOUND_HOSTS.md"))),
("docs/NPM_MIGRATION.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/NPM_MIGRATION.md"))),
("docs/PHP_FPM.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/PHP_FPM.md"))),
("docs/PROMETHEUS.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/PROMETHEUS.md"))),
("docs/README.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/README.md"))),
("docs/REDIRECTS.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/REDIRECTS.md"))),
("docs/REFERENCES.md", "technical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/REFERENCES.md"))),
("docs/RENEWAL.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/RENEWAL.md"))),
("docs/ROADMAP_HISTORY.md", "historical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/ROADMAP_HISTORY.md"))),
("docs/SECRET_STORE_BACKUP.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/SECRET_STORE_BACKUP.md"))),
("docs/SECURITY.md", "technical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/SECURITY.md"))),
("docs/SECURITY_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/SECURITY_AUDIT.md"))),
("docs/SETTINGS.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/SETTINGS.md"))),
("docs/STREAMS.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/STREAMS.md"))),
("docs/TEST_REPORT.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/TEST_REPORT.md"))),
("docs/TODO.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/TODO.md"))),
("docs/TRANSFORM.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/TRANSFORM.md"))),
("docs/TRANSPORT.md", "technical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/TRANSPORT.md"))),
("docs/V0.21.0_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.21.0_AUDIT.md"))),
("docs/V0.22.0_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.22.0_AUDIT.md"))),
("docs/V0.22.1_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.22.1_AUDIT.md"))),
("docs/V0.22.2_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.22.2_AUDIT.md"))),
("docs/V0.22.3_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.22.3_AUDIT.md"))),
("docs/V0.23.0_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.23.0_AUDIT.md"))),
("docs/V0.23.1_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.23.1_AUDIT.md"))),
("docs/V0.23.2_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.23.2_AUDIT.md"))),
("docs/V0.23.3_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.23.3_AUDIT.md"))),
("docs/V0.24.0_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.24.0_AUDIT.md"))),
("docs/V0.24.1_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.24.1_AUDIT.md"))),
("docs/V0.24.3_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.24.3_AUDIT.md"))),
("docs/V0.24.4_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.24.4_AUDIT.md"))),
("docs/V0.24.5_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.24.5_AUDIT.md"))),
("docs/V0.24.6_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.24.6_AUDIT.md"))),
("docs/V0.24.7_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.24.7_AUDIT.md"))),
("docs/V0.24.8_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.24.8_AUDIT.md"))),
("docs/V0.25.0_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.25.0_AUDIT.md"))),
("docs/V0.25.0_SECURITY_PREPROD_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.25.0_SECURITY_PREPROD_AUDIT.md"))),
("docs/VHOSTS.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/VHOSTS.md"))),
("docs/VHOST_ACCESS.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/VHOST_ACCESS.md"))),
("docs/WEB_SERVER.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/WEB_SERVER.md"))),
];
pub(super) async fn index() -> Html<&'static str> {
Html(include_str!("web/docs.html"))
}
pub(super) async fn script() -> impl IntoResponse {
(
[("content-type", "text/javascript; charset=utf-8")],
include_str!("web/docs.js"),
)
}
pub(super) async fn style() -> impl IntoResponse {
(
[("content-type", "text/css; charset=utf-8")],
include_str!("web/docs.css"),
)
}
fn reader_bundle() -> &'static [u8] {
static BUNDLE: OnceLock<Vec<u8>> = OnceLock::new();
BUNDLE
.get_or_init(|| {
let documents: Vec<_> = READER_DOCUMENTS
.iter()
.copied()
.map(|(path, category, content)| {
let file_name = path.rsplit('/').next().unwrap_or(path);
let title = content
.lines()
.find_map(|line| line.strip_prefix("# "))
.filter(|title| !title.trim().is_empty())
.unwrap_or_else(|| file_name.trim_end_matches(".md"));
json!({
"path": path,
"title": title,
"category": category,
"content": content,
})
})
.collect();
serde_json::to_vec(&json!({ "documents": documents }))
.expect("embedded documentation must serialize")
})
.as_slice()
}
pub(super) async fn content() -> impl IntoResponse {
(
[("content-type", "application/json; charset=utf-8")],
reader_bundle(),
)
}
pub(super) async fn schema() -> Json<Value> {
static DOCUMENT: OnceLock<Value> = OnceLock::new();
Json(
@@ -36,6 +143,7 @@ pub(super) async fn schema() -> Json<Value> {
.clone(),
)
}
pub(super) fn asset(name: &'static str) -> Response {
// Bytes are embedded for debug AND release, and always served through the
// authenticated router. Never read a file or fetch a CDN on an HTTP request.
@@ -61,6 +169,21 @@ pub(super) fn asset(name: &'static str) -> Response {
mod tests {
use super::*;
#[test]
fn reader_bundle_contains_embedded_project_and_web_server_docs() {
let value: Value = serde_json::from_slice(reader_bundle()).unwrap();
let documents = value["documents"].as_array().unwrap();
assert!(documents.iter().any(|doc| doc["path"] == "README.md"));
assert!(documents.iter().any(|doc| doc["path"] == "docs/WEB_SERVER.md"));
assert!(documents.iter().any(|doc| doc["path"] == "docs/PHP_FPM.md"));
assert!(documents.iter().all(|doc| {
doc["path"].as_str().is_some_and(|value| !value.is_empty())
&& doc["title"].as_str().is_some_and(|value| !value.is_empty())
&& doc["category"].as_str().is_some_and(|value| !value.is_empty())
&& doc["content"].as_str().is_some_and(|value| !value.is_empty())
}));
}
#[tokio::test]
async fn fixed_vendor_names_return_embedded_assets_with_expected_types() {
for (name, expected_type) in [
+102
View File
@@ -45,6 +45,7 @@ pub fn router(state: Arc<App>) -> Router {
.route("/", get(public::index))
.route("/overview", get(public::index))
.route("/vhosts", get(public::index))
.route("/web-server", get(public::index))
.route("/docker-vhosts", get(public::index))
.route("/redirects", get(public::index))
.route("/404-hosts", get(public::index))
@@ -52,6 +53,7 @@ pub fn router(state: Arc<App>) -> Router {
.route("/acl-profiles", get(public::index))
.route("/configuration", get(public::index))
.route("/tls", get(public::index))
.route("/traffic", get(public::index))
.route("/cache", get(public::index))
.route("/transform", get(public::index))
.route("/logging", get(public::index))
@@ -77,6 +79,7 @@ pub fn router(state: Arc<App>) -> Router {
.route("/docs", get(documentation::index))
.route("/docs/", get(documentation::index))
.route("/docs/openapi.json", get(documentation::schema))
.route("/docs/content.json", get(documentation::content))
.route("/docs/init.js", get(documentation::script))
.route("/docs/style.css", get(documentation::style))
.route(
@@ -127,6 +130,15 @@ pub fn router(state: Arc<App>) -> Router {
)
}),
)
.route(
"/assets/traffic-panel.js",
get(|| async {
(
[("content-type", "text/javascript; charset=utf-8")],
include_str!("web/traffic-panel.js"),
)
}),
)
.route(
"/assets/cache-panel.js",
get(|| async {
@@ -172,6 +184,15 @@ pub fn router(state: Arc<App>) -> Router {
)
}),
)
.route(
"/assets/web-server.js",
get(|| async {
(
[("content-type", "text/javascript; charset=utf-8")],
include_str!("web/web-server.js"),
)
}),
)
.route(
"/assets/docker.js",
get(|| async {
@@ -272,6 +293,7 @@ pub fn router(state: Arc<App>) -> Router {
"/api/certificate-api/{key_id}",
delete(certificate_api_revoke).layer(DefaultBodyLimit::max(4096)),
)
.route("/api/traffic", get(traffic_status))
.route("/api/cache", get(cache_status))
.route("/api/transform", get(transform_status))
.route("/api/cache/purge", post(cache_purge))
@@ -360,6 +382,7 @@ fn panel_document_path(path: &str) -> bool {
path,
"/" | "/overview"
| "/vhosts"
| "/web-server"
| "/docker-vhosts"
| "/redirects"
| "/404-hosts"
@@ -367,6 +390,7 @@ fn panel_document_path(path: &str) -> bool {
| "/acl-profiles"
| "/configuration"
| "/tls"
| "/traffic"
| "/cache"
| "/transform"
| "/logging"
@@ -1280,6 +1304,11 @@ fn status_payload(state: &App) -> Value {
});
payload["system"] = json!(system);
payload["backend_pools"] = json!(snapshot.pools.iter().map(|p| p.view()).collect::<Vec<_>>());
payload["vhost_metrics"] = json!(snapshot
.vhost_metrics
.iter()
.map(|(id, metrics)| (id.clone(), metrics.view()))
.collect::<std::collections::BTreeMap<_, _>>());
let mut backend_pool_domains = std::collections::BTreeMap::new();
for agent in &snapshot.envelope.config.docker_agents.agents {
for assignment in agent
@@ -1544,6 +1573,10 @@ async fn update_section(
config.vhosts =
serde_json::from_value(value).map_err(|e| invalid_section(&section, e))?
}
"web-services" => {
config.web_services =
serde_json::from_value(value).map_err(|e| invalid_section(&section, e))?
}
"redirects" => {
config.redirects =
serde_json::from_value(value).map_err(|e| invalid_section(&section, e))?
@@ -2174,6 +2207,11 @@ mod tests {
router.clone().oneshot(anonymous).await.unwrap().status(),
StatusCode::UNAUTHORIZED
);
let anonymous = request("/api/traffic").body(Body::empty()).unwrap();
assert_eq!(
router.clone().oneshot(anonymous).await.unwrap().status(),
StatusCode::UNAUTHORIZED
);
let anonymous = request("/api/transform").body(Body::empty()).unwrap();
assert_eq!(
router.clone().oneshot(anonymous).await.unwrap().status(),
@@ -2526,6 +2564,7 @@ mod tests {
for path in [
"/overview",
"/vhosts",
"/web-server",
"/docker-vhosts",
"/redirects",
"/404-hosts",
@@ -2533,6 +2572,7 @@ mod tests {
"/acl-profiles",
"/configuration",
"/tls",
"/traffic",
"/cache",
"/transform",
"/logging",
@@ -2651,6 +2691,7 @@ mod tests {
for (path, expected) in [
("/api/logs?vhost=demo&limit=8", 200),
("/api/logs?cache=BAD", 400),
("/api/traffic", 200),
("/api/cache", 200),
("/api/transform", 200),
] {
@@ -3199,6 +3240,7 @@ mod tests {
"/api/config",
"/api/backends",
"/api/docker",
"/api/traffic",
"/api/cache?limit=invalid",
"/api/logs?limit=invalid",
"/api/live-logs",
@@ -3212,6 +3254,7 @@ mod tests {
"/api/metrics",
"/metrics",
"/docs/openapi.json",
"/docs/content.json",
"/docs/init.js",
"/docs/style.css",
"/docs/swagger-ui-bundle.js",
@@ -3221,6 +3264,7 @@ mod tests {
"/assets/app.js",
"/assets/management.js",
"/assets/integrations.js",
"/assets/traffic-panel.js",
"/assets/cache-panel.js",
"/assets/transform-panel.js",
"/assets/vhosts.js",
@@ -3281,6 +3325,7 @@ mod tests {
("/api/auth/setup", "GET"),
("/healthz", "POST"),
("/docs/openapi.json", "HEAD"),
("/docs/content.json", "HEAD"),
] {
let response = router
.clone()
@@ -3381,6 +3426,7 @@ mod tests {
"/docs/swagger-ui-bundle.js",
"/docs/swagger-ui.css",
"/docs/licenses.txt",
"/docs/content.json",
"/assets/app.js",
] {
let response = router
@@ -3415,6 +3461,23 @@ mod tests {
"cookie"
);
assert!(spec["paths"].get("/api/config").is_some());
let response = router
.clone()
.oneshot(
request("/docs/content.json")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let docs = json_body(response).await;
let documents = docs["documents"].as_array().expect("documentation bundle");
assert!(documents.iter().any(|document| document["path"] == "docs/README.md"));
assert!(documents.iter().any(|document| document["path"] == "docs/WEB_SERVER.md"));
assert!(documents.iter().any(|document| document["path"] == "docs/PHP_FPM.md"));
assert!(documents.iter().any(|document| document["path"] == "docs/V0.25.0_AUDIT.md"));
assert!(documents.iter().any(|document| document["path"] == "docs/V0.25.0_SECURITY_PREPROD_AUDIT.md"));
store.close().await;
}
#[tokio::test]
@@ -3515,6 +3578,45 @@ mod tests {
}
}
#[derive(Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct TrafficQuery {
vhost: Option<String>,
}
async fn traffic_status(
State(state): State<Arc<App>>,
Query(query): Query<TrafficQuery>,
) -> Result<Json<Value>, ApiError> {
let snapshot = state.config.load_full();
if query.vhost.as_ref().is_some_and(|id| {
id != "*" && !snapshot.envelope.config.vhosts.iter().any(|vhost| &vhost.id == id)
}) {
return Err(ApiError(
StatusCode::NOT_FOUND,
"unknown vhost traffic scope".into(),
));
}
let scope = query.vhost.as_deref().unwrap_or("*");
let vhosts = snapshot
.envelope
.config
.vhosts
.iter()
.map(|vhost| json!({"id": vhost.id, "hosts": vhost.frontend.hosts}))
.collect::<Vec<_>>();
Ok(Json(json!({
"scope": scope,
"since": "process_start",
"settings": {
"capture_user_agent": snapshot.envelope.config.logging.capture_user_agent,
"capture_country": snapshot.envelope.config.logging.capture_country
},
"geoip": state.geoip.view(),
"analytics": state.traffic.view(Some(scope)),
"vhosts": vhosts
})))
}
#[derive(Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct CacheQuery {
+651 -3
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.24.10",
"version": "0.25.0",
"description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust."
},
"servers": [
@@ -361,6 +361,9 @@
}
},
"description": "Local response returned by RustProxy without opening a backend connection."
},
"source": {
"$ref": "#/components/schemas/Source"
}
},
"additionalProperties": false,
@@ -509,13 +512,15 @@
"type": "boolean",
"default": false,
"description": "RFC 8441 WebSocket Extended CONNECT over HTTP/2. Independent opt-in alongside classic HTTP/1.1 Upgrade; requires WebSocket support and an H2-capable selected listener."
},
"source": {
"$ref": "#/components/schemas/Source"
}
},
"additionalProperties": true,
"required": [
"id",
"frontend",
"backends"
"frontend"
]
},
"Config": {
@@ -665,6 +670,16 @@
"format": {
"type": "object",
"additionalProperties": true
},
"capture_user_agent": {
"type": "boolean",
"default": true,
"description": "Capture a bounded User-Agent string in access logs and traffic analytics."
},
"capture_country": {
"type": "boolean",
"default": false,
"description": "Resolve country from GeoIP for access logs and traffic analytics. Disabled by default to avoid per-request GeoIP work unless country policy/rate-limit already requires it."
}
}
},
@@ -717,6 +732,13 @@
}
},
"description": "Optional asynchronous ERROR-only file sink. Rotation and retention are handled externally by logrotate."
},
"web_services": {
"type": "array",
"maxItems": 128,
"items": {
"$ref": "#/components/schemas/WebService"
}
}
},
"additionalProperties": true
@@ -1605,6 +1627,25 @@
"minimum": 1,
"maximum": 64,
"default": 4
},
"hot_auto_trim_enabled": {
"type": "boolean",
"default": true,
"description": "When HOT exceeds the configured target, periodically remove only sufficiently idle objects in bounded passes."
},
"hot_auto_trim_after_seconds": {
"type": "integer",
"minimum": 5,
"maximum": 86400,
"default": 300,
"description": "Minimum idle age before an object is eligible for automatic HOT trimming."
},
"hot_auto_trim_target_percent": {
"type": "integer",
"minimum": 10,
"maximum": 95,
"default": 80,
"description": "HOT usage waterline targeted by automatic idle trimming."
}
},
"additionalProperties": false
@@ -4057,6 +4098,530 @@
"http3_new_handshakes",
"administration_restart_required"
]
},
"Source": {
"oneOf": [
{
"type": "object",
"additionalProperties": false,
"properties": {
"type": {
"type": "string",
"enum": [
"reverse_proxy"
]
}
},
"required": [
"type"
]
},
{
"type": "object",
"additionalProperties": false,
"properties": {
"type": {
"type": "string",
"enum": [
"web_server"
]
},
"web_service_id": {
"type": "string",
"description": "ID of a reusable Web Service from config.web_services."
}
},
"required": [
"type",
"web_service_id"
]
}
],
"description": "Request source. Missing in older configuration documents is treated as Reverse Proxy."
},
"WebService": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"maxLength": 64
},
"name": {
"type": "string",
"maxLength": 96
},
"document_root": {
"type": "string",
"description": "Absolute local filesystem directory forming the static-file access boundary."
},
"index": {
"type": "array",
"maxItems": 16,
"items": {
"type": "string",
"maxLength": 128
},
"description": "Optional ordered index file names. An empty list skips index lookup; directory listing/error behavior applies directly."
},
"directory_listing": {
"type": "boolean",
"default": false
},
"mime_types": {
"type": "object",
"maxProperties": 64,
"additionalProperties": {
"type": "string",
"maxLength": 256
},
"description": "Optional file-extension to Content-Type overrides. Extensions are stored without a leading dot."
},
"default_mime_type": {
"type": "string",
"maxLength": 256,
"default": "application/octet-stream",
"description": "Fallback Content-Type for extensions not covered by an override or RustProxy built-ins."
},
"cache_control": {
"type": "string",
"nullable": true,
"maxLength": 1024,
"description": "Optional default Cache-Control header for files served by this Web Service. Vhost/location response-header rules remain authoritative and may override it."
},
"cache_rules": {
"type": "array",
"maxItems": 64,
"default": [],
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"matcher",
"cache_control"
],
"properties": {
"matcher": {
"type": "string",
"description": "ext:<extension> or mime:<prefix>"
},
"cache_control": {
"type": "string",
"maxLength": 1024
}
}
},
"description": "Ordered per-extension/per-MIME Cache-Control rules evaluated before cache_control."
},
"response_headers": {
"type": "object",
"maxProperties": 32,
"default": {},
"additionalProperties": {
"type": "string",
"maxLength": 8192
},
"description": "Fixed Web Service response headers. Framing/transport headers are rejected; vhost/location response rules run later."
},
"try_files": {
"type": "array",
"maxItems": 16,
"default": [],
"items": {
"type": "string",
"maxLength": 512
},
"description": "Ordered nginx-style file candidates. Supports $uri, $query_string and a final =status."
},
"rewrites": {
"type": "array",
"maxItems": 32,
"default": [],
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"pattern",
"replacement"
],
"properties": {
"pattern": {
"type": "string",
"maxLength": 512
},
"replacement": {
"type": "string",
"maxLength": 1024
},
"status": {
"type": "integer",
"nullable": true,
"enum": [
301,
302,
307,
308
]
}
}
},
"description": "Regex internal rewrites or explicit redirects evaluated before try_files."
},
"precompressed": {
"type": "object",
"additionalProperties": false,
"default": {
"enabled": false,
"brotli": true,
"gzip": true
},
"properties": {
"enabled": {
"type": "boolean",
"default": false
},
"brotli": {
"type": "boolean",
"default": true
},
"gzip": {
"type": "boolean",
"default": true
}
}
},
"download_extensions": {
"type": "array",
"maxItems": 64,
"default": [],
"items": {
"type": "string",
"maxLength": 32
},
"description": "Extensions served with Content-Disposition: attachment."
},
"symlink_policy": {
"type": "string",
"enum": [
"deny",
"inside_root"
],
"default": "deny",
"description": "deny is portable/default. inside_root is a Linux-only opt-in and cannot currently be combined with PHP-FPM."
},
"php": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean",
"default": false
},
"extensions": {
"type": "array",
"maxItems": 16,
"default": [
"php"
],
"items": {
"type": "string",
"maxLength": 32
}
},
"endpoint": {
"type": "string",
"description": "unix:/path or loopback tcp:literal-IP:port, for example tcp:127.0.0.1:9000 or tcp:[::1]:9000"
},
"connect_timeout_ms": {
"type": "integer",
"format": "uint64",
"minimum": 50,
"maximum": 60000,
"default": 1000
},
"request_timeout_ms": {
"type": "integer",
"format": "uint64",
"minimum": 100,
"maximum": 3600000,
"default": 30000
},
"max_concurrent_requests": {
"type": "integer",
"minimum": 1,
"maximum": 4096,
"default": 64
},
"pass_authorization": {
"type": "boolean",
"default": true,
"description": "Forward inbound Authorization as HTTP_AUTHORIZATION to PHP-FPM."
},
"max_request_body_bytes": {
"type": "integer",
"format": "uint64",
"minimum": 0,
"maximum": 8589934592,
"default": 0,
"description": "RustProxy-side streaming PHP request body limit. 0 disables this additional limit."
},
"max_response_header_bytes": {
"type": "integer",
"minimum": 4096,
"maximum": 1048576,
"default": 65536
},
"stderr_log_bytes": {
"type": "integer",
"minimum": 0,
"maximum": 65536,
"default": 4096,
"description": "Maximum PHP-FPM stderr bytes retained per request for diagnostics."
},
"custom_params": {
"type": "object",
"maxProperties": 64,
"default": {},
"additionalProperties": {
"type": "string",
"maxLength": 8192
},
"description": "Trusted static FastCGI NAME=value parameters. Core CGI, HTTP_* and PHP control names are reserved."
},
"php_values": {
"type": "object",
"maxProperties": 64,
"default": {},
"additionalProperties": {
"type": "string",
"maxLength": 8192
},
"description": "Trusted php.ini directive/value overrides combined into the FastCGI PHP_VALUE parameter. PHP_ADMIN_VALUE is not exposed."
}
},
"description": "Optional bounded direct FastCGI connection to a PHP-FPM pool."
},
"htaccess": {
"type": "object",
"additionalProperties": false,
"default": {
"enabled": false,
"filename": ".htaccess",
"scan_subdirectories": true,
"strict": false
},
"properties": {
"enabled": {
"type": "boolean",
"default": false,
"description": "Compile the bounded RustProxy .htaccess compatibility subset for this Web Service."
},
"filename": {
"type": "string",
"maxLength": 64,
"default": ".htaccess",
"description": "Plain configuration file name searched from the document root."
},
"scan_subdirectories": {
"type": "boolean",
"default": true,
"description": "Compile matching files in subdirectories, bounded to depth 16 and 128 files."
},
"strict": {
"type": "boolean",
"default": false,
"description": "Reject Apply when an unsupported directive is found instead of logging and ignoring it."
}
},
"description": "Migration-oriented Apache .htaccess subset compiled at Web Service apply time; never parsed per request."
},
"hardening": {
"type": "object",
"additionalProperties": false,
"default": {
"block_common_attacks": false,
"strip_unnecessary_headers": false
},
"properties": {
"block_common_attacks": {
"type": "boolean",
"default": false,
"description": "Reject high-confidence exploit probes, sensitive dot/control/backup paths and unsafe TRACE/CONNECT/TRACK methods for this Web Service."
},
"strip_unnecessary_headers": {
"type": "boolean",
"default": false,
"description": "Remove common implementation/fingerprint response headers such as Server and X-Powered-By after Web Service/.htaccess processing."
}
},
"description": "Optional Web Service hardening. This is a bounded deterministic compatibility/security layer, not a generic WAF."
}
},
"required": [
"id",
"name",
"document_root"
]
},
"TrafficTopEntry": {
"type": "object",
"additionalProperties": false,
"required": [
"value",
"count"
],
"properties": {
"value": {
"type": "string"
},
"count": {
"type": "integer",
"format": "int64",
"minimum": 0
}
}
},
"TrafficCounters": {
"type": "object",
"additionalProperties": false,
"properties": {
"requests": {
"type": "integer",
"format": "int64"
},
"errors": {
"type": "integer",
"format": "int64"
},
"duration_ms": {
"type": "integer",
"format": "int64"
},
"request_bytes": {
"type": "integer",
"format": "int64"
},
"response_bytes": {
"type": "integer",
"format": "int64"
},
"mean_duration_ms": {
"type": "number",
"format": "double",
"nullable": true
},
"countries": {
"type": "array",
"items": {
"$ref": "#/components/schemas/TrafficTopEntry"
}
},
"user_agents": {
"type": "array",
"items": {
"$ref": "#/components/schemas/TrafficTopEntry"
}
},
"client_ips": {
"type": "array",
"items": {
"$ref": "#/components/schemas/TrafficTopEntry"
}
},
"methods": {
"type": "array",
"items": {
"$ref": "#/components/schemas/TrafficTopEntry"
}
},
"statuses": {
"type": "array",
"items": {
"$ref": "#/components/schemas/TrafficTopEntry"
}
},
"routes": {
"type": "array",
"items": {
"$ref": "#/components/schemas/TrafficTopEntry"
}
},
"paths": {
"type": "array",
"items": {
"$ref": "#/components/schemas/TrafficTopEntry"
}
},
"cache": {
"type": "array",
"items": {
"$ref": "#/components/schemas/TrafficTopEntry"
}
}
}
},
"TrafficAnalytics": {
"type": "object",
"additionalProperties": false,
"properties": {
"scope": {
"type": "string"
},
"approximate_top_lists": {
"type": "boolean"
},
"queue_capacity": {
"type": "integer"
},
"queue_dropped": {
"type": "integer",
"format": "int64"
},
"data": {
"$ref": "#/components/schemas/TrafficCounters"
}
}
},
"TrafficStatus": {
"type": "object",
"additionalProperties": false,
"properties": {
"scope": {
"type": "string"
},
"since": {
"type": "string",
"enum": [
"process_start"
]
},
"settings": {
"type": "object",
"additionalProperties": false,
"properties": {
"capture_user_agent": {
"type": "boolean"
},
"capture_country": {
"type": "boolean"
}
}
},
"geoip": {
"type": "object",
"additionalProperties": true
},
"analytics": {
"$ref": "#/components/schemas/TrafficAnalytics"
},
"vhosts": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": true
}
}
}
}
}
},
@@ -8371,6 +8936,89 @@
}
]
}
},
"/api/traffic": {
"get": {
"summary": "Bounded per-vhost traffic analytics",
"tags": [
"Traffic"
],
"operationId": "get_api_traffic",
"parameters": [
{
"name": "vhost",
"in": "query",
"required": false,
"description": "Configured vhost ID or * for all vhosts.",
"schema": {
"type": "string",
"default": "*"
}
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TrafficStatus"
}
}
}
},
"401": {
"description": "Authentication required or revoked",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Host, origin or CSRF validation failed",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "Unknown vhost scope",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"429": {
"description": "Bounded worker/session/rate limit",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"503": {
"description": "Control-plane storage/worker unavailable",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
}
}
}
+46 -11
View File
@@ -44,7 +44,7 @@ function updateAdminPreferences(patch) {
}
function resetAdminPreferences() { adminPreferences = null; adminPreferencesLoading = null; adminPreferencesWrite = Promise.resolve(); }
window.rustProxyPreferences = { load: loadAdminPreferences, update: updateAdminPreferences, current: () => adminPreferences, applyTheme: applyAdminTheme };
const PANEL_PATHS = { overview: "/overview", vhosts: "/vhosts", "docker-vhosts": "/docker-vhosts", redirects: "/redirects", notfound: "/404-hosts", streams: "/streams", "acl-profiles": "/acl-profiles", tls: "/tls", cache: "/cache", transform: "/transform", settings: "/settings" };
const PANEL_PATHS = { overview: "/overview", vhosts: "/vhosts", "web-server": "/web-server", "docker-vhosts": "/docker-vhosts", redirects: "/redirects", notfound: "/404-hosts", streams: "/streams", "acl-profiles": "/acl-profiles", tls: "/tls", traffic: "/traffic", cache: "/cache", transform: "/transform", settings: "/settings" };
function panelFromLocation() { if (typeof location === "undefined") return "overview"; if (location.pathname === "/configuration" || location.pathname === "/logging") return "settings"; const found = Object.entries(PANEL_PATHS).find(([, path]) => path === location.pathname); return found?.[0] || "overview"; }
let envelope = null, busy = false, connected = false, currentPanel = panelFromLocation(), previous = null;
let samples = [], protocolSamples = { h1: [], h2: [], h3: [] }, websocketProtocolSamples = { h1: [], h2: [], h3: [] }, transformSamples = [], polling = false, toastTimer, editingBackend = null, editingRoute = null;
@@ -138,6 +138,7 @@ function apiArea(path) {
const section = path.match(/^\/api\/config\/([^/?]+)/)?.[1];
if (section) return ({ "listeners": "settings", "http2": "settings", "docker-agents": "dockerVhosts", "response": "settings", "pages": "settings", "cache": "cache", "transform": "settings", "logging": "settings", "forwarding": "settings", "headers": "settings", "prometheus": "settings", "administration": "administration", "tls": "tls", "vhosts": "vhosts", "redirects": "redirects", "not_found_hosts": "notfound", "streams": "streams", "acl-profiles": "aclProfiles", "host-policies": "security" })[section] || "configuration";
if (path === "/api/config") return "configuration";
if (path.startsWith("/api/traffic")) return "traffic";
if (path.startsWith("/api/cache")) return "cache";
if (path.startsWith("/api/tunnels")) return "overview";
if (path.startsWith("/api/transform")) return "transform";
@@ -176,7 +177,7 @@ function setPanel(name, navigate = true) {
if (navigate && typeof location !== "undefined" && ["http:", "https:"].includes(location.protocol) && location.pathname !== PANEL_PATHS[name]) history.pushState({ panel: name }, "", PANEL_PATHS[name]);
if (name === "settings") window.rustProxySettings?.syncLocation?.();
if (name === "tls") window.rustProxyAcme?.syncLocation?.();
if (name === "cache") void window.rustProxyCachePanel?.refresh(); if (name === "transform") void window.rustProxyTransformPanel?.refresh(); if (name === "docker-vhosts") void window.rustProxyDocker?.refresh(false); if (name === "tls") { void window.rustProxyAcme?.refresh(); void refreshCertificateInventory().then(() => { if (authenticated && currentPanel === "tls") renderCertificates(); }); }
if (name === "traffic") void window.rustProxyTrafficPanel?.refresh(); if (name === "cache") void window.rustProxyCachePanel?.refresh(); if (name === "transform") void window.rustProxyTransformPanel?.refresh(); if (name === "docker-vhosts") void window.rustProxyDocker?.refresh(false); if (name === "tls") { void window.rustProxyAcme?.refresh(); void refreshCertificateInventory().then(() => { if (authenticated && currentPanel === "tls") renderCertificates(); }); }
}
function bytes(value) { if (value < 1024) return `${value} B`; for (const unit of ["KiB", "MiB", "GiB", "TiB"]) { value /= 1024; if (value < 1024 || unit === "TiB") return `${value.toFixed(value < 10 ? 1 : 0)} ${unit}`; } }
function uptime(value) {
@@ -303,7 +304,7 @@ function parseRules(text, target = null) {
});
}
function renderConfig() {
if (!envelope) return; window.rustProxyCachePanel?.configure(); renderCertificates(); renderSettings(); window.rustProxyPages?.render(); window.rustProxyAclProfiles?.render(); window.rustProxyVhosts?.render(); window.rustProxyDocker?.render(); window.rustProxyRedirects?.render(); window.rustProxyNotFound?.render(); window.rustProxyStreams?.render(); const config = envelope.config; if (!settingsDirty.has("config-editor")) $("config-editor").value = JSON.stringify(config, null, 2);
if (!envelope) return; window.rustProxyCachePanel?.configure(); renderCertificates(); renderSettings(); window.rustProxyPages?.render(); window.rustProxyAclProfiles?.render(); window.rustProxyVhosts?.render(); window.rustProxyWebServer?.render(); window.rustProxyDocker?.render(); window.rustProxyRedirects?.render(); window.rustProxyNotFound?.render(); window.rustProxyStreams?.render(); const config = envelope.config; if (!settingsDirty.has("config-editor")) $("config-editor").value = JSON.stringify(config, null, 2);
$("host-policy-list").replaceChildren(...(config.host_policies || []).map((policy, index) => { const box = el("div", undefined, "entity"); box.append(el("span", policy.hosts.join(", "), "entity-title"), el("p", `${t("aclDefault")}: ${t(policy.acl.default || "deny")}\n${rulesText(policy.acl)}`, "entity-details")); const buttons = el("div", undefined, "entity-actions"); buttons.append(action(t("edit"), () => { $("policy-hosts").value = policy.hosts.join(", "); $("policy-default").value = policy.acl.default || "deny"; $("policy-rules").value = rulesText(policy.acl); const editor = $("security-policy-editor"); if (editor) { editor.open = true; editor.scrollIntoView({ block: "nearest" }); } }), action(t("remove"), async () => { if (await appConfirm(t("confirmDelete"), { danger: true })) { const next = structuredClone(config); next.host_policies.splice(index, 1); await saveSection("host-policies", next.host_policies); } })); box.append(buttons); return box; }));
if (!config.host_policies?.length) $("host-policy-list").append(el("p", t("noPolicies"), "empty"));
if ($("security-policy-count")) $("security-policy-count").textContent = String((config.host_policies || []).length);
@@ -324,7 +325,7 @@ function authReturnDestination() {
}
function lock(notify = true) {
const destination = pendingAuthDestination || authReturnDestination(), navigating = typeof location !== "undefined" && (location.protocol === "http:" || location.protocol === "https:");
pendingAuthDestination = destination; if (notify) adminAuthChannel?.postMessage("locked"); $("api-docs").hidden = true; resetAdminPreferences(); window.rustProxyCertificates?.reset(); window.rustProxySettings?.reset(); window.rustProxyCachePanel?.reset(); window.rustProxyTransformPanel?.reset(); window.rustProxyAcme?.reset(); window.rustProxyIntegrations?.reset(); window.rustProxyLogs?.reset(); $("logs-open").hidden = true; window.rustProxyAclProfiles?.reset(); window.rustProxyVhosts?.reset(); window.rustProxyDocker?.reset(); window.rustProxyRedirects?.reset(); window.rustProxyNotFound?.reset(); window.rustProxyStreams?.reset(); authEpoch++; stopLive(); csrf = ""; authenticated = false; sessionInfo = null; envelope = null; certificateInventory = new Map(); previous = null; samples = []; protocolSamples = { h1: [], h2: [], h3: [] }; transformSamples = []; systemSamples = []; connectionOpenSamples = []; connectionExchangeSamples = []; networkInterfaceSamples.clear(); lastLogs = null; latestStatus = null; clearSettingsDirty(); connected = false; busy = false; $("workspace").hidden = true; $("login").hidden = navigating; $("logout").hidden = true; $("password").value = ""; $("password-confirm").value = ""; $("setup-token").value = ""; $("password-form").reset(); $("config-editor").value = ""; $("logs").replaceChildren(); $("audit-rows").replaceChildren(); authMessage("");
pendingAuthDestination = destination; if (notify) adminAuthChannel?.postMessage("locked"); $("api-docs").hidden = true; resetAdminPreferences(); window.rustProxyCertificates?.reset(); window.rustProxySettings?.reset(); window.rustProxyTrafficPanel?.reset(); window.rustProxyCachePanel?.reset(); window.rustProxyTransformPanel?.reset(); window.rustProxyAcme?.reset(); window.rustProxyIntegrations?.reset(); window.rustProxyLogs?.reset(); $("logs-open").hidden = true; window.rustProxyAclProfiles?.reset(); window.rustProxyVhosts?.reset(); window.rustProxyWebServer?.reset(); window.rustProxyDocker?.reset(); window.rustProxyRedirects?.reset(); window.rustProxyNotFound?.reset(); window.rustProxyStreams?.reset(); authEpoch++; stopLive(); csrf = ""; authenticated = false; sessionInfo = null; envelope = null; certificateInventory = new Map(); previous = null; samples = []; protocolSamples = { h1: [], h2: [], h3: [] }; transformSamples = []; systemSamples = []; connectionOpenSamples = []; connectionExchangeSamples = []; networkInterfaceSamples.clear(); lastLogs = null; latestStatus = null; clearSettingsDirty(); connected = false; busy = false; $("workspace").hidden = true; $("login").hidden = navigating; $("logout").hidden = true; $("password").value = ""; $("password-confirm").value = ""; $("setup-token").value = ""; $("password-form").reset(); $("config-editor").value = ""; $("logs").replaceChildren(); $("audit-rows").replaceChildren(); authMessage("");
for (const id of ["certificate-list", "host-policy-list"]) { $(id)?.replaceChildren(); }
$("workspace").querySelectorAll("form").forEach(form => form.reset());
$("workspace").querySelectorAll("input:not([type=checkbox]):not([type=radio]),textarea").forEach(input => { input.value = ""; });
@@ -407,7 +408,34 @@ async function revalidateSession() {
window.addEventListener("pageshow", event => { if (event.persisted) void revalidateSession(); });
document.addEventListener("visibilitychange", () => { if (document.hidden) stopLive(); else if (authenticated) { void revalidateSession(); } });
setInterval(() => { if (!document.hidden) void refresh(); }, 5000);
(async () => { try { await loadLanguages(); translate(); const status = await api("/api/auth/status"); setupRequired = status.setup_required; syncAuthForm(); if (!setupRequired) { try { await activate(await api("/api/auth/session")); } catch (e) { if (e.status === 401) { location.replace(`${location.pathname}${location.search}`); return; } throw e; } } } catch (e) { $("login").hidden = false; document.documentElement.classList.remove("app-loading"); toast(e.message, true); } })();
function showStartupFailure(error) {
// index.html is served only after the server has accepted the browser session.
// A module/configuration error must therefore never masquerade as a logout.
$("login").hidden = true;
$("workspace").hidden = false;
$("logout").hidden = !authenticated;
document.documentElement.classList.remove("app-loading");
toast(error?.message || String(error), true);
}
(async () => {
try {
// Revalidate the session first. The anonymous document has its own login.js;
// app.js belongs to the authenticated shell and must not fall back to its
// legacy embedded sign-in form when another startup step fails.
const info = await api("/api/auth/session");
await loadLanguages();
setupRequired = false;
translate();
syncAuthForm();
await activate(info);
} catch (e) {
if (e.status === 401) {
location.replace(`${location.pathname}${location.search}`);
return;
}
showStartupFailure(e);
}
})();
function renderCertificates() {
if (!envelope) return;
@@ -778,7 +806,7 @@ function renderSettings() {
if (!settingsDirty.has("response-form")) {
const server = config.server_header || {}, compression = config.compression || {}, diagnostics = config.diagnostic_headers || {};
$("expose-request-id").checked = !!diagnostics.request_id;
$("server-mode").value = server.mode || "preserve"; $("server-value").value = server.value || "RustProxy/v0.24.10";
$("server-mode").value = server.mode || "preserve"; $("server-value").value = server.value || "RustProxy/v0.25.0";
for (const name of ["gzip", "br", "zstd", "deflate"]) $(`compress-${name}`).checked = !!compression[name];
$("compress-min").value = compression.min_bytes ?? 1024; syncServerMode();
}
@@ -787,6 +815,9 @@ function renderSettings() {
$("cache-transient-memory").value = (c.transient_max_bytes ?? 67108864) / 1048576;
$("cache-object").value = (c.max_object_bytes ?? 16777216) / 1024; $("cache-entries").value = c.max_entries ?? 4096;
$("cache-eviction-limit").value = c.hot_eviction_max_objects ?? 64;
$("cache-auto-trim-enabled").checked = c.hot_auto_trim_enabled !== false;
$("cache-auto-trim-after").value = c.hot_auto_trim_after_seconds ?? 300;
$("cache-auto-trim-target").value = c.hot_auto_trim_target_percent ?? 80;
$("cache-cold-enabled").checked = !!c.cold_enabled; $("cache-cold-path").value = c.cold_path || "cache/cold";
$("cache-cold-bytes").value = (c.cold_max_bytes ?? 4294967296) / 1048576; $("cache-cold-entries").value = c.cold_max_entries ?? 50000; $("cache-cold-after").value = c.cold_after_seconds ?? 300;
$("cache-cold-queue-bytes").value = (c.cold_queue_max_bytes ?? 33554432) / 1048576;
@@ -822,6 +853,8 @@ function renderSettings() {
}
if (!settingsDirty.has("logging-form")) {
const l = config.logging || {}, file = l.file || {}, logstash = l.logstash || {}, loki = l.loki || {}, format = l.format || {};
$("log-capture-user-agent").checked = l.capture_user_agent !== false;
$("log-capture-country").checked = l.capture_country === true;
$("log-file-enabled").checked = !!file.enabled; $("log-file-per-vhost").checked = !!file.per_vhost; $("log-file-path").value = file.path || "/var/log/rustproxy/access.jsonl"; $("log-file-directory").value = file.directory || "/var/log/rustproxy/vhosts"; syncLoggingFileMode();
$("log-logstash-enabled").checked = !!logstash.enabled; $("log-logstash-address").value = logstash.address || "127.0.0.1:5044";
$("log-loki-enabled").checked = !!loki.enabled; $("log-loki-url").value = loki.url || "http://127.0.0.1:3100/loki/api/v1/push"; $("log-loki-token").value = loki.bearer_token_env || "";
@@ -1046,7 +1079,7 @@ bindSettingsForm("response-form", next => {
next.compression = { min_bytes: Number($("compress-min").value) }; for (const name of ["gzip", "br", "zstd", "deflate"]) next.compression[name] = $(`compress-${name}`).checked;
}, "response", next => ({ server_header: next.server_header, diagnostic_headers: next.diagnostic_headers, compression: next.compression }));
bindSettingsForm("cache-form", next => {
next.cache = { ...(next.cache || {}), enabled: $("cache-enabled").checked, memory_preset: selectedCacheMemoryPreset, max_bytes: Math.round(Number($("cache-memory").value) * 1048576), transient_max_bytes: Math.round(Number($("cache-transient-memory").value) * 1048576), max_object_bytes: Number($("cache-object").value) * 1024, max_entries: Number($("cache-entries").value), hot_eviction_max_objects: Number($("cache-eviction-limit").value), cold_enabled: $("cache-cold-enabled").checked, cold_preset: selectedCacheColdPreset, cold_path: $("cache-cold-path").value.trim(), cold_max_bytes: Math.round(Number($("cache-cold-bytes").value) * 1048576), cold_max_entries: Number($("cache-cold-entries").value), cold_queue_max_bytes: Math.round(Number($("cache-cold-queue-bytes").value) * 1048576), cold_gc_interval_seconds: Number($("cache-cold-gc-interval").value), cold_gc_max_delete: Number($("cache-cold-gc-max-delete").value), cold_after_seconds: Number($("cache-cold-after").value), cold_max_hot_seconds: Number($("cache-cold-max-hot").value), cold_on_idle: $("cache-cold-on-idle").checked, cold_on_age: $("cache-cold-on-age").checked, cold_on_lru: $("cache-cold-on-lru").checked, cold_on_shutdown: $("cache-cold-on-shutdown").checked, max_ttl_seconds: Number($("cache-ttl").value), default_ttl_seconds: Number($("cache-default-ttl").value), collapse_enabled: $("cache-collapse-enabled").checked, collapse_wait_timeout_ms: Number($("cache-collapse-timeout").value), collapse_timeout_fallback: $("cache-collapse-fallback").value, stale_if_error_timeout: $("cache-sie-timeout").checked, stale_if_error_connection: $("cache-sie-connection").checked, stale_if_error_dns: $("cache-sie-dns").checked, stale_if_error_tls: $("cache-sie-tls").checked, stale_if_error_statuses: $("cache-sie-statuses").value.split(/[\s,]+/).filter(Boolean).map(Number), proactive_refresh_enabled: $("cache-proactive-enabled").checked, proactive_refresh_before_expiry_seconds: Number($("cache-proactive-before").value), proactive_refresh_percent: Number($("cache-proactive-percent").value), proactive_refresh_max_inflight: Number($("cache-proactive-inflight").value), bypass_query: $("cache-query").checked, bypass_prefixes: $("cache-prefixes").value.split(/\r?\n/).map(v => v.trim()).filter(Boolean), static_ignore_cookies: $("cache-static-cookie").checked, static_extensions: $("cache-static-extensions").value.split(/[\n,]+/).map(v => v.trim().replace(/^\./, "").toLowerCase()).filter(Boolean), expose_status: $("cache-status-header").checked, expose_hits: $("cache-hits-header").checked, expose_cache_status: $("cache-rfc-header").checked, expose_resident_age: $("cache-resident-age-header").checked };
next.cache = { ...(next.cache || {}), enabled: $("cache-enabled").checked, memory_preset: selectedCacheMemoryPreset, max_bytes: Math.round(Number($("cache-memory").value) * 1048576), transient_max_bytes: Math.round(Number($("cache-transient-memory").value) * 1048576), max_object_bytes: Number($("cache-object").value) * 1024, max_entries: Number($("cache-entries").value), hot_eviction_max_objects: Number($("cache-eviction-limit").value), hot_auto_trim_enabled: $("cache-auto-trim-enabled").checked, hot_auto_trim_after_seconds: Number($("cache-auto-trim-after").value), hot_auto_trim_target_percent: Number($("cache-auto-trim-target").value), cold_enabled: $("cache-cold-enabled").checked, cold_preset: selectedCacheColdPreset, cold_path: $("cache-cold-path").value.trim(), cold_max_bytes: Math.round(Number($("cache-cold-bytes").value) * 1048576), cold_max_entries: Number($("cache-cold-entries").value), cold_queue_max_bytes: Math.round(Number($("cache-cold-queue-bytes").value) * 1048576), cold_gc_interval_seconds: Number($("cache-cold-gc-interval").value), cold_gc_max_delete: Number($("cache-cold-gc-max-delete").value), cold_after_seconds: Number($("cache-cold-after").value), cold_max_hot_seconds: Number($("cache-cold-max-hot").value), cold_on_idle: $("cache-cold-on-idle").checked, cold_on_age: $("cache-cold-on-age").checked, cold_on_lru: $("cache-cold-on-lru").checked, cold_on_shutdown: $("cache-cold-on-shutdown").checked, max_ttl_seconds: Number($("cache-ttl").value), default_ttl_seconds: Number($("cache-default-ttl").value), collapse_enabled: $("cache-collapse-enabled").checked, collapse_wait_timeout_ms: Number($("cache-collapse-timeout").value), collapse_timeout_fallback: $("cache-collapse-fallback").value, stale_if_error_timeout: $("cache-sie-timeout").checked, stale_if_error_connection: $("cache-sie-connection").checked, stale_if_error_dns: $("cache-sie-dns").checked, stale_if_error_tls: $("cache-sie-tls").checked, stale_if_error_statuses: $("cache-sie-statuses").value.split(/[\s,]+/).filter(Boolean).map(Number), proactive_refresh_enabled: $("cache-proactive-enabled").checked, proactive_refresh_before_expiry_seconds: Number($("cache-proactive-before").value), proactive_refresh_percent: Number($("cache-proactive-percent").value), proactive_refresh_max_inflight: Number($("cache-proactive-inflight").value), bypass_query: $("cache-query").checked, bypass_prefixes: $("cache-prefixes").value.split(/\r?\n/).map(v => v.trim()).filter(Boolean), static_ignore_cookies: $("cache-static-cookie").checked, static_extensions: $("cache-static-extensions").value.split(/[\n,]+/).map(v => v.trim().replace(/^\./, "").toLowerCase()).filter(Boolean), expose_status: $("cache-status-header").checked, expose_hits: $("cache-hits-header").checked, expose_cache_status: $("cache-rfc-header").checked, expose_resident_age: $("cache-resident-age-header").checked };
}, "cache");
bindSettingsForm("transform-form", next => {
next.transform = {
@@ -1063,14 +1096,14 @@ bindSettingsForm("transform-form", next => {
max_inflight: Number($("transform-max-inflight").value)
};
}, "transform");
const LOGGING_SOURCE_FIELDS = ["vhost", "route", "cache_effective_enabled", "transform_effective_enabled", "backend", "id", "time_ms", "method", "host", "path", "http_version", "transport", "websocket_bootstrap", "websocket_phase", "websocket_error", "h3_reset_code", "upstream_status", "upstream_error", "client_ip", "status", "duration_ms", "request_bytes", "response_bytes", "outcome", "cache", "cache_reason"];
const LOGGING_SOURCE_FIELDS = ["vhost", "route", "cache_effective_enabled", "transform_effective_enabled", "backend", "id", "time_ms", "method", "host", "path", "http_version", "transport", "websocket_bootstrap", "websocket_phase", "websocket_error", "h3_reset_code", "upstream_status", "upstream_error", "client_ip", "user_agent", "country", "status", "duration_ms", "request_bytes", "response_bytes", "outcome", "cache", "cache_reason"];
const LOGGING_PRESETS = {
standard: {},
minimal: { time_ms: "time_ms", method: "method", host: "host", path: "path", status: "status", duration_ms: "duration_ms" },
nginx: { time_ms: "time_ms", remote_addr: "client_ip", request_method: "method", host: "host", request_uri: "path", status: "status", request_time_ms: "duration_ms", request_length: "request_bytes", bytes_sent: "response_bytes", upstream: "backend", cache: "cache", cache_reason: "cache_reason" },
ecs: { "event.created_ms": "time_ms", "client.address": "client_ip", "http.request.method": "method", "server.address": "host", "url.path": "path", "http.response.status_code": "status", "event.duration_ms": "duration_ms", "http.request.body.bytes": "request_bytes", "http.response.body.bytes": "response_bytes", "service.target.name": "backend", "event.outcome": "outcome", "cache.result": "cache", "cache.reason": "cache_reason" }
nginx: { time_ms: "time_ms", remote_addr: "client_ip", http_user_agent: "user_agent", country: "country", request_method: "method", host: "host", request_uri: "path", status: "status", request_time_ms: "duration_ms", request_length: "request_bytes", bytes_sent: "response_bytes", upstream: "backend", cache: "cache", cache_reason: "cache_reason" },
ecs: { "event.created_ms": "time_ms", "client.address": "client_ip", "user_agent.original": "user_agent", "client.geo.country_iso_code": "country", "http.request.method": "method", "server.address": "host", "url.path": "path", "http.response.status_code": "status", "event.duration_ms": "duration_ms", "http.request.body.bytes": "request_bytes", "http.response.body.bytes": "response_bytes", "service.target.name": "backend", "event.outcome": "outcome", "cache.result": "cache", "cache.reason": "cache_reason" }
};
const LOGGING_PREVIEW_VALUES = { vhost: "app", route: "root", cache_effective_enabled: true, transform_effective_enabled: false, backend: "origin-1", id: 1842, time_ms: 1769328123456, method: "CONNECT", host: "app.example", path: "/socket.io/", http_version: "3", transport: "h3", websocket_bootstrap: "h3_extended_connect", websocket_phase: "tunnel_closed", websocket_error: null, h3_reset_code: null, upstream_status: 101, client_ip: "203.0.113.24", status: 200, duration_ms: 12, request_bytes: 0, response_bytes: 4821, outcome: "websocket_closed", cache: "BYPASS", cache_reason: "request_upgrade" };
const LOGGING_PREVIEW_VALUES = { vhost: "app", route: "root", cache_effective_enabled: true, transform_effective_enabled: false, backend: "origin-1", id: 1842, time_ms: 1769328123456, method: "CONNECT", host: "app.example", path: "/socket.io/", http_version: "3", transport: "h3", websocket_bootstrap: "h3_extended_connect", websocket_phase: "tunnel_closed", websocket_error: null, h3_reset_code: null, upstream_status: 101, client_ip: "203.0.113.24", user_agent: "Mozilla/5.0 RustProxy-Test", country: "PL", status: 200, duration_ms: 12, request_bytes: 0, response_bytes: 4821, outcome: "websocket_closed", cache: "BYPASS", cache_reason: "request_upgrade" };
function parseJsonObjectValue(value, target = null) { let parsed; try { parsed = JSON.parse(value || "{}"); } catch { throw fieldError(target, t("loggingJsonInvalid")); } if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") throw fieldError(target, t("loggingJsonInvalid")); return parsed; }
function loggingMappingKey(mapping) { return JSON.stringify(Object.entries(mapping || {}).sort(([a], [b]) => a.localeCompare(b))); }
function detectLoggingPreset(mapping) { const key = loggingMappingKey(mapping); for (const [name, preset] of Object.entries(LOGGING_PRESETS)) if (loggingMappingKey(preset) === key) return name; return "custom"; }
@@ -1124,6 +1157,8 @@ const selectLoggingSection = next => ({ logging: next.logging, error_log: next.e
bindSettingsForm("logging-form", next => {
const parseObject = id => parseJsonObjectValue($(id).value, $(id));
next.logging = {
capture_user_agent: $("log-capture-user-agent").checked,
capture_country: $("log-capture-country").checked,
file: { enabled: $("log-file-enabled").checked, path: $("log-file-path").value.trim(), per_vhost: $("log-file-per-vhost").checked, directory: $("log-file-directory").value.trim() },
logstash: { enabled: $("log-logstash-enabled").checked, address: $("log-logstash-address").value.trim() },
loki: { enabled: $("log-loki-enabled").checked, url: $("log-loki-url").value.trim(), bearer_token_env: $("log-loki-token").value.trim() || null, labels: parseObject("log-loki-labels") },
+168 -1
View File
@@ -175,4 +175,171 @@ main {
.docs-confirm-actions .docs-confirm-primary {
background: #101923;
color: #fff
}
}
.docs-tabs {
display: flex;
gap: 8px;
margin: 16px 0;
border-bottom: 1px solid #c8d0d8
}
.docs-tabs button {
border: 1px solid #c8d0d8;
border-bottom: 0;
background: #e9edf1;
padding: 9px 15px;
cursor: pointer
}
.docs-tabs button.active {
background: #fff;
font-weight: 700
}
.docs-api-controls {
background: #fff;
border: 1px solid #d7dde3;
padding: 12px 16px;
margin-bottom: 16px
}
.docs-api-controls label {
display: inline-flex;
align-items: center;
gap: 7px
}
.reader-layout {
display: grid;
grid-template-columns: minmax(220px, 300px) minmax(0, 1fr);
gap: 18px;
align-items: start
}
.reader-sidebar,
.markdown-reader {
background: #fff;
border: 1px solid #d7dde3
}
.reader-sidebar {
position: sticky;
top: 12px;
max-height: calc(100vh - 140px);
overflow: auto;
padding: 12px
}
.reader-sidebar label {
display: block;
font-weight: 700;
margin-bottom: 6px
}
.reader-sidebar input {
width: 100%;
padding: 8px 9px;
border: 1px solid #aeb8c2;
margin-bottom: 12px
}
.reader-group {
margin: 14px 0 6px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: .08em;
color: #5f6c79
}
.reader-list button {
width: 100%;
display: block;
text-align: left;
border: 0;
background: transparent;
padding: 7px 8px;
cursor: pointer;
color: #24313e
}
.reader-list button:hover,
.reader-list button.active {
background: #eef2f5
}
.reader-list button small {
display: block;
color: #6d7883;
overflow-wrap: anywhere
}
.markdown-reader {
min-height: 60vh;
padding: 24px 28px;
overflow-wrap: anywhere
}
.markdown-reader h1 {
margin-top: 0;
font-size: 28px
}
.markdown-reader h2 {
margin-top: 30px;
font-size: 21px;
border-bottom: 1px solid #e0e5ea;
padding-bottom: 6px
}
.markdown-reader h3 {
margin-top: 24px;
font-size: 17px
}
.markdown-reader p,
.markdown-reader li {
line-height: 1.6
}
.markdown-reader pre {
overflow: auto;
padding: 14px;
background: #101923;
color: #f3f6f8
}
.markdown-reader code {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace
}
.markdown-reader :not(pre) > code {
padding: 2px 4px;
background: #edf1f4
}
.markdown-reader blockquote {
margin-left: 0;
padding-left: 14px;
border-left: 3px solid #d97743;
color: #52606d
}
.reader-empty {
color: #687581;
padding: 10px 8px
}
@media(max-width:820px) {
.reader-layout {
grid-template-columns: 1fr
}
.reader-sidebar {
position: static;
max-height: 320px
}
.markdown-reader {
padding: 18px
}
}
+27 -15
View File
@@ -1,36 +1,48 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>RustProxy / API</title>
<title>RustProxy / Docs</title>
<link rel="icon" href="/favicon.svg">
<link rel="stylesheet" href="/docs/swagger-ui.css">
<link rel="stylesheet" href="/docs/style.css">
<script defer src="/docs/swagger-ui-bundle.js"></script>
<script defer src="/docs/init.js"></script>
</head>
<body>
<header class="docs-bar"><a class="docs-name" href="/">RustProxy</a>
<h1 data-i18n="docsTitle">API documentation</h1>
<a href="/" data-i18n="docsPanel">Back to panel</a><button type="button" id="docs-logout"
data-i18n="docsLogout">Sign out</button>
<h1 data-i18n="docsTitle">Documentation</h1>
<a href="/" data-i18n="docsPanel">Back to panel</a><button type="button" id="docs-logout" data-i18n="docsLogout">Sign out</button>
</header>
<main>
<section class="docs-session">
<p id="docs-session" role="status"></p>
<p data-i18n="docsSession">Uses your administrator session. Writes require CSRF protection.</p>
<label><input type="checkbox" id="docs-writes"><span data-i18n="docsWrites">Enable write
operations</span></label>
<p data-i18n="docsReadOnly">Read-only execution by default. SSE streams and login/setup are not executed
here.</p>
<p data-i18n="docsSession">Uses your administrator session. API writes require CSRF protection.</p>
</section>
<p id="docs-error" role="alert"></p>
<div id="swagger-ui"></div>
<p><a href="/docs/licenses.txt" data-i18n="docsLicenses">Swagger UI licenses</a></p>
<nav class="docs-tabs" aria-label="Documentation sections">
<button type="button" id="docs-tab-reader" class="active" data-docs-tab="reader" data-i18n="docsReader">Reader</button>
<button type="button" id="docs-tab-api" data-docs-tab="api" data-i18n="docsApi">API</button>
</nav>
<section id="docs-reader-pane" class="docs-pane">
<div class="reader-layout">
<aside class="reader-sidebar">
<label for="docs-reader-search" data-i18n="docsReaderSearch">Search documentation</label>
<input id="docs-reader-search" type="search" autocomplete="off" data-i18n-placeholder="docsReaderSearchPlaceholder" placeholder="Search title or path">
<div id="docs-reader-list" class="reader-list"></div>
</aside>
<article id="docs-reader-content" class="markdown-reader" aria-live="polite"></article>
</div>
</section>
<section id="docs-api-pane" class="docs-pane" hidden>
<section class="docs-api-controls">
<label><input type="checkbox" id="docs-writes"><span data-i18n="docsWrites">Enable write operations</span></label>
<p data-i18n="docsReadOnly">Read-only execution by default. SSE streams and login/setup are not executed here.</p>
</section>
<div id="swagger-ui"></div>
<p><a href="/docs/licenses.txt" data-i18n="docsLicenses">Swagger UI licenses</a></p>
</section>
</main>
</body>
</html>
</html>
+121 -13
View File
@@ -1,9 +1,11 @@
"use strict";
// Fixed same-origin schema + browser session; never query-configurable/CDN-based.
(() => {
const element = id => document.getElementById(id); let session = null, spec = null, ui = null, translations = {}, fallback = {}, epoch = 0;
const element = id => document.getElementById(id);
let session = null, spec = null, ui = null, translations = {}, fallback = {}, epoch = 0;
let documents = [], docsByPath = new Map(), currentDocument = null, activeTab = "reader";
let channel = null; try { channel = new BroadcastChannel("rustproxy-admin-session"); } catch { }
const t = key => translations[key] ?? fallback[key] ?? key;
function docsConfirm(message) {
return new Promise(resolve => {
const dialog = document.createElement("dialog"); dialog.className = "docs-confirm";
@@ -18,11 +20,16 @@
dialog.showModal(); cancel.focus();
});
}
function clear() { epoch++; session = null; spec = null; ui = null; element("docs-writes").checked = false; element("swagger-ui").replaceChildren(); element("docs-session").textContent = ""; }
function clear() {
epoch++; session = null; spec = null; ui = null; documents = []; docsByPath = new Map(); currentDocument = null;
element("docs-writes").checked = false; element("swagger-ui").replaceChildren(); element("docs-reader-list").replaceChildren(); element("docs-reader-content").replaceChildren(); element("docs-session").textContent = "";
}
function expired() { clear(); element("docs-error").replaceChildren(); const link = document.createElement("a"); link.href = "/?next=docs"; link.textContent = t("docsExpired"); element("docs-error").append(link); }
channel?.addEventListener("message", event => { if (event.data === "locked") expired(); });
async function get(path) { const response = await fetch(path, { credentials: "same-origin", cache: "no-store", redirect: "error" }); if (!response.ok) { if (response.status === 401) expired(); throw new Error(t("docsError")); } return response.json(); }
async function checkSession() { const mine = epoch; const info = await get("/api/auth/session"); if (mine !== epoch || !session || info.csrf_token !== session.csrf_token || info.username !== session.username) { expired(); throw new Error(t("docsExpired")); } return info; }
async function requestInterceptor(request) {
if (!session || !spec) throw new Error(t("docsExpired"));
const url = new URL(request.url, location.origin), method = (request.method || "GET").toLowerCase();
@@ -36,12 +43,9 @@
}
return request;
}
function render() {
if (!session || !spec) return;
// Disable the online validator and URL/config overrides. No persisted auth or
// externally supplied schema can send cookies or CSRF tokens elsewhere.
// These mandatory wire headers are supplied by the browser/interceptor, not
// typed into Swagger fields. Keep the downloadable specification accurate.
function renderApi() {
if (!session || !spec || typeof SwaggerUIBundle !== "function") return;
const displaySpec = structuredClone(spec);
for (const path of Object.values(displaySpec.paths)) for (const [method, operation] of Object.entries(path)) {
if (["get", "post", "put", "delete", "patch", "head", "options"].includes(method) && operation.parameters)
@@ -55,17 +59,121 @@
requestInterceptor, responseInterceptor: response => { if (response.status === 401) expired(); return response; }
});
}
element("docs-writes").addEventListener("change", render);
function normalizeDocPath(base, target) {
const clean = target.split("#", 1)[0].split("?", 1)[0];
if (!clean) return base;
const stack = clean.startsWith("/") ? [] : base.split("/").slice(0, -1);
for (const part of clean.replace(/^\//, "").split("/")) {
if (!part || part === ".") continue;
if (part === "..") { if (stack.length) stack.pop(); continue; }
stack.push(part);
}
return stack.join("/");
}
function appendInline(parent, text, basePath) {
const pattern = /(`[^`]+`|\[[^\]]+\]\([^)]+\))/g; let offset = 0;
for (const match of text.matchAll(pattern)) {
if (match.index > offset) parent.append(document.createTextNode(text.slice(offset, match.index)));
const token = match[0];
if (token.startsWith("`")) {
const code = document.createElement("code"); code.textContent = token.slice(1, -1); parent.append(code);
} else {
const parsed = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(token); const anchor = document.createElement("a");
anchor.textContent = parsed[1]; const target = parsed[2].trim();
if (/^https?:\/\//i.test(target)) { anchor.href = target; anchor.target = "_blank"; anchor.rel = "noreferrer"; }
else if (target.startsWith("#")) { anchor.href = target; }
else {
const resolved = normalizeDocPath(basePath, target);
if (docsByPath.has(resolved)) {
anchor.href = `#doc=${encodeURIComponent(resolved)}`;
anchor.addEventListener("click", event => { event.preventDefault(); selectDocument(resolved, true); });
} else { anchor.href = target; }
}
parent.append(anchor);
}
offset = match.index + token.length;
}
if (offset < text.length) parent.append(document.createTextNode(text.slice(offset)));
}
function renderMarkdown(documentInfo) {
const target = element("docs-reader-content"); target.replaceChildren();
const lines = documentInfo.content.replaceAll("\r\n", "\n").split("\n");
let code = null, list = null, paragraph = [];
const flushParagraph = () => { if (!paragraph.length) return; const p = document.createElement("p"); appendInline(p, paragraph.join(" ").trim(), documentInfo.path); target.append(p); paragraph = []; };
const closeList = () => { list = null; };
for (const line of lines) {
if (line.startsWith("```")) {
flushParagraph(); closeList();
if (code) { target.append(code.pre); code = null; }
else { const pre = document.createElement("pre"), node = document.createElement("code"); pre.append(node); code = { pre, node, lines: [] }; }
continue;
}
if (code) { code.lines.push(line); code.node.textContent = code.lines.join("\n"); continue; }
const heading = /^(#{1,4})\s+(.+)$/.exec(line);
if (heading) { flushParagraph(); closeList(); const h = document.createElement(`h${heading[1].length}`); appendInline(h, heading[2], documentInfo.path); target.append(h); continue; }
const bullet = /^\s*[-*]\s+(.+)$/.exec(line), ordered = /^\s*\d+\.\s+(.+)$/.exec(line);
if (bullet || ordered) {
flushParagraph(); const tag = ordered ? "ol" : "ul";
if (!list || list.tagName.toLowerCase() !== tag) { list = document.createElement(tag); target.append(list); }
const item = document.createElement("li"); appendInline(item, (bullet || ordered)[1], documentInfo.path); list.append(item); continue;
}
if (/^\s*>\s?/.test(line)) { flushParagraph(); closeList(); const q = document.createElement("blockquote"); appendInline(q, line.replace(/^\s*>\s?/, ""), documentInfo.path); target.append(q); continue; }
if (/^\s*---+\s*$/.test(line)) { flushParagraph(); closeList(); target.append(document.createElement("hr")); continue; }
if (!line.trim()) { flushParagraph(); closeList(); continue; }
closeList(); paragraph.push(line.trim());
}
flushParagraph(); if (code) target.append(code.pre);
}
const categoryLabel = category => t({ project: "docsCategoryProject", guides: "docsCategoryGuides", technical: "docsCategoryTechnical", audits: "docsCategoryAudits", historical: "docsCategoryHistorical" }[category] || category);
function renderReaderList() {
const query = (element("docs-reader-search").value || "").trim().toLowerCase();
const root = element("docs-reader-list"); root.replaceChildren();
const matches = documents.filter(item => !query || `${item.title} ${item.path}`.toLowerCase().includes(query));
for (const category of ["project", "guides", "technical", "audits", "historical"]) {
const group = matches.filter(item => item.category === category); if (!group.length) continue;
const heading = document.createElement("div"); heading.className = "reader-group"; heading.textContent = categoryLabel(category); root.append(heading);
for (const item of group) {
const button = document.createElement("button"); button.type = "button"; button.classList.toggle("active", item.path === currentDocument?.path);
const title = document.createElement("span"); title.textContent = item.title; const path = document.createElement("small"); path.textContent = item.path;
button.append(title, path); button.addEventListener("click", () => selectDocument(item.path, true)); root.append(button);
}
}
if (!root.children.length) { const empty = document.createElement("div"); empty.className = "reader-empty"; empty.textContent = t("docsReaderEmpty"); root.append(empty); }
}
function selectDocument(path, updateHash = false) {
const item = docsByPath.get(path) || docsByPath.get("docs/README.md") || documents[0]; if (!item) return;
currentDocument = item; renderReaderList(); renderMarkdown(item);
if (updateHash) history.replaceState(null, "", `#doc=${encodeURIComponent(item.path)}`);
}
function selectTab(tab) {
activeTab = tab === "api" ? "api" : "reader";
element("docs-reader-pane").hidden = activeTab !== "reader"; element("docs-api-pane").hidden = activeTab !== "api";
element("docs-tab-reader").classList.toggle("active", activeTab === "reader"); element("docs-tab-api").classList.toggle("active", activeTab === "api");
if (activeTab === "api") renderApi();
}
element("docs-writes").addEventListener("change", renderApi);
element("docs-reader-search").addEventListener("input", renderReaderList);
element("docs-tab-reader").addEventListener("click", () => selectTab("reader"));
element("docs-tab-api").addEventListener("click", () => selectTab("api"));
element("docs-logout").addEventListener("click", async () => { if (!session) return; try { await checkSession(); const response = await fetch("/api/auth/logout", { method: "POST", credentials: "same-origin", cache: "no-store", redirect: "error", headers: { "X-RustProxy-Request": "1", "X-CSRF-Token": session.csrf_token } }); if (!response.ok && response.status !== 401) throw new Error(t("docsError")); channel?.postMessage("locked"); expired(); } catch (error) { element("docs-error").textContent = error.message; } });
async function boot() {
const mine = ++epoch; try {
const info = await get("/api/auth/session"); if (mine !== epoch) return;
const [document, bundle] = await Promise.all([get("/docs/openapi.json"), get("/assets/langs.json")]); if (mine !== epoch) return;
const [documentSpec, bundle, docBundle] = await Promise.all([get("/docs/openapi.json"), get("/assets/langs.json"), get("/docs/content.json")]); if (mine !== epoch) return;
const packs = bundle.languages || []; fallback = packs.find(p => p.id === bundle.default)?.translations || {}; let selected = null; try { selected = localStorage.getItem("rp-language"); } catch { }
const pack = packs.find(p => p.id === selected) || packs.find(p => p.id === bundle.default); translations = pack?.translations || {};
window.document.documentElement.lang = pack?.meta.locale || "und"; window.document.documentElement.dir = pack?.meta.direction || "ltr";
window.document.querySelectorAll("[data-i18n]").forEach(n => n.textContent = t(n.dataset.i18n));
session = info; spec = document; element("docs-session").textContent = `${t("docsAccount")}: ${session.username}`; element("docs-error").textContent = ""; render();
window.document.querySelectorAll("[data-i18n]").forEach(n => n.textContent = t(n.dataset.i18n)); window.document.querySelectorAll("[data-i18n-placeholder]").forEach(n => n.placeholder = t(n.dataset.i18nPlaceholder));
session = info; spec = documentSpec; documents = Array.isArray(docBundle.documents) ? docBundle.documents : []; docsByPath = new Map(documents.map(item => [item.path, item]));
element("docs-session").textContent = `${t("docsAccount")}: ${session.username}`; element("docs-error").textContent = "";
const hash = new URLSearchParams(location.hash.replace(/^#/, "")).get("doc"); selectDocument(hash && docsByPath.has(hash) ? hash : "docs/README.md"); selectTab("reader");
} catch (error) { if (mine === epoch) { clear(); element("docs-error").textContent = error.message; } }
}
document.addEventListener("visibilitychange", () => { if (!document.hidden && session) void checkSession().catch(() => expired()); });
+276 -46
View File
@@ -12,10 +12,12 @@
<script defer="" src="/assets/management.js"></script>
<script defer="" src="/assets/integrations.js"></script>
<script defer="" src="/assets/vhosts.js"></script>
<script defer="" src="/assets/web-server.js"></script>
<script defer="" src="/assets/docker.js"></script>
<script defer="" src="/assets/streams.js"></script>
<script defer="" src="/assets/redirects.js"></script>
<script defer="" src="/assets/host404.js"></script>
<script defer="" src="/assets/traffic-panel.js"></script>
<script defer="" src="/assets/cache-panel.js"></script>
<script defer="" src="/assets/transform-panel.js"></script>
<script defer="" src="/assets/acme.js"></script>
@@ -32,19 +34,21 @@
<button class="nav active" data-panel="overview"><span>01</span><b
data-i18n="overview">Overview</b></button>
<button class="nav" data-panel="vhosts"><span>02</span><b data-i18n="vhosts">Vhosts</b></button>
<button class="nav" data-panel="docker-vhosts"><span>03</span><b data-i18n="docker-vhosts">Docker
<button class="nav" data-panel="web-server"><span>03</span><b data-i18n="webServer">Web Server</b></button>
<button class="nav" data-panel="docker-vhosts"><span>04</span><b data-i18n="docker-vhosts">Docker
vhosts</b></button>
<button class="nav" data-panel="redirects"><span>04</span><b
<button class="nav" data-panel="redirects"><span>05</span><b
data-i18n="redirects">Redirects</b></button>
<button class="nav" data-panel="notfound"><span>05</span><b data-i18n="notfound">404 hosts</b></button>
<button class="nav" data-panel="streams"><span>06</span><b data-i18n="streams">Streams</b></button>
<button class="nav" data-panel="acl-profiles"><span>07</span><b data-i18n="aclProfiles">ACL
<button class="nav" data-panel="notfound"><span>06</span><b data-i18n="notfound">404 hosts</b></button>
<button class="nav" data-panel="streams"><span>07</span><b data-i18n="streams">Streams</b></button>
<button class="nav" data-panel="acl-profiles"><span>08</span><b data-i18n="aclProfiles">ACL
profiles</b></button>
<button class="nav" data-panel="tls"><span>08</span><b data-i18n="tls">TLS</b></button>
<button class="nav" data-panel="cache"><span>09</span><b data-i18n="cache">Cache</b></button>
<button class="nav" data-panel="transform"><span>10</span><b
<button class="nav" data-panel="tls"><span>09</span><b data-i18n="tls">TLS</b></button>
<button class="nav" data-panel="traffic"><span>10</span><b data-i18n="traffic">Traffic</b></button>
<button class="nav" data-panel="cache"><span>11</span><b data-i18n="cache">Cache</b></button>
<button class="nav" data-panel="transform"><span>12</span><b
data-i18n="transform">Transform</b></button>
<button class="nav" data-panel="settings"><span>11</span><b data-i18n="settings">Settings</b></button>
<button class="nav" data-panel="settings"><span>13</span><b data-i18n="settings">Settings</b></button>
</nav>
<div class="sidebar-bottom"><span class="small-label" data-i18n="runtime">RUNTIME</span>
<p>Rust / Tokio / Hyper<br />Axum / Tower / SQLite</p>
@@ -498,7 +502,7 @@
</div>
</article>
</div>
<article class="card pool-card">
<article class="card pool-card" id="backend-diagnostics-card">
<div class="card-heading">
<h2 data-i18n="vhHealthTitle">Backends</h2><button class="quiet" data-i18n="vhManage"
id="pool-open" type="button">Manage vhosts</button>
@@ -547,6 +551,23 @@
</div>
</section>
<section class="panel" hidden="" id="panel-web-server">
<p class="notice fine" data-i18n="webServerIntro">Create reusable local Web Services here, then attach them to a vhost or Custom location.</p>
<div class="vhost-layout">
<article class="card">
<div class="card-heading">
<h2 data-i18n="webServiceList">Web Services</h2>
<div class="vhost-list-tools">
<label class="sr-only" data-i18n="webServiceSearch" for="web-service-search">Search Web Services</label>
<input autocomplete="off" data-i18n-aria="webServiceSearch" data-i18n-placeholder="webServiceSearchPlaceholder" id="web-service-search" type="search" />
<button class="outline" data-i18n="webServiceNew" id="web-service-new" type="button">New Web Service</button>
</div>
</div>
<div class="entity-list" id="web-service-list"></div>
</article>
</div>
</section>
<section class="panel" hidden="" id="panel-docker-vhosts">
<p class="notice fine" data-i18n="dockerVhostsIntro">Published TCP ports discovered by configured
Docker agents. Map names and aliases, select the Docker host IP, and inspect/tune live backend
@@ -937,6 +958,45 @@
</div>
</div>
</section>
<section class="panel" hidden="" id="panel-traffic">
<div class="cache-toolbar card"><label><span data-i18n="logVhost">Vhost</span><select id="traffic-scope">
<option data-i18n="logAllVhosts" value="*">All vhosts</option>
</select></label><button class="quiet" data-i18n="cacheRefresh" id="traffic-refresh" type="button">Refresh</button>
<button class="quiet" data-i18n="settings" id="traffic-settings" type="button">Settings</button>
<div class="cache-toolbar-status"><span class="fine" id="traffic-panel-state" role="status"></span>
<label class="check cache-live-toggle"><input checked="" id="traffic-live" type="checkbox" />
<span data-i18n="cacheLive">Live (3 s)</span></label></div>
</div>
<div class="stats-grid cache-stats">
<article class="stat"><span data-i18n="requests">Requests</span><strong id="traffic-requests">-</strong><small data-i18n="trafficSinceStart">Since process start</small></article>
<article class="stat"><span data-i18n="errors">Errors</span><strong id="traffic-errors">-</strong><small id="traffic-error-ratio">-</small></article>
<article class="stat"><span data-i18n="trafficResponseBytes">Response bytes</span><strong id="traffic-response-bytes">-</strong><small id="traffic-request-bytes">-</small></article>
<article class="stat"><span data-i18n="trafficMeanLatency">Mean latency</span><strong id="traffic-latency">-</strong><small id="traffic-dropped">-</small></article>
</div>
<article class="card">
<div class="card-heading"><div><span class="small-label" data-i18n="trafficBoundedLabel">BOUNDED / ASYNC</span><h2 data-i18n="trafficDimensionsTitle">Traffic dimensions</h2></div></div>
<p class="fine" data-i18n="trafficApproxHelp">Top lists are approximate and bounded so high-cardinality traffic cannot grow proxy memory without limit.</p>
<p class="fine" id="traffic-capture-status"></p>
</article>
<div class="traffic-chart-grid">
<article class="card traffic-chart-card"><h3 class="subheading" data-i18n="trafficTopCountries">Top countries</h3><div class="traffic-donut-layout"><div class="traffic-donut" id="traffic-chart-countries" role="img"><span id="traffic-chart-countries-total">-</span></div><ol class="traffic-chart-legend" id="traffic-chart-countries-legend"></ol></div></article>
<article class="card traffic-chart-card"><h3 class="subheading" data-i18n="trafficTopUserAgents">Top User-Agents</h3><div class="traffic-donut-layout"><div class="traffic-donut" id="traffic-chart-user-agents" role="img"><span id="traffic-chart-user-agents-total">-</span></div><ol class="traffic-chart-legend" id="traffic-chart-user-agents-legend"></ol></div></article>
<article class="card traffic-chart-card"><h3 class="subheading" data-i18n="trafficTopClientIps">Top client IPs</h3><div class="traffic-donut-layout"><div class="traffic-donut" id="traffic-chart-client-ips" role="img"><span id="traffic-chart-client-ips-total">-</span></div><ol class="traffic-chart-legend" id="traffic-chart-client-ips-legend"></ol></div></article>
<article class="card traffic-chart-card"><h3 class="subheading" data-i18n="trafficMethods">Methods</h3><div class="traffic-donut-layout"><div class="traffic-donut" id="traffic-chart-methods" role="img"><span id="traffic-chart-methods-total">-</span></div><ol class="traffic-chart-legend" id="traffic-chart-methods-legend"></ol></div></article>
<article class="card traffic-chart-card"><h3 class="subheading" data-i18n="trafficStatusClasses">Status classes</h3><div class="traffic-donut-layout"><div class="traffic-donut" id="traffic-chart-statuses" role="img"><span id="traffic-chart-statuses-total">-</span></div><ol class="traffic-chart-legend" id="traffic-chart-statuses-legend"></ol></div></article>
<article class="card traffic-chart-card"><h3 class="subheading" data-i18n="trafficCacheOutcomes">Cache outcomes</h3><div class="traffic-donut-layout"><div class="traffic-donut" id="traffic-chart-cache" role="img"><span id="traffic-chart-cache-total">-</span></div><ol class="traffic-chart-legend" id="traffic-chart-cache-legend"></ol></div></article>
</div>
<div class="logging-grid">
<article class="card"><h3 class="subheading" data-i18n="trafficTopCountries">Top countries</h3><div class="table-scroll"><table class="cache-table"><thead><tr><th data-i18n="trafficValue">Value</th><th data-i18n="requests">Requests</th></tr></thead><tbody id="traffic-countries"></tbody></table></div><p class="empty" id="traffic-countries-empty" data-i18n="trafficNoCountryData">Country collection is disabled or no GeoIP data is available.</p></article>
<article class="card"><h3 class="subheading" data-i18n="trafficTopUserAgents">Top User-Agents</h3><div class="table-scroll"><table class="cache-table"><thead><tr><th>User-Agent</th><th data-i18n="requests">Requests</th></tr></thead><tbody id="traffic-user-agents"></tbody></table></div><p class="empty" id="traffic-user-agents-empty" data-i18n="trafficNoUserAgentData">No User-Agent data collected.</p></article>
<article class="card"><h3 class="subheading" data-i18n="trafficTopClientIps">Top client IPs</h3><div class="table-scroll"><table class="cache-table"><thead><tr><th data-i18n="trafficClientIp">Client IP</th><th data-i18n="requests">Requests</th></tr></thead><tbody id="traffic-client-ips"></tbody></table></div><p class="empty" id="traffic-client-ips-empty" data-i18n="trafficNoData">No traffic data yet.</p></article>
<article class="card"><h3 class="subheading" data-i18n="trafficTopPaths">Top paths</h3><div class="table-scroll"><table class="cache-table"><thead><tr><th data-i18n="trafficValue">Value</th><th data-i18n="requests">Requests</th></tr></thead><tbody id="traffic-paths"></tbody></table></div><p class="empty" id="traffic-paths-empty" data-i18n="trafficNoData">No traffic data yet.</p></article>
<article class="card"><h3 class="subheading" data-i18n="trafficTopRoutes">Top routes</h3><div class="table-scroll"><table class="cache-table"><thead><tr><th data-i18n="trafficValue">Value</th><th data-i18n="requests">Requests</th></tr></thead><tbody id="traffic-routes"></tbody></table></div><p class="empty" id="traffic-routes-empty" data-i18n="trafficNoData">No traffic data yet.</p></article>
<article class="card"><h3 class="subheading" data-i18n="trafficMethods">Methods</h3><div class="table-scroll"><table class="cache-table"><thead><tr><th data-i18n="trafficValue">Value</th><th data-i18n="requests">Requests</th></tr></thead><tbody id="traffic-methods"></tbody></table></div><p class="empty" id="traffic-methods-empty" data-i18n="trafficNoData">No traffic data yet.</p></article>
<article class="card"><h3 class="subheading" data-i18n="trafficStatusClasses">Status classes</h3><div class="table-scroll"><table class="cache-table"><thead><tr><th data-i18n="trafficValue">Value</th><th data-i18n="requests">Requests</th></tr></thead><tbody id="traffic-statuses"></tbody></table></div><p class="empty" id="traffic-statuses-empty" data-i18n="trafficNoData">No traffic data yet.</p></article>
<article class="card"><h3 class="subheading" data-i18n="trafficCacheOutcomes">Cache outcomes</h3><div class="table-scroll"><table class="cache-table"><thead><tr><th data-i18n="trafficValue">Value</th><th data-i18n="requests">Requests</th></tr></thead><tbody id="traffic-cache"></tbody></table></div><p class="empty" id="traffic-cache-empty" data-i18n="trafficNoData">No traffic data yet.</p></article>
</div>
</section>
<section class="panel" hidden="" id="panel-cache">
<div class="cache-toolbar card"><label><span data-i18n="logVhost">Vhost</span><select
id="cache-scope">
@@ -1396,7 +1456,7 @@
<option data-i18n="serverCustom" value="custom">Replace / add custom value</option>
</select>
<label data-i18n="serverValue" for="server-value">Custom Server value</label><input
id="server-value" maxlength="256" placeholder="RustProxy/v0.24.10"
id="server-value" maxlength="256" placeholder="RustProxy/v0.25.0"
spellcheck="false" />
<h3 class="subheading" data-i18n="diagnosticHeaders">Diagnostic headers</h3>
<div class="switch-grid">
@@ -1483,10 +1543,14 @@
<div><label data-i18n="cacheEvictionLimit" for="cache-eviction-limit">LRU
removals per admission</label><input id="cache-eviction-limit"
max="4096" min="1" required="" type="number" /></div>
<div><label data-i18n="cacheAutoTrimAfter" for="cache-auto-trim-after">Idle age before HOT trim (seconds)</label><input id="cache-auto-trim-after" max="86400" min="5" required="" type="number" /></div>
<div><label data-i18n="cacheAutoTrimTarget" for="cache-auto-trim-target">HOT trim target (%)</label><input id="cache-auto-trim-target" max="95" min="10" required="" type="number" /></div>
<div><label data-i18n="cacheMaxTtl" for="cache-ttl">Maximum TTL
(seconds)</label><input id="cache-ttl" max="604800" min="1" required=""
type="number" /></div>
</div>
<label class="check"><input id="cache-auto-trim-enabled" type="checkbox" /><span data-i18n="cacheAutoTrimEnabled">Automatically release idle HOT cache memory</span></label>
<p class="fine" data-i18n="cacheAutoTrimHelp">When HOT memory is above the target, only entries idle for the configured age are removed in bounded passes. Active objects are not purged.</p>
<label data-i18n="cacheDefaultTtl" for="cache-default-ttl">Fallback TTL for public
responses (0 = off)</label><input id="cache-default-ttl" max="604800" min="0"
required="" type="number" />
@@ -1756,16 +1820,32 @@
</div>
<div aria-labelledby="settings-tab-logging" class="settings-page" data-settings-page="logging"
hidden="" id="settings-page-logging" role="tabpanel">
<form class="card edit-form" id="logging-form">
<div class="card edit-form" id="logging-settings-card">
<div class="card-heading">
<div><span class="small-label" data-i18n="loggingMode">JSON / ASYNC / BOUNDED</span>
<h2 data-i18n="loggingTitle">Structured access logging</h2>
</div><span class="pill live" data-i18n="hotApply">LIVE APPLY</span>
</div>
<p class="fine" data-i18n="loggingHelp">Live log viewing stays in memory. External sinks
run on
a bounded background queue and never block proxy request I/O.</p>
<div class="logging-grid">
<nav aria-label="Logging settings" class="settings-tabs sub-tabs" id="logging-settings-tabs"
role="tablist">
<button aria-selected="true" data-i18n="loggingTabOutputs"
data-logging-settings-tab="outputs" type="button">Outputs</button>
<button aria-selected="false" data-i18n="loggingTabFormat"
data-logging-settings-tab="format" type="button">JSON format</button>
<button aria-selected="false" data-i18n="loggingTabErrors"
data-logging-settings-tab="errors" type="button">Error log</button>
</nav>
<form class="logging-settings-subform" id="logging-form">
<div data-logging-settings-page="outputs">
<p class="fine" data-i18n="loggingHelp">Live log viewing stays in memory. External sinks
run on a bounded background queue and never block proxy request I/O.</p>
<article class="card soft-card">
<h3 class="subheading" data-i18n="loggingCaptureTitle">Captured request dimensions</h3>
<label class="check"><input id="log-capture-user-agent" type="checkbox" /><span data-i18n="loggingCaptureUserAgent">Capture User-Agent</span></label>
<label class="check"><input id="log-capture-country" type="checkbox" /><span data-i18n="loggingCaptureCountry">Capture country from GeoIP</span></label>
<p class="fine" data-i18n="loggingCaptureCountryHelp">Country lookup is optional and disabled by default. Enable it only when country logs/statistics are needed; country ACL/rate-limit rules still resolve GeoIP when required.</p>
</article>
<div class="logging-grid">
<article class="card">
<h3 class="subheading" data-i18n="loggingFile">JSON file</h3>
<label class="check"><input id="log-file-enabled" type="checkbox" /><span
@@ -1803,13 +1883,15 @@
<label><span data-i18n="loggingLabels">Labels (JSON object)</span><textarea
id="log-loki-labels" rows="4" spellcheck="false"></textarea></label>
</article>
</div>
<p class="fine" id="logging-status" role="status"></p>
</div>
<h3 class="subheading" data-i18n="loggingFormat">Configurable JSON format</h3>
<p class="fine" data-i18n="loggingFormatHelp">Map output names to source fields. Leave
mapping
empty for the complete standard entry.</p>
<div class="logging-format-actions"><label><span data-i18n="loggingPreset">Format
preset</span><select id="log-format-preset">
<div data-logging-settings-page="format" hidden>
<h3 class="subheading" data-i18n="loggingFormat">Configurable JSON format</h3>
<p class="fine" data-i18n="loggingFormatHelp">Map output names to source fields. Leave
mapping empty for the complete standard entry.</p>
<div class="logging-format-actions"><label><span data-i18n="loggingPreset">Format
preset</span><select id="log-format-preset">
<option data-i18n="loggingPresetCustom" value="custom">Custom</option>
<option data-i18n="loggingPresetStandard" value="standard">RustProxy
standard
@@ -1818,32 +1900,29 @@
</option>
<option data-i18n="loggingPresetEcs" value="ecs">ECS-style</option>
<option data-i18n="loggingPresetMinimal" value="minimal">Minimal</option>
</select></label>
<div class="logging-builder-action"><span class="small-label"
data-i18n="loggingBuilder">Visual field builder</span><button
class="outline" data-i18n="loggingBuilderOpen" id="log-format-open"
type="button">Configure
fields</button></div>
</div>
<details>
<summary data-i18n="loggingAdvancedJson">Advanced JSON mapping</summary>
<div class="field-grid"><label><span data-i18n="loggingFields">Field mapping (JSON
object)</span><textarea id="log-format-fields" rows="7"
spellcheck="false"></textarea></label>
<label><span data-i18n="loggingStatic">Static fields (JSON
object)</span><textarea id="log-format-static" rows="7"
spellcheck="false"></textarea></label>
</select></label>
<div class="logging-builder-action"><span class="small-label"
data-i18n="loggingBuilder">Visual field builder</span><button
class="outline" data-i18n="loggingBuilderOpen" id="log-format-open"
type="button">Configure fields</button></div>
</div>
</details>
<p class="fine" id="logging-status" role="status"></p>
<details>
<summary data-i18n="loggingAdvancedJson">Advanced JSON mapping</summary>
<div class="field-grid"><label><span data-i18n="loggingFields">Field mapping (JSON
object)</span><textarea id="log-format-fields" rows="7"
spellcheck="false"></textarea></label>
<label><span data-i18n="loggingStatic">Static fields (JSON
object)</span><textarea id="log-format-static" rows="7"
spellcheck="false"></textarea></label>
</div>
</details>
</div>
<div class="form-actions"><button class="primary" data-i18n="apply"
type="submit">Validate, save
and apply</button></div>
</form>
<form class="card edit-form" id="error-log-form">
<div class="card-heading">
<h2 data-i18n="errorLogTitle">Error file</h2><span class="pill live" data-i18n="hotApply">LIVE APPLY</span>
</div>
</form>
<form class="logging-settings-subform" data-logging-settings-page="errors" hidden id="error-log-form">
<h3 class="subheading" data-i18n="errorLogTitle">Error file</h3>
<label class="check"><input id="error-log-enabled" type="checkbox" /><span
data-i18n="enabled">Enabled</span></label>
<div class="field-grid">
@@ -1859,7 +1938,8 @@
<p class="fine" id="error-log-status" role="status"></p>
<div class="form-actions"><button class="primary" data-i18n="apply"
type="submit">Validate, save and apply</button></div>
</form>
</form>
</div>
</div>
<div aria-labelledby="settings-tab-prometheus" class="settings-page"
data-settings-page="prometheus" hidden="" id="settings-page-prometheus" role="tabpanel">
@@ -2123,7 +2203,7 @@
</section>
</div>
</section>
<footer class="page-footer"><span>RustProxy <b>0.24.10</b></span><span id="footer-status">API - /
<footer class="page-footer"><span>RustProxy <b>0.25.0</b></span><span id="footer-status">API - /
dropped: 0</span></footer>
</div>
</main>
@@ -2365,6 +2445,156 @@
<p class="fine log-footnote" data-i18n="logBufferHint">Live view starts with the latest 256 completed exchanges.
Increase buffer to keep accumulating live entries up to 4096 until the log window is closed.</p>
</dialog>
<dialog aria-labelledby="web-service-editor-title" class="vhost-dialog" id="web-service-dialog">
<form class="card vh-form" id="web-service-form" novalidate="">
<div class="card-heading">
<h2 data-i18n="webServiceEditor" id="web-service-editor-title">Web Service configuration</h2>
<span class="pill" id="web-service-editing">NEW</span>
<button class="quiet" data-i18n="close" id="web-service-close" type="button">Close</button>
</div>
<nav aria-label="Web Service configuration" class="vh-tabs" id="web-service-tabs"></nav>
<div class="web-service-pages">
<div class="vh-grid web-service-page" data-web-service-page="general">
<fieldset class="vh-group vh-wide">
<legend data-i18n="webServicePresetTitle">Configuration preset</legend>
<div class="vh-grid">
<label><span data-i18n="webServicePreset">Preset</span><select id="web-service-preset">
<option data-i18n="webServicePresetCustom" value="custom">Custom</option>
<option data-i18n="webServicePresetStatic" value="static">Static site</option>
<option data-i18n="webServicePresetSpa" value="spa">SPA</option>
<option data-i18n="webServicePresetPhpGeneric" value="php_generic">PHP generic</option>
<option value="wordpress">WordPress</option>
<option value="drupal">Drupal</option>
<option value="laravel">Laravel</option>
<option value="symfony">Symfony</option>
<option value="joomla">Joomla</option>
<option value="prestashop">PrestaShop</option>
<option data-i18n="webServicePresetDownloads" value="downloads">Downloads / file repository</option>
</select></label>
<div class="vh-field"><span class="small-label" data-i18n="webServicePresetAction">Preset action</span><button class="outline" data-i18n="webServicePresetApply" id="web-service-preset-apply" type="button">Apply preset</button></div>
<label class="check"><input id="web-service-hardening-attacks" type="checkbox" /><span data-i18n="webServiceHardeningAttacks">Block common attack probes</span></label>
<label class="check"><input id="web-service-hardening-headers" type="checkbox" /><span data-i18n="webServiceHardeningHeaders">Strip unnecessary fingerprint headers</span></label>
<p class="fine vh-wide" data-i18n="webServicePresetHelp" id="web-service-preset-summary">Presets fill normal Web Service settings and remain fully editable. Document root, service ID/name and PHP-FPM endpoint are not replaced.</p>
</div>
</fieldset>
<label><span data-i18n="webServiceId">Service ID</span><input id="web-service-id" maxlength="64" pattern="[A-Za-z0-9_-]{1,64}" required="" /></label>
<label><span data-i18n="webServiceName">Name</span><input id="web-service-name" maxlength="96" required="" /></label>
<label class="vh-wide"><span data-i18n="webServiceRoot">Document root</span><input id="web-service-root" maxlength="4096" placeholder="/var/www/main" required="" /></label>
<label class="vh-wide"><span data-i18n="webServiceIndex">Index files</span><textarea id="web-service-index" maxlength="4096" rows="5" spellcheck="false"></textarea><small class="fine" data-i18n="webServiceIndexHelp">Optional ordered index list. index.php may be used when PHP-FPM is enabled.</small></label>
<label class="check"><input id="web-service-listing" type="checkbox" /><span data-i18n="webServiceDirectoryListing">Directory listing</span></label>
</div>
<div class="vh-grid web-service-page" data-web-service-page="routing" hidden>
<label class="vh-wide"><span data-i18n="webServiceTryFiles">try_files</span><textarea id="web-service-try-files" maxlength="8192" rows="7" spellcheck="false" placeholder="$uri&#10;$uri/&#10;/index.html"></textarea><small class="fine" data-i18n="webServiceTryFilesHelp">One candidate per line. Supports $uri, $query_string and a final =404/other error status.</small></label>
<label class="vh-wide"><span data-i18n="webServiceRewrites">Rewrite rules</span><textarea id="web-service-rewrites" maxlength="32768" rows="8" spellcheck="false" placeholder="^/old/(.*)$ => 301:/new/$1&#10;^/app$ => /index.html"></textarea><small class="fine" data-i18n="webServiceRewritesHelp">One regex rule per line. Use pattern =&gt; /target for an internal rewrite or pattern =&gt; 301:/target for a redirect.</small></label>
</div>
<div class="vh-grid web-service-page" data-web-service-page="htaccess" hidden>
<label class="check"><input id="web-service-htaccess-enabled" type="checkbox" /><span data-i18n="webServiceHtaccessEnabled">Enable .htaccess compatibility</span></label>
<label><span data-i18n="webServiceHtaccessFilename">Configuration file</span><input id="web-service-htaccess-filename" maxlength="64" value=".htaccess" /></label>
<label class="check"><input id="web-service-htaccess-recursive" type="checkbox" checked /><span data-i18n="webServiceHtaccessRecursive">Scan subdirectories</span></label>
<label class="check"><input id="web-service-htaccess-strict" type="checkbox" /><span data-i18n="webServiceHtaccessStrict">Strict unsupported-directive mode</span></label>
<div class="vh-wide"><button class="outline" id="web-service-htaccess-help" data-i18n="webServiceHtaccessDocs" type="button">Compatibility documentation</button></div>
<p class="fine vh-wide" data-i18n="webServiceHtaccessHelp">RustProxy compiles a bounded Apache-compatible subset when the Web Service is applied. Files are not parsed on every request; apply the Web Service again after editing .htaccess on disk.</p>
</div>
<div class="vh-grid web-service-page" data-web-service-page="mime" hidden>
<label class="vh-wide"><span data-i18n="webServiceMimeOverrides">MIME overrides</span><textarea id="web-service-mime-types" maxlength="16384" rows="8" spellcheck="false" placeholder="wasm = application/wasm&#10;webp = image/webp"></textarea><small class="fine" data-i18n="webServiceMimeHelp">Optional extension-to-Content-Type overrides, one per line. Built-in mappings are used for all other known extensions.</small></label>
<label><span data-i18n="webServiceDefaultMime">Default MIME type</span><input id="web-service-default-mime" maxlength="256" value="application/octet-stream" required="" /></label>
</div>
<div class="vh-grid web-service-page" data-web-service-page="cache" hidden>
<label><span data-i18n="webServiceCacheControl">Default Cache-Control</span><input id="web-service-cache-control" maxlength="1024" placeholder="public, max-age=3600" /></label>
<label class="vh-wide"><span data-i18n="webServiceCacheRules">Cache-Control rules</span><textarea id="web-service-cache-rules" maxlength="32768" rows="7" spellcheck="false" placeholder="ext:html = no-cache&#10;mime:image/ = public, max-age=604800"></textarea><small class="fine" data-i18n="webServiceCacheRulesHelp">Specific rules are evaluated in order before the default. Match ext:css or mime:image/.</small></label>
<label class="vh-wide"><span data-i18n="webServiceResponseHeaders">Response headers</span><textarea id="web-service-response-headers" maxlength="32768" rows="7" spellcheck="false" placeholder="X-Content-Type-Options = nosniff"></textarea><small class="fine" data-i18n="webServiceResponseHeadersHelp">Fixed headers for this service. Vhost/location response-header rules are applied later and can override them.</small></label>
<p class="fine vh-wide" data-i18n="webServiceCacheHelp">Local files use filesystem/OS page cache; this controls browser/proxy caching and does not create a second RustProxy object cache.</p>
</div>
<div class="vh-grid web-service-page" data-web-service-page="delivery" hidden>
<label class="check"><input id="web-service-precompressed" type="checkbox" /><span data-i18n="webServicePrecompressed">Serve precompressed sidecars</span></label>
<label class="check"><input id="web-service-precompressed-br" type="checkbox" /><span data-i18n="webServicePrecompressedBr">Brotli (.br)</span></label>
<label class="check"><input id="web-service-precompressed-gzip" type="checkbox" /><span data-i18n="webServicePrecompressedGzip">Gzip (.gz)</span></label>
<label class="vh-wide"><span data-i18n="webServiceDownloadExtensions">Download extensions</span><textarea id="web-service-download-extensions" maxlength="4096" rows="5" spellcheck="false" placeholder="zip&#10;pdf&#10;tar"></textarea><small class="fine" data-i18n="webServiceDownloadExtensionsHelp">Files with these extensions receive Content-Disposition: attachment.</small></label>
<p class="fine vh-wide" data-i18n="webServiceRangeHelp">Static and precompressed representations keep validators, single-range requests, 206/416 and streaming delivery.</p>
</div>
<div class="vh-grid web-service-page" data-web-service-page="php" hidden>
<label class="check"><input id="web-service-php-enabled" type="checkbox" /><span data-i18n="webServicePhpEnabled">Enable PHP-FPM / FastCGI</span></label>
<label class="vh-wide"><span data-i18n="webServicePhpEndpoint">PHP-FPM endpoint</span><input id="web-service-php-endpoint" maxlength="512" placeholder="unix:/run/php/php-fpm.sock" /><small class="fine" data-i18n="webServicePhpEndpointHelp">Unix: unix:/run/php/php-fpm.sock. TCP: tcp:127.0.0.1:9000 or tcp:[::1]:9000. TCP is intentionally limited to loopback addresses.</small></label>
<label><span data-i18n="webServicePhpExtensions">PHP extensions</span><input id="web-service-php-extensions" maxlength="512" placeholder="php" /></label>
<label class="check"><input id="web-service-php-pass-authorization" type="checkbox" /><span data-i18n="webServicePhpPassAuthorization">Forward Authorization header</span></label>
<label><span data-i18n="webServicePhpConnectTimeout">Connect timeout (ms)</span><input id="web-service-php-connect-timeout" min="50" max="60000" step="1" type="number" /></label>
<label><span data-i18n="webServicePhpRequestTimeout">Total FastCGI request timeout (ms)</span><input id="web-service-php-request-timeout" min="100" max="3600000" step="1" type="number" /></label>
<label><span data-i18n="webServicePhpConcurrency">Max concurrent PHP requests</span><input id="web-service-php-concurrency" min="1" max="4096" step="1" type="number" /></label>
<label><span data-i18n="webServicePhpBodyLimit">PHP request body limit (bytes, 0 = unlimited)</span><input id="web-service-php-body-limit" min="0" max="8589934592" step="1" type="number" /></label>
<label><span data-i18n="webServicePhpHeaderLimit">Max FastCGI response header bytes</span><input id="web-service-php-header-limit" min="4096" max="1048576" step="1" type="number" /></label>
<label><span data-i18n="webServicePhpStderrLimit">Max PHP-FPM stderr log bytes</span><input id="web-service-php-stderr-limit" min="0" max="65536" step="1" type="number" /></label>
<label class="vh-wide"><span data-i18n="webServicePhpCustomParams">Custom FastCGI parameters</span><textarea id="web-service-php-custom-params" maxlength="65536" rows="6" spellcheck="false" placeholder="APP_ENV = production&#10;APP_DEBUG = 0"></textarea><small class="fine" data-i18n="webServicePhpCustomParamsHelp">One NAME = value entry per line. Static trusted values only; core CGI, HTTP_* and PHP_* control names are reserved.</small></label>
<label class="vh-wide"><span data-i18n="webServicePhpValues">PHP INI overrides (PHP_VALUE)</span><textarea id="web-service-php-values" maxlength="65536" rows="6" spellcheck="false" placeholder="memory_limit = 256M&#10;upload_max_filesize = 64M&#10;post_max_size = 64M"></textarea><small class="fine" data-i18n="webServicePhpValuesHelp">One php.ini directive = value per line. RustProxy sends the validated set as PHP_VALUE; PHP_ADMIN_VALUE is never accepted here.</small></label>
<div class="vh-wide form-actions"><button class="outline" data-i18n="webServicePhpDocsButton" id="web-service-php-help" type="button">FastCGI syntax and safety</button></div>
<p class="fine vh-wide" data-i18n="webServicePhpHelp">RustProxy speaks FastCGI directly to a long-running PHP-FPM pool. PHP is not spawned per request and PHP source is never served when PHP handling is selected.</p>
</div>
<div class="vh-grid web-service-page" data-web-service-page="security" hidden>
<label><span data-i18n="webServiceSymlinkPolicy">Symlink policy</span><select id="web-service-symlink-policy"><option data-i18n="webServiceSymlinkDeny" value="deny">Deny</option><option data-i18n="webServiceSymlinkInsideRoot" value="inside_root">Allow only inside document root (Linux)</option></select></label>
<div class="vh-wide">
<span class="small-label" data-i18n="webServiceHardeningTitle">Request/response hardening</span>
<p class="fine" data-i18n="webServiceHardeningSecurityHelp">Hardening switches are shown beside the preset selector on General. They remain independent settings after a preset is applied.</p>
</div>
<p class="fine vh-wide" data-i18n="webServiceSafety">Document root is a strict filesystem boundary. The default denies symlinks. Linux can optionally follow only targets that resolve inside the root; PHP-FPM currently requires the deny policy.</p>
</div>
</div>
<div class="form-actions">
<button class="primary" data-i18n="apply" type="submit">Validate, save and apply</button>
<button class="quiet" data-i18n="vhReset" id="web-service-reset" type="button">Discard changes</button>
</div>
</form>
</dialog>
<dialog aria-labelledby="htaccess-help-title" class="vhost-dialog location-help-dialog" id="htaccess-help-dialog">
<div class="window-heading">
<h2 data-i18n="webServiceHtaccessDocsTitle" id="htaccess-help-title">.htaccess compatibility</h2>
<button class="outline" data-i18n="close" id="htaccess-help-close" type="button">Close</button>
</div>
<article class="card location-help-content">
<p data-i18n="webServiceHtaccessDocsIntro">This is a migration-oriented compatibility layer, not a complete Apache runtime. Supported directives are compiled into RustProxy Web Server rules.</p>
<h3 data-i18n="webServiceHtaccessDocsSupportedTitle">Supported subset</h3>
<p><code>RewriteEngine</code>, <code>RewriteBase</code>, <code>RewriteCond</code>, <code>RewriteRule</code>, <code>DirectoryIndex</code>, <code>Options +/-Indexes</code>, <code>AddType</code>, <code>AddHandler</code> (PHP extensions), <code>Header set/add/unset</code>, <code>ExpiresActive</code>, <code>ExpiresDefault</code>, <code>ExpiresByType</code>, <code>Require all granted/denied</code>.</p>
<h3 data-i18n="webServiceHtaccessDocsRewriteTitle">Rewrite compatibility</h3>
<p data-i18n="webServiceHtaccessDocsRewrite">Rewrite conditions support REQUEST_FILENAME file tests (-f, -d, -s), REQUEST_URI, REQUEST_METHOD, QUERY_STRING, HTTP_HOST, HTTPS, DOCUMENT_ROOT, THE_REQUEST and HTTP headers. Flags: L, END, R, R=301/302/307/308, QSA, NC, F, OR. PT/NE are accepted as no-ops. E= is accepted as a compatibility no-op because request HTTP headers already reach PHP-FPM.</p>
<pre><code>RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.php [L]</code></pre>
<h3 data-i18n="webServiceHtaccessDocsPhpTitle">PHP-FPM</h3>
<p data-i18n="webServiceHtaccessDocsPhp">.htaccess may add PHP extensions with AddHandler, but the PHP-FPM endpoint, timeouts and concurrency remain controlled by the Web Service PHP-FPM tab. Apache SetHandler proxy socket syntax is intentionally not accepted.</p>
<h3 data-i18n="webServiceHtaccessDocsReloadTitle">Loading and limits</h3>
<p data-i18n="webServiceHtaccessDocsReload">Files are read and compiled on Validate, save and apply. RustProxy does not parse .htaccess per request. Re-apply after changing a file. The scan is bounded to 128 files, depth 16, 128 KiB per file and 1 MiB total.</p>
<h3 data-i18n="webServiceHtaccessDocsUnsupportedTitle">Not Apache-complete</h3>
<p data-i18n="webServiceHtaccessDocsUnsupported">ErrorDocument, SetEnvIf, Redirect/RedirectMatch and FallbackResource are not implemented in this compatibility layer. General Files/FilesMatch, unsupported authentication/authorization directives, non-PHP handlers and SetHandler are rejected even when Strict mode is off because silently ignoring them could weaken access control or expose source. .htaccess/.htpasswd files are always blocked from HTTP delivery. Other unsupported non-security directives are logged and ignored unless Strict mode is enabled.</p>
<p class="fine" data-i18n="webServiceHtaccessDocsSecurity">Vhost/location ACL, authentication, error pages and response rules still remain authoritative. .htaccess cannot escape the document root, select an arbitrary FastCGI socket or override protected framing headers.</p>
</article>
</dialog>
<dialog aria-labelledby="php-help-title" class="vhost-dialog location-help-dialog" id="php-help-dialog">
<div class="window-heading">
<h2 data-i18n="webServicePhpDocsTitle" id="php-help-title">PHP-FPM / FastCGI configuration</h2>
<button class="outline" data-i18n="close" id="php-help-close" type="button">Close</button>
</div>
<article class="card location-help-content">
<p data-i18n="webServicePhpDocsIntro">RustProxy connects directly to an existing PHP-FPM pool. Prefer a Unix socket on the same host. TCP is accepted only on loopback and is not a public/network FastCGI transport.</p>
<h3 data-i18n="webServicePhpDocsEndpointTitle">Endpoint syntax</h3>
<pre><code>unix:/run/php/php-fpm.sock
tcp:127.0.0.1:9000
tcp:[::1]:9000</code></pre>
<p class="fine" data-i18n="webServicePhpDocsEndpoint">For TCP, include the tcp: prefix, a literal loopback IP and port. Hostnames and non-loopback addresses are rejected. For Unix sockets, RustProxy must have permission to connect to the socket.</p>
<h3 data-i18n="webServicePhpDocsCustomTitle">Custom FastCGI parameters</h3>
<pre><code>APP_ENV = production
APP_DEBUG = 0
TENANT = main</code></pre>
<p data-i18n="webServicePhpDocsCustom">Use one NAME = value entry per line. Names are normalized to uppercase and may contain A-Z, 0-9 and underscore. These are static trusted operator values. RustProxy rejects core CGI names, HTTP_* names, PHP_VALUE and PHP_ADMIN_VALUE so they cannot override request routing, script selection or HTTP-derived parameters.</p>
<h3 data-i18n="webServicePhpDocsValuesTitle">PHP INI overrides</h3>
<pre><code>memory_limit = 256M
upload_max_filesize = 64M
post_max_size = 64M
max_execution_time = 30</code></pre>
<p data-i18n="webServicePhpDocsValues">These lines are combined into the FastCGI PHP_VALUE parameter. PHP-FPM/PHP still decides which directives are changeable at this scope. Newlines/control characters are rejected and PHP_ADMIN_VALUE is intentionally unavailable.</p>
<h3 data-i18n="webServicePhpDocsLimitsTitle">Limits and headers</h3>
<p data-i18n="webServicePhpDocsLimits">Request timeout covers the FastCGI exchange. The body limit is enforced while streaming and returns 413 when exceeded; 0 disables the RustProxy-side body limit. Response-header and stderr limits bound memory/diagnostic data. Authorization forwarding can be disabled for applications that do not need HTTP_AUTHORIZATION.</p>
<p class="fine" data-i18n="webServicePhpDocsSecurity">Do not put secrets in custom parameters or PHP_VALUE unless you accept storing them in normal Web Service configuration. Use a dedicated non-root PHP-FPM pool identity and keep PHP-FPM off public networks.</p>
</article>
</dialog>
<dialog aria-labelledby="vhost-editor-title" class="vhost-dialog" id="vhost-dialog">
<form class="card vh-form" id="vhost-form" novalidate="">
<div class="card-heading">
+1 -1
View File
@@ -4,7 +4,7 @@
const node = id => document.getElementById(id);
const themes = new Set(["dark", "light"]);
try { const cached = localStorage.getItem("rp-theme"); document.documentElement.dataset.theme = themes.has(cached) ? cached : "dark"; } catch { document.documentElement.dataset.theme = "dark"; }
const panelPaths = new Set(["/overview", "/vhosts", "/docker-vhosts", "/redirects", "/404-hosts", "/streams", "/acl-profiles", "/configuration", "/tls", "/cache", "/transform", "/logging", "/settings"]);
const panelPaths = new Set(["/overview", "/vhosts", "/docker-vhosts", "/redirects", "/404-hosts", "/streams", "/acl-profiles", "/configuration", "/tls", "/traffic", "/cache", "/transform", "/logging", "/settings"]);
const settingsTabs = new Set(["listeners", "connections", "docker-agents", "response", "cache", "transform", "headers", "forwarding", "pages", "logging", "prometheus", "administration", "tls-policy", "manual-config", "security"]);
let packs = new Map(), fallback = null, language = null, setupRequired = false, ready = false;
try { language = localStorage.getItem("rp-language"); } catch { }
+15 -1
View File
@@ -39,6 +39,20 @@
button.addEventListener("keydown", event => { const index = cacheTabs.indexOf(button); let next; if (event.key === "ArrowRight" || event.key === "ArrowDown") next = (index + 1) % cacheTabs.length; else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = (index + cacheTabs.length - 1) % cacheTabs.length; else if (event.key === "Home") next = 0; else if (event.key === "End") next = cacheTabs.length - 1; else return; event.preventDefault(); selectCacheTab(cacheTabs[next].dataset.cacheSettingsTab, true); });
}
selectCacheTab("general");
const loggingTabs = [...document.querySelectorAll("[data-logging-settings-tab]")];
function selectLoggingTab(name, focus = false) {
if (!loggingTabs.some(button => button.dataset.loggingSettingsTab === name)) return;
for (const button of loggingTabs) { const chosen = button.dataset.loggingSettingsTab === name; button.setAttribute("aria-selected", String(chosen)); button.tabIndex = chosen ? 0 : -1; if (chosen && focus) button.focus(); }
document.querySelectorAll("[data-logging-settings-page]").forEach(page => page.hidden = page.dataset.loggingSettingsPage !== name);
const loggingForm = $("logging-form"), errorLogForm = $("error-log-form");
if (loggingForm) loggingForm.hidden = name === "errors";
if (errorLogForm) errorLogForm.hidden = name !== "errors";
}
for (const button of loggingTabs) {
button.addEventListener("click", () => selectLoggingTab(button.dataset.loggingSettingsTab));
button.addEventListener("keydown", event => { const index = loggingTabs.indexOf(button); let next; if (event.key === "ArrowRight" || event.key === "ArrowDown") next = (index + 1) % loggingTabs.length; else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = (index + loggingTabs.length - 1) % loggingTabs.length; else if (event.key === "Home") next = 0; else if (event.key === "End") next = loggingTabs.length - 1; else return; event.preventDefault(); selectLoggingTab(loggingTabs[next].dataset.loggingSettingsTab, true); });
}
selectLoggingTab("outputs");
const httpTabs = [...document.querySelectorAll("[data-http-settings-tab]")];
function selectHttpTab(name, focus = false) {
if (!httpTabs.some(button => button.dataset.httpSettingsTab === name)) return;
@@ -192,6 +206,6 @@ curl --fail --show-error --request POST \
const text = await response.text(); if (epoch !== authEpoch || !authenticated) return; $("prom-preview-output").textContent = text.slice(0, 262144); $("prom-preview-output").hidden = false;
} catch (error) { if (epoch === authEpoch) toast(error.message, true); } finally { button.disabled = false; }
});
window.rustProxySettings = { select, selectHttpTab, syncLocation, render, current: () => active, reset() { select("listeners", false, false); selectCacheTab("general"); selectHttpTab("http2"); certificateApiState = null; certificateApiLoading = false; $("certificate-api-key-id").value = ""; $("certificate-api-comment").value = ""; $("certificate-api-token").value = ""; $("certificate-api-secret").textContent = ""; $("certificate-api-credentials").replaceChildren(); $("certificate-api-secret-wrap").hidden = true; $("prom-preview-output").textContent = ""; $("prom-preview-output").hidden = true; } };
window.rustProxySettings = { select, selectHttpTab, syncLocation, render, current: () => active, reset() { select("listeners", false, false); selectCacheTab("general"); selectLoggingTab("outputs"); selectHttpTab("http2"); certificateApiState = null; certificateApiLoading = false; $("certificate-api-key-id").value = ""; $("certificate-api-comment").value = ""; $("certificate-api-token").value = ""; $("certificate-api-secret").textContent = ""; $("certificate-api-credentials").replaceChildren(); $("certificate-api-secret-wrap").hidden = true; $("prom-preview-output").textContent = ""; $("prom-preview-output").hidden = true; } };
select(active); render();
})();
+134 -3
View File
@@ -4548,6 +4548,95 @@ dialog .window-heading {
margin: 14px 0
}
.traffic-chart-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin: 14px 0
}
.traffic-chart-card {
min-width: 0
}
.traffic-donut-layout {
display: grid;
grid-template-columns: 148px minmax(0, 1fr);
gap: 14px;
align-items: center
}
.traffic-donut {
position: relative;
width: 148px;
aspect-ratio: 1;
border-radius: 50%;
background: var(--panel2);
box-shadow: inset 0 0 0 1px var(--line)
}
.traffic-donut::before {
content: "";
position: absolute;
inset: 23%;
border-radius: 50%;
background: var(--panel);
box-shadow: 0 0 0 1px var(--line)
}
.traffic-donut>span {
position: absolute;
inset: 0;
z-index: 1;
display: grid;
place-items: center;
font: 700 16px var(--mono)
}
.traffic-chart-legend {
display: grid;
gap: 6px;
padding: 0;
margin: 0;
list-style: none;
min-width: 0
}
.traffic-chart-legend li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
font-size: 11px
}
.traffic-chart-legend li>span {
display: flex;
align-items: center;
gap: 7px;
min-width: 0
}
.traffic-chart-swatch {
width: 9px;
height: 9px;
border-radius: 50%;
flex: 0 0 auto
}
.traffic-chart-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap
}
.traffic-chart-legend b {
font: 700 10px var(--mono);
flex: 0 0 auto
}
.logging-grid label {
min-width: 0
}
@@ -4557,12 +4646,40 @@ dialog .window-heading {
max-width: none
}
#vhost-list {
.logging-settings-subform {
margin: 0;
min-width: 0
}
@media(max-width:1100px) {
.traffic-chart-grid {
grid-template-columns: repeat(2, minmax(0, 1fr))
}
}
@media(max-width:720px) {
.traffic-chart-grid,
.logging-grid {
grid-template-columns: 1fr
}
.traffic-donut-layout {
grid-template-columns: 120px minmax(0, 1fr)
}
.traffic-donut {
width: 120px
}
}
#vhost-list,
#web-service-list {
display: grid;
gap: 10px
}
#panel-vhosts .card-heading {
#panel-vhosts .card-heading,
#panel-web-server .card-heading {
flex-wrap: wrap
}
@@ -4573,7 +4690,8 @@ dialog .window-heading {
margin-left: auto
}
#vhost-search {
#vhost-search,
#web-service-search {
width: min(260px, 32vw);
min-width: 180px
}
@@ -7426,3 +7544,16 @@ body[data-density="compact"] .page-footer {
.cache-io-key {
color: var(--transform-chart);
}
/* Web Service editor mirrors the vhost tabbed configuration layout. */
.web-service-pages {
min-width: 0;
}
.web-service-page[hidden] {
display: none !important;
}
#web-service-dialog .web-service-page {
padding-top: 2px;
}
+135
View File
@@ -0,0 +1,135 @@
"use strict";
(() => {
let loading = false, timer = null, lastData = null;
const chartPalette = ["var(--accent)", "var(--green)", "var(--blue)", "var(--transform-chart)", "var(--red)", "var(--notice-text)", "var(--border-hover)", "var(--separator)"];
const scope = () => $("traffic-scope")?.value || "*";
const number = value => fmt(Number(value) || 0);
function configure(data) {
const selected = scope(), options = [el("option", t("logAllVhosts"))];
options[0].value = "*";
for (const vhost of data.vhosts || []) {
const option = el("option", `${vhost.id} / ${(vhost.hosts || []).join(", ")}`);
option.value = vhost.id;
options.push(option);
}
$("traffic-scope").replaceChildren(...options);
$("traffic-scope").value = options.some(option => option.value === selected) ? selected : "*";
}
function renderTop(id, entries, enabled = true) {
const body = $(`traffic-${id}`), empty = $(`traffic-${id}-empty`);
const values = enabled ? (entries || []) : [];
body.replaceChildren(...values.map(item => {
const row = el("tr");
const value = el("td", item.value || "-"); value.title = item.value || "-";
row.append(value, el("td", number(item.count)));
return row;
}));
empty.hidden = values.length > 0;
}
function renderDonut(id, entries, enabled = true) {
const chart = $(`traffic-chart-${id}`), legend = $(`traffic-chart-${id}-legend`), totalNode = $(`traffic-chart-${id}-total`);
if (!chart || !legend || !totalNode) return;
const values = (enabled ? (entries || []) : []).filter(item => Number(item.count) > 0);
const total = values.reduce((sum, item) => sum + Number(item.count || 0), 0);
totalNode.textContent = number(total);
if (!total) {
chart.style.background = "var(--panel2)";
chart.setAttribute("aria-label", t("trafficNoData"));
legend.replaceChildren(el("li", t("trafficNoData"), "fine"));
return;
}
const shown = values.slice(0, 7).map(item => ({ value: item.value || "-", count: Number(item.count) || 0 }));
if (values.length > shown.length) {
shown.push({ value: t("trafficOther"), count: values.slice(shown.length).reduce((sum, item) => sum + Number(item.count || 0), 0) });
}
let cursor = 0;
const segments = shown.map((item, index) => {
const start = cursor / total * 100;
cursor += item.count;
const end = cursor / total * 100;
return `${chartPalette[index % chartPalette.length]} ${start.toFixed(4)}% ${end.toFixed(4)}%`;
});
chart.style.background = `conic-gradient(${segments.join(", ")})`;
const heading = chart.closest(".traffic-chart-card")?.querySelector("h3")?.textContent || t("trafficDimensionsTitle");
chart.setAttribute("aria-label", `${heading}: ${shown.map(item => `${item.value} ${Math.round(item.count / total * 100)}%`).join(", ")}`);
legend.replaceChildren(...shown.map((item, index) => {
const row = el("li"), label = el("span"), swatch = el("i", undefined, "traffic-chart-swatch");
swatch.style.background = chartPalette[index % chartPalette.length];
const text = el("span", item.value || "-", "traffic-chart-label"); text.title = item.value || "-";
label.append(swatch, text);
row.append(label, el("b", `${(item.count / total * 100).toFixed(1)}%`));
return row;
}));
}
function render(data) {
lastData = data;
configure(data);
const analytics = data.analytics || {}, stats = analytics.data || {}, settings = data.settings || {};
$("traffic-requests").textContent = number(stats.requests);
$("traffic-errors").textContent = number(stats.errors);
const requests = Number(stats.requests) || 0, errors = Number(stats.errors) || 0;
$("traffic-error-ratio").textContent = `${requests ? (errors / requests * 100).toFixed(2) : "0.00"}%`;
$("traffic-response-bytes").textContent = fmtBytes(Number(stats.response_bytes) || 0);
$("traffic-request-bytes").textContent = `${t("trafficRequestBytes")}: ${fmtBytes(Number(stats.request_bytes) || 0)}`;
$("traffic-latency").textContent = stats.mean_duration_ms == null ? "-" : `${fmt(stats.mean_duration_ms, 2)} ms`;
$("traffic-dropped").textContent = `${t("trafficDroppedSamples")}: ${number(analytics.queue_dropped)}`;
const capture = [];
capture.push(settings.capture_user_agent ? t("trafficUserAgentEnabled") : t("trafficUserAgentDisabled"));
capture.push(settings.capture_country ? (data.geoip?.loaded ? t("trafficCountryEnabled") : t("trafficCountryNoDb")) : t("trafficCountryDisabled"));
$("traffic-capture-status").textContent = capture.join(" · ");
renderTop("countries", stats.countries, !!settings.capture_country);
renderTop("user-agents", stats.user_agents, !!settings.capture_user_agent);
renderTop("client-ips", stats.client_ips);
renderTop("paths", stats.paths);
renderTop("routes", stats.routes);
renderTop("methods", stats.methods);
renderTop("statuses", stats.statuses);
renderTop("cache", stats.cache);
renderDonut("countries", stats.countries, !!settings.capture_country);
renderDonut("user-agents", stats.user_agents, !!settings.capture_user_agent);
renderDonut("client-ips", stats.client_ips);
renderDonut("methods", stats.methods);
renderDonut("statuses", stats.statuses);
renderDonut("cache", stats.cache);
$("traffic-panel-state").textContent = `${t("trafficUpdated")}: ${new Date().toLocaleTimeString(locale())}`;
}
async function refresh() {
if (!authenticated || loading) return;
loading = true;
try {
const selected = scope();
const data = await api(`/api/traffic?vhost=${encodeURIComponent(selected)}`);
render(data);
} catch (error) {
$("traffic-panel-state").textContent = error.message;
} finally { loading = false; }
}
function reset() {
lastData = null;
if ($("traffic-panel-state")) $("traffic-panel-state").textContent = "";
for (const id of ["countries", "user-agents", "client-ips", "paths", "routes", "methods", "statuses", "cache"]) {
$(`traffic-${id}`)?.replaceChildren();
}
for (const id of ["countries", "user-agents", "client-ips", "methods", "statuses", "cache"]) {
const chart = $(`traffic-chart-${id}`), legend = $(`traffic-chart-${id}-legend`), total = $(`traffic-chart-${id}-total`);
if (chart) chart.style.background = "var(--panel2)";
legend?.replaceChildren();
if (total) total.textContent = "-";
}
}
function startTimer() {
if (timer) return;
timer = setInterval(() => {
if (!authenticated || !$("traffic-live")?.checked || $("panel-traffic")?.hidden) return;
void refresh();
}, 3000);
}
$("traffic-refresh")?.addEventListener("click", () => void refresh());
$("traffic-scope")?.addEventListener("change", () => void refresh());
$("traffic-settings")?.addEventListener("click", () => {
setPanel("settings");
window.rustProxySettings?.select("logging");
});
startTimer();
window.rustProxyTrafficPanel = { refresh, reset, rerender: () => lastData && render(lastData) };
})();
+103 -21
View File
@@ -5,6 +5,7 @@
let basicEditor = null, locationsReader = () => [], aclControl = null, editing = null, revision = null, dirty = false, initialized = false, readBindings = () => [], readHosts = () => [], countryRatesReader = () => [], pagesEditor = null, stagedCertificates = [], uploading = false, draftEpoch = 0;
const chartHistory = new Map();
const backendTrafficHistory = new Map();
const vhostTrafficHistory = new Map();
const dialog = $("vhost-dialog");
const form = $("vhost-form"), container = $("vhost-fields");
const text = (tag, key, cls) => { const node = el(tag, t(key), cls); node.dataset.i18n = key; return node; };
@@ -35,10 +36,17 @@
const nullable = (name, scope) => str(name, scope) || null;
const defaultProxyTimeouts = () => ({ connect_timeout_seconds: 10, response_header_timeout_seconds: 120, read_timeout_seconds: 60, send_timeout_seconds: 60 });
const changed = () => { dirty = true; };
const fillWebServices = (select, chosen = "") => {
const first = el("option", t("webServiceSelect")); first.value = "";
select.replaceChildren(first, ...(envelope?.config.web_services || []).map(service => { const option = el("option", service.name || service.id); option.value = service.id; return option; }));
if (chosen && !Array.from(select.options).some(option => option.value === chosen)) { const missing = el("option", `${chosen} / ${t("locationMissing")}`); missing.value = chosen; select.append(missing); }
select.value = chosen;
};
const allowDiscard = async () => { if (!dirty) return true; if (!(await appConfirm(t("vhUnsaved"), { danger: true, confirmLabel: t("discardAction") }))) return false; dirty = false; return true; };
const defaults = () => ({
id: "", enabled: true, force_ssl: false, forward_client_ip: true, websockets: false, websocket_legacy_support: false, websocket_rfc8441: false, websocket_rfc9220: false, http2_enabled: true, http3_enabled: true, cloudflare_client_ip: false, timing_headers: false, backend_header: false, backend_timing_header: false,
frontend: { hosts: [], listener_ids: [] },
source: { type: "reverse_proxy" },
backends: [{ id: "origin-1", url: "", weight: 1, enabled: true, pool_eligible: true, allow_private: true, accept_local_listener_risk: false, preserve_host: true, protocol: "http1", tls_verify: false }],
cache: { enabled: false }, transform: { enabled: false }, rate_limit: {}, routes: [], headers: {}, acl_profile_id: null, acl: null, tls_policy: null, proxy_timeouts: defaultProxyTimeouts()
});
@@ -168,8 +176,8 @@
identity.append(title, endpoint); head.append(identity); const remove = button("remove", () => { row.remove(); changed(); }); head.append(remove); row.append(head);
const main = el("div", undefined, "vh-grid vh-backend-main"); row.append(main);
const id = field(main, "member-id", "vhMemberId", value.id, "text", { required: true, pattern: "[A-Za-z0-9_-]{1,64}", maxLength: 64 });
const url = field(main, "member-url", "vhUrl", value.url, "url", { required: true, maxLength: 512 });
const id = field(main, "member-id", "vhMemberId", value.id, "text", { pattern: "[A-Za-z0-9_-]{1,64}", maxLength: 64 });
const url = field(main, "member-url", "vhUrl", value.url, "url", { maxLength: 512 });
field(main, "member-weight", "vhWeight", value.weight ?? 1, "number", { required: true, min: 1, max: 256 });
const protocol = field(main, "member-protocol", "vhProtocol", value.protocol || "http1", "select", { values: [["http1", "vhProtocolHttp1"], ["auto", "vhProtocolAuto"], ["http2", "vhProtocolHttp2"]] });
@@ -283,12 +291,14 @@
function readRows() {
return [...rows.children].map(r => {
const action = str("location-action", r), rewriteMode = str("location-rewrite-mode", r);
const handled = action !== "return";
return {
id: str("location-id", r), path_prefix: str("location-path", r), match_type: str("location-match", r),
source: action === "web_server" ? { type: "web_server", web_service_id: str("location-web-service", r) } : { type: "reverse_proxy" },
backend_id: action === "proxy" ? (str("location-backend", r) || null) : null,
enabled: yes("location-enabled", r), cache: action === "proxy" ? (str("location-cache", r) === "" ? null : str("location-cache", r) === "on") : false,
acl: r.readAcl(), headers: r.readHeaders(),
rewrite: { mode: action === "proxy" ? rewriteMode : "preserve", value: action === "proxy" && rewriteMode !== "preserve" ? read("location-rewrite-value", r).value.trim() : "" },
rewrite: { mode: handled ? rewriteMode : "preserve", value: handled && rewriteMode !== "preserve" ? read("location-rewrite-value", r).value.trim() : "" },
response: action === "return" ? { status: Number(str("location-response-status", r)), content_type: str("location-response-type", r), body: read("location-response-body", r).value } : null
};
});
@@ -303,8 +313,10 @@
field(body, "location-id", "locationId", value.id || `location-${suffix}`, "text", { required: true, pattern: "[A-Za-z0-9_-]{1,64}", maxLength: 64 });
const match = field(body, "location-match", "locationMatchType", value.match_type || "prefix", "select", { values: [["prefix", "locationMatchPrefix"], ["prefix_priority", "locationMatchPriority"], ["exact", "locationMatchExact"], ["regex", "locationMatchRegex"], ["regex_insensitive", "locationMatchRegexI"]] });
const path = field(body, "location-path", "locationPathPattern", value.path_prefix || "/", "text", { required: true, maxLength: 1024 }); path.placeholder = "/api or ^/api/v[0-9]+/";
const action = field(body, "location-action", "locationAction", value.response ? "return" : "proxy", "select", { values: [["proxy", "locationProxyAction"], ["return", "locationReturnAction"]] });
const initialAction = value.response ? "return" : value.source?.type === "web_server" ? "web_server" : "proxy";
const action = field(body, "location-action", "locationAction", initialAction, "select", { values: [["proxy", "locationProxyAction"], ["web_server", "locationWebServerAction"], ["return", "locationReturnAction"]] });
const backend = field(body, "location-backend", "backend", value.backend_id || "", "select"); memberOptions(backend, value.backend_id || "");
const webService = field(body, "location-web-service", "webService", value.source?.web_service_id || "", "select"); fillWebServices(webService, value.source?.web_service_id || "");
field(body, "location-enabled", "enabled", value.enabled !== false, "checkbox");
const cache = field(body, "location-cache", "cacheEnabled", value.cache === true ? "on" : value.cache === false ? "off" : "", "select", { values: [["", "inherit"], ["on", "enabled"], ["off", "notEnabled"]] });
@@ -339,14 +351,15 @@
actions.append(dedicated, button("remove", () => { row.remove(); sync(); })); body.append(actions);
const syncUi = () => {
const isReturn = action.value === "return", isRegex = match.value === "regex" || match.value === "regex_insensitive";
backend.disabled = isReturn; cache.disabled = isReturn; dedicated.disabled = isReturn;
const isReturn = action.value === "return", isWeb = action.value === "web_server", isProxy = action.value === "proxy", isRegex = match.value === "regex" || match.value === "regex_insensitive";
backend.disabled = !isProxy; cache.disabled = !isProxy; dedicated.disabled = !isProxy;
webService.disabled = !isWeb; webService.required = isWeb;
returnBox.hidden = !isReturn; rewriteMode.disabled = isReturn; rewriteValue.disabled = isReturn || rewriteMode.value === "preserve" || rewriteMode.value === "strip_prefix";
for (const option of rewriteMode.options) if (option.value === "regex") option.disabled = !isRegex;
if (!isRegex && rewriteMode.value === "regex") rewriteMode.value = "preserve";
path.placeholder = isRegex ? "^/api/v[0-9]+/" : "/api/";
const id = str("location-id", row) || t("locationId"), pattern = str("location-path", row) || "/";
summary.textContent = `${id} · ${t(`locationMatch_${match.value}`)} · ${pattern} · ${t(isReturn ? "locationReturnShort" : "locationProxyShort")}`;
summary.textContent = `${id} · ${t(`locationMatch_${match.value}`)} · ${pattern} · ${t(isReturn ? "locationReturnShort" : isWeb ? "locationWebServerShort" : "locationProxyShort")}`;
previewStatus.hidden = yes("location-enabled", row);
previewValues.match.textContent = `${t(`locationMatch_${match.value}`)} · ${pattern}`;
@@ -356,12 +369,18 @@
previewValues.rewrite.textContent = "-";
previewValues.cache.textContent = t("notEnabled");
} else {
const selected = backend.selectedOptions?.[0]?.textContent || t("locationPool");
previewValues.handling.textContent = `${t("locationProxyAction")}: ${selected}`;
if (isWeb) {
const selected = webService.selectedOptions?.[0]?.textContent || t("webServiceSelect");
previewValues.handling.textContent = `${t("locationWebServerAction")}: ${selected}`;
previewValues.cache.textContent = t("notEnabled");
} else {
const selected = backend.selectedOptions?.[0]?.textContent || t("locationPool");
previewValues.handling.textContent = `${t("locationProxyAction")}: ${selected}`;
previewValues.cache.textContent = t(cache.value === "on" ? "enabled" : cache.value === "off" ? "notEnabled" : "inherit");
}
const rewriteLabels = { preserve: "locationRewritePreserve", strip_prefix: "locationRewriteStrip", replace_prefix: "locationRewriteReplace", regex: "locationRewriteRegex" };
const replacement = read("location-rewrite-value", row).value.trim();
previewValues.rewrite.textContent = `${t(rewriteLabels[rewriteMode.value] || "locationRewritePreserve")}${replacement && !["preserve", "strip_prefix"].includes(rewriteMode.value) ? `: ${replacement}` : ""} · ${t("locationPreviewQueryPreserved")}`;
previewValues.cache.textContent = t(cache.value === "on" ? "enabled" : cache.value === "off" ? "notEnabled" : "inherit");
}
previewValues.access.textContent = yes("acl-use", row) ? t("locationPreviewCustomAcl") : t("locationPreviewVhostAcl");
try {
@@ -423,6 +442,10 @@
container.replaceChildren(); $("vhost-editing").textContent = t(editing ? "vhEditLabel" : "vhNewLabel");
let g = group("vhEditor"); field(g, "id", "vhId", value.id, "text", { required: true, maxLength: 64, pattern: "[A-Za-z0-9_-]{1,64}" }); if (editing) note(g, "vhIdRenameHelp");
readHosts = hostTagEditor(g, value.frontend?.hosts || []); field(g, "enabled", "vhEnabled", value.enabled !== false, "checkbox");
const mainSource = field(g, "source-type", "sourceType", value.source?.type || "reverse_proxy", "select", { values: [["reverse_proxy", "sourceReverseProxy"], ["web_server", "sourceWebServer"]] });
const mainWebService = field(g, "source-web-service", "webService", value.source?.web_service_id || "", "select"); fillWebServices(mainWebService, value.source?.web_service_id || "");
const syncMainSource = () => { const web = mainSource.value === "web_server"; mainWebService.disabled = !web; mainWebService.required = web; }; mainSource.addEventListener("change", syncMainSource); syncMainSource();
note(g, "sourceHelp");
const websocketEnabled = field(g, "websockets", "vhWebsockets", value.websockets !== false, "checkbox");
const websocketModeValue = value.websocket_rfc8441 === true && value.websocket_rfc9220 === true ? "h2h3" : value.websocket_rfc8441 === true ? "h2" : value.websocket_rfc9220 === true ? "h3" : "h1";
const websocketMode = field(g, "websocket-mode", "vhWebsocketMode", websocketModeValue, "select", { values: [["h1", "vhWebsocketH1"], ["h2", "vhWebsocketH2Fallback"], ["h3", "vhWebsocketH3Fallback"], ["h2h3", "vhWebsocketH2H3Fallback"]] });
@@ -600,7 +623,7 @@
authMode.addEventListener("change", syncAuth); syncAuth();
g = group("vhResponsePolicy"); const server = value.server_header, diagnostics = value.diagnostic_headers;
const serverMode = field(g, "server-mode", "serverHeader", server?.mode || "inherit", "select", { values: [["inherit", "vhInherit"], ["preserve", "serverPreserve"], ["hide", "serverHide"], ["custom", "serverCustom"]] });
const serverValue = field(g, "server-value", "serverValue", server?.value || "RustProxy/v0.24.10", "text", { maxLength: 256 });
const serverValue = field(g, "server-value", "serverValue", server?.value || "RustProxy/v0.25.0", "text", { maxLength: 256 });
const syncServer = () => { serverValue.disabled = serverMode.value !== "custom"; serverValue.required = !serverValue.disabled; }; serverMode.addEventListener("change", syncServer); syncServer();
const inheritDiagnostics = field(g, "diag-inherit", "diagInherit", !diagnostics, "checkbox"), diagInputs = [];
for (const [name, key] of [["request_id", "diagRequestId"]]) diagInputs.push(field(g, `diag-${name}`, key, diagnostics?.[name] ?? envelope?.config.diagnostic_headers?.[name], "checkbox"));
@@ -651,9 +674,14 @@
paths = JSON.parse(str("paths")); acl = JSON.parse(str("acl")); headers = JSON.parse(str("headers"));
if (!Array.isArray(paths) || (acl !== null && (typeof acl !== "object" || Array.isArray(acl))) || !headers || typeof headers !== "object" || Array.isArray(headers)) throw Error();
} catch { throw new Error(t("vhJsonInvalid")); }
const members = [...form.querySelectorAll(".vh-member")].map(row => ({ id: str("member-id", row), url: str("member-url", row), weight: num("member-weight", row), enabled: yes("member-enabled", row), pool_eligible: yes("member-pool", row), backup: yes("member-backup", row), drain: yes("member-drain", row), allow_private: yes("member-private", row), accept_local_listener_risk: yes("member-local-risk", row), preserve_host: yes("member-host", row), protocol: str("member-protocol", row), address_allowlist: lines(str("member-allowlist", row)), tls_server_name: nullable("member-tls-name", row), tls_verify: yes("member-tls-verify", row), tls_ca_pem: nullable("member-ca", row), max_concurrent_requests: num("member-max-concurrent", row), queue_timeout_ms: num("member-queue-timeout", row) }));
const members = [...form.querySelectorAll(".vh-member")].map(row => ({ id: str("member-id", row), url: str("member-url", row), weight: num("member-weight", row), enabled: yes("member-enabled", row), pool_eligible: yes("member-pool", row), backup: yes("member-backup", row), drain: yes("member-drain", row), allow_private: yes("member-private", row), accept_local_listener_risk: yes("member-local-risk", row), preserve_host: yes("member-host", row), protocol: str("member-protocol", row), address_allowlist: lines(str("member-allowlist", row)), tls_server_name: nullable("member-tls-name", row), tls_verify: yes("member-tls-verify", row), tls_ca_pem: nullable("member-ca", row), max_concurrent_requests: num("member-max-concurrent", row), queue_timeout_ms: num("member-queue-timeout", row) })).filter(member => member.url);
const bindingSelection = readBindings();
if (!bindingSelection.length || !members.length) throw new Error(t("vhJsonInvalid"));
const mainSourceType = str("source-type");
const needsProxy = mainSourceType === "reverse_proxy" || paths.some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy");
if (!bindingSelection.length) throw new Error(t("vhListenerRequired"));
if (needsProxy && !members.length) throw new Error(t("vhBackendRequired"));
if (mainSourceType === "web_server" && !str("source-web-service")) throw new Error(t("webServiceRequired"));
if (paths.some(route => !route.response && route.source?.type === "web_server" && !route.source.web_service_id)) throw new Error(t("webServiceRequired"));
if (yes("force-ssl")) {
const tlsPorts = new Set(); let tlsSelected = false, httpSelected = false;
for (const id of bindingSelection) {
@@ -681,7 +709,7 @@
if (JSON.stringify(selected) !== JSON.stringify(globalPolicy)) throw new Error(t("vhTlsHttp3Conflict"));
}
return {
id: str("id"), enabled: yes("enabled"), force_ssl: yes("force-ssl"), forward_client_ip: yes("forward-client-ip"), websockets: yes("websockets"), websocket_legacy_support: yes("websockets") && yes("websocket-legacy-support"), websocket_rfc8441: yes("websockets") && ["h2", "h2h3"].includes(str("websocket-mode")), websocket_rfc9220: yes("websockets") && ["h3", "h2h3"].includes(str("websocket-mode")), http2_enabled: yes("http2-enabled"), http3_enabled: yes("http3-enabled"), cloudflare_client_ip: yes("cloudflare-client-ip"), timing_headers: yes("timing-headers"), backend_header: yes("backend-header"), backend_timing_header: yes("backend-timing-header"), basic_auth: basicEditor.read(), certificate_id: nullable("certificate-id"), tls_policy: yes("tls-policy-custom") ? { tls12: yes("tls-policy-12"), tls13: yes("tls-policy-13"), cipher_suites: lines(str("tls-policy-ciphers")) } : null, cache: collectCache(), transform: collectTransform(), pages: pagesEditor.read(), frontend: { hosts: readHosts(), listener_ids: bindingSelection }, acl_profile_id: nullable("acl-profile-id"),
id: str("id"), enabled: yes("enabled"), source: mainSourceType === "web_server" ? { type: "web_server", web_service_id: str("source-web-service") } : { type: "reverse_proxy" }, force_ssl: yes("force-ssl"), forward_client_ip: yes("forward-client-ip"), websockets: yes("websockets"), websocket_legacy_support: yes("websockets") && yes("websocket-legacy-support"), websocket_rfc8441: yes("websockets") && ["h2", "h2h3"].includes(str("websocket-mode")), websocket_rfc9220: yes("websockets") && ["h3", "h2h3"].includes(str("websocket-mode")), http2_enabled: yes("http2-enabled"), http3_enabled: yes("http3-enabled"), cloudflare_client_ip: yes("cloudflare-client-ip"), timing_headers: yes("timing-headers"), backend_header: yes("backend-header"), backend_timing_header: yes("backend-timing-header"), basic_auth: basicEditor.read(), certificate_id: nullable("certificate-id"), tls_policy: yes("tls-policy-custom") ? { tls12: yes("tls-policy-12"), tls13: yes("tls-policy-13"), cipher_suites: lines(str("tls-policy-ciphers")) } : null, cache: collectCache(), transform: collectTransform(), pages: pagesEditor.read(), frontend: { hosts: readHosts(), listener_ids: bindingSelection }, acl_profile_id: nullable("acl-profile-id"),
proxy_timeouts: { connect_timeout_seconds: num("proxy-connect-timeout"), response_header_timeout_seconds: num("proxy-header-timeout"), read_timeout_seconds: num("proxy-read-timeout"), send_timeout_seconds: num("proxy-send-timeout") },
rate_limit: { enabled: yes("rate-enabled"), requests: num("rate-requests"), window_seconds: num("rate-window"), burst: num("rate-burst"), country_access: { mode: str("country-mode"), countries: lines(str("country-list")).map(value => value.toUpperCase()) }, country_limits: countryRatesReader() },
server_header: str("server-mode") === "inherit" ? null : { mode: str("server-mode"), value: str("server-value") },
@@ -710,6 +738,11 @@
if (!envelope) return;
if (!initialized) load(defaults(), [], false);
const config = envelope.config;
const configuredHasProxySource = (config.vhosts || []).some(vhost =>
vhost.source?.type !== "web_server" || (vhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy")
);
const diagnosticsCard = $("backend-diagnostics-card");
if (diagnosticsCard) diagnosticsCard.hidden = !configuredHasProxySource;
const query = ($("vhost-search")?.value || "").trim().toLowerCase();
const visibleVhosts = (config.vhosts || []).filter(v => {
if (!query) return true;
@@ -717,6 +750,8 @@
v.id,
...(v.frontend?.hosts || []),
...(v.backends || []).flatMap(backend => [backend.id, backend.url]),
v.source?.web_service_id,
...(v.routes || []).map(route => route.source?.web_service_id),
].filter(Boolean).join(" ").toLowerCase();
return haystack.includes(query);
});
@@ -725,6 +760,7 @@
const head = el("div", undefined, "vhost-card-head"), identity = el("div", undefined, "vhost-card-identity");
const title = el("div", undefined, "vhost-title-line"); title.append(el("strong", v.id, "entity-title"));
const state = el("span", t(v.enabled === false ? "vhDisabled" : "vhEnabled"), `pill ${v.enabled === false ? "" : "live"}`); title.append(state);
if (v.source?.type === "web_server") title.append(el("span", `${t("webServer")}: ${v.source.web_service_id}`, "pill"));
identity.append(title, el("div", (v.frontend?.hosts || []).join(", ") || "-", "vhost-hosts")); head.append(identity);
const actions = el("div", undefined, "entity-actions");
const configuredHosts = (v.frontend?.hosts || []).filter(Boolean);
@@ -762,7 +798,11 @@
["vhCertificate", v.certificate_id || t("vhCertificateDefault")],
["vhAclProfile", v.acl_profile_id ? ((config.acl_profiles || []).find(profile => profile.id === v.acl_profile_id)?.name || v.acl_profile_id) : t("vhAclProfileNone")],
["cache", v.cache?.enabled === true ? t("enabled") : v.cache?.enabled === false ? t("notEnabled") : t("inherit")],
["vhBackendPool", `${(v.backends || []).length}`],
["vhBackendPool", v.source?.type === "web_server"
? ((v.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy") && (v.backends || []).length
? `${t("webServer")} + ${(v.backends || []).length}`
: t("webServer"))
: `${(v.backends || []).length}`],
];
for (const [key, value] of detailItems) { const item = el("div", undefined, "vhost-detail"); item.append(text("span", key, "small-label"), el("b", value)); details.append(item); }
box.append(details);
@@ -773,7 +813,10 @@
box.append(live);
const chart = el("div", undefined, "vhost-chart"); const chartHead = el("div", undefined, "vhost-chart-head"); chartHead.append(text("span", "vhLifetimeChart", "small-label")); const chartValue = el("span", "-", "fine"); chartValue.dataset.chartValueFor = v.id; chartHead.append(chartValue); chart.append(chartHead);
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.setAttribute("viewBox", "0 0 320 54"); svg.setAttribute("preserveAspectRatio", "none"); svg.dataset.chartFor = v.id; svg.setAttribute("aria-label", t("vhLifetimeChart")); const polyline = document.createElementNS("http://www.w3.org/2000/svg", "polyline"); polyline.setAttribute("class", "vhost-chart-line"); svg.append(polyline); chart.append(svg); box.append(chart);
const health = el("p", t("vhHealthEmpty"), "vh-health-summary"); health.dataset.healthFor = v.id; box.append(health);
const hasProxySource = v.source?.type !== "web_server" || (v.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy");
if (hasProxySource) {
const health = el("p", t("vhHealthEmpty"), "vh-health-summary"); health.dataset.healthFor = v.id; box.append(health);
}
const policy = el("div", undefined, "vhost-policy-pills");
if (v.basic_auth?.enabled) policy.append(el("small", t("authBasic"), "pill"));
if (v.tinyauth?.enabled) policy.append(el("small", "Tinyauth", "pill"));
@@ -809,10 +852,20 @@
backendTrafficHistory.set(key, { total, at: now, snapshot, rate });
return rate;
}
function vhostTrafficRate(id, metrics, snapshot, now, seen) {
seen.add(id);
const total = Math.max(0, Number(metrics?.bytes_in) || 0) + Math.max(0, Number(metrics?.bytes_out) || 0);
const previous = vhostTrafficHistory.get(id);
if (previous?.snapshot === snapshot) return previous.rate;
const elapsed = previous ? Math.max(.001, (now - previous.at) / 1000) : 0;
const rate = previous && total >= previous.total ? (total - previous.total) / elapsed : 0;
vhostTrafficHistory.set(id, { total, at: now, snapshot, rate });
return rate;
}
function renderStatus(status) {
form.querySelectorAll(".vh-member").forEach(row => row.syncLocalRisk?.());
const rows = [], summaries = new Map(), aggregate = new Map();
const trafficSnapshot = status, trafficNow = performance.now(), seenTraffic = new Set();
const trafficSnapshot = status, trafficNow = performance.now(), seenTraffic = new Set(), seenVhostTraffic = new Set();
const statusKeys = { up: "vhUp", down: "vhDown", checking: "vhChecking", draining: "vhDraining", disabled: "vhDisabled" };
const configuredVhosts = new Map((envelope?.config.vhosts || []).map(vhost => [vhost.id, vhost]));
for (const pool of status.backend_pools || []) {
@@ -846,10 +899,36 @@
});
}
for (const key of backendTrafficHistory.keys()) if (!seenTraffic.has(key)) backendTrafficHistory.delete(key);
$("pool-rows").replaceChildren(...rows); $("pool-empty").hidden = rows.length > 0;
for (const [id, vhost] of configuredVhosts) {
const existing = aggregate.get(id) || {};
const metrics = status.vhost_metrics?.[id];
const hasProxySource = vhost.source?.type !== "web_server" || (vhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy");
const disabled = vhost.enabled === false;
aggregate.set(id, {
...existing,
disabled,
staticOnly: !hasProxySource,
healthMode: existing.healthMode,
up: existing.up ?? 0,
total: existing.total ?? 0,
active: metrics ? Number(metrics.active) || 0 : existing.active ?? 0,
requests: metrics ? Number(metrics.requests) || 0 : existing.requests ?? 0,
errors: metrics ? Number(metrics.errors) || 0 : existing.errors ?? 0,
traffic: metrics ? vhostTrafficRate(id, metrics, trafficSnapshot, trafficNow, seenVhostTraffic) : existing.traffic ?? 0,
latency: metrics ? Number(metrics.ewma_latency_ms) || 0 : existing.latency ?? 0,
});
}
for (const key of vhostTrafficHistory.keys()) if (!seenVhostTraffic.has(key)) vhostTrafficHistory.delete(key);
const hasConfiguredProxySource = [...configuredVhosts.values()].some(vhost =>
vhost.source?.type !== "web_server" || (vhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy")
);
$("pool-rows").replaceChildren(...rows);
$("pool-empty").hidden = rows.length > 0 || !hasConfiguredProxySource;
const backendDiagnosticsCard = $("backend-diagnostics-card");
if (backendDiagnosticsCard) backendDiagnosticsCard.hidden = !hasConfiguredProxySource;
document.querySelectorAll("[data-health-for]").forEach(node => node.textContent = summaries.get(node.dataset.healthFor) || t("vhHealthEmpty"));
const fill = (attr, format) => document.querySelectorAll(`[data-${attr.replace(/[A-Z]/g, m => `-${m.toLowerCase()}`)}]`).forEach(node => { const data = aggregate.get(node.dataset[attr]); node.textContent = data ? format(data) : "-"; });
fill("vhostHealth", d => d.disabled ? t("vhDisabled") : d.healthMode === "off" ? t("vhHealthDisabled") : `${d.up}/${d.total} ${t("vhUp")}`);
fill("vhostHealth", d => d.disabled ? t("vhDisabled") : d.staticOnly ? t("vhUp") : d.healthMode === "off" ? t("vhHealthDisabled") : `${d.up}/${d.total} ${t("vhUp")}`);
fill("vhostRequests", d => fmt(d.requests)); fill("vhostActive", d => fmt(d.active)); fill("vhostErrors", d => fmt(d.errors));
fill("vhostLatency", d => d.latency > 0 ? `${fmt(d.latency, 1)} ms` : "-"); fill("vhostTraffic", d => `${rateBytes(d.traffic)}/s`);
const now = performance.now();
@@ -863,7 +942,10 @@
const displayPoints = history.pendingCount ? [...history.points, history.pendingSum / history.pendingCount] : history.points; const actualPeak = Math.max(0, ...displayPoints), scalePeak = Math.max(1, actualPeak); const points = displayPoints.map((value, index) => `${index * 320 / Math.max(1, displayPoints.length - 1)},${50 - value / scalePeak * 44}`).join(" ");
const svg = document.querySelector(`[data-chart-for="${CSS.escape(id)}"]`); svg?.querySelector("polyline")?.setAttribute("points", points); const label = document.querySelector(`[data-chart-value-for="${CSS.escape(id)}"]`); if (label) label.textContent = `${rate.toFixed(1)} ${t("vhReqPerSecond")} · ${t("vhPeak")} ${actualPeak.toFixed(1)}`;
}
$("vhost-health-live").textContent = editing ? (summaries.get(editing) || t("vhHealthEmpty")) : t("vhNewHealthHint");
const editingVhost = editing ? configuredVhosts.get(editing) : null;
const editingHasProxy = editingVhost && (editingVhost.source?.type !== "web_server" || (editingVhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy"));
$("vhost-health-live").hidden = !!editingVhost && !editingHasProxy;
$("vhost-health-live").textContent = editing ? (editingHasProxy ? (summaries.get(editing) || t("vhHealthEmpty")) : "") : t("vhNewHealthHint");
}
form.addEventListener("input", changed); form.addEventListener("change", changed);
form.addEventListener("submit", async event => {
@@ -894,6 +976,6 @@
});
$("pool-open").addEventListener("click", () => setPanel("vhosts"));
$("location-help-close")?.addEventListener("click", () => $("location-help-dialog")?.close());
window.rustProxyVhosts = { render, renderStatus, reset() { dialog.close(); draftEpoch++; stagedCertificates = []; uploading = false; editing = null; revision = null; dirty = false; initialized = false; chartHistory.clear(); container.replaceChildren(); $("vhost-search").value = ""; $("vhost-list").replaceChildren(); $("pool-rows").replaceChildren(); } };
window.rustProxyVhosts = { render, renderStatus, reset() { dialog.close(); draftEpoch++; stagedCertificates = []; uploading = false; editing = null; revision = null; dirty = false; initialized = false; chartHistory.clear(); backendTrafficHistory.clear(); vhostTrafficHistory.clear(); container.replaceChildren(); $("vhost-search").value = ""; $("vhost-list").replaceChildren(); $("pool-rows").replaceChildren(); } };
if (envelope) render(); if (latestStatus) renderStatus(latestStatus);
})();
+438
View File
@@ -0,0 +1,438 @@
"use strict";
(() => {
let editing = null;
const form = $("web-service-form"), dialog = $("web-service-dialog");
const lines = value => value.split(/[\n,]+/).map(item => item.trim()).filter(Boolean);
const strictLines = value => value.split(/\n+/).map(item => item.trim()).filter(Boolean);
const mimeLines = value => Object.entries(value || {}).map(([extension, mime]) => `${extension} = ${mime}`).join("\n");
const mapLines = value => Object.entries(value || {}).map(([name, item]) => `${name} = ${item}`).join("\n");
const cacheLines = value => (value || []).map(rule => `${rule.matcher} = ${rule.cache_control}`).join("\n");
const rewriteLines = value => (value || []).map(rule => `${rule.pattern} => ${rule.status ? `${rule.status}:` : ""}${rule.replacement}`).join("\n");
const staticCacheRules = () => [
{ matcher: "ext:html", cache_control: "no-cache" },
{ matcher: "ext:htm", cache_control: "no-cache" },
{ matcher: "ext:css", cache_control: "public, max-age=604800" },
{ matcher: "ext:js", cache_control: "public, max-age=604800" },
{ matcher: "ext:mjs", cache_control: "public, max-age=604800" },
{ matcher: "mime:image/", cache_control: "public, max-age=2592000" },
{ matcher: "mime:font/", cache_control: "public, max-age=2592000" },
];
const phpFrontController = () => ["$uri", "$uri/", "/index.php?$query_string"];
const presetHints = {
static: "webServicePresetHintStatic", spa: "webServicePresetHintSpa", php_generic: "webServicePresetHintPhpGeneric",
wordpress: "webServicePresetHintWordpress", drupal: "webServicePresetHintDrupal", laravel: "webServicePresetHintLaravel",
symfony: "webServicePresetHintSymfony", joomla: "webServicePresetHintJoomla", prestashop: "webServicePresetHintPrestashop",
downloads: "webServicePresetHintDownloads"
};
function parseMimeTypes(value) {
const result = {};
for (const line of strictLines(value)) {
const match = line.match(/^\.?([A-Za-z0-9_-]{1,32})\s*=\s*(.+)$/);
if (!match) throw new Error(t("webServiceMimeInvalid"));
const extension = match[1].toLowerCase(), mime = match[2].trim();
if (!mime || Object.hasOwn(result, extension)) throw new Error(t("webServiceMimeInvalid"));
result[extension] = mime;
}
return result;
}
function parseKeyValueLines(value, errorKey, normalizeKey = key => key) {
const result = {};
for (const line of strictLines(value)) {
const split = line.indexOf("=");
if (split <= 0) throw new Error(t(errorKey));
const key = normalizeKey(line.slice(0, split).trim()), item = line.slice(split + 1).trim();
if (!key || !item || Object.hasOwn(result, key)) throw new Error(t(errorKey));
result[key] = item;
}
return result;
}
const reservedFastCgiParams = new Set([
"REQUEST_METHOD", "SCRIPT_FILENAME", "SCRIPT_NAME", "QUERY_STRING", "REQUEST_URI", "DOCUMENT_ROOT",
"SERVER_PROTOCOL", "SERVER_NAME", "SERVER_PORT", "REMOTE_ADDR", "REMOTE_PORT", "HTTPS",
"GATEWAY_INTERFACE", "SERVER_SOFTWARE", "REDIRECT_STATUS", "CONTENT_TYPE", "CONTENT_LENGTH",
"PATH_INFO", "PATH_TRANSLATED", "PHP_VALUE", "PHP_ADMIN_VALUE"
]);
function parseFastCgiParams(value) {
const result = parseKeyValueLines(value, "webServicePhpCustomParamsInvalid", key => key.toUpperCase());
const entries = Object.entries(result);
if (entries.length > 64) throw new Error(t("webServicePhpCustomParamsInvalid"));
for (const [name, item] of entries) {
if (!/^[A-Z][A-Z0-9_]{0,63}$/.test(name) || name.startsWith("HTTP_") || reservedFastCgiParams.has(name) || item.length > 8192 || /[\x00-\x1f\x7f]/.test(item)) {
throw new Error(t("webServicePhpCustomParamsInvalid"));
}
}
return result;
}
function parsePhpValues(value) {
const result = parseKeyValueLines(value, "webServicePhpValuesInvalid", key => key.toLowerCase());
const entries = Object.entries(result);
if (entries.length > 64) throw new Error(t("webServicePhpValuesInvalid"));
for (const [name, item] of entries) {
if (!/^[a-z0-9_.-]{1,64}$/.test(name) || item.length > 8192 || /[\x00-\x1f\x7f]/.test(item)) {
throw new Error(t("webServicePhpValuesInvalid"));
}
}
return result;
}
function validatePhpCustomData(customParams, phpValues) {
const size = [...Object.entries(customParams), ...Object.entries(phpValues)]
.reduce((total, [name, value]) => total + name.length + value.length + 2, 0);
if (size > 128 * 1024) throw new Error(t("webServicePhpCustomDataTooLarge"));
}
function parseCacheRules(value) {
const result = [];
for (const line of strictLines(value)) {
const split = line.indexOf("=");
if (split <= 0) throw new Error(t("webServiceCacheRulesInvalid"));
const matcher = line.slice(0, split).trim().toLowerCase(), cache_control = line.slice(split + 1).trim();
if (!/^(?:ext:[A-Za-z0-9_-]{1,32}|mime:.+)$/.test(matcher) || !cache_control) throw new Error(t("webServiceCacheRulesInvalid"));
result.push({ matcher, cache_control });
}
return result;
}
function parseRewrites(value) {
const result = [];
for (const line of strictLines(value)) {
const split = line.indexOf("=>");
if (split <= 0) throw new Error(t("webServiceRewritesInvalid"));
const pattern = line.slice(0, split).trim();
let replacement = line.slice(split + 2).trim(), status = null;
const redirect = replacement.match(/^(301|302|307|308):(\/.*)$/);
if (redirect) { status = Number(redirect[1]); replacement = redirect[2]; }
if (!pattern || !replacement.startsWith("/")) throw new Error(t("webServiceRewritesInvalid"));
result.push({ pattern, replacement, status });
}
return result;
}
const tabDefinitions = [
["general", "webServiceTabGeneral"],
["routing", "webServiceTabRouting"],
["htaccess", "webServiceTabHtaccess"],
["mime", "webServiceTabMime"],
["cache", "webServiceTabCacheHeaders"],
["delivery", "webServiceTabDelivery"],
["php", "webServiceTabPhp"],
["security", "webServiceTabSecurity"],
];
function activateTab(name) {
document.querySelectorAll("#web-service-tabs button").forEach(button => {
const active = button.dataset.tab === name;
button.classList.toggle("active", active);
button.setAttribute("aria-pressed", String(active));
button.setAttribute("aria-selected", String(active));
button.tabIndex = active ? 0 : -1;
});
document.querySelectorAll("[data-web-service-page]").forEach(page => { page.hidden = page.dataset.webServicePage !== name; });
}
function initTabs() {
const tabs = $("web-service-tabs");
tabs.replaceChildren();
tabs.setAttribute("role", "tablist");
for (const [name, key] of tabDefinitions) {
const button = el("button", t(key), "quiet");
button.type = "button";
button.dataset.tab = name;
button.dataset.i18n = key;
button.setAttribute("role", "tab");
button.addEventListener("click", () => activateTab(name));
tabs.append(button);
}
activateTab("general");
}
function syncPhp() {
const enabled = $("web-service-php-enabled").checked;
for (const id of [
"web-service-php-endpoint", "web-service-php-extensions", "web-service-php-connect-timeout",
"web-service-php-request-timeout", "web-service-php-concurrency", "web-service-php-pass-authorization",
"web-service-php-body-limit", "web-service-php-header-limit", "web-service-php-stderr-limit",
"web-service-php-custom-params", "web-service-php-values"
]) $(id).disabled = !enabled;
}
function syncPrecompressed() {
const enabled = $("web-service-precompressed").checked;
$("web-service-precompressed-br").disabled = !enabled;
$("web-service-precompressed-gzip").disabled = !enabled;
}
function syncHtaccess() {
const enabled = $("web-service-htaccess-enabled").checked;
for (const id of ["web-service-htaccess-filename", "web-service-htaccess-recursive", "web-service-htaccess-strict"]) $(id).disabled = !enabled;
}
const sourceId = source => source?.type === "web_server" ? source.web_service_id : null;
function usage(id) {
const uses = [];
for (const vhost of envelope?.config.vhosts || []) {
if (sourceId(vhost.source) === id) uses.push(vhost.id);
for (const route of vhost.routes || []) if (!route.response && sourceId(route.source) === id) uses.push(`${vhost.id}/${route.id}`);
}
return uses;
}
function defaults() {
return {
id: "", name: "", document_root: "", index: ["index.html", "index.htm"], directory_listing: false,
mime_types: {}, default_mime_type: "application/octet-stream", cache_control: null, cache_rules: [], response_headers: {},
try_files: [], rewrites: [], precompressed: { enabled: false, brotli: true, gzip: true }, download_extensions: [],
symlink_policy: "deny", php: {
enabled: false, extensions: ["php"], endpoint: "unix:/run/php/php-fpm.sock", connect_timeout_ms: 1000,
request_timeout_ms: 30000, max_concurrent_requests: 64, pass_authorization: true, max_request_body_bytes: 0,
max_response_header_bytes: 65536, stderr_log_bytes: 4096, custom_params: {}, php_values: {}
},
htaccess: { enabled: false, filename: ".htaccess", scan_subdirectories: true, strict: false },
hardening: { block_common_attacks: false, strip_unnecessary_headers: false }
};
}
function presetConfig(name) {
const item = defaults();
item.precompressed.enabled = true;
item.cache_rules = staticCacheRules();
if (name === "static") return item;
if (name === "spa") { item.index = ["index.html"]; item.try_files = ["$uri", "/index.html"]; return item; }
if (name === "downloads") {
item.index = []; item.directory_listing = true; item.cache_control = "public, max-age=3600"; item.cache_rules = [];
item.precompressed.enabled = false; item.download_extensions = ["zip", "7z", "tar", "gz", "tgz", "bz2", "xz", "pdf", "iso"]; return item;
}
item.index = ["index.php", "index.html", "index.htm"];
item.php.enabled = true;
item.php.extensions = ["php"];
if (name === "php_generic") { item.try_files = ["$uri", "$uri/", "=404"]; return item; }
if (["wordpress", "drupal", "laravel", "symfony", "joomla", "prestashop"].includes(name)) {
item.try_files = phpFrontController();
return item;
}
return null;
}
function populateSettings(item) {
$("web-service-index").value = (item.index || []).join("\n");
$("web-service-listing").checked = item.directory_listing === true;
$("web-service-try-files").value = (item.try_files || []).join("\n");
$("web-service-rewrites").value = rewriteLines(item.rewrites);
$("web-service-mime-types").value = mimeLines(item.mime_types);
$("web-service-default-mime").value = item.default_mime_type || "application/octet-stream";
$("web-service-cache-control").value = item.cache_control || "";
$("web-service-cache-rules").value = cacheLines(item.cache_rules);
$("web-service-response-headers").value = mapLines(item.response_headers);
$("web-service-precompressed").checked = item.precompressed?.enabled === true;
$("web-service-precompressed-br").checked = item.precompressed?.brotli !== false;
$("web-service-precompressed-gzip").checked = item.precompressed?.gzip !== false;
$("web-service-download-extensions").value = (item.download_extensions || []).join("\n");
$("web-service-php-enabled").checked = item.php?.enabled === true;
$("web-service-php-endpoint").value = item.php?.endpoint || "unix:/run/php/php-fpm.sock";
$("web-service-php-extensions").value = (item.php?.extensions || ["php"]).join(", ");
$("web-service-php-connect-timeout").value = item.php?.connect_timeout_ms ?? 1000;
$("web-service-php-request-timeout").value = item.php?.request_timeout_ms ?? 30000;
$("web-service-php-concurrency").value = item.php?.max_concurrent_requests ?? 64;
$("web-service-php-pass-authorization").checked = item.php?.pass_authorization !== false;
$("web-service-php-body-limit").value = item.php?.max_request_body_bytes ?? 0;
$("web-service-php-header-limit").value = item.php?.max_response_header_bytes ?? 65536;
$("web-service-php-stderr-limit").value = item.php?.stderr_log_bytes ?? 4096;
$("web-service-php-custom-params").value = mapLines(item.php?.custom_params);
$("web-service-php-values").value = mapLines(item.php?.php_values);
$("web-service-symlink-policy").value = item.symlink_policy || "deny";
$("web-service-htaccess-enabled").checked = item.htaccess?.enabled === true;
$("web-service-htaccess-filename").value = item.htaccess?.filename || ".htaccess";
$("web-service-htaccess-recursive").checked = item.htaccess?.scan_subdirectories !== false;
$("web-service-htaccess-strict").checked = item.htaccess?.strict === true;
$("web-service-hardening-attacks").checked = item.hardening?.block_common_attacks === true;
$("web-service-hardening-headers").checked = item.hardening?.strip_unnecessary_headers === true;
syncPhp();
syncPrecompressed();
syncHtaccess();
}
function updatePresetSummary() {
const key = presetHints[$("web-service-preset").value];
$("web-service-preset-summary").textContent = key ? t(key) : t("webServicePresetHelp");
}
function applyPreset() {
try {
const preset = presetConfig($("web-service-preset").value);
if (!preset) { toast(t("webServicePresetChoose"), true); return; }
// Keep deployment-specific FastCGI endpoint/tuning while replacing application policy.
preset.php.endpoint = $("web-service-php-endpoint").value.trim() || preset.php.endpoint;
preset.php.connect_timeout_ms = Number($("web-service-php-connect-timeout").value || preset.php.connect_timeout_ms);
preset.php.request_timeout_ms = Number($("web-service-php-request-timeout").value || preset.php.request_timeout_ms);
preset.php.max_concurrent_requests = Number($("web-service-php-concurrency").value || preset.php.max_concurrent_requests);
preset.php.pass_authorization = $("web-service-php-pass-authorization").checked;
preset.php.max_request_body_bytes = Number($("web-service-php-body-limit").value || 0);
preset.php.max_response_header_bytes = Number($("web-service-php-header-limit").value || preset.php.max_response_header_bytes);
preset.php.stderr_log_bytes = Number($("web-service-php-stderr-limit").value || 0);
preset.php.custom_params = parseFastCgiParams($("web-service-php-custom-params").value);
preset.php.php_values = parsePhpValues($("web-service-php-values").value);
validatePhpCustomData(preset.php.custom_params, preset.php.php_values);
preset.hardening.block_common_attacks = $("web-service-hardening-attacks").checked;
preset.hardening.strip_unnecessary_headers = $("web-service-hardening-headers").checked;
populateSettings(preset);
updatePresetSummary();
toast(t("webServicePresetApplied"));
} catch (error) {
toast(error.message, true);
}
}
function presetChanged() {
updatePresetSummary();
// Presets are one-shot form generators. Selecting one must immediately
// materialize its normal Web Service fields; otherwise a user can select a
// CMS, save the form and unknowingly persist the previous/custom values.
if ($("web-service-preset").value !== "custom") applyPreset();
}
function open(value = null) {
const item = Object.assign(defaults(), structuredClone(value || {}));
item.precompressed = Object.assign(defaults().precompressed, item.precompressed || {});
item.php = Object.assign(defaults().php, item.php || {});
item.htaccess = Object.assign(defaults().htaccess, item.htaccess || {});
item.hardening = Object.assign(defaults().hardening, item.hardening || {});
editing = value?.id || null;
$("web-service-id").value = item.id || "";
$("web-service-id").readOnly = !!editing;
$("web-service-name").value = item.name || "";
$("web-service-root").value = item.document_root || "";
populateSettings(item);
$("web-service-preset").value = "custom";
updatePresetSummary();
$("web-service-editing").textContent = t(editing ? "vhEditLabel" : "vhNewLabel");
activateTab("general");
dialog.showModal();
}
function close() { editing = null; form.reset(); if (dialog.open) dialog.close(); }
function render() {
if (!envelope) return;
const query = ($("web-service-search")?.value || "").trim().toLowerCase();
const services = (envelope.config.web_services || []).filter(service => !query || [service.id, service.name, service.document_root].join(" ").toLowerCase().includes(query));
const cards = services.map(service => {
const box = el("article", undefined, "vhost-card");
const head = el("div", undefined, "vhost-card-head"), identity = el("div", undefined, "vhost-card-identity");
const title = el("div", undefined, "vhost-title-line");
title.append(el("strong", service.name || service.id, "entity-title"), el("span", t("webServer"), "pill live"));
identity.append(title, el("div", service.document_root, "vhost-hosts"));
const actions = el("div", undefined, "entity-actions");
const edit = el("button", t("edit"), "quiet"); edit.type = "button"; edit.addEventListener("click", () => open(structuredClone(service)));
const remove = el("button", t("remove"), "quiet"); remove.type = "button"; remove.addEventListener("click", async () => {
const used = usage(service.id);
if (used.length) { toast(`${t("webServiceInUse")}: ${used.join(", ")}`, true); return; }
if (!(await appConfirm(t("webServiceDelete"), { danger: true }))) return;
const next = (envelope.config.web_services || []).filter(item => item.id !== service.id);
if (await saveSection("web-services", next)) render();
});
actions.append(edit, remove); head.append(identity, actions); box.append(head);
const details = el("div", undefined, "vhost-detail-strip");
const detailItems = [
["webServiceId", service.id],
["webServiceIndex", (service.index || []).join(", ") || t("webServiceNoIndex")],
["webServiceDirectoryListing", t(service.directory_listing ? "enabled" : "notEnabled")],
["webServiceTryFiles", (service.try_files || []).length ? String(service.try_files.length) : "-"],
["webServiceHtaccessEnabled", t(service.htaccess?.enabled ? "enabled" : "notEnabled")],
["webServicePhpEnabled", t(service.php?.enabled ? "enabled" : "notEnabled")],
["webServicePrecompressed", t(service.precompressed?.enabled ? "enabled" : "notEnabled")],
["webServiceHardeningTitle", t((service.hardening?.block_common_attacks || service.hardening?.strip_unnecessary_headers) ? "enabled" : "notEnabled")],
["webServiceCacheControl", service.cache_control || t("inherit")],
["webServiceUsedBy", usage(service.id).length ? usage(service.id).join(", ") : t("webServiceUnused")],
];
for (const [key, value] of detailItems) {
const item = el("div", undefined, "vhost-detail");
item.append(el("span", t(key), "small-label"), el("b", value));
details.append(item);
}
box.append(details); return box;
});
$("web-service-list").replaceChildren(...(cards.length ? cards : [el("p", t(query ? "webServiceSearchEmpty" : "webServiceEmpty"), "fine")]));
}
form.addEventListener("submit", async event => {
event.preventDefault(); if (!envelope || busy) return;
const invalid = [...form.elements].find(input => input.willValidate && !input.checkValidity()); if (invalid) { activateTab(invalid.closest("[data-web-service-page]")?.dataset.webServicePage || "general"); invalid.reportValidity(); return; }
try {
const phpEnabled = $("web-service-php-enabled").checked;
const item = {
id: $("web-service-id").value.trim(),
name: $("web-service-name").value.trim(),
document_root: $("web-service-root").value.trim(),
index: lines($("web-service-index").value),
directory_listing: $("web-service-listing").checked,
mime_types: parseMimeTypes($("web-service-mime-types").value),
default_mime_type: $("web-service-default-mime").value.trim() || "application/octet-stream",
cache_control: $("web-service-cache-control").value.trim() || null,
cache_rules: parseCacheRules($("web-service-cache-rules").value),
response_headers: parseKeyValueLines($("web-service-response-headers").value, "webServiceResponseHeadersInvalid", key => key.toLowerCase()),
try_files: strictLines($("web-service-try-files").value),
rewrites: parseRewrites($("web-service-rewrites").value),
precompressed: { enabled: $("web-service-precompressed").checked, brotli: $("web-service-precompressed-br").checked, gzip: $("web-service-precompressed-gzip").checked },
download_extensions: lines($("web-service-download-extensions").value).map(value => value.replace(/^\./, "").toLowerCase()),
symlink_policy: $("web-service-symlink-policy").value,
htaccess: {
enabled: $("web-service-htaccess-enabled").checked,
filename: $("web-service-htaccess-filename").value.trim() || ".htaccess",
scan_subdirectories: $("web-service-htaccess-recursive").checked,
strict: $("web-service-htaccess-strict").checked
},
hardening: {
block_common_attacks: $("web-service-hardening-attacks").checked,
strip_unnecessary_headers: $("web-service-hardening-headers").checked
},
php: {
enabled: phpEnabled,
extensions: lines($("web-service-php-extensions").value).map(value => value.replace(/^\./, "").toLowerCase()),
endpoint: $("web-service-php-endpoint").value.trim() || "unix:/run/php/php-fpm.sock",
connect_timeout_ms: Number($("web-service-php-connect-timeout").value || 1000),
request_timeout_ms: Number($("web-service-php-request-timeout").value || 30000),
max_concurrent_requests: Number($("web-service-php-concurrency").value || 64),
pass_authorization: $("web-service-php-pass-authorization").checked,
max_request_body_bytes: Number($("web-service-php-body-limit").value || 0),
max_response_header_bytes: Number($("web-service-php-header-limit").value || 65536),
stderr_log_bytes: Number($("web-service-php-stderr-limit").value || 0),
custom_params: parseFastCgiParams($("web-service-php-custom-params").value),
php_values: parsePhpValues($("web-service-php-values").value)
}
};
validatePhpCustomData(item.php.custom_params, item.php.php_values);
if (phpEnabled && item.symlink_policy !== "deny") throw new Error(t("webServicePhpSymlinkConflict"));
const next = structuredClone(envelope.config.web_services || []);
const duplicate = next.find(service => service.id === item.id && service.id !== editing);
if (duplicate) { toast(t("webServiceDuplicate"), true); return; }
const position = next.findIndex(service => service.id === editing);
if (position >= 0) next[position] = item; else next.push(item);
if (await saveSection("web-services", next)) { close(); render(); }
} catch (error) {
toast(error.message, true);
}
});
initTabs();
$("web-service-php-enabled").addEventListener("change", syncPhp);
$("web-service-precompressed").addEventListener("change", syncPrecompressed);
$("web-service-htaccess-enabled").addEventListener("change", syncHtaccess);
$("web-service-preset").addEventListener("change", presetChanged);
$("web-service-preset-apply").addEventListener("click", applyPreset);
$("web-service-htaccess-help").addEventListener("click", () => { const help = $("htaccess-help-dialog"); if (!help.open) help.showModal(); });
$("htaccess-help-close").addEventListener("click", () => $("htaccess-help-dialog").close());
$("web-service-php-help").addEventListener("click", () => { const help = $("php-help-dialog"); if (!help.open) help.showModal(); });
$("php-help-close").addEventListener("click", () => $("php-help-dialog").close());
$("web-service-new").addEventListener("click", () => open());
$("web-service-close").addEventListener("click", close);
$("web-service-reset").addEventListener("click", close);
$("web-service-search").addEventListener("input", render);
dialog.addEventListener("cancel", event => { event.preventDefault(); close(); });
window.rustProxyWebServer = { render, reset: close };
if (envelope) render();
})();