Skip to content

Commit d47a9f7

Browse files
MagicalTuxclaude
andcommitted
Per-SNI HTTP/3 over ACME via purecrypto::quic::peek_initial_sni (0.6.25)
- rt/quic.rs: CertSource { Static | Acme }. For ACME, each new QUIC connection peeks the SNI from the Initial (peek_initial_sni) and selects the cert with AcmeManager::choose_cached — a NON-blocking lookup (in-memory cache → disk) so the single QUIC event loop never stalls on issuance (the TCP path issues; browsers reach TCP first and upgrade via Alt-Svc). - AcmeManager::choose_cached: cached/on-disk cert only, no ACME round-trip. - run_h3 builds CertSource from .acme() (per-SNI) or .tls() (static); the http3_host hack stays gone. - CLI: HTTP/3 on by default for ACME too (http3_enabled includes accept_tos). Note: per-SNI h3 over ACME is gated on a purecrypto peek_initial_sni fix — it currently returns Ok(None) for a real curl/ngtcp2 1200-byte padded Initial (the full QuicConnection parses the same datagram fine), so the cert can't be selected yet. Static-cert HTTP/3 is unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 447242c commit d47a9f7

5 files changed

Lines changed: 98 additions & 25 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/acme/manager.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,40 @@ impl AcmeManager {
147147
}
148148
}
149149

150+
/// Like [`choose`](Self::choose) but **never blocks on ACME issuance** —
151+
/// it serves only a cert already cached or on disk. Used by the QUIC/HTTP-3
152+
/// runtime, whose single event loop must not stall on a multi-second
153+
/// issuance; the TCP path issues, and HTTP/3 picks the cert up once it
154+
/// exists (browsers reach TCP first and upgrade via `Alt-Svc`).
155+
pub fn choose_cached(&self, sni: Option<&str>, peer_is_loopback: bool) -> CertChoice {
156+
if peer_is_loopback {
157+
return CertChoice::Serve(self.self_signed());
158+
}
159+
let Some(host) = sni.map(normalize).filter(|h| !h.is_empty()) else {
160+
return CertChoice::Serve(self.self_signed());
161+
};
162+
if let Some(wl) = &self.inner.cfg.host_whitelist
163+
&& !wl.contains(&host)
164+
{
165+
return CertChoice::Reject;
166+
}
167+
if let Some(c) = self.inner.cache.lock().unwrap().get(&host).cloned() {
168+
return CertChoice::Serve(c.acceptor.clone());
169+
}
170+
match self.inner.store.load_cert(&host) {
171+
Ok(Some(stored)) => match TlsAcceptor::from_pem(&stored.chain_pem, &stored.key_pem) {
172+
Ok(acceptor) => {
173+
let not_after = cert_not_after(&stored.chain_pem);
174+
self.cache_put(&host, acceptor.clone(), not_after);
175+
CertChoice::Serve(acceptor)
176+
}
177+
Err(_) => CertChoice::Reject,
178+
},
179+
// Not issued yet: don't block the QUIC loop — let the TCP path issue.
180+
_ => CertChoice::Reject,
181+
}
182+
}
183+
150184
/// Return a ready acceptor for `host`, issuing or renewing as needed.
151185
fn get_or_issue(&self, host: &str) -> Result<TlsAcceptor> {
152186
let now = now_secs();

src/bin/httpsd.rs

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,6 @@ fn run() -> httpsd::Result<()> {
5959
}
6060
});
6161
eprintln!("httpsd: also serving HTTP/3 on udp/{addr}");
62-
} else if !opts.no_http3 && opts.acme_accept_tos && !opts.is_tls() {
63-
eprintln!(
64-
"httpsd: HTTP/3 not started — per-SNI HTTP/3 under ACME awaits QUIC SNI support; \
65-
use --tls-cert for HTTP/3"
66-
);
6762
}
6863

6964
let server = opts.build_server()?;
@@ -261,11 +256,11 @@ impl Options {
261256
Ok(server)
262257
}
263258

264-
/// Whether HTTP/3 should run: on by default with a static TLS cert, off via
265-
/// `--no-http3`. (Per-SNI HTTP/3 under ACME isn't available yet.)
259+
/// Whether HTTP/3 should run: on by default whenever HTTPS is served (a
260+
/// static cert or ACME), off via `--no-http3`.
266261
#[cfg(feature = "h3")]
267262
fn http3_enabled(&self) -> bool {
268-
!self.no_http3 && self.is_tls()
263+
!self.no_http3 && (self.is_tls() || self.acme_accept_tos)
269264
}
270265

271266
/// The port from the listen address (defaults to 443 if unparseable).

