Skip to content
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
17 changes: 15 additions & 2 deletions lib/fluent/plugin_helper/http_server/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 9 additions & 2 deletions test/helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions test/plugin_helper/test_http_server_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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|
Expand Down