Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions docs/src/guides/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,28 @@ Important `Client` and `Transport` knobs:
- explicit proxy routing with `ProxyConfig`, `ProxyURL`, `ProxyFromEnvironment`, and `NoProxy`;
proxy URLs may use `http://`, `socks5://`, or `socks5h://`
- coordinated retries through a shared `RetryBucket`
- binding outbound connections to a specific source address/interface with `local_addr`

### Binding to a local address

Like Go's `net.Dialer.LocalAddr` (and `curl --interface`), `local_addr` selects the
source IP — and therefore the outgoing interface — for a client's connections. This
is useful on multi-homed hosts or to separate traffic by interface. Pass an IP-literal
string (the kernel chooses an ephemeral source port) or a `Reseau.TCP.SocketAddrV4` /
`SocketAddrV6` for full control including a fixed source port:

```julia
# All requests from this client leave via 192.0.2.10.
client = HTTP.Client(local_addr = "192.0.2.10")
HTTP.get("http://example.com"; client = client)

# Equivalent, set on the transport (the canonical home — local_addr is a
# connection-pool property, so each bound client keeps its own pool):
client = HTTP.Client(transport = HTTP.Transport(local_addr = "192.0.2.10"))
```

The address must be an IP assigned to a local interface; binding to an unassigned
address fails fast. Interface *names* are not accepted — resolve them to an IP first.

### Per-`Client` defaults

Expand Down
16 changes: 15 additions & 1 deletion src/http_client.jl
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ Keyword arguments:
- `connect_timeout`, `request_timeout`, `response_header_timeout`,
`read_idle_timeout`, `write_idle_timeout`: defaults applied to every request
unless the call passes the matching keyword. `0` disables.
- `local_addr`: bind this client's outbound connections to a source IP/interface
(Go's `net.Dialer.LocalAddr` model). Accepts an IP-literal `String` (ephemeral
source port) or a `Reseau.TCP.SocketAddrV4`/`SocketAddrV6`. Cannot be combined
with an explicit `transport`; set it on the `Transport` in that case.

Pass a `Client` with the `client` keyword to `request`, `get`, `open`, or the
other verb helpers when you want connection reuse and shared cookies across
Expand Down Expand Up @@ -303,7 +307,7 @@ function (trace::_VerboseTrace)(event::DoneEvent)::Nothing
end

