Skip to content

Commit 54312a2

Browse files
ourwayclaude
andcommitted
release: retunnel 3.1.2 — reach a local app bound to ::1 (#59)
An application the user could browse at http://localhost:PORT returned 502 through the tunnel whenever it was bound to ::1, because the client only ever dialled 127.0.0.1. That is the common shape on macOS, where localhost resolves to ::1 first and many dev servers bind IPv6 only. Addressing the app as the NAME "localhost" is NOT a sufficient fix and was rejected after testing: it depends on the host's /etc/hosts, and on a machine whose localhost has no AAAA record getaddrinfo returns 127.0.0.1 alone, leaving an IPv6-only app just as unreachable. Both loopback literals are now tried explicitly (IPv4 first, as the common case) for HTTP, WebSocket and TCP streams, and whichever answers is remembered for the rest of the session so only the first request pays for probing. Probe: audit/evaluations/probe_local_address_family.py asserts BOTH families, since a fix that merely moved the failure would otherwise look identical. Verified: red on published 3.1.1 (IPv6 502), green on this build; full harness 12/12 against production. Refs #59 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 4fe9146 commit 54312a2

5 files changed

Lines changed: 90 additions & 28 deletions

File tree

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
3.1.1
1+
3.1.2

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "retunnel"
7-
version = "3.1.1"
7+
version = "3.1.2"
88
description = "ReTunnel - Securely expose local servers to the internet"
99
authors = [{name = "ReTunnel Team", email = "support@retunnel.com"}]
1010
license = "MIT"

retunnel/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from __future__ import annotations
22

3-
__version__ = "3.1.1"
3+
__version__ = "3.1.2"
44

55
from .client.client import ReTunnelClient, TunnelConfig
66

retunnel/client/tcp_stream.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import asyncio
66
import logging
77

8+
from retunnel.local_proxy import LOCAL_HOST, LOCAL_HOSTS
89
from retunnel.msg.messages import StreamOpen
910

1011
from .streams import Sender, StreamState
@@ -15,11 +16,24 @@
1516
async def handle_tcp_stream(
1617
msg: StreamOpen, state: StreamState, sender: Sender, port: int
1718
) -> None:
18-
try:
19-
reader, writer = await asyncio.open_connection("127.0.0.1", port)
20-
except Exception as e:
21-
logger.warning("TCP connect to 127.0.0.1:%d failed: %s", port, e)
22-
await sender.reset(f"{type(e).__name__}: {e}")
19+
reader = None
20+
writer = None
21+
last: Exception | None = None
22+
for host in LOCAL_HOSTS:
23+
try:
24+
reader, writer = await asyncio.open_connection(host, port)
25+
break
26+
except OSError as e: # nothing listening on this family
27+
last = e
28+
if reader is None or writer is None:
29+
logger.warning(
30+
"TCP connect to %s:%d failed on %s: %s",
31+
LOCAL_HOST,
32+
port,
33+
"/".join(LOCAL_HOSTS),
34+
last,
35+
)
36+
await sender.reset(f"{type(last).__name__}: {last}")
2337
return
2438

2539
async def local_to_server() -> None:

retunnel/local_proxy.py

Lines changed: 68 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,27 @@
1818
# not send them; the local app must see the caller's request, not ours.
1919
_SKIP_AUTO = frozenset({"Accept", "Accept-Encoding", "User-Agent"})
2020

21+
# Loopback addresses tried, in order, when reaching the local application.
22+
#
23+
# Hardcoding 127.0.0.1 meant an app the user could browse at
24+
# http://localhost:PORT answered 502 through the tunnel whenever it was bound
25+
# to ::1 -- the common case on macOS, where localhost resolves to ::1 first and
26+
# many dev servers bind IPv6 only (issuedb #59).
27+
#
28+
# Resolving the NAME "localhost" is NOT a fix: it depends on the host's
29+
# /etc/hosts, and where localhost has no AAAA record (this build machine)
30+
# getaddrinfo returns 127.0.0.1 alone and an IPv6-only app stays unreachable.
31+
# Both literals are therefore tried explicitly, IPv4 first as the common case;
32+
# whichever answers is remembered for the rest of the session.
33+
LOCAL_HOSTS = ("127.0.0.1", "::1")
34+
# The name used in logs and messages.
35+
LOCAL_HOST = "localhost"
36+
37+
38+
def _authority(host: str, port: int) -> str:
39+
"""host:port, bracketing an IPv6 literal as a URL requires."""
40+
return f"[{host}]:{port}" if ":" in host else f"{host}:{port}"
41+
2142

2243
class LocalProxyResponse:
2344
def __init__(self, resp: aiohttp.ClientResponse) -> None:
@@ -41,11 +62,14 @@ async def close(self) -> None:
4162

4263

4364
class LocalProxy:
44-
"""Connections to the local application on 127.0.0.1:<port>."""
65+
"""Connections to the local application on localhost:<port>."""
4566

4667
def __init__(self, port: int) -> None:
4768
self.port = port
4869
self._session: aiohttp.ClientSession | None = None
70+
# The loopback address that last worked, so only the first request of
71+
# a session pays for probing both families.
72+
self._host: str | None = None
4973

5074
def _get_session(self) -> aiohttp.ClientSession:
5175
if self._session is None or self._session.closed:
@@ -57,10 +81,17 @@ def _get_session(self) -> aiohttp.ClientSession:
5781
)
5882
return self._session
5983

