diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index 06d4c66fa7cf..150dac4f9e73 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -2834,6 +2834,39 @@ void us_listen_socket_remove_server_name(struct us_listen_socket_t *ls, sni_node_destructor(node); } +int us_listen_socket_set_ssl_ctx(struct us_listen_socket_t *ls, SSL_CTX *ctx, + const char *hostname_pattern, int force_sni) { + if (!ls->ssl_ctx) return 0; + SSL_CTX *old = ls->ssl_ctx; + + /* Every reference is taken before the matching one is dropped, so `ctx == + * old` would be a no-op rather than a use-after-free. */ + SSL_CTX_up_ref(ctx); + ls->ssl_ctx = ctx; + /* Both SNI callbacks were installed on the retiring context; a freshly built + * one carries neither. */ + if (ls->sni) SSL_CTX_set_tlsext_servername_callback(ctx, sni_cb); + if (ls->on_server_name) SSL_CTX_set_select_certificate_cb(ctx, us_select_cert_cb); + + if (hostname_pattern && ls->sni) { + struct sni_node_t *node = (struct sni_node_t *)sni_find(ls->sni, hostname_pattern); + /* Bun.listen() registers `old` itself in the tree, so the entry belongs to + * us iff `node->ctx == old` (a node:tls addContext() on the same name is + * the caller's and stays). uWS/Bun.serve registers a separate domainCtx, + * so `force_sni` moves the entry unconditionally. node->user stays on the + * node; do not stamp it onto ctx via ex_data (ctx is now also ls->ssl_ctx, + * so that would route no-SNI clients via that per-domain router). */ + if (node && (force_sni || node->ctx == old)) { + SSL_CTX_up_ref(ctx); + SSL_CTX_free(node->ctx); + node->ctx = ctx; + } + } + + us_internal_ssl_ctx_unref(old); + return 1; +} + void *us_listen_socket_find_server_name_userdata(struct us_listen_socket_t *ls, const char *hostname_pattern) { if (!ls->sni) return NULL; diff --git a/packages/bun-usockets/src/libusockets.h b/packages/bun-usockets/src/libusockets.h index d3431d30a323..5dc7e89c2c18 100644 --- a/packages/bun-usockets/src/libusockets.h +++ b/packages/bun-usockets/src/libusockets.h @@ -404,6 +404,16 @@ int us_listen_socket_add_server_name(struct us_listen_socket_t *ls, __attribute__((nonnull(1, 2, 3))); void us_listen_socket_remove_server_name(struct us_listen_socket_t *ls, const char *hostname_pattern) nonnull_fn_decl; +/* Swap the default context subsequent accepts build their SSL from; already + * accepted sockets keep the one they handshook with. `hostname_pattern` is the + * name the retiring context was registered under in the SNI tree (nullable). + * With `force_sni`=0 the entry follows the swap only when it pointed at the + * retiring default (node:tls's listen-time hint); with `force_sni`!=0 it moves + * unconditionally (uWS registers a separate per-domain context). Returns 0 for + * a non-TLS listener. */ +int us_listen_socket_set_ssl_ctx(struct us_listen_socket_t *ls, + struct ssl_ctx_st *ssl_ctx, const char *hostname_pattern, int force_sni) + __attribute__((nonnull(1, 2))); /* hostname_pattern nullable */ void *us_listen_socket_find_server_name_userdata(struct us_listen_socket_t *ls, const char *hostname_pattern) nonnull_fn_decl; /* Returns an owned reference; the caller must release it. */ diff --git a/src/js/node/tls.ts b/src/js/node/tls.ts index fcfbd5824864..8046df32caf1 100644 --- a/src/js/node/tls.ts +++ b/src/js/node/tls.ts @@ -4,6 +4,7 @@ const net = require("node:net"); const Duplex = require("internal/streams/duplex"); const EventEmitter = require("node:events"); const addServerName = $newRustFunction("Listener.rs", "jsAddServerName", 3); +const setListenerSecureContext = $newRustFunction("Listener.rs", "jsSetSecureContext", 2); const { throwNotImplemented } = require("internal/shared"); const { throwOnInvalidTLSArray, @@ -1244,11 +1245,12 @@ function Server(options, secureConnectionListener): void { options = processPfxOptions(options); const { ALPNProtocols } = options; + // Unlike every other field below, an omitted ALPNProtocols keeps the + // previous value: Node's setSecureContext() never touches it. if (ALPNProtocols) { convertALPNProtocols(ALPNProtocols, next); } else { - // An omitted ALPNProtocols clears the previous call's protocols. - next.ALPNProtocols = undefined; + next.ALPNProtocols = this.ALPNProtocols; } let cert = options.cert; @@ -1385,22 +1387,70 @@ function Server(options, secureConnectionListener): void { next.maxVersion = options.maxVersion; } if (options) { - this.ALPNProtocols = next.ALPNProtocols; - this.cert = next.cert; - this.key = next.key; - this.ca = next.ca; - this.crl = next.crl; - this.allowPartialTrustChain = next.allowPartialTrustChain; - this.sessionTimeout = next.sessionTimeout; - this.sigalgs = next.sigalgs; - this.ecdhCurve = next.ecdhCurve; - this.passphrase = next.passphrase; - this.servername = next.servername; - this.secureOptions = next.secureOptions; - this.ciphers = next.ciphers; - this.secureProtocol = next.secureProtocol; - this.minVersion = next.minVersion; - this.maxVersion = next.maxVersion; + const commit = (src: typeof next) => { + this.ALPNProtocols = src.ALPNProtocols; + this.cert = src.cert; + this.key = src.key; + this.ca = src.ca; + this.crl = src.crl; + this.allowPartialTrustChain = src.allowPartialTrustChain; + this.sessionTimeout = src.sessionTimeout; + this.sigalgs = src.sigalgs; + this.ecdhCurve = src.ecdhCurve; + this.passphrase = src.passphrase; + this.servername = src.servername; + this.secureOptions = src.secureOptions; + this.ciphers = src.ciphers; + this.secureProtocol = src.secureProtocol; + this.minVersion = src.minVersion; + this.maxVersion = src.maxVersion; + }; + + // The native context is built from these fields at listen() time, so an + // already-listening server needs it rebuilt now - that is the whole point + // of setSecureContext (live certificate rotation). Connections already + // accepted keep the certificate they handshook with, matching Node. + // + // buildSharedCreds() and a later listen() both read `this.cert` etc. + // directly, so keep the commit behind the one remaining throw so a bad + // PEM does not leave those reading rejected key material while the + // native listener is still serving the previous certificate. + const handle = this._handle; + if (handle) { + const prev = { + ALPNProtocols: this.ALPNProtocols, + cert: this.cert, + key: this.key, + ca: this.ca, + crl: this.crl, + allowPartialTrustChain: this.allowPartialTrustChain, + sessionTimeout: this.sessionTimeout, + sigalgs: this.sigalgs, + ecdhCurve: this.ecdhCurve, + passphrase: this.passphrase, + servername: this.servername, + secureOptions: this.secureOptions, + ciphers: this.ciphers, + secureProtocol: this.secureProtocol, + minVersion: this.minVersion, + maxVersion: this.maxVersion, + }; + commit(next); + const tls = this[buntls](0, undefined, false)[0]; + // Same transformation net.ts's listen path applies before Bun.listen: + // without it the verify mode on the rebuilt context ends up at + // SSL_VERIFY_FAIL_IF_NO_PEER_CERT for any server that has `ca` but did + // not set `requestCert`. + if (!tls.requestCert) tls.rejectUnauthorized = false; + try { + setListenerSecureContext(handle, tls); + } catch (e) { + commit(prev); + throw e; + } + } else { + commit(next); + } } this._sharedCreds = serverTLSOptions instanceof InternalSecureContext ? serverTLSOptions : null; this[ksharedCredsOptions] = diff --git a/src/runtime/api/BunObject.rs b/src/runtime/api/BunObject.rs index 6c3911db557a..1716022deadc 100644 --- a/src/runtime/api/BunObject.rs +++ b/src/runtime/api/BunObject.rs @@ -1560,7 +1560,7 @@ pub(crate) fn serve(global_object: &JSGlobalObject, callframe: &CallFrame) -> Js ($T:ty) => {{ // SAFETY: tag was matched; ptr was inserted as `*mut $T` below. let server: &mut $T = unsafe { &mut *entry.ptr.cast::<$T>() }; - server.on_reload_from_zig(&mut config, global_object); + server.on_reload_from_zig(&mut config, global_object)?; return Ok(server.js_value.try_get().unwrap_or(JSValue::UNDEFINED)); }}; } diff --git a/src/runtime/server/mod.rs b/src/runtime/server/mod.rs index c47f970f7c72..2ce43d31f5c7 100644 --- a/src/runtime/server/mod.rs +++ b/src/runtime/server/mod.rs @@ -224,6 +224,11 @@ pub struct NewServer { // Never set when !SSL. pub h3_app: Option<*mut uws_sys::h3::App>, pub h3_listener: Option<*mut uws_sys::h3::ListenSocket>, + /// NUL-terminated `serverName` the default context was registered under in + /// the listen socket's SNI tree. Written once in `listen()`; the tree key + /// never changes across reloads, so `on_reload_from_zig` reads this rather + /// than the per-reload config. + pub sni_server_name: Option>, /// Cached `h3=":"; ma=86400` for Alt-Svc on H1 responses; formatted /// once in onH3Listen so renderMetadata doesn't reformat per-request. pub h3_alt_svc: Box<[u8]>, @@ -2013,6 +2018,7 @@ impl NewServer { h3_app: None, h3_listener: None, h3_alt_svc: Box::<[u8]>::default(), + sni_server_name: None, js_value: jsc::JsRef::empty(), pending_requests: 0, active_websocket_count: core::cell::Cell::new(0), @@ -2704,6 +2710,10 @@ impl NewServer { // Ensure routes are set for that domain name. // SAFETY: `this` is the live boxed server from `init()`; no other borrow is live. let _ = unsafe { &mut *this }.set_routes(); + + // SAFETY: `this` is the live boxed server; no other borrow is live. + unsafe { &mut *this }.sni_server_name = + Some(server_name.to_bytes_with_nul().into()); } // SNI: per-hostname contexts diff --git a/src/runtime/server/server_body.rs b/src/runtime/server/server_body.rs index b14647a9bc51..a2f23cebc562 100644 --- a/src/runtime/server/server_body.rs +++ b/src/runtime/server/server_body.rs @@ -2203,9 +2203,35 @@ where /// valid-but-emptied state and its `Drop` frees whatever was *not* taken. /// Any `Some(ws)` is adopted unconditionally — `Handler::from_js` already /// rejected configs with no non-error callback. - pub fn on_reload_from_zig(&mut self, new_config: &mut ServerConfig, global: &JSGlobalObject) { + pub fn on_reload_from_zig( + &mut self, + new_config: &mut ServerConfig, + global: &JSGlobalObject, + ) -> JsResult<()> { httplog!("onReload"); + // A `tls` on `reload()` rotates the default certificate, matching + // `Bun.serve()`'s startup validation: build the context first so a bad + // PEM or key/cert mismatch throws before any handler or route is + // swapped, and the server keeps serving its previous certificate. + let new_ssl_ctx: Option<*mut uws_sys::SslCtx> = if SSL { + if let Some(ssl_config) = new_config.ssl_config.as_ref() { + let mut err = uws_sys::create_bun_socket_error_t::none; + match ssl_config.as_usockets().create_ssl_context(&mut err) { + Some(ctx) => Some(ctx.cast()), + None => { + return Err(global.throw_value( + crate::socket::uws_jsc::create_bun_socket_error_to_js(err, global), + )); + } + } + } else { + None + } + } else { + None + }; + // SAFETY: `on_reload` is only reachable while the server is running // (`self.app` set in `listen()`). self.app_mut().clear_routes(); @@ -2308,6 +2334,30 @@ where )); } } + + if let Some(ctx) = new_ssl_ctx { + // The listen socket up_refs the new context and releases the + // retiring one; connections already accepted keep the certificate + // they handshook with via their own `SSL_new` ref. The SNI-tree key + // is whatever `listen()` registered and never changes. + let server_name = self.sni_server_name.as_deref().map(|bytes| { + // SAFETY: stored NUL-terminated in `listen()`. + unsafe { core::ffi::CStr::from_ptr(bytes.as_ptr().cast()) } + }); + let swapped = self + .listener + .map(|ls| bun_opaque::opaque_deref_mut(ls).set_ssl_ctx(ctx, server_name)) + .unwrap_or(false); + // `create_ssl_context` handed one owned ref; the listen socket took + // its own via `SSL_CTX_up_ref`, so release ours regardless. + // SAFETY: FFI; `ctx` is non-null from `create_ssl_context`. + unsafe { bun_boringssl_sys::SSL_CTX_free(ctx.cast()) }; + if swapped { + self.config.ssl_config = new_config.ssl_config.take(); + } + } + + Ok(()) } pub fn reload_static_routes(&mut self) -> Result { @@ -2364,7 +2414,7 @@ where // ws shadows, and each `wrap_handler_slot` call allocates via // `with_async_context_if_needed`. Same window as `serve()`; same fix. let _handler_pins = super::protect_handler_shadows(&new_config); - self.on_reload_from_zig(&mut new_config, global); + self.on_reload_from_zig(&mut new_config, global)?; Ok(self.js_value.try_get().unwrap_or(JSValue::UNDEFINED)) } diff --git a/src/runtime/socket/Listener.rs b/src/runtime/socket/Listener.rs index 3f27a813a956..58648ae4a391 100644 --- a/src/runtime/socket/Listener.rs +++ b/src/runtime/socket/Listener.rs @@ -84,11 +84,16 @@ pub struct Listener { pub group: JsCell, /// `SSL_CTX*` for accepted sockets. One owned ref; `SSL_CTX_free` on close. /// `SSL_new()` per-accept takes its own ref, so accepted sockets outlive a - /// stopped listener safely. + /// stopped listener safely. `set_secure_context` replaces it. pub secure_ctx: Cell>>, pub ssl: bool, pub protos: Option>, pub reject_unauthorized: bool, + /// NUL-terminated name `secure_ctx` was registered under in the listen + /// socket's SNI tree, so `set_secure_context` can move that entry to the + /// rebuilt context. Written once in `listen()`. + pub server_name: Option>, + pub strong_data: JsCell, /// Reference to this listener's JS wrapper. Strong while it is listening or /// has connections, downgraded to weak once idle so GC can reclaim it. @@ -228,6 +233,7 @@ impl Listener { poll_ref: JsCell::new(KeepAlive::init()), group: JsCell::new(uws::SocketGroup::default()), secure_ctx: Cell::new(None), + server_name: None, strong_data: JsCell::new(Strong::empty()), this_value: JsCell::new(JsRef::empty()), })); @@ -350,6 +356,7 @@ impl Listener { poll_ref: JsCell::new(KeepAlive::init()), group: JsCell::new(uws::SocketGroup::default()), secure_ctx: Cell::new(None), + server_name: None, strong_data: JsCell::new(Strong::empty()), this_value: JsCell::new(JsRef::empty()), })); @@ -540,6 +547,7 @@ impl Listener { secure.as_ptr().cast(), core::ptr::null_mut(), ); + this_ref.server_name = Some(server_name.to_bytes_with_nul().into()); } } // Register the dynamic SNI dispatch when the JS config provided a @@ -776,6 +784,89 @@ impl Listener { Ok(JSValue::UNDEFINED) } + /// Rebuild the listening socket's default `SSL_CTX` from a fresh TLS options + /// object (`node:tls`'s `server.setSecureContext()`). Sockets already + /// accepted keep the certificate they handshook with; every subsequent + /// handshake uses the new one. + pub fn set_secure_context( + this: &Self, + global: &JSGlobalObject, + tls: JSValue, + ) -> JsResult { + // `listen()` hasn't run yet (or failed): the JS-side options stay the + // source of truth and the next `listen()` builds the context from them. + if !this.ssl || matches!(this.listener.get(), ListenerType::None) { + return Ok(JSValue::UNDEFINED); + } + + // Parsing reads the options object, which can run user JS and close the + // server out from under us — re-read the listener afterwards. + // SAFETY: per-thread VM; valid for program lifetime. + let vm = VirtualMachine::get().as_mut(); + let Some(ssl_config) = SSLConfig::from_js(vm, global, tls)? else { + return Ok(JSValue::UNDEFINED); + }; + + let mut create_err = uws::create_bun_socket_error_t::none; + let Some(ctx) = ssl_config.as_usockets().create_ssl_context(&mut create_err) else { + return Err( + global.throw_value(crate::socket::uws_jsc::create_bun_socket_error_to_js( + create_err, global, + )), + ); + }; + // One owned ref, which becomes the listener's once the swap lands. + let ctx = ctx.cast::(); + + match this.listener.get() { + ListenerType::Uws(ls) => { + // `server_name` is the entry the listen-time context was + // registered under in the SNI tree; the C side moves it across + // so a ClientHello carrying that name stops resolving to the + // retired certificate. + let server_name = this.server_name.as_deref().map(|bytes| { + // SAFETY: stored NUL-terminated by `listen()`. + unsafe { core::ffi::CStr::from_ptr(bytes.as_ptr().cast()) } + }); + // S008: `ListenSocket` is an `opaque_ffi!` ZST — safe deref. + let swapped = + bun_opaque::opaque_deref_mut(ls).set_ssl_ctx(ctx.cast(), server_name, false); + // `this.ssl` ⇒ `listen()` stored a non-null `ls->ssl_ctx`; the + // only path that clears it is `us_listen_socket_close`, after + // which `this.listener` is `None` and we returned above. + debug_assert!(swapped, "TLS listener with no ls->ssl_ctx"); + if !swapped { + // SAFETY: FFI — release the ref create_ssl_context handed us. + unsafe { boring_sys::SSL_CTX_free(ctx) }; + return Ok(JSValue::UNDEFINED); + } + if let Some(old) = this.secure_ctx.replace(NonNull::new(ctx)) { + // SAFETY: FFI — drop the listener's ref on the retired + // context. Any live socket still holds its own via `SSL_new`. + unsafe { boring_sys::SSL_CTX_free(old.as_ptr()) }; + } + } + #[cfg(windows)] + ListenerType::NamedPipe(named_pipe) => { + // SAFETY: `named_pipe` is live while `this.listener` holds it. + let old = unsafe { &*named_pipe.as_ptr() } + .ctx + .replace(NonNull::new(ctx)); + if let Some(old) = old { + // SAFETY: FFI — `get_accepted_by` up_ref'd per connection. + unsafe { boring_sys::SSL_CTX_free(old.as_ptr()) }; + } + } + #[cfg(not(windows))] + ListenerType::NamedPipe(_) => unreachable!(), + ListenerType::None => { + // SAFETY: FFI — release the ref create_ssl_context handed us. + unsafe { boring_sys::SSL_CTX_free(ctx) }; + } + } + Ok(JSValue::UNDEFINED) + } + #[bun_jsc::host_fn(method)] pub fn dispose(this: &Self, _global: &JSGlobalObject, _frame: &CallFrame) -> JsResult { Self::do_stop(this, true); @@ -1613,6 +1704,27 @@ pub(crate) fn js_add_server_name(global: &JSGlobalObject, frame: &CallFrame) -> Err(global.throw(format_args!("Expected a Listener instance"))) } +#[bun_jsc::host_fn] +pub(crate) fn js_set_secure_context( + global: &JSGlobalObject, + frame: &CallFrame, +) -> JsResult { + jsc::mark_binding!(); + + let [listener, tls] = frame.arguments_as_array::<2>(); + if frame.arguments_count() < 2 { + return Err(global.throw_not_enough_arguments( + "setSecureContext", + 2, + frame.arguments_count() as usize, + )); + } + if let Some(this) = listener.as_class_ref::() { + return Listener::set_secure_context(this, global, tls); + } + Err(global.throw(format_args!("Expected a Listener instance"))) +} + #[cfg(windows)] fn is_valid_pipe_name(pipe_name: &[u8]) -> bool { // check for valid pipe names @@ -1653,7 +1765,9 @@ pub struct WindowsNamedPipeListeningContext { /// JSC_BORROW: process-lifetime singleton; `&'static` so call sites read /// `self.vm.is_shutting_down()` without a raw-pointer deref. pub vm: &'static VirtualMachine, - pub ctx: Option>, // server reuses the same ctx + /// Server accepts every connection with this context; `set_secure_context` + /// swaps it. + pub ctx: Cell>>, } #[cfg(not(windows))] @@ -1685,7 +1799,8 @@ impl WindowsNamedPipeListeningContext { let listener_ref = this_ref.listener.unwrap(); let listener: &Listener = listener_ref.get(); use crate::socket::windows_named_pipe_context::SocketType as PipeSocketType; - let socket: PipeSocketType = if this_ref.ctx.is_some() { + let ssl_ctx = this_ref.ctx.get(); + let socket: PipeSocketType = if ssl_ctx.is_some() { PipeSocketType::Tls(Listener::on_name_pipe_created::(listener)) } else { PipeSocketType::Tcp(Listener::on_name_pipe_created::(listener)) @@ -1697,7 +1812,7 @@ impl WindowsNamedPipeListeningContext { let result = unsafe { (*client) .named_pipe - .get_accepted_by(&mut this_ref.uv_pipe, this_ref.ctx.map(|p| p.as_ptr())) + .get_accepted_by(&mut this_ref.uv_pipe, ssl_ctx.map(|p| p.as_ptr())) }; if result.is_err() { // connection dropped @@ -1758,7 +1873,7 @@ impl WindowsNamedPipeListeningContext { listener: NonNull::new(listener).map(bun_ptr::BackRef::from), global_this: GlobalRef::from(global_this), vm: global_this.bun_vm(), - ctx: None, + ctx: Cell::new(None), })); // SAFETY: just allocated, non-null, exclusive. let this_ref = unsafe { &mut *this }; @@ -1783,7 +1898,9 @@ impl WindowsNamedPipeListeningContext { let mut err = uws::create_bun_socket_error_t::none; // Create SSL context using uSockets to match behavior of node.js match ctx_opts.create_ssl_context(&mut err) { - Some(ctx) => this_ref.ctx = NonNull::new(ctx.cast::()), + Some(ctx) => this_ref + .ctx + .set(NonNull::new(ctx.cast::())), None => return Err(ListenPipeError::Other(crate::Error::InvalidOptions)), } } diff --git a/src/uws_sys/App.rs b/src/uws_sys/App.rs index a4470107c88c..99b9b8cda3db 100644 --- a/src/uws_sys/App.rs +++ b/src/uws_sys/App.rs @@ -440,6 +440,21 @@ impl ListenSocket { // (a listen socket IS a us_socket_t). crate::socket::NewSocketHandler::::from(std::ptr::from_mut::(self).cast()) } + + /// Swap the default `SSL_CTX` subsequent accepts build their `SSL` from. + /// uWS's `addServerName` registers a separate per-domain context in the SNI + /// tree (not the app's default), so `force_sni` — see + /// `crate::ListenSocket::set_ssl_ctx`. + #[inline] + pub fn set_ssl_ctx( + &mut self, + ssl_ctx: *mut crate::SslCtx, + hostname: Option<&core::ffi::CStr>, + ) -> bool { + // S008: opaque ZST cast as above. + bun_opaque::opaque_deref_mut(std::ptr::from_mut::(self).cast::()) + .set_ssl_ctx(ssl_ctx, hostname, true) + } } #[derive(strum::IntoStaticStr, Debug)] diff --git a/src/uws_sys/ListenSocket.rs b/src/uws_sys/ListenSocket.rs index b888dd162907..ead9575dcd1f 100644 --- a/src/uws_sys/ListenSocket.rs +++ b/src/uws_sys/ListenSocket.rs @@ -86,6 +86,31 @@ impl ListenSocket { unsafe { us_listen_socket_remove_server_name(self, hostname.as_ptr()) } } + /// Swap the default `SSL_CTX` subsequent accepts build their `SSL` from. C + /// `SSL_CTX_up_ref`s `ssl_ctx` and releases the retiring reference, so the + /// caller keeps the one `create_ssl_context` handed it. `hostname` is the + /// name the retiring context was registered under in the SNI tree, if any. + /// With `force_sni` the entry moves unconditionally; otherwise only when it + /// pointed at the retiring default. Returns false for a non-TLS listener. + pub fn set_ssl_ctx( + &mut self, + ssl_ctx: *mut SslCtx, + hostname: Option<&core::ffi::CStr>, + force_sni: bool, + ) -> bool { + // SAFETY: `self` is a valid listen socket; caller guarantees `ssl_ctx` + // is non-null and points at a live SSL_CTX (C up-refs and stores it); + // `hostname` is NUL-terminated and valid for the duration of the call. + unsafe { + us_listen_socket_set_ssl_ctx( + self, + ssl_ctx, + hostname.map_or(core::ptr::null(), |h| h.as_ptr()), + c_int::from(force_sni), + ) != 0 + } + } + pub fn on_server_name( &mut self, cb: extern "C" fn(*mut ListenSocket, *const c_char, *mut c_int, *mut c_void) -> *mut c_void, @@ -109,6 +134,12 @@ unsafe extern "C" { user: *mut c_void, ) -> c_int; fn us_listen_socket_remove_server_name(ls: *mut ListenSocket, hostname: *const c_char); + fn us_listen_socket_set_ssl_ctx( + ls: *mut ListenSocket, + ssl_ctx: *mut SslCtx, + hostname: *const c_char, + force_sni: c_int, + ) -> c_int; safe fn us_listen_socket_on_server_name( ls: &mut ListenSocket, cb: extern "C" fn(*mut ListenSocket, *const c_char, *mut c_int, *mut c_void) -> *mut c_void, diff --git a/test/js/bun/http/bun-serve-ssl.test.ts b/test/js/bun/http/bun-serve-ssl.test.ts index 8cb61734e9cb..21344f108cf5 100644 --- a/test/js/bun/http/bun-serve-ssl.test.ts +++ b/test/js/bun/http/bun-serve-ssl.test.ts @@ -1,4 +1,9 @@ import { describe, expect, test } from "bun:test"; +import { tls as harnessTls } from "harness"; +import { once } from "node:events"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { connect } from "node:tls"; import privateKey from "../../third_party/jsonwebtoken/priv.pem" with { type: "text" }; import publicKey from "../../third_party/jsonwebtoken/pub.pem" with { type: "text" }; @@ -149,3 +154,122 @@ describe("Bun.serve SSL validations", () => { } } }); + +describe("Bun.serve reload({tls})", () => { + // Two distinct self-signed identities so the test can observe the swap. + const certA = { ...harnessTls }; // CN=server-bun + const certB = { + key: readFileSync(join(import.meta.dir, "../../node/tls/fixtures/rsa_private.pem"), "utf8"), + cert: readFileSync(join(import.meta.dir, "../../node/tls/fixtures/rsa_cert.crt"), "utf8"), + }; // CN=localhost + + async function servedCN(port: number, servername?: string) { + const client = connect({ port, host: "127.0.0.1", rejectUnauthorized: false, servername }); + try { + await once(client, "secureConnect"); + return client.getPeerCertificate().subject.CN; + } finally { + client.destroy(); + await once(client, "close"); + } + } + + const fetchHandler = () => new Response("ok"); + + test("serves the replacement certificate on subsequent handshakes", async () => { + await using server = Bun.serve({ port: 0, tls: certA, fetch: fetchHandler }); + const port = server.port; + expect(await servedCN(port)).toBe("server-bun"); + + server.reload({ tls: certB, fetch: fetchHandler }); + expect(await servedCN(port)).toBe("localhost"); + + // A reload without tls leaves the certificate alone. + server.reload({ fetch: fetchHandler }); + expect(await servedCN(port)).toBe("localhost"); + + server.reload({ tls: certA, fetch: fetchHandler }); + expect(await servedCN(port)).toBe("server-bun"); + }); + + test("serves the replacement certificate for clients that send the configured serverName as SNI", async () => { + // uWS's addServerName registers a separate per-domain context in the SNI + // tree (not the app's default ssl_ctx), so the entry has to be moved + // unconditionally - browsers and fetch() always send SNI. + await using server = Bun.serve({ + port: 0, + tls: { ...certA, serverName: "example.test" }, + fetch: fetchHandler, + }); + const port = server.port; + expect(await servedCN(port, "example.test")).toBe("server-bun"); + + server.reload({ tls: { ...certB, serverName: "example.test" }, fetch: fetchHandler }); + expect(await servedCN(port)).toBe("localhost"); + expect(await servedCN(port, "example.test")).toBe("localhost"); + + // The SNI-tree key is whatever listen() registered; a reload that omits + // serverName must keep matching it. + server.reload({ tls: { ...certA }, fetch: fetchHandler }); + expect(await servedCN(port, "example.test")).toBe("server-bun"); + server.reload({ tls: { ...certB }, fetch: fetchHandler }); + expect(await servedCN(port, "example.test")).toBe("localhost"); + }); + + test("routes through the reloaded handler after rotating tls with serverName", async () => { + // After the SNI-tree entry and the default context converge on the same + // SSL_CTX, that context must not carry the per-domain router as ex_data: + // reload() rebuilds self.user_routes, so the per-domain router's entries + // now point at freed UserRoute slots. Every client has to hit the freshly + // reloaded default router instead. + await using server = Bun.serve({ + port: 0, + tls: { ...certA, serverName: "example.test" }, + routes: { "/api": () => new Response("v1") }, + fetch: () => new Response("fallback"), + }); + const port = server.port; + server.reload({ + tls: { ...certB, serverName: "example.test" }, + routes: { "/api": () => new Response("v2") }, + fetch: () => new Response("fallback"), + }); + const get = (serverName?: string) => + fetch(`https://127.0.0.1:${port}/api`, { tls: { rejectUnauthorized: false, serverName } } as any).then(r => + r.text(), + ); + expect(await get()).toBe("v2"); + expect(await get("example.test")).toBe("v2"); + }); + + test("rejects an unusable certificate and keeps serving the previous one", async () => { + await using server = Bun.serve({ port: 0, tls: certA, fetch: fetchHandler }); + const port = server.port; + expect(await servedCN(port)).toBe("server-bun"); + + // Mismatched key/cert pair: the private key does not match the certificate's + // public key, which Bun.serve()'s startup path rejects with KEY_VALUES_MISMATCH. + let error: any; + try { + server.reload({ tls: { key: certB.key, cert: certA.cert }, fetch: fetchHandler }); + } catch (e) { + error = e; + } + expect(error?.code).toBe("ERR_OSSL_X509_KEY_VALUES_MISMATCH"); + expect(await servedCN(port)).toBe("server-bun"); + + // Garbage that is not PEM at all. + error = undefined; + try { + server.reload({ tls: { key: "xxx", cert: "yyy" }, fetch: fetchHandler }); + } catch (e) { + error = e; + } + expect(error?.code).toBe("ERR_OSSL_PEM_NO_START_LINE"); + expect(await servedCN(port)).toBe("server-bun"); + + // A valid rotation after the rejected ones still works. + server.reload({ tls: certB, fetch: fetchHandler }); + expect(await servedCN(port)).toBe("localhost"); + }); +}); diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index dec3666a8801..9473606737b1 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -1102,9 +1102,12 @@ it("SNICallback runs even when the requested servername matches the bind hostnam }); server.listen(0, "localhost"); await once(server, "listening"); - const port = (server.address() as AddressInfo).port; - // host: "localhost" defaults servername to "localhost" - the bind hostname. - const client = connect({ port, host: "localhost", rejectUnauthorized: false }); + const { port, address } = server.address() as AddressInfo; + // Dial the address listen() resolved "localhost" to: the name has both an A + // and an AAAA record on most hosts and connect() need not pick the same one + // (Node has the same split). servername stays "localhost" - the bind hostname + // the internal SNI entry was registered under. + const client = connect({ port, host: address, servername: "localhost", rejectUnauthorized: false }); await once(client, "secureConnect"); expect(sniCalls).toBe(1); // The peer certificate must be the SNICallback's RSA cert, not COMMON_CERT. @@ -1134,6 +1137,173 @@ it("setSecureContext() clears omitted options instead of keeping stale values", expect((server as any).key).toBe(COMMON_CERT.key); }); +// Common name of the certificate the server presented, plus the ALPN protocol +// it negotiated. +async function handshakeWith(options: Record) { + const client = connect({ rejectUnauthorized: false, ...options } as any); + try { + await once(client, "secureConnect"); + return { + cn: (client.getPeerCertificate() as PeerCertificate).subject.CN, + alpn: client.alpnProtocol, + }; + } finally { + client.destroy(); + await once(client, "close"); + } +} + +it("setSecureContext() serves the replacement certificate on subsequent handshakes", async () => { + // Live certificate rotation (certbot/ACME renew hooks): the native context is + // built once at listen() time, so setSecureContext() on a listening server has + // to rebuild it rather than only updating the JS-side options. + const server: Server = createServer({ ...COMMON_CERT }); + server.on("secureConnection", socket => socket.end()); + server.listen(0, "localhost"); + await once(server, "listening"); + const { port, address } = server.address() as AddressInfo; + // "localhost" is the bind hostname, which listen() also registered the + // default context under in the SNI tree - that entry has to move too. + const options = { port, host: address, servername: "localhost" }; + + expect(await handshakeWith(options)).toMatchObject({ cn: "server-bun" }); + server.setSecureContext({ key: rawKey, cert }); + expect(await handshakeWith(options)).toMatchObject({ cn: "localhost" }); + + server.close(); + await once(server, "close"); +}); + +it("setSecureContext() leaves addContext() entries and ALPNProtocols alone", async () => { + // Node replaces only the default context: SNI entries and the server's ALPN + // list survive the swap. + const server: Server = createServer({ key: rawKey, cert, ALPNProtocols: ["h2", "http/1.1"] }); + server.on("secureConnection", socket => socket.end()); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + server.addContext("other.example", { ...COMMON_CERT }); + const options = { port, host: "127.0.0.1", ALPNProtocols: ["h2"] }; + + expect(await handshakeWith({ ...options, servername: "other.example" })).toMatchObject({ cn: "server-bun" }); + expect(await handshakeWith(options)).toEqual({ cn: "localhost", alpn: "h2" }); + + server.setSecureContext({ ...COMMON_CERT }); + expect((server as any).ALPNProtocols).toEqual(Buffer.from("\x02h2\x08http/1.1", "latin1")); + expect(await handshakeWith({ ...options, servername: "other.example" })).toMatchObject({ cn: "server-bun" }); + expect(await handshakeWith(options)).toEqual({ cn: "server-bun", alpn: "h2" }); + + server.close(); + await once(server, "close"); +}); + +it("setSecureContext() with an unusable certificate throws and keeps the live context", async () => { + const server: Server = createServer({ ...COMMON_CERT }); + server.on("secureConnection", socket => socket.end()); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + + let error: any; + try { + server.setSecureContext({ key: rawKey, cert: "-----BEGIN CERTIFICATE-----\nnope\n-----END CERTIFICATE-----" }); + } catch (e) { + error = e; + } + expect(error?.code).toBe("ERR_OSSL_ASN1_DECODE_ERROR"); + // A rotation whose context fails to build must not take the server down. + expect(await handshakeWith({ port, host: "127.0.0.1" })).toMatchObject({ cn: "server-bun" }); + // A later listen() and the STARTTLS wrap both build from `server.cert` + // directly, so those fields must not hold the rejected key material. + expect((server as any).cert).toBe(COMMON_CERT.cert); + expect((server as any).key).toBe(COMMON_CERT.key); + + server.close(); + await once(server, "close"); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + expect(await handshakeWith({ port: (server.address() as AddressInfo).port, host: "127.0.0.1" })).toMatchObject({ + cn: "server-bun", + }); + server.close(); + await once(server, "close"); +}); + +it("setSecureContext() keeps the verify mode a server without requestCert listened with", async () => { + // A server with `ca` but no `requestCert` listens with reject_unauthorized + // clamped to 0 (net.ts does this before Bun.listen). The rotation path has + // to apply the same clamp, or the rebuilt context ends up with + // SSL_VERIFY_FAIL_IF_NO_PEER_CERT and every certless client is aborted at the + // handshake - `secureConnection` never fires, `tlsClientError` does. + const keys = (f: string) => readFileSync(join(import.meta.dir, "../test/fixtures/keys", f), "utf8"); + const server: Server = createServer({ + key: keys("agent1-key.pem"), + cert: keys("agent1-cert.pem"), + ca: [keys("ca1-cert.pem")], + }); + const accepted = Promise.withResolvers(); + server.on("secureConnection", socket => { + accepted.resolve(true); + socket.end(); + }); + server.on("tlsClientError", err => accepted.reject(err)); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + + server.setSecureContext({ key: keys("agent1-key.pem"), cert: keys("agent1-cert.pem"), ca: [keys("ca1-cert.pem")] }); + + const client = connect({ port, host: "127.0.0.1", rejectUnauthorized: false }); + client.on("error", () => {}); + expect(await accepted.promise).toBe(true); + client.destroy(); + await once(client, "close"); + + server.close(); + await once(server, "close"); +}); + +it("setSecureContext() keeps requesting a client certificate when requestCert was omitted", async () => { + // Node never touches requestCert/rejectUnauthorized in setSecureContext(); a + // key/cert-only rotation on an mTLS server has to keep sending + // CertificateRequest so getPeerCertificate() stays populated. + const keys = (f: string) => readFileSync(join(import.meta.dir, "../test/fixtures/keys", f), "utf8"); + const server: Server = createServer({ + key: keys("agent1-key.pem"), + cert: keys("agent1-cert.pem"), + ca: [keys("ca1-cert.pem")], + requestCert: true, + rejectUnauthorized: false, + }); + const seen = Promise.withResolvers(); + server.on("secureConnection", socket => { + seen.resolve((socket.getPeerCertificate() as PeerCertificate)?.subject?.CN); + socket.end(); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + + server.setSecureContext({ key: keys("agent1-key.pem"), cert: keys("agent1-cert.pem") }); + expect((server as any)._requestCert).toBe(true); + expect((server as any)._rejectUnauthorized).toBe(false); + + const client = connect({ + port, + host: "127.0.0.1", + rejectUnauthorized: false, + key: keys("agent1-key.pem"), + cert: keys("agent1-cert.pem"), + }); + await once(client, "secureConnect"); + expect(await seen.promise).toBe("agent1"); + client.destroy(); + await once(client, "close"); + + server.close(); + await once(server, "close"); +}); + it("SNICallback rejecting with a non-Error value drops the connection (no hang)", async () => { // cb(true) / cb("reason"): Node treats any truthy err as an abort. The // boolean form must not be confused with internal sentinels - the