0.34.13_cleaup_code

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-26 14:14:41 +01:00
1 parent ec46a96a29
commit 5c2e50ec06
87 files changed
+4890 -1338

No files matched your search

+3 -5
View File
@@ -2,12 +2,12 @@
4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example 4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example
9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore 9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore
754db9d80640bdb05116d14cf91e1b69bfbbcfee2c522659ace4a4c7d48bb10d AGENTS.md 754db9d80640bdb05116d14cf91e1b69bfbbcfee2c522659ace4a4c7d48bb10d AGENTS.md
191e5abff142bf0353d4e5c29994d4516799f550ec25dc2173be3003a0028a58 CHANGELOG.md df0f23d242cc45064e2e822bbd1fc8297acffa89f71eed2c0d4314695107d5a1 CHANGELOG.md
b548ffccc83f59c886fd111bbfb63b40b34c35889f752e44e586c8e22131cfb8 Cargo.lock b548ffccc83f59c886fd111bbfb63b40b34c35889f752e44e586c8e22131cfb8 Cargo.lock
bb70eb65066a0aa23859a2ae006ba1bb75ff86b87afd4fa6160eae78a8d42af9 Cargo.toml bb70eb65066a0aa23859a2ae006ba1bb75ff86b87afd4fa6160eae78a8d42af9 Cargo.toml
25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile 25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile
a698cd5979a56ea5f8ab8aa4ca8ccf5f6d72515f689fc833b73e16f37ca64a6c README.md 3b0ef7ac49dfb35375780c53c48c03004fe240e68bb7337d3c8c5ad0a0e15998 README.md
9edb7d4a4da46f0f836e504002f3676b88639208d28c1588e8637b085594c154 ROADMAP.md e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ROADMAP.md
da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml
ac07b3bdd46c787989e93f55ebd6ea3d7f5b150ef36f2422e2b58df3f6b7543a build.rs ac07b3bdd46c787989e93f55ebd6ea3d7f5b150ef36f2422e2b58df3f6b7543a build.rs
2c6a0a5cb088399e2cc912073069eb334ec6615eee31734d2f0c161f1415fb6a deploy/README.md 2c6a0a5cb088399e2cc912073069eb334ec6615eee31734d2f0c161f1415fb6a deploy/README.md
@@ -115,7 +115,6 @@ f040ab366fb138a48debf764c71538c2471235353e2426316264c5d3c2d59762 scripts/prefli
20124173452b4514bad8850c1637569a9a191882478451e549bd16b432605fc1 scripts/runtime/acme_provider.py 20124173452b4514bad8850c1637569a9a191882478451e549bd16b432605fc1 scripts/runtime/acme_provider.py
e69813fb1d3a4cdcfb7243964cc323fc21a603087bf2e84188fa42b9d3cee4b5 scripts/runtime/acme_rfc2136.py e69813fb1d3a4cdcfb7243964cc323fc21a603087bf2e84188fa42b9d3cee4b5 scripts/runtime/acme_rfc2136.py
174c8059625e3315a3d9fefe74c17f78ed850ff25ee15bfdabc148107dd344eb scripts/start_stream_backends.sh 174c8059625e3315a3d9fefe74c17f78ed850ff25ee15bfdabc148107dd344eb scripts/start_stream_backends.sh
7302f3446e2306a8618a1c026178e4c34f6215066ef7f7718bb29a42116bb354 scripts/test_admin_config_browser.py
046b4282550f5cb4e53906665c91c7442be70fc68fd0743466fd832582f7c979 scripts/test_stream_tcp.py 046b4282550f5cb4e53906665c91c7442be70fc68fd0743466fd832582f7c979 scripts/test_stream_tcp.py
f0b48ec0cc4411755cc0b4128eebce710375afa99344cb76198cf31074f8eb63 scripts/test_stream_udp.py f0b48ec0cc4411755cc0b4128eebce710375afa99344cb76198cf31074f8eb63 scripts/test_stream_udp.py
22e880e98c7b9d700c7a4c02b9b1d543e5dbd1f4ecdca42813a1083165b32a19 scripts/version.py 22e880e98c7b9d700c7a4c02b9b1d543e5dbd1f4ecdca42813a1083165b32a19 scripts/version.py
@@ -527,7 +526,6 @@ f826d8816bdb9edab966e10991e45335625ef7d7376169cf6fd1e0e5d01bf868 src/web_server
b791762b4ab7a43c3be19df84e523faf3dfb4df055f66d02178f93edb60d2204 src/web_server/serve.rs b791762b4ab7a43c3be19df84e523faf3dfb4df055f66d02178f93edb60d2204 src/web_server/serve.rs
df888adf893d0b6b99348d19fa07bd9c2b8fc0ce7f411d06be7411afb4311fe0 src/web_server/tests.rs df888adf893d0b6b99348d19fa07bd9c2b8fc0ce7f411d06be7411afb4311fe0 src/web_server/tests.rs
f00743af2cf4a5952caef7a1dcf68e7817b5599a252a9973e1c22045517c0b0e tests/fixtures/argon2id.json f00743af2cf4a5952caef7a1dcf68e7817b5599a252a9973e1c22045517c0b0e tests/fixtures/argon2id.json
0930ab8fae4eaaf76185bb1c6c9d4c2ee705a9e09a3ae74d81674ca4631a0169 tests/test_config_api_contract.py
eca68a07c2b1e45fdb8f512294f5a432db3ae5a6aec8fb200b47f155cd9a31d6 tools/README.md eca68a07c2b1e45fdb8f512294f5a432db3ae5a6aec8fb200b47f155cd9a31d6 tools/README.md
7c30c280d0b0a8bf855eeffab583ca34dc161255f7ef6124148aa979c17c26d3 tools/dev/memory_load.py 7c30c280d0b0a8bf855eeffab583ca34dc161255f7ef6124148aa979c17c26d3 tools/dev/memory_load.py
d1418f8e574a7390fd45a2aa879bdf5720b773162d65e295ab0350506aa5220a tools/dev/mock_auth.py d1418f8e574a7390fd45a2aa879bdf5720b773162d65e295ab0350506aa5220a tools/dev/mock_auth.py
File diff suppressed because it is too large. Load diff
+36 -14
View File
@@ -58,13 +58,17 @@ impl Acl {
ensure!(rule.network.len() <= 254, "ACL network is too long"); ensure!(rule.network.len() <= 254, "ACL network is too long");
let target = if let Ok(network) = parse_network(&rule.network) { let target = if let Ok(network) = parse_network(&rule.network) {
if let IpNet::V6(net) = network { if let IpNet::V6(net) = network {
ensure!(net.addr().to_ipv4_mapped().is_none(), ensure!(
"use IPv4 CIDR instead of IPv4-mapped IPv6 in ACL rules"); net.addr().to_ipv4_mapped().is_none(),
"use IPv4 CIDR instead of IPv4-mapped IPv6 in ACL rules"
);
} }
RuleMatch::Ip(network) RuleMatch::Ip(network)
} else { } else {
ensure!(rule.action == Action::Allow, ensure!(
"dynamic DNS ACL rules only support allow; use a static IP/CIDR for deny"); rule.action == Action::Allow,
"dynamic DNS ACL rules only support allow; use a static IP/CIDR for deny"
);
RuleMatch::Dns(dynamic::hostname(&rule.network)?) RuleMatch::Dns(dynamic::hostname(&rule.network)?)
}; };
rules.push((target, rule.action)); rules.push((target, rule.action));
@@ -94,9 +98,16 @@ pub fn allows_profiles_with_country(
let mut explicit_allow = false; let mut explicit_allow = false;
let mut defaults_allow = true; let mut defaults_allow = true;
for profile in profiles { for profile in profiles {
let ip_action = profile.acl.as_ref().and_then(|acl| acl.explicit_action(peer)); let ip_action = profile
.acl
.as_ref()
.and_then(|acl| acl.explicit_action(peer));
let country_listed = country.is_some_and(|code| { let country_listed = country.is_some_and(|code| {
profile.countries.countries.iter().any(|value| value.eq_ignore_ascii_case(code)) profile
.countries
.countries
.iter()
.any(|value| value.eq_ignore_ascii_case(code))
}); });
if ip_action == Some(Action::Deny) if ip_action == Some(Action::Deny)
|| (profile.countries.mode == crate::rate_limit::CountryMode::Block && country_listed) || (profile.countries.mode == crate::rate_limit::CountryMode::Block && country_listed)
@@ -106,20 +117,32 @@ pub fn allows_profiles_with_country(
explicit_allow |= ip_action == Some(Action::Allow) explicit_allow |= ip_action == Some(Action::Allow)
|| (profile.countries.mode == crate::rate_limit::CountryMode::Allow && country_listed); || (profile.countries.mode == crate::rate_limit::CountryMode::Allow && country_listed);
defaults_allow &= profile.countries.mode != crate::rate_limit::CountryMode::Allow defaults_allow &= profile.countries.mode != crate::rate_limit::CountryMode::Allow
&& profile.acl.as_ref().is_none_or(|acl| acl.default == Action::Allow); && profile
.acl
.as_ref()
.is_none_or(|acl| acl.default == Action::Allow);
} }
explicit_allow || defaults_allow explicit_allow || defaults_allow
} }
pub fn profile_ids<'a>(legacy: Option<&'a str>, ids: &'a [String]) -> impl Iterator<Item = &'a str> { pub fn profile_ids<'a>(
legacy: Option<&'a str>,
ids: &'a [String],
) -> impl Iterator<Item = &'a str> {
legacy.into_iter().chain(ids.iter().map(String::as_str)) legacy.into_iter().chain(ids.iter().map(String::as_str))
} }
pub fn validate_profile_ids(legacy: Option<&str>, ids: &[String]) -> Result<()> { pub fn validate_profile_ids(legacy: Option<&str>, ids: &[String]) -> Result<()> {
ensure!(ids.len() + (if legacy.is_some() { 1 } else { 0 }) <= 8, "at most 8 ACL profiles may be assigned"); ensure!(
ids.len() + (if legacy.is_some() { 1 } else { 0 }) <= 8,
"at most 8 ACL profiles may be assigned"
);
let mut seen = std::collections::HashSet::new(); let mut seen = std::collections::HashSet::new();
for id in profile_ids(legacy, ids) { for id in profile_ids(legacy, ids) {
ensure!(crate::config::valid_id(id) && seen.insert(id), "invalid or duplicate ACL profile id"); ensure!(
crate::config::valid_id(id) && seen.insert(id),
"invalid or duplicate ACL profile id"
);
} }
Ok(()) Ok(())
} }
@@ -181,9 +204,7 @@ pub fn allows_with_country(
&& (ip_action == Some(Action::Allow) && (ip_action == Some(Action::Allow)
|| acl.is_none_or(|value| value.default == Action::Allow)) || acl.is_none_or(|value| value.default == Action::Allow))
} }
crate::rate_limit::CountryMode::Allow => { crate::rate_limit::CountryMode::Allow => ip_action == Some(Action::Allow) || country_listed,
ip_action == Some(Action::Allow) || country_listed
}
} }
} }
pub fn validate_decoded_routing_path(path: &str) -> Result<()> { pub fn validate_decoded_routing_path(path: &str) -> Result<()> {
@@ -220,7 +241,8 @@ pub fn routing_path_view(raw: &str) -> Result<std::borrow::Cow<'_, str>> {
); );
} }
ensure!( ensure!(
!raw.split('/').any(|segment| segment == "." || segment == ".."), !raw.split('/')
.any(|segment| segment == "." || segment == ".."),
"ambiguous path segments" "ambiguous path segments"
); );
Ok(std::borrow::Cow::Borrowed(raw)) Ok(std::borrow::Cow::Borrowed(raw))
+6 -3
View File
@@ -21,13 +21,16 @@ fn general_api_token_allowed(method: &Method, path: &str, permission: &str) -> b
| "/api/metrics/json" | "/api/metrics/json"
| "/api/metrics/summary" | "/api/metrics/summary"
| "/api/cache/bans" | "/api/cache/bans"
) || (method == Method::GET )
&& path.strip_prefix("/api/config/") || (method == Method::GET
&& path
.strip_prefix("/api/config/")
.is_some_and(|section| ConfigSection::from_name(section).is_some())); .is_some_and(|section| ConfigSection::from_name(section).is_some()));
let write = permission == "rw" let write = permission == "rw"
&& ((method == Method::PUT && ((method == Method::PUT
&& (path == "/api/config" && (path == "/api/config"
|| path.strip_prefix("/api/config/") || path
.strip_prefix("/api/config/")
.is_some_and(|section| ConfigSection::from_name(section).is_some()))) .is_some_and(|section| ConfigSection::from_name(section).is_some())))
|| (method == Method::POST || (method == Method::POST
&& matches!( && matches!(
+108 -62
View File
@@ -1,6 +1,3 @@
// GET and PUT resolve exactly the same named sections. All writes go through
// App::update_as: full validation, revision check under the update lock, and
// one durable transaction. These handlers do not implement partial patches.
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ConfigSection { enum ConfigSection {
Listeners, Listeners,
@@ -93,66 +90,109 @@ impl ConfigSection {
} }
} }
fn replace(self, config: &mut crate::config::Config, name: &str, value: Value) -> Result<(), ApiError> { fn replace(
self,
config: &mut crate::config::Config,
name: &str,
value: Value,
) -> Result<(), ApiError> {
match self { match self {
Self::Listeners => config.listeners = serde_json::from_value(value) Self::Listeners => {
.map_err(|error| invalid_section(name, error))?, config.listeners =
Self::DockerAgents => config.docker_agents = serde_json::from_value(value) serde_json::from_value(value).map_err(|error| invalid_section(name, error))?
.map_err(|error| invalid_section(name, error))?, }
Self::Http2 => config.http2 = serde_json::from_value(value) Self::DockerAgents => {
.map_err(|error| invalid_section(name, error))?, config.docker_agents =
Self::Pages => config.pages = serde_json::from_value(value) serde_json::from_value(value).map_err(|error| invalid_section(name, error))?
.map_err(|error| invalid_section(name, error))?, }
Self::Cache => config.cache = serde_json::from_value(value) Self::Http2 => {
.map_err(|error| invalid_section(name, error))?, config.http2 =
Self::Transform => config.transform = serde_json::from_value(value) serde_json::from_value(value).map_err(|error| invalid_section(name, error))?
.map_err(|error| invalid_section(name, error))?, }
Self::Forwarding => config.forwarding = serde_json::from_value(value) Self::Pages => {
.map_err(|error| invalid_section(name, error))?, config.pages =
Self::Headers => config.headers = serde_json::from_value(value) serde_json::from_value(value).map_err(|error| invalid_section(name, error))?
.map_err(|error| invalid_section(name, error))?, }
Self::Prometheus => config.prometheus = serde_json::from_value(value) Self::Cache => {
.map_err(|error| invalid_section(name, error))?, config.cache =
Self::Geoip => config.geoip_updates = serde_json::from_value(value) serde_json::from_value(value).map_err(|error| invalid_section(name, error))?
.map_err(|error| invalid_section(name, error))?, }
Self::Administration => config.administration = serde_json::from_value(value) Self::Transform => {
.map_err(|error| invalid_section(name, error))?, config.transform =
Self::Tls => config.tls = serde_json::from_value(value) serde_json::from_value(value).map_err(|error| invalid_section(name, error))?
.map_err(|error| invalid_section(name, error))?, }
Self::Vhosts => config.vhosts = serde_json::from_value(value) Self::Forwarding => {
.map_err(|error| invalid_section(name, error))?, config.forwarding =
Self::WebServices => config.web_services = serde_json::from_value(value) serde_json::from_value(value).map_err(|error| invalid_section(name, error))?
.map_err(|error| invalid_section(name, error))?, }
Self::PhpFpmProfiles => config.php_fpm_profiles = serde_json::from_value(value) Self::Headers => {
.map_err(|error| invalid_section(name, error))?, config.headers =
Self::Redirects => config.redirects = serde_json::from_value(value) serde_json::from_value(value).map_err(|error| invalid_section(name, error))?
.map_err(|error| invalid_section(name, error))?, }
Self::NotFoundHosts => config.not_found_hosts = serde_json::from_value(value) Self::Prometheus => {
.map_err(|error| invalid_section(name, error))?, config.prometheus =
Self::Streams => config.streams = serde_json::from_value(value) serde_json::from_value(value).map_err(|error| invalid_section(name, error))?
.map_err(|error| invalid_section(name, error))?, }
Self::AclProfiles => config.acl_profiles = serde_json::from_value(value) Self::Geoip => {
.map_err(|error| invalid_section(name, error))?, config.geoip_updates =
Self::HostPolicies => config.host_policies = serde_json::from_value(value) serde_json::from_value(value).map_err(|error| invalid_section(name, error))?
.map_err(|error| invalid_section(name, error))?, }
Self::Administration => {
config.administration =
serde_json::from_value(value).map_err(|error| invalid_section(name, error))?
}
Self::Tls => {
config.tls =
serde_json::from_value(value).map_err(|error| invalid_section(name, error))?
}
Self::Vhosts => {
config.vhosts =
serde_json::from_value(value).map_err(|error| invalid_section(name, error))?
}
Self::WebServices => {
config.web_services =
serde_json::from_value(value).map_err(|error| invalid_section(name, error))?
}
Self::PhpFpmProfiles => {
config.php_fpm_profiles =
serde_json::from_value(value).map_err(|error| invalid_section(name, error))?
}
Self::Redirects => {
config.redirects =
serde_json::from_value(value).map_err(|error| invalid_section(name, error))?
}
Self::NotFoundHosts => {
config.not_found_hosts =
serde_json::from_value(value).map_err(|error| invalid_section(name, error))?
}
Self::Streams => {
config.streams =
serde_json::from_value(value).map_err(|error| invalid_section(name, error))?
}
Self::AclProfiles => {
config.acl_profiles =
serde_json::from_value(value).map_err(|error| invalid_section(name, error))?
}
Self::HostPolicies => {
config.host_policies =
serde_json::from_value(value).map_err(|error| invalid_section(name, error))?
}
Self::Response => { Self::Response => {
let part: ResponseSection = serde_json::from_value(value) let part: ResponseSection =
.map_err(|error| invalid_section(name, error))?; serde_json::from_value(value).map_err(|error| invalid_section(name, error))?;
config.server_header = part.server_header; config.server_header = part.server_header;
config.diagnostic_headers = part.diagnostic_headers; config.diagnostic_headers = part.diagnostic_headers;
config.compression = part.compression; config.compression = part.compression;
} }
Self::Logging => { Self::Logging => {
// A logging save must include both sinks; there is no implicit fallback let part: LoggingSection =
// to a single-field payload that could reset error_log accidentally. serde_json::from_value(value).map_err(|error| invalid_section(name, error))?;
let part: LoggingSection = serde_json::from_value(value)
.map_err(|error| invalid_section(name, error))?;
config.logging = part.logging; config.logging = part.logging;
config.error_log = part.error_log; config.error_log = part.error_log;
} }
Self::GlobalSecurity => { Self::GlobalSecurity => {
let part: crate::config::GlobalSecurity = serde_json::from_value(value) let part: crate::config::GlobalSecurity =
.map_err(|error| invalid_section(name, error))?; serde_json::from_value(value).map_err(|error| invalid_section(name, error))?;
if part.user_agent_blocking.is_none() { if part.user_agent_blocking.is_none() {
return Err(ApiError( return Err(ApiError(
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
@@ -167,10 +207,12 @@ impl ConfigSection {
} }
fn requested_config_section(name: &str) -> Result<ConfigSection, ApiError> { fn requested_config_section(name: &str) -> Result<ConfigSection, ApiError> {
ConfigSection::from_name(name).ok_or_else(|| ApiError( ConfigSection::from_name(name).ok_or_else(|| {
StatusCode::NOT_FOUND, ApiError(
"unknown configuration section".into(), StatusCode::NOT_FOUND,
)) "unknown configuration section".into(),
)
})
} }
async fn configuration_section( async fn configuration_section(
@@ -192,7 +234,6 @@ async fn update_section(
Path(section): Path<String>, Path(section): Path<String>,
Json(input): Json<SectionUpdate>, Json(input): Json<SectionUpdate>,
) -> Result<Json<crate::config::Envelope>, ApiError> { ) -> Result<Json<crate::config::Envelope>, ApiError> {
// Resolve the name before taking the snapshot, including for stale requests.
let selected = requested_config_section(&section)?; let selected = requested_config_section(&section)?;
let current = state.config.load_full(); let current = state.config.load_full();
if current.envelope.revision != input.expected_revision { if current.envelope.revision != input.expected_revision {
@@ -203,12 +244,18 @@ async fn update_section(
} }
let mut config = current.envelope.config.clone(); let mut config = current.envelope.config.clone();
selected.replace(&mut config, &section, input.value)?; selected.replace(&mut config, &section, input.value)?;
state.update_as(Update { expected_revision: input.expected_revision, config }, session.username) state
.await.map(Json) .update_as(
Update {
expected_revision: input.expected_revision,
config,
},
session.username,
)
.await
.map(Json)
} }
// GeoIP is an explicit side-effecting action within the geoip settings section.
// GET and PUT are only for desired settings; POST refreshes external data.
async fn geoip_update( async fn geoip_update(
State(state): State<Arc<App>>, State(state): State<Arc<App>>,
Extension(session): Extension<Session>, Extension(session): Extension<Session>,
@@ -239,4 +286,3 @@ async fn geoip_update(
.map_err(auth::internal)?; .map_err(auth::internal)?;
Ok(Json(result)) Ok(Json(result))
} }
+41 -22
View File
@@ -95,32 +95,37 @@ fn configured_file_log_sources(config: &crate::config::Config) -> Vec<FileLogSou
.ok() .ok()
.is_some_and(|meta| meta.file_type().is_dir() && !meta.file_type().is_symlink()); .is_some_and(|meta| meta.file_type().is_dir() && !meta.file_type().is_symlink());
if directory_safe { if directory_safe {
let entries = newest_log_files(std::fs::read_dir(&directory) let entries = newest_log_files(
.ok() std::fs::read_dir(&directory)
.into_iter() .ok()
.flat_map(|iter| iter.filter_map(|entry| entry.ok())) .into_iter()
.filter_map(|entry| { .flat_map(|iter| iter.filter_map(|entry| entry.ok()))
let path = entry.path(); .filter_map(|entry| {
let name = entry.file_name().to_string_lossy().into_owned(); let path = entry.path();
let plain = name.ends_with(".jsonl"); let name = entry.file_name().to_string_lossy().into_owned();
let rotated = name.contains(".jsonl.") && name.ends_with(".gz"); let plain = name.ends_with(".jsonl");
if (!plain && !rotated) || name.len() > 180 { let rotated = name.contains(".jsonl.") && name.ends_with(".gz");
return None; if (!plain && !rotated) || name.len() > 180 {
} return None;
let file_type = entry.file_type().ok()?; }
if !file_type.is_file() || file_type.is_symlink() { let file_type = entry.file_type().ok()?;
return None; if !file_type.is_file() || file_type.is_symlink() {
} return None;
let meta = entry.metadata().ok()?; }
Some((name, path, file_modified_ms(&meta))) let meta = entry.metadata().ok()?;
}), 256); Some((name, path, file_modified_ms(&meta)))
}),
256,
);
for (name, path, _) in entries { for (name, path, _) in entries {
let id = if name.ends_with(".jsonl") { let id = if name.ends_with(".jsonl") {
format!("vhost:{}", name.trim_end_matches(".jsonl")) format!("vhost:{}", name.trim_end_matches(".jsonl"))
} else { } else {
format!("vhost-gz:{name}") format!("vhost-gz:{name}")
}; };
let vhost_name = name.split_once(".jsonl").map_or(name.as_str(), |(prefix, _)| prefix); let vhost_name = name
.split_once(".jsonl")
.map_or(name.as_str(), |(prefix, _)| prefix);
let group_id = format!("vhost:{vhost_name}"); let group_id = format!("vhost:{vhost_name}");
sources.push(source_from_path( sources.push(source_from_path(
id, id,
@@ -144,7 +149,14 @@ fn configured_file_log_sources(config: &crate::config::Config) -> Vec<FileLogSou
config.logging.file.enabled, config.logging.file.enabled,
"JSONL", "JSONL",
)); ));
sources.extend(rotated_gzip_sources(&access_path, "access", "Access log", "access", 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 { let error_format = match config.error_log.format {
crate::logging::ErrorLogFormat::Json => "JSONL", crate::logging::ErrorLogFormat::Json => "JSONL",
@@ -160,7 +172,14 @@ fn configured_file_log_sources(config: &crate::config::Config) -> Vec<FileLogSou
config.error_log.enabled, config.error_log.enabled,
error_format, error_format,
)); ));
sources.extend(rotated_gzip_sources(&error_path, "error", "Error log", "error", config.error_log.enabled, error_format)); sources.extend(rotated_gzip_sources(
&error_path,
"error",
"Error log",
"error",
config.error_log.enabled,
error_format,
));
sources sources
} }
fn file_log_sources_json(sources: &[FileLogSource]) -> Value { fn file_log_sources_json(sources: &[FileLogSource]) -> Value {
+1 -1
View File
@@ -14305,4 +14305,4 @@
} }
}, },
"tags": [] "tags": []
} }
+4 -1
View File
@@ -260,7 +260,10 @@ pub fn router(state: Arc<App>) -> Router {
post(restore_config_history).layer(DefaultBodyLimit::max(4096)), post(restore_config_history).layer(DefaultBodyLimit::max(4096)),
) )
.route("/api/config/geoip/update", post(geoip_update)) .route("/api/config/geoip/update", post(geoip_update))
.route("/api/config/{section}", get(configuration_section).put(update_section)) .route(
"/api/config/{section}",
get(configuration_section).put(update_section),
)
.route("/api/logs", get(logs)) .route("/api/logs", get(logs))
.route("/api/live-logs", get(live_logs_stream)) .route("/api/live-logs", get(live_logs_stream))
.route("/api/file-logs", get(file_logs)) .route("/api/file-logs", get(file_logs))
+180 -67
View File
@@ -654,11 +654,29 @@ mod tests {
#[test] #[test]
fn config_section_registry_read_and_replace_are_symmetric() { fn config_section_registry_read_and_replace_are_symmetric() {
let names = [ let names = [
"listeners", "docker-agents", "http2", "pages", "cache", "transform", "listeners",
"forwarding", "headers", "prometheus", "geoip", "administration", "docker-agents",
"tls", "vhosts", "web-services", "php-fpm-profiles", "redirects", "http2",
"not-found-hosts", "streams", "acl-profiles", "host-policies", "pages",
"response", "logging", "global-security", "cache",
"transform",
"forwarding",
"headers",
"prometheus",
"geoip",
"administration",
"tls",
"vhosts",
"web-services",
"php-fpm-profiles",
"redirects",
"not-found-hosts",
"streams",
"acl-profiles",
"host-policies",
"response",
"logging",
"global-security",
]; ];
let mut original = Config::default(); let mut original = Config::default();
original.global_security.compile().unwrap(); original.global_security.compile().unwrap();
@@ -671,7 +689,14 @@ mod tests {
assert_eq!(serde_json::to_value(&updated).unwrap(), expected, "{name}"); assert_eq!(serde_json::to_value(&updated).unwrap(), expected, "{name}");
assert_eq!(section.read(&updated), value, "{name}"); assert_eq!(section.read(&updated), value, "{name}");
} }
for name in ["user-agent-blocking", "error_log", "history", "storage", "unknown", "tls/nested"] { for name in [
"user-agent-blocking",
"error_log",
"history",
"storage",
"unknown",
"tls/nested",
] {
assert!(ConfigSection::from_name(name).is_none(), "{name}"); assert!(ConfigSection::from_name(name).is_none(), "{name}");
} }
} }
@@ -681,17 +706,40 @@ mod tests {
let (router, store, _dir, secret, _csrf) = fixture().await; let (router, store, _dir, secret, _csrf) = fixture().await;
let cookie = format!("{}={secret}", auth::COOKIE_NAME); let cookie = format!("{}={secret}", auth::COOKIE_NAME);
for name in [ for name in [
"listeners", "docker-agents", "http2", "pages", "cache", "transform", "listeners",
"forwarding", "headers", "prometheus", "geoip", "administration", "tls", "docker-agents",
"vhosts", "web-services", "php-fpm-profiles", "redirects", "not-found-hosts", "http2",
"streams", "acl-profiles", "host-policies", "response", "logging", "pages",
"cache",
"transform",
"forwarding",
"headers",
"prometheus",
"geoip",
"administration",
"tls",
"vhosts",
"web-services",
"php-fpm-profiles",
"redirects",
"not-found-hosts",
"streams",
"acl-profiles",
"host-policies",
"response",
"logging",
"global-security", "global-security",
] { ] {
let response = router.clone().oneshot( let response = router
request(&format!("/api/config/{name}")) .clone()
.header("cookie", &cookie) .oneshot(
.body(Body::empty()).unwrap(), request(&format!("/api/config/{name}"))
).await.unwrap(); .header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK, "{name}"); assert_eq!(response.status(), StatusCode::OK, "{name}");
let data = json_body(response).await; let data = json_body(response).await;
assert_eq!(data["section"], name); assert_eq!(data["section"], name);
@@ -708,24 +756,57 @@ mod tests {
assert!(data["value"]["user_agent_blocking"].is_object()); assert!(data["value"]["user_agent_blocking"].is_object());
} }
} }
let unknown = router.clone().oneshot( let unknown = router
request("/api/config/user-agent-blocking") .clone()
.header("cookie", &cookie) .oneshot(
.body(Body::empty()).unwrap(), request("/api/config/user-agent-blocking")
).await.unwrap(); .header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(unknown.status(), StatusCode::NOT_FOUND); assert_eq!(unknown.status(), StatusCode::NOT_FOUND);
store.close().await; store.close().await;
} }
#[test] #[test]
fn geoip_update_is_scoped_to_settings_for_general_api_tokens() { fn geoip_update_is_scoped_to_settings_for_general_api_tokens() {
assert!(general_api_token_allowed(&Method::GET, "/api/config/geoip", "ro")); assert!(general_api_token_allowed(
assert!(general_api_token_allowed(&Method::PUT, "/api/config/geoip", "rw")); &Method::GET,
assert!(!general_api_token_allowed(&Method::PUT, "/api/config/geoip", "ro")); "/api/config/geoip",
assert!(general_api_token_allowed(&Method::POST, "/api/config/geoip/update", "rw")); "ro"
assert!(!general_api_token_allowed(&Method::POST, "/api/config/geoip/update", "ro")); ));
assert!(!general_api_token_allowed(&Method::POST, "/api/geoip/update", "rw")); assert!(general_api_token_allowed(
assert!(!general_api_token_allowed(&Method::GET, "/api/config/geoip/update", "rw")); &Method::PUT,
"/api/config/geoip",
"rw"
));
assert!(!general_api_token_allowed(
&Method::PUT,
"/api/config/geoip",
"ro"
));
assert!(general_api_token_allowed(
&Method::POST,
"/api/config/geoip/update",
"rw"
));
assert!(!general_api_token_allowed(
&Method::POST,
"/api/config/geoip/update",
"ro"
));
assert!(!general_api_token_allowed(
&Method::POST,
"/api/geoip/update",
"rw"
));
assert!(!general_api_token_allowed(
&Method::GET,
"/api/config/geoip/update",
"rw"
));
} }
#[tokio::test] #[tokio::test]
@@ -736,9 +817,16 @@ mod tests {
("/api/config/geoip/update", StatusCode::METHOD_NOT_ALLOWED), ("/api/config/geoip/update", StatusCode::METHOD_NOT_ALLOWED),
("/api/geoip/update", StatusCode::NOT_FOUND), ("/api/geoip/update", StatusCode::NOT_FOUND),
] { ] {
let response = router.clone().oneshot( let response = router
request(path).header("cookie", &cookie).body(Body::empty()).unwrap(), .clone()
).await.unwrap(); .oneshot(
request(path)
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), expected, "{path}"); assert_eq!(response.status(), expected, "{path}");
} }
store.close().await; store.close().await;
@@ -813,12 +901,10 @@ mod tests {
.header("x-rustproxy-request", "1") .header("x-rustproxy-request", "1")
.header("x-csrf-token", &csrf) .header("x-csrf-token", &csrf)
.body(Body::from( .body(Body::from(
serde_json::to_vec( serde_json::to_vec(&json!({"expected_revision":0,"value":{
&json!({"expected_revision":0,"value":{ "logging":Config::default().logging,
"logging":Config::default().logging, "error_log":Config::default().error_log
"error_log":Config::default().error_log }}))
}}),
)
.unwrap(), .unwrap(),
)) ))
.unwrap(), .unwrap(),
@@ -846,7 +932,6 @@ mod tests {
store.close().await; store.close().await;
} }
#[tokio::test] #[tokio::test]
async fn global_security_section_saves_rules_and_existing_ua_atomically() { async fn global_security_section_saves_rules_and_existing_ua_atomically() {
let (router, _store, _dir, secret, csrf) = fixture().await; let (router, _store, _dir, secret, csrf) = fixture().await;
@@ -858,53 +943,81 @@ mod tests {
"user_agent_blocking": {"enabled":true, "patterns":["bad-bot"]} "user_agent_blocking": {"enabled":true, "patterns":["bad-bot"]}
}); });
let body = serde_json::to_vec(&json!({"expected_revision":0, "value":value})).unwrap(); let body = serde_json::to_vec(&json!({"expected_revision":0, "value":value})).unwrap();
let response = router.clone().oneshot( let response = router
request("/api/config/global-security") .clone()
.method("PUT") .oneshot(
.header("content-type", "application/json") request("/api/config/global-security")
.header("cookie", &cookie) .method("PUT")
.header("origin", "http://127.0.0.1:9090") .header("content-type", "application/json")
.header("x-rustproxy-request", "1") .header("cookie", &cookie)
.header("x-csrf-token", &csrf) .header("origin", "http://127.0.0.1:9090")
.body(Body::from(body)).unwrap(), .header("x-rustproxy-request", "1")
).await.unwrap(); .header("x-csrf-token", &csrf)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
let json = json_body(response).await; let json = json_body(response).await;
assert_eq!(json["revision"], 1); assert_eq!(json["revision"], 1);
assert_eq!(json["config"]["global_security"]["country_access"]["countries"][0], "RU"); assert_eq!(
assert_eq!(json["config"]["global_security"]["security_headers"]["nosniff"], true); json["config"]["global_security"]["country_access"]["countries"][0],
assert_eq!(json["config"]["global_security"]["user_agent_blocking"]["patterns"][0], "bad-bot"); "RU"
);
assert_eq!(
json["config"]["global_security"]["security_headers"]["nosniff"],
true
);
assert_eq!(
json["config"]["global_security"]["user_agent_blocking"]["patterns"][0],
"bad-bot"
);
assert!(json["config"].get("user_agent_blocking").is_none()); assert!(json["config"].get("user_agent_blocking").is_none());
// A partial section update must not silently reset the existing UA policy. // A partial section update must not silently reset the existing UA policy.
let body = serde_json::to_vec(&json!({ let body = serde_json::to_vec(&json!({
"expected_revision":1, "expected_revision":1,
"value": {"method_policy":{"mode":"off","methods":[]}} "value": {"method_policy":{"mode":"off","methods":[]}}
})).unwrap(); }))
let response = router.clone().oneshot( .unwrap();
request("/api/config/global-security") let response = router
.method("PUT") .clone()
.header("content-type", "application/json") .oneshot(
.header("cookie", &cookie) request("/api/config/global-security")
.header("origin", "http://127.0.0.1:9090") .method("PUT")
.header("x-rustproxy-request", "1") .header("content-type", "application/json")
.header("x-csrf-token", &csrf) .header("cookie", &cookie)
.body(Body::from(body)).unwrap(), .header("origin", "http://127.0.0.1:9090")
).await.unwrap(); .header("x-rustproxy-request", "1")
.header("x-csrf-token", &csrf)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST); assert_eq!(response.status(), StatusCode::BAD_REQUEST);
// GET returns the same standalone section shape accepted by PUT, at the // GET returns the same standalone section shape accepted by PUT, at the
// matching revision and without unrequested configuration fields. // matching revision and without unrequested configuration fields.
let read = router.clone().oneshot( let read = router
request("/api/config/global-security") .clone()
.header("cookie", &cookie) .oneshot(
.body(Body::empty()).unwrap(), request("/api/config/global-security")
).await.unwrap(); .header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(read.status(), StatusCode::OK); assert_eq!(read.status(), StatusCode::OK);
let view = json_body(read).await; let view = json_body(read).await;
assert_eq!(view["revision"], 1); assert_eq!(view["revision"], 1);
assert_eq!(view["section"], "global-security"); assert_eq!(view["section"], "global-security");
assert_eq!(view["value"]["user_agent_blocking"]["patterns"][0], "bad-bot"); assert_eq!(
view["value"]["user_agent_blocking"]["patterns"][0],
"bad-bot"
);
assert!(view.get("config").is_none()); assert!(view.get("config").is_none());
assert!(view["value"].get("global_security").is_none()); assert!(view["value"].get("global_security").is_none());
} }
+18 -4
View File
@@ -1505,8 +1505,22 @@ html[data-table-density=ultra] :is(table th, table td) {
gap: 6px; gap: 6px;
max-width: 260px; max-width: 260px;
} }
/* Permission guidance belongs to its generated command block, not the path inputs. */ /* Permission guidance belongs to its generated command block, not the path inputs. */
#certificate-external-acl-example.path-permissions { margin-top: 20px; border-top: 1px solid var(--line); padding-top: 15px; } #certificate-external-acl-example.path-permissions {
.path-permissions-commands { margin-bottom: 0; } margin-top: 20px;
.path-permissions-commands + .path-permissions-description { margin: 7px 0 0; } border-top: 1px solid var(--line);
.path-permissions-actions { margin-top: 10px; } padding-top: 15px;
}
.path-permissions-commands {
margin-bottom: 0;
}
.path-permissions-commands+.path-permissions-description {
margin: 7px 0 0;
}
.path-permissions-actions {
margin-top: 10px;
}
+1 -1
View File
@@ -770,4 +770,4 @@ body[data-density=compact] .page-intro .lead {
to { to {
visibility: visible; visibility: visible;
} }
} }
+10 -11
View File
@@ -231,8 +231,12 @@ fn derive_key(password: &str, salt: &[u8]) -> Result<Zeroizing<[u8; 32]>> {
fn new_crypto(password: &str) -> Result<CryptoContext> { fn new_crypto(password: &str) -> Result<CryptoContext> {
let mut salt = [0u8; 16]; let mut salt = [0u8; 16];
let mut nonce_prefix = [0u8; 4]; let mut nonce_prefix = [0u8; 4];
SysRng.try_fill_bytes(&mut salt).expect("secure operating system random source unavailable"); SysRng
SysRng.try_fill_bytes(&mut nonce_prefix).expect("secure operating system random source unavailable"); .try_fill_bytes(&mut salt)
.expect("secure operating system random source unavailable");
SysRng
.try_fill_bytes(&mut nonce_prefix)
.expect("secure operating system random source unavailable");
let header = CryptoHeader { let header = CryptoHeader {
format_version: FORMAT_VERSION, format_version: FORMAT_VERSION,
kdf: "Argon2id".into(), kdf: "Argon2id".into(),
@@ -395,7 +399,9 @@ fn decrypt_archive(input: &Path, output: &Path, password: &str) -> Result<Zeroiz
fn seal_master_key(master: &[u8], backup_key: &[u8]) -> Result<Vec<u8>> { fn seal_master_key(master: &[u8], backup_key: &[u8]) -> Result<Vec<u8>> {
ensure!(master.len() == 32, "invalid secret store master key"); ensure!(master.len() == 32, "invalid secret store master key");
let mut nonce_bytes = [0u8; 12]; let mut nonce_bytes = [0u8; 12];
SysRng.try_fill_bytes(&mut nonce_bytes).expect("secure operating system random source unavailable"); SysRng
.try_fill_bytes(&mut nonce_bytes)
.expect("secure operating system random source unavailable");
let cipher = cipher(backup_key)?; let cipher = cipher(backup_key)?;
let mut encrypted = Zeroizing::new(master.to_vec()); let mut encrypted = Zeroizing::new(master.to_vec());
cipher cipher
@@ -462,13 +468,6 @@ fn hash_bytes(bytes: &[u8]) -> String {
fn excluded_directory(name: &str) -> bool { fn excluded_directory(name: &str) -> bool {
matches!( matches!(
name, name,
"cache" "cache" | "logs" | "tmp" | "temp" | "work" | "acme-http01" | "acme-python" | "acme_python"
| "logs"
| "tmp"
| "temp"
| "work"
| "acme-http01"
| "acme-python"
| "acme_python"
) )
} }
+6 -3
View File
@@ -14,7 +14,9 @@ mod tests {
let crypto = new_crypto("correct horse battery staple").unwrap(); let crypto = new_crypto("correct horse battery staple").unwrap();
encrypt_archive(&input, &encrypted, &crypto).unwrap(); encrypt_archive(&input, &encrypted, &crypto).unwrap();
let error = decrypt_archive(&encrypted, &wrong_output, "wrong password").unwrap_err(); let error = decrypt_archive(&encrypted, &wrong_output, "wrong password").unwrap_err();
assert!(error.to_string().contains("incorrect backup password or corrupted archive")); assert!(error
.to_string()
.contains("incorrect backup password or corrupted archive"));
decrypt_archive(&encrypted, &output, "correct horse battery staple").unwrap(); decrypt_archive(&encrypted, &output, "correct horse battery staple").unwrap();
assert_eq!(fs::read(&output).unwrap(), b"private backup payload"); assert_eq!(fs::read(&output).unwrap(), b"private backup payload");
@@ -24,7 +26,9 @@ mod tests {
fs::write(&tampered, bytes).unwrap(); fs::write(&tampered, bytes).unwrap();
let error = decrypt_archive(&tampered, &tampered_output, "correct horse battery staple") let error = decrypt_archive(&tampered, &tampered_output, "correct horse battery staple")
.unwrap_err(); .unwrap_err();
assert!(error.to_string().contains("incorrect backup password or corrupted archive")); assert!(error
.to_string()
.contains("incorrect backup password or corrupted archive"));
assert_eq!(fs::read(&output).unwrap(), b"private backup payload"); assert_eq!(fs::read(&output).unwrap(), b"private backup payload");
} }
@@ -98,7 +102,6 @@ mod tests {
assert_eq!(fs::read(restored_link).unwrap(), b"certificate-v1"); assert_eq!(fs::read(restored_link).unwrap(), b"certificate-v1");
} }
#[cfg(unix)] #[cfg(unix)]
#[test] #[test]
fn restore_owner_is_applied_recursively() { fn restore_owner_is_applied_recursively() {
+3 -1
View File
@@ -426,6 +426,8 @@ pub async fn restore(archive: &Path, data_dir: &Path, password: &str) -> Result<
pub fn generated_password() -> String { pub fn generated_password() -> String {
let mut bytes = [0u8; 32]; let mut bytes = [0u8; 32];
SysRng.try_fill_bytes(&mut bytes).expect("secure operating system random source unavailable"); SysRng
.try_fill_bytes(&mut bytes)
.expect("secure operating system random source unavailable");
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
} }
+60 -53
View File
@@ -70,9 +70,13 @@ impl Cache {
continue; continue;
}; };
let body_path = shard.join(&file.body_file); let body_path = shard.join(&file.body_file);
let expected_scope_revision = self.scope_revisions let expected_scope_revision = self
.lock().unwrap_or_else(|e| e.into_inner()) .scope_revisions
.get(&file.scope).copied().unwrap_or(0); .lock()
.unwrap_or_else(|e| e.into_inner())
.get(&file.scope)
.copied()
.unwrap_or(0);
if file.format != COLD_FORMAT_VERSION if file.format != COLD_FORMAT_VERSION
|| file.revision != revision || file.revision != revision
|| file.scope_revision != expected_scope_revision || file.scope_revision != expected_scope_revision
@@ -142,57 +146,60 @@ impl Cache {
let _ = std::fs::remove_file(&body_path); let _ = std::fs::remove_file(&body_path);
continue; continue;
} }
loaded.push((key, ColdEntry { loaded.push((
base_key, key,
vary, ColdEntry {
revision, base_key,
scope: file.scope, vary,
scheme: file.scheme, revision,
authority: if file.authority.is_empty() { scope: file.scope,
file.domain.clone() scheme: file.scheme,
} else { authority: if file.authority.is_empty() {
file.authority file.domain.clone()
} else {
file.authority
},
domain: file.domain,
path: file.path.clone(),
target: if file.target.is_empty() {
file.path
} else {
file.target
},
tags: file.tags,
created_at: if file.created_at_ms == 0 {
stored_at
} else {
system_time_ms(file.created_at_ms)
},
body_path,
meta_path: path,
body_bytes: file.body_bytes,
body_sha256,
disk_bytes: body_meta.len().saturating_add(bytes.len() as u64),
stored_at,
last_access: if file.last_access_ms == 0 {
stored_at
} else {
system_time_ms(file.last_access_ms)
},
age_at_store_seconds: file.age_at_store_seconds,
resident_age_at_store_seconds: file.resident_age_at_store_seconds,
ttl: file.ttl,
stale_while_revalidate: file.stale_while_revalidate,
stale_if_error: file.stale_if_error,
must_revalidate: file.must_revalidate,
proxy_revalidate: file.proxy_revalidate,
has_validator: origin_etag.is_some() || origin_last_modified.is_some(),
hits: file.hits,
transformed: file.transformed,
sequence: 0,
generation: 0,
skip_compression: file.skip_compression,
requires_revalidation: file.requires_revalidation,
pinned_reads: 0,
}, },
domain: file.domain, ));
path: file.path.clone(),
target: if file.target.is_empty() {
file.path
} else {
file.target
},
tags: file.tags,
created_at: if file.created_at_ms == 0 {
stored_at
} else {
system_time_ms(file.created_at_ms)
},
body_path,
meta_path: path,
body_bytes: file.body_bytes,
body_sha256,
disk_bytes: body_meta.len().saturating_add(bytes.len() as u64),
stored_at,
last_access: if file.last_access_ms == 0 {
stored_at
} else {
system_time_ms(file.last_access_ms)
},
age_at_store_seconds: file.age_at_store_seconds,
resident_age_at_store_seconds: file.resident_age_at_store_seconds,
ttl: file.ttl,
stale_while_revalidate: file.stale_while_revalidate,
stale_if_error: file.stale_if_error,
must_revalidate: file.must_revalidate,
proxy_revalidate: file.proxy_revalidate,
has_validator: origin_etag.is_some() || origin_last_modified.is_some(),
hits: file.hits,
transformed: file.transformed,
sequence: 0,
generation: 0,
skip_compression: file.skip_compression,
requires_revalidation: file.requires_revalidation,
pinned_reads: 0,
}));
} }
} }
loaded.sort_by_key(|(_, entry)| entry.last_access); loaded.sort_by_key(|(_, entry)| entry.last_access);
+48 -14
View File
@@ -427,7 +427,12 @@ mod dashboard_tests {
); );
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
settings.annotate_reason_transformed_freshness( settings.annotate_reason_transformed_freshness(
&mut headers, "HIT", 3, -7, "cache_max_stale", false, &mut headers,
"HIT",
3,
-7,
"cache_max_stale",
false,
); );
assert_eq!( assert_eq!(
headers["cache-status"], headers["cache-status"],
@@ -435,7 +440,12 @@ mod dashboard_tests {
); );
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
settings.annotate_reason_transformed_freshness( settings.annotate_reason_transformed_freshness(
&mut headers, "HIT", 3, -5, "cache_stale_while_revalidate", false, &mut headers,
"HIT",
3,
-5,
"cache_stale_while_revalidate",
false,
); );
assert_eq!( assert_eq!(
headers["cache-status"], headers["cache-status"],
@@ -453,20 +463,38 @@ mod dashboard_tests {
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
headers.append("cache-status", HeaderValue::from_static("OriginCache; hit")); headers.append("cache-status", HeaderValue::from_static("OriginCache; hit"));
settings.annotate_reason_transformed_forwarded( settings.annotate_reason_transformed_forwarded(
&mut headers, "HIT", 3, 0, "cache_stale_if_error", false, &mut headers,
"stale", Some(StatusCode::SERVICE_UNAVAILABLE), "HIT",
3,
0,
"cache_stale_if_error",
false,
"stale",
Some(StatusCode::SERVICE_UNAVAILABLE),
); );
assert_eq!(headers["x-cache"], "HIT"); assert_eq!(headers["x-cache"], "HIT");
let values: Vec<_> = headers.get_all("cache-status").iter() let values: Vec<_> = headers
.map(|value| value.to_str().unwrap()).collect(); .get_all("cache-status")
assert_eq!(values, vec![ .iter()
"OriginCache; hit", .map(|value| value.to_str().unwrap())
"RustProxy; fwd=stale; fwd-status=503; detail=\"cache_stale_if_error\"", .collect();
]); assert_eq!(
values,
vec![
"OriginCache; hit",
"RustProxy; fwd=stale; fwd-status=503; detail=\"cache_stale_if_error\"",
]
);
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
settings.annotate_reason_transformed_forwarded( settings.annotate_reason_transformed_forwarded(
&mut headers, "HIT", 2, 42, "cache_revalidated", false, &mut headers,
"request", Some(StatusCode::NOT_MODIFIED), "HIT",
2,
42,
"cache_revalidated",
false,
"request",
Some(StatusCode::NOT_MODIFIED),
); );
assert_eq!( assert_eq!(
headers["cache-status"], headers["cache-status"],
@@ -474,8 +502,14 @@ mod dashboard_tests {
); );
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
settings.annotate_reason_transformed_forwarded( settings.annotate_reason_transformed_forwarded(
&mut headers, "HIT", 2, 0, "cache_stale_if_error", false, &mut headers,
"stale", None, "HIT",
2,
0,
"cache_stale_if_error",
false,
"stale",
None,
); );
assert_eq!( assert_eq!(
headers["cache-status"], headers["cache-status"],
+30 -9
View File
@@ -182,7 +182,15 @@ impl Settings {
ttl: u64, ttl: u64,
reason: &'static str, reason: &'static str,
) { ) {
self.annotate_internal(headers, outcome, hits, ttl as i64, Some(reason), false, None); self.annotate_internal(
headers,
outcome,
hits,
ttl as i64,
Some(reason),
false,
None,
);
} }
pub fn annotate_reason_transformed( pub fn annotate_reason_transformed(
@@ -194,7 +202,15 @@ impl Settings {
reason: &'static str, reason: &'static str,
transformed: bool, transformed: bool,
) { ) {
self.annotate_internal(headers, outcome, hits, ttl as i64, Some(reason), transformed, None); self.annotate_internal(
headers,
outcome,
hits,
ttl as i64,
Some(reason),
transformed,
None,
);
} }
pub fn annotate_reason_transformed_freshness( pub fn annotate_reason_transformed_freshness(
@@ -207,12 +223,16 @@ impl Settings {
transformed: bool, transformed: bool,
) { ) {
self.annotate_internal( self.annotate_internal(
headers, outcome, hits, freshness_ttl, Some(reason), transformed, None, headers,
outcome,
hits,
freshness_ttl,
Some(reason),
transformed,
None,
); );
} }
// RFC 9211 distinguishes whether this request was forwarded from whether
// the legacy X-Cache metric counted the reused representation as a HIT.
pub fn annotate_reason_transformed_forwarded( pub fn annotate_reason_transformed_forwarded(
&self, &self,
headers: &mut HeaderMap, headers: &mut HeaderMap,
@@ -285,8 +305,11 @@ impl Settings {
} }
if self.expose_cache_status { if self.expose_cache_status {
let forwarded = forwarded.or_else(|| { let forwarded = forwarded.or_else(|| {
matches!(reason, Some("cache_revalidated" | "cache_revalidated_uncached")) matches!(
.then_some(("stale", Some(StatusCode::NOT_MODIFIED))) reason,
Some("cache_revalidated" | "cache_revalidated_uncached")
)
.then_some(("stale", Some(StatusCode::NOT_MODIFIED)))
}); });
let mut value = if let Some((why, status)) = forwarded { let mut value = if let Some((why, status)) = forwarded {
let mut value = format!("RustProxy; fwd={why}"); let mut value = format!("RustProxy; fwd={why}");
@@ -296,8 +319,6 @@ impl Settings {
value value
} else { } else {
match outcome { match outcome {
// RFC 9211 permits a negative ttl when stale data is served
// without sending the current request to the origin.
"HIT" => format!("RustProxy; hit; ttl={ttl}"), "HIT" => format!("RustProxy; hit; ttl={ttl}"),
"MISS" => "RustProxy; fwd=miss".to_owned(), "MISS" => "RustProxy; fwd=miss".to_owned(),
"BYPASS" if reason == Some("request_method") => { "BYPASS" if reason == Some("request_method") => {
+1 -1
View File
@@ -126,7 +126,7 @@ impl Drop for ColdReadGuard<'_> {
} }
} }
} }
// RFC 9211 uses signed structured-field integers for freshness (negative when stale).
fn cache_status_ttl(ttl: u64, age: u64) -> i64 { fn cache_status_ttl(ttl: u64, age: u64) -> i64 {
const MAX: i128 = 999_999_999_999_999; const MAX: i128 = 999_999_999_999_999;
(i128::from(ttl) - i128::from(age)).clamp(-MAX, MAX) as i64 (i128::from(ttl) - i128::from(age)).clamp(-MAX, MAX) as i64
-2
View File
@@ -87,9 +87,7 @@ pub enum ColdPreset {
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
pub struct Settings { pub struct Settings {
pub enabled: bool, pub enabled: bool,
/// Ignore client freshness directives on cache lookups, like Varnish builtin VCL.
pub varnish_compatible: bool, pub varnish_compatible: bool,
/// Implicit grace in seconds for eligible fresh-at-storage cached responses.
pub varnish_grace_seconds: u64, pub varnish_grace_seconds: u64,
pub memory_preset: Option<MemoryPreset>, pub memory_preset: Option<MemoryPreset>,
pub max_bytes: usize, pub max_bytes: usize,
+1 -2
View File
@@ -133,8 +133,7 @@ fn stale_mode(
} }
let stale_for = age.saturating_sub(ttl); let stale_for = age.saturating_sub(ttl);
let max_stale = client_max_stale(request_headers); let max_stale = client_max_stale(request_headers);
// An explicit client limit must also constrain implicit Varnish grace.
// In particular, max-stale=0 must not serve a stale representation.
if matches!(max_stale, Some(Some(limit)) if stale_for > limit) { if matches!(max_stale, Some(Some(limit)) if stale_for > limit) {
return None; return None;
} }
+32 -24
View File
@@ -44,9 +44,7 @@ fn directives(headers: &HeaderMap) -> Option<HashMap<String, Option<String>>> {
} }
Some(result) Some(result)
} }
/// Keep all client cache restrictions except the hard-reload directives that
/// Varnish normally disregards for a fresh shared object. Never mutate the
/// actual request (it is still forwarded to the origin and used for Vary).
fn compatible_freshness_headers<'a>( fn compatible_freshness_headers<'a>(
headers: &'a HeaderMap, headers: &'a HeaderMap,
compatible: bool, compatible: bool,
@@ -54,29 +52,31 @@ fn compatible_freshness_headers<'a>(
if !compatible || (!headers.contains_key("cache-control") && !headers.contains_key("pragma")) { if !compatible || (!headers.contains_key("cache-control") && !headers.contains_key("pragma")) {
return std::borrow::Cow::Borrowed(headers); return std::borrow::Cow::Borrowed(headers);
} }
// An invalid cache-control header cannot be made valid by filtering it.
// Also avoid cloning the full map for ordinary requests with no reload
// directives, including min-fresh, max-stale and only-if-cached.
let Some(control) = directives(headers) else { let Some(control) = directives(headers) else {
return std::borrow::Cow::Borrowed(headers); return std::borrow::Cow::Borrowed(headers);
}; };
let remove_reload = control.contains_key("no-cache") let remove_reload = control.contains_key("no-cache")
|| control.get("max-age").and_then(|value| value.as_deref()) || control
.and_then(|value| value.parse::<u64>().ok()) == Some(0); .get("max-age")
.and_then(|value| value.as_deref())
.and_then(|value| value.parse::<u64>().ok())
== Some(0);
if !remove_reload && !pragma_no_cache(headers) { if !remove_reload && !pragma_no_cache(headers) {
return std::borrow::Cow::Borrowed(headers); return std::borrow::Cow::Borrowed(headers);
} }
let mut freshness = headers.clone(); let mut freshness = headers.clone();
let mut changed = false; let mut changed = false;
if pragma_no_cache(headers) { if pragma_no_cache(headers) {
// Pragma only affects freshness through its `no-cache` token in this
// implementation. Preserve other tokens, if any.
freshness.remove("pragma"); freshness.remove("pragma");
for header in headers.get_all("pragma") { for header in headers.get_all("pragma") {
let Ok(text) = header.to_str() else { let Ok(text) = header.to_str() else {
return std::borrow::Cow::Borrowed(headers); return std::borrow::Cow::Borrowed(headers);
}; };
let retained = text.split(',') let retained = text
.split(',')
.map(str::trim) .map(str::trim)
.filter(|token| !token.eq_ignore_ascii_case("no-cache")) .filter(|token| !token.eq_ignore_ascii_case("no-cache"))
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@@ -95,15 +95,21 @@ fn compatible_freshness_headers<'a>(
let Ok(text) = header.to_str() else { let Ok(text) = header.to_str() else {
return std::borrow::Cow::Borrowed(headers); return std::borrow::Cow::Borrowed(headers);
}; };
let retained = text.split(',').map(str::trim).filter(|token| { let retained = text
let (name, value) = token.split_once('=').unwrap_or((token, "")); .split(',')
let name = name.trim(); .map(str::trim)
let skip = name.eq_ignore_ascii_case("no-cache") .filter(|token| {
|| (name.eq_ignore_ascii_case("max-age") let (name, value) = token.split_once('=').unwrap_or((token, ""));
&& value.trim().trim_matches('"').parse::<u64>() == Ok(0)); let name = name.trim();
if skip { changed = true; } let skip = name.eq_ignore_ascii_case("no-cache")
!skip || (name.eq_ignore_ascii_case("max-age")
}).collect::<Vec<_>>(); && value.trim().trim_matches('"').parse::<u64>() == Ok(0));
if skip {
changed = true;
}
!skip
})
.collect::<Vec<_>>();
if !retained.is_empty() { if !retained.is_empty() {
let Ok(value) = HeaderValue::from_str(&retained.join(", ")) else { let Ok(value) = HeaderValue::from_str(&retained.join(", ")) else {
return std::borrow::Cow::Borrowed(headers); return std::borrow::Cow::Borrowed(headers);
@@ -118,8 +124,11 @@ fn compatible_freshness_headers<'a>(
} }
} }
} }
if changed { std::borrow::Cow::Owned(freshness) } if changed {
else { std::borrow::Cow::Borrowed(headers) } std::borrow::Cow::Owned(freshness)
} else {
std::borrow::Cow::Borrowed(headers)
}
} }
pub fn request_freshness_headers<'a>( pub fn request_freshness_headers<'a>(
@@ -243,8 +252,7 @@ pub fn request_policy(
return RequestPolicy::Bypass(reason); return RequestPolicy::Bypass(reason);
} }
} }
// Compatibility only relaxes browser hard-reload freshness requests.
// Retain no-store, only-if-cached, min-fresh and max-stale semantics.
let Some(control) = directives(headers) else { let Some(control) = directives(headers) else {
return RequestPolicy::Bypass("request_cache_control_invalid"); return RequestPolicy::Bypass("request_cache_control_invalid");
}; };
+17 -18
View File
@@ -127,24 +127,23 @@ pub fn plan(
Some(v) => httpdate::parse_http_date(v.to_str().ok()?).ok()?, Some(v) => httpdate::parse_http_date(v.to_str().ok()?).ok()?,
None => now, None => now,
}; };
let declared_ttl = let declared_ttl = if override_public_no_cache {
if override_public_no_cache { Some(settings.default_ttl_seconds)
Some(settings.default_ttl_seconds) } else if let Some(value) = control.get("s-maxage").or_else(|| control.get("max-age")) {
} else if let Some(value) = control.get("s-maxage").or_else(|| control.get("max-age")) { Some(value.as_ref()?.parse::<u64>().ok()?)
Some(value.as_ref()?.parse::<u64>().ok()?) } else if let Some(expires) = headers.get("expires") {
} else if let Some(expires) = headers.get("expires") { Some(
Some( httpdate::parse_http_date(expires.to_str().ok()?)
httpdate::parse_http_date(expires.to_str().ok()?) .ok()?
.ok()? .duration_since(date)
.duration_since(date) .ok()?
.ok()? .as_secs(),
.as_secs(), )
) } else if control.contains_key("public") {
} else if control.contains_key("public") { Some(settings.default_ttl_seconds)
Some(settings.default_ttl_seconds) } else {
} else { None
None };
};
let age = match headers.get("age") { let age = match headers.get("age") {
Some(v) => v.to_str().ok()?.parse::<u64>().ok()?, Some(v) => v.to_str().ok()?.parse::<u64>().ok()?,
None => 0, None => 0,
+40 -13
View File
@@ -58,14 +58,29 @@ mod scope_tests {
fn stale_snapshot_cannot_issue_cache_ticket_after_config_commit() { fn stale_snapshot_cannot_issue_cache_ticket_after_config_commit() {
let cache = Cache::new(1); let cache = Cache::new(1);
cache.set_control_revision(7); cache.set_control_revision(7);
let ticket = |revision| cache.ticket_scoped_for_config( let ticket = |revision| {
revision, 1, "vhost-a", "http", "same.test", "main", "/resource", &HeaderMap::new() cache.ticket_scoped_for_config(
); revision,
1,
"vhost-a",
"http",
"same.test",
"main",
"/resource",
&HeaderMap::new(),
)
};
assert!(ticket(7).is_some(), "the current snapshot can cache"); assert!(ticket(7).is_some(), "the current snapshot can cache");
cache.set_control_revision(8); cache.set_control_revision(8);
assert_eq!(cache.revision(), 1); assert_eq!(cache.revision(), 1);
assert!(ticket(7).is_none(), "late request from the previous snapshot must bypass"); assert!(
assert!(ticket(8).is_some(), "the new snapshot can reuse the same cache identity"); ticket(7).is_none(),
"late request from the previous snapshot must bypass"
);
assert!(
ticket(8).is_some(),
"the new snapshot can reuse the same cache identity"
);
} }
#[test] #[test]
@@ -82,18 +97,30 @@ mod scope_tests {
assert!(effective.enabled && effective.expose_status); assert!(effective.enabled && effective.expose_status);
let mut globally_disabled = global.clone(); let mut globally_disabled = global.clone();
globally_disabled.enabled = false; globally_disabled.enabled = false;
assert!(!Overrides { enabled: Some(true), ..Overrides::default() } assert!(
!Overrides {
enabled: Some(true),
..Overrides::default()
}
.resolve(&globally_disabled) .resolve(&globally_disabled)
.unwrap() .unwrap()
.enabled); .enabled
assert!(!Overrides { enabled: Some(false), ..Overrides::default() } );
assert!(
!Overrides {
enabled: Some(false),
..Overrides::default()
}
.resolve(&global) .resolve(&global)
.unwrap() .unwrap()
.enabled); .enabled
assert!(!Overrides::default() );
.resolve(&globally_disabled) assert!(
.unwrap() !Overrides::default()
.enabled); .resolve(&globally_disabled)
.unwrap()
.enabled
);
assert_eq!(effective.max_bytes, global.max_bytes); assert_eq!(effective.max_bytes, global.max_bytes);
assert_eq!(effective.max_ttl_seconds, 30); assert_eq!(effective.max_ttl_seconds, 30);
assert!(Overrides { assert!(Overrides {
+3 -1
View File
@@ -408,6 +408,8 @@ impl Ticket {
} }
fn freshness_headers(&self) -> &HeaderMap { fn freshness_headers(&self) -> &HeaderMap {
self.freshness_headers.as_ref().unwrap_or(&self.request_headers) self.freshness_headers
.as_ref()
.unwrap_or(&self.request_headers)
} }
} }
+551 -85
View File
@@ -4,7 +4,10 @@ mod varnish_compatible_tests {
fn headers(cache_control: &str) -> HeaderMap { fn headers(cache_control: &str) -> HeaderMap {
let mut h = HeaderMap::new(); let mut h = HeaderMap::new();
h.insert("cache-control", HeaderValue::from_str(cache_control).unwrap()); h.insert(
"cache-control",
HeaderValue::from_str(cache_control).unwrap(),
);
h h
} }
@@ -24,7 +27,10 @@ mod varnish_compatible_tests {
assert_eq!(customized.varnish_grace_seconds, 17); assert_eq!(customized.varnish_grace_seconds, 17);
let mut invalid = customized.clone(); let mut invalid = customized.clone();
invalid.varnish_grace_seconds = 604_801; invalid.varnish_grace_seconds = 604_801;
assert!(invalid.validate().is_err(), "oversized grace must be rejected"); assert!(
invalid.validate().is_err(),
"oversized grace must be rejected"
);
} }
#[test] #[test]
@@ -32,18 +38,58 @@ mod varnish_compatible_tests {
let mut compatible = Settings::default(); let mut compatible = Settings::default();
for value in ["no-cache", "max-age=0", "max-age=\"0\"", "max-age=000"] { for value in ["no-cache", "max-age=0", "max-age=\"0\"", "max-age=000"] {
let h = headers(value); let h = headers(value);
assert_eq!(request_policy(&Method::GET, &h, true, "/style.css", false, &compatible), RequestPolicy::Use); assert_eq!(
request_policy(&Method::GET, &h, true, "/style.css", false, &compatible),
RequestPolicy::Use
);
let freshness = request_freshness_headers(&h, &compatible); let freshness = request_freshness_headers(&h, &compatible);
assert!(!freshness.contains_key("cache-control"), "hard reload must not constrain lookup: {value}"); assert!(
assert!(h.contains_key("cache-control"), "origin request and Vary must remain unchanged"); !freshness.contains_key("cache-control"),
"hard reload must not constrain lookup: {value}"
);
assert!(
h.contains_key("cache-control"),
"origin request and Vary must remain unchanged"
);
} }
for value in ["max-age=1", "min-fresh=600", "max-stale=9999", "only-if-cached"] { for value in [
"max-age=1",
"min-fresh=600",
"max-stale=9999",
"only-if-cached",
] {
let h = headers(value); let h = headers(value);
assert_eq!(request_policy(&Method::GET, &h, true, "/style.css", false, &compatible), RequestPolicy::Use); assert_eq!(
assert!(request_freshness_headers(&h, &compatible).contains_key("cache-control"), "preserve {value}"); request_policy(&Method::GET, &h, true, "/style.css", false, &compatible),
RequestPolicy::Use
);
assert!(
request_freshness_headers(&h, &compatible).contains_key("cache-control"),
"preserve {value}"
);
} }
assert_eq!(request_policy(&Method::GET, &headers("no-store"), true, "/", false, &compatible), RequestPolicy::Bypass("request_no_store")); assert_eq!(
assert_eq!(request_policy(&Method::GET, &headers("invalid directive"), true, "/", false, &compatible), RequestPolicy::Bypass("request_cache_control_invalid")); request_policy(
&Method::GET,
&headers("no-store"),
true,
"/",
false,
&compatible
),
RequestPolicy::Bypass("request_no_store")
);
assert_eq!(
request_policy(
&Method::GET,
&headers("invalid directive"),
true,
"/",
false,
&compatible
),
RequestPolicy::Bypass("request_cache_control_invalid")
);
assert!(only_if_cached(&headers("only-if-cached"))); assert!(only_if_cached(&headers("only-if-cached")));
let h = headers("no-cache, max-age=0, min-fresh=45, max-stale=90, only-if-cached"); let h = headers("no-cache, max-age=0, min-fresh=45, max-stale=90, only-if-cached");
let freshness = request_freshness_headers(&h, &compatible); let freshness = request_freshness_headers(&h, &compatible);
@@ -55,12 +101,48 @@ mod varnish_compatible_tests {
assert!(parsed.contains_key("only-if-cached")); assert!(parsed.contains_key("only-if-cached"));
let mut pragma = HeaderMap::new(); let mut pragma = HeaderMap::new();
pragma.insert("pragma", HeaderValue::from_static("no-cache, custom-token")); pragma.insert("pragma", HeaderValue::from_static("no-cache, custom-token"));
assert_eq!(request_policy(&Method::GET, &pragma, true, "/", false, &compatible), RequestPolicy::Use); assert_eq!(
assert_eq!(request_freshness_headers(&pragma, &compatible)["pragma"], "custom-token"); request_policy(&Method::GET, &pragma, true, "/", false, &compatible),
RequestPolicy::Use
);
assert_eq!(
request_freshness_headers(&pragma, &compatible)["pragma"],
"custom-token"
);
compatible.varnish_compatible = false; compatible.varnish_compatible = false;
assert_eq!(request_policy(&Method::GET, &headers("no-cache"), true, "/", false, &compatible), RequestPolicy::Revalidate("request_no_cache")); assert_eq!(
assert_eq!(request_policy(&Method::GET, &headers("max-age=0"), true, "/", false, &compatible), RequestPolicy::Revalidate("request_max_age")); request_policy(
assert_eq!(request_policy(&Method::GET, &headers("no-store"), true, "/", false, &compatible), RequestPolicy::Bypass("request_no_store")); &Method::GET,
&headers("no-cache"),
true,
"/",
false,
&compatible
),
RequestPolicy::Revalidate("request_no_cache")
);
assert_eq!(
request_policy(
&Method::GET,
&headers("max-age=0"),
true,
"/",
false,
&compatible
),
RequestPolicy::Revalidate("request_max_age")
);
assert_eq!(
request_policy(
&Method::GET,
&headers("no-store"),
true,
"/",
false,
&compatible
),
RequestPolicy::Bypass("request_no_store")
);
} }
#[test] #[test]
@@ -73,16 +155,55 @@ mod varnish_compatible_tests {
("upgrade", "websocket"), ("upgrade", "websocket"),
] { ] {
let mut h = headers("no-cache"); let mut h = headers("no-cache");
h.insert(http::HeaderName::from_bytes(name.as_bytes()).unwrap(), HeaderValue::from_str(value).unwrap()); h.insert(
assert!(matches!(request_policy(&Method::GET, &h, true, "/index.html", false, &settings), RequestPolicy::Bypass(_))); http::HeaderName::from_bytes(name.as_bytes()).unwrap(),
HeaderValue::from_str(value).unwrap(),
);
assert!(matches!(
request_policy(&Method::GET, &h, true, "/index.html", false, &settings),
RequestPolicy::Bypass(_)
));
} }
settings.bypass_query = true; settings.bypass_query = true;
assert_eq!(request_policy(&Method::GET, &HeaderMap::new(), true, "/style.css", true, &settings), RequestPolicy::Bypass("query_excluded")); assert_eq!(
request_policy(
&Method::GET,
&HeaderMap::new(),
true,
"/style.css",
true,
&settings
),
RequestPolicy::Bypass("query_excluded")
);
settings.bypass_query = false; settings.bypass_query = false;
settings.bypass_prefixes = vec!["/private".into()]; settings.bypass_prefixes = vec!["/private".into()];
assert_eq!(request_policy(&Method::GET, &HeaderMap::new(), true, "/private/account", false, &settings), RequestPolicy::Bypass("path_excluded")); assert_eq!(
assert_eq!(request_policy(&Method::POST, &HeaderMap::new(), true, "/style.css", false, &settings), RequestPolicy::Bypass("request_method")); request_policy(
assert!(settings.ignore_cookie_for_path("/style.css"), "existing static cookie feature remains enabled"); &Method::GET,
&HeaderMap::new(),
true,
"/private/account",
false,
&settings
),
RequestPolicy::Bypass("path_excluded")
);
assert_eq!(
request_policy(
&Method::POST,
&HeaderMap::new(),
true,
"/style.css",
false,
&settings
),
RequestPolicy::Bypass("request_method")
);
assert!(
settings.ignore_cookie_for_path("/style.css"),
"existing static cookie feature remains enabled"
);
assert!(!settings.ignore_cookie_for_path("/index.html")); assert!(!settings.ignore_cookie_for_path("/index.html"));
} }
@@ -92,7 +213,13 @@ mod varnish_compatible_tests {
let mut strict = compatible.clone(); let mut strict = compatible.clone();
strict.varnish_compatible = false; strict.varnish_compatible = false;
for control in ["private, max-age=60", "no-store, max-age=60"] { for control in ["private, max-age=60", "no-store, max-age=60"] {
assert!(plan(StatusCode::OK, &headers(control), &compatible, Duration::ZERO).is_none()); assert!(plan(
StatusCode::OK,
&headers(control),
&compatible,
Duration::ZERO
)
.is_none());
assert!(plan(StatusCode::OK, &headers(control), &strict, Duration::ZERO).is_none()); assert!(plan(StatusCode::OK, &headers(control), &strict, Duration::ZERO).is_none());
} }
let mut no_cache = headers("no-cache, max-age=60"); let mut no_cache = headers("no-cache, max-age=60");
@@ -101,7 +228,13 @@ mod varnish_compatible_tests {
.expect("no-cache with ETag remains revalidatable without fallback"); .expect("no-cache with ETag remains revalidatable without fallback");
assert!(validator_plan.requires_revalidation); assert!(validator_plan.requires_revalidation);
assert!(plan(StatusCode::OK, &no_cache, &strict, Duration::ZERO).is_some()); assert!(plan(StatusCode::OK, &no_cache, &strict, Duration::ZERO).is_some());
assert!(plan(StatusCode::OK, &headers("no-cache, max-age=60"), &compatible, Duration::ZERO).is_none()); assert!(plan(
StatusCode::OK,
&headers("no-cache, max-age=60"),
&compatible,
Duration::ZERO
)
.is_none());
let mut fallback = compatible.clone(); let mut fallback = compatible.clone();
fallback.default_ttl_seconds = 42; fallback.default_ttl_seconds = 42;
@@ -111,21 +244,48 @@ mod varnish_compatible_tests {
assert_eq!(fallback_plan.ttl, 42); assert_eq!(fallback_plan.ttl, 42);
assert!(!fallback_plan.requires_revalidation); assert!(!fallback_plan.requires_revalidation);
let nonpublic_no_cache = headers("no-cache, max-age=60"); let nonpublic_no_cache = headers("no-cache, max-age=60");
assert!(plan(StatusCode::OK, &nonpublic_no_cache, &fallback, Duration::ZERO).is_none()); assert!(plan(
StatusCode::OK,
&nonpublic_no_cache,
&fallback,
Duration::ZERO
)
.is_none());
let mut surrogate = public_no_cache.clone(); let mut surrogate = public_no_cache.clone();
surrogate.insert("surrogate-control", HeaderValue::from_static("max-age=120; NO-STORE")); surrogate.insert(
"surrogate-control",
HeaderValue::from_static("max-age=120; NO-STORE"),
);
assert!(plan(StatusCode::OK, &surrogate, &fallback, Duration::ZERO).is_none()); assert!(plan(StatusCode::OK, &surrogate, &fallback, Duration::ZERO).is_none());
assert_eq!(response_reason(StatusCode::OK, &surrogate, &fallback), "response_surrogate_control"); assert_eq!(
assert!(plan(StatusCode::OK, &surrogate, &strict, Duration::ZERO).is_none(), "strict no-cache without ETag remains uncached"); response_reason(StatusCode::OK, &surrogate, &fallback),
"response_surrogate_control"
);
assert!(
plan(StatusCode::OK, &surrogate, &strict, Duration::ZERO).is_none(),
"strict no-cache without ETag remains uncached"
);
let mut private = headers("public, private, no-cache"); let mut private = headers("public, private, no-cache");
private.insert("etag", HeaderValue::from_static("\"secret\"")); private.insert("etag", HeaderValue::from_static("\"secret\""));
assert!(plan(StatusCode::OK, &private, &fallback, Duration::ZERO).is_none()); assert!(plan(StatusCode::OK, &private, &fallback, Duration::ZERO).is_none());
for control in ["max-age=0", "max-age=60, s-maxage=0", "max-age=0, stale-if-error=120"] { for control in [
let p = plan(StatusCode::OK, &headers(control), &compatible, Duration::ZERO) "max-age=0",
.expect("zero TTL is retained for max-stale, validation or stale-if-error"); "max-age=60, s-maxage=0",
"max-age=0, stale-if-error=120",
] {
let p = plan(
StatusCode::OK,
&headers(control),
&compatible,
Duration::ZERO,
)
.expect("zero TTL is retained for max-stale, validation or stale-if-error");
assert_eq!(p.ttl, 0, "{control}"); assert_eq!(p.ttl, 0, "{control}");
assert_eq!(p.stale_while_revalidate, 0, "zero TTL must not receive implicit grace"); assert_eq!(
p.stale_while_revalidate, 0,
"zero TTL must not receive implicit grace"
);
} }
let mut cookie = headers("max-age=60"); let mut cookie = headers("max-age=60");
cookie.insert("set-cookie", HeaderValue::from_static("session=private")); cookie.insert("set-cookie", HeaderValue::from_static("session=private"));
@@ -138,24 +298,66 @@ mod varnish_compatible_tests {
#[test] #[test]
fn explicit_zero_grace_and_stale_if_error_are_preserved() { fn explicit_zero_grace_and_stale_if_error_are_preserved() {
let compatible = Settings::default(); let compatible = Settings::default();
let with_zero = plan(StatusCode::OK, &headers("public, max-age=60, stale-while-revalidate=0"), &compatible, Duration::ZERO).unwrap(); let with_zero = plan(
StatusCode::OK,
&headers("public, max-age=60, stale-while-revalidate=0"),
&compatible,
Duration::ZERO,
)
.unwrap();
assert_eq!(with_zero.stale_while_revalidate, 0); assert_eq!(with_zero.stale_while_revalidate, 0);
let with_grace = plan(StatusCode::OK, &headers("public, max-age=60"), &compatible, Duration::ZERO).unwrap(); let with_grace = plan(
StatusCode::OK,
&headers("public, max-age=60"),
&compatible,
Duration::ZERO,
)
.unwrap();
assert_eq!(with_grace.stale_while_revalidate, 60); assert_eq!(with_grace.stale_while_revalidate, 60);
let mut custom_settings = compatible.clone(); let mut custom_settings = compatible.clone();
custom_settings.varnish_grace_seconds = 25; custom_settings.varnish_grace_seconds = 25;
let with_custom_default = plan(StatusCode::OK, &headers("public, max-age=60"), &custom_settings, Duration::ZERO).unwrap(); let with_custom_default = plan(
StatusCode::OK,
&headers("public, max-age=60"),
&custom_settings,
Duration::ZERO,
)
.unwrap();
assert_eq!(with_custom_default.stale_while_revalidate, 25); assert_eq!(with_custom_default.stale_while_revalidate, 25);
custom_settings.varnish_grace_seconds = 0; custom_settings.varnish_grace_seconds = 0;
let grace_disabled = plan(StatusCode::OK, &headers("public, max-age=60"), &custom_settings, Duration::ZERO).unwrap(); let grace_disabled = plan(
StatusCode::OK,
&headers("public, max-age=60"),
&custom_settings,
Duration::ZERO,
)
.unwrap();
assert_eq!(grace_disabled.stale_while_revalidate, 0); assert_eq!(grace_disabled.stale_while_revalidate, 0);
custom_settings.varnish_compatible = false; custom_settings.varnish_compatible = false;
custom_settings.varnish_grace_seconds = 60; custom_settings.varnish_grace_seconds = 60;
let strict = plan(StatusCode::OK, &headers("public, max-age=60"), &custom_settings, Duration::ZERO).unwrap(); let strict = plan(
StatusCode::OK,
&headers("public, max-age=60"),
&custom_settings,
Duration::ZERO,
)
.unwrap();
assert_eq!(strict.stale_while_revalidate, 0); assert_eq!(strict.stale_while_revalidate, 0);
let with_custom = plan(StatusCode::OK, &headers("public, max-age=60, stale-while-revalidate=30"), &compatible, Duration::ZERO).unwrap(); let with_custom = plan(
StatusCode::OK,
&headers("public, max-age=60, stale-while-revalidate=30"),
&compatible,
Duration::ZERO,
)
.unwrap();
assert_eq!(with_custom.stale_while_revalidate, 30); assert_eq!(with_custom.stale_while_revalidate, 30);
let zero = plan(StatusCode::OK, &headers("public, max-age=0, stale-if-error=120"), &compatible, Duration::ZERO).unwrap(); let zero = plan(
StatusCode::OK,
&headers("public, max-age=0, stale-if-error=120"),
&compatible,
Duration::ZERO,
)
.unwrap();
assert_eq!(zero.ttl, 0); assert_eq!(zero.ttl, 0);
assert_eq!(zero.stale_if_error, 120); assert_eq!(zero.stale_if_error, 120);
assert_eq!(zero.stale_while_revalidate, 0); assert_eq!(zero.stale_while_revalidate, 0);
@@ -165,17 +367,47 @@ mod varnish_compatible_tests {
async fn origin_no_cache_etag_can_still_revalidate_without_fallback() { async fn origin_no_cache_etag_can_still_revalidate_without_fallback() {
let cache = Cache::new(1); let cache = Cache::new(1);
let settings = Settings::default(); let settings = Settings::default();
let ticket = cache.ticket(1, "https", "example.test", "app", "/backend.css", &HeaderMap::new()).unwrap(); let ticket = cache
.ticket(
1,
"https",
"example.test",
"app",
"/backend.css",
&HeaderMap::new(),
)
.unwrap();
let mut response = Response::new(Body::from("etag css")); let mut response = Response::new(Body::from("etag css"));
*response.headers_mut() = headers("public, no-cache, max-age=600"); *response.headers_mut() = headers("public, no-cache, max-age=600");
response.headers_mut().insert("etag", HeaderValue::from_static("\"origin-v1\"")); response
let p = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap(); .headers_mut()
.insert("etag", HeaderValue::from_static("\"origin-v1\""));
let p = plan(
StatusCode::OK,
response.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
assert!(p.requires_revalidation); assert!(p.requires_revalidation);
cache.wrap(response, ticket.clone(), p, &settings).into_body().collect().await.unwrap(); cache
assert!(cache.get(&ticket).is_none(), "backend no-cache cannot produce an unvalidated HIT"); .wrap(response, ticket.clone(), p, &settings)
let stored = cache.revalidation(&ticket).expect("validator and body retained for 304"); .into_body()
.collect()
.await
.unwrap();
assert!(
cache.get(&ticket).is_none(),
"backend no-cache cannot produce an unvalidated HIT"
);
let stored = cache
.revalidation(&ticket)
.expect("validator and body retained for 304");
let mut request = HeaderMap::new(); let mut request = HeaderMap::new();
assert!(stored.apply_validators(&mut request), "conditional request can validate stored object"); assert!(
stored.apply_validators(&mut request),
"conditional request can validate stored object"
);
assert_eq!(request["if-none-match"], "\"origin-v1\""); assert_eq!(request["if-none-match"], "\"origin-v1\"");
} }
@@ -184,14 +416,36 @@ mod varnish_compatible_tests {
let cache = Cache::new(1); let cache = Cache::new(1);
let mut settings = Settings::default(); let mut settings = Settings::default();
settings.default_ttl_seconds = 75; settings.default_ttl_seconds = 75;
let ticket = cache.ticket(1, "https", "example.test", "app", "/backend.css", &HeaderMap::new()).unwrap(); let ticket = cache
.ticket(
1,
"https",
"example.test",
"app",
"/backend.css",
&HeaderMap::new(),
)
.unwrap();
let mut response = Response::new(Body::from("public css")); let mut response = Response::new(Body::from("public css"));
*response.headers_mut() = headers("public, no-cache"); *response.headers_mut() = headers("public, no-cache");
let p = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap(); let p = plan(
StatusCode::OK,
response.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
assert_eq!(p.ttl, 75); assert_eq!(p.ttl, 75);
assert!(!p.requires_revalidation); assert!(!p.requires_revalidation);
cache.wrap(response, ticket.clone(), p, &settings).into_body().collect().await.unwrap(); cache
let hit = cache.get(&ticket).expect("explicit public override allows fresh HIT without ETag"); .wrap(response, ticket.clone(), p, &settings)
.into_body()
.collect()
.await
.unwrap();
let hit = cache
.get(&ticket)
.expect("explicit public override allows fresh HIT without ETag");
assert!(hit.remaining_ttl > 0); assert!(hit.remaining_ttl > 0);
} }
@@ -199,59 +453,189 @@ mod varnish_compatible_tests {
async fn zero_ttl_stale_if_error_fallback_survives_in_varnish_mode() { async fn zero_ttl_stale_if_error_fallback_survives_in_varnish_mode() {
let cache = Cache::new(1); let cache = Cache::new(1);
let settings = Settings::default(); let settings = Settings::default();
let ticket = cache.ticket(1, "https", "example.test", "app", "/fallback.css", &HeaderMap::new()).unwrap(); let ticket = cache
.ticket(
1,
"https",
"example.test",
"app",
"/fallback.css",
&HeaderMap::new(),
)
.unwrap();
let mut response = Response::new(Body::from("public fallback")); let mut response = Response::new(Body::from("public fallback"));
*response.headers_mut() = headers("public, max-age=0, stale-if-error=120"); *response.headers_mut() = headers("public, max-age=0, stale-if-error=120");
let p = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap(); let p = plan(
cache.wrap(response, ticket.clone(), p, &settings).into_body().collect().await.unwrap(); StatusCode::OK,
assert!(cache.get(&ticket).is_none(), "zero TTL must never create a fresh HIT"); response.headers(),
assert!(cache.revalidation(&ticket).unwrap().can_stale_if_error(), "body remains available on backend failure"); &settings,
Duration::ZERO,
)
.unwrap();
cache
.wrap(response, ticket.clone(), p, &settings)
.into_body()
.collect()
.await
.unwrap();
assert!(
cache.get(&ticket).is_none(),
"zero TTL must never create a fresh HIT"
);
assert!(
cache.revalidation(&ticket).unwrap().can_stale_if_error(),
"body remains available on backend failure"
);
} }
#[tokio::test] #[tokio::test]
async fn min_fresh_and_max_stale_still_constrain_compatibility_hits() { async fn min_fresh_and_max_stale_still_constrain_compatibility_hits() {
let cache = Cache::new(1); let cache = Cache::new(1);
let settings = Settings::default(); let settings = Settings::default();
let ticket = cache.ticket(1, "https", "example.test", "app", "/fresh.css", &HeaderMap::new()).unwrap(); let ticket = cache
.ticket(
1,
"https",
"example.test",
"app",
"/fresh.css",
&HeaderMap::new(),
)
.unwrap();
let mut response = Response::new(Body::from("css")); let mut response = Response::new(Body::from("css"));
*response.headers_mut() = headers("public, max-age=60"); *response.headers_mut() = headers("public, max-age=60");
let p = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap(); let p = plan(
cache.wrap(response, ticket.clone(), p, &settings).into_body().collect().await.unwrap(); StatusCode::OK,
response.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
cache
.wrap(response, ticket.clone(), p, &settings)
.into_body()
.collect()
.await
.unwrap();
{ {
let mut store = cache.store.lock().unwrap(); let mut store = cache.store.lock().unwrap();
for entry in store.entries.values_mut() { for entry in store.entries.values_mut() {
entry.received = Instant::now() - Duration::from_secs(45); entry.received = Instant::now() - Duration::from_secs(45);
} }
} }
let too_fresh = cache.ticket(1, "https", "example.test", "app", "/fresh.css", &headers("no-cache, max-age=0, min-fresh=20")).unwrap().with_varnish_compatible(true); let too_fresh = cache
assert!(cache.get(&too_fresh).is_none(), "min-fresh requires 20s but only 15s remain"); .ticket(
let enough = cache.ticket(1, "https", "example.test", "app", "/fresh.css", &headers("min-fresh=10")).unwrap().with_varnish_compatible(true); 1,
assert!(cache.get(&enough).is_some(), "10s remaining freshness is available"); "https",
"example.test",
"app",
"/fresh.css",
&headers("no-cache, max-age=0, min-fresh=20"),
)
.unwrap()
.with_varnish_compatible(true);
assert!(
cache.get(&too_fresh).is_none(),
"min-fresh requires 20s but only 15s remain"
);
let enough = cache
.ticket(
1,
"https",
"example.test",
"app",
"/fresh.css",
&headers("min-fresh=10"),
)
.unwrap()
.with_varnish_compatible(true);
assert!(
cache.get(&enough).is_some(),
"10s remaining freshness is available"
);
{ {
let mut store = cache.store.lock().unwrap(); let mut store = cache.store.lock().unwrap();
for entry in store.entries.values_mut() { for entry in store.entries.values_mut() {
entry.received = Instant::now() - Duration::from_secs(90); entry.received = Instant::now() - Duration::from_secs(90);
} }
} }
let max_stale = cache.ticket(1, "https", "example.test", "app", "/fresh.css", &headers("no-cache, max-stale=45")).unwrap().with_varnish_compatible(true); let max_stale = cache
let stale = cache.get(&max_stale).expect("max-stale explicitly allows retained stale object"); .ticket(
1,
"https",
"example.test",
"app",
"/fresh.css",
&headers("no-cache, max-stale=45"),
)
.unwrap()
.with_varnish_compatible(true);
let stale = cache
.get(&max_stale)
.expect("max-stale explicitly allows retained stale object");
assert!(stale.stale); assert!(stale.stale);
assert!(!stale.background_revalidate, "explicit max-stale never triggers implicit background refresh"); assert!(
let too_old = cache.ticket(1, "https", "example.test", "app", "/fresh.css", &headers("max-stale=10")).unwrap().with_varnish_compatible(true); !stale.background_revalidate,
"explicit max-stale never triggers implicit background refresh"
);
let too_old = cache
.ticket(
1,
"https",
"example.test",
"app",
"/fresh.css",
&headers("max-stale=10"),
)
.unwrap()
.with_varnish_compatible(true);
assert!(cache.get(&too_old).is_none()); assert!(cache.get(&too_old).is_none());
let default = cache.ticket(1, "https", "example.test", "app", "/fresh.css", &HeaderMap::new()).unwrap().with_varnish_compatible(true); let default = cache
assert!(cache.get(&default).is_none(), "expired object cannot be served without stale allowance"); .ticket(
1,
"https",
"example.test",
"app",
"/fresh.css",
&HeaderMap::new(),
)
.unwrap()
.with_varnish_compatible(true);
assert!(
cache.get(&default).is_none(),
"expired object cannot be served without stale allowance"
);
} }
#[tokio::test] #[tokio::test]
async fn varnish_style_hard_reload_keeps_hot_hit_and_age_without_origin_validator() { async fn varnish_style_hard_reload_keeps_hot_hit_and_age_without_origin_validator() {
let cache = Cache::new(1); let cache = Cache::new(1);
let settings = Settings::default(); let settings = Settings::default();
let ticket = cache.ticket(1, "https", "example.test", "app", "/style.css", &HeaderMap::new()).unwrap(); let ticket = cache
.ticket(
1,
"https",
"example.test",
"app",
"/style.css",
&HeaderMap::new(),
)
.unwrap();
let mut response = Response::new(Body::from("public css")); let mut response = Response::new(Body::from("public css"));
*response.headers_mut() = headers("public, max-age=600"); *response.headers_mut() = headers("public, max-age=600");
let plan = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap(); let plan = plan(
cache.wrap(response, ticket.clone(), plan, &settings).into_body().collect().await.unwrap(); StatusCode::OK,
response.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
cache
.wrap(response, ticket.clone(), plan, &settings)
.into_body()
.collect()
.await
.unwrap();
{ {
let mut store = cache.store.lock().unwrap(); let mut store = cache.store.lock().unwrap();
for entry in store.entries.values_mut() { for entry in store.entries.values_mut() {
@@ -260,27 +644,68 @@ mod varnish_compatible_tests {
} }
let mut request = headers("no-cache, max-age=0"); let mut request = headers("no-cache, max-age=0");
request.insert("pragma", HeaderValue::from_static("no-cache")); request.insert("pragma", HeaderValue::from_static("no-cache"));
let compatibility_ticket = cache.ticket(1, "https", "example.test", "app", "/style.css", &request).unwrap().with_varnish_compatible(true); let compatibility_ticket = cache
let first = cache.get(&compatibility_ticket).expect("hard reload should keep a fresh HIT"); .ticket(1, "https", "example.test", "app", "/style.css", &request)
assert!(first.response.headers().get("age").unwrap().to_str().unwrap().parse::<u64>().unwrap() >= 45); .unwrap()
.with_varnish_compatible(true);
let first = cache
.get(&compatibility_ticket)
.expect("hard reload should keep a fresh HIT");
assert!(
first
.response
.headers()
.get("age")
.unwrap()
.to_str()
.unwrap()
.parse::<u64>()
.unwrap()
>= 45
);
assert_eq!(first.hits, 1); assert_eq!(first.hits, 1);
let again = cache.get(&compatibility_ticket).unwrap(); let again = cache.get(&compatibility_ticket).unwrap();
assert_eq!(again.hits, 2, "hard reload must not reset HIT counter"); assert_eq!(again.hits, 2, "hard reload must not reset HIT counter");
assert_eq!(cache.view().entries, 1); assert_eq!(cache.view().entries, 1);
let strict = cache.ticket(1, "https", "example.test", "app", "/style.css", &request).unwrap(); let strict = cache
assert!(cache.get(&strict).is_none(), "strict max-age=0 requires revalidation"); .ticket(1, "https", "example.test", "app", "/style.css", &request)
.unwrap();
assert!(
cache.get(&strict).is_none(),
"strict max-age=0 requires revalidation"
);
} }
#[tokio::test] #[tokio::test]
async fn sixty_second_grace_and_no_unconditional_stale_outside_window() { async fn sixty_second_grace_and_no_unconditional_stale_outside_window() {
let cache = Cache::new(1); let cache = Cache::new(1);
let settings = Settings::default(); let settings = Settings::default();
let ticket = cache.ticket(1, "https", "example.test", "app", "/asset.js", &HeaderMap::new()).unwrap(); let ticket = cache
.ticket(
1,
"https",
"example.test",
"app",
"/asset.js",
&HeaderMap::new(),
)
.unwrap();
let mut response = Response::new(Body::from("js")); let mut response = Response::new(Body::from("js"));
*response.headers_mut() = headers("public, max-age=60"); *response.headers_mut() = headers("public, max-age=60");
let plan = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap(); let plan = plan(
StatusCode::OK,
response.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
assert_eq!(plan.stale_while_revalidate, 60); assert_eq!(plan.stale_while_revalidate, 60);
cache.wrap(response, ticket.clone(), plan, &settings).into_body().collect().await.unwrap(); cache
.wrap(response, ticket.clone(), plan, &settings)
.into_body()
.collect()
.await
.unwrap();
{ {
let mut store = cache.store.lock().unwrap(); let mut store = cache.store.lock().unwrap();
for entry in store.entries.values_mut() { for entry in store.entries.values_mut() {
@@ -289,12 +714,50 @@ mod varnish_compatible_tests {
} }
let hit = cache.get(&ticket).expect("grace HIT"); let hit = cache.get(&ticket).expect("grace HIT");
assert!(hit.stale && hit.background_revalidate); assert!(hit.stale && hit.background_revalidate);
let max_stale_zero = cache.ticket(1, "https", "example.test", "app", "/asset.js", &headers("max-stale=0")).unwrap().with_varnish_compatible(true); let max_stale_zero = cache
assert!(cache.get(&max_stale_zero).is_none(), "explicit max-stale=0 must override implicit grace"); .ticket(
let max_stale_small = cache.ticket(1, "https", "example.test", "app", "/asset.js", &headers("no-cache, max-stale=3")).unwrap().with_varnish_compatible(true); 1,
assert!(cache.get(&max_stale_small).is_none(), "max-stale=3 rejects object stale by 5s, even in grace"); "https",
let max_stale_enough = cache.ticket(1, "https", "example.test", "app", "/asset.js", &headers("no-cache, max-stale=9")).unwrap().with_varnish_compatible(true); "example.test",
let accepted = cache.get(&max_stale_enough).expect("max-stale=9 admits object 5s stale"); "app",
"/asset.js",
&headers("max-stale=0"),
)
.unwrap()
.with_varnish_compatible(true);
assert!(
cache.get(&max_stale_zero).is_none(),
"explicit max-stale=0 must override implicit grace"
);
let max_stale_small = cache
.ticket(
1,
"https",
"example.test",
"app",
"/asset.js",
&headers("no-cache, max-stale=3"),
)
.unwrap()
.with_varnish_compatible(true);
assert!(
cache.get(&max_stale_small).is_none(),
"max-stale=3 rejects object stale by 5s, even in grace"
);
let max_stale_enough = cache
.ticket(
1,
"https",
"example.test",
"app",
"/asset.js",
&headers("no-cache, max-stale=9"),
)
.unwrap()
.with_varnish_compatible(true);
let accepted = cache
.get(&max_stale_enough)
.expect("max-stale=9 admits object 5s stale");
assert!(accepted.stale); assert!(accepted.stale);
{ {
let mut store = cache.store.lock().unwrap(); let mut store = cache.store.lock().unwrap();
@@ -302,6 +765,9 @@ mod varnish_compatible_tests {
entry.received = Instant::now() - Duration::from_secs(125); entry.received = Instant::now() - Duration::from_secs(125);
} }
} }
assert!(cache.get(&ticket).is_none(), "expired beyond grace does not produce HIT"); assert!(
cache.get(&ticket).is_none(),
"expired beyond grace does not produce HIT"
);
} }
} }
+56 -20
View File
@@ -34,14 +34,26 @@ mod tests {
headers.append("accept-encoding", "deflate;q=0".parse().unwrap()); headers.append("accept-encoding", "deflate;q=0".parse().unwrap());
headers.append("cache-control", "max-age=0".parse().unwrap()); headers.append("cache-control", "max-age=0".parse().unwrap());
if let Some(name) = skip { if let Some(name) = skip {
headers.append(name, if name == "range" { "bytes=0-3" } else { "no-transform" }.parse().unwrap()); headers.append(
name,
if name == "range" {
"bytes=0-3"
} else {
"no-transform"
}
.parse()
.unwrap(),
);
} }
headers.insert("cookie", "session=private".parse().unwrap()); headers.insert("cookie", "session=private".parse().unwrap());
headers.insert("authorization", "Bearer test".parse().unwrap()); headers.insert("authorization", "Bearer test".parse().unwrap());
headers.insert("x-large-custom", "x".repeat(4096).parse().unwrap()); headers.insert("x-large-custom", "x".repeat(4096).parse().unwrap());
let selected = request_headers(&headers); let selected = request_headers(&headers);
for name in ["accept-encoding", "range", "cache-control"] { for name in ["accept-encoding", "range", "cache-control"] {
assert_eq!(headers.get_all(name).iter().collect::<Vec<_>>(), selected.get_all(name).iter().collect::<Vec<_>>()); assert_eq!(
headers.get_all(name).iter().collect::<Vec<_>>(),
selected.get_all(name).iter().collect::<Vec<_>>()
);
} }
assert!(!selected.contains_key("cookie")); assert!(!selected.contains_key("cookie"));
assert!(!selected.contains_key("authorization")); assert!(!selected.contains_key("authorization"));
@@ -49,7 +61,10 @@ mod tests {
let narrow = encode(response(), &selected, &method, &Settings::default()).await; let narrow = encode(response(), &selected, &method, &Settings::default()).await;
assert_eq!(full.status(), narrow.status()); assert_eq!(full.status(), narrow.status());
assert_eq!(full.headers(), narrow.headers()); assert_eq!(full.headers(), narrow.headers());
assert_eq!(full.into_body().collect().await.unwrap().to_bytes(), narrow.into_body().collect().await.unwrap().to_bytes()); assert_eq!(
full.into_body().collect().await.unwrap().to_bytes(),
narrow.into_body().collect().await.unwrap().to_bytes()
);
} }
} }
} }
@@ -59,7 +74,10 @@ mod tests {
#[test] #[test]
fn unaccepted_codecs_cannot_be_reenabled_by_scoped_overrides() { fn unaccepted_codecs_cannot_be_reenabled_by_scoped_overrides() {
let global = Settings::default(); let global = Settings::default();
let force_on = Overrides { enabled: Some(true), ..Default::default() }; let force_on = Overrides {
enabled: Some(true),
..Default::default()
};
for offer in ["identity", "gzip;q=0, br;q=0, zstd;q=0", "deflate", "*;q=0"] { for offer in ["identity", "gzip;q=0, br;q=0, zstd;q=0", "deflate", "*;q=0"] {
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
headers.insert("accept-encoding", offer.parse().unwrap()); headers.insert("accept-encoding", offer.parse().unwrap());
@@ -86,10 +104,16 @@ mod tests {
assert!(!selective.gzip); assert!(!selective.gzip);
assert!(selective.br); assert!(selective.br);
assert!(!selective.zstd); assert!(!selective.zstd);
assert!(!selective.deflate, "a scope cannot enable a globally disabled codec"); assert!(
!selective.deflate,
"a scope cannot enable a globally disabled codec"
);
let disabled = Overrides { enabled: Some(false), ..Default::default() } let disabled = Overrides {
.resolve(&global, &inherited); enabled: Some(false),
..Default::default()
}
.resolve(&global, &inherited);
assert!(!disabled.enabled()); assert!(!disabled.enabled());
let parent_disabled = global.scoped(false); let parent_disabled = global.scoped(false);
@@ -164,7 +188,16 @@ mod tests {
.get_all("vary") .get_all("vary")
.iter() .iter()
.any(|v| v.to_str().unwrap().contains("accept-encoding"))); .any(|v| v.to_str().unwrap().contains("accept-encoding")));
assert!(response.into_body().collect().await.unwrap().to_bytes().len() < 1000); assert!(
response
.into_body()
.collect()
.await
.unwrap()
.to_bytes()
.len()
< 1000
);
} }
} }
@@ -244,10 +277,9 @@ mod tests {
dynamic dynamic
.headers_mut() .headers_mut()
.insert("set-cookie", "session=next; HttpOnly".parse().unwrap()); .insert("set-cookie", "session=next; HttpOnly".parse().unwrap());
dynamic.headers_mut().insert( dynamic
"cache-control", .headers_mut()
"private, no-store".parse().unwrap(), .insert("cache-control", "private, no-store".parse().unwrap());
);
let encoded = encode(dynamic, &headers, &Method::GET, &settings).await; let encoded = encode(dynamic, &headers, &Method::GET, &settings).await;
assert_eq!(encoded.headers()["content-encoding"], "gzip"); assert_eq!(encoded.headers()["content-encoding"], "gzip");
} }
@@ -306,19 +338,23 @@ mod tests {
let mut no_transform_request = headers.clone(); let mut no_transform_request = headers.clone();
no_transform_request.insert("cache-control", "no-transform".parse().unwrap()); no_transform_request.insert("cache-control", "no-transform".parse().unwrap());
assert!(!encode(response(), &no_transform_request, &Method::GET, &settings) assert!(
.await !encode(response(), &no_transform_request, &Method::GET, &settings)
.headers() .await
.contains_key("content-encoding")); .headers()
.contains_key("content-encoding")
);
let mut no_transform_response = response(); let mut no_transform_response = response();
no_transform_response no_transform_response
.headers_mut() .headers_mut()
.insert("cache-control", "public, no-transform".parse().unwrap()); .insert("cache-control", "public, no-transform".parse().unwrap());
assert!(!encode(no_transform_response, &headers, &Method::GET, &settings) assert!(
.await !encode(no_transform_response, &headers, &Method::GET, &settings)
.headers() .await
.contains_key("content-encoding")); .headers()
.contains_key("content-encoding")
);
let mut sse = response(); let mut sse = response();
sse.headers_mut() sse.headers_mut()
+7 -2
View File
@@ -72,7 +72,11 @@ pub struct Config {
pub host_policies: Vec<HostPolicy>, pub host_policies: Vec<HostPolicy>,
// Preserve old 0.34.13 and earlier config/backup data until compilation migrates it. // Preserve old 0.34.13 and earlier config/backup data until compilation migrates it.
// Migration happens in compile_with_active, before validation and persistence. // Migration happens in compile_with_active, before validation and persistence.
#[serde(default, rename = "user_agent_blocking", skip_serializing_if = "Option::is_none")] #[serde(
default,
rename = "user_agent_blocking",
skip_serializing_if = "Option::is_none"
)]
pub legacy_user_agent_blocking: Option<crate::user_agent::Policy>, pub legacy_user_agent_blocking: Option<crate::user_agent::Policy>,
#[serde(default)] #[serde(default)]
pub global_security: GlobalSecurity, pub global_security: GlobalSecurity,
@@ -124,7 +128,8 @@ impl Config {
impl GlobalSecurity { impl GlobalSecurity {
pub fn compile(&mut self) -> Result<CompiledGlobalSecurity> { pub fn compile(&mut self) -> Result<CompiledGlobalSecurity> {
let user_agent_blocking = self.user_agent_blocking let user_agent_blocking = self
.user_agent_blocking
.get_or_insert_with(Default::default) .get_or_insert_with(Default::default)
.compile()?; .compile()?;
self.country_access.validate()?; self.country_access.validate()?;
+68 -16
View File
@@ -1189,21 +1189,41 @@ mod tests {
fn old_configuration_retains_user_agent_policy_and_global_security_defaults_off() { fn old_configuration_retains_user_agent_policy_and_global_security_defaults_off() {
let config: Config = serde_json::from_value(serde_json::json!({ let config: Config = serde_json::from_value(serde_json::json!({
"user_agent_blocking": {"enabled":true,"patterns":["bad-bot"]} "user_agent_blocking": {"enabled":true,"patterns":["bad-bot"]}
})).unwrap(); }))
assert_eq!(config.global_security.country_access.mode, crate::rate_limit::CountryMode::Off); .unwrap();
assert_eq!(
config.global_security.country_access.mode,
crate::rate_limit::CountryMode::Off
);
assert!(config.global_security.acl.is_none()); assert!(config.global_security.acl.is_none());
assert_eq!(config.global_security.method_policy.mode, crate::vhost::MethodPolicyMode::Off); assert_eq!(
config.global_security.method_policy.mode,
crate::vhost::MethodPolicyMode::Off
);
assert!(!config.global_security.content_type_policy.enabled); assert!(!config.global_security.content_type_policy.enabled);
assert!(!config.global_security.rate_limit.enabled); assert!(!config.global_security.rate_limit.enabled);
assert!(!config.global_security.security_headers.hsts.enabled); assert!(!config.global_security.security_headers.hsts.enabled);
let snapshot = compile(config, 1, &policy()).unwrap(); let snapshot = compile(config, 1, &policy()).unwrap();
let mut headers = http::HeaderMap::new(); let mut headers = http::HeaderMap::new();
headers.insert(http::header::USER_AGENT, http::HeaderValue::from_static("BAD-BOT/2")); headers.insert(
assert!(snapshot.global_security.user_agent_blocking.blocks(&headers)); http::header::USER_AGENT,
assert!(snapshot.envelope.config.legacy_user_agent_blocking.is_none()); http::HeaderValue::from_static("BAD-BOT/2"),
);
assert!(snapshot
.global_security
.user_agent_blocking
.blocks(&headers));
assert!(snapshot
.envelope
.config
.legacy_user_agent_blocking
.is_none());
let canonical = serde_json::to_value(&snapshot.envelope.config).unwrap(); let canonical = serde_json::to_value(&snapshot.envelope.config).unwrap();
assert!(canonical.get("user_agent_blocking").is_none()); assert!(canonical.get("user_agent_blocking").is_none());
assert_eq!(canonical["global_security"]["user_agent_blocking"]["patterns"][0], "bad-bot"); assert_eq!(
canonical["global_security"]["user_agent_blocking"]["patterns"][0],
"bad-bot"
);
assert!(snapshot.global_security.country_access.allows(None)); assert!(snapshot.global_security.country_access.allows(None));
} }
@@ -1214,7 +1234,8 @@ mod tests {
"global_security": { "global_security": {
"user_agent_blocking": { "enabled":false,"patterns":[] } "user_agent_blocking": { "enabled":false,"patterns":[] }
} }
})).unwrap(); }))
.unwrap();
let err = compile(old_and_new, 1, &policy()).unwrap_err().to_string(); let err = compile(old_and_new, 1, &policy()).unwrap_err().to_string();
assert!(err.contains("conflicting legacy"), "{err}"); assert!(err.contains("conflicting legacy"), "{err}");
} }
@@ -1228,12 +1249,20 @@ mod tests {
}); });
let json = serde_json::to_value(&config).unwrap(); let json = serde_json::to_value(&config).unwrap();
assert!(json.get("user_agent_blocking").is_none()); assert!(json.get("user_agent_blocking").is_none());
assert!(json["global_security"]["user_agent_blocking"]["enabled"].as_bool().unwrap()); assert!(json["global_security"]["user_agent_blocking"]["enabled"]
.as_bool()
.unwrap());
let restored: Config = serde_json::from_value(json).unwrap(); let restored: Config = serde_json::from_value(json).unwrap();
let snapshot = compile(restored, 1, &policy()).unwrap(); let snapshot = compile(restored, 1, &policy()).unwrap();
let mut headers = http::HeaderMap::new(); let mut headers = http::HeaderMap::new();
headers.insert(http::header::USER_AGENT, http::HeaderValue::from_static("trusted-test-bot/1")); headers.insert(
assert!(snapshot.global_security.user_agent_blocking.blocks(&headers)); http::header::USER_AGENT,
http::HeaderValue::from_static("trusted-test-bot/1"),
);
assert!(snapshot
.global_security
.user_agent_blocking
.blocks(&headers));
} }
#[test] #[test]
@@ -1260,16 +1289,35 @@ mod tests {
let snapshot = compile(config, 2, &policy()).unwrap(); let snapshot = compile(config, 2, &policy()).unwrap();
let rules = &snapshot.global_security; let rules = &snapshot.global_security;
assert_eq!(snapshot.envelope.config.global_security.country_access.countries, vec!["RU"]); assert_eq!(
snapshot
.envelope
.config
.global_security
.country_access
.countries,
vec!["RU"]
);
assert!(!rules.country_access.allows(Some("RU"))); assert!(!rules.country_access.allows(Some("RU")));
assert!(rules.country_access.allows(None)); assert!(rules.country_access.allows(None));
assert!(!rules.acl.as_ref().unwrap().allows("192.0.2.2".parse().unwrap())); assert!(!rules
assert!(rules.acl.as_ref().unwrap().allows("198.51.100.1".parse().unwrap())); .acl
.as_ref()
.unwrap()
.allows("192.0.2.2".parse().unwrap()));
assert!(rules
.acl
.as_ref()
.unwrap()
.allows("198.51.100.1".parse().unwrap()));
assert!(!rules.method_policy.allows(&http::Method::TRACE)); assert!(!rules.method_policy.allows(&http::Method::TRACE));
assert!(rules.method_policy.allows(&http::Method::GET)); assert!(rules.method_policy.allows(&http::Method::GET));
assert!(rules.rate_limit.enabled); assert!(rules.rate_limit.enabled);
let mut headers = http::HeaderMap::new(); let mut headers = http::HeaderMap::new();
headers.insert(http::header::CONTENT_TYPE, http::HeaderValue::from_static("application/x-danger; charset=utf-8")); headers.insert(
http::header::CONTENT_TYPE,
http::HeaderValue::from_static("application/x-danger; charset=utf-8"),
);
assert!(rules.content_type_policy.blocks(&headers)); assert!(rules.content_type_policy.blocks(&headers));
headers.clear(); headers.clear();
rules.headers.apply(&mut headers, true); rules.headers.apply(&mut headers, true);
@@ -1292,7 +1340,11 @@ mod tests {
config.global_security.security_headers.hsts.preload = true; config.global_security.security_headers.hsts.preload = true;
assert!(compile(config.clone(), 1, &policy()).is_err()); assert!(compile(config.clone(), 1, &policy()).is_err());
config.global_security.security_headers.hsts.enabled = true; config.global_security.security_headers.hsts.enabled = true;
config.global_security.security_headers.hsts.include_subdomains = true; config
.global_security
.security_headers
.hsts
.include_subdomains = true;
assert!(compile(config, 1, &policy()).is_ok()); assert!(compile(config, 1, &policy()).is_ok());
} }
+2 -2
View File
@@ -1,10 +1,10 @@
use crate::config::{Config, Envelope}; use crate::config::{Config, Envelope};
mod api_tokens;
mod certificate_api; mod certificate_api;
mod dashboard_presets; mod dashboard_presets;
mod history; mod history;
mod api_tokens;
pub use api_tokens::{ApiTokenGrant, ApiTokenMetadata, API_TOKEN_CAPACITY};
use anyhow::{ensure, Context, Result}; use anyhow::{ensure, Context, Result};
pub use api_tokens::{ApiTokenGrant, ApiTokenMetadata, API_TOKEN_CAPACITY};
pub use history::{ConfigurationHistoryDetail, ConfigurationRevision}; pub use history::{ConfigurationHistoryDetail, ConfigurationRevision};
use serde::Serialize; use serde::Serialize;
use sqlx::{ use sqlx::{
+2 -1
View File
@@ -21,7 +21,8 @@ impl Diagnostics {
tokio::spawn(async move { tokio::spawn(async move {
let mut previous: Option<(View, Counters)> = None; let mut previous: Option<(View, Counters)> = None;
loop { loop {
let sample = tokio::task::spawn_blocking(move || system_stats::sample(previous)).await; let sample =
tokio::task::spawn_blocking(move || system_stats::sample(previous)).await;
if let Ok((view, counters)) = sample { if let Ok((view, counters)) = sample {
*sampler.latest.write().unwrap_or_else(|e| e.into_inner()) = view.clone(); *sampler.latest.write().unwrap_or_else(|e| e.into_inner()) = view.clone();
previous = Some((view, counters)); previous = Some((view, counters));
+68 -29
View File
@@ -221,8 +221,15 @@ impl Compiled {
include_original_url: bool, include_original_url: bool,
) -> Result<Context> { ) -> Result<Context> {
self.context_with_peer( self.context_with_peer(
incoming, peer, local, tls, authority, uri, include_original_url, incoming,
None, false, peer,
local,
tls,
authority,
uri,
include_original_url,
None,
false,
) )
} }
@@ -245,7 +252,9 @@ impl Compiled {
if !trusted_peer { if !trusted_peer {
let scheme = if tls { "https" } else { "http" }; let scheme = if tls { "https" } else { "http" };
let authority_text = authority.to_str()?; let authority_text = authority.to_str()?;
if !validated_host { crate::config::request_host(authority_text)?; } if !validated_host {
crate::config::request_host(authority_text)?;
}
let target = uri.path_and_query().map(|v| v.as_str()).unwrap_or("/"); let target = uri.path_and_query().map(|v| v.as_str()).unwrap_or("/");
let original_url = if include_original_url { let original_url = if include_original_url {
format!("{scheme}://{authority_text}{target}") format!("{scheme}://{authority_text}{target}")
@@ -267,12 +276,19 @@ impl Compiled {
}; };
let by_text = cached.is_none().then(|| node(canonical(local.ip()))); let by_text = cached.is_none().then(|| node(canonical(local.ip())));
let peer_node = cached.map_or_else( let peer_node = cached.map_or_else(
|| if peer_ip.is_ipv4() { peer_text.as_deref().unwrap_or("") } || {
else { ipv6_node.as_deref().unwrap_or("") }, if peer_ip.is_ipv4() {
peer_text.as_deref().unwrap_or("")
} else {
ipv6_node.as_deref().unwrap_or("")
}
},
|cached| cached.peer_node.as_str(), |cached| cached.peer_node.as_str(),
); );
let by_node = cached.map_or_else(|| by_text.as_deref().unwrap_or(""), let by_node = cached.map_or_else(
|cached| cached.by_node.as_str()); || by_text.as_deref().unwrap_or(""),
|cached| cached.by_node.as_str(),
);
let forwarded = format!( let forwarded = format!(
"for={peer_node};by={by_node};proto={scheme};host=\"{authority_text}\"" "for={peer_node};by={by_node};proto={scheme};host=\"{authority_text}\""
); );
@@ -282,11 +298,14 @@ impl Compiled {
values.insert("x-real-ip", peer_header); values.insert("x-real-ip", peer_header);
values.insert("x-forwarded-host", authority.clone()); values.insert("x-forwarded-host", authority.clone());
values.insert("x-forwarded-proto", HeaderValue::from_static(scheme)); values.insert("x-forwarded-proto", HeaderValue::from_static(scheme));
values.insert("x-forwarded-port", if let Some(cached) = cached { values.insert(
cached.port_header.clone() "x-forwarded-port",
} else { if let Some(cached) = cached {
local.port().to_string().parse()? cached.port_header.clone()
}); } else {
local.port().to_string().parse()?
},
);
if let Some(peer) = cached { if let Some(peer) = cached {
peer.remember_untrusted(authority, tls, &values); peer.remember_untrusted(authority, tls, &values);
} }
@@ -320,7 +339,9 @@ impl Compiled {
}; };
} }
let authority_text = authority.to_str()?; let authority_text = authority.to_str()?;
if !validated_host { crate::config::request_host(authority_text)?; } if !validated_host {
crate::config::request_host(authority_text)?;
}
let original_target = uri.path_and_query().map(|v| v.as_str()).unwrap_or("/"); let original_target = uri.path_and_query().map(|v| v.as_str()).unwrap_or("/");
let original_url = if include_original_url { let original_url = if include_original_url {
format!("{scheme}://{authority_text}{original_target}") format!("{scheme}://{authority_text}{original_target}")
@@ -331,24 +352,36 @@ impl Compiled {
let mut forwarded = String::with_capacity(ips.len() * 52 + 80 + authority_text.len()); let mut forwarded = String::with_capacity(ips.len() * 52 + 80 + authority_text.len());
let mut xff = String::with_capacity(ips.len() * 44); let mut xff = String::with_capacity(ips.len() * 44);
for (index, ip) in ips.iter().enumerate() { for (index, ip) in ips.iter().enumerate() {
if index > 0 { forwarded.push_str(", "); xff.push_str(", "); } if index > 0 {
forwarded.push_str(", ");
xff.push_str(", ");
}
forwarded.push_str("for="); forwarded.push_str("for=");
if index + 1 == ips.len() { if index + 1 == ips.len() {
if let Some(cached) = cached { forwarded.push_str(&cached.peer_node); } if let Some(cached) = cached {
else { forwarded.push_str(&node(*ip)); } forwarded.push_str(&cached.peer_node);
} else {
forwarded.push_str(&node(*ip));
}
} else { } else {
forwarded.push_str(&node(*ip)); forwarded.push_str(&node(*ip));
} }
if index + 1 == ips.len() { if index + 1 == ips.len() {
if let Some(cached) = cached { xff.push_str(cached.peer_header.to_str()?); } if let Some(cached) = cached {
else { write!(&mut xff, "{ip}")?; } xff.push_str(cached.peer_header.to_str()?);
} else {
write!(&mut xff, "{ip}")?;
}
} else { } else {
write!(&mut xff, "{ip}")?; write!(&mut xff, "{ip}")?;
} }
} }
forwarded.push_str(";by="); forwarded.push_str(";by=");
if let Some(cached) = cached { forwarded.push_str(&cached.by_node); } if let Some(cached) = cached {
else { forwarded.push_str(&node(canonical(local.ip()))); } forwarded.push_str(&cached.by_node);
} else {
forwarded.push_str(&node(canonical(local.ip())));
}
write!(&mut forwarded, ";proto={scheme};host=\"{authority_text}\"")?; write!(&mut forwarded, ";proto={scheme};host=\"{authority_text}\"")?;
let mut values = HeaderMap::with_capacity(7); let mut values = HeaderMap::with_capacity(7);
values.insert("forwarded", forwarded.parse()?); values.insert("forwarded", forwarded.parse()?);
@@ -364,11 +397,14 @@ impl Compiled {
} else { } else {
local.port() local.port()
}; };
values.insert("x-forwarded-port", if cached.is_some() && !(trusted_peer && self.trust_proto) { values.insert(
cached.expect("tested above").port_header.clone() "x-forwarded-port",
} else { if cached.is_some() && !(trusted_peer && self.trust_proto) {
port.to_string().parse()? cached.expect("tested above").port_header.clone()
}); } else {
port.to_string().parse()?
},
);
values.insert("x-forwarded-uri", original_target.parse()?); values.insert("x-forwarded-uri", original_target.parse()?);
Ok(Context { Ok(Context {
client_ip, client_ip,
@@ -423,14 +459,15 @@ impl Context {
for name in headers.keys() { for name in headers.keys() {
let text = name.as_str(); let text = name.as_str();
if matches!(text, "x-original-url" | "x-original-method") if matches!(text, "x-original-url" | "x-original-method")
|| (text.starts_with("x-forwarded-") || (text.starts_with("x-forwarded-") && !self.values.contains_key(text))
&& !self.values.contains_key(text))
{ {
remove.get_or_insert_with(Vec::new).push(name.clone()); remove.get_or_insert_with(Vec::new).push(name.clone());
} }
} }
if let Some(remove) = remove { if let Some(remove) = remove {
for name in remove { headers.remove(name); } for name in remove {
headers.remove(name);
}
} }
if !include_client_ip { if !include_client_ip {
for name in ["forwarded", "x-forwarded-for", "x-real-ip"] { for name in ["forwarded", "x-forwarded-for", "x-real-ip"] {
@@ -438,7 +475,9 @@ impl Context {
} }
} }
for (name, value) in &self.values { for (name, value) in &self.values {
if !include_client_ip && matches!(name.as_str(), "forwarded" | "x-forwarded-for" | "x-real-ip") { if !include_client_ip
&& matches!(name.as_str(), "forwarded" | "x-forwarded-for" | "x-real-ip")
{
continue; continue;
} }
headers.insert(name.clone(), value.clone()); headers.insert(name.clone(), value.clone());
+25 -6
View File
@@ -1,11 +1,11 @@
mod mmdb; mod mmdb;
mod update; mod update;
mod update_http; mod update_http;
pub(crate) use update::{poll_loop as update_poll_loop, update_now};
pub use update::{ pub use update::{
UpdateResult, UpdateSettings, DEFAULT_INTERVAL_SECONDS, DEFAULT_IPV4_URL, DEFAULT_IPV6_URL, UpdateResult, UpdateSettings, DEFAULT_INTERVAL_SECONDS, DEFAULT_IPV4_URL, DEFAULT_IPV6_URL,
DEFAULT_MAX_BYTES, DEFAULT_MMDB_URL, DEFAULT_MAX_BYTES, DEFAULT_MMDB_URL,
}; };
pub(crate) use update::{poll_loop as update_poll_loop, update_now};
use anyhow::{bail, ensure, Context, Result}; use anyhow::{bail, ensure, Context, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -46,7 +46,10 @@ impl Default for DatabaseFormat {
#[derive(Debug)] #[derive(Debug)]
enum Backend { enum Backend {
Csv { ipv4: Box<[V4Range]>, ipv6: Box<[V6Range]> }, Csv {
ipv4: Box<[V4Range]>,
ipv6: Box<[V6Range]>,
},
Mmdb(mmdb::Database), Mmdb(mmdb::Database),
} }
@@ -64,7 +67,12 @@ pub struct View {
} }
impl Database { impl Database {
pub fn empty() -> Self { pub fn empty() -> Self {
Self { backend: Backend::Csv { ipv4: Box::new([]), ipv6: Box::new([]) } } Self {
backend: Backend::Csv {
ipv4: Box::new([]),
ipv6: Box::new([]),
},
}
} }
pub fn load(directory: &Path) -> Result<Self> { pub fn load(directory: &Path) -> Result<Self> {
if directory.join("country.mmdb").is_file() { if directory.join("country.mmdb").is_file() {
@@ -78,11 +86,22 @@ impl Database {
DatabaseFormat::CsvPair => { DatabaseFormat::CsvPair => {
let ipv4 = parse_v4(&directory.join("user-country-ipv4.csv"))?; let ipv4 = parse_v4(&directory.join("user-country-ipv4.csv"))?;
let ipv6 = parse_v6(&directory.join("user-country-ipv6.csv"))?; let ipv6 = parse_v6(&directory.join("user-country-ipv6.csv"))?;
ensure!(!ipv4.is_empty() && !ipv6.is_empty(), "GeoIP country database is empty"); ensure!(
Ok(Self { backend: Backend::Csv { ipv4: ipv4.into_boxed_slice(), ipv6: ipv6.into_boxed_slice() } }) !ipv4.is_empty() && !ipv6.is_empty(),
"GeoIP country database is empty"
);
Ok(Self {
backend: Backend::Csv {
ipv4: ipv4.into_boxed_slice(),
ipv6: ipv6.into_boxed_slice(),
},
})
} }
DatabaseFormat::Mmdb => Ok(Self { DatabaseFormat::Mmdb => Ok(Self {
backend: Backend::Mmdb(mmdb::Database::load(&directory.join("country.mmdb"), MAX_FILE_BYTES)?), backend: Backend::Mmdb(mmdb::Database::load(
&directory.join("country.mmdb"),
MAX_FILE_BYTES,
)?),
}), }),
} }
} }
+174 -40
View File
@@ -36,7 +36,10 @@ struct Budget {
impl Budget { impl Budget {
fn charge(&mut self) -> Result<()> { fn charge(&mut self) -> Result<()> {
self.values = self.values.saturating_add(1); self.values = self.values.saturating_add(1);
ensure!(self.values <= MAX_VALUES, "MMDB value traversal limit exceeded"); ensure!(
self.values <= MAX_VALUES,
"MMDB value traversal limit exceeded"
);
Ok(()) Ok(())
} }
} }
@@ -45,20 +48,56 @@ impl Database {
pub(super) fn load(path: &Path, max_bytes: u64) -> Result<Self> { pub(super) fn load(path: &Path, max_bytes: u64) -> Result<Self> {
let metadata = fs::symlink_metadata(path) let metadata = fs::symlink_metadata(path)
.with_context(|| format!("open GeoIP MMDB {}", path.display()))?; .with_context(|| format!("open GeoIP MMDB {}", path.display()))?;
ensure!(metadata.is_file() && !metadata.file_type().is_symlink(), "GeoIP MMDB must be a regular file"); ensure!(
ensure!(metadata.len() <= max_bytes, "GeoIP MMDB exceeds configured maximum size"); metadata.is_file() && !metadata.file_type().is_symlink(),
let bytes = fs::read(path).with_context(|| format!("read GeoIP MMDB {}", path.display()))?; "GeoIP MMDB must be a regular file"
);
ensure!(
metadata.len() <= max_bytes,
"GeoIP MMDB exceeds configured maximum size"
);
let bytes =
fs::read(path).with_context(|| format!("read GeoIP MMDB {}", path.display()))?;
let marker = metadata_marker(&bytes).context("MMDB metadata marker not found")?; let marker = metadata_marker(&bytes).context("MMDB metadata marker not found")?;
let metadata_offset = marker + METADATA_MARKER.len(); let metadata_offset = marker + METADATA_MARKER.len();
let base = metadata_offset; let base = metadata_offset;
let node_count = u32::try_from(read_uint_path(&bytes, metadata_offset, base, &["node_count"])?).context("MMDB node_count is too large")?; let node_count = u32::try_from(read_uint_path(
let record_size = u16::try_from(read_uint_path(&bytes, metadata_offset, base, &["record_size"])?).context("MMDB record_size is too large")?; &bytes,
let ip_version = u16::try_from(read_uint_path(&bytes, metadata_offset, base, &["ip_version"])?).context("MMDB ip_version is too large")?; metadata_offset,
let major = read_uint_path(&bytes, metadata_offset, base, &["binary_format_major_version"])?; base,
&["node_count"],
)?)
.context("MMDB node_count is too large")?;
let record_size = u16::try_from(read_uint_path(
&bytes,
metadata_offset,
base,
&["record_size"],
)?)
.context("MMDB record_size is too large")?;
let ip_version = u16::try_from(read_uint_path(
&bytes,
metadata_offset,
base,
&["ip_version"],
)?)
.context("MMDB ip_version is too large")?;
let major = read_uint_path(
&bytes,
metadata_offset,
base,
&["binary_format_major_version"],
)?;
let database_type = read_string_path(&bytes, metadata_offset, base, &["database_type"])?; let database_type = read_string_path(&bytes, metadata_offset, base, &["database_type"])?;
ensure!(node_count > 0, "MMDB node_count must be positive"); ensure!(node_count > 0, "MMDB node_count must be positive");
ensure!(matches!(record_size, 24 | 28 | 32), "unsupported MMDB record_size {record_size}"); ensure!(
ensure!(matches!(ip_version, 4 | 6), "invalid MMDB ip_version {ip_version}"); matches!(record_size, 24 | 28 | 32),
"unsupported MMDB record_size {record_size}"
);
ensure!(
matches!(ip_version, 4 | 6),
"invalid MMDB ip_version {ip_version}"
);
ensure!(major == 2, "unsupported MMDB major format version {major}"); ensure!(major == 2, "unsupported MMDB major format version {major}");
let node_bytes = usize::from(record_size) let node_bytes = usize::from(record_size)
.checked_mul(2) .checked_mul(2)
@@ -67,9 +106,17 @@ impl Database {
let search_tree_size = (node_count as usize) let search_tree_size = (node_count as usize)
.checked_mul(node_bytes) .checked_mul(node_bytes)
.context("MMDB search tree size overflow")?; .context("MMDB search tree size overflow")?;
let data_section_start = search_tree_size.checked_add(16).context("MMDB data section overflow")?; let data_section_start = search_tree_size
ensure!(data_section_start <= marker, "MMDB search tree overlaps metadata"); .checked_add(16)
ensure!(bytes.get(search_tree_size..data_section_start) == Some(&[0u8; 16][..]), "MMDB data-section separator is invalid"); .context("MMDB data section overflow")?;
ensure!(
data_section_start <= marker,
"MMDB search tree overlaps metadata"
);
ensure!(
bytes.get(search_tree_size..data_section_start) == Some(&[0u8; 16][..]),
"MMDB data-section separator is invalid"
);
Ok(Self { Ok(Self {
bytes, bytes,
node_count, node_count,
@@ -100,7 +147,8 @@ impl Database {
} }
} }
let mut budget = Budget::default(); let mut budget = Budget::default();
if let Ok(code) = read_string(&self.bytes, offset, self.data_section_start, 0, &mut budget) { if let Ok(code) = read_string(&self.bytes, offset, self.data_section_start, 0, &mut budget)
{
if let Some(code) = super::normalize_country_code(&code) { if let Some(code) = super::normalize_country_code(&code) {
return Some(String::from_utf8_lossy(&code).into_owned()); return Some(String::from_utf8_lossy(&code).into_owned());
} }
@@ -135,17 +183,27 @@ impl Database {
if node == self.node_count { if node == self.node_count {
return Ok(None); return Ok(None);
} }
ensure!(node >= self.node_count.saturating_add(16), "invalid MMDB data pointer"); ensure!(
node >= self.node_count.saturating_add(16),
"invalid MMDB data pointer"
);
let offset = (node as usize) let offset = (node as usize)
.checked_sub(self.node_count as usize) .checked_sub(self.node_count as usize)
.and_then(|value| value.checked_add(self.search_tree_size)) .and_then(|value| value.checked_add(self.search_tree_size))
.context("MMDB data pointer overflow")?; .context("MMDB data pointer overflow")?;
ensure!(offset >= self.data_section_start && offset < self.metadata_offset - METADATA_MARKER.len(), "MMDB data pointer is out of bounds"); ensure!(
offset >= self.data_section_start
&& offset < self.metadata_offset - METADATA_MARKER.len(),
"MMDB data pointer is out of bounds"
);
Ok(Some(offset)) Ok(Some(offset))
} }
fn read_node(&self, node: u32, side: usize) -> Result<u32> { fn read_node(&self, node: u32, side: usize) -> Result<u32> {
ensure!((node as usize) < self.node_count as usize, "MMDB node is out of range"); ensure!(
(node as usize) < self.node_count as usize,
"MMDB node is out of range"
);
let base = match self.record_size { let base = match self.record_size {
24 => node as usize * 6, 24 => node as usize * 6,
28 => node as usize * 7, 28 => node as usize * 7,
@@ -170,7 +228,9 @@ impl Database {
} }
fn metadata_marker(bytes: &[u8]) -> Option<usize> { fn metadata_marker(bytes: &[u8]) -> Option<usize> {
let start = bytes.len().saturating_sub(MAX_METADATA_BYTES + METADATA_MARKER.len()); let start = bytes
.len()
.saturating_sub(MAX_METADATA_BYTES + METADATA_MARKER.len());
bytes[start..] bytes[start..]
.windows(METADATA_MARKER.len()) .windows(METADATA_MARKER.len())
.rposition(|window| window == METADATA_MARKER) .rposition(|window| window == METADATA_MARKER)
@@ -179,17 +239,26 @@ fn metadata_marker(bytes: &[u8]) -> Option<usize> {
fn read_uint_path(bytes: &[u8], offset: usize, base: usize, path: &[&str]) -> Result<u64> { fn read_uint_path(bytes: &[u8], offset: usize, base: usize, path: &[&str]) -> Result<u64> {
let mut budget = Budget::default(); let mut budget = Budget::default();
let value = find_path(bytes, offset, base, path, 0, &mut budget)?.context("MMDB metadata field missing")?; let value = find_path(bytes, offset, base, path, 0, &mut budget)?
.context("MMDB metadata field missing")?;
read_uint(bytes, value, base, 0, &mut budget) read_uint(bytes, value, base, 0, &mut budget)
} }
fn read_string_path(bytes: &[u8], offset: usize, base: usize, path: &[&str]) -> Result<String> { fn read_string_path(bytes: &[u8], offset: usize, base: usize, path: &[&str]) -> Result<String> {
let mut budget = Budget::default(); let mut budget = Budget::default();
let value = find_path(bytes, offset, base, path, 0, &mut budget)?.context("MMDB string field missing")?; let value = find_path(bytes, offset, base, path, 0, &mut budget)?
.context("MMDB string field missing")?;
read_string(bytes, value, base, 0, &mut budget) read_string(bytes, value, base, 0, &mut budget)
} }
fn find_path(bytes: &[u8], offset: usize, base: usize, path: &[&str], depth: usize, budget: &mut Budget) -> Result<Option<usize>> { fn find_path(
bytes: &[u8],
offset: usize,
base: usize,
path: &[&str],
depth: usize,
budget: &mut Budget,
) -> Result<Option<usize>> {
ensure!(depth <= MAX_DEPTH, "MMDB nesting limit exceeded"); ensure!(depth <= MAX_DEPTH, "MMDB nesting limit exceeded");
budget.charge()?; budget.charge()?;
let header = header(bytes, offset, base)?; let header = header(bytes, offset, base)?;
@@ -209,7 +278,9 @@ fn find_path(bytes: &[u8], offset: usize, base: usize, path: &[&str], depth: usi
if path.len() == 1 { if path.len() == 1 {
return Ok(Some(value_offset)); return Ok(Some(value_offset));
} }
if let Some(found) = find_path(bytes, value_offset, base, &path[1..], depth + 1, budget)? { if let Some(found) =
find_path(bytes, value_offset, base, &path[1..], depth + 1, budget)?
{
return Ok(Some(found)); return Ok(Some(found));
} }
} }
@@ -218,7 +289,13 @@ fn find_path(bytes: &[u8], offset: usize, base: usize, path: &[&str], depth: usi
Ok(None) Ok(None)
} }
fn read_string(bytes: &[u8], offset: usize, base: usize, depth: usize, budget: &mut Budget) -> Result<String> { fn read_string(
bytes: &[u8],
offset: usize,
base: usize,
depth: usize,
budget: &mut Budget,
) -> Result<String> {
ensure!(depth <= MAX_DEPTH, "MMDB nesting limit exceeded"); ensure!(depth <= MAX_DEPTH, "MMDB nesting limit exceeded");
budget.charge()?; budget.charge()?;
let h = header(bytes, offset, base)?; let h = header(bytes, offset, base)?;
@@ -226,31 +303,61 @@ fn read_string(bytes: &[u8], offset: usize, base: usize, depth: usize, budget: &
return read_string(bytes, pointer, base, depth + 1, budget); return read_string(bytes, pointer, base, depth + 1, budget);
} }
ensure!(h.kind == 2, "MMDB value is not a string"); ensure!(h.kind == 2, "MMDB value is not a string");
ensure!(h.size <= MAX_STRING_BYTES, "MMDB string exceeds the decode limit"); ensure!(
let data = bytes.get(h.payload..h.next).context("MMDB string is truncated")?; h.size <= MAX_STRING_BYTES,
Ok(std::str::from_utf8(data).context("MMDB string is not UTF-8")?.to_owned()) "MMDB string exceeds the decode limit"
);
let data = bytes
.get(h.payload..h.next)
.context("MMDB string is truncated")?;
Ok(std::str::from_utf8(data)
.context("MMDB string is not UTF-8")?
.to_owned())
} }
fn read_uint(bytes: &[u8], offset: usize, base: usize, depth: usize, budget: &mut Budget) -> Result<u64> { fn read_uint(
bytes: &[u8],
offset: usize,
base: usize,
depth: usize,
budget: &mut Budget,
) -> Result<u64> {
ensure!(depth <= MAX_DEPTH, "MMDB nesting limit exceeded"); ensure!(depth <= MAX_DEPTH, "MMDB nesting limit exceeded");
budget.charge()?; budget.charge()?;
let h = header(bytes, offset, base)?; let h = header(bytes, offset, base)?;
if let Some(pointer) = h.pointer { if let Some(pointer) = h.pointer {
return read_uint(bytes, pointer, base, depth + 1, budget); return read_uint(bytes, pointer, base, depth + 1, budget);
} }
ensure!(matches!(h.kind, 5 | 6 | 9), "MMDB value is not an unsigned integer"); ensure!(
matches!(h.kind, 5 | 6 | 9),
"MMDB value is not an unsigned integer"
);
ensure!(h.size <= 8, "MMDB integer is too wide"); ensure!(h.size <= 8, "MMDB integer is too wide");
read_be(bytes, h.payload, h.size) read_be(bytes, h.payload, h.size)
} }
fn inline_string<'a>(bytes: &'a [u8], offset: usize, base: usize) -> Result<(&'a str, usize)> { fn inline_string<'a>(bytes: &'a [u8], offset: usize, base: usize) -> Result<(&'a str, usize)> {
let h = header(bytes, offset, base)?; let h = header(bytes, offset, base)?;
ensure!(h.pointer.is_none() && h.kind == 2, "MMDB map key is not an inline string"); ensure!(
let value = bytes.get(h.payload..h.next).context("MMDB map key is truncated")?; h.pointer.is_none() && h.kind == 2,
Ok((std::str::from_utf8(value).context("MMDB map key is not UTF-8")?, h.next)) "MMDB map key is not an inline string"
);
let value = bytes
.get(h.payload..h.next)
.context("MMDB map key is truncated")?;
Ok((
std::str::from_utf8(value).context("MMDB map key is not UTF-8")?,
h.next,
))
} }
fn skip(bytes: &[u8], offset: usize, base: usize, depth: usize, budget: &mut Budget) -> Result<usize> { fn skip(
bytes: &[u8],
offset: usize,
base: usize,
depth: usize,
budget: &mut Budget,
) -> Result<usize> {
ensure!(depth <= MAX_DEPTH, "MMDB nesting limit exceeded"); ensure!(depth <= MAX_DEPTH, "MMDB nesting limit exceeded");
budget.charge()?; budget.charge()?;
let h = header(bytes, offset, base)?; let h = header(bytes, offset, base)?;
@@ -290,18 +397,30 @@ fn header(bytes: &[u8], offset: usize, base: usize) -> Result<Header> {
let raw = read_be(bytes, payload, count)? as usize; let raw = read_be(bytes, payload, count)? as usize;
let pointer = match class { let pointer = match class {
0 => (low << 8) | raw, 0 => (low << 8) | raw,
1 => ((low << 16) | raw).checked_add(2048).context("MMDB pointer overflow")?, 1 => ((low << 16) | raw)
2 => ((low << 24) | raw).checked_add(526_336).context("MMDB pointer overflow")?, .checked_add(2048)
.context("MMDB pointer overflow")?,
2 => ((low << 24) | raw)
.checked_add(526_336)
.context("MMDB pointer overflow")?,
3 => raw, 3 => raw,
_ => unreachable!(), _ => unreachable!(),
}; };
let target = base.checked_add(pointer).context("MMDB pointer overflow")?; let target = base.checked_add(pointer).context("MMDB pointer overflow")?;
ensure!(target < bytes.len(), "MMDB pointer is out of bounds"); ensure!(target < bytes.len(), "MMDB pointer is out of bounds");
return Ok(Header { kind: 1, size: 0, payload, next: payload + count, pointer: Some(target) }); return Ok(Header {
kind: 1,
size: 0,
payload,
next: payload + count,
pointer: Some(target),
});
} }
let mut cursor = offset + 1; let mut cursor = offset + 1;
let kind = if primary == 0 { let kind = if primary == 0 {
let extended = *bytes.get(cursor).context("MMDB extended type is truncated")?; let extended = *bytes
.get(cursor)
.context("MMDB extended type is truncated")?;
cursor += 1; cursor += 1;
extended.checked_add(7).context("MMDB type overflow")? extended.checked_add(7).context("MMDB type overflow")?
} else { } else {
@@ -314,20 +433,35 @@ fn header(bytes: &[u8], offset: usize, base: usize) -> Result<Header> {
31 => 65_821 + read_be(bytes, cursor, 3)? as usize, 31 => 65_821 + read_be(bytes, cursor, 3)? as usize,
_ => unreachable!(), _ => unreachable!(),
}; };
cursor += match size_bits { 29 => 1, 30 => 2, 31 => 3, _ => 0 }; cursor += match size_bits {
29 => 1,
30 => 2,
31 => 3,
_ => 0,
};
let next = match kind { let next = match kind {
7 | 11 | 14 => cursor, 7 | 11 | 14 => cursor,
_ => cursor.checked_add(size).context("MMDB field size overflow")?, _ => cursor
.checked_add(size)
.context("MMDB field size overflow")?,
}; };
ensure!(next <= bytes.len(), "MMDB field is out of bounds"); ensure!(next <= bytes.len(), "MMDB field is out of bounds");
Ok(Header { kind, size, payload: cursor, next, pointer: None }) Ok(Header {
kind,
size,
payload: cursor,
next,
pointer: None,
})
} }
fn read_be(bytes: &[u8], offset: usize, count: usize) -> Result<u64> { fn read_be(bytes: &[u8], offset: usize, count: usize) -> Result<u64> {
ensure!(count <= 8, "MMDB integer is too wide"); ensure!(count <= 8, "MMDB integer is too wide");
let end = offset.checked_add(count).context("MMDB read overflow")?; let end = offset.checked_add(count).context("MMDB read overflow")?;
let data = bytes.get(offset..end).context("MMDB value is truncated")?; let data = bytes.get(offset..end).context("MMDB value is truncated")?;
Ok(data.iter().fold(0u64, |value, byte| (value << 8) | u64::from(*byte))) Ok(data
.iter()
.fold(0u64, |value, byte| (value << 8) | u64::from(*byte)))
} }
#[cfg(test)] #[cfg(test)]
+12 -3
View File
@@ -59,7 +59,10 @@ mod tests {
std::fs::write(&path, fixture("PL")).unwrap(); std::fs::write(&path, fixture("PL")).unwrap();
let database = Database::load(&path, 1024 * 1024).unwrap(); let database = Database::load(&path, 1024 * 1024).unwrap();
assert_eq!(database.database_type(), "GeoLite2-Country"); assert_eq!(database.database_type(), "GeoLite2-Country");
assert_eq!(database.country("192.0.2.1".parse().unwrap()).as_deref(), Some("PL")); assert_eq!(
database.country("192.0.2.1".parse().unwrap()).as_deref(),
Some("PL")
);
assert!(database.country("2001:db8::1".parse().unwrap()).is_none()); assert!(database.country("2001:db8::1".parse().unwrap()).is_none());
} }
@@ -69,7 +72,10 @@ mod tests {
let path = directory.path().join("country.mmdb"); let path = directory.path().join("country.mmdb");
std::fs::write(&path, sapics_fixture("DE")).unwrap(); std::fs::write(&path, sapics_fixture("DE")).unwrap();
let database = Database::load(&path, 1024 * 1024).unwrap(); let database = Database::load(&path, 1024 * 1024).unwrap();
assert_eq!(database.country("198.51.100.8".parse().unwrap()).as_deref(), Some("DE")); assert_eq!(
database.country("198.51.100.8".parse().unwrap()).as_deref(),
Some("DE")
);
} }
#[test] #[test]
@@ -78,7 +84,10 @@ mod tests {
let path = directory.path().join("country.mmdb"); let path = directory.path().join("country.mmdb");
std::fs::write(&path, fixture_with_record(string("FR"))).unwrap(); std::fs::write(&path, fixture_with_record(string("FR"))).unwrap();
let database = Database::load(&path, 1024 * 1024).unwrap(); let database = Database::load(&path, 1024 * 1024).unwrap();
assert_eq!(database.country("192.0.2.44".parse().unwrap()).as_deref(), Some("FR")); assert_eq!(
database.country("192.0.2.44".parse().unwrap()).as_deref(),
Some("FR")
);
} }
#[test] #[test]
+28 -7
View File
@@ -6,14 +6,32 @@ mod tests {
assert_eq!(std::mem::size_of::<V6Range>(), 34); assert_eq!(std::mem::size_of::<V6Range>(), 34);
assert_eq!(std::mem::size_of::<V4Range>(), 12); assert_eq!(std::mem::size_of::<V4Range>(), 12);
let ranges = [ let ranges = [
V6Range { start: 0u128.to_be_bytes(), end: 127u128.to_be_bytes(), country: *b"PL" }, V6Range {
V6Range { start: 256u128.to_be_bytes(), end: 65535u128.to_be_bytes(), country: *b"DE" }, start: 0u128.to_be_bytes(),
V6Range { start: (1u128 << 120).to_be_bytes(), end: u128::MAX.to_be_bytes(), country: *b"US" }, end: 127u128.to_be_bytes(),
country: *b"PL",
},
V6Range {
start: 256u128.to_be_bytes(),
end: 65535u128.to_be_bytes(),
country: *b"DE",
},
V6Range {
start: (1u128 << 120).to_be_bytes(),
end: u128::MAX.to_be_bytes(),
country: *b"US",
},
]; ];
for (address, expected) in [ for (address, expected) in [
(0, Some(*b"PL")), (127, Some(*b"PL")), (128, None), (0, Some(*b"PL")),
(255, None), (256, Some(*b"DE")), (65535, Some(*b"DE")), (127, Some(*b"PL")),
(65536, None), (1u128 << 120, Some(*b"US")), (u128::MAX, Some(*b"US")), (128, None),
(255, None),
(256, Some(*b"DE")),
(65535, Some(*b"DE")),
(65536, None),
(1u128 << 120, Some(*b"US")),
(u128::MAX, Some(*b"US")),
] { ] {
assert_eq!(find_v6(&ranges, address.to_be_bytes()), expected); assert_eq!(find_v6(&ranges, address.to_be_bytes()), expected);
} }
@@ -33,7 +51,10 @@ mod tests {
) )
.unwrap(); .unwrap();
let db = Database::load_format(directory.path(), DatabaseFormat::CsvPair).unwrap(); let db = Database::load_format(directory.path(), DatabaseFormat::CsvPair).unwrap();
assert_eq!(db.country("192.0.2.7".parse().unwrap()).as_deref(), Some("PL")); assert_eq!(
db.country("192.0.2.7".parse().unwrap()).as_deref(),
Some("PL")
);
assert!(db.country("198.51.100.1".parse().unwrap()).is_none()); assert!(db.country("198.51.100.1".parse().unwrap()).is_none());
} }
} }
+178 -50
View File
@@ -94,9 +94,12 @@ impl UpdateSettings {
} }
} }
} }
self.ipv4_checksum_url = validate_optional_url("GeoIP IPv4 checksum URL", self.ipv4_checksum_url.take())?; self.ipv4_checksum_url =
self.ipv6_checksum_url = validate_optional_url("GeoIP IPv6 checksum URL", self.ipv6_checksum_url.take())?; validate_optional_url("GeoIP IPv4 checksum URL", self.ipv4_checksum_url.take())?;
self.mmdb_checksum_url = validate_optional_url("GeoIP MMDB checksum URL", self.mmdb_checksum_url.take())?; self.ipv6_checksum_url =
validate_optional_url("GeoIP IPv6 checksum URL", self.ipv6_checksum_url.take())?;
self.mmdb_checksum_url =
validate_optional_url("GeoIP MMDB checksum URL", self.mmdb_checksum_url.take())?;
ensure!( ensure!(
(1024 * 1024..=MAX_CONFIGURED_BYTES).contains(&self.max_bytes), (1024 * 1024..=MAX_CONFIGURED_BYTES).contains(&self.max_bytes),
"GeoIP maximum file size must be between 1 MiB and 256 MiB" "GeoIP maximum file size must be between 1 MiB and 256 MiB"
@@ -166,8 +169,14 @@ fn validate_direct_url(label: &str, value: &str) -> Result<String> {
let parsed = Url::parse(value).with_context(|| format!("invalid {label}"))?; let parsed = Url::parse(value).with_context(|| format!("invalid {label}"))?;
ensure!(parsed.scheme() == "https", "{label} must use https://"); ensure!(parsed.scheme() == "https", "{label} must use https://");
ensure!(parsed.host_str().is_some(), "{label} must contain a host"); ensure!(parsed.host_str().is_some(), "{label} must contain a host");
ensure!(parsed.username().is_empty() && parsed.password().is_none(), "{label} must not contain URL credentials"); ensure!(
ensure!(parsed.fragment().is_none(), "{label} must not contain a fragment"); parsed.username().is_empty() && parsed.password().is_none(),
"{label} must not contain URL credentials"
);
ensure!(
parsed.fragment().is_none(),
"{label} must not contain a fragment"
);
Ok(parsed.to_string()) Ok(parsed.to_string())
} }
@@ -183,9 +192,18 @@ fn validate_optional_url(label: &str, value: Option<String>) -> Result<Option<St
fn validate_legacy_base(label: &str, value: &str) -> Result<String> { fn validate_legacy_base(label: &str, value: &str) -> Result<String> {
let mut parsed = Url::parse(value).with_context(|| format!("invalid {label}"))?; let mut parsed = Url::parse(value).with_context(|| format!("invalid {label}"))?;
ensure!(parsed.scheme() == "https" && parsed.host_str().is_some(), "{label} must be an HTTPS URL"); ensure!(
ensure!(parsed.username().is_empty() && parsed.password().is_none(), "{label} must not contain credentials"); parsed.scheme() == "https" && parsed.host_str().is_some(),
ensure!(parsed.query().is_none() && parsed.fragment().is_none(), "{label} must not contain a query or fragment"); "{label} must be an HTTPS URL"
);
ensure!(
parsed.username().is_empty() && parsed.password().is_none(),
"{label} must not contain credentials"
);
ensure!(
parsed.query().is_none() && parsed.fragment().is_none(),
"{label} must not contain a query or fragment"
);
if !parsed.path().ends_with('/') { if !parsed.path().ends_with('/') {
parsed.set_path(&format!("{}/", parsed.path())); parsed.set_path(&format!("{}/", parsed.path()));
} }
@@ -208,7 +226,9 @@ fn try_update_lock(data_dir: &Path) -> Result<Option<UpdateLock>> {
use std::os::unix::fs::OpenOptionsExt; use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600).custom_flags(libc::O_NOFOLLOW); options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
} }
let file = options.open(&path).with_context(|| format!("open GeoIP update lock {}", path.display()))?; let file = options
.open(&path)
.with_context(|| format!("open GeoIP update lock {}", path.display()))?;
match file.try_lock_exclusive() { match file.try_lock_exclusive() {
Ok(()) => Ok(Some(UpdateLock(file))), Ok(()) => Ok(Some(UpdateLock(file))),
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => Ok(None), Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => Ok(None),
@@ -220,8 +240,12 @@ pub async fn poll_loop(app: Weak<crate::runtime::App>, shutdown: CancellationTok
let mut observed: Option<UpdateSettings> = None; let mut observed: Option<UpdateSettings> = None;
let mut next_attempt = tokio::time::Instant::now(); let mut next_attempt = tokio::time::Instant::now();
loop { loop {
if shutdown.is_cancelled() { return; } if shutdown.is_cancelled() {
let Some(app) = app.upgrade() else { return; }; return;
}
let Some(app) = app.upgrade() else {
return;
};
let settings = app.config.load().envelope.config.geoip_updates.clone(); let settings = app.config.load().envelope.config.geoip_updates.clone();
if observed.as_ref() != Some(&settings) { if observed.as_ref() != Some(&settings) {
next_attempt = tokio::time::Instant::now(); next_attempt = tokio::time::Instant::now();
@@ -235,7 +259,9 @@ pub async fn poll_loop(app: Weak<crate::runtime::App>, shutdown: CancellationTok
Duration::from_secs(settings.interval_seconds) Duration::from_secs(settings.interval_seconds)
} }
Ok(UpdateOutcome::Current) => Duration::from_secs(settings.interval_seconds), Ok(UpdateOutcome::Current) => Duration::from_secs(settings.interval_seconds),
Ok(UpdateOutcome::Busy) => RETRY_INTERVAL.min(Duration::from_secs(settings.interval_seconds)), Ok(UpdateOutcome::Busy) => {
RETRY_INTERVAL.min(Duration::from_secs(settings.interval_seconds))
}
Err(error) => { Err(error) => {
tracing::warn!(error = %format!("{error:#}"), "GeoIP update failed; proxy traffic is unaffected"); tracing::warn!(error = %format!("{error:#}"), "GeoIP update failed; proxy traffic is unaffected");
RETRY_INTERVAL.min(Duration::from_secs(settings.interval_seconds)) RETRY_INTERVAL.min(Duration::from_secs(settings.interval_seconds))
@@ -244,7 +270,9 @@ pub async fn poll_loop(app: Weak<crate::runtime::App>, shutdown: CancellationTok
next_attempt = tokio::time::Instant::now() + interval; next_attempt = tokio::time::Instant::now() + interval;
} }
let wait = if settings.enabled { let wait = if settings.enabled {
next_attempt.saturating_duration_since(tokio::time::Instant::now()).min(CONFIG_POLL_INTERVAL) next_attempt
.saturating_duration_since(tokio::time::Instant::now())
.min(CONFIG_POLL_INTERVAL)
} else { } else {
CONFIG_POLL_INTERVAL CONFIG_POLL_INTERVAL
}; };
@@ -256,7 +284,11 @@ pub async fn poll_loop(app: Weak<crate::runtime::App>, shutdown: CancellationTok
} }
} }
enum UpdateOutcome { Updated(Vec<String>), Current, Busy } enum UpdateOutcome {
Updated(Vec<String>),
Current,
Busy,
}
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct UpdateResult { pub struct UpdateResult {
@@ -273,12 +305,25 @@ pub async fn update_now(app: &Arc<crate::runtime::App>) -> Result<UpdateResult>
UpdateOutcome::Current => ("current", Vec::new()), UpdateOutcome::Current => ("current", Vec::new()),
UpdateOutcome::Busy => ("busy", Vec::new()), UpdateOutcome::Busy => ("busy", Vec::new()),
}; };
Ok(UpdateResult { outcome, updated_files, database: app.geoip.load().view() }) Ok(UpdateResult {
outcome,
updated_files,
database: app.geoip.load().view(),
})
} }
async fn update_once(app: &Arc<crate::runtime::App>, settings: &UpdateSettings) -> Result<UpdateOutcome> { async fn update_once(
let data_dir = app.policy.data_dir.as_deref().context("GeoIP updater requires a data directory")?; app: &Arc<crate::runtime::App>,
let Some(_lock) = try_update_lock(data_dir)? else { return Ok(UpdateOutcome::Busy); }; settings: &UpdateSettings,
) -> Result<UpdateOutcome> {
let data_dir = app
.policy
.data_dir
.as_deref()
.context("GeoIP updater requires a data directory")?;
let Some(_lock) = try_update_lock(data_dir)? else {
return Ok(UpdateOutcome::Busy);
};
let directory = data_dir.join("geoip"); let directory = data_dir.join("geoip");
ensure_safe_directory(&directory)?; ensure_safe_directory(&directory)?;
if settings.format == super::DatabaseFormat::Mmdb && settings.mmdb_url.trim().is_empty() { if settings.format == super::DatabaseFormat::Mmdb && settings.mmdb_url.trim().is_empty() {
@@ -295,9 +340,16 @@ async fn update_once(app: &Arc<crate::runtime::App>, settings: &UpdateSettings)
} }
let mut current = Vec::with_capacity(assets.len()); let mut current = Vec::with_capacity(assets.len());
for asset in &assets { for asset in &assets {
current.push(file_digest(&directory.join(asset.name), settings.max_bytes)?); current.push(file_digest(
&directory.join(asset.name),
settings.max_bytes,
)?);
} }
if assets.iter().enumerate().all(|(index, _)| expected[index].as_ref().is_some_and(|digest| current[index].as_deref() == Some(digest))) { if assets.iter().enumerate().all(|(index, _)| {
expected[index]
.as_ref()
.is_some_and(|digest| current[index].as_deref() == Some(digest))
}) {
reload_if_needed(app, directory, settings.format).await?; reload_if_needed(app, directory, settings.format).await?;
return Ok(UpdateOutcome::Current); return Ok(UpdateOutcome::Current);
} }
@@ -307,35 +359,64 @@ async fn update_once(app: &Arc<crate::runtime::App>, settings: &UpdateSettings)
let result = async { let result = async {
for (index, asset) in assets.iter().enumerate() { for (index, asset) in assets.iter().enumerate() {
let target = stage.join(asset.name); let target = stage.join(asset.name);
if expected[index].as_ref().is_some_and(|digest| current[index].as_deref() == Some(digest)) { if expected[index]
.as_ref()
.is_some_and(|digest| current[index].as_deref() == Some(digest))
{
copy_private(&directory.join(asset.name), &target, settings.max_bytes)?; copy_private(&directory.join(asset.name), &target, settings.max_bytes)?;
} else { } else {
super::update_http::download(&asset.url, &target, settings.max_bytes, expected[index].as_deref()).await?; super::update_http::download(
&asset.url,
&target,
settings.max_bytes,
expected[index].as_deref(),
)
.await?;
} }
} }
let validation_stage = stage.clone(); let validation_stage = stage.clone();
let format = settings.format; let format = settings.format;
tokio::task::spawn_blocking(move || super::validate_directory_format(&validation_stage, format)) tokio::task::spawn_blocking(move || {
.await.context("GeoIP validation worker failed")??; super::validate_directory_format(&validation_stage, format)
})
.await
.context("GeoIP validation worker failed")??;
let mut changed = Vec::new(); let mut changed = Vec::new();
for (index, asset) in assets.iter().enumerate() { for (index, asset) in assets.iter().enumerate() {
let staged = file_digest(&stage.join(asset.name), settings.max_bytes)?.context("staged GeoIP file disappeared")?; let staged = file_digest(&stage.join(asset.name), settings.max_bytes)?
.context("staged GeoIP file disappeared")?;
if current[index].as_deref() != Some(staged.as_str()) { if current[index].as_deref() != Some(staged.as_str()) {
publish_private(&stage.join(asset.name), &directory.join(asset.name), settings.max_bytes)?; publish_private(
&stage.join(asset.name),
&directory.join(asset.name),
settings.max_bytes,
)?;
changed.push(asset.name.to_string()); changed.push(asset.name.to_string());
} }
} }
reload(app, directory.clone(), settings.format).await?; reload(app, directory.clone(), settings.format).await?;
Ok::<Vec<String>, anyhow::Error>(changed) Ok::<Vec<String>, anyhow::Error>(changed)
}.await; }
.await;
let _ = fs::remove_dir_all(&stage); let _ = fs::remove_dir_all(&stage);
let changed = result?; let changed = result?;
if changed.is_empty() { Ok(UpdateOutcome::Current) } else { Ok(UpdateOutcome::Updated(changed)) } if changed.is_empty() {
Ok(UpdateOutcome::Current)
} else {
Ok(UpdateOutcome::Updated(changed))
}
} }
async fn reload_if_needed(app: &Arc<crate::runtime::App>, directory: PathBuf, format: super::DatabaseFormat) -> Result<()> { async fn reload_if_needed(
let expected = match format { super::DatabaseFormat::CsvPair => "csv_pair", super::DatabaseFormat::Mmdb => "mmdb" }; app: &Arc<crate::runtime::App>,
directory: PathBuf,
format: super::DatabaseFormat,
) -> Result<()> {
let expected = match format {
super::DatabaseFormat::CsvPair => "csv_pair",
super::DatabaseFormat::Mmdb => "mmdb",
};
let view = app.geoip.load().view(); let view = app.geoip.load().view();
if !view.loaded || view.format != expected { if !view.loaded || view.format != expected {
reload(app, directory, format).await?; reload(app, directory, format).await?;
@@ -343,9 +424,15 @@ async fn reload_if_needed(app: &Arc<crate::runtime::App>, directory: PathBuf, fo
Ok(()) Ok(())
} }
async fn reload(app: &Arc<crate::runtime::App>, directory: PathBuf, format: super::DatabaseFormat) -> Result<()> { async fn reload(
let database = tokio::task::spawn_blocking(move || super::Database::load_format(&directory, format)) app: &Arc<crate::runtime::App>,
.await.context("GeoIP reload worker failed")??; directory: PathBuf,
format: super::DatabaseFormat,
) -> Result<()> {
let database =
tokio::task::spawn_blocking(move || super::Database::load_format(&directory, format))
.await
.context("GeoIP reload worker failed")??;
publish_database(&app.geoip, database); publish_database(&app.geoip, database);
Ok(()) Ok(())
} }
@@ -365,7 +452,10 @@ async fn publisher_checksum(url: &str) -> Result<String> {
fn ensure_safe_directory(directory: &Path) -> Result<()> { fn ensure_safe_directory(directory: &Path) -> Result<()> {
match fs::symlink_metadata(directory) { match fs::symlink_metadata(directory) {
Ok(metadata) => ensure!(metadata.is_dir() && !metadata.file_type().is_symlink(), "GeoIP directory must be a real directory"), Ok(metadata) => ensure!(
metadata.is_dir() && !metadata.file_type().is_symlink(),
"GeoIP directory must be a real directory"
),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => private_directory(directory)?, Err(error) if error.kind() == std::io::ErrorKind::NotFound => private_directory(directory)?,
Err(error) => return Err(error.into()), Err(error) => return Err(error.into()),
} }
@@ -375,7 +465,10 @@ fn ensure_safe_directory(directory: &Path) -> Result<()> {
fn private_directory(path: &Path) -> Result<()> { fn private_directory(path: &Path) -> Result<()> {
fs::create_dir_all(path)?; fs::create_dir_all(path)?;
let metadata = fs::symlink_metadata(path)?; let metadata = fs::symlink_metadata(path)?;
ensure!(metadata.is_dir() && !metadata.file_type().is_symlink(), "GeoIP directory must not be a symlink"); ensure!(
metadata.is_dir() && !metadata.file_type().is_symlink(),
"GeoIP directory must not be a symlink"
);
#[cfg(unix)] #[cfg(unix)]
{ {
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
@@ -390,15 +483,24 @@ fn file_digest(path: &Path, max_bytes: u64) -> Result<Option<String>> {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error.into()), Err(error) => return Err(error.into()),
}; };
if !metadata.is_file() || metadata.file_type().is_symlink() || metadata.len() > max_bytes { return Ok(None); } if !metadata.is_file() || metadata.file_type().is_symlink() || metadata.len() > max_bytes {
let mut options = OpenOptions::new(); options.read(true); return Ok(None);
#[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; options.custom_flags(libc::O_NOFOLLOW); } }
let mut options = OpenOptions::new();
options.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NOFOLLOW);
}
let mut file = options.open(path)?; let mut file = options.open(path)?;
let mut digest = Sha256::new(); let mut digest = Sha256::new();
let mut buffer = [0u8; 1024 * 1024]; let mut buffer = [0u8; 1024 * 1024];
loop { loop {
let read = file.read(&mut buffer)?; let read = file.read(&mut buffer)?;
if read == 0 { break; } if read == 0 {
break;
}
digest.update(&buffer[..read]); digest.update(&buffer[..read]);
} }
Ok(Some(format!("{:x}", digest.finalize()))) Ok(Some(format!("{:x}", digest.finalize())))
@@ -406,11 +508,20 @@ fn file_digest(path: &Path, max_bytes: u64) -> Result<Option<String>> {
fn copy_private(source: &Path, target: &Path, max_bytes: u64) -> Result<()> { fn copy_private(source: &Path, target: &Path, max_bytes: u64) -> Result<()> {
let metadata = fs::symlink_metadata(source)?; let metadata = fs::symlink_metadata(source)?;
ensure!(metadata.is_file() && !metadata.file_type().is_symlink(), "GeoIP source must be a regular file"); ensure!(
ensure!(metadata.len() <= max_bytes, "GeoIP source exceeds configured maximum size"); metadata.is_file() && !metadata.file_type().is_symlink(),
let mut source_options = OpenOptions::new(); source_options.read(true); "GeoIP source must be a regular file"
let mut target_options = OpenOptions::new(); target_options.write(true).create_new(true); );
#[cfg(unix)] { ensure!(
metadata.len() <= max_bytes,
"GeoIP source exceeds configured maximum size"
);
let mut source_options = OpenOptions::new();
source_options.read(true);
let mut target_options = OpenOptions::new();
target_options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt; use std::os::unix::fs::OpenOptionsExt;
source_options.custom_flags(libc::O_NOFOLLOW); source_options.custom_flags(libc::O_NOFOLLOW);
target_options.mode(0o600).custom_flags(libc::O_NOFOLLOW); target_options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
@@ -419,7 +530,10 @@ fn copy_private(source: &Path, target: &Path, max_bytes: u64) -> Result<()> {
let mut input = source.take(max_bytes.saturating_add(1)); let mut input = source.take(max_bytes.saturating_add(1));
let mut output = target_options.open(target)?; let mut output = target_options.open(target)?;
let copied = std::io::copy(&mut input, &mut output)?; let copied = std::io::copy(&mut input, &mut output)?;
ensure!(copied <= max_bytes, "GeoIP source exceeds configured maximum size"); ensure!(
copied <= max_bytes,
"GeoIP source exceeds configured maximum size"
);
output.sync_all()?; output.sync_all()?;
Ok(()) Ok(())
} }
@@ -427,12 +541,26 @@ fn copy_private(source: &Path, target: &Path, max_bytes: u64) -> Result<()> {
fn publish_private(source: &Path, target: &Path, max_bytes: u64) -> Result<()> { fn publish_private(source: &Path, target: &Path, max_bytes: u64) -> Result<()> {
let parent = target.parent().context("GeoIP target has no parent")?; let parent = target.parent().context("GeoIP target has no parent")?;
ensure_safe_directory(parent)?; ensure_safe_directory(parent)?;
let temporary = parent.join(format!(".{}.tmp-{:016x}", target.file_name().unwrap_or_default().to_string_lossy(), rand::random::<u64>())); let temporary = parent.join(format!(
if let Err(error) = copy_private(source, &temporary, max_bytes) { let _ = fs::remove_file(&temporary); return Err(error); } ".{}.tmp-{:016x}",
if let Err(error) = fs::rename(&temporary, target) { let _ = fs::remove_file(&temporary); return Err(error.into()); } target.file_name().unwrap_or_default().to_string_lossy(),
#[cfg(unix)] { rand::random::<u64>()
));
if let Err(error) = copy_private(source, &temporary, max_bytes) {
let _ = fs::remove_file(&temporary);
return Err(error);
}
if let Err(error) = fs::rename(&temporary, target) {
let _ = fs::remove_file(&temporary);
return Err(error.into());
}
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt; use std::os::unix::fs::OpenOptionsExt;
let directory = OpenOptions::new().read(true).custom_flags(libc::O_DIRECTORY).open(parent)?; let directory = OpenOptions::new()
.read(true)
.custom_flags(libc::O_DIRECTORY)
.open(parent)?;
directory.sync_all()?; directory.sync_all()?;
} }
Ok(()) Ok(())
+10 -13
View File
@@ -9,12 +9,7 @@ use hyper_util::{
}; };
use rustls::ClientConfig; use rustls::ClientConfig;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::{ use std::{fs::OpenOptions, path::Path, sync::Arc, time::Duration};
fs::OpenOptions,
path::Path,
sync::Arc,
time::Duration,
};
use tokio::io::AsyncWriteExt; use tokio::io::AsyncWriteExt;
use url::Url; use url::Url;
@@ -66,12 +61,11 @@ pub(super) async fn download(
fn client() -> Result<Client<hyper_rustls::HttpsConnector<HttpConnector>, Body>> { fn client() -> Result<Client<hyper_rustls::HttpsConnector<HttpConnector>, Body>> {
let roots = crate::update_check::load_root_store(); let roots = crate::update_check::load_root_store();
let tls = ClientConfig::builder_with_provider(Arc::new( let tls =
rustls::crypto::ring::default_provider(), ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
)) .with_safe_default_protocol_versions()?
.with_safe_default_protocol_versions()? .with_root_certificates(roots)
.with_root_certificates(roots) .with_no_client_auth();
.with_no_client_auth();
let mut http = HttpConnector::new(); let mut http = HttpConnector::new();
http.enforce_http(false); http.enforce_http(false);
http.set_connect_timeout(Some(REQUEST_TIMEOUT)); http.set_connect_timeout(Some(REQUEST_TIMEOUT));
@@ -123,7 +117,10 @@ fn redirect(
.to_str() .to_str()
.context("GeoIP redirect Location is not UTF-8")?; .context("GeoIP redirect Location is not UTF-8")?;
let target = crate::update_check::redirect_target(current, location)?; let target = crate::update_check::redirect_target(current, location)?;
ensure!(target.scheme() == "https", "GeoIP redirect must remain HTTPS"); ensure!(
target.scheme() == "https",
"GeoIP redirect must remain HTTPS"
);
Ok(target) Ok(target)
} }
+22 -5
View File
@@ -76,19 +76,36 @@ mod tests {
std::fs::write(&ipv4, "192.0.2.0,192.0.2.255,PL\n").unwrap(); std::fs::write(&ipv4, "192.0.2.0,192.0.2.255,PL\n").unwrap();
std::fs::write(&ipv6, "2001:db8::,2001:db8::ffff,PL\n").unwrap(); std::fs::write(&ipv6, "2001:db8::,2001:db8::ffff,PL\n").unwrap();
let slot = arc_swap::ArcSwap::from_pointee( let slot = arc_swap::ArcSwap::from_pointee(
crate::geoip::Database::load_format(directory.path(), crate::geoip::DatabaseFormat::CsvPair).unwrap(), crate::geoip::Database::load_format(
directory.path(),
crate::geoip::DatabaseFormat::CsvPair,
)
.unwrap(),
); );
let previous = slot.load_full(); let previous = slot.load_full();
assert_eq!(slot.load().country("192.0.2.7".parse().unwrap()).as_deref(), Some("PL")); assert_eq!(
slot.load().country("192.0.2.7".parse().unwrap()).as_deref(),
Some("PL")
);
std::fs::write(&ipv4, "192.0.2.0,192.0.2.255,DE\n").unwrap(); std::fs::write(&ipv4, "192.0.2.0,192.0.2.255,DE\n").unwrap();
std::fs::write(&ipv6, "2001:db8::,2001:db8::ffff,DE\n").unwrap(); std::fs::write(&ipv6, "2001:db8::,2001:db8::ffff,DE\n").unwrap();
publish_database( publish_database(
&slot, &slot,
crate::geoip::Database::load_format(directory.path(), crate::geoip::DatabaseFormat::CsvPair).unwrap(), crate::geoip::Database::load_format(
directory.path(),
crate::geoip::DatabaseFormat::CsvPair,
)
.unwrap(),
); );
assert_eq!(slot.load().country("192.0.2.7".parse().unwrap()).as_deref(), Some("DE")); assert_eq!(
assert_eq!(previous.country("192.0.2.7".parse().unwrap()).as_deref(), Some("PL")); slot.load().country("192.0.2.7".parse().unwrap()).as_deref(),
Some("DE")
);
assert_eq!(
previous.country("192.0.2.7".parse().unwrap()).as_deref(),
Some("PL")
);
} }
} }
+4 -1
View File
@@ -35,7 +35,10 @@ mod tests {
response: vec![], response: vec![],
}; };
assert!(rules.compile().is_err(), "{name}"); assert!(rules.compile().is_err(), "{name}");
let response_rules = HeaderRules { request: vec![], response: rules.request }; let response_rules = HeaderRules {
request: vec![],
response: rules.request,
};
assert!(response_rules.compile().is_err(), "response {name}"); assert!(response_rules.compile().is_err(), "response {name}");
} }
let rules = HeaderRules { let rules = HeaderRules {
+4 -4
View File
@@ -3,8 +3,8 @@ pub mod acme;
pub mod admin; pub mod admin;
#[path = "auth/mod.rs"] #[path = "auth/mod.rs"]
pub mod auth; pub mod auth;
pub mod basic_auth;
pub mod backup; pub mod backup;
pub mod basic_auth;
pub mod config; pub mod config;
pub mod db; pub mod db;
pub mod demo; pub mod demo;
@@ -17,26 +17,26 @@ pub mod runtime;
pub mod server; pub mod server;
pub mod tls; pub mod tls;
pub mod traffic; pub mod traffic;
pub mod upstream;
pub mod update_check; pub mod update_check;
pub mod upstream;
pub mod user_agent; pub mod user_agent;
pub mod cache; pub mod cache;
pub mod compression; pub mod compression;
pub mod transform;
pub mod diagnostics; pub mod diagnostics;
pub mod docker; pub mod docker;
pub mod pages; pub mod pages;
pub mod settings; pub mod settings;
pub mod stream; pub mod stream;
pub mod system_stats; pub mod system_stats;
pub mod transform;
pub mod balance; pub mod balance;
pub mod forwarding; pub mod forwarding;
pub mod geoip; pub mod geoip;
pub mod http3; pub mod http3;
pub mod security;
pub mod secret_store; pub mod secret_store;
pub mod security;
pub mod tinyauth; pub mod tinyauth;
pub mod vhost; pub mod vhost;
pub mod web_server; pub mod web_server;
+10 -2
View File
@@ -119,7 +119,11 @@ fn render_backends(
), ),
("read_timeouts_total", "read_timeouts_total", "counter"), ("read_timeouts_total", "read_timeouts_total", "counter"),
("send_timeouts_total", "send_timeouts_total", "counter"), ("send_timeouts_total", "send_timeouts_total", "counter"),
("upstream_client_generation", "upstream_client_generation", "gauge"), (
"upstream_client_generation",
"upstream_client_generation",
"gauge",
),
( (
"upstream_client_generation_requests", "upstream_client_generation_requests",
"upstream_client_generation_requests", "upstream_client_generation_requests",
@@ -414,7 +418,11 @@ fn render_docker(
), ),
("read_timeouts_total", "read_timeouts_total", "counter"), ("read_timeouts_total", "read_timeouts_total", "counter"),
("send_timeouts_total", "send_timeouts_total", "counter"), ("send_timeouts_total", "send_timeouts_total", "counter"),
("upstream_client_generation", "upstream_client_generation", "gauge"), (
"upstream_client_generation",
"upstream_client_generation",
"gauge",
),
( (
"upstream_client_generation_requests", "upstream_client_generation_requests",
"upstream_client_generation_requests", "upstream_client_generation_requests",
+13 -6
View File
@@ -50,9 +50,8 @@ mod tests {
async fn optimized_empty_request_keeps_exchange_until_response() { async fn optimized_empty_request_keeps_exchange_until_response() {
let (exchange, slots) = observation().await; let (exchange, slots) = observation().await;
let metrics = exchange.metrics.clone(); let metrics = exchange.metrics.clone();
let body = super::super::wrap_observed_request( let body =
Body::empty(), &exchange, Duration::from_secs(1), super::super::wrap_observed_request(Body::empty(), &exchange, Duration::from_secs(1));
);
assert!(body.is_end_stream()); assert!(body.is_end_stream());
drop(body); drop(body);
assert_eq!(slots.available_permits(), 0); assert_eq!(slots.available_permits(), 0);
@@ -66,7 +65,9 @@ mod tests {
let (exchange, slots) = observation().await; let (exchange, slots) = observation().await;
let metrics = exchange.metrics.clone(); let metrics = exchange.metrics.clone();
let body = super::super::wrap_observed_request( let body = super::super::wrap_observed_request(
Body::from("payload"), &exchange, Duration::from_secs(1), Body::from("payload"),
&exchange,
Duration::from_secs(1),
); );
assert!(!body.is_end_stream()); assert!(!body.is_end_stream());
drop(exchange); drop(exchange);
@@ -272,7 +273,10 @@ mod tests {
assert_eq!(entries[0].route.as_deref(), Some("route-1")); assert_eq!(entries[0].route.as_deref(), Some("route-1"));
assert_eq!(entries[0].client_ip.as_deref(), Some("198.51.100.9")); assert_eq!(entries[0].client_ip.as_deref(), Some("198.51.100.9"));
assert_eq!(entries[0].country.as_deref(), Some("DE")); assert_eq!(entries[0].country.as_deref(), Some("DE"));
assert_eq!(entries[0].upstream_error.as_deref(), Some("upstream closed")); assert_eq!(
entries[0].upstream_error.as_deref(),
Some("upstream closed")
);
assert_eq!(entries[0].cache_reason, "cache_disabled"); assert_eq!(entries[0].cache_reason, "cache_disabled");
assert_eq!(entries[0].websocket_error, Some("upstream_http_error")); assert_eq!(entries[0].websocket_error, Some("upstream_http_error"));
assert_eq!(entries[0].h3_reset_code, Some("h3_general_protocol_error")); assert_eq!(entries[0].h3_reset_code, Some("h3_general_protocol_error"));
@@ -296,7 +300,10 @@ mod tests {
assert_eq!(entries[0].host, "localhost"); assert_eq!(entries[0].host, "localhost");
assert_eq!(entries[0].path, "/echo"); assert_eq!(entries[0].path, "/echo");
assert_eq!(entries[0].request_id.len(), 64); assert_eq!(entries[0].request_id.len(), 64);
assert_eq!(ExchangeSource::name(ExchangeSource::ReverseProxy as u8), "reverse_proxy"); assert_eq!(
ExchangeSource::name(ExchangeSource::ReverseProxy as u8),
"reverse_proxy"
);
assert_eq!(read_effective(0), None); assert_eq!(read_effective(0), None);
assert_eq!(read_effective(1), Some(false)); assert_eq!(read_effective(1), Some(false));
assert_eq!(read_effective(2), Some(true)); assert_eq!(read_effective(2), Some(true));
+4 -1
View File
@@ -268,7 +268,10 @@ fn finish_headers(
active: &[crate::vhost::Listener], active: &[crate::vhost::Listener],
) { ) {
// Global security headers form a baseline; explicitly set vhost values take precedence. // Global security headers form a baseline; explicitly set vhost values take precedence.
snapshot.global_security.headers.apply(response.headers_mut(), tls); snapshot
.global_security
.headers
.apply(response.headers_mut(), tls);
if let Some(frontend) = frontend { if let Some(frontend) = frontend {
frontend.security.apply(response.headers_mut(), tls); frontend.security.apply(response.headers_mut(), tls);
} }
+103 -29
View File
@@ -9,19 +9,28 @@ fn cache_http_invalidation_request(
scheme: &str, scheme: &str,
request: &Request, request: &Request,
) -> Result<crate::cache::InvalidationRequest, &'static str> { ) -> Result<crate::cache::InvalidationRequest, &'static str> {
let mut lengths = request.headers().get_all(http::header::CONTENT_LENGTH).iter(); let mut lengths = request
.headers()
.get_all(http::header::CONTENT_LENGTH)
.iter();
if let Some(length) = lengths.next() { if let Some(length) = lengths.next() {
if lengths.next().is_some() || length.as_bytes() != b"0" { if lengths.next().is_some() || length.as_bytes() != b"0" {
return Err("BAN/PURGE must not contain a request body"); return Err("BAN/PURGE must not contain a request body");
} }
} }
if request.headers().contains_key("upgrade") if request.headers().contains_key("upgrade")
|| request.headers().contains_key(http::header::TRANSFER_ENCODING) || request
.headers()
.contains_key(http::header::TRANSFER_ENCODING)
|| !request.body().is_end_stream() || !request.body().is_end_stream()
{ {
return Err("BAN/PURGE must not contain a request body or upgrade"); return Err("BAN/PURGE must not contain a request body or upgrade");
} }
let target = request.uri().path_and_query().map(|pq| pq.as_str()).unwrap_or("/"); let target = request
.uri()
.path_and_query()
.map(|pq| pq.as_str())
.unwrap_or("/");
if target.len() > 4096 || !target.starts_with('/') { if target.len() > 4096 || !target.starts_with('/') {
return Err("invalid BAN/PURGE target"); return Err("invalid BAN/PURGE target");
} }
@@ -35,23 +44,40 @@ fn cache_http_invalidation_request(
} else { } else {
crate::cache::InvalidationSelector::Exact crate::cache::InvalidationSelector::Exact
}; };
if request.headers().get_all("x-rustproxy-ban-ttl").iter().count() > 1 if request
|| request.headers().get_all("x-rustproxy-confirm-wide").iter().count() > 1 .headers()
.get_all("x-rustproxy-ban-ttl")
.iter()
.count()
> 1
|| request
.headers()
.get_all("x-rustproxy-confirm-wide")
.iter()
.count()
> 1
{ {
return Err("duplicate BAN/PURGE control header"); return Err("duplicate BAN/PURGE control header");
} }
let mut ttl_seconds = None; let mut ttl_seconds = None;
if method.as_str() == "BAN" { if method.as_str() == "BAN" {
if let Some(value) = request.headers().get("x-rustproxy-ban-ttl") { if let Some(value) = request.headers().get("x-rustproxy-ban-ttl") {
let seconds = value.to_str().ok().and_then(|v| v.parse::<u64>().ok()) let seconds = value
.to_str()
.ok()
.and_then(|v| v.parse::<u64>().ok())
.ok_or("invalid BAN TTL")?; .ok_or("invalid BAN TTL")?;
if !(1..=604_800).contains(&seconds) { return Err("BAN TTL must be 1..604800"); } if !(1..=604_800).contains(&seconds) {
return Err("BAN TTL must be 1..604800");
}
ttl_seconds = Some(seconds); ttl_seconds = Some(seconds);
} }
} else if request.headers().contains_key("x-rustproxy-ban-ttl") { } else if request.headers().contains_key("x-rustproxy-ban-ttl") {
return Err("BAN TTL is only supported for BAN"); return Err("BAN TTL is only supported for BAN");
} }
let confirm_wide = request.headers().get("x-rustproxy-confirm-wide") let confirm_wide = request
.headers()
.get("x-rustproxy-confirm-wide")
.is_some_and(|value| value == "1"); .is_some_and(|value| value == "1");
if target == "/" && method.as_str() == "BAN" && !confirm_wide { if target == "/" && method.as_str() == "BAN" && !confirm_wide {
return Err("BAN / requires X-RustProxy-Confirm-Wide: 1"); return Err("BAN / requires X-RustProxy-Confirm-Wide: 1");
@@ -71,9 +97,13 @@ fn cache_http_invalidation_request(
host.to_owned() host.to_owned()
}; };
Ok(crate::cache::InvalidationRequest { Ok(crate::cache::InvalidationRequest {
operation, selector, value: format!("{scheme}://{authority}{target}"), operation,
host: Some(host.to_owned()), vhost: Some(vhost.to_owned()), selector,
ttl_seconds, confirm_wide, value: format!("{scheme}://{authority}{target}"),
host: Some(host.to_owned()),
vhost: Some(vhost.to_owned()),
ttl_seconds,
confirm_wide,
}) })
} }
@@ -88,51 +118,95 @@ async fn cache_http_invalidation(
scheme: &str, scheme: &str,
) -> Response { ) -> Response {
let Some(frontend) = frontend else { let Some(frontend) = frontend else {
return error(StatusCode::NOT_FOUND, "RustProxy: no matching vhost for cache invalidation\n"); return error(
StatusCode::NOT_FOUND,
"RustProxy: no matching vhost for cache invalidation\n",
);
}; };
if snapshot.forwarding.trusted(peer_ip) if snapshot.forwarding.trusted(peer_ip) && crate::vhost::canonical_ip(peer_ip) == client_ip {
&& crate::vhost::canonical_ip(peer_ip) == client_ip return error(
StatusCode::FORBIDDEN,
"RustProxy: missing trusted proxy client identity\n",
);
}
if !snapshot
.envelope
.config
.cache
.permits_http_invalidation(client_ip)
{ {
return error(StatusCode::FORBIDDEN, "RustProxy: missing trusted proxy client identity\n"); return error(
StatusCode::FORBIDDEN,
"RustProxy: BAN/PURGE client IP is not allowed\n",
);
} }
if !snapshot.envelope.config.cache.permits_http_invalidation(client_ip) { let requested = match cache_http_invalidation_request(
return error(StatusCode::FORBIDDEN, "RustProxy: BAN/PURGE client IP is not allowed\n"); request.method(),
} host,
let requested = match cache_http_invalidation_request(request.method(), host, &frontend.id, scheme, &request) { &frontend.id,
scheme,
&request,
) {
Ok(requested) => requested, Ok(requested) => requested,
Err(reason) => return error(StatusCode::BAD_REQUEST, reason), Err(reason) => return error(StatusCode::BAD_REQUEST, reason),
}; };
let method = request.method().clone(); let method = request.method().clone();
drop(request); drop(request);
let Ok(permit) = state.cache_invalidation_slots.clone().try_acquire_owned() else { let Ok(permit) = state.cache_invalidation_slots.clone().try_acquire_owned() else {
return error(StatusCode::TOO_MANY_REQUESTS, "RustProxy: cache maintenance busy\n"); return error(
StatusCode::TOO_MANY_REQUESTS,
"RustProxy: cache maintenance busy\n",
);
}; };
let detail = format!( let detail = format!(
"client_ip={client_ip}; method={}; host={host}; vhost={}; target={:?}", "client_ip={client_ip}; method={}; host={host}; vhost={}; target={:?}",
method, frontend.id, requested.value method, frontend.id, requested.value
); );
if let Err(audit_error) = state.store.audit(&format!("http:{client_ip}"), "cache.http_invalidate", &detail).await { if let Err(audit_error) = state
.store
.audit(
&format!("http:{client_ip}"),
"cache.http_invalidate",
&detail,
)
.await
{
tracing::error!(error = %audit_error, "cache HTTP invalidation audit failed"); tracing::error!(error = %audit_error, "cache HTTP invalidation audit failed");
return error(StatusCode::SERVICE_UNAVAILABLE, "RustProxy: cache maintenance unavailable\n"); return error(
StatusCode::SERVICE_UNAVAILABLE,
"RustProxy: cache maintenance unavailable\n",
);
} }
let cache = state.cache.clone(); let cache = state.cache.clone();
let result = tokio::task::spawn_blocking(move || { let result = tokio::task::spawn_blocking(move || {
let _permit = permit; let _permit = permit;
cache.invalidate(requested) cache.invalidate(requested)
}).await; })
.await;
let result = match result { let result = match result {
Ok(Ok(result)) => result, Ok(Ok(result)) => result,
Ok(Err(invalid)) => { Ok(Err(invalid)) => {
tracing::warn!(reason = %invalid, "cache HTTP invalidation rejected"); tracing::warn!(reason = %invalid, "cache HTTP invalidation rejected");
return error(StatusCode::UNPROCESSABLE_ENTITY, "RustProxy: invalid cache maintenance request\n"); return error(
}, StatusCode::UNPROCESSABLE_ENTITY,
Err(_) => return error(StatusCode::INTERNAL_SERVER_ERROR, "RustProxy: cache maintenance failed\n"), "RustProxy: invalid cache maintenance request\n",
);
}
Err(_) => {
return error(
StatusCode::INTERNAL_SERVER_ERROR,
"RustProxy: cache maintenance failed\n",
)
}
}; };
let code = if result.operation == crate::cache::InvalidationOperation::Ban { let code = if result.operation == crate::cache::InvalidationOperation::Ban {
StatusCode::ACCEPTED StatusCode::ACCEPTED
} else { StatusCode::OK }; } else {
StatusCode::OK
};
let mut response = (code, axum::Json(serde_json::json!({"result": result}))).into_response(); let mut response = (code, axum::Json(serde_json::json!({"result": result}))).into_response();
response.headers_mut().insert("cache-control", HeaderValue::from_static("no-store")); response
.headers_mut()
.insert("cache-control", HeaderValue::from_static("no-store"));
response response
} }
+60 -19
View File
@@ -2,14 +2,25 @@ mod http_invalidation_request_tests {
use super::*; use super::*;
fn request(method: &str, target: &str) -> Request { fn request(method: &str, target: &str) -> Request {
Request::builder().method(method).uri(target).body(Body::empty()).unwrap() Request::builder()
.method(method)
.uri(target)
.body(Body::empty())
.unwrap()
} }
fn parse(method: &str, host: &str, scheme: &str, request: &Request) fn parse(
-> Result<crate::cache::InvalidationRequest, &'static str> method: &str,
{ host: &str,
scheme: &str,
request: &Request,
) -> Result<crate::cache::InvalidationRequest, &'static str> {
cache_http_invalidation_request( cache_http_invalidation_request(
&Method::from_bytes(method.as_bytes()).unwrap(), host, "frontend-1", scheme, request, &Method::from_bytes(method.as_bytes()).unwrap(),
host,
"frontend-1",
scheme,
request,
) )
} }
@@ -34,36 +45,66 @@ mod http_invalidation_request_tests {
fn wide_ban_must_be_confirmed_and_ttl_is_bounded() { fn wide_ban_must_be_confirmed_and_ttl_is_bounded() {
assert!(parse("BAN", "a.example", "https", &request("BAN", "/")).is_err()); assert!(parse("BAN", "a.example", "https", &request("BAN", "/")).is_err());
let mut wide = request("BAN", "/"); let mut wide = request("BAN", "/");
wide.headers_mut().insert("x-rustproxy-confirm-wide", "1".parse().unwrap()); wide.headers_mut()
assert!(parse("BAN", "a.example", "https", &wide).unwrap().confirm_wide); .insert("x-rustproxy-confirm-wide", "1".parse().unwrap());
assert!(
parse("BAN", "a.example", "https", &wide)
.unwrap()
.confirm_wide
);
let mut ttl = request("BAN", "/foo"); let mut ttl = request("BAN", "/foo");
ttl.headers_mut().insert("x-rustproxy-ban-ttl", "604801".parse().unwrap()); ttl.headers_mut()
.insert("x-rustproxy-ban-ttl", "604801".parse().unwrap());
assert!(parse("BAN", "a.example", "https", &ttl).is_err()); assert!(parse("BAN", "a.example", "https", &ttl).is_err());
ttl.headers_mut().insert("x-rustproxy-ban-ttl", "30".parse().unwrap()); ttl.headers_mut()
assert_eq!(parse("BAN", "a.example", "https", &ttl).unwrap().ttl_seconds, Some(30)); .insert("x-rustproxy-ban-ttl", "30".parse().unwrap());
ttl.headers_mut().append("x-rustproxy-ban-ttl", "60".parse().unwrap()); assert_eq!(
parse("BAN", "a.example", "https", &ttl)
.unwrap()
.ttl_seconds,
Some(30)
);
ttl.headers_mut()
.append("x-rustproxy-ban-ttl", "60".parse().unwrap());
assert!(parse("BAN", "a.example", "https", &ttl).is_err()); assert!(parse("BAN", "a.example", "https", &ttl).is_err());
} }
#[test] #[test]
fn maintenance_rejects_bodies_and_ambiguous_or_unsupported_controls() { fn maintenance_rejects_bodies_and_ambiguous_or_unsupported_controls() {
let mut body = request("PURGE", "/foo"); let mut body = request("PURGE", "/foo");
body.headers_mut().insert(http::header::CONTENT_LENGTH, "99".parse().unwrap()); body.headers_mut()
.insert(http::header::CONTENT_LENGTH, "99".parse().unwrap());
assert!(parse("PURGE", "a.example", "https", &body).is_err()); assert!(parse("PURGE", "a.example", "https", &body).is_err());
body.headers_mut().insert(http::header::CONTENT_LENGTH, "0".parse().unwrap()); body.headers_mut()
.insert(http::header::CONTENT_LENGTH, "0".parse().unwrap());
assert!(parse("PURGE", "a.example", "https", &body).is_ok()); assert!(parse("PURGE", "a.example", "https", &body).is_ok());
body.headers_mut().append(http::header::CONTENT_LENGTH, "0".parse().unwrap()); body.headers_mut()
.append(http::header::CONTENT_LENGTH, "0".parse().unwrap());
assert!(parse("PURGE", "a.example", "https", &body).is_err()); assert!(parse("PURGE", "a.example", "https", &body).is_err());
let mut transfer = request("PURGE", "/foo"); let mut transfer = request("PURGE", "/foo");
transfer.headers_mut().insert(http::header::TRANSFER_ENCODING, "chunked".parse().unwrap()); transfer
.headers_mut()
.insert(http::header::TRANSFER_ENCODING, "chunked".parse().unwrap());
assert!(parse("PURGE", "a.example", "https", &transfer).is_err()); assert!(parse("PURGE", "a.example", "https", &transfer).is_err());
let mut unexpected = request("PURGE", "/foo"); let mut unexpected = request("PURGE", "/foo");
unexpected.headers_mut().insert("x-rustproxy-confirm-wide", "1".parse().unwrap()); unexpected
.headers_mut()
.insert("x-rustproxy-confirm-wide", "1".parse().unwrap());
assert!(parse("PURGE", "a.example", "https", &unexpected).is_err()); assert!(parse("PURGE", "a.example", "https", &unexpected).is_err());
unexpected = request("BAN", "/foo"); unexpected = request("BAN", "/foo");
unexpected.headers_mut().append("x-rustproxy-confirm-wide", "1".parse().unwrap()); unexpected
unexpected.headers_mut().append("x-rustproxy-confirm-wide", "1".parse().unwrap()); .headers_mut()
.append("x-rustproxy-confirm-wide", "1".parse().unwrap());
unexpected
.headers_mut()
.append("x-rustproxy-confirm-wide", "1".parse().unwrap());
assert!(parse("BAN", "a.example", "https", &unexpected).is_err()); assert!(parse("BAN", "a.example", "https", &unexpected).is_err());
assert!(parse("PURGE", "a.example", "https", &request("PURGE", &format!("/{}", "a".repeat(4097)))).is_err()); assert!(parse(
"PURGE",
"a.example",
"https",
&request("PURGE", &format!("/{}", "a".repeat(4097)))
)
.is_err());
} }
} }
+85 -21
View File
@@ -16,19 +16,33 @@ fn disabled_cache_retains_reason_without_matching_force_rules_or_reparsing_heade
let settings = settings(); let settings = settings();
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
headers.insert("authorization", HeaderValue::from_static("private")); headers.insert("authorization", HeaderValue::from_static("private"));
headers.insert("cache-control", HeaderValue::from_static("max-age=not-a-number")); headers.insert(
"cache-control",
HeaderValue::from_static("max-age=not-a-number"),
);
for reason in [ for reason in [
"cache_disabled", "route_disabled", "admin_control_plane", "basic_auth", "cache_disabled",
"tinyauth", "sticky_cookie", "request_upgrade", "route_disabled",
"admin_control_plane",
"basic_auth",
"tinyauth",
"sticky_cookie",
"request_upgrade",
] { ] {
let plan = CachePlan::new(Some(reason), &settings, "/style.css", || { let plan = CachePlan::new(Some(reason), &settings, "/style.css", || {
panic!("disabled cache must not match force-cache rules") panic!("disabled cache must not match force-cache rules")
}); });
assert!(!plan.force_cache()); assert!(!plan.force_cache());
assert!(!plan.ignore_cookie()); assert!(!plan.ignore_cookie());
let policy = plan.request_policy(&Method::GET, &headers, true, "/style.css", false, &settings); let policy =
plan.request_policy(&Method::GET, &headers, true, "/style.css", false, &settings);
assert_eq!(policy, RequestPolicy::Bypass(reason)); assert_eq!(policy, RequestPolicy::Bypass(reason));
assert_eq!(combine_policy(policy, || panic!("disabled cache must not parse post-rule policy")), policy); assert_eq!(
combine_policy(policy, || panic!(
"disabled cache must not parse post-rule policy"
)),
policy
);
} }
} }
@@ -38,23 +52,49 @@ fn cache_on_keeps_all_existing_request_policy_outcomes() {
for forced in [false, true] { for forced in [false, true] {
let plan = CachePlan::new(None, &settings, "/style.css", || forced); let plan = CachePlan::new(None, &settings, "/style.css", || forced);
for (name, value) in [ for (name, value) in [
("x-irrelevant", "yes"), ("cache-control", "no-store"), ("x-irrelevant", "yes"),
("cache-control", "no-cache"), ("cache-control", "max-age=0"), ("cache-control", "no-store"),
("cache-control", "min-fresh=broken"), ("authorization", "private"), ("cache-control", "no-cache"),
("cookie", "session=private"), ("range", "bytes=0-2"), ("cache-control", "max-age=0"),
("range", "bytes=0-2,4-5"), ("if-range", "\"etag\""), ("cache-control", "min-fresh=broken"),
("if-match", "\"etag\""), ("upgrade", "websocket"), ("authorization", "private"),
("cookie", "session=private"),
("range", "bytes=0-2"),
("range", "bytes=0-2,4-5"),
("if-range", "\"etag\""),
("if-match", "\"etag\""),
("upgrade", "websocket"),
] { ] {
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
headers.insert(http::HeaderName::from_bytes(name.as_bytes()).unwrap(), value.parse().unwrap()); headers.insert(
http::HeaderName::from_bytes(name.as_bytes()).unwrap(),
value.parse().unwrap(),
);
for method in [Method::GET, Method::HEAD, Method::POST] { for method in [Method::GET, Method::HEAD, Method::POST] {
for body_empty in [false, true] { for body_empty in [false, true] {
let expected = if forced { let expected = if forced {
crate::cache::forced_request_policy(&method, &headers, body_empty) crate::cache::forced_request_policy(&method, &headers, body_empty)
} else { } else {
crate::cache::request_policy(&method, &headers, body_empty, "/style.css", false, &settings) crate::cache::request_policy(
&method,
&headers,
body_empty,
"/style.css",
false,
&settings,
)
}; };
assert_eq!(plan.request_policy(&method, &headers, body_empty, "/style.css", false, &settings), expected); assert_eq!(
plan.request_policy(
&method,
&headers,
body_empty,
"/style.css",
false,
&settings
),
expected
);
} }
} }
} }
@@ -73,8 +113,12 @@ fn request_header_rules_cannot_relax_bypass_or_revalidation() {
for original in cases { for original in cases {
for rewritten in cases { for rewritten in cases {
let expected = match (original, rewritten) { let expected = match (original, rewritten) {
(RequestPolicy::Bypass(reason), _) | (_, RequestPolicy::Bypass(reason)) => RequestPolicy::Bypass(reason), (RequestPolicy::Bypass(reason), _) | (_, RequestPolicy::Bypass(reason)) => {
(RequestPolicy::Revalidate(reason), _) | (_, RequestPolicy::Revalidate(reason)) => RequestPolicy::Revalidate(reason), RequestPolicy::Bypass(reason)
}
(RequestPolicy::Revalidate(reason), _) | (_, RequestPolicy::Revalidate(reason)) => {
RequestPolicy::Revalidate(reason)
}
_ => RequestPolicy::Use, _ => RequestPolicy::Use,
}; };
assert_eq!(combine_policy(original, || rewritten), expected); assert_eq!(combine_policy(original, || rewritten), expected);
@@ -85,16 +129,36 @@ fn request_header_rules_cannot_relax_bypass_or_revalidation() {
#[test] #[test]
fn cache_off_still_blocks_range_transformation_and_preserves_origin_headers() { fn cache_off_still_blocks_range_transformation_and_preserves_origin_headers() {
for method in [Method::GET, Method::HEAD, Method::POST] { for method in [Method::GET, Method::HEAD, Method::POST] {
for range in [None, Some("bytes=0-2"), Some("bytes=0-2,4-5"), Some("broken"), Some("bytes=99-")] { for range in [
None,
Some("bytes=0-2"),
Some("bytes=0-2,4-5"),
Some("broken"),
Some("bytes=99-"),
] {
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
if let Some(range) = range { headers.insert("range", range.parse().unwrap()); } if let Some(range) = range {
headers.insert("range", range.parse().unwrap());
}
headers.insert("if-range", HeaderValue::from_static("\"same-validator\"")); headers.insert("if-range", HeaderValue::from_static("\"same-validator\""));
headers.insert("if-none-match", HeaderValue::from_static("\"client-validator\"")); headers.insert(
"if-none-match",
HeaderValue::from_static("\"client-validator\""),
);
let original = headers.clone(); let original = headers.clone();
let baseline = crate::cache::request_range(&method, &headers); let baseline = crate::cache::request_range(&method, &headers);
assert_eq!(range_selection(true, &method, &headers), baseline); assert_eq!(range_selection(true, &method, &headers), baseline);
assert_eq!(matches!(range_selection(false, &method, &headers), RangeSelection::None), matches!(baseline, RangeSelection::None)); assert_eq!(
assert_eq!(headers, original, "origin must receive the original Range/conditional fields"); matches!(
range_selection(false, &method, &headers),
RangeSelection::None
),
matches!(baseline, RangeSelection::None)
);
assert_eq!(
headers, original,
"origin must receive the original Range/conditional fields"
);
} }
} }
} }
+15 -7
View File
@@ -35,10 +35,7 @@ impl ErrorFallbackRequest {
transport: request.extensions().get::<Transport>().copied(), transport: request.extensions().get::<Transport>().copied(),
snapshot: snapshot.clone(), snapshot: snapshot.clone(),
exchange: request.extensions().get::<Arc<Exchange>>().cloned(), exchange: request.extensions().get::<Arc<Exchange>>().cloned(),
connection_lease: request connection_lease: request.extensions().get::<Arc<ConnectionLease>>().cloned(),
.extensions()
.get::<Arc<ConnectionLease>>()
.cloned(),
stop: request.extensions().get::<ServerShutdown>().cloned(), stop: request.extensions().get::<ServerShutdown>().cloned(),
depth: request depth: request
.extensions() .extensions()
@@ -48,7 +45,13 @@ impl ErrorFallbackRequest {
} }
} }
fn rebuild(&self, body: Bytes, peer: PeerAddr, local: LocalAddr, dispatch: InternalFallbackRoute) -> Request { fn rebuild(
&self,
body: Bytes,
peer: PeerAddr,
local: LocalAddr,
dispatch: InternalFallbackRoute,
) -> Request {
let mut request = Request::new(Body::from(body)); let mut request = Request::new(Body::from(body));
*request.method_mut() = self.method.clone(); *request.method_mut() = self.method.clone();
*request.uri_mut() = self.uri.clone(); *request.uri_mut() = self.uri.clone();
@@ -83,7 +86,10 @@ enum ErrorFallbackBody {
const MAX_ERROR_FALLBACK_DEPTH: u8 = 8; const MAX_ERROR_FALLBACK_DEPTH: u8 = 8;
const MAX_ERROR_FALLBACK_REPLAY_BODY_BYTES: u64 = 8 * 1024 * 1024; const MAX_ERROR_FALLBACK_REPLAY_BODY_BYTES: u64 = 8 * 1024 * 1024;
async fn prepare_error_fallback_body(request: &mut Request, enabled: bool) -> Result<ErrorFallbackBody, Response> { async fn prepare_error_fallback_body(
request: &mut Request,
enabled: bool,
) -> Result<ErrorFallbackBody, Response> {
if !enabled { if !enabled {
return Ok(ErrorFallbackBody::Disabled); return Ok(ErrorFallbackBody::Disabled);
} }
@@ -193,6 +199,8 @@ fn intercept_route_origin_error(
} else { } else {
response response
}; };
response.extensions_mut().insert(crate::pages::OriginProvidedResponse); response
.extensions_mut()
.insert(crate::pages::OriginProvidedResponse);
response response
} }
+23 -8
View File
@@ -735,7 +735,10 @@ mod cache_fast_path_tests {
let fixture = Fixture::new(config).await; let fixture = Fixture::new(config).await;
let first = fixture.get("/vary", &[]).await; let first = fixture.get("/vary", &[]).await;
assert_eq!(first.headers["x-cache"], "MISS"); assert_eq!(first.headers["x-cache"], "MISS");
assert!(cache_chain(&first.headers).last().unwrap().starts_with("RustProxy; fwd=miss")); assert!(cache_chain(&first.headers)
.last()
.unwrap()
.starts_with("RustProxy; fwd=miss"));
let revalidated = fixture.get("/vary", &[("cache-control", "no-cache")]).await; let revalidated = fixture.get("/vary", &[("cache-control", "no-cache")]).await;
assert_eq!(revalidated.status, StatusCode::OK); assert_eq!(revalidated.status, StatusCode::OK);
assert_eq!(revalidated.headers["x-cache"], "HIT"); assert_eq!(revalidated.headers["x-cache"], "HIT");
@@ -743,7 +746,9 @@ mod cache_fast_path_tests {
cache_chain(&revalidated.headers).last().copied(), cache_chain(&revalidated.headers).last().copied(),
Some("RustProxy; fwd=request; fwd-status=304; detail=\"cache_revalidated\""), Some("RustProxy; fwd=request; fwd-status=304; detail=\"cache_revalidated\""),
); );
let posted = fixture.send(Method::POST, "/echo", &[], Body::from("posted")).await; let posted = fixture
.send(Method::POST, "/echo", &[], Body::from("posted"))
.await;
assert_eq!(posted.status, StatusCode::OK); assert_eq!(posted.status, StatusCode::OK);
assert_eq!(posted.body, "posted"); assert_eq!(posted.body, "posted");
assert_eq!( assert_eq!(
@@ -783,9 +788,10 @@ mod cache_fast_path_tests {
let stale = fixture.get("/stale-no-validator", &[]).await; let stale = fixture.get("/stale-no-validator", &[]).await;
assert_eq!(stale.status, StatusCode::OK); assert_eq!(stale.status, StatusCode::OK);
assert_eq!(stale.headers["x-cache"], "MISS"); assert_eq!(stale.headers["x-cache"], "MISS");
assert!( assert!(cache_chain(&stale.headers)
cache_chain(&stale.headers).last().unwrap().starts_with("RustProxy; fwd=stale"), .last()
); .unwrap()
.starts_with("RustProxy; fwd=stale"),);
assert_eq!(origin.requests.load(Ordering::Relaxed), 2); assert_eq!(origin.requests.load(Ordering::Relaxed), 2);
fixture.stop().await; fixture.stop().await;
} }
@@ -942,9 +948,18 @@ mod cache_fast_path_tests {
); );
let cache_status = cache_chain(&stale.headers); let cache_status = cache_chain(&stale.headers);
let last = cache_status.last().unwrap(); let last = cache_status.last().unwrap();
let freshness = last.strip_prefix("RustProxy; hit; ttl=").unwrap() let freshness = last
.split(';').next().unwrap().parse::<i64>().unwrap(); .strip_prefix("RustProxy; hit; ttl=")
assert!(freshness <= 0, "expired response must not claim positive freshness"); .unwrap()
.split(';')
.next()
.unwrap()
.parse::<i64>()
.unwrap();
assert!(
freshness <= 0,
"expired response must not claim positive freshness"
);
assert!(last.ends_with("; detail=\"cache_stale_while_revalidate\"")); assert!(last.ends_with("; detail=\"cache_stale_while_revalidate\""));
tokio::time::sleep(Duration::from_millis(100)).await; tokio::time::sleep(Duration::from_millis(100)).await;
assert_eq!( assert_eq!(
+56 -18
View File
@@ -106,7 +106,11 @@ async fn forward_request(
} else { } else {
snapshot.frontend(&host, local.0, tls) snapshot.frontend(&host, local.0, tls)
}; };
if snapshot.global_security.content_type_policy.blocks(request.headers()) { if snapshot
.global_security
.content_type_policy
.blocks(request.headers())
{
return error( return error(
StatusCode::FORBIDDEN, StatusCode::FORBIDDEN,
"RustProxy: request Content-Type denied by global policy\n", "RustProxy: request Content-Type denied by global policy\n",
@@ -118,7 +122,10 @@ async fn forward_request(
"RustProxy: request Content-Type denied by vhost policy\n", "RustProxy: request Content-Type denied by vhost policy\n",
); );
} }
if snapshot.global_security.user_agent_blocking.blocks(request.headers()) if snapshot
.global_security
.user_agent_blocking
.blocks(request.headers())
|| frontend.is_some_and(|frontend| frontend.user_agent_blocking.blocks(request.headers())) || frontend.is_some_and(|frontend| frontend.user_agent_blocking.blocks(request.headers()))
{ {
return error( return error(
@@ -170,7 +177,9 @@ async fn forward_request(
"RustProxy: RFC 9220 WebSocket transport is not enabled for this vhost\n", "RustProxy: RFC 9220 WebSocket transport is not enabled for this vhost\n",
); );
} }
if let Some(response) = method_policy_denial(&snapshot.global_security.method_policy, request.method()) { if let Some(response) =
method_policy_denial(&snapshot.global_security.method_policy, request.method())
{
return response; return response;
} }
if !cache_http_invalidation_method(request.method()) { if !cache_http_invalidation_method(request.method()) {
@@ -239,11 +248,24 @@ async fn forward_request(
} }
} }
// Global rules are cumulative: an explicit IP allow cannot bypass a country deny/allowlist. // Global rules are cumulative: an explicit IP allow cannot bypass a country deny/allowlist.
if !snapshot.global_security.country_access.allows(country.as_deref()) if !snapshot
|| !snapshot.global_security.rate_limit.country_allowed(country.as_deref()) .global_security
|| snapshot.global_security.acl.as_ref().is_some_and(|acl| !acl.allows(context.client_ip)) .country_access
.allows(country.as_deref())
|| !snapshot
.global_security
.rate_limit
.country_allowed(country.as_deref())
|| snapshot
.global_security
.acl
.as_ref()
.is_some_and(|acl| !acl.allows(context.client_ip))
{ {
return error(StatusCode::FORBIDDEN, "RustProxy: denied by global access policy\n"); return error(
StatusCode::FORBIDDEN,
"RustProxy: denied by global access policy\n",
);
} }
if internal_dispatch.is_none() { if internal_dispatch.is_none() {
if let Err(limited) = state.rate_limiter.check_with_user_agent( if let Err(limited) = state.rate_limiter.check_with_user_agent(
@@ -251,7 +273,10 @@ async fn forward_request(
"\u{1f}rustproxy-global-security", "\u{1f}rustproxy-global-security",
context.client_ip, context.client_ip,
country.as_deref(), country.as_deref(),
request.headers().get(http::header::USER_AGENT).and_then(|value| value.to_str().ok()), request
.headers()
.get(http::header::USER_AGENT)
.and_then(|value| value.to_str().ok()),
&snapshot.global_security.rate_limit, &snapshot.global_security.rate_limit,
) { ) {
let mut response = error( let mut response = error(
@@ -265,12 +290,13 @@ async fn forward_request(
} }
} }
if let Some(frontend) = frontend { if let Some(frontend) = frontend {
if !country_resolved && (capture_country if !country_resolved
|| frontend && (capture_country
.profile_policies || frontend
.iter() .profile_policies
.any(|profile| profile.countries.needs_geoip()) .iter()
|| frontend.rate_limit.needs_geoip()) .any(|profile| profile.countries.needs_geoip())
|| frontend.rate_limit.needs_geoip())
{ {
country = cloudflare_country country = cloudflare_country
.clone() .clone()
@@ -331,14 +357,20 @@ async fn forward_request(
if let Some(mut response) = frontend.and_then(|frontend| { if let Some(mut response) = frontend.and_then(|frontend| {
canonical_host_redirect(frontend, &host, tls, local.0.port(), request.uri()) canonical_host_redirect(frontend, &host, tls, local.0.port(), request.uri())
}) { }) {
snapshot.global_security.headers.apply(response.headers_mut(), tls); snapshot
.global_security
.headers
.apply(response.headers_mut(), tls);
return response; return response;
} }
if !tls { if !tls {
if let Some(mut response) = if let Some(mut response) =
frontend.and_then(|frontend| force_ssl_redirect(frontend, &host, request.uri())) frontend.and_then(|frontend| force_ssl_redirect(frontend, &host, request.uri()))
{ {
snapshot.global_security.headers.apply(response.headers_mut(), tls); snapshot
.global_security
.headers
.apply(response.headers_mut(), tls);
return response; return response;
} }
} }
@@ -364,7 +396,10 @@ async fn forward_request(
"RustProxy: redirect target could not be rendered\n", "RustProxy: redirect target could not be rendered\n",
); );
}; };
snapshot.global_security.headers.apply(response.headers_mut(), tls); snapshot
.global_security
.headers
.apply(response.headers_mut(), tls);
response.extensions_mut().insert(RedirectResponse { response.extensions_mut().insert(RedirectResponse {
http3_enabled: _redirect.http3_enabled, http3_enabled: _redirect.http3_enabled,
}); });
@@ -372,7 +407,10 @@ async fn forward_request(
} }
if let Some(item) = local_host { if let Some(item) = local_host {
let mut response = item.page.serve(request.method(), StatusCode::NOT_FOUND); let mut response = item.page.serve(request.method(), StatusCode::NOT_FOUND);
snapshot.global_security.headers.apply(response.headers_mut(), tls); snapshot
.global_security
.headers
.apply(response.headers_mut(), tls);
response.extensions_mut().insert(LocalHostResponse { response.extensions_mut().insert(LocalHostResponse {
http3_enabled: item.http3_enabled, http3_enabled: item.http3_enabled,
}); });
+19 -6
View File
@@ -23,7 +23,9 @@ mod tests {
fn common_connection_tokens_do_not_request_websocket_upgrade() { fn common_connection_tokens_do_not_request_websocket_upgrade() {
for connection in [None, Some("keep-alive"), Some("KEEP-ALIVE"), Some("close")] { for connection in [None, Some("keep-alive"), Some("KEEP-ALIVE"), Some("close")] {
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
if let Some(value) = connection { headers.insert("connection", value.parse().unwrap()); } if let Some(value) = connection {
headers.insert("connection", value.parse().unwrap());
}
assert!(!requests_upgrade(&headers)); assert!(!requests_upgrade(&headers));
} }
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
@@ -52,20 +54,29 @@ mod tests {
headers.append("set-cookie", "rustproxy_session=secret".parse().unwrap()); headers.append("set-cookie", "rustproxy_session=secret".parse().unwrap());
headers.append("set-cookie", "b=2; HttpOnly".parse().unwrap()); headers.append("set-cookie", "b=2; HttpOnly".parse().unwrap());
strip_admin_set_cookie(&mut headers); strip_admin_set_cookie(&mut headers);
let cookies: Vec<_> = headers.get_all("set-cookie").iter() let cookies: Vec<_> = headers
.map(|value| value.to_str().unwrap()).collect(); .get_all("set-cookie")
.iter()
.map(|value| value.to_str().unwrap())
.collect();
assert_eq!(cookies, vec!["a=1; Secure", "b=2; HttpOnly"]); assert_eq!(cookies, vec!["a=1; Secure", "b=2; HttpOnly"]);
} }
#[test] #[test]
fn admin_cookie_can_be_restored_only_from_captured_fragment() { fn admin_cookie_can_be_restored_only_from_captured_fragment() {
let mut h = HeaderMap::new(); let mut h = HeaderMap::new();
h.insert("cookie", "a=1; rustproxy_session=secret; b=2".parse().unwrap()); h.insert(
"cookie",
"a=1; rustproxy_session=secret; b=2".parse().unwrap(),
);
let admin = strip_admin_cookie(&mut h); let admin = strip_admin_cookie(&mut h);
assert_eq!(h.get("cookie").unwrap(), "a=1; b=2"); assert_eq!(h.get("cookie").unwrap(), "a=1; b=2");
assert_eq!(admin, vec!["rustproxy_session=secret"]); assert_eq!(admin, vec!["rustproxy_session=secret"]);
restore_admin_cookie(&mut h, &admin); restore_admin_cookie(&mut h, &admin);
assert_eq!(h.get("cookie").unwrap(), "a=1; b=2; rustproxy_session=secret"); assert_eq!(
h.get("cookie").unwrap(),
"a=1; b=2; rustproxy_session=secret"
);
} }
#[test] #[test]
@@ -87,7 +98,9 @@ mod tests {
fn absent_and_close_connection_preserve_non_hop_headers() { fn absent_and_close_connection_preserve_non_hop_headers() {
for value in [None, Some("close"), Some("CLOSE")] { for value in [None, Some("close"), Some("CLOSE")] {
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
if let Some(value) = value { headers.insert("connection", value.parse().unwrap()); } if let Some(value) = value {
headers.insert("connection", value.parse().unwrap());
}
headers.insert("x-request-policy", "allowed".parse().unwrap()); headers.insert("x-request-policy", "allowed".parse().unwrap());
headers.insert("transfer-encoding", "chunked".parse().unwrap()); headers.insert("transfer-encoding", "chunked".parse().unwrap());
strip_hop_by_hop(&mut headers); strip_hop_by_hop(&mut headers);
+30 -10
View File
@@ -13,9 +13,20 @@ struct SelectedRouteContext {
fn take_routing_identity( fn take_routing_identity(
request: &mut Request, request: &mut Request,
tls: bool, tls: bool,
) -> Result<(Option<SelectedRouteContext>, HeaderValue, String, Option<String>, bool), Response> { ) -> Result<
(
Option<SelectedRouteContext>,
HeaderValue,
String,
Option<String>,
bool,
),
Response,
> {
let mut selected_context = request.extensions_mut().remove::<SelectedRouteContext>(); let mut selected_context = request.extensions_mut().remove::<SelectedRouteContext>();
let validated = selected_context.as_mut().and_then(|context| context.validated.take()); let validated = selected_context
.as_mut()
.and_then(|context| context.validated.take());
let host_already_validated = validated.is_some(); let host_already_validated = validated.is_some();
let (original_host, host, decoded_path) = match validated { let (original_host, host, decoded_path) = match validated {
Some(validated) => ( Some(validated) => (
@@ -24,16 +35,25 @@ fn take_routing_identity(
validated.decoded_path, validated.decoded_path,
), ),
None => { None => {
let original_host = effective_host(request, tls).map_err(|_| error( let original_host = effective_host(request, tls).map_err(|_| {
StatusCode::BAD_REQUEST, error(
"RustProxy: invalid or conflicting request authority\n", StatusCode::BAD_REQUEST,
))?; "RustProxy: invalid or conflicting request authority\n",
let host = original_host.to_str().ok() )
})?;
let host = original_host
.to_str()
.ok()
.and_then(|v| request_host(v).ok()) .and_then(|v| request_host(v).ok())
.ok_or_else(|| error(StatusCode::BAD_REQUEST, .ok_or_else(|| error(StatusCode::BAD_REQUEST, "RustProxy: invalid hostname\n"))?;
"RustProxy: invalid hostname\n"))?;
(original_host, host, None) (original_host, host, None)
} }
}; };
Ok((selected_context, original_host, host, decoded_path, host_already_validated)) Ok((
selected_context,
original_host,
host,
decoded_path,
host_already_validated,
))
} }
+11 -4
View File
@@ -29,7 +29,10 @@ fn canonical_host_redirect(
} else { } else {
("http", local_port) ("http", local_port)
}; };
let path = uri.path_and_query().map(|value| value.as_str()).unwrap_or("/"); 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 default_port = (scheme == "https" && port == 443) || (scheme == "http" && port == 80);
let location = if default_port { let location = if default_port {
format!("{scheme}://{to}{path}") format!("{scheme}://{to}{path}")
@@ -37,8 +40,13 @@ fn canonical_host_redirect(
format!("{scheme}://{to}:{port}{path}") format!("{scheme}://{to}:{port}{path}")
}; };
let mut response = StatusCode::PERMANENT_REDIRECT.into_response(); let mut response = StatusCode::PERMANENT_REDIRECT.into_response();
response.headers_mut().insert(http::header::LOCATION, HeaderValue::from_str(&location).ok()?); response.headers_mut().insert(
response.headers_mut().insert("cache-control", HeaderValue::from_static("no-store")); http::header::LOCATION,
HeaderValue::from_str(&location).ok()?,
);
response
.headers_mut()
.insert("cache-control", HeaderValue::from_static("no-store"));
Some(response) Some(response)
} }
@@ -60,4 +68,3 @@ fn force_ssl_redirect(
.insert("cache-control", HeaderValue::from_static("no-store")); .insert("cache-control", HeaderValue::from_static("no-store"));
Some(response) Some(response)
} }
+1 -3
View File
@@ -32,9 +32,7 @@ mod request_id_tests {
#[test] #[test]
fn independent_sequential_ids_keep_full_length_and_unique_values() { fn independent_sequential_ids_keep_full_length_and_unique_values() {
let ids: std::collections::HashSet<_> = (0..4096u64) let ids: std::collections::HashSet<_> = (0..4096u64).map(request_id_hash).collect();
.map(request_id_hash)
.collect();
assert_eq!(ids.len(), 4096); assert_eq!(ids.len(), 4096);
assert!(ids.iter().all(|id| id.len() == 64)); assert!(ids.iter().all(|id| id.len() == 64));
} }
+9 -6
View File
@@ -129,12 +129,15 @@ pub(super) fn finish_web_revalidation(
if *method == Method::HEAD { if *method == Method::HEAD {
*response.body_mut() = Body::empty(); *response.body_mut() = Body::empty();
} }
let request_forces_validation = crate::cache::request_policy( let request_forces_validation =
method, client, true, "", false, settings, crate::cache::request_policy(method, client, true, "", false, settings)
).revalidation_reason().is_some() .revalidation_reason()
|| cached.request_freshness_revalidation_reason( .is_some()
&crate::cache::request_freshness_headers(client, settings), || cached
).is_some(); .request_freshness_revalidation_reason(&crate::cache::request_freshness_headers(
client, settings,
))
.is_some();
settings.annotate_reason_transformed_forwarded( settings.annotate_reason_transformed_forwarded(
response.headers_mut(), response.headers_mut(),
"HIT", "HIT",
+3 -1
View File
@@ -154,7 +154,9 @@ async fn strict_browser_refresh_uses_request_forward_reason() {
let mut client = HeaderMap::new(); let mut client = HeaderMap::new();
client.insert("cache-control", HeaderValue::from_static("no-cache")); client.insert("cache-control", HeaderValue::from_static("no-cache"));
let response = validate(&service, &cache, &ticket, &settings, Method::GET, client) let response = validate(&service, &cache, &ticket, &settings, Method::GET, client)
.await.unwrap().response; .await
.unwrap()
.response;
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.headers()["x-cache"], "HIT"); assert_eq!(response.headers()["x-cache"], "HIT");
assert_eq!( assert_eq!(
+54 -21
View File
@@ -49,10 +49,13 @@ mod tests {
assert!(limiter.check(7, "site", first, None, &settings).is_ok()); assert!(limiter.check(7, "site", first, None, &settings).is_ok());
{ {
let mut state = lock(&limiter.state); let mut state = lock(&limiter.state);
let bucket = state.buckets.get_mut("site").unwrap().get_mut(&first).unwrap(); let bucket = state
bucket.started = Instant::now() .buckets
.checked_sub(Duration::from_secs(2)) .get_mut("site")
.unwrap()
.get_mut(&first)
.unwrap(); .unwrap();
bucket.started = Instant::now().checked_sub(Duration::from_secs(2)).unwrap();
state.last_sweep = None; state.last_sweep = None;
} }
assert!(limiter.check(7, "site", second, None, &settings).is_ok()); assert!(limiter.check(7, "site", second, None, &settings).is_ok());
@@ -82,9 +85,7 @@ mod tests {
.unwrap() .unwrap()
.get_mut(&ip) .get_mut(&ip)
.unwrap(); .unwrap();
bucket.started = Instant::now() bucket.started = Instant::now().checked_sub(Duration::from_secs(2)).unwrap();
.checked_sub(Duration::from_secs(2))
.unwrap();
} }
limiter.maintenance(); limiter.maintenance();
let state = lock(&limiter.state); let state = lock(&limiter.state);
@@ -116,44 +117,76 @@ mod tests {
requests: 3, requests: 3,
burst: 0, burst: 0,
user_agent_limits: vec![UserAgentLimit { user_agent_limits: vec![UserAgentLimit {
pattern: "*BadBot*".into(), requests: 1, window_seconds: 60, burst: 0, pattern: "*BadBot*".into(),
requests: 1,
window_seconds: 60,
burst: 0,
}], }],
..Default::default() ..Default::default()
}; };
settings.validate().unwrap(); settings.validate().unwrap();
let limiter = Limiter::new(); let limiter = Limiter::new();
let ip = "198.51.100.12".parse().unwrap(); let ip = "198.51.100.12".parse().unwrap();
assert!(limiter.check_with_user_agent(7, "a", ip, None, Some("badbot/1.0"), &settings).is_ok()); assert!(limiter
assert!(limiter.check_with_user_agent(7, "a", ip, None, Some("BADBOT/2"), &settings).is_err()); .check_with_user_agent(7, "a", ip, None, Some("badbot/1.0"), &settings)
.is_ok());
assert!(limiter
.check_with_user_agent(7, "a", ip, None, Some("BADBOT/2"), &settings)
.is_err());
for _ in 0..3 { for _ in 0..3 {
assert!(limiter.check_with_user_agent(7, "a", ip, None, Some("Browser"), &settings).is_ok()); assert!(limiter
.check_with_user_agent(7, "a", ip, None, Some("Browser"), &settings)
.is_ok());
} }
assert!(limiter.check_with_user_agent(7, "a", ip, None, Some("Browser"), &settings).is_err()); assert!(limiter
assert!(limiter.check_with_user_agent(7, "b", ip, None, Some("BadBot"), &settings).is_ok()); .check_with_user_agent(7, "a", ip, None, Some("Browser"), &settings)
.is_err());
assert!(limiter
.check_with_user_agent(7, "b", ip, None, Some("BadBot"), &settings)
.is_ok());
} }
#[test] #[test]
fn user_agent_overrides_work_without_base_rate_or_geoip() { fn user_agent_overrides_work_without_base_rate_or_geoip() {
let mut settings = Settings { let mut settings = Settings {
enabled: false, enabled: false,
user_agent_limits: vec![UserAgentLimit { pattern: "scanner".into(), requests: 1, window_seconds: 60, burst: 0 }], user_agent_limits: vec![UserAgentLimit {
pattern: "scanner".into(),
requests: 1,
window_seconds: 60,
burst: 0,
}],
..Default::default() ..Default::default()
}; };
settings.validate().unwrap(); settings.validate().unwrap();
let limiter = Limiter::new(); let limiter = Limiter::new();
let ip = "192.0.2.100".parse().unwrap(); let ip = "192.0.2.100".parse().unwrap();
assert!(limiter.check_with_user_agent(1,"site",ip,None,Some("ScanNER"),&settings).is_ok()); assert!(limiter
assert!(limiter.check_with_user_agent(1,"site",ip,None,Some("scanner"),&settings).is_err()); .check_with_user_agent(1, "site", ip, None, Some("ScanNER"), &settings)
assert!(limiter.check_with_user_agent(1,"site",ip,None,None,&settings).is_ok()); .is_ok());
assert!(limiter
.check_with_user_agent(1, "site", ip, None, Some("scanner"), &settings)
.is_err());
assert!(limiter
.check_with_user_agent(1, "site", ip, None, None, &settings)
.is_ok());
} }
#[test] #[test]
fn user_agent_override_validation_is_bounded_and_unique() { fn user_agent_override_validation_is_bounded_and_unique() {
let mut settings = Settings { user_agent_limits: vec![ let mut settings = Settings {
UserAgentLimit { pattern: "TEST*".into(), ..Default::default() }, user_agent_limits: vec![
UserAgentLimit { pattern: "test*".into(), ..Default::default() }, UserAgentLimit {
], ..Default::default() }; pattern: "TEST*".into(),
..Default::default()
},
UserAgentLimit {
pattern: "test*".into(),
..Default::default()
},
],
..Default::default()
};
assert!(settings.validate().is_err()); assert!(settings.validate().is_err());
settings.user_agent_limits[1].pattern = "other".into(); settings.user_agent_limits[1].pattern = "other".into();
assert!(settings.validate().is_ok()); assert!(settings.validate().is_ok());
} }
} }
+84 -25
View File
@@ -8,15 +8,21 @@ mod cache_revision_tests {
let identity = cache_revision(&base); let identity = cache_revision(&base);
let mut file_transport = base.clone(); let mut file_transport = base.clone();
file_transport.web_services.push(crate::web_server::Service { file_transport
id: "static-test".into(), name: "Static Test".into(), .web_services
document_root: "/var/www/static-test".into(), .push(crate::web_server::Service {
..Default::default() id: "static-test".into(),
}); name: "Static Test".into(),
document_root: "/var/www/static-test".into(),
..Default::default()
});
let mut zero_copy = file_transport.clone(); let mut zero_copy = file_transport.clone();
zero_copy.web_services[0].sendfile = crate::web_server::SendfileMode::Auto; zero_copy.web_services[0].sendfile = crate::web_server::SendfileMode::Auto;
assert_eq!(cache_revision(&file_transport), cache_revision(&zero_copy)); assert_eq!(cache_revision(&file_transport), cache_revision(&zero_copy));
assert!(matches!(cache_invalidation(&file_transport, &zero_copy), CacheInvalidation::None)); assert!(matches!(
cache_invalidation(&file_transport, &zero_copy),
CacheInvalidation::None
));
let mut logging = base.clone(); let mut logging = base.clone();
logging.logging.file.path = "/tmp/rustproxy-test-access.jsonl".into(); logging.logging.file.path = "/tmp/rustproxy-test-access.jsonl".into();
@@ -88,24 +94,43 @@ mod cache_revision_tests {
protocol_only.vhosts[0].http3_enabled = !base.vhosts[0].http3_enabled; protocol_only.vhosts[0].http3_enabled = !base.vhosts[0].http3_enabled;
} }
assert_ne!( assert_ne!(
(protocol_only.vhosts[0].http2_enabled, protocol_only.vhosts[0].http3_enabled), (
protocol_only.vhosts[0].http2_enabled,
protocol_only.vhosts[0].http3_enabled
),
(base.vhosts[0].http2_enabled, base.vhosts[0].http3_enabled), (base.vhosts[0].http2_enabled, base.vhosts[0].http3_enabled),
"{label} toggle did not change a transport switch" "{label} toggle did not change a transport switch"
); );
assert_eq!(cache_revision(&protocol_only), identity, "{label} rotated cache"); assert_eq!(
assert!(matches!(cache_invalidation(&base, &protocol_only), CacheInvalidation::None)); cache_revision(&protocol_only),
assert!(matches!(cache_invalidation(&protocol_only, &base), CacheInvalidation::None)); identity,
"{label} rotated cache"
);
assert!(matches!(
cache_invalidation(&base, &protocol_only),
CacheInvalidation::None
));
assert!(matches!(
cache_invalidation(&protocol_only, &base),
CacheInvalidation::None
));
} }
let mut both_protocols = base.clone(); let mut both_protocols = base.clone();
both_protocols.vhosts[0].http2_enabled = !base.vhosts[0].http2_enabled; both_protocols.vhosts[0].http2_enabled = !base.vhosts[0].http2_enabled;
both_protocols.vhosts[0].http3_enabled = !base.vhosts[0].http3_enabled; both_protocols.vhosts[0].http3_enabled = !base.vhosts[0].http3_enabled;
assert_eq!(cache_revision(&both_protocols), identity); assert_eq!(cache_revision(&both_protocols), identity);
assert!(matches!(cache_invalidation(&base, &both_protocols), CacheInvalidation::None)); assert!(matches!(
cache_invalidation(&base, &both_protocols),
CacheInvalidation::None
));
let mut vhost_diagnostics = base.clone(); let mut vhost_diagnostics = base.clone();
vhost_diagnostics.vhosts[0].cache.expose_only_hits = Some(true); vhost_diagnostics.vhosts[0].cache.expose_only_hits = Some(true);
assert_eq!(cache_revision(&vhost_diagnostics), identity); assert_eq!(cache_revision(&vhost_diagnostics), identity);
assert!(matches!(cache_invalidation(&base, &vhost_diagnostics), CacheInvalidation::None)); assert!(matches!(
cache_invalidation(&base, &vhost_diagnostics),
CacheInvalidation::None
));
let mut cache = base.clone(); let mut cache = base.clone();
cache.cache.max_entries += 1; cache.cache.max_entries += 1;
@@ -118,12 +143,30 @@ mod cache_revision_tests {
let mut operational = base.clone(); let mut operational = base.clone();
operational.vhosts[0].load_balancing.keepalive.enabled = false; operational.vhosts[0].load_balancing.keepalive.enabled = false;
operational.vhosts[0].load_balancing.keepalive.idle_timeout_seconds = 90; operational.vhosts[0]
operational.vhosts[0].load_balancing.keepalive.max_idle_per_host = 32; .load_balancing
operational.vhosts[0].load_balancing.keepalive.tcp_keepalive_seconds = 90; .keepalive
operational.vhosts[0].load_balancing.keepalive.pool_recycle_requests = 1_000; .idle_timeout_seconds = 90;
operational.vhosts[0].load_balancing.keepalive.pool_recycle_seconds = 1_800; operational.vhosts[0]
operational.vhosts[0].load_balancing.keepalive.http1_max_buffer_bytes = 65_536; .load_balancing
.keepalive
.max_idle_per_host = 32;
operational.vhosts[0]
.load_balancing
.keepalive
.tcp_keepalive_seconds = 90;
operational.vhosts[0]
.load_balancing
.keepalive
.pool_recycle_requests = 1_000;
operational.vhosts[0]
.load_balancing
.keepalive
.pool_recycle_seconds = 1_800;
operational.vhosts[0]
.load_balancing
.keepalive
.http1_max_buffer_bytes = 65_536;
operational.vhosts[0].backends[0].weight += 1; operational.vhosts[0].backends[0].weight += 1;
operational.vhosts[0].backends[0].max_concurrent_requests = 16; operational.vhosts[0].backends[0].max_concurrent_requests = 16;
operational.vhosts[0].backends[0].queue_timeout_ms = 100; operational.vhosts[0].backends[0].queue_timeout_ms = 100;
@@ -132,15 +175,26 @@ mod cache_revision_tests {
operational.vhosts[0].user_agent_blocking = Default::default(); operational.vhosts[0].user_agent_blocking = Default::default();
operational.vhosts[0].proxy_timeouts.read_timeout_seconds = Some(180); operational.vhosts[0].proxy_timeouts.read_timeout_seconds = Some(180);
assert_eq!(cache_revision(&operational), identity); assert_eq!(cache_revision(&operational), identity);
assert_eq!(vhost_cache_revision(&operational.vhosts[0]), vhost_cache_revision(&base.vhosts[0])); assert_eq!(
assert!(matches!(cache_invalidation(&base, &operational), CacheInvalidation::None)); vhost_cache_revision(&operational.vhosts[0]),
assert!(matches!(cache_invalidation(&operational, &base), CacheInvalidation::None)); vhost_cache_revision(&base.vhosts[0])
);
assert!(matches!(
cache_invalidation(&base, &operational),
CacheInvalidation::None
));
assert!(matches!(
cache_invalidation(&operational, &base),
CacheInvalidation::None
));
let mut vhost = base.clone(); let mut vhost = base.clone();
vhost.vhosts[0].backends[0].url = "http://127.0.0.1:9001".into(); vhost.vhosts[0].backends[0].url = "http://127.0.0.1:9001".into();
assert_eq!(cache_revision(&vhost), identity); assert_eq!(cache_revision(&vhost), identity);
let affected_id = base.vhosts[0].id.clone(); let affected_id = base.vhosts[0].id.clone();
assert!(matches!(cache_invalidation(&base, &vhost), CacheInvalidation::Scopes(ids) if ids == vec![affected_id])); assert!(
matches!(cache_invalidation(&base, &vhost), CacheInvalidation::Scopes(ids) if ids == vec![affected_id])
);
let mut multiple = base.clone(); let mut multiple = base.clone();
let mut another = multiple.vhosts[0].clone(); let mut another = multiple.vhosts[0].clone();
@@ -149,10 +203,15 @@ mod cache_revision_tests {
multiple.vhosts.push(another); multiple.vhosts.push(another);
let mut changed_one = multiple.clone(); let mut changed_one = multiple.clone();
changed_one.vhosts[0].backends[0].url = "http://127.0.0.1:9002".into(); changed_one.vhosts[0].backends[0].url = "http://127.0.0.1:9002".into();
assert!(matches!(cache_invalidation(&multiple, &changed_one), CacheInvalidation::Scopes(ids) assert!(
if ids == vec![base.vhosts[0].id.clone()])); matches!(cache_invalidation(&multiple, &changed_one), CacheInvalidation::Scopes(ids)
if ids == vec![base.vhosts[0].id.clone()])
);
let mut changed_global = multiple.clone(); let mut changed_global = multiple.clone();
changed_global.transform.max_body_bytes += 1; changed_global.transform.max_body_bytes += 1;
assert!(matches!(cache_invalidation(&multiple, &changed_global), CacheInvalidation::All(_))); assert!(matches!(
cache_invalidation(&multiple, &changed_global),
CacheInvalidation::All(_)
));
} }
} }
+10 -1
View File
@@ -153,7 +153,16 @@ fn log_protocol_error(
tls_sni: Option<String>, tls_sni: Option<String>,
) { ) {
log_protocol_error_with_identity( log_protocol_error_with_identity(
state, kind, detail, peer, local, transport, http_version, status, None, None, state,
kind,
detail,
peer,
local,
transport,
http_version,
status,
None,
None,
tls_sni, tls_sni,
); );
} }
-1
View File
@@ -205,4 +205,3 @@ async fn connection<I>(
}, },
} }
} }
+3 -3
View File
@@ -201,12 +201,12 @@ fn h2_headers_frame<B>(request: &Request<B>) -> std::result::Result<Bytes, &'sta
((length >> 16) & 0xff) as u8, ((length >> 16) & 0xff) as u8,
((length >> 8) & 0xff) as u8, ((length >> 8) & 0xff) as u8,
(length & 0xff) as u8, (length & 0xff) as u8,
0x1, 0x1,
0x5, 0x5,
0, 0,
0, 0,
0, 0,
1, 1,
]); ]);
frame.extend_from_slice(&block); frame.extend_from_slice(&block);
Ok(Bytes::from(frame)) Ok(Bytes::from(frame))
+207 -58
View File
@@ -5,11 +5,14 @@ use std::{
io, io,
os::fd::{AsRawFd, RawFd}, os::fd::{AsRawFd, RawFd},
pin::Pin, pin::Pin,
sync::{Arc, Mutex},
sync::atomic::Ordering, sync::atomic::Ordering,
sync::{Arc, Mutex},
task::{Context, Poll}, task::{Context, Poll},
}; };
use tokio::{io::{AsyncRead, AsyncWrite, Interest, ReadBuf}, net::TcpStream}; use tokio::{
io::{AsyncRead, AsyncWrite, Interest, ReadBuf},
net::TcpStream,
};
const TAG_LEN: usize = 32; const TAG_LEN: usize = 32;
const FRAME_LEN: usize = 64 * 1024; const FRAME_LEN: usize = 64 * 1024;
@@ -54,24 +57,37 @@ impl SendfileSlots {
return None; return None;
} }
let mut registry = self.0.lock().unwrap_or_else(|e| e.into_inner()); let mut registry = self.0.lock().unwrap_or_else(|e| e.into_inner());
if registry.slots.len() >= MAX_PENDING { return None; } if registry.slots.len() >= MAX_PENDING {
return None;
}
let mut tag = [0u8; TAG_LEN]; let mut tag = [0u8; TAG_LEN];
tag[0] = 0; tag[0] = 0;
tag[1..].copy_from_slice(&rand::random::<[u8; TAG_LEN - 1]>()); tag[1..].copy_from_slice(&rand::random::<[u8; TAG_LEN - 1]>());
if registry.slots.iter().any(|slot| slot.tag == tag) { return None; } if registry.slots.iter().any(|slot| slot.tag == tag) {
return None;
}
let mut data = vec![0u8; FRAME_LEN]; let mut data = vec![0u8; FRAME_LEN];
for chunk in data.chunks_exact_mut(TAG_LEN) { chunk.copy_from_slice(&tag); } for chunk in data.chunks_exact_mut(TAG_LEN) {
chunk.copy_from_slice(&tag);
}
registry.slots.push(Slot { registry.slots.push(Slot {
tag, file: candidate.file, length: candidate.length, written: 0, tag,
file: candidate.file,
length: candidate.length,
written: 0,
}); });
Some(SendfileBody { Some(SendfileBody {
bytes: Bytes::from(data), remaining: candidate.length, web_metrics, bytes: Bytes::from(data),
remaining: candidate.length,
web_metrics,
}) })
} }
fn plan(&self, buf: &[u8]) -> WritePlan { fn plan(&self, buf: &[u8]) -> WritePlan {
let registry = self.0.lock().unwrap_or_else(|e| e.into_inner()); let registry = self.0.lock().unwrap_or_else(|e| e.into_inner());
if registry.slots.is_empty() { return WritePlan::Plain(buf.len()); } if registry.slots.is_empty() {
return WritePlan::Plain(buf.len());
}
if let Some(tag) = registry.active { if let Some(tag) = registry.active {
if let Some(slot) = registry.slots.iter().find(|s| s.tag == tag) { if let Some(slot) = registry.slots.iter().find(|s| s.tag == tag) {
return plan_file(slot, buf); return plan_file(slot, buf);
@@ -79,12 +95,16 @@ impl SendfileSlots {
return WritePlan::Invalid; return WritePlan::Invalid;
} }
for start in 0..buf.len() { for start in 0..buf.len() {
if buf[start] != 0 { continue; } if buf[start] != 0 {
continue;
}
for slot in &registry.slots { for slot in &registry.slots {
let available = buf.len() - start; let available = buf.len() - start;
let compare = available.min(TAG_LEN); let compare = available.min(TAG_LEN);
if buf[start..start + compare] == slot.tag[..compare] { if buf[start..start + compare] == slot.tag[..compare] {
if start > 0 { return WritePlan::Plain(start); } if start > 0 {
return WritePlan::Plain(start);
}
return plan_file(slot, buf); return plan_file(slot, buf);
} }
} }
@@ -107,36 +127,58 @@ impl SendfileSlots {
enum WritePlan { enum WritePlan {
Plain(usize), Plain(usize),
File { tag: [u8; TAG_LEN], fd: RawFd, file: Arc<File>, offset: u64, count: usize }, File {
tag: [u8; TAG_LEN],
fd: RawFd,
file: Arc<File>,
offset: u64,
count: usize,
},
Invalid, Invalid,
} }
fn plan_file(slot: &Slot, buf: &[u8]) -> WritePlan { fn plan_file(slot: &Slot, buf: &[u8]) -> WritePlan {
let expected = (slot.length - slot.written).min(buf.len() as u64) as usize; let expected = (slot.length - slot.written).min(buf.len() as u64) as usize;
let matched = buf[..expected].iter().enumerate() let matched = buf[..expected]
.iter()
.enumerate()
.take_while(|(n, byte)| **byte == slot.tag[(slot.written as usize + n) % TAG_LEN]) .take_while(|(n, byte)| **byte == slot.tag[(slot.written as usize + n) % TAG_LEN])
.count(); .count();
if matched == 0 { return WritePlan::Invalid; } if matched == 0 {
return WritePlan::Invalid;
}
WritePlan::File { WritePlan::File {
tag: slot.tag, fd: slot.file.as_raw_fd(), file: slot.file.clone(), tag: slot.tag,
offset: slot.written, count: matched, fd: slot.file.as_raw_fd(),
file: slot.file.clone(),
offset: slot.written,
count: matched,
} }
} }
impl HttpBody for SendfileBody { impl HttpBody for SendfileBody {
type Data = Bytes; type Data = Bytes;
type Error = io::Error; type Error = io::Error;
fn poll_frame(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) fn poll_frame(
-> Poll<Option<Result<Frame<Bytes>, io::Error>>> mut self: Pin<&mut Self>,
{ _cx: &mut Context<'_>,
if self.remaining == 0 { return Poll::Ready(None); } ) -> Poll<Option<Result<Frame<Bytes>, io::Error>>> {
if self.remaining == 0 {
return Poll::Ready(None);
}
let len = self.remaining.min(FRAME_LEN as u64) as usize; let len = self.remaining.min(FRAME_LEN as u64) as usize;
self.remaining -= len as u64; self.remaining -= len as u64;
self.web_metrics.response_bytes.fetch_add(len as u64, Ordering::Relaxed); self.web_metrics
.response_bytes
.fetch_add(len as u64, Ordering::Relaxed);
Poll::Ready(Some(Ok(Frame::data(self.bytes.slice(0..len))))) Poll::Ready(Some(Ok(Frame::data(self.bytes.slice(0..len)))))
} }
fn size_hint(&self) -> SizeHint { SizeHint::with_exact(self.remaining) } fn size_hint(&self) -> SizeHint {
fn is_end_stream(&self) -> bool { self.remaining == 0 } SizeHint::with_exact(self.remaining)
}
fn is_end_stream(&self) -> bool {
self.remaining == 0
}
} }
pub(crate) struct SendfileIo { pub(crate) struct SendfileIo {
@@ -146,22 +188,46 @@ pub(crate) struct SendfileIo {
impl SendfileIo { impl SendfileIo {
pub fn new(stream: TcpStream) -> (Self, SendfileSlots) { pub fn new(stream: TcpStream) -> (Self, SendfileSlots) {
let slots = SendfileSlots::default(); let slots = SendfileSlots::default();
(Self { stream, slots: slots.clone() }, slots) (
Self {
stream,
slots: slots.clone(),
},
slots,
)
} }
} }
impl AsyncRead for SendfileIo { impl AsyncRead for SendfileIo {
fn poll_read(self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<io::Result<()>> { fn poll_read(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<io::Result<()>> {
Pin::new(&mut self.get_mut().stream).poll_read(cx, buf) Pin::new(&mut self.get_mut().stream).poll_read(cx, buf)
} }
} }
impl AsyncWrite for SendfileIo { impl AsyncWrite for SendfileIo {
fn poll_write(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &[u8]) -> Poll<io::Result<usize>> { fn poll_write(
if buf.is_empty() { return Poll::Ready(Ok(0)); } mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<io::Result<usize>> {
if buf.is_empty() {
return Poll::Ready(Ok(0));
}
match self.slots.plan(buf) { match self.slots.plan(buf) {
WritePlan::Plain(count) => Pin::new(&mut self.stream).poll_write(cx, &buf[..count]), WritePlan::Plain(count) => Pin::new(&mut self.stream).poll_write(cx, &buf[..count]),
WritePlan::Invalid => Poll::Ready(Err(io::Error::new(io::ErrorKind::InvalidData, WritePlan::Invalid => Poll::Ready(Err(io::Error::new(
"sendfile placeholder did not match its registered descriptor"))), io::ErrorKind::InvalidData,
WritePlan::File { tag, fd, file, offset, count } => { "sendfile placeholder did not match its registered descriptor",
))),
WritePlan::File {
tag,
fd,
file,
offset,
count,
} => {
let _keep_open = file; let _keep_open = file;
let socket: RawFd = self.stream.as_raw_fd(); let socket: RawFd = self.stream.as_raw_fd();
let mut at: libc::off_t = offset as libc::off_t; let mut at: libc::off_t = offset as libc::off_t;
@@ -171,13 +237,22 @@ impl AsyncWrite for SendfileIo {
Poll::Ready(Ok(())) => { Poll::Ready(Ok(())) => {
let result = self.stream.try_io(Interest::WRITABLE, || { let result = self.stream.try_io(Interest::WRITABLE, || {
let n = unsafe { libc::sendfile(socket, fd, &mut at, count) }; let n = unsafe { libc::sendfile(socket, fd, &mut at, count) };
if n < 0 { Err(io::Error::last_os_error()) } if n < 0 {
else if n == 0 { Err(io::Error::new(io::ErrorKind::UnexpectedEof, Err(io::Error::last_os_error())
"static source truncated during sendfile")) } } else if n == 0 {
else { Ok(n as usize) } Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"static source truncated during sendfile",
))
} else {
Ok(n as usize)
}
}); });
match result { match result {
Ok(n) => { self.slots.commit(tag, n); Poll::Ready(Ok(n)) }, Ok(n) => {
self.slots.commit(tag, n);
Poll::Ready(Ok(n))
}
Err(e) if e.kind() == io::ErrorKind::WouldBlock => Poll::Pending, Err(e) if e.kind() == io::ErrorKind::WouldBlock => Poll::Pending,
Err(e) => Poll::Ready(Err(e)), Err(e) => Poll::Ready(Err(e)),
} }
@@ -192,9 +267,22 @@ impl AsyncWrite for SendfileIo {
fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> { fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Pin::new(&mut self.stream).poll_shutdown(cx) Pin::new(&mut self.stream).poll_shutdown(cx)
} }
fn is_write_vectored(&self) -> bool { self.stream.is_write_vectored() } fn is_write_vectored(&self) -> bool {
fn poll_write_vectored(mut self: Pin<&mut Self>, cx: &mut Context<'_>, bufs: &[io::IoSlice<'_>]) -> Poll<io::Result<usize>> { self.stream.is_write_vectored()
if self.slots.0.lock().unwrap_or_else(|e| e.into_inner()).slots.is_empty() { }
fn poll_write_vectored(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
bufs: &[io::IoSlice<'_>],
) -> Poll<io::Result<usize>> {
if self
.slots
.0
.lock()
.unwrap_or_else(|e| e.into_inner())
.slots
.is_empty()
{
return Pin::new(&mut self.stream).poll_write_vectored(cx, bufs); return Pin::new(&mut self.stream).poll_write_vectored(cx, bufs);
} }
match bufs.iter().find(|part| !part.is_empty()) { match bufs.iter().find(|part| !part.is_empty()) {
@@ -208,8 +296,8 @@ impl AsyncWrite for SendfileIo {
mod tests { mod tests {
use super::*; use super::*;
use http_body::Body; use http_body::Body;
use tempfile::tempfile;
use std::io::Write; use std::io::Write;
use tempfile::tempfile;
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
#[test] #[test]
@@ -218,19 +306,42 @@ mod tests {
file.write_all(&vec![42u8; 100_000]).unwrap(); file.write_all(&vec![42u8; 100_000]).unwrap();
let slots = SendfileSlots::default(); let slots = SendfileSlots::default();
let counter = Arc::new(crate::web_server::metrics::PhpMetrics::default()); let counter = Arc::new(crate::web_server::metrics::PhpMetrics::default());
let mut body = slots.create_body(SendfileCandidate { file: Arc::new(file), length: 100_000 }, counter.clone()).unwrap(); let mut body = slots
let first = match Pin::new(&mut body).poll_frame(&mut Context::from_waker(std::task::Waker::noop())) { .create_body(
Poll::Ready(Some(Ok(frame))) => frame.into_data().unwrap(), _ => panic!("frame missing"), SendfileCandidate {
file: Arc::new(file),
length: 100_000,
},
counter.clone(),
)
.unwrap();
let first = match Pin::new(&mut body)
.poll_frame(&mut Context::from_waker(std::task::Waker::noop()))
{
Poll::Ready(Some(Ok(frame))) => frame.into_data().unwrap(),
_ => panic!("frame missing"),
}; };
assert_eq!(first.len(), FRAME_LEN); assert_eq!(first.len(), FRAME_LEN);
match slots.plan(&first) { WritePlan::File { count, .. } => assert_eq!(count, FRAME_LEN), _ => panic!("missing kernel file plan") } match slots.plan(&first) {
WritePlan::File { count, .. } => assert_eq!(count, FRAME_LEN),
_ => panic!("missing kernel file plan"),
}
let tag = slots.0.lock().unwrap().slots[0].tag; let tag = slots.0.lock().unwrap().slots[0].tag;
slots.commit(tag, FRAME_LEN); slots.commit(tag, FRAME_LEN);
let second = match Pin::new(&mut body).poll_frame(&mut Context::from_waker(std::task::Waker::noop())) { let second = match Pin::new(&mut body)
Poll::Ready(Some(Ok(frame))) => frame.into_data().unwrap(), _ => panic!("frame missing"), .poll_frame(&mut Context::from_waker(std::task::Waker::noop()))
{
Poll::Ready(Some(Ok(frame))) => frame.into_data().unwrap(),
_ => panic!("frame missing"),
}; };
assert_eq!(second.len(), 34_464); assert_eq!(second.len(), 34_464);
match slots.plan(&second) { WritePlan::File { count, offset, .. } => { assert_eq!(offset, 65_536); assert_eq!(count, 34_464); }, _ => panic!("second plan missing") } match slots.plan(&second) {
WritePlan::File { count, offset, .. } => {
assert_eq!(offset, 65_536);
assert_eq!(count, 34_464);
}
_ => panic!("second plan missing"),
}
slots.commit(tag, second.len()); slots.commit(tag, second.len());
assert!(slots.0.lock().unwrap().slots.is_empty()); assert!(slots.0.lock().unwrap().slots.is_empty());
assert!(body.is_end_stream()); assert!(body.is_end_stream());
@@ -245,36 +356,74 @@ mod tests {
let (tcp, _) = listener.accept().await.unwrap(); let (tcp, _) = listener.accept().await.unwrap();
let (mut socket, slots) = SendfileIo::new(tcp); let (mut socket, slots) = SendfileIo::new(tcp);
for round in 0..2 { for round in 0..2 {
let expected = if round == 0 { vec![0, 1, 2, 3].repeat(25_000) } let expected = if round == 0 {
else { vec![9u8; 100_003] }; vec![0, 1, 2, 3].repeat(25_000)
} else {
vec![9u8; 100_003]
};
let mut file = tempfile().unwrap(); let mut file = tempfile().unwrap();
file.write_all(&expected).unwrap(); file.write_all(&expected).unwrap();
let web = Arc::new(crate::web_server::metrics::PhpMetrics::default()); let web = Arc::new(crate::web_server::metrics::PhpMetrics::default());
let body = slots.create_body(SendfileCandidate { let body = slots
file: Arc::new(file), length: expected.len() as u64, .create_body(
}, web.clone()).unwrap(); SendfileCandidate {
let first = format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n", expected.len()); file: Arc::new(file),
length: expected.len() as u64,
},
web.clone(),
)
.unwrap();
let first = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n",
expected.len()
);
let mut body = body; let mut body = body;
let first_frame = http_body_util::BodyExt::frame(&mut body).await.unwrap().unwrap().into_data().unwrap(); let first_frame = http_body_util::BodyExt::frame(&mut body)
.await
.unwrap()
.unwrap()
.into_data()
.unwrap();
let mut joined = first.into_bytes(); let mut joined = first.into_bytes();
joined.extend_from_slice(&first_frame); joined.extend_from_slice(&first_frame);
socket.write_all(&joined).await.unwrap(); socket.write_all(&joined).await.unwrap();
while let Some(frame) = http_body_util::BodyExt::frame(&mut body).await { while let Some(frame) = http_body_util::BodyExt::frame(&mut body).await {
socket.write_all(&frame.unwrap().into_data().unwrap()).await.unwrap(); socket
.write_all(&frame.unwrap().into_data().unwrap())
.await
.unwrap();
} }
assert_eq!(web.response_bytes.load(Ordering::Relaxed), expected.len() as u64); assert_eq!(
web.response_bytes.load(Ordering::Relaxed),
expected.len() as u64
);
} }
socket.shutdown().await.unwrap(); socket.shutdown().await.unwrap();
}); });
let mut client = tokio::net::TcpStream::connect(address).await.unwrap(); let mut client = tokio::net::TcpStream::connect(address).await.unwrap();
let mut actual = Vec::new(); let mut actual = Vec::new();
tokio::time::timeout(std::time::Duration::from_secs(10), client.read_to_end(&mut actual)).await.unwrap().unwrap(); tokio::time::timeout(
std::time::Duration::from_secs(10),
client.read_to_end(&mut actual),
)
.await
.unwrap()
.unwrap();
server.await.unwrap(); server.await.unwrap();
let mut wanted = Vec::new(); let mut wanted = Vec::new();
for expected in [vec![0, 1, 2, 3].repeat(25_000), vec![9u8; 100_003]] { for expected in [vec![0, 1, 2, 3].repeat(25_000), vec![9u8; 100_003]] {
wanted.extend_from_slice(format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n", expected.len()).as_bytes()); wanted.extend_from_slice(
format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n",
expected.len()
)
.as_bytes(),
);
wanted.extend_from_slice(&expected); wanted.extend_from_slice(&expected);
} }
assert_eq!(actual, wanted, "kernel sendfile must never leak placeholder markers"); assert_eq!(
actual, wanted,
"kernel sendfile must never leak placeholder markers"
);
} }
} }
+15 -5
View File
@@ -67,9 +67,12 @@ fn ensure_locked_listener(config: &mut crate::config::Config, address: SocketAdd
return; return;
} }
let canonical_id = if tls { "https" } else { "http" }; let canonical_id = if tls { "https" } else { "http" };
if let Some(endpoint) = config.listeners.endpoints.iter_mut().find(|endpoint| { if let Some(endpoint) = config
endpoint.id == canonical_id && endpoint.binding.tls == tls .listeners
}) { .endpoints
.iter_mut()
.find(|endpoint| endpoint.id == canonical_id && endpoint.binding.tls == tls)
{
endpoint.binding.address = address; endpoint.binding.address = address;
endpoint.additional_addresses.clear(); endpoint.additional_addresses.clear();
endpoint.enabled = true; endpoint.enabled = true;
@@ -166,7 +169,13 @@ pub struct PanelCompression {
pub gzip: bool, pub gzip: bool,
} }
impl Default for PanelCompression { impl Default for PanelCompression {
fn default() -> Self { Self { zstd: true, br: true, gzip: true } } fn default() -> Self {
Self {
zstd: true,
br: true,
gzip: true,
}
}
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -248,7 +257,8 @@ impl Administration {
*public_origin = normalize_public_admin_origin(public_origin)?; *public_origin = normalize_public_admin_origin(public_origin)?;
} }
if let Some(host) = self.public_origin_host().map(str::to_owned) { if let Some(host) = self.public_origin_host().map(str::to_owned) {
self.allowed_hosts.retain(|allowed| !allowed.eq_ignore_ascii_case(&host)); self.allowed_hosts
.retain(|allowed| !allowed.eq_ignore_ascii_case(&host));
} }
Ok(()) Ok(())
} }
+23 -8
View File
@@ -3,7 +3,8 @@ fn normalize_public_admin_origin(public_origin: &str) -> Result<String> {
public_origin.len() <= 255 && !public_origin.chars().any(|c| matches!(c, '@' | '#' | '?' | ',' | '\\')), public_origin.len() <= 255 && !public_origin.chars().any(|c| matches!(c, '@' | '#' | '?' | ',' | '\\')),
"administration public_origin must be a single HTTPS origin without userinfo, query or fragment" "administration public_origin must be a single HTTPS origin without userinfo, query or fragment"
); );
let uri = public_origin.parse::<http::Uri>() let uri = public_origin
.parse::<http::Uri>()
.context("invalid administration public_origin")?; .context("invalid administration public_origin")?;
ensure!( ensure!(
uri.scheme_str() == Some("https") uri.scheme_str() == Some("https")
@@ -11,7 +12,8 @@ fn normalize_public_admin_origin(public_origin: &str) -> Result<String> {
&& matches!(uri.path(), "" | "/"), && matches!(uri.path(), "" | "/"),
"administration public_origin must be HTTPS without a path or query" "administration public_origin must be HTTPS without a path or query"
); );
let authority = uri.authority() let authority = uri
.authority()
.context("administration public_origin requires an authority")?; .context("administration public_origin requires an authority")?;
let host = crate::config::normalize_host(authority.host())?; let host = crate::config::normalize_host(authority.host())?;
ensure!( ensure!(
@@ -19,9 +21,13 @@ fn normalize_public_admin_origin(public_origin: &str) -> Result<String> {
"administration public_origin must name a DNS hostname" "administration public_origin must name a DNS hostname"
); );
let port = if authority.as_str().contains(':') { let port = if authority.as_str().contains(':') {
let port = authority.port_u16() let port = authority
.port_u16()
.context("invalid administration public_origin port")?; .context("invalid administration public_origin port")?;
ensure!(port > 0, "administration public_origin port must be nonzero"); ensure!(
port > 0,
"administration public_origin port must be nonzero"
);
format!(":{port}") format!(":{port}")
} else { } else {
String::new() String::new()
@@ -31,17 +37,26 @@ fn normalize_public_admin_origin(public_origin: &str) -> Result<String> {
impl Administration { impl Administration {
pub fn public_origin_host(&self) -> Option<&str> { pub fn public_origin_host(&self) -> Option<&str> {
self.public_origin.as_deref() self.public_origin
.as_deref()
.and_then(|url| url.strip_prefix("https://")) .and_then(|url| url.strip_prefix("https://"))
.and_then(|authority| authority.split(':').next()) .and_then(|authority| authority.split(':').next())
} }
pub fn explicitly_allowed_panel_host(&self, host: &str) -> bool { pub fn explicitly_allowed_panel_host(&self, host: &str) -> bool {
self.allowed_hosts.iter().any(|allowed| allowed.eq_ignore_ascii_case(host)) self.allowed_hosts
|| self.public_origin_host().is_some_and(|name| name.eq_ignore_ascii_case(host)) .iter()
.any(|allowed| allowed.eq_ignore_ascii_case(host))
|| self
.public_origin_host()
.is_some_and(|name| name.eq_ignore_ascii_case(host))
} }
pub fn include_public_origin_host(&mut self) { pub fn include_public_origin_host(&mut self) {
if let Some(host) = self.public_origin_host().map(str::to_owned) { if let Some(host) = self.public_origin_host().map(str::to_owned) {
if !self.allowed_hosts.iter().any(|allowed| allowed.eq_ignore_ascii_case(&host)) { if !self
.allowed_hosts
.iter()
.any(|allowed| allowed.eq_ignore_ascii_case(&host))
{
self.allowed_hosts.push(host); self.allowed_hosts.push(host);
} }
} }
+59 -12
View File
@@ -15,11 +15,29 @@ mod tests {
}; };
configured.validate().unwrap(); configured.validate().unwrap();
assert_eq!(configured.idle_timeout(), Duration::from_secs(45)); assert_eq!(configured.idle_timeout(), Duration::from_secs(45));
assert_eq!(configured.max_connection_lifetime(), Some(Duration::from_secs(3_600))); assert_eq!(
configured.max_connection_lifetime(),
Some(Duration::from_secs(3_600))
);
assert_eq!(configured.drain_timeout(), Duration::from_secs(20)); assert_eq!(configured.drain_timeout(), Duration::from_secs(20));
assert!(Http2Connections { idle_timeout_seconds: 0, ..defaults }.validate().is_err()); assert!(Http2Connections {
assert!(Http2Connections { max_connection_lifetime_seconds: 2_592_001, ..defaults }.validate().is_err()); idle_timeout_seconds: 0,
assert!(Http2Connections { drain_timeout_seconds: 0, ..defaults }.validate().is_err()); ..defaults
}
.validate()
.is_err());
assert!(Http2Connections {
max_connection_lifetime_seconds: 2_592_001,
..defaults
}
.validate()
.is_err());
assert!(Http2Connections {
drain_timeout_seconds: 0,
..defaults
}
.validate()
.is_err());
} }
#[test] #[test]
fn new_install_server_header_defaults_to_rustproxy_without_changing_legacy_missing_field() { fn new_install_server_header_defaults_to_rustproxy_without_changing_legacy_missing_field() {
@@ -120,13 +138,29 @@ mod tests {
locks.ensure_config(&mut config); locks.ensure_config(&mut config);
let http = config.listeners.endpoints.iter().find(|item| item.id == "http").unwrap(); let http = config
let https = config.listeners.endpoints.iter().find(|item| item.id == "https").unwrap(); .listeners
.endpoints
.iter()
.find(|item| item.id == "http")
.unwrap();
let https = config
.listeners
.endpoints
.iter()
.find(|item| item.id == "https")
.unwrap();
assert_eq!(http.binding.address, "0.0.0.0:8181".parse().unwrap()); assert_eq!(http.binding.address, "0.0.0.0:8181".parse().unwrap());
assert!(http.additional_addresses.is_empty()); assert!(http.additional_addresses.is_empty());
assert_eq!(https.binding.address, "0.0.0.0:8443".parse().unwrap()); assert_eq!(https.binding.address, "0.0.0.0:8443".parse().unwrap());
assert!(https.additional_addresses.is_empty()); assert!(https.additional_addresses.is_empty());
assert!(!config.listeners.endpoints.iter().filter(|item| item.enabled).flat_map(|item| item.bindings()).any(|binding| binding.address.port() == 80 || binding.address.port() == 443)); assert!(!config
.listeners
.endpoints
.iter()
.filter(|item| item.enabled)
.flat_map(|item| item.bindings())
.any(|binding| binding.address.port() == 80 || binding.address.port() == 443));
} }
#[test] #[test]
@@ -264,19 +298,32 @@ mod tests {
..Administration::default() ..Administration::default()
}; };
admin.validate(&tls).unwrap(); admin.validate(&tls).unwrap();
assert_eq!(admin.public_origin.as_deref(), Some("https://panel.example.test:8443")); assert_eq!(
admin.public_origin.as_deref(),
Some("https://panel.example.test:8443")
);
assert_eq!(admin.public_origin_host(), Some("panel.example.test")); assert_eq!(admin.public_origin_host(), Some("panel.example.test"));
assert!(admin.allowed_hosts.is_empty(), "pin must not be stored twice"); assert!(
admin.allowed_hosts.is_empty(),
"pin must not be stored twice"
);
assert!(admin.explicitly_allowed_panel_host("PANEL.EXAMPLE.TEST")); assert!(admin.explicitly_allowed_panel_host("PANEL.EXAMPLE.TEST"));
assert!(!admin.explicitly_allowed_panel_host("other.example.test")); assert!(!admin.explicitly_allowed_panel_host("other.example.test"));
let mut runtime = admin.clone(); let mut runtime = admin.clone();
runtime.include_public_origin_host(); runtime.include_public_origin_host();
runtime.include_public_origin_host(); runtime.include_public_origin_host();
assert_eq!(runtime.allowed_hosts, vec!["panel.example.test".to_owned()]); assert_eq!(runtime.allowed_hosts, vec!["panel.example.test".to_owned()]);
assert!(admin.allowed_hosts.is_empty(), "saved config remains unchanged"); assert!(
let active = Administration { public_origin: None, ..admin.clone() }; admin.allowed_hosts.is_empty(),
"saved config remains unchanged"
);
let active = Administration {
public_origin: None,
..admin.clone()
};
assert!(admin.restart_differs(&active)); assert!(admin.restart_differs(&active));
let mut legacy: Administration = serde_json::from_str(r#"{"allowed_hosts":["panel.example.test"]}"#).unwrap(); let mut legacy: Administration =
serde_json::from_str(r#"{"allowed_hosts":["panel.example.test"]}"#).unwrap();
legacy.validate(&tls).unwrap(); legacy.validate(&tls).unwrap();
assert_eq!(legacy.public_origin, None); assert_eq!(legacy.public_origin, None);
assert!(legacy.explicitly_allowed_panel_host("panel.example.test")); assert!(legacy.explicitly_allowed_panel_host("panel.example.test"));
+3 -1
View File
@@ -132,7 +132,9 @@ pub fn import(directory: &Path, name: &str, cert: PathBuf, key: PathBuf) -> Resu
#[derive(Debug, PartialEq, Eq)] #[derive(Debug, PartialEq, Eq)]
pub enum ExternalPairDeployment { pub enum ExternalPairDeployment {
Unchanged { covered_hosts: Vec<String> }, Unchanged {
covered_hosts: Vec<String>,
},
Staged { Staged {
cert_file: String, cert_file: String,
key_file: String, key_file: String,
+4 -1
View File
@@ -358,7 +358,10 @@ pub fn compile(settings: &Settings, directory: Option<&Path>) -> Result<Option<A
.strip_prefix("*.") .strip_prefix("*.")
.map(|base| format!("rustproxy-wildcard-check.{base}")) .map(|base| format!("rustproxy-wildcard-check.{base}"))
.unwrap_or_else(|| host.clone()); .unwrap_or_else(|| host.clone());
if !settings.forced_mismatches.contains(&(cert.id.clone(), host.clone())) { if !settings
.forced_mismatches
.contains(&(cert.id.clone(), host.clone()))
{
let mut checker = ResolvesServerCertUsingSni::new(); let mut checker = ResolvesServerCertUsingSni::new();
checker checker
.add(&validation_name, key.clone()) .add(&validation_name, key.clone())
+52 -20
View File
@@ -50,11 +50,19 @@ mod tests {
analytics.push_lazy(|| { analytics.push_lazy(|| {
made.fetch_add(1, Ordering::Relaxed); made.fetch_add(1, Ordering::Relaxed);
Sample { Sample {
vhost: "site".into(), route: None, method: "GET".into(), vhost: "site".into(),
path: "/".into(), client_ip: None, status: 200, route: None,
duration_ms: 1, request_bytes: 0, response_bytes: 1040, method: "GET".into(),
cache: "BYPASS", source: "reverse_proxy", path: "/".into(),
user_agent: None, country: None, client_ip: None,
status: 200,
duration_ms: 1,
request_bytes: 0,
response_bytes: 1040,
cache: "BYPASS",
source: "reverse_proxy",
user_agent: None,
country: None,
} }
}); });
} }
@@ -78,15 +86,27 @@ mod tests {
let mut counters = Counters::default(); let mut counters = Counters::default();
for status in [100, 200, 302, 404, 503, 701] { for status in [100, 200, 302, 404, 503, 701] {
counters.record(Sample { counters.record(Sample {
vhost: "test".into(), route: None, method: "GET".into(), vhost: "test".into(),
path: "/".into(), client_ip: None, status, route: None,
duration_ms: 1, request_bytes: 0, response_bytes: 1, method: "GET".into(),
cache: "BYPASS", source: "reverse_proxy", path: "/".into(),
user_agent: None, country: None, client_ip: None,
status,
duration_ms: 1,
request_bytes: 0,
response_bytes: 1,
cache: "BYPASS",
source: "reverse_proxy",
user_agent: None,
country: None,
}); });
} }
let labels: std::collections::HashSet<_> = counters.view().statuses.into_iter() let labels: std::collections::HashSet<_> = counters
.map(|entry| entry.value).collect(); .view()
.statuses
.into_iter()
.map(|entry| entry.value)
.collect();
for label in ["1xx", "2xx", "3xx", "4xx", "5xx", "7xx"] { for label in ["1xx", "2xx", "3xx", "4xx", "5xx", "7xx"] {
assert!(labels.contains(label)); assert!(labels.contains(label));
} }
@@ -113,12 +133,23 @@ mod tests {
let analytics = Analytics::start(shutdown.clone()); let analytics = Analytics::start(shutdown.clone());
for id in 0..130 { for id in 0..130 {
analytics.push(Sample { analytics.push(Sample {
vhost: if id % 2 == 0 { "left".into() } else { "right".into() }, vhost: if id % 2 == 0 {
route: None, method: "GET".into(), path: "/small".into(), "left".into()
client_ip: None, status: 200, duration_ms: 1, } else {
request_bytes: 0, response_bytes: 1040, "right".into()
cache: "BYPASS", source: "reverse_proxy", },
user_agent: None, country: None, route: None,
method: "GET".into(),
path: "/small".into(),
client_ip: None,
status: 200,
duration_ms: 1,
request_bytes: 0,
response_bytes: 1040,
cache: "BYPASS",
source: "reverse_proxy",
user_agent: None,
country: None,
}); });
} }
tokio::time::timeout(std::time::Duration::from_secs(2), async { tokio::time::timeout(std::time::Duration::from_secs(2), async {
@@ -127,7 +158,9 @@ mod tests {
{ {
tokio::task::yield_now().await; tokio::task::yield_now().await;
} }
}).await.expect("analytics should drain two batches"); })
.await
.expect("analytics should drain two batches");
let all = analytics.view(None).data; let all = analytics.view(None).data;
assert_eq!(all.requests, 130); assert_eq!(all.requests, 130);
assert_eq!(all.response_bytes, 130 * 1040); assert_eq!(all.response_bytes, 130 * 1040);
@@ -136,5 +169,4 @@ mod tests {
assert_eq!(analytics.view(None).data.requests, 65); assert_eq!(analytics.view(None).data.requests, 65);
shutdown.cancel(); shutdown.cancel();
} }
} }
+8 -2
View File
@@ -427,7 +427,13 @@ mod tests {
let result = css::minify(input.as_bytes()).expect("minify panel stylesheet"); let result = css::minify(input.as_bytes()).expect("minify panel stylesheet");
let output = std::str::from_utf8(&result).expect("UTF-8 CSS"); let output = std::str::from_utf8(&result).expect("UTF-8 CSS");
assert!(output.contains("html:is([data-corner-style=square],[data-corner-style=subtle],[data-corner-style=rounded],[data-corner-style=extra]) :is("), "lost descendant pseudo-class combinator"); assert!(output.contains("html:is([data-corner-style=square],[data-corner-style=subtle],[data-corner-style=rounded],[data-corner-style=extra]) :is("), "lost descendant pseudo-class combinator");
assert!(output.contains(".card,.stat,dialog,.login-card,"), "lost card selector list"); assert!(
assert!(output.contains("border-radius:var(--rp-radius-surface) !important"), "lost radius declaration"); output.contains(".card,.stat,dialog,.login-card,"),
"lost card selector list"
);
assert!(
output.contains("border-radius:var(--rp-radius-surface) !important"),
"lost radius declaration"
);
} }
} }
+9 -6
View File
@@ -49,7 +49,6 @@ mod tests {
assert_eq!(stats.generation, 1); assert_eq!(stats.generation, 1);
} }
#[test] #[test]
fn generation_recycles_after_age_without_request() { fn generation_recycles_after_age_without_request() {
let backend = backend("rotation-age-test", 18083); let backend = backend("rotation-age-test", 18083);
@@ -101,14 +100,18 @@ mod tests {
keepalive.pool_recycle_requests = 1_000; keepalive.pool_recycle_requests = 1_000;
keepalive.pool_recycle_seconds = 0; keepalive.pool_recycle_seconds = 0;
let client = client_with_keepalive( let client = client_with_keepalive(
&backend, AddressPolicy::new(&backend).unwrap(), false, &keepalive, &backend,
).unwrap(); AddressPolicy::new(&backend).unwrap(),
for _ in 0..1_000 { client.inner.client_for_request(); } false,
&keepalive,
)
.unwrap();
for _ in 0..1_000 {
client.inner.client_for_request();
}
assert_eq!(client.rotation_stats().rotations, 0); assert_eq!(client.rotation_stats().rotations, 0);
client.inner.client_for_request(); client.inner.client_for_request();
assert_eq!(client.rotation_stats().rotations, 1); assert_eq!(client.rotation_stats().rotations, 1);
assert_eq!(client.rotation_stats().generation_requests, 1); assert_eq!(client.rotation_stats().generation_requests, 1);
} }
} }
+45 -19
View File
@@ -232,9 +232,11 @@ async fn tls_authority_origin(
let key = rustls::pki_types::PrivatePkcs8KeyDer::from(pair.signing_key.serialize_der()); let key = rustls::pki_types::PrivatePkcs8KeyDer::from(pair.signing_key.serialize_der());
let provider = Arc::new(rustls::crypto::ring::default_provider()); let provider = Arc::new(rustls::crypto::ring::default_provider());
let mut config = rustls::ServerConfig::builder_with_provider(provider) let mut config = rustls::ServerConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions().unwrap() .with_safe_default_protocol_versions()
.unwrap()
.with_no_client_auth() .with_no_client_auth()
.with_single_cert(vec![pair.cert.der().clone()], key.into()).unwrap(); .with_single_cert(vec![pair.cert.der().clone()], key.into())
.unwrap();
config.alpn_protocols = if offer_h2 { config.alpn_protocols = if offer_h2 {
vec![b"h2".to_vec(), b"http/1.1".to_vec()] vec![b"h2".to_vec(), b"http/1.1".to_vec()]
} else { } else {
@@ -253,7 +255,9 @@ async fn tls_authority_origin(
async move { async move {
if let Some(tx) = observed.lock().unwrap().take() { if let Some(tx) = observed.lock().unwrap().take() {
let authority = request.uri().authority().map(|v| v.as_str().to_owned()); let authority = request.uri().authority().map(|v| v.as_str().to_owned());
let host = request.headers().get("host") let host = request
.headers()
.get("host")
.map(|v| v.to_str().unwrap().to_owned()); .map(|v| v.to_str().unwrap().to_owned());
let _ = tx.send((request.version(), authority, host, sni)); let _ = tx.send((request.version(), authority, host, sni));
} }
@@ -262,10 +266,12 @@ async fn tls_authority_origin(
}); });
if offer_h2 { if offer_h2 {
let _ = hyper::server::conn::http2::Builder::new(TokioExecutor::new()) let _ = hyper::server::conn::http2::Builder::new(TokioExecutor::new())
.serve_connection(TokioIo::new(tls), service).await; .serve_connection(TokioIo::new(tls), service)
.await;
} else { } else {
let _ = hyper::server::conn::http1::Builder::new() let _ = hyper::server::conn::http1::Builder::new()
.serve_connection(TokioIo::new(tls), service).await; .serve_connection(TokioIo::new(tls), service)
.await;
} }
}); });
(address, observed_rx, server) (address, observed_rx, server)
@@ -278,16 +284,23 @@ async fn strict_h2_preserves_public_authority_while_tcp_and_sni_use_private_orig
backend.tls_server_name = Some("localhost".into()); backend.tls_server_name = Some("localhost".into());
backend.preserve_host = true; backend.preserve_host = true;
let client = client(&backend, AddressPolicy::new(&backend).unwrap(), false).unwrap(); let client = client(&backend, AddressPolicy::new(&backend).unwrap(), false).unwrap();
let request = Request::builder().version(Version::HTTP_2) let request = Request::builder()
.version(Version::HTTP_2)
.uri("https://unitraklub.pl/block_refresh/block/10") .uri("https://unitraklub.pl/block_refresh/block/10")
.body(Body::empty()).unwrap(); .body(Body::empty())
.unwrap();
let response = tokio::time::timeout(Duration::from_secs(5), client.request(request)) let response = tokio::time::timeout(Duration::from_secs(5), client.request(request))
.await.unwrap().unwrap(); .await
.unwrap()
.unwrap();
assert_eq!(response.version(), Version::HTTP_2); assert_eq!(response.version(), Version::HTTP_2);
let (version, authority, host, sni) = seen.await.unwrap(); let (version, authority, host, sni) = seen.await.unwrap();
assert_eq!(version, Version::HTTP_2); assert_eq!(version, Version::HTTP_2);
assert_eq!(authority.as_deref(), Some("unitraklub.pl")); assert_eq!(authority.as_deref(), Some("unitraklub.pl"));
assert!(host.is_none(), "Strict H2 must not duplicate :authority with Host"); assert!(
host.is_none(),
"Strict H2 must not duplicate :authority with Host"
);
assert_eq!(sni.as_deref(), Some("localhost")); assert_eq!(sni.as_deref(), Some("localhost"));
server.abort(); server.abort();
} }
@@ -299,12 +312,16 @@ async fn auto_h1_fallback_preserves_public_host_without_resolving_it() {
backend.tls_server_name = Some("localhost".into()); backend.tls_server_name = Some("localhost".into());
backend.preserve_host = true; backend.preserve_host = true;
let client = client(&backend, AddressPolicy::new(&backend).unwrap(), false).unwrap(); let client = client(&backend, AddressPolicy::new(&backend).unwrap(), false).unwrap();
let request = Request::builder().version(Version::HTTP_11) let request = Request::builder()
.version(Version::HTTP_11)
.uri("https://unitraklub.pl/block_refresh/block/10") .uri("https://unitraklub.pl/block_refresh/block/10")
.header("host", "unitraklub.pl") .header("host", "unitraklub.pl")
.body(Body::empty()).unwrap(); .body(Body::empty())
.unwrap();
let response = tokio::time::timeout(Duration::from_secs(5), client.request(request)) let response = tokio::time::timeout(Duration::from_secs(5), client.request(request))
.await.unwrap().unwrap(); .await
.unwrap()
.unwrap();
assert_eq!(response.version(), Version::HTTP_11); assert_eq!(response.version(), Version::HTTP_11);
let (version, _authority, host, sni) = seen.await.unwrap(); let (version, _authority, host, sni) = seen.await.unwrap();
assert_eq!(version, Version::HTTP_11); assert_eq!(version, Version::HTTP_11);
@@ -320,12 +337,16 @@ async fn auto_h2_alpn_retains_same_public_host_and_pinned_tls_origin() {
backend.tls_server_name = Some("localhost".into()); backend.tls_server_name = Some("localhost".into());
backend.preserve_host = true; backend.preserve_host = true;
let client = client(&backend, AddressPolicy::new(&backend).unwrap(), false).unwrap(); let client = client(&backend, AddressPolicy::new(&backend).unwrap(), false).unwrap();
let request = Request::builder().version(Version::HTTP_11) let request = Request::builder()
.version(Version::HTTP_11)
.uri("https://unitraklub.pl/block_refresh/block/10") .uri("https://unitraklub.pl/block_refresh/block/10")
.header("host", "unitraklub.pl") .header("host", "unitraklub.pl")
.body(Body::empty()).unwrap(); .body(Body::empty())
.unwrap();
let response = tokio::time::timeout(Duration::from_secs(5), client.request(request)) let response = tokio::time::timeout(Duration::from_secs(5), client.request(request))
.await.unwrap().unwrap(); .await
.unwrap()
.unwrap();
assert_eq!(response.version(), Version::HTTP_2); assert_eq!(response.version(), Version::HTTP_2);
let (version, authority, host, sni) = seen.await.unwrap(); let (version, authority, host, sni) = seen.await.unwrap();
assert_eq!(version, Version::HTTP_2); assert_eq!(version, Version::HTTP_2);
@@ -352,15 +373,20 @@ async fn cleartext_h2c_preserves_public_authority_and_pins_socket() {
} }
}); });
let _ = hyper::server::conn::http2::Builder::new(TokioExecutor::new()) let _ = hyper::server::conn::http2::Builder::new(TokioExecutor::new())
.serve_connection(TokioIo::new(stream), service).await; .serve_connection(TokioIo::new(stream), service)
.await;
}); });
let backend = protocol_backend(address, "http", "h2"); let backend = protocol_backend(address, "http", "h2");
let client = client(&backend, AddressPolicy::new(&backend).unwrap(), false).unwrap(); let client = client(&backend, AddressPolicy::new(&backend).unwrap(), false).unwrap();
let request = Request::builder().version(Version::HTTP_2) let request = Request::builder()
.version(Version::HTTP_2)
.uri("http://unitraklub.pl/test") .uri("http://unitraklub.pl/test")
.body(Body::empty()).unwrap(); .body(Body::empty())
.unwrap();
let response = tokio::time::timeout(Duration::from_secs(5), client.request(request)) let response = tokio::time::timeout(Duration::from_secs(5), client.request(request))
.await.unwrap().unwrap(); .await
.unwrap()
.unwrap();
assert_eq!(response.version(), Version::HTTP_2); assert_eq!(response.version(), Version::HTTP_2);
assert_eq!(observed.await.unwrap().as_deref(), Some("unitraklub.pl")); assert_eq!(observed.await.unwrap().as_deref(), Some("unitraklub.pl"));
server.abort(); server.abort();
+45 -45
View File
@@ -1,53 +1,53 @@
use super::*; use super::*;
use http::HeaderValue; use http::HeaderValue;
fn headers(value: Option<&str>) -> HeaderMap { fn headers(value: Option<&str>) -> HeaderMap {
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
if let Some(value) = value { if let Some(value) = value {
headers.insert(USER_AGENT, HeaderValue::from_str(value).unwrap()); headers.insert(USER_AGENT, HeaderValue::from_str(value).unwrap());
}
headers
} }
headers
}
#[test] #[test]
fn plain_patterns_match_case_insensitive_substrings() { fn plain_patterns_match_case_insensitive_substrings() {
let policy = Policy { let policy = Policy {
enabled: true, enabled: true,
patterns: vec!["BadBot".into()], patterns: vec!["BadBot".into()],
}
.compile()
.unwrap();
assert!(policy.blocks(&headers(Some("Mozilla/5.0 badbot/2.1"))));
assert!(!policy.blocks(&headers(Some("Mozilla/5.0"))));
} }
.compile()
.unwrap();
assert!(policy.blocks(&headers(Some("Mozilla/5.0 badbot/2.1"))));
assert!(!policy.blocks(&headers(Some("Mozilla/5.0"))));
}
#[test] #[test]
fn wildcard_patterns_can_anchor_or_span() { fn wildcard_patterns_can_anchor_or_span() {
let policy = Policy { let policy = Policy {
enabled: true, enabled: true,
patterns: vec!["curl/*".into(), "*scanner*".into()], patterns: vec!["curl/*".into(), "*scanner*".into()],
}
.compile()
.unwrap();
assert!(policy.blocks(&headers(Some("curl/8.4.0"))));
assert!(policy.blocks(&headers(Some("AcmeScanner/1"))));
assert!(!policy.blocks(&headers(Some("prefix curl/8.4.0"))));
} }
.compile()
.unwrap();
assert!(policy.blocks(&headers(Some("curl/8.4.0"))));
assert!(policy.blocks(&headers(Some("AcmeScanner/1"))));
assert!(!policy.blocks(&headers(Some("prefix curl/8.4.0"))));
}
#[test] #[test]
fn disabled_or_missing_user_agent_is_allowed() { fn disabled_or_missing_user_agent_is_allowed() {
let disabled = Policy { let disabled = Policy {
enabled: false, enabled: false,
patterns: vec!["*".into()], patterns: vec!["*".into()],
}
.compile()
.unwrap();
assert!(!disabled.blocks(&headers(Some("anything"))));
let enabled = Policy {
enabled: true,
patterns: vec!["*".into()],
}
.compile()
.unwrap();
assert!(!enabled.blocks(&headers(None)));
} }
.compile()
.unwrap();
assert!(!disabled.blocks(&headers(Some("anything"))));
let enabled = Policy {
enabled: true,
patterns: vec!["*".into()],
}
.compile()
.unwrap();
assert!(!enabled.blocks(&headers(None)));
}
+4 -2
View File
@@ -24,7 +24,10 @@ impl Default for ErrorFallback {
impl ErrorFallback { impl ErrorFallback {
pub fn validate(&mut self) -> Result<()> { pub fn validate(&mut self) -> Result<()> {
self.target = self.target.trim().to_owned(); self.target = self.target.trim().to_owned();
ensure!(crate::config::valid_id(&self.target), "invalid error fallback target"); ensure!(
crate::config::valid_id(&self.target),
"invalid error fallback target"
);
ensure!( ensure!(
!self.statuses.is_empty() && self.statuses.len() <= ERROR_FALLBACK_STATUSES.len(), !self.statuses.is_empty() && self.statuses.len() <= ERROR_FALLBACK_STATUSES.len(),
"error fallback requires 1..={} status codes", "error fallback requires 1..={} status codes",
@@ -45,4 +48,3 @@ impl ErrorFallback {
self.statuses.contains(&status.as_u16()) self.statuses.contains(&status.as_u16())
} }
} }
-2
View File
@@ -26,5 +26,3 @@ impl Default for LocationRedirect {
} }
} }
} }
+41 -13
View File
@@ -67,7 +67,10 @@ impl Vhost {
!self.websocket_rfc9220 || self.http3_enabled, !self.websocket_rfc9220 || self.http3_enabled,
"RFC 9220 requires HTTP/3 to be enabled for the vhost" "RFC 9220 requires HTTP/3 to be enabled for the vhost"
); );
ensure!(!self.frontend.hosts.is_empty(), "vhost requires at least one hostname"); ensure!(
!self.frontend.hosts.is_empty(),
"vhost requires at least one hostname"
);
let mut hosts = std::collections::HashSet::new(); let mut hosts = std::collections::HashSet::new();
for host in &mut self.frontend.hosts { for host in &mut self.frontend.hosts {
*host = crate::config::normalize_host_pattern(host)?; *host = crate::config::normalize_host_pattern(host)?;
@@ -155,7 +158,10 @@ impl Vhost {
"location cannot define both a local response and a redirect" "location cannot define both a local response and a redirect"
); );
if let Some(id) = &route.acl_profile_id { if let Some(id) = &route.acl_profile_id {
ensure!(crate::config::valid_id(id), "invalid location ACL profile id"); ensure!(
crate::config::valid_id(id),
"invalid location ACL profile id"
);
} }
route.country_access.validate()?; route.country_access.validate()?;
route.method_policy.validate()?; route.method_policy.validate()?;
@@ -191,13 +197,21 @@ impl Vhost {
} }
} }
let mut starts = vec![None]; let mut starts = vec![None];
starts.extend(self.routes.iter().filter(|route| route.enabled).map(|route| Some(route.id.as_str()))); starts.extend(
self.routes
.iter()
.filter(|route| route.enabled)
.map(|route| Some(route.id.as_str())),
);
for start in starts { for start in starts {
let mut seen = std::collections::HashSet::new(); let mut seen = std::collections::HashSet::new();
let mut current = start; let mut current = start;
loop { loop {
let key = current.unwrap_or("<main>"); let key = current.unwrap_or("<main>");
ensure!(seen.insert(key.to_owned()), "recursive error fallback loop detected"); ensure!(
seen.insert(key.to_owned()),
"recursive error fallback loop detected"
);
let fallback = match current { let fallback = match current {
None => self.error_fallback.as_ref(), None => self.error_fallback.as_ref(),
Some(id) => self Some(id) => self
@@ -215,7 +229,9 @@ impl Vhost {
if let Some(custom) = &member.health_check { if let Some(custom) = &member.health_check {
let mut settings = self.load_balancing.clone(); let mut settings = self.load_balancing.clone();
settings.health_check = custom.clone(); settings.health_check = custom.clone();
settings.validate().with_context(|| format!("backend {} health check", member.id))?; settings
.validate()
.with_context(|| format!("backend {} health check", member.id))?;
} }
} }
self.proxy_timeouts.validate()?; self.proxy_timeouts.validate()?;
@@ -233,7 +249,8 @@ impl Vhost {
"choose either Tinyauth or Basic authentication" "choose either Tinyauth or Basic authentication"
); );
self.pages.validate()?; self.pages.validate()?;
self.transform.resolve(&crate::transform::Settings::default())?; self.transform
.resolve(&crate::transform::Settings::default())?;
self.security_headers.compile()?; self.security_headers.compile()?;
self.user_agent_blocking.compile()?; self.user_agent_blocking.compile()?;
self.headers.compile()?; self.headers.compile()?;
@@ -306,8 +323,10 @@ pub fn validate_listener(listener: Listener) -> Result<()> {
} }
fn www_partner(host: &str) -> Result<String> { fn www_partner(host: &str) -> Result<String> {
if let Some(bare) = host.strip_prefix("www.") { if let Some(bare) = host.strip_prefix("www.") {
ensure!(!bare.is_empty() && bare.parse::<std::net::IpAddr>().is_err(), ensure!(
"www redirect requires a DNS hostname"); !bare.is_empty() && bare.parse::<std::net::IpAddr>().is_err(),
"www redirect requires a DNS hostname"
);
crate::config::normalize_host(bare) crate::config::normalize_host(bare)
} else { } else {
crate::config::normalize_host(&format!("www.{host}")) crate::config::normalize_host(&format!("www.{host}"))
@@ -315,14 +334,23 @@ fn www_partner(host: &str) -> Result<String> {
} }
impl Vhost { impl Vhost {
fn www_canonical_host(&self) -> Result<&str> { fn www_canonical_host(&self) -> Result<&str> {
let canonical = self.www_redirect_target.as_deref() let canonical = self
.www_redirect_target
.as_deref()
.or_else(|| self.frontend.hosts.first().map(String::as_str)) .or_else(|| self.frontend.hosts.first().map(String::as_str))
.ok_or_else(|| anyhow::anyhow!("vhost requires at least one hostname"))?; .ok_or_else(|| anyhow::anyhow!("vhost requires at least one hostname"))?;
ensure!(!canonical.starts_with("*.") && canonical.parse::<std::net::IpAddr>().is_err(), ensure!(
"www redirect requires an exact DNS hostname"); !canonical.starts_with("*.") && canonical.parse::<std::net::IpAddr>().is_err(),
"www redirect requires an exact DNS hostname"
);
let partner = www_partner(canonical)?; let partner = www_partner(canonical)?;
ensure!(self.frontend.hosts.iter().any(|host| host == canonical || host == &partner), ensure!(
"www redirect target or its www/non-www partner must belong to this vhost"); 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) Ok(canonical)
} }
+2 -2
View File
@@ -1,7 +1,7 @@
use super::htaccess;
use super::htaccess::Config as HtaccessConfig;
#[cfg(unix)] #[cfg(unix)]
use super::filesystem::open_root_directory; use super::filesystem::open_root_directory;
use super::htaccess;
use super::htaccess::Config as HtaccessConfig;
use anyhow::{ensure, Context, Result}; use anyhow::{ensure, Context, Result};
use http::{HeaderName, HeaderValue, StatusCode}; use http::{HeaderName, HeaderValue, StatusCode};
use regex::Regex; use regex::Regex;
+51 -19
View File
@@ -11,9 +11,26 @@ pub(super) async fn php_response(
context: RequestContext, context: RequestContext,
) -> Response { ) -> Response {
let start = Instant::now(); let start = Instant::now();
let mut response = php_response_inner(service, php, method, headers, original_uri, script_path, path_info, query, body, context).await; let mut response = php_response_inner(
php.metrics.record(response.status(), start.elapsed().as_micros().min(u64::MAX as u128) as u64); service,
response.extensions_mut().insert(crate::web_server::metrics::ResponseKind::Php); php,
method,
headers,
original_uri,
script_path,
path_info,
query,
body,
context,
)
.await;
php.metrics.record(
response.status(),
start.elapsed().as_micros().min(u64::MAX as u128) as u64,
);
response
.extensions_mut()
.insert(crate::web_server::metrics::ResponseKind::Php);
response response
} }
@@ -51,15 +68,19 @@ async fn php_response_inner(
return response; return response;
} }
}; };
let mut io = match tokio::time::timeout(php.connect_timeout, connect_fastcgi(&php.endpoint)).await { let mut io =
Ok(Ok(io)) => io, match tokio::time::timeout(php.connect_timeout, connect_fastcgi(&php.endpoint)).await {
Ok(Err(error)) => { Ok(Ok(io)) => io,
php.metrics.failure(2); Ok(Err(error)) => {
tracing::warn!(%error, service = %service.id, "PHP-FPM connection failed"); php.metrics.failure(2);
return builtin_error_response(StatusCode::BAD_GATEWAY, &method); tracing::warn!(%error, service = %service.id, "PHP-FPM connection failed");
} return builtin_error_response(StatusCode::BAD_GATEWAY, &method);
Err(_) => { php.metrics.failure(1); return builtin_error_response(StatusCode::GATEWAY_TIMEOUT, &method); }, }
}; Err(_) => {
php.metrics.failure(1);
return builtin_error_response(StatusCode::GATEWAY_TIMEOUT, &method);
}
};
let params = match fastcgi_params( let params = match fastcgi_params(
service, service,
php, php,
@@ -91,8 +112,9 @@ async fn php_response_inner(
let mut body = body; let mut body = body;
let mut body_bytes = 0u64; let mut body_bytes = 0u64;
while let Some(frame) = body.frame().await { while let Some(frame) = body.frame().await {
let frame = frame let frame = frame.map_err(|error| {
.map_err(|error| FastCgiRequestWriteError::Io(io::Error::other(error.to_string())))?; FastCgiRequestWriteError::Io(io::Error::other(error.to_string()))
})?;
if let Ok(data) = frame.into_data() { if let Ok(data) = frame.into_data() {
body_bytes = body_bytes.saturating_add(data.len() as u64); body_bytes = body_bytes.saturating_add(data.len() as u64);
if php.max_request_body_bytes > 0 && body_bytes > php.max_request_body_bytes { if php.max_request_body_bytes > 0 && body_bytes > php.max_request_body_bytes {
@@ -117,7 +139,10 @@ async fn php_response_inner(
tracing::warn!(%error, service = %service.id, "PHP-FPM request write failed"); tracing::warn!(%error, service = %service.id, "PHP-FPM request write failed");
return builtin_error_response(StatusCode::BAD_GATEWAY, &method); return builtin_error_response(StatusCode::BAD_GATEWAY, &method);
} }
Err(_) => { php.metrics.failure(3); return builtin_error_response(StatusCode::GATEWAY_TIMEOUT, &method); }, Err(_) => {
php.metrics.failure(3);
return builtin_error_response(StatusCode::GATEWAY_TIMEOUT, &method);
}
} }
let mut stdout = Vec::new(); let mut stdout = Vec::new();
@@ -128,7 +153,7 @@ async fn php_response_inner(
Ok(value) => value, Ok(value) => value,
Err(error) if error.kind() == io::ErrorKind::TimedOut => { Err(error) if error.kind() == io::ErrorKind::TimedOut => {
php.metrics.failure(5); php.metrics.failure(5);
return builtin_error_response(StatusCode::GATEWAY_TIMEOUT, &method) return builtin_error_response(StatusCode::GATEWAY_TIMEOUT, &method);
} }
Err(error) => { Err(error) => {
tracing::warn!(%error, service = %service.id, "PHP-FPM response read failed"); tracing::warn!(%error, service = %service.id, "PHP-FPM response read failed");
@@ -218,17 +243,24 @@ pub(super) fn method_not_allowed_response() -> Response {
response response
} }
pub(super) fn php_for_path<'a>(service: &'a CompiledService, path: &Path) -> Option<&'a CompiledPhp> { pub(super) fn php_for_path<'a>(
service: &'a CompiledService,
path: &Path,
) -> Option<&'a CompiledPhp> {
let php = service.php.as_ref()?; let php = service.php.as_ref()?;
let extension = path let extension = path
.extension() .extension()
.and_then(|value| value.to_str())? .and_then(|value| value.to_str())?
.to_ascii_lowercase(); .to_ascii_lowercase();
(php.extensions.contains(&extension) || htaccess::php_extension(service.htaccess.as_ref(), path)).then_some(php) (php.extensions.contains(&extension)
|| htaccess::php_extension(service.htaccess.as_ref(), path))
.then_some(php)
} }
pub(super) fn protected_script_without_handler(service: &CompiledService, path: &Path) -> bool { pub(super) fn protected_script_without_handler(service: &CompiledService, path: &Path) -> bool {
if service.php.is_some() { return false; } if service.php.is_some() {
return false;
}
path.extension() path.extension()
.and_then(|value| value.to_str()) .and_then(|value| value.to_str())
.map(|value| value.to_ascii_lowercase()) .map(|value| value.to_ascii_lowercase())
+30 -10
View File
@@ -1,11 +1,11 @@
use super::{CompiledService, SymlinkPolicy}; use super::{CompiledService, SymlinkPolicy};
#[cfg(unix)]
use std::{ffi::CString, sync::Arc};
use std::{ use std::{
fs::File, fs::File,
io, io,
path::{Path, PathBuf}, path::{Path, PathBuf},
}; };
#[cfg(unix)]
use std::{ffi::CString, sync::Arc};
pub(super) struct FilesystemRoot { pub(super) struct FilesystemRoot {
root: PathBuf, root: PathBuf,
@@ -63,12 +63,18 @@ pub(super) fn open_root_directory(path: &Path) -> io::Result<File> {
pub(super) fn path_components(path: &str) -> io::Result<Vec<String>> { pub(super) fn path_components(path: &str) -> io::Result<Vec<String>> {
if !path.starts_with('/') { if !path.starts_with('/') {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "path is not absolute")); return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"path is not absolute",
));
} }
let mut result = Vec::new(); let mut result = Vec::new();
for part in path.split('/').filter(|part| !part.is_empty()) { for part in path.split('/').filter(|part| !part.is_empty()) {
if part == "." || part == ".." || part.contains('\\') || part.contains('\0') { if part == "." || part == ".." || part.contains('\\') || part.contains('\0') {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "ambiguous path")); return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"ambiguous path",
));
} }
result.push(part.to_owned()); result.push(part.to_owned());
} }
@@ -93,7 +99,10 @@ pub(super) fn secure_open_sync(root: &FilesystemRoot, components: &[String]) ->
} }
let current = unsafe { File::from_raw_fd(fd) }; let current = unsafe { File::from_raw_fd(fd) };
let metadata = current.metadata()?; let metadata = current.metadata()?;
return Ok(Opened { file: current, metadata }); return Ok(Opened {
file: current,
metadata,
});
} }
let mut current: Option<File> = None; let mut current: Option<File> = None;
for (index, component) in components.iter().enumerate() { for (index, component) in components.iter().enumerate() {
@@ -135,7 +144,10 @@ pub(super) fn secure_open_sync(root: &FilesystemRoot, components: &[String]) ->
"Web Server serves only regular files and directories", "Web Server serves only regular files and directories",
)); ));
} }
Ok(Opened { file: current, metadata }) Ok(Opened {
file: current,
metadata,
})
} }
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
@@ -192,7 +204,10 @@ pub(super) fn list_directory_sync(
let opened = secure_open_sync(root, components)?; let opened = secure_open_sync(root, components)?;
if !opened.metadata.is_dir() { if !opened.metadata.is_dir() {
return Err(io::Error::new(io::ErrorKind::NotADirectory, "not a directory")); return Err(io::Error::new(
io::ErrorKind::NotADirectory,
"not a directory",
));
} }
let raw = opened.file.try_clone()?.into_raw_fd(); let raw = opened.file.try_clone()?.into_raw_fd();
let dir = unsafe { libc::fdopendir(raw) }; let dir = unsafe { libc::fdopendir(raw) };
@@ -241,7 +256,10 @@ pub(super) fn list_directory_sync(
) -> io::Result<Vec<DirectoryEntry>> { ) -> io::Result<Vec<DirectoryEntry>> {
let opened = secure_open_sync(root, components)?; let opened = secure_open_sync(root, components)?;
if !opened.metadata.is_dir() { if !opened.metadata.is_dir() {
return Err(io::Error::new(io::ErrorKind::NotADirectory, "not a directory")); return Err(io::Error::new(
io::ErrorKind::NotADirectory,
"not a directory",
));
} }
let mut path = root.root.clone(); let mut path = root.root.clone();
for component in components { for component in components {
@@ -249,7 +267,10 @@ pub(super) fn list_directory_sync(
} }
let canonical = std::fs::canonicalize(path)?; let canonical = std::fs::canonicalize(path)?;
if !canonical.starts_with(&root.root) { if !canonical.starts_with(&root.root) {
return Err(io::Error::new(io::ErrorKind::PermissionDenied, "path escaped document root")); return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"path escaped document root",
));
} }
let mut entries = Vec::new(); let mut entries = Vec::new();
for entry in std::fs::read_dir(canonical)? { for entry in std::fs::read_dir(canonical)? {
@@ -275,4 +296,3 @@ pub(super) fn list_directory_sync(
}); });
Ok(entries) Ok(entries)
} }
+127 -31
View File
@@ -1,7 +1,5 @@
use super::{ use super::{
config::CompiledService, config::CompiledService, filesystem::path_components, request::RequestContext,
filesystem::path_components,
request::RequestContext,
routing::open_components, routing::open_components,
}; };
use anyhow::{bail, ensure, Context, Result}; use anyhow::{bail, ensure, Context, Result};
@@ -119,9 +117,19 @@ struct RewriteCondition {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
enum ConditionMatcher { enum ConditionMatcher {
Regex { regex: Regex, negate: bool }, Regex {
Exact { value: String, negate: bool, insensitive: bool }, regex: Regex,
File { kind: FileTest, negate: bool }, negate: bool,
},
Exact {
value: String,
negate: bool,
insensitive: bool,
},
File {
kind: FileTest,
negate: bool,
},
} }
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
@@ -198,7 +206,13 @@ fn tokens(value: &str) -> Result<Vec<String>> {
fn flags(value: Option<&str>) -> Vec<String> { fn flags(value: Option<&str>) -> Vec<String> {
value value
.and_then(|value| value.strip_prefix('[').and_then(|v| v.strip_suffix(']'))) .and_then(|value| value.strip_prefix('[').and_then(|v| v.strip_suffix(']')))
.map(|value| value.split(',').map(|v| v.trim().to_owned()).filter(|v| !v.is_empty()).collect()) .map(|value| {
value
.split(',')
.map(|v| v.trim().to_owned())
.filter(|v| !v.is_empty())
.collect()
})
.unwrap_or_default() .unwrap_or_default()
} }
@@ -227,16 +241,26 @@ fn supported_response_header(name: &HeaderName) -> bool {
fn parse_duration(value: &str) -> Result<Duration> { fn parse_duration(value: &str) -> Result<Duration> {
let value = value.trim().trim_matches('"').trim(); let value = value.trim().trim_matches('"').trim();
if let Some(seconds) = value.strip_prefix('A').or_else(|| value.strip_prefix('a')) if let Some(seconds) = value
.strip_prefix('A')
.or_else(|| value.strip_prefix('a'))
.filter(|seconds| !seconds.is_empty() && seconds.bytes().all(|byte| byte.is_ascii_digit())) .filter(|seconds| !seconds.is_empty() && seconds.bytes().all(|byte| byte.is_ascii_digit()))
{ {
let seconds: u64 = seconds.parse().context("invalid Expires duration")?; let seconds: u64 = seconds.parse().context("invalid Expires duration")?;
return Ok(Duration::from_secs(seconds)); return Ok(Duration::from_secs(seconds));
} }
let parts = value.split_whitespace().collect::<Vec<_>>(); let parts = value.split_whitespace().collect::<Vec<_>>();
ensure!(parts.len() >= 4, "Expires value must look like 'access plus 1 day' or A3600"); ensure!(
ensure!(parts[0].eq_ignore_ascii_case("access") && parts[1].eq_ignore_ascii_case("plus"), "only access-based Expires values are supported"); parts.len() >= 4,
let count: u64 = parts[2].parse().context("invalid Expires duration number")?; "Expires value must look like 'access plus 1 day' or A3600"
);
ensure!(
parts[0].eq_ignore_ascii_case("access") && parts[1].eq_ignore_ascii_case("plus"),
"only access-based Expires values are supported"
);
let count: u64 = parts[2]
.parse()
.context("invalid Expires duration number")?;
let unit = parts[3].to_ascii_lowercase(); let unit = parts[3].to_ascii_lowercase();
let seconds = match unit.trim_end_matches('s') { let seconds = match unit.trim_end_matches('s') {
"second" => count, "second" => count,
@@ -252,32 +276,73 @@ fn parse_duration(value: &str) -> Result<Duration> {
} }
fn parse_condition(parts: &[String]) -> Result<RewriteCondition> { fn parse_condition(parts: &[String]) -> Result<RewriteCondition> {
ensure!(parts.len() >= 3, "RewriteCond needs a test string and pattern"); ensure!(
parts.len() >= 3,
"RewriteCond needs a test string and pattern"
);
let condition_flags = flags(parts.get(3).map(String::as_str)); let condition_flags = flags(parts.get(3).map(String::as_str));
let insensitive = condition_flags.iter().any(|flag| flag.eq_ignore_ascii_case("NC")); let insensitive = condition_flags
let or_next = condition_flags.iter().any(|flag| flag.eq_ignore_ascii_case("OR")); .iter()
.any(|flag| flag.eq_ignore_ascii_case("NC"));
let or_next = condition_flags
.iter()
.any(|flag| flag.eq_ignore_ascii_case("OR"));
for flag in &condition_flags { for flag in &condition_flags {
ensure!(flag.eq_ignore_ascii_case("NC") || flag.eq_ignore_ascii_case("OR"), "unsupported RewriteCond flag [{flag}]"); ensure!(
flag.eq_ignore_ascii_case("NC") || flag.eq_ignore_ascii_case("OR"),
"unsupported RewriteCond flag [{flag}]"
);
} }
let raw = parts[2].as_str(); let raw = parts[2].as_str();
let matcher = if let Some(value) = raw.strip_prefix("!=") { let matcher = if let Some(value) = raw.strip_prefix("!=") {
ConditionMatcher::Exact { value: value.to_owned(), negate: true, insensitive } ConditionMatcher::Exact {
value: value.to_owned(),
negate: true,
insensitive,
}
} else if let Some(value) = raw.strip_prefix('=') { } else if let Some(value) = raw.strip_prefix('=') {
ConditionMatcher::Exact { value: value.to_owned(), negate: false, insensitive } ConditionMatcher::Exact {
value: value.to_owned(),
negate: false,
insensitive,
}
} else { } else {
let (negate, raw) = raw.strip_prefix('!').map_or((false, raw), |v| (true, v)); let (negate, raw) = raw.strip_prefix('!').map_or((false, raw), |v| (true, v));
match raw { match raw {
"-f" => ConditionMatcher::File { kind: FileTest::File, negate }, "-f" => ConditionMatcher::File {
"-d" => ConditionMatcher::File { kind: FileTest::Directory, negate }, kind: FileTest::File,
"-s" => ConditionMatcher::File { kind: FileTest::NonEmptyFile, negate }, negate,
_ => ConditionMatcher::Regex { regex: ci_regex(raw, insensitive)?, negate }, },
"-d" => ConditionMatcher::File {
kind: FileTest::Directory,
negate,
},
"-s" => ConditionMatcher::File {
kind: FileTest::NonEmptyFile,
negate,
},
_ => ConditionMatcher::Regex {
regex: ci_regex(raw, insensitive)?,
negate,
},
} }
}; };
Ok(RewriteCondition { input: parts[1].clone(), matcher, or_next }) Ok(RewriteCondition {
input: parts[1].clone(),
matcher,
or_next,
})
} }
fn parse_rewrite_rule(parts: &[String], conditions: Vec<RewriteCondition>, state: &mut ParseState) -> Result<RewriteRule> { fn parse_rewrite_rule(
ensure!(parts.len() >= 3, "RewriteRule needs a pattern and substitution"); parts: &[String],
conditions: Vec<RewriteCondition>,
state: &mut ParseState,
) -> Result<RewriteRule> {
ensure!(
parts.len() >= 3,
"RewriteRule needs a pattern and substitution"
);
let rule_flags = flags(parts.get(3).map(String::as_str)); let rule_flags = flags(parts.get(3).map(String::as_str));
let mut redirect = None; let mut redirect = None;
let mut forbidden = false; let mut forbidden = false;
@@ -319,15 +384,30 @@ fn parse_rewrite_rule(parts: &[String], conditions: Vec<RewriteCondition>, state
fn normalize_extension(value: &str) -> Result<String> { fn normalize_extension(value: &str) -> Result<String> {
let value = value.trim().trim_start_matches('.').to_ascii_lowercase(); let value = value.trim().trim_start_matches('.').to_ascii_lowercase();
ensure!(!value.is_empty() && value.len() <= 32 && value.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_')), "invalid extension in .htaccess"); ensure!(
!value.is_empty()
&& value.len() <= 32
&& value
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_')),
"invalid extension in .htaccess"
);
Ok(value) Ok(value)
} }
fn unsupported(state: &mut ParseState, config: &Config, file: &Path, line: usize, message: String) -> Result<()> { fn unsupported(
state: &mut ParseState,
config: &Config,
file: &Path,
line: usize,
message: String,
) -> Result<()> {
if config.strict { if config.strict {
bail!("{}:{line}: {message}", file.display()); bail!("{}:{line}: {message}", file.display());
} }
state.unsupported.push(format!("{}:{line}: {message}", file.display())); state
.unsupported
.push(format!("{}:{line}: {message}", file.display()));
Ok(()) Ok(())
} }
@@ -344,17 +424,33 @@ fn read_htaccess_nofollow(path: &Path) -> Result<String> {
#[cfg(not(unix))] #[cfg(not(unix))]
let mut file = { let mut file = {
let metadata = fs::symlink_metadata(path)?; let metadata = fs::symlink_metadata(path)?;
ensure!(!metadata.file_type().is_symlink(), "{} must not be a symlink", path.display()); ensure!(
!metadata.file_type().is_symlink(),
"{} must not be a symlink",
path.display()
);
fs::File::open(path)? fs::File::open(path)?
}; };
let metadata = file.metadata()?; let metadata = file.metadata()?;
ensure!(metadata.is_file(), "{} is not a regular file", path.display()); ensure!(
ensure!(metadata.len() <= MAX_HTACCESS_FILE_BYTES, "{} exceeds the 128 KiB .htaccess limit", path.display()); metadata.is_file(),
"{} is not a regular file",
path.display()
);
ensure!(
metadata.len() <= MAX_HTACCESS_FILE_BYTES,
"{} exceeds the 128 KiB .htaccess limit",
path.display()
);
let mut content = String::new(); let mut content = String::new();
(&mut file) (&mut file)
.take(MAX_HTACCESS_FILE_BYTES + 1) .take(MAX_HTACCESS_FILE_BYTES + 1)
.read_to_string(&mut content) .read_to_string(&mut content)
.with_context(|| format!("{} is not valid UTF-8", path.display()))?; .with_context(|| format!("{} is not valid UTF-8", path.display()))?;
ensure!(content.len() as u64 <= MAX_HTACCESS_FILE_BYTES, "{} exceeds the 128 KiB .htaccess limit", path.display()); ensure!(
content.len() as u64 <= MAX_HTACCESS_FILE_BYTES,
"{} exceeds the 128 KiB .htaccess limit",
path.display()
);
Ok(content) Ok(content)
} }
+92 -20
View File
@@ -1,5 +1,19 @@
pub async fn apply_rewrites(compiled: Option<&Compiled>, service: &CompiledService, method: &Method, headers: &HeaderMap, uri: &Uri, context: &RequestContext, initial_path: &str, initial_query: Option<&str>) -> Result<RewriteOutcome> { pub async fn apply_rewrites(
let Some(compiled) = compiled else { return Ok(RewriteOutcome::Internal { path: initial_path.to_owned(), query: initial_query.map(str::to_owned) }); }; compiled: Option<&Compiled>,
service: &CompiledService,
method: &Method,
headers: &HeaderMap,
uri: &Uri,
context: &RequestContext,
initial_path: &str,
initial_query: Option<&str>,
) -> Result<RewriteOutcome> {
let Some(compiled) = compiled else {
return Ok(RewriteOutcome::Internal {
path: initial_path.to_owned(),
query: initial_query.map(str::to_owned),
});
};
let mut path = initial_path.to_owned(); let mut path = initial_path.to_owned();
let mut query = initial_query.map(str::to_owned); let mut query = initial_query.map(str::to_owned);
let mut exhausted = false; let mut exhausted = false;
@@ -10,37 +24,95 @@ pub async fn apply_rewrites(compiled: Option<&Compiled>, service: &CompiledServi
'scope: for scope in scopes { 'scope: for scope in scopes {
for rule in &scope.rewrites { for rule in &scope.rewrites {
let relative = scope_relative_path(scope, &path); let relative = scope_relative_path(scope, &path);
if !rule.pattern.is_match(&relative) { continue; } if !rule.pattern.is_match(&relative) {
let (conditions_ok, condition_captures) = conditions_match(rule, service, method, headers, &path, query.as_deref(), uri, context).await; continue;
if !conditions_ok { continue; } }
let Some(captures) = rule.pattern.captures(&relative) else { continue; }; let (conditions_ok, condition_captures) = conditions_match(
if rule.forbidden { return Ok(RewriteOutcome::Forbidden); } rule,
service,
method,
headers,
&path,
query.as_deref(),
uri,
context,
)
.await;
if !conditions_ok {
continue;
}
let Some(captures) = rule.pattern.captures(&relative) else {
continue;
};
if rule.forbidden {
return Ok(RewriteOutcome::Forbidden);
}
let replacement = replacement_with_captures(rule, &captures, &condition_captures); let replacement = replacement_with_captures(rule, &captures, &condition_captures);
if replacement != "-" { if replacement != "-" {
let (replacement_path, replacement_query) = if let Some((target, query_part)) = replacement.split_once('?') { (target, Some(query_part.to_owned())) } else { (replacement.as_str(), None) }; let (replacement_path, replacement_query) =
let target = if rule.redirect.is_some() && (replacement_path.starts_with("http://") || replacement_path.starts_with("https://")) { replacement_path.to_owned() } else { scoped_target(scope, replacement_path) }; if let Some((target, query_part)) = replacement.split_once('?') {
(target, Some(query_part.to_owned()))
} else {
(replacement.as_str(), None)
};
let target = if rule.redirect.is_some()
&& (replacement_path.starts_with("http://")
|| replacement_path.starts_with("https://"))
{
replacement_path.to_owned()
} else {
scoped_target(scope, replacement_path)
};
let next_query = if rule.qsa { let next_query = if rule.qsa {
match (replacement_query, query.clone()) { match (replacement_query, query.clone()) {
(Some(new), Some(old)) if !new.is_empty() && !old.is_empty() => Some(format!("{new}&{old}")), (Some(new), Some(old)) if !new.is_empty() && !old.is_empty() => {
Some(format!("{new}&{old}"))
}
(Some(new), _) => Some(new), (Some(new), _) => Some(new),
(None, old) => old, (None, old) => old,
} }
} else { replacement_query.or_else(|| query.clone()) }; } else {
replacement_query.or_else(|| query.clone())
};
if let Some(status) = rule.redirect { if let Some(status) = rule.redirect {
let mut location = target; let mut location = target;
if let Some(query) = next_query.filter(|q| !q.is_empty()) { location.push('?'); location.push_str(&query); } if let Some(query) = next_query.filter(|q| !q.is_empty()) {
return Ok(RewriteOutcome::Redirect(redirect_response(status, &location)?)); location.push('?');
location.push_str(&query);
}
return Ok(RewriteOutcome::Redirect(redirect_response(
status, &location,
)?));
}
ensure!(
target.starts_with('/'),
".htaccess internal rewrite must stay on an absolute local path"
);
if target != path || next_query != query {
changed = true;
path = target;
query = next_query;
} }
ensure!(target.starts_with('/'), ".htaccess internal rewrite must stay on an absolute local path");
if target != path || next_query != query { changed = true; path = target; query = next_query; }
} }
if rule.end { end = true; break 'scope; } if rule.end {
if rule.last { break 'scope; } end = true;
break 'scope;
}
if rule.last {
break 'scope;
}
} }
} }
if end || !changed { break; } if end || !changed {
if pass == 7 { exhausted = true; } break;
}
if pass == 7 {
exhausted = true;
}
} }
ensure!(!exhausted, ".htaccess rewrite exceeded the 8-pass safety limit"); ensure!(
!exhausted,
".htaccess rewrite exceeded the 8-pass safety limit"
);
Ok(RewriteOutcome::Internal { path, query }) Ok(RewriteOutcome::Internal { path, query })
} }
+4 -4
View File
@@ -28,15 +28,15 @@ pub(crate) struct PhpMetrics {
pub(crate) status_4xx: AtomicU64, pub(crate) status_4xx: AtomicU64,
pub(crate) status_5xx: AtomicU64, pub(crate) status_5xx: AtomicU64,
pub(crate) response_headers_us: AtomicU64, pub(crate) response_headers_us: AtomicU64,
pub(crate) response_bytes: AtomicU64, pub(crate) response_bytes: AtomicU64,
pub(crate) in_flight_headers: AtomicU64, pub(crate) in_flight_headers: AtomicU64,
pub(crate) static_requests: AtomicU64, pub(crate) static_requests: AtomicU64,
pub(crate) php_requests: AtomicU64, pub(crate) php_requests: AtomicU64,
pub(crate) other_requests: AtomicU64, pub(crate) other_requests: AtomicU64,
pub(crate) methods: [AtomicU64; 5], pub(crate) methods: [AtomicU64; 5],
pub(crate) selected_status: [AtomicU64; 8], pub(crate) selected_status: [AtomicU64; 8],
pub(crate) failures: [AtomicU64; 9], pub(crate) failures: [AtomicU64; 9],
pub(crate) compression: [AtomicU64; 4], pub(crate) compression: [AtomicU64; 4],
} }
pub(crate) struct HeadersInFlight<'a>(&'a AtomicU64); pub(crate) struct HeadersInFlight<'a>(&'a AtomicU64);
-1
View File
@@ -10,7 +10,6 @@ pub struct RequestContext {
pub version: Version, pub version: Version,
} }
pub(super) fn server_protocol(version: http::Version) -> &'static str { pub(super) fn server_protocol(version: http::Version) -> &'static str {
match version { match version {
http::Version::HTTP_09 => "HTTP/0.9", http::Version::HTTP_09 => "HTTP/0.9",
+16 -6
View File
@@ -161,13 +161,22 @@ async fn file_response_inner(
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
let sendfile_candidate = if service.sendfile == super::SendfileMode::Auto let sendfile_candidate = if service.sendfile == super::SendfileMode::Auto
&& !multipart && single_range.is_none() && content_encoding.is_none() && !multipart
&& single_range.is_none()
&& content_encoding.is_none()
&& body_length >= 64 * 1024 && body_length >= 64 * 1024
{ {
opened.file.try_clone().ok().map(|file| crate::server::sendfile::SendfileCandidate { opened
file: std::sync::Arc::new(file), length: body_length, .file
}) .try_clone()
} else { None }; .ok()
.map(|file| crate::server::sendfile::SendfileCandidate {
file: std::sync::Arc::new(file),
length: body_length,
})
} else {
None
};
let mut file = tokio::fs::File::from_std(opened.file); let mut file = tokio::fs::File::from_std(opened.file);
if let Some(boundary) = boundary { if let Some(boundary) = boundary {
let ranges = ranges.expect("multipart ranges"); let ranges = ranges.expect("multipart ranges");
@@ -203,7 +212,8 @@ async fn file_response_inner(
.await; .await;
}); });
*response.body_mut() = Body::new(file_body::FileBody::new( *response.body_mut() = Body::new(file_body::FileBody::new(
ReaderStream::with_capacity(reader, stream_capacity), response_length, ReaderStream::with_capacity(reader, stream_capacity),
response_length,
)); ));
return response; return response;
} }
+22 -7
View File
@@ -1,7 +1,11 @@
use bytes::Bytes; use bytes::Bytes;
use futures_util::Stream; use futures_util::Stream;
use http_body::{Body, Frame, SizeHint}; use http_body::{Body, Frame, SizeHint};
use std::{io, pin::Pin, task::{Context, Poll}}; use std::{
io,
pin::Pin,
task::{Context, Poll},
};
pub(super) struct FileBody<S> { pub(super) struct FileBody<S> {
stream: Option<S>, stream: Option<S>,
@@ -10,7 +14,10 @@ pub(super) struct FileBody<S> {
impl<S> FileBody<S> { impl<S> FileBody<S> {
pub(super) fn new(stream: S, length: u64) -> Self { pub(super) fn new(stream: S, length: u64) -> Self {
Self { stream: (length != 0).then_some(stream), remaining: length } Self {
stream: (length != 0).then_some(stream),
remaining: length,
}
} }
} }
@@ -26,14 +33,17 @@ where
cx: &mut Context<'_>, cx: &mut Context<'_>,
) -> Poll<Option<Result<Frame<Bytes>, io::Error>>> { ) -> Poll<Option<Result<Frame<Bytes>, io::Error>>> {
let this = self.get_mut(); let this = self.get_mut();
let Some(stream) = this.stream.as_mut() else { return Poll::Ready(None) }; let Some(stream) = this.stream.as_mut() else {
return Poll::Ready(None);
};
match Pin::new(stream).poll_next(cx) { match Pin::new(stream).poll_next(cx) {
Poll::Ready(Some(Ok(data))) => { Poll::Ready(Some(Ok(data))) => {
if data.len() as u64 > this.remaining { if data.len() as u64 > this.remaining {
this.stream = None; this.stream = None;
this.remaining = 0; this.remaining = 0;
return Poll::Ready(Some(Err(io::Error::new( return Poll::Ready(Some(Err(io::Error::new(
io::ErrorKind::InvalidData, "local file body exceeded its declared length", io::ErrorKind::InvalidData,
"local file body exceeded its declared length",
)))); ))));
} }
this.remaining -= data.len() as u64; this.remaining -= data.len() as u64;
@@ -46,7 +56,8 @@ where
this.stream = None; this.stream = None;
this.remaining = 0; this.remaining = 0;
Poll::Ready(Some(Err(io::Error::new( Poll::Ready(Some(Err(io::Error::new(
io::ErrorKind::UnexpectedEof, "local file body ended before its declared length", io::ErrorKind::UnexpectedEof,
"local file body ended before its declared length",
)))) ))))
} }
Poll::Ready(Some(Err(error))) => { Poll::Ready(Some(Err(error))) => {
@@ -58,9 +69,13 @@ where
} }
} }
fn is_end_stream(&self) -> bool { self.stream.is_none() } fn is_end_stream(&self) -> bool {
self.stream.is_none()
}
fn size_hint(&self) -> SizeHint { SizeHint::with_exact(self.remaining) } fn size_hint(&self) -> SizeHint {
SizeHint::with_exact(self.remaining)
}
} }
#[cfg(test)] #[cfg(test)]
+112 -27
View File
@@ -17,30 +17,54 @@ async fn final_file_frame_is_terminal_without_an_extra_eof_poll() {
let mut body = FileBody::new(ReaderStream::with_capacity(file.take(6), 3), 6); let mut body = FileBody::new(ReaderStream::with_capacity(file.take(6), 3), 6);
assert_eq!(body.size_hint().exact(), Some(6)); assert_eq!(body.size_hint().exact(), Some(6));
assert!(!body.is_end_stream()); assert!(!body.is_end_stream());
assert_eq!(body.frame().await.unwrap().unwrap().into_data().unwrap(), "abc"); assert_eq!(
body.frame().await.unwrap().unwrap().into_data().unwrap(),
"abc"
);
assert_eq!(body.size_hint().exact(), Some(3)); assert_eq!(body.size_hint().exact(), Some(3));
assert!(!body.is_end_stream()); assert!(!body.is_end_stream());
assert_eq!(body.frame().await.unwrap().unwrap().into_data().unwrap(), "def"); assert_eq!(
assert!(body.is_end_stream(), "the final data frame must end the body before another poll"); body.frame().await.unwrap().unwrap().into_data().unwrap(),
"def"
);
assert!(
body.is_end_stream(),
"the final data frame must end the body before another poll"
);
assert_eq!(body.size_hint().exact(), Some(0)); assert_eq!(body.size_hint().exact(), Some(0));
assert!(body.stream.is_none(), "the reader/file is released at the length boundary"); assert!(
body.stream.is_none(),
"the reader/file is released at the length boundary"
);
assert!(body.frame().await.is_none()); assert!(body.frame().await.is_none());
} }
#[tokio::test] #[tokio::test]
async fn short_file_and_local_stream_errors_never_become_successful_eof() { async fn short_file_and_local_stream_errors_never_become_successful_eof() {
let mut short = FileBody::new(stream::iter([Ok(Bytes::from_static(b"abc"))]), 6); let mut short = FileBody::new(stream::iter([Ok(Bytes::from_static(b"abc"))]), 6);
assert_eq!(short.frame().await.unwrap().unwrap().into_data().unwrap(), "abc"); assert_eq!(
assert_eq!(short.frame().await.unwrap().unwrap_err().kind(), io::ErrorKind::UnexpectedEof); short.frame().await.unwrap().unwrap().into_data().unwrap(),
"abc"
);
assert_eq!(
short.frame().await.unwrap().unwrap_err().kind(),
io::ErrorKind::UnexpectedEof
);
assert!(short.is_end_stream()); assert!(short.is_end_stream());
assert!(short.frame().await.is_none()); assert!(short.frame().await.is_none());
let mut failed = FileBody::new(stream::iter([Err(io::Error::other("read failed"))]), 6); let mut failed = FileBody::new(stream::iter([Err(io::Error::other("read failed"))]), 6);
assert_eq!(failed.frame().await.unwrap().unwrap_err().to_string(), "read failed"); assert_eq!(
failed.frame().await.unwrap().unwrap_err().to_string(),
"read failed"
);
assert!(failed.is_end_stream()); assert!(failed.is_end_stream());
let mut oversized = FileBody::new(stream::iter([Ok(Bytes::from_static(b"abcdef"))]), 3); let mut oversized = FileBody::new(stream::iter([Ok(Bytes::from_static(b"abcdef"))]), 3);
assert_eq!(oversized.frame().await.unwrap().unwrap_err().kind(), io::ErrorKind::InvalidData); assert_eq!(
oversized.frame().await.unwrap().unwrap_err().kind(),
io::ErrorKind::InvalidData
);
assert!(oversized.is_end_stream()); assert!(oversized.is_end_stream());
} }
@@ -51,10 +75,24 @@ async fn file_shrunk_after_open_returns_unexpected_eof() {
tokio::fs::write(&path, b"abcdef").await.unwrap(); tokio::fs::write(&path, b"abcdef").await.unwrap();
let file = tokio::fs::File::open(&path).await.unwrap(); let file = tokio::fs::File::open(&path).await.unwrap();
let expected = file.metadata().await.unwrap().len(); let expected = file.metadata().await.unwrap().len();
std::fs::OpenOptions::new().write(true).open(path).unwrap().set_len(3).unwrap(); std::fs::OpenOptions::new()
let mut body = FileBody::new(ReaderStream::with_capacity(file.take(expected), 3), expected); .write(true)
assert_eq!(body.frame().await.unwrap().unwrap().into_data().unwrap(), "abc"); .open(path)
assert_eq!(body.frame().await.unwrap().unwrap_err().kind(), io::ErrorKind::UnexpectedEof); .unwrap()
.set_len(3)
.unwrap();
let mut body = FileBody::new(
ReaderStream::with_capacity(file.take(expected), 3),
expected,
);
assert_eq!(
body.frame().await.unwrap().unwrap().into_data().unwrap(),
"abc"
);
assert_eq!(
body.frame().await.unwrap().unwrap_err().kind(),
io::ErrorKind::UnexpectedEof
);
assert!(body.is_end_stream()); assert!(body.is_end_stream());
assert!(body.stream.is_none()); assert!(body.stream.is_none());
} }
@@ -67,12 +105,22 @@ async fn zero_length_file_is_terminal_without_polling_the_reader() {
assert!(body.frame().await.is_none()); assert!(body.frame().await.is_none());
} }
fn settings() -> Settings { Settings { enabled: true, ..Default::default() } } fn settings() -> Settings {
Settings {
enabled: true,
..Default::default()
}
}
fn cacheable(body: AxumBody, length: u64) -> Response { fn cacheable(body: AxumBody, length: u64) -> Response {
let mut response = Response::new(body); let mut response = Response::new(body);
response.headers_mut().insert("cache-control", HeaderValue::from_static("public, max-age=600")); response.headers_mut().insert(
response.headers_mut().insert("content-length", length.to_string().parse().unwrap()); "cache-control",
HeaderValue::from_static("public, max-age=600"),
);
response
.headers_mut()
.insert("content-length", length.to_string().parse().unwrap());
response response
} }
@@ -85,17 +133,33 @@ async fn fully_delivered_file_fills_cache_before_consumer_drops_without_polling_
let body = FileBody::new(ReaderStream::with_capacity(file.take(6), 6), 6); let body = FileBody::new(ReaderStream::with_capacity(file.take(6), 6), 6);
let cache = Cache::new(1); let cache = Cache::new(1);
let settings = settings(); let settings = settings();
let ticket = cache.ticket(1, "http", "localhost", "site", "/file", &HeaderMap::new()).unwrap(); let ticket = cache
.ticket(1, "http", "localhost", "site", "/file", &HeaderMap::new())
.unwrap();
let response = cacheable(AxumBody::new(body), 6); let response = cacheable(AxumBody::new(body), 6);
let plan = cache::plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap(); let plan = cache::plan(
let mut body = cache.wrap(response, ticket.clone(), plan, &settings).into_body(); StatusCode::OK,
assert_eq!(body.frame().await.unwrap().unwrap().into_data().unwrap(), "abcdef"); response.headers(),
&settings,
Duration::ZERO,
)
.unwrap();
let mut body = cache
.wrap(response, ticket.clone(), plan, &settings)
.into_body();
assert_eq!(
body.frame().await.unwrap().unwrap().into_data().unwrap(),
"abcdef"
);
drop(body); drop(body);
assert_eq!(cache.view().entries, 1); assert_eq!(cache.view().entries, 1);
assert_eq!(cache.view().rejected_fills, 0); assert_eq!(cache.view().rejected_fills, 0);
assert_eq!(cache.view().fills_active, 0); assert_eq!(cache.view().fills_active, 0);
let hit = cache.get(&ticket).unwrap(); let hit = cache.get(&ticket).unwrap();
assert_eq!(hit.response.into_body().collect().await.unwrap().to_bytes(), "abcdef"); assert_eq!(
hit.response.into_body().collect().await.unwrap().to_bytes(),
"abcdef"
);
} }
#[tokio::test] #[tokio::test]
@@ -103,14 +167,35 @@ async fn incomplete_or_cancelled_file_cannot_fill_cache() {
for short in [false, true] { for short in [false, true] {
let cache = Cache::new(1); let cache = Cache::new(1);
let settings = settings(); let settings = settings();
let ticket = cache.ticket(1, "http", "localhost", "site", "/file", &HeaderMap::new()).unwrap(); let ticket = cache
let chunks = if short { vec![Ok(Bytes::from_static(b"abc"))] } .ticket(1, "http", "localhost", "site", "/file", &HeaderMap::new())
else { vec![Ok(Bytes::from_static(b"abc")), Ok(Bytes::from_static(b"def"))] }; .unwrap();
let chunks = if short {
vec![Ok(Bytes::from_static(b"abc"))]
} else {
vec![
Ok(Bytes::from_static(b"abc")),
Ok(Bytes::from_static(b"def")),
]
};
let response = cacheable(AxumBody::new(FileBody::new(stream::iter(chunks), 6)), 6); let response = cacheable(AxumBody::new(FileBody::new(stream::iter(chunks), 6)), 6);
let plan = cache::plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap(); let plan = cache::plan(
let mut body = cache.wrap(response, ticket.clone(), plan, &settings).into_body(); StatusCode::OK,
assert_eq!(body.frame().await.unwrap().unwrap().into_data().unwrap(), "abc"); response.headers(),
if short { assert!(body.frame().await.unwrap().is_err()); } &settings,
Duration::ZERO,
)
.unwrap();
let mut body = cache
.wrap(response, ticket.clone(), plan, &settings)
.into_body();
assert_eq!(
body.frame().await.unwrap().unwrap().into_data().unwrap(),
"abc"
);
if short {
assert!(body.frame().await.unwrap().is_err());
}
drop(body); drop(body);
assert!(cache.get(&ticket).is_none()); assert!(cache.get(&ticket).is_none());
assert_eq!(cache.view().rejected_fills, 1); assert_eq!(cache.view().rejected_fills, 1);
+12 -9
View File
@@ -8,7 +8,10 @@ use axum::{body::Body, response::Response};
use http::{header::LOCATION, HeaderMap, HeaderValue, StatusCode}; use http::{header::LOCATION, HeaderMap, HeaderValue, StatusCode};
use std::io; use std::io;
pub(super) async fn open_components(service: &CompiledService, components: Vec<String>) -> io::Result<Opened> { pub(super) async fn open_components(
service: &CompiledService,
components: Vec<String>,
) -> io::Result<Opened> {
let root = FilesystemRoot::for_service(service); let root = FilesystemRoot::for_service(service);
tokio::task::spawn_blocking(move || secure_open_sync(&root, &components)) tokio::task::spawn_blocking(move || secure_open_sync(&root, &components))
.await .await
@@ -28,7 +31,10 @@ fn target_path_and_query(
Some((path, query)) => (path.to_owned(), Some(query.to_owned())), Some((path, query)) => (path.to_owned(), Some(query.to_owned())),
None => (expanded, query.map(str::to_owned)), None => (expanded, query.map(str::to_owned)),
}; };
ensure!(path.starts_with('/'), "Web Server internal target must start with /"); ensure!(
path.starts_with('/'),
"Web Server internal target must start with /"
);
crate::acl::validate_decoded_routing_path(&path) crate::acl::validate_decoded_routing_path(&path)
.map_err(|_| anyhow::anyhow!("invalid Web Server internal target"))?; .map_err(|_| anyhow::anyhow!("invalid Web Server internal target"))?;
Ok((path, query)) Ok((path, query))
@@ -54,11 +60,7 @@ pub(super) fn apply_rewrites(
.pattern .pattern
.replace(&path, rule.replacement.as_str()) .replace(&path, rule.replacement.as_str())
.to_string(); .to_string();
let (next_path, next_query) = target_path_and_query( let (next_path, next_query) = target_path_and_query(&replacement, &path, query.as_deref())?;
&replacement,
&path,
query.as_deref(),
)?;
if let Some(status) = rule.status { if let Some(status) = rule.status {
let mut target = next_path; let mut target = next_path;
if let Some(query) = next_query.filter(|value| !value.is_empty()) { if let Some(query) = next_query.filter(|value| !value.is_empty()) {
@@ -142,7 +144,9 @@ pub(super) async fn select_precompressed(
continue; continue;
} }
let mut sidecar = components.to_vec(); let mut sidecar = components.to_vec();
let Some(last) = sidecar.last_mut() else { continue }; let Some(last) = sidecar.last_mut() else {
continue;
};
last.push_str(suffix); last.push_str(suffix);
if let Ok(opened) = open_components(service, sidecar).await { if let Ok(opened) = open_components(service, sidecar).await {
if opened.metadata.is_file() { if opened.metadata.is_file() {
@@ -152,4 +156,3 @@ pub(super) async fn select_precompressed(
} }
None None
} }
+8 -5
View File
@@ -35,8 +35,8 @@ pub(super) fn hardening_denied_path(service: &CompiledService, path: &str) -> bo
"id_ed25519", "id_ed25519",
]; ];
let backup_suffixes = [ let backup_suffixes = [
".bak", ".backup", ".old", ".orig", ".save", ".swp", ".swo", ".sql", ".bak", ".backup", ".old", ".orig", ".save", ".swp", ".swo", ".sql", ".sqlite", ".sqlite3",
".sqlite", ".sqlite3", ".db", ".db",
]; ];
path_components(path).ok().is_some_and(|components| { path_components(path).ok().is_some_and(|components| {
components.iter().any(|component| { components.iter().any(|component| {
@@ -46,14 +46,18 @@ pub(super) fn hardening_denied_path(service: &CompiledService, path: &str) -> bo
|| component.starts_with(".env.") || component.starts_with(".env.")
|| sensitive_names.contains(&component.as_str()) || sensitive_names.contains(&component.as_str())
|| component.ends_with('~') || component.ends_with('~')
|| backup_suffixes.iter().any(|suffix| component.ends_with(*suffix)) || backup_suffixes
.iter()
.any(|suffix| component.ends_with(*suffix))
}) })
}) })
} }
pub(super) fn hardening_denied_method(service: &CompiledService, method: &Method) -> bool { pub(super) fn hardening_denied_method(service: &CompiledService, method: &Method) -> bool {
service.hardening.block_common_attacks service.hardening.block_common_attacks
&& (method == Method::CONNECT || method == Method::TRACE || method.as_str().eq_ignore_ascii_case("TRACK")) && (method == Method::CONNECT
|| method == Method::TRACE
|| method.as_str().eq_ignore_ascii_case("TRACK"))
} }
pub(super) fn strip_unnecessary_headers(response: &mut Response, service: &CompiledService) { pub(super) fn strip_unnecessary_headers(response: &mut Response, service: &CompiledService) {
@@ -73,4 +77,3 @@ pub(super) fn strip_unnecessary_headers(response: &mut Response, service: &Compi
response.headers_mut().remove(name); response.headers_mut().remove(name);
} }
} }
+32 -9
View File
@@ -122,7 +122,13 @@ async fn missing_files_and_disabled_directory_listing_return_normal_errors() {
assert_eq!(missing.headers()[CONTENT_TYPE], "text/html; charset=utf-8"); assert_eq!(missing.headers()[CONTENT_TYPE], "text/html; charset=utf-8");
assert_eq!(missing.headers()["x-content-type-options"], "nosniff"); assert_eq!(missing.headers()["x-content-type-options"], "nosniff");
assert_eq!( assert_eq!(
missing.into_body().collect().await.unwrap().to_bytes().as_ref(), missing
.into_body()
.collect()
.await
.unwrap()
.to_bytes()
.as_ref(),
crate::pages::builtin_gateway_error(StatusCode::NOT_FOUND) crate::pages::builtin_gateway_error(StatusCode::NOT_FOUND)
.unwrap() .unwrap()
.as_bytes() .as_bytes()
@@ -210,7 +216,9 @@ async fn internal_filesystem_diagnostics_never_appear_in_public_error_responses(
.unwrap() .unwrap()
.as_bytes() .as_bytes()
); );
assert!(!body.windows(b"RustProxy:".len()).any(|w| w == b"RustProxy:")); assert!(!body
.windows(b"RustProxy:".len())
.any(|w| w == b"RustProxy:"));
} }
} }
@@ -222,7 +230,13 @@ async fn web_server_validation_and_unmapped_error_statuses_are_styled() {
assert_eq!(invalid.status(), StatusCode::BAD_REQUEST); assert_eq!(invalid.status(), StatusCode::BAD_REQUEST);
assert_eq!(invalid.headers()[CONTENT_TYPE], "text/html; charset=utf-8"); assert_eq!(invalid.headers()[CONTENT_TYPE], "text/html; charset=utf-8");
assert_eq!( assert_eq!(
invalid.into_body().collect().await.unwrap().to_bytes().as_ref(), invalid
.into_body()
.collect()
.await
.unwrap()
.to_bytes()
.as_ref(),
crate::pages::builtin_gateway_error(StatusCode::BAD_REQUEST) crate::pages::builtin_gateway_error(StatusCode::BAD_REQUEST)
.unwrap() .unwrap()
.as_bytes() .as_bytes()
@@ -255,7 +269,10 @@ async fn web_server_validation_and_unmapped_error_statuses_are_styled() {
assert_eq!(fallback.status(), StatusCode::IM_A_TEAPOT); assert_eq!(fallback.status(), StatusCode::IM_A_TEAPOT);
assert_eq!(fallback.headers()[CONTENT_TYPE], "text/html; charset=utf-8"); assert_eq!(fallback.headers()[CONTENT_TYPE], "text/html; charset=utf-8");
assert_eq!(fallback.headers()[CACHE_CONTROL], "no-store"); assert_eq!(fallback.headers()[CACHE_CONTROL], "no-store");
assert!(fallback.extensions().get::<crate::pages::PageServed>().is_none()); assert!(fallback
.extensions()
.get::<crate::pages::PageServed>()
.is_none());
let fallback_html = fallback.into_body().collect().await.unwrap().to_bytes(); let fallback_html = fallback.into_body().collect().await.unwrap().to_bytes();
let fallback_html = std::str::from_utf8(&fallback_html).unwrap(); let fallback_html = std::str::from_utf8(&fallback_html).unwrap();
assert!(fallback_html.contains("<h1>418 -")); assert!(fallback_html.contains("<h1>418 -"));
@@ -272,7 +289,13 @@ async fn web_server_validation_and_unmapped_error_statuses_are_styled() {
head.headers()[CONTENT_LENGTH], head.headers()[CONTENT_LENGTH],
HeaderValue::from_str(&fallback_html.len().to_string()).unwrap() HeaderValue::from_str(&fallback_html.len().to_string()).unwrap()
); );
assert!(head.into_body().collect().await.unwrap().to_bytes().is_empty()); assert!(head
.into_body()
.collect()
.await
.unwrap()
.to_bytes()
.is_empty());
} }
#[tokio::test] #[tokio::test]
@@ -1831,10 +1854,10 @@ async fn web_metrics_count_handler_status_method_and_consumed_body_bytes() {
assert_eq!(view["status_4xx"], 1); assert_eq!(view["status_4xx"], 1);
assert_eq!(view["static_requests"], 2); assert_eq!(view["static_requests"], 2);
assert_eq!(view["other_requests"], 1); assert_eq!(view["other_requests"], 1);
assert_eq!(view["methods"][0], 2); assert_eq!(view["methods"][0], 2);
assert_eq!(view["methods"][4], 1); assert_eq!(view["methods"][4], 1);
assert_eq!(view["selected_status"][0], 2); assert_eq!(view["selected_status"][0], 2);
assert_eq!(view["selected_status"][4], 1); assert_eq!(view["selected_status"][4], 1);
assert_eq!(view["response_bytes"], 5 + missing_bytes as u64); assert_eq!(view["response_bytes"], 5 + missing_bytes as u64);
assert_eq!(view["active_headers"], 0); assert_eq!(view["active_headers"], 0);
} }