diff --git a/README.md b/README.md index 4da0389ab..cbdd24e4d 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 +- [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 new file mode 100644 index 000000000..c72bcbed5 --- /dev/null +++ b/docs/dual-stack.md @@ -0,0 +1,126 @@ +# Dual-Stack (IPv4 + IPv6) Configuration + +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`. + +## Explicit bindings + +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: + bindings: + - address: 0.0.0.0 +``` + +IPv4 is inherently IPv4-only, so it has no `ipv6_mode` option: + +``` +bind 0.0.0.0:443 ssl crt /var/vcap/jobs/haproxy/config/ssl +``` + +### IPv6 wildcard + +The IPv6 wildcard must explicitly state how it handles IPv4: + +```yaml +ha_proxy: + bindings: + - address: "::" + ipv6_mode: v6only +``` + +This produces an IPv6-only socket: + +``` +bind [::]:443 v6only ssl crt /var/vcap/jobs/haproxy/config/ssl +``` + +For a single dual-stack socket, use `v4v6` instead: + +```yaml +ha_proxy: + bindings: + - address: "::" + ipv6_mode: v4v6 +``` + +``` +bind [::]:443 v4v6 ssl crt /var/vcap/jobs/haproxy/config/ssl +``` + +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: + bindings: + - address: "fd00::12" +``` + +### Separate IPv4 and IPv6 sockets + +Use an IPv4 wildcard and an IPv6-only wildcard when separate sockets are +required: + +```yaml +ha_proxy: + bindings: + - address: 0.0.0.0 + - address: "::" + ipv6_mode: v6only +``` + +All listener-specific options, including TLS, ALPN, and PROXY protocol, are +rendered on every generated bind directive. + +## Validation and compatibility + +`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. + +When `bindings` is absent, the existing `binding_ip` and `v4v6` behavior is +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` + +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 + disable_http: true + frontend_config: | + bind :::443 ssl crt /var/vcap/jobs/haproxy/config/ssl +``` + +After: + +```yaml +ha_proxy: + bindings: + - address: 0.0.0.0 + - address: "::" + ipv6_mode: v6only +``` diff --git a/jobs/haproxy/spec b/jobs/haproxy/spec index 7da59dee4..c69cb71b7 100644 --- a/jobs/haproxy/spec +++ b/jobs/haproxy/spec @@ -582,11 +582,22 @@ 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. 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 legacy ha_proxy.binding_ip + other than nil or an empty string. + example: + - 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 03d334e0c..260c2e74d 100644 --- a/jobs/haproxy/templates/haproxy.config.erb +++ b/jobs/haproxy/templates/haproxy.config.erb @@ -17,6 +17,18 @@ end end + 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(' ') + " 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") %> @@ -173,7 +185,7 @@ if ![true, false, "true", "false", "mtls_only"].include?(disable_domain_fronting end # }}} -# IPv4 and IPv6 binding (v4v6) Option {{{ +# Legacy IPv4 and IPv6 binding (v4v6) Option {{{ v4v6 = "" if_p("ha_proxy.v4v6") do if p("ha_proxy.binding_ip") == "::" @@ -182,6 +194,95 @@ if_p("ha_proxy.v4v6") do end # }}} +# Explicit Bindings Option {{{ +require 'ipaddr' +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") == true + 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.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 = {} + 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 + 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"] + 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 + parsed = IPAddr.new(address) + rescue IPAddr::InvalidAddressError + 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.bindings: duplicate address: #{address}") + end + seen[normalized] = true + + if parsed.ipv6? + 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 + 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 +# }}} + # ALPN Option {{{ default_alpn_config = "" if p("ha_proxy.enable_http2") @@ -398,7 +499,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(bindings, p("ha_proxy.binding_ip"), p("ha_proxy.health_check_port"), [v4v6]) %> mode http option httpclose monitor-uri /health @@ -411,7 +512,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(bindings, p("ha_proxy.binding_ip"), p("ha_proxy.health_check_port") + 1, ["accept-proxy", v4v6]) %> mode http option httpclose monitor-uri /health @@ -444,7 +545,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(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 -%> @@ -558,7 +659,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(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 -%> @@ -761,7 +862,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(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 @@ -1030,7 +1131,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(bindings, p("ha_proxy.binding_ip"), p("ha_proxy.tcp_routing.port_range"), [v4v6]) %> default_backend cf_tcp_routers backend cf_tcp_routers @@ -1061,9 +1162,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(bindings, 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(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 } @@ -1116,7 +1217,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(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") -%> @@ -1127,7 +1228,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(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..b7b944f2e --- /dev/null +++ b/spec/haproxy/templates/haproxy_config/bindings_spec.rb @@ -0,0 +1,173 @@ +# 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(/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', + '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 8f64048be..ed0bd7375 100644 --- a/spec/haproxy/templates/haproxy_config/frontend_http_spec.rb +++ b/spec/haproxy/templates/haproxy_config/frontend_http_spec.rb @@ -29,7 +29,29 @@ end it 'enables ipv6' do - expect(frontend_http).to include('bind :::80 v4v6') + expect(frontend_http).to include('bind :::80 v4v6') + end + end + + context 'when ha_proxy.v4v6 is false and binding_ip is ::' do + let(:properties) do + { 'v4v6' => false, 'binding_ip' => '::' } + end + + 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 + + context 'when ha_proxy.v4v6 is not set and binding_ip is ::' do + let(:properties) do + { 'binding_ip' => '::' } + end + + 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 @@ -90,6 +112,17 @@ end end + context 'when ha_proxy.bindings is provided' do + let(:properties) do + { 'bindings' => [{ 'address' => '0.0.0.0' }, { 'address' => '::', 'ipv6_mode' => 'v6only' }] } + 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 2c447bb5a..aac55068c 100644 --- a/spec/haproxy/templates/haproxy_config/frontend_https_spec.rb +++ b/spec/haproxy/templates/haproxy_config/frontend_https_spec.rb @@ -17,16 +17,27 @@ 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 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 '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 context 'when ha_proxy.v4v6 is true and binding_ip is ::' do @@ -35,7 +46,29 @@ 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 + + context 'when ha_proxy.v4v6 is false and binding_ip is ::' do + let(:properties) do + default_properties.merge({ 'v4v6' => false, 'binding_ip' => '::' }) + end + + 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 + + context 'when ha_proxy.v4v6 is not set and binding_ip is ::' do + let(:properties) do + default_properties.merge({ 'binding_ip' => '::' }) + end + + 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 @@ -74,6 +107,71 @@ end end + context 'when ha_proxy.bindings is provided' do + let(:properties) do + default_properties.merge({ 'bindings' => [{ 'address' => '0.0.0.0' }, { 'address' => '::', 'ipv6_mode' => 'v6only' }] }) + 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({ 'bindings' => [{ 'address' => '::', 'ipv6_mode' => 'v4v6' }], 'v4v6' => true }) + end + + it 'aborts with a meaningful error message' do + 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({ '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(/requires ha_proxy.binding_ip to be nil or an empty string/) + end + end + + context 'when bindings contains duplicates' do + let(:properties) do + default_properties.merge({ 'bindings' => [{ 'address' => '0.0.0.0' }, { 'address' => '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 bindings contains an invalid IP' do + let(:properties) do + default_properties.merge({ 'bindings' => [{ 'address' => '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 bindings is empty' do + let(:properties) do + default_properties.merge({ 'bindings' => [] }) + 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 }) @@ -135,7 +233,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 @@ -144,7 +242,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 @@ -166,7 +264,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 @@ -796,7 +894,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..5fa460982 100644 --- a/spec/haproxy/templates/haproxy_config/frontend_tcp_spec.rb +++ b/spec/haproxy/templates/haproxy_config/frontend_tcp_spec.rb @@ -70,13 +70,31 @@ 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 + context 'when ha_proxy.bindings is provided' do + let(:properties) do + default_properties.merge({ 'bindings' => [{ 'address' => '0.0.0.0' }, { 'address' => '::', 'ipv6_mode' => 'v6only' }] }) + 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 { @@ -97,7 +115,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 +135,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 +148,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 c5a7eaa4d..a795dbcdd 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,29 @@ 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 + + context 'when ha_proxy.v4v6 is false and binding_ip is ::' do + let(:properties) do + default_properties.merge({ 'v4v6' => false, 'binding_ip' => '::' }) + end + + 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 + + context 'when ha_proxy.v4v6 is not set and binding_ip is ::' do + let(:properties) do + default_properties.merge({ 'binding_ip' => '::' }) + end + + 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 @@ -76,6 +98,21 @@ end end + context 'when ha_proxy.bindings is provided' do + let(:properties) do + default_properties.merge({ 'bindings' => [{ 'address' => '0.0.0.0' }, { 'address' => '::', 'ipv6_mode' => 'v6only' }] }) + 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 }) @@ -127,7 +164,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 @@ -136,7 +173,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 @@ -158,7 +195,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