From e2fe0e8c9fd90f44d062795f300efebf747a4bca Mon Sep 17 00:00:00 2001 From: Seyeong Kim Date: Thu, 27 Nov 2025 00:51:26 +0000 Subject: [PATCH 1/4] Add proxy protocol support to preserve actual client IP Currently, log shows proxy IP but need actual client IP. This patch adds required module and configuration to setup options for Apache2 templates and haproxy templates. Use PROXY protocol v1 (send-proxy) instead of v2 (send-proxy-v2) to avoid Apache mod_remoteip incompatibility with PROXYv2 LOCAL command (unsupported command 0x20) during health checks. Also adds missing 'check' keyword to haproxy server lines to ensure health checks are properly performed. Closes-bug: LP#2107999 Signed-off-by: Seyeong Kim (cherry picked from commit f516806411319fd967dee205701c2fabceb3144f) --- charmhelpers/contrib/openstack/context.py | 18 ++- .../contrib/openstack/templates/haproxy.cfg | 8 ++ .../templates/openstack_https_frontend | 4 + .../templates/wsgi-openstack-api.conf | 12 ++ tests/contrib/openstack/test_os_contexts.py | 103 +++++++++++++++++- 5 files changed, 143 insertions(+), 2 deletions(-) diff --git a/charmhelpers/contrib/openstack/context.py b/charmhelpers/contrib/openstack/context.py index b25612cbc..0afc510f4 100644 --- a/charmhelpers/contrib/openstack/context.py +++ b/charmhelpers/contrib/openstack/context.py @@ -1059,6 +1059,9 @@ def __call__(self): if config('haproxy-connect-timeout'): ctxt['haproxy_connect_timeout'] = config('haproxy-connect-timeout') + if config('haproxy-enable-proxy-protocol'): + ctxt['haproxy_enable_proxy_protocol'] = True + if config('prefer-ipv6'): ctxt['local_host'] = 'ip6-localhost' ctxt['haproxy_host'] = '::' @@ -1150,7 +1153,8 @@ class ApacheSSLContext(OSContextGenerator): user = group = 'root' def enable_modules(self): - cmd = ['a2enmod', 'ssl', 'proxy', 'proxy_http', 'headers'] + # NOTE(seyeongkim) : remoteip is for proxy protocol + cmd = ['a2enmod', 'ssl', 'proxy', 'proxy_http', 'headers', 'remoteip'] check_call(cmd) def configure_cert(self, cn=None): @@ -1283,6 +1287,8 @@ def __call__(self): ctxt['ext_ports'].append(int(ext_port)) ctxt['ext_ports'] = sorted(list(set(ctxt['ext_ports']))) + if config('haproxy-enable-proxy-protocol'): + ctxt['haproxy_enable_proxy_protocol'] = True return ctxt @@ -1787,11 +1793,19 @@ def __init__(self, name=None, script=None, admin_script=None, self.admin_process_weight = admin_process_weight self.public_process_weight = public_process_weight + def enable_modules(self): + # NOTE(seyeongkim): this is for proxy protocol and non ssl case. + cmd = ['a2enmod', 'remoteip'] + check_call(cmd) + def __call__(self): total_processes = _calculate_workers() enable_wsgi_socket_rotation = config('wsgi-socket-rotation') if enable_wsgi_socket_rotation is None: enable_wsgi_socket_rotation = True + + self.enable_modules() + ctxt = { "service_name": self.service_name, "user": self.user, @@ -1807,6 +1821,8 @@ def __call__(self): "threads": 1, "wsgi_socket_rotation": enable_wsgi_socket_rotation, } + if config('haproxy-enable-proxy-protocol'): + ctxt['haproxy_enable_proxy_protocol'] = True return ctxt diff --git a/charmhelpers/contrib/openstack/templates/haproxy.cfg b/charmhelpers/contrib/openstack/templates/haproxy.cfg index da2522f64..44cedd35f 100644 --- a/charmhelpers/contrib/openstack/templates/haproxy.cfg +++ b/charmhelpers/contrib/openstack/templates/haproxy.cfg @@ -87,11 +87,19 @@ backend {{ service }}_{{ frontend }} {% endif -%} {% endif -%} {% for unit, address in frontends[frontend]['backends'].items() -%} + {% if haproxy_enable_proxy_protocol -%} + {% if https -%} + server {{ unit }} {{ address }}:{{ ports[1] }} check send-proxy check-send-proxy check-ssl verify none + {% else -%} + server {{ unit }} {{ address }}:{{ ports[1] }} check send-proxy check-send-proxy + {% endif -%} + {% else -%} {% if https -%} server {{ unit }} {{ address }}:{{ ports[1] }} check check-ssl verify none {% else -%} server {{ unit }} {{ address }}:{{ ports[1] }} check {% endif -%} + {% endif -%} {% endfor %} {% endfor -%} {% endfor -%} diff --git a/charmhelpers/contrib/openstack/templates/openstack_https_frontend b/charmhelpers/contrib/openstack/templates/openstack_https_frontend index 6ed869a58..7fca93769 100644 --- a/charmhelpers/contrib/openstack/templates/openstack_https_frontend +++ b/charmhelpers/contrib/openstack/templates/openstack_https_frontend @@ -22,6 +22,10 @@ Listen {{ ext_port }} ProxyPassReverse / http://localhost:{{ int }}/ ProxyPreserveHost on RequestHeader set X-Forwarded-Proto "https" + {% if haproxy_enable_proxy_protocol -%} + RemoteIPProxyProtocol On + RemoteIPProxyProtocolExceptions 127.0.0.1 + {% endif -%} KeepAliveTimeout 75 MaxKeepAliveRequests 1000 diff --git a/charmhelpers/contrib/openstack/templates/wsgi-openstack-api.conf b/charmhelpers/contrib/openstack/templates/wsgi-openstack-api.conf index de5f603fc..0ef99abb0 100644 --- a/charmhelpers/contrib/openstack/templates/wsgi-openstack-api.conf +++ b/charmhelpers/contrib/openstack/templates/wsgi-openstack-api.conf @@ -33,6 +33,10 @@ WSGISocketRotation Off ErrorLog /var/log/apache2/{{ service_name }}_error.log CustomLog /var/log/apache2/{{ service_name }}_access.log combined + {% if haproxy_enable_proxy_protocol -%} + RemoteIPProxyProtocol On + RemoteIPProxyProtocolExceptions 127.0.0.1 + {% endif -%} = 2.4> @@ -61,6 +65,10 @@ WSGISocketRotation Off ErrorLog /var/log/apache2/{{ service_name }}_error.log CustomLog /var/log/apache2/{{ service_name }}_access.log combined + {% if haproxy_enable_proxy_protocol -%} + RemoteIPProxyProtocol On + RemoteIPProxyProtocolExceptions 127.0.0.1 + {% endif -%} = 2.4> @@ -89,6 +97,10 @@ WSGISocketRotation Off ErrorLog /var/log/apache2/{{ service_name }}_error.log CustomLog /var/log/apache2/{{ service_name }}_access.log combined + {% if haproxy_enable_proxy_protocol -%} + RemoteIPProxyProtocol On + RemoteIPProxyProtocolExceptions 127.0.0.1 + {% endif -%} = 2.4> diff --git a/tests/contrib/openstack/test_os_contexts.py b/tests/contrib/openstack/test_os_contexts.py index 32df0277d..665719861 100644 --- a/tests/contrib/openstack/test_os_contexts.py +++ b/tests/contrib/openstack/test_os_contexts.py @@ -361,6 +361,10 @@ def relation_units(self, relation_id): 'haproxy-client-timeout': 50000, } +PROXY_PROTOCOL_CONFIG = { + 'haproxy-enable-proxy-protocol': True, +} + CEPH_RELATION = { 'ceph:0': { 'ceph/0': { @@ -2972,6 +2976,64 @@ def test_haproxy_context_with_prometheus_exporter(self, local_unit, local_addres call('cluster'), call('haproxy-exporter')]) + @patch('charmhelpers.contrib.openstack.context.local_address') + @patch('charmhelpers.contrib.openstack.context.local_unit') + def test_haproxy_context_with_data_proxy_protocol(self, local_unit, local_address): + '''Test haproxy context with proxy protocol enabled''' + cluster_relation = { + 'cluster:0': { + 'peer/1': { + 'private-address': 'cluster-peer1.localnet', + }, + 'peer/2': { + 'private-address': 'cluster-peer2.localnet', + }, + }, + } + local_unit.return_value = 'peer/0' + self.get_relation_ip.side_effect = [None, None, None, + 'cluster-peer0.localnet'] + relation = FakeRelation(cluster_relation) + self.relation_ids.side_effect = relation.relation_ids + self.relation_get.side_effect = relation.get + self.related_units.side_effect = relation.relation_units + self.get_netmask_for_address.return_value = '255.255.0.0' + self.config.return_value = False + self.maxDiff = None + self.config.side_effect = fake_config(PROXY_PROTOCOL_CONFIG) + self.is_ipv6_disabled.return_value = True + haproxy = context.HAProxyContext() + with patch_open() as (_open, _file): + result = haproxy() + ex = { + 'frontends': { + 'cluster-peer0.localnet': { + 'network': 'cluster-peer0.localnet/255.255.0.0', + 'backends': collections.OrderedDict([ + ('peer-0', 'cluster-peer0.localnet'), + ('peer-1', 'cluster-peer1.localnet'), + ('peer-2', 'cluster-peer2.localnet'), + ]), + } + }, + 'default_backend': 'cluster-peer0.localnet', + 'local_host': '127.0.0.1', + 'haproxy_host': '0.0.0.0', + 'ipv6_enabled': False, + 'stat_password': 'testpassword', + 'stat_port': '8888', + 'haproxy_enable_proxy_protocol': True, + } + # Verify the context is generated with proxy protocol enabled. + self.assertEqual(ex, result) + # Verify /etc/default/haproxy is updated. + self.assertEqual(_file.write.call_args_list, + [call('ENABLED=1\n')]) + self.get_relation_ip.assert_has_calls([call('admin', None), + call('internal', None), + call('public', None), + call('cluster')]) + def test_https_context_with_no_https(self): '''Test apache2 https when no https data available''' apache = context.ApacheSSLContext() @@ -3003,6 +3065,8 @@ def _https_context_setup(self): ('10.5.2.100', '10.5.2.1'), ('10.5.3.100', '10.5.3.1'), ] + config = {'haproxy-enable-proxy-protocol': False} + self.config.side_effect = lambda key: config[key] apache.external_ports = '8776' apache.service_namespace = 'cinder' @@ -3075,7 +3139,7 @@ def test_https_context_loads_correct_apache_mods(self): # Test apache2 context also loads required apache modules apache = context.ApacheSSLContext() apache.enable_modules() - ex_cmd = ['a2enmod', 'ssl', 'proxy', 'proxy_http', 'headers'] + ex_cmd = ['a2enmod', 'ssl', 'proxy', 'proxy_http', 'headers', 'remoteip'] self.check_call.assert_called_with(ex_cmd) def test_https_configure_cert(self): @@ -3699,6 +3763,43 @@ def test_wsgi_worker_config_context_user_and_group(self, } self.assertEqual(expect, ctxt()) + def test_wsgi_worker_config_context_apache_mods(self): + # Test apache2 context also loads required apache modules + apache = context.WSGIWorkerConfigContext() + apache.enable_modules() + ex_cmd = ['a2enmod', 'remoteip'] + self.check_call.assert_called_with(ex_cmd) + + @patch.object(context, '_calculate_workers') + def test_wsgi_worker_config_context_proxy_protocol(self, + _calculate_workers): + # Test WSGI worker config context with proxy protocol enabled + self.config.side_effect = fake_config({ + 'worker-multiplier': 2, + 'non-defined-wsgi-socket-rotation': True, + 'haproxy-enable-proxy-protocol': True + }) + _calculate_workers.return_value = 8 + service_name = 'service-name' + script = '/usr/bin/script' + ctxt = context.WSGIWorkerConfigContext(name=service_name, + script=script) + expect = { + "service_name": service_name, + "user": service_name, + "group": service_name, + "script": script, + "admin_script": None, + "public_script": None, + "processes": 8, + "admin_processes": 2, + "public_processes": 6, + "threads": 1, + "wsgi_socket_rotation": True, + "haproxy_enable_proxy_protocol": True + } + self.assertEqual(expect, ctxt()) + def test_zeromq_context_unrelated(self): self.is_relation_made.return_value = False self.assertEquals(context.ZeroMQContext()(), {}) From 956d532d6c060c75031e19fabe67ec47e6e2c7c5 Mon Sep 17 00:00:00 2001 From: Seyeong Kim Date: Mon, 30 Mar 2026 01:03:17 +0000 Subject: [PATCH 2/4] Add IPv6 loopback to RemoteIPProxyProtocolExceptions Add ::1 to the RemoteIPProxyProtocolExceptions directive alongside 127.0.0.1 to properly handle IPv6 loopback connections in both the HTTPS frontend and WSGI templates. Closes-Bug: #2107999 Signed-off-by: Seyeong Kim (cherry picked from commit ccdc2bdb17876bc704c74d5a2af5fc9bf4e7673c) --- .../contrib/openstack/templates/openstack_https_frontend | 2 +- .../contrib/openstack/templates/wsgi-openstack-api.conf | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/charmhelpers/contrib/openstack/templates/openstack_https_frontend b/charmhelpers/contrib/openstack/templates/openstack_https_frontend index 7fca93769..7991d56dc 100644 --- a/charmhelpers/contrib/openstack/templates/openstack_https_frontend +++ b/charmhelpers/contrib/openstack/templates/openstack_https_frontend @@ -24,7 +24,7 @@ Listen {{ ext_port }} RequestHeader set X-Forwarded-Proto "https" {% if haproxy_enable_proxy_protocol -%} RemoteIPProxyProtocol On - RemoteIPProxyProtocolExceptions 127.0.0.1 + RemoteIPProxyProtocolExceptions 127.0.0.1 ::1 {% endif -%} KeepAliveTimeout 75 MaxKeepAliveRequests 1000 diff --git a/charmhelpers/contrib/openstack/templates/wsgi-openstack-api.conf b/charmhelpers/contrib/openstack/templates/wsgi-openstack-api.conf index 0ef99abb0..660973e1b 100644 --- a/charmhelpers/contrib/openstack/templates/wsgi-openstack-api.conf +++ b/charmhelpers/contrib/openstack/templates/wsgi-openstack-api.conf @@ -35,7 +35,7 @@ WSGISocketRotation Off CustomLog /var/log/apache2/{{ service_name }}_access.log combined {% if haproxy_enable_proxy_protocol -%} RemoteIPProxyProtocol On - RemoteIPProxyProtocolExceptions 127.0.0.1 + RemoteIPProxyProtocolExceptions 127.0.0.1 ::1 {% endif -%} @@ -67,7 +67,7 @@ WSGISocketRotation Off CustomLog /var/log/apache2/{{ service_name }}_access.log combined {% if haproxy_enable_proxy_protocol -%} RemoteIPProxyProtocol On - RemoteIPProxyProtocolExceptions 127.0.0.1 + RemoteIPProxyProtocolExceptions 127.0.0.1 ::1 {% endif -%} @@ -99,7 +99,7 @@ WSGISocketRotation Off CustomLog /var/log/apache2/{{ service_name }}_access.log combined {% if haproxy_enable_proxy_protocol -%} RemoteIPProxyProtocol On - RemoteIPProxyProtocolExceptions 127.0.0.1 + RemoteIPProxyProtocolExceptions 127.0.0.1 ::1 {% endif -%} From 2dc4fd608fedbb606f88555d79bcddb882ae8314 Mon Sep 17 00:00:00 2001 From: Seyeong Kim Date: Mon, 30 Mar 2026 01:18:27 +0000 Subject: [PATCH 3/4] Fix E128 pep8 indentation error in test_os_contexts Signed-off-by: Seyeong Kim (cherry picked from commit 63c009cebae3cd54c5f08e86cc4002e22b20b6fa) --- tests/contrib/openstack/test_os_contexts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/contrib/openstack/test_os_contexts.py b/tests/contrib/openstack/test_os_contexts.py index 665719861..471d639af 100644 --- a/tests/contrib/openstack/test_os_contexts.py +++ b/tests/contrib/openstack/test_os_contexts.py @@ -3772,7 +3772,7 @@ def test_wsgi_worker_config_context_apache_mods(self): @patch.object(context, '_calculate_workers') def test_wsgi_worker_config_context_proxy_protocol(self, - _calculate_workers): + _calculate_workers): # Test WSGI worker config context with proxy protocol enabled self.config.side_effect = fake_config({ 'worker-multiplier': 2, From 89bc2b2f63533351156ab2c650f6789e4c50d27b Mon Sep 17 00:00:00 2001 From: Seyeong Kim Date: Thu, 9 Apr 2026 04:26:56 +0000 Subject: [PATCH 4/4] Fix E127 pep8 indentation error in test_os_contexts Signed-off-by: Seyeong Kim --- tests/contrib/openstack/test_os_contexts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/contrib/openstack/test_os_contexts.py b/tests/contrib/openstack/test_os_contexts.py index 471d639af..732cc0ba2 100644 --- a/tests/contrib/openstack/test_os_contexts.py +++ b/tests/contrib/openstack/test_os_contexts.py @@ -3028,7 +3028,7 @@ def test_haproxy_context_with_data_proxy_protocol(self, local_unit, local_addres self.assertEqual(ex, result) # Verify /etc/default/haproxy is updated. self.assertEqual(_file.write.call_args_list, - [call('ENABLED=1\n')]) + [call('ENABLED=1\n')]) self.get_relation_ip.assert_has_calls([call('admin', None), call('internal', None), call('public', None),