401 lines
13 KiB
Rust
401 lines
13 KiB
Rust
//! L7 (HTTP/HTTPS) Data Plane
|
|
//!
|
|
//! Provides HTTP-aware load balancing with content-based routing, TLS termination,
|
|
//! and session persistence.
|
|
|
|
use axum::{
|
|
body::Body,
|
|
extract::{Request, State},
|
|
http::{header, HeaderValue, StatusCode, Uri},
|
|
response::{IntoResponse, Response},
|
|
routing::any,
|
|
Router,
|
|
};
|
|
use hyper_util::client::legacy::connect::HttpConnector;
|
|
use hyper_util::client::legacy::Client;
|
|
use hyper_util::rt::TokioExecutor;
|
|
use std::collections::HashMap;
|
|
use std::net::SocketAddr;
|
|
use std::sync::Arc;
|
|
use tokio::sync::RwLock;
|
|
use tokio::task::JoinHandle;
|
|
|
|
use crate::l7_router::{L7Router, RequestInfo, RoutingResult};
|
|
use crate::metadata::LbMetadataStore;
|
|
use fiberlb_types::{
|
|
Backend, BackendAdminState, BackendStatus, Listener, ListenerId, ListenerProtocol, PoolAlgorithm,
|
|
PoolId,
|
|
};
|
|
|
|
type Result<T> = std::result::Result<T, L7Error>;
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum L7Error {
|
|
#[error("Listener not found: {0}")]
|
|
ListenerNotFound(String),
|
|
#[error("Invalid protocol: expected HTTP/HTTPS")]
|
|
InvalidProtocol,
|
|
#[error("TLS config missing for HTTPS listener")]
|
|
TlsConfigMissing,
|
|
#[error("TLS termination not implemented for HTTPS listeners")]
|
|
TlsNotImplemented,
|
|
#[error("Backend unavailable: {0}")]
|
|
BackendUnavailable(String),
|
|
#[error("Proxy error: {0}")]
|
|
ProxyError(String),
|
|
#[error("Metadata error: {0}")]
|
|
Metadata(String),
|
|
}
|
|
|
|
/// Handle for a running L7 listener
|
|
struct L7ListenerHandle {
|
|
task: JoinHandle<()>,
|
|
}
|
|
|
|
/// L7 HTTP/HTTPS Data Plane
|
|
pub struct L7DataPlane {
|
|
metadata: Arc<LbMetadataStore>,
|
|
router: Arc<L7Router>,
|
|
http_client: Client<HttpConnector, Body>,
|
|
listeners: Arc<RwLock<HashMap<ListenerId, L7ListenerHandle>>>,
|
|
pool_counters: Arc<RwLock<HashMap<PoolId, usize>>>,
|
|
}
|
|
|
|
impl L7DataPlane {
|
|
/// Create a new L7 data plane
|
|
pub fn new(metadata: Arc<LbMetadataStore>) -> Self {
|
|
let http_client = Client::builder(TokioExecutor::new())
|
|
.pool_max_idle_per_host(32)
|
|
.build_http();
|
|
|
|
Self {
|
|
metadata: metadata.clone(),
|
|
router: Arc::new(L7Router::new(metadata)),
|
|
http_client,
|
|
listeners: Arc::new(RwLock::new(HashMap::new())),
|
|
pool_counters: Arc::new(RwLock::new(HashMap::new())),
|
|
}
|
|
}
|
|
|
|
/// Start an HTTP/HTTPS listener
|
|
pub async fn start_listener(&self, listener_id: ListenerId) -> Result<()> {
|
|
let listener = self.find_listener(&listener_id).await?;
|
|
|
|
// Validate protocol
|
|
if !matches!(listener.protocol, ListenerProtocol::Http | ListenerProtocol::Https | ListenerProtocol::TerminatedHttps) {
|
|
return Err(L7Error::InvalidProtocol);
|
|
}
|
|
|
|
let app = self.build_router(&listener).await?;
|
|
let bind_addr: SocketAddr = format!("0.0.0.0:{}", listener.port)
|
|
.parse()
|
|
.map_err(|e| L7Error::ProxyError(format!("Invalid bind address: {}", e)))?;
|
|
|
|
// For now, only implement HTTP (HTTPS/TLS in Phase 3)
|
|
match listener.protocol {
|
|
ListenerProtocol::Http => {
|
|
self.start_http_server(listener_id, bind_addr, app).await
|
|
}
|
|
ListenerProtocol::Https | ListenerProtocol::TerminatedHttps => {
|
|
// TODO: Phase 3 - TLS termination
|
|
Err(L7Error::TlsNotImplemented)
|
|
}
|
|
_ => Err(L7Error::InvalidProtocol),
|
|
}
|
|
}
|
|
|
|
/// Stop a listener
|
|
pub async fn stop_listener(&self, listener_id: &ListenerId) -> Result<()> {
|
|
let mut listeners = self.listeners.write().await;
|
|
if let Some(handle) = listeners.remove(listener_id) {
|
|
handle.task.abort();
|
|
tracing::info!(listener_id = %listener_id, "Stopped L7 listener");
|
|
Ok(())
|
|
} else {
|
|
Err(L7Error::ListenerNotFound(listener_id.to_string()))
|
|
}
|
|
}
|
|
|
|
/// Find listener in metadata
|
|
async fn find_listener(&self, listener_id: &ListenerId) -> Result<Listener> {
|
|
match self
|
|
.metadata
|
|
.find_listener_by_id(listener_id)
|
|
.await
|
|
.map_err(|e| L7Error::Metadata(e.to_string()))?
|
|
{
|
|
Some(listener) => Ok(listener),
|
|
None => Err(L7Error::ListenerNotFound(listener_id.to_string())),
|
|
}
|
|
}
|
|
|
|
/// Build axum router for a listener
|
|
async fn build_router(&self, listener: &Listener) -> Result<Router> {
|
|
let state = ProxyState {
|
|
metadata: self.metadata.clone(),
|
|
router: self.router.clone(),
|
|
http_client: self.http_client.clone(),
|
|
listener_id: listener.id,
|
|
default_pool_id: listener.default_pool_id.clone(),
|
|
pool_counters: self.pool_counters.clone(),
|
|
};
|
|
|
|
Ok(Router::new()
|
|
.route("/*path", any(proxy_handler))
|
|
.route("/", any(proxy_handler))
|
|
.with_state(state))
|
|
}
|
|
|
|
/// Start HTTP server (no TLS)
|
|
async fn start_http_server(
|
|
&self,
|
|
listener_id: ListenerId,
|
|
bind_addr: SocketAddr,
|
|
app: Router,
|
|
) -> Result<()> {
|
|
tracing::info!(
|
|
listener_id = %listener_id,
|
|
addr = %bind_addr,
|
|
"Starting L7 HTTP listener"
|
|
);
|
|
|
|
let tcp_listener = tokio::net::TcpListener::bind(bind_addr)
|
|
.await
|
|
.map_err(|e| L7Error::ProxyError(format!("Failed to bind: {}", e)))?;
|
|
|
|
let task = tokio::spawn(async move {
|
|
if let Err(e) = axum::serve(tcp_listener, app).await {
|
|
tracing::error!("HTTP server error: {}", e);
|
|
}
|
|
});
|
|
|
|
let mut listeners = self.listeners.write().await;
|
|
listeners.insert(listener_id, L7ListenerHandle { task });
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Shared state for proxy handlers
|
|
#[derive(Clone)]
|
|
struct ProxyState {
|
|
metadata: Arc<LbMetadataStore>,
|
|
router: Arc<L7Router>,
|
|
http_client: Client<HttpConnector, Body>,
|
|
listener_id: ListenerId,
|
|
default_pool_id: Option<PoolId>,
|
|
pool_counters: Arc<RwLock<HashMap<PoolId, usize>>>,
|
|
}
|
|
|
|
/// Main proxy request handler
|
|
#[axum::debug_handler]
|
|
async fn proxy_handler(
|
|
State(state): State<ProxyState>,
|
|
request: Request,
|
|
) -> impl IntoResponse {
|
|
// Extract routing info before async operations (Request body is not Send)
|
|
let request_info = RequestInfo::from_request(&request);
|
|
|
|
// 1. Evaluate L7 policies to determine target pool
|
|
let routing_result = state.router
|
|
.evaluate(&state.listener_id, &request_info)
|
|
.await;
|
|
|
|
match routing_result {
|
|
RoutingResult::Pool(pool_id) => {
|
|
proxy_to_pool(&state, pool_id, request).await
|
|
}
|
|
RoutingResult::Redirect { url, status } => {
|
|
// HTTP redirect
|
|
let status_code = StatusCode::from_u16(status as u16)
|
|
.unwrap_or(StatusCode::FOUND);
|
|
Response::builder()
|
|
.status(status_code)
|
|
.header("Location", url)
|
|
.body(Body::empty())
|
|
.unwrap()
|
|
.into_response()
|
|
}
|
|
RoutingResult::Reject { status } => {
|
|
// Reject with status code
|
|
StatusCode::from_u16(status as u16)
|
|
.unwrap_or(StatusCode::FORBIDDEN)
|
|
.into_response()
|
|
}
|
|
RoutingResult::Default => {
|
|
// Use default pool if configured
|
|
match state.default_pool_id {
|
|
Some(pool_id) => proxy_to_pool(&state, pool_id, request).await,
|
|
None => StatusCode::SERVICE_UNAVAILABLE.into_response(),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Proxy request to a backend pool
|
|
async fn proxy_to_pool(
|
|
state: &ProxyState,
|
|
pool_id: PoolId,
|
|
request: Request,
|
|
) -> Response {
|
|
let request_hash = stable_request_hash(&request);
|
|
let backend = match select_backend(state, pool_id, request_hash).await {
|
|
Ok(backend) => backend,
|
|
Err(error) => {
|
|
tracing::warn!(pool_id = %pool_id, error = %error, "no backend available for L7 pool");
|
|
return text_response(StatusCode::SERVICE_UNAVAILABLE, error.to_string());
|
|
}
|
|
};
|
|
|
|
let path_and_query = request
|
|
.uri()
|
|
.path_and_query()
|
|
.map(|value| value.as_str())
|
|
.unwrap_or("/");
|
|
let backend_host = format!("{}:{}", backend.address, backend.port);
|
|
let target_uri: Uri = match format!("http://{}{}", backend_host, path_and_query).parse() {
|
|
Ok(uri) => uri,
|
|
Err(error) => {
|
|
tracing::error!(
|
|
pool_id = %pool_id,
|
|
backend = %backend_host,
|
|
error = %error,
|
|
"failed to build backend URI"
|
|
);
|
|
return text_response(StatusCode::BAD_GATEWAY, "invalid backend URI");
|
|
}
|
|
};
|
|
|
|
let (mut parts, body) = request.into_parts();
|
|
parts.uri = target_uri;
|
|
rewrite_proxy_headers(&mut parts.headers, &backend_host);
|
|
|
|
match state.http_client.request(Request::from_parts(parts, body)).await {
|
|
Ok(response) => {
|
|
let (parts, body) = response.into_parts();
|
|
Response::from_parts(parts, Body::new(body))
|
|
}
|
|
Err(error) => {
|
|
tracing::warn!(
|
|
pool_id = %pool_id,
|
|
backend = %backend_host,
|
|
error = %error,
|
|
"L7 backend request failed"
|
|
);
|
|
text_response(StatusCode::BAD_GATEWAY, "upstream request failed")
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn select_backend(
|
|
state: &ProxyState,
|
|
pool_id: PoolId,
|
|
request_hash: usize,
|
|
) -> Result<Backend> {
|
|
let pool = state
|
|
.metadata
|
|
.load_pool_by_id(&pool_id)
|
|
.await
|
|
.map_err(|error| L7Error::Metadata(error.to_string()))?
|
|
.ok_or_else(|| L7Error::BackendUnavailable(format!("pool {pool_id} not found")))?;
|
|
let mut backends = state
|
|
.metadata
|
|
.list_backends(&pool_id)
|
|
.await
|
|
.map_err(|error| L7Error::Metadata(error.to_string()))?
|
|
.into_iter()
|
|
.filter(backend_is_available)
|
|
.collect::<Vec<_>>();
|
|
|
|
if backends.is_empty() {
|
|
return Err(L7Error::BackendUnavailable(format!(
|
|
"pool {pool_id} has no healthy backends"
|
|
)));
|
|
}
|
|
|
|
backends.sort_by(|lhs, rhs| lhs.name.cmp(&rhs.name));
|
|
|
|
let index = match pool.algorithm {
|
|
PoolAlgorithm::IpHash | PoolAlgorithm::Maglev => request_hash % backends.len(),
|
|
PoolAlgorithm::WeightedRoundRobin => weighted_round_robin_index(state, pool_id, &backends).await,
|
|
PoolAlgorithm::Random => next_counter(state, pool_id).await % backends.len(),
|
|
PoolAlgorithm::LeastConnections | PoolAlgorithm::RoundRobin => {
|
|
next_counter(state, pool_id).await % backends.len()
|
|
}
|
|
};
|
|
|
|
Ok(backends[index].clone())
|
|
}
|
|
|
|
fn backend_is_available(backend: &Backend) -> bool {
|
|
backend.admin_state == BackendAdminState::Enabled
|
|
&& matches!(backend.status, BackendStatus::Online | BackendStatus::Unknown)
|
|
}
|
|
|
|
async fn next_counter(state: &ProxyState, pool_id: PoolId) -> usize {
|
|
let mut counters = state.pool_counters.write().await;
|
|
let counter = counters.entry(pool_id).or_insert(0);
|
|
let current = *counter;
|
|
*counter = counter.wrapping_add(1);
|
|
current
|
|
}
|
|
|
|
async fn weighted_round_robin_index(
|
|
state: &ProxyState,
|
|
pool_id: PoolId,
|
|
backends: &[Backend],
|
|
) -> usize {
|
|
let total_weight = backends
|
|
.iter()
|
|
.map(|backend| backend.weight.max(1) as usize)
|
|
.sum::<usize>();
|
|
if total_weight == 0 {
|
|
return 0;
|
|
}
|
|
|
|
let mut offset = next_counter(state, pool_id).await % total_weight;
|
|
for (index, backend) in backends.iter().enumerate() {
|
|
let weight = backend.weight.max(1) as usize;
|
|
if offset < weight {
|
|
return index;
|
|
}
|
|
offset -= weight;
|
|
}
|
|
|
|
0
|
|
}
|
|
|
|
fn stable_request_hash(request: &Request) -> usize {
|
|
use std::hash::{Hash, Hasher};
|
|
|
|
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
|
request.method().hash(&mut hasher);
|
|
request.uri().path_and_query().map(|value| value.as_str()).hash(&mut hasher);
|
|
request
|
|
.headers()
|
|
.get(header::HOST)
|
|
.and_then(|value| value.to_str().ok())
|
|
.hash(&mut hasher);
|
|
hasher.finish() as usize
|
|
}
|
|
|
|
fn rewrite_proxy_headers(headers: &mut axum::http::HeaderMap, backend_host: &str) {
|
|
headers.remove(header::CONNECTION);
|
|
headers.remove("proxy-connection");
|
|
headers.remove("keep-alive");
|
|
headers.remove(header::TE);
|
|
headers.remove(header::TRAILER);
|
|
headers.remove(header::TRANSFER_ENCODING);
|
|
headers.remove(header::UPGRADE);
|
|
|
|
if let Ok(host) = HeaderValue::from_str(backend_host) {
|
|
headers.insert(header::HOST, host);
|
|
}
|
|
}
|
|
|
|
fn text_response(status: StatusCode, body: impl Into<Body>) -> Response {
|
|
Response::builder()
|
|
.status(status)
|
|
.body(body.into())
|
|
.unwrap()
|
|
}
|