1use std::collections::HashMap;
8use std::sync::Arc;
9
10use async_trait::async_trait;
11use hmac::{Hmac, KeyInit, Mac};
12use sha2::Sha256;
13use tokio::sync::RwLock;
14use tracing::{debug, trace, warn};
15
16use super::{LoadBalancer, RequestContext, TargetSelection, UpstreamTarget};
17use zentinel_common::errors::{ZentinelError, ZentinelResult};
18use zentinel_config::upstreams::StickySessionConfig;
19
20type HmacSha256 = Hmac<Sha256>;
21
22#[derive(Debug, Clone)]
24pub struct StickySessionRuntimeConfig {
25 pub cookie_name: String,
27 pub cookie_ttl_secs: u64,
29 pub cookie_path: String,
31 pub cookie_secure: bool,
33 pub cookie_same_site: zentinel_config::upstreams::SameSitePolicy,
35 pub hmac_key: [u8; 32],
37}
38
39impl StickySessionRuntimeConfig {
40 pub fn from_config(config: &StickySessionConfig) -> Self {
42 use rand::Rng;
43
44 let mut hmac_key = [0u8; 32];
46 rand::rng().fill_bytes(&mut hmac_key);
47
48 Self {
49 cookie_name: config.cookie_name.clone(),
50 cookie_ttl_secs: config.cookie_ttl_secs,
51 cookie_path: config.cookie_path.clone(),
52 cookie_secure: config.cookie_secure,
53 cookie_same_site: config.cookie_same_site,
54 hmac_key,
55 }
56 }
57}
58
59pub struct StickySessionBalancer {
66 config: StickySessionRuntimeConfig,
68 targets: Vec<UpstreamTarget>,
70 fallback: Arc<dyn LoadBalancer>,
72 health_status: Arc<RwLock<HashMap<String, bool>>>,
74}
75
76impl StickySessionBalancer {
77 pub fn new(
79 targets: Vec<UpstreamTarget>,
80 config: StickySessionRuntimeConfig,
81 fallback: Arc<dyn LoadBalancer>,
82 ) -> Self {
83 trace!(
84 target_count = targets.len(),
85 cookie_name = %config.cookie_name,
86 cookie_ttl_secs = config.cookie_ttl_secs,
87 "Creating sticky session balancer"
88 );
89
90 let mut health_status = HashMap::new();
91 for target in &targets {
92 health_status.insert(target.full_address(), true);
93 }
94
95 Self {
96 config,
97 targets,
98 fallback,
99 health_status: Arc::new(RwLock::new(health_status)),
100 }
101 }
102
103 fn extract_affinity(&self, context: &RequestContext) -> Option<usize> {
107 let cookie_header = context.headers.get("cookie")?;
109
110 let cookie_value = cookie_header.split(';').find_map(|cookie| {
112 let parts: Vec<&str> = cookie.trim().splitn(2, '=').collect();
113 if parts.len() == 2 && parts[0] == self.config.cookie_name {
114 Some(parts[1].to_string())
115 } else {
116 None
117 }
118 })?;
119
120 let parts: Vec<&str> = cookie_value.splitn(2, '.').collect();
122 if parts.len() != 2 {
123 trace!(
124 cookie_value = %cookie_value,
125 "Invalid sticky cookie format (missing signature)"
126 );
127 return None;
128 }
129
130 let index: usize = parts[0].parse().ok()?;
131 let signature = parts[1];
132
133 if !self.verify_signature(index, signature) {
135 warn!(
136 cookie_value = %cookie_value,
137 "Invalid sticky cookie signature (possible tampering)"
138 );
139 return None;
140 }
141
142 if index >= self.targets.len() {
144 trace!(
145 index = index,
146 target_count = self.targets.len(),
147 "Sticky cookie index out of bounds"
148 );
149 return None;
150 }
151
152 trace!(
153 cookie_name = %self.config.cookie_name,
154 target_index = index,
155 "Extracted valid sticky session affinity"
156 );
157
158 Some(index)
159 }
160
161 pub fn generate_cookie_value(&self, target_index: usize) -> String {
163 let signature = self.sign_index(target_index);
164 format!("{}.{}", target_index, signature)
165 }
166
167 pub fn generate_set_cookie_header(&self, target_index: usize) -> String {
169 let cookie_value = self.generate_cookie_value(target_index);
170
171 let mut header = format!(
172 "{}={}; Path={}; Max-Age={}",
173 self.config.cookie_name,
174 cookie_value,
175 self.config.cookie_path,
176 self.config.cookie_ttl_secs
177 );
178
179 if self.config.cookie_secure {
180 header.push_str("; HttpOnly; Secure");
181 }
182
183 header.push_str(&format!("; SameSite={}", self.config.cookie_same_site));
184
185 header
186 }
187
188 fn sign_index(&self, index: usize) -> String {
190 let mut mac =
191 HmacSha256::new_from_slice(&self.config.hmac_key).expect("HMAC key length is valid");
192 mac.update(index.to_string().as_bytes());
193 let result = mac.finalize();
194 hex::encode(&result.into_bytes()[..8])
196 }
197
198 fn verify_signature(&self, index: usize, signature: &str) -> bool {
200 let expected = self.sign_index(index);
201 expected == signature
203 }
204
205 async fn is_target_healthy(&self, index: usize) -> bool {
207 if index >= self.targets.len() {
208 return false;
209 }
210
211 let target = &self.targets[index];
212 let health = self.health_status.read().await;
213 *health.get(&target.full_address()).unwrap_or(&true)
214 }
215
216 fn find_target_index(&self, address: &str) -> Option<usize> {
218 self.targets
219 .iter()
220 .position(|t| t.full_address() == address)
221 }
222
223 pub fn cookie_name(&self) -> &str {
225 &self.config.cookie_name
226 }
227
228 pub fn config(&self) -> &StickySessionRuntimeConfig {
230 &self.config
231 }
232}
233
234#[async_trait]
235impl LoadBalancer for StickySessionBalancer {
236 fn session_signing_key(&self) -> Option<[u8; 32]> {
237 Some(self.config.hmac_key)
238 }
239
240 async fn select(&self, context: Option<&RequestContext>) -> ZentinelResult<TargetSelection> {
241 trace!(
242 has_context = context.is_some(),
243 cookie_name = %self.config.cookie_name,
244 "Sticky session select called"
245 );
246
247 if let Some(ctx) = context {
249 if let Some(target_index) = self.extract_affinity(ctx) {
250 if self.is_target_healthy(target_index).await {
252 let target = &self.targets[target_index];
253
254 debug!(
255 target = %target.full_address(),
256 target_index = target_index,
257 cookie_name = %self.config.cookie_name,
258 "Sticky session hit - routing to affinity target"
259 );
260
261 return Ok(TargetSelection {
262 address: target.full_address(),
263 weight: target.weight,
264 metadata: {
265 let mut meta = HashMap::new();
266 meta.insert("sticky_session_hit".to_string(), "true".to_string());
267 meta.insert(
268 "sticky_target_index".to_string(),
269 target_index.to_string(),
270 );
271 meta.insert("algorithm".to_string(), "sticky_session".to_string());
272 meta
273 },
274 });
275 }
276
277 debug!(
278 target_index = target_index,
279 cookie_name = %self.config.cookie_name,
280 "Sticky target unhealthy, falling back to load balancer"
281 );
282 }
283 }
284
285 let mut selection = self.fallback.select(context).await?;
287
288 let target_index = self.find_target_index(&selection.address);
290
291 if let Some(index) = target_index {
292 selection
294 .metadata
295 .insert("sticky_session_new".to_string(), "true".to_string());
296 selection
297 .metadata
298 .insert("sticky_target_index".to_string(), index.to_string());
299 selection.metadata.insert(
300 "sticky_cookie_value".to_string(),
301 self.generate_cookie_value(index),
302 );
303 selection.metadata.insert(
304 "sticky_set_cookie_header".to_string(),
305 self.generate_set_cookie_header(index),
306 );
307
308 debug!(
309 target = %selection.address,
310 target_index = index,
311 cookie_name = %self.config.cookie_name,
312 "New sticky session assignment, will set cookie"
313 );
314 }
315
316 selection
317 .metadata
318 .insert("algorithm".to_string(), "sticky_session".to_string());
319
320 Ok(selection)
321 }
322
323 async fn report_health(&self, address: &str, healthy: bool) {
324 trace!(
325 target = %address,
326 healthy = healthy,
327 algorithm = "sticky_session",
328 "Updating target health status"
329 );
330
331 self.health_status
333 .write()
334 .await
335 .insert(address.to_string(), healthy);
336
337 self.fallback.report_health(address, healthy).await;
339 }
340
341 async fn healthy_targets(&self) -> Vec<String> {
342 self.fallback.healthy_targets().await
344 }
345
346 async fn release(&self, selection: &TargetSelection) {
347 self.fallback.release(selection).await;
349 }
350
351 async fn report_result(
352 &self,
353 selection: &TargetSelection,
354 success: bool,
355 latency: Option<std::time::Duration>,
356 ) {
357 self.fallback
359 .report_result(selection, success, latency)
360 .await;
361 }
362
363 async fn report_result_with_latency(
364 &self,
365 address: &str,
366 success: bool,
367 latency: Option<std::time::Duration>,
368 ) {
369 self.fallback
371 .report_result_with_latency(address, success, latency)
372 .await;
373 }
374}
375
376#[cfg(test)]
377mod tests {
378 use super::*;
379
380 fn create_test_targets(count: usize) -> Vec<UpstreamTarget> {
381 (0..count)
382 .map(|i| UpstreamTarget {
383 address: format!("10.0.0.{}", i + 1),
384 port: 8080,
385 weight: 100,
386 })
387 .collect()
388 }
389
390 fn create_test_config() -> StickySessionRuntimeConfig {
391 StickySessionRuntimeConfig {
392 cookie_name: "SERVERID".to_string(),
393 cookie_ttl_secs: 3600,
394 cookie_path: "/".to_string(),
395 cookie_secure: true,
396 cookie_same_site: zentinel_config::upstreams::SameSitePolicy::Lax,
397 hmac_key: [42u8; 32], }
399 }
400
401 #[test]
402 fn test_cookie_generation_and_validation() {
403 let targets = create_test_targets(3);
404 let config = create_test_config();
405
406 struct MockBalancer;
408
409 #[async_trait]
410 impl LoadBalancer for MockBalancer {
411 async fn select(
412 &self,
413 _context: Option<&RequestContext>,
414 ) -> ZentinelResult<TargetSelection> {
415 Ok(TargetSelection {
416 address: "10.0.0.1:8080".to_string(),
417 weight: 100,
418 metadata: HashMap::new(),
419 })
420 }
421 async fn report_health(&self, _address: &str, _healthy: bool) {}
422 async fn healthy_targets(&self) -> Vec<String> {
423 vec![]
424 }
425 }
426
427 let balancer = StickySessionBalancer::new(targets, config, Arc::new(MockBalancer));
428
429 let cookie_value = balancer.generate_cookie_value(1);
431 assert!(cookie_value.starts_with("1."));
432 assert_eq!(cookie_value.len(), 2 + 16); let parts: Vec<&str> = cookie_value.splitn(2, '.').collect();
436 assert!(balancer.verify_signature(1, parts[1]));
437
438 assert!(!balancer.verify_signature(1, "invalid"));
440 assert!(!balancer.verify_signature(2, parts[1])); }
442
443 #[test]
444 fn test_set_cookie_header_generation() {
445 let targets = create_test_targets(3);
446 let config = create_test_config();
447
448 struct MockBalancer;
449
450 #[async_trait]
451 impl LoadBalancer for MockBalancer {
452 async fn select(
453 &self,
454 _context: Option<&RequestContext>,
455 ) -> ZentinelResult<TargetSelection> {
456 unreachable!()
457 }
458 async fn report_health(&self, _address: &str, _healthy: bool) {}
459 async fn healthy_targets(&self) -> Vec<String> {
460 vec![]
461 }
462 }
463
464 let balancer = StickySessionBalancer::new(targets, config, Arc::new(MockBalancer));
465
466 let header = balancer.generate_set_cookie_header(0);
467 assert!(header.starts_with("SERVERID=0."));
468 assert!(header.contains("Path=/"));
469 assert!(header.contains("Max-Age=3600"));
470 assert!(header.contains("HttpOnly"));
471 assert!(header.contains("Secure"));
472 assert!(header.contains("SameSite=Lax"));
473 }
474
475 #[tokio::test]
476 async fn test_sticky_session_hit() {
477 let targets = create_test_targets(3);
478 let config = create_test_config();
479
480 struct MockBalancer;
481
482 #[async_trait]
483 impl LoadBalancer for MockBalancer {
484 async fn select(
485 &self,
486 _context: Option<&RequestContext>,
487 ) -> ZentinelResult<TargetSelection> {
488 panic!("Fallback should not be called for sticky hit");
490 }
491 async fn report_health(&self, _address: &str, _healthy: bool) {}
492 async fn healthy_targets(&self) -> Vec<String> {
493 vec![
494 "10.0.0.1:8080".to_string(),
495 "10.0.0.2:8080".to_string(),
496 "10.0.0.3:8080".to_string(),
497 ]
498 }
499 }
500
501 let balancer = StickySessionBalancer::new(targets, config, Arc::new(MockBalancer));
502
503 let cookie_value = balancer.generate_cookie_value(1);
505
506 let mut headers = HashMap::new();
508 headers.insert("cookie".to_string(), format!("SERVERID={}", cookie_value));
509
510 let context = RequestContext {
511 client_ip: None,
512 headers,
513 path: "/".to_string(),
514 method: "GET".to_string(),
515 };
516
517 let selection = balancer.select(Some(&context)).await.unwrap();
518
519 assert_eq!(selection.address, "10.0.0.2:8080");
521 assert_eq!(
522 selection.metadata.get("sticky_session_hit"),
523 Some(&"true".to_string())
524 );
525 assert_eq!(
526 selection.metadata.get("sticky_target_index"),
527 Some(&"1".to_string())
528 );
529 }
530
531 #[tokio::test]
532 async fn test_sticky_session_miss_sets_cookie() {
533 let targets = create_test_targets(3);
534 let config = create_test_config();
535
536 struct MockBalancer;
537
538 #[async_trait]
539 impl LoadBalancer for MockBalancer {
540 async fn select(
541 &self,
542 _context: Option<&RequestContext>,
543 ) -> ZentinelResult<TargetSelection> {
544 Ok(TargetSelection {
545 address: "10.0.0.2:8080".to_string(),
546 weight: 100,
547 metadata: HashMap::new(),
548 })
549 }
550 async fn report_health(&self, _address: &str, _healthy: bool) {}
551 async fn healthy_targets(&self) -> Vec<String> {
552 vec!["10.0.0.2:8080".to_string()]
553 }
554 }
555
556 let balancer = StickySessionBalancer::new(targets, config, Arc::new(MockBalancer));
557
558 let context = RequestContext {
560 client_ip: None,
561 headers: HashMap::new(),
562 path: "/".to_string(),
563 method: "GET".to_string(),
564 };
565
566 let selection = balancer.select(Some(&context)).await.unwrap();
567
568 assert_eq!(selection.address, "10.0.0.2:8080");
570 assert_eq!(
571 selection.metadata.get("sticky_session_new"),
572 Some(&"true".to_string())
573 );
574 assert!(selection.metadata.contains_key("sticky_cookie_value"));
575 assert!(selection.metadata.contains_key("sticky_set_cookie_header"));
576 }
577
578 #[tokio::test]
579 async fn test_unhealthy_target_falls_back() {
580 let targets = create_test_targets(3);
581 let config = create_test_config();
582
583 struct MockBalancer;
584
585 #[async_trait]
586 impl LoadBalancer for MockBalancer {
587 async fn select(
588 &self,
589 _context: Option<&RequestContext>,
590 ) -> ZentinelResult<TargetSelection> {
591 Ok(TargetSelection {
592 address: "10.0.0.3:8080".to_string(), weight: 100,
594 metadata: HashMap::new(),
595 })
596 }
597 async fn report_health(&self, _address: &str, _healthy: bool) {}
598 async fn healthy_targets(&self) -> Vec<String> {
599 vec!["10.0.0.3:8080".to_string()]
600 }
601 }
602
603 let balancer = StickySessionBalancer::new(targets, config, Arc::new(MockBalancer));
604
605 balancer.report_health("10.0.0.2:8080", false).await;
607
608 let cookie_value = balancer.generate_cookie_value(1);
610
611 let mut headers = HashMap::new();
612 headers.insert("cookie".to_string(), format!("SERVERID={}", cookie_value));
613
614 let context = RequestContext {
615 client_ip: None,
616 headers,
617 path: "/".to_string(),
618 method: "GET".to_string(),
619 };
620
621 let selection = balancer.select(Some(&context)).await.unwrap();
622
623 assert_eq!(selection.address, "10.0.0.3:8080");
625 assert_eq!(
626 selection.metadata.get("sticky_session_new"),
627 Some(&"true".to_string())
628 );
629 }
630}