This commit is contained in:
Mateusz Gruszczyński committed 2026-10-08 15:59:37 +02:00
1 parent 65e082b9d1
commit 76cb4acef0
72 files changed
+6134 -4709

No files matched your search

+1
View File
@@ -409,6 +409,7 @@ pub(super) static READER_DOCUMENTS: &[(&str, &str, &str)] = &[
"/docs/V0.25.1_AUDIT.md"
)),
),
reader_doc!("docs/V0.29.0_IMPLEMENTATION.md", "technical"),
reader_doc!("docs/V0.28.2_IMPLEMENTATION.md", "technical"),
reader_doc!("docs/V0.28.1_IMPLEMENTATION.md", "technical"),
reader_doc!("docs/V0.28.0_IMPLEMENTATION.md", "technical"),
+30 -56
View File
@@ -4,6 +4,8 @@ const FILE_LOG_SCAN_LINES: usize = 20_000;
const FILE_LOG_MAX_SOURCES: usize = 32;
const FILE_LOG_MAX_LIMIT: usize = 500;
const FILE_LOG_MAX_OFFSET: usize = 10_000;
const FILE_LOG_GZIP_INPUT_MAX: u64 = 8 * 1024 * 1024;
const FILE_LOG_GZIP_OUTPUT_MAX: u64 = 64 * 1024 * 1024;
#[derive(Clone)]
struct FileLogSource {
id: String,
@@ -15,6 +17,7 @@ struct FileLogSource {
exists: bool,
size: u64,
modified_ms: u64,
compressed: bool,
}
#[derive(Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
@@ -48,6 +51,10 @@ fn source_from_path(
let safe_regular = metadata
.as_ref()
.is_some_and(|meta| meta.file_type().is_file() && !meta.file_type().is_symlink());
let compressed = path
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ext.eq_ignore_ascii_case("gz"));
FileLogSource {
id,
label,
@@ -64,8 +71,11 @@ fn source_from_path(
.as_ref()
.filter(|_| safe_regular)
.map_or(0, file_modified_ms),
compressed,
}
}
include!("file_logs_io.rs");
fn configured_file_log_sources(config: &crate::config::Config) -> Vec<FileLogSource> {
let mut sources = Vec::new();
if config.logging.file.per_vhost {
@@ -81,7 +91,9 @@ fn configured_file_log_sources(config: &crate::config::Config) -> Vec<FileLogSou
.filter_map(|entry| {
let path = entry.path();
let name = entry.file_name().to_string_lossy().into_owned();
if !name.ends_with(".jsonl") || name.len() > 160 {
let plain = name.ends_with(".jsonl");
let rotated = name.contains(".jsonl.") && name.ends_with(".gz");
if (!plain && !rotated) || name.len() > 180 {
return None;
}
let file_type = entry.file_type().ok()?;
@@ -95,7 +107,11 @@ fn configured_file_log_sources(config: &crate::config::Config) -> Vec<FileLogSou
entries.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.0.cmp(&b.0)));
entries.truncate(256);
for (name, path, _) in entries {
let id = format!("vhost:{}", name.trim_end_matches(".jsonl"));
let id = if name.ends_with(".jsonl") {
format!("vhost:{}", name.trim_end_matches(".jsonl"))
} else {
format!("vhost-gz:{name}")
};
sources.push(source_from_path(
id,
name,
@@ -107,26 +123,31 @@ fn configured_file_log_sources(config: &crate::config::Config) -> Vec<FileLogSou
}
}
} else {
let access_path = std::path::PathBuf::from(&config.logging.file.path);
sources.push(source_from_path(
"access".into(),
"Access log".into(),
"access",
std::path::PathBuf::from(&config.logging.file.path),
access_path.clone(),
config.logging.file.enabled,
"JSONL",
));
sources.extend(rotated_gzip_sources(&access_path, "access", "Access log", "access", config.logging.file.enabled, "JSONL"));
}
let error_format = match config.error_log.format {
crate::logging::ErrorLogFormat::Json => "JSONL",
crate::logging::ErrorLogFormat::Raw => "RAW",
};
let error_path = std::path::PathBuf::from(&config.error_log.path);
sources.push(source_from_path(
"error".into(),
"Error log".into(),
"error",
std::path::PathBuf::from(&config.error_log.path),
error_path.clone(),
config.error_log.enabled,
match config.error_log.format {
crate::logging::ErrorLogFormat::Json => "JSONL",
crate::logging::ErrorLogFormat::Raw => "RAW",
},
error_format,
));
sources.extend(rotated_gzip_sources(&error_path, "error", "Error log", "error", config.error_log.enabled, error_format));
sources
}
fn file_log_sources_json(sources: &[FileLogSource]) -> Value {
@@ -144,6 +165,7 @@ fn file_log_sources_json(sources: &[FileLogSource]) -> Value {
"exists": source.exists,
"size": source.size,
"modified_ms": source.modified_ms,
"compressed": source.compressed,
})
})
.collect(),
@@ -171,54 +193,6 @@ async fn file_logs(State(state): State<Arc<App>>) -> Result<Json<Value>, ApiErro
}
})))
}
fn open_file_log(path: &std::path::Path) -> std::io::Result<std::fs::File> {
let mut options = std::fs::OpenOptions::new();
options.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NOFOLLOW);
}
let file = options.open(path)?;
let metadata = file.metadata()?;
if !metadata.file_type().is_file() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"configured log path is not a regular file",
));
}
Ok(file)
}
fn read_tail(
source: &FileLogSource,
budget: u64,
line_budget: usize,
) -> std::io::Result<(Vec<String>, u64, bool)> {
use std::io::{Read, Seek, SeekFrom};
let mut file = open_file_log(&source.path)?;
let length = file.metadata()?.len();
let bytes = length.min(budget).min(FILE_LOG_SCAN_BYTES_PER_SOURCE);
let start = length.saturating_sub(bytes);
file.seek(SeekFrom::Start(start))?;
let mut content = Vec::with_capacity(bytes as usize);
file.take(bytes).read_to_end(&mut content)?;
let truncated = start > 0;
if truncated {
if let Some(index) = content.iter().position(|byte| *byte == b'\n') {
content.drain(..=index);
} else {
content.clear();
}
}
let text = String::from_utf8_lossy(&content);
let mut iter = text.lines().rev();
let lines = iter
.by_ref()
.take(line_budget)
.map(str::to_owned)
.collect::<Vec<_>>();
Ok((lines, bytes, truncated || iter.next().is_some()))
}
fn copy_mapped_field(
object: &mut serde_json::Map<String, Value>,
format: &crate::logging::JsonFormat,
+106
View File
@@ -0,0 +1,106 @@
// Bounded file-log inventory helpers and gzip tail reader.
fn rotated_gzip_sources(
active: &std::path::Path,
id_prefix: &str,
label_prefix: &str,
kind: &'static str,
enabled: bool,
format: &'static str,
) -> Vec<FileLogSource> {
let Some(parent) = active.parent() else { return Vec::new(); };
let Some(base) = active.file_name().and_then(|name| name.to_str()) else { return Vec::new(); };
let safe = std::fs::symlink_metadata(parent).ok().is_some_and(|meta| meta.file_type().is_dir() && !meta.file_type().is_symlink());
if !safe { return Vec::new(); }
let prefix = format!("{base}.");
let mut entries = std::fs::read_dir(parent).ok().into_iter().flat_map(|iter| iter.filter_map(|entry| entry.ok())).filter_map(|entry| {
let name = entry.file_name().to_string_lossy().into_owned();
if name.len() > 180 || !name.starts_with(&prefix) || !name.ends_with(".gz") { return None; }
let file_type = entry.file_type().ok()?;
if !file_type.is_file() || file_type.is_symlink() { return None; }
let meta = entry.metadata().ok()?;
Some((name, entry.path(), file_modified_ms(&meta)))
}).collect::<Vec<_>>();
entries.sort_by(|a,b| b.2.cmp(&a.2).then_with(|| a.0.cmp(&b.0)));
entries.truncate(64);
entries.into_iter().map(|(name,path,_)| source_from_path(
format!("{id_prefix}:gz:{name}"),
format!("{label_prefix} · {name}"),
kind, path, enabled, format
)).collect()
}
fn open_file_log(path: &std::path::Path) -> std::io::Result<std::fs::File> {
let mut options = std::fs::OpenOptions::new();
options.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NOFOLLOW);
}
let file = options.open(path)?;
let metadata = file.metadata()?;
if !metadata.file_type().is_file() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"configured log path is not a regular file",
));
}
Ok(file)
}
fn read_tail(
source: &FileLogSource,
budget: u64,
line_budget: usize,
) -> std::io::Result<(Vec<String>, u64, bool)> {
use std::io::{Read, Seek, SeekFrom};
let mut file = open_file_log(&source.path)?;
let length = file.metadata()?.len();
if source.compressed {
// gzip streams are not seekable. Read a complete bounded rotation, then
// keep only the same 2 MiB decompressed tail used for plain files.
if length > budget || length > FILE_LOG_GZIP_INPUT_MAX {
return Ok((Vec::new(), length.min(budget), true));
}
let mut decoder = flate2::read::GzDecoder::new(file);
let mut decoded = Vec::new();
decoder
.by_ref()
.take(FILE_LOG_GZIP_OUTPUT_MAX + 1)
.read_to_end(&mut decoded)?;
if decoded.len() as u64 > FILE_LOG_GZIP_OUTPUT_MAX {
return Ok((Vec::new(), length, true));
}
let keep = decoded.len().min(FILE_LOG_SCAN_BYTES_PER_SOURCE as usize);
let start = decoded.len().saturating_sub(keep);
let mut content = decoded[start..].to_vec();
let truncated = start > 0;
if truncated {
if let Some(index) = content.iter().position(|byte| *byte == b'\n') {
content.drain(..=index);
} else {
content.clear();
}
}
let text = String::from_utf8_lossy(&content);
let mut iter = text.lines().rev();
let lines = iter.by_ref().take(line_budget).map(str::to_owned).collect::<Vec<_>>();
return Ok((lines, length, truncated || iter.next().is_some()));
}
let bytes = length.min(budget).min(FILE_LOG_SCAN_BYTES_PER_SOURCE);
let start = length.saturating_sub(bytes);
file.seek(SeekFrom::Start(start))?;
let mut content = Vec::with_capacity(bytes as usize);
file.take(bytes).read_to_end(&mut content)?;
let truncated = start > 0;
if truncated {
if let Some(index) = content.iter().position(|byte| *byte == b'\n') {
content.drain(..=index);
} else {
content.clear();
}
}
let text = String::from_utf8_lossy(&content);
let mut iter = text.lines().rev();
let lines = iter.by_ref().take(line_budget).map(str::to_owned).collect::<Vec<_>>();
Ok((lines, bytes, truncated || iter.next().is_some()))
}
+70 -3
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.28.2",
"version": "0.29.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": [
@@ -375,6 +375,43 @@
},
"source": {
"$ref": "#/components/schemas/Source"
},
"redirect": {
"type": "object",
"nullable": true,
"additionalProperties": false,
"properties": {
"status": {
"type": "integer",
"enum": [
301,
302,
303,
307,
308
],
"default": 302
},
"target": {
"type": "string",
"maxLength": 2048,
"description": "Absolute HTTP(S) redirect target. Capture references are allowed only in path/query."
},
"rewrite_pattern": {
"type": "string",
"maxLength": 1024,
"nullable": true,
"description": "Optional Rust regex applied to the matched request path before expanding captures in target."
},
"preserve_query": {
"type": "boolean",
"default": true
}
},
"required": [
"target"
],
"description": "Backend-less client redirect for this location."
}
},
"additionalProperties": false,
@@ -395,6 +432,15 @@
"type": "boolean",
"description": "Redirect cleartext requests for this vhost to the selected HTTPS listener using HTTP 308."
},
"www_redirect": {
"type": "boolean",
"default": false,
"description": "Claim the direct www/non-www companion of the selected exact DNS hostname and redirect it permanently to that hostname."
},
"www_redirect_target": {
"type": ["string", "null"],
"description": "An exact hostname from frontend.hosts to use as the canonical redirect destination. Null uses the first hostname for compatibility. Other hostnames continue serving the same vhost without redirects."
},
"forward_client_ip": {
"type": "boolean",
"default": true,
@@ -446,7 +492,27 @@
},
"cache": {
"type": "object",
"properties": {},
"properties": {
"unsafe_force": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean",
"default": false
},
"targets": {
"type": "array",
"maxItems": 32,
"items": {
"type": "string",
"maxLength": 2048
}
}
},
"description": "Dangerous per-vhost-only request-side forced-cache target prefixes. Response privacy exclusions remain authoritative."
}
},
"additionalProperties": true
},
"load_balancing": {
@@ -2889,7 +2955,8 @@
},
"host": {
"type": "string",
"nullable": true
"nullable": true,
"description": "Optional HTTP Host override. When null for an HTTP vhost probe, RustProxy uses the first exact vhost DNS hostname; if none exists it falls back to the backend host."
},
"interval_ms": {
"type": "integer",
+36
View File
@@ -150,3 +150,39 @@ fn stored_log_reader_refuses_symlink_targets() {
symlink(&real, &link).unwrap();
assert!(open_file_log(&link).is_err());
}
#[test]
fn stored_log_tail_reads_rotated_gzip_and_inventory_discovers_it() {
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let active = dir.path().join("access.jsonl");
std::fs::write(&active, b"{\"event\":\"request\",\"time_ms\":3}\n").unwrap();
let rotated = dir.path().join("access.jsonl.1.gz");
let file = std::fs::File::create(&rotated).unwrap();
let mut encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default());
writeln!(encoder, r#"{{"event":"request","time_ms":1}}"#).unwrap();
writeln!(encoder, r#"{{"event":"request","time_ms":2}}"#).unwrap();
encoder.finish().unwrap();
let source = source_from_path(
"access:gz:access.jsonl.1.gz".into(),
"Access log · access.jsonl.1.gz".into(),
"access",
rotated.clone(),
true,
"JSONL",
);
assert!(source.compressed);
let (lines, bytes, truncated) = read_tail(&source, FILE_LOG_SCAN_BYTES_TOTAL, 10).unwrap();
assert_eq!(bytes, std::fs::metadata(&rotated).unwrap().len());
assert!(!truncated);
assert_eq!(lines.len(), 2);
assert!(lines[0].contains(r#""time_ms":2"#));
let mut config = Config::default();
config.logging.file.per_vhost = false;
config.logging.file.path = active.to_string_lossy().into_owned();
let sources = configured_file_log_sources(&config);
assert!(sources.iter().any(|item| item.path == rotated && item.compressed));
}
+2 -2
View File
@@ -2891,7 +2891,7 @@
</section>
</div>
</section>
<footer class="page-footer"><span>RustProxy <b>0.28.2</b></span><span id="footer-status">API - /
<footer class="page-footer"><span>RustProxy <b>0.29.0</b></span><span id="footer-status">API - /
dropped: 0</span></footer>
</div>
</main>
@@ -3635,7 +3635,7 @@ max_execution_time = 30</code></pre>
<article class="card about-card">
<div class="about-product">
<span aria-hidden="true" class="brand-mark">r<span>/</span>p</span>
<div><span class="small-label">RustProxy</span><strong>0.28.2</strong></div>
<div><span class="small-label">RustProxy</span><strong>0.29.0</strong></div>
</div>
<dl class="about-meta">
<div>
+158
View File
@@ -5235,6 +5235,164 @@ dialog .window-heading {
color: var(--red)
}
/* Vhost names and canonical WWW routing share one visual decision surface. */
.vh-www-panel {
padding: 12px;
border: 1px solid var(--line);
background: var(--panel2);
min-width: 0
}
.vh-www-panel.is-enabled {
border-color: var(--green);
box-shadow: inset 3px 0 0 var(--green)
}
.vh-www-header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px 18px
}
.vh-www-title {
font-size: 12px;
color: var(--text)
}
.vh-www-host-count {
padding: 3px 7px;
border: 1px solid var(--line);
color: var(--muted);
font: 10px var(--mono)
}
.vh-www-header .vh-www-toggle {
margin: 0;
min-height: 0;
display: flex;
align-items: center;
color: var(--text);
font-size: 11px
}
.vh-www-inactive,
.vh-www-help {
margin: 9px 0 0;
font-size: 11px
}
.vh-www-details {
margin-top: 12px;
border-top: 1px solid var(--line);
padding-top: 12px
}
.vh-www-option-title {
font-size: 11px;
font-weight: 600;
margin: 0 0 8px;
color: var(--text)
}
.vh-www-choices {
display: grid;
gap: 10px
}
.vh-www-domain-pair {
padding: 9px;
border: 1px solid var(--line);
background: var(--panel)
}
.vh-www-domain-label {
display: block;
font: 11px var(--mono);
color: var(--text);
margin-bottom: 8px;
overflow-wrap: anywhere
}
.vh-www-pair-directions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px
}
@media (max-width: 650px) {
.vh-www-pair-directions { grid-template-columns: minmax(0, 1fr) }
}
.vh-www-choice {
min-width: 0;
display: flex;
align-items: flex-start;
gap: 10px;
padding: 11px;
border: 1px solid var(--line);
background: var(--field-bg);
cursor: pointer
}
.vh-www-choice:hover,
.vh-www-choice:focus-within {
border-color: var(--green)
}
.vh-www-choice.is-selected {
border-color: var(--green);
box-shadow: inset 3px 0 0 var(--green)
}
.vh-www-choice input {
margin: 3px 0 0;
accent-color: var(--green);
flex: 0 0 auto
}
.vh-www-choice-copy {
display: flex;
flex-direction: column;
gap: 5px;
min-width: 0
}
.vh-www-choice-copy strong {
font-size: 12px;
overflow-wrap: anywhere;
color: var(--text)
}
.vh-www-route {
color: var(--muted);
font: 11px var(--mono);
overflow-wrap: anywhere
}
.vh-www-no-hosts {
margin: 6px 0;
font-size: 11px
}
.vh-www-other {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin-top: 12px;
font-size: 11px;
color: var(--muted)
}
.vh-www-alias {
font: 11px var(--mono);
padding: 4px 6px;
border: 1px solid var(--line);
background: var(--panel)
}
.vhost-chart {
margin-top: 9px;
border: 1px solid var(--line);
+21 -9
View File
@@ -43,7 +43,7 @@
};
const allowDiscard = async () => { if (!dirty) return true; if (!(await appConfirm(t("vhUnsaved"), { danger: true, confirmLabel: t("discardAction") }))) return false; dirty = false; return true; };
const defaults = () => ({
id: "", enabled: true, force_ssl: false, forward_client_ip: true, websockets: false, websocket_legacy_support: false, websocket_rfc8441: false, websocket_rfc9220: false, http2_enabled: true, http3_enabled: true, cloudflare_client_ip: false, timing_headers: false, backend_header: false, backend_timing_header: false,
id: "", enabled: true, force_ssl: false, www_redirect: false, www_redirect_target: null, forward_client_ip: true, websockets: false, websocket_legacy_support: false, websocket_rfc8441: false, websocket_rfc9220: false, http2_enabled: true, http3_enabled: true, cloudflare_client_ip: false, timing_headers: false, backend_header: false, backend_timing_header: false,
frontend: { hosts: [], listener_ids: [] },
source: { type: "reverse_proxy" },
backends: [{ id: "origin-1", url: "", weight: 1, enabled: true, pool_eligible: true, allow_private: true, accept_local_listener_risk: false, preserve_host: true, protocol: "http1", tls_verify: false }],
@@ -52,7 +52,7 @@
function activateTab(name) { document.querySelectorAll("#vhost-tabs button").forEach(b => { const active = b.dataset.tab === name; b.classList.toggle("active", active); b.setAttribute("aria-pressed", String(active)); b.setAttribute("aria-selected", String(active)); b.tabIndex = active ? 0 : -1; }); container.querySelectorAll(".vh-group").forEach(g => g.hidden = g.dataset.tab !== name); }
function initTabs() { const tabs = $("vhost-tabs"); tabs.replaceChildren(); tabs.setAttribute("role", "tablist"); for (const [name, key] of [["details", "vhDetailsTab"], ["backends", "vhBackendPool"], ["health", "vhHealth"], ["locations", "vhLocations"], ["auth", "vhTabAuth"], ["ssl", "vhSsl"], ["cache", "vhCache"], ["compression", "vhCompression"], ["transform", "vhTransform"], ["security", "vhSecurity"], ["pages", "vhPages"], ["advanced", "advanced"]]) { const b = button(key, () => activateTab(name)); b.dataset.tab = name; b.setAttribute("role", "tab"); tabs.append(b); } activateTab("details"); }
function collectCache() { const tri = name => str(name) === "" ? null : str(name) === "on", number = (name, mul = 1) => { if (str(name) === "") return null; const value = Number(str(name)) * mul; if (!Number.isSafeInteger(value)) throw new Error(t("vhJsonInvalid")); return value; }; return { enabled: tri("vh-cache-enabled"), max_ttl_seconds: number("vh-cache-ttl"), default_ttl_seconds: number("vh-cache-default"), max_object_bytes: number("vh-cache-object", 1024), bypass_query: tri("vh-cache-query"), static_ignore_cookies: tri("vh-cache-static-cookie"), static_extensions: yes("vh-cache-static-custom") ? lines(str("vh-cache-static-extensions")).map(v => v.replace(/^\./, "").toLowerCase()) : null, expose_status: tri("vh-cache-status"), expose_hits: tri("vh-cache-hits"), expose_cache_status: tri("vh-cache-rfc"), expose_resident_age: tri("vh-cache-resident-age"), bypass_prefixes: yes("vh-cache-prefix-custom") ? lines(str("vh-cache-prefixes")) : null }; }
function collectCache() { const tri = name => str(name) === "" ? null : str(name) === "on", number = (name, mul = 1) => { if (str(name) === "") return null; const value = Number(str(name)) * mul; if (!Number.isSafeInteger(value)) throw new Error(t("vhJsonInvalid")); return value; }; return { enabled: tri("vh-cache-enabled"), max_ttl_seconds: number("vh-cache-ttl"), default_ttl_seconds: number("vh-cache-default"), max_object_bytes: number("vh-cache-object", 1024), bypass_query: tri("vh-cache-query"), static_ignore_cookies: tri("vh-cache-static-cookie"), static_extensions: yes("vh-cache-static-custom") ? lines(str("vh-cache-static-extensions")).map(v => v.replace(/^\./, "").toLowerCase()) : null, expose_status: tri("vh-cache-status"), expose_hits: tri("vh-cache-hits"), expose_cache_status: tri("vh-cache-rfc"), expose_resident_age: tri("vh-cache-resident-age"), bypass_prefixes: yes("vh-cache-prefix-custom") ? lines(str("vh-cache-prefixes")) : null, unsafe_force: { enabled: yes("vh-cache-force-enabled"), targets: lines(str("vh-cache-force-targets")) } }; }
function collectCompression() { const tri = name => str(name) === "" ? null : str(name) === "on"; return { enabled: tri("vh-compression-enabled"), zstd: tri("vh-compression-zstd"), br: tri("vh-compression-br"), gzip: tri("vh-compression-gzip"), deflate: tri("vh-compression-deflate") }; }
function collectTransform() {
const tri = name => str(name) === "" ? null : str(name) === "on";
@@ -312,7 +312,7 @@
function readRows() {
return [...rows.children].map(r => {
const action = str("location-action", r), rewriteMode = str("location-rewrite-mode", r);
const handled = action !== "return";
const handled = action === "proxy" || action === "web_server";
return {
id: str("location-id", r), path_prefix: str("location-path", r), match_type: str("location-match", r),
source: action === "web_server" ? { type: "web_server", web_service_id: str("location-web-service", r) } : { type: "reverse_proxy" },
@@ -324,7 +324,8 @@
method_policy: { mode: str("location-method-mode", r), methods: lines(str("location-method-list", r)).map(value => value.toUpperCase()) },
headers: r.readHeaders(),
rewrite: { mode: handled ? rewriteMode : "preserve", value: handled && rewriteMode !== "preserve" ? read("location-rewrite-value", r).value.trim() : "" },
response: action === "return" ? { status: Number(str("location-response-status", r)), content_type: str("location-response-type", r), body: read("location-response-body", r).value } : null
response: action === "return" ? { status: Number(str("location-response-status", r)), content_type: str("location-response-type", r), body: read("location-response-body", r).value } : null,
redirect: action === "redirect" ? { status: Number(str("location-redirect-status", r)), target: str("location-redirect-target", r), rewrite_pattern: nullable("location-redirect-pattern", r), preserve_query: yes("location-redirect-query", r) } : null
};
});
}
@@ -338,8 +339,8 @@
field(body, "location-id", "locationId", value.id || `location-${suffix}`, "text", { required: true, pattern: "[A-Za-z0-9_-]{1,64}", maxLength: 64 });
const match = field(body, "location-match", "locationMatchType", value.match_type || "prefix", "select", { values: [["prefix", "locationMatchPrefix"], ["prefix_priority", "locationMatchPriority"], ["exact", "locationMatchExact"], ["regex", "locationMatchRegex"], ["regex_insensitive", "locationMatchRegexI"]] });
const path = field(body, "location-path", "locationPathPattern", value.path_prefix || "/", "text", { required: true, maxLength: 1024 }); path.placeholder = "/api or ^/api/v[0-9]+/";
const initialAction = value.response ? "return" : value.source?.type === "web_server" ? "web_server" : "proxy";
const action = field(body, "location-action", "locationAction", initialAction, "select", { values: [["proxy", "locationProxyAction"], ["web_server", "locationWebServerAction"], ["return", "locationReturnAction"]] });
const initialAction = value.response ? "return" : value.redirect ? "redirect" : value.source?.type === "web_server" ? "web_server" : "proxy";
const action = field(body, "location-action", "locationAction", initialAction, "select", { values: [["proxy", "locationProxyAction"], ["web_server", "locationWebServerAction"], ["redirect", "locationRedirectAction"], ["return", "locationReturnAction"]] });
const backend = field(body, "location-backend", "backend", value.backend_id || "", "select"); memberOptions(backend, value.backend_id || "");
const webService = field(body, "location-web-service", "webService", value.source?.web_service_id || "", "select"); fillWebServices(webService, value.source?.web_service_id || "");
field(body, "location-enabled", "enabled", value.enabled !== false, "checkbox");
@@ -352,6 +353,12 @@
field(returnBox, "location-response-status", "locationResponseStatus", value.response?.status ?? 200, "number", { min: 200, max: 599, required: true });
field(returnBox, "location-response-type", "locationResponseType", value.response?.content_type || "text/plain; charset=utf-8", "text", { maxLength: 256, required: true });
field(returnBox, "location-response-body", "locationResponseBody", value.response?.body || "", "textarea", { rows: 5, maxLength: 65536 });
const redirectBox = el("div", undefined, "vh-wide vh-grid location-redirect-box"); handlingBody.append(redirectBox);
field(redirectBox, "location-redirect-status", "locationRedirectStatus", value.redirect?.status ?? 302, "select", { values: [["301", "locationRedirect301"], ["302", "locationRedirect302"], ["303", "locationRedirect303"], ["307", "locationRedirect307"], ["308", "locationRedirect308"]] });
field(redirectBox, "location-redirect-target", "locationRedirectTarget", value.redirect?.target || "", "url", { maxLength: 2048 });
field(redirectBox, "location-redirect-pattern", "locationRedirectPattern", value.redirect?.rewrite_pattern || "", "text", { maxLength: 1024 });
field(redirectBox, "location-redirect-query", "locationRedirectQuery", value.redirect?.preserve_query !== false, "checkbox");
note(redirectBox, "locationRedirectHelp");
note(handlingBody, "locationRewriteHelp");
const extra = el("details", undefined, "vh-wide location-section"), accessBody = el("div", undefined, "vh-grid"); extra.append(text("summary", "locationAccess"), accessBody); body.append(extra);
@@ -387,15 +394,15 @@
actions.append(dedicated, button("remove", () => { row.remove(); sync(); })); body.append(actions);
const syncUi = () => {
const isReturn = action.value === "return", isWeb = action.value === "web_server", isProxy = action.value === "proxy", isRegex = match.value === "regex" || match.value === "regex_insensitive";
const isReturn = action.value === "return", isRedirect = action.value === "redirect", isWeb = action.value === "web_server", isProxy = action.value === "proxy", isRegex = match.value === "regex" || match.value === "regex_insensitive";
backend.disabled = !isProxy; cache.disabled = !isProxy; dedicated.disabled = !isProxy;
webService.disabled = !isWeb; webService.required = isWeb;
returnBox.hidden = !isReturn; rewriteMode.disabled = isReturn; rewriteValue.disabled = isReturn || rewriteMode.value === "preserve" || rewriteMode.value === "strip_prefix";
returnBox.hidden = !isReturn; redirectBox.hidden = !isRedirect; rewriteMode.disabled = isReturn || isRedirect; rewriteValue.disabled = isReturn || isRedirect || rewriteMode.value === "preserve" || rewriteMode.value === "strip_prefix";
for (const option of rewriteMode.options) if (option.value === "regex") option.disabled = !isRegex;
if (!isRegex && rewriteMode.value === "regex") rewriteMode.value = "preserve";
path.placeholder = isRegex ? "^/api/v[0-9]+/" : "/api/";
const id = str("location-id", row) || t("locationId"), pattern = str("location-path", row) || "/";
summary.textContent = `${id} · ${t(`locationMatch_${match.value}`)} · ${pattern} · ${t(isReturn ? "locationReturnShort" : isWeb ? "locationWebServerShort" : "locationProxyShort")}`;
summary.textContent = `${id} · ${t(`locationMatch_${match.value}`)} · ${pattern} · ${t(isReturn ? "locationReturnShort" : isRedirect ? "locationRedirectShort" : isWeb ? "locationWebServerShort" : "locationProxyShort")}`;
previewStatus.hidden = yes("location-enabled", row);
previewValues.match.textContent = `${t(`locationMatch_${match.value}`)} · ${pattern}`;
@@ -404,6 +411,11 @@
previewValues.handling.textContent = `${t("locationReturnAction")}: HTTP ${status} · ${type} · ${t("locationPreviewBackendSkipped")}`;
previewValues.rewrite.textContent = "-";
previewValues.cache.textContent = t("notEnabled");
} else if (isRedirect) {
const status = str("location-redirect-status", row) || "302", target = str("location-redirect-target", row) || "-", capture = str("location-redirect-pattern", row);
previewValues.handling.textContent = `${t("locationRedirectAction")}: HTTP ${status} · ${target} · ${t("locationPreviewBackendSkipped")}`;
previewValues.rewrite.textContent = capture || "-";
previewValues.cache.textContent = t("notEnabled");
} else {
if (isWeb) {
const selected = webService.selectedOptions?.[0]?.textContent || t("webServiceSelect");
+91 -4
View File
@@ -13,7 +13,7 @@
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 = []) {
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"));
@@ -23,7 +23,7 @@
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"); 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 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); } } });
@@ -38,7 +38,7 @@
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");
readHosts = hostTagEditor(g, value.frontend?.hosts || []);
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"]] });
@@ -52,10 +52,92 @@
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");
@@ -267,6 +349,10 @@
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);
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";
@@ -303,6 +389,7 @@
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")) {
@@ -332,7 +419,7 @@
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" }, force_ssl: yes("force-ssl"), forward_client_ip: yes("forward-client-ip"), websockets: yes("websockets"), websocket_legacy_support: yes("websockets") && yes("websocket-legacy-support"), websocket_rfc8441: yes("websockets") && ["h2", "h2h3"].includes(str("websocket-mode")), websocket_rfc9220: yes("websockets") && ["h3", "h2h3"].includes(str("websocket-mode")), http2_enabled: yes("http2-enabled"), http3_enabled: yes("http3-enabled"), cloudflare_client_ip: yes("cloudflare-client-ip"), timing_headers: yes("timing-headers"), backend_header: yes("backend-header"), backend_timing_header: yes("backend-timing-header"), basic_auth: basicEditor.read(), certificate_id: nullable("certificate-id"), tls_policy: yes("tls-policy-custom") ? { tls12: yes("tls-policy-12"), tls13: yes("tls-policy-13"), cipher_suites: lines(str("tls-policy-ciphers")) } : null, cache: collectCache(), compression: collectCompression(), transform: collectTransform(), pages: pagesEditor.read(), frontend: { hosts: readHosts(), listener_ids: bindingSelection }, acl_profile_id: nullable("acl-profile-id"),
id: str("id"), enabled: yes("enabled"), source: mainSourceType === "web_server" ? { type: "web_server", web_service_id: str("source-web-service") } : { type: "reverse_proxy" }, force_ssl: yes("force-ssl"), 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() },
method_policy: { mode: str("method-mode"), methods: lines(str("method-list")).map(value => value.toUpperCase()) },
+4
View File
@@ -204,6 +204,10 @@
if (v.rate_limit?.enabled) policy.append(el("small", t("vhRateEnabled"), "pill"));
if (v.acl_profile_id) policy.append(el("small", `${t("vhAclProfile")}: ${v.acl_profile_id}`, "pill"));
if (v.force_ssl) policy.append(el("small", t("vhForceSsl"), "pill"));
if (v.www_redirect) {
const destination = v.www_redirect_target || (v.frontend?.hosts || []).find(host => !host.startsWith("*.")) || "";
policy.append(el("small", `WWW 308 \u2192 ${destination}`, "pill"));
}
if (v.forward_client_ip === false) policy.append(el("small", t("vhClientIpHidden"), "pill"));
if (v.websockets) {
const transports = ["H1"];