dynamic_config_server/serve.rs
1//! Serving the router over TLS.
2//!
3//! # Why this is fifty lines here rather than a dependency
4//!
5//! `axum-server` is the usual shape and was rejected. The accept loop is the
6//! part of a TLS server where the decisions that matter are made, and all of
7//! them are this crate's to make:
8//!
9//! - **A failed handshake must cost one connection, never the listener.**
10//! With client certificates required, failed handshakes are *normal
11//! traffic* — every port scanner and every health checker that does not
12//! know about the certificate produces one.
13//! - **A connection must not be able to hold a slot forever.** A client that
14//! opens a socket and sends nothing is free for the client and not free
15//! for the server, so the handshake has a deadline
16//! ([`HANDSHAKE_TIMEOUT`]) — and so do the request headers after it
17//! ([`HEADER_TIMEOUT`]), because a client that completes a handshake and
18//! then goes quiet costs exactly as much as one that never handshook.
19//! - **Shutdown must end.** One endpoint here answers with a body that
20//! never ends, so a drain that waits for every response body waits for
21//! every subscriber to leave; [`DRAIN_TIMEOUT`](crate::DRAIN_TIMEOUT) is what keeps a rollout
22//! from hanging on its own change stream.
23//! - **A refused handshake must be recorded, and recorded saying nothing.**
24//! It goes through the same [`AuditSink`](crate::AuditSink) as everything
25//! else, as `endpoint=tls outcome=unauthenticated`, with no caller, no
26//! subject and no reason. An operator who has misconfigured a client CA
27//! needs to see *that* handshakes are failing; nobody needs to see whose.
28//!
29//! `axum-server` would own every one of them and expose none, and its
30//! `tls-rustls` feature selects the `aws-lc-rs` provider — a vendored copy
31//! of AWS-LC, built with cmake — where this crate wants `ring`, which is
32//! already in the workspace's graph. What is used instead is `tokio-rustls`
33//! for the handshake and hyper's own HTTP/1 connection for what follows:
34//! the same two pieces `axum::serve` uses, with the acceptor spliced in
35//! between.
36//!
37//! The rustls configuration itself is *not* built here: it comes from
38//! [`Tls`](crate::tls::Tls), where the key loading, the permission refusal
39//! and the client verifier live.
40
41use std::future::Future;
42use std::io;
43use std::sync::Arc;
44use std::time::Duration;
45
46use axum::Router;
47use hyper::server::conn::http1;
48use hyper_util::rt::{TokioIo, TokioTimer};
49use hyper_util::service::TowerToHyperService;
50use tokio::net::{TcpListener, TcpStream};
51use tokio::sync::watch;
52use tokio::task::JoinSet;
53use tokio_rustls::TlsAcceptor;
54
55use crate::audit::{AuditEntry, AuditSink, Outcome};
56use crate::server::Server;
57
58/// How long a client has to complete a TLS handshake.
59///
60/// Generous for a handshake and short for a socket somebody is squatting on.
61/// It is not a configuration key: a deployment that needs a different number
62/// has an idle timeout of its own in front, and this one exists so that a
63/// server with no such thing in front is still not held open by a client
64/// that connects and says nothing.
65pub const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
66
67/// How long a connection has, after the handshake, to send request headers.
68///
69/// The handshake deadline covers TLS and stops there. Without this one, a
70/// client that completes a handshake and then sends no request bytes — or
71/// drips an incomplete header a byte at a time — holds a socket and a task
72/// for as long as it likes, which is the exhaustion
73/// [`HANDSHAKE_TIMEOUT`] exists to prevent, moved one step further in.
74///
75/// It bounds *headers*, not the request as a whole: a stream's response body
76/// is meant to run for days, and this deadline stops before the first byte
77/// of one is written.
78pub const HEADER_TIMEOUT: Duration = Duration::from_secs(15);
79
80/// How long the accept loop waits after an accept error before trying again.
81///
82/// An `accept` that fails because the process is out of file descriptors
83/// fails again immediately, and a loop that retries instantly turns a
84/// resource limit into a spin. Ten milliseconds is invisible to a client and
85/// is the difference between busy-waiting and waiting.
86const ACCEPT_BACKOFF: Duration = Duration::from_millis(10);
87
88/// Serves `router` over TLS until `shutdown` completes.
89///
90/// The TLS material and the audit sink both come from `server`, which is the
91/// same one the router was built over; `router` is passed separately so that
92/// an embedder can wrap it — a layer of its own, a different fallback — the
93/// way it can with [`axum::serve()`].
94///
95/// Shutdown is graceful in both halves: the listener stops accepting, and
96/// every connection already open is told to finish the request it is serving
97/// and close. A config server is restarted by a rollout, and dropping the
98/// fetch a pod is making at that moment would make a rollout look like a
99/// configuration failure to whoever is starting up beside it.
100///
101/// Graceful, and bounded: a connection that has not finished within
102/// [`DRAIN_TIMEOUT`](crate::DRAIN_TIMEOUT) is dropped. An open change stream has no end of its
103/// own, so an unbounded drain would be a rollout that waits for its
104/// subscribers rather than the other way round.
105///
106/// # Errors
107///
108/// Only what the listener itself reports. A connection that fails — a
109/// handshake that was refused, a client that went away — ends that
110/// connection and nothing else.
111///
112/// # Panics
113///
114/// If `server` is not configured for TLS. Callers reach this function
115/// through [`Server::tls`], which is `None` exactly when that is so.
116pub async fn serve_tls<S>(
117 listener: TcpListener,
118 router: Router,
119 server: &Server,
120 shutdown: S,
121) -> io::Result<()>
122where
123 // No `'static`: the shutdown future is awaited here rather than spawned,
124 // so it may borrow whatever the caller has to hand — a `CancellationToken`
125 // it owns, a channel on its own stack.
126 S: Future<Output = ()> + Send,
127{
128 let tls = server
129 .tls()
130 .expect("serve_tls is reached through Server::tls, which is None without a `[server.tls]`");
131 let acceptor = TlsAcceptor::from(tls.server_config());
132 let audit = server.audit_sink();
133
134 // `false` until the shutdown future completes, at which point every
135 // connection task sees it and finishes what it is doing.
136 let (closing, closed) = watch::channel(false);
137 let mut connections = JoinSet::new();
138
139 tokio::pin!(shutdown);
140
141 loop {
142 tokio::select! {
143 () = &mut shutdown => break,
144 accepted = listener.accept() => {
145 match accepted {
146 Ok((stream, _peer)) => {
147 connections.spawn(connection(
148 acceptor.clone(),
149 stream,
150 router.clone(),
151 Arc::clone(&audit),
152 closed.clone(),
153 ));
154 }
155 // Per-connection, not per-server: a client that went away
156 // between the SYN and the accept, or a descriptor limit.
157 Err(_) => tokio::time::sleep(ACCEPT_BACKOFF).await,
158 }
159 }
160 // Reaping is part of the loop rather than a task of its own, so
161 // that a long-lived server does not accumulate a `JoinSet` entry
162 // per connection it has ever served.
163 Some(_) = connections.join_next(), if !connections.is_empty() => {}
164 }
165 }
166
167 // The listener first: a client connecting during shutdown gets a refused
168 // connection, which it will retry, rather than a connection that is
169 // accepted and then closed unanswered.
170 drop(listener);
171 let _ = closing.send(true);
172
173 while connections.join_next().await.is_some() {}
174
175 Ok(())
176}
177
178/// One connection: handshake, then HTTP until it ends or shutdown does.
179async fn connection(
180 acceptor: TlsAcceptor,
181 stream: TcpStream,
182 router: Router,
183 audit: Arc<dyn AuditSink>,
184 mut closed: watch::Receiver<bool>,
185) {
186 let handshake = tokio::time::timeout(HANDSHAKE_TIMEOUT, acceptor.accept(stream)).await;
187
188 let stream = match handshake {
189 Ok(Ok(stream)) => stream,
190 // Both failures are recorded identically and say nothing about the
191 // certificate that was or was not presented. A refused handshake is
192 // a caller that presented no usable credential, which is an outcome
193 // this log already has a word for.
194 Ok(Err(_)) | Err(_) => {
195 audit.record(&AuditEntry {
196 caller: None,
197 application: None,
198 profile: None,
199 endpoint: "tls",
200 outcome: Outcome::Unauthenticated,
201 generation: None,
202 });
203
204 return;
205 }
206 };
207
208 let service = TowerToHyperService::new(router);
209
210 let mut builder = http1::Builder::new();
211
212 // The timer is not a detail: hyper reads deadlines from the one the
213 // builder was given, and a `header_read_timeout` set without one is
214 // silently not enforced.
215 builder
216 .timer(TokioTimer::new())
217 .header_read_timeout(HEADER_TIMEOUT);
218
219 let connection = builder.serve_connection(TokioIo::new(stream), service);
220
221 tokio::pin!(connection);
222
223 tokio::select! {
224 _ = connection.as_mut() => {}
225 _ = closed.changed() => {
226 // Finish the request in flight, refuse to start another, close
227 // — but not for longer than `DRAIN_TIMEOUT`, because a stream's
228 // body has no end of its own and would otherwise hold the
229 // rollout open until its client happened to leave.
230 connection.as_mut().graceful_shutdown();
231 let _ = tokio::time::timeout(crate::DRAIN_TIMEOUT, connection).await;
232 }
233 }
234}