v0.32.0
This commit is contained in:
1 parent
d23fa5e154
commit
81ab39e70a
51 files changed
+5352
-1489
No files matched your search
+270
-10
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "RustProxy Administration API",
|
||||
"version": "0.31.9",
|
||||
"version": "0.32.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": [
|
||||
@@ -1521,7 +1521,7 @@
|
||||
},
|
||||
"upstream": {
|
||||
"type": "string",
|
||||
"description": "Single raw upstream as IP:port."
|
||||
"description": "Legacy single TCP/UDP upstream (IP:port); used only when backends is empty. An omitted field gets the legacy model default."
|
||||
},
|
||||
"connect_timeout_seconds": {
|
||||
"type": "integer",
|
||||
@@ -1537,13 +1537,34 @@
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 65535
|
||||
},
|
||||
"backends": {
|
||||
"type": "array",
|
||||
"maxItems": 32,
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/StreamBackend"
|
||||
},
|
||||
"description": "Optional backend pool. Empty retains legacy upstream as primary."
|
||||
},
|
||||
"balance": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"round_robin",
|
||||
"least_connections",
|
||||
"least_sessions"
|
||||
],
|
||||
"default": "round_robin"
|
||||
},
|
||||
"metrics_enabled": {
|
||||
"type": "boolean",
|
||||
"nullable": true,
|
||||
"description": "Optional default for per-backend detailed accounting. Null defaults off; basic Stream counters remain active and Prometheus include_streams remains authoritative."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"protocol",
|
||||
"listen",
|
||||
"upstream"
|
||||
"listen"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"description": "Layer-4 stream host. It bypasses the HTTP vhost/ACL/cache/header/compression pipeline."
|
||||
@@ -1595,6 +1616,13 @@
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"minimum": 0
|
||||
},
|
||||
"backends": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/StreamBackendMetrics"
|
||||
},
|
||||
"description": "Runtime backend states; detail metrics are null when disabled."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
@@ -3350,7 +3378,7 @@
|
||||
"dashboard_widgets": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 12,
|
||||
"maxItems": 16,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "string",
|
||||
@@ -3366,7 +3394,11 @@
|
||||
"http3",
|
||||
"tls",
|
||||
"http_protocol",
|
||||
"websocket_protocol"
|
||||
"websocket_protocol",
|
||||
"stream_overview",
|
||||
"stream_traffic",
|
||||
"stream_sessions",
|
||||
"stream_backend_health"
|
||||
]
|
||||
},
|
||||
"default": [
|
||||
@@ -3381,13 +3413,17 @@
|
||||
"http3",
|
||||
"tls",
|
||||
"http_protocol",
|
||||
"websocket_protocol"
|
||||
"websocket_protocol",
|
||||
"stream_overview",
|
||||
"stream_traffic",
|
||||
"stream_sessions",
|
||||
"stream_backend_health"
|
||||
],
|
||||
"description": "Account-scoped order for configurable Overview telemetry cards."
|
||||
},
|
||||
"dashboard_hidden_widgets": {
|
||||
"type": "array",
|
||||
"maxItems": 12,
|
||||
"maxItems": 16,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "string",
|
||||
@@ -3403,10 +3439,19 @@
|
||||
"http3",
|
||||
"tls",
|
||||
"http_protocol",
|
||||
"websocket_protocol"
|
||||
"websocket_protocol",
|
||||
"stream_overview",
|
||||
"stream_traffic",
|
||||
"stream_sessions",
|
||||
"stream_backend_health"
|
||||
]
|
||||
},
|
||||
"default": [],
|
||||
"default": [
|
||||
"stream_overview",
|
||||
"stream_traffic",
|
||||
"stream_sessions",
|
||||
"stream_backend_health"
|
||||
],
|
||||
"description": "Account-scoped Overview telemetry cards hidden from the dashboard."
|
||||
},
|
||||
"backend_table_columns": {
|
||||
@@ -6116,6 +6161,221 @@
|
||||
"credential",
|
||||
"token"
|
||||
]
|
||||
},
|
||||
"StreamBackend": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"address"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9_-]{1,64}$"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"maxLength": 96
|
||||
},
|
||||
"address": {
|
||||
"type": "string",
|
||||
"description": "Concrete upstream IP:port"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
},
|
||||
"drain": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"weight": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 16,
|
||||
"default": 1
|
||||
},
|
||||
"max_sessions": {
|
||||
"type": "integer",
|
||||
"nullable": true,
|
||||
"minimum": 1,
|
||||
"maximum": 65535
|
||||
},
|
||||
"health": {
|
||||
"$ref": "#/components/schemas/StreamHealthCheck"
|
||||
},
|
||||
"metrics_enabled": {
|
||||
"type": "boolean",
|
||||
"nullable": true,
|
||||
"description": "Optional detailed backend metrics. Null inherits Stream metrics_enabled (off when unset); an explicit boolean overrides it."
|
||||
}
|
||||
}
|
||||
},
|
||||
"StreamHealthCheck": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"off",
|
||||
"tcp_connect",
|
||||
"udp_probe"
|
||||
],
|
||||
"default": "off"
|
||||
},
|
||||
"interval_seconds": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 3600,
|
||||
"default": 10
|
||||
},
|
||||
"timeout_ms": {
|
||||
"type": "integer",
|
||||
"minimum": 50,
|
||||
"maximum": 30000,
|
||||
"default": 1000
|
||||
},
|
||||
"rise": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 10,
|
||||
"default": 2
|
||||
},
|
||||
"fall": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 10,
|
||||
"default": 2
|
||||
},
|
||||
"payload_hex": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"description": "At most 128 bytes; sent only when exact reply expected"
|
||||
},
|
||||
"expect_hex": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"maxLength": 256,
|
||||
"description": "Exact response; without it, UDP stays Unverified and no probe is sent"
|
||||
}
|
||||
}
|
||||
},
|
||||
"StreamBackendMetrics": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"address": {
|
||||
"type": "string"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"drain": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"detail_enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"unverified",
|
||||
"checking",
|
||||
"healthy",
|
||||
"unhealthy",
|
||||
"disabled"
|
||||
]
|
||||
},
|
||||
"active": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"sessions_total": {
|
||||
"type": "integer",
|
||||
"nullable": true,
|
||||
"minimum": 0
|
||||
},
|
||||
"sessions_completed": {
|
||||
"type": "integer",
|
||||
"nullable": true,
|
||||
"minimum": 0
|
||||
},
|
||||
"assignments": {
|
||||
"type": "integer",
|
||||
"nullable": true,
|
||||
"minimum": 0
|
||||
},
|
||||
"rejected": {
|
||||
"type": "integer",
|
||||
"nullable": true,
|
||||
"minimum": 0
|
||||
},
|
||||
"errors": {
|
||||
"type": "integer",
|
||||
"nullable": true,
|
||||
"minimum": 0
|
||||
},
|
||||
"bytes_in": {
|
||||
"type": "integer",
|
||||
"nullable": true,
|
||||
"minimum": 0
|
||||
},
|
||||
"bytes_out": {
|
||||
"type": "integer",
|
||||
"nullable": true,
|
||||
"minimum": 0
|
||||
},
|
||||
"packets_in": {
|
||||
"type": "integer",
|
||||
"nullable": true,
|
||||
"minimum": 0
|
||||
},
|
||||
"packets_out": {
|
||||
"type": "integer",
|
||||
"nullable": true,
|
||||
"minimum": 0
|
||||
},
|
||||
"health": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"last_checked_unix_ms": {
|
||||
"type": "integer",
|
||||
"nullable": true
|
||||
},
|
||||
"latency_ms": {
|
||||
"type": "integer",
|
||||
"nullable": true
|
||||
},
|
||||
"last_error": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"successful": {
|
||||
"type": "integer"
|
||||
},
|
||||
"failed": {
|
||||
"type": "integer"
|
||||
},
|
||||
"last_result": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"enum": [
|
||||
"success",
|
||||
"failure"
|
||||
],
|
||||
"description": "Most recent active probe outcome, null if no probe has completed."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -30,8 +30,37 @@ fn default_admin_dashboard_widgets() -> Vec<String> {
|
||||
.map(|value| (*value).to_owned())
|
||||
.collect()
|
||||
}
|
||||
fn default_file_log_sort() -> String { "time".to_owned() }
|
||||
fn default_file_log_descending() -> bool { true }
|
||||
const STREAM_DASHBOARD_WIDGETS: &[&str] = &[
|
||||
"stream_overview",
|
||||
"stream_traffic",
|
||||
"stream_sessions",
|
||||
"stream_backend_health",
|
||||
];
|
||||
fn default_hidden_dashboard_widgets() -> Vec<String> {
|
||||
STREAM_DASHBOARD_WIDGETS
|
||||
.iter()
|
||||
.map(|id| (*id).to_owned())
|
||||
.collect()
|
||||
}
|
||||
// Old accounts stored a complete 12-card order and often an explicit empty
|
||||
// hidden list. Upgrade it in memory: append opt-in Stream cards as hidden.
|
||||
fn migrate_dashboard_widgets(prefs: &mut AdminPreferences) {
|
||||
for id in STREAM_DASHBOARD_WIDGETS {
|
||||
if !prefs.dashboard_widgets.iter().any(|item| item == id) {
|
||||
prefs.dashboard_widgets.push((*id).to_owned());
|
||||
if !prefs.dashboard_hidden_widgets.iter().any(|item| item == id) {
|
||||
prefs.dashboard_hidden_widgets.push((*id).to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_file_log_sort() -> String {
|
||||
"time".to_owned()
|
||||
}
|
||||
fn default_file_log_descending() -> bool {
|
||||
true
|
||||
}
|
||||
fn default_file_log_columns() -> Vec<String> {
|
||||
[
|
||||
"time",
|
||||
@@ -97,7 +126,7 @@ struct AdminPreferences {
|
||||
dashboard_density: String,
|
||||
#[serde(default = "default_admin_dashboard_widgets")]
|
||||
dashboard_widgets: Vec<String>,
|
||||
#[serde(default)]
|
||||
#[serde(default = "default_hidden_dashboard_widgets")]
|
||||
dashboard_hidden_widgets: Vec<String>,
|
||||
#[serde(default = "default_file_log_columns")]
|
||||
file_log_columns: Vec<String>,
|
||||
@@ -150,7 +179,7 @@ impl Default for AdminPreferences {
|
||||
table_density: default_admin_table_density(),
|
||||
dashboard_density: default_admin_dashboard_density(),
|
||||
dashboard_widgets: default_admin_dashboard_widgets(),
|
||||
dashboard_hidden_widgets: Vec::new(),
|
||||
dashboard_hidden_widgets: default_hidden_dashboard_widgets(),
|
||||
file_log_columns: default_file_log_columns(),
|
||||
file_log_sort: default_file_log_sort(),
|
||||
file_log_descending: default_file_log_descending(),
|
||||
@@ -388,8 +417,16 @@ async fn preferences(
|
||||
.map_err(auth::internal)?;
|
||||
let preferences = match stored {
|
||||
Some(document) => match serde_json::from_str::<AdminPreferences>(&document) {
|
||||
Ok(preferences) if validate_admin_preferences(&preferences).is_ok() => preferences,
|
||||
Ok(_) | Err(_) => {
|
||||
Ok(mut preferences) => {
|
||||
migrate_dashboard_widgets(&mut preferences);
|
||||
if validate_admin_preferences(&preferences).is_ok() {
|
||||
preferences
|
||||
} else {
|
||||
tracing::warn!(user = %session.username, "invalid admin view preferences ignored");
|
||||
AdminPreferences::default()
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::warn!(user = %session.username, "invalid admin view preferences ignored");
|
||||
AdminPreferences::default()
|
||||
}
|
||||
@@ -420,4 +457,3 @@ async fn update_preferences(
|
||||
}
|
||||
Ok(Json(preferences))
|
||||
}
|
||||
|
||||
@@ -88,5 +88,8 @@ const DASHBOARD_WIDGETS: &[&str] = &[
|
||||
"tls",
|
||||
"http_protocol",
|
||||
"websocket_protocol",
|
||||
"stream_overview",
|
||||
"stream_traffic",
|
||||
"stream_sessions",
|
||||
"stream_backend_health",
|
||||
];
|
||||
|
||||
+13
-10
@@ -1,7 +1,7 @@
|
||||
mod documentation;
|
||||
mod public;
|
||||
mod panel_content;
|
||||
mod panel_assets;
|
||||
mod panel_content;
|
||||
mod public;
|
||||
|
||||
// Embedded asset URLs change with every build, independent of Cargo package version.
|
||||
// Only an exact identifier from this binary is accepted; arbitrary suffixes are 404.
|
||||
@@ -11,9 +11,13 @@ fn asset_build_id() -> &'static str {
|
||||
fn fingerprinted_path(path: &str, prefix: &str) -> Option<String> {
|
||||
let name = path.strip_prefix(prefix)?;
|
||||
let (stem, extension) = name.rsplit_once('.')?;
|
||||
if extension != "js" && extension != "css" { return None; }
|
||||
if extension != "js" && extension != "css" {
|
||||
return None;
|
||||
}
|
||||
let (base, token) = stem.rsplit_once('_')?;
|
||||
if token != asset_build_id() { return None; }
|
||||
if token != asset_build_id() {
|
||||
return None;
|
||||
}
|
||||
Some(format!("{prefix}{base}.{extension}"))
|
||||
}
|
||||
|
||||
@@ -53,6 +57,7 @@ fn admin_app_javascript() -> &'static str {
|
||||
include_str!("web/app/session-resume.js"),
|
||||
include_str!("web/app/refresh.js"),
|
||||
include_str!("web/app/preferences.js"),
|
||||
include_str!("web/app/stream-dashboard.js"),
|
||||
include_str!("web/app/config-history.js"),
|
||||
include_str!("web/app/runtime.js"),
|
||||
include_str!("web/app/settings.js"),
|
||||
@@ -306,14 +311,12 @@ fn panel_document_path(path: &str) -> bool {
|
||||
fn anonymous_allowed(method: &Method, path: &str) -> bool {
|
||||
if method == Method::GET || method == Method::HEAD {
|
||||
panel_document_path(path)
|
||||
|| fingerprinted_path(path, "/assets/")
|
||||
.is_some_and(|base| matches!(base.as_str(), "/assets/login.js" | "/assets/style.css"))
|
||||
|| fingerprinted_path(path, "/assets/").is_some_and(|base| {
|
||||
matches!(base.as_str(), "/assets/login.js" | "/assets/style.css")
|
||||
})
|
||||
|| matches!(
|
||||
path,
|
||||
"/assets/login-langs.json"
|
||||
| "/favicon.svg"
|
||||
| "/healthz"
|
||||
| "/api/auth/status"
|
||||
"/assets/login-langs.json" | "/favicon.svg" | "/healthz" | "/api/auth/status"
|
||||
)
|
||||
} else {
|
||||
method == Method::POST && matches!(path, "/api/auth/setup" | "/api/auth/login")
|
||||
|
||||
+1010
-483
File diff suppressed because it is too large.
Load diff
@@ -59,7 +59,7 @@ function translate() {
|
||||
window.rustProxyDrafts?.refresh();
|
||||
window.rustProxyPreferences?.refreshAppearanceUi?.();
|
||||
$("connection-label").textContent = t(connected ? "connected" : "disconnected");
|
||||
if (envelope) renderConfig(); if (latestStatus) { renderFooter(latestStatus); renderDiagnostics(latestStatus, false); window.rustProxyVhosts?.renderStatus(latestStatus); window.rustProxyStreams?.renderStatus(latestStatus); } if (lastLogs) renderLogs(lastLogs);
|
||||
if (envelope) renderConfig(); if (latestStatus) { renderFooter(latestStatus); renderDiagnostics(latestStatus, false); window.rustProxyVhosts?.renderStatus(latestStatus); window.rustProxyStreams?.renderStatus(latestStatus); window.rustProxyStreamDashboard?.render(latestStatus); } if (lastLogs) renderLogs(lastLogs);
|
||||
syncSettingsDirtyIndicators();
|
||||
}
|
||||
function hideToast() { const node = $("toast"); if (typeof node.hidePopover === "function" && node.matches(":popover-open")) node.hidePopover(); node.hidden = true; }
|
||||
@@ -160,6 +160,10 @@ 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?.();
|
||||
window.rustProxyRefresh?.setPanel(name);
|
||||
// Paint Stream tiles immediately on return from Appearance using cached status.
|
||||
// Do not create an extra sample/poll on navigation.
|
||||
if (name === "overview" && previousPanel !== name && typeof latestStatus !== "undefined" && latestStatus)
|
||||
window.rustProxyStreamDashboard?.render?.(latestStatus, null, false);
|
||||
// Read the cached Docker status on each visit, independent of the Live-view toggle.
|
||||
// This is GET /api/docker, not the POST that triggers an agent poll.
|
||||
if (name === "docker-vhosts" && previousPanel !== name && authenticated && envelope) void window.rustProxyRefresh?.manual("docker");
|
||||
@@ -262,7 +266,7 @@ function renderStatus(s, systemSample = false) {
|
||||
$("ws-protocol-h2").setAttribute("points", sparklinePoints(websocketProtocolSamples.h2, 300, 60, 5));
|
||||
$("ws-protocol-h3").setAttribute("points", sparklinePoints(websocketProtocolSamples.h3, 300, 60, 5));
|
||||
$("chart-peak").textContent = `max ${Math.max(0, ...samples).toFixed(1)} req/s`;
|
||||
renderFooter(s); renderDiagnostics(s, true, systemSample); window.rustProxyVhosts?.renderStatus(s); window.rustProxyStreams?.renderStatus(s);
|
||||
renderFooter(s); renderDiagnostics(s, true, systemSample); window.rustProxyVhosts?.renderStatus(s); window.rustProxyStreams?.renderStatus(s); window.rustProxyStreamDashboard?.render(s);
|
||||
}
|
||||
function renderFooter(s) {
|
||||
const apiLatency = lastApiLatencyMs == null ? "API -" : `API ${lastApiLatencyMs.toFixed(lastApiLatencyMs < 10 ? 1 : 0)} ms`;
|
||||
@@ -370,7 +374,7 @@ function authReturnDestination() {
|
||||
function lock(notify = true) {
|
||||
window.rustProxyRefresh?.stop();
|
||||
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; $("logs-menu").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(); window.rustProxyFileLogs?.reset(); window.rustProxyDrafts?.reset(); $("logs-open").hidden = true; $("file-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("");
|
||||
pendingAuthDestination = destination; if (notify) adminAuthChannel?.postMessage("locked"); $("api-docs").hidden = true; $("logs-menu").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(); window.rustProxyFileLogs?.reset(); window.rustProxyDrafts?.reset(); $("logs-open").hidden = true; $("file-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(); window.rustProxyStreamDashboard?.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 = ""; });
|
||||
|
||||
@@ -3,10 +3,10 @@ const ADMIN_TIME_FORMATS = new Set(["24h", "12h"]);
|
||||
const ADMIN_CHART_STYLES = new Set(["default", "soft", "technical", "high_contrast", "neon", "monochrome", "warm", "cool"]);
|
||||
const ADMIN_DENSITIES = new Set(["normal", "compact", "ultra"]);
|
||||
const ADMIN_BUTTON_STYLES = new Set(["normal", "compact"]);
|
||||
const ADMIN_DASHBOARD_WIDGETS = Object.freeze(["requests", "active", "bandwidth", "errors", "cache", "memory", "websocket", "http2", "http3", "tls", "http_protocol", "websocket_protocol"]);
|
||||
const ADMIN_DASHBOARD_WIDGETS = Object.freeze(["requests", "active", "bandwidth", "errors", "cache", "memory", "websocket", "http2", "http3", "tls", "http_protocol", "websocket_protocol", "stream_overview", "stream_traffic", "stream_sessions", "stream_backend_health"]);
|
||||
const ADMIN_APPEARANCE_DEFAULTS = Object.freeze({
|
||||
theme: "dark", language: "auto", ui_scale: 100, time_format: "24h", chart_style: "default",
|
||||
button_style: "normal", table_density: "normal", dashboard_density: "compact", dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [],
|
||||
button_style: "normal", table_density: "normal", dashboard_density: "compact", dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: ["stream_overview", "stream_traffic", "stream_sessions", "stream_backend_health"],
|
||||
});
|
||||
const CHART_STYLE_KEYS = Object.freeze({ default: "chartStyleDefault", soft: "chartStyleSoft", technical: "chartStyleTechnical", high_contrast: "chartStyleHighContrast", neon: "chartStyleNeon", monochrome: "chartStyleMonochrome", warm: "chartStyleWarm", cool: "chartStyleCool" });
|
||||
let adminPreferences = null, adminPreferencesLoading = null, adminPreferencesWrite = Promise.resolve(), adminPreferencesUnavailable = false;
|
||||
@@ -24,9 +24,14 @@ function normalizeDashboardWidgets(value) {
|
||||
for (const id of ADMIN_DASHBOARD_WIDGETS) if (!seen.has(id)) result.push(id);
|
||||
return result;
|
||||
}
|
||||
function normalizeHiddenDashboardWidgets(value) {
|
||||
function normalizeHiddenDashboardWidgets(value, savedOrder = ADMIN_DASHBOARD_WIDGETS) {
|
||||
const seen = new Set();
|
||||
return (Array.isArray(value) ? value : []).filter(id => ADMIN_DASHBOARD_WIDGETS.includes(id) && !seen.has(id) && seen.add(id));
|
||||
const hidden = (Array.isArray(value) ? value : []).filter(id => ADMIN_DASHBOARD_WIDGETS.includes(id) && !seen.has(id) && seen.add(id));
|
||||
// Also protect preferences written by 0.31.x with an explicit empty hidden list.
|
||||
for (const id of ADMIN_DASHBOARD_WIDGETS.filter(id => id.startsWith("stream_"))) {
|
||||
if (!savedOrder?.includes(id) && !seen.has(id)) { hidden.push(id); seen.add(id); }
|
||||
}
|
||||
return hidden;
|
||||
}
|
||||
function browserLanguage() {
|
||||
const packs = [...languagePacks.values()], candidates = (navigator.languages || [navigator.language]).filter(Boolean).map(value => value.replaceAll("_", "-").toLowerCase());
|
||||
@@ -81,6 +86,11 @@ function applyDashboardLayout(order, hidden) {
|
||||
const grid = document.querySelector("#panel-overview .stats-grid"); if (!grid) return { order: normalizedOrder, hidden: [...hiddenSet] };
|
||||
const nodes = new Map([...grid.querySelectorAll("[data-dashboard-widget]")].map(node => [node.dataset.dashboardWidget, node]));
|
||||
for (const id of normalizedOrder) { const node = nodes.get(id); if (!node) continue; node.hidden = hiddenSet.has(id); grid.append(node); }
|
||||
// Apply the newly selected visibility, not the previous saved preferences.
|
||||
// During load, adminPreferences is assigned only after this function returns.
|
||||
window.rustProxyStreamDashboard?.syncVisibility?.([...hiddenSet]);
|
||||
if (typeof latestStatus !== "undefined" && latestStatus && currentPanel === "overview")
|
||||
window.rustProxyStreamDashboard?.render?.(latestStatus, [...hiddenSet], false);
|
||||
return { order: normalizedOrder, hidden: [...hiddenSet] };
|
||||
}
|
||||
function dashboardWidgetLabel(id) {
|
||||
@@ -94,7 +104,18 @@ function renderDashboardWidgetSettings() {
|
||||
const row = el("div", undefined, "dashboard-widget-setting"); row.dataset.dashboardWidgetSetting = id;
|
||||
const visible = !hidden.has(id);
|
||||
const toggle = el("button", undefined, "dashboard-widget-toggle"); toggle.type = "button"; toggle.classList.toggle("is-hidden", !visible); toggle.setAttribute("aria-pressed", String(visible)); toggle.setAttribute("aria-label", `${t("dashboardWidgetVisible")}: ${dashboardWidgetLabel(id)}`);
|
||||
toggle.append(el("span", undefined, "dashboard-widget-visibility"), el("span", dashboardWidgetLabel(id), "dashboard-widget-setting-label"));
|
||||
const labelBlock = el("span", undefined, "dashboard-widget-setting-label-block");
|
||||
labelBlock.append(el("span", dashboardWidgetLabel(id), "dashboard-widget-setting-label"));
|
||||
const descriptionKey = ({ stream_overview: "streamWidgetOverviewDescription", stream_traffic: "streamWidgetTrafficDescription", stream_sessions: "streamWidgetSessionsDescription", stream_backend_health: "streamWidgetHealthDescription" })[id];
|
||||
if (descriptionKey) {
|
||||
const description = el("small", t(descriptionKey), "dashboard-widget-description");
|
||||
description.dataset.i18n = descriptionKey;
|
||||
description.id = `dashboard-widget-help-${id}`;
|
||||
description.setAttribute("role", "tooltip");
|
||||
toggle.setAttribute("aria-describedby", description.id);
|
||||
labelBlock.append(description);
|
||||
}
|
||||
toggle.append(el("span", undefined, "dashboard-widget-visibility"), labelBlock);
|
||||
const actions = el("span", undefined, "dashboard-widget-setting-actions");
|
||||
const up = el("button", "↑", "quiet"); up.type = "button"; up.disabled = index === 0; up.setAttribute("aria-label", t("moveUp"));
|
||||
const down = el("button", "↓", "quiet"); down.type = "button"; down.disabled = index === order.length - 1; down.setAttribute("aria-label", t("moveDown"));
|
||||
@@ -127,7 +148,7 @@ function formatDateTime(value) { const date = value instanceof Date ? value : ne
|
||||
function formatTime(value) { const date = value instanceof Date ? value : new Date(value); return date.toLocaleTimeString(locale(), { hour12: (adminPreferences?.time_format || "24h") === "12h" }); }
|
||||
function applyAdminPreferences(value, cache = true) {
|
||||
if (!value) return value;
|
||||
const dashboard = applyDashboardLayout(value.dashboard_widgets, value.dashboard_hidden_widgets);
|
||||
const dashboard = applyDashboardLayout(value.dashboard_widgets, normalizeHiddenDashboardWidgets(value.dashboard_hidden_widgets, value.dashboard_widgets));
|
||||
const normalized = {
|
||||
...value,
|
||||
refresh: normalizeRefreshPreferences(value.refresh),
|
||||
@@ -279,9 +300,9 @@ async function resetAppearancePatch(patch, control, messageKey = "appearanceSect
|
||||
document.querySelectorAll("[data-appearance-reset]").forEach(control => control.addEventListener("click", () => {
|
||||
const patches = {
|
||||
interface: { theme: "dark", language: "auto", ui_scale: 100, time_format: "24h", button_style: "normal" }, charts: { chart_style: "default" }, tables: { table_density: "normal" },
|
||||
dashboard: { dashboard_density: "compact", dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [] },
|
||||
dashboard: { dashboard_density: "compact", dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [...ADMIN_APPEARANCE_DEFAULTS.dashboard_hidden_widgets] },
|
||||
};
|
||||
void resetAppearancePatch(patches[control.dataset.appearanceReset] || {}, control);
|
||||
}));
|
||||
$("appearance-reset")?.addEventListener("click", () => void resetAppearancePatch({ ...ADMIN_APPEARANCE_DEFAULTS, dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [] }, $("appearance-reset"), "appearanceResetDone"));
|
||||
$("appearance-reset")?.addEventListener("click", () => void resetAppearancePatch({ ...ADMIN_APPEARANCE_DEFAULTS, dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [...ADMIN_APPEARANCE_DEFAULTS.dashboard_hidden_widgets] }, $("appearance-reset"), "appearanceResetDone"));
|
||||
refreshAppearanceUi();
|
||||
@@ -0,0 +1,114 @@
|
||||
// Four opt-in cards in the EXISTING dashboard widget order and status refresh.
|
||||
// No timers, fetches, or independent persistence are created here.
|
||||
window.rustProxyStreamDashboard = (() => {
|
||||
const ids = ["stream_overview", "stream_traffic", "stream_sessions", "stream_backend_health"];
|
||||
const history = { last: null, rx: [], tx: [] };
|
||||
let wasVisible = false;
|
||||
const card = id => document.querySelector(`[data-dashboard-widget="${id}"]`);
|
||||
const set = (id, value) => { const node = $(id); if (node) node.textContent = value; };
|
||||
const trafficRates = (rx, tx) => {
|
||||
set("stream-dashboard-rx-rate", rx);
|
||||
set("stream-dashboard-tx-rate", tx);
|
||||
};
|
||||
function clearTraffic() {
|
||||
history.last = null; history.rx.length = 0; history.tx.length = 0;
|
||||
$("stream-dashboard-rx")?.setAttribute("points", "0,55 300,55");
|
||||
$("stream-dashboard-tx")?.setAttribute("points", "0,55 300,55");
|
||||
}
|
||||
function syncVisibility(hiddenOverride = null) {
|
||||
const prefs = window.rustProxyPreferences?.current?.();
|
||||
const hidden = new Set(hiddenOverride ?? prefs?.dashboard_hidden_widgets ?? ADMIN_APPEARANCE_DEFAULTS.dashboard_hidden_widgets);
|
||||
// Visibility belongs exclusively to Appearance. An absent, disabled or
|
||||
// restart-pending Stream must not silently override an explicit opt-in.
|
||||
for (const id of ids) if (card(id)) card(id).hidden = hidden.has(id);
|
||||
if (card("stream_traffic")?.hidden && wasVisible) clearTraffic();
|
||||
wasVisible = !card("stream_traffic")?.hidden;
|
||||
}
|
||||
function line(points) {
|
||||
const peak = Math.max(1, ...history.rx, ...history.tx);
|
||||
return points.map((value, i) => `${i * 300 / Math.max(1, points.length - 1)},${55 - value / peak * 50}`).join(" ");
|
||||
}
|
||||
function render(status, hiddenOverride = null, recordTrafficSample = true) {
|
||||
syncVisibility(hiddenOverride);
|
||||
if (!status || currentPanel !== "overview" || ids.every(id => card(id)?.hidden)) return;
|
||||
const configured = envelope?.config?.streams || status?.streams?.desired || [];
|
||||
const active = (status?.streams?.active || []).filter(item => item.enabled !== false);
|
||||
const metrics = status?.streams?.metrics || {};
|
||||
if (!configured.some(item => item.enabled !== false) && !active.length) {
|
||||
// A deliberately shown tile has a neutral empty state, not a
|
||||
// hidden DOM node. No history is sampled without running streams.
|
||||
for (const name of ["overview", "sessions", "health", "traffic"]) {
|
||||
if (!card(({ overview: "stream_overview", sessions: "stream_sessions", health: "stream_backend_health", traffic: "stream_traffic" })[name])?.hidden) {
|
||||
set(`stream-dashboard-${name}`, "-");
|
||||
if (name === "traffic") trafficRates("-", "-");
|
||||
else set(`stream-dashboard-${name}-detail`, t("streamWidgetUnavailable"));
|
||||
}
|
||||
}
|
||||
if (!card("stream_traffic")?.hidden) clearTraffic();
|
||||
return;
|
||||
}
|
||||
if (!card("stream_overview")?.hidden) {
|
||||
const desired = configured.filter(item => item.enabled !== false);
|
||||
const attention = desired.filter(item => !active.some(a => a.id === item.id)).length +
|
||||
active.filter(item => (metrics[item.id]?.backends || []).length &&
|
||||
!(metrics[item.id].backends || []).some(b => b.enabled && !b.drain && b.status !== "unhealthy")).length;
|
||||
set("stream-dashboard-overview", `${active.length} / ${desired.length}`);
|
||||
set("stream-dashboard-overview-detail", `${t("streamWidgetActiveConfigured")} · ${attention} ${t("streamWidgetAttention")}`);
|
||||
}
|
||||
if (!card("stream_sessions")?.hidden) {
|
||||
const sum = protocol => active.filter(item => item.protocol === protocol)
|
||||
.reduce((count, item) => count + (Number(metrics[item.id]?.active) || 0), 0);
|
||||
const tcp = sum("tcp"), udp = sum("udp");
|
||||
set("stream-dashboard-sessions", fmt(tcp + udp));
|
||||
set("stream-dashboard-sessions-detail", `TCP ${fmt(tcp)} · UDP ${fmt(udp)}`);
|
||||
}
|
||||
if (!card("stream_backend_health")?.hidden) {
|
||||
const backends = active.flatMap(item => metrics[item.id]?.backends || []);
|
||||
const healthy = backends.filter(b => b.status === "healthy").length;
|
||||
const unhealthy = backends.filter(b => b.status === "unhealthy").length;
|
||||
const unverified = backends.filter(b => b.status === "unverified" || b.status === "checking").length;
|
||||
const emptyPools = active.filter(item => (metrics[item.id]?.backends || []).length > 0 &&
|
||||
!(metrics[item.id].backends || []).some(b => b.enabled && !b.drain && b.status !== "unhealthy")).length;
|
||||
set("stream-dashboard-health", `${healthy} / ${unhealthy}`);
|
||||
set("stream-dashboard-health-detail", `${t("streamWidgetHealthyUnhealthy")} · ${unverified} ${t("streamWidgetUnverified")} · ${emptyPools} ${t("streamWidgetEmptyPools")}`);
|
||||
}
|
||||
if (card("stream_traffic")?.hidden) return;
|
||||
const views = active.map(item => metrics[item.id]).filter(Boolean);
|
||||
if (!views.length) {
|
||||
set("stream-dashboard-traffic", t("streamWidgetUnavailable"));
|
||||
trafficRates("-", "-");
|
||||
return;
|
||||
}
|
||||
// Switching tabs or appearance preferences paints cached numbers without
|
||||
// generating an extra rate sample from the same status response.
|
||||
if (!recordTrafficSample) {
|
||||
if (history.rx.length) {
|
||||
const rx = history.rx.at(-1), tx = history.tx.at(-1);
|
||||
set("stream-dashboard-traffic", `${bytes(Math.round(rx))}/s`);
|
||||
trafficRates(`${bytes(Math.round(rx))}/s`, `${bytes(Math.round(tx))}/s`);
|
||||
} else {
|
||||
set("stream-dashboard-traffic", "-");
|
||||
trafficRates("-", "-");
|
||||
}
|
||||
return;
|
||||
}
|
||||
const rxTotal = views.reduce((sum, view) => sum + (Number(view.bytes_in) || 0), 0);
|
||||
const txTotal = views.reduce((sum, view) => sum + (Number(view.bytes_out) || 0), 0);
|
||||
const now = performance.now();
|
||||
let rx = 0, tx = 0;
|
||||
if (history.last) {
|
||||
const seconds = Math.max(.001, (now - history.last.when) / 1000);
|
||||
if (rxTotal >= history.last.rx) rx = (rxTotal - history.last.rx) / seconds;
|
||||
if (txTotal >= history.last.tx) tx = (txTotal - history.last.tx) / seconds;
|
||||
}
|
||||
history.last = { when: now, rx: rxTotal, tx: txTotal };
|
||||
history.rx.push(rx); history.tx.push(tx);
|
||||
if (history.rx.length > 60) { history.rx.shift(); history.tx.shift(); }
|
||||
set("stream-dashboard-traffic", `${bytes(Math.round(rx))}/s`);
|
||||
trafficRates(`${bytes(Math.round(rx))}/s`, `${bytes(Math.round(tx))}/s`);
|
||||
$("stream-dashboard-rx")?.setAttribute("points", line(history.rx));
|
||||
$("stream-dashboard-tx")?.setAttribute("points", line(history.tx));
|
||||
}
|
||||
function reset() { clearTraffic(); wasVisible = false; }
|
||||
return { syncVisibility, render, reset };
|
||||
})();
|
||||
+568
-259
File diff suppressed because it is too large.
Load diff
+100
-8
@@ -53,6 +53,60 @@
|
||||
return () => { if (input.value.trim()) commit(); if (!current) throw new Error(t("streamListenRequired")); return current; };
|
||||
}
|
||||
|
||||
const backendDefaults = (index = 0, address = "127.0.0.1:10001") => ({
|
||||
id: index ? `backend-${index + 1}` : "primary", name: index ? `Backend ${index + 1}` : "Primary",
|
||||
address, enabled: true, drain: false, weight: 1, max_sessions: null, metrics_enabled: null,
|
||||
health: { mode: "off", interval_seconds: 10, timeout_ms: 1000, rise: 2, fall: 2, payload_hex: "", expect_hex: null }
|
||||
});
|
||||
let backendList = null;
|
||||
function renderBackendEditor(parent, backend, protocol) {
|
||||
if (backendList?.children.length >= 32) { toast(t("streamBackendLimit"), true); return; }
|
||||
const row = el("fieldset", undefined, "stream-backend-row vh-wide"); row.append(el("legend", backend.id || t("streamBackend")));
|
||||
const controls = el("div", undefined, "vh-grid"); row.append(controls);
|
||||
field(controls, "backend-id", "streamBackendId", backend.id, "text", { required: true, maxLength: 64, pattern: "[A-Za-z0-9_-]{1,64}" });
|
||||
field(controls, "backend-name", "streamBackendName", backend.name || "", "text", { maxLength: 96 });
|
||||
field(controls, "backend-address", "streamBackendAddress", backend.address, "text", { required: true, maxLength: 128 });
|
||||
field(controls, "backend-weight", "streamBackendWeight", backend.weight ?? 1, "number", { min: 1, max: 16, required: true });
|
||||
field(controls, "backend-max", "streamBackendMaxSessions", backend.max_sessions ?? "", "number", { min: 1, max: 65535 });
|
||||
field(controls, "backend-enabled", "vhEnabled", backend.enabled !== false, "checkbox");
|
||||
field(controls, "backend-drain", "streamBackendDrain", backend.drain === true, "checkbox");
|
||||
const detail = backend.metrics_enabled == null ? "inherit" : String(backend.metrics_enabled);
|
||||
field(controls, "backend-metrics", "streamBackendDetailMetrics", detail, "select", { values: [["inherit", "streamMetricsFromStream"], ["true", "streamMetricsOn"], ["false", "streamMetricsOff"]] });
|
||||
const hc = backend.health || backendDefaults().health;
|
||||
field(controls, "backend-health-mode", "streamHealthMode", hc.mode || "off", "select", { values: [["off", "streamHealthOff"], [protocol === "tcp" ? "tcp_connect" : "udp_probe", protocol === "tcp" ? "streamHealthTcp" : "streamHealthUdp"]] });
|
||||
field(controls, "backend-health-interval", "streamHealthInterval", hc.interval_seconds ?? 10, "number", { min: 1, max: 3600, required: true });
|
||||
field(controls, "backend-health-timeout", "streamHealthTimeout", hc.timeout_ms ?? 1000, "number", { min: 50, max: 30000, required: true });
|
||||
field(controls, "backend-health-rise", "streamHealthRise", hc.rise ?? 2, "number", { min: 1, max: 10, required: true });
|
||||
field(controls, "backend-health-fall", "streamHealthFall", hc.fall ?? 2, "number", { min: 1, max: 10, required: true });
|
||||
if (protocol === "udp") {
|
||||
field(controls, "backend-health-payload", "streamHealthPayload", hc.payload_hex || "", "text", { maxLength: 256 });
|
||||
field(controls, "backend-health-expected", "streamHealthExpected", hc.expect_hex || "", "text", { maxLength: 256 });
|
||||
}
|
||||
const remove = button("remove", () => { if (backendList.children.length > 1) { row.remove(); changed(); } });
|
||||
row.append(remove); backendList.append(row);
|
||||
}
|
||||
const optBool = value => value === "inherit" ? null : value === "true";
|
||||
function collectBackends() {
|
||||
const rows = [...backendList.querySelectorAll(".stream-backend-row")];
|
||||
if (!rows.length) throw new Error(t("streamBackendRequired"));
|
||||
const get = (row, name) => row.querySelector(`[name="${name}"]`);
|
||||
const selected = rows.map(row => ({
|
||||
id: get(row, "backend-id").value.trim(), name: get(row, "backend-name").value.trim(),
|
||||
address: get(row, "backend-address").value.trim(), weight: Number(get(row, "backend-weight").value),
|
||||
max_sessions: get(row, "backend-max").value === "" ? null : Number(get(row, "backend-max").value),
|
||||
enabled: get(row, "backend-enabled").checked, drain: get(row, "backend-drain").checked,
|
||||
metrics_enabled: optBool(get(row, "backend-metrics").value),
|
||||
health: {
|
||||
mode: get(row, "backend-health-mode").value, interval_seconds: Number(get(row, "backend-health-interval").value),
|
||||
timeout_ms: Number(get(row, "backend-health-timeout").value), rise: Number(get(row, "backend-health-rise").value),
|
||||
fall: Number(get(row, "backend-health-fall").value),
|
||||
payload_hex: get(row, "backend-health-payload")?.value.trim() || "",
|
||||
expect_hex: get(row, "backend-health-expected")?.value.trim() || null
|
||||
}
|
||||
}));
|
||||
if (new Set(selected.map(b => b.id)).size !== selected.length) throw new Error(t("streamBackendDuplicate"));
|
||||
return selected;
|
||||
}
|
||||
let readListen = () => "";
|
||||
function load(value = defaults(), show = true) {
|
||||
editing = value.id || null; revision = envelope?.revision ?? null; dirty = false; initialized = true; container.replaceChildren(); initTabs();
|
||||
@@ -63,18 +117,41 @@
|
||||
const protocol = field(g, "protocol", "streamProtocol", value.protocol || "tcp", "select", { values: [["tcp", "streamTcp"], ["udp", "streamUdp"]] });
|
||||
const enabledControl = field(g, "enabled", "vhEnabled", value.enabled !== false, "checkbox");
|
||||
window.rustProxyEnableControl(enabledControl);
|
||||
const enabledLabel = enabledControl.closest("label");
|
||||
enabledLabel?.querySelector(".vh-enable-hint")?.remove();
|
||||
enabledLabel?.classList.add("vh-enable-no-hint");
|
||||
note(g, "streamsHelp");
|
||||
|
||||
g = group("streamNetwork", "network");
|
||||
readListen = socketChipField(g, value.listen || "0.0.0.0:10000");
|
||||
field(g, "upstream", "streamUpstream", value.upstream || "127.0.0.1:10001", "text", { required: true, maxLength: 128 });
|
||||
field(g, "balance", "streamBalance", value.balance || "round_robin", "select", { values: [["round_robin", "streamRoundRobin"], [protocol.value === "tcp" ? "least_connections" : "least_sessions", "streamLeastSessions"]] });
|
||||
field(g, "stream-metrics", "streamDetailMetrics", value.metrics_enabled == null ? "inherit" : String(value.metrics_enabled), "select", { values: [["inherit", "streamMetricsDefaultOff"], ["true", "streamMetricsOn"], ["false", "streamMetricsOff"]] });
|
||||
note(g, "streamMetricsHelp");
|
||||
const pool = el("div", undefined, "vh-wide stream-backend-pool"); pool.append(text("h3", "streamBackendPool"));
|
||||
backendList = el("div", undefined, "stream-backend-list"); pool.append(backendList);
|
||||
const initial = value.backends?.length ? value.backends : [backendDefaults(0, value.upstream || "127.0.0.1:10001")];
|
||||
for (const backend of initial) renderBackendEditor(backendList, backend, protocol.value);
|
||||
pool.append(button("streamBackendAdd", () => { renderBackendEditor(backendList, backendDefaults(backendList.children.length), protocol.value); changed(); }));
|
||||
note(pool, "streamHealthUdpHelp"); g.append(pool);
|
||||
note(g, "streamDedicatedListenerHelp");
|
||||
|
||||
g = group("streamLimits", "limits");
|
||||
const connectTimeout = field(g, "connect-timeout", "streamConnectTimeout", value.connect_timeout_seconds ?? 5, "number", { min: 1, max: 300, required: true });
|
||||
field(g, "idle-timeout", "streamIdleTimeout", value.idle_timeout_seconds ?? 300, "number", { min: 1, max: 86400, required: true });
|
||||
field(g, "max-connections", "streamMaxConnections", value.max_connections ?? 1024, "number", { min: 1, max: 65535, required: true });
|
||||
const sync = () => { connectTimeout.disabled = protocol.value !== "tcp"; }; protocol.addEventListener("change", sync); sync();
|
||||
const sync = () => { connectTimeout.disabled = protocol.value !== "tcp"; }; protocol.addEventListener("change", () => {
|
||||
sync();
|
||||
// Explicitly reset incompatible health modes when switching transport.
|
||||
for (const row of backendList.children) {
|
||||
const mode = row.querySelector('[name="backend-health-mode"]');
|
||||
if (mode?.value !== "off") mode.value = "off";
|
||||
}
|
||||
const currentBackends = collectBackends(); backendList.replaceChildren();
|
||||
currentBackends.forEach(backend => renderBackendEditor(backendList, backend, protocol.value));
|
||||
const balance = read("balance"); balance.replaceChildren();
|
||||
for (const [val, key] of [["round_robin", "streamRoundRobin"], [protocol.value === "tcp" ? "least_connections" : "least_sessions", "streamLeastSessions"]]) { const option = text("option", key); option.value = val; balance.append(option); }
|
||||
changed();
|
||||
}); sync();
|
||||
note(g, "streamLimitsHelp");
|
||||
if (show) { activateTab("details"); if (!dialog.open) dialog.showModal(); }
|
||||
$("stream-live-summary").textContent = editing ? t("streamNoLiveMetrics") : "";
|
||||
@@ -83,18 +160,20 @@
|
||||
}
|
||||
|
||||
function collect() {
|
||||
const backends = collectBackends();
|
||||
return {
|
||||
id: str("id"), name: str("name"), enabled: yes("enabled"), protocol: str("protocol"),
|
||||
listen: readListen(), upstream: str("upstream"), connect_timeout_seconds: Number(str("connect-timeout") || 5),
|
||||
listen: readListen(), upstream: backends[0].address, backends, balance: str("balance"),
|
||||
metrics_enabled: optBool(str("stream-metrics")), connect_timeout_seconds: Number(str("connect-timeout") || 5),
|
||||
idle_timeout_seconds: num("idle-timeout"), max_connections: num("max-connections")
|
||||
};
|
||||
}
|
||||
function suspendDraft() {
|
||||
const state = { editing, revision, label: $("stream-editing").textContent, fields: window.rustProxyDrafts.takeChildren(container), tabs: window.rustProxyDrafts.takeChildren($("stream-tabs")), readListen };
|
||||
const state = { editing, revision, label: $("stream-editing").textContent, fields: window.rustProxyDrafts.takeChildren(container), tabs: window.rustProxyDrafts.takeChildren($("stream-tabs")), readListen, backendList };
|
||||
const title = read("id")?.value.trim() || editing || t("draftUntitled"); editing = null; revision = null; dirty = false; dialog.close(); return { title, state };
|
||||
}
|
||||
function restoreDraft(state) {
|
||||
window.rustProxyDrafts.restoreChildren(container, state.fields); window.rustProxyDrafts.restoreChildren($("stream-tabs"), state.tabs); readListen = state.readListen;
|
||||
window.rustProxyDrafts.restoreChildren(container, state.fields); window.rustProxyDrafts.restoreChildren($("stream-tabs"), state.tabs); readListen = state.readListen; backendList = state.backendList;
|
||||
editing = state.editing; revision = state.revision; initialized = true; dirty = true; $("stream-editing").textContent = state.label; if (!dialog.open) dialog.showModal(); if (latestStatus) renderStatus(latestStatus);
|
||||
}
|
||||
function closeEditor() { if (dirty) return window.rustProxyDrafts.dock("stream"); if (dialog.open) dialog.close(); return true; }
|
||||
@@ -106,7 +185,7 @@
|
||||
const configuredIds = new Set(configured.map(stream => stream.id));
|
||||
for (const id of chartHistory.keys()) if (!configuredIds.has(id)) chartHistory.delete(id);
|
||||
const query = ($("stream-search")?.value || "").trim().toLowerCase();
|
||||
const streams = configured.filter(stream => !query || [stream.id, stream.name, stream.protocol, stream.listen, stream.upstream].filter(Boolean).join(" ").toLowerCase().includes(query));
|
||||
const streams = configured.filter(stream => !query || [stream.id, stream.name, stream.protocol, stream.listen, stream.upstream, ...(stream.backends || []).flatMap(b => [b.id, b.name, b.address])].filter(Boolean).join(" ").toLowerCase().includes(query));
|
||||
const active = new Map((latestStatus?.streams?.active || []).map(item => [item.id, item]));
|
||||
const sortValue = stream => {
|
||||
const live = active.get(stream.id), same = live && JSON.stringify(live) === JSON.stringify(stream);
|
||||
@@ -126,8 +205,17 @@
|
||||
head.append(actions); box.append(head);
|
||||
|
||||
const details = el("div", undefined, "vhost-detail-strip");
|
||||
for (const [key, value] of [["streamProtocol", String(stream.protocol || "tcp").toUpperCase()], ["streamListen", stream.listen], ["streamUpstream", stream.upstream], ["streamIdleShort", `${stream.idle_timeout_seconds}s`], ["streamMaxConnections", String(stream.max_connections)]]) { const item = el("div", undefined, "vhost-detail"); item.append(text("span", key, "small-label"), el("b", value)); details.append(item); }
|
||||
for (const [key, value] of [["streamProtocol", String(stream.protocol || "tcp").toUpperCase()], ["streamListen", stream.listen], ["streamUpstream", `${stream.backends?.length ? stream.backends.length + " · " : ""}${stream.upstream}`], ["streamIdleShort", `${stream.idle_timeout_seconds}s`], ["streamMaxConnections", String(stream.max_connections)]]) { const item = el("div", undefined, "vhost-detail"); item.append(text("span", key, "small-label"), el("b", value)); details.append(item); }
|
||||
box.append(details);
|
||||
const backendBox = el("div", undefined, "stream-backend-status");
|
||||
backendBox.append(text("span", "streamBackendPool", "small-label"));
|
||||
for (const backend of stream.backends?.length ? stream.backends : [backendDefaults(0, stream.upstream)]) {
|
||||
const row = el("div", undefined, "stream-backend-status-row");
|
||||
row.append(el("span", `${backend.name || backend.id} · ${backend.address}`));
|
||||
const health = el("span", t("streamWidgetUnverified"), "pill"); health.dataset.streamBackendStatus = `${stream.id}|${backend.id}`;
|
||||
row.append(health); backendBox.append(row);
|
||||
}
|
||||
box.append(backendBox);
|
||||
|
||||
const live = el("div", undefined, "vhost-live-grid stream-live-grid");
|
||||
const metric = (key, suffix) => { const item = el("div", undefined, "vhost-live-metric"); item.append(text("span", key), el("strong", "-")); item.querySelector("strong").dataset[suffix] = stream.id; live.append(item); };
|
||||
@@ -156,11 +244,15 @@
|
||||
const now = performance.now();
|
||||
for (const desired of envelope?.config.streams || []) {
|
||||
const id = desired.id, live = active.get(id), data = metrics[id];
|
||||
if (currentPanel === "streams") for (const backend of data?.backends || []) {
|
||||
const node = document.querySelector(`[data-stream-backend-status="${CSS.escape(`${id}|${backend.id}`)}"]`);
|
||||
if (node) node.textContent = `${t("streamHealthMode")}: ${t(`streamHealthState_${backend.status}`)} · ${backend.active} ${t("streamActiveSessions")}`;
|
||||
}
|
||||
const same = live && JSON.stringify(live) === JSON.stringify(desired);
|
||||
const state = desired.enabled === false ? t("vhDisabled") : !live || !same ? t("streamPendingRestart") : t("streamRunning");
|
||||
const set = (suffix, value) => document.querySelectorAll(`[data-${suffix.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`)}="${CSS.escape(id)}"]`).forEach(node => node.textContent = value);
|
||||
set("streamState", state); set("streamActive", data ? fmt(data.active) : "-"); set("streamSessions", data ? fmt(data.sessions_total) : "-"); set("streamErrors", data ? fmt(data.errors) : "-"); set("streamRejected", data ? fmt(data.rejected) : "-"); set("streamTransferred", data ? bytes((Number(data.bytes_in) || 0) + (Number(data.bytes_out) || 0)) : "-");
|
||||
if (!data) continue;
|
||||
if (!data || currentPanel !== "streams") continue;
|
||||
const history = chartHistory.get(id) || { lastTime: now, lastIn: Number(data.bytes_in) || 0, lastOut: Number(data.bytes_out) || 0, points: [], bucketSize: 1, pendingRx: 0, pendingTx: 0, pendingCount: 0 };
|
||||
const elapsed = Math.max(.001, (now - history.lastTime) / 1000), inBytes = Number(data.bytes_in) || 0, outBytes = Number(data.bytes_out) || 0;
|
||||
const rx = inBytes >= history.lastIn ? (inBytes - history.lastIn) / elapsed : 0, tx = outBytes >= history.lastOut ? (outBytes - history.lastOut) / elapsed : 0;
|
||||
|
||||
+416
-54
@@ -8474,8 +8474,14 @@ button.config-history-day.is-selected {
|
||||
display: block;
|
||||
overflow: visible;
|
||||
}
|
||||
.config-history-revisions { max-height: none; }
|
||||
.config-history-diff { overflow: visible; }
|
||||
|
||||
.config-history-revisions {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.config-history-diff {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.config-history-change-values {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -8723,7 +8729,6 @@ button.config-history-day.is-selected {
|
||||
box-shadow: 0 1px 0 var(--line);
|
||||
}
|
||||
|
||||
/* Stored Logs sorting is performed by the bounded server scan before pagination. */
|
||||
.file-logs-table th .file-log-sort-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -8737,10 +8742,20 @@ button.config-history-day.is-selected {
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.file-logs-table th .file-log-sort-button:hover { color: var(--accent); }
|
||||
|
||||
.file-logs-table th .file-log-sort-button:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.file-logs-table th[aria-sort="ascending"] .file-log-sort-button,
|
||||
.file-logs-table th[aria-sort="descending"] .file-log-sort-button { color: var(--accent); }
|
||||
.file-logs-table .file-log-sort-indicator { font-size: 11px; opacity: .8; }
|
||||
.file-logs-table th[aria-sort="descending"] .file-log-sort-button {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.file-logs-table .file-log-sort-indicator {
|
||||
font-size: 11px;
|
||||
opacity: .8;
|
||||
}
|
||||
|
||||
.file-logs-table tbody tr:not(.file-log-detail-row) {
|
||||
cursor: pointer;
|
||||
@@ -9789,7 +9804,6 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
||||
stroke-linejoin: var(--chart-linejoin);
|
||||
}
|
||||
|
||||
/* Compact toolbar fields use one scaled box model, so native selects and inputs stay aligned. */
|
||||
.backend-table-tools,
|
||||
.vhost-list-tools,
|
||||
.docker-toolbar,
|
||||
@@ -9814,7 +9828,6 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
||||
padding-block: 0
|
||||
}
|
||||
|
||||
/* ACL policy builder: guided OR-composition for country + IP exceptions. */
|
||||
.acl-profile-layout {
|
||||
grid-template-columns: minmax(260px, .72fr) minmax(440px, 1.28fr)
|
||||
}
|
||||
@@ -10277,6 +10290,7 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
||||
.cache-maintenance-info .metric-info-popover code+.fine {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Stored Logs: compact header option and distinct analysis workspace. */
|
||||
.file-logs-heading-actions .file-log-consolidate {
|
||||
display: inline-flex;
|
||||
@@ -10290,23 +10304,72 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.file-log-consolidate input { width: 14px; height: 14px; margin: 0; accent-color: var(--accent); }
|
||||
.file-log-consolidate:hover { color: var(--text); }
|
||||
.file-log-tabs { display: flex; gap: .25rem; border-bottom: 1px solid var(--line); margin: .9rem 0 1rem; }
|
||||
|
||||
.file-log-consolidate input {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin: 0;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.file-log-consolidate:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.file-log-tabs {
|
||||
display: flex;
|
||||
gap: .25rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
margin: .9rem 0 1rem;
|
||||
}
|
||||
|
||||
.file-log-tab {
|
||||
appearance: none; background: transparent; border: 0; border-bottom: 2px solid transparent;
|
||||
border-radius: 0; padding: .65rem 1rem; color: var(--muted); cursor: pointer;
|
||||
font: inherit; font-weight: 500;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
border-radius: 0;
|
||||
padding: .65rem 1rem;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 500;
|
||||
}
|
||||
.file-log-tab:hover { color: var(--text); }
|
||||
.file-log-tab.active { color: var(--text); border-bottom-color: var(--accent); }
|
||||
.file-log-tab:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||
#file-log-view-counts[hidden], #file-log-view-entries[hidden] { display: none !important; }
|
||||
.file-log-advanced-parser h3 { margin: 0 0 .7rem; font-size: 1rem; }
|
||||
|
||||
.file-log-tab:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.file-log-tab.active {
|
||||
color: var(--text);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.file-log-tab:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
#file-log-view-counts[hidden],
|
||||
#file-log-view-entries[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.file-log-advanced-parser h3 {
|
||||
margin: 0 0 .7rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.file-logs-heading-actions .file-log-consolidate { margin-inline-end: auto; }
|
||||
.file-log-tab { padding: .6rem .7rem; }
|
||||
.file-logs-heading-actions .file-log-consolidate {
|
||||
margin-inline-end: auto;
|
||||
}
|
||||
|
||||
.file-log-tab {
|
||||
padding: .6rem .7rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* 0.31.5 traffic/log analysis actions */
|
||||
.traffic-copy-value,
|
||||
.file-log-aggregate-copy {
|
||||
@@ -10320,18 +10383,21 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
||||
margin: 0;
|
||||
cursor: copy;
|
||||
}
|
||||
|
||||
.traffic-copy-value:hover,
|
||||
.traffic-copy-value:focus-visible,
|
||||
.file-log-aggregate-copy:hover,
|
||||
.file-log-aggregate-copy:focus-visible {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.file-log-advanced-parser {
|
||||
margin: 0 0 1rem;
|
||||
padding: .75rem 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius, 12px);
|
||||
}
|
||||
|
||||
.file-log-aggregate-form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 2fr) minmax(140px, 1fr) minmax(120px, 1fr) auto;
|
||||
@@ -10339,16 +10405,21 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
||||
align-items: end;
|
||||
margin-top: .75rem;
|
||||
}
|
||||
|
||||
.file-log-aggregate-table {
|
||||
margin-top: .75rem;
|
||||
}
|
||||
|
||||
.file-log-aggregate-table th:last-child,
|
||||
.file-log-aggregate-table td:last-child {
|
||||
width: 8rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.file-log-aggregate-form { grid-template-columns: 1fr; }
|
||||
.file-log-aggregate-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10356,6 +10427,7 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
||||
The complete parsed JSON is available by opening any row, while the desktop
|
||||
table and the persisted column picker keep their original behavior. */
|
||||
@media (max-width: 700px) {
|
||||
|
||||
#panel-filelogs,
|
||||
#panel-filelogs .file-logs-workspace,
|
||||
#file-log-view-entries,
|
||||
@@ -10368,33 +10440,43 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#panel-filelogs .file-logs-workspace {
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
#panel-filelogs .card-heading {
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
#panel-filelogs .file-logs-heading-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
#panel-filelogs .file-logs-heading-actions .file-log-consolidate {
|
||||
margin-inline-end: auto;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
#panel-filelogs .file-log-search-help-popover {
|
||||
width: min(320px, calc(100vw - 54px));
|
||||
}
|
||||
|
||||
#panel-filelogs .file-logs-filter,
|
||||
#panel-filelogs .file-log-aggregate-form {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
#panel-filelogs .file-log-limit { max-width: none; }
|
||||
|
||||
#panel-filelogs .file-log-limit {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
#panel-filelogs .file-logs-filter label,
|
||||
#panel-filelogs .file-logs-filter input,
|
||||
#panel-filelogs .file-logs-filter select,
|
||||
@@ -10404,21 +10486,31 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
#panel-filelogs .file-log-tabs { min-width: 0; }
|
||||
|
||||
#panel-filelogs .file-log-tabs {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#panel-filelogs .file-log-tab {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
#panel-filelogs .file-logs-table-toolbar {
|
||||
align-items: flex-start;
|
||||
gap: 5px;
|
||||
}
|
||||
#panel-filelogs .file-log-column-picker { align-self: flex-end; }
|
||||
|
||||
#panel-filelogs .file-log-column-picker {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
#panel-filelogs .file-logs-table {
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
#panel-filelogs .file-logs-table table,
|
||||
#panel-filelogs .file-logs-table tbody {
|
||||
display: block;
|
||||
@@ -10426,7 +10518,11 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
||||
min-width: 0;
|
||||
white-space: normal;
|
||||
}
|
||||
#panel-filelogs .file-logs-table thead { display: none; }
|
||||
|
||||
#panel-filelogs .file-logs-table thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#panel-filelogs .file-logs-table tbody tr:not(.file-log-detail-row) {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
@@ -10438,12 +10534,17 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
#panel-filelogs .file-logs-table tbody tr:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
#panel-filelogs .file-logs-table tbody tr.selected { border-color: var(--accent); }
|
||||
#panel-filelogs .file-logs-table tbody tr:not(.file-log-detail-row) > td {
|
||||
|
||||
#panel-filelogs .file-logs-table tbody tr.selected {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
#panel-filelogs .file-logs-table tbody tr:not(.file-log-detail-row)>td {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
@@ -10454,6 +10555,7 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Every user-selected column is available even when the administrator
|
||||
replaces all default columns with custom parsed fields. */
|
||||
#panel-filelogs .file-logs-table td[data-mobile-column="record"] {
|
||||
@@ -10462,6 +10564,7 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
||||
line-height: 1.45;
|
||||
padding-inline-end: 16px;
|
||||
}
|
||||
|
||||
#panel-filelogs .file-logs-table td[data-mobile-column="time"],
|
||||
#panel-filelogs .file-logs-table td[data-mobile-column="event"],
|
||||
#panel-filelogs .file-logs-table td[data-mobile-column="status"],
|
||||
@@ -10469,14 +10572,17 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
#panel-filelogs .file-logs-table tbody tr:not(.file-log-detail-row) > td::before {
|
||||
|
||||
#panel-filelogs .file-logs-table tbody tr:not(.file-log-detail-row)>td::before {
|
||||
content: attr(data-mobile-label) ": ";
|
||||
color: var(--text-subtle);
|
||||
font-weight: 400;
|
||||
}
|
||||
#panel-filelogs .file-logs-table tbody tr:not(.file-log-detail-row) > td[data-mobile-column="record"]::before {
|
||||
|
||||
#panel-filelogs .file-logs-table tbody tr:not(.file-log-detail-row)>td[data-mobile-column="record"]::before {
|
||||
content: "";
|
||||
}
|
||||
|
||||
#panel-filelogs .file-logs-table tbody tr:not(.file-log-detail-row)::after {
|
||||
content: "+";
|
||||
position: absolute;
|
||||
@@ -10486,7 +10592,11 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
||||
font-size: 15px;
|
||||
pointer-events: none;
|
||||
}
|
||||
#panel-filelogs .file-logs-table tbody tr.selected::after { content: "-"; }
|
||||
|
||||
#panel-filelogs .file-logs-table tbody tr.selected::after {
|
||||
content: "-";
|
||||
}
|
||||
|
||||
#panel-filelogs .file-logs-table .file-log-detail-row {
|
||||
display: block;
|
||||
margin: -8px 0 8px;
|
||||
@@ -10494,6 +10604,7 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
||||
border-top: 0;
|
||||
background: var(--panel2);
|
||||
}
|
||||
|
||||
#panel-filelogs .file-logs-table .file-log-detail-row td {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
@@ -10502,21 +10613,40 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
||||
max-width: 100%;
|
||||
white-space: normal;
|
||||
}
|
||||
#panel-filelogs .file-log-field { grid-template-columns: minmax(0, 1fr); }
|
||||
#panel-filelogs .file-log-inline-detail { min-width: 0; padding: 8px; }
|
||||
#panel-filelogs .file-log-inline-detail pre { max-width: 100%; overflow-x: auto; }
|
||||
|
||||
#panel-filelogs .file-log-field {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
#panel-filelogs .file-log-inline-detail {
|
||||
min-width: 0;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
#panel-filelogs .file-log-inline-detail pre {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
#panel-filelogs .file-log-aggregate-table {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
#panel-filelogs .file-log-aggregate-table table { table-layout: fixed; }
|
||||
|
||||
#panel-filelogs .file-log-aggregate-table table {
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
#panel-filelogs .file-log-aggregate-table td:first-child {
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
#panel-filelogs .file-logs-pager { justify-content: space-between; }
|
||||
|
||||
#panel-filelogs .file-logs-pager {
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tablet/narrow-desktop log tables scroll locally instead of expanding <main>. */
|
||||
@@ -10527,7 +10657,11 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
#panel-filelogs .file-logs-workspace { grid-template-columns: minmax(0, 1fr); }
|
||||
|
||||
#panel-filelogs .file-logs-workspace {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
#panel-filelogs .file-logs-table,
|
||||
#panel-filelogs .file-log-aggregate-table {
|
||||
box-sizing: border-box;
|
||||
@@ -10535,14 +10669,22 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
#panel-filelogs .file-logs-table { overflow: visible; }
|
||||
#panel-filelogs .file-logs-table {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
#panel-filelogs .file-logs-table td[data-mobile-column="time"],
|
||||
#panel-filelogs .file-logs-table td[data-mobile-column="vhost"] { grid-column: 1 / -1; }
|
||||
#panel-filelogs .file-logs-table td[data-mobile-column="vhost"] {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
#panel-filelogs .file-logs-table td[data-mobile-column="record"] {
|
||||
max-height: 4.4em;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#panel-filelogs .file-logs-table tr.selected td[data-mobile-column="record"] {
|
||||
max-height: none;
|
||||
overflow-wrap: anywhere;
|
||||
@@ -10550,21 +10692,241 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
|
||||
}
|
||||
|
||||
/* 0.31.7: separate protocol tuning actions; keep them comfortably tappable. */
|
||||
.listener-protocol-settings-actions { display: flex; gap: 14px; flex-wrap: wrap; align-items: center; margin-block: 10px; }
|
||||
.listener-protocol-settings-actions button { margin: 0; }
|
||||
.listener-protocol-settings-actions button[hidden] { display: none; }
|
||||
.listener-protocol-settings-actions {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
margin-block: 10px;
|
||||
}
|
||||
|
||||
.listener-protocol-settings-actions button {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.listener-protocol-settings-actions button[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 0.31.7 revision preview shows only changed leaf fields. */
|
||||
.config-history-change-values { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||
.config-history-change-values .diff-before { color: var(--muted); text-decoration: line-through; }
|
||||
.config-history-change-values .diff-after { color: var(--text); }
|
||||
.config-history-change-values code { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.config-history-json-dialog { width: min(1200px, calc(100vw - 24px)); max-width: calc(100vw - 24px); max-height: calc(100dvh - 28px); overflow: auto; }
|
||||
.config-history-json-columns { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; padding: 12px; }
|
||||
.config-history-json-columns section { min-width: 0; }
|
||||
.config-history-json-columns pre { padding: 12px; margin: 0; background: var(--panel); border: 0; overflow: auto; max-height: 68dvh; font: 11px/1.5 var(--mono); }
|
||||
@media (max-width: 760px) { .config-history-json-columns { grid-template-columns: 1fr; } .config-history-json-columns pre { max-height: 38dvh; } }
|
||||
.config-history-change-values {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.acl-profile-attached { margin-block: 6px; }
|
||||
.acl-profile-attached summary { cursor: pointer; color: var(--muted); font-size: 12px; }
|
||||
.acl-profile-attached p { overflow-wrap: anywhere; }
|
||||
.config-history-change-values .diff-before {
|
||||
color: var(--muted);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.config-history-change-values .diff-after {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.config-history-change-values code {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.config-history-json-dialog {
|
||||
width: min(1200px, calc(100vw - 24px));
|
||||
max-width: calc(100vw - 24px);
|
||||
max-height: calc(100dvh - 28px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.config-history-json-columns {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.config-history-json-columns section {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.config-history-json-columns pre {
|
||||
padding: 12px;
|
||||
margin: 0;
|
||||
background: var(--panel);
|
||||
border: 0;
|
||||
overflow: auto;
|
||||
max-height: 68dvh;
|
||||
font: 11px/1.5 var(--mono);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.config-history-json-columns {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.config-history-json-columns pre {
|
||||
max-height: 38dvh;
|
||||
}
|
||||
}
|
||||
|
||||
.acl-profile-attached {
|
||||
margin-block: 6px;
|
||||
}
|
||||
|
||||
.acl-profile-attached summary {
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.acl-profile-attached p {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* v0.32 Stream extensions reuse the responsive Vhost editor and stat cards. */
|
||||
.stream-backend-pool {
|
||||
display: grid;
|
||||
gap: .8rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.stream-backend-list {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stream-backend-row {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--line, #454d59);
|
||||
border-radius: .65rem;
|
||||
padding: .85rem;
|
||||
}
|
||||
|
||||
.stream-backend-row>legend {
|
||||
font-weight: 650;
|
||||
padding: 0 .35rem;
|
||||
}
|
||||
|
||||
.stream-backend-row>.quiet {
|
||||
margin-top: .6rem;
|
||||
}
|
||||
|
||||
.stream-backend-status {
|
||||
display: grid;
|
||||
gap: .4rem;
|
||||
padding: .75rem 0;
|
||||
}
|
||||
|
||||
.stream-backend-status-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: .5rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: .85em;
|
||||
}
|
||||
|
||||
.stream-backend-status-row>span:first-child {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
[data-dashboard-widget^="stream_"][hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Description text is a tooltip, not a second line inside each Appearance tile. */
|
||||
.dashboard-widget-setting {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dashboard-widget-setting:is(:hover, :focus-within) {
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.dashboard-widget-setting-label-block {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-widget-description {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
inset-inline-start: 4px;
|
||||
top: calc(100% + 5px);
|
||||
width: max-content;
|
||||
max-width: min(290px, calc(100vw - 48px));
|
||||
box-sizing: border-box;
|
||||
padding: 9px 11px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: var(--panel2);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, .24);
|
||||
color: var(--text);
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
line-height: 1.45;
|
||||
white-space: normal;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transform: translateY(-4px);
|
||||
transition: opacity .12s ease, transform .12s ease, visibility .12s;
|
||||
}
|
||||
|
||||
.dashboard-widget-setting:is(:hover, :focus-within) .dashboard-widget-description {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.dashboard-widget-toggle:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.vh-enable-control.vh-enable-no-hint {
|
||||
grid-template-rows: auto;
|
||||
}
|
||||
|
||||
.vh-enable-control.vh-enable-no-hint> :is(input, .vh-enable-state) {
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
#panel-overview .stats-grid>.stat {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#panel-overview .stats-grid .stat>small {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
#panel-overview .stream-traffic-legend .legend-rx i {
|
||||
background: var(--chart-accent);
|
||||
}
|
||||
|
||||
#panel-overview .stream-traffic-legend .legend-tx i {
|
||||
background: var(--chart-secondary);
|
||||
}
|
||||
|
||||
#panel-overview .stats-grid .stat-chart>.stat-chart-legend {
|
||||
flex-wrap: wrap;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
@media (min-width: 1201px) {
|
||||
#panel-overview .stats-grid {
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
#panel-overview .stats-grid>.stat {
|
||||
min-height: 126px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html[data-dashboard-density="normal"] #panel-overview .stats-grid>.stat {
|
||||
min-height: 136px;
|
||||
}
|
||||
|
||||
html[data-dashboard-density="ultra"] #panel-overview .stats-grid>.stat {
|
||||
min-height: 105px;
|
||||
}
|
||||
}
|
||||
+471
-468
@@ -1,473 +1,476 @@
|
||||
function basicAuthEditor(parent, settings = {}) {
|
||||
const realm = field(parent, "basic-realm", "basicRealm", settings.realm || "Restricted", "text", { maxLength: 128 });
|
||||
const secure = field(parent, "basic-tls", "basicRequireTls", settings.require_tls !== false, "checkbox"), forward = field(parent, "basic-forward", "basicForward", settings.forward_credentials, "checkbox");
|
||||
const rows = el("div", undefined, "vh-wide basic-users"); parent.append(rows); let sequence = 0;
|
||||
function add(user = {}) {
|
||||
if (rows.children.length >= 64) { toast(t("basic.too_many_users"), true); return; } const row = el("div", undefined, "basic-user");
|
||||
const username = field(row, "basic-username", "username", user.username || "", "text", { maxLength: 128 });
|
||||
const password = field(row, "basic-password", "basicPassword", "", "password", { maxLength: 128 }); password.autocomplete = "new-password"; password.placeholder = user.password_hash ? t("basicKeepPassword") : t("basicPassword");
|
||||
const state = el("span", user.password_hash ? t("basicHashSaved") : t("basicHashRequired"), "fine"); row.append(state, button("remove", () => { row.remove(); changed(); })); row.userHash = user.password_hash || ""; row.sequence = ++sequence; rows.append(row);
|
||||
row.prepare = async () => { if (!password.value) { if (!row.userHash) throw new Error(t("basicHashRequired")); return; } const name = username.value.trim(), secret = password.value, epoch = draftEpoch; const result = await api("/api/basic-auth/hash", { method: "POST", body: JSON.stringify({ username: name, password: secret }) }); if (epoch !== draftEpoch || !row.isConnected || password.value !== secret || username.value.trim() !== name) throw new Error(t("vhUnsaved")); row.userHash = result.password_hash; password.value = ""; password.placeholder = t("basicKeepPassword"); state.textContent = t("basicHashSaved"); };
|
||||
function basicAuthEditor(parent, settings = {}) {
|
||||
const realm = field(parent, "basic-realm", "basicRealm", settings.realm || "Restricted", "text", { maxLength: 128 });
|
||||
const secure = field(parent, "basic-tls", "basicRequireTls", settings.require_tls !== false, "checkbox"), forward = field(parent, "basic-forward", "basicForward", settings.forward_credentials, "checkbox");
|
||||
const rows = el("div", undefined, "vh-wide basic-users"); parent.append(rows); let sequence = 0;
|
||||
function add(user = {}) {
|
||||
if (rows.children.length >= 64) { toast(t("basic.too_many_users"), true); return; } const row = el("div", undefined, "basic-user");
|
||||
const username = field(row, "basic-username", "username", user.username || "", "text", { maxLength: 128 });
|
||||
const password = field(row, "basic-password", "basicPassword", "", "password", { maxLength: 128 }); password.autocomplete = "new-password"; password.placeholder = user.password_hash ? t("basicKeepPassword") : t("basicPassword");
|
||||
const state = el("span", user.password_hash ? t("basicHashSaved") : t("basicHashRequired"), "fine"); row.append(state, button("remove", () => { row.remove(); changed(); })); row.userHash = user.password_hash || ""; row.sequence = ++sequence; rows.append(row);
|
||||
row.prepare = async () => { if (!password.value) { if (!row.userHash) throw new Error(t("basicHashRequired")); return; } const name = username.value.trim(), secret = password.value, epoch = draftEpoch; const result = await api("/api/basic-auth/hash", { method: "POST", body: JSON.stringify({ username: name, password: secret }) }); if (epoch !== draftEpoch || !row.isConnected || password.value !== secret || username.value.trim() !== name) throw new Error(t("vhUnsaved")); row.userHash = result.password_hash; password.value = ""; password.placeholder = t("basicKeepPassword"); state.textContent = t("basicHashSaved"); };
|
||||
}
|
||||
for (const user of settings.users || []) add(user); parent.append(button("basicAddUser", () => { add(); changed(); })); note(parent, "basicHelp");
|
||||
return { async prepare() { for (const row of rows.children) await row.prepare(); }, read: () => ({ enabled: str("auth-mode") === "basic", realm: realm.value.trim(), require_tls: secure.checked, forward_credentials: forward.checked, users: [...rows.children].map(row => ({ username: str("basic-username", row), password_hash: row.userHash })) }) };
|
||||
}
|
||||
|
||||
function hostTagEditor(parent, hosts = [], onUpdate = () => { }) {
|
||||
const wrapper = el("div", undefined, "vh-field vh-wide host-chip-field"); wrapper.append(text("span", "vhHosts"));
|
||||
const hidden = document.createElement("textarea"); hidden.name = "hosts"; hidden.hidden = true;
|
||||
const box = el("div", undefined, "host-chip-editor"), input = document.createElement("input"); input.type = "text"; input.maxLength = 253; input.placeholder = "app.example.com / *.example.com"; input.setAttribute("aria-label", t("vhHostAdd"));
|
||||
const values = [];
|
||||
const normalize = raw => {
|
||||
let value = raw.trim().toLowerCase().replace(/\.$/, ""); if (!value) return null; const wildcard = value.startsWith("*."); const base = wildcard ? value.slice(2) : value;
|
||||
if (base.length > 253 || !/^[a-z0-9.-]+$/.test(base) || base.startsWith(".") || base.endsWith(".") || base.split(".").some(part => !part || part.length > 63 || part.startsWith("-") || part.endsWith("-"))) throw new Error(t("vhHostInvalid"));
|
||||
if (wildcard && !base.includes(".")) throw new Error(t("vhHostInvalid")); return wildcard ? `*.${base}` : base;
|
||||
};
|
||||
const sync = () => { hidden.value = values.join("\n"); onUpdate([...values]); box.querySelectorAll(".host-chip").forEach(node => node.remove()); for (const value of values) { const chip = el("span", undefined, "host-chip"); chip.append(el("span", value)); const remove = el("button", "×"); remove.type = "button"; remove.setAttribute("aria-label", `${t("remove")} ${value}`); remove.addEventListener("click", () => { values.splice(values.indexOf(value), 1); sync(); changed(); }); chip.append(remove); box.insertBefore(chip, input); } };
|
||||
const add = raw => { const value = normalize(raw); if (!value || values.includes(value)) return; values.push(value); sync(); changed(); };
|
||||
const consume = raw => { for (const part of raw.split(/[\s,]+/).filter(Boolean)) add(part); input.value = ""; };
|
||||
input.addEventListener("keydown", event => { if (event.key === "Enter" || event.key === ",") { event.preventDefault(); try { consume(input.value); } catch (error) { toast(error.message, true); } } });
|
||||
input.addEventListener("blur", () => { if (!input.value.trim()) return; try { consume(input.value); } catch (error) { toast(error.message, true); } });
|
||||
input.addEventListener("paste", event => { const textValue = event.clipboardData?.getData("text") || ""; if (!/[\s,]/.test(textValue.trim())) return; event.preventDefault(); try { consume(textValue); } catch (error) { toast(error.message, true); } });
|
||||
box.append(input); wrapper.append(hidden, box, text("p", "vhHostsHelp", "fine")); parent.append(wrapper); for (const host of hosts) { try { add(host); } catch { } } dirty = false;
|
||||
return () => { if (input.value.trim()) consume(input.value); if (!values.length) throw new Error(t("vhHostRequired")); return [...values]; };
|
||||
}
|
||||
|
||||
function load(value = defaults(), migrationIds = [], show = true) {
|
||||
draftEpoch++; stagedCertificates = []; uploading = false; backendHealthEditorControl = null;
|
||||
editing = value.id || null; revision = envelope?.revision ?? null; dirty = false; initialized = true;
|
||||
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");
|
||||
let refreshCanonical = () => { };
|
||||
const enabledControl = field(g, "enabled", "vhEnabled", value.enabled !== false, "checkbox");
|
||||
window.rustProxyEnableControl(enabledControl, "vhEnableHint");
|
||||
const enabledLabel = enabledControl.closest("label");
|
||||
enabledLabel?.querySelector(".vh-enable-hint")?.remove();
|
||||
enabledLabel?.classList.add("vh-enable-no-hint");
|
||||
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"]] });
|
||||
note(g, "vhWebsocketTransportHelp");
|
||||
const websocketLegacy = field(g, "websocket-legacy-support", "vhWebsocketLegacySupport", value.websocket_legacy_support === true, "checkbox");
|
||||
note(g, "vhWebsocketLegacySupportHelp");
|
||||
g = group("vhFrontends");
|
||||
// Keep the list of names beside the WWW routing control. Every name in
|
||||
// frontend.hosts remains an independent alias of this vhost.
|
||||
readHosts = hostTagEditor(g, value.frontend?.hosts || [], hosts => refreshCanonical(hosts));
|
||||
const bindingControl = window.rustProxyIntegrations.bindings(g, value.frontend?.listener_ids || [], !editing); readBindings = bindingControl.read;
|
||||
note(g, "listenerDefaultPortsHint");
|
||||
field(g, "force-ssl", "vhForceSsl", value.force_ssl === true, "checkbox");
|
||||
note(g, "vhForceSslHelp");
|
||||
const wwwPanel = el("section", undefined, "vh-wide vh-www-panel");
|
||||
const wwwHeader = el("div", undefined, "vh-www-header");
|
||||
const wwwTitle = text("strong", "vhWwwPanelTitle", "vh-www-title");
|
||||
const wwwHostCount = el("span", "", "vh-www-host-count");
|
||||
const wwwRedirect = field(wwwHeader, "www-redirect", "vhWwwEnable", value.www_redirect === true, "checkbox");
|
||||
wwwRedirect.closest("label").classList.add("vh-www-toggle");
|
||||
wwwHeader.prepend(wwwTitle, wwwHostCount);
|
||||
const wwwInactive = text("p", "vhWwwInactive", "fine vh-www-inactive");
|
||||
const wwwDetails = el("div", undefined, "vh-www-details");
|
||||
wwwDetails.id = "vh-www-details";
|
||||
wwwRedirect.setAttribute("aria-controls", wwwDetails.id);
|
||||
const wwwLabel = text("p", "vhWwwRedirectTarget", "vh-www-option-title");
|
||||
const wwwChoices = el("div", undefined, "vh-www-choices");
|
||||
wwwChoices.setAttribute("role", "radiogroup");
|
||||
wwwChoices.setAttribute("aria-label", t("vhWwwRedirectTarget"));
|
||||
const wwwTarget = document.createElement("input");
|
||||
wwwTarget.type = "hidden"; wwwTarget.name = "www-redirect-target";
|
||||
const wwwNoHosts = text("p", "vhWwwNoHosts", "fine vh-www-no-hosts");
|
||||
const wwwOther = el("div", undefined, "vh-www-other");
|
||||
const wwwHelp = text("p", "vhWwwRedirectHelp", "fine vh-www-help");
|
||||
wwwDetails.append(wwwLabel, wwwChoices, wwwNoHosts, wwwOther, wwwHelp);
|
||||
wwwPanel.append(wwwHeader, wwwInactive, wwwDetails, wwwTarget);
|
||||
g.append(wwwPanel);
|
||||
// Persist only the selected canonical host; choosing the www variant does
|
||||
// not require adding an extra hostname to the vhost manually.
|
||||
let selectedCanonical = value.www_redirect_target || (value.frontend?.hosts || []).find(host => !host.startsWith("*.")) || "";
|
||||
const syncCanonical = () => {
|
||||
const allHosts = lines(read("hosts").value);
|
||||
const exactHosts = allHosts.filter(host => !host.startsWith("*.") && !/^[\d.:]+$/.test(host));
|
||||
const bases = [...new Set(exactHosts.map(host => host.startsWith("www.") ? host.slice(4) : host))]
|
||||
.filter(base => !/^[\d.:]+$/.test(base) && `www.${base}`.length <= 253);
|
||||
const options = bases.flatMap(base => [base, `www.${base}`]);
|
||||
if (!options.includes(selectedCanonical)) selectedCanonical = exactHosts[0] || "";
|
||||
wwwTarget.value = selectedCanonical;
|
||||
wwwHostCount.textContent = t("vhWwwSharedCount").replace("{count}", String(allHosts.length));
|
||||
wwwChoices.replaceChildren();
|
||||
for (const base of bases) {
|
||||
const pair = el("div", undefined, "vh-www-domain-pair");
|
||||
pair.append(el("strong", base, "vh-www-domain-label"));
|
||||
const directions = el("div", undefined, "vh-www-pair-directions");
|
||||
for (const target of [base, `www.${base}`]) {
|
||||
const companion = target === base ? `www.${base}` : base;
|
||||
const choice = el("label", undefined, "vh-www-choice");
|
||||
if (target === selectedCanonical) choice.classList.add("is-selected");
|
||||
const radio = document.createElement("input"); radio.type = "radio"; radio.name = "www-canonical-choice";
|
||||
radio.value = target; radio.checked = target === selectedCanonical;
|
||||
const copy = el("span", undefined, "vh-www-choice-copy");
|
||||
copy.append(el("strong", target), el("span", `${companion} \u2192 ${target}`, "vh-www-route"));
|
||||
choice.append(radio, copy);
|
||||
radio.addEventListener("change", () => {
|
||||
if (!radio.checked) return;
|
||||
selectedCanonical = target;
|
||||
syncCanonical();
|
||||
changed();
|
||||
});
|
||||
directions.append(choice);
|
||||
}
|
||||
pair.append(directions); wwwChoices.append(pair);
|
||||
}
|
||||
for (const user of settings.users || []) add(user); parent.append(button("basicAddUser", () => { add(); changed(); })); note(parent, "basicHelp");
|
||||
return { async prepare() { for (const row of rows.children) await row.prepare(); }, read: () => ({ enabled: str("auth-mode") === "basic", realm: realm.value.trim(), require_tls: secure.checked, forward_credentials: forward.checked, users: [...rows.children].map(row => ({ username: str("basic-username", row), password_hash: row.userHash })) }) };
|
||||
}
|
||||
|
||||
function hostTagEditor(parent, hosts = [], onUpdate = () => {}) {
|
||||
const wrapper = el("div", undefined, "vh-field vh-wide host-chip-field"); wrapper.append(text("span", "vhHosts"));
|
||||
const hidden = document.createElement("textarea"); hidden.name = "hosts"; hidden.hidden = true;
|
||||
const box = el("div", undefined, "host-chip-editor"), input = document.createElement("input"); input.type = "text"; input.maxLength = 253; input.placeholder = "app.example.com / *.example.com"; input.setAttribute("aria-label", t("vhHostAdd"));
|
||||
const values = [];
|
||||
const normalize = raw => {
|
||||
let value = raw.trim().toLowerCase().replace(/\.$/, ""); if (!value) return null; const wildcard = value.startsWith("*."); const base = wildcard ? value.slice(2) : value;
|
||||
if (base.length > 253 || !/^[a-z0-9.-]+$/.test(base) || base.startsWith(".") || base.endsWith(".") || base.split(".").some(part => !part || part.length > 63 || part.startsWith("-") || part.endsWith("-"))) throw new Error(t("vhHostInvalid"));
|
||||
if (wildcard && !base.includes(".")) throw new Error(t("vhHostInvalid")); return wildcard ? `*.${base}` : base;
|
||||
};
|
||||
const sync = () => { hidden.value = values.join("\n"); onUpdate([...values]); box.querySelectorAll(".host-chip").forEach(node => node.remove()); for (const value of values) { const chip = el("span", undefined, "host-chip"); chip.append(el("span", value)); const remove = el("button", "×"); remove.type = "button"; remove.setAttribute("aria-label", `${t("remove")} ${value}`); remove.addEventListener("click", () => { values.splice(values.indexOf(value), 1); sync(); changed(); }); chip.append(remove); box.insertBefore(chip, input); } };
|
||||
const add = raw => { const value = normalize(raw); if (!value || values.includes(value)) return; values.push(value); sync(); changed(); };
|
||||
const consume = raw => { for (const part of raw.split(/[\s,]+/).filter(Boolean)) add(part); input.value = ""; };
|
||||
input.addEventListener("keydown", event => { if (event.key === "Enter" || event.key === ",") { event.preventDefault(); try { consume(input.value); } catch (error) { toast(error.message, true); } } });
|
||||
input.addEventListener("blur", () => { if (!input.value.trim()) return; try { consume(input.value); } catch (error) { toast(error.message, true); } });
|
||||
input.addEventListener("paste", event => { const textValue = event.clipboardData?.getData("text") || ""; if (!/[\s,]/.test(textValue.trim())) return; event.preventDefault(); try { consume(textValue); } catch (error) { toast(error.message, true); } });
|
||||
box.append(input); wrapper.append(hidden, box, text("p", "vhHostsHelp", "fine")); parent.append(wrapper); for (const host of hosts) { try { add(host); } catch { } } dirty = false;
|
||||
return () => { if (input.value.trim()) consume(input.value); if (!values.length) throw new Error(t("vhHostRequired")); return [...values]; };
|
||||
}
|
||||
|
||||
function load(value = defaults(), migrationIds = [], show = true) {
|
||||
draftEpoch++; stagedCertificates = []; uploading = false; backendHealthEditorControl = null;
|
||||
editing = value.id || null; revision = envelope?.revision ?? null; dirty = false; initialized = true;
|
||||
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");
|
||||
let refreshCanonical = () => {};
|
||||
const enabledControl = field(g, "enabled", "vhEnabled", value.enabled !== false, "checkbox");
|
||||
window.rustProxyEnableControl(enabledControl, "vhEnableHint");
|
||||
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"]] });
|
||||
note(g, "vhWebsocketTransportHelp");
|
||||
const websocketLegacy = field(g, "websocket-legacy-support", "vhWebsocketLegacySupport", value.websocket_legacy_support === true, "checkbox");
|
||||
note(g, "vhWebsocketLegacySupportHelp");
|
||||
g = group("vhFrontends");
|
||||
// Keep the list of names beside the WWW routing control. Every name in
|
||||
// frontend.hosts remains an independent alias of this vhost.
|
||||
readHosts = hostTagEditor(g, value.frontend?.hosts || [], hosts => refreshCanonical(hosts));
|
||||
const bindingControl = window.rustProxyIntegrations.bindings(g, value.frontend?.listener_ids || [], !editing); readBindings = bindingControl.read;
|
||||
note(g, "listenerDefaultPortsHint");
|
||||
field(g, "force-ssl", "vhForceSsl", value.force_ssl === true, "checkbox");
|
||||
note(g, "vhForceSslHelp");
|
||||
const wwwPanel = el("section", undefined, "vh-wide vh-www-panel");
|
||||
const wwwHeader = el("div", undefined, "vh-www-header");
|
||||
const wwwTitle = text("strong", "vhWwwPanelTitle", "vh-www-title");
|
||||
const wwwHostCount = el("span", "", "vh-www-host-count");
|
||||
const wwwRedirect = field(wwwHeader, "www-redirect", "vhWwwEnable", value.www_redirect === true, "checkbox");
|
||||
wwwRedirect.closest("label").classList.add("vh-www-toggle");
|
||||
wwwHeader.prepend(wwwTitle, wwwHostCount);
|
||||
const wwwInactive = text("p", "vhWwwInactive", "fine vh-www-inactive");
|
||||
const wwwDetails = el("div", undefined, "vh-www-details");
|
||||
wwwDetails.id = "vh-www-details";
|
||||
wwwRedirect.setAttribute("aria-controls", wwwDetails.id);
|
||||
const wwwLabel = text("p", "vhWwwRedirectTarget", "vh-www-option-title");
|
||||
const wwwChoices = el("div", undefined, "vh-www-choices");
|
||||
wwwChoices.setAttribute("role", "radiogroup");
|
||||
wwwChoices.setAttribute("aria-label", t("vhWwwRedirectTarget"));
|
||||
const wwwTarget = document.createElement("input");
|
||||
wwwTarget.type = "hidden"; wwwTarget.name = "www-redirect-target";
|
||||
const wwwNoHosts = text("p", "vhWwwNoHosts", "fine vh-www-no-hosts");
|
||||
const wwwOther = el("div", undefined, "vh-www-other");
|
||||
const wwwHelp = text("p", "vhWwwRedirectHelp", "fine vh-www-help");
|
||||
wwwDetails.append(wwwLabel, wwwChoices, wwwNoHosts, wwwOther, wwwHelp);
|
||||
wwwPanel.append(wwwHeader, wwwInactive, wwwDetails, wwwTarget);
|
||||
g.append(wwwPanel);
|
||||
// Persist only the selected canonical host; choosing the www variant does
|
||||
// not require adding an extra hostname to the vhost manually.
|
||||
let selectedCanonical = value.www_redirect_target || (value.frontend?.hosts || []).find(host => !host.startsWith("*.")) || "";
|
||||
const syncCanonical = () => {
|
||||
const allHosts = lines(read("hosts").value);
|
||||
const exactHosts = allHosts.filter(host => !host.startsWith("*.") && !/^[\d.:]+$/.test(host));
|
||||
const bases = [...new Set(exactHosts.map(host => host.startsWith("www.") ? host.slice(4) : host))]
|
||||
.filter(base => !/^[\d.:]+$/.test(base) && `www.${base}`.length <= 253);
|
||||
const options = bases.flatMap(base => [base, `www.${base}`]);
|
||||
if (!options.includes(selectedCanonical)) selectedCanonical = exactHosts[0] || "";
|
||||
wwwTarget.value = selectedCanonical;
|
||||
wwwHostCount.textContent = t("vhWwwSharedCount").replace("{count}", String(allHosts.length));
|
||||
wwwChoices.replaceChildren();
|
||||
for (const base of bases) {
|
||||
const pair = el("div", undefined, "vh-www-domain-pair");
|
||||
pair.append(el("strong", base, "vh-www-domain-label"));
|
||||
const directions = el("div", undefined, "vh-www-pair-directions");
|
||||
for (const target of [base, `www.${base}`]) {
|
||||
const companion = target === base ? `www.${base}` : base;
|
||||
const choice = el("label", undefined, "vh-www-choice");
|
||||
if (target === selectedCanonical) choice.classList.add("is-selected");
|
||||
const radio = document.createElement("input"); radio.type = "radio"; radio.name = "www-canonical-choice";
|
||||
radio.value = target; radio.checked = target === selectedCanonical;
|
||||
const copy = el("span", undefined, "vh-www-choice-copy");
|
||||
copy.append(el("strong", target), el("span", `${companion} \u2192 ${target}`, "vh-www-route"));
|
||||
choice.append(radio, copy);
|
||||
radio.addEventListener("change", () => {
|
||||
if (!radio.checked) return;
|
||||
selectedCanonical = target;
|
||||
syncCanonical();
|
||||
changed();
|
||||
});
|
||||
directions.append(choice);
|
||||
}
|
||||
pair.append(directions); wwwChoices.append(pair);
|
||||
}
|
||||
const companion = selectedCanonical.startsWith("www.") ? selectedCanonical.slice(4) : `www.${selectedCanonical}`;
|
||||
const aliases = allHosts.filter(host => host !== selectedCanonical && host !== companion);
|
||||
wwwOther.replaceChildren();
|
||||
if (aliases.length) {
|
||||
wwwOther.append(text("span", "vhWwwOtherDomains"));
|
||||
for (const host of aliases) wwwOther.append(el("code", host, "vh-www-alias"));
|
||||
}
|
||||
wwwNoHosts.hidden = bases.length > 0;
|
||||
wwwRedirect.disabled = !bases.length;
|
||||
if (!bases.length) wwwRedirect.checked = false;
|
||||
// Never hide the destination choices: the unchecked state is a preview
|
||||
// that the operator can configure before enabling the rule.
|
||||
wwwDetails.hidden = false;
|
||||
wwwInactive.hidden = wwwRedirect.checked;
|
||||
wwwRedirect.setAttribute("aria-expanded", "true");
|
||||
wwwPanel.classList.toggle("is-enabled", wwwRedirect.checked);
|
||||
};
|
||||
refreshCanonical = syncCanonical;
|
||||
wwwRedirect.addEventListener("change", () => { syncCanonical(); changed(); });
|
||||
syncCanonical();
|
||||
field(g, "forward-client-ip", "vhForwardClientIp", value.forward_client_ip !== false, "checkbox");
|
||||
note(g, "vhForwardClientIpHelp");
|
||||
field(g, "cloudflare-client-ip", "vhCloudflareClientIp", value.cloudflare_client_ip === true, "checkbox");
|
||||
const protocolRow = el("div", undefined, "vh-wide protocol-toggle-row"); g.append(protocolRow);
|
||||
const h2Enabled = field(protocolRow, "http2-enabled", "vhHttp2Enabled", value.http2_enabled !== false, "checkbox");
|
||||
const h3Enabled = field(protocolRow, "http3-enabled", "vhHttp3Enabled", value.http3_enabled !== false, "checkbox");
|
||||
note(g, "vhFrontendProtocolsHelp"); note(g, "vhFrontendSelectionOnly");
|
||||
const syncFrontendTransports = () => {
|
||||
const capabilities = bindingControl.capabilities();
|
||||
h2Enabled.disabled = !capabilities.http2; h3Enabled.disabled = !capabilities.http3;
|
||||
if (!capabilities.http2) h2Enabled.checked = false;
|
||||
if (!capabilities.http3) h3Enabled.checked = false;
|
||||
const h2Available = capabilities.http2 && h2Enabled.checked;
|
||||
const h3Available = capabilities.http3 && h3Enabled.checked;
|
||||
websocketMode.disabled = !websocketEnabled.checked;
|
||||
websocketLegacy.disabled = !websocketEnabled.checked;
|
||||
const optionH2 = websocketMode.querySelector('option[value="h2"]');
|
||||
const optionH3 = websocketMode.querySelector('option[value="h3"]');
|
||||
const optionH2H3 = websocketMode.querySelector('option[value="h2h3"]');
|
||||
optionH2.disabled = !h2Available;
|
||||
optionH3.disabled = !h3Available;
|
||||
optionH2H3.disabled = !h2Available || !h3Available;
|
||||
const selected = websocketMode.value;
|
||||
if ((selected === "h2" && !h2Available) || (selected === "h3" && !h3Available) || (selected === "h2h3" && (!h2Available || !h3Available))) {
|
||||
websocketMode.value = selected === "h2h3" && h2Available ? "h2" : selected === "h2h3" && h3Available ? "h3" : "h1";
|
||||
}
|
||||
};
|
||||
websocketEnabled.addEventListener("change", syncFrontendTransports);
|
||||
bindingControl.onChange(syncFrontendTransports);
|
||||
h2Enabled.addEventListener("change", syncFrontendTransports);
|
||||
h3Enabled.addEventListener("change", syncFrontendTransports);
|
||||
syncFrontendTransports();
|
||||
g = group("vhBackendPool"); const members = el("div", undefined, "vh-wide vh-members"); g.append(members);
|
||||
(value.backends || []).forEach(m => memberRow(members, m)); g.append(button("vhAddBackend", () => { memberRow(members); changed(); }));
|
||||
g = group("vhBackendDiagnostics");
|
||||
field(g, "timing-headers", "vhTimingHeaders", value.timing_headers === true, "checkbox");
|
||||
field(g, "backend-header", "vhBackendHeader", value.backend_header === true, "checkbox");
|
||||
field(g, "backend-timing-header", "vhBackendTimingHeader", value.backend_timing_header === true, "checkbox");
|
||||
note(g, "vhBackendDiagnosticsHelp");
|
||||
const proxyTimeouts = { ...defaultProxyTimeouts(), ...(value.proxy_timeouts || {}) };
|
||||
g = group("vhProxyTimeouts");
|
||||
field(g, "proxy-connect-timeout", "vhProxyConnectTimeout", proxyTimeouts.connect_timeout_seconds ?? 10, "number", { required: true, min: 1, max: 300 });
|
||||
field(g, "proxy-header-timeout", "vhProxyHeaderTimeout", proxyTimeouts.response_header_timeout_seconds ?? 120, "number", { required: true, min: 1, max: 86400 });
|
||||
field(g, "proxy-read-timeout", "vhProxyReadTimeout", proxyTimeouts.read_timeout_seconds ?? 60, "number", { required: true, min: 1, max: 86400 });
|
||||
field(g, "proxy-send-timeout", "vhProxySendTimeout", proxyTimeouts.send_timeout_seconds ?? 60, "number", { required: true, min: 1, max: 86400 });
|
||||
note(g, "vhProxyTimeoutsHelp");
|
||||
const settings = value.load_balancing || {}, health = settings.health_check || {}, keepalive = settings.keepalive || {}, retry = settings.retry || {}, sticky = settings.sticky || {};
|
||||
g = group("vhBalancing"); field(g, "algorithm", "vhAlgorithm", settings.algorithm || "least_connections", "select", { values: [["round_robin", "vhRoundRobin"], ["least_connections", "vhLeastConnections"], ["least_traffic", "vhLeastTraffic"], ["adaptive", "vhAdaptive"], ["ip_hash", "vhIpHash"]] });
|
||||
field(g, "slow-start", "vhSlowStart", settings.slow_start_seconds ?? 10, "number", { required: true, min: 0, max: 300 });
|
||||
field(g, "retry-max", "vhRetryMax", retry.max_retries ?? 1, "number", { required: true, min: 0, max: 3 });
|
||||
field(g, "retry-connect", "vhRetryConnect", retry.retry_connect_errors !== false, "checkbox");
|
||||
field(g, "retry-timeouts", "vhRetryTimeouts", retry.retry_timeouts !== false, "checkbox");
|
||||
field(g, "retry-statuses", "vhRetryStatuses", (retry.retry_statuses || [502, 503, 504]).join(", "), "text", { maxLength: 64 });
|
||||
note(g, "vhRetryHelp");
|
||||
field(g, "keepalive", "vhKeepalive", keepalive.enabled !== false, "checkbox");
|
||||
field(g, "idle-timeout", "vhIdleTimeout", keepalive.idle_timeout_seconds ?? 30, "number", { required: true, min: 1, max: 3600 });
|
||||
field(g, "max-idle", "vhMaxIdle", keepalive.max_idle_per_host ?? 4, "number", { required: true, min: 1, max: 64 });
|
||||
field(g, "tcp-keepalive", "vhTcpKeepalive", keepalive.tcp_keepalive_seconds ?? 30, "number", { required: true, min: 1, max: 3600 });
|
||||
field(g, "pool-recycle-requests", "vhPoolRecycleRequests", keepalive.pool_recycle_requests ?? 25000, "number", { required: true, min: 0, max: 10000000 });
|
||||
field(g, "pool-recycle-seconds", "vhPoolRecycleSeconds", keepalive.pool_recycle_seconds ?? 3600, "number", { required: true, min: 0, max: 604800 });
|
||||
field(g, "http1-max-buffer", "vhHttp1MaxBuffer", keepalive.http1_max_buffer_bytes ?? 131072, "number", { required: true, min: 8192, max: 1048576 });
|
||||
note(g, "vhPoolRecycleHelp");
|
||||
g = group("vhHealth"); const healthMode = field(g, "health-mode", "vhHealthMode", health.mode || "tcp", "select", { values: [["off", "vhOff"], ["tcp", "vhTcp"], ["http", "vhHttp"]] });
|
||||
const healthOffWarning = note(g, "vhHealthOffWarning"); const syncHealthWarning = () => { healthOffWarning.hidden = healthMode.value !== "off"; }; healthMode.addEventListener("change", syncHealthWarning); syncHealthWarning();
|
||||
field(g, "health-path", "vhHealthPath", health.path || "/", "text", { required: true, maxLength: 1024 });
|
||||
field(g, "health-host", "vhHealthHost", health.host || "", "text", { maxLength: 260 });
|
||||
for (const [name, key, val, min, max] of [["interval", "vhInterval", health.interval_ms ?? 5000, 250, 300000], ["timeout", "vhTimeout", health.timeout_ms ?? 2000, 50, 60000], ["slow", "vhSlow", health.slow_response_ms ?? 1500, 0, 60000], ["fall", "vhFall", health.fall ?? 3, 1, 20], ["rise", "vhRise", health.rise ?? 2, 1, 20], ["status-min", "vhStatusMin", health.expected_status_min ?? 200, 100, 599], ["status-max", "vhStatusMax", health.expected_status_max ?? 399, 100, 599]]) field(g, name, key, val, "number", { required: true, min, max }); note(g, "vhHealthNote");
|
||||
backendHealthEditorControl = backendHealthEditor(g, members);
|
||||
note(g, "backendHealthOverridesHelp");
|
||||
const passive = settings.passive_health || {};
|
||||
g = group("vhPassiveHealth");
|
||||
const passiveEnabled = field(g, "passive-enabled", "vhPassiveEnabled", passive.enabled === true, "checkbox");
|
||||
note(g, "vhPassiveHelp");
|
||||
const passiveFields = [
|
||||
field(g, "passive-connect", "vhPassiveConnect", passive.count_connect_errors !== false, "checkbox"),
|
||||
field(g, "passive-timeouts", "vhPassiveTimeouts", passive.count_timeouts !== false, "checkbox"),
|
||||
field(g, "passive-io", "vhPassiveIo", passive.count_io_errors !== false, "checkbox"),
|
||||
field(g, "passive-5xx", "vhPassive5xx", passive.count_http_5xx !== false, "checkbox"),
|
||||
field(g, "passive-slow", "vhPassiveSlow", passive.count_slow_responses === true, "checkbox"),
|
||||
field(g, "passive-fall", "vhPassiveFall", passive.fall ?? "", "number", { min: 1, max: 20 }),
|
||||
field(g, "passive-rise", "vhPassiveRise", passive.rise ?? "", "number", { min: 1, max: 20 })
|
||||
];
|
||||
const passive5xx = passiveFields[3], passiveSlow = passiveFields[4];
|
||||
const passive5xxWarning = note(g, "vhPassive5xxWarning");
|
||||
const passiveSlowWarning = note(g, "vhPassiveSlowWarning");
|
||||
const syncPassive = () => {
|
||||
passiveFields.forEach(control => { control.disabled = !passiveEnabled.checked; const label = control.closest("label"); if (label) label.hidden = !passiveEnabled.checked; });
|
||||
passive5xxWarning.hidden = !passiveEnabled.checked || !passive5xx.checked;
|
||||
passiveSlowWarning.hidden = !passiveEnabled.checked || !passiveSlow.checked;
|
||||
};
|
||||
passiveEnabled.addEventListener("change", syncPassive);
|
||||
passive5xx.addEventListener("change", syncPassive);
|
||||
passiveSlow.addEventListener("change", syncPassive);
|
||||
syncPassive();
|
||||
g = group("vhSticky"); field(g, "sticky-mode", "vhStickyMode", sticky.mode || "off", "select", { values: [["off", "vhOff"], ["cookie", "vhCookie"], ["ip_hash", "vhIpHash"]] });
|
||||
field(g, "cookie-name", "vhCookieName", sticky.cookie_name || "", "text", { maxLength: 96, pattern: "[A-Za-z0-9_-]*" });
|
||||
field(g, "cookie-ttl", "vhCookieTtl", sticky.ttl_seconds ?? 3600, "number", { required: true, min: 60, max: 604800 }); note(g, "vhStickyNote");
|
||||
const rate = value.rate_limit || {}, access = rate.country_access || {}, methodPolicy = value.method_policy || {};
|
||||
g = group("vhTrafficPolicy");
|
||||
const aclProfile = field(g, "acl-profile-id", "vhAclProfile", value.acl_profile_id || "", "select");
|
||||
const noProfile = text("option", "vhAclProfileNone"); noProfile.value = ""; aclProfile.append(noProfile, ...(envelope?.config.acl_profiles || []).map(profile => { const option = el("option", profile.name || profile.id); option.value = profile.id; return option; })); aclProfile.value = value.acl_profile_id || ""; note(g, "vhAclProfileHelp");
|
||||
field(g, "method-mode", "vhMethodMode", methodPolicy.mode || "off", "select", { values: [["off", "vhOff"], ["allow", "locationMethodAllow"], ["block", "locationMethodBlock"]] });
|
||||
field(g, "method-list", "vhMethodList", (methodPolicy.methods || []).join("\n"), "textarea", { rows: 3, maxLength: 1024 });
|
||||
note(g, "vhMethodHelp");
|
||||
field(g, "rate-enabled", "vhRateEnabled", rate.enabled === true, "checkbox");
|
||||
field(g, "rate-requests", "vhRateRequests", rate.requests ?? 120, "number", { required: true, min: 1, max: 1000000 });
|
||||
field(g, "rate-window", "vhRateWindow", rate.window_seconds ?? 60, "number", { required: true, min: 1, max: 3600 });
|
||||
field(g, "rate-burst", "vhRateBurst", rate.burst ?? 30, "number", { required: true, min: 0, max: 1000000 });
|
||||
field(g, "country-mode", "vhCountryMode", access.mode || "off", "select", { values: [["off", "vhOff"], ["block", "vhCountryBlock"], ["allow", "vhCountryAllow"]] });
|
||||
field(g, "country-list", "vhCountryList", (access.countries || []).join("\n"), "textarea", { rows: 3, maxLength: 1024 });
|
||||
note(g, "vhCountryPolicyHelp"); countryRatesReader = countryRateEditor(g, rate.country_limits || []); uaRatesReader = userAgentRateEditor(g, rate.user_agent_limits || []); note(g, "vhRateHelp");
|
||||
const security = value.security_headers || {}, hsts = security.hsts || {};
|
||||
g = group("vhSecurity"); field(g, "hsts", "vhHsts", hsts.enabled, "checkbox"); field(g, "hsts-age", "vhHstsAge", hsts.max_age_seconds ?? 31536000, "number", { required: true, min: 0, max: 63072000 });
|
||||
field(g, "hsts-sub", "vhHstsSub", hsts.include_subdomains, "checkbox"); field(g, "hsts-preload", "vhHstsPreload", hsts.preload, "checkbox"); field(g, "nosniff", "vhNosniff", security.nosniff, "checkbox");
|
||||
field(g, "frame", "vhFrame", security.frame_options || "preserve", "select", { values: [["preserve", "vhPreserve"], ["deny", "vhDeny"], ["sameorigin", "vhSameorigin"]] });
|
||||
field(g, "referrer", "vhReferrer", security.referrer_policy || "", "text", { maxLength: 8192 });
|
||||
field(g, "csp", "vhCsp", security.content_security_policy || "", "textarea", { rows: 2, maxLength: 8192 });
|
||||
field(g, "permissions", "vhPermissions", security.permissions_policy || "", "text", { maxLength: 8192 });
|
||||
const uaBlock = value.user_agent_blocking || {};
|
||||
field(g, "ua-block-enabled", "vhUaBlockEnabled", uaBlock.enabled === true, "checkbox");
|
||||
field(g, "ua-block-patterns", "vhUaBlockPatterns", (uaBlock.patterns || []).join("\n"), "textarea", { rows: 4, maxLength: 32768 });
|
||||
note(g, "vhUaBlockHelp");
|
||||
g = group("vhAdvanced", true); field(g, "paths", "vhPaths", JSON.stringify(value.routes || [], null, 2), "textarea", { rows: 5, maxLength: 131072 });
|
||||
field(g, "acl", "vhAcl", JSON.stringify(value.acl ?? null, null, 2), "textarea", { rows: 5, maxLength: 32768 });
|
||||
field(g, "headers", "vhHeaders", JSON.stringify(value.headers || {}, null, 2), "textarea", { rows: 5, maxLength: 65536 });
|
||||
g = group("vhLocations");
|
||||
field(g, "main-error-target", "vhErrorFallbackTarget", value.error_fallback?.target || "", "text", { maxLength: 64, pattern: "[A-Za-z0-9_-]{0,64}" });
|
||||
field(g, "main-error-statuses", "vhErrorFallbackStatuses", (value.error_fallback?.statuses || [404]).join(", "), "text", { maxLength: 64 });
|
||||
note(g, "vhErrorFallbackHelp");
|
||||
locationsReader = locationsEditor(g, value.routes || []);
|
||||
g = group("vhSsl");
|
||||
const selector = field(g, "certificate-id", "vhCertificate", value.certificate_id || "", "select");
|
||||
const fillCertificates = () => { const selected = selector.value || value.certificate_id || ""; const inherit = text("option", "vhCertificateDefault"); inherit.value = ""; selector.replaceChildren(inherit, ...[...(envelope?.config.tls?.certificates || []), ...stagedCertificates].map(c => { const o = el("option", c.id); o.value = c.id; return o; })); selector.value = selected; }; fillCertificates();
|
||||
g.append(button("certManage", async () => { if (!(await closeEditor())) return; setPanel("tls"); })); note(g, "certSelectHint");
|
||||
const inheritedTlsPolicy = envelope?.config.tls?.policy || { tls12: true, tls13: true, cipher_suites: [] };
|
||||
const tlsPolicy = value.tls_policy || inheritedTlsPolicy;
|
||||
const customTlsPolicy = field(g, "tls-policy-custom", "vhTlsOverride", value.tls_policy != null, "checkbox");
|
||||
const tls12 = field(g, "tls-policy-12", "tls12", tlsPolicy.tls12 !== false, "checkbox");
|
||||
const tls13 = field(g, "tls-policy-13", "tls13", tlsPolicy.tls13 !== false, "checkbox");
|
||||
const tlsCiphers = field(g, "tls-policy-ciphers", "tlsCiphers", (tlsPolicy.cipher_suites || []).join("\n"), "textarea", { rows: 7, maxLength: 8192 });
|
||||
const syncTlsPolicy = () => { for (const input of [tls12, tls13, tlsCiphers]) input.disabled = !customTlsPolicy.checked; };
|
||||
customTlsPolicy.addEventListener("change", syncTlsPolicy); syncTlsPolicy(); note(g, "vhTlsOverrideHelp");
|
||||
g = group("vhTinyauth"); const auth = value.tinyauth || {}, authRoot = g;
|
||||
const authMode = field(g, "auth-mode", "authMode", value.basic_auth?.enabled ? "basic" : auth.enabled ? "tinyauth" : "none", "select", { values: [["none", "authNone"], ["tinyauth", "authTinyauth"], ["basic", "authBasic"]] });
|
||||
note(g, "authModeHelp");
|
||||
|
||||
const aclBody = el("section", undefined, "vh-wide auth-subcard auth-ip-card"); authRoot.append(aclBody);
|
||||
aclBody.append(text("h3", "vhIpAccess"));
|
||||
aclControl = aclEditor(aclBody, value.acl, () => { if (aclControl) read("acl").value = JSON.stringify(aclControl.read(), null, 2); });
|
||||
|
||||
const taBody = el("section", undefined, "vh-wide auth-subcard"), basicBody = el("section", undefined, "vh-wide auth-subcard"); authRoot.append(taBody, basicBody);
|
||||
taBody.append(text("h3", "authTinyauth"));
|
||||
const taPrimary = el("div", undefined, "vh-grid"); taBody.append(taPrimary);
|
||||
const authEnable = field(taPrimary, "auth-enabled", "taEnable", auth.enabled, "checkbox");
|
||||
const authUrl = field(taPrimary, "auth-url", "taUrl", auth.url || "", "url", { maxLength: 2048 });
|
||||
const loginUrl = field(taPrimary, "auth-login", "taLogin", auth.login_url || "", "url", { maxLength: 2048 });
|
||||
authUrl.placeholder = "http://10.87.7.99:3000"; loginUrl.placeholder = "https://auth.example.com/login";
|
||||
field(taPrimary, "auth-private", "vhPrivate", auth.allow_private, "checkbox");
|
||||
field(taPrimary, "auth-redirect-forbidden", "taRedirectForbidden", auth.redirect_forbidden !== false, "checkbox");
|
||||
authEnable.closest("label").hidden = true;
|
||||
note(taBody, "taHelp");
|
||||
|
||||
const taAdvanced = el("details", undefined, "vh-wide auth-advanced"); taAdvanced.append(text("summary", "taAdvanced"));
|
||||
const taAdvancedGrid = el("div", undefined, "vh-grid"); taAdvanced.append(taAdvancedGrid); taBody.append(taAdvanced);
|
||||
field(taAdvancedGrid, "auth-connect", "taConnect", auth.connect_timeout_seconds ?? 5, "number", { required: true, min: 1, max: 30 });
|
||||
field(taAdvancedGrid, "auth-timeout", "taTimeout", auth.timeout_seconds ?? 10, "number", { required: true, min: 1, max: 60 });
|
||||
field(taAdvancedGrid, "auth-request-headers", "taRequestHeaders", (auth.request_headers || ["cookie", "authorization", "x-api-key", "user-agent", "accept"]).join("\n"), "textarea", { rows: 3, maxLength: 8192 });
|
||||
field(taAdvancedGrid, "auth-response-headers", "taResponseHeaders", (auth.response_headers || ["remote-user", "remote-email"]).join("\n"), "textarea", { rows: 3, maxLength: 8192 });
|
||||
field(taAdvancedGrid, "auth-allowlist", "vhAddressAllowlist", (auth.address_allowlist || []).join("\n"), "textarea", { rows: 2, maxLength: 8192 });
|
||||
field(taAdvancedGrid, "auth-ca", "vhCa", auth.tls_ca_pem || "", "textarea", { rows: 2, maxLength: 65536 });
|
||||
|
||||
basicBody.append(text("h3", "authBasic"));
|
||||
const basicGrid = el("div", undefined, "vh-grid"); basicBody.append(basicGrid);
|
||||
basicEditor = basicAuthEditor(basicGrid, value.basic_auth || {});
|
||||
const syncAuth = () => {
|
||||
authEnable.checked = authMode.value === "tinyauth";
|
||||
taBody.hidden = !authEnable.checked;
|
||||
basicBody.hidden = authMode.value !== "basic";
|
||||
authUrl.required = loginUrl.required = authEnable.checked;
|
||||
};
|
||||
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", "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"));
|
||||
const syncDiag = () => { for (const input of diagInputs) input.disabled = inheritDiagnostics.checked; }; inheritDiagnostics.addEventListener("change", syncDiag); syncDiag(); note(g, "diagHelp");
|
||||
g = group("vhCompression"); const compression = value.compression || {};
|
||||
const compressionTri = (parent, name, key, current) => field(parent, name, key, current === true ? "on" : current === false ? "off" : "", "select", { values: [["", "inherit"], ["on", "enabled"], ["off", "notEnabled"]] });
|
||||
const compressionToggle = compressionTri(g, "vh-compression-enabled", "compressionEnabled", compression.enabled);
|
||||
const compressionOptions = el("div", undefined, "vh-wide vh-grid vh-compression-options"); g.append(compressionOptions);
|
||||
const compressionCodecInputs = [
|
||||
compressionTri(compressionOptions, "vh-compression-zstd", "compressionZstd", compression.zstd),
|
||||
compressionTri(compressionOptions, "vh-compression-br", "compressionBr", compression.br),
|
||||
compressionTri(compressionOptions, "vh-compression-gzip", "compressionGzip", compression.gzip),
|
||||
compressionTri(compressionOptions, "vh-compression-deflate", "compressionDeflate", compression.deflate)
|
||||
];
|
||||
const syncCompressionVisibility = () => {
|
||||
const disabled = compressionToggle.value === "off";
|
||||
compressionOptions.hidden = disabled;
|
||||
for (const input of compressionCodecInputs) input.disabled = disabled;
|
||||
};
|
||||
compressionToggle.addEventListener("change", syncCompressionVisibility); syncCompressionVisibility();
|
||||
note(g, "vhCompressionHelp");
|
||||
g = group("vhCache"); const cache = value.cache || {};
|
||||
const tri = (parent, name, key, current) => field(parent, name, key, current === true ? "on" : current === false ? "off" : "", "select", { values: [["", "inherit"], ["on", "enabled"], ["off", "notEnabled"]] });
|
||||
const cacheToggle = tri(g, "vh-cache-enabled", "cacheEnabled", cache.enabled);
|
||||
if (envelope?.config.cache?.enabled !== true) note(g, "vhCacheGlobalDisabled");
|
||||
const cacheOptions = el("div", undefined, "vh-wide vh-grid vh-cache-options"); g.append(cacheOptions);
|
||||
field(cacheOptions, "vh-cache-ttl", "vhCacheTtl", cache.max_ttl_seconds ?? "", "number", { min: 1, max: 604800 });
|
||||
field(cacheOptions, "vh-cache-default", "vhCacheDefault", cache.default_ttl_seconds ?? "", "number", { min: 0, max: 604800 });
|
||||
field(cacheOptions, "vh-cache-object", "vhCacheObject", cache.max_object_bytes == null ? "" : cache.max_object_bytes / 1024, "number", { min: 1, step: "any", max: (envelope?.config.cache?.max_object_bytes ?? 1048576) / 1024 });
|
||||
tri(cacheOptions, "vh-cache-query", "cacheBypassQuery", cache.bypass_query);
|
||||
tri(cacheOptions, "vh-cache-static-cookie", "vhCacheStaticCookie", cache.static_ignore_cookies);
|
||||
const customStatic = field(cacheOptions, "vh-cache-static-custom", "vhCacheStaticCustom", cache.static_extensions != null, "checkbox");
|
||||
const staticExtensions = field(cacheOptions, "vh-cache-static-extensions", "vhCacheStaticExtensions", (cache.static_extensions || envelope?.config.cache?.static_extensions || []).join("\n"), "textarea", { rows: 5, maxLength: 2048 });
|
||||
const syncStatic = () => { staticExtensions.disabled = !customStatic.checked; }; customStatic.addEventListener("change", syncStatic); syncStatic(); note(cacheOptions, "vhCacheStaticHelp");
|
||||
tri(cacheOptions, "vh-cache-status", "cacheStatusHeader", cache.expose_status); tri(cacheOptions, "vh-cache-hits", "cacheHitsHeader", cache.expose_hits); tri(cacheOptions, "vh-cache-rfc", "cacheRfcHeader", cache.expose_cache_status); tri(cacheOptions, "vh-cache-resident-age", "cacheResidentAge", cache.expose_resident_age); tri(cacheOptions, "vh-cache-only-hits", "cacheOnlyHits", cache.expose_only_hits);
|
||||
field(cacheOptions, "vh-cache-prefix-custom", "vhCacheCustomPrefixes", cache.bypass_prefixes != null, "checkbox");
|
||||
field(cacheOptions, "vh-cache-prefixes", "cacheBypassPrefixes", (cache.bypass_prefixes || []).join("\n"), "textarea", { rows: 2, maxLength: 32768 }); note(cacheOptions, "vhCacheHint");
|
||||
const forceCache = field(cacheOptions, "vh-cache-force-enabled", "vhCacheForceEnabled", cache.unsafe_force?.enabled === true, "checkbox");
|
||||
const forceTargets = field(cacheOptions, "vh-cache-force-targets", "vhCacheForceTargets", (cache.unsafe_force?.targets || []).join("\n"), "textarea", { rows: 3, maxLength: 65536 });
|
||||
const syncForceCache = () => { forceTargets.disabled = !forceCache.checked; }; forceCache.addEventListener("change", async () => { if (forceCache.checked && !(await appConfirm(t("vhCacheForceConfirm"), { danger: true }))) forceCache.checked = false; syncForceCache(); }); syncForceCache();
|
||||
note(cacheOptions, "vhCacheForceHelp");
|
||||
if (editing) cacheOptions.append(button("vhPurgeCache", () => purge(editing)));
|
||||
const syncCacheVisibility = () => {
|
||||
const enabled = envelope?.config.cache?.enabled === true && cacheToggle.value !== "off";
|
||||
cacheOptions.hidden = !enabled;
|
||||
};
|
||||
cacheToggle.addEventListener("change", syncCacheVisibility); syncCacheVisibility();
|
||||
g = group("vhTransform"); const transform = value.transform || { enabled: false };
|
||||
const transformEnabled = field(g, "vh-transform-enabled", "vhTransformEnable", transform.enabled === true, "checkbox");
|
||||
const transformOptions = el("div", undefined, "vh-wide vh-grid vh-transform-options"); g.append(transformOptions);
|
||||
for (const [name, key] of [["javascript", "transformJavascript"], ["css", "transformCss"], ["html", "transformHtml"], ["svg", "transformSvg"], ["json", "transformJson"]]) tri(transformOptions, `vh-transform-${name}`, key, transform[name]);
|
||||
const customTransformExtensions = field(transformOptions, "vh-transform-extensions-custom", "vhTransformExtensionsCustom", transform.extensions != null, "checkbox");
|
||||
const transformExtensions = field(transformOptions, "vh-transform-extensions", "transformExtensions", (transform.extensions || envelope?.config.transform?.extensions || []).join("\n"), "textarea", { rows: 5, maxLength: 2048 });
|
||||
const syncTransform = () => {
|
||||
transformOptions.hidden = !transformEnabled.checked;
|
||||
transformExtensions.disabled = !customTransformExtensions.checked;
|
||||
};
|
||||
transformEnabled.addEventListener("change", syncTransform); customTransformExtensions.addEventListener("change", syncTransform); syncTransform();
|
||||
note(transformOptions, "vhTransformHelp");
|
||||
g = group("vhHeaders"); headerEditors(g, value.headers || {});
|
||||
g = group("vhPages"); g.classList.add("vh-pages-grid"); pagesEditor = window.rustProxyPages.vhostEditor(g, value.pages || {});
|
||||
initTabs(); dirty = false; if (latestStatus) renderStatus(latestStatus); if (show && !dialog.open) dialog.showModal();
|
||||
|
||||
}
|
||||
function collect() {
|
||||
locationsReader(); // Validate every visual row; never silently save an earlier JSON value.
|
||||
let paths, acl, headers;
|
||||
try {
|
||||
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),
|
||||
health_check_enabled: yes("member-health-enabled", row),
|
||||
health_check: yes("member-health-custom", row.memberHealthPanel) ? {
|
||||
mode: str("member-health-mode", row.memberHealthPanel), path: str("member-health-path", row.memberHealthPanel), host: nullable("member-health-host", row.memberHealthPanel),
|
||||
interval_ms: num("member-health-interval", row.memberHealthPanel), timeout_ms: num("member-health-timeout", row.memberHealthPanel), slow_response_ms: num("member-health-slow", row.memberHealthPanel),
|
||||
fall: num("member-health-fall", row.memberHealthPanel), rise: num("member-health-rise", row.memberHealthPanel),
|
||||
expected_status_min: num("member-health-min", row.memberHealthPanel), expected_status_max: num("member-health-max", row.memberHealthPanel)
|
||||
} : null,
|
||||
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();
|
||||
const mainSourceType = str("source-type");
|
||||
const mainFallbackTarget = str("main-error-target"), mainFallbackStatuses = lines(str("main-error-statuses")).map(value => Number(value));
|
||||
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 (yes("www-redirect") && !str("www-redirect-target")) throw new Error(t("vhWwwNoHosts"));
|
||||
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) {
|
||||
const endpoint = envelope?.config.listeners?.endpoints?.find(item => item.id === id);
|
||||
if (!endpoint || endpoint.enabled === false) continue;
|
||||
if (!endpoint.binding?.tls) { httpSelected = true; continue; }
|
||||
tlsSelected = true;
|
||||
for (const address of [endpoint.binding?.address, ...(endpoint.additional_addresses || [])].filter(Boolean)) {
|
||||
const match = String(address).match(/:(\d+)$/); if (match) tlsPorts.add(Number(match[1]));
|
||||
}
|
||||
}
|
||||
if (!httpSelected) throw new Error(t("vhForceSslNeedsHttp"));
|
||||
if (!tlsSelected || !tlsPorts.size) throw new Error(t("vhForceSslNeedsTls"));
|
||||
if (!tlsPorts.has(443) && tlsPorts.size !== 1) throw new Error(t("vhForceSslPortConflict"));
|
||||
const companion = selectedCanonical.startsWith("www.") ? selectedCanonical.slice(4) : `www.${selectedCanonical}`;
|
||||
const aliases = allHosts.filter(host => host !== selectedCanonical && host !== companion);
|
||||
wwwOther.replaceChildren();
|
||||
if (aliases.length) {
|
||||
wwwOther.append(text("span", "vhWwwOtherDomains"));
|
||||
for (const host of aliases) wwwOther.append(el("code", host, "vh-www-alias"));
|
||||
}
|
||||
if (yes("tls-policy-custom") && !yes("tls-policy-12") && !yes("tls-policy-13")) throw new Error(t("tlsPolicyVersionRequired"));
|
||||
const selectedUsesHttp3 = bindingSelection.some(id => {
|
||||
wwwNoHosts.hidden = bases.length > 0;
|
||||
wwwRedirect.disabled = !bases.length;
|
||||
if (!bases.length) wwwRedirect.checked = false;
|
||||
// Never hide the destination choices: the unchecked state is a preview
|
||||
// that the operator can configure before enabling the rule.
|
||||
wwwDetails.hidden = false;
|
||||
wwwInactive.hidden = wwwRedirect.checked;
|
||||
wwwRedirect.setAttribute("aria-expanded", "true");
|
||||
wwwPanel.classList.toggle("is-enabled", wwwRedirect.checked);
|
||||
};
|
||||
refreshCanonical = syncCanonical;
|
||||
wwwRedirect.addEventListener("change", () => { syncCanonical(); changed(); });
|
||||
syncCanonical();
|
||||
field(g, "forward-client-ip", "vhForwardClientIp", value.forward_client_ip !== false, "checkbox");
|
||||
note(g, "vhForwardClientIpHelp");
|
||||
field(g, "cloudflare-client-ip", "vhCloudflareClientIp", value.cloudflare_client_ip === true, "checkbox");
|
||||
const protocolRow = el("div", undefined, "vh-wide protocol-toggle-row"); g.append(protocolRow);
|
||||
const h2Enabled = field(protocolRow, "http2-enabled", "vhHttp2Enabled", value.http2_enabled !== false, "checkbox");
|
||||
const h3Enabled = field(protocolRow, "http3-enabled", "vhHttp3Enabled", value.http3_enabled !== false, "checkbox");
|
||||
note(g, "vhFrontendProtocolsHelp"); note(g, "vhFrontendSelectionOnly");
|
||||
const syncFrontendTransports = () => {
|
||||
const capabilities = bindingControl.capabilities();
|
||||
h2Enabled.disabled = !capabilities.http2; h3Enabled.disabled = !capabilities.http3;
|
||||
if (!capabilities.http2) h2Enabled.checked = false;
|
||||
if (!capabilities.http3) h3Enabled.checked = false;
|
||||
const h2Available = capabilities.http2 && h2Enabled.checked;
|
||||
const h3Available = capabilities.http3 && h3Enabled.checked;
|
||||
websocketMode.disabled = !websocketEnabled.checked;
|
||||
websocketLegacy.disabled = !websocketEnabled.checked;
|
||||
const optionH2 = websocketMode.querySelector('option[value="h2"]');
|
||||
const optionH3 = websocketMode.querySelector('option[value="h3"]');
|
||||
const optionH2H3 = websocketMode.querySelector('option[value="h2h3"]');
|
||||
optionH2.disabled = !h2Available;
|
||||
optionH3.disabled = !h3Available;
|
||||
optionH2H3.disabled = !h2Available || !h3Available;
|
||||
const selected = websocketMode.value;
|
||||
if ((selected === "h2" && !h2Available) || (selected === "h3" && !h3Available) || (selected === "h2h3" && (!h2Available || !h3Available))) {
|
||||
websocketMode.value = selected === "h2h3" && h2Available ? "h2" : selected === "h2h3" && h3Available ? "h3" : "h1";
|
||||
}
|
||||
};
|
||||
websocketEnabled.addEventListener("change", syncFrontendTransports);
|
||||
bindingControl.onChange(syncFrontendTransports);
|
||||
h2Enabled.addEventListener("change", syncFrontendTransports);
|
||||
h3Enabled.addEventListener("change", syncFrontendTransports);
|
||||
syncFrontendTransports();
|
||||
g = group("vhBackendPool"); const members = el("div", undefined, "vh-wide vh-members"); g.append(members);
|
||||
(value.backends || []).forEach(m => memberRow(members, m)); g.append(button("vhAddBackend", () => { memberRow(members); changed(); }));
|
||||
g = group("vhBackendDiagnostics");
|
||||
field(g, "timing-headers", "vhTimingHeaders", value.timing_headers === true, "checkbox");
|
||||
field(g, "backend-header", "vhBackendHeader", value.backend_header === true, "checkbox");
|
||||
field(g, "backend-timing-header", "vhBackendTimingHeader", value.backend_timing_header === true, "checkbox");
|
||||
note(g, "vhBackendDiagnosticsHelp");
|
||||
const proxyTimeouts = { ...defaultProxyTimeouts(), ...(value.proxy_timeouts || {}) };
|
||||
g = group("vhProxyTimeouts");
|
||||
field(g, "proxy-connect-timeout", "vhProxyConnectTimeout", proxyTimeouts.connect_timeout_seconds ?? 10, "number", { required: true, min: 1, max: 300 });
|
||||
field(g, "proxy-header-timeout", "vhProxyHeaderTimeout", proxyTimeouts.response_header_timeout_seconds ?? 120, "number", { required: true, min: 1, max: 86400 });
|
||||
field(g, "proxy-read-timeout", "vhProxyReadTimeout", proxyTimeouts.read_timeout_seconds ?? 60, "number", { required: true, min: 1, max: 86400 });
|
||||
field(g, "proxy-send-timeout", "vhProxySendTimeout", proxyTimeouts.send_timeout_seconds ?? 60, "number", { required: true, min: 1, max: 86400 });
|
||||
note(g, "vhProxyTimeoutsHelp");
|
||||
const settings = value.load_balancing || {}, health = settings.health_check || {}, keepalive = settings.keepalive || {}, retry = settings.retry || {}, sticky = settings.sticky || {};
|
||||
g = group("vhBalancing"); field(g, "algorithm", "vhAlgorithm", settings.algorithm || "least_connections", "select", { values: [["round_robin", "vhRoundRobin"], ["least_connections", "vhLeastConnections"], ["least_traffic", "vhLeastTraffic"], ["adaptive", "vhAdaptive"], ["ip_hash", "vhIpHash"]] });
|
||||
field(g, "slow-start", "vhSlowStart", settings.slow_start_seconds ?? 10, "number", { required: true, min: 0, max: 300 });
|
||||
field(g, "retry-max", "vhRetryMax", retry.max_retries ?? 1, "number", { required: true, min: 0, max: 3 });
|
||||
field(g, "retry-connect", "vhRetryConnect", retry.retry_connect_errors !== false, "checkbox");
|
||||
field(g, "retry-timeouts", "vhRetryTimeouts", retry.retry_timeouts !== false, "checkbox");
|
||||
field(g, "retry-statuses", "vhRetryStatuses", (retry.retry_statuses || [502, 503, 504]).join(", "), "text", { maxLength: 64 });
|
||||
note(g, "vhRetryHelp");
|
||||
field(g, "keepalive", "vhKeepalive", keepalive.enabled !== false, "checkbox");
|
||||
field(g, "idle-timeout", "vhIdleTimeout", keepalive.idle_timeout_seconds ?? 30, "number", { required: true, min: 1, max: 3600 });
|
||||
field(g, "max-idle", "vhMaxIdle", keepalive.max_idle_per_host ?? 4, "number", { required: true, min: 1, max: 64 });
|
||||
field(g, "tcp-keepalive", "vhTcpKeepalive", keepalive.tcp_keepalive_seconds ?? 30, "number", { required: true, min: 1, max: 3600 });
|
||||
field(g, "pool-recycle-requests", "vhPoolRecycleRequests", keepalive.pool_recycle_requests ?? 25000, "number", { required: true, min: 0, max: 10000000 });
|
||||
field(g, "pool-recycle-seconds", "vhPoolRecycleSeconds", keepalive.pool_recycle_seconds ?? 3600, "number", { required: true, min: 0, max: 604800 });
|
||||
field(g, "http1-max-buffer", "vhHttp1MaxBuffer", keepalive.http1_max_buffer_bytes ?? 131072, "number", { required: true, min: 8192, max: 1048576 });
|
||||
note(g, "vhPoolRecycleHelp");
|
||||
g = group("vhHealth"); const healthMode = field(g, "health-mode", "vhHealthMode", health.mode || "tcp", "select", { values: [["off", "vhOff"], ["tcp", "vhTcp"], ["http", "vhHttp"]] });
|
||||
const healthOffWarning = note(g, "vhHealthOffWarning"); const syncHealthWarning = () => { healthOffWarning.hidden = healthMode.value !== "off"; }; healthMode.addEventListener("change", syncHealthWarning); syncHealthWarning();
|
||||
field(g, "health-path", "vhHealthPath", health.path || "/", "text", { required: true, maxLength: 1024 });
|
||||
field(g, "health-host", "vhHealthHost", health.host || "", "text", { maxLength: 260 });
|
||||
for (const [name, key, val, min, max] of [["interval", "vhInterval", health.interval_ms ?? 5000, 250, 300000], ["timeout", "vhTimeout", health.timeout_ms ?? 2000, 50, 60000], ["slow", "vhSlow", health.slow_response_ms ?? 1500, 0, 60000], ["fall", "vhFall", health.fall ?? 3, 1, 20], ["rise", "vhRise", health.rise ?? 2, 1, 20], ["status-min", "vhStatusMin", health.expected_status_min ?? 200, 100, 599], ["status-max", "vhStatusMax", health.expected_status_max ?? 399, 100, 599]]) field(g, name, key, val, "number", { required: true, min, max }); note(g, "vhHealthNote");
|
||||
backendHealthEditorControl = backendHealthEditor(g, members);
|
||||
note(g, "backendHealthOverridesHelp");
|
||||
const passive = settings.passive_health || {};
|
||||
g = group("vhPassiveHealth");
|
||||
const passiveEnabled = field(g, "passive-enabled", "vhPassiveEnabled", passive.enabled === true, "checkbox");
|
||||
note(g, "vhPassiveHelp");
|
||||
const passiveFields = [
|
||||
field(g, "passive-connect", "vhPassiveConnect", passive.count_connect_errors !== false, "checkbox"),
|
||||
field(g, "passive-timeouts", "vhPassiveTimeouts", passive.count_timeouts !== false, "checkbox"),
|
||||
field(g, "passive-io", "vhPassiveIo", passive.count_io_errors !== false, "checkbox"),
|
||||
field(g, "passive-5xx", "vhPassive5xx", passive.count_http_5xx !== false, "checkbox"),
|
||||
field(g, "passive-slow", "vhPassiveSlow", passive.count_slow_responses === true, "checkbox"),
|
||||
field(g, "passive-fall", "vhPassiveFall", passive.fall ?? "", "number", { min: 1, max: 20 }),
|
||||
field(g, "passive-rise", "vhPassiveRise", passive.rise ?? "", "number", { min: 1, max: 20 })
|
||||
];
|
||||
const passive5xx = passiveFields[3], passiveSlow = passiveFields[4];
|
||||
const passive5xxWarning = note(g, "vhPassive5xxWarning");
|
||||
const passiveSlowWarning = note(g, "vhPassiveSlowWarning");
|
||||
const syncPassive = () => {
|
||||
passiveFields.forEach(control => { control.disabled = !passiveEnabled.checked; const label = control.closest("label"); if (label) label.hidden = !passiveEnabled.checked; });
|
||||
passive5xxWarning.hidden = !passiveEnabled.checked || !passive5xx.checked;
|
||||
passiveSlowWarning.hidden = !passiveEnabled.checked || !passiveSlow.checked;
|
||||
};
|
||||
passiveEnabled.addEventListener("change", syncPassive);
|
||||
passive5xx.addEventListener("change", syncPassive);
|
||||
passiveSlow.addEventListener("change", syncPassive);
|
||||
syncPassive();
|
||||
g = group("vhSticky"); field(g, "sticky-mode", "vhStickyMode", sticky.mode || "off", "select", { values: [["off", "vhOff"], ["cookie", "vhCookie"], ["ip_hash", "vhIpHash"]] });
|
||||
field(g, "cookie-name", "vhCookieName", sticky.cookie_name || "", "text", { maxLength: 96, pattern: "[A-Za-z0-9_-]*" });
|
||||
field(g, "cookie-ttl", "vhCookieTtl", sticky.ttl_seconds ?? 3600, "number", { required: true, min: 60, max: 604800 }); note(g, "vhStickyNote");
|
||||
const rate = value.rate_limit || {}, access = rate.country_access || {}, methodPolicy = value.method_policy || {};
|
||||
g = group("vhTrafficPolicy");
|
||||
const aclProfile = field(g, "acl-profile-id", "vhAclProfile", value.acl_profile_id || "", "select");
|
||||
const noProfile = text("option", "vhAclProfileNone"); noProfile.value = ""; aclProfile.append(noProfile, ...(envelope?.config.acl_profiles || []).map(profile => { const option = el("option", profile.name || profile.id); option.value = profile.id; return option; })); aclProfile.value = value.acl_profile_id || ""; note(g, "vhAclProfileHelp");
|
||||
field(g, "method-mode", "vhMethodMode", methodPolicy.mode || "off", "select", { values: [["off", "vhOff"], ["allow", "locationMethodAllow"], ["block", "locationMethodBlock"]] });
|
||||
field(g, "method-list", "vhMethodList", (methodPolicy.methods || []).join("\n"), "textarea", { rows: 3, maxLength: 1024 });
|
||||
note(g, "vhMethodHelp");
|
||||
field(g, "rate-enabled", "vhRateEnabled", rate.enabled === true, "checkbox");
|
||||
field(g, "rate-requests", "vhRateRequests", rate.requests ?? 120, "number", { required: true, min: 1, max: 1000000 });
|
||||
field(g, "rate-window", "vhRateWindow", rate.window_seconds ?? 60, "number", { required: true, min: 1, max: 3600 });
|
||||
field(g, "rate-burst", "vhRateBurst", rate.burst ?? 30, "number", { required: true, min: 0, max: 1000000 });
|
||||
field(g, "country-mode", "vhCountryMode", access.mode || "off", "select", { values: [["off", "vhOff"], ["block", "vhCountryBlock"], ["allow", "vhCountryAllow"]] });
|
||||
field(g, "country-list", "vhCountryList", (access.countries || []).join("\n"), "textarea", { rows: 3, maxLength: 1024 });
|
||||
note(g, "vhCountryPolicyHelp"); countryRatesReader = countryRateEditor(g, rate.country_limits || []); uaRatesReader = userAgentRateEditor(g, rate.user_agent_limits || []); note(g, "vhRateHelp");
|
||||
const security = value.security_headers || {}, hsts = security.hsts || {};
|
||||
g = group("vhSecurity"); field(g, "hsts", "vhHsts", hsts.enabled, "checkbox"); field(g, "hsts-age", "vhHstsAge", hsts.max_age_seconds ?? 31536000, "number", { required: true, min: 0, max: 63072000 });
|
||||
field(g, "hsts-sub", "vhHstsSub", hsts.include_subdomains, "checkbox"); field(g, "hsts-preload", "vhHstsPreload", hsts.preload, "checkbox"); field(g, "nosniff", "vhNosniff", security.nosniff, "checkbox");
|
||||
field(g, "frame", "vhFrame", security.frame_options || "preserve", "select", { values: [["preserve", "vhPreserve"], ["deny", "vhDeny"], ["sameorigin", "vhSameorigin"]] });
|
||||
field(g, "referrer", "vhReferrer", security.referrer_policy || "", "text", { maxLength: 8192 });
|
||||
field(g, "csp", "vhCsp", security.content_security_policy || "", "textarea", { rows: 2, maxLength: 8192 });
|
||||
field(g, "permissions", "vhPermissions", security.permissions_policy || "", "text", { maxLength: 8192 });
|
||||
const uaBlock = value.user_agent_blocking || {};
|
||||
field(g, "ua-block-enabled", "vhUaBlockEnabled", uaBlock.enabled === true, "checkbox");
|
||||
field(g, "ua-block-patterns", "vhUaBlockPatterns", (uaBlock.patterns || []).join("\n"), "textarea", { rows: 4, maxLength: 32768 });
|
||||
note(g, "vhUaBlockHelp");
|
||||
g = group("vhAdvanced", true); field(g, "paths", "vhPaths", JSON.stringify(value.routes || [], null, 2), "textarea", { rows: 5, maxLength: 131072 });
|
||||
field(g, "acl", "vhAcl", JSON.stringify(value.acl ?? null, null, 2), "textarea", { rows: 5, maxLength: 32768 });
|
||||
field(g, "headers", "vhHeaders", JSON.stringify(value.headers || {}, null, 2), "textarea", { rows: 5, maxLength: 65536 });
|
||||
g = group("vhLocations");
|
||||
field(g, "main-error-target", "vhErrorFallbackTarget", value.error_fallback?.target || "", "text", { maxLength: 64, pattern: "[A-Za-z0-9_-]{0,64}" });
|
||||
field(g, "main-error-statuses", "vhErrorFallbackStatuses", (value.error_fallback?.statuses || [404]).join(", "), "text", { maxLength: 64 });
|
||||
note(g, "vhErrorFallbackHelp");
|
||||
locationsReader = locationsEditor(g, value.routes || []);
|
||||
g = group("vhSsl");
|
||||
const selector = field(g, "certificate-id", "vhCertificate", value.certificate_id || "", "select");
|
||||
const fillCertificates = () => { const selected = selector.value || value.certificate_id || ""; const inherit = text("option", "vhCertificateDefault"); inherit.value = ""; selector.replaceChildren(inherit, ...[...(envelope?.config.tls?.certificates || []), ...stagedCertificates].map(c => { const o = el("option", c.id); o.value = c.id; return o; })); selector.value = selected; }; fillCertificates();
|
||||
g.append(button("certManage", async () => { if (!(await closeEditor())) return; setPanel("tls"); })); note(g, "certSelectHint");
|
||||
const inheritedTlsPolicy = envelope?.config.tls?.policy || { tls12: true, tls13: true, cipher_suites: [] };
|
||||
const tlsPolicy = value.tls_policy || inheritedTlsPolicy;
|
||||
const customTlsPolicy = field(g, "tls-policy-custom", "vhTlsOverride", value.tls_policy != null, "checkbox");
|
||||
const tls12 = field(g, "tls-policy-12", "tls12", tlsPolicy.tls12 !== false, "checkbox");
|
||||
const tls13 = field(g, "tls-policy-13", "tls13", tlsPolicy.tls13 !== false, "checkbox");
|
||||
const tlsCiphers = field(g, "tls-policy-ciphers", "tlsCiphers", (tlsPolicy.cipher_suites || []).join("\n"), "textarea", { rows: 7, maxLength: 8192 });
|
||||
const syncTlsPolicy = () => { for (const input of [tls12, tls13, tlsCiphers]) input.disabled = !customTlsPolicy.checked; };
|
||||
customTlsPolicy.addEventListener("change", syncTlsPolicy); syncTlsPolicy(); note(g, "vhTlsOverrideHelp");
|
||||
g = group("vhTinyauth"); const auth = value.tinyauth || {}, authRoot = g;
|
||||
const authMode = field(g, "auth-mode", "authMode", value.basic_auth?.enabled ? "basic" : auth.enabled ? "tinyauth" : "none", "select", { values: [["none", "authNone"], ["tinyauth", "authTinyauth"], ["basic", "authBasic"]] });
|
||||
note(g, "authModeHelp");
|
||||
|
||||
const aclBody = el("section", undefined, "vh-wide auth-subcard auth-ip-card"); authRoot.append(aclBody);
|
||||
aclBody.append(text("h3", "vhIpAccess"));
|
||||
aclControl = aclEditor(aclBody, value.acl, () => { if (aclControl) read("acl").value = JSON.stringify(aclControl.read(), null, 2); });
|
||||
|
||||
const taBody = el("section", undefined, "vh-wide auth-subcard"), basicBody = el("section", undefined, "vh-wide auth-subcard"); authRoot.append(taBody, basicBody);
|
||||
taBody.append(text("h3", "authTinyauth"));
|
||||
const taPrimary = el("div", undefined, "vh-grid"); taBody.append(taPrimary);
|
||||
const authEnable = field(taPrimary, "auth-enabled", "taEnable", auth.enabled, "checkbox");
|
||||
const authUrl = field(taPrimary, "auth-url", "taUrl", auth.url || "", "url", { maxLength: 2048 });
|
||||
const loginUrl = field(taPrimary, "auth-login", "taLogin", auth.login_url || "", "url", { maxLength: 2048 });
|
||||
authUrl.placeholder = "http://10.87.7.99:3000"; loginUrl.placeholder = "https://auth.example.com/login";
|
||||
field(taPrimary, "auth-private", "vhPrivate", auth.allow_private, "checkbox");
|
||||
field(taPrimary, "auth-redirect-forbidden", "taRedirectForbidden", auth.redirect_forbidden !== false, "checkbox");
|
||||
authEnable.closest("label").hidden = true;
|
||||
note(taBody, "taHelp");
|
||||
|
||||
const taAdvanced = el("details", undefined, "vh-wide auth-advanced"); taAdvanced.append(text("summary", "taAdvanced"));
|
||||
const taAdvancedGrid = el("div", undefined, "vh-grid"); taAdvanced.append(taAdvancedGrid); taBody.append(taAdvanced);
|
||||
field(taAdvancedGrid, "auth-connect", "taConnect", auth.connect_timeout_seconds ?? 5, "number", { required: true, min: 1, max: 30 });
|
||||
field(taAdvancedGrid, "auth-timeout", "taTimeout", auth.timeout_seconds ?? 10, "number", { required: true, min: 1, max: 60 });
|
||||
field(taAdvancedGrid, "auth-request-headers", "taRequestHeaders", (auth.request_headers || ["cookie", "authorization", "x-api-key", "user-agent", "accept"]).join("\n"), "textarea", { rows: 3, maxLength: 8192 });
|
||||
field(taAdvancedGrid, "auth-response-headers", "taResponseHeaders", (auth.response_headers || ["remote-user", "remote-email"]).join("\n"), "textarea", { rows: 3, maxLength: 8192 });
|
||||
field(taAdvancedGrid, "auth-allowlist", "vhAddressAllowlist", (auth.address_allowlist || []).join("\n"), "textarea", { rows: 2, maxLength: 8192 });
|
||||
field(taAdvancedGrid, "auth-ca", "vhCa", auth.tls_ca_pem || "", "textarea", { rows: 2, maxLength: 65536 });
|
||||
|
||||
basicBody.append(text("h3", "authBasic"));
|
||||
const basicGrid = el("div", undefined, "vh-grid"); basicBody.append(basicGrid);
|
||||
basicEditor = basicAuthEditor(basicGrid, value.basic_auth || {});
|
||||
const syncAuth = () => {
|
||||
authEnable.checked = authMode.value === "tinyauth";
|
||||
taBody.hidden = !authEnable.checked;
|
||||
basicBody.hidden = authMode.value !== "basic";
|
||||
authUrl.required = loginUrl.required = authEnable.checked;
|
||||
};
|
||||
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", "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"));
|
||||
const syncDiag = () => { for (const input of diagInputs) input.disabled = inheritDiagnostics.checked; }; inheritDiagnostics.addEventListener("change", syncDiag); syncDiag(); note(g, "diagHelp");
|
||||
g = group("vhCompression"); const compression = value.compression || {};
|
||||
const compressionTri = (parent, name, key, current) => field(parent, name, key, current === true ? "on" : current === false ? "off" : "", "select", { values: [["", "inherit"], ["on", "enabled"], ["off", "notEnabled"]] });
|
||||
const compressionToggle = compressionTri(g, "vh-compression-enabled", "compressionEnabled", compression.enabled);
|
||||
const compressionOptions = el("div", undefined, "vh-wide vh-grid vh-compression-options"); g.append(compressionOptions);
|
||||
const compressionCodecInputs = [
|
||||
compressionTri(compressionOptions, "vh-compression-zstd", "compressionZstd", compression.zstd),
|
||||
compressionTri(compressionOptions, "vh-compression-br", "compressionBr", compression.br),
|
||||
compressionTri(compressionOptions, "vh-compression-gzip", "compressionGzip", compression.gzip),
|
||||
compressionTri(compressionOptions, "vh-compression-deflate", "compressionDeflate", compression.deflate)
|
||||
];
|
||||
const syncCompressionVisibility = () => {
|
||||
const disabled = compressionToggle.value === "off";
|
||||
compressionOptions.hidden = disabled;
|
||||
for (const input of compressionCodecInputs) input.disabled = disabled;
|
||||
};
|
||||
compressionToggle.addEventListener("change", syncCompressionVisibility); syncCompressionVisibility();
|
||||
note(g, "vhCompressionHelp");
|
||||
g = group("vhCache"); const cache = value.cache || {};
|
||||
const tri = (parent, name, key, current) => field(parent, name, key, current === true ? "on" : current === false ? "off" : "", "select", { values: [["", "inherit"], ["on", "enabled"], ["off", "notEnabled"]] });
|
||||
const cacheToggle = tri(g, "vh-cache-enabled", "cacheEnabled", cache.enabled);
|
||||
if (envelope?.config.cache?.enabled !== true) note(g, "vhCacheGlobalDisabled");
|
||||
const cacheOptions = el("div", undefined, "vh-wide vh-grid vh-cache-options"); g.append(cacheOptions);
|
||||
field(cacheOptions, "vh-cache-ttl", "vhCacheTtl", cache.max_ttl_seconds ?? "", "number", { min: 1, max: 604800 });
|
||||
field(cacheOptions, "vh-cache-default", "vhCacheDefault", cache.default_ttl_seconds ?? "", "number", { min: 0, max: 604800 });
|
||||
field(cacheOptions, "vh-cache-object", "vhCacheObject", cache.max_object_bytes == null ? "" : cache.max_object_bytes / 1024, "number", { min: 1, step: "any", max: (envelope?.config.cache?.max_object_bytes ?? 1048576) / 1024 });
|
||||
tri(cacheOptions, "vh-cache-query", "cacheBypassQuery", cache.bypass_query);
|
||||
tri(cacheOptions, "vh-cache-static-cookie", "vhCacheStaticCookie", cache.static_ignore_cookies);
|
||||
const customStatic = field(cacheOptions, "vh-cache-static-custom", "vhCacheStaticCustom", cache.static_extensions != null, "checkbox");
|
||||
const staticExtensions = field(cacheOptions, "vh-cache-static-extensions", "vhCacheStaticExtensions", (cache.static_extensions || envelope?.config.cache?.static_extensions || []).join("\n"), "textarea", { rows: 5, maxLength: 2048 });
|
||||
const syncStatic = () => { staticExtensions.disabled = !customStatic.checked; }; customStatic.addEventListener("change", syncStatic); syncStatic(); note(cacheOptions, "vhCacheStaticHelp");
|
||||
tri(cacheOptions, "vh-cache-status", "cacheStatusHeader", cache.expose_status); tri(cacheOptions, "vh-cache-hits", "cacheHitsHeader", cache.expose_hits); tri(cacheOptions, "vh-cache-rfc", "cacheRfcHeader", cache.expose_cache_status); tri(cacheOptions, "vh-cache-resident-age", "cacheResidentAge", cache.expose_resident_age); tri(cacheOptions, "vh-cache-only-hits", "cacheOnlyHits", cache.expose_only_hits);
|
||||
field(cacheOptions, "vh-cache-prefix-custom", "vhCacheCustomPrefixes", cache.bypass_prefixes != null, "checkbox");
|
||||
field(cacheOptions, "vh-cache-prefixes", "cacheBypassPrefixes", (cache.bypass_prefixes || []).join("\n"), "textarea", { rows: 2, maxLength: 32768 }); note(cacheOptions, "vhCacheHint");
|
||||
const forceCache = field(cacheOptions, "vh-cache-force-enabled", "vhCacheForceEnabled", cache.unsafe_force?.enabled === true, "checkbox");
|
||||
const forceTargets = field(cacheOptions, "vh-cache-force-targets", "vhCacheForceTargets", (cache.unsafe_force?.targets || []).join("\n"), "textarea", { rows: 3, maxLength: 65536 });
|
||||
const syncForceCache = () => { forceTargets.disabled = !forceCache.checked; }; forceCache.addEventListener("change", async () => { if (forceCache.checked && !(await appConfirm(t("vhCacheForceConfirm"), { danger: true }))) forceCache.checked = false; syncForceCache(); }); syncForceCache();
|
||||
note(cacheOptions, "vhCacheForceHelp");
|
||||
if (editing) cacheOptions.append(button("vhPurgeCache", () => purge(editing)));
|
||||
const syncCacheVisibility = () => {
|
||||
const enabled = envelope?.config.cache?.enabled === true && cacheToggle.value !== "off";
|
||||
cacheOptions.hidden = !enabled;
|
||||
};
|
||||
cacheToggle.addEventListener("change", syncCacheVisibility); syncCacheVisibility();
|
||||
g = group("vhTransform"); const transform = value.transform || { enabled: false };
|
||||
const transformEnabled = field(g, "vh-transform-enabled", "vhTransformEnable", transform.enabled === true, "checkbox");
|
||||
const transformOptions = el("div", undefined, "vh-wide vh-grid vh-transform-options"); g.append(transformOptions);
|
||||
for (const [name, key] of [["javascript", "transformJavascript"], ["css", "transformCss"], ["html", "transformHtml"], ["svg", "transformSvg"], ["json", "transformJson"]]) tri(transformOptions, `vh-transform-${name}`, key, transform[name]);
|
||||
const customTransformExtensions = field(transformOptions, "vh-transform-extensions-custom", "vhTransformExtensionsCustom", transform.extensions != null, "checkbox");
|
||||
const transformExtensions = field(transformOptions, "vh-transform-extensions", "transformExtensions", (transform.extensions || envelope?.config.transform?.extensions || []).join("\n"), "textarea", { rows: 5, maxLength: 2048 });
|
||||
const syncTransform = () => {
|
||||
transformOptions.hidden = !transformEnabled.checked;
|
||||
transformExtensions.disabled = !customTransformExtensions.checked;
|
||||
};
|
||||
transformEnabled.addEventListener("change", syncTransform); customTransformExtensions.addEventListener("change", syncTransform); syncTransform();
|
||||
note(transformOptions, "vhTransformHelp");
|
||||
g = group("vhHeaders"); headerEditors(g, value.headers || {});
|
||||
g = group("vhPages"); g.classList.add("vh-pages-grid"); pagesEditor = window.rustProxyPages.vhostEditor(g, value.pages || {});
|
||||
initTabs(); dirty = false; if (latestStatus) renderStatus(latestStatus); if (show && !dialog.open) dialog.showModal();
|
||||
|
||||
}
|
||||
function collect() {
|
||||
locationsReader(); // Validate every visual row; never silently save an earlier JSON value.
|
||||
let paths, acl, headers;
|
||||
try {
|
||||
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),
|
||||
health_check_enabled: yes("member-health-enabled", row),
|
||||
health_check: yes("member-health-custom", row.memberHealthPanel) ? {
|
||||
mode: str("member-health-mode", row.memberHealthPanel), path: str("member-health-path", row.memberHealthPanel), host: nullable("member-health-host", row.memberHealthPanel),
|
||||
interval_ms: num("member-health-interval", row.memberHealthPanel), timeout_ms: num("member-health-timeout", row.memberHealthPanel), slow_response_ms: num("member-health-slow", row.memberHealthPanel),
|
||||
fall: num("member-health-fall", row.memberHealthPanel), rise: num("member-health-rise", row.memberHealthPanel),
|
||||
expected_status_min: num("member-health-min", row.memberHealthPanel), expected_status_max: num("member-health-max", row.memberHealthPanel)
|
||||
} : null,
|
||||
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();
|
||||
const mainSourceType = str("source-type");
|
||||
const mainFallbackTarget = str("main-error-target"), mainFallbackStatuses = lines(str("main-error-statuses")).map(value => Number(value));
|
||||
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 (yes("www-redirect") && !str("www-redirect-target")) throw new Error(t("vhWwwNoHosts"));
|
||||
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) {
|
||||
const endpoint = envelope?.config.listeners?.endpoints?.find(item => item.id === id);
|
||||
return !!endpoint?.binding?.http3;
|
||||
});
|
||||
if (yes("http3-enabled") && selectedUsesHttp3 && yes("tls-policy-custom")) {
|
||||
const selected = { tls12: yes("tls-policy-12"), tls13: yes("tls-policy-13"), cipher_suites: lines(str("tls-policy-ciphers")).sort() };
|
||||
const inherited = envelope?.config.tls?.policy || { tls12: true, tls13: true, cipher_suites: [] };
|
||||
const globalPolicy = { tls12: inherited.tls12 !== false, tls13: inherited.tls13 !== false, cipher_suites: [...(inherited.cipher_suites || [])].sort() };
|
||||
if (JSON.stringify(selected) !== JSON.stringify(globalPolicy)) throw new Error(t("vhTlsHttp3Conflict"));
|
||||
if (!endpoint || endpoint.enabled === false) continue;
|
||||
if (!endpoint.binding?.tls) { httpSelected = true; continue; }
|
||||
tlsSelected = true;
|
||||
for (const address of [endpoint.binding?.address, ...(endpoint.additional_addresses || [])].filter(Boolean)) {
|
||||
const match = String(address).match(/:(\d+)$/); if (match) tlsPorts.add(Number(match[1]));
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: str("id"), enabled: yes("enabled"), source: mainSourceType === "web_server" ? { type: "web_server", web_service_id: str("source-web-service") } : { type: "reverse_proxy" }, error_fallback: mainFallbackTarget ? { target: mainFallbackTarget, statuses: mainFallbackStatuses.length ? mainFallbackStatuses : [404] } : null, force_ssl: yes("force-ssl"), www_redirect: yes("www-redirect"), www_redirect_target: str("www-redirect-target") || null, 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(), compression: collectCompression(), 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(), user_agent_limits: uaRatesReader() },
|
||||
method_policy: { mode: str("method-mode"), methods: lines(str("method-list")).map(value => value.toUpperCase()) },
|
||||
server_header: str("server-mode") === "inherit" ? null : { mode: str("server-mode"), value: str("server-value") },
|
||||
diagnostic_headers: yes("diag-inherit") ? null : { request_id: yes("diag-request_id") },
|
||||
tinyauth: { enabled: yes("auth-enabled"), url: str("auth-url"), login_url: str("auth-login"), allow_private: yes("auth-private"), redirect_forbidden: yes("auth-redirect-forbidden"), connect_timeout_seconds: num("auth-connect"), timeout_seconds: num("auth-timeout"), request_headers: lines(str("auth-request-headers")), response_headers: lines(str("auth-response-headers")), address_allowlist: lines(str("auth-allowlist")), tls_ca_pem: nullable("auth-ca") }, backends: members, routes: paths, acl, headers,
|
||||
load_balancing: {
|
||||
algorithm: str("algorithm"), slow_start_seconds: num("slow-start"), keepalive: { enabled: yes("keepalive"), idle_timeout_seconds: num("idle-timeout"), max_idle_per_host: num("max-idle"), tcp_keepalive_seconds: num("tcp-keepalive"), pool_recycle_requests: num("pool-recycle-requests"), pool_recycle_seconds: num("pool-recycle-seconds"), http1_max_buffer_bytes: num("http1-max-buffer") },
|
||||
retry: { max_retries: num("retry-max"), retry_connect_errors: yes("retry-connect"), retry_timeouts: yes("retry-timeouts"), retry_statuses: str("retry-statuses").split(/[\s,]+/).filter(Boolean).map(value => Number(value)) },
|
||||
health_check: { mode: str("health-mode"), path: str("health-path"), host: nullable("health-host"), interval_ms: num("interval"), timeout_ms: num("timeout"), slow_response_ms: num("slow"), fall: num("fall"), rise: num("rise"), expected_status_min: num("status-min"), expected_status_max: num("status-max") },
|
||||
passive_health: {
|
||||
enabled: yes("passive-enabled"),
|
||||
count_connect_errors: yes("passive-connect"),
|
||||
count_timeouts: yes("passive-timeouts"),
|
||||
count_io_errors: yes("passive-io"),
|
||||
count_http_5xx: yes("passive-5xx"),
|
||||
count_slow_responses: yes("passive-slow"),
|
||||
fall: str("passive-fall") === "" ? null : num("passive-fall"),
|
||||
rise: str("passive-rise") === "" ? null : num("passive-rise")
|
||||
},
|
||||
sticky: { mode: str("sticky-mode"), cookie_name: nullable("cookie-name"), ttl_seconds: num("cookie-ttl") }
|
||||
},
|
||||
security_headers: { hsts: { enabled: yes("hsts"), max_age_seconds: num("hsts-age"), include_subdomains: yes("hsts-sub"), preload: yes("hsts-preload") }, nosniff: yes("nosniff"), frame_options: str("frame"), referrer_policy: nullable("referrer"), content_security_policy: nullable("csp"), permissions_policy: nullable("permissions") },
|
||||
user_agent_blocking: { enabled: yes("ua-block-enabled"), patterns: lines(str("ua-block-patterns")) }
|
||||
};
|
||||
if (!httpSelected) throw new Error(t("vhForceSslNeedsHttp"));
|
||||
if (!tlsSelected || !tlsPorts.size) throw new Error(t("vhForceSslNeedsTls"));
|
||||
if (!tlsPorts.has(443) && tlsPorts.size !== 1) throw new Error(t("vhForceSslPortConflict"));
|
||||
}
|
||||
if (yes("tls-policy-custom") && !yes("tls-policy-12") && !yes("tls-policy-13")) throw new Error(t("tlsPolicyVersionRequired"));
|
||||
const selectedUsesHttp3 = bindingSelection.some(id => {
|
||||
const endpoint = envelope?.config.listeners?.endpoints?.find(item => item.id === id);
|
||||
return !!endpoint?.binding?.http3;
|
||||
});
|
||||
if (yes("http3-enabled") && selectedUsesHttp3 && yes("tls-policy-custom")) {
|
||||
const selected = { tls12: yes("tls-policy-12"), tls13: yes("tls-policy-13"), cipher_suites: lines(str("tls-policy-ciphers")).sort() };
|
||||
const inherited = envelope?.config.tls?.policy || { tls12: true, tls13: true, cipher_suites: [] };
|
||||
const globalPolicy = { tls12: inherited.tls12 !== false, tls13: inherited.tls13 !== false, cipher_suites: [...(inherited.cipher_suites || [])].sort() };
|
||||
if (JSON.stringify(selected) !== JSON.stringify(globalPolicy)) throw new Error(t("vhTlsHttp3Conflict"));
|
||||
}
|
||||
return {
|
||||
id: str("id"), enabled: yes("enabled"), source: mainSourceType === "web_server" ? { type: "web_server", web_service_id: str("source-web-service") } : { type: "reverse_proxy" }, error_fallback: mainFallbackTarget ? { target: mainFallbackTarget, statuses: mainFallbackStatuses.length ? mainFallbackStatuses : [404] } : null, force_ssl: yes("force-ssl"), www_redirect: yes("www-redirect"), www_redirect_target: str("www-redirect-target") || null, 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(), compression: collectCompression(), 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(), user_agent_limits: uaRatesReader() },
|
||||
method_policy: { mode: str("method-mode"), methods: lines(str("method-list")).map(value => value.toUpperCase()) },
|
||||
server_header: str("server-mode") === "inherit" ? null : { mode: str("server-mode"), value: str("server-value") },
|
||||
diagnostic_headers: yes("diag-inherit") ? null : { request_id: yes("diag-request_id") },
|
||||
tinyauth: { enabled: yes("auth-enabled"), url: str("auth-url"), login_url: str("auth-login"), allow_private: yes("auth-private"), redirect_forbidden: yes("auth-redirect-forbidden"), connect_timeout_seconds: num("auth-connect"), timeout_seconds: num("auth-timeout"), request_headers: lines(str("auth-request-headers")), response_headers: lines(str("auth-response-headers")), address_allowlist: lines(str("auth-allowlist")), tls_ca_pem: nullable("auth-ca") }, backends: members, routes: paths, acl, headers,
|
||||
load_balancing: {
|
||||
algorithm: str("algorithm"), slow_start_seconds: num("slow-start"), keepalive: { enabled: yes("keepalive"), idle_timeout_seconds: num("idle-timeout"), max_idle_per_host: num("max-idle"), tcp_keepalive_seconds: num("tcp-keepalive"), pool_recycle_requests: num("pool-recycle-requests"), pool_recycle_seconds: num("pool-recycle-seconds"), http1_max_buffer_bytes: num("http1-max-buffer") },
|
||||
retry: { max_retries: num("retry-max"), retry_connect_errors: yes("retry-connect"), retry_timeouts: yes("retry-timeouts"), retry_statuses: str("retry-statuses").split(/[\s,]+/).filter(Boolean).map(value => Number(value)) },
|
||||
health_check: { mode: str("health-mode"), path: str("health-path"), host: nullable("health-host"), interval_ms: num("interval"), timeout_ms: num("timeout"), slow_response_ms: num("slow"), fall: num("fall"), rise: num("rise"), expected_status_min: num("status-min"), expected_status_max: num("status-max") },
|
||||
passive_health: {
|
||||
enabled: yes("passive-enabled"),
|
||||
count_connect_errors: yes("passive-connect"),
|
||||
count_timeouts: yes("passive-timeouts"),
|
||||
count_io_errors: yes("passive-io"),
|
||||
count_http_5xx: yes("passive-5xx"),
|
||||
count_slow_responses: yes("passive-slow"),
|
||||
fall: str("passive-fall") === "" ? null : num("passive-fall"),
|
||||
rise: str("passive-rise") === "" ? null : num("passive-rise")
|
||||
},
|
||||
sticky: { mode: str("sticky-mode"), cookie_name: nullable("cookie-name"), ttl_seconds: num("cookie-ttl") }
|
||||
},
|
||||
security_headers: { hsts: { enabled: yes("hsts"), max_age_seconds: num("hsts-age"), include_subdomains: yes("hsts-sub"), preload: yes("hsts-preload") }, nosniff: yes("nosniff"), frame_options: str("frame"), referrer_policy: nullable("referrer"), content_security_policy: nullable("csp"), permissions_policy: nullable("permissions") },
|
||||
user_agent_blocking: { enabled: yes("ua-block-enabled"), patterns: lines(str("ua-block-patterns")) }
|
||||
};
|
||||
}
|
||||
@@ -55,6 +55,78 @@ fn render_misc(app: &App, snapshot: &crate::config::Snapshot, f: &mut Families)
|
||||
&l,
|
||||
host.max_connections,
|
||||
);
|
||||
// Backend labels are bounded by configured stream/backend IDs; never client addresses.
|
||||
for backend in &view.backends {
|
||||
let lb = labels(&[
|
||||
("stream", &host.id),
|
||||
("protocol", protocol),
|
||||
("backend", &backend.id),
|
||||
]);
|
||||
let state = match backend.status {
|
||||
"checking" => 1,
|
||||
"healthy" => 2,
|
||||
"unhealthy" => 3,
|
||||
"disabled" => 4,
|
||||
_ => 0,
|
||||
};
|
||||
f.add(
|
||||
"rust_proxy_stream_backend_health_state",
|
||||
"gauge",
|
||||
"0 unverified, 1 checking, 2 healthy, 3 unhealthy, 4 disabled.",
|
||||
&lb,
|
||||
state,
|
||||
);
|
||||
f.add(
|
||||
"rust_proxy_stream_backend_active",
|
||||
"gauge",
|
||||
"Active backend assignments including connecting TCP sessions.",
|
||||
&lb,
|
||||
backend.active,
|
||||
);
|
||||
f.add(
|
||||
"rust_proxy_stream_backend_drain",
|
||||
"gauge",
|
||||
"One when backend refuses new sessions due to drain.",
|
||||
&lb,
|
||||
u8::from(backend.drain),
|
||||
);
|
||||
// A disabled detailed-metrics flag does not affect routing counters or probes.
|
||||
if !backend.detail_enabled {
|
||||
continue;
|
||||
}
|
||||
for (name, value) in [
|
||||
("sessions_total", backend.sessions_total),
|
||||
("sessions_completed_total", backend.sessions_completed),
|
||||
("assignments_total", backend.assignments),
|
||||
("rejected_total", backend.rejected),
|
||||
("errors_total", backend.errors),
|
||||
("bytes_in_total", backend.bytes_in),
|
||||
("bytes_out_total", backend.bytes_out),
|
||||
("packets_in_total", backend.packets_in),
|
||||
("packets_out_total", backend.packets_out),
|
||||
("health_success_total", Some(backend.health.successful)),
|
||||
("health_failure_total", Some(backend.health.failed)),
|
||||
] {
|
||||
if let Some(value) = value {
|
||||
f.add(
|
||||
&format!("rust_proxy_stream_backend_{name}"),
|
||||
"counter",
|
||||
"Backend observation since listener startup.",
|
||||
&lb,
|
||||
value,
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(latency) = backend.health.latency_ms {
|
||||
f.add(
|
||||
"rust_proxy_stream_backend_health_latency_ms",
|
||||
"gauge",
|
||||
"Last successful probe latency in milliseconds.",
|
||||
&lb,
|
||||
latency,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if settings.include_logging {
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
//! no vhost routing, ACL, cache, headers, compression or HTTP authentication.
|
||||
|
||||
include!("stream/model.rs");
|
||||
include!("stream/pool.rs");
|
||||
include!("stream/health.rs");
|
||||
include!("stream/runtime.rs");
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// Active checks are only started for explicit verification modes. Probes are
|
||||
// bounded globally and use the serving task's cancellation token.
|
||||
fn probe_permits() -> &'static Arc<Semaphore> {
|
||||
static PERMITS: std::sync::OnceLock<Arc<Semaphore>> = std::sync::OnceLock::new();
|
||||
PERMITS.get_or_init(|| Arc::new(Semaphore::new(16)))
|
||||
}
|
||||
fn spawn_health(pool: Arc<Pool>, tasks: &mut JoinSet<()>, shutdown: CancellationToken) {
|
||||
for backend in &pool.backends {
|
||||
if backend.health.mode == HealthMode::Off
|
||||
|| (backend.health.mode == HealthMode::UdpProbe && backend.health.expect_hex.is_none())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let backend = backend.clone();
|
||||
let stop = shutdown.clone();
|
||||
tasks.spawn(async move {
|
||||
let interval = Duration::from_secs(backend.health.interval_seconds);
|
||||
loop {
|
||||
let permit = tokio::select! {
|
||||
_ = stop.cancelled() => break,
|
||||
result = probe_permits().clone().acquire_owned() => match result { Ok(p) => p, Err(_) => break },
|
||||
};
|
||||
let check = check_backend(&backend);
|
||||
let result = tokio::select! {
|
||||
_ = stop.cancelled() => break,
|
||||
result = check => result,
|
||||
};
|
||||
drop(permit);
|
||||
record_probe(&backend, result);
|
||||
tokio::select! { _ = stop.cancelled() => break, _ = tokio::time::sleep(interval) => {} }
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
async fn check_backend(backend: &RuntimeBackend) -> std::result::Result<u64, String> {
|
||||
let began = Instant::now();
|
||||
let timeout = Duration::from_millis(backend.health.timeout_ms);
|
||||
match backend.health.mode {
|
||||
HealthMode::TcpConnect => {
|
||||
match tokio::time::timeout(timeout, TcpStream::connect(backend.address)).await {
|
||||
Ok(Ok(stream)) => {
|
||||
drop(stream);
|
||||
Ok(began.elapsed().as_millis() as u64)
|
||||
}
|
||||
Ok(Err(error)) => Err(error.to_string()),
|
||||
Err(_) => Err("TCP connect timeout".into()),
|
||||
}
|
||||
}
|
||||
HealthMode::UdpProbe => {
|
||||
let payload =
|
||||
decode_probe_hex(&backend.health.payload_hex).map_err(|e| e.to_string())?;
|
||||
let expected = decode_probe_hex(backend.health.expect_hex.as_deref().unwrap_or(""))
|
||||
.map_err(|e| e.to_string())?;
|
||||
// Socket connect alone can succeed even when the remote UDP service is absent.
|
||||
let local = match backend.address {
|
||||
SocketAddr::V4(_) => "0.0.0.0:0",
|
||||
SocketAddr::V6(_) => "[::]:0",
|
||||
};
|
||||
let socket = UdpSocket::bind(local).await.map_err(|e| e.to_string())?;
|
||||
socket
|
||||
.connect(backend.address)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
tokio::time::timeout(timeout, async {
|
||||
socket.send(&payload).await.map_err(|e| e.to_string())?;
|
||||
let mut buf = [0u8; 512];
|
||||
let received = socket.recv(&mut buf).await.map_err(|e| e.to_string())?;
|
||||
if buf[..received] != expected {
|
||||
return Err("UDP response mismatch".into());
|
||||
}
|
||||
Ok(began.elapsed().as_millis() as u64)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "UDP probe timeout".to_owned())?
|
||||
}
|
||||
HealthMode::Off => Err("health checking disabled".into()),
|
||||
}
|
||||
}
|
||||
fn record_probe(backend: &RuntimeBackend, result: std::result::Result<u64, String>) {
|
||||
let mut observation = backend
|
||||
.observation
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
observation.last_checked_unix_ms = Some(
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as u64,
|
||||
);
|
||||
match result {
|
||||
Ok(latency_ms) => {
|
||||
observation.successful += 1;
|
||||
observation.rises = observation.rises.saturating_add(1);
|
||||
observation.falls = 0;
|
||||
observation.last_result = Some("success");
|
||||
observation.latency_ms = Some(latency_ms);
|
||||
if observation.rises >= backend.health.rise {
|
||||
backend.state.store(HEALTH_HEALTHY, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
observation.failed += 1;
|
||||
observation.falls = observation.falls.saturating_add(1);
|
||||
observation.rises = 0;
|
||||
observation.last_result = Some("failure");
|
||||
observation.last_error = Some(error.chars().take(192).collect());
|
||||
observation.latency_ms = None;
|
||||
if observation.falls >= backend.health.fall {
|
||||
backend.state.store(HEALTH_UNHEALTHY, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+121
-41
@@ -36,6 +36,14 @@ pub struct Host {
|
||||
pub protocol: Protocol,
|
||||
pub listen: SocketAddr,
|
||||
pub upstream: SocketAddr,
|
||||
/// Empty keeps the legacy upstream as one backend named primary.
|
||||
#[serde(default)]
|
||||
pub backends: Vec<Backend>,
|
||||
#[serde(default)]
|
||||
pub balance: BalanceAlgorithm,
|
||||
/// Default for optional backend telemetry; basic stream counters remain.
|
||||
#[serde(default)]
|
||||
pub metrics_enabled: Option<bool>,
|
||||
/// TCP connect timeout. Kept in the common schema so TCP/UDP rows stay simple.
|
||||
pub connect_timeout_seconds: u64,
|
||||
/// Inactivity timeout for a TCP relay or a per-client UDP session.
|
||||
@@ -52,6 +60,9 @@ impl Default for Host {
|
||||
protocol: Protocol::Tcp,
|
||||
listen: "127.0.0.1:10000".parse().expect("static stream listen"),
|
||||
upstream: "127.0.0.1:10001".parse().expect("static stream upstream"),
|
||||
backends: Vec::new(),
|
||||
balance: BalanceAlgorithm::default(),
|
||||
metrics_enabled: None,
|
||||
connect_timeout_seconds: 5,
|
||||
idle_timeout_seconds: 300,
|
||||
max_connections: 1024,
|
||||
@@ -73,6 +84,7 @@ pub struct Metrics {
|
||||
pub bytes_out: AtomicU64,
|
||||
pub packets_in: AtomicU64,
|
||||
pub packets_out: AtomicU64,
|
||||
pub pool: std::sync::OnceLock<Arc<Pool>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -86,6 +98,7 @@ pub struct MetricsView {
|
||||
pub bytes_out: u64,
|
||||
pub packets_in: u64,
|
||||
pub packets_out: u64,
|
||||
pub backends: Vec<BackendView>,
|
||||
}
|
||||
|
||||
impl Metrics {
|
||||
@@ -101,6 +114,7 @@ impl Metrics {
|
||||
bytes_out: load(&self.bytes_out),
|
||||
packets_in: load(&self.packets_in),
|
||||
packets_out: load(&self.packets_out),
|
||||
backends: self.pool.get().map_or_else(Vec::new, |pool| pool.views()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,15 +158,16 @@ pub fn validate(
|
||||
"stream host name must be at most 96 bytes without controls"
|
||||
);
|
||||
ensure!(
|
||||
host.listen.port() > 0 && host.upstream.port() > 0,
|
||||
host.listen.port() > 0 && (host.upstream.port() > 0 || !host.backends.is_empty()),
|
||||
"stream listener/upstream ports must be nonzero"
|
||||
);
|
||||
ensure!(
|
||||
!host.listen.ip().is_multicast() && !host.upstream.ip().is_multicast(),
|
||||
!host.listen.ip().is_multicast()
|
||||
&& (host.backends.len() > 0 || !host.upstream.ip().is_multicast()),
|
||||
"stream listener/upstream must not use multicast addresses"
|
||||
);
|
||||
ensure!(
|
||||
!host.upstream.ip().is_unspecified(),
|
||||
(!host.backends.is_empty() || !host.upstream.ip().is_unspecified()),
|
||||
"stream upstream must use a concrete IP address"
|
||||
);
|
||||
ensure!(
|
||||
@@ -167,6 +182,53 @@ pub fn validate(
|
||||
(1..=65535).contains(&host.max_connections),
|
||||
"stream connection/session limit must be 1..65535"
|
||||
);
|
||||
ensure!(
|
||||
matches!(
|
||||
(host.protocol, host.balance),
|
||||
(
|
||||
Protocol::Tcp,
|
||||
BalanceAlgorithm::RoundRobin | BalanceAlgorithm::LeastConnections
|
||||
) | (
|
||||
Protocol::Udp,
|
||||
BalanceAlgorithm::RoundRobin | BalanceAlgorithm::LeastSessions
|
||||
)
|
||||
),
|
||||
"stream balancing algorithm does not match transport"
|
||||
);
|
||||
ensure!(host.backends.len() <= 32, "at most 32 backends per stream");
|
||||
let mut backend_ids = HashSet::new();
|
||||
for backend in &mut host.backends {
|
||||
ensure!(
|
||||
crate::config::valid_id(&backend.id) && backend_ids.insert(backend.id.clone()),
|
||||
"invalid/duplicate stream backend id"
|
||||
);
|
||||
backend.name = backend.name.trim().to_owned();
|
||||
ensure!(
|
||||
backend.name.len() <= 96 && !backend.name.chars().any(char::is_control),
|
||||
"invalid stream backend name"
|
||||
);
|
||||
ensure!(
|
||||
backend.address.port() > 0
|
||||
&& !backend.address.ip().is_unspecified()
|
||||
&& !backend.address.ip().is_multicast(),
|
||||
"invalid stream backend address"
|
||||
);
|
||||
ensure!(
|
||||
(1..=16).contains(&backend.weight),
|
||||
"stream backend weight must be 1..16"
|
||||
);
|
||||
ensure!(
|
||||
backend
|
||||
.max_sessions
|
||||
.is_none_or(|n| (1..=65535).contains(&n)),
|
||||
"stream backend session limit must be 1..65535"
|
||||
);
|
||||
backend.health.validate(host.protocol)?;
|
||||
}
|
||||
// Retain upstream as a representative address for older clients.
|
||||
if let Some(first) = host.backends.first() {
|
||||
host.upstream = first.address;
|
||||
}
|
||||
}
|
||||
|
||||
for (index, host) in hosts.iter().enumerate() {
|
||||
@@ -220,44 +282,44 @@ pub fn validate(
|
||||
}
|
||||
}
|
||||
|
||||
// Reject direct loops into another RustProxy listener of the same transport.
|
||||
// Reject direct loops for every backend (including disabled/draining entries).
|
||||
for host in hosts.iter().filter(|item| item.enabled) {
|
||||
match host.protocol {
|
||||
Protocol::Tcp => {
|
||||
ensure!(
|
||||
!crate::vhost::socket_overlap(host.upstream, admin)
|
||||
&& prometheus
|
||||
.is_none_or(|addr| !crate::vhost::socket_overlap(host.upstream, addr))
|
||||
&& !http_listeners
|
||||
for backend in host.effective_backends() {
|
||||
let target = backend.address;
|
||||
match host.protocol {
|
||||
Protocol::Tcp => {
|
||||
ensure!(
|
||||
!crate::vhost::socket_overlap(target, admin)
|
||||
&& prometheus
|
||||
.is_none_or(|addr| !crate::vhost::socket_overlap(target, addr))
|
||||
&& !http_listeners
|
||||
.iter()
|
||||
.any(|listener| crate::vhost::socket_overlap(
|
||||
target,
|
||||
listener.address
|
||||
))
|
||||
&& !hosts
|
||||
.iter()
|
||||
.filter(|other| other.enabled && other.protocol == Protocol::Tcp)
|
||||
.any(|other| crate::vhost::socket_overlap(target, other.listen)),
|
||||
"TCP stream {} upstream points at a RustProxy listener",
|
||||
host.id
|
||||
);
|
||||
}
|
||||
Protocol::Udp => {
|
||||
ensure!(
|
||||
!http_listeners
|
||||
.iter()
|
||||
.any(|listener| crate::vhost::socket_overlap(
|
||||
host.upstream,
|
||||
listener.address
|
||||
))
|
||||
&& !hosts
|
||||
.iter()
|
||||
.filter(|other| other.enabled && other.protocol == Protocol::Tcp)
|
||||
.any(|other| crate::vhost::socket_overlap(host.upstream, other.listen)),
|
||||
"TCP stream {} upstream points at a RustProxy listener",
|
||||
host.id
|
||||
);
|
||||
}
|
||||
Protocol::Udp => {
|
||||
ensure!(
|
||||
!http_listeners
|
||||
.iter()
|
||||
.filter(|listener| listener.http3.is_some())
|
||||
.any(|listener| crate::vhost::socket_overlap(
|
||||
host.upstream,
|
||||
listener.address
|
||||
))
|
||||
&& !hosts
|
||||
.iter()
|
||||
.filter(|other| other.enabled && other.protocol == Protocol::Udp)
|
||||
.any(|other| crate::vhost::socket_overlap(host.upstream, other.listen)),
|
||||
"UDP stream {} upstream points at a RustProxy UDP listener",
|
||||
host.id
|
||||
);
|
||||
.filter(|listener| listener.http3.is_some())
|
||||
.any(|listener| crate::vhost::socket_overlap(target, listener.address))
|
||||
&& !hosts
|
||||
.iter()
|
||||
.filter(|other| other.enabled && other.protocol == Protocol::Udp)
|
||||
.any(|other| crate::vhost::socket_overlap(target, other.listen)),
|
||||
"UDP stream {} upstream points at a RustProxy UDP listener",
|
||||
host.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -273,6 +335,9 @@ pub async fn serve_tcp(
|
||||
) -> Result<()> {
|
||||
let slots = Arc::new(Semaphore::new(host.max_connections));
|
||||
let mut tasks = JoinSet::new();
|
||||
let pool = Pool::new(&host);
|
||||
let _ = metrics.pool.set(pool.clone());
|
||||
spawn_health(pool.clone(), &mut tasks, shutdown.clone());
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
@@ -285,6 +350,10 @@ pub async fn serve_tcp(
|
||||
drop(client);
|
||||
continue;
|
||||
};
|
||||
let Some(backend) = pool.choose() else {
|
||||
metrics.rejected.fetch_add(1, Ordering::Relaxed);
|
||||
continue;
|
||||
};
|
||||
metrics.sessions_total.fetch_add(1, Ordering::Relaxed);
|
||||
let cfg = host.clone();
|
||||
let stop = shutdown.clone();
|
||||
@@ -293,24 +362,28 @@ pub async fn serve_tcp(
|
||||
tasks.spawn(async move {
|
||||
let _active = active;
|
||||
let _permit = permit;
|
||||
let target = backend.runtime.address;
|
||||
let upstream = match tokio::time::timeout(
|
||||
Duration::from_secs(cfg.connect_timeout_seconds),
|
||||
TcpStream::connect(cfg.upstream),
|
||||
TcpStream::connect(target),
|
||||
).await {
|
||||
Ok(Ok(stream)) => stream,
|
||||
Ok(Err(error)) => {
|
||||
stream_metrics.errors.fetch_add(1, Ordering::Relaxed);
|
||||
backend.error();
|
||||
tracing::debug!(stream=%cfg.id, %peer, %error, "TCP stream upstream connect failed");
|
||||
return;
|
||||
},
|
||||
Err(_) => {
|
||||
stream_metrics.errors.fetch_add(1, Ordering::Relaxed);
|
||||
backend.error();
|
||||
tracing::debug!(stream=%cfg.id, %peer, "TCP stream upstream connect timed out");
|
||||
return;
|
||||
},
|
||||
};
|
||||
if let Err(error) = relay_tcp(client, upstream, stop, Duration::from_secs(cfg.idle_timeout_seconds), &stream_metrics).await {
|
||||
if let Err(error) = relay_tcp(client, upstream, stop, Duration::from_secs(cfg.idle_timeout_seconds), &stream_metrics, &backend).await {
|
||||
stream_metrics.errors.fetch_add(1, Ordering::Relaxed);
|
||||
backend.error();
|
||||
tracing::debug!(stream=%cfg.id, %peer, %error, "TCP stream relay ended");
|
||||
}
|
||||
});
|
||||
@@ -328,6 +401,7 @@ async fn copy_with_activity<R, W>(
|
||||
mut writer: W,
|
||||
activity: tokio::sync::watch::Sender<Instant>,
|
||||
transferred: &AtomicU64,
|
||||
detailed: Option<&AtomicU64>,
|
||||
) -> std::io::Result<u64>
|
||||
where
|
||||
R: AsyncRead + Unpin,
|
||||
@@ -344,6 +418,9 @@ where
|
||||
writer.write_all(&buffer[..read]).await?;
|
||||
total = total.saturating_add(read as u64);
|
||||
transferred.fetch_add(read as u64, Ordering::Relaxed);
|
||||
if let Some(counter) = detailed {
|
||||
counter.fetch_add(read as u64, Ordering::Relaxed);
|
||||
}
|
||||
activity.send_replace(Instant::now());
|
||||
}
|
||||
}
|
||||
@@ -354,6 +431,7 @@ async fn relay_tcp(
|
||||
shutdown: CancellationToken,
|
||||
idle: Duration,
|
||||
metrics: &Metrics,
|
||||
backend: &BackendLease,
|
||||
) -> Result<()> {
|
||||
let (client_read, client_write) = client.into_split();
|
||||
let (upstream_read, upstream_write) = upstream.into_split();
|
||||
@@ -363,12 +441,14 @@ async fn relay_tcp(
|
||||
upstream_write,
|
||||
activity.clone(),
|
||||
&metrics.bytes_in,
|
||||
backend.detail(&backend.runtime.bytes_in),
|
||||
);
|
||||
let u2c = copy_with_activity(
|
||||
upstream_read,
|
||||
client_write,
|
||||
activity.clone(),
|
||||
&metrics.bytes_out,
|
||||
backend.detail(&backend.runtime.bytes_out),
|
||||
);
|
||||
drop(activity);
|
||||
tokio::pin!(c2u);
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
// Bounded Layer-4 backend pools. No async locks on the traffic hot path.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BalanceAlgorithm {
|
||||
#[default]
|
||||
RoundRobin,
|
||||
LeastSessions,
|
||||
#[serde(alias = "least_connections")]
|
||||
LeastConnections,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum HealthMode {
|
||||
#[default]
|
||||
Off,
|
||||
TcpConnect,
|
||||
UdpProbe,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct HealthCheck {
|
||||
pub mode: HealthMode,
|
||||
pub interval_seconds: u64,
|
||||
pub timeout_ms: u64,
|
||||
pub rise: u32,
|
||||
pub fall: u32,
|
||||
/// Hexadecimal payload sent only to the configured backend when response matching is set.
|
||||
pub payload_hex: String,
|
||||
/// Exact hex response. Without this, UDP remains unverified and no probes are sent.
|
||||
pub expect_hex: Option<String>,
|
||||
}
|
||||
impl Default for HealthCheck {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mode: HealthMode::Off,
|
||||
interval_seconds: 10,
|
||||
timeout_ms: 1000,
|
||||
rise: 2,
|
||||
fall: 2,
|
||||
payload_hex: String::new(),
|
||||
expect_hex: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl HealthCheck {
|
||||
fn validate(&self, protocol: Protocol) -> Result<()> {
|
||||
ensure!(
|
||||
(1..=3600).contains(&self.interval_seconds)
|
||||
&& (50..=30000).contains(&self.timeout_ms)
|
||||
&& self.timeout_ms < self.interval_seconds * 1000,
|
||||
"invalid stream health interval/timeout"
|
||||
);
|
||||
ensure!(
|
||||
(1..=10).contains(&self.rise) && (1..=10).contains(&self.fall),
|
||||
"invalid rise/fall"
|
||||
);
|
||||
ensure!(
|
||||
matches!(
|
||||
(protocol, self.mode),
|
||||
(Protocol::Tcp, HealthMode::Off | HealthMode::TcpConnect)
|
||||
| (Protocol::Udp, HealthMode::Off | HealthMode::UdpProbe)
|
||||
),
|
||||
"health mode does not match stream protocol"
|
||||
);
|
||||
let payload = decode_probe_hex(&self.payload_hex)?;
|
||||
ensure!(payload.len() <= 128, "UDP probe payload exceeds 128 bytes");
|
||||
if let Some(expected) = &self.expect_hex {
|
||||
ensure!(
|
||||
!expected.is_empty() && decode_probe_hex(expected)?.len() <= 128,
|
||||
"invalid UDP probe response"
|
||||
);
|
||||
}
|
||||
if self.mode == HealthMode::UdpProbe && self.expect_hex.is_some() {
|
||||
ensure!(
|
||||
!payload.is_empty(),
|
||||
"verified UDP probes require a nonempty payload"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_probe_hex(value: &str) -> Result<Vec<u8>> {
|
||||
ensure!(
|
||||
value.len() <= 256 && value.len() % 2 == 0 && value.is_ascii(),
|
||||
"invalid probe hex length"
|
||||
);
|
||||
value
|
||||
.as_bytes()
|
||||
.chunks_exact(2)
|
||||
.map(|chunk| {
|
||||
let hex = |byte: u8| -> Option<u8> {
|
||||
match byte {
|
||||
b'0'..=b'9' => Some(byte - b'0'),
|
||||
b'a'..=b'f' => Some(byte - b'a' + 10),
|
||||
b'A'..=b'F' => Some(byte - b'A' + 10),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
Ok((hex(chunk[0]).context("invalid probe hex")? << 4)
|
||||
| hex(chunk[1]).context("invalid probe hex")?)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Backend {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
pub address: SocketAddr,
|
||||
#[serde(default = "backend_default_enabled")]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub drain: bool,
|
||||
#[serde(default = "backend_default_weight")]
|
||||
pub weight: u8,
|
||||
#[serde(default)]
|
||||
pub max_sessions: Option<usize>,
|
||||
#[serde(default)]
|
||||
pub health: HealthCheck,
|
||||
#[serde(default)]
|
||||
pub metrics_enabled: Option<bool>,
|
||||
}
|
||||
fn backend_default_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
fn backend_default_weight() -> u8 {
|
||||
1
|
||||
}
|
||||
impl Default for Backend {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
id: "primary".into(),
|
||||
name: "Primary".into(),
|
||||
address: "127.0.0.1:10001".parse().expect("static backend"),
|
||||
enabled: true,
|
||||
drain: false,
|
||||
weight: 1,
|
||||
max_sessions: None,
|
||||
health: HealthCheck::default(),
|
||||
metrics_enabled: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Host {
|
||||
pub fn effective_backends(&self) -> Vec<Backend> {
|
||||
if self.backends.is_empty() {
|
||||
vec![Backend {
|
||||
address: self.upstream,
|
||||
..Backend::default()
|
||||
}]
|
||||
} else {
|
||||
self.backends.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// health states are distinct from eligibility: an unverified UDP backend may receive
|
||||
// traffic, but must never be labeled "healthy" by a local UDP connect().
|
||||
const HEALTH_UNVERIFIED: u8 = 0;
|
||||
const HEALTH_CHECKING: u8 = 1;
|
||||
const HEALTH_HEALTHY: u8 = 2;
|
||||
const HEALTH_UNHEALTHY: u8 = 3;
|
||||
#[derive(Debug, Default, Clone, Serialize)]
|
||||
pub struct HealthObservation {
|
||||
pub last_checked_unix_ms: Option<u64>,
|
||||
pub latency_ms: Option<u64>,
|
||||
pub last_result: Option<&'static str>,
|
||||
pub last_error: Option<String>,
|
||||
pub successful: u64,
|
||||
pub failed: u64,
|
||||
#[serde(skip)]
|
||||
rises: u32,
|
||||
#[serde(skip)]
|
||||
falls: u32,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
pub struct RuntimeBackend {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub address: SocketAddr,
|
||||
pub enabled: bool,
|
||||
pub drain: bool,
|
||||
pub weight: u8,
|
||||
pub max_sessions: Option<usize>,
|
||||
pub health: HealthCheck,
|
||||
pub detail_enabled: bool,
|
||||
state: std::sync::atomic::AtomicU8,
|
||||
observation: std::sync::Mutex<HealthObservation>,
|
||||
active: AtomicU64,
|
||||
total: AtomicU64,
|
||||
completed: AtomicU64,
|
||||
assignments: AtomicU64,
|
||||
rejected: AtomicU64,
|
||||
errors: AtomicU64,
|
||||
bytes_in: AtomicU64,
|
||||
bytes_out: AtomicU64,
|
||||
packets_in: AtomicU64,
|
||||
packets_out: AtomicU64,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct BackendView {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub address: SocketAddr,
|
||||
pub enabled: bool,
|
||||
pub drain: bool,
|
||||
pub detail_enabled: bool,
|
||||
pub status: &'static str,
|
||||
pub active: u64,
|
||||
pub sessions_total: Option<u64>,
|
||||
pub sessions_completed: Option<u64>,
|
||||
pub assignments: Option<u64>,
|
||||
pub rejected: Option<u64>,
|
||||
pub errors: Option<u64>,
|
||||
pub bytes_in: Option<u64>,
|
||||
pub bytes_out: Option<u64>,
|
||||
pub packets_in: Option<u64>,
|
||||
pub packets_out: Option<u64>,
|
||||
pub health: HealthObservation,
|
||||
}
|
||||
impl RuntimeBackend {
|
||||
fn new(config: Backend, host_metrics_enabled: bool) -> Self {
|
||||
let checked = match config.health.mode {
|
||||
HealthMode::Off => false,
|
||||
HealthMode::UdpProbe if config.health.expect_hex.is_none() => false,
|
||||
_ => true,
|
||||
};
|
||||
let initial = if checked {
|
||||
HEALTH_CHECKING
|
||||
} else {
|
||||
HEALTH_UNVERIFIED
|
||||
};
|
||||
Self {
|
||||
id: config.id,
|
||||
name: config.name,
|
||||
address: config.address,
|
||||
enabled: config.enabled,
|
||||
drain: config.drain,
|
||||
weight: config.weight,
|
||||
max_sessions: config.max_sessions,
|
||||
detail_enabled: config.metrics_enabled.unwrap_or(host_metrics_enabled),
|
||||
health: config.health,
|
||||
state: std::sync::atomic::AtomicU8::new(initial),
|
||||
observation: std::sync::Mutex::new(HealthObservation::default()),
|
||||
active: AtomicU64::new(0),
|
||||
total: AtomicU64::new(0),
|
||||
completed: AtomicU64::new(0),
|
||||
assignments: AtomicU64::new(0),
|
||||
rejected: AtomicU64::new(0),
|
||||
errors: AtomicU64::new(0),
|
||||
bytes_in: AtomicU64::new(0),
|
||||
bytes_out: AtomicU64::new(0),
|
||||
packets_in: AtomicU64::new(0),
|
||||
packets_out: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
fn eligible(&self) -> bool {
|
||||
self.enabled && !self.drain && self.state.load(Ordering::Relaxed) != HEALTH_UNHEALTHY
|
||||
}
|
||||
fn reserve(&self) -> bool {
|
||||
let capacity = self.max_sessions.map_or(u64::MAX, |n| n as u64);
|
||||
let result = self
|
||||
.active
|
||||
.try_update(Ordering::AcqRel, Ordering::Relaxed, |n| {
|
||||
(n < capacity).then_some(n + 1)
|
||||
});
|
||||
if result.is_err() && self.detail_enabled {
|
||||
self.rejected.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
result.is_ok()
|
||||
}
|
||||
fn view(&self) -> BackendView {
|
||||
let load =
|
||||
|counter: &AtomicU64| self.detail_enabled.then(|| counter.load(Ordering::Relaxed));
|
||||
let state = if !self.enabled {
|
||||
"disabled"
|
||||
} else {
|
||||
match self.state.load(Ordering::Relaxed) {
|
||||
HEALTH_CHECKING => "checking",
|
||||
HEALTH_HEALTHY => "healthy",
|
||||
HEALTH_UNHEALTHY => "unhealthy",
|
||||
_ => "unverified",
|
||||
}
|
||||
};
|
||||
BackendView {
|
||||
id: self.id.clone(),
|
||||
name: self.name.clone(),
|
||||
address: self.address,
|
||||
enabled: self.enabled,
|
||||
drain: self.drain,
|
||||
detail_enabled: self.detail_enabled,
|
||||
status: state,
|
||||
active: self.active.load(Ordering::Relaxed),
|
||||
sessions_total: load(&self.total),
|
||||
sessions_completed: load(&self.completed),
|
||||
assignments: load(&self.assignments),
|
||||
rejected: load(&self.rejected),
|
||||
errors: load(&self.errors),
|
||||
bytes_in: load(&self.bytes_in),
|
||||
bytes_out: load(&self.bytes_out),
|
||||
packets_in: load(&self.packets_in),
|
||||
packets_out: load(&self.packets_out),
|
||||
health: self
|
||||
.observation
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Pool {
|
||||
backends: Vec<Arc<RuntimeBackend>>,
|
||||
algorithm: BalanceAlgorithm,
|
||||
cursor: AtomicU64,
|
||||
}
|
||||
impl Pool {
|
||||
fn new(host: &Host) -> Arc<Self> {
|
||||
let backends = host
|
||||
.effective_backends()
|
||||
.into_iter()
|
||||
.map(|backend| {
|
||||
Arc::new(RuntimeBackend::new(
|
||||
backend,
|
||||
host.metrics_enabled.unwrap_or(false),
|
||||
))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Arc::new(Self {
|
||||
backends,
|
||||
algorithm: host.balance,
|
||||
cursor: AtomicU64::new(0),
|
||||
})
|
||||
}
|
||||
fn assign(&self, index: usize) -> Option<BackendLease> {
|
||||
let backend = &self.backends[index];
|
||||
if !backend.eligible() || !backend.reserve() {
|
||||
return None;
|
||||
}
|
||||
// A health result may have arrived after the first eligibility read.
|
||||
if !backend.eligible() {
|
||||
backend.active.fetch_sub(1, Ordering::AcqRel);
|
||||
return None;
|
||||
}
|
||||
backend.total.fetch_add(1, Ordering::Relaxed);
|
||||
if backend.detail_enabled {
|
||||
backend.assignments.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
Some(BackendLease {
|
||||
runtime: backend.clone(),
|
||||
})
|
||||
}
|
||||
fn choose(&self) -> Option<BackendLease> {
|
||||
if self.backends.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let offset = self.cursor.fetch_add(1, Ordering::Relaxed) as usize;
|
||||
match self.algorithm {
|
||||
BalanceAlgorithm::RoundRobin => {
|
||||
// Recompute the bounded eligible weight on each assignment:
|
||||
// unhealthy/draining/capacity-full peers do not skew the ratio.
|
||||
let available = |b: &Arc<RuntimeBackend>| {
|
||||
b.eligible()
|
||||
&& b.max_sessions
|
||||
.is_none_or(|max| b.active.load(Ordering::Relaxed) < max as u64)
|
||||
};
|
||||
let total_weight: usize = self
|
||||
.backends
|
||||
.iter()
|
||||
.filter(|b| available(b))
|
||||
.map(|b| b.weight as usize)
|
||||
.sum();
|
||||
if total_weight == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut ticket = offset % total_weight;
|
||||
let mut preferred = 0;
|
||||
for (index, backend) in self.backends.iter().enumerate() {
|
||||
if !available(backend) {
|
||||
continue;
|
||||
}
|
||||
if ticket < backend.weight as usize {
|
||||
preferred = index;
|
||||
break;
|
||||
}
|
||||
ticket -= backend.weight as usize;
|
||||
}
|
||||
// A concurrent session or health transition may invalidate the
|
||||
// preferred peer after the snapshot: try each other peer once.
|
||||
for delta in 0..self.backends.len() {
|
||||
let index = (preferred + delta) % self.backends.len();
|
||||
if let Some(lease) = self.assign(index) {
|
||||
return Some(lease);
|
||||
}
|
||||
}
|
||||
}
|
||||
BalanceAlgorithm::LeastSessions | BalanceAlgorithm::LeastConnections => {
|
||||
let mut ordered = (0..self.backends.len()).collect::<Vec<_>>();
|
||||
ordered.sort_by_key(|&index| {
|
||||
let backend = &self.backends[index];
|
||||
(
|
||||
backend.active.load(Ordering::Relaxed).saturating_mul(16)
|
||||
/ backend.weight as u64,
|
||||
(index + self.backends.len() - offset % self.backends.len())
|
||||
% self.backends.len(),
|
||||
)
|
||||
});
|
||||
for index in ordered {
|
||||
if let Some(lease) = self.assign(index) {
|
||||
return Some(lease);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
fn views(&self) -> Vec<BackendView> {
|
||||
self.backends.iter().map(|b| b.view()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
struct BackendLease {
|
||||
runtime: Arc<RuntimeBackend>,
|
||||
}
|
||||
impl BackendLease {
|
||||
fn detail<'a>(&self, value: &'a AtomicU64) -> Option<&'a AtomicU64> {
|
||||
self.runtime.detail_enabled.then_some(value)
|
||||
}
|
||||
fn error(&self) {
|
||||
if self.runtime.detail_enabled {
|
||||
self.runtime.errors.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Drop for BackendLease {
|
||||
fn drop(&mut self) {
|
||||
self.runtime.active.fetch_sub(1, Ordering::Relaxed);
|
||||
self.runtime.completed.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
+20
-3
@@ -10,6 +10,9 @@ pub async fn serve_udp(
|
||||
let slots = Arc::new(Semaphore::new(host.max_connections));
|
||||
let next_id = AtomicU64::new(1);
|
||||
let mut tasks = JoinSet::new();
|
||||
let pool = Pool::new(&host);
|
||||
let _ = metrics.pool.set(pool.clone());
|
||||
spawn_health(pool.clone(), &mut tasks, shutdown.clone());
|
||||
let mut buffer = vec![0u8; 65_535];
|
||||
loop {
|
||||
tokio::select! {
|
||||
@@ -31,7 +34,11 @@ pub async fn serve_udp(
|
||||
metrics.rejected.fetch_add(1, Ordering::Relaxed);
|
||||
continue;
|
||||
};
|
||||
let bind = match host.upstream.ip() {
|
||||
let Some(backend) = pool.choose() else {
|
||||
metrics.rejected.fetch_add(1, Ordering::Relaxed);
|
||||
continue;
|
||||
};
|
||||
let bind = match backend.runtime.address.ip() {
|
||||
IpAddr::V4(_) => SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0),
|
||||
IpAddr::V6(_) => SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0),
|
||||
};
|
||||
@@ -39,12 +46,14 @@ pub async fn serve_udp(
|
||||
Ok(socket) => socket,
|
||||
Err(error) => {
|
||||
metrics.errors.fetch_add(1, Ordering::Relaxed);
|
||||
backend.error();
|
||||
tracing::debug!(stream=%host.id, %peer, %error, "UDP stream ephemeral bind failed");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Err(error) = upstream.connect(host.upstream).await {
|
||||
if let Err(error) = upstream.connect(backend.runtime.address).await {
|
||||
metrics.errors.fetch_add(1, Ordering::Relaxed);
|
||||
backend.error();
|
||||
tracing::debug!(stream=%host.id, %peer, %error, "UDP stream upstream connect failed");
|
||||
continue;
|
||||
}
|
||||
@@ -63,7 +72,7 @@ pub async fn serve_udp(
|
||||
let active = ActiveSession::begin(stream_metrics.clone());
|
||||
tasks.spawn(async move {
|
||||
let _permit = permit;
|
||||
udp_session(cfg, peer, id, front, upstream, rx, map, stop, stream_metrics, active).await;
|
||||
udp_session(cfg, peer, id, front, upstream, rx, map, stop, stream_metrics, active, backend).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -85,6 +94,7 @@ async fn udp_session(
|
||||
shutdown: CancellationToken,
|
||||
metrics: Arc<Metrics>,
|
||||
_active: ActiveSession,
|
||||
backend: BackendLease,
|
||||
) {
|
||||
let idle = Duration::from_secs(host.idle_timeout_seconds);
|
||||
let deadline = tokio::time::sleep_until(Instant::now() + idle);
|
||||
@@ -96,22 +106,29 @@ async fn udp_session(
|
||||
Some(data) = incoming.recv() => {
|
||||
if upstream.send(&data).await.is_err() {
|
||||
metrics.errors.fetch_add(1, Ordering::Relaxed);
|
||||
backend.error();
|
||||
break;
|
||||
}
|
||||
if let Some(counter) = backend.detail(&backend.runtime.bytes_in) { counter.fetch_add(data.len() as u64, Ordering::Relaxed); }
|
||||
if let Some(counter) = backend.detail(&backend.runtime.packets_in) { counter.fetch_add(1, Ordering::Relaxed); }
|
||||
deadline.as_mut().reset(Instant::now() + idle);
|
||||
},
|
||||
received = upstream.recv(&mut buffer) => match received {
|
||||
Ok(length) => {
|
||||
if frontend.send_to(&buffer[..length], peer).await.is_err() {
|
||||
metrics.errors.fetch_add(1, Ordering::Relaxed);
|
||||
backend.error();
|
||||
break;
|
||||
}
|
||||
if let Some(counter) = backend.detail(&backend.runtime.bytes_out) { counter.fetch_add(length as u64, Ordering::Relaxed); }
|
||||
if let Some(counter) = backend.detail(&backend.runtime.packets_out) { counter.fetch_add(1, Ordering::Relaxed); }
|
||||
metrics.packets_out.fetch_add(1, Ordering::Relaxed);
|
||||
metrics.bytes_out.fetch_add(length as u64, Ordering::Relaxed);
|
||||
deadline.as_mut().reset(Instant::now() + idle);
|
||||
}
|
||||
Err(_) => {
|
||||
metrics.errors.fetch_add(1, Ordering::Relaxed);
|
||||
backend.error();
|
||||
break;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -60,3 +60,304 @@ mod tests {
|
||||
assert_eq!(view.errors, 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pool_regression {
|
||||
use super::*;
|
||||
|
||||
fn backend(id: &str, port: u16) -> Backend {
|
||||
Backend {
|
||||
id: id.into(),
|
||||
address: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port),
|
||||
..Backend::default()
|
||||
}
|
||||
}
|
||||
fn host_with_pool(protocol: Protocol) -> Host {
|
||||
Host {
|
||||
id: "pool-test".into(),
|
||||
protocol,
|
||||
listen: "127.0.0.1:19001".parse().unwrap(),
|
||||
upstream: "127.0.0.1:19002".parse().unwrap(),
|
||||
backends: vec![backend("node-a", 19002), backend("node-b", 19003)],
|
||||
..Host::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_upstream_remains_a_single_backend() {
|
||||
let host: Host = serde_json::from_value(serde_json::json!({
|
||||
"id": "legacy", "protocol": "tcp", "listen": "127.0.0.1:19001",
|
||||
"upstream": "127.0.0.1:19002"
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(host.backends.is_empty());
|
||||
assert_eq!(host.effective_backends().len(), 1);
|
||||
assert_eq!(host.effective_backends()[0].id, "primary");
|
||||
assert_eq!(host.effective_backends()[0].address, host.upstream);
|
||||
let mut hosts = vec![host];
|
||||
validate(&mut hosts, &[], "127.0.0.1:9090".parse().unwrap(), None).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_validation_rejects_duplicate_id_and_listener_loop() {
|
||||
let mut hosts = vec![host_with_pool(Protocol::Tcp)];
|
||||
hosts[0].backends[1].id = "node-a".into();
|
||||
assert!(validate(&mut hosts, &[], "127.0.0.1:9090".parse().unwrap(), None).is_err());
|
||||
hosts[0].backends[1].id = "node-b".into();
|
||||
hosts[0].backends[1].address = hosts[0].listen;
|
||||
assert!(validate(&mut hosts, &[], "127.0.0.1:9090".parse().unwrap(), None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weighted_round_robin_respects_sequence() {
|
||||
let mut host = host_with_pool(Protocol::Tcp);
|
||||
host.backends[0].weight = 2;
|
||||
let pool = Pool::new(&host);
|
||||
let choices = (0..6)
|
||||
.map(|_| pool.choose().unwrap().runtime.id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
choices,
|
||||
["node-a", "node-a", "node-b", "node-a", "node-a", "node-b"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn least_load_and_draining_and_cap_do_not_leak_leases() {
|
||||
let mut host = host_with_pool(Protocol::Udp);
|
||||
host.balance = BalanceAlgorithm::LeastSessions;
|
||||
let pool = Pool::new(&host);
|
||||
let first = pool.choose().unwrap();
|
||||
let second = pool.choose().unwrap();
|
||||
assert_ne!(first.runtime.id, second.runtime.id);
|
||||
assert_eq!(first.runtime.active.load(Ordering::Relaxed), 1);
|
||||
drop(first);
|
||||
drop(second);
|
||||
assert!(pool
|
||||
.backends
|
||||
.iter()
|
||||
.all(|b| b.active.load(Ordering::Relaxed) == 0));
|
||||
|
||||
host.backends[0].drain = true;
|
||||
host.backends[1].max_sessions = Some(1);
|
||||
let pool = Pool::new(&host);
|
||||
let active = pool.choose().unwrap();
|
||||
assert_eq!(active.runtime.id, "node-b");
|
||||
assert!(pool.choose().is_none());
|
||||
drop(active);
|
||||
assert!(pool.choose().is_some());
|
||||
host.backends[1].enabled = false;
|
||||
assert!(Pool::new(&host).choose().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detailed_metrics_override_does_not_control_routing() {
|
||||
let mut host = host_with_pool(Protocol::Tcp);
|
||||
let legacy = Pool::new(&host);
|
||||
assert!(!legacy.backends[0].detail_enabled);
|
||||
assert!(legacy.choose().is_some());
|
||||
host.metrics_enabled = Some(true);
|
||||
host.backends[1].metrics_enabled = Some(false);
|
||||
let with_stream_default = Pool::new(&host);
|
||||
assert!(with_stream_default.backends[0].detail_enabled);
|
||||
assert!(!with_stream_default.backends[1].detail_enabled);
|
||||
host.metrics_enabled = Some(false);
|
||||
host.backends[0].metrics_enabled = Some(true);
|
||||
let override_pool = Pool::new(&host);
|
||||
assert!(override_pool.backends[0].detail_enabled);
|
||||
assert!(!override_pool.backends[1].detail_enabled);
|
||||
assert!(override_pool.choose().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tcp_hysteresis_requires_rise_and_fall() {
|
||||
let mut host = host_with_pool(Protocol::Tcp);
|
||||
host.backends[0].health.mode = HealthMode::TcpConnect;
|
||||
let pool = Pool::new(&host);
|
||||
let b = &pool.backends[0];
|
||||
assert_eq!(b.view().status, "checking");
|
||||
record_probe(b, Ok(10));
|
||||
assert_eq!(b.view().status, "checking");
|
||||
record_probe(b, Ok(12));
|
||||
assert_eq!(b.view().status, "healthy");
|
||||
record_probe(b, Err("socket unavailable".into()));
|
||||
assert_eq!(b.view().health.last_result, Some("failure"));
|
||||
assert_eq!(b.view().status, "healthy");
|
||||
record_probe(b, Err("socket unavailable".into()));
|
||||
assert_eq!(b.view().status, "unhealthy");
|
||||
assert!(!b.eligible());
|
||||
record_probe(b, Ok(9));
|
||||
record_probe(b, Ok(9));
|
||||
assert_eq!(b.view().status, "healthy");
|
||||
assert_eq!(b.view().health.last_result, Some("success"));
|
||||
assert_eq!(
|
||||
b.view().health.last_error.as_deref(),
|
||||
Some("socket unavailable")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn udp_without_verified_reply_is_not_healthy() {
|
||||
let mut host = host_with_pool(Protocol::Udp);
|
||||
host.backends[0].health.mode = HealthMode::UdpProbe;
|
||||
host.backends[0].health.payload_hex = "50494e47".into();
|
||||
host.backends[1].health.mode = HealthMode::UdpProbe;
|
||||
host.backends[1].health.expect_hex = Some("504f4e47".into());
|
||||
host.backends[1].health.payload_hex = "50494e47".into();
|
||||
let pool = Pool::new(&host);
|
||||
assert_eq!(pool.backends[0].view().status, "unverified");
|
||||
assert_eq!(pool.backends[1].view().status, "checking");
|
||||
host.backends[0].health.expect_hex = Some("ff".into());
|
||||
host.backends[0].health.payload_hex = "".into();
|
||||
assert!(host.backends[0].health.validate(Protocol::Udp).is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tcp_and_udp_probes_require_real_remote_responses() {
|
||||
let tcp = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let mut tcp_config = backend("tcp-probe", tcp.local_addr().unwrap().port());
|
||||
tcp_config.health.mode = HealthMode::TcpConnect;
|
||||
let tcp_backend = RuntimeBackend::new(tcp_config, false);
|
||||
assert!(check_backend(&tcp_backend).await.is_ok());
|
||||
|
||||
let udp = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
let mut udp_config = backend("udp-probe", udp.local_addr().unwrap().port());
|
||||
udp_config.health.mode = HealthMode::UdpProbe;
|
||||
udp_config.health.payload_hex = "50494e47".into();
|
||||
udp_config.health.expect_hex = Some("504f4e47".into());
|
||||
let udp_backend = RuntimeBackend::new(udp_config, false);
|
||||
let task = tokio::spawn(async move {
|
||||
let mut recv = [0u8; 32];
|
||||
let (n, address) = udp.recv_from(&mut recv).await.unwrap();
|
||||
assert_eq!(&recv[..n], b"PING");
|
||||
udp.send_to(b"PONG", address).await.unwrap();
|
||||
});
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_secs(2), check_backend(&udp_backend))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_ok()
|
||||
);
|
||||
task.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tcp_relay_tracks_backend_bytes_and_closes_leases() {
|
||||
let upstream = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let upstream_addr = upstream.local_addr().unwrap();
|
||||
let upstream_task = tokio::spawn(async move {
|
||||
let (mut conn, _) = upstream.accept().await.unwrap();
|
||||
let mut request = [0u8; 4];
|
||||
conn.read_exact(&mut request).await.unwrap();
|
||||
conn.write_all(&request).await.unwrap();
|
||||
conn.shutdown().await.unwrap();
|
||||
});
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let mut host = host_with_pool(Protocol::Tcp);
|
||||
host.listen = listener.local_addr().unwrap();
|
||||
host.backends = vec![backend("echo", upstream_addr.port())];
|
||||
host.metrics_enabled = Some(true);
|
||||
let metrics = Arc::new(Metrics::default());
|
||||
let stop = CancellationToken::new();
|
||||
let task = tokio::spawn(serve_tcp(
|
||||
host.clone(),
|
||||
listener,
|
||||
stop.clone(),
|
||||
Duration::from_secs(1),
|
||||
metrics.clone(),
|
||||
));
|
||||
let mut client = TcpStream::connect(host.listen).await.unwrap();
|
||||
client.write_all(b"PING").await.unwrap();
|
||||
client.shutdown().await.unwrap();
|
||||
let mut reply = [0u8; 4];
|
||||
tokio::time::timeout(Duration::from_secs(2), client.read_exact(&mut reply))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(&reply, b"PING");
|
||||
upstream_task.await.unwrap();
|
||||
drop(client);
|
||||
stop.cancel();
|
||||
tokio::time::timeout(Duration::from_secs(3), task)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(metrics.bytes_in.load(Ordering::Relaxed), 4);
|
||||
assert_eq!(metrics.bytes_out.load(Ordering::Relaxed), 4);
|
||||
assert_eq!(metrics.active.load(Ordering::Relaxed), 0);
|
||||
let view = metrics.view();
|
||||
assert_eq!(view.backends[0].active, 0);
|
||||
assert_eq!(view.backends[0].bytes_in, Some(4));
|
||||
assert_eq!(view.backends[0].bytes_out, Some(4));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn udp_session_remains_on_same_backend_and_counts_packets() {
|
||||
let one = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
let two = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
let first_addr = one.local_addr().unwrap();
|
||||
let second_addr = two.local_addr().unwrap();
|
||||
let first_echo = tokio::spawn(async move {
|
||||
let mut buf = [0u8; 32];
|
||||
for _ in 0..2 {
|
||||
let (_, peer) = one.recv_from(&mut buf).await.unwrap();
|
||||
one.send_to(b"A", peer).await.unwrap();
|
||||
}
|
||||
});
|
||||
let second_echo = tokio::spawn(async move {
|
||||
let mut buf = [0u8; 32];
|
||||
let (_, peer) = two.recv_from(&mut buf).await.unwrap();
|
||||
two.send_to(b"B", peer).await.unwrap();
|
||||
});
|
||||
let listen = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
let mut host = host_with_pool(Protocol::Udp);
|
||||
host.listen = listen.local_addr().unwrap();
|
||||
host.backends = vec![
|
||||
backend("a", first_addr.port()),
|
||||
backend("b", second_addr.port()),
|
||||
];
|
||||
host.metrics_enabled = Some(true);
|
||||
let metrics = Arc::new(Metrics::default());
|
||||
let stop = CancellationToken::new();
|
||||
let task = tokio::spawn(serve_udp(
|
||||
host.clone(),
|
||||
listen,
|
||||
stop.clone(),
|
||||
metrics.clone(),
|
||||
));
|
||||
let first_client = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
let second_client = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
let mut result = [0u8; 2];
|
||||
for _ in 0..2 {
|
||||
first_client.send_to(b"x", host.listen).await.unwrap();
|
||||
let (len, _) =
|
||||
tokio::time::timeout(Duration::from_secs(2), first_client.recv_from(&mut result))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(&result[..len], b"A");
|
||||
}
|
||||
second_client.send_to(b"z", host.listen).await.unwrap();
|
||||
let (len, _) =
|
||||
tokio::time::timeout(Duration::from_secs(2), second_client.recv_from(&mut result))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(&result[..len], b"B");
|
||||
first_echo.await.unwrap();
|
||||
second_echo.await.unwrap();
|
||||
stop.cancel();
|
||||
tokio::time::timeout(Duration::from_secs(3), task)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(metrics.active.load(Ordering::Relaxed), 0);
|
||||
assert_eq!(metrics.packets_in.load(Ordering::Relaxed), 3);
|
||||
assert_eq!(metrics.packets_out.load(Ordering::Relaxed), 3);
|
||||
let views = metrics.view().backends;
|
||||
assert_eq!(views[0].packets_in, Some(2));
|
||||
assert_eq!(views[1].packets_in, Some(1));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user