1#![allow(dead_code)]
7
8use alloc::{collections::BTreeMap, string::String, vec::Vec};
9
10use super::l4::{Backend, LbAlgorithm};
11
12#[derive(Debug, Clone)]
18pub struct HttpRoute {
19 pub path_prefix: String,
21 pub host: String,
23 pub headers: BTreeMap<String, String>,
25 pub backend_group: String,
27}
28
29impl HttpRoute {
30 pub fn matches(&self, path: &str, host: &str, headers: &BTreeMap<String, String>) -> bool {
32 if !path.starts_with(&self.path_prefix) {
34 return false;
35 }
36 if !self.host.is_empty() && self.host != host {
38 return false;
39 }
40 for (key, value) in &self.headers {
42 match headers.get(key) {
43 Some(v) if v == value => {}
44 _ => return false,
45 }
46 }
47 true
48 }
49}
50
51#[derive(Debug, Clone)]
57pub struct BackendGroup {
58 pub name: String,
60 pub backends: Vec<Backend>,
62 pub algorithm: LbAlgorithm,
64 rr_counter: u64,
66}
67
68impl BackendGroup {
69 pub fn new(name: String, algorithm: LbAlgorithm) -> Self {
71 BackendGroup {
72 name,
73 backends: Vec::new(),
74 algorithm,
75 rr_counter: 0,
76 }
77 }
78
79 pub fn select(&mut self) -> Option<(String, u16)> {
81 let healthy: Vec<usize> = self
82 .backends
83 .iter()
84 .enumerate()
85 .filter(|(_, b)| b.healthy)
86 .map(|(i, _)| i)
87 .collect();
88
89 if healthy.is_empty() {
90 return None;
91 }
92
93 let idx = match self.algorithm {
94 LbAlgorithm::RoundRobin | LbAlgorithm::WeightedRoundRobin => {
95 let i = (self.rr_counter as usize) % healthy.len();
96 self.rr_counter += 1;
97 healthy[i]
98 }
99 LbAlgorithm::LeastConnections => {
100 let mut min_idx = healthy[0];
101 let mut min_conn = self.backends[healthy[0]].active_connections;
102 for &h in &healthy[1..] {
103 if self.backends[h].active_connections < min_conn {
104 min_conn = self.backends[h].active_connections;
105 min_idx = h;
106 }
107 }
108 min_idx
109 }
110 _ => {
111 let i = (self.rr_counter as usize) % healthy.len();
112 self.rr_counter += 1;
113 healthy[i]
114 }
115 };
116
117 self.backends[idx].active_connections += 1;
118 self.backends[idx].total_requests += 1;
119 Some((self.backends[idx].address.clone(), self.backends[idx].port))
120 }
121}
122
123#[derive(Debug, Clone)]
129pub struct L7Rule {
130 pub routes: Vec<HttpRoute>,
132 pub default_backend: String,
134}
135
136#[derive(Debug, Clone)]
142pub struct RateLimit {
143 pub requests_per_second: u32,
145 pub burst: u32,
147 pub current_tokens: u32,
149 pub last_refill_tick: u64,
151}
152
153impl RateLimit {
154 pub fn new(requests_per_second: u32, burst: u32) -> Self {
156 RateLimit {
157 requests_per_second,
158 burst,
159 current_tokens: burst,
160 last_refill_tick: 0,
161 }
162 }
163
164 pub fn allow(&mut self, current_tick: u64) -> bool {
166 self.refill(current_tick);
167 if self.current_tokens > 0 {
168 self.current_tokens -= 1;
169 true
170 } else {
171 false
172 }
173 }
174
175 fn refill(&mut self, current_tick: u64) {
177 if current_tick <= self.last_refill_tick {
178 return;
179 }
180 let elapsed = current_tick - self.last_refill_tick;
181 let new_tokens = elapsed.saturating_mul(self.requests_per_second as u64);
182 let clamped = if new_tokens > self.burst as u64 {
184 self.burst
185 } else {
186 new_tokens as u32
187 };
188 self.current_tokens = self.current_tokens.saturating_add(clamped).min(self.burst);
189 self.last_refill_tick = current_tick;
190 }
191}
192
193#[derive(Debug, Clone)]
199pub struct StickySession {
200 pub cookie_name: String,
202 pub backend_map: BTreeMap<String, usize>,
204}
205
206impl StickySession {
207 pub fn new(cookie_name: String) -> Self {
209 StickySession {
210 cookie_name,
211 backend_map: BTreeMap::new(),
212 }
213 }
214
215 pub fn get_backend(&self, session_id: &str) -> Option<usize> {
217 self.backend_map.get(session_id).copied()
218 }
219
220 pub fn set_backend(&mut self, session_id: String, backend_idx: usize) {
222 self.backend_map.insert(session_id, backend_idx);
223 }
224
225 pub fn remove_session(&mut self, session_id: &str) {
227 self.backend_map.remove(session_id);
228 }
229
230 pub fn session_count(&self) -> usize {
232 self.backend_map.len()
233 }
234}
235
236#[derive(Debug, Clone, PartialEq, Eq)]
242pub enum L7Error {
243 NoRouteMatch,
245 BackendGroupNotFound(String),
247 NoHealthyBackend,
249 RateLimitExceeded,
251}
252
253#[derive(Debug)]
259pub struct L7LoadBalancer {
260 rules: Vec<L7Rule>,
262 backend_groups: BTreeMap<String, BackendGroup>,
264 rate_limiters: BTreeMap<String, RateLimit>,
266 sticky_sessions: StickySession,
268 default_rps: u32,
270 default_burst: u32,
272}
273
274impl Default for L7LoadBalancer {
275 fn default() -> Self {
276 Self::new()
277 }
278}
279
280impl L7LoadBalancer {
281 pub fn new() -> Self {
283 L7LoadBalancer {
284 rules: Vec::new(),
285 backend_groups: BTreeMap::new(),
286 rate_limiters: BTreeMap::new(),
287 sticky_sessions: StickySession::new(String::from("VERIDIAN_SESSION")),
288 default_rps: 100,
289 default_burst: 200,
290 }
291 }
292
293 pub fn add_rule(&mut self, rule: L7Rule) {
295 self.rules.push(rule);
296 }
297
298 pub fn add_backend_group(&mut self, group: BackendGroup) {
300 self.backend_groups.insert(group.name.clone(), group);
301 }
302
303 pub fn route_request(
305 &mut self,
306 path: &str,
307 host: &str,
308 headers: &BTreeMap<String, String>,
309 ) -> Result<(String, u16), L7Error> {
310 let mut target_group = None;
312
313 for rule in &self.rules {
314 for route in &rule.routes {
315 if route.matches(path, host, headers) {
316 target_group = Some(route.backend_group.clone());
317 break;
318 }
319 }
320 if target_group.is_some() {
321 break;
322 }
323 if target_group.is_none() && !rule.default_backend.is_empty() {
325 target_group = Some(rule.default_backend.clone());
326 }
327 }
328
329 let group_name = target_group.ok_or(L7Error::NoRouteMatch)?;
330
331 let group = self
332 .backend_groups
333 .get_mut(&group_name)
334 .ok_or_else(|| L7Error::BackendGroupNotFound(group_name.clone()))?;
335
336 group.select().ok_or(L7Error::NoHealthyBackend)
337 }
338
339 pub fn check_rate_limit(&mut self, client_id: &str, current_tick: u64) -> Result<(), L7Error> {
341 let limiter = self
342 .rate_limiters
343 .entry(String::from(client_id))
344 .or_insert_with(|| RateLimit::new(self.default_rps, self.default_burst));
345
346 if limiter.allow(current_tick) {
347 Ok(())
348 } else {
349 Err(L7Error::RateLimitExceeded)
350 }
351 }
352
353 pub fn get_sticky_backend(&self, session_id: &str) -> Option<usize> {
355 self.sticky_sessions.get_backend(session_id)
356 }
357
358 pub fn set_sticky_backend(&mut self, session_id: String, backend_idx: usize) {
360 self.sticky_sessions.set_backend(session_id, backend_idx);
361 }
362
363 pub fn backend_group_count(&self) -> usize {
365 self.backend_groups.len()
366 }
367
368 pub fn rule_count(&self) -> usize {
370 self.rules.len()
371 }
372}
373
374#[cfg(test)]
379mod tests {
380 #[allow(unused_imports)]
381 use alloc::string::ToString;
382 #[allow(unused_imports)]
383 use alloc::vec;
384
385 use super::*;
386
387 fn make_lb() -> L7LoadBalancer {
388 let mut lb = L7LoadBalancer::new();
389
390 let mut group = BackendGroup::new(String::from("api-backends"), LbAlgorithm::RoundRobin);
391 group
392 .backends
393 .push(Backend::new(String::from("10.0.0.1"), 8080, 1));
394 group
395 .backends
396 .push(Backend::new(String::from("10.0.0.2"), 8080, 1));
397 lb.add_backend_group(group);
398
399 let mut default_group =
400 BackendGroup::new(String::from("default-backends"), LbAlgorithm::RoundRobin);
401 default_group
402 .backends
403 .push(Backend::new(String::from("10.0.1.1"), 80, 1));
404 lb.add_backend_group(default_group);
405
406 lb.add_rule(L7Rule {
407 routes: vec![HttpRoute {
408 path_prefix: String::from("/api/"),
409 host: String::new(),
410 headers: BTreeMap::new(),
411 backend_group: String::from("api-backends"),
412 }],
413 default_backend: String::from("default-backends"),
414 });
415
416 lb
417 }
418
419 #[test]
420 fn test_route_by_path() {
421 let mut lb = make_lb();
422 let headers = BTreeMap::new();
423 let (addr, port) = lb
424 .route_request("/api/v1/pods", "example.com", &headers)
425 .unwrap();
426 assert_eq!(port, 8080);
427 assert!(addr.starts_with("10.0.0."));
428 }
429
430 #[test]
431 fn test_route_default_backend() {
432 let mut lb = make_lb();
433 let headers = BTreeMap::new();
434 let (addr, port) = lb
435 .route_request("/other/page", "example.com", &headers)
436 .unwrap();
437 assert_eq!(addr, "10.0.1.1");
438 assert_eq!(port, 80);
439 }
440
441 #[test]
442 fn test_route_with_host_match() {
443 let mut lb = L7LoadBalancer::new();
444 let mut group = BackendGroup::new(String::from("host-group"), LbAlgorithm::RoundRobin);
445 group
446 .backends
447 .push(Backend::new(String::from("10.0.0.5"), 443, 1));
448 lb.add_backend_group(group);
449
450 lb.add_rule(L7Rule {
451 routes: vec![HttpRoute {
452 path_prefix: String::from("/"),
453 host: String::from("api.example.com"),
454 headers: BTreeMap::new(),
455 backend_group: String::from("host-group"),
456 }],
457 default_backend: String::new(),
458 });
459
460 let headers = BTreeMap::new();
461 assert!(lb
462 .route_request("/", "other.example.com", &headers)
463 .is_err());
464 let result = lb.route_request("/", "api.example.com", &headers);
465 assert!(result.is_ok());
466 }
467
468 #[test]
469 fn test_rate_limit_allow() {
470 let mut lb = make_lb();
471 assert!(lb.check_rate_limit("client-1", 100).is_ok());
472 }
473
474 #[test]
475 fn test_rate_limit_exceeded() {
476 let mut lb = L7LoadBalancer::new();
477 lb.default_rps = 1;
478 lb.default_burst = 1;
479
480 lb.check_rate_limit("client-1", 100).unwrap();
481 assert_eq!(
482 lb.check_rate_limit("client-1", 100),
483 Err(L7Error::RateLimitExceeded)
484 );
485 }
486
487 #[test]
488 fn test_rate_limit_refill() {
489 let mut lb = L7LoadBalancer::new();
490 lb.default_rps = 1;
491 lb.default_burst = 2;
492
493 lb.check_rate_limit("c1", 100).unwrap();
494 lb.check_rate_limit("c1", 100).unwrap();
495 assert!(lb.check_rate_limit("c1", 100).is_err());
496 assert!(lb.check_rate_limit("c1", 101).is_ok());
498 }
499
500 #[test]
501 fn test_sticky_session() {
502 let mut lb = make_lb();
503 lb.set_sticky_backend(String::from("sess-123"), 0);
504 assert_eq!(lb.get_sticky_backend("sess-123"), Some(0));
505 assert_eq!(lb.get_sticky_backend("sess-999"), None);
506 }
507
508 #[test]
509 fn test_http_route_matching() {
510 let route = HttpRoute {
511 path_prefix: String::from("/api/"),
512 host: String::from("example.com"),
513 headers: BTreeMap::new(),
514 backend_group: String::from("test"),
515 };
516 let empty_headers = BTreeMap::new();
517 assert!(route.matches("/api/v1", "example.com", &empty_headers));
518 assert!(!route.matches("/web/", "example.com", &empty_headers));
519 assert!(!route.matches("/api/v1", "other.com", &empty_headers));
520 }
521
522 #[test]
523 fn test_http_route_header_matching() {
524 let mut required = BTreeMap::new();
525 required.insert(String::from("x-version"), String::from("v2"));
526 let route = HttpRoute {
527 path_prefix: String::from("/"),
528 host: String::new(),
529 headers: required,
530 backend_group: String::from("test"),
531 };
532
533 let mut headers = BTreeMap::new();
534 headers.insert(String::from("x-version"), String::from("v2"));
535 assert!(route.matches("/", "", &headers));
536
537 let empty_headers = BTreeMap::new();
538 assert!(!route.matches("/", "", &empty_headers));
539 }
540
541 #[test]
542 fn test_backend_group_select() {
543 let mut group = BackendGroup::new(String::from("test"), LbAlgorithm::RoundRobin);
544 group
545 .backends
546 .push(Backend::new(String::from("10.0.0.1"), 80, 1));
547 let result = group.select();
548 assert!(result.is_some());
549 }
550
551 #[test]
552 fn test_no_route_match() {
553 let mut lb = L7LoadBalancer::new();
554 let headers = BTreeMap::new();
555 assert_eq!(
556 lb.route_request("/", "", &headers),
557 Err(L7Error::NoRouteMatch)
558 );
559 }
560}