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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added
- Added `HTTP2Settings` to configure HTTP/2 receive flow-control windows (per-stream `initial_window_size` and connection-level `connection_window_size`). Pass it via the `http2_settings` keyword on `Client`, `Server`, `listen!`, `serve!`, `serve`, and `connect_h2!`. Defaults preserve the protocol-default 65535-byte windows, and the per-stream receive buffer cap is derived from the window. Raising the windows improves single-stream throughput on links with non-trivial latency.
- Added `HTTP.peeraddr(::HTTP.Stream)`, returning the remote (client) `SocketAddr` of a server stream for both plain-TCP and TLS connections and both HTTP/1 and HTTP/2. This is the supported way to obtain the client IP (for rate limiting, audit logging, and per-client policy) without reaching into transport internals, and restores the capability `Sockets.getpeername(::HTTP.Stream)` provided in HTTP.jl 1.x.

### Fixed
- Percent-decode `userinfo` before building the `Basic` auth header (RFC 3986); fixes wrong credentials for request URLs and proxies containing percent-encoded characters.
Expand Down
1 change: 1 addition & 0 deletions docs/src/api/server.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ HTTP.startwrite
HTTP.setstatus
HTTP.addtrailer
HTTP.closeread
HTTP.peeraddr
```

## Routing and Middleware
Expand Down
29 changes: 29 additions & 0 deletions docs/src/guides/migration-1x.md
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,34 @@ SOCKS5 proxies are configured with `socks5://` or `socks5h://` URLs. Both
schemes follow Go's HTTP transport behavior and pass domain targets to the
proxy for resolution.

Peer (client) addresses are read through the transport layer rather than
`Sockets`. In 1.x, `Sockets.getpeername(::HTTP.Stream)` returned the client
IP and port from a server stream; in 2.0, use `HTTP.peeraddr(stream)`, which
returns a `SocketAddr` (or `nothing` when unavailable) for both plain-TCP and
TLS connections.

Before:

```julia
HTTP.listen("127.0.0.1", 8080) do stream
ip, port = Sockets.getpeername(stream)
@info "client" ip port
end
```

After:

```julia
HTTP.listen!("127.0.0.1", 8080) do stream
addr = HTTP.peeraddr(stream)
if addr !== nothing
# `addr.ip` is an NTuple of octets (not a `Sockets.IPAddr`);
# `string(addr)` renders it as "ip:port".
@info "client" ip = addr.ip port = addr.port
end
end
```

## Servers

Request/response servers still use `HTTP.serve!`:
Expand Down Expand Up @@ -616,6 +644,7 @@ Treat these as temporary migration aids. New code should use the documented
- Replace `HTTP.download` with `Downloads.download` or an explicit
`HTTP.request(...; response_stream = io)` file stream.
- Move WebSocket code to `HTTP.WebSockets`.
- Replace `Sockets.getpeername(stream)` with `HTTP.peeraddr(stream)`.
- Replace internal parser/connection/HPACK/HTTP2 usage with documented APIs.
- Run integration tests for redirects, retries, proxy configuration, cookies,
streaming, WebSockets, SSE, and HTTP/2 after upgrading.
2 changes: 1 addition & 1 deletion src/HTTP.jl
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ include("http_websockets.jl")
:close_idle_connections!, :defaultheader!, :delete, :do!, :expired, :fileserver,
:forceclose, :get, :get!, :get_request_context, :hasheader, :head, :header,
:headercontains, :headers, :idle_connection_count, :isaborted, :isrecoverable,
:listen, :listen!, :mkheaders, :nobody, :open, :options, :patch, :port, :post, :put,
:listen, :listen!, :mkheaders, :nobody, :open, :options, :patch, :peeraddr, :port, :post, :put,
:read_request, :removeheader, :request, :retry_attempts, :roundtrip!, :serve, :serve!,
:servecontent, :servefile, :set_deadline!, :setheader, :setstatus, :sse_stream,
:startwrite, :streamhandler, :trailers, :write_request!, :write_response!,
Expand Down
40 changes: 40 additions & 0 deletions src/http_server_streams.jl
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,46 @@ function _server_close(stream::Stream)::Nothing
return nothing
end

"""
peeraddr(stream::Stream) -> Union{Nothing, Reseau.TCP.SocketAddr}

Return the remote (client) socket address of a server `stream`, or `nothing`
when the peer endpoint is unavailable.

The returned `SocketAddr` carries the client IP and port: render it with
`string(addr)` to get `"ip:port"`, or read `addr.ip` (an `NTuple` of octets)
and `addr.port`. Works for both plain-TCP and TLS connections and for HTTP/1
and HTTP/2 server streams.

This is the supported way to obtain the client IP for rate limiting, audit
logging, and other per-client policy; it avoids reaching into transport
internals and restores the capability `Sockets.getpeername(::HTTP.Stream)`
provided in HTTP.jl 1.x.

Throws `ArgumentError` when called on a client-side stream.
"""
function peeraddr(stream::Stream)
_require_server_stream(stream)
conn = _server_stream_transport_conn(stream)
conn === nothing && return nothing
return _conn_remote_addr(conn)
end

