From 7c1d716d844bede83b038c014729fd82a849db03 Mon Sep 17 00:00:00 2001 From: Jakob Bergmeier Date: Tue, 13 Oct 2026 15:30:07 +0200 Subject: [PATCH 01/12] fix: check v4v6 property value instead of presence Signed-off-by: Jakob Bergmeier --- jobs/haproxy/templates/haproxy.config.erb | 6 ++--- .../haproxy_config/frontend_http_spec.rb | 22 +++++++++++++++++++ .../haproxy_config/frontend_https_spec.rb | 22 +++++++++++++++++++ .../haproxy_config/frontend_wss_spec.rb | 22 +++++++++++++++++++ 4 files changed, 68 insertions(+), 4 deletions(-) diff --git a/jobs/haproxy/templates/haproxy.config.erb b/jobs/haproxy/templates/haproxy.config.erb index 03d334e0c..dac33055f 100644 --- a/jobs/haproxy/templates/haproxy.config.erb +++ b/jobs/haproxy/templates/haproxy.config.erb @@ -175,10 +175,8 @@ end # }}} # IPv4 and IPv6 binding (v4v6) Option {{{ v4v6 = "" -if_p("ha_proxy.v4v6") do - if p("ha_proxy.binding_ip") == "::" - v4v6 = "v4v6" - end +if p("ha_proxy.v4v6") && p("ha_proxy.binding_ip") == "::" + v4v6 = "v4v6" end # }}} diff --git a/spec/haproxy/templates/haproxy_config/frontend_http_spec.rb b/spec/haproxy/templates/haproxy_config/frontend_http_spec.rb index 8f64048be..09e93f938 100644 --- a/spec/haproxy/templates/haproxy_config/frontend_http_spec.rb +++ b/spec/haproxy/templates/haproxy_config/frontend_http_spec.rb @@ -33,6 +33,28 @@ end end + context 'when ha_proxy.v4v6 is false and binding_ip is ::' do + let(:properties) do + { 'v4v6' => false, 'binding_ip' => '::' } + end + + it 'does not enable v4v6' do + expect(frontend_http).to include('bind :::80') + expect(frontend_http).not_to include('v4v6') + end + end + + context 'when ha_proxy.v4v6 is not set and binding_ip is ::' do + let(:properties) do + { 'binding_ip' => '::' } + end + + it 'does not enable v4v6' do + expect(frontend_http).to include('bind :::80') + expect(frontend_http).not_to include('v4v6') + end + end + context 'when ha_proxy.accept_proxy is true' do let(:properties) do { 'accept_proxy' => true } diff --git a/spec/haproxy/templates/haproxy_config/frontend_https_spec.rb b/spec/haproxy/templates/haproxy_config/frontend_https_spec.rb index 2c447bb5a..d8517a9b8 100644 --- a/spec/haproxy/templates/haproxy_config/frontend_https_spec.rb +++ b/spec/haproxy/templates/haproxy_config/frontend_https_spec.rb @@ -39,6 +39,28 @@ end end + context 'when ha_proxy.v4v6 is false and binding_ip is ::' do + let(:properties) do + default_properties.merge({ 'v4v6' => false, 'binding_ip' => '::' }) + end + + it 'does not enable v4v6' do + expect(frontend_https).to include('bind :::443 ssl crt /var/vcap/jobs/haproxy/config/ssl') + expect(frontend_https).not_to include('v4v6') + end + end + + context 'when ha_proxy.v4v6 is not set and binding_ip is ::' do + let(:properties) do + default_properties.merge({ 'binding_ip' => '::' }) + end + + it 'does not enable v4v6' do + expect(frontend_https).to include('bind :::443 ssl crt /var/vcap/jobs/haproxy/config/ssl') + expect(frontend_https).not_to include('v4v6') + end + end + context 'when ha_proxy.accept_proxy is true' do let(:properties) do default_properties.merge({ 'accept_proxy' => true }) diff --git a/spec/haproxy/templates/haproxy_config/frontend_wss_spec.rb b/spec/haproxy/templates/haproxy_config/frontend_wss_spec.rb index c5a7eaa4d..fdf9acf30 100644 --- a/spec/haproxy/templates/haproxy_config/frontend_wss_spec.rb +++ b/spec/haproxy/templates/haproxy_config/frontend_wss_spec.rb @@ -41,6 +41,28 @@ end end + context 'when ha_proxy.v4v6 is false and binding_ip is ::' do + let(:properties) do + default_properties.merge({ 'v4v6' => false, 'binding_ip' => '::' }) + end + + it 'does not enable v4v6' do + expect(frontend_wss).to include('bind :::4443 ssl crt /var/vcap/jobs/haproxy/config/ssl') + expect(frontend_wss).not_to include('v4v6') + end + end + + context 'when ha_proxy.v4v6 is not set and binding_ip is ::' do + let(:properties) do + default_properties.merge({ 'binding_ip' => '::' }) + end + + it 'does not enable v4v6' do + expect(frontend_wss).to include('bind :::4443 ssl crt /var/vcap/jobs/haproxy/config/ssl') + expect(frontend_wss).not_to include('v4v6') + end + end + context 'when ha_proxy.accept_proxy is true' do let(:properties) do default_properties.merge({ 'accept_proxy' => true }) From bbbd4061437d249debdd0e079b768a1890e38d39 Mon Sep 17 00:00:00 2001 From: Jakob Bergmeier Date: Tue, 13 Oct 2026 16:52:51 +0200 Subject: [PATCH 02/12] feat: support multiple bind addresses Signed-off-by: Jakob Bergmeier --- jobs/haproxy/spec | 11 +++ jobs/haproxy/templates/haproxy.config.erb | 66 ++++++++++++++--- .../haproxy_config/frontend_http_spec.rb | 13 +++- .../haproxy_config/frontend_https_spec.rb | 73 ++++++++++++++++--- .../haproxy_config/frontend_tcp_spec.rb | 12 +-- .../haproxy_config/frontend_wss_spec.rb | 16 ++-- 6 files changed, 157 insertions(+), 34 deletions(-) diff --git a/jobs/haproxy/spec b/jobs/haproxy/spec index 7da59dee4..4b4e0f4f2 100644 --- a/jobs/haproxy/spec +++ b/jobs/haproxy/spec @@ -587,6 +587,17 @@ properties: ha_proxy.v4v6: description: "Boolean, disabled by default. Enables binding to all IPv4 and IPv6 interfaces. Only applies if `ha_proxy.binding_ip` is set to `::`." default: false + ha_proxy.binding_ips: + description: | + Optional list of IP addresses to bind all generated listeners to. + When set, each listener generates one bind directive per address, + replacing the single address from ha_proxy.binding_ip. + IPv6 addresses automatically receive the v6only option. + Cannot be combined with ha_proxy.v4v6: true. + Other listeners continue using ha_proxy.binding_ip when this is absent. + example: + - 0.0.0.0 + - "::" ha_proxy.cidr_blacklist: description: "List of CIDRs to block for http(s). Format is string array of CIDRs or single string of base64 encoded gzip." diff --git a/jobs/haproxy/templates/haproxy.config.erb b/jobs/haproxy/templates/haproxy.config.erb index dac33055f..ed78b06e4 100644 --- a/jobs/haproxy/templates/haproxy.config.erb +++ b/jobs/haproxy/templates/haproxy.config.erb @@ -17,6 +17,18 @@ end end + def render_bind(binding_addresses, binding_ip, port, extra_options, v4v6_option) + if binding_addresses + binding_addresses.map do |ba| + opts = [ba[:opts], *Array(extra_options)].reject(&:empty?).join(' ') + " bind #{ba[:addr]}:#{port} #{opts}".rstrip + end.join("\n") + else + options = [*Array(extra_options), v4v6_option].reject(&:empty?).join(' ') + " bind #{binding_ip}:#{port} #{options}".rstrip + end + end + if properties.ha_proxy.config_mode == "auto" if properties.ha_proxy.raw_config -%> <%= p("ha_proxy.raw_config") %> @@ -180,6 +192,40 @@ if p("ha_proxy.v4v6") && p("ha_proxy.binding_ip") == "::" end # }}} +# Multiple Bind Addresses (binding_ips) Option {{{ +require 'ipaddr' +binding_addresses = nil +if_p("ha_proxy.binding_ips") do |ips| + unless ips.is_a?(Array) && !ips.empty? + abort("ha_proxy.binding_ips must be a non-empty array of IP addresses") + end + if p("ha_proxy.v4v6") + abort("Conflicting configuration: ha_proxy.binding_ips cannot be combined with ha_proxy.v4v6: true") + end + seen = {} + binding_addresses = ips.map do |ip| + unless ip.is_a?(String) && !ip.empty? + abort("ha_proxy.binding_ips: each entry must be a non-empty IP address string, got: #{ip.inspect}") + end + begin + parsed = IPAddr.new(ip) + rescue IPAddr::InvalidAddressError + abort("ha_proxy.binding_ips: invalid IP address: #{ip.inspect}") + end + normalized = parsed.to_s + if seen[normalized] + abort("ha_proxy.binding_ips: duplicate address: #{ip}") + end + seen[normalized] = true + if parsed.ipv6? + { addr: "[#{normalized}]", opts: "v6only" } + else + { addr: normalized, opts: "" } + end + end +end +# }}} + # ALPN Option {{{ default_alpn_config = "" if p("ha_proxy.enable_http2") @@ -396,7 +442,7 @@ listen stats <% if p("ha_proxy.enable_health_check_http") %> listen health_check_http_url - bind <%= p("ha_proxy.binding_ip") %>:<%= p("ha_proxy.health_check_port") %> <%= v4v6 %> +<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), p("ha_proxy.health_check_port"), "", v4v6) %> mode http option httpclose monitor-uri /health @@ -409,7 +455,7 @@ listen health_check_http_url <%- if enable_additional_health_check_proxy -%> listen health_check_http_url_proxy_protocol - bind <%= p("ha_proxy.binding_ip") %>:<%= p("ha_proxy.health_check_port") + 1 %> accept-proxy <%= v4v6 %> +<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), p("ha_proxy.health_check_port") + 1, "accept-proxy", v4v6) %> mode http option httpclose monitor-uri /health @@ -442,7 +488,7 @@ backend st_tcp_conn_rate # HTTP Frontend {{{ frontend http-in mode http - bind <%= p("ha_proxy.binding_ip") %>:80 <%= accept_proxy %> <%= v4v6 %> +<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), 80, accept_proxy, v4v6) %> <%- if properties.ha_proxy.frontend_config -%> <%= format_indented_multiline_config(p("ha_proxy.frontend_config")) %> <%- end -%> @@ -556,7 +602,7 @@ frontend http-in # HTTPS Frontend {{{ frontend https-in mode http - bind <%= p("ha_proxy.binding_ip") %>:443 <%= accept_proxy %> <%= tls_bind_options %> <%= v4v6 %> <%= default_alpn_config %> +<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), 443, [accept_proxy, tls_bind_options, default_alpn_config], v4v6) %> # Set this acl when the request is a route service request, used by ha_proxy.forward_true_client_ip_header and ha_proxy.forwarded_client_cert acl route_service_request hdr(X-Cf-Proxy-Signature) -m found <%- if disable_domain_fronting -%> @@ -759,7 +805,7 @@ frontend https-in # HTTPS Websockets Frontend {{{ frontend wss-in mode http - bind <%= p("ha_proxy.binding_ip") %>:4443 <%= accept_proxy %> <%= tls_bind_options %> <%= v4v6 %> +<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), 4443, [accept_proxy, tls_bind_options], v4v6) %> <%- if disable_domain_fronting -%> # Check whether the client is attempting domain fronting. # Ensure a host header exists to check against @@ -1028,7 +1074,7 @@ backend http-routed-backend-<%= prefix_hash %> frontend cf_tcp_routing mode tcp - bind <%= p("ha_proxy.binding_ip") %>:<%= p("ha_proxy.tcp_routing.port_range") %> <%= v4v6 %> +<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), p("ha_proxy.tcp_routing.port_range"), "", v4v6) %> default_backend cf_tcp_routers backend cf_tcp_routers @@ -1059,9 +1105,9 @@ end -%> frontend tcp-frontend_<%= tcp_proxy["name"]%> mode tcp <%- if tcp_proxy["ssl"] -%> - bind <%= p("ha_proxy.binding_ip") %>:<%= tcp_proxy["port"] %> <%= tcp_accept_proxy %> <%= tls_bind_options %> <%= v4v6 %> +<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), tcp_proxy["port"], [tcp_accept_proxy, tls_bind_options], v4v6) %> <%- else -%> - bind <%= p("ha_proxy.binding_ip") %>:<%= tcp_proxy["port"] %> <%= tcp_accept_proxy %> <%= v4v6 %> +<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), tcp_proxy["port"], tcp_accept_proxy, v4v6) %> <%- end -%> <%- if p("ha_proxy.expect_proxy_cidrs", []).size > 0 -%> tcp-request connection expect-proxy layer4 if { src -f /var/vcap/jobs/haproxy/config/expect_proxy_cidrs.txt } @@ -1114,7 +1160,7 @@ backend tcp-<%= tcp_proxy["name"] %> <%- if tcp_proxy["health_check_http"] -%> listen health_check_http_tcp-<%= tcp_proxy["name"] %> - bind <%= p("ha_proxy.binding_ip") %>:<%= tcp_proxy["health_check_http"] %> <%= v4v6 %> +<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), tcp_proxy["health_check_http"], "", v4v6) %> mode http monitor-uri /health <%- if p("ha_proxy.accept_proxy") && !p("ha_proxy.disable_health_check_proxy") -%> @@ -1125,7 +1171,7 @@ listen health_check_http_tcp-<%= tcp_proxy["name"] %> <%- if enable_additional_health_check_proxy -%> listen health_check_http_tcp-<%= tcp_proxy["name"] %>_proxy_protocol - bind <%= p("ha_proxy.binding_ip") %>:<%= tcp_proxy["health_check_http"] + 1 %> accept-proxy <%= v4v6 %> +<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), tcp_proxy["health_check_http"] + 1, "accept-proxy", v4v6) %> mode http monitor-uri /health acl tcp-<%= tcp_proxy["name"] %>-routers_down nbsrv(tcp-<%= tcp_proxy["name"] %>) eq 0 diff --git a/spec/haproxy/templates/haproxy_config/frontend_http_spec.rb b/spec/haproxy/templates/haproxy_config/frontend_http_spec.rb index 09e93f938..7f116971a 100644 --- a/spec/haproxy/templates/haproxy_config/frontend_http_spec.rb +++ b/spec/haproxy/templates/haproxy_config/frontend_http_spec.rb @@ -29,7 +29,7 @@ end it 'enables ipv6' do - expect(frontend_http).to include('bind :::80 v4v6') + expect(frontend_http).to include('bind :::80 v4v6') end end @@ -112,6 +112,17 @@ end end + context 'when ha_proxy.binding_ips is provided' do + let(:properties) do + { 'binding_ips' => ['0.0.0.0', '::'] } + end + + it 'generates two bind directives for HTTP' do + expect(frontend_http).to include('bind 0.0.0.0:80') + expect(frontend_http).to include('bind [::]:80 v6only') + end + end + context 'when a custom ha_proxy.frontend_config is provided' do let(:properties) do { 'frontend_config' => 'custom config content' } diff --git a/spec/haproxy/templates/haproxy_config/frontend_https_spec.rb b/spec/haproxy/templates/haproxy_config/frontend_https_spec.rb index d8517a9b8..c9c2caabc 100644 --- a/spec/haproxy/templates/haproxy_config/frontend_https_spec.rb +++ b/spec/haproxy/templates/haproxy_config/frontend_https_spec.rb @@ -17,7 +17,7 @@ let(:properties) { default_properties } it 'binds to all interfaces by default' do - expect(frontend_https).to include('bind :443 ssl crt /var/vcap/jobs/haproxy/config/ssl') + expect(frontend_https).to include('bind :443 ssl crt /var/vcap/jobs/haproxy/config/ssl') end context 'when ha_proxy.binding_ip is provided' do @@ -26,7 +26,7 @@ end it 'binds to the provided ip' do - expect(frontend_https).to include('bind 1.2.3.4:443 ssl crt /var/vcap/jobs/haproxy/config/ssl') + expect(frontend_https).to include('bind 1.2.3.4:443 ssl crt /var/vcap/jobs/haproxy/config/ssl') end context 'when ha_proxy.v4v6 is true and binding_ip is ::' do @@ -35,7 +35,7 @@ end it 'enables ipv6' do - expect(frontend_https).to include('bind :::443 ssl crt /var/vcap/jobs/haproxy/config/ssl v4v6') + expect(frontend_https).to include('bind :::443 ssl crt /var/vcap/jobs/haproxy/config/ssl v4v6') end end @@ -45,7 +45,7 @@ end it 'does not enable v4v6' do - expect(frontend_https).to include('bind :::443 ssl crt /var/vcap/jobs/haproxy/config/ssl') + expect(frontend_https).to include('bind :::443 ssl crt /var/vcap/jobs/haproxy/config/ssl') expect(frontend_https).not_to include('v4v6') end end @@ -56,7 +56,7 @@ end it 'does not enable v4v6' do - expect(frontend_https).to include('bind :::443 ssl crt /var/vcap/jobs/haproxy/config/ssl') + expect(frontend_https).to include('bind :::443 ssl crt /var/vcap/jobs/haproxy/config/ssl') expect(frontend_https).not_to include('v4v6') end end @@ -96,6 +96,61 @@ end end + context 'when ha_proxy.binding_ips is provided' do + let(:properties) do + default_properties.merge({ 'binding_ips' => ['0.0.0.0', '::'] }) + end + + it 'generates two bind directives' do + expect(frontend_https).to include('bind 0.0.0.0:443 ssl crt /var/vcap/jobs/haproxy/config/ssl') + expect(frontend_https).to include('bind [::]:443 v6only ssl crt /var/vcap/jobs/haproxy/config/ssl') + end + + it 'does not include v4v6' do + expect(frontend_https).not_to include('v4v6') + end + + context 'when ha_proxy.v4v6 is also true' do + let(:properties) do + default_properties.merge({ 'binding_ips' => ['0.0.0.0', '::'], 'v4v6' => true, 'binding_ip' => '::' }) + end + + it 'aborts with a meaningful error message' do + expect { frontend_https }.to raise_error(/cannot be combined with ha_proxy.v4v6/) + end + end + + context 'when binding_ips contains duplicates' do + let(:properties) do + default_properties.merge({ 'binding_ips' => ['0.0.0.0', '0.0.0.0'] }) + end + + it 'aborts with a meaningful error message' do + expect { frontend_https }.to raise_error(/duplicate address/) + end + end + + context 'when binding_ips contains an invalid IP' do + let(:properties) do + default_properties.merge({ 'binding_ips' => ['not-an-ip'] }) + end + + it 'aborts with a meaningful error message' do + expect { frontend_https }.to raise_error(/invalid IP address/) + end + end + + context 'when binding_ips is empty' do + let(:properties) do + default_properties.merge({ 'binding_ips' => [] }) + end + + it 'aborts with a meaningful error message' do + expect { frontend_https }.to raise_error(/must be a non-empty array/) + end + end + end + context 'when ha_proxy.disable_domain_fronting is true' do let(:properties) do default_properties.merge({ 'disable_domain_fronting' => true }) @@ -157,7 +212,7 @@ end it 'configures ssl to use the client ca' do - expect(frontend_https).to include('bind :443 ssl crt /var/vcap/jobs/haproxy/config/ssl ca-file /etc/ssl/certs/ca-certificates.crt verify optional') + expect(frontend_https).to include('bind :443 ssl crt /var/vcap/jobs/haproxy/config/ssl ca-file /etc/ssl/certs/ca-certificates.crt verify optional') end context 'when ha_proxy.client_cert_ignore_err is all' do @@ -166,7 +221,7 @@ end it 'adds the crt-ignore-err and ca-ignore-err flags' do - expect(frontend_https).to include('bind :443 ssl crt /var/vcap/jobs/haproxy/config/ssl ca-file /etc/ssl/certs/ca-certificates.crt verify optional crt-ignore-err all ca-ignore-err all') + expect(frontend_https).to include('bind :443 ssl crt /var/vcap/jobs/haproxy/config/ssl ca-file /etc/ssl/certs/ca-certificates.crt verify optional crt-ignore-err all ca-ignore-err all') end context 'when client_cert is not enabled' do @@ -188,7 +243,7 @@ end it 'references the crl list' do - expect(frontend_https).to include('bind :443 ssl crt /var/vcap/jobs/haproxy/config/ssl ca-file /etc/ssl/certs/ca-certificates.crt verify optional crl-file /var/vcap/jobs/haproxy/config/client-revocation-list.pem') + expect(frontend_https).to include('bind :443 ssl crt /var/vcap/jobs/haproxy/config/ssl ca-file /etc/ssl/certs/ca-certificates.crt verify optional crl-file /var/vcap/jobs/haproxy/config/client-revocation-list.pem') end context 'when client_cert is not enabled' do @@ -818,7 +873,7 @@ end it 'enables alpn h2 negotiation' do - expect(frontend_https).to include('bind :443 ssl crt /var/vcap/jobs/haproxy/config/ssl alpn h2,http/1.1') + expect(frontend_https).to include('bind :443 ssl crt /var/vcap/jobs/haproxy/config/ssl alpn h2,http/1.1') end end diff --git a/spec/haproxy/templates/haproxy_config/frontend_tcp_spec.rb b/spec/haproxy/templates/haproxy_config/frontend_tcp_spec.rb index 6e91d3303..ea69e4e7c 100644 --- a/spec/haproxy/templates/haproxy_config/frontend_tcp_spec.rb +++ b/spec/haproxy/templates/haproxy_config/frontend_tcp_spec.rb @@ -70,9 +70,9 @@ end it 'enables ipv6' do - expect(frontend_tcp_redis).to include('bind :::6379 v4v6') - expect(frontend_tcp_mysql).to include('bind :::3306 v4v6') - expect(frontend_tcp_postgres_via_link).to include('bind :::5432 v4v6') + expect(frontend_tcp_redis).to include('bind :::6379 v4v6') + expect(frontend_tcp_mysql).to include('bind :::3306 v4v6') + expect(frontend_tcp_postgres_via_link).to include('bind :::5432 v4v6') end end end @@ -97,7 +97,7 @@ end it 'adds the default ssl options' do - expect(frontend_tcp_redis).to include('bind :6379 ssl') + expect(frontend_tcp_redis).to include('bind :6379 ssl') expect(frontend_tcp_mysql).to include('bind :3306') end @@ -117,7 +117,7 @@ end it 'does not set accept-proxy' do - expect(frontend_tcp_redis).to include('bind :6379 ssl') + expect(frontend_tcp_redis).to include('bind :6379 ssl') expect(frontend_tcp_mysql).to include('bind :3306') end end @@ -130,7 +130,7 @@ end it 'sets expect-proxy of tcp connection to the file proxies_cidrs.txt contents' do - expect(frontend_tcp_redis).to include('bind :6379 ssl') + expect(frontend_tcp_redis).to include('bind :6379 ssl') expect(frontend_tcp_redis).to include('tcp-request connection expect-proxy layer4 if { src -f /var/vcap/jobs/haproxy/config/expect_proxy_cidrs.txt }') expect(frontend_tcp_mysql).to include('bind :3306') diff --git a/spec/haproxy/templates/haproxy_config/frontend_wss_spec.rb b/spec/haproxy/templates/haproxy_config/frontend_wss_spec.rb index fdf9acf30..d96a3a4eb 100644 --- a/spec/haproxy/templates/haproxy_config/frontend_wss_spec.rb +++ b/spec/haproxy/templates/haproxy_config/frontend_wss_spec.rb @@ -19,7 +19,7 @@ let(:properties) { default_properties } it 'binds to all interfaces by default' do - expect(frontend_wss).to include('bind :4443 ssl crt /var/vcap/jobs/haproxy/config/ssl') + expect(frontend_wss).to include('bind :4443 ssl crt /var/vcap/jobs/haproxy/config/ssl') end context 'when ha_proxy.binding_ip is provided' do @@ -28,7 +28,7 @@ end it 'binds to the provided ip' do - expect(frontend_wss).to include('bind 1.2.3.4:4443 ssl crt /var/vcap/jobs/haproxy/config/ssl') + expect(frontend_wss).to include('bind 1.2.3.4:4443 ssl crt /var/vcap/jobs/haproxy/config/ssl') end context 'when ha_proxy.v4v6 is true and binding_ip is ::' do @@ -37,7 +37,7 @@ end it 'enables ipv6' do - expect(frontend_wss).to include('bind :::4443 ssl crt /var/vcap/jobs/haproxy/config/ssl v4v6') + expect(frontend_wss).to include('bind :::4443 ssl crt /var/vcap/jobs/haproxy/config/ssl v4v6') end end @@ -47,7 +47,7 @@ end it 'does not enable v4v6' do - expect(frontend_wss).to include('bind :::4443 ssl crt /var/vcap/jobs/haproxy/config/ssl') + expect(frontend_wss).to include('bind :::4443 ssl crt /var/vcap/jobs/haproxy/config/ssl') expect(frontend_wss).not_to include('v4v6') end end @@ -58,7 +58,7 @@ end it 'does not enable v4v6' do - expect(frontend_wss).to include('bind :::4443 ssl crt /var/vcap/jobs/haproxy/config/ssl') + expect(frontend_wss).to include('bind :::4443 ssl crt /var/vcap/jobs/haproxy/config/ssl') expect(frontend_wss).not_to include('v4v6') end end @@ -149,7 +149,7 @@ end it 'configures ssl to use the client ca' do - expect(frontend_wss).to include('bind :4443 ssl crt /var/vcap/jobs/haproxy/config/ssl ca-file /etc/ssl/certs/ca-certificates.crt verify optional') + expect(frontend_wss).to include('bind :4443 ssl crt /var/vcap/jobs/haproxy/config/ssl ca-file /etc/ssl/certs/ca-certificates.crt verify optional') end context 'when ha_proxy.client_cert_ignore_err is all' do @@ -158,7 +158,7 @@ end it 'adds the crt-ignore-err and ca-ignore-err flags' do - expect(frontend_wss).to include('bind :4443 ssl crt /var/vcap/jobs/haproxy/config/ssl ca-file /etc/ssl/certs/ca-certificates.crt verify optional crt-ignore-err all ca-ignore-err all') + expect(frontend_wss).to include('bind :4443 ssl crt /var/vcap/jobs/haproxy/config/ssl ca-file /etc/ssl/certs/ca-certificates.crt verify optional crt-ignore-err all ca-ignore-err all') end context 'when client_cert is not enabled' do @@ -180,7 +180,7 @@ end it 'references the crl list' do - expect(frontend_wss).to include('bind :4443 ssl crt /var/vcap/jobs/haproxy/config/ssl ca-file /etc/ssl/certs/ca-certificates.crt verify optional crl-file /var/vcap/jobs/haproxy/config/client-revocation-list.pem') + expect(frontend_wss).to include('bind :4443 ssl crt /var/vcap/jobs/haproxy/config/ssl ca-file /etc/ssl/certs/ca-certificates.crt verify optional crl-file /var/vcap/jobs/haproxy/config/client-revocation-list.pem') end context 'when client_cert is not enabled' do From 791e3bfc7fd749dcc51d9b824df60dd7e49de7cd Mon Sep 17 00:00:00 2001 From: Jakob Bergmeier Date: Tue, 13 Oct 2026 16:54:21 +0200 Subject: [PATCH 03/12] docs: document dual-stack bind configuration Signed-off-by: Jakob Bergmeier --- README.md | 1 + docs/dual-stack.md | 82 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 docs/dual-stack.md diff --git a/README.md b/README.md index 4da0389ab..337656ac0 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ To make alterations to the deployment you can use the `bosh deploy [-o operator- ## Documentation - [External Certificates](/docs/external_certs.md) - Using HAProxy with additional external certificates +- [Dual-Stack Configuration](docs/dual-stack.md) - [Mutual TLS](/docs/mutual_tls.md) - Mutual TLS configuration - [Rate Limiting](/docs/rate_limiting.md) - Client IP based rate limiting - [Keepalived](/docs/keepalived.md) - Keepalived integration for high availability diff --git a/docs/dual-stack.md b/docs/dual-stack.md new file mode 100644 index 000000000..945831ffe --- /dev/null +++ b/docs/dual-stack.md @@ -0,0 +1,82 @@ +# Dual-Stack (IPv4 + IPv6) Configuration + +HAProxy can serve traffic on both IPv4 and IPv6 using two approaches. + +## Option A: Single Socket with v4v6 + +A single IPv6 wildcard socket accepts both IPv4 and IPv6 connections: + +```yaml +ha_proxy: + binding_ip: "::" + v4v6: true +``` + +This generates one bind directive per listener: +``` +bind :::443 ssl crt /var/vcap/jobs/haproxy/config/ssl v4v6 +``` + +**Note:** `v4v6` only takes effect when `binding_ip` is `::`. + +## Option B: Separate Sockets with binding_ips + +For environments that require distinct IPv4 and IPv6 sockets: + +```yaml +ha_proxy: + binding_ips: + - 0.0.0.0 + - "::" +``` + +This generates two bind directives per listener: +``` +bind 0.0.0.0:443 ssl crt /var/vcap/jobs/haproxy/config/ssl +bind [::]:443 v6only ssl crt /var/vcap/jobs/haproxy/config/ssl +``` + +IPv6 addresses automatically receive the `v6only` option to prevent +them from also accepting IPv4 connections. + +### Scope + +`binding_ips` applies globally to all generated listeners: +HTTP, HTTPS, WSS (port 4443), health checks, CF TCP routing, and +generic TCP frontends. + +When `binding_ips` is set, `binding_ip` is ignored for generated +listeners. + +### Constraints + +- `binding_ips` cannot be combined with `v4v6: true` +- Entries must be valid IP address literals (no hostnames, CIDRs, or ports) +- Duplicate addresses are rejected +- The list must not be empty when set + +### Migrating from frontend_config + +If you previously used `frontend_config` to add a second bind line, +you can replace it with `binding_ips` and remove the raw configuration: + +Before: +```yaml +ha_proxy: + binding_ip: 0.0.0.0 + disable_http: true + frontend_config: | + bind :::443 ssl crt /var/vcap/jobs/haproxy/config/ssl +``` + +After: +```yaml +ha_proxy: + binding_ips: + - 0.0.0.0 + - "::" +``` + +The `binding_ips` approach is safer because it automatically applies +all TLS, ALPN, mTLS, and PROXY protocol options to every bind directive, +and does not require disabling other frontends. From dc3e3ef7b31177bac5cb5c439470b1fed5c52947 Mon Sep 17 00:00:00 2001 From: Jakob Bergmeier Date: Thu, 15 Oct 2026 13:41:36 +0200 Subject: [PATCH 04/12] refactor: simplify render_bind by removing separate v4v6 parameter Signed-off-by: Jakob Bergmeier --- jobs/haproxy/templates/haproxy.config.erb | 24 +++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/jobs/haproxy/templates/haproxy.config.erb b/jobs/haproxy/templates/haproxy.config.erb index ed78b06e4..fad6f5d6b 100644 --- a/jobs/haproxy/templates/haproxy.config.erb +++ b/jobs/haproxy/templates/haproxy.config.erb @@ -17,14 +17,14 @@ end end - def render_bind(binding_addresses, binding_ip, port, extra_options, v4v6_option) + def render_bind(binding_addresses, binding_ip, port, extra_options) if binding_addresses binding_addresses.map do |ba| opts = [ba[:opts], *Array(extra_options)].reject(&:empty?).join(' ') " bind #{ba[:addr]}:#{port} #{opts}".rstrip end.join("\n") else - options = [*Array(extra_options), v4v6_option].reject(&:empty?).join(' ') + options = [*Array(extra_options)].reject(&:empty?).join(' ') " bind #{binding_ip}:#{port} #{options}".rstrip end end @@ -442,7 +442,7 @@ listen stats <% if p("ha_proxy.enable_health_check_http") %> listen health_check_http_url -<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), p("ha_proxy.health_check_port"), "", v4v6) %> +<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), p("ha_proxy.health_check_port"), v4v6) %> mode http option httpclose monitor-uri /health @@ -455,7 +455,7 @@ listen health_check_http_url <%- if enable_additional_health_check_proxy -%> listen health_check_http_url_proxy_protocol -<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), p("ha_proxy.health_check_port") + 1, "accept-proxy", v4v6) %> +<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), p("ha_proxy.health_check_port") + 1, ["accept-proxy", v4v6]) %> mode http option httpclose monitor-uri /health @@ -488,7 +488,7 @@ backend st_tcp_conn_rate # HTTP Frontend {{{ frontend http-in mode http -<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), 80, accept_proxy, v4v6) %> +<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), 80, [accept_proxy, v4v6]) %> <%- if properties.ha_proxy.frontend_config -%> <%= format_indented_multiline_config(p("ha_proxy.frontend_config")) %> <%- end -%> @@ -602,7 +602,7 @@ frontend http-in # HTTPS Frontend {{{ frontend https-in mode http -<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), 443, [accept_proxy, tls_bind_options, default_alpn_config], v4v6) %> +<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), 443, [accept_proxy, tls_bind_options, default_alpn_config, v4v6]) %> # Set this acl when the request is a route service request, used by ha_proxy.forward_true_client_ip_header and ha_proxy.forwarded_client_cert acl route_service_request hdr(X-Cf-Proxy-Signature) -m found <%- if disable_domain_fronting -%> @@ -805,7 +805,7 @@ frontend https-in # HTTPS Websockets Frontend {{{ frontend wss-in mode http -<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), 4443, [accept_proxy, tls_bind_options], v4v6) %> +<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), 4443, [accept_proxy, tls_bind_options, v4v6]) %> <%- if disable_domain_fronting -%> # Check whether the client is attempting domain fronting. # Ensure a host header exists to check against @@ -1074,7 +1074,7 @@ backend http-routed-backend-<%= prefix_hash %> frontend cf_tcp_routing mode tcp -<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), p("ha_proxy.tcp_routing.port_range"), "", v4v6) %> +<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), p("ha_proxy.tcp_routing.port_range"), v4v6) %> default_backend cf_tcp_routers backend cf_tcp_routers @@ -1105,9 +1105,9 @@ end -%> frontend tcp-frontend_<%= tcp_proxy["name"]%> mode tcp <%- if tcp_proxy["ssl"] -%> -<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), tcp_proxy["port"], [tcp_accept_proxy, tls_bind_options], v4v6) %> +<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), tcp_proxy["port"], [tcp_accept_proxy, tls_bind_options, v4v6]) %> <%- else -%> -<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), tcp_proxy["port"], tcp_accept_proxy, v4v6) %> +<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), tcp_proxy["port"], [tcp_accept_proxy, v4v6]) %> <%- end -%> <%- if p("ha_proxy.expect_proxy_cidrs", []).size > 0 -%> tcp-request connection expect-proxy layer4 if { src -f /var/vcap/jobs/haproxy/config/expect_proxy_cidrs.txt } @@ -1160,7 +1160,7 @@ backend tcp-<%= tcp_proxy["name"] %> <%- if tcp_proxy["health_check_http"] -%> listen health_check_http_tcp-<%= tcp_proxy["name"] %> -<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), tcp_proxy["health_check_http"], "", v4v6) %> +<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), tcp_proxy["health_check_http"], v4v6) %> mode http monitor-uri /health <%- if p("ha_proxy.accept_proxy") && !p("ha_proxy.disable_health_check_proxy") -%> @@ -1171,7 +1171,7 @@ listen health_check_http_tcp-<%= tcp_proxy["name"] %> <%- if enable_additional_health_check_proxy -%> listen health_check_http_tcp-<%= tcp_proxy["name"] %>_proxy_protocol -<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), tcp_proxy["health_check_http"] + 1, "accept-proxy", v4v6) %> +<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), tcp_proxy["health_check_http"] + 1, ["accept-proxy", v4v6]) %> mode http monitor-uri /health acl tcp-<%= tcp_proxy["name"] %>-routers_down nbsrv(tcp-<%= tcp_proxy["name"] %>) eq 0 From 884a00bea9b9650ea346766aed9b49f9e73e5066 Mon Sep 17 00:00:00 2001 From: Jakob Bergmeier Date: Thu, 15 Oct 2026 14:00:53 +0200 Subject: [PATCH 05/12] fix: reject non-empty binding_ip when binding_ips is set Signed-off-by: Jakob Bergmeier --- jobs/haproxy/spec | 2 +- jobs/haproxy/templates/haproxy.config.erb | 3 +++ .../templates/haproxy_config/frontend_https_spec.rb | 10 ++++++++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/jobs/haproxy/spec b/jobs/haproxy/spec index 4b4e0f4f2..01b267b52 100644 --- a/jobs/haproxy/spec +++ b/jobs/haproxy/spec @@ -593,7 +593,7 @@ properties: When set, each listener generates one bind directive per address, replacing the single address from ha_proxy.binding_ip. IPv6 addresses automatically receive the v6only option. - Cannot be combined with ha_proxy.v4v6: true. + Cannot be combined with ha_proxy.v4v6: true or a non-empty ha_proxy.binding_ip. Other listeners continue using ha_proxy.binding_ip when this is absent. example: - 0.0.0.0 diff --git a/jobs/haproxy/templates/haproxy.config.erb b/jobs/haproxy/templates/haproxy.config.erb index fad6f5d6b..fdf068362 100644 --- a/jobs/haproxy/templates/haproxy.config.erb +++ b/jobs/haproxy/templates/haproxy.config.erb @@ -202,6 +202,9 @@ if_p("ha_proxy.binding_ips") do |ips| if p("ha_proxy.v4v6") abort("Conflicting configuration: ha_proxy.binding_ips cannot be combined with ha_proxy.v4v6: true") end + unless p("ha_proxy.binding_ip").empty? + abort("Conflicting configuration: ha_proxy.binding_ip cannot be set when ha_proxy.binding_ips is provided") + end seen = {} binding_addresses = ips.map do |ip| unless ip.is_a?(String) && !ip.empty? diff --git a/spec/haproxy/templates/haproxy_config/frontend_https_spec.rb b/spec/haproxy/templates/haproxy_config/frontend_https_spec.rb index c9c2caabc..a6c943c8d 100644 --- a/spec/haproxy/templates/haproxy_config/frontend_https_spec.rb +++ b/spec/haproxy/templates/haproxy_config/frontend_https_spec.rb @@ -120,6 +120,16 @@ end end + context 'when ha_proxy.binding_ip is also set' do + let(:properties) do + default_properties.merge({ 'binding_ips' => ['0.0.0.0', '::'], 'binding_ip' => '1.2.3.4' }) + end + + it 'aborts with a meaningful error message' do + expect { frontend_https }.to raise_error(/ha_proxy.binding_ip cannot be set when ha_proxy.binding_ips is provided/) + end + end + context 'when binding_ips contains duplicates' do let(:properties) do default_properties.merge({ 'binding_ips' => ['0.0.0.0', '0.0.0.0'] }) From 4a24ca42f03ecddcc47bd66d150227ba2a102505 Mon Sep 17 00:00:00 2001 From: Jakob Bergmeier Date: Fri, 16 Oct 2026 14:09:10 +0200 Subject: [PATCH 06/12] fix: emit v6only when binding_ip is :: without v4v6 Signed-off-by: Jakob Bergmeier --- jobs/haproxy/spec | 2 +- jobs/haproxy/templates/haproxy.config.erb | 31 +++++++++++-------- .../haproxy_config/frontend_http_spec.rb | 8 ++--- .../haproxy_config/frontend_https_spec.rb | 8 ++--- .../haproxy_config/frontend_wss_spec.rb | 8 ++--- 5 files changed, 31 insertions(+), 26 deletions(-) diff --git a/jobs/haproxy/spec b/jobs/haproxy/spec index 01b267b52..206497fea 100644 --- a/jobs/haproxy/spec +++ b/jobs/haproxy/spec @@ -582,7 +582,7 @@ properties: ha_proxy.enable_additional_health_check_proxy: description: "Enable the additional health check listener with use of the PROXY protocol" ha_proxy.binding_ip: - description: "If there are multiple ethernet interfaces, specify which one to bind. Set to `::` to bind to all IPv6 interfaces (no IPv4). IPv6 must be enabled on the HAProxy VM in the deployment manifest." + description: "If there are multiple ethernet interfaces, specify which one to bind. Set to `::` to bind to all IPv6 interfaces only (v6only is set automatically). To also accept IPv4, set ha_proxy.v4v6 to true. IPv6 must be enabled on the HAProxy VM in the deployment manifest." default: "" ha_proxy.v4v6: description: "Boolean, disabled by default. Enables binding to all IPv4 and IPv6 interfaces. Only applies if `ha_proxy.binding_ip` is set to `::`." diff --git a/jobs/haproxy/templates/haproxy.config.erb b/jobs/haproxy/templates/haproxy.config.erb index fdf068362..34d6269d5 100644 --- a/jobs/haproxy/templates/haproxy.config.erb +++ b/jobs/haproxy/templates/haproxy.config.erb @@ -185,10 +185,15 @@ if ![true, false, "true", "false", "mtls_only"].include?(disable_domain_fronting end # }}} -# IPv4 and IPv6 binding (v4v6) Option {{{ +# IPv4 and IPv6 binding (v4v6 / v6only) Options {{{ v4v6 = "" -if p("ha_proxy.v4v6") && p("ha_proxy.binding_ip") == "::" - v4v6 = "v4v6" +v6only = "" +if p("ha_proxy.binding_ip") == "::" + if p("ha_proxy.v4v6") + v4v6 = "v4v6" + else + v6only = "v6only" + end end # }}} @@ -445,7 +450,7 @@ listen stats <% if p("ha_proxy.enable_health_check_http") %> listen health_check_http_url -<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), p("ha_proxy.health_check_port"), v4v6) %> +<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), p("ha_proxy.health_check_port"), [v4v6, v6only]) %> mode http option httpclose monitor-uri /health @@ -458,7 +463,7 @@ listen health_check_http_url <%- if enable_additional_health_check_proxy -%> listen health_check_http_url_proxy_protocol -<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), p("ha_proxy.health_check_port") + 1, ["accept-proxy", v4v6]) %> +<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), p("ha_proxy.health_check_port") + 1, ["accept-proxy", v4v6, v6only]) %> mode http option httpclose monitor-uri /health @@ -491,7 +496,7 @@ backend st_tcp_conn_rate # HTTP Frontend {{{ frontend http-in mode http -<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), 80, [accept_proxy, v4v6]) %> +<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), 80, [accept_proxy, v4v6, v6only]) %> <%- if properties.ha_proxy.frontend_config -%> <%= format_indented_multiline_config(p("ha_proxy.frontend_config")) %> <%- end -%> @@ -605,7 +610,7 @@ frontend http-in # HTTPS Frontend {{{ frontend https-in mode http -<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), 443, [accept_proxy, tls_bind_options, default_alpn_config, v4v6]) %> +<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), 443, [accept_proxy, tls_bind_options, default_alpn_config, v4v6, v6only]) %> # Set this acl when the request is a route service request, used by ha_proxy.forward_true_client_ip_header and ha_proxy.forwarded_client_cert acl route_service_request hdr(X-Cf-Proxy-Signature) -m found <%- if disable_domain_fronting -%> @@ -808,7 +813,7 @@ frontend https-in # HTTPS Websockets Frontend {{{ frontend wss-in mode http -<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), 4443, [accept_proxy, tls_bind_options, v4v6]) %> +<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), 4443, [accept_proxy, tls_bind_options, v4v6, v6only]) %> <%- if disable_domain_fronting -%> # Check whether the client is attempting domain fronting. # Ensure a host header exists to check against @@ -1077,7 +1082,7 @@ backend http-routed-backend-<%= prefix_hash %> frontend cf_tcp_routing mode tcp -<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), p("ha_proxy.tcp_routing.port_range"), v4v6) %> +<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), p("ha_proxy.tcp_routing.port_range"), [v4v6, v6only]) %> default_backend cf_tcp_routers backend cf_tcp_routers @@ -1108,9 +1113,9 @@ end -%> frontend tcp-frontend_<%= tcp_proxy["name"]%> mode tcp <%- if tcp_proxy["ssl"] -%> -<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), tcp_proxy["port"], [tcp_accept_proxy, tls_bind_options, v4v6]) %> +<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), tcp_proxy["port"], [tcp_accept_proxy, tls_bind_options, v4v6, v6only]) %> <%- else -%> -<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), tcp_proxy["port"], [tcp_accept_proxy, v4v6]) %> +<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), tcp_proxy["port"], [tcp_accept_proxy, v4v6, v6only]) %> <%- end -%> <%- if p("ha_proxy.expect_proxy_cidrs", []).size > 0 -%> tcp-request connection expect-proxy layer4 if { src -f /var/vcap/jobs/haproxy/config/expect_proxy_cidrs.txt } @@ -1163,7 +1168,7 @@ backend tcp-<%= tcp_proxy["name"] %> <%- if tcp_proxy["health_check_http"] -%> listen health_check_http_tcp-<%= tcp_proxy["name"] %> -<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), tcp_proxy["health_check_http"], v4v6) %> +<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), tcp_proxy["health_check_http"], [v4v6, v6only]) %> mode http monitor-uri /health <%- if p("ha_proxy.accept_proxy") && !p("ha_proxy.disable_health_check_proxy") -%> @@ -1174,7 +1179,7 @@ listen health_check_http_tcp-<%= tcp_proxy["name"] %> <%- if enable_additional_health_check_proxy -%> listen health_check_http_tcp-<%= tcp_proxy["name"] %>_proxy_protocol -<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), tcp_proxy["health_check_http"] + 1, ["accept-proxy", v4v6]) %> +<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), tcp_proxy["health_check_http"] + 1, ["accept-proxy", v4v6, v6only]) %> mode http monitor-uri /health acl tcp-<%= tcp_proxy["name"] %>-routers_down nbsrv(tcp-<%= tcp_proxy["name"] %>) eq 0 diff --git a/spec/haproxy/templates/haproxy_config/frontend_http_spec.rb b/spec/haproxy/templates/haproxy_config/frontend_http_spec.rb index 7f116971a..a0d276c6e 100644 --- a/spec/haproxy/templates/haproxy_config/frontend_http_spec.rb +++ b/spec/haproxy/templates/haproxy_config/frontend_http_spec.rb @@ -38,8 +38,8 @@ { 'v4v6' => false, 'binding_ip' => '::' } end - it 'does not enable v4v6' do - expect(frontend_http).to include('bind :::80') + it 'enables v6only' do + expect(frontend_http).to include('bind :::80 v6only') expect(frontend_http).not_to include('v4v6') end end @@ -49,8 +49,8 @@ { 'binding_ip' => '::' } end - it 'does not enable v4v6' do - expect(frontend_http).to include('bind :::80') + it 'enables v6only' do + expect(frontend_http).to include('bind :::80 v6only') expect(frontend_http).not_to include('v4v6') end end diff --git a/spec/haproxy/templates/haproxy_config/frontend_https_spec.rb b/spec/haproxy/templates/haproxy_config/frontend_https_spec.rb index a6c943c8d..5eaf52bee 100644 --- a/spec/haproxy/templates/haproxy_config/frontend_https_spec.rb +++ b/spec/haproxy/templates/haproxy_config/frontend_https_spec.rb @@ -44,8 +44,8 @@ default_properties.merge({ 'v4v6' => false, 'binding_ip' => '::' }) end - it 'does not enable v4v6' do - expect(frontend_https).to include('bind :::443 ssl crt /var/vcap/jobs/haproxy/config/ssl') + it 'enables v6only' do + expect(frontend_https).to include('bind :::443 ssl crt /var/vcap/jobs/haproxy/config/ssl v6only') expect(frontend_https).not_to include('v4v6') end end @@ -55,8 +55,8 @@ default_properties.merge({ 'binding_ip' => '::' }) end - it 'does not enable v4v6' do - expect(frontend_https).to include('bind :::443 ssl crt /var/vcap/jobs/haproxy/config/ssl') + it 'enables v6only' do + expect(frontend_https).to include('bind :::443 ssl crt /var/vcap/jobs/haproxy/config/ssl v6only') expect(frontend_https).not_to include('v4v6') end end diff --git a/spec/haproxy/templates/haproxy_config/frontend_wss_spec.rb b/spec/haproxy/templates/haproxy_config/frontend_wss_spec.rb index d96a3a4eb..67378322b 100644 --- a/spec/haproxy/templates/haproxy_config/frontend_wss_spec.rb +++ b/spec/haproxy/templates/haproxy_config/frontend_wss_spec.rb @@ -46,8 +46,8 @@ default_properties.merge({ 'v4v6' => false, 'binding_ip' => '::' }) end - it 'does not enable v4v6' do - expect(frontend_wss).to include('bind :::4443 ssl crt /var/vcap/jobs/haproxy/config/ssl') + it 'enables v6only' do + expect(frontend_wss).to include('bind :::4443 ssl crt /var/vcap/jobs/haproxy/config/ssl v6only') expect(frontend_wss).not_to include('v4v6') end end @@ -57,8 +57,8 @@ default_properties.merge({ 'binding_ip' => '::' }) end - it 'does not enable v4v6' do - expect(frontend_wss).to include('bind :::4443 ssl crt /var/vcap/jobs/haproxy/config/ssl') + it 'enables v6only' do + expect(frontend_wss).to include('bind :::4443 ssl crt /var/vcap/jobs/haproxy/config/ssl v6only') expect(frontend_wss).not_to include('v4v6') end end From 0014d2d28be866081a84ea5f5e57bd90ec08e934 Mon Sep 17 00:00:00 2001 From: Jakob Bergmeier Date: Fri, 16 Oct 2026 14:57:24 +0200 Subject: [PATCH 07/12] test: add binding_ips tests for WSS and TCP frontends Signed-off-by: Jakob Bergmeier --- docs/dual-stack.md | 27 +++++++++++++++++++ .../haproxy_config/frontend_tcp_spec.rb | 18 +++++++++++++ .../haproxy_config/frontend_wss_spec.rb | 15 +++++++++++ 3 files changed, 60 insertions(+) diff --git a/docs/dual-stack.md b/docs/dual-stack.md index 945831ffe..e6c82e314 100644 --- a/docs/dual-stack.md +++ b/docs/dual-stack.md @@ -19,6 +19,32 @@ bind :::443 ssl crt /var/vcap/jobs/haproxy/config/ssl v4v6 **Note:** `v4v6` only takes effect when `binding_ip` is `::`. +## IPv6-only mode (v6only) + +When `binding_ip` is set to `::` without enabling `v4v6`, the `v6only` +option is automatically added to all bind directives: + +```yaml +ha_proxy: + binding_ip: "::" + # v4v6 not set or false +``` + +This generates: +``` +bind :::443 ssl crt /var/vcap/jobs/haproxy/config/ssl v6only +``` + +The `v6only` option ensures the IPv6 socket does not accept IPv4 +connections, regardless of the kernel's `net.ipv6.bindv6only` sysctl +setting. Without it, behavior depends on the kernel default -- on most +Linux distributions (including standard BOSH stemcells), an IPv6 wildcard +socket silently accepts IPv4 connections as `::ffff:` mapped addresses. + +**Breaking change:** Deployments using `binding_ip: "::"` without +`v4v6: true` that relied on the kernel default to accept IPv4 traffic +must now explicitly set `v4v6: true` to restore dual-stack behavior. + ## Option B: Separate Sockets with binding_ips For environments that require distinct IPv4 and IPv6 sockets: @@ -51,6 +77,7 @@ listeners. ### Constraints - `binding_ips` cannot be combined with `v4v6: true` +- `binding_ips` cannot be combined with a non-empty `binding_ip` - Entries must be valid IP address literals (no hostnames, CIDRs, or ports) - Duplicate addresses are rejected - The list must not be empty when set diff --git a/spec/haproxy/templates/haproxy_config/frontend_tcp_spec.rb b/spec/haproxy/templates/haproxy_config/frontend_tcp_spec.rb index ea69e4e7c..d63645219 100644 --- a/spec/haproxy/templates/haproxy_config/frontend_tcp_spec.rb +++ b/spec/haproxy/templates/haproxy_config/frontend_tcp_spec.rb @@ -77,6 +77,24 @@ end end + context 'when ha_proxy.binding_ips is provided' do + let(:properties) do + default_properties.merge({ 'binding_ips' => ['0.0.0.0', '::'] }) + end + + it 'generates two bind directives for TCP frontends' do + expect(frontend_tcp_redis).to include('bind 0.0.0.0:6379') + expect(frontend_tcp_redis).to include('bind [::]:6379 v6only') + expect(frontend_tcp_mysql).to include('bind 0.0.0.0:3306') + expect(frontend_tcp_mysql).to include('bind [::]:3306 v6only') + end + + it 'does not include v4v6' do + expect(frontend_tcp_redis).not_to include('v4v6') + expect(frontend_tcp_mysql).not_to include('v4v6') + end + end + context 'when ssl is enabled on custom backends (not links)' do let(:default_properties) do { diff --git a/spec/haproxy/templates/haproxy_config/frontend_wss_spec.rb b/spec/haproxy/templates/haproxy_config/frontend_wss_spec.rb index 67378322b..30ba67e4a 100644 --- a/spec/haproxy/templates/haproxy_config/frontend_wss_spec.rb +++ b/spec/haproxy/templates/haproxy_config/frontend_wss_spec.rb @@ -98,6 +98,21 @@ end end + context 'when ha_proxy.binding_ips is provided' do + let(:properties) do + default_properties.merge({ 'binding_ips' => ['0.0.0.0', '::'] }) + end + + it 'generates two bind directives for WSS' do + expect(frontend_wss).to include('bind 0.0.0.0:4443 ssl crt /var/vcap/jobs/haproxy/config/ssl') + expect(frontend_wss).to include('bind [::]:4443 v6only ssl crt /var/vcap/jobs/haproxy/config/ssl') + end + + it 'does not include v4v6' do + expect(frontend_wss).not_to include('v4v6') + end + end + context 'when ha_proxy.disable_domain_fronting is true' do let(:properties) do default_properties.merge({ 'disable_domain_fronting' => true }) From 09d90ae8a8882d41cbfee4c5cb3cee70f286636d Mon Sep 17 00:00:00 2001 From: Jakob Bergmeier Date: Tue, 20 Oct 2026 15:46:48 +0200 Subject: [PATCH 08/12] fix: Fail when v4v6: true and binding_ip != :: Signed-off-by: Jakob Bergmeier --- jobs/haproxy/templates/haproxy.config.erb | 3 +++ .../haproxy_config/frontend_https_spec.rb | 14 ++++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/jobs/haproxy/templates/haproxy.config.erb b/jobs/haproxy/templates/haproxy.config.erb index 34d6269d5..583ea6737 100644 --- a/jobs/haproxy/templates/haproxy.config.erb +++ b/jobs/haproxy/templates/haproxy.config.erb @@ -188,6 +188,9 @@ end # IPv4 and IPv6 binding (v4v6 / v6only) Options {{{ v4v6 = "" v6only = "" +if p("ha_proxy.v4v6") && p("ha_proxy.binding_ip") != "::" + abort("Conflicting configuration: ha_proxy.v4v6 is enabled but ha_proxy.binding_ip is not '::'. v4v6 only applies to IPv6 wildcard sockets.") +end if p("ha_proxy.binding_ip") == "::" if p("ha_proxy.v4v6") v4v6 = "v4v6" diff --git a/spec/haproxy/templates/haproxy_config/frontend_https_spec.rb b/spec/haproxy/templates/haproxy_config/frontend_https_spec.rb index 5eaf52bee..16ff8cbd4 100644 --- a/spec/haproxy/templates/haproxy_config/frontend_https_spec.rb +++ b/spec/haproxy/templates/haproxy_config/frontend_https_spec.rb @@ -22,11 +22,21 @@ context 'when ha_proxy.binding_ip is provided' do let(:properties) do - default_properties.merge({ 'binding_ip' => '1.2.3.4' }) + default_properties.merge({ 'binding_ip' => '0.0.0.0' }) end it 'binds to the provided ip' do - expect(frontend_https).to include('bind 1.2.3.4:443 ssl crt /var/vcap/jobs/haproxy/config/ssl') + expect(frontend_https).to include('bind 0.0.0.0:443 ssl crt /var/vcap/jobs/haproxy/config/ssl') + end + + context 'when ha_proxy.v4v6 is true but binding_ip is not ::' do + let(:properties) do + default_properties.merge({ 'v4v6' => true, 'binding_ip' => '0.0.0.0' }) + end + + it 'aborts with a meaningful error message' do + expect { frontend_https }.to raise_error(/v4v6 is enabled but ha_proxy.binding_ip is not '::'/) + end end context 'when ha_proxy.v4v6 is true and binding_ip is ::' do From da5514c06cc7603f48cc8b56663d08021a581287 Mon Sep 17 00:00:00 2001 From: Jakob Bergmeier Date: Thu, 29 Oct 2026 21:05:54 +0100 Subject: [PATCH 09/12] refactor: replace binding_ips with explicit bindings Signed-off-by: Jakob Bergmeier --- jobs/haproxy/spec | 17 +- jobs/haproxy/templates/haproxy.config.erb | 127 +++++++++----- .../templates/haproxy_config/bindings_spec.rb | 161 ++++++++++++++++++ .../haproxy_config/frontend_http_spec.rb | 16 +- .../haproxy_config/frontend_https_spec.rb | 41 ++--- .../haproxy_config/frontend_tcp_spec.rb | 4 +- .../haproxy_config/frontend_wss_spec.rb | 16 +- 7 files changed, 294 insertions(+), 88 deletions(-) create mode 100644 spec/haproxy/templates/haproxy_config/bindings_spec.rb diff --git a/jobs/haproxy/spec b/jobs/haproxy/spec index 206497fea..9d4accfd0 100644 --- a/jobs/haproxy/spec +++ b/jobs/haproxy/spec @@ -582,22 +582,21 @@ properties: ha_proxy.enable_additional_health_check_proxy: description: "Enable the additional health check listener with use of the PROXY protocol" ha_proxy.binding_ip: - description: "If there are multiple ethernet interfaces, specify which one to bind. Set to `::` to bind to all IPv6 interfaces only (v6only is set automatically). To also accept IPv4, set ha_proxy.v4v6 to true. IPv6 must be enabled on the HAProxy VM in the deployment manifest." + description: "If there are multiple ethernet interfaces, specify which one to bind. Set to `::` to bind to all IPv6 interfaces. To also accept IPv4, set ha_proxy.v4v6 to true. IPv6 must be enabled on the HAProxy VM in the deployment manifest." default: "" ha_proxy.v4v6: description: "Boolean, disabled by default. Enables binding to all IPv4 and IPv6 interfaces. Only applies if `ha_proxy.binding_ip` is set to `::`." default: false - ha_proxy.binding_ips: + ha_proxy.bindings: description: | - Optional list of IP addresses to bind all generated listeners to. - When set, each listener generates one bind directive per address, - replacing the single address from ha_proxy.binding_ip. - IPv6 addresses automatically receive the v6only option. + Optional explicit list of bind sockets for all generated listeners. Each entry + requires address. IPv4 addresses have no mode. The IPv6 wildcard :: requires + ipv6_mode, either v6only or v4v6; specific IPv6 addresses have no mode. Cannot be combined with ha_proxy.v4v6: true or a non-empty ha_proxy.binding_ip. - Other listeners continue using ha_proxy.binding_ip when this is absent. example: - - 0.0.0.0 - - "::" + - address: 0.0.0.0 + - address: "::" + ipv6_mode: v6only ha_proxy.cidr_blacklist: description: "List of CIDRs to block for http(s). Format is string array of CIDRs or single string of base64 encoded gzip." diff --git a/jobs/haproxy/templates/haproxy.config.erb b/jobs/haproxy/templates/haproxy.config.erb index 583ea6737..36c6e4086 100644 --- a/jobs/haproxy/templates/haproxy.config.erb +++ b/jobs/haproxy/templates/haproxy.config.erb @@ -17,11 +17,11 @@ end end - def render_bind(binding_addresses, binding_ip, port, extra_options) - if binding_addresses - binding_addresses.map do |ba| - opts = [ba[:opts], *Array(extra_options)].reject(&:empty?).join(' ') - " bind #{ba[:addr]}:#{port} #{opts}".rstrip + def render_bind(bindings, binding_ip, port, extra_options) + if bindings + bindings.map do |binding| + opts = [binding[:ipv6_mode], *Array(extra_options)].compact.reject(&:empty?).join(' ') + " bind #{binding[:address]}:#{port} #{opts}".rstrip end.join("\n") else options = [*Array(extra_options)].reject(&:empty?).join(' ') @@ -185,55 +185,100 @@ if ![true, false, "true", "false", "mtls_only"].include?(disable_domain_fronting end # }}} -# IPv4 and IPv6 binding (v4v6 / v6only) Options {{{ +# Legacy IPv4 and IPv6 binding (v4v6) Option {{{ v4v6 = "" -v6only = "" -if p("ha_proxy.v4v6") && p("ha_proxy.binding_ip") != "::" - abort("Conflicting configuration: ha_proxy.v4v6 is enabled but ha_proxy.binding_ip is not '::'. v4v6 only applies to IPv6 wildcard sockets.") -end -if p("ha_proxy.binding_ip") == "::" - if p("ha_proxy.v4v6") +if_p("ha_proxy.v4v6") do + if p("ha_proxy.binding_ip") == "::" v4v6 = "v4v6" - else - v6only = "v6only" end end # }}} -# Multiple Bind Addresses (binding_ips) Option {{{ +# Explicit Bindings Option {{{ require 'ipaddr' -binding_addresses = nil -if_p("ha_proxy.binding_ips") do |ips| - unless ips.is_a?(Array) && !ips.empty? - abort("ha_proxy.binding_ips must be a non-empty array of IP addresses") +bindings = nil +if_p("ha_proxy.bindings") do |configured_bindings| + unless configured_bindings.is_a?(Array) && !configured_bindings.empty? + abort("ha_proxy.bindings must be a non-empty array of maps") end - if p("ha_proxy.v4v6") - abort("Conflicting configuration: ha_proxy.binding_ips cannot be combined with ha_proxy.v4v6: true") + if p("ha_proxy.v4v6") == true + abort("Conflicting configuration: ha_proxy.bindings cannot be combined with ha_proxy.v4v6: true") end - unless p("ha_proxy.binding_ip").empty? - abort("Conflicting configuration: ha_proxy.binding_ip cannot be set when ha_proxy.binding_ips is provided") + legacy_binding_ip = p("ha_proxy.binding_ip") + unless legacy_binding_ip.nil? || legacy_binding_ip.empty? + abort("Conflicting configuration: ha_proxy.bindings cannot be combined with a non-empty ha_proxy.binding_ip") end + seen = {} - binding_addresses = ips.map do |ip| - unless ip.is_a?(String) && !ip.empty? - abort("ha_proxy.binding_ips: each entry must be a non-empty IP address string, got: #{ip.inspect}") + has_ipv4_wildcard = false + has_ipv4_specific = false + has_ipv6_wildcard = false + has_ipv6_specific = false + + bindings = configured_bindings.map do |binding| + unless binding.is_a?(Hash) + abort("ha_proxy.bindings: each entry must be a map, got: #{binding.inspect}") end + + keys = binding.keys.map(&:to_s) + unless keys.include?("address") && (keys - %w[address ipv6_mode]).empty? && keys.uniq.length == keys.length + abort("ha_proxy.bindings: each entry requires address and only permits ipv6_mode where applicable") + end + + address = binding["address"] || binding[:address] + ipv6_mode_provided = binding.key?("ipv6_mode") || binding.key?(:ipv6_mode) + ipv6_mode = binding["ipv6_mode"] || binding[:ipv6_mode] + unless address.is_a?(String) && !address.empty? && address == address.strip && address !~ /[\[\]\/%]/ + abort("ha_proxy.bindings: address must be a non-empty, unbracketed IP literal, got: #{address.inspect}") + end + begin - parsed = IPAddr.new(ip) + parsed = IPAddr.new(address) rescue IPAddr::InvalidAddressError - abort("ha_proxy.binding_ips: invalid IP address: #{ip.inspect}") + abort("ha_proxy.bindings: invalid IP address: #{address.inspect}") end + + if parsed.ipv6? && parsed.ipv4_mapped? + abort("ha_proxy.bindings: IPv4-mapped IPv6 addresses are not supported: #{address.inspect}") + end + normalized = parsed.to_s if seen[normalized] - abort("ha_proxy.binding_ips: duplicate address: #{ip}") + abort("ha_proxy.bindings: duplicate address: #{address}") end seen[normalized] = true + if parsed.ipv6? - { addr: "[#{normalized}]", opts: "v6only" } + if normalized == "::" + unless ipv6_mode_provided && %w[v6only v4v6].include?(ipv6_mode) + abort("ha_proxy.bindings: IPv6 wildcard :: requires ipv6_mode v6only or v4v6") + end + has_ipv6_wildcard = true + else + abort("ha_proxy.bindings: specific IPv6 addresses cannot specify ipv6_mode") if ipv6_mode_provided + has_ipv6_specific = true + end + { address: "[#{normalized}]", ipv6_mode: ipv6_mode } else - { addr: normalized, opts: "" } + abort("ha_proxy.bindings: IPv4 addresses cannot specify ipv6_mode") if ipv6_mode_provided + if normalized == "0.0.0.0" + has_ipv4_wildcard = true + else + has_ipv4_specific = true + end + { address: normalized, ipv6_mode: nil } end end + + if has_ipv4_wildcard && has_ipv4_specific + abort("ha_proxy.bindings: IPv4 wildcard 0.0.0.0 cannot be combined with a specific IPv4 address") + end + if has_ipv6_wildcard && has_ipv6_specific + abort("ha_proxy.bindings: IPv6 wildcard :: cannot be combined with a specific IPv6 address") + end + if bindings.any? { |binding| binding[:ipv6_mode] == "v4v6" } && bindings.any? { |binding| binding[:ipv6_mode].nil? } + abort("ha_proxy.bindings: IPv6 wildcard :: with ipv6_mode v4v6 cannot be combined with an IPv4 binding") + end end # }}} @@ -453,7 +498,7 @@ listen stats <% if p("ha_proxy.enable_health_check_http") %> listen health_check_http_url -<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), p("ha_proxy.health_check_port"), [v4v6, v6only]) %> +<%= render_bind(bindings, p("ha_proxy.binding_ip"), p("ha_proxy.health_check_port"), [v4v6]) %> mode http option httpclose monitor-uri /health @@ -466,7 +511,7 @@ listen health_check_http_url <%- if enable_additional_health_check_proxy -%> listen health_check_http_url_proxy_protocol -<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), p("ha_proxy.health_check_port") + 1, ["accept-proxy", v4v6, v6only]) %> +<%= render_bind(bindings, p("ha_proxy.binding_ip"), p("ha_proxy.health_check_port") + 1, ["accept-proxy", v4v6]) %> mode http option httpclose monitor-uri /health @@ -499,7 +544,7 @@ backend st_tcp_conn_rate # HTTP Frontend {{{ frontend http-in mode http -<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), 80, [accept_proxy, v4v6, v6only]) %> +<%= render_bind(bindings, p("ha_proxy.binding_ip"), 80, [accept_proxy, v4v6]) %> <%- if properties.ha_proxy.frontend_config -%> <%= format_indented_multiline_config(p("ha_proxy.frontend_config")) %> <%- end -%> @@ -613,7 +658,7 @@ frontend http-in # HTTPS Frontend {{{ frontend https-in mode http -<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), 443, [accept_proxy, tls_bind_options, default_alpn_config, v4v6, v6only]) %> +<%= render_bind(bindings, p("ha_proxy.binding_ip"), 443, [accept_proxy, tls_bind_options, default_alpn_config, v4v6]) %> # Set this acl when the request is a route service request, used by ha_proxy.forward_true_client_ip_header and ha_proxy.forwarded_client_cert acl route_service_request hdr(X-Cf-Proxy-Signature) -m found <%- if disable_domain_fronting -%> @@ -816,7 +861,7 @@ frontend https-in # HTTPS Websockets Frontend {{{ frontend wss-in mode http -<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), 4443, [accept_proxy, tls_bind_options, v4v6, v6only]) %> +<%= render_bind(bindings, p("ha_proxy.binding_ip"), 4443, [accept_proxy, tls_bind_options, v4v6]) %> <%- if disable_domain_fronting -%> # Check whether the client is attempting domain fronting. # Ensure a host header exists to check against @@ -1085,7 +1130,7 @@ backend http-routed-backend-<%= prefix_hash %> frontend cf_tcp_routing mode tcp -<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), p("ha_proxy.tcp_routing.port_range"), [v4v6, v6only]) %> +<%= render_bind(bindings, p("ha_proxy.binding_ip"), p("ha_proxy.tcp_routing.port_range"), [v4v6]) %> default_backend cf_tcp_routers backend cf_tcp_routers @@ -1116,9 +1161,9 @@ end -%> frontend tcp-frontend_<%= tcp_proxy["name"]%> mode tcp <%- if tcp_proxy["ssl"] -%> -<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), tcp_proxy["port"], [tcp_accept_proxy, tls_bind_options, v4v6, v6only]) %> +<%= render_bind(bindings, p("ha_proxy.binding_ip"), tcp_proxy["port"], [tcp_accept_proxy, tls_bind_options, v4v6]) %> <%- else -%> -<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), tcp_proxy["port"], [tcp_accept_proxy, v4v6, v6only]) %> +<%= render_bind(bindings, p("ha_proxy.binding_ip"), tcp_proxy["port"], [tcp_accept_proxy, v4v6]) %> <%- end -%> <%- if p("ha_proxy.expect_proxy_cidrs", []).size > 0 -%> tcp-request connection expect-proxy layer4 if { src -f /var/vcap/jobs/haproxy/config/expect_proxy_cidrs.txt } @@ -1171,7 +1216,7 @@ backend tcp-<%= tcp_proxy["name"] %> <%- if tcp_proxy["health_check_http"] -%> listen health_check_http_tcp-<%= tcp_proxy["name"] %> -<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), tcp_proxy["health_check_http"], [v4v6, v6only]) %> +<%= render_bind(bindings, p("ha_proxy.binding_ip"), tcp_proxy["health_check_http"], [v4v6]) %> mode http monitor-uri /health <%- if p("ha_proxy.accept_proxy") && !p("ha_proxy.disable_health_check_proxy") -%> @@ -1182,7 +1227,7 @@ listen health_check_http_tcp-<%= tcp_proxy["name"] %> <%- if enable_additional_health_check_proxy -%> listen health_check_http_tcp-<%= tcp_proxy["name"] %>_proxy_protocol -<%= render_bind(binding_addresses, p("ha_proxy.binding_ip"), tcp_proxy["health_check_http"] + 1, ["accept-proxy", v4v6, v6only]) %> +<%= render_bind(bindings, p("ha_proxy.binding_ip"), tcp_proxy["health_check_http"] + 1, ["accept-proxy", v4v6]) %> mode http monitor-uri /health acl tcp-<%= tcp_proxy["name"] %>-routers_down nbsrv(tcp-<%= tcp_proxy["name"] %>) eq 0 diff --git a/spec/haproxy/templates/haproxy_config/bindings_spec.rb b/spec/haproxy/templates/haproxy_config/bindings_spec.rb new file mode 100644 index 000000000..dc3a0f2fe --- /dev/null +++ b/spec/haproxy/templates/haproxy_config/bindings_spec.rb @@ -0,0 +1,161 @@ +# frozen_string_literal: true + +require 'rspec' + +describe 'config/haproxy.config explicit bindings' do + let(:properties) { { 'bindings' => bindings } } + let(:rendered_config) { template.render({ 'ha_proxy' => properties }, consumes:) } + let(:haproxy_conf) { parse_haproxy_config(rendered_config) } + let(:bindings) { [{ 'address' => '0.0.0.0' }] } + let(:consumes) { [] } + + def render_with(bindings, additional_properties = {}) + template.render({ 'ha_proxy' => additional_properties.merge('bindings' => bindings) }) + end + + it 'renders an IPv4-only binding without a family mode' do + expect(haproxy_conf['frontend http-in']).to include('bind 0.0.0.0:80') + end + + it 'renders an IPv6 wildcard v6only binding' do + config = parse_haproxy_config(render_with([{ 'address' => '::', 'ipv6_mode' => 'v6only' }])) + + expect(config['frontend http-in']).to include('bind [::]:80 v6only') + end + + it 'renders an IPv6 wildcard v4v6 binding' do + config = parse_haproxy_config(render_with([{ 'address' => '::', 'ipv6_mode' => 'v4v6' }])) + + expect(config['frontend http-in']).to include('bind [::]:80 v4v6') + end + + it 'renders a specific IPv6 binding without a family mode' do + config = parse_haproxy_config(render_with([{ 'address' => 'fd00::12' }])) + + expect(config['frontend http-in']).to include('bind [fd00::12]:80') + end + + it 'preserves input order for separate IPv4 and IPv6 sockets' do + config = render_with([{ 'address' => '::', 'ipv6_mode' => 'v6only' }, + { 'address' => '0.0.0.0' }]) + + expect(config.index('bind [::]:80 v6only')).to be < config.index('bind 0.0.0.0:80') + end + + context 'with generated listeners and listener-specific options' do + let(:bindings) do + [{ 'address' => '0.0.0.0' }, { 'address' => '::', 'ipv6_mode' => 'v6only' }] + end + let(:properties) do + { + 'bindings' => bindings, + 'ssl_pem' => 'ssl pem contents', + 'enable_4443' => true, + 'enable_http2' => true, + 'accept_proxy' => true, + 'enable_health_check_http' => true, + 'enable_additional_health_check_proxy' => true, + 'stats_enable' => true, + 'stats_bind' => '*:9000', + 'tcp' => [{ + 'name' => 'redis', + 'port' => 6379, + 'backend_servers' => ['10.0.0.1'], + 'ssl' => true, + 'health_check_http' => 9095 + }] + } + end + let(:consumes) do + [Bosh::Template::Test::Link.new( + name: 'tcp_router', + instances: [Bosh::Template::Test::LinkInstance.new(address: 'tcp.cf.com')] + )] + end + + it 'applies every binding and listener-specific options without changing stats' do + expect(haproxy_conf['listen health_check_http_url']).to include('bind 0.0.0.0:8080', 'bind [::]:8080 v6only') + expect(haproxy_conf['listen health_check_http_url_proxy_protocol']).to include('bind 0.0.0.0:8081 accept-proxy', 'bind [::]:8081 v6only accept-proxy') + expect(haproxy_conf['frontend http-in']).to include('bind 0.0.0.0:80 accept-proxy', 'bind [::]:80 v6only accept-proxy') + https_binds = haproxy_conf['frontend https-in'].join("\n") + expect(https_binds).to include('bind 0.0.0.0:443 accept-proxy ssl', 'bind [::]:443 v6only accept-proxy ssl', 'alpn h2,http/1.1') + wss_binds = haproxy_conf['frontend wss-in'].join("\n") + expect(wss_binds).to include('bind 0.0.0.0:4443 accept-proxy ssl', 'bind [::]:4443 v6only accept-proxy ssl') + expect(haproxy_conf['frontend cf_tcp_routing']).to include('bind 0.0.0.0:1024-1123', 'bind [::]:1024-1123 v6only') + tcp_binds = haproxy_conf['frontend tcp-frontend_redis'].join("\n") + expect(tcp_binds).to include('bind 0.0.0.0:6379 accept-proxy ssl', 'bind [::]:6379 v6only accept-proxy ssl') + expect(haproxy_conf['listen health_check_http_tcp-redis']).to include('bind 0.0.0.0:9095', 'bind [::]:9095 v6only') + expect(haproxy_conf['listen health_check_http_tcp-redis_proxy_protocol']).to include('bind 0.0.0.0:9096 accept-proxy', 'bind [::]:9096 v6only accept-proxy') + expect(haproxy_conf['listen stats']).to include('bind *:9000') + end + end + + { + 'an empty array' => [], + 'a non-array' => { 'address' => '0.0.0.0' }, + 'a non-map entry' => ['0.0.0.0'], + 'an entry without address' => [{ 'ipv6_mode' => 'v6only' }], + 'an entry with an unknown key' => [{ 'address' => '0.0.0.0', 'unexpected' => true }], + 'an empty address' => [{ 'address' => '' }], + 'a hostname' => [{ 'address' => 'localhost' }], + 'a CIDR' => [{ 'address' => '10.0.0.0/8' }], + 'an address with a port' => [{ 'address' => '1.2.3.4:80' }], + 'a bracketed address' => [{ 'address' => '[::]' }], + 'an IPv6 zone ID' => [{ 'address' => 'fe80::1%eth0' }], + 'an IPv4-mapped IPv6 address' => [{ 'address' => '::ffff:192.0.2.1' }] + }.each do |description, invalid_bindings| + it "rejects #{description}" do + expect { render_with(invalid_bindings) }.to raise_error(/ha_proxy\.bindings/) + end + end + + it 'rejects ipv6_mode on IPv4' do + expect { render_with([{ 'address' => '0.0.0.0', 'ipv6_mode' => 'v6only' }]) }.to raise_error(/IPv4 addresses cannot specify ipv6_mode/) + end + + it 'requires an allowed ipv6_mode on the IPv6 wildcard' do + expect { render_with([{ 'address' => '::' }]) }.to raise_error(/requires ipv6_mode/) + expect { render_with([{ 'address' => '::', 'ipv6_mode' => 'invalid' }]) }.to raise_error(/requires ipv6_mode/) + end + + it 'rejects ipv6_mode on specific IPv6' do + expect { render_with([{ 'address' => 'fd00::12', 'ipv6_mode' => 'v6only' }]) }.to raise_error(/specific IPv6 addresses cannot specify ipv6_mode/) + end + + it 'rejects normalized duplicates' do + expect do + render_with([{ 'address' => 'fd00::12' }, { 'address' => 'fd00:0:0:0:0:0:0:12' }]) + end.to raise_error(/duplicate address/) + end + + it 'rejects IPv4 and IPv6 wildcard overlap with specific addresses' do + expect do + render_with([{ 'address' => '0.0.0.0' }, { 'address' => '192.0.2.10' }]) + end.to raise_error(/IPv4 wildcard/) + expect do + render_with([{ 'address' => '::', 'ipv6_mode' => 'v6only' }, { 'address' => 'fd00::12' }]) + end.to raise_error(/IPv6 wildcard/) + end + + it 'rejects a v4v6 IPv6 wildcard alongside IPv4' do + expect do + render_with([{ 'address' => '::', 'ipv6_mode' => 'v4v6' }, { 'address' => '0.0.0.0' }]) + end.to raise_error(/v4v6 cannot be combined with an IPv4 binding/) + end + + it 'rejects non-empty legacy binding settings' do + expect do + render_with([{ 'address' => '0.0.0.0' }], 'binding_ip' => '192.0.2.10') + end.to raise_error(/non-empty ha_proxy\.binding_ip/) + expect do + render_with([{ 'address' => '::', 'ipv6_mode' => 'v4v6' }], 'v4v6' => true) + end.to raise_error(/ha_proxy\.bindings cannot be combined with ha_proxy\.v4v6/) + end + + it 'does not evaluate bindings when raw_config is used' do + expect(template.render({ 'ha_proxy' => { + 'raw_config' => 'global', + 'bindings' => [] + } })).to eq("global\n") + end +end diff --git a/spec/haproxy/templates/haproxy_config/frontend_http_spec.rb b/spec/haproxy/templates/haproxy_config/frontend_http_spec.rb index a0d276c6e..ed0bd7375 100644 --- a/spec/haproxy/templates/haproxy_config/frontend_http_spec.rb +++ b/spec/haproxy/templates/haproxy_config/frontend_http_spec.rb @@ -38,9 +38,9 @@ { 'v4v6' => false, 'binding_ip' => '::' } end - it 'enables v6only' do - expect(frontend_http).to include('bind :::80 v6only') - expect(frontend_http).not_to include('v4v6') + it 'preserves origin/master if_p handling' do + expect(frontend_http).to include('bind :::80 v4v6') + expect(frontend_http).not_to include('v6only') end end @@ -49,9 +49,9 @@ { 'binding_ip' => '::' } end - it 'enables v6only' do - expect(frontend_http).to include('bind :::80 v6only') - expect(frontend_http).not_to include('v4v6') + it 'preserves origin/master if_p handling' do + expect(frontend_http).to include('bind :::80 v4v6') + expect(frontend_http).not_to include('v6only') end end @@ -112,9 +112,9 @@ end end - context 'when ha_proxy.binding_ips is provided' do + context 'when ha_proxy.bindings is provided' do let(:properties) do - { 'binding_ips' => ['0.0.0.0', '::'] } + { 'bindings' => [{ 'address' => '0.0.0.0' }, { 'address' => '::', 'ipv6_mode' => 'v6only' }] } end it 'generates two bind directives for HTTP' do diff --git a/spec/haproxy/templates/haproxy_config/frontend_https_spec.rb b/spec/haproxy/templates/haproxy_config/frontend_https_spec.rb index 16ff8cbd4..4318b82fe 100644 --- a/spec/haproxy/templates/haproxy_config/frontend_https_spec.rb +++ b/spec/haproxy/templates/haproxy_config/frontend_https_spec.rb @@ -34,8 +34,9 @@ default_properties.merge({ 'v4v6' => true, 'binding_ip' => '0.0.0.0' }) end - it 'aborts with a meaningful error message' do - expect { frontend_https }.to raise_error(/v4v6 is enabled but ha_proxy.binding_ip is not '::'/) + it 'preserves the legacy IPv4 bind' do + expect(frontend_https).to include('bind 0.0.0.0:443 ssl crt /var/vcap/jobs/haproxy/config/ssl') + expect(frontend_https).not_to include('v4v6') end end @@ -54,9 +55,9 @@ default_properties.merge({ 'v4v6' => false, 'binding_ip' => '::' }) end - it 'enables v6only' do - expect(frontend_https).to include('bind :::443 ssl crt /var/vcap/jobs/haproxy/config/ssl v6only') - expect(frontend_https).not_to include('v4v6') + it 'preserves origin/master if_p handling' do + expect(frontend_https).to include('bind :::443 ssl crt /var/vcap/jobs/haproxy/config/ssl v4v6') + expect(frontend_https).not_to include('v6only') end end @@ -65,9 +66,9 @@ default_properties.merge({ 'binding_ip' => '::' }) end - it 'enables v6only' do - expect(frontend_https).to include('bind :::443 ssl crt /var/vcap/jobs/haproxy/config/ssl v6only') - expect(frontend_https).not_to include('v4v6') + it 'preserves origin/master if_p handling' do + expect(frontend_https).to include('bind :::443 ssl crt /var/vcap/jobs/haproxy/config/ssl v4v6') + expect(frontend_https).not_to include('v6only') end end @@ -106,9 +107,9 @@ end end - context 'when ha_proxy.binding_ips is provided' do + context 'when ha_proxy.bindings is provided' do let(:properties) do - default_properties.merge({ 'binding_ips' => ['0.0.0.0', '::'] }) + default_properties.merge({ 'bindings' => [{ 'address' => '0.0.0.0' }, { 'address' => '::', 'ipv6_mode' => 'v6only' }] }) end it 'generates two bind directives' do @@ -122,27 +123,27 @@ context 'when ha_proxy.v4v6 is also true' do let(:properties) do - default_properties.merge({ 'binding_ips' => ['0.0.0.0', '::'], 'v4v6' => true, 'binding_ip' => '::' }) + default_properties.merge({ 'bindings' => [{ 'address' => '::', 'ipv6_mode' => 'v4v6' }], 'v4v6' => true }) end it 'aborts with a meaningful error message' do - expect { frontend_https }.to raise_error(/cannot be combined with ha_proxy.v4v6/) + expect { frontend_https }.to raise_error(/ha_proxy.bindings cannot be combined with ha_proxy.v4v6/) end end context 'when ha_proxy.binding_ip is also set' do let(:properties) do - default_properties.merge({ 'binding_ips' => ['0.0.0.0', '::'], 'binding_ip' => '1.2.3.4' }) + default_properties.merge({ 'bindings' => [{ 'address' => '0.0.0.0' }], 'binding_ip' => '1.2.3.4' }) end it 'aborts with a meaningful error message' do - expect { frontend_https }.to raise_error(/ha_proxy.binding_ip cannot be set when ha_proxy.binding_ips is provided/) + expect { frontend_https }.to raise_error(/ha_proxy.bindings cannot be combined with a non-empty ha_proxy.binding_ip/) end end - context 'when binding_ips contains duplicates' do + context 'when bindings contains duplicates' do let(:properties) do - default_properties.merge({ 'binding_ips' => ['0.0.0.0', '0.0.0.0'] }) + default_properties.merge({ 'bindings' => [{ 'address' => '0.0.0.0' }, { 'address' => '0.0.0.0' }] }) end it 'aborts with a meaningful error message' do @@ -150,9 +151,9 @@ end end - context 'when binding_ips contains an invalid IP' do + context 'when bindings contains an invalid IP' do let(:properties) do - default_properties.merge({ 'binding_ips' => ['not-an-ip'] }) + default_properties.merge({ 'bindings' => [{ 'address' => 'not-an-ip' }] }) end it 'aborts with a meaningful error message' do @@ -160,9 +161,9 @@ end end - context 'when binding_ips is empty' do + context 'when bindings is empty' do let(:properties) do - default_properties.merge({ 'binding_ips' => [] }) + default_properties.merge({ 'bindings' => [] }) end it 'aborts with a meaningful error message' do diff --git a/spec/haproxy/templates/haproxy_config/frontend_tcp_spec.rb b/spec/haproxy/templates/haproxy_config/frontend_tcp_spec.rb index d63645219..5fa460982 100644 --- a/spec/haproxy/templates/haproxy_config/frontend_tcp_spec.rb +++ b/spec/haproxy/templates/haproxy_config/frontend_tcp_spec.rb @@ -77,9 +77,9 @@ end end - context 'when ha_proxy.binding_ips is provided' do + context 'when ha_proxy.bindings is provided' do let(:properties) do - default_properties.merge({ 'binding_ips' => ['0.0.0.0', '::'] }) + default_properties.merge({ 'bindings' => [{ 'address' => '0.0.0.0' }, { 'address' => '::', 'ipv6_mode' => 'v6only' }] }) end it 'generates two bind directives for TCP frontends' do diff --git a/spec/haproxy/templates/haproxy_config/frontend_wss_spec.rb b/spec/haproxy/templates/haproxy_config/frontend_wss_spec.rb index 30ba67e4a..a795dbcdd 100644 --- a/spec/haproxy/templates/haproxy_config/frontend_wss_spec.rb +++ b/spec/haproxy/templates/haproxy_config/frontend_wss_spec.rb @@ -46,9 +46,9 @@ default_properties.merge({ 'v4v6' => false, 'binding_ip' => '::' }) end - it 'enables v6only' do - expect(frontend_wss).to include('bind :::4443 ssl crt /var/vcap/jobs/haproxy/config/ssl v6only') - expect(frontend_wss).not_to include('v4v6') + it 'preserves origin/master if_p handling' do + expect(frontend_wss).to include('bind :::4443 ssl crt /var/vcap/jobs/haproxy/config/ssl v4v6') + expect(frontend_wss).not_to include('v6only') end end @@ -57,9 +57,9 @@ default_properties.merge({ 'binding_ip' => '::' }) end - it 'enables v6only' do - expect(frontend_wss).to include('bind :::4443 ssl crt /var/vcap/jobs/haproxy/config/ssl v6only') - expect(frontend_wss).not_to include('v4v6') + it 'preserves origin/master if_p handling' do + expect(frontend_wss).to include('bind :::4443 ssl crt /var/vcap/jobs/haproxy/config/ssl v4v6') + expect(frontend_wss).not_to include('v6only') end end @@ -98,9 +98,9 @@ end end - context 'when ha_proxy.binding_ips is provided' do + context 'when ha_proxy.bindings is provided' do let(:properties) do - default_properties.merge({ 'binding_ips' => ['0.0.0.0', '::'] }) + default_properties.merge({ 'bindings' => [{ 'address' => '0.0.0.0' }, { 'address' => '::', 'ipv6_mode' => 'v6only' }] }) end it 'generates two bind directives for WSS' do From 1986ca40695b7939008c363cb1be0887b64d82e9 Mon Sep 17 00:00:00 2001 From: Jakob Bergmeier Date: Thu, 29 Oct 2026 21:05:58 +0100 Subject: [PATCH 10/12] docs: document explicit dual-stack bindings Signed-off-by: Jakob Bergmeier --- README.md | 2 +- docs/dual-stack.md | 131 +++++++++++++++++++++++++-------------------- 2 files changed, 74 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index 337656ac0..cbdd24e4d 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ To make alterations to the deployment you can use the `bosh deploy [-o operator- ## Documentation - [External Certificates](/docs/external_certs.md) - Using HAProxy with additional external certificates -- [Dual-Stack Configuration](docs/dual-stack.md) +- [Explicit Dual-Stack Bindings](docs/dual-stack.md) - [Mutual TLS](/docs/mutual_tls.md) - Mutual TLS configuration - [Rate Limiting](/docs/rate_limiting.md) - Client IP based rate limiting - [Keepalived](/docs/keepalived.md) - Keepalived integration for high availability diff --git a/docs/dual-stack.md b/docs/dual-stack.md index e6c82e314..e27962cf5 100644 --- a/docs/dual-stack.md +++ b/docs/dual-stack.md @@ -1,93 +1,110 @@ # Dual-Stack (IPv4 + IPv6) Configuration -HAProxy can serve traffic on both IPv4 and IPv6 using two approaches. +Use `ha_proxy.bindings` to configure the sockets used by every generated +listener. This includes HTTP, HTTPS, WSS, HTTP health checks, CF TCP routing, +generic TCP frontends, and TCP health checks. The stats listener continues to +use `ha_proxy.stats_bind`. -## Option A: Single Socket with v4v6 +## Explicit bindings -A single IPv6 wildcard socket accepts both IPv4 and IPv6 connections: +Each entry has an `address`. Addresses are IP literals, not hostnames, CIDRs, +ports, bracketed IPv6 literals, or zone IDs. Input order is retained in the +generated `bind` directives. + +### IPv4 only ```yaml ha_proxy: - binding_ip: "::" - v4v6: true + bindings: + - address: 0.0.0.0 ``` -This generates one bind directive per listener: +IPv4 is inherently IPv4-only, so it has no `ipv6_mode` option: + ``` -bind :::443 ssl crt /var/vcap/jobs/haproxy/config/ssl v4v6 +bind 0.0.0.0:443 ssl crt /var/vcap/jobs/haproxy/config/ssl ``` -**Note:** `v4v6` only takes effect when `binding_ip` is `::`. +### IPv6 wildcard -## IPv6-only mode (v6only) - -When `binding_ip` is set to `::` without enabling `v4v6`, the `v6only` -option is automatically added to all bind directives: +The IPv6 wildcard must explicitly state how it handles IPv4: ```yaml ha_proxy: - binding_ip: "::" - # v4v6 not set or false + bindings: + - address: "::" + ipv6_mode: v6only ``` -This generates: +This produces an IPv6-only socket: + ``` -bind :::443 ssl crt /var/vcap/jobs/haproxy/config/ssl v6only +bind [::]:443 v6only ssl crt /var/vcap/jobs/haproxy/config/ssl ``` -The `v6only` option ensures the IPv6 socket does not accept IPv4 -connections, regardless of the kernel's `net.ipv6.bindv6only` sysctl -setting. Without it, behavior depends on the kernel default -- on most -Linux distributions (including standard BOSH stemcells), an IPv6 wildcard -socket silently accepts IPv4 connections as `::ffff:` mapped addresses. +For a single dual-stack socket, use `v4v6` instead: -**Breaking change:** Deployments using `binding_ip: "::"` without -`v4v6: true` that relied on the kernel default to accept IPv4 traffic -must now explicitly set `v4v6: true` to restore dual-stack behavior. +```yaml +ha_proxy: + bindings: + - address: "::" + ipv6_mode: v4v6 +``` -## Option B: Separate Sockets with binding_ips +``` +bind [::]:443 v4v6 ssl crt /var/vcap/jobs/haproxy/config/ssl +``` -For environments that require distinct IPv4 and IPv6 sockets: +Only wildcard `::` accepts `ipv6_mode`, because it can be an IPv4 wildcard as +well. A specific IPv6 address cannot act as an IPv4 wildcard and therefore has +no mode: ```yaml ha_proxy: - binding_ips: - - 0.0.0.0 - - "::" + bindings: + - address: "fd00::12" ``` -This generates two bind directives per listener: -``` -bind 0.0.0.0:443 ssl crt /var/vcap/jobs/haproxy/config/ssl -bind [::]:443 v6only ssl crt /var/vcap/jobs/haproxy/config/ssl -``` +### Separate IPv4 and IPv6 sockets -IPv6 addresses automatically receive the `v6only` option to prevent -them from also accepting IPv4 connections. +Use an IPv4 wildcard and an IPv6-only wildcard when separate sockets are +required: -### Scope +```yaml +ha_proxy: + bindings: + - address: 0.0.0.0 + - address: "::" + ipv6_mode: v6only +``` -`binding_ips` applies globally to all generated listeners: -HTTP, HTTPS, WSS (port 4443), health checks, CF TCP routing, and -generic TCP frontends. +All listener-specific options, including TLS, ALPN, and PROXY protocol, are +rendered on every generated bind directive. -When `binding_ips` is set, `binding_ip` is ignored for generated -listeners. +## Validation and compatibility -### Constraints +`bindings` is a non-empty array of maps. Duplicate normalized addresses are +rejected, as are wildcard/specific combinations in the same address family. +An IPv6 wildcard with `ipv6_mode: v4v6` cannot be combined with an IPv4 +binding. `bindings` cannot be used with a non-empty legacy `binding_ip` or +with `v4v6: true`; set those legacy values to their defaults (`""` and +`false`) before migrating. -- `binding_ips` cannot be combined with `v4v6: true` -- `binding_ips` cannot be combined with a non-empty `binding_ip` -- Entries must be valid IP address literals (no hostnames, CIDRs, or ports) -- Duplicate addresses are rejected -- The list must not be empty when set +When `bindings` is absent, the existing `binding_ip` and `v4v6` behavior is +unchanged, including its historical `if_p("ha_proxy.v4v6")` handling. Do not +rely on a legacy bare `binding_ip: "::"` IPv6 socket to select an IPv4 policy: +whether that socket also accepts IPv4 is kernel-dependent +(`net.ipv6.bindv6only`). Use explicit bindings to make the choice portable and +unambiguous. -### Migrating from frontend_config +## Migrating from `frontend_config` -If you previously used `frontend_config` to add a second bind line, -you can replace it with `binding_ips` and remove the raw configuration: +If `frontend_config` was used to add a second bind directive, replace the +manual configuration with explicit bindings. This avoids duplicating TLS and +other listener options and applies the same sockets to all generated listeners. Before: + ```yaml ha_proxy: binding_ip: 0.0.0.0 @@ -97,13 +114,11 @@ ha_proxy: ``` After: + ```yaml ha_proxy: - binding_ips: - - 0.0.0.0 - - "::" + bindings: + - address: 0.0.0.0 + - address: "::" + ipv6_mode: v6only ``` - -The `binding_ips` approach is safer because it automatically applies -all TLS, ALPN, mTLS, and PROXY protocol options to every bind directive, -and does not require disabling other frontends. From 6a80a7285814641722f655ce2a1d9bfb216a0590 Mon Sep 17 00:00:00 2001 From: Jakob Bergmeier Date: Thu, 29 Oct 2026 21:17:12 +0100 Subject: [PATCH 11/12] fix: validate legacy binding IP conflicts Signed-off-by: Jakob Bergmeier --- docs/dual-stack.md | 12 +++++++----- jobs/haproxy/spec | 7 ++++--- jobs/haproxy/templates/haproxy.config.erb | 4 ++-- .../templates/haproxy_config/bindings_spec.rb | 14 +++++++++++++- .../haproxy_config/frontend_https_spec.rb | 2 +- 5 files changed, 27 insertions(+), 12 deletions(-) diff --git a/docs/dual-stack.md b/docs/dual-stack.md index e27962cf5..c72bcbed5 100644 --- a/docs/dual-stack.md +++ b/docs/dual-stack.md @@ -91,11 +91,13 @@ with `v4v6: true`; set those legacy values to their defaults (`""` and `false`) before migrating. When `bindings` is absent, the existing `binding_ip` and `v4v6` behavior is -unchanged, including its historical `if_p("ha_proxy.v4v6")` handling. Do not -rely on a legacy bare `binding_ip: "::"` IPv6 socket to select an IPv4 policy: -whether that socket also accepts IPv4 is kernel-dependent -(`net.ipv6.bindv6only`). Use explicit bindings to make the choice portable and -unambiguous. +unchanged, including its historical `if_p("ha_proxy.v4v6")` handling. Because +`v4v6` has a false default, that legacy presence-based block runs when +`v4v6` is false or omitted. As a result, legacy `binding_ip: "::"` currently +renders `v4v6` regardless of whether `v4v6` is true, false, or omitted. This +compatibility quirk is intentionally preserved. Use `bindings` with +`ipv6_mode: v6only` or `ipv6_mode: v4v6` to explicitly select the IPv6/IPv4 +behavior. ## Migrating from `frontend_config` diff --git a/jobs/haproxy/spec b/jobs/haproxy/spec index 9d4accfd0..c69cb71b7 100644 --- a/jobs/haproxy/spec +++ b/jobs/haproxy/spec @@ -582,17 +582,18 @@ properties: ha_proxy.enable_additional_health_check_proxy: description: "Enable the additional health check listener with use of the PROXY protocol" ha_proxy.binding_ip: - description: "If there are multiple ethernet interfaces, specify which one to bind. Set to `::` to bind to all IPv6 interfaces. To also accept IPv4, set ha_proxy.v4v6 to true. IPv6 must be enabled on the HAProxy VM in the deployment manifest." + description: "If there are multiple ethernet interfaces, specify which one to bind. Set to `::` to bind to all IPv6 interfaces. For compatibility, the legacy renderer adds `v4v6` whenever this is `::`, even if ha_proxy.v4v6 is false or omitted; this presence-based behavior is intentionally preserved. Use ha_proxy.bindings to explicitly select `v6only` or `v4v6`. IPv6 must be enabled on the HAProxy VM in the deployment manifest." default: "" ha_proxy.v4v6: - description: "Boolean, disabled by default. Enables binding to all IPv4 and IPv6 interfaces. Only applies if `ha_proxy.binding_ip` is set to `::`." + description: "Legacy compatibility property, disabled by default. The legacy renderer still adds `v4v6` when ha_proxy.binding_ip is `::` even if this is false or omitted, because of its historical presence-based behavior. This behavior is intentionally preserved; use ha_proxy.bindings with ipv6_mode to explicitly select `v6only` or `v4v6`." default: false ha_proxy.bindings: description: | Optional explicit list of bind sockets for all generated listeners. Each entry requires address. IPv4 addresses have no mode. The IPv6 wildcard :: requires ipv6_mode, either v6only or v4v6; specific IPv6 addresses have no mode. - Cannot be combined with ha_proxy.v4v6: true or a non-empty ha_proxy.binding_ip. + Cannot be combined with ha_proxy.v4v6: true or a legacy ha_proxy.binding_ip + other than nil or an empty string. example: - address: 0.0.0.0 - address: "::" diff --git a/jobs/haproxy/templates/haproxy.config.erb b/jobs/haproxy/templates/haproxy.config.erb index 36c6e4086..c0edccbae 100644 --- a/jobs/haproxy/templates/haproxy.config.erb +++ b/jobs/haproxy/templates/haproxy.config.erb @@ -205,8 +205,8 @@ if_p("ha_proxy.bindings") do |configured_bindings| abort("Conflicting configuration: ha_proxy.bindings cannot be combined with ha_proxy.v4v6: true") end legacy_binding_ip = p("ha_proxy.binding_ip") - unless legacy_binding_ip.nil? || legacy_binding_ip.empty? - abort("Conflicting configuration: ha_proxy.bindings cannot be combined with a non-empty ha_proxy.binding_ip") + unless legacy_binding_ip.nil? || (legacy_binding_ip.is_a?(String) && legacy_binding_ip.empty?) + abort("Conflicting configuration: ha_proxy.bindings requires ha_proxy.binding_ip to be nil or an empty string") end seen = {} diff --git a/spec/haproxy/templates/haproxy_config/bindings_spec.rb b/spec/haproxy/templates/haproxy_config/bindings_spec.rb index dc3a0f2fe..b7b944f2e 100644 --- a/spec/haproxy/templates/haproxy_config/bindings_spec.rb +++ b/spec/haproxy/templates/haproxy_config/bindings_spec.rb @@ -146,12 +146,24 @@ def render_with(bindings, additional_properties = {}) it 'rejects non-empty legacy binding settings' do expect do render_with([{ 'address' => '0.0.0.0' }], 'binding_ip' => '192.0.2.10') - end.to raise_error(/non-empty ha_proxy\.binding_ip/) + end.to raise_error(/requires ha_proxy\.binding_ip to be nil or an empty string/) expect do render_with([{ 'address' => '::', 'ipv6_mode' => 'v4v6' }], 'v4v6' => true) end.to raise_error(/ha_proxy\.bindings cannot be combined with ha_proxy\.v4v6/) end + { + 'false' => false, + 'an integer' => 12_345, + 'a collection' => ['192.0.2.10'] + }.each do |description, malformed_binding_ip| + it "rejects #{description} as a legacy binding_ip when bindings are set" do + expect do + render_with([{ 'address' => '0.0.0.0' }], 'binding_ip' => malformed_binding_ip) + end.to raise_error(/requires ha_proxy\.binding_ip to be nil or an empty string/) + end + end + it 'does not evaluate bindings when raw_config is used' do expect(template.render({ 'ha_proxy' => { 'raw_config' => 'global', diff --git a/spec/haproxy/templates/haproxy_config/frontend_https_spec.rb b/spec/haproxy/templates/haproxy_config/frontend_https_spec.rb index 4318b82fe..aac55068c 100644 --- a/spec/haproxy/templates/haproxy_config/frontend_https_spec.rb +++ b/spec/haproxy/templates/haproxy_config/frontend_https_spec.rb @@ -137,7 +137,7 @@ end it 'aborts with a meaningful error message' do - expect { frontend_https }.to raise_error(/ha_proxy.bindings cannot be combined with a non-empty ha_proxy.binding_ip/) + expect { frontend_https }.to raise_error(/requires ha_proxy.binding_ip to be nil or an empty string/) end end From 92a3463fd38d5d866776757244145d9884bd91ff Mon Sep 17 00:00:00 2001 From: Jakob Bergmeier Date: Fri, 30 Oct 2026 18:15:55 +0100 Subject: [PATCH 12/12] fix: Code efficiency Signed-off-by: Jakob Bergmeier --- jobs/haproxy/templates/haproxy.config.erb | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/jobs/haproxy/templates/haproxy.config.erb b/jobs/haproxy/templates/haproxy.config.erb index c0edccbae..260c2e74d 100644 --- a/jobs/haproxy/templates/haproxy.config.erb +++ b/jobs/haproxy/templates/haproxy.config.erb @@ -220,16 +220,17 @@ if_p("ha_proxy.bindings") do |configured_bindings| abort("ha_proxy.bindings: each entry must be a map, got: #{binding.inspect}") end - keys = binding.keys.map(&:to_s) - unless keys.include?("address") && (keys - %w[address ipv6_mode]).empty? && keys.uniq.length == keys.length + keys = binding.keys + unless keys.include?("address") && (keys - %w[address ipv6_mode]).empty? abort("ha_proxy.bindings: each entry requires address and only permits ipv6_mode where applicable") end - address = binding["address"] || binding[:address] - ipv6_mode_provided = binding.key?("ipv6_mode") || binding.key?(:ipv6_mode) - ipv6_mode = binding["ipv6_mode"] || binding[:ipv6_mode] - unless address.is_a?(String) && !address.empty? && address == address.strip && address !~ /[\[\]\/%]/ - abort("ha_proxy.bindings: address must be a non-empty, unbracketed IP literal, got: #{address.inspect}") + address = binding["address"] + ipv6_mode_provided = binding.key?("ipv6_mode") + ipv6_mode = binding["ipv6_mode"] + + if address.to_s =~ %r{[/%]} + abort("ha_proxy.bindings: address must be a plain IP literal (no CIDR or zone ID): #{address.inspect}") end begin