#[cfg(test)]
#[path = "../../tests/unit/core/tests.rs"]
mod tests;
use crate::security::{ProviderConfig, SecurityChain, SecurityProvider};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use std::time::{Duration, Instant};
use std::{fmt, mem};
use thiserror::Error;
use tokio::sync::RwLock;
use tokio::time::timeout;
use crate::config::Config;
use crate::{debug_fmt, error_fmt, info_fmt, trace_fmt, warn_fmt};
#[cfg(feature = "opentelemetry")]
use opentelemetry::{
Context, KeyValue, global,
trace::Tracer,
trace::{SpanBuilder, SpanKind, Status, TraceContextExt},
};
#[cfg(feature = "opentelemetry")]
use opentelemetry_http::HeaderInjector;
#[cfg(feature = "opentelemetry")]
use opentelemetry_semantic_conventions::attribute::HTTP_RESPONSE_STATUS_CODE;
#[cfg(feature = "opentelemetry")]
use std::borrow::Cow;
#[derive(Error, Debug)]
pub enum ProxyError {
#[error("HTTP client error: {0}")]
ClientError(#[from] reqwest::Error),
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
#[error("request timed out after {0:?}")]
Timeout(Duration),
#[error("routing error: {0}")]
RoutingError(String),
#[error("filter error: {0}")]
FilterError(String),
#[error("configuration error: {0}")]
ConfigError(String),
#[error("security error: {0}")]
SecurityError(String),
#[error("{0}")]
Other(String),
}
impl From<crate::config::error::ConfigError> for ProxyError {
fn from(err: crate::config::error::ConfigError) -> Self {
ProxyError::ConfigError(err.to_string())
}
}
impl From<globset::Error> for ProxyError {
fn from(e: globset::Error) -> Self {
ProxyError::SecurityError(e.to_string())
}
}
impl From<jsonwebtoken::errors::Error> for ProxyError {
fn from(e: jsonwebtoken::errors::Error) -> Self {
ProxyError::SecurityError(e.to_string())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
pub enum HttpMethod {
Get,
Post,
Put,
Delete,
Head,
Options,
Patch,
Trace,
Connect,
}
impl fmt::Display for HttpMethod {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
HttpMethod::Get => write!(f, "GET"),
HttpMethod::Post => write!(f, "POST"),
HttpMethod::Put => write!(f, "PUT"),
HttpMethod::Delete => write!(f, "DELETE"),
HttpMethod::Head => write!(f, "HEAD"),
HttpMethod::Options => write!(f, "OPTIONS"),
HttpMethod::Patch => write!(f, "PATCH"),
HttpMethod::Trace => write!(f, "TRACE"),
HttpMethod::Connect => write!(f, "CONNECT"),
}
}
}
impl From<&reqwest::Method> for HttpMethod {
fn from(method: &reqwest::Method) -> Self {
match *method {
reqwest::Method::GET => HttpMethod::Get,
reqwest::Method::POST => HttpMethod::Post,
reqwest::Method::PUT => HttpMethod::Put,
reqwest::Method::DELETE => HttpMethod::Delete,
reqwest::Method::HEAD => HttpMethod::Head,
reqwest::Method::OPTIONS => HttpMethod::Options,
reqwest::Method::PATCH => HttpMethod::Patch,
reqwest::Method::TRACE => HttpMethod::Trace,
reqwest::Method::CONNECT => HttpMethod::Connect,
_ => HttpMethod::Get, }
}
}
impl From<HttpMethod> for reqwest::Method {
fn from(method: HttpMethod) -> Self {
match method {
HttpMethod::Get => reqwest::Method::GET,
HttpMethod::Post => reqwest::Method::POST,
HttpMethod::Put => reqwest::Method::PUT,
HttpMethod::Delete => reqwest::Method::DELETE,
HttpMethod::Head => reqwest::Method::HEAD,
HttpMethod::Options => reqwest::Method::OPTIONS,
HttpMethod::Patch => reqwest::Method::PATCH,
HttpMethod::Trace => reqwest::Method::TRACE,
HttpMethod::Connect => reqwest::Method::CONNECT,
}
}
}
#[derive(Debug)]
pub struct ProxyRequest {
pub method: HttpMethod,
pub path: String,
pub query: Option<String>,
pub headers: reqwest::header::HeaderMap,
pub body: reqwest::Body,
pub context: Arc<RwLock<RequestContext>>,
pub custom_target: Option<String>,
}
impl Clone for ProxyRequest {
fn clone(&self) -> Self {
Self {
method: self.method,
path: self.path.clone(),
query: self.query.clone(),
headers: self.headers.clone(),
body: reqwest::Body::from(""),
context: self.context.clone(),
custom_target: self.custom_target.clone(),
}
}
}
#[derive(Debug)]
pub struct ProxyResponse {
pub status: u16,
pub headers: reqwest::header::HeaderMap,
pub body: reqwest::Body,
pub context: Arc<RwLock<ResponseContext>>,
}
#[derive(Debug, Default, Clone)]
pub struct RequestContext {
pub client_ip: Option<String>,
pub start_time: Option<std::time::Instant>,
pub attributes: std::collections::HashMap<String, serde_json::Value>,
}
#[derive(Debug, Default, Clone)]
pub struct ResponseContext {
pub receive_time: Option<std::time::Instant>,
pub attributes: std::collections::HashMap<String, serde_json::Value>,
}
#[derive(Debug)]
pub struct ProxyCore {
pub config: Arc<Config>,
pub client: reqwest::Client,
pub router: Arc<dyn Router>,
pub global_filters: Arc<RwLock<Vec<Arc<dyn Filter>>>>,
pub security_chain: Arc<RwLock<SecurityChain>>,
}
impl ProxyCore {
pub async fn new(config: Arc<Config>, router: Arc<dyn Router>) -> Result<Self, ProxyError> {
let timeout_secs: u64 = config.get_or_default("proxy.timeout", 30_u64)?;
let client_builder = reqwest::Client::builder();
let client = client_builder
.timeout(Duration::from_secs(timeout_secs))
.build()
.map_err(ProxyError::ClientError)?;
let actual_security_config: Vec<ProviderConfig> = match config.get("proxy.security_chain") {
Ok(Some(sc)) => sc,
Ok(None) => Vec::new(), Err(e) => {
warn_fmt!(
"Core",
"Could not parse 'proxy.security_chain', defaulting to empty: {}",
e
);
Vec::new() }
};
let security_chain = SecurityChain::from_configs(actual_security_config).await?;
Ok(Self {
config,
client,
router,
global_filters: Arc::new(RwLock::new(Vec::new())),
security_chain: Arc::new(RwLock::new(security_chain)),
})
}
pub async fn add_global_filter(&self, filter: Arc<dyn Filter>) {
let mut filters = self.global_filters.write().await;
filters.push(filter);
}
pub async fn add_security_provider(&self, p: Arc<dyn SecurityProvider>) {
self.security_chain.write().await.add(p);
}
pub async fn process_request(
&self,
request: ProxyRequest,
#[cfg(feature = "opentelemetry")] parent_context: Option<Context>,
) -> Result<ProxyResponse, ProxyError> {
let overall_start = Instant::now();
let method = request.method.to_string();
let path = request.path.clone();
trace_fmt!("Core", "Processing request: {} {}", method, path);
#[cfg(feature = "opentelemetry")]
let span_context = {
let parent = parent_context
.as_ref()
.cloned()
.unwrap_or_else(Context::current);
let span = global::tracer("foxy::proxy").build_with_context(
SpanBuilder {
name: Cow::from(format!("{method} {path}")),
span_kind: Some(SpanKind::Client),
..Default::default()
},
&parent,
);
let span_context = &Context::current_with_span(span);
span_context.clone()
};
let mut request = match self.security_chain.read().await.apply_pre(request).await {
Ok(req) => {
trace_fmt!("Core", "Security pre-auth passed for {} {}", method, path);
req
}
Err(e) => {
warn_fmt!(
"Core",
"Security pre-auth failed for {} {}: {}",
method,
path,
e
);
#[cfg(feature = "opentelemetry")]
{
span_context.span().set_status(Status::Error {
description: Cow::from(e.to_string()),
});
span_context.span().end();
}
return Err(e);
}
};
for f in self.global_filters.read().await.iter() {
if f.filter_type().is_pre() || f.filter_type().is_both() {
trace_fmt!("Core", "Applying global pre-filter: {}", f.name());
match f.pre_filter(request).await {
Ok(req) => request = req,
Err(e) => {
error_fmt!("Core", "Global pre-filter '{}' failed: {}", f.name(), e);
#[cfg(feature = "opentelemetry")]
{
span_context.span().set_status(Status::Error {
description: Cow::from(e.to_string()),
});
span_context.span().end();
}
return Err(e);
}
}
}
}
let mut route = match self.router.route(&request).await {
Ok(r) => {
debug_fmt!(
"Core",
"Request {} {} matched route: {}",
method,
path,
r.id
);
r
}
Err(e) => {
warn_fmt!("Core", "No route found for {} {}: {}", method, path, e);
#[cfg(feature = "opentelemetry")]
{
span_context.span().set_status(Status::Error {
description: Cow::from(e.to_string()),
});
span_context.span().end();
}
return Err(e);
}
};
let route_filters = route.filters.clone().unwrap_or_default();
for f in &route_filters {
if f.filter_type().is_pre() || f.filter_type().is_both() {
trace_fmt!("Core", "Applying route pre-filter: {}", f.name());
match f.pre_filter(request).await {
Ok(req) => request = req,
Err(e) => {
error_fmt!("Core", "Route pre-filter '{}' failed: {}", f.name(), e);
return Err(e);
}
}
}
}
info_fmt!("Core", "Initial target: {}", route.target_base_url);
if request.custom_target.is_some() {
debug_fmt!(
"Core",
"Attempting to dynamically set target base Url: {}",
route.target_base_url
);
route.target_base_url = request.custom_target.clone().unwrap();
debug_fmt!(
"Core",
"Dynamically set target base Url to: {}",
route.target_base_url
);
}
let url = format!("{}{}", route.target_base_url, request.path);
debug_fmt!("Core", "Forwarding to target: {}", url);
let outbound_body = mem::replace(&mut request.body, reqwest::Body::from(""));
#[cfg(feature = "opentelemetry")]
let mut outbound_headers = request.headers.clone();
#[cfg(not(feature = "opentelemetry"))]
let outbound_headers = request.headers.clone();
#[cfg(feature = "opentelemetry")]
{
span_context
.span()
.set_attribute(KeyValue::new("target", url.clone()));
global::get_text_map_propagator(|prop| {
prop.inject_context(&span_context, &mut HeaderInjector(&mut outbound_headers));
});
}
let final_url = if let Some(q) = &request.query {
format!("{url}?{q}")
} else {
url.clone()
};
let builder = self
.client
.request(request.method.into(), &final_url)
.headers(outbound_headers)
.body(outbound_body);
let request_specific_timeout_ms: Option<u64> = request
.context
.read()
.await
.attributes
.get("timeout_ms")
.and_then(|v| v.as_u64());
let timeout_duration = if let Some(ms) = request_specific_timeout_ms {
Duration::from_millis(ms)
} else {
self.config
.get_or_default("proxy.timeout", 30_u64)
.map(Duration::from_secs)?
};
let upstream_start = Instant::now();
trace_fmt!(
"Core",
"Sending request to upstream with timeout: {:?}",
timeout_duration
);
let resp = match timeout(timeout_duration, builder.send()).await {
Ok(result) => match result {
Ok(response) => response,
Err(e) => {
error_fmt!("Core", "Upstream request failed: {}", e);
#[cfg(feature = "opentelemetry")]
{
span_context.span().set_status(Status::Error {
description: Cow::from(e.to_string()),
});
span_context.span().end();
}
return Err(ProxyError::ClientError(e));
}
},
Err(_) => {
warn_fmt!(
"Core",
"Request to {} timed out after {:?}",
url,
timeout_duration
);
#[cfg(feature = "opentelemetry")]
{
span_context.span().set_status(Status::Error {
description: Cow::from("Request timed out"),
});
span_context.span().end();
}
return Err(ProxyError::Timeout(timeout_duration));
}
};
#[cfg(feature = "opentelemetry")]
{
let client_span = span_context.span();
client_span.set_attribute(KeyValue::new(
HTTP_RESPONSE_STATUS_CODE,
resp.status().as_u16() as i64,
));
client_span.end();
}
let upstream_elapsed = upstream_start.elapsed();
trace_fmt!(
"Core",
"Received response from upstream in {:?}",
upstream_elapsed
);
let status = resp.status().as_u16();
let headers = resp.headers().clone();
let body = reqwest::Body::wrap_stream(resp.bytes_stream());
let mut proxy_resp = ProxyResponse {
status,
headers,
body,
context: Arc::new(RwLock::new(ResponseContext::default())),
};
proxy_resp.context.write().await.receive_time = Some(Instant::now());
debug_fmt!("Core", "Upstream responded with status: {}", status);
for f in &route_filters {
if f.filter_type().is_post() || f.filter_type().is_both() {
trace_fmt!("Core", "Applying route post-filter: {}", f.name());
match f.post_filter(request.clone(), proxy_resp).await {
Ok(resp) => proxy_resp = resp,
Err(e) => {
error_fmt!("Core", "Route post-filter '{}' failed: {}", f.name(), e);
return Err(e);
}
}
}
}
for f in self.global_filters.read().await.iter() {
if f.filter_type().is_post() || f.filter_type().is_both() {
trace_fmt!("Core", "Applying global post-filter: {}", f.name());
match f.post_filter(request.clone(), proxy_resp).await {
Ok(resp) => proxy_resp = resp,
Err(e) => {
error_fmt!("Core", "Global post-filter '{}' failed: {}", f.name(), e);
return Err(e);
}
}
}
}
proxy_resp = match self
.security_chain
.read()
.await
.apply_post(request.clone(), proxy_resp)
.await
{
Ok(resp) => {
trace_fmt!("Core", "Security post-auth passed for {} {}", method, path);
resp
}
Err(e) => {
warn_fmt!(
"Core",
"Security post-auth failed for {} {}: {}",
method,
path,
e
);
return Err(e);
}
};
let overall_elapsed = overall_start.elapsed();
let internal_elapsed = overall_elapsed.saturating_sub(upstream_elapsed);
debug_fmt!(
"Core",
"[timing] {} {} -> {} | total={:?} upstream={:?} internal={:?}",
request.method,
request.path,
proxy_resp.status,
overall_elapsed,
upstream_elapsed,
internal_elapsed
);
Ok(proxy_resp)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FilterType {
Pre,
Post,
Both,
}
impl FilterType {
pub fn is_pre(&self) -> bool {
matches!(self, FilterType::Pre | FilterType::Both)
}
pub fn is_post(&self) -> bool {
matches!(self, FilterType::Post | FilterType::Both)
}
pub fn is_both(&self) -> bool {
matches!(self, FilterType::Both)
}
}
#[async_trait::async_trait]
pub trait Filter: fmt::Debug + Send + Sync {
fn filter_type(&self) -> FilterType;
fn name(&self) -> &str;
async fn pre_filter(&self, request: ProxyRequest) -> Result<ProxyRequest, ProxyError> {
Ok(request)
}
async fn post_filter(
&self,
_request: ProxyRequest,
response: ProxyResponse,
) -> Result<ProxyResponse, ProxyError> {
Ok(response)
}
}
#[derive(Debug, Clone)]
pub struct Route {
pub id: String,
pub target_base_url: String,
pub path_pattern: String,
pub filters: Option<Vec<Arc<dyn Filter>>>,
}
#[async_trait::async_trait]
pub trait Router: fmt::Debug + Send + Sync {
async fn route(&self, request: &ProxyRequest) -> Result<Route, ProxyError>;
async fn get_routes(&self) -> Vec<Route>;
async fn add_route(&self, route: Route) -> Result<(), ProxyError>;
async fn remove_route(&self, route_id: &str) -> Result<(), ProxyError>;
}