Add ALPN support for TLS-terminated HTTPS LBs

ALPN is a TLS extension for application-layer protocol negotiation
within the TLS handshake [1].

This patch extends the Listener API to include a new 'alpn_protocols'
parameter. With this parameter, users can set an ALPN preference list
(descending order of preference).

Presently, the amphora provider driver is limited to http/1.0 and
http/1.1 ALPN protocol IDs. Support for "h2" (HTTP/2 over TLS) depends
on HAProxy 2.0 or newer.

[1] https://tools.ietf.org/html/rfc7301

Change-Id: If08a8169498cdfaa75440e8971ba0caff45ac4c4
This commit is contained in:
Carlos Goncalves 2020-08-03 17:48:45 +02:00
parent 0b1d8dd5e7
commit a5f0524fd0
40 changed files with 528 additions and 37 deletions

View File

@ -183,6 +183,22 @@ allowed_cidrs-optional:
min_version: 2.12 min_version: 2.12
required: false required: false
type: array 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: amphora-id:
description: | description: |
The associated amphora ID. The associated amphora ID.

View File

@ -1 +1 @@
curl -X POST -H "Content-Type: application/json" -H "X-Auth-Token: <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: <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

View File

@ -29,6 +29,7 @@
"198.51.100.0/24" "198.51.100.0/24"
], ],
"tls_ciphers": "ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256", "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"]
} }
} }

View File

@ -44,6 +44,7 @@
"198.51.100.0/24" "198.51.100.0/24"
], ],
"tls_ciphers": "ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256", "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"]
} }
} }

View File

@ -44,6 +44,7 @@
"198.51.100.0/24" "198.51.100.0/24"
], ],
"tls_ciphers": "ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256", "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"]
} }
} }

View File

@ -1 +1 @@
curl -X PUT -H "Content-Type: application/json" -H "X-Auth-Token: <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: <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

View File

@ -25,6 +25,7 @@
"198.51.100.0/24" "198.51.100.0/24"
], ],
"tls_ciphers": "ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256", "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"]
} }
} }

View File

@ -44,6 +44,7 @@
"198.51.100.0/24" "198.51.100.0/24"
], ],
"tls_ciphers": "ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256", "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"]
} }
} }

View File

@ -46,7 +46,8 @@
"198.51.100.0/24" "198.51.100.0/24"
], ],
"tls_ciphers": "ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256", "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"]
} }
] ]
} }

View File

@ -47,6 +47,7 @@ Response Parameters
- admin_state_up: admin_state_up - admin_state_up: admin_state_up
- allowed_cidrs: allowed_cidrs - allowed_cidrs: allowed_cidrs
- alpn_protocols: alpn_protocols
- client_authentication: client_authentication - client_authentication: client_authentication
- client_ca_tls_container_ref: client_ca_tls_container_ref - client_ca_tls_container_ref: client_ca_tls_container_ref
- client_crl_container_ref: client_crl_container_ref - client_crl_container_ref: client_crl_container_ref
@ -143,6 +144,7 @@ Request
- admin_state_up: admin_state_up-default-optional - admin_state_up: admin_state_up-default-optional
- allowed_cidrs: allowed_cidrs-optional - allowed_cidrs: allowed_cidrs-optional
- alpn_protocols: alpn_protocols-optional
- client_authentication: client_authentication-optional - client_authentication: client_authentication-optional
- client_ca_tls_container_ref: client_ca_tls_container_ref-optional - client_ca_tls_container_ref: client_ca_tls_container_ref-optional
- client_crl_container_ref: client_crl_container_ref-optional - client_crl_container_ref: client_crl_container_ref-optional
@ -266,6 +268,7 @@ Response Parameters
- admin_state_up: admin_state_up - admin_state_up: admin_state_up
- allowed_cidrs: allowed_cidrs - allowed_cidrs: allowed_cidrs
- alpn_protocols: alpn_protocols
- client_authentication: client_authentication - client_authentication: client_authentication
- client_ca_tls_container_ref: client_ca_tls_container_ref - client_ca_tls_container_ref: client_ca_tls_container_ref
- client_crl_container_ref: client_crl_container_ref - client_crl_container_ref: client_crl_container_ref
@ -346,6 +349,7 @@ Response Parameters
- admin_state_up: admin_state_up - admin_state_up: admin_state_up
- allowed_cidrs: allowed_cidrs - allowed_cidrs: allowed_cidrs
- alpn_protocols: alpn_protocols
- client_authentication: client_authentication - client_authentication: client_authentication
- client_ca_tls_container_ref: client_ca_tls_container_ref - client_ca_tls_container_ref: client_ca_tls_container_ref
- client_crl_container_ref: client_crl_container_ref - client_crl_container_ref: client_crl_container_ref
@ -416,6 +420,7 @@ Request
- admin_state_up: admin_state_up-default-optional - admin_state_up: admin_state_up-default-optional
- allowed_cidrs: allowed_cidrs-optional - allowed_cidrs: allowed_cidrs-optional
- alpn_protocols: alpn_protocols-optional
- client_authentication: client_authentication-optional - client_authentication: client_authentication-optional
- client_ca_tls_container_ref: client_ca_tls_container_ref-optional - client_ca_tls_container_ref: client_ca_tls_container_ref-optional
- client_crl_container_ref: client_crl_container_ref-optional - client_crl_container_ref: client_crl_container_ref-optional
@ -454,6 +459,7 @@ Response Parameters
- admin_state_up: admin_state_up - admin_state_up: admin_state_up
- allowed_cidrs: allowed_cidrs - allowed_cidrs: allowed_cidrs
- alpn_protocols: alpn_protocols
- client_authentication: client_authentication - client_authentication: client_authentication
- client_ca_tls_container_ref: client_ca_tls_container_ref - client_ca_tls_container_ref: client_ca_tls_container_ref
- client_crl_container_ref: client_crl_container_ref - client_crl_container_ref: client_crl_container_ref

