0.34.13_cleaup_code
This commit is contained in:
1 parent
ec46a96a29
commit
5c2e50ec06
83 files changed
+4875
-1323
No files matched your search
+3
-5
@@ -2,12 +2,12 @@
|
||||
4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example
|
||||
9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore
|
||||
754db9d80640bdb05116d14cf91e1b69bfbbcfee2c522659ace4a4c7d48bb10d AGENTS.md
|
||||
191e5abff142bf0353d4e5c29994d4516799f550ec25dc2173be3003a0028a58 CHANGELOG.md
|
||||
df0f23d242cc45064e2e822bbd1fc8297acffa89f71eed2c0d4314695107d5a1 CHANGELOG.md
|
||||
b548ffccc83f59c886fd111bbfb63b40b34c35889f752e44e586c8e22131cfb8 Cargo.lock
|
||||
bb70eb65066a0aa23859a2ae006ba1bb75ff86b87afd4fa6160eae78a8d42af9 Cargo.toml
|
||||
25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile
|
||||
a698cd5979a56ea5f8ab8aa4ca8ccf5f6d72515f689fc833b73e16f37ca64a6c README.md
|
||||
9edb7d4a4da46f0f836e504002f3676b88639208d28c1588e8637b085594c154 ROADMAP.md
|
||||
3b0ef7ac49dfb35375780c53c48c03004fe240e68bb7337d3c8c5ad0a0e15998 README.md
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ROADMAP.md
|
||||
da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml
|
||||
ac07b3bdd46c787989e93f55ebd6ea3d7f5b150ef36f2422e2b58df3f6b7543a build.rs
|
||||
2c6a0a5cb088399e2cc912073069eb334ec6615eee31734d2f0c161f1415fb6a deploy/README.md
|
||||
@@ -115,7 +115,6 @@ f040ab366fb138a48debf764c71538c2471235353e2426316264c5d3c2d59762 scripts/prefli
|
||||
20124173452b4514bad8850c1637569a9a191882478451e549bd16b432605fc1 scripts/runtime/acme_provider.py
|
||||
e69813fb1d3a4cdcfb7243964cc323fc21a603087bf2e84188fa42b9d3cee4b5 scripts/runtime/acme_rfc2136.py
|
||||
174c8059625e3315a3d9fefe74c17f78ed850ff25ee15bfdabc148107dd344eb scripts/start_stream_backends.sh
|
||||
7302f3446e2306a8618a1c026178e4c34f6215066ef7f7718bb29a42116bb354 scripts/test_admin_config_browser.py
|
||||
046b4282550f5cb4e53906665c91c7442be70fc68fd0743466fd832582f7c979 scripts/test_stream_tcp.py
|
||||
f0b48ec0cc4411755cc0b4128eebce710375afa99344cb76198cf31074f8eb63 scripts/test_stream_udp.py
|
||||
22e880e98c7b9d700c7a4c02b9b1d543e5dbd1f4ecdca42813a1083165b32a19 scripts/version.py
|
||||
@@ -527,7 +526,6 @@ f826d8816bdb9edab966e10991e45335625ef7d7376169cf6fd1e0e5d01bf868 src/web_server
|
||||
b791762b4ab7a43c3be19df84e523faf3dfb4df055f66d02178f93edb60d2204 src/web_server/serve.rs
|
||||
df888adf893d0b6b99348d19fa07bd9c2b8fc0ce7f411d06be7411afb4311fe0 src/web_server/tests.rs
|
||||
f00743af2cf4a5952caef7a1dcf68e7817b5599a252a9973e1c22045517c0b0e tests/fixtures/argon2id.json
|
||||
0930ab8fae4eaaf76185bb1c6c9d4c2ee705a9e09a3ae74d81674ca4631a0169 tests/test_config_api_contract.py
|
||||
eca68a07c2b1e45fdb8f512294f5a432db3ae5a6aec8fb200b47f155cd9a31d6 tools/README.md
|
||||
7c30c280d0b0a8bf855eeffab583ca34dc161255f7ef6124148aa979c17c26d3 tools/dev/memory_load.py
|
||||
d1418f8e574a7390fd45a2aa879bdf5720b773162d65e295ab0350506aa5220a tools/dev/mock_auth.py
|
||||
|
||||
+1413
-185
File diff suppressed because it is too large.
Load diff
+36
-14
@@ -58,13 +58,17 @@ impl Acl {
|
||||
ensure!(rule.network.len() <= 254, "ACL network is too long");
|
||||
let target = if let Ok(network) = parse_network(&rule.network) {
|
||||
if let IpNet::V6(net) = network {
|
||||
ensure!(net.addr().to_ipv4_mapped().is_none(),
|
||||
"use IPv4 CIDR instead of IPv4-mapped IPv6 in ACL rules");
|
||||
ensure!(
|
||||
net.addr().to_ipv4_mapped().is_none(),
|
||||
"use IPv4 CIDR instead of IPv4-mapped IPv6 in ACL rules"
|
||||
);
|
||||
}
|
||||
RuleMatch::Ip(network)
|
||||
} else {
|
||||
ensure!(rule.action == Action::Allow,
|
||||
"dynamic DNS ACL rules only support allow; use a static IP/CIDR for deny");
|
||||
ensure!(
|
||||
rule.action == Action::Allow,
|
||||
"dynamic DNS ACL rules only support allow; use a static IP/CIDR for deny"
|
||||
);
|
||||
RuleMatch::Dns(dynamic::hostname(&rule.network)?)
|
||||
};
|
||||
rules.push((target, rule.action));
|
||||
@@ -94,9 +98,16 @@ pub fn allows_profiles_with_country(
|
||||
let mut explicit_allow = false;
|
||||
let mut defaults_allow = true;
|
||||
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| {
|
||||
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)
|
||||
|| (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)
|
||||
|| (profile.countries.mode == crate::rate_limit::CountryMode::Allow && country_listed);
|
||||
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
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
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();
|
||||
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(())
|
||||
}
|
||||
@@ -181,9 +204,7 @@ pub fn allows_with_country(
|
||||
&& (ip_action == Some(Action::Allow)
|
||||
|| acl.is_none_or(|value| value.default == Action::Allow))
|
||||
}
|
||||
crate::rate_limit::CountryMode::Allow => {
|
||||
ip_action == Some(Action::Allow) || country_listed
|
||||
}
|
||||
crate::rate_limit::CountryMode::Allow => ip_action == Some(Action::Allow) || country_listed,
|
||||
}
|
||||
}
|
||||
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!(
|
||||
!raw.split('/').any(|segment| segment == "." || segment == ".."),
|
||||
!raw.split('/')
|
||||
.any(|segment| segment == "." || segment == ".."),
|
||||
"ambiguous path segments"
|
||||
);
|
||||
Ok(std::borrow::Cow::Borrowed(raw))
|
||||
|
||||
@@ -21,13 +21,16 @@ fn general_api_token_allowed(method: &Method, path: &str, permission: &str) -> b
|
||||
| "/api/metrics/json"
|
||||
| "/api/metrics/summary"
|
||||
| "/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()));
|
||||
let write = permission == "rw"
|
||||
&& ((method == Method::PUT
|
||||
&& (path == "/api/config"
|
||||
|| path.strip_prefix("/api/config/")
|
||||
|| path
|
||||
.strip_prefix("/api/config/")
|
||||
.is_some_and(|section| ConfigSection::from_name(section).is_some())))
|
||||
|| (method == Method::POST
|
||||
&& matches!(
|
||||
|
||||
+108
-62
@@ -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)]
|
||||
enum ConfigSection {
|
||||
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 {
|
||||
Self::Listeners => config.listeners = serde_json::from_value(value)
|
||||
.map_err(|error| invalid_section(name, error))?,
|
||||
Self::DockerAgents => config.docker_agents = serde_json::from_value(value)
|
||||
.map_err(|error| invalid_section(name, error))?,
|
||||
Self::Http2 => config.http2 = serde_json::from_value(value)
|
||||
.map_err(|error| invalid_section(name, error))?,
|
||||
Self::Pages => config.pages = serde_json::from_value(value)
|
||||
.map_err(|error| invalid_section(name, error))?,
|
||||
Self::Cache => config.cache = serde_json::from_value(value)
|
||||
.map_err(|error| invalid_section(name, error))?,
|
||||
Self::Transform => config.transform = serde_json::from_value(value)
|
||||
.map_err(|error| invalid_section(name, error))?,
|
||||
Self::Forwarding => config.forwarding = serde_json::from_value(value)
|
||||
.map_err(|error| invalid_section(name, error))?,
|
||||
Self::Headers => config.headers = serde_json::from_value(value)
|
||||
.map_err(|error| invalid_section(name, error))?,
|
||||
Self::Prometheus => config.prometheus = serde_json::from_value(value)
|
||||
.map_err(|error| invalid_section(name, error))?,
|
||||
Self::Geoip => config.geoip_updates = serde_json::from_value(value)
|
||||
.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::Listeners => {
|
||||
config.listeners =
|
||||
serde_json::from_value(value).map_err(|error| invalid_section(name, error))?
|
||||
}
|
||||
Self::DockerAgents => {
|
||||
config.docker_agents =
|
||||
serde_json::from_value(value).map_err(|error| invalid_section(name, error))?
|
||||
}
|
||||
Self::Http2 => {
|
||||
config.http2 =
|
||||
serde_json::from_value(value).map_err(|error| invalid_section(name, error))?
|
||||
}
|
||||
Self::Pages => {
|
||||
config.pages =
|
||||
serde_json::from_value(value).map_err(|error| invalid_section(name, error))?
|
||||
}
|
||||
Self::Cache => {
|
||||
config.cache =
|
||||
serde_json::from_value(value).map_err(|error| invalid_section(name, error))?
|
||||
}
|
||||
Self::Transform => {
|
||||
config.transform =
|
||||
serde_json::from_value(value).map_err(|error| invalid_section(name, error))?
|
||||
}
|
||||
Self::Forwarding => {
|
||||
config.forwarding =
|
||||
serde_json::from_value(value).map_err(|error| invalid_section(name, error))?
|
||||
}
|
||||
Self::Headers => {
|
||||
config.headers =
|
||||
serde_json::from_value(value).map_err(|error| invalid_section(name, error))?
|
||||
}
|
||||
Self::Prometheus => {
|
||||
config.prometheus =
|
||||
serde_json::from_value(value).map_err(|error| invalid_section(name, error))?
|
||||
}
|
||||
Self::Geoip => {
|
||||
config.geoip_updates =
|
||||
serde_json::from_value(value).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 => {
|
||||
let part: ResponseSection = serde_json::from_value(value)
|
||||
.map_err(|error| invalid_section(name, error))?;
|
||||
let part: ResponseSection =
|
||||
serde_json::from_value(value).map_err(|error| invalid_section(name, error))?;
|
||||
config.server_header = part.server_header;
|
||||
config.diagnostic_headers = part.diagnostic_headers;
|
||||
config.compression = part.compression;
|
||||
}
|
||||
Self::Logging => {
|
||||
// A logging save must include both sinks; there is no implicit fallback
|
||||
// to a single-field payload that could reset error_log accidentally.
|
||||
let part: LoggingSection = 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.error_log = part.error_log;
|
||||
}
|
||||
Self::GlobalSecurity => {
|
||||
let part: crate::config::GlobalSecurity = serde_json::from_value(value)
|
||||
.map_err(|error| invalid_section(name, error))?;
|
||||
let part: crate::config::GlobalSecurity =
|
||||
serde_json::from_value(value).map_err(|error| invalid_section(name, error))?;
|
||||
if part.user_agent_blocking.is_none() {
|
||||
return Err(ApiError(
|
||||
StatusCode::BAD_REQUEST,
|
||||
@@ -167,10 +207,12 @@ impl ConfigSection {
|
||||
}
|
||||
|
||||
fn requested_config_section(name: &str) -> Result<ConfigSection, ApiError> {
|
||||
ConfigSection::from_name(name).ok_or_else(|| ApiError(
|
||||
StatusCode::NOT_FOUND,
|
||||
"unknown configuration section".into(),
|
||||
))
|
||||
ConfigSection::from_name(name).ok_or_else(|| {
|
||||
ApiError(
|
||||
StatusCode::NOT_FOUND,
|
||||
"unknown configuration section".into(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async fn configuration_section(
|
||||
@@ -192,7 +234,6 @@ async fn update_section(
|
||||
Path(section): Path<String>,
|
||||
Json(input): Json<SectionUpdate>,
|
||||
) -> Result<Json<crate::config::Envelope>, ApiError> {
|
||||
// Resolve the name before taking the snapshot, including for stale requests.
|
||||
let selected = requested_config_section(§ion)?;
|
||||
let current = state.config.load_full();
|
||||
if current.envelope.revision != input.expected_revision {
|
||||
@@ -203,12 +244,18 @@ async fn update_section(
|
||||
}
|
||||
let mut config = current.envelope.config.clone();
|
||||
selected.replace(&mut config, §ion, input.value)?;
|
||||
state.update_as(Update { expected_revision: input.expected_revision, config }, session.username)
|
||||
.await.map(Json)
|
||||
state
|
||||
.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(
|
||||
State(state): State<Arc<App>>,
|
||||
Extension(session): Extension<Session>,
|
||||
@@ -239,4 +286,3 @@ async fn geoip_update(
|
||||
.map_err(auth::internal)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
+41
-22
@@ -95,32 +95,37 @@ fn configured_file_log_sources(config: &crate::config::Config) -> Vec<FileLogSou
|
||||
.ok()
|
||||
.is_some_and(|meta| meta.file_type().is_dir() && !meta.file_type().is_symlink());
|
||||
if directory_safe {
|
||||
let entries = newest_log_files(std::fs::read_dir(&directory)
|
||||
.ok()
|
||||
.into_iter()
|
||||
.flat_map(|iter| iter.filter_map(|entry| entry.ok()))
|
||||
.filter_map(|entry| {
|
||||
let path = entry.path();
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
let plain = name.ends_with(".jsonl");
|
||||
let rotated = name.contains(".jsonl.") && name.ends_with(".gz");
|
||||
if (!plain && !rotated) || name.len() > 180 {
|
||||
return None;
|
||||
}
|
||||
let file_type = entry.file_type().ok()?;
|
||||
if !file_type.is_file() || file_type.is_symlink() {
|
||||
return None;
|
||||
}
|
||||
let meta = entry.metadata().ok()?;
|
||||
Some((name, path, file_modified_ms(&meta)))
|
||||
}), 256);
|
||||
let entries = newest_log_files(
|
||||
std::fs::read_dir(&directory)
|
||||
.ok()
|
||||
.into_iter()
|
||||
.flat_map(|iter| iter.filter_map(|entry| entry.ok()))
|
||||
.filter_map(|entry| {
|
||||
let path = entry.path();
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
let plain = name.ends_with(".jsonl");
|
||||
let rotated = name.contains(".jsonl.") && name.ends_with(".gz");
|
||||
if (!plain && !rotated) || name.len() > 180 {
|
||||
return None;
|
||||
}
|
||||
let file_type = entry.file_type().ok()?;
|
||||
if !file_type.is_file() || file_type.is_symlink() {
|
||||
return None;
|
||||
}
|
||||
let meta = entry.metadata().ok()?;
|
||||
Some((name, path, file_modified_ms(&meta)))
|
||||
}),
|
||||
256,
|
||||
);
|
||||
for (name, path, _) in entries {
|
||||
let id = if name.ends_with(".jsonl") {
|
||||
format!("vhost:{}", name.trim_end_matches(".jsonl"))
|
||||
} else {
|
||||
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}");
|
||||
sources.push(source_from_path(
|
||||
id,
|
||||
@@ -144,7 +149,14 @@ fn configured_file_log_sources(config: &crate::config::Config) -> Vec<FileLogSou
|
||||
config.logging.file.enabled,
|
||||
"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 {
|
||||
crate::logging::ErrorLogFormat::Json => "JSONL",
|
||||
@@ -160,7 +172,14 @@ fn configured_file_log_sources(config: &crate::config::Config) -> Vec<FileLogSou
|
||||
config.error_log.enabled,
|
||||
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
|
||||
}
|
||||
fn file_log_sources_json(sources: &[FileLogSource]) -> Value {
|
||||
|
||||
+4
-1
@@ -260,7 +260,10 @@ pub fn router(state: Arc<App>) -> Router {
|
||||
post(restore_config_history).layer(DefaultBodyLimit::max(4096)),
|
||||
)
|
||||
.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/live-logs", get(live_logs_stream))
|
||||
.route("/api/file-logs", get(file_logs))
|
||||
|
||||
+180
-67
@@ -654,11 +654,29 @@ mod tests {
|
||||
#[test]
|
||||
fn config_section_registry_read_and_replace_are_symmetric() {
|
||||
let names = [
|
||||
"listeners", "docker-agents", "http2", "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",
|
||||
"listeners",
|
||||
"docker-agents",
|
||||
"http2",
|
||||
"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",
|
||||
];
|
||||
let mut original = Config::default();
|
||||
original.global_security.compile().unwrap();
|
||||
@@ -671,7 +689,14 @@ mod tests {
|
||||
assert_eq!(serde_json::to_value(&updated).unwrap(), expected, "{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}");
|
||||
}
|
||||
}
|
||||
@@ -681,17 +706,40 @@ mod tests {
|
||||
let (router, store, _dir, secret, _csrf) = fixture().await;
|
||||
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||
for name in [
|
||||
"listeners", "docker-agents", "http2", "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",
|
||||
"listeners",
|
||||
"docker-agents",
|
||||
"http2",
|
||||
"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",
|
||||
] {
|
||||
let response = router.clone().oneshot(
|
||||
request(&format!("/api/config/{name}"))
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty()).unwrap(),
|
||||
).await.unwrap();
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request(&format!("/api/config/{name}"))
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK, "{name}");
|
||||
let data = json_body(response).await;
|
||||
assert_eq!(data["section"], name);
|
||||
@@ -708,24 +756,57 @@ mod tests {
|
||||
assert!(data["value"]["user_agent_blocking"].is_object());
|
||||
}
|
||||
}
|
||||
let unknown = router.clone().oneshot(
|
||||
request("/api/config/user-agent-blocking")
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty()).unwrap(),
|
||||
).await.unwrap();
|
||||
let unknown = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/config/user-agent-blocking")
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(unknown.status(), StatusCode::NOT_FOUND);
|
||||
store.close().await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
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(&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"));
|
||||
assert!(general_api_token_allowed(
|
||||
&Method::GET,
|
||||
"/api/config/geoip",
|
||||
"ro"
|
||||
));
|
||||
assert!(general_api_token_allowed(
|
||||
&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]
|
||||
@@ -736,9 +817,16 @@ mod tests {
|
||||
("/api/config/geoip/update", StatusCode::METHOD_NOT_ALLOWED),
|
||||
("/api/geoip/update", StatusCode::NOT_FOUND),
|
||||
] {
|
||||
let response = router.clone().oneshot(
|
||||
request(path).header("cookie", &cookie).body(Body::empty()).unwrap(),
|
||||
).await.unwrap();
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request(path)
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), expected, "{path}");
|
||||
}
|
||||
store.close().await;
|
||||
@@ -813,12 +901,10 @@ mod tests {
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(
|
||||
&json!({"expected_revision":0,"value":{
|
||||
"logging":Config::default().logging,
|
||||
"error_log":Config::default().error_log
|
||||
}}),
|
||||
)
|
||||
serde_json::to_vec(&json!({"expected_revision":0,"value":{
|
||||
"logging":Config::default().logging,
|
||||
"error_log":Config::default().error_log
|
||||
}}))
|
||||
.unwrap(),
|
||||
))
|
||||
.unwrap(),
|
||||
@@ -846,7 +932,6 @@ mod tests {
|
||||
store.close().await;
|
||||
}
|
||||
|
||||
|
||||
#[tokio::test]
|
||||
async fn global_security_section_saves_rules_and_existing_ua_atomically() {
|
||||
let (router, _store, _dir, secret, csrf) = fixture().await;
|
||||
@@ -858,53 +943,81 @@ mod tests {
|
||||
"user_agent_blocking": {"enabled":true, "patterns":["bad-bot"]}
|
||||
});
|
||||
let body = serde_json::to_vec(&json!({"expected_revision":0, "value":value})).unwrap();
|
||||
let response = router.clone().oneshot(
|
||||
request("/api/config/global-security")
|
||||
.method("PUT")
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.body(Body::from(body)).unwrap(),
|
||||
).await.unwrap();
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/config/global-security")
|
||||
.method("PUT")
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let json = json_body(response).await;
|
||||
assert_eq!(json["revision"], 1);
|
||||
assert_eq!(json["config"]["global_security"]["country_access"]["countries"][0], "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_eq!(
|
||||
json["config"]["global_security"]["country_access"]["countries"][0],
|
||||
"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());
|
||||
|
||||
// A partial section update must not silently reset the existing UA policy.
|
||||
let body = serde_json::to_vec(&json!({
|
||||
"expected_revision":1,
|
||||
"value": {"method_policy":{"mode":"off","methods":[]}}
|
||||
})).unwrap();
|
||||
let response = router.clone().oneshot(
|
||||
request("/api/config/global-security")
|
||||
.method("PUT")
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.body(Body::from(body)).unwrap(),
|
||||
).await.unwrap();
|
||||
}))
|
||||
.unwrap();
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/config/global-security")
|
||||
.method("PUT")
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
// GET returns the same standalone section shape accepted by PUT, at the
|
||||
// matching revision and without unrequested configuration fields.
|
||||
let read = router.clone().oneshot(
|
||||
request("/api/config/global-security")
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty()).unwrap(),
|
||||
).await.unwrap();
|
||||
let read = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/config/global-security")
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(read.status(), StatusCode::OK);
|
||||
let view = json_body(read).await;
|
||||
assert_eq!(view["revision"], 1);
|
||||
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["value"].get("global_security").is_none());
|
||||
}
|
||||
|
||||
@@ -1505,8 +1505,22 @@ html[data-table-density=ultra] :is(table th, table td) {
|
||||
gap: 6px;
|
||||
max-width: 260px;
|
||||
}
|
||||
|
||||
/* 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; }
|
||||
.path-permissions-commands { margin-bottom: 0; }
|
||||
.path-permissions-commands + .path-permissions-description { margin: 7px 0 0; }
|
||||
.path-permissions-actions { margin-top: 10px; }
|
||||
#certificate-external-acl-example.path-permissions {
|
||||
margin-top: 20px;
|
||||
border-top: 1px solid var(--line);
|
||||
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;
|
||||
}
|
||||
+10
-11
@@ -231,8 +231,12 @@ fn derive_key(password: &str, salt: &[u8]) -> Result<Zeroizing<[u8; 32]>> {
|
||||
fn new_crypto(password: &str) -> Result<CryptoContext> {
|
||||
let mut salt = [0u8; 16];
|
||||
let mut nonce_prefix = [0u8; 4];
|
||||
SysRng.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");
|
||||
SysRng
|
||||
.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 {
|
||||
format_version: FORMAT_VERSION,
|
||||
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>> {
|
||||
ensure!(master.len() == 32, "invalid secret store master key");
|
||||
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 mut encrypted = Zeroizing::new(master.to_vec());
|
||||
cipher
|
||||
@@ -462,13 +468,6 @@ fn hash_bytes(bytes: &[u8]) -> String {
|
||||
fn excluded_directory(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"cache"
|
||||
| "logs"
|
||||
| "tmp"
|
||||
| "temp"
|
||||
| "work"
|
||||
| "acme-http01"
|
||||
| "acme-python"
|
||||
| "acme_python"
|
||||
"cache" | "logs" | "tmp" | "temp" | "work" | "acme-http01" | "acme-python" | "acme_python"
|
||||
)
|
||||
}
|
||||
+6
-3
@@ -14,7 +14,9 @@ mod tests {
|
||||
let crypto = new_crypto("correct horse battery staple").unwrap();
|
||||
encrypt_archive(&input, &encrypted, &crypto).unwrap();
|
||||
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();
|
||||
assert_eq!(fs::read(&output).unwrap(), b"private backup payload");
|
||||
|
||||
@@ -24,7 +26,9 @@ mod tests {
|
||||
fs::write(&tampered, bytes).unwrap();
|
||||
let error = decrypt_archive(&tampered, &tampered_output, "correct horse battery staple")
|
||||
.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");
|
||||
}
|
||||
|
||||
@@ -98,7 +102,6 @@ mod tests {
|
||||
assert_eq!(fs::read(restored_link).unwrap(), b"certificate-v1");
|
||||
}
|
||||
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn restore_owner_is_applied_recursively() {
|
||||
|
||||
@@ -426,6 +426,8 @@ pub async fn restore(archive: &Path, data_dir: &Path, password: &str) -> Result<
|
||||
|
||||
pub fn generated_password() -> String {
|
||||
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)
|
||||
}
|
||||
Vendored
+60
-53
@@ -70,9 +70,13 @@ impl Cache {
|
||||
continue;
|
||||
};
|
||||
let body_path = shard.join(&file.body_file);
|
||||
let expected_scope_revision = self.scope_revisions
|
||||
.lock().unwrap_or_else(|e| e.into_inner())
|
||||
.get(&file.scope).copied().unwrap_or(0);
|
||||
let expected_scope_revision = self
|
||||
.scope_revisions
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.get(&file.scope)
|
||||
.copied()
|
||||
.unwrap_or(0);
|
||||
if file.format != COLD_FORMAT_VERSION
|
||||
|| file.revision != revision
|
||||
|| file.scope_revision != expected_scope_revision
|
||||
@@ -142,57 +146,60 @@ impl Cache {
|
||||
let _ = std::fs::remove_file(&body_path);
|
||||
continue;
|
||||
}
|
||||
loaded.push((key, ColdEntry {
|
||||
base_key,
|
||||
vary,
|
||||
revision,
|
||||
scope: file.scope,
|
||||
scheme: file.scheme,
|
||||
authority: if file.authority.is_empty() {
|
||||
file.domain.clone()
|
||||
} else {
|
||||
file.authority
|
||||
loaded.push((
|
||||
key,
|
||||
ColdEntry {
|
||||
base_key,
|
||||
vary,
|
||||
revision,
|
||||
scope: file.scope,
|
||||
scheme: file.scheme,
|
||||
authority: if file.authority.is_empty() {
|
||||
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);
|
||||
|
||||
Vendored
+48
-14
@@ -427,7 +427,12 @@ mod dashboard_tests {
|
||||
);
|
||||
let mut headers = HeaderMap::new();
|
||||
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!(
|
||||
headers["cache-status"],
|
||||
@@ -435,7 +440,12 @@ mod dashboard_tests {
|
||||
);
|
||||
let mut headers = HeaderMap::new();
|
||||
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!(
|
||||
headers["cache-status"],
|
||||
@@ -453,20 +463,38 @@ mod dashboard_tests {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.append("cache-status", HeaderValue::from_static("OriginCache; hit"));
|
||||
settings.annotate_reason_transformed_forwarded(
|
||||
&mut headers, "HIT", 3, 0, "cache_stale_if_error", false,
|
||||
"stale", Some(StatusCode::SERVICE_UNAVAILABLE),
|
||||
&mut headers,
|
||||
"HIT",
|
||||
3,
|
||||
0,
|
||||
"cache_stale_if_error",
|
||||
false,
|
||||
"stale",
|
||||
Some(StatusCode::SERVICE_UNAVAILABLE),
|
||||
);
|
||||
assert_eq!(headers["x-cache"], "HIT");
|
||||
let values: Vec<_> = headers.get_all("cache-status").iter()
|
||||
.map(|value| value.to_str().unwrap()).collect();
|
||||
assert_eq!(values, vec![
|
||||
"OriginCache; hit",
|
||||
"RustProxy; fwd=stale; fwd-status=503; detail=\"cache_stale_if_error\"",
|
||||
]);
|
||||
let values: Vec<_> = headers
|
||||
.get_all("cache-status")
|
||||
.iter()
|
||||
.map(|value| value.to_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
values,
|
||||
vec![
|
||||
"OriginCache; hit",
|
||||
"RustProxy; fwd=stale; fwd-status=503; detail=\"cache_stale_if_error\"",
|
||||
]
|
||||
);
|
||||
let mut headers = HeaderMap::new();
|
||||
settings.annotate_reason_transformed_forwarded(
|
||||
&mut headers, "HIT", 2, 42, "cache_revalidated", false,
|
||||
"request", Some(StatusCode::NOT_MODIFIED),
|
||||
&mut headers,
|
||||
"HIT",
|
||||
2,
|
||||
42,
|
||||
"cache_revalidated",
|
||||
false,
|
||||
"request",
|
||||
Some(StatusCode::NOT_MODIFIED),
|
||||
);
|
||||
assert_eq!(
|
||||
headers["cache-status"],
|
||||
@@ -474,8 +502,14 @@ mod dashboard_tests {
|
||||
);
|
||||
let mut headers = HeaderMap::new();
|
||||
settings.annotate_reason_transformed_forwarded(
|
||||
&mut headers, "HIT", 2, 0, "cache_stale_if_error", false,
|
||||
"stale", None,
|
||||
&mut headers,
|
||||
"HIT",
|
||||
2,
|
||||
0,
|
||||
"cache_stale_if_error",
|
||||
false,
|
||||
"stale",
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
headers["cache-status"],
|
||||
|
||||
Vendored
+30
-9
@@ -182,7 +182,15 @@ impl Settings {
|
||||
ttl: u64,
|
||||
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(
|
||||
@@ -194,7 +202,15 @@ impl Settings {
|
||||
reason: &'static str,
|
||||
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(
|
||||
@@ -207,12 +223,16 @@ impl Settings {
|
||||
transformed: bool,
|
||||
) {
|
||||
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(
|
||||
&self,
|
||||
headers: &mut HeaderMap,
|
||||
@@ -285,8 +305,11 @@ impl Settings {
|
||||
}
|
||||
if self.expose_cache_status {
|
||||
let forwarded = forwarded.or_else(|| {
|
||||
matches!(reason, Some("cache_revalidated" | "cache_revalidated_uncached"))
|
||||
.then_some(("stale", Some(StatusCode::NOT_MODIFIED)))
|
||||
matches!(
|
||||
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 = format!("RustProxy; fwd={why}");
|
||||
@@ -296,8 +319,6 @@ impl Settings {
|
||||
value
|
||||
} else {
|
||||
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}"),
|
||||
"MISS" => "RustProxy; fwd=miss".to_owned(),
|
||||
"BYPASS" if reason == Some("request_method") => {
|
||||
|
||||
Vendored
+1
-1
@@ -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 {
|
||||
const MAX: i128 = 999_999_999_999_999;
|
||||
(i128::from(ttl) - i128::from(age)).clamp(-MAX, MAX) as i64
|
||||
|
||||
Vendored
-2
@@ -87,9 +87,7 @@ pub enum ColdPreset {
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Settings {
|
||||
pub enabled: bool,
|
||||
/// Ignore client freshness directives on cache lookups, like Varnish builtin VCL.
|
||||
pub varnish_compatible: bool,
|
||||
/// Implicit grace in seconds for eligible fresh-at-storage cached responses.
|
||||
pub varnish_grace_seconds: u64,
|
||||
pub memory_preset: Option<MemoryPreset>,
|
||||
pub max_bytes: usize,
|
||||
|
||||
Vendored
+1
-2
@@ -133,8 +133,7 @@ fn stale_mode(
|
||||
}
|
||||
let stale_for = age.saturating_sub(ttl);
|
||||
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) {
|
||||
return None;
|
||||
}
|
||||
|
||||
Vendored
+32
-24
@@ -44,9 +44,7 @@ fn directives(headers: &HeaderMap) -> Option<HashMap<String, Option<String>>> {
|
||||
}
|
||||
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>(
|
||||
headers: &'a HeaderMap,
|
||||
compatible: bool,
|
||||
@@ -54,29 +52,31 @@ fn compatible_freshness_headers<'a>(
|
||||
if !compatible || (!headers.contains_key("cache-control") && !headers.contains_key("pragma")) {
|
||||
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 {
|
||||
return std::borrow::Cow::Borrowed(headers);
|
||||
};
|
||||
let remove_reload = control.contains_key("no-cache")
|
||||
|| control.get("max-age").and_then(|value| value.as_deref())
|
||||
.and_then(|value| value.parse::<u64>().ok()) == Some(0);
|
||||
|| control
|
||||
.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) {
|
||||
return std::borrow::Cow::Borrowed(headers);
|
||||
}
|
||||
let mut freshness = headers.clone();
|
||||
|
||||
let mut changed = false;
|
||||
|
||||
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");
|
||||
for header in headers.get_all("pragma") {
|
||||
let Ok(text) = header.to_str() else {
|
||||
return std::borrow::Cow::Borrowed(headers);
|
||||
};
|
||||
let retained = text.split(',')
|
||||
let retained = text
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|token| !token.eq_ignore_ascii_case("no-cache"))
|
||||
.collect::<Vec<_>>();
|
||||
@@ -95,15 +95,21 @@ fn compatible_freshness_headers<'a>(
|
||||
let Ok(text) = header.to_str() else {
|
||||
return std::borrow::Cow::Borrowed(headers);
|
||||
};
|
||||
let retained = text.split(',').map(str::trim).filter(|token| {
|
||||
let (name, value) = token.split_once('=').unwrap_or((token, ""));
|
||||
let name = name.trim();
|
||||
let skip = name.eq_ignore_ascii_case("no-cache")
|
||||
|| (name.eq_ignore_ascii_case("max-age")
|
||||
&& value.trim().trim_matches('"').parse::<u64>() == Ok(0));
|
||||
if skip { changed = true; }
|
||||
!skip
|
||||
}).collect::<Vec<_>>();
|
||||
let retained = text
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|token| {
|
||||
let (name, value) = token.split_once('=').unwrap_or((token, ""));
|
||||
let name = name.trim();
|
||||
let skip = name.eq_ignore_ascii_case("no-cache")
|
||||
|| (name.eq_ignore_ascii_case("max-age")
|
||||
&& value.trim().trim_matches('"').parse::<u64>() == Ok(0));
|
||||
if skip {
|
||||
changed = true;
|
||||
}
|
||||
!skip
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if !retained.is_empty() {
|
||||
let Ok(value) = HeaderValue::from_str(&retained.join(", ")) else {
|
||||
return std::borrow::Cow::Borrowed(headers);
|
||||
@@ -118,8 +124,11 @@ fn compatible_freshness_headers<'a>(
|
||||
}
|
||||
}
|
||||
}
|
||||
if changed { std::borrow::Cow::Owned(freshness) }
|
||||
else { std::borrow::Cow::Borrowed(headers) }
|
||||
if changed {
|
||||
std::borrow::Cow::Owned(freshness)
|
||||
} else {
|
||||
std::borrow::Cow::Borrowed(headers)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn request_freshness_headers<'a>(
|
||||
@@ -243,8 +252,7 @@ pub fn request_policy(
|
||||
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 {
|
||||
return RequestPolicy::Bypass("request_cache_control_invalid");
|
||||
};
|
||||
|
||||
Vendored
+17
-18
@@ -127,24 +127,23 @@ pub fn plan(
|
||||
Some(v) => httpdate::parse_http_date(v.to_str().ok()?).ok()?,
|
||||
None => now,
|
||||
};
|
||||
let declared_ttl =
|
||||
if override_public_no_cache {
|
||||
Some(settings.default_ttl_seconds)
|
||||
} else if let Some(value) = control.get("s-maxage").or_else(|| control.get("max-age")) {
|
||||
Some(value.as_ref()?.parse::<u64>().ok()?)
|
||||
} else if let Some(expires) = headers.get("expires") {
|
||||
Some(
|
||||
httpdate::parse_http_date(expires.to_str().ok()?)
|
||||
.ok()?
|
||||
.duration_since(date)
|
||||
.ok()?
|
||||
.as_secs(),
|
||||
)
|
||||
} else if control.contains_key("public") {
|
||||
Some(settings.default_ttl_seconds)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let declared_ttl = if override_public_no_cache {
|
||||
Some(settings.default_ttl_seconds)
|
||||
} else if let Some(value) = control.get("s-maxage").or_else(|| control.get("max-age")) {
|
||||
Some(value.as_ref()?.parse::<u64>().ok()?)
|
||||
} else if let Some(expires) = headers.get("expires") {
|
||||
Some(
|
||||
httpdate::parse_http_date(expires.to_str().ok()?)
|
||||
.ok()?
|
||||
.duration_since(date)
|
||||
.ok()?
|
||||
.as_secs(),
|
||||
)
|
||||
} else if control.contains_key("public") {
|
||||
Some(settings.default_ttl_seconds)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let age = match headers.get("age") {
|
||||
Some(v) => v.to_str().ok()?.parse::<u64>().ok()?,
|
||||
None => 0,
|
||||
|
||||
Vendored
+40
-13
@@ -58,14 +58,29 @@ mod scope_tests {
|
||||
fn stale_snapshot_cannot_issue_cache_ticket_after_config_commit() {
|
||||
let cache = Cache::new(1);
|
||||
cache.set_control_revision(7);
|
||||
let ticket = |revision| cache.ticket_scoped_for_config(
|
||||
revision, 1, "vhost-a", "http", "same.test", "main", "/resource", &HeaderMap::new()
|
||||
);
|
||||
let ticket = |revision| {
|
||||
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");
|
||||
cache.set_control_revision(8);
|
||||
assert_eq!(cache.revision(), 1);
|
||||
assert!(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");
|
||||
assert!(
|
||||
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]
|
||||
@@ -82,18 +97,30 @@ mod scope_tests {
|
||||
assert!(effective.enabled && effective.expose_status);
|
||||
let mut globally_disabled = global.clone();
|
||||
globally_disabled.enabled = false;
|
||||
assert!(!Overrides { enabled: Some(true), ..Overrides::default() }
|
||||
assert!(
|
||||
!Overrides {
|
||||
enabled: Some(true),
|
||||
..Overrides::default()
|
||||
}
|
||||
.resolve(&globally_disabled)
|
||||
.unwrap()
|
||||
.enabled);
|
||||
assert!(!Overrides { enabled: Some(false), ..Overrides::default() }
|
||||
.enabled
|
||||
);
|
||||
assert!(
|
||||
!Overrides {
|
||||
enabled: Some(false),
|
||||
..Overrides::default()
|
||||
}
|
||||
.resolve(&global)
|
||||
.unwrap()
|
||||
.enabled);
|
||||
assert!(!Overrides::default()
|
||||
.resolve(&globally_disabled)
|
||||
.unwrap()
|
||||
.enabled);
|
||||
.enabled
|
||||
);
|
||||
assert!(
|
||||
!Overrides::default()
|
||||
.resolve(&globally_disabled)
|
||||
.unwrap()
|
||||
.enabled
|
||||
);
|
||||
assert_eq!(effective.max_bytes, global.max_bytes);
|
||||
assert_eq!(effective.max_ttl_seconds, 30);
|
||||
assert!(Overrides {
|
||||
|
||||
Vendored
+3
-1
@@ -408,6 +408,8 @@ impl Ticket {
|
||||
}
|
||||
|
||||
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
@@ -4,7 +4,10 @@ mod varnish_compatible_tests {
|
||||
|
||||
fn headers(cache_control: &str) -> HeaderMap {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -24,7 +27,10 @@ mod varnish_compatible_tests {
|
||||
assert_eq!(customized.varnish_grace_seconds, 17);
|
||||
let mut invalid = customized.clone();
|
||||
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]
|
||||
@@ -32,18 +38,58 @@ mod varnish_compatible_tests {
|
||||
let mut compatible = Settings::default();
|
||||
for value in ["no-cache", "max-age=0", "max-age=\"0\"", "max-age=000"] {
|
||||
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);
|
||||
assert!(!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");
|
||||
assert!(
|
||||
!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);
|
||||
assert_eq!(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, &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!(request_policy(&Method::GET, &headers("invalid directive"), true, "/", false, &compatible), RequestPolicy::Bypass("request_cache_control_invalid"));
|
||||
assert_eq!(
|
||||
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")));
|
||||
let h = headers("no-cache, max-age=0, min-fresh=45, max-stale=90, only-if-cached");
|
||||
let freshness = request_freshness_headers(&h, &compatible);
|
||||
@@ -55,12 +101,48 @@ mod varnish_compatible_tests {
|
||||
assert!(parsed.contains_key("only-if-cached"));
|
||||
let mut pragma = HeaderMap::new();
|
||||
pragma.insert("pragma", HeaderValue::from_static("no-cache, custom-token"));
|
||||
assert_eq!(request_policy(&Method::GET, &pragma, true, "/", false, &compatible), RequestPolicy::Use);
|
||||
assert_eq!(request_freshness_headers(&pragma, &compatible)["pragma"], "custom-token");
|
||||
assert_eq!(
|
||||
request_policy(&Method::GET, &pragma, true, "/", false, &compatible),
|
||||
RequestPolicy::Use
|
||||
);
|
||||
assert_eq!(
|
||||
request_freshness_headers(&pragma, &compatible)["pragma"],
|
||||
"custom-token"
|
||||
);
|
||||
compatible.varnish_compatible = false;
|
||||
assert_eq!(request_policy(&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"));
|
||||
assert_eq!(
|
||||
request_policy(
|
||||
&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]
|
||||
@@ -73,16 +155,55 @@ mod varnish_compatible_tests {
|
||||
("upgrade", "websocket"),
|
||||
] {
|
||||
let mut h = headers("no-cache");
|
||||
h.insert(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(_)));
|
||||
h.insert(
|
||||
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;
|
||||
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_prefixes = vec!["/private".into()];
|
||||
assert_eq!(request_policy(&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_eq!(
|
||||
request_policy(
|
||||
&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"));
|
||||
}
|
||||
|
||||
@@ -92,7 +213,13 @@ mod varnish_compatible_tests {
|
||||
let mut strict = compatible.clone();
|
||||
strict.varnish_compatible = false;
|
||||
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());
|
||||
}
|
||||
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");
|
||||
assert!(validator_plan.requires_revalidation);
|
||||
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();
|
||||
fallback.default_ttl_seconds = 42;
|
||||
@@ -111,21 +244,48 @@ mod varnish_compatible_tests {
|
||||
assert_eq!(fallback_plan.ttl, 42);
|
||||
assert!(!fallback_plan.requires_revalidation);
|
||||
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();
|
||||
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_eq!(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");
|
||||
assert_eq!(
|
||||
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");
|
||||
private.insert("etag", HeaderValue::from_static("\"secret\""));
|
||||
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"] {
|
||||
let p = plan(StatusCode::OK, &headers(control), &compatible, Duration::ZERO)
|
||||
.expect("zero TTL is retained for max-stale, validation or stale-if-error");
|
||||
for control in [
|
||||
"max-age=0",
|
||||
"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.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");
|
||||
cookie.insert("set-cookie", HeaderValue::from_static("session=private"));
|
||||
@@ -138,24 +298,66 @@ mod varnish_compatible_tests {
|
||||
#[test]
|
||||
fn explicit_zero_grace_and_stale_if_error_are_preserved() {
|
||||
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);
|
||||
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);
|
||||
let mut custom_settings = compatible.clone();
|
||||
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);
|
||||
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);
|
||||
custom_settings.varnish_compatible = false;
|
||||
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);
|
||||
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);
|
||||
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.stale_if_error, 120);
|
||||
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() {
|
||||
let cache = Cache::new(1);
|
||||
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"));
|
||||
*response.headers_mut() = headers("public, no-cache, max-age=600");
|
||||
response.headers_mut().insert("etag", HeaderValue::from_static("\"origin-v1\""));
|
||||
let p = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap();
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("etag", HeaderValue::from_static("\"origin-v1\""));
|
||||
let p = plan(
|
||||
StatusCode::OK,
|
||||
response.headers(),
|
||||
&settings,
|
||||
Duration::ZERO,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(p.requires_revalidation);
|
||||
cache.wrap(response, ticket.clone(), p, &settings).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");
|
||||
cache
|
||||
.wrap(response, ticket.clone(), p, &settings)
|
||||
.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();
|
||||
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\"");
|
||||
}
|
||||
|
||||
@@ -184,14 +416,36 @@ mod varnish_compatible_tests {
|
||||
let cache = Cache::new(1);
|
||||
let mut settings = Settings::default();
|
||||
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"));
|
||||
*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!(!p.requires_revalidation);
|
||||
cache.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");
|
||||
cache
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -199,59 +453,189 @@ mod varnish_compatible_tests {
|
||||
async fn zero_ttl_stale_if_error_fallback_survives_in_varnish_mode() {
|
||||
let cache = Cache::new(1);
|
||||
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"));
|
||||
*response.headers_mut() = headers("public, max-age=0, stale-if-error=120");
|
||||
let p = plan(StatusCode::OK, response.headers(), &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");
|
||||
let p = plan(
|
||||
StatusCode::OK,
|
||||
response.headers(),
|
||||
&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]
|
||||
async fn min_fresh_and_max_stale_still_constrain_compatibility_hits() {
|
||||
let cache = Cache::new(1);
|
||||
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"));
|
||||
*response.headers_mut() = headers("public, max-age=60");
|
||||
let p = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap();
|
||||
cache.wrap(response, ticket.clone(), p, &settings).into_body().collect().await.unwrap();
|
||||
let p = plan(
|
||||
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();
|
||||
for entry in store.entries.values_mut() {
|
||||
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);
|
||||
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 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);
|
||||
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();
|
||||
for entry in store.entries.values_mut() {
|
||||
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 stale = cache.get(&max_stale).expect("max-stale explicitly allows retained stale object");
|
||||
let max_stale = cache
|
||||
.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.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!(
|
||||
!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());
|
||||
let default = cache.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");
|
||||
let default = cache
|
||||
.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]
|
||||
async fn varnish_style_hard_reload_keeps_hot_hit_and_age_without_origin_validator() {
|
||||
let cache = Cache::new(1);
|
||||
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"));
|
||||
*response.headers_mut() = headers("public, max-age=600");
|
||||
let plan = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap();
|
||||
cache.wrap(response, ticket.clone(), plan, &settings).into_body().collect().await.unwrap();
|
||||
let plan = plan(
|
||||
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();
|
||||
for entry in store.entries.values_mut() {
|
||||
@@ -260,27 +644,68 @@ mod varnish_compatible_tests {
|
||||
}
|
||||
let mut request = headers("no-cache, max-age=0");
|
||||
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 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);
|
||||
let compatibility_ticket = cache
|
||||
.ticket(1, "https", "example.test", "app", "/style.css", &request)
|
||||
.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);
|
||||
let again = cache.get(&compatibility_ticket).unwrap();
|
||||
assert_eq!(again.hits, 2, "hard reload must not reset HIT counter");
|
||||
assert_eq!(cache.view().entries, 1);
|
||||
let strict = cache.ticket(1, "https", "example.test", "app", "/style.css", &request).unwrap();
|
||||
assert!(cache.get(&strict).is_none(), "strict max-age=0 requires revalidation");
|
||||
let strict = cache
|
||||
.ticket(1, "https", "example.test", "app", "/style.css", &request)
|
||||
.unwrap();
|
||||
assert!(
|
||||
cache.get(&strict).is_none(),
|
||||
"strict max-age=0 requires revalidation"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sixty_second_grace_and_no_unconditional_stale_outside_window() {
|
||||
let cache = Cache::new(1);
|
||||
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"));
|
||||
*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);
|
||||
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();
|
||||
for entry in store.entries.values_mut() {
|
||||
@@ -289,12 +714,50 @@ mod varnish_compatible_tests {
|
||||
}
|
||||
let hit = cache.get(&ticket).expect("grace HIT");
|
||||
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);
|
||||
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");
|
||||
let max_stale_zero = cache
|
||||
.ticket(
|
||||
1,
|
||||
"https",
|
||||
"example.test",
|
||||
"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);
|
||||
{
|
||||
let mut store = cache.store.lock().unwrap();
|
||||
@@ -302,6 +765,9 @@ mod varnish_compatible_tests {
|
||||
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
@@ -34,14 +34,26 @@ mod tests {
|
||||
headers.append("accept-encoding", "deflate;q=0".parse().unwrap());
|
||||
headers.append("cache-control", "max-age=0".parse().unwrap());
|
||||
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("authorization", "Bearer test".parse().unwrap());
|
||||
headers.insert("x-large-custom", "x".repeat(4096).parse().unwrap());
|
||||
let selected = request_headers(&headers);
|
||||
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("authorization"));
|
||||
@@ -49,7 +61,10 @@ mod tests {
|
||||
let narrow = encode(response(), &selected, &method, &Settings::default()).await;
|
||||
assert_eq!(full.status(), narrow.status());
|
||||
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]
|
||||
fn unaccepted_codecs_cannot_be_reenabled_by_scoped_overrides() {
|
||||
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"] {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("accept-encoding", offer.parse().unwrap());
|
||||
@@ -86,10 +104,16 @@ mod tests {
|
||||
assert!(!selective.gzip);
|
||||
assert!(selective.br);
|
||||
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() }
|
||||
.resolve(&global, &inherited);
|
||||
let disabled = Overrides {
|
||||
enabled: Some(false),
|
||||
..Default::default()
|
||||
}
|
||||
.resolve(&global, &inherited);
|
||||
assert!(!disabled.enabled());
|
||||
|
||||
let parent_disabled = global.scoped(false);
|
||||
@@ -164,7 +188,16 @@ mod tests {
|
||||
.get_all("vary")
|
||||
.iter()
|
||||
.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
|
||||
.headers_mut()
|
||||
.insert("set-cookie", "session=next; HttpOnly".parse().unwrap());
|
||||
dynamic.headers_mut().insert(
|
||||
"cache-control",
|
||||
"private, no-store".parse().unwrap(),
|
||||
);
|
||||
dynamic
|
||||
.headers_mut()
|
||||
.insert("cache-control", "private, no-store".parse().unwrap());
|
||||
let encoded = encode(dynamic, &headers, &Method::GET, &settings).await;
|
||||
assert_eq!(encoded.headers()["content-encoding"], "gzip");
|
||||
}
|
||||
@@ -306,19 +338,23 @@ mod tests {
|
||||
|
||||
let mut no_transform_request = headers.clone();
|
||||
no_transform_request.insert("cache-control", "no-transform".parse().unwrap());
|
||||
assert!(!encode(response(), &no_transform_request, &Method::GET, &settings)
|
||||
.await
|
||||
.headers()
|
||||
.contains_key("content-encoding"));
|
||||
assert!(
|
||||
!encode(response(), &no_transform_request, &Method::GET, &settings)
|
||||
.await
|
||||
.headers()
|
||||
.contains_key("content-encoding")
|
||||
);
|
||||
|
||||
let mut no_transform_response = response();
|
||||
no_transform_response
|
||||
.headers_mut()
|
||||
.insert("cache-control", "public, no-transform".parse().unwrap());
|
||||
assert!(!encode(no_transform_response, &headers, &Method::GET, &settings)
|
||||
.await
|
||||
.headers()
|
||||
.contains_key("content-encoding"));
|
||||
assert!(
|
||||
!encode(no_transform_response, &headers, &Method::GET, &settings)
|
||||
.await
|
||||
.headers()
|
||||
.contains_key("content-encoding")
|
||||
);
|
||||
|
||||
let mut sse = response();
|
||||
sse.headers_mut()
|
||||
|
||||
+7
-2
@@ -72,7 +72,11 @@ pub struct Config {
|
||||
pub host_policies: Vec<HostPolicy>,
|
||||
// Preserve old 0.34.13 and earlier config/backup data until compilation migrates it.
|
||||
// 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>,
|
||||
#[serde(default)]
|
||||
pub global_security: GlobalSecurity,
|
||||
@@ -124,7 +128,8 @@ impl Config {
|
||||
|
||||
impl GlobalSecurity {
|
||||
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)
|
||||
.compile()?;
|
||||
self.country_access.validate()?;
|
||||
|
||||
+68
-16
@@ -1189,21 +1189,41 @@ mod tests {
|
||||
fn old_configuration_retains_user_agent_policy_and_global_security_defaults_off() {
|
||||
let config: Config = serde_json::from_value(serde_json::json!({
|
||||
"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_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.rate_limit.enabled);
|
||||
assert!(!config.global_security.security_headers.hsts.enabled);
|
||||
let snapshot = compile(config, 1, &policy()).unwrap();
|
||||
let mut headers = http::HeaderMap::new();
|
||||
headers.insert(http::header::USER_AGENT, 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());
|
||||
headers.insert(
|
||||
http::header::USER_AGENT,
|
||||
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();
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -1214,7 +1234,8 @@ mod tests {
|
||||
"global_security": {
|
||||
"user_agent_blocking": { "enabled":false,"patterns":[] }
|
||||
}
|
||||
})).unwrap();
|
||||
}))
|
||||
.unwrap();
|
||||
let err = compile(old_and_new, 1, &policy()).unwrap_err().to_string();
|
||||
assert!(err.contains("conflicting legacy"), "{err}");
|
||||
}
|
||||
@@ -1228,12 +1249,20 @@ mod tests {
|
||||
});
|
||||
let json = serde_json::to_value(&config).unwrap();
|
||||
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 snapshot = compile(restored, 1, &policy()).unwrap();
|
||||
let mut headers = http::HeaderMap::new();
|
||||
headers.insert(http::header::USER_AGENT, http::HeaderValue::from_static("trusted-test-bot/1"));
|
||||
assert!(snapshot.global_security.user_agent_blocking.blocks(&headers));
|
||||
headers.insert(
|
||||
http::header::USER_AGENT,
|
||||
http::HeaderValue::from_static("trusted-test-bot/1"),
|
||||
);
|
||||
assert!(snapshot
|
||||
.global_security
|
||||
.user_agent_blocking
|
||||
.blocks(&headers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1260,16 +1289,35 @@ mod tests {
|
||||
|
||||
let snapshot = compile(config, 2, &policy()).unwrap();
|
||||
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(None));
|
||||
assert!(!rules.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
|
||||
.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::GET));
|
||||
assert!(rules.rate_limit.enabled);
|
||||
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));
|
||||
headers.clear();
|
||||
rules.headers.apply(&mut headers, true);
|
||||
@@ -1292,7 +1340,11 @@ mod tests {
|
||||
config.global_security.security_headers.hsts.preload = true;
|
||||
assert!(compile(config.clone(), 1, &policy()).is_err());
|
||||
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());
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use crate::config::{Config, Envelope};
|
||||
mod api_tokens;
|
||||
mod certificate_api;
|
||||
mod dashboard_presets;
|
||||
mod history;
|
||||
mod api_tokens;
|
||||
pub use api_tokens::{ApiTokenGrant, ApiTokenMetadata, API_TOKEN_CAPACITY};
|
||||
use anyhow::{ensure, Context, Result};
|
||||
pub use api_tokens::{ApiTokenGrant, ApiTokenMetadata, API_TOKEN_CAPACITY};
|
||||
pub use history::{ConfigurationHistoryDetail, ConfigurationRevision};
|
||||
use serde::Serialize;
|
||||
use sqlx::{
|
||||
|
||||
+2
-1
@@ -21,7 +21,8 @@ impl Diagnostics {
|
||||
tokio::spawn(async move {
|
||||
let mut previous: Option<(View, Counters)> = None;
|
||||
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 {
|
||||
*sampler.latest.write().unwrap_or_else(|e| e.into_inner()) = view.clone();
|
||||
previous = Some((view, counters));
|
||||
|
||||
+68
-29
@@ -221,8 +221,15 @@ impl Compiled {
|
||||
include_original_url: bool,
|
||||
) -> Result<Context> {
|
||||
self.context_with_peer(
|
||||
incoming, peer, local, tls, authority, uri, include_original_url,
|
||||
None, false,
|
||||
incoming,
|
||||
peer,
|
||||
local,
|
||||
tls,
|
||||
authority,
|
||||
uri,
|
||||
include_original_url,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -245,7 +252,9 @@ impl Compiled {
|
||||
if !trusted_peer {
|
||||
let scheme = if tls { "https" } else { "http" };
|
||||
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 original_url = if include_original_url {
|
||||
format!("{scheme}://{authority_text}{target}")
|
||||
@@ -267,12 +276,19 @@ impl Compiled {
|
||||
};
|
||||
let by_text = cached.is_none().then(|| node(canonical(local.ip())));
|
||||
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(),
|
||||
);
|
||||
let by_node = cached.map_or_else(|| by_text.as_deref().unwrap_or(""),
|
||||
|cached| cached.by_node.as_str());
|
||||
let by_node = cached.map_or_else(
|
||||
|| by_text.as_deref().unwrap_or(""),
|
||||
|cached| cached.by_node.as_str(),
|
||||
);
|
||||
let forwarded = format!(
|
||||
"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-forwarded-host", authority.clone());
|
||||
values.insert("x-forwarded-proto", HeaderValue::from_static(scheme));
|
||||
values.insert("x-forwarded-port", if let Some(cached) = cached {
|
||||
cached.port_header.clone()
|
||||
} else {
|
||||
local.port().to_string().parse()?
|
||||
});
|
||||
values.insert(
|
||||
"x-forwarded-port",
|
||||
if let Some(cached) = cached {
|
||||
cached.port_header.clone()
|
||||
} else {
|
||||
local.port().to_string().parse()?
|
||||
},
|
||||
);
|
||||
if let Some(peer) = cached {
|
||||
peer.remember_untrusted(authority, tls, &values);
|
||||
}
|
||||
@@ -320,7 +339,9 @@ impl Compiled {
|
||||
};
|
||||
}
|
||||
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_url = if include_original_url {
|
||||
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 xff = String::with_capacity(ips.len() * 44);
|
||||
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=");
|
||||
if index + 1 == ips.len() {
|
||||
if let Some(cached) = cached { forwarded.push_str(&cached.peer_node); }
|
||||
else { forwarded.push_str(&node(*ip)); }
|
||||
if let Some(cached) = cached {
|
||||
forwarded.push_str(&cached.peer_node);
|
||||
} else {
|
||||
forwarded.push_str(&node(*ip));
|
||||
}
|
||||
} else {
|
||||
forwarded.push_str(&node(*ip));
|
||||
}
|
||||
if index + 1 == ips.len() {
|
||||
if let Some(cached) = cached { xff.push_str(cached.peer_header.to_str()?); }
|
||||
else { write!(&mut xff, "{ip}")?; }
|
||||
if let Some(cached) = cached {
|
||||
xff.push_str(cached.peer_header.to_str()?);
|
||||
} else {
|
||||
write!(&mut xff, "{ip}")?;
|
||||
}
|
||||
} else {
|
||||
write!(&mut xff, "{ip}")?;
|
||||
}
|
||||
}
|
||||
forwarded.push_str(";by=");
|
||||
if let Some(cached) = cached { forwarded.push_str(&cached.by_node); }
|
||||
else { forwarded.push_str(&node(canonical(local.ip()))); }
|
||||
if let Some(cached) = cached {
|
||||
forwarded.push_str(&cached.by_node);
|
||||
} else {
|
||||
forwarded.push_str(&node(canonical(local.ip())));
|
||||
}
|
||||
write!(&mut forwarded, ";proto={scheme};host=\"{authority_text}\"")?;
|
||||
let mut values = HeaderMap::with_capacity(7);
|
||||
values.insert("forwarded", forwarded.parse()?);
|
||||
@@ -364,11 +397,14 @@ impl Compiled {
|
||||
} else {
|
||||
local.port()
|
||||
};
|
||||
values.insert("x-forwarded-port", if cached.is_some() && !(trusted_peer && self.trust_proto) {
|
||||
cached.expect("tested above").port_header.clone()
|
||||
} else {
|
||||
port.to_string().parse()?
|
||||
});
|
||||
values.insert(
|
||||
"x-forwarded-port",
|
||||
if cached.is_some() && !(trusted_peer && self.trust_proto) {
|
||||
cached.expect("tested above").port_header.clone()
|
||||
} else {
|
||||
port.to_string().parse()?
|
||||
},
|
||||
);
|
||||
values.insert("x-forwarded-uri", original_target.parse()?);
|
||||
Ok(Context {
|
||||
client_ip,
|
||||
@@ -423,14 +459,15 @@ impl Context {
|
||||
for name in headers.keys() {
|
||||
let text = name.as_str();
|
||||
if matches!(text, "x-original-url" | "x-original-method")
|
||||
|| (text.starts_with("x-forwarded-")
|
||||
&& !self.values.contains_key(text))
|
||||
|| (text.starts_with("x-forwarded-") && !self.values.contains_key(text))
|
||||
{
|
||||
remove.get_or_insert_with(Vec::new).push(name.clone());
|
||||
}
|
||||
}
|
||||
if let Some(remove) = remove {
|
||||
for name in remove { headers.remove(name); }
|
||||
for name in remove {
|
||||
headers.remove(name);
|
||||
}
|
||||
}
|
||||
if !include_client_ip {
|
||||
for name in ["forwarded", "x-forwarded-for", "x-real-ip"] {
|
||||
@@ -438,7 +475,9 @@ impl Context {
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
headers.insert(name.clone(), value.clone());
|
||||
|
||||
+25
-6
@@ -1,11 +1,11 @@
|
||||
mod mmdb;
|
||||
mod update;
|
||||
mod update_http;
|
||||
pub(crate) use update::{poll_loop as update_poll_loop, update_now};
|
||||
pub use update::{
|
||||
UpdateResult, UpdateSettings, DEFAULT_INTERVAL_SECONDS, DEFAULT_IPV4_URL, DEFAULT_IPV6_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 serde::{Deserialize, Serialize};
|
||||
@@ -46,7 +46,10 @@ impl Default for DatabaseFormat {
|
||||
|
||||
#[derive(Debug)]
|
||||
enum Backend {
|
||||
Csv { ipv4: Box<[V4Range]>, ipv6: Box<[V6Range]> },
|
||||
Csv {
|
||||
ipv4: Box<[V4Range]>,
|
||||
ipv6: Box<[V6Range]>,
|
||||
},
|
||||
Mmdb(mmdb::Database),
|
||||
}
|
||||
|
||||
@@ -64,7 +67,12 @@ pub struct View {
|
||||
}
|
||||
impl Database {
|
||||
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> {
|
||||
if directory.join("country.mmdb").is_file() {
|
||||
@@ -78,11 +86,22 @@ impl Database {
|
||||
DatabaseFormat::CsvPair => {
|
||||
let ipv4 = parse_v4(&directory.join("user-country-ipv4.csv"))?;
|
||||
let ipv6 = parse_v6(&directory.join("user-country-ipv6.csv"))?;
|
||||
ensure!(!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() } })
|
||||
ensure!(
|
||||
!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 {
|
||||
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
@@ -36,7 +36,10 @@ struct Budget {
|
||||
impl Budget {
|
||||
fn charge(&mut self) -> Result<()> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -45,20 +48,56 @@ impl Database {
|
||||
pub(super) fn load(path: &Path, max_bytes: u64) -> Result<Self> {
|
||||
let metadata = fs::symlink_metadata(path)
|
||||
.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!(metadata.len() <= max_bytes, "GeoIP MMDB exceeds configured maximum size");
|
||||
let bytes = fs::read(path).with_context(|| format!("read GeoIP MMDB {}", path.display()))?;
|
||||
ensure!(
|
||||
metadata.is_file() && !metadata.file_type().is_symlink(),
|
||||
"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 metadata_offset = marker + METADATA_MARKER.len();
|
||||
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 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 node_count = u32::try_from(read_uint_path(
|
||||
&bytes,
|
||||
metadata_offset,
|
||||
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"])?;
|
||||
ensure!(node_count > 0, "MMDB node_count must be positive");
|
||||
ensure!(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!(
|
||||
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}");
|
||||
let node_bytes = usize::from(record_size)
|
||||
.checked_mul(2)
|
||||
@@ -67,9 +106,17 @@ impl Database {
|
||||
let search_tree_size = (node_count as usize)
|
||||
.checked_mul(node_bytes)
|
||||
.context("MMDB search tree size overflow")?;
|
||||
let data_section_start = search_tree_size.checked_add(16).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");
|
||||
let data_section_start = search_tree_size
|
||||
.checked_add(16)
|
||||
.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 {
|
||||
bytes,
|
||||
node_count,
|
||||
@@ -100,7 +147,8 @@ impl Database {
|
||||
}
|
||||
}
|
||||
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) {
|
||||
return Some(String::from_utf8_lossy(&code).into_owned());
|
||||
}
|
||||
@@ -135,17 +183,27 @@ impl Database {
|
||||
if node == self.node_count {
|
||||
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)
|
||||
.checked_sub(self.node_count as usize)
|
||||
.and_then(|value| value.checked_add(self.search_tree_size))
|
||||
.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))
|
||||
}
|
||||
|
||||
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 {
|
||||
24 => node as usize * 6,
|
||||
28 => node as usize * 7,
|
||||
@@ -170,7 +228,9 @@ impl Database {
|
||||
}
|
||||
|
||||
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..]
|
||||
.windows(METADATA_MARKER.len())
|
||||
.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> {
|
||||
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)
|
||||
}
|
||||
|
||||
fn read_string_path(bytes: &[u8], offset: usize, base: usize, path: &[&str]) -> Result<String> {
|
||||
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)
|
||||
}
|
||||
|
||||
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");
|
||||
budget.charge()?;
|
||||
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 {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -218,7 +289,13 @@ fn find_path(bytes: &[u8], offset: usize, base: usize, path: &[&str], depth: usi
|
||||
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");
|
||||
budget.charge()?;
|
||||
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);
|
||||
}
|
||||
ensure!(h.kind == 2, "MMDB value is not a string");
|
||||
ensure!(h.size <= MAX_STRING_BYTES, "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())
|
||||
ensure!(
|
||||
h.size <= MAX_STRING_BYTES,
|
||||
"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");
|
||||
budget.charge()?;
|
||||
let h = header(bytes, offset, base)?;
|
||||
if let Some(pointer) = h.pointer {
|
||||
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");
|
||||
read_be(bytes, h.payload, h.size)
|
||||
}
|
||||
|
||||
fn inline_string<'a>(bytes: &'a [u8], offset: usize, base: usize) -> Result<(&'a str, usize)> {
|
||||
let h = header(bytes, offset, base)?;
|
||||
ensure!(h.pointer.is_none() && h.kind == 2, "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))
|
||||
ensure!(
|
||||
h.pointer.is_none() && h.kind == 2,
|
||||
"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");
|
||||
budget.charge()?;
|
||||
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 pointer = match class {
|
||||
0 => (low << 8) | raw,
|
||||
1 => ((low << 16) | raw).checked_add(2048).context("MMDB pointer overflow")?,
|
||||
2 => ((low << 24) | raw).checked_add(526_336).context("MMDB pointer overflow")?,
|
||||
1 => ((low << 16) | raw)
|
||||
.checked_add(2048)
|
||||
.context("MMDB pointer overflow")?,
|
||||
2 => ((low << 24) | raw)
|
||||
.checked_add(526_336)
|
||||
.context("MMDB pointer overflow")?,
|
||||
3 => raw,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let target = base.checked_add(pointer).context("MMDB pointer overflow")?;
|
||||
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 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;
|
||||
extended.checked_add(7).context("MMDB type overflow")?
|
||||
} 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,
|
||||
_ => 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 {
|
||||
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");
|
||||
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> {
|
||||
ensure!(count <= 8, "MMDB integer is too wide");
|
||||
let end = offset.checked_add(count).context("MMDB read overflow")?;
|
||||
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)]
|
||||
|
||||
+12
-3
@@ -59,7 +59,10 @@ mod tests {
|
||||
std::fs::write(&path, fixture("PL")).unwrap();
|
||||
let database = Database::load(&path, 1024 * 1024).unwrap();
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -69,7 +72,10 @@ mod tests {
|
||||
let path = directory.path().join("country.mmdb");
|
||||
std::fs::write(&path, sapics_fixture("DE")).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]
|
||||
@@ -78,7 +84,10 @@ mod tests {
|
||||
let path = directory.path().join("country.mmdb");
|
||||
std::fs::write(&path, fixture_with_record(string("FR"))).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]
|
||||
|
||||
+28
-7
@@ -6,14 +6,32 @@ mod tests {
|
||||
assert_eq!(std::mem::size_of::<V6Range>(), 34);
|
||||
assert_eq!(std::mem::size_of::<V4Range>(), 12);
|
||||
let ranges = [
|
||||
V6Range { start: 0u128.to_be_bytes(), 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" },
|
||||
V6Range {
|
||||
start: 0u128.to_be_bytes(),
|
||||
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 [
|
||||
(0, Some(*b"PL")), (127, Some(*b"PL")), (128, None),
|
||||
(255, None), (256, Some(*b"DE")), (65535, Some(*b"DE")),
|
||||
(65536, None), (1u128 << 120, Some(*b"US")), (u128::MAX, Some(*b"US")),
|
||||
(0, Some(*b"PL")),
|
||||
(127, Some(*b"PL")),
|
||||
(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);
|
||||
}
|
||||
@@ -33,7 +51,10 @@ mod tests {
|
||||
)
|
||||
.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());
|
||||
}
|
||||
}
|
||||
+178
-50
@@ -94,9 +94,12 @@ impl UpdateSettings {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.ipv4_checksum_url = validate_optional_url("GeoIP IPv4 checksum URL", self.ipv4_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())?;
|
||||
self.ipv4_checksum_url =
|
||||
validate_optional_url("GeoIP IPv4 checksum URL", self.ipv4_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!(
|
||||
(1024 * 1024..=MAX_CONFIGURED_BYTES).contains(&self.max_bytes),
|
||||
"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}"))?;
|
||||
ensure!(parsed.scheme() == "https", "{label} must use https://");
|
||||
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!(parsed.fragment().is_none(), "{label} must not contain a fragment");
|
||||
ensure!(
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
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!(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");
|
||||
ensure!(
|
||||
parsed.scheme() == "https" && parsed.host_str().is_some(),
|
||||
"{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('/') {
|
||||
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;
|
||||
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() {
|
||||
Ok(()) => Ok(Some(UpdateLock(file))),
|
||||
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 next_attempt = tokio::time::Instant::now();
|
||||
loop {
|
||||
if shutdown.is_cancelled() { return; }
|
||||
let Some(app) = app.upgrade() else { return; };
|
||||
if shutdown.is_cancelled() {
|
||||
return;
|
||||
}
|
||||
let Some(app) = app.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let settings = app.config.load().envelope.config.geoip_updates.clone();
|
||||
if observed.as_ref() != Some(&settings) {
|
||||
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)
|
||||
}
|
||||
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) => {
|
||||
tracing::warn!(error = %format!("{error:#}"), "GeoIP update failed; proxy traffic is unaffected");
|
||||
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;
|
||||
}
|
||||
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 {
|
||||
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)]
|
||||
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::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> {
|
||||
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); };
|
||||
async fn update_once(
|
||||
app: &Arc<crate::runtime::App>,
|
||||
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");
|
||||
ensure_safe_directory(&directory)?;
|
||||
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());
|
||||
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?;
|
||||
return Ok(UpdateOutcome::Current);
|
||||
}
|
||||
@@ -307,35 +359,64 @@ async fn update_once(app: &Arc<crate::runtime::App>, settings: &UpdateSettings)
|
||||
let result = async {
|
||||
for (index, asset) in assets.iter().enumerate() {
|
||||
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)?;
|
||||
} 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 format = settings.format;
|
||||
tokio::task::spawn_blocking(move || super::validate_directory_format(&validation_stage, format))
|
||||
.await.context("GeoIP validation worker failed")??;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
super::validate_directory_format(&validation_stage, format)
|
||||
})
|
||||
.await
|
||||
.context("GeoIP validation worker failed")??;
|
||||
|
||||
let mut changed = Vec::new();
|
||||
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()) {
|
||||
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());
|
||||
}
|
||||
}
|
||||
reload(app, directory.clone(), settings.format).await?;
|
||||
Ok::<Vec<String>, anyhow::Error>(changed)
|
||||
}.await;
|
||||
}
|
||||
.await;
|
||||
let _ = fs::remove_dir_all(&stage);
|
||||
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<()> {
|
||||
let expected = match format { super::DatabaseFormat::CsvPair => "csv_pair", super::DatabaseFormat::Mmdb => "mmdb" };
|
||||
async fn reload_if_needed(
|
||||
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();
|
||||
if !view.loaded || view.format != expected {
|
||||
reload(app, directory, format).await?;
|
||||
@@ -343,9 +424,15 @@ async fn reload_if_needed(app: &Arc<crate::runtime::App>, directory: PathBuf, fo
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn reload(app: &Arc<crate::runtime::App>, 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")??;
|
||||
async fn reload(
|
||||
app: &Arc<crate::runtime::App>,
|
||||
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);
|
||||
Ok(())
|
||||
}
|
||||
@@ -365,7 +452,10 @@ async fn publisher_checksum(url: &str) -> Result<String> {
|
||||
|
||||
fn ensure_safe_directory(directory: &Path) -> Result<()> {
|
||||
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) => return Err(error.into()),
|
||||
}
|
||||
@@ -375,7 +465,10 @@ fn ensure_safe_directory(directory: &Path) -> Result<()> {
|
||||
fn private_directory(path: &Path) -> Result<()> {
|
||||
fs::create_dir_all(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)]
|
||||
{
|
||||
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) => return Err(error.into()),
|
||||
};
|
||||
if !metadata.is_file() || metadata.file_type().is_symlink() || metadata.len() > max_bytes { return Ok(None); }
|
||||
let mut options = OpenOptions::new(); options.read(true);
|
||||
#[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; options.custom_flags(libc::O_NOFOLLOW); }
|
||||
if !metadata.is_file() || metadata.file_type().is_symlink() || metadata.len() > max_bytes {
|
||||
return Ok(None);
|
||||
}
|
||||
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 digest = Sha256::new();
|
||||
let mut buffer = [0u8; 1024 * 1024];
|
||||
loop {
|
||||
let read = file.read(&mut buffer)?;
|
||||
if read == 0 { break; }
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
digest.update(&buffer[..read]);
|
||||
}
|
||||
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<()> {
|
||||
let metadata = fs::symlink_metadata(source)?;
|
||||
ensure!(metadata.is_file() && !metadata.file_type().is_symlink(), "GeoIP source must be a regular file");
|
||||
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)] {
|
||||
ensure!(
|
||||
metadata.is_file() && !metadata.file_type().is_symlink(),
|
||||
"GeoIP source must be a regular file"
|
||||
);
|
||||
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;
|
||||
source_options.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 output = target_options.open(target)?;
|
||||
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()?;
|
||||
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<()> {
|
||||
let parent = target.parent().context("GeoIP target has no parent")?;
|
||||
ensure_safe_directory(parent)?;
|
||||
let temporary = parent.join(format!(".{}.tmp-{:016x}", target.file_name().unwrap_or_default().to_string_lossy(), 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)] {
|
||||
let temporary = parent.join(format!(
|
||||
".{}.tmp-{:016x}",
|
||||
target.file_name().unwrap_or_default().to_string_lossy(),
|
||||
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;
|
||||
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()?;
|
||||
}
|
||||
Ok(())
|
||||
|
||||
+10
-13
@@ -9,12 +9,7 @@ use hyper_util::{
|
||||
};
|
||||
use rustls::ClientConfig;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
fs::OpenOptions,
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
use std::{fs::OpenOptions, path::Path, sync::Arc, time::Duration};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use url::Url;
|
||||
|
||||
@@ -66,12 +61,11 @@ pub(super) async fn download(
|
||||
|
||||
fn client() -> Result<Client<hyper_rustls::HttpsConnector<HttpConnector>, Body>> {
|
||||
let roots = crate::update_check::load_root_store();
|
||||
let tls = ClientConfig::builder_with_provider(Arc::new(
|
||||
rustls::crypto::ring::default_provider(),
|
||||
))
|
||||
.with_safe_default_protocol_versions()?
|
||||
.with_root_certificates(roots)
|
||||
.with_no_client_auth();
|
||||
let tls =
|
||||
ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
|
||||
.with_safe_default_protocol_versions()?
|
||||
.with_root_certificates(roots)
|
||||
.with_no_client_auth();
|
||||
let mut http = HttpConnector::new();
|
||||
http.enforce_http(false);
|
||||
http.set_connect_timeout(Some(REQUEST_TIMEOUT));
|
||||
@@ -123,7 +117,10 @@ fn redirect(
|
||||
.to_str()
|
||||
.context("GeoIP redirect Location is not UTF-8")?;
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -76,19 +76,36 @@ mod tests {
|
||||
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();
|
||||
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();
|
||||
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(&ipv6, "2001:db8::,2001:db8::ffff,DE\n").unwrap();
|
||||
publish_database(
|
||||
&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!(previous.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("DE")
|
||||
);
|
||||
assert_eq!(
|
||||
previous.country("192.0.2.7".parse().unwrap()).as_deref(),
|
||||
Some("PL")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,10 @@ mod tests {
|
||||
response: vec![],
|
||||
};
|
||||
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}");
|
||||
}
|
||||
let rules = HeaderRules {
|
||||
|
||||
+4
-4
@@ -3,8 +3,8 @@ pub mod acme;
|
||||
pub mod admin;
|
||||
#[path = "auth/mod.rs"]
|
||||
pub mod auth;
|
||||
pub mod basic_auth;
|
||||
pub mod backup;
|
||||
pub mod basic_auth;
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod demo;
|
||||
@@ -17,26 +17,26 @@ pub mod runtime;
|
||||
pub mod server;
|
||||
pub mod tls;
|
||||
pub mod traffic;
|
||||
pub mod upstream;
|
||||
pub mod update_check;
|
||||
pub mod upstream;
|
||||
pub mod user_agent;
|
||||
|
||||
pub mod cache;
|
||||
pub mod compression;
|
||||
pub mod transform;
|
||||
pub mod diagnostics;
|
||||
pub mod docker;
|
||||
pub mod pages;
|
||||
pub mod settings;
|
||||
pub mod stream;
|
||||
pub mod system_stats;
|
||||
pub mod transform;
|
||||
|
||||
pub mod balance;
|
||||
pub mod forwarding;
|
||||
pub mod geoip;
|
||||
pub mod http3;
|
||||
pub mod security;
|
||||
pub mod secret_store;
|
||||
pub mod security;
|
||||
pub mod tinyauth;
|
||||
pub mod vhost;
|
||||
pub mod web_server;
|
||||
|
||||
@@ -119,7 +119,11 @@ fn render_backends(
|
||||
),
|
||||
("read_timeouts_total", "read_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",
|
||||
@@ -414,7 +418,11 @@ fn render_docker(
|
||||
),
|
||||
("read_timeouts_total", "read_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",
|
||||
|
||||
+13
-6
@@ -50,9 +50,8 @@ mod tests {
|
||||
async fn optimized_empty_request_keeps_exchange_until_response() {
|
||||
let (exchange, slots) = observation().await;
|
||||
let metrics = exchange.metrics.clone();
|
||||
let body = super::super::wrap_observed_request(
|
||||
Body::empty(), &exchange, Duration::from_secs(1),
|
||||
);
|
||||
let body =
|
||||
super::super::wrap_observed_request(Body::empty(), &exchange, Duration::from_secs(1));
|
||||
assert!(body.is_end_stream());
|
||||
drop(body);
|
||||
assert_eq!(slots.available_permits(), 0);
|
||||
@@ -66,7 +65,9 @@ mod tests {
|
||||
let (exchange, slots) = observation().await;
|
||||
let metrics = exchange.metrics.clone();
|
||||
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());
|
||||
drop(exchange);
|
||||
@@ -272,7 +273,10 @@ mod tests {
|
||||
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].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].websocket_error, Some("upstream_http_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].path, "/echo");
|
||||
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(1), Some(false));
|
||||
assert_eq!(read_effective(2), Some(true));
|
||||
|
||||
@@ -268,7 +268,10 @@ fn finish_headers(
|
||||
active: &[crate::vhost::Listener],
|
||||
) {
|
||||
// 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 {
|
||||
frontend.security.apply(response.headers_mut(), tls);
|
||||
}
|
||||
|
||||
@@ -9,19 +9,28 @@ fn cache_http_invalidation_request(
|
||||
scheme: &str,
|
||||
request: &Request,
|
||||
) -> 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 lengths.next().is_some() || length.as_bytes() != b"0" {
|
||||
return Err("BAN/PURGE must not contain a request body");
|
||||
}
|
||||
}
|
||||
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()
|
||||
{
|
||||
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('/') {
|
||||
return Err("invalid BAN/PURGE target");
|
||||
}
|
||||
@@ -35,23 +44,40 @@ fn cache_http_invalidation_request(
|
||||
} else {
|
||||
crate::cache::InvalidationSelector::Exact
|
||||
};
|
||||
if request.headers().get_all("x-rustproxy-ban-ttl").iter().count() > 1
|
||||
|| request.headers().get_all("x-rustproxy-confirm-wide").iter().count() > 1
|
||||
if request
|
||||
.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");
|
||||
}
|
||||
let mut ttl_seconds = None;
|
||||
if method.as_str() == "BAN" {
|
||||
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")?;
|
||||
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);
|
||||
}
|
||||
} else if request.headers().contains_key("x-rustproxy-ban-ttl") {
|
||||
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");
|
||||
if target == "/" && method.as_str() == "BAN" && !confirm_wide {
|
||||
return Err("BAN / requires X-RustProxy-Confirm-Wide: 1");
|
||||
@@ -71,9 +97,13 @@ fn cache_http_invalidation_request(
|
||||
host.to_owned()
|
||||
};
|
||||
Ok(crate::cache::InvalidationRequest {
|
||||
operation, selector, value: format!("{scheme}://{authority}{target}"),
|
||||
host: Some(host.to_owned()), vhost: Some(vhost.to_owned()),
|
||||
ttl_seconds, confirm_wide,
|
||||
operation,
|
||||
selector,
|
||||
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,
|
||||
) -> Response {
|
||||
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)
|
||||
&& crate::vhost::canonical_ip(peer_ip) == client_ip
|
||||
if snapshot.forwarding.trusted(peer_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) {
|
||||
return error(StatusCode::FORBIDDEN, "RustProxy: BAN/PURGE client IP is not allowed\n");
|
||||
}
|
||||
let requested = match cache_http_invalidation_request(request.method(), host, &frontend.id, scheme, &request) {
|
||||
let requested = match cache_http_invalidation_request(
|
||||
request.method(),
|
||||
host,
|
||||
&frontend.id,
|
||||
scheme,
|
||||
&request,
|
||||
) {
|
||||
Ok(requested) => requested,
|
||||
Err(reason) => return error(StatusCode::BAD_REQUEST, reason),
|
||||
};
|
||||
let method = request.method().clone();
|
||||
drop(request);
|
||||
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!(
|
||||
"client_ip={client_ip}; method={}; host={host}; vhost={}; target={:?}",
|
||||
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");
|
||||
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 result = tokio::task::spawn_blocking(move || {
|
||||
let _permit = permit;
|
||||
cache.invalidate(requested)
|
||||
}).await;
|
||||
})
|
||||
.await;
|
||||
let result = match result {
|
||||
Ok(Ok(result)) => result,
|
||||
Ok(Err(invalid)) => {
|
||||
tracing::warn!(reason = %invalid, "cache HTTP invalidation rejected");
|
||||
return error(StatusCode::UNPROCESSABLE_ENTITY, "RustProxy: invalid cache maintenance request\n");
|
||||
},
|
||||
Err(_) => return error(StatusCode::INTERNAL_SERVER_ERROR, "RustProxy: cache maintenance failed\n"),
|
||||
return error(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"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 {
|
||||
StatusCode::ACCEPTED
|
||||
} else { StatusCode::OK };
|
||||
} else {
|
||||
StatusCode::OK
|
||||
};
|
||||
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
|
||||
}
|
||||
|
||||
@@ -2,14 +2,25 @@ mod http_invalidation_request_tests {
|
||||
use super::*;
|
||||
|
||||
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)
|
||||
-> Result<crate::cache::InvalidationRequest, &'static str>
|
||||
{
|
||||
fn parse(
|
||||
method: &str,
|
||||
host: &str,
|
||||
scheme: &str,
|
||||
request: &Request,
|
||||
) -> Result<crate::cache::InvalidationRequest, &'static str> {
|
||||
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() {
|
||||
assert!(parse("BAN", "a.example", "https", &request("BAN", "/")).is_err());
|
||||
let mut wide = request("BAN", "/");
|
||||
wide.headers_mut().insert("x-rustproxy-confirm-wide", "1".parse().unwrap());
|
||||
assert!(parse("BAN", "a.example", "https", &wide).unwrap().confirm_wide);
|
||||
wide.headers_mut()
|
||||
.insert("x-rustproxy-confirm-wide", "1".parse().unwrap());
|
||||
assert!(
|
||||
parse("BAN", "a.example", "https", &wide)
|
||||
.unwrap()
|
||||
.confirm_wide
|
||||
);
|
||||
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());
|
||||
ttl.headers_mut().insert("x-rustproxy-ban-ttl", "30".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());
|
||||
ttl.headers_mut()
|
||||
.insert("x-rustproxy-ban-ttl", "30".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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maintenance_rejects_bodies_and_ambiguous_or_unsupported_controls() {
|
||||
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());
|
||||
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());
|
||||
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());
|
||||
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());
|
||||
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());
|
||||
unexpected = request("BAN", "/foo");
|
||||
unexpected.headers_mut().append("x-rustproxy-confirm-wide", "1".parse().unwrap());
|
||||
unexpected.headers_mut().append("x-rustproxy-confirm-wide", "1".parse().unwrap());
|
||||
unexpected
|
||||
.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("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());
|
||||
}
|
||||
}
|
||||
@@ -16,19 +16,33 @@ fn disabled_cache_retains_reason_without_matching_force_rules_or_reparsing_heade
|
||||
let settings = settings();
|
||||
let mut headers = HeaderMap::new();
|
||||
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 [
|
||||
"cache_disabled", "route_disabled", "admin_control_plane", "basic_auth",
|
||||
"tinyauth", "sticky_cookie", "request_upgrade",
|
||||
"cache_disabled",
|
||||
"route_disabled",
|
||||
"admin_control_plane",
|
||||
"basic_auth",
|
||||
"tinyauth",
|
||||
"sticky_cookie",
|
||||
"request_upgrade",
|
||||
] {
|
||||
let plan = CachePlan::new(Some(reason), &settings, "/style.css", || {
|
||||
panic!("disabled cache must not match force-cache rules")
|
||||
});
|
||||
assert!(!plan.force_cache());
|
||||
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!(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] {
|
||||
let plan = CachePlan::new(None, &settings, "/style.css", || forced);
|
||||
for (name, value) in [
|
||||
("x-irrelevant", "yes"), ("cache-control", "no-store"),
|
||||
("cache-control", "no-cache"), ("cache-control", "max-age=0"),
|
||||
("cache-control", "min-fresh=broken"), ("authorization", "private"),
|
||||
("cookie", "session=private"), ("range", "bytes=0-2"),
|
||||
("range", "bytes=0-2,4-5"), ("if-range", "\"etag\""),
|
||||
("if-match", "\"etag\""), ("upgrade", "websocket"),
|
||||
("x-irrelevant", "yes"),
|
||||
("cache-control", "no-store"),
|
||||
("cache-control", "no-cache"),
|
||||
("cache-control", "max-age=0"),
|
||||
("cache-control", "min-fresh=broken"),
|
||||
("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();
|
||||
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 body_empty in [false, true] {
|
||||
let expected = if forced {
|
||||
crate::cache::forced_request_policy(&method, &headers, body_empty)
|
||||
} 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 rewritten in cases {
|
||||
let expected = match (original, rewritten) {
|
||||
(RequestPolicy::Bypass(reason), _) | (_, RequestPolicy::Bypass(reason)) => RequestPolicy::Bypass(reason),
|
||||
(RequestPolicy::Revalidate(reason), _) | (_, RequestPolicy::Revalidate(reason)) => RequestPolicy::Revalidate(reason),
|
||||
(RequestPolicy::Bypass(reason), _) | (_, RequestPolicy::Bypass(reason)) => {
|
||||
RequestPolicy::Bypass(reason)
|
||||
}
|
||||
(RequestPolicy::Revalidate(reason), _) | (_, RequestPolicy::Revalidate(reason)) => {
|
||||
RequestPolicy::Revalidate(reason)
|
||||
}
|
||||
_ => RequestPolicy::Use,
|
||||
};
|
||||
assert_eq!(combine_policy(original, || rewritten), expected);
|
||||
@@ -85,16 +129,36 @@ fn request_header_rules_cannot_relax_bypass_or_revalidation() {
|
||||
#[test]
|
||||
fn cache_off_still_blocks_range_transformation_and_preserves_origin_headers() {
|
||||
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();
|
||||
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-none-match", HeaderValue::from_static("\"client-validator\""));
|
||||
headers.insert(
|
||||
"if-none-match",
|
||||
HeaderValue::from_static("\"client-validator\""),
|
||||
);
|
||||
let original = headers.clone();
|
||||
let baseline = crate::cache::request_range(&method, &headers);
|
||||
assert_eq!(range_selection(true, &method, &headers), baseline);
|
||||
assert_eq!(matches!(range_selection(false, &method, &headers), RangeSelection::None), matches!(baseline, RangeSelection::None));
|
||||
assert_eq!(headers, original, "origin must receive the original Range/conditional fields");
|
||||
assert_eq!(
|
||||
matches!(
|
||||
range_selection(false, &method, &headers),
|
||||
RangeSelection::None
|
||||
),
|
||||
matches!(baseline, RangeSelection::None)
|
||||
);
|
||||
assert_eq!(
|
||||
headers, original,
|
||||
"origin must receive the original Range/conditional fields"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,10 +35,7 @@ impl ErrorFallbackRequest {
|
||||
transport: request.extensions().get::<Transport>().copied(),
|
||||
snapshot: snapshot.clone(),
|
||||
exchange: request.extensions().get::<Arc<Exchange>>().cloned(),
|
||||
connection_lease: request
|
||||
.extensions()
|
||||
.get::<Arc<ConnectionLease>>()
|
||||
.cloned(),
|
||||
connection_lease: request.extensions().get::<Arc<ConnectionLease>>().cloned(),
|
||||
stop: request.extensions().get::<ServerShutdown>().cloned(),
|
||||
depth: request
|
||||
.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));
|
||||
*request.method_mut() = self.method.clone();
|
||||
*request.uri_mut() = self.uri.clone();
|
||||
@@ -83,7 +86,10 @@ enum ErrorFallbackBody {
|
||||
const MAX_ERROR_FALLBACK_DEPTH: u8 = 8;
|
||||
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 {
|
||||
return Ok(ErrorFallbackBody::Disabled);
|
||||
}
|
||||
@@ -193,6 +199,8 @@ fn intercept_route_origin_error(
|
||||
} else {
|
||||
response
|
||||
};
|
||||
response.extensions_mut().insert(crate::pages::OriginProvidedResponse);
|
||||
response
|
||||
.extensions_mut()
|
||||
.insert(crate::pages::OriginProvidedResponse);
|
||||
response
|
||||
}
|
||||
@@ -735,7 +735,10 @@ mod cache_fast_path_tests {
|
||||
let fixture = Fixture::new(config).await;
|
||||
let first = fixture.get("/vary", &[]).await;
|
||||
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;
|
||||
assert_eq!(revalidated.status, StatusCode::OK);
|
||||
assert_eq!(revalidated.headers["x-cache"], "HIT");
|
||||
@@ -743,7 +746,9 @@ mod cache_fast_path_tests {
|
||||
cache_chain(&revalidated.headers).last().copied(),
|
||||
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.body, "posted");
|
||||
assert_eq!(
|
||||
@@ -783,9 +788,10 @@ mod cache_fast_path_tests {
|
||||
let stale = fixture.get("/stale-no-validator", &[]).await;
|
||||
assert_eq!(stale.status, StatusCode::OK);
|
||||
assert_eq!(stale.headers["x-cache"], "MISS");
|
||||
assert!(
|
||||
cache_chain(&stale.headers).last().unwrap().starts_with("RustProxy; fwd=stale"),
|
||||
);
|
||||
assert!(cache_chain(&stale.headers)
|
||||
.last()
|
||||
.unwrap()
|
||||
.starts_with("RustProxy; fwd=stale"),);
|
||||
assert_eq!(origin.requests.load(Ordering::Relaxed), 2);
|
||||
fixture.stop().await;
|
||||
}
|
||||
@@ -942,9 +948,18 @@ mod cache_fast_path_tests {
|
||||
);
|
||||
let cache_status = cache_chain(&stale.headers);
|
||||
let last = cache_status.last().unwrap();
|
||||
let freshness = last.strip_prefix("RustProxy; hit; ttl=").unwrap()
|
||||
.split(';').next().unwrap().parse::<i64>().unwrap();
|
||||
assert!(freshness <= 0, "expired response must not claim positive freshness");
|
||||
let freshness = last
|
||||
.strip_prefix("RustProxy; hit; ttl=")
|
||||
.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\""));
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
assert_eq!(
|
||||
|
||||
+56
-18
@@ -106,7 +106,11 @@ async fn forward_request(
|
||||
} else {
|
||||
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(
|
||||
StatusCode::FORBIDDEN,
|
||||
"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",
|
||||
);
|
||||
}
|
||||
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()))
|
||||
{
|
||||
return error(
|
||||
@@ -170,7 +177,9 @@ async fn forward_request(
|
||||
"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;
|
||||
}
|
||||
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.
|
||||
if !snapshot.global_security.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))
|
||||
if !snapshot
|
||||
.global_security
|
||||
.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 let Err(limited) = state.rate_limiter.check_with_user_agent(
|
||||
@@ -251,7 +273,10 @@ async fn forward_request(
|
||||
"\u{1f}rustproxy-global-security",
|
||||
context.client_ip,
|
||||
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,
|
||||
) {
|
||||
let mut response = error(
|
||||
@@ -265,12 +290,13 @@ async fn forward_request(
|
||||
}
|
||||
}
|
||||
if let Some(frontend) = frontend {
|
||||
if !country_resolved && (capture_country
|
||||
|| frontend
|
||||
.profile_policies
|
||||
.iter()
|
||||
.any(|profile| profile.countries.needs_geoip())
|
||||
|| frontend.rate_limit.needs_geoip())
|
||||
if !country_resolved
|
||||
&& (capture_country
|
||||
|| frontend
|
||||
.profile_policies
|
||||
.iter()
|
||||
.any(|profile| profile.countries.needs_geoip())
|
||||
|| frontend.rate_limit.needs_geoip())
|
||||
{
|
||||
country = cloudflare_country
|
||||
.clone()
|
||||
@@ -331,14 +357,20 @@ async fn forward_request(
|
||||
if let Some(mut response) = frontend.and_then(|frontend| {
|
||||
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;
|
||||
}
|
||||
if !tls {
|
||||
if let Some(mut response) =
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -364,7 +396,10 @@ async fn forward_request(
|
||||
"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 {
|
||||
http3_enabled: _redirect.http3_enabled,
|
||||
});
|
||||
@@ -372,7 +407,10 @@ async fn forward_request(
|
||||
}
|
||||
if let Some(item) = local_host {
|
||||
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 {
|
||||
http3_enabled: item.http3_enabled,
|
||||
});
|
||||
|
||||
@@ -23,7 +23,9 @@ mod tests {
|
||||
fn common_connection_tokens_do_not_request_websocket_upgrade() {
|
||||
for connection in [None, Some("keep-alive"), Some("KEEP-ALIVE"), Some("close")] {
|
||||
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));
|
||||
}
|
||||
let mut headers = HeaderMap::new();
|
||||
@@ -52,20 +54,29 @@ mod tests {
|
||||
headers.append("set-cookie", "rustproxy_session=secret".parse().unwrap());
|
||||
headers.append("set-cookie", "b=2; HttpOnly".parse().unwrap());
|
||||
strip_admin_set_cookie(&mut headers);
|
||||
let cookies: Vec<_> = headers.get_all("set-cookie").iter()
|
||||
.map(|value| value.to_str().unwrap()).collect();
|
||||
let cookies: Vec<_> = headers
|
||||
.get_all("set-cookie")
|
||||
.iter()
|
||||
.map(|value| value.to_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(cookies, vec!["a=1; Secure", "b=2; HttpOnly"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_cookie_can_be_restored_only_from_captured_fragment() {
|
||||
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);
|
||||
assert_eq!(h.get("cookie").unwrap(), "a=1; b=2");
|
||||
assert_eq!(admin, vec!["rustproxy_session=secret"]);
|
||||
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]
|
||||
@@ -87,7 +98,9 @@ mod tests {
|
||||
fn absent_and_close_connection_preserve_non_hop_headers() {
|
||||
for value in [None, Some("close"), Some("CLOSE")] {
|
||||
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("transfer-encoding", "chunked".parse().unwrap());
|
||||
strip_hop_by_hop(&mut headers);
|
||||
|
||||
+30
-10
@@ -13,9 +13,20 @@ struct SelectedRouteContext {
|
||||
fn take_routing_identity(
|
||||
request: &mut Request,
|
||||
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 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 (original_host, host, decoded_path) = match validated {
|
||||
Some(validated) => (
|
||||
@@ -24,16 +35,25 @@ fn take_routing_identity(
|
||||
validated.decoded_path,
|
||||
),
|
||||
None => {
|
||||
let original_host = effective_host(request, tls).map_err(|_| error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"RustProxy: invalid or conflicting request authority\n",
|
||||
))?;
|
||||
let host = original_host.to_str().ok()
|
||||
let original_host = effective_host(request, tls).map_err(|_| {
|
||||
error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"RustProxy: invalid or conflicting request authority\n",
|
||||
)
|
||||
})?;
|
||||
let host = original_host
|
||||
.to_str()
|
||||
.ok()
|
||||
.and_then(|v| request_host(v).ok())
|
||||
.ok_or_else(|| error(StatusCode::BAD_REQUEST,
|
||||
"RustProxy: invalid hostname\n"))?;
|
||||
.ok_or_else(|| error(StatusCode::BAD_REQUEST, "RustProxy: invalid hostname\n"))?;
|
||||
(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,
|
||||
))
|
||||
}
|
||||
@@ -29,7 +29,10 @@ fn canonical_host_redirect(
|
||||
} else {
|
||||
("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 location = if default_port {
|
||||
format!("{scheme}://{to}{path}")
|
||||
@@ -37,8 +40,13 @@ fn canonical_host_redirect(
|
||||
format!("{scheme}://{to}:{port}{path}")
|
||||
};
|
||||
let mut response = StatusCode::PERMANENT_REDIRECT.into_response();
|
||||
response.headers_mut().insert(http::header::LOCATION, HeaderValue::from_str(&location).ok()?);
|
||||
response.headers_mut().insert("cache-control", HeaderValue::from_static("no-store"));
|
||||
response.headers_mut().insert(
|
||||
http::header::LOCATION,
|
||||
HeaderValue::from_str(&location).ok()?,
|
||||
);
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("cache-control", HeaderValue::from_static("no-store"));
|
||||
Some(response)
|
||||
}
|
||||
|
||||
@@ -60,4 +68,3 @@ fn force_ssl_redirect(
|
||||
.insert("cache-control", HeaderValue::from_static("no-store"));
|
||||
Some(response)
|
||||
}
|
||||
|
||||
@@ -32,9 +32,7 @@ mod request_id_tests {
|
||||
|
||||
#[test]
|
||||
fn independent_sequential_ids_keep_full_length_and_unique_values() {
|
||||
let ids: std::collections::HashSet<_> = (0..4096u64)
|
||||
.map(request_id_hash)
|
||||
.collect();
|
||||
let ids: std::collections::HashSet<_> = (0..4096u64).map(request_id_hash).collect();
|
||||
assert_eq!(ids.len(), 4096);
|
||||
assert!(ids.iter().all(|id| id.len() == 64));
|
||||
}
|
||||
|
||||
@@ -129,12 +129,15 @@ pub(super) fn finish_web_revalidation(
|
||||
if *method == Method::HEAD {
|
||||
*response.body_mut() = Body::empty();
|
||||
}
|
||||
let request_forces_validation = crate::cache::request_policy(
|
||||
method, client, true, "", false, settings,
|
||||
).revalidation_reason().is_some()
|
||||
|| cached.request_freshness_revalidation_reason(
|
||||
&crate::cache::request_freshness_headers(client, settings),
|
||||
).is_some();
|
||||
let request_forces_validation =
|
||||
crate::cache::request_policy(method, client, true, "", false, settings)
|
||||
.revalidation_reason()
|
||||
.is_some()
|
||||
|| cached
|
||||
.request_freshness_revalidation_reason(&crate::cache::request_freshness_headers(
|
||||
client, settings,
|
||||
))
|
||||
.is_some();
|
||||
settings.annotate_reason_transformed_forwarded(
|
||||
response.headers_mut(),
|
||||
"HIT",
|
||||
|
||||
@@ -154,7 +154,9 @@ async fn strict_browser_refresh_uses_request_forward_reason() {
|
||||
let mut client = HeaderMap::new();
|
||||
client.insert("cache-control", HeaderValue::from_static("no-cache"));
|
||||
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.headers()["x-cache"], "HIT");
|
||||
assert_eq!(
|
||||
|
||||
+54
-21
@@ -49,10 +49,13 @@ mod tests {
|
||||
assert!(limiter.check(7, "site", first, None, &settings).is_ok());
|
||||
{
|
||||
let mut state = lock(&limiter.state);
|
||||
let bucket = state.buckets.get_mut("site").unwrap().get_mut(&first).unwrap();
|
||||
bucket.started = Instant::now()
|
||||
.checked_sub(Duration::from_secs(2))
|
||||
let bucket = state
|
||||
.buckets
|
||||
.get_mut("site")
|
||||
.unwrap()
|
||||
.get_mut(&first)
|
||||
.unwrap();
|
||||
bucket.started = Instant::now().checked_sub(Duration::from_secs(2)).unwrap();
|
||||
state.last_sweep = None;
|
||||
}
|
||||
assert!(limiter.check(7, "site", second, None, &settings).is_ok());
|
||||
@@ -82,9 +85,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.get_mut(&ip)
|
||||
.unwrap();
|
||||
bucket.started = Instant::now()
|
||||
.checked_sub(Duration::from_secs(2))
|
||||
.unwrap();
|
||||
bucket.started = Instant::now().checked_sub(Duration::from_secs(2)).unwrap();
|
||||
}
|
||||
limiter.maintenance();
|
||||
let state = lock(&limiter.state);
|
||||
@@ -116,44 +117,76 @@ mod tests {
|
||||
requests: 3,
|
||||
burst: 0,
|
||||
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()
|
||||
};
|
||||
settings.validate().unwrap();
|
||||
let limiter = Limiter::new();
|
||||
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.check_with_user_agent(7, "a", ip, None, Some("BADBOT/2"), &settings).is_err());
|
||||
assert!(limiter
|
||||
.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 {
|
||||
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.check_with_user_agent(7, "b", ip, None, Some("BadBot"), &settings).is_ok());
|
||||
assert!(limiter
|
||||
.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]
|
||||
fn user_agent_overrides_work_without_base_rate_or_geoip() {
|
||||
let mut settings = Settings {
|
||||
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()
|
||||
};
|
||||
settings.validate().unwrap();
|
||||
let limiter = Limiter::new();
|
||||
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.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());
|
||||
assert!(limiter
|
||||
.check_with_user_agent(1, "site", ip, None, Some("ScanNER"), &settings)
|
||||
.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]
|
||||
fn user_agent_override_validation_is_bounded_and_unique() {
|
||||
let mut settings = Settings { user_agent_limits: vec![
|
||||
UserAgentLimit { pattern: "TEST*".into(), ..Default::default() },
|
||||
UserAgentLimit { pattern: "test*".into(), ..Default::default() },
|
||||
], ..Default::default() };
|
||||
let mut settings = Settings {
|
||||
user_agent_limits: vec![
|
||||
UserAgentLimit {
|
||||
pattern: "TEST*".into(),
|
||||
..Default::default()
|
||||
},
|
||||
UserAgentLimit {
|
||||
pattern: "test*".into(),
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(settings.validate().is_err());
|
||||
settings.user_agent_limits[1].pattern = "other".into();
|
||||
assert!(settings.validate().is_ok());
|
||||
}
|
||||
|
||||
}
|
||||
+84
-25
@@ -8,15 +8,21 @@ mod cache_revision_tests {
|
||||
let identity = cache_revision(&base);
|
||||
|
||||
let mut file_transport = base.clone();
|
||||
file_transport.web_services.push(crate::web_server::Service {
|
||||
id: "static-test".into(), name: "Static Test".into(),
|
||||
document_root: "/var/www/static-test".into(),
|
||||
..Default::default()
|
||||
});
|
||||
file_transport
|
||||
.web_services
|
||||
.push(crate::web_server::Service {
|
||||
id: "static-test".into(),
|
||||
name: "Static Test".into(),
|
||||
document_root: "/var/www/static-test".into(),
|
||||
..Default::default()
|
||||
});
|
||||
let mut zero_copy = file_transport.clone();
|
||||
zero_copy.web_services[0].sendfile = crate::web_server::SendfileMode::Auto;
|
||||
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();
|
||||
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;
|
||||
}
|
||||
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),
|
||||
"{label} toggle did not change a transport switch"
|
||||
);
|
||||
assert_eq!(cache_revision(&protocol_only), identity, "{label} rotated cache");
|
||||
assert!(matches!(cache_invalidation(&base, &protocol_only), CacheInvalidation::None));
|
||||
assert!(matches!(cache_invalidation(&protocol_only, &base), CacheInvalidation::None));
|
||||
assert_eq!(
|
||||
cache_revision(&protocol_only),
|
||||
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();
|
||||
both_protocols.vhosts[0].http2_enabled = !base.vhosts[0].http2_enabled;
|
||||
both_protocols.vhosts[0].http3_enabled = !base.vhosts[0].http3_enabled;
|
||||
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();
|
||||
vhost_diagnostics.vhosts[0].cache.expose_only_hits = Some(true);
|
||||
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();
|
||||
cache.cache.max_entries += 1;
|
||||
@@ -118,12 +143,30 @@ mod cache_revision_tests {
|
||||
|
||||
let mut operational = base.clone();
|
||||
operational.vhosts[0].load_balancing.keepalive.enabled = false;
|
||||
operational.vhosts[0].load_balancing.keepalive.idle_timeout_seconds = 90;
|
||||
operational.vhosts[0].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]
|
||||
.load_balancing
|
||||
.keepalive
|
||||
.idle_timeout_seconds = 90;
|
||||
operational.vhosts[0]
|
||||
.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].max_concurrent_requests = 16;
|
||||
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].proxy_timeouts.read_timeout_seconds = Some(180);
|
||||
assert_eq!(cache_revision(&operational), identity);
|
||||
assert_eq!(vhost_cache_revision(&operational.vhosts[0]), vhost_cache_revision(&base.vhosts[0]));
|
||||
assert!(matches!(cache_invalidation(&base, &operational), CacheInvalidation::None));
|
||||
assert!(matches!(cache_invalidation(&operational, &base), CacheInvalidation::None));
|
||||
assert_eq!(
|
||||
vhost_cache_revision(&operational.vhosts[0]),
|
||||
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();
|
||||
vhost.vhosts[0].backends[0].url = "http://127.0.0.1:9001".into();
|
||||
assert_eq!(cache_revision(&vhost), identity);
|
||||
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 another = multiple.vhosts[0].clone();
|
||||
@@ -149,10 +203,15 @@ mod cache_revision_tests {
|
||||
multiple.vhosts.push(another);
|
||||
let mut changed_one = multiple.clone();
|
||||
changed_one.vhosts[0].backends[0].url = "http://127.0.0.1:9002".into();
|
||||
assert!(matches!(cache_invalidation(&multiple, &changed_one), CacheInvalidation::Scopes(ids)
|
||||
if ids == vec![base.vhosts[0].id.clone()]));
|
||||
assert!(
|
||||
matches!(cache_invalidation(&multiple, &changed_one), CacheInvalidation::Scopes(ids)
|
||||
if ids == vec![base.vhosts[0].id.clone()])
|
||||
);
|
||||
let mut changed_global = multiple.clone();
|
||||
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
@@ -153,7 +153,16 @@ fn log_protocol_error(
|
||||
tls_sni: Option<String>,
|
||||
) {
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -205,4 +205,3 @@ async fn connection<I>(
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
+206
-57
@@ -5,11 +5,14 @@ use std::{
|
||||
io,
|
||||
os::fd::{AsRawFd, RawFd},
|
||||
pin::Pin,
|
||||
sync::{Arc, Mutex},
|
||||
sync::atomic::Ordering,
|
||||
sync::{Arc, Mutex},
|
||||
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 FRAME_LEN: usize = 64 * 1024;
|
||||
@@ -54,24 +57,37 @@ impl SendfileSlots {
|
||||
return None;
|
||||
}
|
||||
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];
|
||||
tag[0] = 0;
|
||||
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];
|
||||
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 {
|
||||
tag, file: candidate.file, length: candidate.length, written: 0,
|
||||
tag,
|
||||
file: candidate.file,
|
||||
length: candidate.length,
|
||||
written: 0,
|
||||
});
|
||||
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 {
|
||||
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(slot) = registry.slots.iter().find(|s| s.tag == tag) {
|
||||
return plan_file(slot, buf);
|
||||
@@ -79,12 +95,16 @@ impl SendfileSlots {
|
||||
return WritePlan::Invalid;
|
||||
}
|
||||
for start in 0..buf.len() {
|
||||
if buf[start] != 0 { continue; }
|
||||
if buf[start] != 0 {
|
||||
continue;
|
||||
}
|
||||
for slot in ®istry.slots {
|
||||
let available = buf.len() - start;
|
||||
let compare = available.min(TAG_LEN);
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -107,36 +127,58 @@ impl SendfileSlots {
|
||||
|
||||
enum WritePlan {
|
||||
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,
|
||||
}
|
||||
|
||||
fn plan_file(slot: &Slot, buf: &[u8]) -> WritePlan {
|
||||
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])
|
||||
.count();
|
||||
if matched == 0 { return WritePlan::Invalid; }
|
||||
if matched == 0 {
|
||||
return WritePlan::Invalid;
|
||||
}
|
||||
WritePlan::File {
|
||||
tag: slot.tag, fd: slot.file.as_raw_fd(), file: slot.file.clone(),
|
||||
offset: slot.written, count: matched,
|
||||
tag: slot.tag,
|
||||
fd: slot.file.as_raw_fd(),
|
||||
file: slot.file.clone(),
|
||||
offset: slot.written,
|
||||
count: matched,
|
||||
}
|
||||
}
|
||||
|
||||
impl HttpBody for SendfileBody {
|
||||
type Data = Bytes;
|
||||
type Error = io::Error;
|
||||
fn poll_frame(mut self: Pin<&mut Self>, _cx: &mut Context<'_>)
|
||||
-> Poll<Option<Result<Frame<Bytes>, io::Error>>>
|
||||
{
|
||||
if self.remaining == 0 { return Poll::Ready(None); }
|
||||
fn poll_frame(
|
||||
mut self: Pin<&mut Self>,
|
||||
_cx: &mut Context<'_>,
|
||||
) -> 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;
|
||||
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)))))
|
||||
}
|
||||
fn size_hint(&self) -> SizeHint { SizeHint::with_exact(self.remaining) }
|
||||
fn is_end_stream(&self) -> bool { self.remaining == 0 }
|
||||
fn size_hint(&self) -> SizeHint {
|
||||
SizeHint::with_exact(self.remaining)
|
||||
}
|
||||
fn is_end_stream(&self) -> bool {
|
||||
self.remaining == 0
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct SendfileIo {
|
||||
@@ -146,22 +188,46 @@ pub(crate) struct SendfileIo {
|
||||
impl SendfileIo {
|
||||
pub fn new(stream: TcpStream) -> (Self, SendfileSlots) {
|
||||
let slots = SendfileSlots::default();
|
||||
(Self { stream, slots: slots.clone() }, slots)
|
||||
(
|
||||
Self {
|
||||
stream,
|
||||
slots: slots.clone(),
|
||||
},
|
||||
slots,
|
||||
)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
impl AsyncWrite for SendfileIo {
|
||||
fn poll_write(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &[u8]) -> Poll<io::Result<usize>> {
|
||||
if buf.is_empty() { return Poll::Ready(Ok(0)); }
|
||||
fn poll_write(
|
||||
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) {
|
||||
WritePlan::Plain(count) => Pin::new(&mut self.stream).poll_write(cx, &buf[..count]),
|
||||
WritePlan::Invalid => Poll::Ready(Err(io::Error::new(io::ErrorKind::InvalidData,
|
||||
"sendfile placeholder did not match its registered descriptor"))),
|
||||
WritePlan::File { tag, fd, file, offset, count } => {
|
||||
WritePlan::Invalid => Poll::Ready(Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"sendfile placeholder did not match its registered descriptor",
|
||||
))),
|
||||
WritePlan::File {
|
||||
tag,
|
||||
fd,
|
||||
file,
|
||||
offset,
|
||||
count,
|
||||
} => {
|
||||
let _keep_open = file;
|
||||
let socket: RawFd = self.stream.as_raw_fd();
|
||||
let mut at: libc::off_t = offset as libc::off_t;
|
||||
@@ -171,13 +237,22 @@ impl AsyncWrite for SendfileIo {
|
||||
Poll::Ready(Ok(())) => {
|
||||
let result = self.stream.try_io(Interest::WRITABLE, || {
|
||||
let n = unsafe { libc::sendfile(socket, fd, &mut at, count) };
|
||||
if n < 0 { Err(io::Error::last_os_error()) }
|
||||
else if n == 0 { Err(io::Error::new(io::ErrorKind::UnexpectedEof,
|
||||
"static source truncated during sendfile")) }
|
||||
else { Ok(n as usize) }
|
||||
if n < 0 {
|
||||
Err(io::Error::last_os_error())
|
||||
} else if n == 0 {
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::UnexpectedEof,
|
||||
"static source truncated during sendfile",
|
||||
))
|
||||
} else {
|
||||
Ok(n as usize)
|
||||
}
|
||||
});
|
||||
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) => 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<()>> {
|
||||
Pin::new(&mut self.stream).poll_shutdown(cx)
|
||||
}
|
||||
fn is_write_vectored(&self) -> bool { self.stream.is_write_vectored() }
|
||||
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() {
|
||||
fn is_write_vectored(&self) -> bool {
|
||||
self.stream.is_write_vectored()
|
||||
}
|
||||
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);
|
||||
}
|
||||
match bufs.iter().find(|part| !part.is_empty()) {
|
||||
@@ -208,8 +296,8 @@ impl AsyncWrite for SendfileIo {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use http_body::Body;
|
||||
use tempfile::tempfile;
|
||||
use std::io::Write;
|
||||
use tempfile::tempfile;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
#[test]
|
||||
@@ -218,19 +306,42 @@ mod tests {
|
||||
file.write_all(&vec![42u8; 100_000]).unwrap();
|
||||
let slots = SendfileSlots::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 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"),
|
||||
let mut body = slots
|
||||
.create_body(
|
||||
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);
|
||||
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;
|
||||
slots.commit(tag, FRAME_LEN);
|
||||
let second = 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"),
|
||||
let second = 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!(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());
|
||||
assert!(slots.0.lock().unwrap().slots.is_empty());
|
||||
assert!(body.is_end_stream());
|
||||
@@ -245,36 +356,74 @@ mod tests {
|
||||
let (tcp, _) = listener.accept().await.unwrap();
|
||||
let (mut socket, slots) = SendfileIo::new(tcp);
|
||||
for round in 0..2 {
|
||||
let expected = if round == 0 { vec![0, 1, 2, 3].repeat(25_000) }
|
||||
else { vec![9u8; 100_003] };
|
||||
let expected = if round == 0 {
|
||||
vec![0, 1, 2, 3].repeat(25_000)
|
||||
} else {
|
||||
vec![9u8; 100_003]
|
||||
};
|
||||
let mut file = tempfile().unwrap();
|
||||
file.write_all(&expected).unwrap();
|
||||
let web = Arc::new(crate::web_server::metrics::PhpMetrics::default());
|
||||
let body = slots.create_body(SendfileCandidate {
|
||||
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 body = slots
|
||||
.create_body(
|
||||
SendfileCandidate {
|
||||
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 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();
|
||||
joined.extend_from_slice(&first_frame);
|
||||
socket.write_all(&joined).await.unwrap();
|
||||
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();
|
||||
});
|
||||
let mut client = tokio::net::TcpStream::connect(address).await.unwrap();
|
||||
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();
|
||||
let mut wanted = Vec::new();
|
||||
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);
|
||||
}
|
||||
assert_eq!(actual, wanted, "kernel sendfile must never leak placeholder markers");
|
||||
assert_eq!(
|
||||
actual, wanted,
|
||||
"kernel sendfile must never leak placeholder markers"
|
||||
);
|
||||
}
|
||||
}
|
||||
+15
-5
@@ -67,9 +67,12 @@ fn ensure_locked_listener(config: &mut crate::config::Config, address: SocketAdd
|
||||
return;
|
||||
}
|
||||
let canonical_id = if tls { "https" } else { "http" };
|
||||
if let Some(endpoint) = config.listeners.endpoints.iter_mut().find(|endpoint| {
|
||||
endpoint.id == canonical_id && endpoint.binding.tls == tls
|
||||
}) {
|
||||
if let Some(endpoint) = config
|
||||
.listeners
|
||||
.endpoints
|
||||
.iter_mut()
|
||||
.find(|endpoint| endpoint.id == canonical_id && endpoint.binding.tls == tls)
|
||||
{
|
||||
endpoint.binding.address = address;
|
||||
endpoint.additional_addresses.clear();
|
||||
endpoint.enabled = true;
|
||||
@@ -166,7 +169,13 @@ pub struct PanelCompression {
|
||||
pub gzip: bool,
|
||||
}
|
||||
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)]
|
||||
@@ -248,7 +257,8 @@ impl Administration {
|
||||
*public_origin = normalize_public_admin_origin(public_origin)?;
|
||||
}
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -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, '@' | '#' | '?' | ',' | '\\')),
|
||||
"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")?;
|
||||
ensure!(
|
||||
uri.scheme_str() == Some("https")
|
||||
@@ -11,7 +12,8 @@ fn normalize_public_admin_origin(public_origin: &str) -> Result<String> {
|
||||
&& matches!(uri.path(), "" | "/"),
|
||||
"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")?;
|
||||
let host = crate::config::normalize_host(authority.host())?;
|
||||
ensure!(
|
||||
@@ -19,9 +21,13 @@ fn normalize_public_admin_origin(public_origin: &str) -> Result<String> {
|
||||
"administration public_origin must name a DNS hostname"
|
||||
);
|
||||
let port = if authority.as_str().contains(':') {
|
||||
let port = authority.port_u16()
|
||||
let port = authority
|
||||
.port_u16()
|
||||
.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}")
|
||||
} else {
|
||||
String::new()
|
||||
@@ -31,17 +37,26 @@ fn normalize_public_admin_origin(public_origin: &str) -> Result<String> {
|
||||
|
||||
impl Administration {
|
||||
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(|authority| authority.split(':').next())
|
||||
}
|
||||
pub fn explicitly_allowed_panel_host(&self, host: &str) -> bool {
|
||||
self.allowed_hosts.iter().any(|allowed| allowed.eq_ignore_ascii_case(host))
|
||||
|| self.public_origin_host().is_some_and(|name| name.eq_ignore_ascii_case(host))
|
||||
self.allowed_hosts
|
||||
.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) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
+59
-12
@@ -15,11 +15,29 @@ mod tests {
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
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!(Http2Connections { idle_timeout_seconds: 0, ..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());
|
||||
assert!(Http2Connections {
|
||||
idle_timeout_seconds: 0,
|
||||
..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]
|
||||
fn new_install_server_header_defaults_to_rustproxy_without_changing_legacy_missing_field() {
|
||||
@@ -120,13 +138,29 @@ mod tests {
|
||||
|
||||
locks.ensure_config(&mut config);
|
||||
|
||||
let http = config.listeners.endpoints.iter().find(|item| item.id == "http").unwrap();
|
||||
let https = config.listeners.endpoints.iter().find(|item| item.id == "https").unwrap();
|
||||
let http = config
|
||||
.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!(http.additional_addresses.is_empty());
|
||||
assert_eq!(https.binding.address, "0.0.0.0:8443".parse().unwrap());
|
||||
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]
|
||||
@@ -264,19 +298,32 @@ mod tests {
|
||||
..Administration::default()
|
||||
};
|
||||
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!(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("other.example.test"));
|
||||
let mut runtime = admin.clone();
|
||||
runtime.include_public_origin_host();
|
||||
runtime.include_public_origin_host();
|
||||
assert_eq!(runtime.allowed_hosts, vec!["panel.example.test".to_owned()]);
|
||||
assert!(admin.allowed_hosts.is_empty(), "saved config remains unchanged");
|
||||
let active = Administration { public_origin: None, ..admin.clone() };
|
||||
assert!(
|
||||
admin.allowed_hosts.is_empty(),
|
||||
"saved config remains unchanged"
|
||||
);
|
||||
let active = Administration {
|
||||
public_origin: None,
|
||||
..admin.clone()
|
||||
};
|
||||
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();
|
||||
assert_eq!(legacy.public_origin, None);
|
||||
assert!(legacy.explicitly_allowed_panel_host("panel.example.test"));
|
||||
|
||||
@@ -132,7 +132,9 @@ pub fn import(directory: &Path, name: &str, cert: PathBuf, key: PathBuf) -> Resu
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum ExternalPairDeployment {
|
||||
Unchanged { covered_hosts: Vec<String> },
|
||||
Unchanged {
|
||||
covered_hosts: Vec<String>,
|
||||
},
|
||||
Staged {
|
||||
cert_file: String,
|
||||
key_file: String,
|
||||
|
||||
@@ -358,7 +358,10 @@ pub fn compile(settings: &Settings, directory: Option<&Path>) -> Result<Option<A
|
||||
.strip_prefix("*.")
|
||||
.map(|base| format!("rustproxy-wildcard-check.{base}"))
|
||||
.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();
|
||||
checker
|
||||
.add(&validation_name, key.clone())
|
||||
|
||||
+52
-20
@@ -50,11 +50,19 @@ mod tests {
|
||||
analytics.push_lazy(|| {
|
||||
made.fetch_add(1, Ordering::Relaxed);
|
||||
Sample {
|
||||
vhost: "site".into(), route: None, method: "GET".into(),
|
||||
path: "/".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,
|
||||
vhost: "site".into(),
|
||||
route: None,
|
||||
method: "GET".into(),
|
||||
path: "/".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,
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -78,15 +86,27 @@ mod tests {
|
||||
let mut counters = Counters::default();
|
||||
for status in [100, 200, 302, 404, 503, 701] {
|
||||
counters.record(Sample {
|
||||
vhost: "test".into(), route: None, method: "GET".into(),
|
||||
path: "/".into(), client_ip: None, status,
|
||||
duration_ms: 1, request_bytes: 0, response_bytes: 1,
|
||||
cache: "BYPASS", source: "reverse_proxy",
|
||||
user_agent: None, country: None,
|
||||
vhost: "test".into(),
|
||||
route: None,
|
||||
method: "GET".into(),
|
||||
path: "/".into(),
|
||||
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()
|
||||
.map(|entry| entry.value).collect();
|
||||
let labels: std::collections::HashSet<_> = counters
|
||||
.view()
|
||||
.statuses
|
||||
.into_iter()
|
||||
.map(|entry| entry.value)
|
||||
.collect();
|
||||
for label in ["1xx", "2xx", "3xx", "4xx", "5xx", "7xx"] {
|
||||
assert!(labels.contains(label));
|
||||
}
|
||||
@@ -113,12 +133,23 @@ mod tests {
|
||||
let analytics = Analytics::start(shutdown.clone());
|
||||
for id in 0..130 {
|
||||
analytics.push(Sample {
|
||||
vhost: if id % 2 == 0 { "left".into() } else { "right".into() },
|
||||
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,
|
||||
vhost: if id % 2 == 0 {
|
||||
"left".into()
|
||||
} else {
|
||||
"right".into()
|
||||
},
|
||||
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 {
|
||||
@@ -127,7 +158,9 @@ mod tests {
|
||||
{
|
||||
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;
|
||||
assert_eq!(all.requests, 130);
|
||||
assert_eq!(all.response_bytes, 130 * 1040);
|
||||
@@ -136,5 +169,4 @@ mod tests {
|
||||
assert_eq!(analytics.view(None).data.requests, 65);
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -427,7 +427,13 @@ mod tests {
|
||||
let result = css::minify(input.as_bytes()).expect("minify panel stylesheet");
|
||||
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(".card,.stat,dialog,.login-card,"), "lost card selector list");
|
||||
assert!(output.contains("border-radius:var(--rp-radius-surface) !important"), "lost radius declaration");
|
||||
assert!(
|
||||
output.contains(".card,.stat,dialog,.login-card,"),
|
||||
"lost card selector list"
|
||||
);
|
||||
assert!(
|
||||
output.contains("border-radius:var(--rp-radius-surface) !important"),
|
||||
"lost radius declaration"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,6 @@ mod tests {
|
||||
assert_eq!(stats.generation, 1);
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn generation_recycles_after_age_without_request() {
|
||||
let backend = backend("rotation-age-test", 18083);
|
||||
@@ -101,14 +100,18 @@ mod tests {
|
||||
keepalive.pool_recycle_requests = 1_000;
|
||||
keepalive.pool_recycle_seconds = 0;
|
||||
let client = client_with_keepalive(
|
||||
&backend, AddressPolicy::new(&backend).unwrap(), false, &keepalive,
|
||||
).unwrap();
|
||||
for _ in 0..1_000 { client.inner.client_for_request(); }
|
||||
&backend,
|
||||
AddressPolicy::new(&backend).unwrap(),
|
||||
false,
|
||||
&keepalive,
|
||||
)
|
||||
.unwrap();
|
||||
for _ in 0..1_000 {
|
||||
client.inner.client_for_request();
|
||||
}
|
||||
assert_eq!(client.rotation_stats().rotations, 0);
|
||||
client.inner.client_for_request();
|
||||
assert_eq!(client.rotation_stats().rotations, 1);
|
||||
assert_eq!(client.rotation_stats().generation_requests, 1);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -232,9 +232,11 @@ async fn tls_authority_origin(
|
||||
let key = rustls::pki_types::PrivatePkcs8KeyDer::from(pair.signing_key.serialize_der());
|
||||
let provider = Arc::new(rustls::crypto::ring::default_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_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 {
|
||||
vec![b"h2".to_vec(), b"http/1.1".to_vec()]
|
||||
} else {
|
||||
@@ -253,7 +255,9 @@ async fn tls_authority_origin(
|
||||
async move {
|
||||
if let Some(tx) = observed.lock().unwrap().take() {
|
||||
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());
|
||||
let _ = tx.send((request.version(), authority, host, sni));
|
||||
}
|
||||
@@ -262,10 +266,12 @@ async fn tls_authority_origin(
|
||||
});
|
||||
if offer_h2 {
|
||||
let _ = hyper::server::conn::http2::Builder::new(TokioExecutor::new())
|
||||
.serve_connection(TokioIo::new(tls), service).await;
|
||||
.serve_connection(TokioIo::new(tls), service)
|
||||
.await;
|
||||
} else {
|
||||
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)
|
||||
@@ -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.preserve_host = true;
|
||||
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")
|
||||
.body(Body::empty()).unwrap();
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
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);
|
||||
let (version, authority, host, sni) = seen.await.unwrap();
|
||||
assert_eq!(version, Version::HTTP_2);
|
||||
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"));
|
||||
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.preserve_host = true;
|
||||
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")
|
||||
.header("host", "unitraklub.pl")
|
||||
.body(Body::empty()).unwrap();
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
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);
|
||||
let (version, _authority, host, sni) = seen.await.unwrap();
|
||||
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.preserve_host = true;
|
||||
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")
|
||||
.header("host", "unitraklub.pl")
|
||||
.body(Body::empty()).unwrap();
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
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);
|
||||
let (version, authority, host, sni) = seen.await.unwrap();
|
||||
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())
|
||||
.serve_connection(TokioIo::new(stream), service).await;
|
||||
.serve_connection(TokioIo::new(stream), service)
|
||||
.await;
|
||||
});
|
||||
let backend = protocol_backend(address, "http", "h2");
|
||||
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")
|
||||
.body(Body::empty()).unwrap();
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
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!(observed.await.unwrap().as_deref(), Some("unitraklub.pl"));
|
||||
server.abort();
|
||||
|
||||
+45
-45
@@ -1,53 +1,53 @@
|
||||
use super::*;
|
||||
use http::HeaderValue;
|
||||
use super::*;
|
||||
use http::HeaderValue;
|
||||
|
||||
fn headers(value: Option<&str>) -> HeaderMap {
|
||||
let mut headers = HeaderMap::new();
|
||||
if let Some(value) = value {
|
||||
headers.insert(USER_AGENT, HeaderValue::from_str(value).unwrap());
|
||||
}
|
||||
headers
|
||||
fn headers(value: Option<&str>) -> HeaderMap {
|
||||
let mut headers = HeaderMap::new();
|
||||
if let Some(value) = value {
|
||||
headers.insert(USER_AGENT, HeaderValue::from_str(value).unwrap());
|
||||
}
|
||||
headers
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_patterns_match_case_insensitive_substrings() {
|
||||
let policy = Policy {
|
||||
enabled: true,
|
||||
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"))));
|
||||
#[test]
|
||||
fn plain_patterns_match_case_insensitive_substrings() {
|
||||
let policy = Policy {
|
||||
enabled: true,
|
||||
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"))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wildcard_patterns_can_anchor_or_span() {
|
||||
let policy = Policy {
|
||||
enabled: true,
|
||||
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"))));
|
||||
#[test]
|
||||
fn wildcard_patterns_can_anchor_or_span() {
|
||||
let policy = Policy {
|
||||
enabled: true,
|
||||
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"))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_or_missing_user_agent_is_allowed() {
|
||||
let disabled = Policy {
|
||||
enabled: false,
|
||||
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)));
|
||||
#[test]
|
||||
fn disabled_or_missing_user_agent_is_allowed() {
|
||||
let disabled = Policy {
|
||||
enabled: false,
|
||||
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)));
|
||||
}
|
||||
@@ -24,7 +24,10 @@ impl Default for ErrorFallback {
|
||||
impl ErrorFallback {
|
||||
pub fn validate(&mut self) -> Result<()> {
|
||||
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!(
|
||||
!self.statuses.is_empty() && self.statuses.len() <= ERROR_FALLBACK_STATUSES.len(),
|
||||
"error fallback requires 1..={} status codes",
|
||||
@@ -45,4 +48,3 @@ impl ErrorFallback {
|
||||
self.statuses.contains(&status.as_u16())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,5 +26,3 @@ impl Default for LocationRedirect {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+41
-13
@@ -67,7 +67,10 @@ impl Vhost {
|
||||
!self.websocket_rfc9220 || self.http3_enabled,
|
||||
"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();
|
||||
for host in &mut self.frontend.hosts {
|
||||
*host = crate::config::normalize_host_pattern(host)?;
|
||||
@@ -155,7 +158,10 @@ impl Vhost {
|
||||
"location cannot define both a local response and a redirect"
|
||||
);
|
||||
if let Some(id) = &route.acl_profile_id {
|
||||
ensure!(crate::config::valid_id(id), "invalid location ACL profile id");
|
||||
ensure!(
|
||||
crate::config::valid_id(id),
|
||||
"invalid location ACL profile id"
|
||||
);
|
||||
}
|
||||
route.country_access.validate()?;
|
||||
route.method_policy.validate()?;
|
||||
@@ -191,13 +197,21 @@ impl Vhost {
|
||||
}
|
||||
}
|
||||
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 {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut current = start;
|
||||
loop {
|
||||
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 {
|
||||
None => self.error_fallback.as_ref(),
|
||||
Some(id) => self
|
||||
@@ -215,7 +229,9 @@ impl Vhost {
|
||||
if let Some(custom) = &member.health_check {
|
||||
let mut settings = self.load_balancing.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()?;
|
||||
@@ -233,7 +249,8 @@ impl Vhost {
|
||||
"choose either Tinyauth or Basic authentication"
|
||||
);
|
||||
self.pages.validate()?;
|
||||
self.transform.resolve(&crate::transform::Settings::default())?;
|
||||
self.transform
|
||||
.resolve(&crate::transform::Settings::default())?;
|
||||
self.security_headers.compile()?;
|
||||
self.user_agent_blocking.compile()?;
|
||||
self.headers.compile()?;
|
||||
@@ -306,8 +323,10 @@ pub fn validate_listener(listener: Listener) -> Result<()> {
|
||||
}
|
||||
fn www_partner(host: &str) -> Result<String> {
|
||||
if let Some(bare) = host.strip_prefix("www.") {
|
||||
ensure!(!bare.is_empty() && bare.parse::<std::net::IpAddr>().is_err(),
|
||||
"www redirect requires a DNS hostname");
|
||||
ensure!(
|
||||
!bare.is_empty() && bare.parse::<std::net::IpAddr>().is_err(),
|
||||
"www redirect requires a DNS hostname"
|
||||
);
|
||||
crate::config::normalize_host(bare)
|
||||
} else {
|
||||
crate::config::normalize_host(&format!("www.{host}"))
|
||||
@@ -315,14 +334,23 @@ fn www_partner(host: &str) -> Result<String> {
|
||||
}
|
||||
impl Vhost {
|
||||
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))
|
||||
.ok_or_else(|| anyhow::anyhow!("vhost requires at least one hostname"))?;
|
||||
ensure!(!canonical.starts_with("*.") && canonical.parse::<std::net::IpAddr>().is_err(),
|
||||
"www redirect requires an exact DNS hostname");
|
||||
ensure!(
|
||||
!canonical.starts_with("*.") && canonical.parse::<std::net::IpAddr>().is_err(),
|
||||
"www redirect requires an exact DNS hostname"
|
||||
);
|
||||
let partner = www_partner(canonical)?;
|
||||
ensure!(self.frontend.hosts.iter().any(|host| host == canonical || host == &partner),
|
||||
"www redirect target or its www/non-www partner must belong to this vhost");
|
||||
ensure!(
|
||||
self.frontend
|
||||
.hosts
|
||||
.iter()
|
||||
.any(|host| host == canonical || host == &partner),
|
||||
"www redirect target or its www/non-www partner must belong to this vhost"
|
||||
);
|
||||
Ok(canonical)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::htaccess;
|
||||
use super::htaccess::Config as HtaccessConfig;
|
||||
#[cfg(unix)]
|
||||
use super::filesystem::open_root_directory;
|
||||
use super::htaccess;
|
||||
use super::htaccess::Config as HtaccessConfig;
|
||||
use anyhow::{ensure, Context, Result};
|
||||
use http::{HeaderName, HeaderValue, StatusCode};
|
||||
use regex::Regex;
|
||||
|
||||
@@ -11,9 +11,26 @@ pub(super) async fn php_response(
|
||||
context: RequestContext,
|
||||
) -> Response {
|
||||
let start = Instant::now();
|
||||
let mut response = php_response_inner(service, 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);
|
||||
let mut response = php_response_inner(
|
||||
service,
|
||||
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
|
||||
}
|
||||
|
||||
@@ -51,15 +68,19 @@ async fn php_response_inner(
|
||||
return response;
|
||||
}
|
||||
};
|
||||
let mut io = match tokio::time::timeout(php.connect_timeout, connect_fastcgi(&php.endpoint)).await {
|
||||
Ok(Ok(io)) => io,
|
||||
Ok(Err(error)) => {
|
||||
php.metrics.failure(2);
|
||||
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); },
|
||||
};
|
||||
let mut io =
|
||||
match tokio::time::timeout(php.connect_timeout, connect_fastcgi(&php.endpoint)).await {
|
||||
Ok(Ok(io)) => io,
|
||||
Ok(Err(error)) => {
|
||||
php.metrics.failure(2);
|
||||
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);
|
||||
}
|
||||
};
|
||||
let params = match fastcgi_params(
|
||||
service,
|
||||
php,
|
||||
@@ -91,8 +112,9 @@ async fn php_response_inner(
|
||||
let mut body = body;
|
||||
let mut body_bytes = 0u64;
|
||||
while let Some(frame) = body.frame().await {
|
||||
let frame = frame
|
||||
.map_err(|error| FastCgiRequestWriteError::Io(io::Error::other(error.to_string())))?;
|
||||
let frame = frame.map_err(|error| {
|
||||
FastCgiRequestWriteError::Io(io::Error::other(error.to_string()))
|
||||
})?;
|
||||
if let Ok(data) = frame.into_data() {
|
||||
body_bytes = body_bytes.saturating_add(data.len() as u64);
|
||||
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");
|
||||
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();
|
||||
@@ -128,7 +153,7 @@ async fn php_response_inner(
|
||||
Ok(value) => value,
|
||||
Err(error) if error.kind() == io::ErrorKind::TimedOut => {
|
||||
php.metrics.failure(5);
|
||||
return builtin_error_response(StatusCode::GATEWAY_TIMEOUT, &method)
|
||||
return builtin_error_response(StatusCode::GATEWAY_TIMEOUT, &method);
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, service = %service.id, "PHP-FPM response read failed");
|
||||
@@ -218,17 +243,24 @@ pub(super) fn method_not_allowed_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 extension = path
|
||||
.extension()
|
||||
.and_then(|value| value.to_str())?
|
||||
.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 {
|
||||
if service.php.is_some() { return false; }
|
||||
if service.php.is_some() {
|
||||
return false;
|
||||
}
|
||||
path.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.map(|value| value.to_ascii_lowercase())
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use super::{CompiledService, SymlinkPolicy};
|
||||
#[cfg(unix)]
|
||||
use std::{ffi::CString, sync::Arc};
|
||||
use std::{
|
||||
fs::File,
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
#[cfg(unix)]
|
||||
use std::{ffi::CString, sync::Arc};
|
||||
|
||||
pub(super) struct FilesystemRoot {
|
||||
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>> {
|
||||
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();
|
||||
for part in path.split('/').filter(|part| !part.is_empty()) {
|
||||
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());
|
||||
}
|
||||
@@ -93,7 +99,10 @@ pub(super) fn secure_open_sync(root: &FilesystemRoot, components: &[String]) ->
|
||||
}
|
||||
let current = unsafe { File::from_raw_fd(fd) };
|
||||
let metadata = current.metadata()?;
|
||||
return Ok(Opened { file: current, metadata });
|
||||
return Ok(Opened {
|
||||
file: current,
|
||||
metadata,
|
||||
});
|
||||
}
|
||||
let mut current: Option<File> = None;
|
||||
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",
|
||||
));
|
||||
}
|
||||
Ok(Opened { file: current, metadata })
|
||||
Ok(Opened {
|
||||
file: current,
|
||||
metadata,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -192,7 +204,10 @@ pub(super) fn list_directory_sync(
|
||||
|
||||
let opened = secure_open_sync(root, components)?;
|
||||
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 dir = unsafe { libc::fdopendir(raw) };
|
||||
@@ -241,7 +256,10 @@ pub(super) fn list_directory_sync(
|
||||
) -> io::Result<Vec<DirectoryEntry>> {
|
||||
let opened = secure_open_sync(root, components)?;
|
||||
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();
|
||||
for component in components {
|
||||
@@ -249,7 +267,10 @@ pub(super) fn list_directory_sync(
|
||||
}
|
||||
let canonical = std::fs::canonicalize(path)?;
|
||||
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();
|
||||
for entry in std::fs::read_dir(canonical)? {
|
||||
@@ -275,4 +296,3 @@ pub(super) fn list_directory_sync(
|
||||
});
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
use super::{
|
||||
config::CompiledService,
|
||||
filesystem::path_components,
|
||||
request::RequestContext,
|
||||
config::CompiledService, filesystem::path_components, request::RequestContext,
|
||||
routing::open_components,
|
||||
};
|
||||
use anyhow::{bail, ensure, Context, Result};
|
||||
@@ -119,9 +117,19 @@ struct RewriteCondition {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum ConditionMatcher {
|
||||
Regex { regex: Regex, negate: bool },
|
||||
Exact { value: String, negate: bool, insensitive: bool },
|
||||
File { kind: FileTest, negate: bool },
|
||||
Regex {
|
||||
regex: Regex,
|
||||
negate: bool,
|
||||
},
|
||||
Exact {
|
||||
value: String,
|
||||
negate: bool,
|
||||
insensitive: bool,
|
||||
},
|
||||
File {
|
||||
kind: FileTest,
|
||||
negate: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
@@ -198,7 +206,13 @@ fn tokens(value: &str) -> Result<Vec<String>> {
|
||||
fn flags(value: Option<&str>) -> Vec<String> {
|
||||
value
|
||||
.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()
|
||||
}
|
||||
|
||||
@@ -227,16 +241,26 @@ fn supported_response_header(name: &HeaderName) -> bool {
|
||||
|
||||
fn parse_duration(value: &str) -> Result<Duration> {
|
||||
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()))
|
||||
{
|
||||
let seconds: u64 = seconds.parse().context("invalid Expires duration")?;
|
||||
return Ok(Duration::from_secs(seconds));
|
||||
}
|
||||
let parts = value.split_whitespace().collect::<Vec<_>>();
|
||||
ensure!(parts.len() >= 4, "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")?;
|
||||
ensure!(
|
||||
parts.len() >= 4,
|
||||
"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 seconds = match unit.trim_end_matches('s') {
|
||||
"second" => count,
|
||||
@@ -252,32 +276,73 @@ fn parse_duration(value: &str) -> Result<Duration> {
|
||||
}
|
||||
|
||||
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 insensitive = condition_flags.iter().any(|flag| flag.eq_ignore_ascii_case("NC"));
|
||||
let or_next = condition_flags.iter().any(|flag| flag.eq_ignore_ascii_case("OR"));
|
||||
let insensitive = condition_flags
|
||||
.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 {
|
||||
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 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('=') {
|
||||
ConditionMatcher::Exact { value: value.to_owned(), negate: false, insensitive }
|
||||
ConditionMatcher::Exact {
|
||||
value: value.to_owned(),
|
||||
negate: false,
|
||||
insensitive,
|
||||
}
|
||||
} else {
|
||||
let (negate, raw) = raw.strip_prefix('!').map_or((false, raw), |v| (true, v));
|
||||
match raw {
|
||||
"-f" => ConditionMatcher::File { kind: FileTest::File, negate },
|
||||
"-d" => ConditionMatcher::File { kind: FileTest::Directory, negate },
|
||||
"-s" => ConditionMatcher::File { kind: FileTest::NonEmptyFile, negate },
|
||||
_ => ConditionMatcher::Regex { regex: ci_regex(raw, insensitive)?, negate },
|
||||
"-f" => ConditionMatcher::File {
|
||||
kind: FileTest::File,
|
||||
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> {
|
||||
ensure!(parts.len() >= 3, "RewriteRule needs a pattern and substitution");
|
||||
fn parse_rewrite_rule(
|
||||
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 mut redirect = None;
|
||||
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> {
|
||||
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)
|
||||
}
|
||||
|
||||
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 {
|
||||
bail!("{}:{line}: {message}", file.display());
|
||||
}
|
||||
state.unsupported.push(format!("{}:{line}: {message}", file.display()));
|
||||
state
|
||||
.unsupported
|
||||
.push(format!("{}:{line}: {message}", file.display()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -344,17 +424,33 @@ fn read_htaccess_nofollow(path: &Path) -> Result<String> {
|
||||
#[cfg(not(unix))]
|
||||
let mut file = {
|
||||
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)?
|
||||
};
|
||||
let metadata = file.metadata()?;
|
||||
ensure!(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());
|
||||
ensure!(
|
||||
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();
|
||||
(&mut file)
|
||||
.take(MAX_HTACCESS_FILE_BYTES + 1)
|
||||
.read_to_string(&mut content)
|
||||
.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)
|
||||
}
|
||||
@@ -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> {
|
||||
let Some(compiled) = compiled else { return Ok(RewriteOutcome::Internal { path: initial_path.to_owned(), query: initial_query.map(str::to_owned) }); };
|
||||
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> {
|
||||
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 query = initial_query.map(str::to_owned);
|
||||
let mut exhausted = false;
|
||||
@@ -10,37 +24,95 @@ pub async fn apply_rewrites(compiled: Option<&Compiled>, service: &CompiledServi
|
||||
'scope: for scope in scopes {
|
||||
for rule in &scope.rewrites {
|
||||
let relative = scope_relative_path(scope, &path);
|
||||
if !rule.pattern.is_match(&relative) { continue; }
|
||||
let (conditions_ok, condition_captures) = conditions_match(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); }
|
||||
if !rule.pattern.is_match(&relative) {
|
||||
continue;
|
||||
}
|
||||
let (conditions_ok, condition_captures) = conditions_match(
|
||||
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);
|
||||
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 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 (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 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 {
|
||||
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),
|
||||
(None, old) => old,
|
||||
}
|
||||
} else { replacement_query.or_else(|| query.clone()) };
|
||||
} else {
|
||||
replacement_query.or_else(|| query.clone())
|
||||
};
|
||||
if let Some(status) = rule.redirect {
|
||||
let mut location = target;
|
||||
if let Some(query) = next_query.filter(|q| !q.is_empty()) { location.push('?'); location.push_str(&query); }
|
||||
return Ok(RewriteOutcome::Redirect(redirect_response(status, &location)?));
|
||||
if let Some(query) = next_query.filter(|q| !q.is_empty()) {
|
||||
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.last { break 'scope; }
|
||||
if rule.end {
|
||||
end = true;
|
||||
break 'scope;
|
||||
}
|
||||
if rule.last {
|
||||
break 'scope;
|
||||
}
|
||||
}
|
||||
}
|
||||
if end || !changed { break; }
|
||||
if pass == 7 { exhausted = true; }
|
||||
if end || !changed {
|
||||
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 })
|
||||
}
|
||||
@@ -10,7 +10,6 @@ pub struct RequestContext {
|
||||
pub version: Version,
|
||||
}
|
||||
|
||||
|
||||
pub(super) fn server_protocol(version: http::Version) -> &'static str {
|
||||
match version {
|
||||
http::Version::HTTP_09 => "HTTP/0.9",
|
||||
|
||||
@@ -161,13 +161,22 @@ async fn file_response_inner(
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
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
|
||||
{
|
||||
opened.file.try_clone().ok().map(|file| crate::server::sendfile::SendfileCandidate {
|
||||
file: std::sync::Arc::new(file), length: body_length,
|
||||
})
|
||||
} else { None };
|
||||
opened
|
||||
.file
|
||||
.try_clone()
|
||||
.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);
|
||||
if let Some(boundary) = boundary {
|
||||
let ranges = ranges.expect("multipart ranges");
|
||||
@@ -203,7 +212,8 @@ async fn file_response_inner(
|
||||
.await;
|
||||
});
|
||||
*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;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
use bytes::Bytes;
|
||||
use futures_util::Stream;
|
||||
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> {
|
||||
stream: Option<S>,
|
||||
@@ -10,7 +14,10 @@ pub(super) struct FileBody<S> {
|
||||
|
||||
impl<S> FileBody<S> {
|
||||
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<'_>,
|
||||
) -> Poll<Option<Result<Frame<Bytes>, io::Error>>> {
|
||||
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) {
|
||||
Poll::Ready(Some(Ok(data))) => {
|
||||
if data.len() as u64 > this.remaining {
|
||||
this.stream = None;
|
||||
this.remaining = 0;
|
||||
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;
|
||||
@@ -46,7 +56,8 @@ where
|
||||
this.stream = None;
|
||||
this.remaining = 0;
|
||||
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))) => {
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
assert_eq!(body.size_hint().exact(), Some(6));
|
||||
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!(!body.is_end_stream());
|
||||
assert_eq!(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.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!(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());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
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);
|
||||
assert_eq!(short.frame().await.unwrap().unwrap().into_data().unwrap(), "abc");
|
||||
assert_eq!(short.frame().await.unwrap().unwrap_err().kind(), io::ErrorKind::UnexpectedEof);
|
||||
assert_eq!(
|
||||
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.frame().await.is_none());
|
||||
|
||||
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());
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -51,10 +75,24 @@ async fn file_shrunk_after_open_returns_unexpected_eof() {
|
||||
tokio::fs::write(&path, b"abcdef").await.unwrap();
|
||||
let file = tokio::fs::File::open(&path).await.unwrap();
|
||||
let expected = file.metadata().await.unwrap().len();
|
||||
std::fs::OpenOptions::new().write(true).open(path).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);
|
||||
std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(path)
|
||||
.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.stream.is_none());
|
||||
}
|
||||
@@ -67,12 +105,22 @@ async fn zero_length_file_is_terminal_without_polling_the_reader() {
|
||||
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 {
|
||||
let mut response = Response::new(body);
|
||||
response.headers_mut().insert("cache-control", HeaderValue::from_static("public, max-age=600"));
|
||||
response.headers_mut().insert("content-length", length.to_string().parse().unwrap());
|
||||
response.headers_mut().insert(
|
||||
"cache-control",
|
||||
HeaderValue::from_static("public, max-age=600"),
|
||||
);
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("content-length", length.to_string().parse().unwrap());
|
||||
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 cache = Cache::new(1);
|
||||
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 plan = cache::plan(StatusCode::OK, 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");
|
||||
let plan = cache::plan(
|
||||
StatusCode::OK,
|
||||
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);
|
||||
assert_eq!(cache.view().entries, 1);
|
||||
assert_eq!(cache.view().rejected_fills, 0);
|
||||
assert_eq!(cache.view().fills_active, 0);
|
||||
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]
|
||||
@@ -103,14 +167,35 @@ async fn incomplete_or_cancelled_file_cannot_fill_cache() {
|
||||
for short in [false, true] {
|
||||
let cache = Cache::new(1);
|
||||
let settings = settings();
|
||||
let ticket = cache.ticket(1, "http", "localhost", "site", "/file", &HeaderMap::new()).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 ticket = cache
|
||||
.ticket(1, "http", "localhost", "site", "/file", &HeaderMap::new())
|
||||
.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 plan = cache::plan(StatusCode::OK, 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(), "abc");
|
||||
if short { assert!(body.frame().await.unwrap().is_err()); }
|
||||
let plan = cache::plan(
|
||||
StatusCode::OK,
|
||||
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(),
|
||||
"abc"
|
||||
);
|
||||
if short {
|
||||
assert!(body.frame().await.unwrap().is_err());
|
||||
}
|
||||
drop(body);
|
||||
assert!(cache.get(&ticket).is_none());
|
||||
assert_eq!(cache.view().rejected_fills, 1);
|
||||
|
||||
@@ -8,7 +8,10 @@ use axum::{body::Body, response::Response};
|
||||
use http::{header::LOCATION, HeaderMap, HeaderValue, StatusCode};
|
||||
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);
|
||||
tokio::task::spawn_blocking(move || secure_open_sync(&root, &components))
|
||||
.await
|
||||
@@ -28,7 +31,10 @@ fn target_path_and_query(
|
||||
Some((path, query)) => (path.to_owned(), Some(query.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)
|
||||
.map_err(|_| anyhow::anyhow!("invalid Web Server internal target"))?;
|
||||
Ok((path, query))
|
||||
@@ -54,11 +60,7 @@ pub(super) fn apply_rewrites(
|
||||
.pattern
|
||||
.replace(&path, rule.replacement.as_str())
|
||||
.to_string();
|
||||
let (next_path, next_query) = target_path_and_query(
|
||||
&replacement,
|
||||
&path,
|
||||
query.as_deref(),
|
||||
)?;
|
||||
let (next_path, next_query) = target_path_and_query(&replacement, &path, query.as_deref())?;
|
||||
if let Some(status) = rule.status {
|
||||
let mut target = next_path;
|
||||
if let Some(query) = next_query.filter(|value| !value.is_empty()) {
|
||||
@@ -142,7 +144,9 @@ pub(super) async fn select_precompressed(
|
||||
continue;
|
||||
}
|
||||
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);
|
||||
if let Ok(opened) = open_components(service, sidecar).await {
|
||||
if opened.metadata.is_file() {
|
||||
@@ -152,4 +156,3 @@ pub(super) async fn select_precompressed(
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
@@ -35,8 +35,8 @@ pub(super) fn hardening_denied_path(service: &CompiledService, path: &str) -> bo
|
||||
"id_ed25519",
|
||||
];
|
||||
let backup_suffixes = [
|
||||
".bak", ".backup", ".old", ".orig", ".save", ".swp", ".swo", ".sql",
|
||||
".sqlite", ".sqlite3", ".db",
|
||||
".bak", ".backup", ".old", ".orig", ".save", ".swp", ".swo", ".sql", ".sqlite", ".sqlite3",
|
||||
".db",
|
||||
];
|
||||
path_components(path).ok().is_some_and(|components| {
|
||||
components.iter().any(|component| {
|
||||
@@ -46,14 +46,18 @@ pub(super) fn hardening_denied_path(service: &CompiledService, path: &str) -> bo
|
||||
|| component.starts_with(".env.")
|
||||
|| sensitive_names.contains(&component.as_str())
|
||||
|| 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 {
|
||||
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) {
|
||||
@@ -73,4 +77,3 @@ pub(super) fn strip_unnecessary_headers(response: &mut Response, service: &Compi
|
||||
response.headers_mut().remove(name);
|
||||
}
|
||||
}
|
||||
|
||||
+28
-5
@@ -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()["x-content-type-options"], "nosniff");
|
||||
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)
|
||||
.unwrap()
|
||||
.as_bytes()
|
||||
@@ -210,7 +216,9 @@ async fn internal_filesystem_diagnostics_never_appear_in_public_error_responses(
|
||||
.unwrap()
|
||||
.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.headers()[CONTENT_TYPE], "text/html; charset=utf-8");
|
||||
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)
|
||||
.unwrap()
|
||||
.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.headers()[CONTENT_TYPE], "text/html; charset=utf-8");
|
||||
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 = std::str::from_utf8(&fallback_html).unwrap();
|
||||
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],
|
||||
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]
|
||||
|
||||
Reference in new issue
Block a user