plecto_server/lib.rs
1//! plecto-server — the M2 fast path (ADR 000013, TLS 000014, HTTP/2 000015, HTTP/3 000016).
2//!
3//! A tokio listener that turns Plecto from a library into an actual reverse proxy. It serves
4//! HTTP/1.1 and HTTP/2 over TCP (hyper, ALPN-negotiated — ADR 000015) and HTTP/3 over QUIC (quinn +
5//! the h3 crate, an independent UDP listener advertised via `Alt-Svc` — ADR 000016). All three
6//! transports feed the same transaction core (`proxy_core`); only the body adapters differ.
7//! Per request it: builds a header-only `HttpRequest`, asks the control plane which route
8//! matches (host + path prefix), runs that route's filter chain, and either responds now (a
9//! filter short-circuited / failed closed) or forwards the request to the route's upstream and
10//! runs the response side of the chain on the way back.
11//!
12//! **sync↔async bridge (the §6.3 prerequisite).** Filter execution is synchronous and runs on a
13//! wasmtime `Store` that is `!Send`, so it cannot cross an `.await`. Each chain dispatch is moved
14//! to tokio's blocking pool via `spawn_blocking`; the M1 trusted instance pool handles instance
15//! reuse and saturation there. Route matching is pure config lookup and stays on the async thread.
16//!
17//! **Request body: buffered ONLY when a filter reads it (ADR 000025 / 000038).** A route whose
18//! filters all target the header-only `filter` world streams the request body straight to the
19//! upstream (zero-copy); a route with a filter that exports `on-request-body` (`reads_body`) has the
20//! body buffered (bounded) and run through the `on-request-body` chain. The response body always
21//! streams straight back — filters see response headers / status only (they may synthesise a
22//! short-circuit body of their own).
23
24// Hot-path discipline (bp-rust): no unwrap/expect/panic/indexing on the data plane. Exempted
25// under `cfg(test)` — this crate's own `#[cfg(test)] mod` blocks legitimately use them;
26// `tests/*.rs` integration tests are separate crates and are never subject to this attribute.
27#![cfg_attr(
28 not(test),
29 warn(
30 clippy::unwrap_used,
31 clippy::expect_used,
32 clippy::panic,
33 clippy::indexing_slicing
34 )
35)]
36
37mod access_log;
38mod admin;
39mod body;
40mod compression;
41mod conn_limit;
42mod dispatch;
43mod dns;
44mod error;
45mod forward;
46mod h3;
47mod headers;
48mod health;
49mod listener;
50mod metrics;
51mod otlp;
52mod proxy;
53// `pub`: the out-of-workspace fuzz harness (`fuzz/`) drives the pure parser (ADR 000057). Not a
54// semver surface — the crate is `publish = false`.
55pub mod proxy_protocol;
56mod respond;
57mod retry;
58mod tunnel;
59mod upstream_client;
60
61use std::sync::Arc;
62
63use bytes::Bytes;
64use http_body_util::combinators::{BoxBody, UnsyncBoxBody};
65use hyper::header::HeaderValue;
66use hyper_util::client::legacy::connect::HttpConnector;
67use plecto_control::Control;
68use tokio::sync::Semaphore;
69
70use crate::metrics::ServerMetrics;
71use crate::upstream_client::UpstreamClients;
72
73pub use listener::{DEFAULT_DRAIN_DEADLINE, serve, serve_with_shutdown};
74
75/// Cap glibc's per-thread malloc arenas at process start to bound RSS on many-core hosts.
76///
77/// glibc defaults to `8 × ncpu` arenas on 64-bit. Under a many-threaded proxy doing bursty
78/// per-request allocations, freed memory lingers in each thread's arena instead of returning to the
79/// OS, inflating RSS (measured ~2.5× at 1 MB bodies × 50 conns — docs/servey body-tax). This is a
80/// defensive complement to the real fix (not buffering a body no filter reads); routes that
81/// legitimately buffer still allocate, and this bounds their arena fragmentation.
82///
83/// `M_ARENA_MAX` only gates creation of NEW arenas and never reclaims existing ones, so this MUST
84/// run before the runtime spawns its worker threads (call it first in `main`). Default cap **4** — a
85/// portable, contention-safe value used across multithreaded services, chosen over the value that
86/// minimised RSS on one host (1) precisely because Plecto is self-hosted on varied machines.
87/// Override with `PLECTO_MALLOC_ARENA_MAX` (`0` leaves glibc's default in place). No-op off glibc.
88pub fn cap_malloc_arenas() {
89 let max = std::env::var("PLECTO_MALLOC_ARENA_MAX")
90 .ok()
91 .and_then(|s| s.parse::<i32>().ok())
92 .unwrap_or(4);
93 apply_arena_cap(max);
94}
95
96#[cfg(all(target_os = "linux", target_env = "gnu"))]
97fn apply_arena_cap(max: i32) {
98 if max <= 0 {
99 return; // 0 / negative: leave glibc's default (8 × ncpu) untouched.
100 }
101 // SAFETY: a plain libc call made single-threaded at startup, before any worker thread exists.
102 // Returns 1 on success / 0 on failure; a best-effort tuning knob, so a rejection is ignored
103 // rather than failing startup.
104 unsafe {
105 libc::mallopt(libc::M_ARENA_MAX, max as core::ffi::c_int);
106 }
107}
108
109#[cfg(not(all(target_os = "linux", target_env = "gnu")))]
110fn apply_arena_cap(_max: i32) {}
111
112/// A boxed, `Send` error — the unified error type for the boxed request/response bodies.
113pub(crate) type BoxError = Box<dyn std::error::Error + Send + Sync>;
114
115/// The response body the service yields: either a synthesised buffer (`Full`, for a short-circuit
116/// or a fail-closed 5xx) or the upstream's streamed body (`Incoming`), unified behind one boxed
117/// type so the service has a single return shape. `UnsyncBoxBody`, not `BoxBody`: no consumer of
118/// a response body (hyper h1/h2 serve, our h3 send loop) requires `Sync` — `poll_frame` takes
119/// `&mut self` — and demanding it here forced `Send`-only bodies (the zstd encoder in
120/// `compression.rs`) into artificial `Sync` wrappers.
121pub(crate) type ResponseBody = UnsyncBoxBody<Bytes, BoxError>;
122
123/// The request body forwarded to the upstream, boxed so one type covers every inbound transport:
124/// the hyper `Incoming` (HTTP/1.1 + HTTP/2) and the QUIC/h3 recv stream (HTTP/3, ADR 000016). The
125/// body streams straight through opaquely (header-only contract, ADR 000010) regardless of source.
126pub(crate) type ReqBody = BoxBody<Bytes, BoxError>;
127
128/// Global cap on concurrently-served connections across all transports (CWE-770). A permit
129/// is acquired BEFORE each accept, so at saturation the listener stops pulling new connections off
130/// the OS backlog (natural backpressure) instead of spawning per-connection tasks unboundedly.
131pub(crate) const MAX_CONNECTIONS: usize = 10_000;
132
133/// Per-source-IP cap on concurrently-served connections (CWE-770/CWE-400, docs/servey production
134/// hardening — ADR 000027 amendment), enforced by [`conn_limit::PerIpConnLimit`] in both accept
135/// loops (TCP `listener.rs`, QUIC `h3/endpoint.rs`). `MAX_CONNECTIONS` alone bounds the total but
136/// not its distribution: without this, one source can hold every permit and starve every other
137/// client. ~2.6% of `MAX_CONNECTIONS` — comfortably above any legitimate single-source workload
138/// (even a large NAT/corporate gateway), while an attacker needs ~40 distinct source addresses
139/// (or hash-colliding /64s, for IPv6) to exhaust the pool alone, instead of one.
140pub(crate) const MAX_CONNECTIONS_PER_IP: u32 = 256;
141
142/// Raise the process's soft `RLIMIT_NOFILE` to match the hard limit at startup (Unix only; a no-op
143/// twin exists for other platforms below).
144///
145/// Most distros ship a low default soft limit (often 1024) while the hard limit is already
146/// generous — an unprivileged process may always raise its own soft limit up to the hard limit
147/// (POSIX `setrlimit(2)`, no `CAP_SYS_RESOURCE` needed). Without this, an accept loop asking for
148/// `MAX_CONNECTIONS` sockets hits EMFILE at the OS default long before its own cap — and, by
149/// design, a transient accept error is logged and the loop continues (`listener.rs`), so the
150/// server silently stops admitting new connections instead of crashing loudly. Raising the HARD
151/// limit itself needs a privilege Plecto does not request (self-hosting simplicity, deny-by-default
152/// P4); if the hard limit is ALSO below what `MAX_CONNECTIONS` needs, this only warns — an
153/// operator must raise it externally (systemd `LimitNOFILE=`, `docker run --ulimit nofile=...`, or
154/// the container runtime's own ulimit configuration).
155#[cfg(unix)]
156pub fn raise_nofile_limit() {
157 match unix_raise_nofile_limit() {
158 Ok((soft, hard)) => {
159 // 1 client fd + 1 upstream fd per proxied connection (the same accounting nginx
160 // documents for a proxying worker), doubled for headroom: the admin/health listener,
161 // DNS-refresh sockets, TLS resumption file handles, and connections mid-teardown that
162 // have not yet released their `MAX_CONNECTIONS` permit.
163 let wanted = MAX_CONNECTIONS as u64 * 4;
164 if soft < wanted {
165 tracing::warn!(
166 soft,
167 hard,
168 wanted,
169 "RLIMIT_NOFILE is below the recommended floor for MAX_CONNECTIONS; raise the \
170 HARD limit externally (systemd LimitNOFILE=, docker --ulimit nofile=, or \
171 ulimit -Hn) — Plecto does not request CAP_SYS_RESOURCE to do this itself"
172 );
173 } else {
174 tracing::info!(soft, hard, "RLIMIT_NOFILE raised to the hard limit");
175 }
176 }
177 Err(e) => {
178 tracing::warn!(
179 error = %e,
180 "failed to read/raise RLIMIT_NOFILE; leaving the OS default in place"
181 );
182 }
183 }
184}
185
186/// No POSIX resource limits on this platform — nothing to raise.
187#[cfg(not(unix))]
188pub fn raise_nofile_limit() {}
189
190#[cfg(unix)]
191fn unix_raise_nofile_limit() -> std::io::Result<(u64, u64)> {
192 let mut rlim = libc::rlimit {
193 rlim_cur: 0,
194 rlim_max: 0,
195 };
196 // SAFETY: `rlim` is a valid, stack-local `libc::rlimit`; `getrlimit`/`setrlimit` only read/
197 // write through the pointer we pass and signal failure via a `-1` return (checked below),
198 // never touching memory beyond the struct.
199 if unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut rlim) } != 0 {
200 return Err(std::io::Error::last_os_error());
201 }
202 let hard = rlim.rlim_max;
203 if rlim.rlim_cur < hard {
204 rlim.rlim_cur = hard;
205 if unsafe { libc::setrlimit(libc::RLIMIT_NOFILE, &rlim) } != 0 {
206 return Err(std::io::Error::last_os_error());
207 }
208 }
209 // `rlim_t` is `u64` on every Unix target this crate builds for (Linux glibc/musl, macOS).
210 Ok((rlim.rlim_cur, hard))
211}
212
213/// Per-connection cap on concurrent HTTP/2 streams (ADR 000015). A fixed, conservative bound (not
214/// yet manifest-configurable): it stops a single h2 connection from monopolising the fixed-capacity
215/// M1 instance pool (ADR 000012) with concurrent chain dispatches, and is defence-in-depth against
216/// stream-flooding DoS (the h2 crate already mitigates Rapid Reset, CVE-2023-44487). 100 is the
217/// RFC 9113 recommended floor; hyper's own default is version-dependent and not API-stable, so we
218/// pin it explicitly.
219pub(crate) const MAX_CONCURRENT_STREAMS: u32 = 100;
220
221/// Shared per-server state: the control plane (filters, routes, reload), the upstream clients, and
222/// the `Alt-Svc` header value advertising HTTP/3 (ADR 000016) — `Some` only when a QUIC listener is
223/// bound, and added to TCP (HTTP/1.1 + HTTP/2) responses to steer capable clients to h3.
224pub(crate) struct ServerState {
225 control: Arc<Control>,
226 /// Per-security-context upstream clients (ADR 000042): one plain HTTP/1.1 client plus one
227 /// pooled TLS client per distinct `[upstream.tls]` config.
228 clients: UpstreamClients,
229 alt_svc: Option<HeaderValue>,
230 /// Global connection cap across TCP + QUIC: a permit is held for each connection's
231 /// lifetime, so the server never serves more than `MAX_CONNECTIONS` at once.
232 conn_limit: Arc<Semaphore>,
233 /// Per-source-IP connection cap across TCP + QUIC (`MAX_CONNECTIONS_PER_IP`): a slot is held
234 /// for each connection's lifetime, so one source cannot exhaust `conn_limit` alone.
235 per_ip_conn_limit: Arc<conn_limit::PerIpConnLimit>,
236 /// Cap on concurrently-buffered request bodies for the `on-request-body` hook, bounding
237 /// total buffered memory.
238 body_buffer_limit: Arc<Semaphore>,
239 /// Native data-plane metrics (Stage A observability, ADR 000009): RED signals tallied per
240 /// request and served on the admin endpoint. Always recorded (cheap atomics); whether anyone
241 /// can scrape them is gated by `[observability] admin_addr`.
242 metrics: Arc<ServerMetrics>,
243 /// The OTLP span buffer (ADR 000040), present iff `[observability] otlp_endpoint` is set:
244 /// `proxy_core` pushes one SERVER request span per sampled transaction; the export pump
245 /// (spawned by the listener) drains it.
246 otlp: Option<Arc<plecto_control::otlp::OtlpBuffer>>,
247 /// The graceful-shutdown drain flag (ADR 000039), cloned into every spawned upgrade tunnel
248 /// (ADR 000048) so an indefinite tunnel closes at drain instead of outliving the server.
249 drain: tokio::sync::watch::Receiver<bool>,
250 /// The readiness flag (ADR 000059): `true` for the serving lifetime, flipped to `false` at
251 /// the shutdown signal — BEFORE the drain starts — so `/readyz` tells the front load
252 /// balancer to remove this replica while it still accepts connections (the readiness grace).
253 ready: tokio::sync::watch::Receiver<bool>,
254}
255
256/// An upstream TCP connector with `TCP_NODELAY` set. A proxy must disable Nagle on its upstream
257/// sockets: with Nagle on, a streamed request body sent in several writes stalls ~40 ms on the
258/// peer's delayed-ACK timer (surfaced by the body benchmark as a p99 cliff on large streamed
259/// bodies). Disabling Nagle on proxy/upstream sockets is standard practice across mature L7 proxies.
260/// Both the forwarding clients and the health prober use it — plain, and wrapped by the rustls
261/// connector for a TLS upstream (ADR 000042), which is why `enforce_http` is off (the wrapping
262/// `HttpsConnector` dials `https://` URIs through it).
263pub(crate) fn upstream_connector() -> HttpConnector {
264 let mut c = HttpConnector::new();
265 c.set_nodelay(true);
266 c.enforce_http(false);
267 c
268}
269
270#[cfg(test)]
271mod alloc_tuning_tests {
272 #[cfg(all(target_os = "linux", target_env = "gnu"))]
273 #[test]
274 fn mallopt_arena_max_is_accepted_by_glibc() {
275 // Guards the FFI constant + linkage: glibc returns 1 when it accepts the option.
276 let rc = unsafe { libc::mallopt(libc::M_ARENA_MAX, 4) };
277 assert_eq!(
278 rc, 1,
279 "glibc mallopt(M_ARENA_MAX, 4) should return 1 on success"
280 );
281 }
282
283 #[test]
284 fn cap_is_a_noop_when_disabled() {
285 // 0 leaves glibc's default in place; must not panic (and compiles/no-ops off glibc).
286 super::apply_arena_cap(0);
287 }
288}
289
290#[cfg(all(test, unix))]
291mod nofile_tests {
292 #[test]
293 fn raises_soft_limit_to_match_the_hard_limit() {
294 // An unprivileged process may always raise its own soft limit up to the hard limit
295 // (POSIX) — every sandbox this test runs in must allow it, so a failure here is real.
296 let (soft, hard) =
297 super::unix_raise_nofile_limit().expect("getrlimit/setrlimit must succeed");
298 assert_eq!(
299 soft, hard,
300 "soft limit must be raised to match the hard limit"
301 );
302
303 // Calling it again is idempotent: the limit is already at its ceiling.
304 let (soft2, hard2) =
305 super::unix_raise_nofile_limit().expect("a second call must also succeed");
306 assert_eq!((soft2, hard2), (soft, hard));
307 }
308}