diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index c25a4ec2f552..496761f40d6f 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -5193,6 +5193,14 @@ declare module "bun" { interface SocketAddress { /** * The IP address of the client. + * + * A link-local IPv6 address (`fe80::/10`) has its zone: the name of the + * interface, or the index of the interface on Windows. `Bun.connect()`, + * `Bun.udpSocket()`, `node:net` and `node:dgram` take the address with + * its zone. A URL cannot hold a zone, so `fetch()` and `WebSocket` do + * not. + * + * @example "192.168.1.100" | "2001:db8::1" | "fe80::1%eth0" */ address: string; @@ -7310,7 +7318,10 @@ declare module "bun" { /** * Remote IP address connected to the socket - * @example "192.168.1.100" | "2001:db8::1" + * + * A link-local IPv6 address (`fe80::/10`) has its zone: the name of the + * interface, or the index of the interface on Windows. + * @example "192.168.1.100" | "2001:db8::1" | "fe80::1%eth0" */ readonly remoteAddress: string; @@ -7328,7 +7339,10 @@ declare module "bun" { /** * Local IP address connected to the socket - * @example "192.168.1.100" | "2001:db8::1" + * + * A link-local IPv6 address (`fe80::/10`) has its zone: the name of the + * interface, or the index of the interface on Windows. + * @example "192.168.1.100" | "2001:db8::1" | "fe80::1%eth0" */ readonly localAddress: string; diff --git a/packages/bun-types/serve.d.ts b/packages/bun-types/serve.d.ts index 04e35f8e3919..9e45c0b5d1eb 100644 --- a/packages/bun-types/serve.d.ts +++ b/packages/bun-types/serve.d.ts @@ -265,6 +265,10 @@ declare module "bun" { /** * The IP address of the client. * + * A link-local IPv6 address (`fe80::/10`) has its zone: the name of the + * interface, or the index of the interface on Windows. For example + * `"fe80::1%eth0"`. + * * @example * ```ts * console.log(socket.remoteAddress); // "127.0.0.1" diff --git a/packages/bun-usockets/src/bsd.c b/packages/bun-usockets/src/bsd.c index 58e192d97d4c..a694d6214537 100644 --- a/packages/bun-usockets/src/bsd.c +++ b/packages/bun-usockets/src/bsd.c @@ -956,6 +956,16 @@ int bsd_remote_addr(LIBUS_SOCKET_DESCRIPTOR fd, struct bsd_addr_t *addr) { return 0; } +int bsd_local_sockaddr(LIBUS_SOCKET_DESCRIPTOR fd, struct sockaddr_storage *out) { + socklen_t len = sizeof(*out); + return getsockname(fd, (struct sockaddr *) out, &len) == 0 && (out->ss_family == AF_INET || out->ss_family == AF_INET6); +} + +int bsd_remote_sockaddr(LIBUS_SOCKET_DESCRIPTOR fd, struct sockaddr_storage *out) { + socklen_t len = sizeof(*out); + return getpeername(fd, (struct sockaddr *) out, &len) == 0 && (out->ss_family == AF_INET || out->ss_family == AF_INET6); +} + char *bsd_addr_get_ip(struct bsd_addr_t *addr) { return addr->ip; } diff --git a/packages/bun-usockets/src/internal/networking/bsd.h b/packages/bun-usockets/src/internal/networking/bsd.h index 5cb0aab34b3e..623d3b91e902 100644 --- a/packages/bun-usockets/src/internal/networking/bsd.h +++ b/packages/bun-usockets/src/internal/networking/bsd.h @@ -212,6 +212,10 @@ void internal_finalize_bsd_addr(struct bsd_addr_t *addr); int bsd_local_addr(LIBUS_SOCKET_DESCRIPTOR fd, struct bsd_addr_t *addr); int bsd_remote_addr(LIBUS_SOCKET_DESCRIPTOR fd, struct bsd_addr_t *addr); +/* getsockname() / getpeername() into `out`. Returns 1 for an AF_INET or AF_INET6 address, 0 otherwise. */ +int bsd_local_sockaddr(LIBUS_SOCKET_DESCRIPTOR fd, struct sockaddr_storage *out); +int bsd_remote_sockaddr(LIBUS_SOCKET_DESCRIPTOR fd, struct sockaddr_storage *out); + char *bsd_addr_get_ip(struct bsd_addr_t *addr); int bsd_addr_get_ip_length(struct bsd_addr_t *addr); diff --git a/packages/bun-usockets/src/libusockets.h b/packages/bun-usockets/src/libusockets.h index 29e199a6a239..90ea31366600 100644 --- a/packages/bun-usockets/src/libusockets.h +++ b/packages/bun-usockets/src/libusockets.h @@ -186,6 +186,9 @@ struct us_udp_packet_buffer_t; struct ssl_ctx_st; struct ssl_st; +/* / . The address getters fill one whole: a link-local address needs its sin6_scope_id. */ +struct sockaddr_storage; + /* Public interface for UDP sockets */ /* Peeks data and length of UDP payload */ @@ -199,6 +202,10 @@ int us_udp_packet_buffer_truncated(struct us_udp_packet_buffer_t *buf, int index /* Get the bound port in host byte order */ int us_udp_socket_bound_port(struct us_udp_socket_t *s); +/* getsockname() / getpeername() into `out`. Returns 1 for an AF_INET or AF_INET6 address, 0 otherwise. */ +int us_udp_socket_local_sockaddr(struct us_udp_socket_t *s, struct sockaddr_storage *out); +int us_udp_socket_remote_sockaddr(struct us_udp_socket_t *s, struct sockaddr_storage *out); + /* Peeks peer addr (sockaddr) of received packet */ char *us_udp_packet_buffer_peer(struct us_udp_packet_buffer_t *buf, int index); @@ -725,8 +732,9 @@ struct us_socket_t *us_socket_close_lingering(us_socket_r s) nonnull_fn_decl; int us_socket_local_port(us_socket_r s) nonnull_fn_decl; int us_socket_remote_port(us_socket_r s) nonnull_fn_decl; -void us_socket_remote_address(us_socket_r s, char *nonnull_arg buf, int *nonnull_arg length) nonnull_fn_decl; -void us_socket_local_address(us_socket_r s, char *nonnull_arg buf, int *nonnull_arg length) nonnull_fn_decl; +/* getsockname() / getpeername() into `out`. Returns 0 for a unix socket, a named pipe and a closed socket. */ +int us_socket_local_sockaddr(us_socket_r s, struct sockaddr_storage *nonnull_arg out) nonnull_fn_decl; +int us_socket_remote_sockaddr(us_socket_r s, struct sockaddr_storage *nonnull_arg out) nonnull_fn_decl; int us_socket_ipc_write_fd(us_socket_r s, const char *data, int length, int fd) nonnull_fn_decl; /* Dispatches on_writable once the socket is writable, as if a us_socket_write() had come up short. @@ -748,7 +756,6 @@ struct us_socket_t *us_socket_from_fd(us_socket_group_r group, unsigned char kin __attribute__((nonnull(1))); /* ssl_ctx nullable */ /* The bundled Mozilla root certificates, DER-encoded, in static memory. Returns the count. */ size_t us_bundled_root_certs_der(const uint8_t *const **out_certs, const size_t **out_lens); -unsigned int us_get_remote_address_info(char *buf, us_socket_r s, int *port); int us_socket_get_error(us_socket_r s); /* A writable event's write made zero progress: does that prove the peer is * gone? On epoll/kqueue a writable event implies real send-buffer space, so diff --git a/packages/bun-usockets/src/quic.c b/packages/bun-usockets/src/quic.c index df8dfe2bdbc9..78defacf8933 100644 --- a/packages/bun-usockets/src/quic.c +++ b/packages/bun-usockets/src/quic.c @@ -1274,15 +1274,9 @@ int us_quic_listen_socket_port(us_quic_udp_t *ls) { : ((struct sockaddr_in *) &ls->local)->sin_port); } -int us_quic_listen_socket_local_address(us_quic_udp_t *ls, char *buf, int len) { - if (ls->local.ss_family == AF_INET6) { - if (len < 16) return 0; - memcpy(buf, &((struct sockaddr_in6 *) &ls->local)->sin6_addr, 16); - return 16; - } - if (len < 4) return 0; - memcpy(buf, &((struct sockaddr_in *) &ls->local)->sin_addr, 4); - return 4; +int us_quic_listen_socket_local_sockaddr(us_quic_udp_t *ls, struct sockaddr_storage *out) { + *out = ls->local; + return out->ss_family == AF_INET || out->ss_family == AF_INET6; } #define DEF_CB(name, sig) \ @@ -1469,23 +1463,27 @@ int us_quic_stream_headers_too_large(us_quic_stream_t *s) { void *us_quic_socket_ext(us_quic_socket_t *s) { return s + 1; } -void us_quic_socket_remote_address(us_quic_socket_t *s, char *buf, int *len, int *port, int *is_ipv6) { +int us_quic_socket_remote_sockaddr(us_quic_socket_t *s, struct sockaddr_storage *out) { const struct sockaddr *local, *peer; - *len = 0; *port = 0; *is_ipv6 = 0; - if (lsquic_conn_get_sockaddr(s->conn, &local, &peer) != 0) return; + if (lsquic_conn_get_sockaddr(s->conn, &local, &peer) != 0) return 0; if (peer->sa_family == AF_INET6) { const struct sockaddr_in6 *a = (const struct sockaddr_in6 *) peer; - *port = ntohs(a->sin6_port); if (IN6_IS_ADDR_V4MAPPED(&a->sin6_addr)) { - *len = 4; memcpy(buf, (const char *) &a->sin6_addr + 12, 4); + /* An IPv4 peer of a dual-stack socket: ::ffff:a.b.c.d is reported as a.b.c.d. */ + struct sockaddr_in v4; + memset(&v4, 0, sizeof(v4)); + v4.sin_family = AF_INET; + v4.sin_port = a->sin6_port; + memcpy(&v4.sin_addr, (const char *) &a->sin6_addr + 12, 4); + memcpy(out, &v4, sizeof(v4)); } else { - *is_ipv6 = 1; *len = 16; memcpy(buf, &a->sin6_addr, 16); + memcpy(out, a, sizeof(*a)); } - } else { - const struct sockaddr_in *a = (const struct sockaddr_in *) peer; - *port = ntohs(a->sin_port); - *len = 4; memcpy(buf, &a->sin_addr, 4); + return 1; } + if (peer->sa_family != AF_INET) return 0; + memcpy(out, peer, sizeof(struct sockaddr_in)); + return 1; } void us_quic_socket_close(us_quic_socket_t *s) { if (s->conn) lsquic_conn_close(s->conn); } diff --git a/packages/bun-usockets/src/quic.h b/packages/bun-usockets/src/quic.h index 445bf27b203d..ba18d1898e4b 100644 --- a/packages/bun-usockets/src/quic.h +++ b/packages/bun-usockets/src/quic.h @@ -122,7 +122,8 @@ us_quic_udp_t *us_quic_socket_context_listen( void us_quic_listen_socket_close(us_quic_udp_t *ls); int us_quic_listen_socket_port(us_quic_udp_t *ls); -int us_quic_listen_socket_local_address(us_quic_udp_t *ls, char *buf, int len); +/* The address that the listener is bound to, whole. Returns 1 for an AF_INET or AF_INET6 address, 0 otherwise. */ +int us_quic_listen_socket_local_sockaddr(us_quic_udp_t *ls, struct sockaddr_storage *out); /* Client engine: one shared lsquic engine for all outbound connections on * `loop`. Per-connection cert verification is decided at connect time. */ @@ -247,7 +248,8 @@ int us_quic_stream_headers_too_large(us_quic_stream_t *s); /* Connection accessors */ void *us_quic_socket_ext(us_quic_socket_t *s); -void us_quic_socket_remote_address(us_quic_socket_t *s, char *buf, int *len, int *port, int *is_ipv6); +/* The peer of the connection, whole. An IPv4 peer of a dual-stack socket is AF_INET. Returns 1, or 0 with no peer. */ +int us_quic_socket_remote_sockaddr(us_quic_socket_t *s, struct sockaddr_storage *out); void us_quic_socket_close(us_quic_socket_t *s); #ifdef __cplusplus diff --git a/packages/bun-usockets/src/socket.c b/packages/bun-usockets/src/socket.c index 0b44e5bcdf35..c7eacf844b8e 100644 --- a/packages/bun-usockets/src/socket.c +++ b/packages/bun-usockets/src/socket.c @@ -53,24 +53,15 @@ void us_connecting_socket_shutdown_read(struct us_connecting_socket_t *c) { c->shutdown_read = 1; } -void us_socket_remote_address(struct us_socket_t *s, char *buf, int *length) { - struct bsd_addr_t addr; - if (bsd_remote_addr(us_poll_fd(&s->p), &addr) || *length < bsd_addr_get_ip_length(&addr)) { - *length = 0; - } else { - *length = bsd_addr_get_ip_length(&addr); - memcpy(buf, bsd_addr_get_ip(&addr), *length); - } +/* 0 for a closed socket: the kernel can give its descriptor number to another connection. */ +int us_socket_local_sockaddr(struct us_socket_t *s, struct sockaddr_storage *out) { + if (us_socket_is_closed(s)) return 0; + return bsd_local_sockaddr(us_poll_fd(&s->p), out); } -void us_socket_local_address(struct us_socket_t *s, char *buf, int *length) { - struct bsd_addr_t addr; - if (bsd_local_addr(us_poll_fd(&s->p), &addr) || *length < bsd_addr_get_ip_length(&addr)) { - *length = 0; - } else { - *length = bsd_addr_get_ip_length(&addr); - memcpy(buf, bsd_addr_get_ip(&addr), *length); - } +int us_socket_remote_sockaddr(struct us_socket_t *s, struct sockaddr_storage *out) { + if (us_socket_is_closed(s)) return 0; + return bsd_remote_sockaddr(us_poll_fd(&s->p), out); } __attribute__((always_inline)) struct us_socket_group_t *us_socket_group(struct us_socket_t *s) { @@ -802,28 +793,6 @@ int us_connecting_socket_get_dns_error(struct us_connecting_socket_t *c) { return c->error_is_dns ? c->error : 0; } -unsigned int us_get_remote_address_info(char *buf, struct us_socket_t *s, int *port) -{ - // This function is manual inlining + modification of - // us_socket_remote_address - // AsyncSocket::getRemoteAddress - // To get { ip, port, is_ipv6 } for Bun.serve().requestIP() - struct bsd_addr_t addr; - if (bsd_remote_addr(us_poll_fd(&s->p), &addr)) { - return 0; - } - - int length = bsd_addr_get_ip_length(&addr); - if (!length) { - return 0; - } - - memcpy(buf, bsd_addr_get_ip(&addr), length); - *port = bsd_addr_get_port(&addr); - - return length; -} - void us_socket_ref(struct us_socket_t *s) { #ifdef LIBUS_USE_LIBUV uv_ref((uv_handle_t *) s->p.uv_p); diff --git a/packages/bun-usockets/src/udp.c b/packages/bun-usockets/src/udp.c index d69c02b13e08..0508fc622e38 100644 --- a/packages/bun-usockets/src/udp.c +++ b/packages/bun-usockets/src/udp.c @@ -84,24 +84,12 @@ int us_udp_socket_bound_port(struct us_udp_socket_t *s) { return s->port; } -void us_udp_socket_bound_ip(struct us_udp_socket_t *s, char *buf, int *length) { - struct bsd_addr_t addr; - if (bsd_local_addr(us_poll_fd((struct us_poll_t *)s), &addr) || *length < bsd_addr_get_ip_length(&addr)) { - *length = 0; - } else { - *length = bsd_addr_get_ip_length(&addr); - memcpy(buf, bsd_addr_get_ip(&addr), *length); - } +int us_udp_socket_local_sockaddr(struct us_udp_socket_t *s, struct sockaddr_storage *out) { + return bsd_local_sockaddr(us_poll_fd((struct us_poll_t *) s), out); } -void us_udp_socket_remote_ip(struct us_udp_socket_t *s, char *buf, int *length) { - struct bsd_addr_t addr; - if (bsd_remote_addr(us_poll_fd((struct us_poll_t *)s), &addr) || *length < bsd_addr_get_ip_length(&addr)) { - *length = 0; - } else { - *length = bsd_addr_get_ip_length(&addr); - memcpy(buf, bsd_addr_get_ip(&addr), *length); - } +int us_udp_socket_remote_sockaddr(struct us_udp_socket_t *s, struct sockaddr_storage *out) { + return bsd_remote_sockaddr(us_poll_fd((struct us_poll_t *) s), out); } __attribute__((always_inline)) void *us_udp_socket_user(struct us_udp_socket_t *udp) { diff --git a/packages/bun-uws/src/AsyncSocket.h b/packages/bun-uws/src/AsyncSocket.h index 11af74215028..5ca5f62f4336 100644 --- a/packages/bun-uws/src/AsyncSocket.h +++ b/packages/bun-uws/src/AsyncSocket.h @@ -213,14 +213,6 @@ struct AsyncSocket { return true; } - /* Returns the remote IP address or empty string on failure */ - std::string_view getRemoteAddress() { - static thread_local char buf[16]; - int ipLength = 16; - us_socket_remote_address((us_socket_t *) this, buf, &ipLength); - return std::string_view(buf, (unsigned int) ipLength); - } - /** * Flushes the socket buffer by writing as much data as possible to the underlying socket. * diff --git a/packages/bun-uws/src/HttpResponse.h b/packages/bun-uws/src/HttpResponse.h index a8af60182252..6fbfb2169e3c 100644 --- a/packages/bun-uws/src/HttpResponse.h +++ b/packages/bun-uws/src/HttpResponse.h @@ -692,7 +692,6 @@ struct HttpResponse : public AsyncSocket { using Super::close; /* See AsyncSocket */ - using Super::getRemoteAddress; using Super::getNativeHandle; /* Stops reading for a READ_PAUSE_* reason. The idle timeout keeps running, because a blocked response write has nothing else to end it. */ diff --git a/packages/bun-uws/src/WebSocket.h b/packages/bun-uws/src/WebSocket.h index aaeda1814b59..65b5d3951197 100644 --- a/packages/bun-uws/src/WebSocket.h +++ b/packages/bun-uws/src/WebSocket.h @@ -60,7 +60,6 @@ struct WebSocket : AsyncSocket { /* See AsyncSocket */ using Super::getBufferedAmount; - using Super::getRemoteAddress; using Super::getNativeHandle; /* WebSocket close cannot be an alias to AsyncSocket::close since diff --git a/src/http_jsc/websocket_client.rs b/src/http_jsc/websocket_client.rs index f5ee8dbe8419..dd90660baf14 100644 --- a/src/http_jsc/websocket_client.rs +++ b/src/http_jsc/websocket_client.rs @@ -10,7 +10,6 @@ use core::cell::{Cell, RefCell}; use core::ffi::{c_int, c_void}; use core::mem::size_of; -use core::net::IpAddr; use bun_boringssl::c::OwnedSslCtx; use bun_collections::LinearFifo; @@ -1400,14 +1399,6 @@ impl WebSocket { self.poll_ref.set(poll_ref); } - /// In tunnel mode this is the proxy's address. - pub(crate) fn remote_address(&self) -> Option<(IpAddr, u16)> { - match self.tunnel() { - Some(tunnel) => tunnel.remote_address(), - None => remote_address_of(self.tcp.get()), - } - } - /// In tunnel mode this is the socket to the proxy. `None` on the stream transport. pub(crate) fn us_socket(&self) -> Option<*mut bun_uws_sys::us_socket_t> { match self.tunnel() { @@ -1839,34 +1830,6 @@ impl Drop for WebSocket { } } -/// `None` for a unix socket or a closed socket. -pub(crate) fn remote_address_of(socket: Socket) -> Option<(IpAddr, u16)> { - let mut buf = [0u8; 64]; - let bytes = socket.remote_address(&mut buf)?; - let ip = <[u8; 4]>::try_from(bytes) - .map(IpAddr::from) - .or_else(|_| <[u8; 16]>::try_from(bytes).map(IpAddr::from)) - .ok()?; - Some((ip, socket.remote_port()?)) -} - -/// FFI form of [`WebSocket::remote_address`]. Returns false and leaves the out-params untouched -/// when there's no address. -fn write_remote_address( - remote: Option<(IpAddr, u16)>, - address: &mut BunString, - port: &mut u16, - is_ipv6: &mut bool, -) -> bool { - let Some((ip, remote_port)) = remote else { - return false; - }; - *address = BunString::create_format(format_args!("{ip}")); - *port = remote_port; - *is_ipv6 = ip.is_ipv6(); - true -} - // ────────────────────────────────────────────────────────────────────────── // exportAll() // ────────────────────────────────────────────────────────────────────────── @@ -1898,15 +1861,6 @@ pub fn bun__websocketclient__set_ref( ) { this.set_ref(keep_alive) } -// HOST_EXPORT(Bun__WebSocketClient__remoteAddress, c) -pub fn bun__websocketclient__remote_address( - this: &crate::websocket_client::WebSocketClient, - address: &mut bun_core::String, - port: &mut u16, - is_ipv6: &mut bool, -) -> bool { - write_remote_address(this.remote_address(), address, port, is_ipv6) -} // HOST_EXPORT(Bun__WebSocketClient__usSocket, c) pub fn bun__websocketclient__us_socket( this: &crate::websocket_client::WebSocketClient, @@ -2041,15 +1995,6 @@ pub fn bun__websocketclienttls__set_ref( ) { this.set_ref(keep_alive) } -// HOST_EXPORT(Bun__WebSocketClientTLS__remoteAddress, c) -pub fn bun__websocketclienttls__remote_address( - this: &crate::websocket_client::WebSocketClientTLS, - address: &mut bun_core::String, - port: &mut u16, - is_ipv6: &mut bool, -) -> bool { - write_remote_address(this.remote_address(), address, port, is_ipv6) -} // HOST_EXPORT(Bun__WebSocketClientTLS__usSocket, c) pub fn bun__websocketclienttls__us_socket( this: &crate::websocket_client::WebSocketClientTLS, diff --git a/src/http_jsc/websocket_client/WebSocketProxyTunnel.rs b/src/http_jsc/websocket_client/WebSocketProxyTunnel.rs index 2f3cb1e959a1..920c83585ca4 100644 --- a/src/http_jsc/websocket_client/WebSocketProxyTunnel.rs +++ b/src/http_jsc/websocket_client/WebSocketProxyTunnel.rs @@ -29,7 +29,7 @@ use bun_ptr::{BackRef, RefPtr, Root, ThisPtr}; use bun_uws::ssl_wrapper::{Handlers as SslHandlers, SSLWrapper as SslWrapper}; use bun_uws::{NewSocketHandler, us_bun_verify_error_t}; -use crate::websocket_client::{CloseMode, ErrorCode, remote_address_of}; +use crate::websocket_client::{CloseMode, ErrorCode}; use bun_boringssl::c::OwnedSslCtx; @@ -496,14 +496,6 @@ impl WebSocketProxyTunnel { !matches!(self.socket.get(), SocketUnion::None) } - pub(crate) fn remote_address(&self) -> Option<(core::net::IpAddr, u16)> { - match self.socket.get() { - SocketUnion::Tcp(s) => remote_address_of(s), - SocketUnion::Ssl(s) => remote_address_of(s), - SocketUnion::None => None, - } - } - pub(crate) fn us_socket(&self) -> Option<*mut bun_uws_sys::us_socket_t> { match self.socket.get() { SocketUnion::Tcp(s) => s.socket.get(), diff --git a/src/js/ahead-of-time/node/_http_server.ts b/src/js/ahead-of-time/node/_http_server.ts index e2c16faae27b..07c5f692e296 100644 --- a/src/js/ahead-of-time/node/_http_server.ts +++ b/src/js/ahead-of-time/node/_http_server.ts @@ -1,5 +1,5 @@ // What the compiler reads in place of src/js/node/_http_server.ts, which `bun run` evaluates. The two do the same. -// Written against that file with the SHA-256 f14645fc6af6a16c956042871af63a64d9e96e9d5646368ab76f6ecf97ccffe8. +// Written against that file with the SHA-256 cd9d41bc55124187f8a7b002b05febe16d3638d5cd3cc81dde5e3446e33c2f24. // When that file changes, bring the change here and write the new hash: test/internal/second-texts-of-modules.test.ts. // Hardcoded module "node:_http_server" @@ -237,6 +237,9 @@ const setServerMaxHeadersCount = $bindgen("node_http_binding.bind.ts", "setServe server: $Node.NativeHTTPServer, maxHeadersCount: number, ) => void; +const getServerAddress = $bindgen("node_http_binding.bind.ts", "getServerAddress") as ( + server: $Node.NativeHTTPServer, +) => $Node.NativeHTTPServer["address"]; const setBunServerSocketHandlers = $bindgen("node_http_binding.bind.ts", "setBunServerSocketHandlers"); const httpConnectionOf = $bindgen("node_http_binding.bind.ts", "httpConnectionOf") as ( socket: unknown, @@ -1155,7 +1158,7 @@ Server.prototype[Symbol.asyncDispose] = function (this: $Node.HttpServer) { Server.prototype.address = function (this: $Node.HttpServer) { if (!this[serverSymbol]) return null; - return this[serverSymbol].address; + return getServerAddress(this[serverSymbol]); }; Server.prototype.listen = function (this: $Node.HttpServer) { diff --git a/src/js/internal-for-testing.ts b/src/js/internal-for-testing.ts index dc3ad7adaa8e..1f5289f7c7be 100644 --- a/src/js/internal-for-testing.ts +++ b/src/js/internal-for-testing.ts @@ -221,6 +221,12 @@ export const isOperatingSystemMatch: (operatingSystem: string[]) => boolean = $b export const createSocketPair = $bindgen("socket_body.bind.ts", "createSocketPair") as () => [number, number]; +/** `{ address, family, port }` as a socket prints the kernel address `host`:`port`. A `%zone` sets the scope id. */ +export const formatSocketAddress = $bindgen("SocketAddress.bind.ts", "formatSocketAddress") as ( + host: string, + port: number, +) => { address: string; family: string; port: number }; + export const isModuleResolveFilenameSlowPathEnabled: () => boolean = $newCppFunction( "NodeModuleModule.cpp", "jsFunctionIsModuleResolveFilenameSlowPathEnabled", diff --git a/src/js/internal/net/isIP.ts b/src/js/internal/net/isIP.ts index e59a884d65ea..71c5ef8a4558 100644 --- a/src/js/internal/net/isIP.ts +++ b/src/js/internal/net/isIP.ts @@ -31,8 +31,18 @@ function isIP(s: string | null | undefined): 0 | 4 | 6 { return 0; } +const StringPrototypeIndexOf = String.prototype.indexOf; +const StringPrototypeSlice = String.prototype.slice; + +/** An address without the `%zone` that a socket prints after a link-local address. */ +function withoutZone(address: string) { + const zone = StringPrototypeIndexOf.$call(address, "%"); + return zone === -1 ? address : StringPrototypeSlice.$call(address, 0, zone); +} + export default { isIPv4, isIPv6, isIP, + withoutZone, }; diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index 9b1807bfec9e..5d9b45aa4d10 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -142,6 +142,7 @@ const addServerName = $bindgen("Listener.bind.ts", "addServerName"); const setServerAppFlags = $bindgen("node_http_binding.bind.ts", "setServerAppFlags"); const setServerCustomOptions = $bindgen("node_http_binding.bind.ts", "setServerCustomOptions"); const setServerMaxHeadersCount = $bindgen("node_http_binding.bind.ts", "setServerMaxHeadersCount"); +const getServerAddress = $bindgen("node_http_binding.bind.ts", "getServerAddress"); const setBunServerSocketHandlers = $bindgen("node_http_binding.bind.ts", "setBunServerSocketHandlers"); const httpConnectionOf = $bindgen("node_http_binding.bind.ts", "httpConnectionOf"); @@ -753,7 +754,7 @@ Server.prototype[Symbol.asyncDispose] = function () { Server.prototype.address = function () { if (!this[serverSymbol]) return null; - return this[serverSymbol].address; + return getServerAddress(this[serverSymbol]); }; Server.prototype.listen = function () { diff --git a/src/js/node/dgram.ts b/src/js/node/dgram.ts index 3f4b07912711..215d588b63bc 100644 --- a/src/js/node/dgram.ts +++ b/src/js/node/dgram.ts @@ -155,14 +155,14 @@ const UV_ECANCELED: number = $bindgen("node_util_binding.bind.ts", "ecanceledErr const kNoTrySend = Array.prototype.indexOf.$call(process.execArgv, "--test-udp-no-try-send") !== -1; const getFdFn = $bindgen("udp_socket.bind.ts", "getFd") as (socket: $Node.UDPSocketHandle) => number; -// Fresh getsockname on a live socket's descriptor: the cached `.address` -// getter can be stale for an adopted-but-unbound fd once the kernel -// auto-binds on first sendto(); Node calls getsockname on every address(). -const getSockNameFn: (fd: number) => { address: string; port: number; family: string } = $bindgen( - "udp_socket.bind.ts", - "dgramGetSockNameFd", -); - +type AddressInfo = { address: string; port: number; family: string }; +// Node's handle.getsockname(out) and handle.getpeername(out): one system call and a new object per call. +const getSockNameFn = $bindgen("udp_socket.bind.ts", "getSockName") as ( + socket: $Node.UDPSocketHandle, +) => AddressInfo | undefined; +const getPeerNameFn = $bindgen("udp_socket.bind.ts", "getPeerName") as ( + socket: $Node.UDPSocketHandle, +) => AddressInfo | undefined; let BlockList: $Node.BlockListConstructor | undefined; function isBlockList(value: unknown): value is $Node.BlockList { BlockList ??= $bindgen("BlockList.bind.ts", "BlockList") as $Node.BlockListConstructor; @@ -1368,15 +1368,9 @@ Socket.prototype.address = function (this: $Node.DgramSocket) { const socket = this[kStateSymbol].handle!.socket; // Node calls getsockname() on the (lazily created, still fd-less) handle, // which reports EBADF until the socket is bound. - if (!socket) throw new ErrnoException(UV_EBADF, "getsockname"); - if (process.platform === "win32") { - // No fd registry on Windows (fd adoption isn't supported there anyway), - // so the cached value is never stale. - return socket.address; - } - // Fresh getsockname each call, like Node — the cached `.address` can be - // stale if an adopted-but-unbound fd auto-bound on the first sendto(). - return getSockNameFn(getFdFn(socket)); + const out = socket && getSockNameFn(socket); + if (!out) throw new ErrnoException(UV_EBADF, "getsockname"); + return out; }; Socket.prototype.remoteAddress = function (this: $Node.DgramSocket) { @@ -1389,9 +1383,9 @@ Socket.prototype.remoteAddress = function (this: $Node.DgramSocket) { if (state.connectState !== CONNECT_STATE_CONNECTED) throw $ERR_SOCKET_DGRAM_NOT_CONNECTED(); - if (!socket.remoteAddress) throw $ERR_SOCKET_DGRAM_NOT_CONNECTED(); - - return socket.remoteAddress; + const out = getPeerNameFn(socket); + if (!out) throw $ERR_SOCKET_DGRAM_NOT_CONNECTED(); + return out; }; Socket.prototype.setBroadcast = function (this: $Node.DgramSocket, arg: boolean) { diff --git a/src/js/node/http2.ts b/src/js/node/http2.ts index c4e68fa39a1c..3a16aaa97415 100644 --- a/src/js/node/http2.ts +++ b/src/js/node/http2.ts @@ -38,6 +38,7 @@ const fs = require("node:fs"); const { $data } = require("node:fs/promises"); const FileHandle = $data.FileHandle; const netSymbols = require("internal/net/symbols"); +const { withoutZone } = require("internal/net/isIP"); const bunSocketServerOptions: typeof netSymbols.bunSocketServerOptions = netSymbols.bunSocketServerOptions; const kInfoHeaders = Symbol("sent-info-headers"); const kDeferWriteCallback = Symbol("deferWriteCallback"); @@ -4195,7 +4196,8 @@ function initOriginSet(session: Http2Session, socket: SessionSocket) { let hostName = socket.servername; if (!hostName) { if (socket.remoteFamily === "IPv6") { - hostName = `[${socket.remoteAddress}]`; + // An origin cannot hold the zone of a link-local address. + hostName = `[${withoutZone(socket.remoteAddress!)}]`; } else { hostName = socket.remoteAddress; } diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 999fcef6e2dc..23aaa5a6f160 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -43,7 +43,7 @@ const { getTimerDuration } = require("internal/timers"); // The key that internal/timers exports as `kTimeout`. const kTimeout = Symbol.for("::buntimeout::"); const { validateFunction, validateNumber, validatePort, validateBoolean, validateInt32, validateString } = require("internal/validators"); // prettier-ignore -const { isIPv4, isIPv6, isIP } = require("internal/net/isIP"); +const { isIPv4, isIPv6, isIP, withoutZone } = require("internal/net/isIP"); const netSymbols = require("internal/net/symbols"); const kAcceptedTLS: typeof netSymbols.kAcceptedTLS = netSymbols.kAcceptedTLS; const kArmHandshakeTimeout: typeof netSymbols.kArmHandshakeTimeout = netSymbols.kArmHandshakeTimeout; @@ -631,6 +631,15 @@ const isNamedPipeSocket: (socket: SocketHandle | null) => boolean = $bindgen( "isNamedPipeSocket", ); const getBytesRead = $bindgen("socket_body.bind.ts", "getBytesRead"); +// Node's handle.getsockname(out) and handle.getpeername(out). False for a handle with no IP address. +const getSockName: (socket: SocketHandle, out: $Node.SocketName) => boolean = $bindgen( + "socket_body.bind.ts", + "getSockName", +); +const getPeerName: (socket: SocketHandle, out: $Node.SocketName) => boolean = $bindgen( + "socket_body.bind.ts", + "getPeerName", +); const bunTlsSymbol: typeof netSymbols.bunTlsSymbol = netSymbols.bunTlsSymbol; const bunSocketServerOptions: typeof netSymbols.bunSocketServerOptions = netSymbols.bunSocketServerOptions; @@ -1685,6 +1694,14 @@ function initAcceptedTLSSocket(server: $Node.NetServer, socket: $Node.TLSSocket) socket[khandshakeTimer] = timer; socket.once("close", clearHandshakeTimeout); } +// A rule matches an address, not its zone, and isIP() rejects a zone such as `%docker_gwbridge`. +function isPeerBlocked(blockList: $Node.BlockList, remoteAddress: string | undefined) { + if (typeof remoteAddress !== "string") return false; + const address = withoutZone(remoteAddress); + const addressType = isIP(address); + return addressType !== 0 && blockList.check(address, `ipv${addressType}`); +} + // Node.js-compatible onconnection: assigned to server._handle.onconnection in // kRealListen and invoked from SocketHandlers.open with `this` bound to the // listener handle. Kept as a standalone function so tests/cluster can wrap it. @@ -1723,13 +1740,10 @@ function onconnection(this: ListenerHandle, err: number, clientHandle: $Node.Soc _socket[kAttach](clientHandle); const blockList = self.blockList; - if (blockList) { - const addressType = isIP(clientHandle.remoteAddress); - // No 'drop': https://github.com/nodejs/node/blob/v26.5.0/lib/net.js#L2512-L2520 - if (addressType && blockList.check(clientHandle.remoteAddress!, `ipv${addressType}`)) { - clientHandle.end(); - return; - } + // No 'drop': https://github.com/nodejs/node/blob/v26.5.0/lib/net.js#L2512-L2520 + if (blockList && isPeerBlocked(blockList, clientHandle.remoteAddress)) { + clientHandle.end(); + return; } if (self.maxConnections != null && self._connections >= self.maxConnections) { const data = { @@ -2704,13 +2718,9 @@ Socket.prototype._getpeername = function (this: $Node.Socket) { if (!this._handle || this.connecting) { return this._peername || {}; } else if (!this._peername) { - const family = this._handle.remoteFamily; - if (!family) return {}; - this._peername = { - family, - address: this._handle.remoteAddress, - port: this._handle.remotePort, - }; + const out: $Node.SocketName = {}; + if (!getPeerName(this._handle, out)) return out; + this._peername = out; } return this._peername; }; @@ -2719,13 +2729,9 @@ Socket.prototype._getsockname = function (this: $Node.Socket) { if (!this._handle || this.connecting) { return this._sockname || {}; } else if (!this._sockname) { - const family = this._handle.localFamily; - if (!family) return {}; - this._sockname = { - family, - address: this._handle.localAddress, - port: this._handle.localPort, - }; + const out: $Node.SocketName = {}; + if (!getSockName(this._handle, out)) return out; + this._sockname = out; } return this._sockname; }; @@ -4328,14 +4334,10 @@ function onClusterConnection(this: ClusterFauxHandle, err: number, clientHandle: } socket.connect({ fd: clientHandle.fd, fdIsRawSocket: true, pauseOnConnect: self.pauseOnConnect }); const blockList = self.blockList; - if (blockList) { - const remote = socket.remoteAddress; - const t = isIP(remote); - if (t && blockList.check(remote!, `ipv${t}`)) { - // node's onconnection closes a blocked peer silently; 'drop' is for maxConnections only. - socket.destroy(); - return; - } + // node's onconnection closes a blocked peer silently; 'drop' is for maxConnections only. + if (blockList && isPeerBlocked(blockList, socket.remoteAddress)) { + socket.destroy(); + return; } socket.server = self; socket._server = self; diff --git a/src/jsc/bindings/JSSocketAddressDTO.cpp b/src/jsc/bindings/JSSocketAddressDTO.cpp index f85e35e45424..1a3768993c61 100644 --- a/src/jsc/bindings/JSSocketAddressDTO.cpp +++ b/src/jsc/bindings/JSSocketAddressDTO.cpp @@ -13,21 +13,6 @@ static constexpr PropertyOffset addressOffset = 0; static constexpr PropertyOffset familyOffset = 1; static constexpr PropertyOffset portOffset = 2; -JSObject* create(Zig::GlobalObject* globalObject, JSString* value, int32_t port, bool isIPv6) -{ - VM& vm = globalObject->vm(); - - auto& commonStrings = Bun::commonStrings(vm); - auto* family = isIPv6 ? commonStrings.IPv6String() : commonStrings.IPv4String(); - - JSObject* thisObject = constructEmptyObject(vm, globalObject->JSSocketAddressDTOStructure()); - thisObject->putDirectOffset(vm, addressOffset, value); - thisObject->putDirectOffset(vm, familyOffset, family); - thisObject->putDirectOffset(vm, portOffset, jsNumber(port)); - - return thisObject; -} - // Using a structure with inlined offsets should be more lightweight than a class. Structure* createStructure(VM& vm, JSGlobalObject* globalObject) { diff --git a/src/jsc/bindings/JSSocketAddressDTO.h b/src/jsc/bindings/JSSocketAddressDTO.h index 6fe868bac49f..6c77d4a8e2f8 100644 --- a/src/jsc/bindings/JSSocketAddressDTO.h +++ b/src/jsc/bindings/JSSocketAddressDTO.h @@ -4,16 +4,28 @@ #include "root.h" #include "JavaScriptCore/JSObjectInlines.h" #include "ZigGlobalObject.h" +#if OS(WINDOWS) +#include +#else +#include +#endif using namespace JSC; +struct us_socket_t; + namespace Bun { namespace JSSocketAddressDTO { Structure* createStructure(VM& vm, JSGlobalObject* globalObject); -JSObject* create(Zig::GlobalObject* globalObject, JSString* value, int port, bool isIPv6); } // namespace JSSocketAddress } // namespace Bun extern "C" JSC::EncodedJSValue JSSocketAddressDTO__create(JSGlobalObject* globalObject, EncodedJSValue address, uint16_t port, bool isIPv6); + +// getsockname() / getpeername() of a socket, whole. 0 for a unix socket and when the call fails. +extern "C" int us_socket_local_sockaddr(struct us_socket_t*, struct sockaddr_storage* out); +extern "C" int us_socket_remote_sockaddr(struct us_socket_t*, struct sockaddr_storage* out); +// `{ address, family, port }` of a kernel address, as src/runtime/socket/SocketAddress.rs prints it. +extern "C" JSC::EncodedJSValue Bun__sockaddrToJS(JSC::JSGlobalObject*, const struct sockaddr_storage*); diff --git a/src/jsc/bindings/headers.h b/src/jsc/bindings/headers.h index 681d2f3a99d0..6dc1bc5602d6 100644 --- a/src/jsc/bindings/headers.h +++ b/src/jsc/bindings/headers.h @@ -403,7 +403,6 @@ ZIG_DECL size_t Bun__WebSocketClient__bufferedAmount(WebSocketClient* arg0); ZIG_DECL bool Bun__WebSocketClient__pause(WebSocketClient* arg0); ZIG_DECL bool Bun__WebSocketClient__resume(WebSocketClient* arg0); ZIG_DECL void Bun__WebSocketClient__setRef(WebSocketClient* arg0, bool ref); -ZIG_DECL bool Bun__WebSocketClient__remoteAddress(WebSocketClient* arg0, BunString* address, uint16_t* port, bool* isIPv6); ZIG_DECL struct us_socket_t* Bun__WebSocketClient__usSocket(WebSocketClient* arg0); ZIG_DECL void Bun__WebSocketClient__close(WebSocketClient* arg0, uint16_t code, const unsigned char* utf8Reason, size_t utf8ReasonLen); ZIG_DECL void Bun__WebSocketClient__finalize(WebSocketClient* arg0); @@ -425,7 +424,6 @@ ZIG_DECL size_t Bun__WebSocketClientTLS__bufferedAmount(WebSocketClientTLS* arg0 ZIG_DECL bool Bun__WebSocketClientTLS__pause(WebSocketClientTLS* arg0); ZIG_DECL bool Bun__WebSocketClientTLS__resume(WebSocketClientTLS* arg0); ZIG_DECL void Bun__WebSocketClientTLS__setRef(WebSocketClientTLS* arg0, bool ref); -ZIG_DECL bool Bun__WebSocketClientTLS__remoteAddress(WebSocketClientTLS* arg0, BunString* address, uint16_t* port, bool* isIPv6); ZIG_DECL struct us_socket_t* Bun__WebSocketClientTLS__usSocket(WebSocketClientTLS* arg0); ZIG_DECL void Bun__WebSocketClientTLS__close(WebSocketClientTLS* arg0, uint16_t code, const unsigned char* utf8Reason, size_t utf8ReasonLen); ZIG_DECL void Bun__WebSocketClientTLS__finalize(WebSocketClientTLS* arg0); diff --git a/src/jsc/bindings/webcore/JSWebSocket.cpp b/src/jsc/bindings/webcore/JSWebSocket.cpp index b159416a334b..c2c70beea89a 100644 --- a/src/jsc/bindings/webcore/JSWebSocket.cpp +++ b/src/jsc/bindings/webcore/JSWebSocket.cpp @@ -71,6 +71,7 @@ #include "ErrorCode.h" #include "blob.h" #include "ModuleGraph.h" +#include "JSSocketAddressDTO.h" #include namespace WebCore { @@ -1197,15 +1198,12 @@ JSC_DEFINE_HOST_FUNCTION(jsWebSocketRemoteAddressForWs, (JSGlobalObject * lexica auto* socket = dynamicDowncast(callFrame->argument(0)); if (!socket) [[unlikely]] return throwVMTypeError(lexicalGlobalObject, throwScope, "Expected a WebSocket"_s); - auto remoteAddress = socket->wrapped().remoteAddress(); - if (!remoteAddress) + // The peer, or the proxy when there is one. Not connected, or connected over a unix socket: undefined. + us_socket_t* connection = socket->wrapped().usSocket(); + struct sockaddr_storage addr; + if (!connection || !us_socket_remote_sockaddr(connection, &addr)) return JSValue::encode(jsUndefined()); - - auto* result = constructEmptyObject(lexicalGlobalObject); - Bun::putDirectNamed(vm, result, "address"_s, jsString(vm, remoteAddress->address)); - Bun::putDirectNamed(vm, result, "family"_s, jsNontrivialString(vm, remoteAddress->isIPv6 ? "IPv6"_s : "IPv4"_s)); - Bun::putDirectNamed(vm, result, "port"_s, jsNumber(remoteAddress->port)); - return JSValue::encode(result); + RELEASE_AND_RETURN(throwScope, Bun__sockaddrToJS(defaultGlobalObject(lexicalGlobalObject), &addr)); } JSC::JSValue createWebSocketBindingForWs(Zig::GlobalObject* globalObject) diff --git a/src/jsc/bindings/webcore/WebSocket.cpp b/src/jsc/bindings/webcore/WebSocket.cpp index 5fb359b9cf1e..7cd0e0a82f6e 100644 --- a/src/jsc/bindings/webcore/WebSocket.cpp +++ b/src/jsc/bindings/webcore/WebSocket.cpp @@ -717,16 +717,6 @@ void WebSocket::setRef(bool ref) callClient(ref); } -std::optional WebSocket::remoteAddress() const -{ - BunString address { BunStringTag::Empty }; - uint16_t port = 0; - bool isIPv6 = false; - if (callClient(&address, &port, &isIPv6)) - return RemoteAddress { address.transferToWTFString(), port, isIPv6 }; - return std::nullopt; -} - void WebSocket::feed(std::span bytes) { if (m_connectionType == ConnectionType::Stream && m_connectedWebSocketKind == ConnectedWebSocketKind::Client) diff --git a/src/jsc/bindings/webcore/WebSocket.h b/src/jsc/bindings/webcore/WebSocket.h index 549d4afeeb94..9804e32176bd 100644 --- a/src/jsc/bindings/webcore/WebSocket.h +++ b/src/jsc/bindings/webcore/WebSocket.h @@ -205,16 +205,9 @@ class WebSocket final : public RefCounted, public EventTargetWithInli bool resume(); bool isPaused() const { return m_paused && canPause(); } - // setRef() and remoteAddress() back the `ws` module's `_socket`. + // setRef() backs the `ws` module's `_socket`. // Sets whether the connection keeps the process alive. An unref while CONNECTING is applied once connected. void setRef(bool); - struct RemoteAddress { - String address; - uint16_t port; - bool isIPv6; - }; - // Returns nullopt if not connected or if connected over a unix socket. Behind a proxy, this is the proxy's address. - std::optional remoteAddress() const; // createOverStream() only. Parses bytes read from the stream. Handlers run before this returns, so the span must stay valid throughout. void feed(std::span); // createOverStream() only. The stream was closed. diff --git a/src/runtime/dns_jsc/dns.rs b/src/runtime/dns_jsc/dns.rs index 982d2925c2d1..ea8f00943391 100644 --- a/src/runtime/dns_jsc/dns.rs +++ b/src/runtime/dns_jsc/dns.rs @@ -38,6 +38,7 @@ use bun_wyhash::hash as wyhash; use super::cares_jsc::error_to_deferred; use crate::generated_bindgen::dns::LookupOptions; use crate::generated_bindgen::dns_internals::ResolverOptions; +use crate::socket::socket_address::Bun__parseIpAddress; use crate::socket::socket_address::inet::INET6_ADDRSTRLEN; use crate::timer::{ElTimespec, EventLoopTimer, EventLoopTimerState, EventLoopTimerTag}; use bun_cares_sys::c_ares_draft as c_ares; @@ -2836,12 +2837,16 @@ pub(crate) mod internal { (0..len) .map(|i| { let address = addresses.get_index(global, i as u32)?.to_utf8(global)?; - match bun_core::ip_address::to_ip_address(address.slice()) { - Some(ip) => Ok(bun_dns::Address::from_ip(ip, 0).into_storage()), - None => Err(global.throw_invalid_arguments(format_args!( + let address = bun_core::ZBox::from_bytes(address.slice()); + let mut addr: SockaddrStorage = bun_core::ffi::zeroed(); + // The parse of the connect paths: a `%zone` becomes the scope id. + // SAFETY: `address` is NUL-terminated and `addr` is a writable `sockaddr_storage`. + if unsafe { Bun__parseIpAddress(address.as_ptr(), 0, &raw mut addr) } == 0 { + return Err(global.throw_invalid_arguments(format_args!( "addresses[{i}] is not an IPv4 or IPv6 literal" - ))), + ))); } + Ok(addr) }) .collect() } @@ -3909,6 +3914,14 @@ impl RecordType { pub(crate) const DEFAULT: Self = RecordType::A; } +/// `ip` without the `%zone` that a socket prints after a link-local address: c-ares reads none. +fn without_zone(ip: &[u8]) -> &[u8] { + match strings::index_of_char_usize(ip, b'%') { + Some(zone) => &ip[..zone], + None => ip, + } +} + /// Non-NaN, integer-truncated `0..=65535`, else `ERR_SOCKET_BAD_PORT`. fn to_port_number(double: f64, global: &JSGlobalObject) -> JsResult { if double.is_nan() { @@ -5073,7 +5086,7 @@ impl Resolver { // SAFETY: `request` is the heap-allocated GetHostByAddrInfoRequest; channel // stores it as the c-ares ctx and calls back via HostentHandler::on_hostent. unsafe { - (*channel).get_host_by_addr(ip, &mut *request); + (*channel).get_host_by_addr(without_zone(ip), &mut *request); } // SAFETY: `bun_vm()` returns the live VM back-ptr. @@ -5821,7 +5834,7 @@ impl Resolver { // SAFETY: sockaddr_storage is large enough to hold any sockaddr family // get_sockaddr writes (in/in6); the `&mut *` reborrow yields a // `&mut sockaddr` view into that storage. - if c_ares::get_sockaddr(addr_s, port, unsafe { + if c_ares::get_sockaddr(without_zone(addr_s), port, unsafe { // Target type inferred from `get_sockaddr`'s signature: `libc::sockaddr` // on POSIX, `bun_cares_sys::winsock::sockaddr` on Windows (the latter // is crate-private, so it cannot be named here). diff --git a/src/runtime/node/node_http_binding.bind.ts b/src/runtime/node/node_http_binding.bind.ts index dac80bf8c96b..6bfbed3156c9 100644 --- a/src/runtime/node/node_http_binding.bind.ts +++ b/src/runtime/node/node_http_binding.bind.ts @@ -37,6 +37,13 @@ const appFlags = { lenientHttpFlags: b.u8, httpAllowHalfOpen: b.bool } as const; export const setServerAppFlags = b.fn({ args: { server: BunServer, ...appFlags } }); /** 0 means the option is not set. */ export const setServerMaxHeadersCount = b.fn({ args: { server: BunServer, maxHeadersCount: b.u32 } }); +/** `http.Server#address()`: one system call and a new `{ address, family, port }` per call, as in Node.js. */ +export const getServerAddress = b.fn({ + args: { server: BunServer }, + returns: b.any.returning("string", "object", "null"), + capability: "js", + throws: true, +}); /** `onClientError(socketHandle, errorCode, rawPacket)`. */ export const setServerCustomOptions = b.fn({ args: { diff --git a/src/runtime/node/node_http_binding.rs b/src/runtime/node/node_http_binding.rs index b941fc23fe09..e1d2ce39e4f8 100644 --- a/src/runtime/node/node_http_binding.rs +++ b/src/runtime/node/node_http_binding.rs @@ -35,6 +35,11 @@ pub(crate) fn set_server_max_headers_count(server: BunServer<'_>, max_headers_co .set_max_headers_count(max_headers_count)) } +/// `http.Server#address()`: one `getsockname()` and a new object per call, as in Node.js. +pub(crate) fn get_server_address(cx: &mut JsScope<'_>, server: BunServer<'_>) -> JsResult { + with_server!(server, |server| server.get_address(cx)) +} + pub(crate) fn set_server_custom_options<'a>( cx: &mut JsScope<'a>, server: BunServer<'a>, diff --git a/src/runtime/server/AnyRequestContext.rs b/src/runtime/server/AnyRequestContext.rs index 38de9193dcd5..ba63fd52602c 100644 --- a/src/runtime/server/AnyRequestContext.rs +++ b/src/runtime/server/AnyRequestContext.rs @@ -176,8 +176,8 @@ impl AnyRequestContext { dispatch!(self, (), |_T, ctx| ctx.set_cookies(cookie_map)) } - pub(crate) fn get_remote_socket_info(self) -> Option { - dispatch!(self, None, |_T, ctx| ctx.get_remote_socket_info()) + pub(crate) fn remote_sockaddr(self) -> Option { + dispatch!(self, None, |_T, ctx| ctx.remote_sockaddr()) } pub(crate) fn detach_request(self) { diff --git a/src/runtime/server/RequestContext.rs b/src/runtime/server/RequestContext.rs index 0d007c8ce630..7e0141762f1c 100644 --- a/src/runtime/server/RequestContext.rs +++ b/src/runtime/server/RequestContext.rs @@ -4387,17 +4387,9 @@ impl unsafe { this.cast::().as_ref() }.on_start_streaming_request_body() } - pub(crate) fn get_remote_socket_info(&self) -> Option { - let resp = self.live_resp()?; - // `AnyResponse::get_remote_socket_info` returns the uws_sys - // variant; convert to the owned `bun_uws::SocketAddress`. - // SAFETY: FFI handle - let info = resp.get_remote_socket_info()?; - Some(uws::SocketAddress { - ip: info.ip().to_vec().into_boxed_slice(), - port: info.port, - is_ipv6: info.is_ipv6, - }) + /// The peer of the connection. `None` once the response is gone, and for a unix socket. + pub(crate) fn remote_sockaddr(&self) -> Option { + self.live_resp()?.remote_sockaddr() } pub(crate) fn set_timeout(&self, seconds: c_uint) -> bool { diff --git a/src/runtime/server/ServerWebSocket.rs b/src/runtime/server/ServerWebSocket.rs index 3e351ff5d476..c06c0803bc2a 100644 --- a/src/runtime/server/ServerWebSocket.rs +++ b/src/runtime/server/ServerWebSocket.rs @@ -16,6 +16,7 @@ use crate::server::jsc::{ JsRef, JsResult, }; use crate::server::web_socket_server_context::HandlerFlags; +use crate::socket::SocketAddress; use crate::webcore::{Blob, BlobExt}; bun_output::declare_scope!(WebSocketServer, visible); @@ -1244,31 +1245,10 @@ impl ServerWebSocket { return Ok(JSValue::UNDEFINED); } - let mut buf = [0u8; 64]; - let mut text_buf = [0u8; 512]; - - let address_bytes = self.websocket().get_remote_address(&mut buf); - // `format_ip` strips trailing `:port` and `[..]`. Use `SocketAddr{V4,V6}` so - // `format_ip`'s strip logic sees the expected `addr:port` / `[addr]:port` - // shape — passing a bare `IpAddr` would corrupt IPv6 (no brackets/port, - // so the rfind(':') strip eats the last hextet). - let address: std::net::SocketAddr = match address_bytes.len() { - 4 => std::net::SocketAddrV4::new( - std::net::Ipv4Addr::from(<[u8; 4]>::try_from(&address_bytes[0..4]).unwrap()), - 0, - ) - .into(), - 16 => std::net::SocketAddrV6::new( - std::net::Ipv6Addr::from(<[u8; 16]>::try_from(&address_bytes[0..16]).unwrap()), - 0, - 0, - 0, - ) - .into(), - _ => return Ok(JSValue::UNDEFINED), - }; - let text = bun_core::fmt::format_ip(&address, &mut text_buf).expect("unreachable"); - bun_string_jsc::create_utf8_for_js(cx.global(), text) + match self.websocket().remote_sockaddr() { + Some(addr) => SocketAddress::address_from_sockaddr(cx.global(), &addr), + None => Ok(JSValue::UNDEFINED), + } } } diff --git a/src/runtime/server/server_body.rs b/src/runtime/server/server_body.rs index 796273dce9b9..a1939165b17a 100644 --- a/src/runtime/server/server_body.rs +++ b/src/runtime/server/server_body.rs @@ -1116,15 +1116,10 @@ fn request_ip(config: &ServerConfig, cx: &mut JsScope<'_>, request: &Request) -> if config.address.is_unix() { return Ok(JSValue::NULL); } - let Some(info) = request.request_context.get_remote_socket_info() else { + let Some(addr) = request.request_context.remote_sockaddr() else { return Ok(JSValue::NULL); }; - crate::socket::socket_address::SocketAddress::create_dto( - cx.global(), - &info.ip, - u16::try_from(info.port).expect("int cast"), - info.is_ipv6, - ) + SocketAddress::dto_from_sockaddr(cx.global(), &addr) } fn set_request_timeout<'a>( @@ -1912,52 +1907,30 @@ impl NewServer { (self.active_websocket_count.get() & 0x7FFF_FFFF) as i32 } + /// The path of a unix socket server, `{ address, family, port }` of an IP one, `null` when it does not listen. pub(crate) fn get_address(&self, cx: &mut JsScope<'_>) -> JsResult { - let mut port: u16 = match &self.config.get().address { - server_config::Address::Unix(unix) => { - return bun_string_jsc::create_utf8_for_js(cx.global(), unix.as_bytes()); - } - server_config::Address::Tcp { port, .. } => *port, - }; + let global = cx.global(); + if let server_config::Address::Unix(unix) = &self.config.get().address { + return bun_string_jsc::create_utf8_for_js(global, unix.as_bytes()); + } - if let Some(listener) = self.listener.get() { + let addr = if let Some(listener) = self.listener.get() { // S008: `app::ListenSocket` is a ZST opaque — safe deref. - let listener = bun_opaque::opaque_deref_mut(listener); - port = listener.get_local_port().unwrap_or(port); - - let mut buf = [0u8; 64]; - let Some(address_bytes) = listener.socket().local_address(&mut buf) else { - return Ok(JSValue::NULL); - }; - let addr = match SocketAddress::init(address_bytes, port) { - Ok(a) => a, - Err(_) => { - bun_core::hint::cold(); - return Ok(JSValue::NULL); - } - }; - return addr.into_dto(&self.global()); - } - if Self::HAS_H3 { - if let Some(h3l) = self.h3_listener.get() { - // S008: `h3::ListenSocket` is an `opaque_ffi!` ZST — safe deref. - let h3l = bun_opaque::opaque_deref_mut(h3l); - port = h3l.get_local_port().unwrap_or(port); - let mut buf = [0u8; 64]; - let Some(address_bytes) = h3l.get_local_address(&mut buf) else { - return Ok(JSValue::NULL); - }; - let addr = match SocketAddress::init(address_bytes, port) { - Ok(a) => a, - Err(_) => { - bun_core::hint::cold(); - return Ok(JSValue::NULL); - } - }; - return addr.into_dto(&self.global()); - } + bun_opaque::opaque_deref_mut(listener) + .socket() + .local_sockaddr() + } else if Self::HAS_H3 + && let Some(h3l) = self.h3_listener.get() + { + // S008: `h3::ListenSocket` is an `opaque_ffi!` ZST — safe deref. + bun_opaque::opaque_deref_mut(h3l).local_sockaddr() + } else { + None + }; + match addr { + Some(addr) => SocketAddress::dto_from_sockaddr(global, &addr), + None => Ok(JSValue::NULL), } - Ok(JSValue::NULL) } pub(crate) fn get_url(&self, cx: &mut JsScope<'_>) -> JsResult { @@ -1971,18 +1944,6 @@ impl NewServer { server_config::Address::Unix(_) => return Ok(JSValue::UNDEFINED), server_config::Address::Tcp { hostname, .. } => hostname, }; - if let Some(listener) = self.listener.get() { - let mut buf = [0u8; 1024]; - // S008: `app::ListenSocket` is a ZST opaque — safe deref. - if let Some(addr) = bun_opaque::opaque_deref_mut(listener) - .socket() - .remote_address(&mut buf[..1024]) - { - if !addr.is_empty() { - return bun_string_jsc::create_utf8_for_js(global, addr); - } - } - } if let Some(hostname) = hostname { bun_string_jsc::create_utf8_for_js(global, hostname.as_bytes()) } else { @@ -2085,8 +2046,8 @@ impl NewServer { if !self.config.get().address.is_unix() && (!bake::is_allowed_host_header(req, Some(&self.config.get().address)) || !resp - .get_remote_socket_info() - .is_some_and(|address| address.is_loopback())) + .remote_sockaddr() + .is_some_and(|addr| uws_sys::sockaddr_is_loopback(&addr))) { req.set_yield(true); return; @@ -2497,8 +2458,8 @@ impl NewServer { } if resp - .get_remote_socket_info() - .is_some_and(|address| address.is_loopback()) + .remote_sockaddr() + .is_some_and(|addr| uws_sys::sockaddr_is_loopback(&addr)) { break 'brk true; } diff --git a/src/runtime/socket/Listener.rs b/src/runtime/socket/Listener.rs index 7723c19cfd00..b8c24ad81682 100644 --- a/src/runtime/socket/Listener.rs +++ b/src/runtime/socket/Listener.rs @@ -23,7 +23,7 @@ use crate::generated_bindgen::listener::{TLSListener, js_Listener as js}; use crate::socket::SSLConfig; use crate::socket::uws_jsc::{ListenApi, ListenTarget, throw_listen_error}; use crate::socket::{ - Handlers, NewSocket, SocketConfig, SocketFlags, SocketMode, TCPSocket, TLSSocket, + Handlers, NewSocket, SocketAddress, SocketConfig, SocketFlags, SocketMode, TCPSocket, TLSSocket, }; #[cfg(windows)] @@ -959,22 +959,11 @@ impl Listener { return Err(global.throw_invalid_arguments(format_args!("Expected object"))); } - let mut buf = [0u8; 64]; // S008: `ListenSocket` is an `opaque_ffi!` ZST — safe deref. - let socket_ref = bun_opaque::opaque_deref_mut(socket); - let address_bytes = socket_ref.get_local_address(&mut buf).ok(); - let is_ipv6 = address_bytes.is_some_and(|bytes| bytes.len() == 16); - let address_js = super::socket_body::ip_address_to_js(global, address_bytes)?; - if address_js.is_undefined() { - return Ok(()); + match bun_opaque::opaque_deref_mut(socket).local_sockaddr() { + Some(addr) => SocketAddress::assign_from_sockaddr(global, out, &addr), + None => Ok(()), } - let port_js = match socket_ref.get_local_port() { - Some(p) => JSValue::js_number(p as f64), - None => JSValue::UNDEFINED, - }; - - bun_jsc::cpp::JSSocketAddressDTO__assign(global, out, address_js, port_js, is_ipv6)?; - Ok(()) } } diff --git a/src/runtime/socket/SocketAddress.bind.ts b/src/runtime/socket/SocketAddress.bind.ts index e64027ce1473..434458d5203d 100644 --- a/src/runtime/socket/SocketAddress.bind.ts +++ b/src/runtime/socket/SocketAddress.bind.ts @@ -28,3 +28,12 @@ export const SocketAddress = b.class({ }, }, }); + +/** `bun:internal-for-testing`: `{ address, family, port }` as a socket prints the kernel address `host`:`port`. */ +export const formatSocketAddress = b.fn({ + args: { host: b.string.utf8, port: b.u16 }, + returns: b.any.returning("object"), + capability: "js", + throws: true, + rustPath: "crate::socket::socket_address::testing_apis::format_socket_address", +}); diff --git a/src/runtime/socket/SocketAddress.rs b/src/runtime/socket/SocketAddress.rs index 0c7005657023..6be84e057717 100644 --- a/src/runtime/socket/SocketAddress.rs +++ b/src/runtime/socket/SocketAddress.rs @@ -16,6 +16,7 @@ use bun_jsc::{ CommonString, JSGlobalObject, JSString, JSValue, JsClass, JsError, JsResult, StringJsc, }; use bun_ptr::JsCell; +use bun_uws_sys::sockaddr_storage; pub(crate) struct SocketAddress { // NOTE: not C.sockaddr_storage b/c it's _huge_. we need >= 28 bytes for sockaddr_in6, @@ -383,35 +384,7 @@ impl SocketAddress { } } -#[derive(thiserror::Error, strum::IntoStaticStr, Debug)] -pub enum AddressError { - /// Too long or short to be an IPv4 or IPv6 address. - #[error("InvalidLength")] - InvalidLength, -} - impl SocketAddress { - /// Create a new IP socket address. `addr` is assumed to be a valid ipv4 or ipv6 - /// address. Port is in host byte order. - /// - /// ## Errors - /// - If `addr` is not 4 or 16 bytes long. - pub(crate) fn init(addr: &[u8], port_: u16) -> Result { - match addr.len() { - 4 => Ok(Self::init_ipv4( - <[u8; 4]>::try_from(&addr[..4]).unwrap(), - port_, - )), - 16 => Ok(Self::init_ipv6( - <[u8; 16]>::try_from(&addr[..16]).unwrap(), - port_, - 0, - 0, - )), - _ => Err(AddressError::InvalidLength), - } - } - /// Create an IPv4 socket address. `addr` is assumed to be valid. Port is in host byte order. pub(crate) fn init_ipv4(addr: [u8; 4], port_: u16) -> SocketAddress { // TODO: make sure casting doesn't swap byte order on us. @@ -440,58 +413,65 @@ impl SocketAddress { } // ============================================================================= -// ================================ DESTRUCTORS ================================ +// ============================= KERNEL ADDRESSES ============================== // ============================================================================= impl SocketAddress { - /// Turn this address into a DTO. - /// - /// This is similar to `.toJS`, but differs in the following ways: - /// - `this` is consumed - /// - result object is not an instance of `SocketAddress`, so - /// `SocketAddress.isSocketAddress(dto) === false` - /// - address, port, etc. are put directly onto the object instead of being - /// accessed via getters on the prototype. - /// - /// This method is slightly faster if you are creating a lot of socket addresses - /// that will not be around for very long. `createDTO` is even faster, but - /// requires callers to already have a presentation-formatted address. - pub(crate) fn into_dto(self, global: &JSGlobalObject) -> JsResult { - let address = match self._presentation.take() { - cached if cached.tag() != bun_core::Tag::Dead => cached, - _ => self.fmt_presentation(), - }; + /// `{ address, family, port }` of an address that the kernel returned, as Node.js's `AddressToJS` builds it. + pub(crate) fn dto_from_sockaddr( + global: &JSGlobalObject, + addr: &sockaddr_storage, + ) -> JsResult { + let address = bun_uws_sys::SocketAddress::from_sockaddr(addr); Ok(JSSocketAddressDTO__create( global, - address.into_js(global)?, - self.port(), - self.family() == AF::INET6, + Self::text_to_js(global, &address)?, + address.port, + address.is_ipv6, )) } - /// Directly create a socket address DTO. This is a POJO with address, port, and family properties. - /// Used for hot paths that provide existing, pre-formatted/validated address - /// data to JS. - /// - /// - The address string is assumed to be ASCII and a valid IP address (either v4 or v6). - /// - Port is a valid `in_port_t` (between 0 and 2^16) in host byte order. - pub(crate) fn create_dto( - global_object: &JSGlobalObject, - addr_: &[u8], - port_: u16, - is_ipv6: bool, + /// The `address` of [`Self::dto_from_sockaddr`] alone. + pub(crate) fn address_from_sockaddr( + global: &JSGlobalObject, + addr: &sockaddr_storage, ) -> JsResult { - debug_assert!(!addr_.is_empty()); + Self::text_to_js(global, &bun_uws_sys::SocketAddress::from_sockaddr(addr)) + } - Ok(JSSocketAddressDTO__create( - global_object, - bun_string_jsc::create_utf8_for_js(global_object, addr_)?, - port_, - is_ipv6, - )) + /// Node.js's `AddressToJS(env, addr, out)`: sets `address`, `family` and `port` of the object `out`. + pub(crate) fn assign_from_sockaddr( + global: &JSGlobalObject, + out: JSValue, + addr: &sockaddr_storage, + ) -> JsResult<()> { + let address = bun_uws_sys::SocketAddress::from_sockaddr(addr); + bun_jsc::cpp::JSSocketAddressDTO__assign( + global, + out, + Self::text_to_js(global, &address)?, + JSValue::js_number(f64::from(address.port)), + address.is_ipv6, + ) + } + + /// The name of an interface is UTF-8 here, as `os.networkInterfaces()` reads it. Node.js reads Latin-1. + pub(crate) fn text_to_js( + global: &JSGlobalObject, + address: &bun_uws_sys::SocketAddress, + ) -> JsResult { + bun_string_jsc::create_utf8_for_js(global, address.ip()) } } +// HOST_EXPORT(Bun__sockaddrToJS, c) +pub(crate) fn sockaddr_to_js( + global: &JSGlobalObject, + addr: &bun_uws_sys::sockaddr_storage, +) -> JsResult { + SocketAddress::dto_from_sockaddr(global, addr) +} + unsafe extern "C" { safe fn JSSocketAddressDTO__create( global_object: &JSGlobalObject, @@ -995,6 +975,26 @@ fn scope_index(zone: &[u8]) -> u32 { idx } +pub(crate) mod testing_apis { + use super::*; + + /// `{ address, family, port }` as a socket prints the kernel address `host`:`port`. A `%zone` sets the scope id. + pub(crate) fn format_socket_address( + cx: &mut JsScope<'_>, + host: &[u8], + port: u16, + ) -> JsResult { + let global = cx.global(); + let host = bun_core::ZBox::from_bytes(host); + let mut addr: sockaddr_storage = bun_core::ffi::zeroed(); + // SAFETY: `host` is NUL-terminated and `addr` is a writable `sockaddr_storage`. + if unsafe { Bun__parseIpAddress(host.as_ptr(), port, &raw mut addr) } == 0 { + return Err(global.throw_invalid_arguments(format_args!("Expected an IP address"))); + } + SocketAddress::dto_from_sockaddr(global, &addr) + } +} + #[cfg(windows)] pub(crate) mod inet { #![allow(non_camel_case_types)] diff --git a/src/runtime/socket/socket_body.bind.ts b/src/runtime/socket/socket_body.bind.ts index 56f327d823f7..652b1764d8a3 100644 --- a/src/runtime/socket/socket_body.bind.ts +++ b/src/runtime/socket/socket_body.bind.ts @@ -139,6 +139,10 @@ export const checkServerIdentityInHandshake = b.fn({ args: { socket: SocketHandl export const firstFlightBeforeFin = b.fn({ args: { socket: SocketHandle } }); export const isNamedPipeSocket = b.fn({ args: { socket: SocketHandle }, returns: b.bool }); export const getBytesRead = b.fn({ args: { socket: SocketHandle }, returns: b.f64 }); +/** Node's `handle.getsockname(out)` and `handle.getpeername(out)`. False for a handle with no IP address. */ +const setsAddress = { ...js, args: { socket: SocketHandle, out: b.any }, returns: b.bool } as const; +export const getSockName = b.fn(setsAddress); +export const getPeerName = b.fn(setsAddress); // `bun:internal-for-testing` export const createSocketPair = b.fn({ ...js, returns: b.array(b.i32) }); diff --git a/src/runtime/socket/socket_body.rs b/src/runtime/socket/socket_body.rs index 55e1aa57d4fe..44bb971b1594 100644 --- a/src/runtime/socket/socket_body.rs +++ b/src/runtime/socket/socket_body.rs @@ -12,13 +12,11 @@ use bun_io::KeepAlive; use bun_jsc::EncodedSliceJsc as _; use bun_jsc::JsCell; use bun_jsc::bindgen::{JsHeap, JsLocal, JsScope}; -use bun_jsc::bun_string_jsc; use bun_ptr::RefPtr; // do NOT `use bun_boringssl_sys::SSL` here — it shadows the // `const SSL: bool` generic param in `NewSocket` below, making rustc // resolve `` as a type arg (E0747). Use the qualified path instead. use bun_boringssl_sys::SSL_CTX; -use bun_core::{self, fmt as bun_fmt}; use bun_jsc::SysErrorJsc; use bun_jsc::{self as jsc, JSGlobalObject, JSValue, JsRef, JsResult, SystemError}; // `bun_jsc::VirtualMachine` is the *module* (alias of `virtual_machine`); name the @@ -26,8 +24,8 @@ use bun_jsc::{self as jsc, JSGlobalObject, JSValue, JsRef, JsResult, SystemError use super::upgraded_duplex::{ServerVerify, UpgradedDuplex}; use crate::crypto::boringssl_jsc::err_to_js as boringssl_err_to_js; use crate::node::{BlobOrStringOrBuffer, StringObjects, StringOrBuffer}; -use crate::socket::SSLConfig; use crate::socket::ssl_config::ParsedTls; +use crate::socket::{SSLConfig, SocketAddress}; use bun_boringssl_sys as boringssl_sys; use bun_cares_sys::c_ares_draft as c_ares; use bun_core::String as BunString; @@ -54,33 +52,24 @@ fn read_error_from_close_code(code: c_int) -> sys::Error { ) } -/// "IPv4" or "IPv6" for the 4 or 16 address bytes uSockets reports, `undefined` for a socket with no such address. -fn ip_family_to_js(global: &JSGlobalObject, address: Option<&[u8]>) -> JSValue { - match address.map_or(0, <[u8]>::len) { - 4 => global.common_strings().ipv4(), - 16 => global.common_strings().ipv6(), - _ => JSValue::UNDEFINED, +/// "IPv4" or "IPv6" for an address that uSockets read from the kernel, `undefined` for a socket with no such address. +fn family_to_js(global: &JSGlobalObject, addr: Option<&bun_uws_sys::sockaddr_storage>) -> JSValue { + match addr { + Some(addr) if bun_uws_sys::sockaddr_is_ipv6(addr) => global.common_strings().ipv6(), + Some(_) => global.common_strings().ipv4(), + None => JSValue::UNDEFINED, } } -/// The text of the same bytes. -pub(super) fn ip_address_to_js( +/// The text of the same address. +fn address_to_js( global: &JSGlobalObject, - address: Option<&[u8]>, + addr: Option<&bun_uws_sys::sockaddr_storage>, ) -> JsResult { - let address = address.unwrap_or_default(); - let ip: std::net::IpAddr = if let Ok(v4) = <[u8; 4]>::try_from(address) { - v4.into() - } else if let Ok(v6) = <[u8; 16]>::try_from(address) { - v6.into() - } else { - return Ok(JSValue::UNDEFINED); - }; - let mut text_buf = [0u8; 512]; - // `format_ip` strips the port and the brackets of `addr:port` / `[addr]:port`, so a bare `IpAddr` would corrupt IPv6. - let text = - bun_fmt::format_ip(&std::net::SocketAddr::new(ip, 0), &mut text_buf).expect("unreachable"); - bun_string_jsc::create_utf8_for_js(global, text) + match addr { + Some(addr) => SocketAddress::address_from_sockaddr(global, addr), + None => Ok(JSValue::UNDEFINED), + } } /// `errno` as the status libuv hands a write request's callback. @@ -2526,13 +2515,11 @@ impl NewSocket { } pub(crate) fn get_local_family(&self, cx: &mut JsScope<'_>) -> JSValue { - let mut buf = [0u8; 64]; - ip_family_to_js(cx.global(), self.socket.get().local_address(&mut buf)) + family_to_js(cx.global(), self.socket.get().local_sockaddr().as_ref()) } pub(crate) fn get_local_address(&self, cx: &mut JsScope<'_>) -> JsResult { - let mut buf = [0u8; 64]; - ip_address_to_js(cx.global(), self.socket.get().local_address(&mut buf)) + address_to_js(cx.global(), self.socket.get().local_sockaddr().as_ref()) } pub(crate) fn get_local_port(&self) -> Option { @@ -2540,13 +2527,11 @@ impl NewSocket { } pub(crate) fn get_remote_family(&self, cx: &mut JsScope<'_>) -> JSValue { - let mut buf = [0u8; 64]; - ip_family_to_js(cx.global(), self.socket.get().remote_address(&mut buf)) + family_to_js(cx.global(), self.socket.get().remote_sockaddr().as_ref()) } pub(crate) fn get_remote_address(&self, cx: &mut JsScope<'_>) -> JsResult { - let mut buf = [0u8; 64]; - ip_address_to_js(cx.global(), self.socket.get().remote_address(&mut buf)) + address_to_js(cx.global(), self.socket.get().remote_sockaddr().as_ref()) } pub(crate) fn get_remote_port(&self) -> Option { @@ -3874,6 +3859,49 @@ pub(crate) fn get_bytes_read(socket: SocketHandle<'_>) -> f64 { } } +/// node:net's `_getsockname()`: sets `address`, `family` and `port` of `out`. False for a handle with no IP address. +pub(crate) fn get_sock_name( + cx: &mut JsScope<'_>, + socket: SocketHandle<'_>, + out: JsLocal<'_>, +) -> JsResult { + let addr = match socket { + SocketHandle::Tcp(this) => this.socket.get().local_sockaddr(), + SocketHandle::Tls(this) => this.socket.get().local_sockaddr(), + SocketHandle::Ignored(_) => None, + }; + set_address(cx.global(), out.to_js(), addr.as_ref()) +} + +/// node:net's `_getpeername()`, Node's `handle.getpeername(out)`, as [`get_sock_name`]. +pub(crate) fn get_peer_name( + cx: &mut JsScope<'_>, + socket: SocketHandle<'_>, + out: JsLocal<'_>, +) -> JsResult { + let addr = match socket { + SocketHandle::Tcp(this) => this.socket.get().remote_sockaddr(), + SocketHandle::Tls(this) => this.socket.get().remote_sockaddr(), + SocketHandle::Ignored(_) => None, + }; + set_address(cx.global(), out.to_js(), addr.as_ref()) +} + +fn set_address( + global: &JSGlobalObject, + out: JSValue, + addr: Option<&bun_uws_sys::sockaddr_storage>, +) -> JsResult { + if !out.is_object() { + return Err(global.throw_invalid_arguments(format_args!("Expected object"))); + } + let Some(addr) = addr else { + return Ok(false); + }; + SocketAddress::assign_from_sockaddr(global, out, addr)?; + Ok(true) +} + pub(crate) fn create_socket_pair(cx: &mut JsScope<'_>) -> JsResult<[c_int; 2]> { jsc::mark_binding!(); let global = cx.global(); diff --git a/src/runtime/socket/udp_socket.bind.ts b/src/runtime/socket/udp_socket.bind.ts index d6a9081749d3..abcd84588463 100644 --- a/src/runtime/socket/udp_socket.bind.ts +++ b/src/runtime/socket/udp_socket.bind.ts @@ -73,6 +73,15 @@ export const disconnect = b.fn({ capability: "heap", throws: true, }); +/** Node's `handle.getsockname(out)` and `handle.getpeername(out)`: a new `{ address, family, port }` per call. */ +const freshAddress = { + args: { socket: UDPSocket }, + returns: b.any.returning("object", "undefined"), + capability: "js", + throws: true, +} as const; +export const getSockName = b.fn(freshAddress); +export const getPeerName = b.fn(freshAddress); export const bufferSize = b.fn({ args: { socket: UDPSocket, size: b.i32.wrap, isRecv: b.bool }, returns: b.i32, diff --git a/src/runtime/socket/udp_socket.rs b/src/runtime/socket/udp_socket.rs index b3346393ee6a..676051d55bb4 100644 --- a/src/runtime/socket/udp_socket.rs +++ b/src/runtime/socket/udp_socket.rs @@ -22,13 +22,12 @@ use bun_libuv_sys::sockaddr_storage; use bun_sys::{self, SystemErrno}; use bun_uws as uws; #[cfg(not(windows))] -use libc::sockaddr_storage; +use libc::if_nametoindex; #[cfg(not(windows))] -use libc::{IF_NAMESIZE, if_indextoname, if_nametoindex}; +use libc::sockaddr_storage; use crate::api::SocketAddress; use crate::generated_bindgen::udp_socket::js_UDPSocket as js; -use crate::socket::socket_address::inet::{self, INET6_ADDRSTRLEN, sockaddr_in, sockaddr_in6}; bun_output::declare_scope!(UdpSocket, visible); @@ -75,12 +74,6 @@ fn errno_sys(rc: c_int, tag: bun_sys::Tag) -> Option { use bun_core::ip_address; use bun_sys::net::Address; -unsafe extern "C" { - // libc byte-order conversions are pure on the integer argument — no - // pointer/aliasing/thread preconditions — so declare them `safe fn`. - safe fn ntohs(nshort: u16) -> u16; -} - extern "C" fn on_close(socket: *mut uws::udp::Socket) { let this: &UDPSocket = UDPSocket::from_uws(socket); this.closed.set(true); @@ -145,25 +138,6 @@ extern "C" fn on_drain(socket: *mut uws::udp::Socket) { event_loop.exit(); } -/// `address%scope`. The scope is the interface's name where the platform has one for the index. -fn scoped_address(address: &[u8], scope_id: u32) -> BunString { - #[cfg(not(windows))] - { - let mut buffer = [0u8; IF_NAMESIZE + 1]; - // SAFETY: buffer is IF_NAMESIZE+1 bytes, NUL-terminated by zero-init. - if !unsafe { if_indextoname(scope_id, buffer.as_mut_ptr().cast::()) }.is_null() { - // SAFETY: if_indextoname wrote a NUL-terminated string. - let name = unsafe { bun_core::ffi::cstr(buffer.as_ptr().cast::()) }.to_bytes(); - return BunString::create_format(format_args!( - "{}%{}", - bstr::BStr::new(address), - bstr::BStr::new(name) - )); - } - } - BunString::create_format(format_args!("{}%{}", bstr::BStr::new(address), scope_id)) -} - extern "C" fn on_data( socket: *mut uws::udp::Socket, buf: *mut uws::udp::PacketBuffer, @@ -197,46 +171,10 @@ extern "C" fn on_data( // SAFETY: `buf` is valid for the duration of this callback per uws // contract; each access reborrows it per-statement so no reference // spans the JS calls below. - let peer = unsafe { (*buf).get_peer(i) }; - - let mut addr_buf = [0u8; INET6_ADDRSTRLEN + 1]; - let hostname: Option<&[u8]>; - let port: u16; - let mut scope_id: Option = None; - let is_ipv6: bool; - - // SAFETY: peer points to a sockaddr_storage; family discriminates the cast. - match peer.ss_family as c_int { - f if f == inet::AF_INET => { - is_ipv6 = false; - // SAFETY: family == AF_INET so peer is sockaddr_in. - let peer4 = unsafe { &*std::ptr::from_ref(peer).cast::() }; - // SAFETY: src points to in_addr, dst is INET6_ADDRSTRLEN+1 bytes. - hostname = unsafe { - bun_cares_sys::ntop(f, (&raw const peer4.addr).cast(), &mut addr_buf) - }; - port = ntohs(peer4.port); - } - f if f == inet::AF_INET6 => { - is_ipv6 = true; - // SAFETY: family == AF_INET6 so peer is sockaddr_in6. - let peer6 = unsafe { &*std::ptr::from_ref(peer).cast::() }; - // SAFETY: src points to in6_addr, dst is INET6_ADDRSTRLEN+1 bytes. - hostname = unsafe { - bun_cares_sys::ntop(f, (&raw const peer6.addr).cast(), &mut addr_buf) - }; - port = ntohs(peer6.port); - if peer6.scope_id != 0 { - scope_id = Some(peer6.scope_id); - } - } - _ => { - i += 1; - continue; - } - } + let peer = bun_uws_sys::SocketAddress::from_sockaddr(unsafe { (*buf).get_peer(i) }); - if hostname.is_none() || port == 0 { + // An empty address is a sender that is neither `AF_INET` nor `AF_INET6`. + if peer.ip().is_empty() || peer.port == 0 { i += 1; continue; } @@ -247,12 +185,6 @@ extern "C" fn on_data( // `binary_type.to_js` copy below, before user JS runs. let slice = unsafe { (*buf).get_payload(i) }; - let span = hostname.unwrap(); - let hostname_string = match scope_id { - Some(id) => scoped_address(span, id), - None => BunString::clone_utf8(span), - }; - let loop_ = VirtualMachine::get().event_loop_mut(); loop_.enter(); @@ -260,7 +192,7 @@ extern "C" fn on_data( flags.put(global_this, b"truncated", JSValue::from(truncated)); // Per-packet: rinfo.family must reflect the packet's sockaddr, not the // socket's constructor `type` (bind({fd}) can adopt the other family). - flags.put(global_this, b"ipv6", JSValue::from(is_ipv6)); + flags.put(global_this, b"ipv6", JSValue::from(peer.is_ipv6)); let payload_js = match udp_socket .config @@ -275,7 +207,7 @@ extern "C" fn on_data( return; } }; - let hostname_js = match hostname_string.into_js(global_this) { + let hostname_js = match SocketAddress::text_to_js(global_this, &peer) { Ok(v) => v, Err(_) => { loop_.exit(); @@ -290,7 +222,7 @@ extern "C" fn on_data( &[ this_value, payload_js, - JSValue::js_number(port as f64), + JSValue::js_number(peer.port as f64), hostname_js, flags, ], @@ -496,11 +428,6 @@ impl UDPSocketConfig { } } -#[derive(Clone, Copy)] -struct ConnectInfo { - port: u16, -} - pub(crate) struct UDPSocket { pub(crate) config: JsCell, @@ -515,7 +442,7 @@ pub(crate) struct UDPSocket { pub poll_ref: JsCell, /// if marked as closed the socket pointer may be stale pub closed: Cell, - connect_info: Cell>, + connected: Cell, /// Armed while open: the socket closes with the context that opened it. abort_handle: bun_jsc::AbortHandle, } @@ -570,7 +497,7 @@ impl UDPSocket { this_value: JsCell::new(JsRef::empty()), poll_ref: JsCell::new(KeepAlive::init()), closed: Cell::new(false), - connect_info: Cell::new(None), + connected: Cell::new(false), abort_handle: bun_jsc::AbortHandle::for_owner::(), } .into_js_wrapper(cx.global()); @@ -731,8 +658,7 @@ impl UDPSocket { )); } } - this.connect_info - .set(Some(ConnectInfo { port: connect.port })); + this.connected.set(true); } // Disarm the error-path guard. @@ -1137,11 +1063,11 @@ impl UDPSocket { // Cache the connection state before doing anything that can run user JS. // Array index getters, `port.valueOf()`, and `address.toString()` can all // call back into JS and connect/disconnect/close this socket. If we re-read - // `self.connect_info` on every iteration, a mid-loop flip changes how + // `self.connected` on every iteration, a mid-loop flip changes how // `slice_idx` is computed and which branch writes into `payloads`/`lens`/ // `addr_ptrs`, producing out-of-bounds writes (unconnected -> connected) or // uninitialized slots (connected -> disconnected) in the arena buffers. - let connected = self.connect_info.get().is_some(); + let connected = self.connected.get(); let array_len = packets.get_length(global_this)? as usize; if !connected && !array_len.is_multiple_of(3) { @@ -1288,7 +1214,7 @@ impl UDPSocket { return Err(global_this.throw(format_args!("Socket is closed"))); } let (port, address) = (port.to_js(), address.to_js()); - let dst = if self.connect_info.get().is_some() { + let dst = if self.connected.get() { if !port.is_undefined() || !address.is_undefined() { return Err(global_this.throw_invalid_arguments(format_args!( "Cannot specify destination on connected socket" @@ -1530,17 +1456,6 @@ impl UDPSocket { Some(uws::udp::Socket::opaque_mut(self.socket.get()?).bound_port()) } - fn create_sock_addr( - global_this: &JSGlobalObject, - address_bytes: &[u8], - port: u16, - ) -> JsResult { - let Ok(sockaddr) = SocketAddress::init(address_bytes, port) else { - return Ok(JSValue::UNDEFINED); - }; - sockaddr.into_dto(global_this) - } - pub(crate) fn get_address(&self, cx: &mut JsScope<'_>) -> JsResult { if self.closed.get() { return Ok(JSValue::UNDEFINED); @@ -1548,38 +1463,25 @@ impl UDPSocket { let Some(socket) = self.socket.get() else { return Ok(JSValue::UNDEFINED); }; - let mut buf = [0u8; 64]; - let mut length: i32 = 64; // `Socket` is an `opaque_ffi!` ZST — `opaque_mut` is the safe deref. - let socket = uws::udp::Socket::opaque_mut(socket); - socket.bound_ip(buf.as_mut_ptr(), &mut length); - - let address_bytes = &buf[..usize::try_from(length).expect("int cast")]; - let port = socket.bound_port(); - Self::create_sock_addr( - cx.global(), - address_bytes, - u16::try_from(port).expect("int cast"), - ) + match uws::udp::Socket::opaque_mut(socket).local_sockaddr() { + Some(addr) => SocketAddress::dto_from_sockaddr(cx.global(), &addr), + None => Ok(JSValue::UNDEFINED), + } } pub(crate) fn get_remote_address(&self, cx: &mut JsScope<'_>) -> JsResult { - if self.closed.get() { + if self.closed.get() || !self.connected.get() { return Ok(JSValue::UNDEFINED); } - let Some(connect_info) = self.connect_info.get() else { - return Ok(JSValue::UNDEFINED); - }; let Some(socket) = self.socket.get() else { return Ok(JSValue::UNDEFINED); }; - let mut buf = [0u8; 64]; - let mut length: i32 = 64; // `Socket` is an `opaque_ffi!` ZST — `opaque_mut` is the safe deref. - uws::udp::Socket::opaque_mut(socket).remote_ip(buf.as_mut_ptr(), &mut length); - - let address_bytes = &buf[..usize::try_from(length).expect("int cast")]; - Self::create_sock_addr(cx.global(), address_bytes, connect_info.port) + match uws::udp::Socket::opaque_mut(socket).remote_sockaddr() { + Some(addr) => SocketAddress::dto_from_sockaddr(cx.global(), &addr), + None => Ok(JSValue::UNDEFINED), + } } pub(crate) fn get_binary_type(&self, cx: &mut JsScope<'_>) -> JsResult { @@ -1634,7 +1536,7 @@ pub(crate) fn connect( port: i32, ) -> JsResult<()> { let global_this = cx.global(); - if this.connect_info.get().is_some() { + if this.connected.get() { return Err(global_this.throw(format_args!("Socket is already connected"))); } @@ -1661,18 +1563,13 @@ pub(crate) fn connect( if uws::udp::Socket::opaque_mut(socket).connect(connect_host.as_ptr(), port as u32) == -1 { return Err(global_this.throw(format_args!("Failed to connect socket"))); } - this.connect_info.set(Some(ConnectInfo { port })); - - if let Some(this_value) = this.this_value.get().try_get() { - js::address_set_cached(this_value, global_this, JSValue::ZERO); - js::remote_address_set_cached(this_value, global_this, JSValue::ZERO); - } + this.connected.set(true); Ok(()) } pub(crate) fn disconnect<'a>(heap: &JsHeap<'a>, this: &UDPSocket) -> bindgen::Result<'a, ()> { - if this.connect_info.get().is_none() { + if !this.connected.get() { return Err(heap.error(format_args!("Socket is not connected"))); } @@ -1684,11 +1581,21 @@ pub(crate) fn disconnect<'a>(heap: &JsHeap<'a>, this: &UDPSocket) -> bindgen::Re if uws::udp::Socket::opaque_mut(this.socket.get().unwrap()).disconnect() == -1 { return Err(heap.error(format_args!("Failed to disconnect socket"))); } - this.connect_info.set(None); + this.connected.set(false); Ok(()) } +/// Node's `handle.getsockname(out)`: one `getsockname()` and a new object per call. `undefined` when closed. +pub(crate) fn get_sock_name(cx: &mut JsScope<'_>, this: &UDPSocket) -> JsResult { + this.get_address(cx) +} + +/// Node's `handle.getpeername(out)`, as [`get_sock_name`]. `undefined` when the socket is not connected. +pub(crate) fn get_peer_name(cx: &mut JsScope<'_>, this: &UDPSocket) -> JsResult { + this.get_remote_address(cx) +} + /// The resulting SO_RCVBUF/SO_SNDBUF value. `size == 0` /// reads the current value, non-zero sets it. Backs node:dgram's /// get/setRecvBufferSize and get/setSendBufferSize. @@ -1897,31 +1804,15 @@ pub(crate) fn dgram_get_sock_name_fd<'a>( .into_error(&cx.heap), ); } - let (bytes, port): (&[u8], u16) = if storage.ss_family == inet::AF_INET as libc::sa_family_t - { - // SAFETY: the family says this is a sockaddr_in. - let addr4 = unsafe { &*std::ptr::from_ref(&storage).cast::() }; - ( - // SAFETY: in_addr is 4 bytes. - unsafe { core::slice::from_raw_parts((&raw const addr4.addr).cast::(), 4) }, - u16::from_be(addr4.port), - ) - } else if storage.ss_family == inet::AF_INET6 as libc::sa_family_t { - // SAFETY: the family says this is a sockaddr_in6. - let addr6 = unsafe { &*std::ptr::from_ref(&storage).cast::() }; - ( - // SAFETY: in6_addr is 16 bytes. - unsafe { core::slice::from_raw_parts((&raw const addr6.addr).cast::(), 16) }, - u16::from_be(addr6.port), - ) - } else { + let family = c_int::from(storage.ss_family); + if family != libc::AF_INET && family != libc::AF_INET6 { return Err(bun_sys::Error::from_code_int( SystemErrno::EINVAL as c_int, bun_sys::Tag::open, ) .into_error(&cx.heap)); - }; - Ok(UDPSocket::create_sock_addr(cx.global(), bytes, port)?) + } + Ok(SocketAddress::dto_from_sockaddr(cx.global(), &storage)?) } #[cfg(windows)] { diff --git a/src/sql_jsc/postgres/PostgresSQLConnection.rs b/src/sql_jsc/postgres/PostgresSQLConnection.rs index 310d42f58cf1..475267570881 100644 --- a/src/sql_jsc/postgres/PostgresSQLConnection.rs +++ b/src/sql_jsc/postgres/PostgresSQLConnection.rs @@ -1481,34 +1481,22 @@ impl PostgresSQLConnection { }; // The address of the peer: its name can resolve to another server by now. - let mut hostname = [0u8; 46]; - let mut hostname_len = 0; - let mut port = 0; - if self.path.slice().is_empty() { - let mut address = [0u8; 16]; - let (peer, peer_port) = match socket { - Socket::SocketTcp(tcp) => (tcp.remote_address(&mut address), tcp.remote_port()), - Socket::SocketTls(tls) => (tls.remote_address(&mut address), tls.remote_port()), + let peer = if self.path.slice().is_empty() { + let addr = match socket { + Socket::SocketTcp(tcp) => tcp.remote_sockaddr(), + Socket::SocketTls(tls) => tls.remote_sockaddr(), }; - let (Some(peer), Some(peer_port)) = (peer, peer_port) else { + let Some(addr) = addr else { return false; }; - let ip: std::net::IpAddr = match *peer { - [a, b, c, d] => std::net::Ipv4Addr::new(a, b, c, d).into(), - _ => match <[u8; 16]>::try_from(peer) { - Ok(v6) => std::net::Ipv6Addr::from(v6).into(), - Err(_) => return false, - }, - }; - use std::io::Write as _; - let mut unwritten = &mut hostname[..]; - if write!(unwritten, "{ip}").is_err() { - return false; - } - let unwritten = unwritten.len(); - hostname_len = hostname.len() - unwritten; - port = i32::from(peer_port); - } + Some(uws::SocketAddress::from_sockaddr(&addr)) + } else { + None + }; + let (hostname, port) = match &peer { + Some(peer) => (peer.ip(), i32::from(peer.port)), + None => (&b""[..], 0), + }; // SAFETY: `raw` is the live connected socket of this session, in a group of its context. let group = unsafe { @@ -1521,7 +1509,7 @@ impl PostgresSQLConnection { self.global(), group, ConnectParams { - hostname: &hostname[..hostname_len], + hostname, port, strings: ConnectionStrings::new(b"", b"", b"", b"", b"", self.path.slice()), secure: self.secure.clone(), diff --git a/src/uws/lib.rs b/src/uws/lib.rs index 54dd4c6a6bdd..2227cd938edf 100644 --- a/src/uws/lib.rs +++ b/src/uws/lib.rs @@ -96,16 +96,9 @@ pub use bun_uws_sys::{ // Re-export the `_sys` definitions so higher tiers see one type. `to_js` // (`createBunSocketErrorToJS` / `verifyErrorToJS`) live as extension traits // in the *_jsc crate. -pub use bun_uws_sys::{Opcode, SendStatus, create_bun_socket_error_t, us_bun_verify_error_t}; - -/// Owned socket-address shape (boxed IP). Distinct from the sys type by -/// design — that one stores the IP text inline as returned from -/// `Response::get_remote_socket_info`. -pub struct SocketAddress { - pub ip: Box<[u8]>, - pub port: i32, - pub is_ipv6: bool, -} +pub use bun_uws_sys::{ + Opcode, SendStatus, SocketAddress, create_bun_socket_error_t, us_bun_verify_error_t, +}; pub use bun_uws_sys::loop_::free_thread_loop; diff --git a/src/uws_sys/ListenSocket.rs b/src/uws_sys/ListenSocket.rs index 66437a75771c..e04f3ff5156a 100644 --- a/src/uws_sys/ListenSocket.rs +++ b/src/uws_sys/ListenSocket.rs @@ -14,8 +14,9 @@ impl ListenSocket { us_listen_socket_close(self) } - pub fn get_local_address<'a>(&mut self, buf: &'a mut [u8]) -> Result<&'a [u8], crate::Error> { - self.get_socket().local_address(buf) + /// `getsockname()`. `None` when the call fails, and for a listener on a path. + pub fn local_sockaddr(&mut self) -> Option { + self.get_socket().local_sockaddr() } pub fn get_local_port(&mut self) -> Option { diff --git a/src/uws_sys/Response.rs b/src/uws_sys/Response.rs index 6236be827d5b..16b3380986be 100644 --- a/src/uws_sys/Response.rs +++ b/src/uws_sys/Response.rs @@ -13,53 +13,14 @@ use core::ffi::{c_int, c_void}; use core::marker::{PhantomData, PhantomPinned}; +use crate::sockaddr_storage; use crate::thunk; use crate::thunk::OpaqueHandle; use crate::us_socket_t; -use bun_core::{BoundedArray, Fd}; +use bun_core::Fd; use bun_ptr::ThisPtr; // ─── Forward-declared opaques (cycle-break: were `bun_uws::*`, tier > 0) ─── -/// Remote socket address as returned by uWS. The IP text is copied into -/// inline storage at construction. -/// -/// Canonical definition moved down from `bun_uws`. -/// Higher tiers (`bun_uws`, `bun_runtime`) re-export this as -/// `pub use bun_uws_sys::SocketAddress;`. -pub struct SocketAddress { - ip: BoundedArray, - pub port: i32, - pub is_ipv6: bool, -} - -impl SocketAddress { - pub(crate) fn new(ip: &[u8], port: i32, is_ipv6: bool) -> SocketAddress { - let ip = &ip[..ip.len().min(64)]; - SocketAddress { - ip: BoundedArray::from_slice(ip).expect("clamped to capacity"), - port, - is_ipv6, - } - } - - pub fn ip(&self) -> &[u8] { - self.ip.as_slice() - } - - pub fn is_loopback(&self) -> bool { - let ip = self.ip(); - // IPv4 loopback addresses - if ip.starts_with(b"127.") { - return true; - } - // IPv6 loopback addresses - if ip.starts_with(b"::ffff:127.") || ip == b"::1" || ip == b"0:0:0:0:0:0:0:1" { - return true; - } - false - } -} - bun_opaque::opaque_ffi! { /// Opaque uWS WebSocket socket handle (forward-decl; concrete type lives in `bun_uws`). pub struct Socket; @@ -410,26 +371,10 @@ impl Response { } } - pub fn get_remote_socket_info(&mut self) -> Option { - let mut ip_ptr: *const u8 = core::ptr::null(); - let mut port: i32 = 0; - let mut is_ipv6: bool = false; - // This function will fill in the slots and return len. - // if len is zero it will not fill in the slots so it is ub to - // return the struct in that case. - let ip_len = - c::uws_res_get_remote_address_info(self.as_raw(), &mut ip_ptr, &mut port, &mut is_ipv6); - if ip_len > 0 { - Some(SocketAddress::new( - // SAFETY: uws populated ip_ptr/ip_len with bytes valid until the next - // address lookup on this thread; copied before returning. - unsafe { bun_core::ffi::slice(ip_ptr, ip_len) }, - port, - is_ipv6, - )) - } else { - None - } + /// `getpeername()` of the connection. `None` for a unix socket, and when the call fails. + pub fn remote_sockaddr(&mut self) -> Option { + // S008: `us_socket_t` is an `opaque_ffi!` ZST — safe deref. + us_socket_t::opaque_ref(self.downcast_socket()).remote_sockaddr() } /// Register an on-writable callback. @@ -811,8 +756,8 @@ impl AnyResponse { } } - pub fn get_remote_socket_info(self) -> Option { - any_dispatch!(self, |r| r.get_remote_socket_info()) + pub fn remote_sockaddr(self) -> Option { + any_dispatch!(self, |r| r.remote_sockaddr()) } pub fn flush_headers(self, flush_immediately: bool) { @@ -1324,14 +1269,6 @@ pub mod c { unsafe extern "C" { pub(crate) safe fn uws_res_mark_fixed_length_file_body(ssl: i32, res: &mut uws_res); pub(crate) safe fn uws_res_state(ssl: c_int, res: &uws_res) -> State; - // Out-params are `&mut` (non-null, valid for write); the C shim only - // stores into them and returns a length — no read-through precondition. - pub(crate) safe fn uws_res_get_remote_address_info( - res: &mut uws_res, - dest: &mut *const u8, - port: &mut i32, - is_ipv6: &mut bool, - ) -> usize; pub(crate) safe fn uws_res_uncork(ssl: i32, res: &mut uws_res); pub(crate) safe fn uws_res_send_corked(ssl: i32, res: &mut uws_res); pub(crate) safe fn uws_res_send_when_complete(ssl: i32, res: &mut uws_res); diff --git a/src/uws_sys/SocketAddress.rs b/src/uws_sys/SocketAddress.rs new file mode 100644 index 000000000000..41b2d39b7108 --- /dev/null +++ b/src/uws_sys/SocketAddress.rs @@ -0,0 +1,132 @@ +//! The text of a socket address that the kernel returned: from `getsockname()`, `getpeername()` or `recvmsg()`. + +use core::ffi::c_int; +use core::fmt; +use core::net::{Ipv4Addr, Ipv6Addr}; + +#[cfg(windows)] +pub use bun_windows_sys::ws2_32::sockaddr_storage; +#[cfg(windows)] +use bun_windows_sys::ws2_32::{AF_INET, AF_INET6, sockaddr_in, sockaddr_in6}; +#[cfg(not(windows))] +pub use libc::sockaddr_storage; +#[cfg(not(windows))] +use libc::{AF_INET, AF_INET6, sockaddr_in, sockaddr_in6}; + +/// Node.js reserves `INET6_ADDRSTRLEN + UV_IF_NAMESIZE` (63) bytes for an address and its zone. +const TEXT_CAPACITY: usize = 64; + +/// Whether `addr`, which is `AF_INET` or `AF_INET6`, is the latter. +pub fn sockaddr_is_ipv6(addr: &sockaddr_storage) -> bool { + c_int::from(addr.ss_family) == AF_INET6 +} + +/// Whether `addr` is a loopback address: 127.0.0.0/8, also as an IPv4-mapped address, or ::1. +pub fn sockaddr_is_loopback(addr: &sockaddr_storage) -> bool { + let (address, _) = SocketAddress::parse(addr); + let ip = address.ip(); + // IPv4 loopback addresses + if ip.starts_with(b"127.") { + return true; + } + // IPv6 loopback addresses + if ip.starts_with(b"::ffff:127.") || ip == b"::1" || ip == b"0:0:0:0:0:0:0:1" { + return true; + } + false +} + +/// An IP address as text, and its port. +pub struct SocketAddress { + text: [u8; TEXT_CAPACITY], + len: u8, + pub port: u16, + pub is_ipv6: bool, +} + +impl SocketAddress { + /// `addr` as `AddressToJS` of Node.js (src/tcp_wrap.cc) prints it. Empty for an `addr` that is not IP. + pub fn from_sockaddr(addr: &sockaddr_storage) -> SocketAddress { + let (mut address, scope_id) = Self::parse(addr); + if scope_id != 0 { + address.append_zone(scope_id); + } + address + } + + /// The address with no zone, and the scope id to print after it: 0 unless the address is link-local. + fn parse(addr: &sockaddr_storage) -> (SocketAddress, u32) { + let mut address = SocketAddress { + text: [0; TEXT_CAPACITY], + len: 0, + port: 0, + is_ipv6: false, + }; + let mut scope_id = 0; + match c_int::from(addr.ss_family) { + AF_INET => { + // SAFETY: the family says that `addr` holds a `sockaddr_in`. + let v4 = unsafe { &*core::ptr::from_ref(addr).cast::() }; + address.port = u16::from_be(v4.sin_port); + // SAFETY: `sin_addr` is the four bytes of the address, in network order. + let octets = unsafe { *(&raw const v4.sin_addr).cast::<[u8; 4]>() }; + address.write(format_args!("{}", Ipv4Addr::from(octets))); + } + AF_INET6 => { + // SAFETY: the family says that `addr` holds a `sockaddr_in6`. + let v6 = unsafe { &*core::ptr::from_ref(addr).cast::() }; + address.port = u16::from_be(v6.sin6_port); + address.is_ipv6 = true; + let octets = v6.sin6_addr.s6_addr; + if octets[0] == 0xfe && octets[1] & 0xc0 == 0x80 { + scope_id = v6.sin6_scope_id; + } + address.write_ipv6(octets); + } + _ => {} + } + (address, scope_id) + } + + /// The text of libuv's `inet_ntop()`. `Ipv6Addr` prints an IPv4-compatible address (`::1.2.3.4`) as `::102:304`. + fn write_ipv6(&mut self, octets: [u8; 16]) { + if octets[..12] == [0; 12] && (octets[12] != 0 || octets[13] != 0) { + let ipv4 = Ipv4Addr::new(octets[12], octets[13], octets[14], octets[15]); + self.write(format_args!("::{ipv4}")); + } else { + self.write(format_args!("{}", Ipv6Addr::from(octets))); + } + } + + /// Appends `%` and the name of the interface, or its index on Windows and for an interface with no name. + fn append_zone(&mut self, scope_id: u32) { + #[cfg(not(windows))] + { + let mut interface = [0u8; libc::IF_NAMESIZE]; + // SAFETY: `if_indextoname()` writes at most `IF_NAMESIZE` bytes, the NUL included. + if !unsafe { libc::if_indextoname(scope_id, interface.as_mut_ptr().cast()) }.is_null() { + let name = bun_core::ffi::slice_to_nul(&interface); + let len = usize::from(self.len); + self.text[len] = b'%'; + self.text[len + 1..len + 1 + name.len()].copy_from_slice(name); + self.len += 1 + name.len() as u8; + return; + } + } + // Node.js throws here. A getter must not: `net.Server` reads the address of a peer on its own. + self.write(format_args!("%{scope_id}")); + } + + /// An address has at most 45 bytes. Only a link-local one gets a zone, and it has at most 39. + fn write(&mut self, text: fmt::Arguments<'_>) { + use std::io::Write as _; + let mut unwritten = &mut self.text[usize::from(self.len)..]; + let capacity = unwritten.len(); + let _ = unwritten.write_fmt(text); + self.len += (capacity - unwritten.len()) as u8; + } + + pub fn ip(&self) -> &[u8] { + &self.text[..usize::from(self.len)] + } +} diff --git a/src/uws_sys/WebSocket.rs b/src/uws_sys/WebSocket.rs index 86d2622d03a8..a12826e4ff84 100644 --- a/src/uws_sys/WebSocket.rs +++ b/src/uws_sys/WebSocket.rs @@ -226,14 +226,10 @@ impl AnyWebSocket { c::uws_ws_is_paused(ssl, ws) } - pub fn get_remote_address<'a>(self, buf: &'a mut [u8]) -> &'a mut [u8] { - let (ssl_flag, ws) = self.split(); - let mut ptr: *mut u8 = core::ptr::null_mut(); - let len = c::uws_ws_get_remote_address(ssl_flag, ws, &mut ptr); - // SAFETY: uWS returns a pointer+len into its internal buffer. - let src = unsafe { bun_core::ffi::slice(ptr, len) }; - buf[..len].copy_from_slice(src); - &mut buf[..len] + /// `getpeername()` of the connection. `None` for a unix socket, and when the call fails. + pub fn remote_sockaddr(self) -> Option { + // S008: `us_socket_t` is an `opaque_ffi!` ZST — safe deref. + uws::us_socket_t::opaque_ref(self.us_socket()).remote_sockaddr() } } @@ -655,13 +651,5 @@ pub mod c { pub(crate) safe fn uws_ws_pause(ssl: i32, ws: &mut RawWebSocket); pub(crate) safe fn uws_ws_resume(ssl: i32, ws: &mut RawWebSocket); pub(crate) safe fn uws_ws_is_paused(ssl: i32, ws: &mut RawWebSocket) -> bool; - // Out-param `dest` is `&mut *mut u8` (non-null, valid for write); the C - // shim only stores a pointer into socket-owned storage and returns its - // length — no read-through precondition, so `safe fn`. - pub(crate) safe fn uws_ws_get_remote_address( - ssl: i32, - ws: &mut RawWebSocket, - dest: &mut *mut u8, - ) -> usize; } } diff --git a/src/uws_sys/h2.rs b/src/uws_sys/h2.rs index 6ae0422f5fee..216f0a19b3d8 100644 --- a/src/uws_sys/h2.rs +++ b/src/uws_sys/h2.rs @@ -3,11 +3,11 @@ //! regardless of transport. Requests are [`crate::h3::Request`]: both //! transports hand C++ the same decoded header list. -use core::ffi::c_void; +use core::ffi::{c_int, c_void}; use core::ptr; -use crate::SocketAddress; use crate::response::{State, WriteResult}; +use crate::sockaddr_storage; use crate::thunk; use crate::{AnyRequest, AnyResponse}; use bun_ptr::ThisPtr; @@ -128,18 +128,9 @@ impl Response { } pub(crate) fn uncork(&mut self) {} pub(crate) fn prepare_for_sendfile(&mut self) {} - pub(crate) fn get_remote_socket_info(&mut self) -> Option { - let mut port: i32 = 0; - let mut is_ipv6: bool = false; - let mut ip_ptr: *const u8 = ptr::null(); - let len = c::uws_h2_res_get_remote_address_info(self, &mut ip_ptr, &mut port, &mut is_ipv6); - if len == 0 { - return None; - } - // SAFETY: uws returns a pointer+len pair valid until the next address lookup - // on this thread; copied before returning. - let ip = unsafe { bun_core::ffi::slice(ip_ptr, len) }; - Some(SocketAddress::new(ip, port, is_ipv6)) + pub(crate) fn remote_sockaddr(&mut self) -> Option { + let mut addr: sockaddr_storage = bun_core::ffi::zeroed(); + (c::uws_h2_res_get_remote_sockaddr(self, &mut addr) != 0).then_some(addr) } pub(crate) fn force_close(&mut self) { c::uws_h2_res_force_close(self) @@ -657,13 +648,9 @@ mod c { ud: *mut c_void, cb: unsafe extern "C" fn(*mut c_void), ); - // Out-params are `&mut` (non-null, valid for write); the C shim only - // stores into them and returns a length — no read-through precondition. - pub(super) safe fn uws_h2_res_get_remote_address_info( + pub(super) safe fn uws_h2_res_get_remote_sockaddr( res: &mut Response, - ip: &mut *const u8, - port: &mut i32, - is_ipv6: &mut bool, - ) -> usize; + out: &mut sockaddr_storage, + ) -> c_int; } } diff --git a/src/uws_sys/h3.rs b/src/uws_sys/h3.rs index 25bb4334f602..7f26f056a831 100644 --- a/src/uws_sys/h3.rs +++ b/src/uws_sys/h3.rs @@ -5,8 +5,8 @@ use core::ffi::{c_char, c_int, c_void}; use core::ptr; -use crate::SocketAddress; use crate::response::{State, WriteResult}; +use crate::sockaddr_storage; use crate::socket_context::BunSocketContextOptions; use crate::thunk; use crate::{AnyRequest, AnyResponse}; @@ -25,19 +25,10 @@ impl ListenSocket { pub fn get_local_port(&mut self) -> Option { u16::try_from(c::uws_h3_listen_socket_port(self)).ok() } - pub fn get_local_address<'a>(&mut self, buf: &'a mut [u8]) -> Option<&'a [u8]> { - // SAFETY: self is a live FFI handle; buf ptr/len valid for write - let n = unsafe { - c::uws_h3_listen_socket_local_address( - self, - buf.as_mut_ptr(), - c_int::try_from(buf.len()).expect("int cast"), - ) - }; - if n <= 0 { - return None; - } - Some(&buf[..usize::try_from(n).expect("int cast")]) + /// The address that the listener is bound to. + pub fn local_sockaddr(&mut self) -> Option { + let mut addr: sockaddr_storage = bun_core::ffi::zeroed(); + (c::uws_h3_listen_socket_local_sockaddr(self, &mut addr) != 0).then_some(addr) } } @@ -184,18 +175,10 @@ impl Response { } pub(crate) fn uncork(&mut self) {} pub(crate) fn prepare_for_sendfile(&mut self) {} - pub(crate) fn get_remote_socket_info(&mut self) -> Option { - let mut port: i32 = 0; - let mut is_ipv6: bool = false; - let mut ip_ptr: *const u8 = ptr::null(); - let len = c::uws_h3_res_get_remote_address_info(self, &mut ip_ptr, &mut port, &mut is_ipv6); - if len == 0 { - return None; - } - // SAFETY: uws returns a pointer+len pair valid until the next address lookup - // on this thread; copied before returning. - let ip = unsafe { bun_core::ffi::slice(ip_ptr, len) }; - Some(SocketAddress::new(ip, port, is_ipv6)) + /// The peer of the connection. An IPv4 peer of a dual-stack socket is `AF_INET`. + pub(crate) fn remote_sockaddr(&mut self) -> Option { + let mut addr: sockaddr_storage = bun_core::ffi::zeroed(); + (c::uws_h3_res_get_remote_sockaddr(self, &mut addr) != 0).then_some(addr) } pub(crate) fn force_close(&mut self) { c::uws_h3_res_force_close(self) @@ -695,10 +678,9 @@ mod c { ud: *mut c_void, ); pub(super) safe fn uws_h3_listen_socket_port(ls: &mut ListenSocket) -> i32; - pub(super) fn uws_h3_listen_socket_local_address( - ls: *mut ListenSocket, - buf: *mut u8, - len: c_int, + pub(super) safe fn uws_h3_listen_socket_local_sockaddr( + ls: &mut ListenSocket, + out: &mut sockaddr_storage, ) -> c_int; pub(super) safe fn uws_h3_listen_socket_close(ls: &mut ListenSocket); @@ -770,14 +752,10 @@ mod c { ud: *mut c_void, cb: unsafe extern "C" fn(*mut c_void), ); - // Out-params are `&mut` (non-null, valid for write); the C shim only - // stores into them and returns a length — no read-through precondition. - pub(super) safe fn uws_h3_res_get_remote_address_info( + pub(super) safe fn uws_h3_res_get_remote_sockaddr( res: &mut Response, - ip: &mut *const u8, - port: &mut i32, - is_ipv6: &mut bool, - ) -> usize; + out: &mut sockaddr_storage, + ) -> c_int; pub(super) safe fn uws_h3_req_set_yield(req: &mut Request, y: bool); // Out-param `out` is `&mut *const u8` (non-null, valid for write); the C diff --git a/src/uws_sys/lib.rs b/src/uws_sys/lib.rs index 9a87274d7d17..476ea6111d30 100644 --- a/src/uws_sys/lib.rs +++ b/src/uws_sys/lib.rs @@ -285,6 +285,8 @@ pub mod quic; pub mod request; #[path = "Response.rs"] pub mod response; +#[path = "SocketAddress.rs"] +pub mod socket_address; #[path = "SocketContext.rs"] pub mod socket_context; #[path = "SocketGroup.rs"] @@ -377,7 +379,8 @@ pub use connecting_socket::ConnectingSocket; pub use listen_socket::ListenSocket; pub use request::{AnyRequest, Request}; pub use response::c::uws_res; -pub use response::{AnyResponse, SocketAddress, WebSocketUpgradeContext}; +pub use response::{AnyResponse, WebSocketUpgradeContext}; +pub use socket_address::{SocketAddress, sockaddr_is_ipv6, sockaddr_is_loopback, sockaddr_storage}; pub use socket_context::BunSocketContextOptions; pub use socket_group::ConnectResult; pub use socket_group::SocketGroup; diff --git a/src/uws_sys/libuwsockets.cpp b/src/uws_sys/libuwsockets.cpp index d9c9eaf338ec..bd6774afef45 100644 --- a/src/uws_sys/libuwsockets.cpp +++ b/src/uws_sys/libuwsockets.cpp @@ -7,8 +7,6 @@ #include #include -extern "C" const char* ares_inet_ntop(int af, const char *src, char *dst, size_t size); - #define uws_res_r uws_res_t* nonnull_arg static inline std::string_view stringViewFromC(const char* message, size_t length) { if(length) { @@ -744,14 +742,6 @@ extern "C" return withWebSocket(ssl, ws, [](auto *uws) { return uws->getBufferedAmount(); }); } - size_t uws_ws_get_remote_address(int ssl, uws_websocket_t *ws, - const char **dest) - { - std::string_view value = withWebSocket(ssl, ws, [](auto *uws) { return uws->getRemoteAddress(); }); - *dest = value.data(); - return value.length(); - } - void uws_res_end(int ssl, uws_res_r res, const char *data, size_t length, bool close_connection) { @@ -1528,31 +1518,6 @@ __attribute__((callback (corker, ctx))) return us_poll_fd(&s->p); } - // Gets the remote address and port - // Returns 0 if failure / unix socket - uint64_t uws_res_get_remote_address_info(uws_res_r res, const char **dest, int *port, bool *is_ipv6) - { - // This function is manual inlining + modification of - // us_socket_remote_address - // AsyncSocket::getRemoteAddress - // To get { ip, port, is_ipv6 } for Bun.serve().requestIP() - static thread_local char b[64]; - auto length = us_get_remote_address_info(b, (us_socket_t *)res, port); - - if (length == 0) return 0; - if (length == 4) { - ares_inet_ntop(AF_INET, b, &b[4], 64 - 4); - *dest = &b[4]; - *is_ipv6 = false; - return strlen(*dest); - } else { - ares_inet_ntop(AF_INET6, b, &b[16], 64 - 16); - *dest = &b[16]; - *is_ipv6 = true; - return strlen(*dest); - } - } - // A thread that ran a uws loop (a Worker) is exiting; free its loop. On Windows the loop sits on // the thread's libuv loop, which the caller closes after this returns. extern "C" void bun_free_loop_at_thread_exit() { diff --git a/src/uws_sys/libuwsockets_h2.cpp b/src/uws_sys/libuwsockets_h2.cpp index 0c9fd03056fd..b922b666e5cc 100644 --- a/src/uws_sys/libuwsockets_h2.cpp +++ b/src/uws_sys/libuwsockets_h2.cpp @@ -186,12 +186,9 @@ void uws_h2_res_cork(uws_h2_res_t* res, void* ctx, void (*corker)(void*)) ((Http2Response*)res)->cork([ctx, corker]() { corker(ctx); }); } -typedef struct uws_res_s uws_res_t; -uint64_t uws_res_get_remote_address_info(uws_res_t* res, const char** dest, int* port, bool* is_ipv6); -uint64_t uws_h2_res_get_remote_address_info(uws_h2_res_t* res, const char** dest, int* port, bool* is_ipv6) +int uws_h2_res_get_remote_sockaddr(uws_h2_res_t* res, struct sockaddr_storage* out) { - /* The HTTP/1 shim only reads the us_socket_t behind the response. */ - return uws_res_get_remote_address_info((uws_res_t*)((Http2Response*)res)->conn->s, dest, port, is_ipv6); + return us_socket_remote_sockaddr(((Http2Response*)res)->conn->s, out); } #pragma clang attribute pop diff --git a/src/uws_sys/libuwsockets_h3.cpp b/src/uws_sys/libuwsockets_h3.cpp index 1e0fce8baa5b..eca03aeaf0db 100644 --- a/src/uws_sys/libuwsockets_h3.cpp +++ b/src/uws_sys/libuwsockets_h3.cpp @@ -14,8 +14,6 @@ #include // clang-format on -extern "C" const char* ares_inet_ntop(int af, const char* src, char* dst, size_t size); - using uWS::H3App; using uWS::Http3Request; using uWS::Http3Response; @@ -87,9 +85,9 @@ void uws_h3_app_listen_with_config(uws_h3_app_t* app, const char* host, uint16_t } int uws_h3_listen_socket_port(us_quic_udp_t* ls) { return us_quic_listen_socket_port(ls); } -int uws_h3_listen_socket_local_address(us_quic_udp_t* ls, char* buf, int len) +int uws_h3_listen_socket_local_sockaddr(us_quic_udp_t* ls, struct sockaddr_storage* out) { - return us_quic_listen_socket_local_address(ls, buf, len); + return us_quic_listen_socket_local_sockaddr(ls, out); } void uws_h3_listen_socket_close(us_quic_udp_t* ls) { us_quic_listen_socket_close(ls); } @@ -202,35 +200,10 @@ void uws_h3_res_cork(uws_h3_res_t* res, void* ctx, void (*corker)(void*)) ((Http3Response*)res)->cork([ctx, corker]() { corker(ctx); }); } -uint64_t uws_h3_res_get_remote_address_info(uws_h3_res_t* res, const char** dest, int* port, bool* is_ipv6) +int uws_h3_res_get_remote_sockaddr(uws_h3_res_t* res, struct sockaddr_storage* out) { - /* Mirror uws_res_get_remote_address_info: stringify with inet_ntop so the - * caller gets a text slice, not raw in_addr bytes. */ - static thread_local char b[64]; - int len = 0, ipv6 = 0; us_quic_socket_t* qs = us_quic_stream_socket((us_quic_stream_t*)res); - if (!qs) { - *dest = b; - *port = 0; - *is_ipv6 = false; - return 0; - } - us_quic_socket_remote_address(qs, b, &len, port, &ipv6); - if (len == 0) { - *dest = b; - *is_ipv6 = false; - return 0; - } - if (len == 4) { - ares_inet_ntop(AF_INET, b, &b[4], 64 - 4); - *dest = &b[4]; - *is_ipv6 = false; - } else { - ares_inet_ntop(AF_INET6, b, &b[16], 64 - 16); - *dest = &b[16]; - *is_ipv6 = true; - } - return (uint64_t)strlen(*dest); + return qs ? us_quic_socket_remote_sockaddr(qs, out) : 0; } /* ───── request ───── */ diff --git a/src/uws_sys/socket.rs b/src/uws_sys/socket.rs index efaef8df57c1..696a58353bb9 100644 --- a/src/uws_sys/socket.rs +++ b/src/uws_sys/socket.rs @@ -17,7 +17,7 @@ use bun_core::{Fd, ZStr}; use crate::{ CloseCode, ConnectResult, ConnectingSocket, LIBUS_SOCKET_DESCRIPTOR, QueuedInput, SocketGroup, - SocketKind, SslCtx, us_bun_verify_error_t, us_socket_t, + SocketKind, SslCtx, sockaddr_storage, us_bun_verify_error_t, us_socket_t, }; bun_core::declare_scope!(uws, visible); @@ -754,24 +754,22 @@ impl NewSocketHandler { } } - pub fn local_address<'b>(&self, buf: &'b mut [u8]) -> Option<&'b [u8]> { - match self.socket { - // getsockname() can fail (EBADF/ENOTCONN/…) on a socket the OS - // closed or reset underneath us; callers treat that as "no address". - InternalSocket::Connected(s) | InternalSocket::UwsHttp(s) => { - sock(s).local_address(buf).ok() - } - _ => None, - } + /// `getsockname()`. `None` when the call fails, and for a socket that is not connected over IP. + pub fn local_sockaddr(&self) -> Option { + on_socket!(self.socket; + connected s => s.local_sockaddr(), + transport _t => None, + else => None, + ) } - pub fn remote_address<'b>(&self, buf: &'b mut [u8]) -> Option<&'b [u8]> { - match self.socket { - InternalSocket::Connected(s) | InternalSocket::UwsHttp(s) => { - sock(s).remote_address(buf).ok() - } - _ => None, - } + /// `getpeername()`, as [`Self::local_sockaddr`]. + pub fn remote_sockaddr(&self) -> Option { + on_socket!(self.socket; + connected s => s.remote_sockaddr(), + transport _t => None, + else => None, + ) } pub fn mark_needs_more_for_sendfile(&self) { diff --git a/src/uws_sys/udp.rs b/src/uws_sys/udp.rs index 7518f776f216..988f440fd687 100644 --- a/src/uws_sys/udp.rs +++ b/src/uws_sys/udp.rs @@ -110,14 +110,16 @@ impl Socket { us_udp_socket_bound_port(self) } - pub fn bound_ip(&mut self, buf: *mut u8, length: &mut i32) { - // SAFETY: buf must point to at least *length bytes; thin FFI passthrough. - unsafe { us_udp_socket_bound_ip(self, buf, length) } + /// `getsockname()`. `None` when the call fails. + pub fn local_sockaddr(&mut self) -> Option { + let mut addr: sockaddr_storage = bun_core::ffi::zeroed(); + (us_udp_socket_local_sockaddr(self, &mut addr) != 0).then_some(addr) } - pub fn remote_ip(&mut self, buf: *mut u8, length: &mut i32) { - // SAFETY: buf must point to at least *length bytes; thin FFI passthrough. - unsafe { us_udp_socket_remote_ip(self, buf, length) } + /// `getpeername()`. `None` when the socket is not connected. + pub fn remote_sockaddr(&mut self) -> Option { + let mut addr: sockaddr_storage = bun_core::ffi::zeroed(); + (us_udp_socket_remote_sockaddr(self, &mut addr) != 0).then_some(addr) } pub fn close(&mut self) { @@ -219,8 +221,9 @@ unsafe extern "C" { ) -> c_int; safe fn us_udp_socket_user(socket: &mut Socket) -> *mut c_void; safe fn us_udp_socket_bound_port(socket: &mut Socket) -> c_int; - fn us_udp_socket_bound_ip(socket: *mut Socket, buf: *mut u8, length: *mut i32); - fn us_udp_socket_remote_ip(socket: *mut Socket, buf: *mut u8, length: *mut i32); + safe fn us_udp_socket_local_sockaddr(socket: &mut Socket, out: &mut sockaddr_storage) -> c_int; + safe fn us_udp_socket_remote_sockaddr(socket: &mut Socket, out: &mut sockaddr_storage) + -> c_int; safe fn us_udp_socket_close(socket: &mut Socket); safe fn us_udp_socket_set_broadcast(socket: &mut Socket, enabled: c_int) -> c_int; safe fn us_udp_socket_buffer_size( diff --git a/src/uws_sys/us_socket_t.rs b/src/uws_sys/us_socket_t.rs index e0a786350133..45aa961e7b46 100644 --- a/src/uws_sys/us_socket_t.rs +++ b/src/uws_sys/us_socket_t.rs @@ -3,7 +3,7 @@ use core::ptr::{self, NonNull}; use bun_core::Fd; -use crate::{LIBUS_SOCKET_DESCRIPTOR, SocketGroup, SocketKind, SslCtx}; +use crate::{LIBUS_SOCKET_DESCRIPTOR, SocketGroup, SocketKind, SslCtx, sockaddr_storage}; bun_core::declare_scope!(uws, visible); @@ -149,36 +149,16 @@ impl us_socket_t { u16::try_from(c::us_socket_remote_port(self)).ok() } - /// Returned slice is a view into `buf`. - pub(crate) fn local_address<'a>(&self, buf: &'a mut [u8]) -> Result<&'a [u8], crate::Error> { - let mut length: i32 = i32::try_from(buf.len().min(MAX_I32)).expect("int cast"); - unsafe { - // SAFETY: buf.as_mut_ptr() valid for `length` bytes; length is in/out - c::us_socket_local_address(self, buf.as_mut_ptr(), &raw mut length); - } - if length < 0 { - return Err(crate::Error::Sys(bun_errno::SystemErrno::from_raw( - bun_errno::last_error() as u16, - ))); - } - debug_assert!(buf.len() >= length as usize); - Ok(&buf[..usize::try_from(length).expect("int cast")]) + /// `getsockname()`. `None` when the call fails, and for a socket that is not `AF_INET` or `AF_INET6`. + pub(crate) fn local_sockaddr(&self) -> Option { + let mut addr: sockaddr_storage = bun_core::ffi::zeroed(); + (c::us_socket_local_sockaddr(self, &mut addr) != 0).then_some(addr) } - /// Returned slice is a view into `buf`. On error, `errno` should be set. - pub(crate) fn remote_address<'a>(&self, buf: &'a mut [u8]) -> Result<&'a [u8], crate::Error> { - let mut length: i32 = i32::try_from(buf.len().min(MAX_I32)).expect("int cast"); - unsafe { - // SAFETY: buf.as_mut_ptr() valid for `length` bytes; length is in/out - c::us_socket_remote_address(self, buf.as_mut_ptr(), &raw mut length); - } - if length < 0 { - return Err(crate::Error::Sys(bun_errno::SystemErrno::from_raw( - bun_errno::last_error() as u16, - ))); - } - debug_assert!(buf.len() >= length as usize); - Ok(&buf[..usize::try_from(length).expect("int cast")]) + /// `getpeername()`, as [`Self::local_sockaddr`]. + pub(crate) fn remote_sockaddr(&self) -> Option { + let mut addr: sockaddr_storage = bun_core::ffi::zeroed(); + (c::us_socket_remote_sockaddr(self, &mut addr) != 0).then_some(addr) } pub(crate) fn set_timeout(&mut self, seconds: u32) { @@ -479,16 +459,14 @@ mod c { pub(super) safe fn us_socket_local_port(s: &us_socket_t) -> i32; pub(super) safe fn us_socket_remote_port(s: &us_socket_t) -> i32; - pub(super) fn us_socket_remote_address( - s: *const us_socket_t, - buf: *mut u8, - length: *mut i32, - ); - pub(super) fn us_socket_local_address( - s: *const us_socket_t, - buf: *mut u8, - length: *mut i32, - ); + pub(super) safe fn us_socket_local_sockaddr( + s: &us_socket_t, + out: &mut sockaddr_storage, + ) -> c_int; + pub(super) safe fn us_socket_remote_sockaddr( + s: &us_socket_t, + out: &mut sockaddr_storage, + ) -> c_int; pub(super) safe fn us_socket_timeout(s: &mut us_socket_t, seconds: c_uint); pub(super) safe fn us_socket_nodelay(s: &mut us_socket_t, enable: c_int); pub(super) safe fn us_socket_set_tos(s: &mut us_socket_t, tos: c_int) -> c_int; diff --git a/test/harness.ts b/test/harness.ts index 731dcf529d62..f52fba1115e8 100644 --- a/test/harness.ts +++ b/test/harness.ts @@ -2039,6 +2039,54 @@ export function isIPv4() { return isIP("IPv4"); } +let linkLocal: { address: string; zone: string; scoped: string } | null | undefined; + +/** + * An fe80::/10 address that a socket of this host can bind, and the zone that Node.js prints after it: the name of the + * interface, or its index on Windows. `scoped` is `address%zone`. Undefined when no interface has one, as on a host + * without IPv6. + */ +export function linkLocalIPv6() { + if (linkLocal === undefined) { + linkLocal = null; + for (const [name, addresses] of Object.entries(os.networkInterfaces())) { + const found = addresses?.find(a => a.family === "IPv6" && a.address.startsWith("fe80:")); + if (!found) continue; + const zone = isWindows ? String(found.scopeid) : name; + const scoped = `${found.address}%${zone}`; + try { + Bun.listen({ hostname: scoped, port: 0, socket: { data() {} } }).stop(true); + } catch { + continue; + } + linkLocal = { address: found.address, zone, scoped }; + break; + } + } + return linkLocal ?? undefined; +} + +let canMakeInterface: boolean | undefined; + +/** + * The command that runs the Bun under test with `args` in a network namespace of its own, where the interface `name` + * has the link-local address fe80::1. Undefined where a test cannot make an interface: that takes Linux, unshare(1), + * ip(8) and the right to make a user namespace. + */ +export function cmdWithLinkLocalInterface(name: string, args: string[]) { + // A connection to an address of the host itself goes through the loopback interface. + const setup = + 'ip link set lo up && ip link add name "$0" type veth peer name peer && ip link set "$0" up && ' + + 'ip link set peer up && ip -6 addr add fe80::1/64 dev "$0" nodad && exec "$@"'; + const cmd = (command: string[]) => ["unshare", "-Urn", "sh", "-c", setup, name, ...command]; + canMakeInterface ??= + isLinux && + !!which("unshare") && + !!which("ip") && + spawnSync({ cmd: cmd(["true"]), env: bunEnv, stdout: "ignore", stderr: "ignore" }).exitCode === 0; + return canMakeInterface ? cmd([bunExe(), ...args]) : undefined; +} + let glibcVersion: string | undefined; export function getGlibcVersion() { diff --git a/test/js/bun/http/bun-server.test.ts b/test/js/bun/http/bun-server.test.ts index 1ca707e29d54..015e9d392af7 100644 --- a/test/js/bun/http/bun-server.test.ts +++ b/test/js/bun/http/bun-server.test.ts @@ -4,7 +4,9 @@ import { bunEnv, bunExe, bunRun, + isLinux, isWindows, + linkLocalIPv6, normalizeBunSnapshot, rejectUnauthorizedScope, tempDir, @@ -233,6 +235,67 @@ describe.concurrent("Server", () => { expect({ status: response.status, text: await response.text() }).toEqual({ status: 200, text: "Hello" }); }); + // A link-local address names a peer only together with its interface, so an address without the zone is one + // that Bun.connect() and node:net cannot reach. + test.skipIf(!linkLocalIPv6())("address and requestIP() have the zone of a link-local address", async () => { + const { scoped } = linkLocalIPv6()!; + using server = Bun.serve({ + port: 0, + hostname: scoped, + fetch: (req, server) => Response.json(server.requestIP(req)), + }); + expect(server.address).toEqual({ address: scoped, family: "IPv6", port: server.port }); + + // A URL cannot hold a zone, so the request goes over a socket. + const received: Buffer[] = []; + const { resolve, promise } = Promise.withResolvers(); + const connection = await Bun.connect({ + hostname: server.address.address, + port: server.port, + socket: { + data: (_socket, data) => void received.push(data), + close: () => resolve(), + }, + }); + const { localPort } = connection; + connection.write("GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"); + await promise; + expect(JSON.parse(Buffer.concat(received).toString().split("\r\n\r\n")[1])).toEqual({ + address: scoped, + family: "IPv6", + port: localPort, + }); + }); + + // The address of a server that listens for HTTP/3 alone is the one of its QUIC socket. + test.skipIf(!linkLocalIPv6())("address of an HTTP/3 server has the zone of a link-local address", () => { + const { scoped } = linkLocalIPv6()!; + using server = Bun.serve({ + port: 0, + hostname: scoped, + tls, + http3: true, + http1: false, + fetch: () => new Response("ok"), + }); + expect(server.address).toEqual({ address: scoped, family: "IPv6", port: server.port }); + }); + + // Where both ends of a connection have one address, nothing shows which of them requestIP() read. A connection + // to 127.0.0.2 leaves from 127.0.0.1 on Linux. + test.skipIf(!isLinux)("requestIP() is the address of the client, and address is that of the server", async () => { + using server = Bun.serve({ + port: 0, + hostname: "0.0.0.0", + fetch: (req, server) => Response.json(server.requestIP(req)), + }); + const response = await fetch(`http://127.0.0.2:${server.port}/`); + expect({ requestIP: await response.json(), address: server.address }).toEqual({ + requestIP: { address: "127.0.0.1", family: "IPv4", port: expect.any(Number) }, + address: { address: "0.0.0.0", family: "IPv4", port: server.port }, + }); + }); + test("allows listen on IPV6", async () => { { using server = Bun.serve({ diff --git a/test/js/bun/net/tcp-server.test.ts b/test/js/bun/net/tcp-server.test.ts index 6d8e32e9eaaa..4fb1eaa03968 100644 --- a/test/js/bun/net/tcp-server.test.ts +++ b/test/js/bun/net/tcp-server.test.ts @@ -1,6 +1,6 @@ -import { connect, listen, SocketHandler, TCPSocketListener } from "bun"; +import { connect, listen, type Socket, SocketHandler, TCPSocketListener } from "bun"; import { describe, expect, it } from "bun:test"; -import { bunEnv, bunExe, isWindows } from "harness"; +import { bunEnv, bunExe, isLinux, isWindows, linkLocalIPv6, tls } from "harness"; import { join } from "node:path"; type Resolve = (value?: unknown) => void; @@ -58,6 +58,82 @@ it("remoteAddress works", async () => { await prom; }); +const addressesOf = (socket: Socket) => ({ + localAddress: socket.localAddress, + localFamily: socket.localFamily, + remoteAddress: socket.remoteAddress, + remoteFamily: socket.remoteFamily, +}); +const transports = [ + ["TCP", undefined, undefined], + ["TLS", tls, { rejectUnauthorized: false }], +] as const; + +// A link-local address names a peer only together with its interface, so a +// socket that prints one without the zone prints an address that does not connect. +it.skipIf(!linkLocalIPv6()).each(transports)( + "localAddress and remoteAddress of a %s socket have the zone of a link-local address", + async (_name, serverTLS, clientTLS) => { + const { scoped } = linkLocalIPv6()!; + const accepted = Promise.withResolvers>(); + using server = Bun.listen({ + hostname: scoped, + port: 0, + tls: serverTLS, + socket: { open: socket => accepted.resolve(addressesOf(socket)), data() {} }, + }); + const client = await Bun.connect({ hostname: scoped, port: server.port, tls: clientTLS, socket: { data() {} } }); + try { + const expected = { localAddress: scoped, localFamily: "IPv6", remoteAddress: scoped, remoteFamily: "IPv6" }; + expect({ client: addressesOf(client), accepted: await accepted.promise }).toEqual({ + client: expected, + accepted: expected, + }); + + // The address that a socket printed connects. + const again = await Bun.connect({ + hostname: client.remoteAddress, + port: server.port, + tls: clientTLS, + socket: { data() {} }, + }); + expect(again.remoteAddress).toBe(scoped); + again.end(); + } finally { + client.end(); + } + }, +); + +// Where both ends of a connection have one address, nothing shows which of them a getter read. A connection to +// 127.0.0.2 leaves from 127.0.0.1 on Linux. +it.skipIf(!isLinux).each(transports)( + "localAddress and remoteAddress are the two ends of a %s connection", + async (_name, serverTLS, clientTLS) => { + const accepted = Promise.withResolvers>(); + using server = Bun.listen({ + hostname: "0.0.0.0", + port: 0, + tls: serverTLS, + socket: { open: socket => accepted.resolve(addressesOf(socket)), data() {} }, + }); + const client = await Bun.connect({ + hostname: "127.0.0.2", + port: server.port, + tls: clientTLS, + socket: { data() {} }, + }); + try { + expect({ client: addressesOf(client), accepted: await accepted.promise }).toEqual({ + client: { localAddress: "127.0.0.1", localFamily: "IPv4", remoteAddress: "127.0.0.2", remoteFamily: "IPv4" }, + accepted: { localAddress: "127.0.0.2", localFamily: "IPv4", remoteAddress: "127.0.0.1", remoteFamily: "IPv4" }, + }); + } finally { + client.end(); + } + }, +); + it("should not allow invalid tls option", () => { [1, "string", Symbol("symbol")].forEach(value => { expect(() => { diff --git a/test/js/bun/udp/udp_socket.test.ts b/test/js/bun/udp/udp_socket.test.ts index bd3a7919e252..bf7420d91690 100644 --- a/test/js/bun/udp/udp_socket.test.ts +++ b/test/js/bun/udp/udp_socket.test.ts @@ -1,17 +1,22 @@ import { udpSocket } from "bun"; +import { formatSocketAddress } from "bun:internal-for-testing"; import { heapStats } from "bun:jsc"; import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, + cmdWithLinkLocalInterface, disableAggressiveGCScope, expectRssDeltaBelow, isIPv6, isWindows, + linkLocalIPv6, + nodeExeAtLeast, randomPort, tempDir, } from "harness"; import { closeSync, openSync } from "node:fs"; +import { networkInterfaces } from "node:os"; import path from "node:path"; import { dataCases, dataTypes } from "./testdata"; @@ -332,6 +337,145 @@ describe("udpSocket()", () => { } }); + // A link-local address names a peer only together with its interface, so a + // socket that prints one without the zone prints an address nothing can use. + test.skipIf(!linkLocalIPv6())("address and remoteAddress have the zone of a link-local address", async () => { + const { scoped } = linkLocalIPv6()!; + const { promise, resolve } = Promise.withResolvers<{ address: string; port: number }>(); + using server = await udpSocket({ + hostname: scoped, + socket: { data: (_socket, _data, port, address) => resolve({ address, port }) }, + }); + expect(server.address).toEqual({ address: scoped, family: "IPv6", port: server.port }); + + // The address that the server printed connects to it. + using client = await udpSocket({ + hostname: "::", + connect: { hostname: server.address.address, port: server.port }, + }); + expect({ remoteAddress: client.remoteAddress, address: client.address }).toEqual({ + remoteAddress: { address: scoped, family: "IPv6", port: server.port }, + address: { address: scoped, family: "IPv6", port: client.port }, + }); + client.send("hello"); + expect(await promise).toEqual({ address: scoped, port: client.port }); + }); + + // The rule of every socket address, on addresses that no interface of the host needs to have: + // https://github.com/nodejs/node/blob/v26.3.0/src/tcp_wrap.cc#L520-L596 + test("only a link-local address with a scope id is printed with a zone", () => { + // "lo" or "lo0". On Windows a zone is the index of an interface, and any index prints. + const zone = isWindows + ? "1" + : Object.entries(networkInterfaces()).find(([, addresses]) => addresses?.some(a => a.internal))![0]; + const printed = (host: string) => formatSocketAddress(host, 4321); + const ipv6 = (address: string) => ({ address, family: "IPv6", port: 4321 }); + + expect({ + linkLocal: printed(`fe80::1%${zone}`), + endOfLinkLocal: printed(`febf:ffff::1%${zone}`), + linkLocalWithoutScope: printed("fe80::1"), + siteLocal: printed(`fec0::1%${zone}`), + linkLocalMulticast: printed(`ff02::1%${zone}`), + loopback: printed(`::1%${zone}`), + mapped: printed(`::ffff:127.0.0.1%${zone}`), + any: printed("::"), + ipv4: printed("127.0.0.1"), + // An interface that is gone has no name. Node.js throws there, what libuv's uv_if_indextoiid() returned. + interfaceWithoutName: printed("fe80::1%2147483647"), + // libuv's inet_ntop(), as Node.js prints an address: an IPv4 address in ::/96 or in ::ffff:0:0/96 is dotted. + ipv4Compatible: printed("::102:304"), + ipv4Mapped: printed("::ffff:102:304"), + nat64: printed("64:ff9b::102:304"), + longestZeroRunFirst: printed("2001:db8:0:0:1:0:0:1"), + }).toEqual({ + linkLocal: ipv6(`fe80::1%${zone}`), + endOfLinkLocal: ipv6(`febf:ffff::1%${zone}`), + linkLocalWithoutScope: ipv6("fe80::1"), + siteLocal: ipv6("fec0::1"), + linkLocalMulticast: ipv6("ff02::1"), + loopback: ipv6("::1"), + mapped: ipv6("::ffff:127.0.0.1"), + any: ipv6("::"), + ipv4: { address: "127.0.0.1", family: "IPv4", port: 4321 }, + interfaceWithoutName: ipv6("fe80::1%2147483647"), + ipv4Compatible: ipv6("::1.2.3.4"), + ipv4Mapped: ipv6("::ffff:1.2.3.4"), + nat64: ipv6("64:ff9b::102:304"), + longestZeroRunFirst: ipv6("2001:db8::1:0:0:1"), + }); + }); + + // The text is that of libuv's inet_ntop(). net.SocketAddress of Node.js prints an address with it. + test.skipIf(!nodeExeAtLeast("26.3.0"))("an IPv6 address is printed as Node.js prints it", async () => { + // A fixed sequence, so that a failure can be run again. + let state = 0x9e3779b9; + const next = () => { + state ^= state << 13; + state ^= state >>> 17; + state ^= state << 5; + return state >>> 0; + }; + // A group is zero three times in four, so that runs of zeros of every length are at every place. + const addresses = Array.from({ length: 2000 }, () => + Array.from({ length: 8 }, () => (next() % 4 === 0 ? (next() % 0x10000).toString(16) : "0")).join(":"), + ); + const script = ` + const { SocketAddress } = require("node:net"); + const addresses = JSON.parse(require("node:fs").readFileSync(0, "utf8")); + console.log(JSON.stringify(addresses.map(address => new SocketAddress({ address, family: "ipv6" }).address))); + `; + await using proc = Bun.spawn({ + cmd: [nodeExeAtLeast("26.3.0")!, "-e", script], + env: bunEnv, + stdin: new Blob([JSON.stringify(addresses)]), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stderr, exitCode }).toEqual({ stderr: "", exitCode: 0 }); + expect(addresses.map(address => formatSocketAddress(address, 1).address)).toEqual(JSON.parse(stdout)); + }); + + // The name of an interface is bytes that the OS chose. os.networkInterfaces() reads them as UTF-8, and so does a + // zone: the printed address then names the interface again. Node.js reads a zone as Latin-1: + // https://github.com/nodejs/node/blob/v26.3.0/src/tcp_wrap.cc#L555 + test.skipIf(!cmdWithLinkLocalInterface("vé0", []))("the zone of an interface whose name is not ASCII", async () => { + const script = ` + const dgram = require("node:dgram"); + const server = dgram.createSocket("udp6"); + server.bind(0, "fe80::1%vé0", () => { + const { address, port } = server.address(); + const client = dgram.createSocket("udp6"); + client.connect(port, address, error => { + console.log(JSON.stringify({ + address, + interfaces: Object.keys(require("node:os").networkInterfaces()).sort(), + remoteAddress: error ? error.message : client.remoteAddress().address, + })); + client.close(); + server.close(); + }); + }); + `; + await using proc = Bun.spawn({ + cmd: cmdWithLinkLocalInterface("vé0", ["-e", script])!, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ + stdout: JSON.stringify({ + address: "fe80::1%vé0", + interfaces: ["lo", "peer", "vé0"], + remoteAddress: "fe80::1%vé0", + }), + stderr: "", + exitCode: 0, + }); + }); + // The Strong ref on the JS wrapper used to be left in place when udpSocket() // threw before the underlying uws socket was created (invalid options, bind // failure), pinning the wrapper forever and leaking the Zig struct. diff --git a/test/js/bun/websocket/websocket-server.test.ts b/test/js/bun/websocket/websocket-server.test.ts index 7eeffbc84132..ad44af015deb 100644 --- a/test/js/bun/websocket/websocket-server.test.ts +++ b/test/js/bun/websocket/websocket-server.test.ts @@ -2,7 +2,18 @@ import type { Server, ServerWebSocket, Subprocess, WebSocketHandler } from "bun" import { serve, spawn } from "bun"; import { estimateShallowMemoryUsageOf } from "bun:jsc"; import { afterEach, describe, expect, it } from "bun:test"; -import { bunEnv, bunExe, forceGuardMalloc, isASAN, isDebug, isWindows, tempDir, tls as tlsCert } from "harness"; +import { + bunEnv, + bunExe, + forceGuardMalloc, + isASAN, + isDebug, + isLinux, + isWindows, + linkLocalIPv6, + tempDir, + tls as tlsCert, +} from "harness"; import { once } from "node:events"; import net, { isIP } from "node:net"; import path from "node:path"; @@ -3765,6 +3776,65 @@ describe.concurrent("ServerWebSocket argument validation", () => { }); }); +// A link-local address names a peer only together with its interface. +it.skipIf(!linkLocalIPv6())("ws.remoteAddress has the zone of a link-local peer", async () => { + const { scoped } = linkLocalIPv6()!; + const address = Promise.withResolvers(); + using server = serve({ + port: 0, + hostname: scoped, + fetch(req, server) { + if (server.upgrade(req)) return; + return new Response("no", { status: 400 }); + }, + websocket: { + open(ws) { + address.resolve(ws.remoteAddress); + ws.close(); + }, + message() {}, + }, + }); + // A URL cannot hold a zone, so the handshake goes over a socket. + const client = await Bun.connect({ hostname: scoped, port: server.port!, socket: { data() {} } }); + try { + client.write( + "GET / HTTP/1.1\r\nHost: localhost\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" + + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n", + ); + expect(await address.promise).toBe(scoped); + } finally { + client.end(); + } +}); + +// Where both ends of a connection have one address, nothing shows which of them the getter read. A connection to +// 127.0.0.2 leaves from 127.0.0.1 on Linux. +it.skipIf(!isLinux)("ws.remoteAddress is the address of the client", async () => { + const address = Promise.withResolvers(); + using server = serve({ + port: 0, + hostname: "0.0.0.0", + fetch(req, server) { + if (server.upgrade(req)) return; + return new Response("no", { status: 400 }); + }, + websocket: { + open(ws) { + address.resolve(ws.remoteAddress); + ws.close(); + }, + message() {}, + }, + }); + const closed = Promise.withResolvers(); + const ws = new WebSocket(`ws://127.0.0.2:${server.port}/`); + ws.onerror = () => address.reject(new Error("the WebSocket did not connect")); + ws.onclose = () => closed.resolve(); + expect(await address.promise).toBe("127.0.0.1"); + await closed.promise; +}); + // The fd is closed before close() runs, so the address has to be read while the socket is still open. describe.concurrent("ws.remoteAddress read in open() is still available in close()", () => { it.each(["the client sends a Close frame", "the client drops the connection", "ws.close()", "ws.terminate()"])( diff --git a/test/js/first_party/ws/ws-compat-shape.test.ts b/test/js/first_party/ws/ws-compat-shape.test.ts index 13ef27089e33..c3dafdde1a5d 100644 --- a/test/js/first_party/ws/ws-compat-shape.test.ts +++ b/test/js/first_party/ws/ws-compat-shape.test.ts @@ -1,7 +1,9 @@ // Tests the `ws` module's exports, the shape of the WebSocket class and its instances, and pause() / resume(). See // ws-raw-peer.ts. +import { dnsCacheSeed } from "bun:internal-for-testing"; import { describe, expect, test } from "bun:test"; -import EventEmitter from "node:events"; +import { linkLocalIPv6 } from "harness"; +import EventEmitter, { once } from "node:events"; import util from "node:util"; import * as builtinNamespace from "ws"; import * as npmNamespace from "../../../node_modules/ws/wrapper.mjs"; @@ -429,3 +431,29 @@ describe.each(implementations)("ws API shape ($name)", ({ ws: implementation, bu ); }); }); + +// The `_socket` of the built-in client is no net.Socket. It prints its peer as one does. +test.skipIf(!linkLocalIPv6())("_socket of a client has the zone of a link-local peer", async () => { + const { scoped } = linkLocalIPv6()!; + using server = Bun.serve({ + port: 0, + hostname: scoped, + fetch: (req, server) => (server.upgrade(req) ? undefined : new Response("no", { status: 400 })), + websocket: { message() {} }, + }); + // A URL cannot hold a zone, so the client gets the address from a name. + const hostname = `link-local-${server.port}.test`; + dnsCacheSeed(hostname, [scoped]); + const ws = new builtinNamespace.WebSocket(`ws://${hostname}:${server.port}/`); + try { + await once(ws, "open"); + const { remoteAddress, remoteFamily, remotePort } = (ws as any)._socket; + expect({ remoteAddress, remoteFamily, remotePort }).toEqual({ + remoteAddress: scoped, + remoteFamily: "IPv6", + remotePort: server.port, + }); + } finally { + ws.terminate(); + } +}); diff --git a/test/js/node/dns/dns-resolver-class.test.ts b/test/js/node/dns/dns-resolver-class.test.ts index 5124663172ee..fdcb87f81cc0 100644 --- a/test/js/node/dns/dns-resolver-class.test.ts +++ b/test/js/node/dns/dns-resolver-class.test.ts @@ -1,6 +1,6 @@ // node-dns.test.js resolves public hostnames. These tests need no network. import { describe, expect, test } from "bun:test"; -import { runWithError } from "harness"; +import { bunEnv, bunExe, runWithError } from "harness"; import dns from "node:dns"; describe.each([ @@ -46,3 +46,53 @@ describe.each([ expect(missing).toEqual(runWithError(() => handle[method](undefined))); }); }); + +// A socket prints a link-local peer with its zone, and a program hands that text to these. Node.js answers +// ENOTFOUND for it too. +test("reverse() and lookupService() ask for the name of an address with a zone as for the address alone", async () => { + const script = ` + const dgram = require("node:dgram"); + const dns = require("node:dns"); + + // A name server that has no name for any address. + const asked = new Set(); + const server = dgram.createSocket("udp4"); + server.on("message", (query, { port, address }) => { + let end = 12; + const labels = []; + while (query[end] !== 0) { + labels.push(query.toString("latin1", end + 1, end + 1 + query[end])); + end += 1 + query[end]; + } + asked.add(labels.join(".")); + const answer = Buffer.from(query.subarray(0, end + 5)); + answer[2] |= 0x80; // QR + answer[3] = 3; // NXDOMAIN + answer.fill(0, 6, 12); // no records + server.send(answer, port, address); + }); + server.bind(0, "127.0.0.1", async () => { + dns.setServers(["127.0.0.1:" + server.address().port]); + const code = ask => new Promise(resolve => ask(error => resolve(error ? error.code : "a name"))); + const answers = {}; + for (const address of ["fe80::1234", "fe80::1234%eth0"]) { + answers[address] = [ + await code(callback => dns.reverse(address, callback)), + await code(callback => dns.lookupService(address, 80, callback)), + ]; + } + console.log(JSON.stringify({ answers, asked: [...asked] })); + server.close(); + }); + `; + await using proc = Bun.spawn({ cmd: [bunExe(), "-e", script], env: bunEnv, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ + stdout: JSON.stringify({ + answers: { "fe80::1234": ["ENOTFOUND", "ENOTFOUND"], "fe80::1234%eth0": ["ENOTFOUND", "ENOTFOUND"] }, + asked: ["4.3.2.1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.e.f.ip6.arpa"], + }), + stderr: "", + exitCode: 0, + }); +}); diff --git a/test/js/node/http2/node-http2.test.js b/test/js/node/http2/node-http2.test.js index 1adb3fe249d8..ce7840c90ad6 100644 --- a/test/js/node/http2/node-http2.test.js +++ b/test/js/node/http2/node-http2.test.js @@ -1,5 +1,17 @@ import { estimateShallowMemoryUsageOf, jscDescribe } from "bun:jsc"; -import { bunEnv, bunExe, isASAN, isCI, isDebug, isLinux, isWindows, nodeExe, nodeExeAtLeast, tempDir } from "harness"; +import { + bunEnv, + bunExe, + isASAN, + isCI, + isDebug, + isLinux, + isWindows, + linkLocalIPv6, + nodeExe, + nodeExeAtLeast, + tempDir, +} from "harness"; import { createTest } from "node-harness"; import { AsyncLocalStorage } from "node:async_hooks"; import dc from "node:diagnostics_channel"; @@ -9876,6 +9888,46 @@ it("originSet is undefined on a destroyed TLS session that never read it", async } }); +// RFC 8336 starts the set with the address of the server, and an origin cannot hold the zone that a socket prints +// after a link-local address. Node.js throws ERR_INVALID_URL when it reads the set of such a session: +// https://github.com/nodejs/node/blob/v26.3.0/lib/internal/http2/core.js#L700-L712 +it.skipIf(!linkLocalIPv6())("originSet of a session with a link-local peer and no server name", async () => { + const { address, scoped } = linkLocalIPv6(); + const server = http2.createSecureServer({ ...TLS_CERT, allowHTTP1: false }); + const { promise: listening, resolve: onListening } = Promise.withResolvers(); + server.listen(0, scoped, onListening); + await listening; + try { + const { port } = server.address(); + // A URL cannot hold a zone either, so the test dials the socket. An IP address is no server name, and the + // certificate is for another one. + const client = http2.connect(`https://localhost:${port}`, { + createConnection: () => + tls.connect({ + ...TLS_OPTIONS, + host: scoped, + port, + ALPNProtocols: ["h2"], + checkServerIdentity: () => undefined, + }), + }); + try { + const { promise: connected, resolve: onConnect, reject } = Promise.withResolvers(); + client.on("error", reject); + client.on("connect", onConnect); + await connected; + expect({ remoteAddress: client.socket.remoteAddress, originSet: client.originSet }).toEqual({ + remoteAddress: scoped, + originSet: [`https://[${address}]:${port}`], + }); + } finally { + client.destroy(); + } + } finally { + server.close(); + } +}); + it("a session over TLS whose peer reset is first seen by a write ends without a hang", async () => { // How the reset looks differs by kernel, in Node as well: a clean end on Linux (the failed send() // consumed the socket's error), ECONNRESET on Windows. Never EBADF. diff --git a/test/js/node/net/node-address-zone.test.ts b/test/js/node/net/node-address-zone.test.ts new file mode 100644 index 000000000000..b729f301af63 --- /dev/null +++ b/test/js/node/net/node-address-zone.test.ts @@ -0,0 +1,398 @@ +/** + * The tests in this file run in both Bun and Node.js: `bun test` runs them + * here, and a test of the last block runs this same file under Node.js. + * + * A handle prints the address that getsockname(), getpeername() or recvmsg() + * returned with AddressToJS(). A link-local address (fe80::/10) with a scope + * id gets `%` and its interface: the name of the interface, or its index on + * Windows. No other address gets a zone, whatever its scope id is. Without + * the zone such an address names no peer, so connect() refuses it. + * https://github.com/nodejs/node/blob/v26.3.0/src/tcp_wrap.cc#L520-L596 + * + * address() and remoteAddress() of a dgram socket ask the kernel on every + * call and return a new object: + * https://github.com/nodejs/node/blob/v26.3.0/lib/dgram.js#L815-L840 + */ +import assert from "node:assert"; +import dgram from "node:dgram"; +import { once } from "node:events"; +import http from "node:http"; +import net from "node:net"; +import os from "node:os"; +import { describe, test } from "node:test"; +import { fileURLToPath } from "node:url"; + +function findLinkLocal() { + for (const [name, addresses] of Object.entries(os.networkInterfaces())) { + for (const { address, family, scopeid } of addresses ?? []) { + if (family === "IPv6" && address.startsWith("fe80:")) { + const zone = process.platform === "win32" ? String(scopeid) : name; + return { address, zone, scoped: `${address}%${zone}` }; + } + } + } +} + +function canBind(type: "udp4" | "udp6", address: string) { + return new Promise(resolve => { + const probe = dgram.createSocket(type); + probe.once("error", () => probe.close(() => resolve(false))); + probe.bind(0, address, () => probe.close(() => resolve(true))); + }); +} + +// CI hosts can be without a link-local address, and without IPv6 at all. +const found = findLinkLocal(); +const linkLocal = found && (await canBind("udp6", found.scoped)) ? found : undefined; +const hasIPv6Loopback = await canBind("udp6", "::1"); + +async function bound(type: "udp4" | "udp6", address: string) { + const socket = dgram.createSocket(type); + const listening = once(socket, "listening"); + socket.bind(0, address); + await listening; + return socket; +} + +function connect(socket: dgram.Socket, port: number, address: string) { + return new Promise((resolve, reject) => { + socket.once("error", reject); + socket.connect(port, address, (err?: Error | null) => { + socket.off("error", reject); + if (err) reject(err); + else resolve(); + }); + }); +} + +describe("node:dgram", () => { + test("address() of a socket on a link-local address has the zone", { skip: !linkLocal }, async () => { + const socket = await bound("udp6", linkLocal!.scoped); + try { + const { address, family, port } = socket.address(); + assert.deepStrictEqual({ address, family }, { address: linkLocal!.scoped, family: "IPv6" }); + assert.ok(port > 0); + } finally { + socket.close(); + } + }); + + test("the address that address() returns connects, and every end has the zone", { skip: !linkLocal }, async () => { + const server = await bound("udp6", linkLocal!.scoped); + const client = dgram.createSocket("udp6"); + try { + const { address, port } = server.address(); + await connect(client, port, address); + assert.deepStrictEqual(client.remoteAddress(), { address: linkLocal!.scoped, family: "IPv6", port }); + // The kernel took the source address for that peer, and with it the interface. + assert.deepStrictEqual(client.address(), { + address: linkLocal!.scoped, + family: "IPv6", + port: client.address().port, + }); + + const message = once(server, "message"); + client.send("hello"); + const [, rinfo] = await message; + assert.deepStrictEqual( + { address: rinfo.address, family: rinfo.family, port: rinfo.port }, + { address: linkLocal!.scoped, family: "IPv6", port: client.address().port }, + ); + } finally { + client.close(); + server.close(); + } + }); + + // Linux reports the scope id of a link-local multicast peer too. Only fe80::/10 gets a zone. + const reportsMulticastScope = linkLocal !== undefined && process.platform === "linux"; + test("a multicast peer with a scope id has no zone", { skip: !reportsMulticastScope }, async () => { + const socket = dgram.createSocket("udp6"); + try { + await connect(socket, 4321, `ff02::1%${linkLocal!.zone}`); + assert.deepStrictEqual(socket.remoteAddress(), { address: "ff02::1", family: "IPv6", port: 4321 }); + } finally { + socket.close(); + } + }); + + for (const [type, address, family] of [ + ["udp4", "127.0.0.1", "IPv4"], + ["udp6", "::1", "IPv6"], + ] as const) { + const skip = type === "udp6" && !hasIPv6Loopback; + + test(`${type}: address() and remoteAddress() of a socket on ${address}`, { skip }, async () => { + const server = await bound(type, address); + const client = await bound(type, address); + try { + const port = server.address().port; + assert.deepStrictEqual(server.address(), { address, family, port }); + await connect(client, port, address); + assert.deepStrictEqual(client.remoteAddress(), { address, family, port }); + assert.deepStrictEqual(client.address(), { address, family, port: client.address().port }); + } finally { + client.close(); + server.close(); + } + }); + + test(`${type}: address() and remoteAddress() return a new object on every call`, { skip }, async () => { + const server = await bound(type, address); + const client = await bound(type, address); + try { + await connect(client, server.address().port, address); + const remote = client.remoteAddress(); + assert.notStrictEqual(client.remoteAddress(), remote); + assert.deepStrictEqual(client.remoteAddress(), remote); + const local = client.address(); + assert.notStrictEqual(client.address(), local); + assert.deepStrictEqual(client.address(), local); + + // What the caller does to a result does not reach the next one. + (remote as { address: string }).address = "changed"; + assert.strictEqual(client.remoteAddress().address, address); + } finally { + client.close(); + server.close(); + } + }); + } +}); + +describe("node:net", () => { + const addressesOf = (socket: net.Socket) => ({ + remoteAddress: socket.remoteAddress, + remoteFamily: socket.remoteFamily, + remotePort: socket.remotePort, + localAddress: socket.localAddress, + localPort: socket.localPort, + address: socket.address(), + }); + + async function connected(host: string) { + const server = net.createServer(); + server.listen(0, host); + await once(server, "listening"); + const listening = server.address() as net.AddressInfo; + const connection = once(server, "connection") as Promise<[net.Socket]>; + // The address that the server printed connects to it. + const client = net.connect(listening.port, listening.address); + await once(client, "connect"); + const [accepted] = await connection; + return { + listening, + client, + accepted, + close() { + client.destroy(); + accepted.destroy(); + server.close(); + }, + }; + } + + for (const [name, host, family, skip] of [ + ["a link-local address, with the zone", linkLocal?.scoped, "IPv6", !linkLocal], + ["127.0.0.1", "127.0.0.1", "IPv4", false], + ["::1", "::1", "IPv6", !hasIPv6Loopback], + ] as const) { + test(`every address of a connection over ${name}`, { skip }, async () => { + const { listening, client, accepted, close } = await connected(host!); + try { + const port = listening.port; + const clientPort = client.localPort!; + assert.deepStrictEqual( + { listening, client: addressesOf(client), accepted: addressesOf(accepted) }, + { + listening: { address: host, family, port }, + client: { + remoteAddress: host, + remoteFamily: family, + remotePort: port, + localAddress: host, + localPort: clientPort, + address: { address: host, family, port: clientPort }, + }, + accepted: { + remoteAddress: host, + remoteFamily: family, + remotePort: clientPort, + localAddress: host, + localPort: port, + address: { address: host, family, port }, + }, + }, + ); + } finally { + close(); + } + }); + } + + // Where both ends have one address, nothing shows which of them a getter read. A connection to 127.0.0.2 leaves + // from 127.0.0.1 on Linux. + test("the local and the remote address of a connection", { skip: process.platform !== "linux" }, async () => { + const server = net.createServer(); + server.listen(0, "0.0.0.0"); + await once(server, "listening"); + const connection = once(server, "connection") as Promise<[net.Socket]>; + const client = net.connect((server.address() as net.AddressInfo).port, "127.0.0.2"); + await once(client, "connect"); + const [accepted] = await connection; + try { + assert.deepStrictEqual( + { + client: { localAddress: client.localAddress, remoteAddress: client.remoteAddress }, + accepted: { localAddress: accepted.localAddress, remoteAddress: accepted.remoteAddress }, + }, + { + client: { localAddress: "127.0.0.1", remoteAddress: "127.0.0.2" }, + accepted: { localAddress: "127.0.0.2", remoteAddress: "127.0.0.1" }, + }, + ); + } finally { + client.destroy(); + accepted.destroy(); + server.close(); + } + }); + + // A rule matches the address of the peer. Its zone is no part of the address. + test("a blockList closes a link-local peer", { skip: !linkLocal }, async () => { + const blockList = new net.BlockList(); + blockList.addSubnet("fe80::", 10, "ipv6"); + const events: string[] = []; + const server = net.createServer({ blockList }, () => events.push("connection")); + server.on("drop", () => events.push("drop")); + server.listen(0, linkLocal!.scoped); + await once(server, "listening"); + try { + const { address, port } = server.address() as net.AddressInfo; + const client = net.connect(port, address).resume(); + await once(client, "close"); + assert.deepStrictEqual(events, []); + } finally { + server.close(); + } + }); +}); + +describe("node:http", () => { + for (const [name, host, family, skip] of [ + ["a link-local address, with the zone", linkLocal?.scoped, "IPv6", !linkLocal], + ["127.0.0.1", "127.0.0.1", "IPv4", false], + ] as const) { + test(`server.address() and req.socket of a server on ${name}`, { skip }, async () => { + const request = Promise.withResolvers(); + const server = http.createServer((req, res) => { + const { remoteAddress, remoteFamily, remotePort, localAddress, localPort } = req.socket; + request.resolve({ remoteAddress, remoteFamily, remotePort, localAddress, localPort }); + res.end(); + }); + server.listen(0, host); + await once(server, "listening"); + try { + const listening = server.address() as net.AddressInfo; + // The address that the server printed connects to it. + const [response] = (await once( + http.get({ host: listening.address, port: listening.port, agent: false }), + "response", + )) as [http.IncomingMessage]; + const clientPort = response.socket.localPort; + response.resume(); + await once(response, "end"); + assert.deepStrictEqual( + { listening, request: await request.promise }, + { + listening: { address: host, family, port: listening.port }, + request: { + remoteAddress: host, + remoteFamily: family, + remotePort: clientPort, + localAddress: host, + localPort: listening.port, + }, + }, + ); + } finally { + server.close(); + } + }); + } + + // It is net.Server#address(): https://github.com/nodejs/node/blob/v26.3.0/lib/net.js#L2289-L2301 + test("server.address() returns a new object on every call", async () => { + const server = http.createServer(); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + try { + const address = server.address() as net.AddressInfo; + assert.notStrictEqual(server.address(), address); + assert.deepStrictEqual(server.address(), address); + + // What the caller does to a result does not reach the next one. + address.address = "changed"; + assert.strictEqual((server.address() as net.AddressInfo).address, "127.0.0.1"); + } finally { + server.close(); + } + }); +}); + +// Only in Bun: when Node.js runs this file it must not spawn itself again. +if (typeof Bun !== "undefined") { + const { bunEnv, cmdWithLinkLocalInterface, nodeExeAtLeast } = await import("harness"); + // The expected values above are those of v26.3.0. + const node = nodeExeAtLeast("26.3.0"); + + describe("Node.js compatibility", () => { + (node ? test : test.skip)("all tests pass in Node.js", async () => { + // A direct run, not `node --test`: the runner mode forks a second node + // process per file. node:test still exits non-zero on any failure. + await using proc = Bun.spawn({ + cmd: [node!, fileURLToPath(import.meta.url)], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + assert.deepStrictEqual({ exitCode, output: exitCode === 0 ? "" : stdout + stderr }, { exitCode: 0, output: "" }); + }); + }); + + // A rule matches an address, and the zone that a link-local peer is printed with is no part of it. net.isIP() + // accepts only some interface names in a zone, so Node.js v26 does not check a peer on docker_gwbridge: + // https://github.com/nodejs/node/blob/v26.3.0/lib/net.js#L2338-L2346 + describe("node:net, where Bun is not as Node.js", () => { + const script = ` + const net = require("node:net"); + const blockList = new net.BlockList(); + blockList.addSubnet("fe80::", 10, "ipv6"); + const events = []; + const server = net.createServer({ blockList }, socket => { + events.push(["connection", socket.remoteAddress]); + socket.destroy(); + }); + server.on("drop", () => events.push(["drop"])); + server.listen(0, "::", () => { + const client = net.connect(server.address().port, "fe80::1%docker_gwbridge").resume(); + client.on("error", error => events.push(["error", error.code])); + client.on("close", () => { + console.log(JSON.stringify({ events, isIP: net.isIP("fe80::1%docker_gwbridge") })); + server.close(); + }); + }); + `; + const cmd = cmdWithLinkLocalInterface("docker_gwbridge", ["-e", script]); + + (cmd ? test : test.skip)("a blockList closes a link-local peer, whatever the name of its interface", async () => { + await using proc = Bun.spawn({ cmd: cmd!, env: bunEnv, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + assert.deepStrictEqual( + { stdout: stdout.trim(), stderr, exitCode }, + { stdout: JSON.stringify({ events: [], isIP: 0 }), stderr: "", exitCode: 0 }, + ); + }); + }); +} diff --git a/test/js/sql/postgres-connection.test.ts b/test/js/sql/postgres-connection.test.ts index 684d4d14cc9c..a03371a02289 100644 --- a/test/js/sql/postgres-connection.test.ts +++ b/test/js/sql/postgres-connection.test.ts @@ -11,6 +11,7 @@ import { isDebug, isLinux, isWindows, + linkLocalIPv6, tls as localhostTls, tempDir, } from "harness"; @@ -550,6 +551,8 @@ async function cancelServer( /** false: the session gets no BackendKeyData. */ key?: boolean; socket?: string; + /** The address to listen on. 127.0.0.1 if there is none. */ + hostname?: string; second?: "answers" | "stays open" | "declines TLS" | "has another certificate" | "holds the SSLRequest"; } = {}, ) { @@ -632,13 +635,15 @@ async function cancelServer( } const listener = net.createServer(onSocket); + const hostname = options.hostname ?? "127.0.0.1"; if (options.socket) listener.listen(options.socket); - else listener.listen(0, "127.0.0.1"); + else listener.listen(0, hostname); await once(listener, "listening"); const port = options.socket ? 0 : (listener.address() as net.AddressInfo).port; const server = { url: `postgres://u@127.0.0.1:${port}/db`, + port, auto: false, sockets, /** The type of each message of the session after its StartupMessage. */ @@ -664,7 +669,7 @@ async function cancelServer( }, /** Whatever the client wrote before this call has arrived when it resolves. */ async fence() { - const socket = net.connect(port, "127.0.0.1", () => socket.end()); + const socket = net.connect(port, hostname, () => socket.end()); await once(socket, "close"); }, /** Takes no more connections. */ @@ -705,6 +710,27 @@ describe.concurrent("cancel()", () => { expect(await result).toEqual(canceledError); }); + // The CancelRequest is dialed to the address of the session's peer, and a link-local peer is reached only with its + // zone. + test.skipIf(!linkLocalIPv6())("reaches a server on a link-local address", async () => { + const { scoped } = linkLocalIPv6()!; + await using server = await cancelServer({ hostname: scoped }); + await using sql = new SQL({ + adapter: "postgres", + hostname: scoped, + port: server.port, + username: "u", + database: "db", + max: 1, + }); + const { query, result } = await running(server, sql); + query.cancel(); + + expect(await server.request()).toEqual(pgCancelRequest(...cancelKey)); + stop(server); + expect(await result).toEqual(canceledError); + }); + // The key of a session that is encrypted stops its queries. It goes to the server that the session trusts, encrypted. describe("of a query on a TLS session", () => { const verifyFull = { tls: { ca: localhostTls.cert }, max: 1 };