From be6af88823ae65c4219752880347176b5c241a6b Mon Sep 17 00:00:00 2001 From: Jesse Awan Date: Tue, 27 Jan 2026 17:46:57 +0100 Subject: [PATCH 1/5] fix: Add IPv6 support for bind parameter Fixes two IPv6-related bugs when using bind='::': 1. Log message construction: Previously created invalid URL 'http://:::24231' instead of 'http://[::]:24231' 2. Worker aggregation: When bind is '::', the code tried to connect to '::' directly instead of converting it to '::1' (IPv6 localhost), causing connection failures Changes: - Add bracket notation for IPv6 addresses in log messages - Convert '::' to '::1' for inter-worker communication - Maintain backward compatibility with IPv4 addresses Fixes #229 Signed-off-by: Jesse Awan --- lib/fluent/plugin/in_prometheus.rb | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/lib/fluent/plugin/in_prometheus.rb b/lib/fluent/plugin/in_prometheus.rb index ab201de..06adea1 100644 --- a/lib/fluent/plugin/in_prometheus.rb +++ b/lib/fluent/plugin/in_prometheus.rb @@ -68,7 +68,9 @@ def start super scheme = @secure ? 'https' : 'http' - log.debug "listening prometheus http server on #{scheme}:://#{@bind}:#{@port}/#{@metrics_path} for worker#{fluentd_worker_id}" + # Format bind address properly for URLs (add brackets for IPv6) + bind_display = @bind.include?(':') ? "[#{@bind}]" : @bind + log.debug "listening prometheus http server on #{scheme}://#{bind_display}:#{@port}/#{@metrics_path} for worker#{fluentd_worker_id}" proto = @secure ? :tls : :tcp @@ -207,7 +209,16 @@ def all_workers_metrics end def send_request_to_each_worker - bind = (@bind == '0.0.0.0') ? '127.0.0.1' : @bind + # Convert bind address to localhost for inter-worker communication + # 0.0.0.0 and :: are not connectable, use localhost instead + bind = case @bind + when '0.0.0.0' + '127.0.0.1' + when '::' + '::1' # IPv6 localhost + else + @bind + end [*(@base_port...(@base_port + @num_workers))].each do |worker_port| do_request(host: bind, port: worker_port, secure: @secure) do |http| yield(http.get(@metrics_path)) From 456e02483e8a8fe7ab2a8f44a5e998e3aaf89163 Mon Sep 17 00:00:00 2001 From: Jesse Awan Date: Tue, 10 Feb 2026 15:18:41 +0100 Subject: [PATCH 2/5] test: add IPv6 integration tests with shared examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ipv6_enabled? helper to detect IPv6 support - Add integration tests for IPv6 loopback (::1), any (::), and pre-bracketed addresses - Refactor tests using shared_examples pattern to reduce duplication (60 → 35 lines) - Handle pre-bracketed addresses by stripping brackets before socket binding - Pass bracketed IPv6 addresses to http_server helper for proper URI construction - All IPv6 tests pass on systems with IPv6 support, skip gracefully otherwise - Add /vendor/ to .gitignore for local bundle installations Signed-off-by: Jesse Awan --- .gitignore | 1 + lib/fluent/plugin/in_prometheus.rb | 10 ++- spec/fluent/plugin/in_prometheus_spec.rb | 108 +++++++++++++++++++++++ spec/spec_helper.rb | 18 ++++ 4 files changed, 136 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 8ae0b46..d96124b 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ *.o *.a mkmf.log +/vendor/ diff --git a/lib/fluent/plugin/in_prometheus.rb b/lib/fluent/plugin/in_prometheus.rb index 06adea1..a699d66 100644 --- a/lib/fluent/plugin/in_prometheus.rb +++ b/lib/fluent/plugin/in_prometheus.rb @@ -67,6 +67,10 @@ def multi_workers_ready? def start super + # Normalize bind address: strip brackets if present (for consistency) + # Brackets are only for URI formatting, not for socket binding + @bind = @bind[1..-2] if @bind.start_with?('[') && @bind.end_with?(']') + scheme = @secure ? 'https' : 'http' # Format bind address properly for URLs (add brackets for IPv6) bind_display = @bind.include?(':') ? "[#{@bind}]" : @bind @@ -112,7 +116,11 @@ def start ssl_config end - http_server_create_http_server(:in_prometheus_server, addr: @bind, port: @port, logger: log, proto: proto, tls_opts: tls_opt) do |server| + # Pass address with brackets for IPv6 to http_server helper + # The http_server helper builds a URI internally and needs proper formatting + addr_for_server = @bind.include?(':') ? "[#{@bind}]" : @bind + + http_server_create_http_server(:in_prometheus_server, addr: addr_for_server, port: @port, logger: log, proto: proto, tls_opts: tls_opt) do |server| server.get(@metrics_path) { |_req| all_metrics } server.get(@aggregated_metrics_path) { |_req| all_workers_metrics } end diff --git a/spec/fluent/plugin/in_prometheus_spec.rb b/spec/fluent/plugin/in_prometheus_spec.rb index 08caae0..5067453 100644 --- a/spec/fluent/plugin/in_prometheus_spec.rb +++ b/spec/fluent/plugin/in_prometheus_spec.rb @@ -278,4 +278,112 @@ end end end + + describe 'IPv6 support' do + context 'IPv6 address formatting' do + it 'formats ::1 with brackets for display' do + ipv6_addr = '::1' + # Test the bracketing logic from the code + formatted = ipv6_addr.include?(':') ? "[#{ipv6_addr}]" : ipv6_addr + expect(formatted).to eq('[::1]') + end + + it 'formats :: with brackets for display' do + ipv6_addr = '::' + formatted = ipv6_addr.include?(':') ? "[#{ipv6_addr}]" : ipv6_addr + expect(formatted).to eq('[::]') + end + + it 'does not add brackets to IPv4 addresses' do + ipv4_addr = '127.0.0.1' + formatted = ipv4_addr.include?(':') ? "[#{ipv4_addr}]" : ipv4_addr + expect(formatted).to eq('127.0.0.1') + end + end + + context 'bind address conversion for worker communication' do + it 'converts :: to ::1 for localhost communication' do + bind = '::' + converted = case bind + when '0.0.0.0' then '127.0.0.1' + when '::' then '::1' + else bind + end + expect(converted).to eq('::1') + end + + it 'converts 0.0.0.0 to 127.0.0.1 for localhost communication' do + bind = '0.0.0.0' + converted = case bind + when '0.0.0.0' then '127.0.0.1' + when '::' then '::1' + else bind + end + expect(converted).to eq('127.0.0.1') + end + + it 'does not convert specific addresses' do + bind = '::1' + converted = case bind + when '0.0.0.0' then '127.0.0.1' + when '::' then '::1' + else bind + end + expect(converted).to eq('::1') + end + end + + context 'URI construction with IPv6' do + it 'creates valid URI with bracketed IPv6 address' do + ipv6_addr = '::1' + formatted = ipv6_addr.include?(':') ? "[#{ipv6_addr}]" : ipv6_addr + uri = URI("http://#{formatted}:24231/metrics") + expect(uri.to_s).to eq('http://[::1]:24231/metrics') + end + + it 'creates valid URI with bracketed IPv6 wildcard' do + ipv6_addr = '::' + formatted = ipv6_addr.include?(':') ? "[#{ipv6_addr}]" : ipv6_addr + uri = URI("http://#{formatted}:24231/metrics") + expect(uri.to_s).to eq('http://[::]:24231/metrics') + end + end + end + + describe '#run with IPv6' do + shared_examples 'IPv6 server binding' do |bind_addr, connect_addr, description| + let(:config) do + # Quote the bind address if it contains brackets + bind_value = bind_addr.include?('[') ? "\"#{bind_addr}\"" : bind_addr + %[ + @type prometheus + bind #{bind_value} + ] + end + + it description do + skip 'IPv6 not available on this system' unless ipv6_enabled? + + driver.run(timeout: 3) do + Net::HTTP.start(connect_addr, port) do |http| + req = Net::HTTP::Get.new('/metrics') + res = http.request(req) + expect(res.code).to eq('200') + end + end + end + end + + context 'IPv6 loopback address ::1' do + include_examples 'IPv6 server binding', '::1', '::1', 'binds and serves on IPv6 loopback address' + end + + context 'IPv6 any address ::' do + include_examples 'IPv6 server binding', '::', '::1', 'binds on :: and connects via ::1' + end + + context 'pre-bracketed IPv6 address [::1]' do + include_examples 'IPv6 server binding', '[::1]', '::1', 'handles pre-bracketed address correctly' + end + end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 61b4dce..d2a12f7 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -8,3 +8,21 @@ Fluent::Test.setup include Fluent::Test::Helpers + +def ipv6_enabled? + require 'socket' + + begin + # 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 Errno::EADDRNOTAVAIL, Errno::EAFNOSUPPORT, SocketError + false + end +end From 0eeb729ce12cba4cf63a8ac56ccdb08ca45363d6 Mon Sep 17 00:00:00 2001 From: Jesse Awan Date: Wed, 18 Feb 2026 01:38:01 +0100 Subject: [PATCH 3/5] fix: bind raw IPv6, drop unit tests Signed-off-by: Jesse Awan --- lib/fluent/plugin/in_prometheus.rb | 8 +-- spec/fluent/plugin/in_prometheus_spec.rb | 71 ------------------------ 2 files changed, 3 insertions(+), 76 deletions(-) diff --git a/lib/fluent/plugin/in_prometheus.rb b/lib/fluent/plugin/in_prometheus.rb index a699d66..54686f2 100644 --- a/lib/fluent/plugin/in_prometheus.rb +++ b/lib/fluent/plugin/in_prometheus.rb @@ -116,11 +116,9 @@ def start ssl_config end - # Pass address with brackets for IPv6 to http_server helper - # The http_server helper builds a URI internally and needs proper formatting - addr_for_server = @bind.include?(':') ? "[#{@bind}]" : @bind - - http_server_create_http_server(:in_prometheus_server, addr: addr_for_server, port: @port, logger: log, proto: proto, tls_opts: tls_opt) do |server| + # Use raw bind address for socket binding (no brackets) + # Brackets are only for URL/URI formatting, not for bind() + http_server_create_http_server(:in_prometheus_server, addr: @bind, port: @port, logger: log, proto: proto, tls_opts: tls_opt) do |server| server.get(@metrics_path) { |_req| all_metrics } server.get(@aggregated_metrics_path) { |_req| all_workers_metrics } end diff --git a/spec/fluent/plugin/in_prometheus_spec.rb b/spec/fluent/plugin/in_prometheus_spec.rb index 5067453..a1883fd 100644 --- a/spec/fluent/plugin/in_prometheus_spec.rb +++ b/spec/fluent/plugin/in_prometheus_spec.rb @@ -279,77 +279,6 @@ end end - describe 'IPv6 support' do - context 'IPv6 address formatting' do - it 'formats ::1 with brackets for display' do - ipv6_addr = '::1' - # Test the bracketing logic from the code - formatted = ipv6_addr.include?(':') ? "[#{ipv6_addr}]" : ipv6_addr - expect(formatted).to eq('[::1]') - end - - it 'formats :: with brackets for display' do - ipv6_addr = '::' - formatted = ipv6_addr.include?(':') ? "[#{ipv6_addr}]" : ipv6_addr - expect(formatted).to eq('[::]') - end - - it 'does not add brackets to IPv4 addresses' do - ipv4_addr = '127.0.0.1' - formatted = ipv4_addr.include?(':') ? "[#{ipv4_addr}]" : ipv4_addr - expect(formatted).to eq('127.0.0.1') - end - end - - context 'bind address conversion for worker communication' do - it 'converts :: to ::1 for localhost communication' do - bind = '::' - converted = case bind - when '0.0.0.0' then '127.0.0.1' - when '::' then '::1' - else bind - end - expect(converted).to eq('::1') - end - - it 'converts 0.0.0.0 to 127.0.0.1 for localhost communication' do - bind = '0.0.0.0' - converted = case bind - when '0.0.0.0' then '127.0.0.1' - when '::' then '::1' - else bind - end - expect(converted).to eq('127.0.0.1') - end - - it 'does not convert specific addresses' do - bind = '::1' - converted = case bind - when '0.0.0.0' then '127.0.0.1' - when '::' then '::1' - else bind - end - expect(converted).to eq('::1') - end - end - - context 'URI construction with IPv6' do - it 'creates valid URI with bracketed IPv6 address' do - ipv6_addr = '::1' - formatted = ipv6_addr.include?(':') ? "[#{ipv6_addr}]" : ipv6_addr - uri = URI("http://#{formatted}:24231/metrics") - expect(uri.to_s).to eq('http://[::1]:24231/metrics') - end - - it 'creates valid URI with bracketed IPv6 wildcard' do - ipv6_addr = '::' - formatted = ipv6_addr.include?(':') ? "[#{ipv6_addr}]" : ipv6_addr - uri = URI("http://#{formatted}:24231/metrics") - expect(uri.to_s).to eq('http://[::]:24231/metrics') - end - end - end - describe '#run with IPv6' do shared_examples 'IPv6 server binding' do |bind_addr, connect_addr, description| let(:config) do From 3abe21a62b8fcb973e14a28fa7d3d5da7d8a50c6 Mon Sep 17 00:00:00 2001 From: Jesse Awan Date: Thu, 19 Feb 2026 12:39:03 +0100 Subject: [PATCH 4/5] fix: Support IPv6 bind addresses in prometheus input plugin - Route IPv6 addresses to webrick (http_server helper can't construct IPv6 URIs) - Add IPv6 + TLS guard (raises clear ConfigError for unsupported combination) - Fix start_webrick to handle non-SSL cases (prevents NoMethodError) - Add IPv6 URI bracketing in async_wrapper (formats as http://[::1]:port) - Add test for IPv6 + TLS error case - Fix FULL_CONFIG -> LOCAL_CONFIG in multi-worker test (FULL_CONFIG was undefined) Fixes Ruby 3.4 test failures with IPv6 addresses. All tests pass: 20 examples, 0 failures Signed-off-by: Jesse Awan --- lib/fluent/plugin/in_prometheus.rb | 50 ++++++++++++------- .../plugin/in_prometheus/async_wrapper.rb | 11 +++- spec/fluent/plugin/in_prometheus_spec.rb | 18 ++++++- 3 files changed, 57 insertions(+), 22 deletions(-) diff --git a/lib/fluent/plugin/in_prometheus.rb b/lib/fluent/plugin/in_prometheus.rb index 54686f2..862e298 100644 --- a/lib/fluent/plugin/in_prometheus.rb +++ b/lib/fluent/plugin/in_prometheus.rb @@ -78,7 +78,14 @@ def start proto = @secure ? :tls : :tcp - if @ssl && @ssl['enable'] && @ssl['extra_conf'] + # IPv6 + TLS combination is not currently supported + if @bind.include?(':') && @secure + raise Fluent::ConfigError, 'IPv6 with is not currently supported. Use bind 0.0.0.0 with TLS, or bind ::1 without TLS.' + end + + # Use webrick for IPv6 or SSL extra_conf + # The http_server helper has issues with IPv6 URI construction + if (@ssl && @ssl['enable'] && @ssl['extra_conf']) || @bind.include?(':') start_webrick return end @@ -135,6 +142,7 @@ def shutdown private # For compatiblity because http helper can't support extra_conf option + # Also used for IPv6 addresses since http helper has IPv6 URI issues def start_webrick require 'webrick/https' require 'webrick' @@ -146,28 +154,32 @@ def start_webrick Logger: WEBrick::Log.new(STDERR, WEBrick::Log::FATAL), AccessLog: [], } - if (@ssl['certificate_path'] && @ssl['private_key_path'].nil?) || (@ssl['certificate_path'].nil? && @ssl['private_key_path']) - raise RuntimeError.new("certificate_path and private_key_path most both be defined") - end + + # Configure SSL if enabled + if @ssl && @ssl['enable'] + if (@ssl['certificate_path'] && @ssl['private_key_path'].nil?) || (@ssl['certificate_path'].nil? && @ssl['private_key_path']) + raise RuntimeError.new("certificate_path and private_key_path most both be defined") + end - ssl_config = { - SSLEnable: true, - SSLCertName: [['CN', 'nobody'], ['DC', 'example']] - } + ssl_config = { + SSLEnable: true, + SSLCertName: [['CN', 'nobody'], ['DC', 'example']] + } - if @ssl['certificate_path'] - cert = OpenSSL::X509::Certificate.new(File.read(@ssl['certificate_path'])) - ssl_config[:SSLCertificate] = cert - end + if @ssl['certificate_path'] + cert = OpenSSL::X509::Certificate.new(File.read(@ssl['certificate_path'])) + ssl_config[:SSLCertificate] = cert + end - if @ssl['private_key_path'] - key = OpenSSL::PKey.read(@ssl['private_key_path']) - ssl_config[:SSLPrivateKey] = key - end + if @ssl['private_key_path'] + key = OpenSSL::PKey.read(@ssl['private_key_path']) + ssl_config[:SSLPrivateKey] = key + end - ssl_config[:SSLCACertificateFile] = @ssl['ca_path'] if @ssl['ca_path'] - ssl_config = ssl_config.merge(@ssl['extra_conf']) if @ssl['extra_conf'] - config = ssl_config.merge(config) + ssl_config[:SSLCACertificateFile] = @ssl['ca_path'] if @ssl['ca_path'] + ssl_config = ssl_config.merge(@ssl['extra_conf']) if @ssl['extra_conf'] + config = ssl_config.merge(config) + end @log.on_debug do @log.debug("WEBrick conf: #{config}") diff --git a/lib/fluent/plugin/in_prometheus/async_wrapper.rb b/lib/fluent/plugin/in_prometheus/async_wrapper.rb index 00860c6..9434b5c 100644 --- a/lib/fluent/plugin/in_prometheus/async_wrapper.rb +++ b/lib/fluent/plugin/in_prometheus/async_wrapper.rb @@ -4,13 +4,20 @@ module Fluent::Plugin class PrometheusInput module AsyncWrapper def do_request(host:, port:, secure:) + # Format host for URI - bracket IPv6 addresses if not already bracketed + uri_host = if host.include?(':') && !host.start_with?('[') + "[#{host}]" + else + host + end + endpoint = if secure context = OpenSSL::SSL::SSLContext.new context.verify_mode = OpenSSL::SSL::VERIFY_NONE - Async::HTTP::Endpoint.parse("https://#{host}:#{port}", ssl_context: context) + Async::HTTP::Endpoint.parse("https://#{uri_host}:#{port}", ssl_context: context) else - Async::HTTP::Endpoint.parse("http://#{host}:#{port}") + Async::HTTP::Endpoint.parse("http://#{uri_host}:#{port}") end Async::HTTP::Client.open(endpoint) do |client| diff --git a/spec/fluent/plugin/in_prometheus_spec.rb b/spec/fluent/plugin/in_prometheus_spec.rb index a1883fd..9592d22 100644 --- a/spec/fluent/plugin/in_prometheus_spec.rb +++ b/spec/fluent/plugin/in_prometheus_spec.rb @@ -89,6 +89,22 @@ end end + context 'IPv6 with TLS' do + let(:config) do + %[ + @type prometheus + bind ::1 + + insecure true + + ] + end + + it 'raises ConfigError for unsupported combination' do + expect { driver.run(timeout: 1) }.to raise_error(Fluent::ConfigError, /IPv6 with is not currently supported/) + end + end + context 'old parameters are given' do context 'when extra_conf is used' do let(:config) do @@ -258,7 +274,7 @@ describe '#run_multi_workers' do context '/metrics' do Fluent::SystemConfig.overwrite_system_config('workers' => 4) do - let(:config) { FULL_CONFIG + %[ + let(:config) { LOCAL_CONFIG + %[ port #{port - 2} ] } From e1bee9c465d191480d3420a802949a424d343291 Mon Sep 17 00:00:00 2001 From: Jesse Awan Date: Fri, 20 Feb 2026 13:56:14 +0100 Subject: [PATCH 5/5] Revert unrelated FULL_CONFIG change This line should not have been touched as part of the IPv6 fix. Keeping the original FULL_CONFIG as requested. Signed-off-by: Jesse Awan --- spec/fluent/plugin/in_prometheus_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/fluent/plugin/in_prometheus_spec.rb b/spec/fluent/plugin/in_prometheus_spec.rb index 9592d22..8bff55f 100644 --- a/spec/fluent/plugin/in_prometheus_spec.rb +++ b/spec/fluent/plugin/in_prometheus_spec.rb @@ -274,7 +274,7 @@ describe '#run_multi_workers' do context '/metrics' do Fluent::SystemConfig.overwrite_system_config('workers' => 4) do - let(:config) { LOCAL_CONFIG + %[ + let(:config) { FULL_CONFIG + %[ port #{port - 2} ] }