Skip to main content

praxis_protocol/http/pingora/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2024 Praxis Contributors
3
4//! Pingora HTTP integration: handler, listener setup, health endpoints.
5//!
6//! This module adapts Praxis's filter pipeline to Pingora's HTTP proxy
7//! service. Pingora owns request-smuggling prevention, HTTP/2
8//! backpressure, connection-pool safety, and HTTP/1.1 upgrade detection
9//! with bidirectional forwarding (WebSocket and similar). Praxis code
10//! layered on top, here and in the [`handler`](crate::http::pingora::handler) submodule, owns
11//! hop-by-hop header stripping (with conditional preservation for
12//! upgrade requests), Host validation, `X-Forwarded-*` injection, and
13//! retry logic.
14
15use std::sync::Arc;
16
17use praxis_core::{
18    PingoraServerRuntime, ProxyError,
19    config::{Config, ProtocolKind},
20};
21
22use crate::{ListenerPipelines, Protocol};
23
24/// Per-request context for filter pipeline results.
25pub mod context;
26pub(crate) mod convert;
27/// Trailers-Only responses for proxy-generated gRPC errors.
28pub(crate) mod grpc_trailers;
29/// HTTP proxy handler and Pingora integration.
30pub mod handler;
31/// Health check infrastructure: admin endpoints, probes, and background runner.
32pub mod health;
33#[cfg(feature = "admin-api")]
34pub(crate) mod json;
35/// Admin endpoints for runtime key-value store CRUD.
36#[cfg(feature = "admin-api")]
37pub mod kv;
38/// Listener configuration and TLS setup.
39pub mod listener;
40/// Prometheus metrics: recorder, HTTP request counters, and scrape endpoint.
41pub mod metrics;
42
43// -----------------------------------------------------------------------------
44// PingoraHttp
45// -----------------------------------------------------------------------------
46
47/// Pingora-backed HTTP protocol implementation.
48///
49/// Registers HTTP listeners from the configuration, binding them to Pingora
50/// HTTP proxy services with filter pipelines. Delegates to
51/// [`handler::load_http_handler`] for each listener. Implements [`Protocol`].
52///
53/// [`Protocol`]: crate::Protocol
54pub struct PingoraHttp;
55
56impl Protocol for PingoraHttp {
57    fn register(
58        self: Box<Self>,
59        server: &mut PingoraServerRuntime,
60        config: &Config,
61        pipelines: &ListenerPipelines,
62    ) -> Result<Vec<tokio::sync::watch::Sender<bool>>, ProxyError> {
63        let http_listeners: Vec<_> = config
64            .listeners
65            .iter()
66            .filter(|l| l.protocol == ProtocolKind::Http)
67            .collect();
68
69        if http_listeners.is_empty() {
70            return Ok(Vec::new());
71        }
72
73        let mut cert_watcher_shutdowns = Vec::new();
74        for listener in &http_listeners {
75            let pipeline = pipelines.get(&listener.name).map(Arc::clone).ok_or_else(|| {
76                ProxyError::Config(format!("no pipeline for listener '{name}'", name = listener.name))
77            })?;
78
79            handler::load_http_handler(server.server_mut(), listener, pipeline, &mut cert_watcher_shutdowns)?;
80        }
81
82        Ok(cert_watcher_shutdowns)
83    }
84}
85
86// -----------------------------------------------------------------------------
87// Tests
88// -----------------------------------------------------------------------------
89
90#[cfg(test)]
91#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
92#[allow(
93    clippy::unwrap_used,
94    clippy::expect_used,
95    clippy::indexing_slicing,
96    clippy::panic,
97    clippy::too_many_lines,
98    clippy::use_self,
99    clippy::missing_panics_doc,
100    reason = "tests"
101)]
102mod tests {
103    use std::collections::HashMap;
104
105    use praxis_core::config::{
106        AdminConfig, BodyLimitsConfig, InsecureOptions, Listener, MetricsConfig, RuntimeConfig, TelemetryConfig,
107    };
108    use praxis_filter::{FilterPipeline, FilterRegistry};
109
110    use super::*;
111
112    /// Build a minimal Config with the given listeners.
113    fn make_config(listeners: Vec<Listener>) -> Config {
114        Config {
115            admin: AdminConfig::default(),
116            body_limits: BodyLimitsConfig::default(),
117            clusters: vec![],
118            filter_chains: vec![],
119            insecure_options: InsecureOptions::default(),
120            listeners,
121            metrics: MetricsConfig::default(),
122            runtime: RuntimeConfig::default(),
123            shutdown_timeout_secs: 30,
124            telemetry: TelemetryConfig::default(),
125        }
126    }
127
128    /// Build a Listener with the given name and protocol.
129    fn make_listener(name: &str, protocol: ProtocolKind) -> Listener {
130        Listener {
131            name: name.to_owned(),
132            address: "127.0.0.1:8080".to_owned(),
133            protocol,
134            cluster: None,
135            downstream_read_timeout_ms: None,
136            filter_chains: vec![],
137            max_connections: None,
138            tcp_max_duration_secs: None,
139            tcp_session_timeout_ms: None,
140            tls: None,
141            upstream: None,
142        }
143    }
144
145    /// Build `ListenerPipelines` with empty pipelines for the given listener names.
146    fn make_pipelines(names: &[&str]) -> ListenerPipelines {
147        let registry = FilterRegistry::with_builtins();
148        let mut map = HashMap::new();
149        for name in names {
150            let pipeline = Arc::new(FilterPipeline::build(&mut [], &registry).unwrap());
151            map.insert((*name).to_owned(), pipeline);
152        }
153        ListenerPipelines::new(map)
154    }
155
156    #[test]
157    fn register_returns_empty_vec_when_no_http_listeners() {
158        // Config with only TCP listeners
159        let listeners = vec![
160            make_listener("tcp1", ProtocolKind::Tcp),
161            make_listener("tcp2", ProtocolKind::Tcp),
162        ];
163        let config = make_config(listeners);
164
165        // PingoraServerRuntime is not easily mockable, so we test just the filtering logic
166        // by checking what listeners would be processed
167        let http_listeners: Vec<_> = config
168            .listeners
169            .iter()
170            .filter(|l| l.protocol == ProtocolKind::Http)
171            .collect();
172
173        assert!(
174            http_listeners.is_empty(),
175            "should have no HTTP listeners when all are TCP"
176        );
177    }
178
179    #[test]
180    fn register_returns_empty_vec_when_no_listeners_at_all() {
181        let config = make_config(vec![]);
182
183        let http_listeners: Vec<_> = config
184            .listeners
185            .iter()
186            .filter(|l| l.protocol == ProtocolKind::Http)
187            .collect();
188
189        assert!(
190            http_listeners.is_empty(),
191            "should have no HTTP listeners in empty config"
192        );
193    }
194
195    #[test]
196    fn register_filters_http_listeners_correctly() {
197        let listeners = vec![
198            make_listener("http1", ProtocolKind::Http),
199            make_listener("tcp1", ProtocolKind::Tcp),
200            make_listener("http2", ProtocolKind::Http),
201        ];
202        let config = make_config(listeners);
203
204        let http_listeners: Vec<_> = config
205            .listeners
206            .iter()
207            .filter(|l| l.protocol == ProtocolKind::Http)
208            .collect();
209
210        assert_eq!(http_listeners.len(), 2, "should have exactly 2 HTTP listeners");
211        assert_eq!(http_listeners[0].name, "http1");
212        assert_eq!(http_listeners[1].name, "http2");
213    }
214
215    #[test]
216    fn register_fails_when_pipeline_missing_for_listener() {
217        let listeners = vec![
218            make_listener("http1", ProtocolKind::Http),
219            make_listener("http2", ProtocolKind::Http),
220        ];
221        let config = make_config(listeners);
222
223        // Only provide pipeline for http1, not http2
224        let pipelines = make_pipelines(&["http1"]);
225
226        // Simulate the error path
227        let listener = &config.listeners[1]; // http2
228        let result = pipelines
229            .get(&listener.name)
230            .ok_or_else(|| ProxyError::Config(format!("no pipeline for listener '{name}'", name = listener.name)));
231
232        assert!(result.is_err(), "should fail when pipeline is missing");
233        if let Err(ProxyError::Config(msg)) = result {
234            assert!(
235                msg.contains("no pipeline for listener"),
236                "error should mention missing pipeline"
237            );
238            assert!(msg.contains("http2"), "error should include listener name");
239        } else {
240            panic!("expected ProxyError::Config");
241        }
242    }
243
244    #[test]
245    fn register_finds_pipeline_for_matching_listener() {
246        let listeners = vec![make_listener("web", ProtocolKind::Http)];
247        let config = make_config(listeners);
248        let pipelines = make_pipelines(&["web"]);
249
250        let listener = &config.listeners[0];
251        let result = pipelines.get(&listener.name);
252
253        assert!(result.is_some(), "should find pipeline for matching listener");
254    }
255
256    #[test]
257    fn register_processes_multiple_http_listeners() {
258        let listeners = vec![
259            make_listener("http1", ProtocolKind::Http),
260            make_listener("http2", ProtocolKind::Http),
261            make_listener("http3", ProtocolKind::Http),
262        ];
263        let config = make_config(listeners);
264        let pipelines = make_pipelines(&["http1", "http2", "http3"]);
265
266        // Verify all listeners have matching pipelines
267        for listener in &config.listeners {
268            assert!(
269                pipelines.get(&listener.name).is_some(),
270                "should have pipeline for {name}",
271                name = listener.name
272            );
273        }
274    }
275
276    #[test]
277    fn register_error_message_format() {
278        let listener_name = "my-http-listener";
279        let error = ProxyError::Config(format!("no pipeline for listener '{listener_name}'"));
280
281        match error {
282            ProxyError::Config(msg) => {
283                assert_eq!(msg, "no pipeline for listener 'my-http-listener'");
284                assert!(msg.starts_with("no pipeline"));
285            },
286        }
287    }
288
289    #[test]
290    fn protocol_kind_http_equality() {
291        assert_eq!(ProtocolKind::Http, ProtocolKind::Http);
292        assert_ne!(ProtocolKind::Http, ProtocolKind::Tcp);
293    }
294
295    #[test]
296    fn pingora_http_can_be_boxed() {
297        let protocol: Box<dyn Protocol> = Box::new(PingoraHttp);
298        // Just verify it can be created and boxed
299        drop(protocol);
300    }
301
302    #[test]
303    fn listener_name_used_for_pipeline_lookup() {
304        let listener1 = make_listener("listener-one", ProtocolKind::Http);
305        let listener2 = make_listener("listener-two", ProtocolKind::Http);
306
307        let pipelines = make_pipelines(&["listener-one"]);
308
309        assert!(
310            pipelines.get(&listener1.name).is_some(),
311            "should find pipeline by exact name match"
312        );
313        assert!(
314            pipelines.get(&listener2.name).is_none(),
315            "should not find pipeline for non-existent name"
316        );
317    }
318
319    #[test]
320    fn empty_pipeline_lookup_returns_none() {
321        let pipelines = make_pipelines(&[]);
322        assert!(
323            pipelines.get("any-name").is_none(),
324            "empty pipelines should return None"
325        );
326    }
327
328    #[test]
329    fn protocol_error_config_variant() {
330        let error = ProxyError::Config("test error".to_owned());
331        match error {
332            ProxyError::Config(msg) => assert_eq!(msg, "test error"),
333        }
334    }
335
336    #[test]
337    fn http_listener_filtering_with_mixed_protocols() {
338        let listeners = vec![
339            make_listener("http1", ProtocolKind::Http),
340            make_listener("tcp1", ProtocolKind::Tcp),
341            make_listener("http2", ProtocolKind::Http),
342            make_listener("tcp2", ProtocolKind::Tcp),
343            make_listener("http3", ProtocolKind::Http),
344        ];
345        let config = make_config(listeners);
346
347        let http_listeners: Vec<_> = config
348            .listeners
349            .iter()
350            .filter(|l| l.protocol == ProtocolKind::Http)
351            .collect();
352
353        assert_eq!(http_listeners.len(), 3);
354        assert!(http_listeners.iter().all(|l| l.protocol == ProtocolKind::Http));
355    }
356}