photoncloud-monorepo/fiberlb/crates/fiberlb-server/src/l7_router.rs
centra 3eeb303dcb feat: Batch commit for T039.S3 deployment
Includes all pending changes needed for nixos-anywhere:
- fiberlb: L7 policy, rule, certificate types
- deployer: New service for cluster management
- nix-nos: Generic network modules
- Various service updates and fixes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-13 04:34:51 +09:00

223 lines
7.1 KiB
Rust

//! L7 Routing Engine
//!
//! Evaluates L7 policies and rules to determine request routing.
use axum::extract::Request;
use axum::http::{HeaderMap, Uri};
use std::sync::Arc;
use crate::metadata::LbMetadataStore;
use fiberlb_types::{
L7CompareType, L7Policy, L7PolicyAction, L7Rule, L7RuleType, ListenerId, PoolId,
};
/// Request information extracted for routing (Send + Sync safe)
#[derive(Debug, Clone)]
pub struct RequestInfo {
pub headers: HeaderMap,
pub uri: Uri,
pub sni_hostname: Option<String>,
}
impl RequestInfo {
/// Extract routing info from request
pub fn from_request(request: &Request) -> Self {
Self {
headers: request.headers().clone(),
uri: request.uri().clone(),
sni_hostname: request.extensions().get::<SniHostname>().map(|s| s.0.clone()),
}
}
}
/// Routing decision result
#[derive(Debug, Clone)]
pub enum RoutingResult {
/// Route to a specific pool
Pool(PoolId),
/// HTTP redirect to URL
Redirect { url: String, status: u32 },
/// Reject with status code
Reject { status: u32 },
/// Use default pool (no policy matched)
Default,
}
/// L7 routing engine
pub struct L7Router {
metadata: Arc<LbMetadataStore>,
}
impl L7Router {
/// Create a new L7 router
pub fn new(metadata: Arc<LbMetadataStore>) -> Self {
Self { metadata }
}
/// Evaluate policies for a request
pub async fn evaluate(
&self,
listener_id: &ListenerId,
request_info: &RequestInfo,
) -> RoutingResult {
// Load policies ordered by position
let policies = match self.metadata.list_l7_policies(listener_id).await {
Ok(p) => p,
Err(e) => {
tracing::warn!("Failed to load L7 policies: {}", e);
return RoutingResult::Default;
}
};
// Iterate through policies in order
for policy in policies.iter().filter(|p| p.enabled) {
// Load rules for this policy
let rules = match self.metadata.list_l7_rules(&policy.id).await {
Ok(r) => r,
Err(e) => {
tracing::warn!("Failed to load L7 rules for policy {}: {}", policy.id, e);
continue;
}
};
// All rules must match (AND logic)
let all_match = rules.iter().all(|rule| self.evaluate_rule(rule, request_info));
if all_match {
return self.apply_policy_action(policy);
}
}
RoutingResult::Default
}
/// Evaluate a single rule
fn evaluate_rule(&self, rule: &L7Rule, info: &RequestInfo) -> bool {
let value = match rule.rule_type {
L7RuleType::HostName => {
// Extract from Host header
info.headers
.get("host")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
}
L7RuleType::Path => {
// Extract from request URI
Some(info.uri.path().to_string())
}
L7RuleType::FileType => {
// Extract file extension from path
info.uri
.path()
.rsplit('.')
.next()
.filter(|ext| !ext.is_empty() && !ext.contains('/'))
.map(|s| format!(".{}", s))
}
L7RuleType::Header => {
// Extract specific header by key
rule.key.as_ref().and_then(|key| {
info.headers
.get(key)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
})
}
L7RuleType::Cookie => {
// Extract cookie value by key
self.extract_cookie(info, rule.key.as_deref())
}
L7RuleType::SslConnHasSni => {
// SNI extracted during TLS handshake (Phase 3)
info.sni_hostname.clone()
}
};
let matched = match value {
Some(v) => self.compare(&v, &rule.value, rule.compare_type),
None => false,
};
// Apply invert logic
if rule.invert {
!matched
} else {
matched
}
}
/// Compare a value against a pattern
fn compare(&self, value: &str, pattern: &str, compare_type: L7CompareType) -> bool {
match compare_type {
L7CompareType::EqualTo => value == pattern,
L7CompareType::StartsWith => value.starts_with(pattern),
L7CompareType::EndsWith => value.ends_with(pattern),
L7CompareType::Contains => value.contains(pattern),
L7CompareType::Regex => {
// Compile regex on-the-fly (production should cache)
regex::Regex::new(pattern)
.map(|r| r.is_match(value))
.unwrap_or(false)
}
}
}
/// Extract cookie value from request
fn extract_cookie(&self, info: &RequestInfo, cookie_name: Option<&str>) -> Option<String> {
let name = cookie_name?;
info.headers
.get("cookie")
.and_then(|v| v.to_str().ok())
.and_then(|cookies| {
cookies.split(';').find_map(|c| {
let parts: Vec<_> = c.trim().splitn(2, '=').collect();
if parts.len() == 2 && parts[0] == name {
Some(parts[1].to_string())
} else {
None
}
})
})
}
/// Apply policy action
fn apply_policy_action(&self, policy: &L7Policy) -> RoutingResult {
match policy.action {
L7PolicyAction::RedirectToPool => {
if let Some(pool_id) = &policy.redirect_pool_id {
RoutingResult::Pool(*pool_id)
} else {
tracing::warn!(
policy_id = %policy.id,
"RedirectToPool action but no pool_id configured"
);
RoutingResult::Default
}
}
L7PolicyAction::RedirectToUrl => {
if let Some(url) = &policy.redirect_url {
let status = policy.redirect_http_status_code.unwrap_or(302) as u32;
RoutingResult::Redirect {
url: url.clone(),
status,
}
} else {
tracing::warn!(
policy_id = %policy.id,
"RedirectToUrl action but no URL configured"
);
RoutingResult::Default
}
}
L7PolicyAction::Reject => {
let status = policy.redirect_http_status_code.unwrap_or(403) as u32;
RoutingResult::Reject { status }
}
}
}
}
/// SNI hostname extension (for TLS connections)
#[derive(Debug, Clone)]
pub struct SniHostname(pub String);