View File

@ -38,6 +38,14 @@ cli=openstack loadbalancer listener create [--allowed-cidr <allowed_cidr>] <load
driver.amphora=complete driver.amphora=complete
driver.ovn=missing driver.ovn=missing
[operation.alpn_protocol]
title=alpn_protocol
status=optional
notes=List of accepted ALPN protocols (can be set multiple times).
cli=openstack loadbalancer listener create [--alpn-protocol <protocol>] <loadbalancer>
driver.amphora=complete
driver.ovn=missing
[operation.client_authentication] [operation.client_authentication]
title=client_authentication title=client_authentication
status=optional status=optional

View File

@ -84,6 +84,11 @@
# either. Available versions: SSLv3, TLSv1, TLSv1.1, TLSv1.2, TLSv1.3 # either. Available versions: SSLv3, TLSv1, TLSv1.1, TLSv1.2, TLSv1.3
# minimum_tls_version = # 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] [database]
# This line MUST be changed to actually run the plugin. # This line MUST be changed to actually run the plugin.
# Example: # Example:

View File

@ -70,7 +70,7 @@ munch==2.2.0
netaddr==0.7.19 netaddr==0.7.19
netifaces==0.10.4 netifaces==0.10.4
networkx==1.11 networkx==1.11
octavia-lib==2.0.0 octavia-lib==2.2.0
openstacksdk==0.12.0 openstacksdk==0.12.0
os-client-config==1.29.0 os-client-config==1.29.0
os-service-types==1.2.0 os-service-types==1.2.0

View File

@ -18,6 +18,7 @@ from dateutil import parser
import netaddr import netaddr
from wsme import types as wtypes from wsme import types as wtypes
from octavia.common import constants
from octavia.common import exceptions from octavia.common import exceptions
from octavia.common import validate from octavia.common import validate
@ -55,6 +56,19 @@ class CidrType(wtypes.UserType):
raise ValueError(error) from e 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): class URLType(wtypes.UserType):
basetype = str basetype = str
name = 'url' name = 'url'

View File

@ -62,6 +62,19 @@ class AmphoraProviderDriver(driver_base.ProviderDriver):
user_fault_string=msg, user_fault_string=msg,
operator_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 # Load Balancer
def create_vip_port(self, loadbalancer_id, project_id, vip_dictionary): def create_vip_port(self, loadbalancer_id, project_id, vip_dictionary):
vip_obj = driver_utils.provider_vip_dict_to_vip_obj(vip_dictionary) vip_obj = driver_utils.provider_vip_dict_to_vip_obj(vip_dictionary)
@ -123,6 +136,7 @@ class AmphoraProviderDriver(driver_base.ProviderDriver):
# Listener # Listener
def listener_create(self, listener): def listener_create(self, listener):
self._validate_alpn_protocols(listener)
payload = {consts.LISTENER_ID: listener.listener_id} payload = {consts.LISTENER_ID: listener.listener_id}
self.client.cast({}, 'create_listener', **payload) self.client.cast({}, 'create_listener', **payload)
@ -132,6 +146,7 @@ class AmphoraProviderDriver(driver_base.ProviderDriver):
self.client.cast({}, 'delete_listener', **payload) self.client.cast({}, 'delete_listener', **payload)
def listener_update(self, old_listener, new_listener): def listener_update(self, old_listener, new_listener):
self._validate_alpn_protocols(new_listener)
listener_dict = new_listener.to_dict() listener_dict = new_listener.to_dict()
if 'admin_state_up' in listener_dict: if 'admin_state_up' in listener_dict:
listener_dict['enabled'] = listener_dict.pop('admin_state_up') listener_dict['enabled'] = listener_dict.pop('admin_state_up')

