foxy/core/
mod.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Core primitives – requests, responses, filters & routing.
6//!
7//! Everything that physically moves through the proxy pipeline is defined
8//! in this module.  No protocol-level logic lives here; that sits in
9//! `server.rs` (IO) and `filters.rs` (behaviour).
10
11#[cfg(test)]
12#[path = "../../tests/unit/core/tests.rs"]
13mod tests;
14
15use crate::security::{ProviderConfig, SecurityChain, SecurityProvider};
16use serde::{Deserialize, Serialize};
17use std::sync::Arc;
18use std::time::{Duration, Instant};
19use std::{fmt, mem};
20use thiserror::Error;
21use tokio::sync::RwLock;
22use tokio::time::timeout;
23
24use crate::config::Config;
25
26use crate::{debug_fmt, error_fmt, info_fmt, trace_fmt, warn_fmt};
27#[cfg(feature = "opentelemetry")]
28use opentelemetry::{
29    Context, KeyValue, global,
30    trace::Tracer,
31    trace::{SpanBuilder, SpanKind, Status, TraceContextExt},
32};
33#[cfg(feature = "opentelemetry")]
34use opentelemetry_http::HeaderInjector;
35#[cfg(feature = "opentelemetry")]
36use opentelemetry_semantic_conventions::attribute::HTTP_RESPONSE_STATUS_CODE;
37#[cfg(feature = "opentelemetry")]
38use std::borrow::Cow;
39
40/// Errors that can occur during proxy operations.
41#[derive(Error, Debug)]
42pub enum ProxyError {
43    /// HTTP client error
44    #[error("HTTP client error: {0}")]
45    ClientError(#[from] reqwest::Error),
46
47    /// IO error
48    #[error("IO error: {0}")]
49    IoError(#[from] std::io::Error),
50
51    /// Timeout error
52    #[error("request timed out after {0:?}")]
53    Timeout(Duration),
54
55    /// Router error
56    #[error("routing error: {0}")]
57    RoutingError(String),
58
59    /// Filter error
60    #[error("filter error: {0}")]
61    FilterError(String),
62
63    /// Configuration error
64    #[error("configuration error: {0}")]
65    ConfigError(String),
66
67    /// Security provider error
68    #[error("security error: {0}")]
69    SecurityError(String),
70
71    /// Generic error
72    #[error("{0}")]
73    Other(String),
74}
75
76impl From<crate::config::error::ConfigError> for ProxyError {
77    fn from(err: crate::config::error::ConfigError) -> Self {
78        ProxyError::ConfigError(err.to_string())
79    }
80}
81
82impl From<globset::Error> for ProxyError {
83    fn from(e: globset::Error) -> Self {
84        ProxyError::SecurityError(e.to_string())
85    }
86}
87
88impl From<jsonwebtoken::errors::Error> for ProxyError {
89    fn from(e: jsonwebtoken::errors::Error) -> Self {
90        ProxyError::SecurityError(e.to_string())
91    }
92}
93
94/// HTTP methods supported by the proxy.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(rename_all = "UPPERCASE")]
97pub enum HttpMethod {
98    Get,
99    Post,
100    Put,
101    Delete,
102    Head,
103    Options,
104    Patch,
105    Trace,
106    Connect,
107}
108
109impl fmt::Display for HttpMethod {
110    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111        match self {
112            HttpMethod::Get => write!(f, "GET"),
113            HttpMethod::Post => write!(f, "POST"),
114            HttpMethod::Put => write!(f, "PUT"),
115            HttpMethod::Delete => write!(f, "DELETE"),
116            HttpMethod::Head => write!(f, "HEAD"),
117            HttpMethod::Options => write!(f, "OPTIONS"),
118            HttpMethod::Patch => write!(f, "PATCH"),
119            HttpMethod::Trace => write!(f, "TRACE"),
120            HttpMethod::Connect => write!(f, "CONNECT"),
121        }
122    }
123}
124
125impl From<&reqwest::Method> for HttpMethod {
126    fn from(method: &reqwest::Method) -> Self {
127        match *method {
128            reqwest::Method::GET => HttpMethod::Get,
129            reqwest::Method::POST => HttpMethod::Post,
130            reqwest::Method::PUT => HttpMethod::Put,
131            reqwest::Method::DELETE => HttpMethod::Delete,
132            reqwest::Method::HEAD => HttpMethod::Head,
133            reqwest::Method::OPTIONS => HttpMethod::Options,
134            reqwest::Method::PATCH => HttpMethod::Patch,
135            reqwest::Method::TRACE => HttpMethod::Trace,
136            reqwest::Method::CONNECT => HttpMethod::Connect,
137            _ => HttpMethod::Get, // Default to GET for unsupported methods
138        }
139    }
140}
141
142impl From<HttpMethod> for reqwest::Method {
143    fn from(method: HttpMethod) -> Self {
144        match method {
145            HttpMethod::Get => reqwest::Method::GET,
146            HttpMethod::Post => reqwest::Method::POST,
147            HttpMethod::Put => reqwest::Method::PUT,
148            HttpMethod::Delete => reqwest::Method::DELETE,
149            HttpMethod::Head => reqwest::Method::HEAD,
150            HttpMethod::Options => reqwest::Method::OPTIONS,
151            HttpMethod::Patch => reqwest::Method::PATCH,
152            HttpMethod::Trace => reqwest::Method::TRACE,
153            HttpMethod::Connect => reqwest::Method::CONNECT,
154        }
155    }
156}
157
158/// Represents an HTTP request that can be processed by the proxy.
159#[derive(Debug)]
160pub struct ProxyRequest {
161    pub method: HttpMethod,
162    pub path: String,
163    pub query: Option<String>,
164    pub headers: reqwest::header::HeaderMap,
165    pub body: reqwest::Body,
166    pub context: Arc<RwLock<RequestContext>>,
167    pub custom_target: Option<String>,
168}
169
170impl Clone for ProxyRequest {
171    fn clone(&self) -> Self {
172        // A streaming body can't be duplicated.  Give filters an empty one.
173        Self {
174            method: self.method,
175            path: self.path.clone(),
176            query: self.query.clone(),
177            headers: self.headers.clone(),
178            body: reqwest::Body::from(""),
179            context: self.context.clone(),
180            custom_target: self.custom_target.clone(),
181        }
182    }
183}
184
185/// Represents an HTTP response returned by the proxy.
186#[derive(Debug)]
187pub struct ProxyResponse {
188    pub status: u16,
189    pub headers: reqwest::header::HeaderMap,
190    pub body: reqwest::Body,
191    pub context: Arc<RwLock<ResponseContext>>,
192}
193
194/// Context data that can be attached to a request and accessed by filters.
195#[derive(Debug, Default, Clone)]
196pub struct RequestContext {
197    /// The original client's IP address
198    pub client_ip: Option<String>,
199    /// The start time of the request
200    pub start_time: Option<std::time::Instant>,
201    /// Custom attributes that can be set by filters
202    pub attributes: std::collections::HashMap<String, serde_json::Value>,
203}
204
205/// Context data that can be attached to a response and accessed by filters.
206#[derive(Debug, Default, Clone)]
207pub struct ResponseContext {
208    /// The time when the response was received from the target
209    pub receive_time: Option<std::time::Instant>,
210    /// Custom attributes that can be set by filters
211    pub attributes: std::collections::HashMap<String, serde_json::Value>,
212}
213
214/// Core proxy server implementation.
215#[derive(Debug)]
216pub struct ProxyCore {
217    /// Configuration for the proxy
218    pub config: Arc<Config>,
219    /// HTTP client for making outbound requests
220    pub client: reqwest::Client,
221    /// Router for matching requests to routes
222    pub router: Arc<dyn Router>,
223    /// Global filters that apply to all routes
224    pub global_filters: Arc<RwLock<Vec<Arc<dyn Filter>>>>,
225    /// Security chain that applies to all routes
226    pub security_chain: Arc<RwLock<SecurityChain>>,
227}
228
229impl ProxyCore {
230    /// Create a new proxy core with the given configuration and router.
231    pub async fn new(config: Arc<Config>, router: Arc<dyn Router>) -> Result<Self, ProxyError> {
232        // Configure the HTTP client based on the configuration
233        let timeout_secs: u64 = config.get_or_default("proxy.timeout", 30_u64)?;
234
235        let client_builder = reqwest::Client::builder();
236        let client = client_builder
237            .timeout(Duration::from_secs(timeout_secs))
238            .build()
239            .map_err(ProxyError::ClientError)?;
240
241        let actual_security_config: Vec<ProviderConfig> = match config.get("proxy.security_chain") {
242            Ok(Some(sc)) => sc,
243            Ok(None) => Vec::new(), // No security chain configured
244            Err(e) => {
245                warn_fmt!(
246                    "Core",
247                    "Could not parse 'proxy.security_chain', defaulting to empty: {}",
248                    e
249                );
250                Vec::new() // Default to empty on error
251            }
252        };
253
254        let security_chain = SecurityChain::from_configs(actual_security_config).await?;
255
256        Ok(Self {
257            config,
258            client,
259            router,
260            global_filters: Arc::new(RwLock::new(Vec::new())),
261            security_chain: Arc::new(RwLock::new(security_chain)),
262        })
263    }
264
265    /// Add a global filter.
266    pub async fn add_global_filter(&self, filter: Arc<dyn Filter>) {
267        let mut filters = self.global_filters.write().await;
268        filters.push(filter);
269    }
270
271    /// Add a security filter to the chain.
272    pub async fn add_security_provider(&self, p: Arc<dyn SecurityProvider>) {
273        self.security_chain.write().await.add(p);
274    }
275
276    /// Process a request through the proxy.
277    pub async fn process_request(
278        &self,
279        request: ProxyRequest,
280        #[cfg(feature = "opentelemetry")] parent_context: Option<Context>,
281    ) -> Result<ProxyResponse, ProxyError> {
282        let overall_start = Instant::now();
283        let method = request.method.to_string();
284        let path = request.path.clone();
285
286        trace_fmt!("Core", "Processing request: {} {}", method, path);
287
288        #[cfg(feature = "opentelemetry")]
289        let span_context = {
290            let parent = parent_context
291                .as_ref()
292                .cloned()
293                .unwrap_or_else(Context::current);
294
295            let span = global::tracer("foxy::proxy").build_with_context(
296                SpanBuilder {
297                    name: Cow::from(format!("{method} {path}")),
298                    span_kind: Some(SpanKind::Client),
299                    ..Default::default()
300                },
301                &parent,
302            );
303
304            let span_context = &Context::current_with_span(span);
305            span_context.clone()
306        };
307
308        /* ---------- Security chain pre auth ---------- */
309        let mut request = match self.security_chain.read().await.apply_pre(request).await {
310            Ok(req) => {
311                trace_fmt!("Core", "Security pre-auth passed for {} {}", method, path);
312                req
313            }
314            Err(e) => {
315                warn_fmt!(
316                    "Core",
317                    "Security pre-auth failed for {} {}: {}",
318                    method,
319                    path,
320                    e
321                );
322
323                #[cfg(feature = "opentelemetry")]
324                {
325                    span_context.span().set_status(Status::Error {
326                        description: Cow::from(e.to_string()),
327                    });
328                    span_context.span().end();
329                }
330
331                return Err(e);
332            }
333        };
334
335        /* ---------- PRE-filters ---------- */
336        for f in self.global_filters.read().await.iter() {
337            if f.filter_type().is_pre() || f.filter_type().is_both() {
338                trace_fmt!("Core", "Applying global pre-filter: {}", f.name());
339                match f.pre_filter(request).await {
340                    Ok(req) => request = req,
341                    Err(e) => {
342                        error_fmt!("Core", "Global pre-filter '{}' failed: {}", f.name(), e);
343
344                        #[cfg(feature = "opentelemetry")]
345                        {
346                            span_context.span().set_status(Status::Error {
347                                description: Cow::from(e.to_string()),
348                            });
349                            span_context.span().end();
350                        }
351
352                        return Err(e);
353                    }
354                }
355            }
356        }
357
358        let mut route = match self.router.route(&request).await {
359            Ok(r) => {
360                debug_fmt!(
361                    "Core",
362                    "Request {} {} matched route: {}",
363                    method,
364                    path,
365                    r.id
366                );
367                r
368            }
369            Err(e) => {
370                warn_fmt!("Core", "No route found for {} {}: {}", method, path, e);
371
372                #[cfg(feature = "opentelemetry")]
373                {
374                    span_context.span().set_status(Status::Error {
375                        description: Cow::from(e.to_string()),
376                    });
377                    span_context.span().end();
378                }
379
380                return Err(e);
381            }
382        };
383
384        let route_filters = route.filters.clone().unwrap_or_default();
385        for f in &route_filters {
386            if f.filter_type().is_pre() || f.filter_type().is_both() {
387                trace_fmt!("Core", "Applying route pre-filter: {}", f.name());
388                match f.pre_filter(request).await {
389                    Ok(req) => request = req,
390                    Err(e) => {
391                        error_fmt!("Core", "Route pre-filter '{}' failed: {}", f.name(), e);
392                        return Err(e);
393                    }
394                }
395            }
396        }
397
398        info_fmt!("Core", "Initial target: {}", route.target_base_url);
399
400        /* ---------- build outbound req ---------- */
401        if request.custom_target.is_some() {
402            debug_fmt!(
403                "Core",
404                "Attempting to dynamically set target base Url: {}",
405                route.target_base_url
406            );
407            route.target_base_url = request.custom_target.clone().unwrap();
408            debug_fmt!(
409                "Core",
410                "Dynamically set target base Url to: {}",
411                route.target_base_url
412            );
413        }
414
415        let url = format!("{}{}", route.target_base_url, request.path);
416        debug_fmt!("Core", "Forwarding to target: {}", url);
417        let outbound_body = mem::replace(&mut request.body, reqwest::Body::from(""));
418
419        #[cfg(feature = "opentelemetry")]
420        let mut outbound_headers = request.headers.clone();
421        #[cfg(not(feature = "opentelemetry"))]
422        let outbound_headers = request.headers.clone();
423        #[cfg(feature = "opentelemetry")]
424        {
425            span_context
426                .span()
427                .set_attribute(KeyValue::new("target", url.clone()));
428
429            global::get_text_map_propagator(|prop| {
430                prop.inject_context(&span_context, &mut HeaderInjector(&mut outbound_headers));
431            });
432        }
433
434        // If there's a query string, append it to the URL directly
435        let final_url = if let Some(q) = &request.query {
436            format!("{url}?{q}")
437        } else {
438            url.clone()
439        };
440
441        let builder = self
442            .client
443            .request(request.method.into(), &final_url)
444            .headers(outbound_headers)
445            .body(outbound_body);
446
447        /* ---------- send with timeout ---------- */
448        // The client already has a timeout configured.
449        // If a per-request timeout from a TimeoutFilter is present in context, it should override.
450        // For now, let's rely on the client's global timeout.
451        // A more advanced implementation could check request.context for a specific timeout.
452        let request_specific_timeout_ms: Option<u64> = request
453            .context
454            .read()
455            .await
456            .attributes
457            .get("timeout_ms")
458            .and_then(|v| v.as_u64());
459
460        let timeout_duration = if let Some(ms) = request_specific_timeout_ms {
461            Duration::from_millis(ms)
462        } else {
463            // Fallback to the client's configured timeout, or a default if not available.
464            // However, the client *is* configured with a timeout in ProxyCore::new.
465            // For consistency, we could fetch it from config again or store it in ProxyCore.
466            // For now, let's assume the client's timeout is sufficient.
467            // If we want to re-fetch:
468            self.config
469                .get_or_default("proxy.timeout", 30_u64)
470                .map(Duration::from_secs)?
471        };
472
473        let upstream_start = Instant::now();
474        trace_fmt!(
475            "Core",
476            "Sending request to upstream with timeout: {:?}",
477            timeout_duration
478        );
479
480        let resp = match timeout(timeout_duration, builder.send()).await {
481            Ok(result) => match result {
482                Ok(response) => response,
483                Err(e) => {
484                    error_fmt!("Core", "Upstream request failed: {}", e);
485
486                    #[cfg(feature = "opentelemetry")]
487                    {
488                        span_context.span().set_status(Status::Error {
489                            description: Cow::from(e.to_string()),
490                        });
491                        span_context.span().end();
492                    }
493
494                    return Err(ProxyError::ClientError(e));
495                }
496            },
497            Err(_) => {
498                warn_fmt!(
499                    "Core",
500                    "Request to {} timed out after {:?}",
501                    url,
502                    timeout_duration
503                );
504
505                #[cfg(feature = "opentelemetry")]
506                {
507                    span_context.span().set_status(Status::Error {
508                        description: Cow::from("Request timed out"),
509                    });
510                    span_context.span().end();
511                }
512
513                return Err(ProxyError::Timeout(timeout_duration));
514            }
515        };
516
517        #[cfg(feature = "opentelemetry")]
518        {
519            let client_span = span_context.span();
520
521            client_span.set_attribute(KeyValue::new(
522                HTTP_RESPONSE_STATUS_CODE,
523                resp.status().as_u16() as i64,
524            ));
525            client_span.end();
526        }
527
528        let upstream_elapsed = upstream_start.elapsed();
529        trace_fmt!(
530            "Core",
531            "Received response from upstream in {:?}",
532            upstream_elapsed
533        );
534
535        /* ---------- wrap streaming response ---------- */
536        let status = resp.status().as_u16();
537        let headers = resp.headers().clone();
538        let body = reqwest::Body::wrap_stream(resp.bytes_stream());
539
540        let mut proxy_resp = ProxyResponse {
541            status,
542            headers,
543            body,
544            context: Arc::new(RwLock::new(ResponseContext::default())),
545        };
546        proxy_resp.context.write().await.receive_time = Some(Instant::now());
547
548        debug_fmt!("Core", "Upstream responded with status: {}", status);
549
550        /* ---------- POST-filters ---------- */
551        for f in &route_filters {
552            if f.filter_type().is_post() || f.filter_type().is_both() {
553                trace_fmt!("Core", "Applying route post-filter: {}", f.name());
554                match f.post_filter(request.clone(), proxy_resp).await {
555                    Ok(resp) => proxy_resp = resp,
556                    Err(e) => {
557                        error_fmt!("Core", "Route post-filter '{}' failed: {}", f.name(), e);
558                        return Err(e);
559                    }
560                }
561            }
562        }
563
564        for f in self.global_filters.read().await.iter() {
565            if f.filter_type().is_post() || f.filter_type().is_both() {
566                trace_fmt!("Core", "Applying global post-filter: {}", f.name());
567                match f.post_filter(request.clone(), proxy_resp).await {
568                    Ok(resp) => proxy_resp = resp,
569                    Err(e) => {
570                        error_fmt!("Core", "Global post-filter '{}' failed: {}", f.name(), e);
571                        return Err(e);
572                    }
573                }
574            }
575        }
576
577        /* ---------- Security chain post auth ---------- */
578        proxy_resp = match self
579            .security_chain
580            .read()
581            .await
582            .apply_post(request.clone(), proxy_resp)
583            .await
584        {
585            Ok(resp) => {
586                trace_fmt!("Core", "Security post-auth passed for {} {}", method, path);
587                resp
588            }
589            Err(e) => {
590                warn_fmt!(
591                    "Core",
592                    "Security post-auth failed for {} {}: {}",
593                    method,
594                    path,
595                    e
596                );
597                return Err(e);
598            }
599        };
600
601        /* ---------- timing log ---------- */
602        let overall_elapsed = overall_start.elapsed();
603        let internal_elapsed = overall_elapsed.saturating_sub(upstream_elapsed);
604
605        debug_fmt!(
606            "Core",
607            "[timing] {} {} -> {} | total={:?} upstream={:?} internal={:?}",
608            request.method,
609            request.path,
610            proxy_resp.status,
611            overall_elapsed,
612            upstream_elapsed,
613            internal_elapsed
614        );
615
616        Ok(proxy_resp)
617    }
618}
619
620/// Describes when a filter should be applied.
621#[derive(Debug, Clone, Copy, PartialEq, Eq)]
622pub enum FilterType {
623    /// Filter applied before the request is sent to the target
624    Pre,
625    /// Filter applied after the response is received from the target
626    Post,
627    /// Filter applied both before and after
628    Both,
629}
630
631impl FilterType {
632    /// Returns true if this is a pre-filter or both.
633    pub fn is_pre(&self) -> bool {
634        matches!(self, FilterType::Pre | FilterType::Both)
635    }
636
637    /// Returns true if this is a post-filter or both.
638    pub fn is_post(&self) -> bool {
639        matches!(self, FilterType::Post | FilterType::Both)
640    }
641
642    /// Returns true if this is both a pre and post filter.
643    pub fn is_both(&self) -> bool {
644        matches!(self, FilterType::Both)
645    }
646}
647
648/// A filter that processes requests and responses.
649#[async_trait::async_trait]
650pub trait Filter: fmt::Debug + Send + Sync {
651    /// Get the filter type.
652    fn filter_type(&self) -> FilterType;
653
654    /// Get the filter name.
655    fn name(&self) -> &str;
656
657    /// Process a request before it is sent to the target.
658    async fn pre_filter(&self, request: ProxyRequest) -> Result<ProxyRequest, ProxyError> {
659        // Default implementation: pass through the request unchanged
660        Ok(request)
661    }
662
663    /// Process a response after it is received from the target.
664    async fn post_filter(
665        &self,
666        _request: ProxyRequest,
667        response: ProxyResponse,
668    ) -> Result<ProxyResponse, ProxyError> {
669        // Default implementation: pass through the response unchanged
670        Ok(response)
671    }
672}
673
674/// A route that the proxy can forward requests to.
675#[derive(Debug, Clone)]
676pub struct Route {
677    /// The ID of the route (for logging and reference)
678    pub id: String,
679    /// The base URL of the target
680    pub target_base_url: String,
681    /// The path pattern that this route matches
682    pub path_pattern: String,
683    /// The filters that should be applied to this route
684    pub filters: Option<Vec<Arc<dyn Filter>>>,
685}
686
687/// A router that matches requests to routes.
688#[async_trait::async_trait]
689pub trait Router: fmt::Debug + Send + Sync {
690    /// Find a route for the given request.
691    async fn route(&self, request: &ProxyRequest) -> Result<Route, ProxyError>;
692
693    /// Get all routes managed by this router.
694    async fn get_routes(&self) -> Vec<Route>;
695
696    /// Add a new route to the router.
697    async fn add_route(&self, route: Route) -> Result<(), ProxyError>;
698
699    /// Remove a route from the router.
700    async fn remove_route(&self, route_id: &str) -> Result<(), ProxyError>;
701}