plecto_server/listener.rs
1//! The fast-path listener: bind, spawn the health supervisor + the HTTP/3 endpoint, and run the
2//! TCP accept loop. Each connection is HTTP/1.1, or HTTP/2 when TLS-ALPN negotiates `h2` (ADR
3//! 000015); the per-request handling (route → chain → forward) is shared with all transports via
4//! `proxy_core`. Graceful shutdown (ADR 000039 / 000059): when the caller's shutdown future
5//! resolves, `/readyz` flips not-ready and the readiness grace elapses (accepts continue, so a
6//! front LB can take the replica out of rotation first); then the accept loops stop and every
7//! connection is told to finish its in-flight work and close (HTTP/1.1 stops keep-alive, HTTP/2
8//! and HTTP/3 send GOAWAY), and connections still open at the drain window are cut.
9
10use std::future::Future;
11use std::net::SocketAddr;
12use std::sync::Arc;
13use std::time::Duration;
14
15use hyper::header::HeaderValue;
16use hyper::service::service_fn;
17use hyper_util::rt::{TokioExecutor, TokioIo};
18use plecto_control::Control;
19use tokio::net::TcpListener;
20use tokio::sync::{Semaphore, watch};
21use tokio::task::JoinSet;
22use tokio_rustls::TlsAcceptor;
23
24use crate::body::MAX_INFLIGHT_BODY_BUFFERS;
25use crate::conn_limit::PerIpConnLimit;
26use crate::dispatch::handle;
27use crate::error::ServerError;
28use crate::h3::{build_h3_endpoint, serve_h3};
29use crate::health::serve_health_checks;
30use crate::metrics::ServerMetrics;
31use crate::upstream_client::UpstreamClients;
32use crate::{MAX_CONCURRENT_STREAMS, MAX_CONNECTIONS, MAX_CONNECTIONS_PER_IP, ServerState, admin};
33
34/// Explicit cap on inbound request header lines. hyper's http1 default (~100) is documented
35/// as not API-stable, so pin it — as `MAX_CONCURRENT_STREAMS` already does for h2.
36const MAX_HEADERS: usize = 100;
37/// How long a connection may take to send its request headers before it is dropped (slowloris on
38/// headers). hyper enforces a header-read timeout ONLY when a timer is configured, so the
39/// server sets both the timer and this value rather than relying on the (timer-less, inert) default.
40const INBOUND_HEADER_READ_TIMEOUT: Duration = Duration::from_secs(15);
41
42/// HTTP/2 keep-alive ping cadence / response deadline: detects and closes connections whose peer
43/// is gone (dead NAT entry, vanished client) so they release their `MAX_CONNECTIONS` permits —
44/// h1 gets the same effect from `header_read_timeout` between requests; h3 from quinn's idle
45/// timeout. An idle-but-responsive h2 client keeps its connection (normal keep-alive behaviour).
46const H2_KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(20);
47const H2_KEEP_ALIVE_TIMEOUT: Duration = Duration::from_secs(20);
48
49/// Default drain window for graceful shutdown (ADR 000039), used when `[listen.drain] window_ms`
50/// is not declared (ADR 000059): generous enough for normal in-flight requests (the default
51/// per-try upstream timeout is 30 s too) and aligned with the common 30 s termination grace of
52/// process supervisors.
53pub const DEFAULT_DRAIN_DEADLINE: Duration = Duration::from_secs(30);
54
55/// Serve the fast path on an already-bound `listener` until it errors unrecoverably. Each accepted
56/// connection is handled on its own task; the protocol is HTTP/1.1, or HTTP/2 when TLS-ALPN
57/// negotiates `h2` (ADR 000015). A per-connection error is logged, not fatal. Bind with
58/// `TcpListener::bind` (the caller picks the addr, so a test can use an ephemeral `127.0.0.1:0`
59/// and read `local_addr`).
60///
61/// Public boundary stays `anyhow::Result` (bp-rust: typed errors are a library-internal
62/// convention, not a public-API commitment) — the internal `ServerError` is `pub(crate)`, so a
63/// caller in another crate could not even name it. `serve_inner` does the typed work.
64pub async fn serve(control: Arc<Control>, listener: TcpListener) -> anyhow::Result<()> {
65 serve_inner(control, listener, std::future::pending::<()>())
66 .await
67 .map_err(Into::into)
68}
69
70/// Serve like [`serve`], but run the graceful-shutdown sequence when `shutdown` resolves
71/// (ADR 000039 / 000059): `/readyz` flips not-ready, accepts continue for the readiness grace
72/// (`[listen.drain] readiness_grace_ms`, default zero), then the drain starts and in-flight
73/// connections get the drain window (`[listen.drain] window_ms`, default 30 s) before the
74/// stragglers are cut.
75pub async fn serve_with_shutdown(
76 control: Arc<Control>,
77 listener: TcpListener,
78 shutdown: impl Future<Output = ()> + Send,
79) -> anyhow::Result<()> {
80 serve_inner(control, listener, shutdown)
81 .await
82 .map_err(Into::into)
83}
84
85async fn serve_inner(
86 control: Arc<Control>,
87 listener: TcpListener,
88 shutdown: impl Future<Output = ()> + Send,
89) -> Result<(), ServerError> {
90 let tcp_addr = listener.local_addr().map_err(ServerError::Bind)?;
91
92 // HTTP/3 (ADR 000016): when QUIC TLS is configured (i.e. there is `[[tls]]`), bind an
93 // independent QUIC/UDP listener on the SAME port number as the TCP one and advertise it via
94 // `Alt-Svc` on TCP responses. No TLS → no h3 (QUIC requires TLS), and no `Alt-Svc`. The
95 // advertised port is `[listen] advertised_port` when set (container port mapping — the
96 // PUBLISHED port, field report §3.4), else the bound port.
97 let quic_cfg = control.quic_tls_config();
98 let advertised_port = control.advertised_port().unwrap_or_else(|| tcp_addr.port());
99 let alt_svc = quic_cfg
100 .as_ref()
101 .and_then(|_| HeaderValue::from_str(&format!("h3=\":{advertised_port}\"; ma=86400")).ok());
102
103 // OTLP export wiring (ADR 000040): grab the buffer + endpoint before `control` moves into
104 // the state; the pump task itself is spawned below, once the drain channel exists.
105 let otlp_export = control
106 .otlp_endpoint()
107 .map(str::to_string)
108 .zip(control.otlp_buffer());
109
110 // The drain flag (ADR 000039): flipped to `true` exactly once, at shutdown. Every connection
111 // task holds a receiver and gracefully closes its connection when it flips; the h3 loops send
112 // GOAWAY and stop accepting; spawned upgrade tunnels (ADR 000048) close. `false` for the
113 // serving lifetime.
114 let (drain_tx, drain_rx) = watch::channel(false);
115 // The readiness flag (ADR 000059): flipped to `false` at the shutdown signal, BEFORE the
116 // drain — `/readyz` goes 503 while accepts continue, so a front LB can take the replica out
117 // of rotation during the readiness grace.
118 let (ready_tx, ready_rx) = watch::channel(true);
119
120 let state = Arc::new(ServerState {
121 control,
122 clients: UpstreamClients::new(),
123 alt_svc,
124 conn_limit: Arc::new(Semaphore::new(MAX_CONNECTIONS)),
125 per_ip_conn_limit: Arc::new(PerIpConnLimit::new(MAX_CONNECTIONS_PER_IP)),
126 body_buffer_limit: Arc::new(Semaphore::new(MAX_INFLIGHT_BODY_BUFFERS)),
127 metrics: Arc::new(ServerMetrics::new()),
128 otlp: otlp_export.as_ref().map(|(_, buffer)| buffer.clone()),
129 drain: drain_rx.clone(),
130 ready: ready_rx,
131 });
132
133 // Drain settings (ADR 000059), captured once like the rest of `[listen]` (a reload does not
134 // change them): one window shared by every drain path — TCP in-flight, h3 GOAWAY, tunnels.
135 let drain_window = state
136 .control
137 .drain_window()
138 .unwrap_or(DEFAULT_DRAIN_DEADLINE);
139 let readiness_grace = state.control.readiness_grace();
140
141 // Admin endpoint (Stage A observability, ADR 000009): a SEPARATE listener for `/metrics` +
142 // liveness/readiness, bound only when `[observability] admin_addr` is set. A bad address disables
143 // it (logged) without affecting the data plane — observability never fails serving closed.
144 if let Some(admin_addr) = state.control.admin_addr() {
145 match admin_addr.parse::<SocketAddr>() {
146 Ok(addr) => {
147 tokio::spawn(admin::serve_admin(state.clone(), addr, drain_rx.clone()));
148 }
149 Err(e) => {
150 tracing::error!(addr = admin_addr, error = %e, "invalid observability.admin_addr; admin endpoint disabled");
151 }
152 }
153 }
154
155 // Active health checks (ADR 000017): a background supervisor probes each upstream instance and
156 // flips its healthy/unhealthy state, so the round-robin in `proxy_core` only ever picks live
157 // instances. Spawned like the reload loop — the server owns the task, Control owns the state.
158 // Every supervisor observes the drain flag: in the binary the process exits anyway, but an
159 // embedder calling `serve_with_shutdown` must not be left with orphan tasks probing upstreams
160 // and holding `Control` alive after serve returns.
161 tokio::spawn(serve_health_checks(state.control.clone(), drain_rx.clone()));
162
163 // Periodic DNS re-resolution of hostname upstreams (`resolve_interval_ms`): a second
164 // supervisor beside the health checks, swapping each resolving group's endpoint set in place
165 // (nginx `resolve` / Envoy STRICT_DNS shape). Idles cheaply when no upstream opts in.
166 tokio::spawn(crate::dns::serve_dns_refresh(
167 state.control.clone(),
168 drain_rx.clone(),
169 ));
170
171 // OTLP export pump (ADR 000040): drains the span buffer to the collector. The handle is kept
172 // (unlike the fire-and-forget tasks above) so shutdown can await its final flush.
173 let otlp_pump = otlp_export.map(|(endpoint, buffer)| {
174 tokio::spawn(crate::otlp::serve_otlp_export(
175 buffer,
176 endpoint,
177 drain_rx.clone(),
178 ))
179 });
180
181 // The handle is kept (like `otlp_pump`) so shutdown can await the h3 drain — otherwise the
182 // closing CONNECTION_CLOSE flush can be cut by process exit and peers idle out instead of
183 // learning of the close.
184 let h3_task = quic_cfg.and_then(|cfg| match build_h3_endpoint(cfg, tcp_addr) {
185 Ok(endpoint) => {
186 tracing::info!(port = tcp_addr.port(), "HTTP/3 (QUIC) listener bound");
187 Some(tokio::spawn(serve_h3(
188 state.clone(),
189 endpoint,
190 drain_rx.clone(),
191 drain_window,
192 )))
193 }
194 // a QUIC bind failure must not take down the TCP fast path; log and serve TCP only.
195 Err(e) => {
196 tracing::error!(error = %e, "failed to bind HTTP/3 listener; serving TCP only");
197 None
198 }
199 });
200
201 // PROXY protocol v2 (ADR 000057): read once at startup, like the bind itself — `[listen]`
202 // is fixed for the process lifetime, a reload does not change it. `Some` = every trusted
203 // peer must present a v2 header (and only trusted peers may), `None` = feature off.
204 // `Arc` so the per-connection capture below is a refcount bump, not a CIDR-list clone.
205 let proxy_trust = state.control.proxy_protocol_trust().map(Arc::new);
206 if proxy_trust.is_some() {
207 tracing::info!(
208 "PROXY protocol v2 enabled on the TCP listener (the h3/UDP listener is not covered — ADR 000057)"
209 );
210 }
211
212 // The readiness contract (ADR 000059): the caller's shutdown signal first flips `/readyz`
213 // to not-ready, then — while the accept loops keep serving — waits the readiness grace so
214 // the front LB stops routing here, and only then lets the drain begin. Zero grace (the
215 // default) collapses to "not-ready and drain in the same instant".
216 let shutdown = async move {
217 shutdown.await;
218 let _ = ready_tx.send(false);
219 if !readiness_grace.is_zero() {
220 tracing::info!(
221 grace_ms = readiness_grace.as_millis() as u64,
222 "shutdown signal: /readyz is not-ready; accepting through the readiness grace"
223 );
224 tokio::time::sleep(readiness_grace).await;
225 }
226 };
227
228 // Connection tasks live in a JoinSet so the drain deadline can cut the stragglers
229 // (`abort_all`). Finished tasks are reaped opportunistically in the accept loop below.
230 let mut conns = JoinSet::new();
231 tokio::pin!(shutdown);
232 loop {
233 // Acquire a connection permit BEFORE accepting: at saturation we stop pulling
234 // connections off the backlog (backpressure) rather than spawning tasks without bound. The
235 // permit is moved into the connection task and released when it ends.
236 let permit = tokio::select! {
237 _ = &mut shutdown => break,
238 // reap finished connection tasks so the JoinSet does not grow unboundedly
239 Some(_) = conns.join_next() => continue,
240 permit = state.conn_limit.clone().acquire_owned() => match permit {
241 Ok(p) => p,
242 Err(_) => return Ok(()), // semaphore closed → stop serving
243 },
244 };
245 let (mut stream, peer) = tokio::select! {
246 _ = &mut shutdown => break,
247 Some(_) = conns.join_next() => continue, // the permit is re-acquired next iteration
248 accepted = listener.accept() => match accepted {
249 Ok(pair) => pair,
250 // a transient accept error (e.g. fd exhaustion) must not kill the listener.
251 Err(e) => {
252 tracing::warn!(error = %e, "accept failed");
253 continue;
254 }
255 },
256 };
257 // Disable Nagle downstream, symmetric with `upstream_connector`: a streamed response
258 // relayed to the client in several writes must not stall on the peer's delayed-ACK timer.
259 // A failure is survivable but operationally meaningful (Nagle staying on reproduces the
260 // documented ~40 ms p99 cliff), so it is logged instead of discarded.
261 if let Err(e) = stream.set_nodelay(true) {
262 tracing::debug!(error = %e, "set_nodelay failed; Nagle stays on for this connection");
263 }
264 let state = state.clone();
265 // The TLS config is read PER accept (ADR 000014): a reload's new certs apply to new
266 // connections, while in-flight ones keep the cert they negotiated with. `None` → plain.
267 let tls = state.control.tls_config();
268 let drain = drain_rx.clone();
269 let proxy_trust = proxy_trust.clone();
270 conns.spawn(async move {
271 let _permit = permit; // released when this connection task ends
272 // PROXY v2 (ADR 000057) sits below TLS: consume (trusted) or peek (untrusted) the
273 // header BEFORE the handshake, and let the restored peer replace `peer` for every
274 // downstream consumer (rate-limit key, X-Forwarded-*, Maglev SourceIp, access log).
275 // Any receipt-rule violation cuts the connection (fail-closed), with the fault code.
276 let peer = match &proxy_trust {
277 Some(trust) => {
278 match crate::proxy_protocol::resolve_peer(
279 &mut stream,
280 peer,
281 trust,
282 INBOUND_HEADER_READ_TIMEOUT,
283 )
284 .await
285 {
286 Ok(resolved) => resolved,
287 Err(fault) => {
288 tracing::warn!(peer = %peer, fault = %fault, "proxy-protocol: connection rejected");
289 return;
290 }
291 }
292 }
293 None => peer,
294 };
295 // Per-IP admission (CWE-770/CWE-400): keyed on the RESOLVED peer, not the raw accept()
296 // address — with PROXY v2 trust configured, the raw peer is the load balancer's own
297 // address, and every connection behind it would collide on one slot. Checked here
298 // (post-resolution, pre-TLS) rather than before accept: the real client is only known
299 // once PROXY v2 (if any) has been read off the wire.
300 let Some(_ip_guard) = PerIpConnLimit::try_acquire(&state.per_ip_conn_limit, peer.ip())
301 else {
302 tracing::debug!(peer = %peer, "per-IP connection limit reached; refusing connection");
303 return;
304 };
305 match tls {
306 // The handshake is bounded like the h1 header read (pre-TLS slowloris): a client
307 // that connects and never completes its ClientHello would otherwise hold this
308 // task — and its `MAX_CONNECTIONS` permit — forever.
309 Some(cfg) => match tokio::time::timeout(
310 INBOUND_HEADER_READ_TIMEOUT,
311 TlsAcceptor::from(cfg).accept(stream),
312 )
313 .await
314 {
315 Err(_elapsed) => {
316 tracing::debug!(peer = %peer, "TLS handshake timed out");
317 }
318 Ok(Ok(tls_stream)) => {
319 // ALPN picks the protocol: `h2` → HTTP/2, anything else (`http/1.1`, or no
320 // ALPN) → HTTP/1.1 (ADR 000015 — h2 over TLS+ALPN only). The connection
321 // terminated TLS, so the chain sees `https`.
322 let h2 = tls_stream.get_ref().1.alpn_protocol() == Some(b"h2".as_ref());
323 serve_conn(state, TokioIo::new(tls_stream), "https", h2, peer, drain).await;
324 }
325 // a failed TLS handshake (incl. ALPN mismatch) just drops the connection
326 // (fail-closed; nothing is forwarded), it is not a server error.
327 Ok(Err(e)) => tracing::debug!(error = %e, "TLS handshake failed"),
328 },
329 // plaintext: HTTP/1.1 only — no h2c / prior-knowledge (ADR 000015). `http` scheme.
330 None => serve_conn(state, TokioIo::new(stream), "http", false, peer, drain).await,
331 }
332 });
333 }
334
335 // Graceful shutdown (ADR 000039): stop accepting (drop the listener), flip the drain flag so
336 // every connection finishes its in-flight work and closes, then wait for ALL connection
337 // permits (TCP + QUIC share `conn_limit`) to come home — bounded by the drain deadline, after
338 // which the stragglers are cut.
339 drop(listener);
340 let _ = drain_tx.send(true);
341 let all_drained = state.conn_limit.acquire_many(MAX_CONNECTIONS as u32);
342 if tokio::time::timeout(drain_window, all_drained)
343 .await
344 .is_ok()
345 {
346 tracing::info!("graceful shutdown: all connections drained");
347 } else {
348 tracing::warn!(
349 deadline_ms = drain_window.as_millis() as u64,
350 "graceful shutdown: drain window expired; cutting remaining connections"
351 );
352 conns.abort_all();
353 }
354 // Await the h3 drain (bounded by its own window + a beat): serve_h3 closes its endpoint and
355 // flushes CONNECTION_CLOSE frames; returning before that flush completes would cut it in the
356 // binary (process exit) and leave h3 peers idling out instead of learning of the close.
357 if let Some(h3) = h3_task {
358 let _ = tokio::time::timeout(drain_window + Duration::from_secs(1), h3).await;
359 }
360 // Flush the OTLP queue before returning (the spec's Shutdown-includes-ForceFlush): the pump
361 // saw the same drain flip and is flushing under its own deadline; give it that long plus a
362 // beat, then move on — telemetry never holds the process open indefinitely.
363 if let Some(pump) = otlp_pump {
364 let _ = tokio::time::timeout(
365 crate::otlp::SHUTDOWN_FLUSH_DEADLINE + Duration::from_secs(1),
366 pump,
367 )
368 .await;
369 }
370 Ok(())
371}
372
373/// Resolve when the drain flag flips to `true` — or when the sender is gone (serve returned),
374/// which closes the same way. A helper rather than an inline `wait_for` in `select!` arms:
375/// `wait_for` yields a `watch::Ref` (a lock guard, not `Send`), and dropping it INSIDE this fn
376/// keeps the surrounding connection future `Send` (spawnable).
377pub(crate) async fn drained(drain: &mut watch::Receiver<bool>) {
378 let _ = drain.wait_for(|d| *d).await;
379}
380
381/// Serve one connection: HTTP/2 when `h2` (the ALPN result), HTTP/1.1 otherwise. `scheme` is the
382/// connection's wire scheme, passed through to the chain. Request handling (route → chain →
383/// forward) is identical across protocols; only the wire framing differs — for h2 the multiplexed
384/// streams each become one transaction, capped at `MAX_CONCURRENT_STREAMS` (ADR 000015). When the
385/// drain flag flips (ADR 000039), the connection finishes its in-flight requests and closes —
386/// hyper's `graceful_shutdown` disables HTTP/1.1 keep-alive (an idle connection closes at once)
387/// and sends the HTTP/2 GOAWAY.
388async fn serve_conn<I>(
389 state: Arc<ServerState>,
390 io: I,
391 scheme: &'static str,
392 h2: bool,
393 peer: SocketAddr,
394 mut drain: watch::Receiver<bool>,
395) where
396 I: hyper::rt::Read + hyper::rt::Write + Unpin + Send + 'static,
397{
398 let service = service_fn(move |req| handle(state.clone(), scheme, peer, req));
399 let result = if h2 {
400 let conn = hyper::server::conn::http2::Builder::new(TokioExecutor::new())
401 .max_concurrent_streams(MAX_CONCURRENT_STREAMS)
402 // Detect dead h2 peers (the h2 sibling of h1's `header_read_timeout`): without a
403 // ping-based keep-alive, a client that completes the preface and vanishes holds its
404 // connection permit until the process exits. hyper's keep-alive only runs with a
405 // timer configured, so the timer is set here exactly like the h1 branch below.
406 .timer(hyper_util::rt::TokioTimer::new())
407 .keep_alive_interval(Some(H2_KEEP_ALIVE_INTERVAL))
408 .keep_alive_timeout(H2_KEEP_ALIVE_TIMEOUT)
409 .serve_connection(io, service);
410 tokio::pin!(conn);
411 tokio::select! {
412 res = conn.as_mut() => res,
413 // `wait_for` also completes when the sender is gone (serve returned) — same close.
414 _ = drained(&mut drain) => {
415 conn.as_mut().graceful_shutdown();
416 conn.await
417 }
418 }
419 } else {
420 let conn = hyper::server::conn::http1::Builder::new()
421 // enforce a header-read timeout (slowloris on headers) and an explicit
422 // header-count cap. The header-read timeout only fires with a timer configured, so set
423 // both rather than relying on hyper's timer-less (inert) default.
424 .timer(hyper_util::rt::TokioTimer::new())
425 .header_read_timeout(INBOUND_HEADER_READ_TIMEOUT)
426 .max_headers(MAX_HEADERS)
427 .serve_connection(io, service)
428 // Upgrade support (ADR 000048): without this, the OnUpgrade a 101 fulfils would
429 // error and no tunnel could ever splice. h1 only — h2/h3 use (extended) CONNECT.
430 .with_upgrades();
431 tokio::pin!(conn);
432 tokio::select! {
433 res = conn.as_mut() => res,
434 _ = drained(&mut drain) => {
435 conn.as_mut().graceful_shutdown();
436 conn.await
437 }
438 }
439 };
440 if let Err(e) = result {
441 tracing::debug!(error = %e, "connection closed with error");
442 }
443}