Skip to content

Commit b4b0a5e

Browse files
quinnjclaude
andauthored
Bind outbound connections to a local address/interface via local_addr (#834) (#1307)
* Bind outbound connections to a local address/interface via local_addr (#834) Adds a `local_addr` keyword to `Transport` and `Client` that binds outbound connections to a specific source IP — and therefore the outgoing interface — mirroring Go's `net.Dialer.LocalAddr` (and `curl --interface`). Useful on multi-homed hosts and for separating traffic by interface. Reseau's HostResolver/TCP.connect already accept a local bind endpoint; this threads it through HTTP's transport. `local_addr` is a connection-pool property (each bound Client keeps its own pool), so it lives on the Transport's resolver — no pool-key changes and no cross-binding reuse, which is also why it is set per-Transport/Client rather than per-request (matching Go, where LocalAddr is a Dialer property, not a per-request option). Accepts an IP-literal String (kernel-chosen ephemeral source port) or a Reseau TCP.SocketAddrV4/SocketAddrV6 for a fixed source port; interface names are not accepted (resolve to an IP first, as Go does). Binding to an address not assigned locally fails fast at bind(). Passing local_addr alongside an explicit transport is rejected as ambiguous. Tests cover the normalizer, client- and transport-level binding, the ambiguity guard, and that an unassigned source address fails (proving the bind is actually applied). Documents the knob in the client guide. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Collapse _normalize_local_addr into a single method (review feedback) Keep the local_addr normalization in one method with early-return isa checks instead of four dispatched methods, so inference does not have to pick between them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent eeb77b2 commit b4b0a5e

4 files changed

Lines changed: 109 additions & 2 deletions

File tree

docs/src/guides/client.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,28 @@ Important `Client` and `Transport` knobs:
145145
- explicit proxy routing with `ProxyConfig`, `ProxyURL`, `ProxyFromEnvironment`, and `NoProxy`;
146146
proxy URLs may use `http://`, `socks5://`, or `socks5h://`
147147
- coordinated retries through a shared `RetryBucket`
148+
- binding outbound connections to a specific source address/interface with `local_addr`
149+
150+
### Binding to a local address
151+
152+
Like Go's `net.Dialer.LocalAddr` (and `curl --interface`), `local_addr` selects the
153+
source IP — and therefore the outgoing interface — for a client's connections. This
154+
is useful on multi-homed hosts or to separate traffic by interface. Pass an IP-literal
155+
string (the kernel chooses an ephemeral source port) or a `Reseau.TCP.SocketAddrV4` /
156+
`SocketAddrV6` for full control including a fixed source port:
157+
158+
```julia
159+
# All requests from this client leave via 192.0.2.10.
160+
client = HTTP.Client(local_addr = "192.0.2.10")
161+
HTTP.get("http://example.com"; client = client)
162+
163+
# Equivalent, set on the transport (the canonical home — local_addr is a
164+
# connection-pool property, so each bound client keeps its own pool):
165+
client = HTTP.Client(transport = HTTP.Transport(local_addr = "192.0.2.10"))
166+
```
167+
168+
The address must be an IP assigned to a local interface; binding to an unassigned
169+
address fails fast. Interface *names* are not accepted — resolve them to an IP first.
148170

149171
### Per-`Client` defaults
150172

src/http_client.jl

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ Keyword arguments:
2828
- `connect_timeout`, `request_timeout`, `response_header_timeout`,
2929
`read_idle_timeout`, `write_idle_timeout`: defaults applied to every request
3030
unless the call passes the matching keyword. `0` disables.
31+
- `local_addr`: bind this client's outbound connections to a source IP/interface
32+
(Go's `net.Dialer.LocalAddr` model). Accepts an IP-literal `String` (ephemeral
33+
source port) or a `Reseau.TCP.SocketAddrV4`/`SocketAddrV6`. Cannot be combined
34+
with an explicit `transport`; set it on the `Transport` in that case.
3135
3236
Pass a `Client` with the `client` keyword to `request`, `get`, `open`, or the
3337
other verb helpers when you want connection reuse and shared cookies across
@@ -303,7 +307,7 @@ function (trace::_VerboseTrace)(event::DoneEvent)::Nothing
303307
end
304308

305309
function Client(;
306-
transport::Transport=Transport(proxy=ProxyFromEnvironment()),
310+
transport::Union{Nothing,Transport}=nothing,
307311
check_redirect=nothing,
308312
cookiejar::Union{Nothing,CookieJar}=CookieJar(),
309313
max_redirects::Integer=10,
@@ -317,8 +321,18 @@ function Client(;
317321
response_header_timeout::Real=0,
318322
read_idle_timeout::Real=0,
319323
write_idle_timeout::Real=0,
324+
local_addr=nothing,
320325
)
321326
max_redirects >= 0 || throw(ArgumentError("max_redirects must be >= 0"))
327+
# `local_addr` is a convenience that binds outbound connections to a source
328+
# IP/interface (Go's net.Dialer.LocalAddr model). It belongs to the
329+
# transport's connection pool, so it folds into the default transport; if the
330+
# caller supplies their own transport, they must set it there instead.
331+
if transport === nothing
332+
transport = Transport(proxy=ProxyFromEnvironment(), local_addr=local_addr)
333+
elseif local_addr !== nothing
334+
throw(ArgumentError("local_addr cannot be combined with an explicit `transport`; pass local_addr to Transport(...) instead"))
335+
end
322336
return Client{typeof(check_redirect)}(
323337
transport,
324338
check_redirect,

src/http_transport.jl

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,28 @@ mutable struct H1Body <: AbstractBody
230230
cancel_callback::Union{Nothing,Function}
231231
end
232232

233+
# Normalize a user-supplied local bind address into a Reseau `SocketEndpoint`.
234+
# Modeled on Go's `net.Dialer.LocalAddr`: an IP-literal string binds that source
235+
# IP with an OS-chosen ephemeral port (the common case — pick the interface, let
236+
# the kernel pick the port), while a ready-made `TCP.SocketAddrV4`/`SocketAddrV6`
237+
# gives full control including a fixed source port. Interface *names* (curl's
238+
# `--interface eth0`) are intentionally not accepted — like Go, the address is
239+
# an IP, which keeps resolution out of the transport.
240+
function _normalize_local_addr(addr)::Union{Nothing,TCP.SocketEndpoint}
241+
addr === nothing && return nothing
242+
addr isa TCP.SocketEndpoint && return addr
243+
if addr isa AbstractString
244+
s = String(addr)
245+
isempty(s) && throw(ArgumentError("local_addr string must be a non-empty IP literal"))
246+
v4 = HostResolvers._parse_ipv4_literal(s)
247+
v4 === nothing || return TCP.SocketAddrV4(v4, 0)
248+
v6 = HostResolvers._parse_ipv6_literal(s)
249+
v6 === nothing || return TCP.SocketAddrV6(v6, 0)
250+
throw(ArgumentError("local_addr '$s' is not a valid IPv4 or IPv6 address literal"))
251+
end
252+
throw(ArgumentError("local_addr must be an IP-literal String or a Reseau TCP.SocketAddrV4/SocketAddrV6, got $(typeof(addr))"))
253+
end
254+
233255
function Transport(;
234256
tls_config::Union{Nothing,TLS.Config}=nothing,
235257
proxy=nothing,
@@ -238,12 +260,13 @@ function Transport(;
238260
max_idle_total::Integer=64,
239261
max_conns_per_host::Integer=0,
240262
idle_timeout_ns::Integer=Int64(90_000_000_000),
263+
local_addr=nothing,
241264
)
242265
max_idle_per_host > 0 || throw(ArgumentError("max_idle_per_host must be > 0"))
243266
max_idle_total > 0 || throw(ArgumentError("max_idle_total must be > 0"))
244267
max_conns_per_host >= 0 || throw(ArgumentError("max_conns_per_host must be >= 0"))
245268
idle_timeout_ns >= 0 || throw(ArgumentError("idle_timeout_ns must be >= 0"))
246-
host_resolver = HostResolvers.HostResolver()
269+
host_resolver = HostResolvers.HostResolver(local_addr=_normalize_local_addr(local_addr))
247270
return Transport(
248271
host_resolver,
249272
tls_config,

test/http_client_transport_tests.jl

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1137,3 +1137,51 @@ end
11371137
close(server)
11381138
end
11391139
end
1140+
1141+
@testset "local_addr binds outbound connections to a source IP (#834)" begin
1142+
# Normalizer: IP-literal strings become ephemeral-port endpoints; ready-made
1143+
# SocketEndpoints pass through; junk is rejected (Go net.Dialer.LocalAddr model).
1144+
n = HT._normalize_local_addr
1145+
@test n(nothing) === nothing
1146+
a4 = n("127.0.0.1")
1147+
@test a4 isa NC.SocketAddrV4 && a4.ip == (0x7f, 0x00, 0x00, 0x01) && a4.port == 0x0000
1148+
a6 = n("::1")
1149+
@test a6 isa NC.SocketAddrV6 && a6.port == 0x0000
1150+
fixed = NC.SocketAddrV4((10, 0, 0, 1), 5555)
1151+
@test n(fixed) === fixed
1152+
@test_throws ArgumentError n("not-an-ip")
1153+
@test_throws ArgumentError n("")
1154+
@test_throws ArgumentError n(12345)
1155+
1156+
server = HTTP.serve!("127.0.0.1", 0) do req
1157+
return HTTP.Response(200, "bound")
1158+
end
1159+
try
1160+
url = "http://127.0.0.1:$(HTTP.port(server))/"
1161+
1162+
# Client-level binding to a valid local source succeeds.
1163+
client = HTTP.Client(local_addr = "127.0.0.1")
1164+
resp = HTTP.get(url; client = client)
1165+
@test resp.status == 200
1166+
@test String(resp.body) == "bound"
1167+
1168+
# Transport-level binding is the canonical form and behaves identically.
1169+
tclient = HTTP.Client(transport = HTTP.Transport(local_addr = "127.0.0.1"))
1170+
@test HTTP.get(url; client = tclient).status == 200
1171+
1172+
# Binding to an address not assigned to any interface must fail at bind()
1173+
# (EADDRNOTAVAIL) — proof the source address is actually applied, not ignored.
1174+
@test_throws Exception HTTP.get(
1175+
url;
1176+
client = HTTP.Client(local_addr = "203.0.113.7"), # TEST-NET-3, never local
1177+
retry = false,
1178+
connect_timeout = 5,
1179+
)
1180+
1181+
# local_addr is ambiguous alongside an explicit transport.
1182+
@test_throws ArgumentError HTTP.Client(transport = HTTP.Transport(), local_addr = "127.0.0.1")
1183+
finally
1184+
HTTP.forceclose(server)
1185+
wait(server)
1186+
end
1187+
end

0 commit comments

Comments
 (0)