Skip to main content

zentinel_proxy/upstream/
sticky_session.rs

1//! Cookie-based sticky session load balancer
2//!
3//! Routes requests to the same backend based on an affinity cookie.
4//! Falls back to a configurable algorithm when no cookie is present
5//! or the target is unavailable.
6
7use 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/// Runtime configuration for sticky sessions
23#[derive(Debug, Clone)]
24pub struct StickySessionRuntimeConfig {
25    /// Cookie name for session affinity
26    pub cookie_name: String,
27    /// Cookie TTL in seconds
28    pub cookie_ttl_secs: u64,
29    /// Cookie path
30    pub cookie_path: String,
31    /// Whether to set Secure and HttpOnly flags
32    pub cookie_secure: bool,
33    /// SameSite policy
34    pub cookie_same_site: zentinel_config::upstreams::SameSitePolicy,
35    /// HMAC key for signing cookie values
36    pub hmac_key: [u8; 32],
37}
38
39impl StickySessionRuntimeConfig {
40    /// Create runtime config from parsed config, generating HMAC key
41    pub fn from_config(config: &StickySessionConfig) -> Self {
42        use rand::Rng;
43
44        // Generate random HMAC key
45        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
59/// Cookie-based sticky session load balancer
60///
61/// This balancer wraps a fallback load balancer and adds session affinity
62/// based on cookies. When a client has a valid affinity cookie, requests
63/// are routed to the same backend. Otherwise, the fallback balancer is used
64/// and a new cookie is set.
65pub struct StickySessionBalancer {
66    /// Runtime configuration
67    config: StickySessionRuntimeConfig,
68    /// All upstream targets
69    targets: Vec<UpstreamTarget>,
70    /// Fallback load balancer
71    fallback: Arc<dyn LoadBalancer>,
72    /// Target health status
73    health_status: Arc<RwLock<HashMap<String, bool>>>,
74}
75
76impl StickySessionBalancer {
77    /// Create a new sticky session balancer
78    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    /// Extract and validate sticky cookie from request
104    ///
105    /// Returns the target index if the cookie is valid and properly signed.
106    fn extract_affinity(&self, context: &RequestContext) -> Option<usize> {
107        // Get cookie header
108        let cookie_header = context.headers.get("cookie")?;
109
110        // Parse cookies and find our sticky session cookie
111        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        // Validate cookie format: "{index}.{signature}"
121        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        // Verify HMAC signature
134        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        // Verify index is valid
143        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    /// Generate signed cookie value for target
162    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    /// Generate full Set-Cookie header value
168    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    /// Sign target index with HMAC-SHA256
189    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        // Use first 8 bytes of signature (16 hex chars) for compactness
195        hex::encode(&result.into_bytes()[..8])
196    }
197
198    /// Verify HMAC signature for target index
199    fn verify_signature(&self, index: usize, signature: &str) -> bool {
200        let expected = self.sign_index(index);
201        // Constant-time comparison
202        expected == signature
203    }
204
205    /// Check if target at index is healthy
206    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    /// Find target index by address
217    fn find_target_index(&self, address: &str) -> Option<usize> {
218        self.targets
219            .iter()
220            .position(|t| t.full_address() == address)
221    }
222
223    /// Get the cookie name
224    pub fn cookie_name(&self) -> &str {
225        &self.config.cookie_name
226    }
227
228    /// Get the config for Set-Cookie header generation
229    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        // Try to extract affinity from cookie
248        if let Some(ctx) = context {
249            if let Some(target_index) = self.extract_affinity(ctx) {
250                // Check if target is healthy
251                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        // No valid cookie or target unavailable - use fallback
286        let mut selection = self.fallback.select(context).await?;
287
288        // Find target index for the selected address
289        let target_index = self.find_target_index(&selection.address);
290
291        if let Some(index) = target_index {
292            // Mark that we need to set a new cookie
293            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        // Update local health status
332        self.health_status
333            .write()
334            .await
335            .insert(address.to_string(), healthy);
336
337        // Propagate to fallback balancer
338        self.fallback.report_health(address, healthy).await;
339    }
340
341    async fn healthy_targets(&self) -> Vec<String> {
342        // Delegate to fallback balancer for consistency
343        self.fallback.healthy_targets().await
344    }
345
346    async fn release(&self, selection: &TargetSelection) {
347        // Delegate to fallback balancer
348        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        // Delegate to fallback balancer
358        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        // Delegate to fallback balancer
370        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], // Fixed key for testing
398        }
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        // Create a mock fallback balancer
407        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        // Test cookie value generation
430        let cookie_value = balancer.generate_cookie_value(1);
431        assert!(cookie_value.starts_with("1."));
432        assert_eq!(cookie_value.len(), 2 + 16); // "1." + 16 hex chars
433
434        // Test signature verification
435        let parts: Vec<&str> = cookie_value.splitn(2, '.').collect();
436        assert!(balancer.verify_signature(1, parts[1]));
437
438        // Test invalid signature
439        assert!(!balancer.verify_signature(1, "invalid"));
440        assert!(!balancer.verify_signature(2, parts[1])); // Wrong index
441    }
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                // Should not be called when we have valid cookie
489                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        // Generate a valid cookie for target 1
504        let cookie_value = balancer.generate_cookie_value(1);
505
506        // Create context with sticky cookie
507        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        // Should route to target 1 (10.0.0.2:8080)
520        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        // Create context without sticky cookie
559        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        // Should use fallback and mark for cookie setting
569        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(), // Different target
593                    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        // Mark target 1 as unhealthy
606        balancer.report_health("10.0.0.2:8080", false).await;
607
608        // Generate cookie for unhealthy target 1
609        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        // Should fall back to another target and set new cookie
624        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}