src/rt/mod.rs

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -278,28 +278,30 @@ impl Server {
278278
/// Run an HTTP/3 server on a QUIC/UDP event loop, listening on the same
279279
/// address(es) as the TCP server (but over UDP). HTTP/3 is always encrypted.
280280
///
281-
/// Uses the static [`tls`](Server::tls) acceptor's certificate. Per-SNI
282-
/// certificate selection over QUIC (the HTTP/3 equivalent of the TCP path's
283-
/// ClientHello peek) needs the QUIC engine to expose the SNI from the
284-
/// encrypted Initial packet before the handshake; until then, on-demand
285-
/// ACME certificates aren't available over HTTP/3. Blocks the calling thread.
281+
/// Under ACME, certificates are selected per-connection by peeking the SNI
282+
/// from the QUIC Initial (`purecrypto::quic::peek_initial_sni`); the QUIC
283+
/// loop serves already-issued certs (the TCP path does the issuing). With a
284+
/// static [`tls`](Server::tls) acceptor, that one certificate is used.
285+
/// Blocks the calling thread.
286286
#[cfg(feature = "h3")]
287287
pub fn run_h3(self) -> Result<()> {
288-
let acceptor = self.h3_acceptor()?;
288+
let certs = self.h3_cert_source()?;
289289
let cfg = self.session_config();
290-
quic::run(self.addrs.clone(), cfg, acceptor)
290+
quic::run(self.addrs.clone(), cfg, certs)
291291
}
292292

293293
#[cfg(feature = "h3")]
294-
fn h3_acceptor(&self) -> Result<TlsAcceptor> {
294+
fn h3_cert_source(&self) -> Result<quic::CertSource> {
295+
#[cfg(feature = "acme")]
296+
if let Some(mgr) = &self.acme {
297+
return Ok(quic::CertSource::Acme(mgr.clone()));
298+
}
295299
#[cfg(feature = "tls")]
296300
if let Some(acc) = &self.tls {
297-
return Ok(acc.clone());
301+
return Ok(quic::CertSource::Static(acc.clone()));
298302
}
299303
Err(Error::Config(
300-
"HTTP/3 needs a static TLS certificate (Server::tls); per-SNI HTTP/3 under ACME \
301-
awaits QUIC ClientHello/SNI peek support in purecrypto"
302-
.into(),
304+
"HTTP/3 requires TLS: a static cert via .tls(), or ACME via .acme()".into(),
303305
))
304306
}
305307
}

src/rt/quic.rs

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,44 @@ use crate::h3::H3Conn;
2020
use crate::session::SessionConfig;
2121
use crate::tls::TlsAcceptor;
2222

23+
#[cfg(feature = "acme")]
24+
use crate::acme::{AcmeManager, CertChoice};
25+
26+
/// Where the QUIC listener gets a certificate for each new connection.
27+
pub(crate) enum CertSource {
28+
/// One static certificate for every connection.
29+
Static(TlsAcceptor),
30+
/// Per-SNI certificates from ACME, selected by peeking the QUIC Initial.
31+
#[cfg(feature = "acme")]
32+
Acme(AcmeManager),
33+
}
34+
35+
impl CertSource {
36+
/// Choose the acceptor for a new connection given its first datagram.
37+
/// Returns `None` to drop the datagram (SNI not yet available, host not
38+
/// permitted, or no cert issued yet — the client retries / falls back).
39+
#[cfg_attr(not(feature = "acme"), allow(unused_variables))]
40+
fn acceptor_for(&self, peer: SocketAddr, first_datagram: &[u8]) -> Option<TlsAcceptor> {
41+
match self {
42+
CertSource::Static(acceptor) => Some(acceptor.clone()),
43+
#[cfg(feature = "acme")]
44+
CertSource::Acme(mgr) => {
45+
// The QUIC ClientHello rides in the encrypted Initial; peek its
46+
// SNI without committing to a connection.
47+
let info = match purecrypto::quic::peek_initial_sni(first_datagram) {
48+
Ok(Some(info)) => info,
49+
// Need the full Initial, or not a ClientHello — wait for retry.
50+
Ok(None) | Err(_) => return None,
51+
};
52+
match mgr.choose_cached(info.server_name.as_deref(), peer.ip().is_loopback()) {
53+
CertChoice::Serve(acceptor) => Some(acceptor),
54+
CertChoice::Reject => None,
55+
}
56+
}
57+
}
58+
}
59+
}
60+
2361
/// QUIC datagrams must fit a conservative MTU; 1350 is the usual safe ceiling,
2462
/// we read into a slightly larger buffer.
2563
const RECV_BUF: usize = 2048;
@@ -34,7 +72,7 @@ struct Conn {
3472
}
3573

3674
/// Bind a UDP socket and serve HTTP/3 until a fatal socket error.
37-
pub(crate) fn run(addrs: Vec<SocketAddr>, cfg: SessionConfig, acceptor: TlsAcceptor) -> Result<()> {
75+
pub(crate) fn run(addrs: Vec<SocketAddr>, cfg: SessionConfig, certs: CertSource) -> Result<()> {
3876
let socket = bind_first(&addrs)?;
3977
let start = Instant::now();
4078
let mut conns: HashMap<SocketAddr, Conn> = HashMap::new();
@@ -55,7 +93,7 @@ pub(crate) fn run(addrs: Vec<SocketAddr>, cfg: SessionConfig, acceptor: TlsAccep
5593
match socket.recv_from(&mut buf) {
5694
Ok((n, peer)) => {
5795
let data = buf[..n].to_vec();
58-
if let Err(e) = on_datagram(&socket, &mut conns, peer, &data, &cfg, &acceptor) {
96+
if let Err(e) = on_datagram(&socket, &mut conns, peer, &data, &cfg, &certs) {
5997
if cfg!(debug_assertions) {
6098
eprintln!("httpsd: h3 connection error from {peer}: {e}");
6199
}
@@ -90,9 +128,13 @@ fn on_datagram(
90128
peer: SocketAddr,
91129
data: &[u8],
92130
cfg: &SessionConfig,
93-
acceptor: &TlsAcceptor,
131+
certs: &CertSource,
94132
) -> Result<()> {
95133
if let std::collections::hash_map::Entry::Vacant(slot) = conns.entry(peer) {
134+
// New connection: pick the certificate from the Initial's SNI.
135+
let Some(acceptor) = certs.acceptor_for(peer, data) else {
136+
return Ok(()); // SNI unavailable / host not served — drop, client retries
137+
};
96138
let qcfg = acceptor.quic_config()?;
97139
let quic = QuicConnection::server(qcfg).map_err(qerr)?;
98140
slot.insert(Conn {

0 commit comments

Comments
 (0)