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"];
+42
View File
@@ -277,6 +277,45 @@ fn valid_relative_cache_path(value: &str) -> bool {
.all(|part| matches!(part, Component::Normal(_)))
}
/// Explicit, per-vhost-only request-side cache override. It never weakens the
/// response-side privacy guards (Set-Cookie/private/no-store/Vary rules).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct UnsafeForce {
pub enabled: bool,
pub targets: Vec<String>,
}
impl UnsafeForce {
pub fn validate(&self) -> Result<()> {
ensure!(self.targets.len() <= 32, "forced cache supports at most 32 target prefixes");
for target in &self.targets {
ensure!(
!target.is_empty()
&& target.len() <= 2048
&& target.starts_with('/')
&& !target.contains('#')
&& !target.chars().any(char::is_control),
"forced cache targets must be 1..2048 byte origin-form path/query prefixes"
);
let path = target.split('?').next().unwrap_or(target);
ensure!(crate::acl::routing_path(path)? == path, "forced cache target path must be normalized");
}
Ok(())
}
pub fn matches(&self, path: &str, target: &str) -> bool {
if !self.enabled {
return false;
}
self.targets.iter().any(|configured| {
if configured.contains('?') {
target.starts_with(configured)
} else {
crate::config::prefix_matches(configured, path)
}
})
}
}
/// Per-vhost behavior, not independent memory pools. None inherits the global
/// default; the global resident-byte/entry/object ceilings remain hard limits.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
@@ -294,9 +333,12 @@ pub struct Overrides {
pub expose_hits: Option<bool>,
pub expose_cache_status: Option<bool>,
pub expose_resident_age: Option<bool>,
/// Deliberately unavailable in the global cache configuration.
pub unsafe_force: UnsafeForce,
}
impl Overrides {
pub fn resolve(&self, global: &Settings) -> Result<Settings> {
self.unsafe_force.validate()?;
let mut result = global.clone();
// A vhost may narrow the global policy, but can never re-enable a cache
// that the administrator disabled globally.
+25
View File
@@ -92,6 +92,31 @@ fn pragma_no_cache(headers: &HeaderMap) -> bool {
.any(|token| token.trim().eq_ignore_ascii_case("no-cache"))
})
}
pub fn forced_request_policy(
method: &Method,
headers: &HeaderMap,
empty_body: bool,
) -> RequestPolicy {
if method != Method::GET && method != Method::HEAD {
return RequestPolicy::Bypass("request_method");
}
if !empty_body {
return RequestPolicy::Bypass("request_body");
}
if matches!(request_range(method, headers), RangeSelection::Unsupported) {
return RequestPolicy::Bypass("request_range_unsupported");
}
if headers.contains_key("if-range") && !headers.contains_key("range") {
return RequestPolicy::Bypass("request_precondition");
}
for key in ["if-match", "if-unmodified-since", "upgrade"] {
if headers.contains_key(key) {
return RequestPolicy::Bypass(if key == "upgrade" { "request_upgrade" } else { "request_precondition" });
}
}
RequestPolicy::Use
}
pub fn request_policy(
method: &Method,
headers: &HeaderMap,
+8
View File
@@ -3,6 +3,14 @@ impl Plan {
self.ttl.saturating_sub(self.initial_age.as_secs())
}
pub fn with_vary(mut self, name: &str) -> Self {
if !self.vary.iter().any(|value| value.eq_ignore_ascii_case(name)) {
self.vary.push(name.to_ascii_lowercase());
self.vary.sort();
}
self
}
pub fn without_vary(mut self, name: &str) -> Self {
self.vary.retain(|value| !value.eq_ignore_ascii_case(name));
self
+37
View File
@@ -97,3 +97,40 @@ mod scope_tests {
.is_err());
}
}
#[test]
fn unsafe_force_cache_is_per_vhost_and_matches_path_or_target_prefix() {
let force = UnsafeForce {
enabled: true,
targets: vec!["/convert?url=".into(), "/assets".into()],
};
force.validate().unwrap();
assert!(force.matches(
"/convert",
"/convert?url=https%3A%2F%2Fexample.test%2Flist.txt"
));
assert!(force.matches("/assets/app.js", "/assets/app.js?v=1"));
assert!(!force.matches("/api", "/api?url=x"));
assert!(UnsafeForce {
enabled: true,
targets: vec!["https://example.test/".into()],
}
.validate()
.is_err());
}
#[test]
fn forced_request_policy_keeps_protocol_safety_but_ignores_cookie_and_client_no_store() {
let mut headers = HeaderMap::new();
headers.insert("cookie", HeaderValue::from_static("session=abc"));
headers.insert("authorization", HeaderValue::from_static("Bearer token"));
headers.insert("cache-control", HeaderValue::from_static("no-store"));
assert_eq!(
forced_request_policy(&Method::GET, &headers, true),
RequestPolicy::Use
);
assert_eq!(
forced_request_policy(&Method::POST, &headers, true),
RequestPolicy::Bypass("request_method")
);
}
+44
View File
@@ -367,6 +367,50 @@ fn validate_route_rewrite(
Ok(())
}
fn validate_location_redirect(redirect: Option<&crate::vhost::LocationRedirect>) -> Result<Option<crate::config::CompiledLocationRedirect>> {
let Some(redirect) = redirect else {
return Ok(None);
};
ensure!(
matches!(redirect.status, 301 | 302 | 303 | 307 | 308),
"location redirect status must be 301, 302, 303, 307 or 308"
);
ensure!(
!redirect.target.is_empty()
&& redirect.target.len() <= 2048
&& !redirect.target.chars().any(char::is_control),
"invalid location redirect target"
);
// Replace capture references with a harmless segment so URL structure can
// be validated before requests reach the data plane.
let authority_end = redirect.target.find("://").and_then(|start| {
let rest = &redirect.target[start + 3..];
rest.char_indices().find(|(_, ch)| matches!(ch, '/' | '?' | '#')).map(|(offset, _)| start + 3 + offset).or(Some(redirect.target.len()))
}).ok_or_else(|| anyhow::anyhow!("location redirect target must be an absolute URL"))?;
ensure!(!redirect.target[..authority_end].contains('$'), "location redirect captures are not allowed in the URL authority");
let sample = regex::Regex::new(r"\$\{?[A-Za-z0-9_]+\}?")
.expect("static capture regex")
.replace_all(&redirect.target, "capture");
let parsed = url::Url::parse(&sample).context("location redirect target must be an absolute URL")?;
ensure!(matches!(parsed.scheme(), "http" | "https") && parsed.host_str().is_some(), "location redirect target must use http/https and include a host");
ensure!(parsed.username().is_empty() && parsed.password().is_none(), "location redirect target cannot contain credentials");
let rewrite_regex = match redirect.rewrite_pattern.as_deref() {
None | Some("") => None,
Some(pattern) => {
ensure!(pattern.len() <= 1024 && !pattern.chars().any(char::is_control), "invalid location redirect rewrite regex");
let mut builder = regex::RegexBuilder::new(pattern);
builder.size_limit(2 * 1024 * 1024).dfa_size_limit(2 * 1024 * 1024);
Some(builder.build().context("invalid location redirect rewrite regex")?)
}
};
Ok(Some(crate::config::CompiledLocationRedirect {
status: redirect.status,
target: redirect.target.clone(),
rewrite_regex,
preserve_query: redirect.preserve_query,
}))
}
fn validate_location_response(response: Option<&crate::vhost::LocationResponse>) -> Result<()> {
let Some(response) = response else {
return Ok(());
+12
View File
@@ -182,6 +182,14 @@ pub enum CompiledSource {
WebServer(Arc<crate::web_server::CompiledService>),
}
#[derive(Debug, Clone)]
pub struct CompiledLocationRedirect {
pub status: u16,
pub target: String,
pub rewrite_regex: Option<regex::Regex>,
pub preserve_query: bool,
}
#[derive(Debug, Clone)]
pub struct CompiledRoute {
pub backend_id: Option<String>,
@@ -195,6 +203,7 @@ pub struct CompiledRoute {
pub path_regex: Option<regex::Regex>,
pub rewrite: crate::vhost::PathRewrite,
pub response: Option<crate::vhost::LocationResponse>,
pub redirect: Option<CompiledLocationRedirect>,
pub source: CompiledSource,
pub backend: Option<Arc<CompiledBackend>>,
pub acl_profile_id: Option<String>,
@@ -213,6 +222,8 @@ pub struct CompiledFrontend {
pub websocket_rfc8441: bool,
pub websocket_rfc9220: bool,
pub force_ssl: bool,
/// Optional quick www/non-www canonical redirect (from, to).
pub www_redirect: Option<(String, String)>,
pub forward_client_ip: bool,
/// HTTPS target port used by Force SSL redirects. Port 443 wins when multiple TLS ports are selected.
pub https_redirect_port: Option<u16>,
@@ -242,6 +253,7 @@ pub struct CompiledFrontend {
pub security: crate::security::CompiledSecurity,
pub compression: crate::compression::Overrides,
pub cache: crate::cache::Settings,
pub force_cache: crate::cache::UnsafeForce,
pub transform: crate::transform::Settings,
pub pages: crate::pages::VhostPages,
pub proxy_timeouts: crate::vhost::ProxyTimeouts,
+27 -7
View File
@@ -16,6 +16,8 @@ fn compile_routing(
let mut pools = Vec::new();
let mut vhost_metrics = std::collections::BTreeMap::new();
for host in &mut config.vhosts {
let routing_hosts = host.routing_hosts()?;
let www_redirect = host.www_redirect_pair()?;
let host_bindings = host.frontend.resolved(&config.listeners)?;
let tls_ports: HashSet<u16> = host_bindings
.iter()
@@ -76,7 +78,7 @@ fn compile_routing(
"prefixed affinity cookies require TLS on every resolved frontend"
);
// Disabled vhosts also reserve their name, preventing fallback exposure.
for name in &host.frontend.hosts {
for name in &routing_hosts {
let claimed = claimed_hosts.entry(name.clone()).or_default();
for a in claimed.iter() {
for b in &host_bindings {
@@ -107,9 +109,22 @@ fn compile_routing(
let pool = if compiled_members.is_empty() {
None
} else {
let mut pool_settings = host.load_balancing.clone();
// Blank health-check Host means the first exact DNS name of the vhost.
// This is important when the backend URL is an IP shared by several vhosts.
if pool_settings.health_check.host.is_none()
&& pool_settings.health_check.mode == crate::balance::HealthMode::Http
{
pool_settings.health_check.host = host
.frontend
.hosts
.iter()
.find(|name| !name.starts_with("*.") && name.parse::<std::net::IpAddr>().is_err())
.cloned();
}
let mut pool = crate::balance::Pool::new(
host.id.clone(),
host.load_balancing.clone(),
pool_settings,
&host.backends,
compiled_members,
)?;
@@ -159,6 +174,7 @@ fn compile_routing(
websocket_rfc8441: host.websocket_rfc8441,
websocket_rfc9220: host.websocket_rfc9220,
force_ssl: host.force_ssl,
www_redirect,
forward_client_ip: host.forward_client_ip,
https_redirect_port,
http2_enabled: host.http2_enabled,
@@ -192,6 +208,7 @@ fn compile_routing(
security: host.security_headers.compile()?,
compression: host.compression.clone(),
cache: host.cache.resolve(&config.cache)?,
force_cache: host.cache.unsafe_force.clone(),
transform: host.transform.resolve(&config.transform)?,
pages: host.pages.clone(),
proxy_timeouts: host.proxy_timeouts.clone(),
@@ -229,6 +246,7 @@ fn compile_routing(
path_regex: None,
rewrite: crate::vhost::PathRewrite::default(),
response: None,
redirect: None,
source: fallback_source,
backend: fallback_backend,
acl_profile_id: None,
@@ -240,7 +258,7 @@ fn compile_routing(
headers: crate::headers::CompiledHeaders::default(),
unavailable: host.transient_unavailable,
};
for name in &host.frontend.hosts {
for name in &routing_hosts {
fallbacks
.entry(name.clone())
.or_default()
@@ -293,6 +311,7 @@ fn compile_routing(
);
validate_route_rewrite(route, path_regex.as_ref())?;
validate_location_response(route.response.as_ref())?;
let compiled_redirect = validate_location_redirect(route.redirect.as_ref())?;
let (route_profile_acl, route_profile_country_access) =
match route.acl_profile_id.as_deref() {
Some(id) => acl_profiles.get(id).cloned().ok_or_else(|| {
@@ -317,8 +336,8 @@ fn compile_routing(
}
}
if host.enabled && route.enabled {
let (source, route_pool, backend) = if route.response.is_some() {
// Local return is handled before source dispatch and needs no origin.
let (source, route_pool, backend) = if route.response.is_some() || route.redirect.is_some() {
// Local return/redirect is handled before source dispatch and needs no origin.
(CompiledSource::ReverseProxy, None, None)
} else {
match &route.source {
@@ -364,6 +383,7 @@ fn compile_routing(
path_regex,
rewrite: route.rewrite.clone(),
response: route.response.clone(),
redirect: compiled_redirect,
source,
backend,
acl_profile_id: route.acl_profile_id.clone(),
@@ -375,7 +395,7 @@ fn compile_routing(
headers,
unavailable: false,
};
for name in &host.frontend.hosts {
for name in &routing_hosts {
routes
.entry(name.clone())
.or_default()
@@ -384,7 +404,7 @@ fn compile_routing(
}
}
if host.enabled {
for name in &host.frontend.hosts {
for name in &routing_hosts {
frontends
.entry(name.clone())
.or_default()
+23
View File
@@ -261,6 +261,29 @@ where
}
impl CompiledRoute {
pub fn redirect_location(&self, path: &str, uri: &http::Uri) -> Result<Option<http::HeaderValue>> {
let Some(spec) = &self.redirect else {
return Ok(None);
};
let target = if let Some(regex) = &spec.rewrite_regex {
ensure!(regex.is_match(path), "location redirect rewrite regex did not match");
regex.replace(path, spec.target.as_str()).into_owned()
} else {
spec.target.clone()
};
let mut target = url::Url::parse(&target).context("invalid compiled location redirect target")?;
if spec.preserve_query {
if let Some(query) = uri.query() {
let merged = match target.query() {
Some(existing) if !existing.is_empty() => format!("{existing}&{query}"),
_ => query.to_owned(),
};
target.set_query(Some(&merged));
}
}
Ok(Some(http::HeaderValue::from_str(target.as_str()).context("invalid redirect Location header")?))
}
/// Return a normalized replacement path for the upstream hop. `None` means
/// preserve the client's exact origin-form path bytes.
pub fn rewritten_path(&self, path: &str) -> Result<Option<String>> {
+115
View File
@@ -205,3 +205,118 @@ fn legacy_startup_remote_administration_is_left_to_cli_risk_gate() {
assert!(c.administration.listen.is_none());
assert!(compile(c, 1, &p).is_ok());
}
#[test]
fn www_redirect_claims_companion_hostname_and_keeps_first_host_canonical() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
c.vhosts[0].frontend.hosts = vec!["example.test".into()];
c.vhosts[0].www_redirect = true;
let snapshot = compile(c, 1, &policy()).unwrap();
assert!(snapshot.select("www.example.test", "/path").is_some());
let frontend = snapshot
.frontend("www.example.test", "127.0.0.1:8080".parse().unwrap(), false)
.unwrap();
assert_eq!(
frontend.www_redirect.as_ref(),
Some(&("www.example.test".into(), "example.test".into()))
);
}
#[test]
fn multiple_domains_share_vhost_and_selected_canonical_redirect() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
c.vhosts[0].frontend.hosts = vec![
"suba.local".into(), "subb.local".into(), "site.test".into(),
];
c.vhosts[0].www_redirect = true;
c.vhosts[0].www_redirect_target = Some("site.test".into());
let snapshot = compile(c, 1, &policy()).unwrap();
for hostname in ["suba.local", "subb.local", "site.test", "www.site.test"] {
assert!(snapshot.select(hostname, "/same").is_some(), "{hostname}");
}
for hostname in ["suba.local", "subb.local", "www.site.test"] {
let frontend = snapshot.frontend(hostname, "127.0.0.1:8080".parse().unwrap(), false).unwrap();
assert_eq!(frontend.www_redirect.as_ref(),
Some(&("www.site.test".into(), "site.test".into())));
}
}
#[test]
fn implicit_www_target_is_claimed_and_other_domain_stays_independent() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
c.vhosts[0].frontend.hosts = vec!["suba.local".into(), "subb.local".into()];
c.vhosts[0].www_redirect = true;
c.vhosts[0].www_redirect_target = Some("www.subb.local".into());
let snapshot = compile(c, 1, &policy()).unwrap();
for host in ["suba.local", "subb.local", "www.subb.local"] {
assert!(snapshot.select(host, "/path").is_some(), "{host}");
}
assert!(snapshot.select("www.suba.local", "/path").is_none());
let frontend = snapshot.frontend("subb.local", "127.0.0.1:8080".parse().unwrap(), false).unwrap();
assert_eq!(frontend.www_redirect.as_ref(),
Some(&(String::from("subb.local"), String::from("www.subb.local"))));
}
#[test]
fn selected_www_redirect_target_must_belong_to_vhost() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
c.vhosts[0].frontend.hosts = vec!["suba.local".into()];
c.vhosts[0].www_redirect = true;
c.vhosts[0].www_redirect_target = Some("elsewhere.local".into());
assert!(compile(c, 1, &policy()).is_err());
}
#[test]
fn http_health_check_defaults_host_to_first_exact_vhost_dns_name() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
c.vhosts[0].frontend.hosts = vec!["app.example.test".into()];
c.vhosts[0].load_balancing.health_check.mode = crate::balance::HealthMode::Http;
c.vhosts[0].load_balancing.health_check.host = None;
let snapshot = compile(c, 1, &policy()).unwrap();
assert_eq!(
snapshot.pools[0].settings.health_check.host.as_deref(),
Some("app.example.test")
);
}
#[test]
fn explicit_http_health_check_host_override_wins_over_vhost_hostname() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
c.vhosts[0].frontend.hosts = vec!["app.example.test".into()];
c.vhosts[0].load_balancing.health_check.mode = crate::balance::HealthMode::Http;
c.vhosts[0].load_balancing.health_check.host = Some("health.example.test".into());
let snapshot = compile(c, 1, &policy()).unwrap();
assert_eq!(
snapshot.pools[0].settings.health_check.host.as_deref(),
Some("health.example.test")
);
}
#[test]
fn custom_location_redirect_supports_separate_capture_regex_and_query() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
c.vhosts[0].routes = vec![crate::vhost::PathRoute {
id: "archives".into(),
path_prefix: r"\.(?:tar\.gz|zip?)$".into(),
match_type: crate::vhost::PathMatch::RegexInsensitive,
response: None,
redirect: Some(crate::vhost::LocationRedirect {
status: 301,
target: "https://storage.example.test/o/$1".into(),
rewrite_pattern: Some(r"^/(.*)$".into()),
preserve_query: true,
}),
..Default::default()
}];
let snapshot = compile(c, 1, &policy()).unwrap();
let route = snapshot.select("localhost", "/repo/file.tar.gz").unwrap();
let uri: http::Uri = "/repo/file.tar.gz?download=1".parse().unwrap();
let location = route
.redirect_location("/repo/file.tar.gz", &uri)
.unwrap()
.unwrap();
assert_eq!(
location,
"https://storage.example.test/o/repo/file.tar.gz?download=1"
);
}
+1 -1
View File
@@ -41,7 +41,7 @@ use zeroize::Zeroizing;
#[command(
name = "rust-proxy",
version,
about = "RustProxy - independent Rust reverse proxy and static Web Server 0.28.2"
about = "RustProxy - independent Rust reverse proxy and static Web Server 0.29.0"
)]
struct Cli {
#[arg(
+1 -1
View File
@@ -239,7 +239,7 @@ async fn live_logs_command(dir: PathBuf, addr: SocketAddr) -> Result<()> {
}
});
let interactive = io::stdin().is_terminal() && io::stdout().is_terminal();
println!("RustProxy live logs 0.28.2 - all vhosts. Type 'help' for interactive filters; Ctrl-C or 'quit' exits.");
println!("RustProxy live logs 0.29.0 - all vhosts. Type 'help' for interactive filters; Ctrl-C or 'quit' exits.");
if interactive { print!("> "); io::stdout().flush()?; }
let mut filters = LiveLogFilters::default();
let mut history = std::collections::BTreeMap::<u64, serde_json::Value>::new();
+16
View File
@@ -41,6 +41,22 @@ mod tests {
assert!(response.extensions().get::<PageServed>().is_some());
}
#[tokio::test]
async fn static_vhost_page_can_serve_html_with_503_status() {
let page = Page {
kind: Kind::Html,
html: "<h1>Maintenance</h1>".into(),
status: Some(503),
};
page.validate().unwrap();
let response = page.serve(&Method::GET, StatusCode::OK);
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(
response.into_body().collect().await.unwrap().to_bytes(),
"<h1>Maintenance</h1>"
);
}
#[test]
fn builtin_gateway_pages_cover_edge_policy_and_gateway_errors() {
assert!(builtin_gateway_error(StatusCode::UNAUTHORIZED)
+55 -25
View File
@@ -6,6 +6,8 @@ if let Some(exchange) = request.extensions().get::<Arc<Exchange>>() {
*exchange.route.lock().unwrap_or_else(|e| e.into_inner()) = Some(route.id.clone());
*exchange.source.lock().unwrap_or_else(|e| e.into_inner()) = if route.response.is_some() {
"local_response"
} else if route.redirect.is_some() {
"local_redirect"
} else {
match &route.source {
crate::config::CompiledSource::WebServer(_) => "web_server",
@@ -18,6 +20,17 @@ if let Some(exchange) = request.extensions().get::<Arc<Exchange>>() {
if let Some(spec) = &route.response {
return local_location_response(spec, request.method());
}
if let Some(spec) = &route.redirect {
let location = match route.redirect_location(&path, request.uri()) {
Ok(Some(location)) => location,
_ => return error(StatusCode::INTERNAL_SERVER_ERROR, "RustProxy: invalid location redirect\n"),
};
let status = StatusCode::from_u16(spec.status).unwrap_or(StatusCode::FOUND);
let mut response = status.into_response();
response.headers_mut().insert(http::header::LOCATION, location);
response.headers_mut().insert("cache-control", HeaderValue::from_static("no-store"));
return response;
}
if route.unavailable {
let mut response = error(
StatusCode::SERVICE_UNAVAILABLE,
@@ -178,28 +191,38 @@ let body_empty = if cache_enabled
} else {
request.body().is_end_stream()
};
let ignore_static_cookie = cache_settings.ignore_cookie_for_path(&path);
let original_policy = policy_reason
.map(crate::cache::RequestPolicy::Bypass)
.unwrap_or_else(|| {
crate::cache::request_policy(
request.method(),
request.headers(),
body_empty,
&path,
request.uri().query().is_some(),
cache_settings,
)
});
let only_cached =
cache_settings.active_for(route.cache) && crate::cache::only_if_cached(request.headers());
let public_suffix = request
.uri()
.path_and_query()
.map(|v| v.as_str())
.unwrap_or("/")
.to_owned();
let force_cache = policy_reason.is_none()
&& cache_settings.active_for(route.cache)
&& route
.frontend
.as_ref()
.is_some_and(|frontend| frontend.force_cache.matches(&path, &public_suffix));
let ignore_static_cookie = !force_cache && cache_settings.ignore_cookie_for_path(&path);
let original_policy = policy_reason
.map(crate::cache::RequestPolicy::Bypass)
.unwrap_or_else(|| {
if force_cache {
crate::cache::forced_request_policy(request.method(), request.headers(), body_empty)
} else {
crate::cache::request_policy(
request.method(),
request.headers(),
body_empty,
&path,
request.uri().query().is_some(),
cache_settings,
)
}
});
let only_cached =
cache_settings.active_for(route.cache) && crate::cache::only_if_cached(request.headers());
let origin_suffix = match route.rewritten_path(&path) {
Ok(None) => public_suffix.clone(),
Ok(Some(rewritten)) => {
@@ -283,14 +306,20 @@ if websocket_legacy_support {
let range_selection = crate::cache::request_range(request.method(), request.headers());
let if_range = request.headers().get("if-range").cloned();
let range_requested = !matches!(range_selection, crate::cache::RangeSelection::None);
let post_policy = crate::cache::request_policy(
request.method(),
request.headers(),
body_empty,
&path,
request.uri().query().is_some(),
cache_settings,
);
let post_policy = if force_cache {
crate::cache::forced_request_policy(request.method(), request.headers(), body_empty)
} else {
crate::cache::request_policy(
request.method(),
request.headers(),
body_empty,
&path,
request.uri().query().is_some(),
cache_settings,
)
};
let force_vary_cookie = force_cache && request.headers().contains_key("cookie");
let force_vary_authorization = force_cache && request.headers().contains_key("authorization");
let effective_policy = match (original_policy, post_policy) {
(crate::cache::RequestPolicy::Bypass(reason), _) => {
crate::cache::RequestPolicy::Bypass(reason)
@@ -353,7 +382,8 @@ let pending_revalidation = revalidation_reason.is_some()
let miss_recorded = false;
(
backend, backend_lease, affinity_cookie, admin_control_plane, cache_settings,
transform_settings, scope, ignore_static_cookie, only_cached, public_suffix,
transform_settings, scope, ignore_static_cookie, force_cache, force_vary_cookie,
force_vary_authorization, only_cached, public_suffix,
origin_suffix, uri, exchange, lease, stop, downstream, h2_downstream,
range_selection, if_range, range_requested, effective_reason, revalidation_reason,
cache_ticket, client_conditionals, cached_revalidation, freshness_revalidation_reason,
+1 -1
View File
@@ -117,7 +117,7 @@ let reason = effective_reason
if cache_ticket.is_some() && transformed_plan.is_none() {
"response_headers_policy"
} else if cache_ticket.is_some() {
"cache_miss"
if force_cache { "cache_miss_forced" } else { "cache_miss" }
} else {
"configuration_changed"
}
+4 -1
View File
@@ -118,7 +118,10 @@ let origin_plan = if cache_ticket.is_some()
response.headers(),
cache_settings,
upstream_started.elapsed(),
)
).map(|plan| {
let plan = if force_vary_cookie { plan.with_vary("cookie") } else { plan };
if force_vary_authorization { plan.with_vary("authorization") } else { plan }
})
} else {
None
};
+8
View File
@@ -146,6 +146,11 @@ async fn forward(
{
return response;
}
if let Some(response) = frontend.and_then(|frontend| {
canonical_host_redirect(frontend, &host, tls, local.0.port(), request.uri())
}) {
return response;
}
if !tls {
if let Some(response) =
frontend.and_then(|frontend| force_ssl_redirect(frontend, &host, request.uri()))
@@ -378,6 +383,9 @@ async fn forward(
transform_settings,
scope,
ignore_static_cookie,
force_cache,
force_vary_cookie,
force_vary_authorization,
only_cached,
public_suffix,
origin_suffix,
+1
View File
@@ -1,6 +1,7 @@
include!("types.rs");
include!("cache_helpers.rs");
include!("upstream_helpers.rs");
include!("redirect_helpers.rs");
include!("forward_entry.rs");
#[cfg(test)]
+66
View File
@@ -0,0 +1,66 @@
// Frontend redirect helpers.
/// Build a same-host HTTPS redirect while preserving the request path and query.
fn force_ssl_location(host: &str, uri: &Uri, port: u16) -> Option<HeaderValue> {
let path = uri
.path_and_query()
.map(|value| value.as_str())
.unwrap_or("/");
let location = if port == 443 {
format!("https://{host}{path}")
} else {
format!("https://{host}:{port}{path}")
};
HeaderValue::from_str(&location).ok()
}
fn canonical_host_redirect(
frontend: &crate::config::CompiledFrontend,
host: &str,
tls: bool,
local_port: u16,
uri: &Uri,
) -> Option<Response> {
let (from, to) = frontend.www_redirect.as_ref()?;
if !host.eq_ignore_ascii_case(from) {
return None;
}
let (scheme, port) = if tls {
("https", local_port)
} else if frontend.force_ssl {
("https", frontend.https_redirect_port?)
} else {
("http", local_port)
};
let path = uri.path_and_query().map(|value| value.as_str()).unwrap_or("/");
let default_port = (scheme == "https" && port == 443) || (scheme == "http" && port == 80);
let location = if default_port {
format!("{scheme}://{to}{path}")
} else {
format!("{scheme}://{to}:{port}{path}")
};
let mut response = StatusCode::PERMANENT_REDIRECT.into_response();
response.headers_mut().insert(http::header::LOCATION, HeaderValue::from_str(&location).ok()?);
response.headers_mut().insert("cache-control", HeaderValue::from_static("no-store"));
Some(response)
}
fn force_ssl_redirect(
frontend: &crate::config::CompiledFrontend,
host: &str,
uri: &Uri,
) -> Option<Response> {
if !frontend.force_ssl {
return None;
}
let location = force_ssl_location(host, uri, frontend.https_redirect_port?)?;
let mut response = StatusCode::PERMANENT_REDIRECT.into_response();
response
.headers_mut()
.insert(http::header::LOCATION, location);
// Do not pin a redirect in a browser after an operator later disables Force SSL.
response
.headers_mut()
.insert("cache-control", HeaderValue::from_static("no-store"));
Some(response)
}
+10
View File
@@ -297,3 +297,13 @@ mod authority_tests {
);
}
}
#[test]
fn upstream_uri_preserves_nested_url_query_without_rejecting_valid_separators() {
let config = crate::config::demo_config("127.0.0.1:9000".parse().unwrap());
let snapshot = crate::config::compile(config, 1, &validation_policy()).unwrap();
let backend = snapshot.select("localhost", "/").unwrap().backend.as_ref().unwrap();
let suffix = "/convert?url=https%3A//easylist.to/easylist/easylist.txt&ip=0.0.0.0";
let uri = upstream_uri(backend, suffix).unwrap();
assert_eq!(uri.path_and_query().unwrap().as_str(), suffix);
}
-34
View File
@@ -420,40 +420,6 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
))
})
}
/// Build a same-host HTTPS redirect while preserving the request path and query.
fn force_ssl_location(host: &str, uri: &Uri, port: u16) -> Option<HeaderValue> {
let path = uri
.path_and_query()
.map(|value| value.as_str())
.unwrap_or("/");
let location = if port == 443 {
format!("https://{host}{path}")
} else {
format!("https://{host}:{port}{path}")
};
HeaderValue::from_str(&location).ok()
}
fn force_ssl_redirect(
frontend: &crate::config::CompiledFrontend,
host: &str,
uri: &Uri,
) -> Option<Response> {
if !frontend.force_ssl {
return None;
}
let location = force_ssl_location(host, uri, frontend.https_redirect_port?)?;
let mut response = StatusCode::PERMANENT_REDIRECT.into_response();
response
.headers_mut()
.insert(http::header::LOCATION, location);
// Do not pin a redirect in a browser after an operator later disables Force SSL.
response
.headers_mut()
.insert("cache-control", HeaderValue::from_static("no-store"));
Some(response)
}
/// HTTP/2 and HTTP/3 authority is a pseudo-header, not necessarily a Host field.
/// HTTP/1 absolute-form remains forbidden: this is not a forward proxy.
fn effective_host(request: &Request, tls: bool) -> Result<HeaderValue, ()> {
+2 -1
View File
@@ -283,7 +283,8 @@ pub fn for_all_frontends(
};
for vhost in vhosts {
if let Some(id) = &vhost.certificate_id {
assign(&vhost.frontend.hosts, id, "vhost")?;
let hosts = vhost.routing_hosts()?;
assign(&hosts, id, "vhost")?;
}
}
for redirect in redirects {
+1
View File
@@ -2,6 +2,7 @@
//! A Host header is never evidence that traffic arrived on a private interface.
include!("vhost/model.rs");
include!("vhost/redirect.rs");
include!("vhost/validation.rs");
#[cfg(test)]
include!("vhost/tests.rs");
+10 -1
View File
@@ -193,7 +193,6 @@ impl Default for LocationResponse {
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Source {
@@ -268,6 +267,13 @@ pub struct Vhost {
/// Redirect cleartext requests for this vhost to the selected HTTPS frontend.
#[serde(default)]
pub force_ssl: bool,
/// Redirect the www/non-www companion to a selected exact hostname.
#[serde(default)]
pub www_redirect: bool,
/// Canonical hostname for the quick www redirect. None preserves the legacy
/// first-hostname behavior. Other vhost hostnames remain independent aliases.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub www_redirect_target: Option<String>,
/// Forward the effective client IP to the application using Forwarded/X-Forwarded-For/X-Real-IP.
/// Disable only for applications that must see the RustProxy transport peer instead.
#[serde(default = "yes")]
@@ -483,4 +489,7 @@ pub struct PathRoute {
/// Nginx-style local `return`: when present no backend connection is opened.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub response: Option<LocationResponse>,
/// Local HTTP redirect; mutually exclusive with `response`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub redirect: Option<LocationRedirect>,
}
+34
View File
@@ -0,0 +1,34 @@
// Custom-location redirect model.
fn redirect_status() -> u16 {
302
}
fn preserve_redirect_query() -> bool {
true
}
/// Local redirect action for a custom location. `rewrite_pattern` is optional;
/// when present it is applied to the normalized request path and supports the
/// Rust regex replacement syntax (`$1`, `${name}`, ...).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct LocationRedirect {
#[serde(default = "redirect_status")]
pub status: u16,
pub target: String,
#[serde(default)]
pub rewrite_pattern: Option<String>,
#[serde(default = "preserve_redirect_query")]
pub preserve_query: bool,
}
impl Default for LocationRedirect {
fn default() -> Self {
Self {
status: redirect_status(),
target: String::new(),
rewrite_pattern: None,
preserve_query: preserve_redirect_query(),
}
}
}
+76 -1
View File
@@ -15,6 +15,7 @@ impl Default for PathRoute {
headers: HeaderRules::default(),
rewrite: PathRewrite::default(),
response: None,
redirect: None,
}
}
}
@@ -40,6 +41,7 @@ impl PathRoute {
&& self.rewrite.mode == RewriteMode::Preserve
&& self.rewrite.value.is_empty()
&& self.response.is_none()
&& self.redirect.is_none()
}
}
impl Vhost {
@@ -71,6 +73,22 @@ impl Vhost {
*host = crate::config::normalize_host_pattern(host)?;
ensure!(hosts.insert(host.clone()), "duplicate vhost hostname");
}
if let Some(target) = &mut self.www_redirect_target {
*target = crate::config::normalize_host_pattern(target)?;
ensure!(
!target.starts_with("*.") && target.parse::<std::net::IpAddr>().is_err(),
"www redirect target must be an exact DNS hostname"
);
let partner = www_partner(target)?;
ensure!(
self.frontend.hosts.contains(target) || self.frontend.hosts.contains(&partner),
"www redirect target or its www/non-www partner must belong to this vhost"
);
}
if self.www_redirect {
let _ = self.www_redirect_pair()?;
let _ = self.routing_hosts()?;
}
if let Some(id) = &self.acl_profile_id {
ensure!(crate::config::valid_id(id), "invalid ACL profile id");
}
@@ -86,7 +104,10 @@ impl Vhost {
self.source.validate()?;
let needs_proxy = self.source.is_reverse_proxy()
|| self.routes.iter().any(|route| {
route.enabled && route.response.is_none() && route.source.is_reverse_proxy()
route.enabled
&& route.response.is_none()
&& route.redirect.is_none()
&& route.source.is_reverse_proxy()
});
ensure!(
self.backends.len() <= 32,
@@ -121,6 +142,10 @@ impl Vhost {
self.routes.retain(|route| !route.is_legacy_main_fallback());
for route in &mut self.routes {
route.source.validate()?;
ensure!(
route.response.is_none() || route.redirect.is_none(),
"location cannot define both a local response and a redirect"
);
if let Some(id) = &route.acl_profile_id {
ensure!(crate::config::valid_id(id), "invalid location ACL profile id");
}
@@ -224,6 +249,56 @@ pub fn validate_listener(listener: Listener) -> Result<()> {
);
Ok(())
}
/// Return the other side of a www/non-www redirect pair. The target and its
/// counterpart must both be routable, even if only one was configured.
fn www_partner(host: &str) -> Result<String> {
if let Some(bare) = host.strip_prefix("www.") {
ensure!(!bare.is_empty() && bare.parse::<std::net::IpAddr>().is_err(),
"www redirect requires a DNS hostname");
crate::config::normalize_host(bare)
} else {
crate::config::normalize_host(&format!("www.{host}"))
}
}
impl Vhost {
fn www_canonical_host(&self) -> Result<&str> {
let canonical = self.www_redirect_target.as_deref()
.or_else(|| self.frontend.hosts.first().map(String::as_str))
.ok_or_else(|| anyhow::anyhow!("vhost requires at least one hostname"))?;
ensure!(!canonical.starts_with("*.") && canonical.parse::<std::net::IpAddr>().is_err(),
"www redirect requires an exact DNS hostname");
let partner = www_partner(canonical)?;
ensure!(self.frontend.hosts.iter().any(|host| host == canonical || host == &partner),
"www redirect target or its www/non-www partner must belong to this vhost");
Ok(canonical)
}
/// Hostnames owned by the vhost at runtime. Both the selected canonical
/// host and its www/non-www partner are claimed, even if one is implicit.
pub fn routing_hosts(&self) -> Result<Vec<String>> {
let mut hosts = self.frontend.hosts.clone();
if !self.www_redirect {
return Ok(hosts);
}
let canonical = self.www_canonical_host()?.to_owned();
let companion = www_partner(&canonical)?;
for name in [canonical, companion] {
if !hosts.contains(&name) {
hosts.push(name);
}
}
Ok(hosts)
}
pub fn www_redirect_pair(&self) -> Result<Option<(String, String)>> {
if !self.www_redirect {
return Ok(None);
}
let canonical = self.www_canonical_host()?.to_owned();
let companion = www_partner(&canonical)?;
Ok(Some((companion, canonical)))
}
}
impl Frontend {
pub fn resolved(&self, settings: &crate::settings::Listeners) -> Result<Vec<Listener>> {
let mut result = Vec::new();
+1 -1
View File
@@ -159,7 +159,7 @@ pub(super) fn fastcgi_params(
if context.tls { "on" } else { "off" }.into(),
);
params.insert("GATEWAY_INTERFACE".into(), "CGI/1.1".into());
params.insert("SERVER_SOFTWARE".into(), "RustProxy/0.28.2".into());
params.insert("SERVER_SOFTWARE".into(), "RustProxy/0.29.0".into());
params.insert("REDIRECT_STATUS".into(), "200".into());
if let Some(value) = headers
.get(CONTENT_TYPE)
+1 -1
View File
@@ -185,7 +185,7 @@ pub(super) fn directory_listing(path: &str, entries: &[DirectoryEntry]) -> Vec<u
"<a class=\"{class}\" href=\"{href}\"><span class=\"type\">{type_label}</span><span class=\"name\">{label}{slash}</span><span class=\"kind\">{kind}</span></a>"
));
}
html.push_str(r#"</div></section><footer class="page-footer"><span>RustProxy <b>0.28.2</b></span><span>Web Server · Directory listing</span></footer></main></body></html>"#);
html.push_str(r#"</div></section><footer class="page-footer"><span>RustProxy <b>0.29.0</b></span><span>Web Server · Directory listing</span></footer></main></body></html>"#);
html.into_bytes()
}
+1 -1
View File
@@ -202,7 +202,7 @@ async fn empty_index_can_go_directly_to_directory_listing() {
let body = std::str::from_utf8(&body).unwrap();
assert!(body.contains("file.txt"));
assert!(body.contains("DIRECTORY LISTING"));
assert!(body.contains("RustProxy <b>0.28.2</b>"));
assert!(body.contains("RustProxy <b>0.29.0</b>"));
}
#[tokio::test]