View File

@ -64,6 +64,19 @@ class AmphoraProviderDriver(driver_base.ProviderDriver):
user_fault_string=msg, user_fault_string=msg,
operator_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 # Load Balancer
def create_vip_port(self, loadbalancer_id, project_id, vip_dictionary): def create_vip_port(self, loadbalancer_id, project_id, vip_dictionary):
vip_obj = driver_utils.provider_vip_dict_to_vip_obj(vip_dictionary) vip_obj = driver_utils.provider_vip_dict_to_vip_obj(vip_dictionary)
@ -136,6 +149,7 @@ class AmphoraProviderDriver(driver_base.ProviderDriver):
# Listener # Listener
def listener_create(self, listener): def listener_create(self, listener):
self._validate_alpn_protocols(listener)
payload = {consts.LISTENER: listener.to_dict()} payload = {consts.LISTENER: listener.to_dict()}
self._encrypt_listener_dict(payload) self._encrypt_listener_dict(payload)
@ -146,6 +160,7 @@ class AmphoraProviderDriver(driver_base.ProviderDriver):
self.client.cast({}, 'delete_listener', **payload) self.client.cast({}, 'delete_listener', **payload)
def listener_update(self, old_listener, new_listener): def listener_update(self, old_listener, new_listener):
self._validate_alpn_protocols(new_listener)
original_listener = old_listener.to_dict() original_listener = old_listener.to_dict()
listener_updates = new_listener.to_dict() listener_updates = new_listener.to_dict()

View File

@ -122,6 +122,9 @@ class RootController(object):
self._add_a_version(versions, 'v2.18', 'v2', 'SUPPORTED', self._add_a_version(versions, 'v2.18', 'v2', 'SUPPORTED',
'2020-04-29T01:00:00Z', host_url) '2020-04-29T01:00:00Z', host_url)
# Add quota support to octavia's l7policy and l7rule # 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) '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} return {'versions': versions}

View File

@ -298,6 +298,8 @@ class ListenersController(base.BaseController):
validate.check_tls_version_list(listener_dict['tls_versions']) validate.check_tls_version_list(listener_dict['tls_versions'])
# Validate TLS versions against minimum # Validate TLS versions against minimum
validate.check_tls_version_min(listener_dict['tls_versions']) validate.check_tls_version_min(listener_dict['tls_versions'])
# Validate ALPN protocol list
validate.check_alpn_protocols(listener_dict['alpn_protocols'])
try: try:
db_listener = self.repositories.listener.create( db_listener = self.repositories.listener.create(
@ -511,6 +513,10 @@ class ListenersController(base.BaseController):
# Validate TLS versions against minimum # Validate TLS versions against minimum
validate.check_tls_version_min(listener.tls_versions) 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): def _set_default_on_none(self, listener):
"""Reset settings to their default values if None/null was passed in """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: if listener.tls_versions is None:
listener.tls_versions = ( listener.tls_versions = (
CONF.api_settings.default_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, @wsme_pecan.wsexpose(listener_types.ListenerRootResponse, wtypes.text,
body=listener_types.ListenerRootPUT, status_code=200) body=listener_types.ListenerRootPUT, status_code=200)

View File

@ -65,6 +65,7 @@ class ListenerResponse(BaseListenerType):
allowed_cidrs = wtypes.wsattr([types.CidrType()]) allowed_cidrs = wtypes.wsattr([types.CidrType()])
tls_ciphers = wtypes.StringType() tls_ciphers = wtypes.StringType()
tls_versions = wtypes.wsattr(wtypes.ArrayType(wtypes.StringType())) tls_versions = wtypes.wsattr(wtypes.ArrayType(wtypes.StringType()))
alpn_protocols = wtypes.wsattr(wtypes.ArrayType(types.AlpnProtocolType()))
@classmethod @classmethod
def from_data_model(cls, data_model, children=False): 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] l7policy_type.from_data_model(i) for i in data_model.l7policies]
listener.tls_versions = data_model.tls_versions listener.tls_versions = data_model.tls_versions
listener.alpn_protocols = data_model.alpn_protocols
return listener return listener
@ -158,6 +160,7 @@ class ListenerPOST(BaseListenerType):
tls_ciphers = wtypes.StringType(max_length=2048) tls_ciphers = wtypes.StringType(max_length=2048)
tls_versions = wtypes.wsattr(wtypes.ArrayType(wtypes.StringType( tls_versions = wtypes.wsattr(wtypes.ArrayType(wtypes.StringType(
max_length=32))) max_length=32)))
alpn_protocols = wtypes.wsattr(wtypes.ArrayType(types.AlpnProtocolType()))
class ListenerRootPOST(types.BaseType): class ListenerRootPOST(types.BaseType):
@ -198,6 +201,7 @@ class ListenerPUT(BaseListenerType):
tls_ciphers = wtypes.StringType(max_length=2048) tls_ciphers = wtypes.StringType(max_length=2048)
tls_versions = wtypes.wsattr(wtypes.ArrayType(wtypes.StringType( tls_versions = wtypes.wsattr(wtypes.ArrayType(wtypes.StringType(
max_length=32))) max_length=32)))
alpn_protocols = wtypes.wsattr(wtypes.ArrayType(types.AlpnProtocolType()))
class ListenerRootPUT(types.BaseType): class ListenerRootPUT(types.BaseType):
@ -251,6 +255,7 @@ class ListenerSingleCreate(BaseListenerType):
tls_ciphers = wtypes.StringType(max_length=2048) tls_ciphers = wtypes.StringType(max_length=2048)
tls_versions = wtypes.wsattr(wtypes.ArrayType(wtypes.StringType( tls_versions = wtypes.wsattr(wtypes.ArrayType(wtypes.StringType(
max_length=32))) max_length=32)))
alpn_protocols = wtypes.wsattr(wtypes.ArrayType(types.AlpnProtocolType()))
class ListenerStatusResponse(BaseListenerType): class ListenerStatusResponse(BaseListenerType):