function Client(;
transport::Transport=Transport(proxy=ProxyFromEnvironment()),
transport::Union{Nothing,Transport}=nothing,
check_redirect=nothing,
cookiejar::Union{Nothing,CookieJar}=CookieJar(),
max_redirects::Integer=10,
Expand All @@ -317,8 +321,18 @@ function Client(;
response_header_timeout::Real=0,
read_idle_timeout::Real=0,
write_idle_timeout::Real=0,
local_addr=nothing,
)
max_redirects >= 0 || throw(ArgumentError("max_redirects must be >= 0"))
# `local_addr` is a convenience that binds outbound connections to a source
# IP/interface (Go's net.Dialer.LocalAddr model). It belongs to the
# transport's connection pool, so it folds into the default transport; if the
# caller supplies their own transport, they must set it there instead.
if transport === nothing
transport = Transport(proxy=ProxyFromEnvironment(), local_addr=local_addr)
elseif local_addr !== nothing
throw(ArgumentError("local_addr cannot be combined with an explicit `transport`; pass local_addr to Transport(...) instead"))
end
return Client{typeof(check_redirect)}(
transport,
check_redirect,
Expand Down
25 changes: 24 additions & 1 deletion src/http_transport.jl
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,28 @@ mutable struct H1Body <: AbstractBody
cancel_callback::Union{Nothing,Function}
end

# Normalize a user-supplied local bind address into a Reseau `SocketEndpoint`.
# Modeled on Go's `net.Dialer.LocalAddr`: an IP-literal string binds that source
# IP with an OS-chosen ephemeral port (the common case — pick the interface, let
# the kernel pick the port), while a ready-made `TCP.SocketAddrV4`/`SocketAddrV6`
# gives full control including a fixed source port. Interface *names* (curl's
# `--interface eth0`) are intentionally not accepted — like Go, the address is
# an IP, which keeps resolution out of the transport.
function _normalize_local_addr(addr)::Union{Nothing,TCP.SocketEndpoint}
addr === nothing && return nothing
addr isa TCP.SocketEndpoint && return addr
if addr isa AbstractString
s = String(addr)
isempty(s) && throw(ArgumentError("local_addr string must be a non-empty IP literal"))
v4 = HostResolvers._parse_ipv4_literal(s)
v4 === nothing || return TCP.SocketAddrV4(v4, 0)
v6 = HostResolvers._parse_ipv6_literal(s)
v6 === nothing || return TCP.SocketAddrV6(v6, 0)
throw(ArgumentError("local_addr '$s' is not a valid IPv4 or IPv6 address literal"))
end
throw(ArgumentError("local_addr must be an IP-literal String or a Reseau TCP.SocketAddrV4/SocketAddrV6, got $(typeof(addr))"))
end

function Transport(;
tls_config::Union{Nothing,TLS.Config}=nothing,
proxy=nothing,
Expand All @@ -238,12 +260,13 @@ function Transport(;
max_idle_total::Integer=64,
max_conns_per_host::Integer=0,
idle_timeout_ns::Integer=Int64(90_000_000_000),
local_addr=nothing,
)
max_idle_per_host > 0 || throw(ArgumentError("max_idle_per_host must be > 0"))
max_idle_total > 0 || throw(ArgumentError("max_idle_total must be > 0"))
max_conns_per_host >= 0 || throw(ArgumentError("max_conns_per_host must be >= 0"))
idle_timeout_ns >= 0 || throw(ArgumentError("idle_timeout_ns must be >= 0"))
host_resolver = HostResolvers.HostResolver()
host_resolver = HostResolvers.HostResolver(local_addr=_normalize_local_addr(local_addr))
return Transport(
host_resolver,
tls_config,
Expand Down
48 changes: 48 additions & 0 deletions test/http_client_transport_tests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -1137,3 +1137,51 @@ end
close(server)
end
end

@testset "local_addr binds outbound connections to a source IP (#834)" begin
# Normalizer: IP-literal strings become ephemeral-port endpoints; ready-made
# SocketEndpoints pass through; junk is rejected (Go net.Dialer.LocalAddr model).
n = HT._normalize_local_addr
@test n(nothing) === nothing
a4 = n("127.0.0.1")
@test a4 isa NC.SocketAddrV4 && a4.ip == (0x7f, 0x00, 0x00, 0x01) && a4.port == 0x0000
a6 = n("::1")
@test a6 isa NC.SocketAddrV6 && a6.port == 0x0000
fixed = NC.SocketAddrV4((10, 0, 0, 1), 5555)
@test n(fixed) === fixed
@test_throws ArgumentError n("not-an-ip")
@test_throws ArgumentError n("")
@test_throws ArgumentError n(12345)

server = HTTP.serve!("127.0.0.1", 0) do req
return HTTP.Response(200, "bound")
end
try
url = "http://127.0.0.1:$(HTTP.port(server))/"

# Client-level binding to a valid local source succeeds.
client = HTTP.Client(local_addr = "127.0.0.1")
resp = HTTP.get(url; client = client)
@test resp.status == 200
@test String(resp.body) == "bound"

# Transport-level binding is the canonical form and behaves identically.
tclient = HTTP.Client(transport = HTTP.Transport(local_addr = "127.0.0.1"))
@test HTTP.get(url; client = tclient).status == 200

# Binding to an address not assigned to any interface must fail at bind()
# (EADDRNOTAVAIL) — proof the source address is actually applied, not ignored.
@test_throws Exception HTTP.get(
url;
client = HTTP.Client(local_addr = "203.0.113.7"), # TEST-NET-3, never local
retry = false,
connect_timeout = 5,
)

# local_addr is ambiguous alongside an explicit transport.
@test_throws ArgumentError HTTP.Client(transport = HTTP.Transport(), local_addr = "127.0.0.1")
finally
HTTP.forceclose(server)
wait(server)
end
end
Loading