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>
223 lines
7.1 KiB
Rust
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);
|