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