[
https://issues.apache.org/jira/browse/THRIFT-6191?page=com.atlassian.jira.plugin.system.issuetabpanels:comment-tabpanel&focusedCommentId=18113551#comment-18113551
]
Jens Geyer commented on THRIFT-6191:
------------------------------------
h2. Direction decided, and a correction to the analysis above
h3. The framing above is not quite right
The description says the client "kept the flag", which reads as an oversight.
It was not.
{{9b9567b23}} touched {{TSocket.cpp}} as well (+6/-2) and made a *deliberately
different choice
per side*:
* server: drop {{AI_ADDRCONFIG}} unconditionally;
* client: keep it, and extend the previously Windows-only retry-without-it to
POSIX on
{{EAI_NODATA}}.
So the author saw both sides in the same commit and kept the flag on the client
on purpose --
presumably to avoid futile IPv6 connect attempts. THRIFT-5880 only widened that
retry with
{{EAI_ADDRFAMILY}}. Any direction that strips {{AI_ADDRCONFIG}} from the client
is reversing a
considered decision rather than fixing a slip.
h3. Measured, rather than reasoned about
A probe in a container with {{::1}} on {{lo}} and no other IPv6 address:
{code}
--- named host: localhost ---
server today (AI_PASSIVE|AI_V4MAPPED) -> ::1 127.0.0.1
client today (AI_PASSIVE|AI_ADDRCONFIG) -> 127.0.0.1
server + AI_ADDRCONFIG -> 127.0.0.1
--- wildcard bind: NULL host + AI_PASSIVE ---
server today (AI_PASSIVE|AI_V4MAPPED) -> 0.0.0.0 ::
server + AI_ADDRCONFIG -> 0.0.0.0
--- numeric ::1 ---
server today (AI_PASSIVE|AI_V4MAPPED) -> ::1
server + AI_ADDRCONFIG -> ERROR EAI_ADDRFAMILY (-9)
{code}
Two things this settles:
* {{AI_V4MAPPED}} is *decorative* on the server today.
{{AddressResolutionHelper::query()}} sets
{{ai_family = AF_UNSPEC}}, and {{AI_V4MAPPED}} is ignored unless the family
is {{AF_INET6}}.
The entire behavioural difference between the two sides is {{AI_ADDRCONFIG}}.
* The wildcard bind is *unaffected*. {{0.0.0.0}} already sorts ahead of {{::}},
so the server
binds IPv4 either way; and where IPv6 genuinely is configured,
{{AI_ADDRCONFIG}} does not
remove {{::}}.
h3. The three directions in the description
* *bind {{::}} instead of the first resolved address* -- rejected. For the
wildcard case this is
already what happens. For a named address it is a silent and
security-relevant behaviour
change: {{TServerSocket("127.0.0.1", 9090)}} is how a caller keeps a server
off the network,
and this would turn it into a bind on every interface.
* *try every resolved address* -- does not fix this, because {{::1}} *binds
successfully*; the
iteration never triggers. Read as "bind them all" it means a multi-socket
listener, which is a
much larger change to {{TServerSocket}}. Worth noting separately that the
existing loop does
not do what its comment claims -- see the follow-up below.
* *drop {{AI_ADDRCONFIG}} on the client* -- works, but reverses the 5186
decision and
reintroduces what the flag exists to prevent: on a v4-only network talking to
a dual-stack
remote, the client tries the AAAA first, which on a network that blackholes
IPv6 blocks until
the connect timeout.
h3. A fourth direction, which is what was implemented
Make the *server* match the *client*, not the other way round: resolve with
{{AI_ADDRCONFIG}} and
fall back to resolving without it when that leaves nothing to bind -- exactly
the shape
{{TSocket::open()}} already has.
* the container case: both sides see {{127.0.0.1}}, agree, connect;
* THRIFT-5186's case (host with no configured address): first resolve fails,
fallback resolves;
* THRIFT-5880's numeric case: covered by the same fallback -- and as the
measurement shows, an
explicit {{"::1"}} really does fail with {{EAI_ADDRFAMILY}}, so the fallback
is load-bearing
rather than defensive;
* no client-side connect-timeout hazard, because the client does not change;
* it is already shipping: the {{#ifdef ANDROID}} branch passed
{{AI_PASSIVE|AI_ADDRCONFIG}}
today, so Android never had this bug. That branch is now redundant and goes
away.
PR: [#3836|https://github.com/apache/thrift/pull/3836]
h3. Tests
{{TServerSocketTest/test_bind_to_address}} already covered this for
{{TServerSocket}}.
{{TNonblockingServerSocket}} carried the identical line with no equivalent
coverage, so
{{TNonblockingServerTest/bind_and_connect_agree_on_hostname}} was added. Both
were confirmed to
fail against the unmodified library first, with {{connect() failed: Connection
refused}}. Full
C++ suite in the affected container: 39/39 {{ctest}} pass, where {{UnitTests}}
was red before.
h3. Two follow-ups found while checking direction 2, both separate from this
ticket
* {{TServerSocket::listen()}}'s bind loop is commented "we iterate over what
system gave us,
picking the first address that works", but {{retryLimit_}} defaults to *0*, so
{{while ((retries++ < retryLimit_) && ...)}} is false after the first
address. Only one address
is ever tried unless the caller sets {{setRetryLimit()}}. A {{socket()}}
failure for {{AF_INET6}}
on a kernel without IPv6 {{continue}}s into the same false condition instead
of falling through
to the IPv4 address.
* the same loop leaks a descriptor: when {{retryLimit_ > 0}} and {{bind()}}
fails, the next
iteration overwrites {{serverSocket_}} with a fresh {{socket()}} without
closing the previous one.
Also noted, harmless today: {{AddressResolutionHelper}} defaults its flags to
{{AI_V4MAPPED | AI_ADDRCONFIG}} with no {{AI_PASSIVE}} -- a third combination
that agrees with
neither call site. Both existing callers pass explicit flags, so it is
unreachable, but it is a
trap for the next one.
_Analysis and patch prepared with AI assistance (Claude Opus 5); reviewed and
submitted by Jens Geyer._
> C++ server sockets and TSocket resolve the same host with different
> getaddrinfo flags
> -------------------------------------------------------------------------------------
>
> Key: THRIFT-6191
> URL: https://issues.apache.org/jira/browse/THRIFT-6191
> Project: Thrift
> Issue Type: Bug
> Components: C++ - Library
> Reporter: Jens Geyer
> Priority: Major
> Time Spent: 10m
> Remaining Estimate: 0h
>
> h2. What happens
> {{TServerSocket::listen()}} and {{TNonblockingServerSocket::listen()}}
> resolve the bind address
> with {{AI_PASSIVE | AI_V4MAPPED}}. {{TSocket::open()}} resolves the connect
> address with
> {{AI_PASSIVE | AI_ADDRCONFIG}}.
> On a host where {{/etc/hosts}} lists {{::1 localhost}} but no IPv6 address is
> configured on
> {{lo}} -- a default Docker container, for instance -- the two disagree about
> what
> {{"localhost"}} means:
> ||Caller||Flags||First address returned||
> |{{TServerSocket::listen()}}|{{AI_PASSIVE}} + {{AI_V4MAPPED}}|{{::1}}, and
> the bind succeeds|
> |{{TSocket::open()}}|{{AI_PASSIVE}} + {{AI_ADDRCONFIG}}|{{127.0.0.1}}|
> A C++ server bound to {{"localhost"}} therefore listens on {{::1}} while a
> C++ client connecting
> to {{"localhost"}} on the same machine dials {{127.0.0.1}}, and the connect
> fails with
> {{ECONNREFUSED}}.
> The {{IPV6_V6ONLY=0}} that {{listen()}} sets on an {{AF_INET6}} socket does
> not rescue it: the
> bind is to {{::1}} specifically rather than to {{::}}, and a v4-mapped
> address is not covered by
> that.
> h2. Where it shows
> {{lib/cpp/test/TServerSocketTest.cpp}}'s {{test_bind_to_address}} fails in
> exactly this
> environment, and has nothing to do with the test itself:
> {noformat}
> unknown location(0): fatal error: in "TServerSocketTest/test_bind_to_address":
> apache::thrift::transport::TTransportException: connect() failed: Connection
> refused
> {noformat}
> CI does not catch it. GitHub runners have {{::1}} on {{lo}}, so
> {{AI_ADDRCONFIG}} returns IPv6
> for the client as well and both sides agree again.
> h2. Measured
> A standalone probe that resolves and binds with each flag set and then
> connects, run in a
> container with no IPv6 address on {{lo}}:
> {noformat}
> server pre-0.14.0 (AI_PASSIVE|AI_ADDRCONFIG) server binds IPv4, client
> tries IPv4 -> CONNECTED
> server today (AI_PASSIVE|AI_V4MAPPED) server binds IPv6, client
> tries IPv4 -> Connection refused
> server today, without IPV6_V6ONLY=0 server binds IPv6, client
> tries IPv4 -> Connection refused
> {noformat}
> h2. When it changed
> Last worked in 0.13.0. THRIFT-5186 removed {{AI_ADDRCONFIG}} from the server
> sockets in
> {{9b9567b23}} (2020-04-27), first released in 0.14.0. That change was right
> on its own terms --
> {{AI_ADDRCONFIG}} does not count the loopback address as a configured
> address, so a host with no
> other address could not resolve localhost at all. The client side kept the
> flag, and the
> asymmetry is what breaks.
> THRIFT-5880 ({{25202e1b0}}, 0.23.0) later gave {{TSocket::open()}} a retry
> without
> {{AI_ADDRCONFIG}}, but it is conditional on {{EAI_NODATA}} /
> {{EAI_ADDRFAMILY}} and does not fire
> here, because the first call succeeds.
> h2. Possible directions
> Not decided, and worth discussing before anyone writes a patch:
> * bind {{::}} rather than the first resolved address, keeping
> {{IPV6_V6ONLY=0}};
> * try every resolved address instead of stopping at the first that binds;
> * drop {{AI_ADDRCONFIG}} on the client too, matching what THRIFT-5186 did for
> the server.
> {{TServerSocket}} and {{TNonblockingServerSocket}} carry the same line and
> would need the same
> treatment.
> _Filed with AI assistance (Claude Opus 5); reviewed and filed by Jens Geyer._
--
This message was sent by Atlassian Jira
(v8.20.10#820010)