# Live server streams track their connection in `tracked` for both HTTP/1 and
# HTTP/2 (an h2 stream is constructed with `tracked` and a shared `h2_conn`
# pointing at the same connection); `h2_conn` is a defensive fallback. The
# connection may be plain-TCP or TLS, and both transports expose a public
# `remote_addr`. A server stream without a live connection (e.g. one built from
# a buffered request) yields `nothing`.
@inline function _server_stream_transport_conn(stream::Stream)
tracked = stream.tracked
tracked === nothing || return tracked.conn
return stream.h2_conn
end

@inline _conn_remote_addr(conn::TCP.Conn) = TCP.remote_addr(conn)
@inline _conn_remote_addr(conn::TLS.Conn) = TLS.remote_addr(conn)

function _write_response_body_to_stream!(stream::Stream, body)::Nothing
body === nothing && return nothing
if body isa EmptyBody
Expand Down
27 changes: 27 additions & 0 deletions test/http2_server_tests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,33 @@ end
end
end

@testset "HTTP/2 server peeraddr exposes the client socket address" begin
captured = Channel{Any}(1)
server = HT.listen!("127.0.0.1", 0; listenany = true) do stream
put!(captured, HT.peeraddr(stream))
_ = HT.startread(stream)
HT.setstatus(stream, 200)
HT.startwrite(stream)
write(stream, "ok")
return nothing
end
address = HT.server_addr(server)
conn = HT.connect_h2!(address; secure = false)
try
req = HT.Request("GET", "/"; host = address, body = HT.EmptyBody(), content_length = 0, proto_major = 2, proto_minor = 0)
res = HT.h2_roundtrip!(conn, req)
@test res.status == 200
addr = take!(captured)
@test addr isa NC.SocketAddr
@test addr.ip == (0x7f, 0x00, 0x00, 0x01)
@test addr.port > 0
finally
close(conn)
HT.forceclose(server)
_ = timedwait(() -> istaskdone(server.serve_task::Task), 3.0; pollint = 0.001)
end
end

@testset "HTTP/2 server writes vector responses with trailers" begin
server = HT.serve!("127.0.0.1", 0; listenany = true) do request
_ = request
Expand Down
76 changes: 76 additions & 0 deletions test/http_server_http1_tests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,82 @@ end
end
end

@testset "HTTP server peeraddr exposes the client socket address" begin
captured = Channel{Any}(1)
server = HT.listen!("127.0.0.1", 0; listenany = true) do stream
put!(captured, HT.peeraddr(stream))
_ = HT.startread(stream)
HT.setstatus(stream, 200)
HT.startwrite(stream)
write(stream, "ok")
return nothing
end
address = HT.server_addr(server)
try
resp = HT.get("http://$(address)/")
@test resp.status == 200
addr = take!(captured)
@test addr isa NC.SocketAddr
# The client connected over IPv4 loopback, so the server sees 127.0.0.1
# with the client's ephemeral source port.
@test addr.ip == (0x7f, 0x00, 0x00, 0x01)
@test addr.port > 0

# peeraddr is a server-stream operation: a client-side stream must
# reject it rather than report a bogus address.
HT.open(:GET, "http://$(address)/") do client_stream
@test_throws ArgumentError HT.peeraddr(client_stream)
_ = HT.startread(client_stream)
read(client_stream)
end
finally
_run_with_timeout(() -> HT.forceclose(server); label = "server forceclose")
_run_with_timeout(() -> wait(server); label = "server task completion")
end

# A server stream with no live connection (e.g. one built from a buffered
# request) reports no peer address rather than throwing.
@test HT.peeraddr(HT.Stream(HT.Request("GET", "/"))) === nothing
end

@testset "HTTP server peeraddr exposes the client socket address over TLS" begin
cert_file = joinpath(@__DIR__, "resources", "localhost-only.crt")
key_file = joinpath(@__DIR__, "resources", "localhost-only.key")
captured = Channel{Any}(1)
tls_listener = Reseau.TLS.listen(
"tcp",
"127.0.0.1:0",
Reseau.TLS.Config(verify_peer = false, cert_file = cert_file, key_file = key_file);
backlog = 8,
)
server = HT.listen!(tls_listener) do stream
put!(captured, HT.peeraddr(stream))
_ = HT.startread(stream)
HT.setstatus(stream, 200)
HT.startwrite(stream)
write(stream, "ok")
return nothing
end
tls_addr = Reseau.TLS.addr(tls_listener)::NC.SocketAddrV4
address = ND.join_host_port("localhost", Int(tls_addr.port))
client = HT.Client(
transport = HT.Transport(tls_config = Reseau.TLS.Config(verify_peer = false, verify_hostname = true)),
prefer_http2 = false,
)
try
resp = HT.get("https://$(address)/"; client = client, retry = false, status_exception = false)
@test resp.status == 200
addr = take!(captured)
@test addr isa NC.SocketAddr
@test addr.ip == (0x7f, 0x00, 0x00, 0x01)
@test addr.port > 0
finally
HT.@try_ignore close(client)
_run_with_timeout(() -> HT.forceclose(server); label = "tls server forceclose")
_run_with_timeout(() -> wait(server); label = "tls server task completion")
end
end

@testset "HTTP servecontent direct conditionals and single ranges" begin
payload = collect(codeunits("abcdef"))
modtime = Dates.DateTime(2024, 1, 2, 3, 4, 5)
Expand Down
Loading