Skip to content

Commit e892be1

Browse files
committed
Harden load balancer runtime state persistence
1 parent 8a273cb commit e892be1

9 files changed

Lines changed: 258 additions & 48 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,19 @@ behavior when the change improves security or project direction.
3232
pool has `proxy.load_balance.runtime_state_file` configured, and keep
3333
`persistent: false` for in-memory-only pools.
3434

35+
### Security
36+
37+
- Move request-path persistence state-file writes to the blocking worker pool
38+
with serialized snapshot writes, while keeping admin mutation saves
39+
synchronous and ordered.
40+
- Harden runtime state files with fd-based permission setting, temp-file
41+
cleanup on failed writes, and all-or-nothing restore validation for mixed
42+
policy/persistence state.
43+
- Document and warn when raw header or cookie persistence writes client affinity
44+
identifiers to `proxy.load_balance.runtime_state_file`; prefer
45+
`managed-cookie` or encrypted, access-restricted storage for session-bearing
46+
identifiers.
47+
3548
## 1.5.8 - 2026-06-07
3649

3750
### Added

docs/config-reference.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1274,6 +1274,14 @@ Corrupt, oversized, incompatible, or stale state is ignored and rebuilt instead
12741274
of poisoning the pool. The file is local to one Fluxheim process and does not
12751275
share managed-cookie signing keys across nodes.
12761276

1277+
When persistence is enabled, the state file includes local affinity table keys.
1278+
`source-ip` mode writes client IP bytes, `header` mode writes the configured
1279+
header value, and `cookie` mode writes the configured cookie value.
1280+
`managed-cookie` mode writes opaque affinity keys generated by Fluxheim. Use
1281+
`managed-cookie` for session affinity when possible, or place the state file on
1282+
an encrypted, access-restricted volume when raw header or cookie identifiers are
1283+
used.
1284+
12771285
The current `1.5.x` load-balancer line does not add/remove pool members at
12781286
runtime, apply runtime weights to hash/ring selectors, share managed-cookie
12791287
signing keys across nodes, or synchronize load-balancer state across

packaging/container/load-balancer.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ all_down_status = 503
6060
max_iterations = 128
6161
# Optional local restart-persistent runtime state. Mount the parent directory
6262
# as a persistent volume before enabling this in containers.
63+
# Header and cookie persistence modes write raw affinity identifiers to this
64+
# file; prefer managed-cookie mode or an encrypted volume for session-bearing
65+
# identifiers.
6366
# runtime_state_file = "/var/lib/fluxheim/load-balancer/default.json"
6467

6568
[vhosts.proxy.load_balance.health_check]

release-notes/RELEASE_NOTES_1.5.9.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,22 @@ Fluxheim 1.5.9 starts the restart-persistent load-balancer state line.
2626
has a runtime state file configured, and `persistent: false` for in-memory
2727
pools.
2828

29+
## Hardened
30+
31+
- Persistence-table state-file writes from request selection now run on the
32+
blocking worker pool instead of performing synchronous fsync work on an async
33+
executor thread.
34+
- Runtime state file writes now set Unix permissions on the already-open file
35+
descriptor and remove temporary files when a write, sync, rename, or directory
36+
sync fails.
37+
- Runtime state restore now validates policy overrides and persistence entries
38+
before committing either half, preventing partial restore of a mixed-validity
39+
state file.
40+
- Documentation and startup warnings now call out that raw `header` and
41+
`cookie` persistence modes write client affinity identifiers to
42+
`proxy.load_balance.runtime_state_file`; use `managed-cookie` or encrypted,
43+
access-restricted storage for session-bearing identifiers.
44+
2945
## Stop Line
3046

3147
This release does not add cross-node state sync, runtime add/remove-member,

src/config_load_balance.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,25 @@ impl LoadBalanceConfig {
145145
"proxy.load_balance.runtime_state_file",
146146
Some(path),
147147
)?;
148+
if self.persistence.enabled
149+
&& matches!(
150+
self.persistence.mode,
151+
LoadBalancePersistenceMode::Header | LoadBalancePersistenceMode::Cookie
152+
)
153+
{
154+
let persistence_mode = match self.persistence.mode {
155+
LoadBalancePersistenceMode::Header => "header",
156+
LoadBalancePersistenceMode::Cookie => "cookie",
157+
LoadBalancePersistenceMode::SourceIp
158+
| LoadBalancePersistenceMode::ManagedCookie => unreachable!(),
159+
};
160+
log::warn!(
161+
target: "fluxheim::security",
162+
"proxy.load_balance.runtime_state_file is configured with raw {} persistence; client affinity identifiers are written to disk at {}. Use managed-cookie mode or an encrypted, access-restricted volume for session-bearing identifiers.",
163+
persistence_mode,
164+
path.display()
165+
);
166+
}
148167
}
149168
self.queue.validate()?;
150169
Ok(())

