v0.34.0_1
This commit is contained in:
1 parent
9b3d6731cc
commit
00f29f92c8
68 files changed
+2599
-513
No files matched your search
@@ -5415,6 +5415,12 @@
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"sendfile": {
|
||||
"type": "string",
|
||||
"enum": ["off", "auto"],
|
||||
"default": "off",
|
||||
"description": "Linux TCP HTTP/1.1 kernel sendfile for eligible unmodified static GET responses. Transport-only; off by default."
|
||||
},
|
||||
"mime_types": {
|
||||
"type": "object",
|
||||
"maxProperties": 64,
|
||||
|
||||
@@ -3567,6 +3567,11 @@
|
||||
responses are never stored.</p>
|
||||
</div>
|
||||
<div class="vh-grid web-service-page" data-web-service-page="delivery" hidden>
|
||||
<label class="vh-wide"><span data-i18n="webServiceSendfile">Linux sendfile (zero-copy)</span>
|
||||
<select id="web-service-sendfile">
|
||||
<option value="off" data-i18n="notEnabled">Disabled</option>
|
||||
<option value="auto" data-i18n="webServiceSendfileAuto">Auto (eligible plain HTTP/1.1)</option>
|
||||
</select><small class="fine" data-i18n="webServiceSendfileHelp">Opt-in for static GET files at least 64 KiB on Linux HTTP/1.1 without TLS, cache, compression or transform. Other requests use safe streaming; switching this setting never clears the cache.</small></label>
|
||||
<label><span data-i18n="webServiceCompression">Dynamic compression</span><select
|
||||
id="web-service-compression">
|
||||
<option value="" data-i18n="inherit">Inherit</option>
|
||||
|
||||
@@ -199,7 +199,7 @@
|
||||
|
||||
function defaults() {
|
||||
return {
|
||||
id: "", name: "", document_root: "", index: ["index.html", "index.htm"], directory_listing: false,
|
||||
id: "", name: "", document_root: "", index: ["index.html", "index.htm"], directory_listing: false, sendfile: "off",
|
||||
mime_types: {}, default_mime_type: "application/octet-stream", cache_control: null, cache_rules: [], response_headers: {},
|
||||
try_files: [], rewrites: [], compression: { enabled: null, gzip: null, br: null, zstd: null, deflate: null }, precompressed: { enabled: false, brotli: true, gzip: true }, download_extensions: [],
|
||||
symlink_policy: "deny", php_profile_id: null, php: {
|
||||
@@ -236,6 +236,7 @@
|
||||
function populateSettings(item) {
|
||||
$("web-service-index").value = (item.index || []).join("\n");
|
||||
$("web-service-listing").checked = item.directory_listing === true;
|
||||
$("web-service-sendfile").value = item.sendfile === "auto" ? "auto" : "off";
|
||||
$("web-service-try-files").value = (item.try_files || []).join("\n");
|
||||
$("web-service-rewrites").value = rewriteLines(item.rewrites);
|
||||
$("web-service-mime-types").value = mimeLines(item.mime_types);
|
||||
@@ -467,6 +468,7 @@
|
||||
document_root: $("web-service-root").value.trim(),
|
||||
index: lines($("web-service-index").value),
|
||||
directory_listing: $("web-service-listing").checked,
|
||||
sendfile: $("web-service-sendfile").value,
|
||||
mime_types: parseMimeTypes($("web-service-mime-types").value),
|
||||
default_mime_type: $("web-service-default-mime").value.trim() || "application/octet-stream",
|
||||
cache_control: $("web-service-cache-control").value.trim() || null,
|
||||
|
||||
+19
-2
@@ -2,6 +2,7 @@ impl BackendState {
|
||||
fn new(mode: HealthMode) -> Self {
|
||||
let now = Instant::now();
|
||||
Self {
|
||||
healthy_cached: AtomicBool::new(mode == HealthMode::Off),
|
||||
health: Mutex::new(Health {
|
||||
healthy: mode == HealthMode::Off,
|
||||
active_healthy: mode == HealthMode::Off,
|
||||
@@ -111,6 +112,11 @@ impl BackendState {
|
||||
}
|
||||
}
|
||||
pub fn healthy(&self) -> bool {
|
||||
// No lock/clock acquisition for a healthy backend. Only an unhealthy
|
||||
// backend enters the quarantine/cooldown recovery path below.
|
||||
if self.healthy_cached.load(Ordering::Acquire) {
|
||||
return true;
|
||||
}
|
||||
let mut h = lock(&self.health);
|
||||
// Passive-only monitoring has no active probes to recover an ejected
|
||||
// backend. After a bounded quarantine, allow one new traffic attempt.
|
||||
@@ -125,6 +131,12 @@ impl BackendState {
|
||||
}
|
||||
h.healthy
|
||||
}
|
||||
#[cfg(test)]
|
||||
fn set_healthy_for_test(&self, healthy: bool) {
|
||||
lock(&self.health).healthy = healthy;
|
||||
self.healthy_cached.store(healthy, Ordering::Release);
|
||||
}
|
||||
|
||||
fn clear_passive_ejection(h: &mut Health) {
|
||||
h.passive_healthy = true;
|
||||
h.passive_failures = 0;
|
||||
@@ -195,6 +207,7 @@ impl BackendState {
|
||||
|
||||
fn finish_transition(&self, h: &mut Health, previous: bool) {
|
||||
h.healthy = h.active_healthy && h.passive_healthy;
|
||||
self.healthy_cached.store(h.healthy, Ordering::Release);
|
||||
if previous != h.healthy {
|
||||
h.transitions = h.transitions.saturating_add(1);
|
||||
if h.healthy {
|
||||
@@ -446,6 +459,10 @@ pub struct Member {
|
||||
pub backend: Arc<CompiledBackend>,
|
||||
pub definition: PoolMember,
|
||||
pub state: Arc<BackendState>,
|
||||
// Immutable effective per-backend policies are compiled once per pool
|
||||
// generation, not deep-cloned for every request/lease.
|
||||
health_settings: Arc<HealthCheck>,
|
||||
passive_settings: Arc<PassiveHealth>,
|
||||
token: String,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
@@ -461,7 +478,7 @@ pub struct Pool {
|
||||
#[derive(Debug)]
|
||||
pub struct Lease {
|
||||
pub member: Arc<Member>,
|
||||
health: HealthCheck,
|
||||
passive_health: PassiveHealth,
|
||||
health: Arc<HealthCheck>,
|
||||
passive_health: Arc<PassiveHealth>,
|
||||
error_recorded: AtomicBool,
|
||||
}
|
||||
@@ -386,6 +386,9 @@ impl Default for FailureObservation {
|
||||
}
|
||||
#[derive(Debug)]
|
||||
pub struct BackendState {
|
||||
// Lock-free healthy lookups on the hot request-selection path. The mutex
|
||||
// remains authoritative for transitions, probes and quarantine expiry.
|
||||
healthy_cached: AtomicBool,
|
||||
health: Mutex<Health>,
|
||||
traffic: Mutex<Traffic>,
|
||||
last_failure: Mutex<FailureObservation>,
|
||||
|
||||
+78
-46
@@ -20,10 +20,14 @@ impl Pool {
|
||||
.cloned()
|
||||
.zip(backends)
|
||||
.map(|(definition, backend)| {
|
||||
let health_settings = Arc::new(settings.health_for(&definition));
|
||||
let passive_settings = Arc::new(settings.passive_for(&definition));
|
||||
Arc::new(Member {
|
||||
state: Arc::new(BackendState::new(settings.health_for(&definition).mode)),
|
||||
state: Arc::new(BackendState::new(health_settings.mode)),
|
||||
definition,
|
||||
backend,
|
||||
health_settings,
|
||||
passive_settings,
|
||||
token: rand::rng()
|
||||
.sample_iter(Alphanumeric)
|
||||
.take(32)
|
||||
@@ -88,7 +92,7 @@ impl Pool {
|
||||
}
|
||||
}
|
||||
fn candidates_excluding(&self, excluded: &[String]) -> Vec<&Arc<Member>> {
|
||||
let all: Vec<_> = self
|
||||
let mut eligible: Vec<_> = self
|
||||
.members
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
@@ -100,10 +104,10 @@ impl Pool {
|
||||
&& !excluded.iter().any(|id| id == &m.definition.id)
|
||||
})
|
||||
.collect();
|
||||
let primary = all.iter().any(|m| !m.definition.backup);
|
||||
all.into_iter()
|
||||
.filter(|m| !primary || !m.definition.backup)
|
||||
.collect()
|
||||
if eligible.iter().any(|m| !m.definition.backup) {
|
||||
eligible.retain(|m| !m.definition.backup);
|
||||
}
|
||||
eligible
|
||||
}
|
||||
pub fn ready(&self) -> bool {
|
||||
!self.eligible_healthy_excluding(&[]).is_empty()
|
||||
@@ -197,8 +201,8 @@ impl Pool {
|
||||
Some(Selection {
|
||||
lease: Arc::new(Lease {
|
||||
member: member.clone(),
|
||||
health: self.settings.health_for(&member.definition),
|
||||
passive_health: self.settings.passive_for(&member.definition),
|
||||
health: member.health_settings.clone(),
|
||||
passive_health: member.passive_settings.clone(),
|
||||
error_recorded: AtomicBool::new(false),
|
||||
}),
|
||||
cookie: None,
|
||||
@@ -211,7 +215,32 @@ impl Pool {
|
||||
tls: bool,
|
||||
excluded: &[String],
|
||||
) -> Option<Selection> {
|
||||
let mut scheduling = lock(&self.scheduling);
|
||||
// Most deployments have one healthy backend. For non-cookie modes it
|
||||
// needs neither candidate Vecs, weight scoring nor the shared scheduler
|
||||
// mutex. Still use the same atomic capacity lease and health gate.
|
||||
if self.members.len() == 1 && !self.cookie_affinity() {
|
||||
let member = &self.members[0];
|
||||
if !member.definition.pool_eligible
|
||||
|| !member.definition.enabled
|
||||
|| member.definition.drain
|
||||
|| !member.state.healthy()
|
||||
|| excluded.iter().any(|id| id == &member.definition.id)
|
||||
|| !member
|
||||
.state
|
||||
.try_acquire_active(member.definition.max_concurrent_requests)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
return Some(Selection {
|
||||
lease: Arc::new(Lease {
|
||||
member: member.clone(),
|
||||
health: member.health_settings.clone(),
|
||||
passive_health: member.passive_settings.clone(),
|
||||
error_recorded: AtomicBool::new(false),
|
||||
}),
|
||||
cookie: None,
|
||||
});
|
||||
}
|
||||
let members = self.candidates_excluding(excluded);
|
||||
let existing = if self.cookie_affinity() {
|
||||
self.affinity(headers)
|
||||
@@ -237,27 +266,24 @@ impl Pool {
|
||||
let selected = if let Some(member) = matched {
|
||||
member
|
||||
} else {
|
||||
let weights: Vec<f64> = members
|
||||
.iter()
|
||||
.map(|m| {
|
||||
m.state
|
||||
.weight(m.definition.weight, self.settings.slow_start_seconds)
|
||||
})
|
||||
.collect();
|
||||
if self.settings.algorithm == Algorithm::RoundRobin
|
||||
&& self.settings.sticky.mode != StickyMode::IpHash
|
||||
{
|
||||
// Smooth weighted round-robin: distribute weighted requests,
|
||||
// rather than sending long consecutive bursts to one member.
|
||||
// Smooth weighted round-robin keeps its historical state and
|
||||
// global ordering. Only that algorithm takes the mutex.
|
||||
let mut scheduling = lock(&self.scheduling);
|
||||
let mut chosen = members[0];
|
||||
let mut best = f64::NEG_INFINITY;
|
||||
let mut total = 0.0;
|
||||
for (member, weight) in members.iter().zip(&weights) {
|
||||
for member in &members {
|
||||
let weight = member
|
||||
.state
|
||||
.weight(member.definition.weight, self.settings.slow_start_seconds);
|
||||
let current = scheduling
|
||||
.entry(member.definition.id.clone())
|
||||
.or_insert(0.0);
|
||||
*current += *weight;
|
||||
total += *weight;
|
||||
*current += weight;
|
||||
total += weight;
|
||||
if *current > best {
|
||||
best = *current;
|
||||
chosen = *member;
|
||||
@@ -270,43 +296,49 @@ impl Pool {
|
||||
} else {
|
||||
let mut chosen = members[(cursor as usize) % members.len()];
|
||||
let mut best = f64::INFINITY;
|
||||
let algorithm = if self.settings.sticky.mode == StickyMode::IpHash {
|
||||
Algorithm::IpHash
|
||||
} else {
|
||||
self.settings.algorithm
|
||||
};
|
||||
// Canonicalize the address once, not for every candidate. The
|
||||
// SHA-256 input and rendezvous scoring remain bit-for-bit equal.
|
||||
let hash_ip = (algorithm == Algorithm::IpHash)
|
||||
.then(|| crate::vhost::canonical_ip(peer).to_string());
|
||||
for offset in 0..members.len() {
|
||||
let index = ((cursor as usize) % members.len() + offset) % members.len();
|
||||
let member = members[index];
|
||||
let active = member.state.active.load(Ordering::Relaxed) as f64;
|
||||
let algorithm = if self.settings.sticky.mode == StickyMode::IpHash {
|
||||
Algorithm::IpHash
|
||||
} else {
|
||||
self.settings.algorithm
|
||||
};
|
||||
let score = match algorithm {
|
||||
Algorithm::IpHash => {
|
||||
let digest = Sha256::digest(
|
||||
format!(
|
||||
"{}|{}|{}",
|
||||
self.id,
|
||||
crate::vhost::canonical_ip(peer),
|
||||
member.definition.id
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
let mut hash = Sha256::new();
|
||||
hash.update(self.id.as_bytes());
|
||||
hash.update(b"|");
|
||||
hash.update(hash_ip.as_deref().expect("hash address").as_bytes());
|
||||
hash.update(b"|");
|
||||
hash.update(member.definition.id.as_bytes());
|
||||
let digest = hash.finalize();
|
||||
let n = u64::from_be_bytes([
|
||||
digest[0], digest[1], digest[2], digest[3], digest[4], digest[5],
|
||||
digest[6], digest[7],
|
||||
]);
|
||||
// Weighted rendezvous: stable across requests, no client-controlled forwarding headers.
|
||||
let unit = (n as f64 + 1.0) / (u64::MAX as f64 + 2.0);
|
||||
-unit.max(f64::MIN_POSITIVE).ln() / member.definition.weight as f64
|
||||
}
|
||||
Algorithm::LeastTraffic => {
|
||||
(member.state.traffic_rate() + (active + 1.0) * 1024.0) / weights[index]
|
||||
_ => {
|
||||
let weight = member.state.weight(
|
||||
member.definition.weight,
|
||||
self.settings.slow_start_seconds,
|
||||
);
|
||||
match algorithm {
|
||||
Algorithm::LeastTraffic =>
|
||||
(member.state.traffic_rate() + (active + 1.0) * 1024.0) / weight,
|
||||
Algorithm::Adaptive =>
|
||||
((active + 1.0) * member.state.latency()
|
||||
+ member.state.traffic_rate() / 16_384.0) / weight,
|
||||
_ => (active + 1.0) / weight,
|
||||
}
|
||||
}
|
||||
Algorithm::Adaptive => {
|
||||
((active + 1.0) * member.state.latency()
|
||||
+ member.state.traffic_rate() / 16_384.0)
|
||||
/ weights[index]
|
||||
}
|
||||
_ => (active + 1.0) / weights[index],
|
||||
};
|
||||
if score < best {
|
||||
best = score;
|
||||
@@ -338,8 +370,8 @@ impl Pool {
|
||||
Some(Selection {
|
||||
lease: Arc::new(Lease {
|
||||
member: selected.clone(),
|
||||
health: self.settings.health_for(&selected.definition),
|
||||
passive_health: self.settings.passive_for(&selected.definition),
|
||||
health: selected.health_settings.clone(),
|
||||
passive_health: selected.passive_settings.clone(),
|
||||
error_recorded: AtomicBool::new(false),
|
||||
}),
|
||||
cookie,
|
||||
|
||||
+43
-5
@@ -533,7 +533,7 @@ mod tests {
|
||||
headers.insert("cookie", format!("{cookie}; {cookie}").parse().unwrap());
|
||||
assert!(pool.affinity(&headers).is_none());
|
||||
headers.insert("cookie", cookie.parse().unwrap());
|
||||
lock(&first.lease.member.state.health).healthy = false;
|
||||
first.lease.member.state.set_healthy_for_test(false);
|
||||
let next = pool
|
||||
.select("127.0.0.1".parse().unwrap(), &headers, false)
|
||||
.unwrap();
|
||||
@@ -542,7 +542,7 @@ mod tests {
|
||||
first.lease.member.definition.id
|
||||
);
|
||||
assert!(next.cookie.is_some());
|
||||
lock(&next.lease.member.state.health).healthy = false;
|
||||
next.lease.member.state.set_healthy_for_test(false);
|
||||
assert!(pool
|
||||
.select("127.0.0.1".parse().unwrap(), &headers, false)
|
||||
.is_none());
|
||||
@@ -611,9 +611,9 @@ mod tests {
|
||||
for _ in 0..8 {
|
||||
assert_eq!(select(&pool).lease.member.definition.id, "a");
|
||||
}
|
||||
lock(&pool.members[0].state.health).healthy = false;
|
||||
pool.members[0].state.set_healthy_for_test(false);
|
||||
assert_eq!(select(&pool).lease.member.definition.id, "b");
|
||||
lock(&pool.members[0].state.health).healthy = true;
|
||||
pool.members[0].state.set_healthy_for_test(true);
|
||||
assert_eq!(select(&pool).lease.member.definition.id, "a");
|
||||
}
|
||||
#[tokio::test]
|
||||
@@ -635,7 +635,7 @@ mod tests {
|
||||
.select("127.0.0.1".parse().unwrap(), &headers, false)
|
||||
.unwrap();
|
||||
assert_eq!(existing.lease.member.definition.id, "a");
|
||||
lock(&pool.members[0].state.health).healthy = false;
|
||||
pool.members[0].state.set_healthy_for_test(false);
|
||||
assert_eq!(
|
||||
pool.select("127.0.0.1".parse().unwrap(), &headers, false)
|
||||
.unwrap()
|
||||
@@ -764,4 +764,42 @@ mod tests {
|
||||
assert!(state.healthy());
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn single_backend_fast_path_observes_capacity_and_health_without_affinity_cookie() {
|
||||
let pool = capacity_pool(1, 0);
|
||||
assert_eq!(pool.members.len(), 1);
|
||||
let peer = "127.0.0.1".parse().unwrap();
|
||||
let headers = HeaderMap::new();
|
||||
let first = pool.select(peer, &headers, false).expect("healthy backend");
|
||||
assert!(first.cookie.is_none());
|
||||
assert!(pool.select(peer, &headers, false).is_none());
|
||||
drop(first);
|
||||
pool.members[0].state.set_healthy_for_test(false);
|
||||
assert!(pool.select(peer, &headers, false).is_none());
|
||||
pool.members[0].state.set_healthy_for_test(true);
|
||||
assert!(pool.select(peer, &headers, false).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leases_reuse_immutable_backend_health_policies() {
|
||||
let pool = capacity_pool(0, 0);
|
||||
let first = select(&pool);
|
||||
let second = select(&pool);
|
||||
assert!(Arc::ptr_eq(&first.lease.health, &second.lease.health));
|
||||
assert!(Arc::ptr_eq(&first.lease.passive_health, &second.lease.passive_health));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn healthy_fast_path_tracks_active_and_passive_ejections() {
|
||||
let state = BackendState::new(HealthMode::Off);
|
||||
assert!(state.healthy());
|
||||
let active = settings(HealthMode::Off);
|
||||
let passive = PassiveHealth { enabled: true, fall: Some(1), rise: Some(1), ..Default::default() };
|
||||
state.observe_passive(&active, &passive, "connect_error", Duration::from_millis(2));
|
||||
assert!(!state.healthy());
|
||||
// Preserve quarantine rules; an active probe is not invented here.
|
||||
state.observe_passive(&active, &passive, "ok", Duration::from_millis(2));
|
||||
assert!(state.healthy());
|
||||
}
|
||||
} // mod tests
|
||||
Vendored
+8
@@ -51,11 +51,15 @@ impl Cache {
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
return false;
|
||||
};
|
||||
let scope_revision = self.scope_revisions
|
||||
.lock().unwrap_or_else(|e| e.into_inner())
|
||||
.get(&entry.scope).copied().unwrap_or(0);
|
||||
self.cold_queue_items.fetch_add(1, Ordering::Relaxed);
|
||||
if tx
|
||||
.try_send(ColdCommand::Store(ColdDraft {
|
||||
key,
|
||||
revision,
|
||||
scope_revision,
|
||||
generation,
|
||||
scope_generation,
|
||||
key_generation,
|
||||
@@ -186,9 +190,13 @@ impl Cache {
|
||||
}),
|
||||
}
|
||||
});
|
||||
let scope_revision = self.scope_revisions
|
||||
.lock().unwrap_or_else(|e| e.into_inner())
|
||||
.get(&entry.scope).copied().unwrap_or(0);
|
||||
ColdDraft {
|
||||
key,
|
||||
revision,
|
||||
scope_revision,
|
||||
generation,
|
||||
scope_generation,
|
||||
key_generation,
|
||||
|
||||
Vendored
+7
-1
@@ -72,7 +72,13 @@ impl Cache {
|
||||
continue;
|
||||
};
|
||||
let body_path = shard.join(&file.body_file);
|
||||
if file.format != COLD_FORMAT_VERSION || file.revision != revision {
|
||||
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
|
||||
{
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(&body_path);
|
||||
continue;
|
||||
|
||||
Vendored
+1
@@ -121,6 +121,7 @@ impl Cache {
|
||||
base_key: key_hex(&draft.entry.base_key),
|
||||
vary: draft.entry.vary.clone(),
|
||||
revision: draft.revision,
|
||||
scope_revision: draft.scope_revision,
|
||||
scope: draft.entry.scope.clone(),
|
||||
scheme: draft.entry.scheme.clone(),
|
||||
authority: draft.entry.authority.clone(),
|
||||
|
||||
Vendored
+23
@@ -8,6 +8,7 @@ impl Cache {
|
||||
revision: AtomicU64::new(revision),
|
||||
cold: Arc::new(Mutex::new(ColdStore::default())),
|
||||
tier_settings: Mutex::new(Settings::default()),
|
||||
scope_revisions: Mutex::new(HashMap::new()),
|
||||
data_root: None,
|
||||
cold_tx: None,
|
||||
cold_delete_fallbacks: Arc::new(Semaphore::new(COLD_DELETE_FALLBACKS)),
|
||||
@@ -37,6 +38,16 @@ impl Cache {
|
||||
data_root: Option<PathBuf>,
|
||||
settings: Settings,
|
||||
shutdown: CancellationToken,
|
||||
) -> Arc<Self> {
|
||||
Self::start_with_scope_revisions(revision, data_root, settings, shutdown, HashMap::new())
|
||||
}
|
||||
|
||||
pub fn start_with_scope_revisions(
|
||||
revision: u64,
|
||||
data_root: Option<PathBuf>,
|
||||
settings: Settings,
|
||||
shutdown: CancellationToken,
|
||||
scope_revisions: HashMap<String, u64>,
|
||||
) -> Arc<Self> {
|
||||
let (tx, rx) = mpsc::channel(COLD_QUEUE);
|
||||
let cache = Arc::new(Self {
|
||||
@@ -47,6 +58,7 @@ impl Cache {
|
||||
revision: AtomicU64::new(revision),
|
||||
cold: Arc::new(Mutex::new(ColdStore::default())),
|
||||
tier_settings: Mutex::new(settings),
|
||||
scope_revisions: Mutex::new(scope_revisions),
|
||||
data_root,
|
||||
cold_tx: Some(tx),
|
||||
cold_delete_fallbacks: Arc::new(Semaphore::new(COLD_DELETE_FALLBACKS)),
|
||||
@@ -291,6 +303,17 @@ impl Cache {
|
||||
}
|
||||
}
|
||||
|
||||
/// Block requests bound to an old immutable routing snapshot from creating
|
||||
/// cache tickets during a concurrent configuration publication. This does
|
||||
/// not clear HOT or COLD and does not modify the representation revision.
|
||||
pub fn set_control_revision(&self, revision: u64) {
|
||||
self.store.lock().unwrap_or_else(|e| e.into_inner()).control_revision = revision;
|
||||
}
|
||||
|
||||
pub fn replace_scope_revisions(&self, scopes: HashMap<String, u64>) {
|
||||
*self.scope_revisions.lock().unwrap_or_else(|e| e.into_inner()) = scopes;
|
||||
}
|
||||
|
||||
pub fn configure(&self, settings: Settings) {
|
||||
*self.tier_settings.lock().unwrap_or_else(|e| e.into_inner()) = settings.clone();
|
||||
// Hot-applied limit reductions take effect immediately for published HOT
|
||||
|
||||
Vendored
+7
@@ -201,6 +201,9 @@ struct ColdFile {
|
||||
#[serde(default)]
|
||||
vary: Vec<String>,
|
||||
revision: u64,
|
||||
/// 0 denotes legacy/unscoped entries; production vhosts require an exact match.
|
||||
#[serde(default)]
|
||||
scope_revision: u64,
|
||||
scope: String,
|
||||
#[serde(default = "default_cache_scheme")]
|
||||
scheme: String,
|
||||
@@ -296,6 +299,7 @@ struct ColdStore {
|
||||
struct ColdDraft {
|
||||
key: Key,
|
||||
revision: u64,
|
||||
scope_revision: u64,
|
||||
generation: u64,
|
||||
scope_generation: u64,
|
||||
key_generation: u64,
|
||||
@@ -321,6 +325,9 @@ struct Store {
|
||||
sequence: u64,
|
||||
generation: u64,
|
||||
revision: u64,
|
||||
/// Current persisted administrative config revision. Distinct from cache
|
||||
/// representation revision: operational saves do not evict data.
|
||||
control_revision: u64,
|
||||
scope_generations: HashMap<String, u64>,
|
||||
key_generations: HashMap<Key, u64>,
|
||||
domains: BTreeMap<(String, String), DomainCounters>,
|
||||
|
||||
Vendored
+2
@@ -55,6 +55,8 @@ pub struct Cache {
|
||||
revision: AtomicU64,
|
||||
cold: Arc<Mutex<ColdStore>>,
|
||||
tier_settings: Mutex<Settings>,
|
||||
/// Per-vhost content compatibility used by persisted COLD metadata.
|
||||
scope_revisions: Mutex<HashMap<String, u64>>,
|
||||
data_root: Option<PathBuf>,
|
||||
cold_tx: Option<mpsc::Sender<ColdCommand>>,
|
||||
cold_delete_fallbacks: Arc<Semaphore>,
|
||||
|
||||
Vendored
+35
@@ -774,4 +774,39 @@ fn cold_path_cannot_escape_data_directory() {
|
||||
settings.cold_path = "cache/cold".into();
|
||||
assert!(settings.validate().is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cold_scope_fingerprint_preserves_unmodified_vhost_and_rejects_stale_restart() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let settings = cold_settings();
|
||||
let shutdown = CancellationToken::new();
|
||||
let mut fingerprints = HashMap::new();
|
||||
fingerprints.insert("cold-test".to_owned(), 77);
|
||||
let cache = Cache::start_with_scope_revisions(
|
||||
11, Some(dir.path().to_path_buf()), settings.clone(), shutdown.clone(),
|
||||
fingerprints.clone(),
|
||||
);
|
||||
fill(&cache, &settings, "/one", "stable-contents").await;
|
||||
fill(&cache, &settings, "/two", "second-contents").await;
|
||||
wait_for_cold(&cache, 1).await;
|
||||
shutdown.cancel();
|
||||
drop(cache);
|
||||
|
||||
let restart = CancellationToken::new();
|
||||
let same = Cache::start_with_scope_revisions(
|
||||
11, Some(dir.path().to_path_buf()), settings.clone(), restart.clone(),
|
||||
fingerprints.clone(),
|
||||
);
|
||||
assert_eq!(same.view().cold_entries, 1, "operational saves retain COLD");
|
||||
restart.cancel();
|
||||
drop(same);
|
||||
|
||||
fingerprints.insert("cold-test".to_owned(), 78);
|
||||
let restart = CancellationToken::new();
|
||||
let changed = Cache::start_with_scope_revisions(
|
||||
11, Some(dir.path().to_path_buf()), settings.clone(), restart.clone(), fingerprints,
|
||||
);
|
||||
assert_eq!(changed.view().cold_entries, 0, "old COLD must never survive a content revision");
|
||||
restart.cancel();
|
||||
}
|
||||
}
|
||||
Vendored
+16
@@ -54,6 +54,22 @@ mod scope_tests {
|
||||
fill(&cache, ticket("a")).await;
|
||||
assert_eq!(cache.scope_view("a").entries, 1);
|
||||
}
|
||||
#[test]
|
||||
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()
|
||||
);
|
||||
assert!(ticket(7).is_some(), "the current snapshot can cache");
|
||||
// An operational save only advances the config publication barrier.
|
||||
// It must not change the representation revision or clear any objects.
|
||||
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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_vhost_settings_inherit_and_cannot_expand_shared_capacity() {
|
||||
let global = Settings::default();
|
||||
|
||||
Vendored
+36
-1
@@ -22,6 +22,39 @@ impl Cache {
|
||||
_route: &str,
|
||||
target: &str,
|
||||
headers: &HeaderMap,
|
||||
) -> Option<Ticket> {
|
||||
self.ticket_scoped_impl(None, revision, scope, scheme, authority, _route, target, headers)
|
||||
}
|
||||
|
||||
/// Production requests bind the cache ticket to the immutable routing
|
||||
/// configuration used to authorize/route the request. After a config save,
|
||||
/// stale in-flight snapshots cannot re-fill a scope after its purge.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn ticket_scoped_for_config(
|
||||
&self,
|
||||
control_revision: u64,
|
||||
revision: u64,
|
||||
scope: &str,
|
||||
scheme: &str,
|
||||
authority: &str,
|
||||
route: &str,
|
||||
target: &str,
|
||||
headers: &HeaderMap,
|
||||
) -> Option<Ticket> {
|
||||
self.ticket_scoped_impl(Some(control_revision), revision, scope, scheme, authority, route, target, headers)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn ticket_scoped_impl(
|
||||
&self,
|
||||
expected_control_revision: Option<u64>,
|
||||
revision: u64,
|
||||
scope: &str,
|
||||
scheme: &str,
|
||||
authority: &str,
|
||||
_route: &str,
|
||||
target: &str,
|
||||
headers: &HeaderMap,
|
||||
) -> Option<Ticket> {
|
||||
if target.len() > 8192 || scope.len() > 64 {
|
||||
return None;
|
||||
@@ -50,7 +83,9 @@ impl Cache {
|
||||
}
|
||||
let key: Key = digest.finalize().into();
|
||||
let store = self.store.lock().unwrap_or_else(|e| e.into_inner());
|
||||
(store.revision == revision).then_some(Ticket {
|
||||
(store.revision == revision
|
||||
&& expected_control_revision.is_none_or(|expected| expected == store.control_revision))
|
||||
.then_some(Ticket {
|
||||
scheme: hash_scheme,
|
||||
authority: hash_authority,
|
||||
domain,
|
||||
|
||||
@@ -207,8 +207,8 @@ fn compile_local_hosts(config: &Config) -> Result<LocalHostBuild> {
|
||||
}
|
||||
|
||||
struct RoutingBuild {
|
||||
routes: HashMap<String, Vec<CompiledRoute>>,
|
||||
fallbacks: HashMap<String, Vec<CompiledRoute>>,
|
||||
routes: HashMap<String, Vec<Arc<CompiledRoute>>>,
|
||||
fallbacks: HashMap<String, Vec<Arc<CompiledRoute>>>,
|
||||
frontends: HashMap<String, Vec<Arc<CompiledFrontend>>>,
|
||||
redirects: HashMap<String, Vec<Arc<crate::redirect::CompiledHost>>>,
|
||||
not_found_hosts: HashMap<String, Vec<Arc<crate::not_found::CompiledHost>>>,
|
||||
|
||||
@@ -10,8 +10,20 @@ impl Snapshot {
|
||||
local: SocketAddr,
|
||||
tls: bool,
|
||||
) -> Option<&CompiledRoute> {
|
||||
self.select_internal_on_arc(host, vhost_id, target, local, tls)
|
||||
.map(Arc::as_ref)
|
||||
}
|
||||
|
||||
pub fn select_internal_on_arc(
|
||||
&self,
|
||||
host: &str,
|
||||
vhost_id: &str,
|
||||
target: &str,
|
||||
local: SocketAddr,
|
||||
tls: bool,
|
||||
) -> Option<&Arc<CompiledRoute>> {
|
||||
for pattern in Self::host_patterns(host) {
|
||||
let owns_host = self.fallbacks.get(&pattern).is_some_and(|routes| {
|
||||
let owns_host = self.fallbacks.get(pattern.as_ref()).is_some_and(|routes| {
|
||||
routes.iter().any(|route| {
|
||||
route.frontend.as_ref().is_some_and(|frontend| frontend.id == vhost_id)
|
||||
&& route.bindings.iter().any(|binding| binding.matches(local, tls))
|
||||
@@ -20,7 +32,7 @@ impl Snapshot {
|
||||
if !owns_host {
|
||||
continue;
|
||||
}
|
||||
return self.routes.get(&pattern).and_then(|routes| {
|
||||
return self.routes.get(pattern.as_ref()).and_then(|routes| {
|
||||
routes.iter().find(|route| {
|
||||
route.internal
|
||||
&& route.name == target
|
||||
|
||||
+2
-2
@@ -289,9 +289,9 @@ pub struct Snapshot {
|
||||
frontends: HashMap<String, Vec<Arc<CompiledFrontend>>>,
|
||||
pub tls: Option<Arc<rustls::ServerConfig>>,
|
||||
pub envelope: Envelope,
|
||||
routes: HashMap<String, Vec<CompiledRoute>>,
|
||||
routes: HashMap<String, Vec<Arc<CompiledRoute>>>,
|
||||
/// Main vhost backend fallback, kept separate from user Custom locations.
|
||||
fallbacks: HashMap<String, Vec<CompiledRoute>>,
|
||||
fallbacks: HashMap<String, Vec<Arc<CompiledRoute>>>,
|
||||
redirects: HashMap<String, Vec<Arc<crate::redirect::CompiledHost>>>,
|
||||
not_found_hosts: HashMap<String, Vec<Arc<crate::not_found::CompiledHost>>>,
|
||||
host_acls: HashMap<String, Arc<CompiledAcl>>,
|
||||
|
||||
@@ -9,8 +9,8 @@ fn compile_routing(
|
||||
effective_tls: &crate::tls::Settings,
|
||||
tls: &Option<Arc<rustls::ServerConfig>>,
|
||||
) -> Result<RoutingBuild> {
|
||||
let mut routes: HashMap<String, Vec<CompiledRoute>> = HashMap::new();
|
||||
let mut fallbacks: HashMap<String, Vec<CompiledRoute>> = HashMap::new();
|
||||
let mut routes: HashMap<String, Vec<Arc<CompiledRoute>>> = HashMap::new();
|
||||
let mut fallbacks: HashMap<String, Vec<Arc<CompiledRoute>>> = HashMap::new();
|
||||
let mut frontends: HashMap<String, Vec<Arc<CompiledFrontend>>> = HashMap::new();
|
||||
let (redirects, not_found_hosts, mut claimed_hosts) = compile_local_hosts(config)?;
|
||||
let mut pools = Vec::new();
|
||||
@@ -247,7 +247,7 @@ fn compile_routing(
|
||||
None,
|
||||
),
|
||||
};
|
||||
let fallback = CompiledRoute {
|
||||
let fallback = Arc::new(CompiledRoute {
|
||||
backend_id: None,
|
||||
bindings: frontend.bindings.clone(),
|
||||
frontend: Some(frontend.clone()),
|
||||
@@ -273,7 +273,7 @@ fn compile_routing(
|
||||
method_policy: crate::vhost::MethodPolicy::default(),
|
||||
headers: crate::headers::CompiledHeaders::default(),
|
||||
unavailable: host.transient_unavailable && host.source.is_reverse_proxy(),
|
||||
};
|
||||
});
|
||||
for name in &routing_hosts {
|
||||
fallbacks
|
||||
.entry(name.clone())
|
||||
@@ -389,7 +389,7 @@ fn compile_routing(
|
||||
),
|
||||
}
|
||||
};
|
||||
let compiled = CompiledRoute {
|
||||
let compiled = Arc::new(CompiledRoute {
|
||||
backend_id: route.backend_id.clone(),
|
||||
bindings: frontend.bindings.clone(),
|
||||
frontend: Some(frontend.clone()),
|
||||
@@ -415,7 +415,7 @@ fn compile_routing(
|
||||
method_policy: route.method_policy.clone(),
|
||||
headers,
|
||||
unavailable: false,
|
||||
};
|
||||
});
|
||||
for name in &routing_hosts {
|
||||
routes
|
||||
.entry(name.clone())
|
||||
|
||||
+31
-21
@@ -1,17 +1,16 @@
|
||||
impl Snapshot {
|
||||
fn host_patterns(host: &str) -> Vec<String> {
|
||||
let mut patterns = vec![host.to_owned()];
|
||||
for (index, _) in host.match_indices('.') {
|
||||
let suffix = &host[index + 1..];
|
||||
if suffix.contains('.') {
|
||||
patterns.push(format!("*.{suffix}"));
|
||||
}
|
||||
}
|
||||
patterns
|
||||
// Exact host: no allocation. Wildcards: lazy and most-specific-first.
|
||||
fn host_patterns(host: &str) -> impl Iterator<Item = std::borrow::Cow<'_, str>> {
|
||||
std::iter::once(std::borrow::Cow::Borrowed(host)).chain(
|
||||
host.match_indices('.').filter_map(move |(index, _)| {
|
||||
let suffix = &host[index + 1..];
|
||||
suffix.contains('.').then(|| std::borrow::Cow::Owned(format!("*.{suffix}")))
|
||||
}),
|
||||
)
|
||||
}
|
||||
pub fn host_allowed(&self, host: &str, peer: IpAddr) -> bool {
|
||||
for pattern in Self::host_patterns(host) {
|
||||
if let Some(acl) = self.host_acls.get(&pattern) {
|
||||
if let Some(acl) = self.host_acls.get(pattern.as_ref()) {
|
||||
return acl.allows(peer);
|
||||
}
|
||||
}
|
||||
@@ -53,8 +52,19 @@ impl Snapshot {
|
||||
local: SocketAddr,
|
||||
tls: bool,
|
||||
) -> Option<&CompiledRoute> {
|
||||
self.select_on_arc(host, path, local, tls).map(Arc::as_ref)
|
||||
}
|
||||
|
||||
/// Shared compiled-route handle for middleware and forward().
|
||||
pub fn select_on_arc(
|
||||
&self,
|
||||
host: &str,
|
||||
path: &str,
|
||||
local: SocketAddr,
|
||||
tls: bool,
|
||||
) -> Option<&Arc<CompiledRoute>> {
|
||||
for pattern in Self::host_patterns(host) {
|
||||
let fallback = self.fallbacks.get(&pattern).and_then(|routes| {
|
||||
let fallback = self.fallbacks.get(pattern.as_ref()).and_then(|routes| {
|
||||
routes
|
||||
.iter()
|
||||
.find(|route| route.bindings.iter().any(|l| l.matches(local, tls)))
|
||||
@@ -65,7 +75,7 @@ impl Snapshot {
|
||||
if let Some(fallback) = fallback {
|
||||
return self
|
||||
.routes
|
||||
.get(&pattern)
|
||||
.get(pattern.as_ref())
|
||||
.and_then(|routes| {
|
||||
select_route(routes, path, |r| {
|
||||
r.bindings.iter().any(|l| l.matches(local, tls))
|
||||
@@ -76,7 +86,6 @@ impl Snapshot {
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn frontend(
|
||||
&self,
|
||||
host: &str,
|
||||
@@ -84,7 +93,7 @@ impl Snapshot {
|
||||
tls: bool,
|
||||
) -> Option<&Arc<CompiledFrontend>> {
|
||||
for pattern in Self::host_patterns(host) {
|
||||
if let Some(frontend) = self.frontends.get(&pattern).and_then(|frontends| {
|
||||
if let Some(frontend) = self.frontends.get(pattern.as_ref()).and_then(|frontends| {
|
||||
frontends
|
||||
.iter()
|
||||
.find(|f| f.bindings.iter().any(|l| l.matches(local, tls)))
|
||||
@@ -155,7 +164,7 @@ impl Snapshot {
|
||||
&crate::redirect::CompiledRule,
|
||||
)> {
|
||||
for pattern in Self::host_patterns(host) {
|
||||
if let Some((redirect, rule)) = self.redirects.get(&pattern).and_then(|redirects| {
|
||||
if let Some((redirect, rule)) = self.redirects.get(pattern.as_ref()).and_then(|redirects| {
|
||||
redirects.iter().find_map(|redirect| {
|
||||
if !redirect
|
||||
.bindings
|
||||
@@ -184,7 +193,7 @@ impl Snapshot {
|
||||
tls: bool,
|
||||
) -> Option<&Arc<crate::not_found::CompiledHost>> {
|
||||
for pattern in Self::host_patterns(host) {
|
||||
if let Some(item) = self.not_found_hosts.get(&pattern).and_then(|items| {
|
||||
if let Some(item) = self.not_found_hosts.get(pattern.as_ref()).and_then(|items| {
|
||||
items.iter().find(|item| {
|
||||
item.bindings
|
||||
.iter()
|
||||
@@ -202,14 +211,15 @@ impl Snapshot {
|
||||
for pattern in Self::host_patterns(host) {
|
||||
if let Some(fallback) = self
|
||||
.fallbacks
|
||||
.get(&pattern)
|
||||
.get(pattern.as_ref())
|
||||
.and_then(|routes| routes.first())
|
||||
{
|
||||
return self
|
||||
.routes
|
||||
.get(&pattern)
|
||||
.get(pattern.as_ref())
|
||||
.and_then(|routes| select_route(routes, path, |_| true))
|
||||
.or(Some(fallback));
|
||||
.or(Some(fallback))
|
||||
.map(Arc::as_ref);
|
||||
}
|
||||
}
|
||||
None
|
||||
@@ -217,10 +227,10 @@ impl Snapshot {
|
||||
}
|
||||
|
||||
fn select_route<'a, F>(
|
||||
routes: &'a [CompiledRoute],
|
||||
routes: &'a [Arc<CompiledRoute>],
|
||||
path: &str,
|
||||
allowed: F,
|
||||
) -> Option<&'a CompiledRoute>
|
||||
) -> Option<&'a Arc<CompiledRoute>>
|
||||
where
|
||||
F: Fn(&CompiledRoute) -> bool,
|
||||
{
|
||||
|
||||
@@ -9,6 +9,19 @@ fn policy() -> ValidationPolicy {
|
||||
admin_addr: "127.0.0.1:9090".parse().unwrap(),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn route_handles_reuse_snapshot_ownership_without_cloning_compiled_routes() {
|
||||
let snapshot = compile(demo_config("127.0.0.1:9000".parse().unwrap()), 1, &policy()).unwrap();
|
||||
let local = "127.0.0.1:8080".parse().unwrap();
|
||||
let first = snapshot.select_on_arc("localhost", "/", local, false).unwrap();
|
||||
let second = snapshot.select_on_arc("localhost", "/child", local, false).unwrap();
|
||||
// Both selectors point into one immutable compiled snapshot.
|
||||
assert!(std::sync::Arc::ptr_eq(first, second));
|
||||
let legacy = snapshot.select_on("localhost", "/", local, false).unwrap();
|
||||
assert!(std::ptr::eq(first.as_ref(), legacy));
|
||||
assert!(snapshot.select_on_arc("not-this-host", "/", local, false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_http2_settings_use_release_defaults() {
|
||||
let config: Config = serde_json::from_value(serde_json::json!({})).unwrap();
|
||||
|
||||
@@ -151,7 +151,11 @@ impl Logs {
|
||||
None
|
||||
};
|
||||
drop(evicted);
|
||||
let _ = self.live.send(entry.clone());
|
||||
// No live viewers means no reason to allocate/clone a second LogEntry.
|
||||
// History retention and external sinks remain unchanged.
|
||||
if self.live.receiver_count() != 0 {
|
||||
let _ = self.live.send(entry.clone());
|
||||
}
|
||||
self.push_sink(entry);
|
||||
}
|
||||
fn push_sink(&self, entry: LogEntry) {
|
||||
|
||||
+71
-20
@@ -10,7 +10,7 @@ use std::{
|
||||
future::Future,
|
||||
pin::Pin,
|
||||
sync::{
|
||||
atomic::{AtomicBool, AtomicU16, AtomicU64, Ordering},
|
||||
atomic::{AtomicBool, AtomicU16, AtomicU64, AtomicU8, Ordering},
|
||||
Arc, Mutex,
|
||||
},
|
||||
task::{Context, Poll},
|
||||
@@ -21,13 +21,44 @@ use tokio::{
|
||||
time::{sleep_until, Sleep},
|
||||
};
|
||||
|
||||
/// Compact source tags avoid a mutex on every completed exchange.
|
||||
#[repr(u8)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum ExchangeSource {
|
||||
Unresolved = 0,
|
||||
LocalResponse = 1,
|
||||
LocalRedirect = 2,
|
||||
WebServer = 3,
|
||||
ReverseProxy = 4,
|
||||
}
|
||||
impl ExchangeSource {
|
||||
fn name(value: u8) -> &'static str {
|
||||
match value {
|
||||
1 => "local_response",
|
||||
2 => "local_redirect",
|
||||
3 => "web_server",
|
||||
4 => "reverse_proxy",
|
||||
_ => "unresolved",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 0 = unknown, 1 = Some(false), 2 = Some(true). Preserve log schema.
|
||||
fn read_effective(value: u8) -> Option<bool> {
|
||||
match value {
|
||||
1 => Some(false),
|
||||
2 => Some(true),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Exchange {
|
||||
pub vhost_metrics: Option<Arc<crate::metrics::VhostMetrics>>,
|
||||
pub vhost: Option<String>,
|
||||
pub route: Mutex<Option<String>>,
|
||||
pub source: Mutex<&'static str>,
|
||||
pub cache_effective_enabled: Mutex<Option<bool>>,
|
||||
pub transform_effective_enabled: Mutex<Option<bool>>,
|
||||
pub source: AtomicU8,
|
||||
pub cache_effective_enabled: AtomicU8,
|
||||
pub transform_effective_enabled: AtomicU8,
|
||||
pub backend: Mutex<Option<Arc<crate::balance::Lease>>>,
|
||||
pub id: u64,
|
||||
pub request_id: String,
|
||||
@@ -59,6 +90,17 @@ pub struct Exchange {
|
||||
pub _permit: OwnedSemaphorePermit,
|
||||
}
|
||||
impl Exchange {
|
||||
pub fn set_source(&self, source: ExchangeSource) {
|
||||
self.source.store(source as u8, Ordering::Relaxed);
|
||||
}
|
||||
pub fn set_cache_enabled(&self, enabled: bool) {
|
||||
self.cache_effective_enabled
|
||||
.store(if enabled { 2 } else { 1 }, Ordering::Relaxed);
|
||||
}
|
||||
pub fn set_transform_enabled(&self, enabled: bool) {
|
||||
self.transform_effective_enabled
|
||||
.store(if enabled { 2 } else { 1 }, Ordering::Relaxed);
|
||||
}
|
||||
pub fn websocket_phase(&self, phase: &'static str) {
|
||||
if self.websocket_bootstrap.is_some() {
|
||||
*self
|
||||
@@ -145,15 +187,9 @@ impl Drop for Exchange {
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let route = self.route.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
let source = *self.source.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let cache_effective_enabled = *self
|
||||
.cache_effective_enabled
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let transform_effective_enabled = *self
|
||||
.transform_effective_enabled
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let source = ExchangeSource::name(self.source.load(Ordering::Relaxed));
|
||||
let cache_effective_enabled = read_effective(self.cache_effective_enabled.load(Ordering::Relaxed));
|
||||
let transform_effective_enabled = read_effective(self.transform_effective_enabled.load(Ordering::Relaxed));
|
||||
let client_ip = self
|
||||
.client_ip
|
||||
.lock()
|
||||
@@ -213,17 +249,17 @@ impl Drop for Exchange {
|
||||
authority: None,
|
||||
matched_by: None,
|
||||
cache_reason: *self.cache_reason.lock().unwrap_or_else(|e| e.into_inner()),
|
||||
vhost: self.vhost.clone(),
|
||||
vhost: self.vhost.take(),
|
||||
route,
|
||||
cache_effective_enabled,
|
||||
transform_effective_enabled,
|
||||
backend,
|
||||
id: self.id,
|
||||
request_id: self.request_id.clone(),
|
||||
request_id: std::mem::take(&mut self.request_id),
|
||||
time_ms: now_ms(),
|
||||
method: self.method.clone(),
|
||||
host: self.host.clone(),
|
||||
path: self.path.clone(),
|
||||
method: std::mem::take(&mut self.method),
|
||||
host: std::mem::take(&mut self.host),
|
||||
path: std::mem::take(&mut self.path),
|
||||
http_version: self.http_version,
|
||||
transport: self.transport,
|
||||
websocket_bootstrap: self.websocket_bootstrap,
|
||||
@@ -243,7 +279,7 @@ impl Drop for Exchange {
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone(),
|
||||
client_ip,
|
||||
user_agent: self.user_agent.clone(),
|
||||
user_agent: self.user_agent.take(),
|
||||
country,
|
||||
status,
|
||||
duration_ms,
|
||||
@@ -262,6 +298,9 @@ pub enum Direction {
|
||||
pub struct MeteredBody {
|
||||
inner: Body,
|
||||
exchange: Option<Arc<Exchange>>,
|
||||
/// For a response, backend selection is final before this wrapper is made.
|
||||
/// Keep the selected lease alive and avoid locking Exchange.backend per frame.
|
||||
response_backend: Option<Arc<crate::balance::Lease>>,
|
||||
direction: Direction,
|
||||
timer: Option<Pin<Box<Sleep>>>,
|
||||
deadline: tokio::time::Instant,
|
||||
@@ -271,9 +310,13 @@ pub struct MeteredBody {
|
||||
impl MeteredBody {
|
||||
pub fn new(inner: Body, exchange: Arc<Exchange>, direction: Direction, idle: Duration) -> Self {
|
||||
let finished = inner.is_end_stream();
|
||||
let response_backend = matches!(direction, Direction::Response)
|
||||
.then(|| exchange.backend.lock().unwrap_or_else(|e| e.into_inner()).clone())
|
||||
.flatten();
|
||||
Self {
|
||||
inner,
|
||||
exchange: Some(exchange),
|
||||
response_backend,
|
||||
direction,
|
||||
// Ready/empty bodies never need a timer allocation. Keep the
|
||||
// original deadline so a delayed first poll still times out.
|
||||
@@ -286,6 +329,7 @@ impl MeteredBody {
|
||||
fn finish(&mut self) {
|
||||
self.finished = true;
|
||||
self.timer.take();
|
||||
self.response_backend.take();
|
||||
self.exchange.take();
|
||||
}
|
||||
}
|
||||
@@ -305,7 +349,14 @@ impl HttpBody for MeteredBody {
|
||||
Poll::Ready(Some(Ok(frame))) => {
|
||||
if let (Some(data), Some(exchange)) = (frame.data_ref(), this.exchange.as_ref()) {
|
||||
let count = data.len() as u64;
|
||||
exchange.backend_bytes(count, matches!(this.direction, Direction::Request));
|
||||
match this.direction {
|
||||
Direction::Request => exchange.backend_bytes(count, true),
|
||||
Direction::Response => {
|
||||
if let Some(lease) = &this.response_backend {
|
||||
lease.bytes(count, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
match this.direction {
|
||||
Direction::Request => {
|
||||
exchange.request_bytes.fetch_add(count, Ordering::Relaxed);
|
||||
|
||||
+27
-3
@@ -11,9 +11,9 @@ mod tests {
|
||||
vhost_metrics: None,
|
||||
vhost: None,
|
||||
route: Mutex::new(None),
|
||||
source: Mutex::new("unresolved"),
|
||||
cache_effective_enabled: Mutex::new(None),
|
||||
transform_effective_enabled: Mutex::new(None),
|
||||
source: AtomicU8::new(ExchangeSource::Unresolved as u8),
|
||||
cache_effective_enabled: AtomicU8::new(0),
|
||||
transform_effective_enabled: AtomicU8::new(0),
|
||||
backend: Mutex::new(None),
|
||||
id: 1,
|
||||
request_id: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".into(),
|
||||
@@ -225,4 +225,28 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn atomic_exchange_tags_keep_original_observability_fields() {
|
||||
let (exchange, slots) = observation().await;
|
||||
let logs = exchange.logs.clone();
|
||||
exchange.set_source(ExchangeSource::ReverseProxy);
|
||||
exchange.set_cache_enabled(false);
|
||||
exchange.set_transform_enabled(true);
|
||||
drop(exchange);
|
||||
assert_eq!(slots.available_permits(), 1);
|
||||
let entries = logs.recent();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].cache_effective_enabled, Some(false));
|
||||
assert_eq!(entries[0].transform_effective_enabled, Some(true));
|
||||
assert_eq!(entries[0].cache_reason, "not_proxy_response");
|
||||
assert_eq!(entries[0].method, "POST");
|
||||
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!(read_effective(0), None);
|
||||
assert_eq!(read_effective(1), Some(false));
|
||||
assert_eq!(read_effective(2), Some(true));
|
||||
}
|
||||
}
|
||||
@@ -159,7 +159,7 @@ fn response_context(
|
||||
request: &Request,
|
||||
) -> (
|
||||
Option<Arc<crate::config::CompiledFrontend>>,
|
||||
Option<crate::config::CompiledRoute>,
|
||||
Option<Arc<crate::config::CompiledRoute>>,
|
||||
) {
|
||||
let tls = request
|
||||
.extensions()
|
||||
@@ -174,7 +174,7 @@ fn response_context(
|
||||
let frontend = snapshot.frontend(&host, local, tls).cloned();
|
||||
let route = crate::acl::routing_path(request.uri().path())
|
||||
.ok()
|
||||
.and_then(|path| snapshot.select_on(&host, &path, local, tls).cloned());
|
||||
.and_then(|path| snapshot.select_on_arc(&host, &path, local, tls).cloned());
|
||||
Some((frontend, route))
|
||||
})();
|
||||
context.unwrap_or((None, None))
|
||||
|
||||
@@ -6,7 +6,7 @@ struct InternalFallbackRoute {
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FinalRouteContext(crate::config::CompiledRoute);
|
||||
struct FinalRouteContext(Arc<crate::config::CompiledRoute>);
|
||||
|
||||
/// The named location has already produced the terminal HTTP response.
|
||||
/// In particular, do not replace its 404 HTML with a global/vhost error page.
|
||||
@@ -135,20 +135,21 @@ async fn execute_error_fallback(
|
||||
tls: bool,
|
||||
route: &crate::config::CompiledRoute,
|
||||
status: StatusCode,
|
||||
template: &ErrorFallbackRequest,
|
||||
template: &Option<ErrorFallbackRequest>,
|
||||
body: &ErrorFallbackBody,
|
||||
) -> Option<Response> {
|
||||
let fallback = route
|
||||
.error_fallback
|
||||
.as_ref()
|
||||
.filter(|fallback| fallback.matches(status))?;
|
||||
let template = template.as_ref()?;
|
||||
let frontend = route.frontend.as_ref()?;
|
||||
if template.depth >= MAX_ERROR_FALLBACK_DEPTH {
|
||||
tracing::warn!(vhost = %frontend.id, route = %route.id, target = %fallback.target, "error fallback depth limit reached");
|
||||
return None;
|
||||
}
|
||||
let Some(target) = snapshot
|
||||
.select_internal_on(host, &frontend.id, &fallback.target, local.0, tls)
|
||||
.select_internal_on_arc(host, &frontend.id, &fallback.target, local.0, tls)
|
||||
.cloned()
|
||||
else {
|
||||
tracing::warn!(
|
||||
|
||||
@@ -4,16 +4,16 @@
|
||||
// allow-only country entries and explicit IP allows form one policy scope.
|
||||
if let Some(exchange) = request.extensions().get::<Arc<Exchange>>() {
|
||||
*exchange.route.lock().unwrap_or_else(|e| e.into_inner()) = Some(route.id.clone());
|
||||
*exchange.source.lock().unwrap_or_else(|e| e.into_inner()) = if route.response.is_some() {
|
||||
"local_response"
|
||||
exchange.set_source(if route.response.is_some() {
|
||||
ExchangeSource::LocalResponse
|
||||
} else if route.redirect.is_some() {
|
||||
"local_redirect"
|
||||
ExchangeSource::LocalRedirect
|
||||
} else {
|
||||
match &route.source {
|
||||
crate::config::CompiledSource::WebServer(_) => "web_server",
|
||||
crate::config::CompiledSource::ReverseProxy => "reverse_proxy",
|
||||
crate::config::CompiledSource::WebServer(_) => ExchangeSource::WebServer,
|
||||
crate::config::CompiledSource::ReverseProxy => ExchangeSource::ReverseProxy,
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
// Local location responses intentionally still pass host/profile/location ACLs
|
||||
// and the outer response-header/security pipeline, but never select/open an upstream.
|
||||
@@ -126,10 +126,8 @@ let transform_settings = route.frontend.as_ref().map(|f| &f.transform);
|
||||
let scope = route.frontend.as_ref().map(|f| f.id.as_str()).unwrap_or("");
|
||||
if let Some(exchange) = request.extensions().get::<Arc<Exchange>>() {
|
||||
*exchange.route.lock().unwrap_or_else(|e| e.into_inner()) = Some(route.id.clone());
|
||||
*exchange.cache_effective_enabled.lock().unwrap_or_else(|e| e.into_inner()) =
|
||||
Some(cache_settings.active_for(route.cache));
|
||||
*exchange.transform_effective_enabled.lock().unwrap_or_else(|e| e.into_inner()) =
|
||||
Some(transform_settings.is_some_and(|settings| settings.enabled));
|
||||
exchange.set_cache_enabled(cache_settings.active_for(route.cache));
|
||||
exchange.set_transform_enabled(transform_settings.is_some_and(|settings| settings.enabled));
|
||||
}
|
||||
let policy_reason = if admin_control_plane {
|
||||
Some("admin_control_plane")
|
||||
@@ -304,7 +302,8 @@ let key_headers = cache_key_headers
|
||||
.as_ref()
|
||||
.unwrap_or_else(|| request.headers());
|
||||
let cache_ticket = if effective_cacheable {
|
||||
state.cache.ticket_scoped(
|
||||
state.cache.ticket_scoped_for_config(
|
||||
snapshot.envelope.revision,
|
||||
state.cache.revision(),
|
||||
route.frontend.as_ref().map(|f| f.id.as_str()).unwrap_or(""),
|
||||
context.scheme,
|
||||
|
||||
@@ -60,15 +60,36 @@ if pending_revalidation {
|
||||
if request.method() == Method::HEAD && cache_ticket.is_some() {
|
||||
*request.method_mut() = Method::GET;
|
||||
}
|
||||
// Preserve a pre-normalization Host snapshot for pool retries. Each next
|
||||
// backend gets its OWN target and Host/:authority, never the previous peer's.
|
||||
let retry_headers = request.headers().clone();
|
||||
// Save the pre-normalization headers only for replayable requests that may
|
||||
// actually be retried. The common one-backend HTTP/1 route has no retry peer
|
||||
// or H2 GOAWAY handling, so copying HeaderMap for every request was wasted.
|
||||
// Never replay writes, streaming uploads, auth or WebSocket requests.
|
||||
let retry_method = request.method().clone();
|
||||
let retry_policy = route.pool.as_ref().map(|pool| &pool.settings.retry);
|
||||
let pool_retry_possible = route.backend_id.is_none()
|
||||
&& !admin_control_plane
|
||||
&& handshake.is_none()
|
||||
&& retry_safe_method(&retry_method)
|
||||
&& route.pool.as_ref().is_some_and(|pool| pool.members.len() > 1)
|
||||
&& retry_policy.is_some_and(|policy| policy.max_retries > 0);
|
||||
let h2_replay_possible = !admin_control_plane
|
||||
&& handshake.is_none()
|
||||
&& retry_safe_method(&retry_method)
|
||||
&& matches!(backend.protocol,
|
||||
crate::config::UpstreamProtocol::Http2
|
||||
| crate::config::UpstreamProtocol::H2
|
||||
| crate::config::UpstreamProtocol::Auto);
|
||||
let replay_headers = ((pool_retry_possible || h2_replay_possible)
|
||||
&& (request.body().size_hint().exact() == Some(0)
|
||||
|| request.version() == Version::HTTP_3))
|
||||
.then(|| request.headers().clone());
|
||||
let expected_backend_host = if backend.preserve_host {
|
||||
&original_host
|
||||
} else {
|
||||
&backend.host_header
|
||||
};
|
||||
let retry_host_follows_backend = retry_headers.get(HOST)
|
||||
let retry_host_follows_backend = replay_headers.as_ref()
|
||||
.and_then(|headers| headers.get(HOST))
|
||||
.is_some_and(|value| value == expected_backend_host);
|
||||
let Some(upstream_target) = upstream_request_target(
|
||||
&uri, request.headers_mut(), &backend,
|
||||
@@ -125,14 +146,7 @@ let proactive_base_headers = if cache_ticket.is_some() && cache_settings.proacti
|
||||
let response_header_timeout = frontend
|
||||
.and_then(|frontend| frontend.proxy_timeouts.response_header_timeout())
|
||||
.unwrap_or(state.header_timeout);
|
||||
let retry_method = request.method().clone();
|
||||
let retry_policy = route.pool.as_ref().map(|pool| pool.settings.retry.clone());
|
||||
let retry_enabled = route.backend_id.is_none()
|
||||
&& !admin_control_plane
|
||||
&& handshake.is_none()
|
||||
&& request_body_empty
|
||||
&& retry_safe_method(&retry_method)
|
||||
&& retry_policy.as_ref().is_some_and(|policy| policy.max_retries > 0);
|
||||
let retry_enabled = pool_retry_possible && request_body_empty && replay_headers.is_some();
|
||||
let upstream_started = Instant::now();
|
||||
let mut attempt_started = Instant::now();
|
||||
let mut active_backend = backend.clone();
|
||||
@@ -165,15 +179,15 @@ let upstream_result = loop {
|
||||
&& (is_graceful_h2_goaway(cause)
|
||||
|| is_related_h2_close(cause, &active_backend))
|
||||
{
|
||||
if let Some(next_request) = retry_request(
|
||||
if let Some(next_request) = replay_headers.as_ref().and_then(|headers| retry_request(
|
||||
&retry_method,
|
||||
upstream_request_version(&active_backend, false),
|
||||
&retry_headers,
|
||||
headers,
|
||||
&active_backend,
|
||||
&original_host,
|
||||
&origin_suffix,
|
||||
retry_host_follows_backend,
|
||||
) {
|
||||
)) {
|
||||
graceful_retry_done = true;
|
||||
if let Some(lease) = &active_lease {
|
||||
lease.retry_attempted();
|
||||
@@ -201,6 +215,9 @@ let upstream_result = loop {
|
||||
break result;
|
||||
}
|
||||
attempted_backends.push(active_backend.id.clone());
|
||||
let Some(retry_headers) = replay_headers.as_ref() else {
|
||||
break result;
|
||||
};
|
||||
let Some(pool) = route.pool.as_ref() else {
|
||||
break result;
|
||||
};
|
||||
|
||||
@@ -11,6 +11,14 @@
|
||||
Err(_) => return error(StatusCode::BAD_REQUEST,
|
||||
"RustProxy: invalid Web Server rewrite path\n"),
|
||||
};
|
||||
#[cfg(target_os = "linux")]
|
||||
let kernel_sendfile = request.extensions()
|
||||
.get::<crate::server::sendfile::SendfileSlots>().cloned();
|
||||
#[cfg(target_os = "linux")]
|
||||
let identity_request = request.version() == http::Version::HTTP_11
|
||||
&& !request.headers().contains_key("accept-encoding")
|
||||
&& !request.headers().contains_key("range")
|
||||
&& !request.headers().contains_key("if-range");
|
||||
let cache_settings = route.frontend.as_ref()
|
||||
.map(|frontend| &frontend.cache)
|
||||
.unwrap_or(&snapshot.envelope.config.cache);
|
||||
@@ -19,9 +27,8 @@
|
||||
let enabled = cache_settings.active_for(route.cache)
|
||||
&& frontend.is_none_or(|frontend| frontend.basic_auth.is_none() && frontend.tinyauth.is_none());
|
||||
if let Some(exchange) = request.extensions().get::<Arc<Exchange>>() {
|
||||
*exchange.cache_effective_enabled.lock().unwrap_or_else(|e| e.into_inner()) = Some(enabled);
|
||||
*exchange.transform_effective_enabled.lock().unwrap_or_else(|e| e.into_inner()) =
|
||||
Some(transform_settings.is_some_and(|settings| settings.enabled));
|
||||
exchange.set_cache_enabled(enabled);
|
||||
exchange.set_transform_enabled(transform_settings.is_some_and(|settings| settings.enabled));
|
||||
}
|
||||
// The authority is already validated by effective_host. HTTP/2 and HTTP/3
|
||||
// requests commonly have :authority but no Host field, which otherwise
|
||||
@@ -64,7 +71,8 @@
|
||||
Some(headers)
|
||||
} else { None };
|
||||
let cache_ticket = if eligible {
|
||||
state.cache.ticket_scoped(state.cache.revision(), scope, context.scheme,
|
||||
state.cache.ticket_scoped_for_config(snapshot.envelope.revision,
|
||||
state.cache.revision(), scope, context.scheme,
|
||||
original_host.to_str().unwrap_or(""), &route.id, &public_suffix,
|
||||
key_headers.as_ref().unwrap_or(&request_headers))
|
||||
} else { None };
|
||||
@@ -280,5 +288,32 @@
|
||||
cache_settings.annotate_reason(response.headers_mut(), outcome, 0, 0, reason);
|
||||
let transformed = response.extensions().get::<crate::transform::Applied>().is_some();
|
||||
cache_settings.annotate_transformed(response.headers_mut(), transformed);
|
||||
// Register only AFTER the vhost response policy, cache and transformation
|
||||
// decisions are final. Never feed synthetic transport placeholders into
|
||||
// cache, compression or the transformation engine. The ordinary body
|
||||
// remains the fallback on every other protocol/response and when the
|
||||
// per-connection bridge has no capacity.
|
||||
#[cfg(target_os = "linux")]
|
||||
if !enabled && identity_request && request_method == Method::GET
|
||||
&& !transform_settings.is_some_and(|settings| settings.enabled)
|
||||
&& response.status() == StatusCode::OK
|
||||
&& !response.headers().contains_key("content-encoding")
|
||||
&& !response.headers().contains_key("transfer-encoding")
|
||||
&& !response.headers().contains_key("content-range")
|
||||
{
|
||||
if let (Some(slots), Some(candidate)) = (
|
||||
kernel_sendfile,
|
||||
response.extensions().get::<crate::server::sendfile::SendfileCandidate>().cloned(),
|
||||
) {
|
||||
let correct_length = response.headers().get("content-length")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.parse::<u64>().ok()) == Some(candidate.length);
|
||||
if correct_length {
|
||||
if let Some(body) = slots.create_body(candidate, service.web_metrics.clone()) {
|
||||
*response.body_mut() = Body::new(body);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
response
|
||||
}
|
||||
@@ -107,7 +107,14 @@ async fn forward_request(
|
||||
"RustProxy: ambiguous request path\n",
|
||||
);
|
||||
};
|
||||
let frontend = snapshot.frontend(&host, local.0, tls);
|
||||
let selected_context = request.extensions().get::<SelectedRouteContext>().cloned();
|
||||
let frontend = if let Some(selection) = &selected_context {
|
||||
selection.frontend.as_ref()
|
||||
} else {
|
||||
// Internal error fallback calls forward_request directly, without
|
||||
// rerunning the observation middleware.
|
||||
snapshot.frontend(&host, local.0, tls)
|
||||
};
|
||||
if snapshot.user_agent_blocking.blocks(request.headers())
|
||||
|| frontend.is_some_and(|frontend| frontend.user_agent_blocking.blocks(request.headers()))
|
||||
{
|
||||
@@ -307,6 +314,8 @@ async fn forward_request(
|
||||
} else {
|
||||
snapshot.select_internal_on(&host, &dispatch.vhost_id, &dispatch.target, local.0, tls)
|
||||
}
|
||||
} else if let Some(selection) = &selected_context {
|
||||
selection.route.as_deref()
|
||||
} else {
|
||||
snapshot.select_on(&host, &path, local.0, tls)
|
||||
};
|
||||
@@ -343,7 +352,11 @@ async fn forward_request(
|
||||
);
|
||||
}
|
||||
}
|
||||
let fallback_request = ErrorFallbackRequest::capture(&request, &snapshot);
|
||||
// Normal routes almost never have an internal error fallback. Do not copy
|
||||
// the complete HeaderMap and request metadata when no fallback exists.
|
||||
let fallback_request = selected
|
||||
.filter(|route| route.error_fallback.is_some())
|
||||
.map(|_| ErrorFallbackRequest::capture(&request, &snapshot));
|
||||
// The admin cookie is never ordinary application state. Strip it BEFORE
|
||||
// auth/cache and keep only the removed fragment locally. A verified self-admin
|
||||
// backend may restore it after the actual upstream has been selected.
|
||||
|
||||
+9
-2
@@ -13,7 +13,7 @@ use axum::{
|
||||
response::{IntoResponse, Response},
|
||||
Extension, Router,
|
||||
};
|
||||
use body::{Direction, Exchange, MeteredBody};
|
||||
use body::{Direction, Exchange, ExchangeSource, MeteredBody};
|
||||
use bytes::Bytes;
|
||||
use http::{
|
||||
header::{HeaderValue, HOST, USER_AGENT},
|
||||
@@ -21,6 +21,13 @@ use http::{
|
||||
};
|
||||
use http_body::Body as HttpBody;
|
||||
use http_body_util::BodyExt;
|
||||
/// A route resolved against the immutable snapshot in the observation layer.
|
||||
/// The internal error-fallback dispatcher must resolve its target independently.
|
||||
#[derive(Clone)]
|
||||
struct SelectedRouteContext {
|
||||
frontend: Option<Arc<crate::config::CompiledFrontend>>,
|
||||
route: Option<Arc<crate::config::CompiledRoute>>,
|
||||
}
|
||||
#[derive(Clone)]
|
||||
struct ResponseRulesApplied;
|
||||
#[derive(Clone)]
|
||||
@@ -42,7 +49,7 @@ struct OriginBackend(String);
|
||||
|
||||
use std::{
|
||||
sync::{
|
||||
atomic::{AtomicBool, AtomicU16, AtomicU64, Ordering},
|
||||
atomic::{AtomicBool, AtomicU16, AtomicU64, AtomicU8, Ordering},
|
||||
Arc, Mutex,
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
|
||||
@@ -23,6 +23,12 @@ fn should_replace_error_page(response: &Response, minimal_welcome: bool) -> bool
|
||||
async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next) -> Response {
|
||||
let snapshot = state.config.load_full();
|
||||
let (frontend_context, route_context) = response_context(&snapshot, &request);
|
||||
// The same snapshot is inserted into the request. No second host/regex
|
||||
// selection or expensive full CompiledRoute clone is necessary in forward().
|
||||
request.extensions_mut().insert(SelectedRouteContext {
|
||||
frontend: frontend_context.clone(),
|
||||
route: route_context.clone(),
|
||||
});
|
||||
let local = request.extensions().get::<LocalAddr>().map(|v| v.0);
|
||||
let transport_tls = request
|
||||
.extensions()
|
||||
@@ -90,7 +96,7 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
|
||||
response.headers_mut(),
|
||||
&snapshot,
|
||||
frontend_context.as_deref(),
|
||||
route_context.as_ref(),
|
||||
route_context.as_deref(),
|
||||
false,
|
||||
);
|
||||
finish_headers(
|
||||
@@ -106,7 +112,7 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
|
||||
response.headers_mut(),
|
||||
&snapshot,
|
||||
frontend_context.as_deref(),
|
||||
route_context.as_ref(),
|
||||
route_context.as_deref(),
|
||||
);
|
||||
return response;
|
||||
};
|
||||
@@ -153,9 +159,9 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
|
||||
vhost_metrics: frontend_context.as_ref().map(|f| f.metrics.clone()),
|
||||
vhost: frontend_context.as_ref().map(|f| f.id.clone()),
|
||||
route: Mutex::new(None),
|
||||
source: Mutex::new("unresolved"),
|
||||
cache_effective_enabled: Mutex::new(None),
|
||||
transform_effective_enabled: Mutex::new(None),
|
||||
source: AtomicU8::new(ExchangeSource::Unresolved as u8),
|
||||
cache_effective_enabled: AtomicU8::new(0),
|
||||
transform_effective_enabled: AtomicU8::new(0),
|
||||
backend: Mutex::new(None),
|
||||
id,
|
||||
request_id: request_id.clone(),
|
||||
@@ -210,7 +216,9 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
|
||||
.extensions()
|
||||
.get::<FinalRouteContext>()
|
||||
.map(|context| context.0.clone());
|
||||
let effective_route_context = final_route_context.as_ref().or(route_context.as_ref());
|
||||
let effective_route_context = final_route_context
|
||||
.as_deref()
|
||||
.or_else(|| route_context.as_deref());
|
||||
let minimal_welcome = response
|
||||
.extensions()
|
||||
.get::<crate::pages::MinimalWelcome>()
|
||||
|
||||
@@ -98,12 +98,14 @@ impl App {
|
||||
.as_ref()
|
||||
.and_then(|path| path.parent())
|
||||
.map(std::path::Path::to_path_buf);
|
||||
let cache = crate::cache::Cache::start(
|
||||
let cache = crate::cache::Cache::start_with_scope_revisions(
|
||||
cache_revision(&snapshot.envelope.config),
|
||||
cache_data_root,
|
||||
snapshot.envelope.config.cache.clone(),
|
||||
shutdown.clone(),
|
||||
vhost_cache_revisions(&snapshot.envelope.config),
|
||||
);
|
||||
cache.set_control_revision(snapshot.envelope.revision);
|
||||
let active_listeners = snapshot.listeners.clone();
|
||||
let active_streams = snapshot.envelope.config.streams.clone();
|
||||
let stream_metrics = active_streams
|
||||
|
||||
+48
-1
@@ -7,6 +7,17 @@ mod cache_revision_tests {
|
||||
let base = config::demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
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()
|
||||
});
|
||||
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));
|
||||
|
||||
let mut logging = base.clone();
|
||||
logging.logging.file.path = "/tmp/rustproxy-test-access.jsonl".into();
|
||||
assert_eq!(cache_revision(&logging), identity);
|
||||
@@ -107,8 +118,44 @@ mod cache_revision_tests {
|
||||
CacheInvalidation::All(value) if value == cache_identity
|
||||
));
|
||||
|
||||
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].backends[0].weight += 1;
|
||||
operational.vhosts[0].backends[0].max_concurrent_requests = 16;
|
||||
operational.vhosts[0].backends[0].queue_timeout_ms = 100;
|
||||
operational.vhosts[0].timing_headers = true;
|
||||
operational.vhosts[0].acl_profile_id = Some("test-only-acl".into());
|
||||
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));
|
||||
|
||||
let mut vhost = base.clone();
|
||||
vhost.vhosts[0].backends[0].url = "http://127.0.0.1:9001".into();
|
||||
assert_ne!(cache_revision(&vhost), identity);
|
||||
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]));
|
||||
|
||||
// A content change must not invalidate another independent vhost.
|
||||
let mut multiple = base.clone();
|
||||
let mut another = multiple.vhosts[0].clone();
|
||||
another.id = "other-scope".into();
|
||||
another.frontend.hosts = vec!["other.example.test".into()];
|
||||
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()]));
|
||||
let mut changed_global = multiple.clone();
|
||||
changed_global.transform.max_body_bytes += 1;
|
||||
assert!(matches!(cache_invalidation(&multiple, &changed_global), CacheInvalidation::All(_)));
|
||||
}
|
||||
}
|
||||
+86
-30
@@ -78,25 +78,19 @@ impl IntoResponse for ApiError {
|
||||
enum CacheInvalidation {
|
||||
None,
|
||||
All(u64),
|
||||
Scopes(Vec<String>),
|
||||
}
|
||||
|
||||
/// Stable cache compatibility identity. Unlike the configuration revision, this
|
||||
/// changes only when a setting can alter an origin request or cached response.
|
||||
/// Keeping it stable across logging/TLS/admin saves lets the persistent cold
|
||||
/// tier survive a restart after unrelated control-plane edits.
|
||||
/// Global compatibility identity for HOT/COLD cache entries. Vhost-specific
|
||||
/// representation changes use individual scope generations and COLD fingerprints.
|
||||
/// Connection-pool/LB knobs do not enter either identity.
|
||||
fn cache_revision(config: &config::Config) -> u64 {
|
||||
// Preset fields are UI intent metadata. Persist them, but do not rotate the
|
||||
// cache compatibility identity (and purge HOT/COLD) when only the label
|
||||
// changes while the effective cache limits stay identical.
|
||||
let mut cache = config.cache.clone();
|
||||
cache.memory_preset = None;
|
||||
cache.cold_preset = None;
|
||||
// Request bypass switches and response-only diagnostic headers do not alter
|
||||
// already cached representations. Keep cache objects across these policy
|
||||
// edits; the new behavior applies immediately on the next request.
|
||||
// These are request admission or response diagnostics, never stored objects.
|
||||
cache.bypass_query = false;
|
||||
cache.bypass_prefixes.clear();
|
||||
// Remote maintenance permissions cannot change stored object representations.
|
||||
cache.http_invalidation_allowed_cidrs.clear();
|
||||
cache.expose_status = false;
|
||||
cache.expose_hits = false;
|
||||
@@ -104,43 +98,105 @@ fn cache_revision(config: &config::Config) -> u64 {
|
||||
cache.expose_resident_age = false;
|
||||
cache.expose_only_hits = false;
|
||||
cache.via_header = None;
|
||||
// Resource-only transform controls decide how much optimization work may
|
||||
// happen concurrently, but do not change the transformed representation.
|
||||
// Keep those out of the cache identity while including representation and
|
||||
// eligibility controls such as enabled kinds, extensions and max body.
|
||||
let mut transform = config.transform.clone();
|
||||
transform.preset = None;
|
||||
transform.memory_budget_bytes = 0;
|
||||
transform.max_inflight = 0;
|
||||
// A per-vhost diagnostics override is also presentation-only and must not
|
||||
// invalidate cached objects when switched from inherited to HIT-only.
|
||||
let mut vhosts = config.vhosts.clone();
|
||||
for vhost in &mut vhosts {
|
||||
vhost.cache.expose_only_hits = None;
|
||||
// Frontend ALPN/QUIC switches decide how a client reaches a vhost,
|
||||
// not which representation the origin returns. Hot and cold entries
|
||||
// must survive HTTP/2 or HTTP/3 opt-in/out saves.
|
||||
vhost.http2_enabled = true;
|
||||
vhost.http3_enabled = true;
|
||||
// Web services and FPM profiles can change the bytes served by a location.
|
||||
// Until their dependency graph is split into scopes, invalidate globally.
|
||||
let mut web_services = config.web_services.clone();
|
||||
for service in &mut web_services {
|
||||
// Kernel write strategy never changes the HTTP representation.
|
||||
service.sendfile = crate::web_server::SendfileMode::Off;
|
||||
}
|
||||
let encoded = serde_json::to_vec(&(
|
||||
cache_digest(&(
|
||||
&cache,
|
||||
&transform,
|
||||
&config.headers,
|
||||
&config.forwarding,
|
||||
&vhosts,
|
||||
&web_services,
|
||||
&config.php_fpm_profiles,
|
||||
))
|
||||
.expect("serializable cache compatibility view");
|
||||
}
|
||||
|
||||
fn cache_digest<T: serde::Serialize>(value: &T) -> u64 {
|
||||
let encoded = serde_json::to_vec(value).expect("serializable cache compatibility view");
|
||||
let digest = Sha256::digest(encoded);
|
||||
u64::from_be_bytes(digest[..8].try_into().expect("sha256 prefix"))
|
||||
}
|
||||
|
||||
/// Only effective origin/content policies contribute to this scope identity.
|
||||
/// A vhost keepalive or load-balancer control save must retain both cache tiers.
|
||||
fn vhost_cache_revision(vhost: &crate::vhost::Vhost) -> u64 {
|
||||
let mut view = vhost.clone();
|
||||
view.websockets = false;
|
||||
view.websocket_legacy_support = false;
|
||||
view.websocket_rfc8441 = false;
|
||||
view.websocket_rfc9220 = false;
|
||||
view.http2_enabled = true;
|
||||
view.http3_enabled = true;
|
||||
view.enabled = true;
|
||||
view.force_ssl = false;
|
||||
view.www_redirect = false;
|
||||
view.www_redirect_target = None;
|
||||
view.timing_headers = false;
|
||||
view.backend_header = false;
|
||||
view.backend_timing_header = false;
|
||||
view.certificate_id = None;
|
||||
view.tls_policy = None;
|
||||
view.server_header = None;
|
||||
view.diagnostic_headers = None;
|
||||
view.rate_limit = Default::default();
|
||||
view.method_policy = Default::default();
|
||||
view.basic_auth = Default::default();
|
||||
view.tinyauth = Default::default();
|
||||
view.acl = None;
|
||||
view.acl_profile_id = None;
|
||||
view.user_agent_blocking = Default::default();
|
||||
view.proxy_timeouts = Default::default();
|
||||
view.frontend.listener_ids.clear();
|
||||
view.cache.expose_only_hits = None;
|
||||
// The complete LB policy (keepalive, recycling, weights, retries, health,
|
||||
// affinity and queues) governs connection selection, not cache identity.
|
||||
// Sticky authentication checks and bypass still run before the cache lookup.
|
||||
view.load_balancing = Default::default();
|
||||
for member in &mut view.backends {
|
||||
member.weight = 1;
|
||||
member.backup = false;
|
||||
member.drain = false;
|
||||
member.health_check_enabled = true;
|
||||
member.health_check = None;
|
||||
member.max_concurrent_requests = 0;
|
||||
member.queue_timeout_ms = 0;
|
||||
}
|
||||
cache_digest(&view)
|
||||
}
|
||||
|
||||
fn vhost_cache_revisions(config: &config::Config) -> HashMap<String, u64> {
|
||||
config.vhosts.iter().map(|vhost| (vhost.id.clone(), vhost_cache_revision(vhost))).collect()
|
||||
}
|
||||
|
||||
fn cache_invalidation(previous: &config::Config, next: &config::Config) -> CacheInvalidation {
|
||||
let before = cache_revision(previous);
|
||||
let after = cache_revision(next);
|
||||
if before == after {
|
||||
if before != after {
|
||||
return CacheInvalidation::All(after);
|
||||
}
|
||||
let old = vhost_cache_revisions(previous);
|
||||
let new = vhost_cache_revisions(next);
|
||||
let mut changed: Vec<_> = old
|
||||
.iter()
|
||||
.filter(|(id, revision)| new.get(*id) != Some(*revision))
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect();
|
||||
// New scopes do not have previous cache objects, but include the set for
|
||||
// completeness if an ID is ever reused within a single config transaction.
|
||||
changed.extend(new.keys().filter(|id| !old.contains_key(*id)).cloned());
|
||||
changed.sort();
|
||||
changed.dedup();
|
||||
if changed.is_empty() {
|
||||
CacheInvalidation::None
|
||||
} else {
|
||||
CacheInvalidation::All(after)
|
||||
CacheInvalidation::Scopes(changed)
|
||||
}
|
||||
}
|
||||
@@ -147,12 +147,24 @@ impl App {
|
||||
// intentionally decoupled from the configuration revision, so unrelated
|
||||
// control-plane saves do not invalidate hot/cold objects.
|
||||
let envelope = prepared.envelope.clone();
|
||||
// After commit, reject late cache tickets from the old routing
|
||||
// snapshot before any scope purge. Existing unaffected objects stay.
|
||||
this.cache.set_control_revision(envelope.revision);
|
||||
match invalidation {
|
||||
CacheInvalidation::All(cache_revision) => {
|
||||
this.cache.purge(Some(cache_revision));
|
||||
}
|
||||
CacheInvalidation::Scopes(scopes) => {
|
||||
for scope in scopes {
|
||||
this.cache.purge_scope(&scope);
|
||||
}
|
||||
}
|
||||
CacheInvalidation::None => {}
|
||||
}
|
||||
// A COLD entry is usable after restart only if its scope still has
|
||||
// the same origin/content policy. Publish the fingerprints after
|
||||
// the cache generation barriers but before the routing snapshot.
|
||||
this.cache.replace_scope_revisions(vhost_cache_revisions(&envelope.config));
|
||||
this.cache.configure(envelope.config.cache.clone());
|
||||
this.rate_limiter.reset();
|
||||
this.logs.configure(envelope.config.logging.clone());
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
//! a validated HTTP/1.1 `Upgrade: h2c` bootstrap.
|
||||
|
||||
include!("server/listeners.rs");
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) mod sendfile;
|
||||
#[cfg(target_os = "linux")]
|
||||
type TransportSendfileSlots = Option<sendfile::SendfileSlots>;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
type TransportSendfileSlots = ();
|
||||
include!("server/http.rs");
|
||||
include!("server/tls_runtime.rs");
|
||||
include!("server/http1_connection.rs");
|
||||
include!("server/serve.rs");
|
||||
@@ -0,0 +1,207 @@
|
||||
// HTTP/1 dispatcher and upgrade flow; split from TLS listener runtime.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn connection<I>(
|
||||
stream: I,
|
||||
router: Router,
|
||||
peer: SocketAddr,
|
||||
local: SocketAddr,
|
||||
transport: Transport,
|
||||
tls_sni: Option<String>,
|
||||
h2: bool,
|
||||
extended_connect: bool,
|
||||
h2c_upgrade: bool,
|
||||
frontend_state: Option<Arc<App>>,
|
||||
lease: Arc<ConnectionLease>,
|
||||
stop: CancellationToken,
|
||||
grace: Duration,
|
||||
http2_settings: Http2Connections,
|
||||
sendfile_slots: TransportSendfileSlots,
|
||||
) where
|
||||
I: AsyncRead + AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
if h2 {
|
||||
http2_connection(
|
||||
stream,
|
||||
router,
|
||||
peer,
|
||||
local,
|
||||
transport,
|
||||
tls_sni.clone(),
|
||||
extended_connect,
|
||||
frontend_state.clone(),
|
||||
lease,
|
||||
stop,
|
||||
grace,
|
||||
http2_settings,
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
let request_stop = stop.clone();
|
||||
let service_frontend_state = frontend_state.clone();
|
||||
let service = service_fn(move |mut request: Request<Incoming>| {
|
||||
let router = router.clone();
|
||||
let request_stop = request_stop.clone();
|
||||
let request_lease = lease.clone();
|
||||
let upgrade_state = service_frontend_state.clone();
|
||||
#[cfg(target_os = "linux")]
|
||||
let sendfile_slots = sendfile_slots.clone();
|
||||
async move {
|
||||
request.extensions_mut().insert(PeerAddr(peer));
|
||||
request.extensions_mut().insert(LocalAddr(local));
|
||||
request.extensions_mut().insert(transport);
|
||||
request.extensions_mut().insert(request_lease.clone());
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some(slots) = sendfile_slots {
|
||||
request.extensions_mut().insert(slots);
|
||||
}
|
||||
request
|
||||
.extensions_mut()
|
||||
.insert(ServerShutdown(request_stop.clone()));
|
||||
|
||||
if h2c_attempt(&request) {
|
||||
if !h2c_upgrade {
|
||||
let (authority, host) = request_log_identity(&request);
|
||||
log_protocol_error_with_identity(
|
||||
upgrade_state.as_ref(),
|
||||
"h2c_upgrade_rejected",
|
||||
"h2c upgrade is disabled on this listener",
|
||||
peer,
|
||||
local,
|
||||
"tcp",
|
||||
"1.1",
|
||||
400,
|
||||
authority,
|
||||
host,
|
||||
None,
|
||||
);
|
||||
return Ok::<_, Infallible>(
|
||||
Response::builder()
|
||||
.status(http::StatusCode::BAD_REQUEST)
|
||||
.header(http::header::CACHE_CONTROL, "no-store")
|
||||
.body(Body::from("h2c upgrade is disabled on this listener"))
|
||||
.expect("static h2c disabled response"),
|
||||
);
|
||||
}
|
||||
let injected_headers = match h2_headers_frame(&request) {
|
||||
Ok(frame) => frame,
|
||||
Err(message) => {
|
||||
let (authority, host) = request_log_identity(&request);
|
||||
log_protocol_error_with_identity(
|
||||
upgrade_state.as_ref(),
|
||||
"h2c_upgrade_invalid",
|
||||
message,
|
||||
peer,
|
||||
local,
|
||||
"tcp",
|
||||
"1.1",
|
||||
400,
|
||||
authority,
|
||||
host,
|
||||
None,
|
||||
);
|
||||
return Ok::<_, Infallible>(
|
||||
Response::builder()
|
||||
.status(http::StatusCode::BAD_REQUEST)
|
||||
.header(http::header::CACHE_CONTROL, "no-store")
|
||||
.body(Body::from(message))
|
||||
.expect("static h2c validation response"),
|
||||
);
|
||||
}
|
||||
};
|
||||
let on_upgrade = hyper::upgrade::on(&mut request);
|
||||
let upgrade_router = router.clone();
|
||||
let upgrade_stop = request_stop.clone();
|
||||
let upgrade_lease = request_lease.clone();
|
||||
let upgrade_state_for_task = upgrade_state.clone();
|
||||
let task = async move {
|
||||
match on_upgrade.await {
|
||||
Ok(upgraded) => {
|
||||
if let Some(state) = &upgrade_state_for_task {
|
||||
state
|
||||
.metrics
|
||||
.http2_connections
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
let extended_connect =
|
||||
upgrade_state_for_task.as_ref().is_some_and(|state| {
|
||||
state.config.load().websocket_rfc8441_on(local, false)
|
||||
});
|
||||
let io = H2cUpgradeIo::new(TokioIo::new(upgraded), injected_headers);
|
||||
http2_connection(
|
||||
io,
|
||||
upgrade_router,
|
||||
peer,
|
||||
local,
|
||||
Transport { tls: false },
|
||||
None,
|
||||
extended_connect,
|
||||
upgrade_state_for_task.clone(),
|
||||
upgrade_lease,
|
||||
upgrade_stop,
|
||||
grace,
|
||||
http2_settings,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(error) => {
|
||||
log_protocol_error(
|
||||
upgrade_state_for_task.as_ref(),
|
||||
"h2c_transport_error",
|
||||
&error,
|
||||
peer,
|
||||
local,
|
||||
"tcp",
|
||||
"2",
|
||||
0,
|
||||
None,
|
||||
);
|
||||
tracing::debug!(%error, "h2c upgrade transport failed");
|
||||
}
|
||||
}
|
||||
};
|
||||
if let Some(state) = upgrade_state {
|
||||
state.tunnels.spawn(task);
|
||||
} else {
|
||||
tokio::spawn(task);
|
||||
}
|
||||
return Ok::<_, Infallible>(
|
||||
Response::builder()
|
||||
.status(http::StatusCode::SWITCHING_PROTOCOLS)
|
||||
.header(http::header::CONNECTION, "Upgrade")
|
||||
.header(http::header::UPGRADE, "h2c")
|
||||
.body(Body::empty())
|
||||
.expect("static h2c switching response"),
|
||||
);
|
||||
}
|
||||
|
||||
router.oneshot(request.map(Body::new)).await
|
||||
}
|
||||
});
|
||||
let mut builder = http1::Builder::new();
|
||||
builder
|
||||
.timer(TokioTimer::new())
|
||||
.keep_alive(true)
|
||||
.max_headers(64)
|
||||
.max_buf_size(65_536)
|
||||
.header_read_timeout(Duration::from_secs(10));
|
||||
let conn = builder
|
||||
.serve_connection(TokioIo::new(stream), service)
|
||||
.with_upgrades();
|
||||
tokio::pin!(conn);
|
||||
tokio::select! {
|
||||
result = &mut conn => {
|
||||
if let Err(error) = result {
|
||||
log_protocol_error(
|
||||
frontend_state.as_ref(), "http1_connection_error", &error, peer, local,
|
||||
if transport.tls { "tls" } else { "tcp" }, "1.1", 400, tls_sni.clone(),
|
||||
);
|
||||
tracing::debug!(%error, "HTTP/1 connection closed");
|
||||
}
|
||||
},
|
||||
_ = stop.cancelled() => {
|
||||
conn.as_mut().graceful_shutdown(); let _ = tokio::time::timeout(grace, &mut conn).await;
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
//! Linux HTTP/1 kernel sendfile bridge.
|
||||
//!
|
||||
//! Hyper's Body uses Bytes, not file descriptors. This opt-in bridge conveys a
|
||||
//! uniquely tagged, exact-length placeholder through the ordinary metering
|
||||
//! pipeline; a TCP-only AsyncWrite adapter replaces those bytes with the
|
||||
//! original, already-validated file via Linux sendfile(2). An unmatched marker
|
||||
//! or short file is a hard I/O error, never silently returned as user content.
|
||||
//! Never register a placeholder for cached, compressed, transformed, TLS, H2,
|
||||
//! H3, ranged or non-GET responses.
|
||||
|
||||
use bytes::Bytes;
|
||||
use http_body::{Body as HttpBody, Frame, SizeHint};
|
||||
use std::{
|
||||
fs::File,
|
||||
io,
|
||||
os::fd::{AsRawFd, RawFd},
|
||||
pin::Pin,
|
||||
sync::{Arc, Mutex},
|
||||
sync::atomic::Ordering,
|
||||
task::{Context, Poll},
|
||||
};
|
||||
use tokio::{io::{AsyncRead, AsyncWrite, Interest, ReadBuf}, net::TcpStream};
|
||||
|
||||
const TAG_LEN: usize = 32;
|
||||
const FRAME_LEN: usize = 64 * 1024;
|
||||
const MAX_PENDING: usize = 8;
|
||||
const MIN_SIZE: u64 = 64 * 1024;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct SendfileCandidate {
|
||||
pub file: Arc<File>,
|
||||
pub length: u64,
|
||||
}
|
||||
|
||||
struct Slot {
|
||||
tag: [u8; TAG_LEN],
|
||||
file: Arc<File>,
|
||||
length: u64,
|
||||
written: u64,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Registry {
|
||||
slots: Vec<Slot>,
|
||||
active: Option<[u8; TAG_LEN]>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct SendfileSlots(Arc<Mutex<Registry>>);
|
||||
|
||||
pub(crate) struct SendfileBody {
|
||||
bytes: Bytes,
|
||||
remaining: u64,
|
||||
web_metrics: Arc<crate::web_server::metrics::PhpMetrics>,
|
||||
}
|
||||
|
||||
// Web Server counters count emitted body frames before any downstream encoder.
|
||||
// Atomic reference is supplied by the Web Server that originally opened the file.
|
||||
impl SendfileSlots {
|
||||
pub fn create_body(
|
||||
&self,
|
||||
candidate: SendfileCandidate,
|
||||
web_metrics: Arc<crate::web_server::metrics::PhpMetrics>,
|
||||
) -> Option<SendfileBody> {
|
||||
if !(MIN_SIZE..=i64::MAX as u64).contains(&candidate.length) {
|
||||
return None;
|
||||
}
|
||||
let mut registry = self.0.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if registry.slots.len() >= MAX_PENDING { return None; }
|
||||
let mut tag = [0u8; TAG_LEN];
|
||||
tag[0] = 0; // A valid HTTP/1 response header never contains NUL.
|
||||
tag[1..].copy_from_slice(&rand::random::<[u8; TAG_LEN - 1]>());
|
||||
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); }
|
||||
registry.slots.push(Slot {
|
||||
tag, file: candidate.file, length: candidate.length, written: 0,
|
||||
});
|
||||
Some(SendfileBody {
|
||||
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());
|
||||
// Critical for mixed reverse-proxy traffic: almost all socket writes
|
||||
// have no pending static file; do not inspect their payload at all.
|
||||
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);
|
||||
}
|
||||
return WritePlan::Invalid;
|
||||
}
|
||||
// A marker is an opaque 32-byte random tag, searched only on a
|
||||
// connection that has a registered response. Headers preceding a
|
||||
// marker are written intact; each subsequent body byte is validated.
|
||||
for start in 0..buf.len() {
|
||||
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); }
|
||||
return plan_file(slot, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
WritePlan::Plain(buf.len())
|
||||
}
|
||||
|
||||
fn commit(&self, tag: [u8; TAG_LEN], count: usize) {
|
||||
let mut registry = self.0.lock().unwrap_or_else(|e| e.into_inner());
|
||||
registry.active = Some(tag);
|
||||
if let Some(slot) = registry.slots.iter_mut().find(|s| s.tag == tag) {
|
||||
slot.written += count as u64;
|
||||
if slot.written == slot.length {
|
||||
registry.slots.retain(|s| s.tag != tag);
|
||||
registry.active = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum WritePlan {
|
||||
Plain(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()
|
||||
.take_while(|(n, byte)| **byte == slot.tag[(slot.written as usize + n) % TAG_LEN])
|
||||
.count();
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
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); }
|
||||
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);
|
||||
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 }
|
||||
}
|
||||
|
||||
pub(crate) struct SendfileIo {
|
||||
stream: TcpStream,
|
||||
slots: SendfileSlots,
|
||||
}
|
||||
impl SendfileIo {
|
||||
pub fn new(stream: TcpStream) -> (Self, SendfileSlots) {
|
||||
let slots = SendfileSlots::default();
|
||||
(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<()>> {
|
||||
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)); }
|
||||
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 } => {
|
||||
let _keep_open = file;
|
||||
let socket: RawFd = self.stream.as_raw_fd();
|
||||
let mut at: libc::off_t = offset as libc::off_t;
|
||||
match self.stream.poll_write_ready(cx) {
|
||||
Poll::Pending => Poll::Pending,
|
||||
Poll::Ready(Err(e)) => Poll::Ready(Err(e)),
|
||||
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) }
|
||||
});
|
||||
match result {
|
||||
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)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
Pin::new(&mut self.stream).poll_flush(cx)
|
||||
}
|
||||
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>> {
|
||||
// Preserve zero-extra-syscall vectored I/O whenever no sendfile body
|
||||
// has been prepared on this connection.
|
||||
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()) {
|
||||
Some(part) => self.poll_write(cx, part),
|
||||
None => Poll::Ready(Ok(0)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use http_body::Body;
|
||||
use tempfile::tempfile;
|
||||
use std::io::Write;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
#[test]
|
||||
fn placeholder_has_exact_frame_lengths_and_recovers_fd() {
|
||||
let mut file = tempfile().unwrap();
|
||||
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"),
|
||||
};
|
||||
assert_eq!(first.len(), FRAME_LEN);
|
||||
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"),
|
||||
};
|
||||
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") }
|
||||
slots.commit(tag, second.len());
|
||||
assert!(slots.0.lock().unwrap().slots.is_empty());
|
||||
assert!(body.is_end_stream());
|
||||
assert_eq!(counter.response_bytes.load(Ordering::Relaxed), 100_000);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_sendfile_transfers_exact_file_and_keeps_socket_reusable() {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
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 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());
|
||||
// Combine header and first marker into one socket write: the
|
||||
// bridge must send the header before enabling kernel transfer.
|
||||
let mut body = body;
|
||||
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();
|
||||
}
|
||||
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();
|
||||
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(&expected);
|
||||
}
|
||||
assert_eq!(actual, wanted, "kernel sendfile must never leak placeholder markers");
|
||||
}
|
||||
}
|
||||
+20
-199
@@ -134,6 +134,7 @@ async fn serve_inner(
|
||||
stop,
|
||||
grace,
|
||||
http2_settings,
|
||||
Default::default(),
|
||||
)
|
||||
.await;
|
||||
},
|
||||
@@ -178,6 +179,7 @@ async fn serve_inner(
|
||||
stop,
|
||||
grace,
|
||||
Http2Connections::default(),
|
||||
Default::default(),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
@@ -208,6 +210,7 @@ async fn serve_inner(
|
||||
stop,
|
||||
grace,
|
||||
Http2Connections::default(),
|
||||
Default::default(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -242,6 +245,22 @@ async fn serve_inner(
|
||||
&& frontend_state
|
||||
.as_ref()
|
||||
.is_some_and(|state| state.config.load().websocket_rfc8441_on(local, false));
|
||||
// The existing Hyper transport remains untouched when the
|
||||
// option is disabled. Connections opened after a hot config
|
||||
// change acquire a new bridge automatically.
|
||||
#[cfg(target_os = "linux")]
|
||||
if !prior_h2 && frontend_state.as_ref().is_some_and(|state| {
|
||||
state.config.load().web_php_services.values()
|
||||
.any(|service| service.sendfile == crate::web_server::SendfileMode::Auto)
|
||||
}) {
|
||||
let (io, slots) = sendfile::SendfileIo::new(stream);
|
||||
connection(
|
||||
io, router, peer, local, Transport { tls: false }, None,
|
||||
false, false, h2c, frontend_state, lease, stop, grace,
|
||||
http2_settings, Some(slots),
|
||||
).await;
|
||||
return;
|
||||
}
|
||||
connection(
|
||||
stream,
|
||||
router,
|
||||
@@ -257,6 +276,7 @@ async fn serve_inner(
|
||||
stop,
|
||||
grace,
|
||||
http2_settings,
|
||||
Default::default(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -273,205 +293,6 @@ async fn serve_inner(
|
||||
while tasks.join_next().await.is_some() {}
|
||||
Ok(())
|
||||
}
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn connection<I>(
|
||||
stream: I,
|
||||
router: Router,
|
||||
peer: SocketAddr,
|
||||
local: SocketAddr,
|
||||
transport: Transport,
|
||||
tls_sni: Option<String>,
|
||||
h2: bool,
|
||||
extended_connect: bool,
|
||||
h2c_upgrade: bool,
|
||||
frontend_state: Option<Arc<App>>,
|
||||
lease: Arc<ConnectionLease>,
|
||||
stop: CancellationToken,
|
||||
grace: Duration,
|
||||
http2_settings: Http2Connections,
|
||||
) where
|
||||
I: AsyncRead + AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
if h2 {
|
||||
http2_connection(
|
||||
stream,
|
||||
router,
|
||||
peer,
|
||||
local,
|
||||
transport,
|
||||
tls_sni.clone(),
|
||||
extended_connect,
|
||||
frontend_state.clone(),
|
||||
lease,
|
||||
stop,
|
||||
grace,
|
||||
http2_settings,
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
let request_stop = stop.clone();
|
||||
let service_frontend_state = frontend_state.clone();
|
||||
let service = service_fn(move |mut request: Request<Incoming>| {
|
||||
let router = router.clone();
|
||||
let request_stop = request_stop.clone();
|
||||
let request_lease = lease.clone();
|
||||
let upgrade_state = service_frontend_state.clone();
|
||||
async move {
|
||||
request.extensions_mut().insert(PeerAddr(peer));
|
||||
request.extensions_mut().insert(LocalAddr(local));
|
||||
request.extensions_mut().insert(transport);
|
||||
request.extensions_mut().insert(request_lease.clone());
|
||||
request
|
||||
.extensions_mut()
|
||||
.insert(ServerShutdown(request_stop.clone()));
|
||||
|
||||
if h2c_attempt(&request) {
|
||||
if !h2c_upgrade {
|
||||
let (authority, host) = request_log_identity(&request);
|
||||
log_protocol_error_with_identity(
|
||||
upgrade_state.as_ref(),
|
||||
"h2c_upgrade_rejected",
|
||||
"h2c upgrade is disabled on this listener",
|
||||
peer,
|
||||
local,
|
||||
"tcp",
|
||||
"1.1",
|
||||
400,
|
||||
authority,
|
||||
host,
|
||||
None,
|
||||
);
|
||||
return Ok::<_, Infallible>(
|
||||
Response::builder()
|
||||
.status(http::StatusCode::BAD_REQUEST)
|
||||
.header(http::header::CACHE_CONTROL, "no-store")
|
||||
.body(Body::from("h2c upgrade is disabled on this listener"))
|
||||
.expect("static h2c disabled response"),
|
||||
);
|
||||
}
|
||||
let injected_headers = match h2_headers_frame(&request) {
|
||||
Ok(frame) => frame,
|
||||
Err(message) => {
|
||||
let (authority, host) = request_log_identity(&request);
|
||||
log_protocol_error_with_identity(
|
||||
upgrade_state.as_ref(),
|
||||
"h2c_upgrade_invalid",
|
||||
message,
|
||||
peer,
|
||||
local,
|
||||
"tcp",
|
||||
"1.1",
|
||||
400,
|
||||
authority,
|
||||
host,
|
||||
None,
|
||||
);
|
||||
return Ok::<_, Infallible>(
|
||||
Response::builder()
|
||||
.status(http::StatusCode::BAD_REQUEST)
|
||||
.header(http::header::CACHE_CONTROL, "no-store")
|
||||
.body(Body::from(message))
|
||||
.expect("static h2c validation response"),
|
||||
);
|
||||
}
|
||||
};
|
||||
let on_upgrade = hyper::upgrade::on(&mut request);
|
||||
let upgrade_router = router.clone();
|
||||
let upgrade_stop = request_stop.clone();
|
||||
let upgrade_lease = request_lease.clone();
|
||||
let upgrade_state_for_task = upgrade_state.clone();
|
||||
let task = async move {
|
||||
match on_upgrade.await {
|
||||
Ok(upgraded) => {
|
||||
if let Some(state) = &upgrade_state_for_task {
|
||||
state
|
||||
.metrics
|
||||
.http2_connections
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
let extended_connect =
|
||||
upgrade_state_for_task.as_ref().is_some_and(|state| {
|
||||
state.config.load().websocket_rfc8441_on(local, false)
|
||||
});
|
||||
let io = H2cUpgradeIo::new(TokioIo::new(upgraded), injected_headers);
|
||||
http2_connection(
|
||||
io,
|
||||
upgrade_router,
|
||||
peer,
|
||||
local,
|
||||
Transport { tls: false },
|
||||
None,
|
||||
extended_connect,
|
||||
upgrade_state_for_task.clone(),
|
||||
upgrade_lease,
|
||||
upgrade_stop,
|
||||
grace,
|
||||
http2_settings,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(error) => {
|
||||
log_protocol_error(
|
||||
upgrade_state_for_task.as_ref(),
|
||||
"h2c_transport_error",
|
||||
&error,
|
||||
peer,
|
||||
local,
|
||||
"tcp",
|
||||
"2",
|
||||
0,
|
||||
None,
|
||||
);
|
||||
tracing::debug!(%error, "h2c upgrade transport failed");
|
||||
}
|
||||
}
|
||||
};
|
||||
if let Some(state) = upgrade_state {
|
||||
state.tunnels.spawn(task);
|
||||
} else {
|
||||
tokio::spawn(task);
|
||||
}
|
||||
return Ok::<_, Infallible>(
|
||||
Response::builder()
|
||||
.status(http::StatusCode::SWITCHING_PROTOCOLS)
|
||||
.header(http::header::CONNECTION, "Upgrade")
|
||||
.header(http::header::UPGRADE, "h2c")
|
||||
.body(Body::empty())
|
||||
.expect("static h2c switching response"),
|
||||
);
|
||||
}
|
||||
|
||||
router.oneshot(request.map(Body::new)).await
|
||||
}
|
||||
});
|
||||
let mut builder = http1::Builder::new();
|
||||
builder
|
||||
.timer(TokioTimer::new())
|
||||
.keep_alive(true)
|
||||
.max_headers(64)
|
||||
.max_buf_size(65_536)
|
||||
.header_read_timeout(Duration::from_secs(10));
|
||||
let conn = builder
|
||||
.serve_connection(TokioIo::new(stream), service)
|
||||
.with_upgrades();
|
||||
tokio::pin!(conn);
|
||||
tokio::select! {
|
||||
result = &mut conn => {
|
||||
if let Err(error) = result {
|
||||
log_protocol_error(
|
||||
frontend_state.as_ref(), "http1_connection_error", &error, peer, local,
|
||||
if transport.tls { "tls" } else { "tcp" }, "1.1", 400, tls_sni.clone(),
|
||||
);
|
||||
tracing::debug!(%error, "HTTP/1 connection closed");
|
||||
}
|
||||
},
|
||||
_ = stop.cancelled() => {
|
||||
conn.as_mut().graceful_shutdown(); let _ = tokio::time::timeout(grace, &mut conn).await;
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum H2ShutdownCause {
|
||||
Idle,
|
||||
|
||||
@@ -426,6 +426,7 @@ impl Service {
|
||||
web_metrics: Arc::new(crate::web_server::metrics::PhpMetrics::default()),
|
||||
id: self.id.clone(),
|
||||
name: self.name.clone(),
|
||||
sendfile: self.sendfile,
|
||||
root,
|
||||
index: self.index.clone(),
|
||||
directory_listing: self.directory_listing,
|
||||
|
||||
@@ -216,6 +216,16 @@ pub enum SymlinkPolicy {
|
||||
InsideRoot,
|
||||
}
|
||||
|
||||
/// Opt-in kernel zero-copy for suitable Linux plain HTTP/1.1 static GETs.
|
||||
/// Other requests continue through the original streaming implementation.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SendfileMode {
|
||||
#[default]
|
||||
Off,
|
||||
Auto,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct RewriteRule {
|
||||
@@ -338,6 +348,8 @@ pub struct Service {
|
||||
pub document_root: PathBuf,
|
||||
pub index: Vec<String>,
|
||||
pub directory_listing: bool,
|
||||
/// Transport-only preference: it must not rotate cache identity.
|
||||
pub sendfile: SendfileMode,
|
||||
/// Optional per-extension MIME overrides. Keys are extensions without a leading dot.
|
||||
pub mime_types: BTreeMap<String, String>,
|
||||
/// Fallback MIME type used when neither an override nor a built-in mapping exists.
|
||||
@@ -382,6 +394,7 @@ impl Default for Service {
|
||||
document_root: PathBuf::new(),
|
||||
index: default_index(),
|
||||
directory_listing: false,
|
||||
sendfile: SendfileMode::Off,
|
||||
mime_types: BTreeMap::new(),
|
||||
default_mime_type: "application/octet-stream".into(),
|
||||
cache_control: None,
|
||||
|
||||
@@ -47,6 +47,7 @@ pub struct CompiledService {
|
||||
pub id: String,
|
||||
pub(crate) web_metrics: Arc<crate::web_server::metrics::PhpMetrics>,
|
||||
pub name: String,
|
||||
pub sendfile: SendfileMode,
|
||||
pub(super) root: PathBuf,
|
||||
pub(super) index: Vec<String>,
|
||||
pub(super) directory_listing: bool,
|
||||
|
||||
@@ -17,7 +17,7 @@ mod serve;
|
||||
|
||||
pub use config::{
|
||||
CacheRule, CompiledService, Hardening, Php, PhpFpmProfile, Precompressed, RewriteRule, Service,
|
||||
SymlinkPolicy,
|
||||
SymlinkPolicy, SendfileMode,
|
||||
};
|
||||
pub use htaccess::Config as HtaccessConfig;
|
||||
pub use request::RequestContext;
|
||||
|
||||
@@ -162,6 +162,18 @@ async fn file_response_inner(
|
||||
return response;
|
||||
}
|
||||
|
||||
// Clone the already confined, validated file descriptor. Never reopen
|
||||
// the user-controlled path for kernel sendfile (TOCTOU/symlink escape).
|
||||
// When no HTTP/1 TCP bridge accepts it, the regular reader remains used.
|
||||
#[cfg(target_os = "linux")]
|
||||
let sendfile_candidate = if service.sendfile == super::SendfileMode::Auto
|
||||
&& !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 };
|
||||
let mut file = tokio::fs::File::from_std(opened.file);
|
||||
if let Some(boundary) = boundary {
|
||||
let ranges = ranges.expect("multipart ranges");
|
||||
@@ -211,6 +223,10 @@ async fn file_response_inner(
|
||||
}
|
||||
let stream = ReaderStream::with_capacity(file.take(body_length), stream_capacity);
|
||||
*response.body_mut() = Body::new(file_body::FileBody::new(stream, body_length));
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some(candidate) = sendfile_candidate {
|
||||
response.extensions_mut().insert(candidate);
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
|
||||
@@ -2225,3 +2225,54 @@ async fn cancelled_static_and_multipart_bodies_release_files_and_count_only_emit
|
||||
assert_eq!(service.web_metrics.view()["response_bytes"], accounted);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[tokio::test]
|
||||
async fn sendfile_auto_without_tcp_bridge_preserves_full_head_range_and_validators() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let original = vec![0x5au8; 128 * 1024 + 3];
|
||||
std::fs::write(tmp.path().join("large.bin"), &original).unwrap();
|
||||
let service = Service {
|
||||
id: "sendfile-fallback".into(),
|
||||
name: "Sendfile fallback".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
sendfile: SendfileMode::Auto,
|
||||
..Default::default()
|
||||
}.compile().unwrap();
|
||||
|
||||
let get = serve_request(&service, &request(Method::GET, "/large.bin"), "/large.bin").await;
|
||||
assert_eq!(get.status(), StatusCode::OK);
|
||||
assert_eq!(get.headers()[CONTENT_LENGTH].to_str().unwrap(), original.len().to_string());
|
||||
// No transport adapter here. Even if a candidate extension was produced,
|
||||
// its fallback Body must still stream the exact original representation.
|
||||
let etag = get.headers()[ETAG].clone();
|
||||
assert_eq!(get.into_body().collect().await.unwrap().to_bytes().to_vec(), original);
|
||||
|
||||
let head = serve_request(&service, &request(Method::HEAD, "/large.bin"), "/large.bin").await;
|
||||
assert_eq!(head.status(), StatusCode::OK);
|
||||
assert_eq!(head.headers()[CONTENT_LENGTH].to_str().unwrap(), original.len().to_string());
|
||||
assert!(head.into_body().collect().await.unwrap().to_bytes().is_empty());
|
||||
|
||||
let mut range = request(Method::GET, "/large.bin");
|
||||
range.headers_mut().insert(RANGE, HeaderValue::from_static("bytes=100-199"));
|
||||
let ranged = serve_request(&service, &range, "/large.bin").await;
|
||||
assert_eq!(ranged.status(), StatusCode::PARTIAL_CONTENT);
|
||||
assert_eq!(ranged.headers()[CONTENT_RANGE].to_str().unwrap(), format!("bytes 100-199/{}", original.len()));
|
||||
assert_eq!(ranged.into_body().collect().await.unwrap().to_bytes().to_vec(), original[100..200]);
|
||||
|
||||
let mut conditional = request(Method::GET, "/large.bin");
|
||||
conditional.headers_mut().insert(IF_NONE_MATCH, etag);
|
||||
let unchanged = serve_request(&service, &conditional, "/large.bin").await;
|
||||
assert_eq!(unchanged.status(), StatusCode::NOT_MODIFIED);
|
||||
assert!(unchanged.into_body().collect().await.unwrap().to_bytes().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sendfile_mode_is_backward_compatible_and_rejects_unknown_values() {
|
||||
assert_eq!(SendfileMode::default(), SendfileMode::Off);
|
||||
assert_eq!(serde_json::from_str::<SendfileMode>("\"auto\"").unwrap(), SendfileMode::Auto);
|
||||
assert_eq!(serde_json::from_str::<SendfileMode>("\"off\"").unwrap(), SendfileMode::Off);
|
||||
assert!(serde_json::from_str::<SendfileMode>("\"unsafe\"").is_err());
|
||||
let legacy = serde_json::json!({"id":"legacy", "name":"Legacy", "document_root":"/tmp"});
|
||||
assert_eq!(serde_json::from_value::<Service>(legacy).unwrap().sendfile, SendfileMode::Off);
|
||||
}
|
||||
Reference in new issue
Block a user