Skip to content

fix(pep): resolve a named endpoint over a bound socket - #99

Merged
bojieli merged 2 commits into
mainfrom
fix/bind-endpoint-resolver
Sep 4, 2026
Merged

fix(pep): resolve a named endpoint over a bound socket#99
bojieli merged 2 commits into
mainfrom
fix/bind-endpoint-resolver

Conversation

@bojieli

@bojieli bojieli commented Sep 4, 2026

Copy link
Copy Markdown
Owner

What was wrong

--local-address exists so the outer path bypasses a host TUN route, and netbind.InterfaceControl asserts that at the kernel level with IP_BOUND_IF — which is what makes NEAppProxyFlow.isBound true, so a transparent proxy honouring it leaves those sockets alone.

That covers the sockets this client creates. It did not cover the ones the resolver creates on its behalf. net.Dialer applies LocalAddr and Control to the connection it dials, never to the lookup that turns the endpoint name into the address it dials to — and dialQUICConnection used net.ResolveUDPAddr, which is the default resolver outright.

So a client bound to a physical interface still asked that one question over an unbound socket.

Why it is fatal rather than untidy

On a host whose transparent proxy redirects port 53, that unbound question is claimed and sent to the proxy — which, when this client is the proxy's upstream, is this client. The name has to resolve before the datapath exists, and the datapath has to exist before the name resolves.

Observed on a live host: a provider whose endpoint is <name>:12540 logged lookup <name>: no such host on both transports 25,365 times in 67 minutes and never came up. Its lane sockets were fine the whole time — 20,162 of them went out direct under bypass:bound-interface, exactly as designed. Only the lookup leaked.

The fix

netbind.ResolverFor(spec, control) returns a resolver whose own sockets carry the same interface binding and source address as a data socket built for spec. Both lane paths use it for the endpoint name: the TCP dialer via net.Dialer.Resolver, and the QUIC path via a new resolveUDPAddrBound.

Two deliberate limits:

  • PreferGo is required or the cgo resolver answers inside libSystem and never calls Dial, silently dropping the binding. That has a cost — the pure resolver reads /etc/resolv.conf rather than the platform's own configuration — which is why the scope is the endpoint lookup rather than a process-wide resolver. A gateway endpoint is a public name reached over a specific interface, the lookup least likely to want split-horizon rules.
  • A source address is bound only when its family matches the nameserver's, since a mismatched one cannot carry the query. The interface binding still applies, and that is the part capture reads.

A literal endpoint address needs no lookup and takes the same path it always did.

Tests

New: internal/netbind/resolver_test.go (default resolver when unbound, error propagation, the dial actually leaving from the bound address, and family matching); internal/pep/resolve_test.go (literal fast path for v4 and v6, malformed endpoint, and an unusable local address surfacing as itself rather than as a lookup failure).

go vet and staticcheck -checks=all,-U1000 clean on both packages.

Note: go test -short -race ./... locally times out in internal/fec on TestTheWindowRateIsWhatTheWindowNeeds (~10m under the race detector). That is pre-existing and unreachable from these packages — go list -deps ./internal/fec contains neither netbind nor pep — and is not the combination CI runs.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MHr1RSawjHAuUBJwK63vxE

bojieli and others added 2 commits September 4, 2026 18:56
`--local-address` exists so the outer path bypasses a host TUN route, and
netbind.InterfaceControl asserts that with IP_BOUND_IF so a transparent
proxy honouring NEAppProxyFlow.isBound leaves those sockets alone. That
covers the sockets this client creates. It did not cover the ones the
resolver creates on its behalf: net.Dialer applies LocalAddr and Control
to the connection it dials, never to the lookup that turns the endpoint
name into the address it dials to, and dialQUICConnection went through
net.ResolveUDPAddr, which is the default resolver outright.

So a client bound to a physical interface still asked that one question
over an unbound socket. On a host whose transparent proxy redirects port
53 into this very client, the question cannot be answered: the name has
to resolve before the datapath exists, and the datapath has to exist
before the name resolves. Both transports then fail with `no such host`
and the provider never comes up.

netbind.ResolverFor builds a resolver whose own sockets carry the same
binding, and both lane paths use it for the endpoint name. PreferGo is
required or the cgo resolver answers inside libSystem and the binding is
silently dropped; the scope is kept to the endpoint lookup for that
reason, since the pure resolver reads /etc/resolv.conf rather than the
platform's configuration. A literal endpoint needs no lookup and keeps
the path it had.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHr1RSawjHAuUBJwK63vxE
…already did

Both dial paths resolve the local-address spec to build the socket's own
binding, and resolving an "auto" or "if:NAME" spec enumerates the host's
interfaces. Calling netbind.ResolverFor afterwards repeated that on every
connection, which is worst exactly where this matters: a client with
--local-address set, opening and replacing lanes.

netbind.ResolverForResult takes what the caller already has, and both
paths pass it. Behaviour is unchanged, and an unset --local-address still
never constructs a resolver at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHr1RSawjHAuUBJwK63vxE
@bojieli
bojieli merged commit a9a7ae1 into main Sep 4, 2026
23 checks passed
@bojieli
bojieli deleted the fix/bind-endpoint-resolver branch September 4, 2026 13:21
@bojieli bojieli mentioned this pull request Sep 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant