seq_runtime/tls.rs
1//! TLS client for Seq.
2//!
3//! Wraps a connected `may::net::TcpStream` in a `rustls::ClientConnection`
4//! and stores the result in the shared `STREAMS` registry as
5//! `StreamKind::Tls`. Existing `net.tcp.read` / `net.tcp.write` /
6//! `net.tcp.close` builtins dispatch over the `StreamKind` enum
7//! transparently — the user upgrades a Socket and keeps using it.
8//!
9//! ## Surface
10//!
11//! `net.tls.client ( Socket String -- Socket Bool )` — consumes a
12//! connected TCP socket and a hostname, returns the *same* Socket id
13//! now pointing at a TLS-wrapped stream. The hostname drives SNI and
14//! webpki certificate validation; trust roots come from `webpki-roots`.
15//!
16//! ## Handshake timing
17//!
18//! Eager: the handshake completes inside this builtin via
19//! `conn.complete_io(&mut tcp)`. A bad cert, expired cert, hostname
20//! mismatch, or any other TLS-layer error surfaces as
21//! `(0, false)` — matching the way every other fallible Seq
22//! networking word reports failure. A subsequent `net.tcp.read` reads
23//! application data only.
24//!
25//! ## Known limitations (v1)
26//!
27//! - `net.tcp.close` on a TLS-wrapped socket is a *hard* close — the
28//! underlying `TcpStream` is dropped without first sending the TLS
29//! `close_notify` alert. RFC 5246 expects clients to send the alert
30//! before closing; modern servers tolerate truncation but some older
31//! stacks log it as a truncation-attack indicator. A graceful-shutdown
32//! variant is a planned follow-up.
33//! - No client-certificate authentication (mTLS).
34//! - No caller-side ALPN selection — rustls defaults apply.
35//! - No way to inspect the negotiated cipher / peer certificate from
36//! Seq. Planned follow-ups once the four-layer stack stabilises.
37
38use crate::http_client::conn::Conn;
39use crate::stack::{Stack, pop, push};
40use crate::value::Value;
41use rustls::pki_types::ServerName;
42use rustls::{ClientConfig, ClientConnection, RootCertStore, StreamOwned};
43#[cfg(test)]
44use std::sync::Mutex;
45use std::sync::{Arc, LazyLock};
46
47/// Process-wide TLS client config. Trust roots are the Mozilla CA
48/// bundle shipped by `webpki-roots`; the `ring` crypto provider is
49/// installed defensively here so we don't depend on rustls's
50/// crate-features auto-install — if any transitive dep ever enables
51/// `aws_lc_rs` alongside `ring`, the auto-install path would panic at
52/// first use ("multiple default providers"). Cached for the process
53/// lifetime — the `Arc<ClientConfig>` is cheap to clone into each
54/// handshake.
55static TLS_CONFIG: LazyLock<Arc<ClientConfig>> = LazyLock::new(|| {
56 // Ignore the "already installed" Err — if another module beat us
57 // to it (or the crate-features path raced us), the provider is
58 // still ring, which is the only one this build pulls.
59 let _ = rustls::crypto::ring::default_provider().install_default();
60
61 let mut roots = RootCertStore::empty();
62 roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
63 let config = ClientConfig::builder()
64 .with_root_certificates(roots)
65 .with_no_client_auth();
66 Arc::new(config)
67});
68
69// -----------------------------------------------------------------------------
70// Test-only trust-root override
71// -----------------------------------------------------------------------------
72//
73// Lets the happy-path TLS integration test install a `ClientConfig`
74// whose trust roots include the test's self-signed CA. Without the
75// override, the handshake would fail validation (the CA isn't in
76// webpki-roots). Production builds never see this hook.
77
78#[cfg(test)]
79static TEST_TLS_CONFIG: LazyLock<Mutex<Option<Arc<ClientConfig>>>> =
80 LazyLock::new(|| Mutex::new(None));
81
82#[cfg(test)]
83pub(crate) fn install_test_tls_config(cfg: Arc<ClientConfig>) {
84 *TEST_TLS_CONFIG.lock().unwrap() = Some(cfg);
85}
86
87#[cfg(test)]
88pub(crate) fn clear_test_tls_config() {
89 *TEST_TLS_CONFIG.lock().unwrap() = None;
90}
91
92/// Returns whichever `ClientConfig` should drive the next handshake:
93/// the test override if one is installed, otherwise the
94/// `webpki-roots`-backed production config.
95fn current_tls_config() -> Arc<ClientConfig> {
96 #[cfg(test)]
97 if let Some(cfg) = TEST_TLS_CONFIG.lock().unwrap().as_ref() {
98 return cfg.clone();
99 }
100 TLS_CONFIG.clone()
101}
102
103/// Upgrade a connected Socket to TLS.
104///
105/// Stack effect: `( Socket String -- Socket Bool )` — top of stack
106/// is the hostname (String), with the existing TCP socket id beneath
107/// it. On success, returns `(socket_id, true)` where `socket_id` is
108/// the *same* id the caller passed in: the registry slot is upgraded
109/// in place from `Tcp` to `Tls`, so any caller-side data structures
110/// keyed on the socket id remain valid. On failure (empty hostname,
111/// type mismatch, wrong-kind socket, handshake error, no slot found),
112/// returns `(0, false)`; on the failure paths that already took the
113/// stream out of the registry, the underlying socket is closed (the
114/// `TcpStream` is dropped) and the slot is freed.
115///
116/// # Safety
117/// Stack must have a String (hostname) on top of a Socket (Int).
118#[unsafe(no_mangle)]
119pub unsafe extern "C" fn patch_seq_tls_client(stack: Stack) -> Stack {
120 unsafe {
121 let (stack, host_val) = pop(stack);
122 let host = match host_val {
123 Value::String(s) => s,
124 _ => return push_failure(stack),
125 };
126 let (stack, sock_val) = pop(stack);
127 let socket_id = match sock_val {
128 Value::Int(id) => id as usize,
129 _ => return push_failure(stack),
130 };
131 let hostname = host.as_str_or_empty().to_string();
132 if hostname.is_empty() {
133 return push_failure(stack);
134 }
135
136 // The take/reinstall/free dance against the STREAMS registry
137 // lives inside tcp.rs so the "slot is reserved across a
138 // strand-yielding operation" invariant stays co-located with
139 // the registry itself. We just provide the callback that
140 // produces the TLS-wrapped stream.
141 let ok = crate::tcp::upgrade_tcp_in_place(socket_id, |tcp| build_tls(tcp, hostname));
142 if !ok {
143 return push_failure(stack);
144 }
145 let stack = push(stack, Value::Int(socket_id as i64));
146 push(stack, Value::Bool(true))
147 }
148}
149
150/// Build a fully-handshaked TLS stream over `tcp`. The TCP stream is
151/// consumed regardless of outcome — on Err, it is dropped (which
152/// closes the socket). The hostname is moved in: rustls's
153/// `ServerName<'static>` takes an owned `String`, so threading the
154/// caller's owned hostname through avoids a redundant clone.
155/// Per-handshake read/write timeout in milliseconds. Default 10 000ms.
156///
157/// Bounds each individual read/write inside `complete_io`. rustls
158/// has no native deadline knob; the underlying stream's per-op
159/// timeout is what catches a peer that stops responding mid-handshake.
160/// A handshake with many small rounds takes at most N × timeout, but
161/// any single stall lasting longer than `TLS_HANDSHAKE_TIMEOUT`
162/// surfaces as a handshake error.
163const DEFAULT_TLS_HANDSHAKE_TIMEOUT_MS: u64 = 10_000;
164
165static TLS_HANDSHAKE_TIMEOUT: LazyLock<std::time::Duration> = LazyLock::new(|| {
166 let ms = std::env::var("SEQ_TLS_HANDSHAKE_TIMEOUT_MS")
167 .ok()
168 .and_then(|v| v.parse::<u64>().ok())
169 .filter(|n| *n > 0)
170 .unwrap_or(DEFAULT_TLS_HANDSHAKE_TIMEOUT_MS);
171 std::time::Duration::from_millis(ms)
172});
173
174/// Test-only override for `TLS_HANDSHAKE_TIMEOUT`. When `Some`, takes
175/// precedence over the LazyLock-cached value. Mirrors the HTTP-side
176/// hook in `request::set_test_http_request_timeout`.
177#[cfg(test)]
178static TLS_HANDSHAKE_TIMEOUT_OVERRIDE: Mutex<Option<std::time::Duration>> = Mutex::new(None);
179
180#[cfg(test)]
181pub(crate) fn set_test_tls_handshake_timeout(dur: Option<std::time::Duration>) {
182 *TLS_HANDSHAKE_TIMEOUT_OVERRIDE.lock().unwrap() = dur;
183}
184
185fn tls_handshake_timeout() -> std::time::Duration {
186 #[cfg(test)]
187 if let Some(dur) = *TLS_HANDSHAKE_TIMEOUT_OVERRIDE.lock().unwrap() {
188 return dur;
189 }
190 *TLS_HANDSHAKE_TIMEOUT
191}
192
193fn build_tls(
194 mut tcp: may::net::TcpStream,
195 hostname: String,
196) -> Result<StreamOwned<ClientConnection, may::net::TcpStream>, ()> {
197 // Bound each individual read/write inside the handshake. rustls's
198 // complete_io calls plain `read`/`write` on the wrapped stream,
199 // so a may::net read/write timeout is what catches a peer that
200 // goes silent partway through.
201 let handshake_timeout = Some(tls_handshake_timeout());
202 tcp.set_read_timeout(handshake_timeout).map_err(|_| ())?;
203 tcp.set_write_timeout(handshake_timeout).map_err(|_| ())?;
204
205 let server_name = ServerName::try_from(hostname).map_err(|_| ())?;
206 let mut conn = ClientConnection::new(current_tls_config(), server_name).map_err(|_| ())?;
207 conn.complete_io(&mut tcp).map_err(|_| ())?;
208
209 // Reset timeouts before handing the stream over. The application
210 // IO phase (HTTP request / response) sets its own per-op timeout
211 // from a different env var — leaving the handshake's short
212 // deadline in place would cap every subsequent read/write at the
213 // handshake's bound, which is the wrong budget for app traffic.
214 //
215 // Errors from set_*_timeout(None) are intentionally swallowed.
216 // `setsockopt(SO_*TIMEO)` on a healthy fd that just completed a
217 // handshake essentially can't fail; the only realistic failure
218 // mode is the fd being closed concurrently, in which case the
219 // returned stream is already dead and the next read/write will
220 // surface that. Propagating the clear-failure here would mask the
221 // underlying state with a synthetic handshake error. HTTP-client
222 // callers also re-set the timeout per request in `run_once`, so
223 // a stale handshake deadline can't leak into their app IO.
224 let _ = tcp.set_read_timeout(None);
225 let _ = tcp.set_write_timeout(None);
226 Ok(StreamOwned::new(conn, tcp))
227}
228
229/// Variant of `build_tls` exposed to the HTTP client. Returns the
230/// handshaked stream already type-erased as `Conn` (a
231/// `Box<dyn HttpStream + Send>`).
232///
233/// Why erase here: the `Box::new(stream) as Conn` cast emits the
234/// vtable for `dyn HttpStream` over `StreamOwned<ClientConnection,
235/// TcpStream>`, and *that* vtable references rustls's drop chain.
236/// Keeping the cast inside `tls.rs` means the vtable is reachable
237/// only via `dial_tls`, which is itself reachable only via the HTTP
238/// client's HTTPS path. When no Seq program reaches that path,
239/// `--gc-sections` strips the vtable and the rustls drop chain
240/// disappears from the binary. The HTTP client never holds a
241/// concretely-typed TLS stream — it only ever sees `Conn`.
242pub(crate) fn dial_tls(tcp: may::net::TcpStream, hostname: String) -> Result<Conn, ()> {
243 let stream = build_tls(tcp, hostname)?;
244 Ok(Box::new(stream) as Conn)
245}
246
247unsafe fn push_failure(stack: Stack) -> Stack {
248 unsafe {
249 let stack = push(stack, Value::Int(0));
250 push(stack, Value::Bool(false))
251 }
252}