v0.29.0
This commit is contained in:
1 parent
65e082b9d1
commit
76cb4acef0
72 files changed
+6134
-4709
No files matched your search
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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()) },
|
||||
|
||||
@@ -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"];
|
||||
|
||||
Vendored
+42
@@ -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.
|
||||
|
||||
Vendored
+25
@@ -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,
|
||||
|
||||
Vendored
+8
@@ -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
|
||||
|
||||
Vendored
+37
@@ -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")
|
||||
);
|
||||
}
|
||||
@@ -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(());
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
include!("types.rs");
|
||||
include!("cache_helpers.rs");
|
||||
include!("upstream_helpers.rs");
|
||||
include!("redirect_helpers.rs");
|
||||
include!("forward_entry.rs");
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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, ()> {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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>,
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user