60-
def url(self, target: str, scheme: str = "http") -> URL:
84+
def hosts(self) -> tuple[str, ...]:
85+
"""Loopback addresses to try, best-known first."""
86+
if self._host is not None:
87+
return (self._host,)
88+
return LOCAL_HOSTS
89+
90+
def url(self, target: str, scheme: str = "http", host: str = "") -> URL:
6191
if not target.startswith("/"):
6292
target = "/" + target
63-
return URL(f"{scheme}://127.0.0.1:{self.port}{target}", encoded=True)
93+
authority = _authority(host or self.hosts()[0], self.port)
94+
return URL(f"{scheme}://{authority}{target}", encoded=True)
6495

6596
async def open_http(
6697
self,
@@ -69,15 +100,23 @@ async def open_http(
69100
headers: list[tuple[str, str]],
70101
body: bytes,
71102
) -> LocalProxyResponse:
72-
resp = await self._get_session().request(
73-
method,
74-
self.url(target),
75-
headers=CIMultiDict(headers),
76-
data=body if body else None,
77-
allow_redirects=False,
78-
skip_auto_headers=_SKIP_AUTO,
79-
)
80-
return LocalProxyResponse(resp)
103+
last: Exception | None = None
104+
for host in self.hosts():
105+
try:
106+
resp = await self._get_session().request(
107+
method,
108+
self.url(target, host=host),
109+
headers=CIMultiDict(headers),
110+
data=body if body else None,
111+
allow_redirects=False,
112+
skip_auto_headers=_SKIP_AUTO,
113+
)
114+
except aiohttp.ClientConnectorError as e:
115+
last = e # nothing listening on this family; try the other
116+
continue
117+
self._host = host
118+
return LocalProxyResponse(resp)
119+
raise last if last is not None else RuntimeError("no loopback address")
81120

82121
async def open_ws(
83122
self,
@@ -87,14 +126,23 @@ async def open_ws(
87126
) -> aiohttp.ClientWebSocketResponse:
88127
"""Upgrade to the local app. Raises aiohttp.WSServerHandshakeError
89128
(with .status and .headers) when the app refuses the upgrade."""
90-
return await self._get_session().ws_connect(
91-
self.url(target, "ws"),
92-
headers=CIMultiDict(headers),
93-
protocols=subprotocols,
94-
autoping=True,
95-
max_msg_size=0,
96-
compress=0,
97-
)
129+
last: Exception | None = None
130+
for host in self.hosts():
131+
try:
132+
ws = await self._get_session().ws_connect(
133+
self.url(target, "ws", host=host),
134+
headers=CIMultiDict(headers),
135+
protocols=subprotocols,
136+
autoping=True,
137+
max_msg_size=0,
138+
compress=0,
139+
)
140+
except aiohttp.ClientConnectorError as e:
141+
last = e
142+
continue
143+
self._host = host
144+
return ws
145+
raise last if last is not None else RuntimeError("no loopback address")
98146

99147
async def close(self) -> None:
100148
if self._session is not None and not self._session.closed:

0 commit comments

Comments
 (0)