View File

@ -128,7 +128,12 @@ api_opts = [
cfg.StrOpt('minimum_tls_version', cfg.StrOpt('minimum_tls_version',
default=None, default=None,
choices=constants.TLS_ALL_VERSIONS + [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 # Options only used by the amphora agent

View File

@ -854,3 +854,10 @@ OCTAVIA_OWNED = 'octavia_owned'
# but they should be on the pool. Dealing with it until v3. # but they should be on the pool. Dealing with it until v3.
LISTENER_PROTOCOLS_SUPPORTING_HEADER_INSERTION = [PROTOCOL_HTTP, LISTENER_PROTOCOLS_SUPPORTING_HEADER_INSERTION = [PROTOCOL_HTTP,
PROTOCOL_TERMINATED_HTTPS] 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]

View File

@ -395,7 +395,8 @@ class Listener(BaseDataModel):
timeout_member_data=None, timeout_tcp_inspect=None, timeout_member_data=None, timeout_tcp_inspect=None,
tags=None, client_ca_tls_certificate_id=None, tags=None, client_ca_tls_certificate_id=None,
client_authentication=None, client_crl_container_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.id = id
self.project_id = project_id self.project_id = project_id
self.name = name self.name = name
@ -430,6 +431,7 @@ class Listener(BaseDataModel):
self.allowed_cidrs = allowed_cidrs or [] self.allowed_cidrs = allowed_cidrs or []
self.tls_ciphers = tls_ciphers self.tls_ciphers = tls_ciphers
self.tls_versions = tls_versions self.tls_versions = tls_versions
self.alpn_protocols = alpn_protocols
def update(self, update_dict): def update(self, update_dict):
for key, value in update_dict.items(): for key, value in update_dict.items():

View File

@ -289,6 +289,8 @@ class JinjaTemplater(object):
ret_value['tls_ciphers'] = listener.tls_ciphers ret_value['tls_ciphers'] = listener.tls_ciphers
if listener.tls_versions is not None: if listener.tls_versions is not None:
ret_value['tls_versions'] = listener.tls_versions ret_value['tls_versions'] = listener.tls_versions
if listener.alpn_protocols is not None:
ret_value['alpn_protocols'] = ",".join(listener.alpn_protocols)
pools = [] pools = []
pool_gen = (pool for pool in listener.pools if pool_gen = (pool for pool in listener.pools if

View File

@ -66,8 +66,14 @@ peers {{ "%s_peers"|format(loadbalancer.id.replace("-", ""))|trim() }}
{% set tls_versions_opt = tls_versions_opt + " no-tlsv13" %} {% set tls_versions_opt = tls_versions_opt + " no-tlsv13" %}
{% endif %} {% endif %}
{% 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 }} {{ 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 %} {% endmacro %}

View File

@ -515,3 +515,21 @@ def check_default_tls_versions_min_conflict():
check_tls_version_min(CONF.api_settings.default_pool_tls_versions, check_tls_version_min(CONF.api_settings.default_pool_tls_versions,
message=pool_message) 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)))

View File

@ -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)
)

View File

@ -540,6 +540,7 @@ class Listener(base_models.BASE, base_models.IdMixin,
client_crl_container_id = sa.Column(sa.String(255), nullable=True) client_crl_container_id = sa.Column(sa.String(255), nullable=True)
tls_ciphers = sa.Column(sa.String(2048), nullable=True) tls_ciphers = sa.Column(sa.String(2048), nullable=True)
tls_versions = sa.Column(ScalarListType(), nullable=True) tls_versions = sa.Column(ScalarListType(), nullable=True)
alpn_protocols = sa.Column(ScalarListType(), nullable=True)
_tags = orm.relationship( _tags = orm.relationship(
'Tags', 'Tags',

View File

@ -113,6 +113,10 @@ def create_listener(listener_dict, lb_id):
listener_dict['tls_versions'] is None): listener_dict['tls_versions'] is None):
listener_dict['tls_versions'] = ( listener_dict['tls_versions'] = (
CONF.api_settings.default_listener_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 return listener_dict

View File

@ -469,7 +469,9 @@ class SampleDriverDataModels(object):
constants.CLIENT_CRL_CONTAINER_ID: self.client_crl_container_ref, 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.ALLOWED_CIDRS: ['192.0.2.0/24', '198.51.100.0/24'],
lib_consts.TLS_CIPHERS: constants.CIPHERS_OWASP_SUITE_B, 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) self.test_listener1_dict.update(self._common_test_dict)
@ -509,6 +511,7 @@ class SampleDriverDataModels(object):
self.provider_listener1_dict = { self.provider_listener1_dict = {
lib_consts.ADMIN_STATE_UP: True, lib_consts.ADMIN_STATE_UP: True,
lib_consts.ALLOWED_CIDRS: ['192.0.2.0/24', '198.51.100.0/24'], 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.CONNECTION_LIMIT: 10000,
lib_consts.DEFAULT_POOL: self.provider_pool1_dict, lib_consts.DEFAULT_POOL: self.provider_pool1_dict,
lib_consts.DEFAULT_POOL_ID: self.pool1_id, 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_REF: self.client_crl_container_ref,
lib_consts.CLIENT_CRL_CONTAINER_DATA: crl_file_content, lib_consts.CLIENT_CRL_CONTAINER_DATA: crl_file_content,
lib_consts.TLS_CIPHERS: constants.CIPHERS_OWASP_SUITE_B, 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( self.provider_listener2_dict = copy.deepcopy(

View File

@ -45,7 +45,7 @@ class TestRootController(base_db_test.OctaviaDBTestBase):
def test_api_versions(self): def test_api_versions(self):
versions = self._get_versions_with_config() versions = self._get_versions_with_config()
version_ids = tuple(v.get('id') for v in versions) 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.0', version_ids)
self.assertIn('v2.1', version_ids) self.assertIn('v2.1', version_ids)
self.assertIn('v2.2', 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.17', version_ids)
self.assertIn('v2.18', version_ids) self.assertIn('v2.18', version_ids)
self.assertIn('v2.19', 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 # Each version should have a 'self' 'href' to the API version URL
# [{u'rel': u'self', u'href': u'http://localhost/v2'}] # [{u'rel': u'self', u'href': u'http://localhost/v2'}]

View File

@ -1755,6 +1755,8 @@ class TestListener(base.BaseAPITest):
self.conf.config(group='api_settings', self.conf.config(group='api_settings',
default_listener_ciphers=( default_listener_ciphers=(
constants.CIPHERS_OWASP_SUITE_B)) 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 = [ self.cert_manager_mock().get_secret.side_effect = [
sample_certs.X509_CA_CERT, sample_certs.X509_CA_CRL, 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_crl_container_ref=crl_tls_uuid,
client_ca_tls_container_ref=ca_tls_uuid, client_ca_tls_container_ref=ca_tls_uuid,
tls_versions=[lib_consts.TLS_VERSION_1_3], 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) self.set_lb_status(self.lb_id)
unset_params = { unset_params = {
'name': None, 'description': None, 'connection_limit': None, '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, 'timeout_tcp_inspect': None, 'client_ca_tls_container_ref': None,
'client_authentication': None, 'default_pool_id': None, 'client_authentication': None, 'default_pool_id': None,
'client_crl_container_ref': None, 'tls_versions': None, 'client_crl_container_ref': None, 'tls_versions': None,
'tls_ciphers': None} 'tls_ciphers': None, 'alpn_protocols': None}
body = self._build_body(unset_params) body = self._build_body(unset_params)
listener_path = self.LISTENER_PATH.format( listener_path = self.LISTENER_PATH.format(
listener_id=listener['id']) listener_id=listener['id'])
@ -1817,6 +1820,7 @@ class TestListener(base.BaseAPITest):
api_listener['tls_versions']) api_listener['tls_versions'])
self.assertEqual(constants.CIPHERS_OWASP_SUITE_B, self.assertEqual(constants.CIPHERS_OWASP_SUITE_B,
api_listener['tls_ciphers']) api_listener['tls_ciphers'])
self.assertEqual(['http/1.1'], api_listener['alpn_protocols'])
@mock.patch('octavia.common.tls_utils.cert_parser.load_certificates_data') @mock.patch('octavia.common.tls_utils.cert_parser.load_certificates_data')
def test_update_with_bad_ca_cert(self, mock_cert_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), .format(constants.PROTOCOL_TERMINATED_HTTPS),
listener.get('faultstring')) 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 # TODO(johnsom) Fix this when there is a noop certificate manager
@mock.patch('octavia.common.tls_utils.cert_parser.load_certificates_data') @mock.patch('octavia.common.tls_utils.cert_parser.load_certificates_data')
def test_create_with_sni_data(self, mock_cert_data): def test_create_with_sni_data(self, mock_cert_data):

View File

@ -17,6 +17,7 @@ import random
from unittest import mock from unittest import mock
from octavia_lib.api.drivers import exceptions as lib_exceptions 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 cfg
from oslo_config import fixture as oslo_fixture from oslo_config import fixture as oslo_fixture
from oslo_utils import uuidutils from oslo_utils import uuidutils
@ -2623,7 +2624,8 @@ class TestLoadBalancerGraph(base.BaseAPITest):
'client_crl_container_ref': None, 'client_crl_container_ref': None,
'allowed_cidrs': None, 'allowed_cidrs': None,
'tls_ciphers': None, 'tls_ciphers': None,
'tls_versions': None 'tls_versions': None,
'alpn_protocols': None
} }
if create_sni_containers: if create_sni_containers:
create_listener['sni_container_refs'] = 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_ciphers'] = constants.CIPHERS_OWASP_SUITE_B
expected_listener['tls_versions'] = ( expected_listener['tls_versions'] = (
constants.TLS_VERSIONS_OWASP_SUITE_B) 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 return create_listener, expected_listener

View File

@ -122,11 +122,24 @@ class TestAmphoraDriver(base.TestRpc):
@mock.patch('oslo_messaging.RPCClient.cast') @mock.patch('oslo_messaging.RPCClient.cast')
def test_listener_create(self, mock_cast): def test_listener_create(self, mock_cast):
provider_listener = driver_dm.Listener( 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) self.amp_driver.listener_create(provider_listener)
payload = {consts.LISTENER_ID: self.sample_data.listener1_id} payload = {consts.LISTENER_ID: self.sample_data.listener1_id}
mock_cast.assert_called_with({}, 'create_listener', **payload) 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') @mock.patch('oslo_messaging.RPCClient.cast')
def test_listener_delete(self, mock_cast): def test_listener_delete(self, mock_cast):
provider_listener = driver_dm.Listener( provider_listener = driver_dm.Listener(
@ -161,6 +174,20 @@ class TestAmphoraDriver(base.TestRpc):
consts.LISTENER_UPDATES: listener_dict} consts.LISTENER_UPDATES: listener_dict}
mock_cast.assert_called_with({}, 'update_listener', **payload) 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 # Pool
@mock.patch('oslo_messaging.RPCClient.cast') @mock.patch('oslo_messaging.RPCClient.cast')
def test_pool_create(self, mock_cast): def test_pool_create(self, mock_cast):

View File

@ -122,11 +122,24 @@ class TestAmphoraDriver(base.TestRpc):
@mock.patch('oslo_messaging.RPCClient.cast') @mock.patch('oslo_messaging.RPCClient.cast')
def test_listener_create(self, mock_cast): def test_listener_create(self, mock_cast):
provider_listener = driver_dm.Listener( 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) self.amp_driver.listener_create(provider_listener)
payload = {consts.LISTENER: provider_listener.to_dict()} payload = {consts.LISTENER: provider_listener.to_dict()}
mock_cast.assert_called_with({}, 'create_listener', **payload) 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') @mock.patch('oslo_messaging.RPCClient.cast')
def test_listener_delete(self, mock_cast): def test_listener_delete(self, mock_cast):
provider_listener = driver_dm.Listener( provider_listener = driver_dm.Listener(
@ -163,6 +176,20 @@ class TestAmphoraDriver(base.TestRpc):
consts.LISTENER_UPDATES: listener_dict} consts.LISTENER_UPDATES: listener_dict}
mock_cast.assert_called_with({}, 'update_listener', **payload) 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 # Pool
@mock.patch('oslo_messaging.RPCClient.cast') @mock.patch('oslo_messaging.RPCClient.cast')
def test_pool_create(self, mock_cast): def test_pool_create(self, mock_cast):

View File

@ -137,6 +137,13 @@ class TestListenerPOST(base.BaseTypesTest, TestListener):
listener = wsme_json.fromjson(self._type, body) listener = wsme_json.fromjson(self._type, body)
self.assertEqual(listener.project_id, body['project_id']) 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): class TestListenerPUT(base.BaseTypesTest, TestListener):
@ -153,3 +160,8 @@ class TestListenerPUT(base.BaseTypesTest, TestListener):
"tags": ['test_tag']} "tags": ['test_tag']}
listener = wsme_json.fromjson(self._type, body) listener = wsme_json.fromjson(self._type, body)
self.assertEqual(wsme_types.Unset, listener.admin_state_up) 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)

View File

@ -52,13 +52,14 @@ class TestHaproxyCfg(base.TestCase):
"ca-file /var/lib/octavia/certs/sample_loadbalancer_id_1/" "ca-file /var/lib/octavia/certs/sample_loadbalancer_id_1/"
"client_ca.pem verify required crl-file /var/lib/octavia/" "client_ca.pem verify required crl-file /var/lib/octavia/"
"certs/sample_loadbalancer_id_1/SHA_ID.pem ciphers {ciphers} " "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" " mode http\n"
" default_backend sample_pool_id_1:sample_listener_id_1\n" " default_backend sample_pool_id_1:sample_listener_id_1\n"
" timeout client 50000\n").format( " timeout client 50000\n").format(
maxconn=constants.HAPROXY_DEFAULT_MAXCONN, maxconn=constants.HAPROXY_DEFAULT_MAXCONN,
crt_list=FAKE_CRT_LIST_FILENAME, 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" be = ("backend sample_pool_id_1:sample_listener_id_1\n"
" mode http\n" " mode http\n"
" balance roundrobin\n" " balance roundrobin\n"
@ -105,13 +106,14 @@ class TestHaproxyCfg(base.TestCase):
" maxconn {maxconn}\n" " maxconn {maxconn}\n"
" redirect scheme https if !{{ ssl_fc }}\n" " redirect scheme https if !{{ ssl_fc }}\n"
" bind 10.0.0.2:443 ssl crt-list {crt_list}" " 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" " mode http\n"
" default_backend sample_pool_id_1:sample_listener_id_1\n" " default_backend sample_pool_id_1:sample_listener_id_1\n"
" timeout client 50000\n").format( " timeout client 50000\n").format(
maxconn=constants.HAPROXY_DEFAULT_MAXCONN, maxconn=constants.HAPROXY_DEFAULT_MAXCONN,
crt_list=FAKE_CRT_LIST_FILENAME, 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" be = ("backend sample_pool_id_1:sample_listener_id_1\n"
" mode http\n" " mode http\n"
" balance roundrobin\n" " balance roundrobin\n"
@ -155,12 +157,13 @@ class TestHaproxyCfg(base.TestCase):
" maxconn {maxconn}\n" " maxconn {maxconn}\n"
" redirect scheme https if !{{ ssl_fc }}\n" " redirect scheme https if !{{ ssl_fc }}\n"
" bind 10.0.0.2:443 ssl crt-list {crt_list} " " 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" " mode http\n"
" default_backend sample_pool_id_1:sample_listener_id_1\n" " default_backend sample_pool_id_1:sample_listener_id_1\n"
" timeout client 50000\n").format( " timeout client 50000\n").format(
maxconn=constants.HAPROXY_DEFAULT_MAXCONN, 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" be = ("backend sample_pool_id_1:sample_listener_id_1\n"
" mode http\n" " mode http\n"
" balance roundrobin\n" " balance roundrobin\n"
@ -207,13 +210,15 @@ class TestHaproxyCfg(base.TestCase):
"ssl crt-list {crt_list} " "ssl crt-list {crt_list} "
"ca-file /var/lib/octavia/certs/sample_loadbalancer_id_1/" "ca-file /var/lib/octavia/certs/sample_loadbalancer_id_1/"
"client_ca.pem verify required crl-file /var/lib/octavia/" "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" " mode http\n"
" default_backend sample_pool_id_1:sample_listener_id_1\n" " default_backend sample_pool_id_1:sample_listener_id_1\n"
" timeout client 50000\n").format( " timeout client 50000\n").format(
maxconn=constants.HAPROXY_DEFAULT_MAXCONN, maxconn=constants.HAPROXY_DEFAULT_MAXCONN,
crt_list=FAKE_CRT_LIST_FILENAME, 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" be = ("backend sample_pool_id_1:sample_listener_id_1\n"
" mode http\n" " mode http\n"
" balance roundrobin\n" " balance roundrobin\n"
@ -259,12 +264,14 @@ class TestHaproxyCfg(base.TestCase):
fe = ("frontend sample_listener_id_1\n" fe = ("frontend sample_listener_id_1\n"
" maxconn {maxconn}\n" " maxconn {maxconn}\n"
" redirect scheme https if !{{ ssl_fc }}\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" " mode http\n"
" default_backend sample_pool_id_1:sample_listener_id_1\n" " default_backend sample_pool_id_1:sample_listener_id_1\n"
" timeout client 50000\n").format( " timeout client 50000\n").format(
maxconn=constants.HAPROXY_DEFAULT_MAXCONN, 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" be = ("backend sample_pool_id_1:sample_listener_id_1\n"
" mode http\n" " mode http\n"
" balance roundrobin\n" " balance roundrobin\n"
@ -299,6 +306,110 @@ class TestHaproxyCfg(base.TestCase):
frontend=fe, backend=be), frontend=fe, backend=be),
rendered_obj) 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): def test_render_template_http(self):
be = ("backend sample_pool_id_1:sample_listener_id_1\n" be = ("backend sample_pool_id_1:sample_listener_id_1\n"
" mode http\n" " mode http\n"
@ -1397,7 +1508,7 @@ class TestHaproxyCfg(base.TestCase):
" maxconn {maxconn}\n" " maxconn {maxconn}\n"
" redirect scheme https if !{{ ssl_fc }}\n" " redirect scheme https if !{{ ssl_fc }}\n"
" bind 10.0.0.2:443 ciphers {ciphers} " " 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" " mode http\n"
" acl sample_l7rule_id_1 path -m beg /api\n" " acl sample_l7rule_id_1 path -m beg /api\n"
" use_backend sample_pool_id_2:sample_listener_id_1" " 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" " default_backend sample_pool_id_1:sample_listener_id_1\n"
" timeout client 50000\n".format( " timeout client 50000\n".format(
maxconn=constants.HAPROXY_DEFAULT_MAXCONN, 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" be = ("backend sample_pool_id_1:sample_listener_id_1\n"
" mode http\n" " mode http\n"
" balance roundrobin\n" " balance roundrobin\n"

View File

@ -605,13 +605,16 @@ def sample_listener_tuple(proto=None, monitor=True, alloc_default_pool=True,
backend_tls_ciphers=None, backend_tls_ciphers=None,
tls_versions=constants.TLS_VERSIONS_OWASP_SUITE_B, tls_versions=constants.TLS_VERSIONS_OWASP_SUITE_B,
backend_tls_versions=constants. 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 proto = 'HTTP' if proto is None else proto
if be_proto is None: if be_proto is None:
be_proto = 'HTTP' if proto == 'TERMINATED_HTTPS' else proto be_proto = 'HTTP' if proto == 'TERMINATED_HTTPS' else proto
if proto != constants.PROTOCOL_TERMINATED_HTTPS: if proto != constants.PROTOCOL_TERMINATED_HTTPS:
tls_ciphers = None tls_ciphers = None
tls_versions = None tls_versions = None
alpn_protocols = None
if pool_cert is False: if pool_cert is False:
backend_tls_versions = None backend_tls_versions = None
topology = 'SINGLE' if topology is None else topology 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, ' 'timeout_tcp_inspect, client_ca_tls_certificate_id, '
'client_ca_tls_certificate, client_authentication, ' 'client_ca_tls_certificate, client_authentication, '
'client_crl_container_id, provisioning_status, ' 'client_crl_container_id, provisioning_status, '
'tls_ciphers, tls_versions') 'tls_ciphers, tls_versions, alpn_protocols')
if l7: if l7:
pools = [ pools = [
sample_pool_tuple( 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 '', client_crl_container_id='cont_id_crl' if client_crl_cert else '',
provisioning_status=provisioning_status, provisioning_status=provisioning_status,
tls_ciphers=tls_ciphers, tls_ciphers=tls_ciphers,
tls_versions=tls_versions tls_versions=tls_versions,
alpn_protocols=alpn_protocols
) )
if recursive_nest: if recursive_nest:
listener.load_balancer.listeners.append(listener) listener.load_balancer.listeners.append(listener)

View File

@ -513,3 +513,17 @@ class TestValidations(base.TestCase):
'TLSv1', 'TLSv1.3']) 'TLSv1', 'TLSv1.3'])
self.assertRaises(exceptions.ValidationException, self.assertRaises(exceptions.ValidationException,
validate.check_default_tls_versions_min_conflict) 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,
[])

View File

@ -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.

View File

@ -43,7 +43,7 @@ castellan>=0.16.0 # Apache-2.0
tenacity>=5.0.4 # Apache-2.0 tenacity>=5.0.4 # Apache-2.0
distro>=1.2.0 # Apache-2.0 distro>=1.2.0 # Apache-2.0
jsonschema>=3.2.0 # MIT 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 netaddr>=0.7.19 # BSD
simplejson>=3.13.2 # MIT simplejson>=3.13.2 # MIT
setproctitle>=1.1.10 # BSD setproctitle>=1.1.10 # BSD