Skip to content

Commit 623949f

Browse files
fix(websocket): handle IPv6 4-tuple in listener getsockname()
WebsocketListener.listen() unpacked sock.getsockname() directly into (sock_addr, sock_port), which raised ValueError on IPv6 sockets whose getsockname() returns (host, port, flowinfo, scopeid). The ValueError was caught by the outer except and re-raised as OpenConnectionError, so IPv6 WebSocket listeners could not start. Pull the parse into a small _extract_host_port_from_sockname helper that accepts any tuple of length >= 2 with (str, int) in the first two slots, and falls back gracefully on unknown shapes. Add unit tests covering IPv4 2-tuple, IPv6 4-tuple (zero and nonzero scopeid), and unexpected shapes — no actual IPv6 socket needed. Fixes libp2p#1316.
1 parent d9c50c0 commit 623949f

3 files changed

Lines changed: 79 additions & 3 deletions

File tree

libp2p/transport/websocket/listener.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,26 @@
3131
logger = logging.getLogger(__name__)
3232

3333

34+
def _extract_host_port_from_sockname(
35+
sock_name: Any,
36+
) -> tuple[str, int] | None:
37+
"""
38+
Return ``(host, port)`` from a ``socket.getsockname()`` return value, or
39+
``None`` if the value is not recognised.
40+
41+
``socket.getsockname()`` yields a 2-tuple ``(host, port)`` for IPv4 and a
42+
4-tuple ``(host, port, flowinfo, scopeid)`` for IPv6. Only the first two
43+
fields are meaningful for multiaddr reconstruction, so we accept any
44+
tuple of length ≥ 2 where the first two elements are a string and an int.
45+
"""
46+
if not isinstance(sock_name, tuple) or len(sock_name) < 2:
47+
return None
48+
host, port = sock_name[0], sock_name[1]
49+
if not isinstance(host, str) or not isinstance(port, int):
50+
return None
51+
return host, port
52+
53+
3454
@dataclass
3555
class WebsocketListenerConfig:
3656
"""Configuration for WebSocket listener."""
@@ -297,9 +317,16 @@ async def _run_server() -> None:
297317
if hasattr(server_info, "socket"):
298318
sock = server_info.socket
299319
if hasattr(sock, "getsockname"):
300-
sock_addr, sock_port = sock.getsockname()
301-
actual_host = sock_addr
302-
actual_port = sock_port
320+
sock_name = sock.getsockname()
321+
host_port = _extract_host_port_from_sockname(sock_name)
322+
if host_port is not None:
323+
actual_host, actual_port = host_port
324+
else:
325+
logger.warning(
326+
"Unexpected getsockname() result %r; "
327+
"falling back to requested host/port",
328+
sock_name,
329+
)
303330
elif hasattr(server_info, "port"):
304331
# If we can't get socket, at least get the port
305332
actual_port = server_info.port

newsfragments/1316.bugfix.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
Fix ``WebsocketListener.listen()`` binding IPv6 addresses. ``socket.getsockname()``
2+
returns a 4-tuple ``(host, port, flowinfo, scopeid)`` for IPv6 sockets, which
3+
previously failed to unpack into ``(sock_addr, sock_port)`` and surfaced as
4+
``OpenConnectionError("Failed to listen on ...")``. The listener now accepts
5+
any 2-or-more-tuple with ``(host: str, port: int)`` in the first two slots and
6+
falls back to the requested host/port on unknown shapes.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""
2+
Unit tests for ``_extract_host_port_from_sockname``.
3+
4+
Historically ``WebsocketListener.listen`` unpacked ``sock.getsockname()``
5+
as a 2-tuple, which worked for IPv4 sockets but failed with
6+
``ValueError: too many values to unpack`` for IPv6 sockets (whose
7+
``getsockname()`` returns a 4-tuple ``(host, port, flowinfo, scopeid)``).
8+
This regression test pins the supported shapes for the extractor so the
9+
IPv6 path doesn't re-break.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
from libp2p.transport.websocket.listener import _extract_host_port_from_sockname
15+
16+
17+
def test_ipv4_two_tuple() -> None:
18+
assert _extract_host_port_from_sockname(("127.0.0.1", 12345)) == (
19+
"127.0.0.1",
20+
12345,
21+
)
22+
23+
24+
def test_ipv6_four_tuple() -> None:
25+
# (host, port, flowinfo, scopeid)
26+
assert _extract_host_port_from_sockname(("::1", 23456, 0, 0)) == ("::1", 23456)
27+
28+
29+
def test_ipv6_four_tuple_with_nonzero_scopeid() -> None:
30+
assert _extract_host_port_from_sockname(("fe80::1", 34567, 0, 3)) == (
31+
"fe80::1",
32+
34567,
33+
)
34+
35+
36+
def test_unexpected_shape_returns_none() -> None:
37+
# Not a tuple, single element, wrong element types — all graceful.
38+
assert _extract_host_port_from_sockname(None) is None
39+
assert _extract_host_port_from_sockname(("127.0.0.1",)) is None
40+
assert _extract_host_port_from_sockname((12345, "127.0.0.1")) is None
41+
# A raw string (e.g. what an AF_UNIX socket's getsockname returns) is
42+
# not a tuple and must be rejected.
43+
assert _extract_host_port_from_sockname("socket-path") is None

0 commit comments

Comments
 (0)