diff --git a/CHANGELOG.md b/CHANGELOG.md index f6d8a2b02e..659da5c06f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +# v1.20 + +## Release v1.20.0 - TBD + +### Bug fix + +* http_server helper: Fix IPv6 bind address support in URI construction + * Fixed `URI::InvalidURIError` when binding to IPv6 addresses (e.g., `::`, `::1`) + * IPv6 addresses are now properly bracketed in URIs per RFC 3986 (e.g., `http://[::]:24231`) + * Handles pre-bracketed addresses correctly to avoid double-bracketing + * Affects all plugins using http_server helper with IPv6 bind addresses + # v1.19 ## Release v1.19.1 - 2025/11/06 diff --git a/lib/fluent/plugin_helper/http_server/server.rb b/lib/fluent/plugin_helper/http_server/server.rb index 9c099292d8..1fdcab39a0 100644 --- a/lib/fluent/plugin_helper/http_server/server.rb +++ b/lib/fluent/plugin_helper/http_server/server.rb @@ -31,13 +31,26 @@ class Server # @param default_app [Object] This method must have #call. # @param tls_context [OpenSSL::SSL::SSLContext] def initialize(addr:, port:, logger:, default_app: nil, tls_context: nil) - @addr = addr + # Normalize the address: strip brackets if present + # Brackets are only for URI formatting, not for socket binding + @addr = if addr.start_with?('[') && addr.end_with?(']') + addr[1..-2] # Remove surrounding brackets + else + addr + end @port = port @logger = logger # TODO: support http2 scheme = tls_context ? 'https' : 'http' - @uri = URI("#{scheme}://#{@addr}:#{@port}").to_s + # Handle IPv6 addresses properly in URI construction per RFC 3986 + # Add brackets to IPv6 addresses for URI compliance (ip-literal per RFC 3986) + ip_literal = if @addr.include?(":") + "[#{@addr}]" # IPv6 address - add brackets + else + @addr # IPv4 or hostname - use directly + end + @uri = URI("#{scheme}://#{ip_literal}:#{@port}").to_s @router = Router.new(default_app) @server_task = nil Console.logger = Fluent::Log::ConsoleAdapter.wrap(@logger) diff --git a/test/helper.rb b/test/helper.rb index 4e9f028a9c..97b0f64917 100644 --- a/test/helper.rb +++ b/test/helper.rb @@ -161,9 +161,16 @@ def ipv6_enabled? require 'socket' begin - TCPServer.open("::1", 0) + # Try to actually bind to an IPv6 address to verify it works + sock = Socket.new(Socket::AF_INET6, Socket::SOCK_STREAM, 0) + sock.bind(Socket.sockaddr_in(0, '::1')) + sock.close + + # Also test that we can resolve IPv6 addresses + # This is needed because some systems can bind but can't connect + Socket.getaddrinfo('::1', nil, Socket::AF_INET6) true - rescue + rescue Errno::EADDRNOTAVAIL, Errno::EAFNOSUPPORT, SocketError false end end diff --git a/test/plugin_helper/test_http_server_helper.rb b/test/plugin_helper/test_http_server_helper.rb index bc9853c013..d206367c95 100644 --- a/test/plugin_helper/test_http_server_helper.rb +++ b/test/plugin_helper/test_http_server_helper.rb @@ -363,6 +363,28 @@ def start_https_request(addr, port, verify: true, cert_path: nil, selfsigned: tr end end end + + data( + 'IPv6 loopback ::1' => [:ipv6_loopback_test, '::1', '::1'], + 'IPv6 any address ::' => [:ipv6_any_test, '::', '::1'], + 'pre-bracketed IPv6 [::1]' => [:ipv6_bracketed_test, '[::1]', '::1'] + ) + test 'http_server can bind and serve on IPv6 address' do |data| + return unless ipv6_enabled? + + test_name, bind_addr, connect_addr = data + + on_driver do |driver| + driver.http_server_create_http_server(test_name, addr: bind_addr, port: @port, logger: NULL_LOGGER) do |s| + s.get('/test') { [200, { 'Content-Type' => 'text/plain' }, 'OK'] } + end + + http = Net::HTTP.new(connect_addr, @port) + response = http.get('/test') + assert_equal('200', response.code) + assert_equal('OK', response.body) + end + end test 'must be called #start and #stop' do on_driver do |driver|