From 2194758bcf4cfc8aae2e632e94b0ab27616af10b Mon Sep 17 00:00:00 2001 From: Kobi Samoray Date: Tue, 15 Dec 2015 15:17:13 +0200 Subject: [PATCH] Implement custom header support for Octavia Implements support for custom header insertion in Octavia. A listener may be configured to insert custom headers which are supported by Octavia. Currently implemented support for X-Forwarded-For header, and X-Forwarded-Port Change-Id: I784f4939225c3acef362fcb5df57e77dbfb0f774 --- doc/source/api/octaviaapi.rst | 8 ++- octavia/api/v1/controllers/listener.py | 8 +++ octavia/api/v1/types/listener.py | 3 + octavia/common/constants.py | 5 ++ octavia/common/data_models.py | 3 +- octavia/common/jinja/haproxy/jinja_cfg.py | 1 + .../common/jinja/haproxy/templates/macros.j2 | 7 +++ .../versions/4d9cf7d32f2_insert_headers.py | 32 +++++++++++ octavia/db/models.py | 2 + .../tests/functional/api/v1/test_listener.py | 17 +++++- .../functional/api/v1/test_load_balancer.py | 3 +- .../common/jinja/haproxy/test_jinja_cfg.py | 55 ++++++++++++++++--- .../common/sample_configs/sample_configs.py | 21 ++++--- 13 files changed, 144 insertions(+), 21 deletions(-) create mode 100644 octavia/db/migration/alembic_migrations/versions/4d9cf7d32f2_insert_headers.py diff --git a/doc/source/api/octaviaapi.rst b/doc/source/api/octaviaapi.rst index 46b4104630..6b09678f12 100644 --- a/doc/source/api/octaviaapi.rst +++ b/doc/source/api/octaviaapi.rst @@ -518,6 +518,9 @@ Listeners +---------------------+------------+-------------------------------------+ | provisioning_status | String | Physical status of a listener | +---------------------+------------+-------------------------------------+ +| insert_headers | Dictionary | Dictionary of additional headers \ | +| | | insertion into HTTP header | ++---------------------+------------+-------------------------------------+ List Listeners ************** @@ -649,6 +652,8 @@ Create a listener. +------------------+----------+ | enabled | no | +------------------+----------+ +| insert_headers | no | ++------------------+----------+ **Request Example**:: @@ -660,7 +665,8 @@ Create a listener. 'name': 'listener_name', 'description': 'listener_description', 'default_pool_id': 'uuid', - 'enabled': true + 'enabled': true, + 'insert_headers': {'X-Forwarded-For': 'true', 'X-Forwarded-Port': 'true'} } **Response Example**:: diff --git a/octavia/api/v1/controllers/listener.py b/octavia/api/v1/controllers/listener.py index c673a2f63a..9c4636c05a 100644 --- a/octavia/api/v1/controllers/listener.py +++ b/octavia/api/v1/controllers/listener.py @@ -111,6 +111,14 @@ class ListenersController(base.BaseController): Update the load balancer db when provisioning status changes. """ lb_repo = self.repositories.load_balancer + if (listener_dict + and listener_dict.get('insert_headers') + and list(set(listener_dict['insert_headers'].keys()) - + set(constants.SUPPORTED_HTTP_HEADERS))): + raise exceptions.InvalidOption( + value=listener_dict.get('insert_headers'), + option='insert_headers') + try: sni_containers = listener_dict.pop('sni_containers', []) db_listener = self.repositories.listener.create( diff --git a/octavia/api/v1/types/listener.py b/octavia/api/v1/types/listener.py index b6465852da..0299edfb32 100644 --- a/octavia/api/v1/types/listener.py +++ b/octavia/api/v1/types/listener.py @@ -44,6 +44,7 @@ class ListenerResponse(base.BaseType): default_pool_id = wtypes.wsattr(wtypes.UuidType()) default_pool = wtypes.wsattr(pool.PoolResponse) l7policies = wtypes.wsattr([l7policy.L7PolicyResponse]) + insert_headers = wtypes.wsattr(wtypes.DictType(str, str)) @classmethod def from_data_model(cls, data_model, children=False): @@ -90,6 +91,7 @@ class ListenerPOST(base.BaseType): default_pool_id = wtypes.wsattr(wtypes.UuidType()) default_pool = wtypes.wsattr(pool.PoolPOST) l7policies = wtypes.wsattr([l7policy.L7PolicyPOST], default=[]) + insert_headers = wtypes.wsattr(wtypes.DictType(str, str)) class ListenerPUT(base.BaseType): @@ -104,3 +106,4 @@ class ListenerPUT(base.BaseType): tls_termination = wtypes.wsattr(TLSTermination) sni_containers = [wtypes.StringType(max_length=255)] default_pool_id = wtypes.wsattr(wtypes.UuidType()) + insert_headers = wtypes.wsattr(wtypes.DictType(str, str)) diff --git a/octavia/common/constants.py b/octavia/common/constants.py index 386fe7db7c..72218c0f5b 100644 --- a/octavia/common/constants.py +++ b/octavia/common/constants.py @@ -308,5 +308,10 @@ API_VERSION = '0.5' HAPROXY_BASE_PEER_PORT = 1025 KEEPALIVED_CONF = 'keepalived.conf.j2' CHECK_SCRIPT_CONF = 'keepalived_check_script.conf.j2' + PLUGGED_INTERFACES = '/var/lib/octavia/plugged_interfaces' AMPHORA_NAMESPACE = 'amphora-haproxy' + +# List of HTTP headers which are supported for insertion +SUPPORTED_HTTP_HEADERS = ['X-Forwarded-For', + 'X-Forwarded-Port'] diff --git a/octavia/common/data_models.py b/octavia/common/data_models.py index 43193843bd..1c5e42a727 100644 --- a/octavia/common/data_models.py +++ b/octavia/common/data_models.py @@ -263,7 +263,7 @@ class Listener(BaseDataModel): enabled=None, provisioning_status=None, operating_status=None, tls_certificate_id=None, stats=None, default_pool=None, load_balancer=None, sni_containers=None, peer_port=None, - l7policies=None, pools=None): + l7policies=None, pools=None, insert_headers=None): self.id = id self.project_id = project_id self.name = name @@ -283,6 +283,7 @@ class Listener(BaseDataModel): self.sni_containers = sni_containers or [] self.peer_port = peer_port self.l7policies = l7policies or [] + self.insert_headers = insert_headers or {} self.pools = pools or [] def update(self, update_dict): diff --git a/octavia/common/jinja/haproxy/jinja_cfg.py b/octavia/common/jinja/haproxy/jinja_cfg.py index 4f5415a47f..c6c24294aa 100644 --- a/octavia/common/jinja/haproxy/jinja_cfg.py +++ b/octavia/common/jinja/haproxy/jinja_cfg.py @@ -167,6 +167,7 @@ class JinjaTemplater(object): 'protocol_mode': PROTOCOL_MAP[listener.protocol], 'protocol': listener.protocol, 'peer_port': listener.peer_port, + 'insert_headers': listener.insert_headers, 'topology': listener.load_balancer.topology, 'amphorae': listener.load_balancer.amphorae, 'enabled': listener.enabled diff --git a/octavia/common/jinja/haproxy/templates/macros.j2 b/octavia/common/jinja/haproxy/templates/macros.j2 index 8b84b7c12b..e7c6bc55fc 100644 --- a/octavia/common/jinja/haproxy/templates/macros.j2 +++ b/octavia/common/jinja/haproxy/templates/macros.j2 @@ -196,7 +196,14 @@ backend {{ pool.id }} {% endif %} {% endif %} {% if pool.protocol.lower() == constants.PROTOCOL_HTTP.lower() %} + {% if listener.insert_headers.get('X-Forwarded-For', + 'False').lower() == 'true' %} option forwardfor + {% endif %} + {% if listener.insert_headers.get('X-Forwarded-Port', + 'False').lower() == 'true' %} + http-request set-header X-Forwarded-Port %[dst_port] + {% endif %} {% endif %} {% for member in pool.members if member.enabled %} {{- member_macro(constants, pool, member) -}} diff --git a/octavia/db/migration/alembic_migrations/versions/4d9cf7d32f2_insert_headers.py b/octavia/db/migration/alembic_migrations/versions/4d9cf7d32f2_insert_headers.py new file mode 100644 index 0000000000..248367a898 --- /dev/null +++ b/octavia/db/migration/alembic_migrations/versions/4d9cf7d32f2_insert_headers.py @@ -0,0 +1,32 @@ +# Copyright 2016 VMware +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""Insert headers + +Revision ID: 4d9cf7d32f2 +Revises: 9bf4d21caaea +Create Date: 2016-02-21 17:16:22.316744 + +""" + +# revision identifiers, used by Alembic. +revision = '4d9cf7d32f2' +down_revision = '9bf4d21caaea' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.add_column('listener', sa.Column('insert_headers', sa.PickleType())) diff --git a/octavia/db/models.py b/octavia/db/models.py index 45e98d4780..fff5170127 100644 --- a/octavia/db/models.py +++ b/octavia/db/models.py @@ -378,7 +378,9 @@ class Listener(base_models.BASE, base_models.IdMixin, default_pool = orm.relationship("Pool", uselist=False, backref=orm.backref("_default_listeners", uselist=True)) + peer_port = sa.Column(sa.Integer(), nullable=True) + insert_headers = sa.Column(sa.PickleType()) # This property should be a unique list of the default_pool and anything # referenced by enabled L7Policies with at least one rule that also diff --git a/octavia/tests/functional/api/v1/test_listener.py b/octavia/tests/functional/api/v1/test_listener.py index 6b950abd74..0aad7334e0 100644 --- a/octavia/tests/functional/api/v1/test_listener.py +++ b/octavia/tests/functional/api/v1/test_listener.py @@ -87,6 +87,7 @@ class TestListener(base.BaseAPITest): 'protocol_port': 80, 'connection_limit': 10, 'tls_certificate_id': uuidutils.generate_uuid(), 'sni_containers': [sni1, sni2], + 'insert_headers': {}, 'project_id': uuidutils.generate_uuid()} lb_listener.update(optionals) response = self.post(self.listeners_path, lb_listener) @@ -169,7 +170,8 @@ class TestListener(base.BaseAPITest): defaults = {'name': None, 'default_pool_id': None, 'description': None, 'enabled': True, 'connection_limit': None, 'tls_certificate_id': None, - 'sni_containers': [], 'project_id': None} + 'sni_containers': [], 'project_id': None, + 'insert_headers': {}} lb_listener = {'protocol': constants.PROTOCOL_HTTP, 'protocol_port': 80} response = self.post(self.listeners_path, lb_listener) @@ -395,3 +397,16 @@ class TestListener(base.BaseAPITest): self.assertIsNone(listener.get('tls_termination')) get_listener = self.get(listener_path).json self.assertIsNone(get_listener.get('tls_termination')) + + def test_create_with_valid_insert_headers(self): + lb_listener = {'protocol': 'HTTP', + 'protocol_port': 80, + 'insert_headers': {'X-Forwarded-For': 'true'}} + self.post(self.listeners_path, lb_listener, status=202) + + def test_create_with_bad_insert_headers(self): + lb_listener = {'protocol': 'HTTP', + 'protocol_port': 80, + # 'insert_headers': {'x': 'x'}} + 'insert_headers': {'X-Forwarded-Four': 'true'}} + self.post(self.listeners_path, lb_listener, status=400) diff --git a/octavia/tests/functional/api/v1/test_load_balancer.py b/octavia/tests/functional/api/v1/test_load_balancer.py index 2498e2fab6..603006cb55 100644 --- a/octavia/tests/functional/api/v1/test_load_balancer.py +++ b/octavia/tests/functional/api/v1/test_load_balancer.py @@ -320,7 +320,8 @@ class TestLoadBalancerGraph(base.BaseAPITest): 'connection_limit': None, 'enabled': True, 'provisioning_status': constants.PENDING_CREATE, - 'operating_status': constants.OFFLINE + 'operating_status': constants.OFFLINE, + 'insert_headers': {} } if create_sni_containers: create_listener['sni_containers'] = create_sni_containers diff --git a/octavia/tests/unit/common/jinja/haproxy/test_jinja_cfg.py b/octavia/tests/unit/common/jinja/haproxy/test_jinja_cfg.py index a2fa6e8ebb..ff04ec7807 100644 --- a/octavia/tests/unit/common/jinja/haproxy/test_jinja_cfg.py +++ b/octavia/tests/unit/common/jinja/haproxy/test_jinja_cfg.py @@ -47,7 +47,6 @@ class TestHaproxyCfg(base.TestCase): " timeout check 31\n" " option httpchk GET /index.html\n" " http-check expect rstatus 418\n" - " option forwardfor\n" " server sample_member_id_1 10.0.0.99:82 " "weight 13 check inter 30s fall 3 rise 2 " "cookie sample_member_id_1\n" @@ -83,7 +82,6 @@ class TestHaproxyCfg(base.TestCase): " timeout check 31\n" " option httpchk GET /index.html\n" " http-check expect rstatus 418\n" - " option forwardfor\n" " server sample_member_id_1 10.0.0.99:82 " "weight 13 check inter 30s fall 3 rise 2 " "cookie sample_member_id_1\n" @@ -110,7 +108,6 @@ class TestHaproxyCfg(base.TestCase): " timeout check 31\n" " option httpchk GET /index.html\n" " http-check expect rstatus 418\n" - " option forwardfor\n" " server sample_member_id_1 10.0.0.99:82 " "weight 13 check inter 30s fall 3 rise 2 " "cookie sample_member_id_1\n" @@ -154,7 +151,6 @@ class TestHaproxyCfg(base.TestCase): " mode http\n" " balance roundrobin\n" " cookie SRV insert indirect nocache\n" - " option forwardfor\n" " server sample_member_id_1 10.0.0.99:82 weight 13 " "cookie sample_member_id_1\n" " server sample_member_id_2 10.0.0.98:82 weight 13 " @@ -206,7 +202,6 @@ class TestHaproxyCfg(base.TestCase): be = ("backend sample_pool_id_1\n" " mode http\n" " balance roundrobin\n" - " option forwardfor\n" " server sample_member_id_1 10.0.0.99:82 weight 13\n" " server sample_member_id_2 10.0.0.98:82 weight 13\n\n") rendered_obj = self.jinja_cfg.render_loadbalancer_obj( @@ -224,7 +219,6 @@ class TestHaproxyCfg(base.TestCase): " timeout check 31\n" " option httpchk GET /index.html\n" " http-check expect rstatus 418\n" - " option forwardfor\n" " server sample_member_id_1 10.0.0.99:82 " "weight 13 check inter 30s fall 3 rise 2\n" " server sample_member_id_2 10.0.0.98:82 " @@ -246,7 +240,6 @@ class TestHaproxyCfg(base.TestCase): " timeout check 31\n" " option httpchk GET /index.html\n" " http-check expect rstatus 418\n" - " option forwardfor\n" " server sample_member_id_1 10.0.0.99:82 " "weight 13 check inter 30s fall 3 rise 2\n" " server sample_member_id_2 10.0.0.98:82 " @@ -286,7 +279,6 @@ class TestHaproxyCfg(base.TestCase): " timeout check 31\n" " option httpchk GET /index.html\n" " http-check expect rstatus 418\n" - " option forwardfor\n" " server sample_member_id_1 10.0.0.99:82 weight 13 check " "inter 30s fall 3 rise 2 cookie sample_member_id_1\n" " server sample_member_id_2 10.0.0.98:82 weight 13 check " @@ -299,7 +291,6 @@ class TestHaproxyCfg(base.TestCase): " timeout check 31\n" " option httpchk GET /healthmon.html\n" " http-check expect rstatus 418\n" - " option forwardfor\n" " server sample_member_id_3 10.0.0.97:82 weight 13 check " "inter 30s fall 3 rise 2 cookie sample_member_id_3\n\n") rendered_obj = self.jinja_cfg.render_loadbalancer_obj( @@ -307,6 +298,52 @@ class TestHaproxyCfg(base.TestCase): self.assertEqual(sample_configs.sample_base_expected_config( frontend=fe, backend=be), rendered_obj) + def test_render_template_http_xff(self): + be = ("backend sample_pool_id_1\n" + " mode http\n" + " balance roundrobin\n" + " cookie SRV insert indirect nocache\n" + " timeout check 31\n" + " option httpchk GET /index.html\n" + " http-check expect rstatus 418\n" + " option forwardfor\n" + " server sample_member_id_1 10.0.0.99:82 " + "weight 13 check inter 30s fall 3 rise 2 " + "cookie sample_member_id_1\n" + " server sample_member_id_2 10.0.0.98:82 " + "weight 13 check inter 30s fall 3 rise 2 " + "cookie sample_member_id_2\n\n") + rendered_obj = self.jinja_cfg.render_loadbalancer_obj( + sample_configs.sample_listener_tuple( + insert_headers={'X-Forwarded-For': 'true'})) + self.assertEqual( + sample_configs.sample_base_expected_config(backend=be), + rendered_obj) + + def test_render_template_http_xff_xfport(self): + be = ("backend sample_pool_id_1\n" + " mode http\n" + " balance roundrobin\n" + " cookie SRV insert indirect nocache\n" + " timeout check 31\n" + " option httpchk GET /index.html\n" + " http-check expect rstatus 418\n" + " option forwardfor\n" + " http-request set-header X-Forwarded-Port %[dst_port]\n" + " server sample_member_id_1 10.0.0.99:82 " + "weight 13 check inter 30s fall 3 rise 2 " + "cookie sample_member_id_1\n" + " server sample_member_id_2 10.0.0.98:82 " + "weight 13 check inter 30s fall 3 rise 2 " + "cookie sample_member_id_2\n\n") + rendered_obj = self.jinja_cfg.render_loadbalancer_obj( + sample_configs.sample_listener_tuple( + insert_headers={'X-Forwarded-For': 'true', + 'X-Forwarded-Port': 'true'})) + self.assertEqual( + sample_configs.sample_base_expected_config(backend=be), + rendered_obj) + def test_transform_session_persistence(self): in_persistence = sample_configs.sample_session_persistence_tuple() ret = self.jinja_cfg._transform_session_persistence(in_persistence) diff --git a/octavia/tests/unit/common/sample_configs/sample_configs.py b/octavia/tests/unit/common/sample_configs/sample_configs.py index 55f9285b62..b84874c621 100644 --- a/octavia/tests/unit/common/sample_configs/sample_configs.py +++ b/octavia/tests/unit/common/sample_configs/sample_configs.py @@ -204,7 +204,8 @@ RET_LISTENER = { 'topology': 'SINGLE', 'pools': [RET_POOL_1], 'l7policies': [], - 'enabled': True} + 'enabled': True, + 'insert_headers': {}} RET_LISTENER_L7 = { 'id': 'sample_listener_id_1', @@ -219,7 +220,8 @@ RET_LISTENER_L7 = { 'pools': [RET_POOL_1, RET_POOL_2], 'l7policies': [RET_L7POLICY_1, RET_L7POLICY_2, RET_L7POLICY_3, RET_L7POLICY_4, RET_L7POLICY_5], - 'enabled': True} + 'enabled': True, + 'insert_headers': {}} RET_LISTENER_TLS = { 'id': 'sample_listener_id_1', @@ -233,7 +235,8 @@ RET_LISTENER_TLS = { 'default_tls_container': RET_DEF_TLS_CONT, 'pools': [RET_POOL_1], 'l7policies': [], - 'enabled': True} + 'enabled': True, + 'insert_headers': {}} RET_LISTENER_TLS_SNI = { 'id': 'sample_listener_id_1', @@ -250,7 +253,8 @@ RET_LISTENER_TLS_SNI = { 'sni_containers': [RET_SNI_CONT_1, RET_SNI_CONT_2], 'pools': [RET_POOL_1], 'l7policies': [], - 'enabled': True} + 'enabled': True, + 'insert_headers': {}} RET_LB = { 'name': 'test-lb', @@ -345,18 +349,19 @@ def sample_vip_tuple(): def sample_listener_tuple(proto=None, monitor=True, persistence=True, persistence_type=None, persistence_cookie=None, tls=False, sni=False, peer_port=None, topology=None, - l7=False, enabled=True): + l7=False, enabled=True, insert_headers=None): proto = 'HTTP' if proto is None else proto be_proto = 'HTTP' if proto is 'TERMINATED_HTTPS' else proto topology = 'SINGLE' if topology is None else topology port = '443' if proto is 'HTTPS' or proto is 'TERMINATED_HTTPS' else '80' peer_port = 1024 if peer_port is None else peer_port + insert_headers = insert_headers or {} in_listener = collections.namedtuple( 'listener', 'id, project_id, protocol_port, protocol, default_pool, ' 'connection_limit, tls_certificate_id, ' 'sni_container_ids, default_tls_container, ' 'sni_containers, load_balancer, peer_port, pools, ' - 'l7policies, enabled',) + 'l7policies, enabled, insert_headers',) if l7: pools = [ sample_pool_tuple( @@ -419,7 +424,8 @@ def sample_listener_tuple(proto=None, monitor=True, persistence=True, if sni else [], pools=pools, l7policies=l7policies, - enabled=enabled + enabled=enabled, + insert_headers=insert_headers ) @@ -615,7 +621,6 @@ def sample_base_expected_config(frontend=None, backend=None, peers=None): " timeout check 31\n" " option httpchk GET /index.html\n" " http-check expect rstatus 418\n" - " option forwardfor\n" " server sample_member_id_1 10.0.0.99:82 weight 13 " "check inter 30s fall 3 rise 2 cookie sample_member_id_1\n" " server sample_member_id_2 10.0.0.98:82 weight 13 "