From 8a851693539204f90376e7bcd2944d2bfd0e622a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 30 Oct 2026 04:41:43 +0000 Subject: [PATCH 1/2] sockets: read an address of a socket as a sockaddr, and print it in one place Six usockets getters return the whole sockaddr of getsockname() and getpeername(). They replace the seven that returned only the IP bytes, so a formatter can see sin6_scope_id. bun_uws_sys::SocketAddress prints an address as Node's AddressToJS does: a link-local address with a scope id has its zone. node:dgram, node:net, node:http, node:http2 and the ws client print the zone. Each reads an address through a native of its own, with one system call. The address getters of Bun.udpSocket, Bun.listen, Bun.connect, Bun.serve and ServerWebSocket print what they printed before. What reads the new text inside Bun takes it: a blockList checks a peer without its zone, the origin set of an HTTP/2 session has none, and dns.reverse() and dns.lookupService() ask for the address alone. --- packages/bun-usockets/src/bsd.c | 10 + .../src/internal/networking/bsd.h | 4 + packages/bun-usockets/src/libusockets.h | 13 +- packages/bun-usockets/src/quic.c | 36 +- packages/bun-usockets/src/quic.h | 6 +- packages/bun-usockets/src/socket.c | 45 +- packages/bun-usockets/src/udp.c | 20 +- packages/bun-uws/src/AsyncSocket.h | 8 - packages/bun-uws/src/HttpResponse.h | 1 - packages/bun-uws/src/WebSocket.h | 1 - src/http_jsc/websocket_client.rs | 55 --- .../websocket_client/WebSocketProxyTunnel.rs | 10 +- src/js/ahead-of-time/node/_http_server.ts | 7 +- src/js/internal-for-testing.ts | 6 + src/js/internal/net/isIP.ts | 10 + src/js/node/_http_server.ts | 3 +- src/js/node/dgram.ts | 34 +- src/js/node/http2.ts | 4 +- src/js/node/net.ts | 62 +-- src/jsc/bindings/JSSocketAddressDTO.cpp | 15 - src/jsc/bindings/JSSocketAddressDTO.h | 14 +- src/jsc/bindings/headers.h | 2 - src/jsc/bindings/webcore/JSWebSocket.cpp | 14 +- src/jsc/bindings/webcore/WebSocket.cpp | 10 - src/jsc/bindings/webcore/WebSocket.h | 9 +- src/runtime/dns_jsc/dns.rs | 25 +- src/runtime/node/node_http_binding.bind.ts | 7 + src/runtime/node/node_http_binding.rs | 6 + src/runtime/server/AnyRequestContext.rs | 4 +- src/runtime/server/RequestContext.rs | 14 +- src/runtime/server/ServerWebSocket.rs | 30 +- src/runtime/server/server_body.rs | 95 ++--- src/runtime/socket/Listener.rs | 19 +- src/runtime/socket/SocketAddress.bind.ts | 9 + src/runtime/socket/SocketAddress.rs | 152 ++++--- src/runtime/socket/mod.rs | 2 +- src/runtime/socket/socket_body.bind.ts | 4 + src/runtime/socket/socket_body.rs | 94 +++-- src/runtime/socket/udp_socket.bind.ts | 9 + src/runtime/socket/udp_socket.rs | 206 +++------ src/sql_jsc/postgres/PostgresSQLConnection.rs | 40 +- src/uws/lib.rs | 13 +- src/uws_sys/ListenSocket.rs | 5 +- src/uws_sys/Response.rs | 79 +--- src/uws_sys/SocketAddress.rs | 137 ++++++ src/uws_sys/WebSocket.rs | 20 +- src/uws_sys/h2.rs | 29 +- src/uws_sys/h3.rs | 52 +-- src/uws_sys/lib.rs | 5 +- src/uws_sys/libuwsockets.cpp | 35 -- src/uws_sys/libuwsockets_h2.cpp | 7 +- src/uws_sys/libuwsockets_h3.cpp | 35 +- src/uws_sys/socket.rs | 32 +- src/uws_sys/udp.rs | 19 +- src/uws_sys/us_socket_t.rs | 56 +-- test/harness.ts | 48 +++ test/js/bun/http/bun-server.test.ts | 16 + test/js/bun/net/tcp-server.test.ts | 44 +- test/js/bun/udp/udp_socket.test.ts | 119 ++++++ .../js/bun/websocket/websocket-server.test.ts | 39 +- .../js/first_party/ws/ws-compat-shape.test.ts | 30 +- test/js/node/dns/dns-resolver-class.test.ts | 52 ++- test/js/node/http2/node-http2.test.js | 54 ++- test/js/node/net/node-address-zone.test.ts | 398 ++++++++++++++++++ test/js/sql/postgres-connection.test.ts | 30 +- 65 files changed, 1517 insertions(+), 952 deletions(-) create mode 100644 src/uws_sys/SocketAddress.rs create mode 100644 test/js/node/net/node-address-zone.test.ts 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..c059af4141d6 100644 --- a/src/runtime/node/node_http_binding.rs +++ b/src/runtime/node/node_http_binding.rs @@ -8,6 +8,7 @@ use bun_uws_sys::response::PerMessageDeflateParams; use crate::generated_bindgen::node_http_binding::{BunServer, DeflateParams}; use crate::generated_bindgen::socket_body::SocketHandle; use crate::server::{AnyServer, ServerWebSocket}; +use crate::socket::Zone; use crate::socket::node_http_socket::{self, WebSocketUpgrade}; macro_rules! with_server { @@ -35,6 +36,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.address(cx.global(), Zone::Printed)) +} + 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..9470cc93fca0 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, Zone}; 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, Zone::Omitted), + None => Ok(JSValue::UNDEFINED), + } } } diff --git a/src/runtime/server/server_body.rs b/src/runtime/server/server_body.rs index 796273dce9b9..719556319c5c 100644 --- a/src/runtime/server/server_body.rs +++ b/src/runtime/server/server_body.rs @@ -12,6 +12,7 @@ use crate::generated_bindgen::server_body::{ BunServe__onRejectPlugins, BunServe__onResolvePlugins, }; use crate::node::types::PathLikeExt as _; +use crate::socket::Zone; use crate::socket::listener::Connections; use crate::webcore::BlobExt; use crate::webcore::body::Value as BodyValue; @@ -1116,15 +1117,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, Zone::Omitted) } fn set_request_timeout<'a>( @@ -1913,51 +1909,32 @@ impl NewServer { } 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, - }; - - 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); + self.address(cx.global(), Zone::Omitted) + } - 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()); + /// The path of a unix socket server, `{ address, family, port }` of an IP one, `null` when it does not listen. + pub(crate) fn address(&self, global: &JSGlobalObject, zone: Zone) -> JsResult { + if let server_config::Address::Unix(unix) = &self.config.get().address { + return bun_string_jsc::create_utf8_for_js(global, unix.as_bytes()); } - 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()); - } + + let addr = if let Some(listener) = self.listener.get() { + // S008: `app::ListenSocket` is a ZST opaque — safe deref. + 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, zone), + None => Ok(JSValue::NULL), } - Ok(JSValue::NULL) } pub(crate) fn get_url(&self, cx: &mut JsScope<'_>) -> JsResult { @@ -1971,18 +1948,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 +2050,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 +2462,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..e607ae5041d0 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,83 @@ impl SocketAddress { } // ============================================================================= -// ================================ DESTRUCTORS ================================ +// ============================= KERNEL ADDRESSES ============================== // ============================================================================= +/// Whether a getter prints the zone of a link-local address. +#[derive(Clone, Copy)] +pub(crate) enum Zone { + /// `fe80::1%eth0`, as Node.js prints every address that the kernel returns. + Printed, + /// `fe80::1`, as the address getters of `Bun.*` sockets and servers print it. + Omitted, +} + 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, + zone: Zone, + ) -> JsResult { + let address = Self::format_sockaddr(addr, zone); 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, + zone: Zone, ) -> JsResult { - debug_assert!(!addr_.is_empty()); + Self::text_to_js(global, &Self::format_sockaddr(addr, zone)) + } - 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()) + } + + fn format_sockaddr(addr: &sockaddr_storage, zone: Zone) -> bun_uws_sys::SocketAddress { + match zone { + Zone::Printed => bun_uws_sys::SocketAddress::from_sockaddr(addr), + Zone::Omitted => bun_uws_sys::SocketAddress::from_sockaddr_without_zone(addr), + } } } +// 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, Zone::Printed) +} + unsafe extern "C" { safe fn JSSocketAddressDTO__create( global_object: &JSGlobalObject, @@ -995,6 +993,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, Zone::Printed) + } +} + #[cfg(windows)] pub(crate) mod inet { #![allow(non_camel_case_types)] diff --git a/src/runtime/socket/mod.rs b/src/runtime/socket/mod.rs index 8cee40c1d7f9..6e117a1a8e6a 100644 --- a/src/runtime/socket/mod.rs +++ b/src/runtime/socket/mod.rs @@ -61,7 +61,7 @@ pub(crate) use ssl_config::{SSLConfig, http_client_defaults, resolve_reject_unau pub(crate) use handlers::{Handlers, SocketConfig}; pub(crate) use listener::Listener; -pub(crate) use socket_address::SocketAddress; +pub(crate) use socket_address::{SocketAddress, Zone}; pub(crate) use socket_body::{ DeferredWriteCompletion, Flags as SocketFlags, NativeCallbacks, NewSocket, SocketMode, TCPSocket, TLSSocket, server_ctx_rejects_unauthorized, server_ctx_requests_cert, 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..16e3892f2383 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, Zone}; 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, Zone::Omitted), + 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..c0936246070a 100644 --- a/src/runtime/socket/udp_socket.rs +++ b/src/runtime/socket/udp_socket.rs @@ -22,13 +22,13 @@ 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}; +use crate::socket::Zone; bun_output::declare_scope!(UdpSocket, visible); @@ -75,12 +75,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 +139,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 +172,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 +186,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 +193,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 +208,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 +223,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 +429,6 @@ impl UDPSocketConfig { } } -#[derive(Clone, Copy)] -struct ConnectInfo { - port: u16, -} - pub(crate) struct UDPSocket { pub(crate) config: JsCell, @@ -515,7 +443,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 +498,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 +659,7 @@ impl UDPSocket { )); } } - this.connect_info - .set(Some(ConnectInfo { port: connect.port })); + this.connected.set(true); } // Disarm the error-path guard. @@ -1137,11 +1064,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 +1215,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,56 +1457,42 @@ 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 { + self.local_address(cx.global(), Zone::Omitted) } - pub(crate) fn get_address(&self, cx: &mut JsScope<'_>) -> JsResult { + pub(crate) fn get_remote_address(&self, cx: &mut JsScope<'_>) -> JsResult { + self.remote_address(cx.global(), Zone::Omitted) + } + + /// `{ address, family, port }` of `getsockname()`. `undefined` when the socket is closed. + fn local_address(&self, global: &JSGlobalObject, zone: Zone) -> JsResult { if self.closed.get() { 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. - 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(global, &addr, zone), + None => Ok(JSValue::UNDEFINED), + } } - pub(crate) fn get_remote_address(&self, cx: &mut JsScope<'_>) -> JsResult { - if self.closed.get() { + /// `{ address, family, port }` of `getpeername()`. `undefined` when the socket is closed or not connected. + fn remote_address(&self, global: &JSGlobalObject, zone: Zone) -> JsResult { + 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(global, &addr, zone), + None => Ok(JSValue::UNDEFINED), + } } pub(crate) fn get_binary_type(&self, cx: &mut JsScope<'_>) -> JsResult { @@ -1634,7 +1547,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 +1574,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 +1592,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.local_address(cx.global(), Zone::Printed) +} + +/// 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.remote_address(cx.global(), Zone::Printed) +} + /// 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 +1815,19 @@ 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, + Zone::Printed, + )?) } #[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..597c5dda2e6d --- /dev/null +++ b/src/uws_sys/SocketAddress.rs @@ -0,0 +1,137 @@ +//! 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::from_sockaddr_without_zone(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 + } + + /// [`Self::from_sockaddr`] with no zone. + pub fn from_sockaddr_without_zone(addr: &sockaddr_storage) -> SocketAddress { + Self::parse(addr).0 + } + + /// 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..b4e87f9baa6c 100644 --- a/test/js/bun/http/bun-server.test.ts +++ b/test/js/bun/http/bun-server.test.ts @@ -4,6 +4,7 @@ import { bunEnv, bunExe, bunRun, + isLinux, isWindows, normalizeBunSnapshot, rejectUnauthorizedScope, @@ -233,6 +234,21 @@ describe.concurrent("Server", () => { expect({ status: response.status, text: await response.text() }).toEqual({ status: 200, text: "Hello" }); }); + // 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..3464c21c4582 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, tls } from "harness"; import { join } from "node:path"; type Resolve = (value?: unknown) => void; @@ -58,6 +58,46 @@ 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; + +// 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..98203cafabec 100644 --- a/test/js/bun/udp/udp_socket.test.ts +++ b/test/js/bun/udp/udp_socket.test.ts @@ -1,17 +1,21 @@ 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, + 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 +336,121 @@ describe("udpSocket()", () => { } }); + // 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..c6e75465a121 100644 --- a/test/js/bun/websocket/websocket-server.test.ts +++ b/test/js/bun/websocket/websocket-server.test.ts @@ -2,7 +2,17 @@ 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, + tempDir, + tls as tlsCert, +} from "harness"; import { once } from "node:events"; import net, { isIP } from "node:net"; import path from "node:path"; @@ -3765,6 +3775,33 @@ describe.concurrent("ServerWebSocket argument validation", () => { }); }); +// 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 }; From b8600342a6fb73f2d27b380f013a2d8e316bb013 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 30 Oct 2026 05:26:59 +0000 Subject: [PATCH 2/2] Bun.udpSocket, Bun.listen, Bun.connect, Bun.serve, ServerWebSocket: print the zone of a link-local address The address getters of the Bun-native sockets and servers print a link-local address as the node:* ones do: with its zone. The printed address of a peer then connects again through Bun.connect() and Bun.udpSocket(). Before, these getters dropped the zone, and the data callback of Bun.udpSocket printed it. This commit changes Bun-native strings. The commit before it changes none. --- packages/bun-types/bun.d.ts | 18 ++++++- packages/bun-types/serve.d.ts | 4 ++ src/runtime/node/node_http_binding.rs | 3 +- src/runtime/server/ServerWebSocket.rs | 4 +- src/runtime/server/server_body.rs | 12 ++--- src/runtime/socket/SocketAddress.rs | 26 ++-------- src/runtime/socket/mod.rs | 2 +- src/runtime/socket/socket_body.rs | 4 +- src/runtime/socket/udp_socket.rs | 27 +++-------- src/uws_sys/SocketAddress.rs | 7 +-- test/js/bun/http/bun-server.test.ts | 47 +++++++++++++++++++ test/js/bun/net/tcp-server.test.ts | 38 ++++++++++++++- test/js/bun/udp/udp_socket.test.ts | 25 ++++++++++ .../js/bun/websocket/websocket-server.test.ts | 33 +++++++++++++ 14 files changed, 183 insertions(+), 67 deletions(-) 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/src/runtime/node/node_http_binding.rs b/src/runtime/node/node_http_binding.rs index c059af4141d6..e1d2ce39e4f8 100644 --- a/src/runtime/node/node_http_binding.rs +++ b/src/runtime/node/node_http_binding.rs @@ -8,7 +8,6 @@ use bun_uws_sys::response::PerMessageDeflateParams; use crate::generated_bindgen::node_http_binding::{BunServer, DeflateParams}; use crate::generated_bindgen::socket_body::SocketHandle; use crate::server::{AnyServer, ServerWebSocket}; -use crate::socket::Zone; use crate::socket::node_http_socket::{self, WebSocketUpgrade}; macro_rules! with_server { @@ -38,7 +37,7 @@ pub(crate) fn set_server_max_headers_count(server: BunServer<'_>, max_headers_co /// `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.address(cx.global(), Zone::Printed)) + with_server!(server, |server| server.get_address(cx)) } pub(crate) fn set_server_custom_options<'a>( diff --git a/src/runtime/server/ServerWebSocket.rs b/src/runtime/server/ServerWebSocket.rs index 9470cc93fca0..c06c0803bc2a 100644 --- a/src/runtime/server/ServerWebSocket.rs +++ b/src/runtime/server/ServerWebSocket.rs @@ -16,7 +16,7 @@ use crate::server::jsc::{ JsRef, JsResult, }; use crate::server::web_socket_server_context::HandlerFlags; -use crate::socket::{SocketAddress, Zone}; +use crate::socket::SocketAddress; use crate::webcore::{Blob, BlobExt}; bun_output::declare_scope!(WebSocketServer, visible); @@ -1246,7 +1246,7 @@ impl ServerWebSocket { } match self.websocket().remote_sockaddr() { - Some(addr) => SocketAddress::address_from_sockaddr(cx.global(), &addr, Zone::Omitted), + 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 719556319c5c..a1939165b17a 100644 --- a/src/runtime/server/server_body.rs +++ b/src/runtime/server/server_body.rs @@ -12,7 +12,6 @@ use crate::generated_bindgen::server_body::{ BunServe__onRejectPlugins, BunServe__onResolvePlugins, }; use crate::node::types::PathLikeExt as _; -use crate::socket::Zone; use crate::socket::listener::Connections; use crate::webcore::BlobExt; use crate::webcore::body::Value as BodyValue; @@ -1120,7 +1119,7 @@ fn request_ip(config: &ServerConfig, cx: &mut JsScope<'_>, request: &Request) -> let Some(addr) = request.request_context.remote_sockaddr() else { return Ok(JSValue::NULL); }; - SocketAddress::dto_from_sockaddr(cx.global(), &addr, Zone::Omitted) + SocketAddress::dto_from_sockaddr(cx.global(), &addr) } fn set_request_timeout<'a>( @@ -1908,12 +1907,9 @@ impl NewServer { (self.active_websocket_count.get() & 0x7FFF_FFFF) as i32 } - pub(crate) fn get_address(&self, cx: &mut JsScope<'_>) -> JsResult { - self.address(cx.global(), Zone::Omitted) - } - /// The path of a unix socket server, `{ address, family, port }` of an IP one, `null` when it does not listen. - pub(crate) fn address(&self, global: &JSGlobalObject, zone: Zone) -> JsResult { + pub(crate) fn get_address(&self, cx: &mut JsScope<'_>) -> JsResult { + 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()); } @@ -1932,7 +1928,7 @@ impl NewServer { None }; match addr { - Some(addr) => SocketAddress::dto_from_sockaddr(global, &addr, zone), + Some(addr) => SocketAddress::dto_from_sockaddr(global, &addr), None => Ok(JSValue::NULL), } } diff --git a/src/runtime/socket/SocketAddress.rs b/src/runtime/socket/SocketAddress.rs index e607ae5041d0..6be84e057717 100644 --- a/src/runtime/socket/SocketAddress.rs +++ b/src/runtime/socket/SocketAddress.rs @@ -416,23 +416,13 @@ impl SocketAddress { // ============================= KERNEL ADDRESSES ============================== // ============================================================================= -/// Whether a getter prints the zone of a link-local address. -#[derive(Clone, Copy)] -pub(crate) enum Zone { - /// `fe80::1%eth0`, as Node.js prints every address that the kernel returns. - Printed, - /// `fe80::1`, as the address getters of `Bun.*` sockets and servers print it. - Omitted, -} - impl SocketAddress { /// `{ 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, - zone: Zone, ) -> JsResult { - let address = Self::format_sockaddr(addr, zone); + let address = bun_uws_sys::SocketAddress::from_sockaddr(addr); Ok(JSSocketAddressDTO__create( global, Self::text_to_js(global, &address)?, @@ -445,9 +435,8 @@ impl SocketAddress { pub(crate) fn address_from_sockaddr( global: &JSGlobalObject, addr: &sockaddr_storage, - zone: Zone, ) -> JsResult { - Self::text_to_js(global, &Self::format_sockaddr(addr, zone)) + Self::text_to_js(global, &bun_uws_sys::SocketAddress::from_sockaddr(addr)) } /// Node.js's `AddressToJS(env, addr, out)`: sets `address`, `family` and `port` of the object `out`. @@ -473,13 +462,6 @@ impl SocketAddress { ) -> JsResult { bun_string_jsc::create_utf8_for_js(global, address.ip()) } - - fn format_sockaddr(addr: &sockaddr_storage, zone: Zone) -> bun_uws_sys::SocketAddress { - match zone { - Zone::Printed => bun_uws_sys::SocketAddress::from_sockaddr(addr), - Zone::Omitted => bun_uws_sys::SocketAddress::from_sockaddr_without_zone(addr), - } - } } // HOST_EXPORT(Bun__sockaddrToJS, c) @@ -487,7 +469,7 @@ pub(crate) fn sockaddr_to_js( global: &JSGlobalObject, addr: &bun_uws_sys::sockaddr_storage, ) -> JsResult { - SocketAddress::dto_from_sockaddr(global, addr, Zone::Printed) + SocketAddress::dto_from_sockaddr(global, addr) } unsafe extern "C" { @@ -1009,7 +991,7 @@ pub(crate) mod testing_apis { 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, Zone::Printed) + SocketAddress::dto_from_sockaddr(global, &addr) } } diff --git a/src/runtime/socket/mod.rs b/src/runtime/socket/mod.rs index 6e117a1a8e6a..8cee40c1d7f9 100644 --- a/src/runtime/socket/mod.rs +++ b/src/runtime/socket/mod.rs @@ -61,7 +61,7 @@ pub(crate) use ssl_config::{SSLConfig, http_client_defaults, resolve_reject_unau pub(crate) use handlers::{Handlers, SocketConfig}; pub(crate) use listener::Listener; -pub(crate) use socket_address::{SocketAddress, Zone}; +pub(crate) use socket_address::SocketAddress; pub(crate) use socket_body::{ DeferredWriteCompletion, Flags as SocketFlags, NativeCallbacks, NewSocket, SocketMode, TCPSocket, TLSSocket, server_ctx_rejects_unauthorized, server_ctx_requests_cert, diff --git a/src/runtime/socket/socket_body.rs b/src/runtime/socket/socket_body.rs index 16e3892f2383..44bb971b1594 100644 --- a/src/runtime/socket/socket_body.rs +++ b/src/runtime/socket/socket_body.rs @@ -25,7 +25,7 @@ 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::ssl_config::ParsedTls; -use crate::socket::{SSLConfig, SocketAddress, Zone}; +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; @@ -67,7 +67,7 @@ fn address_to_js( addr: Option<&bun_uws_sys::sockaddr_storage>, ) -> JsResult { match addr { - Some(addr) => SocketAddress::address_from_sockaddr(global, addr, Zone::Omitted), + Some(addr) => SocketAddress::address_from_sockaddr(global, addr), None => Ok(JSValue::UNDEFINED), } } diff --git a/src/runtime/socket/udp_socket.rs b/src/runtime/socket/udp_socket.rs index c0936246070a..676051d55bb4 100644 --- a/src/runtime/socket/udp_socket.rs +++ b/src/runtime/socket/udp_socket.rs @@ -28,7 +28,6 @@ use libc::sockaddr_storage; use crate::api::SocketAddress; use crate::generated_bindgen::udp_socket::js_UDPSocket as js; -use crate::socket::Zone; bun_output::declare_scope!(UdpSocket, visible); @@ -1458,15 +1457,6 @@ impl UDPSocket { } pub(crate) fn get_address(&self, cx: &mut JsScope<'_>) -> JsResult { - self.local_address(cx.global(), Zone::Omitted) - } - - pub(crate) fn get_remote_address(&self, cx: &mut JsScope<'_>) -> JsResult { - self.remote_address(cx.global(), Zone::Omitted) - } - - /// `{ address, family, port }` of `getsockname()`. `undefined` when the socket is closed. - fn local_address(&self, global: &JSGlobalObject, zone: Zone) -> JsResult { if self.closed.get() { return Ok(JSValue::UNDEFINED); } @@ -1475,13 +1465,12 @@ impl UDPSocket { }; // `Socket` is an `opaque_ffi!` ZST — `opaque_mut` is the safe deref. match uws::udp::Socket::opaque_mut(socket).local_sockaddr() { - Some(addr) => SocketAddress::dto_from_sockaddr(global, &addr, zone), + Some(addr) => SocketAddress::dto_from_sockaddr(cx.global(), &addr), None => Ok(JSValue::UNDEFINED), } } - /// `{ address, family, port }` of `getpeername()`. `undefined` when the socket is closed or not connected. - fn remote_address(&self, global: &JSGlobalObject, zone: Zone) -> JsResult { + pub(crate) fn get_remote_address(&self, cx: &mut JsScope<'_>) -> JsResult { if self.closed.get() || !self.connected.get() { return Ok(JSValue::UNDEFINED); } @@ -1490,7 +1479,7 @@ impl UDPSocket { }; // `Socket` is an `opaque_ffi!` ZST — `opaque_mut` is the safe deref. match uws::udp::Socket::opaque_mut(socket).remote_sockaddr() { - Some(addr) => SocketAddress::dto_from_sockaddr(global, &addr, zone), + Some(addr) => SocketAddress::dto_from_sockaddr(cx.global(), &addr), None => Ok(JSValue::UNDEFINED), } } @@ -1599,12 +1588,12 @@ pub(crate) fn disconnect<'a>(heap: &JsHeap<'a>, this: &UDPSocket) -> bindgen::Re /// 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.local_address(cx.global(), Zone::Printed) + 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.remote_address(cx.global(), Zone::Printed) + this.get_remote_address(cx) } /// The resulting SO_RCVBUF/SO_SNDBUF value. `size == 0` @@ -1823,11 +1812,7 @@ pub(crate) fn dgram_get_sock_name_fd<'a>( ) .into_error(&cx.heap)); } - Ok(SocketAddress::dto_from_sockaddr( - cx.global(), - &storage, - Zone::Printed, - )?) + Ok(SocketAddress::dto_from_sockaddr(cx.global(), &storage)?) } #[cfg(windows)] { diff --git a/src/uws_sys/SocketAddress.rs b/src/uws_sys/SocketAddress.rs index 597c5dda2e6d..41b2d39b7108 100644 --- a/src/uws_sys/SocketAddress.rs +++ b/src/uws_sys/SocketAddress.rs @@ -23,7 +23,7 @@ pub fn sockaddr_is_ipv6(addr: &sockaddr_storage) -> bool { /// 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::from_sockaddr_without_zone(addr); + let (address, _) = SocketAddress::parse(addr); let ip = address.ip(); // IPv4 loopback addresses if ip.starts_with(b"127.") { @@ -54,11 +54,6 @@ impl SocketAddress { address } - /// [`Self::from_sockaddr`] with no zone. - pub fn from_sockaddr_without_zone(addr: &sockaddr_storage) -> SocketAddress { - Self::parse(addr).0 - } - /// 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 { diff --git a/test/js/bun/http/bun-server.test.ts b/test/js/bun/http/bun-server.test.ts index b4e87f9baa6c..015e9d392af7 100644 --- a/test/js/bun/http/bun-server.test.ts +++ b/test/js/bun/http/bun-server.test.ts @@ -6,6 +6,7 @@ import { bunRun, isLinux, isWindows, + linkLocalIPv6, normalizeBunSnapshot, rejectUnauthorizedScope, tempDir, @@ -234,6 +235,52 @@ 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 () => { diff --git a/test/js/bun/net/tcp-server.test.ts b/test/js/bun/net/tcp-server.test.ts index 3464c21c4582..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, type Socket, SocketHandler, TCPSocketListener } from "bun"; import { describe, expect, it } from "bun:test"; -import { bunEnv, bunExe, isLinux, isWindows, tls } from "harness"; +import { bunEnv, bunExe, isLinux, isWindows, linkLocalIPv6, tls } from "harness"; import { join } from "node:path"; type Resolve = (value?: unknown) => void; @@ -69,6 +69,42 @@ const transports = [ ["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)( diff --git a/test/js/bun/udp/udp_socket.test.ts b/test/js/bun/udp/udp_socket.test.ts index 98203cafabec..bf7420d91690 100644 --- a/test/js/bun/udp/udp_socket.test.ts +++ b/test/js/bun/udp/udp_socket.test.ts @@ -10,6 +10,7 @@ import { expectRssDeltaBelow, isIPv6, isWindows, + linkLocalIPv6, nodeExeAtLeast, randomPort, tempDir, @@ -336,6 +337,30 @@ 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", () => { diff --git a/test/js/bun/websocket/websocket-server.test.ts b/test/js/bun/websocket/websocket-server.test.ts index c6e75465a121..ad44af015deb 100644 --- a/test/js/bun/websocket/websocket-server.test.ts +++ b/test/js/bun/websocket/websocket-server.test.ts @@ -10,6 +10,7 @@ import { isDebug, isLinux, isWindows, + linkLocalIPv6, tempDir, tls as tlsCert, } from "harness"; @@ -3775,6 +3776,38 @@ 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 () => {