diff --git a/api-ref/source/parameters.yaml b/api-ref/source/parameters.yaml index 859d5df7d8..0b047833d3 100644 --- a/api-ref/source/parameters.yaml +++ b/api-ref/source/parameters.yaml @@ -183,6 +183,22 @@ allowed_cidrs-optional: min_version: 2.12 required: false type: array +alpn_protocols: + description: | + A list of ALPN protocols. + Available protocols: http/1.0, http/1.1, h2 + in: body + min_version: 2.20 + required: true + type: array +alpn_protocols-optional: + description: | + A list of ALPN protocols. + Available protocols: http/1.0, http/1.1, h2 + in: body + min_version: 2.20 + required: false + type: array amphora-id: description: | The associated amphora ID. diff --git a/api-ref/source/v2/examples/listener-create-curl b/api-ref/source/v2/examples/listener-create-curl index 8b2c044235..7d46a7c327 100644 --- a/api-ref/source/v2/examples/listener-create-curl +++ b/api-ref/source/v2/examples/listener-create-curl @@ -1 +1 @@ -curl -X POST -H "Content-Type: application/json" -H "X-Auth-Token: " -d '{"listener": {"protocol": "TERMINATED_HTTPS", "description": "A great TLS listener", "admin_state_up": true, "connection_limit": 200, "protocol_port": "443", "loadbalancer_id": "607226db-27ef-4d41-ae89-f2a800e9c2db", "name": "great_tls_listener", "insert_headers": {"X-Forwarded-For": "true", "X-Forwarded-Port": "true"}, "default_tls_container_ref": "http://198.51.100.10:9311/v1/containers/a570068c-d295-4780-91d4-3046a325db51", "sni_container_refs": ["http://198.51.100.10:9311/v1/containers/a570068c-d295-4780-91d4-3046a325db51", "http://198.51.100.10:9311/v1/containers/aaebb31e-7761-4826-8cb4-2b829caca3ee"], "timeout_client_data": 50000, "timeout_member_connect": 5000, "timeout_member_data": 50000, "timeout_tcp_inspect": 0, "tags": ["test_tag"], "client_ca_tls_container_ref": "http://198.51.100.10:9311/v1/containers/35649991-49f3-4625-81ce-2465fe8932e5", "client_authentication": "MANDATORY", "client_crl_container_ref": "http://198.51.100.10:9311/v1/containers/e222b065-b93b-4e2a-9a02-804b7a118c3c", "allowed_cidrs": ["192.0.2.0/24", "198.51.100.0/24"], "tls_ciphers": "ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256", "tls_versions": ["TLSv1.2", "TLSv1.3"]}}' http://198.51.100.10:9876/v2/lbaas/listeners +curl -X POST -H "Content-Type: application/json" -H "X-Auth-Token: " -d '{"listener": {"protocol": "TERMINATED_HTTPS", "description": "A great TLS listener", "admin_state_up": true, "connection_limit": 200, "protocol_port": "443", "loadbalancer_id": "607226db-27ef-4d41-ae89-f2a800e9c2db", "name": "great_tls_listener", "insert_headers": {"X-Forwarded-For": "true", "X-Forwarded-Port": "true"}, "default_tls_container_ref": "http://198.51.100.10:9311/v1/containers/a570068c-d295-4780-91d4-3046a325db51", "sni_container_refs": ["http://198.51.100.10:9311/v1/containers/a570068c-d295-4780-91d4-3046a325db51", "http://198.51.100.10:9311/v1/containers/aaebb31e-7761-4826-8cb4-2b829caca3ee"], "timeout_client_data": 50000, "timeout_member_connect": 5000, "timeout_member_data": 50000, "timeout_tcp_inspect": 0, "tags": ["test_tag"], "client_ca_tls_container_ref": "http://198.51.100.10:9311/v1/containers/35649991-49f3-4625-81ce-2465fe8932e5", "client_authentication": "MANDATORY", "client_crl_container_ref": "http://198.51.100.10:9311/v1/containers/e222b065-b93b-4e2a-9a02-804b7a118c3c", "allowed_cidrs": ["192.0.2.0/24", "198.51.100.0/24"], "tls_ciphers": "ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256", "tls_versions": ["TLSv1.2", "TLSv1.3"], "alpn_protocols": ["http/1.1", "http/1.0"]}}' http://198.51.100.10:9876/v2/lbaas/listeners diff --git a/api-ref/source/v2/examples/listener-create-request.json b/api-ref/source/v2/examples/listener-create-request.json index 1df83d565a..fec3dcb6e1 100644 --- a/api-ref/source/v2/examples/listener-create-request.json +++ b/api-ref/source/v2/examples/listener-create-request.json @@ -29,6 +29,7 @@ "198.51.100.0/24" ], "tls_ciphers": "ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256", - "tls_versions": ["TLSv1.2", "TLSv1.3"] + "tls_versions": ["TLSv1.2", "TLSv1.3"], + "alpn_protocols": ["http/1.1", "http/1.0"] } } diff --git a/api-ref/source/v2/examples/listener-create-response.json b/api-ref/source/v2/examples/listener-create-response.json index 2e21dcca07..162b4b96f8 100644 --- a/api-ref/source/v2/examples/listener-create-response.json +++ b/api-ref/source/v2/examples/listener-create-response.json @@ -44,6 +44,7 @@ "198.51.100.0/24" ], "tls_ciphers": "ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256", - "tls_versions": ["TLSv1.2", "TLSv1.3"] + "tls_versions": ["TLSv1.2", "TLSv1.3"], + "alpn_protocols": ["http/1.1", "http/1.0"] } } diff --git a/api-ref/source/v2/examples/listener-show-response.json b/api-ref/source/v2/examples/listener-show-response.json index 4bcc4cf090..e94a120789 100644 --- a/api-ref/source/v2/examples/listener-show-response.json +++ b/api-ref/source/v2/examples/listener-show-response.json @@ -44,6 +44,7 @@ "198.51.100.0/24" ], "tls_ciphers": "ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256", - "tls_versions": ["TLSv1.2", "TLSv1.3"] + "tls_versions": ["TLSv1.2", "TLSv1.3"], + "alpn_protocols": ["http/1.1", "http/1.0"] } } diff --git a/api-ref/source/v2/examples/listener-update-curl b/api-ref/source/v2/examples/listener-update-curl index b80a2f395c..21ab5d27f9 100644 --- a/api-ref/source/v2/examples/listener-update-curl +++ b/api-ref/source/v2/examples/listener-update-curl @@ -1 +1 @@ -curl -X PUT -H "Content-Type: application/json" -H "X-Auth-Token: " -d '{"listener": {"description": "An updated great TLS listener", "admin_state_up": true, "connection_limit": 200, "name": "great_updated_tls_listener", "insert_headers": {"X-Forwarded-For": "false", "X-Forwarded-Port": "true"}, "default_tls_container_ref": "http://198.51.100.10:9311/v1/containers/a570068c-d295-4780-91d4-3046a325db51", "sni_container_refs": ["http://198.51.100.10:9311/v1/containers/a570068c-d295-4780-91d4-3046a325db51", "http://198.51.100.10:9311/v1/containers/aaebb31e-7761-4826-8cb4-2b829caca3ee"], "timeout_client_data": 100000, "timeout_member_connect": 1000, "timeout_member_data": 100000, "timeout_tcp_inspect": 5, "tags": ["updated_tag"], "client_ca_tls_container_ref": null, "allowed_cidrs": ["192.0.2.0/24", "198.51.100.0/24"], "tls_ciphers": "ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256", "tls_versions": ["TLSv1.2", "TLSv1.3"]}}' http://198.51.100.10:9876/v2/lbaas/listeners/023f2e34-7806-443b-bfae-16c324569a3d +curl -X PUT -H "Content-Type: application/json" -H "X-Auth-Token: " -d '{"listener": {"description": "An updated great TLS listener", "admin_state_up": true, "connection_limit": 200, "name": "great_updated_tls_listener", "insert_headers": {"X-Forwarded-For": "false", "X-Forwarded-Port": "true"}, "default_tls_container_ref": "http://198.51.100.10:9311/v1/containers/a570068c-d295-4780-91d4-3046a325db51", "sni_container_refs": ["http://198.51.100.10:9311/v1/containers/a570068c-d295-4780-91d4-3046a325db51", "http://198.51.100.10:9311/v1/containers/aaebb31e-7761-4826-8cb4-2b829caca3ee"], "timeout_client_data": 100000, "timeout_member_connect": 1000, "timeout_member_data": 100000, "timeout_tcp_inspect": 5, "tags": ["updated_tag"], "client_ca_tls_container_ref": null, "allowed_cidrs": ["192.0.2.0/24", "198.51.100.0/24"], "tls_ciphers": "ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256", "tls_versions": ["TLSv1.2", "TLSv1.3"], "alpn_protocols": ["http/1.1", "http/1.0"]}}' http://198.51.100.10:9876/v2/lbaas/listeners/023f2e34-7806-443b-bfae-16c324569a3d diff --git a/api-ref/source/v2/examples/listener-update-request.json b/api-ref/source/v2/examples/listener-update-request.json index 0708b2eb76..ef9387dda6 100644 --- a/api-ref/source/v2/examples/listener-update-request.json +++ b/api-ref/source/v2/examples/listener-update-request.json @@ -25,6 +25,7 @@ "198.51.100.0/24" ], "tls_ciphers": "ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256", - "tls_versions": ["TLSv1.2", "TLSv1.3"] + "tls_versions": ["TLSv1.2", "TLSv1.3"], + "alpn_protocols": ["http/1.1", "http/1.0"] } } diff --git a/api-ref/source/v2/examples/listener-update-response.json b/api-ref/source/v2/examples/listener-update-response.json index fe48622b88..7a67dc6bbb 100644 --- a/api-ref/source/v2/examples/listener-update-response.json +++ b/api-ref/source/v2/examples/listener-update-response.json @@ -44,6 +44,7 @@ "198.51.100.0/24" ], "tls_ciphers": "ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256", - "tls_versions": ["TLSv1.2", "TLSv1.3"] + "tls_versions": ["TLSv1.2", "TLSv1.3"], + "alpn_protocols": ["http/1.1", "http/1.0"] } } diff --git a/api-ref/source/v2/examples/listeners-list-response.json b/api-ref/source/v2/examples/listeners-list-response.json index 4531a73ff7..ae2b59c396 100644 --- a/api-ref/source/v2/examples/listeners-list-response.json +++ b/api-ref/source/v2/examples/listeners-list-response.json @@ -46,7 +46,8 @@ "198.51.100.0/24" ], "tls_ciphers": "ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256", - "tls_versions": ["TLSv1.2", "TLSv1.3"] + "tls_versions": ["TLSv1.2", "TLSv1.3"], + "alpn_protocols": ["http/1.1", "http/1.0"] } ] } diff --git a/api-ref/source/v2/listener.inc b/api-ref/source/v2/listener.inc index 3ac23902ca..55c5ac79e6 100644 --- a/api-ref/source/v2/listener.inc +++ b/api-ref/source/v2/listener.inc @@ -47,6 +47,7 @@ Response Parameters - admin_state_up: admin_state_up - allowed_cidrs: allowed_cidrs + - alpn_protocols: alpn_protocols - client_authentication: client_authentication - client_ca_tls_container_ref: client_ca_tls_container_ref - client_crl_container_ref: client_crl_container_ref @@ -143,6 +144,7 @@ Request - admin_state_up: admin_state_up-default-optional - allowed_cidrs: allowed_cidrs-optional + - alpn_protocols: alpn_protocols-optional - client_authentication: client_authentication-optional - client_ca_tls_container_ref: client_ca_tls_container_ref-optional - client_crl_container_ref: client_crl_container_ref-optional @@ -266,6 +268,7 @@ Response Parameters - admin_state_up: admin_state_up - allowed_cidrs: allowed_cidrs + - alpn_protocols: alpn_protocols - client_authentication: client_authentication - client_ca_tls_container_ref: client_ca_tls_container_ref - client_crl_container_ref: client_crl_container_ref @@ -346,6 +349,7 @@ Response Parameters - admin_state_up: admin_state_up - allowed_cidrs: allowed_cidrs + - alpn_protocols: alpn_protocols - client_authentication: client_authentication - client_ca_tls_container_ref: client_ca_tls_container_ref - client_crl_container_ref: client_crl_container_ref @@ -416,6 +420,7 @@ Request - admin_state_up: admin_state_up-default-optional - allowed_cidrs: allowed_cidrs-optional + - alpn_protocols: alpn_protocols-optional - client_authentication: client_authentication-optional - client_ca_tls_container_ref: client_ca_tls_container_ref-optional - client_crl_container_ref: client_crl_container_ref-optional @@ -454,6 +459,7 @@ Response Parameters - admin_state_up: admin_state_up - allowed_cidrs: allowed_cidrs + - alpn_protocols: alpn_protocols - client_authentication: client_authentication - client_ca_tls_container_ref: client_ca_tls_container_ref - client_crl_container_ref: client_crl_container_ref diff --git a/doc/source/user/feature-classification/feature-matrix-listener.ini b/doc/source/user/feature-classification/feature-matrix-listener.ini index 8f701b9c8b..e6eecb2056 100644 --- a/doc/source/user/feature-classification/feature-matrix-listener.ini +++ b/doc/source/user/feature-classification/feature-matrix-listener.ini @@ -38,6 +38,14 @@ cli=openstack loadbalancer listener create [--allowed-cidr ] ] +driver.amphora=complete +driver.ovn=missing + [operation.client_authentication] title=client_authentication status=optional diff --git a/etc/octavia.conf b/etc/octavia.conf index 0eacfd9ab7..bc4e75a7f1 100644 --- a/etc/octavia.conf +++ b/etc/octavia.conf @@ -84,6 +84,11 @@ # either. Available versions: SSLv3, TLSv1, TLSv1.1, TLSv1.2, TLSv1.3 # minimum_tls_version = +# List of default ALPN protocols to be used on new TLS-terminated +# listeners. Available protocols: http/1.0, http/1.1, h2 +# default_listener_alpn_protocols = http/1.1, http/1.0 + + [database] # This line MUST be changed to actually run the plugin. # Example: diff --git a/lower-constraints.txt b/lower-constraints.txt index 9245c1a002..61522d6167 100644 --- a/lower-constraints.txt +++ b/lower-constraints.txt @@ -70,7 +70,7 @@ munch==2.2.0 netaddr==0.7.19 netifaces==0.10.4 networkx==1.11 -octavia-lib==2.0.0 +octavia-lib==2.2.0 openstacksdk==0.12.0 os-client-config==1.29.0 os-service-types==1.2.0 diff --git a/octavia/api/common/types.py b/octavia/api/common/types.py index 07bdd2f121..ad8980115f 100644 --- a/octavia/api/common/types.py +++ b/octavia/api/common/types.py @@ -18,6 +18,7 @@ from dateutil import parser import netaddr from wsme import types as wtypes +from octavia.common import constants from octavia.common import exceptions from octavia.common import validate @@ -55,6 +56,19 @@ class CidrType(wtypes.UserType): raise ValueError(error) from e +class AlpnProtocolType(wtypes.UserType): + basetype = str + name = 'alpn_protocol' + + @staticmethod + def validate(value): + """Validates whether value is a valid ALPN protocol ID.""" + if value in constants.SUPPORTED_ALPN_PROTOCOLS: + return value + error = 'Value should be a valid ALPN protocol ID' + raise ValueError(error) + + class URLType(wtypes.UserType): basetype = str name = 'url' diff --git a/octavia/api/drivers/amphora_driver/v1/driver.py b/octavia/api/drivers/amphora_driver/v1/driver.py index 40ba5f8f0a..bff27de103 100644 --- a/octavia/api/drivers/amphora_driver/v1/driver.py +++ b/octavia/api/drivers/amphora_driver/v1/driver.py @@ -62,6 +62,19 @@ class AmphoraProviderDriver(driver_base.ProviderDriver): user_fault_string=msg, operator_fault_string=msg) + def _validate_alpn_protocols(self, listener): + if not listener.alpn_protocols: + return + supported = consts.AMPHORA_SUPPORTED_ALPN_PROTOCOLS + not_supported = set(listener.alpn_protocols) - set(supported) + if not_supported: + msg = ('Amphora provider does not support %s ALPN protocol(s). ' + 'Supported: %s' + % (", ".join(not_supported), ", ".join(supported))) + raise exceptions.UnsupportedOptionError( + user_fault_string=msg, + operator_fault_string=msg) + # Load Balancer def create_vip_port(self, loadbalancer_id, project_id, vip_dictionary): vip_obj = driver_utils.provider_vip_dict_to_vip_obj(vip_dictionary) @@ -123,6 +136,7 @@ class AmphoraProviderDriver(driver_base.ProviderDriver): # Listener def listener_create(self, listener): + self._validate_alpn_protocols(listener) payload = {consts.LISTENER_ID: listener.listener_id} self.client.cast({}, 'create_listener', **payload) @@ -132,6 +146,7 @@ class AmphoraProviderDriver(driver_base.ProviderDriver): self.client.cast({}, 'delete_listener', **payload) def listener_update(self, old_listener, new_listener): + self._validate_alpn_protocols(new_listener) listener_dict = new_listener.to_dict() if 'admin_state_up' in listener_dict: listener_dict['enabled'] = listener_dict.pop('admin_state_up') diff --git a/octavia/api/drivers/amphora_driver/v2/driver.py b/octavia/api/drivers/amphora_driver/v2/driver.py index 7be8e916e8..5337d06c5a 100644 --- a/octavia/api/drivers/amphora_driver/v2/driver.py +++ b/octavia/api/drivers/amphora_driver/v2/driver.py @@ -64,6 +64,19 @@ class AmphoraProviderDriver(driver_base.ProviderDriver): user_fault_string=msg, operator_fault_string=msg) + def _validate_alpn_protocols(self, listener): + if not listener.alpn_protocols: + return + supported = consts.AMPHORA_SUPPORTED_ALPN_PROTOCOLS + not_supported = set(listener.alpn_protocols) - set(supported) + if not_supported: + msg = ('Amphora provider does not support %s ALPN protocol(s). ' + 'Supported: %s' + % (", ".join(not_supported), ", ".join(supported))) + raise exceptions.UnsupportedOptionError( + user_fault_string=msg, + operator_fault_string=msg) + # Load Balancer def create_vip_port(self, loadbalancer_id, project_id, vip_dictionary): vip_obj = driver_utils.provider_vip_dict_to_vip_obj(vip_dictionary) @@ -136,6 +149,7 @@ class AmphoraProviderDriver(driver_base.ProviderDriver): # Listener def listener_create(self, listener): + self._validate_alpn_protocols(listener) payload = {consts.LISTENER: listener.to_dict()} self._encrypt_listener_dict(payload) @@ -146,6 +160,7 @@ class AmphoraProviderDriver(driver_base.ProviderDriver): self.client.cast({}, 'delete_listener', **payload) def listener_update(self, old_listener, new_listener): + self._validate_alpn_protocols(new_listener) original_listener = old_listener.to_dict() listener_updates = new_listener.to_dict() diff --git a/octavia/api/root_controller.py b/octavia/api/root_controller.py index 09076e5b07..fc4a2aec15 100644 --- a/octavia/api/root_controller.py +++ b/octavia/api/root_controller.py @@ -122,6 +122,9 @@ class RootController(object): self._add_a_version(versions, 'v2.18', 'v2', 'SUPPORTED', '2020-04-29T01:00:00Z', host_url) # Add quota support to octavia's l7policy and l7rule - self._add_a_version(versions, 'v2.19', 'v2', 'CURRENT', + self._add_a_version(versions, 'v2.19', 'v2', 'SUPPORTED', '2020-05-12T00:00:00Z', host_url) + # ALPN protocols + self._add_a_version(versions, 'v2.20', 'v2', 'CURRENT', + '2020-08-02T00:00:00Z', host_url) return {'versions': versions} diff --git a/octavia/api/v2/controllers/listener.py b/octavia/api/v2/controllers/listener.py index 979965b7c0..0d332b58c3 100644 --- a/octavia/api/v2/controllers/listener.py +++ b/octavia/api/v2/controllers/listener.py @@ -298,6 +298,8 @@ class ListenersController(base.BaseController): validate.check_tls_version_list(listener_dict['tls_versions']) # Validate TLS versions against minimum validate.check_tls_version_min(listener_dict['tls_versions']) + # Validate ALPN protocol list + validate.check_alpn_protocols(listener_dict['alpn_protocols']) try: db_listener = self.repositories.listener.create( @@ -511,6 +513,10 @@ class ListenersController(base.BaseController): # Validate TLS versions against minimum validate.check_tls_version_min(listener.tls_versions) + if listener.alpn_protocols is not wtypes.Unset: + # Validate ALPN protocol list + validate.check_alpn_protocols(listener.alpn_protocols) + def _set_default_on_none(self, listener): """Reset settings to their default values if None/null was passed in @@ -543,6 +549,9 @@ class ListenersController(base.BaseController): if listener.tls_versions is None: listener.tls_versions = ( CONF.api_settings.default_listener_tls_versions) + if listener.alpn_protocols is None: + listener.alpn_protocols = ( + CONF.api_settings.default_listener_alpn_protocols) @wsme_pecan.wsexpose(listener_types.ListenerRootResponse, wtypes.text, body=listener_types.ListenerRootPUT, status_code=200) diff --git a/octavia/api/v2/types/listener.py b/octavia/api/v2/types/listener.py index da82de66ed..1f72456403 100644 --- a/octavia/api/v2/types/listener.py +++ b/octavia/api/v2/types/listener.py @@ -65,6 +65,7 @@ class ListenerResponse(BaseListenerType): allowed_cidrs = wtypes.wsattr([types.CidrType()]) tls_ciphers = wtypes.StringType() tls_versions = wtypes.wsattr(wtypes.ArrayType(wtypes.StringType())) + alpn_protocols = wtypes.wsattr(wtypes.ArrayType(types.AlpnProtocolType())) @classmethod def from_data_model(cls, data_model, children=False): @@ -87,6 +88,7 @@ class ListenerResponse(BaseListenerType): l7policy_type.from_data_model(i) for i in data_model.l7policies] listener.tls_versions = data_model.tls_versions + listener.alpn_protocols = data_model.alpn_protocols return listener @@ -158,6 +160,7 @@ class ListenerPOST(BaseListenerType): tls_ciphers = wtypes.StringType(max_length=2048) tls_versions = wtypes.wsattr(wtypes.ArrayType(wtypes.StringType( max_length=32))) + alpn_protocols = wtypes.wsattr(wtypes.ArrayType(types.AlpnProtocolType())) class ListenerRootPOST(types.BaseType): @@ -198,6 +201,7 @@ class ListenerPUT(BaseListenerType): tls_ciphers = wtypes.StringType(max_length=2048) tls_versions = wtypes.wsattr(wtypes.ArrayType(wtypes.StringType( max_length=32))) + alpn_protocols = wtypes.wsattr(wtypes.ArrayType(types.AlpnProtocolType())) class ListenerRootPUT(types.BaseType): @@ -251,6 +255,7 @@ class ListenerSingleCreate(BaseListenerType): tls_ciphers = wtypes.StringType(max_length=2048) tls_versions = wtypes.wsattr(wtypes.ArrayType(wtypes.StringType( max_length=32))) + alpn_protocols = wtypes.wsattr(wtypes.ArrayType(types.AlpnProtocolType())) class ListenerStatusResponse(BaseListenerType): diff --git a/octavia/common/config.py b/octavia/common/config.py index 53160ebef2..dc0aaaa189 100644 --- a/octavia/common/config.py +++ b/octavia/common/config.py @@ -128,7 +128,12 @@ api_opts = [ cfg.StrOpt('minimum_tls_version', default=None, choices=constants.TLS_ALL_VERSIONS + [None], - help=_('Minimum allowed TLS version for listeners and pools.')) + help=_('Minimum allowed TLS version for listeners and pools.')), + cfg.ListOpt('default_listener_alpn_protocols', + default=[lib_consts.ALPN_PROTOCOL_HTTP_1_1, + lib_consts.ALPN_PROTOCOL_HTTP_1_0], + help=_('List of ALPN protocols to use for new TLS-enabled ' + 'listeners.')), ] # Options only used by the amphora agent diff --git a/octavia/common/constants.py b/octavia/common/constants.py index 1917cdbfc7..bdd05f6444 100644 --- a/octavia/common/constants.py +++ b/octavia/common/constants.py @@ -854,3 +854,10 @@ OCTAVIA_OWNED = 'octavia_owned' # but they should be on the pool. Dealing with it until v3. LISTENER_PROTOCOLS_SUPPORTING_HEADER_INSERTION = [PROTOCOL_HTTP, PROTOCOL_TERMINATED_HTTPS] + +SUPPORTED_ALPN_PROTOCOLS = [lib_consts.ALPN_PROTOCOL_HTTP_2, + lib_consts.ALPN_PROTOCOL_HTTP_1_1, + lib_consts.ALPN_PROTOCOL_HTTP_1_0] + +AMPHORA_SUPPORTED_ALPN_PROTOCOLS = [lib_consts.ALPN_PROTOCOL_HTTP_1_1, + lib_consts.ALPN_PROTOCOL_HTTP_1_0] diff --git a/octavia/common/data_models.py b/octavia/common/data_models.py index 236943df3e..525a72fba0 100644 --- a/octavia/common/data_models.py +++ b/octavia/common/data_models.py @@ -395,7 +395,8 @@ class Listener(BaseDataModel): timeout_member_data=None, timeout_tcp_inspect=None, tags=None, client_ca_tls_certificate_id=None, client_authentication=None, client_crl_container_id=None, - allowed_cidrs=None, tls_ciphers=None, tls_versions=None): + allowed_cidrs=None, tls_ciphers=None, tls_versions=None, + alpn_protocols=None): self.id = id self.project_id = project_id self.name = name @@ -430,6 +431,7 @@ class Listener(BaseDataModel): self.allowed_cidrs = allowed_cidrs or [] self.tls_ciphers = tls_ciphers self.tls_versions = tls_versions + self.alpn_protocols = alpn_protocols def update(self, update_dict): for key, value in update_dict.items(): diff --git a/octavia/common/jinja/haproxy/combined_listeners/jinja_cfg.py b/octavia/common/jinja/haproxy/combined_listeners/jinja_cfg.py index ec167179a0..942025203e 100644 --- a/octavia/common/jinja/haproxy/combined_listeners/jinja_cfg.py +++ b/octavia/common/jinja/haproxy/combined_listeners/jinja_cfg.py @@ -289,6 +289,8 @@ class JinjaTemplater(object): ret_value['tls_ciphers'] = listener.tls_ciphers if listener.tls_versions is not None: ret_value['tls_versions'] = listener.tls_versions + if listener.alpn_protocols is not None: + ret_value['alpn_protocols'] = ",".join(listener.alpn_protocols) pools = [] pool_gen = (pool for pool in listener.pools if diff --git a/octavia/common/jinja/haproxy/combined_listeners/templates/macros.j2 b/octavia/common/jinja/haproxy/combined_listeners/templates/macros.j2 index bf44261c2f..41077267ec 100644 --- a/octavia/common/jinja/haproxy/combined_listeners/templates/macros.j2 +++ b/octavia/common/jinja/haproxy/combined_listeners/templates/macros.j2 @@ -66,8 +66,14 @@ peers {{ "%s_peers"|format(loadbalancer.id.replace("-", ""))|trim() }} {% set tls_versions_opt = tls_versions_opt + " no-tlsv13" %} {% endif %} {% endif %} + {% if listener.alpn_protocols is defined %} + {% set alpn_opt = "alpn %s"|format(listener.alpn_protocols)|trim() %} + {% else %} + {% set alpn_opt = "" %} + {% endif %} + bind {{ lb_vip_address }}:{{ listener.protocol_port }} {{ -"%s %s %s %s%s"|format(def_crt_opt, client_ca_opt, ca_crl_opt, ciphers_opt, tls_versions_opt)|trim() }} +"%s %s %s %s%s %s"|format(def_crt_opt, client_ca_opt, ca_crl_opt, ciphers_opt, tls_versions_opt, alpn_opt)|trim() }} {% endmacro %} diff --git a/octavia/common/validate.py b/octavia/common/validate.py index 54be669733..204d35a36f 100644 --- a/octavia/common/validate.py +++ b/octavia/common/validate.py @@ -515,3 +515,21 @@ def check_default_tls_versions_min_conflict(): check_tls_version_min(CONF.api_settings.default_pool_tls_versions, message=pool_message) + + +def check_alpn_protocols(protocols): + if protocols == []: + raise exceptions.ValidationException( + detail=_('Empty ALPN protocol list. Either specify at least one ' + 'ALPN protocol or remove this parameter to use the ' + 'default.')) + + # Unset action + if protocols is None: + return + + invalid_protocols = [p for p in protocols + if p not in constants.SUPPORTED_ALPN_PROTOCOLS] + if invalid_protocols: + raise exceptions.ValidationException( + detail=_('Invalid ALPN protocol: ' + ', '.join(invalid_protocols))) diff --git a/octavia/db/migration/alembic_migrations/versions/2ab994dd3ec2_add_listener_alpn_protocols_column.py b/octavia/db/migration/alembic_migrations/versions/2ab994dd3ec2_add_listener_alpn_protocols_column.py new file mode 100644 index 0000000000..1bd6e43fce --- /dev/null +++ b/octavia/db/migration/alembic_migrations/versions/2ab994dd3ec2_add_listener_alpn_protocols_column.py @@ -0,0 +1,35 @@ +# Copyright 2020 Red Hat, Inc. All rights reserved. +# +# 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. + +"""add listener alpn protocols column + +Revision ID: 2ab994dd3ec2 +Revises: 32e5c35b26a8 +Create Date: 2020-08-02 21:51:21.261087 + +""" + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '2ab994dd3ec2' +down_revision = '32e5c35b26a8' + + +def upgrade(): + op.add_column( + 'listener', + sa.Column('alpn_protocols', sa.String(512), nullable=True) + ) diff --git a/octavia/db/models.py b/octavia/db/models.py index 83ec3740b5..1fe187d715 100644 --- a/octavia/db/models.py +++ b/octavia/db/models.py @@ -540,6 +540,7 @@ class Listener(base_models.BASE, base_models.IdMixin, client_crl_container_id = sa.Column(sa.String(255), nullable=True) tls_ciphers = sa.Column(sa.String(2048), nullable=True) tls_versions = sa.Column(ScalarListType(), nullable=True) + alpn_protocols = sa.Column(ScalarListType(), nullable=True) _tags = orm.relationship( 'Tags', diff --git a/octavia/db/prepare.py b/octavia/db/prepare.py index b2bc6ec704..8c9266771c 100644 --- a/octavia/db/prepare.py +++ b/octavia/db/prepare.py @@ -113,6 +113,10 @@ def create_listener(listener_dict, lb_id): listener_dict['tls_versions'] is None): listener_dict['tls_versions'] = ( CONF.api_settings.default_listener_tls_versions) + if ('alpn_protocols' not in listener_dict or + listener_dict['alpn_protocols'] is None): + listener_dict['alpn_protocols'] = ( + CONF.api_settings.default_listener_alpn_protocols) return listener_dict diff --git a/octavia/tests/common/sample_data_models.py b/octavia/tests/common/sample_data_models.py index fe0e474d9e..7a6a9b218a 100644 --- a/octavia/tests/common/sample_data_models.py +++ b/octavia/tests/common/sample_data_models.py @@ -469,7 +469,9 @@ class SampleDriverDataModels(object): constants.CLIENT_CRL_CONTAINER_ID: self.client_crl_container_ref, lib_consts.ALLOWED_CIDRS: ['192.0.2.0/24', '198.51.100.0/24'], lib_consts.TLS_CIPHERS: constants.CIPHERS_OWASP_SUITE_B, - lib_consts.TLS_VERSIONS: constants.TLS_VERSIONS_OWASP_SUITE_B + lib_consts.TLS_VERSIONS: constants.TLS_VERSIONS_OWASP_SUITE_B, + lib_consts.ALPN_PROTOCOLS: + constants.AMPHORA_SUPPORTED_ALPN_PROTOCOLS } self.test_listener1_dict.update(self._common_test_dict) @@ -509,6 +511,7 @@ class SampleDriverDataModels(object): self.provider_listener1_dict = { lib_consts.ADMIN_STATE_UP: True, lib_consts.ALLOWED_CIDRS: ['192.0.2.0/24', '198.51.100.0/24'], + lib_consts.ALPN_PROTOCOLS: [lib_consts.ALPN_PROTOCOL_HTTP_1_1], lib_consts.CONNECTION_LIMIT: 10000, lib_consts.DEFAULT_POOL: self.provider_pool1_dict, lib_consts.DEFAULT_POOL_ID: self.pool1_id, @@ -538,7 +541,9 @@ class SampleDriverDataModels(object): lib_consts.CLIENT_CRL_CONTAINER_REF: self.client_crl_container_ref, lib_consts.CLIENT_CRL_CONTAINER_DATA: crl_file_content, lib_consts.TLS_CIPHERS: constants.CIPHERS_OWASP_SUITE_B, - lib_consts.TLS_VERSIONS: constants.TLS_VERSIONS_OWASP_SUITE_B + lib_consts.TLS_VERSIONS: constants.TLS_VERSIONS_OWASP_SUITE_B, + lib_consts.ALPN_PROTOCOLS: + constants.AMPHORA_SUPPORTED_ALPN_PROTOCOLS } self.provider_listener2_dict = copy.deepcopy( diff --git a/octavia/tests/functional/api/test_root_controller.py b/octavia/tests/functional/api/test_root_controller.py index fb9fa715d2..d32989ade9 100644 --- a/octavia/tests/functional/api/test_root_controller.py +++ b/octavia/tests/functional/api/test_root_controller.py @@ -45,7 +45,7 @@ class TestRootController(base_db_test.OctaviaDBTestBase): def test_api_versions(self): versions = self._get_versions_with_config() version_ids = tuple(v.get('id') for v in versions) - self.assertEqual(20, len(version_ids)) + self.assertEqual(21, len(version_ids)) self.assertIn('v2.0', version_ids) self.assertIn('v2.1', version_ids) self.assertIn('v2.2', version_ids) @@ -66,6 +66,7 @@ class TestRootController(base_db_test.OctaviaDBTestBase): self.assertIn('v2.17', version_ids) self.assertIn('v2.18', version_ids) self.assertIn('v2.19', version_ids) + self.assertIn('v2.20', version_ids) # Each version should have a 'self' 'href' to the API version URL # [{u'rel': u'self', u'href': u'http://localhost/v2'}] diff --git a/octavia/tests/functional/api/v2/test_listener.py b/octavia/tests/functional/api/v2/test_listener.py index 078c83bf8f..f8cc0e9d52 100644 --- a/octavia/tests/functional/api/v2/test_listener.py +++ b/octavia/tests/functional/api/v2/test_listener.py @@ -1755,6 +1755,8 @@ class TestListener(base.BaseAPITest): self.conf.config(group='api_settings', default_listener_ciphers=( constants.CIPHERS_OWASP_SUITE_B)) + self.conf.config(group='api_settings', + default_listener_alpn_protocols=['http/1.1']) self.cert_manager_mock().get_secret.side_effect = [ sample_certs.X509_CA_CERT, sample_certs.X509_CA_CRL, @@ -1781,7 +1783,8 @@ class TestListener(base.BaseAPITest): client_crl_container_ref=crl_tls_uuid, client_ca_tls_container_ref=ca_tls_uuid, tls_versions=[lib_consts.TLS_VERSION_1_3], - tls_ciphers='TLS_AES_256_GCM_SHA384').get(self.root_tag) + tls_ciphers='TLS_AES_256_GCM_SHA384', + alpn_protocols=['http/1.0']).get(self.root_tag) self.set_lb_status(self.lb_id) unset_params = { 'name': None, 'description': None, 'connection_limit': None, @@ -1791,7 +1794,7 @@ class TestListener(base.BaseAPITest): 'timeout_tcp_inspect': None, 'client_ca_tls_container_ref': None, 'client_authentication': None, 'default_pool_id': None, 'client_crl_container_ref': None, 'tls_versions': None, - 'tls_ciphers': None} + 'tls_ciphers': None, 'alpn_protocols': None} body = self._build_body(unset_params) listener_path = self.LISTENER_PATH.format( listener_id=listener['id']) @@ -1817,6 +1820,7 @@ class TestListener(base.BaseAPITest): api_listener['tls_versions']) self.assertEqual(constants.CIPHERS_OWASP_SUITE_B, api_listener['tls_ciphers']) + self.assertEqual(['http/1.1'], api_listener['alpn_protocols']) @mock.patch('octavia.common.tls_utils.cert_parser.load_certificates_data') def test_update_with_bad_ca_cert(self, mock_cert_data): @@ -2403,6 +2407,95 @@ class TestListener(base.BaseAPITest): .format(constants.PROTOCOL_TERMINATED_HTTPS), listener.get('faultstring')) + # TODO(johnsom) Fix this when there is a noop certificate manager + @mock.patch('octavia.common.tls_utils.cert_parser.load_certificates_data') + def test_create_with_alpn(self, mock_cert_data): + cert1 = data_models.TLSContainer(certificate='cert 1') + mock_cert_data.return_value = {'tls_cert': cert1} + cert_id = uuidutils.generate_uuid() + alpn_protocols = [lib_consts.ALPN_PROTOCOL_HTTP_2, + lib_consts.ALPN_PROTOCOL_HTTP_1_1] + listener = self.create_listener(constants.PROTOCOL_TERMINATED_HTTPS, + 80, self.lb_id, + default_tls_container_ref=cert_id, + alpn_protocols=['h2', 'http/1.1']) + listener_path = self.LISTENER_PATH.format( + listener_id=listener['listener']['id']) + get_listener = self.get(listener_path).json['listener'] + self.assertEqual(alpn_protocols, get_listener['alpn_protocols']) + + # TODO(johnsom) Fix this when there is a noop certificate manager + @mock.patch('octavia.common.tls_utils.cert_parser.load_certificates_data') + def test_create_with_alpn_negative(self, mock_cert_data): + cert1 = data_models.TLSContainer(certificate='cert 1') + mock_cert_data.return_value = {'tls_cert': cert1} + cert_id = uuidutils.generate_uuid() + req_dict = {'protocol': constants.PROTOCOL_TERMINATED_HTTPS, + 'protocol_port': 80, + 'loadbalancer_id': self.lb_id, + 'default_tls_container_ref': cert_id, + 'alpn_protocols': [lib_consts.ALPN_PROTOCOL_HTTP_1_1, + 'invalid-proto']} + res = self.post(self.LISTENERS_PATH, self._build_body(req_dict), + status=400) + fault = res.json['faultstring'] + self.assertIn( + 'Invalid input for field/attribute alpn_protocols', fault) + self.assertIn('Value should be a valid ALPN protocol ID', fault) + self.assert_correct_status(lb_id=self.lb_id) + + # TODO(johnsom) Fix this when there is a noop certificate manager + @mock.patch('octavia.common.tls_utils.cert_parser.load_certificates_data') + def test_update_with_alpn(self, mock_cert_data): + cert_id = uuidutils.generate_uuid() + cert1 = data_models.TLSContainer(certificate='cert 1') + mock_cert_data.return_value = {'tls_cert': cert1} + alpn_protocols_orig = [lib_consts.ALPN_PROTOCOL_HTTP_1_0] + alpn_protocols = [lib_consts.ALPN_PROTOCOL_HTTP_2, + lib_consts.ALPN_PROTOCOL_HTTP_1_1] + listener = self.create_listener( + constants.PROTOCOL_TERMINATED_HTTPS, 80, self.lb_id, + default_tls_container_ref=cert_id, + alpn_protocols=alpn_protocols_orig) + self.set_lb_status(self.lb_id) + listener_path = self.LISTENER_PATH.format( + listener_id=listener['listener']['id']) + get_listener = self.get(listener_path).json['listener'] + self.assertEqual(alpn_protocols_orig, + get_listener.get('alpn_protocols')) + self.put(listener_path, + self._build_body({'alpn_protocols': alpn_protocols})) + get_listener = self.get(listener_path).json['listener'] + self.assertEqual(alpn_protocols, get_listener.get('alpn_protocols')) + + # TODO(johnsom) Fix this when there is a noop certificate manager + @mock.patch('octavia.common.tls_utils.cert_parser.load_certificates_data') + def test_update_with_alpn_negative(self, mock_cert_data): + cert_id = uuidutils.generate_uuid() + cert1 = data_models.TLSContainer(certificate='cert 1') + mock_cert_data.return_value = {'tls_cert': cert1} + alpn_protocols_orig = [lib_consts.ALPN_PROTOCOL_HTTP_1_0] + listener = self.create_listener( + constants.PROTOCOL_TERMINATED_HTTPS, 80, self.lb_id, + default_tls_container_ref=cert_id, + alpn_protocols=alpn_protocols_orig) + self.set_lb_status(self.lb_id) + listener_path = self.LISTENER_PATH.format( + listener_id=listener['listener']['id']) + get_listener = self.get(listener_path).json['listener'] + self.assertEqual(alpn_protocols_orig, + get_listener.get('alpn_protocols')) + + req_dict = {'alpn_protocols': [ + lib_consts.ALPN_PROTOCOL_HTTP_1_1, 'invalid-proto']} + res = self.put(self.LISTENERS_PATH, self._build_body(req_dict), + status=400) + fault = res.json['faultstring'] + self.assertIn( + 'Invalid input for field/attribute alpn_protocols', fault) + self.assertIn('Value should be a valid ALPN protocol ID', fault) + self.assert_correct_status(lb_id=self.lb_id) + # TODO(johnsom) Fix this when there is a noop certificate manager @mock.patch('octavia.common.tls_utils.cert_parser.load_certificates_data') def test_create_with_sni_data(self, mock_cert_data): diff --git a/octavia/tests/functional/api/v2/test_load_balancer.py b/octavia/tests/functional/api/v2/test_load_balancer.py index 4912f0e414..cc2face756 100644 --- a/octavia/tests/functional/api/v2/test_load_balancer.py +++ b/octavia/tests/functional/api/v2/test_load_balancer.py @@ -17,6 +17,7 @@ import random from unittest import mock from octavia_lib.api.drivers import exceptions as lib_exceptions +from octavia_lib.common import constants as lib_consts from oslo_config import cfg from oslo_config import fixture as oslo_fixture from oslo_utils import uuidutils @@ -2623,7 +2624,8 @@ class TestLoadBalancerGraph(base.BaseAPITest): 'client_crl_container_ref': None, 'allowed_cidrs': None, 'tls_ciphers': None, - 'tls_versions': None + 'tls_versions': None, + 'alpn_protocols': None } if create_sni_containers: create_listener['sni_container_refs'] = create_sni_containers @@ -2673,6 +2675,9 @@ class TestLoadBalancerGraph(base.BaseAPITest): expected_listener['tls_ciphers'] = constants.CIPHERS_OWASP_SUITE_B expected_listener['tls_versions'] = ( constants.TLS_VERSIONS_OWASP_SUITE_B) + expected_listener['alpn_protocols'] = ( + [lib_consts.ALPN_PROTOCOL_HTTP_1_1, + lib_consts.ALPN_PROTOCOL_HTTP_1_0]) return create_listener, expected_listener diff --git a/octavia/tests/unit/api/drivers/amphora_driver/v1/test_amphora_driver.py b/octavia/tests/unit/api/drivers/amphora_driver/v1/test_amphora_driver.py index 07bf73d127..84fcdb6d72 100644 --- a/octavia/tests/unit/api/drivers/amphora_driver/v1/test_amphora_driver.py +++ b/octavia/tests/unit/api/drivers/amphora_driver/v1/test_amphora_driver.py @@ -122,11 +122,24 @@ class TestAmphoraDriver(base.TestRpc): @mock.patch('oslo_messaging.RPCClient.cast') def test_listener_create(self, mock_cast): provider_listener = driver_dm.Listener( - listener_id=self.sample_data.listener1_id) + listener_id=self.sample_data.listener1_id, + alpn_protocols=consts.AMPHORA_SUPPORTED_ALPN_PROTOCOLS) self.amp_driver.listener_create(provider_listener) payload = {consts.LISTENER_ID: self.sample_data.listener1_id} mock_cast.assert_called_with({}, 'create_listener', **payload) + @mock.patch('oslo_messaging.RPCClient.cast') + def test_listener_create_unsupported_alpn(self, mock_cast): + provider_listener = driver_dm.Listener( + listener_id=self.sample_data.listener1_id) + # NOTE(cgoncalves): test will fail once HTTP/2 is supported + provider_listener.alpn_protocols = ['http/1.1', 'h2'] + self.assertRaises( + exceptions.UnsupportedOptionError, + self.amp_driver.listener_create, + provider_listener) + mock_cast.assert_not_called() + @mock.patch('oslo_messaging.RPCClient.cast') def test_listener_delete(self, mock_cast): provider_listener = driver_dm.Listener( @@ -161,6 +174,20 @@ class TestAmphoraDriver(base.TestRpc): consts.LISTENER_UPDATES: listener_dict} mock_cast.assert_called_with({}, 'update_listener', **payload) + @mock.patch('oslo_messaging.RPCClient.cast') + def test_listener_update_unsupported_alpn(self, mock_cast): + old_provider_listener = driver_dm.Listener( + listener_id=self.sample_data.listener1_id) + # NOTE(cgoncalves): test will fail once HTTP/2 is supported + provider_listener = driver_dm.Listener( + listener_id=self.sample_data.listener1_id, + alpn_protocols=['http/1.1', 'h2']) + self.assertRaises( + exceptions.UnsupportedOptionError, + self.amp_driver.listener_update, + old_provider_listener, + provider_listener) + # Pool @mock.patch('oslo_messaging.RPCClient.cast') def test_pool_create(self, mock_cast): diff --git a/octavia/tests/unit/api/drivers/amphora_driver/v2/test_amphora_driver.py b/octavia/tests/unit/api/drivers/amphora_driver/v2/test_amphora_driver.py index 822ce8dfd1..dbc2a8154d 100644 --- a/octavia/tests/unit/api/drivers/amphora_driver/v2/test_amphora_driver.py +++ b/octavia/tests/unit/api/drivers/amphora_driver/v2/test_amphora_driver.py @@ -122,11 +122,24 @@ class TestAmphoraDriver(base.TestRpc): @mock.patch('oslo_messaging.RPCClient.cast') def test_listener_create(self, mock_cast): provider_listener = driver_dm.Listener( - listener_id=self.sample_data.listener1_id) + listener_id=self.sample_data.listener1_id, + alpn_protocols=consts.AMPHORA_SUPPORTED_ALPN_PROTOCOLS) self.amp_driver.listener_create(provider_listener) payload = {consts.LISTENER: provider_listener.to_dict()} mock_cast.assert_called_with({}, 'create_listener', **payload) + @mock.patch('oslo_messaging.RPCClient.cast') + def test_listener_create_unsupported_alpn(self, mock_cast): + provider_listener = driver_dm.Listener( + listener_id=self.sample_data.listener1_id) + # NOTE(cgoncalves): test will fail once HTTP/2 is supported + provider_listener.alpn_protocols = ['http/1.1', 'h2'] + self.assertRaises( + exceptions.UnsupportedOptionError, + self.amp_driver.listener_create, + provider_listener) + mock_cast.assert_not_called() + @mock.patch('oslo_messaging.RPCClient.cast') def test_listener_delete(self, mock_cast): provider_listener = driver_dm.Listener( @@ -163,6 +176,20 @@ class TestAmphoraDriver(base.TestRpc): consts.LISTENER_UPDATES: listener_dict} mock_cast.assert_called_with({}, 'update_listener', **payload) + @mock.patch('oslo_messaging.RPCClient.cast') + def test_listener_update_unsupported_alpn(self, mock_cast): + old_provider_listener = driver_dm.Listener( + listener_id=self.sample_data.listener1_id) + # NOTE(cgoncalves): test will fail once HTTP/2 is supported + provider_listener = driver_dm.Listener( + listener_id=self.sample_data.listener1_id, + alpn_protocols=['http/1.1', 'h2']) + self.assertRaises( + exceptions.UnsupportedOptionError, + self.amp_driver.listener_update, + old_provider_listener, + provider_listener) + # Pool @mock.patch('oslo_messaging.RPCClient.cast') def test_pool_create(self, mock_cast): diff --git a/octavia/tests/unit/api/v2/types/test_listeners.py b/octavia/tests/unit/api/v2/types/test_listeners.py index 5659e18949..6cd1a47c70 100644 --- a/octavia/tests/unit/api/v2/types/test_listeners.py +++ b/octavia/tests/unit/api/v2/types/test_listeners.py @@ -137,6 +137,13 @@ class TestListenerPOST(base.BaseTypesTest, TestListener): listener = wsme_json.fromjson(self._type, body) self.assertEqual(listener.project_id, body['project_id']) + def test_invalid_alpn_protocols(self): + body = {"protocol": constants.PROTOCOL_HTTP, "protocol_port": 80, + "loadbalancer_id": uuidutils.generate_uuid(), + "alpn_protocols": ["bad", "boy"]} + self.assertRaises(exc.InvalidInput, wsme_json.fromjson, self._type, + body) + class TestListenerPUT(base.BaseTypesTest, TestListener): @@ -153,3 +160,8 @@ class TestListenerPUT(base.BaseTypesTest, TestListener): "tags": ['test_tag']} listener = wsme_json.fromjson(self._type, body) self.assertEqual(wsme_types.Unset, listener.admin_state_up) + + def test_invalid_alpn_protocols(self): + body = {"alpn_protocols": ["bad", "boy"]} + self.assertRaises(exc.InvalidInput, wsme_json.fromjson, self._type, + body) diff --git a/octavia/tests/unit/common/jinja/haproxy/combined_listeners/test_jinja_cfg.py b/octavia/tests/unit/common/jinja/haproxy/combined_listeners/test_jinja_cfg.py index e521645ff3..e9fc6d3c3f 100644 --- a/octavia/tests/unit/common/jinja/haproxy/combined_listeners/test_jinja_cfg.py +++ b/octavia/tests/unit/common/jinja/haproxy/combined_listeners/test_jinja_cfg.py @@ -52,13 +52,14 @@ class TestHaproxyCfg(base.TestCase): "ca-file /var/lib/octavia/certs/sample_loadbalancer_id_1/" "client_ca.pem verify required crl-file /var/lib/octavia/" "certs/sample_loadbalancer_id_1/SHA_ID.pem ciphers {ciphers} " - "no-sslv3 no-tlsv10 no-tlsv11\n" + "no-sslv3 no-tlsv10 no-tlsv11 alpn {alpn}\n" " mode http\n" " default_backend sample_pool_id_1:sample_listener_id_1\n" " timeout client 50000\n").format( maxconn=constants.HAPROXY_DEFAULT_MAXCONN, crt_list=FAKE_CRT_LIST_FILENAME, - ciphers=constants.CIPHERS_OWASP_SUITE_B) + ciphers=constants.CIPHERS_OWASP_SUITE_B, + alpn=",".join(constants.AMPHORA_SUPPORTED_ALPN_PROTOCOLS)) be = ("backend sample_pool_id_1:sample_listener_id_1\n" " mode http\n" " balance roundrobin\n" @@ -105,13 +106,14 @@ class TestHaproxyCfg(base.TestCase): " maxconn {maxconn}\n" " redirect scheme https if !{{ ssl_fc }}\n" " bind 10.0.0.2:443 ssl crt-list {crt_list}" - " ciphers {ciphers} no-sslv3 no-tlsv10 no-tlsv11\n" + " ciphers {ciphers} no-sslv3 no-tlsv10 no-tlsv11 alpn {alpn}\n" " mode http\n" " default_backend sample_pool_id_1:sample_listener_id_1\n" " timeout client 50000\n").format( maxconn=constants.HAPROXY_DEFAULT_MAXCONN, crt_list=FAKE_CRT_LIST_FILENAME, - ciphers=constants.CIPHERS_OWASP_SUITE_B) + ciphers=constants.CIPHERS_OWASP_SUITE_B, + alpn=",".join(constants.AMPHORA_SUPPORTED_ALPN_PROTOCOLS)) be = ("backend sample_pool_id_1:sample_listener_id_1\n" " mode http\n" " balance roundrobin\n" @@ -155,12 +157,13 @@ class TestHaproxyCfg(base.TestCase): " maxconn {maxconn}\n" " redirect scheme https if !{{ ssl_fc }}\n" " bind 10.0.0.2:443 ssl crt-list {crt_list} " - "no-sslv3 no-tlsv10 no-tlsv11\n" + "no-sslv3 no-tlsv10 no-tlsv11 alpn {alpn}\n" " mode http\n" " default_backend sample_pool_id_1:sample_listener_id_1\n" " timeout client 50000\n").format( maxconn=constants.HAPROXY_DEFAULT_MAXCONN, - crt_list=FAKE_CRT_LIST_FILENAME) + crt_list=FAKE_CRT_LIST_FILENAME, + alpn=",".join(constants.AMPHORA_SUPPORTED_ALPN_PROTOCOLS)) be = ("backend sample_pool_id_1:sample_listener_id_1\n" " mode http\n" " balance roundrobin\n" @@ -207,13 +210,15 @@ class TestHaproxyCfg(base.TestCase): "ssl crt-list {crt_list} " "ca-file /var/lib/octavia/certs/sample_loadbalancer_id_1/" "client_ca.pem verify required crl-file /var/lib/octavia/" - "certs/sample_loadbalancer_id_1/SHA_ID.pem ciphers {ciphers}\n" + "certs/sample_loadbalancer_id_1/SHA_ID.pem ciphers {ciphers} " + "alpn {alpn}\n" " mode http\n" " default_backend sample_pool_id_1:sample_listener_id_1\n" " timeout client 50000\n").format( maxconn=constants.HAPROXY_DEFAULT_MAXCONN, crt_list=FAKE_CRT_LIST_FILENAME, - ciphers=constants.CIPHERS_OWASP_SUITE_B) + ciphers=constants.CIPHERS_OWASP_SUITE_B, + alpn=",".join(constants.AMPHORA_SUPPORTED_ALPN_PROTOCOLS)) be = ("backend sample_pool_id_1:sample_listener_id_1\n" " mode http\n" " balance roundrobin\n" @@ -259,12 +264,14 @@ class TestHaproxyCfg(base.TestCase): fe = ("frontend sample_listener_id_1\n" " maxconn {maxconn}\n" " redirect scheme https if !{{ ssl_fc }}\n" - " bind 10.0.0.2:443 ssl crt-list {crt_list}\n" + " bind 10.0.0.2:443 ssl crt-list {crt_list} " + "alpn {alpn}\n" " mode http\n" " default_backend sample_pool_id_1:sample_listener_id_1\n" " timeout client 50000\n").format( maxconn=constants.HAPROXY_DEFAULT_MAXCONN, - crt_list=FAKE_CRT_LIST_FILENAME) + crt_list=FAKE_CRT_LIST_FILENAME, + alpn=",".join(constants.AMPHORA_SUPPORTED_ALPN_PROTOCOLS)) be = ("backend sample_pool_id_1:sample_listener_id_1\n" " mode http\n" " balance roundrobin\n" @@ -299,6 +306,110 @@ class TestHaproxyCfg(base.TestCase): frontend=fe, backend=be), rendered_obj) + def test_render_template_tls_alpn(self): + conf = oslo_fixture.Config(cfg.CONF) + conf.config(group="haproxy_amphora", base_cert_dir='/fake_cert_dir') + FAKE_CRT_LIST_FILENAME = os.path.join( + CONF.haproxy_amphora.base_cert_dir, + 'sample_loadbalancer_id_1/sample_listener_id_1.pem') + alpn_protocols = ['chip', 'dale'] + fe = ("frontend sample_listener_id_1\n" + " maxconn {maxconn}\n" + " redirect scheme https if !{{ ssl_fc }}\n" + " bind 10.0.0.2:443 ssl crt-list {crt_list} " + "ciphers {ciphers} no-sslv3 no-tlsv10 no-tlsv11 alpn {alpn}\n" + " mode http\n" + " default_backend sample_pool_id_1:sample_listener_id_1\n" + " timeout client 50000\n").format( + maxconn=constants.HAPROXY_DEFAULT_MAXCONN, + crt_list=FAKE_CRT_LIST_FILENAME, + ciphers=constants.CIPHERS_OWASP_SUITE_B, + alpn=",".join(alpn_protocols)) + be = ("backend sample_pool_id_1:sample_listener_id_1\n" + " mode http\n" + " balance roundrobin\n" + " cookie SRV insert indirect nocache\n" + " timeout check 31s\n" + " option httpchk GET /index.html HTTP/1.0\\r\\n\n" + " http-check expect rstatus 418\n" + " fullconn {maxconn}\n" + " option allbackups\n" + " timeout connect 5000\n" + " timeout server 50000\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").format( + maxconn=constants.HAPROXY_DEFAULT_MAXCONN) + rendered_obj = self.jinja_cfg.render_loadbalancer_obj( + sample_configs_combined.sample_amphora_tuple(), + [sample_configs_combined.sample_listener_tuple( + proto='TERMINATED_HTTPS', tls=True, + alpn_protocols=alpn_protocols)], + tls_certs={'cont_id_1': + sample_configs_combined.sample_tls_container_tuple( + id='tls_container_id', + certificate='ImAalsdkfjCert', + private_key='ImAsdlfksdjPrivateKey', + primary_cn="FakeCN")}) + self.assertEqual( + sample_configs_combined.sample_base_expected_config( + frontend=fe, backend=be), + rendered_obj) + + def test_render_template_tls_no_alpn(self): + conf = oslo_fixture.Config(cfg.CONF) + conf.config(group="haproxy_amphora", base_cert_dir='/fake_cert_dir') + FAKE_CRT_LIST_FILENAME = os.path.join( + CONF.haproxy_amphora.base_cert_dir, + 'sample_loadbalancer_id_1/sample_listener_id_1.pem') + fe = ("frontend sample_listener_id_1\n" + " maxconn {maxconn}\n" + " redirect scheme https if !{{ ssl_fc }}\n" + " bind 10.0.0.2:443 ssl crt-list {crt_list} " + "ciphers {ciphers} no-sslv3 no-tlsv10 no-tlsv11\n" + " mode http\n" + " default_backend sample_pool_id_1:sample_listener_id_1\n" + " timeout client 50000\n").format( + maxconn=constants.HAPROXY_DEFAULT_MAXCONN, + crt_list=FAKE_CRT_LIST_FILENAME, + ciphers=constants.CIPHERS_OWASP_SUITE_B) + be = ("backend sample_pool_id_1:sample_listener_id_1\n" + " mode http\n" + " balance roundrobin\n" + " cookie SRV insert indirect nocache\n" + " timeout check 31s\n" + " option httpchk GET /index.html HTTP/1.0\\r\\n\n" + " http-check expect rstatus 418\n" + " fullconn {maxconn}\n" + " option allbackups\n" + " timeout connect 5000\n" + " timeout server 50000\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").format( + maxconn=constants.HAPROXY_DEFAULT_MAXCONN) + rendered_obj = self.jinja_cfg.render_loadbalancer_obj( + sample_configs_combined.sample_amphora_tuple(), + [sample_configs_combined.sample_listener_tuple( + proto='TERMINATED_HTTPS', tls=True, + alpn_protocols=None)], + tls_certs={'cont_id_1': + sample_configs_combined.sample_tls_container_tuple( + id='tls_container_id', + certificate='ImAalsdkfjCert', + private_key='ImAsdlfksdjPrivateKey', + primary_cn="FakeCN")}) + self.assertEqual( + sample_configs_combined.sample_base_expected_config( + frontend=fe, backend=be), + rendered_obj) + def test_render_template_http(self): be = ("backend sample_pool_id_1:sample_listener_id_1\n" " mode http\n" @@ -1397,7 +1508,7 @@ class TestHaproxyCfg(base.TestCase): " maxconn {maxconn}\n" " redirect scheme https if !{{ ssl_fc }}\n" " bind 10.0.0.2:443 ciphers {ciphers} " - "no-sslv3 no-tlsv10 no-tlsv11\n" + "no-sslv3 no-tlsv10 no-tlsv11 alpn {alpn}\n" " mode http\n" " acl sample_l7rule_id_1 path -m beg /api\n" " use_backend sample_pool_id_2:sample_listener_id_1" @@ -1433,7 +1544,8 @@ class TestHaproxyCfg(base.TestCase): " default_backend sample_pool_id_1:sample_listener_id_1\n" " timeout client 50000\n".format( maxconn=constants.HAPROXY_DEFAULT_MAXCONN, - ciphers=constants.CIPHERS_OWASP_SUITE_B)) + ciphers=constants.CIPHERS_OWASP_SUITE_B, + alpn=",".join(constants.AMPHORA_SUPPORTED_ALPN_PROTOCOLS))) be = ("backend sample_pool_id_1:sample_listener_id_1\n" " mode http\n" " balance roundrobin\n" diff --git a/octavia/tests/unit/common/sample_configs/sample_configs_combined.py b/octavia/tests/unit/common/sample_configs/sample_configs_combined.py index 4359638636..ef41ad6d79 100644 --- a/octavia/tests/unit/common/sample_configs/sample_configs_combined.py +++ b/octavia/tests/unit/common/sample_configs/sample_configs_combined.py @@ -605,13 +605,16 @@ def sample_listener_tuple(proto=None, monitor=True, alloc_default_pool=True, backend_tls_ciphers=None, tls_versions=constants.TLS_VERSIONS_OWASP_SUITE_B, backend_tls_versions=constants. - TLS_VERSIONS_OWASP_SUITE_B): + TLS_VERSIONS_OWASP_SUITE_B, + alpn_protocols=constants. + AMPHORA_SUPPORTED_ALPN_PROTOCOLS): proto = 'HTTP' if proto is None else proto if be_proto is None: be_proto = 'HTTP' if proto == 'TERMINATED_HTTPS' else proto if proto != constants.PROTOCOL_TERMINATED_HTTPS: tls_ciphers = None tls_versions = None + alpn_protocols = None if pool_cert is False: backend_tls_versions = None topology = 'SINGLE' if topology is None else topology @@ -628,7 +631,7 @@ def sample_listener_tuple(proto=None, monitor=True, alloc_default_pool=True, 'timeout_tcp_inspect, client_ca_tls_certificate_id, ' 'client_ca_tls_certificate, client_authentication, ' 'client_crl_container_id, provisioning_status, ' - 'tls_ciphers, tls_versions') + 'tls_ciphers, tls_versions, alpn_protocols') if l7: pools = [ sample_pool_tuple( @@ -747,7 +750,8 @@ def sample_listener_tuple(proto=None, monitor=True, alloc_default_pool=True, client_crl_container_id='cont_id_crl' if client_crl_cert else '', provisioning_status=provisioning_status, tls_ciphers=tls_ciphers, - tls_versions=tls_versions + tls_versions=tls_versions, + alpn_protocols=alpn_protocols ) if recursive_nest: listener.load_balancer.listeners.append(listener) diff --git a/octavia/tests/unit/common/test_validations.py b/octavia/tests/unit/common/test_validations.py index ce4d1c6374..731338026a 100644 --- a/octavia/tests/unit/common/test_validations.py +++ b/octavia/tests/unit/common/test_validations.py @@ -513,3 +513,17 @@ class TestValidations(base.TestCase): 'TLSv1', 'TLSv1.3']) self.assertRaises(exceptions.ValidationException, validate.check_default_tls_versions_min_conflict) + + def test_check_alpn_protocols(self): + # Test valid list + validate.check_alpn_protocols(['h2', 'http/1.1', 'http/1.0']) + # Test invalid list + self.assertRaises( + exceptions.ValidationException, + validate.check_alpn_protocols, + ['httpie', 'foobar/1.2.3']) + # Test empty list + self.assertRaises( + exceptions.ValidationException, + validate.check_alpn_protocols, + []) diff --git a/releasenotes/notes/add-listener-tls-alpn-support-3056fb01b418c88f.yaml b/releasenotes/notes/add-listener-tls-alpn-support-3056fb01b418c88f.yaml new file mode 100644 index 0000000000..f0b2ee19a1 --- /dev/null +++ b/releasenotes/notes/add-listener-tls-alpn-support-3056fb01b418c88f.yaml @@ -0,0 +1,9 @@ +--- +features: + - | + Added support for TLS extension Application Layer Protocol Negotiation + (ALPN) to TLS-terminated HTTPS load balancers. A new parameter + ``alpn_protocols`` was added to the Listener API. + - | + Octavia provider drivers can now offer HTTP/2 over TLS (protocol + negotiation via ALPN) to clients. diff --git a/requirements.txt b/requirements.txt index c600d9909e..fcbc03ff92 100644 --- a/requirements.txt +++ b/requirements.txt @@ -43,7 +43,7 @@ castellan>=0.16.0 # Apache-2.0 tenacity>=5.0.4 # Apache-2.0 distro>=1.2.0 # Apache-2.0 jsonschema>=3.2.0 # MIT -octavia-lib>=2.0.0 # Apache-2.0 +octavia-lib>=2.2.0 # Apache-2.0 netaddr>=0.7.19 # BSD simplejson>=3.13.2 # MIT setproctitle>=1.1.10 # BSD