praxis_filter/tcp_filter.rs
1// SPDX-License-Identifier: MIT
2// Copyright (c) 2024 Praxis Contributors
3
4//! The [`TcpFilter`] trait and per-connection [`TcpFilterContext`].
5
6use std::{borrow::Cow, sync::Arc, time::Instant};
7
8use async_trait::async_trait;
9use praxis_core::{health::HealthRegistry, kv::KvStoreRegistry};
10
11use crate::{actions::FilterAction, filter::FilterError};
12
13// -----------------------------------------------------------------------------
14// TcpFilter Trait
15// -----------------------------------------------------------------------------
16
17/// A filter that participates in TCP connection processing.
18///
19/// ```
20/// use std::{borrow::Cow, time::Instant};
21///
22/// use async_trait::async_trait;
23/// use praxis_filter::{FilterAction, FilterError, TcpFilter, TcpFilterContext};
24///
25/// struct LogFilter;
26///
27/// #[async_trait]
28/// impl TcpFilter for LogFilter {
29/// fn name(&self) -> &'static str {
30/// "log"
31/// }
32///
33/// async fn on_connect(
34/// &self,
35/// ctx: &mut TcpFilterContext<'_>,
36/// ) -> Result<FilterAction, FilterError> {
37/// println!("connection from {}", ctx.remote_addr);
38/// Ok(FilterAction::Continue)
39/// }
40/// }
41///
42/// # fn example() {
43/// let mut ctx = TcpFilterContext {
44/// remote_addr: "127.0.0.1:1234",
45/// local_addr: "0.0.0.0:8080",
46/// sni: None,
47/// upstream_addr: Some(Cow::Borrowed("10.0.0.1:80")),
48/// cluster: None,
49/// health_registry: None,
50/// kv_stores: None,
51/// connect_time: Instant::now(),
52/// bytes_in: 0,
53/// bytes_out: 0,
54/// };
55/// # }
56/// ```
57#[async_trait]
58pub trait TcpFilter: Send + Sync {
59 /// Unique name identifying this filter type.
60 fn name(&self) -> &'static str;
61
62 /// Called when a new TCP connection is accepted.
63 async fn on_connect(&self, ctx: &mut TcpFilterContext<'_>) -> Result<FilterAction, FilterError> {
64 let _ = ctx;
65 Ok(FilterAction::Continue)
66 }
67
68 /// Called when a TCP connection is closed.
69 async fn on_disconnect(&self, ctx: &mut TcpFilterContext<'_>) -> Result<(), FilterError> {
70 let _ = ctx;
71 Ok(())
72 }
73}
74
75// -----------------------------------------------------------------------------
76// TcpFilterContext
77// -----------------------------------------------------------------------------
78
79/// Per-connection state for TCP filters.
80pub struct TcpFilterContext<'a> {
81 /// Remote client address.
82 pub remote_addr: &'a str,
83
84 /// Local listener address.
85 pub local_addr: &'a str,
86
87 /// SNI hostname extracted from the TLS `ClientHello`, if present.
88 pub sni: Option<&'a str>,
89
90 /// Upstream address being proxied to.
91 ///
92 /// `None` until a static upstream or a filter (e.g. `sni_router`)
93 /// provides one.
94 pub upstream_addr: Option<Cow<'a, str>>,
95
96 /// Cluster name selected for this connection.
97 ///
98 /// Set by the listener config when `cluster` is configured.
99 /// Read by `tcp_load_balancer` to look up the strategy.
100 pub cluster: Option<Arc<str>>,
101
102 /// Shared health registry for endpoint health lookups.
103 pub health_registry: Option<&'a HealthRegistry>,
104
105 /// Named key-value stores for runtime mappings.
106 pub kv_stores: Option<&'a KvStoreRegistry>,
107
108 /// When the connection was accepted.
109 pub connect_time: Instant,
110
111 /// Bytes received from client (populated after forwarding completes).
112 pub bytes_in: u64,
113
114 /// Bytes sent to client (populated after forwarding completes).
115 pub bytes_out: u64,
116}
117
118// -----------------------------------------------------------------------------
119// Tests
120// -----------------------------------------------------------------------------
121
122#[cfg(test)]
123#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
124#[allow(
125 clippy::unwrap_used,
126 clippy::expect_used,
127 clippy::indexing_slicing,
128 clippy::panic,
129 reason = "tests"
130)]
131mod tests {
132 use super::*;
133
134 #[tokio::test]
135 async fn default_on_connect_returns_continue() {
136 let filter = NoopTcpFilter;
137 let mut ctx = TcpFilterContext {
138 remote_addr: "127.0.0.1:12345",
139 local_addr: "0.0.0.0:5432",
140 sni: None,
141 upstream_addr: Some(Cow::Borrowed("10.0.0.1:5432")),
142 cluster: None,
143 health_registry: None,
144 kv_stores: None,
145 connect_time: Instant::now(),
146 bytes_in: 0,
147 bytes_out: 0,
148 };
149 let action = filter.on_connect(&mut ctx).await.unwrap();
150 assert!(matches!(action, FilterAction::Continue));
151 }
152
153 #[tokio::test]
154 async fn default_on_disconnect_succeeds() {
155 let filter = NoopTcpFilter;
156 let mut ctx = TcpFilterContext {
157 remote_addr: "127.0.0.1:12345",
158 local_addr: "0.0.0.0:5432",
159 sni: None,
160 upstream_addr: Some(Cow::Borrowed("10.0.0.1:5432")),
161 cluster: None,
162 health_registry: None,
163 kv_stores: None,
164 connect_time: Instant::now(),
165 bytes_in: 0,
166 bytes_out: 0,
167 };
168 filter.on_disconnect(&mut ctx).await.unwrap();
169 }
170
171 // -------------------------------------------------------------------------
172 // Test Utilities
173 // -------------------------------------------------------------------------
174
175 /// Minimal TCP filter that uses all trait defaults.
176 struct NoopTcpFilter;
177
178 #[async_trait]
179 impl TcpFilter for NoopTcpFilter {
180 fn name(&self) -> &'static str {
181 "noop_tcp"
182 }
183 }
184}