diff --git a/CHANGELOG.md b/CHANGELOG.md index f1406e239..82c9ea9ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/src/api/server.md b/docs/src/api/server.md index 73ec5a43a..98aecc89b 100644 --- a/docs/src/api/server.md +++ b/docs/src/api/server.md @@ -29,6 +29,7 @@ HTTP.startwrite HTTP.setstatus HTTP.addtrailer HTTP.closeread +HTTP.peeraddr ``` ## Routing and Middleware diff --git a/docs/src/guides/migration-1x.md b/docs/src/guides/migration-1x.md index 95aa69bc9..734f66f88 100644 --- a/docs/src/guides/migration-1x.md +++ b/docs/src/guides/migration-1x.md @@ -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!`: @@ -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. diff --git a/src/HTTP.jl b/src/HTTP.jl index 80d64c17d..f43b346e9 100644 --- a/src/HTTP.jl +++ b/src/HTTP.jl @@ -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!, diff --git a/src/http_server_streams.jl b/src/http_server_streams.jl index 50d2fe1d4..34ee226cd 100644 --- a/src/http_server_streams.jl +++ b/src/http_server_streams.jl @@ -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 diff --git a/test/http2_server_tests.jl b/test/http2_server_tests.jl index 060eebfb5..e90c682c4 100644 --- a/test/http2_server_tests.jl +++ b/test/http2_server_tests.jl @@ -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 diff --git a/test/http_server_http1_tests.jl b/test/http_server_http1_tests.jl index a6091339d..e0eafbb7e 100644 --- a/test/http_server_http1_tests.jl +++ b/test/http_server_http1_tests.jl @@ -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)