Skip to main content

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}