Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions packages/bun-usockets/src/crypto/openssl.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
10 changes: 10 additions & 0 deletions packages/bun-usockets/src/libusockets.h
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
86 changes: 68 additions & 18 deletions src/js/node/tls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Comment thread
robobun marked this conversation as resolved.
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;
Expand Down Expand Up @@ -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.
Comment thread
robobun marked this conversation as resolved.
//
// 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.
Comment thread
robobun marked this conversation as resolved.
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`.
Comment thread
robobun marked this conversation as resolved.
if (!tls.requestCert) tls.rejectUnauthorized = false;
try {
setListenerSecureContext(handle, tls);
} catch (e) {
commit(prev);
throw e;
}
} else {
commit(next);
}
Comment thread
robobun marked this conversation as resolved.
Comment thread
robobun marked this conversation as resolved.
}
this._sharedCreds = serverTLSOptions instanceof InternalSecureContext ? serverTLSOptions : null;
this[ksharedCredsOptions] =
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/api/BunObject.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}};
}
Expand Down
10 changes: 10 additions & 0 deletions src/runtime/server/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,11 @@ pub struct NewServer<const SSL: bool, const DEBUG: bool> {
// 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.
Comment thread
robobun marked this conversation as resolved.
pub sni_server_name: Option<Box<[u8]>>,
/// Cached `h3=":<port>"; 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]>,
Expand Down Expand Up @@ -2013,6 +2018,7 @@ impl<const SSL: bool, const DEBUG: bool> NewServer<SSL, DEBUG> {
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),
Expand Down Expand Up @@ -2704,6 +2710,10 @@ impl<const SSL: bool, const DEBUG: bool> NewServer<SSL, DEBUG> {
// 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
Expand Down
54 changes: 52 additions & 2 deletions src/runtime/server/server_body.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
robobun marked this conversation as resolved.
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
Comment thread
robobun marked this conversation as resolved.
};

// SAFETY: `on_reload` is only reachable while the server is running
// (`self.app` set in `listen()`).
self.app_mut().clear_routes();
Expand Down Expand Up @@ -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.
Comment thread
robobun marked this conversation as resolved.
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();
}
Comment thread
robobun marked this conversation as resolved.
}

Ok(())
}

pub fn reload_static_routes(&mut self) -> Result<bool, crate::Error> {
Expand Down Expand Up @@ -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))
}
Expand Down
Loading
Loading