src/load_balancer.rs

Lines changed: 129 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use std::fmt::{Debug, Formatter};
22
use std::io;
33
use std::net::IpAddr;
4-
use std::path::PathBuf;
4+
use std::path::{Path, PathBuf};
55
use std::sync::Arc;
66
use std::sync::atomic::{AtomicUsize, Ordering};
77
use std::time::{Duration, Instant};
@@ -74,6 +74,7 @@ pub struct UpstreamLoadBalancer {
7474
queue_policy: LoadBalanceQueueConfig,
7575
queue_waiting: Arc<AtomicUsize>,
7676
runtime_state_file: Option<Arc<PathBuf>>,
77+
runtime_state_save_lock: Arc<std::sync::Mutex<()>>,
7778
round_robin_cursor: Arc<AtomicUsize>,
7879
state_prune_counter: Arc<AtomicUsize>,
7980
counters: Arc<BackendConnectionCounters>,
@@ -663,12 +664,14 @@ impl UpstreamLoadBalancer {
663664
let selected = self.prepare_selected(selected, persistence_outcome)?;
664665
if let (Some(persistence), Some(key)) = (&self.persistence, persistence_key) {
665666
persistence.record(key, backend_key(&selected.backend));
666-
self.save_runtime_state_if_configured("persistence_record");
667+
self.save_runtime_state_if_configured_in_background("persistence_record");
667668
} else if let Some(persistence) = &self.persistence
668669
&& let Some((key, cookie)) = persistence.new_managed_cookie()
669670
{
670671
persistence.record(&key, backend_key(&selected.backend));
671-
self.save_runtime_state_if_configured("managed_cookie_persistence_record");
672+
self.save_runtime_state_if_configured_in_background(
673+
"managed_cookie_persistence_record",
674+
);
672675
let mut selected = selected;
673676
selected.managed_affinity_cookie = Some(cookie);
674677
return Some(selected);
@@ -757,6 +760,7 @@ impl UpstreamLoadBalancer {
757760
queue_policy: config.load_balance.queue.clone(),
758761
queue_waiting: Arc::new(AtomicUsize::new(0)),
759762
runtime_state_file: config.load_balance.runtime_state_file.clone().map(Arc::new),
763+
runtime_state_save_lock: Arc::new(std::sync::Mutex::new(())),
760764
round_robin_cursor: Arc::new(AtomicUsize::new(0)),
761765
state_prune_counter: Arc::new(AtomicUsize::new(0)),
762766
counters: Arc::new(BackendConnectionCounters::default()),
@@ -775,27 +779,23 @@ impl UpstreamLoadBalancer {
775779
return;
776780
}
777781
let backends = self.inner.backends();
778-
let live_connection_keys = backends
779-
.iter()
780-
.map(backend_key)
781-
.collect::<std::collections::HashSet<_>>();
782-
let live_policy_keys = backends
782+
let live_keys = backends
783783
.iter()
784784
.map(backend_key)
785785
.collect::<std::collections::HashSet<_>>();
786-
self.counters.prune_stale(&live_connection_keys);
787-
self.backend_policy.prune_stale(&live_policy_keys);
786+
self.counters.prune_stale(&live_keys);
787+
self.backend_policy.prune_stale(&live_keys);
788788
if let Some(persistence) = &self.persistence {
789-
persistence.prune_stale_for_live_backends(&live_policy_keys);
789+
persistence.prune_stale_for_live_backends(&live_keys);
790790
}
791791
if let Some(passive_health) = &self.passive_health {
792-
passive_health.prune_stale(&live_connection_keys);
792+
passive_health.prune_stale(&live_keys);
793793
}
794794
if let Some(slow_start) = &self.slow_start {
795-
slow_start.prune_stale(&live_connection_keys);
795+
slow_start.prune_stale(&live_keys);
796796
}
797797
if let Some(latency) = self.inner.latency_state() {
798-
latency.prune_stale(&live_connection_keys);
798+
latency.prune_stale(&live_keys);
799799
}
800800
}
801801

@@ -1057,19 +1057,29 @@ impl UpstreamLoadBalancer {
10571057
"unsupported load balancer runtime state version",
10581058
));
10591059
}
1060-
self.backend_policy
1061-
.restore_runtime_snapshot(&snapshot.runtime_overrides)
1060+
let prepared_policy = self
1061+
.backend_policy
1062+
.prepare_runtime_snapshot(&snapshot.runtime_overrides)
10621063
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
10631064
let live_keys = self.live_backend_keys();
1064-
let persistence_entries = if let (Some(persistence), Some(snapshot)) =
1065+
let prepared_persistence = if let (Some(persistence), Some(snapshot)) =
10651066
(&self.persistence, &snapshot.persistence)
10661067
{
1067-
persistence
1068-
.restore_snapshot(snapshot, &live_keys)
1069-
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?
1068+
Some(
1069+
persistence
1070+
.prepare_snapshot(snapshot, &live_keys)
1071+
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?,
1072+
)
10701073
} else {
1071-
0
1074+
None
10721075
};
1076+
let persistence_entries = prepared_persistence
1077+
.as_ref()
1078+
.map_or(0, |snapshot| snapshot.restored_entries());
1079+
self.backend_policy.commit_runtime_snapshot(prepared_policy);
1080+
if let (Some(persistence), Some(snapshot)) = (&self.persistence, prepared_persistence) {
1081+
persistence.commit_snapshot(snapshot);
1082+
}
10731083
Ok(LoadBalancerRuntimeStateRestore {
10741084
persistence_entries,
10751085
})
@@ -1108,25 +1118,51 @@ impl UpstreamLoadBalancer {
11081118
let Some(path) = &self.runtime_state_file else {
11091119
return;
11101120
};
1121+
let _guard = self
1122+
.runtime_state_save_lock
1123+
.lock()
1124+
.unwrap_or_else(|poisoned| poisoned.into_inner());
11111125
let snapshot = self.runtime_state_snapshot();
1112-
match write_runtime_state_file(path, &snapshot) {
1113-
Ok(()) => log::debug!(
1114-
target: "fluxheim::load_balancer",
1115-
"load balancer runtime state saved path={} reason={}",
1116-
path.display(),
1117-
reason
1118-
),
1119-
Err(error) => log::warn!(
1120-
target: "fluxheim::security",
1121-
"load balancer runtime state save failed path={} reason={} error={}",
1122-
path.display(),
1123-
reason,
1124-
error
1125-
),
1126+
write_runtime_state_snapshot(path, &snapshot, reason);
1127+
}
1128+
1129+
fn save_runtime_state_if_configured_in_background(&self, reason: &'static str) {
1130+
if self.runtime_state_file.is_none() {
1131+
return;
1132+
}
1133+
let balancer = self.clone();
1134+
if let Ok(handle) = tokio::runtime::Handle::try_current() {
1135+
handle.spawn_blocking(move || {
1136+
balancer.save_runtime_state_if_configured(reason);
1137+
});
1138+
} else {
1139+
self.save_runtime_state_if_configured(reason);
11261140
}
11271141
}
11281142
}
11291143

1144+
fn write_runtime_state_snapshot(
1145+
path: &Path,
1146+
snapshot: &LoadBalancerRuntimeStateSnapshot,
1147+
reason: &str,
1148+
) {
1149+
match write_runtime_state_file(path, snapshot) {
1150+
Ok(()) => log::debug!(
1151+
target: "fluxheim::load_balancer",
1152+
"load balancer runtime state saved path={} reason={}",
1153+
path.display(),
1154+
reason
1155+
),
1156+
Err(error) => log::warn!(
1157+
target: "fluxheim::security",
1158+
"load balancer runtime state save failed path={} reason={} error={}",
1159+
path.display(),
1160+
reason,
1161+
error
1162+
),
1163+
}
1164+
}
1165+
11301166
fn backend_runtime_status_eligible(backend: &LoadBalancerBackendRuntimeStats) -> bool {
11311167
backend.ready
11321168
&& !backend.disabled
@@ -1914,6 +1950,64 @@ mod tests {
19141950
assert_eq!(selected.backend.addr.to_string(), "127.0.0.1:3000");
19151951
}
19161952

1953+
#[test]
1954+
fn runtime_state_file_restore_is_all_or_nothing() {
1955+
install_test_crypto_provider();
1956+
let dir = unique_temp_path("lb-runtime-state-atomic-restore");
1957+
std::fs::create_dir_all(&dir).unwrap();
1958+
let state_file = safe_child_path(&dir, "lb-state.json");
1959+
let disabled_key = backend_key(&FluxBackend::new("127.0.0.1:3001").unwrap());
1960+
std::fs::write(
1961+
&state_file,
1962+
format!(
1963+
r#"{{
1964+
"version": 1,
1965+
"runtime_overrides": {{
1966+
"states": [
1967+
{{
1968+
"key": {disabled_key},
1969+
"state": "disabled",
1970+
"changed_at_unix_secs": 1
1971+
}}
1972+
],
1973+
"weights": []
1974+
}},
1975+
"persistence": {{
1976+
"entries": [
1977+
{{
1978+
"key": [],
1979+
"backend_key": {disabled_key},
1980+
"ttl_remaining_secs": 60
1981+
}}
1982+
]
1983+
}}
1984+
}}"#
1985+
),
1986+
)
1987+
.unwrap();
1988+
let config = ProxyConfig {
1989+
upstreams: vec!["127.0.0.1:3000".to_owned(), "127.0.0.1:3001".to_owned()],
1990+
load_balance: LoadBalanceConfig {
1991+
runtime_state_file: Some(state_file),
1992+
persistence: LoadBalancePersistenceConfig {
1993+
enabled: true,
1994+
ttl_secs: 60,
1995+
table_max_entries: 16,
1996+
..LoadBalancePersistenceConfig::default()
1997+
},
1998+
..LoadBalanceConfig::default()
1999+
},
2000+
..ProxyConfig::default()
2001+
};
2002+
2003+
let balancer = UpstreamLoadBalancer::from_proxy_config(&config)
2004+
.unwrap()
2005+
.unwrap();
2006+
let stats = balancer.runtime_stats();
2007+
assert_eq!(stats.runtime_disabled_backend_count, 0);
2008+
assert_eq!(stats.persistence.entry_count, 0);
2009+
}
2010+
19172011
#[test]
19182012
fn header_persistence_reuses_selected_backend() {
19192013
install_test_crypto_provider();

src/load_balancer/persistence.rs

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,17 @@ pub(crate) struct LoadBalancerPersistenceSnapshot {
5555
pub(crate) entries: Vec<LoadBalancerPersistenceEntrySnapshot>,
5656
}
5757

58+
#[derive(Debug)]
59+
pub(super) struct PreparedLoadBalancerPersistenceSnapshot {
60+
entries: std::collections::HashMap<Vec<u8>, LoadBalancerPersistenceEntry>,
61+
}
62+
63+
impl PreparedLoadBalancerPersistenceSnapshot {
64+
pub(super) fn restored_entries(&self) -> usize {
65+
self.entries.len()
66+
}
67+
}
68+
5869
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
5970
pub(crate) struct LoadBalancerPersistenceEntrySnapshot {
6071
pub(crate) key: Vec<u8>,
@@ -236,11 +247,23 @@ impl LoadBalancerPersistenceState {
236247
LoadBalancerPersistenceSnapshot { entries }
237248
}
238249

250+
#[cfg(test)]
239251
pub(crate) fn restore_snapshot(
240252
&self,
241253
snapshot: &LoadBalancerPersistenceSnapshot,
242254
live_backend_keys: &std::collections::HashSet<u64>,
243255
) -> Result<usize, &'static str> {
256+
let prepared = self.prepare_snapshot(snapshot, live_backend_keys)?;
257+
let restored = prepared.restored_entries();
258+
self.commit_snapshot(prepared);
259+
Ok(restored)
260+
}
261+
262+
pub(super) fn prepare_snapshot(
263+
&self,
264+
snapshot: &LoadBalancerPersistenceSnapshot,
265+
live_backend_keys: &std::collections::HashSet<u64>,
266+
) -> Result<PreparedLoadBalancerPersistenceSnapshot, &'static str> {
244267
if snapshot.entries.len() > self.table_max_entries {
245268
return Err("load balancer persistence snapshot exceeds table limit");
246269
}
@@ -269,12 +292,14 @@ impl LoadBalancerPersistenceState {
269292
return Err("load balancer persistence snapshot has duplicate keys");
270293
}
271294
}
272-
let restored = next.len();
295+
Ok(PreparedLoadBalancerPersistenceSnapshot { entries: next })
296+
}
297+
298+
pub(super) fn commit_snapshot(&self, prepared: PreparedLoadBalancerPersistenceSnapshot) {
273299
*self
274300
.table
275301
.lock()
276-
.unwrap_or_else(|poisoned| poisoned.into_inner()) = next;
277-
Ok(restored)
302+
.unwrap_or_else(|poisoned| poisoned.into_inner()) = prepared.entries;
278303
}
279304

280305
pub(super) fn new_managed_cookie(&self) -> Option<(Vec<u8>, ManagedAffinityCookie)> {

0 commit comments

Comments
 (0)